0

We're building a multi-tenant setup with a C# Web API and KeyCloak for auth and APISIX as application gateway. APISIX handles the authentication and passes an X-Access-Token to our API when authentication was successful.

For authorization purposes in our API we use this token to get the user. We have come up with the following code:

// IServiceCollection auth extension methods
public static class AuthExtensions
{
    // Configuration cache (key = realm, value = OIDC config)
    private static readonly IMemoryCache _realmconfigurations = new MemoryCache(new MemoryCacheOptions());

    // Configures auth
    public static IServiceCollection AddDefaultAuth(this IServiceCollection services, IConfiguration configuration)
        => services
        .AddTransient<IClaimsTransformation, ClaimsTransformer>()
        .AddAuthentication(JwtBearerDefaults.AuthenticationScheme).AddJwtBearer(options =>
        {
            var baseuri = new Uri("http://keycloak.host.example/realms/");
            var ttl = TimeSpan.FromHours(1);

            options.TokenValidationParameters = new TokenValidationParameters
            {
                ValidateLifetime = true,
                ValidateIssuer = true,
                ValidateAudience = true,
                ValidAudiences = ["frontend", "swagger", "app"],
                ValidateIssuerSigningKey = true,
                // Checks issuer against realm's OIDC config
                IssuerValidator = (issuer, securityToken, parameters) => GetConfiguration(baseuri, securityToken, ttl).Issuer,
                // Checks signing keys against realm's OIDC config JsonWebKeySet keys
                IssuerSigningKeyResolver = (token, securityToken, kid, parameters) => GetConfiguration(baseuri, securityToken, ttl ).JsonWebKeySet.Keys
            };
            options.SaveToken = true;
            options.Events = new JwtBearerEvents
            {
                OnMessageReceived = context =>
                {
                    if (context.Request.Headers.TryGetValue("X-Access-Token", out var header))
                    {
                        context.Token = header;
                    }
                    return Task.CompletedTask;
                }
            };
            options.Validate();
        }).Services
        .AddAuthorization();


    // Retrieves, and caches, OIDC configuration from IDP
    private static OpenIdConnectConfiguration GetConfiguration(Uri baseUri, SecurityToken securityToken, TimeSpan ttl)
    {
        var realm = GetRealmFromToken(securityToken);
        return _realmconfigurations.GetOrCreate(realm, (entry) =>
        {
            entry.AbsoluteExpirationRelativeToNow = ttl;
            return GetConfigurationManager(baseUri, realm).GetConfigurationAsync(CancellationToken.None).GetAwaiter().GetResult();
        }) ?? throw new InvalidOperationException();
    }

    // Creates and returns a realm-specific configurationmanager
    public static IConfigurationManager<OpenIdConnectConfiguration> GetConfigurationManager(Uri baseUri, string realm)
        => new ConfigurationManager<OpenIdConnectConfiguration>(GetRealmBaseUri(baseUri, realm) + ".well-known/openid-configuration",
            new OpenIdConnectConfigurationRetriever(),
            new HttpDocumentRetriever()
        );

    // Creates a real-specific Base Uri
    private static Uri GetRealmBaseUri(Uri baseKeysUri, string realm) => new(baseKeysUri, Uri.EscapeDataString(realm) + "/");

    // Gets the real from a token's issuer
    private static string GetRealmFromToken(SecurityToken securityToken) => securityToken.Issuer.Split('/')[^1];

This uses the OIDC configuration for specific url from the .well-known/openid-configuration KeyCloak endpoint and caches this configuration in a memorycache so we have at most 1 request per realm to get this configuration and store it (and "under the hood" another request to get the jwks certs from /realms/{realm}/protocol/openid-connect/certs as far as I'm aware).

The realm is determined by taking the last element from the token's Issuer.

  • I wonder, however, if this is the best way to go. In my reasoning, tokens from unknown issuers won't be accepted because the .well-known/openid-configuration for a given 'randomly guessed' realm won't exist. And for existing realms, the configuration will be retrieved from KeyCloak, including all signing keys etc. so a token can, and will, then be, validated with that configuration data. When we add a realm to KeyCloak this should be automatically 'picked up' in this setup. OIDC configs in the memorycache expire, currently, every, hour though I'm not sure if/how they are (for example) auto-rotated, then this may require a little extra 'trigger' to renew our cached information).

  • One thing I don't like currently is that this code is synchronous, but because the ConfigurationManager's GetConfigurationAsync is Async we're pretty much forced to use .GetAwaiter().GetResult() which is... meh. I don't see how we can make this code async in the current setup (IssuerValidator and IssuerSigningKeyResolver are synchronous delegates).

2
  • Could you set up each tenant as a separate issuer in a similar way to this thread? - stackoverflow.com/questions/49694383/… Commented May 31, 2024 at 16:19
  • I'm not sure I understand what you mean. But I also think maybe I wasn't clear on the 'issuer part' of my question. I tried to explain that, as far as I can see, the issuer will either be 'recognised' because the realm exists and then be validated with the corresponding keyset, or it won't (for self-generated JWT tokens by attackers). The configuration data for that is cached; I wonder though if - under normal circumstances - that data even changes or I can cache indefinitely. And if it does change / keys gets rotated, what would be a good approach to know when to expire the cache? Commented Jun 1, 2024 at 0:06

0

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.