12 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. The canonical OpenAPI 3.1 contract is public at /openapi.yaml; see contract maintenance.
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.
issmust exactly equal the trimmedKEYCLOAK_ISSUER_URLconfiguration.audmust containKEYCLOAK_CLIENT_ID(a string or audience array is supported).azpdoes not substitute foraud.expand a nonblank stringsubare required. Expired tokens and futurenbfare rejected with no added clock tolerance.- The required role (configured
KEYCLOAK_REQUIRED_ROLE, defaultminecraft-account-manager-admin) must appear inresource_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; tokeniss,jku, andx5unever 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.
Obtain and use a machine token safely
The Keycloak token endpoint is <KEYCLOAK_ISSUER_URL-without-final-slash>/protocol/openid-connect/token. Use grant_type=client_credentials with a separately provisioned confidential service-account client. Its issued access token must include the portal audience and the portal client's administrator role described above; the machine client's own ID or azp is not a substitute. Client provisioning, credential retrieval and role/audience changes require separate operational approval. Production uses issuer https://auth.20faces.games/realms/infra, token endpoint https://auth.20faces.games/realms/infra/protocol/openid-connect/token, and portal https://portal.somc.club. Confirm these against current approved configuration before use. The token endpoint is owned by Keycloak, not a portal route.
No helper is installed. This optional, one-shot Python 3 standard-library example prompts on the controlling terminal, keeps the client secret and access token in process memory, and prints only the HTTP status of whoami. It does not save or print the token or identity response. Use only on an approved trusted workstation. Do not enable shell tracing, HTTP debug logging, terminal recording, or request-body/header capture. Never paste a secret into a command, curl -d, an Authorization argument, environment export, chat, or a log. For automation, use an approved secret-manager/protected-file input and pass credentials directly to an HTTP library in memory rather than command arguments.
python3 - <<'PY'
import getpass
import json
import sys
import urllib.error
import urllib.parse
import urllib.request
class NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl):
return None
# HTTPS certificate verification remains enabled; never follow credential redirects.
def https_url(value):
value = value.strip().rstrip("/")
url = urllib.parse.urlsplit(value)
if (url.scheme != "https" or not url.hostname or url.username is not None
or url.password is not None or url.query or url.fragment):
raise ValueError("An approved HTTPS URL is required")
return value
try:
with open("/dev/tty", "r") as terminal:
def prompt(label):
print(label, end="", flush=True)
return terminal.readline().strip()
issuer = https_url(prompt("Approved Keycloak issuer URL: "))
portal = https_url(prompt("Approved portal URL: "))
client_id = prompt("Machine client ID: ")
client_secret = getpass.getpass("Machine client secret: ")
opener = urllib.request.build_opener(NoRedirect)
form = urllib.parse.urlencode({
"grant_type": "client_credentials",
"client_id": client_id,
"client_secret": client_secret,
}).encode()
token_request = urllib.request.Request(
issuer + "/protocol/openid-connect/token", data=form,
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
with opener.open(token_request, timeout=10) as response:
access_token = json.load(response)["access_token"]
identity_request = urllib.request.Request(
portal + "/api/admin/whoami",
headers={"Authorization": "Bearer " + access_token},
)
with opener.open(identity_request, timeout=10) as response:
print("whoami HTTP", response.status)
# To read suggestions, use the same in-memory header with /api/suggestions.
# Process exit releases memory; this is not a secure-memory erasure guarantee.
except urllib.error.HTTPError as error:
print("Request failed; HTTP", error.code, file=sys.stderr)
sys.exit(1)
except Exception:
print("Request failed; verify configuration and connectivity securely.", file=sys.stderr)
sys.exit(1)
PY
Token acquisition is not part of this application's implementation or offline tests. The example performs real network requests only when an operator explicitly runs it; it is not run by the source checks. A 401/403/503 from whoami has the semantics below. Do not print upstream error bodies while diagnosing issuance failures. Token requests can appear in identity-provider access logs: confirm that request bodies and Authorization headers are redacted before use.
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 mockedjwtVerify), 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).