6

I need to implement multi-tenant REST API on asp.net core and use the Jwt Web token for authentication.

Asp.net core docs suggest using the following code in Startup.cs ConfigureServices method:

    services.AddAuthentication().AddJwtBearer("Bearer", options =>
    {
        options.Audience = "MyAudience";
        options.Authority = "https://myauhorityserver.com";
    }

The issue is that my REST API application is multi-tenant. The tenant is discovered from the URL, e.g.

https://apple.myapi.com, 
https://samsung.myapi.com, 
https://google.myapi.com

So each of such URLs will eventually point to the same IP, but based on the first word in the URL the app discovers tenant on using the appropriate DB connection.

Each such tenant has its own Authority URL. We use Keycloak as an identity management server, so each tenant on it has its own REALM. So the Authority URL per tenant is something like that:

https://mykeycloack.com/auth/realms/11111111, 
https://mykeycloack.com/auth/realms/22222222,
https://mykeycloack.com/auth/realms/33333333

The API application should be able to add and remove tenants dynamically, without restarting the application so, setting all the tenants in the application startup is not a good idea.

I was trying to add more schemas with more calls to AddJwtBearer, however, all the calls go to the schema "Bearer", according to options.Events.OnAuthenticationFailed event. It's not clear how to make other schemas to handle calls with Bearer token in HTTP header. Even though if it's somehow possible with a help of custom middle-ware, as I mention before providing tenant-specific configuration for Bearer token authentication in the app startup is not a solution as new tenants need to be added dynamically.

Additional info: According to the fiddler, the Authority URL is finally combined with

/.well-known/openid-configuration

and called when the first request comes to the API endpoint marked with

[Authorize]

If the to configuration fails, the API call fails too. If the call to configuration is successful, the application is not calling it again on the next API requests.

jps
  • 20,041
  • 15
  • 75
  • 79
  • have you seen this one not sure its what youre after though https://stackoverflow.com/questions/57462151/jwt-authentication-based-on-the-parameter-in-multi-tenant-asp-net-core-web-site – SWilko Sep 16 '19 at 06:44
  • In case you were still interested in an answer :) Look bellow. – David Lebee Jul 23 '20 at 18:32

2 Answers2

7

I have found a solution and made it a Nuget Package for reusability you can take a look.

Pretty much I replace the JwtBearerTokenHandler by a DynamicBearerTokenHandler, it gives the flexibility, to resolve OpenIdConnectOptions at runtime.

I've also made another package that cares care of that for you, the only thing you need is to resolve the authority for the ongoing request, you receive the HttpContext.

Here is the package: https://github.com/PoweredSoft/DynamicJwtBearer

How to use it.

public class Startup
{
public void ConfigureServices(IServiceCollection services)
        {
            services.AddHttpContextAccessor();
            services.AddMemoryCache();
            services
                .AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
                .AddDynamicJwtBearer(JwtBearerDefaults.AuthenticationScheme, options =>
                {
                    options.TokenValidationParameters.ValidateAudience = false;
                })
                .AddDynamicAuthorityJwtBearerResolver<ResolveAuthorityService>();

            services.AddControllers();
        }
}

The service

internal class ResolveAuthorityService : IDynamicJwtBearerAuthorityResolver
    {
        private readonly IConfiguration configuration;

        public ResolveAuthorityService(IConfiguration configuration)
        {
            this.configuration = configuration;
        }

        public TimeSpan ExpirationOfConfiguration => TimeSpan.FromHours(1);

        public Task<string> ResolveAuthority(HttpContext httpContext)
        {
            var realm = httpContext.Request.Headers["X-Tenant"].FirstOrDefault() ?? configuration["KeyCloak:MasterRealm"];
            var authority = $"{configuration["KeyCloak:Endpoint"]}/realms/{realm}";
            return Task.FromResult(authority);
        }
    }

Whats different from the original one


// before 

if (_configuration == null && Options.ConfigurationManager != null)
{
    _configuration = await 
    Options.ConfigurationManager.GetConfigurationAsync(Context.RequestAborted);
}

// after
var currentConfiguration = await this.dynamicJwtBearerHanderConfigurationResolver.ResolveCurrentOpenIdConfiguration(Context);

How its replaced

 public static AuthenticationBuilder AddDynamicJwtBearer(this AuthenticationBuilder builder, string authenticationScheme, Action<JwtBearerOptions> action = null)
        {
            builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton<IPostConfigureOptions<JwtBearerOptions>, JwtBearerPostConfigureOptions>());

            if (action != null)
                return builder.AddScheme<JwtBearerOptions, DynamicJwtBearerHandler>(authenticationScheme, null, action);

            return builder.AddScheme<JwtBearerOptions, DynamicJwtBearerHandler>(authenticationScheme, null, _ => { });
        }

Source of the Handler

public class DynamicJwtBearerHandler : JwtBearerHandler
    {
        private readonly IDynamicJwtBearerHanderConfigurationResolver dynamicJwtBearerHanderConfigurationResolver;

        public DynamicJwtBearerHandler(IOptionsMonitor<JwtBearerOptions> options, ILoggerFactory logger, UrlEncoder encoder, ISystemClock clock, IDynamicJwtBearerHanderConfigurationResolver dynamicJwtBearerHanderConfigurationResolver) : base(options, logger, encoder, clock)
        {
            this.dynamicJwtBearerHanderConfigurationResolver = dynamicJwtBearerHanderConfigurationResolver;
        }

        /// <summary>
        /// Searches the 'Authorization' header for a 'Bearer' token. If the 'Bearer' token is found, it is validated using <see cref="TokenValidationParameters"/> set in the options.
        /// </summary>
        /// <returns></returns>
        protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
        {
            string token = null;
            try
            {
                // Give application opportunity to find from a different location, adjust, or reject token
                var messageReceivedContext = new MessageReceivedContext(Context, Scheme, Options);

                // event can set the token
                await Events.MessageReceived(messageReceivedContext);
                if (messageReceivedContext.Result != null)
                {
                    return messageReceivedContext.Result;
                }

                // If application retrieved token from somewhere else, use that.
                token = messageReceivedContext.Token;

                if (string.IsNullOrEmpty(token))
                {
                    string authorization = Request.Headers[HeaderNames.Authorization];

                    // If no authorization header found, nothing to process further
                    if (string.IsNullOrEmpty(authorization))
                    {
                        return AuthenticateResult.NoResult();
                    }

                    if (authorization.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
                    {
                        token = authorization.Substring("Bearer ".Length).Trim();
                    }

                    // If no token found, no further work possible
                    if (string.IsNullOrEmpty(token))
                    {
                        return AuthenticateResult.NoResult();
                    }
                }

                var currentConfiguration = await this.dynamicJwtBearerHanderConfigurationResolver.ResolveCurrentOpenIdConfiguration(Context);
                var validationParameters = Options.TokenValidationParameters.Clone();
                if (currentConfiguration != null)
                {
                    var issuers = new[] { currentConfiguration.Issuer };
                    validationParameters.ValidIssuers = validationParameters.ValidIssuers?.Concat(issuers) ?? issuers;

                    validationParameters.IssuerSigningKeys = validationParameters.IssuerSigningKeys?.Concat(currentConfiguration.SigningKeys)
                        ?? currentConfiguration.SigningKeys;
                }

                List<Exception> validationFailures = null;
                SecurityToken validatedToken;
                foreach (var validator in Options.SecurityTokenValidators)
                {
                    if (validator.CanReadToken(token))
                    {
                        ClaimsPrincipal principal;
                        try
                        {
                            principal = validator.ValidateToken(token, validationParameters, out validatedToken);
                        }
                        catch (Exception ex)
                        {
                            Logger.TokenValidationFailed(ex);

                            // Refresh the configuration for exceptions that may be caused by key rollovers. The user can also request a refresh in the event.
                            if (Options.RefreshOnIssuerKeyNotFound && Options.ConfigurationManager != null
                                && ex is SecurityTokenSignatureKeyNotFoundException)
                            {
                                Options.ConfigurationManager.RequestRefresh();
                            }

                            if (validationFailures == null)
                            {
                                validationFailures = new List<Exception>(1);
                            }
                            validationFailures.Add(ex);
                            continue;
                        }

                        Logger.TokenValidationSucceeded();

                        var tokenValidatedContext = new TokenValidatedContext(Context, Scheme, Options)
                        {
                            Principal = principal,
                            SecurityToken = validatedToken
                        };

                        await Events.TokenValidated(tokenValidatedContext);
                        if (tokenValidatedContext.Result != null)
                        {
                            return tokenValidatedContext.Result;
                        }

                        if (Options.SaveToken)
                        {
                            tokenValidatedContext.Properties.StoreTokens(new[]
                            {
                                new AuthenticationToken { Name = "access_token", Value = token }
                            });
                        }

                        tokenValidatedContext.Success();
                        return tokenValidatedContext.Result;
                    }
                }

                if (validationFailures != null)
                {
                    var authenticationFailedContext = new AuthenticationFailedContext(Context, Scheme, Options)
                    {
                        Exception = (validationFailures.Count == 1) ? validationFailures[0] : new AggregateException(validationFailures)
                    };

                    await Events.AuthenticationFailed(authenticationFailedContext);
                    if (authenticationFailedContext.Result != null)
                    {
                        return authenticationFailedContext.Result;
                    }

                    return AuthenticateResult.Fail(authenticationFailedContext.Exception);
                }

                return AuthenticateResult.Fail("No SecurityTokenValidator available for token: " + token ?? "[null]");
            }
            catch (Exception ex)
            {
                Logger.ErrorProcessingMessage(ex);

                var authenticationFailedContext = new AuthenticationFailedContext(Context, Scheme, Options)
                {
                    Exception = ex
                };

                await Events.AuthenticationFailed(authenticationFailedContext);
                if (authenticationFailedContext.Result != null)
                {
                    return authenticationFailedContext.Result;
                }

                throw;
            }
        }
    }
David Lebee
  • 596
  • 4
  • 14
  • This works for me, however in my Development Environment, I am using keycloak under HTTP and get an error saying to set RequireHttps property on IDocumentRetriever to false. To do so, I need to implement my own DynamicAuthorityJwtBearerHandlerConfigurationResolver. It would be nice to have this as an option passed onto .AddDynamicAuthorityJwtBearerResolver(); – monty.py Jun 30 '23 at 09:02
  • 1
    @monty.py you can inject the options in your service. IOptionsMonitor options – David Lebee Jul 07 '23 at 11:36
0

If you're still trying to figure this out you can try Finbuckle.

https://www.finbuckle.com/MultiTenant/Docs/Authentication

Noah
  • 481
  • 5
  • 15