Skip to content

Commit b9a14e0

Browse files
committed
fix(auth): distinguish rejected refresh tokens
1 parent 95490fa commit b9a14e0

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

Comments
 (0)