From 6598193a3cded1e69df7840eae1d269a2fbd51a0 Mon Sep 17 00:00:00 2001 From: femto Date: Mon, 16 Mar 2026 10:53:52 +0800 Subject: [PATCH 1/3] feat: add HTTP proxy support via environment variables When http_proxy/https_proxy/all_proxy environment variables are set, use reqwest (which natively supports proxy) for token refresh instead of yup-oauth2's hyper-based client (which doesn't support proxy). This enables gws to work in environments that require HTTP proxy to access Google APIs (e.g., users in China). Changes: - Cargo.toml: Enable reqwest's default features including proxy support - src/auth.rs: Add proxy-aware token refresh using reqwest as fallback Fixes #422 --- Cargo.toml | 2 +- src/auth.rs | 73 +++++++++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 72 insertions(+), 3 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 24bc253b..3f08104c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,7 +38,7 @@ clap = { version = "4", features = ["derive", "string"] } dirs = "5" dotenvy = "0.15" hostname = "0.4" -reqwest = { version = "0.12", features = ["json", "stream", "rustls-tls-native-roots"], default-features = false } +reqwest = { version = "0.12", features = ["json", "stream", "rustls-tls-native-roots", "socks"] } rand = "0.8" serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/src/auth.rs b/src/auth.rs index b602d840..4b2a3ef7 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -21,9 +21,56 @@ use std::path::PathBuf; use anyhow::Context; +use serde::Deserialize; use crate::credential_store; +/// Response from Google's token endpoint +#[derive(Debug, Deserialize)] +struct TokenResponse { + access_token: String, + #[allow(dead_code)] + expires_in: u64, + #[allow(dead_code)] + token_type: String, +} + +/// Refresh an access token using reqwest (supports HTTP proxy via environment variables). +/// This is used as a fallback when yup-oauth2's hyper-based client fails due to proxy issues. +async fn refresh_token_with_reqwest( + client_id: &str, + client_secret: &str, + refresh_token: &str, +) -> anyhow::Result { + let client = reqwest::Client::new(); + let params = [ + ("client_id", client_id), + ("client_secret", client_secret), + ("refresh_token", refresh_token), + ("grant_type", "refresh_token"), + ]; + + let response = client + .post("https://oauth2.googleapis.com/token") + .form(¶ms) + .send() + .await + .context("Failed to send token refresh request")?; + + if !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + anyhow::bail!("Token refresh failed with status {}: {}", status, body); + } + + let token_response: TokenResponse = response + .json() + .await + .context("Failed to parse token response")?; + + Ok(token_response.access_token) +} + /// Returns the project ID to be used for quota and billing (sets the `x-goog-user-project` header). /// /// Priority: @@ -173,14 +220,36 @@ pub async fn get_token(scopes: &[&str]) -> anyhow::Result { get_token_inner(scopes, creds, &token_cache).await } +/// Check if HTTP proxy environment variables are set +fn has_proxy_env() -> bool { + std::env::var("http_proxy").is_ok() + || std::env::var("HTTP_PROXY").is_ok() + || std::env::var("https_proxy").is_ok() + || std::env::var("HTTPS_PROXY").is_ok() + || std::env::var("all_proxy").is_ok() + || std::env::var("ALL_PROXY").is_ok() +} + async fn get_token_inner( scopes: &[&str], creds: Credential, token_cache_path: &std::path::Path, ) -> anyhow::Result { match creds { - Credential::AuthorizedUser(secret) => { - let auth = yup_oauth2::AuthorizedUserAuthenticator::builder(secret) + Credential::AuthorizedUser(ref secret) => { + // If proxy env vars are set, use reqwest directly (it supports proxy) + // This avoids waiting for yup-oauth2's hyper client to timeout + if has_proxy_env() { + return refresh_token_with_reqwest( + &secret.client_id, + &secret.client_secret, + &secret.refresh_token, + ) + .await; + } + + // No proxy - use yup-oauth2 (faster, has token caching) + let auth = yup_oauth2::AuthorizedUserAuthenticator::builder(secret.clone()) .with_storage(Box::new(crate::token_storage::EncryptedTokenStorage::new( token_cache_path.to_path_buf(), ))) From 7f13b6d8ee942267c3020df26896896b96c9c182 Mon Sep 17 00:00:00 2001 From: femto Date: Mon, 16 Mar 2026 10:53:59 +0800 Subject: [PATCH 2/3] feat: add proxy support for auth login flow When proxy env vars are set, use a custom OAuth flow with reqwest for token exchange instead of yup-oauth2's hyper-based client. Changes to auth_commands.rs: - Add login_with_proxy_support() for proxy-aware OAuth login - Add exchange_code_with_reqwest() for token exchange via reqwest - Detect proxy env vars and choose appropriate flow --- src/auth_commands.rs | 300 +++++++++++++++++++++++++++++++------------ 1 file changed, 218 insertions(+), 82 deletions(-) diff --git a/src/auth_commands.rs b/src/auth_commands.rs index f51ba6dd..b311b419 100644 --- a/src/auth_commands.rs +++ b/src/auth_commands.rs @@ -13,13 +13,157 @@ // limitations under the License. use std::collections::HashSet; +use std::io::{BufRead, BufReader, Write}; +use std::net::TcpListener; use std::path::PathBuf; +use serde::Deserialize; use serde_json::json; use crate::credential_store; use crate::error::GwsError; +/// Check if HTTP proxy environment variables are set +fn has_proxy_env() -> bool { + std::env::var("http_proxy").is_ok() + || std::env::var("HTTP_PROXY").is_ok() + || std::env::var("https_proxy").is_ok() + || std::env::var("HTTPS_PROXY").is_ok() + || std::env::var("all_proxy").is_ok() + || std::env::var("ALL_PROXY").is_ok() +} + +/// Response from Google's token endpoint +#[derive(Debug, Deserialize)] +struct OAuthTokenResponse { + access_token: String, + refresh_token: Option, + #[allow(dead_code)] + expires_in: u64, + #[allow(dead_code)] + token_type: String, +} + +/// Exchange authorization code for tokens using reqwest (supports HTTP proxy) +async fn exchange_code_with_reqwest( + client_id: &str, + client_secret: &str, + code: &str, + redirect_uri: &str, +) -> Result { + let client = reqwest::Client::new(); + let params = [ + ("client_id", client_id), + ("client_secret", client_secret), + ("code", code), + ("redirect_uri", redirect_uri), + ("grant_type", "authorization_code"), + ]; + + let response = client + .post("https://oauth2.googleapis.com/token") + .form(¶ms) + .send() + .await + .map_err(|e| GwsError::Auth(format!("Failed to send token request: {e}")))?; + + if !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + return Err(GwsError::Auth(format!( + "Token exchange failed with status {}: {}", + status, body + ))); + } + + response + .json() + .await + .map_err(|e| GwsError::Auth(format!("Failed to parse token response: {e}"))) +} + +/// 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") + .map_err(|e| GwsError::Auth(format!("Failed to start local server: {e}")))?; + let port = listener.local_addr().unwrap().port(); + let redirect_uri = format!("http://localhost:{}", port); + + // Build OAuth URL + let scopes_str = scopes.join(" "); + let auth_url = format!( + "https://accounts.google.com/o/oauth2/auth?\ + scope={}&\ + access_type=offline&\ + redirect_uri={}&\ + response_type=code&\ + client_id={}&\ + prompt=select_account+consent", + urlencoding(&scopes_str), + urlencoding(&redirect_uri), + urlencoding(client_id) + ); + + println!("Open this URL in your browser to authenticate:\n"); + println!(" {}\n", auth_url); + + // Wait for OAuth callback + let (mut stream, _) = listener + .accept() + .map_err(|e| GwsError::Auth(format!("Failed to accept connection: {e}")))?; + + let mut reader = BufReader::new(&stream); + let mut request_line = String::new(); + reader + .read_line(&mut request_line) + .map_err(|e| GwsError::Auth(format!("Failed to read request: {e}")))?; + + // Extract code from URL: GET /?code=XXX&... HTTP/1.1 + let path = request_line + .split_whitespace() + .nth(1) + .ok_or_else(|| GwsError::Auth("Invalid HTTP request".to_string()))?; + + let code = path + .split('?') + .nth(1) + .and_then(|query| { + query.split('&').find_map(|pair| { + let mut parts = pair.split('='); + if parts.next() == Some("code") { + parts.next().map(|v| v.to_string()) + } else { + None + } + }) + }) + .ok_or_else(|| GwsError::Auth("No authorization code in callback".to_string()))?; + + // Send success response to browser + let response = "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\n\r\n\ +

Success!

You may now close this window.

"; + let _ = stream.write_all(response.as_bytes()); + + // Exchange code for tokens using reqwest (proxy-aware) + let token_response = exchange_code_with_reqwest(client_id, client_secret, &code, &redirect_uri).await?; + + let refresh_token = token_response + .refresh_token + .ok_or_else(|| GwsError::Auth("No refresh token returned".to_string()))?; + + Ok((token_response.access_token, refresh_token)) +} + +/// Simple URL encoding +fn urlencoding(s: &str) -> String { + percent_encoding::utf8_percent_encode(s, percent_encoding::NON_ALPHANUMERIC).to_string() +} + /// Mask a secret string by showing only the first 4 and last 4 characters. /// Strings with 8 or fewer characters are fully replaced with "***". fn mask_secret(s: &str) -> String { @@ -266,15 +410,6 @@ async fn handle_login(args: &[String]) -> Result<(), GwsError> { // Remove restrictive scopes when broader alternatives are present. let mut scopes = filter_redundant_restrictive_scopes(scopes); - 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()], - ..Default::default() - }; - // Ensure openid + email scopes are always present so we can identify the user // via the userinfo endpoint after login. let identity_scopes = ["openid", "https://www.googleapis.com/auth/userinfo.email"]; @@ -284,96 +419,97 @@ async fn handle_login(args: &[String]) -> Result<(), GwsError> { } } - // Use a temp file for yup-oauth2's token persistence, then encrypt it - let temp_path = config_dir().join("credentials.tmp"); - - // Always start fresh — delete any stale temp cache from prior login attempts. - let _ = std::fs::remove_file(&temp_path); - // Ensure config directory exists - if let Some(parent) = temp_path.parent() { - std::fs::create_dir_all(parent) - .map_err(|e| GwsError::Validation(format!("Failed to create config directory: {e}")))?; - } + let config = config_dir(); + 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 has_proxy_env() { + login_with_proxy_support(&client_id, &client_secret, &scopes).await? + } else { + // No proxy - use yup-oauth2 + 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()], + ..Default::default() + }; - 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: None })) - .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(); - let token = auth - .token(&scope_refs) + let temp_path = config.join("credentials.tmp"); + let _ = std::fs::remove_file(&temp_path); + + 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) + .flow_delegate(Box::new(CliFlowDelegate { login_hint: None })) + .build() .await - .map_err(|e| GwsError::Auth(format!("OAuth flow failed: {e}")))?; + .map_err(|e| GwsError::Auth(format!("Failed to build authenticator: {e}")))?; + + let scope_refs: Vec<&str> = scopes.iter().map(|s| s.as_str()).collect(); + let token = auth + .token(&scope_refs) + .await + .map_err(|e| GwsError::Auth(format!("OAuth flow failed: {e}")))?; + + let access_token = token + .token() + .ok_or_else(|| GwsError::Auth("No access token returned".to_string()))? + .to_string(); - if token.token().is_some() { - // Read yup-oauth2's token cache to extract the refresh_token. - // EncryptedTokenStorage stores data encrypted, so we must decrypt first. let token_data = std::fs::read(&temp_path) .ok() .and_then(|bytes| crate::credential_store::decrypt(&bytes).ok()) .and_then(|decrypted| String::from_utf8(decrypted).ok()) .unwrap_or_default(); let refresh_token = extract_refresh_token(&token_data).ok_or_else(|| { - GwsError::Auth( - "OAuth flow completed but no refresh token was returned. \ - Ensure the OAuth consent screen includes 'offline' access." - .to_string(), - ) + GwsError::Auth("No refresh token returned".to_string()) })?; - // Build credentials in the standard authorized_user format - let creds_json = json!({ - "type": "authorized_user", - "client_id": client_id, - "client_secret": client_secret, - "refresh_token": refresh_token, - }); + let _ = std::fs::remove_file(&temp_path); + (access_token, refresh_token) + }; - let creds_str = serde_json::to_string_pretty(&creds_json) - .map_err(|e| GwsError::Validation(format!("Failed to serialize credentials: {e}")))?; + // Build credentials in the standard authorized_user format + let creds_json = json!({ + "type": "authorized_user", + "client_id": client_id, + "client_secret": client_secret, + "refresh_token": refresh_token, + }); - // Fetch the user's email from Google userinfo - let access_token = token.token().unwrap_or_default(); - let actual_email = fetch_userinfo_email(access_token).await; + let creds_str = serde_json::to_string_pretty(&creds_json) + .map_err(|e| GwsError::Validation(format!("Failed to serialize credentials: {e}")))?; - // Save encrypted credentials - let enc_path = credential_store::save_encrypted(&creds_str) - .map_err(|e| GwsError::Auth(format!("Failed to encrypt credentials: {e}")))?; + // Fetch the user's email from Google userinfo + let actual_email = fetch_userinfo_email(&access_token).await; - // Clean up temp file - let _ = std::fs::remove_file(&temp_path); + // Save encrypted credentials + let enc_path = credential_store::save_encrypted(&creds_str) + .map_err(|e| GwsError::Auth(format!("Failed to encrypt credentials: {e}")))?; - let output = json!({ - "status": "success", - "message": "Authentication successful. Encrypted credentials saved.", - "account": actual_email.as_deref().unwrap_or("(unknown)"), - "credentials_file": enc_path.display().to_string(), - "encryption": "AES-256-GCM (key in OS keyring or local `.encryption_key`; set GOOGLE_WORKSPACE_CLI_KEYRING_BACKEND=file for headless)", - "scopes": scopes, - }); - println!( - "{}", - serde_json::to_string_pretty(&output).unwrap_or_default() - ); - Ok(()) - } else { - // Clean up temp file on failure - let _ = std::fs::remove_file(&temp_path); - Err(GwsError::Auth( - "OAuth flow completed but no token was returned.".to_string(), - )) - } + let output = json!({ + "status": "success", + "message": "Authentication successful. Encrypted credentials saved.", + "account": actual_email.as_deref().unwrap_or("(unknown)"), + "credentials_file": enc_path.display().to_string(), + "encryption": "AES-256-GCM (key in OS keyring or local `.encryption_key`; set GOOGLE_WORKSPACE_CLI_KEYRING_BACKEND=file for headless)", + "scopes": scopes, + }); + println!( + "{}", + serde_json::to_string_pretty(&output).unwrap_or_default() + ); + Ok(()) } /// Fetch the authenticated user's email from Google's userinfo endpoint. From 9bf3701520a20e4cb7c128d785b671830e57d51e Mon Sep 17 00:00:00 2001 From: femto Date: Mon, 16 Mar 2026 11:22:39 +0800 Subject: [PATCH 3/3] fix: address proxy auth review feedback --- .changeset/proxy-support-review-fixes.md | 5 + Cargo.toml | 2 +- src/auth.rs | 63 +++++- src/auth_commands.rs | 276 +++++++++++++++-------- 4 files changed, 237 insertions(+), 109 deletions(-) create mode 100644 .changeset/proxy-support-review-fixes.md diff --git a/.changeset/proxy-support-review-fixes.md b/.changeset/proxy-support-review-fixes.md new file mode 100644 index 00000000..f7ce0597 --- /dev/null +++ b/.changeset/proxy-support-review-fixes.md @@ -0,0 +1,5 @@ +--- +"@googleworkspace/cli": patch +--- + +Improve proxy-aware OAuth flows and clean up review feedback for auth login. diff --git a/Cargo.toml b/Cargo.toml index 3f08104c..afb8476d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,7 +38,7 @@ clap = { version = "4", features = ["derive", "string"] } dirs = "5" dotenvy = "0.15" hostname = "0.4" -reqwest = { version = "0.12", features = ["json", "stream", "rustls-tls-native-roots", "socks"] } +reqwest = { version = "0.12", features = ["json", "stream", "rustls-tls-native-roots", "socks"], default-features = false } rand = "0.8" serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/src/auth.rs b/src/auth.rs index 4b2a3ef7..4df2abcd 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -25,6 +25,15 @@ use serde::Deserialize; use crate::credential_store; +const PROXY_ENV_VARS: &[&str] = &[ + "http_proxy", + "HTTP_PROXY", + "https_proxy", + "HTTPS_PROXY", + "all_proxy", + "ALL_PROXY", +]; + /// Response from Google's token endpoint #[derive(Debug, Deserialize)] struct TokenResponse { @@ -59,7 +68,7 @@ async fn refresh_token_with_reqwest( if !response.status().is_success() { let status = response.status(); - let body = response.text().await.unwrap_or_default(); + let body = response_text_or_placeholder(response.text().await); anyhow::bail!("Token refresh failed with status {}: {}", status, body); } @@ -221,13 +230,14 @@ pub async fn get_token(scopes: &[&str]) -> anyhow::Result { } /// Check if HTTP proxy environment variables are set -fn has_proxy_env() -> bool { - std::env::var("http_proxy").is_ok() - || std::env::var("HTTP_PROXY").is_ok() - || std::env::var("https_proxy").is_ok() - || std::env::var("HTTPS_PROXY").is_ok() - || std::env::var("all_proxy").is_ok() - || std::env::var("ALL_PROXY").is_ok() +pub(crate) fn has_proxy_env() -> bool { + PROXY_ENV_VARS + .iter() + .any(|key| std::env::var_os(key).is_some_and(|value| !value.is_empty())) +} + +pub(crate) fn response_text_or_placeholder(result: Result) -> String { + result.unwrap_or_else(|_| "(could not read error response body)".to_string()) } async fn get_token_inner( @@ -467,6 +477,43 @@ mod tests { } } + fn clear_proxy_env() -> Vec { + PROXY_ENV_VARS + .iter() + .map(|key| EnvVarGuard::remove(key)) + .collect() + } + + #[test] + #[serial_test::serial] + fn has_proxy_env_returns_false_when_unset() { + let _guards = clear_proxy_env(); + assert!(!has_proxy_env()); + } + + #[test] + #[serial_test::serial] + fn has_proxy_env_returns_true_when_set() { + let mut guards = clear_proxy_env(); + guards.push(EnvVarGuard::set( + "HTTPS_PROXY", + "http://proxy.internal:8080", + )); + assert!(has_proxy_env()); + } + + #[test] + fn response_text_or_placeholder_returns_body() { + let body = response_text_or_placeholder(Result::::Ok("error body".to_string())); + assert_eq!(body, "error body"); + } + + #[test] + fn response_text_or_placeholder_returns_placeholder_on_error() { + let body = response_text_or_placeholder(Result::::Err(())); + assert_eq!(body, "(could not read error response body)"); + } + #[tokio::test] #[serial_test::serial] async fn test_load_credentials_no_options() { diff --git a/src/auth_commands.rs b/src/auth_commands.rs index b311b419..6890cf22 100644 --- a/src/auth_commands.rs +++ b/src/auth_commands.rs @@ -15,7 +15,7 @@ use std::collections::HashSet; use std::io::{BufRead, BufReader, Write}; use std::net::TcpListener; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use serde::Deserialize; use serde_json::json; @@ -23,16 +23,6 @@ use serde_json::json; use crate::credential_store; use crate::error::GwsError; -/// Check if HTTP proxy environment variables are set -fn has_proxy_env() -> bool { - std::env::var("http_proxy").is_ok() - || std::env::var("HTTP_PROXY").is_ok() - || std::env::var("https_proxy").is_ok() - || std::env::var("HTTPS_PROXY").is_ok() - || std::env::var("all_proxy").is_ok() - || std::env::var("ALL_PROXY").is_ok() -} - /// Response from Google's token endpoint #[derive(Debug, Deserialize)] struct OAuthTokenResponse { @@ -69,7 +59,7 @@ async fn exchange_code_with_reqwest( if !response.status().is_success() { let status = response.status(); - let body = response.text().await.unwrap_or_default(); + let body = crate::auth::response_text_or_placeholder(response.text().await); return Err(GwsError::Auth(format!( "Token exchange failed with status {}: {}", status, body @@ -82,21 +72,9 @@ async fn exchange_code_with_reqwest( .map_err(|e| GwsError::Auth(format!("Failed to parse token response: {e}"))) } -/// 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") - .map_err(|e| GwsError::Auth(format!("Failed to start local server: {e}")))?; - let port = listener.local_addr().unwrap().port(); - let redirect_uri = format!("http://localhost:{}", port); - - // Build OAuth URL +fn build_proxy_auth_url(client_id: &str, redirect_uri: &str, scopes: &[String]) -> String { let scopes_str = scopes.join(" "); - let auth_url = format!( + format!( "https://accounts.google.com/o/oauth2/auth?\ scope={}&\ access_type=offline&\ @@ -105,44 +83,64 @@ async fn login_with_proxy_support( client_id={}&\ prompt=select_account+consent", urlencoding(&scopes_str), - urlencoding(&redirect_uri), + urlencoding(redirect_uri), urlencoding(client_id) - ); - - println!("Open this URL in your browser to authenticate:\n"); - println!(" {}\n", auth_url); - - // Wait for OAuth callback - let (mut stream, _) = listener - .accept() - .map_err(|e| GwsError::Auth(format!("Failed to accept connection: {e}")))?; - - let mut reader = BufReader::new(&stream); - let mut request_line = String::new(); - reader - .read_line(&mut request_line) - .map_err(|e| GwsError::Auth(format!("Failed to read request: {e}")))?; + ) +} - // Extract code from URL: GET /?code=XXX&... HTTP/1.1 +fn extract_authorization_code(request_line: &str) -> Result { let path = request_line .split_whitespace() .nth(1) .ok_or_else(|| GwsError::Auth("Invalid HTTP request".to_string()))?; - let code = path - .split('?') + path.split('?') .nth(1) .and_then(|query| { query.split('&').find_map(|pair| { let mut parts = pair.split('='); if parts.next() == Some("code") { - parts.next().map(|v| v.to_string()) + parts.next().map(|value| value.to_string()) } else { None } }) }) - .ok_or_else(|| GwsError::Auth("No authorization code in callback".to_string()))?; + .ok_or_else(|| GwsError::Auth("No authorization code in callback".to_string())) +} + +/// 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") + .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 auth_url = build_proxy_auth_url(client_id, &redirect_uri, scopes); + + println!("Open this URL in your browser to authenticate:\n"); + println!(" {}\n", auth_url); + + // Wait for OAuth callback + let (mut stream, _) = listener + .accept() + .map_err(|e| GwsError::Auth(format!("Failed to accept connection: {e}")))?; + + let mut reader = BufReader::new(&stream); + let mut request_line = String::new(); + reader + .read_line(&mut request_line) + .map_err(|e| GwsError::Auth(format!("Failed to read request: {e}")))?; + + let code = extract_authorization_code(&request_line)?; // Send success response to browser let response = "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\n\r\n\ @@ -150,15 +148,88 @@ async fn login_with_proxy_support( let _ = stream.write_all(response.as_bytes()); // Exchange code for tokens using reqwest (proxy-aware) - let token_response = exchange_code_with_reqwest(client_id, client_secret, &code, &redirect_uri).await?; + let token_response = + exchange_code_with_reqwest(client_id, client_secret, &code, &redirect_uri).await?; - let refresh_token = token_response - .refresh_token - .ok_or_else(|| GwsError::Auth("No refresh token returned".to_string()))?; + let refresh_token = token_response.refresh_token.ok_or_else(|| { + GwsError::Auth( + "OAuth flow completed but no refresh token was returned. \ + Ensure the OAuth consent screen includes 'offline' access." + .to_string(), + ) + })?; Ok((token_response.access_token, refresh_token)) } +fn read_refresh_token_from_cache(temp_path: &Path) -> Result { + let token_data = std::fs::read(temp_path) + .ok() + .and_then(|bytes| crate::credential_store::decrypt(&bytes).ok()) + .and_then(|decrypted| String::from_utf8(decrypted).ok()) + .unwrap_or_default(); + + extract_refresh_token(&token_data).ok_or_else(|| { + GwsError::Auth( + "OAuth flow completed but no refresh token was returned. \ + Ensure the OAuth consent screen includes 'offline' access." + .to_string(), + ) + }) +} + +async fn login_with_yup_oauth( + config_dir: &Path, + client_id: &str, + client_secret: &str, + scopes: &[String], +) -> Result<(String, String), GwsError> { + let secret = yup_oauth2::ApplicationSecret { + client_id: client_id.to_string(), + client_secret: client_secret.to_string(), + 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()], + ..Default::default() + }; + + let temp_path = config_dir.join("credentials.tmp"); + let _ = std::fs::remove_file(&temp_path); + + let result = async { + 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) + .flow_delegate(Box::new(CliFlowDelegate { login_hint: None })) + .build() + .await + .map_err(|e| GwsError::Auth(format!("Failed to build authenticator: {e}")))?; + + let scope_refs: Vec<&str> = scopes.iter().map(|s| s.as_str()).collect(); + let token = auth + .token(&scope_refs) + .await + .map_err(|e| GwsError::Auth(format!("OAuth flow failed: {e}")))?; + + let access_token = token + .token() + .ok_or_else(|| GwsError::Auth("No access token returned".to_string()))? + .to_string(); + let refresh_token = read_refresh_token_from_cache(&temp_path)?; + + Ok((access_token, refresh_token)) + } + .await; + + let _ = std::fs::remove_file(&temp_path); + result +} + /// Simple URL encoding fn urlencoding(s: &str) -> String { percent_encoding::utf8_percent_encode(s, percent_encoding::NON_ALPHANUMERIC).to_string() @@ -426,57 +497,10 @@ async fn handle_login(args: &[String]) -> Result<(), GwsError> { // 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 has_proxy_env() { + let (access_token, refresh_token) = if crate::auth::has_proxy_env() { login_with_proxy_support(&client_id, &client_secret, &scopes).await? } else { - // No proxy - use yup-oauth2 - 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()], - ..Default::default() - }; - - let temp_path = config.join("credentials.tmp"); - let _ = std::fs::remove_file(&temp_path); - - 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) - .flow_delegate(Box::new(CliFlowDelegate { login_hint: None })) - .build() - .await - .map_err(|e| GwsError::Auth(format!("Failed to build authenticator: {e}")))?; - - let scope_refs: Vec<&str> = scopes.iter().map(|s| s.as_str()).collect(); - let token = auth - .token(&scope_refs) - .await - .map_err(|e| GwsError::Auth(format!("OAuth flow failed: {e}")))?; - - let access_token = token - .token() - .ok_or_else(|| GwsError::Auth("No access token returned".to_string()))? - .to_string(); - - let token_data = std::fs::read(&temp_path) - .ok() - .and_then(|bytes| crate::credential_store::decrypt(&bytes).ok()) - .and_then(|decrypted| String::from_utf8(decrypted).ok()) - .unwrap_or_default(); - let refresh_token = extract_refresh_token(&token_data).ok_or_else(|| { - GwsError::Auth("No refresh token returned".to_string()) - })?; - - let _ = std::fs::remove_file(&temp_path); - (access_token, refresh_token) + login_with_yup_oauth(&config, &client_id, &client_secret, &scopes).await? }; // Build credentials in the standard authorized_user format @@ -2344,4 +2368,56 @@ mod tests { let result = extract_scopes_from_doc(&doc, false); assert!(result.is_empty()); } + + #[test] + fn build_proxy_auth_url_encodes_scope_and_redirect_uri() { + let scopes = vec![ + "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); + + 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") + ))); + } + + #[test] + fn extract_authorization_code_returns_code() { + let code = + extract_authorization_code("GET /?state=abc&code=4/test-code&scope=openid HTTP/1.1") + .unwrap(); + assert_eq!(code, "4/test-code"); + } + + #[test] + fn extract_authorization_code_rejects_missing_code() { + let err = extract_authorization_code("GET /?state=abc HTTP/1.1").unwrap_err(); + assert!(err.to_string().contains("No authorization code")); + } + + #[test] + fn read_refresh_token_from_cache_reads_encrypted_storage() { + let token_data = r#"[{"token":{"refresh_token":"1//refresh-token"}}]"#; + let encrypted = crate::credential_store::encrypt(token_data.as_bytes()).unwrap(); + let mut file = tempfile::NamedTempFile::new().unwrap(); + std::io::Write::write_all(&mut file, &encrypted).unwrap(); + + let refresh_token = read_refresh_token_from_cache(file.path()).unwrap(); + assert_eq!(refresh_token, "1//refresh-token"); + } + + #[test] + fn read_refresh_token_from_cache_requires_refresh_token() { + let token_data = r#"[{"token":{"access_token":"ya29.no-refresh"}}]"#; + let encrypted = crate::credential_store::encrypt(token_data.as_bytes()).unwrap(); + let mut file = tempfile::NamedTempFile::new().unwrap(); + std::io::Write::write_all(&mut file, &encrypted).unwrap(); + + let err = read_refresh_token_from_cache(file.path()).unwrap_err(); + assert!(err.to_string().contains("no refresh token was returned")); + } }