Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .changeset/adc-support.md
Original file line number Diff line number Diff line change
@@ -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.
210 changes: 174 additions & 36 deletions src/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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.
Expand Down Expand Up @@ -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<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,
Expand All @@ -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"
Expand Down Expand Up @@ -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."
)
}

Expand All @@ -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(
Expand Down Expand Up @@ -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());
Expand All @@ -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,
Expand All @@ -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
Expand Down
Loading