fix(codex-oauth): make managed accounts safe & usable with the bare CLI

Addresses review feedback on #3879. These backend changes are deeply
intertwined across shared files, so they are committed together.

Refreshable managed auth (bare `codex` support):
- Write a full, native-shaped auth.json bundle (tokens.refresh_token + top-level
  last_refresh) instead of an access-only token, so the Codex CLI can self-refresh
  and the managed account keeps working past ~1h without a proxy.
- last_refresh reflects the access token's real obtained-at time (not write time),
  so the CLI doesn't treat a cached token as freshly refreshed.
- Read back the CLI-rotated refresh_token from ~/.codex/auth.json before writing
  (account_id-matched, chatgpt-mode guarded) so re-switching doesn't clobber the
  CLI's valid login with a stale refresh_token.

Never persist managed tokens at rest:
- Backfill of a managed provider always replaces live auth with the stored
  placeholder (even on marker mismatch), so native tokens can't leak into the
  provider's DB config.
- Sanitize managed auth out of Live backups on the initial/strict snapshot paths
  and in update_live_backup_from_provider_inner before serialization.

Concurrency / blocking:
- Drop the redundant outer Arc<RwLock<CodexOAuthManager>> (all methods are &self)
  so token refresh no longer holds a coarse lock across the network; give OAuth
  token/device requests a 30s timeout instead of the shared 600s client default.
- Add a persistence lock and linearize the access-token cache under a consistent
  accounts -> access_tokens order (existence-checked reads/writes; remove/clear
  clear the cache atomically) to prevent stale or resurrected cache entries.

Switch ordering:
- Preflight the managed token before committing the current provider on
  switch/add/update, so a failed token fetch can't leave DB/UI pointing at a
  provider whose live config was never written.

Cleanup:
- Remove the now-dead content-fingerprint marker machinery; managed writes are
  now shape-identical to a native login, so ownership is judged by account_id.

Known, documented limitations (B-scheme, narrow & recoverable): a remove+re-login
of the same account within an in-flight refresh window (ABA), a login authorized
after logout, and switch-away cleanup being unable to distinguish our write from a
user's native login of the same account.
This commit is contained in:
SaladDay
2026-07-01 15:19:12 +00:00
parent d3df16b5d1
commit c72bd49213
9 changed files with 763 additions and 380 deletions
+1 -3
View File
@@ -23,7 +23,6 @@ use super::{
ProxyError,
};
use crate::commands::{CodexOAuthState, CopilotAuthState};
use crate::proxy::providers::codex_oauth_auth::CodexOAuthManager;
use crate::proxy::providers::copilot_auth::CopilotAuthManager;
use crate::{
app_config::AppType,
@@ -1484,8 +1483,7 @@ impl RequestForwarder {
if auth.strategy == AuthStrategy::CodexOAuth {
if let Some(app_handle) = &self.app_handle {
let codex_state = app_handle.state::<CodexOAuthState>();
let codex_auth: tokio::sync::RwLockReadGuard<'_, CodexOAuthManager> =
codex_state.0.read().await;
let codex_auth = &codex_state.0;
// 从 provider.meta 获取关联的 ChatGPT 账号 ID
let account_id = provider
+233 -36
View File
@@ -23,6 +23,7 @@ use std::fs;
use std::io::Write;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::{Mutex, RwLock};
use super::copilot_auth::{GitHubAccount, GitHubDeviceCodeResponse};
@@ -48,6 +49,10 @@ const DEVICE_REDIRECT_URI: &str = "https://auth.openai.com/deviceauth/callback";
/// Token 刷新提前量(毫秒)
const TOKEN_REFRESH_BUFFER_MS: i64 = 60_000;
/// OAuth token/device 端点的单请求超时。共享 HTTP client 默认 600s 超时是给
/// 大模型流式响应用的,对认证请求过长;网络卡住时应尽快失败而非长时间阻塞。
const OAUTH_HTTP_TIMEOUT: Duration = Duration::from_secs(30);
/// Device Code 默认有效时长(秒),OpenAI 文档约定 15 分钟
const DEVICE_CODE_DEFAULT_EXPIRES_IN: u64 = 900;
@@ -160,6 +165,10 @@ struct CachedAccessToken {
token: String,
/// 过期时间戳(毫秒)
expires_at_ms: i64,
/// 获取(刷新)时间戳(毫秒)。用于写入托管 auth.json 的 `last_refresh`
/// 使其如实反映 access_token 的真实获取时间,而非写盘时刻——否则 Codex CLI
/// 会误判一个旧 token 是刚刷新的。
obtained_at_ms: i64,
}
impl CachedAccessToken {
@@ -225,6 +234,17 @@ struct CodexOAuthStore {
default_account_id: Option<String>,
}
/// 写入托管 Codex `auth.json` 所需的完整可刷新 token 束。
#[derive(Debug, Clone)]
pub(crate) struct ManagedTokenBundle {
pub access_token: String,
pub id_token: Option<String>,
pub refresh_token: String,
/// access_token 的真实获取时间,RFC3339 纳秒精度 + `Z`(与原生 auth.json 的
/// `last_refresh` 形状一致)。反映 token 何时真正刷新,而非写盘时刻。
pub last_refresh: String,
}
/// Codex OAuth 认证管理器(多账号)
pub struct CodexOAuthManager {
accounts: Arc<RwLock<HashMap<String, CodexAccountData>>>,
@@ -237,6 +257,10 @@ pub struct CodexOAuthManager {
/// 过期条目会在 start_device_flow 时被清理,防止放弃的登录流程导致无界增长
pending_device_codes: Arc<RwLock<HashMap<String, PendingDeviceCode>>>,
storage_path: PathBuf,
/// 持久化串行锁:`save_to_disk` 与 `clear_auth` 的「快照+写盘/删文件」都在此锁内
/// 完成。此前由外层 `RwLock<CodexOAuthManager>` 的写锁隐式串行化;去掉外层锁后
/// 需要它防止并发保存/清除交错,导致已删账号被旧快照复活。
storage_lock: Arc<Mutex<()>>,
}
impl CodexOAuthManager {
@@ -250,6 +274,7 @@ impl CodexOAuthManager {
refresh_locks: Arc::new(RwLock::new(HashMap::new())),
pending_device_codes: Arc::new(RwLock::new(HashMap::new())),
storage_path,
storage_lock: Arc::new(Mutex::new(())),
};
if let Err(e) = manager.load_from_disk_sync() {
@@ -272,6 +297,7 @@ impl CodexOAuthManager {
let response = crate::proxy::http_client::get()
.post(DEVICE_AUTH_USERCODE_URL)
.timeout(OAUTH_HTTP_TIMEOUT)
.header("Content-Type", "application/json")
.header("User-Agent", CODEX_USER_AGENT)
.json(&serde_json::json!({ "client_id": CODEX_CLIENT_ID }))
@@ -354,6 +380,7 @@ impl CodexOAuthManager {
let poll_response = crate::proxy::http_client::get()
.post(DEVICE_AUTH_TOKEN_URL)
.timeout(OAUTH_HTTP_TIMEOUT)
.header("Content-Type", "application/json")
.header("User-Agent", CODEX_USER_AGENT)
.json(&serde_json::json!({
@@ -408,21 +435,11 @@ impl CodexOAuthManager {
CodexOAuthError::ParseError("无法从 token 中提取 account_id".to_string())
})?;
// 缓存 access_token
{
let mut tokens_cache = self.access_tokens.write().await;
tokens_cache.insert(
account_id.clone(),
CachedAccessToken {
token: tokens.access_token.clone(),
expires_at_ms: compute_expires_at_ms(tokens.expires_in),
},
);
}
// 先落账号(写 accounts + 持久化),再按全局 accounts -> access_tokens 顺序、
// 在存在性确认下写 token 缓存,遵守「缓存条目只对应存在的账号」。
let account = self
.add_account_internal(
account_id,
account_id.clone(),
refresh_token,
email,
// 空字符串视为缺失,避免写出空的 id_token
@@ -430,6 +447,21 @@ impl CodexOAuthManager {
)
.await?;
{
let accounts = self.accounts.read().await;
if accounts.contains_key(&account_id) {
let mut tokens_cache = self.access_tokens.write().await;
tokens_cache.insert(
account_id.clone(),
CachedAccessToken {
token: tokens.access_token.clone(),
expires_at_ms: compute_expires_at_ms(tokens.expires_in),
obtained_at_ms: chrono::Utc::now().timestamp_millis(),
},
);
}
}
Ok(Some(account))
}
@@ -441,6 +473,7 @@ impl CodexOAuthManager {
) -> Result<OAuthTokenResponse, CodexOAuthError> {
let response = crate::proxy::http_client::get()
.post(OAUTH_TOKEN_URL)
.timeout(OAUTH_HTTP_TIMEOUT)
.header("Content-Type", "application/x-www-form-urlencoded")
.header("User-Agent", CODEX_USER_AGENT)
.form(&[
@@ -474,6 +507,7 @@ impl CodexOAuthManager {
) -> Result<OAuthTokenResponse, CodexOAuthError> {
let response = crate::proxy::http_client::get()
.post(OAUTH_TOKEN_URL)
.timeout(OAUTH_HTTP_TIMEOUT)
.header("Content-Type", "application/x-www-form-urlencoded")
.header("User-Agent", CODEX_USER_AGENT)
.form(&[
@@ -510,12 +544,36 @@ impl CodexOAuthManager {
&self,
account_id: &str,
) -> Result<String, CodexOAuthError> {
// 先检查缓存
Ok(self.resolve_valid_cached_token(account_id).await?.token)
}
/// 解析账号的有效缓存 token(含真实获取时间),必要时刷新。
///
/// 返回完整 `CachedAccessToken`,使 token 与其 `obtained_at_ms` 天然配套(写托管
/// auth.json 的 `last_refresh` 直接取用),避免分两次读缓存造成的错配。
///
/// 并发正确性:统一按 `accounts -> access_tokens` 顺序加锁——读/写 token 缓存前都
/// 在 `accounts` 读锁下确认账号仍存在。配合 `remove_account`/`clear_auth` 在
/// `accounts` 写锁内原子清缓存,杜绝「已删账号的 token 被写回或被继续返回」。
///
/// 已知未覆盖边界(ABA,极窄且可恢复):若一次刷新已用旧 refresh_token 在飞(≤30s
/// 超时),期间同一 `account_id` 被 remove 后又重新登录,则旧刷新返回时可能把旧
/// generation 的 token 写进新账号。需要 generation/version 校验才能彻底关闭;因触发
/// 需要「刷新在飞窗口内 remove+重加同一账号」且结果可通过重新登录恢复,暂不引入。
async fn resolve_valid_cached_token(
&self,
account_id: &str,
) -> Result<CachedAccessToken, CodexOAuthError> {
// 快路径:确认账号存在后读缓存
{
let accounts = self.accounts.read().await;
if !accounts.contains_key(account_id) {
return Err(CodexOAuthError::AccountNotFound(account_id.to_string()));
}
let tokens = self.access_tokens.read().await;
if let Some(cached) = tokens.get(account_id) {
if !cached.is_expiring_soon() {
return Ok(cached.token.clone());
return Ok(cached.clone());
}
}
}
@@ -525,12 +583,16 @@ impl CodexOAuthManager {
let refresh_lock = self.get_refresh_lock(account_id).await;
let _guard = refresh_lock.lock().await;
// double-check
// double-check(同样在 accounts 读锁下)
{
let accounts = self.accounts.read().await;
if !accounts.contains_key(account_id) {
return Err(CodexOAuthError::AccountNotFound(account_id.to_string()));
}
let tokens = self.access_tokens.read().await;
if let Some(cached) = tokens.get(account_id) {
if !cached.is_expiring_soon() {
return Ok(cached.token.clone());
return Ok(cached.clone());
}
}
}
@@ -574,21 +636,24 @@ impl CodexOAuthManager {
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);
let cached = CachedAccessToken {
token: new_tokens.access_token.clone(),
expires_at_ms: compute_expires_at_ms(new_tokens.expires_in),
obtained_at_ms: chrono::Utc::now().timestamp_millis(),
};
// 在 accounts 读锁下确认账号仍存在,再写缓存:与 remove/clear(持 accounts
// 写锁并原子清缓存)互斥,杜绝把已删账号的 token 写回缓存。
{
let accounts = self.accounts.read().await;
if !accounts.contains_key(account_id) {
return Err(CodexOAuthError::AccountNotFound(account_id.to_string()));
}
let mut tokens = self.access_tokens.write().await;
tokens.insert(
account_id.to_string(),
CachedAccessToken {
token: access_token.clone(),
expires_at_ms,
},
);
tokens.insert(account_id.to_string(), cached.clone());
}
Ok(access_token)
Ok(cached)
}
/// 获取指定账号的有效 access_token 与 id_token(必要时自动刷新)
@@ -609,6 +674,89 @@ impl CodexOAuthManager {
Ok((access_token, id_token))
}
/// 获取写入托管 Codex `auth.json` 所需的完整可刷新 token 束
/// access_token + id_token + refresh_token)。
///
/// 与仅返回 access_token 不同:写入 Codex CLI 的 auth.json 必须携带
/// refresh_token,否则 CLI 在 access_token 过期后无法自刷新(详见托管直连
/// 场景 “裸跑 codex”)。
pub(crate) async fn get_valid_token_bundle_for_account(
&self,
account_id: &str,
) -> Result<ManagedTokenBundle, CodexOAuthError> {
// access_token 与其获取时间来自同一次解析(缓存命中即同一条目、刷新即新铸),
// 天然配套,杜绝「旧 token + 新时间戳」的错配。
let cached = self.resolve_valid_cached_token(account_id).await?;
let last_refresh =
chrono::DateTime::<chrono::Utc>::from_timestamp_millis(cached.obtained_at_ms)
.unwrap_or_else(chrono::Utc::now)
.to_rfc3339_opts(chrono::SecondsFormat::Nanos, true);
let (id_token, refresh_token) = {
let accounts = self.accounts.read().await;
let account = accounts
.get(account_id)
.ok_or_else(|| CodexOAuthError::AccountNotFound(account_id.to_string()))?;
(account.id_token.clone(), account.refresh_token.clone())
};
Ok(ManagedTokenBundle {
access_token: cached.token,
id_token,
refresh_token,
last_refresh,
})
}
/// 采纳(读回)Codex CLI 轮换后的 refresh_token / id_token。
///
/// 托管账号以「完整 bundle」写入 auth.json 后,Codex CLI 会自行刷新并把新的
/// refresh_token 回写 auth.json。切换回该 provider 前调用本方法,把盘上的最新
/// refresh_token 采纳进本地存储,避免用陈腐 token 覆盖 CLI 的有效登录。
///
/// 仅当账号确由本 manager 托管、且值确有变化时才更新并落盘;返回是否更新。
pub async fn adopt_account_refresh_token(
&self,
account_id: &str,
refresh_token: String,
id_token: Option<String>,
) -> Result<bool, CodexOAuthError> {
let refresh_token = refresh_token.trim().to_string();
if refresh_token.is_empty() {
return Ok(false);
}
// 与该账号的刷新串行化:若一个 refresh 正持旧 refresh_token 在飞,避免它返回后
// 覆盖我们刚采纳的 CLI 轮换值。
let refresh_lock = self.get_refresh_lock(account_id).await;
let _guard = refresh_lock.lock().await;
let mut changed = false;
{
let mut accounts = self.accounts.write().await;
let Some(account) = accounts.get_mut(account_id) else {
// 不是本 manager 托管的账号:不接管、不落盘。
return Ok(false);
};
if account.refresh_token != refresh_token {
account.refresh_token = refresh_token;
changed = true;
}
if let Some(id_token) = id_token.filter(|token| !token.trim().is_empty()) {
if account.id_token.as_deref() != Some(id_token.as_str()) {
account.id_token = Some(id_token);
changed = true;
}
}
// 采纳了 CLI 轮换后的 refresh_token:与之配套的旧 access_token 可能已被
// 服务端提前失效。在同一 accounts 写锁内(accounts -> access_tokens 顺序)
// 清缓存,避免释放锁后被快路径读到旧 token;下次按新 refresh_token 重取。
if changed {
self.access_tokens.write().await.remove(account_id);
}
}
if changed {
self.save_to_disk().await?;
}
Ok(changed)
}
/// 获取默认账号的有效 token
pub async fn get_valid_token(&self) -> Result<String, CodexOAuthError> {
match self.resolve_default_account_id().await {
@@ -636,15 +784,13 @@ impl CodexOAuthManager {
log::info!("[CodexOAuth] 移除账号: {account_id}");
{
// 在 accounts 写锁内原子清除该账号的 token 缓存(accounts -> access_tokens
// 顺序),确保不存在「账号已删但缓存仍在」的窗口。
let mut accounts = self.accounts.write().await;
if accounts.remove(account_id).is_none() {
return Err(CodexOAuthError::AccountNotFound(account_id.to_string()));
}
}
{
let mut tokens = self.access_tokens.write().await;
tokens.remove(account_id);
self.access_tokens.write().await.remove(account_id);
}
{
let mut locks = self.refresh_locks.write().await;
@@ -683,18 +829,21 @@ impl CodexOAuthManager {
pub async fn clear_auth(&self) -> Result<(), CodexOAuthError> {
log::info!("[CodexOAuth] 清除所有认证");
// 与 save_to_disk 共用持久化锁:确保「清内存 + 删文件」相对于并发保存原子,
// 不会被一个持有旧快照的 save 复活已清除的账号。
let _persist = self.storage_lock.lock().await;
{
// 在 accounts 写锁内原子清除 accounts 与 token 缓存(accounts ->
// access_tokens 顺序),杜绝「账号已清但缓存仍在」及并发 refresh 回填。
let mut accounts = self.accounts.write().await;
accounts.clear();
self.access_tokens.write().await.clear();
}
{
let mut default = self.default_account_id.write().await;
*default = None;
}
{
let mut tokens = self.access_tokens.write().await;
tokens.clear();
}
{
let mut locks = self.refresh_locks.write().await;
locks.clear();
@@ -757,6 +906,7 @@ impl CodexOAuthManager {
CachedAccessToken {
token: access_token.to_string(),
expires_at_ms: chrono::Utc::now().timestamp_millis() + 3_600_000,
obtained_at_ms: chrono::Utc::now().timestamp_millis(),
},
);
@@ -937,6 +1087,9 @@ impl CodexOAuthManager {
}
async fn save_to_disk(&self) -> Result<(), CodexOAuthError> {
// 串行化「快照 + 写盘」:在持久化锁内取快照,确保并发保存/清除不会用
// 陈旧快照覆盖,避免已删账号被复活。
let _persist = self.storage_lock.lock().await;
let accounts = self.accounts.read().await.clone();
let default = self.resolve_default_account_id().await;
@@ -1093,6 +1246,7 @@ mod tests {
let expiring = CachedAccessToken {
token: "t".to_string(),
expires_at_ms: now + 30_000,
obtained_at_ms: now,
};
assert!(expiring.is_expiring_soon());
@@ -1100,6 +1254,7 @@ mod tests {
let valid = CachedAccessToken {
token: "t".to_string(),
expires_at_ms: now + 3_600_000,
obtained_at_ms: now,
};
assert!(!valid.is_expiring_soon());
}
@@ -1203,4 +1358,46 @@ mod tests {
assert_eq!(accounts.len(), 1);
assert_eq!(accounts[0].id, "acc-456");
}
#[tokio::test]
async fn adopt_account_refresh_token_syncs_rotated_value() {
let temp = tempfile::tempdir().unwrap();
let manager = CodexOAuthManager::new(temp.path().to_path_buf());
manager
.add_test_account_with_access_token("acc-1", "access-cached", Some("id-1"))
.await
.unwrap();
// 采纳 Codex CLI 轮换后的 refresh_token / id_token。
let changed = manager
.adopt_account_refresh_token(
"acc-1",
"rotated-rt".to_string(),
Some("id-2".to_string()),
)
.await
.unwrap();
assert!(changed, "rotated refresh_token should be adopted");
// 完整 bundle 反映新的 refresh_tokenaccess_token 仍取自缓存(无需联网)。
let bundle = manager
.get_valid_token_bundle_for_account("acc-1")
.await
.unwrap();
assert_eq!(bundle.refresh_token, "rotated-rt");
assert_eq!(bundle.access_token, "access-cached");
assert_eq!(bundle.id_token.as_deref(), Some("id-2"));
// 未知账号不接管。
assert!(!manager
.adopt_account_refresh_token("acc-unknown", "x".to_string(), None)
.await
.unwrap());
// 相同值不算变化。
assert!(!manager
.adopt_account_refresh_token("acc-1", "rotated-rt".to_string(), None)
.await
.unwrap());
}
}