From 97a1db13436d91ae78e592178992c88e72e7caa9 Mon Sep 17 00:00:00 2001 From: DeepnaPooja Date: Tue, 25 Aug 2026 16:46:05 +0530 Subject: [PATCH 1/2] Refactor auth to AuthHex --- Distribution/Features/Auth/AuthController.cs | 30 ++- Distribution/Features/Auth/AuthDTOs.cs | 2 +- Distribution/Features/Auth/AuthService.cs | 30 +-- .../Authorization/AuthHex/AuthHexClient.cs | 21 ++ .../Authorization/AuthHex/AuthHexResponse.cs | 11 + .../Authorization/AuthHex/IAuthHexClient.cs | 6 + .../Authorization/Jwt/IJwtTokenService.cs | 6 - .../Authorization/Jwt/JwtTokenService.cs | 47 ----- .../Authorization/Jwt/RsaXmlKeyReader.cs | 32 +++ .../Authorization/Middleware/ApiResponse.cs | 10 + .../Middleware/AuthMiddleware.cs | 185 +++++++++++++++++ .../Authorization/Middleware/JwtMiddleware.cs | 191 ++++++++++++++++++ .../Authorization/Middleware/JwtOptions.cs | 13 ++ Distribution/Program.cs | 46 ++++- Distribution/appsettings.json | 12 +- 15 files changed, 561 insertions(+), 81 deletions(-) create mode 100644 Distribution/Infra/Authorization/AuthHex/AuthHexClient.cs create mode 100644 Distribution/Infra/Authorization/AuthHex/AuthHexResponse.cs create mode 100644 Distribution/Infra/Authorization/AuthHex/IAuthHexClient.cs delete mode 100644 Distribution/Infra/Authorization/Jwt/IJwtTokenService.cs delete mode 100644 Distribution/Infra/Authorization/Jwt/JwtTokenService.cs create mode 100644 Distribution/Infra/Authorization/Jwt/RsaXmlKeyReader.cs create mode 100644 Distribution/Infra/Authorization/Middleware/ApiResponse.cs create mode 100644 Distribution/Infra/Authorization/Middleware/AuthMiddleware.cs create mode 100644 Distribution/Infra/Authorization/Middleware/JwtMiddleware.cs create mode 100644 Distribution/Infra/Authorization/Middleware/JwtOptions.cs diff --git a/Distribution/Features/Auth/AuthController.cs b/Distribution/Features/Auth/AuthController.cs index 1209f46..385c9b7 100644 --- a/Distribution/Features/Auth/AuthController.cs +++ b/Distribution/Features/Auth/AuthController.cs @@ -1,15 +1,37 @@ +using Distribution.Infra.Authorization; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; namespace Distribution.Features.Auth; [ApiController] [Route("api/auth")] -public class AuthController(AuthService service) : ControllerBase +public class AuthController(AuthService service, ICurrentUserService currentUser) : ControllerBase { + private static IActionResult ToActionResult(AuthHexResponse result) => + result.Success ? new OkObjectResult(result) : new ObjectResult(result) { StatusCode = result.StatusCode is >= 400 and < 600 ? result.StatusCode : 401 }; + [HttpPost("login")] - public async Task Login(LoginRequest request) + public async Task Login(LoginRequest request, CancellationToken ct) => + ToActionResult(await service.LoginAsync(request, ct)); + + [HttpPost("register")] + public async Task Register(RegisterRequest request, CancellationToken ct) => + ToActionResult(await service.RegisterAsync(request, ct)); + + [HttpGet("me")] + [Authorize] + public async Task Me(CancellationToken ct) { - var result = await service.LoginAsync(request); - return result is null ? Unauthorized() : Ok(result); + var userId = currentUser.UserId ?? throw new UnauthorizedAccessException(); + return ToActionResult(await service.GetUserDetailsAsync(userId, ct)); + } + + [HttpPost("logout")] + [Authorize] + public async Task Logout(CancellationToken ct) + { + var token = Request.Headers.Authorization.ToString().Replace("Bearer ", "", StringComparison.OrdinalIgnoreCase); + return ToActionResult(await service.LogoutAsync(token, ct)); } } diff --git a/Distribution/Features/Auth/AuthDTOs.cs b/Distribution/Features/Auth/AuthDTOs.cs index bb7d71f..0aa80a6 100644 --- a/Distribution/Features/Auth/AuthDTOs.cs +++ b/Distribution/Features/Auth/AuthDTOs.cs @@ -2,4 +2,4 @@ namespace Distribution.Features.Auth; public record LoginRequest(string Username, string Password); -public record LoginResponse(string Token, Guid UserId, string Username, string RoleName); +public record RegisterRequest(string Username, string Password, string? FullName); diff --git a/Distribution/Features/Auth/AuthService.cs b/Distribution/Features/Auth/AuthService.cs index e9f4928..4986750 100644 --- a/Distribution/Features/Auth/AuthService.cs +++ b/Distribution/Features/Auth/AuthService.cs @@ -1,24 +1,24 @@ -using Distribution.Features.User; using Distribution.Infra.Authorization; -using Microsoft.AspNetCore.Identity; namespace Distribution.Features.Auth; -public class AuthService(IUserRepository userRepo, IJwtTokenService tokenService) +/// +/// Own-user auth is fully delegated to AuthHex — this service only proxies the four functions +/// AuthHex exposes (loginUser/registerUser/getUserDetails/LogoutUser). No local password check or +/// token issuance happens here; AuthHex owns credentials and signs the JWT this app validates +/// (see Program.cs's JwtBearer registration + OnTokenValidated role mapping). +/// +public class AuthService(IAuthHexClient authHex) { - private readonly PasswordHasher _hasher = new(); + public Task LoginAsync(LoginRequest request, CancellationToken ct = default) => + authHex.CallAsync("loginUser", request, reference: Guid.NewGuid().ToString(), ct: ct); - public async Task LoginAsync(LoginRequest request) - { - var user = await userRepo.GetByUsernameAsync(request.Username); - if (user is null || !user.IsActive) - return null; + public Task RegisterAsync(RegisterRequest request, CancellationToken ct = default) => + authHex.CallAsync("registerUser", request, reference: Guid.NewGuid().ToString(), ct: ct); - var result = _hasher.VerifyHashedPassword(user, user.PasswordHash, request.Password); - if (result == PasswordVerificationResult.Failed) - return null; + public Task GetUserDetailsAsync(Guid userId, CancellationToken ct = default) => + authHex.CallAsync("getUserDetails", new { userId }, ct: ct); - var token = tokenService.GenerateToken(user); - return new LoginResponse(token, user.Id, user.Username, user.Role?.Name ?? string.Empty); - } + public Task LogoutAsync(string token, CancellationToken ct = default) => + authHex.CallAsync("LogoutUser", new { token }, ct: ct); } diff --git a/Distribution/Infra/Authorization/AuthHex/AuthHexClient.cs b/Distribution/Infra/Authorization/AuthHex/AuthHexClient.cs new file mode 100644 index 0000000..659107f --- /dev/null +++ b/Distribution/Infra/Authorization/AuthHex/AuthHexClient.cs @@ -0,0 +1,21 @@ +using System.Net.Http.Json; + +namespace Distribution.Infra.Authorization; + +/// +/// Proxies own-user auth calls (login/register/getUserDetails/logout) to the external AuthHex +/// identity service. AuthHex owns credentials and issues the RS256-signed JWT this app validates +/// (see JwtTokenService's replacement — token generation is no longer done locally); this app +/// never sees or stores a plaintext/hashed password for the AuthHex-authenticated flow. +/// +public class AuthHexClient(HttpClient http) : IAuthHexClient +{ + public async Task CallAsync(string functionName, object? payload, string? reference = null, CancellationToken ct = default) + { + var body = new { functionName, payload, reference }; + var response = await http.PostAsJsonAsync("/api/user", body, ct); + + var result = await response.Content.ReadFromJsonAsync(cancellationToken: ct); + return result ?? new AuthHexResponse((int)response.StatusCode, false, "Empty response from AuthHex.", null); + } +} diff --git a/Distribution/Infra/Authorization/AuthHex/AuthHexResponse.cs b/Distribution/Infra/Authorization/AuthHex/AuthHexResponse.cs new file mode 100644 index 0000000..d7687eb --- /dev/null +++ b/Distribution/Infra/Authorization/AuthHex/AuthHexResponse.cs @@ -0,0 +1,11 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Distribution.Infra.Authorization; + +/// Raw response shape returned by AuthHex's `/api/user` endpoint for every functionName. +public record AuthHexResponse( + [property: JsonPropertyName("statusCode")] int StatusCode, + [property: JsonPropertyName("success")] bool Success, + [property: JsonPropertyName("message")] string? Message, + [property: JsonPropertyName("data")] JsonElement? Data); diff --git a/Distribution/Infra/Authorization/AuthHex/IAuthHexClient.cs b/Distribution/Infra/Authorization/AuthHex/IAuthHexClient.cs new file mode 100644 index 0000000..38f7536 --- /dev/null +++ b/Distribution/Infra/Authorization/AuthHex/IAuthHexClient.cs @@ -0,0 +1,6 @@ +namespace Distribution.Infra.Authorization; + +public interface IAuthHexClient +{ + Task CallAsync(string functionName, object? payload, string? reference = null, CancellationToken ct = default); +} diff --git a/Distribution/Infra/Authorization/Jwt/IJwtTokenService.cs b/Distribution/Infra/Authorization/Jwt/IJwtTokenService.cs deleted file mode 100644 index d2e0651..0000000 --- a/Distribution/Infra/Authorization/Jwt/IJwtTokenService.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace Distribution.Infra.Authorization; - -public interface IJwtTokenService -{ - string GenerateToken(Models.User user); -} diff --git a/Distribution/Infra/Authorization/Jwt/JwtTokenService.cs b/Distribution/Infra/Authorization/Jwt/JwtTokenService.cs deleted file mode 100644 index 9a0eb04..0000000 --- a/Distribution/Infra/Authorization/Jwt/JwtTokenService.cs +++ /dev/null @@ -1,47 +0,0 @@ -using System.IdentityModel.Tokens.Jwt; -using System.Security.Claims; -using System.Text; -using Microsoft.Extensions.Configuration; -using Microsoft.IdentityModel.Tokens; - -namespace Distribution.Infra.Authorization; - -public class JwtTokenService : IJwtTokenService -{ - private readonly IConfiguration _configuration; - - public JwtTokenService(IConfiguration configuration) - { - _configuration = configuration; - } - - public string GenerateToken(Models.User user) - { - var jwtSection = _configuration.GetSection("Jwt"); - var key = jwtSection["Key"] ?? throw new InvalidOperationException("Jwt:Key is not configured."); - var issuer = jwtSection["Issuer"]; - var audience = jwtSection["Audience"]; - var expiryMinutes = int.TryParse(jwtSection["ExpiryMinutes"], out var m) ? m : 120; - - var claims = new List - { - new("sub", user.Id.ToString()), - new("username", user.Username), - new("role", user.Role?.Name ?? string.Empty), - new("role_id", user.FK_RoleId.ToString()), - new("role_level", (user.Role?.Level ?? 0).ToString()) - }; - - var signingKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(key)); - var credentials = new SigningCredentials(signingKey, SecurityAlgorithms.HmacSha256); - - var token = new JwtSecurityToken( - issuer: issuer, - audience: audience, - claims: claims, - expires: DateTime.Now.AddMinutes(expiryMinutes), - signingCredentials: credentials); - - return new JwtSecurityTokenHandler().WriteToken(token); - } -} diff --git a/Distribution/Infra/Authorization/Jwt/RsaXmlKeyReader.cs b/Distribution/Infra/Authorization/Jwt/RsaXmlKeyReader.cs new file mode 100644 index 0000000..4825efb --- /dev/null +++ b/Distribution/Infra/Authorization/Jwt/RsaXmlKeyReader.cs @@ -0,0 +1,32 @@ +using System.Security.Cryptography; +using System.Xml.Linq; + +namespace Distribution.Infra.Authorization; + +/// +/// Parses an XML-format RSA public key (`<RSAKeyValue>...</RSAKeyValue>`, as issued by +/// AuthHex) into an instance. Written by hand instead of relying on +/// RSA.FromXmlString, which is Windows-only in .NET (throws PlatformNotSupportedException on +/// Linux/macOS) — this keeps token validation portable across hosts. +/// +public static class RsaXmlKeyReader +{ + public static RSA ReadPublicKey(string xml) + { + var root = XElement.Parse(xml); + var modulus = root.Element("Modulus")?.Value + ?? throw new InvalidOperationException("RSA XML key is missing ."); + var exponent = root.Element("Exponent")?.Value + ?? throw new InvalidOperationException("RSA XML key is missing ."); + + var parameters = new RSAParameters + { + Modulus = Convert.FromBase64String(modulus), + Exponent = Convert.FromBase64String(exponent) + }; + + var rsa = RSA.Create(); + rsa.ImportParameters(parameters); + return rsa; + } +} diff --git a/Distribution/Infra/Authorization/Middleware/ApiResponse.cs b/Distribution/Infra/Authorization/Middleware/ApiResponse.cs new file mode 100644 index 0000000..1a62d31 --- /dev/null +++ b/Distribution/Infra/Authorization/Middleware/ApiResponse.cs @@ -0,0 +1,10 @@ +namespace Distribution.Infra.Authorization.Middleware; + +/// Stand-in for OSMCore's OMSCore.Common.Models.IOs.ApiResponse<T>, which has no equivalent in this project. +public sealed record ApiResponse +{ + public int StatusCode { get; init; } + public bool Success { get; init; } + public string? Message { get; init; } + public T? Data { get; init; } +} diff --git a/Distribution/Infra/Authorization/Middleware/AuthMiddleware.cs b/Distribution/Infra/Authorization/Middleware/AuthMiddleware.cs new file mode 100644 index 0000000..0715e3f --- /dev/null +++ b/Distribution/Infra/Authorization/Middleware/AuthMiddleware.cs @@ -0,0 +1,185 @@ +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 + }); + } +} diff --git a/Distribution/Infra/Authorization/Middleware/JwtMiddleware.cs b/Distribution/Infra/Authorization/Middleware/JwtMiddleware.cs new file mode 100644 index 0000000..082e57e --- /dev/null +++ b/Distribution/Infra/Authorization/Middleware/JwtMiddleware.cs @@ -0,0 +1,191 @@ +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); + } + } +} diff --git a/Distribution/Infra/Authorization/Middleware/JwtOptions.cs b/Distribution/Infra/Authorization/Middleware/JwtOptions.cs new file mode 100644 index 0000000..fd26ee9 --- /dev/null +++ b/Distribution/Infra/Authorization/Middleware/JwtOptions.cs @@ -0,0 +1,13 @@ +namespace Distribution.Infra.Authorization.Middleware; + +/// +/// Ported from OSMCore's System/Authentication/JwtOptions.cs. Binds to the same "Jwt" config +/// section already used by Program.cs's AddJwtBearer setup (Jwt:PublicKey/Audience/Issuer) — no +/// new config keys needed. +/// +public sealed record JwtOptions +{ + public string PublicKey { get; init; } = string.Empty; + public string Audience { get; init; } = string.Empty; + public string Issuer { get; init; } = string.Empty; +} diff --git a/Distribution/Program.cs b/Distribution/Program.cs index a2f48a2..11e4ff6 100644 --- a/Distribution/Program.cs +++ b/Distribution/Program.cs @@ -1,4 +1,4 @@ -using System.Text; +using System.Security.Claims; using Distribution.Data; using Distribution.Features.Auth; using Distribution.Features.Brand; @@ -117,7 +117,11 @@ builder.Services.AddScoped(); builder.Services.AddHttpContextAccessor(); builder.Services.AddScoped(); -builder.Services.AddScoped(); +builder.Services.AddHttpClient(client => +{ + var baseUrl = builder.Configuration["AuthHex:BaseUrl"] ?? throw new InvalidOperationException("AuthHex:BaseUrl is not configured."); + client.BaseAddress = new Uri(baseUrl); +}); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -126,7 +130,8 @@ builder.Services.AddScoped(); var jwtSection = builder.Configuration.GetSection("Jwt"); -var jwtKey = jwtSection["Key"] ?? throw new InvalidOperationException("Jwt:Key is not configured."); +var jwtPublicKeyXml = jwtSection["PublicKey"] ?? throw new InvalidOperationException("Jwt:PublicKey is not configured."); +var jwtSigningKey = new RsaSecurityKey(RsaXmlKeyReader.ReadPublicKey(jwtPublicKeyXml)); builder.Services.AddAuthentication(options => { @@ -142,10 +147,43 @@ builder.Services.AddAuthentication(options => ValidateAudience = true, ValidAudience = jwtSection["Audience"], ValidateIssuerSigningKey = true, - IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey)), + IssuerSigningKey = jwtSigningKey, ValidateLifetime = true, ClockSkew = TimeSpan.FromMinutes(1) }; + + // AuthHex issues the token and owns its claim shape; this app's authorization (permission + // policies, role-level checks) runs against the *local* Role/Permission tables, so every + // validated token is mapped back to a local User/Role once here, not duplicated per + // endpoint. A token for a user with no matching local User record is rejected outright — + // login via AuthHex does not implicitly provision a local user. + options.Events = new JwtBearerEvents + { + OnTokenValidated = async context => + { + var subClaim = context.Principal?.FindFirst("sub")?.Value + ?? context.Principal?.FindFirst(ClaimTypes.NameIdentifier)?.Value; + + if (!Guid.TryParse(subClaim, out var userId)) + { + context.Fail("Token is missing a valid user id claim."); + return; + } + + var userRepo = context.HttpContext.RequestServices.GetRequiredService(); + var user = await userRepo.GetByIdAsync(userId); + if (user is null || !user.IsActive) + { + context.Fail("User not found or inactive."); + return; + } + + var identity = (ClaimsIdentity)context.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())); + } + }; }); builder.Services.AddAuthorization(); diff --git a/Distribution/appsettings.json b/Distribution/appsettings.json index e217f92..9a9f90d 100644 --- a/Distribution/appsettings.json +++ b/Distribution/appsettings.json @@ -10,10 +10,14 @@ }, "AllowedHosts": "*", "Jwt": { - "Key": "CHANGE_ME_dev_only_replace_with_a_long_random_secret_before_deploying", - "Issuer": "Distribution.Api", - "Audience": "Distribution.Api", - "ExpiryMinutes": 120 + "PublicKey": "1LlNkMBQNdpXJiDal7XMxkG/3ad+YBsMCuY9JD/abHMzniFXtQlovjfbeaaHJ0v1kvSo9731CJ0YC1qhPU5rPQwZwxOWZ9BOBZlMDghONdjOH/HyCUbb5Z18ibqc0QenFSnEYz+jkVZiayj8DV/+VUe+eKzpQTlU6aWtHvlbwfuXaDu+QvFlpLJ7/m8na+0s2nYhLX8Wfi4C/2AoNaYhFkIwYhMMGoaSHuIoQ5R6181Rh0gKvYopRW+IpTD5RV8bXV3AM6zOcoisOifBYROHwA5ZZpoHXuTvHYmPWW8kL8PKme7BwBldPi8KrJUroRE+WXA87aAA5Wtt1oxePcXvhQ==AQAB", + "Audience": "AuthHexClient", + "Issuer": "AuthHex" + // "ExpiryMinutes": 120 + + }, + "AuthHex": { + "BaseUrl": "http://localhost:6102" }, "StoreManagement": { "UnverifiedKycOrderThreshold": 0 From 08ad17f15bf494f9a2b0b47f4a24fe9b83c244b1 Mon Sep 17 00:00:00 2001 From: DeepnaPooja Date: Tue, 25 Aug 2026 16:46:12 +0530 Subject: [PATCH 2/2] Document auth flow --- Distribution/docs/authentication_flow.md | 102 +++++++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 Distribution/docs/authentication_flow.md diff --git a/Distribution/docs/authentication_flow.md b/Distribution/docs/authentication_flow.md new file mode 100644 index 0000000..ecddb8c --- /dev/null +++ b/Distribution/docs/authentication_flow.md @@ -0,0 +1,102 @@ +# Authentication — Own-User Auth via External Identity Service (AuthHex) + +**Status**: Done +**Depends On**: — (cross-cutting infrastructure, not a numbered feature step) + +--- + +## Goal + +Own-user (staff/API) authentication is delegated to an external identity service ("AuthHex"), +which owns credentials and signs RS256 JWTs. This app validates those tokens and maps them onto +its own `User`/`Role` tables so the existing permission-policy authorization stays unchanged. + +--- + +## Background / Context + +Previously this app verified passwords and minted its own HMAC-signed JWT locally. That's been +replaced — AuthHex now owns credentials and signing; this app only holds AuthHex's **public** key +to validate tokens, and never sees a plaintext password for this flow. + +Authorization is untouched: permission policies, role levels, scope checks, and the seeded role +set all still work exactly as before, because every validated token is mapped back onto a local +`User`/`Role` row before any authorization check runs. + +--- + +## Configuration Changes + +```jsonc +"Jwt": { + "PublicKey": "...", + "Audience": "AuthHexClient", + "Issuer": "AuthHex" +}, +"AuthHex": { "BaseUrl": "http://localhost:6102" } +``` + +`Jwt:Key` (old symmetric secret) is removed. `Jwt:PublicKey` is AuthHex's RSA public key in XML +format (not PEM). + +--- + +## API Endpoints + +| Method | Route | Notes | +|---|---|---| +| POST | /api/auth/login | Proxies to AuthHex `loginUser` | +| POST | /api/auth/register | Proxies to AuthHex `registerUser` | +| GET | /api/auth/me | `[Authorize]`, proxies to AuthHex `getUserDetails` | +| POST | /api/auth/logout | `[Authorize]`, proxies to AuthHex `LogoutUser` | + +All four just forward to `POST {AuthHex:BaseUrl}/api/user` and pass AuthHex's response back +as-is. No cookie — token returned in the response body, sent back as `Authorization: Bearer`. + +--- + +## Business Rules + +1. No local password check or token issuance — both delegated to AuthHex. +2. `AddJwtBearer` validates issuer/audience/signature/lifetime against AuthHex's public key. +3. `OnTokenValidated` maps the token's `sub` claim to a local `User` (`IUserRepository`); fails + the token if no matching active user exists. +4. On success, `username`/`role_id`/`role_level` claims are attached from the **local** `User`/ + `Role` row — authorization checks downstream are unaffected by anything AuthHex's own token + claims contain. +5. Only one validation mechanism runs (`AddJwtBearer`) — two ported alternatives exist but are not + registered (see Implementation Notes). +6. No refresh tokens — once the AuthHex-issued token expires, the client must log in again. + +--- + +## Permissions to Add to Seed + +None — this only changes authentication. Permission policies and role seed data are untouched. + +--- + +## Open Questions + +- Add a refresh-token flow if AuthHex doesn't offer one and re-login UX proves disruptive. +- Keep or delete the ported `AuthMiddleware`/`JwtMiddleware` if never actually needed. +- No retry/circuit-breaker if AuthHex is unreachable. + +--- + +## Implementation Notes + +``` +Features/Auth/ — AuthController, AuthService, AuthDTOs (thin AuthHex proxies) +Infra/Authorization/ + AuthHex/ — IAuthHexClient, AuthHexClient, AuthHexResponse + Jwt/RsaXmlKeyReader.cs — portable XML→RSA key parser (RSA.FromXmlString is Windows-only) + Middleware/ — AuthMiddleware, JwtMiddleware, JwtOptions, ApiResponse + (ported from OSMCore for reference — NOT registered) +Program.cs — AddHttpClient, AddJwtBearer + OnTokenValidated + (maps token → local User/Role → role_id/role_level claims) +``` + +**Gotcha:** don't run more than one of `AddJwtBearer` / `AuthMiddleware` / +`JwtMiddleware.CustomMiddlewareAttribute` at once — each re-validates the same token +independently.