@@ -147,15 +147,14 @@ describe('OAuth Authorization', () => {
147147 expect ( mockFetch ) . toHaveBeenCalledTimes ( 2 ) ;
148148 } ) ;
149149
150- it ( 'throws on 404 errors' , async ( ) => {
150+ it ( 'returns undefined on 404 errors (per SEP-985) ' , async ( ) => {
151151 mockFetch . mockResolvedValueOnce ( {
152152 ok : false ,
153153 status : 404
154154 } ) ;
155155
156- await expect ( discoverOAuthProtectedResourceMetadata ( 'https://resource.example.com' ) ) . rejects . toThrow (
157- 'Resource server does not implement OAuth 2.0 Protected Resource Metadata.'
158- ) ;
156+ const metadata = await discoverOAuthProtectedResourceMetadata ( 'https://resource.example.com' ) ;
157+ expect ( metadata ) . toBeUndefined ( ) ;
159158 } ) ;
160159
161160 it ( 'throws on non-404 errors' , async ( ) => {
@@ -248,7 +247,7 @@ describe('OAuth Authorization', () => {
248247 }
249248 ) ;
250249
251- it ( 'throws error when both path-aware and root discovery return 404' , async ( ) => {
250+ it ( 'returns undefined when both path-aware and root discovery return 404 (per SEP-985) ' , async ( ) => {
252251 // First call (path-aware) returns 404
253252 mockFetch . mockResolvedValueOnce ( {
254253 ok : false ,
@@ -261,9 +260,8 @@ describe('OAuth Authorization', () => {
261260 status : 404
262261 } ) ;
263262
264- await expect ( discoverOAuthProtectedResourceMetadata ( 'https://resource.example.com/path/name' ) ) . rejects . toThrow (
265- 'Resource server does not implement OAuth 2.0 Protected Resource Metadata.'
266- ) ;
263+ const metadata = await discoverOAuthProtectedResourceMetadata ( 'https://resource.example.com/path/name' ) ;
264+ expect ( metadata ) . toBeUndefined ( ) ;
267265
268266 const calls = mockFetch . mock . calls ;
269267 expect ( calls . length ) . toBe ( 2 ) ;
@@ -282,16 +280,15 @@ describe('OAuth Authorization', () => {
282280 expect ( calls . length ) . toBe ( 1 ) ; // Should not attempt fallback
283281 } ) ;
284282
285- it ( 'does not fallback when the original URL is already at root path' , async ( ) => {
283+ it ( 'returns undefined when the original URL is already at root path and returns 404 (per SEP-985) ' , async ( ) => {
286284 // First call (path-aware for root) returns 404
287285 mockFetch . mockResolvedValueOnce ( {
288286 ok : false ,
289287 status : 404
290288 } ) ;
291289
292- await expect ( discoverOAuthProtectedResourceMetadata ( 'https://resource.example.com/' ) ) . rejects . toThrow (
293- 'Resource server does not implement OAuth 2.0 Protected Resource Metadata.'
294- ) ;
290+ const metadata = await discoverOAuthProtectedResourceMetadata ( 'https://resource.example.com/' ) ;
291+ expect ( metadata ) . toBeUndefined ( ) ;
295292
296293 const calls = mockFetch . mock . calls ;
297294 expect ( calls . length ) . toBe ( 1 ) ; // Should not attempt fallback
@@ -300,16 +297,15 @@ describe('OAuth Authorization', () => {
300297 expect ( url . toString ( ) ) . toBe ( 'https://resource.example.com/.well-known/oauth-protected-resource' ) ;
301298 } ) ;
302299
303- it ( 'does not fallback when the original URL has no path' , async ( ) => {
300+ it ( 'returns undefined when the original URL has no path and returns 404 (per SEP-985) ' , async ( ) => {
304301 // First call (path-aware for no path) returns 404
305302 mockFetch . mockResolvedValueOnce ( {
306303 ok : false ,
307304 status : 404
308305 } ) ;
309306
310- await expect ( discoverOAuthProtectedResourceMetadata ( 'https://resource.example.com' ) ) . rejects . toThrow (
311- 'Resource server does not implement OAuth 2.0 Protected Resource Metadata.'
312- ) ;
307+ const metadata = await discoverOAuthProtectedResourceMetadata ( 'https://resource.example.com' ) ;
308+ expect ( metadata ) . toBeUndefined ( ) ;
313309
314310 const calls = mockFetch . mock . calls ;
315311 expect ( calls . length ) . toBe ( 1 ) ; // Should not attempt fallback
@@ -349,18 +345,17 @@ describe('OAuth Authorization', () => {
349345 } ) ;
350346 } ) ;
351347
352- it ( 'does not fallback when resourceMetadataUrl is provided' , async ( ) => {
348+ it ( 'returns undefined when resourceMetadataUrl is provided but returns 404 (per SEP-985) ' , async ( ) => {
353349 // Call with explicit URL returns 404
354350 mockFetch . mockResolvedValueOnce ( {
355351 ok : false ,
356352 status : 404
357353 } ) ;
358354
359- await expect (
360- discoverOAuthProtectedResourceMetadata ( 'https://resource.example.com/path' , {
361- resourceMetadataUrl : 'https://custom.example.com/metadata'
362- } )
363- ) . rejects . toThrow ( 'Resource server does not implement OAuth 2.0 Protected Resource Metadata.' ) ;
355+ const metadata = await discoverOAuthProtectedResourceMetadata ( 'https://resource.example.com/path' , {
356+ resourceMetadataUrl : 'https://custom.example.com/metadata'
357+ } ) ;
358+ expect ( metadata ) . toBeUndefined ( ) ;
364359
365360 const calls = mockFetch . mock . calls ;
366361 expect ( calls . length ) . toBe ( 1 ) ; // Should not attempt fallback when explicit URL is provided
@@ -2180,6 +2175,217 @@ describe('OAuth Authorization', () => {
21802175 // Verify custom fetch was called for AS metadata discovery
21812176 expect ( customFetch . mock . calls [ 1 ] [ 0 ] . toString ( ) ) . toBe ( 'https://auth.example.com/.well-known/oauth-authorization-server' ) ;
21822177 } ) ;
2178+
2179+ describe ( 'SEP-985: WWW-Authenticate fallback behavior' , ( ) => {
2180+ it ( 'uses resource_metadata URL from WWW-Authenticate header when provided' , async ( ) => {
2181+ // Mock PRM discovery from explicit URL
2182+ mockFetch . mockImplementation ( url => {
2183+ const urlString = url . toString ( ) ;
2184+
2185+ if ( urlString === 'https://resource.example.com/custom/.well-known/oauth-protected-resource' ) {
2186+ return Promise . resolve ( {
2187+ ok : true ,
2188+ status : 200 ,
2189+ json : async ( ) => ( {
2190+ resource : 'https://resource.example.com' ,
2191+ authorization_servers : [ 'https://auth.example.com' ]
2192+ } )
2193+ } ) ;
2194+ } else if ( urlString . includes ( '/.well-known/oauth-authorization-server' ) ) {
2195+ return Promise . resolve ( {
2196+ ok : true ,
2197+ status : 200 ,
2198+ json : async ( ) => ( {
2199+ issuer : 'https://auth.example.com' ,
2200+ authorization_endpoint : 'https://auth.example.com/authorize' ,
2201+ token_endpoint : 'https://auth.example.com/token' ,
2202+ response_types_supported : [ 'code' ] ,
2203+ code_challenge_methods_supported : [ 'S256' ]
2204+ } )
2205+ } ) ;
2206+ }
2207+
2208+ return Promise . resolve ( { ok : false , status : 404 } ) ;
2209+ } ) ;
2210+
2211+ ( mockProvider . clientInformation as jest . Mock ) . mockResolvedValue ( {
2212+ client_id : 'test-client' ,
2213+ client_secret : 'test-secret'
2214+ } ) ;
2215+ ( mockProvider . tokens as jest . Mock ) . mockResolvedValue ( undefined ) ;
2216+
2217+ // Pass resourceMetadataUrl extracted from WWW-Authenticate header
2218+ const result = await auth ( mockProvider , {
2219+ serverUrl : 'https://resource.example.com' ,
2220+ resourceMetadataUrl : new URL ( 'https://resource.example.com/custom/.well-known/oauth-protected-resource' )
2221+ } ) ;
2222+
2223+ expect ( result ) . toBe ( 'REDIRECT' ) ;
2224+
2225+ // Verify that the custom URL was used, not the default well-known path
2226+ const firstCall = mockFetch . mock . calls [ 0 ] ;
2227+ expect ( firstCall [ 0 ] . toString ( ) ) . toBe ( 'https://resource.example.com/custom/.well-known/oauth-protected-resource' ) ;
2228+ } ) ;
2229+
2230+ it ( 'falls back to well-known when WWW-Authenticate header has no resource_metadata' , async ( ) => {
2231+ // Simulate: WWW-Authenticate present but without resource_metadata parameter
2232+ // In this case, resourceMetadataUrl would be undefined
2233+ mockFetch . mockImplementation ( url => {
2234+ const urlString = url . toString ( ) ;
2235+
2236+ if ( urlString === 'https://resource.example.com/.well-known/oauth-protected-resource' ) {
2237+ return Promise . resolve ( {
2238+ ok : true ,
2239+ status : 200 ,
2240+ json : async ( ) => ( {
2241+ resource : 'https://resource.example.com' ,
2242+ authorization_servers : [ 'https://auth.example.com' ]
2243+ } )
2244+ } ) ;
2245+ } else if ( urlString . includes ( '/.well-known/oauth-authorization-server' ) ) {
2246+ return Promise . resolve ( {
2247+ ok : true ,
2248+ status : 200 ,
2249+ json : async ( ) => ( {
2250+ issuer : 'https://auth.example.com' ,
2251+ authorization_endpoint : 'https://auth.example.com/authorize' ,
2252+ token_endpoint : 'https://auth.example.com/token' ,
2253+ response_types_supported : [ 'code' ] ,
2254+ code_challenge_methods_supported : [ 'S256' ]
2255+ } )
2256+ } ) ;
2257+ }
2258+
2259+ return Promise . resolve ( { ok : false , status : 404 } ) ;
2260+ } ) ;
2261+
2262+ ( mockProvider . clientInformation as jest . Mock ) . mockResolvedValue ( {
2263+ client_id : 'test-client' ,
2264+ client_secret : 'test-secret'
2265+ } ) ;
2266+ ( mockProvider . tokens as jest . Mock ) . mockResolvedValue ( undefined ) ;
2267+
2268+ // No resourceMetadataUrl provided (as if WWW-Authenticate had no resource_metadata)
2269+ const result = await auth ( mockProvider , {
2270+ serverUrl : 'https://resource.example.com'
2271+ } ) ;
2272+
2273+ expect ( result ) . toBe ( 'REDIRECT' ) ;
2274+
2275+ // Verify fallback to well-known path
2276+ const firstCall = mockFetch . mock . calls [ 0 ] ;
2277+ expect ( firstCall [ 0 ] . toString ( ) ) . toBe ( 'https://resource.example.com/.well-known/oauth-protected-resource' ) ;
2278+ } ) ;
2279+
2280+ it ( 'continues gracefully when well-known PRM returns 404' , async ( ) => {
2281+ // Per SEP-985, protected resource metadata is optional
2282+ // Client should fall back to using server as auth server
2283+ mockFetch . mockImplementation ( url => {
2284+ const urlString = url . toString ( ) ;
2285+
2286+ if ( urlString === 'https://resource.example.com/.well-known/oauth-protected-resource' ) {
2287+ // PRM not available - return 404
2288+ return Promise . resolve ( {
2289+ ok : false ,
2290+ status : 404
2291+ } ) ;
2292+ } else if ( urlString === 'https://resource.example.com/.well-known/oauth-authorization-server' ) {
2293+ // Fall back to server as auth server
2294+ return Promise . resolve ( {
2295+ ok : true ,
2296+ status : 200 ,
2297+ json : async ( ) => ( {
2298+ issuer : 'https://resource.example.com' ,
2299+ authorization_endpoint : 'https://resource.example.com/authorize' ,
2300+ token_endpoint : 'https://resource.example.com/token' ,
2301+ registration_endpoint : 'https://resource.example.com/register' ,
2302+ response_types_supported : [ 'code' ] ,
2303+ code_challenge_methods_supported : [ 'S256' ]
2304+ } )
2305+ } ) ;
2306+ } else if ( urlString === 'https://resource.example.com/register' ) {
2307+ return Promise . resolve ( {
2308+ ok : true ,
2309+ status : 200 ,
2310+ json : async ( ) => ( {
2311+ client_id : 'registered-client-id' ,
2312+ client_secret : 'registered-secret' ,
2313+ redirect_uris : [ 'http://localhost:3000/callback' ] ,
2314+ client_name : 'Test Client'
2315+ } )
2316+ } ) ;
2317+ }
2318+
2319+ return Promise . resolve ( { ok : false , status : 404 } ) ;
2320+ } ) ;
2321+
2322+ ( mockProvider . clientInformation as jest . Mock ) . mockResolvedValue ( undefined ) ;
2323+ ( mockProvider . tokens as jest . Mock ) . mockResolvedValue ( undefined ) ;
2324+ mockProvider . saveClientInformation = jest . fn ( ) ;
2325+
2326+ const result = await auth ( mockProvider , {
2327+ serverUrl : 'https://resource.example.com'
2328+ } ) ;
2329+
2330+ expect ( result ) . toBe ( 'REDIRECT' ) ;
2331+
2332+ // Verify we tried PRM discovery
2333+ expect ( mockFetch . mock . calls [ 0 ] [ 0 ] . toString ( ) ) . toBe ( 'https://resource.example.com/.well-known/oauth-protected-resource' ) ;
2334+
2335+ // Verify we fell back to auth server metadata on same server
2336+ expect ( mockFetch . mock . calls [ 1 ] [ 0 ] . toString ( ) ) . toBe ( 'https://resource.example.com/.well-known/oauth-authorization-server' ) ;
2337+
2338+ // Verify client registration happened
2339+ expect ( mockProvider . saveClientInformation ) . toHaveBeenCalled ( ) ;
2340+ } ) ;
2341+
2342+ it ( 'handles CORS error on PRM discovery and falls back gracefully' , async ( ) => {
2343+ let callCount = 0 ;
2344+
2345+ mockFetch . mockImplementation ( url => {
2346+ callCount ++ ;
2347+ const urlString = url . toString ( ) ;
2348+
2349+ if ( callCount <= 2 && urlString . includes ( 'oauth-protected-resource' ) ) {
2350+ // Simulate CORS error on PRM discovery (both with and without headers)
2351+ return Promise . reject ( new TypeError ( 'Network request failed' ) ) ;
2352+ } else if ( urlString . includes ( 'oauth-authorization-server' ) ) {
2353+ // Auth server metadata succeeds
2354+ return Promise . resolve ( {
2355+ ok : true ,
2356+ status : 200 ,
2357+ json : async ( ) => ( {
2358+ issuer : 'https://resource.example.com' ,
2359+ authorization_endpoint : 'https://resource.example.com/authorize' ,
2360+ token_endpoint : 'https://resource.example.com/token' ,
2361+ response_types_supported : [ 'code' ] ,
2362+ code_challenge_methods_supported : [ 'S256' ]
2363+ } )
2364+ } ) ;
2365+ }
2366+
2367+ return Promise . resolve ( { ok : false , status : 404 } ) ;
2368+ } ) ;
2369+
2370+ ( mockProvider . clientInformation as jest . Mock ) . mockResolvedValue ( {
2371+ client_id : 'test-client' ,
2372+ client_secret : 'test-secret'
2373+ } ) ;
2374+ ( mockProvider . tokens as jest . Mock ) . mockResolvedValue ( undefined ) ;
2375+
2376+ const result = await auth ( mockProvider , {
2377+ serverUrl : 'https://resource.example.com'
2378+ } ) ;
2379+
2380+ expect ( result ) . toBe ( 'REDIRECT' ) ;
2381+
2382+ // Verify we tried PRM discovery (with retry for CORS)
2383+ expect ( mockFetch . mock . calls . filter ( call => call [ 0 ] . toString ( ) . includes ( 'oauth-protected-resource' ) ) . length ) . toBeGreaterThan ( 0 ) ;
2384+
2385+ // Verify we eventually fell back to auth server metadata
2386+ expect ( mockFetch . mock . calls . some ( call => call [ 0 ] . toString ( ) . includes ( 'oauth-authorization-server' ) ) ) . toBe ( true ) ;
2387+ } ) ;
2388+ } ) ;
21832389 } ) ;
21842390
21852391 describe ( 'exchangeAuthorization with multiple client authentication methods' , ( ) => {
0 commit comments