feat: add HTTP proxy support via environment variables (#622)
* 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 * 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 * fix: address proxy auth review feedback * fix: reuse shared reqwest client for auth flows
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@googleworkspace/cli": patch
|
||||
---
|
||||
|
||||
Improve proxy-aware OAuth flows and clean up review feedback for auth login.
|
||||
@@ -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"], default-features = false }
|
||||
rand = "0.8"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
|
||||
@@ -21,9 +21,65 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::Context;
|
||||
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 {
|
||||
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<String> {
|
||||
let client = crate::client::shared_client().map_err(anyhow::Error::from)?;
|
||||
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_or_placeholder(response.text().await);
|
||||
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 +229,37 @@ pub async fn get_token(scopes: &[&str]) -> anyhow::Result<String> {
|
||||
get_token_inner(scopes, creds, &token_cache).await
|
||||
}
|
||||
|
||||
/// Check if HTTP proxy environment variables are set
|
||||
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<E>(result: Result<String, E>) -> String {
|
||||
result.unwrap_or_else(|_| "(could not read error response body)".to_string())
|
||||
}
|
||||
|
||||
async fn get_token_inner(
|
||||
scopes: &[&str],
|
||||
creds: Credential,
|
||||
token_cache_path: &std::path::Path,
|
||||
) -> anyhow::Result<String> {
|
||||
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(),
|
||||
)))
|
||||
@@ -398,6 +477,43 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn clear_proxy_env() -> Vec<EnvVarGuard> {
|
||||
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::<String, ()>::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::<String, ()>::Err(()));
|
||||
assert_eq!(body, "(could not read error response body)");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial_test::serial]
|
||||
async fn test_load_credentials_no_options() {
|
||||
|
||||
@@ -13,13 +13,228 @@
|
||||
// limitations under the License.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::path::PathBuf;
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::net::TcpListener;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::credential_store;
|
||||
use crate::error::GwsError;
|
||||
|
||||
/// Response from Google's token endpoint
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct OAuthTokenResponse {
|
||||
access_token: String,
|
||||
refresh_token: Option<String>,
|
||||
#[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<OAuthTokenResponse, GwsError> {
|
||||
let client = crate::client::shared_client()?;
|
||||
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 = crate::auth::response_text_or_placeholder(response.text().await);
|
||||
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}")))
|
||||
}
|
||||
|
||||
fn build_proxy_auth_url(client_id: &str, redirect_uri: &str, scopes: &[String]) -> String {
|
||||
let scopes_str = scopes.join(" ");
|
||||
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)
|
||||
)
|
||||
}
|
||||
|
||||
fn extract_authorization_code(request_line: &str) -> Result<String, GwsError> {
|
||||
let path = request_line
|
||||
.split_whitespace()
|
||||
.nth(1)
|
||||
.ok_or_else(|| GwsError::Auth("Invalid HTTP request".to_string()))?;
|
||||
|
||||
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(|value| value.to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
})
|
||||
.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\
|
||||
<html><body><h1>Success!</h1><p>You may now close this window.</p></body></html>";
|
||||
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(
|
||||
"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<String, GwsError> {
|
||||
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()
|
||||
}
|
||||
|
||||
/// Mask a secret string by showing only the first 4 and last 4 characters.
|
||||
/// Strings with 8 or fewer characters are fully replaced with "***".
|
||||
///
|
||||
@@ -383,15 +598,6 @@ async fn handle_login_inner(
|
||||
// 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 + profile scopes are always present so we can
|
||||
// identify the user via the userinfo endpoint after login, and so the
|
||||
// Gmail helpers can fall back to the People API to populate the From
|
||||
@@ -407,96 +613,50 @@ async fn handle_login_inner(
|
||||
}
|
||||
}
|
||||
|
||||
// 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}")))?;
|
||||
|
||||
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)
|
||||
.await
|
||||
.map_err(|e| GwsError::Auth(format!("OAuth flow failed: {e}")))?;
|
||||
|
||||
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(),
|
||||
)
|
||||
})?;
|
||||
|
||||
// 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 creds_str = serde_json::to_string_pretty(&creds_json)
|
||||
.map_err(|e| GwsError::Validation(format!("Failed to serialize credentials: {e}")))?;
|
||||
|
||||
// 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;
|
||||
|
||||
// Save encrypted credentials
|
||||
let enc_path = credential_store::save_encrypted(&creds_str)
|
||||
.map_err(|e| GwsError::Auth(format!("Failed to encrypt credentials: {e}")))?;
|
||||
|
||||
// Clean up temp file
|
||||
let _ = std::fs::remove_file(&temp_path);
|
||||
|
||||
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(())
|
||||
// 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 {
|
||||
// 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(),
|
||||
))
|
||||
}
|
||||
login_with_yup_oauth(&config, &client_id, &client_secret, &scopes).await?
|
||||
};
|
||||
|
||||
// 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 creds_str = serde_json::to_string_pretty(&creds_json)
|
||||
.map_err(|e| GwsError::Validation(format!("Failed to serialize credentials: {e}")))?;
|
||||
|
||||
// Fetch the user's email from Google userinfo
|
||||
let actual_email = fetch_userinfo_email(&access_token).await;
|
||||
|
||||
// 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(())
|
||||
}
|
||||
|
||||
/// Fetch the authenticated user's email from Google's userinfo endpoint.
|
||||
@@ -1203,68 +1363,71 @@ async fn handle_status() -> Result<(), GwsError> {
|
||||
if let (Some(cid), Some(csec), Some(rt)) = (client_id, client_secret, refresh_token)
|
||||
{
|
||||
// Exchange refresh token for access token
|
||||
let http_client = reqwest::Client::new();
|
||||
let token_resp = http_client
|
||||
.post("https://oauth2.googleapis.com/token")
|
||||
.form(&[
|
||||
("client_id", cid),
|
||||
("client_secret", csec),
|
||||
("refresh_token", rt),
|
||||
("grant_type", "refresh_token"),
|
||||
])
|
||||
.send()
|
||||
.await;
|
||||
if let Ok(http_client) = crate::client::shared_client() {
|
||||
let token_resp = http_client
|
||||
.post("https://oauth2.googleapis.com/token")
|
||||
.form(&[
|
||||
("client_id", cid),
|
||||
("client_secret", csec),
|
||||
("refresh_token", rt),
|
||||
("grant_type", "refresh_token"),
|
||||
])
|
||||
.send()
|
||||
.await;
|
||||
|
||||
if let Ok(resp) = token_resp {
|
||||
if let Ok(token_json) = resp.json::<serde_json::Value>().await {
|
||||
if let Some(access_token) =
|
||||
token_json.get("access_token").and_then(|v| v.as_str())
|
||||
{
|
||||
output["token_valid"] = json!(true);
|
||||
|
||||
// Get user info
|
||||
if let Ok(user_resp) = http_client
|
||||
.get("https://www.googleapis.com/oauth2/v1/userinfo")
|
||||
.bearer_auth(access_token)
|
||||
.send()
|
||||
.await
|
||||
if let Ok(resp) = token_resp {
|
||||
if let Ok(token_json) = resp.json::<serde_json::Value>().await {
|
||||
if let Some(access_token) =
|
||||
token_json.get("access_token").and_then(|v| v.as_str())
|
||||
{
|
||||
if let Ok(user_json) =
|
||||
user_resp.json::<serde_json::Value>().await
|
||||
output["token_valid"] = json!(true);
|
||||
|
||||
// Get user info
|
||||
if let Ok(user_resp) = http_client
|
||||
.get("https://www.googleapis.com/oauth2/v1/userinfo")
|
||||
.bearer_auth(access_token)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
if let Some(email) =
|
||||
user_json.get("email").and_then(|v| v.as_str())
|
||||
if let Ok(user_json) =
|
||||
user_resp.json::<serde_json::Value>().await
|
||||
{
|
||||
output["user"] = json!(email);
|
||||
if let Some(email) =
|
||||
user_json.get("email").and_then(|v| v.as_str())
|
||||
{
|
||||
output["user"] = json!(email);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get granted scopes via tokeninfo
|
||||
let tokeninfo_url = format!(
|
||||
"https://oauth2.googleapis.com/tokeninfo?access_token={}",
|
||||
access_token
|
||||
);
|
||||
if let Ok(info_resp) = http_client.get(&tokeninfo_url).send().await
|
||||
{
|
||||
if let Ok(info_json) =
|
||||
info_resp.json::<serde_json::Value>().await
|
||||
// Get granted scopes via tokeninfo
|
||||
let tokeninfo_url = format!(
|
||||
"https://oauth2.googleapis.com/tokeninfo?access_token={}",
|
||||
access_token
|
||||
);
|
||||
if let Ok(info_resp) =
|
||||
http_client.get(&tokeninfo_url).send().await
|
||||
{
|
||||
if let Some(scope_str) =
|
||||
info_json.get("scope").and_then(|v| v.as_str())
|
||||
if let Ok(info_json) =
|
||||
info_resp.json::<serde_json::Value>().await
|
||||
{
|
||||
let scopes: Vec<&str> = scope_str.split(' ').collect();
|
||||
output["scopes"] = json!(scopes);
|
||||
output["scope_count"] = json!(scopes.len());
|
||||
if let Some(scope_str) =
|
||||
info_json.get("scope").and_then(|v| v.as_str())
|
||||
{
|
||||
let scopes: Vec<&str> =
|
||||
scope_str.split(' ').collect();
|
||||
output["scopes"] = json!(scopes);
|
||||
output["scope_count"] = json!(scopes.len());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
output["token_valid"] = json!(false);
|
||||
if let Some(err) =
|
||||
token_json.get("error_description").and_then(|v| v.as_str())
|
||||
{
|
||||
output["token_error"] = json!(err);
|
||||
} else {
|
||||
output["token_valid"] = json!(false);
|
||||
if let Some(err) =
|
||||
token_json.get("error_description").and_then(|v| v.as_str())
|
||||
{
|
||||
output["token_error"] = json!(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2317,4 +2480,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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
|
||||
//! HTTP client with retry logic for Google API requests.
|
||||
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use reqwest::header::{HeaderMap, HeaderValue};
|
||||
|
||||
const MAX_RETRIES: u32 = 3;
|
||||
@@ -22,7 +24,7 @@ const MAX_RETRIES: u32 = 3;
|
||||
const MAX_RETRY_DELAY_SECS: u64 = 60;
|
||||
const CONNECT_TIMEOUT_SECS: u64 = 10;
|
||||
|
||||
pub fn build_client() -> Result<reqwest::Client, crate::error::GwsError> {
|
||||
fn build_client_inner() -> Result<reqwest::Client, String> {
|
||||
let mut headers = HeaderMap::new();
|
||||
let name = env!("CARGO_PKG_NAME");
|
||||
let version = env!("CARGO_PKG_VERSION");
|
||||
@@ -37,9 +39,26 @@ pub fn build_client() -> Result<reqwest::Client, crate::error::GwsError> {
|
||||
.default_headers(headers)
|
||||
.connect_timeout(std::time::Duration::from_secs(CONNECT_TIMEOUT_SECS))
|
||||
.build()
|
||||
.map_err(|e| {
|
||||
crate::error::GwsError::Other(anyhow::anyhow!("Failed to build HTTP client: {e}"))
|
||||
})
|
||||
.map_err(|e| format!("Failed to build HTTP client: {e}"))
|
||||
}
|
||||
|
||||
pub fn build_client() -> Result<reqwest::Client, crate::error::GwsError> {
|
||||
build_client_inner().map_err(|message| crate::error::GwsError::Other(anyhow::anyhow!(message)))
|
||||
}
|
||||
|
||||
/// Returns a shared reqwest client clone backed by a single global connection pool.
|
||||
///
|
||||
/// `reqwest::Client` is cheap to clone, so callers can take ownership of the
|
||||
/// returned value while still sharing pooled connections underneath.
|
||||
pub fn shared_client() -> Result<reqwest::Client, crate::error::GwsError> {
|
||||
static CLIENT: OnceLock<Result<reqwest::Client, String>> = OnceLock::new();
|
||||
|
||||
match CLIENT.get_or_init(build_client_inner) {
|
||||
Ok(client) => Ok(client.clone()),
|
||||
Err(message) => Err(crate::error::GwsError::Other(anyhow::anyhow!(
|
||||
message.clone()
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Send an HTTP request with automatic retry on 429 (rate limit) responses
|
||||
@@ -100,6 +119,20 @@ mod tests {
|
||||
assert!(build_client().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_client_succeeds() {
|
||||
assert!(shared_client().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_client_can_be_reused() {
|
||||
let client_a = shared_client().unwrap();
|
||||
let client_b = shared_client().unwrap();
|
||||
let request_a = client_a.get("https://example.com").build().unwrap();
|
||||
let request_b = client_b.get("https://example.com").build().unwrap();
|
||||
assert_eq!(request_a.url(), request_b.url());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retry_delay_caps_large_header_value() {
|
||||
assert_eq!(compute_retry_delay(Some("999999"), 0), MAX_RETRY_DELAY_SECS);
|
||||
|
||||
Reference in New Issue
Block a user