From 11995b1b0c1f566e660ca1f508c48f674864d8a7 Mon Sep 17 00:00:00 2001 From: Mattsson <111893710+mattssonn@users.noreply.github.com> Date: Wed, 12 Aug 2026 16:45:41 +0200 Subject: [PATCH] feat(auth): make automatic logout an opt-in per-user setting (#1536) * 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 * 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 * 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 --------- Co-authored-by: Claude Fable 5 --- .env.docker.example | 3 + .env.example | 12 +- DECISIONS.md | 1 + Dockerfile | 1 + .../auth/heartbeat/__tests__/route.test.ts | 78 ++++++++++ app/api/auth/heartbeat/route.ts | 48 +++--- .../user/preferences/__tests__/route.test.ts | 54 ++++++- app/api/user/preferences/route.ts | 74 ++++++++-- components/settings/AutoLogoutToggle.tsx | 89 ++++++++++++ components/settings/SecuritySettings.tsx | 4 + docker-entrypoint.sh | 2 + docs/SELF-HOSTING.md | 19 ++- lib/auth/__tests__/session-timeout.test.ts | 137 ++++++++++++++++++ lib/auth/session-timeout.ts | 76 +++++++++- lib/supabase/__tests__/middleware.test.ts | 95 +++++++++++- lib/supabase/middleware.ts | 48 ++++-- messages/en.json | 6 + messages/sv.json | 6 + ...60812134817_add_auto_logout_preference.sql | 8 + 19 files changed, 702 insertions(+), 59 deletions(-) create mode 100644 components/settings/AutoLogoutToggle.tsx create mode 100644 supabase/migrations/20260812134817_add_auto_logout_preference.sql diff --git a/.env.docker.example b/.env.docker.example index 08fa0a04..e441a239 100644 --- a/.env.docker.example +++ b/.env.docker.example @@ -9,9 +9,12 @@ NEXT_PUBLIC_SELF_HOSTED=true # Session timeouts are also disabled by default for self-hosted deployments. # Uncomment to opt into hosted-style banking-app limits (milliseconds). +# Automatic logout is additionally opt-in per user (Settings > Security); +# set NEXT_PUBLIC_SESSION_TIMEOUT_FORCE_ALL=true to enforce it for everyone. # NEXT_PUBLIC_SESSION_IDLE_TIMEOUT_MS=1800000 # NEXT_PUBLIC_SESSION_ABSOLUTE_TIMEOUT_MS=43200000 # NEXT_PUBLIC_SESSION_WARNING_MS=120000 +# NEXT_PUBLIC_SESSION_TIMEOUT_FORCE_ALL=false # Optional dedicated HMAC secret; otherwise SUPABASE_SERVICE_ROLE_KEY is used. # SESSION_TIMEOUT_SECRET= diff --git a/.env.example b/.env.example index efe15884..ddcf78ab 100644 --- a/.env.example +++ b/.env.example @@ -25,14 +25,20 @@ RECEIPT_HUNT_MODEL_ID= RECEIPT_HUNT_MIN_CONFIDENCE= RECEIPT_HUNT_COMPANY_IDS= -# Hosted session security defaults: 30 minutes idle, 12 hours absolute, -# with a warning 2 minutes before expiry. Set a timeout to 0 to disable that -# limit. Self-hosted deployments default both limits to 0 unless overridden. +# Session timeouts are opt-in per user (user_preferences.auto_logout, toggled +# in Settings > Security): users who have not opted in stay signed in for the +# full Supabase session lifetime. The variables below set the limits that +# apply to opted-in users: 30 minutes idle, 12 hours absolute, warning 2 +# minutes before expiry. Set a timeout to 0 to disable that limit entirely. +# Self-hosted deployments default both limits to 0 unless overridden. +# NEXT_PUBLIC_SESSION_TIMEOUT_FORCE_ALL=true enforces the timeouts for every +# user regardless of their preference (emergency lever / strict deployments). # The signing key falls back to SUPABASE_SERVICE_ROLE_KEY; set a dedicated # random secret if session signing should rotate independently. # NEXT_PUBLIC_SESSION_IDLE_TIMEOUT_MS=1800000 # NEXT_PUBLIC_SESSION_ABSOLUTE_TIMEOUT_MS=43200000 # NEXT_PUBLIC_SESSION_WARNING_MS=120000 +# NEXT_PUBLIC_SESSION_TIMEOUT_FORCE_ALL=false # SESSION_TIMEOUT_SECRET= # Self-hosted only: set to true when public signup is turned off in your diff --git a/DECISIONS.md b/DECISIONS.md index 449b8fa8..6e4abd51 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -876,4 +876,5 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-08-11] Anthropic, Vercel and Supabase removed from the portal directory: all three email their invoices to European customers, so listing them told the user to go and log in for a document already in their inbox. The directory's bar is "does not send the invoice", not "also has a portal", and the poll it was seeded from asked which portals people log into, which people answered with where an invoice can ALSO be found. The same objection may reach further down the list; an entry is a claim that the invoice cannot be had any other way and is worth checking per vendor. [2026-08-11] Portal URLs are swept by scripts/check-portal-urls.mts rather than trusted: the directory shipped with 18 hand-written paths, none opened, the file said so and shipped anyway, and a founder then hit a 404 on Google Workspace (/ac/billing/history). A sweep found GitHub's /settings/billing 404 too. Rule now is the shallowest URL that certainly resolves: landing one click short of the invoice costs little, landing on an error page spends the trust the feature runs on. Google, OpenAI and Hetzner refuse automated requests, so they cannot be swept and are kept shallow deliberately; only a genuine 404 fails the script, since failing on an unreachable host would train people to ignore it. Trygg Hansa removed: neither candidate URL could be reached at all. [2026-08-11] Credit-note deduction fields (deduction_total, per-item deduction_amount) stay POSITIVE magnitudes, unlike every other amount on a credit note: both columns carry CHECK (>= 0) in the DB, and negating them made every ROT/RUT credit fail at insert (prod support case 2026-08-11). Verified inert: the reversing verifikat recomputes the ROT/RUT split from quantity/unit_price (generateRotRutLines), the PDF hides the deduction section for credit notes, getAmountToPay skips deductions when credited_invoice_id is set, and ROT payout candidates require status='paid', which invoices_credit_note_not_paid makes impossible for credit notes. Any future reader summing these fields across invoice + credit note must special-case credit notes. +[2026-08-12] Automatic logout is opt-in per user (user_preferences.auto_logout, default OFF), reversing the 2026-07 always-on session timeouts: founder decision after a user complaint about multiple daily re-logins. The objection that an opt-in security control is effectively a removed one was raised and overruled; the compromise levers kept are NEXT_PUBLIC_SESSION_TIMEOUT_FORCE_ALL=true (re-enables enforcement for everyone without a code change) and the opt-in snapshot living inside the signed timeout cookie (no per-request DB read; the preference is read only at mint, and the preferences API clears the cookie on change so a toggle takes effect on the next request). No backstop absolute cap was added: "off" means the Supabase refresh-token lifetime governs, exactly the pre-hardening behavior. Pre-toggle cookies (no autoLogout field) are authentic-but-stale and re-minted preserving their timers, never routed down the tamper path, so the rollout logs nobody out. [2026-08-12] Content dedupe on document ingest is an opt-in uploadDocument flag wired into the intake funnel (uploadAndExtract + mail-hunt ingest), NOT a unique index on (company_id, sha256_hash): archival callers (sent invoices, filings, bank exports) legitimately store repeating bytes and a blanket constraint would break them; the SELECT-then-insert race is accepted exactly as in the WhatsApp precedent. On a hit the funnel adopts the existing inbox item (callers always get a real inbox_item_id) or files an item against the existing document; the mail hunt skips outright since a second item would only duplicate work in Underlag. diff --git a/Dockerfile b/Dockerfile index a3530e2b..9a1c461d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -41,6 +41,7 @@ ENV NEXT_PUBLIC_REQUIRE_MFA=__NEXT_PUBLIC_REQUIRE_MFA__ ENV NEXT_PUBLIC_SESSION_IDLE_TIMEOUT_MS=__NEXT_PUBLIC_SESSION_IDLE_TIMEOUT_MS__ ENV NEXT_PUBLIC_SESSION_ABSOLUTE_TIMEOUT_MS=__NEXT_PUBLIC_SESSION_ABSOLUTE_TIMEOUT_MS__ ENV NEXT_PUBLIC_SESSION_WARNING_MS=__NEXT_PUBLIC_SESSION_WARNING_MS__ +ENV NEXT_PUBLIC_SESSION_TIMEOUT_FORCE_ALL=__NEXT_PUBLIC_SESSION_TIMEOUT_FORCE_ALL__ # Keep the branding placeholder intact through prebuild's inject script so # docker-entrypoint.sh can substitute the runtime value into public/sw.js. ENV NEXT_PUBLIC_BRANDING_APP_NAME=__NEXT_PUBLIC_BRANDING_APP_NAME__ diff --git a/app/api/auth/heartbeat/__tests__/route.test.ts b/app/api/auth/heartbeat/__tests__/route.test.ts index 98e17b00..2cdec0d8 100644 --- a/app/api/auth/heartbeat/__tests__/route.test.ts +++ b/app/api/auth/heartbeat/__tests__/route.test.ts @@ -26,12 +26,24 @@ vi.mock('next/headers', () => ({ 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', () => { @@ -47,18 +59,23 @@ describe('session heartbeat route', () => { 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 @@ -127,6 +144,67 @@ describe('session heartbeat route', () => { }) }) + 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 = { + ...createSessionTimeoutState({ + userId: 'user-1', + sessionId: 'session-1', + method: 'password', + autoLogout: true, + now: startedAt, + }), + } + delete legacy.autoLogout + mocks.cookieValue = + (await signSessionTimeoutState( + legacy as Parameters[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, diff --git a/app/api/auth/heartbeat/route.ts b/app/api/auth/heartbeat/route.ts index 128a9101..02a2c272 100644 --- a/app/api/auth/heartbeat/route.ts +++ b/app/api/auth/heartbeat/route.ts @@ -5,8 +5,10 @@ import { requireAuth } from '@/lib/auth/require-auth' import { createSessionTimeoutState, evaluateSessionTimeout, + fetchAutoLogoutPreference, getSessionTimeoutConfig, sessionStateMatchesUser, + sessionStateNeedsRemint, sessionTimeoutCookieOptions, signSessionTimeoutState, toSessionTimeoutClientState, @@ -71,28 +73,40 @@ async function heartbeat(updateActivity: boolean): Promise { const state = await verifySessionTimeoutState(encodedState) const sessionId = await getSessionId(auth.supabase) - if (!state || !sessionStateMatchesUser(state, auth.user.id, sessionId)) { - // Mirror middleware initialization: a missing or session-mismatched - // cookie means the timeout state has not been established for this - // session yet, not that the session expired. + const stateMatches = + state !== null && sessionStateMatchesUser(state, auth.user.id, sessionId) + + if (!state || !stateMatches || sessionStateNeedsRemint(state)) { + // Mirror middleware initialization: a missing, session-mismatched, or + // pre-toggle cookie means the timeout state has not been established + // for this session yet, not that the session expired. const hintedMethod = cookieStore.get(SESSION_AUTH_METHOD_HINT_COOKIE)?.value - const freshState = createSessionTimeoutState({ - userId: auth.user.id, - sessionId, - method: isSessionAuthMethod(hintedMethod) ? hintedMethod : 'password', - now, - }) + const autoLogout = await fetchAutoLogoutPreference(auth.supabase, auth.user.id) + const freshState = state && stateMatches + ? { ...state, autoLogout: autoLogout ?? false } + : createSessionTimeoutState({ + userId: auth.user.id, + sessionId, + method: isSessionAuthMethod(hintedMethod) ? hintedMethod : 'password', + autoLogout: autoLogout ?? false, + now, + }) const response = NextResponse.json({ data: toSessionTimeoutClientState(freshState, config, now), }) response.headers.set('Cache-Control', 'no-store') - const signedFresh = await signSessionTimeoutState(freshState) - if (signedFresh) { - response.cookies.set( - SESSION_TIMEOUT_COOKIE, - signedFresh, - sessionTimeoutCookieOptions(), - ) + // Unknown preference (failed read): answer this poll without persisting + // a fail-open snapshot; the next resync retries the read (mirrors the + // middleware). + if (autoLogout !== null) { + const signedFresh = await signSessionTimeoutState(freshState) + if (signedFresh) { + response.cookies.set( + SESSION_TIMEOUT_COOKIE, + signedFresh, + sessionTimeoutCookieOptions(), + ) + } } return response } diff --git a/app/api/user/preferences/__tests__/route.test.ts b/app/api/user/preferences/__tests__/route.test.ts index 27f85bdf..e41f0060 100644 --- a/app/api/user/preferences/__tests__/route.test.ts +++ b/app/api/user/preferences/__tests__/route.test.ts @@ -21,7 +21,9 @@ function unauthed() { }) } -function authedForGet(row: { hide_assistant_fab: boolean } | null) { +function authedForGet( + row: { hide_assistant_fab?: boolean; auto_logout?: boolean } | null, +) { const maybeSingle = vi.fn().mockResolvedValue({ data: row, error: null }) const supabase = { from: vi.fn(() => ({ @@ -56,19 +58,19 @@ describe('GET /api/user/preferences', () => { expect(res.status).toBe(401) }) - it('returns the stored preference', async () => { - authedForGet({ hide_assistant_fab: true }) + it('returns the stored preferences', async () => { + authedForGet({ hide_assistant_fab: true, auto_logout: true }) const res = await GET() const { status, body } = await parseJsonResponse<{ data: unknown }>(res) expect(status).toBe(200) - expect(body.data).toEqual({ hide_assistant_fab: true }) + expect(body.data).toEqual({ hide_assistant_fab: true, auto_logout: true }) }) it('defaults to false when no preferences row exists', async () => { authedForGet(null) const res = await GET() const { body } = await parseJsonResponse<{ data: unknown }>(res) - expect(body.data).toEqual({ hide_assistant_fab: false }) + expect(body.data).toEqual({ hide_assistant_fab: false, auto_logout: false }) }) }) @@ -103,6 +105,48 @@ describe('PATCH /api/user/preferences', () => { ) }) + it('writes a multi-field request as one atomic upsert', async () => { + const { upsert } = authedForPatch() + const res = await PATCH( + patchRequest({ hide_assistant_fab: true, auto_logout: false }) + ) + expect(res.status).toBe(200) + expect(upsert).toHaveBeenCalledTimes(1) + expect(upsert).toHaveBeenCalledWith( + { user_id: 'user-1', hide_assistant_fab: true, auto_logout: false }, + { onConflict: 'user_id' } + ) + }) + + it('rejects an empty body with 400', async () => { + authedForPatch() + const res = await PATCH(patchRequest({})) + expect(res.status).toBe(400) + }) + + it('upserts auto_logout and resets the session timeout cookie', async () => { + const { upsert } = authedForPatch() + const res = await PATCH(patchRequest({ auto_logout: true })) + const { status, body } = await parseJsonResponse<{ data: unknown }>(res) + expect(status).toBe(200) + expect(body.data).toEqual({ auto_logout: true }) + expect(upsert).toHaveBeenCalledWith( + { user_id: 'user-1', auto_logout: true }, + { onConflict: 'user_id' } + ) + // The signed timeout cookie caches the opt-in; a change must clear it so + // the middleware re-mints with the new preference on the next request. + const setCookie = res.headers.get('set-cookie') ?? '' + expect(setCookie).toContain('gnubok-session-timeout=;') + }) + + it('does not touch the session timeout cookie for unrelated preferences', async () => { + authedForPatch() + const res = await PATCH(patchRequest({ hide_assistant_fab: true })) + expect(res.status).toBe(200) + expect(res.headers.get('set-cookie')).toBeNull() + }) + it('returns 500 when the upsert fails', async () => { authedForPatch({ message: 'boom' }) const res = await PATCH(patchRequest({ hide_assistant_fab: false })) diff --git a/app/api/user/preferences/route.ts b/app/api/user/preferences/route.ts index 856fedb8..a986c9de 100644 --- a/app/api/user/preferences/route.ts +++ b/app/api/user/preferences/route.ts @@ -1,6 +1,9 @@ import { NextResponse } from 'next/server' import { z } from 'zod' import { requireAuth } from '@/lib/auth/require-auth' +import { getErrorMessage } from '@/lib/errors/get-error-message' +import { sessionTimeoutClearCookieOptions } from '@/lib/auth/session-timeout' +import { SESSION_TIMEOUT_COOKIE } from '@/lib/auth/session-timeout-shared' // User-level UI preferences (not company-scoped), stored on user_preferences. // Mirrors the /api/user/locale pattern: requireAuth directly because these @@ -8,9 +11,13 @@ import { requireAuth } from '@/lib/auth/require-auth' const BodySchema = z .object({ - hide_assistant_fab: z.boolean(), + hide_assistant_fab: z.boolean().optional(), + auto_logout: z.boolean().optional(), }) .strict() + .refine((value) => Object.keys(value).length > 0, { + message: 'At least one preference is required', + }) export async function GET() { const { user, supabase, error } = await requireAuth() @@ -18,12 +25,15 @@ export async function GET() { const { data } = await supabase .from('user_preferences') - .select('hide_assistant_fab') + .select('hide_assistant_fab, auto_logout') .eq('user_id', user.id) .maybeSingle() return NextResponse.json({ - data: { hide_assistant_fab: data?.hide_assistant_fab ?? false }, + data: { + hide_assistant_fab: data?.hide_assistant_fab ?? false, + auto_logout: data?.auto_logout ?? false, + }, }) } @@ -43,16 +53,56 @@ export async function PATCH(request: Request) { return NextResponse.json({ error: 'Invalid preferences' }, { status: 400 }) } - const { error: upsertError } = await supabase - .from('user_preferences') - .upsert( - { user_id: user.id, hide_assistant_fab: parsed.data.hide_assistant_fab }, - { onConflict: 'user_id' } - ) + const { hide_assistant_fab, auto_logout } = parsed.data - if (upsertError) { - return NextResponse.json({ error: 'Could not save preference' }, { status: 500 }) + // One literal upsert per accepted field combination: the phantom-column + // schema guard cannot resolve spread payloads, and a single write keeps a + // multi-field request atomic. + let upsertError: unknown = null + if (hide_assistant_fab !== undefined && auto_logout !== undefined) { + const { error } = await supabase + .from('user_preferences') + .upsert( + { user_id: user.id, hide_assistant_fab, auto_logout }, + { onConflict: 'user_id' }, + ) + upsertError = error + } else if (hide_assistant_fab !== undefined) { + const { error } = await supabase + .from('user_preferences') + .upsert({ user_id: user.id, hide_assistant_fab }, { onConflict: 'user_id' }) + upsertError = error + } else if (auto_logout !== undefined) { + const { error } = await supabase + .from('user_preferences') + .upsert({ user_id: user.id, auto_logout }, { onConflict: 'user_id' }) + upsertError = error } - return NextResponse.json({ data: parsed.data }) + if (upsertError) { + return NextResponse.json( + { + error: getErrorMessage(upsertError, { + context: 'settings', + statusCode: 500, + }), + }, + { status: 500 }, + ) + } + + const response = NextResponse.json({ data: parsed.data }) + + if (parsed.data.auto_logout !== undefined) { + // The middleware caches the opt-in inside the signed timeout cookie. + // Clearing it forces a re-mint on the next request, so the change takes + // effect immediately instead of at the next login. + response.cookies.set( + SESSION_TIMEOUT_COOKIE, + '', + sessionTimeoutClearCookieOptions(), + ) + } + + return response } diff --git a/components/settings/AutoLogoutToggle.tsx b/components/settings/AutoLogoutToggle.tsx new file mode 100644 index 00000000..1094f12a --- /dev/null +++ b/components/settings/AutoLogoutToggle.tsx @@ -0,0 +1,89 @@ +'use client' + +import { useEffect, useState } from 'react' +import { useTranslations } from 'next-intl' +import { Switch } from '@/components/ui/switch' +import { useToast } from '@/components/ui/use-toast' +import { SettingsRow } from '@/components/settings/SettingsRows' + +// Session timeouts are a hosted concern: self-hosted deployments have them +// disabled at the config level, so the toggle would be a no-op there. +const isSelfHosted = process.env.NEXT_PUBLIC_SELF_HOSTED === 'true' + +/** + * Per-user opt-in for automatic logout. Off by default: the session then + * lives as long as the Supabase refresh token. On: the hosted idle/absolute + * timeouts apply (enforced by the middleware via the signed timeout cookie, + * which the preferences API resets on change). + */ +export function AutoLogoutToggle() { + const t = useTranslations('settings_security') + const { toast } = useToast() + const [enabled, setEnabled] = useState(false) + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + + useEffect(() => { + if (isSelfHosted) return + let active = true + ;(async () => { + try { + const res = await fetch('/api/user/preferences', { cache: 'no-store' }) + if (!res.ok) return + const payload = (await res.json()) as { + data?: { auto_logout?: boolean } + } + if (active) setEnabled(payload.data?.auto_logout === true) + } catch { + // Leave the default (off); a failed read must not block the page. + } finally { + if (active) setLoading(false) + } + })() + return () => { + active = false + } + }, []) + + if (isSelfHosted) return null + + async function handleChange(next: boolean) { + setEnabled(next) + setSaving(true) + try { + const res = await fetch('/api/user/preferences', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ auto_logout: next }), + }) + if (!res.ok) throw new Error('Could not save') + toast({ + title: next + ? t('auto_logout_enabled_toast') + : t('auto_logout_disabled_toast'), + }) + } catch { + setEnabled(!next) + toast({ title: t('auto_logout_save_failed'), variant: 'destructive' }) + } finally { + setSaving(false) + } + } + + return ( + + void handleChange(value)} + disabled={loading || saving} + /> + + + ) +} diff --git a/components/settings/SecuritySettings.tsx b/components/settings/SecuritySettings.tsx index e5f3f293..070eabb3 100644 --- a/components/settings/SecuritySettings.tsx +++ b/components/settings/SecuritySettings.tsx @@ -10,6 +10,7 @@ import { useToast } from '@/components/ui/use-toast' import { Loader2, ShieldCheck, ShieldOff } from 'lucide-react' import { isMfaRequired } from '@/lib/auth/mfa' import { isBankIdEnabled } from '@/lib/auth/bankid' +import { AutoLogoutToggle } from '@/components/settings/AutoLogoutToggle' import { BankIdSettings } from '@/components/settings/BankIdSettings' import { userHasPassword } from '@/lib/auth/has-password' import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message' @@ -327,6 +328,9 @@ export function SecuritySettings() { )} )} + + {/* Automatic logout: per-user opt-in, renders nothing on self-hosted */} + ) } diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index 54701062..c7b8f49b 100755 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -95,6 +95,7 @@ if [ -n "$SUBST_PATHS" ]; then E_SESSION_IDLE_TIMEOUT_MS=$(sed_esc "${NEXT_PUBLIC_SESSION_IDLE_TIMEOUT_MS:-}") E_SESSION_ABSOLUTE_TIMEOUT_MS=$(sed_esc "${NEXT_PUBLIC_SESSION_ABSOLUTE_TIMEOUT_MS:-}") E_SESSION_WARNING_MS=$(sed_esc "${NEXT_PUBLIC_SESSION_WARNING_MS:-}") + E_SESSION_TIMEOUT_FORCE_ALL=$(sed_esc "${NEXT_PUBLIC_SESSION_TIMEOUT_FORCE_ALL:-}") E_BRANDING_APP_NAME=$(sed_esc "${NEXT_PUBLIC_BRANDING_APP_NAME:-Gnubok}") # File-type coverage: @@ -119,6 +120,7 @@ if [ -n "$SUBST_PATHS" ]; then -e "s|__NEXT_PUBLIC_SESSION_IDLE_TIMEOUT_MS__|${E_SESSION_IDLE_TIMEOUT_MS}|g" \ -e "s|__NEXT_PUBLIC_SESSION_ABSOLUTE_TIMEOUT_MS__|${E_SESSION_ABSOLUTE_TIMEOUT_MS}|g" \ -e "s|__NEXT_PUBLIC_SESSION_WARNING_MS__|${E_SESSION_WARNING_MS}|g" \ + -e "s|__NEXT_PUBLIC_SESSION_TIMEOUT_FORCE_ALL__|${E_SESSION_TIMEOUT_FORCE_ALL}|g" \ -e "s|__NEXT_PUBLIC_BRANDING_APP_NAME__|${E_BRANDING_APP_NAME}|g" fi diff --git a/docs/SELF-HOSTING.md b/docs/SELF-HOSTING.md index acb74536..7a91492e 100644 --- a/docs/SELF-HOSTING.md +++ b/docs/SELF-HOSTING.md @@ -93,9 +93,12 @@ CRON_SECRET= `NEXT_PUBLIC_APP_URL` must match your public-facing URL. It is used in invoice reminder emails, calendar feed links, and PSD2 callbacks. If left as a placeholder, links will be broken. -Hosted Accounted sessions default to a 30-minute idle limit, a 12-hour absolute -limit, and a warning 2 minutes before expiry. Self-hosted installations leave -both limits disabled unless you opt in (values are milliseconds): +Automatic logout is opt-in per user: sessions only expire for users who have +enabled "Automatic logout" in Settings > Security (stored on +`user_preferences.auto_logout`, default off). For opted-in users, hosted +Accounted defaults to a 30-minute idle limit, a 12-hour absolute limit, and a +warning 2 minutes before expiry. Self-hosted installations leave both limits +disabled unless you opt in (values are milliseconds): ```bash NEXT_PUBLIC_SESSION_IDLE_TIMEOUT_MS=1800000 @@ -103,7 +106,15 @@ NEXT_PUBLIC_SESSION_ABSOLUTE_TIMEOUT_MS=43200000 NEXT_PUBLIC_SESSION_WARNING_MS=120000 ``` -Set either timeout to `0` to disable only that limit. Timeout state is signed +Set either timeout to `0` to disable only that limit. To enforce the timeouts +for every user regardless of their per-user preference (the pre-2026-08 +behavior), also set: + +```bash +NEXT_PUBLIC_SESSION_TIMEOUT_FORCE_ALL=true +``` + +Timeout state is signed with `SUPABASE_SERVICE_ROLE_KEY` by default; set `SESSION_TIMEOUT_SECRET` to a separate random value if you want to rotate it independently. Changing either signing secret invalidates existing timeout cookies and requires users to sign diff --git a/lib/auth/__tests__/session-timeout.test.ts b/lib/auth/__tests__/session-timeout.test.ts index 88baaec9..d7632efc 100644 --- a/lib/auth/__tests__/session-timeout.test.ts +++ b/lib/auth/__tests__/session-timeout.test.ts @@ -3,9 +3,12 @@ import { apiRequestSkipsSessionTimeout, createSessionTimeoutState, evaluateSessionTimeout, + fetchAutoLogoutPreference, getSessionTimeoutConfig, sessionStateMatchesUser, + sessionStateNeedsRemint, signSessionTimeoutState, + toSessionTimeoutClientState, verifySessionTimeoutState, } from '../session-timeout' @@ -20,6 +23,7 @@ describe('session timeout configuration', () => { idleTimeoutMs: 30 * 60 * 1000, absoluteTimeoutMs: 12 * 60 * 60 * 1000, warningMs: 2 * 60 * 1000, + enforceForAll: false, }) }) @@ -29,6 +33,7 @@ describe('session timeout configuration', () => { idleTimeoutMs: 0, absoluteTimeoutMs: 0, warningMs: 0, + enforceForAll: false, }) }) @@ -42,6 +47,7 @@ describe('session timeout configuration', () => { idleTimeoutMs: 60000, absoluteTimeoutMs: 0, warningMs: 60000, + enforceForAll: false, }) }) @@ -55,9 +61,16 @@ describe('session timeout configuration', () => { idleTimeoutMs: 0, absoluteTimeoutMs: 60000, warningMs: 10000, + enforceForAll: false, }) }) + it('reads the force-all override', () => { + expect(getSessionTimeoutConfig({ + NEXT_PUBLIC_SESSION_TIMEOUT_FORCE_ALL: 'true', + })).toMatchObject({ enforceForAll: true }) + }) + it('ignores negative and non-integer overrides', () => { expect(getSessionTimeoutConfig({ NEXT_PUBLIC_SESSION_IDLE_TIMEOUT_MS: '-1', @@ -75,6 +88,7 @@ describe('signed session timeout state', () => { userId: 'user-1', sessionId: 'session-1', method: 'bankid', + autoLogout: true, now: 1000, }) @@ -89,6 +103,7 @@ describe('signed session timeout state', () => { userId: 'user-1', sessionId: 'session-1', method: 'password', + autoLogout: true, now: 1000, }) @@ -100,6 +115,7 @@ describe('signed session timeout state', () => { userId: 'user-1', sessionId: 'session-1', method: 'password', + autoLogout: true, now: 1000, }) const signed = await signSessionTimeoutState(state, SIGNING_ENV) @@ -118,6 +134,7 @@ describe('signed session timeout state', () => { userId: 'user-1', sessionId: 'session-1', method: 'password', + autoLogout: true, now: 1000, }) @@ -131,12 +148,14 @@ describe('signed session timeout state', () => { userId: 'user-1', sessionId: 'session-1', method: 'password', + autoLogout: true, now: 1000, }) const unbound = createSessionTimeoutState({ userId: 'user-1', sessionId: null, method: 'password', + autoLogout: true, now: 1000, }) @@ -152,6 +171,7 @@ describe('session expiry', () => { idleTimeoutMs: 30_000, absoluteTimeoutMs: 60_000, warningMs: 10_000, + enforceForAll: false, } it('uses inclusive boundaries and gives absolute expiry precedence', () => { @@ -160,6 +180,7 @@ describe('session expiry', () => { userId: 'user-1', sessionId: 'session-1', method: 'password' as const, + autoLogout: true, now: 1000, }), lastActivityAt: 31_000, @@ -174,6 +195,7 @@ describe('session expiry', () => { userId: 'user-1', sessionId: null, method: 'password', + autoLogout: true, now: 1000, }) @@ -182,6 +204,121 @@ describe('session expiry', () => { }) }) +describe('per-user opt-in gating', () => { + const config = { + enabled: true, + idleTimeoutMs: 30_000, + absoluteTimeoutMs: 60_000, + warningMs: 10_000, + enforceForAll: false, + } + + function makeState(autoLogout: boolean) { + return createSessionTimeoutState({ + userId: 'user-1', + sessionId: null, + method: 'password' as const, + autoLogout, + now: 1000, + }) + } + + it('never expires a session that has not opted in', () => { + const state = makeState(false) + + // Far past both limits: still no timeout without the opt-in. + expect(evaluateSessionTimeout(state, config, 10_000_000)).toBeNull() + }) + + it('expires an opted-in session normally', () => { + const state = makeState(true) + + expect(evaluateSessionTimeout(state, config, 61_000)).toBe('absolute') + }) + + it('lets enforceForAll override the opt-out', () => { + const state = makeState(false) + + expect( + evaluateSessionTimeout(state, { ...config, enforceForAll: true }, 61_000), + ).toBe('absolute') + }) + + it('treats a pre-toggle state as authentic but needing a re-mint', async () => { + const legacy = makeState(true) as { autoLogout?: boolean } + delete legacy.autoLogout + const state = legacy as ReturnType + + const signed = await signSessionTimeoutState(state, SIGNING_ENV) + await expect(verifySessionTimeoutState(signed!, SIGNING_ENV)).resolves.toEqual(state) + expect(sessionStateNeedsRemint(state)).toBe(true) + expect(sessionStateNeedsRemint(makeState(false))).toBe(false) + // A legacy state is never enforced against until it has been re-minted. + expect(evaluateSessionTimeout(state, config, 10_000_000)).toBeNull() + }) + + it('reports the client state as enabled only when enforced', () => { + expect(toSessionTimeoutClientState(makeState(true), config, 2000).enabled).toBe(true) + expect(toSessionTimeoutClientState(makeState(false), config, 2000).enabled).toBe(false) + expect( + toSessionTimeoutClientState( + makeState(false), + { ...config, enforceForAll: true }, + 2000, + ).enabled, + ).toBe(true) + expect( + toSessionTimeoutClientState( + makeState(true), + { ...config, enabled: false }, + 2000, + ).enabled, + ).toBe(false) + }) +}) + +describe('fetchAutoLogoutPreference', () => { + function client(result: { data: unknown; error: unknown } | 'throw') { + return { + from: () => ({ + select: () => ({ + eq: () => ({ + maybeSingle: async () => { + if (result === 'throw') throw new Error('network down') + return result + }, + }), + }), + }), + } as unknown as Parameters[0] + } + + it('returns the stored opt-in', async () => { + await expect( + fetchAutoLogoutPreference(client({ data: { auto_logout: true }, error: null }), 'u1'), + ).resolves.toBe(true) + await expect( + fetchAutoLogoutPreference(client({ data: { auto_logout: false }, error: null }), 'u1'), + ).resolves.toBe(false) + }) + + it('treats a missing row as a definitive opt-out', async () => { + await expect( + fetchAutoLogoutPreference(client({ data: null, error: null }), 'u1'), + ).resolves.toBe(false) + }) + + it('returns null (unknown) on a failed read, never a fail-open false', async () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + await expect( + fetchAutoLogoutPreference(client({ data: null, error: { message: 'boom' } }), 'u1'), + ).resolves.toBeNull() + await expect(fetchAutoLogoutPreference(client('throw'), 'u1')).resolves.toBeNull() + expect(errorSpy).toHaveBeenCalled() + errorSpy.mockRestore() + }) +}) + describe('API exclusions', () => { it('only lets bearer-authenticated machine surfaces bypass timeouts', () => { expect(apiRequestSkipsSessionTimeout('/api/v1/companies/c1/invoices', true)).toBe(true) diff --git a/lib/auth/session-timeout.ts b/lib/auth/session-timeout.ts index 59cee7cb..b8ff863a 100644 --- a/lib/auth/session-timeout.ts +++ b/lib/auth/session-timeout.ts @@ -1,3 +1,4 @@ +import type { SupabaseClient } from '@supabase/supabase-js' import { SESSION_TIMEOUT_COOKIE, type SessionAuthMethod, @@ -16,6 +17,9 @@ export interface SessionTimeoutConfig { idleTimeoutMs: number absoluteTimeoutMs: number warningMs: number + // Ignore the per-user auto_logout opt-in and enforce timeouts for every + // cookie session (emergency lever / strict self-hosted deployments). + enforceForAll: boolean } export interface SessionTimeoutState { @@ -25,6 +29,10 @@ export interface SessionTimeoutState { startedAt: number lastActivityAt: number method: SessionAuthMethod + // Snapshot of user_preferences.auto_logout taken when the state was minted. + // Absent on cookies minted before the opt-in toggle existed: those must be + // re-minted (reading the preference), never treated as tampered. + autoLogout?: boolean } type Environment = Record @@ -64,6 +72,7 @@ export function getSessionTimeoutConfig( idleTimeoutMs, absoluteTimeoutMs, warningMs, + enforceForAll: env.NEXT_PUBLIC_SESSION_TIMEOUT_FORCE_ALL === 'true', } } @@ -71,6 +80,7 @@ export function createSessionTimeoutState(args: { userId: string sessionId: string | null method: SessionAuthMethod + autoLogout: boolean now?: number }): SessionTimeoutState { const now = args.now ?? Date.now() @@ -81,6 +91,56 @@ export function createSessionTimeoutState(args: { startedAt: now, lastActivityAt: now, method: args.method, + autoLogout: args.autoLogout, + } +} + +/** + * Whether timeouts actually apply to this session. Auto logout is opt-in + * per user (founder decision 2026-08-12): without the opt-in snapshot, or + * the global force-all override, a session lives as long as the Supabase + * refresh token. + */ +export function sessionTimeoutEnforced( + state: SessionTimeoutState, + config: SessionTimeoutConfig, +): boolean { + return config.enabled && (state.autoLogout === true || config.enforceForAll) +} + +/** + * Read the user's auto_logout opt-in. A missing row is a definitive false + * (never opted in); a FAILED read returns null, deliberately distinct from + * the opt-out path: callers must not persist a snapshot for an unknown + * preference (which would silently disable the control for an opted-in + * user for the cookie's lifetime) and instead skip minting so the next + * request retries the read. Sessions that already carry a snapshot are + * unaffected: enforcement keeps running off the signed cookie. + */ +export async function fetchAutoLogoutPreference( + supabase: SupabaseClient, + userId: string, +): Promise { + try { + const { data, error } = await supabase + .from('user_preferences') + .select('auto_logout') + .eq('user_id', userId) + .maybeSingle() + if (error) { + console.error( + '[session-timeout] auto_logout preference read FAILED; enforcement undetermined for this request', + error, + ) + return null + } + return data?.auto_logout === true + } catch (error) { + console.error( + '[session-timeout] auto_logout preference read FAILED; enforcement undetermined for this request', + error, + ) + return null } } @@ -89,6 +149,8 @@ export function evaluateSessionTimeout( config: SessionTimeoutConfig, now = Date.now(), ): SessionTimeoutReason | null { + if (!sessionTimeoutEnforced(state, config)) return null + if ( config.absoluteTimeoutMs > 0 && now - state.startedAt >= config.absoluteTimeoutMs @@ -169,6 +231,9 @@ function isValidState(value: unknown): value is SessionTimeoutState { Number.isSafeInteger(state.startedAt) && Number.isSafeInteger(state.lastActivityAt) && (state.method === 'password' || state.method === 'bankid') && + // Absent on pre-toggle cookies: valid, but callers re-mint (see + // sessionStateNeedsRemint) instead of treating the cookie as forged. + (state.autoLogout === undefined || typeof state.autoLogout === 'boolean') && (state.startedAt as number) > 0 && (state.lastActivityAt as number) >= (state.startedAt as number) ) @@ -284,7 +349,7 @@ export function toSessionTimeoutClientState( serverNow = Date.now(), ): SessionTimeoutClientState { return { - enabled: config.enabled, + enabled: sessionTimeoutEnforced(state, config), idleTimeoutMs: config.idleTimeoutMs, absoluteTimeoutMs: config.absoluteTimeoutMs, warningMs: config.warningMs, @@ -309,6 +374,15 @@ export function sessionStateMatchesUser( return state.sessionId === sessionId } +/** + * Pre-toggle cookies carry no auto_logout snapshot. They are authentic, so + * they must not hit the tampering path; callers re-mint them, reading the + * preference, so every live cookie converges on the v-with-snapshot shape. + */ +export function sessionStateNeedsRemint(state: SessionTimeoutState): boolean { + return state.autoLogout === undefined +} + export function apiRequestSkipsSessionTimeout( pathname: string, hasAuthorizationHeader: boolean, diff --git a/lib/supabase/__tests__/middleware.test.ts b/lib/supabase/__tests__/middleware.test.ts index fdb7b484..045c02b5 100644 --- a/lib/supabase/__tests__/middleware.test.ts +++ b/lib/supabase/__tests__/middleware.test.ts @@ -27,6 +27,9 @@ const state = vi.hoisted(() => ({ error: unknown }, signOut: vi.fn(async () => ({ error: null })), + // Row returned for user_preferences reads (the auto_logout mint lookup). + userPreferences: null as null | { auto_logout: boolean }, + userPreferencesError: null as unknown, })) vi.mock('@supabase/ssr', () => ({ @@ -46,13 +49,20 @@ vi.mock('@supabase/ssr', () => ({ }, }, rpc: vi.fn(async () => state.company), - from: vi.fn(() => { + from: vi.fn((table: string) => { const chain: Record = {} const self = new Proxy(chain, { get: (_t, prop) => { if (prop === 'then') return undefined if (prop === 'maybeSingle' || prop === 'single') { - return async () => ({ data: null, error: null }) + return async () => ({ + data: + table === 'user_preferences' && !state.userPreferencesError + ? state.userPreferences + : null, + error: + table === 'user_preferences' ? state.userPreferencesError : null, + }) } return () => self }, @@ -102,6 +112,8 @@ describe('updateSession redirect destinations', () => { data: [{ company_id: 'company-1', locale: 'sv', used_fallback: false }], error: null, } + state.userPreferences = null + state.userPreferencesError = null delete process.env.NEXT_PUBLIC_REQUIRE_MFA delete process.env.NEXT_PUBLIC_SELF_HOSTED process.env.SESSION_TIMEOUT_SECRET = 'middleware-test-secret' @@ -139,18 +151,26 @@ describe('updateSession redirect destinations', () => { method?: 'password' | 'bankid' userId?: string sessionId?: string | null + autoLogout?: boolean + legacy?: boolean }) { const stateValue = { ...createSessionTimeoutState({ userId: args?.userId ?? 'user-1', sessionId: args?.sessionId === undefined ? 'session-1' : args.sessionId, method: args?.method ?? 'password', + // Default the opt-in to true: these tests exercise enforcement. + autoLogout: args?.autoLogout ?? true, now: args?.startedAt ?? Date.now(), }), ...(args?.lastActivityAt === undefined ? {} : { lastActivityAt: args.lastActivityAt }), } + if (args?.legacy) { + // Pre-toggle cookies carry no auto_logout snapshot. + delete (stateValue as { autoLogout?: boolean }).autoLogout + } const signed = await signSessionTimeoutState(stateValue) if (!signed) throw new Error('test signing secret missing') return signed @@ -233,6 +253,77 @@ describe('updateSession redirect destinations', () => { expect((await run('/api/v1/companies/c1/invoices', { headers })).status).toBe(200) }) + it('mints the cookie with the auto_logout opt-out default for new sessions', async () => { + const response = await run('/settings/tax') + + expect(response.status).toBe(200) + const encoded = response.cookies.get(SESSION_TIMEOUT_COOKIE)?.value + await expect(verifySessionTimeoutState(encoded)).resolves.toMatchObject({ + autoLogout: false, + }) + }) + + it('snapshots an opted-in preference at mint time', async () => { + state.userPreferences = { auto_logout: true } + + const response = await run('/settings/tax') + + const encoded = response.cookies.get(SESSION_TIMEOUT_COOKIE)?.value + await expect(verifySessionTimeoutState(encoded)).resolves.toMatchObject({ + autoLogout: true, + }) + }) + + it('persists no snapshot when the preference read fails', async () => { + state.userPreferencesError = { message: 'connection reset' } + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + const response = await run('/settings/tax') + + // Unknown preference: nothing minted, nobody logged out; the next + // request retries the read. + expect(response.status).toBe(200) + expect(response.cookies.get(SESSION_TIMEOUT_COOKIE)).toBeUndefined() + expect(state.signOut).not.toHaveBeenCalled() + errorSpy.mockRestore() + }) + + it('never logs out a session that has not opted in', async () => { + const now = Date.now() + // Far past both limits: without the opt-in the session must survive. + const encoded = await signedCookie({ + startedAt: now - 600_000, + lastActivityAt: now - 600_000, + autoLogout: false, + }) + + const response = await run('/reports/vat', { + headers: { cookie: `${SESSION_TIMEOUT_COOKIE}=${encoded}` }, + }) + + expect(response.status).toBe(200) + expect(state.signOut).not.toHaveBeenCalled() + }) + + it('upgrades a pre-toggle cookie in place instead of treating it as forged', async () => { + state.userPreferences = { auto_logout: true } + const startedAt = Date.now() - 5_000 + const encoded = await signedCookie({ startedAt, legacy: true }) + + const response = await run('/settings/tax', { + headers: { cookie: `${SESSION_TIMEOUT_COOKIE}=${encoded}` }, + }) + + expect(response.status).toBe(200) + expect(state.signOut).not.toHaveBeenCalled() + const upgraded = response.cookies.get(SESSION_TIMEOUT_COOKIE)?.value + // Timers survive the upgrade: only the opt-in snapshot is added. + await expect(verifySessionTimeoutState(upgraded)).resolves.toMatchObject({ + startedAt, + autoLogout: true, + }) + }) + it('starts a new timeout window when the Supabase session changes', async () => { const encoded = await signedCookie({ sessionId: 'old-session' }) diff --git a/lib/supabase/middleware.ts b/lib/supabase/middleware.ts index 99b528c3..b8114e10 100644 --- a/lib/supabase/middleware.ts +++ b/lib/supabase/middleware.ts @@ -9,8 +9,10 @@ import { apiRequestSkipsSessionTimeout, createSessionTimeoutState, evaluateSessionTimeout, + fetchAutoLogoutPreference, getSessionTimeoutConfig, sessionStateMatchesUser, + sessionStateNeedsRemint, sessionTimeoutClearCookieOptions, sessionTimeoutCookieOptions, signSessionTimeoutState, @@ -103,9 +105,14 @@ export async function updateSession(request: NextRequest) { ) } + const stateMatches = + verifiedState !== null && + sessionStateMatchesUser(verifiedState, user.id, sessionId) + if ( !verifiedState || - !sessionStateMatchesUser(verifiedState, user.id, sessionId) + !stateMatches || + sessionStateNeedsRemint(verifiedState) ) { const hintedMethod = request.cookies.get( SESSION_AUTH_METHOD_HINT_COOKIE, @@ -113,21 +120,32 @@ export async function updateSession(request: NextRequest) { const method = isSessionAuthMethod(hintedMethod) ? hintedMethod : 'password' - const state = createSessionTimeoutState({ - userId: user.id, - sessionId, - method, - }) - const signedState = await signSessionTimeoutState(state) + const autoLogout = await fetchAutoLogoutPreference(supabase, user.id) - if (signedState) { - request.cookies.set(SESSION_TIMEOUT_COOKIE, signedState) - supabaseResponse.cookies.set( - SESSION_TIMEOUT_COOKIE, - signedState, - sessionTimeoutCookieOptions(), - ) - clearAuthMethodHint(request, supabaseResponse) + // Unknown preference (failed read): mint nothing, so no fail-open + // snapshot gets persisted; the next request retries the read. + if (autoLogout !== null) { + // A matching pre-toggle cookie keeps its timers: upgrading the shape + // must not restart the absolute window. + const state = verifiedState && stateMatches + ? { ...verifiedState, autoLogout } + : createSessionTimeoutState({ + userId: user.id, + sessionId, + method, + autoLogout, + }) + const signedState = await signSessionTimeoutState(state) + + if (signedState) { + request.cookies.set(SESSION_TIMEOUT_COOKIE, signedState) + supabaseResponse.cookies.set( + SESSION_TIMEOUT_COOKIE, + signedState, + sessionTimeoutCookieOptions(), + ) + clearAuthMethodHint(request, supabaseResponse) + } } } else { const timeoutReason = evaluateSessionTimeout( diff --git a/messages/en.json b/messages/en.json index 41ef4c03..6fcd86a1 100644 --- a/messages/en.json +++ b/messages/en.json @@ -2605,6 +2605,12 @@ "link_button": "Link BankID" }, "settings_security": { + "auto_logout_label": "Automatic logout", + "auto_logout_description": "Signs you out automatically after a period of inactivity, and at the latest after a per-session maximum. When off, you stay signed in on this browser until you sign out yourself.", + "auto_logout_switch_label": "Log me out automatically when inactive", + "auto_logout_enabled_toast": "Automatic logout enabled", + "auto_logout_disabled_toast": "Automatic logout disabled", + "auto_logout_save_failed": "Could not save the setting", "set_password_title": "Set a password", "set_password_description": "You signed in with BankID and have no password yet. Set one to enable 2FA or to sign in when BankID is unavailable.", "set_password_button": "Set password", diff --git a/messages/sv.json b/messages/sv.json index 9ec242ec..67fbc888 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -2605,6 +2605,12 @@ "link_button": "Koppla BankID" }, "settings_security": { + "auto_logout_label": "Automatisk utloggning", + "auto_logout_description": "Loggar ut dig automatiskt efter en period av inaktivitet, och senast efter en maxtid per session. Avstängd förblir du inloggad i den här webbläsaren tills du loggar ut själv.", + "auto_logout_switch_label": "Logga ut mig automatiskt vid inaktivitet", + "auto_logout_enabled_toast": "Automatisk utloggning aktiverad", + "auto_logout_disabled_toast": "Automatisk utloggning avstängd", + "auto_logout_save_failed": "Kunde inte spara inställningen", "set_password_title": "Sätt ett lösenord", "set_password_description": "Du loggade in med BankID och har inget lösenord ännu. Sätt ett lösenord för att kunna aktivera 2FA eller logga in när BankID inte är tillgängligt.", "set_password_button": "Sätt lösenord", diff --git a/supabase/migrations/20260812134817_add_auto_logout_preference.sql b/supabase/migrations/20260812134817_add_auto_logout_preference.sql new file mode 100644 index 00000000..ab569867 --- /dev/null +++ b/supabase/migrations/20260812134817_add_auto_logout_preference.sql @@ -0,0 +1,8 @@ +-- Per-user opt-in automatic logout (founder-approved 2026-08-12): the hosted +-- session timeouts (idle/absolute) only apply to users who enable this. +-- Default false keeps everyone signed in for the full Supabase session +-- lifetime, the behavior from before the 2026-07 session hardening. +alter table public.user_preferences + add column if not exists auto_logout boolean not null default false; + +notify pgrst, 'reload schema';