feat(server): Cloudflare Access OIDC Bearer auth on /mcp (JEF-472)#120
Merged
thejefflarson merged 1 commit intoJul 22, 2026
Conversation
`/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
deleted the
thejefflarson/jef-472-mcp-auth-cloudflare-access-oidc-in-front-of-mcp-edge-owns
branch
July 22, 2026 06:19
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 anAuthorization: Bearer <token>Cloudflare Access JWT, which the origin validates (never mints — ADR 0013) using the sharedaccess_jwt::Verifier. Edge owns identity; watcher validates.mcp_authmodule — a Bearer guard + RFC 9728 protected-resource metadata. Reusesaccess_jwt::Verifier(no duplicated JWT logic);Verifiergainsfor_team(shared issuer/JWKS derivation from the team domain) and anissuer()accessor.WATCHER_MCP_ACCESS_AUD), distinct from the browser app'sWATCHER_ACCESS_AUD, so a browser-scoped token can't be replayed at/mcp(and vice-versa).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./apiguard (which fails open on a JWKS outage since the edge is still the gate),/mcpis the only auth on its surface: an unresolvable JWKS → 401, andWATCHER_MCP_ENABLEDset without auth configured →/mcpis not mounted at all (loud startup error). No code path serves/mcpunauthenticated.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.Testing
cd server && cargo fmt --check && cargo clippy --all-targets --locked && cargo test --locked— all green (54 tests). Newsmoke.rstests, 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)
../cluster, not this repo.🤖 Generated with Claude Code
https://claude.ai/code/session_01JbrmfzHsTMMzPaSrUkZgWo