feat(auth): verify machine tokens for admin read APIs
This commit is contained in:
@@ -0,0 +1,88 @@
|
||||
# Admin API authentication
|
||||
|
||||
Implemented for `GET /api/admin/whoami` and the read-only [suggestions API](admin-suggestions-api.md). 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](admin-oidc-keycloak-setup.md) 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`:
|
||||
|
||||
```json
|
||||
{
|
||||
"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:
|
||||
|
||||
```sh
|
||||
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).
|
||||
@@ -1,6 +1,6 @@
|
||||
# Admin OIDC setup
|
||||
|
||||
The admin console will use Keycloak OIDC and JWT-backed Auth.js sessions, following the established pattern in the sibling Retro application.
|
||||
The admin console uses Keycloak OIDC and JWT-backed NextAuth sessions.
|
||||
|
||||
## Application environment
|
||||
|
||||
@@ -21,4 +21,10 @@ Allow exact callback and logout URLs for each environment. Avoid wildcard origin
|
||||
|
||||
Create the realm role `minecraft-account-manager-admin` and assign it directly or through an admin group. Ensure realm roles are emitted in `realm_access.roles`.
|
||||
|
||||
The admin console will reject sign-in when the required role is absent, even when Keycloak authentication itself succeeds.
|
||||
The admin console rejects sign-in when the required role is absent, even when Keycloak authentication itself succeeds. Existing browser sign-in accepts realm or configured-client roles; this behavior is unchanged.
|
||||
|
||||
## Read-only machine API access
|
||||
|
||||
The [admin API guard](admin-api-authentication.md) independently verifies signed Keycloak access tokens. Machine tokens must include `KEYCLOAK_CLIENT_ID` in `aud` and `KEYCLOAK_REQUIRED_ROLE` in `resource_access[KEYCLOAK_CLIENT_ID].roles`. A realm role alone is **not** sufficient for bearer access. The identity provider must emit both the portal audience and this client role; a token's `azp` is not an audience substitute.
|
||||
|
||||
Verification uses the HTTPS issuer's `/protocol/openid-connect/certs` JWKS endpoint and RS256 only. It does not use `KEYCLOAK_CLIENT_SECRET`, exchange tokens, or create a browser session. Browser client configuration above remains required for interactive SSO. Provisioning or changing machine clients, role/audience mappers, credentials, and production deployment requires separate operational approval; this source implementation performs none of those operations.
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
# Admin suggestions API
|
||||
|
||||
The portal provides a read-only view of one Discord **forum channel**, using the existing NextAuth admin session and configured Keycloak role. Player sessions and Discord bot credentials are not accepted as API credentials. Sign in at `/admin/login` first; same-origin browser calls send the session cookie. Unattended machine authentication is not provided.
|
||||
The portal provides a read-only view of one Discord **forum channel**, using the existing NextAuth admin session or a verified Keycloak machine bearer token. Player sessions and Discord bot credentials are not accepted as API credentials. Browser users sign in at `/admin/login`; same-origin calls send the session cookie. Machine clients use `Authorization: Bearer <access-token>` with the configured portal audience and **client** role. See [Admin API authentication](admin-api-authentication.md) for verification rules, safe identity checks, and failure behavior.
|
||||
|
||||
## Portal interface
|
||||
|
||||
Open `/admin/suggestions` from the administrator navigation. The idea desk lists active or archived forum posts with tags, status, approximate message counts, author IDs, timestamps, and Discord links. Select a title to open `/admin/suggestions/:id`, read the starter post and its reaction counts, and page through discussion newest-first. The starter is not duplicated in the discussion view.
|
||||
|
||||
The UI uses these same session-protected GET endpoints, not a second integration. Both pages independently check admin access before rendering; the APIs recheck it on every read. Expired/unauthorized API access offers an admin sign-in link. Loading, empty/deleted content, missing-text, and retryable failure states are explicit. Changing status aborts obsolete requests, and pagination restores keyboard focus to the page indicator. Text is rendered literally with React escaping, never as HTML or interpreted Discord Markdown. No bot token, forum configuration value, reply input, vote button, or moderation control is added to the client bundle.
|
||||
The UI uses these same admin-protected GET endpoints with its browser session, not a second integration. Both pages independently check admin access before rendering; the APIs recheck it on every read. Expired/unauthorized API access offers an admin sign-in link. Loading, empty/deleted content, missing-text, and retryable failure states are explicit. Changing status aborts obsolete requests, and pagination restores keyboard focus to the page indicator. Text is rendered literally with React escaping, never as HTML or interpreted Discord Markdown. No bot token, forum configuration value, reply input, vote button, or moderation control is added to the client bundle.
|
||||
|
||||
Pagination history is page-local and resets when switching status or leaving the page. Reload the browser to refresh a view; upstream reads may use the documented 30-second cache. The real forum ID is still configured only through GitOps, not the UI or source.
|
||||
|
||||
@@ -37,8 +37,9 @@ Suggestion fields: `id`, `title`, `authorId`, `createdAt`, `archived`, `locked`,
|
||||
|
||||
Errors use RFC 9457 `application/problem+json`, HTTP-matching `status`, stable `urn:error:*` types, and safe details:
|
||||
|
||||
- `401 unauthorized`: no admin session; no redirect.
|
||||
- `403 forbidden`: session lacks the required role.
|
||||
- `401 unauthorized`: no admin session or invalid supplied credentials; no redirect; includes `WWW-Authenticate: Bearer realm="admin-api"`.
|
||||
- `403 forbidden`: verified identity lacks the required role (configured-client role for bearer tokens).
|
||||
- `503 admin-auth-unavailable`: authentication configuration, browser session service, or JWKS service unavailable; no session fallback for supplied credentials.
|
||||
- `400 invalid-request`: invalid ID, cursor, limit, status, or list/message query parameter.
|
||||
- `404 suggestion-not-found`: inaccessible/deleted thread, or thread outside the configured forum.
|
||||
- `405 method-not-allowed`: writes are unsupported; `Allow: GET, HEAD`.
|
||||
|
||||
Reference in New Issue
Block a user