From c1c33caab5a59900596e6aaaecc2c01e42b4bc74 Mon Sep 17 00:00:00 2001 From: Tanish Manku Date: Mon, 24 Aug 2026 20:29:23 -0700 Subject: [PATCH] =?UTF-8?q?mcp:=20wire=20the=20OAuth=20provider=20?= =?UTF-8?q?=E2=80=94=20lazy=20401,=20RFC=209728/8414=20metadata,=20DCR,=20?= =?UTF-8?q?CIMD,=20validating=20/authorize?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit src/auth/oauth.ts configures @cloudflare/workers-oauth-provider: /mcp and legacy /sse protected; /authorize (ours), /token and /register (library); CIMD enabled (with global_fetch_strictly_public in wrangler.toml); scopes read:account/read:jobs/read:catalog; 1h access, 30d idle refresh; error logging by code/category only. `resource` and `authorization_servers` are derived per request origin so one build serves every environment; production sets workers_dev = false so its only origin is canonical. src/index.ts checks the environment invariant first (a misconfigured deploy fails even /token), then hands off to the provider. src/runtime.ts holds the memoized config+app. src/auth/authorize.ts validates the OAuth request via parseAuthRequest, renders unknown-client errors locally and client-validated errors as redirects, and stops at 501 — it never auto-approves. src/mcp/handler.ts is a 501 stub reachable only with a valid token. Tests (10): 401 + resource_metadata challenge on /mcp and /sse, bogus bearer → 401 not 500, health still reachable; PRM at root and /mcp paths with derived resource and this origin as issuer; AS metadata with S256, CIMD flag, "none" auth method, iss support, registration and revocation endpoints; /authorize 400 locally on malformed and unknown client, 201 DCR then 501 with no redirect. pnpm check: typecheck, eslint, prettier, 87 tests passing. Claude-Session: https://claude.ai/code/session_01H3gfN2NCx3hBXSrZXYHUQ3 --- mcp/package.json | 1 + mcp/pnpm-lock.yaml | 8 ++ mcp/src/app.ts | 11 ++- mcp/src/auth/authorize.ts | 40 ++++++++++ mcp/src/auth/oauth.ts | 94 ++++++++++++++++++++++ mcp/src/index.ts | 57 ++++++-------- mcp/src/mcp/handler.ts | 19 +++++ mcp/src/oauth-env.d.ts | 13 ++++ mcp/src/runtime.ts | 30 ++++++++ mcp/test/auth/provider.test.ts | 137 +++++++++++++++++++++++++++++++++ mcp/wrangler.toml | 4 +- 11 files changed, 374 insertions(+), 40 deletions(-) create mode 100644 mcp/src/auth/authorize.ts create mode 100644 mcp/src/auth/oauth.ts create mode 100644 mcp/src/mcp/handler.ts create mode 100644 mcp/src/oauth-env.d.ts create mode 100644 mcp/src/runtime.ts create mode 100644 mcp/test/auth/provider.test.ts diff --git a/mcp/package.json b/mcp/package.json index c83f506c5f..af431380ab 100644 --- a/mcp/package.json +++ b/mcp/package.json @@ -23,6 +23,7 @@ "check": "pnpm typecheck && pnpm lint && pnpm format && pnpm test" }, "dependencies": { + "@cloudflare/workers-oauth-provider": "0.10.3", "hono": "4.13.4", "openapi-fetch": "0.17.0", "zod": "4.4.3" diff --git a/mcp/pnpm-lock.yaml b/mcp/pnpm-lock.yaml index 1d70217037..a07d41c870 100644 --- a/mcp/pnpm-lock.yaml +++ b/mcp/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@cloudflare/workers-oauth-provider': + specifier: 0.10.3 + version: 0.10.3 hono: specifier: 4.13.4 version: 4.13.4 @@ -149,6 +152,9 @@ packages: cpu: [x64] os: [win32] + '@cloudflare/workers-oauth-provider@0.10.3': + resolution: {integrity: sha512-25ufMONJir9PllqVpK4GwOOoSFgpYm3+bM6NBedj7ufMCIfNf5jk4OI0LPuTJBaJgLl2lGCLqFqEPJXcSuPopQ==} + '@cloudflare/workers-types@5.20260825.1': resolution: {integrity: sha512-/XZntbK+BlJWC5jxkaDNhnDLr2Bf2627sZ6VMrxqKrnc84pc6gNu5NdAyaTkZn1LzQVFIa3SL7V/7veLF59P7w==} @@ -1555,6 +1561,8 @@ snapshots: '@cloudflare/workerd-windows-64@1.20260820.1': optional: true + '@cloudflare/workers-oauth-provider@0.10.3': {} + '@cloudflare/workers-types@5.20260825.1': optional: true diff --git a/mcp/src/app.ts b/mcp/src/app.ts index 62f28424b6..fed2c6c222 100644 --- a/mcp/src/app.ts +++ b/mcp/src/app.ts @@ -1,13 +1,14 @@ import { Hono } from "hono"; +import { authorizeRoutes } from "./auth/authorize"; import type { Config } from "./config"; /** - * The HTTP surface of the Worker. Built once from a validated `Config`; routes are added here - * as milestones land (see mcp/CLAUDE.md → Architecture). + * The unprotected HTTP surface: health, the authorization endpoint, and (later) the pages. + * Protected MCP routes never reach this app — the OAuth provider owns them. */ -export function createApp(config: Config): Hono { - const app = new Hono(); +export function createApp(config: Config): Hono<{ Bindings: Cloudflare.Env }> { + const app = new Hono<{ Bindings: Cloudflare.Env }>(); app.get("/healthz", (c) => c.json({ @@ -16,5 +17,7 @@ export function createApp(config: Config): Hono { }), ); + app.route("/", authorizeRoutes); + return app; } diff --git a/mcp/src/auth/authorize.ts b/mcp/src/auth/authorize.ts new file mode 100644 index 0000000000..42b32819e8 --- /dev/null +++ b/mcp/src/auth/authorize.ts @@ -0,0 +1,40 @@ +import { AuthorizationError, type AuthRequest } from "@cloudflare/workers-oauth-provider"; +import { Hono } from "hono"; + +import { OAUTH_ENDPOINTS } from "./oauth"; + +/** + * The application-owned authorization endpoint. The provider validates the OAuth request + * (client, exact redirect URI, response type, resource, PKCE); this module owns what happens + * next: sign-in and consent, which arrive with the `Authenticator` seam in the next PR. + * + * Until then the endpoint validates and stops. It must never auto-approve. + */ +export const authorizeRoutes = new Hono<{ Bindings: Cloudflare.Env }>(); + +authorizeRoutes.get(OAUTH_ENDPOINTS.authorize, async (c) => { + let authRequest: AuthRequest; + try { + authRequest = await c.env.OAUTH_PROVIDER.parseAuthRequest(c.req.raw); + } catch (error) { + if (!(error instanceof AuthorizationError)) throw error; + // Only once the client and its exact registered redirect URI are validated may an error + // be sent back to the client; otherwise it is rendered locally. + if (!error.redirectUri) { + return c.text(`Invalid authorization request: ${error.description}`, 400); + } + const redirect = new URL(error.redirectUri); + redirect.searchParams.set("error", error.code); + redirect.searchParams.set("error_description", error.description); + if (error.state) redirect.searchParams.set("state", error.state); + if (error.issuer) redirect.searchParams.set("iss", error.issuer); + return c.redirect(redirect.toString(), 302); + } + + const client = await c.env.OAUTH_PROVIDER.lookupClient(authRequest.clientId); + if (!client) { + return c.text("Unknown OAuth client", 400); + } + + return c.text("Sign-in is not available yet.", 501); +}); diff --git a/mcp/src/auth/oauth.ts b/mcp/src/auth/oauth.ts new file mode 100644 index 0000000000..fa9b1bba63 --- /dev/null +++ b/mcp/src/auth/oauth.ts @@ -0,0 +1,94 @@ +import { OAuthProvider, type OAuthProviderOptions } from "@cloudflare/workers-oauth-provider"; + +/** + * Configuration of the OAuth 2.1 authorization server that fronts the MCP endpoint. The + * library (`@cloudflare/workers-oauth-provider`) owns discovery metadata, client registration + * (CIMD and DCR), PKCE, the token endpoint with refresh rotation, and revocation. We own the + * `/authorize` UI (sign-in + consent, see auth/authorize.ts) and the protected MCP handler. + * + * Everything here is static per Worker: the public origin is taken from each request by the + * library, so one configuration serves local, preview, and production unchanged. + */ + +export const OAUTH_ENDPOINTS = { + authorize: "/authorize", + token: "/token", + register: "/register", +} as const; + +/** Protected route prefixes. `/sse` is the legacy transport some clients still default to. */ +export const MCP_ROUTES = ["/mcp", "/sse"] as const; + +/** Scopes a grant may carry in M1. `generate` is deliberately absent until it has a design. */ +export const SCOPES = ["read:account", "read:jobs", "read:catalog"] as const; +export type Scope = (typeof SCOPES)[number]; + +/** Access tokens are short-lived; refresh tokens rotate and expire after this much idle time. */ +export const ACCESS_TOKEN_TTL_SECONDS = 60 * 60; +export const REFRESH_TOKEN_TTL_SECONDS = 30 * 24 * 60 * 60; + +/** + * Absolute lifetime of a grant regardless of refresh activity. The upstream session cannot + * be refreshed, only held or deleted, so grants must re-consent eventually; this is also the + * runway for swapping the upstream credential later (mcp/CLAUDE.md → Lifetimes). + */ +export const GRANT_MAX_AGE_SECONDS = 90 * 24 * 60 * 60; + +export const RESOURCE_NAME = "Artcraft"; + +/** An ExportedHandler that definitely has `fetch` — what the provider accepts for handlers. */ +export type FetchHandler = ExportedHandler & { + fetch: NonNullable["fetch"]>; +}; + +export interface OAuthHandlers { + /** Unprotected routes: `/authorize`, pages, health. */ + readonly defaultHandler: FetchHandler; + /** The MCP transport; only reached with a valid access token. */ + readonly apiHandler: FetchHandler; +} + +/** Exported separately so tests can call `getOAuthApi(options, env)` with the same options. */ +export function createOAuthProviderOptions(handlers: OAuthHandlers): OAuthProviderOptions { + return { + apiRoute: [...MCP_ROUTES], + apiHandler: handlers.apiHandler, + defaultHandler: handlers.defaultHandler, + + authorizeEndpoint: OAUTH_ENDPOINTS.authorize, + tokenEndpoint: OAUTH_ENDPOINTS.token, + // DCR stays enabled as the fallback for clients without CIMD support. + clientRegistrationEndpoint: OAUTH_ENDPOINTS.register, + // Preferred registration for Claude and ChatGPT; needs `global_fetch_strictly_public`. + clientIdMetadataDocumentEnabled: true, + + scopesSupported: [...SCOPES], + resourceMetadata: { + // `resource` and `authorization_servers` are derived from the request origin, so the + // same build serves every environment. Production has exactly one public origin + // (workers_dev = false), which is what makes the derived resource canonical. + scopes_supported: [...SCOPES], + bearer_methods_supported: ["header"], + resource_name: RESOURCE_NAME, + }, + + accessTokenTTL: ACCESS_TOKEN_TTL_SECONDS, + refreshTokenTTL: REFRESH_TOKEN_TTL_SECONDS, + + onError(error) { + // Codes and categories only: descriptions can echo client-supplied input. + console.warn( + JSON.stringify({ + event: "oauth_error", + code: error.code, + status: error.status, + category: error.internal?.category, + }), + ); + }, + }; +} + +export function createOAuthProvider(handlers: OAuthHandlers): OAuthProvider { + return new OAuthProvider(createOAuthProviderOptions(handlers)); +} diff --git a/mcp/src/index.ts b/mcp/src/index.ts index 3a7e45feb8..667f4c6e9f 100644 --- a/mcp/src/index.ts +++ b/mcp/src/index.ts @@ -1,48 +1,35 @@ -import type { Hono } from "hono"; - -import { createApp } from "./app"; -import { ConfigError, loadConfig } from "./config"; +import { createOAuthProvider } from "./auth/oauth"; +import { ConfigError } from "./config"; +import { mcpApiHandler } from "./mcp/handler"; +import { getRuntime } from "./runtime"; /** - * Worker entry point. The app is built once per isolate from the bindings. If the bindings - * violate the environment invariant, every request fails with a 500 that names the problem — - * a misconfigured deploy must be impossible to miss. + * Worker entry point. Every request first passes the environment invariant (a misconfigured + * deploy fails everything, including the OAuth endpoints), then the OAuth provider routes it: + * protected MCP routes require a valid access token; everything else reaches the Hono app. */ -let cachedApp: Hono | undefined; -let cachedFailure: ConfigError | undefined; - -function getApp(env: unknown): Hono { - if (cachedApp) return cachedApp; - if (cachedFailure) throw cachedFailure; - try { - cachedApp = createApp(loadConfig(env)); - return cachedApp; - } catch (error) { - if (error instanceof ConfigError) { - cachedFailure = error; - } - throw error; - } -} +const provider = createOAuthProvider({ + defaultHandler: { + fetch: (request, env, ctx) => getRuntime(env).app.fetch(request, env, ctx), + }, + apiHandler: mcpApiHandler, +}); export default { - fetch( - request: Request, - env: Cloudflare.Env, - ctx: ExecutionContext, - ): Response | Promise { - let app: Hono; + fetch(request: Request, env: Cloudflare.Env, ctx: ExecutionContext): Promise { try { - app = getApp(env); + getRuntime(env); } catch (error) { if (error instanceof ConfigError) { - return new Response(`Worker misconfigured: ${error.message}`, { - status: 500, - headers: { "content-type": "text/plain; charset=utf-8" }, - }); + return Promise.resolve( + new Response(`Worker misconfigured: ${error.message}`, { + status: 500, + headers: { "content-type": "text/plain; charset=utf-8" }, + }), + ); } throw error; } - return app.fetch(request, env, ctx); + return provider.fetch(request, env, ctx); }, } satisfies ExportedHandler; diff --git a/mcp/src/mcp/handler.ts b/mcp/src/mcp/handler.ts new file mode 100644 index 0000000000..e4a978f9cd --- /dev/null +++ b/mcp/src/mcp/handler.ts @@ -0,0 +1,19 @@ +import type { FetchHandler } from "../auth/oauth"; + +/** + * The protected MCP route. The OAuth provider only invokes this after validating a bearer + * token, and exposes the decrypted grant props on `ctx.props`. The transport itself lands in + * a later PR; until then this answers 501 so a valid token can be proven end to end without + * anything being callable. + */ +export const mcpApiHandler: FetchHandler = { + fetch(_request, _env, ctx) { + const props: unknown = (ctx as ExecutionContext & { props?: unknown }).props; + return Promise.resolve( + Response.json( + { error: "MCP transport not available yet", authenticated: props !== undefined }, + { status: 501 }, + ), + ); + }, +}; diff --git a/mcp/src/oauth-env.d.ts b/mcp/src/oauth-env.d.ts new file mode 100644 index 0000000000..93adbff9b3 --- /dev/null +++ b/mcp/src/oauth-env.d.ts @@ -0,0 +1,13 @@ +import type { OAuthHelpers } from "@cloudflare/workers-oauth-provider"; + +// The provider injects its helpers into `env` for every handler it invokes. The generated +// worker-configuration.d.ts knows only the wrangler.toml bindings, so add this one here. +declare global { + namespace Cloudflare { + interface Env { + OAUTH_PROVIDER: OAuthHelpers; + } + } +} + +export {}; diff --git a/mcp/src/runtime.ts b/mcp/src/runtime.ts new file mode 100644 index 0000000000..f48f640f63 --- /dev/null +++ b/mcp/src/runtime.ts @@ -0,0 +1,30 @@ +import type { Hono } from "hono"; + +import { createApp } from "./app"; +import { type Config, ConfigError, loadConfig } from "./config"; + +/** + * Per-isolate state built once from the bindings. If the bindings violate the environment + * invariant, the failure is memoized and rethrown on every call so a misconfigured deploy + * can never serve a single successful request. + */ +export interface Runtime { + readonly config: Config; + readonly app: Hono<{ Bindings: Cloudflare.Env }>; +} + +let cachedRuntime: Runtime | undefined; +let cachedFailure: ConfigError | undefined; + +export function getRuntime(env: unknown): Runtime { + if (cachedRuntime) return cachedRuntime; + if (cachedFailure) throw cachedFailure; + try { + const config = loadConfig(env); + cachedRuntime = { config, app: createApp(config) }; + return cachedRuntime; + } catch (error) { + if (error instanceof ConfigError) cachedFailure = error; + throw error; + } +} diff --git a/mcp/test/auth/provider.test.ts b/mcp/test/auth/provider.test.ts new file mode 100644 index 0000000000..9a19122651 --- /dev/null +++ b/mcp/test/auth/provider.test.ts @@ -0,0 +1,137 @@ +import { createExecutionContext, waitOnExecutionContext } from "cloudflare:test"; +import { env } from "cloudflare:workers"; +import { describe, expect, it } from "vitest"; + +import { MCP_ROUTES, OAUTH_ENDPOINTS, SCOPES } from "../../src/auth/oauth"; +import worker from "../../src/index"; + +const ORIGIN = "https://mcp.test"; + +async function call(path: string, init?: RequestInit): Promise { + const ctx = createExecutionContext(); + const response = await worker.fetch(new Request(`${ORIGIN}${path}`, init), env, ctx); + await waitOnExecutionContext(ctx); + return response; +} + +async function json(path: string): Promise> { + const response = await call(path); + expect(response.status).toBe(200); + return response.json>(); +} + +describe("lazy authentication on the MCP route", () => { + it.each(MCP_ROUTES)("answers %s with 401 and a resource_metadata challenge", async (route) => { + const response = await call(route, { method: "POST" }); + expect(response.status).toBe(401); + const challenge = response.headers.get("www-authenticate") ?? ""; + expect(challenge).toMatch(/^Bearer /); + expect(challenge).toContain( + `resource_metadata="${ORIGIN}/.well-known/oauth-protected-resource${route}"`, + ); + }); + + it("rejects a made-up bearer token with 401, not 500", async () => { + const response = await call("/mcp", { + method: "POST", + headers: { authorization: "Bearer not-a-real-token" }, + }); + expect(response.status).toBe(401); + }); + + it("keeps the unprotected routes reachable", async () => { + const response = await call("/healthz"); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ ok: true, environment: "local" }); + }); +}); + +describe("protected resource metadata (RFC 9728)", () => { + it("is served at the root well-known path with a derived resource and this origin as issuer", async () => { + const metadata = await json("/.well-known/oauth-protected-resource"); + expect(metadata.authorization_servers).toEqual([ORIGIN]); + expect(metadata.scopes_supported).toEqual([...SCOPES]); + expect(metadata.bearer_methods_supported).toEqual(["header"]); + expect(metadata.resource_name).toBe("Artcraft"); + expect(typeof metadata.resource).toBe("string"); + expect(metadata.resource).toMatch(new RegExp(`^${ORIGIN}`)); + }); + + it("derives the MCP endpoint as the resource on the path-specific document", async () => { + const metadata = await json("/.well-known/oauth-protected-resource/mcp"); + expect(metadata.resource).toBe(`${ORIGIN}/mcp`); + expect(metadata.authorization_servers).toEqual([ORIGIN]); + }); +}); + +describe("authorization server metadata (RFC 8414)", () => { + it("advertises exactly what Claude and ChatGPT need to pick CIMD and S256 PKCE", async () => { + const metadata = await json("/.well-known/oauth-authorization-server"); + expect(metadata.issuer).toBe(ORIGIN); + expect(metadata.authorization_endpoint).toBe(`${ORIGIN}${OAUTH_ENDPOINTS.authorize}`); + expect(metadata.token_endpoint).toBe(`${ORIGIN}${OAUTH_ENDPOINTS.token}`); + expect(metadata.registration_endpoint).toBe(`${ORIGIN}${OAUTH_ENDPOINTS.register}`); + expect(metadata.code_challenge_methods_supported).toEqual(["S256"]); + expect(metadata.client_id_metadata_document_supported).toBe(true); + expect(metadata.token_endpoint_auth_methods_supported).toContain("none"); + expect(metadata.authorization_response_iss_parameter_supported).toBe(true); + expect(metadata.scopes_supported).toEqual([...SCOPES]); + expect(metadata.response_types_supported).toEqual(["code"]); + expect(metadata.grant_types_supported).toEqual( + expect.arrayContaining(["authorization_code", "refresh_token"]), + ); + expect(typeof metadata.revocation_endpoint).toBe("string"); + }); +}); + +describe("the authorization endpoint before sign-in exists", () => { + it("renders a malformed request locally as 400", async () => { + const response = await call(`${OAUTH_ENDPOINTS.authorize}?response_type=code`); + expect(response.status).toBe(400); + expect(await response.text()).toMatch(/^Invalid authorization request/); + }); + + it("renders an unknown client locally as 400 and never redirects", async () => { + const params = new URLSearchParams({ + response_type: "code", + client_id: "does-not-exist", + redirect_uri: "https://client.example/callback", + state: "s", + code_challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM", + code_challenge_method: "S256", + }); + const response = await call(`${OAUTH_ENDPOINTS.authorize}?${params.toString()}`); + expect(response.status).toBe(400); + expect(response.headers.get("location")).toBeNull(); + }); + + it("validates a registered client's request and then stops with 501 — never auto-approves", async () => { + const registration = await call(OAUTH_ENDPOINTS.register, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + client_name: "Test client", + redirect_uris: ["https://client.example/callback"], + token_endpoint_auth_method: "none", + grant_types: ["authorization_code", "refresh_token"], + response_types: ["code"], + }), + }); + expect(registration.status).toBe(201); + const client = await registration.json<{ client_id: string }>(); + + const params = new URLSearchParams({ + response_type: "code", + client_id: client.client_id, + redirect_uri: "https://client.example/callback", + scope: "read:account read:jobs", + state: "s", + code_challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM", + code_challenge_method: "S256", + resource: `${ORIGIN}/mcp`, + }); + const response = await call(`${OAUTH_ENDPOINTS.authorize}?${params.toString()}`); + expect(response.status).toBe(501); + expect(response.headers.get("location")).toBeNull(); + }); +}); diff --git a/mcp/wrangler.toml b/mcp/wrangler.toml index f76bc12a79..1d7622bd1b 100644 --- a/mcp/wrangler.toml +++ b/mcp/wrangler.toml @@ -8,7 +8,8 @@ name = "artcraft-mcp" main = "src/index.ts" compatibility_date = "2026-08-01" -compatibility_flags = ["nodejs_compat"] +# global_fetch_strictly_public: required by workers-oauth-provider for CIMD fetches (SSRF protection). +compatibility_flags = ["nodejs_compat", "global_fetch_strictly_public"] # --------------------------------------------------------------------------------------------- # local — `wrangler dev`. Talks to the fake upstream on localhost. `.dev.vars` may override. @@ -40,6 +41,7 @@ id = "REPLACE_WITH_PREVIEW_KV_ID" # from docs/infra-request.md §1 # --------------------------------------------------------------------------------------------- [env.production] name = "artcraft-mcp" +workers_dev = false # one canonical public URL: the custom domain. The OAuth `resource` must match it exactly. # Enable once the getartcraft.com zone is on Cloudflare (docs/infra-request.md §1): # routes = [{ pattern = "mcp.getartcraft.com", custom_domain = true }]