@@ -45,9 +45,10 @@ const OAuthErrorResponseSchema = z.object({
4545 error_description : z . string ( ) . optional ( ) ,
4646} ) ;
4747type OAuthTokenResponse = z . infer < typeof OAuthTokenResponseSchema > ;
48+ type OAuthErrorResponse = z . infer < typeof OAuthErrorResponseSchema > ;
4849
4950export type TokenRefreshErrorKind =
50- | 'invalid_grant '
51+ | 'refresh_token_rejected '
5152 | 'transient'
5253 | 'configuration'
5354 | 'invalid_response'
@@ -304,10 +305,9 @@ export const exchangeRefreshToken = async (
304305 bodyParams . redirect_uri = new URL ( '/api/auth/callback/gitlab' , env . AUTH_URL ) . toString ( ) ;
305306 }
306307
307- let response : Response | undefined ;
308308 for ( let attempt = 1 ; attempt <= TOKEN_REFRESH_MAX_ATTEMPTS ; attempt ++ ) {
309309 try {
310- response = await fetch ( url , {
310+ const response = await fetch ( url , {
311311 method : 'POST' ,
312312 headers : {
313313 'Content-Type' : 'application/x-www-form-urlencoded' ,
@@ -320,11 +320,7 @@ export const exchangeRefreshToken = async (
320320 signal : AbortSignal . timeout ( TOKEN_REFRESH_TIMEOUT_MS ) ,
321321 } ) ;
322322
323- if ( ! response . ok ) {
324- throw await classifyTokenRefreshErrorResponse ( response , providerType ) ;
325- }
326-
327- break ;
323+ return await parseTokenRefreshResponse ( response , providerType ) ;
328324 } catch ( error ) {
329325 const classifiedError = error instanceof TokenRefreshError
330326 ? error
@@ -342,22 +338,43 @@ export const exchangeRefreshToken = async (
342338 }
343339 }
344340
345- if ( ! response ) {
346- throw new TokenRefreshError ( `${ providerType } token refresh produced no response.` , {
347- kind : 'invalid_response' ,
348- } ) ;
349- }
341+ throw new TokenRefreshError ( `${ providerType } token refresh produced no response.` , {
342+ kind : 'invalid_response' ,
343+ } ) ;
344+ } ;
350345
346+ const parseTokenRefreshResponse = async (
347+ response : Response ,
348+ providerType : SupportedProviderType ,
349+ ) : Promise < OAuthTokenResponse > => {
350+ const responseText = await response . text ( ) ;
351351 let json : unknown ;
352352 try {
353- json = await response . json ( ) ;
353+ json = JSON . parse ( responseText ) ;
354354 } catch ( error ) {
355+ if ( ! response . ok ) {
356+ throw classifyTokenRefreshErrorResponse ( response . status , providerType ) ;
357+ }
358+
355359 throw new TokenRefreshError ( `${ providerType } returned a non-JSON token response.` , {
356360 kind : 'invalid_response' ,
357361 cause : error ,
358362 } ) ;
359363 }
360364
365+ const oauthErrorResult = OAuthErrorResponseSchema . safeParse ( json ) ;
366+ if ( oauthErrorResult . success ) {
367+ throw classifyTokenRefreshErrorResponse (
368+ response . status ,
369+ providerType ,
370+ oauthErrorResult . data ,
371+ ) ;
372+ }
373+
374+ if ( ! response . ok ) {
375+ throw classifyTokenRefreshErrorResponse ( response . status , providerType ) ;
376+ }
377+
361378 const result = OAuthTokenResponseSchema . safeParse ( json ) ;
362379
363380 if ( ! result . success ) {
@@ -387,58 +404,50 @@ const classifyTokenRefreshFetchError = (
387404 ) ;
388405} ;
389406
390- const classifyTokenRefreshErrorResponse = async (
391- response : Response ,
407+ const classifyTokenRefreshErrorResponse = (
408+ status : number ,
392409 providerType : SupportedProviderType ,
393- ) : Promise < TokenRefreshError > => {
394- let oauthError : string | undefined ;
395- let errorDescription : string | undefined ;
396-
397- try {
398- const responseText = await response . text ( ) ;
399- const result = OAuthErrorResponseSchema . safeParse ( JSON . parse ( responseText ) ) ;
400- if ( result . success ) {
401- oauthError = result . data . error ;
402- errorDescription = result . data . error_description ;
403- }
404- } catch {
405- // Non-JSON and malformed OAuth errors are still classified by HTTP status.
406- }
407-
410+ oauthErrorResponse ?: OAuthErrorResponse ,
411+ ) : TokenRefreshError => {
412+ const oauthError = oauthErrorResponse ?. error ;
413+ const errorDescription = oauthErrorResponse ?. error_description ;
408414 const details = errorDescription ? `: ${ errorDescription } ` : '' ;
415+ const isRefreshTokenRejected =
416+ oauthError === 'invalid_grant' ||
417+ ( providerType === 'github' && oauthError === 'bad_refresh_token' ) ;
409418
410- if ( oauthError === 'invalid_grant' ) {
419+ if ( isRefreshTokenRejected ) {
411420 return new TokenRefreshError ( `${ providerType } rejected the OAuth refresh token${ details } ` , {
412- kind : 'invalid_grant ' ,
413- status : response . status ,
421+ kind : 'refresh_token_rejected ' ,
422+ status,
414423 oauthError,
415424 errorDescription,
416425 } ) ;
417426 }
418427
419428 if (
420- response . status === 408 ||
421- response . status === 429 ||
422- response . status >= 500 ||
429+ status === 408 ||
430+ status === 429 ||
431+ status >= 500 ||
423432 oauthError === 'server_error' ||
424433 oauthError === 'temporarily_unavailable'
425434 ) {
426435 return new TokenRefreshError (
427- `${ providerType } token endpoint is temporarily unavailable (HTTP ${ response . status } )${ details } ` ,
436+ `${ providerType } token endpoint is temporarily unavailable (HTTP ${ status } )${ details } ` ,
428437 {
429438 kind : 'transient' ,
430- status : response . status ,
439+ status,
431440 oauthError,
432441 errorDescription,
433442 } ,
434443 ) ;
435444 }
436445
437446 return new TokenRefreshError (
438- `${ providerType } token endpoint rejected the refresh request (HTTP ${ response . status } )${ details } ` ,
447+ `${ providerType } token endpoint rejected the refresh request (HTTP ${ status } )${ details } ` ,
439448 {
440449 kind : 'configuration' ,
441- status : response . status ,
450+ status,
442451 oauthError,
443452 errorDescription,
444453 } ,
0 commit comments