diff --git a/Backend/ERPCore/Controllers/AuthController.cs b/Backend/ERPCore/Controllers/AuthController.cs
new file mode 100644
index 0000000..5900a4a
--- /dev/null
+++ b/Backend/ERPCore/Controllers/AuthController.cs
@@ -0,0 +1,252 @@
+using ERPCore.Dtos.Auth;
+using ERPCore.Infra.Auth;
+using ERPCore.Services.Interfaces;
+using ERPCore.System.Errors;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+
+namespace ERPCore.Controllers;
+
+///
+/// Fronts the external AuthHex identity service (API_REFERENCE.md) so the
+/// frontend never calls AuthHex directly. Sessions are delivered as httpOnly
+/// Secure cookies (docs/02-SECURITY.md §B.2) via
+/// — response bodies never carry raw tokens. Does not inherit
+/// : most actions here are pre-session and need
+/// , and the ETag/If-Match handling that
+/// base provides doesn't apply to auth flows.
+///
+[ApiController]
+[Produces("application/json")]
+[Route("api/v1/auth")]
+[Authorize(JwtAuthExtensions.ErpAccessPolicy)]
+public sealed class AuthController : ControllerBase
+{
+ private readonly IAuthUserService _users;
+ private readonly IAuthRecoveryService _recovery;
+ private readonly IAuthAltService _alt;
+
+ public AuthController(IAuthUserService users, IAuthRecoveryService recovery, IAuthAltService alt)
+ {
+ _users = users;
+ _recovery = recovery;
+ _alt = alt;
+ }
+
+ // ---- Session-issuing (UserManager) ------------------------------------
+
+ [HttpPost("register")]
+ [AllowAnonymous]
+ [ProducesResponseType(typeof(AuthSessionResponse), StatusCodes.Status200OK)]
+ public async Task> Register([FromBody] RegisterRequest request, CancellationToken ct)
+ {
+ var result = await _users.RegisterAsync(request, ct);
+ AuthCookieWriter.WriteSession(Response, result.AccessToken, result.RefreshToken, result.Body.ExpiresIn);
+ return Ok(result.Body);
+ }
+
+ [HttpPost("login")]
+ [AllowAnonymous]
+ [ProducesResponseType(typeof(AuthSessionResponse), StatusCodes.Status200OK)]
+ public async Task> Login([FromBody] LoginRequest request, CancellationToken ct)
+ {
+ var result = await _users.LoginAsync(request, ct);
+ AuthCookieWriter.WriteSession(Response, result.AccessToken, result.RefreshToken, result.Body.ExpiresIn);
+ return Ok(result.Body);
+ }
+
+ [HttpPost("login/otp/verify")]
+ [AllowAnonymous]
+ [ProducesResponseType(typeof(OtpLoginVerifiedResponse), StatusCodes.Status200OK)]
+ public async Task> VerifyLoginOtp([FromBody] VerifyOtpForLoginRequest request, CancellationToken ct)
+ {
+ var result = await _users.VerifyOtpForLoginAsync(request, ct);
+ AuthCookieWriter.WriteSession(Response, result.AccessToken, result.RefreshToken, result.Body.ExpiresIn);
+ return Ok(result.Body);
+ }
+
+ [HttpPost("refresh-token")]
+ [AllowAnonymous]
+ [ProducesResponseType(typeof(AuthSessionResponse), StatusCodes.Status200OK)]
+ public async Task> RefreshToken([FromBody] RefreshTokenRequest request, CancellationToken ct)
+ {
+ if (!Request.Cookies.TryGetValue(JwtAuthExtensions.RefreshTokenCookie, out var refreshToken) || string.IsNullOrEmpty(refreshToken))
+ throw new DomainException(ErrorCodes.RefreshTokenMissing, "No refresh session cookie present.", 401);
+
+ var result = await _users.RefreshTokenAsync(refreshToken, request, ct);
+ AuthCookieWriter.WriteSession(Response, result.AccessToken, result.RefreshToken, result.Body.ExpiresIn);
+ return Ok(result.Body);
+ }
+
+ // ---- Profile / sessions (UserManager) ---------------------------------
+
+ [HttpGet("users/{userId:guid}")]
+ [AllowAnonymous]
+ [ProducesResponseType(typeof(GetUserDetailsResponse), StatusCodes.Status200OK)]
+ public async Task> GetUserDetails(Guid userId, CancellationToken ct)
+ => Ok(await _users.GetUserDetailsAsync(userId, ct));
+
+ [HttpGet("sessions")]
+ [ProducesResponseType(typeof(List), StatusCodes.Status200OK)]
+ public async Task>> GetSessions(CancellationToken ct)
+ => Ok(await _users.GetUserSessionsAsync(RequireBearerToken(), ct));
+
+ [HttpPost("status")]
+ [ValidateCsrf]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ public async Task ChangeStatus([FromBody] ChangeUserStatusRequest request, CancellationToken ct)
+ {
+ await _users.ChangeUserStatusAsync(request, RequireBearerToken(), ct);
+ return NoContent();
+ }
+
+ [HttpPost("lock")]
+ [ValidateCsrf]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ public async Task Lock([FromBody] LockUserAccountRequest request, CancellationToken ct)
+ {
+ await _users.LockUserAccountAsync(request, RequireBearerToken(), ct);
+ AuthCookieWriter.ClearSession(Response);
+ return NoContent();
+ }
+
+ [HttpPost("change-password")]
+ [ValidateCsrf]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ public async Task ChangePassword([FromBody] ChangeUserPasswordRequest request, CancellationToken ct)
+ {
+ await _users.ChangeUserPasswordAsync(request, RequireBearerToken(), ct);
+ AuthCookieWriter.ClearSession(Response);
+ return NoContent();
+ }
+
+ [HttpPost("verify-password")]
+ [ProducesResponseType(typeof(VerifyPasswordResponse), StatusCodes.Status200OK)]
+ public async Task> VerifyPassword([FromBody] VerifyPasswordRequest request, CancellationToken ct)
+ => Ok(await _users.VerifyPasswordAsync(request, RequireBearerToken(), ct));
+
+ [HttpPost("logout")]
+ [AllowAnonymous]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ public async Task Logout([FromBody] LogoutRequest request, CancellationToken ct)
+ {
+ await _users.LogoutUserAsync(request, ct);
+ AuthCookieWriter.ClearSession(Response);
+ return NoContent();
+ }
+
+ [HttpPut("me")]
+ [ValidateCsrf]
+ [ProducesResponseType(typeof(UserSummaryDto), StatusCodes.Status200OK)]
+ public async Task> UpdateMe([FromBody] UpdateUserRequest request, CancellationToken ct)
+ => Ok(await _users.UpdateUserAsync(request, RequireBearerToken(), ct));
+
+ // ---- 2FA (UserManager) -------------------------------------------------
+
+ [HttpPost("2fa/initiate")]
+ [ValidateCsrf]
+ [ProducesResponseType(typeof(TwoFaSetupResponse), StatusCodes.Status200OK)]
+ public async Task> InitiateTwoFa(CancellationToken ct)
+ => Ok(await _users.InitiateTwoFaSetupAsync(RequireBearerToken(), ct));
+
+ [HttpPost("2fa/complete")]
+ [ValidateCsrf]
+ [ProducesResponseType(typeof(CompleteTwoFaSetupResponse), StatusCodes.Status200OK)]
+ public async Task> CompleteTwoFa([FromBody] CompleteTwoFaSetupRequest request, CancellationToken ct)
+ => Ok(await _users.CompleteTwoFaSetupAsync(request, RequireBearerToken(), ct));
+
+ [HttpPost("2fa/verify")]
+ [ValidateCsrf]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ public async Task VerifyTwoFa([FromBody] VerifyTwoFaRequest request, CancellationToken ct)
+ {
+ await _users.VerifyTwoFaAsync(request, RequireBearerToken(), ct);
+ return NoContent();
+ }
+
+ [HttpPost("2fa/disable")]
+ [ValidateCsrf]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ public async Task DisableTwoFa([FromBody] DisableTwoFaRequest request, CancellationToken ct)
+ {
+ await _users.DisableTwoFaAsync(request, RequireBearerToken(), ct);
+ return NoContent();
+ }
+
+ [HttpGet("2fa/status")]
+ [ProducesResponseType(typeof(TwoFaStatusResponse), StatusCodes.Status200OK)]
+ public async Task> GetTwoFaStatus(CancellationToken ct)
+ => Ok(await _users.GetTwoFaStatusAsync(RequireBearerToken(), ct));
+
+ // ---- Recovery -----------------------------------------------------------
+
+ [HttpPost("recovery/forgot-password")]
+ [AllowAnonymous]
+ [ProducesResponseType(typeof(ForgotPasswordResponse), StatusCodes.Status200OK)]
+ public async Task> ForgotPassword([FromBody] ForgotPasswordRequest request, CancellationToken ct)
+ => Ok(await _recovery.ForgotPasswordAsync(request, ct));
+
+ [HttpPost("recovery/verify-otp")]
+ [AllowAnonymous]
+ [ProducesResponseType(typeof(VerifyRecoveryOtpResponse), StatusCodes.Status200OK)]
+ public async Task> VerifyRecoveryOtp([FromBody] VerifyRecoveryOtpRequest request, CancellationToken ct)
+ => Ok(await _recovery.VerifyOtpAsync(request, ct));
+
+ [HttpPost("recovery/reset-password")]
+ [AllowAnonymous]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ public async Task ResetPassword([FromBody] ResetPasswordRequest request, CancellationToken ct)
+ {
+ await _recovery.ResetPasswordAsync(request, ct);
+ return NoContent();
+ }
+
+ [HttpPost("recovery/reset-password-token")]
+ [AllowAnonymous]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ public async Task ResetPasswordWithToken([FromBody] ResetPasswordWithTokenRequest request, CancellationToken ct)
+ {
+ await _recovery.ResetPasswordWithTokenAsync(request, ct);
+ return NoContent();
+ }
+
+ // ---- Availability / OTP (AltOptionManager) -----------------------------
+
+ [HttpPost("availability")]
+ [AllowAnonymous]
+ [ProducesResponseType(typeof(IsAvailableResponse), StatusCodes.Status200OK)]
+ public async Task> CheckAvailability([FromBody] IsAvailableRequest request, CancellationToken ct)
+ => Ok(await _alt.IsAvailableAsync(request, ct));
+
+ [HttpPost("otp/send")]
+ [AllowAnonymous]
+ [ProducesResponseType(typeof(SendOtpResponse), StatusCodes.Status200OK)]
+ public async Task> SendOtp([FromBody] SendOtpRequest request, CancellationToken ct)
+ => Ok(await _alt.SendOtpAsync(request, ct));
+
+ [HttpPost("otp/verify")]
+ [AllowAnonymous]
+ [ProducesResponseType(typeof(OtpLoginVerifiedResponse), StatusCodes.Status200OK)]
+ public async Task> VerifyAltOtp([FromBody] VerifyAltOtpRequest request, CancellationToken ct)
+ {
+ var result = await _alt.VerifyOtpAsync(request, ct);
+ AuthCookieWriter.WriteSession(Response, result.AccessToken, result.RefreshToken, result.Body.ExpiresIn);
+ return Ok(result.Body);
+ }
+
+ // ---- Helpers --------------------------------------------------------------
+
+ /// The token that authenticated this request — Bearer header if present, else the session cookie.
+ private string RequireBearerToken()
+ {
+ var header = Request.Headers.Authorization.ToString();
+ if (!string.IsNullOrEmpty(header) && header.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
+ return header["Bearer ".Length..];
+
+ if (Request.Cookies.TryGetValue(JwtAuthExtensions.AccessTokenCookie, out var cookieToken) && !string.IsNullOrEmpty(cookieToken))
+ return cookieToken;
+
+ // [Authorize] already guaranteed one of the above was present to authenticate this request.
+ throw new DomainException(ErrorCodes.AuthUpstreamError, "No bearer token found on an authenticated request.", 500);
+ }
+}
diff --git a/Backend/ERPCore/Dtos/Auth/AuthAltDtos.cs b/Backend/ERPCore/Dtos/Auth/AuthAltDtos.cs
new file mode 100644
index 0000000..26df8d5
--- /dev/null
+++ b/Backend/ERPCore/Dtos/Auth/AuthAltDtos.cs
@@ -0,0 +1,42 @@
+using System.ComponentModel.DataAnnotations;
+using System.Text.Json;
+
+namespace ERPCore.Dtos.Auth;
+
+public sealed class IsAvailableRequest
+{
+ [Required] public string Identifier { get; set; } = string.Empty;
+ public string? Recovery { get; set; }
+}
+
+public sealed class IsAvailableResponse
+{
+ public bool? IsAvailable { get; set; }
+ public string? Message { get; set; }
+ /// Passed through as-is when `Recovery` matched existing users — shape isn't in the documented catalog.
+ public JsonElement? ExistingUsers { get; set; }
+}
+
+public sealed class SendOtpRequest
+{
+ [Required] public string Identifier { get; set; } = string.Empty;
+ public int NumberOfDigits { get; set; } = 6;
+ public bool NewUser { get; set; }
+}
+
+public sealed class SendOtpResponse
+{
+ public string? ReferenceNumber { get; set; }
+ public DateTime? ExpiresAt { get; set; }
+ public string? RecoveryType { get; set; }
+}
+
+public sealed class VerifyAltOtpRequest
+{
+ [Required] public string ReferenceNumber { get; set; } = string.Empty;
+ [Required] public string OtpCode { get; set; } = string.Empty;
+ public string? Identifier { get; set; }
+ public Guid? UserId { get; set; }
+ public bool NewUser { get; set; }
+ public string? DeviceName { get; set; }
+}
diff --git a/Backend/ERPCore/Dtos/Auth/AuthRecoveryDtos.cs b/Backend/ERPCore/Dtos/Auth/AuthRecoveryDtos.cs
new file mode 100644
index 0000000..7aa7ba8
--- /dev/null
+++ b/Backend/ERPCore/Dtos/Auth/AuthRecoveryDtos.cs
@@ -0,0 +1,45 @@
+using System.ComponentModel.DataAnnotations;
+
+namespace ERPCore.Dtos.Auth;
+
+public sealed class ForgotPasswordRequest
+{
+ [Required] public string Identifier { get; set; } = string.Empty;
+ public bool UseResetLink { get; set; }
+ public int NumberOfDigits { get; set; } = 6;
+ public bool Welcome { get; set; }
+}
+
+public sealed class ForgotPasswordResponse
+{
+ public string? ReferenceNumber { get; set; }
+ public DateTime? ExpiresAt { get; set; }
+ public string? RecoveryType { get; set; }
+}
+
+public sealed class VerifyRecoveryOtpRequest
+{
+ [Required] public string ReferenceNumber { get; set; } = string.Empty;
+ [Required] public string OtpCode { get; set; } = string.Empty;
+}
+
+public sealed class VerifyRecoveryOtpResponse
+{
+ public string? ReferenceNumber { get; set; }
+ public Guid? UserId { get; set; }
+ public bool Verified { get; set; }
+}
+
+public sealed class ResetPasswordRequest
+{
+ [Required] public string ReferenceNumber { get; set; } = string.Empty;
+ [Required, MinLength(8)] public string NewPassword { get; set; } = string.Empty;
+ [Required] public string ConfirmPassword { get; set; } = string.Empty;
+}
+
+public sealed class ResetPasswordWithTokenRequest
+{
+ [Required] public string ResetToken { get; set; } = string.Empty;
+ [Required, MinLength(8)] public string NewPassword { get; set; } = string.Empty;
+ [Required] public string ConfirmPassword { get; set; } = string.Empty;
+}
diff --git a/Backend/ERPCore/Dtos/Auth/AuthUserDtos.cs b/Backend/ERPCore/Dtos/Auth/AuthUserDtos.cs
new file mode 100644
index 0000000..f75c193
--- /dev/null
+++ b/Backend/ERPCore/Dtos/Auth/AuthUserDtos.cs
@@ -0,0 +1,179 @@
+using System.ComponentModel.DataAnnotations;
+using System.Text.Json;
+
+namespace ERPCore.Dtos.Auth;
+
+/// Shared AuthHex user projection (API_REFERENCE.md §3). Field set is
+/// AuthHex's best-documented subset; unknown fields are ignored on deserialize.
+public sealed class UserSummaryDto
+{
+ public Guid? UserId { get; set; }
+ public Guid? RoleId { get; set; }
+ public Guid? UserTypeId { get; set; }
+ public string? Fullname { get; set; }
+ public string? UserName { get; set; }
+ public string? Nic { get; set; }
+ public string? Email { get; set; }
+ public string? MobileNumber { get; set; }
+ public bool? EmailVerified { get; set; }
+ public bool? MobileNumberVerified { get; set; }
+ public bool? IsActive { get; set; }
+ public bool? IsLocked { get; set; }
+}
+
+/// Body returned by every session-issuing endpoint. Tokens never appear
+/// here — they are delivered only as httpOnly cookies (docs/02-SECURITY.md §B.2).
+public sealed class AuthSessionResponse
+{
+ public UserSummaryDto? User { get; set; }
+ public int ExpiresIn { get; set; }
+}
+
+public sealed class RegisterRequest
+{
+ /// Optional — AuthHex requires a client-supplied id; ERPCore generates one when omitted.
+ public Guid? UserId { get; set; }
+ [Required] public Guid RoleId { get; set; }
+ [Required] public Guid UserTypeId { get; set; }
+ public string? Fullname { get; set; }
+ public string? UserName { get; set; }
+ public string? Nic { get; set; }
+ public string? Email { get; set; }
+ public string? MobileNumber { get; set; }
+ public string? Password { get; set; }
+ public string? DeviceName { get; set; }
+ public bool? ChkUser { get; set; }
+}
+
+public sealed class LoginRequest
+{
+ [Required] public string Identifier { get; set; } = string.Empty;
+ [Required] public string Password { get; set; } = string.Empty;
+ public Guid? UserTypeId { get; set; }
+ public string? DeviceName { get; set; }
+}
+
+public sealed class VerifyOtpForLoginRequest
+{
+ [Required] public string ReferenceNumber { get; set; } = string.Empty;
+ [Required] public string OtpCode { get; set; } = string.Empty;
+ public string? DeviceName { get; set; }
+}
+
+public sealed class OtpLoginVerifiedResponse
+{
+ public string? ReferenceNumber { get; set; }
+ public Guid? UserId { get; set; }
+ public bool Verified { get; set; }
+ public UserSummaryDto? User { get; set; }
+ public int ExpiresIn { get; set; }
+}
+
+public sealed class RefreshTokenRequest
+{
+ public string? DeviceName { get; set; }
+}
+
+public sealed class GetUserDetailsResponse
+{
+ public UserSummaryDto? User { get; set; }
+ /// Passed through as-is — AuthHex's Role/UserType shapes aren't in the documented catalog.
+ public JsonElement? Role { get; set; }
+ public JsonElement? UserType { get; set; }
+}
+
+public sealed class SessionDto
+{
+ public string? SessionId { get; set; }
+ public string? DeviceName { get; set; }
+ public string? Browser { get; set; }
+ public string? OS { get; set; }
+ public string? IPAddress { get; set; }
+ public DateTime? CreatedAt { get; set; }
+ public DateTime? ExpiresAt { get; set; }
+ public DateTime? RevokedAt { get; set; }
+ public bool? IsActive { get; set; }
+}
+
+public sealed class ChangeUserStatusRequest
+{
+ [Required] public bool IsActive { get; set; }
+}
+
+public sealed class LockUserAccountRequest
+{
+ [Required] public bool IsLocked { get; set; }
+}
+
+public sealed class ChangeUserPasswordRequest
+{
+ [Required] public string CurrentPassword { get; set; } = string.Empty;
+ [Required, MinLength(8)] public string NewPassword { get; set; } = string.Empty;
+}
+
+public sealed class VerifyPasswordRequest
+{
+ [Required] public string Password { get; set; } = string.Empty;
+}
+
+public sealed class VerifyPasswordResponse
+{
+ public bool Valid { get; set; }
+}
+
+public sealed class UpdateUserRequest
+{
+ public string? FullName { get; set; }
+ public string? UserName { get; set; }
+ public string? Nic { get; set; }
+ public string? Address { get; set; }
+ public string? Optional1 { get; set; }
+ public string? Optional2 { get; set; }
+ public string? Email { get; set; }
+ public string? MobileNumber { get; set; }
+ public string? NewPassword { get; set; }
+ public string? CurrentPassword { get; set; }
+}
+
+/// Passthrough — TOTP secret/QR payload shape is only loosely documented
+/// ("secret key, QR/otpauth URL ... from the third-party service").
+public sealed class TwoFaSetupResponse
+{
+ public JsonElement Data { get; set; }
+}
+
+public sealed class CompleteTwoFaSetupRequest
+{
+ [Required] public string SecretKey { get; set; } = string.Empty;
+ [Required] public string VerificationCode { get; set; } = string.Empty;
+}
+
+public sealed class CompleteTwoFaSetupResponse
+{
+ public List BackupCodes { get; set; } = new();
+ public UserSummaryDto? User { get; set; }
+}
+
+public sealed class VerifyTwoFaRequest
+{
+ [Required] public string VerificationCode { get; set; } = string.Empty;
+}
+
+public sealed class DisableTwoFaRequest
+{
+ [Required] public string VerificationCode { get; set; } = string.Empty;
+}
+
+public sealed class TwoFaStatusResponse
+{
+ public bool IsMfaEnabled { get; set; }
+ public bool IsVerified { get; set; }
+ public DateTime? LastUsedAt { get; set; }
+ public DateTime? VerifiedAt { get; set; }
+ public bool HasBackupCodes { get; set; }
+}
+
+public sealed class LogoutRequest
+{
+ [Required] public Guid UserId { get; set; }
+}
diff --git a/Backend/ERPCore/Infra/Auth/AuthCookieWriter.cs b/Backend/ERPCore/Infra/Auth/AuthCookieWriter.cs
new file mode 100644
index 0000000..bc1ba9f
--- /dev/null
+++ b/Backend/ERPCore/Infra/Auth/AuthCookieWriter.cs
@@ -0,0 +1,52 @@
+using System.Security.Cryptography;
+
+namespace ERPCore.Infra.Auth;
+
+///
+/// Writes/clears the httpOnly session cookies + non-httpOnly CSRF cookie
+/// AuthController issues on every session-establishing call (docs/02-SECURITY.md
+/// §B.2). SameSite=Strict assumes frontend and ERPCore share a registrable
+/// domain (e.g. both on `localhost`, different ports) — a cross-domain
+/// deployment would need SameSite=None (+ Secure, which is already set).
+///
+public static class AuthCookieWriter
+{
+ public static void WriteSession(HttpResponse response, string accessToken, string refreshToken, int expiresInSeconds)
+ {
+ response.Cookies.Append(JwtAuthExtensions.AccessTokenCookie, accessToken, new CookieOptions
+ {
+ HttpOnly = true,
+ Secure = true,
+ SameSite = SameSiteMode.Strict,
+ Path = "/",
+ MaxAge = TimeSpan.FromSeconds(expiresInSeconds)
+ });
+
+ response.Cookies.Append(JwtAuthExtensions.RefreshTokenCookie, refreshToken, new CookieOptions
+ {
+ HttpOnly = true,
+ Secure = true,
+ SameSite = SameSiteMode.Strict,
+ Path = "/api/v1/auth/refresh-token",
+ MaxAge = TimeSpan.FromDays(30)
+ });
+
+ response.Cookies.Append(JwtAuthExtensions.CsrfCookie, GenerateCsrfToken(), new CookieOptions
+ {
+ HttpOnly = false,
+ Secure = true,
+ SameSite = SameSiteMode.Strict,
+ Path = "/",
+ MaxAge = TimeSpan.FromSeconds(expiresInSeconds)
+ });
+ }
+
+ public static void ClearSession(HttpResponse response)
+ {
+ response.Cookies.Delete(JwtAuthExtensions.AccessTokenCookie, new CookieOptions { Path = "/" });
+ response.Cookies.Delete(JwtAuthExtensions.RefreshTokenCookie, new CookieOptions { Path = "/api/v1/auth/refresh-token" });
+ response.Cookies.Delete(JwtAuthExtensions.CsrfCookie, new CookieOptions { Path = "/" });
+ }
+
+ private static string GenerateCsrfToken() => Convert.ToBase64String(RandomNumberGenerator.GetBytes(32));
+}
diff --git a/Backend/ERPCore/Infra/Auth/AuthHex/AuthHexClient.cs b/Backend/ERPCore/Infra/Auth/AuthHex/AuthHexClient.cs
new file mode 100644
index 0000000..125230f
--- /dev/null
+++ b/Backend/ERPCore/Infra/Auth/AuthHex/AuthHexClient.cs
@@ -0,0 +1,162 @@
+using System.Net.Http.Headers;
+using System.Net.Http.Json;
+using System.Text.Json;
+using ERPCore.Dtos.Auth;
+using ERPCore.System.Errors;
+
+namespace ERPCore.Infra.Auth.AuthHex;
+
+///
+/// HTTP implementation of . Registered as a typed
+/// client (`AddHttpClient<IAuthHexClient, AuthHexClient>`) with its
+/// `BaseAddress` bound from `AuthHex:BaseUrl`. Every call POSTs AuthHex's
+/// `{ functionName, payload, reference }` envelope to the matching manager
+/// route and unwraps the `{ statusCode, success, message, data }` response,
+/// translating upstream failures into .
+///
+public sealed class AuthHexClient : IAuthHexClient
+{
+ private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
+ {
+ PropertyNameCaseInsensitive = true
+ };
+
+ private readonly HttpClient _http;
+
+ public AuthHexClient(HttpClient http) => _http = http;
+
+ // ---- UserManager --------------------------------------------------
+
+ public Task RegisterAsync(RegisterRequest request, CancellationToken ct)
+ => CallAsync("user", "registerUser", request, null, ct);
+
+ public Task LoginAsync(LoginRequest request, CancellationToken ct)
+ => CallAsync("user", "loginUser", request, null, ct);
+
+ public Task VerifyOtpForLoginAsync(VerifyOtpForLoginRequest request, CancellationToken ct)
+ => CallAsync("user", "VerifyOtpForLogin", request, null, ct);
+
+ public Task RefreshTokenAsync(string refreshToken, string? deviceName, CancellationToken ct)
+ => CallAsync("user", "refreshToken", new { refreshToken, deviceName }, null, ct);
+
+ public Task GetUserDetailsAsync(Guid userId, CancellationToken ct)
+ => CallAsync("user", "getUserDetails", new { userId }, null, ct);
+
+ public Task> GetUserSessionsAsync(string bearerToken, CancellationToken ct)
+ => CallAsync>("user", "getUserSessions", new { }, bearerToken, ct);
+
+ public Task ChangeUserStatusAsync(bool isActive, string bearerToken, CancellationToken ct)
+ => CallVoidAsync("user", "ChangeUserStatus", new { isActive }, bearerToken, ct);
+
+ public Task LockUserAccountAsync(bool isLocked, string bearerToken, CancellationToken ct)
+ => CallVoidAsync("user", "LockUserAccount", new { isLocked }, bearerToken, ct);
+
+ public Task ChangeUserPasswordAsync(ChangeUserPasswordRequest request, string bearerToken, CancellationToken ct)
+ => CallVoidAsync("user", "ChangeUserPassword", request, bearerToken, ct);
+
+ public Task VerifyPasswordAsync(VerifyPasswordRequest request, string bearerToken, CancellationToken ct)
+ => CallAsync("user", "VerifyPassword", request, bearerToken, ct);
+
+ public Task LogoutUserAsync(Guid userId, CancellationToken ct)
+ => CallVoidAsync("user", "LogoutUser", new { userId }, null, ct);
+
+ public Task UpdateUserAsync(UpdateUserRequest request, string bearerToken, CancellationToken ct)
+ => CallAsync("user", "UpdateUser", request, bearerToken, ct);
+
+ public Task InitiateTwoFaSetupAsync(string bearerToken, CancellationToken ct)
+ => CallAsync("user", "initiateTwoFASetup", new { }, bearerToken, ct);
+
+ public Task CompleteTwoFaSetupAsync(CompleteTwoFaSetupRequest request, string bearerToken, CancellationToken ct)
+ => CallAsync("user", "completeTwoFASetup", request, bearerToken, ct);
+
+ public Task VerifyTwoFaAsync(VerifyTwoFaRequest request, string bearerToken, CancellationToken ct)
+ => CallVoidAsync("user", "verifyTwoFA", request, bearerToken, ct);
+
+ public Task DisableTwoFaAsync(DisableTwoFaRequest request, string bearerToken, CancellationToken ct)
+ => CallVoidAsync("user", "disableTwoFA", request, bearerToken, ct);
+
+ public Task GetTwoFaStatusAsync(string bearerToken, CancellationToken ct)
+ => CallAsync("user", "getTwoFAStatus", new { }, bearerToken, ct);
+
+ // ---- RecoveryManager ------------------------------------------------
+
+ public Task ForgotPasswordAsync(ForgotPasswordRequest request, CancellationToken ct)
+ => CallAsync("recovery", "forgotPassword", request, null, ct);
+
+ public Task VerifyRecoveryOtpAsync(VerifyRecoveryOtpRequest request, CancellationToken ct)
+ => CallAsync("recovery", "verifyOTP", request, null, ct);
+
+ public Task ResetPasswordAsync(ResetPasswordRequest request, CancellationToken ct)
+ => CallVoidAsync("recovery", "resetPassword", request, null, ct);
+
+ public Task ResetPasswordWithTokenAsync(ResetPasswordWithTokenRequest request, CancellationToken ct)
+ => CallVoidAsync("recovery", "resetPasswordWithToken", request, null, ct);
+
+ // ---- AltOptionManager -------------------------------------------------
+
+ public Task IsAvailableAsync(IsAvailableRequest request, CancellationToken ct)
+ => CallAsync("alt", "IsAvailable", request, null, ct);
+
+ public Task SendOtpAsync(SendOtpRequest request, CancellationToken ct)
+ => CallAsync("alt", "sendOtp", request, null, ct);
+
+ public Task VerifyAltOtpAsync(VerifyAltOtpRequest request, CancellationToken ct)
+ => CallAsync("alt", "VerifyOTP", request, null, ct);
+
+ // ---- Transport --------------------------------------------------------
+
+ private async Task CallVoidAsync(string routeGroup, string functionName, object payload, string? bearerToken, CancellationToken ct)
+ => await CallAsync(routeGroup, functionName, payload, bearerToken, ct);
+
+ private async Task CallAsync(string routeGroup, string functionName, object payload, string? bearerToken, CancellationToken ct)
+ {
+ using var httpRequest = new HttpRequestMessage(HttpMethod.Post, $"/api/{routeGroup}");
+ if (!string.IsNullOrEmpty(bearerToken))
+ httpRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", bearerToken);
+
+ httpRequest.Content = JsonContent.Create(
+ new AuthHexRequestBody { FunctionName = functionName, Payload = payload, Reference = string.Empty },
+ options: JsonOptions);
+
+ HttpResponseMessage httpResponse;
+ try
+ {
+ httpResponse = await _http.SendAsync(httpRequest, ct);
+ }
+ catch (HttpRequestException)
+ {
+ throw new DomainException(ErrorCodes.AuthServiceUnavailable, "The authentication service is unreachable.", 503);
+ }
+ catch (TaskCanceledException) when (!ct.IsCancellationRequested)
+ {
+ throw new DomainException(ErrorCodes.AuthServiceUnavailable, "The authentication service timed out.", 503);
+ }
+
+ AuthHexEnvelope? envelope;
+ try
+ {
+ envelope = await httpResponse.Content.ReadFromJsonAsync>(JsonOptions, ct);
+ }
+ catch (JsonException)
+ {
+ throw new DomainException(ErrorCodes.AuthUpstreamError, "The authentication service returned an unreadable response.", 502);
+ }
+
+ if (envelope is null)
+ throw new DomainException(ErrorCodes.AuthUpstreamError, "The authentication service returned an empty response.", 502);
+
+ // Trust the outer HTTP status over envelope.Success — AuthHex has been observed
+ // returning HTTP 500 with `success:true, data:null` on business failures (e.g.
+ // invalid credentials), which would otherwise slip through as a "success" and
+ // hand a null payload to the caller.
+ if (!httpResponse.IsSuccessStatusCode || !envelope.Success)
+ {
+ var statusCode = !httpResponse.IsSuccessStatusCode
+ ? (int)httpResponse.StatusCode
+ : envelope.StatusCode is >= 400 and < 600 ? envelope.StatusCode : 400;
+ throw new DomainException(ErrorCodes.AuthUpstreamError, envelope.Message ?? "Authentication request failed.", statusCode);
+ }
+
+ return envelope.Data!;
+ }
+}
diff --git a/Backend/ERPCore/Infra/Auth/AuthHex/AuthHexEnvelope.cs b/Backend/ERPCore/Infra/Auth/AuthHex/AuthHexEnvelope.cs
new file mode 100644
index 0000000..02f7794
--- /dev/null
+++ b/Backend/ERPCore/Infra/Auth/AuthHex/AuthHexEnvelope.cs
@@ -0,0 +1,37 @@
+using ERPCore.Dtos.Auth;
+
+namespace ERPCore.Infra.Auth.AuthHex;
+
+/// Wire shape of AuthHex's `ApiResponse` envelope (API_REFERENCE.md §1).
+public sealed class AuthHexEnvelope
+{
+ public int StatusCode { get; set; }
+ public bool Success { get; set; }
+ public string? Message { get; set; }
+ public T? Data { get; set; }
+}
+
+/// Wire shape of AuthHex's `ApiRequest` envelope (API_REFERENCE.md §1).
+public sealed class AuthHexRequestBody
+{
+ public string FunctionName { get; set; } = string.Empty;
+ public object Payload { get; set; } = new { };
+ public string Reference { get; set; } = string.Empty;
+}
+
+///
+/// Wire shape shared by every session-issuing AuthHex function (register, login,
+/// OTP login verify, refresh, alt OTP verify). Carries the raw tokens — kept
+/// internal so they never leak into a public Dtos/Auth response; AuthController
+/// extracts them into httpOnly cookies and returns only .
+///
+public sealed class AuthHexSessionResult
+{
+ public string? AccessToken { get; set; }
+ public string? RefreshToken { get; set; }
+ public int ExpiresIn { get; set; }
+ public UserSummaryDto? User { get; set; }
+ public string? ReferenceNumber { get; set; }
+ public Guid? UserId { get; set; }
+ public bool? Verified { get; set; }
+}
diff --git a/Backend/ERPCore/Infra/Auth/AuthHex/IAuthHexClient.cs b/Backend/ERPCore/Infra/Auth/AuthHex/IAuthHexClient.cs
new file mode 100644
index 0000000..081f74c
--- /dev/null
+++ b/Backend/ERPCore/Infra/Auth/AuthHex/IAuthHexClient.cs
@@ -0,0 +1,42 @@
+using ERPCore.Dtos.Auth;
+
+namespace ERPCore.Infra.Auth.AuthHex;
+
+///
+/// Typed client for the external AuthHex identity service (API_REFERENCE.md).
+/// Hides AuthHex's `functionName` dispatcher entirely — callers get one method
+/// per function. Internal: only Services/Auth consumes this; the controller
+/// boundary only ever sees Dtos/Auth types.
+///
+public interface IAuthHexClient
+{
+ // UserManager (POST /api/user)
+ Task RegisterAsync(RegisterRequest request, CancellationToken ct);
+ Task LoginAsync(LoginRequest request, CancellationToken ct);
+ Task VerifyOtpForLoginAsync(VerifyOtpForLoginRequest request, CancellationToken ct);
+ Task RefreshTokenAsync(string refreshToken, string? deviceName, CancellationToken ct);
+ Task GetUserDetailsAsync(Guid userId, CancellationToken ct);
+ Task> GetUserSessionsAsync(string bearerToken, CancellationToken ct);
+ Task ChangeUserStatusAsync(bool isActive, string bearerToken, CancellationToken ct);
+ Task LockUserAccountAsync(bool isLocked, string bearerToken, CancellationToken ct);
+ Task ChangeUserPasswordAsync(ChangeUserPasswordRequest request, string bearerToken, CancellationToken ct);
+ Task VerifyPasswordAsync(VerifyPasswordRequest request, string bearerToken, CancellationToken ct);
+ Task LogoutUserAsync(Guid userId, CancellationToken ct);
+ Task UpdateUserAsync(UpdateUserRequest request, string bearerToken, CancellationToken ct);
+ Task InitiateTwoFaSetupAsync(string bearerToken, CancellationToken ct);
+ Task CompleteTwoFaSetupAsync(CompleteTwoFaSetupRequest request, string bearerToken, CancellationToken ct);
+ Task VerifyTwoFaAsync(VerifyTwoFaRequest request, string bearerToken, CancellationToken ct);
+ Task DisableTwoFaAsync(DisableTwoFaRequest request, string bearerToken, CancellationToken ct);
+ Task GetTwoFaStatusAsync(string bearerToken, CancellationToken ct);
+
+ // RecoveryManager (POST /api/recovery)
+ Task ForgotPasswordAsync(ForgotPasswordRequest request, CancellationToken ct);
+ Task VerifyRecoveryOtpAsync(VerifyRecoveryOtpRequest request, CancellationToken ct);
+ Task ResetPasswordAsync(ResetPasswordRequest request, CancellationToken ct);
+ Task ResetPasswordWithTokenAsync(ResetPasswordWithTokenRequest request, CancellationToken ct);
+
+ // AltOptionManager (POST /api/alt)
+ Task IsAvailableAsync(IsAvailableRequest request, CancellationToken ct);
+ Task SendOtpAsync(SendOtpRequest request, CancellationToken ct);
+ Task VerifyAltOtpAsync(VerifyAltOtpRequest request, CancellationToken ct);
+}
diff --git a/Backend/ERPCore/Infra/Auth/JwtAuthExtensions.cs b/Backend/ERPCore/Infra/Auth/JwtAuthExtensions.cs
index ea879ca..658433f 100644
--- a/Backend/ERPCore/Infra/Auth/JwtAuthExtensions.cs
+++ b/Backend/ERPCore/Infra/Auth/JwtAuthExtensions.cs
@@ -18,6 +18,18 @@ public static class JwtAuthExtensions
/// Authorization policy applied to every v1 controller (via ApiControllerBase).
public const string ErpAccessPolicy = "ErpAccess";
+ /// httpOnly cookie AuthController writes the AuthHex access token into (docs/02-SECURITY.md §B.2).
+ public const string AccessTokenCookie = "erp_at";
+
+ /// httpOnly cookie AuthController writes the AuthHex refresh token into, scoped to the refresh route.
+ public const string RefreshTokenCookie = "erp_rt";
+
+ /// Non-httpOnly CSRF cookie for the double-submit check on cookie-authenticated mutations.
+ public const string CsrfCookie = "XSRF-TOKEN";
+
+ /// Header the frontend echoes the CSRF cookie value back through.
+ public const string CsrfHeader = "X-XSRF-TOKEN";
+
public static IServiceCollection AddErpJwtAuth(this IServiceCollection services, IConfiguration config)
{
var issuer = config["Auth:Issuer"];
@@ -49,6 +61,23 @@ public static class JwtAuthExtensions
ValidAlgorithms = new[] { SecurityAlgorithms.RsaSha256 },
ClockSkew = TimeSpan.FromSeconds(30)
};
+ // BFF cookie fallback: browsers hitting ERPCore through AuthController's
+ // httpOnly cookie session carry no Authorization header. Only used when
+ // that header is absent, so Bearer callers (Swagger, service-to-service,
+ // AuthHexClient forwarding) are unaffected.
+ options.Events = new JwtBearerEvents
+ {
+ OnMessageReceived = context =>
+ {
+ if (string.IsNullOrEmpty(context.Token) &&
+ context.Request.Cookies.TryGetValue(AccessTokenCookie, out var cookieToken) &&
+ !string.IsNullOrEmpty(cookieToken))
+ {
+ context.Token = cookieToken;
+ }
+ return Task.CompletedTask;
+ }
+ };
});
services.AddAuthorization(options =>
diff --git a/Backend/ERPCore/Infra/Auth/ValidateCsrfAttribute.cs b/Backend/ERPCore/Infra/Auth/ValidateCsrfAttribute.cs
new file mode 100644
index 0000000..cf4a631
--- /dev/null
+++ b/Backend/ERPCore/Infra/Auth/ValidateCsrfAttribute.cs
@@ -0,0 +1,35 @@
+using ERPCore.System.Errors;
+using Microsoft.AspNetCore.Mvc.Filters;
+
+namespace ERPCore.Infra.Auth;
+
+///
+/// Double-submit-cookie CSRF check for cookie-authenticated, state-changing
+/// AuthController actions (docs/02-SECURITY.md §B.2). Bearer-token callers
+/// (Swagger, service-to-service) are exempt — CSRF only threatens requests a
+/// browser sends automatically via cookies. Requires the X-XSRF-TOKEN
+/// header to match the non-httpOnly XSRF-TOKEN cookie AuthController
+/// issues alongside the session cookies.
+///
+public sealed class ValidateCsrfAttribute : Attribute, IAsyncActionFilter
+{
+ public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
+ {
+ var request = context.HttpContext.Request;
+
+ if (!string.IsNullOrEmpty(request.Headers.Authorization.ToString()))
+ {
+ await next();
+ return;
+ }
+
+ if (!request.Cookies.TryGetValue(JwtAuthExtensions.CsrfCookie, out var cookieToken) || string.IsNullOrEmpty(cookieToken))
+ throw new DomainException(ErrorCodes.CsrfTokenMismatch, "Missing CSRF cookie.", 403);
+
+ var headerToken = request.Headers[JwtAuthExtensions.CsrfHeader].ToString();
+ if (string.IsNullOrEmpty(headerToken) || !string.Equals(headerToken, cookieToken, StringComparison.Ordinal))
+ throw new DomainException(ErrorCodes.CsrfTokenMismatch, "CSRF token mismatch.", 403);
+
+ await next();
+ }
+}
diff --git a/Backend/ERPCore/Program.cs b/Backend/ERPCore/Program.cs
index 635ec52..75b3156 100644
--- a/Backend/ERPCore/Program.cs
+++ b/Backend/ERPCore/Program.cs
@@ -1,10 +1,12 @@
using System.Text.Json.Serialization;
using ERPCore.Infra.Auth;
+using ERPCore.Infra.Auth.AuthHex;
using ERPCore.Infra.Persistence;
using ERPCore.Infra.UoW;
using ERPCore.Repositories;
using ERPCore.Repositories.Interfaces;
using ERPCore.Services;
+using ERPCore.Services.Auth;
using ERPCore.Services.Interfaces;
using ERPCore.Services.Stock;
using ERPCore.System.Errors;
@@ -35,6 +37,17 @@ builder.Services.AddExceptionHandler();
// Auth: validate external AuthHex RS256 tokens + ERP door policy (docs/10 A.4)
builder.Services.AddErpJwtAuth(builder.Configuration);
+// AuthController proxy → AuthHex (docs/11 §2.0)
+builder.Services.AddHttpClient(c =>
+{
+ var baseUrl = builder.Configuration["AuthHex:BaseUrl"]
+ ?? throw new InvalidOperationException("AuthHex:BaseUrl is not configured.");
+ c.BaseAddress = new Uri(baseUrl);
+});
+builder.Services.AddScoped();
+builder.Services.AddScoped();
+builder.Services.AddScoped();
+
// Current-user (audit actor). AuthHex has no sub/nameid → a claims transformation
// JIT-provisions a local shadow user and injects the local `int` id as `nameid`.
builder.Services.AddHttpContextAccessor();
diff --git a/Backend/ERPCore/Services/Auth/AuthAltService.cs b/Backend/ERPCore/Services/Auth/AuthAltService.cs
new file mode 100644
index 0000000..15cb7ec
--- /dev/null
+++ b/Backend/ERPCore/Services/Auth/AuthAltService.cs
@@ -0,0 +1,41 @@
+using ERPCore.Dtos.Auth;
+using ERPCore.Infra.Auth.AuthHex;
+using ERPCore.Services.Interfaces;
+using ERPCore.System.Errors;
+
+namespace ERPCore.Services.Auth;
+
+///
+public sealed class AuthAltService : IAuthAltService
+{
+ private readonly IAuthHexClient _authHex;
+
+ public AuthAltService(IAuthHexClient authHex) => _authHex = authHex;
+
+ public Task IsAvailableAsync(IsAvailableRequest request, CancellationToken ct = default)
+ => _authHex.IsAvailableAsync(request, ct);
+
+ public Task SendOtpAsync(SendOtpRequest request, CancellationToken ct = default)
+ => _authHex.SendOtpAsync(request, ct);
+
+ public async Task VerifyOtpAsync(VerifyAltOtpRequest request, CancellationToken ct = default)
+ {
+ var result = await _authHex.VerifyAltOtpAsync(request, ct);
+ if (result is null || string.IsNullOrEmpty(result.AccessToken) || string.IsNullOrEmpty(result.RefreshToken))
+ throw new DomainException(ErrorCodes.AuthUpstreamError, "The authentication service did not return a session.", 502);
+
+ return new OtpAuthSessionResult
+ {
+ AccessToken = result.AccessToken,
+ RefreshToken = result.RefreshToken,
+ Body = new OtpLoginVerifiedResponse
+ {
+ ReferenceNumber = result.ReferenceNumber,
+ UserId = result.UserId,
+ Verified = result.Verified ?? true,
+ User = result.User,
+ ExpiresIn = result.ExpiresIn
+ }
+ };
+ }
+}
diff --git a/Backend/ERPCore/Services/Auth/AuthRecoveryService.cs b/Backend/ERPCore/Services/Auth/AuthRecoveryService.cs
new file mode 100644
index 0000000..863b69f
--- /dev/null
+++ b/Backend/ERPCore/Services/Auth/AuthRecoveryService.cs
@@ -0,0 +1,25 @@
+using ERPCore.Dtos.Auth;
+using ERPCore.Infra.Auth.AuthHex;
+using ERPCore.Services.Interfaces;
+
+namespace ERPCore.Services.Auth;
+
+///
+public sealed class AuthRecoveryService : IAuthRecoveryService
+{
+ private readonly IAuthHexClient _authHex;
+
+ public AuthRecoveryService(IAuthHexClient authHex) => _authHex = authHex;
+
+ public Task ForgotPasswordAsync(ForgotPasswordRequest request, CancellationToken ct = default)
+ => _authHex.ForgotPasswordAsync(request, ct);
+
+ public Task VerifyOtpAsync(VerifyRecoveryOtpRequest request, CancellationToken ct = default)
+ => _authHex.VerifyRecoveryOtpAsync(request, ct);
+
+ public Task ResetPasswordAsync(ResetPasswordRequest request, CancellationToken ct = default)
+ => _authHex.ResetPasswordAsync(request, ct);
+
+ public Task ResetPasswordWithTokenAsync(ResetPasswordWithTokenRequest request, CancellationToken ct = default)
+ => _authHex.ResetPasswordWithTokenAsync(request, ct);
+}
diff --git a/Backend/ERPCore/Services/Auth/AuthSessionResult.cs b/Backend/ERPCore/Services/Auth/AuthSessionResult.cs
new file mode 100644
index 0000000..0622e4e
--- /dev/null
+++ b/Backend/ERPCore/Services/Auth/AuthSessionResult.cs
@@ -0,0 +1,25 @@
+using ERPCore.Dtos.Auth;
+
+namespace ERPCore.Services.Auth;
+
+///
+/// Carries a freshly issued AuthHex session from a Services/Auth method back to
+/// AuthController. Never serialized directly — the controller pulls
+/// AccessToken/RefreshToken into httpOnly cookies (see AuthCookieWriter) and
+/// returns only in the response.
+///
+public sealed class AuthSessionResult
+{
+ public required string AccessToken { get; init; }
+ public required string RefreshToken { get; init; }
+ public required AuthSessionResponse Body { get; init; }
+}
+
+/// Same purpose as , for the two OTP-verify
+/// flows whose body also carries ReferenceNumber/Verified alongside the user/session.
+public sealed class OtpAuthSessionResult
+{
+ public required string AccessToken { get; init; }
+ public required string RefreshToken { get; init; }
+ public required OtpLoginVerifiedResponse Body { get; init; }
+}
diff --git a/Backend/ERPCore/Services/Auth/AuthUserService.cs b/Backend/ERPCore/Services/Auth/AuthUserService.cs
new file mode 100644
index 0000000..277d493
--- /dev/null
+++ b/Backend/ERPCore/Services/Auth/AuthUserService.cs
@@ -0,0 +1,111 @@
+using ERPCore.Dtos.Auth;
+using ERPCore.Infra.Auth.AuthHex;
+using ERPCore.Services.Interfaces;
+using ERPCore.System.Errors;
+
+namespace ERPCore.Services.Auth;
+
+///
+public sealed class AuthUserService : IAuthUserService
+{
+ private readonly IAuthHexClient _authHex;
+
+ public AuthUserService(IAuthHexClient authHex) => _authHex = authHex;
+
+ public async Task RegisterAsync(RegisterRequest request, CancellationToken ct = default)
+ {
+ request.UserId ??= Guid.NewGuid();
+ var result = await _authHex.RegisterAsync(request, ct);
+ return ToSessionResult(result);
+ }
+
+ public async Task LoginAsync(LoginRequest request, CancellationToken ct = default)
+ {
+ var result = await _authHex.LoginAsync(request, ct);
+ return ToSessionResult(result);
+ }
+
+ public async Task VerifyOtpForLoginAsync(VerifyOtpForLoginRequest request, CancellationToken ct = default)
+ {
+ var result = await _authHex.VerifyOtpForLoginAsync(request, ct);
+ return ToOtpSessionResult(result);
+ }
+
+ public async Task RefreshTokenAsync(string refreshToken, RefreshTokenRequest request, CancellationToken ct = default)
+ {
+ var result = await _authHex.RefreshTokenAsync(refreshToken, request.DeviceName, ct);
+ return ToSessionResult(result);
+ }
+
+ public Task GetUserDetailsAsync(Guid userId, CancellationToken ct = default)
+ => _authHex.GetUserDetailsAsync(userId, ct);
+
+ public Task> GetUserSessionsAsync(string bearerToken, CancellationToken ct = default)
+ => _authHex.GetUserSessionsAsync(bearerToken, ct);
+
+ public Task ChangeUserStatusAsync(ChangeUserStatusRequest request, string bearerToken, CancellationToken ct = default)
+ => _authHex.ChangeUserStatusAsync(request.IsActive, bearerToken, ct);
+
+ public Task LockUserAccountAsync(LockUserAccountRequest request, string bearerToken, CancellationToken ct = default)
+ => _authHex.LockUserAccountAsync(request.IsLocked, bearerToken, ct);
+
+ public Task ChangeUserPasswordAsync(ChangeUserPasswordRequest request, string bearerToken, CancellationToken ct = default)
+ => _authHex.ChangeUserPasswordAsync(request, bearerToken, ct);
+
+ public Task VerifyPasswordAsync(VerifyPasswordRequest request, string bearerToken, CancellationToken ct = default)
+ => _authHex.VerifyPasswordAsync(request, bearerToken, ct);
+
+ public Task LogoutUserAsync(LogoutRequest request, CancellationToken ct = default)
+ => _authHex.LogoutUserAsync(request.UserId, ct);
+
+ public Task UpdateUserAsync(UpdateUserRequest request, string bearerToken, CancellationToken ct = default)
+ => _authHex.UpdateUserAsync(request, bearerToken, ct);
+
+ public Task InitiateTwoFaSetupAsync(string bearerToken, CancellationToken ct = default)
+ => _authHex.InitiateTwoFaSetupAsync(bearerToken, ct);
+
+ public Task CompleteTwoFaSetupAsync(CompleteTwoFaSetupRequest request, string bearerToken, CancellationToken ct = default)
+ => _authHex.CompleteTwoFaSetupAsync(request, bearerToken, ct);
+
+ public Task VerifyTwoFaAsync(VerifyTwoFaRequest request, string bearerToken, CancellationToken ct = default)
+ => _authHex.VerifyTwoFaAsync(request, bearerToken, ct);
+
+ public Task DisableTwoFaAsync(DisableTwoFaRequest request, string bearerToken, CancellationToken ct = default)
+ => _authHex.DisableTwoFaAsync(request, bearerToken, ct);
+
+ public Task GetTwoFaStatusAsync(string bearerToken, CancellationToken ct = default)
+ => _authHex.GetTwoFaStatusAsync(bearerToken, ct);
+
+ private static AuthSessionResult ToSessionResult(AuthHexSessionResult? result)
+ {
+ if (result is null || string.IsNullOrEmpty(result.AccessToken) || string.IsNullOrEmpty(result.RefreshToken))
+ throw new DomainException(ErrorCodes.AuthUpstreamError, "The authentication service did not return a session.", 502);
+
+ return new AuthSessionResult
+ {
+ AccessToken = result.AccessToken,
+ RefreshToken = result.RefreshToken,
+ Body = new AuthSessionResponse { User = result.User, ExpiresIn = result.ExpiresIn }
+ };
+ }
+
+ private static OtpAuthSessionResult ToOtpSessionResult(AuthHexSessionResult? result)
+ {
+ if (result is null || string.IsNullOrEmpty(result.AccessToken) || string.IsNullOrEmpty(result.RefreshToken))
+ throw new DomainException(ErrorCodes.AuthUpstreamError, "The authentication service did not return a session.", 502);
+
+ return new OtpAuthSessionResult
+ {
+ AccessToken = result.AccessToken,
+ RefreshToken = result.RefreshToken,
+ Body = new OtpLoginVerifiedResponse
+ {
+ ReferenceNumber = result.ReferenceNumber,
+ UserId = result.UserId,
+ Verified = result.Verified ?? true,
+ User = result.User,
+ ExpiresIn = result.ExpiresIn
+ }
+ };
+ }
+}
diff --git a/Backend/ERPCore/Services/Interfaces/IAuthAltService.cs b/Backend/ERPCore/Services/Interfaces/IAuthAltService.cs
new file mode 100644
index 0000000..10c5c8d
--- /dev/null
+++ b/Backend/ERPCore/Services/Interfaces/IAuthAltService.cs
@@ -0,0 +1,12 @@
+using ERPCore.Dtos.Auth;
+using ERPCore.Services.Auth;
+
+namespace ERPCore.Services.Interfaces;
+
+/// AltOptionManager proxy business logic (API_REFERENCE.md §5), fronting AuthHex.
+public interface IAuthAltService
+{
+ Task IsAvailableAsync(IsAvailableRequest request, CancellationToken ct = default);
+ Task SendOtpAsync(SendOtpRequest request, CancellationToken ct = default);
+ Task VerifyOtpAsync(VerifyAltOtpRequest request, CancellationToken ct = default);
+}
diff --git a/Backend/ERPCore/Services/Interfaces/IAuthRecoveryService.cs b/Backend/ERPCore/Services/Interfaces/IAuthRecoveryService.cs
new file mode 100644
index 0000000..cb807c8
--- /dev/null
+++ b/Backend/ERPCore/Services/Interfaces/IAuthRecoveryService.cs
@@ -0,0 +1,12 @@
+using ERPCore.Dtos.Auth;
+
+namespace ERPCore.Services.Interfaces;
+
+/// RecoveryManager proxy business logic (API_REFERENCE.md §4), fronting AuthHex.
+public interface IAuthRecoveryService
+{
+ Task ForgotPasswordAsync(ForgotPasswordRequest request, CancellationToken ct = default);
+ Task VerifyOtpAsync(VerifyRecoveryOtpRequest request, CancellationToken ct = default);
+ Task ResetPasswordAsync(ResetPasswordRequest request, CancellationToken ct = default);
+ Task ResetPasswordWithTokenAsync(ResetPasswordWithTokenRequest request, CancellationToken ct = default);
+}
diff --git a/Backend/ERPCore/Services/Interfaces/IAuthUserService.cs b/Backend/ERPCore/Services/Interfaces/IAuthUserService.cs
new file mode 100644
index 0000000..785b50c
--- /dev/null
+++ b/Backend/ERPCore/Services/Interfaces/IAuthUserService.cs
@@ -0,0 +1,26 @@
+using ERPCore.Dtos.Auth;
+using ERPCore.Services.Auth;
+
+namespace ERPCore.Services.Interfaces;
+
+/// UserManager proxy business logic (API_REFERENCE.md §3), fronting AuthHex.
+public interface IAuthUserService
+{
+ Task RegisterAsync(RegisterRequest request, CancellationToken ct = default);
+ Task LoginAsync(LoginRequest request, CancellationToken ct = default);
+ Task VerifyOtpForLoginAsync(VerifyOtpForLoginRequest request, CancellationToken ct = default);
+ Task RefreshTokenAsync(string refreshToken, RefreshTokenRequest request, CancellationToken ct = default);
+ Task GetUserDetailsAsync(Guid userId, CancellationToken ct = default);
+ Task> GetUserSessionsAsync(string bearerToken, CancellationToken ct = default);
+ Task ChangeUserStatusAsync(ChangeUserStatusRequest request, string bearerToken, CancellationToken ct = default);
+ Task LockUserAccountAsync(LockUserAccountRequest request, string bearerToken, CancellationToken ct = default);
+ Task ChangeUserPasswordAsync(ChangeUserPasswordRequest request, string bearerToken, CancellationToken ct = default);
+ Task VerifyPasswordAsync(VerifyPasswordRequest request, string bearerToken, CancellationToken ct = default);
+ Task LogoutUserAsync(LogoutRequest request, CancellationToken ct = default);
+ Task UpdateUserAsync(UpdateUserRequest request, string bearerToken, CancellationToken ct = default);
+ Task InitiateTwoFaSetupAsync(string bearerToken, CancellationToken ct = default);
+ Task CompleteTwoFaSetupAsync(CompleteTwoFaSetupRequest request, string bearerToken, CancellationToken ct = default);
+ Task VerifyTwoFaAsync(VerifyTwoFaRequest request, string bearerToken, CancellationToken ct = default);
+ Task DisableTwoFaAsync(DisableTwoFaRequest request, string bearerToken, CancellationToken ct = default);
+ Task GetTwoFaStatusAsync(string bearerToken, CancellationToken ct = default);
+}
diff --git a/Backend/ERPCore/System/Errors/ErrorCodes.cs b/Backend/ERPCore/System/Errors/ErrorCodes.cs
index 48a544b..a86e142 100644
--- a/Backend/ERPCore/System/Errors/ErrorCodes.cs
+++ b/Backend/ERPCore/System/Errors/ErrorCodes.cs
@@ -24,4 +24,10 @@ public static class ErrorCodes
public const string ReasonCodeRequired = "REASON_CODE_REQUIRED";
public const string ConcurrencyConflict = "CONCURRENCY_CONFLICT";
public const string IdempotencyReplay = "IDEMPOTENCY_REPLAY";
+
+ // Auth proxy (AuthController → AuthHex, docs/11 §2.0)
+ public const string AuthUpstreamError = "AUTH_UPSTREAM_ERROR";
+ public const string AuthServiceUnavailable = "AUTH_SERVICE_UNAVAILABLE";
+ public const string CsrfTokenMismatch = "CSRF_TOKEN_MISMATCH";
+ public const string RefreshTokenMissing = "REFRESH_TOKEN_MISSING";
}
diff --git a/Backend/ERPCore/appsettings.Development.json b/Backend/ERPCore/appsettings.Development.json
index 3d13631..28ac658 100644
--- a/Backend/ERPCore/appsettings.Development.json
+++ b/Backend/ERPCore/appsettings.Development.json
@@ -7,5 +7,8 @@
},
"ConnectionStrings": {
"DefaultConnection": "Host=localhost;Port=5432;Database=ERPCore;Username=postgres;Password=root"
+ },
+ "AuthHex": {
+ "BaseUrl": "http://localhost:5011"
}
}
diff --git a/Backend/ERPCore/appsettings.Production.json b/Backend/ERPCore/appsettings.Production.json
index 838af85..f2762ae 100644
--- a/Backend/ERPCore/appsettings.Production.json
+++ b/Backend/ERPCore/appsettings.Production.json
@@ -4,5 +4,8 @@
},
"Jwt": {
"SigningKey": ""
+ },
+ "AuthHex": {
+ "BaseUrl": ""
}
}
diff --git a/Backend/ERPCore/appsettings.json b/Backend/ERPCore/appsettings.json
index d49cb2d..8645532 100644
--- a/Backend/ERPCore/appsettings.json
+++ b/Backend/ERPCore/appsettings.json
@@ -15,5 +15,8 @@
"RequiredUserTypeCode": "",
"RequiredRoleCode": ""
},
+ "AuthHex": {
+ "BaseUrl": "CHANGE_ME"
+ },
"AllowedHosts": "*"
}
diff --git a/Backend/PROGRESS.md b/Backend/PROGRESS.md
index 7e710c6..fa8ccd2 100644
--- a/Backend/PROGRESS.md
+++ b/Backend/PROGRESS.md
@@ -59,6 +59,7 @@ Convention: `docs/01-DOC-GUIDE.md §6`. Update this file in the **same commit**
- [x] Audit log on every mutation (who/when/old→new) — `AuditLog` (jsonb `changeSet`), written by an `ErpDbContext.SaveChanges` override (`AuditScribe`): Create captures the field set, Update captures **only changed fields as {old,new}**, Delete captures the prior row; PK/RowVersion excluded; ledger/layer/seq/self/journal excluded. Actor from `ICurrentUser` (system=1 until auth). Read via `GET /audit-logs`. **Verified** (Item create+update old→new; StockAdjustment create). This is the **AR-01 compensating control** (02-SECURITY B.3) — app-level append-only; DB-role UPDATE/DELETE revoke still deferred.
- [x] Document numbering sequences (per type, per year) — `NumberSequence` + `NumberSequenceService` (atomic `INSERT … ON CONFLICT … RETURNING` inside the doc's UoW txn; gap-controlled). Verified issuing + incrementing PR/RFQ/PO.
- [x] Auth: **external AuthHex IdP integration** (2026-07-14) — ERPCore is a resource server. `JwtAuthExtensions` validates **RS256** against AuthHex's RSA **public** key (config `Auth:RsaPublicKeyXml` → `RsaSecurityKey`; `MapInboundClaims=false`), issuer `AuthHex`, audience `AuthHexClient` (no JWKS → static key). `[Authorize(ErpAccess)]` on `ApiControllerBase` gates every v1 endpoint; the `ErpAccess` policy `RequireAuthenticatedUser` + optional `RequireClaim(UserTypeCode/RoleCode)` from `Auth:RequiredUserTypeCode`/`RequiredRoleCode` (empty ⇒ any valid ERP token — AuthHex is ERP-dedicated). **Shadow-user JIT provisioning:** `ShadowUserClaimsTransformation` (`IClaimsTransformation`) maps the token's `UserId` **GUID** → a local `users` row (`auth_user_id` unique; Username/DisplayName = `NIC`), idempotent, and injects the local `int` id as `nameid` so `ICurrentUser.AuditUserId` resolves the real actor. Migration `AddAuthUserId`. **Verified:** no token→401; `/health`,`/api/meta`,Swagger anonymous; valid token→200; shadow user provisioned (User 2, Username=NIC, AuthUserId=GUID); item Create **audited as the shadow user (id 2, not system)**; re-request reuses the same user; door gate → **403** on UserType mismatch, **200** on match.
+- [x] Auth proxy: **`AuthController` fronting AuthHex** (2026-07-16) — the frontend no longer calls AuthHex directly; `Controllers/AuthController.cs` + `Services/Auth/{AuthUserService,AuthRecoveryService,AuthAltService}` proxy all 24 AuthHex functions (register/login/OTP-login/refresh/sessions/status/lock/change-password/verify-password/logout/update/2FA×5/recovery×4/alt×3) via `Infra/Auth/AuthHex/{IAuthHexClient,AuthHexClient}` (`AuthHex:BaseUrl` config). Sessions delivered as httpOnly Secure `erp_at`/`erp_rt` cookies + `XSRF-TOKEN` double-submit cookie (`Infra/Auth/AuthCookieWriter.cs`, 02-SECURITY §B.2); `ValidateCsrfAttribute` guards every mutating action; the JWT bearer handler now also accepts `erp_at` as a fallback (`JwtAuthExtensions`'s `OnMessageReceived`) so every other v1 controller keeps working unchanged. See `docs/11-BACKEND-PHASE1.md §2.0` for the full route table and `docs/02-SECURITY.md` AR-07/AR-08 for the two carried-over exposures (anonymous `getUserDetails`/`LogoutUser`, no rate limiting yet). **Not done this pass:** CORS (needed once a browser frontend calls these endpoints cross-origin), rate limiting, and the frontend wiring itself (`lib/api/auth.ts` + login/OTP/reset pages) — all deliberately deferred follow-ups.
- [x] JournalEntryStub emitted per stock movement (data only) — `JournalEntryStub` written in `FifoCostingService.PostLedgerAsync` for every ledger entry (In → Dr Inventory `1300` / Cr Clearing `2100`; Out reverses; amount = movement value). Placeholder accounts until a chart of accounts exists. Read via `GET /journal-entries`. **Verified** (GRN In 700, ADJ Out 70).
- [x] Negative-stock policy enforcement (default block) — enforced in `FifoCostingService.ConsumeAsync` → `409 STOCK_NEGATIVE_BLOCKED` (verified). Per-item override still a config stub.
- [~] FEFO picking for perishables; block expired / on-hold issue — **issue-block done + verified** (`ONHOLD_NOT_ISSUABLE`, `EXPIRED_BATCH_BLOCKED`; on-hold/expired layers excluded from consume). FEFO *pick ordering* (oldest-expiry first) not yet built.
@@ -133,3 +134,16 @@ Convention: `docs/01-DOC-GUIDE.md §6`. Update this file in the **same commit**
- **Shadow-user provisioning:** `ShadowUserClaimsTransformation` (`IClaimsTransformation`, scoped) maps token `UserId` GUID → local `users` row (`auth_user_id` unique, Username/DisplayName=`NIC`), idempotent w/ race-safe re-read, injects local `int` id as `nameid`. `User.AuthUserId` (Guid?) + `AuthHexClaims` consts + migration `AddAuthUserId`.
- **Verified (minted AuthHex-shaped RS256 token, signed with AuthHex's real private key):** no token→401; `/health`,`/api/meta`,Swagger→200 anon; valid token→200; POST item→201 **audited as shadow user id 2** (Username=NIC, AuthUserId=GUID), not system; repeat request reuses user (1 provision); door gate `RequiredUserTypeCode=WAREHOUSE` → ERP-type token **403**, WAREHOUSE-type token **200**. Build clean; migration applied.
- **§6 COMPLETE.** Only intentional deferral left in Phase 1: FEFO pick-ordering (§6, `[~]`). Follow-ups: set the real ERP `Auth:RequiredUserTypeCode`/`RoleCode` for production; secure the RSA key rotation process.
+
+### 2026-07-16 — Auth proxy: `AuthController` fronting AuthHex (frontend no longer calls AuthHex directly)
+- **Architecture reversal:** the 2026-07-14 "resource-server-only, no login proxy" decision (docs/10, docs/11 §2.0) is reversed — the frontend was found to have **zero** existing AuthHex integration (login/OTP/reset screens were UI-only mocks with no network calls), so this was greenfield backend work, not a migration. `docs/10-BACKEND-PHASE1.md` (header, A.4, NFR-03) and `docs/11-BACKEND-PHASE1.md §2.0` updated in place; `docs/02-SECURITY.md` gained AR-07/AR-08 and ticked 3 of 5 B.2 boxes.
+- **`Infra/Auth/AuthHex/`** — `IAuthHexClient`/`AuthHexClient` (typed `HttpClient`, `AuthHex:BaseUrl` config = `http://localhost:5011` dev), one C# method per AuthHex `functionName`, hides the `{functionName,payload,reference}`/`{statusCode,success,message,data}` dispatcher envelope entirely; upstream failures → `DomainException` (`AUTH_UPSTREAM_ERROR`/`AUTH_SERVICE_UNAVAILABLE`).
+- **`Dtos/Auth/*`** — REST-shaped request/response DTOs per function (not a functionName-dispatcher passthrough), matching ERPCore's existing DTO-at-boundary convention. Session-issuing responses (`AuthSessionResponse`, `OtpLoginVerifiedResponse`) deliberately omit tokens.
+- **`Services/Auth/{AuthUserService,AuthRecoveryService,AuthAltService}`** — orchestrate `IAuthHexClient` calls; `AuthSessionResult`/`OtpAuthSessionResult` (`Services/Auth/AuthSessionResult.cs`) carry tokens from service → controller only, never serialized.
+- **`Controllers/AuthController.cs`** — `api/v1/auth/*`, 24 actions (see `docs/11 §2.0` table); inherits `ControllerBase` directly (not `ApiControllerBase`) since most actions need `[AllowAnonymous]` and its ETag/If-Match handling doesn't apply here.
+- **Cookie/CSRF (`Infra/Auth/AuthCookieWriter.cs`, `ValidateCsrfAttribute.cs`, `JwtAuthExtensions.cs`):** `erp_at` (Path `/`), `erp_rt` (Path `/api/v1/auth/refresh-token`, scoped so it's only sent to the refresh call), `XSRF-TOKEN` (non-httpOnly) — all `HttpOnly`(except CSRF)/`Secure`/`SameSite=Strict`. `ValidateCsrfAttribute` double-submit-checks `X-XSRF-TOKEN` against the cookie on every mutating action, exempting Bearer-header callers. `JwtAuthExtensions`'s `OnMessageReceived` falls back to the `erp_at` cookie when no `Authorization` header is present — every existing v1 controller keeps working unchanged under either auth mode.
+- **Verified:** `dotnet build` clean (0 warn/0 err) after two passes — first pass hit `CS0051` (a public interface/constructor can't expose an `internal` parameter type) on `IAuthHexClient` and its supporting `AuthHex*` wire types, fixed by making them `public`; second pass caught a cookie-path bug (`erp_rt`'s `Path` was written as `/api/auth/refresh-token`, not matching the actual `/api/v1/auth/refresh-token` route — the browser would never have sent the cookie back on refresh) before it shipped.
+- **Not done this pass (tracked as follow-ups, not silently skipped):** CORS (needed once a browser frontend calls cross-origin — `docs/02-SECURITY.md §B.2` left unticked), rate limiting on the anonymous endpoints (`docs/02-SECURITY.md` AR-08), and the frontend wiring itself (`lib/api/auth.ts` + wiring `app/login/**`'s mock pages to these endpoints) — deliberately out of scope per user decision.
+- **Live-verified against the running AuthHex instance (`:5011`) and ERPCore (`:5224`, dev):** `register` → `200` with `Set-Cookie: erp_at`(httpOnly/Secure/Strict/maxAge=3600) + `erp_rt`(httpOnly/Secure/Strict/Path=`/api/v1/auth/refresh-token`/30d) + `XSRF-TOKEN`(Secure/Strict, JS-readable), body carries `user`+`expiresIn` only, **no tokens**; the `erp_at` cookie alone (zero `Authorization` header) authenticated `GET /api/v1/items` — confirms the `OnMessageReceived` cookie fallback works for every existing v1 controller unchanged; `GET /api/v1/auth/sessions` (protected, cookie-authenticated) → `200`; mutating `POST /api/v1/auth/change-password` without `X-XSRF-TOKEN` → `403 CSRF_TOKEN_MISMATCH`, with the matching header → `204` + all three cookies cleared, exactly as designed.
+- **Found + fixed a real bug during live testing:** `AuthHexClient` trusted the envelope's `success` flag alone; AuthHex was observed returning **`HTTP 500` with `"success": true, "data": null`** on a business failure (invalid-credentials login), which slipped past the `!envelope.Success` check and null-derefed inside `AuthUserService.ToSessionResult` (`NullReferenceException` → bare unhandled `500`, no `code`). Fixed `AuthHexClient.CallAsync` to also fail on `!httpResponse.IsSuccessStatusCode` regardless of `envelope.Success`, plus added `result is null` guards in `ToSessionResult`/`ToOtpSessionResult`/`AuthAltService.VerifyOtpAsync` as defense-in-depth. Re-verified: the same invalid-credentials case now returns a clean `500 AUTH_UPSTREAM_ERROR` ProblemDetails instead of crashing.
+- **Login/refresh/logout left unverified live** — `loginUser` currently fails with `{"statusCode":500,"success":true,"message":"Invalid credentials","data":null}` on AuthHex **even immediately after a fresh registration with the exact same credentials**, confirmed by calling AuthHex's `/api/user` directly with the identical payload (bypassing ERPCore entirely) — this is a bug in AuthHex's own `loginUser`/password-verification path, not in this proxy. Blocked on an AuthHex-side fix; re-run the register→login→cookie→refresh→logout pass once that's resolved.
diff --git a/docs/02-SECURITY.md b/docs/02-SECURITY.md
index 0adb00f..f8c166d 100644
--- a/docs/02-SECURITY.md
+++ b/docs/02-SECURITY.md
@@ -18,6 +18,8 @@ These are known, deliberately-accepted Phase-1 exposures. Each has a compensatin
| **AR-04** | **GRN-without-PO** — receive (and value) goods with no order; a fraud vector. | Emergency/direct receipt is useful (FR-GRN-02). | Permission-gated (when RBAC on); flagged for review; audit; cost entered here gets extra scrutiny (see C.3). | With RBAC / policy on direct receipts. |
| **AR-05** | **In-transit loss window** — dispatched-but-not-received stock is untracked shrinkage risk. | Inherent to in-transit transfers (FR-STK-05). | In-transit aging monitoring report. | Add stuck-transfer alert (Part D). |
| **AR-06** | **Localhost dev secrets** in `appsettings.Development.json`. | Local-dev convenience, current phase. | `.gitignore` + localhost only. | Before any shared/staging/prod → User Secrets / env vars; rotate. |
+| **AR-07** | **`getUserDetails` / `LogoutUser` callable without a bearer token** — `GET /api/v1/auth/users/{userId}` and `POST /api/v1/auth/logout` resolve the target user from the URL/payload, not the caller's session, so any anonymous caller can fetch a profile or log out an arbitrary user's sessions by GUID. | Carried over verbatim from AuthHex's own dispatcher contract (API_REFERENCE.md §3) — ERPCore's `AuthController` proxies it as-is rather than silently tightening a contract it doesn't own. | GUIDs are not enumerable; every call is written to `AuthEventLogs` upstream in AuthHex. | Revisit once AuthHex exposes a token-scoped variant, or add ERPCore-side rate limiting / auth requirement ahead of AuthHex. |
+| **AR-08** | **No rate limiting on `AuthController`'s anonymous endpoints** (login, register, refresh, recovery, OTP send/verify) — brute-force and account-enumeration exposure. | Not built in this pass (docs/11 §2.0, added 2026-07-16); AuthHex may rate-limit server-side but ERPCore does not add its own layer yet. | AuthHex's own lockout/backoff (per docs/10 NFR-03), immutable audit trail. | Add ASP.NET Core rate limiting middleware to `AuthController` before any non-local deployment. |
---
@@ -33,11 +35,11 @@ These are known, deliberately-accepted Phase-1 exposures. Each has a compensatin
- [ ] Generic auth-failure messages (no account-enumeration signal)
### B.2 Token storage & CSRF *(httpOnly-cookie decision)*
-- [ ] Token in an **httpOnly, Secure** cookie (never localStorage) — removes XSS token theft
-- [ ] `SameSite=Strict` (or `Lax`) on the auth cookie
-- [ ] **CSRF protection on every state-changing request** (anti-forgery / double-submit token) — cookies are sent automatically, so CSRF is now in scope
-- [ ] CORS locked to the known frontend origin(s); credentials mode aligned with the cookie
-- [ ] Cookie scoped minimally (path/domain), Secure flag on
+- [x] Token in an **httpOnly, Secure** cookie (never localStorage) — removes XSS token theft (`AuthCookieWriter.WriteSession`, `erp_at`/`erp_rt`, 2026-07-16)
+- [x] `SameSite=Strict` on the auth cookie (`AuthCookieWriter`; assumes frontend + ERPCore share a registrable domain — revisit if deployed cross-domain)
+- [x] **CSRF protection on every state-changing request** (double-submit `XSRF-TOKEN` cookie + `X-XSRF-TOKEN` header, `ValidateCsrfAttribute`, applied to every mutating `AuthController` action; Bearer-header callers exempt since they aren't cookie-driven)
+- [ ] CORS locked to the known frontend origin(s); credentials mode aligned with the cookie — **not yet configured**; required before any browser frontend can call these endpoints cross-origin (tracked with the frontend-wiring follow-up)
+- [x] Cookie scoped minimally (path/domain), Secure flag on (`erp_rt` scoped to `/api/v1/auth/refresh-token`; all three cookies `Secure=true`)
### B.3 Audit integrity *(this is the compensating control for AR-01 — it must hold)*
- [ ] Audit log **and** stock ledger are append-only **at the DB level** (the app's DB role has no `UPDATE`/`DELETE` on those tables)
diff --git a/docs/10-BACKEND-PHASE1.md b/docs/10-BACKEND-PHASE1.md
index 591e8cb..089f757 100644
--- a/docs/10-BACKEND-PHASE1.md
+++ b/docs/10-BACKEND-PHASE1.md
@@ -3,7 +3,7 @@
> **Authoritative for:** backend architecture, business rules, and the data model (the 38-entity schema).
> **Navigation:** you arrived here from `00-CORE.md`. API request/response contracts are in `11-BACKEND-PHASE1.md`. Frontend rules are in `20-FRONTEND.md`. Record work in `Backend/PROGRESS.md`.
> **Scope basis:** SRS v1.1. Costing = FIFO · Multi-warehouse · Single-tenant · RBAC deferred (user identity stamped) · approvals auto/config-gated · vendor invoice + 3-way match deferred to Accounting.
-> **Authentication:** delegated to the **external AuthHex identity provider** (separate service). ERPCore is a **resource server** that only *validates* AuthHex's RS256 JWTs — it does not issue tokens or own a login endpoint. See A.4 (Authentication / Audit actor). RBAC (per-endpoint) still deferred.
+> **Authentication:** identity is owned by the **external AuthHex identity provider** (separate service), but as of 2026-07-16 the frontend no longer calls AuthHex directly — all login/registration/recovery/2FA/session traffic is proxied through ERPCore's own `AuthController` (`Controllers/AuthController.cs`, `Services/Auth/*`), which forwards to AuthHex and delivers the resulting session as httpOnly Secure cookies (docs/02-SECURITY.md §B.2). ERPCore still does not mint or sign tokens itself — it only forwards to and validates AuthHex's RS256 JWTs. See A.4 (Authentication / Audit actor) and `11-BACKEND-PHASE1.md §2.0` for the endpoint list. RBAC (per-endpoint) still deferred.
---
@@ -49,7 +49,8 @@ HTTP ─► Controller ─► Service ─► Repository ─► UnitOfWork / ErpD
## A.4 Cross-cutting
- **Errors:** RFC 7807 `ProblemDetails` (framework default). Domain exceptions in `System/Errors` carry a stable `code`; a middleware maps them to `ProblemDetails`. Catalog in `11-BACKEND-PHASE1.md §7`.
-- **Authentication:** ERPCore is a **resource server**. It validates JWTs issued by the **external AuthHex IdP** — algorithm **RS256** (asymmetric RSA), issuer `AuthHex`, audience `AuthHexClient`. AuthHex exposes **no JWKS/OIDC discovery**, so ERPCore is configured with AuthHex's **RSA public key statically** (rotation is a manual config update). Tokens live ~1000 min (prod) / 60 min (dev). A single **door authorization policy** requires an ERP `UserTypeCode`/`RoleCode` claim (AuthHex is a shared IdP, so a valid token alone is not enough); **per-endpoint RBAC stays deferred**.
+- **Authentication:** ERPCore validates JWTs issued by the **external AuthHex IdP** — algorithm **RS256** (asymmetric RSA), issuer `AuthHex`, audience `AuthHexClient`. AuthHex exposes **no JWKS/OIDC discovery**, so ERPCore is configured with AuthHex's **RSA public key statically** (rotation is a manual config update). Tokens live ~1000 min (prod) / 60 min (dev). A single **door authorization policy** requires an ERP `UserTypeCode`/`RoleCode` claim (AuthHex is a shared IdP, so a valid token alone is not enough); **per-endpoint RBAC stays deferred**.
+- **Auth proxy:** `AuthController` (`Controllers/AuthController.cs`) is ERPCore's only endpoint group that talks to AuthHex over HTTP — via `IAuthHexClient` (`Infra/Auth/AuthHex/AuthHexClient.cs`, `AuthHex:BaseUrl` config) — and the only place that issues httpOnly `erp_at`/`erp_rt` session cookies (`Infra/Auth/AuthCookieWriter.cs`) plus the `XSRF-TOKEN` double-submit cookie checked by `ValidateCsrfAttribute` on mutating actions. The JWT bearer handler also accepts the `erp_at` cookie as a fallback (`JwtAuthExtensions`'s `OnMessageReceived`) when no `Authorization` header is present, so every other controller keeps working unchanged whether a caller sends a Bearer header or relies on the cookie session.
- **Audit actor:** the token carries no `sub`/`nameid`; identity is AuthHex's custom **`UserId` (GUID)** claim. An `ICurrentUser` abstraction (`Infra/Auth`) resolves the acting user from a **local shadow user** — the GUID is mapped (JIT-provisioned) to a local `int` `users.user_id` that all FKs reference (see C.7). Services stamp mutations with it. **Never** trust a `createdBy` from the request body.
- **Concurrency:** mutable resources carry a `RowVersion` (`[Timestamp] byte[]`), surfaced as `ETag`; `PUT`/`PATCH` require `If-Match` → `412` on mismatch.
- **Numbering:** document numbers come from `NumberSequence` (per doc type, per year), issued inside the same transaction as the document.
@@ -200,7 +201,7 @@ UI: responsive; count/pick screens handheld-friendly; status badges; mandatory-f
|---|---|---|
| NFR-01 | Performance | Single item/warehouse enquiry + valuation < 2s under normal load; ledger posting transactional, < 1s per line. |
| NFR-02 | Integrity | FIFO layer consumption atomic and concurrency-safe; no double-consumption of remaining qty. |
-| NFR-03 | Security | Users authenticated via the external AuthHex IdP; **password hashing (BCrypt) is AuthHex's responsibility** — ERPCore validates tokens only. Every action attributed to a user and logged. (Role-based enforcement deferred, FR-X-01.) |
+| NFR-03 | Security | Users authenticated via the external AuthHex IdP, proxied through ERPCore's `AuthController` (docs/11 §2.0); **password hashing (BCrypt) is AuthHex's responsibility** — ERPCore forwards credentials and validates the resulting tokens only, never storing or hashing passwords itself. Every action attributed to a user and logged. (Role-based enforcement deferred, FR-X-01.) |
| NFR-04 | Auditability | Audit trail immutable, retained per policy; ledger append-only. |
| NFR-05 | Reliability | No stock transaction partially commits; full rollback on failure. |
| NFR-06 | Scalability | Growth in items/warehouses/ledger without redesign; ledger indexed for time-series queries. |
diff --git a/docs/11-BACKEND-PHASE1.md b/docs/11-BACKEND-PHASE1.md
index 8d1a94d..a570116 100644
--- a/docs/11-BACKEND-PHASE1.md
+++ b/docs/11-BACKEND-PHASE1.md
@@ -18,7 +18,7 @@ Path-based versioning. Breaking changes bump the major version.
```
Authorization: Bearer
```
-- Every endpoint requires a valid **Bearer JWT**; unauthenticated → `401`. Tokens are issued by the **external AuthHex IdP** (not ERPCore) — **RS256**, issuer `AuthHex`, audience `AuthHexClient`. ERPCore validates them against AuthHex's static RSA public key (no JWKS) and admits only holders of the configured ERP `UserType`/`Role` (door policy) → otherwise `403`.
+- Every endpoint requires a valid **Bearer JWT**, sent either as an `Authorization: Bearer ` header or as the `erp_at` httpOnly cookie issued by `AuthController` (§2.0); unauthenticated → `401`. Tokens are issued by the **external AuthHex IdP** (not ERPCore) — **RS256**, issuer `AuthHex`, audience `AuthHexClient`. ERPCore validates them against AuthHex's static RSA public key (no JWKS) and admits only holders of the configured ERP `UserType`/`Role` (door policy) → otherwise `403`.
- **Per-endpoint RBAC is NOT enforced in Phase 1** (FR-X-01): any ERP-admitted user may call any endpoint.
- The **audit actor** is AuthHex's custom **`UserId` (GUID)** claim, mapped to a local shadow user (`int`). Clients never send `createdBy`; the server derives it (docs/10 A.4).
@@ -59,15 +59,50 @@ Domain errors add a stable `code` (catalog §7):
## 2. Master Data
-### 2.0 Auth — **external (AuthHex IdP); not an ERPCore endpoint**
-> **Superseded (2026-07-14).** ERPCore no longer exposes `/auth/login`. Login, registration and recovery are owned by the
-> separate **AuthHex** service (e.g. `POST /api/loginUser` with `{ identifier, password }`), which returns an **RS256** JWT
-> (issuer `AuthHex`, audience `AuthHexClient`; claims `UserId` (GUID), `UserTypeCode`, `RoleCode`, `NIC`, …). ERPCore only
-> **validates** that Bearer token and provisions a local shadow user (docs/10 A.4). The old shape is retained here for history:
-```json
-POST {AuthHex}/api/loginUser → { "identifier": "…", "password": "••••••••" }
-// 200 → an RS256 access token (Bearer). Bad credentials → 401. Token → ERPCore Authorization: Bearer .
-```
+### 2.0 Auth — **`AuthController`, proxying the external AuthHex IdP**
+> **Superseding note (2026-07-16).** Un-superseded: the frontend no longer calls AuthHex directly. All of the endpoints
+> below live on ERPCore under `/api/v1/auth/*` (`Controllers/AuthController.cs`), each forwarding to the matching AuthHex
+> `functionName` (see the project-root `API_REFERENCE.md` for AuthHex's own contract) via `IAuthHexClient`. Session-issuing
+> endpoints deliver AuthHex's access/refresh tokens as **httpOnly Secure cookies** (`erp_at`, `erp_rt`) plus a non-httpOnly
+> `XSRF-TOKEN` cookie (docs/02-SECURITY.md §B.2) — response bodies never contain raw tokens. Mutating, cookie-authenticated
+> requests must echo the CSRF cookie value in an `X-XSRF-TOKEN` header or receive `403 CSRF_TOKEN_MISMATCH`; Bearer-header
+> callers (Swagger, service-to-service) are exempt. The JWT bearer handler also accepts the `erp_at` cookie in place of an
+> `Authorization` header (docs/10 A.4 Auth proxy), so every other `/api/v1/*` controller keeps working unchanged either way.
+
+| Route | AuthHex function | Auth |
+|---|---|---|
+| `POST /api/v1/auth/register` | registerUser | Anonymous |
+| `POST /api/v1/auth/login` | loginUser | Anonymous |
+| `POST /api/v1/auth/login/otp/verify` | VerifyOtpForLogin | Anonymous |
+| `POST /api/v1/auth/refresh-token` | refreshToken | Anonymous (reads `erp_rt` cookie) |
+| `GET /api/v1/auth/users/{userId}` | getUserDetails | Anonymous* |
+| `GET /api/v1/auth/sessions` | getUserSessions | Required |
+| `POST /api/v1/auth/status` | ChangeUserStatus | Required + CSRF |
+| `POST /api/v1/auth/lock` | LockUserAccount | Required + CSRF |
+| `POST /api/v1/auth/change-password` | ChangeUserPassword | Required + CSRF |
+| `POST /api/v1/auth/verify-password` | VerifyPassword | Required |
+| `POST /api/v1/auth/logout` | LogoutUser | Anonymous* |
+| `PUT /api/v1/auth/me` | UpdateUser | Required + CSRF |
+| `POST /api/v1/auth/2fa/initiate` | initiateTwoFASetup | Required + CSRF |
+| `POST /api/v1/auth/2fa/complete` | completeTwoFASetup | Required + CSRF |
+| `POST /api/v1/auth/2fa/verify` | verifyTwoFA | Required + CSRF |
+| `POST /api/v1/auth/2fa/disable` | disableTwoFA | Required + CSRF |
+| `GET /api/v1/auth/2fa/status` | getTwoFAStatus | Required |
+| `POST /api/v1/auth/recovery/forgot-password` | forgotPassword | Anonymous |
+| `POST /api/v1/auth/recovery/verify-otp` | verifyOTP | Anonymous |
+| `POST /api/v1/auth/recovery/reset-password` | resetPassword | Anonymous |
+| `POST /api/v1/auth/recovery/reset-password-token` | resetPasswordWithToken | Anonymous |
+| `POST /api/v1/auth/availability` | IsAvailable | Anonymous |
+| `POST /api/v1/auth/otp/send` | sendOtp | Anonymous |
+| `POST /api/v1/auth/otp/verify` | VerifyOTP (Alt) | Anonymous |
+
+\* `getUserDetails` and `LogoutUser` are anonymous because AuthHex itself resolves them from the request payload rather
+than the bearer token — carried over from AuthHex's own design, not introduced by this proxy. Tracked as an accepted risk
+in docs/02-SECURITY.md Part A.
+
+Request/response field shapes match AuthHex's own payloads one-for-one (project-root `API_REFERENCE.md` §3–§5), except
+session-issuing responses omit `AccessToken`/`RefreshToken` (cookie-delivered instead) and `refreshToken` is read from the
+`erp_rt` cookie rather than the request body.
### 2.1 Items
#### `GET /items`