using System.IdentityModel.Tokens.Jwt; using System.Security.Claims; using System.Security.Cryptography; using Distribution.Features.User; using Distribution.Infra.Authorization; using Microsoft.IdentityModel.Tokens; namespace Distribution.Infra.Authorization.Middleware; /// /// Ported from OSMCore's System/Authentication/AuthMiddleware.cs (global path-skip-based /// middleware). NOT registered in Program.cs โ€” this project's actual token validation is the /// standard AddJwtBearer handler (see Program.cs), same as OSMCore's own recommendation. Kept here /// unregistered, exactly like OSMCore's own copy, for reference/reuse if a project needs a /// middleware-based (rather than AddJwtBearer-based) validation entry point. /// /// Two adaptations from the OSMCore original, both required for this to actually work against /// this project's authorization system rather than just compiling: /// 1. RSA key parsing goes through instead of RSA.FromXmlString, /// which is Windows-only in modern .NET (see docs/authentication_flow.md ยง2.2 Gotcha). /// 2. After validating the token, the user id is mapped onto a local User/Role row and /// role_id/role_level/username claims are attached โ€” the same step Program.cs's /// OnTokenValidated performs for the AddJwtBearer path. Without this, a request authenticated /// through this middleware would have no role_id claim, and every /// [Authorize(Policy = "resource.action")] check (PermissionAuthorizationHandler) would fail. /// public sealed class AuthMiddleware(RequestDelegate next, IConfiguration configuration, ILogger logger) { public async Task InvokeAsync(HttpContext context) { if (ShouldSkipAuthentication(context.Request.Path)) { await next(context); return; } if (!TryGetToken(context, out var token)) { await RejectAsync(context, "Authentication token is missing."); return; } try { var principal = ValidateToken(token); var userRepo = context.RequestServices.GetRequiredService(); if (!await TryAttachLocalRoleClaimsAsync(principal, userRepo)) { await RejectAsync(context, "User not found or inactive."); return; } context.User = principal; PopulateContextItems(context, principal); await next(context); } catch (Exception ex) when (ex is SecurityTokenException or ArgumentException or CryptographicException) { logger.LogInformation(ex, "JWT validation failed for request path {Path}", context.Request.Path); await RejectAsync(context, "Invalid or expired token."); } } private ClaimsPrincipal ValidateToken(string token) { var jwtOptions = configuration.GetSection("Jwt").Get() ?? throw new InvalidOperationException("Jwt configuration is missing."); if (string.IsNullOrWhiteSpace(jwtOptions.PublicKey)) { throw new InvalidOperationException("Jwt:PublicKey configuration is missing."); } using var rsa = RsaXmlKeyReader.ReadPublicKey(jwtOptions.PublicKey); var handler = new JwtSecurityTokenHandler(); var parameters = new TokenValidationParameters { ValidateIssuer = true, ValidIssuer = jwtOptions.Issuer, ValidateAudience = true, ValidAudience = jwtOptions.Audience, ValidateIssuerSigningKey = true, IssuerSigningKey = new RsaSecurityKey(rsa), ValidateLifetime = true, ClockSkew = TimeSpan.FromMinutes(1) }; return handler.ValidateToken(token, parameters, out _); } /// Mirrors Program.cs's OnTokenValidated mapping โ€” see class remarks. private static async Task TryAttachLocalRoleClaimsAsync(ClaimsPrincipal principal, IUserRepository userRepo) { var subClaim = principal.FindFirst("sub")?.Value ?? principal.FindFirst(ClaimTypes.NameIdentifier)?.Value; if (!Guid.TryParse(subClaim, out var userId)) return false; var user = await userRepo.GetByIdAsync(userId); if (user is null || !user.IsActive) return false; var identity = (ClaimsIdentity)principal.Identity!; identity.AddClaim(new Claim("username", user.Username)); identity.AddClaim(new Claim("role_id", user.FK_RoleId.ToString())); identity.AddClaim(new Claim("role_level", (user.Role?.Level ?? 0).ToString())); return true; } private static bool TryGetToken(HttpContext context, out string token) { token = string.Empty; if (TryGetBearerToken(context.Request.Headers.Authorization, out token)) { return true; } var headerToken = context.Request.Headers["MobileAuthToken"].FirstOrDefault(); if (TryGetBearerToken(headerToken, out token)) { return true; } var cookieToken = context.Request.Cookies["access_token"]; if (!string.IsNullOrWhiteSpace(cookieToken)) { token = cookieToken.Trim(); return true; } return false; } private static bool TryGetBearerToken(string? authorizationHeader, out string token) { token = string.Empty; if (string.IsNullOrWhiteSpace(authorizationHeader)) { return false; } const string bearerPrefix = "Bearer "; if (!authorizationHeader.StartsWith(bearerPrefix, StringComparison.OrdinalIgnoreCase)) { token = authorizationHeader.Trim(); return !string.IsNullOrWhiteSpace(token); } token = authorizationHeader[bearerPrefix.Length..].Trim(); return !string.IsNullOrWhiteSpace(token); } /// /// Adapted from OSMCore's path list: this project has no Shopify-style webhook route, so that /// skip is dropped; /api/auth is narrowed to just login/register (this project's /api/auth/me /// and /api/auth/logout are meant to require auth, unlike a blanket "/api/auth" skip). /// private static bool ShouldSkipAuthentication(PathString path) => path.Equals("/health", StringComparison.OrdinalIgnoreCase) || path.Equals("/api/auth/login", StringComparison.OrdinalIgnoreCase) || path.Equals("/api/auth/register", StringComparison.OrdinalIgnoreCase) || path.StartsWithSegments("/swagger", StringComparison.OrdinalIgnoreCase); private static void PopulateContextItems(HttpContext context, ClaimsPrincipal principal) { context.Items["UserId"] = principal.FindFirst("sub")?.Value ?? principal.FindFirst(ClaimTypes.NameIdentifier)?.Value; context.Items["Username"] = principal.FindFirst("username")?.Value; context.Items["RoleId"] = principal.FindFirst("role_id")?.Value; context.Items["RoleLevel"] = principal.FindFirst("role_level")?.Value; } private static async Task RejectAsync(HttpContext context, string message) { context.Response.StatusCode = StatusCodes.Status401Unauthorized; await context.Response.WriteAsJsonAsync(new ApiResponse { StatusCode = StatusCodes.Status401Unauthorized, Success = false, Message = message, Data = null }); } }