Skip to content

Instantly share code, notes, and snippets.

@chriskuech
Created April 1, 2020 20:01
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save chriskuech/3b20a13eb8be0fd2569f9f5032c4d385 to your computer and use it in GitHub Desktop.
Save chriskuech/3b20a13eb8be0fd2569f9f5032c4d385 to your computer and use it in GitHub Desktop.

Many teams are choosing to decompose their monolithic applications into microservices. Dividing up business logic into separate microservices is straightforward until you hit cross-cutting concerns, like security. Packaging all the security access control logic into every microservice would cause heavy coupling between microservices, and security bugfixes would require updating the security library in every microservice. To mitigate this issue, we will implement a token service to encapsulate our stateful security components.

What is a token service?

A token service is a web service whose sole responsibility is creating bearer tokens (JWTs) and providing the means (a JWKS) to verify the tokens. The token service is the only service with permissions to read and write the authorization database. The authorization information is securely packaged into JSON Web Tokens (JWTs), such that any server can determine if the client has access solely by inspecting the token and without any calls to a database.

Why implement a custom token service?

Good question. There is almost no good reason to assume the liability of "rolling your own" token service. Use an existing Identity-as-a-Service provider, like Okta, Auth0, or Azure Active Directory instead.

If your organization has

  • lingering legacy components in your system that can't integrate with these identity providers
  • security professionals available who can pen test and monitor the security of your token service

or you are simply looking to sabotage your current team with a ticking time bomb of security vulnerability, you can build and integrate a token service fairly easily in ASP.NET Core.

Concepts

We leverage a few mainstream protocols/standards that maximize security and support by ASP.NET Core.

We can feasibly implement and integrate a token service because ASP.NET Core and the Microsoft.IdentityModel library provides all the types and extensibilty already. You will need to understand how ASP.NET Core apps implement authentication and authorization before we can add our custom token service authentication and authorization. Concepts like ClaimsPrincipals and AuthenticationService are unique to this stack.

Asymmetric JWTs

Asymmetrically signed JWTs are JWTs that are signed by a secret private key (within the token service) and later verified with a public key (published by the token service). The payload of the JWT is a JSON object mapping claim types to claim values. Claims are type-value pairs that describe the token subject (the entity who requested the token). Because JWTs are signed, they can be passed around for use without requiring the recipient to trust the sender--the recipient only has to verify the token's signature against the public JSON Web Key set (JWKS) to trust its contents.

This allows the JWT to be

  • relayed to other services as a bearer token rather than obtaining new tokens for every call.
  • used to authorize a client, as all the client's identity information is stored in the token.

OpenID Connect Discovery

OpenID Connect is a popular protocol for authenticating, authorizing, and reading profile info for users. Because we are implement server-to-server authentication, these features are not beneficial.

We will only implement a small subset of the OpenID Connect spec: theOpenID Connect Discovery protocol. Adhering to this protocol, we will make a special discovery endpoint. Given only this endpoint, the client and server will automagically obtain the token endpoint and the key set for obtaining and validating tokens with the token service.

OAuth 2.0

We will be implementing a simplified version of the OAuth "client_credentials" grant flow. You can alternatively implement the full "client_credentials" spec, which mostly requires using specific schemas for your requests and responses instead of our simplified requests and responses. This can simplify forward compatibility if you add other grant flows, such as for user authentication, in the future.

Scopes

Scopes are permission identifiers. Your token service will maintain a mapping from client ID to the scopes granted to the client ID. When clients request a token, they include in the request which scopes they would like granted in the token. The token service asserts that the client has access to all the scopes it requests before returning the token. When another service determines if the client can invoke an operation associated with a specific scope, it asserts that the scope is present in the client's token before continuing.

Scopes are serialized into objects depending on the context. Typically, scope properties contain a space-delimited string of scopes whereas scopes properties contain an array of scope strings.

There is no standard format for scopes. I recommend prefixing each permission identifier with the service name. For example, a scope for updating item resources in a service myservice might look like service:myservice/item.update.

Request flow

With this flow,

  • The client lazily fetches and caches the token endpoint information from the discover endpoint using a ConfigurationManager<OpenIdConnectConfiguration>.
  • The server lazily fetches and caches the key set from the discover endpoint magically within the AuthenticationService.

These lazy cached calls are denoted in the diagram.

Token flow diagrem

Request timeline

  1. At app start, the client creates and caches a ConfigurationManager<OpenIdConnectConfiguration>.
  2. The client requests the OpenIdConnectConfiguration from the ConfigurationManager<OpenIdConnectConfiguration>, which in turn lazily fetches and caches the OpenIdConnectConfiguration from the discovery endpoint.
  3. The client reads the token endpoint from the OpenIdConnectConfiguration.
  4. The client POSTs a request to the token endpoint with some application-specific request data.
  5. The client requests the protected endpoint using the retrieved bearer token.
  6. The server's AuthenticationService lazily retrieves and caches the OpenIdConnectConfiguration from the discovery endpoint.
  7. The server's AuthenticationService lazily retrieves and caches the token service's key set from the key set endpoint it discovered from the OpenIdConnectConfiguration.
  8. The server's AuthenticationService verifies the signature of the bearer token against the key set. If the signature is invalid, the server returns 401.
  9. The server's AuthenticationService set's reads the claims in the JWT payload into the HttpContext's ClaimsPrincipal.
  10. The protected endpoint receives the request with the prepopulated ClaimsPrincipal. From here it can assume the request is safe, or conduct additional validation on the ClaimsPrincipal.

Implementing the token service

Before you begin, you'll need...

An empty ASP.NET Core microservice

Custom token services only make sense if you have microservices. You'll need a new ASP.NET Core project to contain your new token microservice. The microservice will need its own unique base URL. We will refer to this unique identifying URL as the "Authority" or "Issuer".

An identity provider

Each microservice should have a unique identity. When the microservice requests a token from the token service, it will include its identity in the request and the token service will verify the client microservice's identity before returning a token.

Your implementation will again depend entirely on your current system. Common solutions leverage client certificates, JWTs, or if you trust all the microservices in your cluster, a shared secret.

Add your private keys to Dependency Injection

Your token service needs exclusive access to a set of private key certificates. How you integrate these certificates depends entirely on your team's infrastructure design. In this guide, we'll assume you add your private keys to dependency injection as IEnumerable<X509Certificate2>. We will reference IEnumerable<X509Certificate2> in our dependency injected controller constructors.

Define some constants

For conciseness, we will reference this constants class throughout the implementation. Your implementation should not have a shared constants class, as you should only use the Discovery endpoint to communicate routes. If you add a shared class, you will introduce tight coupling, complicating any future efforts to rollout a new major version of the token endpoint.

https://gist.github.com/10385716d9209972aa251d95cf791527

Define your API contracts

These classes are specific to your implementation, but have some basic requirements.

TokenRequest class

This request object should include (at a minimum),

  • The client's identity, if you are using application layer identity. If you are using client certificates, the certificate is instead found in HttpContext.Connection.ClientCertificate and the client's identity string will be HttpContext.Connection.ClientCertificate.Subject.Substring(3) (removing the "CN=" prefix from the certificate's subject).
  • The client's requested scopes.

TokenResponse class

For simplicity, in this guide, we return the token string directly. OAuth services will return an OAuth access token response object containing metadata about the token in addition to the token.

TokenError class

For simplicity, in this guide, we return the error message string directly along with a 400 status code if we cannot authenticate and mint a token for the client. OAuth services will return an OAuth error response object with an error code and error message.

Add the token endpoint controller

We will host the token route at /token. When a client POSTs to /token, the token service will

  1. Verify the client's identity. If the client sent a client certificate, the certificate's chain and validity period should be verified; if the client sent a JWT, the JWT's signature and validity period should be verified.
  2. Call the authorization method to determine if the client has access to the scopes it requested. This probably involves a database call to determine the available set of scopes for the client.
  3. Derive the claims from the client. One of the claims will be the scopes that the client requested. This may involve a database call to look up additional information about the client.
  4. Package the claims into a signed JWT.
  5. Send back the JWT to the client.

https://gist.github.com/c83dc2f7322aabc2169149d84d92dbb7

Add the key set endpoint controller

The key set endpoint is just a public JsonWebKeySet (JWKS) object that we derive from our signing certificates. The JWKS schema is defined in the JSON Web Key (JWK) spec. The ASP.NET Core AuthenticationService maintains a cached JWKS that it uses to verify the signatures of JWTs it receives.

https://gist.github.com/6d3c59b13a256e0c95150bb876613da9

Add the discovery endpoint controller

The ASP.NET Core AuthenticationService automatically builds the discovery endpoint route by appending ".well-known/openid-configuration" to the Authority. AuthenticationService makes an unauthenticated request to the discovery endpoint to determine the JWKS URL.

https://gist.github.com/b95afc3c51b02d4b2f131e61f7bb0956

Using the token service

Client: Retrieving a token

To retrieve a token, we'll first discover the token endpoint from the discovery configuration.

https://gist.github.com/91e16d7b5fbd2ff06883eeece4b4a67d

The client can then POST a TokenRequest JSON to the token endpoint and receive a token.

Server: Authenticating from a token

Update your ConfigureServices method to define and add your authentication scheme.

https://gist.github.com/b920f84b8f51cd346fdd7363aa68b9a3

Next steps

Authorize clients based on scopes

This guide showed you how to add custom tokens to your service that are automatically parsed into the HttpContext's ClaimsPrincipal. Next, you should implement an Authorization policy that asserts that checks if the client's token includes specific scopes.

Certificate rotation without restarts

In this guide, we integrated our private key certificates into our service by adding IEnumerable<X509Certificate2> to Dependency Injection. While suitable for an initial iteration, you should replace IEnumerable<X509Certificate2> with your own singleton class that can lazily retrieve and cache private key certificates and clear the cache periodically to implement certificate rotation.

Cache tokens

Each token has a lifetime and can be reused for the duration of its lifetime. Implement a cache for your tokens to maximize reuse and expire the cache at some percentage of the token's lifespan to ensure the token doesn't expire while it's being used.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment