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,9 +188,10 @@ 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) { if (isRefreshing && refreshPromise) {
await refreshPromise; await refreshPromise;
@@ -199,14 +209,12 @@ export const onResponseErrorAuthRefresh: FetchHook = async (ctx) => {
await refreshPromise; await refreshPromise;
} }
// 刷新失败 → 清除数据并抛出 const newToken = await getAuthTokenAsync(isGuestMode);
const newToken = await getAuthTokenAsync();
if (!newToken) { if (!newToken) {
AuthStorage.getInstance().clearAuthData();
throw new ApiError( throw new ApiError(
ErrorCode.unknownError, ErrorCode.unknownError,
"Token refresh failed", "Token refresh failed",
status status,
); );
} }
@@ -214,6 +222,11 @@ export const onResponseErrorAuthRefresh: FetchHook = async (ctx) => {
const headers = new Headers(ctx.options.headers); const headers = new Headers(ctx.options.headers);
headers.set("Authorization", `Bearer ${newToken}`); headers.set("Authorization", `Bearer ${newToken}`);
ctx.options.headers = headers; ctx.options.headers = headers;
} catch (error) {
const clearResult = await AuthStorage.getInstance().clearAuthData();
if (Result.isErr(clearResult)) throw clearResult.error;
throw error;
}
}; };
/** /**
@@ -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();
});
});
+31 -16
View File
@@ -112,18 +112,22 @@ 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);
} }
try {
const deviceId = await deviceIdentifier.getDeviceId(); const deviceId = await deviceIdentifier.getDeviceId();
const guestResult = await this.guestLogin(deviceId); const guestResult = await this.guestLogin(deviceId);
if (Result.isOk(guestResult)) return Result.ok(undefined); 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;
if (hasGuestToken.data) {
const providerResult = await this.storage.setLoginProvider( const providerResult = await this.storage.setLoginProvider(
LoginStatus.Guest, LoginStatus.Guest,
); );
@@ -136,6 +140,9 @@ export class AuthRepository implements IAuthRepository {
} }
return Result.err(guestResult.error); return Result.err(guestResult.error);
} catch (e) {
return Result.err(e);
}
} }
/** /**
@@ -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>> {
return Result.wrap(async () => {
const existing = await this.storage.getRefreshToken(); const existing = await this.storage.getRefreshToken();
if (!existing.success || !existing.data) { if (Result.isErr(existing)) throw existing.error;
return Result.err( if (!existing.data) {
new ApiError( 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;
}); });