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:
SaladDay
2026-06-23 16:59:24 +00:00
parent 0aeca9a235
commit fc5fa85807
13 changed files with 307 additions and 34 deletions
@@ -189,6 +189,10 @@ struct CodexAccountData {
pub refresh_token: String,
/// 认证时间戳(秒)
pub authenticated_at: i64,
/// ChatGPT id_tokenJWT,持久化)。用于让托管写入的 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 的旧账号为 trueCopilot 恒为 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,