Skip to content

Commit 54bb522

Browse files
authored
feat: include granted scopes in OAuth refresh token request (#731)
* fix: include granted scopes in OAuth refresh token request * docs: document scope forwarding in token refresh flow
1 parent be24898 commit 54bb522

File tree

2 files changed

+178
-6
lines changed

2 files changed

+178
-6
lines changed

crates/rmcp/src/transport/auth.rs

Lines changed: 176 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1176,17 +1176,22 @@ impl AuthorizationManager {
11761176
.ok_or_else(|| AuthError::InternalError("OAuth client not configured".to_string()))?;
11771177

11781178
let stored = self.credential_store.load().await?;
1179-
let current_credentials = stored
1180-
.and_then(|s| s.token_response)
1181-
.ok_or_else(|| AuthError::AuthorizationRequired)?;
1179+
let stored_credentials = stored.ok_or(AuthError::AuthorizationRequired)?;
1180+
let current_credentials = stored_credentials
1181+
.token_response
1182+
.ok_or(AuthError::AuthorizationRequired)?;
11821183

11831184
let refresh_token = current_credentials.refresh_token().ok_or_else(|| {
11841185
AuthError::TokenRefreshFailed("No refresh token available".to_string())
11851186
})?;
11861187
debug!("refresh token present, attempting refresh");
11871188

1188-
let token_result = oauth_client
1189-
.exchange_refresh_token(&RefreshToken::new(refresh_token.secret().to_string()))
1189+
let refresh_token_value = RefreshToken::new(refresh_token.secret().to_string());
1190+
let mut refresh_request = oauth_client.exchange_refresh_token(&refresh_token_value);
1191+
for scope in &stored_credentials.granted_scopes {
1192+
refresh_request = refresh_request.add_scope(Scope::new(scope.clone()));
1193+
}
1194+
let token_result = refresh_request
11901195
.request_async(&OAuthReqwestClient(self.http_client.clone()))
11911196
.await
11921197
.map_err(|e| AuthError::TokenRefreshFailed(e.to_string()))?;
@@ -3580,4 +3585,170 @@ mod tests {
35803585
"io.modelcontextprotocol/oauth-client-credentials"
35813586
);
35823587
}
3588+
3589+
// -- refresh_token --
3590+
3591+
fn make_token_response_with_refresh(
3592+
access_token: &str,
3593+
refresh_token: &str,
3594+
) -> OAuthTokenResponse {
3595+
use oauth2::RefreshToken;
3596+
let mut resp = make_token_response(access_token, Some(3600));
3597+
resp.set_refresh_token(Some(RefreshToken::new(refresh_token.to_string())));
3598+
resp
3599+
}
3600+
3601+
#[tokio::test]
3602+
async fn refresh_token_returns_error_when_no_stored_credentials() {
3603+
let mut manager = manager_with_metadata(None).await;
3604+
manager.configure_client(test_client_config()).unwrap();
3605+
3606+
let err = manager.refresh_token().await.unwrap_err();
3607+
assert!(
3608+
matches!(err, AuthError::AuthorizationRequired),
3609+
"expected AuthorizationRequired when no credentials stored, got: {err:?}"
3610+
);
3611+
}
3612+
3613+
#[tokio::test]
3614+
async fn refresh_token_returns_error_when_no_token_response() {
3615+
let mut manager = manager_with_metadata(None).await;
3616+
manager.configure_client(test_client_config()).unwrap();
3617+
3618+
let stored = StoredCredentials {
3619+
client_id: "my-client".to_string(),
3620+
token_response: None,
3621+
granted_scopes: vec![],
3622+
token_received_at: None,
3623+
};
3624+
manager.credential_store.save(stored).await.unwrap();
3625+
3626+
let err = manager.refresh_token().await.unwrap_err();
3627+
assert!(
3628+
matches!(err, AuthError::AuthorizationRequired),
3629+
"expected AuthorizationRequired when token_response is None, got: {err:?}"
3630+
);
3631+
}
3632+
3633+
#[tokio::test]
3634+
async fn refresh_token_returns_error_when_no_refresh_token() {
3635+
let mut manager = manager_with_metadata(None).await;
3636+
manager.configure_client(test_client_config()).unwrap();
3637+
3638+
let stored = StoredCredentials {
3639+
client_id: "my-client".to_string(),
3640+
token_response: Some(make_token_response("old-token", Some(3600))),
3641+
granted_scopes: vec![],
3642+
token_received_at: Some(AuthorizationManager::now_epoch_secs()),
3643+
};
3644+
manager.credential_store.save(stored).await.unwrap();
3645+
3646+
let err = manager.refresh_token().await.unwrap_err();
3647+
assert!(
3648+
matches!(err, AuthError::TokenRefreshFailed(_)),
3649+
"expected TokenRefreshFailed when no refresh token, got: {err:?}"
3650+
);
3651+
}
3652+
3653+
async fn start_token_server() -> (String, Arc<std::sync::Mutex<Option<String>>>) {
3654+
use axum::{Router, body::Body, http::Response, routing::post};
3655+
let captured: Arc<std::sync::Mutex<Option<String>>> = Arc::new(std::sync::Mutex::new(None));
3656+
let captured_clone = Arc::clone(&captured);
3657+
3658+
let app = Router::new().route(
3659+
"/token",
3660+
post(move |body: axum::body::Bytes| {
3661+
let cap = Arc::clone(&captured_clone);
3662+
async move {
3663+
*cap.lock().unwrap() =
3664+
Some(String::from_utf8(body.to_vec()).unwrap());
3665+
Response::builder()
3666+
.status(200)
3667+
.header("content-type", "application/json")
3668+
.body(Body::from(
3669+
r#"{"access_token":"new-token","token_type":"Bearer","expires_in":3600}"#,
3670+
))
3671+
.unwrap()
3672+
}
3673+
}),
3674+
);
3675+
3676+
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3677+
let addr = listener.local_addr().unwrap();
3678+
tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
3679+
3680+
(format!("http://{}", addr), captured)
3681+
}
3682+
3683+
#[tokio::test]
3684+
async fn refresh_token_sends_granted_scopes_in_request() {
3685+
let (base_url, captured) = start_token_server().await;
3686+
3687+
let mut manager = manager_with_metadata(Some(AuthorizationMetadata {
3688+
authorization_endpoint: format!("{}/authorize", base_url),
3689+
token_endpoint: format!("{}/token", base_url),
3690+
..Default::default()
3691+
}))
3692+
.await;
3693+
manager.configure_client(test_client_config()).unwrap();
3694+
3695+
let stored = StoredCredentials {
3696+
client_id: "my-client".to_string(),
3697+
token_response: Some(make_token_response_with_refresh(
3698+
"old-token",
3699+
"my-refresh-token",
3700+
)),
3701+
granted_scopes: vec!["read".to_string(), "write".to_string()],
3702+
token_received_at: Some(AuthorizationManager::now_epoch_secs()),
3703+
};
3704+
manager.credential_store.save(stored).await.unwrap();
3705+
3706+
manager.refresh_token().await.unwrap();
3707+
3708+
let body = captured.lock().unwrap().take().unwrap();
3709+
let params: std::collections::HashMap<_, _> = url::form_urlencoded::parse(body.as_bytes())
3710+
.into_owned()
3711+
.collect();
3712+
let scope = params
3713+
.get("scope")
3714+
.expect("scope should be present in refresh request");
3715+
let mut scope_parts: Vec<&str> = scope.split_whitespace().collect();
3716+
scope_parts.sort_unstable();
3717+
assert_eq!(scope_parts, vec!["read", "write"]);
3718+
}
3719+
3720+
#[tokio::test]
3721+
async fn refresh_token_omits_scope_when_granted_scopes_is_empty() {
3722+
let (base_url, captured) = start_token_server().await;
3723+
3724+
let mut manager = manager_with_metadata(Some(AuthorizationMetadata {
3725+
authorization_endpoint: format!("{}/authorize", base_url),
3726+
token_endpoint: format!("{}/token", base_url),
3727+
..Default::default()
3728+
}))
3729+
.await;
3730+
manager.configure_client(test_client_config()).unwrap();
3731+
3732+
let stored = StoredCredentials {
3733+
client_id: "my-client".to_string(),
3734+
token_response: Some(make_token_response_with_refresh(
3735+
"old-token",
3736+
"my-refresh-token",
3737+
)),
3738+
granted_scopes: vec![],
3739+
token_received_at: Some(AuthorizationManager::now_epoch_secs()),
3740+
};
3741+
manager.credential_store.save(stored).await.unwrap();
3742+
3743+
manager.refresh_token().await.unwrap();
3744+
3745+
let body = captured.lock().unwrap().take().unwrap();
3746+
let params: std::collections::HashMap<_, _> = url::form_urlencoded::parse(body.as_bytes())
3747+
.into_owned()
3748+
.collect();
3749+
assert!(
3750+
!params.contains_key("scope"),
3751+
"scope should be absent when granted_scopes is empty, body: {body}"
3752+
);
3753+
}
35833754
}

docs/OAUTH_SUPPORT.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,7 @@ cargo run -p mcp-client-examples --example clients_oauth_client
127127
6. **Authorization Request**: Build authorization URL with PKCE (S256) and RFC 8707 resource parameter
128128
7. **Authorization Code Exchange**: After user authorization, exchange code for access token (with resource parameter)
129129
8. **Token Usage**: Use access token for API calls via `AuthClient` or `AuthorizedHttpClient`
130-
9. **Token Refresh**: Automatically use refresh token to get new access token when current one expires
130+
9. **Token Refresh**: Automatically use refresh token to get new access token when current one expires; previously granted scopes are forwarded in the refresh request so providers that require them (e.g. Azure AD v2) work correctly
131131
10. **Scope Upgrade**: On 403 insufficient_scope, compute scope union and re-authorize with upgraded scopes
132132

133133
## Security Considerations
@@ -158,3 +158,4 @@ If you encounter authorization issues, check the following:
158158
- [RFC 8707: Resource Indicators for OAuth 2.0](https://datatracker.ietf.org/doc/html/rfc8707)
159159
- [RFC 9728: OAuth 2.0 Protected Resource Metadata](https://datatracker.ietf.org/doc/html/rfc9728)
160160
- [RFC 7636: Proof Key for Code Exchange (PKCE)](https://datatracker.ietf.org/doc/html/rfc7636)
161+
- [RFC 6749 §6: Refreshing an Access Token](https://www.rfc-editor.org/rfc/rfc6749#section-6)

0 commit comments

Comments
 (0)