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