← 中文资源

简体中文版本

OAuth — 并发刷新竞态返回 invalid_grant

修复并发刷新令牌请求互相作废导致的 401 风暴:按会话串行刷新,并优雅处理令牌轮换。

阅读英文原文

症状

  时间线先于代码暴露竞态:同一秒内三条 grant_type=refresh_token,第一条 200(令牌已轮换),随后两条 400 invalid_grant——三个标签页同时到期,第一个刷新成功并轮换了令牌,后来者拿的是刚被撤销的值。用户体验是“高并发下随机登出”,而 provider 完全按轮换语义行事,缺的是客户端协调。客户端用已作废的旧令牌反复重试,只会把局面推深到锁定风险。

诊断思路

  先在时间线上关联同一会话同秒的多次刷新请求,竞态在日志里可见;再读 provider 的错误详情——“token already used / revoked” 的措辞直接指向轮换语义;最后审计刷新调用点,每个能独立刷新的调用方都是一个竞争者,修复是把它收敛为每会话一个协调者。多标签页并发、单例 HTTP 层没有按会话的刷新协调,是两大结构温床。

根因

  刷新令牌轮换下,provider 每次使用都签发新令牌对并撤销所出示的旧令牌。同一会话的并发刷新相互竞速:赢家完成轮换,其余在途请求拿着已撤销的令牌以 invalid_grant 失败,级联成用户可见的 401 风暴。

修复与验证

  按会话做 single-flight 刷新:协调者持有一个在途 Promise,并发 401 恢复全部排队在同一次刷新后面,新令牌传播给所有等待者;跨实例时用共享存储或按会话的分布式锁实现同样的协调。为真正的丢失做规划——轮换发生在签发与持久化之间进程崩溃时,恢复路径是无感重新认证,而不是拿被撤销令牌重试。验证时并发测试同时打 N 个到期请求:恰好执行一次刷新、所有调用方带着新令牌成功、日志零 invalid_grant。

代码示例

09:14:03.112  POST /token grant_type=refresh_token  -> 200 (rotated)
09:14:03.118  POST /token grant_type=refresh_token  -> 400 invalid_grant
09:14:03.121  POST /token grant_type=refresh_token  -> 400 invalid_grant
class TokenCoordinator {
  constructor() { this.pending = null; }

  async getAccessToken(session) {
    if (this.pending) return this.pending;      // join the in-flight refresh
    this.pending = this.doRefresh(session)
      .finally(() => { this.pending = null; });
    return this.pending;
  }

  async doRefresh(session) {
    const tokens = await store.load(session);
    const res = await tokenEndpoint({
      grant_type: 'refresh_token',
      refresh_token: tokens.refreshToken,   // current value, once
    });
    await store.save(session, res.data);   // rotated pair persisted
    return res.data.access_token;
  }
}