using Gateway.Data; using Microsoft.IdentityModel.Protocols; using Microsoft.IdentityModel.Protocols.OpenIdConnect; using Microsoft.IdentityModel.Tokens; using System.IdentityModel.Tokens.Jwt; using System.Security.Claims; using System.Text.Json; namespace Gateway.Security; /// /// Tight auth contract: /// 1) POST /api/auth/session -> MUST be Entra JWT (Authorization: Bearer ) /// 2) All other /api/* -> MUST be valid session token (X-Session-Token OR Authorization: Bearer ) /// 3) Dev bypass -> optional (Development or Auth:AllowDevBypass=true) /// /// Populates ClientContext for downstream services. /// Emits Warning logs so Azure Log Stream shows request-level auth flow. /// Adds debug headers (X-Correlation-Id, X-Auth-Path, X-Auth-Fail). /// public sealed class ClientAuthMiddleware { private readonly RequestDelegate _next; private readonly ILogger _logger; private readonly IConfiguration _config; // Exact paths that do not require authentication private static readonly HashSet _anonymousExact = new(StringComparer.OrdinalIgnoreCase) { "/", "/health" }; // Prefix paths that do not require authentication private static readonly string[] _anonymousPrefixes = { "/swagger", "/api/test" }; // Cache OpenID config manager (avoid fetching metadata every request) private static ConfigurationManager? _oidcConfigManager; private static readonly object _oidcLock = new(); public ClientAuthMiddleware(RequestDelegate next, ILogger logger, IConfiguration config) { _next = next; _logger = logger; _config = config; } public async Task InvokeAsync(HttpContext context, ClientContext clientContext, SqlService sql) { var pathRaw = context.Request.Path.Value ?? ""; var path = pathRaw.ToLowerInvariant(); var corrId = EnsureCorrelationId(context); // Always visible in ACA log stream _logger.LogWarning("[Auth] HIT {Method} {Path} | Corr={Corr}", context.Request.Method, pathRaw, corrId); // Anonymous paths if (IsAnonymousPath(path)) { SetAuthDebugHeaders(context, corrId, authPath: "anonymous", authFail: null); await _next(context); return; } // --------------------------------------------------------------------- // 1) SESSION EXCHANGE: MUST be Entra JWT // --------------------------------------------------------------------- if (path.StartsWith("/api/auth/session", StringComparison.OrdinalIgnoreCase)) { if (await TryJwtAuthAsync(context, clientContext, corrId)) { SetAuthDebugHeaders(context, corrId, authPath: "jwt(session-exchange)", authFail: null); _logger.LogWarning("[Auth] Session exchange authorized via JWT | Email={Email} | Corr={Corr}", clientContext.Email, corrId); await _next(context); return; } SetAuthDebugHeaders(context, corrId, authPath: "jwt(session-exchange)", authFail: "jwt-required"); _logger.LogWarning("[Auth] Session exchange denied: valid Entra JWT required | Corr={Corr}", corrId); context.Response.StatusCode = 401; await context.Response.WriteAsJsonAsync(new { ok = false, error = "Valid Entra authentication required", correlationId = corrId }); return; } // --------------------------------------------------------------------- // 2) ALL OTHER /api/auth/*: MUST be session (or dev bypass) // (signoff, refresh, me, switch-client, etc.) // --------------------------------------------------------------------- if (path.StartsWith("/api/auth", StringComparison.OrdinalIgnoreCase)) { if (TryDevBypass(context, clientContext, corrId)) { SetAuthDebugHeaders(context, corrId, authPath: "dev-bypass(auth)", authFail: null); await _next(context); return; } if (await TrySessionAuthAsync(context, clientContext, sql, corrId)) { SetAuthDebugHeaders(context, corrId, authPath: "session(auth)", authFail: null); await _next(context); return; } SetAuthDebugHeaders(context, corrId, authPath: "session(auth)", authFail: "session-required"); _logger.LogWarning("[Auth] /api/auth denied: valid session required | Corr={Corr}", corrId); context.Response.StatusCode = 401; await context.Response.WriteAsJsonAsync(new { ok = false, error = "Valid session required", correlationId = corrId }); return; } // --------------------------------------------------------------------- // 3) ALL OTHER REQUESTS (typically /api/*): MUST be session (or dev bypass) // NO JWT FALLBACK. Keeps Bearer= unambiguous. // --------------------------------------------------------------------- if (TryDevBypass(context, clientContext, corrId)) { SetAuthDebugHeaders(context, corrId, authPath: "dev-bypass", authFail: null); await _next(context); return; } if (await TrySessionAuthAsync(context, clientContext, sql, corrId)) { SetAuthDebugHeaders(context, corrId, authPath: "session", authFail: null); await _next(context); return; } SetAuthDebugHeaders(context, corrId, authPath: "session", authFail: "session-required"); _logger.LogWarning("[Auth] UNAUTHORIZED: valid session required | {Path} | Corr={Corr}", pathRaw, corrId); context.Response.StatusCode = 401; await context.Response.WriteAsJsonAsync(new { ok = false, error = "Valid session required", correlationId = corrId }); } private static bool IsAnonymousPath(string pathLower) { if (_anonymousExact.Contains(pathLower)) return true; return _anonymousPrefixes.Any(p => pathLower.StartsWith(p, StringComparison.OrdinalIgnoreCase)); } private static string EnsureCorrelationId(HttpContext context) { const string header = "X-Correlation-Id"; if (!context.Request.Headers.TryGetValue(header, out var existing) || string.IsNullOrWhiteSpace(existing.FirstOrDefault())) { var id = Guid.NewGuid().ToString("N"); context.Request.Headers[header] = id; return id; } return existing.First()!; } private static void SetAuthDebugHeaders(HttpContext context, string corrId, string authPath, string? authFail) { context.Response.Headers["X-Correlation-Id"] = corrId; context.Response.Headers["X-Auth-Path"] = authPath; if (!string.IsNullOrWhiteSpace(authFail)) context.Response.Headers["X-Auth-Fail"] = authFail; } /// /// Session token authentication: Validate against our session database. /// Accepts X-Session-Token header OR Authorization: Bearer . /// private async Task TrySessionAuthAsync(HttpContext context, ClientContext clientContext, SqlService sql, string corrId) { string? token = null; if (context.Request.Headers.TryGetValue("X-Session-Token", out var sessionHeader)) token = sessionHeader.FirstOrDefault(); if (string.IsNullOrWhiteSpace(token) && context.Request.Headers.TryGetValue("Authorization", out var authHeader)) { var headerValue = authHeader.FirstOrDefault(); if (!string.IsNullOrWhiteSpace(headerValue) && headerValue.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) { token = headerValue["Bearer ".Length..].Trim(); } } if (string.IsNullOrWhiteSpace(token)) { _logger.LogWarning("[Auth] Session auth skipped (no token) | Corr={Corr}", corrId); return false; } try { _logger.LogWarning("[Auth] Session validation starting | Corr={Corr}", corrId); var rqst = JsonSerializer.Serialize(new { sessionToken = token }); var resp = await sql.ExecProcAsync("dbo.spClientSession", "validate", rqst, ct: context.RequestAborted); if (string.IsNullOrWhiteSpace(resp)) { _logger.LogWarning("[Auth] Session validation failed: empty response | Corr={Corr}", corrId); return false; } using var doc = JsonDocument.Parse(resp); var root = doc.RootElement; if (root.TryGetProperty("ok", out var okProp) && okProp.ValueKind == JsonValueKind.True) { var data = root.TryGetProperty("data", out var dataProp) ? dataProp : root; clientContext.SessionId = data.TryGetProperty("sessionId", out var sid) ? sid.GetString() : null; clientContext.ClientId = data.TryGetProperty("clientId", out var cid) ? cid.GetString() : null; clientContext.ClientName = data.TryGetProperty("clientName", out var cn) ? cn.GetString() : null; clientContext.ClientCategory = data.TryGetProperty("clientCategory", out var ccat) ? ccat.GetString() : null; clientContext.UserId = data.TryGetProperty("userId", out var uid) ? uid.GetString() : null; clientContext.Email = data.TryGetProperty("userEmail", out var ue) ? ue.GetString() : null; clientContext.Role = data.TryGetProperty("role", out var role) ? role.GetString() : null; clientContext.IsDevBypass = false; _logger.LogWarning("[Auth] Session validated OK | ClientId={ClientId} Email={Email} | Corr={Corr}", clientContext.ClientId, clientContext.Email, corrId); return clientContext.IsAuthenticated; } _logger.LogWarning("[Auth] Session validation failed: ok=false | Corr={Corr}", corrId); return false; } catch (Exception ex) { _logger.LogError(ex, "[Auth] Session validation error | Corr={Corr}", corrId); return false; } } /// /// Development bypass: Accept X-Dev-ClientId header. /// Only works when ASPNETCORE_ENVIRONMENT=Development or Auth:AllowDevBypass=true. /// private bool TryDevBypass(HttpContext context, ClientContext clientContext, string corrId) { var env = _config["ASPNETCORE_ENVIRONMENT"] ?? Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT"); var allowBypass = _config.GetValue("Auth:AllowDevBypass"); if (!string.Equals(env, "Development", StringComparison.OrdinalIgnoreCase) && !allowBypass) return false; if (!context.Request.Headers.TryGetValue("X-Dev-ClientId", out var devClientId)) return false; var clientId = devClientId.FirstOrDefault(); if (string.IsNullOrWhiteSpace(clientId)) return false; clientContext.ClientId = clientId; clientContext.IsDevBypass = true; if (context.Request.Headers.TryGetValue("X-Dev-TenantId", out var devTenantId)) clientContext.TenantId = devTenantId.FirstOrDefault(); if (context.Request.Headers.TryGetValue("X-Dev-ClientName", out var devName)) clientContext.ClientName = devName.FirstOrDefault(); _logger.LogWarning("[Auth] Dev bypass OK | ClientId={ClientId} | Corr={Corr}", clientId, corrId); return true; } /// /// JWT authentication: Validate Entra ID Bearer token. /// Used ONLY for /api/auth/session. /// private async Task TryJwtAuthAsync(HttpContext context, ClientContext clientContext, string corrId) { if (!context.Request.Headers.TryGetValue("Authorization", out var authHeader)) { _logger.LogWarning("[Auth] JWT skipped (no Authorization) | Corr={Corr}", corrId); return false; } var headerValue = authHeader.FirstOrDefault(); if (string.IsNullOrWhiteSpace(headerValue) || !headerValue.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) { _logger.LogWarning("[Auth] JWT skipped (Authorization not Bearer) | Corr={Corr}", corrId); return false; } var token = headerValue["Bearer ".Length..].Trim(); if (string.IsNullOrWhiteSpace(token)) { _logger.LogWarning("[Auth] JWT skipped (empty bearer token) | Corr={Corr}", corrId); return false; } var tenantId = _config["Auth:EntraId:TenantId"] ?? _config["ENTRA_TENANT_ID"]; var clientId = _config["Auth:EntraId:ClientId"] ?? _config["ENTRA_CLIENT_ID"]; var instance = _config["Auth:EntraId:Instance"] ?? "https://login.microsoftonline.com/"; var audienceOverride = _config["Auth:EntraId:Audience"]; // optional (e.g. api://xxx or App ID URI) if (string.IsNullOrWhiteSpace(tenantId) || string.IsNullOrWhiteSpace(clientId)) { _logger.LogWarning("[Auth] JWT disabled (missing TenantId/ClientId) | Corr={Corr}", corrId); return false; } try { // Diagnostics (safe enough for logs; do NOT log full token) var handler = new JwtSecurityTokenHandler(); if (handler.CanReadToken(token)) { var jwt = handler.ReadJwtToken(token); _logger.LogWarning("[Auth] JWT presented | iss={Iss} aud={Aud} sub={Sub} | Corr={Corr}", jwt.Issuer, jwt.Audiences.FirstOrDefault(), jwt.Subject, corrId); } var authority = $"{instance.TrimEnd('/')}/{tenantId}/v2.0"; var metadataAddress = $"{authority}/.well-known/openid-configuration"; var mgr = GetOrCreateConfigManager(metadataAddress); var openIdConfig = await mgr.GetConfigurationAsync(context.RequestAborted); var validAudiences = new List { clientId, $"api://{clientId}" }; if (!string.IsNullOrWhiteSpace(audienceOverride)) validAudiences.Add(audienceOverride); // fix applied var validationParams = new TokenValidationParameters { ValidateIssuer = true, ValidIssuers = new[] { $"https://login.microsoftonline.com/{tenantId}/v2.0", $"https://sts.windows.net/{tenantId}/" }, ValidateAudience = true, ValidAudiences = validAudiences, ValidateLifetime = true, IssuerSigningKeys = openIdConfig.SigningKeys, ClockSkew = TimeSpan.FromMinutes(5) }; var principal = handler.ValidateToken(token, validationParams, out _); clientContext.ClientId = principal.FindFirstValue("oid") ?? principal.FindFirstValue(ClaimTypes.NameIdentifier) ?? principal.FindFirstValue("sub"); clientContext.Email = principal.FindFirstValue("preferred_username") ?? principal.FindFirstValue(ClaimTypes.Email) ?? principal.FindFirstValue("upn"); clientContext.ClientName = principal.FindFirstValue("name") ?? principal.FindFirstValue(ClaimTypes.Name); clientContext.IsDevBypass = false; _logger.LogWarning("[Auth] JWT validated OK | oid={Oid} email={Email} | Corr={Corr}", clientContext.ClientId, clientContext.Email, corrId); return clientContext.IsAuthenticated; } catch (SecurityTokenException ex) { _logger.LogWarning("[Auth] JWT validation FAILED: {Msg} | Corr={Corr}", ex.Message, corrId); return false; } catch (Exception ex) { _logger.LogError(ex, "[Auth] JWT validation ERROR | Corr={Corr}", corrId); return false; } } private static ConfigurationManager GetOrCreateConfigManager(string metadataAddress) { lock (_oidcLock) { _oidcConfigManager ??= new ConfigurationManager( metadataAddress, new OpenIdConnectConfigurationRetriever()); return _oidcConfigManager; } } }