@@ -370,6 +370,128 @@ describe('withOAuth', () => {
370370 fetchFn : mockFetch
371371 } ) ;
372372 } ) ;
373+
374+ it ( 'should retry request after successful auth on 403 response' , async ( ) => {
375+ mockProvider . tokens
376+ . mockResolvedValueOnce ( {
377+ access_token : 'old-token' ,
378+ token_type : 'Bearer' ,
379+ expires_in : 3600
380+ } )
381+ . mockResolvedValueOnce ( {
382+ access_token : 'new-token' ,
383+ token_type : 'Bearer' ,
384+ expires_in : 3600
385+ } ) ;
386+
387+ const forbiddenResponse = new Response ( 'Forbidden' , {
388+ status : 403 ,
389+ headers : { 'www-authenticate' : 'Bearer realm="oauth" scope="write"' }
390+ } ) ;
391+ const successResponse = new Response ( 'success' , { status : 200 } ) ;
392+
393+ mockFetch . mockResolvedValueOnce ( forbiddenResponse ) . mockResolvedValueOnce ( successResponse ) ;
394+
395+ mockExtractWWWAuthenticateParams . mockReturnValue ( { scope : 'write' } ) ;
396+ mockAuth . mockResolvedValue ( 'AUTHORIZED' ) ;
397+
398+ const enhancedFetch = withOAuth ( mockProvider , 'https://api.example.com' ) ( mockFetch ) ;
399+
400+ const result = await enhancedFetch ( 'https://api.example.com/data' ) ;
401+
402+ expect ( result ) . toBe ( successResponse ) ;
403+ expect ( mockFetch ) . toHaveBeenCalledTimes ( 2 ) ;
404+ expect ( mockAuth ) . toHaveBeenCalledWith ( mockProvider , {
405+ serverUrl : 'https://api.example.com' ,
406+ resourceMetadataUrl : undefined ,
407+ scope : 'write' ,
408+ fetchFn : mockFetch
409+ } ) ;
410+ } ) ;
411+
412+ it ( 'should throw UnauthorizedError on persistent 403 after re-auth' , async ( ) => {
413+ mockProvider . tokens . mockResolvedValue ( {
414+ access_token : 'test-token' ,
415+ token_type : 'Bearer' ,
416+ expires_in : 3600
417+ } ) ;
418+
419+ mockFetch . mockResolvedValue ( new Response ( 'Forbidden' , { status : 403 } ) ) ;
420+ mockExtractWWWAuthenticateParams . mockReturnValue ( { } ) ;
421+ mockAuth . mockResolvedValue ( 'AUTHORIZED' ) ;
422+
423+ const enhancedFetch = withOAuth ( mockProvider , 'https://api.example.com' ) ( mockFetch ) ;
424+
425+ await expect ( enhancedFetch ( 'https://api.example.com/data' ) ) . rejects . toThrow (
426+ 'Authentication failed for https://api.example.com/data'
427+ ) ;
428+
429+ expect ( mockFetch ) . toHaveBeenCalledTimes ( 2 ) ;
430+ } ) ;
431+
432+ it ( 'should complete auth code flow when provider implements getAuthorizationCode' , async ( ) => {
433+ mockProvider . tokens
434+ . mockResolvedValueOnce ( {
435+ access_token : 'old-token' ,
436+ token_type : 'Bearer' ,
437+ expires_in : 3600
438+ } )
439+ . mockResolvedValueOnce ( {
440+ access_token : 'fresh-token' ,
441+ token_type : 'Bearer' ,
442+ expires_in : 3600
443+ } ) ;
444+
445+ const unauthorizedResponse = new Response ( 'Unauthorized' , { status : 401 } ) ;
446+ const successResponse = new Response ( 'success' , { status : 200 } ) ;
447+
448+ mockFetch . mockResolvedValueOnce ( unauthorizedResponse ) . mockResolvedValueOnce ( successResponse ) ;
449+
450+ mockExtractWWWAuthenticateParams . mockReturnValue ( { scope : 'read' } ) ;
451+ // First auth() call returns REDIRECT; second (with auth code) returns AUTHORIZED
452+ mockAuth . mockResolvedValueOnce ( 'REDIRECT' ) . mockResolvedValueOnce ( 'AUTHORIZED' ) ;
453+
454+ // Provider that can supply the authorization code after the redirect
455+ const providerWithCode = {
456+ ...mockProvider ,
457+ getAuthorizationCode : vi . fn ( ) . mockResolvedValue ( 'auth-code-123' )
458+ } ;
459+
460+ const enhancedFetch = withOAuth ( providerWithCode , 'https://api.example.com' ) ( mockFetch ) ;
461+
462+ const result = await enhancedFetch ( 'https://api.example.com/data' ) ;
463+
464+ expect ( result ) . toBe ( successResponse ) ;
465+ expect ( providerWithCode . getAuthorizationCode ) . toHaveBeenCalledTimes ( 1 ) ;
466+ expect ( mockAuth ) . toHaveBeenCalledTimes ( 2 ) ;
467+ // Second auth() call should include the authorization code
468+ expect ( mockAuth ) . toHaveBeenNthCalledWith ( 2 , providerWithCode , {
469+ serverUrl : 'https://api.example.com' ,
470+ resourceMetadataUrl : undefined ,
471+ scope : 'read' ,
472+ authorizationCode : 'auth-code-123' ,
473+ fetchFn : mockFetch
474+ } ) ;
475+ expect ( mockFetch ) . toHaveBeenCalledTimes ( 2 ) ;
476+ } ) ;
477+
478+ it ( 'should throw UnauthorizedError when auth returns REDIRECT and provider has no getAuthorizationCode' , async ( ) => {
479+ mockProvider . tokens . mockResolvedValue ( {
480+ access_token : 'test-token' ,
481+ token_type : 'Bearer' ,
482+ expires_in : 3600
483+ } ) ;
484+
485+ mockFetch . mockResolvedValue ( new Response ( 'Unauthorized' , { status : 401 } ) ) ;
486+ mockExtractWWWAuthenticateParams . mockReturnValue ( { } ) ;
487+ mockAuth . mockResolvedValue ( 'REDIRECT' ) ;
488+
489+ const enhancedFetch = withOAuth ( mockProvider , 'https://api.example.com' ) ( mockFetch ) ;
490+
491+ await expect ( enhancedFetch ( 'https://api.example.com/data' ) ) . rejects . toThrow (
492+ 'Authentication requires user authorization - redirect initiated'
493+ ) ;
494+ } ) ;
373495} ) ;
374496
375497describe ( 'withLogging' , ( ) => {
0 commit comments