Skip to content

refactor(auth): route the auth endpoints through IAccessModule - #1041

Merged
mforce merged 12 commits into
mainfrom
refactor/857c-auth-endpoints
Oct 3, 2026
Merged

mforce merged 12 commits into
mainfrom
refactor/857c-auth-endpoints

Conversation

@mforce

@mforce mforce commented Oct 3, 2026 •

Copy link
Copy Markdown
Owner

Based on main. This PR was stacked on #1037 (PR B), which merged as 75e2b1be. main is merged in as 62832479. That merge commit's tree is identical to C's previous head plus main, because main and B's branch had identical trees. git diff origin/main...HEAD --stat lists only C's six files.

Nothing changes for users. Sign-in, refresh, logout, password change and step-up keep every status, body, cookie, token claim, security event, lockout count and SQL statement, which the trace below shows across 35 scenarios.

For maintainers, the auth endpoints now reach Access through one surface, IAccessModule, instead of six Identity services and IOptions<JwtOptions>. Only the refresh-cookie lifetime leaves the JWT options; the signing keys stay in Identity.

This is PR C of the Access contract (#857, design v2). It moves the authentication endpoints, so it needs a security review.

Refs #857

What changed

  1. Commit 1, f1cfd7a0: tests first, green before the move. AuthSessionContractTests pins two things the endpoints decide themselves rather than through IIdentityProvider, which no suite pinned:

    • the refresh cookie's Expires follows Jwt:RefreshTokenDays on login, refresh and change-password;
    • the unknown-farm and suspended-farm login branches each log exactly one LoginFailed event, with the same property keys as the unknown-user failure and no UserId or Email.
  2. Commit 2, 649eae0b: the move.

    • IAccessModule gains ten session members: ResolveFarmCodeAsync, LoginAsync, IsFirstRunProvisionedAsync, RecordLoginFailure, RefreshAsync, RevokeRefreshTokenAsync, RecordLogoutAsync, ChangeOwnPasswordAsync, IssueStepUpGrantAsync and RefreshTokenLifetimeDays.
    • AccessModule forwards each one to the service the endpoint called before: IIdentityProvider (from DI, so decorators stay in the path), IStepUpGrantService, FirstRunStatusService, AuthSecurityEventLogger, ChangeOwnPasswordHandler and JwtOptions.RefreshTokenDays. Nothing is cached; every member is a single call.
    • AuthEndpoints: Login, Refresh, Logout, ChangePassword and StepUp take IAccessModule in place of those services. The order of checks, every cookie write and every response is unchanged. IIdentityProvider.RefreshAsync and RevokeRefreshTokenAsync take expectedAccountId last and optional. The module takes it before the cancellation token and requires it, so every call site names its account explicitly. Each of the five call sites (two refreshes, three revokes) passes the same account it passed before, and the one legacy-only logout revoke that relied on the default now passes null explicitly.
    • Ledger. AuthEndpoints.Logout and AuthEndpoints.StepUp gain adapter rows reaching Access. The matrix cell Platform → Access becomes A (47), regenerated.
  3. Commit 3, c17e8436: review hardening (Opus F1, F2), test-only. The lifetime test read Jwt:RefreshTokenDays from a host that never set it, so it proved only "30 days", which a cookie written with the default also satisfies. Both lifetime tests now run on a host with Jwt:RefreshTokenDays=7. The fourth refresh-cookie write, the legacy-cookie upgrade, had no lifetime assertion, and a new test adds one. No production code changed.

Auth parity trace

I adapted #854's harness into a scratch test (not committed). For each scenario it records:

  • every SQL statement and transaction event, normalised;
  • the status and the normalised body;
  • each Set-Cookie line: name, decoded value length, expiry relative to now, path and flags;
  • the access token's header and claims, with exp relative to nbf, the time and id claims masked, and security_stamp as a length;
  • the password hasher's verify and hash counts, so a skipped dummy hash shows;
  • every SecurityEvent log event with its property keys;
  • the user's lockout state afterwards: AccessFailedCount, locked or not, and CredentialEpoch.

The 35 scenarios:

  • Login (14): success; unknown farm; suspended farm with the right and the wrong password; unknown user; wrong password; disabled user with the right and the wrong password; locked-out user; the default farm with no Owner (the first-run notice); one email in two farms; a foreign bearer on the login request; an oversized body; an over-length password.
  • Refresh (10): rotation; replay within the grace window; missing CSRF header; header and cookie for different farms; a malformed header; two farm cookies and no header; no cookie; an over-length cookie; one cookie and no header; the legacy-cookie upgrade.
  • Password change (2) and step-up (4): wrong and right current password; step-up with a wrong password, success, the grant used, and the grant replayed.
  • Logout (5): cookie and bearer; bearer only; cookie only; missing CSRF header; legacy cookie only.

A separate fact exhausts the login rate-limit partition. Three logins return 200 and the fourth 429; step-up and change-password then return 429 from the same partition, and refresh returns 200.

Two runs on the base (87a2eddf, B's head with main merged) were identical. The normalised head trace is byte-identical to the base across all 35 scenarios and the limiter partition. That covers 96 SQL statements, the cookie writes in 13 scenarios, the tokens issued in 9, and 10 LoginFailed events. The base trace also matches the one taken before #1036 merged.

Mutation table

Each mutant was applied by an exact single-match replacement, built, and run against the required set, then reverted. That set is 24 classes and 170 tests: v2's list, plus every other test class declared in the same files (BootstrapRetryBoundaryTests, PerFarmRefreshCookieTests, RefreshGraceDisabledTests and AuthCookieSecureTests).

# Mutant Red Failing assertion, quoted
C1 RefreshTokenLifetimeDays returns the configured days + 1 AuthSessionContractTests.RefreshCookie_ExpiresAfterTheConfiguredLifetime_OnLoginRefreshAndPasswordChange (new) login: expected the refresh cookie to expire about 2026-11-02T16:37…, got 2026-11-03T16:37…
C2 RecordLoginFailure does nothing the build CS9113: Parameter 'securityEvents' is unread
C2n the same, but still reading securityEvents AuthSessionContractTests.UnknownFarmAndSuspendedFarm_EachLogOneLoginFailed_WithTheUnknownUserShape (new) Assert.Equal() Failure: Values differ Expected: 3 Actual: 1
C3 IsFirstRunProvisionedAsync always true the build CS9113: Parameter 'firstRun' is unread
C3n the same, but still reading firstRun FirstRunLoginNoticeTests.ReportsNoOwner_UntilTheDefaultAccountHasOne_ThenTheOrdinaryDenialReturns Expected: "Auth.NoOwnerProvisioned" Actual: "Identity.InvalidCredentials"
C4 module refresh drops expectedAccountId RefreshAccountBindingTests.MismatchIsRefusedAndRotatesNothing, PerFarmRefreshCookieTests.LegacyCookie_WithDifferentFarmSelector_IsRefusedAndRotatesNothing Expected: Unauthorized Actual: OK
C5 module revoke drops expectedAccountId PerFarmRefreshCookieTests ×3, the cross-farm logout cases response must not set or clear 'cluckwork_rt'
C6 module RecordLogoutAsync does nothing StepUpAuthTests ×3, the logout-revokes-grant cases Expected: Forbidden Actual: Created
C7 module swaps account and user ids on step-up 30: StepUpAuthTests, SecurityEventLoggingTests and others step-up returns 400 (Bad Request)
C8 module logs in against the default farm 117 failed of 167 that finished; 3 CredentialEpochRaceTests barrier tests hung on a login that never succeeds, and I stopped them Response status code does not indicate success: 401 (Unauthorized)
C9 endpoint's farm-scoped legacy revoke passes null PerFarmRefreshCookieTests ×2 response must not set or clear 'cluckwork_rt'
C10 endpoint's refresh passes null for the selected farm RefreshAccountBindingTests.MismatchIsRefusedAndRotatesNothing Expected: Unauthorized Actual: OK
F1 login writes the cookie with a hardcoded 30 (Opus O1) AuthSessionContractTests.RefreshCookie_ExpiresAfterTheConfiguredLifetime_OnLoginRefreshAndPasswordChange (commit 3; survived before it) login: expected the refresh cookie to expire about 2026-10-10T20:06…, got 2026-11-02T20:06…
F2 the legacy upgrade writes a one-day cookie (Opus O2) AuthSessionContractTests.LegacyCookieUpgrade_WritesThePerFarmCookieForTheConfiguredLifetime (new in commit 3; survived 32 tests before it) legacy upgrade: expected the refresh cookie to expire about 2026-10-10T20:06…, got 2026-10-04T20:06…
— all reverted 170 of 170; after commit 3, 3 of 3 AuthSessionContractTests

C2 and C3 show that TreatWarningsAsErrors turns a dropped forwarding dependency into a build error. C2n and C3n keep the field read, so a test, not the compiler, has to catch them. C1 and C2n are caught only by the new AuthSessionContractTests. After commit 3, C1 (days + 1) was re-run against the 7-day host and is red in both lifetime tests.

A correction from the first run. My first run filtered by v2's class names and missed the three extra classes. C5 survived that run. With the full set it is red three times, so it was my filter's gap, not a test gap. The counts above are from the full run.

Timing

This is evidence, not a gate. The scratch harness timed six login branches in 60 interleaved rounds. I ran it three times on the base and three on the head, alternating, and dropped the first five rounds of each run as warm-up. That leaves 165 samples per branch per side, in milliseconds:

Branch Status base p50 head p50 base p90 head p90
unknown user 401 64.81 51.81 94.37 96.56
wrong password 401 70.90 57.51 109.48 103.77
disabled user 401 66.12 52.10 92.59 95.34
success 200 70.43 55.56 100.88 107.26
suspended farm 401 2.25 1.82 5.05 4.61
unknown farm 401 1.89 1.48 4.33 3.77

What it shows. The shape is the same on both sides. The four branches that reach the credential check cost one password hash each and sit within about 10% of one another. The two farm branches hash nothing and return in about 2 ms, as they did before.

What it does not show. The head's lower medians are not a speed-up. Other agents' test runs pushed the host's load average between 9 and 23 during these runs, and the p90s overlap.

Test counts

Measured on this machine.

Suite base 87a2eddf (B with main merged) head 649eae0b
Cluckwork.Application.Tests (full, including every ledger, adapter-reach, seam, peer-contract and coupling-matrix guard) 702 702
Integration: the required set (24 classes) 168 170 (+2 new)
After the main merge and commit 3 (head c17e8436), with #1039's query-shape check on: every class that calls an auth, users or me route or names IAccessModule, plus the required set and QueryShapeWarningTests (65 classes, found by walking the test files) — 584 of 584, and no query-shape warning raised
Cluckwork.Application.Tests at c17e8436 — 702

The required classes pass unedited. No existing test file changed. The full integration suite was not run locally; CI runs it once this PR targets main. While it is stacked on B, CI may not run at all.

Test edits

None. No existing test file changed (git diff --numstat 87a2eddf...HEAD -- tests/ lists only the new AuthSessionContractTests.cs and the two ledger data files).

Deslop record

/deslop and a by-hand /no-comments pass (subagents are off for this slice):

  • The src diff only forwards calls. It adds no logic, no caching and no try/catch; each module member is one call to the service the endpoint used before.
  • I removed a "Sessions." comment block in IAccessModule that repeated the interface header, and rewrapped the header.
  • Each remaining new comment gives a reason the code cannot show:
    • why AccessModule lives in Infrastructure;
    • which account IsFirstRunProvisionedAsync asks about;
    • that RecordLoginFailure is for failures decided before any credential check;
    • that only the refresh lifetime leaves the JWT options.

Change map

BEFORE
 AuthEndpoints.Login          --> IIdentityProvider (ResolveFarmCode, Login), FirstRunStatusService,
                                  AuthSecurityEventLogger, IOptions<JwtOptions>
 AuthEndpoints.Refresh        --> IIdentityProvider (Refresh), IOptions<JwtOptions>
 AuthEndpoints.Logout         --> IIdentityProvider (RecordLogout, RevokeRefreshToken)
 AuthEndpoints.ChangePassword --> ChangeOwnPasswordHandler, IOptions<JwtOptions>
 AuthEndpoints.StepUp         --> IStepUpGrantService

AFTER
 AuthEndpoints (5 handlers)   --> IAccessModule --> AccessModule [Infrastructure.Identity]
                                                     --> the same IIdentityProvider (from DI), IStepUpGrantService,
                                                         FirstRunStatusService, AuthSecurityEventLogger,
                                                         ChangeOwnPasswordHandler, JwtOptions.RefreshTokenDays
 ledger: Platform/adapters -> Access A 45 -> 47 (+Logout, +StepUp)
 unchanged: the middleware order, CredentialEpochMiddleware's fresh read, the rate-limit policies and
            partitions on each route, cookie names and attributes, IIdentityProvider itself
 not here: the operator verbs and the refresh-token purge (PR D); the contract declaration, the seeders'
           CreateUserAsync, and retiring IIdentityProvider/IStepUpGrantService as Platform ports (PR E)

Review notes: properties to try to refute

  • Login order. The order of every login branch is unchanged: the oversized-body rejection, then farm resolution, then unknown farm, then suspended farm, then the credential check. The first-run notice still appears only for the default farm. Neither farm branch touches a credential or a lockout counter. The trace's hasher counts and lockout state are identical.
  • Farm-branch failure events. The unknown-farm and suspended-farm branches each log exactly one LoginFailed, with the unknown-user shape and no UserId or Email.
  • Account binding. All five refresh and revoke call sites pass the same expectedAccountId as before. One farm's logout never revokes or clears another farm's session held in the shared legacy cookie.
  • Decorators. AccessModule takes IIdentityProvider from DI, so a registered decorator stays in the path. MustChangePasswordGateTests replaces it with ChangePasswordThrowingIdentityProvider and still passes.
  • No caching. The module is scoped and caches nothing. CredentialEpochMiddleware, its fresh read per request, and the middleware order are untouched.
  • No dual issue. Each endpoint calls the module once where it called the service once. The trace's statement counts and token claims are identical, so no request mints an extra token or writes an extra refresh row.
  • Rate limits. The rate-limit policies and partitions are untouched. The trace's limiter fact shows login, step-up and change-password sharing one partition, with refresh outside it.
  • Cookie lifetime. The refresh cookie's lifetime equals Jwt:RefreshTokenDays on all four writes: login, refresh, the legacy upgrade and change-password. Since commit 3, a test pins each of the four against a non-default lifetime.
  • Cost. Resolving IAccessModule now constructs the session services as well, so the users and me routes build a few more scoped objects per request. None of them touches the database on construction. The timing section measures login.

Not in this PR:

  • the operator verbs and the refresh-token purge (PR D);
  • the contract declaration, the seeders, and retiring IIdentityProvider and IStepUpGrantService as Platform ports (PR E, sub-step 6).

mforce added 8 commits October 3, 2026 14:45
SaleAllocationPolicyTests pins when ConfirmSaleHandler reads the actor's
role and the allocation policy, but nothing pinned when it reads a plain
Worker's flock assignments. This test parks a restricted worker's confirm
on the account lock while another transaction removes the worker's only
assignment, then requires the confirm to allocate as an unrestricted
worker. It passes on main and guards the move of that read behind
IAccessLookup.

Refs #857
…r reads behind IAccessLookup

IAccessModule is Access's surface for HTTP adapters. The users and me
endpoints call it for every route; each member forwards to the existing
handler or identity call, so step-up checks, locks, audit rows and errors
are unchanged. It takes IIdentityProvider from DI, so a registered
decorator stays in the path.

IAccessLookup is the peer read port: the effective role and the flock
assignments. AccessLookup depends only on the scoped AppDbContext.
ConfirmSaleHandler, FlockScopeGuard and FlockScopeResolutionMiddleware
read through it. The effective-role query is one routine,
AccessLookup.ReadEffectiveRoleAsync, which
IdentityProvider.GetEffectiveRoleAsync also runs, with both statements
and Roles.ResolveEffective unchanged.

SQL is unchanged except confirm's assignment read, which drops
ORDER BY "Id" (the rows are used as a set), and the middleware and
guard reads, whose parameter is now @userid instead of @user_UserId.

Four test lines change, each passing the same unreachable context
through the real lookup: FlockScopeGuardTests (1),
FlockScopeMiddlewareTests (2) and TenantResolutionMiddlewareTests (1).
The ledger gains the GetMe and ListUsers adapter rows and two
Access -> Farm symbols; the tenant-bypass rows follow the moved query.

Refs #857
…ssLookup

No suite put two workers with different assignments in one farm, or gave
one worker both a flock row and a farm-wide row. A lookup that returned
the whole farm's rows, or dropped farm-wide rows, passed every test. The
first would let a restricted worker inherit a colleague's farm-wide
access. These tests seed exactly those shapes, at the port and through
the flock-scope middleware.

Refs #857
…role read's account scope

Review hardening for PR #1037; both gaps predate the PR and the port now
owns them.

- ListFlockAssignmentsAsync relies on the tenant query filter. Under an
  unresolved tenant it read no rows, and no rows means account-wide
  access. It now throws before the query (the SQL is unchanged). Every
  caller resolves the tenant first: HTTP with the actor, both seeders
  before acting, confirm before its account lock. AppDbContext exposes
  its TenantContext, as it already exposes FlockScope.
- Nothing pinned the account predicate in the shared effective-role
  query. A new test gives one email a different role in two farms and
  requires each farm to see only its own user's role.

Refs #857
…posing it on AppDbContext

AppDbContext.Tenant made the DbContext carry request state for one caller.
AccessLookup now takes the same scoped TenantContext through its own
constructor, next to AppDbContext. The fail-closed guard and the SQL are
unchanged; the getter is gone, so AppDbContext matches main again. The
four test lines that construct AccessLookup by hand pass the tenant.

Refs #857
… failures

Two things the auth endpoints decide themselves, outside
IIdentityProvider, were unpinned. The refresh cookie's lifetime comes from
Jwt:RefreshTokenDays on login, refresh and password change, and the
unknown-farm and suspended-farm login branches each log one LoginFailed
with the same shape as an unknown user. Both tests pass on the current
endpoints and guard their move behind IAccessModule.

Refs #857
Login, refresh, logout, change-password and step-up now take IAccessModule
instead of IIdentityProvider, IOptions<JwtOptions>, FirstRunStatusService,
AuthSecurityEventLogger, ChangeOwnPasswordHandler and IStepUpGrantService.
Each new member forwards to the same service, and the endpoints keep their
order of checks, cookies and responses. Only the refresh-token lifetime
leaves JwtOptions. Logout and StepUp gain adapter rows reaching Access.

Refs #857
@mforce

mforce commented Oct 3, 2026

Copy link
Copy Markdown
Owner Author

Review of record (Codex Astra, xhigh) at head 649eae0b. Posted by the coordinator.

PR #1041 security review. Reviewed 87a2eddf through 649eae0b61c89f0e26aea3e5c7d6e804469b5428 on 2026-10-03. The supplied six-file C-only diff matches the Git diff exactly. I reviewed this myself, without delegation.

No CONFIRMED or PLAUSIBLE P0–P3 findings. I found no product or security defect in this change.

I compared every changed endpoint call with the base and followed it through the module to its original implementation. Locations below refer to the reviewed head.

Endpoint Forwarding and behavior checked
Login, src/Cluckwork.Api/Endpoints/Auth/AuthEndpoints.cs:172 ResolveFarmCodeAsync(request.FarmCode, ct) and LoginAsync(farm.AccountId, request.Email, request.Password, ct) still reach the injected IIdentityProvider. Unknown and suspended farms call the same parameterless security-event logger. Failed login consults the same first-run service only for the default account. Validation, branch order, all 401 titles/details and the successful token response are unchanged.
Refresh, AuthEndpoints.cs:297 Both calls preserve the expected account: legacy refresh passes the nullable selector; ordinary refresh passes the selected cookie's account. AccessModule.cs:104 reorders the arguments back to the provider's (token, ct, expectedAccountId) signature. Body draining, CSRF refusal, malformed/absent selectors, multi-cookie refusal, legacy upgrade, error mapping and cookie clearing are unchanged.
Logout, AuthEndpoints.cs:562 CSRF precedes bearer logout recording. The selected-cookie revoke and farm-scoped legacy revoke pass the same account as before. The legacy-only revoke passes explicit null, equivalent to the old default. All three reach the same provider method through AccessModule.cs:107. No selector, attribution, revocation or clearing branch moved. The response remains 204.
Change-password, AuthEndpoints.cs:443 The same command and authenticated currentUser.UserId reach ChangeOwnPasswordHandler.HandleAsync through AccessModule.cs:113. Validation and 400 error mapping remain in the endpoint. The successful cookie uses the returned pair's account and refresh token.
Step-up, AuthEndpoints.cs:480 tenant.AccountId, currentUser.UserId, the supplied password and cancellation token reach IStepUpGrantService.IssueAsync unchanged through AccessModule.cs:117. Authentication/tenant checks precede validation and issuance. Wrong-password results remain 400, so the client does not refresh and replay the same failed password attempt.

The cookie contract is preserved at all four successful writes, including legacy upgrade. AuthCookies.cs:29 retains cluckwork_rt_{accountId:N}; the legacy name remains read-only. Path is /api/v1/auth, HttpOnly and SameSite=Strict remain set, Secure remains true outside Development, and deletion uses the same attributes. Refresh credentials remain absent from response JSON.

AccessModule.cs:121 evaluates jwt.Value.RefreshTokenDays when the endpoint writes the cookie. The old endpoint evaluated that same property at the same point. The new constructor does not read or snapshot the value. Both versions use the same registered IOptions<JwtOptions>, whose Value is cached by the options service; neither gains reload behavior. There is no switch to a boot-time module field, a fresh per-request options snapshot, or the access-token lifetime. The new cookie test passes before and after the move.

Unknown and suspended farms still emit one identity-free LoginFailed each and perform no password check or failed-access increment. That deliberately disclosed farm distinction is allowed by #530. Unknown users, wrong passwords, disabled users and locked-out users still follow the same provider branches, hash work and lockout rules. Bad-password logging still occurs before the durable lockout update. The new event test and the existing logging tests exercise the event shape; the trace compares hash counts and lockout state as well as responses.

Route metadata and limiter registration are unchanged. Login, change-password and step-up still share #143/#544's IP-keyed login policy and IFixedWindowCounter; refresh uses its separate policy. My trace records three successful logins, then 429 for the fourth login, step-up and change-password, followed by a successful refresh. Logout gains no limiter.

The #338 ordering remains intact. Issuance verifies the password, snapshots the credential epoch/security stamp, resets failed-access state, checks that the credential has not changed, then signs the grant with its logout epoch. Protected handlers still validate proof before target reads. Validation checks the grant and stored security stamp, then consumes the shared claim before reading the durable logout epoch. There is no new outer transaction or retry around this work.

Logout still advances the bearer's step-up epoch before cookie revocation. The provider attributes the cookie to its durable owner, checks the expected account before either revocation axis, advances that owner's step-up epoch, then revokes the exact token lineage. A stale rotated cookie can still identify its owner. A cookie-revocation failure cannot undo the already-recorded bearer logout. CredentialEpochMiddleware.cs:26 still exempts exactly logout and its trailing-slash spelling, so a superseded bearer can end its session.

All five auth paths retain their idempotency exemptions. No live token or consumed grant becomes a cached response. MustChangePasswordMiddleware still runs after the fresh epoch check and before authorization/idempotency, allowing only change-password and logout while forced reset is pending. The injected provider remains the DI registration, so the throwing decorators in MustChangePasswordGateTests and StepUpAuthTests remain on the HTTP path.

DI review found no captive dependency or new credential cache. CluckworkIdentityServiceCollectionExtensions.cs:128 still registers IAccessModule as Scoped. Identity, step-up, the registry, first-run status, event logger, handlers and context retain their scoped lifetimes. The existing singleton first-run latch stores only its provisioning boolean. The wider module constructor builds more scoped objects but does no database, hash or signing-key work. #510's serving-only key validation remains at registration; #364's verifier still performs its own fresh read. No token, account, epoch or grant is cached by the module.

Independent parity evidence is trace-comparison.txt: all 36 files are byte-identical, covering 35 scenarios plus the limiter check. That includes 96 SQL statements, cookie writes in 13 scenarios, JWTs in nine scenarios and ten LoginFailed events. I ran the supplied auth adaptation of the #854 SQL recorder on both pinned revisions in the assigned checkout. Base trace and head trace are retained.

I also measured one 60-round interleaved timing run per revision and discarded five warm-up rounds. Each cell below uses 55 samples; times are milliseconds.

Login branch Base p50 Head p50 Base p90 Head p90
Unknown user 39.95 39.93 41.40 41.78
Wrong password 42.65 42.86 44.48 45.21
Disabled user 39.90 40.00 41.66 41.30
Success 43.85 44.07 45.84 45.97
Suspended farm 1.45 1.48 2.40 2.26
Unknown farm 0.98 1.01 1.93 1.84

These observations support the same branch costs; they are not a constant-time guarantee or a performance benchmark on an isolated host. Raw samples and measurement limitations are retained.

I rebuilt and reproduced three listed mutants and one additional mutant, restoring each afterward. These failures describe deliberately broken review copies, not defects in the PR.

Mutant Independent result Evidence
C5, drop the account on module revoke Three of 19 PerFarmRefreshCookieTests fail because logout sets or clears the foreign legacy cookie. The cases cover a selected farm with only a foreign legacy cookie, a selected session plus a foreign legacy session, and a foreign token under both names. Log, patch
C8, log in against the default farm One of two AuthSessionContractTests fails at the valid non-default-farm login, which returns 401. This reproduces C8 without running the barrier tests that hung in the author's broad mutation run. Log, patch
C6, omit bearer logout recording Three of 45 StepUpAuthTests fail with expected Forbidden, actual Created. They cover no cookie, another user's cookie, and a cookie revoke that throws. Log, patch
Additional, read AccessTokenMinutes as cookie lifetime days The new lifetime test fails on login: expected November 2, actual October 18, a 15-day cookie instead of 30 days. Log, patch

The disclosed filter mistake is credible and resolved in the supplied evidence: PerFarmRefreshCookieTests is a separate class inside RefreshAccountBindingTests.cs, so filtering only the latter class omits C5's guards. My C5 run explicitly selected the former class and reproduced all three failures.

Final verification on the restored, unmodified head passed 193/193 targeted integration tests across 22 classes and 8/8 Application architecture checks across four classes. Every class ran alone through sg docker; the filters include the full namespace and class name. The architecture checks cover the module ledger, adapter reach, generated matrix and public-interface persistence rules. The integration runs cover the five endpoints, fresh epoch reads and races, suspension, retry boundaries, lockout, event logging, shared-store step-up admission, cookie contracts and logout/refresh lineage races. See final results and the individual logs alongside them. Both new tests also passed on f1cfd7a0, before the move, in this run. No existing test was edited.

The ledger is accurate. module-ledger.json:696 adds Logout and :708 adds StepUp, both reaching Access. They previously used Platform-owned interfaces and now name the Access contract. The other three auth rows already existed. Counting the rows gives 45 on the base and 47 on the head, matching both changed matrix cells. No unrelated ownership allowance or table change was added.

Code quality: the thermo-nuclear review found no structural regression. The module gains direct forwarding members and no branching logic, cache, retry, transaction or generic dispatch. Source sizes are 647 lines for AuthEndpoints, 87 for the interface and 122 for the module; none crosses 1,000. The approved module boundary explains the forwarding layer. Quality summary and detail are retained.

Ponytail-review: Lean already. Ship.

Nits: none.

Limits: I did not run the full integration suite or claim an exhaustive concurrent-schedule proof from the normalized trace. The trace masks random values and rounds cookie expiry to days; the new committed test adds a two-minute expiry tolerance. Separate existing race, retry, suspension and shared-store tests supply the schedule checks. #579's accepted check-then-mint window remains unchanged. The invalid stored-epoch issue identified during the earlier design review has already been addressed by #1031 on the supplied base and is not a finding against C.

All reviewer mutations were restored, the scratch test was removed, and Git status was clean at the reviewed head. The assigned detached checkout was then removed with the requested command. No commit, push or GitHub comment was made; no other checkout was edited. Cleanup record.

Verdict: APPROVE PR #1041 at 649eae0b; no product or security defect found.

@mforce

mforce commented Oct 3, 2026

Copy link
Copy Markdown
Owner Author

Independent security and code-quality review (Claude Opus 5.5: security-review, thermo-nuclear and ponytail) at head 649eae0b. Posted by the coordinator.

PR #1041 security review (Opus): head 649eae0b vs base 87a2eddf (PR C only)

Reviewer: Claude Opus 5.5, independent of Codex Astra. I formed my view from the diff and the code before I read the PR body or any other review. I did not read Astra's file.
Scope: git diff 87a2eddf...HEAD, 6 files, +211/−37. Evidence (mutant logs, the mutation script, guard runs): /home/mforce/.cluckwork-slices/reviews/1041-649eae0b-opus-evidence/.

Bottom line

  • There is no product defect and no security defect. Each new AccessModule member makes one call to the same service, with the same arguments, that the endpoint called before. All five refresh and revoke call sites pass the same expectedAccountId as before. The route mapping, limiter policies, cookie helpers and middleware are untouched.
  • There are two findings, both P3 and CONFIRMED by mutants that survive. Both are test gaps around the refresh-cookie lifetime. Neither changes behavior today.
  • The quality findings are P3 and structural. IAccessModule grows from 14 to 24 members, mostly one-line pass-throughs. That is what the design asked for in this step; the remedy belongs after PR E.

What I verified (adversarial pass)

Question Answer Evidence
Admits a revoked, disabled, suspended or foreign-farm credential? No RefreshAsync/RevokeRefreshTokenAsync forward expectedAccountId into the same IIdentityProvider parameter (AccessModule.cs:104-109). The reordering is safe: Guid? and CancellationToken cannot be swapped without a compile error. Making expectedAccountId required is a small improvement, because the one legacy-only revoke now writes null explicitly (AuthEndpoints.cs:620). CredentialEpochMiddleware and its fresh read are not in the diff. Reproduced C4 red.
Mints or refreshes when it should refuse? No Login order is unchanged: size check, resolve farm, unknown, suspended, credential check, then the first-run notice for the default farm only. Each endpoint makes one module call where it made one service call before.
Skips step-up or reuses a grant? No IssueStepUpGrantAsync(accountId, userId, password) maps to IStepUpGrantService.IssueAsync(Guid accountId, Guid userId, string currentPassword) (IStepUpGrantService.cs:19-20) in the same order. Grant consumption lives in the handlers, which C does not touch. Reproduced C7 (ids swapped) red.
Logs out the wrong lineage? No Logout's three revoke calls pass the same scope as before. RecordLogoutAsync(currentUser.UserId) is unchanged.
Changes cookie name, path, flags or lifetime? No AuthCookies.* is not in the diff. The lifetime is jwt.Value.RefreshTokenDays before and after, read through the same IOptions<JwtOptions>.
Leaks farm or user existence through a new event, error or timing? No RecordLoginFailure() calls the same scoped AuthSecurityEventLogger.LoginFailed() (no user id, no email). Titles are unchanged. Timing: the minimal-API binder constructs AccessModule and its graph before the handler runs, so every branch pays the same cost and no new differential appears. The PR's timing table agrees, but I did not re-measure it. Reproduced C2n red.
Changes the rate limiter? No MapAuthEndpoints (AuthEndpoints.cs:20-62) is not in the diff: .RequireRateLimiting(...) is the same on login, refresh, change-password and step-up. Logout still has no limiter, as before.
Captures scoped auth state in a longer-lived service? No IAccessModule is AddScoped (CluckworkIdentityServiceCollectionExtensions.cs:128). Every new dependency is scoped (AuthSecurityEventLogger, FirstRunStatusService, IStepUpGrantService, ChangeOwnPasswordHandler), except IOptions<>, which is a singleton snapshot. Nothing under src/ resolves IAccessModule outside an endpoint parameter. AccessModule holds no fields beyond its constructor parameters.
Can the extra construction throw or change an outcome on anonymous routes? No Login, refresh and logout now also build StepUpGrantService, PersistentStepUpGrantRegistry, FirstRunStatusService and 11 user handlers. All of them have bodiless primary constructors. The Redis multiplexer is a singleton with AbortOnConnectFail=false (SharedStateRegistration.cs:48-50), and the login and refresh limiters already resolve it.
Decorators still in the path? Yes The module takes IIdentityProvider from DI (AccessModule.cs:35), not the concrete type.
Architecture guards Green dotnet test tests/Cluckwork.Application.Tests --filter FullyQualifiedName~Architecture: 359/359 (architecture.log). This covers the ledger, adapter reach, seam surface and coupling matrix.

Mutation runs (each reverted; one integration class at a time)

The script is mutate.py: an exact single-match replacement, a build, one filtered run, then a revert asserted by git diff --quiet. The baseline at head is 28/28 green for AuthSessionContractTests, PerFarmRefreshCookieTests and AuthCookie* (baseline.log). The checkout is clean after every run.

# Mutant Class run Result Failing assertion
C2n (PR) RecordLoginFailure emits nothing, still reads the field AuthSessionContractTests RED Expected: 3 Actual: 1
C4 (PR) module refresh passes null for expectedAccountId RefreshAccountBindingTests RED MismatchIsRefusedAndRotatesNothing: Expected: Unauthorized Actual: OK
C7 (PR) module swaps account and user ids on step-up StepUpAuthTests RED 400 (Bad Request) on four or more tests
O1 (mine) login writes the cookie with a literal 30 instead of access.RefreshTokenLifetimeDays AuthSessionContractTests GREEN, survives 2/2 pass
O2 (mine) legacy-cookie upgrade writes a one-day cookie PerFarmRefreshCookieTests, RefreshAccountBindingTests, AuthSessionContractTests, AuthCookie* GREEN, survives 32/32 pass, including LegacyCookie_RefreshSucceeds_SetsPerFarmCookie_AndDeletesLegacy, which runs the mutated line

The three PR mutants I reproduced fail with the assertions the PR quotes.

Findings

F1: P3, CONFIRMED (test gap): the lifetime test cannot tell configuration from the default

  • Where: tests/Cluckwork.Api.IntegrationTests/AuthSessionContractTests.cs:69; the guarded line is src/Cluckwork.Api/Endpoints/Auth/AuthEndpoints.cs:293 (also 427 and 472).
  • Defect: the test reads days from the factory's IOptions<JwtOptions>, and nothing in the test host sets Jwt:RefreshTokenDays. So days is 30, which equals both JwtOptions.RefreshTokenDays's default (JwtOptions.cs:12) and appsettings.json:29. The test's name and header say the cookie follows Jwt:RefreshTokenDays. In fact it proves only that the cookie lasts 30 days.
  • Scenario: a later edit replaces access.RefreshTokenLifetimeDays with a literal or with new JwtOptions().RefreshTokenDays. An operator who sets Jwt__RefreshTokenDays=7 then gets 7-day server rows (IdentityProvider.cs:1595) under 30-day cookies, and every suite stays green. This is a usability mismatch, not a credential leak: the server row still expires.
  • Evidence: mutant O1 survives (O1.log). The PR's C1 (days + 1) is red only because it moves the value away from 30.
  • Fix (in slice): give this class a non-default lifetime, for example factory.WithWebHostBuilder(b => b.UseSetting("Jwt:RefreshTokenDays", "11")), and assert against 11.

F2: P3, CONFIRMED (test gap; pre-existing): no committed test pins the legacy-upgrade cookie lifetime

  • Where: src/Cluckwork.Api/Endpoints/Auth/AuthEndpoints.cs:379 → LegacyUpgradeResult (:435-441).
  • Defect: this is the fourth refresh-cookie write. The PR's review notes say the lifetime equals Jwt:RefreshTokenDays "on all four writes: login, refresh, the legacy upgrade and change-password". Only the uncommitted scratch trace shows the fourth. AuthSessionContractTests covers three, and no other test reads Expires on that path.
  • Scenario: the upgrade path writes a short-lived per-farm cookie. Users who are still on the legacy cookie are silently signed out after the shorter lifetime. No suite notices.
  • Evidence: mutant O2 (lifetime 1) survives 32 tests. That includes LegacyCookie_RefreshSucceeds_SetsPerFarmCookie_AndDeletesLegacy, which runs the mutated line (O2.log).
  • Fix (in slice): add an AssertLifetime step to the legacy upgrade, or correct the PR text to "three committed, four in the trace". The legacy path is temporary (see the #537 comment at :366), so editing the text is acceptable.

No other finding survived. In particular, I looked for and rejected these:

  • argument transposition in the five refresh and revoke sites (types prevent it);
  • a captive dependency (everything is scoped);
  • a changed limiter partition (mapping not in the diff);
  • a new timing differential (construction happens before branching);
  • a lost decorator (the interface comes from DI).

security-review skill output

The skill ran in the coordinator's parent worktree (HEAD at 05d7e7a7), so its injected git context was empty. The brief forbids subagents, so I applied the skill's three phases and its false-positive filter by hand to C's diff:

  • Phase 1 (context). Auth uses asymmetric JWTs, rotating refresh tokens bound to a farm (expectedAccountId), the credential-epoch middleware, a step-up registry backed by IClaimOnceStore, and Redis or in-process limiters. All of these sit outside the diff.
  • Phase 2 (comparison). The new code adds no input path, query, deserialization, crypto, logging of secrets or PII, or authorization decision. It replaces six injected services with one scoped facade that forwards each call unchanged.
  • Phase 3 (assessment).
    • Authentication bypass: none, because every token decision is still made inside IdentityProvider and StepUpGrantService.
    • Authorization: unchanged, because .RequireAuthorization() and the IsResolved checks are not in the diff.
    • Session management: the account binding and the cookie are unchanged.
    • Data exposure: RecordLoginFailure logs the existing user-free shape.
    • The test file is excluded under the skill's hard exclusion 11.
  • Result: no vulnerabilities at confidence ≥ 8. F1 and F2 are test-strength issues, which the skill excludes, so I report them only in the adversarial section above.

pstack:thermo-nuclear-code-quality-review (report only; all P3, slice-scoped)

File sizes: AuthEndpoints.cs goes from 650 to 647 lines, IAccessModule.cs from 59 to 87, and AccessModule.cs from 86 to 122. No file crosses 1k lines. No new conditionals; the endpoint diff is a parameter swap.

  • Q1: P3. IAccessModule is a wide, shallow facade (IAccessModule.cs:21-86, AccessModule.cs:22-40). It now has 24 members across four concerns: user administration, own profile, assignments and sessions. Most are one-line forwards, and AccessModule takes 18 constructor parameters. Every user-admin route therefore builds the session services, and every login builds the 11 user-admin handlers. Removing the six services from the endpoints moves complexity behind the interface rather than deleting it. The concepts a reader holds in Login are the same five calls with new names. Code-judo path (after PR E, not here): split a session surface out, for example IAccessSessions, the same way the design already separates IAccessOperations for process-role reasons, so each surface constructs only what its routes use. Then deepen it: one SignInAsync(farmCode, email, password) returning a closed outcome (UnknownFarm | Suspended | NoOwnerProvisioned | InvalidCredentials | Success(pair)) would move the branch order into Access and remove ResolveFarmCodeAsync, IsFirstRunProvisionedAsync and RecordLoginFailure from the contract. Doing that in C would move security-sensitive ordering, which v2 and the parity trace deliberately freeze, so forwarding is the right shape for this step.
  • Q2: P3. RecordLoginFailure() exposes Access's observability policy as a command (IAccessModule.cs:70, AccessModule.cs:102). The adapter decides when a LoginFailed happens. Any holder of IAccessModule can emit the event without checking a credential. That is not a vulnerability, but it is a contract that cannot hold its own invariant. Q1's SignInAsync removes it.
  • Q3: P3. RefreshTokenLifetimeDays puts a configuration value on a behavioral contract (IAccessModule.cs:86, AccessModule.cs:121), and the endpoint threads it through four cookie writes plus LegacyUpgradeResult's lifetimeDays parameter. IdentityProvider already computes the row's ExpiresAt = now.AddDays(RefreshTokenDays) (IdentityProvider.cs:1595). If TokenPair carried RefreshTokenExpiry, the cookie would take that instant. That would remove the property, the module's IOptions<JwtOptions> dependency and the parameter threading, and make the cookie's Expires match the stored row exactly. It would also fix F1 by construction. This is outside C's mandate: the design says "No PR writes token-minting code" and TokenPair belongs to the mint path. Record it for PR E or later.

The approval bar is met for a mechanical move. There is no structural regression relative to the base, only a deferred deepening.

ponytail:ponytail-review (report only; all P3, slice-scoped)

  • AuthSessionContractTests.cs:L22-25: reuse: third private copy of ScalarOf (also AuthRateLimitLoggingTests.cs:56, ClientErrorReportTests.cs:57). One internal static in Infrastructure/ replaces all three. Moving the other two is outside this slice, so at most stop adding copies.
  • AuthSessionContractTests.cs:L27-28: shrink: LoginFailures() has one caller (:55). Inline it into var events = ….
  • AuthSessionContractTests.cs:L30-31: reuse: a problem-title reader already exists twice (RefreshAccountBindingTests.cs:257, CredentialEpochFloorTests.cs:252). Same remedy as ScalarOf.
  • src/ diff: nothing to cut. Each forward exists to take one of the six dependencies off the endpoint, which is the PR's purpose; Q3 covers the only member that could disappear.

net: -6 lines possible.

Is there any product or security defect?

No. I found no product defect and no security defect. F1 and F2 are confirmed test gaps (P3). Q1–Q3 are P3 design notes for after PR E.

Verdict: merge. F1 is a cheap in-slice hardening worth adding first if another round happens anyway.

Base automatically changed from refactor/857b-users-and-access-lookup to main October 3, 2026 20:02
mforce added 2 commits October 3, 2026 20:03
…dpoints

# Conflicts:
#	src/Cluckwork.Application/Features/Users/IAccessModule.cs
#	src/Cluckwork.Infrastructure/Identity/AccessModule.cs
#	tests/Cluckwork.Application.Tests/Architecture/Data/coupling-matrix.md
…he legacy upgrade

Review of #1041 (Opus F1, F2). The lifetime test read Jwt:RefreshTokenDays
from a host that never set it, so it proved only "30 days", which a cookie
written with the default also satisfied. Both lifetime tests now run on a
host with Jwt:RefreshTokenDays=7. The legacy-cookie upgrade, the fourth
refresh-cookie write, had no lifetime assertion; it has one now.

Refs #857
@mforce

mforce commented Oct 3, 2026

Copy link
Copy Markdown
Owner Author

Pushed c17e8436: main merged in, plus the two owner-approved cookie-lifetime tests. No rebase and no force-push.

  • Main merge (62832479). refactor(users): put user administration behind IAccessModule and peer reads behind IAccessLookup #1037 merged as 75e2b1be. main's tree was identical to B's branch head, so every conflict took C's side, and the merge commit's tree equals C's previous tree plus main. git diff origin/main...HEAD --stat lists only C's six files.
  • F1. Both lifetime tests now run on a host with Jwt:RefreshTokenDays=7. Opus's O1 (login hardcodes 30) is red: login: expected the refresh cookie to expire about 2026-10-10T20:06…, got 2026-11-02T20:06….
  • F2. A new test pins the legacy-cookie upgrade's lifetime. Opus's O2 (a one-day cookie) is red: legacy upgrade: expected … 2026-10-10T20:06…, got 2026-10-04T20:06…. The PR's C1 (days + 1) is red in both lifetime tests.
  • Test-only. No production code changed.
  • Verification at c17e8436, with test: fail the integration suite on EF Core query-shape warnings #1039's query-shape check on. The 65 integration classes that call an auth, users or me route or name IAccessModule, found by walking the test files, pass 584 of 584 and raise no query-shape warning. The Application suite passes 702 of 702.

The mutation table and the test counts in the body are updated.

@mforce
mforce merged commit a78729d into main Oct 3, 2026
19 checks passed
@mforce
mforce deleted the refactor/857c-auth-endpoints branch October 3, 2026 21:05
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.

1 participant