Skip to content
Closed
Show file tree
Hide file tree
Changes from 9 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/oauth-redirect-override.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@googleworkspace/cli": minor
---

Allow customizing the `gws auth login` OAuth callback so it works when the CLI runs on a remote host whose `localhost` the browser connot reach directly: `GOOGLE_WORKSPACE_CLI_OAUTH_REDIRECT_URI` overrides the redirect URI sent to Google, `GOOGLE_WORKSPACE_CLI_OAUTH_STATE` sets the `state` value passed through to the auth URL, and `GOOGLE_WORKSPACE_CLI_OAUTH_PORT` pins the local callback (loopback) port.
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,12 @@
# GOOGLE_WORKSPACE_CLI_CLIENT_ID=
# GOOGLE_WORKSPACE_CLI_CLIENT_SECRET=

# ── OAuth callback customization (for remote-host logins) ─────────
# Override the redirect URI sent to Google, set a state value passed through to the auth URL, and/or pin the local callback port.
# GOOGLE_WORKSPACE_CLI_OAUTH_REDIRECT_URI=
# GOOGLE_WORKSPACE_CLI_OAUTH_STATE=
# GOOGLE_WORKSPACE_CLI_OAUTH_PORT=

# ── Configuration ─────────────────────────────────────────────────
# Override the config directory (default: ~/.config/gws)
# GOOGLE_WORKSPACE_CLI_CONFIG_DIR=
Expand Down
3 changes: 3 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,9 @@ See [`src/helpers/README.md`](crates/google-workspace-cli/src/helpers/README.md)
|---|---|
| `GOOGLE_WORKSPACE_CLI_CLIENT_ID` | OAuth client ID (for `gws auth login` when no `client_secret.json` is saved) |
| `GOOGLE_WORKSPACE_CLI_CLIENT_SECRET` | OAuth client secret (paired with `CLIENT_ID` above) |
| `GOOGLE_WORKSPACE_CLI_OAUTH_REDIRECT_URI` | Override the OAuth redirect URI (for remote-host logins) |
| `GOOGLE_WORKSPACE_CLI_OAUTH_STATE` | OAuth `state` value, passed through to the auth URL |
| `GOOGLE_WORKSPACE_CLI_OAUTH_PORT` | Pin the local OAuth callback port (default: random) |

### Sanitization (Model Armor)

Expand Down
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ The CLI supports multiple auth workflows so it works on your laptop, in CI, and
| A GCP project but no `gcloud` | [Manual OAuth setup](#manual-oauth-setup-google-cloud-console) |
| An existing OAuth access token | [`GOOGLE_WORKSPACE_CLI_TOKEN`](#pre-obtained-access-token) |
| Existing Credentials | [`GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE`](#service-account-server-to-server) |
| A remote host unreachable via `localhost` from my browser | [Remote host](#remote-host-browser-on-another-machine) |

### Interactive (local desktop)

Expand Down Expand Up @@ -187,6 +188,22 @@ If scope checkboxes appear, select required scopes (or **Select all**) before co
gws drive files list # just works
```

### Remote host (browser on another machine)

When the CLI runs where the browser cannot reach via `localhost` (e.g. a cloud development environment), tell the OAuth server to redirect the authorization code to the CLI's host:

```bash
# Where Google sends the browser (must be an authorized redirect URI on the OAuth client)
export GOOGLE_WORKSPACE_CLI_OAUTH_REDIRECT_URI="https://example.com/callback"
# Optional value passed through to the listening server (see OAuth 2.0 docs)
export GOOGLE_WORKSPACE_CLI_OAUTH_STATE="$STATE"
# Optional port for the CLI's callback server
export GOOGLE_WORKSPACE_CLI_OAUTH_PORT=42067
gws auth login
```

This flow requires a web application OAuth client that would allow redirects to URIs other than `http://localhost`. Provide the client's details via `GOOGLE_WORKSPACE_CLI_CLIENT_ID` and `GOOGLE_WORKSPACE_CLI_CLIENT_SECRET` environment variables.

### Service Account (server-to-server)

Point to your key file; no login needed.
Expand Down Expand Up @@ -381,6 +398,9 @@ All variables are optional. See [`.env.example`](.env.example) for a copy-paste
| `GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE` | Path to OAuth credentials JSON (user or service account) |
| `GOOGLE_WORKSPACE_CLI_CLIENT_ID` | OAuth client ID (alternative to `client_secret.json`) |
| `GOOGLE_WORKSPACE_CLI_CLIENT_SECRET` | OAuth client secret (paired with `CLIENT_ID`) |
| `GOOGLE_WORKSPACE_CLI_OAUTH_REDIRECT_URI` | Override the OAuth redirect URI (for remote-host logins) |
| `GOOGLE_WORKSPACE_CLI_OAUTH_STATE` | OAuth `state` value, passed through to the auth URL |
| `GOOGLE_WORKSPACE_CLI_OAUTH_PORT` | Pin the local OAuth callback port (default: random) |
| `GOOGLE_WORKSPACE_CLI_CONFIG_DIR` | Override config directory (default: `~/.config/gws`) |
| `GOOGLE_WORKSPACE_CLI_SANITIZE_TEMPLATE` | Default Model Armor template |
| `GOOGLE_WORKSPACE_CLI_SANITIZE_MODE` | `warn` (default) or `block` |
Expand Down Expand Up @@ -457,6 +477,8 @@ gws auth login --scopes drive,gmail,calendar

The OAuth client was not created as a **Desktop app** type. In the [Credentials](https://console.cloud.google.com/apis/credentials) page, delete the existing client, create a new one with type **Desktop app**, and download the new JSON.

Using the [remote-host flow](#remote-host-browser-on-another-machine) instead? There it means the URL in `GOOGLE_WORKSPACE_CLI_OAUTH_REDIRECT_URI` is not registered on your **Web application** client - add it under **Authorized redirect URIs**.

### API not enabled — `accessNotConfigured`

If a required Google API is not enabled for your GCP project, you will see a
Expand Down
39 changes: 1 addition & 38 deletions crates/google-workspace-cli/src/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -436,47 +436,10 @@ async fn load_credentials_inner(
#[cfg(test)]
mod tests {
use super::*;
use crate::test_support::EnvVarGuard;
use std::io::Write;
use tempfile::NamedTempFile;

/// RAII guard that saves the current value of an environment variable and
/// restores it when dropped. This ensures cleanup even if a test panics.
struct EnvVarGuard {
name: String,
original: Option<std::ffi::OsString>,
}

impl EnvVarGuard {
/// Save the current value of `name`, then set it to `value`.
fn set(name: &str, value: impl AsRef<std::ffi::OsStr>) -> Self {
let original = std::env::var_os(name);
std::env::set_var(name, value);
Self {
name: name.to_string(),
original,
}
}

/// Save the current value of `name`, then remove it.
fn remove(name: &str) -> Self {
let original = std::env::var_os(name);
std::env::remove_var(name);
Self {
name: name.to_string(),
original,
}
}
}

impl Drop for EnvVarGuard {
fn drop(&mut self) {
match &self.original {
Some(v) => std::env::set_var(&self.name, v),
None => std::env::remove_var(&self.name),
}
}
}

fn clear_proxy_env() -> Vec<EnvVarGuard> {
PROXY_ENV_VARS
.iter()
Expand Down
118 changes: 104 additions & 14 deletions crates/google-workspace-cli/src/auth_commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -72,9 +72,14 @@ async fn exchange_code_with_reqwest(
.map_err(|e| GwsError::Auth(format!("Failed to parse token response: {e}")))
}

fn build_proxy_auth_url(client_id: &str, redirect_uri: &str, scopes: &[String]) -> String {
fn build_proxy_auth_url(
client_id: &str,
redirect_uri: &str,
scopes: &[String],
state: Option<&str>,
) -> String {
let scopes_str = scopes.join(" ");
format!(
let mut url = format!(
"https://accounts.google.com/o/oauth2/auth?\
scope={}&\
access_type=offline&\
Expand All @@ -85,7 +90,27 @@ fn build_proxy_auth_url(client_id: &str, redirect_uri: &str, scopes: &[String])
urlencoding(&scopes_str),
urlencoding(redirect_uri),
urlencoding(client_id)
)
);
if let Some(state) = state {
url.push_str("&state=");
url.push_str(&urlencoding(state));
}
url
}

// Env vars for customizing the OAuth login callback (see `gws --help`).
const ENV_OAUTH_REDIRECT_URI: &str = "GOOGLE_WORKSPACE_CLI_OAUTH_REDIRECT_URI";
const ENV_OAUTH_STATE: &str = "GOOGLE_WORKSPACE_CLI_OAUTH_STATE";
const ENV_OAUTH_PORT: &str = "GOOGLE_WORKSPACE_CLI_OAUTH_PORT";

fn get_non_empty_env_var(key: &str) -> Option<String> {
std::env::var(key).ok().filter(|v| !v.is_empty())
}

fn has_oauth_callback_override() -> bool {
[ENV_OAUTH_REDIRECT_URI, ENV_OAUTH_STATE, ENV_OAUTH_PORT]
.iter()
.any(|key| get_non_empty_env_var(key).is_some())
}

fn extract_authorization_code(request_line: &str) -> Result<String, GwsError> {
Expand All @@ -100,31 +125,52 @@ fn extract_authorization_code(request_line: &str) -> Result<String, GwsError> {
query.split('&').find_map(|pair| {
let mut parts = pair.split('=');
if parts.next() == Some("code") {
parts.next().map(|value| value.to_string())
parts.next()
} else {
None
}
})
})
.map(|value| {
percent_encoding::percent_decode_str(value)
.decode_utf8_lossy()
.into_owned()
})
.ok_or_else(|| GwsError::Auth("No authorization code in callback".to_string()))
}
Comment thread
npeshkov marked this conversation as resolved.
Outdated

fn oauth_redirect_uri(port: u16) -> String {
get_non_empty_env_var(ENV_OAUTH_REDIRECT_URI)
.unwrap_or_else(|| format!("http://localhost:{port}"))
}

fn oauth_callback_port() -> Result<u16, GwsError> {
get_non_empty_env_var(ENV_OAUTH_PORT)
.map(|p| {
p.parse::<u16>()
.map_err(|e| GwsError::Auth(format!("Invalid {ENV_OAUTH_PORT}: {e}")))
})
.transpose()
.map(|port| port.unwrap_or(0))
}

/// Perform OAuth login flow with proxy support using reqwest for token exchange
async fn login_with_proxy_support(
client_id: &str,
client_secret: &str,
scopes: &[String],
) -> Result<(String, String), GwsError> {
// Start local server to receive OAuth callback
let listener = TcpListener::bind("127.0.0.1:0")
let listener = TcpListener::bind(("127.0.0.1", oauth_callback_port()?))
Comment thread
npeshkov marked this conversation as resolved.
.map_err(|e| GwsError::Auth(format!("Failed to start local server: {e}")))?;
Comment thread
npeshkov marked this conversation as resolved.
let port = listener
.local_addr()
.map_err(|e| GwsError::Auth(format!("Failed to inspect local server: {e}")))?
.port();
let redirect_uri = format!("http://localhost:{}", port);
let redirect_uri = oauth_redirect_uri(port);
let state = get_non_empty_env_var(ENV_OAUTH_STATE);

let auth_url = build_proxy_auth_url(client_id, &redirect_uri, scopes);
let auth_url = build_proxy_auth_url(client_id, &redirect_uri, scopes, state.as_deref());

println!("Open this URL in your browser to authenticate:\n");
println!(" {}\n", auth_url);
Expand Down Expand Up @@ -618,13 +664,14 @@ async fn handle_login_inner(
std::fs::create_dir_all(&config)
.map_err(|e| GwsError::Validation(format!("Failed to create config directory: {e}")))?;

// If proxy env vars are set, use proxy-aware OAuth flow (reqwest)
// If proxy or oauth env vars are set, use proxy-aware OAuth flow (reqwest)
// Otherwise use yup-oauth2 (faster, but doesn't support proxy)
let (access_token, refresh_token) = if crate::auth::has_proxy_env() {
login_with_proxy_support(&client_id, &client_secret, &scopes).await?
} else {
login_with_yup_oauth(&config, &client_id, &client_secret, &scopes).await?
};
let (access_token, refresh_token) =
if crate::auth::has_proxy_env() || has_oauth_callback_override() {
login_with_proxy_support(&client_id, &client_secret, &scopes).await?
} else {
login_with_yup_oauth(&config, &client_id, &client_secret, &scopes).await?
};

// Build credentials in the standard authorized_user format
let creds_json = json!({
Expand Down Expand Up @@ -1722,6 +1769,7 @@ async fn augment_with_dynamic_scopes(
#[cfg(test)]
mod tests {
use super::*;
use crate::test_support::EnvVarGuard;

/// Helper to run resolve_scopes in tests (async).
fn run_resolve_scopes(scope_mode: ScopeMode, project_id: Option<&str>) -> Vec<String> {
Expand Down Expand Up @@ -2487,14 +2535,47 @@ mod tests {
"https://www.googleapis.com/auth/drive".to_string(),
"openid".to_string(),
];
let url = build_proxy_auth_url("client id", "http://localhost:8080/callback path", &scopes);
let url = build_proxy_auth_url(
"client id",
"http://localhost:8080/callback path",
&scopes,
None,
);

assert!(url.contains("client_id=client%20id"));
assert!(url.contains("redirect_uri=http%3A%2F%2Flocalhost%3A8080%2Fcallback%20path"));
assert!(url.contains(&format!(
"scope={}",
urlencoding("https://www.googleapis.com/auth/drive openid")
)));
assert!(!url.contains("state="));
}

#[test]
fn build_proxy_auth_url_includes_state_when_present() {
let scopes = vec!["openid".to_string()];
let url = build_proxy_auth_url(
"client id",
"https://example.com/callback",
&scopes,
Some("eyJub25jZSI6eyJyZWRpcmVjdFVybCI6Ii4uLiJ9fQ"),
);

assert!(url.contains("state=eyJub25jZSI6eyJyZWRpcmVjdFVybCI6Ii4uLiJ9fQ"));
}

#[test]
#[serial_test::serial]
fn oauth_redirect_uri_defaults_to_localhost() {
let _uri = EnvVarGuard::remove(ENV_OAUTH_REDIRECT_URI);
assert_eq!(oauth_redirect_uri(12345), "http://localhost:12345");
}

#[test]
#[serial_test::serial]
fn oauth_redirect_uri_honors_override() {
let _uri = EnvVarGuard::set(ENV_OAUTH_REDIRECT_URI, "https://example.com/callback");
assert_eq!(oauth_redirect_uri(12345), "https://example.com/callback");
}

#[test]
Expand All @@ -2505,6 +2586,15 @@ mod tests {
assert_eq!(code, "4/test-code");
}

#[test]
fn extract_authorization_code_decodes_percent_encoding() {
let code = extract_authorization_code(
"GET /?state=abc&code=4%2Ftest-code&scope=openid HTTP/1.1",
)
.unwrap();
assert_eq!(code, "4/test-code");
}

Comment thread
npeshkov marked this conversation as resolved.
#[test]
fn extract_authorization_code_rejects_missing_code() {
let err = extract_authorization_code("GET /?state=abc HTTP/1.1").unwrap_err();
Expand Down
11 changes: 11 additions & 0 deletions crates/google-workspace-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ mod schema;
mod services;
mod setup;
mod setup_tui;
#[cfg(test)]
mod test_support;
mod text;
mod timezone;
mod token_storage;
Expand Down Expand Up @@ -481,6 +483,15 @@ fn print_usage() {
println!(
" GOOGLE_WORKSPACE_CLI_CLIENT_SECRET OAuth client secret (for gws auth login)"
);
println!(
" GOOGLE_WORKSPACE_CLI_OAUTH_REDIRECT_URI Override OAuth redirect URI (for remote-host logins)"
);
println!(
" GOOGLE_WORKSPACE_CLI_OAUTH_STATE OAuth state value, passed through to the auth URL"
);
println!(
" GOOGLE_WORKSPACE_CLI_OAUTH_PORT Pin the local OAuth callback port (default: random)"
);
println!(
" GOOGLE_WORKSPACE_CLI_CONFIG_DIR Override config directory (default: ~/.config/gws)"
);
Expand Down
Loading
Loading