diff --git a/crates/google-workspace-cli/src/auth_commands.rs b/crates/google-workspace-cli/src/auth_commands.rs index d7571e74..2d8ab34c 100644 --- a/crates/google-workspace-cli/src/auth_commands.rs +++ b/crates/google-workspace-cli/src/auth_commands.rs @@ -717,8 +717,7 @@ async fn handle_export(unmasked: bool) -> Result<(), GwsError> { /// Resolve OAuth client credentials from env vars or saved config file. fn resolve_client_credentials() -> Result<(String, String, Option), GwsError> { // 1. Try env vars first - let env_id = std::env::var("GOOGLE_WORKSPACE_CLI_CLIENT_ID").ok(); - let env_secret = std::env::var("GOOGLE_WORKSPACE_CLI_CLIENT_SECRET").ok(); + let (env_id, env_secret) = oauth_client_env_credentials(); if let (Some(id), Some(secret)) = (env_id, env_secret) { // Still try to load project_id from config file for the scope picker @@ -1260,6 +1259,8 @@ async fn handle_status() -> Result<(), GwsError> { } } + add_oauth_env_status_fields(&mut output, has_config); + // Show credential source by attempting actual resolution let has_token_env = std::env::var("GOOGLE_WORKSPACE_CLI_TOKEN") .ok() @@ -1272,9 +1273,8 @@ async fn handle_status() -> Result<(), GwsError> { } else { match resolve_client_credentials() { Ok((_, _, _)) => { - let has_env_id = std::env::var("GOOGLE_WORKSPACE_CLI_CLIENT_ID").is_ok(); - let has_env_secret = std::env::var("GOOGLE_WORKSPACE_CLI_CLIENT_SECRET").is_ok(); - if has_env_id && has_env_secret { + let (env_id, env_secret) = oauth_client_env_credentials(); + if env_id.is_some() && env_secret.is_some() { "environment_variables" } else { "client_secret.json" @@ -1453,6 +1453,30 @@ async fn handle_status() -> Result<(), GwsError> { Ok(()) } +fn oauth_client_env_credentials() -> (Option, Option) { + let env_client_id = std::env::var("GOOGLE_WORKSPACE_CLI_CLIENT_ID") + .ok() + .filter(|value| !value.is_empty()); + let env_client_secret = std::env::var("GOOGLE_WORKSPACE_CLI_CLIENT_SECRET") + .ok() + .filter(|value| !value.is_empty()); + (env_client_id, env_client_secret) +} + +fn add_oauth_env_status_fields(output: &mut serde_json::Value, has_config: bool) { + let (env_client_id, env_client_secret) = oauth_client_env_credentials(); + let env_client_secret_set = env_client_secret.is_some(); + + output["env_client_id_set"] = json!(env_client_id.is_some()); + output["env_client_secret_set"] = json!(env_client_secret_set); + output["env_overrides_client_config"] = + json!(has_config && env_client_id.is_some() && env_client_secret_set); + + if let Some(client_id) = env_client_id { + output["env_client_id"] = json!(mask_secret(&client_id)); + } +} + fn handle_logout() -> Result<(), GwsError> { let plain_path = plain_credentials_path(); let enc_path = credential_store::encrypted_credentials_path(); @@ -1974,6 +1998,49 @@ mod tests { assert_eq!(secret, "test-secret"); } + #[test] + #[serial_test::serial] + fn oauth_client_env_credentials_ignores_empty_values() { + unsafe { + std::env::set_var("GOOGLE_WORKSPACE_CLI_CLIENT_ID", ""); + std::env::set_var("GOOGLE_WORKSPACE_CLI_CLIENT_SECRET", "test-secret"); + } + + let (client_id, client_secret) = oauth_client_env_credentials(); + + unsafe { + std::env::remove_var("GOOGLE_WORKSPACE_CLI_CLIENT_ID"); + std::env::remove_var("GOOGLE_WORKSPACE_CLI_CLIENT_SECRET"); + } + + assert!(client_id.is_none()); + assert_eq!(client_secret.as_deref(), Some("test-secret")); + } + + #[test] + #[serial_test::serial] + fn resolve_credentials_ignores_empty_env_vars() { + let dir = tempfile::tempdir().unwrap(); + unsafe { + std::env::set_var("GOOGLE_WORKSPACE_CLI_CONFIG_DIR", dir.path()); + std::env::set_var("GOOGLE_WORKSPACE_CLI_CLIENT_ID", ""); + std::env::set_var("GOOGLE_WORKSPACE_CLI_CLIENT_SECRET", "test-secret"); + } + + let result = resolve_client_credentials(); + + unsafe { + std::env::remove_var("GOOGLE_WORKSPACE_CLI_CONFIG_DIR"); + std::env::remove_var("GOOGLE_WORKSPACE_CLI_CLIENT_ID"); + std::env::remove_var("GOOGLE_WORKSPACE_CLI_CLIENT_SECRET"); + } + + match result.unwrap_err() { + GwsError::Auth(msg) => assert!(msg.contains("No OAuth client configured")), + other => panic!("Expected Auth error, got: {other:?}"), + } + } + #[tokio::test] async fn handle_status_succeeds_without_credentials() { // status should always succeed and report "none" @@ -1982,6 +2049,53 @@ mod tests { assert!(result.is_ok()); } + #[test] + #[serial_test::serial] + fn add_oauth_env_status_fields_marks_env_override() { + unsafe { + std::env::set_var( + "GOOGLE_WORKSPACE_CLI_CLIENT_ID", + "1234567890abcdef.apps.googleusercontent.com", + ); + std::env::set_var("GOOGLE_WORKSPACE_CLI_CLIENT_SECRET", "test-secret"); + } + + let mut output = json!({}); + add_oauth_env_status_fields(&mut output, true); + + unsafe { + std::env::remove_var("GOOGLE_WORKSPACE_CLI_CLIENT_ID"); + std::env::remove_var("GOOGLE_WORKSPACE_CLI_CLIENT_SECRET"); + } + + assert_eq!(output["env_client_id_set"], json!(true)); + assert_eq!(output["env_client_secret_set"], json!(true)); + assert_eq!(output["env_overrides_client_config"], json!(true)); + assert_eq!(output["env_client_id"], json!("1234....com")); + } + + #[test] + #[serial_test::serial] + fn add_oauth_env_status_fields_ignores_empty_env_vars() { + unsafe { + std::env::set_var("GOOGLE_WORKSPACE_CLI_CLIENT_ID", ""); + std::env::set_var("GOOGLE_WORKSPACE_CLI_CLIENT_SECRET", "test-secret"); + } + + let mut output = json!({}); + add_oauth_env_status_fields(&mut output, true); + + unsafe { + std::env::remove_var("GOOGLE_WORKSPACE_CLI_CLIENT_ID"); + std::env::remove_var("GOOGLE_WORKSPACE_CLI_CLIENT_SECRET"); + } + + assert_eq!(output["env_client_id_set"], json!(false)); + assert_eq!(output["env_client_secret_set"], json!(true)); + assert_eq!(output["env_overrides_client_config"], json!(false)); + assert!(output["env_client_id"].is_null()); + } + #[test] fn credential_store_save_load_round_trip() { // Use encrypt/decrypt directly to avoid writing to the real config dir