From 7d43623930dd8ead2c6ec888f57e0cfd66f50689 Mon Sep 17 00:00:00 2001 From: ghbvf <104540935+ghbvf@users.noreply.github.com> Date: Wed, 17 Jun 2026 22:38:50 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat(access):=20=E5=86=B7=E5=90=AF=E5=8A=A8?= =?UTF-8?q?=20httpOnly=20cookie=20=E9=9D=99=E9=BB=98=E7=BB=AD=E6=9C=9F?= =?UTF-8?q?=E6=8E=A5=E7=BA=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - refresh() 改 cookie 优先:去早退,无内存 token 也发空 body 续期,内存 token 作 body 兜底 - login/refresh/logout 加 withCredentials,收发后端 __Host-gocell_rt cookie - bootstrap.ts/main.ts 注释同步:bootstrapSession 经 cookie 主动续期,await-before-mount 不闪 /login - access README 同步;单测覆盖 cookie 模式 + 冷启动;e2e 冷启动整页重载冒烟 依赖后端 BR-005 (ghbvf/gocell#1278,已交付)。契约未改(请求体未类型化,{} 可编译); 跨源 CORS 联调后端未实装(backend #1680)。 Refs #27 Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/web/src/bootstrap.spec.ts | 31 +++++++- apps/web/src/bootstrap.ts | 12 +-- apps/web/src/main.ts | 5 +- e2e/auth.spec.ts | 74 +++++++++++++++++++ packages/access/README.md | 8 +- .../access/src/stores/useAuthStore.spec.ts | 49 +++++++++--- packages/access/src/stores/useAuthStore.ts | 37 +++++++--- 7 files changed, 180 insertions(+), 36 deletions(-) diff --git a/apps/web/src/bootstrap.spec.ts b/apps/web/src/bootstrap.spec.ts index 26a8ef7..ee7e69a 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..1e27224 100644 --- a/apps/web/src/bootstrap.ts +++ b/apps/web/src/bootstrap.ts @@ -44,11 +44,13 @@ 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.) * * Must be called AFTER createPinia() so useAuthStore() resolves. */ diff --git a/apps/web/src/main.ts b/apps/web/src/main.ts index b6b1cfe..5c17e08 100644 --- a/apps/web/src/main.ts +++ b/apps/web/src/main.ts @@ -30,6 +30,7 @@ app.provide(PDP_INJECTION_KEY, pdpClient) // 5. Route guards (three-stage: first-run → auth → PDP) registerGuards(router, app, pdpClient) -// 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 b772eb1..947f053 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 0815c2b..cc69e91 100644 --- a/packages/access/src/stores/useAuthStore.spec.ts +++ b/packages/access/src/stores/useAuthStore.spec.ts @@ -99,14 +99,36 @@ describe('useAuthStore', () => { }) describe('refresh()', () => { - it('returns null when there is no refreshToken', async () => { + // Every refresh must opt the browser into sending/receiving the httpOnly + // refresh cookie (`__Host-gocell_rt`) so cold-start renewal works. + const WITH_CREDS = { withCredentials: true } + + 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', {}, WITH_CREDS) + 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', {}, WITH_CREDS) 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) @@ -121,9 +143,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' }, + WITH_CREDS, + ) expect(result).toBe('access-tok-new') expect(store.accessToken).toBe('access-tok-new') // refreshToken is internal; verify it works by doing a second refresh @@ -131,9 +155,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' }, + WITH_CREDS, + ) }) it('clearSession and returns null when http.post rejects', async () => { @@ -183,6 +209,7 @@ describe('useAuthStore', () => { expect(mockHttp.post).toHaveBeenCalledWith('/api/v1/access/sessions/login', credentials, { __skipAuthRefresh: true, + withCredentials: true, }) }) @@ -224,7 +251,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 f9094f7..0fbb464 100644 --- a/packages/access/src/stores/useAuthStore.ts +++ b/packages/access/src/stores/useAuthStore.ts @@ -65,10 +65,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) } @@ -79,12 +83,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. @@ -94,23 +101,29 @@ 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 - } - try { - const res = await http.post(REFRESH_URL, { - refreshToken: _refreshToken.value, - }) + const res = await http.post( + REFRESH_URL, + _refreshToken.value ? { refreshToken: _refreshToken.value } : {}, + { withCredentials: true }, + ) setSession(res.data.data) return res.data.data.accessToken } catch { From 085165eaebd2614a7468c96542541492ffc2acd5 Mon Sep 17 00:00:00 2001 From: ghbvf <104540935+ghbvf@users.noreply.github.com> Date: Wed, 17 Jun 2026 22:50:09 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix(access):=20PR=20#65=20review=20?= =?UTF-8?q?=E2=80=94=20refresh=20=E8=AF=B7=E6=B1=82=E4=BD=93=E5=A5=91?= =?UTF-8?q?=E7=BA=A6=E7=B1=BB=E5=9E=8B=E5=8C=96=20+=20timeout=20=E5=85=9C?= =?UTF-8?q?=E5=BA=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 六维度 review(2 reviewer,diff 216 行 → 2 档)Cx2 修复: - refresh() 请求体显式标注 HttpAuthRefreshV1Request | Record, 契约重命名时编译期暴露(去回了对 @gocell/contracts 的引用) - refresh() 加 timeout 兜底:bootstrapSession await 在 app.mount() 前, 无超时的 http 实例遇后端挂起会永久白屏;本 PR 让 mount 依赖网络故放大该既有风险 - bootstrap.ts JSDoc 注明 refresh() 永不 reject(.finally(mount) 依赖此不变量) a11y 维度无 finding;withCredentials per-request 归属经确认合理(HTTP 单点未破)。 遗留(见 PR 评论):@gocell/request 全局 timeout、bootstrap 测试重置钩、e2e __Host- cookie 保真度。 Refs #27 Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/web/src/bootstrap.ts | 4 ++++ .../access/src/stores/useAuthStore.spec.ts | 23 +++++++++++++------ packages/access/src/stores/useAuthStore.ts | 23 +++++++++++++++---- 3 files changed, 38 insertions(+), 12 deletions(-) diff --git a/apps/web/src/bootstrap.ts b/apps/web/src/bootstrap.ts index 1e27224..d784332 100644 --- a/apps/web/src/bootstrap.ts +++ b/apps/web/src/bootstrap.ts @@ -52,6 +52,10 @@ let bootstrapPromise: Promise | null = null * 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. */ export function bootstrapSession(): Promise { diff --git a/packages/access/src/stores/useAuthStore.spec.ts b/packages/access/src/stores/useAuthStore.spec.ts index cc69e91..f95c22a 100644 --- a/packages/access/src/stores/useAuthStore.spec.ts +++ b/packages/access/src/stores/useAuthStore.spec.ts @@ -99,9 +99,10 @@ describe('useAuthStore', () => { }) describe('refresh()', () => { - // Every refresh must opt the browser into sending/receiving the httpOnly - // refresh cookie (`__Host-gocell_rt`) so cold-start renewal works. - const WITH_CREDS = { withCredentials: true } + // 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() @@ -111,7 +112,11 @@ describe('useAuthStore', () => { const result = await store.refresh() - expect(mockHttp.post).toHaveBeenCalledWith('/api/v1/access/sessions/refresh', {}, WITH_CREDS) + 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') @@ -123,7 +128,11 @@ describe('useAuthStore', () => { const result = await store.refresh() - expect(mockHttp.post).toHaveBeenCalledWith('/api/v1/access/sessions/refresh', {}, WITH_CREDS) + expect(mockHttp.post).toHaveBeenCalledWith( + '/api/v1/access/sessions/refresh', + {}, + REFRESH_CONFIG, + ) expect(result).toBeNull() expect(store.isAuthenticated).toBe(false) }) @@ -146,7 +155,7 @@ describe('useAuthStore', () => { expect(mockHttp.post).toHaveBeenCalledWith( '/api/v1/access/sessions/refresh', { refreshToken: 'refresh-tok-1' }, - WITH_CREDS, + REFRESH_CONFIG, ) expect(result).toBe('access-tok-new') expect(store.accessToken).toBe('access-tok-new') @@ -158,7 +167,7 @@ describe('useAuthStore', () => { expect(mockHttp.post).toHaveBeenLastCalledWith( '/api/v1/access/sessions/refresh', { refreshToken: 'refresh-tok-new' }, - WITH_CREDS, + REFRESH_CONFIG, ) }) diff --git a/packages/access/src/stores/useAuthStore.ts b/packages/access/src/stores/useAuthStore.ts index 0fbb464..966a21d 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 + export const useAuthStore = defineStore('access.auth', () => { // ─── state (all in-memory, never persisted) ─────────────────────────────── const user = ref(null) @@ -118,12 +125,18 @@ export const useAuthStore = defineStore('access.auth', () => { * setupAxios here — that is the app assembly layer's job. */ async function refresh(): Promise { + // 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.value ? { refreshToken: _refreshToken.value } : {}, - { withCredentials: true }, - ) + const res = await http.post(REFRESH_URL, body, { + withCredentials: true, + timeout: REFRESH_TIMEOUT_MS, + }) setSession(res.data.data) return res.data.data.accessToken } catch {