mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-28 08:44:41 +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:
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<RwLock<CodexOAuthManager>>,
|
||||
account_id: String,
|
||||
) -> Result<String, AppError> {
|
||||
) -> Result<(String, Option<String>), 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");
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user