fix(auth): handle logout and refresh results

Propagate storage Result failures through logout, guest restoration, token refresh, and user state actors. Clear invalid sessions after failed automatic refreshes and cover both business and guest flows.
This commit is contained in:
2026-07-13 17:23:54 +08:00
parent eba01fe637
commit 225d232763
5 changed files with 388 additions and 89 deletions
@@ -32,19 +32,17 @@ let isRefreshing = false;
let refreshPromise: Promise<void> | null = null; let refreshPromise: Promise<void> | null = null;
/** /**
* 取当前可用的 auth token(登录 token 优先,其次游客 token * 取当前刷新模式对应的 token,不跨登录态回退。
*/ */
async function getAuthTokenAsync(): Promise<string | null> { async function getAuthTokenAsync(isGuestMode: boolean): Promise<string | null> {
const storage = AuthStorage.getInstance(); const storage = AuthStorage.getInstance();
const loginResult = await storage.getLoginToken(); const tokenResult = isGuestMode
if (Result.isOk(loginResult) && loginResult.data && loginResult.data.length > 0) { ? await storage.getGuestToken()
return loginResult.data; : await storage.getLoginToken();
} if (Result.isErr(tokenResult)) throw tokenResult.error;
const guestResult = await storage.getGuestToken(); return tokenResult.data && tokenResult.data.length > 0
if (Result.isOk(guestResult) && guestResult.data && guestResult.data.length > 0) { ? tokenResult.data
return guestResult.data; : null;
}
return null;
} }
/** /**
@@ -67,7 +65,8 @@ async function refreshToken(
async function refreshLoginToken(baseUrl: string): Promise<void> { async function refreshLoginToken(baseUrl: string): Promise<void> {
const storage = AuthStorage.getInstance(); const storage = AuthStorage.getInstance();
const refreshResult = await storage.getRefreshToken(); const refreshResult = await storage.getRefreshToken();
if (!Result.isOk(refreshResult) || !refreshResult.data) { if (Result.isErr(refreshResult)) throw refreshResult.error;
if (!refreshResult.data) {
throw new ApiError( throw new ApiError(
"HTTP_UNAUTHORIZED", "HTTP_UNAUTHORIZED",
"No refresh token available", "No refresh token available",
@@ -95,12 +94,18 @@ async function refreshLoginToken(baseUrl: string): Promise<void> {
data?: { token?: string; refreshToken?: string }; data?: { token?: string; refreshToken?: string };
}; };
if (json.success && json.data) { if (!json.success || !json.data?.token) {
if (json.data.token) await storage.setLoginToken(json.data.token);
if (json.data.refreshToken) await storage.setRefreshToken(json.data.refreshToken);
} else {
throw new ApiError(ErrorCode.parseError, "Refresh response invalid"); throw new ApiError(ErrorCode.parseError, "Refresh response invalid");
} }
const tokenResult = await storage.setLoginToken(json.data.token);
if (Result.isErr(tokenResult)) throw tokenResult.error;
if (json.data.refreshToken) {
const refreshTokenResult = await storage.setRefreshToken(
json.data.refreshToken,
);
if (Result.isErr(refreshTokenResult)) throw refreshTokenResult.error;
}
} }
/** /**
@@ -130,12 +135,16 @@ async function refreshGuestToken(baseUrl: string): Promise<void> {
data?: { token?: string; deviceId?: string }; data?: { token?: string; deviceId?: string };
}; };
if (json.success && json.data) { if (!json.success || !json.data?.token) {
if (json.data.token) await storage.setGuestToken(json.data.token);
if (json.data.deviceId) await storage.setDeviceId(json.data.deviceId);
} else {
throw new ApiError(ErrorCode.parseError, "Guest refresh response invalid"); throw new ApiError(ErrorCode.parseError, "Guest refresh response invalid");
} }
const tokenResult = await storage.setGuestToken(json.data.token);
if (Result.isErr(tokenResult)) throw tokenResult.error;
if (json.data.deviceId) {
const deviceResult = await storage.setDeviceId(json.data.deviceId);
if (Result.isErr(deviceResult)) throw deviceResult.error;
}
} }
function apiCodeForStatus(status: number): string { function apiCodeForStatus(status: number): string {
@@ -179,41 +188,45 @@ export const onResponseErrorAuthRefresh: FetchHook = async (ctx) => {
// 已登录则刷新登录 token,否则刷新游客 token // 已登录则刷新登录 token,否则刷新游客 token
const hasLoginTokenResult = await AuthStorage.getInstance().hasLoginToken(); const hasLoginTokenResult = await AuthStorage.getInstance().hasLoginToken();
const isGuestMode = if (Result.isErr(hasLoginTokenResult)) throw hasLoginTokenResult.error;
!Result.isOk(hasLoginTokenResult) || !hasLoginTokenResult.data; const isGuestMode = !hasLoginTokenResult.data;
// 并发刷新:等待或启动 try {
if (isRefreshing && refreshPromise) { // 并发刷新:等待或启动
await refreshPromise; if (isRefreshing && refreshPromise) {
} else { await refreshPromise;
isRefreshing = true; } else {
const baseUrl = new URL(requestUrl).origin; isRefreshing = true;
refreshPromise = (async () => { const baseUrl = new URL(requestUrl).origin;
try { refreshPromise = (async () => {
await refreshToken(isGuestMode, baseUrl); try {
} finally { await refreshToken(isGuestMode, baseUrl);
isRefreshing = false; } finally {
refreshPromise = null; isRefreshing = false;
} refreshPromise = null;
})(); }
await refreshPromise; })();
await refreshPromise;
}
const newToken = await getAuthTokenAsync(isGuestMode);
if (!newToken) {
throw new ApiError(
ErrorCode.unknownError,
"Token refresh failed",
status,
);
}
// 刷新成功:更新当前 optionsretry 时 tokenInterceptor 也会重新读取最新 token。
const headers = new Headers(ctx.options.headers);
headers.set("Authorization", `Bearer ${newToken}`);
ctx.options.headers = headers;
} catch (error) {
const clearResult = await AuthStorage.getInstance().clearAuthData();
if (Result.isErr(clearResult)) throw clearResult.error;
throw error;
} }
// 刷新失败 → 清除数据并抛出
const newToken = await getAuthTokenAsync();
if (!newToken) {
AuthStorage.getInstance().clearAuthData();
throw new ApiError(
ErrorCode.unknownError,
"Token refresh failed",
status
);
}
// 刷新成功:更新当前 optionsretry 时 tokenInterceptor 也会重新读取最新 token。
const headers = new Headers(ctx.options.headers);
headers.set("Authorization", `Bearer ${newToken}`);
ctx.options.headers = headers;
}; };
/** /**
@@ -0,0 +1,152 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
GuestLoginResponse,
RefreshTokenResponse,
} from "@/data/dto/auth";
import { AuthRepository } from "@/data/repositories/auth_repository";
import type { AuthApi } from "@/data/services/api";
import type { IAuthStorage } from "@/data/storage/auth";
import type { IUserStorage } from "@/data/storage/user";
import { Result } from "@/utils";
function createRepository(input: {
api?: Partial<AuthApi>;
storage?: Partial<IAuthStorage>;
userStorage?: Partial<IUserStorage>;
} = {}) {
const storage = {
getRefreshToken: vi.fn(async () => Result.ok("refresh-token")),
setLoginToken: vi.fn(async () => Result.ok(undefined)),
setRefreshToken: vi.fn(async () => Result.ok(undefined)),
setGuestToken: vi.fn(async () => Result.ok(undefined)),
setLoginProvider: vi.fn(async () => Result.ok(undefined)),
setDeviceId: vi.fn(async () => Result.ok(undefined)),
clearBusinessAuthData: vi.fn(async () => Result.ok(undefined)),
hasGuestToken: vi.fn(async () => Result.ok(false)),
...input.storage,
} as unknown as IAuthStorage;
const userStorage = {
setUserId: vi.fn(async () => Result.ok(undefined)),
clearUserData: vi.fn(async () => Result.ok(undefined)),
...input.userStorage,
} as unknown as IUserStorage;
const api = {
logout: vi.fn(async () => undefined),
...input.api,
} as AuthApi;
return {
repository: new AuthRepository(api, storage, userStorage),
api,
storage,
userStorage,
};
}
afterEach(() => {
vi.restoreAllMocks();
});
describe("AuthRepository session Result handling", () => {
it("returns a failed auth cleanup Result while still attempting user cleanup", async () => {
const clearError = new Error("auth cleanup failed");
const { repository, userStorage } = createRepository({
storage: {
clearBusinessAuthData: vi.fn(async () => Result.err(clearError)),
},
});
const result = await repository.logout();
expect(Result.isErr(result)).toBe(true);
expect(Result.isErr(result) && result.error.message).toBe(
clearError.message,
);
expect(userStorage.clearUserData).toHaveBeenCalledOnce();
});
it("returns a failed user cleanup Result from logout", async () => {
const clearError = new Error("user cleanup failed");
const { repository } = createRepository({
userStorage: {
clearUserData: vi.fn(async () => Result.err(clearError)),
},
});
const result = await repository.logout();
expect(Result.isErr(result)).toBe(true);
expect(Result.isErr(result) && result.error.message).toBe(
clearError.message,
);
});
it("propagates refresh-token storage read failures", async () => {
const storageError = new Error("refresh token read failed");
const refreshToken = vi.fn();
const { repository } = createRepository({
api: { refreshToken },
storage: {
getRefreshToken: vi.fn(async () => Result.err(storageError)),
},
});
const result = await repository.refreshToken();
expect(Result.isErr(result)).toBe(true);
expect(Result.isErr(result) && result.error.message).toBe(
storageError.message,
);
expect(refreshToken).not.toHaveBeenCalled();
});
it("does not report refresh success when a new token cannot be stored", async () => {
const storageError = new Error("login token write failed");
const response = RefreshTokenResponse.from({
token: "new-login-token",
refreshToken: "new-refresh-token",
userId: "user-1",
});
const { repository, storage } = createRepository({
api: { refreshToken: vi.fn(async () => response) },
storage: {
setLoginToken: vi.fn(async () => Result.err(storageError)),
},
});
const result = await repository.refreshToken();
expect(Result.isErr(result)).toBe(true);
expect(Result.isErr(result) && result.error.message).toBe(
storageError.message,
);
expect(storage.setRefreshToken).not.toHaveBeenCalled();
});
it("does not report guest login success when session persistence fails", async () => {
const storageError = new Error("guest token write failed");
const response = GuestLoginResponse.from({
token: "guest-token",
deviceId: "device-1",
userId: "guest-1",
isDeviceUser: true,
expiresIn: 3600,
user: null,
});
const { repository, storage } = createRepository({
api: { guestLogin: vi.fn(async () => response) },
storage: {
setGuestToken: vi.fn(async () => Result.err(storageError)),
},
});
const result = await repository.guestLogin("device-1");
expect(Result.isErr(result)).toBe(true);
expect(Result.isErr(result) && result.error.message).toBe(
storageError.message,
);
expect(storage.setLoginProvider).not.toHaveBeenCalled();
});
});
+49 -34
View File
@@ -112,30 +112,37 @@ export class AuthRepository implements IAuthRepository {
} }
try { try {
await this.storage.clearBusinessAuthData(); const authClearResult = await this.storage.clearBusinessAuthData();
await this.userStorage.clearUserData(); const userClearResult = await this.userStorage.clearUserData();
if (Result.isErr(authClearResult)) return authClearResult;
if (Result.isErr(userClearResult)) return userClearResult;
} catch (e) { } catch (e) {
return Result.err(e); return Result.err(e);
} }
const deviceId = await deviceIdentifier.getDeviceId(); try {
const guestResult = await this.guestLogin(deviceId); const deviceId = await deviceIdentifier.getDeviceId();
if (Result.isOk(guestResult)) return Result.ok(undefined); const guestResult = await this.guestLogin(deviceId);
if (Result.isOk(guestResult)) return Result.ok(undefined);
const hasGuestToken = await this.storage.hasGuestToken(); const hasGuestToken = await this.storage.hasGuestToken();
if (hasGuestToken.success && hasGuestToken.data) { if (Result.isErr(hasGuestToken)) return hasGuestToken;
const providerResult = await this.storage.setLoginProvider( if (hasGuestToken.data) {
LoginStatus.Guest, const providerResult = await this.storage.setLoginProvider(
); LoginStatus.Guest,
if (Result.isErr(providerResult)) return providerResult; );
log.warn( if (Result.isErr(providerResult)) return providerResult;
"[AuthRepository] guest login failed after logout, falling back to local guest token", log.warn(
guestResult.error, "[AuthRepository] guest login failed after logout, falling back to local guest token",
); guestResult.error,
return Result.ok(undefined); );
return Result.ok(undefined);
}
return Result.err(guestResult.error);
} catch (e) {
return Result.err(e);
} }
return Result.err(guestResult.error);
} }
/** /**
@@ -156,11 +163,17 @@ export class AuthRepository implements IAuthRepository {
hasUser: !!response.user, hasUser: !!response.user,
userLastMessageAt: response.user?.lastMessageAt, userLastMessageAt: response.user?.lastMessageAt,
}); });
await this.storage.setGuestToken(response.token); const tokenResult = await this.storage.setGuestToken(response.token);
await this.storage.setLoginProvider(LoginStatus.Guest); if (Result.isErr(tokenResult)) throw tokenResult.error;
await this.storage.setDeviceId(deviceId); const providerResult = await this.storage.setLoginProvider(
LoginStatus.Guest,
);
if (Result.isErr(providerResult)) throw providerResult.error;
const deviceResult = await this.storage.setDeviceId(deviceId);
if (Result.isErr(deviceResult)) throw deviceResult.error;
if (response.userId) { if (response.userId) {
await this.userStorage.setUserId(response.userId); const userIdResult = await this.userStorage.setUserId(response.userId);
if (Result.isErr(userIdResult)) throw userIdResult.error;
} }
return response; return response;
}).catch((e) => { }).catch((e) => {
@@ -304,24 +317,26 @@ export class AuthRepository implements IAuthRepository {
* 调用 API 成功后写回新的 login token + refresh token。 * 调用 API 成功后写回新的 login token + refresh token。
*/ */
async refreshToken(): Promise<Result<RefreshTokenResponse>> { async refreshToken(): Promise<Result<RefreshTokenResponse>> {
const existing = await this.storage.getRefreshToken(); return Result.wrap(async () => {
if (!existing.success || !existing.data) { const existing = await this.storage.getRefreshToken();
return Result.err( if (Result.isErr(existing)) throw existing.error;
new ApiError( if (!existing.data) {
throw new ApiError(
"HTTP_UNAUTHORIZED", "HTTP_UNAUTHORIZED",
"No refresh token available", "No refresh token available",
ErrorCode.httpUnauthorized, ErrorCode.httpUnauthorized,
), );
); }
}
const refreshToken = existing.data;
return Result.wrap(async () => {
const response = await this.api.refreshToken( const response = await this.api.refreshToken(
RefreshTokenRequest.from({ refreshToken }), RefreshTokenRequest.from({ refreshToken: existing.data }),
); );
await this.storage.setLoginToken(response.token); const tokenResult = await this.storage.setLoginToken(response.token);
if (Result.isErr(tokenResult)) throw tokenResult.error;
if (response.refreshToken) { if (response.refreshToken) {
await this.storage.setRefreshToken(response.refreshToken); const refreshTokenResult = await this.storage.setRefreshToken(
response.refreshToken,
);
if (Result.isErr(refreshTokenResult)) throw refreshTokenResult.error;
} }
return response; return response;
}); });
@@ -3,7 +3,7 @@ import { createStorage } from "unstorage";
import memoryDriver from "unstorage/drivers/memory"; import memoryDriver from "unstorage/drivers/memory";
import { AuthStorage } from "@/data/storage/auth"; import { AuthStorage } from "@/data/storage/auth";
import { SpAsyncUtil } from "@/utils"; import { deviceIdentifier, Result, SpAsyncUtil } from "@/utils";
import { ErrorCode } from "../api_result"; import { ErrorCode } from "../api_result";
import { createHttpClient } from "../http_client"; import { createHttpClient } from "../http_client";
@@ -29,6 +29,7 @@ describe("createHttpClient", () => {
afterEach(() => { afterEach(() => {
vi.unstubAllGlobals(); vi.unstubAllGlobals();
vi.restoreAllMocks();
}); });
it("refreshes a business token and replays the original request once after 401", async () => { it("refreshes a business token and replays the original request once after 401", async () => {
@@ -143,5 +144,122 @@ describe("createHttpClient", () => {
"/api/chat/history", "/api/chat/history",
"/api/auth/refresh", "/api/auth/refresh",
]); ]);
await expect(authStorage.getLoginToken()).resolves.toEqual(Result.ok(null));
await expect(authStorage.getRefreshToken()).resolves.toEqual(Result.ok(null));
});
it("rejects instead of replaying when the refreshed token cannot be stored", async () => {
const authStorage = AuthStorage.getInstance();
await authStorage.setLoginToken("old-login-token");
await authStorage.setRefreshToken("refresh-token");
vi.spyOn(authStorage, "setLoginToken").mockResolvedValue(
Result.err(new Error("login token write failed")),
);
const fetchMock = vi.fn<typeof fetch>(async (input) => {
const url = new URL(input instanceof Request ? input.url : String(input));
if (url.pathname === "/api/auth/refresh") {
return jsonResponse({
success: true,
data: {
token: "new-login-token",
refreshToken: "new-refresh-token",
},
});
}
return jsonResponse(
{ message: "expired" },
{ status: ErrorCode.httpUnauthorized, statusText: "Unauthorized" },
);
});
vi.stubGlobal("fetch", fetchMock);
const http = createHttpClient();
await expect(http("/api/chat/history")).rejects.toMatchObject({
message: "login token write failed",
});
expect(fetchMock.mock.calls.map((call) => callUrl(call).pathname)).toEqual([
"/api/chat/history",
"/api/auth/refresh",
]);
});
it("propagates refresh-token read failures instead of treating them as missing", async () => {
const authStorage = AuthStorage.getInstance();
await authStorage.setLoginToken("old-login-token");
vi.spyOn(authStorage, "getRefreshToken").mockResolvedValue(
Result.err(new Error("refresh token read failed")),
);
const fetchMock = vi.fn<typeof fetch>(async () =>
jsonResponse(
{ message: "expired" },
{ status: ErrorCode.httpUnauthorized, statusText: "Unauthorized" },
),
);
vi.stubGlobal("fetch", fetchMock);
const http = createHttpClient();
await expect(http("/api/chat/history")).rejects.toMatchObject({
message: "refresh token read failed",
});
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("propagates login-state read failures without attempting a guest refresh", async () => {
const authStorage = AuthStorage.getInstance();
await authStorage.setLoginToken("old-login-token");
vi.spyOn(authStorage, "hasLoginToken").mockResolvedValue(
Result.err(new Error("login state read failed")),
);
const fetchMock = vi.fn<typeof fetch>(async () =>
jsonResponse(
{ message: "expired" },
{ status: ErrorCode.httpUnauthorized, statusText: "Unauthorized" },
),
);
vi.stubGlobal("fetch", fetchMock);
const http = createHttpClient();
await expect(http("/api/chat/history")).rejects.toMatchObject({
message: "login state read failed",
});
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("rejects a guest refresh when the new guest token cannot be stored", async () => {
const authStorage = AuthStorage.getInstance();
await authStorage.setGuestToken("old-guest-token");
vi.spyOn(deviceIdentifier, "getDeviceId").mockResolvedValue("device-1");
vi.spyOn(authStorage, "setGuestToken").mockResolvedValue(
Result.err(new Error("guest token write failed")),
);
const fetchMock = vi.fn<typeof fetch>(async (input) => {
const url = new URL(input instanceof Request ? input.url : String(input));
if (url.pathname === "/api/auth/guest") {
return jsonResponse({
success: true,
data: { token: "new-guest-token", deviceId: "device-1" },
});
}
return jsonResponse(
{ message: "expired" },
{ status: ErrorCode.httpUnauthorized, statusText: "Unauthorized" },
);
});
vi.stubGlobal("fetch", fetchMock);
const http = createHttpClient();
await expect(http("/api/chat/history")).rejects.toMatchObject({
message: "guest token write failed",
});
expect(fetchMock.mock.calls.map((call) => callUrl(call).pathname)).toEqual([
"/api/chat/history",
"/api/auth/guest",
]);
await expect(authStorage.getGuestToken()).resolves.toEqual(Result.ok(null));
}); });
}); });
+2 -1
View File
@@ -87,5 +87,6 @@ export const userFetchActor = fromPromise<UserFetchData>(async () => {
* - onDone / onError 都跑 `clearUser` actionmachine 层)—— 失败也清本地 * - onDone / onError 都跑 `clearUser` actionmachine 层)—— 失败也清本地
*/ */
export const userLogoutActor = fromPromise<void>(async () => { export const userLogoutActor = fromPromise<void>(async () => {
await getAuthRepository().logout(); const result = await getAuthRepository().logout();
if (Result.isErr(result)) throw result.error;
}); });