From c3ebe0c8097fb3564828cb9a421cc78df797c695 Mon Sep 17 00:00:00 2001 From: huyan Date: Fri, 7 Aug 2026 00:39:42 +0800 Subject: [PATCH 1/7] feat(api): recover unauthorized requests once Business authentication failures arrive in successful HTTP envelopes. Add a scoped recovery provider and replay eligible requests once with the latest token. Authentication clients can opt out to prevent recursive refresh attempts. --- frontend/src/shared/api/index.test.ts | 123 ++++++++++++++++++++++++++ frontend/src/shared/api/index.ts | 60 ++++++++++++- 2 files changed, 179 insertions(+), 4 deletions(-) diff --git a/frontend/src/shared/api/index.test.ts b/frontend/src/shared/api/index.test.ts index 2c2dfe4d..c210dd02 100644 --- a/frontend/src/shared/api/index.test.ts +++ b/frontend/src/shared/api/index.test.ts @@ -5,6 +5,7 @@ import { createApiClient, getApiAccessToken, registerApiAccessTokenProvider, + registerApiUnauthorizedRecovery, } from './index' afterEach(() => vi.unstubAllEnvs()) @@ -127,6 +128,128 @@ describe('createApiClient', () => { expect(authorization).toBe('Bearer access-token') }) + it('recovers a valid HTTP 200 business 401 and replays once with the new token', async () => { + let accessToken = 'expired-token' + const authorizations: (string | null)[] = [] + const unregister = registerApiUnauthorizedRecovery(async () => { + accessToken = 'renewed-token' + return true + }) + const client = createApiClient({ + baseUrl: 'https://api.windup.test', + getAccessToken: () => accessToken, + fetchFn: async (input, init) => { + authorizations.push(new Request(input, init).headers.get('authorization')) + const payload = + authorizations.length === 1 + ? { code: 401, message: '登录状态已过期', data: null } + : { code: 200, message: 'success', data: { id: 7 } } + return new Response(JSON.stringify(payload), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) + }, + }) + + await expect(client.request('/resources/7')).resolves.toEqual({ id: 7 }) + expect(authorizations).toEqual(['Bearer expired-token', 'Bearer renewed-token']) + unregister() + }) + + it('never recovers or replays the same request twice', async () => { + const recover = vi.fn(async () => true) + const unregister = registerApiUnauthorizedRecovery(recover) + const fetchFn = vi.fn( + async () => + new Response(JSON.stringify({ code: 401, message: '登录状态已过期', data: null }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ) + const client = createApiClient({ baseUrl: 'https://api.windup.test', fetchFn }) + + await expect(client.request('/resources')).rejects.toMatchObject({ + kind: 'business', + code: 401, + }) + expect(recover).toHaveBeenCalledTimes(1) + expect(fetchFn).toHaveBeenCalledTimes(2) + unregister() + }) + + it('keeps the original 401 when recovery fails and lets clients opt out', async () => { + const recover = vi.fn(async () => { + throw new Error('refresh failed') + }) + const unregister = registerApiUnauthorizedRecovery(recover) + const response = () => + new Response(JSON.stringify({ code: 401, message: '原始登录错误', data: null }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) + const recoveringClient = createApiClient({ + baseUrl: 'https://api.windup.test', + fetchFn: async () => response(), + }) + const authClient = createApiClient({ + baseUrl: 'https://api.windup.test', + fetchFn: async () => response(), + recoverUnauthorized: false, + }) + + await expect(recoveringClient.request('/resources')).rejects.toMatchObject({ + code: 401, + message: '原始登录错误', + }) + await expect(authClient.request('/auth/refresh')).rejects.toMatchObject({ + code: 401, + message: '原始登录错误', + }) + expect(recover).toHaveBeenCalledTimes(1) + unregister() + }) + + it('does not recover a business 401 carried by a failed HTTP response', async () => { + const recover = vi.fn(async () => true) + const unregister = registerApiUnauthorizedRecovery(recover) + const client = createApiClient({ + baseUrl: 'https://api.windup.test', + fetchFn: async () => + new Response(JSON.stringify({ code: 401, message: 'unauthorized', data: null }), { + status: 401, + headers: { 'content-type': 'application/json' }, + }), + }) + + await expect(client.request('/resources')).rejects.toMatchObject({ + kind: 'http', + status: 401, + }) + expect(recover).not.toHaveBeenCalled() + unregister() + }) + + it('does not recover a business 401 carried by a non-200 successful HTTP response', async () => { + const recover = vi.fn(async () => true) + const unregister = registerApiUnauthorizedRecovery(recover) + const client = createApiClient({ + baseUrl: 'https://api.windup.test', + fetchFn: async () => + new Response(JSON.stringify({ code: 401, message: 'unauthorized', data: null }), { + status: 201, + headers: { 'content-type': 'application/json' }, + }), + }) + + await expect(client.request('/resources')).rejects.toMatchObject({ + kind: 'business', + code: 401, + status: 201, + }) + expect(recover).not.toHaveBeenCalled() + unregister() + }) + it('wraps a rejected fetch as a network ApiError', async () => { const connectionError = new TypeError('Failed to fetch') const client = createApiClient({ diff --git a/frontend/src/shared/api/index.ts b/frontend/src/shared/api/index.ts index 06f8ad8c..9ef07dda 100644 --- a/frontend/src/shared/api/index.ts +++ b/frontend/src/shared/api/index.ts @@ -25,6 +25,8 @@ export interface ApiClientOptions { fetchFn?: typeof fetch /** 只在请求发出时读取;token 的取得、保存与刷新由调用方负责。 */ getAccessToken?: () => string | null | undefined + /** 认证端点关闭此项,避免 refresh 自身的 401 递归进入恢复流程。 */ + recoverUnauthorized?: boolean } export type ApiAccessTokenProvider = NonNullable @@ -48,6 +50,23 @@ export function getApiAccessToken(): string | null | undefined { return accessTokenProviders.at(-1)?.() } +export type ApiUnauthorizedRecovery = () => Promise + +const unauthorizedRecoveryProviders: ApiUnauthorizedRecovery[] = [] + +/** 注册会话层提供的 401 恢复函数;shared/api 不持有任何认证业务状态。 */ +export function registerApiUnauthorizedRecovery(recovery: ApiUnauthorizedRecovery): () => void { + unauthorizedRecoveryProviders.push(recovery) + return () => { + const index = unauthorizedRecoveryProviders.lastIndexOf(recovery) + if (index >= 0) unauthorizedRecoveryProviders.splice(index, 1) + } +} + +function getApiUnauthorizedRecovery(): ApiUnauthorizedRecovery | undefined { + return unauthorizedRecoveryProviders.at(-1) +} + export type ApiErrorKind = 'business' | 'http' | 'invalid-response' | 'network' /** 后端业务错误与传输错误统一进入这一种前端错误。 */ @@ -181,6 +200,7 @@ export function createApiClient({ baseUrl, fetchFn = globalThis.fetch, getAccessToken, + recoverUnauthorized = true, }: ApiClientOptions): ApiClient { const normalizedBaseUrl = resolveApiBaseUrl(baseUrl) @@ -194,10 +214,43 @@ export function createApiClient({ } } + function canReplay(options: ApiRequestOptions | undefined): boolean { + const body = options?.body + return !(typeof ReadableStream !== 'undefined' && body instanceof ReadableStream) + } + + async function receiveEnvelope( + path: string, + options: ApiRequestOptions | undefined, + replayed = false, + ): Promise<{ response: Response; envelope: ApiEnvelope }> { + const response = await send(path, options) + const envelope = await readEnvelope(response) + + const recovery = getApiUnauthorizedRecovery() + if ( + !replayed && + recoverUnauthorized && + response.status === 200 && + envelope.code === 401 && + recovery && + canReplay(options) + ) { + let recovered = false + try { + recovered = await recovery() + } catch { + // 恢复失败仍应向调用方交付原始 401,而不是泄漏 refresh 的错误。 + } + if (recovered) return receiveEnvelope(path, options, true) + } + + return { response, envelope } + } + return { async request(path: string, options?: ApiRequestOptions) { - const response = await send(path, options) - const envelope = await readEnvelope(response) + const { response, envelope } = await receiveEnvelope(path, options) assertSuccessfulEnvelope(response, envelope) return envelope.data as T @@ -207,8 +260,7 @@ export function createApiClient({ * 分页字段与 data 同级,不在 data 内再嵌套分页对象。 */ async requestList(path: string, options?: ApiRequestOptions) { - const response = await send(path, options) - const envelope = await readEnvelope(response) + const { response, envelope } = await receiveEnvelope(path, options) assertSuccessfulEnvelope(response, envelope) if ( From 3bf8cf15e464b8d8ae85585631b8b70c5d1b39aa Mon Sep 17 00:00:00 2001 From: huyan Date: Fri, 7 Aug 2026 00:39:57 +0800 Subject: [PATCH 2/7] feat(user): add authentication API adapter Authentication endpoints expose backend DTOs that do not match frontend naming. Map all eight operations and validate successful user and token payloads explicitly. Callers receive a stable user contract while authentication requests avoid recursive recovery. --- frontend/src/entities/user/api.test.ts | 198 +++++++++++++++++++++++++ frontend/src/entities/user/api.ts | 163 ++++++++++++++++++++ frontend/src/entities/user/index.ts | 37 +++++ 3 files changed, 398 insertions(+) create mode 100644 frontend/src/entities/user/api.test.ts create mode 100644 frontend/src/entities/user/api.ts create mode 100644 frontend/src/entities/user/index.ts diff --git a/frontend/src/entities/user/api.test.ts b/frontend/src/entities/user/api.test.ts new file mode 100644 index 00000000..79623e07 --- /dev/null +++ b/frontend/src/entities/user/api.test.ts @@ -0,0 +1,198 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { registerApiUnauthorizedRecovery, type ApiClient } from '@/shared/api' + +import { createUserApis } from './api' + +const tokenResponse = { + access_token: 'access-token', + refresh_token: 'refresh-token', + token_type: 'bearer', + expires_in: 900, + user: { + id: 7, + email: 'reader@example.com', + nickname: 'Reader', + email_verified_at: '2026-08-07T01:02:03Z', + status: 37, + last_login_at: '2026-08-07T01:02:03Z', + create_at: '2026-08-01T01:02:03Z', + update_at: '2026-08-07T01:02:03Z', + }, +} + +describe('createUserApis', () => { + let request: ReturnType + let client: ApiClient + + beforeEach(() => { + request = vi.fn() + client = { + request: request as unknown as ApiClient['request'], + requestList: vi.fn() as unknown as ApiClient['requestList'], + } + }) + + it('maps every authentication command to its exact backend path and request body', async () => { + request + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(tokenResponse) + .mockResolvedValueOnce(tokenResponse) + .mockResolvedValueOnce(tokenResponse) + .mockResolvedValueOnce(tokenResponse) + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(null) + + const apis = createUserApis({ client }) + + await apis.sendCode({ email: 'reader@example.com', purpose: 'reset_password' }) + await apis.register({ + email: 'reader@example.com', + password: 'password-123', + code: '123456', + nickname: 'Reader', + }) + await apis.login({ + email: 'reader@example.com', + password: 'password-123', + code: '123456', + }) + await apis.loginByCode({ email: 'reader@example.com', code: '123456' }) + await apis.refresh('refresh-token') + await apis.logout('refresh-token') + await apis.changePassword({ oldPassword: 'password-123', newPassword: 'new-password-123' }) + + expect(request.mock.calls).toEqual([ + [ + '/auth/send-code', + { + method: 'POST', + json: { email: 'reader@example.com', purpose: 'reset_password' }, + }, + ], + [ + '/auth/register', + { + method: 'POST', + json: { + email: 'reader@example.com', + password: 'password-123', + code: '123456', + nickname: 'Reader', + }, + }, + ], + [ + '/auth/login', + { + method: 'POST', + json: { + email: 'reader@example.com', + password: 'password-123', + code: '123456', + }, + }, + ], + [ + '/auth/login-by-code', + { + method: 'POST', + json: { email: 'reader@example.com', code: '123456' }, + }, + ], + ['/auth/refresh', { method: 'POST', json: { refresh_token: 'refresh-token' } }], + ['/auth/logout', { method: 'POST', json: { refresh_token: 'refresh-token' } }], + [ + '/auth/change-password', + { + method: 'POST', + json: { old_password: 'password-123', new_password: 'new-password-123' }, + }, + ], + ]) + }) + + it('maps token and current-user payloads while preserving an unknown numeric status', async () => { + request.mockResolvedValueOnce(tokenResponse).mockResolvedValueOnce({ + id: 7, + email: 'reader@example.com', + nickname: null, + email_verified_at: null, + status: 37, + }) + + const apis = createUserApis({ client }) + + await expect( + apis.loginByCode({ email: 'reader@example.com', code: '123456' }), + ).resolves.toEqual({ + accessToken: 'access-token', + refreshToken: 'refresh-token', + user: { + id: '7', + email: 'reader@example.com', + nickname: 'Reader', + emailVerifiedAt: '2026-08-07T01:02:03Z', + statusCode: 37, + }, + }) + await expect(apis.me()).resolves.toEqual({ + id: '7', + email: 'reader@example.com', + nickname: null, + emailVerifiedAt: null, + statusCode: 37, + }) + expect(request).toHaveBeenLastCalledWith('/auth/me') + }) + + it.each([ + ['missing id', { ...tokenResponse, user: { ...tokenResponse.user, id: null } }], + ['missing email', { ...tokenResponse, user: { ...tokenResponse.user, email: null } }], + ])('rejects a successful token response with %s', async (_label, response) => { + request.mockResolvedValue(response) + const apis = createUserApis({ client }) + + await expect(apis.refresh('refresh-token')).rejects.toMatchObject({ + name: 'ApiError', + kind: 'invalid-response', + }) + }) + + it('omits an empty optional nickname', async () => { + request.mockResolvedValue(tokenResponse) + const apis = createUserApis({ client }) + + await apis.register({ + email: 'reader@example.com', + password: 'password-123', + code: '123456', + nickname: '', + }) + + expect(request).toHaveBeenCalledWith('/auth/register', { + method: 'POST', + json: { + email: 'reader@example.com', + password: 'password-123', + code: '123456', + }, + }) + }) + + it('disables global unauthorized recovery for authentication requests', async () => { + const recover = vi.fn(async () => true) + const unregister = registerApiUnauthorizedRecovery(recover) + const apis = createUserApis({ + baseUrl: 'https://api.windup.test', + fetchFn: async () => + new Response(JSON.stringify({ code: 401, message: 'refresh rejected', data: null }), { + status: 200, + }), + }) + + await expect(apis.refresh('expired-refresh-token')).rejects.toMatchObject({ code: 401 }) + expect(recover).not.toHaveBeenCalled() + unregister() + }) +}) diff --git a/frontend/src/entities/user/api.ts b/frontend/src/entities/user/api.ts new file mode 100644 index 00000000..47c60af2 --- /dev/null +++ b/frontend/src/entities/user/api.ts @@ -0,0 +1,163 @@ +import type { AuthTokens, User, UserApis } from '.' + +import { ApiError, createApiClient, getApiAccessToken } from '@/shared/api' +import type { ApiClient, ApiClientOptions } from '@/shared/api' + +interface UserDto { + id: number | null + email: string | null + nickname: string | null + email_verified_at: string | null + status: number + [key: string]: unknown +} + +interface AuthTokensDto { + access_token: string + refresh_token: string + user: UserDto + [key: string]: unknown +} + +export interface CreateUserApisOptions extends ApiClientOptions { + client?: ApiClient +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function invalidResponse(data: unknown): never { + throw new ApiError('用户认证响应格式无效', { kind: 'invalid-response', data }) +} + +function toUser(value: unknown): User { + if ( + !isRecord(value) || + !Number.isInteger(value.id) || + (value.id as number) <= 0 || + typeof value.email !== 'string' || + (typeof value.nickname !== 'string' && value.nickname !== null) || + (typeof value.email_verified_at !== 'string' && value.email_verified_at !== null) || + !Number.isInteger(value.status) + ) { + invalidResponse(value) + } + + const dto = value as unknown as UserDto + return { + id: String(dto.id), + email: dto.email as string, + nickname: dto.nickname, + emailVerifiedAt: dto.email_verified_at, + statusCode: dto.status, + } +} + +function toAuthTokens(value: unknown): AuthTokens { + if ( + !isRecord(value) || + typeof value.access_token !== 'string' || + typeof value.refresh_token !== 'string' || + !Object.hasOwn(value, 'user') + ) { + invalidResponse(value) + } + + const dto = value as unknown as AuthTokensDto + return { + accessToken: dto.access_token, + refreshToken: dto.refresh_token, + user: toUser(dto.user), + } +} + +export function createUserApis(options: CreateUserApisOptions = {}): UserApis { + const { client, ...clientOptions } = options + const apiClient = + client ?? + createApiClient({ + ...clientOptions, + getAccessToken: clientOptions.getAccessToken ?? getApiAccessToken, + recoverUnauthorized: false, + }) + + return { + async sendCode(input) { + await apiClient.request('/auth/send-code', { method: 'POST', json: input }) + }, + + async register(input) { + const json = { + email: input.email, + password: input.password, + code: input.code, + ...(input.nickname ? { nickname: input.nickname } : {}), + } + return toAuthTokens( + await apiClient.request('/auth/register', { method: 'POST', json }), + ) + }, + + async login(input) { + return toAuthTokens( + await apiClient.request('/auth/login', { method: 'POST', json: input }), + ) + }, + + async loginByCode(input) { + return toAuthTokens( + await apiClient.request('/auth/login-by-code', { + method: 'POST', + json: input, + }), + ) + }, + + async refresh(refreshToken) { + return toAuthTokens( + await apiClient.request('/auth/refresh', { + method: 'POST', + json: { refresh_token: refreshToken }, + }), + ) + }, + + async logout(refreshToken) { + await apiClient.request('/auth/logout', { + method: 'POST', + json: { refresh_token: refreshToken }, + }) + }, + + async me() { + return toUser(await apiClient.request('/auth/me')) + }, + + async changePassword(input) { + await apiClient.request('/auth/change-password', { + method: 'POST', + json: { old_password: input.oldPassword, new_password: input.newPassword }, + }) + }, + } +} + +let defaultApis: UserApis | undefined + +function getDefaultApis(): UserApis { + defaultApis ??= createUserApis() + return defaultApis +} + +/** 延迟创建默认 client,避免仅导入 entities 时要求运行环境已经配置 API 地址。 */ +export const userApis: UserApis = { + sendCode: (input) => getDefaultApis().sendCode(input), + register: (input) => getDefaultApis().register(input), + login: (input) => getDefaultApis().login(input), + loginByCode: (input) => getDefaultApis().loginByCode(input), + refresh: (refreshToken) => getDefaultApis().refresh(refreshToken), + logout: (refreshToken) => getDefaultApis().logout(refreshToken), + me: () => getDefaultApis().me(), + changePassword: (input) => getDefaultApis().changePassword(input), +} diff --git a/frontend/src/entities/user/index.ts b/frontend/src/entities/user/index.ts new file mode 100644 index 00000000..518f42fb --- /dev/null +++ b/frontend/src/entities/user/index.ts @@ -0,0 +1,37 @@ +/** 已认证用户在前端领域层的稳定表示。 */ +export interface User { + id: string + email: string + nickname: string | null + emailVerifiedAt: string | null + statusCode: number +} + +/** 一次认证成功后由后端签发的完整会话材料。 */ +export interface AuthTokens { + accessToken: string + refreshToken: string + user: User +} + +export type SendCodePurpose = 'login' | 'register' | 'reset_password' + +/** 用户认证与账户设置的后端接口。 */ +export interface UserApis { + sendCode(input: { email: string; purpose: SendCodePurpose }): Promise + register(input: { + email: string + password: string + code: string + nickname?: string + }): Promise + login(input: { email: string; password: string; code: string }): Promise + loginByCode(input: { email: string; code: string }): Promise + refresh(refreshToken: string): Promise + logout(refreshToken: string): Promise + me(): Promise + changePassword(input: { oldPassword: string; newPassword: string }): Promise +} + +export { createUserApis, userApis } from './api' +export type { CreateUserApisOptions } from './api' From d6e1733146c4edd8ed20af63618552f400793e92 Mon Sep 17 00:00:00 2001 From: huyan Date: Fri, 7 Aug 2026 00:40:15 +0800 Subject: [PATCH 3/7] feat(user): export the user entity contract Feature modules consume entities through the repository public barrel. Expose user models, API types, the factory, and the lazy default instance. Authentication features can depend on the user entity without bypassing layer boundaries. --- frontend/src/entities/index.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/frontend/src/entities/index.ts b/frontend/src/entities/index.ts index 79a3d212..7ab91f0b 100644 --- a/frontend/src/entities/index.ts +++ b/frontend/src/entities/index.ts @@ -1,5 +1,9 @@ /** entities 唯一公开入口。外部不得绕过本文件访问内部文件。 */ +/* 用户 —— 认证传输与稳定会话身份 */ +export { createUserApis, userApis } from './user' +export type { AuthTokens, CreateUserApisOptions, SendCodePurpose, User, UserApis } from './user' + /* 项目 —— 全局约束:视角、朝向、精灵尺寸、画风 */ export { CHARACTER_PERSPECTIVE, DIRECTIONAL_MOVEMENT } from './project' export { projectApis } from './project' From 5f98bc9bd2e21a8c02bf954ba736b87565409d79 Mon Sep 17 00:00:00 2001 From: huyan Date: Fri, 7 Aug 2026 00:40:30 +0800 Subject: [PATCH 4/7] feat(auth-session): persist refresh tokens safely Refresh tokens need durable storage without making browser storage a login prerequisite. Add the contracted key with an in-memory fallback and failure tombstones. Storage errors now preserve the current tab session and cannot resurrect stale credentials. --- .../auth-session/session-storage.test.ts | 101 ++++++++++++++++++ .../features/auth-session/session-storage.ts | 71 ++++++++++++ 2 files changed, 172 insertions(+) create mode 100644 frontend/src/features/auth-session/session-storage.test.ts create mode 100644 frontend/src/features/auth-session/session-storage.ts diff --git a/frontend/src/features/auth-session/session-storage.test.ts b/frontend/src/features/auth-session/session-storage.test.ts new file mode 100644 index 00000000..8edf5824 --- /dev/null +++ b/frontend/src/features/auth-session/session-storage.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it, vi } from 'vitest' + +import { REFRESH_TOKEN_STORAGE_KEY, createRefreshTokenStorage } from './session-storage' + +describe('refresh token storage', () => { + it('persists only the refresh token under the contracted key', () => { + const values = new Map() + const storage = { + getItem: vi.fn((key: string) => values.get(key) ?? null), + setItem: vi.fn((key: string, value: string) => values.set(key, value)), + removeItem: vi.fn((key: string) => values.delete(key)), + } + const tokens = createRefreshTokenStorage(storage) + + tokens.save('refresh-token') + + expect(storage.setItem).toHaveBeenCalledWith('windup.auth.refresh-token', 'refresh-token') + expect(tokens.load()).toBe('refresh-token') + expect(REFRESH_TOKEN_STORAGE_KEY).toBe('windup.auth.refresh-token') + + tokens.clear() + expect(storage.removeItem).toHaveBeenCalledWith('windup.auth.refresh-token') + expect(tokens.load()).toBeNull() + }) + + it('retains the current-tab refresh token in memory when storage throws', () => { + const failure = new DOMException('Storage is disabled', 'SecurityError') + const storage = { + getItem: vi.fn(() => { + throw failure + }), + setItem: vi.fn(() => { + throw failure + }), + removeItem: vi.fn(() => { + throw failure + }), + } + const tokens = createRefreshTokenStorage(storage) + + expect(() => tokens.save('memory-refresh-token')).not.toThrow() + expect(tokens.load()).toBe('memory-refresh-token') + expect(() => tokens.clear()).not.toThrow() + expect(tokens.load()).toBeNull() + }) + + it('uses the memory value after a previously working storage becomes unavailable', () => { + let available = true + const values = new Map() + const storage = { + getItem: (key: string) => { + if (!available) throw new DOMException('Storage is disabled', 'SecurityError') + return values.get(key) ?? null + }, + setItem: (key: string, value: string) => { + if (!available) throw new DOMException('Storage is disabled', 'SecurityError') + values.set(key, value) + }, + removeItem: (key: string) => { + if (!available) throw new DOMException('Storage is disabled', 'SecurityError') + values.delete(key) + }, + } + const tokens = createRefreshTokenStorage(storage) + + tokens.save('refresh-token') + available = false + + expect(tokens.load()).toBe('refresh-token') + }) + + it('does not let a readable stale value replace a token whose persistence failed', () => { + const storage = { + getItem: vi.fn(() => 'stale-refresh-token'), + setItem: vi.fn(() => { + throw new DOMException('Quota exceeded', 'QuotaExceededError') + }), + removeItem: vi.fn(), + } + const tokens = createRefreshTokenStorage(storage) + + tokens.save('memory-refresh-token') + + expect(tokens.load()).toBe('memory-refresh-token') + }) + + it('keeps an in-memory tombstone when persistent removal fails', () => { + const storage = { + getItem: vi.fn(() => 'stale-refresh-token'), + setItem: vi.fn(), + removeItem: vi.fn(() => { + throw new DOMException('Storage is disabled', 'SecurityError') + }), + } + const tokens = createRefreshTokenStorage(storage) + + tokens.clear() + + expect(tokens.load()).toBeNull() + }) +}) diff --git a/frontend/src/features/auth-session/session-storage.ts b/frontend/src/features/auth-session/session-storage.ts new file mode 100644 index 00000000..a79851a8 --- /dev/null +++ b/frontend/src/features/auth-session/session-storage.ts @@ -0,0 +1,71 @@ +export const REFRESH_TOKEN_STORAGE_KEY = 'windup.auth.refresh-token' + +type RefreshTokenStorage = Pick + +export interface RefreshTokenStore { + load(): string | null + save(refreshToken: string): void + clear(): void +} + +function getLocalStorage(): RefreshTokenStorage | null { + try { + return globalThis.localStorage + } catch { + return null + } +} + +/** + * localStorage 是跨刷新、跨标签的增强能力,不是维持当前页面登录的前提。 + * 浏览器拒绝存储访问时,闭包中的副本继续支撑本标签页会话。 + */ +export function createRefreshTokenStorage(storage?: RefreshTokenStorage | null): RefreshTokenStore { + let memoryValue: string | null = null + let memoryOnly = false + const resolveStorage = () => (storage === undefined ? getLocalStorage() : storage) + + return { + load() { + if (memoryOnly) return memoryValue + const target = resolveStorage() + if (!target) return memoryValue + try { + memoryValue = target.getItem(REFRESH_TOKEN_STORAGE_KEY) + } catch { + memoryOnly = true + } + return memoryValue + }, + save(refreshToken) { + memoryValue = refreshToken + try { + resolveStorage()?.setItem(REFRESH_TOKEN_STORAGE_KEY, refreshToken) + } catch { + memoryOnly = true + } + }, + clear() { + memoryValue = null + try { + resolveStorage()?.removeItem(REFRESH_TOKEN_STORAGE_KEY) + } catch { + memoryOnly = true + } + }, + } +} + +const defaultRefreshTokenStorage = createRefreshTokenStorage() + +export function loadRefreshToken(): string | null { + return defaultRefreshTokenStorage.load() +} + +export function saveRefreshToken(refreshToken: string): void { + defaultRefreshTokenStorage.save(refreshToken) +} + +export function clearRefreshToken(): void { + defaultRefreshTokenStorage.clear() +} From 8acab24bc312f7d59fbdbc4a010d4c89454cf7f3 Mon Sep 17 00:00:00 2001 From: huyan Date: Fri, 7 Aug 2026 00:41:01 +0800 Subject: [PATCH 5/7] feat(auth-session): manage authenticated sessions Authentication state must survive refreshes while access tokens remain memory-only. Add session transitions, scheduled and reactive renewal, and cross-tab synchronization. Generation gates and per-token deduplication prevent stale refreshes from corrupting newer sessions. --- .../src/features/auth-session/index.test.tsx | 511 ++++++++++++++++++ frontend/src/features/auth-session/index.tsx | 360 ++++++++++++ 2 files changed, 871 insertions(+) create mode 100644 frontend/src/features/auth-session/index.test.tsx create mode 100644 frontend/src/features/auth-session/index.tsx diff --git a/frontend/src/features/auth-session/index.test.tsx b/frontend/src/features/auth-session/index.test.tsx new file mode 100644 index 00000000..bfc39cbd --- /dev/null +++ b/frontend/src/features/auth-session/index.test.tsx @@ -0,0 +1,511 @@ +// @vitest-environment jsdom +import { StrictMode, type ReactNode } from 'react' +import { act, cleanup, render, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import type { AuthTokens, UserApis } from '@/entities/user' +import { createApiClient, getApiAccessToken, registerApiUnauthorizedRecovery } from '@/shared/api' +import { AuthSessionProvider, type AuthSessionValue, useAuthSession } from './index' +import { REFRESH_TOKEN_STORAGE_KEY, clearRefreshToken } from './session-storage' + +const user = { + id: '7', + email: 'reader@example.com', + nickname: 'Reader', + emailVerifiedAt: '2026-08-07T01:02:03Z', + statusCode: 37, +} + +function jwt(exp: number): string { + const payload = btoa(JSON.stringify({ exp })) + .replaceAll('+', '-') + .replaceAll('/', '_') + .replace(/=+$/, '') + return `header.${payload}.signature` +} + +function tokens(refreshToken = 'refresh-token', accessToken = 'access-token'): AuthTokens { + return { accessToken, refreshToken, user } +} + +function deferred() { + let resolve!: (value: T) => void + let reject!: (reason?: unknown) => void + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise + reject = rejectPromise + }) + return { promise, resolve, reject } +} + +function createApis(): UserApis & Record> { + return { + sendCode: vi.fn(async () => undefined), + register: vi.fn(async () => tokens()), + login: vi.fn(async () => tokens()), + loginByCode: vi.fn(async () => tokens()), + refresh: vi.fn(async () => tokens()), + logout: vi.fn(async () => undefined), + me: vi.fn(async () => user), + changePassword: vi.fn(async () => undefined), + } +} + +let currentSession: AuthSessionValue | null = null + +function SessionProbe() { + currentSession = useAuthSession() + return ( + + {currentSession.state.status}: + {currentSession.state.status === 'guest' ? currentSession.state.reason : ''}: + {currentSession.state.status === 'authenticated' ? currentSession.state.user.email : ''} + + ) +} + +function renderSession(apis: UserApis, wrapper?: (children: ReactNode) => ReactNode) { + const content = ( + + + + ) + return render(wrapper ? wrapper(content) : content) +} + +function session(): AuthSessionValue { + if (!currentSession) throw new Error('session is not mounted') + return currentSession +} + +async function expectState(value: string) { + await waitFor(() => + expect(document.querySelector('[data-testid="session"]')?.textContent).toBe(value), + ) +} + +afterEach(() => { + cleanup() + clearRefreshToken() + currentSession = null + vi.useRealTimers() +}) + +beforeEach(() => { + window.localStorage.clear() +}) + +describe('AuthSessionProvider', () => { + it('boots directly to a reasonless guest when there is no refresh token', async () => { + const apis = createApis() + + renderSession(apis) + + await expectState('guest::') + expect(apis.refresh).not.toHaveBeenCalled() + }) + + it('deduplicates StrictMode bootstrap and restores tokens from persisted refresh state', async () => { + const bootstrap = deferred() + const apis = createApis() + apis.refresh.mockReturnValue(bootstrap.promise) + window.localStorage.setItem(REFRESH_TOKEN_STORAGE_KEY, 'stored-refresh-token') + + const view = renderSession(apis, (children) => {children}) + + await waitFor(() => expect(apis.refresh).toHaveBeenCalledTimes(1)) + expect(apis.refresh).toHaveBeenCalledWith('stored-refresh-token') + await act(async () => bootstrap.resolve(tokens('rotated-refresh-token', 'restored-access'))) + + await expectState('authenticated::reader@example.com') + expect(window.localStorage.getItem(REFRESH_TOKEN_STORAGE_KEY)).toBe('rotated-refresh-token') + expect(getApiAccessToken()).toBe('restored-access') + + view.unmount() + expect(getApiAccessToken()).toBeUndefined() + }) + + it.each(['register', 'login', 'loginByCode'] as const)( + '%s applies the complete returned session', + async (method) => { + const apis = createApis() + apis[method].mockResolvedValue(tokens(`${method}-refresh`, `${method}-access`)) + renderSession(apis) + await expectState('guest::') + + await act(async () => { + if (method === 'register') { + await session().register({ + email: 'reader@example.com', + password: 'password-123', + code: '123456', + }) + } else if (method === 'login') { + await session().login({ + email: 'reader@example.com', + password: 'password-123', + code: '123456', + }) + } else { + await session().loginByCode({ email: 'reader@example.com', code: '123456' }) + } + }) + + await expectState('authenticated::reader@example.com') + expect(getApiAccessToken()).toBe(`${method}-access`) + expect(window.localStorage.getItem(REFRESH_TOKEN_STORAGE_KEY)).toBe(`${method}-refresh`) + }, + ) + + it('clears local state before best-effort logout finishes and never restores it on failure', async () => { + const logout = deferred() + const apis = createApis() + apis.logout.mockReturnValue(logout.promise) + renderSession(apis) + await expectState('guest::') + await act(async () => session().loginByCode({ email: 'reader@example.com', code: '123456' })) + + let logoutPromise!: Promise + act(() => { + logoutPromise = session().logout() + }) + await expectState('guest::') + expect(getApiAccessToken()).toBeNull() + expect(window.localStorage.getItem(REFRESH_TOKEN_STORAGE_KEY)).toBeNull() + expect(apis.logout).toHaveBeenCalledWith('refresh-token') + + const rejectedLogout = expect(logoutPromise).rejects.toThrow('network unavailable') + await act(async () => logout.reject(new Error('network unavailable'))) + await rejectedLogout + await expectState('guest::') + }) + + it('clears the session with a password-changed reason after changing the password', async () => { + const apis = createApis() + renderSession(apis) + await expectState('guest::') + await act(async () => session().loginByCode({ email: 'reader@example.com', code: '123456' })) + + await act(async () => + session().changePassword({ oldPassword: 'password-123', newPassword: 'new-password-123' }), + ) + + await expectState('guest:password-changed:') + expect(getApiAccessToken()).toBeNull() + expect(window.localStorage.getItem(REFRESH_TOKEN_STORAGE_KEY)).toBeNull() + }) + + it('deduplicates concurrent 401 recovery and lets both requests replay with the rotated token', async () => { + const refresh = deferred() + const apis = createApis() + apis.refresh.mockReturnValue(refresh.promise) + renderSession(apis) + await expectState('guest::') + await act(async () => session().loginByCode({ email: 'reader@example.com', code: '123456' })) + + const authorizations: (string | null)[] = [] + const client = createApiClient({ + baseUrl: 'https://api.windup.test', + getAccessToken: getApiAccessToken, + fetchFn: async (input, init) => { + const authorization = new Request(input, init).headers.get('authorization') + authorizations.push(authorization) + const payload = + authorization === 'Bearer renewed-access' + ? { code: 200, message: 'success', data: { ok: true } } + : { code: 401, message: '登录状态已过期', data: null } + return new Response(JSON.stringify(payload), { status: 200 }) + }, + }) + + const first = client.request('/first') + const second = client.request('/second') + await waitFor(() => expect(apis.refresh).toHaveBeenCalledTimes(1)) + await act(async () => refresh.resolve(tokens('renewed-refresh', 'renewed-access'))) + + await expect(Promise.all([first, second])).resolves.toEqual([{ ok: true }, { ok: true }]) + expect(authorizations).toEqual([ + 'Bearer access-token', + 'Bearer access-token', + 'Bearer renewed-access', + 'Bearer renewed-access', + ]) + await expectState('authenticated::reader@example.com') + }) + + it('marks the session expired when reactive recovery cannot refresh', async () => { + const apis = createApis() + apis.refresh.mockRejectedValue(new Error('refresh rejected')) + renderSession(apis) + await expectState('guest::') + await act(async () => session().loginByCode({ email: 'reader@example.com', code: '123456' })) + const client = createApiClient({ + baseUrl: 'https://api.windup.test', + fetchFn: async () => + new Response(JSON.stringify({ code: 401, message: 'expired', data: null }), { + status: 200, + }), + }) + + await expect(client.request('/resources')).rejects.toMatchObject({ code: 401 }) + + await expectState('guest:session-expired:') + }) + + it('refreshes a JWT sixty seconds before expiry', async () => { + vi.useFakeTimers() + vi.setSystemTime(2_000_000_000_000) + const exp = Date.now() / 1_000 + 120 + const apis = createApis() + apis.refresh + .mockResolvedValueOnce(tokens('boot-rotated', jwt(exp))) + .mockResolvedValueOnce(tokens('scheduled-rotated', jwt(exp + 900))) + window.localStorage.setItem(REFRESH_TOKEN_STORAGE_KEY, 'stored-refresh') + + renderSession(apis) + await act(async () => Promise.resolve()) + expect(apis.refresh).toHaveBeenCalledTimes(1) + + await act(async () => vi.advanceTimersByTimeAsync(60_000)) + + expect(apis.refresh).toHaveBeenCalledTimes(2) + expect(apis.refresh).toHaveBeenLastCalledWith('boot-rotated') + expect(window.localStorage.getItem(REFRESH_TOKEN_STORAGE_KEY)).toBe('scheduled-rotated') + }) + + it('adopts cross-tab rotation without refreshing an already authenticated tab', async () => { + const apis = createApis() + renderSession(apis) + await expectState('guest::') + await act(async () => session().loginByCode({ email: 'reader@example.com', code: '123456' })) + + window.localStorage.setItem(REFRESH_TOKEN_STORAGE_KEY, 'other-tab-refresh') + act(() => + window.dispatchEvent( + new StorageEvent('storage', { + key: REFRESH_TOKEN_STORAGE_KEY, + oldValue: 'refresh-token', + newValue: 'other-tab-refresh', + storageArea: window.localStorage, + }), + ), + ) + + expect(apis.refresh).not.toHaveBeenCalled() + await act(async () => session().logout()) + expect(apis.logout).toHaveBeenCalledWith('other-tab-refresh') + }) + + it('refreshes a cross-tab login for a guest and clears on cross-tab logout without rewriting storage', async () => { + const apis = createApis() + apis.refresh.mockResolvedValue(tokens('rotated-by-this-tab', 'cross-tab-access')) + const removeItem = vi.spyOn(Storage.prototype, 'removeItem') + renderSession(apis) + await expectState('guest::') + + window.localStorage.setItem(REFRESH_TOKEN_STORAGE_KEY, 'other-tab-login') + act(() => + window.dispatchEvent( + new StorageEvent('storage', { + key: REFRESH_TOKEN_STORAGE_KEY, + newValue: 'other-tab-login', + storageArea: window.localStorage, + }), + ), + ) + await expectState('authenticated::reader@example.com') + expect(apis.refresh).toHaveBeenCalledWith('other-tab-login') + + removeItem.mockClear() + act(() => + window.dispatchEvent( + new StorageEvent('storage', { + key: REFRESH_TOKEN_STORAGE_KEY, + oldValue: 'rotated-by-this-tab', + newValue: null, + storageArea: window.localStorage, + }), + ), + ) + + await expectState('guest::') + expect(removeItem).not.toHaveBeenCalled() + removeItem.mockRestore() + }) + + it('adopts a newer cross-tab token when an old-token bootstrap loses the rotation race', async () => { + const oldRefresh = deferred() + const apis = createApis() + apis.refresh.mockImplementation((refreshToken: string) => { + if (refreshToken === 'old-refresh') return oldRefresh.promise + return Promise.resolve(tokens('winner-rotated', 'winner-access')) + }) + window.localStorage.setItem(REFRESH_TOKEN_STORAGE_KEY, 'old-refresh') + renderSession(apis) + await waitFor(() => expect(apis.refresh).toHaveBeenCalledWith('old-refresh')) + + window.localStorage.setItem(REFRESH_TOKEN_STORAGE_KEY, 'winner-refresh') + act(() => + window.dispatchEvent( + new StorageEvent('storage', { + key: REFRESH_TOKEN_STORAGE_KEY, + oldValue: 'old-refresh', + newValue: 'winner-refresh', + storageArea: window.localStorage, + }), + ), + ) + await act(async () => oldRefresh.reject(new Error('old token revoked'))) + + await expectState('authenticated::reader@example.com') + expect(apis.refresh).toHaveBeenCalledWith('winner-refresh') + expect(window.localStorage.getItem(REFRESH_TOKEN_STORAGE_KEY)).toBe('winner-rotated') + expect(getApiAccessToken()).toBe('winner-access') + }) + + it('does not rotate a new local login token when an older recovery finishes late', async () => { + const oldRefresh = deferred() + const apis = createApis() + apis.loginByCode + .mockResolvedValueOnce(tokens('old-refresh', 'old-access')) + .mockResolvedValueOnce(tokens('new-login-refresh', 'new-login-access')) + apis.refresh.mockImplementation((refreshToken: string) => { + if (refreshToken === 'old-refresh') return oldRefresh.promise + return Promise.resolve(tokens('unexpected-rotation', 'unexpected-access')) + }) + renderSession(apis) + await expectState('guest::') + await act(async () => session().loginByCode({ email: 'reader@example.com', code: '123456' })) + const client = createApiClient({ + baseUrl: 'https://api.windup.test', + fetchFn: async () => + new Response(JSON.stringify({ code: 401, message: 'expired', data: null }), { + status: 200, + }), + }) + + const request = client.request('/resources') + const rejectedRequest = expect(request).rejects.toMatchObject({ code: 401 }) + await waitFor(() => expect(apis.refresh).toHaveBeenCalledWith('old-refresh')) + await act(async () => session().loginByCode({ email: 'reader@example.com', code: '123456' })) + await act(async () => oldRefresh.resolve(tokens('old-rotated', 'old-rotated-access'))) + + await rejectedRequest + expect(apis.refresh.mock.calls).toEqual([['old-refresh']]) + expect(getApiAccessToken()).toBe('new-login-access') + expect(window.localStorage.getItem(REFRESH_TOKEN_STORAGE_KEY)).toBe('new-login-refresh') + }) + + it('deduplicates the same token across an overlapping A-B-A refresh sequence', async () => { + const refreshA = deferred() + const refreshB = deferred() + const apis = createApis() + apis.refresh.mockImplementation((refreshToken: string) => + refreshToken === 'refresh-a' ? refreshA.promise : refreshB.promise, + ) + window.localStorage.setItem(REFRESH_TOKEN_STORAGE_KEY, 'refresh-a') + renderSession(apis) + await waitFor(() => expect(apis.refresh).toHaveBeenCalledWith('refresh-a')) + + window.localStorage.setItem(REFRESH_TOKEN_STORAGE_KEY, 'refresh-b') + act(() => + window.dispatchEvent( + new StorageEvent('storage', { + key: REFRESH_TOKEN_STORAGE_KEY, + oldValue: 'refresh-a', + newValue: 'refresh-b', + storageArea: window.localStorage, + }), + ), + ) + await waitFor(() => expect(apis.refresh).toHaveBeenCalledWith('refresh-b')) + + window.localStorage.setItem(REFRESH_TOKEN_STORAGE_KEY, 'refresh-a') + act(() => + window.dispatchEvent( + new StorageEvent('storage', { + key: REFRESH_TOKEN_STORAGE_KEY, + oldValue: 'refresh-b', + newValue: 'refresh-a', + storageArea: window.localStorage, + }), + ), + ) + + expect(apis.refresh.mock.calls.filter(([value]) => value === 'refresh-a')).toHaveLength(1) + await act(async () => refreshA.resolve(tokens('refresh-a-rotated', 'access-a'))) + await act(async () => refreshB.resolve(tokens('refresh-b-rotated', 'access-b'))) + await expectState('authenticated::reader@example.com') + expect(getApiAccessToken()).toBe('access-a') + }) + + it('does not write tokens when a pending unauthorized recovery finishes after unmount', async () => { + const refresh = deferred() + const apis = createApis() + apis.refresh.mockReturnValue(refresh.promise) + const view = renderSession(apis) + await expectState('guest::') + await act(async () => session().loginByCode({ email: 'reader@example.com', code: '123456' })) + const client = createApiClient({ + baseUrl: 'https://api.windup.test', + fetchFn: async () => + new Response(JSON.stringify({ code: 401, message: 'expired', data: null }), { + status: 200, + }), + }) + + const request = client.request('/resources') + const rejectedRequest = expect(request).rejects.toMatchObject({ code: 401 }) + await waitFor(() => expect(apis.refresh).toHaveBeenCalledWith('refresh-token')) + view.unmount() + await act(async () => refresh.resolve(tokens('late-rotated', 'late-access'))) + + await rejectedRequest + expect(window.localStorage.getItem(REFRESH_TOKEN_STORAGE_KEY)).toBe('refresh-token') + }) + + it('does not write tokens when a pending storage refresh finishes after unmount', async () => { + const refresh = deferred() + const apis = createApis() + apis.refresh.mockReturnValue(refresh.promise) + const view = renderSession(apis) + await expectState('guest::') + + window.localStorage.setItem(REFRESH_TOKEN_STORAGE_KEY, 'other-tab-refresh') + act(() => + window.dispatchEvent( + new StorageEvent('storage', { + key: REFRESH_TOKEN_STORAGE_KEY, + newValue: 'other-tab-refresh', + storageArea: window.localStorage, + }), + ), + ) + await waitFor(() => expect(apis.refresh).toHaveBeenCalledWith('other-tab-refresh')) + view.unmount() + await act(async () => refresh.resolve(tokens('late-rotated', 'late-access'))) + + expect(window.localStorage.getItem(REFRESH_TOKEN_STORAGE_KEY)).toBe('other-tab-refresh') + }) + + it('unregisters unauthorized recovery when the provider unmounts', async () => { + const apis = createApis() + const view = renderSession(apis) + await expectState('guest::') + view.unmount() + const fallbackRecovery = vi.fn(async () => false) + const unregister = registerApiUnauthorizedRecovery(fallbackRecovery) + const client = createApiClient({ + baseUrl: 'https://api.windup.test', + fetchFn: async () => + new Response(JSON.stringify({ code: 401, message: 'expired', data: null }), { + status: 200, + }), + }) + + await expect(client.request('/resources')).rejects.toMatchObject({ code: 401 }) + expect(fallbackRecovery).toHaveBeenCalledTimes(1) + expect(apis.refresh).not.toHaveBeenCalled() + unregister() + }) +}) diff --git a/frontend/src/features/auth-session/index.tsx b/frontend/src/features/auth-session/index.tsx new file mode 100644 index 00000000..7ee93fd5 --- /dev/null +++ b/frontend/src/features/auth-session/index.tsx @@ -0,0 +1,360 @@ +/* oxlint-disable react/only-export-components -- Provider 与 hook 构成同一个公开会话边界。 */ +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, + type ReactNode, +} from 'react' + +import type { AuthTokens, User, UserApis } from '@/entities' +import { registerApiAccessTokenProvider, registerApiUnauthorizedRecovery } from '@/shared/api' +import { + REFRESH_TOKEN_STORAGE_KEY, + clearRefreshToken, + loadRefreshToken, + saveRefreshToken, +} from './session-storage' + +export type AuthGuestReason = null | 'session-expired' | 'password-changed' + +export type AuthSessionState = + | { status: 'booting'; user: null } + | { status: 'guest'; user: null; reason: AuthGuestReason } + | { status: 'authenticated'; user: User } + +export interface AuthSessionValue { + state: AuthSessionState + sendCode(input: Parameters[0]): Promise + register(input: Parameters[0]): Promise + login(input: Parameters[0]): Promise + loginByCode(input: Parameters[0]): Promise + changePassword(input: Parameters[0]): Promise + logout(): Promise +} + +export interface AuthSessionProviderProps { + apis: UserApis + children: ReactNode +} + +const AuthSessionContext = createContext(null) + +export function AuthSessionProvider({ apis, children }: AuthSessionProviderProps) { + const initialState: AuthSessionState = { status: 'booting', user: null } + const [state, setState] = useState(initialState) + const [accessTokenVersion, setAccessTokenVersion] = useState(0) + const stateRef = useRef(initialState) + const accessTokenRef = useRef(null) + const refreshTokenRef = useRef(null) + const generationRef = useRef(0) + const mountedRef = useRef(true) + const refreshInFlightRef = useRef(new Map>()) + const recoveryInFlightRef = useRef | null>(null) + const bootstrapPromiseRef = useRef | null>(null) + + const updateState = useCallback((next: AuthSessionState) => { + stateRef.current = next + setState(next) + }, []) + + const storeTokens = useCallback((tokens: AuthTokens) => { + accessTokenRef.current = tokens.accessToken + refreshTokenRef.current = tokens.refreshToken + saveRefreshToken(tokens.refreshToken) + setAccessTokenVersion((version) => version + 1) + }, []) + + const commitRefresh = useCallback( + (tokens: AuthTokens, expectedGeneration: number): boolean => { + if (!mountedRef.current || generationRef.current !== expectedGeneration) return false + storeTokens(tokens) + updateState({ status: 'authenticated', user: tokens.user }) + return true + }, + [storeTokens, updateState], + ) + + const startSession = useCallback( + (tokens: AuthTokens) => { + generationRef.current += 1 + recoveryInFlightRef.current = null + storeTokens(tokens) + updateState({ status: 'authenticated', user: tokens.user }) + }, + [storeTokens, updateState], + ) + + const clearSession = useCallback( + (reason: AuthGuestReason, persist = true) => { + generationRef.current += 1 + recoveryInFlightRef.current = null + accessTokenRef.current = null + refreshTokenRef.current = null + if (persist) clearRefreshToken() + setAccessTokenVersion((version) => version + 1) + updateState({ status: 'guest', user: null, reason }) + }, + [updateState], + ) + + const rotateTokens = useCallback( + (refreshToken: string): Promise => { + const inFlight = refreshInFlightRef.current.get(refreshToken) + if (inFlight) return inFlight + + const promise = apis.refresh(refreshToken) + refreshInFlightRef.current.set(refreshToken, promise) + const release = () => { + if (refreshInFlightRef.current.get(refreshToken) === promise) + refreshInFlightRef.current.delete(refreshToken) + } + void promise.then(release, release) + return promise + }, + [apis], + ) + + /** 若旧 token 输掉跨标签轮换竞态,优先跟随胜出的 token,不能清掉新会话。 */ + const rotateLatestTokens = useCallback( + async (attemptedToken: string, expectedGeneration: number): Promise => { + try { + const tokens = await rotateTokens(attemptedToken) + if (!mountedRef.current || generationRef.current !== expectedGeneration) return null + const newerToken = refreshTokenRef.current + if (newerToken && newerToken !== attemptedToken) { + const newerTokens = await rotateTokens(newerToken) + return mountedRef.current && generationRef.current === expectedGeneration + ? newerTokens + : null + } + return tokens + } catch (error) { + if (!mountedRef.current || generationRef.current !== expectedGeneration) return null + const memoryToken = refreshTokenRef.current + const storedToken = loadRefreshToken() + const newerToken = + memoryToken && memoryToken !== attemptedToken + ? memoryToken + : storedToken && storedToken !== attemptedToken + ? storedToken + : null + if (!newerToken) throw error + refreshTokenRef.current = newerToken + const newerTokens = await rotateTokens(newerToken) + return mountedRef.current && generationRef.current === expectedGeneration + ? newerTokens + : null + } + }, + [rotateTokens], + ) + + const recoverUnauthorized = useCallback((): Promise => { + if (recoveryInFlightRef.current) return recoveryInFlightRef.current + const refreshToken = refreshTokenRef.current + if (!refreshToken) return Promise.resolve(false) + const generation = generationRef.current + + const promise = rotateLatestTokens(refreshToken, generation).then( + (tokens) => (tokens ? commitRefresh(tokens, generation) : false), + () => { + if (mountedRef.current && generationRef.current === generation) + clearSession('session-expired') + return false + }, + ) + recoveryInFlightRef.current = promise + const release = () => { + if (recoveryInFlightRef.current === promise) recoveryInFlightRef.current = null + } + void promise.then(release, release) + return promise + }, [clearSession, commitRefresh, rotateLatestTokens]) + + useEffect(() => { + mountedRef.current = true + return () => { + mountedRef.current = false + } + }, []) + useEffect(() => registerApiAccessTokenProvider(() => accessTokenRef.current), []) + useEffect(() => registerApiUnauthorizedRecovery(recoverUnauthorized), [recoverUnauthorized]) + + useEffect(() => { + let active = true + const generation = generationRef.current + + if (!bootstrapPromiseRef.current) { + const refreshToken = loadRefreshToken() + refreshTokenRef.current = refreshToken + bootstrapPromiseRef.current = refreshToken + ? rotateLatestTokens(refreshToken, generation) + : Promise.resolve(null) + } + + void bootstrapPromiseRef.current.then( + (tokens) => { + if (!active || generationRef.current !== generation) return + if (tokens) commitRefresh(tokens, generation) + else clearSession(null) + }, + () => { + if (active && generationRef.current === generation) clearSession('session-expired') + }, + ) + + return () => { + active = false + } + }, [clearSession, commitRefresh, rotateLatestTokens]) + + useEffect(() => { + const onStorage = (event: StorageEvent) => { + if (event.key !== REFRESH_TOKEN_STORAGE_KEY) return + if (event.newValue === null) { + clearSession(null, false) + return + } + if (event.newValue === refreshTokenRef.current) return + + refreshTokenRef.current = event.newValue + if (stateRef.current.status === 'authenticated') return + + const generation = ++generationRef.current + recoveryInFlightRef.current = null + accessTokenRef.current = null + updateState({ status: 'booting', user: null }) + void rotateLatestTokens(event.newValue, generation).then( + (tokens) => { + if (tokens) commitRefresh(tokens, generation) + }, + () => { + if (mountedRef.current && generationRef.current === generation) + clearSession('session-expired') + }, + ) + } + + window.addEventListener('storage', onStorage) + return () => window.removeEventListener('storage', onStorage) + }, [clearSession, commitRefresh, rotateLatestTokens, updateState]) + + useEffect(() => { + const refreshAt = getRefreshTime(accessTokenRef.current) + if (refreshAt === null) return + let cancelled = false + let timer: ReturnType | undefined + + const schedule = () => { + const delay = Math.max(0, refreshAt - Date.now()) + timer = setTimeout( + () => { + if (cancelled) return + if (Date.now() < refreshAt) { + schedule() + return + } + const refreshToken = refreshTokenRef.current + if (!refreshToken) return + const generation = generationRef.current + void rotateLatestTokens(refreshToken, generation).then( + (tokens) => { + if (!cancelled && tokens) commitRefresh(tokens, generation) + }, + () => { + if (!cancelled && generationRef.current === generation) + clearSession('session-expired') + }, + ) + }, + Math.min(delay, 2_147_483_647), + ) + } + + schedule() + return () => { + cancelled = true + if (timer !== undefined) clearTimeout(timer) + } + }, [accessTokenVersion, clearSession, commitRefresh, rotateLatestTokens]) + + const sendCode = useCallback( + (input: Parameters[0]) => apis.sendCode(input), + [apis], + ) + const register = useCallback( + async (input: Parameters[0]) => { + const tokens = await apis.register(input) + startSession(tokens) + return tokens + }, + [apis, startSession], + ) + const login = useCallback( + async (input: Parameters[0]) => { + const tokens = await apis.login(input) + startSession(tokens) + return tokens + }, + [apis, startSession], + ) + const loginByCode = useCallback( + async (input: Parameters[0]) => { + const tokens = await apis.loginByCode(input) + startSession(tokens) + return tokens + }, + [apis, startSession], + ) + const changePassword = useCallback( + async (input: Parameters[0]) => { + await apis.changePassword(input) + clearSession('password-changed') + }, + [apis, clearSession], + ) + const logout = useCallback(async () => { + const refreshToken = refreshTokenRef.current + clearSession(null) + if (refreshToken) await apis.logout(refreshToken) + }, [apis, clearSession]) + + const value = useMemo( + () => ({ state, sendCode, register, login, loginByCode, changePassword, logout }), + [changePassword, login, loginByCode, logout, register, sendCode, state], + ) + + return {children} +} + +export function useAuthSession(): AuthSessionValue { + const session = useContext(AuthSessionContext) + if (!session) throw new Error('useAuthSession 必须在 AuthSessionProvider 内使用') + return session +} + +function getRefreshTime(accessToken: string | null): number | null { + const payload = accessToken?.split('.')[1] + if (!payload) return null + try { + const base64 = payload.replaceAll('-', '+').replaceAll('_', '/') + const padded = base64.padEnd(Math.ceil(base64.length / 4) * 4, '=') + const value: unknown = JSON.parse(globalThis.atob(padded)) + if ( + typeof value !== 'object' || + value === null || + !('exp' in value) || + typeof value.exp !== 'number' || + !Number.isFinite(value.exp) + ) { + return null + } + return value.exp * 1_000 - 60_000 + } catch { + return null + } +} From b1c814f179e49148b410e0db51df9a51d86c0854 Mon Sep 17 00:00:00 2001 From: huyan Date: Fri, 7 Aug 2026 00:41:20 +0800 Subject: [PATCH 6/7] feat(auth-session): mount the session provider The application root needs the real backend authentication session. Wrap App with AuthSessionProvider and inject the lazy user API instance. Runtime requests now share the mounted access-token and recovery lifecycle. --- frontend/src/main.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index ff3b9104..57f7649c 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -2,10 +2,14 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import { App } from '@/app' +import { userApis } from '@/entities' +import { AuthSessionProvider } from '@/features/auth-session' import './index.css' createRoot(document.getElementById('root')!).render( - + + + , ) From beccca2efa7e8539b24d11f792ede82eb43f61da Mon Sep 17 00:00:00 2001 From: huyan Date: Fri, 7 Aug 2026 10:57:04 +0800 Subject: [PATCH 7/7] fix(auth-session): keep a refreshed session when the newer token fails The rotation follow-up compared candidate tokens against the token the call started with, so a failure while following a newer cross-tab token fell through to the outer handler, which read that same failed token as untried and refreshed with it a second time. Track the tokens tried within a call, and fall back to the pair the successful refresh already produced instead of discarding it. Following a rejected newer token no longer signs the user out while a usable session is in hand. Refs #158 --- .../src/features/auth-session/index.test.tsx | 47 +++++++++++++++++++ frontend/src/features/auth-session/index.tsx | 43 +++++++++-------- 2 files changed, 71 insertions(+), 19 deletions(-) diff --git a/frontend/src/features/auth-session/index.test.tsx b/frontend/src/features/auth-session/index.test.tsx index bfc39cbd..76d4ddd3 100644 --- a/frontend/src/features/auth-session/index.test.tsx +++ b/frontend/src/features/auth-session/index.test.tsx @@ -252,6 +252,53 @@ describe('AuthSessionProvider', () => { await expectState('guest:session-expired:') }) + it('keeps the session it just obtained when the newer cross-tab token is rejected', async () => { + const rotation = deferred() + const apis = createApis() + apis.loginByCode.mockResolvedValue(tokens('original-refresh', 'original-access')) + apis.refresh.mockImplementation(async (refreshToken: string) => { + if (refreshToken === 'original-refresh') return rotation.promise + throw new Error(`refresh rejected for ${refreshToken}`) + }) + renderSession(apis) + await expectState('guest::') + await act(async () => session().loginByCode({ email: 'reader@example.com', code: '123456' })) + + const client = createApiClient({ + baseUrl: 'https://api.windup.test', + getAccessToken: getApiAccessToken, + fetchFn: async (input, init) => { + const authorization = new Request(input, init).headers.get('authorization') + const payload = + authorization === 'Bearer rotated-access' + ? { code: 200, message: 'success', data: { ok: true } } + : { code: 401, message: '登录状态已过期', data: null } + return new Response(JSON.stringify(payload), { status: 200 }) + }, + }) + const replayed = client.request('/resources') + await waitFor(() => expect(apis.refresh).toHaveBeenCalledWith('original-refresh')) + + // 另一个标签页在本次续期途中写入了新的 refresh token,本页已登录,只吸收不重走认证。 + await act(async () => { + window.dispatchEvent( + new StorageEvent('storage', { + key: REFRESH_TOKEN_STORAGE_KEY, + newValue: 'revoked-refresh', + oldValue: 'original-refresh', + }), + ) + }) + await act(async () => rotation.resolve(tokens('rotated-refresh', 'rotated-access'))) + + await expect(replayed).resolves.toEqual({ ok: true }) + // 更新的 token 只试一次,且失败不该牵连本次已经换到手的这套。 + expect(apis.refresh.mock.calls.flat()).toEqual(['original-refresh', 'revoked-refresh']) + await expectState('authenticated::reader@example.com') + expect(getApiAccessToken()).toBe('rotated-access') + expect(window.localStorage.getItem(REFRESH_TOKEN_STORAGE_KEY)).toBe('rotated-refresh') + }) + it('refreshes a JWT sixty seconds before expiry', async () => { vi.useFakeTimers() vi.setSystemTime(2_000_000_000_000) diff --git a/frontend/src/features/auth-session/index.tsx b/frontend/src/features/auth-session/index.tsx index 7ee93fd5..b1c1d6f4 100644 --- a/frontend/src/features/auth-session/index.tsx +++ b/frontend/src/features/auth-session/index.tsx @@ -121,33 +121,38 @@ export function AuthSessionProvider({ apis, children }: AuthSessionProviderProps /** 若旧 token 输掉跨标签轮换竞态,优先跟随胜出的 token,不能清掉新会话。 */ const rotateLatestTokens = useCallback( async (attemptedToken: string, expectedGeneration: number): Promise => { + /** 记下本次已经换过的 token,避免把刚失败的那个当成没试过的又试一遍。 */ + const attempted = new Set([attemptedToken]) + const isCurrent = () => mountedRef.current && generationRef.current === expectedGeneration + const takeUntriedToken = (): string | null => { + const memoryToken = refreshTokenRef.current + if (memoryToken && !attempted.has(memoryToken)) return memoryToken + const storedToken = loadRefreshToken() + if (storedToken && !attempted.has(storedToken)) return storedToken + return null + } + try { const tokens = await rotateTokens(attemptedToken) - if (!mountedRef.current || generationRef.current !== expectedGeneration) return null - const newerToken = refreshTokenRef.current - if (newerToken && newerToken !== attemptedToken) { + if (!isCurrent()) return null + const newerToken = takeUntriedToken() + if (!newerToken) return tokens + attempted.add(newerToken) + try { const newerTokens = await rotateTokens(newerToken) - return mountedRef.current && generationRef.current === expectedGeneration - ? newerTokens - : null + return isCurrent() ? newerTokens : null + } catch { + // 更新的 token 用不了,不代表本次换到手的这套也用不了,退回它而不是一起丢掉。 + return isCurrent() ? tokens : null } - return tokens } catch (error) { - if (!mountedRef.current || generationRef.current !== expectedGeneration) return null - const memoryToken = refreshTokenRef.current - const storedToken = loadRefreshToken() - const newerToken = - memoryToken && memoryToken !== attemptedToken - ? memoryToken - : storedToken && storedToken !== attemptedToken - ? storedToken - : null + if (!isCurrent()) return null + const newerToken = takeUntriedToken() if (!newerToken) throw error + attempted.add(newerToken) refreshTokenRef.current = newerToken const newerTokens = await rotateTokens(newerToken) - return mountedRef.current && generationRef.current === expectedGeneration - ? newerTokens - : null + return isCurrent() ? newerTokens : null } }, [rotateTokens],