SCRUM-203 — admin-auth: POST /admin-auth/login¶
Plan ref: AA-4 (docs/11-admin-plane-plan.md). Stacked on SCRUM-199. Implements the
login half of the contract in doc 06 §13.1 that the admin UI is already coded against.
Behaviour¶
POST /admin-auth/login {"email","password"} →
- 200
{"access_token","expires_in","user":{"id","name","roles"}}+Set-Cookie: otomo_refresh=<32 random bytes, base64url>; Path=/admin-auth/; Max-Age=<refresh TTL>; HttpOnly; Secure; SameSite=Strict. Onlysha256(token)is stored (refresh_session, new family id, client IP, UA). - 401
invalid_credentials"that email and password do not match an account" for every failure — unknown email, wrong password, disabled, locked. Same body, same code. - 400
invalid_body/validation_failedfor bad bodies (16 KiB cap).
Security properties:
| Property | How |
|---|---|
| passwords | argon2id PHC m=19456,t=2,p=1 (OWASP minimum), 16-byte salt; parameters read back from the stored string so they can be raised later; constant-time compare |
| no user enumeration by work | unknown email verifies against a dummy hash; a known user is always verified before the disabled/locked checks, so every path does one argon2id |
| lockout | one atomic UPDATE: 5 failures (ADMIN_AUTH_LOGIN_MAX_FAILURES) → locked 15 min (ADMIN_AUTH_LOGIN_LOCKOUT), counter reset; attempts while locked don't count; login.locked audit row in the same tx |
| audit | login.success with the session insert, one transaction |
case-insensitive (lower(email)) |
|
| client IP | X-Forwarded-For (gateway_dev sets it to exactly the peer on this route, SCRUM-202), else RemoteAddr |
New env: ADMIN_AUTH_REFRESH_TOKEN_TTL (168h, ≤720h), _LOGIN_MAX_FAILURES (5),
_LOGIN_LOCKOUT (15m).
Not yet: MFA. A user with a confirmed TOTP factor currently logs in with the password alone (logged as a warning) — SCRUM-208 closes this. No user can have one until then, because enrollment is also SCRUM-208.
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 -count=1 -v -run 'TestLogin|Excessive' ./internal/server/ ./internal/password/
| Test | Proves |
|---|---|
password tests |
round-trip, wrong password, malformed PHC, params parsed from the string, absurd params rejected |
TestLoginSuccess |
200 shape; every cookie attribute; access token verifies and carries sub/name/roles; DB holds the sha256, never the raw token; login.success audit |
TestLoginEmailIsCaseInsensitive |
USER@x logs in user@x |
TestLoginFailuresShareOneResponse |
unknown / wrong password / disabled → byte-identical code + message |
TestLoginLockoutAndRecovery |
5 wrong → one login.locked; correct password while locked → 401; after lock expiry → 200 |
TestLoginVerifyRunsExactlyOncePerAttempt |
unknown email and wrong password each run argon2id exactly once (no wall-clock timing asserts — they flake) |
TestLoginBadBodies |
9 bad-body cases → 400 |
TestLoginSurvivesHostileUserAgent |
UA with invalid UTF-8, or cut mid-rune by the cap → still 200 |
By hand, once a user exists (SCRUM-205 bootstrap-root / SCRUM-206 invites; until
then insert one with a hash from password.Hash):
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 (334 s, ~57k output tokens —
the heaviest run so far). Claude review found and fixed:
- bug: the user-agent was cut at 256 bytes without regard to UTF-8, and a
client can send invalid UTF-8 outright; Postgres rejects that in a text column,
so such a login returned 500. Now truncated to valid UTF-8 (IP too); the new
test fails with 500 without the fix (checked).
- hardening: argon2id parameters parsed from the DB are capped (256 MiB, 16 passes,
16 lanes), so a corrupt row cannot turn one login into a huge allocation.