Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/fix-oauth-quota-project-header.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@googleworkspace/cli": patch
---

Skip x-goog-user-project header for OAuth auth to fix 403 errors for non-project-member users
35 changes: 35 additions & 0 deletions crates/google-workspace-cli/src/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,17 @@ fn adc_well_known_path() -> Option<PathBuf> {
})
}

/// Tracks what authentication method was used for the request.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AuthMethod {
/// OAuth2 bearer token from a user credential (`gws auth login`)
OAuth,
/// Bearer token from a service-account key
ServiceAccount,
/// No authentication was provided
None,
}

/// Types of credentials we support
#[derive(Debug)]
enum Credential {
Expand Down Expand Up @@ -229,6 +240,30 @@ pub async fn get_token(scopes: &[&str]) -> anyhow::Result<String> {
get_token_inner(scopes, creds, &token_cache).await
}

/// Like [`get_token`] but also returns the [`AuthMethod`] so callers can
/// decide whether to include the `x-goog-user-project` quota header.
pub async fn get_token_with_kind(scopes: &[&str]) -> anyhow::Result<(String, AuthMethod)> {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

The implementation of get_token_with_kind duplicates the entire token loading and fetching logic of get_token. To improve maintainability and avoid future divergence, get_token should be refactored to delegate to get_token_with_kind:

pub async fn get_token(scopes: &[&str]) -> anyhow::Result<String> {
    get_token_with_kind(scopes).await.map(|(token, _)| token)
}

if let Ok(token) = std::env::var("GOOGLE_WORKSPACE_CLI_TOKEN") {
if !token.is_empty() {
return Ok((token, AuthMethod::OAuth));
}
}

let creds_file = std::env::var("GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE").ok();
let config_dir = crate::auth_commands::config_dir();
let enc_path = credential_store::encrypted_credentials_path();
let default_path = config_dir.join("credentials.json");
let token_cache = config_dir.join("token_cache.json");

let creds = load_credentials_inner(creds_file.as_deref(), &enc_path, &default_path).await?;
let kind = match &creds {
Credential::ServiceAccount(_) => AuthMethod::ServiceAccount,
Credential::AuthorizedUser(_) => AuthMethod::OAuth,
};
let token = get_token_inner(scopes, creds, &token_cache).await?;
Ok((token, kind))
}

/// Check if HTTP proxy environment variables are set
pub(crate) fn has_proxy_env() -> bool {
PROXY_ENV_VARS
Expand Down
112 changes: 101 additions & 11 deletions crates/google-workspace-cli/src/executor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,14 +31,7 @@ use crate::discovery::{RestDescription, RestMethod};
use crate::error::GwsError;
use crate::output::sanitize_for_terminal;

/// Tracks what authentication method was used for the request.
#[derive(Debug, Clone, PartialEq)]
pub enum AuthMethod {
/// OAuth2 bearer token from credentials file
OAuth,
/// No authentication was provided
None,
}
pub use crate::auth::AuthMethod;

/// Source for media upload content.
///
Expand Down Expand Up @@ -182,13 +175,23 @@ async fn build_http_request(
};

if let Some(token) = token {
if *auth_method == AuthMethod::OAuth {
if matches!(*auth_method, AuthMethod::OAuth | AuthMethod::ServiceAccount) {
request = request.bearer_auth(token);
}
}

// Set quota project from ADC for billing/quota attribution
if let Some(quota_project) = crate::auth::get_quota_project() {
// For service-account auth, always forward the quota project (env var, config, or ADC).
// For OAuth, only send when GOOGLE_WORKSPACE_PROJECT_ID is explicitly set — the user
// has opted in, so we honour it even though OAuth users may not be IAM members of every
// project. Omit the header entirely when neither condition is met to avoid 403 errors.
let quota_project = match auth_method {
AuthMethod::ServiceAccount => crate::auth::get_quota_project(),
AuthMethod::OAuth => std::env::var("GOOGLE_WORKSPACE_PROJECT_ID")
.ok()
.filter(|s| !s.is_empty()),
AuthMethod::None => None,
};
if let Some(quota_project) = quota_project {
request = request.header("x-goog-user-project", quota_project);
}

Expand Down Expand Up @@ -2399,3 +2402,90 @@ async fn test_get_does_not_set_content_length_zero() {
"GET with no body should not have Content-Length header"
);
}

/// Mutex to serialise tests that mutate GOOGLE_WORKSPACE_PROJECT_ID so they
/// don't race with each other when the test binary runs its threads in parallel.
static QUOTA_PROJECT_ENV_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());

#[tokio::test]
async fn test_oauth_auth_does_not_set_quota_project_header_by_default() {
let _guard = QUOTA_PROJECT_ENV_MUTEX.lock().unwrap();
Comment on lines +2406 to +2412
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

Using a local QUOTA_PROJECT_ENV_MUTEX only serializes tests within executor.rs. However, tests in auth.rs (such as test_get_quota_project_priority_env_var) also mutate and read the process-wide GOOGLE_WORKSPACE_PROJECT_ID environment variable and are marked with #[serial_test::serial]. Because serial_test only serializes tests marked with #[serial], these tests will run in parallel with the auth.rs tests, leading to flaky test failures due to concurrent environment variable modification.

To prevent this, remove the local mutex and use #[serial_test::serial] on both tests to ensure global serialization across all tests in the test suite.

#[tokio::test]
#[serial_test::serial]
async fn test_oauth_auth_does_not_set_quota_project_header_by_default() {

// Without GOOGLE_WORKSPACE_PROJECT_ID set, OAuth requests must omit x-goog-user-project
// because OAuth users are not necessarily IAM members of the project.
std::env::remove_var("GOOGLE_WORKSPACE_PROJECT_ID");
let client = reqwest::Client::new();
let method = RestMethod {
http_method: "GET".to_string(),
path: "files".to_string(),
..Default::default()
};
let input = ExecutionInput {
full_url: "https://example.com/files".to_string(),
body: None,
params: Map::new(),
query_params: Vec::new(),
is_upload: false,
};

let request = build_http_request(
&client,
&method,
&input,
Some("fake-token"),
&AuthMethod::OAuth,
None,
0,
&None,
)
.await
.unwrap();

let built = request.build().unwrap();
assert!(
built.headers().get("x-goog-user-project").is_none(),
"OAuth requests must not include x-goog-user-project header when env var is not set"
);
}

#[tokio::test]
async fn test_oauth_auth_sends_quota_project_when_env_var_explicitly_set() {
let _guard = QUOTA_PROJECT_ENV_MUTEX.lock().unwrap();
Comment on lines +2450 to +2452
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

Use #[serial_test::serial] to serialize this test globally and remove the local mutex guard.

Suggested change
#[tokio::test]
async fn test_oauth_auth_sends_quota_project_when_env_var_explicitly_set() {
let _guard = QUOTA_PROJECT_ENV_MUTEX.lock().unwrap();
#[tokio::test]
#[serial_test::serial]
async fn test_oauth_auth_sends_quota_project_when_env_var_explicitly_set() {

// When GOOGLE_WORKSPACE_PROJECT_ID is explicitly set, OAuth requests should
// honour it and send x-goog-user-project (the user opted in).
std::env::set_var("GOOGLE_WORKSPACE_PROJECT_ID", "my-explicit-project");
let client = reqwest::Client::new();
let method = RestMethod {
http_method: "GET".to_string(),
path: "files".to_string(),
..Default::default()
};
let input = ExecutionInput {
full_url: "https://example.com/files".to_string(),
body: None,
params: Map::new(),
query_params: Vec::new(),
is_upload: false,
};

let request = build_http_request(
&client,
&method,
&input,
Some("fake-token"),
&AuthMethod::OAuth,
None,
0,
&None,
)
.await
.unwrap();

std::env::remove_var("GOOGLE_WORKSPACE_PROJECT_ID");

let built = request.build().unwrap();
assert_eq!(
built.headers().get("x-goog-user-project").and_then(|v| v.to_str().ok()),
Some("my-explicit-project"),
"OAuth requests must include x-goog-user-project when GOOGLE_WORKSPACE_PROJECT_ID is set"
);
}
4 changes: 2 additions & 2 deletions crates/google-workspace-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -262,8 +262,8 @@ async fn run() -> Result<(), GwsError> {
let scopes: Vec<&str> = select_scope(&method.scopes).into_iter().collect();

// Authenticate: try OAuth, fail with error if credentials exist but are broken
let (token, auth_method) = match auth::get_token(&scopes).await {
Ok(t) => (Some(t), executor::AuthMethod::OAuth),
let (token, auth_method) = match auth::get_token_with_kind(&scopes).await {
Ok((t, method)) => (Some(t), method),
Err(e) => {
// If credentials were found but failed (e.g. decryption error, invalid token),
// propagate the error instead of silently falling back to unauthenticated.
Expand Down
Loading