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)> {
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
116 changes: 105 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,94 @@ 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.
#[cfg(test)]
static QUOTA_PROJECT_ENV_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
Comment on lines +2406 to +2409

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 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 the shared GOOGLE_WORKSPACE_PROJECT_ID environment variable. Because they do not share this mutex, they can still run in parallel and race, leading to flaky test failures.

Since the serial_test crate is already a dependency and used in auth.rs, we should use #[serial_test::serial] on these tests instead and remove this local mutex entirely.


#[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 +2413

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 tests in auth.rs (which also mutate environment variables like GOOGLE_WORKSPACE_PROJECT_ID) are annotated with #[serial_test::serial]. Since serial_test uses its own global static lock to serialize tests, any test not annotated with #[serial] can run concurrently with them. By using a local QUOTA_PROJECT_ENV_MUTEX instead of #[serial_test::serial], these new tests in executor.rs can run in parallel with the auth.rs tests, leading to environment variable races and flaky test failures in CI. Using #[serial_test::serial] ensures all environment-mutating tests across the workspace are properly serialized.

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

Comment on lines +2411 to +2413

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 with other tests in the crate that mutate environment variables, and remove the local mutex guard.

Suggested change
#[tokio::test]
async fn test_oauth_auth_does_not_set_quota_project_header_by_default() {
let _guard = QUOTA_PROJECT_ENV_MUTEX.lock().unwrap();
#[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 +2451 to +2453

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

Replace the local mutex guard with #[serial_test::serial] to ensure this test is properly serialized with other environment-mutating tests in the workspace (such as those in auth.rs), preventing flaky test runs.

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() {

Comment on lines +2451 to +2453

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 with other tests in the crate that mutate environment variables, 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