Add project files.
This commit is contained in:
169
Gateway/Security/AccessLogMiddleware.cs
Normal file
169
Gateway/Security/AccessLogMiddleware.cs
Normal file
@@ -0,0 +1,169 @@
|
||||
using Gateway.Data;
|
||||
using System.Diagnostics;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Gateway.Security;
|
||||
|
||||
/// <summary>
|
||||
/// Logs all HTTP requests to tbAccessLog for security monitoring and debugging.
|
||||
/// Should be registered early in the pipeline (after routing, before auth).
|
||||
/// Logs asynchronously to avoid impacting response time.
|
||||
/// </summary>
|
||||
public sealed class AccessLogMiddleware
|
||||
{
|
||||
private readonly RequestDelegate _next;
|
||||
private readonly ILogger<AccessLogMiddleware> _logger;
|
||||
|
||||
// Paths to skip logging (health checks, static files, etc.)
|
||||
private static readonly HashSet<string> _skipPaths = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"/health",
|
||||
"/favicon.ico"
|
||||
};
|
||||
|
||||
public AccessLogMiddleware(RequestDelegate next, ILogger<AccessLogMiddleware> logger)
|
||||
{
|
||||
_next = next;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task InvokeAsync(HttpContext context, ClientContext clientContext, SqlService sql)
|
||||
{
|
||||
var path = context.Request.Path.Value ?? "/";
|
||||
|
||||
// Skip logging for noisy endpoints
|
||||
if (ShouldSkip(path))
|
||||
{
|
||||
await _next(context);
|
||||
return;
|
||||
}
|
||||
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
string? errorCode = null;
|
||||
string? errorMessage = null;
|
||||
|
||||
try
|
||||
{
|
||||
await _next(context);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errorCode = "unhandled-exception";
|
||||
errorMessage = ex.Message;
|
||||
throw; // Re-throw to let error handling middleware deal with it
|
||||
}
|
||||
finally
|
||||
{
|
||||
stopwatch.Stop();
|
||||
|
||||
// Capture error info from response headers if set by auth middleware
|
||||
if (context.Response.Headers.TryGetValue("X-Auth-Fail", out var authFail))
|
||||
{
|
||||
errorCode = authFail.FirstOrDefault();
|
||||
}
|
||||
|
||||
// Fire-and-forget logging (don't await)
|
||||
_ = LogAccessAsync(sql, context, clientContext, stopwatch.ElapsedMilliseconds, errorCode, errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool ShouldSkip(string path)
|
||||
{
|
||||
if (_skipPaths.Contains(path))
|
||||
return true;
|
||||
|
||||
// Skip swagger
|
||||
if (path.StartsWith("/swagger", StringComparison.OrdinalIgnoreCase))
|
||||
return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private async Task LogAccessAsync(
|
||||
SqlService sql,
|
||||
HttpContext context,
|
||||
ClientContext clientContext,
|
||||
long durationMs,
|
||||
string? errorCode,
|
||||
string? errorMessage)
|
||||
{
|
||||
try
|
||||
{
|
||||
var correlationId = context.Request.Headers["X-Correlation-Id"].FirstOrDefault()
|
||||
?? context.Response.Headers["X-Correlation-Id"].FirstOrDefault();
|
||||
|
||||
var authPath = context.Response.Headers["X-Auth-Path"].FirstOrDefault();
|
||||
|
||||
var rqst = JsonSerializer.Serialize(new
|
||||
{
|
||||
correlationId,
|
||||
method = context.Request.Method,
|
||||
path = context.Request.Path.Value,
|
||||
queryString = context.Request.QueryString.HasValue
|
||||
? SanitizeQueryString(context.Request.QueryString.Value)
|
||||
: null,
|
||||
authPath,
|
||||
userId = clientContext.UserId,
|
||||
clientId = clientContext.ClientId,
|
||||
sessionId = clientContext.SessionId,
|
||||
statusCode = context.Response.StatusCode,
|
||||
durationMs,
|
||||
ipAddress = GetClientIp(context),
|
||||
userAgent = context.Request.Headers.UserAgent.FirstOrDefault(),
|
||||
errorCode,
|
||||
errorMessage
|
||||
});
|
||||
|
||||
await sql.ExecProcAsync("dbo.spAccessLog", "log", rqst);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Don't let logging failures affect the response
|
||||
_logger.LogError(ex, "Failed to write access log");
|
||||
}
|
||||
}
|
||||
|
||||
private static string? GetClientIp(HttpContext context)
|
||||
{
|
||||
// Check X-Forwarded-For first (for requests behind load balancer/proxy)
|
||||
var forwarded = context.Request.Headers["X-Forwarded-For"].FirstOrDefault();
|
||||
if (!string.IsNullOrWhiteSpace(forwarded))
|
||||
{
|
||||
return forwarded.Split(',')[0].Trim();
|
||||
}
|
||||
|
||||
return context.Connection.RemoteIpAddress?.ToString();
|
||||
}
|
||||
|
||||
private static string? SanitizeQueryString(string? queryString)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(queryString))
|
||||
return null;
|
||||
|
||||
// Remove sensitive params (add more as needed)
|
||||
var sensitiveParams = new[] { "token", "key", "secret", "password", "apikey" };
|
||||
|
||||
foreach (var param in sensitiveParams)
|
||||
{
|
||||
// Simple regex-free approach: just note that sensitive data may be present
|
||||
if (queryString.Contains(param, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return "[REDACTED]";
|
||||
}
|
||||
}
|
||||
|
||||
// Truncate if too long
|
||||
return queryString.Length > 1000 ? queryString[..1000] : queryString;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extension method for cleaner registration in Program.cs
|
||||
/// </summary>
|
||||
public static class AccessLogMiddlewareExtensions
|
||||
{
|
||||
public static IApplicationBuilder UseAccessLogging(this IApplicationBuilder builder)
|
||||
{
|
||||
return builder.UseMiddleware<AccessLogMiddleware>();
|
||||
}
|
||||
}
|
||||
415
Gateway/Security/ClientAuthMiddleware.cs
Normal file
415
Gateway/Security/ClientAuthMiddleware.cs
Normal file
@@ -0,0 +1,415 @@
|
||||
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.spSession", "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.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);
|
||||
|
||||
var validationParams = new TokenValidationParameters
|
||||
{
|
||||
ValidateIssuer = true,
|
||||
ValidIssuers = new[] { $"{instance.TrimEnd('/')}/{tenantId}/v2.0" },
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
60
Gateway/Security/ClientContext.cs
Normal file
60
Gateway/Security/ClientContext.cs
Normal file
@@ -0,0 +1,60 @@
|
||||
namespace Gateway.Security;
|
||||
|
||||
/// <summary>
|
||||
/// Holds authenticated client information for the current request.
|
||||
/// Populated by ClientAuthMiddleware.
|
||||
/// </summary>
|
||||
public sealed class ClientContext
|
||||
{
|
||||
/// <summary>
|
||||
/// Session ID from session-based auth.
|
||||
/// </summary>
|
||||
public string? SessionId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The authenticated client ID (from session, JWT sub claim, or dev header).
|
||||
/// This identifies the client/organization in our platform.
|
||||
/// </summary>
|
||||
public string? ClientId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional tenant ID for the ad platform (e.g., Google Ads customer ID).
|
||||
/// May be derived from ClientId mapping or passed in request.
|
||||
/// </summary>
|
||||
public string? TenantId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Display name from token or session (if available).
|
||||
/// </summary>
|
||||
public string? ClientName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// User ID from session (if using session auth).
|
||||
/// </summary>
|
||||
public string? UserId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Email from token or session (if available).
|
||||
/// </summary>
|
||||
public string? Email { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// User role from session (admin, user, readonly).
|
||||
/// </summary>
|
||||
public string? Role { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this request was authenticated via dev bypass (vs real auth).
|
||||
/// </summary>
|
||||
public bool IsDevBypass { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The authentication provider used (microsoft, google, etc.)
|
||||
/// </summary>
|
||||
public string? AuthProvider { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// True if we have a valid ClientId.
|
||||
/// </summary>
|
||||
public bool IsAuthenticated => !string.IsNullOrWhiteSpace(ClientId);
|
||||
}
|
||||
512
Gateway/Security/MultiProviderAuthMiddleware.cs
Normal file
512
Gateway/Security/MultiProviderAuthMiddleware.cs
Normal file
@@ -0,0 +1,512 @@
|
||||
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>
|
||||
/// Multi-provider authentication middleware.
|
||||
/// Supports: Microsoft Entra ID, Google, and extensible for others.
|
||||
///
|
||||
/// For /api/auth/session: Validates JWT from any configured provider
|
||||
/// For all other /api/*: Validates session token
|
||||
/// </summary>
|
||||
public sealed class MultiProviderAuthMiddleware
|
||||
{
|
||||
private readonly RequestDelegate _next;
|
||||
private readonly ILogger<MultiProviderAuthMiddleware> _logger;
|
||||
private readonly IConfiguration _config;
|
||||
|
||||
// Paths that don't require auth
|
||||
private static readonly HashSet<string> _anonymousExact = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"/",
|
||||
"/health"
|
||||
};
|
||||
|
||||
private static readonly string[] _anonymousPrefixes = { "/swagger", "/api/test" };
|
||||
|
||||
// OIDC config managers (cached per provider)
|
||||
private static readonly Dictionary<string, ConfigurationManager<OpenIdConnectConfiguration>> _oidcManagers = new();
|
||||
private static readonly object _oidcLock = new();
|
||||
|
||||
public MultiProviderAuthMiddleware(RequestDelegate next, ILogger<MultiProviderAuthMiddleware> 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);
|
||||
|
||||
_logger.LogWarning("[Auth] HIT {Method} {Path} | Corr={Corr}", context.Request.Method, pathRaw, corrId);
|
||||
|
||||
// Anonymous paths
|
||||
if (IsAnonymousPath(path))
|
||||
{
|
||||
SetAuthHeaders(context, corrId, "anonymous", null);
|
||||
await _next(context);
|
||||
return;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// SESSION EXCHANGE: Accept JWT from any configured provider
|
||||
// ---------------------------------------------------------------------
|
||||
if (path.StartsWith("/api/auth/session", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var (jwtValid, provider) = await TryMultiProviderJwtAsync(context, clientContext, corrId);
|
||||
|
||||
if (jwtValid)
|
||||
{
|
||||
SetAuthHeaders(context, corrId, $"jwt({provider})", null);
|
||||
_logger.LogWarning("[Auth] Session exchange authorized via {Provider} JWT | Email={Email} | Corr={Corr}",
|
||||
provider, clientContext.Email, corrId);
|
||||
await _next(context);
|
||||
return;
|
||||
}
|
||||
|
||||
SetAuthHeaders(context, corrId, "jwt", "jwt-required");
|
||||
_logger.LogWarning("[Auth] Session exchange denied: valid JWT required | Corr={Corr}", corrId);
|
||||
|
||||
context.Response.StatusCode = 401;
|
||||
await context.Response.WriteAsJsonAsync(new
|
||||
{
|
||||
ok = false,
|
||||
error = "Valid authentication required from a supported provider",
|
||||
correlationId = corrId
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// ALL OTHER /api/* PATHS: Require session token (or dev bypass)
|
||||
// ---------------------------------------------------------------------
|
||||
if (TryDevBypass(context, clientContext, corrId))
|
||||
{
|
||||
SetAuthHeaders(context, corrId, "dev-bypass", null);
|
||||
await _next(context);
|
||||
return;
|
||||
}
|
||||
|
||||
if (await TrySessionAuthAsync(context, clientContext, sql, corrId))
|
||||
{
|
||||
SetAuthHeaders(context, corrId, "session", null);
|
||||
await _next(context);
|
||||
return;
|
||||
}
|
||||
|
||||
SetAuthHeaders(context, corrId, "session", "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
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Try to validate JWT from multiple providers.
|
||||
/// Returns (success, providerName).
|
||||
/// </summary>
|
||||
private async Task<(bool Success, string? Provider)> TryMultiProviderJwtAsync(
|
||||
HttpContext context,
|
||||
ClientContext clientContext,
|
||||
string corrId)
|
||||
{
|
||||
if (!context.Request.Headers.TryGetValue("Authorization", out var authHeader))
|
||||
{
|
||||
_logger.LogWarning("[Auth] No Authorization header | Corr={Corr}", corrId);
|
||||
return (false, null);
|
||||
}
|
||||
|
||||
var headerValue = authHeader.FirstOrDefault();
|
||||
if (string.IsNullOrWhiteSpace(headerValue) || !headerValue.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_logger.LogWarning("[Auth] Invalid Authorization header format | Corr={Corr}", corrId);
|
||||
return (false, null);
|
||||
}
|
||||
|
||||
var token = headerValue["Bearer ".Length..].Trim();
|
||||
if (string.IsNullOrWhiteSpace(token))
|
||||
{
|
||||
_logger.LogWarning("[Auth] Empty bearer token | Corr={Corr}", corrId);
|
||||
return (false, null);
|
||||
}
|
||||
|
||||
// Check for provider hint from frontend
|
||||
var providerHint = context.Request.Headers["X-Auth-Provider"].FirstOrDefault()?.ToLowerInvariant();
|
||||
|
||||
// Read token to get issuer (for auto-detection)
|
||||
var handler = new JwtSecurityTokenHandler();
|
||||
JwtSecurityToken? jwt = null;
|
||||
|
||||
if (handler.CanReadToken(token))
|
||||
{
|
||||
jwt = handler.ReadJwtToken(token);
|
||||
_logger.LogWarning("[Auth] JWT presented | iss={Iss} aud={Aud} | Corr={Corr}",
|
||||
jwt.Issuer, jwt.Audiences.FirstOrDefault(), corrId);
|
||||
}
|
||||
|
||||
// Try providers in order (hint first, then auto-detect)
|
||||
var providersToTry = new List<string>();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(providerHint))
|
||||
providersToTry.Add(providerHint);
|
||||
|
||||
// Auto-detect based on issuer
|
||||
if (jwt != null)
|
||||
{
|
||||
if (jwt.Issuer.Contains("login.microsoftonline.com") || jwt.Issuer.Contains("sts.windows.net") || jwt.Issuer.Contains("ciamlogin.com"))
|
||||
providersToTry.Add("microsoft");
|
||||
else if (jwt.Issuer.Contains("accounts.google.com"))
|
||||
providersToTry.Add("google");
|
||||
}
|
||||
|
||||
// Fallback: try all configured providers
|
||||
if (IsProviderConfigured("microsoft") && !providersToTry.Contains("microsoft"))
|
||||
providersToTry.Add("microsoft");
|
||||
if (IsProviderConfigured("google") && !providersToTry.Contains("google"))
|
||||
providersToTry.Add("google");
|
||||
|
||||
foreach (var provider in providersToTry.Distinct())
|
||||
{
|
||||
var success = provider switch
|
||||
{
|
||||
"microsoft" => await TryValidateMicrosoftJwtAsync(token, clientContext, corrId),
|
||||
"google" => await TryValidateGoogleJwtAsync(token, clientContext, corrId),
|
||||
_ => false
|
||||
};
|
||||
|
||||
if (success)
|
||||
{
|
||||
clientContext.AuthProvider = provider;
|
||||
return (true, provider);
|
||||
}
|
||||
}
|
||||
|
||||
return (false, null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validate Microsoft Entra ID JWT
|
||||
/// </summary>
|
||||
private async Task<bool> TryValidateMicrosoftJwtAsync(string token, ClientContext clientContext, string corrId)
|
||||
{
|
||||
var tenantId = _config["Auth:Microsoft:TenantId"] ?? _config["Auth:EntraId:TenantId"] ?? _config["ENTRA_TENANT_ID"];
|
||||
var clientId = _config["Auth:Microsoft:ClientId"] ?? _config["Auth:EntraId:ClientId"] ?? _config["ENTRA_CLIENT_ID"];
|
||||
var ciamDomain = _config["Auth:Microsoft:CiamDomain"] ?? _config["Auth:EntraId:CiamDomain"];
|
||||
|
||||
if (string.IsNullOrWhiteSpace(tenantId) || string.IsNullOrWhiteSpace(clientId))
|
||||
{
|
||||
_logger.LogWarning("[Auth] Microsoft provider not configured | Corr={Corr}", corrId);
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Peek at the token issuer to determine if this is a CIAM token
|
||||
var handler = new JwtSecurityTokenHandler();
|
||||
var jwt = handler.ReadJwtToken(token);
|
||||
var isCiam = jwt.Issuer.Contains("ciamlogin.com", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
// Build authority + valid issuers based on token type
|
||||
string authority;
|
||||
string metadataAddress;
|
||||
string[] validIssuers;
|
||||
|
||||
if (isCiam)
|
||||
{
|
||||
// CIAM (External ID): derive domain from issuer or config
|
||||
var domain = ciamDomain;
|
||||
if (string.IsNullOrWhiteSpace(domain))
|
||||
{
|
||||
// Extract domain from issuer, e.g. "https://USIMClients.ciamlogin.com/{tenant}/v2.0"
|
||||
var issuerUri = new Uri(jwt.Issuer);
|
||||
domain = issuerUri.Host;
|
||||
}
|
||||
|
||||
authority = $"https://{domain}/{tenantId}/v2.0";
|
||||
metadataAddress = $"{authority}/.well-known/openid-configuration";
|
||||
validIssuers = new[]
|
||||
{
|
||||
$"https://{domain}/{tenantId}/v2.0",
|
||||
$"https://{domain}/{tenantId}"
|
||||
};
|
||||
|
||||
_logger.LogWarning("[Auth] CIAM token detected | domain={Domain} | Corr={Corr}", domain, corrId);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Standard Entra ID
|
||||
authority = $"https://login.microsoftonline.com/{tenantId}/v2.0";
|
||||
metadataAddress = $"{authority}/.well-known/openid-configuration";
|
||||
validIssuers = new[]
|
||||
{
|
||||
$"https://login.microsoftonline.com/{tenantId}/v2.0",
|
||||
$"https://sts.windows.net/{tenantId}/"
|
||||
};
|
||||
}
|
||||
|
||||
var mgr = GetOrCreateOidcManager(isCiam ? "microsoft-ciam" : "microsoft", metadataAddress);
|
||||
var openIdConfig = await mgr.GetConfigurationAsync(CancellationToken.None);
|
||||
|
||||
var validationParams = new TokenValidationParameters
|
||||
{
|
||||
ValidateIssuer = true,
|
||||
ValidIssuers = validIssuers,
|
||||
ValidateAudience = true,
|
||||
ValidAudiences = new[] { clientId, $"api://{clientId}" },
|
||||
ValidateLifetime = true,
|
||||
IssuerSigningKeys = openIdConfig.SigningKeys,
|
||||
ClockSkew = TimeSpan.FromMinutes(5)
|
||||
};
|
||||
|
||||
var tokenHandler = new JwtSecurityTokenHandler();
|
||||
var principal = tokenHandler.ValidateToken(token, validationParams, out _);
|
||||
|
||||
ExtractClaims(principal, clientContext);
|
||||
|
||||
_logger.LogWarning("[Auth] Microsoft JWT validated ({Mode}) | sub={Sub} email={Email} | Corr={Corr}",
|
||||
isCiam ? "CIAM" : "Entra", clientContext.ClientId, clientContext.Email, corrId);
|
||||
|
||||
return clientContext.IsAuthenticated;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning("[Auth] Microsoft JWT validation failed: {Msg} | Corr={Corr}", ex.Message, corrId);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validate Google ID token
|
||||
/// </summary>
|
||||
private async Task<bool> TryValidateGoogleJwtAsync(string token, ClientContext clientContext, string corrId)
|
||||
{
|
||||
var clientId = _config["Auth:Google:ClientId"] ?? _config["GOOGLE_CLIENT_ID"];
|
||||
|
||||
if (string.IsNullOrWhiteSpace(clientId))
|
||||
{
|
||||
_logger.LogWarning("[Auth] Google provider not configured | Corr={Corr}", corrId);
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var metadataAddress = "https://accounts.google.com/.well-known/openid-configuration";
|
||||
|
||||
var mgr = GetOrCreateOidcManager("google", metadataAddress);
|
||||
var openIdConfig = await mgr.GetConfigurationAsync(CancellationToken.None);
|
||||
|
||||
var validationParams = new TokenValidationParameters
|
||||
{
|
||||
ValidateIssuer = true,
|
||||
ValidIssuers = new[] { "https://accounts.google.com", "accounts.google.com" },
|
||||
ValidateAudience = true,
|
||||
ValidAudiences = new[] { clientId },
|
||||
ValidateLifetime = true,
|
||||
IssuerSigningKeys = openIdConfig.SigningKeys,
|
||||
ClockSkew = TimeSpan.FromMinutes(5)
|
||||
};
|
||||
|
||||
var handler = new JwtSecurityTokenHandler();
|
||||
var principal = handler.ValidateToken(token, validationParams, out _);
|
||||
|
||||
ExtractClaims(principal, clientContext);
|
||||
|
||||
_logger.LogWarning("[Auth] Google JWT validated | sub={Sub} email={Email} | Corr={Corr}",
|
||||
clientContext.ClientId, clientContext.Email, corrId);
|
||||
|
||||
return clientContext.IsAuthenticated;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning("[Auth] Google JWT validation failed: {Msg} | Corr={Corr}", ex.Message, corrId);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extract standard claims into ClientContext
|
||||
/// </summary>
|
||||
private static void ExtractClaims(ClaimsPrincipal principal, ClientContext clientContext)
|
||||
{
|
||||
clientContext.ClientId =
|
||||
principal.FindFirstValue("oid") ?? // Microsoft object ID
|
||||
principal.FindFirstValue("sub") ?? // Standard subject
|
||||
principal.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
|
||||
clientContext.Email =
|
||||
principal.FindFirstValue("email") ??
|
||||
principal.FindFirstValue("preferred_username") ??
|
||||
principal.FindFirstValue(ClaimTypes.Email);
|
||||
|
||||
clientContext.ClientName =
|
||||
principal.FindFirstValue("name") ??
|
||||
principal.FindFirstValue(ClaimTypes.Name);
|
||||
|
||||
clientContext.IsDevBypass = false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Session token validation (unchanged from original)
|
||||
/// </summary>
|
||||
private async Task<bool> TrySessionAuthAsync(HttpContext context, ClientContext clientContext, SqlService sql, string corrId)
|
||||
{
|
||||
string? token = null;
|
||||
|
||||
// Check X-Session-Token header first
|
||||
if (context.Request.Headers.TryGetValue("X-Session-Token", out var sessionHeader))
|
||||
token = sessionHeader.FirstOrDefault();
|
||||
|
||||
// Fall back to Authorization: Bearer (session token, not JWT)
|
||||
if (string.IsNullOrWhiteSpace(token) && context.Request.Headers.TryGetValue("Authorization", out var authHeader))
|
||||
{
|
||||
var auth = authHeader.FirstOrDefault();
|
||||
if (!string.IsNullOrWhiteSpace(auth) && auth.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
|
||||
token = auth["Bearer ".Length..].Trim();
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(token))
|
||||
{
|
||||
_logger.LogWarning("[Auth] No session token provided | Corr={Corr}", corrId);
|
||||
return false;
|
||||
}
|
||||
|
||||
_logger.LogWarning("[Auth] Session validation starting | Corr={Corr}", corrId);
|
||||
|
||||
try
|
||||
{
|
||||
var rqst = JsonSerializer.Serialize(new { sessionToken = token });
|
||||
var resp = await sql.ExecProcAsync("dbo.spSession", "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.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
|
||||
/// </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();
|
||||
|
||||
_logger.LogWarning("[Auth] Dev bypass OK | ClientId={ClientId} | Corr={Corr}", clientId, corrId);
|
||||
return true;
|
||||
}
|
||||
|
||||
private bool IsProviderConfigured(string provider)
|
||||
{
|
||||
return provider switch
|
||||
{
|
||||
"microsoft" => !string.IsNullOrWhiteSpace(
|
||||
_config["Auth:Microsoft:ClientId"] ?? _config["Auth:EntraId:ClientId"] ?? _config["ENTRA_CLIENT_ID"]),
|
||||
"google" => !string.IsNullOrWhiteSpace(
|
||||
_config["Auth:Google:ClientId"] ?? _config["GOOGLE_CLIENT_ID"]),
|
||||
_ => false
|
||||
};
|
||||
}
|
||||
|
||||
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 SetAuthHeaders(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;
|
||||
}
|
||||
|
||||
private static ConfigurationManager<OpenIdConnectConfiguration> GetOrCreateOidcManager(string provider, string metadataAddress)
|
||||
{
|
||||
lock (_oidcLock)
|
||||
{
|
||||
if (!_oidcManagers.TryGetValue(provider, out var mgr))
|
||||
{
|
||||
mgr = new ConfigurationManager<OpenIdConnectConfiguration>(
|
||||
metadataAddress,
|
||||
new OpenIdConnectConfigurationRetriever());
|
||||
_oidcManagers[provider] = mgr;
|
||||
}
|
||||
return mgr;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user