fix(api): replay requests after token refresh
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* 认证刷新拦截器
|
* 认证刷新拦截器
|
||||||
*
|
*
|
||||||
* 处理 401 错误,自动刷新 token 并重试原请求。
|
* 处理 401 错误,自动刷新 token;原请求重放由 ofetch retry 机制完成。
|
||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
import type { FetchHook } from "ofetch";
|
import type { FetchHook } from "ofetch";
|
||||||
@@ -132,7 +132,7 @@ function withTestAccountFlag<T extends Record<string, unknown>>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 401 → 刷新 token 并重试
|
* 401 → 刷新 token,并允许 ofetch retry 重放原请求
|
||||||
*/
|
*/
|
||||||
export const onResponseErrorAuthRefresh: FetchHook = async (ctx) => {
|
export const onResponseErrorAuthRefresh: FetchHook = async (ctx) => {
|
||||||
const status = ctx.response?.status;
|
const status = ctx.response?.status;
|
||||||
@@ -144,10 +144,15 @@ export const onResponseErrorAuthRefresh: FetchHook = async (ctx) => {
|
|||||||
const requestUrl =
|
const requestUrl =
|
||||||
ctx.request instanceof Request ? ctx.request.url : ctx.request;
|
ctx.request instanceof Request ? ctx.request.url : ctx.request;
|
||||||
const path = new URL(requestUrl).pathname;
|
const path = new URL(requestUrl).pathname;
|
||||||
if (NO_AUTH_REFRESH_PATHS.some((p) => path.includes(p))) return;
|
if (NO_AUTH_REFRESH_PATHS.some((p) => path.includes(p))) {
|
||||||
|
ctx.options.retry = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// 已登录则刷新登录 token,否则刷新游客 token
|
// 已登录则刷新登录 token,否则刷新游客 token
|
||||||
const isGuestMode = !AuthStorage.getInstance().hasLoginToken();
|
const hasLoginTokenResult = await AuthStorage.getInstance().hasLoginToken();
|
||||||
|
const isGuestMode =
|
||||||
|
!Result.isOk(hasLoginTokenResult) || !hasLoginTokenResult.data;
|
||||||
|
|
||||||
// 并发刷新:等待或启动
|
// 并发刷新:等待或启动
|
||||||
if (isRefreshing && refreshPromise) {
|
if (isRefreshing && refreshPromise) {
|
||||||
@@ -177,10 +182,10 @@ export const onResponseErrorAuthRefresh: FetchHook = async (ctx) => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 刷新成功:更新请求头的 token
|
// 刷新成功:更新当前 options;retry 时 tokenInterceptor 也会重新读取最新 token。
|
||||||
if (ctx.request instanceof Request) {
|
const headers = new Headers(ctx.options.headers);
|
||||||
ctx.request.headers.set("Authorization", `Bearer ${newToken}`);
|
headers.set("Authorization", `Bearer ${newToken}`);
|
||||||
}
|
ctx.options.headers = headers;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ const NO_TOKEN_PATHS: readonly string[] = [
|
|||||||
ApiPath.emailLogin,
|
ApiPath.emailLogin,
|
||||||
ApiPath.register,
|
ApiPath.register,
|
||||||
ApiPath.guestLogin,
|
ApiPath.guestLogin,
|
||||||
|
ApiPath.refresh,
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { createStorage } from "unstorage";
|
||||||
|
import memoryDriver from "unstorage/drivers/memory";
|
||||||
|
|
||||||
|
import { AuthStorage } from "@/data/storage/auth";
|
||||||
|
import { SpAsyncUtil } from "@/utils";
|
||||||
|
|
||||||
|
import { createHttpClient } from "../http_client";
|
||||||
|
|
||||||
|
function jsonResponse(body: unknown, init?: ResponseInit): Response {
|
||||||
|
return new Response(JSON.stringify(body), {
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
...init,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function callUrl(call: Parameters<typeof fetch>): URL {
|
||||||
|
const request = call[0];
|
||||||
|
return new URL(request instanceof Request ? request.url : String(request));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("createHttpClient", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
process.env.NEXT_PUBLIC_API_BASE_URL = "https://api.example.test";
|
||||||
|
SpAsyncUtil.setStorage(createStorage({ driver: memoryDriver() }));
|
||||||
|
AuthStorage._resetForTests();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refreshes a business token and replays the original request once after 401", async () => {
|
||||||
|
const authStorage = AuthStorage.getInstance();
|
||||||
|
await authStorage.setLoginToken("old-login-token");
|
||||||
|
await authStorage.setRefreshToken("refresh-token");
|
||||||
|
|
||||||
|
const requestAuthHeaders: Array<string | null> = [];
|
||||||
|
const fetchMock = vi.fn<typeof fetch>(async (input, init) => {
|
||||||
|
const url = new URL(input instanceof Request ? input.url : String(input));
|
||||||
|
const headers =
|
||||||
|
input instanceof Request ? input.headers : new Headers(init?.headers);
|
||||||
|
requestAuthHeaders.push(headers.get("Authorization"));
|
||||||
|
|
||||||
|
if (url.pathname === "/api/auth/refresh") {
|
||||||
|
return jsonResponse({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
token: "new-login-token",
|
||||||
|
refreshToken: "new-refresh-token",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
url.pathname === "/api/chat/history" &&
|
||||||
|
fetchMock.mock.calls.filter(
|
||||||
|
(call) => callUrl(call).pathname === "/api/chat/history",
|
||||||
|
).length === 1
|
||||||
|
) {
|
||||||
|
return jsonResponse(
|
||||||
|
{ message: "expired" },
|
||||||
|
{ status: 401, statusText: "Unauthorized" },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsonResponse({ success: true, data: { ok: true } });
|
||||||
|
});
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
|
||||||
|
const http = createHttpClient();
|
||||||
|
await expect(http("/api/chat/history")).resolves.toEqual({
|
||||||
|
success: true,
|
||||||
|
data: { ok: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||||
|
expect(fetchMock.mock.calls.map((call) => callUrl(call).pathname)).toEqual([
|
||||||
|
"/api/chat/history",
|
||||||
|
"/api/auth/refresh",
|
||||||
|
"/api/chat/history",
|
||||||
|
]);
|
||||||
|
expect(requestAuthHeaders).toEqual([
|
||||||
|
"Bearer old-login-token",
|
||||||
|
null,
|
||||||
|
"Bearer new-login-token",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -22,6 +22,17 @@ function chain(...fns: (FetchHook | undefined)[]): FetchHook {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function shouldLetAuthRetryRun(ctx: {
|
||||||
|
response?: { status?: number };
|
||||||
|
options?: { retry?: number | false };
|
||||||
|
}): boolean {
|
||||||
|
return (
|
||||||
|
ctx.response?.status === ErrorCode.httpUnauthorized &&
|
||||||
|
typeof ctx.options?.retry === "number" &&
|
||||||
|
ctx.options.retry > 0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 创建 ofetch 实例
|
* 创建 ofetch 实例
|
||||||
*/
|
*/
|
||||||
@@ -31,7 +42,8 @@ export function createHttpClient(): $Fetch {
|
|||||||
return ofetch.create({
|
return ofetch.create({
|
||||||
baseURL: config.baseUrl,
|
baseURL: config.baseUrl,
|
||||||
timeout: config.receiveTimeout,
|
timeout: config.receiveTimeout,
|
||||||
retry: 0, // 401 刷新由 auth_refresh_interceptor 自定义处理
|
retry: 1, // 401 刷新后由 ofetch 重放一次原请求
|
||||||
|
retryStatusCodes: [ErrorCode.httpUnauthorized],
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
@@ -45,9 +57,11 @@ export function createHttpClient(): $Fetch {
|
|||||||
authRefreshInterceptor.onResponseError,
|
authRefreshInterceptor.onResponseError,
|
||||||
((ctx: unknown) => {
|
((ctx: unknown) => {
|
||||||
const c = ctx as {
|
const c = ctx as {
|
||||||
|
options?: { retry?: number | false };
|
||||||
response?: { status?: number; _data?: unknown };
|
response?: { status?: number; _data?: unknown };
|
||||||
error?: Error;
|
error?: Error;
|
||||||
};
|
};
|
||||||
|
if (shouldLetAuthRetryRun(c)) return;
|
||||||
const status = c.response?.status;
|
const status = c.response?.status;
|
||||||
const code =
|
const code =
|
||||||
status === ErrorCode.httpUnauthorized
|
status === ErrorCode.httpUnauthorized
|
||||||
|
|||||||
Reference in New Issue
Block a user