@@ -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
298302impl 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