SCRUM-199 — admin-auth: signing key, staff token issuer, JWKS¶
Plan ref: AA-3 (docs/11-admin-plane-plan.md). Stacked on SCRUM-198.
What changed¶
Ported from services/auth (keyfile, JWKS cache, key store, JWKS handler are
identical but for names), with staff-specific differences:
| Piece | Behaviour |
|---|---|
admin_auth genkey [-kid] [-out] |
writes an Ed25519 PKCS#8 PEM (never overwrites) and inserts the matching active signing_key row; default kid admin-auth-<date> |
serve startup |
loads ADMIN_AUTH_SIGNING_KEY_PATH; exits 1 if the key's public half has no active row; builds the JWKS from all active keys |
token.Signer.Issue(sub, name, roles, now) |
EdDSA, header kid; iss=https://admin-auth.otomo.internal, aud=["otomo:staff"], sub, roles (empty roles refused), optional name, iat/nbf/exp (TTL ADMIN_AUTH_ACCESS_TOKEN_TTL, default 15m, max 1h) |
GET /.well-known/jwks.json |
public, Cache-Control: no-store; fetched east-west by gateway_dev and the backends, never proxied by gateway_dev |
/readyz |
now green once a key is published (the AA-1 placeholder is gone) |
| metric | admin_auth_jwks_keys_active |
The name claim feeds Config's audit_log.actor_name (added in SCRUM-211).
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 ./...
| Test | Proves |
|---|---|
token tests |
Issue → verifies with EdDSA pinning, issuer, audience, exp required; roles/name/sub/kid present; exp−iat == TTL; empty roles → error; keyfile round-trip |
token.TestContract* |
the payload decodes into the exact claims struct gateway_dev/Config use; aud is an array with otomo:staff |
| server tests | JWKS 200 + no-store + one OKP/Ed25519 key with the kid; /readyz 200 with a key |
| store tests | insert → FindActiveByPublicKey / ListActiveSigningKeys (unique kids per run) |
| config tests | signing key path required for serve; TTL >0 and ≤1h |
End-to-end (done during review — the ticket's acceptance criterion)¶
Real binaries, local Postgres: admin_auth genkey + serve; unmodified
Config (SCRUM-211 branch) and gateway_dev (SCRUM-195 branch) with
*_STAFF_JWKS_URL=http://127.0.0.1:18180/.well-known/jwks.json; tokens minted with
admin-auth's own Signer from the generated key. Results through gateway_dev:
| Request | Result |
|---|---|
viewer GET /api/admin/config/namespaces |
200 from Config |
admin POST /api/admin/config/namespaces |
201; Config audit row actor_name = "E2E admin" (the name claim) |
live_ops POST …/namespaces |
403 (Config's own admin bar) |
viewer GET /api/admin/users |
403 (gateway's admin bar) |
| admin token with one signature byte changed | 401 invalid_signature |
Session was not run live (it also needs Valkey); it uses the same claims struct.
To repeat it by hand: admin_auth genkey && admin_auth serve, point Config and
gateway_dev's staff JWKS URL at it, and mint a token in a Go test inside
internal/token with Load(keyPath) + Signer.Issue (there is deliberately no
mint command — AA-4's login is the only issuer).
Results at time of writing¶
go vet,go test -race(5 packages) against Postgres 16: pass.- End-to-end table above: as shown.
How it was built¶
DeepSeek run scoped (Landlock) to services/admin_auth (274 s, ~47k output
tokens). Claude: added the jwt dependency beforehand; reviewed by diffing against
services/auth; ran the cross-service end-to-end check.