Skip to content

认证与凭证刷新

本页对应学习路径的阶段七。它排在所有能力的最后,因为它是唯一一个状态多到需要 先画图的模块——但没有人是一次画出那张图的。本页按它真实的生长顺序走:从十行的 直觉拦截器开始,每暴露一个坑,多长一个状态;走到「复盘」一节,图就自己画完了。

全文回答这些问题,前六步长出状态机,之后把它接进真实项目:

#回答的问题引入的东西
110 个并发 401 怎么只刷一次refreshPromise 单飞
2迟到的 401 为什么不配再刷一轮credentialVersion 凭证代际
3重放还是 401 怎么不死循环__authRetry 一次性重放
4刷新请求自己 401 怎么不递归独立实例 + __authManaged 盖章
5刷新接口挂了怎么办,谁判「凭证已死」熔断冷却 + shouldExpireSession
6会话终结怎么只宣告一次expireOnce
7这套东西怎么装进项目AuthSession → 适配器 → createHttpClient({ auth })
8登录、登出撞上在途刷新怎么办sessionEpoch + runAuthTransition()
9令牌存哪,Cookie 方案的安全前提内存 + HttpOnly Cookie
10多标签页下这套封装会怎样轮换、宽限窗口、会话同步
11换一种认证方案要改多少四动作适配器契约

场景

页面同时发了 10 个请求,令牌恰好在这一刻过期,10 个请求全部收到 401。

先写最直觉的那版

ts
// 幼稚版
axiosInstance.interceptors.response.use(null, async (error) => {
  if (error.response?.status === 401) {
    await refresh(); // ← 10 个请求各调一次
    return axiosInstance(error.config);
  }
  return Promise.reject(error);
});

它塌在哪

  1. 打 10 次刷新接口,拿回 10 个新令牌,后面的把前面的挤掉——用户随机掉线。
  2. 重放还是 401 就无限递归,浏览器标签页卡死。
  3. 刷新请求自己收到 401 时,也会走进这个拦截器,再触发一次刷新。
  4. 刷新接口挂了的时候,每个请求都去捅它一下。
  5. 10 个请求各弹一次「登录已过期」

第一步:单飞——10 个 401 只换一次刷新

先修最贵的坑 ①。直觉的修法是布尔锁加队列:isRefreshing 置起来之后,后来的请求进 一个数组排队,刷新完了再挨个放行。能跑,但锁、队列、放行三样东西要自己维护、自己 保证不漏;而 Promise 天生就是「一件事 + 一群等它的人」,三样合一:

ts
let refreshPromise: Promise<void> | undefined;

function refreshOnce() {
  // 已经有人在刷:不发起第二次,所有人共享同一个进行中的 Promise
  refreshPromise ??= doRefresh().finally(() => {
    refreshPromise = undefined;
  });
  return refreshPromise;
}

10 个请求都 await refreshOnce(),真正打刷新接口的只有第一个——「队列」就是挂在 同一个 Promise 上的九个 await,「放行」就是它落定。这正是源码 http/auth.tsrefreshOnce 的骨架(它还多做几件事,后面几步逐个长出来)。流程收敛成:

text
10 个业务请求返回 401

   共享 refreshPromise

      1 次刷新

 10 个请求分别重放一次

共享的是刷新过程,不是业务请求结果。每个业务请求仍然有自己的 Promise、取消状态、 尝试次数和最终结果。401 重放也是「一次逻辑请求产生多次物理尝试」的第二个来源 (第一个是重试)。

单飞挡住的是同时撞 401 的请求。还有一种 401 它管不着:迟到的

第二步:凭证代际——迟到的 401 不配再刷一轮

刷新刚成功,一个发得早、回得慢的请求才带着 401 回来。它被拒理所应当——用的是旧 令牌;但此刻 refreshPromise 已经清空,按第一步的逻辑它会发起一轮全新的刷新。接着 下一个慢请求又到……10 个并发就算有单飞,也能这样串行地刷上 10 轮

拒绝它的理由应该是「你手里已经有新令牌了」。直觉的判法是对比令牌字符串——这个 401 的请求带的 token 不等于当前 token,就是旧的。能用,但令牌字符串得一路传进比较逻辑。 计数器更干净,凭证每变一次加一:

ts
let credentialVersion = 0; // 刷新成功、登录、采纳别的标签页的会话,都 +1

// 请求拦截器:发出时盖上当时的代数
config.__credentialVersion = credentialVersion;

// 响应拦截器收到 401 时:
if (requestVersion >= credentialVersion) {
  await refreshOnce(requestVersion); // 我用的就是当前代凭证,刷新才有意义
}
// 版本已落后则跳过刷新:别的请求刚刷新过,直接拿新令牌重放,一次往返都不多花

于是「401」被拆成了两种:我的令牌旧了(版本落后,直接重放)和当前令牌不行 (版本一致,去刷新)。还有第三种——刷新完重放,还是 401。

第三步:重放只有一次机会

坑 ②:拿新令牌重放,结果仍是 401。新令牌都不被认可,说明问题不是「令牌旧了」,是 这个用户真的没有这个权限——再刷一次不会有不同的答案,而直觉版会在 401 和刷新 之间无限循环,标签页就此卡死。

「重放过没有」是单个请求的历史,不是全局状态,所以标记盖在请求 config 上:

ts
if (config.__authRetry) {
  // 重放过还是 401:宣告会话终结(怎么保证只宣告一次,见第六步)
  expireOnce(requestVersion);
  return Promise.reject(error);
}

config.__authRetry = true;
return axiosInstance(config); // 整个 config 重新灌回实例,拦截器链会给它换上新令牌

第四步:刷新请求不能走进自己的拦截器

坑 ③ 有两层。第一层好防:刷新走一个独立的裸 Axios 实例http/adapters/auth.ts 里的 refreshClient),它身上没有业务拦截器,自己收到 401 时不会再触发一次刷新。

第二层藏得深:独立实例并没有让两条链路自动隔离——

text
业务请求 → 请求拦截器 await refreshPromise
                    ↓ 刷新失败
        业务实例的响应错误拦截器收到刷新的 AxiosError
                    ↓ config.url = "/auth/refresh", status = 401
        误判成业务 401 → 重放刷新请求 → 其响应成为业务请求的结果

所以请求拦截器要盖章 __authManaged = true,响应拦截器把没盖章的错误原样放行。 判断「这个错误是不是我该处理的」时,状态码和 URL 都不够,必须确认它来自本实例

第五步:刷新失败——熔断、冷却,以及谁来判「凭证已死」

坑 ④:刷新接口挂了。此刻每个撞 401 的请求都想去捅它一下,而它答不上来。

先分类。刷新失败不是一种失败,是两种,处置完全不同:

text
刷新端点回 401    Refresh Token 本身失效,凭证死了
                  → expireSession():清会话、跳登录(每代只触发一次)

网络错 / 超时 / 5xx   端点此刻答不上来,凭证多半还活着
                  → 只记入熔断缓存,会话原样保留

直觉的写法是不分:刷新一失败就踢登录。这会把一次抖动放大成一次强制登出——凭证 仍然有效、服务端一秒后就恢复了,用户却已经站在登录页上。分辨两者依赖一条明确的 后端契约:本项目约定刷新端点用且仅用 401 表示 Refresh Token 失效(D-65),上面那 张两分支图才成立。这是项目约定而非通用规律——OAuth2 后端(RFC 6749 §5.2)就用 400 + invalid_grant 表达同一件事,照搬 401 判定的话,真正的凭证死亡会被当成 「暂时答不上来」而永远困在熔断冷却里。所以「哪种失败终结会话」的判定不进引擎, 放在适配器的 shouldExpireSession(error) 里,引擎不内置任何状态码假设。

「暂时答不上来」的那支怎么办?记入失败缓存,冷却窗口内不再打端点:

ts
if (failedVersion === version) {
  if (Date.now() - failedAt < refreshCooldownMs) {
    return Promise.reject(failedError); // 熔断打开:复用上次的失败,不打端点
  }
  failedVersion = undefined; // 冷却结束,清掉缓存放行一次新的尝试
}

熔断缓存对两种失败都生效:冷却窗口内的后续 401 直接复用缓存的失败,不再打刷新 端点。没有冷却,抖动会被记到页面关闭为止——每个后续请求都只拿到缓存里的旧 错误,用户除了刷新页面无路可走。有了冷却,窗口结束后放行一次新的尝试,端点恢复 则静默自愈:全程会话未清、没有惊动用户。它是熔断,不是终身锁定。

第六步:会话终结只宣告一次

坑 ⑤ 是体验坑。「会话没救了」有两个出口——第三步的重放仍 401、第五步的凭证判死。 10 个并发请求会先后走到出口,每个都清一次会话、跳一次登录页、弹一次「登录已过期」。

终结和刷新一样要去重,但它拦的不是并发,是同一代凭证的重复宣告

ts
let expiredVersion: number | undefined;

function expireOnce(version: number) {
  if (expiredVersion === version) return; // 这一代已经宣告过
  expiredVersion = version;
  adapter.expireSession(); // 清会话、跳登录——具体动作由适配器决定(见「装配」)
}

按代去重而不是全局一次性布尔,是给「终结之后再登录」留路:新会话是新的一代,它 自己的终结将来仍然要能触发一次。

复盘:五组状态各挡一个坑

到这里,幼稚版的五个塌方各有一个状态在挡:

text
refreshPromise           单飞。已经在刷了就复用同一个 Promise      → 挡 ①(第一步)
credentialVersion        凭证代际:迟到的 401 不再多刷一轮          →(第二步)
__authRetry              已经重放过一次就不再刷新                   → 挡 ②(第三步)
__authManaged            只处理本实例盖过章的请求                   → 挡 ③(第四步)
failedVersion/Error/At   熔断 + 冷却窗口                            → 挡 ④(第五步)
expiredVersion           expireSession() 每代只触发一次             → 挡 ⑤(第六步)
sessionEpoch             会话代际:跨过会话边界(登录、登出)时推进 →(见下文)
activeTransitions        边界闸:登录、登出执行期间不许新刷新启程   →(见下文)

这张表就是 http/auth.ts 文件头那张「需要先画」的图。它不是设计出来的,是被五个坑 逼出来的——每一行都指回一个具体的塌方。表里多出的最后两行是仅剩没讲的状态:它们 要等这套东西装进真实项目、开始处理登录登出之后才会暴露,所以先把装配讲完。

装配:从 AuthSession 到 createHttpClient({ auth })

前面的代码全住在通用引擎 http/auth.ts 里,但引擎从头到尾没出现过「token 存在哪」 「刷新接口是哪个」——这些是项目决定。装配链三级,逐级注入:

text
AuthSession(会话怎么存 —— session.ts)
   ↓ 注入
createBearerAuthAdapter(本项目的认证方案 —— http/adapters/auth.ts)
   ↓ 作为 auth 选项
createHttpClient({ auth })(引擎在内部 installAuth —— http/client.ts)

第一级,会话对象。四个约定,UI 和请求层共用这一份状态:

ts
// src/api/session.ts
export interface AuthSession {
  getAccessToken(): string | null;
  setAccessToken(token: string): void;
  clearSession(): void; // 会话作废时清状态
  onExpired(): void; // 清完之后的动作(跳登录页)。expireOnce 保证它每代只响一次
}

const session = createMemoryAuthSession({
  onExpired: () => router.push("/login"),
});

第二级,适配器把这份会话翻译成引擎要的四个动作——带凭证、刷新凭证、判定终结、 作废会话(完整契约表在「换方案」一节):

ts
// 装配处。test/http-client.test.ts 开头的 createTestAuth 就是这个形状的现成模板
const auth = createBearerAuthAdapter({
  baseURL: "/api",
  getAccessToken: session.getAccessToken,
  setAccessToken: session.setAccessToken,
  expireSession() {
    session.clearSession();
    session.onExpired();
  },
  // 从刷新响应里挑出新令牌。格式不对就抛——那属于「这次刷新失败」。
  // 源码版走 readApiEnvelope 严格校验,见 createTestAuth。
  selectAccessToken(response) {
    const body = response.data as { data?: { accessToken?: string } };
    const accessToken = body?.data?.accessToken;
    if (!accessToken) throw new Error("刷新响应里没有 accessToken");
    return accessToken;
  },
});

第三级,交给客户端工厂。引擎自动挂上请求/响应两个拦截器,业务代码对刷新零感知:

ts
const http = createHttpClient({ baseURL: "/api", auth });

// 登录、注册这类「本来就没有令牌」的接口跳过认证,它们的 401 不触发刷新:
await http.post("/auth/login", payload, { skipAuth: true });

分工从此清晰:引擎管时机(什么时候刷、结果要不要采纳),适配器管方案(凭证 怎么带、找谁刷)。「换一种认证方案要改多少」的答案已经写进这个结构里——换适配器, 引擎一行不动;这条线在「换方案」一节展开。

会话代际:登录、登出撞上在途刷新

装配完成,应用开始处理登录——复盘表里剩下的最后一个状态在这里暴露。登录成功后的 写入是两步:

ts
session.setAccessToken(result.accessToken);
http.resetAuthState(); // 顺序不能反

resetAuthState() 做的是「翻过这一页」而不是「清理干净」——所有代际往前推一格, 于是上一会话在途的刷新回来时会发现自己已经过时,自动作废:

  • 它成功了也丢弃——拿回来的是旧会话的令牌,提交了会覆盖用户刚登录的新凭证。 为此适配器的 refreshCredential取回凭证、返回一个提交函数,写入由状态机 确认代际未变后执行;若适配器自己落盘,等代际检查跑到时新凭证已经被盖掉,检查 只能追认损失。
  • 它失败了也不调 expireSession()——否则会把刚登录的会话立刻清掉。
  • 已经在等它的请求忽略这个失败,改用新凭证继续。

自动刷新成功时模块已自行推进版本,不需要调这个方法。它用于登录、重新登录、切换 账号、登出这些会话边界。登出最容易被漏掉:只清会话不推代际,在途的旧刷新 回来一提交,已终结的会话就地复活——而那个令牌是服务端真实签发的,复活之后一切 请求照常成功,没有任何报错提醒你。

代际作废的只是内存侧的提交。轮换制下刷新响应还带着 Set-Cookie——那是浏览器 在响应到达的一刻直接写入的,JS 没有任何拦截点。于是边界动作即使推了代际,晚到的旧 刷新响应仍会把它的 Cookie 盖在边界动作刚写的那份上面。危害分两级,由后端的登出语义 决定:

  • 后端登出只吊销单条凭证——被盖回去的旧凭证还活着,已登出的会话在 Cookie 层 复活,下一个 401 一刷新就整个回来了;
  • 后端登出按链/家族吊销(admin-backend-3 的 revokeRefreshSession 内部就是整族 撤销)——盖回去的是死凭证,会话不会复活,但新登录的第一次刷新会撞上它,被一次 不该发生的登出打断。

所以这个口子要两侧配对才算收上。服务端一侧,登出必须按家族吊销(这同时是轮换 方案自身的安全底线)。前端一侧,直觉的修法是边界动作发出前排空在途刷新——引擎 提供了 waitForRefreshSettled(),等在途刷新落定(成败都算)再放行边界动作。

但排空只清得掉已经在途的刷新。它返回之后、登录响应回来之前还隔着一整个网络 往返,这期间任何旧请求撞上 401 都会起一个新的刷新——它的 Set-Cookie 照样晚到、 照样回盖。挡存量挡不住增量,唯一的办法是把整段边界动作关进一个闸里:

ts
// ①上闸:新 401 只排队不再起刷新 → ②排空在途 → ③执行边界动作 → ④放闸
await http.runAuthTransition(async () => {
  const result = await loginApi(payload); // 此刻没有任何认证响应在途或能启程
  session.setAccessToken(result.accessToken);
  http.resetAuthState(); // 凭证写入也在闸内:响应到达与写入落地之间没有缝
  return result;
});

复盘表最后那行 activeTransitions 就是这道闸的计数器。闸内排队的 401 在放闸后先 复查凭证代际:登录已经换代就直接拿新令牌重放,不再刷新;代际没动(比如登录失败) 才继续走正常刷新。两个细节:边界请求本身必须是 skipAuth 的(装配一节本来就这么 发),否则它撞上 401 会排进闸里等自己;排空原语 waitForRefreshSettled() 仍然公开, 但它只是闸的第②步,单独使用挡得住存量、挡不住增量。

令牌存哪

text
Access Token   内存(会话对象里)
Refresh Token  HttpOnly Cookie,前端读不到

Access Token 不写 localStorage/sessionStorage:那样任何 XSS 都能直接读走它,而 内存中的令牌随页面卸载消失。

会话对象放在 session.ts 而不是 http/ 下——会话怎么存是项目状态,真实项目会 换成 Pinia / Zustand / Redux 切片。HTTP 模块只通过 AuthAdapter 读写它。

withCredentials: true 只开在刷新实例上,跨域 Cookie 的暴露面被压到一个接口。

把 Refresh Token 放进 Cookie,等于把「带凭证」交给浏览器自动完成——反过来说, 任何页面向刷新端点发请求,浏览器都可能替它把 Cookie 带上(CSRF)。所以这个 方案不是装上就安全,它把四件事变成后端的硬前提,采用前逐条确认:

  • HttpOnly + Secure:JS 读不到、明文信道不发送——本节的 XSS 立场靠它们成立;
  • SameSite=Strict(至少 Lax):跨站页面发起的请求不带这枚 Cookie,堵掉 CSRF 的主通道,代价是前后端必须同站(子域可以,这是很多项目选子域拆分而不是跨域的 真实原因);
  • Path 锁到认证路由:业务接口拿不到这枚 Cookie,暴露面只剩认证端点本身;
  • Origin 校验SameSite 是浏览器行为,旧浏览器与非浏览器客户端不受约束, 服务端仍要对登录/刷新/登出校验 Origin 白名单。校验失败只拒绝、不动 CookieOrigin 是攻击者可控的请求头,若按它下发清除性的 Set-Cookie,任意跨站页面都 能借合法站点之口强制登出受害者——清 Cookie 只属于通过校验的正常登出。

admin-backend-3 的对应实现:Cookie 属性集中在后端 auth-cookies.tsHttpOnly + SameSite=Strict + Secure + Path=/admin/api/auth),三个认证端点入口统一过 isTrustedBrowserOrigin。四条全在服务端,前端能做的只有上面那个最小暴露面。

AuthSession 的 Pinia 实现

createMemoryAuthSession() 可以直接投产,它唯一的局限是不响应式:导航栏显示 用户名、路由守卫判断登录态,UI 要的是能 watch 的状态。所以换 Pinia 的动机是响应式, 不是给 token 换个存储位置。用 store 实现同一个 AuthSession,通用模块零改动:

ts
// stores/session.ts —— UI 直接消费 store 的响应式状态
export const useSessionStore = defineStore("session", () => {
  const accessToken = ref<string | null>(null);
  const isAuthenticated = computed(() => accessToken.value !== null);
  return { accessToken, isAuthenticated };
});
ts
// 应用入口的装配处 —— store 适配成 AuthSession 的四个约定
const store = useSessionStore();

const session: AuthSession = {
  getAccessToken: () => store.accessToken,
  setAccessToken: (token) => {
    store.accessToken = token;
  },
  clearSession: () => {
    store.accessToken = null;
  },
  onExpired: () => {
    router.push("/login");
  },
};

接线去向和内存版完全一样:getAccessToken/setAccessToken 直传给 createBearerAuthAdapterclearSession + onExpired 合成它的 expireSession—— test/http-client.test.ts 开头的 createTestAuth 就是现成模板。两个配置要点:

  • 不装持久化插件。 持久化插件会把 token 写进 localStorage,本节开头的安全立场 就被一个插件改掉。刷新页面后的会话不靠持久化恢复:启动时用 HttpOnly Cookie 调 一次刷新接口,成功即有会话,失败即未登录。
  • 装配晚于 app.use(pinia) useSessionStore() 要求 Pinia 实例已激活,所以 「创建带 auth 的 http」要放进应用入口的装配流程,不能像内存版那样在模块顶层执行。

真实项目 admin-backend-3 用的是更严格的三层版本:token 的唯一权威(SSOT)是 api/session.ts 里的模块级内存变量,连 Pinia 都不放;Pinia store 只订阅它的变更、 给 UI 做响应式镜像;请求层依赖注入的会话接口,从不 import Pinia:

text
api/http/*        无状态请求套件,只认注入的会话接口     对应本工程 http/
api/session.ts    内存 SSOT,真正持有 token              对应本工程 session.ts
stores/auth.ts    Pinia 响应式镜像 + 路由联动            本工程未含(UI 层)

多拆这层的收益是换 UI 框架时会话逻辑原地不动,代价是多一份订阅同步代码;中小项目 用上面的 Pinia 版本就够。该项目在 2026-07 的请求层换代(其 ADR-0004)中已整体换用 本工程的引擎,会话层保持这个三层结构,额外只接了一座跨标签页会话同步桥——就是 下一节末尾那个旁挂模块。

多标签页:单飞管不到的并发刷新

前面六组状态全是单个标签页内的内存变量,这引出一个部署前必须想清楚的问题: 这套封装拿到多标签页场景下用,会发生什么?

先划边界。前面六组状态全是模块级内存变量,作用域是一个标签页。用户开两个 标签页,就有两套互相看不见的状态机——单飞、熔断、代际,在隔壁标签页眼里都不存在。 大多数时候这没有问题:两边各自持有内存里的 access token,各刷各的。真正共享的只有 一样东西——Refresh Cookie。麻烦恰恰从这份共享开始。

先理解轮换:刷新接口为什么是一次性的

主流后端的刷新接口是轮换(rotation)式的,本工程对接的契约也是:每次刷新,旧 Refresh Token 立刻作废、发一个新的。把续期凭证变成一次性的,后端就免费获得一个 能力——泄露检测

text
攻击者偷到 Refresh Token R1
攻击者用 R1 刷新成功 → 拿到 R2,R1 作废
真用户随后用 R1 刷新 → 后端发现 R1 被用了第二次
                      = 此刻持有 R1 的有两方,必有一方是贼
                      → 撤销这条会话链的全部凭证(token family,令牌家族)
                      → 双方一起下线:真用户重新登录夺回会话,攻击者出局

「同一个一次性凭证出现第二次」是无法伪造的泄露信号。这正是 OAuth2 生态(Auth0、 Keycloak……)把 Refresh Token Rotation 当默认实践的原因——重放必须被惩罚,机制 才成立

两个标签页,在后端眼里就是「贼 + 真用户」

现在把两个无辜的标签页放回这个机制里。它们共享同一份 Cookie;令牌又是同时签发的, 于是同时过期。如果两边恰好都在这时发请求:各自撞 401,各自发起刷新,两个刷新 请求带的是同一个 R1。后端处理完先到的那个,R1 作废;后到的那个,和上面时间线里 「真用户的重放」一模一样——后端没有任何办法区分「贼 + 真用户」和「两个标签页」。 按机制处理就是撤销家族:两个标签页一起被踢到登录页

什么条件下会真的撞上?需要「同时过期」加「在一次刷新往返(几十到几百毫秒)内并发 发出刷新」,典型场景按概率排:

  • 浏览器重启恢复上次会话——一次打开 N 个标签页,每个内存里都没有 token,全都 拿同一份 Cookie 去恢复会话。最容易复现的一撞。
  • 标签页挂后台 + 自动请求——轮询定时器、窗口回焦自动重拉数据。用户离开超过 令牌寿命再回来,几个标签页几乎同时撞 401。
  • 手动操作反而不容易撞:先在 A 页点一下、再切到 B 页点一下,只要间隔超过一次 刷新往返,A 已经把新 Cookie 写回浏览器,B 带的就是新凭证——人手的速度天然把 刷新串行化了。
  • 还有一个不需要第二个标签页的变体:刷新请求发出、后端已完成轮换,响应在网络上 丢了。客户端超时重试带的还是旧 token,同样构成重放。这个变体值得记住,下面 马上用到。

解法在哪一侧:宽限窗口 vs 前端互斥

后端侧的标准解叫轮换宽限窗口(reuse interval / rotation grace period):旧凭证 在被轮换后的几十秒内重放,视为「响应丢失重试」而不是攻击。admin-backend-3 的宽限 是 60 秒,行为上有三个精确点,每个都容易想错:

  • 它不是延长旧 token 的寿命。 旧凭证仍然在轮换那一刻作废,宽限只是对「作废后 紧接着的重放」网开一面——超过窗口的重放照常撤销家族,退出登录之后的重放在窗口 内也不会复活会话。
  • 后来者拿到的不是先到者那份新凭证,而是一份补发的。 后端给宽限内的重放者 新建一条兄弟会话(sibling),两个标签页从此各持一条凭证链、各自独立轮换,互不 再干扰。
  • 安全性没有实质让步。 检测窗口只是收窄了几十秒;攻击者事后(从日志、备份里) 拿到旧凭证再重放,早已出窗,照样触发家族撤销。

前端侧的解是跨标签页互斥:Web Locks 保证同一浏览器内同时只有一个标签页在刷新, 其余标签页通过 BroadcastChannel 等它的结果。admin-backend-3 曾经两侧都做——它的 前端代码里写着一句注释:「服务端轮换宽限期是这里的最后一道兜底。」后来的请求层 换代(其 ADR-0004)把前端互斥整层删掉、只留后端宽限,多标签页照常工作——「兜底」 被实践验证为真正的防线。前端互斥本来就管不住上面那个单标签页变体(丢响应重试时, 锁一直在自己手里),也管不住另一台设备;真正决定正确性的始终是后端那一侧。

本工程为什么不把前端互斥做进来

三个原因,按分量排:

  1. 它不提供独立的正确性。 装了它,丢响应重试的重放照样发生;后端有宽限,不装 它也几乎无感。多标签页能不能安全用,从头到尾由后端策略决定,前端互斥只是减少 触发宽限的次数。
  2. 它不是旁挂模块。 互斥要包住整个刷新流程;拿到锁之后要先复查凭证是否已被 隔壁标签页刷新过——这些判断必须长在单飞状态机内部(admin-backend-3 换代前的 会话协调器就是这么写的),等价于重写 refreshOnce,而不是新增一个文件。
  3. 它绑定纯浏览器原语。 Web Locks 至今没有 Node 实现,进来就要么到处 mock, 要么升级成多页面浏览器测试。(BroadcastChannel 不在此列——它自 Node 15.4 起 是内建原语,这正是下面那个同步模块能全量 Node 测试的原因。)

所以采用本封装前,向后端确认一句话:「刷新轮换有没有并发宽限窗口(reuse interval)?」这一问不是走过场,因为宽限不是默认配置:OAuth2 安全最佳实践 (RFC 9700)对轮换后旧凭证重放的推荐处置就是撤销整条家族,宽限是各实现自选的缓和 项——托管方案里也一样,比如 Auth0 的 reuse interval 默认为 0、要显式开启。答案是 有,多标签页就可以放心用;没有、且用户确实会开多标签页,再在项目侧补前端互斥 (重写单飞状态机;admin-backend-3 换代前的会话协调器可在其 git 历史里找到参照)。 那是一个明确的扩展点,不是本工程的缺口。

互斥不进,同步进:旁挂的会话同步模块

到这里多标签页的刷新并发已经收口:正确性归后端宽限窗口,前端互斥不进源码。 但多标签页还剩一类问题,和「谁去刷新」无关——会话事实的传播

  • A 标签页登出了,B 标签页内存里还留着 access token,继续以已吊销的会话工作, 直到撞上下一个 401;
  • A 标签页刷新拿到新令牌,轮换制下 B 的旧令牌已作废,B 只能自己再撞 401、再刷 一轮——「每个标签页各自刷新」正是反复触发宽限窗口的来源。

这类问题不需要互斥,只需要广播:把「会话变了」这个既成事实告诉所有标签页。 src/api/session-sync.ts 只做这一件事(D-67):

ts
const sync = createSessionSync<Session>("my-app-auth", {
  onSessionUpdated(session) {
    /* 写入会话存储 + resetAuthState() 开新代际 */
  },
  onSessionEnded() {
    /* 清空会话存储 + resetAuthState() 开新代际 */
  },
});
// 本地登录 / 刷新成功 / 登出时反向广播:
sync.publishSessionUpdated(session);
sync.publishSessionEnded();

它能进源码而互斥不能,分界就一条:是否介入刷新决策。同步不参与「什么时候 刷新」,只搬运结果;模块对 http/ 零依赖,handlers 由项目侧接线——admin-backend-3 的桥接层就是现成样板:收到更新写入自己的会话存储并 resetAuthState(),收到终结 清空存储并同样开新代际(终结也是会话边界),采纳期间抑制回声广播。

模块内唯一有分量的逻辑是事件屏障:BroadcastChannel 不保证多标签页事件的全局 顺序,「登出之后才到达的过期更新」会复活已终结的会话。每个事件盖单调递增的时间戳 (max(Date.now(), 上一事件 + 1)),只接受比已见事件新的,旧事实就盖不掉新事实。 时间戳打平(两个标签页同一毫秒各自发事件)是真并发,谁先谁后没有事实答案,屏障 退而求确定性:按(时间戳, 类型, 来源)全序裁决,终结压过更新——宁可多登出一 次,不可复活已终结的会话。少了这条裁决,结局取决于各标签页的到达顺序,同一对冲 突事件会让一部分标签页登出、另一部分留着会话。

接上它还有一个顺带的收益:刷新成功的新令牌会随 session-updated 传给所有标签页, 其余标签页直接采纳、不再各自刷新,宽限窗口从常规路径退回真正的兜底。

换一种认证方案会怎样:格式、架构与插件接缝

到这里为止讲的都是「这套方案怎么工作」。最后回答一个换后端时必然遇到的问题—— 后端同学说:「我们用的是 JWT,不是你们这套双 token。」前端封装要改多少?

答案取决于「JWT」这个词在指什么。它经常同时承载两个概念,先拆开:

概念说的是什么取值举例
格式令牌字符串本身怎么编码(RFC 7519:三段 Base64、带签名、payload 可含过期时间 expJWT / 不透明随机串
架构有几个凭证、怎么续期单 token 过期重登 / Access + Refresh 双 token

两者正交:双 token 架构里的 access token 完全可以是 JWT 格式——而且这是主流生产 形态。可以自查:找任何一个 OAuth2/OIDC 后端(Auth0、Keycloak、Cognito……),登录 响应里 access_tokenrefresh_token 同时存在,把 access_token 粘到 jwt.io 能解开。双 token 和 JWT 在同一个响应里同时成立,因为它们不在同一个维度上。

教程里的「JWT 单 token 方案」(一个无状态 JWT 替代服务端会话)真实存在,但它是这个 领域的「幼稚版」:服务端不存状态,签出去的令牌到期前无法吊销,于是只能选长寿命 (被偷就完)或短寿命(频繁重登)。生产为解决这个矛盾收敛出来的形态,恰恰是「短寿命 JWT + Refresh Token 续期」——绕回了双 token。

所以最常见的情况是:后端说「我们用 JWT」,指的只是令牌格式 → 零改动。 本封装从头到尾不解码令牌——没有一处 decode,没有 jwt-decode 依赖,Bearer ${token} 里的字符串长什么样它不知道也不关心。响应信封适配器把信封 code 当 元数据、不拿它判定成败,这里不解码令牌、不拿 payload 做判定——同一个决定的两次 出现:不消费的信息不解析,换格式才能不改代码。

认证空间的两条轴

真正需要动手的是架构变了。整个空间用两条正交轴就能描述:

取值当前实现
有没有续期凭证无(过期重登) / 有(Refresh Token)有(HttpOnly Cookie)
刷新何时触发被动(收到 401 后) / 主动(已知过期时间,提前续)被动

令牌格式不在任何一轴上。

顺带澄清一个常见误解:无感刷新 ≠ 主动刷新。「无感」指调用方无感——本页前面那套 「401 → 单飞刷新 → 重放」就已经是无感刷新,原请求的 Promise 从头到尾没断过,只是慢 了一拍。主动刷新(利用已知过期时间提前续)省掉的只是那一次注定失败的 401 往返, 它是无感刷新的优化,不是前提。而且过期时间未必来自解析 JWT:OAuth2 标准本来就在 令牌响应体里给 expires_in 字段,所以连主动刷新都不绑定 JWT 格式。

换方案 = 换插件

方案没有写死在状态机里。auth.ts 只依赖四个动作,这就是插件契约:

契约方法回答的问题当前实现(Bearer + Cookie)
applyCredential(config)凭证怎么带上请求Authorization: Bearer <内存里的 token>
refreshCredential()401 之后怎么续期调刷新接口(自动带 Refresh Cookie),返回提交函数
shouldExpireSession(err)哪种失败算凭证已死刷新端点回 401(本项目契约,D-65)
expireSession()续不动了怎么办清会话、跳登录页

单飞、熔断冷却、会话代际、重放去重——全在状态机里,任何插件免费继承

实例:后端真的是单 token JWT

先向后端确认一件事:**过期之后是直接重新登录,还是有「拿旧 token 换新 token」的 续期接口?**两种答案对应两个变体。

变体 (a):过期即重登。

ts
// adapters/jwt-auth.ts —— 整个适配就这一个新文件
export function createJwtAuthAdapter(options: {
  getAccessToken(): string | null;
  expireSession(): void;
}): AuthAdapter {
  return {
    applyCredential(config) {
      const token = options.getAccessToken();
      if (token) config.headers.set("Authorization", `Bearer ${token}`);
      else config.headers.delete("Authorization");
    },
    // 后端没有续期接口:刷新即失败,状态机会接住它走 expireOnce
    async refreshCredential(): Promise<() => void> {
      throw new Error("Single-token scheme has no refresh endpoint");
    },
    // 没有任何续期途径,所以每一种刷新失败都意味着「续不动了」
    shouldExpireSession: () => true,
    expireSession: options.expireSession,
  };
}

「没有刷新还装认证模块干什么?」——装的不是刷新,是它周边的保障:

  • 10 个并发 401 → refreshCredential 只被调一次(单飞),expireOnce 保证登录页 只跳一次,不是弹 10 次;
  • 失败进入冷却缓存,后续 401 不再反复捅一个不存在的接口;
  • 错误被标记「认证已处理」,全局 Toast 不会和跳登录叠在一起。

变体 (b):旧 token 换新 token(滑动过期)。

在 (a) 的基础上把 refreshCredential 换成真的;工厂参数相应多收三项——写回新 token 的 setAccessToken、从续期响应里挑出新 token 的 selectAccessToken、可选的 renewUrl

ts
// 续期走独立裸实例——它身上没有业务拦截器,自己收到 401 不会再触发一次续期。
// 注意没有 withCredentials:本方案没有 Cookie,凭证就是旧 token 本身,放 header 里。
const renewClient = axios.create({ baseURL, timeout: 10_000 });

async refreshCredential() {
  const current = options.getAccessToken();
  if (!current) throw new Error("No token to renew");
  const response = await renewClient.post(options.renewUrl ?? "/auth/renew", null, {
    headers: { Authorization: `Bearer ${current}` },
  });
  const next = options.selectAccessToken(response);
  // 只取回不落盘:写入由状态机确认会话代际未变后执行
  return () => options.setAccessToken(next);
}

对照前面「令牌存哪」的 Cookie 方案,差异只有两点:凭证从 Cookie 变成旧 token 进 header;不再需要 withCredentials

但 (b) 有一个结构性时序问题,这是它和双 token 最大的不同:旧 token 是唯一凭证, 过期之后就没有任何东西能换新的了;而被动触发恰恰要等到过期后的第一个 401 才知道 过期——等状态机反应过来,续期接口只会说「这个 token 已经失效」。所以 (b) 几乎必然 要配主动触发。这是主动刷新从「优化」升格为「必需」的唯一场景。两种引入方式:

  • adapter 内部自己起定时器,在过期前调续期——零契约改动,今天就能做;
  • AuthAdapter 加一个可选的「凭证是否将过期」方法,状态机在发送前多一个触发点, 复用 refreshOnce 的全部并发保障——契约扩展,更干净。

无论哪种,401 被动路径必须原样保留:客户端时钟会偏、服务端会提前吊销令牌, 主动预判永远可能失手。

改动清单:

文件动不动
adapters/jwt-auth.ts新增,整个适配的全部内容
index.ts装配那一行换成新工厂
session.ts不动——它存的本来就是内存里一个字符串
auth.ts 状态机 / client.ts / errors.ts / retry.ts不动
登录接口照旧 skipAuth: true

两个教程常见做法这里明确不跟:token 仍放内存、不进 localStorage(XSS 一读一个准, 换方案不改变这条);前端不验签、不拿 payload 做业务判断——decode 出 exp 最多用于 (b) 的续期时机,令牌真伪永远由后端裁决(和「HTTP 状态是唯一权威」同一精神)。

什么时候必须改契约,而不是写新插件

三条边界,撞上任何一条,新增 adapter 文件就不够了:

  1. applyCredential 是同步的——每次请求都要 await 的方案(WebCrypto 算签名、 发送前确认过期)装不进去;
  2. 状态机假设业务端点的「401 = 凭证问题、可续期」——业务侧用 403 表达过期、 或走 WWW-Authenticate 协商的方案对不上(刷新端点侧的失败语义不在此列, 它已经由 shouldExpireSession 归适配器回答);
  3. 一个客户端实例只有一条刷新轨道——两套独立续期的凭证(比如用户态 + 应用态) 会在同一个 credentialVersion 上打架。

最后是选择发生的时刻。方案选择留在装配时——index.ts 组装的那一行——不预建 「认证方案注册表」。这和本封装另外两个决定是同一条规则:

决策消除的不确定性保留的东西
Loading 不建 Adapter项目端永远只有显示/隐藏两个动作一个布尔回调
retry 默认关闭接数据请求层后重试归上层按请求显式开启的口子
认证不建注册表每个部署只有一个方案在跑AuthAdapter 接缝本身

规则一句话:**接缝便宜,尽管留;机制贵,等需求。**哪天真出现「同一个构建要面对多种 后端方案」(多租户、私有化交付、SDK 化),注册表用动态 import 加在装配层,按启动 配置只加载一个 adapter,核心一行不动。

自己验证

test/auth-session-isolation.test.ts 覆盖会话代际; test/http-client.test.ts 里有并发 401 只刷新一次、在途刷新撞上登录/登出被代际 作废、会话边界 runAuthTransition() 挡住新刷新并先排空在途、以及终结判定交给 适配器(OAuth 式 400 + invalid_grant)的用例; test/failure-budgets.test.ts 覆盖两类刷新失败的分野——5xx 只熔断不清会话、冷却 结束后静默自愈;test/session-sync.test.ts 覆盖跨标签页同步与事件屏障——全部在 Node 里跑,正是 BroadcastChannel 是 Node 内建原语的直接证据。

另做两道不用写代码的推演题:

  1. 把本页开头「10 个请求全部收到 401」的场景套在变体 (a) 上,推演三个问题—— refreshCredential 会被调几次?登录页会跳几次?冷却窗口内随后到达的 401 拿到 什么?(答案都藏在那六组状态里:一次;一次;复用缓存的失败,不再打续期接口。)
  2. 两个标签页同时用 R1 刷新,后端带 60 秒宽限窗口:后到的标签页拿到的是先到者的 R2 吗?先到者的凭证链会因此中断吗?(答案在「多标签页」一节:不是,是补发的 兄弟凭证;不会,两条链从此各自独立轮换。)

本页源码

构建时从 docs/projects/axios-http/ 的真实文件直读,和测试跑的是同一份。对照上文: http/auth.ts 是第一到六步的状态机加「会话代际」与边界闸;http/adapters/auth.ts 是装配第二级的适配器(含第四步的独立刷新实例);session.ts 是装配第一级的会话 对象;session-sync.ts 是「多标签页」末尾的旁挂同步模块。每个文件头注释是该文件的 地图。auth.ts 建议先把文件头那六组状态看明白再读实现。

ts
/**
 * 401 自动刷新与重放。这是整个封装里状态最多的文件,先说清楚它在解决什么问题。
 *
 * 场景:页面同时发了 10 个请求,令牌恰好在这一刻过期,于是 10 个请求全部收到 401。
 * 幼稚的实现会打 10 次刷新接口,拿回 10 个新令牌,后面的把前面的挤掉——用户随机
 * 掉线。所以核心诉求是:**无论多少请求同时撞上 401,只刷新一次,然后把它们全部重放**。
 *
 * 围绕这个诉求有六组状态,各自挡住一个坑:
 *
 *   refreshPromise            单飞。已经在刷了就复用同一个 Promise,不发起第二次。
 *   credentialVersion         凭证代际。区分「你拿旧令牌失败」和「新令牌也失败」:
 *                             前者该刷新重放,后者说明是真的没权限,再刷也没用。
 *   failedVersion/Error/At    熔断。刷新接口自己挂掉时,别让每个请求都去捅它一下;
 *                             但也不能永久锁死,所以带一个冷却窗口。
 *   expiredVersion            去重。让 expireSession()(通常是跳登录页)每代只触发
 *                             一次,而不是 10 个请求弹 10 次。
 *   sessionEpoch              会话代际。跨过会话边界(登录、登出)时推进,使上一个
 *                             会话遗留的在途刷新不再影响新状态。
 *   activeTransitions         边界闸。登录/登出执行期间挡住新刷新的产生——代际只能
 *                             作废旧刷新的内存写入,Set-Cookie 拦不到,唯有不让它
 *                             启程。见 runAuthTransition()。
 *
 * 另有两个 WeakSet,用来标记「这个错误认证模块已经处理过了」,client.ts 据此决定
 * 不再叠一个全局提示。用 WeakSet 而不是往错误上加字段,既不污染错误对象,也不会
 * 被 JSON.stringify 带进监控上报。
 */

import axios, {
  type AxiosInstance,
  type InternalAxiosRequestConfig,
} from "axios";

export interface AuthBehavior {
  skipAuth?: boolean;
}

export interface AuthAdapter {
  applyCredential(config: InternalAxiosRequestConfig): void;
  /**
   * 取回新凭证但**不落盘**,返回一个提交函数。写入由认证模块在确认会话代际未变后
   * 执行——若适配器自己写,旧会话的在途刷新回来时已经覆盖了用户刚登录的新凭证,
   * 代际检查只能追认损失。
   */
  refreshCredential(): Promise<() => void>;
  /**
   * 判定一次刷新失败是否意味着凭证已死、会话应当终结。「哪种失败表示 Refresh
   * Token 失效」是后端契约(本项目是刷新端点的 401,OAuth 式后端是
   * 400 + invalid_grant),所以住在适配器里,引擎不内置任何状态码假设。
   * 返回 false 的失败走引擎的熔断冷却,窗口结束后重试。
   */
  shouldExpireSession(error: unknown): boolean;
  expireSession(): void;
}

export interface AuthControl {
  resetAuthState(): void;
  /**
   * 会话边界动作(登录、登出)的执行闸:先挡住新刷新的产生,再排空已在途的刷新,
   * 然后执行 action——边界请求与凭证写入都要放进 action 里闸内完成。代际机制只护
   * 得住内存里的令牌,刷新响应里的 Set-Cookie 由浏览器在响应到达时直接写入,JS
   * 拦不到;闸保证从排空到写入的整个窗口内没有任何认证响应在途或启程,边界动作的
   * 响应就是最后写 Cookie 的那一个。闸内撞上 401 的请求会排队,闸放开后复查凭证
   * 代际:边界已换代就直接用新凭证重放,不再刷新。
   * action 的返回值与异常原样透传;无论成败,闸都会释放。注意 action 里只能发
   * skipAuth 的边界请求——普通请求撞 401 会排队等闸,在 action 里等自己就是死锁。
   */
  runAuthTransition<T>(action: () => Promise<T>): Promise<T>;
  /**
   * 等到没有在途刷新为止。刷新失败也算「落定」,此方法不复抛刷新的错误。
   * 这是 runAuthTransition 的排空原语:单独使用只能清掉「已有的」在途刷新,挡不住
   * 排空之后、边界动作往返期间新起的那一个——会话边界请用 runAuthTransition。
   */
  waitForRefreshSettled(): Promise<void>;
}

export interface InstallAuthOptions {
  refreshCooldownMs?: number;
}

// 刷新失败后的熔断冷却:窗口期内不再打刷新端点,窗口结束后放行一次新的尝试。
// 它是「熔断」不是「锁定」——刷新接口抖动一下不该让用户在整个会话里都用不了。
const DEFAULT_REFRESH_COOLDOWN_MS = 30_000;

type AuthRequestConfig = InternalAxiosRequestConfig &
  AuthBehavior & {
    /** 本实例的请求拦截器盖的章。见下面响应拦截器里为什么必须认它。 */
    __authManaged?: boolean;
    /** 已经因为 401 重放过一次。防止「刷新了、重放了、还是 401」时无限循环。 */
    __authRetry?: boolean;
    /** 发出这个请求时用的是第几代凭证。 */
    __credentialVersion?: number;
  };

const handledAuthErrors = new WeakSet<object>();
const authRefreshErrors = new WeakSet<object>();

function markHandledAuthError(
  error: unknown,
  origin: "business" | "auth-refresh",
) {
  if ((typeof error === "object" && error !== null) || typeof error === "function") {
    handledAuthErrors.add(error);
    if (origin === "auth-refresh") {
      authRefreshErrors.add(error);
    }
  }
  return error;
}

export function isHandledAuthError(error: unknown) {
  return Boolean(
    ((typeof error === "object" && error !== null) || typeof error === "function") &&
      handledAuthErrors.has(error),
  );
}

export function readHandledAuthErrorOrigin(error: unknown) {
  if (
    (typeof error !== "object" || error === null) &&
    typeof error !== "function"
  ) {
    return undefined;
  }

  if (authRefreshErrors.has(error)) {
    return "auth-refresh" as const;
  }

  return handledAuthErrors.has(error) ? ("business" as const) : undefined;
}

// 刷新可能要等几百毫秒,用户在这期间完全可能切走页面。所以等待前后都要复查取消
// 状态,别把一个已经没人要的请求重新发出去。
function throwIfCanceled(config: AuthRequestConfig) {
  if (config.signal?.aborted) {
    throw new axios.CanceledError("Request canceled", config);
  }
}

export function installAuth(
  axiosInstance: AxiosInstance,
  adapter: AuthAdapter,
  options: InstallAuthOptions = {},
): AuthControl {
  const refreshCooldownMs =
    options.refreshCooldownMs ?? DEFAULT_REFRESH_COOLDOWN_MS;
  let credentialVersion = 0;
  let refreshPromise: Promise<void> | undefined;
  let failedVersion: number | undefined;
  let failedError: unknown;
  let failedAt = 0;
  let expiredVersion: number | undefined;
  // 会话代际:显式重建会话时推进,用于判定在途刷新是否已经过时。
  let sessionEpoch = 0;
  // 会话边界闸:>0 表示登录/登出正在执行。计数而非布尔,是给「边界里又套边界」
  // 这种理论情况留的余量;闸的等待方在 refreshOnce() 顶部。
  let activeTransitions = 0;
  let transitionGate: Promise<void> | undefined;
  let openTransitionGate: (() => void) | undefined;

  // 同一代凭证只失效一次。10 个请求同时确认「登录真的过期了」,用户也只该被踢到
  // 登录页一次。
  function expireOnce(version: number) {
    if (expiredVersion === version) {
      return;
    }

    expiredVersion = version;
    adapter.expireSession();
  }

  // 单飞的实现:所有撞上 401 的请求都调它,但真正打刷新接口的只有第一个。
  async function refreshOnce(version: number) {
    // 会话边界(登录、登出)执行期间不起新刷新:边界动作的响应必须是最后写 Cookie
    // 的认证响应,此刻多起的刷新会晚到并把它回盖。等闸放开——用循环是因为醒来时
    // 可能又有新的边界开始了。
    while (transitionGate) {
      await transitionGate;
    }

    // ——闸后复查代际:边界动作若已提交新凭证(登录成功),这个 401 属于上一代,
    // 直接返回让调用方拿新令牌重放;代际没动(比如登录失败了)才继续走刷新。
    if (version < credentialVersion) {
      return;
    }

    if (failedVersion === version) {
      if (Date.now() - failedAt < refreshCooldownMs) {
        // 熔断打开:直接复用上次那个失败,不再打刷新端点。
        return Promise.reject(failedError);
      }

      // 冷却结束,清掉失败缓存放行一次新的刷新。少了这一段,刷新端点抖动一次就会
      // 把客户端永久锁死,用户不刷新页面就再也发不出请求。
      failedVersion = undefined;
      failedError = undefined;
    }

    if (!refreshPromise) {
      // 把当下的会话代际捕获进闭包。刷新是异步的,等它回来时用户可能已经重新登录
      // 过了;对比这个快照就能认出「我是上一个会话遗留的刷新」。
      const epoch = sessionEpoch;
      let pending: Promise<void> | undefined;
      pending = adapter
        .refreshCredential()
        .then((commitCredential) => {
          // 上一会话的刷新即使成功也要丢弃:它拿回来的是旧会话的令牌,提交了会把
          // 用户刚登录的新凭证覆盖掉。适配器只取回不落盘,落盘由这里把关。
          if (epoch !== sessionEpoch) {
            return;
          }

          commitCredential();
          credentialVersion += 1;
          failedVersion = undefined;
          failedError = undefined;
        })
        .catch((error: unknown) => {
          const handledError = markHandledAuthError(error, "auth-refresh");
          // 同理,上一会话的刷新失败也不该把用户刚建立的新会话再踢下线。
          if (epoch === sessionEpoch) {
            failedVersion = version;
            failedError = handledError;
            failedAt = Date.now();
            // 只有适配器确认「这次失败意味着凭证已死」才终结会话(本项目的答案
            // 是刷新端点的 401,见适配器;D-65)。网络错、超时、5xx 只说明端点
            // 「暂时无法回答」,此刻清会话会把一次抖动放大成一次强制登出;
            // 留给上面的熔断冷却,窗口结束后自愈。
            if (adapter.shouldExpireSession(error)) {
              expireOnce(version);
            }
          }

          throw handledError;
        })
        .finally(() => {
          // 只在自己仍然是「当前那一次刷新」时才清空。resetAuthState() 可能已经把
          // refreshPromise 换成别的了,无条件清空会把新的那次也一起抹掉。
          if (refreshPromise === pending) {
            refreshPromise = undefined;
          }
        });
      refreshPromise = pending;
    }

    return refreshPromise;
  }

  axiosInstance.interceptors.request.use(async (config) => {
    const currentConfig = config as AuthRequestConfig;
    // 盖章:标记这个请求由本实例受理,响应拦截器只处理盖过章的。
    currentConfig.__authManaged = true;
    if (currentConfig.skipAuth) {
      return currentConfig;
    }

    // 刷新进行中就先排队等着,而不是拿着明知已经过期的令牌硬发出去——那样只会白白
    // 换回一个 401,然后走一遍重放流程。等一下反而更快。
    if (refreshPromise) {
      const waitedEpoch = sessionEpoch;
      try {
        await refreshPromise;
      } catch (error) {
        // 等待期间用户重新登录了:上一会话的刷新失败与我无关,拿新凭证继续就行。
        if (sessionEpoch === waitedEpoch) {
          throw error;
        }
      }
    }

    throwIfCanceled(currentConfig);
    // 记下这次用的是第几代凭证。收到 401 时靠它判断「是我的令牌旧了,还是刷新过
    // 之后仍然被拒」——这两种情况的处置完全不同。
    currentConfig.__credentialVersion = credentialVersion;
    adapter.applyCredential(currentConfig);
    return currentConfig;
  });

  axiosInstance.interceptors.response.use(
    (response) => response,
    async (error: unknown) => {
      if (!axios.isAxiosError(error)) {
        return Promise.reject(error);
      }

      const config = error.config as AuthRequestConfig | undefined;
      if (
        !config ||
        // 只处理盖过章的请求。刷新客户端等别的 Axios 实例产生的错误也可能流经这条
        // 链,把它们当成业务 401 会触发一次本不该发生的刷新。
        !config.__authManaged ||
        error.response?.status !== 401 ||
        config.skipAuth
      ) {
        return Promise.reject(error);
      }

      const requestVersion = config.__credentialVersion ?? credentialVersion;
      // 已经重放过一次还是 401:新令牌都不认,那就是真的没有权限。到此为止,
      // 不再刷新——否则就是死循环。
      if (config.__authRetry) {
        expireOnce(requestVersion);
        return Promise.reject(markHandledAuthError(error, "business"));
      }

      try {
        // 只有「我用的凭证还是当前这一代」才需要刷新。如果 credentialVersion 已经
        // 涨上去了,说明别的请求刚刚刷新成功,本请求直接拿新令牌重放即可。
        if (requestVersion >= credentialVersion) {
          await refreshOnce(requestVersion);
        }

        throwIfCanceled(config);
        config.__authRetry = true;
        // 重放。注意是把整个 config 重新灌回实例,所以它会再走一遍完整的拦截器链,
        // 请求拦截器会在这时给它换上刚刷新出来的新令牌。
        return axiosInstance(config);
      } catch (refreshError) {
        return Promise.reject(refreshError);
      }
    },
  );

  return {
    // 跨过会话边界(登录、登出、切换账号)时调。它做的是「翻过这一页」,而不是
    // 「清理干净」——所有代际都往前推一格,于是上一会话的在途刷新回来时会发现
    // 自己已经过时,自动作废。
    resetAuthState() {
      sessionEpoch += 1;
      credentialVersion += 1;
      failedVersion = undefined;
      failedError = undefined;
      failedAt = 0;
      // 清掉它,新会话才能在需要时重新触发一次 expireSession。
      expiredVersion = undefined;
      // 与在途刷新脱钩:新会话的请求不再排队等上一会话那次刷新的结果。
      refreshPromise = undefined;
    },
    async runAuthTransition<T>(action: () => Promise<T>): Promise<T> {
      // ①上闸:从此刻起新撞上 401 的请求只在 refreshOnce() 顶部排队,不再起刷新。
      activeTransitions += 1;
      transitionGate ??= new Promise((resolve) => {
        openTransitionGate = resolve;
      });

      try {
        // ②排空:等已在途的刷新落定(成败都算)。闸已上,排空后不会再冒出新的。
        while (refreshPromise) {
          await refreshPromise.catch(() => undefined);
        }

        // ③执行边界动作:登录/登出请求和凭证写入都在闸内完成,「响应已回、写入
        // 未落」的微任务缝隙也在闸的保护之内。
        return await action();
      } finally {
        // ④放闸:无论 action 成败都释放,唤醒排队的 401 去复查代际。
        activeTransitions -= 1;
        if (activeTransitions === 0) {
          const release = openTransitionGate;
          transitionGate = undefined;
          openTransitionGate = undefined;
          release?.();
        }
      }
    },
    async waitForRefreshSettled() {
      // 用循环而不是单次 await:等待期间可能又有新请求触发下一轮刷新,
      // 要等到「此刻确实没有在途刷新」才放行。
      while (refreshPromise) {
        await refreshPromise.catch(() => undefined);
      }
    },
  };
}
ts
/**
 * 认证适配器:把「本项目用 Bearer Token + Cookie 刷新」这套具体做法,翻译成 auth.ts
 * 要的四个动作(带凭证、刷新凭证、判定终结、作废会话)。
 *
 * auth.ts 那边只管**什么时候**刷新(单飞、冷却、重放)与**要不要采纳**刷新结果
 * (会话代际把关),完全不知道令牌长什么样。换成别的认证方案——自定义 header、
 * 双 token、OAuth——只需要另写一个这样的文件。
 */

import axios, {
  type AxiosResponse,
  type InternalAxiosRequestConfig,
} from "axios";

import type { AuthAdapter } from "../auth";

export interface CreateBearerAuthAdapterOptions {
  baseURL: string;
  timeout?: number;
  refreshUrl?: string;
  getAccessToken(): string | null;
  setAccessToken(token: string): void;
  selectAccessToken(response: AxiosResponse<unknown>): string;
  expireSession(): void;
}

export function createBearerAuthAdapter(
  options: CreateBearerAuthAdapterOptions,
): AuthAdapter {
  // 刷新走一个**独立的** Axios 实例,这是关键设计而不是随手为之:
  //
  //   · 它身上没装业务实例的拦截器,所以刷新请求自己收到 401 时不会再触发一次刷新,
  //     否则就是无限递归。
  //   · withCredentials: true 只开在这一个实例上。刷新令牌是 HttpOnly Cookie,只有
  //     刷新接口需要带它;业务请求默认不带(见 client.ts),跨域 Cookie 的暴露面
  //     就被压到了一个接口。
  const refreshClient = axios.create({
    baseURL: options.baseURL,
    timeout: options.timeout ?? 10_000,
    allowAbsoluteUrls: false,
    withCredentials: true,
    transitional: {
      clarifyTimeoutError: true,
    },
  });

  return {
    applyCredential(config: InternalAxiosRequestConfig) {
      const accessToken = options.getAccessToken();
      if (accessToken) {
        config.headers.set("Authorization", `Bearer ${accessToken}`);
      } else {
        // 没有令牌时要主动**删掉** header,不能只是不设。重放的请求用的是同一个
        // config 对象,上面还留着退出登录前的旧令牌。
        config.headers.delete("Authorization");
      }
    },

    async refreshCredential() {
      const response = await refreshClient.post<unknown>(
        options.refreshUrl ?? "/auth/refresh",
      );
      // 解析在取回时就做——响应格式不对属于「这次刷新失败」,要立刻抛出去。
      const accessToken = options.selectAccessToken(response);

      // 只取回不落盘:写入由认证模块确认会话代际未变后执行。刷新在途期间用户可能
      // 已经重新登录或登出,这里直接写会把旧会话的令牌盖到新状态上。
      return () => {
        options.setAccessToken(accessToken);
      };
    },

    shouldExpireSession(error) {
      // 本项目的后端契约:刷新端点用且仅用 401 表示 Refresh Token 失效(D-65)。
      // 这是项目约定而非通用规律——OAuth 式后端就用 400 + invalid_grant 表达
      // 同一件事,接那种后端时换掉这一条判定即可。
      return axios.isAxiosError(error) && error.response?.status === 401;
    },

    expireSession: options.expireSession,
  };
}
ts
/**
 * 会话状态归项目所有,不属于通用 HTTP 模块。
 *
 * 这里的内存实现只是最小可用样板:Access Token 存在内存里,刷新令牌由后端的
 * HttpOnly Cookie 持有。真实项目通常换成 Pinia、Zustand 或 Redux 中的会话切片,
 * 只要仍然满足 AuthSession 的四个约定即可,通用模块不需要改动。
 *
 * Access Token 不写入 localStorage 或 sessionStorage:那样任何 XSS 都能直接读走它,
 * 而内存中的令牌随页面卸载消失。
 *
 * 它放在 src/api/session.ts 而不是 http/ 目录下,是因为「会话怎么存」是项目状态,
 * 不是 HTTP 传输的一部分。HTTP 模块只通过 adapters/auth.ts 的 AuthAdapter 读写它,
 * 两边靠接口连接,谁都不知道对方的实现。
 */
export interface AuthSession {
  getAccessToken(): string | null;
  setAccessToken(token: string): void;
  /** 刷新失败、会话作废时调用,只清状态。 */
  clearSession(): void;
  /** 清完之后通知项目做跳转登录页之类的动作。跟 clearSession 分开,是因为 auth.ts
   *  会保证它整个会话只触发一次,避免并发的五个请求弹五次登录框。 */
  onExpired(): void;
}

export interface CreateMemoryAuthSessionOptions {
  initialAccessToken?: string | null;
  onExpired: () => void;
}

export function createMemoryAuthSession(
  options: CreateMemoryAuthSessionOptions,
): AuthSession {
  let accessToken = options.initialAccessToken ?? null;

  return {
    getAccessToken() {
      return accessToken;
    },

    setAccessToken(token) {
      accessToken = token;
    },

    clearSession() {
      accessToken = null;
    },

    onExpired: options.onExpired,
  };
}
ts
/**
 * 跨标签页会话同步:把「这个标签页登录/登出/换了令牌」的事实广播给同源的其他
 * 标签页,让所有标签页共享同一份会话。
 *
 * 本文件只是**传输机制**——BroadcastChannel 收发加乱序防护,不碰任何会话存储;
 * 事件到达后做什么由调用方的 handlers 决定(项目侧把 handlers 接到自己的会话
 * 存储上:收到更新就写入并调用 resetAuthState() 开新代际,收到终结就清空)。
 *
 * 它与 http/ 目录是并列关系,不是其中一环:同步不参与「什么时候刷新」的决策,
 * 只搬运刷新/登录/登出的结果。跨标签页的**互斥**(防并发刷新触发轮换重放)被
 * 有意排除在外——那要重写单飞状态机,且正确性本就由后端轮换宽限窗口保证,
 * 划界依据见 DESIGN.md D-66/D-67。
 *
 * 乱序防护解决的问题:BroadcastChannel 不保证多标签页事件的全局顺序。标签页 A
 * 广播「会话更新」、标签页 B 紧接着广播「会话终结」,C 可能先收到终结再收到更新,
 * 于是一个已登出的会话被复活。给每个事件盖单调递增的时间戳,只接受比已见事件更新
 * 的,就把「旧事实覆盖新事实」挡住了。
 *
 * 时间戳相同(两个标签页在同一毫秒各自发事件)是真并发,谁先谁后没有事实答案,
 * 能做的是**确定性裁决**让所有标签页收敛到同一结局:终结优先于更新(宁可多登出
 * 一次,不可复活已终结的会话),同型再按来源序。少了裁决,结局取决于各标签页的
 * 到达顺序,同一对冲突事件会让一部分标签页登出、另一部分保留会话。
 */

export type SessionSyncEvent<Session> =
  | { sentAt: number; sourceId: string; type: "session-ended" }
  | { sentAt: number; session: Session; sourceId: string; type: "session-updated" };

export interface SessionSyncHandlers<Session> {
  onSessionEnded(): void;
  onSessionUpdated(session: Session): void;
}

export interface SessionSync<Session> {
  dispose(): void;
  publishSessionEnded(): void;
  publishSessionUpdated(session: Session): void;
}

function createSourceId() {
  return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
}

interface EventOrderKey {
  sentAt: number;
  sourceId: string;
  type: SessionSyncEvent<unknown>["type"];
}

// 平局时终结压过更新:复活已终结的会话比多登出一次危害大。
const TYPE_RANK: Record<EventOrderKey["type"], number> = {
  "session-updated": 0,
  "session-ended": 1,
};

// 事件全序:(时间戳, 类型权重, 来源)。前两项都相同才比到来源——它只为让各标签页
// 对「谁生效」达成一致,本身没有语义。返回 0 意味着同一事件(重复投递)。
function compareEvents(a: EventOrderKey, b: EventOrderKey) {
  if (a.sentAt !== b.sentAt) {
    return a.sentAt - b.sentAt;
  }
  if (a.type !== b.type) {
    return TYPE_RANK[a.type] - TYPE_RANK[b.type];
  }
  return a.sourceId < b.sourceId ? -1 : a.sourceId > b.sourceId ? 1 : 0;
}

export function createSessionSync<Session>(
  channelName: string,
  handlers: SessionSyncHandlers<Session>,
): SessionSync<Session> {
  // 环境没有 BroadcastChannel(SSR、极旧浏览器)就降级为空实现:单标签页照常工作,
  // 只是失去跨标签页联动。调用方不需要感知差别。
  if (typeof BroadcastChannel === "undefined") {
    return {
      dispose() {},
      publishSessionEnded() {},
      publishSessionUpdated() {},
    };
  }

  const sourceId = createSourceId();
  const channel = new BroadcastChannel(channelName);
  // Node 环境(测试)里别让频道句柄拖住进程退出;浏览器没有 unref,安静跳过。
  (channel as unknown as { unref?: () => void }).unref?.();

  let disposed = false;
  let lastEvent: EventOrderKey = { sentAt: 0, sourceId: "", type: "session-updated" };

  // 本地时钟可能和其他标签页有偏差,也可能同一毫秒连发两个事件。取
  // max(现在, 上一事件 + 1) 保证自己发出的时间戳一定比已见过的所有事件都新。
  function stampEvent(type: EventOrderKey["type"]) {
    const sentAt = Math.max(Date.now(), lastEvent.sentAt + 1);
    lastEvent = { sentAt, sourceId, type };
    return sentAt;
  }

  channel.onmessage = (event: MessageEvent) => {
    const data = event.data as Partial<SessionSyncEvent<Session>> | undefined;
    if (
      disposed ||
      !data ||
      typeof data.sentAt !== "number" ||
      typeof data.sourceId !== "string" ||
      (data.type !== "session-ended" && data.type !== "session-updated")
    ) {
      return;
    }

    // 全序比较:更旧的丢弃,同一事件的重复投递(比较结果为 0)也丢弃。
    if (compareEvents(data as EventOrderKey, lastEvent) <= 0) {
      return;
    }

    lastEvent = { sentAt: data.sentAt, sourceId: data.sourceId, type: data.type };

    if (data.type === "session-ended") {
      handlers.onSessionEnded();
    } else {
      handlers.onSessionUpdated(
        (data as SessionSyncEvent<Session> & { session: Session }).session,
      );
    }
  };

  return {
    dispose() {
      disposed = true;
      channel.close();
    },
    publishSessionEnded() {
      if (disposed) {
        return;
      }
      channel.postMessage({
        sentAt: stampEvent("session-ended"),
        sourceId,
        type: "session-ended",
      });
    },
    publishSessionUpdated(session: Session) {
      if (disposed) {
        return;
      }
      channel.postMessage({
        sentAt: stampEvent("session-updated"),
        session,
        sourceId,
        type: "session-updated",
      });
    },
  };
}