Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/fix-auth-legacy-credentials.md
Original file line number Diff line number Diff line change
@@ -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
45 changes: 29 additions & 16 deletions src/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Option<String>> {
pub fn resolve_account(account: Option<&str>) -> anyhow::Result<Option<String>> {
let registry = crate::accounts::load_accounts()?;

match (account, &registry) {
Expand Down Expand Up @@ -131,20 +132,8 @@ fn resolve_account(account: Option<&str>) -> anyhow::Result<Option<String>> {
);
}
}
// 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),
}
}

Expand Down Expand Up @@ -454,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}"
);
}
}
87 changes: 81 additions & 6 deletions src/auth_commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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(),
Expand Down Expand Up @@ -423,15 +431,23 @@ async fn fetch_userinfo_email(access_token: &str) -> Option<String> {
.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}");
Expand Down Expand Up @@ -1765,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:?}"),
}
}
}
8 changes: 6 additions & 2 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down