diff --git a/.changeset/fix-auth-export-multi-account.md b/.changeset/fix-auth-export-multi-account.md new file mode 100644 index 0000000..9ab45d3 --- /dev/null +++ b/.changeset/fix-auth-export-multi-account.md @@ -0,0 +1,5 @@ +--- +"@googleworkspace/cli": patch +--- + +Fix `auth export` to resolve per-account credentials instead of only checking the legacy `credentials.enc` path diff --git a/src/auth.rs b/src/auth.rs index 97a1839..d583b2c 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -126,7 +126,7 @@ pub async fn get_token(scopes: &[&str], account: Option<&str>) -> anyhow::Result /// 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. -fn resolve_account(account: Option<&str>) -> anyhow::Result> { +pub(crate) fn resolve_account(account: Option<&str>) -> anyhow::Result> { let registry = crate::accounts::load_accounts()?; match (account, ®istry) { diff --git a/src/auth_commands.rs b/src/auth_commands.rs index c9b12e1..482a533 100644 --- a/src/auth_commands.rs +++ b/src/auth_commands.rs @@ -129,7 +129,10 @@ fn token_cache_path() -> PathBuf { } /// Handle `gws auth `. -pub async fn handle_auth_command(args: &[String]) -> Result<(), GwsError> { +pub async fn handle_auth_command( + args: &[String], + global_account: Option<&str>, +) -> Result<(), GwsError> { const USAGE: &str = concat!( "Usage: gws auth [options]\n\n", " login Authenticate via OAuth2 (opens browser)\n", @@ -144,6 +147,8 @@ pub async fn handle_auth_command(args: &[String]) -> Result<(), GwsError> { " --project Use a specific GCP project\n", " status Show current authentication state\n", " export Print decrypted credentials to stdout\n", + " --account EMAIL Export a specific account's credentials\n", + " --unmasked Show secrets without masking\n", " logout Clear saved credentials and token cache\n", " --account EMAIL Logout a specific account (otherwise: all)\n", " list List all registered accounts\n", @@ -161,10 +166,7 @@ pub async fn handle_auth_command(args: &[String]) -> Result<(), GwsError> { "login" => handle_login(&args[1..]).await, "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 - } + "export" => handle_export(&args[1..], global_account).await, "logout" => handle_logout(&args[1..]), "list" => handle_list(), "default" => handle_default(&args[1..]), @@ -463,15 +465,53 @@ 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(args: &[String], global_account: Option<&str>) -> Result<(), GwsError> { + // Parse --unmasked and --account from args + let mut unmasked = false; + let mut local_account: Option = None; + let mut args_iter = args.iter().peekable(); + while let Some(arg) = args_iter.next() { + match arg.as_str() { + "--unmasked" => unmasked = true, + "--account" => match args_iter.peek() { + Some(val) if !val.starts_with('-') => { + local_account = Some(args_iter.next().unwrap().clone()); + } + _ => { + return Err(GwsError::Validation( + "The --account flag requires a value.".to_string(), + )); + } + }, + _ => { + if let Some(value) = arg.strip_prefix("--account=") { + local_account = Some(value.to_string()); + } else { + return Err(GwsError::Validation(format!( + "Unknown argument for export: '{arg}'" + ))); + } + } + } + } + + // Resolve account: local --account > global --account > default account + let account = local_account.as_deref().or(global_account); + 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}"); @@ -1740,7 +1780,7 @@ mod tests { #[tokio::test] async fn handle_auth_command_empty_args_prints_usage() { let args: Vec = vec![]; - let result = handle_auth_command(&args).await; + let result = handle_auth_command(&args, None).await; // Empty args now prints usage and returns Ok assert!(result.is_ok()); } @@ -1748,21 +1788,21 @@ mod tests { #[tokio::test] async fn handle_auth_command_help_flag_returns_ok() { let args = vec!["--help".to_string()]; - let result = handle_auth_command(&args).await; + let result = handle_auth_command(&args, None).await; assert!(result.is_ok()); } #[tokio::test] async fn handle_auth_command_help_short_flag_returns_ok() { let args = vec!["-h".to_string()]; - let result = handle_auth_command(&args).await; + let result = handle_auth_command(&args, None).await; assert!(result.is_ok()); } #[tokio::test] async fn handle_auth_command_invalid_subcommand() { let args = vec!["frobnicate".to_string()]; - let result = handle_auth_command(&args).await; + let result = handle_auth_command(&args, None).await; assert!(result.is_err()); match result.unwrap_err() { GwsError::Validation(msg) => assert!(msg.contains("frobnicate")), @@ -1815,7 +1855,7 @@ mod tests { async fn handle_status_succeeds_without_credentials() { // status should always succeed and report "none" let args = vec!["status".to_string()]; - let result = handle_auth_command(&args).await; + let result = handle_auth_command(&args, None).await; assert!(result.is_ok()); } @@ -2184,4 +2224,77 @@ mod tests { // Exactly 9 chars — first 4 + last 4 with "..." in between assert_eq!(mask_secret("123456789"), "1234...6789"); } + + #[tokio::test] + async fn handle_export_nonexistent_account_returns_auth_error() { + // Requesting a non-existent account should always fail + let args = vec![ + "--account".to_string(), + "nonexistent@example.com".to_string(), + ]; + let result = handle_export(&args, None).await; + assert!(result.is_err()); + match result.unwrap_err() { + GwsError::Auth(_) => {} // expected + other => panic!("Expected Auth error, got: {other:?}"), + } + } + + #[tokio::test] + async fn handle_export_global_account_nonexistent_returns_auth_error() { + // Global --account with non-existent email should fail + let args: Vec = vec![]; + let result = handle_export(&args, Some("nonexistent@example.com")).await; + assert!(result.is_err()); + match result.unwrap_err() { + GwsError::Auth(_) => {} // expected + other => panic!("Expected Auth error, got: {other:?}"), + } + } + + #[tokio::test] + async fn handle_export_dispatch_nonexistent_account() { + // Verify the dispatch path passes global_account through + let args = vec!["export".to_string()]; + let result = handle_auth_command(&args, Some("nonexistent@example.com")).await; + assert!(result.is_err()); + match result.unwrap_err() { + GwsError::Auth(_) => {} // expected + other => panic!("Expected Auth error, got: {other:?}"), + } + } + + #[tokio::test] + async fn handle_export_unknown_arg_returns_validation_error() { + let args = vec!["--unmask".to_string()]; + let result = handle_export(&args, None).await; + assert!(result.is_err()); + match result.unwrap_err() { + GwsError::Validation(msg) => assert!(msg.contains("--unmask")), + other => panic!("Expected Validation error, got: {other:?}"), + } + } + + #[tokio::test] + async fn handle_export_account_missing_value_returns_validation_error() { + let args = vec!["--account".to_string()]; + let result = handle_export(&args, None).await; + assert!(result.is_err()); + match result.unwrap_err() { + GwsError::Validation(msg) => assert!(msg.contains("requires a value")), + other => panic!("Expected Validation error, got: {other:?}"), + } + } + + #[tokio::test] + async fn handle_export_account_flag_as_value_returns_validation_error() { + // --account followed by another flag should not treat the flag as a value + let args = vec!["--account".to_string(), "--unmasked".to_string()]; + let result = handle_export(&args, None).await; + assert!(result.is_err()); + match result.unwrap_err() { + GwsError::Validation(msg) => assert!(msg.contains("requires a value")), + other => panic!("Expected Validation error, got: {other:?}"), + } + } } diff --git a/src/main.rs b/src/main.rs index 6940238..7c5dc66 100644 --- a/src/main.rs +++ b/src/main.rs @@ -134,7 +134,7 @@ async fn run() -> Result<(), GwsError> { // Handle the `auth` command if first_arg == "auth" { let auth_args: Vec = args.iter().skip(2).cloned().collect(); - return auth_commands::handle_auth_command(&auth_args).await; + return auth_commands::handle_auth_command(&auth_args, account.as_deref()).await; } // Handle the `mcp` command