421 lines
17 KiB
C#
421 lines
17 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// Tight auth contract:
|
|
/// 1) POST /api/auth/session -> MUST be Entra JWT (Authorization: Bearer <entraJwt>)
|
|
/// 2) All other /api/* -> MUST be valid session token (X-Session-Token OR Authorization: Bearer <sessionToken>)
|
|
/// 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).
|
|
/// </summary>
|
|
public sealed class ClientAuthMiddleware
|
|
{
|
|
private readonly RequestDelegate _next;
|
|
private readonly ILogger<ClientAuthMiddleware> _logger;
|
|
private readonly IConfiguration _config;
|
|
|
|
// Exact paths that do not require authentication
|
|
private static readonly HashSet<string> _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<OpenIdConnectConfiguration>? _oidcConfigManager;
|
|
private static readonly object _oidcLock = new();
|
|
|
|
public ClientAuthMiddleware(RequestDelegate next, ILogger<ClientAuthMiddleware> 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=<sessionToken> 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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Session token authentication: Validate against our session database.
|
|
/// Accepts X-Session-Token header OR Authorization: Bearer <sessionToken>.
|
|
/// </summary>
|
|
private async Task<bool> 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;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Development bypass: Accept X-Dev-ClientId header.
|
|
/// Only works when ASPNETCORE_ENVIRONMENT=Development or Auth:AllowDevBypass=true.
|
|
/// </summary>
|
|
private bool TryDevBypass(HttpContext context, ClientContext clientContext, string corrId)
|
|
{
|
|
var env = _config["ASPNETCORE_ENVIRONMENT"] ?? Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT");
|
|
var allowBypass = _config.GetValue<bool>("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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// JWT authentication: Validate Entra ID Bearer token.
|
|
/// Used ONLY for /api/auth/session.
|
|
/// </summary>
|
|
private async Task<bool> 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<string> { 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<OpenIdConnectConfiguration> GetOrCreateConfigManager(string metadataAddress)
|
|
{
|
|
lock (_oidcLock)
|
|
{
|
|
_oidcConfigManager ??= new ConfigurationManager<OpenIdConnectConfiguration>(
|
|
metadataAddress,
|
|
new OpenIdConnectConfigurationRetriever());
|
|
|
|
return _oidcConfigManager;
|
|
}
|
|
}
|
|
} |