You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Authentication: sign-in, sessions and the Admin's first access #8
Let people sign in and keep a session: ASP.NET Core Identity for credentials, JWT access tokens, sliding refresh tokens and the Admin's mandatory first password change.
Scope
Identity manages password hashes and lockout; credentials are not part of the user record (AUTH-19, ADR-0019).
Sign in with login and password (AUTH-07); lockout after 5 consecutive failures for 15 minutes, without revealing which field was wrong (AUTH-08); deactivated accounts cannot sign in (AUTH-09).
Password rules: at least 8 characters, typed twice (AUTH-16).
Access token of 15 minutes; refresh token of 8 hours, sliding and rotated, in an HttpOnly cookie scoped to the refresh endpoint; revoked on sign-out (AUTH-17, ADR-0032).
Admin first access: initial password from configuration, claim must_change_password, every other endpoint answers 401 password_change_required until it is changed (AUTH-13, AUTH-14, AUTH-15).
Rate limiting on sign-in and refresh (ADR-0023).
Operations: signIn, hasSession (refresh), signOut, changePassword, me with the effective level on every screen, as listed in docs/product/features/authentication.md#operations.
Durations and limits come from configuration (AUTH-22); the signing key comes from user secrets.
Out of scope
Activation and reset links (#11, #12), per-screen authorization (#9).
Done when
Integration tests: successful sign-in; wrong password; lockout after 5 failures (429 locked_out); deactivated account (403 account_inactive); refresh after 8 idle hours fails (fake TimeProvider); Admin with the initial password gets 401 password_change_required on another endpoint.
Messages verbatim from docs/product/features/authentication.md#messages.
No password, token or link appears in logs or test output.
The owner is reminded to run dotnet dev-certs https --trust before testing in the browser.
Scope note carried over from the plan of #7 (decision D3):
ICurrentUser (created in Persistence: EF Core, PostgreSQL, audit, concurrency and seeded system records #7, implemented by API/Common/HttpCurrentUser) reads the user id from the sub claim of the authenticated principal. The access token issued here must carry the user id in sub, and the JWT bearer setup must keep that name (MapInboundClaims = false), or every save made by a signed-in person fails.
This comment is the hand-over from the plan session to the build session (implement-a-feature.md). Work in pair mode (ADR-0033): one Red → Green → Refactor cycle per test, one pause at the end of each cycle. If anything below turns out wrong or incomplete, stop and ask; never replace a planned approach silently. Items marked verify depend on .NET 10 / Identity / IdentityModel APIs: check the current documentation before writing them, and stop and ask if the API does not behave as described.
Read before starting: docs/product/features/authentication.md, docs/product/features/users.md (The Admin system user), docs/product/features/permission-profiles.md (PERM-05, PERM-06, PERM-23), docs/product/conventions.md, docs/api/conventions.md, ADR-0009, ADR-0019, ADR-0020, ADR-0022, ADR-0023, ADR-0032, docs/agents/guides/architecture.md, docs/agents/guides/testing.md, docs/agents/guides/local-environment.md, and the plan comment of #7 (decisions D3 and D9).
Owner's decisions
#
Decision
Settles
D1
Session responses.POST /auth/sign-in, POST /auth/refresh and POST /auth/change-password answer 200 with { "accessToken": "<jwt>", "expiresIn": 900, "mustChangePassword": false }. expiresIn is in seconds (not a timestamp), so a wrong clock on the person's computer does not break the refresh decision. Refresh without a valid session → 401 session_expired. POST /auth/sign-out → 204. The front-end's HTTP client keeps the token and returns to the screens what the prototype returns today.
Operations table, ADR-0019, ADR-0032
D2
me() levels.GET /api/v1/me → 200 { id, displayName, login, levels }, where levels is a list [{ "screen": "<key>", "level": "<wire value>" }] with every screen of ScreenKeys.All, in catalog order, including negado. Same shape as a profile's levels.
Operations table, PERM-05, PERM-06
D3
Sign-out without the cookie. The refresh cookie stays scoped to /api/v1/auth/refresh (ADR-0019 unchanged), so sign-out never receives it. The access token carries a sid claim with the session id; sign-out ends that session by id and expires the cookie (same name and Path). Other sessions of the same person stay open.
ADR-0019 (no ADR change)
D4
Change password without a mandatory change → 403 forbidden, message "Sua senha já foi criada. Para trocá-la, use “Esqueci minha senha”." New rule AUTH-24.
Operations table ("Only for the account with a mandatory change"), AUTH-15
D5
Back-end message for session codes (API-12 requires one): 401 session_expired → "Sua sessão terminou. Entre de novo para continuar. Suas abas continuam abertas."; 401 password_change_required → "Por segurança, a senha inicial precisa ser trocada no primeiro acesso."; 401 account_inactive (during a session, including refresh) → "Este acesso está desativado. Fale com o responsável pelo sistema." New rule AUTH-27. Rate limit 429 locked_out → new message "Muitas tentativas em pouco tempo. Aguarde alguns instantes e tente de novo.", new rule AUTH-26 (the lockout message says "15 minutos", which is not the rate-limit wait).
API-12, AUTH-14, ADR-0023
D6
When the lockout starts. The 5th consecutive wrong password already answers 429 locked_out. While locked, even the right password gets 429 (lockout is checked before the password, and those attempts are not counted). Retry-After = seconds left until the lockout ends, rounded up. A successful sign-in resets the failure count ("consecutive").
AUTH-08
D7
Known limitation, accepted: a login that does not exist never gets locked, so 5 attempts reveal whether a login exists. This is Identity's standard behavior; the per-IP rate limit makes scanning logins slow. Record it as a note under Signing in in authentication.md (not a rule).
AUTH-08 vs. enumeration
D8
New password equal to the initial one is refused: field password, "A nova senha precisa ser diferente da senha inicial." New rule AUTH-25.
AUTH-14
D9
The Admin's activatedAt (open item from #7, D9) is filled with the time of the first password change. New rule USR-34. This does not break USR-23, for the same reason as AUTH-15: it is the Admin's own first access, not an edit of the record. The change is authored by the Admin itself, read from sub (D3 of #7).
Refresh checks the account status. Refresh is anonymous (cookie only), so #9's authorization handler will not cover it. A non-active account on refresh → 401 account_inactive (message of D5), and that session is removed.
AUTH-18, ADR-0032
D11
Configured values in messages. The minimum password length and the lockout minutes are interpolated from configuration; with the defaults the text is exactly the one in authentication.md. New rule AUTH-28: Messages that state a duration or a limit show the configured value.
AUTH-22, AUTH-08, AUTH-16
D12
Schema approved: one new migration, AddAuthentication.user_credentials (Identity's user: id = users.id, FK ON DELETE RESTRICT; user_name = the user id, not the login, so it never needs syncing; Identity's e-mail and phone columns stay empty; extra column must_change_password boolean not null). Identity's user_credential_claims, user_credential_logins, user_credential_tokens (required by the EF store, unused). user_sessions (id, user_id FK RESTRICT, token_hashunique, created_at, expires_at). Sign-out and password change delete session rows. Review the generated migration before committing (no Red for the migration itself: say so).
ADR-0019, AUTH-19
D13
Package references approved (versions already in Directory.Packages.props, no new version): Microsoft.AspNetCore.Identity.EntityFrameworkCore in Infrastructure, Microsoft.AspNetCore.Authentication.JwtBearer in the API. Rate limiting is in the shared framework.
ADR-0019, ADR-0023
D14
Rate limits. Sliding window of 60 s with 6 segments, partitioned by client IP (RemoteIpAddress; unknown when null, as in TestServer), no queue: RateLimiting:SignIn:PermitLimit = 20, RateLimiting:Refresh:PermitLimit = 60. The global limit of ADR-0023 is out of scope (a new issue).
ADR-0023, AUTH-22
Technical decisions (proposed in the plan session, accepted by the owner)
#
Decision
T1
Layers. Application gains Auth/SignIn, Auth/RefreshSession, Auth/SignOut, Auth/ChangePassword (with ChangePasswordValidator, ADR-0008) and Auth/GetMe, plus interfaces with only the methods these tests need: Auth/ICredentialStore (check password with lockout → Succeeded / Failed / LockedOut(lockoutEnd); must-change flag; is-current-password; replace password, which clears the flag), Auth/ISessionStore (start, rotate, end one, end all), Auth/IAccessTokenIssuer (userId, sessionId, mustChangePassword → token + expiresIn), Users/IUserRepository (GetByLoginAsync, GetByIdAsync), PermissionProfiles/IPermissionProfileRepository (ListByIdsAsync). Handlers orchestrate; lockout counting lives in Identity.
T2
Identity.Infrastructure/Auth/UserCredential : IdentityUser<Guid>; AppDbContext inherits IdentityUserContext<UserCredential, Guid> (no roles). AddIdentityCore<UserCredential>().AddEntityFrameworkStores<AppDbContext>(), no SignInManager (it pulls cookie authentication): use UserManager.IsLockedOutAsync, CheckPasswordAsync, AccessFailedAsync, ResetAccessFailedCountAsync. Password options: no digit/upper/lower/symbol, RequiredUniqueChars = 1, RequiredLength = Auth:PasswordMinLength, so Identity never refuses what the validator accepts. Lockout: MaxFailedAccessAttempts = Auth:LockoutMaxFailedAttempts, DefaultLockoutTimeSpan = Auth:LockoutMinutes, AllowedForNewUsers = true. Verify that Identity's lockout uses the TimeProvider from DI (tests 34, 40 depend on it). Passwords are never trimmed (CNV-03 is about stored text; a password is stored only as a hash).
T3
Admin credential.SystemRecordsSeeder creates it when missing, independently of the Admin row (so existing development databases get it): PasswordHasher<UserCredential> over Admin:InitialPassword, new security stamp, user_name = the Admin id, LockoutEnabled = true, must_change_password = true. Admin:InitialPassword is read only when the credential is created.
T4
Sessions. Refresh token = 32 random bytes (RandomNumberGenerator), base64url; only its SHA-256 is stored. Rotation is oneUPDATE … SET token_hash = new, expires_at = now + idle WHERE token_hash = old AND expires_at > now (ExecuteUpdateAsync), so two refreshes with the same token cannot both succeed. A session is valid while now < expires_at; expires_at = now + Auth:RefreshTokenIdleHours at sign-in and at each refresh. Session rows are infrastructure, not aggregates: no audit fields.
T5
JWT. HS256. Auth:SigningKey = base64 of at least 32 bytes (user secrets). Auth:Issuer = Auth:Audience = control-service (appsettings.json). Claims: sub (user id), sid (session id), iat, exp, and must_change_password = trueonly when the change is mandatory. No personal data in the token. Validation: MapInboundClaims = false, issuer, audience, lifetime, ClockSkew = TimeSpan.Zero, clock from TimeProvider (verify the JwtBearer / IdentityModel property). JwtAccessTokenIssuer lives in API/Auth (JwtBearer brings Microsoft.IdentityModel.JsonWebTokens).
T6
Cookie. Name __Secure-cs-refresh, Path=/api/v1/auth/refresh, HttpOnly, Secure, SameSite=Strict, Expires = the session's expires_at. Sign-out and change-password expire the cookie with the same name and Path (change-password then sets the new one). Tests use an https://localhost base address, or the Secure cookie never comes back.
T7
Generic 401. JwtBearer OnChallenge handles the response and writes Problem Details 401 session_expired with the D5 message through ErrorResults, so a missing, invalid or expired token looks like every other error (API-12).
T8
password_change_required filter. An endpoint filter on the /api/v1 group, built by one extension MapApiV1() in API/Common that Program.csand the test factory use. The group RequireAuthorization(); sign-in and refresh are AllowAnonymous(). me, sign-out and change-password opt out through endpoint metadata (for example .AllowPendingPasswordChange()). Tokens with must_change_password get 401 password_change_required (D5 message) on every other endpoint. #8 has no other real authenticated endpoint, so the test uses a test-only endpoint mapped through MapApiV1().
T9
Rate limiting as in D14 (AddRateLimiter, named policies on the sign-in and refresh endpoints). OnRejected writes 429 locked_out with the AUTH-26 message and Retry-After from the lease's RetryAfter metadata (verify the sliding window limiter provides it; if not, use the window length).
T10
Retry-After of the lockout. The handler returns the lockout error with Details["retryAfterSeconds"]; the sign-in endpoint copies it to the Retry-After header. The body therefore also carries details.retryAfterSeconds (additive; approved by the owner). Document it in the Errors table of authentication.md. The rate-limit 429 carries the same detail when the wait is known.
T11
Options validated at startup (ValidateOnStart, like AdminOptions in #7). AuthOptions (Auth section): AccessTokenMinutes 15, RefreshTokenIdleHours 8, SigningKey, Issuer, Audience, PasswordMinLength 8, LockoutMaxFailedAttempts 5, LockoutMinutes 15. AdminOptions.InitialPassword: required, at least PasswordMinLength. Failure messages name the key (Admin:InitialPassword, Auth:SigningKey) and never print the value.
T12
Test support. Move FixedTimeProvider to Api.IntegrationTests/Common and add Advance(TimeSpan). ApiFactory also sets fictitious Admin:InitialPassword and Auth:SigningKey test values. Auth/AuthApiFactory: controllable clock, https://localhost base address, rate limits high enough (for example 1000) that no other test hits them, and the test-only endpoint /api/v1/test-only/protected mapped through MapApiV1(). Auth/RateLimitedApiFactory: PermitLimit = 2. Tests create users through one helper (active user + credential with a password, unique login per test). Application.Tests/Fakes: in-memory fakes of the T1 interfaces, a fake IUnitOfWork and its own fixed TimeProvider (hand-written, ADR-0033).
New rule IDs to add to docs/ in the same pull request
ID
Document
Rule (write it in English, like the others)
AUTH-24
authentication.md, First access of the Admin
Changing the password without a mandatory change is refused (403 forbidden) with "Sua senha já foi criada. Para trocá-la, use “Esqueci minha senha”."
AUTH-25
authentication.md, Passwords
The new password must be different from the initial one: "A nova senha precisa ser diferente da senha inicial." (field password)
AUTH-26
authentication.md, Signing in
Too many sign-in or refresh requests from the same address are refused (429 locked_out, Retry-After) with "Muitas tentativas em pouco tempo. Aguarde alguns instantes e tente de novo."
AUTH-27
authentication.md, Sessions
The server's message for 401 session_expired, password_change_required and account_inactive (during a session) is, respectively, the "Session ended" message, the AUTH-14 text and the "Deactivated account" message.
AUTH-28
authentication.md, Credentials and links
Messages that state a duration or a limit show the configured value (AUTH-22).
USR-34
users.md, The Admin system user
The Admin's activatedAt is filled when it replaces the initial password on its first access.
Also in authentication.md: the response shapes of D1 and D2 in the Operations table; the 5th-attempt behavior of D6; the D7 note; 403 forbidden (AUTH-24), 401 account_inactive on refresh (D10) and details.retryAfterSeconds (T10) in the Errors table; the AUTH-26 message in the Messages table.
Test list, in order
Domain (ControlService.Domain.Tests)
#
Test
Rule
Expected result
1
Completing_the_first_access_fills_activated_at
USR-34
Admin → CompleteFirstAccess(t) → ActivatedAt = t, Status = Active (the system-record guard does not apply)
Success: access token from the fake for (userId, sessionId, no flag), expiresIn = 900, mustChangePassword = false, refresh token returned for the endpoint, one session started for the user
Fake returns LockedOut ending at now + 10 min → locked_out, Muitas tentativas sem sucesso. Aguarde 15 minutos e tente de novo., Details["retryAfterSeconds"] = 600
9
Lockout_message_uses_the_configured_minutes
AUTH-28
LockoutMinutes = 30 → Muitas tentativas sem sucesso. Aguarde 30 minutos e tente de novo.
id, displayName, login; levels has every key of ScreenKeys.All in catalog order; with one profile granting Editor on gerenciamento/usuarios: that screen editor, all others negado
27
Me_without_profiles_is_denied_everywhere
USR-21, PERM-05
Every screen negado
28
Me_of_the_admin_is_manager_everywhere
PERM-06, PERM-23
Every screen gerenciador
Infrastructure (ControlService.Api.IntegrationTests/Auth, real PostgreSQL)
200 mustChangePassword: false, new cookie; new token → test-only endpoint 200; sign-in with the old password → 401, with the new one → 200; users.activated_at filled; users.updated_by = Admin id
55
Change_password_ends_the_other_sessions
ADR-0019
A second session's cookie → refresh → 401 session_expired
A hand-written capturing ILoggerProvider at Trace, during sign-in, refresh and change-password: no captured line contains the password, the access token or the refresh cookie value
The existing tests must stay green on the changed factories. ErrorResultsTests needs no new code (forbidden, locked_out and the others are already mapped); the sign-in endpoint returns the 403 of account_inactive explicitly (architecture guide).
Docs:docs/product/features/authentication.md (AUTH-24 to AUTH-28 and the items listed above); docs/product/features/users.md (USR-34); docs/agents/guides/local-environment.md (new Auth:* and RateLimiting:* keys, dotnet user-secrets commands for Auth:SigningKey and Admin:InitialPassword, how to generate a 32-byte base64 key in PowerShell 5.1 without printing a real one); docs/agents/guides/architecture.md (Auth feature, Identity context, MapApiV1, sessions); docs/agents/guides/testing.md (AuthApiFactory, https base address, rate-limit factory); README roadmap.
The global rate limit of ADR-0023 and cleanup of expired user_sessions rows: new issues.
CORS (OQ-07), the Bearer security scheme in Scalar, reuse detection of rotated refresh tokens.
Any change to .github/ or CI. If CI needs a change, stop and ask.
For the owner, before the end-to-end check
Start Docker Desktop.
From backend/ControlService: dotnet user-secrets set "Auth:SigningKey" "<base64 of 32 random bytes>" --project src/ControlService.API and dotnet user-secrets set "Admin:InitialPassword" "<at least 8 characters>" --project src/ControlService.API.
Run dotnet dev-certs https --trust before testing in the browser (agents must never run it).
Activity
feat(application): application and API building blocks (handlers, val…
MendesMat commentedon Sep 28, 2026
Scope note carried over from the plan of #7 (decision D3):
ICurrentUser(created in Persistence: EF Core, PostgreSQL, audit, concurrency and seeded system records #7, implemented byAPI/Common/HttpCurrentUser) reads the user id from thesubclaim of the authenticated principal. The access token issued here must carry the user id insub, and the JWT bearer setup must keep that name (MapInboundClaims = false), or every save made by a signed-in person fails.activatedAtempty (USR-32). Decide in this issue whether the Admin's first password change (AUTH-14) fills it.Admin:InitialPassword, AUTH-13) was left out of Persistence: EF Core, PostgreSQL, audit, concurrency and seeded system records #7's seed: it belongs to the Identity tables created here.MendesMat commentedon Sep 30, 2026
Plan (approved by the owner on 2026-09-30)
This comment is the hand-over from the plan session to the build session (implement-a-feature.md). Work in pair mode (ADR-0033): one Red → Green → Refactor cycle per test, one pause at the end of each cycle. If anything below turns out wrong or incomplete, stop and ask; never replace a planned approach silently. Items marked verify depend on .NET 10 / Identity / IdentityModel APIs: check the current documentation before writing them, and stop and ask if the API does not behave as described.
Read before starting:
docs/product/features/authentication.md,docs/product/features/users.md(The Admin system user),docs/product/features/permission-profiles.md(PERM-05, PERM-06, PERM-23),docs/product/conventions.md,docs/api/conventions.md, ADR-0009, ADR-0019, ADR-0020, ADR-0022, ADR-0023, ADR-0032,docs/agents/guides/architecture.md,docs/agents/guides/testing.md,docs/agents/guides/local-environment.md, and the plan comment of #7 (decisions D3 and D9).Owner's decisions
POST /auth/sign-in,POST /auth/refreshandPOST /auth/change-passwordanswer 200 with{ "accessToken": "<jwt>", "expiresIn": 900, "mustChangePassword": false }.expiresInis in seconds (not a timestamp), so a wrong clock on the person's computer does not break the refresh decision. Refresh without a valid session → 401session_expired.POST /auth/sign-out→ 204. The front-end's HTTP client keeps the token and returns to the screens what the prototype returns today.me()levels.GET /api/v1/me→ 200{ id, displayName, login, levels }, wherelevelsis a list[{ "screen": "<key>", "level": "<wire value>" }]with every screen ofScreenKeys.All, in catalog order, includingnegado. Same shape as a profile'slevels./api/v1/auth/refresh(ADR-0019 unchanged), so sign-out never receives it. The access token carries asidclaim with the session id; sign-out ends that session by id and expires the cookie (same name andPath). Other sessions of the same person stay open.forbidden, message "Sua senha já foi criada. Para trocá-la, use “Esqueci minha senha”." New rule AUTH-24.messagefor session codes (API-12 requires one): 401session_expired→ "Sua sessão terminou. Entre de novo para continuar. Suas abas continuam abertas."; 401password_change_required→ "Por segurança, a senha inicial precisa ser trocada no primeiro acesso."; 401account_inactive(during a session, including refresh) → "Este acesso está desativado. Fale com o responsável pelo sistema." New rule AUTH-27. Rate limit 429locked_out→ new message "Muitas tentativas em pouco tempo. Aguarde alguns instantes e tente de novo.", new rule AUTH-26 (the lockout message says "15 minutos", which is not the rate-limit wait).locked_out. While locked, even the right password gets 429 (lockout is checked before the password, and those attempts are not counted).Retry-After= seconds left until the lockout ends, rounded up. A successful sign-in resets the failure count ("consecutive").authentication.md(not a rule).password, "A nova senha precisa ser diferente da senha inicial." New rule AUTH-25.activatedAt(open item from #7, D9) is filled with the time of the first password change. New rule USR-34. This does not break USR-23, for the same reason as AUTH-15: it is the Admin's own first access, not an edit of the record. The change is authored by the Admin itself, read fromsub(D3 of #7).activatedAtdefinitionaccount_inactive(message of D5), and that session is removed.authentication.md. New rule AUTH-28: Messages that state a duration or a limit show the configured value.AddAuthentication.user_credentials(Identity's user:id=users.id, FKON DELETE RESTRICT;user_name= the user id, not the login, so it never needs syncing; Identity's e-mail and phone columns stay empty; extra columnmust_change_password boolean not null). Identity'suser_credential_claims,user_credential_logins,user_credential_tokens(required by the EF store, unused).user_sessions(id,user_idFKRESTRICT,token_hashunique,created_at,expires_at). Sign-out and password change delete session rows. Review the generated migration before committing (no Red for the migration itself: say so).Directory.Packages.props, no new version):Microsoft.AspNetCore.Identity.EntityFrameworkCorein Infrastructure,Microsoft.AspNetCore.Authentication.JwtBearerin the API. Rate limiting is in the shared framework.RemoteIpAddress;unknownwhen null, as inTestServer), no queue:RateLimiting:SignIn:PermitLimit= 20,RateLimiting:Refresh:PermitLimit= 60. The global limit of ADR-0023 is out of scope (a new issue).Technical decisions (proposed in the plan session, accepted by the owner)
Auth/SignIn,Auth/RefreshSession,Auth/SignOut,Auth/ChangePassword(withChangePasswordValidator, ADR-0008) andAuth/GetMe, plus interfaces with only the methods these tests need:Auth/ICredentialStore(check password with lockout →Succeeded/Failed/LockedOut(lockoutEnd); must-change flag; is-current-password; replace password, which clears the flag),Auth/ISessionStore(start, rotate, end one, end all),Auth/IAccessTokenIssuer(userId, sessionId, mustChangePassword → token +expiresIn),Users/IUserRepository(GetByLoginAsync,GetByIdAsync),PermissionProfiles/IPermissionProfileRepository(ListByIdsAsync). Handlers orchestrate; lockout counting lives in Identity.Infrastructure/Auth/UserCredential : IdentityUser<Guid>;AppDbContextinheritsIdentityUserContext<UserCredential, Guid>(no roles).AddIdentityCore<UserCredential>().AddEntityFrameworkStores<AppDbContext>(), noSignInManager(it pulls cookie authentication): useUserManager.IsLockedOutAsync,CheckPasswordAsync,AccessFailedAsync,ResetAccessFailedCountAsync. Password options: no digit/upper/lower/symbol,RequiredUniqueChars= 1,RequiredLength=Auth:PasswordMinLength, so Identity never refuses what the validator accepts. Lockout:MaxFailedAccessAttempts=Auth:LockoutMaxFailedAttempts,DefaultLockoutTimeSpan=Auth:LockoutMinutes,AllowedForNewUsers= true. Verify that Identity's lockout uses theTimeProviderfrom DI (tests 34, 40 depend on it). Passwords are never trimmed (CNV-03 is about stored text; a password is stored only as a hash).SystemRecordsSeedercreates it when missing, independently of the Admin row (so existing development databases get it):PasswordHasher<UserCredential>overAdmin:InitialPassword, new security stamp,user_name= the Admin id,LockoutEnabled= true,must_change_password= true.Admin:InitialPasswordis read only when the credential is created.RandomNumberGenerator), base64url; only its SHA-256 is stored. Rotation is oneUPDATE … SET token_hash = new, expires_at = now + idle WHERE token_hash = old AND expires_at > now(ExecuteUpdateAsync), so two refreshes with the same token cannot both succeed. A session is valid whilenow < expires_at;expires_at= now +Auth:RefreshTokenIdleHoursat sign-in and at each refresh. Session rows are infrastructure, not aggregates: no audit fields.Auth:SigningKey= base64 of at least 32 bytes (user secrets).Auth:Issuer=Auth:Audience=control-service(appsettings.json). Claims:sub(user id),sid(session id),iat,exp, andmust_change_password=trueonly when the change is mandatory. No personal data in the token. Validation:MapInboundClaims = false, issuer, audience, lifetime,ClockSkew = TimeSpan.Zero, clock fromTimeProvider(verify the JwtBearer / IdentityModel property).JwtAccessTokenIssuerlives inAPI/Auth(JwtBearer bringsMicrosoft.IdentityModel.JsonWebTokens).__Secure-cs-refresh,Path=/api/v1/auth/refresh,HttpOnly,Secure,SameSite=Strict,Expires= the session'sexpires_at. Sign-out and change-password expire the cookie with the same name andPath(change-password then sets the new one). Tests use anhttps://localhostbase address, or theSecurecookie never comes back.OnChallengehandles the response and writes Problem Details 401session_expiredwith the D5 message throughErrorResults, so a missing, invalid or expired token looks like every other error (API-12).password_change_requiredfilter. An endpoint filter on the/api/v1group, built by one extensionMapApiV1()inAPI/CommonthatProgram.csand the test factory use. The groupRequireAuthorization(); sign-in and refresh areAllowAnonymous().me,sign-outandchange-passwordopt out through endpoint metadata (for example.AllowPendingPasswordChange()). Tokens withmust_change_passwordget 401password_change_required(D5 message) on every other endpoint. #8 has no other real authenticated endpoint, so the test uses a test-only endpoint mapped throughMapApiV1().AddRateLimiter, named policies on the sign-in and refresh endpoints).OnRejectedwrites 429locked_outwith the AUTH-26 message andRetry-Afterfrom the lease'sRetryAftermetadata (verify the sliding window limiter provides it; if not, use the window length).Retry-Afterof the lockout. The handler returns the lockout error withDetails["retryAfterSeconds"]; the sign-in endpoint copies it to theRetry-Afterheader. The body therefore also carriesdetails.retryAfterSeconds(additive; approved by the owner). Document it in the Errors table ofauthentication.md. The rate-limit 429 carries the same detail when the wait is known.ValidateOnStart, likeAdminOptionsin #7).AuthOptions(Authsection):AccessTokenMinutes15,RefreshTokenIdleHours8,SigningKey,Issuer,Audience,PasswordMinLength8,LockoutMaxFailedAttempts5,LockoutMinutes15.AdminOptions.InitialPassword: required, at leastPasswordMinLength. Failure messages name the key (Admin:InitialPassword,Auth:SigningKey) and never print the value.FixedTimeProvidertoApi.IntegrationTests/Commonand addAdvance(TimeSpan).ApiFactoryalso sets fictitiousAdmin:InitialPasswordandAuth:SigningKeytest values.Auth/AuthApiFactory: controllable clock,https://localhostbase address, rate limits high enough (for example 1000) that no other test hits them, and the test-only endpoint/api/v1/test-only/protectedmapped throughMapApiV1().Auth/RateLimitedApiFactory:PermitLimit= 2. Tests create users through one helper (active user + credential with a password, unique login per test).Application.Tests/Fakes: in-memory fakes of the T1 interfaces, a fakeIUnitOfWorkand its own fixedTimeProvider(hand-written, ADR-0033).New rule IDs to add to
docs/in the same pull requestauthentication.md, First access of the Adminforbidden) with "Sua senha já foi criada. Para trocá-la, use “Esqueci minha senha”."authentication.md, Passwordspassword)authentication.md, Signing inlocked_out,Retry-After) with "Muitas tentativas em pouco tempo. Aguarde alguns instantes e tente de novo."authentication.md, Sessionsmessagefor 401session_expired,password_change_requiredandaccount_inactive(during a session) is, respectively, the "Session ended" message, the AUTH-14 text and the "Deactivated account" message.authentication.md, Credentials and linksusers.md, The Admin system useractivatedAtis filled when it replaces the initial password on its first access.Also in
authentication.md: the response shapes of D1 and D2 in the Operations table; the 5th-attempt behavior of D6; the D7 note; 403forbidden(AUTH-24), 401account_inactiveon refresh (D10) anddetails.retryAfterSeconds(T10) in the Errors table; the AUTH-26 message in the Messages table.Test list, in order
Domain (
ControlService.Domain.Tests)Completing_the_first_access_fills_activated_atCompleteFirstAccess(t)→ActivatedAt= t,Status=Active(the system-record guard does not apply)Completing_the_first_access_again_keeps_the_first_timeActivatedAtstill tApplication (
ControlService.Application.Tests, hand-written fakes)Sign_in_with_the_right_password_returns_a_token_and_starts_a_sessionexpiresIn= 900,mustChangePassword= false, refresh token returned for the endpoint, one session started for the userUnknown_login_is_refused_as_invalid_credentialsinvalid_credentials,Login ou senha incorretos.Login_outside_the_login_format_is_refused_as_invalid_credentials"a"→ same error as test 4Wrong_password_is_refused_with_the_same_error_as_an_unknown_loginErrorequal to test 4'sPending_account_is_refused_as_invalid_credentialsinvalid_credentialsLocked_out_login_is_refused_even_with_the_right_passwordLockedOutending at now + 10 min →locked_out,Muitas tentativas sem sucesso. Aguarde 15 minutos e tente de novo.,Details["retryAfterSeconds"]= 600Lockout_message_uses_the_configured_minutesLockoutMinutes= 30 →Muitas tentativas sem sucesso. Aguarde 30 minutos e tente de novo.Deactivated_account_with_the_right_password_is_refusedaccount_inactive,Este acesso está desativado. Fale com o responsável pelo sistema.; no session startedDeactivated_account_with_a_wrong_password_gets_invalid_credentialsinvalid_credentialsAccount_with_a_mandatory_password_change_gets_a_restricted_tokenmustChangePassword= true; token issued with the flagRefresh_with_a_valid_token_rotates_it_and_issues_a_new_access_tokenRefresh_with_an_unknown_or_expired_token_is_refused_as_session_expiredsession_expired,Sua sessão terminou. Entre de novo para continuar. Suas abas continuam abertas.Refresh_of_a_deactivated_account_is_refused_and_ends_the_sessionaccount_inactivewith the test 10 message; the session is endedRefresh_keeps_the_mandatory_password_changemustChangePassword= true; token issued with the flagSign_out_ends_only_the_current_sessionsidsession is ended; another session of the same user remainsShort_password_is_refusedvalidation_failed,Alguns campos precisam ser corrigidos.,Fields["password"]= [A senha precisa ter pelo menos 8 caracteres.]Password_of_exactly_the_minimum_length_is_acceptedDifferent_confirmation_is_refusedFields["passwordConfirmation"]= [As duas senhas não são iguais. Digite de novo.]Minimum_length_message_uses_the_configured_lengthPasswordMinLength= 10 →A senha precisa ter pelo menos 10 caracteres.Password_equal_to_the_initial_one_is_refusedvalidation_failed,Fields["password"]= [A nova senha precisa ser diferente da senha inicial.]Account_without_a_mandatory_change_cannot_use_change_passwordforbidden,Sua senha já foi criada. Para trocá-la, use “Esqueci minha senha”.Changing_the_password_ends_every_session_and_starts_a_new_onemustChangePassword= falseChanging_the_initial_password_fills_the_admin_activation_timeActivatedAt= the clock's time; saved throughIUnitOfWorkMe_returns_the_person_and_every_screen_of_the_catalogid,displayName,login;levelshas every key ofScreenKeys.Allin catalog order; with one profile granting Editor ongerenciamento/usuarios: that screeneditor, all othersnegadoMe_without_profiles_is_denied_everywherenegadoMe_of_the_admin_is_manager_everywheregerenciadorInfrastructure (
ControlService.Api.IntegrationTests/Auth, real PostgreSQL)Startup_creates_the_admin_credential_with_a_mandatory_changemust_change_password= trueRestarting_does_not_change_the_admin_credentialAdmin:InitialPassword: the original password still verifies, the new one does notApi_does_not_start_without_a_valid_initial_password(Theory: empty,1234567)Admin:InitialPasswordand does not contain the valueApi_does_not_start_without_a_valid_signing_key(Theory: empty, base64 of 16 bytes)Auth:SigningKeyCredential_store_locks_the_login_on_the_fifth_consecutive_failureFailed; failure 5 →LockedOutending at now + 15 minLockout_ends_after_the_configured_minutesSucceededA_successful_check_resets_the_failure_countFailed(not locked)Session_store_keeps_only_the_hash_of_the_refresh_tokenuser_sessions.token_hash= SHA-256 of the token; the plain token is stored nowhereAPI (
ControlService.Api.IntegrationTests/Auth, end to end over HTTP)Sign_in_returns_200_with_an_access_token_and_the_refresh_cookie{ accessToken: <non-empty>, expiresIn: 900, mustChangePassword: false };Set-Cookie__Secure-cs-refreshwithHttpOnly,Secure,SameSite=Strict,Path=/api/v1/auth/refreshAccess_token_carries_the_user_id_in_sub_and_the_session_in_sidsub= the user id,sidpresent, nomust_change_password,exp−iat= 900Wrong_password_returns_401_invalid_credentialscodeandmessageof test 4Fifth_wrong_password_returns_429_locked_out_with_retry_afterlocked_out,…Aguarde 15 minutos e tente de novo.,Retry-After: 900,details.retryAfterSeconds= 900Deactivated_account_returns_403_account_inactiveaccount_inactive, test 10 messageMe_returns_the_signed_in_person_and_their_levels{ id, displayName, login, levels: [{ screen, level }] }for a user with one profileMe_without_a_token_returns_401_session_expiredsession_expired, test 14 messageExpired_access_token_returns_401_session_expiredme→ 401session_expiredRefresh_rotates_the_cookie_and_the_old_one_stops_workingsession_expiredRefresh_slides_the_session_for_another_8_hoursRefresh_after_8_idle_hours_returns_401_session_expiredsession_expiredRefresh_without_a_cookie_returns_401_session_expiredsession_expiredRefresh_of_a_deactivated_account_returns_401_account_inactiveaccount_inactive; refresh again → 401session_expiredSign_out_returns_204_expires_the_cookie_and_ends_the_sessionSet-Cookieexpiring__Secure-cs-refreshwith the samePath; refresh with the old value → 401session_expiredAdmin_with_the_initial_password_signs_in_with_must_change_passwordmustChangePassword: true; JWT hasmust_change_passwordAdmin_with_the_initial_password_gets_401_password_change_required_on_another_endpointpassword_change_required,Por segurança, a senha inicial precisa ser trocada no primeiro acesso.Admin_with_the_initial_password_can_use_me_and_sign_outme→ 200;sign-out→ 204Change_password_returns_new_tokens_and_unlocks_other_endpointsmustChangePassword: false, new cookie; new token → test-only endpoint 200; sign-in with the old password → 401, with the new one → 200;users.activated_atfilled;users.updated_by= Admin idChange_password_ends_the_other_sessionssession_expiredChange_password_with_invalid_fields_returns_400_with_field_messagesvalidation_failed,errors.passwordanderrors.passwordConfirmationwith the test 18 and 20 messagesChange_password_without_a_mandatory_change_returns_403_forbiddenforbidden, test 23 messageSign_in_over_the_rate_limit_returns_429_with_retry_afterRateLimitedApiFactory: 3rd request → 429locked_out,Muitas tentativas em pouco tempo. Aguarde alguns instantes e tente de novo.,Retry-AfterpresentRefresh_over_the_rate_limit_returns_429Authentication_writes_no_password_or_token_to_the_logsILoggerProvideratTrace, during sign-in, refresh and change-password: no captured line contains the password, the access token or the refresh cookie valueThe existing tests must stay green on the changed factories.
ErrorResultsTestsneeds no new code (forbidden,locked_outand the others are already mapped); the sign-in endpoint returns the 403 ofaccount_inactiveexplicitly (architecture guide).Files to create or change
Users/User.cs(CompleteFirstAccess(DateTimeOffset)).Auth/SignIn/,Auth/RefreshSession/,Auth/SignOut/,Auth/ChangePassword/(with validator),Auth/GetMe/;Auth/ICredentialStore.cs,Auth/ISessionStore.cs,Auth/IAccessTokenIssuer.cs;Users/IUserRepository.cs;PermissionProfiles/IPermissionProfileRepository.cs.Auth/UserCredential.cs,Auth/CredentialStore.cs,Auth/UserSession.cs,Auth/SessionStore.cs,Auth/AuthOptions.cs;Persistence/AppDbContext.cs,Persistence/Configurations/(credentials, sessions),Persistence/UserRepository.cs,Persistence/PermissionProfileRepository.cs,Persistence/SystemRecordsSeeder.cs,Persistence/AdminOptions.cs(InitialPassword); migrationAddAuthentication;DependencyInjection.cs;.csproj(Identity).Auth/AuthEndpoints.cs(MapAuthEndpoints: sign-in, refresh, sign-out, change-password;me),Auth/JwtAccessTokenIssuer.cs,Auth/PasswordChangeRequiredFilter.cs;Common/ApiV1Group.cs(MapApiV1);Program.cs(authentication, authorization, rate limiter,MapApiV1);appsettings.json(issuer, audience, durations, limits; no secrets);ControlService.API.http;.csproj(JwtBearer).Application.Tests/Fakes/*,Application.Tests/Auth/*Tests.cs;Api.IntegrationTests/Common/FixedTimeProvider.cs(moved,Advance),ApiFactory.cs(test secrets),Auth/AuthApiFactory.cs,Auth/RateLimitedApiFactory.cs,Auth/*Tests.cs, capturing logger provider; DomainUsers/UserTests.cs.docs/product/features/authentication.md(AUTH-24 to AUTH-28 and the items listed above);docs/product/features/users.md(USR-34);docs/agents/guides/local-environment.md(newAuth:*andRateLimiting:*keys,dotnet user-secretscommands forAuth:SigningKeyandAdmin:InitialPassword, how to generate a 32-byte base64 key in PowerShell 5.1 without printing a real one);docs/agents/guides/architecture.md(Auth feature, Identity context,MapApiV1, sessions);docs/agents/guides/testing.md(AuthApiFactory,httpsbase address, rate-limit factory); README roadmap.Out of scope
me,sign-outandchange-password.ICredentialStore.user_sessionsrows: new issues..github/or CI. If CI needs a change, stop and ask.For the owner, before the end-to-end check
backend/ControlService:dotnet user-secrets set "Auth:SigningKey" "<base64 of 32 random bytes>" --project src/ControlService.APIanddotnet user-secrets set "Admin:InitialPassword" "<at least 8 characters>" --project src/ControlService.API.dotnet dev-certs https --trustbefore testing in the browser (agents must never run it).feat(auth): sign in, sessions and the current user (#28)