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 EAIAdminSL.Middleware { /// /// Authenticates gbEAI's callback into POST /EAIAdmin/ResolveContext — the first /// gbEAI-calls-GB5 direction (every other integration so far is GB5 calling gbEAI). Same /// idiom as KmsSL.Middleware.KmsApiKeyAuthMiddleware (itself modeled on /// PartnerSL.Middleware.ApiKeyAuthMiddleware): X-Api-Key header, Vault-backed shared secret, /// constant-time SHA-256 comparison, fail CLOSED on any Vault outage. /// /// Flow per request: /// 1. Path not in the exact allowlist (/EAIAdmin/ResolveContext only) → skip entirely. /// 2. No X-Api-Key header → 401. /// 3. Resolve the expected key from Vault (eaiadmin/gbeai-api-key) and compare via /// constant-time hash comparison. /// 4. Vault unreachable / key missing / mismatch → 401 (fail CLOSED). /// 5. Synthesize a PLACEHOLDER LoginDTO (ClientId = -1, UserId = 0) and inject it as the /// Login header — only satisfies FastEndpoints' [FromHeader] string Login binding. The /// real tenant identity comes from the request body's own TenantId field (already on the /// wire in ResolveContextParameters) — EAIContextResolverBLL rebuilds a real, /// DB-routable LoginDTO from that field, same `new LoginDTO { ClientId = tenantId, /// UserId = -1 }` idiom already used by EntitlementLoginFactory/FlsReminderJobHandler/ /// ComplianceSL.AlertCheckSubscriber/KmDiscoveryBLL.EffectiveLogin. /// /// Registration (PlatformHost/Program.cs), exact-path allowlist via app.UseWhen — see that /// file's existing Partner/KMS ApiKeyAuthMiddleware UseWhen blocks for the sibling pattern. /// public sealed class EAIAdminApiKeyAuthMiddleware { private const string ApiKeyHeader = "X-Api-Key"; private const string VaultKeyPath = "eaiadmin/gbeai-api-key"; private readonly RequestDelegate _next; private readonly ILogger _logger; public EAIAdminApiKeyAuthMiddleware(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("EAIAdminApiKeyAuthMiddleware: 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, "EAIAdminApiKeyAuthMiddleware: Vault lookup failed — rejecting request"); await WriteUnauthorized(context, "Authentication temporarily unavailable. Please retry."); return; } if (string.IsNullOrWhiteSpace(expectedKey) || !ConstantTimeEquals(rawKey, expectedKey)) { _logger.LogWarning( "EAIAdminApiKeyAuthMiddleware: 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("EAIAdminApiKeyAuthMiddleware: 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)); } } }