Skip to content

Commit f4b9f30

Browse files
DaleSeopeicodes
authored andcommitted
feat: include granted scopes in OAuth refresh token request (modelcontextprotocol#731)
* fix: include granted scopes in OAuth refresh token request * docs: document scope forwarding in token refresh flow
1 parent 07ef421 commit f4b9f30

3 files changed

Lines changed: 192 additions & 13 deletions

File tree

crates/rmcp/Cargo.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,8 @@ schemars = ["dep:schemars"]
143143
[dev-dependencies]
144144
tokio = { version = "1", features = ["full"] }
145145
schemars = { version = "1.1.0", features = ["chrono04"] }
146-
146+
axum = { version = "0.8", default-features = false, features = ["http1", "tokio"] }
147+
url = "2.4"
147148
anyhow = "1.0"
148149
tracing-subscriber = { version = "0.3", features = [
149150
"env-filter",

crates/rmcp/src/transport/auth.rs

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

662662
let stored = self.credential_store.load().await?;
663-
let current_credentials = stored
664-
.and_then(|s| s.token_response)
665-
.ok_or_else(|| AuthError::AuthorizationRequired)?;
663+
let stored_credentials = stored.ok_or(AuthError::AuthorizationRequired)?;
664+
let current_credentials = stored_credentials
665+
.token_response
666+
.ok_or(AuthError::AuthorizationRequired)?;
666667

667668
let refresh_token = current_credentials.refresh_token().ok_or_else(|| {
668669
AuthError::TokenRefreshFailed("No refresh token available".to_string())
669670
})?;
670671
debug!("refresh token: {:?}", refresh_token);
671672

672-
let token_result = oauth_client
673-
.exchange_refresh_token(&RefreshToken::new(refresh_token.secret().to_string()))
673+
let refresh_token_value = RefreshToken::new(refresh_token.secret().to_string());
674+
let mut refresh_request = oauth_client.exchange_refresh_token(&refresh_token_value);
675+
for scope in &stored_credentials.granted_scopes {
676+
refresh_request = refresh_request.add_scope(Scope::new(scope.clone()));
677+
}
678+
let token_result = refresh_request
674679
.request_async(&self.http_client)
675680
.await
676681
.map_err(|e| AuthError::TokenRefreshFailed(e.to_string()))?;
@@ -1843,4 +1848,170 @@ mod tests {
18431848
"expected InternalError when OAuth client is not configured, got: {err:?}"
18441849
);
18451850
}
1851+
1852+
// -- refresh_token --
1853+
1854+
fn make_token_response_with_refresh(
1855+
access_token: &str,
1856+
refresh_token_str: &str,
1857+
) -> OAuthTokenResponse {
1858+
use oauth2::RefreshToken;
1859+
let mut resp = make_token_response(access_token, Some(3600));
1860+
resp.set_refresh_token(Some(RefreshToken::new(refresh_token_str.to_string())));
1861+
resp
1862+
}
1863+
1864+
#[tokio::test]
1865+
async fn refresh_token_returns_error_when_no_stored_credentials() {
1866+
let mut manager = manager_with_metadata(None).await;
1867+
manager.configure_client(test_client_config()).unwrap();
1868+
1869+
let err = manager.refresh_token().await.unwrap_err();
1870+
assert!(
1871+
matches!(err, AuthError::AuthorizationRequired),
1872+
"expected AuthorizationRequired when no credentials stored, got: {err:?}"
1873+
);
1874+
}
1875+
1876+
#[tokio::test]
1877+
async fn refresh_token_returns_error_when_no_token_response() {
1878+
let mut manager = manager_with_metadata(None).await;
1879+
manager.configure_client(test_client_config()).unwrap();
1880+
1881+
let stored = StoredCredentials {
1882+
client_id: "my-client".to_string(),
1883+
token_response: None,
1884+
granted_scopes: vec![],
1885+
token_received_at: None,
1886+
};
1887+
manager.credential_store.save(stored).await.unwrap();
1888+
1889+
let err = manager.refresh_token().await.unwrap_err();
1890+
assert!(
1891+
matches!(err, AuthError::AuthorizationRequired),
1892+
"expected AuthorizationRequired when token_response is None, got: {err:?}"
1893+
);
1894+
}
1895+
1896+
#[tokio::test]
1897+
async fn refresh_token_returns_error_when_no_refresh_token() {
1898+
let mut manager = manager_with_metadata(None).await;
1899+
manager.configure_client(test_client_config()).unwrap();
1900+
1901+
let stored = StoredCredentials {
1902+
client_id: "my-client".to_string(),
1903+
token_response: Some(make_token_response("old-token", Some(3600))),
1904+
granted_scopes: vec![],
1905+
token_received_at: Some(AuthorizationManager::now_epoch_secs()),
1906+
};
1907+
manager.credential_store.save(stored).await.unwrap();
1908+
1909+
let err = manager.refresh_token().await.unwrap_err();
1910+
assert!(
1911+
matches!(err, AuthError::TokenRefreshFailed(_)),
1912+
"expected TokenRefreshFailed when no refresh token, got: {err:?}"
1913+
);
1914+
}
1915+
1916+
async fn start_token_server() -> (String, Arc<std::sync::Mutex<Option<String>>>) {
1917+
use axum::{Router, body::Body, http::Response, routing::post};
1918+
let captured: Arc<std::sync::Mutex<Option<String>>> = Arc::new(std::sync::Mutex::new(None));
1919+
let captured_clone = Arc::clone(&captured);
1920+
1921+
let app = Router::new().route(
1922+
"/token",
1923+
post(move |body: axum::body::Bytes| {
1924+
let cap = Arc::clone(&captured_clone);
1925+
async move {
1926+
*cap.lock().unwrap() =
1927+
Some(String::from_utf8(body.to_vec()).unwrap());
1928+
Response::builder()
1929+
.status(200)
1930+
.header("content-type", "application/json")
1931+
.body(Body::from(
1932+
r#"{"access_token":"new-token","token_type":"Bearer","expires_in":3600}"#,
1933+
))
1934+
.unwrap()
1935+
}
1936+
}),
1937+
);
1938+
1939+
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1940+
let addr = listener.local_addr().unwrap();
1941+
tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
1942+
1943+
(format!("http://{}", addr), captured)
1944+
}
1945+
1946+
#[tokio::test]
1947+
async fn refresh_token_sends_granted_scopes_in_request() {
1948+
let (base_url, captured) = start_token_server().await;
1949+
1950+
let mut manager = manager_with_metadata(Some(AuthorizationMetadata {
1951+
authorization_endpoint: format!("{}/authorize", base_url),
1952+
token_endpoint: format!("{}/token", base_url),
1953+
..Default::default()
1954+
}))
1955+
.await;
1956+
manager.configure_client(test_client_config()).unwrap();
1957+
1958+
let stored = StoredCredentials {
1959+
client_id: "my-client".to_string(),
1960+
token_response: Some(make_token_response_with_refresh(
1961+
"old-token",
1962+
"my-refresh-token",
1963+
)),
1964+
granted_scopes: vec!["read".to_string(), "write".to_string()],
1965+
token_received_at: Some(AuthorizationManager::now_epoch_secs()),
1966+
};
1967+
manager.credential_store.save(stored).await.unwrap();
1968+
1969+
manager.refresh_token().await.unwrap();
1970+
1971+
let body = captured.lock().unwrap().take().unwrap();
1972+
let params: std::collections::HashMap<_, _> = url::form_urlencoded::parse(body.as_bytes())
1973+
.into_owned()
1974+
.collect();
1975+
let scope = params
1976+
.get("scope")
1977+
.expect("scope should be present in refresh request");
1978+
let mut scope_parts: Vec<&str> = scope.split_whitespace().collect();
1979+
scope_parts.sort_unstable();
1980+
assert_eq!(scope_parts, vec!["read", "write"]);
1981+
}
1982+
1983+
#[tokio::test]
1984+
async fn refresh_token_omits_scope_when_granted_scopes_is_empty() {
1985+
let (base_url, captured) = start_token_server().await;
1986+
1987+
let mut manager = manager_with_metadata(Some(AuthorizationMetadata {
1988+
authorization_endpoint: format!("{}/authorize", base_url),
1989+
token_endpoint: format!("{}/token", base_url),
1990+
..Default::default()
1991+
}))
1992+
.await;
1993+
manager.configure_client(test_client_config()).unwrap();
1994+
1995+
let stored = StoredCredentials {
1996+
client_id: "my-client".to_string(),
1997+
token_response: Some(make_token_response_with_refresh(
1998+
"old-token",
1999+
"my-refresh-token",
2000+
)),
2001+
granted_scopes: vec![],
2002+
token_received_at: Some(AuthorizationManager::now_epoch_secs()),
2003+
};
2004+
manager.credential_store.save(stored).await.unwrap();
2005+
2006+
manager.refresh_token().await.unwrap();
2007+
2008+
let body = captured.lock().unwrap().take().unwrap();
2009+
let params: std::collections::HashMap<_, _> = url::form_urlencoded::parse(body.as_bytes())
2010+
.into_owned()
2011+
.collect();
2012+
assert!(
2013+
!params.contains_key("scope"),
2014+
"scope should be absent when granted_scopes is empty, body: {body}"
2015+
);
2016+
}
18462017
}

docs/OAUTH_SUPPORT.md

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -95,12 +95,15 @@ cargo run --example oauth-client
9595

9696
## Authorization Flow Description
9797

98-
1. **Metadata Discovery**: Client attempts to get authorization server metadata from `/.well-known/oauth-authorization-server`
99-
2. **Client Registration**: If supported, client dynamically registers itself
100-
3. **Authorization Request**: Build authorization URL with PKCE and guide user to access
101-
4. **Authorization Code Exchange**: After user authorization, exchange authorization code for access token
102-
5. **Token Usage**: Use access token for API calls
103-
6. **Token Refresh**: Automatically use refresh token to get new access token when current one expires
98+
1. **Resource Metadata Discovery**: Client probes the server and extracts `WWW-Authenticate` parameters including `resource_metadata` URL and `scope`
99+
2. **Protected Resource Metadata**: Client fetches resource server metadata (RFC 9728) to find authorization server(s) and supported scopes
100+
3. **AS Metadata Discovery**: Client discovers authorization server metadata via RFC 8414 and OpenID Connect well-known endpoints
101+
4. **Client Registration**: If supported, client dynamically registers itself (or uses URL-based Client ID via SEP-991)
102+
5. **Scope Selection**: SDK picks scopes from WWW-Authenticate > PRM > AS metadata > caller defaults
103+
6. **Authorization Request**: Build authorization URL with PKCE (S256) and RFC 8707 resource parameter
104+
7. **Authorization Code Exchange**: After user authorization, exchange code for access token (with resource parameter)
105+
8. **Token Usage**: Use access token for API calls via `AuthClient` or `AuthorizedHttpClient`
106+
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
104107

105108
## Security Considerations
106109

@@ -123,4 +126,8 @@ If you encounter authorization issues, check the following:
123126
- [MCP Authorization Specification](https://spec.modelcontextprotocol.io/specification/2025-03-26/basic/authorization/)
124127
- [OAuth 2.1 Specification Draft](https://oauth.net/2.1/)
125128
- [RFC 8414: OAuth 2.0 Authorization Server Metadata](https://datatracker.ietf.org/doc/html/rfc8414)
126-
- [RFC 7591: OAuth 2.0 Dynamic Client Registration Protocol](https://datatracker.ietf.org/doc/html/rfc7591)
129+
- [RFC 7591: OAuth 2.0 Dynamic Client Registration Protocol](https://datatracker.ietf.org/doc/html/rfc7591)
130+
- [RFC 8707: Resource Indicators for OAuth 2.0](https://datatracker.ietf.org/doc/html/rfc8707)
131+
- [RFC 9728: OAuth 2.0 Protected Resource Metadata](https://datatracker.ietf.org/doc/html/rfc9728)
132+
- [RFC 7636: Proof Key for Code Exchange (PKCE)](https://datatracker.ietf.org/doc/html/rfc7636)
133+
- [RFC 6749 §6: Refreshing an Access Token](https://www.rfc-editor.org/rfc/rfc6749#section-6)

0 commit comments

Comments
 (0)