From 511dba8b630abc9983ce0f4cde71b8cea8977711 Mon Sep 17 00:00:00 2001 From: zerone0x Date: Thu, 5 Mar 2026 07:19:29 +0100 Subject: [PATCH 1/2] feat: add Application Default Credentials (ADC) support (#103) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends the credential chain in get_token() to include ADC as a 4th source: 1. GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE env var 2. Encrypted credentials (~/.config/gws/credentials.enc) 3. Plaintext credentials (~/.config/gws/credentials.json) 4. ADC — GOOGLE_APPLICATION_CREDENTIALS env var, then ~/.config/gcloud/application_default_credentials.json Both authorized_user and service_account ADC formats are detected via the 'type' field and parsed accordingly. This means users can authenticate with: gcloud auth application-default login --client-id-file=client_secret.json and gws will automatically pick up those credentials. Closes #103 Co-Authored-By: Claude --- .changeset/adc-support.md | 18 +++++++ src/auth.rs | 107 +++++++++++++++++++++++++++++++++++++- 2 files changed, 124 insertions(+), 1 deletion(-) create mode 100644 .changeset/adc-support.md diff --git a/.changeset/adc-support.md b/.changeset/adc-support.md new file mode 100644 index 0000000..7e71c07 --- /dev/null +++ b/.changeset/adc-support.md @@ -0,0 +1,18 @@ +--- +"@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_CREDENTIALS_FILE` env var +2. Encrypted credentials (`~/.config/gws/credentials.enc`) +3. Plaintext credentials (`~/.config/gws/credentials.json`) +4. **ADC** — `GOOGLE_APPLICATION_CREDENTIALS` env var, then + `~/.config/gcloud/application_default_credentials.json` + +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..cc63b03 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -24,6 +24,12 @@ use anyhow::Context; use crate::credential_store; +/// Returns the well-known Application Default Credentials path: +/// `~/.config/gcloud/application_default_credentials.json`. +fn adc_well_known_path() -> Option { + dirs::config_dir().map(|d| d.join("gcloud").join("application_default_credentials.json")) +} + /// Types of credentials we support #[derive(Debug)] enum Credential { @@ -38,6 +44,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. @@ -281,9 +291,37 @@ async fn load_credentials_inner( )); } + // 4. Application Default Credentials (ADC) + // Checks GOOGLE_APPLICATION_CREDENTIALS env var first, then the well-known path. + let adc_path = std::env::var("GOOGLE_APPLICATION_CREDENTIALS") + .ok() + .map(PathBuf::from) + .or_else(adc_well_known_path); + + if let Some(ref adc) = adc_path { + if adc.exists() { + let content = tokio::fs::read_to_string(adc) + .await + .with_context(|| format!("Failed to read ADC from {}", adc.display()))?; + let json: serde_json::Value = + serde_json::from_str(&content).context("Failed to parse ADC JSON")?; + if json.get("type").and_then(|v| v.as_str()) == Some("service_account") { + let key = yup_oauth2::parse_service_account_key(&content) + .context("Failed to parse service account key from ADC")?; + return Ok(Credential::ServiceAccount(key)); + } + let secret: yup_oauth2::authorized_user::AuthorizedUserSecret = + serde_json::from_str(&content) + .context("Failed to parse authorized user credentials from ADC")?; + return Ok(Credential::AuthorizedUser(secret)); + } + } + 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 +346,73 @@ 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(); + + unsafe { + 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; + + unsafe { + 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_missing_file() { + unsafe { + std::env::set_var("GOOGLE_APPLICATION_CREDENTIALS", "/does/not/exist.json"); + } + + // A missing ADC file pointed to by env var should fall through to the + // "No credentials found" error, not a hard failure. The env var path + // is only used when it points to an existing file. + let err = load_credentials_inner( + None, + &PathBuf::from("/missing/enc"), + &PathBuf::from("/missing/plain"), + ) + .await; + + unsafe { + std::env::remove_var("GOOGLE_APPLICATION_CREDENTIALS"); + } + + assert!(err.is_err()); + assert!( + err.unwrap_err().to_string().contains("No credentials found"), + "Should fall through when ADC env var points to missing file" + ); + } + #[tokio::test] async fn test_load_credentials_env_file_missing() { let err = load_credentials_inner( From 0739dd5a8c32ed37ed43ee2b52d8457dc6606c0b Mon Sep 17 00:00:00 2001 From: zerone0x Date: Thu, 5 Mar 2026 10:21:54 +0100 Subject: [PATCH 2/2] fix(auth): address review feedback on ADC support - Extract duplicated JSON credential parsing into parse_credential_file() helper to reduce duplication between GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE and ADC code paths; uses serde_json::from_value to avoid second string parse - Fix well-known ADC path on macOS: dirs::config_dir() returns ~/Library/Application Support on macOS, not ~/.config; use dirs::home_dir().join('.config/gcloud/...') instead - Hard-error when GOOGLE_APPLICATION_CREDENTIALS points to a missing file (was: silently fall through to 'No credentials found') - Add test_load_credentials_adc_env_var_service_account covering service account credentials loaded via GOOGLE_APPLICATION_CREDENTIALS - Remove unnecessary unsafe blocks from env var tests (set_var/remove_var are not unsafe functions; thread safety is already handled by serial_test) - Update changeset to include GOOGLE_WORKSPACE_CLI_TOKEN at top of lookup order and clarify ADC fallback behaviour Addresses review feedback from jpoehnelt on #125. Co-Authored-By: Claude --- .changeset/adc-support.md | 11 ++- src/auth.rs | 187 ++++++++++++++++++++++---------------- 2 files changed, 116 insertions(+), 82 deletions(-) diff --git a/.changeset/adc-support.md b/.changeset/adc-support.md index 7e71c07..dc8360f 100644 --- a/.changeset/adc-support.md +++ b/.changeset/adc-support.md @@ -7,11 +7,12 @@ 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_CREDENTIALS_FILE` env var -2. Encrypted credentials (`~/.config/gws/credentials.enc`) -3. Plaintext credentials (`~/.config/gws/credentials.json`) -4. **ADC** — `GOOGLE_APPLICATION_CREDENTIALS` env var, then - `~/.config/gcloud/application_default_credentials.json` +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. diff --git a/src/auth.rs b/src/auth.rs index cc63b03..e0794e3 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -26,8 +26,15 @@ 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::config_dir().map(|d| d.join("gcloud").join("application_default_credentials.json")) + dirs::home_dir().map(|d| { + d.join(".config") + .join("gcloud") + .join("application_default_credentials.json") + }) } /// Types of credentials we support @@ -213,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, @@ -222,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" @@ -291,29 +303,28 @@ async fn load_credentials_inner( )); } - // 4. Application Default Credentials (ADC) - // Checks GOOGLE_APPLICATION_CREDENTIALS env var first, then the well-known path. - let adc_path = std::env::var("GOOGLE_APPLICATION_CREDENTIALS") - .ok() - .map(PathBuf::from) - .or_else(adc_well_known_path); + // 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" + ); + } - if let Some(ref adc) = adc_path { - if adc.exists() { - let content = tokio::fs::read_to_string(adc) + // 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 {}", adc.display()))?; - let json: serde_json::Value = - serde_json::from_str(&content).context("Failed to parse ADC JSON")?; - if json.get("type").and_then(|v| v.as_str()) == Some("service_account") { - let key = yup_oauth2::parse_service_account_key(&content) - .context("Failed to parse service account key from ADC")?; - return Ok(Credential::ServiceAccount(key)); - } - let secret: yup_oauth2::authorized_user::AuthorizedUserSecret = - serde_json::from_str(&content) - .context("Failed to parse authorized user credentials from ADC")?; - return Ok(Credential::AuthorizedUser(secret)); + .with_context(|| format!("Failed to read ADC from {}", well_known.display()))?; + return parse_credential_file(&well_known, &content).await; } } @@ -358,12 +369,10 @@ mod tests { }"#; file.write_all(json.as_bytes()).unwrap(); - unsafe { - std::env::set_var( - "GOOGLE_APPLICATION_CREDENTIALS", - file.path().to_str().unwrap(), - ); - } + std::env::set_var( + "GOOGLE_APPLICATION_CREDENTIALS", + file.path().to_str().unwrap(), + ); let res = load_credentials_inner( None, @@ -372,9 +381,7 @@ mod tests { ) .await; - unsafe { - std::env::remove_var("GOOGLE_APPLICATION_CREDENTIALS"); - } + std::env::remove_var("GOOGLE_APPLICATION_CREDENTIALS"); match res.unwrap() { Credential::AuthorizedUser(secret) => { @@ -387,14 +394,49 @@ mod tests { #[tokio::test] #[serial_test::serial] - async fn test_load_credentials_adc_env_var_missing_file() { - unsafe { - std::env::set_var("GOOGLE_APPLICATION_CREDENTIALS", "/does/not/exist.json"); + 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"), } + } - // A missing ADC file pointed to by env var should fall through to the - // "No credentials found" error, not a hard failure. The env var path - // is only used when it points to an existing file. + #[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"), @@ -402,14 +444,13 @@ mod tests { ) .await; - unsafe { - std::env::remove_var("GOOGLE_APPLICATION_CREDENTIALS"); - } + std::env::remove_var("GOOGLE_APPLICATION_CREDENTIALS"); assert!(err.is_err()); + let msg = err.unwrap_err().to_string(); assert!( - err.unwrap_err().to_string().contains("No credentials found"), - "Should fall through when ADC env var points to missing file" + msg.contains("does not exist"), + "Should hard-error when GOOGLE_APPLICATION_CREDENTIALS points to missing file, got: {msg}" ); } @@ -514,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()); @@ -538,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, @@ -549,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