Skip to content

Commit b38b760

Browse files
zerone0xclaude
andauthored
feat: add Application Default Credentials (ADC) support (npm#125)
* feat: add Application Default Credentials (ADC) support (npm#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 npm#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 npm#125. Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent c6095dd commit b38b760

2 files changed

Lines changed: 193 additions & 36 deletions

File tree

.changeset/adc-support.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
"@googleworkspace/cli": minor
3+
---
4+
5+
Add Application Default Credentials (ADC) support.
6+
7+
`gws` now discovers ADC as a fourth credential source, after the encrypted
8+
and plaintext credential files. The lookup order is:
9+
10+
1. `GOOGLE_WORKSPACE_CLI_TOKEN` env var (raw access token, highest priority)
11+
2. `GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE` env var
12+
3. Encrypted credentials (`~/.config/gws/credentials.enc`)
13+
4. Plaintext credentials (`~/.config/gws/credentials.json`)
14+
5. **ADC**`GOOGLE_APPLICATION_CREDENTIALS` env var (hard error if file missing), then
15+
`~/.config/gcloud/application_default_credentials.json` (silent if absent)
16+
17+
This means `gcloud auth application-default login --client-id-file=client_secret.json`
18+
is now a fully supported auth flow — no need to run `gws auth login` separately.
19+
Both `authorized_user` and `service_account` ADC formats are supported.

src/auth.rs

Lines changed: 174 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,19 @@ use anyhow::Context;
2424

2525
use crate::credential_store;
2626

27+
/// Returns the well-known Application Default Credentials path:
28+
/// `~/.config/gcloud/application_default_credentials.json`.
29+
///
30+
/// Note: `dirs::config_dir()` returns `~/Library/Application Support` on macOS, which is
31+
/// wrong for gcloud. The Google Cloud SDK always uses `~/.config/gcloud` regardless of OS.
32+
fn adc_well_known_path() -> Option<PathBuf> {
33+
dirs::home_dir().map(|d| {
34+
d.join(".config")
35+
.join("gcloud")
36+
.join("application_default_credentials.json")
37+
})
38+
}
39+
2740
/// Types of credentials we support
2841
#[derive(Debug)]
2942
enum Credential {
@@ -38,6 +51,10 @@ enum Credential {
3851
/// 1. `GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE` env var (plaintext JSON, can be User or Service Account)
3952
/// 2. Per-account encrypted credentials via `accounts.json` registry
4053
/// 3. Plaintext credentials at `~/.config/gws/credentials.json` (User only)
54+
/// 4. Application Default Credentials (ADC):
55+
/// - `GOOGLE_APPLICATION_CREDENTIALS` env var (path to a JSON credentials file), then
56+
/// - Well-known ADC path: `~/.config/gcloud/application_default_credentials.json`
57+
/// (populated by `gcloud auth application-default login`)
4158
///
4259
/// When `account` is `Some`, a specific registered account is used.
4360
/// When `account` is `None`, the default account from `accounts.json` is used.
@@ -201,6 +218,30 @@ async fn get_token_inner(
201218
}
202219
}
203220

221+
/// Parse a plaintext JSON credential file into a [`Credential`].
222+
///
223+
/// Determines the credential type from the `"type"` field:
224+
/// - `"service_account"` → [`Credential::ServiceAccount`]
225+
/// - anything else (including `"authorized_user"`) → [`Credential::AuthorizedUser`]
226+
///
227+
/// Uses the already-parsed `serde_json::Value` to avoid a second string parse.
228+
async fn parse_credential_file(path: &std::path::Path, content: &str) -> anyhow::Result<Credential> {
229+
let json: serde_json::Value = serde_json::from_str(content)
230+
.with_context(|| format!("Failed to parse credentials JSON at {}", path.display()))?;
231+
232+
if json.get("type").and_then(|v| v.as_str()) == Some("service_account") {
233+
let key = yup_oauth2::parse_service_account_key(content)
234+
.with_context(|| format!("Failed to parse service account key from {}", path.display()))?;
235+
return Ok(Credential::ServiceAccount(key));
236+
}
237+
238+
// Deserialize from the Value we already have — avoids a second string parse.
239+
let secret: yup_oauth2::authorized_user::AuthorizedUserSecret =
240+
serde_json::from_value(json)
241+
.with_context(|| format!("Failed to parse authorized user credentials from {}", path.display()))?;
242+
Ok(Credential::AuthorizedUser(secret))
243+
}
244+
204245
async fn load_credentials_inner(
205246
env_file: Option<&str>,
206247
enc_path: &std::path::Path,
@@ -210,29 +251,10 @@ async fn load_credentials_inner(
210251
if let Some(path) = env_file {
211252
let p = PathBuf::from(path);
212253
if p.exists() {
213-
// Read file content first to determine type
214254
let content = tokio::fs::read_to_string(&p)
215255
.await
216256
.with_context(|| format!("Failed to read credentials from {path}"))?;
217-
218-
let json: serde_json::Value =
219-
serde_json::from_str(&content).context("Failed to parse credentials JSON")?;
220-
221-
// Check for "type" field
222-
if let Some(type_str) = json.get("type").and_then(|v| v.as_str()) {
223-
if type_str == "service_account" {
224-
let key = yup_oauth2::parse_service_account_key(&content)
225-
.context("Failed to parse service account key")?;
226-
return Ok(Credential::ServiceAccount(key));
227-
}
228-
}
229-
230-
// Default to parsed authorized user secret if not service account
231-
// We re-parse specifically to AuthorizedUserSecret to validate fields
232-
let secret: yup_oauth2::authorized_user::AuthorizedUserSecret =
233-
serde_json::from_str(&content)
234-
.context("Failed to parse authorized user credentials")?;
235-
return Ok(Credential::AuthorizedUser(secret));
257+
return parse_credential_file(&p, &content).await;
236258
}
237259
anyhow::bail!(
238260
"GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE points to {path}, but file does not exist"
@@ -279,9 +301,36 @@ async fn load_credentials_inner(
279301
));
280302
}
281303

304+
// 4a. GOOGLE_APPLICATION_CREDENTIALS env var (explicit path — hard error if missing)
305+
if let Ok(adc_env) = std::env::var("GOOGLE_APPLICATION_CREDENTIALS") {
306+
let adc_path = PathBuf::from(&adc_env);
307+
if adc_path.exists() {
308+
let content = tokio::fs::read_to_string(&adc_path)
309+
.await
310+
.with_context(|| format!("Failed to read ADC from {adc_env}"))?;
311+
return parse_credential_file(&adc_path, &content).await;
312+
}
313+
anyhow::bail!(
314+
"GOOGLE_APPLICATION_CREDENTIALS points to {adc_env}, but file does not exist"
315+
);
316+
}
317+
318+
// 4b. Well-known ADC path: ~/.config/gcloud/application_default_credentials.json
319+
// (populated by `gcloud auth application-default login`). Silent if absent.
320+
if let Some(well_known) = adc_well_known_path() {
321+
if well_known.exists() {
322+
let content = tokio::fs::read_to_string(&well_known)
323+
.await
324+
.with_context(|| format!("Failed to read ADC from {}", well_known.display()))?;
325+
return parse_credential_file(&well_known, &content).await;
326+
}
327+
}
328+
282329
anyhow::bail!(
283330
"No credentials found. Run `gws auth setup` to configure, \
284-
`gws auth login` to authenticate, or set GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE"
331+
`gws auth login` to authenticate, or set GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE.\n\
332+
Tip: Application Default Credentials (ADC) are also supported — run \
333+
`gcloud auth application-default login` or set GOOGLE_APPLICATION_CREDENTIALS."
285334
)
286335
}
287336

@@ -306,6 +355,103 @@ mod tests {
306355
.contains("No credentials found"));
307356
}
308357

358+
#[tokio::test]
359+
#[serial_test::serial]
360+
async fn test_load_credentials_adc_env_var_authorized_user() {
361+
let mut file = NamedTempFile::new().unwrap();
362+
let json = r#"{
363+
"client_id": "adc_id",
364+
"client_secret": "adc_secret",
365+
"refresh_token": "adc_refresh",
366+
"type": "authorized_user"
367+
}"#;
368+
file.write_all(json.as_bytes()).unwrap();
369+
370+
std::env::set_var(
371+
"GOOGLE_APPLICATION_CREDENTIALS",
372+
file.path().to_str().unwrap(),
373+
);
374+
375+
let res = load_credentials_inner(
376+
None,
377+
&PathBuf::from("/missing/enc"),
378+
&PathBuf::from("/missing/plain"),
379+
)
380+
.await;
381+
382+
std::env::remove_var("GOOGLE_APPLICATION_CREDENTIALS");
383+
384+
match res.unwrap() {
385+
Credential::AuthorizedUser(secret) => {
386+
assert_eq!(secret.client_id, "adc_id");
387+
assert_eq!(secret.refresh_token, "adc_refresh");
388+
}
389+
_ => panic!("Expected AuthorizedUser from ADC"),
390+
}
391+
}
392+
393+
#[tokio::test]
394+
#[serial_test::serial]
395+
async fn test_load_credentials_adc_env_var_service_account() {
396+
let mut file = NamedTempFile::new().unwrap();
397+
let json = r#"{
398+
"type": "service_account",
399+
"project_id": "test-project",
400+
"private_key_id": "adc-key-id",
401+
"private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvwIBADANBgkqhkiG9w0BAQEFAASC\n-----END PRIVATE KEY-----\n",
402+
"client_email": "adc-sa@test-project.iam.gserviceaccount.com",
403+
"client_id": "456",
404+
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
405+
"token_uri": "https://oauth2.googleapis.com/token"
406+
}"#;
407+
file.write_all(json.as_bytes()).unwrap();
408+
409+
std::env::set_var(
410+
"GOOGLE_APPLICATION_CREDENTIALS",
411+
file.path().to_str().unwrap(),
412+
);
413+
414+
let res = load_credentials_inner(
415+
None,
416+
&PathBuf::from("/missing/enc"),
417+
&PathBuf::from("/missing/plain"),
418+
)
419+
.await;
420+
421+
std::env::remove_var("GOOGLE_APPLICATION_CREDENTIALS");
422+
423+
match res.unwrap() {
424+
Credential::ServiceAccount(key) => {
425+
assert_eq!(key.client_email, "adc-sa@test-project.iam.gserviceaccount.com");
426+
}
427+
_ => panic!("Expected ServiceAccount from ADC"),
428+
}
429+
}
430+
431+
#[tokio::test]
432+
#[serial_test::serial]
433+
async fn test_load_credentials_adc_env_var_missing_file() {
434+
std::env::set_var("GOOGLE_APPLICATION_CREDENTIALS", "/does/not/exist.json");
435+
436+
// When GOOGLE_APPLICATION_CREDENTIALS points to a missing file, we error immediately
437+
// rather than falling through — the user explicitly asked for this file.
438+
let err = load_credentials_inner(
439+
None,
440+
&PathBuf::from("/missing/enc"),
441+
&PathBuf::from("/missing/plain"),
442+
)
443+
.await;
444+
445+
std::env::remove_var("GOOGLE_APPLICATION_CREDENTIALS");
446+
447+
assert!(err.is_err());
448+
let msg = err.unwrap_err().to_string();
449+
assert!(
450+
msg.contains("does not exist"),
451+
"Should hard-error when GOOGLE_APPLICATION_CREDENTIALS points to missing file, got: {msg}"
452+
);
453+
}
454+
309455
#[tokio::test]
310456
async fn test_load_credentials_env_file_missing() {
311457
let err = load_credentials_inner(
@@ -407,18 +553,14 @@ mod tests {
407553
let old_token = std::env::var("GOOGLE_WORKSPACE_CLI_TOKEN").ok();
408554

409555
// Set the token env var
410-
unsafe {
411-
std::env::set_var("GOOGLE_WORKSPACE_CLI_TOKEN", "my-test-token");
412-
}
556+
std::env::set_var("GOOGLE_WORKSPACE_CLI_TOKEN", "my-test-token");
413557

414558
let result = get_token(&["https://www.googleapis.com/auth/drive"], None).await;
415559

416-
unsafe {
417-
if let Some(t) = old_token {
418-
std::env::set_var("GOOGLE_WORKSPACE_CLI_TOKEN", t);
419-
} else {
420-
std::env::remove_var("GOOGLE_WORKSPACE_CLI_TOKEN");
421-
}
560+
if let Some(t) = old_token {
561+
std::env::set_var("GOOGLE_WORKSPACE_CLI_TOKEN", t);
562+
} else {
563+
std::env::remove_var("GOOGLE_WORKSPACE_CLI_TOKEN");
422564
}
423565

424566
assert!(result.is_ok());
@@ -431,9 +573,7 @@ mod tests {
431573
// An empty token should not short-circuit — it should be ignored
432574
// and fall through to normal credential loading.
433575
// We test with non-existent credential paths to ensure fallthrough.
434-
unsafe {
435-
std::env::set_var("GOOGLE_WORKSPACE_CLI_TOKEN", "");
436-
}
576+
std::env::set_var("GOOGLE_WORKSPACE_CLI_TOKEN", "");
437577

438578
let result = load_credentials_inner(
439579
None,
@@ -442,9 +582,7 @@ mod tests {
442582
)
443583
.await;
444584

445-
unsafe {
446-
std::env::remove_var("GOOGLE_WORKSPACE_CLI_TOKEN");
447-
}
585+
std::env::remove_var("GOOGLE_WORKSPACE_CLI_TOKEN");
448586

449587
// Should fall through to normal credential loading, which fails
450588
// because we pointed at non-existent paths

0 commit comments

Comments
 (0)