Skip to content

Instantly share code, notes, and snippets.

@terryaney
Created April 30, 2024 15:10
Show Gist options
  • Select an option

  • Save terryaney/550e8d929af195029c3ea933ec105e86 to your computer and use it in GitHub Desktop.

Select an option

Save terryaney/550e8d929af195029c3ea933ec105e86 to your computer and use it in GitHub Desktop.
Multiple Jwt Signing Keys when using FastEndpoints

I had two specific desires of my Jwt secrets used for signing. They are provided via a secrets.json file created/managed on deployed servers and integrated into the IConfiguration workflow.

  1. I wanted Jwt secrets updatable in the configuration file and have updates applied to my Jwt verification process without the need to restart the application(s).
  2. I wanted the ability to have some FastEndpoints endpoints to support a secondary/alternate Jwt secret for signing verification.

The basic flow is the following.

  1. Add Jwt support via FastEndpoints Services.AddAuthenticationJwtBearer extenion method (if not using FastEndpoints, look at this helper method to see what manually needs to be done, but mostly it is just call to AddAuthentication and AddJwtBearer (with appropriate settings)).
  2. Add a IConfigureOptions<JwtBearerOptions> singleton to the service collection and provide a IssuerSigningKeyResolver delegate. (Note: this needed to be an IConfigureOptions<JwtBearerOptions> instead of simply assigning this same delegate during the Services.AddAuthenticationJwtBearer configuration action because I needed some dependency injection to support reading my IConfiguration).

References:

  1. Updated Secrets
    1. This aspnetcore issue describes how I ended up creating the UpdatableJwtBearerOptions class.
    2. My only concern, simply because I'm mostly a novice in DI, is my use of GetRequiredService in my IConfigureOptions<JwtBearerOptions> option. I've read calling GetRequiredService is an anti-pattern, but this was the only way to get ahold of dependencies I needed and hopefully my use case is not within the anti-pattern range.
  2. Alternate Jwt
    1. Originally, I was going to manually call AddJwtBearer for primary and secondary signing keys, and then follow FastEndpoints Combined Authentication Scheme pattern, but when I saw that I already had ability to swap to an alternate via my IssuerSigningKeyResolver I used that instead (which allowed me to continue to use the AddAuthenticationJwtBearer convenience method).
    2. The original FastEndpoints discussion that provided me the information to attempt the multiple AddJwtBearer pattern.

Implementation:

Program.cs

// Define the IConfiguration 'key' name to use when request starts with one of the provided paths
var jwtTypes = new Dictionary<string, string[]>
{
	{ "xDS", new [] { ApiEndpoints.VersionBase } }
};
var alternateJwtTypes = new Dictionary<string, string[]>
{
	{ "ExcelAddIn", new [] { ApiEndpoints.Single.Base, ApiEndpoints.GlobalTables.Update } }
};

builder.Services.AddAuthenticationJwtBearer( 
	signingOptions: o => 
	{
		// Need to assign key to non null so AddAuthenticationJwtBearer adds a signing key  
		o.SigningKey = "secretUpdatedViaUpdatableJwtBearerOptions";
		o.SigningStyle = JWTBearer.TokenSigningStyle.Symmetric;
	}, 
	bearerOptions: o =>
	{
		// Configure bearer options as you see fit...

		// https://discord.com/channels/933662816458645504/1200521676912332861/1200648920435544144
		// https://github.com/FastEndpoints/FastEndpoints/issues/526 
		// 	PR explaining reason FastEndpoints changing functionality to set to false, but I don't use FastEndpoint's `JWTBearer.CreateToken` so had a mismatch after update
		o.MapInboundClaims = true;
	} );

builder.Services.AddSingleton<IConfigureOptions<JwtBearerOptions>>( provider =>
{
	var configuration = provider.GetRequiredService<IConfiguration>();
	var httpContextAccessor = provider.GetRequiredService<IHttpContextAccessor>();
	var options = new UpdatableJwtBearerOptions( configuration, httpContextAccessor, jwtTypes, alternateJwtTypes );
	return options;
} );

UpdatableJwtBearerOptions.cs

public class UpdatableJwtBearerOptions : IConfigureNamedOptions<JwtBearerOptions>
{
	private readonly IConfiguration configuration;
	private readonly IHttpContextAccessor httpContextAccessor;
	private readonly Dictionary<string, string[]> jwtTypes;
	private readonly Dictionary<string, string[]>? alternateJwtTypes;

	public UpdatableJwtBearerOptions( IConfiguration configuration, IHttpContextAccessor httpContextAccessor, Dictionary<string, string[]> jwtTypes, Dictionary<string, string[]>? alternateJwtTypes = null )
	{
		this.configuration = configuration;
		this.httpContextAccessor = httpContextAccessor;
		this.jwtTypes = jwtTypes;
		this.alternateJwtTypes = alternateJwtTypes;
	}

	public void Configure( string? name, JwtBearerOptions options )
	{
		options.TokenValidationParameters.IssuerSigningKeyResolver = ( token, securityToken, kid, validationParameters ) =>
		{
			var ctx = httpContextAccessor.HttpContext!;
			var path = ctx.Request.Path;
			var jwtType = jwtTypes.Count == 1
				? jwtTypes.Keys.First() // If only one type mapping, that is global
				: jwtTypes.Keys.FirstOrDefault( k => jwtTypes[ k ].Any( p => path!.StartsWithSegments( p ) ) );

			if ( ( alternateJwtTypes?.Any() ?? false ) && ctx.Request.Headers.TryGetValue( "x-kat-alternate-jwt", out var h ) && string.Compare( h.FirstOrDefault(), "true", true ) == 0 )
			{
				jwtType = alternateJwtTypes.Keys.FirstOrDefault( k => alternateJwtTypes[ k ].Any( p => path!.StartsWithSegments( p ) ) );
			}

			validationParameters.ValidIssuer =
				configuration.GetValue<string>( $"TheKeep:Jwt:{jwtType}:Issuer" ) ??
				configuration.GetValue<string>( $"MyKeep:Jwt:{jwtType}:Issuer" );

			var secret =
				configuration.GetValue<string>( $"TheKeep:Jwt:{jwtType}:Secret" ) ??
				configuration.GetValue<string>( $"MyKeep:Jwt:{jwtType}:Secret" )!;

			return new List<SecurityKey>() 
			{ 
				new SymmetricSecurityKey( Encoding.ASCII.GetBytes( secret ) )
			};
		};
	}

	[ExcludeFromCodeCoverage( Justification = "Interface method not used/supported" )]
	public void Configure( JwtBearerOptions options ) => throw new NotImplementedException();
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment