using System; using System.Text.Json; using System.Text.Json.Serialization; using System.Threading.Tasks; using GB5Shared.Connection; using GB5Shared.DTO.Framework.ServerConfig; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.IdentityModel.Tokens; namespace GB5Shared.Auth.Jwt { // Builds the JwtBearerEvents that make token validation genuinely multi-tenant — see the // "Multi-tenant Keycloak architecture" section of the GB5 Repo-Wide Authentication Hardening // plan. A single `AddJwtBearer()` scheme is normally configured once at startup with one fixed // Authority; that doesn't fit GB5, where one running host serves many tenants, each potentially // expecting a different Keycloak realm's JWKS (or none at all, for AuthMode=Native tenants — // the permanent, first-class "no Keycloak" option for BYOC/on-prem deployments). // // Approach: resolve the tenant hint from the same raw "Login" header FastEndpoints later binds // into LoginDTO (this runs in ASP.NET Core's authentication middleware, BEFORE FastEndpoints' // own model binding, so only the raw header is available yet — not a bound LoginDTO). Mirrors // ApplicationConnection.ResolveConnectionName's exact preference order (ConnectionDatabaseName, // falling back to DatabaseName) so tenant resolution here and DB-connection resolution never // disagree about which tenant a request belongs to. // // If the resolved tenant's MSERVERCONFIG.AUTHMODE is Native (or no tenant/row resolves at all — // e.g. pre-login flows, a malformed header), this calls context.NoResult() to cleanly skip // Keycloak validation for this request; BaseEndPoint.GetLoginDTOFromRequest's dual-mode bridge // then falls through to today's trusted-header path exactly as if no JwtBearer scheme existed. // If AuthMode=Keycloak, this mutates context.Options.TokenValidationParameters for THIS request // only (a supported pattern — JwtBearerHandler reads Options.TokenValidationParameters fresh // after OnMessageReceived runs) to validate against that tenant's realm JWKS/issuer specifically, // cross-checking the resolved tenant against the token's own issuer so a token issued for one // tenant's realm can never be accepted against a different tenant's request. public static class MultiTenantJwtBearerEvents { private class LoginHeaderTenantHint { [JsonPropertyName("ConnectionDatabaseName")] public string? ConnectionDatabaseName { get; set; } [JsonPropertyName("DatabaseName")] public string? DatabaseName { get; set; } } private static readonly JsonSerializerOptions _tenantHintOptions = new() { PropertyNameCaseInsensitive = true }; public static JwtBearerEvents Build() => new() { OnMessageReceived = async context => { var logger = context.HttpContext.RequestServices .GetRequiredService>(); string? connectionName = ResolveConnectionNameFromLoginHeader(context.Request.Headers["Login"]); if (string.IsNullOrWhiteSpace(connectionName)) { // No tenant hint available yet (pre-login flow, malformed/absent Login header) — // skip Keycloak validation for this request; the trusted-header/Native path in // GetLoginDTOFromRequest handles it exactly as today. context.NoResult(); return; } var appConnection = context.HttpContext.RequestServices .GetRequiredService(); TenantAuthConfigDTO authConfig = await appConnection.AuthConfigCached(connectionName); if (!authConfig.IsKeycloak || string.IsNullOrWhiteSpace(authConfig.KeycloakHost) || string.IsNullOrWhiteSpace(authConfig.KeycloakRealm)) { // AuthMode=Native for this tenant — permanent, not transitional (BYOC/on-prem // deployments with no Keycloak at all land here forever, not just during a // migration window). Skip validation; header-trust path handles the request. context.NoResult(); return; } var jwksCache = context.HttpContext.RequestServices .GetRequiredService(); try { var (keys, issuer) = await jwksCache.GetSigningKeysAsync( authConfig.KeycloakHost, authConfig.KeycloakRealm, context.HttpContext.RequestAborted); // Mutate this request's own TokenValidationParameters only — JwtBearerHandler // reads Options.TokenValidationParameters fresh after OnMessageReceived, so this // does not leak across concurrent requests for other tenants. context.Options.TokenValidationParameters = context.Options.TokenValidationParameters.Clone(); context.Options.TokenValidationParameters.ValidateIssuer = true; context.Options.TokenValidationParameters.ValidIssuer = issuer; context.Options.TokenValidationParameters.ValidateIssuerSigningKey = true; context.Options.TokenValidationParameters.IssuerSigningKeys = keys; } catch (Exception ex) { // JWKS fetch failed (realm unreachable, misconfigured host) — fail this // request's token validation rather than silently falling back to the // trusted-header path, since the tenant explicitly expects Keycloak auth. logger.LogWarning(ex, "MultiTenantJwtBearerEvents: failed to resolve JWKS for tenant connection '{ConnectionName}' realm '{Realm}' at {Host}", connectionName, authConfig.KeycloakRealm, authConfig.KeycloakHost); context.Fail("Unable to resolve Keycloak signing keys for this tenant."); } } }; private static string? ResolveConnectionNameFromLoginHeader(string? loginHeaderValue) { if (string.IsNullOrWhiteSpace(loginHeaderValue)) return null; try { var hint = JsonSerializer.Deserialize(loginHeaderValue, _tenantHintOptions); if (hint == null) return null; return !string.IsNullOrWhiteSpace(hint.ConnectionDatabaseName) ? hint.ConnectionDatabaseName : hint.DatabaseName; } catch { // Malformed Login header JSON — treat as "no tenant hint," same as GetLoginDTOFromRequest's // own tolerant fallback behavior on parse failure. return null; } } } }