Skip to content

Commit 855732b

Browse files
authored
feat: pin OAuth tokens to the MCP resource (#183)
1 parent 21cef36 commit 855732b

7 files changed

Lines changed: 96 additions & 13 deletions

File tree

src/auth/api-token-mode.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,12 @@ export async function resolveExternalToken({
120120
const tokenOwner = cloudflareTokenOwner(token)
121121
try {
122122
const identity = await getCachedIdentity(token, tokenOwner, env.OAUTH_KV)
123-
return { props: buildAuthProps(token, identity) }
123+
return {
124+
props: buildAuthProps(token, identity),
125+
// Cloudflare API tokens are opaque credentials, so successful identity
126+
// validation establishes their local protected-resource audience.
127+
audience: env.MCP_RESOURCE
128+
}
124129
} catch (error) {
125130
if (error instanceof OAuthError) throw externalTokenError(error, tokenOwner)
126131
throw error

src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ export default {
5656
() => getOAuthApi(oauthOptions, env)
5757
),
5858
resourceMetadata: {
59+
resource: env.MCP_RESOURCE,
5960
resource_name: 'Cloudflare API MCP Server'
6061
},
6162
accessTokenTTL: 3600,

tests/auth/api-token-mode.test.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,8 @@ describe('resolveExternalToken', () => {
131131
type: 'account_token',
132132
accessToken: 'cfat_account-token',
133133
account: ACCOUNT
134-
}
134+
},
135+
audience: env.MCP_RESOURCE
135136
})
136137
expect(calls.userCalls()).toBe(0)
137138
expect(calls.accountCalls()).toBe(1)
@@ -146,7 +147,8 @@ describe('resolveExternalToken', () => {
146147
})
147148

148149
await expect(resolveExternalToken(resolverInput(token))).resolves.toMatchObject({
149-
props: { type: 'user_token', accessToken: token, user: USER, accounts: [ACCOUNT] }
150+
props: { type: 'user_token', accessToken: token, user: USER, accounts: [ACCOUNT] },
151+
audience: env.MCP_RESOURCE
150152
})
151153
expect(calls.userCalls()).toBe(1)
152154
expect(calls.accountCalls()).toBe(1)

tests/auth/oauth-routes.test.ts

Lines changed: 76 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { server } from '../setup/msw'
2121

2222
const REDIRECT_URI = 'https://app.example.com/cb'
2323
const MCP_ORIGIN = 'https://mcp.cloudflare.com'
24+
const MCP_RESOURCE = `${MCP_ORIGIN}/mcp`
2425
const DOWNSTREAM_CODE_VERIFIER = 'test-downstream-code-verifier'
2526
const DOWNSTREAM_CODE_CHALLENGE = 'I4fhllfHqqQsgap17V2SDI0scSei8H7U0e0rZBDIcbo'
2627

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

4344
function authorizeUrl(params: Record<string, string>): string {
4445
const u = new URL(`${MCP_ORIGIN}/authorize`)
46+
u.searchParams.set('resource', MCP_RESOURCE)
4547
for (const [k, v] of Object.entries(params)) u.searchParams.set(k, v)
4648
u.searchParams.set('code_challenge', DOWNSTREAM_CODE_CHALLENGE)
4749
u.searchParams.set('code_challenge_method', 'S256')
@@ -73,6 +75,7 @@ async function beginAuthorization(options: { state?: string; scopes?: string } =
7375
response_type: 'code',
7476
client_id: clientId,
7577
redirect_uri: REDIRECT_URI,
78+
resource: MCP_RESOURCE,
7679
code_challenge: DOWNSTREAM_CODE_CHALLENGE,
7780
code_challenge_method: 'S256',
7881
scope: options.scopes ?? 'user:read',
@@ -163,6 +166,29 @@ afterEach(async () => {
163166
await clearKv(env.OAUTH_KV)
164167
})
165168

169+
describe('OAuth metadata policy', () => {
170+
it('advertises the canonical MCP endpoint as the protected resource', async () => {
171+
const response = await exports.default.fetch(
172+
new Request(`${MCP_ORIGIN}/.well-known/oauth-protected-resource/mcp`)
173+
)
174+
175+
expect(response.status).toBe(200)
176+
await expect(response.json()).resolves.toMatchObject({ resource: MCP_RESOURCE })
177+
})
178+
179+
it('advertises RFC 9207 authorization response issuer support', async () => {
180+
const response = await exports.default.fetch(
181+
new Request(`${MCP_ORIGIN}/.well-known/oauth-authorization-server`)
182+
)
183+
184+
expect(response.status).toBe(200)
185+
await expect(response.json()).resolves.toMatchObject({
186+
issuer: MCP_ORIGIN,
187+
authorization_response_iss_parameter_supported: true
188+
})
189+
})
190+
})
191+
166192
describe('GET /authorize', () => {
167193
it('renders the consent dialog for a registered client', async () => {
168194
const clientId = await registerClient()
@@ -173,6 +199,7 @@ describe('GET /authorize', () => {
173199
response_type: 'code',
174200
client_id: clientId,
175201
redirect_uri: REDIRECT_URI,
202+
resource: MCP_RESOURCE,
176203
code_challenge: DOWNSTREAM_CODE_CHALLENGE,
177204
code_challenge_method: 'S256',
178205
scope: 'user:read'
@@ -228,6 +255,32 @@ describe('GET /authorize', () => {
228255
])
229256
})
230257

258+
it('rejects a resource other than the canonical MCP endpoint', async () => {
259+
const clientId = await registerClient()
260+
const response = await exports.default.fetch(
261+
new Request(
262+
authorizeUrl({
263+
response_type: 'code',
264+
client_id: clientId,
265+
redirect_uri: REDIRECT_URI,
266+
resource: MCP_ORIGIN,
267+
state: 'client-state'
268+
})
269+
),
270+
{ redirect: 'manual' }
271+
)
272+
273+
expect(response.status).toBe(302)
274+
const redirect = new URL(response.headers.get('location')!)
275+
expect(redirect.origin + redirect.pathname).toBe(REDIRECT_URI)
276+
expect(redirect.searchParams.get('error')).toBe('invalid_request')
277+
expect(redirect.searchParams.get('state')).toBe('client-state')
278+
expect(redirect.searchParams.get('iss')).toBe(MCP_ORIGIN)
279+
expect(response.headers.get('cache-control')).toBe('no-store')
280+
expect(writtenEvents(metricsSpy)).not.toContain('auth_user')
281+
expect((await env.OAUTH_KV.list({ prefix: 'grant:' })).keys).toHaveLength(0)
282+
})
283+
231284
it('rejects unknown requested scopes instead of silently downgrading them', async () => {
232285
const clientId = await registerClient()
233286
const response = await exports.default.fetch(
@@ -338,6 +391,7 @@ describe('GET /authorize', () => {
338391
response_type: 'code',
339392
client_id: clientId,
340393
redirect_uri: REDIRECT_URI,
394+
resource: MCP_RESOURCE,
341395
state: 'client-state'
342396
}).toString()
343397
const response = await exports.default.fetch(new Request(url), { redirect: 'manual' })
@@ -381,6 +435,7 @@ describe('GET /authorize', () => {
381435
response_type: 'code',
382436
client_id: 'does-not-exist',
383437
redirect_uri: REDIRECT_URI,
438+
resource: MCP_RESOURCE,
384439
code_challenge: DOWNSTREAM_CODE_CHALLENGE,
385440
code_challenge_method: 'S256'
386441
})
@@ -466,6 +521,7 @@ describe('GET /oauth/callback', () => {
466521
const redirect = new URL(cbRes.headers.get('location')!)
467522
expect(redirect.origin + redirect.pathname).toBe(REDIRECT_URI)
468523
expect(redirect.searchParams.get('code')).toBeTruthy()
524+
expect(redirect.searchParams.get('iss')).toBe(MCP_ORIGIN)
469525

470526
// A successful login records an auth_user datapoint with the userId (blob3)
471527
// and no error message (blob4).
@@ -497,17 +553,28 @@ describe('GET /oauth/callback', () => {
497553
const code = new URL(callback.headers.get('location')!).searchParams.get('code')
498554
expect(code).toBeTruthy()
499555

556+
const tokenParams = {
557+
grant_type: 'authorization_code',
558+
code: code!,
559+
client_id: clientId,
560+
redirect_uri: REDIRECT_URI,
561+
code_verifier: DOWNSTREAM_CODE_VERIFIER
562+
}
563+
const missingResourceResponse = await exports.default.fetch(
564+
new Request(`${MCP_ORIGIN}/token`, {
565+
method: 'POST',
566+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
567+
body: new URLSearchParams(tokenParams).toString()
568+
})
569+
)
570+
expect(missingResourceResponse.status).toBe(400)
571+
await expect(missingResourceResponse.json()).resolves.toMatchObject({ error: 'invalid_target' })
572+
500573
const tokenResponse = await exports.default.fetch(
501574
new Request(`${MCP_ORIGIN}/token`, {
502575
method: 'POST',
503576
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
504-
body: new URLSearchParams({
505-
grant_type: 'authorization_code',
506-
code: code!,
507-
client_id: clientId,
508-
redirect_uri: REDIRECT_URI,
509-
code_verifier: DOWNSTREAM_CODE_VERIFIER
510-
}).toString()
577+
body: new URLSearchParams({ ...tokenParams, resource: MCP_RESOURCE }).toString()
511578
})
512579
)
513580
expect(tokenResponse.status).toBe(200)
@@ -538,7 +605,8 @@ describe('GET /oauth/callback', () => {
538605
code: code!,
539606
client_id: clientId,
540607
redirect_uri: REDIRECT_URI,
541-
code_verifier: DOWNSTREAM_CODE_VERIFIER
608+
code_verifier: DOWNSTREAM_CODE_VERIFIER,
609+
resource: MCP_RESOURCE
542610
}).toString()
543611
})
544612
)

vitest.config.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@ export default defineConfig({
1111
bindings: {
1212
MCP_COOKIE_ENCRYPTION_KEY: 'test-cookie-encryption-key-0000000000000000',
1313
CLOUDFLARE_CLIENT_ID: 'test-client-id',
14-
CLOUDFLARE_CLIENT_SECRET: 'test-client-secret'
14+
CLOUDFLARE_CLIENT_SECRET: 'test-client-secret',
15+
MCP_RESOURCE: 'https://mcp.cloudflare.com/mcp'
1516
}
1617
}
1718
})

worker-configuration.d.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ declare namespace Cloudflare {
1313
AI: Ai;
1414
CLOUDFLARE_API_BASE: "https://api.staging.cloudflare.com/client/v4";
1515
CLOUDFLARE_OAUTH_DOMAIN: "https://dash.staging.cloudflare.com";
16+
MCP_RESOURCE: "https://staging.mcp.cloudflare.com/mcp";
1617
OPENAPI_SPEC_URL: "https://raw.githubusercontent.com/cloudflare/api-schemas/main/openapi.json";
1718
MCP_COOKIE_ENCRYPTION_KEY: string;
1819
CLOUDFLARE_CLIENT_ID: string;
@@ -28,6 +29,7 @@ declare namespace Cloudflare {
2829
AI: Ai;
2930
CLOUDFLARE_API_BASE: "https://api.cloudflare.com/client/v4";
3031
CLOUDFLARE_OAUTH_DOMAIN: "https://dash.cloudflare.com";
32+
MCP_RESOURCE: "https://mcp.cloudflare.com/mcp";
3133
OPENAPI_SPEC_URL: "https://raw.githubusercontent.com/cloudflare/api-schemas/main/openapi.json";
3234
MCP_COOKIE_ENCRYPTION_KEY: string;
3335
CLOUDFLARE_CLIENT_ID: string;
@@ -47,6 +49,7 @@ declare namespace Cloudflare {
4749
AI?: Ai;
4850
CLOUDFLARE_API_BASE: "https://api.staging.cloudflare.com/client/v4" | "https://api.cloudflare.com/client/v4";
4951
CLOUDFLARE_OAUTH_DOMAIN: "https://dash.staging.cloudflare.com" | "https://dash.cloudflare.com";
52+
MCP_RESOURCE: "https://staging.mcp.cloudflare.com/mcp" | "https://mcp.cloudflare.com/mcp" | "http://localhost:2529/mcp";
5053
OPENAPI_SPEC_URL: "https://raw.githubusercontent.com/cloudflare/api-schemas/main/openapi.json";
5154
GLOBAL_OUTBOUND: Service /* entrypoint GlobalOutbound from cloudflare-api-mcp-staging */ | Service /* entrypoint GlobalOutbound from cloudflare-api-mcp */ | Service<typeof import("./src/index").GlobalOutbound>;
5255
}
@@ -56,7 +59,7 @@ type StringifyValues<EnvType extends Record<string, unknown>> = {
5659
[Binding in keyof EnvType]: EnvType[Binding] extends string ? EnvType[Binding] : string;
5760
};
5861
declare namespace NodeJS {
59-
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">> {}
62+
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">> {}
6063
}
6164

6265
// Begin runtime types

wrangler.jsonc

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
"vars": {
1919
"CLOUDFLARE_API_BASE": "https://api.cloudflare.com/client/v4",
2020
"CLOUDFLARE_OAUTH_DOMAIN": "https://dash.cloudflare.com",
21+
"MCP_RESOURCE": "http://localhost:2529/mcp",
2122
"OPENAPI_SPEC_URL": "https://raw.githubusercontent.com/cloudflare/api-schemas/main/openapi.json"
2223
},
2324
"worker_loaders": [
@@ -103,6 +104,7 @@
103104
"vars": {
104105
"CLOUDFLARE_API_BASE": "https://api.staging.cloudflare.com/client/v4",
105106
"CLOUDFLARE_OAUTH_DOMAIN": "https://dash.staging.cloudflare.com",
107+
"MCP_RESOURCE": "https://staging.mcp.cloudflare.com/mcp",
106108
"OPENAPI_SPEC_URL": "https://raw.githubusercontent.com/cloudflare/api-schemas/main/openapi.json"
107109
}
108110
},
@@ -153,6 +155,7 @@
153155
"vars": {
154156
"CLOUDFLARE_API_BASE": "https://api.cloudflare.com/client/v4",
155157
"CLOUDFLARE_OAUTH_DOMAIN": "https://dash.cloudflare.com",
158+
"MCP_RESOURCE": "https://mcp.cloudflare.com/mcp",
156159
"OPENAPI_SPEC_URL": "https://raw.githubusercontent.com/cloudflare/api-schemas/main/openapi.json"
157160
}
158161
}

0 commit comments

Comments
 (0)