Skip to content

Commit b05ebdf

Browse files
feat(auth): accumulate client-side scopes during step-up authorization
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 8f5310b commit b05ebdf

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
@@ -164,6 +164,10 @@ pub struct StoredAuthorizationState {
164164
#[serde(default)]
165165
pub require_issuer: bool,
166166
pub created_at: u64,
167+
/// scopes requested in this round, used to resolve the grant when the token response omits
168+
/// `scope` (RFC 6749 §5.1)
169+
#[serde(default)]
170+
pub requested_scopes: Vec<String>,
167171
}
168172

169173
impl std::fmt::Debug for StoredAuthorizationState {
@@ -174,6 +178,7 @@ impl std::fmt::Debug for StoredAuthorizationState {
174178
.field("expected_issuer", &self.expected_issuer)
175179
.field("require_issuer", &self.require_issuer)
176180
.field("created_at", &self.created_at)
181+
.field("requested_scopes", &self.requested_scopes)
177182
.finish()
178183
}
179184
}
@@ -225,9 +230,16 @@ impl StoredAuthorizationState {
225230
.duration_since(std::time::UNIX_EPOCH)
226231
.map(|d| d.as_secs())
227232
.unwrap_or(0),
233+
requested_scopes: Vec::new(),
228234
}
229235
}
230236

237+
/// record the scopes requested in this authorization round (SEP-2350)
238+
pub fn with_requested_scopes(mut self, scopes: Vec<String>) -> Self {
239+
self.requested_scopes = scopes;
240+
self
241+
}
242+
231243
pub fn into_pkce_verifier(self) -> PkceCodeVerifier {
232244
PkceCodeVerifier::new(self.pkce_verifier)
233245
}
@@ -1054,7 +1066,7 @@ impl AuthorizationManager {
10541066

10551067
let (auth_url, csrf_token) = auth_request.url();
10561068

1057-
// store pkce verifier and expected issuer for later use via state store
1069+
// store pkce verifier, expected issuer, and the requested scopes for later use via state store
10581070
let expected_issuer = self
10591071
.metadata
10601072
.as_ref()
@@ -1074,7 +1086,8 @@ impl AuthorizationManager {
10741086
&csrf_token,
10751087
expected_issuer,
10761088
require_issuer,
1077-
);
1089+
)
1090+
.with_requested_scopes(scopes.iter().map(|s| s.to_string()).collect());
10781091
self.state_store
10791092
.save(csrf_token.secret(), stored_state)
10801093
.await?;
@@ -1089,11 +1102,33 @@ impl AuthorizationManager {
10891102

10901103
/// compute the union of current scopes and required scopes
10911104
fn compute_scope_union(current: &[String], required: &str) -> Vec<String> {
1092-
let mut scope_set: std::collections::HashSet<String> = current.iter().cloned().collect();
1093-
for scope in required.split_whitespace() {
1094-
scope_set.insert(scope.to_string());
1105+
let mut scopes = current.to_vec();
1106+
scopes.extend(required.split_whitespace().map(|s| s.to_string()));
1107+
Self::dedup_scopes(scopes)
1108+
}
1109+
1110+
/// deduplicate scopes preserving first-seen order (SEP-2350: stable for testability)
1111+
fn dedup_scopes(scopes: Vec<String>) -> Vec<String> {
1112+
let mut seen = std::collections::HashSet::new();
1113+
scopes
1114+
.into_iter()
1115+
.filter(|s| seen.insert(s.clone()))
1116+
.collect()
1117+
}
1118+
1119+
/// resolve the granted scope set from a token response (SEP-2350, RFC 6749 §5.1): an explicit
1120+
/// `scope` may narrow the grant; an omitted one means the request was granted in full, so fall
1121+
/// back to the requested scopes (or the previously granted set when none were recorded).
1122+
fn resolve_granted_scopes(
1123+
response_scopes: Option<Vec<String>>,
1124+
requested_scopes: &[String],
1125+
current_scopes: &[String],
1126+
) -> Vec<String> {
1127+
match response_scopes {
1128+
Some(scopes) => scopes,
1129+
None if !requested_scopes.is_empty() => requested_scopes.to_vec(),
1130+
None => current_scopes.to_vec(),
10951131
}
1096-
scope_set.into_iter().collect()
10971132
}
10981133

10991134
/// check if a scope upgrade is possible and allowed
@@ -1116,35 +1151,42 @@ impl AuthorizationManager {
11161151
scopes
11171152
}
11181153

1119-
/// select scopes based on SEP-835 priority:
1120-
/// 1. scope from WWW-Authenticate header (argument or stored from initial 401 probe)
1121-
/// 2. scopes_supported from protected resource metadata (RFC 9728)
1122-
/// 3. scopes_supported from authorization server metadata
1123-
/// 4. provided default scopes
1154+
/// select scopes following SEP-2350: re-authorization requests the union of the
1155+
/// previously requested scopes and the newly challenged scopes. Server-reported
1156+
/// scopes (WWW-Authenticate challenge, protected resource metadata) are operational
1157+
/// requirements for the current operation, never an exclusive directive, so they
1158+
/// accumulate rather than replace. The AS metadata and caller defaults only seed the
1159+
/// request when nothing has been requested or challenged yet.
11241160
fn select_base_scopes(
11251161
&self,
11261162
www_authenticate_scope: Option<&str>,
11271163
default_scopes: &[&str],
11281164
) -> Vec<String> {
1129-
if let Some(scope) = www_authenticate_scope {
1130-
return scope.split_whitespace().map(|s| s.to_string()).collect();
1165+
let mut accumulated: Vec<String> = Vec::new();
1166+
1167+
// previously requested scopes
1168+
if let Ok(guard) = self.current_scopes.try_read() {
1169+
accumulated.extend(guard.iter().cloned());
11311170
}
11321171

1133-
// use scopes from initial 401 WWW-Authenticate header
1172+
// newly challenged scopes for the current operation (RFC 6750 §3.1)
1173+
if let Some(scope) = www_authenticate_scope {
1174+
accumulated.extend(scope.split_whitespace().map(|s| s.to_string()));
1175+
}
11341176
if let Ok(guard) = self.www_auth_scopes.try_read() {
1135-
if !guard.is_empty() {
1136-
return guard.clone();
1137-
}
1177+
accumulated.extend(guard.iter().cloned());
11381178
}
11391179

1140-
// use scopes_supported from protected resource metadata (RFC 9728)
1180+
// scopes required for the current operation per protected resource metadata (RFC 9728)
11411181
if let Ok(guard) = self.resource_scopes.try_read() {
1142-
if !guard.is_empty() {
1143-
return guard.clone();
1144-
}
1182+
accumulated.extend(guard.iter().cloned());
11451183
}
11461184

1147-
// use scopes_supported from authorization server metadata
1185+
if !accumulated.is_empty() {
1186+
return Self::dedup_scopes(accumulated);
1187+
}
1188+
1189+
// nothing requested or challenged yet: seed from AS metadata, then caller defaults
11481190
if let Some(metadata) = &self.metadata {
11491191
if let Some(scopes_supported) = &metadata.scopes_supported {
11501192
if !scopes_supported.is_empty() {
@@ -1284,6 +1326,9 @@ impl AuthorizationManager {
12841326

12851327
Self::validate_authorization_response_issuer(&stored_state, received_issuer)?;
12861328

1329+
// capture requested scopes before the state is consumed
1330+
let requested_scopes = stored_state.requested_scopes.clone();
1331+
12871332
// Reconstruct the PKCE verifier
12881333
let pkce_verifier = stored_state.into_pkce_verifier();
12891334

@@ -1322,10 +1367,14 @@ impl AuthorizationManager {
13221367

13231368
debug!("exchange token result: {:?}", token_result);
13241369

1325-
let granted_scopes: Vec<String> = token_result
1370+
// SEP-2350: an omitted `scope` means the grant equals the request (RFC 6749 §5.1).
1371+
let response_scopes = token_result
13261372
.scopes()
1327-
.map(|scopes| scopes.iter().map(|s| s.to_string()).collect())
1328-
.unwrap_or_default();
1373+
.map(|scopes| scopes.iter().map(|s| s.to_string()).collect());
1374+
let granted_scopes = {
1375+
let current = self.current_scopes.read().await;
1376+
Self::resolve_granted_scopes(response_scopes, &requested_scopes, &current)
1377+
};
13291378

13301379
*self.current_scopes.write().await = granted_scopes.clone();
13311380
*self.scope_upgrade_attempts.write().await = 0;
@@ -3107,17 +3156,27 @@ mod tests {
31073156
fn test_stored_authorization_state_serialization() {
31083157
let pkce = PkceCodeVerifier::new("my-verifier".to_string());
31093158
let csrf = CsrfToken::new("my-csrf".to_string());
3110-
let state = StoredAuthorizationState::new(&pkce, &csrf);
3159+
let state = StoredAuthorizationState::new(&pkce, &csrf)
3160+
.with_requested_scopes(vec!["read".to_string(), "write".to_string()]);
31113161

31123162
let json = serde_json::to_string(&state).unwrap();
31133163
let deserialized: StoredAuthorizationState = serde_json::from_str(&json).unwrap();
31143164

31153165
assert_eq!(deserialized.pkce_verifier, "my-verifier");
31163166
assert_eq!(deserialized.csrf_token, "my-csrf");
3167+
assert_eq!(deserialized.requested_scopes, vec!["read", "write"]);
31173168
assert_eq!(deserialized.expected_issuer, None);
31183169
assert!(!deserialized.require_issuer);
31193170
}
31203171

3172+
#[test]
3173+
fn stored_authorization_state_defaults_requested_scopes_when_absent() {
3174+
let json = r#"{"pkce_verifier":"v","csrf_token":"c","created_at":1}"#;
3175+
let state: StoredAuthorizationState = serde_json::from_str(json).unwrap();
3176+
3177+
assert!(state.requested_scopes.is_empty());
3178+
}
3179+
31213180
#[test]
31223181
fn test_stored_authorization_state_records_expected_issuer() {
31233182
let pkce = PkceCodeVerifier::new("my-verifier".to_string());
@@ -3882,6 +3941,104 @@ mod tests {
38823941
assert!(scopes.contains(&"email".to_string()));
38833942
}
38843943

3944+
// -- SEP-2350: client-side scope accumulation in step-up authorization --
3945+
3946+
#[tokio::test]
3947+
async fn select_scopes_unions_challenge_with_previously_requested() {
3948+
let mgr = manager_with_metadata(None).await;
3949+
*mgr.current_scopes.write().await = vec!["read".to_string()];
3950+
3951+
let scopes = mgr.select_scopes(Some("write"), &[]);
3952+
3953+
assert_eq!(scopes, vec!["read".to_string(), "write".to_string()]);
3954+
}
3955+
3956+
#[tokio::test]
3957+
async fn select_scopes_does_not_replace_previously_requested_with_challenge() {
3958+
let mgr = manager_with_metadata(None).await;
3959+
*mgr.current_scopes.write().await = vec!["read".to_string(), "profile".to_string()];
3960+
3961+
let scopes = mgr.select_scopes(Some("write"), &[]);
3962+
3963+
assert!(scopes.contains(&"read".to_string()));
3964+
assert!(scopes.contains(&"profile".to_string()));
3965+
assert!(scopes.contains(&"write".to_string()));
3966+
}
3967+
3968+
#[tokio::test]
3969+
async fn select_scopes_accumulates_across_multiple_step_up_rounds() {
3970+
let mgr = manager_with_metadata(None).await;
3971+
*mgr.current_scopes.write().await = vec!["read".to_string()];
3972+
3973+
// round one: server challenges for "write"
3974+
let round_one = mgr.select_scopes(Some("write"), &[]);
3975+
assert_eq!(round_one, vec!["read".to_string(), "write".to_string()]);
3976+
*mgr.current_scopes.write().await = round_one;
3977+
3978+
// round two: server challenges for "admin", earlier scopes are retained
3979+
let round_two = mgr.select_scopes(Some("admin"), &[]);
3980+
assert_eq!(
3981+
round_two,
3982+
vec!["read".to_string(), "write".to_string(), "admin".to_string()]
3983+
);
3984+
}
3985+
3986+
#[tokio::test]
3987+
async fn select_scopes_deduplicates_challenge_already_requested() {
3988+
let mgr = manager_with_metadata(None).await;
3989+
*mgr.current_scopes.write().await = vec!["read".to_string(), "write".to_string()];
3990+
3991+
let scopes = mgr.select_scopes(Some("write admin"), &[]);
3992+
3993+
assert_eq!(
3994+
scopes,
3995+
vec!["read".to_string(), "write".to_string(), "admin".to_string()]
3996+
);
3997+
}
3998+
3999+
#[tokio::test]
4000+
async fn select_scopes_unions_resource_metadata_as_operational_requirement() {
4001+
let mgr = manager_with_metadata(None).await;
4002+
*mgr.current_scopes.write().await = vec!["read".to_string()];
4003+
*mgr.resource_scopes.write().await = vec!["profile".to_string()];
4004+
4005+
let scopes = mgr.select_scopes(Some("write"), &[]);
4006+
4007+
assert!(scopes.contains(&"read".to_string()));
4008+
assert!(scopes.contains(&"write".to_string()));
4009+
assert!(scopes.contains(&"profile".to_string()));
4010+
}
4011+
4012+
#[test]
4013+
fn resolve_granted_scopes_uses_requested_when_response_omits_scope() {
4014+
let granted = AuthorizationManager::resolve_granted_scopes(
4015+
None,
4016+
&["read".to_string(), "write".to_string()],
4017+
&["read".to_string()],
4018+
);
4019+
4020+
assert_eq!(granted, vec!["read".to_string(), "write".to_string()]);
4021+
}
4022+
4023+
#[test]
4024+
fn resolve_granted_scopes_honors_explicit_server_downgrade() {
4025+
let granted = AuthorizationManager::resolve_granted_scopes(
4026+
Some(vec!["read".to_string()]),
4027+
&["read".to_string(), "write".to_string()],
4028+
&["read".to_string()],
4029+
);
4030+
4031+
assert_eq!(granted, vec!["read".to_string()]);
4032+
}
4033+
4034+
#[test]
4035+
fn resolve_granted_scopes_falls_back_to_current_when_nothing_requested() {
4036+
let granted =
4037+
AuthorizationManager::resolve_granted_scopes(None, &[], &["read".to_string()]);
4038+
4039+
assert_eq!(granted, vec!["read".to_string()]);
4040+
}
4041+
38854042
#[tokio::test]
38864043
async fn add_offline_access_if_supported_works_with_explicit_scopes() {
38874044
let mgr = manager_with_metadata(Some(AuthorizationMetadata {

0 commit comments

Comments
 (0)