mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-28 00:35:32 +08:00
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.
This commit is contained in:
@@ -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<String>,
|
||||
}
|
||||
|
||||
/// 公开的账号信息(返回给前端,复用 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<String>), 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<String, CodexOAuthError> {
|
||||
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<String>,
|
||||
id_token: Option<String>,
|
||||
) -> Result<GitHubAccount, CodexOAuthError> {
|
||||
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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user