@@ -345,6 +345,147 @@ fn token_cache_path() -> PathBuf {
345345 config_dir ( ) . join ( "token_cache.json" )
346346}
347347
348+ fn scopes_path ( ) -> PathBuf {
349+ config_dir ( ) . join ( "scopes.json" )
350+ }
351+
352+ /// Save the configured scope set so scope changes can be detected across sessions.
353+ async fn save_scopes ( scopes : & [ String ] ) -> Result < ( ) , GwsError > {
354+ let json = serde_json:: to_string_pretty ( scopes)
355+ . map_err ( |e| GwsError :: Validation ( format ! ( "Failed to serialize scopes: {e}" ) ) ) ?;
356+ crate :: fs_util:: atomic_write_async ( & scopes_path ( ) , json. as_bytes ( ) )
357+ . await
358+ . map_err ( |e| GwsError :: Validation ( format ! ( "Failed to save scopes file: {e}" ) ) ) ?;
359+ Ok ( ( ) )
360+ }
361+
362+ /// Load the previously saved scope set, if any.
363+ ///
364+ /// Returns `Ok(None)` if `scopes.json` does not exist, `Ok(Some(...))` on
365+ /// success, or `Err` if the file exists but is unreadable or contains invalid
366+ /// JSON.
367+ pub async fn load_saved_scopes ( ) -> Result < Option < Vec < String > > , GwsError > {
368+ let path = scopes_path ( ) ;
369+ match tokio:: fs:: read_to_string ( & path) . await {
370+ Ok ( data) => {
371+ let scopes: Vec < String > = serde_json:: from_str ( & data) . map_err ( |e| {
372+ GwsError :: Validation ( format ! ( "Failed to parse {}: {e}" , path. display( ) ) )
373+ } ) ?;
374+ Ok ( Some ( scopes) )
375+ }
376+ Err ( e) if e. kind ( ) == std:: io:: ErrorKind :: NotFound => Ok ( None ) ,
377+ Err ( e) => Err ( GwsError :: Validation ( format ! (
378+ "Failed to read {}: {e}" ,
379+ path. display( )
380+ ) ) ) ,
381+ }
382+ }
383+
384+ /// Returns true if a scope does not grant write access (identity or .readonly scopes).
385+ fn is_non_write_scope ( scope : & str ) -> bool {
386+ scope. ends_with ( ".readonly" )
387+ || scope == "openid"
388+ || scope. starts_with ( "https://www.googleapis.com/auth/userinfo." )
389+ || scope == "email"
390+ || scope == "profile"
391+ }
392+
393+ /// Returns true if the saved scopes are all read-only.
394+ ///
395+ /// The result is cached for the lifetime of the process to avoid reading
396+ /// `scopes.json` on every API call.
397+ pub async fn is_readonly_session ( ) -> Result < bool , GwsError > {
398+ static CACHE : std:: sync:: OnceLock < bool > = std:: sync:: OnceLock :: new ( ) ;
399+ if let Some ( & val) = CACHE . get ( ) {
400+ return Ok ( val) ;
401+ }
402+ let res = load_saved_scopes ( )
403+ . await ?
404+ . map ( |scopes| scopes. iter ( ) . all ( |s| is_non_write_scope ( s) ) )
405+ . unwrap_or ( false ) ;
406+ let _ = CACHE . set ( res) ;
407+ Ok ( res)
408+ }
409+
410+ /// Check if the requested scopes are compatible with the current session.
411+ ///
412+ /// In a readonly session, write-scope requests are rejected with a clear error.
413+ pub async fn check_scopes_allowed ( scopes : & [ & str ] ) -> Result < ( ) , GwsError > {
414+ if is_readonly_session ( ) . await ? {
415+ if let Some ( scope) = scopes. iter ( ) . find ( |s| !is_non_write_scope ( s) ) {
416+ return Err ( GwsError :: Auth ( format ! (
417+ "This operation requires scope '{}' (write access), but the current session \
418+ uses read-only scopes. Run `gws auth login` (without --readonly) to upgrade.",
419+ scope
420+ ) ) ) ;
421+ }
422+ }
423+ Ok ( ( ) )
424+ }
425+
426+ /// Attempt to revoke the old refresh token via Google's revocation endpoint.
427+ ///
428+ /// Best-effort: warns on failure but does not return an error, since the
429+ /// subsequent credential cleanup and fresh login will proceed regardless.
430+ async fn attempt_token_revocation ( ) {
431+ let creds_str = match credential_store:: load_encrypted ( ) {
432+ Ok ( s) => s,
433+ Err ( e) => {
434+ eprintln ! (
435+ "Warning: could not load credentials ({}). Old token was not revoked." ,
436+ crate :: output:: sanitize_for_terminal( & e. to_string( ) )
437+ ) ;
438+ return ;
439+ }
440+ } ;
441+
442+ let creds: serde_json:: Value = match serde_json:: from_str ( & creds_str) {
443+ Ok ( j) => j,
444+ Err ( e) => {
445+ eprintln ! (
446+ "Warning: could not parse credentials ({}). Old token was not revoked." ,
447+ crate :: output:: sanitize_for_terminal( & e. to_string( ) )
448+ ) ;
449+ return ;
450+ }
451+ } ;
452+
453+ if let Some ( rt) = creds. get ( "refresh_token" ) . and_then ( |v| v. as_str ( ) ) {
454+ let client = match crate :: client:: shared_client ( ) {
455+ Ok ( c) => c,
456+ Err ( e) => {
457+ eprintln ! (
458+ "Warning: could not build HTTP client ({}). Old token was not revoked." ,
459+ crate :: output:: sanitize_for_terminal( & e. to_string( ) )
460+ ) ;
461+ return ;
462+ }
463+ } ;
464+ match client
465+ . post ( "https://oauth2.googleapis.com/revoke" )
466+ . form ( & [ ( "token" , rt) ] )
467+ . send ( )
468+ . await
469+ {
470+ Ok ( resp) if resp. status ( ) . is_success ( ) => { }
471+ Ok ( resp) => {
472+ eprintln ! (
473+ "Warning: token revocation returned HTTP {}. \
474+ The old token may still be valid on Google's side.",
475+ resp. status( )
476+ ) ;
477+ }
478+ Err ( e) => {
479+ eprintln ! (
480+ "Warning: could not revoke old token ({}). \
481+ The old token may still be valid on Google's side.",
482+ crate :: output:: sanitize_for_terminal( & e. to_string( ) )
483+ ) ;
484+ }
485+ }
486+ }
487+ }
488+
348489/// Which scope set to use for login.
349490enum ScopeMode {
350491 /// Use the default scopes (MINIMAL_SCOPES).
@@ -598,6 +739,42 @@ async fn handle_login_inner(
598739 // Remove restrictive scopes when broader alternatives are present.
599740 let mut scopes = filter_redundant_restrictive_scopes ( scopes) ;
600741
742+ // If scopes changed from the previous login, revoke the old refresh token
743+ // so Google removes the prior consent grant. Without revocation, Google's
744+ // consent screen shows previously-granted scopes pre-checked and the user
745+ // may unknowingly re-grant broad access.
746+ if let Some ( prev_scopes) = load_saved_scopes ( ) . await ? {
747+ // Filter out identity scopes from both sets — they are auto-added after
748+ // this comparison, so including them would cause a false mismatch on
749+ // every login (prev_scopes has them, current scopes don't yet).
750+ let prev_set: HashSet < & str > = prev_scopes
751+ . iter ( )
752+ . map ( |s| s. as_str ( ) )
753+ . filter ( |s| !is_non_write_scope ( s) || s. ends_with ( ".readonly" ) )
754+ . collect ( ) ;
755+ let new_set: HashSet < & str > = scopes. iter ( ) . map ( |s| s. as_str ( ) ) . collect ( ) ;
756+ if prev_set != new_set {
757+ attempt_token_revocation ( ) . await ;
758+ // Clear local credential and cache files to force a fresh login.
759+ let enc_path = credential_store:: encrypted_credentials_path ( ) ;
760+ if let Err ( e) = tokio:: fs:: remove_file ( & enc_path) . await {
761+ if e. kind ( ) != std:: io:: ErrorKind :: NotFound {
762+ return Err ( GwsError :: Auth ( format ! (
763+ "Failed to remove old credentials file: {e}. Please remove it manually."
764+ ) ) ) ;
765+ }
766+ }
767+ if let Err ( e) = tokio:: fs:: remove_file ( token_cache_path ( ) ) . await {
768+ if e. kind ( ) != std:: io:: ErrorKind :: NotFound {
769+ return Err ( GwsError :: Auth ( format ! (
770+ "Failed to remove old token cache: {e}. Please remove it manually."
771+ ) ) ) ;
772+ }
773+ }
774+ eprintln ! ( "Scopes changed — revoked previous credentials." ) ;
775+ }
776+ }
777+
601778 // Ensure openid + email + profile scopes are always present so we can
602779 // identify the user via the userinfo endpoint after login, and so the
603780 // Gmail helpers can fall back to the People API to populate the From
@@ -644,6 +821,10 @@ async fn handle_login_inner(
644821 let enc_path = credential_store:: save_encrypted ( & creds_str)
645822 . map_err ( |e| GwsError :: Auth ( format ! ( "Failed to encrypt credentials: {e}" ) ) ) ?;
646823
824+ // Persist the configured scope set for scope-change detection and
825+ // client-side guard enforcement.
826+ save_scopes ( & scopes) . await ?;
827+
647828 let output = json ! ( {
648829 "status" : "success" ,
649830 "message" : "Authentication successful. Encrypted credentials saved." ,
@@ -1446,6 +1627,17 @@ async fn handle_status() -> Result<(), GwsError> {
14461627 }
14471628 } // end !cfg!(test)
14481629
1630+ // Show configured scope mode from scopes.json (independent of network)
1631+ if let Some ( saved_scopes) = load_saved_scopes ( ) . await ? {
1632+ let is_readonly = saved_scopes. iter ( ) . all ( |s| is_non_write_scope ( s) ) ;
1633+ output[ "configured_scopes" ] = json ! ( saved_scopes) ;
1634+ output[ "scope_mode" ] = json ! ( if is_readonly {
1635+ "readonly"
1636+ } else {
1637+ "default"
1638+ } ) ;
1639+ }
1640+
14491641 println ! (
14501642 "{}" ,
14511643 serde_json:: to_string_pretty( & output) . unwrap_or_default( )
@@ -1458,10 +1650,11 @@ fn handle_logout() -> Result<(), GwsError> {
14581650 let enc_path = credential_store:: encrypted_credentials_path ( ) ;
14591651 let token_cache = token_cache_path ( ) ;
14601652 let sa_token_cache = config_dir ( ) . join ( "sa_token_cache.json" ) ;
1653+ let scopes_file = scopes_path ( ) ;
14611654
14621655 let mut removed = Vec :: new ( ) ;
14631656
1464- for path in [ & enc_path, & plain_path, & token_cache, & sa_token_cache] {
1657+ for path in [ & enc_path, & plain_path, & token_cache, & sa_token_cache, & scopes_file ] {
14651658 if path. exists ( ) {
14661659 std:: fs:: remove_file ( path) . map_err ( |e| {
14671660 GwsError :: Validation ( format ! ( "Failed to remove {}: {e}" , path. display( ) ) )
@@ -2532,4 +2725,38 @@ mod tests {
25322725 let err = read_refresh_token_from_cache ( file. path ( ) ) . unwrap_err ( ) ;
25332726 assert ! ( err. to_string( ) . contains( "no refresh token was returned" ) ) ;
25342727 }
2728+
2729+ // --- Scope persistence and guard tests ---
2730+
2731+ #[ test]
2732+ fn test_save_and_load_scopes_roundtrip ( ) {
2733+ let dir = tempfile:: tempdir ( ) . unwrap ( ) ;
2734+ let path = dir. path ( ) . join ( "scopes.json" ) ;
2735+ let scopes = vec ! [
2736+ "https://www.googleapis.com/auth/gmail.readonly" . to_string( ) ,
2737+ "openid" . to_string( ) ,
2738+ ] ;
2739+ let json = serde_json:: to_string_pretty ( & scopes) . unwrap ( ) ;
2740+ crate :: fs_util:: atomic_write ( & path, json. as_bytes ( ) ) . unwrap ( ) ;
2741+ let loaded: Vec < String > =
2742+ serde_json:: from_str ( & std:: fs:: read_to_string ( & path) . unwrap ( ) ) . unwrap ( ) ;
2743+ assert_eq ! ( loaded, scopes) ;
2744+ }
2745+
2746+ #[ test]
2747+ fn test_is_non_write_scope ( ) {
2748+ // Readonly and identity scopes are non-write
2749+ assert ! ( is_non_write_scope( "https://www.googleapis.com/auth/drive.readonly" ) ) ;
2750+ assert ! ( is_non_write_scope( "https://www.googleapis.com/auth/gmail.readonly" ) ) ;
2751+ assert ! ( is_non_write_scope( "openid" ) ) ;
2752+ assert ! ( is_non_write_scope( "email" ) ) ;
2753+ assert ! ( is_non_write_scope( "profile" ) ) ;
2754+ assert ! ( is_non_write_scope( "https://www.googleapis.com/auth/userinfo.email" ) ) ;
2755+
2756+ // Write scopes are not non-write
2757+ assert ! ( !is_non_write_scope( "https://www.googleapis.com/auth/drive" ) ) ;
2758+ assert ! ( !is_non_write_scope( "https://www.googleapis.com/auth/gmail.modify" ) ) ;
2759+ assert ! ( !is_non_write_scope( "https://www.googleapis.com/auth/calendar" ) ) ;
2760+ assert ! ( !is_non_write_scope( "https://www.googleapis.com/auth/pubsub" ) ) ;
2761+ }
25352762}
0 commit comments