Mocking JWT tokens in ASP.NET Core integration tests

As we've been migrating services over to .NET Core we needed to mock JWT tokens in ASP.NET Core integration tests. I finally found a way that worked.

As we've been migrating services over to .NET Core we needed to mock JWT tokens in ASP.NET Core integration tests. I finally found a way that worked.

The problem is, by default, the JWT authentication handler in ASP.NET Core tries to communicate with the issuer defined in the JWT token to download the appropriate metadata needed to validate the tokens, but in our case we didn't want to be dependent on that when running through the tests, but we still wanted to make sure our authentication policies worked as intended.

I found out that by setting the Configuration property in the JwtBearerOptions we were able to short-circuit this behavior and make the JWT authentication handler skip the metadata download step.

Here's how!

First of all, we created a static helper class in our integration test project that initializes the required security keys and algorithms to create and sign our mocked JWT tokens. We then, through the standard service configuration, initialize the JWT authentication handler with that same information, making it possible for it to validate our generated JWT tokens.

It's quite simple really!

First, let's create our static helper class:

public static class MockJwtTokens
{
    public static string Issuer { get; } = Guid.NewGuid().ToString();
    public static SecurityKey SecurityKey { get; }
    public static SigningCredentials SigningCredentials { get; }

    private static readonly JwtSecurityTokenHandler s_tokenHandler = new JwtSecurityTokenHandler();
    private static readonly RandomNumberGenerator s_rng = RandomNumberGenerator.Create();
    private static readonly byte[] s_key = new byte[32];

    static MockJwtTokens()
    {
        s_rng.GetBytes(s_key);
        SecurityKey = new SymmetricSecurityKey(s_key) { KeyId = Guid.NewGuid().ToString() };
        SigningCredentials = new SigningCredentials(SecurityKey, SecurityAlgorithms.HmacSha256);
    }

    public static string GenerateJwtToken(IEnumerable<Claim> claims)
    {
        return s_tokenHandler.WriteToken(new JwtSecurityToken(Issuer, null, claims, null, DateTime.UtcNow.AddMinutes(20), SigningCredentials));
    }
}

Here we define our mock JWT issuer, it's security key, and create a simple helper method that accept claims and generates a JWT token with those claims. We just set our JWT tokens to be valid for 20 minutes.

Now all we need to do is hook this up to the JWT authentication handler and that was as simple as overriding a few settings when the tests are being initialized.

public class BaseIntegrationTest : WebApplicationFactory<Startup>
{
    protected override void ConfigureWebHost(IWebHostBuilder builder)
    {
        builder.ConfigureTestServices(ConfigureServices);
        builder.ConfigureLogging((WebHostBuilderContext context, ILoggingBuilder loggingBuilder) =>
        {
            loggingBuilder.ClearProviders();
            loggingBuilder.AddConsole(options => options.IncludeScopes = true);
        });
    }

    protected virtual void ConfigureServices(IServiceCollection services)
    {
        services.Configure<JwtBearerOptions>(JwtBearerDefaults.AuthenticationScheme, options =>
        {
            var config = new OpenIdConnectConfiguration()
            {
                Issuer = MockJwtTokens.Issuer
            };

            config.SigningKeys.Add(MockJwtTokens.SecurityKey);
            options.Configuration = config;
        });
     }
}

We now have a base class that we can have our integration tests inherit from to customize the configuration further. The trick here is to set the Configuration property on the JwtBearerOptions to the values defined in our mock. When that value has been set, the JWT authentication handler will see that it already has the information it needs to validate the JWT tokens we generate, and will not try to download the required metadata. This is documented behavior:

Configuration provided directly by the developer. If provided, then MetadataAddress and the Backchannel properties will not be used. This information should not be updated during request processing.

Hope this helps :)

Mastodon