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