1c9d378df8
* feat(auth): enforce session idle and absolute timeouts Hosted browser sessions now carry an HMAC-signed, HttpOnly cookie holding session start, last activity and sign-in method, bound to the Supabase session. Middleware enforces a 30 min idle and 12 h absolute limit (reason-coded redirects to /login), a heartbeat route advances idle activity from real user input, and a client controller warns 2 minutes before expiry. BankID users are routed back to BankID on re-auth via a short-lived method hint. API-key and MCP bearer surfaces are exempt; self-hosted installs default off and can opt in via env vars. Fixes #362 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(auth): derive session-timeout signing key via HKDF The HMAC key is now HKDF-derived with a purpose-bound info string, so the SUPABASE_SERVICE_ROLE_KEY fallback never uses the privileged credential directly as a signing key. Addresses the security review finding on PR #1387. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(auth): back signature bytes with a plain ArrayBuffer crypto.subtle.verify requires a BufferSource; Uint8Array.from is typed over ArrayBufferLike, which the Vercel TypeScript build rejects. Decode base64url into a Uint8Array constructed over a fresh ArrayBuffer. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(auth): address session-timeout review findings - signSessionTimeoutState returns null on signing failure instead of throwing, so a missing secret degrades the timeout feature in line with verifySessionTimeoutState rather than crashing authenticated requests; middleware and heartbeat skip the cookie write when null - heartbeat initializes a fresh signed state for a missing or session-mismatched cookie, mirroring middleware, instead of returning SESSION_EXPIRED during normal initialization - sessionStateMatchesUser treats an unresolved current session id as a mismatch for session-bound state so another session's cookie is never accepted on the userId fallback alone - drop aria-live from the countdown DialogDescription so screen readers are not interrupted every second Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
140 lines
4.2 KiB
TypeScript
140 lines
4.2 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import { NextResponse } from 'next/server'
|
|
import {
|
|
createSessionTimeoutState,
|
|
signSessionTimeoutState,
|
|
verifySessionTimeoutState,
|
|
} from '@/lib/auth/session-timeout'
|
|
import { SESSION_TIMEOUT_COOKIE } from '@/lib/auth/session-timeout-shared'
|
|
|
|
const mocks = vi.hoisted(() => ({
|
|
requireAuth: vi.fn(),
|
|
cookieValue: undefined as string | undefined,
|
|
}))
|
|
|
|
vi.mock('@/lib/auth/require-auth', () => ({
|
|
requireAuth: mocks.requireAuth,
|
|
}))
|
|
|
|
vi.mock('next/headers', () => ({
|
|
cookies: vi.fn(async () => ({
|
|
get: (name: string) => name === SESSION_TIMEOUT_COOKIE && mocks.cookieValue
|
|
? { name, value: mocks.cookieValue }
|
|
: undefined,
|
|
})),
|
|
}))
|
|
|
|
import { GET, POST } from '../route'
|
|
|
|
const supabase = {
|
|
auth: {
|
|
getClaims: vi.fn(async () => ({
|
|
data: { claims: { session_id: 'session-1' } },
|
|
})),
|
|
},
|
|
}
|
|
|
|
describe('session heartbeat route', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
process.env.SESSION_TIMEOUT_SECRET = 'heartbeat-test-secret'
|
|
process.env.NEXT_PUBLIC_SESSION_IDLE_TIMEOUT_MS = '30000'
|
|
process.env.NEXT_PUBLIC_SESSION_ABSOLUTE_TIMEOUT_MS = '60000'
|
|
process.env.NEXT_PUBLIC_SESSION_WARNING_MS = '10000'
|
|
mocks.requireAuth.mockResolvedValue({
|
|
user: { id: 'user-1' },
|
|
supabase,
|
|
error: null,
|
|
})
|
|
mocks.cookieValue = undefined
|
|
})
|
|
|
|
async function setState(args?: {
|
|
startedAt?: number
|
|
lastActivityAt?: number
|
|
sessionId?: string
|
|
}) {
|
|
const state = {
|
|
...createSessionTimeoutState({
|
|
userId: 'user-1',
|
|
sessionId: args?.sessionId ?? 'session-1',
|
|
method: 'password',
|
|
now: args?.startedAt ?? Date.now(),
|
|
}),
|
|
...(args?.lastActivityAt === undefined
|
|
? {}
|
|
: { lastActivityAt: args.lastActivityAt }),
|
|
}
|
|
mocks.cookieValue = (await signSessionTimeoutState(state)) ?? undefined
|
|
return state
|
|
}
|
|
|
|
it('returns the server-authoritative timeout state without extending it on GET', async () => {
|
|
const state = await setState()
|
|
|
|
const response = await GET()
|
|
|
|
expect(response.status).toBe(200)
|
|
await expect(response.json()).resolves.toMatchObject({
|
|
data: {
|
|
enabled: true,
|
|
startedAt: state.startedAt,
|
|
lastActivityAt: state.lastActivityAt,
|
|
},
|
|
})
|
|
expect(response.cookies.get(SESSION_TIMEOUT_COOKIE)).toBeUndefined()
|
|
})
|
|
|
|
it('advances activity and rotates the signed cookie on POST', async () => {
|
|
const state = await setState({ startedAt: Date.now() - 1000 })
|
|
|
|
const response = await POST()
|
|
|
|
expect(response.status).toBe(200)
|
|
const rotated = response.cookies.get(SESSION_TIMEOUT_COOKIE)?.value
|
|
expect(rotated).toBeTruthy()
|
|
await expect(verifySessionTimeoutState(rotated)).resolves.toMatchObject({
|
|
startedAt: state.startedAt,
|
|
lastActivityAt: expect.any(Number),
|
|
})
|
|
})
|
|
|
|
it('rejects an expired state', async () => {
|
|
const now = Date.now()
|
|
await setState({ startedAt: now - 60_000, lastActivityAt: now - 1 })
|
|
const expired = await GET()
|
|
expect(expired.status).toBe(401)
|
|
expect(expired.headers.get('x-session-timeout-reason')).toBe('absolute')
|
|
})
|
|
|
|
it('initializes fresh state for a missing or mismatched cookie like middleware', async () => {
|
|
const missing = await GET()
|
|
expect(missing.status).toBe(200)
|
|
const initialized = missing.cookies.get(SESSION_TIMEOUT_COOKIE)?.value
|
|
expect(initialized).toBeTruthy()
|
|
await expect(verifySessionTimeoutState(initialized)).resolves.toMatchObject({
|
|
userId: 'user-1',
|
|
sessionId: 'session-1',
|
|
})
|
|
|
|
await setState({ sessionId: 'another-session' })
|
|
const mismatch = await GET()
|
|
expect(mismatch.status).toBe(200)
|
|
const reminted = mismatch.cookies.get(SESSION_TIMEOUT_COOKIE)?.value
|
|
expect(reminted).toBeTruthy()
|
|
await expect(verifySessionTimeoutState(reminted)).resolves.toMatchObject({
|
|
sessionId: 'session-1',
|
|
})
|
|
})
|
|
|
|
it('passes through the existing authentication error', async () => {
|
|
mocks.requireAuth.mockResolvedValue({
|
|
user: null,
|
|
supabase,
|
|
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
|
|
})
|
|
|
|
expect((await GET()).status).toBe(401)
|
|
})
|
|
})
|