using System.Security.Cryptography; using System.Text; using Dapper; using GB5Shared.Connection; using GB5Shared.DTO.Framework.Enum; using GB5Shared.DTO.Framework.Login; using GB5Shared.DTO.Framework.ResponseStandard; using Microsoft.Data.SqlClient; using Newtonsoft.Json; using PartnerSL.Services; using static GB5Shared.GB5Constant.Constant; namespace PartnerSL.Middleware { /// /// Authenticates headless M2M callers via the X-Api-Key header. /// /// Flow per request: /// 1. No X-Api-Key header → skip (fall through to session-token auth). /// 2. Compute SHA-256(rawKey) → look up TPARTNERAPIKEY for a matching, active, non-expired row. /// 3. Resolve ClientId + DatabaseName from the Host header via DomainCacheService. /// 4. Synthesize a LoginDTO and inject it as the Login header so all endpoints work unchanged. /// 5. Fire-and-forget LASTUSEDUTC update on TPARTNERAPIKEY. /// 6. Scope check: if SCOPES is set, the request path must start with one of the prefixes. /// 7. API key not found / expired / scope mismatch → 401. /// Note: a DB lookup failure also → 401 (fails CLOSED, not open — see the catch block). /// /// Placement: registered BEFORE SessionHeartbeatMiddleware so a valid API key /// short-circuits the session check for machine-to-machine callers. /// Registration (Program.cs): /// app.UseMiddleware<ApiKeyAuthMiddleware>(); /// public sealed class ApiKeyAuthMiddleware { private const string ApiKeyHeader = "X-Api-Key"; // PARTNERID is denormalized directly onto TPARTNERAPIKEY (resolved once at save time, // in PartnerApiKeyDAL.SavePartnerApiKey) — not joined from TPARTNERPRODUCT, which lives // per-tenant and is unreachable from this GB5System-only connection. private const string LookupSql = @" SELECT AK.APIKEYID AS ApiKeyId, AK.PARTNERPRODUCTID AS PartnerProductId, AK.PARTNERID AS PartnerId, AK.SCOPES AS Scopes, AK.RATELIMITPERMINUTE AS RateLimitPerMinute FROM TPARTNERAPIKEY AK WHERE AK.HASHEDKEY = @HashedKey AND AK.STATUS = 1 AND (AK.EXPIRESON IS NULL OR AK.EXPIRESON > GETUTCDATE());"; private const string LastUsedSql = @" UPDATE TPARTNERAPIKEY SET LASTUSEDUTC = GETUTCDATE() WHERE APIKEYID = @ApiKeyId;"; private readonly RequestDelegate _next; private readonly ILogger _logger; public ApiKeyAuthMiddleware(RequestDelegate next, ILogger logger) { _next = next; _logger = logger; } public async Task InvokeAsync( HttpContext context, IApplicationConnection appConnection, DomainCacheService domainCache) { // ── Step 1: Only handle requests that carry X-Api-Key ────────────── if (!context.Request.Headers.TryGetValue(ApiKeyHeader, out var rawKeyValues) || string.IsNullOrWhiteSpace(rawKeyValues.FirstOrDefault())) { await _next(context); return; } // ── Step 2: Skip paths that never need auth ──────────────────────── if (IsInternalPath(context.Request.Path)) { await _next(context); return; } var rawKey = rawKeyValues.First()!.Trim(); // ── Step 3: Compute SHA-256 hash ─────────────────────────────────── var hashBytes = SHA256.HashData(Encoding.UTF8.GetBytes(rawKey)); var hashedKey = Convert.ToHexString(hashBytes).ToLowerInvariant(); // ── Step 4: Look up the API key in the system DB ─────────────────── ApiKeyRecord? keyRecord = null; try { string connStr = await appConnection.Gb5SystemConnectionString().ConfigureAwait(false); using var conn = new SqlConnection(connStr); var rows = await conn.QueryAsync(LookupSql, new { HashedKey = hashedKey }) .ConfigureAwait(false); keyRecord = rows?.FirstOrDefault(); } catch (Exception ex) { // Fail CLOSED, not open — a transient DB/Vault outage must reject the request, not // silently authenticate it as if no key were required. The previous behavior here // (log a warning, call _next(context) anyway) meant an infrastructure hiccup let // an unauthenticated caller straight through. _logger.LogError(ex, "ApiKeyAuthMiddleware: DB lookup failed — rejecting request"); await WriteUnauthorized(context, "Authentication temporarily unavailable. Please retry."); return; } if (keyRecord is null) { _logger.LogWarning("ApiKeyAuthMiddleware: invalid or expired API key (hint: last-4={Hint})", rawKey.Length >= 4 ? rawKey[^4..] : "???"); await WriteUnauthorized(context, "Invalid or expired API key."); return; } // ── Step 5: Scope check ──────────────────────────────────────────── if (!string.IsNullOrWhiteSpace(keyRecord.Scopes)) { var allowedPrefixes = keyRecord.Scopes .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); var requestPath = context.Request.Path.Value ?? ""; bool scopeOk = allowedPrefixes.Any(prefix => requestPath.StartsWith("/" + prefix, StringComparison.OrdinalIgnoreCase) || requestPath.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)); if (!scopeOk) { _logger.LogWarning("ApiKeyAuthMiddleware: scope denied | ApiKeyId={ApiKeyId} Path={Path}", keyRecord.ApiKeyId, requestPath); await WriteUnauthorized(context, "API key does not have permission for this endpoint."); return; } } // ── Step 6: Resolve ClientId/DatabaseName from the Host header ───── var host = context.Request.Host.Host; int clientId = 0; string databaseName = ""; if (domainCache.TryResolve(host, out var domainDto) && domainDto is not null) { clientId = domainDto.ClientId; databaseName = domainDto.DatabaseName ?? ""; } // ── Step 7: Synthesize LoginDTO and inject as Login header ───────── var login = new LoginDTO { PartnerProductId = keyRecord.PartnerProductId, ClientId = clientId, DatabaseName = databaseName, LanguageId = "EN", UserId = 0 // machine user }; var loginJson = JsonConvert.SerializeObject(login); context.Request.Headers["Login"] = loginJson; // ── Step 8: Fire-and-forget LastUsedUtc update ──────────────────── _ = Task.Run(async () => { try { string connStr = await appConnection.Gb5SystemConnectionString().ConfigureAwait(false); using var conn = new SqlConnection(connStr); await conn.ExecuteAsync(LastUsedSql, new { keyRecord.ApiKeyId }).ConfigureAwait(false); } catch (Exception ex) { _logger.LogWarning(ex, "ApiKeyAuthMiddleware: LastUsedUtc update failed for ApiKeyId {ApiKeyId}", keyRecord.ApiKeyId); } }); _logger.LogInformation( "ApiKeyAuthMiddleware: authenticated | ApiKeyId={ApiKeyId} PartnerProductId={PartnerProductId} ClientId={ClientId}", keyRecord.ApiKeyId, keyRecord.PartnerProductId, clientId); await _next(context); } // ── Helpers ────────────────────────────────────────────────────────────── private static bool IsInternalPath(PathString path) { var p = path.Value ?? string.Empty; return p.StartsWith("/dapr/", StringComparison.OrdinalIgnoreCase) || p.StartsWith("/swagger", StringComparison.OrdinalIgnoreCase) || p.StartsWith("/Authentication/", StringComparison.OrdinalIgnoreCase) || p.Equals("/healthz", StringComparison.OrdinalIgnoreCase) || p.Equals("/health", StringComparison.OrdinalIgnoreCase) || p.Equals("/", StringComparison.OrdinalIgnoreCase); } 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)); } private sealed record ApiKeyRecord( int ApiKeyId, int PartnerProductId, int PartnerId, string? Scopes, int RateLimitPerMinute); } }