using System.Security.Cryptography; using System.Text; using GB5Shared.DTO.Framework.Enum; using GB5Shared.DTO.Framework.ResponseStandard; using GB5Shared.Vault; using Newtonsoft.Json; namespace FrameworkSL.Middleware { /// /// Gates every FrameworkSL/Endpoints/KeyCloak/* admin-API endpoint (CreateRealm, CreateClient, /// CreateUser, GetMasterAccessToken, ...) — found this session all 15 were AllowAnonymous() /// with zero authentication of any kind: anyone who could reach this host (confirmed /// internet-reachable, no gateway, earlier this session) could create/delete Keycloak realms, /// clients, and users, or retrieve a live Keycloak admin bearer token via GetMasterAccessToken. /// /// Same idiom as EAIAdminApiKeyAuthMiddleware/KmsApiKeyAuthMiddleware/PartnerSL's /// ApiKeyAuthMiddleware: X-Api-Key header, Vault-backed shared secret, constant-time SHA-256 /// comparison, fail CLOSED on any Vault outage. Gates the whole "/Keycloak" prefix (not an /// exact-path allowlist like those examples) — every endpoint under it is part of the same /// single admin surface, all equally sensitive (GetMasterAccessToken alone would leak a live /// admin token to anyone who could reach it). /// /// This key is GB5's OWN internal M2M shared secret protecting access to this admin-API /// wrapper — distinct from Keycloak's own admin-cli credential at /// "keycloak/admin-username"/"keycloak/admin-password" (KeyCloakDAL.GetMasterAccessToken), /// which this middleware never touches. /// /// No Login header is synthesized here (unlike EAIAdminApiKeyAuthMiddleware) — none of the /// Keycloak endpoints' request records declare a Login property, so BaseEndPoint's /// reflection-based lookup already returns null safely; nothing downstream expects one. /// /// Registration (FrameworkSL/Program.cs): app.UseWhen(path starts with "/Keycloak", ...) — /// same convention as PlatformHost's own Partner/KMS/EAIAdmin ApiKeyAuthMiddleware blocks. /// public sealed class KeycloakApiKeyAuthMiddleware { private const string ApiKeyHeader = "X-Api-Key"; private const string VaultKeyPath = "keycloak-admin-api/shared-secret"; private readonly RequestDelegate _next; private readonly ILogger _logger; public KeycloakApiKeyAuthMiddleware(RequestDelegate next, ILogger logger) { _next = next; _logger = logger; } public async Task InvokeAsync(HttpContext context, IVaultService vaultService) { if (!context.Request.Headers.TryGetValue(ApiKeyHeader, out var rawKeyValues) || string.IsNullOrWhiteSpace(rawKeyValues.FirstOrDefault())) { _logger.LogWarning("KeycloakApiKeyAuthMiddleware: missing X-Api-Key header on {Path}", context.Request.Path); await WriteUnauthorized(context, "X-Api-Key header is required."); return; } var rawKey = rawKeyValues.First()!.Trim(); string? expectedKey; try { expectedKey = await vaultService.GetSecretAsync(VaultKeyPath, context.RequestAborted) .ConfigureAwait(false); } catch (Exception ex) { // Fail CLOSED — a Vault outage must reject the request, never silently authenticate. _logger.LogError(ex, "KeycloakApiKeyAuthMiddleware: Vault lookup failed — rejecting request"); await WriteUnauthorized(context, "Authentication temporarily unavailable. Please retry."); return; } if (string.IsNullOrWhiteSpace(expectedKey) || !ConstantTimeEquals(rawKey, expectedKey)) { _logger.LogWarning( "KeycloakApiKeyAuthMiddleware: invalid API key (hint: last-4={Hint}) on {Path}", rawKey.Length >= 4 ? rawKey[^4..] : "???", context.Request.Path); await WriteUnauthorized(context, "Invalid API key."); return; } _logger.LogInformation("KeycloakApiKeyAuthMiddleware: authenticated M2M call | Path={Path}", context.Request.Path); await _next(context); } private static bool ConstantTimeEquals(string rawKey, string expectedKey) { var rawHash = SHA256.HashData(Encoding.UTF8.GetBytes(rawKey)); var expectedHash = SHA256.HashData(Encoding.UTF8.GetBytes(expectedKey)); return CryptographicOperations.FixedTimeEquals(rawHash, expectedHash); } private static async Task WriteUnauthorized(HttpContext context, string message) { var response = new ResponseStandardDTO { Status = FrameworkEnumDTO.ResponseStatus.Unauthorized, Body = message, ErrorBody = message }; context.Response.StatusCode = 401; context.Response.ContentType = "application/json"; await context.Response.WriteAsync(JsonConvert.SerializeObject(response)); } } }