Skip to content

Commit ebbe694

Browse files
committed
feat: pin OAuth tokens to the MCP resource
1 parent b21ae22 commit ebbe694

8 files changed

Lines changed: 96 additions & 15 deletions

File tree

package-lock.json

Lines changed: 4 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
"seed:prod": "tsx scripts/seed-r2.ts production"
2020
},
2121
"dependencies": {
22-
"@cloudflare/workers-oauth-provider": "^0.8.0",
22+
"@cloudflare/workers-oauth-provider": "https://pkg.pr.new/cloudflare/workers-oauth-provider/@cloudflare/workers-oauth-provider@256",
2323
"@modelcontextprotocol/server": "2.0.0-beta.4",
2424
"hono": "^4.12.25",
2525
"zod": "^4.3.5"

src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ export default {
6363
() => getOAuthApi(oauthOptions, env)
6464
),
6565
resourceMetadata: {
66+
resource: env.MCP_RESOURCE,
6667
resource_name: 'Cloudflare API MCP Server'
6768
},
6869
accessTokenTTL: 3600,

tests/auth/oauth-routes.test.ts

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

2121
const REDIRECT_URI = 'https://app.example.com/cb'
2222
const MCP_ORIGIN = 'https://mcp.cloudflare.com'
23+
const MCP_RESOURCE = `${MCP_ORIGIN}/mcp`
24+
const DOWNSTREAM_CODE_VERIFIER = 'test-downstream-code-verifier'
25+
const DOWNSTREAM_CODE_CHALLENGE = 'I4fhllfHqqQsgap17V2SDI0scSei8H7U0e0rZBDIcbo'
2326

2427
/** Register a client via the provider's RFC 7591 endpoint; returns its id. */
2528
async function registerClient(): Promise<string> {
@@ -56,6 +59,9 @@ async function beginAuthorization(options: { state?: string; scopes?: string } =
5659
response_type: 'code',
5760
client_id: clientId,
5861
redirect_uri: REDIRECT_URI,
62+
resource: MCP_RESOURCE,
63+
code_challenge: DOWNSTREAM_CODE_CHALLENGE,
64+
code_challenge_method: 'S256',
5965
scope: 'user:read',
6066
...(options.state === undefined ? {} : { state: options.state })
6167
})
@@ -136,6 +142,29 @@ afterEach(async () => {
136142
await clearKv(env.OAUTH_KV)
137143
})
138144

145+
describe('OAuth metadata policy', () => {
146+
it('advertises the canonical MCP endpoint as the protected resource', async () => {
147+
const response = await exports.default.fetch(
148+
new Request(`${MCP_ORIGIN}/.well-known/oauth-protected-resource/mcp`)
149+
)
150+
151+
expect(response.status).toBe(200)
152+
await expect(response.json()).resolves.toMatchObject({ resource: MCP_RESOURCE })
153+
})
154+
155+
it('advertises RFC 9207 authorization response issuer support', async () => {
156+
const response = await exports.default.fetch(
157+
new Request(`${MCP_ORIGIN}/.well-known/oauth-authorization-server`)
158+
)
159+
160+
expect(response.status).toBe(200)
161+
await expect(response.json()).resolves.toMatchObject({
162+
issuer: MCP_ORIGIN,
163+
authorization_response_iss_parameter_supported: true
164+
})
165+
})
166+
})
167+
139168
describe('GET /authorize', () => {
140169
it('renders the consent dialog for a registered client', async () => {
141170
const clientId = await registerClient()
@@ -146,6 +175,9 @@ describe('GET /authorize', () => {
146175
response_type: 'code',
147176
client_id: clientId,
148177
redirect_uri: REDIRECT_URI,
178+
resource: MCP_RESOURCE,
179+
code_challenge: DOWNSTREAM_CODE_CHALLENGE,
180+
code_challenge_method: 'S256',
149181
scope: 'user:read'
150182
})
151183
)
@@ -160,13 +192,37 @@ describe('GET /authorize', () => {
160192
expect(writtenEvents(metricsSpy)).not.toContain('auth_user')
161193
})
162194

195+
it('rejects a resource other than the canonical MCP endpoint', async () => {
196+
const clientId = await registerClient()
197+
const res = await exports.default.fetch(
198+
new Request(
199+
authorizeUrl({
200+
response_type: 'code',
201+
client_id: clientId,
202+
redirect_uri: REDIRECT_URI,
203+
resource: MCP_ORIGIN,
204+
code_challenge: DOWNSTREAM_CODE_CHALLENGE,
205+
code_challenge_method: 'S256'
206+
})
207+
)
208+
)
209+
210+
expect(res.status).toBe(500)
211+
expect(await res.text()).toContain('Server Error')
212+
expect(writtenEvents(metricsSpy)).toContain('auth_user')
213+
expect((await env.OAUTH_KV.list({ prefix: 'grant:' })).keys).toHaveLength(0)
214+
})
215+
163216
it('logs an auth_user error and 500s for an unknown client', async () => {
164217
const res = await exports.default.fetch(
165218
new Request(
166219
authorizeUrl({
167220
response_type: 'code',
168221
client_id: 'does-not-exist',
169-
redirect_uri: REDIRECT_URI
222+
redirect_uri: REDIRECT_URI,
223+
resource: MCP_RESOURCE,
224+
code_challenge: DOWNSTREAM_CODE_CHALLENGE,
225+
code_challenge_method: 'S256'
170226
})
171227
)
172228
)
@@ -208,6 +264,7 @@ describe('GET /oauth/callback', () => {
208264
const redirect = new URL(cbRes.headers.get('location')!)
209265
expect(redirect.origin + redirect.pathname).toBe(REDIRECT_URI)
210266
expect(redirect.searchParams.get('code')).toBeTruthy()
267+
expect(redirect.searchParams.get('iss')).toBe(MCP_ORIGIN)
211268

212269
// A successful login records an auth_user datapoint with the userId (blob3)
213270
// and no error message (blob4).
@@ -232,16 +289,28 @@ describe('GET /oauth/callback', () => {
232289
const code = new URL(callback.headers.get('location')!).searchParams.get('code')
233290
expect(code).toBeTruthy()
234291

292+
const tokenParams = {
293+
grant_type: 'authorization_code',
294+
code: code!,
295+
client_id: clientId,
296+
redirect_uri: REDIRECT_URI,
297+
code_verifier: DOWNSTREAM_CODE_VERIFIER
298+
}
299+
const missingResourceResponse = await exports.default.fetch(
300+
new Request(`${MCP_ORIGIN}/token`, {
301+
method: 'POST',
302+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
303+
body: new URLSearchParams(tokenParams).toString()
304+
})
305+
)
306+
expect(missingResourceResponse.status).toBe(400)
307+
await expect(missingResourceResponse.json()).resolves.toMatchObject({ error: 'invalid_target' })
308+
235309
const tokenResponse = await exports.default.fetch(
236310
new Request(`${MCP_ORIGIN}/token`, {
237311
method: 'POST',
238312
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
239-
body: new URLSearchParams({
240-
grant_type: 'authorization_code',
241-
code: code!,
242-
client_id: clientId,
243-
redirect_uri: REDIRECT_URI
244-
}).toString()
313+
body: new URLSearchParams({ ...tokenParams, resource: MCP_RESOURCE }).toString()
245314
})
246315
)
247316
expect(tokenResponse.status).toBe(200)

tests/mcp-modern.test.ts

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

2222
const API_TOKEN = 'modern-mcp-token'
2323
const ACCOUNT_ID = '00000000000000000000000000000001'
24+
const MCP_ORIGIN = new URL(MCP_URL).origin
2425

2526
const SPEC_PATHS = {
2627
'/accounts/{account_id}/workers/scripts': {
@@ -249,7 +250,10 @@ describe('MCP 2026-07-28 stateless handler', () => {
249250
)
250251

251252
expect(response.status).toBe(401)
252-
expect(await response.json()).toMatchObject({ error: 'invalid_token' })
253+
expect(response.headers.get('WWW-Authenticate')).toContain(
254+
`resource_metadata="${MCP_ORIGIN}/.well-known/oauth-protected-resource/mcp"`
255+
)
256+
expect(await response.text()).toBe('')
253257
})
254258
})
255259

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)