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.
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.
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 ClaimsPrincipal
s and 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 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 service:myservice/item.update
.
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.
- At app start, the client creates and caches a
ConfigurationManager<OpenIdConnectConfiguration>
. - The client requests the
OpenIdConnectConfiguration
from theConfigurationManager<OpenIdConnectConfiguration>
, which in turn lazily fetches and caches theOpenIdConnectConfiguration
from the discovery endpoint. - The client reads the token endpoint from the
OpenIdConnectConfiguration
. - 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
AuthenticationService
lazily retrieves and caches theOpenIdConnectConfiguration
from the discovery endpoint. - The server's
AuthenticationService
lazily retrieves and caches the token service's key set from the key set endpoint it discovered from theOpenIdConnectConfiguration
. - The server's
AuthenticationService
verifies the signature of the bearer token against the key set. If the signature is invalid, the server returns401
. - The server's
AuthenticationService
set's reads the claims in the JWT payload into theHttpContext
'sClaimsPrincipal
. - The protected endpoint receives the request with the prepopulated
ClaimsPrincipal
. From here it can assume the request is safe, or conduct additional validation on theClaimsPrincipal
.
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".
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.
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.
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
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.ClientCertificate
and the client's identitystring
will beHttpContext.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.
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.
https://gist.github.com/c83dc2f7322aabc2169149d84d92dbb7
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
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
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.
Update your ConfigureServices
method to define and add your authentication scheme.
https://gist.github.com/b920f84b8f51cd346fdd7363aa68b9a3
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.
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.