@@ -312,22 +312,33 @@ describe("chutes-oauth", () => {
312312 expectRefreshedCredential ( refreshed , now ) ;
313313 } ) ;
314314
315- it ( "bounds token exchange error bodies without using response.text()" , async ( ) => {
316- const tracked = cancelTrackedResponse ( `${ "chutes exchange failure " . repeat ( 1024 ) } tail` , {
317- status : 401 ,
318- headers : { "content-type" : "text/plain" } ,
319- } ) ;
320- const textSpy = vi . spyOn ( tracked . response , "text" ) . mockRejectedValue ( new Error ( "unbounded" ) ) ;
315+ it ( "normalizes and redacts structured token exchange errors" , async ( ) => {
316+ const leakedClientSecret = "oauth-client-secret-1234567890" ;
317+ const response = new Response (
318+ JSON . stringify ( {
319+ error : "invalid_grant" ,
320+ error_description : `Authorization failed for client_secret=${ leakedClientSecret } ` ,
321+ } ) ,
322+ {
323+ status : 400 ,
324+ headers : {
325+ "content-type" : "application/json" ,
326+ "x-request-id" : "chutes_req_123" ,
327+ } ,
328+ } ,
329+ ) ;
330+ const textSpy = vi . spyOn ( response , "text" ) . mockRejectedValue ( new Error ( "unbounded" ) ) ;
321331 const fetchFn = withFetchPreconnect ( async ( input : RequestInfo | URL ) => {
322332 const url = urlToString ( input ) ;
323333 if ( url === CHUTES_TOKEN_ENDPOINT ) {
324- return tracked . response ;
334+ return response ;
325335 }
326336 return new Response ( "not found" , { status : 404 } ) ;
327337 } ) ;
328338
329- await expect (
330- exchangeChutesCodeForTokens ( {
339+ let error : unknown ;
340+ try {
341+ await exchangeChutesCodeForTokens ( {
331342 app : {
332343 clientId : "cid_test" ,
333344 redirectUri : "http://127.0.0.1:1456/oauth-callback" ,
@@ -337,17 +348,35 @@ describe("chutes-oauth", () => {
337348 codeVerifier : "verifier_401" ,
338349 fetchFn,
339350 now : 1_000_000 ,
340- } ) ,
341- ) . rejects . toThrow ( "Chutes token exchange failed: chutes exchange failure" ) ;
351+ } ) ;
352+ } catch ( caught ) {
353+ error = caught ;
354+ }
355+
356+ expect ( error ) . toMatchObject ( {
357+ name : "ProviderHttpError" ,
358+ status : 400 ,
359+ errorCode : "invalid_grant" ,
360+ requestId : "chutes_req_123" ,
361+ } ) ;
362+ const message = ( error as Error ) . message ;
363+ expect ( message ) . toContain ( "Chutes token exchange failed (400): Authorization failed" ) ;
364+ expect ( message ) . toContain ( "[code=invalid_grant]" ) ;
365+ expect ( message ) . not . toContain ( leakedClientSecret ) ;
366+ expect ( message ) . not . toContain ( "error_description" ) ;
367+ expect ( ( error as { errorBody ?: string } ) . errorBody ) . not . toContain ( leakedClientSecret ) ;
342368 expect ( textSpy ) . not . toHaveBeenCalled ( ) ;
343- expect ( tracked . wasCanceled ( ) ) . toBe ( true ) ;
344369 } ) ;
345370
346- it ( "bounds token refresh error bodies without using response.text()" , async ( ) => {
347- const tracked = cancelTrackedResponse ( `${ "chutes refresh failure " . repeat ( 1024 ) } tail` , {
348- status : 401 ,
349- headers : { "content-type" : "text/plain" } ,
350- } ) ;
371+ it ( "bounds and redacts plain-text token refresh errors" , async ( ) => {
372+ const leakedRefreshToken = "oauth-refresh-secret-1234567890" ;
373+ const tracked = cancelTrackedResponse (
374+ `${ `refresh_token=${ leakedRefreshToken } unavailable ` . repeat ( 1024 ) } tail-marker` ,
375+ {
376+ status : 401 ,
377+ headers : { "content-type" : "text/plain" } ,
378+ } ,
379+ ) ;
351380 const textSpy = vi . spyOn ( tracked . response , "text" ) . mockRejectedValue ( new Error ( "unbounded" ) ) ;
352381 const fetchFn = withFetchPreconnect ( async ( input : RequestInfo | URL ) => {
353382 const url = urlToString ( input ) ;
@@ -357,13 +386,23 @@ describe("chutes-oauth", () => {
357386 return new Response ( "not found" , { status : 404 } ) ;
358387 } ) ;
359388
360- await expect (
361- refreshChutesTokens ( {
389+ let error : unknown ;
390+ try {
391+ await refreshChutesTokens ( {
362392 credential : createStoredCredential ( 5_000_000 ) ,
363393 fetchFn,
364394 now : 5_000_000 ,
365- } ) ,
366- ) . rejects . toThrow ( "Chutes token refresh failed: chutes refresh failure" ) ;
395+ } ) ;
396+ } catch ( caught ) {
397+ error = caught ;
398+ }
399+
400+ expect ( error ) . toMatchObject ( { name : "ProviderHttpError" , status : 401 } ) ;
401+ const message = ( error as Error ) . message ;
402+ expect ( message ) . toContain ( "Chutes token refresh failed (401): refresh_token=" ) ;
403+ expect ( message ) . not . toContain ( leakedRefreshToken ) ;
404+ expect ( message ) . not . toContain ( "tail-marker" ) ;
405+ expect ( ( error as { errorBody ?: string } ) . errorBody ) . not . toContain ( leakedRefreshToken ) ;
367406 expect ( textSpy ) . not . toHaveBeenCalled ( ) ;
368407 expect ( tracked . wasCanceled ( ) ) . toBe ( true ) ;
369408 } ) ;
0 commit comments