症状
时间线先于代码暴露竞态:同一秒内三条 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;
}
}