Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion src/auth/api-token-mode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ export default {
() => getOAuthApi(oauthOptions, env)
),
resourceMetadata: {
resource: env.MCP_RESOURCE,
resource_name: 'Cloudflare API MCP Server'
},
accessTokenTTL: 3600,
Expand Down
6 changes: 4 additions & 2 deletions tests/auth/api-token-mode.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand Down
84 changes: 76 additions & 8 deletions tests/auth/oauth-routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand All @@ -42,6 +43,7 @@ async function registerClient(): Promise<string> {

function authorizeUrl(params: Record<string, string>): 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')
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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()
Expand All @@ -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'
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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' })
Expand Down Expand Up @@ -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'
})
Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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()
})
)
Expand Down
3 changes: 2 additions & 1 deletion vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
}
}
})
Expand Down
5 changes: 4 additions & 1 deletion worker-configuration.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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<typeof import("./src/index").GlobalOutbound>;
}
Expand All @@ -56,7 +59,7 @@ type StringifyValues<EnvType extends Record<string, unknown>> = {
[Binding in keyof EnvType]: EnvType[Binding] extends string ? EnvType[Binding] : string;
};
declare namespace NodeJS {
interface ProcessEnv extends StringifyValues<Pick<Cloudflare.Env, "CLOUDFLARE_API_BASE" | "CLOUDFLARE_OAUTH_DOMAIN" | "OPENAPI_SPEC_URL" | "MCP_COOKIE_ENCRYPTION_KEY" | "CLOUDFLARE_CLIENT_ID" | "CLOUDFLARE_CLIENT_SECRET" | "CLOUDFLARE_API_KEY">> {}
interface ProcessEnv extends StringifyValues<Pick<Cloudflare.Env, "CLOUDFLARE_API_BASE" | "CLOUDFLARE_OAUTH_DOMAIN" | "MCP_RESOURCE" | "OPENAPI_SPEC_URL" | "MCP_COOKIE_ENCRYPTION_KEY" | "CLOUDFLARE_CLIENT_ID" | "CLOUDFLARE_CLIENT_SECRET" | "CLOUDFLARE_API_KEY">> {}
}

// Begin runtime types
Expand Down
3 changes: 3 additions & 0 deletions wrangler.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down Expand Up @@ -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"
}
},
Expand Down Expand Up @@ -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"
}
}
Expand Down