feat(10-01): harden the admin cookie session and prefix boot guards

- refresh and logout read the Bearer header first, then the summer_admin
  cookie; a cookie refresh rotates the cookie without a token in the body and
  logout always expires the cookie
- backend.cookie_secure (default true) may drop Secure outside production only
- activation rejects controller vendor segments api, assets, login, settings
- BuildRouter rejects non-cabana routes at or under the admin prefix
- SPA single-flights refresh on 401, replays once, and refreshes proactively
  at 80 percent of expires_in; dist rebuilt
- scripts/check-admin-dist.sh rebuilds the SPA and fails on dist drift
- tests: TestPhase10CookieAuth, TestPhase10CSRF, TestPhase10Prefix,
  TestPhase10AdminPrefixCollision, boardwalk serving and header tests
This commit is contained in:
Jakub Zych
2026-09-27 15:34:19 +02:00
parent 5f9353841b
commit dafdb18234
15 changed files with 1283 additions and 18 deletions

View File

@@ -2,6 +2,11 @@
// paths (D-15). Every request carries X-Requested-With (the CSRF header the
// admin API requires on state-changing cookie requests, D-19) and same-origin
// credentials; the JWT lives in an HttpOnly cookie the SPA never reads.
//
// A 401 on any call other than login and refresh starts one shared refresh
// (concurrent 401s wait for the same one); on success the original request is
// replayed once from a clone taken before it was sent. A failed refresh or a
// second 401 reports the session as gone.
import createClient, { type Middleware } from 'openapi-fetch'
import type { paths } from './schema'
import { runtime } from '../app/runtime'
@@ -9,28 +14,81 @@ import { runtime } from '../app/runtime'
export const REQUESTED_WITH = 'XMLHttpRequest'
type UnauthorizedHandler = () => void
type RefreshedHandler = (expiresIn: number | null) => void
let unauthorizedHandler: UnauthorizedHandler | null = null
let refreshedHandler: RefreshedHandler | null = null
let inflight: Promise<boolean> | null = null
const replays = new WeakMap<Request, Request>()
/** Registers what happens when an API call other than login returns 401. */
/** Registers what happens when the session cannot be recovered after a 401. */
export function onUnauthorized(handler: UnauthorizedHandler | null): void {
unauthorizedHandler = handler
}
function isLoginRequest(request: Request): boolean {
return new URL(request.url, 'http://local').pathname.endsWith('/auth/login')
/** Registers a listener for successful refreshes (the new access lifetime). */
export function onRefreshed(handler: RefreshedHandler | null): void {
refreshedHandler = handler
}
function pathOf(request: Request): string {
return new URL(request.url, 'http://local').pathname
}
function isAuthEndpoint(request: Request): boolean {
const path = pathOf(request)
return path.endsWith('/auth/login') || path.endsWith('/auth/refresh')
}
/**
* Refreshes the cookie session. Concurrent callers share one request; the
* promise resolves to whether the server issued a new session.
*/
export function refreshSession(): Promise<boolean> {
if (inflight) {
return inflight
}
inflight = (async () => {
try {
const { data, response } = await api.POST('/auth/refresh')
if (!response.ok || !data) {
return false
}
const expiresIn = data.data.expires_in
refreshedHandler?.(typeof expiresIn === 'number' ? expiresIn : null)
return true
} catch {
return false
} finally {
inflight = null
}
})()
return inflight
}
export const transport: Middleware = {
onRequest({ request }) {
request.headers.set('X-Requested-With', REQUESTED_WITH)
if (!isAuthEndpoint(request)) {
replays.set(request, request.clone())
}
return request
},
onResponse({ request, response }) {
if (response.status === 401 && !isLoginRequest(request)) {
async onResponse({ request, response }) {
if (response.status !== 401 || isAuthEndpoint(request)) {
return response
}
const replay = replays.get(request)
replays.delete(request)
if (!replay || !(await refreshSession())) {
unauthorizedHandler?.()
return response
}
const retried = await globalThis.fetch(replay)
if (retried.status === 401) {
unauthorizedHandler?.()
}
return response
return retried
},
}

View File

@@ -1,11 +1,34 @@
// Admin session state. The JWT travels in an HttpOnly cookie (D-19): the SPA
// keeps only the profile and the access lifetime, never a token.
import { readonly, ref } from 'vue'
import { api } from '../api/client'
import { api, onRefreshed, refreshSession } from '../api/client'
import type { AdminProfile } from '../api/types'
const user = ref<AdminProfile | null>(null)
const expiresIn = ref<number | null>(null)
let refreshTimer: ReturnType<typeof setTimeout> | null = null
/** Refreshes proactively at 80 percent of the access token lifetime. */
function scheduleRefresh(seconds: number | null): void {
cancelRefresh()
expiresIn.value = seconds
if (seconds === null || seconds <= 0) {
return
}
refreshTimer = setTimeout(() => {
refreshTimer = null
void refreshSession()
}, seconds * 800)
}
function cancelRefresh(): void {
if (refreshTimer !== null) {
clearTimeout(refreshTimer)
refreshTimer = null
}
}
onRefreshed(scheduleRefresh)
export const currentUser = readonly(user)
@@ -17,7 +40,7 @@ export async function login(identifier: string, password: string): Promise<boole
if (!response.ok || !data) {
return false
}
expiresIn.value = typeof data.data.expires_in === 'number' ? data.data.expires_in : null
scheduleRefresh(typeof data.data.expires_in === 'number' ? data.data.expires_in : null)
return true
}
@@ -29,6 +52,7 @@ export async function me(): Promise<AdminProfile | null> {
}
export function clearUser(): void {
cancelRefresh()
user.value = null
expiresIn.value = null
}

View File

@@ -5,7 +5,7 @@ import App from '../../src/App.vue'
import LoginView from '../../src/views/LoginView.vue'
import { createAdminRouter, safeRedirect } from '../../src/app/router'
import { runtime } from '../../src/app/runtime'
import { onUnauthorized } from '../../src/api/client'
import { api, onUnauthorized } from '../../src/api/client'
import { clearUser, me, useAuth } from '../../src/state/useAuth'
import { loadNavigation, setNavigation } from '../../src/state/useNavigation'
import navigation from '../fixtures/navigation.json'
@@ -197,3 +197,106 @@ describe('navigation shell and list', () => {
])
})
})
describe('session refresh', () => {
function json(status: number, body: unknown): Response {
return new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } })
}
const unauthenticated = { error: { code: 'unauthenticated', message: 'Unauthenticated', details: {} } }
it('single-flights one refresh for concurrent 401s and replays each request once', async () => {
let refreshed = false
const seen: Request[] = []
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input: RequestInfo | URL) => {
const request = input as Request
seen.push(request)
const path = pathOf(request)
if (path === `${API}/auth/refresh`) {
await new Promise((resolve) => setTimeout(resolve, 5))
refreshed = true
return json(200, { data: { token_type: 'cookie', expires_in: 3600 }, meta: {} })
}
if (!refreshed) {
return json(401, unauthenticated)
}
if (path === `${API}/navigation`) {
return json(200, navigation)
}
return json(200, profile)
})
const handler = vi.fn()
onUnauthorized(handler)
const [nav, who] = await Promise.all([api.GET('/navigation'), api.GET('/auth/me')])
expect(nav.response.status).toBe(200)
expect(who.response.status).toBe(200)
expect(nav.data?.data).toHaveLength(3)
expect(who.data?.data.login).toBe('dev')
const refreshes = seen.filter((request) => pathOf(request) === `${API}/auth/refresh`)
expect(refreshes).toHaveLength(1)
expect(refreshes[0]!.method).toBe('POST')
expect(refreshes[0]!.headers.get('X-Requested-With')).toBe('XMLHttpRequest')
expect(seen.filter((request) => pathOf(request) === `${API}/navigation`)).toHaveLength(2)
expect(seen.filter((request) => pathOf(request) === `${API}/auth/me`)).toHaveLength(2)
expect(handler).not.toHaveBeenCalled()
})
it('goes to login when the refresh fails and never replays twice', async () => {
const seen: Request[] = []
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input: RequestInfo | URL) => {
const request = input as Request
seen.push(request)
return json(401, unauthenticated)
})
const handler = vi.fn()
onUnauthorized(handler)
const result = await api.GET('/navigation')
expect(result.response.status).toBe(401)
expect(seen.filter((request) => pathOf(request) === `${API}/auth/refresh`)).toHaveLength(1)
expect(seen.filter((request) => pathOf(request) === `${API}/navigation`)).toHaveLength(1)
expect(handler).toHaveBeenCalledTimes(1)
})
it('goes to login when the replay is still unauthorized', async () => {
const seen: Request[] = []
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input: RequestInfo | URL) => {
const request = input as Request
seen.push(request)
if (pathOf(request) === `${API}/auth/refresh`) {
return json(200, { data: { token_type: 'cookie', expires_in: 3600 }, meta: {} })
}
return json(401, unauthenticated)
})
const handler = vi.fn()
onUnauthorized(handler)
const result = await api.GET('/navigation')
expect(result.response.status).toBe(401)
expect(seen.filter((request) => pathOf(request) === `${API}/navigation`)).toHaveLength(2)
expect(handler).toHaveBeenCalledTimes(1)
})
it('schedules a proactive refresh at 80 percent of expires_in', async () => {
vi.useFakeTimers()
try {
const calls = mockApi({
[`POST ${API}/auth/login`]: { body: { data: { token_type: 'cookie', expires_in: 100 }, meta: {} } },
[`POST ${API}/auth/refresh`]: { body: { data: { token_type: 'cookie', expires_in: 100 }, meta: {} } },
})
expect(await useAuth().login('dev', 'secret')).toBe(true)
await vi.advanceTimersByTimeAsync(79_000)
expect(calls.filter((request) => pathOf(request) === `${API}/auth/refresh`)).toHaveLength(0)
await vi.advanceTimersByTimeAsync(2_000)
expect(calls.filter((request) => pathOf(request) === `${API}/auth/refresh`)).toHaveLength(1)
clearUser()
await vi.advanceTimersByTimeAsync(200_000)
expect(calls.filter((request) => pathOf(request) === `${API}/auth/refresh`)).toHaveLength(1)
} finally {
vi.useRealTimers()
}
})
})