mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-29 09:37:37 +08:00
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:
@@ -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
|
||||
|
||||
@@ -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_token;access_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());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user