From d1f6d0bb4dfcd1e4c4b5eb98796210394ca1cf03 Mon Sep 17 00:00:00 2001 From: jpoehnelt-bot Date: Thu, 5 Mar 2026 18:34:11 -0700 Subject: [PATCH 1/3] feat(auth): auto-migrate legacy credentials.enc to per-account format Fixes #232 - Add migrate_legacy_credentials() that runs once on startup - Decrypts legacy file, resolves email via userinfo, re-saves per-account - Creates accounts.json with migrated account as default - Renames legacy file to .bak - Remove legacy credentials.enc fallback from resolve_account - Gracefully handles offline/failure with warnings --- .changeset/migrate-legacy-credentials.md | 12 ++ src/auth.rs | 174 +++++++++++++++++++++-- src/auth_commands.rs | 2 +- 3 files changed, 179 insertions(+), 9 deletions(-) create mode 100644 .changeset/migrate-legacy-credentials.md diff --git a/.changeset/migrate-legacy-credentials.md b/.changeset/migrate-legacy-credentials.md new file mode 100644 index 0000000..e54ab13 --- /dev/null +++ b/.changeset/migrate-legacy-credentials.md @@ -0,0 +1,12 @@ +--- +"@googleworkspace/cli": minor +--- + +Auto-migrate legacy `credentials.enc` to per-account format on first run. When a legacy credential file exists without an `accounts.json` registry, gws now automatically: +1. Decrypts the legacy credentials +2. Determines the account email via Google userinfo +3. Re-saves as `credentials..enc` +4. Creates `accounts.json` with the account as default +5. Renames the legacy file to `credentials.enc.bak` + +Also removes the legacy `credentials.enc` fallback path — all credential resolution now goes through the accounts registry or ADC. diff --git a/src/auth.rs b/src/auth.rs index 97a1839..7c5f234 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -86,19 +86,27 @@ pub async fn get_token(scopes: &[&str], account: Option<&str>) -> anyhow::Result // If env var credentials are specified, skip account resolution entirely if creds_file.is_some() { - 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"); + // When using env var creds, we don't need account-specific paths + let enc_path = PathBuf::from("/nonexistent"); let creds = load_credentials_inner(creds_file.as_deref(), &enc_path, &default_path).await?; return get_token_inner(scopes, creds, &token_cache, impersonated_user.as_deref()).await; } + // Auto-migrate legacy credentials.enc if present and no accounts.json exists + migrate_legacy_credentials().await; + // Resolve account from registry let resolved_account = resolve_account(account)?; let enc_path = match &resolved_account { Some(email) => credential_store::encrypted_credentials_path_for(email), - None => credential_store::encrypted_credentials_path(), + None => { + // No account resolved — no legacy fallback, just use a non-existent path + // so load_credentials_inner falls through to ADC/plaintext + config_dir.join("credentials.nonexistent.enc") + } }; // Per-account token cache: token_cache..json @@ -125,7 +133,7 @@ pub async fn get_token(scopes: &[&str], account: Option<&str>) -> anyhow::Result /// Resolve which account to use: /// 1. Explicit `account` parameter takes priority. /// 2. Fall back to `accounts.json` default. -/// 3. If no registry exists, return None to allow legacy `credentials.enc` fallthrough. +/// 3. If no registry exists, return None (caller falls through to ADC/plaintext). fn resolve_account(account: Option<&str>) -> anyhow::Result> { let registry = crate::accounts::load_accounts()?; @@ -161,13 +169,163 @@ fn resolve_account(account: Option<&str>) -> anyhow::Result> { ); } } - // No account, no registry — use legacy credentials if they exist - (None, None) => { - // Fall through to standard credential loading which will pick up - // the legacy credentials.enc file if it exists. - Ok(None) + // No account, no registry — no credentials to resolve + (None, None) => Ok(None), + } +} + +/// Auto-migrate legacy `credentials.enc` to the per-account format. +/// +/// If `credentials.enc` exists and no `accounts.json` registry has been created +/// yet, this function: +/// 1. Decrypts the legacy file +/// 2. Obtains an access token to determine the email via Google tokeninfo +/// 3. Saves as `credentials..enc` +/// 4. Registers the account in `accounts.json` as default +/// 5. Renames `credentials.enc` → `credentials.enc.bak` +/// +/// On failure (e.g. offline, can't determine email), prints a warning and +/// leaves the legacy file in place — the user can manually re-run `gws auth login`. +async fn migrate_legacy_credentials() { + use std::sync::Once; + static MIGRATED: Once = Once::new(); + + let mut did_work = false; + MIGRATED.call_once(|| { + let legacy_path = credential_store::encrypted_credentials_path(); + let registry = crate::accounts::load_accounts().ok().flatten(); + + // Only migrate if legacy file exists AND no accounts registry exists + if !legacy_path.exists() || registry.is_some() { + return; + } + did_work = true; + }); + + if !did_work { + return; + } + + // Do the actual async migration work outside call_once + let legacy_path = credential_store::encrypted_credentials_path(); + if !legacy_path.exists() { + return; + } + let registry = crate::accounts::load_accounts().ok().flatten(); + if registry.is_some() { + return; + } + + eprintln!("[gws] Migrating legacy credentials to per-account format..."); + + // Decrypt the legacy credentials + let json_str = match credential_store::load_encrypted() { + Ok(s) => s, + Err(e) => { + eprintln!("[gws] Warning: Failed to decrypt legacy credentials: {e}"); + eprintln!("[gws] Run 'gws auth login' to re-authenticate."); + return; + } + }; + + // Parse credentials to get refresh_token + let creds: serde_json::Value = match serde_json::from_str(&json_str) { + Ok(v) => v, + Err(e) => { + eprintln!("[gws] Warning: Failed to parse legacy credentials: {e}"); + return; + } + }; + + let client_id = creds["client_id"].as_str().unwrap_or_default(); + let client_secret = creds["client_secret"].as_str().unwrap_or_default(); + let refresh_token = creds["refresh_token"].as_str().unwrap_or_default(); + + if client_id.is_empty() || client_secret.is_empty() || refresh_token.is_empty() { + eprintln!("[gws] Warning: Legacy credentials are incomplete, cannot migrate."); + eprintln!("[gws] Run 'gws auth login' to re-authenticate."); + return; + } + + // Get an access token to determine the email + let secret = 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(), + }; + + let auth = match yup_oauth2::AuthorizedUserAuthenticator::builder(secret) + .build() + .await + { + Ok(a) => a, + Err(e) => { + eprintln!("[gws] Warning: Failed to build authenticator for migration: {e}"); + eprintln!("[gws] Run 'gws auth login' to re-authenticate."); + return; + } + }; + + let token = match auth + .token(&["https://www.googleapis.com/auth/userinfo.email"]) + .await + { + Ok(t) => t, + Err(e) => { + eprintln!("[gws] Warning: Failed to get token for migration: {e}"); + eprintln!("[gws] Run 'gws auth login' to re-authenticate."); + return; + } + }; + + let access_token = match token.token() { + Some(t) => t.to_string(), + None => { + eprintln!("[gws] Warning: No access token available for migration."); + return; } + }; + + // Get email via tokeninfo + let email = match crate::auth_commands::fetch_userinfo_email(&access_token).await { + Some(e) => e, + None => { + eprintln!("[gws] Warning: Could not determine email from legacy credentials."); + eprintln!("[gws] Run 'gws auth login' to re-authenticate."); + return; + } + }; + + eprintln!("[gws] Found account: {email}"); + + // Save as per-account credentials + if let Err(e) = credential_store::save_encrypted_for(&json_str, &email) { + eprintln!("[gws] Warning: Failed to save migrated credentials: {e}"); + return; } + + // Register in accounts.json using the existing helper + let mut registry = crate::accounts::AccountsRegistry::default(); + crate::accounts::add_account(&mut registry, &email); + + if let Err(e) = crate::accounts::save_accounts(®istry) { + eprintln!("[gws] Warning: Failed to save accounts registry: {e}"); + return; + } + + // Rename legacy file to .bak + let backup_path = legacy_path.with_extension("enc.bak"); + if let Err(e) = std::fs::rename(&legacy_path, &backup_path) { + eprintln!("[gws] Warning: Failed to rename legacy credentials: {e}"); + // Still succeeded in migration, just couldn't clean up + } + + eprintln!( + "[gws] ✓ Migrated credentials for {}. Backup at {}", + email, + backup_path.display() + ); } async fn get_token_inner( diff --git a/src/auth_commands.rs b/src/auth_commands.rs index c9b12e1..e0ee2ac 100644 --- a/src/auth_commands.rs +++ b/src/auth_commands.rs @@ -443,7 +443,7 @@ async fn handle_login(args: &[String]) -> Result<(), GwsError> { } /// Fetch the authenticated user's email from Google's userinfo endpoint. -async fn fetch_userinfo_email(access_token: &str) -> Option { +pub(crate) async fn fetch_userinfo_email(access_token: &str) -> Option { let client = match crate::client::build_client() { Ok(c) => c, Err(_) => return None, From 69253d6f0e142a13465f7c56311befe6dc975313 Mon Sep 17 00:00:00 2001 From: jpoehnelt-bot Date: Thu, 5 Mar 2026 18:56:05 -0700 Subject: [PATCH 2/3] refactor: use AtomicBool+Mutex for migration guard, handle Windows backup Addresses review comments: - Replace std::sync::Once with AtomicBool + tokio::sync::Mutex to eliminate duplicated precondition checks and simplify concurrency logic for the async migration function. - Remove existing .bak file before rename to prevent failures on Windows where rename fails if destination already exists. --- src/auth.rs | 46 ++++++++++++++++++++++++++++------------------ 1 file changed, 28 insertions(+), 18 deletions(-) diff --git a/src/auth.rs b/src/auth.rs index 7c5f234..fc4bbd4 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -187,32 +187,33 @@ fn resolve_account(account: Option<&str>) -> anyhow::Result> { /// On failure (e.g. offline, can't determine email), prints a warning and /// leaves the legacy file in place — the user can manually re-run `gws auth login`. async fn migrate_legacy_credentials() { - use std::sync::Once; - static MIGRATED: Once = Once::new(); + use std::sync::atomic::{AtomicBool, Ordering}; + use tokio::sync::Mutex; - let mut did_work = false; - MIGRATED.call_once(|| { - let legacy_path = credential_store::encrypted_credentials_path(); - let registry = crate::accounts::load_accounts().ok().flatten(); + static MIGRATION_LOCK: Mutex<()> = Mutex::const_new(()); + static MIGRATION_ATTEMPTED: AtomicBool = AtomicBool::new(false); - // Only migrate if legacy file exists AND no accounts registry exists - if !legacy_path.exists() || registry.is_some() { - return; - } - did_work = true; - }); - - if !did_work { + // Quick, non-locking check to bail out early if migration has already been handled. + if MIGRATION_ATTEMPTED.load(Ordering::Relaxed) { return; } - // Do the actual async migration work outside call_once - let legacy_path = credential_store::encrypted_credentials_path(); - if !legacy_path.exists() { + // Acquire a lock to ensure only one task performs the detailed check and migration. + let _guard = MIGRATION_LOCK.lock().await; + + // Re-check after acquiring the lock, in case another task just finished. + if MIGRATION_ATTEMPTED.load(Ordering::SeqCst) { return; } + + // Mark as attempted before the checks, so we only ever try this logic once per process. + MIGRATION_ATTEMPTED.store(true, Ordering::SeqCst); + + let legacy_path = credential_store::encrypted_credentials_path(); let registry = crate::accounts::load_accounts().ok().flatten(); - if registry.is_some() { + + // Only migrate if legacy file exists AND no accounts registry exists + if !legacy_path.exists() || registry.is_some() { return; } @@ -315,7 +316,16 @@ async fn migrate_legacy_credentials() { } // Rename legacy file to .bak + // On Windows, `rename` fails if the destination exists. Remove old backup first. let backup_path = legacy_path.with_extension("enc.bak"); + if backup_path.exists() { + if let Err(e) = std::fs::remove_file(&backup_path) { + eprintln!( + "[gws] Warning: Failed to remove existing backup file '{}': {e}", + backup_path.display() + ); + } + } if let Err(e) = std::fs::rename(&legacy_path, &backup_path) { eprintln!("[gws] Warning: Failed to rename legacy credentials: {e}"); // Still succeeded in migration, just couldn't clean up From 960471531d836fefd98d8c5c286e02a640823691 Mon Sep 17 00:00:00 2001 From: jpoehnelt-bot Date: Thu, 5 Mar 2026 19:01:48 -0700 Subject: [PATCH 3/3] fix: safe JSON field access and async file I/O for migration Addresses review comments: - Use creds.get().and_then(as_str) instead of index operator to prevent potential panics on missing or non-string JSON values. - Use tokio::fs for rename/remove operations to avoid blocking the async runtime during backup file cleanup. --- src/auth.rs | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/src/auth.rs b/src/auth.rs index fc4bbd4..7b48e72 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -238,9 +238,18 @@ async fn migrate_legacy_credentials() { } }; - let client_id = creds["client_id"].as_str().unwrap_or_default(); - let client_secret = creds["client_secret"].as_str().unwrap_or_default(); - let refresh_token = creds["refresh_token"].as_str().unwrap_or_default(); + let client_id = creds + .get("client_id") + .and_then(|v| v.as_str()) + .unwrap_or_default(); + let client_secret = creds + .get("client_secret") + .and_then(|v| v.as_str()) + .unwrap_or_default(); + let refresh_token = creds + .get("refresh_token") + .and_then(|v| v.as_str()) + .unwrap_or_default(); if client_id.is_empty() || client_secret.is_empty() || refresh_token.is_empty() { eprintln!("[gws] Warning: Legacy credentials are incomplete, cannot migrate."); @@ -318,15 +327,15 @@ async fn migrate_legacy_credentials() { // Rename legacy file to .bak // On Windows, `rename` fails if the destination exists. Remove old backup first. let backup_path = legacy_path.with_extension("enc.bak"); - if backup_path.exists() { - if let Err(e) = std::fs::remove_file(&backup_path) { + if tokio::fs::metadata(&backup_path).await.is_ok() { + if let Err(e) = tokio::fs::remove_file(&backup_path).await { eprintln!( "[gws] Warning: Failed to remove existing backup file '{}': {e}", backup_path.display() ); } } - if let Err(e) = std::fs::rename(&legacy_path, &backup_path) { + if let Err(e) = tokio::fs::rename(&legacy_path, &backup_path).await { eprintln!("[gws] Warning: Failed to rename legacy credentials: {e}"); // Still succeeded in migration, just couldn't clean up }