At Faithlife, we’ve been using OAuth 1.0a to handle authentication between services. Instead of designing our apps as monoliths, we’ve been perferring to build lightweight frontend applications that call RESTful microservices, returning entities as JSON. These frontend applications don’t touch our databases directly. Among other benefits, this allows us to better allocate hardware resources (CPU, RAM, disk) to applications that need them.
A typical request to Faithlife might look something like this:
At the beginning of the request, Faithlife makes a call to OAuth API to ensure the current OAuth access token and secret are still valid. After that check passes, the current user’s OAuth credentials are also passed to all downstream services that require auth.
An authorization header presented to a downstream API looks something like:
Authorization: OAuth oauth_consumer_key="1E18E56BD0C3A51A945D98136D6462FCEAE65199",oauth_signature="0B847E32C6DE692A7BA899DF67EF5C1BCCAEFA89%262D3F6B2BD18B2DD85821EFF0F07EB130AD46E5C5",oauth_signature_method="PLAINTEXT",oauth_version="1.0",oauth_token="FE009074810F3D2E3A2EB6BF5603B1CA08082AB7"
However, this poses a problem - our microservices do not have access to the OAuth database directly, and can’t validate the current user’s authorization header without first calling OAuth API. These calls are not free - on average, we measured the time taking from 10-35 ms for apps within the same data center, depending on a variety of factors. As we add more API calls to Faithlife, it gets progressively worse:
What we needed was a way to pass a token to downstream APIs that identifies the current user. OAuth 1.0a was suitable for a long time, and several years ago we decided to hold off on migrating to OAuth 2 because the need was not strong enough. OAuth 2 adds a few steps to the authentication flow:
JWT access tokens have a few desirable properties for our use case. Tokens contain claims about the current user (such as the user ID and current roles), an expiration date, and are signed with a public/private key pair. Downstream APIs can validate their integrity using the public key, but only the signing authority can issue new ones. For our use case, we established some requirements for all JWT access tokens:
JWT access tokens are presented to the downstream APIs that Faithlife calls. On the very first request, the current public key is requested from OAuth API. Using this public key, tokens can now be validated locally. All future tokens for the lifetime of the app are validated with this public key. Because the token has claims stored within it, we now have no need to call OAuth API for successful authentication attempts. If the token can’t be validated locally, either because the token appears to be expired due to clock skew, or because the signing key was changed, the downstream API makes a validation call to OAuth API (just like it did before).
We did not end up implementing the full OAuth 2 authorization flow when adding support for JWT access tokens. Instead, we used the OAuth 1 credentials in place of OAuth 2 refresh tokens to obtain access tokens.
This approach follows the full OAuth 1.0a authorization flow, but replaces OAuth 1 plaintext tokens with JWT access tokens when communicating with downstream APIs. An OAuth 1 plaintext token is still obtained and stored by the frontend web application (in an encrypted cookie), and then upgraded to an JWT access token at the beginning of a frontend request. We could have migrated our auth services to a full OAuth 2 implementation, but this would be a non-trivial amount of work, and was more than we wanted to take on in this iteration. We were mainly interested in the scalability wins of using JWT access tokens, and leaving open the future possibility of using the full OAuth 2 authorization flow in the future.
When Faithlife gets a web request, it makes a call to OAuth API:
GET /oauth/v1/users/current
Authorization: OAuth oauth_consumer_key="1E18E56BD0C3A51A945D98136D6462FCEAE65199",oauth_signature="0B847E32C6DE692A7BA899DF67EF5C1BCCAEFA89%262D3F6B2BD18B2DD85821EFF0F07EB130AD46E5C5",oauth_signature_method="PLAINTEXT",oauth_version="1.0",oauth_token="FE009074810F3D2E3A2EB6BF5603B1CA08082AB7"
And gets back a JWT:
X-Bearer-Authorization: Bearer eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyOTg2Njg5IiwiY29uc3VtZXJOYW1lIjoiRmFpdGhsaWZlIiwiY29uc3VtZXJUb2tlbiI6IjRFNTdGQTk1MDE1MTJDMUM0RjdFMzQ1NzE0NjNDMjI0QjBCMzc1NEQiLCJpc0FkbWluQ29uc3VtZXIiOiJ0cnVlIiwibmJmIjoxNTMyOTgyMDQ2LCJleHAiOjE1MzI5ODMyNDYsImlhdCI6MTUzMjk4MjY0NiwiaXNzIjoiYXV0aC5mYWl0aGxpZmUuY29tIiwiYXVkIjoiZmFpdGhsaWZlLWJhY2tlbmQtYXBpcyJ9.7GSdItnnCr8QOLS3uCbJMY0X-D7jTjp_XUAp8clo9LY4X5Zlf_5I7RSMZr3J6kOihwbHjEbuh0AFMXmF5YQZLg
Which decodes to:
{
"sub": "2986689",
"consumerName": "Faithlife",
"consumerToken": "4E57FA9501512C1C4F7E34571463C224B0B3754D",
"isAdminConsumer": "true",
"nbf": 1532982046,
"exp": 1532983246,
"iat": 1532982646,
"iss": "auth.faithlife.com",
"aud": "faithlife-backend-apis"
}
This JWT is passed to all downstream services in the Authorization
header. Our request graph now looks much better:
On average we measured validating this token taking less than 4 ms per request. We’re happy with the results so far and are in the process of rolling support out to all of our APIs.
There is more than one way to solve this problem. A few other strategies we considered:
Using a shared service account to communicate with downstream services. This increases the chance that a frontend regression reveals access to data that the user is not allowed to see (e.g. posts to a secret group). There are many different teams in charge of the APIs that Faithlife calls (e.g. commerce APIs are separated from community APIs), and validation at the API layer is much easier to enforce across team boundaries.
Using a shared key and pass a signed token or cookie. This presents several security problems. Rotating the shared token would have been very complicated, and having the signing key on all of our web nodes increases the attack surface. We wanted a solution that was standardized and would scale well into the future. Some of these solutions are also tighly coupled to the web application stack (ASPXAUTH cookies for example), and our auth solution needs to work on multiple API platforms.
We primarily use ASP.NET here at Faithlife, although we also host services using NodeJS and .NET Core. A sample .NET Core app that demonstrates both signing and validating ES256 tokens is hosted here:
https://github.com/Faithlife/ES256-Demo
This demo uses these NuGet packages:
If you’re intersted in working on projects like this, come work with us! Thanks to Robert Bolender, Justin Brooks, and Bradley Grainger for giving feedback on early drafts of this post.
Posted by Dustin Masters on August 06, 2018