Manually validating Azure AD B2C/Microsoft identity platform JWT access tokens in ASP.NET
An ASP.NET Web API that accepts bearer token as a proof of authentication is secured by validating the token they receive from the callers. To validate an id_token
or an access_token
, the app should validate:
- token’s signature
- claims
- nonce, as a token replay attack mitigation
- “not before” and “expiration time” claims, to verify that the ID token has not expired
- in case of access tokens, your app should also validate the issuer, the audience, and the signing tokens
These need to be validated against the values in the respective OpenID discovery document. For example, a tenant-independent version of the the OpenID discovery document for Azure AD is located at https://login.microsoftonline.com/common/.well-known/openid-configuration.
In most cases this validation is done by the built-in capabilities provided by the Azure AD/ASP.NET authentication middleware and Microsoft Identity Model Extension for .NET. When a developer generates a skeleton Web API project using Visual Studio, token validation libraries and code to carry out basic token validation is automatically generated for the project. This is usually sufficient for most scenarios. However, there are some cases when these defaults are insufficient:
- token validation parameters such as public keys are not known in advance, which is necessary when registering the authentication middleware
- restricting the API to just one or more Apps (App IDs) or tenants (issuers)
- implementing additional custom authentication schemes, such as API keys
- implementing dynamic authorization schemes that go beyond claims issued during login and fixed set of policies registered during the startup
- use external configuration during validation or make use of the HTTP execution context
ISecurityTokenValidator
The simplest way to implement custom validation is to provide a custom ISecurityTokenValidator
implementation in the Microsoft.AspNetCore.Authentication.JwtBearer
package. The JwtBearerOptions
type contains a list of ISecurityTokenValidator
so you can potentially use custom token validators, but the default is to use the built-in JwtBearerHandler
.
We can register our custom validator together with the authentication middleware it in the service container:
The biggest issue with our validator is that it is instantiated only once, making it effectively a singleton, so we can’t have dependencies on transient and scoped services. To fix this we have to either manually force a new scope from IServiceProvider
for each token validator call, or register a custom implementation of IPostConfigureOptions<JwtBearerOptions>
which will wire up the MyTokenValidator
when instantiated by the service container, and which call also pass any necessary dependencies.
To access the HttpContext
we would need to register the IHttpContextAccessor
service in the service container, and use one of the DI workarounds to pass it to the handler where we can obtain the value of the IHttpContextAccessor.HttpContext
property.
Additionally, bypassing the authentication altogether in case of an e.g. unprotected endpoint requires overriding the OnChallenge
event of the JwtBearerEvents
class, with a custom logic that also has to be specified when registering the authentication middleware:
AuthenticationHandler
Creating your own AuthenticationHandler
by deriving from AuthenticationHandler<TOptions>
and implementing HandleAuthenticateAsync
is also one option. However this is a low-level interface that requires a lot of plumbing, and should really only be used by used to implement novel authentication schemes which are meant to be used by others. In general, the outline of the process is as follows:
- Implement the options class inheriting from
AuthenticationSchemeOptions
- Create the handler, inherit from
AuthenticationHandler<TOptions>
- Implement the constructor and
HandleAuthenticateAsync
in the handler - Use the static methods of
AuthenticateResult
to create different results (None, Fail or Success) - Override other methods to change standard behavior
- Register the scheme with
AddScheme<TOptions, THandler>(string, Action<TOptions>)
on theAuthenticationBuilder
, which you get by callingAddAuthentication
on the service collection
An example implemention for HTTP Basic authentication can be found here.
Custom authentication middleware
The simplest method to implement our custom JWT validation is to bypass the built-in authentication components entirely and instead implement our own authentication middleware. Our middleware should be built on top of IApplicationBuilder
and invokable like so:
The InvokeAsync
method that we must implement in order to conform to the IMiddleware
interface has both access to the current HttpContext
and the transient and scoped services, which we just list as additional parameters similar to constructor injection.
Let’s see how we should process the request step by step. First, we check if the required HTTP header used for authentication is present.
If the required header is present, the next step should be an inspection of whether the header is in the proper format, i.e. having an appropriate prefix such as Bearer
or APIKEY
. The appropriate JWT/API key should be extracted and eventually, after a successful validation, mapped to set of options that identify the user and provide an authorization context, to be used later in the request pipeline.
JWT token should be parsed manually and analyzed before invoking the validator. For example, In case of multiple issuers/audiences, it is advisable to do a manual check whether the issued JWT conforms to the required issuer origin policy. For example, if we have separate AD directories for development and production, tokens issued for the development environment should fail validation if used on the production environment, and vice versa. Sometimes however hosts are only available in production environments, and in such cases audiences should be valid in both environment. Such policies can be hard-coded because they are not changed frequently. An example implementation would be as follows:
Prior to token validation the OpenID metadata endpoint should be constructed. Instead of hard-coding it, the preferred approach is to construct it from the payload claims referring to the issuer and the sign-in policy name tfp
:
This metadata endpoint is then fed into the ConfigurationManager
. A very important thing to keep in mind is to make sure that the ConfigurationManager
initialization is done only once, either by making the instance a singleton, or by caching the instances into a static Dictionary<string, ConfigurationManager<OpenIdConnectConfiguration>>
field, with the metadata endpoint used as a key. This is due to the fact that processing the metadata endpoint incurs a non-trivial time cost, and by caching the instance we can reuse it for validation in subsequent requests, saving dozens of milliseconds.
In token validation parameters we specify all the possible valid audiences and issuers:
Finally, we validate the token in a try-catch block. The derived types of SecurityTokenValidationException
that are of interest should be logged for auditing purposes.
If the validation is successful, we can store the acquired ClaimsPrincipal
object into the HttpContext.User
property to make use of it in the authorization handler later. Additionally, other checks can be performed on the user identity against external configuration sources or data stores, such as whether the user account has been disabled since the token was issued, or at all if that kind of information wasn’t available during the login. Such catch-all deny policies are best kept directly in the authentication layer to make sure that no user context or options initialization occurs at all.
Usually the JWT token contains a lot more than it is required to establish the user identity; the stuff like user ID, assigned roles and whatever else is necessary to authorize access to resources. In fact, the official JWT site explicitly mentions “authorization” (in contrast to “authentication”) as a use case for JWT, assuming that the token claims have been issued against a data store containing all the necessary information to create an authorization context, which is often not the case. This kind of usage makes it impossible to alter those types of policies for a specific user after the token has been issued. The usual fix for rejecting invalidated tokes involves a data store call to verify blacklisted tokens, which defeats the purpose of using JWT for authorization in the first place. Lastly, it’s inconvenient to force a user to relogin/refresh their token each time their access rights have been changed.
That being said, JWT token should be used exclusively for authentication. Once the identity is established the authentication middleware should fill in all the options necessary to provide an authorization context for server resources in lower layers. Some of these options could be extracted from the JWT token payload, some from the data store or any other external sources. Since the InvokeAsync
has a painless access to the service container, let’s first define a set of options we’re interested in:
Then, we use it in the InvokeAsync
:
This way we can bypass the primitive “claims principal” collection entirely, and inject a well-typed set of options IOptionsSnapshot<SecurityOptions>
scoped to the current request and referring to the current user’s execution context into any endpoint.