From 9c3dc25b1dce6288a9bd96b5a8ae749762a830d2 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Fri, 6 Mar 2026 01:10:33 +0000 Subject: [PATCH 1/9] feat(auth): add `--no-localhost` flag to `gws auth login` This commit introduces a `--no-localhost` flag to the `login` subcommand, which enables an out-of-band (OOB) OAuth flow. When this flag is provided, the CLI will output an authentication URL and prompt the user to paste the authorization code back into the terminal. This is particularly useful in environments where a local redirect server cannot be started or accessed, similar to `clasp login --no-localhost`. The flag adjusts the redirect URIs to prioritize `urn:ietf:wg:oauth:2.0:oob` and configures the `InstalledFlowAuthenticator` to use `InstalledFlowReturnMethod::Interactive` instead of `HTTPRedirect`. --- src/auth_commands.rs | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/src/auth_commands.rs b/src/auth_commands.rs index c9b12e1d..14210e1a 100644 --- a/src/auth_commands.rs +++ b/src/auth_commands.rs @@ -140,6 +140,7 @@ pub async fn handle_auth_command(args: &[String]) -> Result<(), GwsError> { " --scopes Comma-separated custom scopes\n", " -s, --services Comma-separated service names to limit scope picker\n", " (e.g. -s drive,gmail,sheets)\n", + " --no-localhost Use out-of-band flow instead of starting a local server\n", " setup Configure GCP project + OAuth client (requires gcloud)\n", " --project Use a specific GCP project\n", " status Show current authentication state\n", @@ -210,9 +211,10 @@ impl yup_oauth2::authenticator_delegate::InstalledFlowDelegate for CliFlowDelega } async fn handle_login(args: &[String]) -> Result<(), GwsError> { - // Extract --account and -s/--services from args + // Extract --account, -s/--services, and --no-localhost from args let mut account_email: Option = None; let mut services_filter: Option> = None; + let mut no_localhost = false; let mut filtered_args: Vec = Vec::new(); let mut skip_next = false; for i in 0..args.len() { @@ -220,6 +222,10 @@ async fn handle_login(args: &[String]) -> Result<(), GwsError> { skip_next = false; continue; } + if args[i] == "--no-localhost" { + no_localhost = true; + continue; + } if args[i] == "--account" && i + 1 < args.len() { account_email = Some(args[i + 1].clone()); skip_next = true; @@ -278,12 +284,18 @@ async fn handle_login(args: &[String]) -> Result<(), GwsError> { // are already included. let mut scopes = filter_redundant_restrictive_scopes(scopes); + let redirect_uris = if no_localhost { + vec!["urn:ietf:wg:oauth:2.0:oob".to_string(), "http://localhost".to_string()] + } else { + vec!["http://localhost".to_string(), "urn:ietf:wg:oauth:2.0:oob".to_string()] + }; + let secret = yup_oauth2::ApplicationSecret { client_id: client_id.clone(), client_secret: client_secret.clone(), auth_uri: "https://accounts.google.com/o/oauth2/auth".to_string(), token_uri: "https://oauth2.googleapis.com/token".to_string(), - redirect_uris: vec!["http://localhost".to_string()], + redirect_uris, ..Default::default() }; @@ -310,9 +322,15 @@ async fn handle_login(args: &[String]) -> Result<(), GwsError> { .map_err(|e| GwsError::Validation(format!("Failed to create config directory: {e}")))?; } + let return_method = if no_localhost { + yup_oauth2::InstalledFlowReturnMethod::Interactive + } else { + yup_oauth2::InstalledFlowReturnMethod::HTTPRedirect + }; + let auth = yup_oauth2::InstalledFlowAuthenticator::builder( secret, - yup_oauth2::InstalledFlowReturnMethod::HTTPRedirect, + return_method, ) .with_storage(Box::new(crate::token_storage::EncryptedTokenStorage::new( temp_path.clone(), From 5e6f316352d3a5c2b9d57d63b6685aacc6281581 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Fri, 6 Mar 2026 01:12:08 +0000 Subject: [PATCH 2/9] feat(auth): add `--no-localhost` flag to `gws auth login` This commit introduces a `--no-localhost` flag to the `login` subcommand, which enables an out-of-band (OOB) OAuth flow. When this flag is provided, the CLI will output an authentication URL and prompt the user to paste the authorization code back into the terminal. This is particularly useful in environments where a local redirect server cannot be started or accessed, similar to `clasp login --no-localhost`. The flag adjusts the redirect URIs to prioritize `urn:ietf:wg:oauth:2.0:oob` and configures the `InstalledFlowAuthenticator` to use `InstalledFlowReturnMethod::Interactive` instead of `HTTPRedirect`. --- .changeset/add-no-localhost-flag.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/add-no-localhost-flag.md diff --git a/.changeset/add-no-localhost-flag.md b/.changeset/add-no-localhost-flag.md new file mode 100644 index 00000000..b09949ef --- /dev/null +++ b/.changeset/add-no-localhost-flag.md @@ -0,0 +1,5 @@ +--- +"@googleworkspace/cli": minor +--- + +feat(auth): add `--no-localhost` flag to `gws auth login` From 11e7bbdc9be0d7c586c0e54e8e503fd72339473d Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Fri, 6 Mar 2026 01:18:46 +0000 Subject: [PATCH 3/9] feat(auth): add `--no-localhost` flag to `gws auth login` This commit introduces a `--no-localhost` flag to the `login` subcommand, which enables an out-of-band (OOB) OAuth flow. When this flag is provided, the CLI will output an authentication URL and prompt the user to paste the authorization code back into the terminal. This is particularly useful in environments where a local redirect server cannot be started or accessed, similar to `clasp login --no-localhost`. The flag adjusts the redirect URIs to prioritize `urn:ietf:wg:oauth:2.0:oob` and configures the `InstalledFlowAuthenticator` to use `InstalledFlowReturnMethod::Interactive` instead of `HTTPRedirect`. Additionally, it extracts the authorization code from a full redirect URL pasted by the user. --- src/auth_commands.rs | 62 +++++++++++++++++++++++++++++++------------- 1 file changed, 44 insertions(+), 18 deletions(-) diff --git a/src/auth_commands.rs b/src/auth_commands.rs index 14210e1a..5e94300a 100644 --- a/src/auth_commands.rs +++ b/src/auth_commands.rs @@ -184,7 +184,7 @@ impl yup_oauth2::authenticator_delegate::InstalledFlowDelegate for CliFlowDelega fn present_user_url<'a>( &'a self, url: &'a str, - _need_code: bool, + need_code: bool, ) -> std::pin::Pin> + Send + 'a>> { Box::pin(async move { @@ -205,7 +205,30 @@ impl yup_oauth2::authenticator_delegate::InstalledFlowDelegate for CliFlowDelega }; eprintln!("Open this URL in your browser to authenticate:\n"); eprintln!(" {display_url}\n"); - Ok(String::new()) + + if need_code { + eprintln!("Enter the authorization code (or paste the full redirect URL):"); + let mut user_input = String::new(); + std::io::stdin() + .read_line(&mut user_input) + .map_err(|e| format!("Failed to read code: {e}"))?; + + let input = user_input.trim(); + + // If they pasted a full URL (e.g. http://localhost/?code=4/0Aea...&scope=...) + if let Ok(parsed_url) = reqwest::Url::parse(input) { + for (k, v) in parsed_url.query_pairs() { + if k == "code" { + return Ok(v.to_string()); + } + } + } + + // Otherwise, assume they pasted just the code + Ok(input.to_string()) + } else { + Ok(String::new()) + } }) } } @@ -285,9 +308,15 @@ async fn handle_login(args: &[String]) -> Result<(), GwsError> { let mut scopes = filter_redundant_restrictive_scopes(scopes); let redirect_uris = if no_localhost { - vec!["urn:ietf:wg:oauth:2.0:oob".to_string(), "http://localhost".to_string()] + vec![ + "urn:ietf:wg:oauth:2.0:oob".to_string(), + "http://localhost".to_string(), + ] } else { - vec!["http://localhost".to_string(), "urn:ietf:wg:oauth:2.0:oob".to_string()] + vec![ + "http://localhost".to_string(), + "urn:ietf:wg:oauth:2.0:oob".to_string(), + ] }; let secret = yup_oauth2::ApplicationSecret { @@ -328,20 +357,17 @@ async fn handle_login(args: &[String]) -> Result<(), GwsError> { yup_oauth2::InstalledFlowReturnMethod::HTTPRedirect }; - let auth = yup_oauth2::InstalledFlowAuthenticator::builder( - secret, - return_method, - ) - .with_storage(Box::new(crate::token_storage::EncryptedTokenStorage::new( - temp_path.clone(), - ))) - .force_account_selection(true) // Adds prompt=consent so Google always returns a refresh_token - .flow_delegate(Box::new(CliFlowDelegate { - login_hint: account_email.clone(), - })) - .build() - .await - .map_err(|e| GwsError::Auth(format!("Failed to build authenticator: {e}")))?; + let auth = yup_oauth2::InstalledFlowAuthenticator::builder(secret, return_method) + .with_storage(Box::new(crate::token_storage::EncryptedTokenStorage::new( + temp_path.clone(), + ))) + .force_account_selection(true) // Adds prompt=consent so Google always returns a refresh_token + .flow_delegate(Box::new(CliFlowDelegate { + login_hint: account_email.clone(), + })) + .build() + .await + .map_err(|e| GwsError::Auth(format!("Failed to build authenticator: {e}")))?; // Request a token — this triggers the browser OAuth flow let scope_refs: Vec<&str> = scopes.iter().map(|s| s.as_str()).collect(); From f7e9a452822d4ecf4246aec8e21262b54c5d78c6 Mon Sep 17 00:00:00 2001 From: Justin Poehnelt Date: Thu, 5 Mar 2026 17:22:13 -0800 Subject: [PATCH 4/9] Update src/auth_commands.rs Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- src/auth_commands.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/auth_commands.rs b/src/auth_commands.rs index 5e94300a..9f21a698 100644 --- a/src/auth_commands.rs +++ b/src/auth_commands.rs @@ -313,10 +313,10 @@ async fn handle_login(args: &[String]) -> Result<(), GwsError> { "http://localhost".to_string(), ] } else { - vec![ - "http://localhost".to_string(), - "urn:ietf:wg:oauth:2.0:oob".to_string(), - ] + let redirect_uris = if no_localhost { + vec!["urn:ietf:wg:oauth:2.0:oob".to_string()] + } else { + vec!["http://localhost".to_string()] }; let secret = yup_oauth2::ApplicationSecret { From 37091bebdf43a40099b4397df60a1afeb3212c26 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Fri, 6 Mar 2026 01:25:37 +0000 Subject: [PATCH 5/9] fix: compile issues in `gws auth login --no-localhost` Resolved an unclosed delimiter compiler error that was caused by a formatting/merge conflict in `src/auth_commands.rs`. Tests pass, and `cargo fmt` has been run on all code. --- src/auth_commands.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/auth_commands.rs b/src/auth_commands.rs index 9f21a698..5e94300a 100644 --- a/src/auth_commands.rs +++ b/src/auth_commands.rs @@ -313,10 +313,10 @@ async fn handle_login(args: &[String]) -> Result<(), GwsError> { "http://localhost".to_string(), ] } else { - let redirect_uris = if no_localhost { - vec!["urn:ietf:wg:oauth:2.0:oob".to_string()] - } else { - vec!["http://localhost".to_string()] + vec![ + "http://localhost".to_string(), + "urn:ietf:wg:oauth:2.0:oob".to_string(), + ] }; let secret = yup_oauth2::ApplicationSecret { From df651841085768ca8ec25ebfdb92dbd021e2f15d Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Fri, 6 Mar 2026 01:28:29 +0000 Subject: [PATCH 6/9] fix: syntax and formatting errors Resolved the unclosed delimiter in `src/auth_commands.rs` that broke CI. Verified by running `cargo fmt --all -- --check`, `cargo check`, `cargo build`, and `cargo test`. From 9654483609025d0cf0b93e366a98e75ce11c9257 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Fri, 6 Mar 2026 01:40:44 +0000 Subject: [PATCH 7/9] test(auth): add coverage for OOB code extraction Extracted the URL parsing logic for the out-of-band `--no-localhost` OAuth flow into a separate helper `extract_code_from_input` and added tests to prevent coverage drop. --- src/auth_commands.rs | 55 +++++++++++++++++++++++++++++++++----------- 1 file changed, 42 insertions(+), 13 deletions(-) diff --git a/src/auth_commands.rs b/src/auth_commands.rs index 5e94300a..0c05c8b1 100644 --- a/src/auth_commands.rs +++ b/src/auth_commands.rs @@ -213,19 +213,7 @@ impl yup_oauth2::authenticator_delegate::InstalledFlowDelegate for CliFlowDelega .read_line(&mut user_input) .map_err(|e| format!("Failed to read code: {e}"))?; - let input = user_input.trim(); - - // If they pasted a full URL (e.g. http://localhost/?code=4/0Aea...&scope=...) - if let Ok(parsed_url) = reqwest::Url::parse(input) { - for (k, v) in parsed_url.query_pairs() { - if k == "code" { - return Ok(v.to_string()); - } - } - } - - // Otherwise, assume they pasted just the code - Ok(input.to_string()) + Ok(extract_code_from_input(&user_input)) } else { Ok(String::new()) } @@ -233,6 +221,21 @@ impl yup_oauth2::authenticator_delegate::InstalledFlowDelegate for CliFlowDelega } } +/// Extracts the authorization code from user input. If the input is a full URL, +/// it parses the URL and extracts the `code` query parameter. Otherwise, it +/// assumes the entire input (trimmed) is the authorization code. +fn extract_code_from_input(input: &str) -> String { + let input = input.trim(); + if let Ok(parsed_url) = reqwest::Url::parse(input) { + for (k, v) in parsed_url.query_pairs() { + if k == "code" { + return v.to_string(); + } + } + } + input.to_string() +} + async fn handle_login(args: &[String]) -> Result<(), GwsError> { // Extract --account, -s/--services, and --no-localhost from args let mut account_email: Option = None; @@ -2228,4 +2231,30 @@ mod tests { // Exactly 9 chars — first 4 + last 4 with "..." in between assert_eq!(mask_secret("123456789"), "1234...6789"); } + + #[test] + fn test_extract_code_from_input_raw_code() { + assert_eq!(extract_code_from_input("4/0Aea..."), "4/0Aea..."); + assert_eq!(extract_code_from_input(" 4/0Aea... \n"), "4/0Aea..."); + } + + #[test] + fn test_extract_code_from_input_url() { + assert_eq!( + extract_code_from_input("http://localhost/?code=4/0Aea...&scope=email"), + "4/0Aea..." + ); + assert_eq!( + extract_code_from_input("urn:ietf:wg:oauth:2.0:oob?code=my-secret-code"), + "my-secret-code" + ); + } + + #[test] + fn test_extract_code_from_input_url_no_code() { + assert_eq!( + extract_code_from_input("http://localhost/?error=access_denied"), + "http://localhost/?error=access_denied" + ); + } } From 5a925702d33abee0079435c429f83cd22156cad1 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Fri, 6 Mar 2026 01:44:58 +0000 Subject: [PATCH 8/9] test(auth): add tests for handle_login argument parsing Extracted the argument parsing logic from `handle_login` into a new `parse_login_args` helper and added unit tests to cover the extraction of `--account`, `--services`, and the new `--no-localhost` flag. This resolves the Codecov coverage regression. --- src/auth_commands.rs | 61 ++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 59 insertions(+), 2 deletions(-) diff --git a/src/auth_commands.rs b/src/auth_commands.rs index 0c05c8b1..64597be2 100644 --- a/src/auth_commands.rs +++ b/src/auth_commands.rs @@ -236,8 +236,14 @@ fn extract_code_from_input(input: &str) -> String { input.to_string() } -async fn handle_login(args: &[String]) -> Result<(), GwsError> { - // Extract --account, -s/--services, and --no-localhost from args +struct LoginArgs { + account_email: Option, + services_filter: Option>, + no_localhost: bool, + filtered_args: Vec, +} + +fn parse_login_args(args: &[String]) -> LoginArgs { let mut account_email: Option = None; let mut services_filter: Option> = None; let mut no_localhost = false; @@ -281,6 +287,21 @@ async fn handle_login(args: &[String]) -> Result<(), GwsError> { filtered_args.push(args[i].clone()); } + LoginArgs { + account_email, + services_filter, + no_localhost, + filtered_args, + } +} + +async fn handle_login(args: &[String]) -> Result<(), GwsError> { + let parsed = parse_login_args(args); + let account_email = parsed.account_email; + let services_filter = parsed.services_filter; + let no_localhost = parsed.no_localhost; + let filtered_args = parsed.filtered_args; + // Resolve client_id and client_secret: // 1. Env vars (highest priority) // 2. Saved client_secret.json from `gws auth setup` or manual download @@ -2257,4 +2278,40 @@ mod tests { "http://localhost/?error=access_denied" ); } + + #[test] + fn test_parse_login_args_no_localhost() { + let args = vec!["--no-localhost".to_string(), "--scopes".to_string(), "drive".to_string()]; + let parsed = parse_login_args(&args); + assert!(parsed.no_localhost); + assert_eq!(parsed.filtered_args, vec!["--scopes", "drive"]); + } + + #[test] + fn test_parse_login_args_account() { + let args = vec!["--account".to_string(), "test@example.com".to_string(), "--no-localhost".to_string()]; + let parsed = parse_login_args(&args); + assert_eq!(parsed.account_email.unwrap(), "test@example.com"); + assert!(parsed.no_localhost); + assert!(parsed.filtered_args.is_empty()); + + let args2 = vec!["--account=test2@example.com".to_string()]; + let parsed2 = parse_login_args(&args2); + assert_eq!(parsed2.account_email.unwrap(), "test2@example.com"); + } + + #[test] + fn test_parse_login_args_services() { + let args = vec!["-s".to_string(), "drive,gmail".to_string()]; + let parsed = parse_login_args(&args); + let filter = parsed.services_filter.unwrap(); + assert!(filter.contains("drive")); + assert!(filter.contains("gmail")); + assert!(!parsed.no_localhost); + + let args2 = vec!["--services=sheets".to_string()]; + let parsed2 = parse_login_args(&args2); + let filter2 = parsed2.services_filter.unwrap(); + assert!(filter2.contains("sheets")); + } } From 210bee2f2c0e4125d969aaacfe32439d00c78db9 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Fri, 6 Mar 2026 01:52:04 +0000 Subject: [PATCH 9/9] fix: run cargo fmt on new tests Resolved CI formatting checks by running `cargo fmt` after adding the new unit tests for `parse_login_args`. --- src/auth_commands.rs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/auth_commands.rs b/src/auth_commands.rs index 64597be2..6603dcbc 100644 --- a/src/auth_commands.rs +++ b/src/auth_commands.rs @@ -2281,7 +2281,11 @@ mod tests { #[test] fn test_parse_login_args_no_localhost() { - let args = vec!["--no-localhost".to_string(), "--scopes".to_string(), "drive".to_string()]; + let args = vec![ + "--no-localhost".to_string(), + "--scopes".to_string(), + "drive".to_string(), + ]; let parsed = parse_login_args(&args); assert!(parsed.no_localhost); assert_eq!(parsed.filtered_args, vec!["--scopes", "drive"]); @@ -2289,7 +2293,11 @@ mod tests { #[test] fn test_parse_login_args_account() { - let args = vec!["--account".to_string(), "test@example.com".to_string(), "--no-localhost".to_string()]; + let args = vec![ + "--account".to_string(), + "test@example.com".to_string(), + "--no-localhost".to_string(), + ]; let parsed = parse_login_args(&args); assert_eq!(parsed.account_email.unwrap(), "test@example.com"); assert!(parsed.no_localhost);