From d6fc595af632a051469cdce65e6421ac136cce35 Mon Sep 17 00:00:00 2001 From: Kyle Gallatin Date: Wed, 4 Mar 2026 19:31:57 -0800 Subject: [PATCH 1/2] feat(auth): add gws auth use-adc command Adds support for importing Application Default Credentials from gcloud, eliminating the need for custom OAuth clients in team setups. Features: - New `gws auth use-adc` command to import ADC credentials - Includes quota_project_id support for proper API quota tracking - Adds x-goog-user-project header to API requests - Simplifies team onboarding to 2 commands - No network validation - credentials validated on first API use Technical changes: - New handle_use_adc() function in src/auth_commands.rs (simple import, no network calls) - Added get_quota_project_id() helper in src/executor.rs - Request builder includes quota project header when available - Supports both ~/.config/gcloud/ and ~/Library/Application Support/gcloud/ paths This makes team authentication simpler - everyone uses Google's built-in OAuth client instead of creating/sharing custom clients. Co-Authored-By: Claude Sonnet 4.5 --- .changeset/add-auth-use-adc.md | 30 +++++++ README.md | 33 ++++++++ src/auth_commands.rs | 141 ++++++++++++++++++++++++++++++--- src/executor.rs | 38 +++++++++ 4 files changed, 230 insertions(+), 12 deletions(-) create mode 100644 .changeset/add-auth-use-adc.md diff --git a/.changeset/add-auth-use-adc.md b/.changeset/add-auth-use-adc.md new file mode 100644 index 0000000..a5c334a --- /dev/null +++ b/.changeset/add-auth-use-adc.md @@ -0,0 +1,30 @@ +--- +"gws": minor +--- + +feat(auth): add `gws auth use-adc` command for Application Default Credentials + +Adds a new `gws auth use-adc` command that imports Application Default Credentials +(ADC) from `gcloud`, eliminating the need to create custom OAuth clients for team +setups. + +**Usage:** +```bash +gcloud auth application-default login --project=my-project --scopes= +gws auth use-adc +``` + +**Features:** +- Imports ADC credentials from gcloud to gws +- Includes `quota_project_id` support for proper API quota tracking +- Adds `x-goog-user-project` header to API requests when quota project is set +- Simplifies team onboarding (no custom OAuth client needed) + +**Technical changes:** +- New `handle_use_adc()` function in `src/auth_commands.rs` +- Added `get_quota_project_id()` helper in `src/executor.rs` +- Request builder includes quota project header when available +- Supports both `~/.config/gcloud/` and `~/Library/Application Support/gcloud/` ADC paths + +This makes team authentication simpler - everyone uses Google's built-in OAuth client +instead of creating/sharing custom clients. diff --git a/README.md b/README.md index 7a97291..0deb9a9 100644 --- a/README.md +++ b/README.md @@ -116,6 +116,39 @@ Then run: gws auth login ``` +### Team / Shared Setup (Recommended) + +For teams sharing a GCP project, use Application Default Credentials with Workspace scopes. Everyone authenticates with their own Google account - no OAuth client creation needed. + +**One-time setup per person:** + +```bash +# 1. Authenticate with ADC - choose your scopes based on what APIs you need +# Example with common Workspace scopes: +gcloud auth application-default login --project=my-project \ + --scopes=openid,https://www.googleapis.com/auth/userinfo.email,\ +https://www.googleapis.com/auth/drive,https://www.googleapis.com/auth/gmail.modify,\ +https://www.googleapis.com/auth/spreadsheets,https://www.googleapis.com/auth/calendar + +# 2. Import credentials to gws +gws auth use-adc + +# Done! Now use gws commands: +gws drive files list --params '{"pageSize": 5}' +``` + +**Choosing scopes:** +- **Minimal** (recommended for most teams): `drive`, `gmail.modify`, `spreadsheets`, `calendar`, `documents` +- **Readonly**: Use `.readonly` variants (`drive.readonly`, `gmail.readonly`, etc.) +- **Full access**: Add `cloud-platform`, `pubsub` (may require app verification) +- See the [full scope list](https://developers.google.com/identity/protocols/oauth2/scopes#workspace) for all options + +**Why this works:** +- Uses Google's built-in OAuth client (no custom client needed) +- Each person uses their own credentials and permissions +- Project quota is tracked to your specified GCP project +- Simpler than creating/managing custom OAuth clients + ### Browser-assisted auth (human or agent) You can complete OAuth either manually or with browser automation. diff --git a/src/auth_commands.rs b/src/auth_commands.rs index ef422bb..96d3bc0 100644 --- a/src/auth_commands.rs +++ b/src/auth_commands.rs @@ -97,17 +97,19 @@ fn token_cache_path() -> PathBuf { /// Handle `gws auth `. pub async fn handle_auth_command(args: &[String]) -> Result<(), GwsError> { const USAGE: &str = concat!( - "Usage: gws auth \n\n", - " login Authenticate via OAuth2 (opens browser)\n", - " --readonly Request read-only scopes\n", - " --full Request all scopes incl. pubsub + cloud-platform\n", - " (may trigger restricted_client for unverified apps)\n", - " --scopes Comma-separated custom scopes\n", - " setup Configure GCP project + OAuth client (requires gcloud)\n", - " --project Use a specific GCP project\n", - " status Show current authentication state\n", - " export Print decrypted credentials to stdout\n", - " logout Clear saved credentials and token cache", + "Usage: gws auth \n\n", + " login Authenticate via OAuth2 (opens browser)\n", + " --readonly Request read-only scopes\n", + " --full Request all scopes incl. pubsub + cloud-platform\n", + " (may trigger restricted_client for unverified apps)\n", + " --scopes Comma-separated custom scopes\n", + " setup Configure GCP project + OAuth client (requires gcloud)\n", + " --project Use a specific GCP project\n", + " use-adc Use Application Default Credentials from gcloud\n", + " (requires: gcloud auth application-default login with Workspace scopes)\n", + " status Show current authentication state\n", + " export Print decrypted credentials to stdout\n", + " logout Clear saved credentials and token cache", ); // Honour --help / -h before treating the first arg as a subcommand. @@ -119,6 +121,7 @@ pub async fn handle_auth_command(args: &[String]) -> Result<(), GwsError> { match args[0].as_str() { "login" => handle_login(&args[1..]).await, "setup" => crate::setup::run_setup(&args[1..]).await, + "use-adc" => handle_use_adc().await, "status" => handle_status().await, "export" => { let unmasked = args.len() > 1 && args[1] == "--unmasked"; @@ -126,7 +129,7 @@ pub async fn handle_auth_command(args: &[String]) -> Result<(), GwsError> { } "logout" => handle_logout(), other => Err(GwsError::Validation(format!( - "Unknown auth subcommand: '{other}'. Use: login, setup, status, export, logout" + "Unknown auth subcommand: '{other}'. Use: login, setup, use-adc, status, export, logout" ))), } } @@ -901,6 +904,120 @@ async fn handle_status() -> Result<(), GwsError> { Ok(()) } +/// Import Application Default Credentials from gcloud and save them for gws. +/// +/// This allows users to run: +/// gcloud auth application-default login --scopes= +/// gws auth use-adc +/// +/// Instead of creating a custom OAuth client via `gws auth setup`. +async fn handle_use_adc() -> Result<(), GwsError> { + // Try both common locations for ADC credentials + let adc_path_config = dirs::home_dir() + .ok_or_else(|| GwsError::Validation("Could not determine home directory".to_string()))? + .join(".config") + .join("gcloud") + .join("application_default_credentials.json"); + + let adc_path_app_support = dirs::config_dir() + .ok_or_else(|| GwsError::Validation("Could not determine config directory".to_string()))? + .join("gcloud") + .join("application_default_credentials.json"); + + let adc_path = if adc_path_config.exists() { + adc_path_config + } else if adc_path_app_support.exists() { + adc_path_app_support + } else { + adc_path_config // default for error message + }; + + if !adc_path.exists() { + return Err(GwsError::Validation(format!( + "Application Default Credentials not found at: {}\n\n\ + Run this first:\n \ + gcloud auth application-default login --project= \\\n \ + --scopes=\n\n\ + Example with Workspace scopes:\n \ + gcloud auth application-default login --project=my-project \\\n \ + --scopes=openid,https://www.googleapis.com/auth/userinfo.email,\\\n \ + https://www.googleapis.com/auth/drive,https://www.googleapis.com/auth/gmail.modify", + adc_path.display() + ))); + } + + let adc_contents = tokio::fs::read_to_string(&adc_path).await.map_err(|e| { + GwsError::Validation(format!( + "Failed to read ADC credentials from {}: {}", + adc_path.display(), + e + )) + })?; + + let adc: serde_json::Value = serde_json::from_str(&adc_contents) + .map_err(|e| GwsError::Validation(format!("Failed to parse ADC credentials: {}", e)))?; + + // Validate required fields exist + let client_id = adc + .get("client_id") + .and_then(|v| v.as_str()) + .ok_or_else(|| GwsError::Validation("ADC credentials missing 'client_id'".to_string()))?; + + let client_secret = adc + .get("client_secret") + .and_then(|v| v.as_str()) + .ok_or_else(|| { + GwsError::Validation("ADC credentials missing 'client_secret'".to_string()) + })?; + + let refresh_token = adc + .get("refresh_token") + .and_then(|v| v.as_str()) + .ok_or_else(|| { + GwsError::Validation("ADC credentials missing 'refresh_token'".to_string()) + })?; + + let cred_type = adc.get("type").and_then(|v| v.as_str()); + if cred_type != Some("authorized_user") { + return Err(GwsError::Validation(format!( + "ADC credentials must be type 'authorized_user', got: {:?}", + cred_type + ))); + } + + // Build gws credentials with the same fields + let mut gws_creds = json!({ + "type": "authorized_user", + "client_id": client_id, + "client_secret": client_secret, + "refresh_token": refresh_token, + }); + + // Include quota_project_id if present in ADC credentials + if let Some(quota_project) = adc.get("quota_project_id").and_then(|v| v.as_str()) { + gws_creds["quota_project_id"] = json!(quota_project); + } + + let creds_str = serde_json::to_string(&gws_creds) + .map_err(|e| GwsError::Validation(format!("Failed to serialize credentials: {}", e)))?; + + let enc_path = credential_store::save_encrypted(&creds_str) + .map_err(|e| GwsError::Validation(format!("Failed to save credentials: {}", e)))?; + + let output = json!({ + "status": "success", + "message": "ADC credentials imported successfully.", + "credentials_file": enc_path.display().to_string(), + "quota_project_id": adc.get("quota_project_id"), + }); + + println!( + "{}", + serde_json::to_string_pretty(&output).unwrap_or_default() + ); + Ok(()) +} + fn handle_logout() -> Result<(), GwsError> { let plain_path = plain_credentials_path(); let enc_path = credential_store::encrypted_credentials_path(); diff --git a/src/executor.rs b/src/executor.rs index 5a900a0..2b2fbd8 100644 --- a/src/executor.rs +++ b/src/executor.rs @@ -29,6 +29,38 @@ use tokio::io::AsyncWriteExt; use crate::discovery::{RestDescription, RestMethod}; use crate::error::GwsError; +/// Get the quota_project_id from stored credentials if present. +/// Used to set the x-goog-user-project header for ADC credentials. +fn get_quota_project_id() -> Result { + // Check encrypted credentials first + if let Ok(contents) = crate::credential_store::load_encrypted() { + if let Ok(creds) = serde_json::from_str::(&contents) { + if let Some(quota_project) = creds.get("quota_project_id").and_then(|v| v.as_str()) { + return Ok(quota_project.to_string()); + } + } + } + + // Check plaintext credentials + let config_dir = dirs::config_dir() + .unwrap_or_else(|| std::path::PathBuf::from(".")) + .join("gws"); + let plain_path = config_dir.join("credentials.json"); + + if plain_path.exists() { + if let Ok(contents) = std::fs::read_to_string(&plain_path) { + if let Ok(creds) = serde_json::from_str::(&contents) { + if let Some(quota_project) = creds.get("quota_project_id").and_then(|v| v.as_str()) + { + return Ok(quota_project.to_string()); + } + } + } + } + + Err("No quota_project_id found".to_string()) +} + /// Tracks what authentication method was used for the request. #[derive(Debug, Clone, PartialEq)] pub enum AuthMethod { @@ -162,6 +194,12 @@ async fn build_http_request( if let Some(token) = token { if *auth_method == AuthMethod::OAuth { request = request.bearer_auth(token); + + // Add quota project header if present in credentials + // This is required for ADC credentials to work properly + if let Ok(quota_project) = get_quota_project_id() { + request = request.header("x-goog-user-project", quota_project); + } } } From 611728d85bf359ff283ef2ad5a9ccfee4662c803 Mon Sep 17 00:00:00 2001 From: Kyle Gallatin Date: Wed, 4 Mar 2026 21:21:09 -0800 Subject: [PATCH 2/2] Update src/auth_commands.rs Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- src/auth_commands.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/auth_commands.rs b/src/auth_commands.rs index 96d3bc0..53b5cc3 100644 --- a/src/auth_commands.rs +++ b/src/auth_commands.rs @@ -1013,7 +1013,7 @@ async fn handle_use_adc() -> Result<(), GwsError> { println!( "{}", - serde_json::to_string_pretty(&output).unwrap_or_default() + serde_json::to_string_pretty(&output).unwrap() ); Ok(()) }