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
59 changes: 47 additions & 12 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,14 +175,17 @@ 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() {
request = request.header("x-goog-user-project", quota_project);
// 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);
}
}
Comment on lines +183 to 189
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

By completely skipping the x-goog-user-project header for all OAuth requests, we break the documented behavior of the GOOGLE_WORKSPACE_PROJECT_ID environment variable. If a user explicitly sets GOOGLE_WORKSPACE_PROJECT_ID to override the quota/billing project, they expect it to be sent even when using OAuth.

We should only skip the automatic project ID from client_secret.json for OAuth users, but still allow explicit overrides via GOOGLE_WORKSPACE_PROJECT_ID.

    // Only send quota project for service-account auth, or if explicitly overridden via env var for OAuth.
    // OAuth users may not be IAM members of the project in client_secret.json, so sending it unconditionally triggers 403 errors.
    let quota_project = if *auth_method == AuthMethod::ServiceAccount {
        crate::auth::get_quota_project()
    } else if *auth_method == AuthMethod::OAuth {
        std::env::var("GOOGLE_WORKSPACE_PROJECT_ID").ok().filter(|s| !s.is_empty())
    } else {
        None
    };

    if let Some(quota_project) = quota_project {
        request = request.header("x-goog-user-project", quota_project);
    }


let mut all_query_params = input.query_params.clone();
Expand Down Expand Up @@ -2399,3 +2395,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"
);
}
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