From 43d6164a15874b4966c85b3c643c9a73567ba1e5 Mon Sep 17 00:00:00 2001 From: voson Date: Sat, 11 Apr 2026 21:28:50 +0800 Subject: [PATCH] =?UTF-8?q?fix(oauth):=20reqwest=20=E5=90=AF=E7=94=A8=20sy?= =?UTF-8?q?stem-proxy=EF=BC=9BGoogle=20=E6=8D=A2=20token=20=E8=AF=8A?= =?UTF-8?q?=E6=96=AD=E4=B8=8E=200.5.26?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 工作区 reqwest 增加 system-proxy,与系统代理一致 - google.rs:超时/非 2xx 体日志;OAuth 请求单独 45s 超时 - README / AGENTS / deploy/.env.example:OAuth 出站与 HTTPS_PROXY 说明 --- AGENTS.md | 4 ++ Cargo.lock | 46 +++++++++++++++- Cargo.toml | 3 +- README.md | 3 +- crates/secrets-mcp/CHANGELOG.md | 12 ++++ crates/secrets-mcp/Cargo.toml | 2 +- crates/secrets-mcp/src/oauth/google.rs | 76 +++++++++++++++++++++----- deploy/.env.example | 7 ++- 8 files changed, 135 insertions(+), 18 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 7ff5d14..a8a17c1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -170,6 +170,10 @@ oauth_accounts ( `crates/secrets-mcp/CHANGELOG.md` 在构建时嵌入,服务端以 **Markdown** 渲染为 HTML(`pulldown-cmark`)。**首页**(`/`)页脚与 **Dashboard**(`/dashboard`,MCP 配置页)页脚均提供「变更记录」链接;发版时随 `secrets-mcp` 版本更新该文件即可。 +### Google OAuth 出站 HTTP + +换 token(`POST https://oauth2.googleapis.com/token`)与拉取 userinfo 使用工作区 **`reqwest`**。根目录 `Cargo.toml` 中为 `reqwest` 启用了 **`system-proxy`**(因 `default-features = false` 须显式打开),以便在 **macOS / Windows** 上读取**系统代理**,避免「浏览器能上 Google、服务端换 token 超时」这类代理不一致。若仅提供端口代理、系统代理未生效,可设 **`HTTPS_PROXY` / `NO_PROXY`**,见 `deploy/.env.example`。 + ### Web JSON API 与会话 除页面路由使用的 `require_valid_user`(未登录或 `key_version` 与库不一致时重定向 `/login`)外,JSON API(`/api/...`)使用等价校验:会话中的 `key_version` 须与 `users.key_version` 一致,否则返回 **401** JSON,避免仅校验 `user_id` 时与页面行为不一致。 diff --git a/Cargo.lock b/Cargo.lock index 1b01bbd..5dc7cc5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -356,6 +356,16 @@ dependencies = [ "version_check", ] +[[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-sys" version = "0.8.7" @@ -1025,9 +1035,11 @@ dependencies = [ "percent-encoding", "pin-project-lite", "socket2", + "system-configuration", "tokio", "tower-service", "tracing", + "windows-registry", ] [[package]] @@ -2093,7 +2105,7 @@ dependencies = [ [[package]] name = "secrets-mcp" -version = "0.5.24" +version = "0.5.26" dependencies = [ "anyhow", "askama", @@ -2611,6 +2623,27 @@ dependencies = [ "syn", ] +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags", + "core-foundation", + "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.27.0" @@ -3378,6 +3411,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 0e34dfe..b67dd33 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,4 +36,5 @@ tracing-subscriber = { version = "^0.3", features = ["env-filter"] } dotenvy = "^0.15" # HTTP -reqwest = { version = "^0.12", default-features = false, features = ["rustls-tls", "json"] } +# system-proxy:与浏览器一致,读取 macOS/Windows 系统代理(禁用 default 后须显式开启,否则 OAuth 出站不走 Clash 等) +reqwest = { version = "^0.12", default-features = false, features = ["rustls-tls", "json", "system-proxy"] } diff --git a/README.md b/README.md index ff42da5..a97e2ed 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,8 @@ cargo build --release -p secrets-mcp | `SECRETS_ENV` | 可选。设为 `prod` / `production` 时会拒绝弱 PostgreSQL TLS 模式(`prefer`、`disable`、`allow`、`require`)。 | | `BASE_URL` | 对外访问基址;OAuth 回调为 `{BASE_URL}/auth/google/callback`。默认 `http://localhost:9315`。 | | `SECRETS_MCP_BIND` | 监听地址,默认 `127.0.0.1:9315`。容器内或直接对外暴露端口时请改为 `0.0.0.0:9315`;反代时常为 `127.0.0.1:9315`。 | -| `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` | 可选;不配置则无 Google 登录入口。运行时从环境读取,勿写入 CI、勿打入二进制。 | +| `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` | 可选;不配置则无 Google 登录入口。运行时从环境读取,勿写入 CI、勿打入二进制。换 token 须访问 `oauth2.googleapis.com`:工作区 **`reqwest` 已启用 `system-proxy`**,与浏览器一致可走 macOS/Windows **系统代理**(如 Clash 系统代理模式)。 | +| `HTTPS_PROXY` / `NO_PROXY` | 可选。仅当系统代理未被进程识别、又需走本地端口代理时设置;示例见 [`deploy/.env.example`](deploy/.env.example)。 | | `RUST_LOG` | 可选;日志级别,如 `secrets_mcp=debug`。 | | `SECRETS_DATABASE_POOL_SIZE` | 可选。连接池最大连接数,默认 `10`。 | | `SECRETS_DATABASE_ACQUIRE_TIMEOUT` | 可选。获取连接超时秒数,默认 `5`。 | diff --git a/crates/secrets-mcp/CHANGELOG.md b/crates/secrets-mcp/CHANGELOG.md index 813f405..78ff07e 100644 --- a/crates/secrets-mcp/CHANGELOG.md +++ b/crates/secrets-mcp/CHANGELOG.md @@ -1,5 +1,17 @@ 本文档在构建时嵌入 Web 的 `/changelog` 页面,并由服务端渲染为 HTML。 +## [0.5.26] - 2026-04-11 + +### Fixed + +- **Google OAuth**:工作区 `reqwest` 此前关闭默认特性且未启用 **`system-proxy`**,进程不读取 macOS/Windows 系统代理,易出现与浏览器不一致(本机可上 Google 但换 token 超时)。已显式启用 `system-proxy`。 + +## [0.5.25] - 2026-04-11 + +### Changed + +- Google OAuth:token / userinfo 请求单独 **45s** 超时(避免仅触达默认客户端 15s);失败时区分超时、连接错误,并在非 2xx 时记录/返回 Google 响应体片段(如 `invalid_grant`、`redirect_uri_mismatch`)。 + ## [0.5.24] - 2026-04-11 ### Changed diff --git a/crates/secrets-mcp/Cargo.toml b/crates/secrets-mcp/Cargo.toml index 6a22168..2d849f5 100644 --- a/crates/secrets-mcp/Cargo.toml +++ b/crates/secrets-mcp/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "secrets-mcp" -version = "0.5.24" +version = "0.5.26" edition.workspace = true [[bin]] diff --git a/crates/secrets-mcp/src/oauth/google.rs b/crates/secrets-mcp/src/oauth/google.rs index 4fbc184..136e607 100644 --- a/crates/secrets-mcp/src/oauth/google.rs +++ b/crates/secrets-mcp/src/oauth/google.rs @@ -1,8 +1,13 @@ +use std::time::Duration; + use anyhow::{Context, Result}; use serde::Deserialize; use super::{OAuthConfig, OAuthUserInfo}; +/// OAuth token / userinfo calls can be slow on poor routes; keep above client default if needed. +const OAUTH_HTTP_TIMEOUT: Duration = Duration::from_secs(45); + #[derive(Deserialize)] struct TokenResponse { access_token: String, @@ -20,14 +25,28 @@ struct UserInfo { picture: Option, } +fn map_reqwest_send_err(e: reqwest::Error) -> anyhow::Error { + if e.is_timeout() { + anyhow::anyhow!( + "timeout reaching Google OAuth ({}s); ensure outbound HTTPS to oauth2.googleapis.com works (firewall/proxy/VPN if Google is unreachable)", + OAUTH_HTTP_TIMEOUT.as_secs() + ) + } else if e.is_connect() { + anyhow::anyhow!("connection error to Google OAuth: {e}") + } else { + anyhow::Error::new(e) + } +} + /// Exchange authorization code for tokens and fetch user profile. pub async fn exchange_code( client: &reqwest::Client, config: &OAuthConfig, code: &str, ) -> Result { - let token_resp: TokenResponse = client + let token_http = client .post("https://oauth2.googleapis.com/token") + .timeout(OAUTH_HTTP_TIMEOUT) .form(&[ ("code", code), ("client_id", &config.client_id), @@ -37,24 +56,55 @@ pub async fn exchange_code( ]) .send() .await - .context("failed to exchange Google code")? - .error_for_status() - .context("Google token endpoint error")? - .json() - .await - .context("failed to parse Google token response")?; + .map_err(map_reqwest_send_err) + .context("Google token HTTP request failed")?; - let user: UserInfo = client + let status = token_http.status(); + let body_bytes = token_http + .bytes() + .await + .context("read Google token response body")?; + + if !status.is_success() { + let body_lossy = String::from_utf8_lossy(&body_bytes); + tracing::warn!(%status, body = %body_lossy, "Google token endpoint error"); + anyhow::bail!( + "Google token error {}: {}", + status, + body_lossy.chars().take(512).collect::() + ); + } + + let token_resp: TokenResponse = + serde_json::from_slice(&body_bytes).context("failed to parse Google token JSON")?; + + let user_http = client .get("https://openidconnect.googleapis.com/v1/userinfo") + .timeout(OAUTH_HTTP_TIMEOUT) .bearer_auth(&token_resp.access_token) .send() .await - .context("failed to fetch Google userinfo")? - .error_for_status() - .context("Google userinfo endpoint error")? - .json() + .map_err(map_reqwest_send_err) + .context("Google userinfo HTTP request failed")?; + + let status = user_http.status(); + let body_bytes = user_http + .bytes() .await - .context("failed to parse Google userinfo")?; + .context("read Google userinfo body")?; + + if !status.is_success() { + let body_lossy = String::from_utf8_lossy(&body_bytes); + tracing::warn!(%status, body = %body_lossy, "Google userinfo endpoint error"); + anyhow::bail!( + "Google userinfo error {}: {}", + status, + body_lossy.chars().take(512).collect::() + ); + } + + let user: UserInfo = + serde_json::from_slice(&body_bytes).context("failed to parse Google userinfo JSON")?; Ok(OAuthUserInfo { provider: "google".to_string(), diff --git a/deploy/.env.example b/deploy/.env.example index 6ade06b..ab88fde 100644 --- a/deploy/.env.example +++ b/deploy/.env.example @@ -20,9 +20,14 @@ BASE_URL=https://secrets.example.com # ─── Google OAuth ───────────────────────────────────────────────────── # Google Cloud Console → APIs & Services → Credentials -# 授权回调 URI 须配置为:${BASE_URL}/auth/google/callback +# 授权回调 URI 须与 BASE_URL 完全一致:${BASE_URL}/auth/google/callback(含 http/https、主机名、端口) +# 运行 secrets-mcp 的机器须能访问 Google(oauth2.googleapis.com)。若本机用 Clash/Surge「系统代理」上网: +# 构建时已启用 reqwest 的 system-proxy,进程会跟随系统代理;仍失败时可设 HTTPS_PROXY(见下方)。 GOOGLE_CLIENT_ID= GOOGLE_CLIENT_SECRET= +# 若仍无法换 token(仅提供端口代理、无系统代理):可取消注释并改为本机代理地址 +# HTTPS_PROXY=http://127.0.0.1:7890 +# NO_PROXY=localhost,127.0.0.1 # ─── 微信登录(暂未开放,预留)─────────────────────────────────────── # WECHAT_APP_CLIENT_ID=