使用 Web Locks API 修复 Token 刷新竞态
使用 Web Locks API、navigator.locks.request 和令牌重检查,解决标签页间的令牌刷新竞态并防止登出。
当多个标签页共用一个轮换的 refresh token 时,第一个标签页的刷新请求可能会使其他标签页即将发送的 token 失效。这些标签页的刷新随之失败,用户可能会在所有标签页中同时被登出。解决办法是用 navigator.locks.request 包裹刷新逻辑,让恰好一个标签页执行刷新,其余标签页等待,然后复用它存储的 token。
如果你正在追查一份写着”我什么都没做就被登出了”的 bug 报告,而它在你的机器上从不复现,那就问问报告者当时开了多少个标签页。你的拦截器中那个单标签页的刷新队列本身并没有错,但它看不到这个竞态。
本文将梳理导致登出的完整时序、为什么 localStorage 标志位不是一个可靠的修复方案,以及修复方案的三个组成部分:锁、锁内部的重新检查,以及拦截器的接线。
核心要点
- 当多个标签页同时刷新一个轮换的 token 时,只有第一次调用会成功;服务器会使其他每个标签页即将发送的 token 失效,导致用户在所有地方被登出。
navigator.locks.request为每个同源的标签页、iframe 和 worker 提供了一个共享的互斥锁,并且它已达到 Baseline Widely available(广泛可用)状态,因此无需编写回退代码。localStorage标志位不是锁:它没有原子的 compare-and-set,而且由崩溃的标签页写入的标志位会一直卡住,而 Web Lock 则会自动释放。- 等待中的标签页必须在锁的回调内部重新检查已存储的 token,并以 token 自身的过期时间作为判断依据,而不是依据某个”最近刚刷新过”的时间戳。
- 正确实现后,任意数量的标签页遇到过期 token 时,只会产生恰好一次网络刷新。
为什么 refresh token 在多标签页下会失败?
这个竞态需要四个要素:短生命周期的 access token、一个刷新端点、refresh token 轮换,以及多于一个的标签页。这四者都很常见。RFC 9700 即 OAuth 2.0 安全最佳当前实践,为公开客户端(public client)的 refresh token 留下了两种选择:将每个 token 绑定到签发给它的客户端,或者在每次使用时发放一个新的。这使得轮换成为 SPA 的默认姿态。
时序是这样的:用户开着三个标签页,此时 access token 过期。每个标签页的下一个请求都收到 401,每个标签页的拦截器都独立调用刷新端点。标签页 A 的调用最先到达并成功;如果服务器在使用时轮换并使先前的 refresh token 失效,那么标签页 B 和 C 现在发送的就是一个已失效的凭据。它们的刷新失败,它们的错误处理器把失败的刷新视为终局性的认证失败,于是用户在任务进行中被重定向到登录页。
在会话回放中,这个 bug 有一个鲜明的特征:重定向到登录界面之前没有任何用户交互,并且在同一秒内发生在用户打开的每一个标签页中。识别它的依据正是这个特征,而不是控制台里的任何东西。
为什么单标签页状态或 localStorage 标志位无法修复它?
你拦截器中的 isRefreshing 标志位和 promise 队列存在于单个标签页的 JavaScript 内存中。标签页之间不共享内存,所以标签页 B 永远看不到标签页 A 的标志位。跨标签页协调需要一个浏览器级别的原语。
传统的变通做法——在 localStorage 里放一个”刷新进行中”的标志位——并不是锁。Web Storage API 提供了 getItem 和 setItem,但没有原子的 compare-and-set,因此两个标签页可以都读到”没有刷新在进行中”,并在任一写入落地之前都开始刷新。这个标志位在相反方向上也会失效:如果设置它的标签页在刷新过程中崩溃或关闭,标志位会永久保持为已设置状态,而所有存活的标签页都会等待一个永远不会完成的刷新。Web Lock 会在其持有者的文档消失的那一刻由浏览器释放,而这恰恰是让手工实现的标志位卡死的那种故障。
如何用 navigator.locks.request 包裹刷新逻辑?
Web Locks API 为某个源(origin)上的每个标签页、iframe 和 worker 提供了一个共享的互斥锁。使用 navigator.locks.request(name, callback) 时,同一名称的持有者在同一时刻只有一个会运行其回调,并且一旦该回调返回的 promise 敲定(settle),无论是 resolve 还是 reject,浏览器都会立即释放锁。没有需要记着调用的 unlock,fetch 失败时也不会泄漏锁。自 Safari 15.4 在 2022 年 3 月加入支持以来,所有主流引擎都已发布该 API,这也是 MDN 将其评为 Baseline Widely available 的原因,因此不需要做特性检测或写回退分支。
async function refreshTokenAcrossTabs() {
return navigator.locks.request("token-refresh", async () => {
const existing = getUsableToken();
if (existing) return existing; // another tab already refreshed
const res = await fetch("/auth/refresh", {
method: "POST",
credentials: "include",
});
if (!res.ok) throw new Error("refresh_failed");
const { accessToken, expiresAt } = await res.json();
writeToken(accessToken, expiresAt);
return accessToken;
});
}
readToken 和 writeToken 被有意写得抽象:token 存放在哪里是一个独立的安全决策,本文不作定论,而且无论存在哪里,锁的工作方式都是一样的。
重新检查:检验 token,而不是时钟
大多数实现漏掉的一步是锁回调内部的重新检查:等到锁的标签页应当先检验已存储的 token 现在是否有效,如果有效,就直接返回它,而不发起第二次网络调用。三个标签页在锁上排队;第一个完成往返请求,第二个和第三个随后获得锁,发现有可用的 token,立即返回。无论标签页数量多少,只有一次网络刷新。
让这个重新检查以”已存储的 token 是否真的可用”为判断依据,而不是以某个时间戳写入的时间有多近为依据。一个基于挂钟时间的”最近 5 秒内刷新过”的判断在两个方向上都会出问题:比时间窗更慢的刷新会让等待中的标签页错误地断定什么都没发生并发起重复请求;而客户端时钟偏移则会在任一方向上使该比较失效。token 自身的过期时间不会对自己说谎。
const SKEW_MS = 30_000; // tolerate modest clock drift
function getUsableToken() {
const stored = readToken(); // { token, expiresAt } or null
if (!stored) return null;
return stored.expiresAt - SKEW_MS > Date.now() ? stored.token : null;
}
将锁接入 401 拦截器
拦截器的职责没有变化:捕获 401,取得一个新 token,重放原始请求一次。唯一的区别是刷新调用现在变成了 refreshTokenAcrossTabs(),因此串行化的范围覆盖了所有标签页。
api.interceptors.response.use(
(response) => response,
async (error) => {
const original = error.config;
if (error.response?.status !== 401 || original._retry) {
return Promise.reject(error);
}
if (original.url?.includes("/auth/refresh")) {
await logout(); // the refresh itself failed: session is gone
return Promise.reject(error);
}
original._retry = true;
try {
const token = await refreshTokenAcrossTabs();
original.headers.Authorization = `Bearer ${token}`;
return api(original);
} catch (refreshError) {
await logout();
return Promise.reject(refreshError);
}
}
);
_retry 守卫防止循环,而来自刷新端点本身的 401 意味着登出,而不是重试。从一个等待中的标签页的视角看,完整路径是:401,在锁上排队,获得锁,发现一个有效的 token,零网络调用地返回,重放原始请求。
值得了解的边界情况
- Web Locks 需要安全上下文(secure context);
http://localhost符合潜在可信源的条件。 - 规范的终止规则会在文档卸载时释放其持有的锁,因此锁绝不会在重新加载或导航后存活,也绝不是持久化状态。
- 让临界区只包含刷新本身;你在其中 await 的任何其他东西都会阻塞所有标签页。
- 绝不要在某个锁自己的回调内部再请求同一个锁:内层请求会排在外层持有之后,并且会静默地永久挂起。
- 共享模式(shared mode)、
ifAvailable领导者选举以及steal是为其他任务而存在的;token 刷新一个都不需要。 - BroadcastChannel 告诉其他标签页某件事发生了;而锁则阻止它们同时去做这件事。
结论
这个随机登出的 bug 是一场跑在用户机器上的分布式系统竞态,而浏览器已经提供了终结它的互斥锁。用 navigator.locks.request 包裹你的刷新逻辑,把回调的第一行写成一次 token 有效性检查,并让你的 401 处理器走这条路径。先用多个标签页和很短的 token 生命周期复现这个 bug,然后应用锁,看着 N 次刷新调用坍缩为一次。
常见问题
BroadcastChannel 能否替代 Web Locks API 来做跨标签页的 token 刷新?
不能。BroadcastChannel 是一种消息传输机制,而不是互斥锁:它可以宣告刷新已经发生,但无法阻止两个标签页在任一消息到达之前都开始刷新,这与 localStorage 标志位所具有的先读后行竞态是同一个问题。请使用 navigator.locks.request 来串行化刷新,只有在你希望把新 token 推送给正在监听的标签页时,才在此之后加上 BroadcastChannel。
如何为 navigator.locks.request 调用添加超时?
通过 signal 选项传入一个 AbortSignal。在请求仍在队列中等待时中止它,promise 就会以 AbortError 拒绝,因此 AbortSignal.timeout 可以为等待设定一个截止时间。锁被授予之后,该 signal 就不再有任何效果,所以超时无法中断一个已经在运行的回调。将 signal 与 steal 或 ifAvailable 搭配使用会以 NotSupportedError 拒绝,因此每个请求只选一种策略。
Web Locks API 在 web worker 和 service worker 中可用吗?
可用。规范将 LockManager 暴露给 Window 和 Worker 两类上下文,因此 dedicated worker、shared worker 和 service worker 都可以调用 navigator.locks.request。同源上的每个上下文共享同一个锁管理器,这意味着一个请求 'token-refresh' 锁的 worker 会与请求同一名称的标签页一起排队。因此,即使你的部分认证逻辑运行在主线程之外,这个模式依然是正确的。