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