@@ -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 ,
@@ -483,9 +484,16 @@ pub enum AuthError {
483484 #[ error( "OAuth token exchange failed: {0}" ) ]
484485 TokenExchangeFailed ( String ) ,
485486
487+ /// The refresh attempt failed without a definitive refresh-token rejection.
488+ ///
489+ /// Callers may retry this error because it includes transient request and provider failures.
486490 #[ error( "OAuth token refresh failed: {0}" ) ]
487491 TokenRefreshFailed ( String ) ,
488492
493+ /// The authorization server definitively rejected the refresh token.
494+ #[ error( "OAuth refresh token was rejected: {0}" ) ]
495+ TokenRefreshRejected ( String ) ,
496+
489497 #[ error( "HTTP error: {0}" ) ]
490498 HttpError ( #[ from] reqwest:: Error ) ,
491499
@@ -1714,9 +1722,11 @@ impl AuthorizationManager {
17141722
17151723 /// Get access token from local credential store.
17161724 /// If expired, refresh it automatically when a refresh token is available.
1717- /// When the access token has expired and no refresh token is available (or
1718- /// the refresh itself fails), returns [`AuthError::AuthorizationRequired`]
1719- /// so the caller can re-authenticate.
1725+ /// When the access token has expired and no refresh token is available, or the
1726+ /// authorization server rejects the refresh token, returns
1727+ /// [`AuthError::AuthorizationRequired`] so the caller can re-authenticate.
1728+ /// Transient refresh failures return [`AuthError::TokenRefreshFailed`] so the
1729+ /// caller can retry; other errors are propagated as-is.
17201730 pub async fn get_access_token ( & self ) -> Result < String , AuthError > {
17211731 let stored = self . credential_store . load ( ) . await ?;
17221732 let Some ( stored_creds) = stored else {
@@ -1757,7 +1767,7 @@ impl AuthorizationManager {
17571767 tracing:: info!( "Refreshed access token." ) ;
17581768 Ok ( new_creds. access_token ( ) . secret ( ) . to_string ( ) )
17591769 }
1760- Err ( e @ ( AuthError :: AuthorizationRequired | AuthError :: TokenRefreshFailed ( _) ) ) => {
1770+ Err ( e @ ( AuthError :: AuthorizationRequired | AuthError :: TokenRefreshRejected ( _) ) ) => {
17611771 tracing:: warn!( error = %e, "Token refresh not possible, re-authorization required." ) ;
17621772 Err ( AuthError :: AuthorizationRequired )
17631773 }
@@ -1778,9 +1788,9 @@ impl AuthorizationManager {
17781788 . token_response
17791789 . ok_or ( AuthError :: AuthorizationRequired ) ?;
17801790
1781- let refresh_token = current_credentials. refresh_token ( ) . ok_or_else ( || {
1782- AuthError :: TokenRefreshFailed ( "No refresh token available" . to_string ( ) )
1783- } ) ?;
1791+ let refresh_token = current_credentials
1792+ . refresh_token ( )
1793+ . ok_or ( AuthError :: AuthorizationRequired ) ?;
17841794 debug ! ( "refresh token present, attempting refresh" ) ;
17851795
17861796 let refresh_token_value = RefreshToken :: new ( refresh_token. secret ( ) . to_string ( ) ) ;
@@ -1799,7 +1809,14 @@ impl AuthorizationManager {
17991809 redirect_policy : self . refresh_redirect_policy ,
18001810 } )
18011811 . await
1802- . map_err ( |e| AuthError :: TokenRefreshFailed ( e. to_string ( ) ) ) ?;
1812+ . map_err ( |error| match & error {
1813+ RequestTokenError :: ServerResponse ( response)
1814+ if response. error ( ) == & BasicErrorResponseType :: InvalidGrant =>
1815+ {
1816+ AuthError :: TokenRefreshRejected ( error. to_string ( ) )
1817+ }
1818+ _ => AuthError :: TokenRefreshFailed ( error. to_string ( ) ) ,
1819+ } ) ?;
18031820
18041821 // RFC 6749 section 6: issuing a new refresh token on refresh is optional.
18051822 // When the response omits one, keep the existing refresh token rather than
@@ -5908,6 +5925,52 @@ mod tests {
59085925 resp
59095926 }
59105927
5928+ async fn manager_with_refresh_error ( error : & ' static str ) -> AuthorizationManager {
5929+ use axum:: { Router , body:: Body , http:: Response , routing:: post} ;
5930+
5931+ let app = Router :: new ( ) . route (
5932+ "/token" ,
5933+ post ( move || async move {
5934+ Response :: builder ( )
5935+ . status ( 400 )
5936+ . header ( "content-type" , "application/json" )
5937+ . body ( Body :: from (
5938+ serde_json:: json!( {
5939+ "error" : error,
5940+ "error_description" : "refresh failed" ,
5941+ } )
5942+ . to_string ( ) ,
5943+ ) )
5944+ . unwrap ( )
5945+ } ) ,
5946+ ) ;
5947+ let listener = tokio:: net:: TcpListener :: bind ( "127.0.0.1:0" ) . await . unwrap ( ) ;
5948+ let addr = listener. local_addr ( ) . unwrap ( ) ;
5949+ tokio:: spawn ( async move { axum:: serve ( listener, app) . await . unwrap ( ) } ) ;
5950+
5951+ let mut manager = manager_with_metadata ( Some ( AuthorizationMetadata {
5952+ authorization_endpoint : format ! ( "http://{addr}/authorize" ) ,
5953+ token_endpoint : format ! ( "http://{addr}/token" ) ,
5954+ ..Default :: default ( )
5955+ } ) )
5956+ . await ;
5957+ manager. configure_client ( test_client_config ( ) ) . unwrap ( ) ;
5958+ manager
5959+ . credential_store
5960+ . save ( StoredCredentials {
5961+ client_id : "my-client" . to_string ( ) ,
5962+ token_response : Some ( make_token_response_with_refresh (
5963+ "old-token" ,
5964+ "my-refresh-token" ,
5965+ ) ) ,
5966+ granted_scopes : vec ! [ ] ,
5967+ token_received_at : Some ( AuthorizationManager :: now_epoch_secs ( ) ) ,
5968+ } )
5969+ . await
5970+ . unwrap ( ) ;
5971+ manager
5972+ }
5973+
59115974 #[ tokio:: test]
59125975 async fn refresh_token_returns_error_when_no_stored_credentials ( ) {
59135976 let mut manager = manager_with_metadata ( None ) . await ;
@@ -5954,9 +6017,33 @@ mod tests {
59546017 manager. credential_store . save ( stored) . await . unwrap ( ) ;
59556018
59566019 let err = manager. refresh_token ( ) . await . unwrap_err ( ) ;
6020+ assert ! (
6021+ matches!( err, AuthError :: AuthorizationRequired ) ,
6022+ "expected AuthorizationRequired when no refresh token, got: {err:?}"
6023+ ) ;
6024+ }
6025+
6026+ #[ tokio:: test]
6027+ async fn invalid_grant_refresh_requires_reauthorization ( ) {
6028+ let manager = manager_with_refresh_error ( "invalid_grant" ) . await ;
6029+
6030+ let err = manager. try_refresh_or_reauth ( ) . await . unwrap_err ( ) ;
6031+
6032+ assert ! (
6033+ matches!( err, AuthError :: AuthorizationRequired ) ,
6034+ "expected AuthorizationRequired when the refresh token is rejected, got: {err:?}"
6035+ ) ;
6036+ }
6037+
6038+ #[ tokio:: test]
6039+ async fn temporary_refresh_failure_does_not_require_reauthorization ( ) {
6040+ let manager = manager_with_refresh_error ( "temporarily_unavailable" ) . await ;
6041+
6042+ let err = manager. try_refresh_or_reauth ( ) . await . unwrap_err ( ) ;
6043+
59576044 assert ! (
59586045 matches!( err, AuthError :: TokenRefreshFailed ( _) ) ,
5959- "expected TokenRefreshFailed when no refresh token , got: {err:?}"
6046+ "expected TokenRefreshFailed for a temporary provider failure , got: {err:?}"
59606047 ) ;
59616048 }
59626049
0 commit comments