Appearance
认证与凭证刷新
本页对应学习路径的阶段七。它排在所有能力的最后,因为它是唯一一个状态多到需要 先画图的模块——但没有人是一次画出那张图的。本页按它真实的生长顺序走:从十行的 直觉拦截器开始,每暴露一个坑,多长一个状态;走到「复盘」一节,图就自己画完了。
全文回答这些问题,前六步长出状态机,之后把它接进真实项目:
| # | 回答的问题 | 引入的东西 |
|---|---|---|
| 1 | 10 个并发 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);
});它塌在哪
- 打 10 次刷新接口,拿回 10 个新令牌,后面的把前面的挤掉——用户随机掉线。
- 重放还是 401 就无限递归,浏览器标签页卡死。
- 刷新请求自己收到 401 时,也会走进这个拦截器,再触发一次刷新。
- 刷新接口挂了的时候,每个请求都去捅它一下。
- 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.ts 里 refreshOnce 的骨架(它还多做几件事,后面几步逐个长出来)。流程收敛成:
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
代际作废的只是内存侧的提交。轮换制下刷新响应还带着 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 的暴露面被压到一个接口。
Cookie 方案的前提:刷新端点自带防线
把 Refresh Token 放进 Cookie,等于把「带凭证」交给浏览器自动完成——反过来说, 任何页面向刷新端点发请求,浏览器都可能替它把 Cookie 带上(CSRF)。所以这个 方案不是装上就安全,它把四件事变成后端的硬前提,采用前逐条确认:
HttpOnly+Secure:JS 读不到、明文信道不发送——本节的 XSS 立场靠它们成立;SameSite=Strict(至少Lax):跨站页面发起的请求不带这枚 Cookie,堵掉 CSRF 的主通道,代价是前后端必须同站(子域可以,这是很多项目选子域拆分而不是跨域的 真实原因);Path锁到认证路由:业务接口拿不到这枚 Cookie,暴露面只剩认证端点本身;- Origin 校验:
SameSite是浏览器行为,旧浏览器与非浏览器客户端不受约束, 服务端仍要对登录/刷新/登出校验Origin白名单。校验失败只拒绝、不动 Cookie:Origin是攻击者可控的请求头,若按它下发清除性的Set-Cookie,任意跨站页面都 能借合法站点之口强制登出受害者——清 Cookie 只属于通过校验的正常登出。
admin-backend-3 的对应实现:Cookie 属性集中在后端 auth-cookies.ts(HttpOnly + 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 直传给 createBearerAuthAdapter,clearSession + 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)把前端互斥整层删掉、只留后端宽限,多标签页照常工作——「兜底」 被实践验证为真正的防线。前端互斥本来就管不住上面那个单标签页变体(丢响应重试时, 锁一直在自己手里),也管不住另一台设备;真正决定正确性的始终是后端那一侧。
本工程为什么不把前端互斥做进来
三个原因,按分量排:
- 它不提供独立的正确性。 装了它,丢响应重试的重放照样发生;后端有宽限,不装 它也几乎无感。多标签页能不能安全用,从头到尾由后端策略决定,前端互斥只是减少 触发宽限的次数。
- 它不是旁挂模块。 互斥要包住整个刷新流程;拿到锁之后要先复查凭证是否已被 隔壁标签页刷新过——这些判断必须长在单飞状态机内部(admin-backend-3 换代前的 会话协调器就是这么写的),等价于重写
refreshOnce,而不是新增一个文件。 - 它绑定纯浏览器原语。 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 可含过期时间 exp) | JWT / 不透明随机串 |
| 架构 | 有几个凭证、怎么续期 | 单 token 过期重登 / Access + Refresh 双 token |
两者正交:双 token 架构里的 access token 完全可以是 JWT 格式——而且这是主流生产 形态。可以自查:找任何一个 OAuth2/OIDC 后端(Auth0、Keycloak、Cognito……),登录 响应里 access_token 和 refresh_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 文件就不够了:
applyCredential是同步的——每次请求都要await的方案(WebCrypto 算签名、 发送前确认过期)装不进去;- 状态机假设业务端点的「401 = 凭证问题、可续期」——业务侧用 403 表达过期、 或走
WWW-Authenticate协商的方案对不上(刷新端点侧的失败语义不在此列, 它已经由shouldExpireSession归适配器回答); - 一个客户端实例只有一条刷新轨道——两套独立续期的凭证(比如用户态 + 应用态) 会在同一个
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 内建原语的直接证据。
另做两道不用写代码的推演题:
- 把本页开头「10 个请求全部收到 401」的场景套在变体 (a) 上,推演三个问题——
refreshCredential会被调几次?登录页会跳几次?冷却窗口内随后到达的 401 拿到 什么?(答案都藏在那六组状态里:一次;一次;复用缓存的失败,不再打续期接口。) - 两个标签页同时用 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",
});
},
};
}