Skip to content

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. Only sha256(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_failed for 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
email 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):

curl -i -X POST localhost:8080/admin-auth/login -d '{"email":"root@otomo.internal","password":"…"}'

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.