diff --git a/.changeset/mcp-oauth-discovery.md b/.changeset/mcp-oauth-discovery.md new file mode 100644 index 00000000..d265ef83 --- /dev/null +++ b/.changeset/mcp-oauth-discovery.md @@ -0,0 +1,6 @@ +--- +"@cloudhal/api": patch +"@cloudhal/agent-runtime": patch +--- + +MCP OAuth discovery (RFC-0044 §1, phase 1 of #144): with `mcp.oauth` set, the 401 of `/mcp` carries a `WWW-Authenticate` challenge naming the RFC 9728 protected resource metadata and the scopes to request, the metadata is served at both well-known forms, and a token principal calling a tool without a scope its member's role holds gets a `403 insufficient_scope` step-up challenge. Deployments do not set the option yet: the authorization server is phase 2. diff --git a/docs/adr/0004-mcp-server.md b/docs/adr/0004-mcp-server.md index 161c9326..54bf8172 100644 --- a/docs/adr/0004-mcp-server.md +++ b/docs/adr/0004-mcp-server.md @@ -23,6 +23,6 @@ MCP spec 2026-07-28 removed sessions and `initialize`; every request is self-des ## Addendum (2026-10-22): what was built instead Three parts of the Decision were not built. The stateless server, `createMcpHandler`, `outputSchema`/`structuredContent`, honest annotations, the deterministic `tools/list` and the layered approval (elicitation with a `confirmToken` fallback) are as decided. -- **No OAuth provider.** `@cloudflare/workers-oauth-provider` is not used. `/mcp` is mounted in the Hono API, which resolves the caller before the MCP handler sees the request: a session cookie for a person, or an agent key (Better Auth API key) as `x-api-key` or `Authorization: Bearer`. See [ADR-0011](0011-agent-layer.md) §5–6, which deferred OAuth 2.1. +- **No OAuth provider.** `@cloudflare/workers-oauth-provider` is not used. `/mcp` is mounted in the Hono API, which resolves the caller before the MCP handler sees the request: a session cookie for a person, or an agent key (Better Auth API key) as `x-api-key` or `Authorization: Bearer`. See [ADR-0011](0011-agent-layer.md) §5–6, which deferred OAuth 2.1. *Superseded (2026-11-08):* [RFC-0044](../rfcs/0044-mcp-oauth-authorization.md) brings OAuth 2.1 in three phases. Phase 1 (discovery, `packages/api/src/mcp-oauth.ts`) ships the RFC 9728 metadata, the `WWW-Authenticate` challenge on the 401 and the `insufficient_scope` step-up behind `McpMountOptions.oauth`; the authorization server (Better Auth's `mcp()` plugin, not `@cloudflare/workers-oauth-provider`) and client registration follow with their own ADR. Agent keys keep working. - **No server-minted handles.** Tools take the contract ids (`snap_…` and the other prefixed ids) and the node's `rev`; every call is re-authorized by the policy engine, and approval state is an HMAC-signed `requestState` bound to the tool, the arguments and the principal (ADR-0011 §4), not a stored handle. - **More than four scopes.** The scope list grew with the features (content, preview, assets, analytics, comments, schedules and releases, `:own` variants, person-only scopes). The single source is `KEY_SCOPES` and `PERSON_SCOPES` in `packages/agent-runtime/src/policy.ts`; roles are in `src/roles.ts` ([ADR-0018](0018-roles-and-invites.md)). Each tool's scopes are in the [tool reference](../../packages/mcp-server/README.md#tool-reference). diff --git a/packages/agent-runtime/src/policy.ts b/packages/agent-runtime/src/policy.ts index 940725db..c0feb2bf 100644 --- a/packages/agent-runtime/src/policy.ts +++ b/packages/agent-runtime/src/policy.ts @@ -257,6 +257,11 @@ export interface Principal { elevation?: { from: AgentMode; until: string } | undefined; /** The API key an agent authenticated with (lets it ask for an elevation of that key). */ keyId?: string | undefined; + /** + * RFC-0044 §5: the OAuth grant an agent's access token belongs to (a connection approved on the + * consent screen). Only such principals get the `insufficient_scope` step-up challenge. + */ + grantId?: string | undefined; /** * RFC-0008 §11.2: the key is an automation's key (`purpose: "automation"` in its metadata). It * works only inside an open run of that automation, except `begin_run` and `get_mode`. diff --git a/packages/api/src/app.ts b/packages/api/src/app.ts index de1356fa..6382bbdc 100644 --- a/packages/api/src/app.ts +++ b/packages/api/src/app.ts @@ -6,13 +6,13 @@ import { type Principal, } from "@cloudhal/agent-runtime"; import { ANALYTICS_OFF } from "@cloudhal/analytics/service"; -import { createCloudhalMcpHandler } from "@cloudhal/mcp-server/http"; import { schedulesAvailable } from "@cloudhal/mcp-server/schedules"; import { defineTools, toolPolicies } from "@cloudhal/mcp-server/tools"; import { type Context, Hono } from "hono"; import { createBuildHub } from "./build.ts"; import type { ApiDeps, Env } from "./context.ts"; -import { errorHandler, unauthorized } from "./errors.ts"; +import { errorHandler } from "./errors.ts"; +import { mountMcp } from "./mcp-mount.ts"; import { PLAYGROUND_PRINCIPAL, playgroundGuard } from "./playground.ts"; import { activity, deploymentActivity } from "./routes/activity.ts"; import { mountAdmin } from "./routes/admin-mount.ts"; @@ -222,37 +222,8 @@ export function createApi(deps: ApiDeps) { app.get("/api/me", meHandler(membership)); - if (deps.mcp) { - const mcp = createCloudhalMcpHandler({ - store: deps.store, - build, - ...deps.mcp, - previewSecret: deps.mcp.previewSecret ?? deps.preview?.secret, - now: deps.now, - ownership, - analytics: deps.analytics, - comments: deps.comments, - schedules, - }); - app.all("/mcp", async (c) => { - // DNS-rebinding / cross-site protection: browsers send Origin; it must be this origin. - const origin = c.req.header("origin"); - if (origin && origin !== new URL(c.req.url).origin) - return c.json( - { error: "forbidden", message: "cross-origin MCP requests are not allowed" }, - 403, - ); - const principal = c.get("principal"); - if (!principal) throw unauthorized(); - // A signed-in person without a membership has no scopes; refuse rather than list tools. - if (deps.staff && principal.actor.kind === "human" && !principal.person) - return c.json( - { error: "not_a_member", message: "Your access was removed. Ask an administrator." }, - 403, - ); - return mcp.fetch(c.req.raw, principal); - }); - } + // RFC-0044 §1: with `mcp.oauth`, the route also serves OAuth discovery. + if (deps.mcp) mountMcp(app, { ...deps, mcp: deps.mcp }, { build, ownership, schedules }); if (deps.agentKeys) app.route("/api/agent-keys", agentKeys(deps.agentKeys)); diff --git a/packages/api/src/context.ts b/packages/api/src/context.ts index 542a44e2..6fdc7dff 100644 --- a/packages/api/src/context.ts +++ b/packages/api/src/context.ts @@ -17,6 +17,7 @@ import type { Context } from "hono"; import type { AgentKeyService, StaffService } from "./auth.ts"; import type { BuildHub } from "./build.ts"; import type { MailSender } from "./mail.ts"; +import type { McpOauthOptions } from "./mcp-oauth.ts"; import type { PlaygroundOptions } from "./playground.ts"; import type { AiDeps } from "./routes/ai.ts"; import type { UploadSecret } from "./uploads.ts"; @@ -42,6 +43,11 @@ export interface McpMountOptions { readonly media?: MediaToolOptions | undefined; /** A browser for `render_page` (RFC-0021 §4). */ readonly render?: RenderDeps | undefined; + /** + * RFC-0044 §1: OAuth discovery for remote clients (the `WWW-Authenticate` challenge, the RFC 9728 + * documents, the `insufficient_scope` step-up). Absent: `/mcp` answers a bare 401, as before. + */ + readonly oauth?: McpOauthOptions | undefined; } export interface ApiDeps { diff --git a/packages/api/src/mcp-mount.ts b/packages/api/src/mcp-mount.ts new file mode 100644 index 00000000..c7cd7fcc --- /dev/null +++ b/packages/api/src/mcp-mount.ts @@ -0,0 +1,67 @@ +/** + * `/mcp` (ADR-0011): the API resolves the caller, then hands the request and its `Principal` to + * the stateless MCP handler. With `McpMountOptions.oauth` (RFC-0044 §1) the route also speaks + * OAuth discovery: the challenge on 401, the protected resource metadata, the step-up 403. + */ +import type { OwnershipResolver } from "@cloudhal/agent-runtime"; +import { createCloudhalMcpHandler } from "@cloudhal/mcp-server/http"; +import type { Hono } from "hono"; +import type { BuildHub } from "./build.ts"; +import type { ApiDeps, Env } from "./context.ts"; +import { unauthorized } from "./errors.ts"; +import { + mountProtectedResourceMetadata, + stepUpChallenge, + unauthorizedChallenge, +} from "./mcp-oauth.ts"; + +export interface McpMountParts { + readonly build: BuildHub; + readonly ownership: OwnershipResolver; + readonly schedules: ApiDeps["schedules"]; +} + +export function mountMcp( + app: Hono, + deps: ApiDeps & { readonly mcp: NonNullable }, + parts: McpMountParts, +): void { + const mcp = createCloudhalMcpHandler({ + store: deps.store, + build: parts.build, + ...deps.mcp, + previewSecret: deps.mcp.previewSecret ?? deps.preview?.secret, + now: deps.now, + ownership: parts.ownership, + analytics: deps.analytics, + comments: deps.comments, + schedules: parts.schedules, + }); + const oauth = deps.mcp.oauth; + if (oauth) mountProtectedResourceMetadata(app, oauth); + app.all("/mcp", async (c) => { + // DNS-rebinding / cross-site protection: browsers send Origin; it must be this origin. + const origin = c.req.header("origin"); + if (origin && origin !== new URL(c.req.url).origin) + return c.json( + { error: "forbidden", message: "cross-origin MCP requests are not allowed" }, + 403, + ); + const principal = c.get("principal"); + if (!principal) { + if (oauth) return unauthorizedChallenge(oauth, c.req.raw); + throw unauthorized(); + } + // A signed-in person without a membership has no scopes; refuse rather than list tools. + if (deps.staff && principal.actor.kind === "human" && !principal.person) + return c.json( + { error: "not_a_member", message: "Your access was removed. Ask an administrator." }, + 403, + ); + if (oauth) { + const stepUp = await stepUpChallenge(c.req.raw, principal, c.get("policy")(), oauth); + if (stepUp) return stepUp; + } + return mcp.fetch(c.req.raw, principal); + }); +} diff --git a/packages/api/src/mcp-oauth.ts b/packages/api/src/mcp-oauth.ts new file mode 100644 index 00000000..ea826282 --- /dev/null +++ b/packages/api/src/mcp-oauth.ts @@ -0,0 +1,200 @@ +/** + * RFC-0044 §1, Phase 1: what an OAuth MCP client needs to find the deployment's authorization + * server. The MCP authorization spec (2026-07-28) makes an MCP server a resource server that + * "MUST implement OAuth 2.0 Protected Resource Metadata" (RFC 9728) and name that document in the + * `WWW-Authenticate` challenge of its 401; a call a token cannot make for want of a scope the + * member's role holds answers `403 insufficient_scope` with the scopes to ask for (step-up). + * + * Nothing here verifies a token: that is Phase 2 (the authorization server). Until then a + * deployment that sets `McpMountOptions.oauth` advertises an issuer whose metadata does not + * exist, so clients fail at "authorization server metadata", a diagnosable error (RFC-0044 §9). + */ +import { + holds, + isKeyScope, + type PolicyEngine, + type Principal, + roleScopes, + type Scope, + scopesForMode, +} from "@cloudhal/agent-runtime"; +import type { Context, Hono } from "hono"; +import type { Env } from "./context.ts"; + +export interface McpOauthOptions { + /** + * The authorization server's issuer identifier (RFC 8414): Better Auth's base path on the + * deployment's public origin, e.g. `https://studio.example.com/api/auth`. + */ + readonly issuer: string; + /** + * The canonical URL of this MCP server (RFC 8707 §2, RFC 9728 `resource`): the public origin + * plus `/mcp`, no trailing slash, e.g. `https://studio.example.com/mcp`. Tokens are bound to it. + */ + readonly resource: string; + /** + * `scopes_supported`: the minimal set for basic use, which clients request when the challenge + * names none. Defaults to what a new agent key gets (`scopesForMode("edits")`). + */ + readonly scopesSupported?: readonly Scope[] | undefined; + /** `resource_name`, shown by some clients (RFC 9728 §2). */ + readonly resourceName?: string | undefined; + /** `resource_documentation` (RFC 9728 §2). */ + readonly documentation?: string | undefined; +} + +/** RFC 9728 §3: the well-known path; the path-aware form appends the resource's path. */ +export const PROTECTED_RESOURCE_PATH = "/.well-known/oauth-protected-resource"; + +/** The resource identifier as served: no trailing slash (MCP "Canonical Server URI"). */ +export function canonicalResource(resource: string): string { + const url = new URL(resource); + url.hash = ""; + return url.href.replace(/\/$/, ""); +} + +/** The URL of the path-aware metadata document, named in every challenge. */ +export function resourceMetadataUrl(oauth: McpOauthOptions): string { + const url = new URL(canonicalResource(oauth.resource)); + return `${url.origin}${PROTECTED_RESOURCE_PATH}${url.pathname}`; +} + +/** The scopes the challenge and `scopes_supported` name (RFC-0044 §1). */ +export function supportedScopes(oauth: McpOauthOptions): readonly Scope[] { + return oauth.scopesSupported ?? scopesForMode("edits"); +} + +/** The RFC 9728 document. */ +export function protectedResourceMetadata(oauth: McpOauthOptions): Record { + return { + resource: canonicalResource(oauth.resource), + authorization_servers: [oauth.issuer], + scopes_supported: [...supportedScopes(oauth)], + bearer_methods_supported: ["header"], + ...(oauth.resourceName ? { resource_name: oauth.resourceName } : {}), + ...(oauth.documentation ? { resource_documentation: oauth.documentation } : {}), + }; +} + +/** + * Serves the document at both well-known forms (`ROUTE_SCOPES` declares them `public`): the + * path-aware one clients try first, and the root one the spec makes them fall back to. + */ +export function mountProtectedResourceMetadata(app: Hono, oauth: McpOauthOptions): void { + const body = JSON.stringify(protectedResourceMetadata(oauth)); + const handler = (c: Context) => + c.body(body, 200, { + "content-type": "application/json", + // Static per deployment; RFC 9728 §3 and the hardening guide want it cacheable. + "cache-control": "public, max-age=3600", + }); + app.get( + `${PROTECTED_RESOURCE_PATH}${new URL(canonicalResource(oauth.resource)).pathname}`, + handler, + ); + app.get(PROTECTED_RESOURCE_PATH, handler); +} + +export interface ChallengeOptions { + /** RFC 6750 §3.1 error code; absent on a first, credential-less request. */ + readonly error?: "invalid_token" | "insufficient_scope" | undefined; + readonly description?: string | undefined; + /** The scopes to ask for (RFC 6750 `scope`, space-separated). */ + readonly scope?: readonly string[] | undefined; +} + +/** The `WWW-Authenticate: Bearer …` value (RFC 6750 §3, RFC 9728 §5.1). */ +export function challengeHeader(oauth: McpOauthOptions, options: ChallengeOptions = {}): string { + const quote = (v: string) => `"${v.replace(/["\\]/g, "")}"`; + const params: string[] = []; + if (options.error) params.push(`error=${quote(options.error)}`); + if (options.description) params.push(`error_description=${quote(options.description)}`); + params.push(`resource_metadata=${quote(resourceMetadataUrl(oauth))}`); + const scope = options.scope ?? supportedScopes(oauth); + if (scope.length) params.push(`scope=${quote(scope.join(" "))}`); + return `Bearer ${params.join(", ")}`; +} + +/** True when the request presented a bearer credential (RFC 6750 §2.1) that did not resolve. */ +export function presentedBearer(request: Request): boolean { + return /^bearer\s+\S/i.test(request.headers.get("authorization") ?? ""); +} + +/** The 401 of `/mcp` with the challenge; the JSON body is `unauthorized()`'s. */ +export function unauthorizedChallenge(oauth: McpOauthOptions, request: Request): Response { + return Response.json( + { error: "unauthorized", message: "sign in required" }, + { + status: 401, + headers: { + "www-authenticate": challengeHeader( + oauth, + presentedBearer(request) ? { error: "invalid_token" } : {}, + ), + }, + }, + ); +} + +/** The one JSON-RPC message a stateless request carries, when it is a tool call. */ +async function toolCallOf( + request: Request, +): Promise<{ name: string; args: Record } | undefined> { + if (request.method !== "POST") return undefined; + if (!/^application\/json\b/i.test(request.headers.get("content-type") ?? "")) return undefined; + let message: unknown; + try { + message = JSON.parse(await request.clone().text()); + } catch { + return undefined; + } + if (!message || typeof message !== "object" || Array.isArray(message)) return undefined; + const { method, params } = message as { method?: unknown; params?: unknown }; + if (method !== "tools/call" || !params || typeof params !== "object") return undefined; + const { name, arguments: args } = params as { name?: unknown; arguments?: unknown }; + if (typeof name !== "string") return undefined; + return { name, args: args && typeof args === "object" ? (args as Record) : {} }; +} + +/** + * RFC-0044 §1 step-up: a token principal (`grantId`) calling a tool whose scopes the token lacks + * but the member's role holds gets `403 insufficient_scope` naming every missing scope at once, + * so the client re-authorizes with the union (MCP "Scope Challenge Handling"). Every other denial + * (role, mode, `:own`, approvals) stays a JSON-RPC result from the handler, and a key principal + * is never challenged: a key cannot re-authorize. + */ +export async function stepUpChallenge( + request: Request, + principal: Principal, + policy: PolicyEngine, + oauth: McpOauthOptions, +): Promise { + if (!principal.grantId || !principal.person) return undefined; + const call = await toolCallOf(request); + if (!call) return undefined; + const tool = policy.tool(call.name); + if (!tool) return undefined; + const decision = policy.evaluate(principal, call.name, { args: call.args }); + if (decision.kind !== "deny" || decision.code !== "missing_scope") return undefined; + const missing = tool.scopes.filter((s) => !holds(principal.scopes, s)); + const role = roleScopes(principal.person.role); + // Only scopes a token can carry and the role holds: anything else would make the client loop. + if (!missing.length || !missing.every((s) => isKeyScope(s) && holds(role, s))) return undefined; + return Response.json( + { + error: "insufficient_scope", + message: `this connection needs ${missing.join(", ")}; authorize it again to add them`, + details: { scope: missing }, + }, + { + status: 403, + headers: { + "www-authenticate": challengeHeader(oauth, { + error: "insufficient_scope", + scope: missing, + description: `${call.name} needs ${missing.join(" ")}`, + }), + }, + }, + ); +} diff --git a/packages/api/src/playground.ts b/packages/api/src/playground.ts index 948f4488..17e033f1 100644 --- a/packages/api/src/playground.ts +++ b/packages/api/src/playground.ts @@ -58,6 +58,10 @@ const DENY: readonly (readonly [string, string, PlaygroundBlock])[] = [ ["ALL", "/api/agent-keys", "keys"], ["ALL", "/api/agent-keys/*", "keys"], ["ALL", "/mcp", "mcp"], + // RFC-0044 decision 10: a playground has one fixed principal and no authorization server, so + // its MCP never speaks OAuth discovery (the documents are only mounted with `mcp.oauth`). + ["GET", "/.well-known/oauth-protected-resource", "mcp"], + ["GET", "/.well-known/oauth-protected-resource/mcp", "mcp"], ["ALL", `${S}/approvals`, "agent"], ["ALL", `${S}/approvals/*`, "agent"], ["ALL", `${S}/ai/*`, "agent"], diff --git a/packages/api/src/scopes.ts b/packages/api/src/scopes.ts index 2c8645e1..33848264 100644 --- a/packages/api/src/scopes.ts +++ b/packages/api/src/scopes.ts @@ -39,6 +39,10 @@ export const ROUTE_SCOPES: readonly (readonly [Method, string, RouteRequirement] ["GET", "/api/auth/*", "public"], ["POST", "/api/auth/*", "public"], ["ALL", "/mcp", "signed-in"], + // RFC-0044 §1: OAuth discovery (served only with `mcp.oauth`; outside `/api`, so `routeGuard` + // never sees them, but every mounted route is declared). + ["GET", "/.well-known/oauth-protected-resource", "public"], + ["GET", "/.well-known/oauth-protected-resource/mcp", "public"], ["GET", "/api/me", "signed-in"], // operator (deployment token) diff --git a/packages/api/src/testing-mcp-oauth.ts b/packages/api/src/testing-mcp-oauth.ts new file mode 100644 index 00000000..1eccd049 --- /dev/null +++ b/packages/api/src/testing-mcp-oauth.ts @@ -0,0 +1,218 @@ +/** + * Shared suite for RFC-0044 §1 (Phase 1): what an OAuth MCP client sees on a deployment before it + * has a token. The `WWW-Authenticate` challenge on the 401 of `/mcp`, the RFC 9728 metadata at + * both well-known forms, and the `insufficient_scope` step-up for a token principal. Runs on + * better-sqlite3 and Miniflare D1 (the step-up's key-principal case reads the key from the + * database). + */ +import { type Principal, scopesForMode } from "@cloudhal/agent-runtime"; +import { seedStarterSite } from "@cloudhal/core-ts"; +import { afterAll, describe, expect, it } from "vitest"; +import { createApi } from "./app.ts"; +import { createAuth } from "./auth.ts"; +import type { ApiDeps } from "./context.ts"; +import { type McpOauthOptions, PROTECTED_RESOURCE_PATH } from "./mcp-oauth.ts"; +import type { RolesBackend } from "./testing-roles.ts"; + +type Json = Record; +const ORIGIN = "http://studio.test"; +const OAUTH: McpOauthOptions = { + issuer: `${ORIGIN}/api/auth`, + resource: `${ORIGIN}/mcp/`, // the trailing slash is dropped by the canonical form + resourceName: "Cloudhal test", +}; +const METADATA_URL = `${ORIGIN}${PROTECTED_RESOURCE_PATH}/mcp`; + +/** A tool call as a 2026-07-28 client sends it: one JSON-RPC message per request. */ +function toolCall(name: string, args: Json): RequestInit { + return { + method: "POST", + headers: { + "content-type": "application/json", + accept: "application/json, text/event-stream", + "mcp-protocol-version": "2026-07-28", + }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "tools/call", + params: { name, arguments: args }, + }), + }; +} + +export function describeMcpOauthDiscovery(label: string, backend: RolesBackend): void { + describe(`MCP OAuth discovery: ${label}`, { timeout: 180_000 }, () => { + afterAll(async () => { + await backend.dispose?.(); + }); + + /** The deployment with Better Auth, with or without `mcp.oauth`. */ + const setup = async (oauth: McpOauthOptions | undefined) => { + const b = await backend.create(); + const site = await seedStarterSite(b.store, { name: "Blog", slug: "blog" }); + const auth = createAuth({ + database: b.database, + sql: b.sql, + baseURL: ORIGIN, + secret: "mcp-oauth-secret-0123456789abcdefghijklm", + signUp: "first-user", + }); + const app = createApi({ + store: b.store, + resolvePrincipal: auth.resolvePrincipal, + authHandler: auth.handler, + agentKeys: auth.agentKeys, + staff: auth.staff, + signUp: auth.signUp, + mcp: { stateKey: "mcp-oauth-state-key-0123456789abcdefghij", oauth }, + }); + const signUp = await app.request(`${ORIGIN}/api/auth/sign-up/email`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + name: "Olive Owner", + email: "owner@example.com", + password: "correct horse battery staple", + }), + }); + const cookie = + (signUp.headers.getSetCookie?.() ?? []) + .map((c) => c.split(";")[0] as string) + .find((c) => c.startsWith("better-auth.session_token=")) ?? ""; + return { app, cookie, siteId: site.id }; + }; + + /** A deployment whose principal is injected: a token principal of Phase 2, before Phase 2. */ + const setupWithPrincipal = async (principal: Principal | undefined) => { + const b = await backend.create(); + const site = await seedStarterSite(b.store, { name: "Blog", slug: "blog" }); + const deps: ApiDeps = { + store: b.store, + resolvePrincipal: async () => principal, + mcp: { stateKey: "mcp-oauth-state-key-0123456789abcdefghij", oauth: OAUTH }, + }; + return { app: createApi(deps), siteId: site.id }; + }; + + const grantPrincipal = ( + role: "editor" | "contributor", + scopes: Principal["scopes"], + ): Principal => ({ + actor: { kind: "agent", id: "agent_grant0001", name: "Claude" }, + scopes, + mode: "edits", + grantId: "grant_0001", + keyScopes: scopes, + person: { id: "user_owner1", userId: "owner1", role, name: "Olive Owner" }, + }); + + it("answers 401 with the RFC 9728 challenge and the scopes to request", async () => { + const { app } = await setup(OAUTH); + const res = await app.request(`${ORIGIN}/mcp`, toolCall("get_mode", {})); + expect(res.status).toBe(401); + expect(await res.json()).toEqual({ error: "unauthorized", message: "sign in required" }); + const header = res.headers.get("www-authenticate") ?? ""; + expect(header).toMatch(/^Bearer /); + expect(header).toContain(`resource_metadata="${METADATA_URL}"`); + expect(header).toContain(`scope="${scopesForMode("edits").join(" ")}"`); + expect(header).not.toContain("error="); + }); + + it("names invalid_token when a bearer was presented, for keys and tokens alike", async () => { + const { app } = await setup(OAUTH); + const res = await app.request(`${ORIGIN}/mcp`, { + ...toolCall("get_mode", {}), + headers: { ...(toolCall("get_mode", {}).headers as Json), authorization: "Bearer nope" }, + }); + expect(res.status).toBe(401); + expect(res.headers.get("www-authenticate")).toContain('error="invalid_token"'); + }); + + it("serves the protected resource metadata at both well-known forms, cacheable", async () => { + const { app } = await setup(OAUTH); + const docs = await Promise.all( + [`${PROTECTED_RESOURCE_PATH}/mcp`, PROTECTED_RESOURCE_PATH].map(async (path) => { + const res = await app.request(`${ORIGIN}${path}`); + expect(res.status, path).toBe(200); + expect(res.headers.get("content-type")).toMatch(/^application\/json/); + expect(res.headers.get("cache-control")).toBe("public, max-age=3600"); + return (await res.json()) as Json; + }), + ); + expect(docs[0]).toEqual(docs[1]); + expect(docs[0]).toEqual({ + resource: `${ORIGIN}/mcp`, + authorization_servers: [`${ORIGIN}/api/auth`], + scopes_supported: scopesForMode("edits"), + bearer_methods_supported: ["header"], + resource_name: "Cloudhal test", + }); + }); + + it("stays a bare 401 without metadata when oauth is not configured", async () => { + const { app } = await setup(undefined); + const res = await app.request(`${ORIGIN}/mcp`, toolCall("get_mode", {})); + expect(res.status).toBe(401); + expect(res.headers.get("www-authenticate")).toBeNull(); + expect((await app.request(`${ORIGIN}${PROTECTED_RESOURCE_PATH}`)).status).toBe(404); + expect((await app.request(`${ORIGIN}${PROTECTED_RESOURCE_PATH}/mcp`)).status).toBe(404); + }); + + it("challenges a token principal for a scope its member's role holds (step-up)", async () => { + const { app, siteId } = await setupWithPrincipal( + grantPrincipal("editor", ["site:read", "content:read"]), + ); + const res = await app.request(`${ORIGIN}/mcp`, toolCall("publish", { siteId })); + expect(res.status).toBe(403); + const header = res.headers.get("www-authenticate") ?? ""; + expect(header).toContain('error="insufficient_scope"'); + expect(header).toContain('scope="site:publish"'); + expect(header).toContain(`resource_metadata="${METADATA_URL}"`); + expect(await res.json()).toMatchObject({ + error: "insufficient_scope", + details: { scope: ["site:publish"] }, + }); + // With the scope, the call reaches the handler (and waits for approval, as policy says). + const ok = await app.request(`${ORIGIN}/mcp`, toolCall("publish", { siteId })); + expect(ok.status).toBe(403); // same request, same answer: the challenge is deterministic + const allowed = await setupWithPrincipal( + grantPrincipal("editor", ["site:read", "content:read", "site:publish"]), + ); + const reached = await allowed.app.request( + `${ORIGIN}/mcp`, + toolCall("publish", { siteId: allowed.siteId }), + ); + expect(reached.status).not.toBe(403); + expect(reached.headers.get("www-authenticate")).toBeNull(); + }); + + it("never challenges for a scope the role lacks, and never challenges a key", async () => { + // A Contributor's role has no site:publish: a challenge would make the client loop. + const contributor = await setupWithPrincipal( + grantPrincipal("contributor", ["site:read", "content:read"]), + ); + const denied = await contributor.app.request( + `${ORIGIN}/mcp`, + toolCall("publish", { siteId: contributor.siteId }), + ); + expect(denied.status).not.toBe(403); + expect(denied.headers.get("www-authenticate")).toBeNull(); + // An agent key narrowed to read scopes: the policy denial stays a JSON-RPC result. + const { app, cookie, siteId } = await setup(OAUTH); + const created = await app.request(`${ORIGIN}/api/agent-keys`, { + method: "POST", + headers: { "content-type": "application/json", cookie }, + body: JSON.stringify({ name: "reader", scopes: ["site:read", "content:read"] }), + }); + expect(created.status).toBe(201); + const key = ((await created.json()) as { key: string }).key; + const res = await app.request(`${ORIGIN}/mcp`, { + ...toolCall("publish", { siteId }), + headers: { ...(toolCall("publish", { siteId }).headers as Json), "x-api-key": key }, + }); + expect(res.status).not.toBe(403); + expect(res.headers.get("www-authenticate")).toBeNull(); + }); + }); +} diff --git a/packages/api/test/mcp-oauth.test.ts b/packages/api/test/mcp-oauth.test.ts new file mode 100644 index 00000000..efaacd34 --- /dev/null +++ b/packages/api/test/mcp-oauth.test.ts @@ -0,0 +1,34 @@ +import { SiteGraphStore } from "@cloudhal/core-ts"; +import { MemoryBlobStore } from "@cloudhal/storage"; +import { D1Driver } from "@cloudhal/storage/d1"; +import { BetterSqlite3Driver } from "@cloudhal/storage/sqlite"; +import { createMiniflareD1, createTableReset } from "@cloudhal/storage/testing"; +import Database from "better-sqlite3"; +import { describeMcpOauthDiscovery } from "../src/testing-mcp-oauth.ts"; + +describeMcpOauthDiscovery("better-sqlite3", { + async create() { + const database = new Database(":memory:"); + const sql = new BetterSqlite3Driver(database); + const blobs = new MemoryBlobStore(); + const store = new SiteGraphStore({ sql, blobs }); + await store.init(); + return { store, sql, blobs, database }; + }, +}); + +let d1: Awaited> | undefined; +const reset = createTableReset(); +describeMcpOauthDiscovery("Cloudflare D1 (Miniflare)", { + async create() { + d1 ??= await createMiniflareD1(); + const sql = new D1Driver(d1.db); + const blobs = new MemoryBlobStore(); + const store = new SiteGraphStore({ sql, blobs }); + await reset(sql, () => store.init()); + return { store, sql, blobs, database: d1.db }; + }, + async dispose() { + await d1?.dispose(); + }, +}); diff --git a/packages/api/test/route-scopes.test.ts b/packages/api/test/route-scopes.test.ts index 38cb5b39..cf1895cb 100644 --- a/packages/api/test/route-scopes.test.ts +++ b/packages/api/test/route-scopes.test.ts @@ -34,7 +34,10 @@ async function fullApp() { authHandler: auth.handler, agentKeys: auth.agentKeys, staff: auth.staff, - mcp: { stateKey: "route-scopes-state-key-0123456789abcdefg" }, + mcp: { + stateKey: "route-scopes-state-key-0123456789abcdefg", + oauth: { issuer: "http://localhost/api/auth", resource: "http://localhost/mcp" }, + }, preview: { secret: "route-scopes-preview-secret-0123456789ab" }, admin: { deployToken: "route-scopes-deploy-token-0123456789abc", storage: { sql, blobs } }, analytics: new AnalyticsService({ sql, store: "sql", live: new SqlAnalytics(sql) }), diff --git a/scripts/size-baseline.json b/scripts/size-baseline.json index b9e092f8..1fb59f7b 100644 --- a/scripts/size-baseline.json +++ b/scripts/size-baseline.json @@ -304,7 +304,7 @@ "params": 5 }, "packages/api/src/app.ts::createApi": { - "lines": 217 + "lines": 188 }, "packages/api/src/build-turn.ts::runBuildTurn": { "lines": 260