Skip to content

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.