diff --git a/src/auth/api-token-mode.ts b/src/auth/api-token-mode.ts index d3f04f2..c1c4a20 100644 --- a/src/auth/api-token-mode.ts +++ b/src/auth/api-token-mode.ts @@ -120,7 +120,12 @@ export async function resolveExternalToken({ const tokenOwner = cloudflareTokenOwner(token) try { const identity = await getCachedIdentity(token, tokenOwner, env.OAUTH_KV) - return { props: buildAuthProps(token, identity) } + return { + props: buildAuthProps(token, identity), + // Cloudflare API tokens are opaque credentials, so successful identity + // validation establishes their local protected-resource audience. + audience: env.MCP_RESOURCE + } } catch (error) { if (error instanceof OAuthError) throw externalTokenError(error, tokenOwner) throw error diff --git a/src/index.ts b/src/index.ts index 7f7f8f9..4e93e01 100644 --- a/src/index.ts +++ b/src/index.ts @@ -56,6 +56,7 @@ export default { () => getOAuthApi(oauthOptions, env) ), resourceMetadata: { + resource: env.MCP_RESOURCE, resource_name: 'Cloudflare API MCP Server' }, accessTokenTTL: 3600, diff --git a/tests/auth/api-token-mode.test.ts b/tests/auth/api-token-mode.test.ts index 8d25938..6387854 100644 --- a/tests/auth/api-token-mode.test.ts +++ b/tests/auth/api-token-mode.test.ts @@ -131,7 +131,8 @@ describe('resolveExternalToken', () => { type: 'account_token', accessToken: 'cfat_account-token', account: ACCOUNT - } + }, + audience: env.MCP_RESOURCE }) expect(calls.userCalls()).toBe(0) expect(calls.accountCalls()).toBe(1) @@ -146,7 +147,8 @@ describe('resolveExternalToken', () => { }) await expect(resolveExternalToken(resolverInput(token))).resolves.toMatchObject({ - props: { type: 'user_token', accessToken: token, user: USER, accounts: [ACCOUNT] } + props: { type: 'user_token', accessToken: token, user: USER, accounts: [ACCOUNT] }, + audience: env.MCP_RESOURCE }) expect(calls.userCalls()).toBe(1) expect(calls.accountCalls()).toBe(1) diff --git a/tests/auth/oauth-routes.test.ts b/tests/auth/oauth-routes.test.ts index a2b33b6..1acf79d 100644 --- a/tests/auth/oauth-routes.test.ts +++ b/tests/auth/oauth-routes.test.ts @@ -21,6 +21,7 @@ import { server } from '../setup/msw' const REDIRECT_URI = 'https://app.example.com/cb' const MCP_ORIGIN = 'https://mcp.cloudflare.com' +const MCP_RESOURCE = `${MCP_ORIGIN}/mcp` const DOWNSTREAM_CODE_VERIFIER = 'test-downstream-code-verifier' const DOWNSTREAM_CODE_CHALLENGE = 'I4fhllfHqqQsgap17V2SDI0scSei8H7U0e0rZBDIcbo' @@ -42,6 +43,7 @@ async function registerClient(): Promise { function authorizeUrl(params: Record): string { const u = new URL(`${MCP_ORIGIN}/authorize`) + u.searchParams.set('resource', MCP_RESOURCE) for (const [k, v] of Object.entries(params)) u.searchParams.set(k, v) u.searchParams.set('code_challenge', DOWNSTREAM_CODE_CHALLENGE) u.searchParams.set('code_challenge_method', 'S256') @@ -73,6 +75,7 @@ async function beginAuthorization(options: { state?: string; scopes?: string } = response_type: 'code', client_id: clientId, redirect_uri: REDIRECT_URI, + resource: MCP_RESOURCE, code_challenge: DOWNSTREAM_CODE_CHALLENGE, code_challenge_method: 'S256', scope: options.scopes ?? 'user:read', @@ -163,6 +166,29 @@ afterEach(async () => { await clearKv(env.OAUTH_KV) }) +describe('OAuth metadata policy', () => { + it('advertises the canonical MCP endpoint as the protected resource', async () => { + const response = await exports.default.fetch( + new Request(`${MCP_ORIGIN}/.well-known/oauth-protected-resource/mcp`) + ) + + expect(response.status).toBe(200) + await expect(response.json()).resolves.toMatchObject({ resource: MCP_RESOURCE }) + }) + + it('advertises RFC 9207 authorization response issuer support', async () => { + const response = await exports.default.fetch( + new Request(`${MCP_ORIGIN}/.well-known/oauth-authorization-server`) + ) + + expect(response.status).toBe(200) + await expect(response.json()).resolves.toMatchObject({ + issuer: MCP_ORIGIN, + authorization_response_iss_parameter_supported: true + }) + }) +}) + describe('GET /authorize', () => { it('renders the consent dialog for a registered client', async () => { const clientId = await registerClient() @@ -173,6 +199,7 @@ describe('GET /authorize', () => { response_type: 'code', client_id: clientId, redirect_uri: REDIRECT_URI, + resource: MCP_RESOURCE, code_challenge: DOWNSTREAM_CODE_CHALLENGE, code_challenge_method: 'S256', scope: 'user:read' @@ -228,6 +255,32 @@ describe('GET /authorize', () => { ]) }) + it('rejects a resource other than the canonical MCP endpoint', async () => { + const clientId = await registerClient() + const response = await exports.default.fetch( + new Request( + authorizeUrl({ + response_type: 'code', + client_id: clientId, + redirect_uri: REDIRECT_URI, + resource: MCP_ORIGIN, + state: 'client-state' + }) + ), + { redirect: 'manual' } + ) + + expect(response.status).toBe(302) + const redirect = new URL(response.headers.get('location')!) + expect(redirect.origin + redirect.pathname).toBe(REDIRECT_URI) + expect(redirect.searchParams.get('error')).toBe('invalid_request') + expect(redirect.searchParams.get('state')).toBe('client-state') + expect(redirect.searchParams.get('iss')).toBe(MCP_ORIGIN) + expect(response.headers.get('cache-control')).toBe('no-store') + expect(writtenEvents(metricsSpy)).not.toContain('auth_user') + expect((await env.OAUTH_KV.list({ prefix: 'grant:' })).keys).toHaveLength(0) + }) + it('rejects unknown requested scopes instead of silently downgrading them', async () => { const clientId = await registerClient() const response = await exports.default.fetch( @@ -338,6 +391,7 @@ describe('GET /authorize', () => { response_type: 'code', client_id: clientId, redirect_uri: REDIRECT_URI, + resource: MCP_RESOURCE, state: 'client-state' }).toString() const response = await exports.default.fetch(new Request(url), { redirect: 'manual' }) @@ -381,6 +435,7 @@ describe('GET /authorize', () => { response_type: 'code', client_id: 'does-not-exist', redirect_uri: REDIRECT_URI, + resource: MCP_RESOURCE, code_challenge: DOWNSTREAM_CODE_CHALLENGE, code_challenge_method: 'S256' }) @@ -466,6 +521,7 @@ describe('GET /oauth/callback', () => { const redirect = new URL(cbRes.headers.get('location')!) expect(redirect.origin + redirect.pathname).toBe(REDIRECT_URI) expect(redirect.searchParams.get('code')).toBeTruthy() + expect(redirect.searchParams.get('iss')).toBe(MCP_ORIGIN) // A successful login records an auth_user datapoint with the userId (blob3) // and no error message (blob4). @@ -497,17 +553,28 @@ describe('GET /oauth/callback', () => { const code = new URL(callback.headers.get('location')!).searchParams.get('code') expect(code).toBeTruthy() + const tokenParams = { + grant_type: 'authorization_code', + code: code!, + client_id: clientId, + redirect_uri: REDIRECT_URI, + code_verifier: DOWNSTREAM_CODE_VERIFIER + } + const missingResourceResponse = await exports.default.fetch( + new Request(`${MCP_ORIGIN}/token`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams(tokenParams).toString() + }) + ) + expect(missingResourceResponse.status).toBe(400) + await expect(missingResourceResponse.json()).resolves.toMatchObject({ error: 'invalid_target' }) + const tokenResponse = await exports.default.fetch( new Request(`${MCP_ORIGIN}/token`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body: new URLSearchParams({ - grant_type: 'authorization_code', - code: code!, - client_id: clientId, - redirect_uri: REDIRECT_URI, - code_verifier: DOWNSTREAM_CODE_VERIFIER - }).toString() + body: new URLSearchParams({ ...tokenParams, resource: MCP_RESOURCE }).toString() }) ) expect(tokenResponse.status).toBe(200) @@ -538,7 +605,8 @@ describe('GET /oauth/callback', () => { code: code!, client_id: clientId, redirect_uri: REDIRECT_URI, - code_verifier: DOWNSTREAM_CODE_VERIFIER + code_verifier: DOWNSTREAM_CODE_VERIFIER, + resource: MCP_RESOURCE }).toString() }) ) diff --git a/vitest.config.ts b/vitest.config.ts index 4075d26..d640fbc 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -11,7 +11,8 @@ export default defineConfig({ bindings: { MCP_COOKIE_ENCRYPTION_KEY: 'test-cookie-encryption-key-0000000000000000', CLOUDFLARE_CLIENT_ID: 'test-client-id', - CLOUDFLARE_CLIENT_SECRET: 'test-client-secret' + CLOUDFLARE_CLIENT_SECRET: 'test-client-secret', + MCP_RESOURCE: 'https://mcp.cloudflare.com/mcp' } } }) diff --git a/worker-configuration.d.ts b/worker-configuration.d.ts index 9b6816a..8d5b02e 100644 --- a/worker-configuration.d.ts +++ b/worker-configuration.d.ts @@ -13,6 +13,7 @@ declare namespace Cloudflare { AI: Ai; CLOUDFLARE_API_BASE: "https://api.staging.cloudflare.com/client/v4"; CLOUDFLARE_OAUTH_DOMAIN: "https://dash.staging.cloudflare.com"; + MCP_RESOURCE: "https://staging.mcp.cloudflare.com/mcp"; OPENAPI_SPEC_URL: "https://raw.githubusercontent.com/cloudflare/api-schemas/main/openapi.json"; MCP_COOKIE_ENCRYPTION_KEY: string; CLOUDFLARE_CLIENT_ID: string; @@ -28,6 +29,7 @@ declare namespace Cloudflare { AI: Ai; CLOUDFLARE_API_BASE: "https://api.cloudflare.com/client/v4"; CLOUDFLARE_OAUTH_DOMAIN: "https://dash.cloudflare.com"; + MCP_RESOURCE: "https://mcp.cloudflare.com/mcp"; OPENAPI_SPEC_URL: "https://raw.githubusercontent.com/cloudflare/api-schemas/main/openapi.json"; MCP_COOKIE_ENCRYPTION_KEY: string; CLOUDFLARE_CLIENT_ID: string; @@ -47,6 +49,7 @@ declare namespace Cloudflare { AI?: Ai; CLOUDFLARE_API_BASE: "https://api.staging.cloudflare.com/client/v4" | "https://api.cloudflare.com/client/v4"; CLOUDFLARE_OAUTH_DOMAIN: "https://dash.staging.cloudflare.com" | "https://dash.cloudflare.com"; + MCP_RESOURCE: "https://staging.mcp.cloudflare.com/mcp" | "https://mcp.cloudflare.com/mcp" | "http://localhost:2529/mcp"; OPENAPI_SPEC_URL: "https://raw.githubusercontent.com/cloudflare/api-schemas/main/openapi.json"; GLOBAL_OUTBOUND: Service /* entrypoint GlobalOutbound from cloudflare-api-mcp-staging */ | Service /* entrypoint GlobalOutbound from cloudflare-api-mcp */ | Service; } @@ -56,7 +59,7 @@ type StringifyValues> = { [Binding in keyof EnvType]: EnvType[Binding] extends string ? EnvType[Binding] : string; }; declare namespace NodeJS { - interface ProcessEnv extends StringifyValues> {} + interface ProcessEnv extends StringifyValues> {} } // Begin runtime types diff --git a/wrangler.jsonc b/wrangler.jsonc index bce4984..dcf83d1 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -18,6 +18,7 @@ "vars": { "CLOUDFLARE_API_BASE": "https://api.cloudflare.com/client/v4", "CLOUDFLARE_OAUTH_DOMAIN": "https://dash.cloudflare.com", + "MCP_RESOURCE": "http://localhost:2529/mcp", "OPENAPI_SPEC_URL": "https://raw.githubusercontent.com/cloudflare/api-schemas/main/openapi.json" }, "worker_loaders": [ @@ -103,6 +104,7 @@ "vars": { "CLOUDFLARE_API_BASE": "https://api.staging.cloudflare.com/client/v4", "CLOUDFLARE_OAUTH_DOMAIN": "https://dash.staging.cloudflare.com", + "MCP_RESOURCE": "https://staging.mcp.cloudflare.com/mcp", "OPENAPI_SPEC_URL": "https://raw.githubusercontent.com/cloudflare/api-schemas/main/openapi.json" } }, @@ -153,6 +155,7 @@ "vars": { "CLOUDFLARE_API_BASE": "https://api.cloudflare.com/client/v4", "CLOUDFLARE_OAUTH_DOMAIN": "https://dash.cloudflare.com", + "MCP_RESOURCE": "https://mcp.cloudflare.com/mcp", "OPENAPI_SPEC_URL": "https://raw.githubusercontent.com/cloudflare/api-schemas/main/openapi.json" } }