From da992de9f28ba5a3f91d5239718e15bc414ff3c2 Mon Sep 17 00:00:00 2001 From: iamrajiv Date: Thu, 5 Mar 2026 13:48:52 +0530 Subject: [PATCH 1/3] fix: enhance account resolution and export command handling --- src/auth.rs | 21 +++++---------------- src/auth_commands.rs | 28 ++++++++++++++++++++++------ src/main.rs | 8 ++++++-- 3 files changed, 33 insertions(+), 24 deletions(-) diff --git a/src/auth.rs b/src/auth.rs index d895f26..673bbc8 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -94,9 +94,10 @@ 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 but legacy `credentials.enc` exists, fail with upgrade message. +/// 3. If no registry exists but legacy `credentials.enc` exists, return None +/// so the caller falls back to the legacy credential path. /// 4. If nothing exists, return None (will fall through to standard error). -fn resolve_account(account: Option<&str>) -> anyhow::Result> { +pub fn resolve_account(account: Option<&str>) -> anyhow::Result> { let registry = crate::accounts::load_accounts()?; match (account, ®istry) { @@ -131,20 +132,8 @@ fn resolve_account(account: Option<&str>) -> anyhow::Result> { ); } } - // No account, no registry — check for legacy credentials - (None, None) => { - let legacy_path = credential_store::encrypted_credentials_path(); - if legacy_path.exists() { - anyhow::bail!( - "Legacy credentials found at {}. \ - gws now supports multiple accounts. \ - Please run 'gws auth login' to upgrade your credentials.", - legacy_path.display() - ); - } - // No registry, no legacy — fall through to standard credential loading - Ok(None) - } + // No account, no registry — fall through to legacy credential path + (None, None) => Ok(None), } } diff --git a/src/auth_commands.rs b/src/auth_commands.rs index d559d94..62abfc3 100644 --- a/src/auth_commands.rs +++ b/src/auth_commands.rs @@ -141,7 +141,9 @@ pub async fn handle_auth_command(args: &[String]) -> Result<(), GwsError> { " setup Configure GCP project + OAuth client (requires gcloud)\n", " --project Use a specific GCP project\n", " status Show current authentication state\n", - " export Print decrypted credentials to stdout\n", + " export Print decrypted credentials to stdout (masked by default)\n", + " --unmasked Show full unmasked credential values\n", + " --account EMAIL Export credentials for a specific account\n", " logout Clear saved credentials and token cache\n", " --account EMAIL Logout a specific account (otherwise: all)\n", " list List all registered accounts\n", @@ -160,8 +162,14 @@ pub async fn handle_auth_command(args: &[String]) -> Result<(), GwsError> { "setup" => crate::setup::run_setup(&args[1..]).await, "status" => handle_status().await, "export" => { - let unmasked = args.len() > 1 && args[1] == "--unmasked"; - handle_export(unmasked).await + let sub = &args[1..]; + let unmasked = sub.iter().any(|a| a == "--unmasked"); + let account = sub + .iter() + .position(|a| a == "--account") + .and_then(|i| sub.get(i + 1)) + .map(|s| s.as_str()); + handle_export(unmasked, account).await } "logout" => handle_logout(&args[1..]), "list" => handle_list(), @@ -423,15 +431,23 @@ async fn fetch_userinfo_email(access_token: &str) -> Option { .map(|s| s.to_string()) } -async fn handle_export(unmasked: bool) -> Result<(), GwsError> { - let enc_path = credential_store::encrypted_credentials_path(); +async fn handle_export(unmasked: bool, account: Option<&str>) -> Result<(), GwsError> { + // Resolve account: explicit flag → registry default → legacy path + let resolved = crate::auth::resolve_account(account) + .map_err(|e| GwsError::Auth(e.to_string()))?; + + let enc_path = match &resolved { + Some(email) => credential_store::encrypted_credentials_path_for(email), + None => credential_store::encrypted_credentials_path(), + }; + if !enc_path.exists() { return Err(GwsError::Auth( "No encrypted credentials found. Run 'gws auth login' first.".to_string(), )); } - match credential_store::load_encrypted() { + match credential_store::load_encrypted_from_path(&enc_path) { Ok(contents) => { if unmasked { println!("{contents}"); diff --git a/src/main.rs b/src/main.rs index 4497731..d24f459 100644 --- a/src/main.rs +++ b/src/main.rs @@ -235,10 +235,14 @@ async fn run() -> Result<(), GwsError> { // Get scopes from the method let scopes: Vec<&str> = method.scopes.iter().map(|s| s.as_str()).collect(); - // Authenticate: try OAuth, otherwise proceed unauthenticated + // Authenticate: try OAuth, otherwise report the error so users can diagnose let (token, auth_method) = match auth::get_token(&scopes, account.as_deref()).await { Ok(t) => (Some(t), executor::AuthMethod::OAuth), - Err(_) => (None, executor::AuthMethod::None), + Err(e) => { + eprintln!("Warning: failed to load credentials: {e}"); + eprintln!("Proceeding without authentication. Run `gws auth login` to authenticate."); + (None, executor::AuthMethod::None) + } }; // Execute From dcdd684081820245769f7afcbb5914b7292086c2 Mon Sep 17 00:00:00 2001 From: iamrajiv Date: Thu, 5 Mar 2026 14:08:12 +0530 Subject: [PATCH 2/3] fmt --- src/auth_commands.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/auth_commands.rs b/src/auth_commands.rs index 62abfc3..b9e004f 100644 --- a/src/auth_commands.rs +++ b/src/auth_commands.rs @@ -433,8 +433,8 @@ async fn fetch_userinfo_email(access_token: &str) -> Option { async fn handle_export(unmasked: bool, account: Option<&str>) -> Result<(), GwsError> { // Resolve account: explicit flag → registry default → legacy path - let resolved = crate::auth::resolve_account(account) - .map_err(|e| GwsError::Auth(e.to_string()))?; + let resolved = + crate::auth::resolve_account(account).map_err(|e| GwsError::Auth(e.to_string()))?; let enc_path = match &resolved { Some(email) => credential_store::encrypted_credentials_path_for(email), From e7244d4c3b85815394e78766821d59a791626fc2 Mon Sep 17 00:00:00 2001 From: iamrajiv Date: Thu, 5 Mar 2026 14:11:51 +0530 Subject: [PATCH 3/3] fix --- .changeset/fix-auth-legacy-credentials.md | 10 ++++ src/auth.rs | 24 +++++++++ src/auth_commands.rs | 59 +++++++++++++++++++++++ 3 files changed, 93 insertions(+) create mode 100644 .changeset/fix-auth-legacy-credentials.md diff --git a/.changeset/fix-auth-legacy-credentials.md b/.changeset/fix-auth-legacy-credentials.md new file mode 100644 index 0000000..374a84f --- /dev/null +++ b/.changeset/fix-auth-legacy-credentials.md @@ -0,0 +1,10 @@ +--- +"@googleworkspace/cli": patch +--- + +fix: resolve 401 errors for legacy credentials and improve auth export command + +- Fixed `resolve_account()` rejecting legacy `credentials.enc` when no account registry exists, causing silent 401 errors on all commands +- Credential loading errors are now logged to stderr instead of silently discarded +- `gws auth export` now supports `--account EMAIL` for multi-account setups +- Documented `--unmasked` and `--account` flags in export help text diff --git a/src/auth.rs b/src/auth.rs index 673bbc8..5208485 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -443,4 +443,28 @@ mod tests { .to_string() .contains("No credentials found")); } + + #[test] + #[serial_test::serial] + fn resolve_account_no_registry_no_legacy_returns_none() { + // When no accounts.json and no legacy credentials exist, returns Ok(None) + let result = resolve_account(None); + // On a CI/test machine with no gws config, this should return Ok(None). + // On a dev machine with an accounts.json, it may return Ok(Some(...)). + // Either way it must not error. + assert!(result.is_ok()); + } + + #[test] + #[serial_test::serial] + fn resolve_account_explicit_unknown_account_errors() { + // Requesting a specific account that doesn't exist should error + let result = resolve_account(Some("nonexistent@example.com")); + assert!(result.is_err()); + let msg = result.unwrap_err().to_string(); + assert!( + msg.contains("not found"), + "Expected 'not found' in error, got: {msg}" + ); + } } diff --git a/src/auth_commands.rs b/src/auth_commands.rs index b9e004f..cfda538 100644 --- a/src/auth_commands.rs +++ b/src/auth_commands.rs @@ -1781,4 +1781,63 @@ mod tests { "https://www.googleapis.com/auth/chat.messages" )); } + + // ── mask_secret tests ─────────────────────────────────────────────── + + #[test] + fn mask_secret_long_string_shows_prefix_and_suffix() { + let masked = mask_secret("abcdefghijklmnop"); + assert_eq!(masked, "abcd...mnop"); + } + + #[test] + fn mask_secret_short_string_fully_masked() { + let masked = mask_secret("abcd1234"); + assert_eq!(masked, "***"); + } + + #[test] + fn mask_secret_empty_string_fully_masked() { + let masked = mask_secret(""); + assert_eq!(masked, "***"); + } + + // ── export subcommand tests ───────────────────────────────────────── + + #[tokio::test] + async fn handle_export_subcommand_parses_unmasked_flag() { + // Verify export subcommand recognises --unmasked without crashing + // (will fail with "No encrypted credentials" which is expected) + let args = vec!["export".to_string(), "--unmasked".to_string()]; + let result = handle_auth_command(&args).await; + // Should error about missing credentials, not about arg parsing + assert!(result.is_err()); + match result.unwrap_err() { + GwsError::Auth(msg) => assert!( + msg.contains("No encrypted credentials") || msg.contains("resolve"), + "Unexpected error: {msg}" + ), + other => panic!("Expected Auth error, got: {other:?}"), + } + } + + #[tokio::test] + async fn handle_export_subcommand_parses_account_flag() { + // Verify export subcommand recognises --account without crashing + let args = vec![ + "export".to_string(), + "--account".to_string(), + "test@example.com".to_string(), + ]; + let result = handle_auth_command(&args).await; + assert!(result.is_err()); + // Should error about the account, not about arg parsing + match result.unwrap_err() { + GwsError::Auth(msg) => assert!( + msg.contains("not found") || msg.contains("No encrypted"), + "Unexpected error: {msg}" + ), + other => panic!("Expected Auth error, got: {other:?}"), + } + } }