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` diff --git a/src/auth_commands.rs b/src/auth_commands.rs index c9b12e1d..6603dcbc 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", @@ -183,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 { @@ -204,15 +205,48 @@ 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}"))?; + + Ok(extract_code_from_input(&user_input)) + } else { + Ok(String::new()) + } }) } } -async fn handle_login(args: &[String]) -> Result<(), GwsError> { - // Extract --account and -s/--services from args +/// 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() +} + +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; let mut filtered_args: Vec = Vec::new(); let mut skip_next = false; for i in 0..args.len() { @@ -220,6 +254,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; @@ -249,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 @@ -278,12 +331,24 @@ 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,20 +375,23 @@ async fn handle_login(args: &[String]) -> Result<(), GwsError> { .map_err(|e| GwsError::Validation(format!("Failed to create config directory: {e}")))?; } - let auth = yup_oauth2::InstalledFlowAuthenticator::builder( - secret, - yup_oauth2::InstalledFlowReturnMethod::HTTPRedirect, - ) - .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 return_method = if no_localhost { + yup_oauth2::InstalledFlowReturnMethod::Interactive + } else { + 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}")))?; // Request a token — this triggers the browser OAuth flow let scope_refs: Vec<&str> = scopes.iter().map(|s| s.as_str()).collect(); @@ -2184,4 +2252,74 @@ 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" + ); + } + + #[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")); + } }