Encrypting Identity Tokens in IdentityServer4

Scott Brady
Scott Brady
Identity Server

I previously wrote an article on how to use Proof-Key for Code Exchange (PKCE) in a server-side ASP.NET Core application. In the IdentityServer world authorization code with PKCE now replaces OpenID Connect's (OIDC) hybrid flow as our most secure authorization method; however, not all client libraries or even OpenID Providers support PKCE yet. An alternative approach that gives a comparatively high level of assurance is to use the OIDC hybrid flow in combination with encrypted identity tokens via JSON Web Encryption (JWE).

Using the hybrid flow with encrypted identity tokens allows us to validate the authorization response (via identity token validation), ensure that the authorization code was intended for us (via c_hash validation), and prevent PII passing via the browser (thanks to JWE).

Note that whilst PKCE gives both the authorization server and client application a high degree of assurance that the authorization code is being used by the correct party, when using c_hash validation, only the client application gets that level of assurance. If you want to go crazy, there is no reason you can’t use encrypted identity tokens, the hybrid flow, and PKCE.

In this article, we’ll look at how to encrypt identity tokens in IdentityServer4 and then decrypt them in ASP.Net Core. We’ll also discuss the implications of encrypted identity tokens when calling the end session endpoint with an id_token_hint.

If you want to learn more about JWE, check out my other articles Understanding JWE and “JWE in .NET”.

When to Encrypt Identity Tokens

I’ve found that it is beneficial to encrypt your identity tokens when:

  • Your client library does not support PKCE
  • You require NIST Federation Assurance Level (FAL) 2 or level 3 compliance
  • TLS is being terminated by a 3rd party before the token reaches your application
  • Your Identity-as-a-Service provider won’t stop stuffing identity tokens with Personally Identifiable Information (PII)

The Flow

When using JWE identity tokens, the client application holds the private key whilst the OpenID Provider (IdentityServer) holds the public key. Keys must be exchanged beforehand and, as far as I am aware, there is no method in the protocol to do so (other than a custom JSON Web Key Set (JWKS), I guess).

When issuing identity tokens as part of an OpenID Connect authorization or token request, the OpenID Provider will encrypt the token using the public key. Encrypted identity tokens always use JWE Compact Serialization, with the inner/nested token being signed using JSON Web Signature (JWS).

Upon receiving the identity token, the client application will decrypt the identity token using the private key and then validate the inner token using the OpenID Provider’s public key, as per usual.

Every client application will have its own private key, with IdentityServer storing the corresponding public key. This ensures that only the intended client application can read the identity token.

Adding JWE Support to IdentityServer4

I’m going to assume you have a working IdentityServer4 installation, there are enough articles about that already. If you don’t, check out my getting started guide, work through the quickstarts, or use a template. For my sample code on GitHub, I used:

dotnet new is4inmem

To start encrypting identity tokens, we need to change how tokens are generated within IdentityServer. The best place to do this by augmenting the default ITokenCreationService implementation called DefaultTokenCreationService.

DefaultTokenCreationService breaks down JWT creation by header and payload. Ideally, we would take advantage of both existing methods, however, currently, the Microsoft libraries do not support the encrypting of an existing token. Thankfully, open source Microsoft is awesome, and this is something that is going to be added in an upcoming release.

So, until then, my override of CreateTokenAsync must look something like:

public class JweTokenCreationService : DefaultTokenCreationService
{
    public JweTokenCreationService(
        ISystemClock clock, 
        IKeyMaterialService keys, 
        ILogger<DefaultTokenCreationService> logger) 
        : base(clock, keys, logger)
    {
    }

    public override async Task<string> CreateTokenAsync(Token token)
    {
        if (token.Type == IdentityServerConstants.TokenTypes.IdentityToken)
        {
            var payload = await base.CreatePayloadAsync(token);

            var handler = new JsonWebTokenHandler();
            var jwe = handler.CreateToken(
                payload.SerializeToJson(),
                await Keys.GetSigningCredentialsAsync(),

                // hardcoded... instead load public key per client
                new X509EncryptingCredentials(new X509Certificate2("idsrv3test.cer"))); 

            return jwe;
        }

        return await base.CreateTokenAsync(token);
    }
}

We can register our new token creation service using the same lifetime scoping as the default:

services.AddTransient<ITokenCreationService, JweTokenCreationService>();

This is just demo code, but all that’s missing is a configuration store. In production, we must use a different public key for each client app and not all client applications would require identity token encryption.

Handling Encrypted Identity Tokens in ASP.NET Core

Now that we are sending out encrypted identity tokens, we need to have our ASP.NET Core client application be able to decrypt them.

To start with, our client app should look something like the following, where we are using the OpenID Connect hybrid flow, have a client secret, and a local cookie to store session data:

public void ConfigureServices(IServiceCollection services)
{
    services.AddMvc();
    
    services.AddAuthentication(options =>
        {
            options.DefaultAuthenticateScheme = "cookie";
            options.DefaultChallengeScheme = "oidc";
        })
        .AddCookie("cookie")
        .AddOpenIdConnect("oidc", options =>
        {
            options.Authority = "http://localhost:5000";
            options.ClientId = "mvc";
            options.ClientSecret = "49C1A7E1-0C79-4A89-A3D6-A37998FB86B0";
            options.ResponseType = "id_token code";

            options.SignInScheme = "cookie";
            options.RequireHttpsMetadata = false;
        });
}

public void Configure(IApplicationBuilder app)
{
    app.UseDeveloperExceptionPage();

    app.UseAuthentication();

    app.UseStaticFiles();
    app.UseMvcWithDefaultRoute();
}

To allow this library to decrypt incoming identity tokens, all we need to do is set the TokenDecryptionKey on OpenIdConnectsOptions’s TokenValidationParameters to the appropriate private key:

// Allows automatic decryption of JWE identity tokens
options.TokenValidationParameters = new TokenValidationParameters
{
    TokenDecryptionKey = new X509SecurityKey(new X509Certificate2("idsrv3test.pfx", "idsrv3test"))
};

However, if you try and run the client application in its current state, you’ll be met with the following exception thrown by the OpenIdConnectProtocolValidator:

ArgumentNullException: IDX10000: The parameter 'hashAlgorithm' cannot be a 'null' or an empty object.

This is because OpenIdConnectProtocolValidator is not designed to handle JWE, but rather JWS. As we discussed earlier, encrypted identity tokens have a nested token in the JWS format that OpenIdConnectProtocolValidator requires.

Solution 1: Events

The obvious solution to this would be to use an OpenID Connect handler event to switch the token to that’s being sent to the OpenIdConnectProtocolValidator to be the inner signed token. After all, we’ve already proven the outer token to be valid whilst decrypting it:

options.Events = new OpenIdConnectEvents
{
    // after initial validation, but before calling ProtocolValidator
    OnTokenValidated = context =>
    {
        context.SecurityToken = context.SecurityToken.InnerToken ?? context.SecurityToken;
        return Task.CompletedTask;
    }
};

Unfortunately, this event only works if we are receiving a single identity token, for instance when using the implicit flow (id_token token) or authorization code flow (code). When taking advantage of hybrid flow’s c_hash validation (code id_token), we are actually receiving two identity tokens: one from the authorization endpoint via the front-channel, and another from the token endpoint via the back-channel. In this scenario, the Microsoft OpenID Connect handler does not expose an event that we can use to switch token to validate from the JWE format to the JWS format. I’ve requested an event be added to the library to handle this, which you can track here.

Solution 2: Custom Protocol Validator

A workaround that is not generally encouraged, is to create a custom implementation of OpenIdConnectProtocolValidator that on each method simply extracts the inner token when it is present. A pull request was opened in the hosting repo for this, however, the project owner reasoned that this is not the correct place to do this. It is a fair comment, and one that I agree with, however by using this workaround we are for now able to unblock ourselves.

public class JweProtocolValidator : OpenIdConnectProtocolValidator
{
    protected override void ValidateIdToken(OpenIdConnectProtocolValidationContext validationContext)
    {
        if (validationContext.ValidatedIdToken.InnerToken != null)
            validationContext.ValidatedIdToken = validationContext.ValidatedIdToken.InnerToken;

        base.ValidateIdToken(validationContext);
    }

    public override void ValidateTokenResponse(OpenIdConnectProtocolValidationContext validationContext)
    {
        if (validationContext.ValidatedIdToken.InnerToken != null)
            validationContext.ValidatedIdToken = validationContext.ValidatedIdToken.InnerToken;

        base.ValidateTokenResponse(validationContext);
    }

    public override void ValidateUserInfoResponse(OpenIdConnectProtocolValidationContext validationContext)
    {
        if (validationContext.ValidatedIdToken.InnerToken != null)
            validationContext.ValidatedIdToken = validationContext.ValidatedIdToken.InnerToken;

        base.ValidateUserInfoResponse(validationContext);
    }
}

We can then register this new implementation in our OpenID Connect options whilst still maintaining default settings using:

.AddOpenIdConnect("oidc", options =>
{
    // existing config...

    // Allows JWEs to be validated (work-around for https://github.com/aspnet/AspNetCore/issues/9154)
    options.ProtocolValidator = new JweProtocolValidator
    {
        RequireStateValidation = options.ProtocolValidator.RequireStateValidation,
        NonceLifetime = options.ProtocolValidator.NonceLifetime
    };
}

Now, you should be able to authenticate using both encrypted and unencrypted identity tokens.

Bonus: Adding JWE Support to IdentityServer 4 Logout

The other use case for identity tokens is for requests to the end session endpoint. Here we send the identity token as the id_token_hint, sent via the query string, which IdentityServer will then validate and use to help drive single sign out. Sending the token in its current JWE format won’t help though, since IdentityServer only has the public key and therefore can’t read it.

The OpenID Connect specification offers us some guidance here:

If the ID Token received by the RP from the OP is encrypted, to use it as an id_token_hint, the Client MUST decrypt the signed ID Token contained within the encrypted ID Token. The Client MAY re-encrypt the signed ID token to the Authentication Server using a key that enables the server to decrypt the ID Token, and use the re-encrypted ID token as the id_token_hint value.

OpenID Connect Core: 3.1.2.1 Authentication Request

Sending the nested JWS token seems counterproductive since we are back to exposing PII. Re-encrypting the token seems like a better bet, and would even useful when using PKCE. We can even publicize IdentityServer’s public encryption key on its JWKS endpoint.

However, that is a topic for another day.

Summary

Encrypting identity tokens with JWE is certainly possible with IdentityServer4 and ASP.NET Core, however, it’s not without its challenges. However, I think those challenges goes to demonstrate how client library limitations can be a contributing factor in security decisions. Thankfully, the extensibility of the libraries that Microsoft provide, and the responsiveness of their dev teams, allow us to keep our apps secure.

Source Code

You can find a working implementation of the above available on GitHub.