OAuth 令牌刷新的竞态条件:一个 Bug 引发的连锁反应
GitHub Issue #2036 的标题很无聊:
"OAuth token refresh conflicts with Claude Code CLI"
但评论区已经 200+ 条了。
出了什么问题
场景是这样的:
- 用户同时装了 Claude Code(官方 CLI)和 Moltbot
- 两个程序共享同一个 OAuth refresh token
- 两边同时尝试刷新 token
- 一边成功,另一边的 refresh token 失效
- 失效的那边报错,用户被踢出登录
具体流程:
时间线:
T+0 Moltbot: 检测到 access_token 过期
T+0 Claude Code: 检测到 access_token 过期
T+1 Moltbot: 发送 refresh_token 请求
T+1 Claude Code: 发送 refresh_token 请求
T+2 Anthropic API: 收到 Moltbot 的请求
→ 生成新 access_token (A1)
→ 生成新 refresh_token (R1)
→ 旧 refresh_token 失效
T+2.1 Anthropic API: 收到 Claude Code 的请求
→ 旧 refresh_token 已失效
→ 返回错误: "invalid_grant"
T+3 Claude Code: 刷新失败,提示重新登录
用户: ???
两个客户端"打架"了。
为什么会这样
OAuth 2.0 的 refresh token 有个特性叫 token rotation(令牌轮换)。
每次用 refresh token 换新的 access token,refresh token 本身也会更新。旧的 refresh token 立即失效。
这是安全设计:如果 refresh token 泄露,攻击者只能用一次。
但如果两个合法客户端同时刷新,就会出问题:
┌─────────────┐
│ Anthropic │
│ API │
└──────┬──────┘
│
┌───────────────┼───────────────┐
│ │ │
▼ ▼ ▼
refresh_token_v1 refresh_token_v1 (只有一个能成功)
│ │
┌──────┴──────┐ ┌──────┴──────┐
│ Moltbot │ │ Claude Code │
└─────────────┘ └─────────────┘
先到的请求成功,后到的失败。
因为网络延迟不可预测,哪边失败也不可预测。
用户看到的症状
$ claude-code
Error: Your session has expired. Please log in again.
$ anthropic login
Logging in...
Success!
$ claude-code
# 工作正常
# 5 分钟后,Moltbot 后台刷新 token
$ claude-code
Error: Your session has expired. Please log in again.
# 又要重新登录
反复被踢出,很烦。
有用户说一天要重新登录 10+ 次。
根本原因
问题出在 Moltbot 和 Claude Code 共享了同一个 OAuth 凭证。
Anthropic 的 OAuth 流程:
用户授权 → 获得 authorization_code
authorization_code → 换取 access_token + refresh_token
不管是哪个客户端发起的授权,拿到的都是同一套凭证。
理想情况下,每个客户端应该有自己的凭证:
Moltbot: access_token_m, refresh_token_m
Claude Code: access_token_c, refresh_token_c
互不干扰。
但 Anthropic 的 OAuth 实现是"用户级"的,不是"客户端级"的:
用户 John: access_token, refresh_token
↑ 所有客户端共享
Anthropic 的回应
Issue 里有 Anthropic 员工的回复:
"This is a known limitation of our current OAuth implementation. We're working on device-specific tokens but don't have an ETA."
翻译:我们知道这个问题,在做了,不知道什么时候好。
临时建议:
"As a workaround, you can use separate API keys for different applications instead of OAuth."
用 API key 代替 OAuth。
API key 没有刷新问题,但也没有 OAuth 的好处(用户不需要手动管理密钥、可以随时撤销授权等)。
社区的修复尝试
Moltbot 维护者提了个 PR:
// 加锁,避免同时刷新
class TokenManager {
private refreshLock: Mutex = new Mutex();
async refreshToken(): Promise<string> {
return this.refreshLock.runExclusive(async () => {
// 检查是否真的需要刷新
if (this.tokenStillValid()) {
return this.currentToken;
}
// 刷新
const newToken = await this.doRefresh();
return newToken;
});
}
}
这解决了 Moltbot 自身的并发问题,但解决不了 Moltbot 和 Claude Code 之间的竞态。
另一个思路:错开刷新时间。
async scheduleRefresh() {
const expiry = this.token.expiresAt;
const buffer = 5 * 60 * 1000; // 5 分钟提前量
// 加随机延迟,错开和其他客户端的冲突
const jitter = Math.random() * 60 * 1000; // 0-60秒随机
const refreshTime = expiry - buffer - jitter;
setTimeout(() => this.refreshToken(), refreshTime - Date.now());
}
统计上能减少冲突概率,但没法完全避免。
真正的解决方案
需要 Anthropic 改 OAuth 实现。
方案 1:客户端级 token
每个客户端授权时,生成独立的 token 对:
POST /oauth/authorize
client_id: moltbot
user: john
Response:
access_token: at_moltbot_xxx
refresh_token: rt_moltbot_xxx
---
POST /oauth/authorize
client_id: claude-code
user: john
Response:
access_token: at_claude_xxx # 独立的
refresh_token: rt_claude_xxx # 独立的
方案 2:允许旧 refresh token 短暂有效
refresh token 轮换后,旧 token 在 N 秒内仍然可用:
T+0 refresh_token_v1 有效
T+1 Moltbot 刷新成功,生成 refresh_token_v2
T+1 refresh_token_v1 进入"宽限期"(30秒)
T+2 Claude Code 用 refresh_token_v1 刷新
→ 因为在宽限期内,成功
→ 生成 refresh_token_v3
T+31 refresh_token_v1 彻底失效
Google 的 OAuth 就是这么做的。
方案 3:refresh token 不轮换
有些 OAuth 实现选择不轮换 refresh token:
access_token: 短期(1 小时)
refresh_token: 长期(不变)
安全性稍差,但避免了竞态问题。
用户现在能做什么
选项 1:只用其中一个
# 要么用 Claude Code
anthropic logout
# Moltbot 用 API key
# 要么用 Moltbot
# Claude Code 不用 OAuth
选项 2:用 API key
// ~/.moltbot/config.json
{
"providers": {
"anthropic": {
"authType": "api_key",
"apiKey": "sk-ant-xxx"
}
}
}
# Claude Code 也用 API key
export ANTHROPIC_API_KEY=sk-ant-xxx
claude-code
两边都不用 OAuth,就没有冲突。
选项 3:错开使用时间
听起来傻,但有人真这么干:
"我早上用 Claude Code 写代码,晚上用 Moltbot 处理邮件。两边不同时活跃,就不会冲突。"
更深的问题
这个 bug 暴露了一个更深的问题:AI 工具的互操作性。
现在每个 AI 工具都有自己的授权、自己的 token、自己的 session。
用户同时用多个工具,就会遇到各种冲突:
- OAuth token 竞态(这个 bug)
- API 配额共享冲突
- Session 状态不同步
- 配置文件格式不兼容
没有标准,没有协议,各干各的。
理想状态:
用户 → AI Gateway → 路由到各个工具
↑
统一的认证
统一的配额
统一的 session
现实:
用户 → Claude Code → Anthropic API
│
└─→ Moltbot → Anthropic API
│
└─→ OpenAI API
│
└─→ Cursor → 自己的后端
各自为政,互相打架。
给开发者的建议
如果你在做 AI 工具:
- 支持 API key 和 OAuth 两种方式
{
"auth": {
"type": "api_key", // 或 "oauth"
"apiKey": "..."
}
}
让用户有选择。
- OAuth 刷新要加锁 + 重试
async refreshWithRetry(maxRetries = 3) {
for (let i = 0; i < maxRetries; i++) {
try {
return await this.refreshLock.runExclusive(
() => this.doRefresh()
);
} catch (e) {
if (e.code === 'invalid_grant' && i < maxRetries - 1) {
// 可能是竞态导致的,等一下重试
await sleep(1000 * (i + 1));
continue;
}
throw e;
}
}
}
- 告诉用户可能的冲突
[警告] 检测到其他应用可能在使用同一个 OAuth 凭证。
如果频繁被踢出登录,建议改用 API key 认证。
别让用户自己去 debug。
一个 OAuth 的竞态条件 bug,200+ 条评论,几百个用户受影响。
有人说这是"小问题",有人说这是"设计缺陷"。
无论如何,用户的体验被打断了。
在工具越来越多的 AI 时代,互操作性会成为越来越大的问题。
希望 Anthropic 早点修好。
参考资料
- GitHub Issue #2036: OAuth token refresh conflicts
- OAuth 2.0 RFC 6749
- Google OAuth: Refresh Token Rotation
- Auth0: Token Best Practices