diff --git a/crates/google-workspace-cli/src/auth_commands.rs b/crates/google-workspace-cli/src/auth_commands.rs index d7571e74..35b42fc1 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}; @@ -345,6 +345,36 @@ 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 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() -> Vec { + let mut removed = Vec::new(); + + for path in [token_cache_path(), service_account_token_cache_path()] { + match remove_file_if_exists(&path) { + Ok(true) => removed.push(path.display().to_string()), + Ok(false) => {} + Err(e) => eprintln!("Warning: {e}"), + } + } + + removed +} + /// Which scope set to use for login. enum ScopeMode { /// Use the default scopes (MINIMAL_SCOPES). @@ -644,6 +674,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 +689,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,22 +1496,29 @@ 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(); + let mut failures = 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())) - })?; - removed.push(path.display().to_string()); + match remove_file_if_exists(path) { + Ok(true) => removed.push(path.display().to_string()), + Ok(false) => {} + 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", @@ -1900,6 +1946,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(); + + 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![];