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.
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
AuthenticationService are unique to this stack.
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.
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 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
With this flow,
- The client lazily fetches and caches the token endpoint information from the discover endpoint using a
- The server lazily fetches and caches the key set from the discover endpoint magically within the
These lazy cached calls are denoted in the diagram.
- At app start, the client creates and caches a
- The client requests the
ConfigurationManager<OpenIdConnectConfiguration>, which in turn lazily fetches and caches the
OpenIdConnectConfigurationfrom the discovery endpoint.
- The client reads the token endpoint from the
- The client POSTs a request to the token endpoint with some application-specific request data.
- The client requests the protected endpoint using the retrieved bearer token.
- The server's
AuthenticationServicelazily retrieves and caches the
OpenIdConnectConfigurationfrom the discovery endpoint.
- The server's
AuthenticationServicelazily retrieves and caches the token service's key set from the key set endpoint it discovered from the
- The server's
AuthenticationServiceverifies the signature of the bearer token against the key set. If the signature is invalid, the server returns
- The server's
AuthenticationServiceset's reads the claims in the JWT payload into the
- 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
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.
Define your API contracts
These classes are specific to your implementation, but have some basic requirements.
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.ClientCertificateand the client's identity
HttpContext.Connection.ClientCertificate.Subject.Substring(3)(removing the "CN=" prefix from the certificate's subject).
- The client's requested scopes.
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.
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
- 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.
- 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.
- 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.
- Package the claims into a signed JWT.
- Send back the JWT to the client.
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.
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.
Using the token service
Client: Retrieving a token
To retrieve a token, we'll first discover the token endpoint from the discovery configuration.
The client can then POST a
TokenRequest JSON to the token endpoint and receive a token.
Server: Authenticating from a token
ConfigureServices method to define and add your authentication scheme.
Authorize clients based on scopes
This guide showed you how to add custom tokens to your service that are automatically parsed into the
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.
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.