@@ -21,6 +21,7 @@ import { server } from '../setup/msw'
2121
2222const REDIRECT_URI = 'https://app.example.com/cb'
2323const MCP_ORIGIN = 'https://mcp.cloudflare.com'
24+ const MCP_RESOURCE = `${ MCP_ORIGIN } /mcp`
2425const DOWNSTREAM_CODE_VERIFIER = 'test-downstream-code-verifier'
2526const DOWNSTREAM_CODE_CHALLENGE = 'I4fhllfHqqQsgap17V2SDI0scSei8H7U0e0rZBDIcbo'
2627
@@ -42,6 +43,7 @@ async function registerClient(): Promise<string> {
4243
4344function 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+
166192describe ( '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 )
0 commit comments