@@ -178,6 +178,174 @@ describe("OAuth Authorization", () => {
178
178
await expect ( discoverOAuthProtectedResourceMetadata ( "https://resource.example.com" ) )
179
179
. rejects . toThrow ( ) ;
180
180
} ) ;
181
+
182
+ it ( "returns metadata when discovery succeeds with path" , async ( ) => {
183
+ mockFetch . mockResolvedValueOnce ( {
184
+ ok : true ,
185
+ status : 200 ,
186
+ json : async ( ) => validMetadata ,
187
+ } ) ;
188
+
189
+ const metadata = await discoverOAuthProtectedResourceMetadata ( "https://resource.example.com/path/name" ) ;
190
+ expect ( metadata ) . toEqual ( validMetadata ) ;
191
+ const calls = mockFetch . mock . calls ;
192
+ expect ( calls . length ) . toBe ( 1 ) ;
193
+ const [ url ] = calls [ 0 ] ;
194
+ expect ( url . toString ( ) ) . toBe ( "https://resource.example.com/.well-known/oauth-protected-resource/path/name" ) ;
195
+ } ) ;
196
+
197
+ it ( "preserves query parameters in path-aware discovery" , async ( ) => {
198
+ mockFetch . mockResolvedValueOnce ( {
199
+ ok : true ,
200
+ status : 200 ,
201
+ json : async ( ) => validMetadata ,
202
+ } ) ;
203
+
204
+ const metadata = await discoverOAuthProtectedResourceMetadata ( "https://resource.example.com/path?param=value" ) ;
205
+ expect ( metadata ) . toEqual ( validMetadata ) ;
206
+ const calls = mockFetch . mock . calls ;
207
+ expect ( calls . length ) . toBe ( 1 ) ;
208
+ const [ url ] = calls [ 0 ] ;
209
+ expect ( url . toString ( ) ) . toBe ( "https://resource.example.com/.well-known/oauth-protected-resource/path?param=value" ) ;
210
+ } ) ;
211
+
212
+ it ( "falls back to root discovery when path-aware discovery returns 404" , async ( ) => {
213
+ // First call (path-aware) returns 404
214
+ mockFetch . mockResolvedValueOnce ( {
215
+ ok : false ,
216
+ status : 404 ,
217
+ } ) ;
218
+
219
+ // Second call (root fallback) succeeds
220
+ mockFetch . mockResolvedValueOnce ( {
221
+ ok : true ,
222
+ status : 200 ,
223
+ json : async ( ) => validMetadata ,
224
+ } ) ;
225
+
226
+ const metadata = await discoverOAuthProtectedResourceMetadata ( "https://resource.example.com/path/name" ) ;
227
+ expect ( metadata ) . toEqual ( validMetadata ) ;
228
+
229
+ const calls = mockFetch . mock . calls ;
230
+ expect ( calls . length ) . toBe ( 2 ) ;
231
+
232
+ // First call should be path-aware
233
+ const [ firstUrl , firstOptions ] = calls [ 0 ] ;
234
+ expect ( firstUrl . toString ( ) ) . toBe ( "https://resource.example.com/.well-known/oauth-protected-resource/path/name" ) ;
235
+ expect ( firstOptions . headers ) . toEqual ( {
236
+ "MCP-Protocol-Version" : LATEST_PROTOCOL_VERSION
237
+ } ) ;
238
+
239
+ // Second call should be root fallback
240
+ const [ secondUrl , secondOptions ] = calls [ 1 ] ;
241
+ expect ( secondUrl . toString ( ) ) . toBe ( "https://resource.example.com/.well-known/oauth-protected-resource" ) ;
242
+ expect ( secondOptions . headers ) . toEqual ( {
243
+ "MCP-Protocol-Version" : LATEST_PROTOCOL_VERSION
244
+ } ) ;
245
+ } ) ;
246
+
247
+ it ( "throws error when both path-aware and root discovery return 404" , async ( ) => {
248
+ // First call (path-aware) returns 404
249
+ mockFetch . mockResolvedValueOnce ( {
250
+ ok : false ,
251
+ status : 404 ,
252
+ } ) ;
253
+
254
+ // Second call (root fallback) also returns 404
255
+ mockFetch . mockResolvedValueOnce ( {
256
+ ok : false ,
257
+ status : 404 ,
258
+ } ) ;
259
+
260
+ await expect ( discoverOAuthProtectedResourceMetadata ( "https://resource.example.com/path/name" ) )
261
+ . rejects . toThrow ( "Resource server does not implement OAuth 2.0 Protected Resource Metadata." ) ;
262
+
263
+ const calls = mockFetch . mock . calls ;
264
+ expect ( calls . length ) . toBe ( 2 ) ;
265
+ } ) ;
266
+
267
+ it ( "does not fallback when the original URL is already at root path" , async ( ) => {
268
+ // First call (path-aware for root) returns 404
269
+ mockFetch . mockResolvedValueOnce ( {
270
+ ok : false ,
271
+ status : 404 ,
272
+ } ) ;
273
+
274
+ await expect ( discoverOAuthProtectedResourceMetadata ( "https://resource.example.com/" ) )
275
+ . rejects . toThrow ( "Resource server does not implement OAuth 2.0 Protected Resource Metadata." ) ;
276
+
277
+ const calls = mockFetch . mock . calls ;
278
+ expect ( calls . length ) . toBe ( 1 ) ; // Should not attempt fallback
279
+
280
+ const [ url ] = calls [ 0 ] ;
281
+ expect ( url . toString ( ) ) . toBe ( "https://resource.example.com/.well-known/oauth-protected-resource" ) ;
282
+ } ) ;
283
+
284
+ it ( "does not fallback when the original URL has no path" , async ( ) => {
285
+ // First call (path-aware for no path) returns 404
286
+ mockFetch . mockResolvedValueOnce ( {
287
+ ok : false ,
288
+ status : 404 ,
289
+ } ) ;
290
+
291
+ await expect ( discoverOAuthProtectedResourceMetadata ( "https://resource.example.com" ) )
292
+ . rejects . toThrow ( "Resource server does not implement OAuth 2.0 Protected Resource Metadata." ) ;
293
+
294
+ const calls = mockFetch . mock . calls ;
295
+ expect ( calls . length ) . toBe ( 1 ) ; // Should not attempt fallback
296
+
297
+ const [ url ] = calls [ 0 ] ;
298
+ expect ( url . toString ( ) ) . toBe ( "https://resource.example.com/.well-known/oauth-protected-resource" ) ;
299
+ } ) ;
300
+
301
+ it ( "falls back when path-aware discovery encounters CORS error" , async ( ) => {
302
+ // First call (path-aware) fails with TypeError (CORS)
303
+ mockFetch . mockImplementationOnce ( ( ) => Promise . reject ( new TypeError ( "CORS error" ) ) ) ;
304
+
305
+ // Retry path-aware without headers (simulating CORS retry)
306
+ mockFetch . mockResolvedValueOnce ( {
307
+ ok : false ,
308
+ status : 404 ,
309
+ } ) ;
310
+
311
+ // Second call (root fallback) succeeds
312
+ mockFetch . mockResolvedValueOnce ( {
313
+ ok : true ,
314
+ status : 200 ,
315
+ json : async ( ) => validMetadata ,
316
+ } ) ;
317
+
318
+ const metadata = await discoverOAuthProtectedResourceMetadata ( "https://resource.example.com/deep/path" ) ;
319
+ expect ( metadata ) . toEqual ( validMetadata ) ;
320
+
321
+ const calls = mockFetch . mock . calls ;
322
+ expect ( calls . length ) . toBe ( 3 ) ;
323
+
324
+ // Final call should be root fallback
325
+ const [ lastUrl , lastOptions ] = calls [ 2 ] ;
326
+ expect ( lastUrl . toString ( ) ) . toBe ( "https://resource.example.com/.well-known/oauth-protected-resource" ) ;
327
+ expect ( lastOptions . headers ) . toEqual ( {
328
+ "MCP-Protocol-Version" : LATEST_PROTOCOL_VERSION
329
+ } ) ;
330
+ } ) ;
331
+
332
+ it ( "does not fallback when resourceMetadataUrl is provided" , async ( ) => {
333
+ // Call with explicit URL returns 404
334
+ mockFetch . mockResolvedValueOnce ( {
335
+ ok : false ,
336
+ status : 404 ,
337
+ } ) ;
338
+
339
+ await expect ( discoverOAuthProtectedResourceMetadata ( "https://resource.example.com/path" , {
340
+ resourceMetadataUrl : "https://custom.example.com/metadata"
341
+ } ) ) . rejects . toThrow ( "Resource server does not implement OAuth 2.0 Protected Resource Metadata." ) ;
342
+
343
+ const calls = mockFetch . mock . calls ;
344
+ expect ( calls . length ) . toBe ( 1 ) ; // Should not attempt fallback when explicit URL is provided
345
+
346
+ const [ url ] = calls [ 0 ] ;
347
+ expect ( url . toString ( ) ) . toBe ( "https://custom.example.com/metadata" ) ;
348
+ } ) ;
181
349
} ) ;
182
350
183
351
describe ( "discoverOAuthMetadata" , ( ) => {
0 commit comments