Skip to content
Closed
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
30 changes: 30 additions & 0 deletions .changeset/add-auth-use-adc.md
Original file line number Diff line number Diff line change
@@ -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=<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.
33 changes: 33 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
141 changes: 129 additions & 12 deletions src/auth_commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -97,17 +97,19 @@ fn token_cache_path() -> PathBuf {
/// Handle `gws auth <subcommand>`.
pub async fn handle_auth_command(args: &[String]) -> Result<(), GwsError> {
const USAGE: &str = concat!(
"Usage: gws auth <login|setup|status|export|logout>\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 <login|setup|use-adc|status|export|logout>\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.
Expand All @@ -119,14 +121,15 @@ 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";
handle_export(unmasked).await
}
"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"
))),
}
}
Expand Down Expand Up @@ -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=<workspace-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
};
Comment on lines +927 to +933
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

This logic for determining adc_path works, but has a small issue. If neither path exists, the subsequent error message on line 936 will only show adc_path_config, which can be misleading on platforms like Windows where the correct path is adc_path_app_support. Consider modifying this to provide a more informative error message that lists all paths that were checked.


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=<project> \\\n \
--scopes=<comma-separated-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())
})?;
Comment on lines +961 to +978
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The validation logic for client_id, client_secret, and refresh_token is repeated. This could be refactored into a helper function to reduce code duplication and improve readability. For example:

fn get_string_field<'a>(json: &'a serde_json::Value, field: &str) -> Result<&'a str, GwsError> {
    json.get(field)
        .and_then(|v| v.as_str())
        .ok_or_else(|| GwsError::Validation(format!("ADC credentials missing '{field}'")))
}

// ...
let client_id = get_string_field(&adc, "client_id")?;
let client_secret = get_string_field(&adc, "client_secret")?;
let refresh_token = get_string_field(&adc, "refresh_token")?;


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()
);
Ok(())
}

fn handle_logout() -> Result<(), GwsError> {
let plain_path = plain_credentials_path();
let enc_path = credential_store::encrypted_credentials_path();
Expand Down
38 changes: 38 additions & 0 deletions src/executor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, String> {
// Check encrypted credentials first
if let Ok(contents) = crate::credential_store::load_encrypted() {
if let Ok(creds) = serde_json::from_str::<serde_json::Value>(&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::<serde_json::Value>(&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())
}
Comment on lines +34 to +62
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

This function reimplements logic for finding and parsing credentials, but it's incomplete. It doesn't check for the GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE environment variable, which is a supported way to provide credentials. This will cause get_quota_project_id to fail and the x-goog-user-project header to be omitted when credentials are set via this environment variable.

To fix this and avoid code duplication, the logic for loading credentials should be centralized and reused here. Consider moving plain_credentials_path() from auth_commands.rs to a shared module (e.g., a new config.rs) and using it here.


/// Tracks what authentication method was used for the request.
#[derive(Debug, Clone, PartialEq)]
pub enum AuthMethod {
Expand Down Expand Up @@ -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);
}
}
}

Expand Down