Skip to content

Latest commit

 

History

History
921 lines (756 loc) · 84.5 KB

specs.md

File metadata and controls

921 lines (756 loc) · 84.5 KB

SPEC — mcp-auth-proxy

Objective

Reverse proxy in Go acting as an OAuth 2.1 Authorization Server compatible with the MCP auth spec, federating authentication to any OIDC Identity Provider (Keycloak, Microsoft Entra ID, Auth0, Okta, Google...) via auto-discovery. It lets MCP clients (claude.ai, Claude Code) access a private MCP server through a standard PKCE flow.

Companion docs: docs/conformance.md (RFC claim matrix + IdP evidence), docs/threat-model.md (STRIDE coverage with code + test + runbook links).


Standards Conformance

This proxy MUST conform to the following specifications:

Spec Usage
OAuth 2.1 (draft-ietf-oauth-v2-1-13) Authorization code + PKCE flow, token handling, security requirements. Draft-13 is the version pinned by MCP Authorization 2025-06-18; the IETF OAuth WG has since published draft-15 — deltas tracked separately and not yet adopted here
RFC 8414 — OAuth 2.0 Authorization Server Metadata GET /.well-known/oauth-authorization-server discovery endpoint
RFC 7591 — OAuth 2.0 Dynamic Client Registration POST /register — automatic client registration for MCP clients
RFC 9728 — OAuth 2.0 Protected Resource Metadata GET /.well-known/oauth-protected-resource — MCP clients discover the AS through this endpoint; WWW-Authenticate header on 401 responses MUST include resource_metadata URL
RFC 8707 — Resource Indicators for OAuth 2.0 resource parameter accepted in /authorize and /token requests
RFC 7636 — PKCE code_verifier must be 43-128 characters, code_challenge_method must be S256
MCP Authorization Spec (2025-06-18) End-to-end MCP auth flow combining the above RFCs

Claude-specific requirements

  • Claude's OAuth callback URL: https://claude.ai/api/mcp/auth_callback (may migrate to https://claude.com/api/mcp/auth_callback)
  • Claude's OAuth client name: Claude
  • Claude supports Dynamic Client Registration (DCR)
  • Claude supports both SSE and Streamable HTTP transports (SSE may be deprecated)
  • Claude supports token expiry and refresh

Architecture

MCP Client (claude.ai / Claude Code)
    │
    │  0. MCP request → 401 + WWW-Authenticate header
    │  1. GET /.well-known/oauth-protected-resource  (RFC 9728)
    │  2. GET /.well-known/oauth-authorization-server (RFC 8414)
    │  3. POST /register  (RFC 7591 Dynamic Client Registration)
    │  4. GET /authorize  (PKCE + resource param)
    │  5. POST /token     (+ resource param)
    │  6. MCP requests with Bearer token
    ▼
mcp-auth-proxy  (this service)
    │
    │  Federates auth → OIDC IdP (Keycloak, Entra, Auth0...)
    │  Validates incoming Bearer tokens
    │  Forwards requests to upstream MCP server
    ▼
Upstream MCP Server (target, unmodified)

Stateless design

All transient OAuth state (client registrations, authorize sessions, authorization codes, refresh tokens) is AES-GCM encrypted into the tokens and URL parameters themselves. Any instance sharing the same TOKEN_SIGNING_SECRET can handle any request — no shared storage, no sticky sessions.

Flow state Encrypted into Carries audience?
Client registration client_id (encrypted blob, 7d default TTL — configurable via CLIENT_REGISTRATION_TTL) yes
Authorize session IdP state parameter (encrypted blob, 10min TTL) yes
Authorization code code parameter (encrypted blob, 60s TTL) yes
Access token Opaque token (encrypted claims, 1h TTL; at most 1h in forwarding mode, min(1h, IdP expiry − 60 s)) yes
Refresh token Opaque token (encrypted claims + iat, 7d TTL) yes
IdP refresh / access token (forwarding mode only) the authorization code (refresh token only), the refresh token (refresh token) and the access token (access token) — see Upstream IdP token forwarding inherited from the carrier

Audience binding (cross-instance replay protection)

Every sealed payload carries the proxy's PROXY_BASE_URL as an audience field, populated at creation time and verified on every open. Two deployments accidentally sharing the same TOKEN_SIGNING_SECRET (e.g. by copy-pasted Helm values, mirrored DR configs, or a shared Secret) cannot replay each other's tokens — the receiving instance rejects any payload whose audience does not match its own PROXY_BASE_URL.

The check is enforced in:

  • middleware/auth.go:Validate — access token bearer check
  • handlers/authorize.go — sealedClient open
  • handlers/callback.go — sealedSession open (before the IdP exchange runs)
  • handlers/token.go:handleAuthorizationCode — sealedCode and sealedClient open
  • handlers/token.go:handleRefreshToken — sealedRefresh and sealedClient open

Within a single deployment this is invisible: the audience always matches and nothing changes for clients. The cost is one string comparison per check.

Trade-offs:

  • No per-token revocation without a shared store. Mitigated by short access token TTL (1h), PKCE preventing code replay, and REVOKE_BEFORE for bulk revocation.
  • Authorization codes are replayable within their 60-second TTL when no replay store is configured. Mitigated by PKCE (the attacker also needs the code_verifier) and the short window. Set REDIS_URL to make codes strictly single-use across replicas (RFC 6749 §4.1.2).
  • Bulk revocation via REVOKE_BEFORE: set to the current timestamp and redeploy — all existing access tokens AND refresh tokens with iat before the cutoff are rejected. Refresh tokens carry their own iat so an attacker holding a leaked refresh cannot keep minting fresh access tokens past the cutoff. Incident response: rotate REVOKE_BEFORE and watch a kubectl rollout status complete before assuming the cutoff is enforced fleet-wide.

Replay protection (Redis-backed, required by default)

REDIS_REQUIRED defaults to true: the proxy fails startup with Fatal if REDIS_URL is unset. Stateless mode is an explicit opt-out (REDIS_REQUIRED=false) for dev or single-replica deployments that accept the trade-off.

Set REDIS_URL (e.g. redis://redis:6379/0, or rediss:// for TLS) to enable two layered protections backed by Redis SET NX / EXISTS. All Redis keys are namespaced with REDIS_KEY_PREFIX (default mcp-auth-proxy:) so multiple proxy deployments can safely share a single Redis DB without key collisions:

  • Single-use authorization codes. Each code carries a unique tid (UUID); /token claims the key atomically, so a second exchange attempt is rejected with invalid_grant + error_code: code_replay. Claim TTL matches the remaining code lifetime.
  • Refresh rotation with reuse detection (RFC 6749 §10.4 / OAuth 2.1 §6.1). Each refresh carries a unique tid and a fam (family ID). The fam is seeded at /callback (on the sealed code) and inherited by every refresh that descends from it, so a replayed authorization code (per RFC 6749 §4.1.2) and a replayed refresh both target the same family marker — the legitimate holder and the attacker are revoked together. On rotation the old tid is claimed; replaying an already-rotated token past the REFRESH_RACE_GRACE_SEC window is detected as reuse, revokes the whole family (refresh_family_revoked:<fam> marker, 7-day TTL), and any subsequent use of any sibling refresh is rejected with error_code: refresh_family_revoked. The compromised lineage stops minting tokens; both parties are forced back through /authorize. A racing second submit inside the grace window (parallel-tab refresh, slow-network double-submit) returns 429 refresh_concurrent_submit instead — the legitimate peer's rotation already succeeded, no family is killed, the racing client retries with the new refresh once it lands.

On Redis failure the handler fails closed (503 server_error / error_code: replay_store_unavailable) rather than issuing tokens against an unknown replay state. When REDIS_URL is unset the proxy stays fully stateless: codes remain replayable within the 60s TTL (mitigated by PKCE), and refresh tokens rotate without reuse detection. The exception is UPSTREAM_FORWARD_IDP_TOKEN=true, which then uses an in-memory replay store (single instance only).


Configuration

Migration notes (breaking changes since the previous spec)

Defaults were tightened to enforce the strict OAuth 2.1 / MCP posture. An operator pulling a new image without re-reading the config table will hit a hard Fatal at startup for most of these; RENDER_CONSENT_PAGE instead changes the shape of the /authorize response.

  • UPSTREAM_MCP_URL now requires an explicit path. Origin-only URLs (http://backend, http://backend/) used to be the only legal shape; they are now rejected. The path is the proxy's public mount AND the path forwarded upstream — pick what your upstream actually serves (FastMCP default: /mcp). The path is also restricted to RFC 3986 unreserved characters plus /, so :, *, {, }, @, + etc. are rejected — they would otherwise silently register chi router patterns instead of literal segments.
  • PROD_MODE now defaults to true. Was false. Strict mode rejects every relaxation flag (PKCE_REQUIRED=false, COMPAT_ALLOW_STATELESS=true, REDIS_REQUIRED=false, REDIS_URL empty, OIDC_ALLOW_INSECURE_HTTP=true, a weak TOKEN_SIGNING_SECRET, legacy TRUST_PROXY_HEADERS=true without TRUSTED_PROXY_CIDRS; RATE_LIMIT_ENABLED=false joined the list later, see below). Existing dev / single-replica deployments that depended on those flags must set PROD_MODE=false explicitly.
  • RENDER_CONSENT_PAGE now defaults to true. Was false. /authorize now returns a 200 HTML consent page instead of a 302 to the IdP; the user clicks Approve or Deny on a <form action="/consent" method="POST">. Closes the open-DCR-plus-active-IdP-session silent-issuance phishing class. Browser-driven MCP clients (claude.ai, Claude Code, Cursor, MCP Inspector, ChatGPT) follow the form transparently. If you drive /authorize from a non-browser caller (CI rig, scrape test, headless agent that expected a 302), you have two options: set RENDER_CONSENT_PAGE=false to keep the legacy silent redirect, or update the caller to handle a 200 HTML response with a consent_token-bearing form (see keycloak_e2e_test.go::approveConsent for the reference walk-through).
  • POST /consent with an Authorization header now answers 400, not 401. A challenge-less 401 violates RFC 7235 §3.1, and a challenge would pop a browser credential dialog on an endpoint that rejects credentials by design. Any harness asserting 401 there breaks.
  • /authorize, /consent and /callback now answer text/html to a caller sending Accept: text/html. Many HTTP clients send that by default, so a non-browser caller that previously received JSON now receives a ~1.3 kB page on the same status and with the same error/error_code. Send Accept: application/json (or no Accept) to keep the JSON body. /token, /register and discovery are unaffected whatever they ask for.
  • Wrong-method and unrouted requests now carry a body. chi's defaults returned an empty 405 and a text/plain 404; both now answer JSON, or the error page on a browser-facing path with a real navigation.
  • Request headers are capped at 64 KB on both listeners (MaxHeaderBytes), down from net/http's 1 MB default. Over that, net/http answers 431 Request Header Fields Too Large before any middleware, so it appears in no access log and no metric. The groups claim inside an access token is capped at mint time (GROUPS_CLAIM_MAX_BYTES, default 32 KB, range [1 KB, 40 KB]) to keep the proxy's own bearer token inside that budget — watch mcp_auth_groups_claim_truncated_total.
  • PROD_MODE=true now also rejects RATE_LIMIT_ENABLED=false. The per-IP buckets are the only bound on the unauthenticated pre-auth surface, and the browser-facing error page makes a typical rejection ~13x the JSON body it replaced (~16x on the throttle path). A production pod running both settings now fails startup; keep the limiter on, or set PROD_MODE=false if a WAF genuinely enforces the bound upstream.

All configuration is via environment variables.

Variable Description Example
OIDC_ISSUER_URL OIDC Issuer URL (auto-discovery via /.well-known/openid-configuration) https://keycloak.example.com/realms/myrealm or https://login.microsoftonline.com/{tenant}/v2.0
OIDC_CLIENT_ID OIDC client ID registered with the IdP xxxxxxxx-...
OIDC_CLIENT_SECRET OIDC client secret ...
PROXY_BASE_URL Public URL of this proxy https://mcp-proxy.example.com
UPSTREAM_MCP_URL Upstream MCP URL. Path is mandatory and is used verbatim as both the proxy's public mount AND the path forwarded upstream. Query, fragment, userinfo, origin-only URLs (no path / lone /), and paths that collide with a reserved control-plane route (/healthz, /register, /authorize, /callback, /token, /.well-known) are rejected at startup. http://mcp-server:8080/mcp
LISTEN_ADDR Bind address :8080
METRICS_ADDR Prometheus metrics bind address. Default 127.0.0.1:9090 — loopback only so /metrics and /readyz are never exposed on the public interface. Override to :9090 or a specific interface when a Prometheus scraper must reach the pod over the network 127.0.0.1:9090 (default)
TOKEN_SIGNING_SECRET Secret for AES-GCM opaque tokens (min 32 bytes, shared across all instances) ...
LOG_LEVEL debug, info, warn info
GROUPS_CLAIM Flat claim name in the OIDC id_token containing user groups groups (default)
ALLOWED_GROUPS Comma-separated group allowlist. Empty = allow all authenticated users admin,mcp-users
REVOKE_BEFORE RFC3339 timestamp — both access tokens AND refresh tokens with iat before this are rejected (bulk revocation). Empty = disabled 2026-03-28T12:00:00Z
PKCE_REQUIRED Require PKCE on /authorize (default true). Set false for Cursor, MCP Inspector, ChatGPT compat true
SHUTDOWN_TIMEOUT Graceful shutdown deadline. Raise above the longest expected SSE stream so rolling deploys do not cut MCP sessions mid-stream. Match terminationGracePeriodSeconds in K8s 120s (default)
REDIS_URL Enables single-use authorization codes and refresh rotation with reuse detection (OAuth 2.1 §6.1) across replicas. rediss:// for TLS. On Redis failure the proxy fails closed (503) redis://redis:6379/0
REDIS_REQUIRED Fail startup (logger.Fatal) when REDIS_URL is unset. Default true — stateless mode leaves authorization codes / refresh tokens replayable within their TTL (findings C3/C4). Set false only for dev or single-replica deployments that accept the trade-off true (default)
REDIS_KEY_PREFIX Prefix applied to every Redis key (default mcp-auth-proxy:). Override when sharing a Redis DB between multiple proxy deployments to avoid key collisions. Set explicitly to empty (REDIS_KEY_PREFIX=) to opt out of namespacing prod-mcp:
RATE_LIMIT_ENABLED Per-IP rate limiting on pre-auth endpoints and on the authenticated MCP route (default true). Keyed on the stripped RemoteAddr by default; set TRUST_PROXY_HEADERS=true to honor X-Forwarded-For/X-Real-IP/True-Client-IP behind a trusted frontend true
TRUST_PROXY_HEADERS Legacy blanket trust of X-Forwarded-For/X-Real-IP/True-Client-IP when keying the rate limiter (default false). Prefer TRUSTED_PROXY_CIDRS; PROD_MODE=true rejects this flag unless CIDRs are configured, otherwise a direct client can trivially mint its own rate-limit key and bypass the limiter false (default)
MCP_PER_SUBJECT_CONCURRENCY Per-subject in-flight request cap on the authenticated MCP route (default 16). A runaway or compromised client identity cannot saturate the proxy / upstream pool at the expense of others. Entries for subjects with no in-flight work are reclaimed by a background pruner after ≥5 min idle so map memory stays proportional to active principals, not the lifetime set of ever-seen subjects. 0 disables the limit. Excess requests return 503 temporarily_unavailable with Retry-After: 1 and increment mcp_auth_access_denied_total{reason="subject_concurrency_exceeded"} 16 (default)
COMPAT_ALLOW_STATELESS When true, /authorize synthesizes a state server-side if the client omits it (legacy MCP Inspector / Cursor). Default false — strict mode refuses with 400 invalid_request because a silent server-synth hides client-side CSRF bugs. mcp_auth_access_denied_total{reason="state_missing"} is incremented either way so operators can see how many clients still rely on the compat path false (default)
MCP_LOG_BODY_MAX Max bytes buffered per authenticated request for JSON-RPC method extraction into access logs (default 65536). 0 disables buffering — no rpc_method/rpc_tool/rpc_id fields are emitted. Only triggered when Content-Type: application/json and Content-Length is set and within the limit; SSE / chunked uploads pass through untouched 65536 (default)
ACCESS_LOG_SKIP_RE Go RE2 regexp matched against r.URL.Path on the public listener only. Matching paths are dropped from the access log; handler response, Prometheus counters, and panic recovery are unaffected. Compiled once at startup; invalid pattern is fatal. RE2 is linear-time — no ReDoS surface. Whitespace-only values are treated as unset. /readyz and /metrics live on METRICS_ADDR and never reach this middleware. Always anchor with ^…$; unanchored substrings can match unrelated upstream paths and .* silences the entire access log ^/healthz$
GROUPS_CLAIM_MAX_BYTES Byte budget for the groups claim sealed into an access token; excess dropped at mint time with a groups_claim_truncated WARN naming the subject. Range [1024, 40960] — the ceiling is measured, since the seal expands the claim ~1.39x and 40 KB of groups already fills 56 KB of the 64 KB header block. With UPSTREAM_FORWARD_IDP_TOKEN=true the IdP access token's length comes off this budget first 32768
PROD_MODE Strict-posture gate. Default true — fails startup if any compatibility flag that weakens a security control is set (PKCE_REQUIRED=false, COMPAT_ALLOW_STATELESS=true, REDIS_REQUIRED=false, RATE_LIMIT_ENABLED=false, REDIS_URL empty, OIDC_ALLOW_INSECURE_HTTP=true, a weak TOKEN_SIGNING_SECRET, or legacy TRUST_PROXY_HEADERS=true without TRUSTED_PROXY_CIDRS). Set PROD_MODE=false explicitly for dev / single-replica work that needs one of the relaxation toggles true (default)
TRUSTED_PROXY_CIDRS Comma-separated CIDRs of peers whose X-Forwarded-For/X-Real-IP/True-Client-IP headers are honored for rate-limit keying. Other peers fall back to RemoteAddr. Preferred over TRUST_PROXY_HEADERS; takes precedence when both are set 10.0.0.0/8,172.16.0.0/12,192.168.0.0/16
MCP_RESOURCE_NAME Optional human-readable display name advertised under resource_name in the RFC 9728 PRM. Used by MCP clients for consent / UI display. Field is omitted when unset ACME MCP
UPSTREAM_AUTHORIZATION_HEADER When non-empty, sent verbatim as the Authorization header on every request to the upstream MCP backend (full value incl. scheme, e.g. Bearer s3cr3t). Treat as a secret — mount from a Secret, not a ConfigMap. Mutually exclusive with UPSTREAM_FORWARD_IDP_TOKEN Bearer xyz
UPSTREAM_FORWARD_IDP_TOKEN true forwards the signed-in user's IdP access token to the upstream as Authorization: Bearer. Always runs with a replay store: REDIS_URL, required under PROD_MODE (startup fails without it). Outside PROD_MODE, with REDIS_REQUIRED=false and no REDIS_URL, the proxy uses an in-memory store and logs replay_store_in_memory: single instance only, state lost on restart. Mutually exclusive with UPSTREAM_AUTHORIZATION_HEADER. See Upstream IdP token forwarding false (default)
OIDC_EXTRA_SCOPES Space-separated scopes appended to openid email profile on the IdP authorize request and, in forwarding mode, on every IdP refresh request. Operator-only: clients still cannot request scopes empty (default)
TOKEN_SIGNING_SECRETS_PREVIOUS Whitespace-separated retired signing secrets accepted on Open during a rolling rotation. New seals always use TOKEN_SIGNING_SECRET (primary); Open tries primary first, then each previous entry. Each entry must be ≥32 bytes <old1> <old2>
LOG_LEVEL Zap log level (debug / info / warn / error) info (default)
GROUPS_CLAIM Flat claim name in id_token that carries user group memberships groups (default)
ALLOWED_GROUPS Comma-separated allowlist; empty = allow all authenticated users admin,mcp-users

Project structure

mcp-auth-proxy/
├── main.go
├── go.mod
├── go.sum
├── config/
│   └── config.go              # env parsing, validation
├── handlers/
│   ├── helpers.go             # OAuthError + error_code vocabulary, the error sink, sealed types, isLoopback
│   ├── pages.go               # shared page styles, CSPs, buffered render path
│   ├── error_page.go          # Accept negotiation, title/advice mapping, pre-rendered 429/405/404 responders
│   ├── consent.go             # consent page + POST /consent + nav interstitial
│   ├── resource_metadata.go   # GET /.well-known/oauth-protected-resource (RFC 9728)
│   ├── discovery.go           # GET /.well-known/oauth-authorization-server (RFC 8414)
│   ├── register.go            # POST /register  (RFC 7591 DCR)
│   ├── authorize.go           # GET /authorize  (+ resource param, RFC 8707)
│   ├── callback.go            # GET /callback  (OIDC IdP return)
│   └── token.go               # POST /token    (+ resource param, RFC 8707)
├── middleware/
│   └── auth.go                # Bearer token validation on MCP routes
├── proxy/
│   └── proxy.go               # reverse proxy to upstream MCP server
├── token/
│   └── token.go               # AES-GCM seal/open, access token issue/validate
├── replay/
│   ├── replay.go              # Store interface + ErrAlreadyClaimed
│   ├── redis.go               # Redis-backed Store (SET NX, SET, EXISTS with prefix)
│   └── memory.go              # In-process Store (tests / single replica)
├── internal/
│   ├── health/                # readiness probe state
│   └── subjectlimiter/        # per-subject in-flight cap on the MCP route
├── middleware/
│   ├── auth.go                # Bearer validation on the proxied MCP route
│   └── rpc_peek.go            # JSON-RPC method/tool extraction for logs + metrics
├── metrics/
│   └── metrics.go             # Prometheus counters for security events
└── Dockerfile

Go dependencies

See go.mod — kept inline previously, now the source of truth lives next to the code.


Endpoints

GET /.well-known/oauth-protected-resource — Protected Resource Metadata (RFC 9728)

Response 200 JSON:

{
  "resource": "{PROXY_BASE_URL}/",
  "authorization_servers": ["{PROXY_BASE_URL}"],
  "bearer_methods_supported": ["header"],
  "scopes_supported": [],
  "resource_name": "{MCP_RESOURCE_NAME if set}"
}

MCP clients use this endpoint to discover which authorization server protects this resource. No authentication required.

Intentional deviation from RFC 9728 §3. The spec reads the resource value at the origin-root PRM as the identifier into which the well-known suffix was inserted — i.e. {PROXY_BASE_URL} without the trailing slash. We instead advertise {PROXY_BASE_URL}/ because Claude.ai canonicalizes RFC 8707 resource indicators with a trailing slash; stripping the slash here would cause resource-param equality checks on every /authorize and /token call from Claude.ai to fail. matchResource (handlers/helpers.go) is trailing-slash insensitive, so clients that send the spec-strict form without the slash still validate correctly. Strict-spec clients that want the canonical form should fetch the per-mount variant below, which advertises exactly {PROXY_BASE_URL}<mount> with no suffix.

scopes_supported is emitted as an empty array: the proxy has no scope model (scopes are not parsed at /authorize, not sealed into access tokens, not checked by the RS middleware). Publishing [] is more informative than omitting — least-privilege-aware clients see a concrete "no scopes" signal rather than having to probe.

resource_name is advertised when MCP_RESOURCE_NAME is set; omitted otherwise. Clients use it for consent / UI display.

GET /.well-known/oauth-protected-resource<mount> — per-resource PRM (RFC 9728 §3.1)

Where <mount> is the path component of UPSTREAM_MCP_URL (e.g. /mcp, /api/v1/mcp). Same shape, resource = {PROXY_BASE_URL}<mount>. This variant is spec-strict: the resource value matches the identifier into which the well-known suffix was inserted, no trailing slash. MCP clients that follow RFC 9728 §3.1 per-resource discovery fetch this path and get the canonical form.


GET /.well-known/oauth-authorization-server — Authorization Server Metadata (RFC 8414)

Also served at /.well-known/oauth-authorization-server<mount> (non-spec compat for MCP clients that probe the per-resource suffix), where <mount> is the path component of UPSTREAM_MCP_URL (e.g. /mcp, /api/v1/mcp). Both paths return the same document.

Response 200 JSON:

{
  "issuer": "{PROXY_BASE_URL}",
  "authorization_endpoint": "{PROXY_BASE_URL}/authorize",
  "token_endpoint": "{PROXY_BASE_URL}/token",
  "registration_endpoint": "{PROXY_BASE_URL}/register",
  "response_types_supported": ["code"],
  "grant_types_supported": ["authorization_code", "refresh_token"],
  "code_challenge_methods_supported": ["S256"],
  "token_endpoint_auth_methods_supported": ["none"],
  "scopes_supported": []
}

PKCE-only proxy: no client secrets are validated. scopes_supported is an explicit empty array — the proxy carries no scope model (see PRM note above). No authentication required on this endpoint.


POST /register — Dynamic Client Registration (RFC 7591)

Request body (JSON):

{
  "redirect_uris": ["https://claude.ai/..."],
  "client_name": "Claude",
  "token_endpoint_auth_method": "none"
}

Behavior:

  • Validate that redirect_uris is present and non-empty
  • Reject client_name longer than 512 bytes so unauthenticated registrations cannot amplify into oversized logs or sealed client_id responses
  • OAuth 2.1 §2.3.1: each redirect_uri must use HTTPS, or HTTP when pointing at a loopback host. Loopback is recognized via net.ParseIP().IsLoopback() (covers the full 127/8 range, ::1, ::ffff:127.0.0.1, ::0.0.0.1) plus the literal localhost / localhost.. Non-http(s) schemes (e.g. ftp://, ldap://, file://, custom app schemes) are rejected unconditionally even when the host is loopback
  • Generate an internal UUID for the client
  • Encrypt the whole { id, redirect_uris, client_name, expires_at } with AES-GCM → this is the returned client_id
  • TTL embedded in the encrypted blob: 7d default, configurable via CLIENT_REGISTRATION_TTL (Go duration, capped at 90d; 0 = never expires, sealing a zero expires_at). The 7d default matches refreshTokenTTL so a client holding a still-valid refresh token can always exchange it — a shorter TTL silently kills long-running MCP clients (which treat DCR as one-shot at startup) the moment their access token first expires. 0 is the opt-in escape hatch for clients that never re-run DCR on invalid_client (e.g. Azure APIM connectors); it trades away the reuse-window bound on an exfiltrated client_id.
  • Request body limited to 1 MB (MaxBytesReader)

Response 201 JSON: Headers: Cache-Control: no-store, Pragma: no-cache.

{
  "client_id": "<encrypted blob>",
  "client_id_issued_at": 1234567890,
  "client_id_expires_at": 1234654290,
  "redirect_uris": ["..."],
  "client_name": "<echoed if submitted>",
  "token_endpoint_auth_method": "none"
}

client_id_expires_at (an RFC 7591 extension field — the spec defines client_secret_expires_at, not client_id_expires_at) is the UNIX timestamp at which the sealed client_id stops opening (default client_id_issued_at + 7d, configurable via CLIENT_REGISTRATION_TTL). Clients that cache the handle should re-register before this time to avoid a 400 on /authorize and on /token refresh-token rotations. When CLIENT_REGISTRATION_TTL=0, client_id_expires_at is emitted as 0 ("never expires", mirroring RFC 7591 §3.2.1 client_secret_expires_at) and the expiry check is skipped on every validation path.

Error responses use RFC 7591 §3.2.2 codes: invalid_redirect_uri for any redirect_uri-shape defect (missing, over-count, over-length, malformed, opaque, hostless, fragment-bearing, userinfo-bearing, or non-https-non-loopback); invalid_client_metadata for unsupported token_endpoint_auth_method, over-length client_name, or client_name containing control bytes (NUL/CR/LF/TAB) or the X-User-Groups delimiter , (the field is sealed into the returned client_id and emitted to logs; raw control bytes would smuggle past zap's JSON-escaping when downstream code unsealed and parsed it); invalid_request for structural problems: 400 with "invalid JSON body" for a malformed body, 413 with "request body exceeds the 1 MB cap" when the body crosses MaxBytesReader.


GET /authorize

Query params:

  • response_type=code (required, reject otherwise)
  • client_id (required, decrypt and validate not expired)
  • redirect_uri (required, must match a registered URI — exact match)
  • code_challenge (required if PKCE_REQUIRED=true, optional otherwise; 43-128 unreserved characters per RFC 7636)
  • code_challenge_method=S256 (required if code_challenge present)
  • state (required by default; strict mode rejects /authorize with 400 invalid_request when absent. Set COMPAT_ALLOW_STATELESS=true to keep the legacy server-synth behavior for Cursor / MCP Inspector — mcp_auth_access_denied_total{reason="state_missing"} is incremented either way for visibility)
  • resource (optional, RFC 8707 — accepted when it matches either {PROXY_BASE_URL} / {PROXY_BASE_URL}/ or the configured mount resource {PROXY_BASE_URL}<mount>)

Behavior:

  1. Validate all params; reject repeated singleton params (resource may appear more than once per RFC 8707); every resource value must match an accepted resource URI
  2. Decrypt the client_id → verify not expired, redirect_uri matched
  3. Consent fork. The validated parameters are sealed into a sealedConsent blob (PurposeConsent AAD, audience-bound, 5-min TTL) and an HTML page is rendered showing the registered client_name, the parsed redirect host, and the resource. The user clicks Approve or Deny. The form POSTs to /consent (see below). Default behavior; setting RENDER_CONSENT_PAGE=false skips this step and runs the silent redirect at step 4 directly — only safe when every caller is non-interactive and known-trusted.
  4. Encrypt the session with AES-GCM (10min TTL):
    {
      client_id (internal UUID),
      redirect_uri, code_challenge,
      original_state,
      expires_at
    }
    
  5. Use the encrypted blob as the state parameter sent to the IdP
  6. Build the authorization URL from endpoints discovered via OIDC auto-discovery:
    {discovered_authorization_endpoint}
      ?client_id={OIDC_CLIENT_ID}
      &response_type=code
      &redirect_uri={PROXY_BASE_URL}/callback
      &scope=openid email profile {OIDC_EXTRA_SCOPES when set}
      &state={encrypted_session}
      &response_mode=query
    
  7. Redirect 302 to the IdP

POST /consent

Always registered. Driven by the consent form rendered at step 3 of /authorize (default behaviour; bypassed only when the operator sets RENDER_CONSENT_PAGE=false).

Form fields: consent_token (sealed blob from the GET-side render), action (approve or deny).

Guards (same shape as /token, except the Authorization rejection):

  • r.URL.RawQuery != "" → 400. The sealed token would otherwise leak into access logs / browser history / Referer.
  • Authorization header present → 400 invalid_request + error_code=consent_auth_header_present, no challenge. The endpoint advertises no client-auth scheme; a 401 would require a WWW-Authenticate challenge (RFC 7235 §3.1) that pops a browser credential dialog on an endpoint rejecting credentials by design.
  • Body capped at 1 MB; repeated consent_token / action fields rejected.

Behavior:

  1. Open consent_token (PurposeConsent AAD); reject on bad shape, wrong purpose, audience mismatch, or expired (5 min TTL).
  2. action=deny: increment mcp_auth_consent_decisions_total{decision="denied"}, log consent_denied, answer with the navigation interstitial (200 HTML, meta http-equiv="refresh", no JS) targeting the registered redirect_uri with error=access_denied per RFC 6749 §4.1.2.1.
  3. action=approve: mint upstream OIDC nonce (random 32 hex) + upstream PKCE verifier, regenerate the H6 server-side PKCE pair if the consent blob recorded one, seal a sealedSession (same shape as step 4 of /authorize), answer with the navigation interstitial targeting the IdP authorize URL. Increment mcp_auth_consent_decisions_total{decision="approved"}.
  4. Anything else: 400 invalid_request.

Why an interstitial, not a 302: Chromium enforces the consent page's CSP form-action directive against every hop of the redirect chain a form submit initiates — including redirects the OAuth client performs after receiving the code, which are unknowable in advance. Terminating the form navigation at a same-origin 200 ends that enforcement; the meta refresh starts a regular navigation that form-action does not govern. This keeps the consent page CSP at form-action 'self' with no origin enumeration. The interstitial's own CSP is fully locked (form-action 'none', no scripts) and Cache-Control: no-store. Server-error paths (seal failure, nonce failure) deliver the RFC 6749 error envelope through the same interstitial; only the replay-store-unavailable 503 and pre-validation 4xx stay at the proxy (same-origin, no redirect to block) — and since /consent is BrowserFacing, those render as the negotiated error page for a browser and as JSON for everyone else.

Single-use replay defense on consent_token: when a replay store is wired (REDIS_URL), the consent token's jti is claimed atomically before either branch runs — the decision (Approve or Deny) executes at most once per token. Each GET /authorize render mints a fresh jti, so the back-button case still works. On replay (double-submit, back-button re-POST, captured token): the proxy re-renders the consent page (200) with a notice, a fresh jti, and the original ExpiresAt — replay→re-render cycles cannot extend a captured blob's life past the original 5-minute window, and a new explicit click is always required, so nothing auto-approves. mcp_auth_replay_detected_total{kind="consent"} increments once per replayed POST — note this now also counts benign double-submits, not only adversarial replays; do not page on low counts of this kind (see Metrics). The re-render loop is bounded by the per-IP /consent rate bucket. With no replay store wired (configured opt-out) the handler falls back to the prior stateless behavior (token unique, audience-bound, TTL-bounded, but replayable within the 5-min window).


GET /callback

Query params: code, state (from the IdP)

Behavior:

  1. If the IdP returns an error (RFC 6749 §4.1.2.1), propagate it to the client
  2. Decrypt the state → retrieve the session, verify not expired
  3. Single-use claim on the session's sid when a replay store is wired — a replayed /callback is rejected with 400 invalid_request + error_code=callback_state_replay BEFORE the upstream IdP exchange runs (no fan-out, no audit-log noise). mcp_auth_replay_detected_total{kind="callback_state"} increments. Empty sid (legacy session in flight during rollout) falls through to stateless behavior. Store-error path fail-closes to 503 + replay_store_unavailable + Retry-After: 5.
  4. Exchange the code with the IdP (POST token endpoint, 10s timeout) to obtain id_token + access_token
  5. Validate the id_token via go-oidc (JWKS signature auto-discovery, issuer, audience)
  6. Extract claims: sub, email, email_verified, name
  7. If email_verified is present and false, reject with 403 access_denied + error_code: email_not_verified (absent claim is accepted — not all IdPs emit it)
  8. Extract groups from the configured claim (GROUPS_CLAIM, default groups)
  9. If ALLOWED_GROUPS is configured, verify the user belongs to at least one allowed group → 403 access_denied + error_code: group_not_allowed otherwise (the metric label stays group)
  10. Encrypt an internal authorization code with AES-GCM (60s TTL):
{
  token_id (UUID, used for single-use replay check),
  client_id (internal UUID),
  redirect_uri, code_challenge,
  subject, email, name, groups,
  expires_at
}
  1. Redirect 302 to redirect_uri?code={encrypted_code}&state={original_state}&iss={PROXY_BASE_URL}
    • Built via url.Parse + merged query params (safe even if redirect_uri already contains query params)
    • iss is emitted per RFC 9700 §2.1.4 (mix-up defense): a client that talks to multiple ASes can verify the response came from the AS it actually sent the request to. Value matches the issuer field in the RFC 8414 metadata document.

POST /token

Request body (application/x-www-form-urlencoded, max 1 MB):

For grant_type=authorization_code:

grant_type=authorization_code
&code=<encrypted internal code>
&redirect_uri=<must match>
&client_id=<must match>
&code_verifier=<PKCE verifier, 43-128 chars per RFC 7636 §4.1>

For grant_type=refresh_token:

grant_type=refresh_token
&refresh_token=<encrypted token>
&client_id=<must match>

Behavior — authorization_code:

  1. Reject repeated singleton params (resource may appear more than once per RFC 8707); every resource value must match an accepted resource URI; validate code_verifier shape (43-128 unreserved characters, RFC 7636 §4.1)
  2. Decrypt the code, verify not expired
  3. Decrypt the client_id, verify not expired
  4. Verify client_id (internal UUID) and redirect_uri match the code
  5. Validate PKCE: base64url-encoded SHA256(code_verifier) == stored code_challenge (constant-time comparison)
  6. If REDIS_URL is configured, atomically claim the code's token_id via SET NX. A second attempt is rejected with invalid_grant + error_code: code_replay; Redis failures fail closed with 503
  7. Forwarding mode only: redeem the IdP refresh token at the IdP (see Upstream IdP token forwarding)
  8. Issue an opaque access token (AES-GCM, 1h TTL, at most 1h in forwarding mode) and a refresh token (AES-GCM, 7d TTL)

Behavior — refresh_token:

  1. Decrypt the refresh token, verify its audience matches PROXY_BASE_URL

  2. If REVOKE_BEFORE is configured, reject if refresh iat < cutoff (bulk revocation applies to refresh tokens too, not only access tokens)

  3. Verify the refresh is not expired

  4. Decrypt the client_id, verify audience + not expired + UUID matches the refresh

  5. If REDIS_URL is configured: a. Reject if refresh_family_revoked:<fam> is set (prior reuse killed the family) b. Atomically claim refresh:<tid> via SET NX, recording the claim's set time. If the key is already claimed AND the prior claim landed within REFRESH_RACE_GRACE_SEC (default 2s, max 10s, set 0 to disable): treat as a benign concurrent submit — return 429 invalid_grant with error_code: refresh_concurrent_submit + Retry-After: 2. The family is NOT revoked; the legitimate peer's rotation already succeeded, the racing client retries with the new refresh once it lands. If the prior claim is past the grace window: reuse detected — mark the family revoked for 7 days, reject with error_code: refresh_reuse_detected. Any subsequent sibling refresh also gets rejected in step 5a

    Note on the 429 status: RFC 6749 §5.2 defines /token errors as 400 / 401. Returning 429 here is a deliberate deviation — most OAuth client libraries treat 429 + Retry-After as "back off and retry", which is exactly the desired behavior for a racing peer (the legit rotation already succeeded; the racing client should retry with the new refresh from shared storage). The error_code=refresh_concurrent_submit field disambiguates from generic rate limiting for clients that look at the body. A 400 invalid_grant was considered but rejected: it would force every client library to add a custom retry path keyed off error_code instead of using its existing 429 handling.

  6. Forwarding mode only: redeem the IdP refresh token at the IdP (see Upstream IdP token forwarding)

  7. Issue new access + refresh tokens. The new refresh inherits the original fam (so reuse detection spans the lineage) and gets a fresh tid. iat is set to now so it survives the next REVOKE_BEFORE application

Response 200 JSON:

{
  "access_token": "<opaque token>",
  "token_type": "Bearer",
  "expires_in": 3600,
  "refresh_token": "<opaque refresh token>"
}

expires_in is 3600. In forwarding mode it is at most 3600: the access token expires at min(1h, IdP expiry − 60 s).

Headers Cache-Control: no-store and Pragma: no-cache required (RFC 6749 §5.1).

Standard OAuth2 errors:

{ "error": "invalid_grant", "error_description": "...", "error_code": "..." }

error_code is an optional extension field for machine-readable internal error identifiers. Clients must treat it as advisory and rely on the standard error field for OAuth behavior.


Internal token

Opaque format: JSON → AES-GCM encryption with TOKEN_SIGNING_SECRET → base64url.

Access token payload:

type Claims struct {
    TokenID   string    // UUID
    Audience  string    // PROXY_BASE_URL — bound at issuance, verified on every Validate
    Subject   string    // IdP sub
    Email     string
    Groups    []string  // from GROUPS_CLAIM in id_token
    ClientID  string
    IssuedAt  time.Time
    ExpiresAt time.Time
    IdPAccessToken string // forwarding mode only (omitempty); see below
}

Refresh token payload (sealed identically; TokenID and FamilyID drive the optional Redis-backed reuse detection):

type sealedRefresh struct {
    TokenID   string    // UUID, unique per refresh (single-use key when Redis is wired)
    FamilyID  string    // UUID, constant across rotations — shared by every sibling in the lineage
    Subject   string
    Email     string
    Groups    []string
    ClientID  string
    Audience  string
    IssuedAt  time.Time // used by REVOKE_BEFORE bulk revocation
    ExpiresAt time.Time
    IdPRefreshToken string // forwarding mode only (omitempty); see below
}

No JWT exposed — opaque token only on the MCP client side. Validated by AES-GCM decryption + audience check + expiry. No store required.


Auth middleware (MCP routes)

All MCP routes (/* except OAuth endpoints):

  1. Extract Authorization: Bearer <token>
  2. Decode and validate the opaque token (AES-GCM decryption, expiry check)
  3. Verify claims.Audience == PROXY_BASE_URL — rejects tokens minted by a sibling instance sharing the same secret but with a different baseURL
  4. If REVOKE_BEFORE is configured, reject if iat < cutoff (bulk revocation)
  5. Inject into context: sub, email, groups — and, in forwarding mode, the IdP access token; a token without one is refused with invalid_token (minted before the mode was switched on)
  6. On failure, return 401 per RFC 6750 §3.1:
    • Missing or malformed Authorization header → { "error": "invalid_request", "error_description": "bearer credential is missing or malformed" }
    • Token decrypt/expiry/audience/iat failures → { "error": "invalid_token", "error_description": "bearer token is invalid, expired, or not intended for this resource" } Both responses include WWW-Authenticate: Bearer error="<code>", error_description="<text>", resource_metadata="{PROXY_BASE_URL}/.well-known/oauth-protected-resource" (RFC 9728 §5.1 + RFC 6750 §3). The error_description is a closed allowlist of fixed strings — no caller-controlled data reaches the header.

MCP proxy

After auth middleware passes:

// Forward to upstream MCP server
// Client request path forwarded verbatim; proxy mount == UPSTREAM_MCP_URL path
// Added headers:
r.Header.Set("X-User-Sub", claims.Subject)
r.Header.Set("X-User-Email", claims.Email)
r.Header.Set("X-User-Groups", "group1,group2")  // comma-separated, omitted if empty
r.Header.Del("Authorization")  // do not leak the internal token
// Forwarding mode only: the user's IdP access token, re-applied on
// every same-origin redirect hop. Otherwise UPSTREAM_AUTHORIZATION_HEADER
// when set, otherwise nothing.
r.Header.Set("Authorization", "Bearer "+idpAccessToken)

// Support SSE (text/event-stream): no response buffering
// Support Streamable HTTP (chunked): immediate flush

Use httputil.ReverseProxy with FlushInterval: -1 (immediate flush) to support SSE and streaming. The underlying *http.Transport sets ResponseHeaderTimeout: 30s so a wedged upstream fails fast during header negotiation — stream bodies themselves remain uncapped. The transport follows 307/308 redirects server-side (Python FastAPI/Starlette backends), same-host only, body replayed, max 10 hops. On exhaustion the proxy responds 502 Bad Gateway with {"error":"bad_gateway","error_description":"too many upstream redirects"} rather than echoing the last 307/308 (which would leak a broken upstream Location: to the MCP client). Proxied request bodies are capped at 16 MiB via http.MaxBytesReader to bound the memory the redirect-follow buffer can hold.

Upstream IdP token forwarding (opt-in)

Off by default. For upstream MCP servers that must call other APIs as the user — typically through an on-behalf-of exchange at the IdP — knowing who the user is from X-User-* is not enough; they need a token the IdP issued for them. With UPSTREAM_FORWARD_IDP_TOKEN=true:

  1. /authorize asks the IdP for openid email profile plus OIDC_EXTRA_SCOPES (e.g. api://<upstream-app>/access_as_user offline_access on Entra ID). Clients still cannot request scopes: scopes_supported stays [] and the client's scope is ignored.
  2. /callback verifies the id_token as usual and also keeps the IdP refresh token, sealed into the authorization code. The IdP access token is not kept: the code travels in a redirect URL and an access token with a groups claim can run to several KB. An IdP that returns no refresh token fails the sign-in with idp_refresh_token_missing.
  3. /token (code grant), after PKCE and the single-use claim, redeems the IdP refresh token once at the IdP token endpoint with an explicit scope. The proxy access token carries the IdP access token and expires at min(1h, IdP expiry − 60 s) (expires_in reports the real remaining lifetime; an IdP token that would leave the proxy token under a minute — one living under about two minutes — is refused); the proxy refresh token carries the latest IdP refresh token. When the IdP sends no expires_in, a JWT access token's own exp bounds the lifetime.
  4. Each MCP request: the auth middleware hands the IdP access token to the reverse proxy, which — after stripping the client's own token as always — sends it as Authorization: Bearer. X-User-* are injected as before.
  5. /token (refresh grant), after the existing cutoff, family and reuse checks (so a revoked or replayed refresh token never reaches the IdP), refreshes at the IdP and seals the new IdP tokens into the new proxy tokens. A verified id_token returned on refresh re-applies the sign-in policy: same sub (else id_token_verification_failed), email_verified, group-name validation and ALLOWED_GROUPS, and updates email and groups. When the IdP returns no id_token, the previous email and groups are kept, never widened, and idp_refresh_id_token_missing is logged. An id_token that is present but fails verification, or whose claims cannot be parsed, is refused: 400 invalid_grant / id_token_verification_failed, log idp_refresh_id_token_unverified. The refresh token stays spent and the user signs in again. Verification runs under its own 10 s timeout.

IdP answers at /token that are not a success map to three outcomes. Rejected (IdP error codes invalid_grant, interaction_required, login_required, consent_required, invalid_scope, on a 4xx other than 429): 400 invalid_grant / idp_refresh_rejected, the claim stays spent and the client signs in again. Unavailable: 503 temporarily_unavailable / idp_refresh_unavailable + Retry-After. This covers a transport error, a timeout, any 5xx or 429 whatever its body, the IdP error codes temporarily_unavailable or server_error on a 4xx, the proxy's own client credentials refused (invalid_client, unauthorized_client, 401), a 4xx without an error member, any 3xx (redirects are never followed), or a 2xx whose body is not a JSON object (a maintenance page, a WAF block). A local throttle (IDP_EXCHANGE_*) answers 503 idp_exchange_throttled the same way. In both cases the proxy releases the single-use claim, so the client retries with the same code or refresh token. A failed release answers invalid_grant instead, since the retry would read as a replay. Failed: 400 invalid_grant / idp_refresh_failed, the claim stays spent. This covers a JSON error with an unknown code, a 2xx JSON object that is malformed or has no usable Bearer access token, a 2xx whose body broke off mid-read, and an expires_in that is stated and zero or negative. Retry-After is the proxy's jittered 2-4 s. On a 5xx, a 429 or a temporarily_unavailable / server_error answer, an IdP Retry-After in seconds above 4 replaces it, capped at 120 s (the HTTP-date form is ignored). Otherwise it is 60 s on a 3xx, and when a 4xx other than 429 refused the proxy's client credentials or carried no error member. Logs for these failures carry the IdP's error code, the HTTP status and the proxy's own reason (transport error, unsupported token_type). They never carry a token or the IdP's error_description. Any failure to mint tokens after the IdP call also answers invalid_grant, never a 5xx: the client's token is spent, and a retry would only trip reuse detection. The IdP refresh is a hand-written RFC 6749 §6 call — x/oauth2 cannot add a scope to a refresh, and without one some IdPs (Entra ID) choose the new token's audience themselves. Client authentication follows the endpoint's AuthStyle; auto-detection tries Basic, falls back to form parameters on a non-grant 4xx other than 429, and remembers the style once the IdP accepts the credentials. Redirects are never followed. At most 64 refresh calls run at once. A call over that bound is answered idp_refresh_unavailable without reaching the IdP.

What does not change: the stateless design (the IdP tokens are optional fields inside the existing sealed payloads; Redis still holds replay markers only), AEAD purpose and audience binding, RFC 8707 resource binding, PKCE, DCR, the consent page, refresh rotation with reuse detection and REVOKE_BEFORE. The proxy always strips the token the client presents and never forwards it. The upstream receives a different token, one the IdP issued to the proxy's own client for the scopes in OIDC_EXTRA_SCOPES. Whether that avoids the "token passthrough" anti-pattern of the MCP security best practices depends on the operator. The token carries the upstream's audience only when OIDC_EXTRA_SCOPES names the upstream's scope, or an audience mapper at the IdP adds it. The proxy does not check aud. With forwarding on and OIDC_EXTRA_SCOPES empty, the token has the IdP's default audience (a Microsoft Graph token on Entra ID), and the upstream could replay it there. Set the upstream scope. The upstream must validate the token itself (issuer, audience, signature, expiry).

Trade-offs, also in docs/threat-model.md row 17: whoever holds TOKEN_SIGNING_SECRET can now open the IdP refresh tokens inside client-held tokens; refresh needs the IdP to be reachable (docs/runbooks/idp-outage.md); codes, access and refresh tokens grow by the IdP token sizes, so their open() cap is raised to the 64 KB header block in this mode (only for those three purposes), the IdP access token's length comes off the groups budget, and a code, access or refresh token that would not pass its own cap is never minted. In return, revocation gets stronger: every refresh asks the IdP, so a password reset or a disabled account bites within one access-token lifetime. A group removal does too when the IdP returns an id_token on refresh. When it returns none, email and groups stay as last verified (log idp_refresh_id_token_missing). A verified refresh id_token without the groups claim reads as "no groups", exactly as at sign-in (fail closed), and is logged as idp_refresh_groups_claim_missing.

IdP prerequisites. Entra ID: expose a scope on the upstream's app registration, grant it to the proxy's app registration, and set OIDC_EXTRA_SCOPES="api://<upstream-app>/<scope> offline_access". Keycloak: add an audience mapper for the upstream on the proxy's client. The user needs the offline_access realm role when that scope is requested. The default 5-minute access tokens give 4-minute proxy tokens, so clients refresh every 4 minutes.

Known limitation. After an ambiguous IdP outcome (the 10 s timeout, or the client hanging up once the request left) the proxy releases the single-use claim and the client retries with the same token. An IdP that rotates refresh tokens and revokes on reuse (Keycloak with "Revoke Refresh Token", Okta, Auth0) then rejects the retry, and the user signs in again. Entra ID does not revoke on reuse and is not affected.

The IdP call (up to 10 s) also runs while the client's single-use claim is held. A client that resends the same authorization code while the first request is still waiting on the IdP, or the same refresh token more than REFRESH_RACE_GRACE_SEC after the first request, is read as a replay: the family is revoked and authorization_code_replay / refresh_token_reuse_detected fires. It fails closed (no extra tokens; the user signs in once more) but raises a false alert. Marking claims "in flight" in the replay store would remove it; left out of this change to keep the replay semantics untouched.

Upstream path handling

UPSTREAM_MCP_URL must include a path component; that path is the proxy's public mount and the path forwarded upstream, verbatim both sides. No join, no strip, no rewrite. If the client hits {PROXY_BASE_URL}<path>, the upstream sees <path>.

Real-world MCP endpoints use many paths — set UPSTREAM_MCP_URL accordingly:

Product Path Example value
FastMCP (Python) default /mcp http://fastmcp:8000/mcp
GitHub Copilot (api.githubcopilot.com) /mcp https://api.githubcopilot.com/mcp
Cloudflare (mcp.cloudflare.com) /mcp https://mcp.cloudflare.com/mcp
Atlassian Rovo /v1/mcp https://rovo.atlassian.com/v1/mcp
GitLab /api/v4/mcp https://gitlab.com/api/v4/mcp

Origin-only URLs (no path / lone /), query, fragment, userinfo, and paths that collide with a reserved control-plane route (/healthz, /register, /authorize, /callback, /token, /.well-known) are rejected at startup.


Routing

The router is built in main.go (func main) — see that file rather than a copy here, since this block historically rotted. High level:

  • Global middlewares: in-flight WaitGroup → strip inbound X-Request-Id → chimw.RequestID → zapMiddleware → chimw.Recoverer → per-IP rate limiter.
  • OAuth endpoints (/register, /authorize, /consent, /callback, /token) and the discovery surface (/.well-known/oauth-authorization-server, /.well-known/oauth-protected-resource, mount-suffixed variants, and the openid-configuration / under-mount 404 carve-outs) carry per-endpoint rate limiters when RATE_LIMIT_ENABLED=true (passthrough otherwise). Discovery is silent on rate-limit per RFC 8414 §3 / RFC 9728 §3.1; the 60/min/IP ceiling here only catches floods. The 429 carries Retry-After: <window seconds>, which states when to retry without disclosing the per-window quota; the X-RateLimit-Limit / -Remaining / -Reset headers httprate sets internally are stripped from every response — production MCP servers (Cloudflare, GitHub Copilot, Atlassian, Notion, Sentry) all keep them silent, and the IETF rate-limit-headers draft warns that disclosing quota state on auth/error paths leaks operational capacity to attackers. replayStore is wired when REDIS_URL is set, and as an in-memory store for UPSTREAM_FORWARD_IDP_TOKEN=true without it.
  • 405 responder (installMethodNotAllowed, wired after every route so it can read the finished routing table): replaces chi's empty-bodied default with the shared error sink, keeps the RFC 9110 §15.5.6 Allow header, and applies the BrowserFacing marker per path — a wrong method on /consent renders the page, one on /token or /register stays application/json like every other error there.
  • Liveness /healthz (always 200) on the public listener; readiness /readyz lives ONLY on the metrics listener (an unauthenticated /readyz on the public port is a Redis-DoS amplifier — see comment at main.go:304).
  • MCP proxy mounts at cfg.UpstreamMCPMountPath (path from UPSTREAM_MCP_URL) under authMW.Validate → RPCPeek → per-subject concurrency limiter. Client path == upstream path, verbatim, no rewrite.

OAuth2 error handling

Always return errors conforming to RFC 6749. This is the wire form for every programmatic caller; a browser that asks for text/html gets the same error as a page instead (see Browser-facing rendering below):

type OAuthError struct {
    Error            string `json:"error"`
    ErrorDescription string `json:"error_description,omitempty"`
    ErrorCode        string `json:"error_code,omitempty"`
}

Standard error values (RFC 6749 §5.2): invalid_request, invalid_client, invalid_grant, unauthorized_client, unsupported_grant_type, invalid_scope, server_error, access_denied, temporarily_unavailable.

Extension error_code values (proxy-specific, advisory — clients MUST rely on error for OAuth behavior):

error_code When Paired error
code_replay Authorization code reused (requires Redis) invalid_grant
refresh_reuse_detected Refresh token replayed after rotation → family revoked (requires Redis) invalid_grant
refresh_family_revoked Refresh token whose family was previously revoked invalid_grant
email_not_verified id_token email_verified is false (metric label: email_unverified); also re-checked on a forwarding-mode refresh access_denied (invalid_grant at /token)
subject_missing IdP returned a verified id_token without a sub claim (L5) access_denied
group_invalid IdP group name contains , \r \n \x00; also re-checked on a forwarding-mode refresh access_denied (invalid_grant at /token)
group_not_allowed User belongs to none of ALLOWED_GROUPS (metric label: group); also re-checked on a forwarding-mode refresh when the IdP returns an id_token, so a removal then bites within one access-token lifetime access_denied (invalid_grant at /token)
session_expired / session_audience_mismatch / callback_params_missing /callback flow state is unusable; the page tells the user to start again invalid_request
session_unknown /callback state cannot be opened. Paired error is invalid_request, or the allowlisted IdP error when the IdP itself returned one invalid_request (or the IdP's)
consent_token_missing / consent_token_invalid / consent_token_expired / consent_token_audience_mismatch /consent flow state is unusable; same "start again" advice invalid_request
idp_exchange_failed / id_token_missing / id_token_claims_unparsable Upstream IdP failed the exchange or returned an unusable id_token server_error
callback_state_replay /callback replayed (requires Redis); metric: mcp_auth_replay_detected_total{kind="callback_state"} — not an access_denied_total reason invalid_request
idp_exchange_throttled Outbound IdP exchange throttled (IDP_EXCHANGE_RATE_PER_SEC) — at /callback, and at /token in forwarding mode, where the code or refresh token stays usable for the retry (invalid_grant instead if it could not be given back) temporarily_unavailable (or invalid_grant at /token)
refresh_concurrent_submit Racing refresh inside REFRESH_RACE_GRACE_SEC invalid_grant
redirect_uri_missing / redirect_uri_mismatch redirect_uri absent or not registered (client input, /authorize) invalid_request
redirect_uri_malformed An already-validated redirect_uri failed to re-parse at /authorize, /consent or /callback, leaving nowhere to deliver the §4.1.2.1 envelope — invariant violation, see the browser-facing rendering note the original envelope's error (server_error, access_denied, unsupported_response_type, invalid_target, invalid_request, or the allowlisted IdP one)
consent_query_params_forbidden / consent_auth_header_present / consent_body_too_large / consent_form_malformed / consent_action_invalid POST /consent request-shape rejections invalid_request
parameter_repeated A singleton query/form parameter was sent more than once (/authorize, /consent, /callback, /token) invalid_request
method_not_allowed Wrong HTTP method for the route (e.g. GET /consent from a bookmark) invalid_request
not_found No route at that path — a mistyped or trailing-slash bookmark, a stray probe, or a discovery carve-out. One body for all of them; only the browser-facing paths additionally render it as a page invalid_request
refresh_revoked_iat_cutoff Refresh token predates REVOKE_BEFORE (bulk cutoff). Emits no metric — mcp_auth_access_denied_total{reason="token_revoked_iat_cutoff"} counts the separate access-token rejection in middleware/auth.go, which carries no wire code. Distinct from refresh_family_revoked, which is reuse detection invalid_grant
code_seal_failed / interstitial_render_failed Internal failure minting a code or rendering the interstitial server_error
client_id_missing client_id absent (/authorize only — /token folds it into an unmetered generic invalid_request) invalid_request
client_id_unknown client_id did not decrypt, or decrypted with the wrong purpose tag. One code for both on purpose: distinct codes would tell an unauthenticated caller which of the two happened, i.e. whether a blob they hold is a genuine token of this proxy. The metric labels stay distinct (client_id_invalid, client_typ_mismatch) invalid_client / invalid_grant
client_audience_mismatch / client_registration_expired Client registered for another PROXY_BASE_URL, or past its TTL; each matches the metric label of the same name invalid_client
replay_store_unavailable Redis unreachable; handler fails closed server_error
id_token_verification_failed go-oidc rejected the IdP id_token; at /token in forwarding mode, the id_token returned on refresh fails verification, has unparsable claims, or names a different sub (invalid_grant) server_error (or invalid_grant)
token_issue_failed AES-GCM seal error when minting an access token. In forwarding mode also an IdP access token that would leave the proxy token under a minute (i.e. lives under about two minutes), or IdP tokens that would push an access or refresh token over its open() cap; there the code or refresh token is already spent, so the answer is invalid_grant — a 5xx would invite a retry that reads as a replay server_error (invalid_grant in forwarding mode)
idp_refresh_token_missing /callback in forwarding mode (UPSTREAM_FORWARD_IDP_TOKEN): the IdP code exchange returned no refresh token — offline access not granted to the proxy's client server_error
idp_token_missing /token in forwarding mode: the code or refresh token was minted before the mode was switched on and carries no IdP token; the client signs in again. Metric: mcp_auth_access_denied_total{reason="idp_token_missing"} invalid_grant
idp_refresh_rejected /token in forwarding mode: the IdP refused the refresh (invalid_grant, interaction_required, login_required, consent_required, invalid_scope); the client signs in again. Metric: mcp_auth_idp_refresh_total{result="rejected"} invalid_grant
idp_refresh_unavailable /token in forwarding mode: A transport error, a timeout, any 5xx or 429 whatever its body, the IdP error codes temporarily_unavailable or server_error on a 4xx, the proxy's own client credentials refused (invalid_client, unauthorized_client, 401), a 4xx without an error member, any 3xx (redirects are never followed), or a 2xx whose body is not a JSON object (a maintenance page, a WAF block), or more than 64 refresh calls in flight. The code or refresh token is NOT consumed — retry after Retry-After. Retry-After is the proxy's jittered 2-4 s. On a 5xx, a 429 or a temporarily_unavailable / server_error answer, an IdP Retry-After in seconds above 4 replaces it, capped at 120 s (the HTTP-date form is ignored). Otherwise it is 60 s on a 3xx, and when a 4xx other than 429 refused the proxy's client credentials or carried no error member. If giving it back fails (replay store down at the same time), the answer is invalid_grant without Retry-After: a retry would read as a replay. Metric: mcp_auth_idp_refresh_total{result="unavailable"} temporarily_unavailable (or invalid_grant)
idp_refresh_failed /token in forwarding mode: a failure waiting will not clear — a JSON error with an unknown code (invalid_request, unsupported_grant_type, …), a JSON 2xx without a usable Bearer access token, a 2xx whose body broke off mid-read, or an expires_in that is stated and zero or negative. The code or refresh token stays spent; the client signs in again. Metric: mcp_auth_idp_refresh_total{result="failed"} invalid_grant

Browser-facing rendering. /authorize, /consent and /callback terminate in the user's browser, not in the MCP client — a raw JSON body there is a dead end for the human reading it. Those three routes are wrapped in handlers.BrowserFacing, and the shared error sink renders an HTML page when both hold: the route carries that marker, and the request's Accept lists text/html explicitly. Everything else gets the RFC 6749 JSON body unchanged.

Gating on the route, not on Accept alone, is what keeps /token and /register on application/json as RFC 6749 §5.2 and RFC 7591 §3.2.2 require, whatever a caller asks for; discovery is likewise machine-only. */* never triggers the page, and a text/html sitting inside a quoted parameter does not either.

The page carries a title derived from the status, the error value and the error_code: a transient code reads as "Temporarily unavailable" even on a 5xx, and otherwise a server_error never reads as an authorization decision whatever status it rides on. It carries the error_description as its reason sentence, one line of advice, and error + error_code in small print for support. The advice is chosen from the failure: wait and retry on 429/503 and on the transient error_codes (idp_exchange_throttled, replay_store_unavailable — 502 alone is not enough, since two permanent IdP misconfigurations ride it); wait, then go back to the application and try again on idp_exchange_failed, whose callback state was claimed before the exchange, so reloading the same URL can only yield callback_state_replay; go back to the application and start again on the error_codes whose flow state is dead (session_*, consent_token_*, callback_*) and on the /consent request-shape rejections a fresh flow re-forms (consent_query_params_forbidden, consent_form_malformed, consent_action_invalid, parameter_repeated, consent_auth_header_present, consent_body_too_large, method_not_allowed, not_found); reconnect this service in your application on the stale-registration codes (client_registration_expired, client_id_unknown, client_audience_mismatch, redirect_uri_mismatch), which a client-side re-registration clears (see the runbook); verify your email address with your identity provider on email_not_verified, the one denial the user clears without an operator; and contact the administrator quoting the code otherwise. The mapping is codeHints in handlers/error_page.go, and TestBrowserReachableCodes_HaveDeliberateAdvice fails if a browser-reachable code has neither a row there nor an explicit record that the fallback is intended — telling a throttled user to call an administrator is worse than saying nothing. The per-endpoint rate limiter serves the pre-rendered page only to a request that also carries Sec-Fetch-Dest: document (a real top-level navigation) and answers plain JSON otherwise — the 429 path is unbounded and its page is ~16x its own 78-byte JSON body, so a spoofed Accept alone must not buy the amplification; its responses carry Vary: Accept, Sec-Fetch-Dest.

Scope is the errors the proxy renders itself. An error that §4.1.2.1 delivers to an already-validated redirect_uri — the IdP-error path on /callback, every /authorize failure past redirect_uri validation, deny and server-error on /consent — leaves as a redirect or through the consent interstitial, not as a page. The one exception is the invariant-violation fallback: if that already-validated redirect_uri fails to re-parse there is nowhere to send the envelope, so the error goes back through the sink and renders like any other.

The page is script-free under its own locked CSP (errorPageCSP, handlers/pages.go — default-src 'none'; style-src 'sha256-…'; form-action 'none'; frame-ancestors 'none'; base-uri 'none'; the style-src names the sha256 of the page's compile-time <style> content instead of 'unsafe-inline', computed from the same constant the template embeds so the two cannot drift; all three proxy-rendered pages are built this way). Both representations carry Cache-Control: no-store, and Vary: Accept is set on the browser-facing routes only — /token and /register have a single representation by RFC mandate, so advertising negotiation on them would invite a cache to key on an Accept that changes nothing. A WWW-Authenticate challenge is dropped whenever a page ships, where it would only make the browser pop a credential dialog and discard the body. The body is rendered into a buffer before any header is committed, so a template failure falls back to the JSON body instead of shipping a blank 4xx (and increments mcp_auth_page_render_failed_total{page="error"}). The HTTP status is identical either way, so nothing machine-observable changes.

IdP-supplied error values on /callback are allowlisted against the RFC 6749 §4.1.2.1 set (invalid_request, invalid_client, unauthorized_client, access_denied, unsupported_response_type, invalid_scope, server_error, temporarily_unavailable); anything outside that set is rewritten to server_error before being echoed to the MCP client. The IdP-supplied error_description is not echoed at all: it is discarded and replaced by a fixed literal on both the redirect and the proxy-rendered branch, because it is attacker-supplied on a /callback hit and would otherwise be rendered under the proxy's own domain. The 200-byte / printable-ASCII clamp at the sink remains as defence in depth for any future caller.


Dockerfile

See Dockerfile. Static Go binary on gcr.io/distroless/static-debian13:nonroot (UID 65532, no shell, no apt). Build args inject build-time metadata via -ldflags -X; OCI labels carry source/created/version/revision.


Constraints and implementation notes

Operator-load-bearing invariants that are not surfaced by the env table or the endpoint reference. The "Configuration" table is the canonical knob list; the "Architecture" and "Endpoints" sections are the canonical flow descriptions.

  • redirect_uri shape: exact match (with the RFC 8252 §7.3 loopback-port relaxation at /authorize); http:// allowed only to a loopback host (full 127/8 range, ::1, ::ffff:127.0.0.1, ::0.0.0.1, localhost, localhost.); non-http(s) schemes rejected even on loopback; fragments and userinfo rejected; length capped at 512 chars; at most 5 entries per client registration. The port relaxation applies only to the registered ↔ /authorize match — at /token the client MUST echo the same redirect_uri value it sent to /authorize (RFC 6749 §4.1.3 byte-equality), which native apps already do because they bind their loopback port once and reuse it for the whole flow.
  • Structured logs: zap JSON, request_id on every line. Inbound X-Request-Id is stripped before chi mints one (defeats client-controlled log forgery). Authenticated requests carry sub and email; JSON-RPC requests additionally carry rpc_method, rpc_tool (capped at 128 chars), rpc_id (capped at 64), all passed through a narrow allowlist (ASCII alphanumerics plus ._:/-+). MCP_LOG_BODY_MAX=0 suppresses the rpc_* fields; ACCESS_LOG_SKIP_RE drops whole lines for matching paths.
  • Business metrics (under /metrics on METRICS_ADDR):
    • mcp_auth_tokens_issued_total{grant_type}
    • mcp_auth_access_denied_total{reason} — see docs/configuration.md for the enumerated reasons
    • mcp_auth_replay_detected_total{kind} (code / refresh / consent / callback_state) — code, refresh and callback_state are rejected requests (security signal); consent counts replayed consent POSTs that were answered with a re-rendered consent page, which includes benign double-submits / back-button re-POSTs — alert on sustained rate, not single ticks
    • mcp_auth_authorize_initiated_total{flow} — /authorize requests admitted past validation, by flow (consent / redirect)
    • mcp_auth_idp_exchange_throttled_total — outbound proxy → IdP exchanges refused by IDP_EXCHANGE_RATE_PER_SEC, at /callback and at /token in forwarding mode. A throttled /token call also increments mcp_auth_idp_refresh_total{result="throttled"}
    • mcp_auth_rate_limited_total{endpoint}
    • mcp_auth_clients_registered_total
    • mcp_auth_groups_claim_shape_mismatch_total — IdP-schema-drift signal; user is admitted with empty groups, so it's NOT a denial
    • mcp_auth_page_render_failed_total{page} — proxy-rendered page template execute failures, by page (error / consent / interstitial). The error page falls back to the JSON body; the consent page and interstitial fall back to the client's redirect_uri envelope. Zero in a healthy deploy; any increment is a template regression
    • mcp_auth_groups_claim_truncated_total — access tokens minted with a truncated groups claim (GROUPS_CLAIM_MAX_BYTES, default 32 KB). Authorization-visible: the claim is forwarded upstream as X-User-Groups, so a truncated user may be refused by the MCP server despite passing ALLOWED_GROUPS here
    • mcp_auth_token_seals_total{purpose} — cross-replica AES-GCM seal counter; alert on sum(increase(metric[7d])) > 2**28 to drive TOKEN_SIGNING_SECRET rotation
    • mcp_auth_rpc_calls_total{tool} / mcp_auth_rpc_calls_failed_total{tool} / mcp_auth_rpc_request_bytes_total{tool} / mcp_auth_rpc_response_bytes_total{tool} — per-tool RPC traffic. Fire only on JSON-RPC tools/call (protocol-level methods like initialize / tools/list are excluded so _unknown reliably flags malformed tools/call payloads). Batches fan out into one rpc_calls_total increment per tools/call entry; byte counters stay scoped to single-call requests because per-call Content-Length / response bytes cannot be honestly attributed inside a batch. Disabled by default (cardinality + privacy trade); opt-in via MCP_TOOL_METRICS=true. Distinct labels capped by MCP_TOOL_METRICS_MAX_CARDINALITY (default 256) — overflow folds into _overflow, unparseable tool names into _unknown
    • mcp_auth_rpc_batches_total / mcp_auth_rpc_batches_failed_total / mcp_auth_rpc_batch_bytes_total{direction} — batch-shape counters, disjoint from the per-tool family. One increment per HTTP request that decoded as a JSON-RPC batch with at least one tools/call entry; carries the request's actual Content-Length / BytesWritten. No per-tool label — batch contents do not have honest per-call attribution. Same opt-in toggle (MCP_TOOL_METRICS=true) as the per-tool family
  • HTTP timeouts: ReadTimeout: 30s, WriteTimeout: 0 (SSE), IdleTimeout: 120s, ReadHeaderTimeout: 10s.
  • Header size limit: MaxHeaderBytes: 64 KB (down from net/http's 1 MB default) on both listeners, so every header-driven cost — including the pre-auth Accept negotiation scan, which the rate limiter by construction does not cover — is bounded.
  • SSE streams MUST flush; do not buffer text/event-stream responses.
  • Body size limit: POST endpoints capped at 1 MB via MaxBytesReader.
  • 307/308 redirect following: proxy follows server-side for Python MCP backends that redirect /mcp → /mcp/. Same-host only, body replayed, max 10 hops, scheme downgrade rejected.
  • PRM resource field: the root /.well-known/oauth-protected-resource carries the trailing-slash form for Claude.ai canonicalization (intentional deviation from RFC 9728 §3 — see the Endpoints section). The per-mount variant is spec-strict.
  • Email verification: email_verified=false in the id_token is rejected at /callback with 403 access_denied + error_code: email_not_verified. A missing claim is accepted — not every IdP emits it.
  • Security headers: every response on the public listener carries Strict-Transport-Security: max-age=63072000; includeSubDomains (RFC 6797), X-Content-Type-Options: nosniff, X-Frame-Options: DENY, Referrer-Policy: no-referrer (RFC 9700 §4.2.4 RECOMMENDED for OAuth ASes — defends against code leakage via Referer), and Content-Security-Policy: default-src 'none'; frame-ancestors 'none'. Headers are set before the handler runs so they apply to every status code, including upstream pass-through 5xx and rate-limiter 429s. Not applied to the metrics listener (Prometheus scrape is in-cluster only). The HSTS includeSubDomains directive assumes the operator's parent zone is all-HTTPS.

Multi-instance deployment (K8s)

The stateless design supports horizontal scaling without sticky sessions. Required configuration symmetry across replicas:

  1. TOKEN_SIGNING_SECRET — must be byte-identical (mount from a Secret, not generated per-pod). The single most important invariant: a mismatch breaks every cross-pod token validation.
  2. PROXY_BASE_URL — must be the public DNS name reached by clients, not a per-pod hostname. Audience binding enforces this — a pod with a wrong PROXY_BASE_URL will reject every token minted by its siblings.
  3. OIDC_* — same registration on the same IdP.
  4. UPSTREAM_MCP_URL — same in-cluster service URL.
  5. PKCE_REQUIRED, ALLOWED_GROUPS, GROUPS_CLAIM, REVOKE_BEFORE, REDIS_URL, REDIS_KEY_PREFIX, RATE_LIMIT_ENABLED — any asymmetry produces "works on some pods, not others" bugs. REDIS_URL + REDIS_KEY_PREFIX in particular must match across all replicas, otherwise single-use and reuse-detection guarantees only hold within each pod's local set of clients.

Recommended Deployment shape:

apiVersion: apps/v1
kind: Deployment
spec:
  replicas: 3
  strategy:
    rollingUpdate:
      maxUnavailable: 0
      maxSurge: 1
  template:
    spec:
      terminationGracePeriodSeconds: 120  # match SHUTDOWN_TIMEOUT
      containers:
      - name: mcp-auth-proxy
        envFrom:
        - secretRef:
            name: mcp-auth-proxy-secret
        - configMapRef:
            name: mcp-auth-proxy-config
        livenessProbe:
          httpGet:
            path: /healthz
            port: http
        # /readyz lives ONLY on the metrics port — an unauthenticated
        # readiness endpoint on the public listener is a Redis-DoS
        # amplifier (a sustained probe flood saturates the pool, flips
        # readiness fleet-wide, and drops every pod from the Service
        # simultaneously). Probe via the metrics port the kubelet can
        # reach in-cluster.
        readinessProbe:
          httpGet:
            path: /readyz
            port: metrics
        ports:
        - { name: http,    containerPort: 8080 }
        - { name: metrics, containerPort: 9090 }
---
apiVersion: v1
kind: Service
spec:
  sessionAffinity: None  # explicit; no stickiness needed
  selector: { app: mcp-auth-proxy }
  ports:
  - { name: http, port: 80, targetPort: 8080 }

Ship a PodDisruptionBudget with minAvailable: 1 (or 2 for ≥3 replicas) so node drains do not take out the whole auth plane at once.

The manifests/ folder ships a turn-key demo: a Docker Compose stack (Keycloak + Redis + fake MCP upstream + proxy) for local exploration and a Kubernetes reference set (Deployment, Service, Ingress, PodDisruptionBudget, plus scripts/generate-signing-secret.sh).

Bulk revocation rollout caveat

REVOKE_BEFORE is read at startup. Updating it requires a rolling restart, and during the rollout window some pods enforce the new cutoff while others still use the old one. Wait for kubectl rollout status to converge before assuming the cutoff is fleet-wide enforced.


Startup validation

config.Load() fails closed on the following config mistakes (fatal at startup):

  • TOKEN_SIGNING_SECRET shorter than 32 bytes.
  • TOKEN_SIGNING_SECRET matching an obvious-weakness pattern when PROD_MODE=true. Three rejection classes: (1) all-same byte (aaaa…); (2) short repeating period (abcabc…, 0123456789abcdef0123456789abcdef); (3) tiny alphabet — fewer than 8 distinct byte values, catching uneven-run-length shapes (aaaa…b) that defeat the period and all-same checks. Same gate applies to every entry in TOKEN_SIGNING_SECRETS_PREVIOUS so a rolling rotation cannot regress. Under PROD_MODE=false the same secret triggers a non-fatal weakness warning instead. Real random output of any encoding (raw, hex, base64) is non-periodic AND has well over 8 distinct values in 32+ bytes, so it passes; canonical generator is manifests/scripts/generate-signing-secret.sh.
  • SHUTDOWN_TIMEOUT non-positive or greater than 15 minutes (L2).
  • REDIS_KEY_PREFIX containing {, }, \r, \n, or any byte outside the 0x20..0x7E ASCII-printable range (L3).
  • PROXY_BASE_URL with a scheme other than https:// (or http:// to a loopback host), a non-empty userinfo, a fragment, or a path beyond / (L8).
  • MCP_LOG_BODY_MAX / MCP_PER_SUBJECT_CONCURRENCY not parseable as non-negative integers.
  • SHUTDOWN_TIMEOUT / REVOKE_BEFORE unparseable as duration / RFC3339.
  • ACCESS_LOG_SKIP_RE not compilable as a Go RE2 regexp.
  • UPSTREAM_FORWARD_IDP_TOKEN=true without REDIS_URL under PROD_MODE, or together with UPSTREAM_AUTHORIZATION_HEADER. Without a replay store, a stolen refresh token would mint a forwardable IdP token on every replay. Outside PROD_MODE, with REDIS_REQUIRED=false, the proxy falls back to an in-memory replay store (single instance only, log replay_store_in_memory).

Non-fatal startup warnings:

  • token_signing_secret_weak fires when the secret matches an obvious-weakness pattern (all-same byte, repeating period < length, or fewer than 8 distinct byte values) — signals a human-typed / patterned secret whose effective entropy is well below its length.
  • token_seal_rotation_threshold fires once after 2^28 successful seals per Manager, suggesting TOKEN_SIGNING_SECRET rotation before AES-GCM nonce-collision bounds matter (L6).