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