feat(server): rework /mcp auth for Cloudflare Managed OAuth (JEF-493)#121
Merged
thejefflarson merged 1 commit intoJul 23, 2026
Conversation
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
deleted the
thejefflarson/jef-493-rework-mcp-auth-for-cloudflare-managed-oauth-validate-cf
branch
July 23, 2026 00:59
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-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-AssertionJWT — the same header/issuer/team-JWKS that/apivalidates (JEF-473). JEF-472's guard instead validated the rawAuthorization: Beareras a JWT (the opaque token would now be rejected) and self-served OAuth metadata that Cloudflare owns. This PR fixes the model.What changed
/mcpguard validatesCf-Access-Jwt-Assertionvia the sharedaccess_jwt::VerifierwithWATCHER_MCP_ACCESS_AUD(distinct AUD from the browser/apiapp), not the rawAuthorization: Bearer. The header-extract + verify step is factored into a sharedcheck_access_assertionhelper that both the/apiaccess_guardand the/mcpguard call./mcpfails closed (rejects even a JWKS-unavailable assertion);/apistill fails open (edge is the gate)./.well-known/oauth-protected-resource+ authorization-server discovery (Cloudflare Managed OAuth owns it). Missing/invalid assertion still returns401.WWW-Authenticateis 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./mcpstays read-only + default-OFF; enabled withoutWATCHER_MCP_ACCESS_AUD→ not mounted (fail closed) — preserved.Cf-Access-Jwt-Assertionat origin), noting it supersedes JEF-472's raw-Bearer / self-metadata design./apiunchangedThe
/apiguard and browser flow are behaviorally unchanged — the refactor preserves its status codes and its exact diagnostic warn log (the verify-error reason is carried throughAssertion::Invalid).Tests
smoke.rsupdated to the assertion-header model (the Cloudflare edge is simulated by injectingCf-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.mcp_authunit tests cover theWWW-Authenticatetoggle (off by default, bareBearerwhen enabled).cargo fmt+cargo check(warnings-as-errors) +cargo test --lockedall green (43 unit + 53 integration).Decision made (DECISION NEEDED for architect)
Kept the ability to emit a
WWW-Authenticatechallenge but reduced it to a bareWWW-Authenticate: Bearerheader (noresource_metadata, since we no longer self-serve PRM), gated behindWATCHER_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