From 7461d5ba71bf4a0d4c540829bfd46ad868586157 Mon Sep 17 00:00:00 2001 From: Lubrsy706 Date: Thu, 14 May 2026 01:20:10 +0800 Subject: [PATCH 1/4] fix(auth): invalidate token caches after login --- .../google-workspace-cli/src/auth_commands.rs | 54 ++++++++++++++++++- 1 file changed, 53 insertions(+), 1 deletion(-) diff --git a/crates/google-workspace-cli/src/auth_commands.rs b/crates/google-workspace-cli/src/auth_commands.rs index d7571e74..89e43489 100644 --- a/crates/google-workspace-cli/src/auth_commands.rs +++ b/crates/google-workspace-cli/src/auth_commands.rs @@ -345,6 +345,25 @@ fn token_cache_path() -> PathBuf { config_dir().join("token_cache.json") } +fn service_account_token_cache_path() -> PathBuf { + config_dir().join("sa_token_cache.json") +} + +fn invalidate_token_caches() -> Result, GwsError> { + let mut removed = Vec::new(); + + for path in [token_cache_path(), service_account_token_cache_path()] { + if path.exists() { + std::fs::remove_file(&path).map_err(|e| { + GwsError::Validation(format!("Failed to remove {}: {e}", path.display())) + })?; + removed.push(path.display().to_string()); + } + } + + Ok(removed) +} + /// Which scope set to use for login. enum ScopeMode { /// Use the default scopes (MINIMAL_SCOPES). @@ -644,6 +663,14 @@ async fn handle_login_inner( let enc_path = credential_store::save_encrypted(&creds_str) .map_err(|e| GwsError::Auth(format!("Failed to encrypt credentials: {e}")))?; + // A successful login may change the active account or granted scopes. + // Remove cached access tokens so the next API call mints a token from the + // newly saved refresh token instead of reusing stale credentials. + let invalidated_token_caches = invalidate_token_caches()?; + + // Invalidate cached account timezone (may belong to the previous account). + crate::timezone::invalidate_cache(); + let output = json!({ "status": "success", "message": "Authentication successful. Encrypted credentials saved.", @@ -651,6 +678,7 @@ async fn handle_login_inner( "credentials_file": enc_path.display().to_string(), "encryption": "AES-256-GCM (key in OS keyring or local `.encryption_key`; set GOOGLE_WORKSPACE_CLI_KEYRING_BACKEND=file for headless)", "scopes": scopes, + "invalidated_token_caches": invalidated_token_caches, }); println!( "{}", @@ -1457,7 +1485,7 @@ fn handle_logout() -> Result<(), GwsError> { let plain_path = plain_credentials_path(); 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 sa_token_cache = service_account_token_cache_path(); let mut removed = Vec::new(); @@ -1900,6 +1928,30 @@ mod tests { assert!(path.starts_with(config_dir())); } + #[test] + #[serial_test::serial] + fn invalidate_token_caches_removes_user_and_service_account_caches() { + let dir = tempfile::tempdir().unwrap(); + unsafe { + std::env::set_var("GOOGLE_WORKSPACE_CLI_CONFIG_DIR", dir.path()); + } + + let token_cache = token_cache_path(); + let sa_token_cache = service_account_token_cache_path(); + std::fs::write(&token_cache, "{}").unwrap(); + std::fs::write(&sa_token_cache, "{}").unwrap(); + + let removed = invalidate_token_caches().unwrap(); + + assert_eq!(removed.len(), 2); + assert!(!token_cache.exists()); + assert!(!sa_token_cache.exists()); + + unsafe { + std::env::remove_var("GOOGLE_WORKSPACE_CLI_CONFIG_DIR"); + } + } + #[tokio::test] async fn handle_auth_command_empty_args_prints_usage() { let args: Vec = vec![]; From 05a6ee2f70597bab16b3594a4e144849edaa7536 Mon Sep 17 00:00:00 2001 From: Lubrsy706 Date: Thu, 14 May 2026 10:03:43 +0800 Subject: [PATCH 2/4] fix(auth): sanitize cache removal errors --- .../google-workspace-cli/src/auth_commands.rs | 24 ++++++++++++------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/crates/google-workspace-cli/src/auth_commands.rs b/crates/google-workspace-cli/src/auth_commands.rs index 89e43489..b53de5ea 100644 --- a/crates/google-workspace-cli/src/auth_commands.rs +++ b/crates/google-workspace-cli/src/auth_commands.rs @@ -13,7 +13,7 @@ // limitations under the License. use std::collections::HashSet; -use std::io::{BufRead, BufReader, Write}; +use std::io::{BufRead, BufReader, ErrorKind, Write}; use std::net::TcpListener; use std::path::{Path, PathBuf}; @@ -349,14 +349,23 @@ fn service_account_token_cache_path() -> PathBuf { config_dir().join("sa_token_cache.json") } +fn remove_file_if_exists(path: &Path) -> Result { + // Remove directly instead of checking existence first; a pre-check would + // still be subject to the usual filesystem TOCTOU race. + match std::fs::remove_file(path) { + Ok(()) => Ok(true), + Err(e) if e.kind() == ErrorKind::NotFound => Ok(false), + Err(e) => Err(GwsError::Validation(crate::output::sanitize_for_terminal( + &format!("Failed to remove {}: {e}", path.display()), + ))), + } +} + fn invalidate_token_caches() -> Result, GwsError> { let mut removed = Vec::new(); for path in [token_cache_path(), service_account_token_cache_path()] { - if path.exists() { - std::fs::remove_file(&path).map_err(|e| { - GwsError::Validation(format!("Failed to remove {}: {e}", path.display())) - })?; + if remove_file_if_exists(&path)? { removed.push(path.display().to_string()); } } @@ -1490,10 +1499,7 @@ fn handle_logout() -> Result<(), GwsError> { let mut removed = Vec::new(); for path in [&enc_path, &plain_path, &token_cache, &sa_token_cache] { - if path.exists() { - std::fs::remove_file(path).map_err(|e| { - GwsError::Validation(format!("Failed to remove {}: {e}", path.display())) - })?; + if remove_file_if_exists(path)? { removed.push(path.display().to_string()); } } From b6c1e8597e02e03ca3dd0c719ac7926fb623e844 Mon Sep 17 00:00:00 2001 From: Lubrsy706 Date: Thu, 14 May 2026 11:20:02 +0800 Subject: [PATCH 3/4] fix(auth): make cache invalidation best effort --- .../google-workspace-cli/src/auth_commands.rs | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/crates/google-workspace-cli/src/auth_commands.rs b/crates/google-workspace-cli/src/auth_commands.rs index b53de5ea..64eb9245 100644 --- a/crates/google-workspace-cli/src/auth_commands.rs +++ b/crates/google-workspace-cli/src/auth_commands.rs @@ -361,16 +361,18 @@ fn remove_file_if_exists(path: &Path) -> Result { } } -fn invalidate_token_caches() -> Result, GwsError> { +fn invalidate_token_caches() -> Vec { let mut removed = Vec::new(); for path in [token_cache_path(), service_account_token_cache_path()] { - if remove_file_if_exists(&path)? { - removed.push(path.display().to_string()); + match remove_file_if_exists(&path) { + Ok(true) => removed.push(path.display().to_string()), + Ok(false) => {} + Err(e) => eprintln!("Warning: {e}"), } } - Ok(removed) + removed } /// Which scope set to use for login. @@ -675,7 +677,7 @@ async fn handle_login_inner( // A successful login may change the active account or granted scopes. // Remove cached access tokens so the next API call mints a token from the // newly saved refresh token instead of reusing stale credentials. - let invalidated_token_caches = invalidate_token_caches()?; + let invalidated_token_caches = invalidate_token_caches(); // Invalidate cached account timezone (may belong to the previous account). crate::timezone::invalidate_cache(); @@ -1499,8 +1501,10 @@ fn handle_logout() -> Result<(), GwsError> { let mut removed = Vec::new(); for path in [&enc_path, &plain_path, &token_cache, &sa_token_cache] { - if remove_file_if_exists(path)? { - removed.push(path.display().to_string()); + match remove_file_if_exists(path) { + Ok(true) => removed.push(path.display().to_string()), + Ok(false) => {} + Err(e) => eprintln!("Warning: {e}"), } } @@ -1947,7 +1951,7 @@ mod tests { std::fs::write(&token_cache, "{}").unwrap(); std::fs::write(&sa_token_cache, "{}").unwrap(); - let removed = invalidate_token_caches().unwrap(); + let removed = invalidate_token_caches(); assert_eq!(removed.len(), 2); assert!(!token_cache.exists()); From a94efd1bf079cefc1aafb7dc35a8523ff7e16937 Mon Sep 17 00:00:00 2001 From: Lubrsy706 Date: Thu, 14 May 2026 15:54:41 +0800 Subject: [PATCH 4/4] fix(auth): report incomplete logout cleanup --- crates/google-workspace-cli/src/auth_commands.rs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/crates/google-workspace-cli/src/auth_commands.rs b/crates/google-workspace-cli/src/auth_commands.rs index 64eb9245..35b42fc1 100644 --- a/crates/google-workspace-cli/src/auth_commands.rs +++ b/crates/google-workspace-cli/src/auth_commands.rs @@ -1499,18 +1499,26 @@ fn handle_logout() -> Result<(), GwsError> { let sa_token_cache = service_account_token_cache_path(); let mut removed = Vec::new(); + let mut failures = Vec::new(); for path in [&enc_path, &plain_path, &token_cache, &sa_token_cache] { match remove_file_if_exists(path) { Ok(true) => removed.push(path.display().to_string()), Ok(false) => {} - Err(e) => eprintln!("Warning: {e}"), + Err(e) => failures.push(e.to_string()), } } // Invalidate cached account timezone (may belong to old account) crate::timezone::invalidate_cache(); + if !failures.is_empty() { + return Err(GwsError::Validation(format!( + "Logout incomplete. Failed to remove one or more credential/cache files: {}", + failures.join("; ") + ))); + } + let output = if removed.is_empty() { json!({ "status": "success",