44 * ports are two origins, and loopback is reachable under the self-hosted profile.
55 */
66import { createServer , type IncomingHttpHeaders , type Server } from 'node:http'
7- import type { AddressInfo , Socket } from 'node:net'
8- import { exchangeAuthorization } from '@modelcontextprotocol/sdk/client/auth.js'
9- import { ServerError } from '@modelcontextprotocol/sdk/server/auth/errors.js'
7+ import type { AddressInfo } from 'node:net'
8+ import type { OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth.js'
109import { LATEST_PROTOCOL_VERSION } from '@modelcontextprotocol/sdk/types.js'
1110import { afterEach , describe , expect , it , vi } from 'vitest'
1211import { McpClient } from '@/lib/mcp/client'
13- import { createSsrfGuardedMcpFetch } from '@/lib/mcp/pinned-fetch'
12+ import { mcpAuthGuarded } from '@/lib/mcp/oauth/auth'
13+ import { createGuardedMcpFetch } from '@/lib/mcp/pinned-fetch'
1414
1515interface RecordedRequest {
1616 method : string
@@ -21,7 +21,6 @@ interface RecordedRequest {
2121interface LocalServer {
2222 origin : string
2323 requests : RecordedRequest [ ]
24- openSockets : Set < Socket >
2524 close : ( ) => Promise < void >
2625}
2726
@@ -35,7 +34,6 @@ const servers: LocalServer[] = []
3534
3635async function startServer ( handler : Handler ) : Promise < LocalServer > {
3736 const requests : RecordedRequest [ ] = [ ]
38- const openSockets = new Set < Socket > ( )
3937 const server : Server = createServer ( ( req , res ) => {
4038 const chunks : Buffer [ ] = [ ]
4139 req . on ( 'data' , ( chunk : Buffer ) => chunks . push ( chunk ) )
@@ -49,16 +47,11 @@ async function startServer(handler: Handler): Promise<LocalServer> {
4947 } )
5048 } )
5149 } )
52- server . on ( 'connection' , ( socket ) => {
53- openSockets . add ( socket )
54- socket . on ( 'close' , ( ) => openSockets . delete ( socket ) )
55- } )
5650 await new Promise < void > ( ( resolve ) => server . listen ( 0 , '127.0.0.1' , resolve ) )
5751 const { port } = server . address ( ) as AddressInfo
5852 const local : LocalServer = {
5953 origin : `http://127.0.0.1:${ port } ` ,
6054 requests,
61- openSockets,
6255 close : ( ) =>
6356 new Promise < void > ( ( resolve ) => {
6457 server . closeAllConnections ( )
@@ -134,37 +127,116 @@ describe('MCP transport redirects', () => {
134127 } )
135128} )
136129
137- describe ( 'guarded MCP OAuth fetch' , ( ) => {
138- it ( 'hands an unfollowed cross-origin token redirect back to the SDK, naming its target, and releases the socket' , async ( ) => {
139- const elsewhere = await startServer ( ( _request , _body , respond ) => respond ( 200 , { } , '{}' ) )
140- const authServer = await startServer ( ( _request , _body , respond ) => {
141- respond ( 302 , { location : `${ elsewhere . origin } /token` } , 'moved' )
142- } )
130+ const OAUTH_FETCHES = [
131+ { name : 'the default OAuth fetch' , fetchFn : ( ) => undefined } ,
132+ {
133+ name : 'the transport fetch reused by coordinated refresh' ,
134+ fetchFn : ( serverUrl : string ) => createGuardedMcpFetch ( serverUrl ) . fetch ,
135+ } ,
136+ ] as const
143137
144- const exchange = exchangeAuthorization ( authServer . origin , {
145- metadata : {
146- issuer : authServer . origin ,
147- authorization_endpoint : `${ authServer . origin } /authorize` ,
148- token_endpoint : `${ authServer . origin } /token` ,
149- response_types_supported : [ 'code' ] ,
150- } ,
151- clientInformation : { client_id : 'client-1' } ,
152- authorizationCode : 'code-1' ,
153- codeVerifier : 'verifier-1' ,
154- redirectUri : 'http://127.0.0.1/callback' ,
155- fetchFn : createSsrfGuardedMcpFetch ( {
156- serverUrl : `${ authServer . origin } /mcp` ,
157- timeoutMs : 5_000 ,
158- } ) ,
159- } )
138+ function recordingProvider ( ) {
139+ const authorizationUrls : URL [ ] = [ ]
140+ const provider : OAuthClientProvider = {
141+ get redirectUrl ( ) {
142+ return 'http://127.0.0.1/callback'
143+ } ,
144+ get clientMetadata ( ) {
145+ return { redirect_uris : [ 'http://127.0.0.1/callback' ] }
146+ } ,
147+ clientInformation : ( ) => ( { client_id : 'client-1' } ) ,
148+ tokens : ( ) => undefined ,
149+ saveTokens : ( ) => { } ,
150+ redirectToAuthorization : ( url ) => {
151+ authorizationUrls . push ( url )
152+ } ,
153+ saveCodeVerifier : ( ) => { } ,
154+ codeVerifier : ( ) => 'verifier-1' ,
155+ }
156+ return { provider, authorizationUrls }
157+ }
160158
161- const error = await exchange . catch ( ( caught : unknown ) => caught )
162- expect ( error ) . toBeInstanceOf ( ServerError )
163- expect ( ( error as Error ) . message ) . toBe (
164- `HTTP 302: Redirect to ${ elsewhere . origin } /token not followed`
165- )
166- expect ( authServer . requests . map ( ( request ) => request . method ) ) . toEqual ( [ 'POST' ] )
167- expect ( elsewhere . requests ) . toHaveLength ( 0 )
168- await vi . waitFor ( ( ) => expect ( authServer . openSockets . size ) . toBe ( 0 ) , { timeout : 2_000 } )
159+ function authorizationServerMetadata ( origin : string ) {
160+ return JSON . stringify ( {
161+ issuer : origin ,
162+ authorization_endpoint : `${ origin } /authorize` ,
163+ token_endpoint : `${ origin } /token` ,
164+ response_types_supported : [ 'code' ] ,
165+ code_challenge_methods_supported : [ 'S256' ] ,
169166 } )
167+ }
168+
169+ describe ( 'MCP OAuth redirects' , ( ) => {
170+ it . each ( OAUTH_FETCHES ) (
171+ 'follows a cross-origin redirect of protected-resource discovery through $name, without its headers' ,
172+ async ( { fetchFn } ) => {
173+ const json = { 'content-type' : 'application/json' }
174+ let apexOrigin = ''
175+ const www = await startServer ( ( _request , _body , respond ) => {
176+ respond (
177+ 200 ,
178+ json ,
179+ JSON . stringify ( { resource : `${ apexOrigin } /mcp` , authorization_servers : [ apexOrigin ] } )
180+ )
181+ } )
182+ const apex = await startServer ( ( request , _body , respond ) => {
183+ if ( request . url === '/.well-known/oauth-protected-resource/mcp' ) {
184+ respond ( 302 , { location : `${ www . origin } ${ request . url } ` } , '' )
185+ } else if ( request . url === '/.well-known/oauth-authorization-server' ) {
186+ respond ( 200 , json , authorizationServerMetadata ( apexOrigin ) )
187+ } else {
188+ respond ( 404 , { } , '' )
189+ }
190+ } )
191+ apexOrigin = apex . origin
192+ const serverUrl = `${ apex . origin } /mcp`
193+ const { provider, authorizationUrls } = recordingProvider ( )
194+
195+ await expect (
196+ mcpAuthGuarded ( provider , { serverUrl, fetchFn : fetchFn ( serverUrl ) } )
197+ ) . resolves . toBe ( 'REDIRECT' )
198+
199+ expect ( apex . requests [ 0 ] . headers [ 'mcp-protocol-version' ] ) . toBeDefined ( )
200+ expect ( www . requests ) . toHaveLength ( 1 )
201+ expect ( www . requests [ 0 ] . headers [ 'mcp-protocol-version' ] ) . toBeUndefined ( )
202+ expect ( www . requests [ 0 ] . headers . authorization ) . toBeUndefined ( )
203+ expect ( authorizationUrls ) . toHaveLength ( 1 )
204+ expect ( authorizationUrls [ 0 ] . searchParams . get ( 'resource' ) ) . toBe ( serverUrl )
205+ }
206+ )
207+
208+ it . each ( OAUTH_FETCHES ) (
209+ 'refuses a cross-origin 307 of the token request through $name, as it would forward the body' ,
210+ async ( { fetchFn } ) => {
211+ const elsewhere = await startServer ( ( _request , _body , respond ) => respond ( 200 , { } , '{}' ) )
212+ const apex = await startServer ( ( request , _body , respond ) => {
213+ if ( request . url === '/.well-known/oauth-authorization-server' ) {
214+ respond (
215+ 200 ,
216+ { 'content-type' : 'application/json' } ,
217+ authorizationServerMetadata ( apex . origin )
218+ )
219+ } else if ( request . url === '/token' ) {
220+ respond ( 307 , { location : `${ elsewhere . origin } /token` } , '' )
221+ } else {
222+ respond ( 404 , { } , '' )
223+ }
224+ } )
225+ const serverUrl = `${ apex . origin } /mcp`
226+ const { provider } = recordingProvider ( )
227+
228+ await expect (
229+ mcpAuthGuarded ( provider , {
230+ serverUrl,
231+ authorizationCode : 'code-1' ,
232+ fetchFn : fetchFn ( serverUrl ) ,
233+ } )
234+ ) . rejects . toThrow (
235+ 'Blocked by SSRF policy: cross-origin redirect would forward a request body'
236+ )
237+
238+ expect ( apex . requests . filter ( ( request ) => request . url === '/token' ) ) . toHaveLength ( 1 )
239+ expect ( elsewhere . requests ) . toHaveLength ( 0 )
240+ }
241+ )
170242} )
0 commit comments