Files
minecraft-account-manager/docs/admin-api-authentication.md
T
dmg 47782b3ccc
CI / validate (push) Successful in 7m15s
Release / release (push) Successful in 11m32s
feat(api): publish validated OpenAPI contract
2026-09-10 15:11:57 -04:00

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.
  • 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.

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 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).