using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; using System.Net.Http; using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging; using Microsoft.IdentityModel.Tokens; namespace GB5Shared.Auth.Jwt { // Per-tenant realm JWKS fetch/cache — the multi-tenant sibling of GB5Shared/SSO/OidcDiscovery.cs // (which is bound to one fixed OidcOptionsDTO/one realm). One running GB5 host serves many // tenants, each potentially resolving to a *different* Keycloak realm (or none, for // AuthMode=Native tenants) — so this cache is keyed by "{KeycloakHost}|{Realm}", not a // singleton bound to one issuer. Registered as a Singleton (safe: only ever mutates its own // ConcurrentDictionary and a per-entry SemaphoreSlim, no scoped/per-request state). public class MultiTenantOidcJwksCache { private class CacheEntry { public IReadOnlyList Keys = Array.Empty(); public string Issuer = string.Empty; public DateTime FetchedAtUtc = DateTime.MinValue; public readonly SemaphoreSlim Lock = new(1, 1); } private readonly IHttpClientFactory _httpFactory; private readonly ILogger _logger; private readonly ConcurrentDictionary _entries = new(); private readonly TimeSpan _cacheDuration = TimeSpan.FromMinutes(30); public MultiTenantOidcJwksCache(IHttpClientFactory httpFactory, ILogger logger) { _httpFactory = httpFactory; _logger = logger; } // Returns (SigningKeys, Issuer) for the given realm, fetching+caching on first use and on // TTL expiry. KeycloakHost must be the bare host (e.g. "https://sso.goodbookserp.in"), no // trailing "/realms/...". Uses the same sanctioned "oidc" named HttpClient every other // Keycloak-facing call in this repo uses (accepts internal self-signed CAs where configured) // — never `new HttpClient()`. public async Task<(IReadOnlyList Keys, string Issuer)> GetSigningKeysAsync( string keycloakHost, string realm, CancellationToken ct = default) { string cacheKey = $"{keycloakHost}|{realm}"; var entry = _entries.GetOrAdd(cacheKey, _ => new CacheEntry()); if (entry.Keys.Count > 0 && DateTime.UtcNow - entry.FetchedAtUtc < _cacheDuration) return (entry.Keys, entry.Issuer); await entry.Lock.WaitAsync(ct); try { if (entry.Keys.Count > 0 && DateTime.UtcNow - entry.FetchedAtUtc < _cacheDuration) return (entry.Keys, entry.Issuer); string realmBase = $"{keycloakHost.TrimEnd('/')}/realms/{realm}"; using var client = _httpFactory.CreateClient("oidc"); var discoRes = await client.GetAsync($"{realmBase}/.well-known/openid-configuration", ct); discoRes.EnsureSuccessStatusCode(); using var discoDoc = JsonDocument.Parse(await discoRes.Content.ReadAsStringAsync(ct)); string issuer = discoDoc.RootElement.GetProperty("issuer").GetString()!; string jwksUri = discoDoc.RootElement.GetProperty("jwks_uri").GetString()!; var jwksRes = await client.GetAsync(jwksUri, ct); jwksRes.EnsureSuccessStatusCode(); string jwksJson = await jwksRes.Content.ReadAsStringAsync(ct); var jwks = new JsonWebKeySet(jwksJson); var keys = jwks.GetSigningKeys().ToList(); entry.Keys = keys; entry.Issuer = issuer; entry.FetchedAtUtc = DateTime.UtcNow; _logger.LogInformation( "MultiTenantOidcJwksCache: refreshed {KeyCount} signing key(s) for realm '{Realm}' at {Host}", keys.Count, realm, keycloakHost); return (entry.Keys, entry.Issuer); } finally { entry.Lock.Release(); } } } }