From fc5fa85807d2e7683b6282f3a29ccd204e8f5e6b Mon Sep 17 00:00:00 2001 From: SaladDay Date: Tue, 23 Jun 2026 16:59:24 +0000 Subject: [PATCH] feat(codex): write managed ChatGPT id_token into live auth + reauth prompt Bind the selected ChatGPT account's id_token into the Codex official managed auth.json so it matches a native browser login for the fields that drive behavior (auth_mode, account/plan/email via id_token claims). - Persist id_token on CodexAccountData; capture at login and on refresh (empty string treated as missing). Add get_valid_token_and_id_token_for_account. - codex_managed_oauth_live_auth now writes tokens.id_token when available. - Keep the managed-vs-native safety marker intact: extract_codex_managed_oauth_auth tolerates id_token but still rejects native logins (which carry refresh_token / top-level last_refresh), so cc-switch never clears a real browser login. refresh_token and last_refresh are intentionally NOT written for this reason. - Surface reauth_required (account has no stored id_token) through GitHubAccount -> ManagedAuthAccount -> TS. Legacy accounts get a styled amber prompt + one-click re-login (device flow) in CodexOAuthSection, flagged in both the selector dropdown and the select-mode hint. - i18n: reauth strings added for en / ja / zh / zh-TW. Verified: pnpm typecheck + build:renderer green; cross-model review of Rust correctness, the managed-vs-native safety invariant, and the id_token lifecycle. --- src-tauri/src/codex_config.rs | 46 +++++++- src-tauri/src/commands/auth.rs | 3 + .../src/proxy/providers/codex_oauth_auth.rs | 72 +++++++++++-- src-tauri/src/proxy/providers/copilot_auth.rs | 7 ++ src-tauri/src/services/provider/live.rs | 66 +++++++++--- src-tauri/src/services/provider/mod.rs | 6 +- src-tauri/src/services/proxy.rs | 13 ++- .../providers/forms/CodexOAuthSection.tsx | 102 ++++++++++++++++-- src/i18n/locales/en.json | 6 ++ src/i18n/locales/ja.json | 6 ++ src/i18n/locales/zh-TW.json | 6 ++ src/i18n/locales/zh.json | 6 ++ src/lib/api/auth.ts | 2 + 13 files changed, 307 insertions(+), 34 deletions(-) diff --git a/src-tauri/src/codex_config.rs b/src-tauri/src/codex_config.rs index 29ae5c334..825eab9da 100644 --- a/src-tauri/src/codex_config.rs +++ b/src-tauri/src/codex_config.rs @@ -86,9 +86,11 @@ fn extract_codex_managed_oauth_auth(auth: &Value) -> Option<(String, String)> { let tokens = auth.get("tokens").and_then(|value| value.as_object())?; + // id_token 与原生浏览器登录一致,允许存在;但原生登录特有的 + // refresh_token 等字段仍会被拒绝,从而保留「托管 vs 原生」的指纹区分。 if tokens .keys() - .any(|key| !matches!(key.as_str(), "access_token" | "account_id")) + .any(|key| !matches!(key.as_str(), "access_token" | "account_id" | "id_token")) { return None; } @@ -1792,6 +1794,48 @@ base_url = "https://single.example.com/v1" ); } + #[test] + #[serial] + fn recorded_managed_oauth_marker_tolerates_id_token_but_rejects_native_login_shape() { + let _home = TempHome::new(); + // 托管写入:tokens 仅含 id_token + access_token + account_id + let managed_auth_with_id_token = json!({ + "auth_mode": "chatgpt", + "OPENAI_API_KEY": null, + "tokens": { + "id_token": "managed-id-token", + "access_token": "managed-token", + "account_id": "acct-managed" + } + }); + // 原生浏览器登录:即使 access_token 巧合相同,也带有 refresh_token + // 与顶层 last_refresh,必须被判定为非托管、永不清除。 + let native_login_auth = json!({ + "auth_mode": "chatgpt", + "OPENAI_API_KEY": null, + "tokens": { + "id_token": "native-id-token", + "access_token": "managed-token", + "refresh_token": "native-refresh-token", + "account_id": "acct-managed" + }, + "last_refresh": "2026-01-01T00:00:00Z" + }); + + record_codex_managed_oauth_live_auth(&managed_auth_with_id_token).expect("record marker"); + + assert!( + codex_auth_matches_recorded_managed_oauth(&managed_auth_with_id_token, "acct-managed") + .expect("managed auth with id_token should match"), + "an id_token alongside access_token/account_id must still be treated as managed" + ); + assert!( + !codex_auth_matches_recorded_managed_oauth(&native_login_auth, "acct-managed") + .expect("native login should not match"), + "a real browser login (refresh_token + last_refresh) must never be treated as managed" + ); + } + #[test] fn prepare_provider_live_config_uses_top_level_token_for_reserved_provider() { let input = r#"model_provider = "openai" diff --git a/src-tauri/src/commands/auth.rs b/src-tauri/src/commands/auth.rs index c3036023a..a64901c58 100644 --- a/src-tauri/src/commands/auth.rs +++ b/src-tauri/src/commands/auth.rs @@ -19,6 +19,8 @@ pub struct ManagedAuthAccount { pub authenticated_at: i64, pub is_default: bool, pub github_domain: String, + /// 托管账号是否需要重新登录以补全缺失的凭据(Codex 旧账号缺少 id_token) + pub reauth_required: bool, } #[derive(Debug, Clone, serde::Serialize)] @@ -55,6 +57,7 @@ fn map_account( ) -> ManagedAuthAccount { ManagedAuthAccount { is_default: default_account_id == Some(account.id.as_str()), + reauth_required: account.reauth_required, id: account.id, provider: provider.to_string(), login: account.login, diff --git a/src-tauri/src/proxy/providers/codex_oauth_auth.rs b/src-tauri/src/proxy/providers/codex_oauth_auth.rs index 2d2d81ba0..c018ddd7f 100644 --- a/src-tauri/src/proxy/providers/codex_oauth_auth.rs +++ b/src-tauri/src/proxy/providers/codex_oauth_auth.rs @@ -189,6 +189,10 @@ struct CodexAccountData { pub refresh_token: String, /// 认证时间戳(秒) pub authenticated_at: i64, + /// ChatGPT id_token(JWT,持久化)。用于让托管写入的 Codex auth.json + /// 与原生浏览器登录保持一致的 tokens 字段形状;刷新时若返回新值则更新。 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub id_token: Option, } /// 公开的账号信息(返回给前端,复用 GitHubAccount 结构) @@ -204,6 +208,8 @@ impl From<&CodexAccountData> for GitHubAccount { avatar_url: None, authenticated_at: data.authenticated_at, github_domain: "github.com".to_string(), + // 旧账号(升级前登录)没有持久化 id_token,需重新登录补全 + reauth_required: data.id_token.is_none(), } } } @@ -419,7 +425,13 @@ impl CodexOAuthManager { } let account = self - .add_account_internal(account_id, refresh_token, email) + .add_account_internal( + account_id, + refresh_token, + email, + // 空字符串视为缺失,避免写出空的 id_token + tokens.id_token.clone().filter(|t| !t.trim().is_empty()), + ) .await?; Ok(Some(account)) @@ -539,17 +551,34 @@ impl CodexOAuthManager { let new_tokens = self.refresh_with_token(&refresh_token).await?; - // 如果服务端返回了新的 refresh_token,更新存储 - if let Some(new_refresh) = new_tokens.refresh_token.clone() { - if new_refresh != refresh_token { - let mut accounts = self.accounts.write().await; - if let Some(account) = accounts.get_mut(account_id) { - account.refresh_token = new_refresh; + // 如果服务端返回了新的 refresh_token 或 id_token,更新存储 + let mut needs_save = false; + { + let mut accounts = self.accounts.write().await; + if let Some(account) = accounts.get_mut(account_id) { + if let Some(new_refresh) = new_tokens.refresh_token.clone() { + if new_refresh != account.refresh_token { + account.refresh_token = new_refresh; + needs_save = true; + } + } + // 刷新使用 openid scope,正常会返回新 id_token;为空则视为缺失, + // 保留旧值而非覆盖(旧值的 claims 仍可用于账号/套餐显示)。 + if let Some(new_id_token) = new_tokens + .id_token + .clone() + .filter(|token| !token.trim().is_empty()) + { + if account.id_token.as_deref() != Some(new_id_token.as_str()) { + account.id_token = Some(new_id_token); + needs_save = true; + } } - drop(accounts); - self.save_to_disk().await?; } } + if needs_save { + self.save_to_disk().await?; + } let access_token = new_tokens.access_token.clone(); let expires_at_ms = compute_expires_at_ms(new_tokens.expires_in); @@ -568,6 +597,24 @@ impl CodexOAuthManager { Ok(access_token) } + /// 获取指定账号的有效 access_token 与 id_token(必要时自动刷新) + /// + /// id_token 用于让托管写入的 Codex auth.json 与原生浏览器登录保持 + /// 一致的 tokens 字段形状(仅托管绑定路径使用)。旧账号若无 id_token + /// 会返回 `None`,前端据此提示重新登录。 + pub async fn get_valid_token_and_id_token_for_account( + &self, + account_id: &str, + ) -> Result<(String, Option), CodexOAuthError> { + // 先确保 access_token 有效;刷新过程会顺带更新持久化的 id_token + let access_token = self.get_valid_token_for_account(account_id).await?; + let id_token = { + let accounts = self.accounts.read().await; + accounts.get(account_id).and_then(|a| a.id_token.clone()) + }; + Ok((access_token, id_token)) + } + /// 获取默认账号的有效 token pub async fn get_valid_token(&self) -> Result { match self.resolve_default_account_id().await { @@ -700,11 +747,13 @@ impl CodexOAuthManager { &self, account_id: &str, access_token: &str, + id_token: Option<&str>, ) -> Result<(), CodexOAuthError> { self.add_account_internal( account_id.to_string(), "test-refresh-token".to_string(), Some(format!("{account_id}@example.test")), + id_token.map(|token| token.to_string()), ) .await?; @@ -727,6 +776,7 @@ impl CodexOAuthManager { account_id: String, refresh_token: String, email: Option, + id_token: Option, ) -> Result { let now = chrono::Utc::now().timestamp(); @@ -735,6 +785,7 @@ impl CodexOAuthManager { email, refresh_token, authenticated_at: now, + id_token, }; let account = GitHubAccount::from(&data); @@ -1116,6 +1167,7 @@ mod tests { "acc-123".to_string(), "rt-secret".to_string(), Some("user@example.com".to_string()), + None, ) .await .unwrap(); @@ -1138,6 +1190,7 @@ mod tests { "acc-123".to_string(), "rt".to_string(), Some("a@example.com".to_string()), + None, ) .await .unwrap(); @@ -1146,6 +1199,7 @@ mod tests { "acc-456".to_string(), "rt2".to_string(), Some("b@example.com".to_string()), + None, ) .await .unwrap(); diff --git a/src-tauri/src/proxy/providers/copilot_auth.rs b/src-tauri/src/proxy/providers/copilot_auth.rs index c82011fc6..e224d42db 100644 --- a/src-tauri/src/proxy/providers/copilot_auth.rs +++ b/src-tauri/src/proxy/providers/copilot_auth.rs @@ -341,6 +341,10 @@ pub struct GitHubAccount { /// GitHub 域名(github.com 或 GHES 域名) #[serde(default = "default_github_domain")] pub github_domain: String, + /// 托管账号是否需要重新登录以补全缺失的凭据。 + /// Codex:缺少持久化 id_token 的旧账号为 true;Copilot 恒为 false。 + #[serde(default)] + pub reauth_required: bool, } impl From<&GitHubAccountData> for GitHubAccount { @@ -351,6 +355,7 @@ impl From<&GitHubAccountData> for GitHubAccount { avatar_url: data.user.avatar_url.clone(), authenticated_at: data.authenticated_at, github_domain: data.github_domain.clone(), + reauth_required: false, } } } @@ -547,6 +552,7 @@ impl CopilotAuthManager { avatar_url: user.avatar_url.clone(), authenticated_at: now, github_domain, + reauth_required: false, }; { @@ -1557,6 +1563,7 @@ mod tests { avatar_url: Some("https://example.com/avatar.png".to_string()), authenticated_at: 1234567890, github_domain: DEFAULT_GITHUB_DOMAIN.to_string(), + reauth_required: false, }], default_account_id: Some("12345".to_string()), migration_error: None, diff --git a/src-tauri/src/services/provider/live.rs b/src-tauri/src/services/provider/live.rs index 1daffcf04..1f2092c64 100644 --- a/src-tauri/src/services/provider/live.rs +++ b/src-tauri/src/services/provider/live.rs @@ -586,8 +586,9 @@ fn apply_codex_managed_oauth_auth( )); }; - let access_token = get_codex_managed_oauth_token(manager.clone(), account_id.clone())?; - let auth = codex_managed_oauth_live_auth(&account_id, &access_token); + let (access_token, id_token) = + get_codex_managed_oauth_token(manager.clone(), account_id.clone())?; + let auth = codex_managed_oauth_live_auth(&account_id, &access_token, id_token.as_deref()); let Some(settings_obj) = provider.settings_config.as_object_mut() else { return Err(AppError::Config( @@ -602,13 +603,13 @@ fn apply_codex_managed_oauth_auth( fn get_codex_managed_oauth_token( manager: Arc>, account_id: String, -) -> Result { +) -> Result<(String, Option), AppError> { let result = std::thread::spawn(move || { tauri::async_runtime::block_on(async move { let error_account_id = account_id.clone(); let manager = manager.read().await; manager - .get_valid_token_for_account(&account_id) + .get_valid_token_and_id_token_for_account(&account_id) .await .map_err(|err| { format!( @@ -623,14 +624,28 @@ fn get_codex_managed_oauth_token( result.map_err(AppError::Message) } -pub(crate) fn codex_managed_oauth_live_auth(account_id: &str, access_token: &str) -> Value { +pub(crate) fn codex_managed_oauth_live_auth( + account_id: &str, + access_token: &str, + id_token: Option<&str>, +) -> Value { + // 与原生 Codex 浏览器登录的 tokens 字段顺序对齐:id_token 在前。 + let mut tokens = serde_json::Map::new(); + if let Some(id_token) = id_token { + tokens.insert("id_token".to_string(), Value::String(id_token.to_string())); + } + tokens.insert( + "access_token".to_string(), + Value::String(access_token.to_string()), + ); + tokens.insert( + "account_id".to_string(), + Value::String(account_id.to_string()), + ); json!({ "auth_mode": "chatgpt", "OPENAI_API_KEY": null, - "tokens": { - "access_token": access_token, - "account_id": account_id, - }, + "tokens": Value::Object(tokens), }) } @@ -1799,7 +1814,24 @@ mod tests { #[test] fn codex_managed_oauth_live_auth_matches_codex_cli_shape() { assert_eq!( - codex_managed_oauth_live_auth("acct-managed", "access-token"), + codex_managed_oauth_live_auth("acct-managed", "access-token", Some("id-token")), + json!({ + "auth_mode": "chatgpt", + "OPENAI_API_KEY": null, + "tokens": { + "id_token": "id-token", + "access_token": "access-token", + "account_id": "acct-managed" + } + }), + "managed live auth should include id_token to match native browser login" + ); + } + + #[test] + fn codex_managed_oauth_live_auth_without_id_token_omits_it() { + assert_eq!( + codex_managed_oauth_live_auth("acct-managed", "access-token", None), json!({ "auth_mode": "chatgpt", "OPENAI_API_KEY": null, @@ -1807,7 +1839,8 @@ mod tests { "access_token": "access-token", "account_id": "acct-managed" } - }) + }), + "without a stored id_token the field is omitted rather than written as null" ); } @@ -1821,7 +1854,11 @@ mod tests { manager .read() .await - .add_test_account_with_access_token("acct-managed", "managed-token") + .add_test_account_with_access_token( + "acct-managed", + "managed-token", + Some("managed-id-token"), + ) .await .expect("seed managed account"); }); @@ -1854,7 +1891,8 @@ mod tests { provider.settings_config.get("auth"), Some(&codex_managed_oauth_live_auth( "acct-managed", - "managed-token" + "managed-token", + Some("managed-id-token") )), "explicit account binding should write the managed ChatGPT token to Codex live auth" ); @@ -1870,7 +1908,7 @@ mod tests { manager .read() .await - .add_test_account_with_access_token("acct-managed", "managed-token") + .add_test_account_with_access_token("acct-managed", "managed-token", None) .await .expect("seed managed account"); }); diff --git a/src-tauri/src/services/provider/mod.rs b/src-tauri/src/services/provider/mod.rs index bcabe07df..47f7b6328 100644 --- a/src-tauri/src/services/provider/mod.rs +++ b/src-tauri/src/services/provider/mod.rs @@ -886,7 +886,11 @@ base_url = "http://localhost:8080" .codex_oauth_manager .read() .await - .add_test_account_with_access_token("acct-managed", "managed-token") + .add_test_account_with_access_token( + "acct-managed", + "managed-token", + Some("managed-id-token"), + ) .await .expect("seed managed Codex OAuth account"); }); diff --git a/src-tauri/src/services/proxy.rs b/src-tauri/src/services/proxy.rs index d05a5dd06..7b5d38e5e 100644 --- a/src-tauri/src/services/proxy.rs +++ b/src-tauri/src/services/proxy.rs @@ -5079,7 +5079,11 @@ base_url = "https://codex.example/v1" .codex_oauth_manager .read() .await - .add_test_account_with_access_token("acct-managed", "managed-token") + .add_test_account_with_access_token( + "acct-managed", + "managed-token", + Some("managed-id-token"), + ) .await .expect("seed managed Codex OAuth account"); @@ -5146,6 +5150,13 @@ base_url = "https://codex.example/v1" .and_then(|value| value.as_str()), Some("acct-managed") ); + assert_eq!( + stored + .pointer("/auth/tokens/id_token") + .and_then(|value| value.as_str()), + Some("managed-id-token"), + "managed backup auth should carry the account id_token to match native login" + ); } #[tokio::test] diff --git a/src/components/providers/forms/CodexOAuthSection.tsx b/src/components/providers/forms/CodexOAuthSection.tsx index 5eeb0babe..1423879f6 100644 --- a/src/components/providers/forms/CodexOAuthSection.tsx +++ b/src/components/providers/forms/CodexOAuthSection.tsx @@ -22,6 +22,8 @@ import { Sparkles, User, Settings2, + AlertTriangle, + RefreshCw, } from "lucide-react"; import { useCodexOauth } from "./hooks/useCodexOauth"; import { copyText } from "@/lib/clipboard"; @@ -118,6 +120,16 @@ export const CodexOAuthSection: React.FC = ({ } }; + // 升级前登录的旧账号没有持久化 id_token,需重新登录补全 + const hasReauthAccounts = accounts.some( + (account) => account.reauth_required, + ); + const selectedAccountNeedsReauth = + !!selectedAccountId && + accounts.some( + (account) => account.id === selectedAccountId && account.reauth_required, + ); + const accountSelect = onAccountSelect && (mode === "select" || hasAnyAccount || noneOptionLabel) && (
@@ -150,6 +162,12 @@ export const CodexOAuthSection: React.FC = ({
{account.login} + {account.reauth_required && ( + + + {t("codexOauth.reauthBadge", "需要重新登录")} + + )}
))} @@ -178,6 +196,24 @@ export const CodexOAuthSection: React.FC = ({
)} + {/* 旧账号需重新登录提示(缺少 id_token) */} + {mode === "manage" && hasReauthAccounts && ( +
+ +
+

+ {t("codexOauth.reauthTitle", "部分账号需要重新登录")} +

+

+ {t( + "codexOauth.reauthDescription", + "为与浏览器登录行为保持一致,这些账号需要重新登录以补全所需的登录凭据(id_token)。重新登录后即可正常用于托管绑定。", + )} +

+
+
+ )} + {/* 账号选择器 */} {mode === "select" && accountSelect ? (
@@ -198,6 +234,28 @@ export const CodexOAuthSection: React.FC = ({ accountSelect )} + {/* select 模式:所选账号需重新登录的内联提示 */} + {mode === "select" && selectedAccountNeedsReauth && ( +
+ +
+ {t( + "codexOauth.reauthSelectHint", + "该账号需重新登录以启用托管绑定。", + )} + {onManageAccounts && ( + + )} +
+
+ )} + {onFastModeChange && (
@@ -229,23 +287,51 @@ export const CodexOAuthSection: React.FC = ({ {accounts.map((account) => (
-
- - {account.login} +
+ + + {account.login} + {defaultAccountId === account.id && ( - + {t("codexOauth.defaultAccount", "默认")} )} {selectedAccountId === account.id && ( - + {t("codexOauth.selected", "已选中")} )} + {account.reauth_required && ( + + + {t("codexOauth.reauthBadge", "需要重新登录")} + + )}
-
+
+ {account.reauth_required && ( + + )} {defaultAccountId !== account.id && (