Files
accounted/app/api/auth/heartbeat/__tests__/route.test.ts
T
Mattsson 1c9d378df8 feat(auth): enforce session idle and absolute timeouts (#1387)
* 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>
2026-08-04 09:59:42 +02:00

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