Skip to content

feat(server): Cloudflare Access OIDC Bearer auth on /mcp (JEF-472)#120

Merged
thejefflarson merged 1 commit into
mainfrom
thejefflarson/jef-472-mcp-auth-cloudflare-access-oidc-in-front-of-mcp-edge-owns
Jul 22, 2026
Merged

feat(server): Cloudflare Access OIDC Bearer auth on /mcp (JEF-472)#120
thejefflarson merged 1 commit into
mainfrom
thejefflarson/jef-472-mcp-auth-cloudflare-access-oidc-in-front-of-mcp-edge-owns

Conversation

@thejefflarson

Copy link
Copy Markdown
Owner

Closes JEF-472.

What

/mcp (the read-only MCP server from JEF-471 / ADR 0018) shipped default-OFF and unauthenticated. This adds its own auth so it can be safely enabled: an MCP client authenticates with an Authorization: Bearer <token> Cloudflare Access JWT, which the origin validates (never mints — ADR 0013) using the shared access_jwt::Verifier. Edge owns identity; watcher validates.

  • New mcp_auth module — a Bearer guard + RFC 9728 protected-resource metadata. Reuses access_jwt::Verifier (no duplicated JWT logic); Verifier gains for_team (shared issuer/JWKS derivation from the team domain) and an issuer() accessor.
  • Separate MCP AUD (WATCHER_MCP_ACCESS_AUD), distinct from the browser app's WATCHER_ACCESS_AUD, so a browser-scoped token can't be replayed at /mcp (and vice-versa).
  • 401, not a redirect on missing / invalid / expired / wrong-aud, carrying a WWW-Authenticate: Bearer … resource_metadata="…" challenge. The metadata document is served unauthenticated at /.well-known/oauth-protected-resource/mcp (and the root alias) and names the Cloudflare Access OIDC authorization server.
  • Fails CLOSED — unlike the defense-in-depth /api guard (which fails open on a JWKS outage since the edge is still the gate), /mcp is the only auth on its surface: an unresolvable JWKS → 401, and WATCHER_MCP_ENABLED set without auth configured → /mcp is not mounted at all (loud startup error). No code path serves /mcp unauthenticated.
  • Host allow-list — re-scopable via WATCHER_MCP_ALLOWED_HOSTS (comma-separated); default stays disabled because the tunnel host varies per deployment and Bearer auth already defeats DNS-rebinding (a rebinding attacker's browser can't forge a valid Access token). Documented in the ADR.
  • ADR 0019 records the auth model and cross-references 0013/0018.

Testing

cd server && cargo fmt --check && cargo clippy --all-targets --locked && cargo test --locked — all green (54 tests). New smoke.rs tests, using a locally-signed JWK + a local JWKS server (no Cloudflare, no network):

  • mcp_bearer_auth_401_matrix — missing / garbage / wrong-aud / expired → 401 with the metadata challenge; valid → admitted.
  • mcp_serves_protected_resource_metadata_unauthenticated — both well-known paths served, pointing at the Access OIDC authorization server.
  • mcp_fails_closed_when_auth_unconfigured — enabled-without-auth serves no MCP surface and no metadata.
  • mcp_lists_tools_and_calls_read_queries — updated to drive the rmcp client with a valid Bearer token end-to-end.

Scope / decisions (for the architect)

  • DCR spike is out of scope (human/prod). Whether claude.ai's remote-connector OAuth flow completes against Cloudflare Access OIDC — including dynamic client registration — needs the live Access OIDC app + the connector. The server side built here is spec-compliant and agnostic to how the client got its token; if Access lacks DCR support the fallback is a pre-registered client (no server change). Recorded in ADR 0019 for confirmation.
  • Cluster untouched. The Access OIDC application is a GitOps/human follow-up in ../cluster, not this repo.
  • Host allow-list: kept disabled by default, re-scopable via env — chosen because the public host varies per deployment and Bearer auth is the DNS-rebinding mitigation. Documented; flag if you'd prefer a hard-required allow-list.

🤖 Generated with Claude Code

https://claude.ai/code/session_01JbrmfzHsTMMzPaSrUkZgWo

`/mcp` (the read-only MCP server, JEF-471) shipped default-OFF and
unauthenticated. Add its own auth so it can be safely enabled: an MCP
client authenticates with an `Authorization: Bearer <token>` Cloudflare
Access JWT, which the origin validates (never mints — ADR 0013) via the
shared `access_jwt::Verifier`.

- New `mcp_auth` module: Bearer guard + RFC 9728 protected-resource
  metadata. Reuses `Verifier` (no duplicated JWT logic); `Verifier` gains
  `for_team` (shared issuer/JWKS derivation) and `issuer()`.
- A separate MCP AUD (`WATCHER_MCP_ACCESS_AUD`) distinct from the browser
  app's, so a browser-scoped token can't be replayed at `/mcp`.
- 401 (never a login redirect) on missing/invalid/expired/wrong-aud, with
  a `WWW-Authenticate` challenge pointing at the metadata doc; the doc is
  served unauthenticated at `/.well-known/oauth-protected-resource[/mcp]`.
- Fails CLOSED: unlike the defense-in-depth `/api` guard, an unresolvable
  JWKS -> 401; and enabled-without-auth -> `/mcp` not mounted at all.
- Re-scope rmcp's Host allow-list via `WATCHER_MCP_ALLOWED_HOSTS` (default
  stays disabled — Bearer auth defeats DNS-rebinding; documented in ADR).
- ADR 0019 records the model + the DCR spike (human/prod follow-up).

Tests: smoke.rs covers the 401/200 matrix, the metadata endpoint, and the
fail-closed refusal (local JWK + local JWKS, no network).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JbrmfzHsTMMzPaSrUkZgWo
@thejefflarson
thejefflarson merged commit 5e7d5ee into main Jul 22, 2026
4 checks passed
@thejefflarson
thejefflarson deleted the thejefflarson/jef-472-mcp-auth-cloudflare-access-oidc-in-front-of-mcp-edge-owns branch July 22, 2026 06:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant