diff --git a/README.md b/README.md
index 7be9c31..9e8456d 100644
--- a/README.md
+++ b/README.md
@@ -90,7 +90,8 @@ The current slice is tracked in the milestone [M1: Sign-in, users and permission
- [x] Back-end skeleton, local environment with Aspire, CI
- [x] Domain: value objects, users, permission profiles, effective access
- [x] Persistence: EF Core, audit fields, concurrency, seeded system records
-- [ ] Authentication and per-screen authorization
+- [x] Sign-in, sessions and the current user (`/me`)
+- [ ] Per-screen authorization
- [ ] Permission profiles and users endpoints, activation and password reset links
- [ ] Connect the front-end to the real API
- [ ] Remaining screens: catalog, commercial, finance and reports
diff --git a/backend/ControlService/src/ControlService.API/Auth/AuthClaims.cs b/backend/ControlService/src/ControlService.API/Auth/AuthClaims.cs
new file mode 100644
index 0000000..e84140d
--- /dev/null
+++ b/backend/ControlService/src/ControlService.API/Auth/AuthClaims.cs
@@ -0,0 +1,14 @@
+using System.Security.Claims;
+
+namespace ControlService.API.Auth;
+
+/// Reads the claims the access token carries (T5).
+internal static class AuthClaims
+{
+ public static Guid? UserId(this ClaimsPrincipal principal) => GuidClaim(principal, "sub");
+
+ public static Guid? SessionId(this ClaimsPrincipal principal) => GuidClaim(principal, JwtAccessTokenIssuer.SessionIdClaim);
+
+ private static Guid? GuidClaim(ClaimsPrincipal principal, string type) =>
+ Guid.TryParse(principal.FindFirstValue(type), out var value) ? value : null;
+}
diff --git a/backend/ControlService/src/ControlService.API/Auth/AuthEndpoints.cs b/backend/ControlService/src/ControlService.API/Auth/AuthEndpoints.cs
new file mode 100644
index 0000000..aa08848
--- /dev/null
+++ b/backend/ControlService/src/ControlService.API/Auth/AuthEndpoints.cs
@@ -0,0 +1,157 @@
+using System.Globalization;
+using System.Security.Claims;
+using ControlService.API.Common;
+using ControlService.Application.Auth;
+using ControlService.Application.Auth.ChangePassword;
+using ControlService.Application.Auth.GetMe;
+using ControlService.Application.Auth.RefreshSession;
+using ControlService.Application.Auth.SignIn;
+using ControlService.Application.Auth.SignOut;
+using ControlService.Application.Common;
+using ControlService.Domain.Common;
+using Microsoft.AspNetCore.Http.HttpResults;
+using Microsoft.AspNetCore.RateLimiting;
+
+namespace ControlService.API.Auth;
+
+public static class AuthEndpoints
+{
+ public static RouteGroupBuilder MapAuthEndpoints(this RouteGroupBuilder api)
+ {
+ api.MapPost("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/auth/sign-in", SignIn)
+ .AllowAnonymous()
+ .RequireRateLimiting(AuthRateLimiting.SignInPolicy)
+ .WithSummary("Signs in with login and password; sets the refresh cookie.");
+
+ api.MapPost("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/auth/refresh", Refresh)
+ .AllowAnonymous()
+ .RequireRateLimiting(AuthRateLimiting.RefreshPolicy)
+ .WithSummary("Renews the session from the refresh cookie and rotates it.");
+
+ api.MapPost("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/auth/sign-out", SignOut)
+ .AllowPendingPasswordChange()
+ .WithSummary("Ends the session named by the access token and expires the refresh cookie.");
+
+ api.MapPost("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/auth/change-password", ChangePassword)
+ .AllowPendingPasswordChange()
+ .WithSummary("Replaces the initial password; ends every session and starts a new one.");
+
+ api.MapGet("/me", Me)
+ .AllowPendingPasswordChange()
+ .WithSummary("The signed-in person and their effective level on every screen.");
+
+ return api;
+ }
+
+ private static async Task, ProblemHttpResult>> Refresh(
+ ICommandHandler handler,
+ HttpContext httpContext,
+ CancellationToken cancellationToken)
+ {
+ var refreshToken = httpContext.Request.Cookies[RefreshCookie.Name] ?? string.Empty;
+ var result = await handler.Handle(new RefreshSessionCommand(refreshToken), cancellationToken);
+ if (result.IsFailure)
+ {
+ return result.Error.ToProblem();
+ }
+
+ RefreshCookie.Append(httpContext.Response, result.Value.RefreshToken, result.Value.RefreshTokenExpiresAt);
+ return TypedResults.Ok(SessionResponse.From(result.Value));
+ }
+
+ private static async Task, ProblemHttpResult>> ChangePassword(
+ ChangePasswordRequest request,
+ ClaimsPrincipal principal,
+ ICommandHandler handler,
+ HttpContext httpContext,
+ CancellationToken cancellationToken)
+ {
+ var userId = principal.UserId();
+ if (userId is null)
+ {
+ return AuthErrors.SessionExpired.ToProblem();
+ }
+
+ var result = await handler.Handle(
+ new ChangePasswordCommand(
+ userId.Value, request.Password ?? string.Empty, request.PasswordConfirmation ?? string.Empty), cancellationToken);
+ if (result.IsFailure)
+ {
+ return result.Error.ToProblem();
+ }
+
+ RefreshCookie.Append(httpContext.Response, result.Value.RefreshToken, result.Value.RefreshTokenExpiresAt);
+ return TypedResults.Ok(SessionResponse.From(result.Value));
+ }
+
+ // The refresh cookie is scoped to the refresh route, so sign-out never receives it: the session comes
+ // from the access token's `sid` claim instead (D3).
+ private static async Task> SignOut(
+ ClaimsPrincipal principal,
+ ICommandHandler handler,
+ HttpContext httpContext,
+ CancellationToken cancellationToken)
+ {
+ var sessionId = principal.SessionId();
+ if (sessionId is null)
+ {
+ return AuthErrors.SessionExpired.ToProblem();
+ }
+
+ await handler.Handle(new SignOutCommand(sessionId.Value), cancellationToken);
+ RefreshCookie.Expire(httpContext.Response);
+ return TypedResults.NoContent();
+ }
+
+ private static async Task, ProblemHttpResult>> Me(
+ ClaimsPrincipal principal,
+ IQueryHandler handler,
+ CancellationToken cancellationToken)
+ {
+ var userId = principal.UserId();
+ if (userId is null)
+ {
+ return AuthErrors.SessionExpired.ToProblem();
+ }
+
+ var result = await handler.Handle(new GetMeQuery(userId.Value), cancellationToken);
+ return result.IsFailure ? result.Error.ToProblem() : TypedResults.Ok(result.Value);
+ }
+
+ private static async Task, ProblemHttpResult>> SignIn(
+ SignInRequest request,
+ ICommandHandler handler,
+ HttpContext httpContext,
+ CancellationToken cancellationToken)
+ {
+ var result = await handler.Handle(new SignInCommand(request.Login ?? string.Empty, request.Password ?? string.Empty), cancellationToken);
+ if (result.IsFailure)
+ {
+ return SignInProblem(result.Error, httpContext);
+ }
+
+ RefreshCookie.Append(httpContext.Response, result.Value.RefreshToken, result.Value.RefreshTokenExpiresAt);
+ return TypedResults.Ok(SessionResponse.From(result.Value));
+ }
+
+ // The 403 of account_inactive is sign-in only, and the lockout tells the browser how long to wait.
+ private static ProblemHttpResult SignInProblem(Error error, HttpContext httpContext)
+ {
+ if (error.Code == "account_inactive")
+ {
+ return error.ToProblem(StatusCodes.Status403Forbidden);
+ }
+
+ if (error.Details is not null && error.Details.TryGetValue("retryAfterSeconds", out var seconds))
+ {
+ httpContext.Response.Headers.RetryAfter = Convert.ToString(seconds, CultureInfo.InvariantCulture);
+ }
+
+ return error.ToProblem();
+ }
+
+ // A missing or null field is treated as empty, so it fails validation instead of crashing (API-12).
+ private sealed record ChangePasswordRequest(string? Password = null, string? PasswordConfirmation = null);
+
+ private sealed record SignInRequest(string? Login = null, string? Password = null);
+}
diff --git a/backend/ControlService/src/ControlService.API/Auth/AuthRateLimiting.cs b/backend/ControlService/src/ControlService.API/Auth/AuthRateLimiting.cs
new file mode 100644
index 0000000..4929ae4
--- /dev/null
+++ b/backend/ControlService/src/ControlService.API/Auth/AuthRateLimiting.cs
@@ -0,0 +1,62 @@
+using System.Globalization;
+using System.Threading.RateLimiting;
+using ControlService.API.Common;
+using ControlService.Application.Auth;
+using Microsoft.AspNetCore.RateLimiting;
+using Microsoft.Extensions.Options;
+
+namespace ControlService.API.Auth;
+
+/// Rate limits of the authentication routes (ADR-0023, D14, T9): a sliding window of 60 seconds
+/// in 6 segments per client address, with no queue. A refused request answers like a lockout does:
+/// 429 `locked_out` with `Retry-After` (AUTH-26).
+internal static class AuthRateLimiting
+{
+ public const string SignInPolicy = "sign-in";
+ public const string RefreshPolicy = "refresh";
+
+ private static readonly TimeSpan Window = TimeSpan.FromSeconds(60);
+
+ public static IServiceCollection AddAuthRateLimiting(this IServiceCollection services, IConfiguration configuration)
+ {
+ services.AddOptions()
+ .Bind(configuration.GetSection(RateLimitingOptions.SectionName))
+ .Validate(
+ options => options.SignIn.PermitLimit > 0 && options.Refresh.PermitLimit > 0,
+ "RateLimiting:SignIn:PermitLimit and RateLimiting:Refresh:PermitLimit must be greater than zero.")
+ .ValidateOnStart();
+
+ services.AddRateLimiter();
+ services.AddOptions().Configure>((limiter, limits) =>
+ {
+ limiter.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
+ limiter.AddPolicy(SignInPolicy, context => PartitionByAddress(context, limits.Value.SignIn.PermitLimit));
+ limiter.AddPolicy(RefreshPolicy, context => PartitionByAddress(context, limits.Value.Refresh.PermitLimit));
+ limiter.OnRejected = RejectAsync;
+ });
+
+ return services;
+ }
+
+ // `unknown` covers a request with no remote address, as in the test server.
+ private static RateLimitPartition PartitionByAddress(HttpContext context, int permitLimit) =>
+ RateLimitPartition.GetSlidingWindowLimiter(
+ context.Connection.RemoteIpAddress?.ToString() ?? "unknown",
+ _ => new SlidingWindowRateLimiterOptions
+ {
+ PermitLimit = permitLimit,
+ Window = Window,
+ SegmentsPerWindow = 6,
+ QueueLimit = 0,
+ });
+
+ private static async ValueTask RejectAsync(OnRejectedContext context, CancellationToken cancellationToken)
+ {
+ // The limiter reports when the next segment frees up; the whole window is the safe fallback.
+ var retryAfter = context.Lease.TryGetMetadata(MetadataName.RetryAfter, out var wait) ? wait : Window;
+ var seconds = (int)Math.Ceiling(retryAfter.TotalSeconds);
+
+ context.HttpContext.Response.Headers.RetryAfter = seconds.ToString(CultureInfo.InvariantCulture);
+ await AuthErrors.TooManyRequests(seconds).ToProblem().ExecuteAsync(context.HttpContext);
+ }
+}
diff --git a/backend/ControlService/src/ControlService.API/Auth/AuthServiceCollectionExtensions.cs b/backend/ControlService/src/ControlService.API/Auth/AuthServiceCollectionExtensions.cs
new file mode 100644
index 0000000..27df179
--- /dev/null
+++ b/backend/ControlService/src/ControlService.API/Auth/AuthServiceCollectionExtensions.cs
@@ -0,0 +1,56 @@
+using ControlService.Application.Auth;
+using ControlService.Application.Auth.ChangePassword;
+using ControlService.Application.Auth.GetMe;
+using ControlService.Application.Auth.RefreshSession;
+using ControlService.Application.Auth.SignIn;
+using ControlService.Application.Auth.SignOut;
+using ControlService.Application.Common;
+using Microsoft.AspNetCore.Authentication.JwtBearer;
+using Microsoft.Extensions.Options;
+
+namespace ControlService.API.Auth;
+
+public static class AuthServiceCollectionExtensions
+{
+ public static IServiceCollection AddAuthFeature(this IServiceCollection services, IConfiguration configuration)
+ {
+ services.AddOptions()
+ .Bind(configuration.GetSection(JwtOptions.SectionName))
+ .ValidateDataAnnotations()
+ .Validate(IsValidSigningKey, "Auth:SigningKey must be the base64 of at least 32 bytes.")
+ .ValidateOnStart();
+
+ services.AddSingleton();
+
+ services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme).AddJwtBearer();
+ services.AddSingleton, ConfigureJwtBearer>();
+ services.AddAuthorization();
+ services.AddAuthRateLimiting(configuration);
+
+ // Handlers are registered by hand (ADR-0008); the ones with input to validate are wrapped in the decorator.
+ services.AddScoped, SignInHandler>();
+ services.AddScoped>(serviceProvider =>
+ new ValidatingCommandHandler(
+ ActivatorUtilities.CreateInstance(serviceProvider),
+ new ChangePasswordValidator(serviceProvider.GetRequiredService())));
+ services.AddScoped, RefreshSessionHandler>();
+ services.AddScoped, SignOutHandler>();
+ services.AddScoped, GetMeHandler>();
+
+ return services;
+ }
+
+ // Never echoes the key: the message names it, and that is all. Binding leaves it null when the user
+ // secret was never set.
+ private static bool IsValidSigningKey(JwtOptions options)
+ {
+ if (options.SigningKey is null)
+ {
+ return false;
+ }
+
+ // Sized to what the text can decode to, so a key longer than the minimum is never refused.
+ Span buffer = new byte[options.SigningKey.Length * 3 / 4];
+ return Convert.TryFromBase64String(options.SigningKey, buffer, out var written) && written >= 32;
+ }
+}
diff --git a/backend/ControlService/src/ControlService.API/Auth/ConfigureJwtBearer.cs b/backend/ControlService/src/ControlService.API/Auth/ConfigureJwtBearer.cs
new file mode 100644
index 0000000..e6b23f7
--- /dev/null
+++ b/backend/ControlService/src/ControlService.API/Auth/ConfigureJwtBearer.cs
@@ -0,0 +1,52 @@
+using ControlService.API.Common;
+using ControlService.Application.Auth;
+using Microsoft.AspNetCore.Authentication.JwtBearer;
+using Microsoft.Extensions.Options;
+using Microsoft.IdentityModel.Tokens;
+
+namespace ControlService.API.Auth;
+
+/// How access tokens are checked (T5, T7). Claim names stay as issued (`sub`, `sid`), because
+/// ICurrentUser reads `sub`; the lifetime is judged by the application's clock, with no skew, so 15
+/// minutes means 15 minutes; and every failed authentication looks like any other error (API-12).
+public sealed class ConfigureJwtBearer(IOptions jwtOptions, TimeProvider timeProvider)
+ : IConfigureNamedOptions
+{
+ public void Configure(JwtBearerOptions options) => Configure(Options.DefaultName, options);
+
+ public void Configure(string? name, JwtBearerOptions options)
+ {
+ var jwt = jwtOptions.Value;
+
+ options.MapInboundClaims = false;
+ options.TokenValidationParameters = new TokenValidationParameters
+ {
+ ValidateIssuer = true,
+ ValidIssuer = jwt.Issuer,
+ ValidateAudience = true,
+ ValidAudience = jwt.Audience,
+ ValidateLifetime = true,
+ ValidateIssuerSigningKey = true,
+ IssuerSigningKey = new SymmetricSecurityKey(jwt.SigningKeyBytes()),
+ ValidAlgorithms = [SecurityAlgorithms.HmacSha256],
+ ClockSkew = TimeSpan.Zero,
+
+ // IdentityModel has no clock seam, so the lifetime check is ours: it reads TimeProvider (which
+ // tests control), and `exp` is exclusive, as RFC 7519 says.
+ LifetimeValidator = (notBefore, expires, _, _) =>
+ {
+ var now = timeProvider.GetUtcNow().UtcDateTime;
+ return expires is not null && now < expires && (notBefore is null || notBefore <= now);
+ },
+ };
+
+ options.Events = new JwtBearerEvents
+ {
+ OnChallenge = async context =>
+ {
+ context.HandleResponse();
+ await AuthErrors.SessionExpired.ToProblem().ExecuteAsync(context.HttpContext);
+ },
+ };
+ }
+}
diff --git a/backend/ControlService/src/ControlService.API/Auth/JwtAccessTokenIssuer.cs b/backend/ControlService/src/ControlService.API/Auth/JwtAccessTokenIssuer.cs
new file mode 100644
index 0000000..c9dade5
--- /dev/null
+++ b/backend/ControlService/src/ControlService.API/Auth/JwtAccessTokenIssuer.cs
@@ -0,0 +1,44 @@
+using ControlService.Application.Auth;
+using Microsoft.Extensions.Options;
+using Microsoft.IdentityModel.JsonWebTokens;
+using Microsoft.IdentityModel.Tokens;
+
+namespace ControlService.API.Auth;
+
+/// Issues the HS256 access token (T5). It carries the user id in `sub` (ICurrentUser reads it),
+/// the session in `sid`, and `must_change_password` only while the change is mandatory. No personal data.
+public sealed class JwtAccessTokenIssuer(IOptions options, TimeProvider timeProvider) : IAccessTokenIssuer
+{
+ public const string MustChangePasswordClaim = "must_change_password";
+ public const string SessionIdClaim = "sid";
+
+ public AccessToken Issue(Guid userId, Guid sessionId, bool mustChangePassword)
+ {
+ var jwt = options.Value;
+ var now = timeProvider.GetUtcNow().UtcDateTime;
+ var lifetime = TimeSpan.FromMinutes(jwt.AccessTokenMinutes);
+
+ var claims = new Dictionary
+ {
+ [JwtRegisteredClaimNames.Sub] = userId.ToString(),
+ [SessionIdClaim] = sessionId.ToString(),
+ };
+ if (mustChangePassword)
+ {
+ claims[MustChangePasswordClaim] = true;
+ }
+
+ var descriptor = new SecurityTokenDescriptor
+ {
+ Issuer = jwt.Issuer,
+ Audience = jwt.Audience,
+ IssuedAt = now,
+ NotBefore = now,
+ Expires = now + lifetime,
+ Claims = claims,
+ SigningCredentials = new SigningCredentials(new SymmetricSecurityKey(jwt.SigningKeyBytes()), SecurityAlgorithms.HmacSha256),
+ };
+
+ return new AccessToken(new JsonWebTokenHandler().CreateToken(descriptor), (int)lifetime.TotalSeconds);
+ }
+}
diff --git a/backend/ControlService/src/ControlService.API/Auth/JwtOptions.cs b/backend/ControlService/src/ControlService.API/Auth/JwtOptions.cs
new file mode 100644
index 0000000..f383b90
--- /dev/null
+++ b/backend/ControlService/src/ControlService.API/Auth/JwtOptions.cs
@@ -0,0 +1,24 @@
+using System.ComponentModel.DataAnnotations;
+
+namespace ControlService.API.Auth;
+
+/// The `Auth` keys that decide how access tokens are signed and checked (ADR-0019, ADR-0032).
+/// The keys Infrastructure reads live in the same section.
+public sealed class JwtOptions
+{
+ public const string SectionName = "Auth";
+
+ [Range(1, 24 * 60)]
+ public int AccessTokenMinutes { get; init; }
+
+ [Required]
+ public string Issuer { get; init; } = string.Empty;
+
+ [Required]
+ public string Audience { get; init; } = string.Empty;
+
+ /// The base64 of at least 32 random bytes. A secret: user secrets locally (T5).
+ public string SigningKey { get; init; } = string.Empty;
+
+ public byte[] SigningKeyBytes() => Convert.FromBase64String(SigningKey);
+}
diff --git a/backend/ControlService/src/ControlService.API/Auth/PasswordChangeRequiredFilter.cs b/backend/ControlService/src/ControlService.API/Auth/PasswordChangeRequiredFilter.cs
new file mode 100644
index 0000000..110de10
--- /dev/null
+++ b/backend/ControlService/src/ControlService.API/Auth/PasswordChangeRequiredFilter.cs
@@ -0,0 +1,34 @@
+using ControlService.API.Common;
+using ControlService.Application.Auth;
+
+namespace ControlService.API.Auth;
+
+/// While the Admin has not replaced the initial password, its access token carries
+/// `must_change_password` and every endpoint except the ones that opted out answers 401
+/// `password_change_required` (AUTH-14, ADR-0032, T8).
+public sealed class PasswordChangeRequiredFilter : IEndpointFilter
+{
+ public ValueTask