Search code examples
asp.net-coreauthenticationoauthjwtkeycloak

Dynamic Authority for AddJwtBearer


I have an Mulit-tenant application using KeyCloak as Identity Broker. There is TenantId in token through which , we will be able to identity which Tenant's user it is. Now, when authenticating the token, we use AddJwtBearer with Authority. I want that Authority to be fetched from configuration based on TenantId.

Is there any way to achieve this?

public static class KeycloakAuthenticationExtensions
{
    public static void AddKeycloakAuthentication(this IServiceCollection services, IConfiguration configuration)
    {
        var jwtConfiguration = configuration.GetSection(Core.Authentication.Constants.JwtConfigurationSection).Get<JwtConfiguration>();

        services.AddOptions<JwtConfiguration>().Bind(configuration.GetSection(Authentication.Constants.JwtConfigurationSection));
        services.AddAuthentication(options =>
        {
            options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
            options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;

        }).AddJwtBearer(options =>
        {
            options.Authority = jwtConfiguration!.Authority;
            options.SaveToken = false;
            options.TokenValidationParameters = new Microsoft.IdentityModel.Tokens.TokenValidationParameters
            {
                ValidAudiences = jwtConfiguration.Audiences,
                ValidateAudience = true,
                ValidateIssuer = true,
                ValidateLifetime = true,
                ValidateIssuerSigningKey = true,
                //https://learn.microsoft.com/en-us/aspnet/core/security/authentication/claims?view=aspnetcore-6.0#extend-or-add-custom-claims-using-iclaimstransformation
                //Name claim and role claim mapping
                NameClaimType = ApiConstants.PreferredUserNameClaim
            };

            options.Events = new JwtBearerEvents()
            {
                OnMessageReceived = context =>
                {
                    return Task.CompletedTask;
                },
                OnAuthenticationFailed = context =>
                {
                    context.Response.StatusCode = StatusCodes.Status401Unauthorized;
                    return context.Response.WriteAsync(context.Request.Headers[ApiConstants.AuthorizationHeader].ToString());
                },
                OnTokenValidated = context =>
                {
                    return Task.CompletedTask;
                }
            };

        });
        services.AddSingleton<ITokenProvider, KeyCloakJwtTokenProvider>();
        services.AddSingleton<ITokenStore, InMemoryCachedTokenStore>();
        services.AddTransient<AuthenticationHttpMessageHandler>();
        services.AddHttpClient<ITokenProvider, KeyCloakJwtTokenProvider>(client =>
        {
            client.BaseAddress = new Uri(jwtConfiguration!.Authority + ApiConstants.TokenEndpoint);
        });
    }
}

I have changed that jwtConfiguration to be a list of Tenants information.

For each request , AddJwtBearer is called to validate the token. Based on tenant Id coming as part of token I want to Set Authority and validate the token. I am expecting something as below where I will be able to get that TenantId and get the tenant info from list of tenants stored in configuration

    services.AddOptions<TenantConfiguration>().Bind(configuration.GetSection(Authentication.Constants.TenantConfiguration));
    services.AddAuthentication(options =>
    {
        options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
        options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
        options.DefaultScheme = JwtBearerDefaults.AuthenticationScheme;
    }).AddJwtBearer(options =>
    {
        var tenant1 = jwtConfiguration!.TenantInfo.FirstOrDefault(x => x.TenantId.Equals("**tenantId**", StringComparison.OrdinalIgnoreCase));
        options.Authority = royHillTenant?.Authority;
        options.SaveToken = false;
        options.TokenValidationParameters = new Microsoft.IdentityModel.Tokens.TokenValidationParameters
        {
            ValidAudiences = royHillTenant?.Audiences,
            ValidateAudience = true,
            ValidateIssuer = true,
            ValidateLifetime = true,
            ValidateIssuerSigningKey = true,
            //https://learn.microsoft.com/en-us/aspnet/core/security/authentication/claims?view=aspnetcore-6.0#extend-or-add-custom-claims-using-iclaimstransformation
            //Name claim and role claim mapping
            NameClaimType = ApiConstants.PreferredUserNameClaim
        };

        options.Events = new JwtBearerEvents()
        {
            OnMessageReceived = context =>
            {
                return Task.CompletedTask;
            },
            OnAuthenticationFailed = context =>
            {
                context.Response.StatusCode = StatusCodes.Status401Unauthorized;
                return context.Response.WriteAsync(context.Request.Headers[ApiConstants.AuthorizationHeader].ToString());
            },
            OnTokenValidated = context =>
            {
                return Task.CompletedTask;
            }
        };

    });
    services.AddSingleton<ITokenProvider, KeyCloakJwtTokenProvider>();
    services.AddSingleton<ITokenStore, InMemoryCachedTokenStore>();
    services.AddTransient<AuthenticationHttpMessageHandler>();
    services.AddHttpClient<ITokenProvider, KeyCloakJwtTokenProvider>();// client =>
    //{
    //    client.BaseAddress = new Uri(jwtConfiguration!.Authority + ApiConstants.TokenEndpoint);
    //});
}

UPDATE 1 I tried the way mentioned by @Qiang Fu. I created a middleware and added before authentication pipeline, it modifies the authority dynamically but getting error saying " Unable to validate issuer. validationParameters.ValidIssuer is null or whitespace AND validationParameters.ValidIssuers is null or empty.". I know we can set validIssuer params but I have used same params as the one present in service extensions in the new middleware.

namespace Optym.RMX.Core.Hosting.WebAPI
{
    public class JwtBearerOptionMiddleware
    {
        private readonly RequestDelegate _next;
        private readonly IRailMaxLogger _logger;
        private readonly IServiceProvider serviceProvider;
        private readonly IHttpContextAccessor _contextAccessor;
        private readonly TenantConfiguration tenantConfiguration;
        public JwtBearerOptionMiddleware(RequestDelegate next, IRailMaxLogger logger, IServiceProvider serviceProvider
            , IHttpContextAccessor contextAccessor, IOptions<TenantConfiguration> tenantConfiguration)
        {
            this._logger = logger;
            this._next = next;
            this.serviceProvider = serviceProvider;
            this._contextAccessor = contextAccessor;
            this.tenantConfiguration = tenantConfiguration.Value;
        }
        public async Task InvokeAsync(HttpContext httpContext)
        {
           // string token = this._contextAccessor.HttpContext?.Request.Headers["Authorization"];
            var accessToken = this._contextAccessor.HttpContext?.Request.Headers.Authorization.FirstOrDefault()?.Split(" ").Last();
            var handler = new JwtSecurityTokenHandler();
            var jwtDecoded = handler.ReadToken(accessToken) as JwtSecurityToken;
            string tenantId = jwtDecoded?.Claims.First(claim => claim.Type == "TenantId").Value;

            var tenantInfo = this.tenantConfiguration.TenantInfo.FirstOrDefault(x => x.TenantId.Equals(tenantId, StringComparison.OrdinalIgnoreCase));
            if (tenantInfo == null)
            {
                throw new RmxException($"No tenant information found for Tenant Id {tenantId}");
            }

            //var claims = this._contextAccessor.HttpContext?.User.Identities.First().Claims;
            // string tenantId = claims?.First(claim => claim.Type == "TenantId").Value;

            var jwtoptionsMonitor = serviceProvider.GetService<IOptionsMonitor<JwtBearerOptions>>();
            var jwtoptions = jwtoptionsMonitor.Get(JwtBearerDefaults.AuthenticationScheme);

            //then you can modify jwtoptions as you like
            jwtoptions.Authority = tenantInfo.Authority;
            jwtoptions.TokenValidationParameters.ValidAudiences = tenantInfo.Audiences;

            jwtoptions.SaveToken = false;
            jwtoptions.TokenValidationParameters = new Microsoft.IdentityModel.Tokens.TokenValidationParameters
            {
                ValidAudiences = tenantInfo.Audiences,
                ValidateAudience = true,
                ValidateIssuer = true,
                ValidateLifetime = true,
                ValidateIssuerSigningKey = true,
                //https://learn.microsoft.com/en-us/aspnet/core/security/authentication/claims?view=aspnetcore-6.0#extend-or-add-custom-claims-using-iclaimstransformation
                //Name claim and role claim mapping
                NameClaimType = ApiConstants.PreferredUserNameClaim
            };
            jwtoptions.Events = new JwtBearerEvents()
            {
                OnMessageReceived = context =>
                {
                    return Task.CompletedTask;
                },
                OnAuthenticationFailed = context =>
                {
                    context.Response.StatusCode = StatusCodes.Status401Unauthorized;
                    return context.Response.WriteAsync(context.Request.Headers[ApiConstants.AuthorizationHeader].ToString());
                },
                OnTokenValidated = context =>
                {
                    return Task.CompletedTask;
                }
            };
            await this._next(httpContext);
        }
    }
}

UPDATE 2 Adding ValidIssuer and IssuerSigningKeys in TokenValidationParameters has solved the issue


Solution

  • You could change any service options using IOptionsMonitor later in the controller or in a custom middleware. Try following to modify the options in a controller

        [Route("api/[controller]")]
        [ApiController]
        public class ValuesController : ControllerBase
        {
            private readonly IServiceProvider _provider;
    
            public ValuesController(IServiceProvider provider)
            {
                this._provider = provider;
            }
            [HttpGet("test")]
            public void Test()
            {
                var jwtoptionsMonitor = _provider.GetService<IOptionsMonitor<JwtBearerOptions>>();
                var jwtoptions = jwtoptionsMonitor.Get(JwtBearerDefaults.AuthenticationScheme);
    
                //then you can modify jwtoptions as you like
                jwtoptions.Authority = "new value";
                jwtoptions.TokenValidationParameters.ValidAudience = "new value";
    
            }
        }
    
    

    (Note: Only using inject Iserviceprovider to get IoptionsMonitor works. If you inject IOptionsMonitor directly, it won't work.)