Skip to content

Commit 7824bfd

Browse files
feat(auth): accumulate client-side scopes during step-up authorization (#888)
SEP-2350 [1] clarifies that scope accumulation is a client-side responsibility: during re-authorization the client requests the union of its previously requested scopes and the newly challenged scopes, because servers report only the scopes needed for the current operation in 403 / insufficient_scope challenges (RFC 6750 §3.1), not the union of everything granted so far. select_base_scopes returned a single source, so a 403 challenge replaced the previously requested scopes instead of widening them, dropping prior permissions across step-up rounds. I make it union the previously requested scopes, the WWW-Authenticate challenge, and the protected resource metadata scopes (RFC 9728), treating each server-reported set as an operational requirement for the current operation rather than an exclusive directive; AS metadata and caller defaults only seed the request when nothing has been requested or challenged yet. exchange_code_for_token treats an explicit scope list as authoritative, so a server may still narrow the grant. When the server omits scope it has granted exactly what the client requested (RFC 6749 §5.1), so the grant has to fall back to the scopes requested in this round, not the previously granted set; otherwise a step-up that the server confirms by omitting scope would silently drop the just-added permission and the client would loop on the same 403. The widened request was not persisted anywhere the exchange could read it, so I record it on StoredAuthorizationState per authorization (defaulting empty for states stored before this field existed) and resolve the grant from there. This addresses review feedback [2] that the earlier fallback returned the previous grant rather than the request. Deduplication preserves first-seen order for stable, testable output. Tests cover multi-round accumulation, dedup, resource-metadata unioning, and grant resolution when the response omits scope. Implements [3]. [1]: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/docs/specification/draft/basic/authorization.mdx#L682 [2]: #888 (review) [3]: #877 Signed-off-by: Stefano Amorelli <stefano@amorelli.tech>
1 parent 3549e86 commit 7824bfd

1 file changed

Lines changed: 183 additions & 26 deletions

File tree

crates/rmcp/src/transport/auth.rs

Lines changed: 183 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -293,6 +293,10 @@ pub struct StoredAuthorizationState {
293293
#[serde(default)]
294294
pub require_issuer: bool,
295295
pub created_at: u64,
296+
/// scopes requested in this round, used to resolve the grant when the token response omits
297+
/// `scope` (RFC 6749 §5.1)
298+
#[serde(default)]
299+
pub requested_scopes: Vec<String>,
296300
}
297301

298302
impl std::fmt::Debug for StoredAuthorizationState {
@@ -303,6 +307,7 @@ impl std::fmt::Debug for StoredAuthorizationState {
303307
.field("expected_issuer", &self.expected_issuer)
304308
.field("require_issuer", &self.require_issuer)
305309
.field("created_at", &self.created_at)
310+
.field("requested_scopes", &self.requested_scopes)
306311
.finish()
307312
}
308313
}
@@ -354,9 +359,16 @@ impl StoredAuthorizationState {
354359
.duration_since(std::time::UNIX_EPOCH)
355360
.map(|d| d.as_secs())
356361
.unwrap_or(0),
362+
requested_scopes: Vec::new(),
357363
}
358364
}
359365

366+
/// record the scopes requested in this authorization round (SEP-2350)
367+
pub fn with_requested_scopes(mut self, scopes: Vec<String>) -> Self {
368+
self.requested_scopes = scopes;
369+
self
370+
}
371+
360372
pub fn into_pkce_verifier(self) -> PkceCodeVerifier {
361373
PkceCodeVerifier::new(self.pkce_verifier)
362374
}
@@ -1365,7 +1377,7 @@ impl AuthorizationManager {
13651377

13661378
let (auth_url, csrf_token) = auth_request.url();
13671379

1368-
// store pkce verifier and expected issuer for later use via state store
1380+
// store pkce verifier, expected issuer, and the requested scopes for later use via state store
13691381
let expected_issuer = self
13701382
.metadata
13711383
.as_ref()
@@ -1385,7 +1397,8 @@ impl AuthorizationManager {
13851397
&csrf_token,
13861398
expected_issuer,
13871399
require_issuer,
1388-
);
1400+
)
1401+
.with_requested_scopes(scopes.iter().map(|s| s.to_string()).collect());
13891402
self.state_store
13901403
.save(csrf_token.secret(), stored_state)
13911404
.await?;
@@ -1400,11 +1413,33 @@ impl AuthorizationManager {
14001413

14011414
/// compute the union of current scopes and required scopes
14021415
fn compute_scope_union(current: &[String], required: &str) -> Vec<String> {
1403-
let mut scope_set: std::collections::HashSet<String> = current.iter().cloned().collect();
1404-
for scope in required.split_whitespace() {
1405-
scope_set.insert(scope.to_string());
1416+
let mut scopes = current.to_vec();
1417+
scopes.extend(required.split_whitespace().map(|s| s.to_string()));
1418+
Self::dedup_scopes(scopes)
1419+
}
1420+
1421+
/// deduplicate scopes preserving first-seen order (SEP-2350: stable for testability)
1422+
fn dedup_scopes(scopes: Vec<String>) -> Vec<String> {
1423+
let mut seen = std::collections::HashSet::new();
1424+
scopes
1425+
.into_iter()
1426+
.filter(|s| seen.insert(s.clone()))
1427+
.collect()
1428+
}
1429+
1430+
/// resolve the granted scope set from a token response (SEP-2350, RFC 6749 §5.1): an explicit
1431+
/// `scope` may narrow the grant; an omitted one means the request was granted in full, so fall
1432+
/// back to the requested scopes (or the previously granted set when none were recorded).
1433+
fn resolve_granted_scopes(
1434+
response_scopes: Option<Vec<String>>,
1435+
requested_scopes: &[String],
1436+
current_scopes: &[String],
1437+
) -> Vec<String> {
1438+
match response_scopes {
1439+
Some(scopes) => scopes,
1440+
None if !requested_scopes.is_empty() => requested_scopes.to_vec(),
1441+
None => current_scopes.to_vec(),
14061442
}
1407-
scope_set.into_iter().collect()
14081443
}
14091444

14101445
/// check if a scope upgrade is possible and allowed
@@ -1427,35 +1462,42 @@ impl AuthorizationManager {
14271462
scopes
14281463
}
14291464

1430-
/// select scopes based on SEP-835 priority:
1431-
/// 1. scope from WWW-Authenticate header (argument or stored from initial 401 probe)
1432-
/// 2. scopes_supported from protected resource metadata (RFC 9728)
1433-
/// 3. scopes_supported from authorization server metadata
1434-
/// 4. provided default scopes
1465+
/// select scopes following SEP-2350: re-authorization requests the union of the
1466+
/// previously requested scopes and the newly challenged scopes. Server-reported
1467+
/// scopes (WWW-Authenticate challenge, protected resource metadata) are operational
1468+
/// requirements for the current operation, never an exclusive directive, so they
1469+
/// accumulate rather than replace. The AS metadata and caller defaults only seed the
1470+
/// request when nothing has been requested or challenged yet.
14351471
fn select_base_scopes(
14361472
&self,
14371473
www_authenticate_scope: Option<&str>,
14381474
default_scopes: &[&str],
14391475
) -> Vec<String> {
1440-
if let Some(scope) = www_authenticate_scope {
1441-
return scope.split_whitespace().map(|s| s.to_string()).collect();
1476+
let mut accumulated: Vec<String> = Vec::new();
1477+
1478+
// previously requested scopes
1479+
if let Ok(guard) = self.current_scopes.try_read() {
1480+
accumulated.extend(guard.iter().cloned());
14421481
}
14431482

1444-
// use scopes from initial 401 WWW-Authenticate header
1483+
// newly challenged scopes for the current operation (RFC 6750 §3.1)
1484+
if let Some(scope) = www_authenticate_scope {
1485+
accumulated.extend(scope.split_whitespace().map(|s| s.to_string()));
1486+
}
14451487
if let Ok(guard) = self.www_auth_scopes.try_read() {
1446-
if !guard.is_empty() {
1447-
return guard.clone();
1448-
}
1488+
accumulated.extend(guard.iter().cloned());
14491489
}
14501490

1451-
// use scopes_supported from protected resource metadata (RFC 9728)
1491+
// scopes required for the current operation per protected resource metadata (RFC 9728)
14521492
if let Ok(guard) = self.resource_scopes.try_read() {
1453-
if !guard.is_empty() {
1454-
return guard.clone();
1455-
}
1493+
accumulated.extend(guard.iter().cloned());
1494+
}
1495+
1496+
if !accumulated.is_empty() {
1497+
return Self::dedup_scopes(accumulated);
14561498
}
14571499

1458-
// use scopes_supported from authorization server metadata
1500+
// nothing requested or challenged yet: seed from AS metadata, then caller defaults
14591501
if let Some(metadata) = &self.metadata {
14601502
if let Some(scopes_supported) = &metadata.scopes_supported {
14611503
if !scopes_supported.is_empty() {
@@ -1595,6 +1637,9 @@ impl AuthorizationManager {
15951637

15961638
Self::validate_authorization_response_issuer(&stored_state, received_issuer)?;
15971639

1640+
// capture requested scopes before the state is consumed
1641+
let requested_scopes = stored_state.requested_scopes.clone();
1642+
15981643
// Reconstruct the PKCE verifier
15991644
let pkce_verifier = stored_state.into_pkce_verifier();
16001645

@@ -1632,10 +1677,14 @@ impl AuthorizationManager {
16321677

16331678
debug!("exchange token result: {:?}", token_result);
16341679

1635-
let granted_scopes: Vec<String> = token_result
1680+
// SEP-2350: an omitted `scope` means the grant equals the request (RFC 6749 §5.1).
1681+
let response_scopes = token_result
16361682
.scopes()
1637-
.map(|scopes| scopes.iter().map(|s| s.to_string()).collect())
1638-
.unwrap_or_default();
1683+
.map(|scopes| scopes.iter().map(|s| s.to_string()).collect());
1684+
let granted_scopes = {
1685+
let current = self.current_scopes.read().await;
1686+
Self::resolve_granted_scopes(response_scopes, &requested_scopes, &current)
1687+
};
16391688

16401689
*self.current_scopes.write().await = granted_scopes.clone();
16411690
*self.scope_upgrade_attempts.write().await = 0;
@@ -4283,17 +4332,27 @@ mod tests {
42834332
fn test_stored_authorization_state_serialization() {
42844333
let pkce = PkceCodeVerifier::new("my-verifier".to_string());
42854334
let csrf = CsrfToken::new("my-csrf".to_string());
4286-
let state = StoredAuthorizationState::new(&pkce, &csrf);
4335+
let state = StoredAuthorizationState::new(&pkce, &csrf)
4336+
.with_requested_scopes(vec!["read".to_string(), "write".to_string()]);
42874337

42884338
let json = serde_json::to_string(&state).unwrap();
42894339
let deserialized: StoredAuthorizationState = serde_json::from_str(&json).unwrap();
42904340

42914341
assert_eq!(deserialized.pkce_verifier, "my-verifier");
42924342
assert_eq!(deserialized.csrf_token, "my-csrf");
4343+
assert_eq!(deserialized.requested_scopes, vec!["read", "write"]);
42934344
assert_eq!(deserialized.expected_issuer, None);
42944345
assert!(!deserialized.require_issuer);
42954346
}
42964347

4348+
#[test]
4349+
fn stored_authorization_state_defaults_requested_scopes_when_absent() {
4350+
let json = r#"{"pkce_verifier":"v","csrf_token":"c","created_at":1}"#;
4351+
let state: StoredAuthorizationState = serde_json::from_str(json).unwrap();
4352+
4353+
assert!(state.requested_scopes.is_empty());
4354+
}
4355+
42974356
#[test]
42984357
fn test_stored_authorization_state_records_expected_issuer() {
42994358
let pkce = PkceCodeVerifier::new("my-verifier".to_string());
@@ -5091,6 +5150,104 @@ mod tests {
50915150
assert!(scopes.contains(&"email".to_string()));
50925151
}
50935152

5153+
// -- SEP-2350: client-side scope accumulation in step-up authorization --
5154+
5155+
#[tokio::test]
5156+
async fn select_scopes_unions_challenge_with_previously_requested() {
5157+
let mgr = manager_with_metadata(None).await;
5158+
*mgr.current_scopes.write().await = vec!["read".to_string()];
5159+
5160+
let scopes = mgr.select_scopes(Some("write"), &[]);
5161+
5162+
assert_eq!(scopes, vec!["read".to_string(), "write".to_string()]);
5163+
}
5164+
5165+
#[tokio::test]
5166+
async fn select_scopes_does_not_replace_previously_requested_with_challenge() {
5167+
let mgr = manager_with_metadata(None).await;
5168+
*mgr.current_scopes.write().await = vec!["read".to_string(), "profile".to_string()];
5169+
5170+
let scopes = mgr.select_scopes(Some("write"), &[]);
5171+
5172+
assert!(scopes.contains(&"read".to_string()));
5173+
assert!(scopes.contains(&"profile".to_string()));
5174+
assert!(scopes.contains(&"write".to_string()));
5175+
}
5176+
5177+
#[tokio::test]
5178+
async fn select_scopes_accumulates_across_multiple_step_up_rounds() {
5179+
let mgr = manager_with_metadata(None).await;
5180+
*mgr.current_scopes.write().await = vec!["read".to_string()];
5181+
5182+
// round one: server challenges for "write"
5183+
let round_one = mgr.select_scopes(Some("write"), &[]);
5184+
assert_eq!(round_one, vec!["read".to_string(), "write".to_string()]);
5185+
*mgr.current_scopes.write().await = round_one;
5186+
5187+
// round two: server challenges for "admin", earlier scopes are retained
5188+
let round_two = mgr.select_scopes(Some("admin"), &[]);
5189+
assert_eq!(
5190+
round_two,
5191+
vec!["read".to_string(), "write".to_string(), "admin".to_string()]
5192+
);
5193+
}
5194+
5195+
#[tokio::test]
5196+
async fn select_scopes_deduplicates_challenge_already_requested() {
5197+
let mgr = manager_with_metadata(None).await;
5198+
*mgr.current_scopes.write().await = vec!["read".to_string(), "write".to_string()];
5199+
5200+
let scopes = mgr.select_scopes(Some("write admin"), &[]);
5201+
5202+
assert_eq!(
5203+
scopes,
5204+
vec!["read".to_string(), "write".to_string(), "admin".to_string()]
5205+
);
5206+
}
5207+
5208+
#[tokio::test]
5209+
async fn select_scopes_unions_resource_metadata_as_operational_requirement() {
5210+
let mgr = manager_with_metadata(None).await;
5211+
*mgr.current_scopes.write().await = vec!["read".to_string()];
5212+
*mgr.resource_scopes.write().await = vec!["profile".to_string()];
5213+
5214+
let scopes = mgr.select_scopes(Some("write"), &[]);
5215+
5216+
assert!(scopes.contains(&"read".to_string()));
5217+
assert!(scopes.contains(&"write".to_string()));
5218+
assert!(scopes.contains(&"profile".to_string()));
5219+
}
5220+
5221+
#[test]
5222+
fn resolve_granted_scopes_uses_requested_when_response_omits_scope() {
5223+
let granted = AuthorizationManager::resolve_granted_scopes(
5224+
None,
5225+
&["read".to_string(), "write".to_string()],
5226+
&["read".to_string()],
5227+
);
5228+
5229+
assert_eq!(granted, vec!["read".to_string(), "write".to_string()]);
5230+
}
5231+
5232+
#[test]
5233+
fn resolve_granted_scopes_honors_explicit_server_downgrade() {
5234+
let granted = AuthorizationManager::resolve_granted_scopes(
5235+
Some(vec!["read".to_string()]),
5236+
&["read".to_string(), "write".to_string()],
5237+
&["read".to_string()],
5238+
);
5239+
5240+
assert_eq!(granted, vec!["read".to_string()]);
5241+
}
5242+
5243+
#[test]
5244+
fn resolve_granted_scopes_falls_back_to_current_when_nothing_requested() {
5245+
let granted =
5246+
AuthorizationManager::resolve_granted_scopes(None, &[], &["read".to_string()]);
5247+
5248+
assert_eq!(granted, vec!["read".to_string()]);
5249+
}
5250+
50945251
#[tokio::test]
50955252
async fn add_offline_access_if_supported_works_with_explicit_scopes() {
50965253
let mgr = manager_with_metadata(Some(AuthorizationMetadata {

0 commit comments

Comments
 (0)