From abcb2fbe2ac75e9f3f760b719604f6f0c4184154 Mon Sep 17 00:00:00 2001 From: Varun Nuthalapati Date: Sun, 31 May 2026 20:15:05 -0700 Subject: [PATCH 1/3] fix(executor): skip x-goog-user-project header for OAuth auth method --- .changeset/fix-oauth-quota-project-header.md | 5 ++ crates/google-workspace-cli/src/executor.rs | 48 ++++++++++++++++++-- 2 files changed, 50 insertions(+), 3 deletions(-) create mode 100644 .changeset/fix-oauth-quota-project-header.md diff --git a/.changeset/fix-oauth-quota-project-header.md b/.changeset/fix-oauth-quota-project-header.md new file mode 100644 index 00000000..604dd5ae --- /dev/null +++ b/.changeset/fix-oauth-quota-project-header.md @@ -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 diff --git a/crates/google-workspace-cli/src/executor.rs b/crates/google-workspace-cli/src/executor.rs index 46f31ac4..80fbc694 100644 --- a/crates/google-workspace-cli/src/executor.rs +++ b/crates/google-workspace-cli/src/executor.rs @@ -187,9 +187,12 @@ async fn build_http_request( } } - // Set quota project from ADC for billing/quota attribution - if let Some(quota_project) = crate::auth::get_quota_project() { - request = request.header("x-goog-user-project", quota_project); + // Only send quota project for ADC/service-account auth; OAuth users are not + // necessarily IAM members of the project, so the header causes 403 errors. + if *auth_method != AuthMethod::OAuth { + if let Some(quota_project) = crate::auth::get_quota_project() { + request = request.header("x-goog-user-project", quota_project); + } } let mut all_query_params = input.query_params.clone(); @@ -2399,3 +2402,42 @@ async fn test_get_does_not_set_content_length_zero() { "GET with no body should not have Content-Length header" ); } + +#[tokio::test] +async fn test_oauth_auth_does_not_set_quota_project_header() { + // Arrange: even if get_quota_project() would return a value, OAuth requests + // must NOT send x-goog-user-project because OAuth users are not necessarily + // IAM members of the project and the header would trigger 403 errors. + 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" + ); +} From 52cff70ef9b72bb7fef4d0f371e10a0cbbe119c4 Mon Sep 17 00:00:00 2001 From: Varun Nuthalapati Date: Sun, 31 May 2026 21:22:14 -0700 Subject: [PATCH 2/3] fix(executor): distinguish service-account auth to correctly gate quota header AuthMethod::OAuth covers both user OAuth and service-account credentials, so the previous check (*auth_method != AuthMethod::OAuth) would incorrectly suppress the x-goog-user-project header for service accounts (which do need it) while also setting it on unauthenticated (None) requests. Add AuthMethod::ServiceAccount and auth::CredentialKind so the call site in main.rs can tag the request with the right variant. The quota header is now only sent for ServiceAccount auth; user OAuth requests remain header-free to avoid 403 errors for users who are not IAM members of the project. --- crates/google-workspace-cli/src/auth.rs | 33 +++++++++++++++++++++ crates/google-workspace-cli/src/executor.rs | 12 ++++---- crates/google-workspace-cli/src/main.rs | 10 +++++-- 3 files changed, 48 insertions(+), 7 deletions(-) diff --git a/crates/google-workspace-cli/src/auth.rs b/crates/google-workspace-cli/src/auth.rs index 9d8847e4..2fc1127d 100644 --- a/crates/google-workspace-cli/src/auth.rs +++ b/crates/google-workspace-cli/src/auth.rs @@ -126,6 +126,15 @@ fn adc_well_known_path() -> Option { }) } +/// What kind of credential provided the token. +#[derive(Debug, Clone, PartialEq)] +pub enum CredentialKind { + /// Browser-based OAuth 2.0 user credential (from `gws auth login`) + UserOAuth, + /// Service-account key credential + ServiceAccount, +} + /// Types of credentials we support #[derive(Debug)] enum Credential { @@ -229,6 +238,30 @@ pub async fn get_token(scopes: &[&str]) -> anyhow::Result { get_token_inner(scopes, creds, &token_cache).await } +/// Like [`get_token`] but also returns the [`CredentialKind`] 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, CredentialKind)> { + if let Ok(token) = std::env::var("GOOGLE_WORKSPACE_CLI_TOKEN") { + if !token.is_empty() { + return Ok((token, CredentialKind::UserOAuth)); + } + } + + 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(_) => CredentialKind::ServiceAccount, + Credential::AuthorizedUser(_) => CredentialKind::UserOAuth, + }; + 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 diff --git a/crates/google-workspace-cli/src/executor.rs b/crates/google-workspace-cli/src/executor.rs index 80fbc694..17c68769 100644 --- a/crates/google-workspace-cli/src/executor.rs +++ b/crates/google-workspace-cli/src/executor.rs @@ -34,8 +34,10 @@ 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 + /// OAuth2 bearer token from a user credential (`gws auth login`) OAuth, + /// Bearer token from a service-account key + ServiceAccount, /// No authentication was provided None, } @@ -182,14 +184,14 @@ 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); } } - // Only send quota project for ADC/service-account auth; OAuth users are not - // necessarily IAM members of the project, so the header causes 403 errors. - if *auth_method != AuthMethod::OAuth { + // Only send quota project for service-account auth; OAuth users may not be + // IAM members of the project, so the header would trigger 403 errors. + if *auth_method == AuthMethod::ServiceAccount { if let Some(quota_project) = crate::auth::get_quota_project() { request = request.header("x-goog-user-project", quota_project); } diff --git a/crates/google-workspace-cli/src/main.rs b/crates/google-workspace-cli/src/main.rs index 41dcc1e1..620aefb2 100644 --- a/crates/google-workspace-cli/src/main.rs +++ b/crates/google-workspace-cli/src/main.rs @@ -262,8 +262,14 @@ 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, kind)) => { + let method = match kind { + auth::CredentialKind::ServiceAccount => executor::AuthMethod::ServiceAccount, + auth::CredentialKind::UserOAuth => executor::AuthMethod::OAuth, + }; + (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. From be15ebc58d74f6b4f0bd7efdb1eedbd5a6f7730e Mon Sep 17 00:00:00 2001 From: Varun Nuthalapati Date: Mon, 1 Jun 2026 09:23:32 -0700 Subject: [PATCH 3/3] refactor(auth): move AuthMethod to auth.rs and re-export in executor.rs Replace the separate CredentialKind enum with the existing AuthMethod enum (moved from executor.rs to auth.rs so authentication types live with authentication code). get_token_with_kind now returns AuthMethod directly, eliminating the mapping boilerplate in main.rs. Resolves Gemini r2 comment on PR #827. --- crates/google-workspace-cli/src/auth.rs | 24 +++++++++++---------- crates/google-workspace-cli/src/executor.rs | 11 +--------- crates/google-workspace-cli/src/main.rs | 8 +------ 3 files changed, 15 insertions(+), 28 deletions(-) diff --git a/crates/google-workspace-cli/src/auth.rs b/crates/google-workspace-cli/src/auth.rs index 2fc1127d..66f8bdf4 100644 --- a/crates/google-workspace-cli/src/auth.rs +++ b/crates/google-workspace-cli/src/auth.rs @@ -126,13 +126,15 @@ fn adc_well_known_path() -> Option { }) } -/// What kind of credential provided the token. -#[derive(Debug, Clone, PartialEq)] -pub enum CredentialKind { - /// Browser-based OAuth 2.0 user credential (from `gws auth login`) - UserOAuth, - /// Service-account key credential +/// 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 @@ -238,12 +240,12 @@ pub async fn get_token(scopes: &[&str]) -> anyhow::Result { get_token_inner(scopes, creds, &token_cache).await } -/// Like [`get_token`] but also returns the [`CredentialKind`] so callers can +/// 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, CredentialKind)> { +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, CredentialKind::UserOAuth)); + return Ok((token, AuthMethod::OAuth)); } } @@ -255,8 +257,8 @@ pub async fn get_token_with_kind(scopes: &[&str]) -> anyhow::Result<(String, Cre let creds = load_credentials_inner(creds_file.as_deref(), &enc_path, &default_path).await?; let kind = match &creds { - Credential::ServiceAccount(_) => CredentialKind::ServiceAccount, - Credential::AuthorizedUser(_) => CredentialKind::UserOAuth, + Credential::ServiceAccount(_) => AuthMethod::ServiceAccount, + Credential::AuthorizedUser(_) => AuthMethod::OAuth, }; let token = get_token_inner(scopes, creds, &token_cache).await?; Ok((token, kind)) diff --git a/crates/google-workspace-cli/src/executor.rs b/crates/google-workspace-cli/src/executor.rs index 17c68769..feac04d6 100644 --- a/crates/google-workspace-cli/src/executor.rs +++ b/crates/google-workspace-cli/src/executor.rs @@ -31,16 +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 a user credential (`gws auth login`) - OAuth, - /// Bearer token from a service-account key - ServiceAccount, - /// No authentication was provided - None, -} +pub use crate::auth::AuthMethod; /// Source for media upload content. /// diff --git a/crates/google-workspace-cli/src/main.rs b/crates/google-workspace-cli/src/main.rs index 620aefb2..5e9fb10c 100644 --- a/crates/google-workspace-cli/src/main.rs +++ b/crates/google-workspace-cli/src/main.rs @@ -263,13 +263,7 @@ async fn run() -> Result<(), GwsError> { // Authenticate: try OAuth, fail with error if credentials exist but are broken let (token, auth_method) = match auth::get_token_with_kind(&scopes).await { - Ok((t, kind)) => { - let method = match kind { - auth::CredentialKind::ServiceAccount => executor::AuthMethod::ServiceAccount, - auth::CredentialKind::UserOAuth => executor::AuthMethod::OAuth, - }; - (Some(t), method) - } + 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.