diff --git a/.changeset/adc-support.md b/.changeset/adc-support.md new file mode 100644 index 0000000..dc8360f --- /dev/null +++ b/.changeset/adc-support.md @@ -0,0 +1,19 @@ +--- +"@googleworkspace/cli": minor +--- + +Add Application Default Credentials (ADC) support. + +`gws` now discovers ADC as a fourth credential source, after the encrypted +and plaintext credential files. The lookup order is: + +1. `GOOGLE_WORKSPACE_CLI_TOKEN` env var (raw access token, highest priority) +2. `GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE` env var +3. Encrypted credentials (`~/.config/gws/credentials.enc`) +4. Plaintext credentials (`~/.config/gws/credentials.json`) +5. **ADC** — `GOOGLE_APPLICATION_CREDENTIALS` env var (hard error if file missing), then + `~/.config/gcloud/application_default_credentials.json` (silent if absent) + +This means `gcloud auth application-default login --client-id-file=client_secret.json` +is now a fully supported auth flow — no need to run `gws auth login` separately. +Both `authorized_user` and `service_account` ADC formats are supported. diff --git a/src/auth.rs b/src/auth.rs index e1200d9..e0794e3 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -24,6 +24,19 @@ use anyhow::Context; use crate::credential_store; +/// Returns the well-known Application Default Credentials path: +/// `~/.config/gcloud/application_default_credentials.json`. +/// +/// Note: `dirs::config_dir()` returns `~/Library/Application Support` on macOS, which is +/// wrong for gcloud. The Google Cloud SDK always uses `~/.config/gcloud` regardless of OS. +fn adc_well_known_path() -> Option { + dirs::home_dir().map(|d| { + d.join(".config") + .join("gcloud") + .join("application_default_credentials.json") + }) +} + /// Types of credentials we support #[derive(Debug)] enum Credential { @@ -38,6 +51,10 @@ enum Credential { /// 1. `GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE` env var (plaintext JSON, can be User or Service Account) /// 2. Per-account encrypted credentials via `accounts.json` registry /// 3. Plaintext credentials at `~/.config/gws/credentials.json` (User only) +/// 4. Application Default Credentials (ADC): +/// - `GOOGLE_APPLICATION_CREDENTIALS` env var (path to a JSON credentials file), then +/// - Well-known ADC path: `~/.config/gcloud/application_default_credentials.json` +/// (populated by `gcloud auth application-default login`) /// /// When `account` is `Some`, a specific registered account is used. /// When `account` is `None`, the default account from `accounts.json` is used. @@ -203,6 +220,30 @@ async fn get_token_inner( } } +/// Parse a plaintext JSON credential file into a [`Credential`]. +/// +/// Determines the credential type from the `"type"` field: +/// - `"service_account"` → [`Credential::ServiceAccount`] +/// - anything else (including `"authorized_user"`) → [`Credential::AuthorizedUser`] +/// +/// Uses the already-parsed `serde_json::Value` to avoid a second string parse. +async fn parse_credential_file(path: &std::path::Path, content: &str) -> anyhow::Result { + let json: serde_json::Value = serde_json::from_str(content) + .with_context(|| format!("Failed to parse credentials JSON at {}", path.display()))?; + + if json.get("type").and_then(|v| v.as_str()) == Some("service_account") { + let key = yup_oauth2::parse_service_account_key(content) + .with_context(|| format!("Failed to parse service account key from {}", path.display()))?; + return Ok(Credential::ServiceAccount(key)); + } + + // Deserialize from the Value we already have — avoids a second string parse. + let secret: yup_oauth2::authorized_user::AuthorizedUserSecret = + serde_json::from_value(json) + .with_context(|| format!("Failed to parse authorized user credentials from {}", path.display()))?; + Ok(Credential::AuthorizedUser(secret)) +} + async fn load_credentials_inner( env_file: Option<&str>, enc_path: &std::path::Path, @@ -212,29 +253,10 @@ async fn load_credentials_inner( if let Some(path) = env_file { let p = PathBuf::from(path); if p.exists() { - // Read file content first to determine type let content = tokio::fs::read_to_string(&p) .await .with_context(|| format!("Failed to read credentials from {path}"))?; - - let json: serde_json::Value = - serde_json::from_str(&content).context("Failed to parse credentials JSON")?; - - // Check for "type" field - if let Some(type_str) = json.get("type").and_then(|v| v.as_str()) { - if type_str == "service_account" { - let key = yup_oauth2::parse_service_account_key(&content) - .context("Failed to parse service account key")?; - return Ok(Credential::ServiceAccount(key)); - } - } - - // Default to parsed authorized user secret if not service account - // We re-parse specifically to AuthorizedUserSecret to validate fields - let secret: yup_oauth2::authorized_user::AuthorizedUserSecret = - serde_json::from_str(&content) - .context("Failed to parse authorized user credentials")?; - return Ok(Credential::AuthorizedUser(secret)); + return parse_credential_file(&p, &content).await; } anyhow::bail!( "GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE points to {path}, but file does not exist" @@ -281,9 +303,36 @@ async fn load_credentials_inner( )); } + // 4a. GOOGLE_APPLICATION_CREDENTIALS env var (explicit path — hard error if missing) + if let Ok(adc_env) = std::env::var("GOOGLE_APPLICATION_CREDENTIALS") { + let adc_path = PathBuf::from(&adc_env); + if adc_path.exists() { + let content = tokio::fs::read_to_string(&adc_path) + .await + .with_context(|| format!("Failed to read ADC from {adc_env}"))?; + return parse_credential_file(&adc_path, &content).await; + } + anyhow::bail!( + "GOOGLE_APPLICATION_CREDENTIALS points to {adc_env}, but file does not exist" + ); + } + + // 4b. Well-known ADC path: ~/.config/gcloud/application_default_credentials.json + // (populated by `gcloud auth application-default login`). Silent if absent. + if let Some(well_known) = adc_well_known_path() { + if well_known.exists() { + let content = tokio::fs::read_to_string(&well_known) + .await + .with_context(|| format!("Failed to read ADC from {}", well_known.display()))?; + return parse_credential_file(&well_known, &content).await; + } + } + anyhow::bail!( "No credentials found. Run `gws auth setup` to configure, \ - `gws auth login` to authenticate, or set GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE" + `gws auth login` to authenticate, or set GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE.\n\ + Tip: Application Default Credentials (ADC) are also supported — run \ + `gcloud auth application-default login` or set GOOGLE_APPLICATION_CREDENTIALS." ) } @@ -308,6 +357,103 @@ mod tests { .contains("No credentials found")); } + #[tokio::test] + #[serial_test::serial] + async fn test_load_credentials_adc_env_var_authorized_user() { + let mut file = NamedTempFile::new().unwrap(); + let json = r#"{ + "client_id": "adc_id", + "client_secret": "adc_secret", + "refresh_token": "adc_refresh", + "type": "authorized_user" + }"#; + file.write_all(json.as_bytes()).unwrap(); + + std::env::set_var( + "GOOGLE_APPLICATION_CREDENTIALS", + file.path().to_str().unwrap(), + ); + + let res = load_credentials_inner( + None, + &PathBuf::from("/missing/enc"), + &PathBuf::from("/missing/plain"), + ) + .await; + + std::env::remove_var("GOOGLE_APPLICATION_CREDENTIALS"); + + match res.unwrap() { + Credential::AuthorizedUser(secret) => { + assert_eq!(secret.client_id, "adc_id"); + assert_eq!(secret.refresh_token, "adc_refresh"); + } + _ => panic!("Expected AuthorizedUser from ADC"), + } + } + + #[tokio::test] + #[serial_test::serial] + async fn test_load_credentials_adc_env_var_service_account() { + let mut file = NamedTempFile::new().unwrap(); + let json = r#"{ + "type": "service_account", + "project_id": "test-project", + "private_key_id": "adc-key-id", + "private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvwIBADANBgkqhkiG9w0BAQEFAASC\n-----END PRIVATE KEY-----\n", + "client_email": "adc-sa@test-project.iam.gserviceaccount.com", + "client_id": "456", + "auth_uri": "https://accounts.google.com/o/oauth2/auth", + "token_uri": "https://oauth2.googleapis.com/token" + }"#; + file.write_all(json.as_bytes()).unwrap(); + + std::env::set_var( + "GOOGLE_APPLICATION_CREDENTIALS", + file.path().to_str().unwrap(), + ); + + let res = load_credentials_inner( + None, + &PathBuf::from("/missing/enc"), + &PathBuf::from("/missing/plain"), + ) + .await; + + std::env::remove_var("GOOGLE_APPLICATION_CREDENTIALS"); + + match res.unwrap() { + Credential::ServiceAccount(key) => { + assert_eq!(key.client_email, "adc-sa@test-project.iam.gserviceaccount.com"); + } + _ => panic!("Expected ServiceAccount from ADC"), + } + } + + #[tokio::test] + #[serial_test::serial] + async fn test_load_credentials_adc_env_var_missing_file() { + std::env::set_var("GOOGLE_APPLICATION_CREDENTIALS", "/does/not/exist.json"); + + // When GOOGLE_APPLICATION_CREDENTIALS points to a missing file, we error immediately + // rather than falling through — the user explicitly asked for this file. + let err = load_credentials_inner( + None, + &PathBuf::from("/missing/enc"), + &PathBuf::from("/missing/plain"), + ) + .await; + + std::env::remove_var("GOOGLE_APPLICATION_CREDENTIALS"); + + assert!(err.is_err()); + let msg = err.unwrap_err().to_string(); + assert!( + msg.contains("does not exist"), + "Should hard-error when GOOGLE_APPLICATION_CREDENTIALS points to missing file, got: {msg}" + ); + } + #[tokio::test] async fn test_load_credentials_env_file_missing() { let err = load_credentials_inner( @@ -409,18 +555,14 @@ mod tests { let old_token = std::env::var("GOOGLE_WORKSPACE_CLI_TOKEN").ok(); // Set the token env var - unsafe { - std::env::set_var("GOOGLE_WORKSPACE_CLI_TOKEN", "my-test-token"); - } + std::env::set_var("GOOGLE_WORKSPACE_CLI_TOKEN", "my-test-token"); let result = get_token(&["https://www.googleapis.com/auth/drive"], None).await; - unsafe { - if let Some(t) = old_token { - std::env::set_var("GOOGLE_WORKSPACE_CLI_TOKEN", t); - } else { - std::env::remove_var("GOOGLE_WORKSPACE_CLI_TOKEN"); - } + if let Some(t) = old_token { + std::env::set_var("GOOGLE_WORKSPACE_CLI_TOKEN", t); + } else { + std::env::remove_var("GOOGLE_WORKSPACE_CLI_TOKEN"); } assert!(result.is_ok()); @@ -433,9 +575,7 @@ mod tests { // An empty token should not short-circuit — it should be ignored // and fall through to normal credential loading. // We test with non-existent credential paths to ensure fallthrough. - unsafe { - std::env::set_var("GOOGLE_WORKSPACE_CLI_TOKEN", ""); - } + std::env::set_var("GOOGLE_WORKSPACE_CLI_TOKEN", ""); let result = load_credentials_inner( None, @@ -444,9 +584,7 @@ mod tests { ) .await; - unsafe { - std::env::remove_var("GOOGLE_WORKSPACE_CLI_TOKEN"); - } + std::env::remove_var("GOOGLE_WORKSPACE_CLI_TOKEN"); // Should fall through to normal credential loading, which fails // because we pointed at non-existent paths