@@ -19,14 +19,19 @@ const SERVER_URL = "https://mcp.example.com/mcp"
1919const AS_A = "https://as-a.example.com"
2020const AS_B = "https://as-b.example.com"
2121
22- type Recorded = { url : string ; method : string ; body : string }
22+ const CONFIGURED = { clientId : "configured-client" , clientSecret : "configured-secret" }
23+
24+ // `sent` is everything that could carry a credential: the body and a decoded Basic auth header.
25+ type Recorded = { url : URL ; sent : string }
2326
2427function authServer ( advertised : ( ) => string ) {
2528 const requests : Recorded [ ] = [ ]
2629 const fetchFn = async ( input : string | URL | Request , init ?: RequestInit ) => {
2730 const url = new URL ( input instanceof Request ? input . url : String ( input ) )
2831 const body = init ?. body ? String ( init . body ) : ""
29- requests . push ( { url : url . href , method : init ?. method ?? "GET" , body } )
32+ const authorization = new Headers ( init ?. headers ) . get ( "authorization" ) ?? ""
33+ const basic = authorization . startsWith ( "Basic " ) ? atob ( authorization . slice ( 6 ) ) : authorization
34+ requests . push ( { url, sent : `${ body } \n${ basic } ` } )
3035
3136 if ( url . origin === new URL ( SERVER_URL ) . origin && url . pathname . startsWith ( "/.well-known/oauth-protected-resource" ) ) {
3237 return Response . json ( { resource : SERVER_URL , authorization_servers : [ advertised ( ) ] } )
@@ -54,13 +59,18 @@ function authServer(advertised: () => string) {
5459 }
5560 return new Response ( "not found" , { status : 404 } )
5661 }
57- return { requests, fetchFn }
62+ const sentTo = ( origin : string ) => requests . filter ( ( r ) => r . url . origin === origin )
63+ return { requests, sentTo, fetchFn }
5864}
5965
6066function provider ( redirects : URL [ ] = [ ] ) {
6167 return new McpOAuthProvider ( MCP_NAME , SERVER_URL , { } , { onRedirect : ( url ) => void redirects . push ( url ) } )
6268}
6369
70+ function configuredProvider ( ) {
71+ return new McpOAuthProvider ( MCP_NAME , SERVER_URL , CONFIGURED , { onRedirect : ( ) => { } } )
72+ }
73+
6474async function storeCredentials ( issuer : string | undefined ) {
6575 await McpAuth . set (
6676 MCP_NAME ,
@@ -86,28 +96,14 @@ describe("McpOAuthProvider issuer binding", () => {
8696 expect ( ( await p . tokens ( ) ) ?. issuer ) . toBe ( AS_A )
8797 } )
8898
89- test ( "does not copy configured client credentials to storage" , async ( ) => {
90- const p = new McpOAuthProvider (
91- MCP_NAME ,
92- SERVER_URL ,
93- { clientId : "configured" , clientSecret : "s" } ,
94- {
95- onRedirect : ( ) => { } ,
96- } ,
97- )
98- await p . saveClientInformation ( { client_id : "configured" , client_secret : "s" , redirect_uris : [ ] , issuer : AS_A } )
99-
100- expect ( await McpAuth . get ( MCP_NAME ) ) . toBeUndefined ( )
101- } )
102-
10399 test ( "refreshes with the authorization server that issued the tokens" , async ( ) => {
104100 await storeCredentials ( AS_A )
105101 const server = authServer ( ( ) => AS_A )
106102
107103 expect ( await auth ( provider ( ) , { serverUrl : SERVER_URL , fetchFn : server . fetchFn } ) ) . toBe ( "AUTHORIZED" )
108104
109- const refresh = server . requests . find ( ( r ) => r . url === ` ${ AS_A } /token` )
110- expect ( refresh ?. body ) . toContain ( "refresh_token=refresh-a" )
105+ const refresh = server . sentTo ( AS_A ) . find ( ( r ) => r . url . pathname === " /token" )
106+ expect ( refresh ?. sent ) . toContain ( "refresh_token=refresh-a" )
111107 const stored = await McpAuth . get ( MCP_NAME )
112108 expect ( stored ?. tokens ?. accessToken ) . toBe ( "access-from-as-a.example.com" )
113109 expect ( stored ?. tokens ?. issuer ) . toBe ( AS_A )
@@ -120,10 +116,10 @@ describe("McpOAuthProvider issuer binding", () => {
120116
121117 expect ( await auth ( provider ( redirects ) , { serverUrl : SERVER_URL , fetchFn : server . fetchFn } ) ) . toBe ( "REDIRECT" )
122118
123- const sentToB = server . requests . filter ( ( r ) => r . url . startsWith ( AS_B ) ) . map ( ( r ) => r . body )
124- expect ( sentToB . some ( ( body ) => body . includes ( "refresh-a" ) ) ) . toBe ( false )
125- expect ( sentToB . some ( ( body ) => body . includes ( "secret-a" ) ) ) . toBe ( false )
126- expect ( server . requests . some ( ( r ) => r . url === ` ${ AS_B } /token` ) ) . toBe ( false )
119+ const sentToB = server . sentTo ( AS_B )
120+ expect ( sentToB . some ( ( r ) => r . sent . includes ( "refresh-a" ) ) ) . toBe ( false )
121+ expect ( sentToB . some ( ( r ) => r . sent . includes ( "secret-a" ) ) ) . toBe ( false )
122+ expect ( sentToB . some ( ( r ) => r . url . pathname === " /token" ) ) . toBe ( false )
127123 // A fresh registration with the new authorization server replaces the old binding.
128124 expect ( redirects [ 0 ] ?. searchParams . get ( "client_id" ) ) . toBe ( "client-from-as-b.example.com" )
129125 const stored = await McpAuth . get ( MCP_NAME )
@@ -141,3 +137,64 @@ describe("McpOAuthProvider issuer binding", () => {
141137 expect ( stored ?. tokens ?. issuer ) . toBe ( AS_A )
142138 } )
143139} )
140+
141+ describe ( "McpOAuthProvider configured client binding" , ( ) => {
142+ async function storeConfiguredBinding ( issuer : string ) {
143+ await McpAuth . set (
144+ MCP_NAME ,
145+ {
146+ clientInfo : { clientId : CONFIGURED . clientId , issuer } ,
147+ tokens : { accessToken : "access-a" , refreshToken : "refresh-a" , expiresAt : Date . now ( ) / 1000 - 60 , issuer } ,
148+ oauthState : "state" ,
149+ } ,
150+ SERVER_URL ,
151+ )
152+ }
153+
154+ test ( "binds configured credentials on first use without storing the secret" , async ( ) => {
155+ await McpAuth . set (
156+ MCP_NAME ,
157+ { tokens : { accessToken : "access-a" , refreshToken : "refresh-a" , expiresAt : Date . now ( ) / 1000 - 60 } } ,
158+ SERVER_URL ,
159+ )
160+ const server = authServer ( ( ) => AS_A )
161+
162+ expect ( await auth ( configuredProvider ( ) , { serverUrl : SERVER_URL , fetchFn : server . fetchFn } ) ) . toBe ( "AUTHORIZED" )
163+
164+ expect ( server . sentTo ( AS_A ) . find ( ( r ) => r . url . pathname === "/token" ) ?. sent ) . toContain ( CONFIGURED . clientSecret )
165+ const stored = await McpAuth . get ( MCP_NAME )
166+ expect ( stored ?. clientInfo ) . toEqual ( { clientId : CONFIGURED . clientId , issuer : AS_A } )
167+ expect ( ( await configuredProvider ( ) . clientInformation ( ) ) ?. issuer ) . toBe ( AS_A )
168+ } )
169+
170+ test ( "does not present configured credentials to a different authorization server" , async ( ) => {
171+ await storeConfiguredBinding ( AS_A )
172+ const server = authServer ( ( ) => AS_B )
173+
174+ // The SDK registers a throwaway client with the new server instead of using the configured one,
175+ expect ( await auth ( configuredProvider ( ) , { serverUrl : SERVER_URL , fetchFn : server . fetchFn } ) ) . toBe ( "REDIRECT" )
176+ // and the code exchange that would carry the configured secret is refused.
177+ await expect (
178+ auth ( configuredProvider ( ) , { serverUrl : SERVER_URL , authorizationCode : "code" , fetchFn : server . fetchFn } ) ,
179+ ) . rejects . toThrow ( )
180+
181+ const sentToB = server . sentTo ( AS_B )
182+ expect ( sentToB . some ( ( r ) => r . sent . includes ( CONFIGURED . clientSecret ) ) ) . toBe ( false )
183+ expect ( sentToB . some ( ( r ) => r . sent . includes ( "refresh-a" ) ) ) . toBe ( false )
184+ expect ( sentToB . some ( ( r ) => r . url . pathname === "/token" ) ) . toBe ( false )
185+ expect ( ( await McpAuth . get ( MCP_NAME ) ) ?. clientInfo ) . toEqual ( { clientId : CONFIGURED . clientId , issuer : AS_A } )
186+ } )
187+
188+ test ( "keeps the configured binding when the SDK invalidates credentials" , async ( ) => {
189+ await storeConfiguredBinding ( AS_A )
190+ const p = configuredProvider ( )
191+
192+ await p . invalidateCredentials ( "client" )
193+ await p . invalidateCredentials ( "all" )
194+
195+ const stored = await McpAuth . get ( MCP_NAME )
196+ expect ( stored ?. clientInfo ) . toEqual ( { clientId : CONFIGURED . clientId , issuer : AS_A } )
197+ expect ( stored ?. tokens ) . toBeUndefined ( )
198+ expect ( ( await p . clientInformation ( ) ) ?. issuer ) . toBe ( AS_A )
199+ } )
200+ } )
0 commit comments