Skip to content

Authentication: sign-in, sessions and the Admin's first access #8

Description

@MendesMat

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

  • 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.
  • Build with zero warnings, all tests pass.

References

docs/product/features/authentication.md, docs/api/conventions.md, ADR-0019, ADR-0022, ADR-0023, ADR-0032.

Activity

  1. MendesMat commented on Sep 28, 2026

    @MendesMat
    OwnerAuthor

    Scope note carried over from the plan of #7 (decision D3):

  2. MendesMat commented on Sep 30, 2026

    @MendesMat
    OwnerAuthor

    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/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). USR-32, USR-23, AUTH-15, users.md activatedAt definition
    D10 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_hash unique, 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 one UPDATE … 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 = true only 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.cs and 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)
    2 Completing_the_first_access_again_keeps_the_first_time USR-34 Second call at t + 1 h → ActivatedAt still t

    Application (ControlService.Application.Tests, hand-written fakes)

    # Test Rule Expected result
    3 Sign_in_with_the_right_password_returns_a_token_and_starts_a_session AUTH-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 user
    4 Unknown_login_is_refused_as_invalid_credentials AUTH-08 invalid_credentials, Login ou senha incorretos.
    5 Login_outside_the_login_format_is_refused_as_invalid_credentials AUTH-08 "a" → same error as test 4
    6 Wrong_password_is_refused_with_the_same_error_as_an_unknown_login AUTH-08 Error equal to test 4's
    7 Pending_account_is_refused_as_invalid_credentials AUTH-02, AUTH-08 Pending user without a credential → invalid_credentials
    8 Locked_out_login_is_refused_even_with_the_right_password AUTH-08, D6 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.
    10 Deactivated_account_with_the_right_password_is_refused AUTH-09 account_inactive, Este acesso está desativado. Fale com o responsável pelo sistema.; no session started
    11 Deactivated_account_with_a_wrong_password_gets_invalid_credentials AUTH-08, AUTH-09 invalid_credentials
    12 Account_with_a_mandatory_password_change_gets_a_restricted_token AUTH-14, ADR-0032 mustChangePassword = true; token issued with the flag
    13 Refresh_with_a_valid_token_rotates_it_and_issues_a_new_access_token AUTH-17 New refresh token ≠ old; access token for the same sessionId
    14 Refresh_with_an_unknown_or_expired_token_is_refused_as_session_expired AUTH-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_session AUTH-18, AUTH-27, D10 account_inactive with the test 10 message; the session is ended
    16 Refresh_keeps_the_mandatory_password_change AUTH-14 mustChangePassword = true; token issued with the flag
    17 Sign_out_ends_only_the_current_session ADR-0019, D3 The sid session is ended; another session of the same user remains
    18 Short_password_is_refused AUTH-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_accepted AUTH-16 8 characters → success
    20 Different_confirmation_is_refused AUTH-16 Fields["passwordConfirmation"] = [As duas senhas não são iguais. Digite de novo.]
    21 Minimum_length_message_uses_the_configured_length AUTH-28 PasswordMinLength = 10 → A senha precisa ter pelo menos 10 caracteres.
    22 Password_equal_to_the_initial_one_is_refused AUTH-25 validation_failed, Fields["password"] = [A nova senha precisa ser diferente da senha inicial.]
    23 Account_without_a_mandatory_change_cannot_use_change_password AUTH-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_one ADR-0019, ADR-0032 Password replaced, flag cleared, all the user's sessions ended, one new session, mustChangePassword = false
    25 Changing_the_initial_password_fills_the_admin_activation_time USR-34 Admin's ActivatedAt = the clock's time; saved through IUnitOfWork
    26 Me_returns_the_person_and_every_screen_of_the_catalog Operations, PERM-05, D2 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)

    # Test Rule Expected result
    29 Startup_creates_the_admin_credential_with_a_mandatory_change AUTH-13, ADR-0022 A credential with the Admin id exists; the configured initial password verifies; must_change_password = true
    30 Restarting_does_not_change_the_admin_credential AUTH-13, T3 Second host start with another Admin:InitialPassword: the original password still verifies, the new one does not
    31 Api_does_not_start_without_a_valid_initial_password (Theory: empty, 1234567) AUTH-13, AUTH-16 Host start throws; message contains Admin:InitialPassword and does not contain the value
    32 Api_does_not_start_without_a_valid_signing_key (Theory: empty, base64 of 16 bytes) ADR-0019, T5 Host start throws; message contains Auth:SigningKey
    33 Credential_store_locks_the_login_on_the_fifth_consecutive_failure AUTH-08, D6 Failures 1–4 → Failed; failure 5 → LockedOut ending at now + 15 min
    34 Lockout_ends_after_the_configured_minutes AUTH-08 Clock + 15 min → right password → Succeeded
    35 A_successful_check_resets_the_failure_count AUTH-08 4 failures, 1 success, 4 failures → Failed (not locked)
    36 Session_store_keeps_only_the_hash_of_the_refresh_token AUTH-19 user_sessions.token_hash = SHA-256 of the token; the plain token is stored nowhere

    API (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_cookie AUTH-07, AUTH-17, D1 200 { accessToken: <non-empty>, expiresIn: 900, mustChangePassword: false }; Set-Cookie __Secure-cs-refresh with HttpOnly, Secure, SameSite=Strict, Path=/api/v1/auth/refresh
    38 Access_token_carries_the_user_id_in_sub_and_the_session_in_sid ADR-0019, D3 of #7 Decoded JWT: sub = the user id, sid present, no must_change_password, exp − iat = 900
    39 Wrong_password_returns_401_invalid_credentials AUTH-08 401, code and message of test 4
    40 Fifth_wrong_password_returns_429_locked_out_with_retry_after AUTH-08, D6, T10 Attempts 1–4 → 401; attempt 5 → 429 locked_out, …Aguarde 15 minutos e tente de novo., Retry-After: 900, details.retryAfterSeconds = 900
    41 Deactivated_account_returns_403_account_inactive AUTH-09 403, account_inactive, test 10 message
    42 Me_returns_the_signed_in_person_and_their_levels Operations, D2 200 { id, displayName, login, levels: [{ screen, level }] } for a user with one profile
    43 Me_without_a_token_returns_401_session_expired ADR-0009, AUTH-27, T7 401 session_expired, test 14 message
    44 Expired_access_token_returns_401_session_expired AUTH-17 Clock + 15 min → me → 401 session_expired
    45 Refresh_rotates_the_cookie_and_the_old_one_stops_working AUTH-17 200 with a new cookie value ≠ old; refresh with the old value → 401 session_expired
    46 Refresh_slides_the_session_for_another_8_hours AUTH-17, ADR-0032 Refresh at + 7 h → 200; again at + 14 h → 200
    47 Refresh_after_8_idle_hours_returns_401_session_expired AUTH-17, ADR-0032 Refresh at exactly + 8 h → 401 session_expired
    48 Refresh_without_a_cookie_returns_401_session_expired AUTH-17 401 session_expired
    49 Refresh_of_a_deactivated_account_returns_401_account_inactive AUTH-18, D10 User deactivated through the domain and saved → refresh → 401 account_inactive; refresh again → 401 session_expired
    50 Sign_out_returns_204_expires_the_cookie_and_ends_the_session ADR-0019, D3 204; Set-Cookie expiring __Secure-cs-refresh with the same Path; refresh with the old value → 401 session_expired
    51 Admin_with_the_initial_password_signs_in_with_must_change_password AUTH-13, AUTH-14 200 mustChangePassword: true; JWT has must_change_password
    52 Admin_with_the_initial_password_gets_401_password_change_required_on_another_endpoint AUTH-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_out ADR-0032 me → 200; sign-out → 204
    54 Change_password_returns_new_tokens_and_unlocks_other_endpoints AUTH-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_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
    56 Change_password_with_invalid_fields_returns_400_with_field_messages AUTH-16, API-15 400 validation_failed, errors.password and errors.passwordConfirmation with the test 18 and 20 messages
    57 Change_password_without_a_mandatory_change_returns_403_forbidden AUTH-24 403 forbidden, test 23 message
    58 Sign_in_over_the_rate_limit_returns_429_with_retry_after AUTH-26, ADR-0023 RateLimitedApiFactory: 3rd request → 429 locked_out, Muitas tentativas em pouco tempo. Aguarde alguns instantes e tente de novo., Retry-After present
    59 Refresh_over_the_rate_limit_returns_429 AUTH-26, ADR-0023 Same as test 58 on refresh
    60 Authentication_writes_no_password_or_token_to_the_logs Issue DoD, AUTH-20 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).

    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); migration AddAuthentication; 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; Domain Users/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 (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.

    Out of scope

    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).
  3. added a commit that references this issue on Sep 30, 2026
    0902859
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions