* feat(auth): make automatic logout an opt-in per-user setting Session timeouts (30 min idle / 12 h absolute on hosted) now apply only to users who enable "Automatic logout" in Settings > Security. Default is off: sessions live for the full Supabase refresh-token lifetime, the behavior from before the 2026-07 session hardening. - user_preferences.auto_logout (migration, default false), toggled via the extended /api/user/preferences route - The opt-in is snapshotted into the signed timeout cookie at mint, so enforcement stays DB-read-free per request; the preferences route clears the cookie on change so a toggle takes effect immediately - Pre-toggle cookies are authentic-but-stale: re-minted preserving their timers, never routed down the tamper path, so the rollout does not log anyone out - NEXT_PUBLIC_SESSION_TIMEOUT_FORCE_ALL=true enforces timeouts for every user regardless of preference (emergency lever, also plumbed through the Docker image); self-hosted stays disabled by default Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(auth): resolve PR #1536 review findings - Replace the spread upsert in /api/user/preferences with one literal payload per field: the phantom-column schema guard cannot resolve spread payloads (Unit tests 3/4 ceiling failure) - Map the preferences 500 through getErrorMessage so the user-facing text is Swedish (CodeRabbit) - fetchAutoLogoutPreference now returns null on a FAILED read instead of a fail-open false: callers skip minting so an unknown preference is never persisted into the year-long signed cookie, and the next request retries; failures log at error level, distinct from the normal opt-out path (compliance swarm GDPR Art.32(1)(b) / ISO A.8.5) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(auth): write multi-field preference updates as one atomic upsert A request carrying both hide_assistant_fab and auto_logout previously issued two sequential writes, so a failure of the second returned 500 after half the request had persisted (CodeRabbit, PR #1536). One literal upsert per accepted field combination keeps the write atomic and stays resolvable for the phantom-column schema guard. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
218 lines
6.6 KiB
TypeScript
218 lines
6.6 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 preference = { autoLogout: false, error: null as unknown }
|
|
|
|
const supabase = {
|
|
auth: {
|
|
getClaims: vi.fn(async () => ({
|
|
data: { claims: { session_id: 'session-1' } },
|
|
})),
|
|
},
|
|
from: vi.fn(() => ({
|
|
select: vi.fn(() => ({
|
|
eq: vi.fn(() => ({
|
|
maybeSingle: vi.fn(async () => ({
|
|
data: preference.error ? null : { auto_logout: preference.autoLogout },
|
|
error: preference.error,
|
|
})),
|
|
})),
|
|
})),
|
|
})),
|
|
}
|
|
|
|
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
|
|
preference.autoLogout = false
|
|
preference.error = null
|
|
})
|
|
|
|
async function setState(args?: {
|
|
startedAt?: number
|
|
lastActivityAt?: number
|
|
sessionId?: string
|
|
autoLogout?: boolean
|
|
}) {
|
|
const state = {
|
|
...createSessionTimeoutState({
|
|
userId: 'user-1',
|
|
sessionId: args?.sessionId ?? 'session-1',
|
|
method: 'password',
|
|
// Default the opt-in to true: most cases here exercise enforcement.
|
|
autoLogout: args?.autoLogout ?? true,
|
|
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('reports enabled: false and never expires an opted-out session', async () => {
|
|
const now = Date.now()
|
|
// Times that would be long expired if enforcement applied.
|
|
await setState({
|
|
startedAt: now - 120_000,
|
|
lastActivityAt: now - 120_000,
|
|
autoLogout: false,
|
|
})
|
|
|
|
const response = await GET()
|
|
|
|
expect(response.status).toBe(200)
|
|
await expect(response.json()).resolves.toMatchObject({
|
|
data: { enabled: false },
|
|
})
|
|
})
|
|
|
|
it('upgrades a pre-toggle cookie in place, keeping its timers', async () => {
|
|
preference.autoLogout = true
|
|
const startedAt = Date.now() - 5_000
|
|
const legacy: Record<string, unknown> = {
|
|
...createSessionTimeoutState({
|
|
userId: 'user-1',
|
|
sessionId: 'session-1',
|
|
method: 'password',
|
|
autoLogout: true,
|
|
now: startedAt,
|
|
}),
|
|
}
|
|
delete legacy.autoLogout
|
|
mocks.cookieValue =
|
|
(await signSessionTimeoutState(
|
|
legacy as Parameters<typeof signSessionTimeoutState>[0],
|
|
)) ?? undefined
|
|
|
|
const response = await GET()
|
|
|
|
expect(response.status).toBe(200)
|
|
const upgraded = response.cookies.get(SESSION_TIMEOUT_COOKIE)?.value
|
|
expect(upgraded).toBeTruthy()
|
|
await expect(verifySessionTimeoutState(upgraded)).resolves.toMatchObject({
|
|
startedAt,
|
|
autoLogout: true,
|
|
})
|
|
})
|
|
|
|
it('answers without persisting a snapshot when the preference read fails', async () => {
|
|
preference.error = { message: 'connection reset' }
|
|
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
|
|
const response = await GET()
|
|
|
|
expect(response.status).toBe(200)
|
|
await expect(response.json()).resolves.toMatchObject({
|
|
data: { enabled: false },
|
|
})
|
|
// No cookie: the unknown preference must not be baked into a snapshot.
|
|
expect(response.cookies.get(SESSION_TIMEOUT_COOKIE)).toBeUndefined()
|
|
errorSpy.mockRestore()
|
|
})
|
|
|
|
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)
|
|
})
|
|
})
|