SCRUM-204 — admin-auth: refresh, logout, me¶
Plan ref: AA-5 (docs/11-admin-plane-plan.md). Stacked on SCRUM-203. Completes the
five-route staff surface of doc 06 §13.1 that the admin UI is coded against.
Behaviour¶
POST /admin-auth/refresh (cookie otomo_refresh, no body). One transaction,
the session row locked FOR UPDATE:
| Situation | Result |
|---|---|
| no cookie / unknown / revoked / expired | 401 invalid_credentials "no valid refresh session", cookie cleared |
| account not active | family revoked, 401, cookie cleared |
token already rotated, within ADMIN_AUTH_REFRESH_REUSE_GRACE (30s) |
200 with a fresh access token, no Set-Cookie, nothing revoked — the benign multi-tab race: two tabs refresh with the same cookie at once and the browser already holds the successor |
| token already rotated, after the grace | reuse detected: every session in the family revoked, audit session.reuse_detected {family_id}, 401, cookie cleared |
| otherwise | old row rotated_at, successor in the same family, name and roles re-read from the DB (a demotion applies at the next refresh), new cookie, 200 with the login body |
POST /admin-auth/logout: revokes the cookie's whole family (audit logout);
always 204 with a cleared cookie, even with no or an unknown cookie.
GET /admin-auth/me (Bearer): verified in-process (EdDSA, active kid,
iss/aud, exp, 30s leeway) with the same rejection codes gateway_dev uses
(missing_token, invalid_signature, expired, iss_mismatch, aud_mismatch,
invalid_token); user loaded from the DB by sub (missing or not active → 401);
returns {id, name, roles} from the DB, not the token.
Cleared cookie = same attributes (Path=/admin-auth/, HttpOnly, Secure,
SameSite=Strict), empty value, Max-Age=0.
How to verify¶
cd services/admin_auth
export ADMIN_AUTH_TEST_DATABASE_URL='postgres://USER:PASS@127.0.0.1:5433/admin_auth_test?sslmode=disable'
go vet ./... && go test -race -count=1 ./...
go test -race -count=1 -v -run 'Refresh|Logout|Me' ./internal/server/ ./internal/token/
| Test | Proves |
|---|---|
TestRefreshRotates |
new cookie ≠ old; old row rotated; successor in the same family; token valid |
TestRefreshRereadsRolesFromTheDatabase |
roles changed in the DB → next refresh's token and body carry them |
TestRefreshReuseWithinGrace |
200, no Set-Cookie, nothing revoked, no reuse audit |
TestRefreshReuseAfterGraceRevokesTheFamily |
401, whole family revoked, one session.reuse_detected |
TestRefreshConcurrent |
5 simultaneous refreshes with one cookie → exactly one rotation, the rest 200 via grace, nothing revoked |
TestRefreshRejectsDeadCookies |
expired / revoked / unknown → 401 + cleared cookie |
TestRefreshDisabledUserRevokesFamily |
401, family revoked |
TestRefreshGraceDoesNotResurrectADisabledUser |
disabled right after a refresh, then the old cookie replayed inside grace → 401 |
TestLogoutRevokesFamilyAndAlwaysClears |
204, cleared cookie, family revoked, later refresh 401; no cookie → 204 |
TestMeReadsTheUserFromTheDatabase, TestMeRejections |
DB values returned; tampered / expired / wrong audience / unknown kid / missing / disabled → 401 with the right code |
Results at time of writing¶
go vet,go test -race(6 packages) against Postgres 16: pass.
How it was built¶
DeepSeek run scoped (Landlock) to services/admin_auth (371 s, ~54k output
tokens). Claude review found a real hole: the grace branch ran before the
account-status check, so replaying the just-rotated cookie within 30s of an account
being disabled still minted a working access token. The status check now runs
first; the new test gets 200 + a token without the fix and 401 with it (checked).