using System.IdentityModel.Tokens.Jwt; using System.Security.Claims; using System.Security.Cryptography; using Distribution.Features.User; using Distribution.Infra.Authorization; using Microsoft.AspNetCore.Mvc.Filters; using Microsoft.IdentityModel.Tokens; namespace Distribution.Infra.Authorization.Middleware; /// /// Ported from OSMCore's System/Authentication/JwtMiddleware.cs (action-filter attribute pattern, /// applied per controller/action as [JwtMiddleware.CustomMiddleware]). Not applied anywhere in /// this project — the standard AddJwtBearer handler (Program.cs) is this project's actual /// validation path. /// /// docs/authentication_flow.md explicitly calls out not running this alongside AuthMiddleware or /// AddJwtBearer at the same time — all three independently re-validate the same RSA-signed token, /// which is wasteful and error-prone (this is the same duplication OSMCore's own guide flags as a /// Gotcha). Kept here, unregistered, only as a ported reference implementation. /// /// Same two adaptations as AuthMiddleware: portable RSA parsing via /// , and local User/Role → role_id/role_level claim mapping after /// validation (see AuthMiddleware's remarks for why this is required, not optional). /// public sealed class JwtMiddleware { [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)] public sealed class CustomMiddlewareAttribute : Attribute, IAsyncActionFilter { public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) { var configuration = context.HttpContext.RequestServices.GetService(); if (!TryGetToken(context.HttpContext, out var token)) { await RejectAsync(context.HttpContext, "Authentication token is missing."); return; } try { if (configuration is null) { throw new InvalidOperationException("Jwt configuration is missing."); } var principal = ValidateToken(token, configuration); var userRepo = context.HttpContext.RequestServices.GetRequiredService(); if (!await TryAttachLocalRoleClaimsAsync(principal, userRepo)) { await RejectAsync(context.HttpContext, "User not found or inactive."); return; } context.HttpContext.User = principal; PopulateContextItems(context.HttpContext, principal); await next(); } catch (Exception ex) when (ex is SecurityTokenException or ArgumentException or CryptographicException or InvalidOperationException) { await RejectAsync(context.HttpContext, "Invalid or expired token."); } } private static ClaimsPrincipal ValidateToken(string token, IConfiguration configuration) { var xmlKey = configuration["Jwt:PublicKey"]; if (string.IsNullOrWhiteSpace(xmlKey)) throw new SecurityTokenException("JWT public key not configured."); using var rsa = RsaXmlKeyReader.ReadPublicKey(xmlKey); var rsaParameters = rsa.ExportParameters(false); var key = new RsaSecurityKey(rsaParameters) { KeyId = ComputeKeyId(rsaParameters) }; var handler = new JwtSecurityTokenHandler(); var parameters = new TokenValidationParameters { ValidateIssuer = true, ValidIssuer = configuration["Jwt:Issuer"], ValidateAudience = true, ValidAudience = configuration["Jwt:Audience"], ValidateIssuerSigningKey = true, IssuerSigningKey = key, 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); } 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 }); } private static string ComputeKeyId(RSAParameters publicParameters) { var modulus = publicParameters.Modulus ?? []; var exponent = publicParameters.Exponent ?? []; var payload = $"{Convert.ToBase64String(modulus)}.{Convert.ToBase64String(exponent)}"; var hash = SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(payload)); return Base64UrlEncoder.Encode(hash); } } }