diff --git a/Distribution/Features/Auth/AuthController.cs b/Distribution/Features/Auth/AuthController.cs
index 9f7504b..385c9b7 100644
--- a/Distribution/Features/Auth/AuthController.cs
+++ b/Distribution/Features/Auth/AuthController.cs
@@ -1,28 +1,37 @@
+using Distribution.Infra.Authorization;
+using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.Auth;
-///
-/// Proxies all authentication traffic from the Next.js frontend to the
-/// external Auth Service. The frontend must never call the Auth Service
-/// directly — all auth goes through this controller.
-///
-/// TODO: Implement endpoints once Auth Service contract is agreed.
-///
[ApiController]
[Route("api/auth")]
-public class AuthController(IAuthServiceClient authServiceClient) : ControllerBase
+public class AuthController(AuthService service, ICurrentUserService currentUser) : ControllerBase
{
- // TODO: replace stub endpoints below with real proxy calls once
- // the Auth Service API contract is finalised.
+ 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 Task Login()
+ 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)
{
- // TODO: accept login request shape from Auth Service contract,
- // forward to authServiceClient, relay response (token / session) back.
- return Task.FromResult(StatusCode(501, new { error = "Auth integration not yet implemented." }));
+ var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
+ return ToActionResult(await service.GetUserDetailsAsync(userId, ct));
}
- // TODO: add further endpoints as needed (refresh, logout, etc.)
+ [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 7d124b2..0aa80a6 100644
--- a/Distribution/Features/Auth/AuthDTOs.cs
+++ b/Distribution/Features/Auth/AuthDTOs.cs
@@ -1,5 +1,5 @@
namespace Distribution.Features.Auth;
-// TODO: Define request/response shapes once the Auth Service contract is agreed.
-// These will be the DTOs passed between the Next.js frontend and this proxy controller,
-// which should mirror (or adapt) whatever the Auth Service expects/returns.
+public record LoginRequest(string Username, string Password);
+
+public record RegisterRequest(string Username, string Password, string? FullName);
diff --git a/Distribution/Features/Auth/AuthService.cs b/Distribution/Features/Auth/AuthService.cs
index ed74e7f..4986750 100644
--- a/Distribution/Features/Auth/AuthService.cs
+++ b/Distribution/Features/Auth/AuthService.cs
@@ -1,6 +1,24 @@
+using Distribution.Infra.Authorization;
+
namespace Distribution.Features.Auth;
-// TODO: This service previously contained local password validation logic.
-// It has been replaced by the IAuthServiceClient connector which proxies
-// to the external Auth Service. Remove this file or repurpose it for any
-// local orchestration logic once the Auth Service contract is defined.
+///
+/// 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)
+{
+ public Task LoginAsync(LoginRequest request, CancellationToken ct = default) =>
+ authHex.CallAsync("loginUser", request, reference: Guid.NewGuid().ToString(), ct: ct);
+
+ public Task RegisterAsync(RegisterRequest request, CancellationToken ct = default) =>
+ authHex.CallAsync("registerUser", request, reference: Guid.NewGuid().ToString(), ct: ct);
+
+ public Task GetUserDetailsAsync(Guid userId, CancellationToken ct = default) =>
+ authHex.CallAsync("getUserDetails", new { userId }, ct: ct);
+
+ 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