@@ -331,17 +331,137 @@ describe('OAuthProvider', () => {
331331 }
332332 } ) ;
333333
334- it ( 'should throw on token exchange failure' , async ( ) => {
334+ it ( 'should throw with sanitized message on token exchange failure' , async ( ) => {
335335 const originalFetch = global . fetch ;
336336
337337 global . fetch = async ( ) => ( {
338338 ok : false ,
339- text : async ( ) => 'Invalid client credentials' ,
339+ status : 401 ,
340+ statusText : 'Unauthorized' ,
341+ headers : { get : ( ) => null } ,
342+ } ) ;
343+
344+ try {
345+ await assert . rejects ( async ( ) => await provider . exchangeCodeForToken ( 'code' , 'https://callback' ) , {
346+ message : / T o k e n e x c h a n g e f a i l e d .* 4 0 1 U n a u t h o r i z e d / i,
347+ } ) ;
348+ } finally {
349+ global . fetch = originalFetch ;
350+ }
351+ } ) ;
352+
353+ it ( 'should extract error_description from JSON error response' , async ( ) => {
354+ const originalFetch = global . fetch ;
355+
356+ global . fetch = async ( ) => ( {
357+ ok : false ,
358+ status : 400 ,
359+ statusText : 'Bad Request' ,
360+ headers : { get : ( h ) => ( h === 'content-type' ? 'application/json' : null ) } ,
361+ json : async ( ) => ( {
362+ error : 'invalid_client' ,
363+ error_description : 'The client credentials are invalid' ,
364+ } ) ,
365+ } ) ;
366+
367+ try {
368+ await assert . rejects ( async ( ) => await provider . exchangeCodeForToken ( 'code' , 'https://callback' ) , {
369+ message : / T o k e n e x c h a n g e f a i l e d .* T h e c l i e n t c r e d e n t i a l s a r e i n v a l i d / i,
370+ } ) ;
371+ } finally {
372+ global . fetch = originalFetch ;
373+ }
374+ } ) ;
375+
376+ it ( 'should not leak HTML error pages in token exchange errors' , async ( ) => {
377+ const originalFetch = global . fetch ;
378+
379+ global . fetch = async ( ) => ( {
380+ ok : false ,
381+ status : 500 ,
382+ statusText : 'Internal Server Error' ,
383+ headers : { get : ( h ) => ( h === 'content-type' ? 'text/html' : null ) } ,
384+ } ) ;
385+
386+ try {
387+ await assert . rejects ( async ( ) => await provider . exchangeCodeForToken ( 'code' , 'https://callback' ) , {
388+ message : / T o k e n e x c h a n g e f a i l e d .* 5 0 0 I n t e r n a l S e r v e r E r r o r / i,
389+ } ) ;
390+ } finally {
391+ global . fetch = originalFetch ;
392+ }
393+ } ) ;
394+
395+ it ( 'should drain response body on non-JSON error to prevent socket leak' , async ( ) => {
396+ const originalFetch = global . fetch ;
397+ let bodyCancelled = false ;
398+
399+ global . fetch = async ( ) => ( {
400+ ok : false ,
401+ status : 500 ,
402+ statusText : 'Internal Server Error' ,
403+ headers : { get : ( h ) => ( h === 'content-type' ? 'text/html' : null ) } ,
404+ body : {
405+ cancel : async ( ) => {
406+ bodyCancelled = true ;
407+ } ,
408+ } ,
409+ } ) ;
410+
411+ try {
412+ await assert . rejects ( async ( ) => await provider . exchangeCodeForToken ( 'code' , 'https://callback' ) , {
413+ message : / T o k e n e x c h a n g e f a i l e d / ,
414+ } ) ;
415+ assert . ok ( bodyCancelled , 'response.body.cancel() should have been called' ) ;
416+ } finally {
417+ global . fetch = originalFetch ;
418+ }
419+ } ) ;
420+ } ) ;
421+
422+ describe ( 'JSON Parse Safety' , ( ) => {
423+ beforeEach ( ( ) => {
424+ provider = new OAuthProvider ( mockConfig , mockLogger ) ;
425+ } ) ;
426+
427+ it ( 'should fall back to status when JSON parse fails in token exchange' , async ( ) => {
428+ const originalFetch = global . fetch ;
429+
430+ global . fetch = async ( ) => ( {
431+ ok : false ,
432+ status : 502 ,
433+ statusText : 'Bad Gateway' ,
434+ headers : { get : ( h ) => ( h === 'content-type' ? 'application/json' : null ) } ,
435+ json : async ( ) => {
436+ throw new SyntaxError ( 'Unexpected end of JSON input' ) ;
437+ } ,
340438 } ) ;
341439
342440 try {
343441 await assert . rejects ( async ( ) => await provider . exchangeCodeForToken ( 'code' , 'https://callback' ) , {
344- message : / T o k e n e x c h a n g e f a i l e d .* I n v a l i d c l i e n t c r e d e n t i a l s / i,
442+ message : / T o k e n e x c h a n g e f a i l e d .* 5 0 2 B a d G a t e w a y / i,
443+ } ) ;
444+ } finally {
445+ global . fetch = originalFetch ;
446+ }
447+ } ) ;
448+
449+ it ( 'should fall back to status when JSON parse fails in token refresh' , async ( ) => {
450+ const originalFetch = global . fetch ;
451+
452+ global . fetch = async ( ) => ( {
453+ ok : false ,
454+ status : 502 ,
455+ statusText : 'Bad Gateway' ,
456+ headers : { get : ( h ) => ( h === 'content-type' ? 'application/json' : null ) } ,
457+ json : async ( ) => {
458+ throw new SyntaxError ( 'Unexpected end of JSON input' ) ;
459+ } ,
460+ } ) ;
461+
462+ try {
463+ await assert . rejects ( async ( ) => await provider . refreshAccessToken ( 'bad-token' ) , {
464+ message : / T o k e n r e f r e s h f a i l e d .* 5 0 2 B a d G a t e w a y / i,
345465 } ) ;
346466 } finally {
347467 global . fetch = originalFetch ;
@@ -478,19 +598,68 @@ describe('OAuthProvider', () => {
478598 }
479599 } ) ;
480600
481- it ( 'should throw on HTTP error during token refresh' , async ( ) => {
601+ it ( 'should throw with sanitized message on HTTP error during token refresh' , async ( ) => {
482602 const originalFetch = global . fetch ;
483603
484604 global . fetch = async ( ) => ( {
485605 ok : false ,
486606 status : 400 ,
487607 statusText : 'Bad Request' ,
488- text : async ( ) => 'invalid_grant: The refresh token is invalid' ,
608+ headers : { get : ( ) => null } ,
609+ } ) ;
610+
611+ try {
612+ await assert . rejects ( async ( ) => await provider . refreshAccessToken ( 'bad-token' ) , {
613+ message : / T o k e n r e f r e s h f a i l e d .* 4 0 0 B a d R e q u e s t / i,
614+ } ) ;
615+ } finally {
616+ global . fetch = originalFetch ;
617+ }
618+ } ) ;
619+
620+ it ( 'should drain response body on error to prevent socket leak' , async ( ) => {
621+ const originalFetch = global . fetch ;
622+ let bodyCancelled = false ;
623+
624+ global . fetch = async ( ) => ( {
625+ ok : false ,
626+ status : 502 ,
627+ statusText : 'Bad Gateway' ,
628+ headers : { get : ( ) => null } ,
629+ body : {
630+ cancel : async ( ) => {
631+ bodyCancelled = true ;
632+ } ,
633+ } ,
634+ } ) ;
635+
636+ try {
637+ await assert . rejects ( async ( ) => await provider . refreshAccessToken ( 'bad-token' ) , {
638+ message : / T o k e n r e f r e s h f a i l e d / ,
639+ } ) ;
640+ assert . ok ( bodyCancelled , 'response.body.cancel() should have been called' ) ;
641+ } finally {
642+ global . fetch = originalFetch ;
643+ }
644+ } ) ;
645+
646+ it ( 'should extract error_description from JSON error during token refresh' , async ( ) => {
647+ const originalFetch = global . fetch ;
648+
649+ global . fetch = async ( ) => ( {
650+ ok : false ,
651+ status : 400 ,
652+ statusText : 'Bad Request' ,
653+ headers : { get : ( h ) => ( h === 'content-type' ? 'application/json' : null ) } ,
654+ json : async ( ) => ( {
655+ error : 'invalid_grant' ,
656+ error_description : 'The refresh token is invalid' ,
657+ } ) ,
489658 } ) ;
490659
491660 try {
492661 await assert . rejects ( async ( ) => await provider . refreshAccessToken ( 'bad-token' ) , {
493- message : / T o k e n r e f r e s h f a i l e d .* i n v a l i d _ g r a n t / i,
662+ message : / T o k e n r e f r e s h f a i l e d .* T h e r e f r e s h t o k e n i s i n v a l i d / i,
494663 } ) ;
495664 } finally {
496665 global . fetch = originalFetch ;
0 commit comments