Skip to content

feat(auth): sign in, sessions and the current user - #28

Merged
MendesMat merged 22 commits into
mainfrom
feat/authentication
Sep 30, 2026
Merged

MendesMat merged 22 commits into
mainfrom
feat/authentication

Conversation

@MendesMat

@MendesMat MendesMat commented Sep 30, 2026 •

Copy link
Copy Markdown
Owner

Closes #8

What

Sign-in with login and password, sessions, the current user and the mandatory change of the Admin's initial password:

  • POST /api/v1/auth/sign-in, /auth/refresh, /auth/sign-out, /auth/change-password and GET /api/v1/me.
  • Credentials with ASP.NET Core Identity (user_credentials), sessions as hashed refresh tokens (user_sessions), an HS256 access token with sub and sid, lockout after 5 consecutive failures and a rate limit on sign-in and refresh.
  • Migration AddAuthentication, and the Admin's credential seeded from Admin:InitialPassword.

Why

AUTH-07 to AUTH-09, AUTH-13 to AUTH-18, AUTH-24 to AUTH-28 (new), USR-34 (new), PERM-05, PERM-06, PERM-23, USR-21; ADR-0009, ADR-0019, ADR-0022, ADR-0023, ADR-0032. The plan and the owner's decisions are in the issue.

How to test

./check.ps1 from backend/ControlService (needs Docker): 243 tests, zero warnings.

To try it by hand, set the two secrets (the guide in docs/agents/guides/local-environment.md has a PowerShell command that does not print them), start the AppHost and use ControlService.API.http:

  1. Sign in as admin with Admin:InitialPassword: mustChangePassword is true and every route except me, sign-out and change-password answers 401 password_change_required.
  2. Change the password: new tokens, other sessions ended, activated_at filled.
  3. GET /api/v1/me lists every screen as gerenciador.

Notes for the review

  • Identity reads the real clock for lockout, and IdentityModel has no clock seam (the plan marked both as verify). CredentialUserManager overrides the two virtual lockout methods to use TimeProvider, and ConfigureJwtBearer judges the lifetime with a LifetimeValidator on TimeProvider. Behavior is the planned one (5th failure locks, exp exclusive, clock skew zero); only the mechanism differs.
  • Auth options are split by layer over the same section (AuthOptions in Infrastructure, JwtOptions in the API), and AuthSettings carries the two values that messages state into Application, so the API does not reference Infrastructure outside Program.cs.
  • Tests without a Red are marked in the commit messages: they were covered by the code of an earlier cycle (for example the deactivated account with a wrong password) or are wiring (the migration, checked by the startup tests).
  • AppDbContext now inherits Identity's context, whose own Users set (credentials) is hidden by the domain Users set; credentials are reached through Set<UserCredential>().

Checklist

  • Developed test-first: every behavior has a test that failed before the code existed (ADR-0033); exceptions are named in the commit messages
  • dotnet test --solution ControlService.slnx passes locally
  • Documentation updated (docs/product/, ADRs, README) when behavior or decisions changed
  • Rule IDs covered or changed are listed above (for example USR-06, PERM-05)
  • User-facing messages are verbatim from the feature document (CNV-16)

USR-34: the Admin replacing the initial password is its own first access,
so the system-record guard does not apply. Repeating it keeps the first time.
AUTH-07, AUTH-08: the handler returns invalid_credentials for an unknown
login, a login outside the login format and a wrong password, so the answer
never reveals which part was wrong. A pending account has no credential and
fails like a wrong password (AUTH-02): that test passed without a Red because
the previous cycle already covered a login with no stored password.
… change at sign-in

AUTH-08, AUTH-09, AUTH-14, AUTH-28: a locked login is refused even with the
right password, with the configured minutes and the seconds left; only a
correct password reveals a deactivated account; an account with its initial
password gets a token flagged for the mandatory change. The deactivated
account with a wrong password had no Red: the password check already comes
first.
AUTH-17, AUTH-18, AUTH-27, AUTH-14: refresh answers session_expired for an
unknown or expired token, checks the account status because the route is
anonymous and ends the session of a deactivated account, and keeps the
mandatory password change flag.
AUTH-16, AUTH-28, ADR-0019: the password needs the configured minimum length
(default 8) and a matching confirmation, with the messages under the fields
password and passwordConfirmation. Sign-out ends only the session named by
the sid claim. The configured-length test had no Red: the validator already
interpolated the setting.
AUTH-24, AUTH-25, USR-34, ADR-0019, ADR-0032: only an account with a mandatory
change may use it (403 forbidden otherwise), the new password must differ from
the initial one, the Admin's activatedAt is filled through the unit of work, every
session ends and a new one starts. The refused save keeps the password unchanged:
that test had no Red, since the branch was written with the previous cycle.
PERM-05, PERM-06, PERM-23, USR-21: me lists every screen of the catalog in
order, including the denied ones. The no-profiles and Admin tests had no Red,
since the effective-access rules are already covered in the domain.
…tial

AUTH-13, AUTH-19, ADR-0019, ADR-0022: the Admin credential is created from
Admin:InitialPassword when missing, independently of the Admin row, with a
mandatory password change. Migration AddAuthentication adds user_credentials
(Identity), its unused satellite tables and user_sessions; it has no Red of its
own, the startup tests cover it. The restart and invalid-password tests had no
Red: the first cycle already brought that behavior.
AUTH-19, AUTH-17, ADR-0019, ADR-0032: sessions keep only the SHA-256 of a
32-byte refresh token. Rotation is a single UPDATE on the old hash while the
session is valid, so two refreshes with the same token cannot both succeed.
The rotation and expiry behavior is exercised by the API tests.
AUTH-08, D6: the fifth wrong password already locks, the lockout ends after the
configured minutes and a success resets the count. Identity reads the real clock
for lockout, so CredentialUserManager overrides only the two virtual methods that
compare or set the lockout end to use the application's TimeProvider. Tests 34
and 35 had no Red: the first cycle brought the whole store.
AUTH-07, AUTH-08, AUTH-09, AUTH-17, D1, D6, T10: POST /api/v1/auth/sign-in answers
200 with the access token and sets the refresh cookie (HttpOnly, Secure,
SameSite=Strict, scoped to the refresh route). The access token carries the user
id in sub and the session in sid. A locked login answers 429 with Retry-After and
details.retryAfterSeconds, a deactivated account 403. Wrong password, signing-key
validation and the JWT claims had no Red: they were covered by the code of the
first cycle.
…rer token

Operations, D2, T5, T7: /api/v1/me lists the person and their level on every
screen. Access tokens are checked with MapInboundClaims off, no clock skew and a
lifetime validator that reads TimeProvider, since IdentityModel has no clock seam.
A missing, invalid or expired token answers 401 session_expired.
AUTH-17, AUTH-18, ADR-0019, ADR-0032, D3, D10: refresh rotates the cookie, slides
the session for 8 hours from the last use and refuses an unknown, expired or missing
cookie with session_expired, and a deactivated account with account_inactive
(ending its session). Sign-out ends the session named by the sid claim and expires
the cookie with the same name and path.
AUTH-14, AUTH-16, AUTH-24, AUTH-25, AUTH-27, USR-34, ADR-0019, ADR-0032, T8: an
endpoint filter on the /api/v1 group answers 401 password_change_required while the
token carries must_change_password, except for me, sign-out and change-password.
Changing the password returns new tokens, ends the other sessions and fills the
Admin's activation time. A test-only endpoint mapped through MapApiV1 stands for
any other endpoint.
…he logs

AUTH-26, AUTH-20, ADR-0023, D14, T9, T10: a sliding window of 60 seconds in 6
segments per client address, 20 sign-ins and 60 refreshes by default. A refused
request answers 429 locked_out with Retry-After and details.retryAfterSeconds.
A capturing logger at Trace level, during sign-in, refresh and change-password,
finds no password, token or cookie value; that test had no Red, since nothing
logs them.
A JSON body with null login, password or confirmation crashed the handlers with a
NullReferenceException (500). They now fail like empty values do: invalid_credentials
at sign-in and validation_failed at change-password (API-12).
AUTH-24 to AUTH-28 and USR-34 are new rules. The Operations table gets the response
shapes, the 5th-attempt behavior and the known limitation of AUTH-08 are written down,
and the Errors table gains 403 forbidden and details.retryAfterSeconds. The local
environment guide lists the new Auth and RateLimiting keys and how to set the secrets;
the architecture and testing guides describe Identity, sessions, the JWT and the
authentication test factories.
Invoke-RestMethod with a web session does not keep a Secure, SameSite=Strict cookie,
so a manual refresh answers 401 although the server is right. curl.exe with a cookie
jar is the way to test it by hand.
…s it

The Admin is one row in a database shared by the whole test assembly. The change-password
tests left activated_at filled, so StartupSeedTests failed on CI, where the classes ran
in another order than on the developer machine. The three classes that sign in as the
Admin now reset it in DisposeAsync, so the order stops mattering.
…g keys

A user secret that was never set binds as null, and both startup
validators crashed on it (NullReferenceException, ArgumentNullException)
instead of naming Admin:InitialPassword or Auth:SigningKey (T11). That
is the most common first-run mistake, so the message matters.

The signing key was decoded into a 64-byte buffer, so a valid key longer
than 64 bytes was refused as "at least 32 bytes". The buffer now fits
the text.
SetMinimumLevel applies only when no filter rule matches, and the
appsettings rules (Default: Information, Microsoft.AspNetCore: Warning)
always match, so the test never saw a Debug or Trace line although it
claimed to. A filter rule of the capturing provider wins over them. With
the real capture, no password, token or cookie value is logged either.
Tokens were numbered by the current count of sessions, so after a
rotation a new session could get a token already in use, and a later
refresh would rotate the wrong session. Test support only: no Red.
@MendesMat
MendesMat merged commit 0902859 into main Sep 30, 2026
5 checks passed
@MendesMat
MendesMat deleted the feat/authentication branch September 30, 2026 06:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

1 participant