From 016bb2974b8cb1b7072d8fb2db131fe9a0f28a7c Mon Sep 17 00:00:00 2001 From: femto Date: Thu, 12 Mar 2026 12:42:24 +0800 Subject: [PATCH 1/2] 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 Co-Authored-By: Claude Opus 4.5 --- Cargo.lock | 198 +++++++++++++++++++++++++++++++++++++++++++++++++++- Cargo.toml | 2 +- src/auth.rs | 73 ++++++++++++++++++- 3 files changed, 267 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 68931d42..ff938a42 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -332,6 +332,16 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation" version = "0.10.1" @@ -615,6 +625,15 @@ version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -703,6 +722,21 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -1023,6 +1057,22 @@ dependencies = [ "tower-service", ] +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + [[package]] name = "hyper-util" version = "0.1.20" @@ -1041,9 +1091,11 @@ dependencies = [ "percent-encoding", "pin-project-lite", "socket2", + "system-configuration", "tokio", "tower-service", "tracing", + "windows-registry", ] [[package]] @@ -1416,6 +1468,12 @@ dependencies = [ "autocfg", ] +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + [[package]] name = "minimal-lexical" version = "0.2.1" @@ -1434,6 +1492,23 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "native-tls" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe 0.1.6", + "openssl-sys", + "schannel", + "security-framework 2.11.1", + "security-framework-sys", + "tempfile", +] + [[package]] name = "nix" version = "0.29.0" @@ -1510,12 +1585,56 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" +[[package]] +name = "openssl" +version = "0.10.75" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328" +dependencies = [ + "bitflags 2.11.0", + "cfg-if", + "foreign-types", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "openssl-probe" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" + [[package]] name = "openssl-probe" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" +[[package]] +name = "openssl-sys" +version = "0.9.111" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "option-ext" version = "0.2.0" @@ -1667,6 +1786,12 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + [[package]] name = "polyval" version = "0.6.2" @@ -2005,16 +2130,21 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ "base64", "bytes", + "encoding_rs", "futures-core", "futures-util", + "h2", "http", "http-body", "http-body-util", "hyper", "hyper-rustls", + "hyper-tls", "hyper-util", "js-sys", "log", + "mime", + "native-tls", "percent-encoding", "pin-project-lite", "quinn", @@ -2026,6 +2156,7 @@ dependencies = [ "serde_urlencoded", "sync_wrapper", "tokio", + "tokio-native-tls", "tokio-rustls", "tokio-util", "tower", @@ -2100,10 +2231,10 @@ version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" dependencies = [ - "openssl-probe", + "openssl-probe 0.2.1", "rustls-pki-types", "schannel", - "security-framework", + "security-framework 3.7.0", ] [[package]] @@ -2175,6 +2306,19 @@ version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags 2.11.0", + "core-foundation 0.9.4", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + [[package]] name = "security-framework" version = "3.7.0" @@ -2182,7 +2326,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ "bitflags 2.11.0", - "core-foundation", + "core-foundation 0.10.1", "core-foundation-sys", "libc", "security-framework-sys", @@ -2461,6 +2605,27 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags 2.11.0", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "tempfile" version = "3.26.0" @@ -2662,6 +2827,16 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + [[package]] name = "tokio-rustls" version = "0.26.4" @@ -2860,6 +3035,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version_check" version = "0.9.5" @@ -3169,6 +3350,17 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + [[package]] name = "windows-result" version = "0.4.1" diff --git a/Cargo.toml b/Cargo.toml index 44bf0235..e3b404bb 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 2b6716f1..bc5b6fa5 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: @@ -106,14 +153,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 3758274f31910357b7da3dafb800ba92a5a5bf4f Mon Sep 17 00:00:00 2001 From: femto Date: Thu, 12 Mar 2026 12:53:18 +0800 Subject: [PATCH 2/2] 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 Co-Authored-By: Claude Opus 4.5 --- 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 ade681d6..1ec3b0ba 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.