using System.Security.Cryptography; using System.Text; using GB5Shared.DTO.Framework.Enum; using GB5Shared.DTO.Framework.Login; using GB5Shared.DTO.Framework.ResponseStandard; using GB5Shared.Vault; using Newtonsoft.Json; namespace KmsSL.Middleware { /// /// Authenticates the machine-to-machine calls gbEAI's kms_discovery_adapter.py makes directly /// into /KmDiscovery/SaveCandidate and /KmDiscovery/UpdateJobStatus via the X-Api-Key header — /// unlike every other KMS endpoint (browser-session-driven, AllowAnonymous() + a real Login /// header decoded server-side), these two have no HTTP session to inherit tenant identity /// from, so they need their own explicit check. Same idiom as /// PartnerSL.Middleware.ApiKeyAuthMiddleware (the only established precedent for this in the /// repo — no IPreProcessor usage exists anywhere), simplified for a single external caller /// (gbEAI) instead of a partner registry: one shared secret in Vault, not a per-caller DB table. /// /// Flow per request: /// 1. Path not in the exact allowlist below → skip entirely (never touches the /// browser-driven KmDiscovery endpoints or any other KMS route). /// 2. No X-Api-Key header → 401 (these two paths REQUIRE it; there is no session fallback). /// 3. Resolve the expected key from Vault (kms/gbeai-api-key) and compare via constant-time /// SHA-256 hash comparison. /// 4. Vault unreachable / key missing / mismatch → 401 (fail CLOSED, matching Partner's own /// documented discipline — a transient outage must reject the request, not silently let /// it through). /// 5. Synthesize a PLACEHOLDER LoginDTO (ClientId = -1, UserId = 0) and inject it as the /// Login header — this only satisfies FastEndpoints' [FromHeader] string Login binding. /// The real tenant identity for this call is the DTO's own TenantId field (already on /// the wire in KmCandidateDTO/KmExtractionJobDTO) — KmDiscoveryBLL.SaveCandidate/ /// UpdateJobStatus rebuild a real, DB-routable LoginDTO from that field, matching the /// established `new LoginDTO { ClientId = tenantId, UserId = -1 }` idiom already used by /// EntitlementLoginFactory/FlsReminderJobHandler/ComplianceSL's AlertCheckSubscriber for /// exactly this "no HTTP session, only a tenant id" situation. /// /// Registration (PlatformHost/Program.cs), exact-path allowlist via app.UseWhen — see that /// file's existing Partner ApiKeyAuthMiddleware UseWhen block for the sibling pattern. /// public sealed class KmsApiKeyAuthMiddleware { private const string ApiKeyHeader = "X-Api-Key"; private const string VaultKeyPath = "kms/gbeai-api-key"; private readonly RequestDelegate _next; private readonly ILogger _logger; public KmsApiKeyAuthMiddleware(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("KmsApiKeyAuthMiddleware: 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, "KmsApiKeyAuthMiddleware: Vault lookup failed — rejecting request"); await WriteUnauthorized(context, "Authentication temporarily unavailable. Please retry."); return; } if (string.IsNullOrWhiteSpace(expectedKey) || !ConstantTimeEquals(rawKey, expectedKey)) { _logger.LogWarning( "KmsApiKeyAuthMiddleware: invalid API key (hint: last-4={Hint})", rawKey.Length >= 4 ? rawKey[^4..] : "???"); await WriteUnauthorized(context, "Invalid API key."); return; } // Placeholder only — see class doc comment. Real tenant identity comes from the // request body's own TenantId field, resolved inside the BLL. var login = new LoginDTO { ClientId = -1, UserId = 0 }; context.Request.Headers["Login"] = JsonConvert.SerializeObject(login); _logger.LogInformation("KmsApiKeyAuthMiddleware: 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)); } } }