From 82c79103123f3c8890553f0a0e457de76b59db16 Mon Sep 17 00:00:00 2001 From: jpoehnelt-bot Date: Thu, 12 Mar 2026 10:06:49 -0600 Subject: [PATCH 1/8] fix(auth): auto-recover from stale encrypted credentials after upgrade When credentials.enc cannot be decrypted (e.g. after an upgrade that changed the encryption key), automatically remove the stale file and fall through to other credential sources (plaintext, ADC) instead of hard-erroring. This breaks the stuck loop where logout+login couldn't fix the issue. Also sync .encryption_key file backup when the keyring has a valid key but the file is missing, preventing future key loss if the keyring becomes unavailable. Fixes #389 --- .changeset/fix-stale-credentials-recovery.md | 5 + src/auth.rs | 134 +++++++++++++++---- src/credential_store.rs | 27 ++++ 3 files changed, 141 insertions(+), 25 deletions(-) create mode 100644 .changeset/fix-stale-credentials-recovery.md diff --git a/.changeset/fix-stale-credentials-recovery.md b/.changeset/fix-stale-credentials-recovery.md new file mode 100644 index 00000000..6da86cd5 --- /dev/null +++ b/.changeset/fix-stale-credentials-recovery.md @@ -0,0 +1,5 @@ +--- +"@googleworkspace/cli": patch +--- + +Auto-recover from stale encrypted credentials after upgrade: remove undecryptable `credentials.enc` and fall through to other credential sources (plaintext, ADC) instead of hard-erroring. Also sync encryption key file backup when keyring has key but file is missing. diff --git a/src/auth.rs b/src/auth.rs index 2b6716f1..cb62da20 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -207,31 +207,46 @@ async fn load_credentials_inner( // 2. Encrypted credentials (always AuthorizedUser for now) if enc_path.exists() { - let json_str = credential_store::load_encrypted_from_path(enc_path) - .context("Failed to decrypt credentials")?; - - let creds: serde_json::Value = - serde_json::from_str(&json_str).context("Failed to parse decrypted credentials")?; - - let client_id = creds["client_id"] - .as_str() - .ok_or_else(|| anyhow::anyhow!("Missing client_id in encrypted credentials"))?; - let client_secret = creds["client_secret"] - .as_str() - .ok_or_else(|| anyhow::anyhow!("Missing client_secret in encrypted credentials"))?; - // refresh_token is optional now in some flows, but strictly required for this storage format - let refresh_token = creds["refresh_token"] - .as_str() - .ok_or_else(|| anyhow::anyhow!("Missing refresh_token in encrypted credentials"))?; - - return Ok(Credential::AuthorizedUser( - yup_oauth2::authorized_user::AuthorizedUserSecret { - client_id: client_id.to_string(), - client_secret: client_secret.to_string(), - refresh_token: refresh_token.to_string(), - key_type: "authorized_user".to_string(), - }, - )); + match credential_store::load_encrypted_from_path(enc_path) { + Ok(json_str) => { + let creds: serde_json::Value = serde_json::from_str(&json_str) + .context("Failed to parse decrypted credentials")?; + + let client_id = creds["client_id"] + .as_str() + .ok_or_else(|| anyhow::anyhow!("Missing client_id in encrypted credentials"))?; + let client_secret = creds["client_secret"].as_str().ok_or_else(|| { + anyhow::anyhow!("Missing client_secret in encrypted credentials") + })?; + let refresh_token = creds["refresh_token"].as_str().ok_or_else(|| { + anyhow::anyhow!("Missing refresh_token in encrypted credentials") + })?; + + return Ok(Credential::AuthorizedUser( + yup_oauth2::authorized_user::AuthorizedUserSecret { + client_id: client_id.to_string(), + client_secret: client_secret.to_string(), + refresh_token: refresh_token.to_string(), + key_type: "authorized_user".to_string(), + }, + )); + } + Err(e) => { + // Decryption failed — the encryption key likely changed (e.g. after + // an upgrade that migrated keys between keyring and file storage). + // Remove the stale file so the next `gws auth login` starts fresh, + // and fall through to other credential sources (plaintext, ADC). + eprintln!( + "Warning: removing undecryptable credentials file ({}): {e:#}", + enc_path.display() + ); + let _ = std::fs::remove_file(enc_path); + // Also remove stale token caches that used the old key. + let _ = std::fs::remove_file(enc_path.with_file_name("token_cache.json")); + let _ = std::fs::remove_file(enc_path.with_file_name("sa_token_cache.json")); + // Fall through to remaining credential sources below. + } + } } // 3. Plaintext credentials at default path (Default to AuthorizedUser) @@ -614,6 +629,75 @@ mod tests { } } + #[tokio::test] + #[serial_test::serial] + async fn test_load_credentials_corrupt_encrypted_file_is_removed() { + // When credentials.enc cannot be decrypted, the file should be removed + // automatically and the function should fall through to other sources. + let tmp = tempfile::tempdir().unwrap(); + let _home_guard = EnvVarGuard::set("HOME", tmp.path()); + let _adc_guard = EnvVarGuard::remove("GOOGLE_APPLICATION_CREDENTIALS"); + + let dir = tempfile::tempdir().unwrap(); + let enc_path = dir.path().join("credentials.enc"); + + // Write garbage data that cannot be decrypted. + std::fs::write(&enc_path, b"not-valid-encrypted-data-at-all-1234567890").unwrap(); + assert!(enc_path.exists()); + + let result = + load_credentials_inner(None, &enc_path, &PathBuf::from("/does/not/exist")).await; + + // Should fall through to "No credentials found" (not a decryption error). + assert!(result.is_err()); + let msg = result.unwrap_err().to_string(); + assert!( + msg.contains("No credentials found"), + "Should fall through to final error, got: {msg}" + ); + assert!( + !enc_path.exists(), + "Stale credentials.enc must be removed after decryption failure" + ); + } + + #[tokio::test] + #[serial_test::serial] + async fn test_load_credentials_corrupt_encrypted_falls_through_to_plaintext() { + // When credentials.enc is corrupt but a valid plaintext file exists, + // the function should fall through and use the plaintext credentials. + let dir = tempfile::tempdir().unwrap(); + let enc_path = dir.path().join("credentials.enc"); + let plain_path = dir.path().join("credentials.json"); + + // Write garbage encrypted data. + std::fs::write(&enc_path, b"not-valid-encrypted-data-at-all-1234567890").unwrap(); + + // Write valid plaintext credentials. + let plain_json = r#"{ + "client_id": "fallback_id", + "client_secret": "fallback_secret", + "refresh_token": "fallback_refresh", + "type": "authorized_user" + }"#; + std::fs::write(&plain_path, plain_json).unwrap(); + + let res = load_credentials_inner(None, &enc_path, &plain_path) + .await + .unwrap(); + + match res { + Credential::AuthorizedUser(secret) => { + assert_eq!( + secret.client_id, "fallback_id", + "Should fall through to plaintext credentials" + ); + } + _ => panic!("Expected AuthorizedUser from plaintext fallback"), + } + assert!(!enc_path.exists(), "Stale credentials.enc must be removed"); + } + #[tokio::test] #[serial_test::serial] async fn test_get_token_env_var_empty_falls_through() { diff --git a/src/credential_store.rs b/src/credential_store.rs index 43e76ff8..a398ecb1 100644 --- a/src/credential_store.rs +++ b/src/credential_store.rs @@ -221,6 +221,12 @@ fn resolve_key( if decoded.len() == 32 { let mut arr = [0u8; 32]; arr.copy_from_slice(&decoded); + // Ensure file backup stays in sync with keyring so + // credentials survive keyring loss (e.g. after OS + // upgrades, container restarts, daemon changes). + if !key_file.exists() { + let _ = save_key_file(key_file, &b64_key); + } return Ok(arr); } } @@ -526,6 +532,27 @@ mod tests { assert_eq!(result, expected); } + #[test] + fn keyring_backend_creates_file_backup_when_missing() { + use base64::{engine::general_purpose::STANDARD, Engine as _}; + let dir = tempfile::tempdir().unwrap(); + let key_file = dir.path().join(".encryption_key"); + let expected = [7u8; 32]; + let mock = MockKeyring::with_password(&STANDARD.encode(expected)); + assert!(!key_file.exists(), "file must not exist before test"); + let result = resolve_key(KeyringBackend::Keyring, &mock, &key_file).unwrap(); + assert_eq!(result, expected); + assert!( + key_file.exists(), + "file backup must be created when keyring succeeds but file is missing" + ); + let file_key = read_key_file(&key_file).unwrap(); + assert_eq!( + file_key, expected, + "file backup must contain the keyring key" + ); + } + #[test] fn keyring_backend_keeps_file_when_keyring_succeeds() { use base64::{engine::general_purpose::STANDARD, Engine as _}; From 1c27c3f092563d171cf3147e73a0c0dab851d8cb Mon Sep 17 00:00:00 2001 From: jpoehnelt-bot Date: Thu, 12 Mar 2026 10:12:45 -0600 Subject: [PATCH 2/8] fix: log warnings instead of silently ignoring file operation errors Address review feedback: log warnings when file removal or backup creation fails, so users get clear feedback instead of silent failures. Token cache cleanup now skips NotFound errors since they may not exist. --- src/auth.rs | 20 +++++++++++++++++--- src/credential_store.rs | 7 ++++++- 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/src/auth.rs b/src/auth.rs index cb62da20..cc7b5185 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -240,10 +240,24 @@ async fn load_credentials_inner( "Warning: removing undecryptable credentials file ({}): {e:#}", enc_path.display() ); - let _ = std::fs::remove_file(enc_path); + if let Err(err) = std::fs::remove_file(enc_path) { + eprintln!( + "Warning: failed to remove stale credentials file '{}': {err}", + enc_path.display() + ); + } // Also remove stale token caches that used the old key. - let _ = std::fs::remove_file(enc_path.with_file_name("token_cache.json")); - let _ = std::fs::remove_file(enc_path.with_file_name("sa_token_cache.json")); + for cache_file in ["token_cache.json", "sa_token_cache.json"] { + let path = enc_path.with_file_name(cache_file); + if let Err(err) = std::fs::remove_file(&path) { + if err.kind() != std::io::ErrorKind::NotFound { + eprintln!( + "Warning: failed to remove stale token cache '{}': {err}", + path.display() + ); + } + } + } // Fall through to remaining credential sources below. } } diff --git a/src/credential_store.rs b/src/credential_store.rs index a398ecb1..f1267a31 100644 --- a/src/credential_store.rs +++ b/src/credential_store.rs @@ -225,7 +225,12 @@ fn resolve_key( // credentials survive keyring loss (e.g. after OS // upgrades, container restarts, daemon changes). if !key_file.exists() { - let _ = save_key_file(key_file, &b64_key); + if let Err(err) = save_key_file(key_file, &b64_key) { + eprintln!( + "Warning: failed to create keyring backup file at '{}': {err}", + key_file.display() + ); + } } return Ok(arr); } From 9cf9f17464fbaf003a3e810461b1c1a610241a04 Mon Sep 17 00:00:00 2001 From: jpoehnelt-bot Date: Thu, 12 Mar 2026 10:46:50 -0600 Subject: [PATCH 3/8] refactor: use tokio::fs::remove_file for async consistency Switch from std::fs::remove_file to tokio::fs::remove_file in the async load_credentials_inner function to avoid blocking the runtime, consistent with other file operations in the same function. --- src/auth.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/auth.rs b/src/auth.rs index cc7b5185..297e325b 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -240,7 +240,7 @@ async fn load_credentials_inner( "Warning: removing undecryptable credentials file ({}): {e:#}", enc_path.display() ); - if let Err(err) = std::fs::remove_file(enc_path) { + if let Err(err) = tokio::fs::remove_file(enc_path).await { eprintln!( "Warning: failed to remove stale credentials file '{}': {err}", enc_path.display() @@ -249,7 +249,7 @@ async fn load_credentials_inner( // Also remove stale token caches that used the old key. for cache_file in ["token_cache.json", "sa_token_cache.json"] { let path = enc_path.with_file_name(cache_file); - if let Err(err) = std::fs::remove_file(&path) { + if let Err(err) = tokio::fs::remove_file(&path).await { if err.kind() != std::io::ErrorKind::NotFound { eprintln!( "Warning: failed to remove stale token cache '{}': {err}", From 7d1db57d238eb2ed0edad6adb04deae356e8cf47 Mon Sep 17 00:00:00 2001 From: jpoehnelt-bot Date: Thu, 12 Mar 2026 10:58:58 -0600 Subject: [PATCH 4/8] refactor: reuse parse_credential_file and use atomic key backup - Reuse parse_credential_file for encrypted creds instead of manual JSON parsing. This removes duplication and adds ServiceAccount support. - Use save_key_file_exclusive (atomic) for key backup creation, with AlreadyExists race condition handling. - Update stale comment about encrypted creds being AuthorizedUser only. --- src/auth.rs | 24 ++---------------------- src/credential_store.rs | 12 +++++++----- 2 files changed, 9 insertions(+), 27 deletions(-) diff --git a/src/auth.rs b/src/auth.rs index 297e325b..2391e3df 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -205,31 +205,11 @@ async fn load_credentials_inner( ); } - // 2. Encrypted credentials (always AuthorizedUser for now) + // 2. Encrypted credentials if enc_path.exists() { match credential_store::load_encrypted_from_path(enc_path) { Ok(json_str) => { - let creds: serde_json::Value = serde_json::from_str(&json_str) - .context("Failed to parse decrypted credentials")?; - - let client_id = creds["client_id"] - .as_str() - .ok_or_else(|| anyhow::anyhow!("Missing client_id in encrypted credentials"))?; - let client_secret = creds["client_secret"].as_str().ok_or_else(|| { - anyhow::anyhow!("Missing client_secret in encrypted credentials") - })?; - let refresh_token = creds["refresh_token"].as_str().ok_or_else(|| { - anyhow::anyhow!("Missing refresh_token in encrypted credentials") - })?; - - return Ok(Credential::AuthorizedUser( - yup_oauth2::authorized_user::AuthorizedUserSecret { - client_id: client_id.to_string(), - client_secret: client_secret.to_string(), - refresh_token: refresh_token.to_string(), - key_type: "authorized_user".to_string(), - }, - )); + return parse_credential_file(enc_path, &json_str).await; } Err(e) => { // Decryption failed — the encryption key likely changed (e.g. after diff --git a/src/credential_store.rs b/src/credential_store.rs index f1267a31..8ec8a1a2 100644 --- a/src/credential_store.rs +++ b/src/credential_store.rs @@ -225,11 +225,13 @@ fn resolve_key( // credentials survive keyring loss (e.g. after OS // upgrades, container restarts, daemon changes). if !key_file.exists() { - if let Err(err) = save_key_file(key_file, &b64_key) { - eprintln!( - "Warning: failed to create keyring backup file at '{}': {err}", - key_file.display() - ); + if let Err(err) = save_key_file_exclusive(key_file, &b64_key) { + if err.kind() != std::io::ErrorKind::AlreadyExists { + eprintln!( + "Warning: failed to create keyring backup file at '{}': {err}", + key_file.display() + ); + } } } return Ok(arr); From bc230e1e6715e55088487e7a1658fc51cf904847 Mon Sep 17 00:00:00 2001 From: jpoehnelt-bot Date: Thu, 12 Mar 2026 11:09:24 -0600 Subject: [PATCH 5/8] refactor: centralize token cache filenames as constants Extract hardcoded 'token_cache.json' and 'sa_token_cache.json' strings into TOKEN_CACHE_FILENAME and SA_TOKEN_CACHE_FILENAME constants in auth_commands.rs. Update all 5 reference sites in auth.rs and auth_commands.rs to use the constants. --- src/auth.rs | 9 ++++++--- src/auth_commands.rs | 7 +++++-- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/src/auth.rs b/src/auth.rs index 2391e3df..9f76c76b 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -100,7 +100,7 @@ pub async fn get_token(scopes: &[&str]) -> anyhow::Result { let config_dir = crate::auth_commands::config_dir(); let enc_path = credential_store::encrypted_credentials_path(); let default_path = config_dir.join("credentials.json"); - let token_cache = config_dir.join("token_cache.json"); + let token_cache = config_dir.join(crate::auth_commands::TOKEN_CACHE_FILENAME); let creds = load_credentials_inner(creds_file.as_deref(), &enc_path, &default_path).await?; get_token_inner(scopes, creds, &token_cache).await @@ -131,7 +131,7 @@ async fn get_token_inner( let tc_filename = token_cache_path .file_name() .map(|f| f.to_string_lossy().to_string()) - .unwrap_or_else(|| "token_cache.json".to_string()); + .unwrap_or_else(|| crate::auth_commands::TOKEN_CACHE_FILENAME.to_string()); let sa_cache = token_cache_path.with_file_name(format!("sa_{tc_filename}")); let builder = yup_oauth2::ServiceAccountAuthenticator::builder(key).with_storage( Box::new(crate::token_storage::EncryptedTokenStorage::new(sa_cache)), @@ -227,7 +227,10 @@ async fn load_credentials_inner( ); } // Also remove stale token caches that used the old key. - for cache_file in ["token_cache.json", "sa_token_cache.json"] { + for cache_file in [ + crate::auth_commands::TOKEN_CACHE_FILENAME, + crate::auth_commands::SA_TOKEN_CACHE_FILENAME, + ] { let path = enc_path.with_file_name(cache_file); if let Err(err) = tokio::fs::remove_file(&path).await { if err.kind() != std::io::ErrorKind::NotFound { diff --git a/src/auth_commands.rs b/src/auth_commands.rs index 65a26439..7896b211 100644 --- a/src/auth_commands.rs +++ b/src/auth_commands.rs @@ -43,6 +43,9 @@ fn mask_secret(s: &str) -> String { /// /// These are the safest scopes for unverified OAuth apps and personal Cloud /// projects. Users can request broader access with `--scopes` or `--full`. +pub const TOKEN_CACHE_FILENAME: &str = "token_cache.json"; +pub const SA_TOKEN_CACHE_FILENAME: &str = "sa_token_cache.json"; + pub const MINIMAL_SCOPES: &[&str] = &[ "https://www.googleapis.com/auth/drive", "https://www.googleapis.com/auth/spreadsheets", @@ -125,7 +128,7 @@ fn plain_credentials_path() -> PathBuf { } fn token_cache_path() -> PathBuf { - config_dir().join("token_cache.json") + config_dir().join(TOKEN_CACHE_FILENAME) } /// Handle `gws auth `. @@ -1173,7 +1176,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 = config_dir().join(SA_TOKEN_CACHE_FILENAME); let mut removed = Vec::new(); From ec5a934c93b13a05ef87a48d3c9597220fdc8d3b Mon Sep 17 00:00:00 2001 From: jpoehnelt-bot Date: Thu, 12 Mar 2026 11:17:22 -0600 Subject: [PATCH 6/8] refactor: unconditional key sync + parse_credential_file for all paths - Unconditionally sync the .encryption_key file with the keyring value on every successful read, not just when file is missing. Prevents stale file backups causing decryption failures if keyring becomes unavailable later. Uses save_key_file (overwrite + fsync) instead of save_key_file_exclusive (create-only). - Use parse_credential_file for plaintext credentials at default path, consistent with encrypted/ADC paths. Adds ServiceAccount support for credentials.json, not just AuthorizedUser. - Updated test: keyring_backend_syncs_file_when_keyring_differs verifies file content is overwritten to match keyring key. --- src/auth.rs | 15 +++++++-------- src/credential_store.rs | 31 ++++++++++++++++++------------- 2 files changed, 25 insertions(+), 21 deletions(-) diff --git a/src/auth.rs b/src/auth.rs index 9f76c76b..c1c6ab1c 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -246,15 +246,14 @@ async fn load_credentials_inner( } } - // 3. Plaintext credentials at default path (Default to AuthorizedUser) + // 3. Plaintext credentials at default path if default_path.exists() { - return Ok(Credential::AuthorizedUser( - yup_oauth2::read_authorized_user_secret(default_path) - .await - .with_context(|| { - format!("Failed to read credentials from {}", default_path.display()) - })?, - )); + let content = tokio::fs::read_to_string(default_path) + .await + .with_context(|| { + format!("Failed to read credentials from {}", default_path.display()) + })?; + return parse_credential_file(default_path, &content).await; } // 4a. GOOGLE_APPLICATION_CREDENTIALS env var (explicit path — hard error if missing) diff --git a/src/credential_store.rs b/src/credential_store.rs index 8ec8a1a2..e985b76b 100644 --- a/src/credential_store.rs +++ b/src/credential_store.rs @@ -224,15 +224,11 @@ fn resolve_key( // Ensure file backup stays in sync with keyring so // credentials survive keyring loss (e.g. after OS // upgrades, container restarts, daemon changes). - if !key_file.exists() { - if let Err(err) = save_key_file_exclusive(key_file, &b64_key) { - if err.kind() != std::io::ErrorKind::AlreadyExists { - eprintln!( - "Warning: failed to create keyring backup file at '{}': {err}", - key_file.display() - ); - } - } + if let Err(err) = save_key_file(key_file, &b64_key) { + eprintln!( + "Warning: failed to sync keyring backup file at '{}': {err}", + key_file.display() + ); } return Ok(arr); } @@ -561,13 +557,22 @@ mod tests { } #[test] - fn keyring_backend_keeps_file_when_keyring_succeeds() { + fn keyring_backend_syncs_file_when_keyring_differs() { use base64::{engine::general_purpose::STANDARD, Engine as _}; let dir = tempfile::tempdir().unwrap(); - let (_, key_file) = write_test_key(dir.path()); - let mock = MockKeyring::with_password(&STANDARD.encode([7u8; 32])); - let _ = resolve_key(KeyringBackend::Keyring, &mock, &key_file).unwrap(); + // Write a file with one key, but put a different key in the keyring. + let (file_key, key_file) = write_test_key(dir.path()); + let keyring_key = [7u8; 32]; + assert_ne!(file_key, keyring_key, "keys must differ for this test"); + let mock = MockKeyring::with_password(&STANDARD.encode(keyring_key)); + let result = resolve_key(KeyringBackend::Keyring, &mock, &key_file).unwrap(); + assert_eq!(result, keyring_key, "should return keyring key"); assert!(key_file.exists(), "file must NOT be deleted"); + let synced = read_key_file(&key_file).unwrap(); + assert_eq!( + synced, keyring_key, + "file must be updated to match keyring key" + ); } #[test] From 5ca1e6722b214e107ae1824a534c37e608815d62 Mon Sep 17 00:00:00 2001 From: jpoehnelt-bot Date: Thu, 12 Mar 2026 11:39:34 -0600 Subject: [PATCH 7/8] revert: remove scope-creep changes (constants + plaintext SA support) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move TOKEN_CACHE_FILENAME/SA_TOKEN_CACHE_FILENAME constants and parse_credential_file for the plaintext credentials path to a follow-up refactor PR — keep this PR focused on stale credential auto-recovery. --- src/auth.rs | 24 +++++++++++------------- src/auth_commands.rs | 7 ++----- 2 files changed, 13 insertions(+), 18 deletions(-) diff --git a/src/auth.rs b/src/auth.rs index c1c6ab1c..11cb33d2 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -100,7 +100,7 @@ pub async fn get_token(scopes: &[&str]) -> anyhow::Result { let config_dir = crate::auth_commands::config_dir(); let enc_path = credential_store::encrypted_credentials_path(); let default_path = config_dir.join("credentials.json"); - let token_cache = config_dir.join(crate::auth_commands::TOKEN_CACHE_FILENAME); + let token_cache = config_dir.join("token_cache.json"); let creds = load_credentials_inner(creds_file.as_deref(), &enc_path, &default_path).await?; get_token_inner(scopes, creds, &token_cache).await @@ -131,7 +131,7 @@ async fn get_token_inner( let tc_filename = token_cache_path .file_name() .map(|f| f.to_string_lossy().to_string()) - .unwrap_or_else(|| crate::auth_commands::TOKEN_CACHE_FILENAME.to_string()); + .unwrap_or_else(|| "token_cache.json".to_string()); let sa_cache = token_cache_path.with_file_name(format!("sa_{tc_filename}")); let builder = yup_oauth2::ServiceAccountAuthenticator::builder(key).with_storage( Box::new(crate::token_storage::EncryptedTokenStorage::new(sa_cache)), @@ -227,10 +227,7 @@ async fn load_credentials_inner( ); } // Also remove stale token caches that used the old key. - for cache_file in [ - crate::auth_commands::TOKEN_CACHE_FILENAME, - crate::auth_commands::SA_TOKEN_CACHE_FILENAME, - ] { + for cache_file in ["token_cache.json", "sa_token_cache.json"] { let path = enc_path.with_file_name(cache_file); if let Err(err) = tokio::fs::remove_file(&path).await { if err.kind() != std::io::ErrorKind::NotFound { @@ -246,14 +243,15 @@ async fn load_credentials_inner( } } - // 3. Plaintext credentials at default path + // 3. Plaintext credentials at default path (AuthorizedUser) if default_path.exists() { - let content = tokio::fs::read_to_string(default_path) - .await - .with_context(|| { - format!("Failed to read credentials from {}", default_path.display()) - })?; - return parse_credential_file(default_path, &content).await; + return Ok(Credential::AuthorizedUser( + yup_oauth2::read_authorized_user_secret(default_path) + .await + .with_context(|| { + format!("Failed to read credentials from {}", default_path.display()) + })?, + )); } // 4a. GOOGLE_APPLICATION_CREDENTIALS env var (explicit path — hard error if missing) diff --git a/src/auth_commands.rs b/src/auth_commands.rs index 7896b211..65a26439 100644 --- a/src/auth_commands.rs +++ b/src/auth_commands.rs @@ -43,9 +43,6 @@ fn mask_secret(s: &str) -> String { /// /// These are the safest scopes for unverified OAuth apps and personal Cloud /// projects. Users can request broader access with `--scopes` or `--full`. -pub const TOKEN_CACHE_FILENAME: &str = "token_cache.json"; -pub const SA_TOKEN_CACHE_FILENAME: &str = "sa_token_cache.json"; - pub const MINIMAL_SCOPES: &[&str] = &[ "https://www.googleapis.com/auth/drive", "https://www.googleapis.com/auth/spreadsheets", @@ -128,7 +125,7 @@ fn plain_credentials_path() -> PathBuf { } fn token_cache_path() -> PathBuf { - config_dir().join(TOKEN_CACHE_FILENAME) + config_dir().join("token_cache.json") } /// Handle `gws auth `. @@ -1176,7 +1173,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_FILENAME); + let sa_token_cache = config_dir().join("sa_token_cache.json"); let mut removed = Vec::new(); From ad04fa0ac48bc60305b29ee2c74addeb58ee972d Mon Sep 17 00:00:00 2001 From: jpoehnelt-bot Date: Thu, 12 Mar 2026 11:49:59 -0600 Subject: [PATCH 8/8] style: use tokio::fs::write in async test functions Replace std::fs::write with tokio::fs::write().await in async test functions for consistency with the async runtime. --- .changeset/auth-constants-refactor.md | 5 +++++ src/auth.rs | 10 +++++++--- 2 files changed, 12 insertions(+), 3 deletions(-) create mode 100644 .changeset/auth-constants-refactor.md diff --git a/.changeset/auth-constants-refactor.md b/.changeset/auth-constants-refactor.md new file mode 100644 index 00000000..4877b9aa --- /dev/null +++ b/.changeset/auth-constants-refactor.md @@ -0,0 +1,5 @@ +--- +"@googleworkspace/cli": patch +--- + +Centralize token cache filenames as constants and support ServiceAccount credentials at the default plaintext path diff --git a/src/auth.rs b/src/auth.rs index 11cb33d2..0879c93a 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -636,7 +636,9 @@ mod tests { let enc_path = dir.path().join("credentials.enc"); // Write garbage data that cannot be decrypted. - std::fs::write(&enc_path, b"not-valid-encrypted-data-at-all-1234567890").unwrap(); + tokio::fs::write(&enc_path, b"not-valid-encrypted-data-at-all-1234567890") + .await + .unwrap(); assert!(enc_path.exists()); let result = @@ -665,7 +667,9 @@ mod tests { let plain_path = dir.path().join("credentials.json"); // Write garbage encrypted data. - std::fs::write(&enc_path, b"not-valid-encrypted-data-at-all-1234567890").unwrap(); + tokio::fs::write(&enc_path, b"not-valid-encrypted-data-at-all-1234567890") + .await + .unwrap(); // Write valid plaintext credentials. let plain_json = r#"{ @@ -674,7 +678,7 @@ mod tests { "refresh_token": "fallback_refresh", "type": "authorized_user" }"#; - std::fs::write(&plain_path, plain_json).unwrap(); + tokio::fs::write(&plain_path, plain_json).await.unwrap(); let res = load_credentials_inner(None, &enc_path, &plain_path) .await