Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 28 additions & 3 deletions apps/web/src/bootstrap.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
})
})
16 changes: 11 additions & 5 deletions apps/web/src/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,11 +44,17 @@ let bootstrapPromise: Promise<void> | 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.
*/
Expand Down
5 changes: 3 additions & 2 deletions apps/web/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'))
74 changes: 74 additions & 0 deletions e2e/auth.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
await page.route(STATUS_URL, (route) => route.fulfill({ json: { data: { hasAdmin } } }))
}

function sessionData(accessToken: string): { data: Record<string, unknown> } {
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 为
Expand Down Expand Up @@ -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()
})
})
8 changes: 4 additions & 4 deletions packages/access/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | null>`:`@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<string | null>`:`@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`)

Expand Down
58 changes: 48 additions & 10 deletions packages/access/src/stores/useAuthStore.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -139,19 +170,23 @@ 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
mockHttp.post.mockResolvedValueOnce({
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 () => {
Expand Down Expand Up @@ -201,6 +236,7 @@ describe('useAuthStore', () => {

expect(mockHttp.post).toHaveBeenCalledWith('/api/v1/access/sessions/login', credentials, {
__skipAuthRefresh: true,
withCredentials: true,
})
})

Expand Down Expand Up @@ -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()
})
Expand Down
48 changes: 37 additions & 11 deletions packages/access/src/stores/useAuthStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { http } from '@gocell/request'
import type {
HttpAuthLoginV1Request,
HttpAuthLoginV1Response,
HttpAuthRefreshV1Request,
HttpAuthRefreshV1Response,
} from '@gocell/contracts'

Expand All @@ -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<string, unknown> | null {
const payload = token.split('.')[1]
if (!payload) return null
Expand Down Expand Up @@ -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<void> {
const res = await http.post<HttpAuthLoginV1Response>(LOGIN_URL, credentials, {
__skipAuthRefresh: true,
withCredentials: true,
})
setSession(res.data.data)
}
Expand All @@ -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<void> {
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.
Expand All @@ -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<string | null> {
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<string, never> = _refreshToken.value
? { refreshToken: _refreshToken.value }
: {}
try {
const res = await http.post<HttpAuthRefreshV1Response>(REFRESH_URL, {
refreshToken: _refreshToken.value,
const res = await http.post<HttpAuthRefreshV1Response>(REFRESH_URL, body, {
withCredentials: true,
timeout: REFRESH_TIMEOUT_MS,
})
setSession(res.data.data)
return res.data.data.accessToken
Expand Down
Loading