Skip to content

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).