Skip to content

feat(auth): auto-migrate legacy credentials.enc to per-account format#239

Open
jpoehnelt wants to merge 3 commits intomainfrom
fix/legacy-credential-migration
Open

feat(auth): auto-migrate legacy credentials.enc to per-account format#239
jpoehnelt wants to merge 3 commits intomainfrom
fix/legacy-credential-migration

Conversation

@jpoehnelt
Copy link
Member

Summary

Fixes #232

When users who authenticated with an older version of gws run the updated CLI, their legacy credentials.enc file is automatically migrated to the per-account format:

[gws] Migrating legacy credentials to per-account format...
[gws] Found account: user@gmail.com
[gws] ✓ Migrated credentials for user@gmail.com. Backup at ~/.config/gws/credentials.enc.bak

What changed

  1. src/auth.rs: Added migrate_legacy_credentials() that runs once before account resolution:

    • Decrypts legacy credentials.enc
    • Gets an access token and calls Google userinfo to determine the email
    • Saves as credentials.<b64-email>.enc
    • Creates accounts.json with the account as default
    • Renames legacy file to .bak
    • On failure (offline, invalid creds), prints a warning and leaves the file
  2. src/auth.rs: Removed the None → legacy credentials.enc fallback in resolve_account(). After migration, all credential resolution goes through the accounts registry or ADC.

  3. src/auth_commands.rs: Made fetch_userinfo_email pub(crate) so the migration function can use it.

Migration is safe

  • Offline/failure: Prints a warning, leaves legacy file in place. User can re-run gws auth login.
  • Backup: Legacy file is renamed to .bak, not deleted.
  • Once only: Uses std::sync::Once so migration runs at most once per process.
  • No registry exists yet: Only triggers when accounts.json doesn't exist.

Test plan

  • All 462 existing tests pass
  • cargo clippy -- -D warnings clean
  • CI

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-bot
Copy link

changeset-bot bot commented Mar 6, 2026

🦋 Changeset detected

Latest commit: 9604715

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@googleworkspace/cli Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@gemini-code-assist
Copy link
Contributor

Summary of Changes

Hello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request introduces a significant improvement to the gws CLI's authentication system by implementing an automatic migration process for legacy user credentials. It seamlessly transitions users from a single, older credentials.enc file to a more robust, per-account credential management system, enhancing security and maintainability without requiring manual user intervention. This change streamlines credential handling and prepares the CLI for future multi-account features.

Highlights

  • Automatic Legacy Credential Migration: A new migrate_legacy_credentials() function was added to src/auth.rs to automatically migrate old credentials.enc files to the new per-account format. This function decrypts the legacy file, determines the user's email via Google userinfo, saves the credentials as credentials.<b64-email>.enc, creates an accounts.json registry, and renames the original file to .bak. Error handling is included to print warnings and leave the legacy file in place on failure.
  • Credential Resolution Simplification: The resolve_account() function in src/auth.rs was updated to remove the fallback mechanism for None accounts to credentials.enc. After migration, all credential resolution now explicitly goes through the accounts registry or Application Default Credentials (ADC).
  • Function Visibility Adjustment: The fetch_userinfo_email function in src/auth_commands.rs was changed to pub(crate) visibility, allowing it to be used by the new credential migration logic within the auth module.
Changelog
  • .changeset/migrate-legacy-credentials.md
    • Added a new changeset entry documenting the automatic migration of legacy credentials.enc to a per-account format.
    • Described the steps involved in the migration process, including decryption, email determination, re-saving, account registration, and file renaming.
    • Noted the removal of the legacy credentials.enc fallback path.
  • src/auth.rs
    • Introduced the migrate_legacy_credentials asynchronous function to handle the automatic migration of old credentials.enc files to the new per-account format.
    • Modified the get_token function to call migrate_legacy_credentials before resolving accounts, ensuring migration occurs on first run.
    • Updated the resolve_account function to remove the fallback to credentials.enc when no specific account is resolved, directing credential resolution through the accounts registry or ADC.
    • Adjusted the enc_path determination for environment variable credentials to use a non-existent path, preventing unintended legacy file interactions.
  • src/auth_commands.rs
    • Changed the visibility of the fetch_userinfo_email function from private (fn) to pub(crate) to allow it to be called by the new migrate_legacy_credentials function within the auth module.
Activity
  • All 462 existing tests are passing.
  • cargo clippy -- -D warnings is clean.
  • CI is pending.
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for Github and other Google products, sign up here.

You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension.

Footnotes

  1. Review the Generative AI Prohibited Use Policy, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

@googleworkspace-bot googleworkspace-bot added the cla: yes This human has signed the Contributor License Agreement. label Mar 6, 2026
@codecov
Copy link

codecov bot commented Mar 6, 2026

Codecov Report

❌ Patch coverage is 1.90476% with 103 lines in your changes missing coverage. Please review.
✅ Project coverage is 57.29%. Comparing base (f07dd2b) to head (9604715).
⚠️ Report is 2 commits behind head on main.

Files with missing lines Patch % Lines
src/auth.rs 1.92% 102 Missing ⚠️
src/auth_commands.rs 0.00% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #239      +/-   ##
==========================================
- Coverage   57.69%   57.29%   -0.41%     
==========================================
  Files          38       38              
  Lines       14328    14430     +102     
==========================================
  Hits         8267     8267              
- Misses       6061     6163     +102     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Copy link
Contributor

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces an automatic migration for legacy credentials, which is a great enhancement for user experience. The implementation is thorough, with good error handling and user feedback. I've identified two areas for improvement in src/auth.rs:

  1. The logic for ensuring the migration runs only once per process is complex and involves duplicated code. I've suggested a refactoring to a more standard pattern using tokio::sync::Mutex which is cleaner and easier to maintain.
  2. The backup mechanism for the legacy credentials file doesn't account for pre-existing backup files on Windows, which could cause the rename operation to fail. I've suggested a fix to make this more robust.

Overall, these are solid changes that improve the CLI's usability.

@googleworkspace-bot googleworkspace-bot added the gemini: reviewed Gemini Code Assist has reviewed the latest changes label Mar 6, 2026
…ckup

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.
@googleworkspace-bot googleworkspace-bot removed the gemini: reviewed Gemini Code Assist has reviewed the latest changes label Mar 6, 2026
@googleworkspace-bot
Copy link
Collaborator

/gemini review

Copy link
Contributor

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a helpful feature to automatically migrate legacy credentials to the new per-account format. The implementation is comprehensive, covering various steps from decryption to creating backups. However, I've identified some critical issues. The new migration function uses blocking file I/O within an async context, which can cause performance problems. Additionally, the JSON parsing for legacy credentials is not robust and could lead to panics. I've also suggested refactoring the large migration function to improve maintainability and testability. Addressing these points will make the migration process more reliable and robust.

src/auth.rs Outdated
Comment on lines +321 to +328
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()
);
}
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

This code uses the blocking std::fs::remove_file and backup_path.exists() inside an async function. Synchronous I/O operations can block the thread of the async runtime, leading to performance issues or even deadlocks in a concurrent application.

You should use the asynchronous equivalents from tokio::fs. This issue is present for other file operations within migrate_legacy_credentials as well:

  • credential_store::load_encrypted() (line 223, uses std::fs::read)
  • credential_store::save_encrypted_for() (line 304, uses blocking I/O)
  • crate::accounts::save_accounts() (line 313, uses blocking I/O)
  • std::fs::rename() (line 329)

All these blocking calls should be replaced with their tokio::fs counterparts.

Suggested change
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 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()
);
}
}

Comment on lines +189 to 339
async fn migrate_legacy_credentials() {
use std::sync::atomic::{AtomicBool, Ordering};
use tokio::sync::Mutex;

static MIGRATION_LOCK: Mutex<()> = Mutex::const_new(());
static MIGRATION_ATTEMPTED: AtomicBool = AtomicBool::new(false);

// Quick, non-locking check to bail out early if migration has already been handled.
if MIGRATION_ATTEMPTED.load(Ordering::Relaxed) {
return;
}

// 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();

// Only migrate if legacy file exists AND no accounts registry exists
if !legacy_path.exists() || 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(&registry) {
eprintln!("[gws] Warning: Failed to save accounts registry: {e}");
return;
}

// 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
}

eprintln!(
"[gws] ✓ Migrated credentials for {}. Backup at {}",
email,
backup_path.display()
);
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The migrate_legacy_credentials function is over 150 lines long and handles many different concerns: concurrency control, file I/O, decryption, JSON parsing, network requests, and state updates. This high complexity makes the function difficult to read, test, and maintain.

Consider refactoring this logic into several smaller, more focused functions. For example:

  • A function to decrypt and parse the legacy credentials, returning a Result.
  • A function to fetch the user's email, given the credentials.
  • A function to save the new credentials and update the accounts registry.
  • A function to handle file cleanup.

This would improve modularity and allow for more granular error handling and unit testing.

src/auth.rs Outdated
Comment on lines +241 to +243
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();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Accessing JSON fields by index (e.g., creds["client_id"]) will panic if the key is missing. Using as_str() followed by unwrap_or_default() will also panic if the value is present but not a string (e.g., null or a number). This makes the migration process fragile. For more robust parsing, you should use get() and and_then() to safely access and convert the values.

Suggested change
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();

@googleworkspace-bot googleworkspace-bot added the gemini: reviewed Gemini Code Assist has reviewed the latest changes label Mar 6, 2026
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.
@googleworkspace-bot googleworkspace-bot removed the gemini: reviewed Gemini Code Assist has reviewed the latest changes label Mar 6, 2026
@googleworkspace-bot
Copy link
Collaborator

/gemini review

Copy link
Contributor

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces an automatic migration for legacy credentials to a new per-account format. The changes are well-structured, and the migration logic correctly handles backups and potential failures. However, I've identified a potential inter-process race condition in the migration logic that could occur if multiple instances of the CLI are run concurrently. My feedback focuses on addressing this to make the migration process more robust.

///
/// 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() {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

There is a potential inter-process race condition here. If two gws commands are run simultaneously, both processes could attempt the migration concurrently. The current locking mechanism (Mutex and AtomicBool) only prevents race conditions within a single process.

While this may not lead to data corruption in this specific case (as both processes would be writing the same data), it can cause confusing output for the user and result in redundant API calls and file operations. The second process to finish will likely fail to rename the legacy credentials file, adding to the confusion.

To prevent this, you should implement an inter-process locking mechanism, such as a file lock. A common strategy is to atomically create a lock file (e.g., ~/.config/gws/.migration.lock) at the beginning of the migration process and remove it upon completion or failure.

For example, you could use tokio::fs::OpenOptions::new().create_new(true) to attempt to create the lock file. If it succeeds, this process has the lock. If it fails with AlreadyExists, another process holds the lock, and this process should wait or exit.

@googleworkspace-bot googleworkspace-bot added the gemini: reviewed Gemini Code Assist has reviewed the latest changes label Mar 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area: auth cla: yes This human has signed the Contributor License Agreement. gemini: reviewed Gemini Code Assist has reviewed the latest changes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Auto-migrate legacy credentials.enc to per-account format and remove fallback paths

3 participants