Skip to content

Commit 9df629e

Browse files
fix(auth): distinguish rejected refresh tokens from transient failures (#963)
* fix(auth): distinguish rejected refresh tokens * docs(auth): clarify refresh failure handling
1 parent 2e2c791 commit 9df629e

1 file changed

Lines changed: 97 additions & 10 deletions

File tree

crates/rmcp/src/transport/auth.rs

Lines changed: 97 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -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
};
1819
use 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

Comments
 (0)