Skip to content

feat(server): rework /mcp auth for Cloudflare Managed OAuth (JEF-493)#121

Merged
thejefflarson merged 1 commit into
mainfrom
thejefflarson/jef-493-rework-mcp-auth-for-cloudflare-managed-oauth-validate-cf
Jul 23, 2026
Merged

feat(server): rework /mcp auth for Cloudflare Managed OAuth (JEF-493)#121
thejefflarson merged 1 commit into
mainfrom
thejefflarson/jef-493-rework-mcp-auth-for-cloudflare-managed-oauth-validate-cf

Conversation

@thejefflarson

Copy link
Copy Markdown
Owner

Closes JEF-493.

Why

The chosen production mechanism is Cloudflare Access Managed OAuth: Cloudflare is the DCR OAuth authorization server (what claude.ai's connector needs), issues the MCP client an opaque token, resolves it at the edge, and forwards the origin the standard Cf-Access-Jwt-Assertion JWT — the same header/issuer/team-JWKS that /api validates (JEF-473). JEF-472's guard instead validated the raw Authorization: Bearer as a JWT (the opaque token would now be rejected) and self-served OAuth metadata that Cloudflare owns. This PR fixes the model.

What changed

  • /mcp guard validates Cf-Access-Jwt-Assertion via the shared access_jwt::Verifier with WATCHER_MCP_ACCESS_AUD (distinct AUD from the browser /api app), not the raw Authorization: Bearer. The header-extract + verify step is factored into a shared check_access_assertion helper that both the /api access_guard and the /mcp guard call. /mcp fails closed (rejects even a JWKS-unavailable assertion); /api still fails open (edge is the gate).
  • Removed watcher's self-served /.well-known/oauth-protected-resource + authorization-server discovery (Cloudflare Managed OAuth owns it). Missing/invalid assertion still returns 401.
  • Residual WWW-Authenticate is a small toggle (WATCHER_MCP_WWW_AUTHENTICATE, default OFF → Cloudflare-owns-it). A live Claude-connector test will tell us whether Cloudflare intercepts the origin's 401 challenge or the origin must emit it; flipping the toggle needs no code change.
  • /mcp stays read-only + default-OFF; enabled without WATCHER_MCP_ACCESS_AUD → not mounted (fail closed) — preserved.
  • ADR 0019 rewritten to the Managed-OAuth model (opaque client token → edge-resolved Cf-Access-Jwt-Assertion at origin), noting it supersedes JEF-472's raw-Bearer / self-metadata design.

/api unchanged

The /api guard and browser flow are behaviorally unchanged — the refactor preserves its status codes and its exact diagnostic warn log (the verify-error reason is carried through Assertion::Invalid).

Tests

  • smoke.rs updated to the assertion-header model (the Cloudflare edge is simulated by injecting Cf-Access-Jwt-Assertion): mcp_assertion_auth_401_matrix (missing / garbage / wrong-AUD / expired → 401; valid → admitted, fail-closed), mcp_fails_closed_when_auth_unconfigured, and the authenticated end-to-end rmcp client test. Removed the now-wrong Bearer-JWT + self-metadata tests.
  • New mcp_auth unit tests cover the WWW-Authenticate toggle (off by default, bare Bearer when enabled).
  • cargo fmt + cargo check (warnings-as-errors) + cargo test --locked all green (43 unit + 53 integration).

Decision made (DECISION NEEDED for architect)

Kept the ability to emit a WWW-Authenticate challenge but reduced it to a bare WWW-Authenticate: Bearer header (no resource_metadata, since we no longer self-serve PRM), gated behind WATCHER_MCP_WWW_AUTHENTICATE, default OFF. Rationale: under Managed OAuth the edge owns discovery and a client reaching the origin already cleared the edge, so the origin should normally stay silent — but a live connector test is the only way to confirm whether Cloudflare passes an origin 401 challenge through. The toggle lets us react without a redeploy-shaped code change.

🤖 Generated with Claude Code

https://claude.ai/code/session_01JbrmfzHsTMMzPaSrUkZgWo

The chosen prod mechanism is Cloudflare Access Managed OAuth: Cloudflare is
the DCR OAuth authorization server, issues the MCP client an opaque token,
resolves it at the edge, and forwards the origin the standard
Cf-Access-Jwt-Assertion JWT — the same header/issuer/team-JWKS /api validates
(JEF-473). JEF-472's guard instead validated the raw Authorization: Bearer as
a JWT (the opaque token would now be rejected) and self-served OAuth metadata
Cloudflare owns. Fix the model.

- /mcp guard validates Cf-Access-Jwt-Assertion via the shared verifier with
  WATCHER_MCP_ACCESS_AUD (distinct AUD), reusing the /api assertion path via a
  new shared check_access_assertion helper. Fails closed (rejects even a
  JWKS-unavailable assertion); /api still fails open.
- Drop the self-served /.well-known/oauth-protected-resource + authorization
  -server discovery. Residual WWW-Authenticate emission is now a small toggle
  (WATCHER_MCP_WWW_AUTHENTICATE), default OFF (Cloudflare owns discovery).
- /mcp stays read-only + default-OFF; unconfigured MCP AUD => not mounted.
- smoke.rs updated to the assertion-header model (edge simulated by injecting
  the header); ADR 0019 rewritten to Managed OAuth, superseding JEF-472.

Tests: smoke.rs covers the fail-closed 401 matrix (missing/garbage/wrong-aud/
expired) + the authenticated end-to-end client; mcp_auth unit tests cover the
WWW-Authenticate toggle. cargo fmt + check (warnings-as-errors) + test green.

Claude-Session: https://claude.ai/code/session_01JbrmfzHsTMMzPaSrUkZgWo

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
@thejefflarson
thejefflarson merged commit 9acb534 into main Jul 23, 2026
4 checks passed
@thejefflarson
thejefflarson deleted the thejefflarson/jef-493-rework-mcp-auth-for-cloudflare-managed-oauth-validate-cf branch July 23, 2026 00:59
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