@@ -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
@@ -1757,7 +1765,7 @@ impl AuthorizationManager {
17571765 tracing:: info!( "Refreshed access token." ) ;
17581766 Ok ( new_creds. access_token ( ) . secret ( ) . to_string ( ) )
17591767 }
1760- Err ( e @ ( AuthError :: AuthorizationRequired | AuthError :: TokenRefreshFailed ( _) ) ) => {
1768+ Err ( e @ ( AuthError :: AuthorizationRequired | AuthError :: TokenRefreshRejected ( _) ) ) => {
17611769 tracing:: warn!( error = %e, "Token refresh not possible, re-authorization required." ) ;
17621770 Err ( AuthError :: AuthorizationRequired )
17631771 }
@@ -1778,9 +1786,9 @@ impl AuthorizationManager {
17781786 . token_response
17791787 . ok_or ( AuthError :: AuthorizationRequired ) ?;
17801788
1781- let refresh_token = current_credentials. refresh_token ( ) . ok_or_else ( || {
1782- AuthError :: TokenRefreshFailed ( "No refresh token available" . to_string ( ) )
1783- } ) ?;
1789+ let refresh_token = current_credentials
1790+ . refresh_token ( )
1791+ . ok_or ( AuthError :: AuthorizationRequired ) ?;
17841792 debug ! ( "refresh token present, attempting refresh" ) ;
17851793
17861794 let refresh_token_value = RefreshToken :: new ( refresh_token. secret ( ) . to_string ( ) ) ;
@@ -1799,7 +1807,14 @@ impl AuthorizationManager {
17991807 redirect_policy : self . refresh_redirect_policy ,
18001808 } )
18011809 . await
1802- . map_err ( |e| AuthError :: TokenRefreshFailed ( e. to_string ( ) ) ) ?;
1810+ . map_err ( |error| match & error {
1811+ RequestTokenError :: ServerResponse ( response)
1812+ if response. error ( ) == & BasicErrorResponseType :: InvalidGrant =>
1813+ {
1814+ AuthError :: TokenRefreshRejected ( error. to_string ( ) )
1815+ }
1816+ _ => AuthError :: TokenRefreshFailed ( error. to_string ( ) ) ,
1817+ } ) ?;
18031818
18041819 // RFC 6749 section 6: issuing a new refresh token on refresh is optional.
18051820 // When the response omits one, keep the existing refresh token rather than
@@ -5908,6 +5923,52 @@ mod tests {
59085923 resp
59095924 }
59105925
5926+ async fn manager_with_refresh_error ( error : & ' static str ) -> AuthorizationManager {
5927+ use axum:: { Router , body:: Body , http:: Response , routing:: post} ;
5928+
5929+ let app = Router :: new ( ) . route (
5930+ "/token" ,
5931+ post ( move || async move {
5932+ Response :: builder ( )
5933+ . status ( 400 )
5934+ . header ( "content-type" , "application/json" )
5935+ . body ( Body :: from (
5936+ serde_json:: json!( {
5937+ "error" : error,
5938+ "error_description" : "refresh failed" ,
5939+ } )
5940+ . to_string ( ) ,
5941+ ) )
5942+ . unwrap ( )
5943+ } ) ,
5944+ ) ;
5945+ let listener = tokio:: net:: TcpListener :: bind ( "127.0.0.1:0" ) . await . unwrap ( ) ;
5946+ let addr = listener. local_addr ( ) . unwrap ( ) ;
5947+ tokio:: spawn ( async move { axum:: serve ( listener, app) . await . unwrap ( ) } ) ;
5948+
5949+ let mut manager = manager_with_metadata ( Some ( AuthorizationMetadata {
5950+ authorization_endpoint : format ! ( "http://{addr}/authorize" ) ,
5951+ token_endpoint : format ! ( "http://{addr}/token" ) ,
5952+ ..Default :: default ( )
5953+ } ) )
5954+ . await ;
5955+ manager. configure_client ( test_client_config ( ) ) . unwrap ( ) ;
5956+ manager
5957+ . credential_store
5958+ . save ( StoredCredentials {
5959+ client_id : "my-client" . to_string ( ) ,
5960+ token_response : Some ( make_token_response_with_refresh (
5961+ "old-token" ,
5962+ "my-refresh-token" ,
5963+ ) ) ,
5964+ granted_scopes : vec ! [ ] ,
5965+ token_received_at : Some ( AuthorizationManager :: now_epoch_secs ( ) ) ,
5966+ } )
5967+ . await
5968+ . unwrap ( ) ;
5969+ manager
5970+ }
5971+
59115972 #[ tokio:: test]
59125973 async fn refresh_token_returns_error_when_no_stored_credentials ( ) {
59135974 let mut manager = manager_with_metadata ( None ) . await ;
@@ -5954,9 +6015,33 @@ mod tests {
59546015 manager. credential_store . save ( stored) . await . unwrap ( ) ;
59556016
59566017 let err = manager. refresh_token ( ) . await . unwrap_err ( ) ;
6018+ assert ! (
6019+ matches!( err, AuthError :: AuthorizationRequired ) ,
6020+ "expected AuthorizationRequired when no refresh token, got: {err:?}"
6021+ ) ;
6022+ }
6023+
6024+ #[ tokio:: test]
6025+ async fn invalid_grant_refresh_requires_reauthorization ( ) {
6026+ let manager = manager_with_refresh_error ( "invalid_grant" ) . await ;
6027+
6028+ let err = manager. try_refresh_or_reauth ( ) . await . unwrap_err ( ) ;
6029+
6030+ assert ! (
6031+ matches!( err, AuthError :: AuthorizationRequired ) ,
6032+ "expected AuthorizationRequired when the refresh token is rejected, got: {err:?}"
6033+ ) ;
6034+ }
6035+
6036+ #[ tokio:: test]
6037+ async fn temporary_refresh_failure_does_not_require_reauthorization ( ) {
6038+ let manager = manager_with_refresh_error ( "temporarily_unavailable" ) . await ;
6039+
6040+ let err = manager. try_refresh_or_reauth ( ) . await . unwrap_err ( ) ;
6041+
59576042 assert ! (
59586043 matches!( err, AuthError :: TokenRefreshFailed ( _) ) ,
5959- "expected TokenRefreshFailed when no refresh token , got: {err:?}"
6044+ "expected TokenRefreshFailed for a temporary provider failure , got: {err:?}"
59606045 ) ;
59616046 }
59626047
0 commit comments