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

Add `--callback-host` and `--callback-port` flags to `gws auth login` so users can configure the OAuth callback server host and port. Both flags also read from environment variables `GOOGLE_WORKSPACE_CLI_CALLBACK_HOST` and `GOOGLE_WORKSPACE_CLI_CALLBACK_PORT` respectively (CLI flags take precedence). This is useful when the OAuth app is registered with a fixed redirect URI or when running in Docker/CI with port-forwarding.
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,8 @@ 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_CALLBACK_HOST` | Hostname used in the OAuth redirect URI during `gws auth login` (default: `localhost`; overridden by `--callback-host`) |
| `GOOGLE_WORKSPACE_CLI_CALLBACK_PORT` | Port for the local OAuth callback server during `gws auth login` (default: `0` = OS-assigned; overridden by `--callback-port`) |

### Sanitization (Model Armor)

Expand Down
2 changes: 1 addition & 1 deletion crates/google-workspace-cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ google-workspace = { version = "0.22.5", path = "../google-workspace" }
tempfile = "3"
aes-gcm = "0.10"
anyhow = "1"
clap = { version = "4", features = ["derive", "string"] }
clap = { version = "4", features = ["derive", "string", "env"] }
dirs = "5"
dotenvy = "0.15"
hostname = "0.4"
Expand Down
157 changes: 140 additions & 17 deletions crates/google-workspace-cli/src/auth_commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -114,15 +114,24 @@ async fn login_with_proxy_support(
client_id: &str,
client_secret: &str,
scopes: &[String],
callback_host: &str,
callback_port: u16,
) -> Result<(String, String), GwsError> {
// Start local server to receive OAuth callback
let listener = TcpListener::bind("127.0.0.1:0")
// Start local server to receive OAuth callback.
// Bind to loopback for local hostnames, or all interfaces for custom hosts
// (e.g. Docker/CI where the callback arrives via port-forwarding).
let bind_addr = if callback_host == "localhost" || callback_host == "127.0.0.1" {
format!("127.0.0.1:{}", callback_port)
} else {
format!("0.0.0.0:{}", callback_port)
};
let listener = TcpListener::bind(&bind_addr)
.map_err(|e| GwsError::Auth(format!("Failed to start local server: {e}")))?;
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 = format!("http://{}:{}", callback_host, port);

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

Expand Down Expand Up @@ -392,6 +401,23 @@ fn build_login_subcommand() -> clap::Command {
)
.value_name("services"),
)
.arg(
clap::Arg::new("callback-host")
.long("callback-host")
.env("GOOGLE_WORKSPACE_CLI_CALLBACK_HOST")
.help("Hostname used in the OAuth redirect URI (default: localhost)")
.value_name("HOST")
.default_value("localhost"),
)
.arg(
clap::Arg::new("callback-port")
.long("callback-port")
.env("GOOGLE_WORKSPACE_CLI_CALLBACK_PORT")
.help("Port for the local OAuth callback server (0 = OS-assigned)")
.value_name("PORT")
.value_parser(clap::value_parser!(u16))
.default_value("0"),
)
}

/// Build the clap Command for `gws auth`.
Expand Down Expand Up @@ -448,9 +474,10 @@ pub async fn handle_auth_command(args: &[String]) -> Result<(), GwsError> {

match matches.subcommand() {
Some(("login", sub_m)) => {
let (scope_mode, services_filter) = parse_login_args(sub_m);
let (scope_mode, services_filter, callback_host, callback_port) =
parse_login_args(sub_m);

handle_login_inner(scope_mode, services_filter).await
handle_login_inner(scope_mode, services_filter, callback_host, callback_port).await
}
Some(("setup", sub_m)) => {
// Collect remaining args and delegate to setup's own clap parser.
Expand Down Expand Up @@ -482,8 +509,10 @@ fn login_command() -> clap::Command {
build_login_subcommand()
}

/// Extract `ScopeMode` and optional services filter from parsed login args.
fn parse_login_args(matches: &clap::ArgMatches) -> (ScopeMode, Option<HashSet<String>>) {
/// Extract `ScopeMode`, optional services filter, and OAuth callback config from parsed login args.
fn parse_login_args(
matches: &clap::ArgMatches,
) -> (ScopeMode, Option<HashSet<String>>, String, u16) {
let scope_mode = if let Some(scopes_str) = matches.get_one::<String>("scopes") {
ScopeMode::Custom(
scopes_str
Expand All @@ -508,7 +537,16 @@ fn parse_login_args(matches: &clap::ArgMatches) -> (ScopeMode, Option<HashSet<St
.collect()
});

(scope_mode, services_filter)
let callback_host = matches
.get_one::<String>("callback-host")
.expect("callback-host has a default_value and is always present")
.clone();

let callback_port = *matches
.get_one::<u16>("callback-port")
.expect("callback-port has a default_value and is always present");

(scope_mode, services_filter, callback_host, callback_port)
}

/// Run the `auth login` flow.
Expand All @@ -532,9 +570,9 @@ pub async fn run_login(args: &[String]) -> Result<(), GwsError> {
Err(e) => return Err(GwsError::Validation(e.to_string())),
};

let (scope_mode, services_filter) = parse_login_args(&matches);
let (scope_mode, services_filter, callback_host, callback_port) = parse_login_args(&matches);

handle_login_inner(scope_mode, services_filter).await
handle_login_inner(scope_mode, services_filter, callback_host, callback_port).await
}
/// Custom delegate that prints the OAuth URL on its own line for easy copying.
/// Optionally includes `login_hint` in the URL for account pre-selection.
Expand Down Expand Up @@ -576,6 +614,8 @@ impl yup_oauth2::authenticator_delegate::InstalledFlowDelegate for CliFlowDelega
async fn handle_login_inner(
scope_mode: ScopeMode,
services_filter: Option<HashSet<String>>,
callback_host: String,
callback_port: u16,
) -> Result<(), GwsError> {
// Resolve client_id and client_secret:
// 1. Env vars (highest priority)
Expand Down Expand Up @@ -618,13 +658,22 @@ 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)
// 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?
};
// If proxy env vars are set, or a custom callback host/port is requested,
// use proxy-aware OAuth flow (reqwest). Otherwise use yup-oauth2 (faster,
// but doesn't support proxy or custom callback configuration).
let (access_token, refresh_token) =
if crate::auth::has_proxy_env() || callback_port != 0 || callback_host != "localhost" {
login_with_proxy_support(
&client_id,
&client_secret,
&scopes,
&callback_host,
callback_port,
)
.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 @@ -2532,4 +2581,78 @@ mod tests {
let err = read_refresh_token_from_cache(file.path()).unwrap_err();
assert!(err.to_string().contains("no refresh token was returned"));
}

#[test]
#[serial_test::serial]
fn parse_login_args_defaults_callback_host_and_port() {
unsafe {
std::env::remove_var("GOOGLE_WORKSPACE_CLI_CALLBACK_HOST");
std::env::remove_var("GOOGLE_WORKSPACE_CLI_CALLBACK_PORT");
}
let matches = build_login_subcommand()
.try_get_matches_from(["login"])
.unwrap();
let (_, _, callback_host, callback_port) = parse_login_args(&matches);
assert_eq!(callback_host, "localhost");
assert_eq!(callback_port, 0);
}

#[test]
fn parse_login_args_custom_callback_host_and_port() {
let matches = build_login_subcommand()
.try_get_matches_from(["login", "--callback-host", "127.0.0.1", "--callback-port", "9090"])
.unwrap();
let (_, _, callback_host, callback_port) = parse_login_args(&matches);
assert_eq!(callback_host, "127.0.0.1");
assert_eq!(callback_port, 9090u16);
}

#[test]
#[serial_test::serial]
fn parse_login_args_callback_host_from_env() {
unsafe {
std::env::set_var("GOOGLE_WORKSPACE_CLI_CALLBACK_HOST", "myhost.local");
}
let matches = build_login_subcommand()
.try_get_matches_from(["login"])
.unwrap();
let (_, _, callback_host, _) = parse_login_args(&matches);
unsafe {
std::env::remove_var("GOOGLE_WORKSPACE_CLI_CALLBACK_HOST");
}
assert_eq!(callback_host, "myhost.local");
}

#[test]
#[serial_test::serial]
fn parse_login_args_callback_port_from_env() {
unsafe {
std::env::set_var("GOOGLE_WORKSPACE_CLI_CALLBACK_PORT", "8888");
}
let matches = build_login_subcommand()
.try_get_matches_from(["login"])
.unwrap();
let (_, _, _, callback_port) = parse_login_args(&matches);
unsafe {
std::env::remove_var("GOOGLE_WORKSPACE_CLI_CALLBACK_PORT");
}
assert_eq!(callback_port, 8888u16);
}

#[test]
#[serial_test::serial]
fn parse_login_args_cli_arg_overrides_env_for_callback() {
// CLI arg takes precedence even when env var is set
unsafe {
std::env::set_var("GOOGLE_WORKSPACE_CLI_CALLBACK_PORT", "7777");
}
let matches = build_login_subcommand()
.try_get_matches_from(["login", "--callback-port", "5555"])
.unwrap();
let (_, _, _, callback_port) = parse_login_args(&matches);
unsafe {
std::env::remove_var("GOOGLE_WORKSPACE_CLI_CALLBACK_PORT");
}
assert_eq!(callback_port, 5555u16);
}
}
Loading