feat: add Application Default Credentials (ADC) support (#125)

* feat: add Application Default Credentials (ADC) support (#103)

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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
zerone0x
2026-03-06 04:23:13 +08:00
committed by GitHub
parent c6095ddb27
commit b38b760d79
2 changed files with 193 additions and 36 deletions
+19
View File
@@ -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.
+174 -36
View File
@@ -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<PathBuf> {
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.
@@ -201,6 +218,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<Credential> {
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,
@@ -210,29 +251,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"
@@ -279,9 +301,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."
)
}
@@ -306,6 +355,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(
@@ -407,18 +553,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());
@@ -431,9 +573,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,
@@ -442,9 +582,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