Files
minecraft-account-manager/docs/admin-api-authentication.md
T
dmg c2ac2ad16b
CI / validate (push) Successful in 6m49s
Release / release (push) Successful in 11m24s
feat(auth): verify machine tokens for admin read APIs
2026-09-10 14:53:47 -04:00

8.0 KiB

Admin API authentication

Implemented for GET /api/admin/whoami and the read-only suggestions API. The shared guard is apps/web/src/lib/auth/admin-api-auth.ts. This is not browser token login, a general admin mutation API, or the Velocity admission credential mechanism. Browser pages, privileged server actions, RCON, and Velocity authentication are unchanged. OpenAPI publication is separate work (US-026).

Credential selection

  • No Authorization header: use the existing NextAuth administrator session and its configured role check. Player sessions do not qualify. Existing browser realm/client role behavior remains intact.
  • Any Authorization header supplied: exclusively use bearer authentication. Empty, malformed, duplicated/combined, unsupported, expired, or otherwise invalid credentials never fall back to a browser session, including a privileged session cookie.
  • The bearer scheme is case-insensitive. Send one compact signed JWT, not a client secret, bot token, or refresh token.

Machine verification

The web application directly depends on jose 6. Verification uses real cryptographic signature checking, not decoded-token role extraction:

  • Only RS256 is accepted.
  • iss must exactly equal the trimmed KEYCLOAK_ISSUER_URL configuration.
  • aud must contain KEYCLOAK_CLIENT_ID (a string or audience array is supported). azp does not substitute for aud.
  • exp and a nonblank string sub are required. Expired tokens and future nbf are rejected with no added clock tolerance.
  • The required role (configured KEYCLOAK_REQUIRED_ROLE, default minecraft-account-manager-admin) must appear in resource_access[KEYCLOAK_CLIENT_ID].roles. Realm roles or roles for other clients are not accepted.
  • The configured issuer must be HTTPS with no embedded credentials, query, or fragment. Keys come only from <issuer-without-final-slash>/protocol/openid-connect/certs; token iss, jku, and x5u never select a key URL. Redirects are not followed.

One bounded, process-local remote JWKS resolver caches keys for ten minutes, coalesces concurrent fetches, permits refresh for unknown keys after a 30-second cooldown, and bounds each network fetch to five seconds. A changed configured issuer replaces the resolver. Replicas do not share the cache. Key rotation can temporarily reject a new key during cooldown; removed keys may remain usable until the cache refreshes. Access-token validity is local JWT verification, not per-request revocation/introspection. Use suitably short token lifetimes and synchronized clocks.

KEYCLOAK_CLIENT_SECRET is not needed for machine verification. This implementation does not obtain tokens or alter identity-provider clients, role/audience mappers, credentials, or deployments. See OIDC setup for the distinct browser configuration.

Safe identity endpoint

GET /api/admin/whoami authenticates and checks administrator permission before returning JSON with Cache-Control: no-store:

{
  "authenticationMethod": "bearer",
  "subject": "machine-subject",
  "name": null,
  "email": null
}

For browser sessions, authenticationMethod is session, subject is null (the existing session does not expose it), and name/email are the existing session values or null. Machine profile claims are not returned. No raw token, role list, session expiry, key material, client secret, or arbitrary claims are exposed. This endpoint requires no Discord or database access.

Failures

All guard failures use RFC 9457 application/problem+json, Cache-Control: no-store, a matching HTTP/body status, and a request-path instance. There are no sign-in redirects or token/error-detail logs.

Status Type Meaning
401 urn:error:unauthorized Missing session or invalid supplied credentials; includes WWW-Authenticate: Bearer realm="admin-api".
403 urn:error:forbidden Authenticated identity lacks the required permission.
503 urn:error:admin-auth-unavailable Invalid/missing machine configuration, JWKS transport/format failure, or browser session service failure.

Authentication is checked before suggestions cache access or Discord requests. Valid machine credentials do not enable writes: suggestions write methods remain 405. Errors do not reveal credentials, raw claims, upstream response bodies, or exception messages.

Verification and rollout boundary

Focused coverage lives in:

  • apps/web/src/lib/auth/admin-api-auth.test.ts: real signed JWTs, controlled JWKS HTTP transport (not mocked jwtVerify), validation failures, client-role isolation, configuration/network safety, caching, concurrent reads, and rotation.
  • apps/web/src/app/api/admin/whoami/route.test.ts: safe identity projection and guard failures through the route.
  • apps/web/src/app/api/suggestions/{route,machine-auth}.test.ts: browser regression, bearer precedence, all three read routes, cache authorization, and read-only behavior.

Run from the source repository:

npm test --workspace @minecraft-account-manager/web -- src/lib/auth/admin-api-auth.test.ts src/app/api/admin/whoami/route.test.ts src/app/api/suggestions
npm test
npm run lint
npm run typecheck
npm run build
npm run velocity:build

Test-first implementation evidence (US-025)

The following runs were observed against the local implementation; no commit or publication is part of this task:

Slice / focused test arguments after npm test --workspace @minecraft-account-manager/web -- Red before implementation Green after implementation
src/app/api/suggestions/route.test.ts 6 failures: supplied headers fell through to the browser path (503 instead of 401), and missing-session 401 lacked the challenge. 21 passing after shared-guard integration.
src/lib/auth/admin-api-auth.test.ts 15 failures: valid signed tokens were rejected; client-role and unavailable-service responses were not implemented. 53 passing with the suggestions regression suite after actual JWT/JWKS verification.
src/app/api/admin/whoami/route.test.ts 6 explicit route-absence assertion failures (suite ran without a broken module import). 59 passing across all three suites after adding the route; route discovery then refactored to a direct import.

Additional integration/resilience regression coverage brought the focused suite to 72 passing tests: every suggestions read route, authorization before cached reads, unchanged write denial, untrusted signing keys, algorithm restrictions, concurrent JWKS fetching, key rotation, malformed upstream responses, and redirect refusal. Negative-token tests use an available privileged browser session to verify that invalid bearer credentials never fall back. The tests sign actual JWTs and exercise jose verification against controlled transport responses; jwtVerify is never mocked.

Full local verification passed: npm test (252 tests, including 214 web tests), npm run lint (zero errors; two existing navigation warnings in unchanged map-view-toggle.tsx), npm run typecheck, npm run build (whoami emitted as a dynamic route), and npm run velocity:build (clean test shadowJar, Java 17). The unrelated next-env.d.ts addition generated by Next.js during the build was removed to keep the source diff scoped.

Security scan: semgrep scan --config p/typescript --config p/jwt --metrics=off on the shared guard, suggestions wrapper, and whoami implementation completed with 74 rules, three files, zero findings. The initial --config auto --metrics=off invocation was rejected by Semgrep; the explicit-rule run is the successful result. This is scoped static-analysis evidence, not a complete security audit.

Offline tests do not establish live Keycloak audience/role issuance, JWKS reachability, or production authorization. Production deployment remains separately gated. After explicit release approval, verify machine whoami and suggestions reads, rejection of an unauthorized identity, and browser session access using approved credential handling (never token values in chat, command arguments, or logs).