Repository navigation
Authentication: sign-in, sessions and the Admin's first access #8
Description
Activity
- added a commit that references this issue
on 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.- The Admin is seeded in Persistence: EF Core, PostgreSQL, audit, concurrency and seeded system records #7 with
activatedAtempty (USR-32). Decide in this issue whether the Admin's first password change (AUTH-14) fills it. - The Admin's initial password (
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.
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
# Decision Settles D1 Session responses. 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.Operations table, ADR-0019, ADR-0032 D2 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.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 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.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 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).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 fromsub(D3 of #7).USR-32, USR-23, AUTH-15, users.md activatedAtdefinitionD10 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, 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).ADR-0019, AUTH-19 D13 Package references approved (versions already in Directory.Packages.props, no new version):Microsoft.AspNetCore.Identity.EntityFrameworkCorein Infrastructure,Microsoft.AspNetCore.Authentication.JwtBearerin 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;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).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(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.T2 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).T3 Admin credential. 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.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 whilenow < expires_at;expires_at= now +Auth:RefreshTokenIdleHoursat 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, 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).T6 Cookie. Name __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.T7 Generic 401. JwtBearer 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).T8 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().T9 Rate limiting as in D14 ( 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).T10 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.T11 Options validated at startup ( 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.T12 Test support. Move 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 requestID Document Rule (write it in English, like the others) AUTH-24 authentication.md, First access of the AdminChanging 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, PasswordsThe 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 inToo 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, SessionsThe server's messagefor 401session_expired,password_change_requiredandaccount_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 linksMessages that state a duration or a limit show the configured value (AUTH-22). USR-34 users.md, The Admin system userThe Admin's activatedAtis 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)# Test Rule Expected result 1 Completing_the_first_access_fills_activated_atUSR-34 Admin → CompleteFirstAccess(t)→ActivatedAt= t,Status=Active(the system-record guard does not apply)2 Completing_the_first_access_again_keeps_the_first_timeUSR-34 Second call at t + 1 h → ActivatedAtstill tApplication (
ControlService.Application.Tests, hand-written fakes)# Test Rule Expected result 3 Sign_in_with_the_right_password_returns_a_token_and_starts_a_sessionAUTH-07 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 user4 Unknown_login_is_refused_as_invalid_credentialsAUTH-08 invalid_credentials,Login ou senha incorretos.5 Login_outside_the_login_format_is_refused_as_invalid_credentialsAUTH-08 "a"→ same error as test 46 Wrong_password_is_refused_with_the_same_error_as_an_unknown_loginAUTH-08 Errorequal to test 4's7 Pending_account_is_refused_as_invalid_credentialsAUTH-02, AUTH-08 Pending user without a credential → invalid_credentials8 Locked_out_login_is_refused_even_with_the_right_passwordAUTH-08, D6 Fake returns LockedOutending at now + 10 min →locked_out,Muitas tentativas sem sucesso. Aguarde 15 minutos e tente de novo.,Details["retryAfterSeconds"]= 6009 Lockout_message_uses_the_configured_minutesAUTH-28 LockoutMinutes= 30 →Muitas tentativas sem sucesso. Aguarde 30 minutos e tente de novo.10 Deactivated_account_with_the_right_password_is_refusedAUTH-09 account_inactive,Este acesso está desativado. Fale com o responsável pelo sistema.; no session started11 Deactivated_account_with_a_wrong_password_gets_invalid_credentialsAUTH-08, AUTH-09 invalid_credentials12 Account_with_a_mandatory_password_change_gets_a_restricted_tokenAUTH-14, ADR-0032 mustChangePassword= true; token issued with the flag13 Refresh_with_a_valid_token_rotates_it_and_issues_a_new_access_tokenAUTH-17 New refresh token ≠ old; access token for the same sessionId 14 Refresh_with_an_unknown_or_expired_token_is_refused_as_session_expiredAUTH-17, AUTH-27 session_expired,Sua sessão terminou. Entre de novo para continuar. Suas abas continuam abertas.15 Refresh_of_a_deactivated_account_is_refused_and_ends_the_sessionAUTH-18, AUTH-27, D10 account_inactivewith the test 10 message; the session is ended16 Refresh_keeps_the_mandatory_password_changeAUTH-14 mustChangePassword= true; token issued with the flag17 Sign_out_ends_only_the_current_sessionADR-0019, D3 The sidsession is ended; another session of the same user remains18 Short_password_is_refusedAUTH-16, API-15 7 characters → validation_failed,Alguns campos precisam ser corrigidos.,Fields["password"]= [A senha precisa ter pelo menos 8 caracteres.]19 Password_of_exactly_the_minimum_length_is_acceptedAUTH-16 8 characters → success 20 Different_confirmation_is_refusedAUTH-16 Fields["passwordConfirmation"]= [As duas senhas não são iguais. Digite de novo.]21 Minimum_length_message_uses_the_configured_lengthAUTH-28 PasswordMinLength= 10 →A senha precisa ter pelo menos 10 caracteres.22 Password_equal_to_the_initial_one_is_refusedAUTH-25 validation_failed,Fields["password"]= [A nova senha precisa ser diferente da senha inicial.]23 Account_without_a_mandatory_change_cannot_use_change_passwordAUTH-24 forbidden,Sua senha já foi criada. Para trocá-la, use “Esqueci minha senha”.24 Changing_the_password_ends_every_session_and_starts_a_new_oneADR-0019, ADR-0032 Password replaced, flag cleared, all the user's sessions ended, one new session, mustChangePassword= false25 Changing_the_initial_password_fills_the_admin_activation_timeUSR-34 Admin's ActivatedAt= the clock's time; saved throughIUnitOfWork26 Me_returns_the_person_and_every_screen_of_the_catalogOperations, PERM-05, D2 id,displayName,login;levelshas every key ofScreenKeys.Allin catalog order; with one profile granting Editor ongerenciamento/usuarios: that screeneditor, all othersnegado27 Me_without_profiles_is_denied_everywhereUSR-21, PERM-05 Every screen negado28 Me_of_the_admin_is_manager_everywherePERM-06, PERM-23 Every screen gerenciadorInfrastructure (
ControlService.Api.IntegrationTests/Auth, real PostgreSQL)# Test Rule Expected result 29 Startup_creates_the_admin_credential_with_a_mandatory_changeAUTH-13, ADR-0022 A credential with the Admin id exists; the configured initial password verifies; must_change_password= true30 Restarting_does_not_change_the_admin_credentialAUTH-13, T3 Second host start with another Admin:InitialPassword: the original password still verifies, the new one does not31 Api_does_not_start_without_a_valid_initial_password(Theory: empty,1234567)AUTH-13, AUTH-16 Host start throws; message contains Admin:InitialPasswordand does not contain the value32 Api_does_not_start_without_a_valid_signing_key(Theory: empty, base64 of 16 bytes)ADR-0019, T5 Host start throws; message contains Auth:SigningKey33 Credential_store_locks_the_login_on_the_fifth_consecutive_failureAUTH-08, D6 Failures 1–4 → Failed; failure 5 →LockedOutending at now + 15 min34 Lockout_ends_after_the_configured_minutesAUTH-08 Clock + 15 min → right password → Succeeded35 A_successful_check_resets_the_failure_countAUTH-08 4 failures, 1 success, 4 failures → Failed(not locked)36 Session_store_keeps_only_the_hash_of_the_refresh_tokenAUTH-19 user_sessions.token_hash= SHA-256 of the token; the plain token is stored nowhereAPI (
ControlService.Api.IntegrationTests/Auth, end to end over HTTP)# Test Rule Expected result 37 Sign_in_returns_200_with_an_access_token_and_the_refresh_cookieAUTH-07, AUTH-17, D1 200 { accessToken: <non-empty>, expiresIn: 900, mustChangePassword: false };Set-Cookie__Secure-cs-refreshwithHttpOnly,Secure,SameSite=Strict,Path=/api/v1/auth/refresh38 Access_token_carries_the_user_id_in_sub_and_the_session_in_sidADR-0019, D3 of #7 Decoded JWT: sub= the user id,sidpresent, nomust_change_password,exp−iat= 90039 Wrong_password_returns_401_invalid_credentialsAUTH-08 401, codeandmessageof test 440 Fifth_wrong_password_returns_429_locked_out_with_retry_afterAUTH-08, D6, T10 Attempts 1–4 → 401; attempt 5 → 429 locked_out,…Aguarde 15 minutos e tente de novo.,Retry-After: 900,details.retryAfterSeconds= 90041 Deactivated_account_returns_403_account_inactiveAUTH-09 403, account_inactive, test 10 message42 Me_returns_the_signed_in_person_and_their_levelsOperations, D2 200 { id, displayName, login, levels: [{ screen, level }] }for a user with one profile43 Me_without_a_token_returns_401_session_expiredADR-0009, AUTH-27, T7 401 session_expired, test 14 message44 Expired_access_token_returns_401_session_expiredAUTH-17 Clock + 15 min → me→ 401session_expired45 Refresh_rotates_the_cookie_and_the_old_one_stops_workingAUTH-17 200 with a new cookie value ≠ old; refresh with the old value → 401 session_expired46 Refresh_slides_the_session_for_another_8_hoursAUTH-17, ADR-0032 Refresh at + 7 h → 200; again at + 14 h → 200 47 Refresh_after_8_idle_hours_returns_401_session_expiredAUTH-17, ADR-0032 Refresh at exactly + 8 h → 401 session_expired48 Refresh_without_a_cookie_returns_401_session_expiredAUTH-17 401 session_expired49 Refresh_of_a_deactivated_account_returns_401_account_inactiveAUTH-18, D10 User deactivated through the domain and saved → refresh → 401 account_inactive; refresh again → 401session_expired50 Sign_out_returns_204_expires_the_cookie_and_ends_the_sessionADR-0019, D3 204; Set-Cookieexpiring__Secure-cs-refreshwith the samePath; refresh with the old value → 401session_expired51 Admin_with_the_initial_password_signs_in_with_must_change_passwordAUTH-13, AUTH-14 200 mustChangePassword: true; JWT hasmust_change_password52 Admin_with_the_initial_password_gets_401_password_change_required_on_another_endpointAUTH-14, AUTH-27, ADR-0032 Test-only endpoint → 401 password_change_required,Por segurança, a senha inicial precisa ser trocada no primeiro acesso.53 Admin_with_the_initial_password_can_use_me_and_sign_outADR-0032 me→ 200;sign-out→ 20454 Change_password_returns_new_tokens_and_unlocks_other_endpointsAUTH-14, USR-34, D3 of #7 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_atfilled;users.updated_by= Admin id55 Change_password_ends_the_other_sessionsADR-0019 A second session's cookie → refresh → 401 session_expired56 Change_password_with_invalid_fields_returns_400_with_field_messagesAUTH-16, API-15 400 validation_failed,errors.passwordanderrors.passwordConfirmationwith the test 18 and 20 messages57 Change_password_without_a_mandatory_change_returns_403_forbiddenAUTH-24 403 forbidden, test 23 message58 Sign_in_over_the_rate_limit_returns_429_with_retry_afterAUTH-26, ADR-0023 RateLimitedApiFactory: 3rd request → 429locked_out,Muitas tentativas em pouco tempo. Aguarde alguns instantes e tente de novo.,Retry-Afterpresent59 Refresh_over_the_rate_limit_returns_429AUTH-26, ADR-0023 Same as test 58 on refresh 60 Authentication_writes_no_password_or_token_to_the_logsIssue DoD, AUTH-20 A hand-written capturing ILoggerProvideratTrace, 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
- Domain:
Users/User.cs(CompleteFirstAccess(DateTimeOffset)). - Application:
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. - Infrastructure:
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). - API:
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). - Tests:
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:
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
- Per-request account check and HybridCache (Authorization per screen and per-request account checks #9). Authorization per screen and per-request account checks #9 must also apply the account check to
me,sign-outandchange-password. - Revoking sessions on deactivation (AUTH-21) and every user endpoint (Users endpoints with a temporary password #11).
- Activation and reset links, and clearing a lockout through a reset (AUTH-12): Future: account activation and password reset by e-mail #12. Future: account activation and password reset by e-mail #12 sets passwords through the same
ICredentialStore. - The global rate limit of ADR-0023 and cleanup of expired
user_sessionsrows: 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.APIanddotnet user-secrets set "Admin:InitialPassword" "<at least 8 characters>" --project src/ControlService.API. - Run
dotnet dev-certs https --trustbefore testing in the browser (agents must never run it).
- Domain:
- added a commit that references this issue
on Sep 30, 2026
Goal
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
HttpOnlycookie scoped to the refresh endpoint; revoked on sign-out (AUTH-17, ADR-0032).must_change_password, every other endpoint answers 401password_change_requireduntil it is changed (AUTH-13, AUTH-14, AUTH-15).signIn,hasSession(refresh),signOut,changePassword,mewith the effective level on every screen, as listed indocs/product/features/authentication.md#operations.Out of scope
Activation and reset links (#11, #12), per-screen authorization (#9).
Done when
locked_out); deactivated account (403account_inactive); refresh after 8 idle hours fails (fakeTimeProvider); Admin with the initial password gets 401password_change_requiredon another endpoint.docs/product/features/authentication.md#messages.dotnet dev-certs https --trustbefore testing in the browser.References
docs/product/features/authentication.md,docs/api/conventions.md, ADR-0019, ADR-0022, ADR-0023, ADR-0032.