Skip to content

Commit 0507fdc

Browse files
committed
fix(auth): distinguish rejected refresh tokens
1 parent 2e2c791 commit 0507fdc

1 file changed

Lines changed: 92 additions & 7 deletions

File tree

crates/rmcp/src/transport/auth.rs

Lines changed: 92 additions & 7 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

@@ -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

Comments
 (0)