From 14d3fd87be1dce2c97f1f365d303b91d8f2cfbf7 Mon Sep 17 00:00:00 2001 From: Csongor Vogel <15221068+gerfalcon@users.noreply.github.com> Date: Tue, 17 Mar 2026 20:41:44 +0400 Subject: [PATCH 01/13] fix(auth): enforce readonly scopes by revoking stale tokens and adding client-side guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a user previously authenticated with full scopes, `gws auth login --readonly` did not actually enforce read-only access. The underlying refresh token retained the original consent grant, and Google's token endpoint ignores the scope parameter on refresh requests — so the cached token silently carried write access. This commit fixes the issue with a layered approach: - Persist the configured scope set to scopes.json on login so scope changes can be detected across sessions. - When scopes change, revoke the old refresh token via Google's revocation endpoint and clear local credential/cache files before starting the new OAuth flow. - Add a client-side scope guard that rejects write-scope API requests when the session is readonly. - Show scope_mode and configured_scopes in `gws auth status`. - Clean up scopes.json on logout. Fixes #168 --- .changeset/fix-readonly-scope-enforcement.md | 7 + src/auth_commands.rs | 197 ++++++++++++++++++- src/main.rs | 5 + 3 files changed, 208 insertions(+), 1 deletion(-) create mode 100644 .changeset/fix-readonly-scope-enforcement.md diff --git a/.changeset/fix-readonly-scope-enforcement.md b/.changeset/fix-readonly-scope-enforcement.md new file mode 100644 index 00000000..e35b967a --- /dev/null +++ b/.changeset/fix-readonly-scope-enforcement.md @@ -0,0 +1,7 @@ +--- +"@googleworkspace/cli": patch +--- + +fix(auth): enforce readonly scopes by revoking stale tokens on scope change and adding client-side guard + +Fixes #168 diff --git a/src/auth_commands.rs b/src/auth_commands.rs index 47d2d4e2..eb76e6b3 100644 --- a/src/auth_commands.rs +++ b/src/auth_commands.rs @@ -128,6 +128,56 @@ fn token_cache_path() -> PathBuf { config_dir().join("token_cache.json") } +fn scopes_path() -> PathBuf { + config_dir().join("scopes.json") +} + +/// Save the configured scope set so scope changes can be detected across sessions. +fn save_scopes(scopes: &[String]) { + if let Ok(json) = serde_json::to_string_pretty(scopes) { + let _ = crate::fs_util::atomic_write(&scopes_path(), json.as_bytes()); + } +} + +/// Load the previously saved scope set, if any. +pub fn load_saved_scopes() -> Option> { + let data = std::fs::read_to_string(scopes_path()).ok()?; + serde_json::from_str(&data).ok() +} + +/// Returns true if the saved scopes are all read-only. +pub fn is_readonly_session() -> bool { + load_saved_scopes() + .map(|scopes| { + scopes.iter().all(|s| { + s.ends_with(".readonly") + || s == "openid" + || s.starts_with("https://www.googleapis.com/auth/userinfo.") + || s == "email" + }) + }) + .unwrap_or(false) +} + +/// Check if a requested scope is compatible with the current session. +/// +/// In a readonly session, write-scope requests are rejected with a clear error. +pub fn check_scope_allowed(scope: &str) -> Result<(), GwsError> { + if is_readonly_session() + && !scope.ends_with(".readonly") + && scope != "openid" + && !scope.starts_with("https://www.googleapis.com/auth/userinfo.") + && scope != "email" + { + return Err(GwsError::Auth(format!( + "This operation requires scope '{}' (write access), but the current session \ + uses read-only scopes. Run `gws auth login` (without --readonly) to upgrade.", + scope + ))); + } + Ok(()) +} + /// Handle `gws auth `. pub async fn handle_auth_command(args: &[String]) -> Result<(), GwsError> { const USAGE: &str = concat!( @@ -267,6 +317,34 @@ async fn handle_login(args: &[String]) -> Result<(), GwsError> { ..Default::default() }; + // If scopes changed from the previous login, revoke the old refresh token + // so Google removes the prior consent grant. Without revocation, Google's + // consent screen shows previously-granted scopes pre-checked and the user + // may unknowingly re-grant broad access. + if let Some(prev_scopes) = load_saved_scopes() { + let prev_set: HashSet<&str> = prev_scopes.iter().map(|s| s.as_str()).collect(); + let new_set: HashSet<&str> = scopes.iter().map(|s| s.as_str()).collect(); + if prev_set != new_set { + // Best-effort revocation: load the old refresh token and revoke it. + if let Ok(creds_str) = credential_store::load_encrypted() { + if let Ok(creds) = serde_json::from_str::(&creds_str) { + if let Some(rt) = creds.get("refresh_token").and_then(|v| v.as_str()) { + let client = reqwest::Client::new(); + let _ = client + .post("https://oauth2.googleapis.com/revoke") + .form(&[("token", rt)]) + .send() + .await; + } + } + } + // Clear local credential and cache files regardless of revocation result. + let _ = std::fs::remove_file(credential_store::encrypted_credentials_path()); + let _ = std::fs::remove_file(token_cache_path()); + eprintln!("Scopes changed — revoked previous credentials."); + } + } + // Ensure openid + email scopes are always present so we can identify the user // via the userinfo endpoint after login. let identity_scopes = ["openid", "https://www.googleapis.com/auth/userinfo.email"]; @@ -343,6 +421,10 @@ async fn handle_login(args: &[String]) -> Result<(), GwsError> { let enc_path = credential_store::save_encrypted(&creds_str) .map_err(|e| GwsError::Auth(format!("Failed to encrypt credentials: {e}")))?; + // Persist the configured scope set for scope-change detection and + // client-side guard enforcement. + save_scopes(&scopes); + // Clean up temp file let _ = std::fs::remove_file(&temp_path); @@ -1141,6 +1223,16 @@ async fn handle_status() -> Result<(), GwsError> { } } // end !cfg!(test) + // Show configured scope mode from scopes.json (independent of network) + if let Some(saved) = load_saved_scopes() { + output["configured_scopes"] = json!(saved); + output["scope_mode"] = json!(if is_readonly_session() { + "readonly" + } else { + "default" + }); + } + println!( "{}", serde_json::to_string_pretty(&output).unwrap_or_default() @@ -1153,10 +1245,11 @@ fn handle_logout() -> Result<(), GwsError> { let enc_path = credential_store::encrypted_credentials_path(); let token_cache = token_cache_path(); let sa_token_cache = config_dir().join("sa_token_cache.json"); + let scopes_file = scopes_path(); let mut removed = Vec::new(); - for path in [&enc_path, &plain_path, &token_cache, &sa_token_cache] { + for path in [&enc_path, &plain_path, &token_cache, &sa_token_cache, &scopes_file] { if path.exists() { std::fs::remove_file(path).map_err(|e| { GwsError::Validation(format!("Failed to remove {}: {e}", path.display())) @@ -1947,4 +2040,106 @@ mod tests { // Exactly 9 chars — first 4 + last 4 with "..." in between assert_eq!(mask_secret("123456789"), "1234...6789"); } + + // --- Scope persistence and guard tests --- + + #[test] + fn test_save_and_load_scopes_roundtrip() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("scopes.json"); + let scopes = vec![ + "https://www.googleapis.com/auth/gmail.readonly".to_string(), + "openid".to_string(), + ]; + let json = serde_json::to_string_pretty(&scopes).unwrap(); + crate::fs_util::atomic_write(&path, json.as_bytes()).unwrap(); + let loaded: Vec = + serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(); + assert_eq!(loaded, scopes); + } + + #[test] + fn test_is_readonly_all_readonly_scopes() { + let scopes = vec![ + "https://www.googleapis.com/auth/drive.readonly", + "https://www.googleapis.com/auth/gmail.readonly", + "openid", + "https://www.googleapis.com/auth/userinfo.email", + "email", + ]; + assert!(scopes.iter().all(|s| { + s.ends_with(".readonly") + || *s == "openid" + || s.starts_with("https://www.googleapis.com/auth/userinfo.") + || *s == "email" + })); + } + + #[test] + fn test_is_readonly_mixed_scopes() { + let scopes = vec![ + "https://www.googleapis.com/auth/drive", + "https://www.googleapis.com/auth/gmail.readonly", + "openid", + ]; + let is_readonly = scopes.iter().all(|s| { + s.ends_with(".readonly") + || *s == "openid" + || s.starts_with("https://www.googleapis.com/auth/userinfo.") + || *s == "email" + }); + assert!(!is_readonly); + } + + #[test] + fn test_check_scope_allowed_blocks_write_in_readonly() { + // Simulate the readonly check logic directly + let saved_scopes = vec![ + "https://www.googleapis.com/auth/gmail.readonly".to_string(), + "openid".to_string(), + ]; + let is_readonly = saved_scopes.iter().all(|s| { + s.ends_with(".readonly") + || s == "openid" + || s.starts_with("https://www.googleapis.com/auth/userinfo.") + || s == "email" + }); + assert!(is_readonly); + + // A write scope should be rejected + let scope = "https://www.googleapis.com/auth/gmail.modify"; + let blocked = is_readonly + && !scope.ends_with(".readonly") + && scope != "openid" + && !scope.starts_with("https://www.googleapis.com/auth/userinfo.") + && scope != "email"; + assert!(blocked); + } + + #[test] + fn test_check_scope_allowed_permits_readonly_in_readonly() { + let scope = "https://www.googleapis.com/auth/gmail.readonly"; + let blocked = !scope.ends_with(".readonly") + && scope != "openid" + && !scope.starts_with("https://www.googleapis.com/auth/userinfo.") + && scope != "email"; + assert!(!blocked); + } + + #[test] + fn test_check_scope_allowed_permits_all_in_default() { + // When saved scopes include write scopes, is_readonly is false + let saved_scopes = vec![ + "https://www.googleapis.com/auth/drive".to_string(), + "https://www.googleapis.com/auth/gmail.modify".to_string(), + ]; + let is_readonly = saved_scopes.iter().all(|s| { + s.ends_with(".readonly") + || s == "openid" + || s.starts_with("https://www.googleapis.com/auth/userinfo.") + || s == "email" + }); + assert!(!is_readonly); + // In non-readonly mode, no scope is blocked + } } diff --git a/src/main.rs b/src/main.rs index a448fec1..2069810c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -233,6 +233,11 @@ async fn run() -> Result<(), GwsError> { // to avoid restrictive scopes like gmail.metadata that block query parameters. let scopes: Vec<&str> = select_scope(&method.scopes).into_iter().collect(); + // Enforce readonly session: reject write scopes if user logged in with --readonly + if let Some(scope) = scopes.first() { + auth_commands::check_scope_allowed(scope)?; + } + // Authenticate: try OAuth, fail with error if credentials exist but are broken let (token, auth_method) = match auth::get_token(&scopes).await { Ok(t) => (Some(t), executor::AuthMethod::OAuth), From 80b47e761893f71ba483854b3329e178983981ad Mon Sep 17 00:00:00 2001 From: Csongor Vogel <15221068+gerfalcon@users.noreply.github.com> Date: Tue, 17 Mar 2026 21:41:33 +0400 Subject: [PATCH 02/13] fix(auth): propagate save_scopes errors instead of silently ignoring Address review feedback: save_scopes now returns Result<(), GwsError> and the call site uses ? to propagate failures. This ensures scopes.json is always consistent with the actual login state. --- src/auth_commands.rs | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/auth_commands.rs b/src/auth_commands.rs index 1982ea86..92d13b1e 100644 --- a/src/auth_commands.rs +++ b/src/auth_commands.rs @@ -133,10 +133,12 @@ fn scopes_path() -> PathBuf { } /// Save the configured scope set so scope changes can be detected across sessions. -fn save_scopes(scopes: &[String]) { - if let Ok(json) = serde_json::to_string_pretty(scopes) { - let _ = crate::fs_util::atomic_write(&scopes_path(), json.as_bytes()); - } +fn save_scopes(scopes: &[String]) -> Result<(), GwsError> { + let json = serde_json::to_string_pretty(scopes) + .map_err(|e| GwsError::Validation(format!("Failed to serialize scopes: {e}")))?; + crate::fs_util::atomic_write(&scopes_path(), json.as_bytes()) + .map_err(|e| GwsError::Validation(format!("Failed to save scopes file: {e}")))?; + Ok(()) } /// Load the previously saved scope set, if any. @@ -431,7 +433,7 @@ async fn handle_login(args: &[String]) -> Result<(), GwsError> { // Persist the configured scope set for scope-change detection and // client-side guard enforcement. - save_scopes(&scopes); + save_scopes(&scopes)?; // Clean up temp file let _ = std::fs::remove_file(&temp_path); From ec96752f929e2b6beb9b2df02de47faac3236cb9 Mon Sep 17 00:00:00 2001 From: Csongor Vogel <15221068+gerfalcon@users.noreply.github.com> Date: Wed, 18 Mar 2026 00:29:31 +0400 Subject: [PATCH 03/13] refactor(auth): extract is_non_write_scope helper to eliminate duplication DRY up the scope classification logic that was duplicated between is_readonly_session and check_scope_allowed. Rewrite tests to call the extracted helper directly instead of re-implementing the logic. --- src/auth_commands.rs | 117 ++++++++----------------------------------- 1 file changed, 22 insertions(+), 95 deletions(-) diff --git a/src/auth_commands.rs b/src/auth_commands.rs index 92d13b1e..a67daa88 100644 --- a/src/auth_commands.rs +++ b/src/auth_commands.rs @@ -147,17 +147,18 @@ pub fn load_saved_scopes() -> Option> { serde_json::from_str(&data).ok() } +/// Returns true if a scope does not grant write access (identity or .readonly scopes). +fn is_non_write_scope(scope: &str) -> bool { + scope.ends_with(".readonly") + || scope == "openid" + || scope.starts_with("https://www.googleapis.com/auth/userinfo.") + || scope == "email" +} + /// Returns true if the saved scopes are all read-only. pub fn is_readonly_session() -> bool { load_saved_scopes() - .map(|scopes| { - scopes.iter().all(|s| { - s.ends_with(".readonly") - || s == "openid" - || s.starts_with("https://www.googleapis.com/auth/userinfo.") - || s == "email" - }) - }) + .map(|scopes| scopes.iter().all(|s| is_non_write_scope(s))) .unwrap_or(false) } @@ -165,12 +166,7 @@ pub fn is_readonly_session() -> bool { /// /// In a readonly session, write-scope requests are rejected with a clear error. pub fn check_scope_allowed(scope: &str) -> Result<(), GwsError> { - if is_readonly_session() - && !scope.ends_with(".readonly") - && scope != "openid" - && !scope.starts_with("https://www.googleapis.com/auth/userinfo.") - && scope != "email" - { + if is_readonly_session() && !is_non_write_scope(scope) { return Err(GwsError::Auth(format!( "This operation requires scope '{}' (write access), but the current session \ uses read-only scopes. Run `gws auth login` (without --readonly) to upgrade.", @@ -2322,87 +2318,18 @@ mod tests { } #[test] - fn test_is_readonly_all_readonly_scopes() { - let scopes = vec![ - "https://www.googleapis.com/auth/drive.readonly", - "https://www.googleapis.com/auth/gmail.readonly", - "openid", - "https://www.googleapis.com/auth/userinfo.email", - "email", - ]; - assert!(scopes.iter().all(|s| { - s.ends_with(".readonly") - || *s == "openid" - || s.starts_with("https://www.googleapis.com/auth/userinfo.") - || *s == "email" - })); - } - - #[test] - fn test_is_readonly_mixed_scopes() { - let scopes = vec![ - "https://www.googleapis.com/auth/drive", - "https://www.googleapis.com/auth/gmail.readonly", - "openid", - ]; - let is_readonly = scopes.iter().all(|s| { - s.ends_with(".readonly") - || *s == "openid" - || s.starts_with("https://www.googleapis.com/auth/userinfo.") - || *s == "email" - }); - assert!(!is_readonly); - } - - #[test] - fn test_check_scope_allowed_blocks_write_in_readonly() { - // Simulate the readonly check logic directly - let saved_scopes = vec![ - "https://www.googleapis.com/auth/gmail.readonly".to_string(), - "openid".to_string(), - ]; - let is_readonly = saved_scopes.iter().all(|s| { - s.ends_with(".readonly") - || s == "openid" - || s.starts_with("https://www.googleapis.com/auth/userinfo.") - || s == "email" - }); - assert!(is_readonly); - - // A write scope should be rejected - let scope = "https://www.googleapis.com/auth/gmail.modify"; - let blocked = is_readonly - && !scope.ends_with(".readonly") - && scope != "openid" - && !scope.starts_with("https://www.googleapis.com/auth/userinfo.") - && scope != "email"; - assert!(blocked); - } + fn test_is_non_write_scope() { + // Readonly and identity scopes are non-write + assert!(is_non_write_scope("https://www.googleapis.com/auth/drive.readonly")); + assert!(is_non_write_scope("https://www.googleapis.com/auth/gmail.readonly")); + assert!(is_non_write_scope("openid")); + assert!(is_non_write_scope("email")); + assert!(is_non_write_scope("https://www.googleapis.com/auth/userinfo.email")); - #[test] - fn test_check_scope_allowed_permits_readonly_in_readonly() { - let scope = "https://www.googleapis.com/auth/gmail.readonly"; - let blocked = !scope.ends_with(".readonly") - && scope != "openid" - && !scope.starts_with("https://www.googleapis.com/auth/userinfo.") - && scope != "email"; - assert!(!blocked); - } - - #[test] - fn test_check_scope_allowed_permits_all_in_default() { - // When saved scopes include write scopes, is_readonly is false - let saved_scopes = vec![ - "https://www.googleapis.com/auth/drive".to_string(), - "https://www.googleapis.com/auth/gmail.modify".to_string(), - ]; - let is_readonly = saved_scopes.iter().all(|s| { - s.ends_with(".readonly") - || s == "openid" - || s.starts_with("https://www.googleapis.com/auth/userinfo.") - || s == "email" - }); - assert!(!is_readonly); - // In non-readonly mode, no scope is blocked + // Write scopes are not non-write + assert!(!is_non_write_scope("https://www.googleapis.com/auth/drive")); + assert!(!is_non_write_scope("https://www.googleapis.com/auth/gmail.modify")); + assert!(!is_non_write_scope("https://www.googleapis.com/auth/calendar")); + assert!(!is_non_write_scope("https://www.googleapis.com/auth/pubsub")); } } From 14a2dfebbc4c0daee5dc108db72a31bda0382a02 Mon Sep 17 00:00:00 2001 From: Csongor Vogel <15221068+gerfalcon@users.noreply.github.com> Date: Wed, 18 Mar 2026 11:00:35 +0400 Subject: [PATCH 04/13] fix(auth): handle revocation and file removal errors properly Warn the user when token revocation fails (network error or non-200 response) so they know the old token may still be valid server-side. Return errors when credential/cache file removal fails (ignoring NotFound) instead of silently continuing with stale files. --- src/auth_commands.rs | 41 +++++++++++++++++++++++++++++++++++------ 1 file changed, 35 insertions(+), 6 deletions(-) diff --git a/src/auth_commands.rs b/src/auth_commands.rs index a67daa88..ea12de13 100644 --- a/src/auth_commands.rs +++ b/src/auth_commands.rs @@ -331,22 +331,51 @@ async fn handle_login(args: &[String]) -> Result<(), GwsError> { let prev_set: HashSet<&str> = prev_scopes.iter().map(|s| s.as_str()).collect(); let new_set: HashSet<&str> = scopes.iter().map(|s| s.as_str()).collect(); if prev_set != new_set { - // Best-effort revocation: load the old refresh token and revoke it. + // Revoke the old refresh token so Google removes the prior consent grant. if let Ok(creds_str) = credential_store::load_encrypted() { if let Ok(creds) = serde_json::from_str::(&creds_str) { if let Some(rt) = creds.get("refresh_token").and_then(|v| v.as_str()) { let client = reqwest::Client::new(); - let _ = client + match client .post("https://oauth2.googleapis.com/revoke") .form(&[("token", rt)]) .send() - .await; + .await + { + Ok(resp) if resp.status().is_success() => {} + Ok(resp) => { + eprintln!( + "Warning: token revocation returned HTTP {}. \ + The old token may still be valid on Google's side.", + resp.status() + ); + } + Err(e) => { + eprintln!( + "Warning: could not revoke old token ({e}). \ + The old token may still be valid on Google's side." + ); + } + } } } } - // Clear local credential and cache files regardless of revocation result. - let _ = std::fs::remove_file(credential_store::encrypted_credentials_path()); - let _ = std::fs::remove_file(token_cache_path()); + // Clear local credential and cache files to force a fresh login. + let enc_path = credential_store::encrypted_credentials_path(); + if let Err(e) = std::fs::remove_file(&enc_path) { + if e.kind() != std::io::ErrorKind::NotFound { + return Err(GwsError::Auth(format!( + "Failed to remove old credentials file: {e}. Please remove it manually." + ))); + } + } + if let Err(e) = std::fs::remove_file(token_cache_path()) { + if e.kind() != std::io::ErrorKind::NotFound { + return Err(GwsError::Auth(format!( + "Failed to remove old token cache: {e}. Please remove it manually." + ))); + } + } eprintln!("Scopes changed — revoked previous credentials."); } } From 85a0f2f5b5f710643a4a41c5514df8439b101cb3 Mon Sep 17 00:00:00 2001 From: Csongor Vogel <15221068+gerfalcon@users.noreply.github.com> Date: Mon, 23 Mar 2026 02:45:33 +0400 Subject: [PATCH 05/13] fix(auth): move scope guard into auth::get_token for full coverage The scope guard was only in main.rs, but helper commands (+send, +triage, etc.) call auth::get_token() directly, bypassing main.rs dispatch. Move the check into get_token() so ALL code paths are protected. Remove the now-redundant check from main.rs. Verified with manual testing: - gmail +send blocked in readonly session - calendar +agenda works (uses .readonly scope) - Switching scopes.json to default unblocks write ops --- src/auth.rs | 7 ++++++- src/main.rs | 7 ++----- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/src/auth.rs b/src/auth.rs index b602d840..db76bcd9 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -156,7 +156,12 @@ impl AccessTokenProvider for FakeTokenProvider { /// - Well-known ADC path: `~/.config/gcloud/application_default_credentials.json` /// (populated by `gcloud auth application-default login`) pub async fn get_token(scopes: &[&str]) -> anyhow::Result { - // 0. Direct token from env var (highest priority, bypasses all credential loading) + // 0. Enforce readonly session: reject write scopes if user logged in with --readonly + for scope in scopes { + crate::auth_commands::check_scope_allowed(scope)?; + } + + // 1. Direct token from env var (highest priority, bypasses all credential loading) if let Ok(token) = std::env::var("GOOGLE_WORKSPACE_CLI_TOKEN") { if !token.is_empty() { return Ok(token); diff --git a/src/main.rs b/src/main.rs index a45bdff9..bee618a9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -261,12 +261,9 @@ async fn run() -> Result<(), GwsError> { // to avoid restrictive scopes like gmail.metadata that block query parameters. let scopes: Vec<&str> = select_scope(&method.scopes).into_iter().collect(); - // Enforce readonly session: reject write scopes if user logged in with --readonly - if let Some(scope) = scopes.first() { - auth_commands::check_scope_allowed(scope)?; - } - // Authenticate: try OAuth, fail with error if credentials exist but are broken + // Note: readonly scope guard is enforced inside auth::get_token() so it also + // covers helper commands that call get_token() directly. let (token, auth_method) = match auth::get_token(&scopes).await { Ok(t) => (Some(t), executor::AuthMethod::OAuth), Err(e) => { From 27ef1e15e1bddc95cd5b224f07977f031a208d15 Mon Sep 17 00:00:00 2001 From: Csongor Vogel <15221068+gerfalcon@users.noreply.github.com> Date: Mon, 23 Mar 2026 02:59:07 +0400 Subject: [PATCH 06/13] refactor(auth): convert to async I/O and sanitize error output - Convert save_scopes, load_saved_scopes, is_readonly_session, and check_scope_allowed to async functions using tokio::fs and atomic_write_async to avoid blocking the executor thread. - Use tokio::fs::remove_file for credential/cache cleanup in handle_login. - Sanitize reqwest error via crate::output::sanitize_for_terminal before printing to prevent terminal escape sequence injection. - Update all call sites to .await the async functions. --- src/auth.rs | 2 +- src/auth_commands.rs | 32 +++++++++++++++++--------------- 2 files changed, 18 insertions(+), 16 deletions(-) diff --git a/src/auth.rs b/src/auth.rs index db76bcd9..ad0e6de5 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -158,7 +158,7 @@ impl AccessTokenProvider for FakeTokenProvider { pub async fn get_token(scopes: &[&str]) -> anyhow::Result { // 0. Enforce readonly session: reject write scopes if user logged in with --readonly for scope in scopes { - crate::auth_commands::check_scope_allowed(scope)?; + crate::auth_commands::check_scope_allowed(scope).await?; } // 1. Direct token from env var (highest priority, bypasses all credential loading) diff --git a/src/auth_commands.rs b/src/auth_commands.rs index a94d7228..56ba4206 100644 --- a/src/auth_commands.rs +++ b/src/auth_commands.rs @@ -133,17 +133,18 @@ fn scopes_path() -> PathBuf { } /// Save the configured scope set so scope changes can be detected across sessions. -fn save_scopes(scopes: &[String]) -> Result<(), GwsError> { +async fn save_scopes(scopes: &[String]) -> Result<(), GwsError> { let json = serde_json::to_string_pretty(scopes) .map_err(|e| GwsError::Validation(format!("Failed to serialize scopes: {e}")))?; - crate::fs_util::atomic_write(&scopes_path(), json.as_bytes()) + crate::fs_util::atomic_write_async(&scopes_path(), json.as_bytes()) + .await .map_err(|e| GwsError::Validation(format!("Failed to save scopes file: {e}")))?; Ok(()) } /// Load the previously saved scope set, if any. -pub fn load_saved_scopes() -> Option> { - let data = std::fs::read_to_string(scopes_path()).ok()?; +pub async fn load_saved_scopes() -> Option> { + let data = tokio::fs::read_to_string(scopes_path()).await.ok()?; serde_json::from_str(&data).ok() } @@ -156,8 +157,9 @@ fn is_non_write_scope(scope: &str) -> bool { } /// Returns true if the saved scopes are all read-only. -pub fn is_readonly_session() -> bool { +pub async fn is_readonly_session() -> bool { load_saved_scopes() + .await .map(|scopes| scopes.iter().all(|s| is_non_write_scope(s))) .unwrap_or(false) } @@ -165,8 +167,8 @@ pub fn is_readonly_session() -> bool { /// Check if a requested scope is compatible with the current session. /// /// In a readonly session, write-scope requests are rejected with a clear error. -pub fn check_scope_allowed(scope: &str) -> Result<(), GwsError> { - if is_readonly_session() && !is_non_write_scope(scope) { +pub async fn check_scope_allowed(scope: &str) -> Result<(), GwsError> { + if is_readonly_session().await && !is_non_write_scope(scope) { return Err(GwsError::Auth(format!( "This operation requires scope '{}' (write access), but the current session \ uses read-only scopes. Run `gws auth login` (without --readonly) to upgrade.", @@ -327,7 +329,7 @@ async fn handle_login(args: &[String]) -> Result<(), GwsError> { // so Google removes the prior consent grant. Without revocation, Google's // consent screen shows previously-granted scopes pre-checked and the user // may unknowingly re-grant broad access. - if let Some(prev_scopes) = load_saved_scopes() { + if let Some(prev_scopes) = load_saved_scopes().await { let prev_set: HashSet<&str> = prev_scopes.iter().map(|s| s.as_str()).collect(); let new_set: HashSet<&str> = scopes.iter().map(|s| s.as_str()).collect(); if prev_set != new_set { @@ -352,9 +354,9 @@ async fn handle_login(args: &[String]) -> Result<(), GwsError> { } Err(e) => { eprintln!( - "Warning: could not revoke old token ({:?}). \ + "Warning: could not revoke old token ({}). \ The old token may still be valid on Google's side.", - e + crate::output::sanitize_for_terminal(&e.to_string()) ); } } @@ -363,14 +365,14 @@ async fn handle_login(args: &[String]) -> Result<(), GwsError> { } // Clear local credential and cache files to force a fresh login. let enc_path = credential_store::encrypted_credentials_path(); - if let Err(e) = std::fs::remove_file(&enc_path) { + if let Err(e) = tokio::fs::remove_file(&enc_path).await { if e.kind() != std::io::ErrorKind::NotFound { return Err(GwsError::Auth(format!( "Failed to remove old credentials file: {e}. Please remove it manually." ))); } } - if let Err(e) = std::fs::remove_file(token_cache_path()) { + if let Err(e) = tokio::fs::remove_file(token_cache_path()).await { if e.kind() != std::io::ErrorKind::NotFound { return Err(GwsError::Auth(format!( "Failed to remove old token cache: {e}. Please remove it manually." @@ -465,7 +467,7 @@ async fn handle_login(args: &[String]) -> Result<(), GwsError> { // Persist the configured scope set for scope-change detection and // client-side guard enforcement. - save_scopes(&scopes)?; + save_scopes(&scopes).await?; // Clean up temp file let _ = std::fs::remove_file(&temp_path); @@ -1284,9 +1286,9 @@ async fn handle_status() -> Result<(), GwsError> { } // end !cfg!(test) // Show configured scope mode from scopes.json (independent of network) - if let Some(saved) = load_saved_scopes() { + if let Some(saved) = load_saved_scopes().await { output["configured_scopes"] = json!(saved); - output["scope_mode"] = json!(if is_readonly_session() { + output["scope_mode"] = json!(if is_readonly_session().await { "readonly" } else { "default" From fd5f68b45fd18b276bcc0cdd0fc8886a5ca4ec95 Mon Sep 17 00:00:00 2001 From: Csongor Vogel <15221068+gerfalcon@users.noreply.github.com> Date: Mon, 23 Mar 2026 03:07:03 +0400 Subject: [PATCH 07/13] perf(auth): batch scope check to avoid reading scopes.json per scope Rename check_scope_allowed to check_scopes_allowed accepting a slice, so get_token reads scopes.json once for the entire batch instead of once per scope. --- src/auth.rs | 4 +--- src/auth_commands.rs | 19 +++++++++++-------- 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/src/auth.rs b/src/auth.rs index ad0e6de5..ba865ebd 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -157,9 +157,7 @@ impl AccessTokenProvider for FakeTokenProvider { /// (populated by `gcloud auth application-default login`) pub async fn get_token(scopes: &[&str]) -> anyhow::Result { // 0. Enforce readonly session: reject write scopes if user logged in with --readonly - for scope in scopes { - crate::auth_commands::check_scope_allowed(scope).await?; - } + crate::auth_commands::check_scopes_allowed(scopes).await?; // 1. Direct token from env var (highest priority, bypasses all credential loading) if let Ok(token) = std::env::var("GOOGLE_WORKSPACE_CLI_TOKEN") { diff --git a/src/auth_commands.rs b/src/auth_commands.rs index 56ba4206..12613199 100644 --- a/src/auth_commands.rs +++ b/src/auth_commands.rs @@ -164,16 +164,19 @@ pub async fn is_readonly_session() -> bool { .unwrap_or(false) } -/// Check if a requested scope is compatible with the current session. +/// Check if the requested scopes are compatible with the current session. /// /// In a readonly session, write-scope requests are rejected with a clear error. -pub async fn check_scope_allowed(scope: &str) -> Result<(), GwsError> { - if is_readonly_session().await && !is_non_write_scope(scope) { - return Err(GwsError::Auth(format!( - "This operation requires scope '{}' (write access), but the current session \ - uses read-only scopes. Run `gws auth login` (without --readonly) to upgrade.", - scope - ))); +/// Reads `scopes.json` once for the entire batch. +pub async fn check_scopes_allowed(scopes: &[&str]) -> Result<(), GwsError> { + if is_readonly_session().await { + if let Some(scope) = scopes.iter().find(|s| !is_non_write_scope(s)) { + return Err(GwsError::Auth(format!( + "This operation requires scope '{}' (write access), but the current session \ + uses read-only scopes. Run `gws auth login` (without --readonly) to upgrade.", + scope + ))); + } } Ok(()) } From ca37cc6ff399e715a0f98e929de8a57244cb0ab6 Mon Sep 17 00:00:00 2001 From: Csongor Vogel <15221068+gerfalcon@users.noreply.github.com> Date: Mon, 23 Mar 2026 03:07:25 +0400 Subject: [PATCH 08/13] perf(auth): reuse loaded scopes in handle_status to avoid double read Inline the readonly check using the already-loaded scopes instead of calling is_readonly_session() which reads scopes.json a second time. --- src/auth_commands.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/auth_commands.rs b/src/auth_commands.rs index 12613199..57638281 100644 --- a/src/auth_commands.rs +++ b/src/auth_commands.rs @@ -1289,9 +1289,10 @@ async fn handle_status() -> Result<(), GwsError> { } // end !cfg!(test) // Show configured scope mode from scopes.json (independent of network) - if let Some(saved) = load_saved_scopes().await { - output["configured_scopes"] = json!(saved); - output["scope_mode"] = json!(if is_readonly_session().await { + if let Some(saved_scopes) = load_saved_scopes().await { + let is_readonly = saved_scopes.iter().all(|s| is_non_write_scope(s)); + output["configured_scopes"] = json!(saved_scopes); + output["scope_mode"] = json!(if is_readonly { "readonly" } else { "default" From 6b321d45ca41c5c46c1396cbb5e304aa6a295370 Mon Sep 17 00:00:00 2001 From: Csongor Vogel <15221068+gerfalcon@users.noreply.github.com> Date: Mon, 23 Mar 2026 11:08:35 +0400 Subject: [PATCH 09/13] perf(auth): offload credential decryption to blocking thread pool credential_store::load_encrypted() performs synchronous file I/O and AES-256-GCM decryption. Wrap it in tokio::task::spawn_blocking to avoid stalling the async runtime during the revocation flow. --- src/auth_commands.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/auth_commands.rs b/src/auth_commands.rs index 57638281..20c73b4c 100644 --- a/src/auth_commands.rs +++ b/src/auth_commands.rs @@ -337,7 +337,10 @@ async fn handle_login(args: &[String]) -> Result<(), GwsError> { let new_set: HashSet<&str> = scopes.iter().map(|s| s.as_str()).collect(); if prev_set != new_set { // Revoke the old refresh token so Google removes the prior consent grant. - if let Ok(creds_str) = credential_store::load_encrypted() { + // load_encrypted() does sync I/O (AES-256-GCM decryption), so offload + // to the blocking thread pool to avoid stalling the async runtime. + let creds_res = tokio::task::spawn_blocking(credential_store::load_encrypted).await; + if let Ok(Ok(creds_str)) = creds_res { if let Ok(creds) = serde_json::from_str::(&creds_str) { if let Some(rt) = creds.get("refresh_token").and_then(|v| v.as_str()) { let client = reqwest::Client::new(); From 39b43d43439aa39e922435c2b143e4ead590ae22 Mon Sep 17 00:00:00 2001 From: Csongor Vogel <15221068+gerfalcon@users.noreply.github.com> Date: Mon, 23 Mar 2026 11:10:02 +0400 Subject: [PATCH 10/13] revert: remove spawn_blocking for credential decryption MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reverts the spawn_blocking wrapper around load_encrypted(). On macOS, the Keychain ties access permissions to the calling thread identity — spawn_blocking runs on a different thread pool worker, causing repeated Keychain permission prompts. The sync call is fine here since it's a one-time operation during login, not a hot path. --- src/auth_commands.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/auth_commands.rs b/src/auth_commands.rs index 20c73b4c..14aa0170 100644 --- a/src/auth_commands.rs +++ b/src/auth_commands.rs @@ -337,10 +337,10 @@ async fn handle_login(args: &[String]) -> Result<(), GwsError> { let new_set: HashSet<&str> = scopes.iter().map(|s| s.as_str()).collect(); if prev_set != new_set { // Revoke the old refresh token so Google removes the prior consent grant. - // load_encrypted() does sync I/O (AES-256-GCM decryption), so offload - // to the blocking thread pool to avoid stalling the async runtime. - let creds_res = tokio::task::spawn_blocking(credential_store::load_encrypted).await; - if let Ok(Ok(creds_str)) = creds_res { + // Note: load_encrypted() is sync but intentionally not spawn_blocking — + // it accesses the macOS Keychain which ties permissions to the calling + // thread identity. spawn_blocking causes repeated Keychain prompts. + if let Ok(creds_str) = credential_store::load_encrypted() { if let Ok(creds) = serde_json::from_str::(&creds_str) { if let Some(rt) = creds.get("refresh_token").and_then(|v| v.as_str()) { let client = reqwest::Client::new(); From cb1a586ada441061349f8ec0a5f22fc4ea6b1103 Mon Sep 17 00:00:00 2001 From: Csongor Vogel <15221068+gerfalcon@users.noreply.github.com> Date: Mon, 23 Mar 2026 11:17:28 +0400 Subject: [PATCH 11/13] fix(auth): add 'profile' as a non-write scope alias 'profile' is a standard OpenID Connect alias for userinfo.profile and does not grant write access. Without this, --scopes=profile would incorrectly classify the session as having write access. --- src/auth_commands.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/auth_commands.rs b/src/auth_commands.rs index 14aa0170..87282561 100644 --- a/src/auth_commands.rs +++ b/src/auth_commands.rs @@ -154,6 +154,7 @@ fn is_non_write_scope(scope: &str) -> bool { || scope == "openid" || scope.starts_with("https://www.googleapis.com/auth/userinfo.") || scope == "email" + || scope == "profile" } /// Returns true if the saved scopes are all read-only. @@ -2369,6 +2370,7 @@ mod tests { assert!(is_non_write_scope("https://www.googleapis.com/auth/gmail.readonly")); assert!(is_non_write_scope("openid")); assert!(is_non_write_scope("email")); + assert!(is_non_write_scope("profile")); assert!(is_non_write_scope("https://www.googleapis.com/auth/userinfo.email")); // Write scopes are not non-write From 9f735c648cc0909abe4968ec15cf55affb525472 Mon Sep 17 00:00:00 2001 From: Csongor Vogel <15221068+gerfalcon@users.noreply.github.com> Date: Mon, 23 Mar 2026 11:22:59 +0400 Subject: [PATCH 12/13] fix(auth): surface errors from corrupt scopes.json and credential loading - load_saved_scopes now returns Result>, GwsError> to distinguish missing file (Ok(None)) from corrupt file (Err). A corrupt scopes.json previously silently disabled the readonly guard. - is_readonly_session now returns Result to propagate load errors. - Credential loading/parsing failures during revocation now warn the user instead of being silently swallowed. - All call sites updated to propagate errors with ?. --- src/auth_commands.rs | 104 +++++++++++++++++++++++++++++-------------- 1 file changed, 70 insertions(+), 34 deletions(-) diff --git a/src/auth_commands.rs b/src/auth_commands.rs index 87282561..9be5070e 100644 --- a/src/auth_commands.rs +++ b/src/auth_commands.rs @@ -143,9 +143,26 @@ async fn save_scopes(scopes: &[String]) -> Result<(), GwsError> { } /// Load the previously saved scope set, if any. -pub async fn load_saved_scopes() -> Option> { - let data = tokio::fs::read_to_string(scopes_path()).await.ok()?; - serde_json::from_str(&data).ok() +/// +/// Returns `Ok(None)` if `scopes.json` does not exist, `Ok(Some(...))` on +/// success, or `Err` if the file exists but is unreadable or contains invalid +/// JSON. This ensures a corrupt file is surfaced as an error rather than +/// silently disabling the readonly guard. +pub async fn load_saved_scopes() -> Result>, GwsError> { + let path = scopes_path(); + match tokio::fs::read_to_string(&path).await { + Ok(data) => { + let scopes: Vec = serde_json::from_str(&data).map_err(|e| { + GwsError::Validation(format!("Failed to parse {}: {e}", path.display())) + })?; + Ok(Some(scopes)) + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(GwsError::Validation(format!( + "Failed to read {}: {e}", + path.display() + ))), + } } /// Returns true if a scope does not grant write access (identity or .readonly scopes). @@ -158,11 +175,14 @@ fn is_non_write_scope(scope: &str) -> bool { } /// Returns true if the saved scopes are all read-only. -pub async fn is_readonly_session() -> bool { - load_saved_scopes() - .await +/// +/// Propagates errors from `load_saved_scopes` (corrupt file). +/// Returns `false` if no scopes file exists. +pub async fn is_readonly_session() -> Result { + Ok(load_saved_scopes() + .await? .map(|scopes| scopes.iter().all(|s| is_non_write_scope(s))) - .unwrap_or(false) + .unwrap_or(false)) } /// Check if the requested scopes are compatible with the current session. @@ -170,7 +190,7 @@ pub async fn is_readonly_session() -> bool { /// In a readonly session, write-scope requests are rejected with a clear error. /// Reads `scopes.json` once for the entire batch. pub async fn check_scopes_allowed(scopes: &[&str]) -> Result<(), GwsError> { - if is_readonly_session().await { + if is_readonly_session().await? { if let Some(scope) = scopes.iter().find(|s| !is_non_write_scope(s)) { return Err(GwsError::Auth(format!( "This operation requires scope '{}' (write access), but the current session \ @@ -333,7 +353,7 @@ async fn handle_login(args: &[String]) -> Result<(), GwsError> { // so Google removes the prior consent grant. Without revocation, Google's // consent screen shows previously-granted scopes pre-checked and the user // may unknowingly re-grant broad access. - if let Some(prev_scopes) = load_saved_scopes().await { + if let Some(prev_scopes) = load_saved_scopes().await? { let prev_set: HashSet<&str> = prev_scopes.iter().map(|s| s.as_str()).collect(); let new_set: HashSet<&str> = scopes.iter().map(|s| s.as_str()).collect(); if prev_set != new_set { @@ -341,33 +361,49 @@ async fn handle_login(args: &[String]) -> Result<(), GwsError> { // Note: load_encrypted() is sync but intentionally not spawn_blocking — // it accesses the macOS Keychain which ties permissions to the calling // thread identity. spawn_blocking causes repeated Keychain prompts. - if let Ok(creds_str) = credential_store::load_encrypted() { - if let Ok(creds) = serde_json::from_str::(&creds_str) { - if let Some(rt) = creds.get("refresh_token").and_then(|v| v.as_str()) { - let client = reqwest::Client::new(); - match client - .post("https://oauth2.googleapis.com/revoke") - .form(&[("token", rt)]) - .send() - .await - { - Ok(resp) if resp.status().is_success() => {} - Ok(resp) => { - eprintln!( - "Warning: token revocation returned HTTP {}. \ - The old token may still be valid on Google's side.", - resp.status() - ); - } - Err(e) => { - eprintln!( - "Warning: could not revoke old token ({}). \ - The old token may still be valid on Google's side.", - crate::output::sanitize_for_terminal(&e.to_string()) - ); + match credential_store::load_encrypted() { + Ok(creds_str) => match serde_json::from_str::(&creds_str) { + Ok(creds) => { + if let Some(rt) = creds.get("refresh_token").and_then(|v| v.as_str()) { + let client = reqwest::Client::new(); + match client + .post("https://oauth2.googleapis.com/revoke") + .form(&[("token", rt)]) + .send() + .await + { + Ok(resp) if resp.status().is_success() => {} + Ok(resp) => { + eprintln!( + "Warning: token revocation returned HTTP {}. \ + The old token may still be valid on Google's side.", + resp.status() + ); + } + Err(e) => { + eprintln!( + "Warning: could not revoke old token ({}). \ + The old token may still be valid on Google's side.", + crate::output::sanitize_for_terminal(&e.to_string()) + ); + } } } } + Err(e) => { + eprintln!( + "Warning: could not parse credentials ({}). \ + Old token was not revoked.", + crate::output::sanitize_for_terminal(&e.to_string()) + ); + } + }, + Err(e) => { + eprintln!( + "Warning: could not load credentials ({}). \ + Old token was not revoked.", + crate::output::sanitize_for_terminal(&e.to_string()) + ); } } // Clear local credential and cache files to force a fresh login. @@ -1293,7 +1329,7 @@ async fn handle_status() -> Result<(), GwsError> { } // end !cfg!(test) // Show configured scope mode from scopes.json (independent of network) - if let Some(saved_scopes) = load_saved_scopes().await { + if let Some(saved_scopes) = load_saved_scopes().await? { let is_readonly = saved_scopes.iter().all(|s| is_non_write_scope(s)); output["configured_scopes"] = json!(saved_scopes); output["scope_mode"] = json!(if is_readonly { From e5ffef72a367dcbeaacf6c75f4e59ccfe60e47fa Mon Sep 17 00:00:00 2001 From: Csongor Vogel <15221068+gerfalcon@users.noreply.github.com> Date: Mon, 23 Mar 2026 14:01:00 +0400 Subject: [PATCH 13/13] refactor(auth): extract attempt_token_revocation helper Move the deeply nested revocation logic out of handle_login into a dedicated async helper for readability and maintainability. --- src/auth_commands.rs | 107 +++++++++++++++++++++++-------------------- 1 file changed, 58 insertions(+), 49 deletions(-) diff --git a/src/auth_commands.rs b/src/auth_commands.rs index 9be5070e..7a3882d5 100644 --- a/src/auth_commands.rs +++ b/src/auth_commands.rs @@ -284,6 +284,63 @@ impl yup_oauth2::authenticator_delegate::InstalledFlowDelegate for CliFlowDelega } } +/// Attempt to revoke the old refresh token via Google's revocation endpoint. +/// +/// Best-effort: warns on failure but does not return an error, since the +/// subsequent credential cleanup and fresh login will proceed regardless. +/// Kept as sync `load_encrypted()` intentionally — the macOS Keychain ties +/// ACL permissions to the calling thread, and `spawn_blocking` causes +/// repeated Keychain prompts. +async fn attempt_token_revocation() { + let creds_str = match credential_store::load_encrypted() { + Ok(s) => s, + Err(e) => { + eprintln!( + "Warning: could not load credentials ({}). Old token was not revoked.", + crate::output::sanitize_for_terminal(&e.to_string()) + ); + return; + } + }; + + let creds: serde_json::Value = match serde_json::from_str(&creds_str) { + Ok(j) => j, + Err(e) => { + eprintln!( + "Warning: could not parse credentials ({}). Old token was not revoked.", + crate::output::sanitize_for_terminal(&e.to_string()) + ); + return; + } + }; + + if let Some(rt) = creds.get("refresh_token").and_then(|v| v.as_str()) { + let client = reqwest::Client::new(); + match client + .post("https://oauth2.googleapis.com/revoke") + .form(&[("token", rt)]) + .send() + .await + { + Ok(resp) if resp.status().is_success() => {} + Ok(resp) => { + eprintln!( + "Warning: token revocation returned HTTP {}. \ + The old token may still be valid on Google's side.", + resp.status() + ); + } + Err(e) => { + eprintln!( + "Warning: could not revoke old token ({}). \ + The old token may still be valid on Google's side.", + crate::output::sanitize_for_terminal(&e.to_string()) + ); + } + } + } +} + async fn handle_login(args: &[String]) -> Result<(), GwsError> { // Extract -s/--services from args let mut services_filter: Option> = None; @@ -357,55 +414,7 @@ async fn handle_login(args: &[String]) -> Result<(), GwsError> { let prev_set: HashSet<&str> = prev_scopes.iter().map(|s| s.as_str()).collect(); let new_set: HashSet<&str> = scopes.iter().map(|s| s.as_str()).collect(); if prev_set != new_set { - // Revoke the old refresh token so Google removes the prior consent grant. - // Note: load_encrypted() is sync but intentionally not spawn_blocking — - // it accesses the macOS Keychain which ties permissions to the calling - // thread identity. spawn_blocking causes repeated Keychain prompts. - match credential_store::load_encrypted() { - Ok(creds_str) => match serde_json::from_str::(&creds_str) { - Ok(creds) => { - if let Some(rt) = creds.get("refresh_token").and_then(|v| v.as_str()) { - let client = reqwest::Client::new(); - match client - .post("https://oauth2.googleapis.com/revoke") - .form(&[("token", rt)]) - .send() - .await - { - Ok(resp) if resp.status().is_success() => {} - Ok(resp) => { - eprintln!( - "Warning: token revocation returned HTTP {}. \ - The old token may still be valid on Google's side.", - resp.status() - ); - } - Err(e) => { - eprintln!( - "Warning: could not revoke old token ({}). \ - The old token may still be valid on Google's side.", - crate::output::sanitize_for_terminal(&e.to_string()) - ); - } - } - } - } - Err(e) => { - eprintln!( - "Warning: could not parse credentials ({}). \ - Old token was not revoked.", - crate::output::sanitize_for_terminal(&e.to_string()) - ); - } - }, - Err(e) => { - eprintln!( - "Warning: could not load credentials ({}). \ - Old token was not revoked.", - crate::output::sanitize_for_terminal(&e.to_string()) - ); - } - } + attempt_token_revocation().await; // Clear local credential and cache files to force a fresh login. let enc_path = credential_store::encrypted_credentials_path(); if let Err(e) = tokio::fs::remove_file(&enc_path).await {