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

@@ -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
}