diff --git a/apps/web/src/bootstrap.spec.ts b/apps/web/src/bootstrap.spec.ts index 0423fdb..e357ff9 100644 --- a/apps/web/src/bootstrap.spec.ts +++ b/apps/web/src/bootstrap.spec.ts @@ -135,10 +135,35 @@ describe('bootstrap bootstrapSession', () => { expect(spy).toHaveBeenCalledOnce() }) - it('resolves without throwing on a cold load (no session to restore)', async () => { + it('resolves without throwing on a cold load with no valid cookie (refresh fails)', async () => { const { bootstrapSession } = await import('./bootstrap') - // No setSession → real refresh() short-circuits to null with no HTTP call. + const auth = useAuthStore() + // No httpOnly cookie / logged out → refresh resolves null and clears state. + vi.spyOn(auth, 'refresh').mockResolvedValue(null) + await expect(bootstrapSession()).resolves.toBeUndefined() - expect(useAuthStore().isAuthenticated).toBe(false) + expect(auth.isAuthenticated).toBe(false) + }) + + it('restores the session before mount when refresh succeeds via cookie (warm cold-load)', async () => { + const { bootstrapSession } = await import('./bootstrap') + const auth = useAuthStore() + // A valid httpOnly cookie → refresh writes the restored session. + vi.spyOn(auth, 'refresh').mockImplementation(async () => { + auth.setSession({ + userId: 'u1', + accessToken: 'restored-tok', + refreshToken: 'rt2', + passwordResetRequired: false, + expiresAt: '2099-01-01T00:00:00Z', + sessionId: 's2', + }) + return 'restored-tok' + }) + + await bootstrapSession() + + expect(auth.isAuthenticated).toBe(true) + expect(auth.accessToken).toBe('restored-tok') }) }) diff --git a/apps/web/src/bootstrap.ts b/apps/web/src/bootstrap.ts index 6a52589..d784332 100644 --- a/apps/web/src/bootstrap.ts +++ b/apps/web/src/bootstrap.ts @@ -44,11 +44,17 @@ let bootstrapPromise: Promise | null = null * Attempt to restore a session on app start via a single refresh, before the * first route guard evaluates auth. * - * Idempotent. With the current body-based, in-memory refresh token (no httpOnly - * cookie yet — see issue #12 H2), a cold load has no token so authStore.refresh() - * short-circuits to null and this is a silent no-op; the guards then route - * protected pages to /login. The seam is in place for when the backend adds a - * refresh cookie that survives reload. + * Idempotent. The backend ships the refresh token as an httpOnly cookie + * (`__Host-gocell_rt`, BR-005) that survives a reload, so authStore.refresh() + * renews silently from the cookie even when nothing is in memory: a warm cold + * load restores the session (guards let protected pages through), while a logged + * out / no-cookie load fails the refresh and the guards route to /login. Because + * main.ts awaits this before app.mount(), the first guard runs post-restore — + * no /login flash. (See issue #12 H2.) + * + * auth.refresh() swallows its own errors and is timeout-bounded, so this Promise + * never rejects: main.ts mounts in `.finally()` without an unhandled rejection, + * and a dead/slow backend still lets the app mount and route to /login. * * Must be called AFTER createPinia() so useAuthStore() resolves. */ diff --git a/apps/web/src/main.ts b/apps/web/src/main.ts index 62eb509..d229a18 100644 --- a/apps/web/src/main.ts +++ b/apps/web/src/main.ts @@ -37,6 +37,7 @@ registerGuards(router, app, pdpClient, (reasonCode: string) => { useUiStore().notifyAccessDenied(reasonCode) }) -// 6. Attempt silent session restore before the first navigation, then mount. -// No-op on a cold load until the backend ships a refresh cookie (#12 H2). +// 6. Silent session restore via the httpOnly refresh cookie before the first +// navigation, then mount. Awaiting before mount keeps the cold-start renewal +// ahead of the first guard, so a reload never flashes /login (#12 H2 / #27). void bootstrapSession().finally(() => app.mount('#app')) diff --git a/e2e/auth.spec.ts b/e2e/auth.spec.ts index cdc8257..6394d82 100644 --- a/e2e/auth.spec.ts +++ b/e2e/auth.spec.ts @@ -11,11 +11,25 @@ import { stubHealthEndpoints } from './helpers' const STATUS_URL = '**/api/v1/access/setup/status' const ADMIN_URL = '**/api/v1/access/setup/admin' const LOGIN_URL = '**/api/v1/access/sessions/login' +const REFRESH_URL = '**/api/v1/access/sessions/refresh' async function stubSetupStatus(page: Page, hasAdmin: boolean): Promise { await page.route(STATUS_URL, (route) => route.fulfill({ json: { data: { hasAdmin } } })) } +function sessionData(accessToken: string): { data: Record } { + return { + data: { + accessToken, + refreshToken: 'r', + expiresAt: '2099-01-01T00:00:00Z', + sessionId: 's1', + userId: 'u1', + passwordResetRequired: false, + }, + } +} + test.describe('Login', () => { test('/login 直接渲染独立登录页(不套 AppShell)', async ({ page }) => { // 受保护路由→/login 的重定向冒烟见 e2e/health.spec.ts(Batch 7 起 home 为 @@ -102,3 +116,63 @@ test.describe('First-run', () => { await expect(page).toHaveURL(/\/login$/) }) }) + +test.describe('Cold-start renewal (httpOnly cookie)', () => { + // #27 / #12 H2: a full-page reload wipes the in-memory token, but the httpOnly + // refresh cookie survives. main.ts awaits bootstrapSession() (a silent + // POST /sessions/refresh) before app.mount(), so the session is restored + // before the first guard runs — no /login flash. + // + // The real backend sets `__Host-gocell_rt` (HttpOnly; Secure; SameSite=Strict) + // and reads it on refresh. Over http://localhost the stub uses a plain HttpOnly + // cookie so the round-trip is deterministic regardless of the Secure / __Host- + // prefix constraints — the frontend never reads the cookie (it is httpOnly), so + // its name/attributes are immaterial to the frontend behaviour under test. The + // withCredentials wiring itself is asserted in useAuthStore.spec.ts. + + test('整页重载经 cookie 静默续期,会话不丢、停留受保护页', async ({ page }) => { + await stubSetupStatus(page, true) + await stubHealthEndpoints(page) + + await page.route(LOGIN_URL, (route) => + route.fulfill({ + status: 201, + headers: { 'set-cookie': 'gocell_rt=cookie-1; Path=/; HttpOnly; SameSite=Strict' }, + json: sessionData('a1'), + }), + ) + + // Refresh honours the cookie: present → renew (200), absent → reject (401). + await page.route(REFRESH_URL, (route) => { + const hasCookie = (route.request().headers()['cookie'] ?? '').includes('gocell_rt=') + return hasCookie + ? route.fulfill({ status: 200, json: sessionData('a2') }) + : route.fulfill({ status: 401, json: { error: { code: 'ERR_AUTH_REFRESH' } } }) + }) + + // 1) Log in → cookie set, land on protected '/'. + await page.goto('/login') + await page.fill('#login-username', 'admin') + await page.fill('#login-password', 'SecretPass!23') + await page.click('button[type="submit"]') + await expect(page).toHaveURL(/\/$/) + + // 2) Full-page reload — in-memory token gone, only the cookie remains. + await page.goto('/') + await expect(page).toHaveURL(/\/$/) + await expect(page.locator('nav').first()).toBeVisible() + }) + + test('无有效 cookie 时整页冷启动落 /login', async ({ page }) => { + await stubSetupStatus(page, true) + await stubHealthEndpoints(page) + // No login → no cookie → bootstrap refresh fails → auth guard bounces to /login. + await page.route(REFRESH_URL, (route) => + route.fulfill({ status: 401, json: { error: { code: 'ERR_AUTH_REFRESH' } } }), + ) + + await page.goto('/') + await expect(page).toHaveURL(/\/login(\?|$)/) + await expect(page.locator('#login-username')).toBeVisible() + }) +}) diff --git a/packages/access/README.md b/packages/access/README.md index c598429..616d6ff 100644 --- a/packages/access/README.md +++ b/packages/access/README.md @@ -52,10 +52,10 @@ contract 来源:`@gocell/contracts`(codegen 派生,只读)。 - **getter**:`isAuthenticated: boolean` - **actions**: - `setSession(payload)` / `clearSession()` - - `login({ username, password })`:POST `/sessions/login`(带 `__skipAuthRefresh`,401 不触发 refresh),成功 `setSession`,失败抛出(错误带 `i18nKey`);导航由调用方负责 - - `logout()`:best-effort `DELETE /sessions/{id}` + `clearSession()` - - `refresh(): Promise`:`@gocell/request` 的 `onRefresh` 回调 -- 安全铁律:access token / refresh token **仅内存**,绝不写 localStorage / sessionStorage。 + - `login({ username, password })`:POST `/sessions/login`(带 `__skipAuthRefresh` + `withCredentials`,401 不触发 refresh),成功 `setSession`,失败抛出(错误带 `i18nKey`);导航由调用方负责 + - `logout()`:best-effort `DELETE /sessions/{id}`(带 `withCredentials`,收后端清 cookie)+ `clearSession()` + - `refresh(): Promise`:`@gocell/request` 的 `onRefresh` 回调,同时是 `bootstrapSession()` 冷启动续期的引擎。cookie 优先(靠 httpOnly `__Host-gocell_rt`,BR-005),无内存 token 也发 `POST /sessions/refresh`(空 body)续期;内存 token 作 body 兜底 +- 安全铁律:access token / 内存 refresh token **仅内存**,绝不写 localStorage / sessionStorage。冷启动续期靠浏览器托管的 **httpOnly cookie**(JS 不可读),铁律不破。 ### `LoginView` / `FirstRunSetupView` (`./views/login` · `./views/first-run`) diff --git a/packages/access/src/stores/useAuthStore.spec.ts b/packages/access/src/stores/useAuthStore.spec.ts index 84999c4..310ff6a 100644 --- a/packages/access/src/stores/useAuthStore.spec.ts +++ b/packages/access/src/stores/useAuthStore.spec.ts @@ -117,14 +117,45 @@ describe('useAuthStore', () => { }) describe('refresh()', () => { - it('returns null when there is no refreshToken', async () => { + // Every refresh opts the browser into sending/receiving the httpOnly refresh + // cookie (`__Host-gocell_rt`) and is timeout-bounded so a hung backend cannot + // block app mount (bootstrapSession awaits refresh() before app.mount()). + const REFRESH_CONFIG = { withCredentials: true, timeout: 10_000 } + + it('cookie mode: with no in-memory token, posts an empty body and restores the session from the cookie', async () => { + const store = useAuthStore() + // Cold start: nothing in memory; the refresh token lives only in the + // browser's httpOnly cookie, which the backend reads server-side. + mockHttp.post.mockResolvedValueOnce({ data: { data: sessionPayload } }) + + const result = await store.refresh() + + expect(mockHttp.post).toHaveBeenCalledWith( + '/api/v1/access/sessions/refresh', + {}, + REFRESH_CONFIG, + ) + expect(result).toBe('access-tok-1') + expect(store.isAuthenticated).toBe(true) + expect(store.accessToken).toBe('access-tok-1') + }) + + it('cookie mode: clears session and returns null when refresh fails (no valid cookie)', async () => { const store = useAuthStore() + mockHttp.post.mockRejectedValueOnce(new Error('401')) + const result = await store.refresh() + + expect(mockHttp.post).toHaveBeenCalledWith( + '/api/v1/access/sessions/refresh', + {}, + REFRESH_CONFIG, + ) expect(result).toBeNull() - expect(mockHttp.post).not.toHaveBeenCalled() + expect(store.isAuthenticated).toBe(false) }) - it('calls http.post with refreshToken and returns new accessToken on success', async () => { + it('sends the in-memory refresh token as a body fallback and returns the new accessToken on success', async () => { const store = useAuthStore() store.setSession(sessionPayload) @@ -139,9 +170,11 @@ describe('useAuthStore', () => { const result = await store.refresh() // Validates that the internal refreshToken was correctly stored from setSession - expect(mockHttp.post).toHaveBeenCalledWith('/api/v1/access/sessions/refresh', { - refreshToken: 'refresh-tok-1', - }) + expect(mockHttp.post).toHaveBeenCalledWith( + '/api/v1/access/sessions/refresh', + { refreshToken: 'refresh-tok-1' }, + REFRESH_CONFIG, + ) expect(result).toBe('access-tok-new') expect(store.accessToken).toBe('access-tok-new') // refreshToken is internal; verify it works by doing a second refresh @@ -149,9 +182,11 @@ describe('useAuthStore', () => { data: { data: { ...newPayload, accessToken: 'access-tok-3' } }, }) await store.refresh() - expect(mockHttp.post).toHaveBeenLastCalledWith('/api/v1/access/sessions/refresh', { - refreshToken: 'refresh-tok-new', - }) + expect(mockHttp.post).toHaveBeenLastCalledWith( + '/api/v1/access/sessions/refresh', + { refreshToken: 'refresh-tok-new' }, + REFRESH_CONFIG, + ) }) it('clearSession and returns null when http.post rejects', async () => { @@ -201,6 +236,7 @@ describe('useAuthStore', () => { expect(mockHttp.post).toHaveBeenCalledWith('/api/v1/access/sessions/login', credentials, { __skipAuthRefresh: true, + withCredentials: true, }) }) @@ -242,7 +278,9 @@ describe('useAuthStore', () => { await store.logout() - expect(mockHttp.delete).toHaveBeenCalledWith('/api/v1/access/sessions/sess-1') + expect(mockHttp.delete).toHaveBeenCalledWith('/api/v1/access/sessions/sess-1', { + withCredentials: true, + }) expect(store.isAuthenticated).toBe(false) expect(store.user).toBeNull() }) diff --git a/packages/access/src/stores/useAuthStore.ts b/packages/access/src/stores/useAuthStore.ts index 0003658..04d6c49 100644 --- a/packages/access/src/stores/useAuthStore.ts +++ b/packages/access/src/stores/useAuthStore.ts @@ -4,6 +4,7 @@ import { http } from '@gocell/request' import type { HttpAuthLoginV1Request, HttpAuthLoginV1Response, + HttpAuthRefreshV1Request, HttpAuthRefreshV1Response, } from '@gocell/contracts' @@ -24,6 +25,12 @@ const REFRESH_URL = '/api/v1/access/sessions/refresh' /** DELETE /sessions/{id} — logout revokes the current session server-side. */ const SESSION_URL = '/api/v1/access/sessions/' +/** + * Cap the silent refresh so an unresponsive backend cannot block app mount + * forever — apps/web bootstrapSession() awaits refresh() before app.mount(). + */ +const REFRESH_TIMEOUT_MS = 10_000 + function decodeJwtPayload(token: string): Record | null { const payload = token.split('.')[1] if (!payload) return null @@ -89,10 +96,14 @@ export const useAuthStore = defineStore('access.auth', () => { * /login (see @gocell/request). On failure the error propagates (with its * i18nKey attached by the interceptor) for the caller to display; the store * is left untouched. Navigation is the view's responsibility, not the store's. + * + * withCredentials lets the browser accept the backend's Set-Cookie for the + * httpOnly refresh cookie (`__Host-gocell_rt`) that powers cold-start renewal. */ async function login(credentials: HttpAuthLoginV1Request): Promise { const res = await http.post(LOGIN_URL, credentials, { __skipAuthRefresh: true, + withCredentials: true, }) setSession(res.data.data) } @@ -103,12 +114,15 @@ export const useAuthStore = defineStore('access.auth', () => { * Best-effort: a failed DELETE (network / already-expired) must not block the * local sign-out, so the session is always cleared in `finally`. Navigation * to /login is the caller's responsibility. + * + * withCredentials lets the browser receive the backend's cookie-clearing + * Set-Cookie (Max-Age=0) so a subsequent cold start does not silently renew. */ async function logout(): Promise { const sid = _sessionId.value try { if (sid) { - await http.delete(`${SESSION_URL}${sid}`) + await http.delete(`${SESSION_URL}${sid}`, { withCredentials: true }) } } catch { // Swallowed by design — local sign-out proceeds regardless. @@ -118,22 +132,34 @@ export const useAuthStore = defineStore('access.auth', () => { } /** - * Attempt to exchange the current refresh token for a new access token. + * Attempt to exchange the refresh token for a new access token. + * + * Cookie-first: the backend reads the refresh token from the httpOnly + * `__Host-gocell_rt` cookie (which survives a reload), so we always attempt the + * call — including on a cold start when nothing is in memory. The in-memory + * token, when present, is sent as a body fallback for the backend's dual + * channel; with no token we POST an empty body and rely purely on the cookie. + * withCredentials lets the browser attach the cookie and accept the rotated one. * * Returns the new accessToken on success, null otherwise. - * On network/server failure: clearSession() + return null. + * On network/server failure (e.g. no valid cookie): clearSession() + return null. * - * This function is the onRefresh callback for apps/web setupAxios (PR-06). - * Do NOT call setupAxios here — that is the app assembly layer's job. + * This function is the onRefresh callback for apps/web setupAxios (PR-06) and + * the engine behind bootstrapSession()'s cold-start restore. Do NOT call + * setupAxios here — that is the app assembly layer's job. */ async function refresh(): Promise { - if (!_refreshToken.value) { - return null - } - + // Cookie-only cold start sends an empty body; an in-memory token, when + // present, rides along as the backend's documented body fallback. Typed + // against the contract so a future schema rename surfaces here at compile + // time instead of silently sending a stale field name. + const body: HttpAuthRefreshV1Request | Record = _refreshToken.value + ? { refreshToken: _refreshToken.value } + : {} try { - const res = await http.post(REFRESH_URL, { - refreshToken: _refreshToken.value, + const res = await http.post(REFRESH_URL, body, { + withCredentials: true, + timeout: REFRESH_TIMEOUT_MS, }) setSession(res.data.data) return res.data.data.accessToken