opencodex 多账户 OAuth 认证体系:从 auth.json 多账户存储到 GUI 账户切换的完整实现
【免费下载链接】opencodexUniversal provider proxy for OpenAI Codex & Claude Code — use any LLM (Claude, Gemini, Grok, DeepSeek, Ollama…) with Codex CLI, App, SDK, and Claude Code项目地址: https://gitcode.com/gh_mirrors/ope/opencodex
opencodex 作为 OpenAI Codex / Claude Code 的通用 Provider 代理,其 OAuth 认证层在 260706 迭代中完成了多账户化改造(multiauth):同一 Provider 下可以同时登录多个账户,运行时只使用“激活账户”的凭据,并在 GUI 上提供账户下拉切换、添加、删除能力。本文以devlog/_fin/260706_provider-multiauth的开发记录为主线,结合src/oauth源码与测试,完整讲解数据模型、迁移策略、解析器、Token Guardian、管理 API 与 GUI 交互,并如实说明各 Provider 的适用边界。
功能背景与设计目标
在 multiauth 之前,~/.opencodex/auth.json中每个 Provider 只保存一份 OAuth 凭据(单槽位)。用户在同一 Provider 下拥有多个账号时,只能反复登出再登录,且后台账户的刷新令牌无人维护,容易出现“登录中途失效”(开发日志中称为“로그인 안풀리는”问题)。
multiauth 单元的明确目标(见 000_plan.md)是:
auth.json每个 Provider 保存 N 个账户,支持激活账户切换;- GUI Provider 卡片出现细条账户下拉框,点击即激活;
- 请求只使用激活账户(本单元不做跨账户 429 自动轮换,轮换属于后续单元);
- 显式非目标:跨账户 429 自动轮换、按账户拉取配额、API-key 池、Codex 池改动、usage-log 的 accountId 归属。
范围排除:Codex / ChatGPT 的 passthrough 池已有独立体系(codex-accounts.json+ Codex Auth 页),multiauth 不重复实现。
数据模型与迁移策略
新形状:ProviderAccountSet
每个 Provider 在auth.json中的值从单个凭据对象升级为账户集合(types.ts):
export interface ProviderAccount { id: string; // 追加时一次性生成的稳定短 id,轮换后不再重推 alias?: string; // 用户自定义显示名,不参与鉴权 credential: OAuthCredentials; // access / refresh / expires / email / accountId ... needsReauth?: boolean; // 终态刷新失败(invalid_grant / reused / revoked) addedAt?: number; } export interface ProviderAccountSet { activeAccountId: string; // 当前激活账户,请求只使用它 selectionRevision?: string; // 选择代数,旧仓库无此字段 accounts: ProviderAccount[]; }持久化形状为{ provider: { activeAccountId, accounts: [...] } },示例(000_plan.md):
{ "anthropic": { "activeAccountId": "a1b2", "accounts": [ { "id": "a1b2", "credential": { "access": "...", "refresh": "...", "expires": 0, "email": "x@y.z" }, "needsReauth": false, "addedAt": 0 } ] } }账户 id 派生规则
账户 id 必须“对同一凭据稳定、对不同凭据可区分”。src/oauth/store.ts中newAccountId()使用sha256(accountId ?? email ?? refresh)取 128 bit(32 个 hex 字符);追加时若与既有账户冲突则加-1、-2后缀(distinctAccountId)。注意:id 只在追加时生成一次并持久化,轮换刷新令牌后不再重推——因为getAccountCredential依赖 id 定位账户,若每次刷新都重新派生 id,会导致刷新结果写入失败、令牌静默丢失。
旧数据兼容:legacy 归一化 + 一次性降级备份
- 加载时若某 Provider 的值是旧的单凭据对象(含
access字符串),normalizeAccountSet会将其包装为{ activeAccountId: <derived>, accounts: [一条] },登录不丢失; - 持久化一律写新形状,加载器对两种形状永久兼容;
- 降级安全网:首次以新形状覆盖仍含 legacy 条目的文件前,会写入一次性备份
auth.json.pre-multiauth(0600 权限),防止旧版 opencodex 静默丢弃新形状后无法恢复刷新令牌(实现见 store.ts 的backupLegacyOnce)。
单槽位例外
chatgpt始终单槽位:codex-auth-api把它当作 Codex 池登录的临时槽位,池子自己有codex-accounts.json账本,因此saveCredential("chatgpt", ...)总是整体替换(SINGLE_SLOT_PROVIDERS)。- 无身份字段(无 accountId/email)的凭据:普通登录时替换激活槽位(避免轮换刷新令牌制造重复账户);显式“添加账户”登录则保留旧槽位并追加(
preserveIdentityless选项)。
存储层:mutateStore 与多账户 API
src/oauth/store.ts(约 1108 行)是 multiauth 的核心存储实现,全部写操作走in-process 串行化:mutateStore内部先获取auth.store.lock文件锁(30s stale),在队列中执行 load → mutate → persist,避免“guardian 刷新非激活账户”与“用户切换激活账户”并发造成的丢失更新。跨进程竞态被接受(单代理假设)。
存储公开 API(store.ts):
| API | 语义 |
|---|---|
getCredential(provider) | 返回激活账户凭据(请求路径用) |
saveCredential(provider, cred) | 同身份 upsert(替换凭据并清除 needsReauth)并激活;新身份追加;无身份替换激活槽 |
removeCredential(provider) | 仅删除激活账户,剩余账户提升第一个,否则删除 Provider 键 |
listAccounts(provider) | 列出全部账户 |
getAccountSet(provider) | 获取账户集合 |
getAccountCredential(provider, accountId)/getAccountCredentialWithStatus | 读取指定账户凭据(后者一次性带回 needsReauth 标志,省一次全文件解析) |
saveAccountCredential(provider, accountId, cred) | 为指定账户持久化刷新后的凭据,不动 activeAccountId(guardian 专用路径) |
setActiveAccount(provider, accountId) | 切换激活账户 |
removeAccount(provider, accountId) | 删除指定账户(激活账户被删则提升下一个) |
markAccountNeedsReauth(provider, accountId, flag) | 标记终态刷新失败 |
upsertCredentialByIdentity(provider, cred) | 原子插入/更新身份凭据(导入器用),返回 "inserted"/"updated" |
commitOAuthAccountSelection/captureOAuthAccountSelection配合selectionRevision实现选择一致性:只有选择确实变化(或手动重申)才推进代数,持久化后通过publishAccountSelection发布事件,GUI 可订阅实时刷新。
账户感知的认证解析层
src/oauth/index.ts是解析层核心:
- 单飞锁升级:single-flight Map 的键从
provider改为provider + "\u0000" + accountId,每个 (provider, account) 独立去重刷新; getValidAccessToken(provider)解析激活账户后委托给新的getValidAccessTokenForAccount(provider, accountId):- 读取
getAccountCredential;过期则发起单飞刷新; - 刷新成功 →
saveAccountCredential(provider, accountId, merged)——先落盘再返回 access(轮换安全:新 refresh token 先写盘,与 auth2api manager 的实践一致),刷新时保留 identity 字段; - 刷新失败分类:响应体含
invalid_grant/refresh_token_reused/expired(大小写不敏感)视为终态→markAccountNeedsReauth(provider, accountId, true)并抛OAuthLoginRequiredError;其余错误按瞬时错误重抛;
- 读取
- kiro 本地 CLI 导入回退:仅在
accountId === activeAccountId时执行(避免后台账户被错误导入); - forceLogin:xai / anthropic 传
importLocal: "off"跳过本地 CLI 令牌导入、走真实 OAuth 流,使“添加第二个账户”不会复用浏览器会话的第一个账户;antigravity 强制select_account提示;cursor 的第二个参数是轮询间隔数字,不能盲传 opts。
Token Guardian:为每个账户保活
src/oauth/token-guardian.ts负责后台主动刷新:
- 遍历每个 Provider 的全部账户(
listAccounts),跳过needsReauth账户(终态,只有重新登录能修复),调用getValidAccessTokenForAccount; - 退避键升级为
oauth:<provider>:<accountId>,needsReauth 账户获得永久退避,不再反复锤击死掉的刷新令牌; - 安全设计:guardian 只触碰有效 refreshPolicy 为
proactive的 Provider,且全局开关config.tokenGuardian.enabled默认关闭——默认无新增后台流量; - 保活语义(呼应“登录不失效”诉求):非激活账户仅当 Provider 为 proactive 策略时才由 guardian 保活。Anthropic 默认保持
disabled(ToS 风险),其第二个账户只能存活到其刷新令牌自然过期为止。
管理 API:/api/oauth/accounts
src/server.ts(OAuth 端点块)新增/修改的接口(详见 030_api.md):
GET /api/oauth/accounts?provider=x→{ activeAccountId, accounts: [{ id, email(掩码), active, needsReauth, expiresAt, health... }] };未知 Provider 返回 400;PUT /api/oauth/accounts/activebody{ provider, accountId }→ 切换激活账户;账户不存在返回 404;成功后使clearProviderQuotaCache()使配额缓存失效并清除该 Provider 的 live-model 缓存,配额条与模型列表立即反映新账户;DELETE /api/oauth/accounts?provider=x&id=y→ 删除指定账户;删除最后一个账户时同时clearLoginState(provider);POST /api/oauth/login接受可选{ addAccount: true }→ 以forceLogin: true启动登录流,允许在浏览器中选择新身份;原有单账户登录行为不变;POST /api/oauth/logout不变(删除激活账户)。
安全不变量:邮箱经maskEmail掩码;任何响应不含 token;OAuthAccountSummary与OAuthAccessSnapshot为手写白名单结构,从构造上保证机密不出响应。
测试tests/oauth/oauth-accounts-api.test.ts验证了:GET 列表邮箱被掩码且不含 token(raw.includes("t1") === false)、needsReauth 账户投影reauth_required健康状态与ocx login anthropic修复动作、PUT 切换后activeAccountId变更、未知账户 404 / 未知 Provider 400、DELETE 删除激活账户后自动提升剩余账户,以及切换 Antigravity 账户后 account-scoped live-model 缓存被清空、在途的旧账户模型发现被丢弃。
GUI:Provider 卡片上的账户下拉
gui/src/pages/Providers.tsx与gui/src/styles.css实现了:
- 移除
.provider-quota顶部分割线(对应“중간선 없애고”需求),保留 padding; - OAuth 卡片新增细条
Accounts (N)折叠行(chevron 随展开旋转),展开后列出账户:圆点 + 掩码邮箱 + 激活徽标,点击非激活账户 →PUT /api/oauth/accounts/active→ 刷新账户列表 + 配额 + 通知;每账户提供删除图标;末尾+ Add account行 →POST /api/oauth/login { addAccount: true }(复用既有 loginOAuth 轮询,提取为接受 addAccount 标志); - 仅
authMode === "oauth"的卡片显示(跳过 forward/passthrough 与 chatgpt——Codex 池独立);单账户卡片也显示,作为添加第二个账户的入口; - i18n 键(en/ko/zh):
prov.accounts("Accounts {n}")、prov.accountActive、prov.accountSwitch、prov.accountAdd、prov.accountRemoved、prov.accountSwitched; - 无邮箱的账户(如 cursor)回退显示短账户 id(i18n
prov.accountNoLabel)。
GUI 构建验证:tsc -b干净、vite build 通过、浏览器截图确认分割线消失、下拉可打开并切换 Active 徽标、390px 移动宽度无溢出。
适用边界与诚实限制
multiauth 不是对所有 Provider 一视同仁,开发日志的 D 总结(050_done.md)明确划定了边界:
- 有效多账户 Provider:xai、anthropic、google-antigravity(凭据含稳定身份);
- 保持单账户:kimi、kiro、cursor(刷新令牌轮换、凭据无稳定身份,派生 id 会把同一人拆成多个账户)。Kiro 第二个账户不支持(import-first 流程)。不过 kimi 会从 JWT 提取
user_id/sub作为 accountId、cursor 提取 JWTsub,二者在 multiauth 下可追加带身份的独立账户; - Anthropic 后台账户保活受限:默认 refreshPolicy 为 disabled,第二个账户只存活到刷新令牌自然过期;
- 本单元不含跨账户 429 自动轮换(后续单元,设计草稿:cooldown + Retry-After + 下次请求切换);
- 跨进程存储竞态被接受(单代理假设)。
验证方式与运行说明
开发日志记录的质量门禁(可复现验证):
# 类型检查(根目录 + GUI) bun x tsc --noEmit && cd gui && bun x tsc -b # 全量测试(含 oauth-store-multi、oauth-accounts-api、token-guardian 新断言) bun test ./tests/ # 隐私扫描(确认无密钥泄露) bun run privacy:scan # 运行时冒烟:隔离 OPENCODEX_HOME 启动代理,GET 掩码账户列表、PUT 切换、DELETE 提升存储层测试位于 tests/oauth/oauth-store-multi.test.ts,覆盖:legacy→新形状往返、新身份追加并激活、同身份替换、setActiveAccount 切换、激活账户删除后提升、末位删除清除 Provider、needsReauth 持久化、非法条目丢弃;管理 API 测试位于 tests/oauth/oauth-accounts-api.test.ts。关闭记录(999_closed.md)确认:commit 12aff01 交付多账户 OAuth 存储与 API-key 池支持,最新全量套件 1555 通过 / 0 失败。
使用提示:auth.json位于~/.opencodex/(可通过OPENCODEX_HOME环境变量重定向);GUI 仪表板默认监听 10100 端口;CLI 侧删除账户可用ocx login <provider>重新登录、管理 API DELETE 精确删除指定账户。
【免费下载链接】opencodexUniversal provider proxy for OpenAI Codex & Claude Code — use any LLM (Claude, Gemini, Grok, DeepSeek, Ollama…) with Codex CLI, App, SDK, and Claude Code项目地址: https://gitcode.com/gh_mirrors/ope/opencodex
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考