@@ -13,7 +13,8 @@ use oauth2::{
1313 AsyncHttpClient , AuthType , AuthUrl , AuthorizationCode , ClientId , ClientSecret , CsrfToken ,
1414 EmptyExtraTokenFields , ExtraTokenFields , HttpRequest , HttpResponse , PkceCodeChallenge ,
1515 PkceCodeVerifier , RedirectUrl , RefreshToken , RequestTokenError , Scope , StandardTokenResponse ,
16- TokenResponse , TokenUrl , basic:: BasicTokenType ,
16+ TokenResponse , TokenUrl ,
17+ basic:: { BasicErrorResponseType , BasicTokenType } ,
1718} ;
1819use reqwest:: {
1920 Client as ReqwestClient , IntoUrl , StatusCode , Url ,
@@ -465,9 +466,16 @@ pub enum AuthError {
465466 #[ error( "OAuth token exchange failed: {0}" ) ]
466467 TokenExchangeFailed ( String ) ,
467468
469+ /// The refresh attempt failed without a definitive refresh-token rejection.
470+ ///
471+ /// Callers may retry this error because it includes transient request and provider failures.
468472 #[ error( "OAuth token refresh failed: {0}" ) ]
469473 TokenRefreshFailed ( String ) ,
470474
475+ /// The authorization server definitively rejected the refresh token.
476+ #[ error( "OAuth refresh token was rejected: {0}" ) ]
477+ TokenRefreshRejected ( String ) ,
478+
471479 #[ error( "HTTP error: {0}" ) ]
472480 HttpError ( #[ from] reqwest:: Error ) ,
473481
@@ -1666,7 +1674,7 @@ impl AuthorizationManager {
16661674 tracing:: info!( "Refreshed access token." ) ;
16671675 Ok ( new_creds. access_token ( ) . secret ( ) . to_string ( ) )
16681676 }
1669- Err ( e @ ( AuthError :: AuthorizationRequired | AuthError :: TokenRefreshFailed ( _) ) ) => {
1677+ Err ( e @ ( AuthError :: AuthorizationRequired | AuthError :: TokenRefreshRejected ( _) ) ) => {
16701678 tracing:: warn!( error = %e, "Token refresh not possible, re-authorization required." ) ;
16711679 Err ( AuthError :: AuthorizationRequired )
16721680 }
@@ -1687,9 +1695,9 @@ impl AuthorizationManager {
16871695 . token_response
16881696 . ok_or ( AuthError :: AuthorizationRequired ) ?;
16891697
1690- let refresh_token = current_credentials. refresh_token ( ) . ok_or_else ( || {
1691- AuthError :: TokenRefreshFailed ( "No refresh token available" . to_string ( ) )
1692- } ) ?;
1698+ let refresh_token = current_credentials
1699+ . refresh_token ( )
1700+ . ok_or ( AuthError :: AuthorizationRequired ) ?;
16931701 debug ! ( "refresh token present, attempting refresh" ) ;
16941702
16951703 let refresh_token_value = RefreshToken :: new ( refresh_token. secret ( ) . to_string ( ) ) ;
@@ -1708,7 +1716,14 @@ impl AuthorizationManager {
17081716 redirect_policy : self . refresh_redirect_policy ,
17091717 } )
17101718 . await
1711- . map_err ( |e| AuthError :: TokenRefreshFailed ( e. to_string ( ) ) ) ?;
1719+ . map_err ( |error| match & error {
1720+ RequestTokenError :: ServerResponse ( response)
1721+ if response. error ( ) == & BasicErrorResponseType :: InvalidGrant =>
1722+ {
1723+ AuthError :: TokenRefreshRejected ( error. to_string ( ) )
1724+ }
1725+ _ => AuthError :: TokenRefreshFailed ( error. to_string ( ) ) ,
1726+ } ) ?;
17121727
17131728 // RFC 6749 section 6: issuing a new refresh token on refresh is optional.
17141729 // When the response omits one, keep the existing refresh token rather than
@@ -5192,6 +5207,52 @@ mod tests {
51925207 resp
51935208 }
51945209
5210+ async fn manager_with_refresh_error ( error : & ' static str ) -> AuthorizationManager {
5211+ use axum:: { Router , body:: Body , http:: Response , routing:: post} ;
5212+
5213+ let app = Router :: new ( ) . route (
5214+ "/token" ,
5215+ post ( move || async move {
5216+ Response :: builder ( )
5217+ . status ( 400 )
5218+ . header ( "content-type" , "application/json" )
5219+ . body ( Body :: from (
5220+ serde_json:: json!( {
5221+ "error" : error,
5222+ "error_description" : "refresh failed" ,
5223+ } )
5224+ . to_string ( ) ,
5225+ ) )
5226+ . unwrap ( )
5227+ } ) ,
5228+ ) ;
5229+ let listener = tokio:: net:: TcpListener :: bind ( "127.0.0.1:0" ) . await . unwrap ( ) ;
5230+ let addr = listener. local_addr ( ) . unwrap ( ) ;
5231+ tokio:: spawn ( async move { axum:: serve ( listener, app) . await . unwrap ( ) } ) ;
5232+
5233+ let mut manager = manager_with_metadata ( Some ( AuthorizationMetadata {
5234+ authorization_endpoint : format ! ( "http://{addr}/authorize" ) ,
5235+ token_endpoint : format ! ( "http://{addr}/token" ) ,
5236+ ..Default :: default ( )
5237+ } ) )
5238+ . await ;
5239+ manager. configure_client ( test_client_config ( ) ) . unwrap ( ) ;
5240+ manager
5241+ . credential_store
5242+ . save ( StoredCredentials {
5243+ client_id : "my-client" . to_string ( ) ,
5244+ token_response : Some ( make_token_response_with_refresh (
5245+ "old-token" ,
5246+ "my-refresh-token" ,
5247+ ) ) ,
5248+ granted_scopes : vec ! [ ] ,
5249+ token_received_at : Some ( AuthorizationManager :: now_epoch_secs ( ) ) ,
5250+ } )
5251+ . await
5252+ . unwrap ( ) ;
5253+ manager
5254+ }
5255+
51955256 #[ tokio:: test]
51965257 async fn refresh_token_returns_error_when_no_stored_credentials ( ) {
51975258 let mut manager = manager_with_metadata ( None ) . await ;
@@ -5238,9 +5299,33 @@ mod tests {
52385299 manager. credential_store . save ( stored) . await . unwrap ( ) ;
52395300
52405301 let err = manager. refresh_token ( ) . await . unwrap_err ( ) ;
5302+ assert ! (
5303+ matches!( err, AuthError :: AuthorizationRequired ) ,
5304+ "expected AuthorizationRequired when no refresh token, got: {err:?}"
5305+ ) ;
5306+ }
5307+
5308+ #[ tokio:: test]
5309+ async fn invalid_grant_refresh_requires_reauthorization ( ) {
5310+ let manager = manager_with_refresh_error ( "invalid_grant" ) . await ;
5311+
5312+ let err = manager. try_refresh_or_reauth ( ) . await . unwrap_err ( ) ;
5313+
5314+ assert ! (
5315+ matches!( err, AuthError :: AuthorizationRequired ) ,
5316+ "expected AuthorizationRequired when the refresh token is rejected, got: {err:?}"
5317+ ) ;
5318+ }
5319+
5320+ #[ tokio:: test]
5321+ async fn temporary_refresh_failure_does_not_require_reauthorization ( ) {
5322+ let manager = manager_with_refresh_error ( "temporarily_unavailable" ) . await ;
5323+
5324+ let err = manager. try_refresh_or_reauth ( ) . await . unwrap_err ( ) ;
5325+
52415326 assert ! (
52425327 matches!( err, AuthError :: TokenRefreshFailed ( _) ) ,
5243- "expected TokenRefreshFailed when no refresh token , got: {err:?}"
5328+ "expected TokenRefreshFailed for a temporary provider failure , got: {err:?}"
52445329 ) ;
52455330 }
52465331
0 commit comments