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:
@@ -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
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user