Skip to content

Commit a32a9c8

Browse files
authored
feat(auth): implement SEP-2207 OIDC-flavored refresh token guidance (#676)
* feat: implement sep-2207 refresh token guidance * fix: update client-metadata.json to allow refresh tokens
1 parent baf22d3 commit a32a9c8

File tree

2 files changed

+171
-4
lines changed

2 files changed

+171
-4
lines changed

client-metadata.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"client_id": "https://raw.githubusercontent.com/modelcontextprotocol/rust-sdk/refs/heads/main/client-metadata.json",
33
"redirect_uris": ["http://127.0.0.1:8080/callback"],
4-
"grant_types": ["authorization_code"],
4+
"grant_types": ["authorization_code", "refresh_token"],
55
"response_types": ["code"],
66
"token_endpoint_auth_method": "none"
77
}

crates/rmcp/src/transport/auth.rs

Lines changed: 170 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -971,12 +971,23 @@ impl AuthorizationManager {
971971
attempts < self.scope_upgrade_config.max_upgrade_attempts
972972
}
973973

974+
/// select scopes to request from authorization server
975+
pub fn select_scopes(
976+
&self,
977+
www_authenticate_scope: Option<&str>,
978+
default_scopes: &[&str],
979+
) -> Vec<String> {
980+
let mut scopes = self.select_base_scopes(www_authenticate_scope, default_scopes);
981+
self.add_offline_access_if_supported(&mut scopes);
982+
scopes
983+
}
984+
974985
/// select scopes based on SEP-835 priority:
975986
/// 1. scope from WWW-Authenticate header (argument or stored from initial 401 probe)
976987
/// 2. scopes_supported from protected resource metadata (RFC 9728)
977988
/// 3. scopes_supported from authorization server metadata
978989
/// 4. provided default scopes
979-
pub fn select_scopes(
990+
fn select_base_scopes(
980991
&self,
981992
www_authenticate_scope: Option<&str>,
982993
default_scopes: &[&str],
@@ -1011,6 +1022,21 @@ impl AuthorizationManager {
10111022
default_scopes.iter().map(|s| s.to_string()).collect()
10121023
}
10131024

1025+
/// SEP-2207: when the AS advertises `offline_access` in `scopes_supported`, append
1026+
/// it so OIDC-flavored Authorization Servers will issue refresh tokens.
1027+
fn add_offline_access_if_supported(&self, scopes: &mut Vec<String>) {
1028+
if scopes.is_empty() || scopes.iter().any(|s| s == "offline_access") {
1029+
return;
1030+
}
1031+
if let Some(metadata) = &self.metadata {
1032+
if let Some(supported) = &metadata.scopes_supported {
1033+
if supported.iter().any(|s| s == "offline_access") {
1034+
scopes.push("offline_access".to_string());
1035+
}
1036+
}
1037+
}
1038+
}
1039+
10141040
/// attempt to upgrade scopes after receiving a 403 insufficient_scope error.
10151041
/// returns the authorization URL for re-authorization with upgraded scopes.
10161042
pub async fn request_scope_upgrade(&self, required_scope: &str) -> Result<String, AuthError> {
@@ -1143,7 +1169,11 @@ impl AuthorizationManager {
11431169
/// to avoid races between token retrieval and the actual HTTP request.
11441170
const REFRESH_BUFFER_SECS: u64 = 30;
11451171

1146-
/// get access token, if expired, refresh it automatically
1172+
/// Get access token from local credential store.
1173+
/// If expired, refresh it automatically when a refresh token is available.
1174+
/// When the access token has expired and no refresh token is available (or
1175+
/// the refresh itself fails), returns [`AuthError::AuthorizationRequired`]
1176+
/// so the caller can re-authenticate.
11471177
pub async fn get_access_token(&self) -> Result<String, AuthError> {
11481178
let stored = self.credential_store.load().await?;
11491179
let Some(stored_creds) = stored else {
@@ -2275,7 +2305,9 @@ impl OAuthState {
22752305
let selected_scopes: Vec<String> = if scopes.is_empty() {
22762306
manager.select_scopes(None, &[])
22772307
} else {
2278-
scopes.iter().map(|s| s.to_string()).collect()
2308+
let mut s: Vec<String> = scopes.iter().map(|s| s.to_string()).collect();
2309+
manager.add_offline_access_if_supported(&mut s);
2310+
s
22792311
};
22802312
let scope_refs: Vec<&str> = selected_scopes.iter().map(|s| s.as_str()).collect();
22812313
debug!("start session");
@@ -3279,6 +3311,141 @@ mod tests {
32793311
assert_eq!(result.len(), 2);
32803312
}
32813313

3314+
// -- SEP-2207: offline_access --
3315+
3316+
#[tokio::test]
3317+
async fn select_scopes_adds_offline_access_when_as_supports_it() {
3318+
let mgr = manager_with_metadata(Some(AuthorizationMetadata {
3319+
authorization_endpoint: "http://localhost/authorize".to_string(),
3320+
token_endpoint: "http://localhost/token".to_string(),
3321+
scopes_supported: Some(vec!["profile".to_string(), "offline_access".to_string()]),
3322+
..Default::default()
3323+
}))
3324+
.await;
3325+
*mgr.resource_scopes.write().await = vec!["profile".to_string()];
3326+
3327+
let scopes = mgr.select_scopes(None, &[]);
3328+
assert!(
3329+
scopes.contains(&"offline_access".to_string()),
3330+
"offline_access should be added when AS supports it"
3331+
);
3332+
assert!(scopes.contains(&"profile".to_string()));
3333+
}
3334+
3335+
#[tokio::test]
3336+
async fn select_scopes_does_not_add_offline_access_when_as_does_not_support_it() {
3337+
let mgr = manager_with_metadata(Some(AuthorizationMetadata {
3338+
authorization_endpoint: "http://localhost/authorize".to_string(),
3339+
token_endpoint: "http://localhost/token".to_string(),
3340+
scopes_supported: Some(vec!["profile".to_string(), "email".to_string()]),
3341+
..Default::default()
3342+
}))
3343+
.await;
3344+
*mgr.resource_scopes.write().await = vec!["profile".to_string()];
3345+
3346+
let scopes = mgr.select_scopes(None, &[]);
3347+
assert!(
3348+
!scopes.contains(&"offline_access".to_string()),
3349+
"offline_access should not be added when AS does not support it"
3350+
);
3351+
}
3352+
3353+
#[tokio::test]
3354+
async fn select_scopes_falls_back_to_defaults() {
3355+
let mgr = manager_with_metadata(Some(AuthorizationMetadata {
3356+
authorization_endpoint: "http://localhost/authorize".to_string(),
3357+
token_endpoint: "http://localhost/token".to_string(),
3358+
scopes_supported: None,
3359+
..Default::default()
3360+
}))
3361+
.await;
3362+
3363+
let scopes = mgr.select_scopes(None, &["default_scope"]);
3364+
assert_eq!(scopes, vec!["default_scope".to_string()]);
3365+
}
3366+
3367+
#[tokio::test]
3368+
async fn select_scopes_does_not_duplicate_offline_access() {
3369+
let mgr = manager_with_metadata(Some(AuthorizationMetadata {
3370+
authorization_endpoint: "http://localhost/authorize".to_string(),
3371+
token_endpoint: "http://localhost/token".to_string(),
3372+
scopes_supported: Some(vec!["profile".to_string(), "offline_access".to_string()]),
3373+
..Default::default()
3374+
}))
3375+
.await;
3376+
3377+
// When AS metadata is the scope source and already contains offline_access,
3378+
// it should appear exactly once.
3379+
let scopes = mgr.select_scopes(None, &[]);
3380+
let count = scopes.iter().filter(|s| *s == "offline_access").count();
3381+
assert_eq!(count, 1, "offline_access should not be duplicated");
3382+
}
3383+
3384+
#[tokio::test]
3385+
async fn select_scopes_adds_offline_access_to_www_authenticate_scopes() {
3386+
let mgr = manager_with_metadata(Some(AuthorizationMetadata {
3387+
authorization_endpoint: "http://localhost/authorize".to_string(),
3388+
token_endpoint: "http://localhost/token".to_string(),
3389+
scopes_supported: Some(vec!["profile".to_string(), "offline_access".to_string()]),
3390+
..Default::default()
3391+
}))
3392+
.await;
3393+
*mgr.www_auth_scopes.write().await = vec!["profile".to_string()];
3394+
3395+
let scopes = mgr.select_scopes(None, &[]);
3396+
assert!(scopes.contains(&"offline_access".to_string()));
3397+
assert!(scopes.contains(&"profile".to_string()));
3398+
}
3399+
3400+
#[tokio::test]
3401+
async fn select_scopes_adds_offline_access_to_www_authenticate_argument() {
3402+
let mgr = manager_with_metadata(Some(AuthorizationMetadata {
3403+
authorization_endpoint: "http://localhost/authorize".to_string(),
3404+
token_endpoint: "http://localhost/token".to_string(),
3405+
scopes_supported: Some(vec!["profile".to_string(), "offline_access".to_string()]),
3406+
..Default::default()
3407+
}))
3408+
.await;
3409+
3410+
let scopes = mgr.select_scopes(Some("profile email"), &[]);
3411+
assert!(scopes.contains(&"offline_access".to_string()));
3412+
assert!(scopes.contains(&"profile".to_string()));
3413+
assert!(scopes.contains(&"email".to_string()));
3414+
}
3415+
3416+
#[tokio::test]
3417+
async fn add_offline_access_if_supported_works_with_explicit_scopes() {
3418+
let mgr = manager_with_metadata(Some(AuthorizationMetadata {
3419+
authorization_endpoint: "http://localhost/authorize".to_string(),
3420+
token_endpoint: "http://localhost/token".to_string(),
3421+
scopes_supported: Some(vec!["profile".to_string(), "offline_access".to_string()]),
3422+
..Default::default()
3423+
}))
3424+
.await;
3425+
3426+
let mut explicit = vec!["read".to_string(), "write".to_string()];
3427+
mgr.add_offline_access_if_supported(&mut explicit);
3428+
assert!(explicit.contains(&"offline_access".to_string()));
3429+
}
3430+
3431+
#[tokio::test]
3432+
async fn add_offline_access_if_supported_skips_empty_scopes() {
3433+
let mgr = manager_with_metadata(Some(AuthorizationMetadata {
3434+
authorization_endpoint: "http://localhost/authorize".to_string(),
3435+
token_endpoint: "http://localhost/token".to_string(),
3436+
scopes_supported: Some(vec!["profile".to_string(), "offline_access".to_string()]),
3437+
..Default::default()
3438+
}))
3439+
.await;
3440+
3441+
let mut empty: Vec<String> = vec![];
3442+
mgr.add_offline_access_if_supported(&mut empty);
3443+
assert!(
3444+
empty.is_empty(),
3445+
"offline_access should not be the only scope"
3446+
);
3447+
}
3448+
32823449
#[test]
32833450
fn scope_upgrade_config_default_values() {
32843451
let config = ScopeUpgradeConfig::default();

0 commit comments

Comments
 (0)