mcp: wire the OAuth provider — lazy 401, RFC 9728/8414 metadata, DCR, CIMD, validating /authorize

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
This commit is contained in:
Tanish Manku committed 2026-08-24 20:29:23 -07:00
1 parent 7e2708e589
commit c1c33caab5
11 files changed
+374 -40

No files matched your search

+1
View File
@@ -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"
+8
View File
@@ -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
+7 -4
View File
@@ -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;
}
+40
View File
@@ -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);
});
+94
View File
@@ -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<Cloudflare.Env> & {
fetch: NonNullable<ExportedHandler<Cloudflare.Env>["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));
}
+22 -35
View File
@@ -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<Response> {
let app: Hono;
fetch(request: Request, env: Cloudflare.Env, ctx: ExecutionContext): Promise<Response> {
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<Cloudflare.Env>;
+19
View File
@@ -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 },
),
);
},
};
+13
View File
@@ -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 {};
+30
View File
@@ -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;
}
}
+137
View File
@@ -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<Response> {
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<Record<string, unknown>> {
const response = await call(path);
expect(response.status).toBe(200);
return response.json<Record<string, unknown>>();
}
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();
});
});
+3 -1
View File
@@ -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 }]