diff --git a/docs/adr/0019-mcp-auth-cloudflare-access-oidc.md b/docs/adr/0019-mcp-auth-cloudflare-access-oidc.md index 330f43e..8d5a264 100644 --- a/docs/adr/0019-mcp-auth-cloudflare-access-oidc.md +++ b/docs/adr/0019-mcp-auth-cloudflare-access-oidc.md @@ -1,93 +1,109 @@ -# 0019. Authenticate `/mcp` with a Cloudflare Access OIDC Bearer token +# 0019. Authenticate `/mcp` via Cloudflare Access Managed OAuth (validate `Cf-Access-Jwt-Assertion`) - Status: Accepted -- Date: 2026-07-21 +- Date: 2026-07-22 - Related: [0013](0013-auth-at-the-edge.md) (edge auth + origin verify), [0018](0018-read-only-mcp-server-in-process.md) (the read-only MCP server) +- Revises: the initial JEF-472 design recorded in this ADR (raw-`Bearer` JWT validation + self-served OAuth metadata), superseded by JEF-493. ## Context [0018](0018-read-only-mcp-server-in-process.md) mounted a read-only MCP server on -`/mcp`, default OFF, deliberately **unauthenticated** — its auth was left to this -ticket (JEF-472). `/mcp` sits *outside* the browser edge auth that fronts the UI + -`/api` (Cloudflare Access, [0013](0013-auth-at-the-edge.md)): an MCP client (Claude -Code, MCP Inspector, claude.ai's remote connector) is not a browser and carries no -Access cookie, so the cookie-based edge policy can't gate it. Until it had its own -auth the endpoint could not be safely enabled. +`/mcp`, default OFF, deliberately **unauthenticated** — its auth was left to a +follow-up. `/mcp` sits *outside* the browser edge auth that fronts the UI + `/api` +(Cloudflare Access, [0013](0013-auth-at-the-edge.md)): an MCP client (Claude Code, MCP +Inspector, claude.ai's remote connector) is not a browser and carries no Access +cookie, so the cookie-based edge policy can't gate it. Until it had its own auth the +endpoint could not be safely enabled. -The MCP authorization spec (2025-06-18) models an MCP server as an OAuth 2.0 -**protected resource**: the client obtains a token from an authorization server and -presents it as `Authorization: Bearer `; the resource server validates it and, -on failure, points the client at the authorization server via -[RFC 9728](https://www.rfc-editor.org/rfc/rfc9728) Protected Resource Metadata. +The MCP authorization spec models an MCP server as an OAuth 2.0 **protected resource**: +the client obtains a token from an authorization server and presents it; the resource +server validates it. The open question was *who is the authorization server* and *what +does the origin actually receive*. -We already verify Cloudflare Access JWTs at the origin -([`access_jwt::Verifier`](../../server/src/access_jwt.rs), JEF-473). Cloudflare Access -can also act as an **OIDC provider** for a dedicated Access application, minting JWTs -we can validate with the *same* verifier. So the edge still owns identity; watcher -only validates, never mints (the [0013](0013-auth-at-the-edge.md) invariant). +**Initial design (JEF-472, now revised).** The first cut had watcher itself act as the +OAuth-aware resource server: it validated the raw `Authorization: Bearer ` as a +Cloudflare Access **OIDC** JWT and *self-served* the RFC 9728 protected-resource +metadata (`/.well-known/oauth-protected-resource`) pointing clients at the Access OIDC +authorization server. + +**Spike finding (JEF-493).** The mechanism Cloudflare actually provides for this is +Access **Managed OAuth**: Cloudflare is the OAuth authorization server the client needs +(including the dynamic client registration — DCR — that claude.ai's connector performs), +it issues the client an **opaque** access token, resolves that token at its **edge**, +and forwards the origin the standard **`Cf-Access-Jwt-Assertion`** JWT — the *same* +header, issuer, and team JWKS that `/api` already validates (JEF-473). Under this model +the JEF-472 design is wrong in two ways: the origin would receive an *opaque* token in +`Authorization: Bearer` (not a JWT — it would fail JWT validation), and OAuth +discovery/metadata is owned by Cloudflare, not the origin. ## Decision -- **Bearer validation, reusing the shared verifier.** `/mcp` requires - `Authorization: Bearer `; the token is validated by the shared - `access_jwt::Verifier` (RS256 via the team's JWKS, `iss` = team domain, `aud`, and - expiry). No JWT logic is duplicated — a small [`mcp_auth`](../../server/src/mcp_auth.rs) - module wires the verifier into an axum middleware and the discovery routes. +- **Validate the forwarded `Cf-Access-Jwt-Assertion`, not the raw `Authorization` + header.** `/mcp`'s guard validates the `Cf-Access-Jwt-Assertion` JWT the Cloudflare + edge sets after resolving the client's opaque Managed-OAuth token — via the shared + [`access_jwt::Verifier`](../../server/src/access_jwt.rs) (RS256 via the team's JWKS, + `iss` = team domain, `aud`, and expiry). This is the **same** assertion model as + JEF-473's `/api` `access_guard`; the header-extraction + verify step is factored into + one shared `check_access_assertion` helper both guards call. The origin only ever + **validates**, never mints (the [0013](0013-auth-at-the-edge.md) invariant), and never + parses the opaque OAuth token. -- **A separate AUD for the MCP app.** The MCP endpoint expects its **own** Access - application 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. The team domain (`WATCHER_ACCESS_TEAM_DOMAIN`) is shared. `Verifier::for_team` - derives the issuer/JWKS URLs from that team domain for both apps. +- **A separate AUD for the MCP app.** `/mcp` expects its **own** Access application AUD + (`WATCHER_MCP_ACCESS_AUD`), distinct from the browser app's `WATCHER_ACCESS_AUD`, so a + browser-scoped assertion can't be replayed at `/mcp` and vice versa. The team domain + (`WATCHER_ACCESS_TEAM_DOMAIN`) is shared; `Verifier::for_team` derives the issuer/JWKS + URLs for both apps. -- **401, not a redirect — with discovery.** A missing / invalid / expired / wrong-AUD - token yields `401` with a `WWW-Authenticate: Bearer … resource_metadata="…"` - challenge (never an HTML login redirect — the caller is a program). The - `/.well-known/oauth-protected-resource/mcp` document (also served at the un-suffixed - root path some clients probe) is returned **unauthenticated** and names the - Cloudflare Access OIDC authorization server, so a spec-compliant client can discover - where to get a token. +- **No self-served OAuth metadata.** watcher no longer serves + `/.well-known/oauth-protected-resource` (or authorization-server discovery) — + Cloudflare Managed OAuth owns discovery, DCR, and token issuance. A + missing/invalid/expired/wrong-AUD assertion still yields `401`. Whether the origin + should additionally emit a `WWW-Authenticate` challenge is uncertain: under Managed + OAuth the edge fronts the origin, so a client reaching the origin has already cleared + the edge, and it is unknown whether Cloudflare intercepts an origin 401 challenge or + passes it through. We therefore keep a **small, easily-toggled** path: a bare + `WWW-Authenticate: Bearer` header, emitted only when `WATCHER_MCP_WWW_AUTHENTICATE=1`, + **default OFF** (Cloudflare-owns-it). A live Claude-connector test will settle it; + flipping the toggle needs no redeploy logic change. - **Fail closed — unlike the browser guard.** The `/api` origin guard is *defense-in-depth* behind the edge, so on a cold-cache JWKS outage it fails **open** - (the edge stays the gate — [0013](0013-auth-at-the-edge.md)). `/mcp` has **no** edge - in front of it; its Bearer guard is the *only* auth, so it fails **closed**: an - unverifiable token (JWKS unavailable) is rejected `401`, same as an invalid one. And - when `WATCHER_MCP_ENABLED` is set but auth is unconfigured (no team domain / MCP - AUD), `/mcp` is **not mounted at all** rather than served open — the operator gets a - loud startup error. There is no code path that exposes an unauthenticated `/mcp`. + (the edge stays the gate — [0013](0013-auth-at-the-edge.md)). `/mcp`'s guard is the + *only* auth on that surface, so it fails **closed**: an unverifiable assertion (JWKS + unavailable) is rejected `401`, same as an invalid one. And when `WATCHER_MCP_ENABLED` + is set but auth is unconfigured (no team domain / MCP AUD), `/mcp` is **not mounted at + all** rather than served open — the operator gets a loud startup error. There is no + code path that exposes an unauthenticated `/mcp`. -- **DNS-rebinding guard: Bearer auth replaces the Host allow-list by default.** rmcp's - transport defaults to a loopback-only `Host` allow-list (a DNS-rebinding guard for - locally-run servers reached by a browser). watcher's MCP is a server-to-server - endpoint reached through a public tunnel host whose name varies by deployment, so - that default rejects every legitimate client (why [0018](0018-read-only-mcp-server-in-process.md) - disabled it). With Bearer auth now in front, DNS rebinding is already defeated — a - rebinding attacker's browser JS cannot forge a valid Access token, so it never gets - past the guard regardless of `Host`. We therefore keep the list disabled by default - but let an operator re-scope it to their known host(s) via `WATCHER_MCP_ALLOWED_HOSTS` - (comma-separated) for belt-and-braces. Origin validation stays off (MCP clients are - not browsers and send no `Origin`). +- **DNS-rebinding guard: the edge-set assertion replaces the Host allow-list by + default.** rmcp's transport defaults to a loopback-only `Host` allow-list (a + DNS-rebinding guard for locally-run servers reached by a browser). watcher's MCP is a + server-to-server endpoint reached through a public tunnel host whose name varies by + deployment, so that default rejects every legitimate client (why + [0018](0018-read-only-mcp-server-in-process.md) disabled it). With assertion auth in + front, DNS rebinding is already defeated — `Cf-Access-Jwt-Assertion` is an **edge-set** + header (Cloudflare strips any client-supplied copy), so a rebinding attacker's browser + JS cannot forge one regardless of `Host`. We keep the list disabled by default but let + an operator re-scope it via `WATCHER_MCP_ALLOWED_HOSTS` for belt-and-braces. Origin + validation stays off (MCP clients are not browsers and send no `Origin`). ## Consequences - `/mcp` can be safely enabled: it is inert unless an operator sets `WATCHER_MCP_ENABLED` **and** configures `WATCHER_ACCESS_TEAM_DOMAIN` + - `WATCHER_MCP_ACCESS_AUD` (and, at the edge, a dedicated Access OIDC application). The - ordering mirrors the "create the Access app first" runbook rule of - [0013](0013-auth-at-the-edge.md). -- The 401/200 matrix (missing / garbage / wrong-AUD / expired → 401 with the metadata - challenge; valid → admitted), the resource-metadata document, and the fail-closed - refusal are covered by integration tests (`server/tests/smoke.rs`) using a - locally-signed JWK and a local JWKS server — no Cloudflare, no network. + `WATCHER_MCP_ACCESS_AUD` (and, at the edge, a dedicated Access application with + **Managed OAuth** enabled). The ordering mirrors the "create the Access app first" + runbook rule of [0013](0013-auth-at-the-edge.md). +- The 401 matrix (missing / garbage / wrong-AUD / expired → 401; valid → admitted) and + the fail-closed refusal are covered by integration tests (`server/tests/smoke.rs`) + using a locally-signed JWK and a local JWKS server — the edge is simulated by injecting + the `Cf-Access-Jwt-Assertion` header. No Cloudflare, no network. - **DECISION NEEDED / spike (human, not code — out of scope here).** The end-to-end - question — does claude.ai's remote-connector OAuth flow actually **complete** against - Cloudflare Access OIDC, including **dynamic client registration (DCR)**? — needs the - live Access OIDC app + the claude.ai connector, which is a prod/human verification. - The server side built here is spec-compliant (RFC 9728 protected-resource metadata + - Bearer validation) and is agnostic to *how* the client obtained its token. If - Cloudflare Access does **not** support DCR for the connector, the fallback is a - **pre-registered OAuth client** (register the client with Access once, configure the - connector with those credentials) — no server change required either way, since - watcher only validates the resulting token. The cluster-side Access OIDC application - is a separate GitOps/human follow-up in the `../cluster` repo (not this one). + question — does claude.ai's remote-connector OAuth flow **complete** against Cloudflare + Access Managed OAuth (including DCR), and does Cloudflare intercept or forward an origin + `401`/`WWW-Authenticate` challenge? — needs the live Access Managed-OAuth app + the + claude.ai connector, a prod/human verification. The server side is agnostic to *how* + the client obtained its token (it validates only the resulting edge-forwarded + assertion), and the `WATCHER_MCP_WWW_AUTHENTICATE` toggle lets us react to the challenge + finding without a code change. The cluster-side Access Managed-OAuth application is a + separate GitOps/human follow-up in the `../cluster` repo (not this one). diff --git a/docs/adr/README.md b/docs/adr/README.md index 5438e79..89b163f 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -27,4 +27,4 @@ Copy [`0000-template.md`](0000-template.md) to start one. | [0016](0016-self-log-instrumentation.md) | Self-instrument watcher's own logs in-process | Accepted | | [0017](0017-self-trace-instrumentation-in-process.md) | Self-instrument watcher's own traces in-process | Accepted | | [0018](0018-read-only-mcp-server-in-process.md) | Expose the read API as a read-only MCP server, in-process on `/mcp` | Accepted | -| [0019](0019-mcp-auth-cloudflare-access-oidc.md) | Authenticate `/mcp` with a Cloudflare Access OIDC Bearer token | Accepted | +| [0019](0019-mcp-auth-cloudflare-access-oidc.md) | Authenticate `/mcp` via Cloudflare Access Managed OAuth (validate `Cf-Access-Jwt-Assertion`) | Accepted | diff --git a/server/src/lib.rs b/server/src/lib.rs index 9f43a7b..fdc372a 100644 --- a/server/src/lib.rs +++ b/server/src/lib.rs @@ -31,9 +31,47 @@ use crate::access_jwt::{Verifier, VerifyError}; use crate::mcp_auth::McpAuth; /// The header Cloudflare Access sets on requests that cleared its edge policy, -/// carrying the signed identity JWT the origin re-verifies (JEF-473). +/// carrying the signed identity JWT the origin re-verifies (JEF-473). Under +/// Managed OAuth this is also what the edge forwards after resolving an MCP +/// client's opaque OAuth token (JEF-493), so `/api` and `/mcp` verify the same +/// header. Cloudflare strips any client-supplied `Cf-Access-*` header, so the +/// origin can trust it as edge-set. const ACCESS_JWT_HEADER: &str = "Cf-Access-Jwt-Assertion"; +/// Outcome of checking a request's `Cf-Access-Jwt-Assertion` header against a +/// [`Verifier`]. Shared by the `/api` [`access_guard`] (which fails **open** on +/// `KeysUnavailable`, since the edge is still the gate) and the `/mcp` +/// [`mcp_auth::assertion_guard`] (which fails **closed** — it is the only auth). +pub(crate) enum Assertion { + /// A valid assertion — admit the request. + Valid, + /// No `Cf-Access-Jwt-Assertion` header on the request. + Missing, + /// Header present but the JWT did not validate (bad signature, wrong + /// `aud`/`iss`, or expired); carries the reason for the guard's warn log. + Invalid(String), + /// The JWKS could not be obtained (cold cache) so the token can't be checked; + /// the caller decides fail-open vs. fail-closed. + KeysUnavailable, +} + +/// Verify a request's `Cf-Access-Jwt-Assertion` header against `verifier`. The +/// single place the header name and the `VerifyError` → outcome mapping live, so +/// the `/api` and `/mcp` guards share exactly one verification path (JEF-493). +pub(crate) async fn check_access_assertion( + verifier: &Verifier, + headers: &axum::http::HeaderMap, +) -> Assertion { + let Some(token) = headers.get(ACCESS_JWT_HEADER).and_then(|v| v.to_str().ok()) else { + return Assertion::Missing; + }; + match verifier.verify(token).await { + Ok(_claims) => Assertion::Valid, + Err(VerifyError::KeysUnavailable) => Assertion::KeysUnavailable, + Err(VerifyError::Invalid(why)) => Assertion::Invalid(why), + } +} + /// Reads W3C trace-context headers off an incoming request so watcher can /// continue the caller's trace (e.g. traefik's) rather than starting a new one. struct HeaderExtractor<'a>(&'a axum::http::HeaderMap); @@ -136,25 +174,20 @@ async fn access_guard( req: Request, next: Next, ) -> Response { - let token = req - .headers() - .get(ACCESS_JWT_HEADER) - .and_then(|v| v.to_str().ok()); - let Some(token) = token else { - tracing::warn!( - path = %req.uri().path(), - "rejecting request with no {ACCESS_JWT_HEADER} header", - ); - return (StatusCode::UNAUTHORIZED, "missing Cloudflare Access token").into_response(); - }; - - match verifier.verify(token).await { - Ok(_claims) => next.run(req).await, + match check_access_assertion(&verifier, req.headers()).await { + Assertion::Valid => next.run(req).await, // Cold-cache / JWKS-outage: fail open (the edge is still the gate) so a // Cloudflare certs blip can't take the whole read surface down. - Err(VerifyError::KeysUnavailable) => next.run(req).await, - Err(e @ VerifyError::Invalid(_)) => { - tracing::warn!(path = %req.uri().path(), "rejecting request: {e}"); + Assertion::KeysUnavailable => next.run(req).await, + Assertion::Missing => { + tracing::warn!( + path = %req.uri().path(), + "rejecting request with no {ACCESS_JWT_HEADER} header", + ); + (StatusCode::UNAUTHORIZED, "missing Cloudflare Access token").into_response() + } + Assertion::Invalid(why) => { + tracing::warn!(path = %req.uri().path(), "rejecting request: invalid Access token: {why}"); (StatusCode::UNAUTHORIZED, "invalid Cloudflare Access token").into_response() } } @@ -175,7 +208,7 @@ pub fn app_with_access(pool: PgPool, access: Option>) -> Router { } /// Build the HTTP router, optionally enforcing Cloudflare Access JWT verification -/// (JEF-473) on the read surface and Bearer auth (JEF-472) on `/mcp`. +/// (JEF-473) on the read surface and Managed-OAuth assertion auth (JEF-493) on `/mcp`. /// /// The server holds no app-layer auth by default — auth lives at the edge /// (Cloudflare Access for the public read surface) and ingest is only reachable @@ -186,9 +219,10 @@ pub fn app_with_access(pool: PgPool, access: Option>) -> Router { /// /// `/mcp` (when `WATCHER_MCP_ENABLED`) is served **only** when `mcp_auth` is `Some`: /// with no auth configured it is refused rather than exposed unauthenticated (fail -/// closed). Its Bearer guard and the unauthenticated resource-metadata documents are -/// wired here — outside the browser Access guard, since an MCP client is not a -/// browser. +/// closed). Its guard validates the same `Cf-Access-Jwt-Assertion` the edge forwards +/// after resolving the MCP client's opaque Managed-OAuth token — but with a distinct +/// AUD and failing closed. It is wired outside the browser Access guard since an MCP +/// client is not a browser. pub fn app_with_auth( pool: PgPool, access: Option>, @@ -245,36 +279,23 @@ pub fn app_with_auth( // Read-only MCP server (JEF-471), opt-in via WATCHER_MCP_ENABLED (default OFF). // Nested as its own tower service *outside* the `/api` router — and therefore // outside the browser Access guard above, since an MCP client is not a browser - // and carries no Access cookie. It gets its **own** Bearer auth (JEF-472): the - // transport is wrapped in `mcp_auth::bearer_guard`, and the unauthenticated - // discovery documents are served so a client can find the authorization server. + // and carries no Access cookie. Under Cloudflare Managed OAuth (JEF-493) the edge + // resolves the client's opaque OAuth token and forwards a `Cf-Access-Jwt-Assertion`; + // `mcp_auth::assertion_guard` validates that assertion (its own AUD, fail-closed). + // Cloudflare owns OAuth discovery, so no `.well-known` metadata is self-served. // // Fail closed: when MCP is enabled but no auth is configured, `/mcp` is not // mounted at all (the operator-facing error is logged in `main`) — we never // expose an unauthenticated MCP surface. if mcp::enabled() { if let Some(auth) = mcp_auth { + // The MCP transport behind the assertion guard. Nesting before + // `with_state` keeps the service off `with_state` (it carries its own pool). let auth = Arc::new(auth); - - // Discovery (RFC 9728): served unauthenticated — the client fetches this - // *before* it has a token. Explicit routes take precedence over the SPA - // fallback, and they sit outside the Access guard. The root path is an - // alias some clients probe. - for path in [mcp_auth::METADATA_PATH, mcp_auth::METADATA_PATH_ROOT] { - let md = auth.clone(); - router = router.route( - path, - get(move |headers| mcp_auth::protected_resource_metadata(md.clone(), headers)), - ); - } - - // The MCP transport behind the Bearer guard. Nesting before `with_state` - // keeps the service off `with_state` (it carries its own pool). - let guard = auth.clone(); let guarded_mcp = Router::new() .nest_service("/mcp", mcp::service(pool.clone())) .layer(axum::middleware::from_fn(move |req, next| { - mcp_auth::bearer_guard(guard.clone(), req, next) + mcp_auth::assertion_guard(auth.clone(), req, next) })); router = router.merge(guarded_mcp); } diff --git a/server/src/main.rs b/server/src/main.rs index f982ca0..1f60950 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -190,12 +190,15 @@ async fn main() -> anyhow::Result<()> { } // Read-only MCP server (JEF-471): mounted at /mcp by `app_with_access` only when - // WATCHER_MCP_ENABLED is set (default OFF). Its Bearer auth (JEF-472) requires - // WATCHER_ACCESS_TEAM_DOMAIN + WATCHER_MCP_ACCESS_AUD; with those unset the - // endpoint fails closed (is NOT served) rather than exposing read access. + // WATCHER_MCP_ENABLED is set (default OFF). Its Managed-OAuth assertion auth + // (JEF-493) requires WATCHER_ACCESS_TEAM_DOMAIN + WATCHER_MCP_ACCESS_AUD; with + // those unset the endpoint fails closed (is NOT served) rather than exposing read + // access. if mcp::enabled() { if mcp_auth::McpAuth::from_env().is_some() { - tracing::info!("MCP server (read-only) enabled at /mcp with Access Bearer auth"); + tracing::info!( + "MCP server (read-only) enabled at /mcp with Cloudflare Access assertion auth" + ); } else { tracing::error!( "WATCHER_MCP_ENABLED is set but MCP auth is unconfigured \ diff --git a/server/src/mcp.rs b/server/src/mcp.rs index 56153ad..974f6af 100644 --- a/server/src/mcp.rs +++ b/server/src/mcp.rs @@ -11,9 +11,11 @@ //! `/mcp` is gated behind `WATCHER_MCP_ENABLED` (default OFF) and only mounted //! when enabled. It mounts *outside* the browser-cookie edge auth the UI/`/api` sit //! behind (Cloudflare Access, ADR 0013): an MCP client is not a browser and carries -//! no Access cookie. Its auth is its own `Authorization: Bearer` Access token, -//! validated by [`crate::mcp_auth`] (JEF-472) — `app_with_access` wraps this service -//! in that guard and refuses to serve `/mcp` when the guard is unconfigured. +//! no Access cookie. Under Cloudflare Managed OAuth the edge resolves the client's +//! opaque OAuth token and forwards a `Cf-Access-Jwt-Assertion`, which +//! [`crate::mcp_auth`] validates (JEF-493, its own AUD) — `app_with_access` wraps +//! this service in that guard and refuses to serve `/mcp` when the guard is +//! unconfigured. use std::sync::Arc; @@ -345,9 +347,10 @@ pub fn service(pool: PgPool) -> StreamableHttpService` -//! whose token is a Cloudflare Access-issued JWT minted for a **dedicated** Access -//! application (its own AUD, distinct from the browser app's). The origin only ever -//! **validates** that token — it never mints one (ADR 0013) — reusing the shared -//! [`access_jwt::Verifier`] (RS256 + JWKS + `iss`/`aud`/`exp`). +//! Access cookie. The chosen production mechanism is Cloudflare Access **Managed +//! OAuth**: Cloudflare is the OAuth authorization server the MCP client (claude.ai's +//! remote connector) registers with (DCR) and obtains an **opaque** token from; +//! Cloudflare resolves that token at its edge and forwards the origin the standard +//! **`Cf-Access-Jwt-Assertion`** JWT — the *same* header/issuer/team-JWKS that `/api` +//! validates (JEF-473). So the origin only ever **validates** that assertion — it +//! never mints one (ADR 0013), never parses the opaque `Authorization: Bearer`, and +//! never self-serves OAuth metadata (Cloudflare owns discovery). +//! +//! This supersedes JEF-472's design, which validated the raw `Authorization: Bearer` +//! as a JWT (now the *opaque* Managed-OAuth token → would be rejected) and self-served +//! `/.well-known/oauth-protected-resource`. See ADR 0019. //! //! ## Fail **closed** — unlike the browser guard //! //! The `/api` guard ([`crate::access_guard`]) is *defense-in-depth* behind the edge, //! so on a cold-cache JWKS outage it fails **open** (the edge stays the gate). `/mcp` //! is the **primary** and only auth on that surface, so it fails **closed**: a missing, -//! invalid, expired, wrong-audience *or* unverifiable (JWKS-unavailable) token is +//! invalid, expired, wrong-audience *or* unverifiable (JWKS-unavailable) assertion is //! rejected `401`. It must never admit an unauthenticated client. -//! -//! ## Discovery (MCP auth spec, 2025-06-18 / RFC 9728) -//! -//! A `401` carries a `WWW-Authenticate: Bearer` challenge pointing at -//! `/.well-known/oauth-protected-resource`, whose JSON names the Cloudflare Access -//! OIDC authorization server. That lets a spec-compliant MCP client discover where to -//! obtain a token. The metadata document is served **unauthenticated** (the client -//! fetches it *before* it has a token). use std::sync::Arc; use axum::{ body::Body, - http::{header, HeaderMap, Request, StatusCode}, + http::{header, Request, StatusCode}, middleware::Next, response::{IntoResponse, Response}, - Json, }; -use serde_json::json; -use crate::access_jwt::{Verifier, VerifyError}; +use crate::access_jwt::Verifier; +use crate::Assertion; /// The Access application AUD tag for the MCP app. Distinct from the browser app's -/// `WATCHER_ACCESS_AUD` so a browser-scoped token can't be replayed at `/mcp`. +/// `WATCHER_ACCESS_AUD` so a browser-scoped assertion can't be replayed at `/mcp`. const MCP_AUD_ENV: &str = "WATCHER_MCP_ACCESS_AUD"; /// The Cloudflare Access team domain, shared with the browser guard (JEF-473). const TEAM_DOMAIN_ENV: &str = "WATCHER_ACCESS_TEAM_DOMAIN"; -/// Path of the OAuth Protected Resource Metadata document (RFC 9728 §3.1, with the -/// resource's `/mcp` path suffixed as the spec prescribes for a non-root resource). -pub const METADATA_PATH: &str = "/.well-known/oauth-protected-resource/mcp"; - -/// A root-level alias for the same document. Some clients probe the un-suffixed path. -pub const METADATA_PATH_ROOT: &str = "/.well-known/oauth-protected-resource"; +/// Opt-in toggle: emit a bare `WWW-Authenticate: Bearer` challenge on a `401`. +/// Default OFF — under Managed OAuth the Cloudflare edge owns the OAuth +/// challenge/discovery, so the origin normally stays silent (a client that reaches +/// the origin already cleared the edge). A live Claude-connector test will tell us +/// whether Cloudflare intercepts the challenge or the origin must emit it; flip this +/// on (`WATCHER_MCP_WWW_AUTHENTICATE=1`) if the origin turns out to need it. +const WWW_AUTHENTICATE_ENV: &str = "WATCHER_MCP_WWW_AUTHENTICATE"; -/// Auth context for `/mcp`: the token verifier plus the authorization-server URL -/// advertised to clients. Built once and shared (cheap `Arc` clone) across the guard -/// middleware and the metadata handler. +/// Auth context for `/mcp`: the Access-assertion verifier plus the challenge toggle. +/// Built once and shared (cheap `Arc` clone) across the guard middleware. pub struct McpAuth { verifier: Arc, - /// The Cloudflare Access OIDC authorization server (the team domain issuer). - authorization_server: String, + /// When set, a `401` carries a bare `WWW-Authenticate: Bearer` challenge (see + /// [`WWW_AUTHENTICATE_ENV`]). Default OFF: Cloudflare owns OAuth discovery. + emit_challenge: bool, } impl McpAuth { /// Construct from an explicit verifier (used by tests pointing at a local JWKS). - /// The authorization server advertised in metadata is the verifier's issuer. + /// The `WWW-Authenticate` challenge is off — matching the production default. pub fn new(verifier: Arc) -> Self { - let authorization_server = verifier.issuer().to_string(); Self { verifier, - authorization_server, + emit_challenge: false, } } @@ -80,7 +78,25 @@ impl McpAuth { pub fn from_env() -> Option { let team = env_nonempty(TEAM_DOMAIN_ENV)?; let aud = env_nonempty(MCP_AUD_ENV)?; - Some(Self::new(Arc::new(Verifier::for_team(&team, aud)))) + Some(Self { + verifier: Arc::new(Verifier::for_team(&team, aud)), + emit_challenge: env_flag(WWW_AUTHENTICATE_ENV), + }) + } + + /// A `401` for a rejected `/mcp` request, optionally carrying a bare + /// `WWW-Authenticate: Bearer` challenge (see [`Self::emit_challenge`]). + fn unauthorized(&self) -> Response { + if self.emit_challenge { + ( + StatusCode::UNAUTHORIZED, + [(header::WWW_AUTHENTICATE, "Bearer")], + "unauthorized", + ) + .into_response() + } else { + (StatusCode::UNAUTHORIZED, "unauthorized").into_response() + } } } @@ -91,140 +107,64 @@ fn env_nonempty(key: &str) -> Option { .filter(|s| !s.is_empty()) } -/// axum middleware: require a valid Access Bearer token on `/mcp`, else `401` with a -/// resource-metadata discovery challenge. Fails **closed** (see the module docs). -pub async fn bearer_guard(auth: Arc, req: Request, next: Next) -> Response { - let token = req - .headers() - .get(header::AUTHORIZATION) - .and_then(|v| v.to_str().ok()) - .and_then(bearer_token); - - let Some(token) = token else { - tracing::warn!("rejecting MCP request: missing/!Bearer Authorization header"); - return challenge( - req.headers(), - "a Cloudflare Access Bearer token is required", - ); - }; +/// Parse a truthy env flag (`1`/`true`/`on`); anything else (incl. unset) is false. +fn env_flag(key: &str) -> bool { + std::env::var(key) + .map(|v| matches!(v.trim(), "1" | "true" | "on")) + .unwrap_or(false) +} - match auth.verifier.verify(token).await { - Ok(_claims) => next.run(req).await, +/// axum middleware: require a valid `Cf-Access-Jwt-Assertion` on `/mcp`, else `401`. +/// Reuses the shared [`crate::check_access_assertion`] the `/api` guard runs, but +/// fails **closed** (see the module docs): unlike `/api` it rejects even a +/// JWKS-unavailable assertion, since `/mcp` has no edge auth behind it. +pub async fn assertion_guard(auth: Arc, req: Request, next: Next) -> Response { + let detail = match crate::check_access_assertion(&auth.verifier, req.headers()).await { + Assertion::Valid => return next.run(req).await, + Assertion::Missing => "missing Cf-Access-Jwt-Assertion header".to_string(), + Assertion::Invalid(why) => format!("invalid Access assertion: {why}"), // MCP is the primary auth (not defense-in-depth), so an unresolvable JWKS // must fail CLOSED — never admit an unverified client on a certs outage. - Err(e @ VerifyError::KeysUnavailable) => { - tracing::warn!("rejecting MCP request (fail closed): {e}"); - challenge(req.headers(), "token could not be verified") - } - Err(e @ VerifyError::Invalid(_)) => { - tracing::warn!("rejecting MCP request: {e}"); - challenge(req.headers(), "the Bearer token is invalid or expired") + Assertion::KeysUnavailable => { + "Access assertion could not be verified (JWKS unavailable)".to_string() } - } -} - -/// Handler for the OAuth Protected Resource Metadata document (RFC 9728). Points the -/// client at the Cloudflare Access OIDC authorization server. Served unauthenticated. -pub async fn protected_resource_metadata( - auth: Arc, - headers: HeaderMap, -) -> Json { - let base = base_url(&headers); - Json(json!({ - "resource": format!("{base}/mcp"), - "authorization_servers": [auth.authorization_server], - "bearer_methods_supported": ["header"], - })) -} - -/// Extract the token from an `Authorization: Bearer ` value (scheme match is -/// case-insensitive per RFC 7235), or `None` when it isn't a non-empty Bearer. -fn bearer_token(value: &str) -> Option<&str> { - let (scheme, token) = value.split_once(' ')?; - if !scheme.eq_ignore_ascii_case("bearer") { - return None; - } - let token = token.trim(); - (!token.is_empty()).then_some(token) -} - -/// A `401` carrying the `WWW-Authenticate: Bearer` discovery challenge, pointing the -/// client at the resource-metadata document (derived from the request's host, so it's -/// correct behind whatever tunnel host fronts the pod). `detail` is a fixed, -/// code-supplied string (never caller input), so it can't inject header bytes. -fn challenge(headers: &HeaderMap, detail: &str) -> Response { - let metadata_url = format!("{}{METADATA_PATH}", base_url(headers)); - let www = format!( - "Bearer error=\"invalid_token\", error_description=\"{detail}\", \ - resource_metadata=\"{metadata_url}\"" - ); - ( - StatusCode::UNAUTHORIZED, - [(header::WWW_AUTHENTICATE, www)], - "unauthorized", - ) - .into_response() -} - -/// Reconstruct the request's origin (`scheme://host`) from headers. Behind Cloudflare -/// the origin sees the public host via `Host` and the scheme via `X-Forwarded-Proto`. -/// The host is sanitized to a conservative charset so a hostile `Host` can't smuggle -/// junk into the metadata/challenge we echo back; anything odd falls back to a -/// placeholder rather than being reflected. -fn base_url(headers: &HeaderMap) -> String { - let host = headers - .get(header::HOST) - .and_then(|v| v.to_str().ok()) - .filter(|h| is_sane_host(h)) - .unwrap_or("localhost"); - let scheme = headers - .get("x-forwarded-proto") - .and_then(|v| v.to_str().ok()) - .and_then(|v| v.split(',').next()) - .map(str::trim) - .filter(|s| *s == "http" || *s == "https") - .unwrap_or("https"); - format!("{scheme}://{host}") -} - -/// A permissive but safe host check: DNS labels, IPv6 literals, and an optional port. -fn is_sane_host(host: &str) -> bool { - !host.is_empty() - && host.len() <= 255 - && host - .bytes() - .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'-' | b':' | b'[' | b']')) + }; + tracing::warn!("rejecting MCP request (fail closed): {detail}"); + auth.unauthorized() } #[cfg(test)] mod tests { use super::*; - #[test] - fn bearer_token_parses_case_insensitively() { - assert_eq!(bearer_token("Bearer abc"), Some("abc")); - assert_eq!(bearer_token("bearer abc "), Some("abc")); - assert_eq!(bearer_token("Basic abc"), None); - assert_eq!(bearer_token("Bearer "), None); - assert_eq!(bearer_token("abc"), None); + fn auth(emit_challenge: bool) -> McpAuth { + McpAuth { + verifier: Arc::new(Verifier::new( + "https://team.cloudflareaccess.com", + "http://127.0.0.1:1/certs", + "aud", + )), + emit_challenge, + } } #[test] - fn base_url_uses_host_and_forwarded_proto() { - let mut h = HeaderMap::new(); - h.insert(header::HOST, "watcher.example.com".parse().unwrap()); - assert_eq!(base_url(&h), "https://watcher.example.com"); - h.insert("x-forwarded-proto", "http".parse().unwrap()); - assert_eq!(base_url(&h), "http://watcher.example.com"); + fn unauthorized_omits_challenge_by_default() { + let resp = auth(false).unauthorized(); + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); + assert!( + !resp.headers().contains_key(header::WWW_AUTHENTICATE), + "Cloudflare owns discovery by default — origin stays silent" + ); } #[test] - fn base_url_rejects_hostile_host_and_proto() { - let mut h = HeaderMap::new(); - // A header value can't carry CRLF (hyper rejects it), but odd bytes still get - // scrubbed to the placeholder rather than reflected into the response. - h.insert(header::HOST, "evil host/with space".parse().unwrap()); - h.insert("x-forwarded-proto", "javascript".parse().unwrap()); - assert_eq!(base_url(&h), "https://localhost"); + fn unauthorized_emits_bare_bearer_challenge_when_toggled() { + let resp = auth(true).unauthorized(); + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); + assert_eq!( + resp.headers().get(header::WWW_AUTHENTICATE).unwrap(), + "Bearer" + ); } } diff --git a/server/tests/smoke.rs b/server/tests/smoke.rs index 0c2a919..3fcdcb1 100644 --- a/server/tests/smoke.rs +++ b/server/tests/smoke.rs @@ -2658,19 +2658,21 @@ async fn access_unconfigured_leaves_api_open() { ); } -// --- MCP server + auth (JEF-471 / JEF-472) --------------------------------- +// --- MCP server + auth (JEF-471 / JEF-493) --------------------------------- // -// `/mcp` authenticates with an `Authorization: Bearer ` Cloudflare Access -// JWT minted for a DEDICATED Access app (its own AUD, distinct from the browser -// app's) — validated by the shared `access_jwt::Verifier`. These tests reuse the -// browser-auth test key/JWKS but sign with the MCP AUD, and prove the 401/200 -// matrix, the resource-metadata discovery document, and the fail-closed refusal -// when `/mcp` is enabled without auth configured. +// Under Cloudflare Managed OAuth the edge resolves the MCP client's opaque OAuth +// token and forwards the origin the standard `Cf-Access-Jwt-Assertion` JWT — the +// SAME header `/api` validates (JEF-473), but minted for a DEDICATED Access app +// (its own AUD, distinct from the browser app's) and validated by the shared +// `access_jwt::Verifier`. These tests reuse the browser-auth test key/JWKS but sign +// with the MCP AUD, and prove the 401/200 matrix (fail-closed) and the fail-closed +// refusal when `/mcp` is enabled without auth configured. There is no self-served +// OAuth metadata — Cloudflare owns discovery. const MCP_AUD: &str = "smoke-mcp-aud-tag"; -/// Build the app with `/mcp` enabled and Bearer auth wired to a local JWKS (no -/// Cloudflare, no network), plus a freshly-signed valid MCP token. +/// Build the app with `/mcp` enabled and assertion auth wired to a local JWKS (no +/// Cloudflare, no network), plus a freshly-signed valid MCP assertion token. async fn mcp_app_with_auth(pool: sqlx::PgPool) -> (axum::Router, String) { let certs_url = spawn_jwks().await; let verifier = @@ -2686,20 +2688,18 @@ async fn mcp_app_with_auth(pool: sqlx::PgPool) -> (axum::Router, String) { (router, access_token(ACCESS_ISSUER, MCP_AUD, 3600)) } -/// POST an `initialize` frame to `/mcp` with an optional Bearer token, returning the -/// status, the `WWW-Authenticate` challenge (if any), and whether the MCP transport -/// admitted the request (it sets an `mcp-session-id` header on a live session). -async fn mcp_post( - router: &axum::Router, - bearer: Option<&str>, -) -> (StatusCode, Option, bool) { +/// POST an `initialize` frame to `/mcp` with an optional `Cf-Access-Jwt-Assertion` +/// (the header Cloudflare's edge forwards after resolving the client's opaque OAuth +/// token), returning the status and whether the MCP transport admitted the request +/// (it sets an `mcp-session-id` header on a live session). +async fn mcp_post(router: &axum::Router, assertion: Option<&str>) -> (StatusCode, bool) { let mut builder = Request::builder() .method("POST") .uri("/mcp") .header("content-type", "application/json") .header("accept", "application/json, text/event-stream"); - if let Some(t) = bearer { - builder = builder.header("authorization", format!("Bearer {t}")); + if let Some(t) = assertion { + builder = builder.header("Cf-Access-Jwt-Assertion", t); } let body = r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"smoke","version":"0"}}}"#; let resp = router @@ -2708,47 +2708,38 @@ async fn mcp_post( .await .unwrap(); let status = resp.status(); - let www = resp - .headers() - .get("www-authenticate") - .and_then(|v| v.to_str().ok()) - .map(str::to_string); let has_session = resp.headers().contains_key("mcp-session-id"); - (status, www, has_session) + (status, has_session) } #[tokio::test] #[serial] -async fn mcp_bearer_auth_401_matrix() { +async fn mcp_assertion_auth_401_matrix() { let Some(pool) = pool_or_skip().await else { return; }; let (router, valid) = mcp_app_with_auth(pool).await; - // Missing token → 401 with a resource-metadata discovery challenge (NOT a - // login redirect), and the transport never saw the request. - let (status, www, session) = mcp_post(&router, None).await; - assert_eq!(status, StatusCode::UNAUTHORIZED, "missing Bearer must 401"); - let www = www.expect("401 must carry a WWW-Authenticate challenge"); - assert!( - www.starts_with("Bearer "), - "challenge must be a Bearer scheme" - ); - assert!( - www.contains("resource_metadata=") && www.contains(mcp_auth::METADATA_PATH), - "challenge must point at the resource metadata: {www}" + // Missing assertion → 401, and the transport never saw the request. By default + // (Managed OAuth) the origin emits no WWW-Authenticate — Cloudflare owns the + // OAuth challenge/discovery. + let (status, session) = mcp_post(&router, None).await; + assert_eq!( + status, + StatusCode::UNAUTHORIZED, + "missing assertion must 401" ); assert!(!session, "a rejected request must not open an MCP session"); - // Garbage token → 401. + // Garbage assertion → 401. assert_eq!( mcp_post(&router, Some("not-a-jwt")).await.0, StatusCode::UNAUTHORIZED, - "garbage Bearer must 401" + "garbage assertion must 401" ); // Well-signed but minted for a DIFFERENT Access app (browser AUD) → 401. This is - // the crux: an MCP token must be scoped to the MCP app's own AUD. + // the crux: an MCP assertion must be scoped to the MCP app's own AUD. let wrong_aud = access_token(ACCESS_ISSUER, ACCESS_AUD, 3600); assert_eq!( mcp_post(&router, Some(&wrong_aud)).await.0, @@ -2756,56 +2747,29 @@ async fn mcp_bearer_auth_401_matrix() { "a token for the browser Access app must not be accepted at /mcp" ); - // Expired MCP token → 401. + // Expired MCP assertion → 401. let expired = access_token(ACCESS_ISSUER, MCP_AUD, -3600); assert_eq!( mcp_post(&router, Some(&expired)).await.0, StatusCode::UNAUTHORIZED, - "expired Bearer must 401" + "expired assertion must 401" ); - // Valid MCP token → the guard admits it (not 401); the request reaches the MCP - // transport. Full functional proof (handshake + tool calls) is the authenticated - // end-to-end client test below. - let (status, _, _) = mcp_post(&router, Some(&valid)).await; + // Valid MCP assertion → the guard admits it (not 401); the request reaches the + // MCP transport. Full functional proof (handshake + tool calls) is the + // authenticated end-to-end client test below. + let (status, _) = mcp_post(&router, Some(&valid)).await; assert_ne!( status, StatusCode::UNAUTHORIZED, - "valid Bearer must not 401" + "valid assertion must not 401" ); assert!( status.is_success() || status.is_client_error(), - "valid Bearer reaches the transport, not a 5xx: {status}" + "valid assertion reaches the transport, not a 5xx: {status}" ); } -#[tokio::test] -#[serial] -async fn mcp_serves_protected_resource_metadata_unauthenticated() { - let Some(pool) = pool_or_skip().await else { - return; - }; - let (router, _valid) = mcp_app_with_auth(pool).await; - - // The discovery document is served WITHOUT a token (the client fetches it before - // it has one) and names the Cloudflare Access OIDC authorization server. - for path in [mcp_auth::METADATA_PATH, mcp_auth::METADATA_PATH_ROOT] { - let (status, body) = get_json(&router, path).await; - assert_eq!(status, StatusCode::OK, "{path} must be served"); - assert_eq!( - body["authorization_servers"], - serde_json::json!([ACCESS_ISSUER]), - "{path} must point at the Access OIDC authorization server" - ); - assert!( - body["resource"] - .as_str() - .is_some_and(|r| r.ends_with("/mcp")), - "{path} resource must be the /mcp endpoint: {body}" - ); - } -} - #[tokio::test] #[serial] async fn mcp_fails_closed_when_auth_unconfigured() { @@ -2818,30 +2782,24 @@ async fn mcp_fails_closed_when_auth_unconfigured() { std::env::remove_var("WATCHER_MCP_ENABLED"); // The MCP surface is not mounted: an unauthenticated POST to /mcp is NOT met by - // the Bearer guard (which would 401) — it falls through to the SPA fallback, and - // opens no MCP session. There is simply no MCP endpoint to reach. - let (status, _www, session) = mcp_post(&router, None).await; + // the assertion guard (which would 401) — it falls through to the SPA fallback, + // and opens no MCP session. There is simply no MCP endpoint to reach. + let (status, session) = mcp_post(&router, None).await; assert_ne!( status, StatusCode::UNAUTHORIZED, - "fail-closed /mcp is unmounted — no Bearer guard present" + "fail-closed /mcp is unmounted — no assertion guard present" ); assert!(!session, "fail-closed /mcp must expose no MCP session"); - - // And the auth-discovery document is not served (no metadata → the surface is - // simply absent), regardless of whether a built UI answers the fallback. - let (_, body) = get_json(&router, mcp_auth::METADATA_PATH).await; - assert!( - body.get("authorization_servers").is_none(), - "fail-closed must not serve resource metadata: {body}" - ); } -/// End-to-end MCP smoke test: enable `/mcp` with Bearer auth, serve the real app on -/// an ephemeral port, and drive it with the official rmcp streamable-HTTP client -/// (authenticated with a valid Access token) — list the tools and call -/// `list_services` + `query_logs`, asserting the JSON shape. Multi-thread runtime so -/// the server accept-loop and client run concurrently. +/// End-to-end MCP smoke test: enable `/mcp` with assertion auth, serve the real app +/// on an ephemeral port, and drive it with the official rmcp streamable-HTTP client +/// — list the tools and call `list_services` + `query_logs`, asserting the JSON +/// shape. A tiny front layer injects the `Cf-Access-Jwt-Assertion` header on every +/// request, standing in for Cloudflare's edge (which resolves the client's opaque +/// OAuth token into that assertion). Multi-thread runtime so the server accept-loop +/// and client run concurrently. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[serial] async fn mcp_lists_tools_and_calls_read_queries() { @@ -2864,6 +2822,21 @@ async fn mcp_lists_tools_and_calls_read_queries() { let (router, token) = mcp_app_with_auth(pool).await; + // Stand in for Cloudflare's edge: inject the `Cf-Access-Jwt-Assertion` the origin + // validates on every request (the edge sets it after resolving the client's opaque + // Managed-OAuth token). The rmcp client itself sends no auth header. + let assertion: axum::http::HeaderValue = token.parse().unwrap(); + let router = router.layer(axum::middleware::from_fn( + move |mut req: axum::extract::Request, next: axum::middleware::Next| { + let assertion = assertion.clone(); + async move { + req.headers_mut() + .insert("Cf-Access-Jwt-Assertion", assertion); + next.run(req).await + } + }, + )); + // Serve on an ephemeral port so a real MCP client can drive the transport. let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); @@ -2871,10 +2844,7 @@ async fn mcp_lists_tools_and_calls_read_queries() { axum::serve(listener, router).await.unwrap(); }); - // The client attaches the Access Bearer token on every request (auth_header is - // the raw token; rmcp prefixes `Bearer `). - let config = StreamableHttpClientTransportConfig::with_uri(format!("http://{addr}/mcp")) - .auth_header(token); + let config = StreamableHttpClientTransportConfig::with_uri(format!("http://{addr}/mcp")); let transport = StreamableHttpClientTransport::from_config(config); let client = ().serve(transport).await.expect("mcp handshake");