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
andIssuerSigningKeyResolver
are synchronous delegates).