OAuth 令牌刷新的竞态条件:一个 Bug 引发的连锁反应

OAuth 令牌刷新的竞态条件:一个 Bug 引发的连锁反应

GitHub Issue #2036 的标题很无聊:

"OAuth token refresh conflicts with Claude Code CLI"

但评论区已经 200+ 条了。

出了什么问题

场景是这样的:

  1. 用户同时装了 Claude Code(官方 CLI)和 Moltbot
  2. 两个程序共享同一个 OAuth refresh token
  3. 两边同时尝试刷新 token
  4. 一边成功,另一边的 refresh token 失效
  5. 失效的那边报错,用户被踢出登录

具体流程:

时间线:

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 工具:

  1. 支持 API key 和 OAuth 两种方式
{
  "auth": {
    "type": "api_key",  // 或 "oauth"
    "apiKey": "..."
  }
}

让用户有选择。

  1. 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;
    }
  }
}
  1. 告诉用户可能的冲突
[警告] 检测到其他应用可能在使用同一个 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
← 返回博客列表