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 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?
    – RobIII
    Commented Jun 1 at 0:06

0

Browse other questions tagged or ask your own question.