From 1c9d378df8e5537426cfd78238540a43b9ebe835 Mon Sep 17 00:00:00 2001 From: Mattsson <111893710+mattssonn@users.noreply.github.com> Date: Tue, 4 Aug 2026 09:59:42 +0200 Subject: [PATCH] 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 * 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 * 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 * 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 --------- Co-authored-by: Claude Fable 5 --- .env.docker.example | 8 + .env.example | 10 + CLAUDE.md | 2 +- Dockerfile | 3 + app/(auth)/login/page.tsx | 70 +++- app/(dashboard)/layout.tsx | 4 + app/(onboarding)/layout.tsx | 2 + .../auth/heartbeat/__tests__/route.test.ts | 139 ++++++++ app/api/auth/heartbeat/route.ts | 131 +++++++ app/companies/new/page.tsx | 2 + components/auth/SessionTimeoutController.tsx | 250 +++++++++++++ components/auth/SessionTimeoutModal.tsx | 57 +++ docker-entrypoint.sh | 6 + docs/SELF-HOSTING.md | 18 +- lib/auth/__tests__/session-timeout.test.ts | 193 ++++++++++ lib/auth/session-timeout-shared.ts | 28 ++ lib/auth/session-timeout.ts | 330 ++++++++++++++++++ lib/supabase/__tests__/middleware.test.ts | 158 ++++++++- lib/supabase/middleware.ts | 192 +++++++++- messages/en.json | 10 + messages/sv.json | 10 + 21 files changed, 1610 insertions(+), 13 deletions(-) create mode 100644 app/api/auth/heartbeat/__tests__/route.test.ts create mode 100644 app/api/auth/heartbeat/route.ts create mode 100644 components/auth/SessionTimeoutController.tsx create mode 100644 components/auth/SessionTimeoutModal.tsx create mode 100644 lib/auth/__tests__/session-timeout.test.ts create mode 100644 lib/auth/session-timeout-shared.ts create mode 100644 lib/auth/session-timeout.ts diff --git a/.env.docker.example b/.env.docker.example index fc92b31b..2bd3f938 100644 --- a/.env.docker.example +++ b/.env.docker.example @@ -7,6 +7,14 @@ CRON_SECRET=generate-a-random-secret # Self-hosted (Docker) flag: disables application-side MFA enforcement. 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). +# NEXT_PUBLIC_SESSION_IDLE_TIMEOUT_MS=1800000 +# NEXT_PUBLIC_SESSION_ABSOLUTE_TIMEOUT_MS=43200000 +# NEXT_PUBLIC_SESSION_WARNING_MS=120000 +# Optional dedicated HMAC secret; otherwise SUPABASE_SERVICE_ROLE_KEY is used. +# SESSION_TIMEOUT_SECRET= + # Optional: WebSocket origin allowed for Supabase Realtime in the CSP. # Defaults to NEXT_PUBLIC_SUPABASE_URL with https:// replaced by wss:// # (http:// by ws://). Set only if Realtime is served from another origin. diff --git a/.env.example b/.env.example index c37693a6..9ec7881e 100644 --- a/.env.example +++ b/.env.example @@ -14,6 +14,16 @@ NEXT_PUBLIC_APP_URL=http://localhost:3000 # Any non-empty random string for local dev: openssl rand -hex 16 CRON_SECRET=generate-a-random-secret +# 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. +# 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 +# SESSION_TIMEOUT_SECRET= + # ── Optional: extension features (core runs without these) ─ # AI features # ANTHROPIC_API_KEY= diff --git a/CLAUDE.md b/CLAUDE.md index 7b0dde18..4ceb6908 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -68,7 +68,7 @@ npm run skills:generate # Regenerate agent_atom_registry seed after editing an - **Journal entry lifecycle**: `createDraftEntry()` → `commitEntry()` (atomic voucher via `commit_journal_entry` RPC); `createJournalEntry()` does both. Everything accounting-shaped routes through this engine. - **Tenancy**: every business table has `company_id`. Active company resolves in `lib/supabase/middleware.ts` from `user_preferences.active_company_id` (authoritative: RLS reads the same value via `current_active_company_id()`), falling back to first non-archived membership. The `gnubok-company-id` cookie is written as a hint for legacy read paths but deliberately no longer read: letting it override the DB would desync Next.js from RLS. RLS uses `user_company_ids()`; queries still filter by `company_id` explicitly (defense in depth: service-role paths have no RLS). -- **Auth**: Supabase email+password + TOTP MFA, enforced **application-side**, not in RLS. `NEXT_PUBLIC_REQUIRE_MFA=true` on hosted; `NEXT_PUBLIC_SELF_HOSTED=true` disables MFA. API routes wrap `withRouteContext`: it is the only path that enforces MFA, so never hand-roll `supabase.auth.getUser()` in a route. +- **Auth**: Supabase email+password + TOTP MFA, enforced **application-side**, not in RLS. `NEXT_PUBLIC_REQUIRE_MFA=true` on hosted; `NEXT_PUBLIC_SELF_HOSTED=true` disables MFA. Hosted browser sessions also have signed server-enforced idle/absolute limits (`lib/auth/session-timeout.ts`); API-key and MCP bearer surfaces are exempt. API routes wrap `withRouteContext`: it is the only path that enforces MFA, so never hand-roll `supabase.auth.getUser()` in a route. - **Events**: `lib/events/bus.ts` is a module-level singleton. Any route that emits events must call `ensureInitialized()` (`lib/init.ts`) at module level: otherwise extension handlers are never wired and events silently go nowhere. - **Supabase clients**: browser `client.ts`, server `createClient()`, service role `createServiceClient()`, cookieless service role `createServiceClientNoCookies()` (lives in `lib/auth/api-keys.ts`; for API-key/MCP paths). Paginate with `fetchAllRows()`: PostgREST silently caps at 1000 rows. - **Extensions**: opt-in plugins in `extensions/general//`; `extensions.config.json` is the source of truth for what's enabled. Core must run with zero extensions. diff --git a/Dockerfile b/Dockerfile index c46ffa25..a3530e2b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -38,6 +38,9 @@ ENV NEXT_PUBLIC_APP_URL=__NEXT_PUBLIC_APP_URL__ ENV NEXT_PUBLIC_VAPID_PUBLIC_KEY=__NEXT_PUBLIC_VAPID_PUBLIC_KEY__ ENV NEXT_PUBLIC_SELF_HOSTED=__NEXT_PUBLIC_SELF_HOSTED__ 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__ # 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/(auth)/login/page.tsx b/app/(auth)/login/page.tsx index 1e03dd33..abda8772 100644 --- a/app/(auth)/login/page.tsx +++ b/app/(auth)/login/page.tsx @@ -22,6 +22,12 @@ import { INVITE_PROBLEM_MESSAGE_KEYS, } from '@/lib/auth/consume-invite-cookie' import { AuthPageSkeleton } from '@/components/auth/AuthPageSkeleton' +import { resetAnalyticsIdentity } from '@/lib/analytics/reset' +import { + isSessionAuthMethod, + setSessionAuthMethodHint, + type SessionTimeoutReason, +} from '@/lib/auth/session-timeout-shared' const branding = getBranding() import type { BankIdResult } from '@/components/auth/BankIdAuth' @@ -47,6 +53,7 @@ function LoginPageContent() { const [isLoading, setIsLoading] = useState(false) const [isEmailSent, setIsEmailSent] = useState(false) const [showResetPassword, setShowResetPassword] = useState(false) + const [showPasswordFallback, setShowPasswordFallback] = useState(false) const [resetCooldownUntil, setResetCooldownUntil] = useState(null) const [resetCooldownRemaining, setResetCooldownRemaining] = useState(0) const [bankIdNoAccount, setBankIdNoAccount] = useState<{ givenName?: string; surname?: string } | null>(null) @@ -55,6 +62,11 @@ function LoginPageContent() { const searchParams = useSearchParams() const callbackError = searchParams.get('error') const callbackFlow = searchParams.get('flow') + const reasonParam = searchParams.get('reason') + const timeoutReason: SessionTimeoutReason | null = + reasonParam === 'idle' || reasonParam === 'absolute' ? reasonParam : null + const methodParam = searchParams.get('method') + const requestedMethod = isSessionAuthMethod(methodParam) ? methodParam : 'password' // Post-login destination, set e.g. by the MCP OAuth authorize endpoint // (/login?next=/api/mcp-oauth/authorize?...). Sanitized to a same-origin // relative path; '/' means no explicit destination. @@ -66,6 +78,10 @@ function LoginPageContent() { const tInvite = useTranslations('invite') const errorLocale = useLocale() as ErrorLocale + useEffect(() => { + if (timeoutReason) resetAnalyticsIdentity() + }, [timeoutReason]) + // Accept a pending invite, if any, and report a non-definitive failure. // Returns true when the caller should land the user in the app directly. // The invite cookie survives anything that is not a settled outcome, so @@ -107,6 +123,7 @@ function LoginPageContent() { if (result.error === 'service_unavailable') { setBankIdUnavailable(true) + setShowPasswordFallback(true) return } @@ -136,6 +153,8 @@ function LoginPageContent() { return } + setSessionAuthMethodHint('bankid') + // Check for pending invite token if (await acceptPendingInvite()) { window.location.href = '/' @@ -189,6 +208,8 @@ function LoginPageContent() { return } + setSessionAuthMethodHint('password') + // Check MFA status const { data: aal } = await supabase.auth.mfa.getAuthenticatorAssuranceLevel() @@ -267,6 +288,14 @@ function LoginPageContent() { } } + const isBankIdReauth = timeoutReason !== null && + requestedMethod === 'bankid' && + bankIdEnabled + const showPasswordLogin = !isBankIdReauth || + showPasswordFallback || + bankIdUnavailable || + bankIdNoAccount !== null + // Email sent confirmation screen if (isEmailSent) { const webmailHint = detectWebmailHint(email, branding.authEmailFrom) @@ -402,6 +431,18 @@ function LoginPageContent() {
+ {timeoutReason && ( +
+

+ {timeoutReason === 'idle' + ? tAuth('session_idle') + : tAuth('session_absolute')} +

+
+ )} {callbackError === 'auth_error' && (
{callbackFlow === 'recovery' ? ( @@ -457,14 +498,25 @@ function LoginPageContent() {
)} -
-
-
+ {isBankIdReauth && !showPasswordLogin ? ( + + ) : ( +
+
+
+
+
+ {tAuth('or_email_divider')} +
-
- {tAuth('or_email_divider')} -
-
+ )} )} {bankIdUnavailable && ( @@ -477,6 +529,8 @@ function LoginPageContent() {

)} + {showPasswordLogin && ( + <>
@@ -547,6 +601,8 @@ function LoginPageContent() { {tAuth('no_account')} + + )}

diff --git a/app/(dashboard)/layout.tsx b/app/(dashboard)/layout.tsx index 88a7930b..990004e4 100644 --- a/app/(dashboard)/layout.tsx +++ b/app/(dashboard)/layout.tsx @@ -9,6 +9,7 @@ import { AgentSheetProvider } from '@/components/agent/AgentSheetProvider' import AgentTrigger from '@/components/agent/AgentTrigger' import LazyCommandPalette from '@/components/common/LazyCommandPalette' import { SettingsHotkey } from '@/components/settings/SettingsHotkey' +import { SessionTimeoutController } from '@/components/auth/SessionTimeoutController' import { SandboxBanner } from '@/components/dashboard/SandboxBanner' import { getExtensionNavItems } from '@/lib/extensions/sectors' import { CompanyProvider } from '@/contexts/CompanyContext' @@ -108,6 +109,7 @@ export default async function DashboardLayout({ trialEndsAt: null, }} > +

@@ -205,6 +207,7 @@ export default async function DashboardLayout({ return ( +
@@ -284,6 +287,7 @@ export default async function DashboardLayout({ return ( + + {user && }
diff --git a/app/api/auth/heartbeat/__tests__/route.test.ts b/app/api/auth/heartbeat/__tests__/route.test.ts new file mode 100644 index 00000000..98e17b00 --- /dev/null +++ b/app/api/auth/heartbeat/__tests__/route.test.ts @@ -0,0 +1,139 @@ +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) + }) +}) diff --git a/app/api/auth/heartbeat/route.ts b/app/api/auth/heartbeat/route.ts new file mode 100644 index 00000000..128a9101 --- /dev/null +++ b/app/api/auth/heartbeat/route.ts @@ -0,0 +1,131 @@ +import { cookies } from 'next/headers' +import { NextResponse } from 'next/server' +import type { SupabaseClient } from '@supabase/supabase-js' +import { requireAuth } from '@/lib/auth/require-auth' +import { + createSessionTimeoutState, + evaluateSessionTimeout, + getSessionTimeoutConfig, + sessionStateMatchesUser, + sessionTimeoutCookieOptions, + signSessionTimeoutState, + toSessionTimeoutClientState, + verifySessionTimeoutState, +} from '@/lib/auth/session-timeout' +import { + SESSION_AUTH_METHOD_HINT_COOKIE, + SESSION_TIMEOUT_COOKIE, + isSessionAuthMethod, + type SessionTimeoutReason, +} from '@/lib/auth/session-timeout-shared' + +export const dynamic = 'force-dynamic' + +async function getSessionId(supabase: SupabaseClient): Promise { + if (typeof supabase.auth.getClaims !== 'function') return null + + try { + const { data } = await supabase.auth.getClaims() + return typeof data?.claims?.session_id === 'string' + ? data.claims.session_id + : null + } catch { + return null + } +} + +function expiredResponse(reason: SessionTimeoutReason): NextResponse { + const response = NextResponse.json( + { error: { code: 'SESSION_EXPIRED', reason } }, + { status: 401 }, + ) + response.headers.set('X-Session-Timeout-Reason', reason) + response.headers.set('Cache-Control', 'no-store') + return response +} + +async function heartbeat(updateActivity: boolean): Promise { + const auth = await requireAuth() + if (auth.error) return auth.error + + const config = getSessionTimeoutConfig() + const now = Date.now() + + if (!config.enabled) { + return NextResponse.json({ + data: { + enabled: false, + idleTimeoutMs: 0, + absoluteTimeoutMs: 0, + warningMs: 0, + serverNow: now, + startedAt: now, + lastActivityAt: now, + method: 'password', + }, + }) + } + + const cookieStore = await cookies() + const encodedState = cookieStore.get(SESSION_TIMEOUT_COOKIE)?.value + 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 hintedMethod = cookieStore.get(SESSION_AUTH_METHOD_HINT_COOKIE)?.value + const freshState = createSessionTimeoutState({ + userId: auth.user.id, + sessionId, + method: isSessionAuthMethod(hintedMethod) ? hintedMethod : 'password', + 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(), + ) + } + return response + } + + const reason = evaluateSessionTimeout(state, config, now) + if (reason) return expiredResponse(reason) + + const nextState = updateActivity + ? { ...state, lastActivityAt: now } + : state + const response = NextResponse.json({ + data: toSessionTimeoutClientState(nextState, config, now), + }) + response.headers.set('Cache-Control', 'no-store') + + if (updateActivity) { + const signedNext = await signSessionTimeoutState(nextState) + if (signedNext) { + response.cookies.set( + SESSION_TIMEOUT_COOKIE, + signedNext, + sessionTimeoutCookieOptions(), + ) + } + } + + return response +} + +export async function GET(): Promise { + return heartbeat(false) +} + +export async function POST(): Promise { + return heartbeat(true) +} diff --git a/app/companies/new/page.tsx b/app/companies/new/page.tsx index 85784e3c..e57cc3d0 100644 --- a/app/companies/new/page.tsx +++ b/app/companies/new/page.tsx @@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server' import { redirect } from 'next/navigation' import OnboardingBackdrop from '@/components/onboarding/OnboardingBackdrop' import OnboardingJourney from '@/components/onboarding/journey/OnboardingJourney' +import { SessionTimeoutController } from '@/components/auth/SessionTimeoutController' export const dynamic = 'force-dynamic' @@ -36,6 +37,7 @@ export default async function NewCompanyPage() { return (
+
diff --git a/components/auth/SessionTimeoutController.tsx b/components/auth/SessionTimeoutController.tsx new file mode 100644 index 00000000..74cb5e2e --- /dev/null +++ b/components/auth/SessionTimeoutController.tsx @@ -0,0 +1,250 @@ +'use client' + +import { useCallback, useEffect, useRef, useState } from 'react' +import { createClient } from '@/lib/supabase/client' +import { resetAnalyticsIdentity } from '@/lib/analytics/reset' +import { + SESSION_TIMEOUT_CHANNEL, + type SessionTimeoutClientState, + type SessionTimeoutReason, +} from '@/lib/auth/session-timeout-shared' +import { SessionTimeoutModal } from './SessionTimeoutModal' + +const ACTIVITY_HEARTBEAT_INTERVAL_MS = 15_000 +const SERVER_RESYNC_INTERVAL_MS = 60_000 +const ACTIVITY_EVENTS = ['pointerdown', 'keydown', 'scroll', 'touchstart'] as const + +type Warning = { reason: SessionTimeoutReason; seconds: number } + +function timeoutDeadline( + state: SessionTimeoutClientState, + lastActivityAt: number, +): { at: number; reason: SessionTimeoutReason } { + const absoluteAt = state.absoluteTimeoutMs > 0 + ? state.startedAt + state.absoluteTimeoutMs + : Number.POSITIVE_INFINITY + const idleAt = state.idleTimeoutMs > 0 + ? lastActivityAt + state.idleTimeoutMs + : Number.POSITIVE_INFINITY + + return absoluteAt <= idleAt + ? { at: absoluteAt, reason: 'absolute' } + : { at: idleAt, reason: 'idle' } +} + +export function SessionTimeoutController() { + const [warning, setWarning] = useState(null) + const [isExtending, setIsExtending] = useState(false) + const stateRef = useRef(null) + const lastActivityAtRef = useRef(0) + const lastHeartbeatAtRef = useRef(0) + const heartbeatInFlightRef = useRef(false) + const expiringRef = useRef(false) + const warningOpenRef = useRef(false) + const channelRef = useRef(null) + + const expire = useCallback(async (reason: SessionTimeoutReason) => { + if (expiringRef.current) return + expiringRef.current = true + resetAnalyticsIdentity() + + try { + await createClient().auth.signOut({ scope: 'local' }) + } catch { + // Middleware remains authoritative and clears the server cookies. + } + + const method = stateRef.current?.method ?? 'password' + const url = new URL('/login', window.location.origin) + url.searchParams.set('reason', reason) + url.searchParams.set('method', method) + const next = window.location.pathname + window.location.search + if (next !== '/') url.searchParams.set('next', next) + window.location.assign(url.toString()) + }, []) + + const applyServerState = useCallback((state: SessionTimeoutClientState) => { + if (!state.enabled) { + stateRef.current = null + lastActivityAtRef.current = 0 + warningOpenRef.current = false + setWarning(null) + return + } + + const clockOffset = Date.now() - state.serverNow + stateRef.current = { + ...state, + startedAt: state.startedAt + clockOffset, + lastActivityAt: state.lastActivityAt + clockOffset, + serverNow: Date.now(), + } + lastActivityAtRef.current = state.lastActivityAt + clockOffset + }, []) + + const handleExpiredResponse = useCallback((response: Response) => { + const reason = response.headers.get('x-session-timeout-reason') === 'idle' + ? 'idle' + : 'absolute' + void expire(reason) + }, [expire]) + + const syncFromServer = useCallback(async () => { + try { + const response = await fetch('/api/auth/heartbeat', { + method: 'GET', + cache: 'no-store', + credentials: 'same-origin', + }) + if (response.status === 401) { + handleExpiredResponse(response) + return + } + if (!response.ok) return + const payload = await response.json() as { data: SessionTimeoutClientState } + applyServerState(payload.data) + } catch { + // A later resync or protected request will retry server enforcement. + } + }, [applyServerState, handleExpiredResponse]) + + const sendHeartbeat = useCallback(async (showProgress = false) => { + if (heartbeatInFlightRef.current || !stateRef.current) return false + heartbeatInFlightRef.current = true + if (showProgress) setIsExtending(true) + + try { + const response = await fetch('/api/auth/heartbeat', { + method: 'POST', + cache: 'no-store', + credentials: 'same-origin', + }) + if (response.status === 401) { + handleExpiredResponse(response) + return false + } + if (!response.ok) return false + const payload = await response.json() as { data: SessionTimeoutClientState } + applyServerState(payload.data) + lastHeartbeatAtRef.current = Date.now() + warningOpenRef.current = false + setWarning(null) + channelRef.current?.postMessage({ type: 'heartbeat', state: payload.data }) + return true + } catch { + return false + } finally { + heartbeatInFlightRef.current = false + if (showProgress) setIsExtending(false) + } + }, [applyServerState, handleExpiredResponse]) + + const recordActivity = useCallback(() => { + const state = stateRef.current + if (!state || warningOpenRef.current || expiringRef.current) return + + const now = Date.now() + lastActivityAtRef.current = now + channelRef.current?.postMessage({ type: 'activity', at: now }) + + if (now - lastHeartbeatAtRef.current >= ACTIVITY_HEARTBEAT_INTERVAL_MS) { + void sendHeartbeat() + } + }, [sendHeartbeat]) + + useEffect(() => { + void syncFromServer() + + const channel = typeof BroadcastChannel === 'undefined' + ? null + : new BroadcastChannel(SESSION_TIMEOUT_CHANNEL) + channelRef.current = channel + if (channel) { + channel.onmessage = (event: MessageEvent<{ + type: 'activity' | 'heartbeat' + at?: number + state?: SessionTimeoutClientState + }>) => { + if (event.data.type === 'heartbeat' && event.data.state) { + applyServerState(event.data.state) + lastHeartbeatAtRef.current = Date.now() + warningOpenRef.current = false + setWarning(null) + } else if ( + event.data.type === 'activity' && + typeof event.data.at === 'number' && + !warningOpenRef.current + ) { + lastActivityAtRef.current = Math.max( + lastActivityAtRef.current, + event.data.at, + ) + } + } + } + + for (const eventName of ACTIVITY_EVENTS) { + window.addEventListener(eventName, recordActivity, { passive: true }) + } + + const onVisibilityChange = () => { + if (document.visibilityState === 'visible') void syncFromServer() + } + document.addEventListener('visibilitychange', onVisibilityChange) + + const timer = window.setInterval(() => { + const state = stateRef.current + if (!state) return + const deadline = timeoutDeadline(state, lastActivityAtRef.current) + const remainingMs = deadline.at - Date.now() + + if (remainingMs <= 0) { + void expire(deadline.reason) + return + } + + if (state.warningMs > 0 && remainingMs <= state.warningMs) { + warningOpenRef.current = true + setWarning({ + reason: deadline.reason, + seconds: Math.max(1, Math.ceil(remainingMs / 1000)), + }) + } else if (warningOpenRef.current) { + warningOpenRef.current = false + setWarning(null) + } + }, 1000) + const resyncTimer = window.setInterval(() => { + if (document.visibilityState === 'visible') void syncFromServer() + }, SERVER_RESYNC_INTERVAL_MS) + + return () => { + window.clearInterval(timer) + window.clearInterval(resyncTimer) + document.removeEventListener('visibilitychange', onVisibilityChange) + for (const eventName of ACTIVITY_EVENTS) { + window.removeEventListener(eventName, recordActivity) + } + channel?.close() + channelRef.current = null + } + }, [applyServerState, expire, recordActivity, syncFromServer]) + + if (!warning) return null + + return ( + { + if (warning.reason === 'absolute') { + void expire('absolute') + } else { + lastActivityAtRef.current = Date.now() + void sendHeartbeat(true) + } + }} + /> + ) +} diff --git a/components/auth/SessionTimeoutModal.tsx b/components/auth/SessionTimeoutModal.tsx new file mode 100644 index 00000000..27c239a1 --- /dev/null +++ b/components/auth/SessionTimeoutModal.tsx @@ -0,0 +1,57 @@ +'use client' + +import { Loader2, ShieldAlert } from 'lucide-react' +import { useTranslations } from 'next-intl' +import { Button } from '@/components/ui/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import type { SessionTimeoutReason } from '@/lib/auth/session-timeout-shared' + +export function SessionTimeoutModal({ + reason, + seconds, + isExtending, + onContinue, +}: { + reason: SessionTimeoutReason + seconds: number + isExtending: boolean + onContinue: () => void +}) { + const t = useTranslations('session_timeout') + + return ( + {}}> + event.preventDefault()} + onPointerDownOutside={(event) => event.preventDefault()} + onInteractOutside={(event) => event.preventDefault()} + > + +
+
+ {t('warning_title')} + + {reason === 'idle' + ? t('idle_warning', { seconds }) + : t('absolute_warning', { seconds })} + +
+ + + +
+
+ ) +} diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index 6bcd0171..54701062 100755 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -92,6 +92,9 @@ if [ -n "$SUBST_PATHS" ]; then E_VAPID_PUBLIC_KEY=$(sed_esc "${NEXT_PUBLIC_VAPID_PUBLIC_KEY:-}") E_SELF_HOSTED=$(sed_esc "${NEXT_PUBLIC_SELF_HOSTED:-true}") E_REQUIRE_MFA=$(sed_esc "${NEXT_PUBLIC_REQUIRE_MFA:-false}") + 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_BRANDING_APP_NAME=$(sed_esc "${NEXT_PUBLIC_BRANDING_APP_NAME:-Gnubok}") # File-type coverage: @@ -113,6 +116,9 @@ if [ -n "$SUBST_PATHS" ]; then -e "s|__NEXT_PUBLIC_VAPID_PUBLIC_KEY__|${E_VAPID_PUBLIC_KEY}|g" \ -e "s|__NEXT_PUBLIC_SELF_HOSTED__|${E_SELF_HOSTED}|g" \ -e "s|__NEXT_PUBLIC_REQUIRE_MFA__|${E_REQUIRE_MFA}|g" \ + -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_BRANDING_APP_NAME__|${E_BRANDING_APP_NAME}|g" fi diff --git a/docs/SELF-HOSTING.md b/docs/SELF-HOSTING.md index 3b2f7493..ccc604ed 100644 --- a/docs/SELF-HOSTING.md +++ b/docs/SELF-HOSTING.md @@ -24,7 +24,7 @@ In the Supabase dashboard under **Authentication > URL Configuration**: Accounted uses email + password authentication with magic link as a fallback. The default Supabase email auth settings work out of the box. For production, configure a custom SMTP provider under **Authentication > SMTP Settings** to avoid Supabase's built-in rate limits. -MFA (two-factor authentication via TOTP) is **not enforced** for self-hosted deployments: the Docker image sets `NEXT_PUBLIC_SELF_HOSTED=true` by default, which disables MFA enforcement. Users can still optionally enable 2FA in Settings > Säkerhet if they wish. +MFA (two-factor authentication via TOTP) is **not enforced** for self-hosted deployments: the Docker image sets `NEXT_PUBLIC_SELF_HOSTED=true` by default, which disables MFA enforcement. Users can still optionally enable 2FA in Settings > Säkerhet if they wish. Idle and absolute session timeouts are also off by default for self-hosted installs; operators can opt in with the variables below. ## 3. Apply Database Migrations @@ -93,6 +93,22 @@ 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): + +```bash +NEXT_PUBLIC_SESSION_IDLE_TIMEOUT_MS=1800000 +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 +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 +in again. + ## 5. Start the Application ```bash diff --git a/lib/auth/__tests__/session-timeout.test.ts b/lib/auth/__tests__/session-timeout.test.ts new file mode 100644 index 00000000..88baaec9 --- /dev/null +++ b/lib/auth/__tests__/session-timeout.test.ts @@ -0,0 +1,193 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + apiRequestSkipsSessionTimeout, + createSessionTimeoutState, + evaluateSessionTimeout, + getSessionTimeoutConfig, + sessionStateMatchesUser, + signSessionTimeoutState, + verifySessionTimeoutState, +} from '../session-timeout' + +const SIGNING_ENV = { SESSION_TIMEOUT_SECRET: 'test-session-timeout-secret' } + +describe('session timeout configuration', () => { + afterEach(() => vi.unstubAllEnvs()) + + it('uses banking-style defaults for hosted deployments', () => { + expect(getSessionTimeoutConfig({})).toEqual({ + enabled: true, + idleTimeoutMs: 30 * 60 * 1000, + absoluteTimeoutMs: 12 * 60 * 60 * 1000, + warningMs: 2 * 60 * 1000, + }) + }) + + it('is disabled by default for self-hosted deployments', () => { + expect(getSessionTimeoutConfig({ NEXT_PUBLIC_SELF_HOSTED: 'true' })).toEqual({ + enabled: false, + idleTimeoutMs: 0, + absoluteTimeoutMs: 0, + warningMs: 0, + }) + }) + + it('allows self-hosted deployments to opt in and bounds the warning', () => { + expect(getSessionTimeoutConfig({ + NEXT_PUBLIC_SELF_HOSTED: 'true', + NEXT_PUBLIC_SESSION_IDLE_TIMEOUT_MS: '60000', + NEXT_PUBLIC_SESSION_WARNING_MS: '120000', + })).toEqual({ + enabled: true, + idleTimeoutMs: 60000, + absoluteTimeoutMs: 0, + warningMs: 60000, + }) + }) + + it('warns when only the absolute limit is enabled', () => { + expect(getSessionTimeoutConfig({ + NEXT_PUBLIC_SELF_HOSTED: 'true', + NEXT_PUBLIC_SESSION_ABSOLUTE_TIMEOUT_MS: '60000', + NEXT_PUBLIC_SESSION_WARNING_MS: '10000', + })).toEqual({ + enabled: true, + idleTimeoutMs: 0, + absoluteTimeoutMs: 60000, + warningMs: 10000, + }) + }) + + it('ignores negative and non-integer overrides', () => { + expect(getSessionTimeoutConfig({ + NEXT_PUBLIC_SESSION_IDLE_TIMEOUT_MS: '-1', + NEXT_PUBLIC_SESSION_ABSOLUTE_TIMEOUT_MS: '12.5', + })).toMatchObject({ + idleTimeoutMs: 30 * 60 * 1000, + absoluteTimeoutMs: 12 * 60 * 60 * 1000, + }) + }) +}) + +describe('signed session timeout state', () => { + it('round-trips an authentic state', async () => { + const state = createSessionTimeoutState({ + userId: 'user-1', + sessionId: 'session-1', + method: 'bankid', + now: 1000, + }) + + const signed = await signSessionTimeoutState(state, SIGNING_ENV) + + expect(signed).not.toBeNull() + await expect(verifySessionTimeoutState(signed!, SIGNING_ENV)).resolves.toEqual(state) + }) + + it('returns null instead of throwing when no signing secret is configured', async () => { + const state = createSessionTimeoutState({ + userId: 'user-1', + sessionId: 'session-1', + method: 'password', + now: 1000, + }) + + await expect(signSessionTimeoutState(state, {})).resolves.toBeNull() + }) + + it('rejects payload and signature tampering', async () => { + const state = createSessionTimeoutState({ + userId: 'user-1', + sessionId: 'session-1', + method: 'password', + now: 1000, + }) + const signed = await signSessionTimeoutState(state, SIGNING_ENV) + const [payload, signature] = signed!.split('.') + + await expect( + verifySessionTimeoutState(`${payload}x.${signature}`, SIGNING_ENV), + ).resolves.toBeNull() + await expect( + verifySessionTimeoutState(`${payload}.${signature.slice(0, -1)}x`, SIGNING_ENV), + ).resolves.toBeNull() + }) + + it('binds state to both the user and Supabase session', () => { + const state = createSessionTimeoutState({ + userId: 'user-1', + sessionId: 'session-1', + method: 'password', + now: 1000, + }) + + expect(sessionStateMatchesUser(state, 'user-1', 'session-1')).toBe(true) + expect(sessionStateMatchesUser(state, 'user-2', 'session-1')).toBe(false) + expect(sessionStateMatchesUser(state, 'user-1', 'session-2')).toBe(false) + }) + + it('treats an unresolved current session id as a mismatch for bound state', () => { + const bound = createSessionTimeoutState({ + userId: 'user-1', + sessionId: 'session-1', + method: 'password', + now: 1000, + }) + const unbound = createSessionTimeoutState({ + userId: 'user-1', + sessionId: null, + method: 'password', + now: 1000, + }) + + expect(sessionStateMatchesUser(bound, 'user-1', null)).toBe(false) + expect(sessionStateMatchesUser(unbound, 'user-1', null)).toBe(true) + expect(sessionStateMatchesUser(unbound, 'user-1', 'session-2')).toBe(true) + }) +}) + +describe('session expiry', () => { + const config = { + enabled: true, + idleTimeoutMs: 30_000, + absoluteTimeoutMs: 60_000, + warningMs: 10_000, + } + + it('uses inclusive boundaries and gives absolute expiry precedence', () => { + const state = { + ...createSessionTimeoutState({ + userId: 'user-1', + sessionId: 'session-1', + method: 'password' as const, + now: 1000, + }), + lastActivityAt: 31_000, + } + + expect(evaluateSessionTimeout(state, config, 60_999)).toBeNull() + expect(evaluateSessionTimeout(state, config, 61_000)).toBe('absolute') + }) + + it('expires an otherwise valid session after the idle limit', () => { + const state = createSessionTimeoutState({ + userId: 'user-1', + sessionId: null, + method: 'password', + now: 1000, + }) + + expect(evaluateSessionTimeout(state, config, 30_999)).toBeNull() + expect(evaluateSessionTimeout(state, config, 31_000)).toBe('idle') + }) +}) + +describe('API exclusions', () => { + it('only lets bearer-authenticated machine surfaces bypass timeouts', () => { + expect(apiRequestSkipsSessionTimeout('/api/v1/companies/c1/invoices', true)).toBe(true) + expect(apiRequestSkipsSessionTimeout('/api/v1/companies/c1/invoices', false)).toBe(false) + expect(apiRequestSkipsSessionTimeout('/api/invoices', true)).toBe(false) + expect(apiRequestSkipsSessionTimeout('/api/mcp-oauth/token', false)).toBe(true) + expect(apiRequestSkipsSessionTimeout('/api/health', false)).toBe(true) + }) +}) diff --git a/lib/auth/session-timeout-shared.ts b/lib/auth/session-timeout-shared.ts new file mode 100644 index 00000000..f04584fe --- /dev/null +++ b/lib/auth/session-timeout-shared.ts @@ -0,0 +1,28 @@ +export const SESSION_TIMEOUT_COOKIE = 'gnubok-session-timeout' +export const SESSION_AUTH_METHOD_HINT_COOKIE = 'gnubok-auth-method' +export const SESSION_TIMEOUT_CHANNEL = 'gnubok-session-timeout' + +export type SessionAuthMethod = 'password' | 'bankid' +export type SessionTimeoutReason = 'idle' | 'absolute' + +export interface SessionTimeoutClientState { + enabled: boolean + idleTimeoutMs: number + absoluteTimeoutMs: number + warningMs: number + serverNow: number + startedAt: number + lastActivityAt: number + method: SessionAuthMethod +} + +export function isSessionAuthMethod(value: unknown): value is SessionAuthMethod { + return value === 'password' || value === 'bankid' +} + +export function setSessionAuthMethodHint(method: SessionAuthMethod): void { + if (typeof document === 'undefined') return + + const secure = window.location.protocol === 'https:' ? '; Secure' : '' + document.cookie = `${SESSION_AUTH_METHOD_HINT_COOKIE}=${method}; Path=/; Max-Age=300; SameSite=Lax${secure}` +} diff --git a/lib/auth/session-timeout.ts b/lib/auth/session-timeout.ts new file mode 100644 index 00000000..59cee7cb --- /dev/null +++ b/lib/auth/session-timeout.ts @@ -0,0 +1,330 @@ +import { + SESSION_TIMEOUT_COOKIE, + type SessionAuthMethod, + type SessionTimeoutClientState, + type SessionTimeoutReason, +} from './session-timeout-shared' + +const DEFAULT_IDLE_TIMEOUT_MS = 30 * 60 * 1000 +const DEFAULT_ABSOLUTE_TIMEOUT_MS = 12 * 60 * 60 * 1000 +const DEFAULT_WARNING_MS = 2 * 60 * 1000 +const COOKIE_MAX_AGE_SECONDS = 365 * 24 * 60 * 60 +const SIGNING_CONTEXT = 'accounted-session-timeout-v1:' + +export interface SessionTimeoutConfig { + enabled: boolean + idleTimeoutMs: number + absoluteTimeoutMs: number + warningMs: number +} + +export interface SessionTimeoutState { + version: 1 + userId: string + sessionId: string | null + startedAt: number + lastActivityAt: number + method: SessionAuthMethod +} + +type Environment = Record + +function parseDuration(value: string | undefined, fallback: number): number { + if (value === undefined || value.trim() === '') return fallback + + const parsed = Number(value) + if (!Number.isSafeInteger(parsed) || parsed < 0) return fallback + return parsed +} + +export function getSessionTimeoutConfig( + env: Environment = process.env, +): SessionTimeoutConfig { + const defaultTimeout = env.NEXT_PUBLIC_SELF_HOSTED === 'true' ? 0 : undefined + const idleTimeoutMs = parseDuration( + env.NEXT_PUBLIC_SESSION_IDLE_TIMEOUT_MS, + defaultTimeout ?? DEFAULT_IDLE_TIMEOUT_MS, + ) + const absoluteTimeoutMs = parseDuration( + env.NEXT_PUBLIC_SESSION_ABSOLUTE_TIMEOUT_MS, + defaultTimeout ?? DEFAULT_ABSOLUTE_TIMEOUT_MS, + ) + const configuredWarningMs = parseDuration( + env.NEXT_PUBLIC_SESSION_WARNING_MS, + DEFAULT_WARNING_MS, + ) + const enabledTimeouts = [idleTimeoutMs, absoluteTimeoutMs] + .filter((timeout) => timeout > 0) + const warningMs = enabledTimeouts.length > 0 + ? Math.min(configuredWarningMs, ...enabledTimeouts) + : 0 + + return { + enabled: idleTimeoutMs > 0 || absoluteTimeoutMs > 0, + idleTimeoutMs, + absoluteTimeoutMs, + warningMs, + } +} + +export function createSessionTimeoutState(args: { + userId: string + sessionId: string | null + method: SessionAuthMethod + now?: number +}): SessionTimeoutState { + const now = args.now ?? Date.now() + return { + version: 1, + userId: args.userId, + sessionId: args.sessionId, + startedAt: now, + lastActivityAt: now, + method: args.method, + } +} + +export function evaluateSessionTimeout( + state: SessionTimeoutState, + config: SessionTimeoutConfig, + now = Date.now(), +): SessionTimeoutReason | null { + if ( + config.absoluteTimeoutMs > 0 && + now - state.startedAt >= config.absoluteTimeoutMs + ) { + return 'absolute' + } + + if ( + config.idleTimeoutMs > 0 && + now - state.lastActivityAt >= config.idleTimeoutMs + ) { + return 'idle' + } + + return null +} + +function getSigningSecret(env: Environment): string { + const dedicated = env.SESSION_TIMEOUT_SECRET?.trim() + if (dedicated) return dedicated + + const serviceRole = env.SUPABASE_SERVICE_ROLE_KEY?.trim() + if (serviceRole) return serviceRole + + throw new Error( + 'Session timeout enforcement requires SESSION_TIMEOUT_SECRET or SUPABASE_SERVICE_ROLE_KEY', + ) +} + +function bytesToBase64Url(bytes: Uint8Array): string { + let binary = '' + for (const byte of bytes) binary += String.fromCharCode(byte) + return btoa(binary) + .replaceAll('+', '-') + .replaceAll('/', '_') + .replace(/=+$/u, '') +} + +function base64UrlToBytes(value: string): Uint8Array | null { + try { + const base64 = value.replaceAll('-', '+').replaceAll('_', '/') + const padded = base64.padEnd(Math.ceil(base64.length / 4) * 4, '=') + const binary = atob(padded) + const bytes = new Uint8Array(binary.length) + for (let index = 0; index < binary.length; index += 1) { + bytes[index] = binary.charCodeAt(index) + } + return bytes + } catch { + return null + } +} + +function encodePayload(state: SessionTimeoutState): string { + return bytesToBase64Url(new TextEncoder().encode(JSON.stringify(state))) +} + +function decodePayload(payload: string): unknown { + const bytes = base64UrlToBytes(payload) + if (!bytes) return null + + try { + return JSON.parse(new TextDecoder().decode(bytes)) + } catch { + return null + } +} + +function isValidState(value: unknown): value is SessionTimeoutState { + if (!value || typeof value !== 'object') return false + const state = value as Partial + + return ( + state.version === 1 && + typeof state.userId === 'string' && + state.userId.length > 0 && + (state.sessionId === null || typeof state.sessionId === 'string') && + Number.isSafeInteger(state.startedAt) && + Number.isSafeInteger(state.lastActivityAt) && + (state.method === 'password' || state.method === 'bankid') && + (state.startedAt as number) > 0 && + (state.lastActivityAt as number) >= (state.startedAt as number) + ) +} + +async function importSigningKey(secret: string): Promise { + // The signing key is HKDF-derived with a purpose-bound info string, never + // the raw secret: the SUPABASE_SERVICE_ROLE_KEY fallback must not use the + // privileged credential itself as an HMAC key. + const baseKey = await crypto.subtle.importKey( + 'raw', + new TextEncoder().encode(secret), + 'HKDF', + false, + ['deriveKey'], + ) + return crypto.subtle.deriveKey( + { + name: 'HKDF', + hash: 'SHA-256', + salt: new Uint8Array(32), + info: new TextEncoder().encode(SIGNING_CONTEXT), + }, + baseKey, + { name: 'HMAC', hash: 'SHA-256', length: 256 }, + false, + ['sign', 'verify'], + ) +} + +export async function signSessionTimeoutState( + state: SessionTimeoutState, + env: Environment = process.env, +): Promise { + // A missing signing secret must degrade the timeout feature, never crash + // authenticated requests: verifySessionTimeoutState already returns null in + // the same misconfiguration, so returning null here keeps both halves of + // the feature consistently disabled. + try { + const payload = encodePayload(state) + const key = await importSigningKey(getSigningSecret(env)) + const signature = await crypto.subtle.sign( + 'HMAC', + key, + new TextEncoder().encode(`${SIGNING_CONTEXT}${payload}`), + ) + return `${payload}.${bytesToBase64Url(new Uint8Array(signature))}` + } catch (error) { + console.error( + '[session-timeout] signing failed; timeout state not persisted', + error, + ) + return null + } +} + +export async function verifySessionTimeoutState( + value: string | undefined, + env: Environment = process.env, +): Promise { + if (!value) return null + const [payload, signature, extra] = value.split('.') + if (!payload || !signature || extra !== undefined) return null + + const signatureBytes = base64UrlToBytes(signature) + if (!signatureBytes) return null + + try { + const key = await importSigningKey(getSigningSecret(env)) + const valid = await crypto.subtle.verify( + 'HMAC', + key, + signatureBytes, + new TextEncoder().encode(`${SIGNING_CONTEXT}${payload}`), + ) + if (!valid) return null + } catch { + return null + } + + const state = decodePayload(payload) + return isValidState(state) ? state : null +} + +interface SessionTimeoutCookieOptions { + path: '/' + httpOnly: true + secure: boolean + sameSite: 'lax' + maxAge: number +} + +export function sessionTimeoutCookieOptions(): SessionTimeoutCookieOptions { + return { + path: '/', + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + maxAge: COOKIE_MAX_AGE_SECONDS, + } +} + +export function sessionTimeoutClearCookieOptions(): SessionTimeoutCookieOptions { + return { + ...sessionTimeoutCookieOptions(), + maxAge: 0, + } +} + +export function toSessionTimeoutClientState( + state: SessionTimeoutState, + config: SessionTimeoutConfig, + serverNow = Date.now(), +): SessionTimeoutClientState { + return { + enabled: config.enabled, + idleTimeoutMs: config.idleTimeoutMs, + absoluteTimeoutMs: config.absoluteTimeoutMs, + warningMs: config.warningMs, + serverNow, + startedAt: state.startedAt, + lastActivityAt: state.lastActivityAt, + method: state.method, + } +} + +export function sessionStateMatchesUser( + state: SessionTimeoutState, + userId: string, + sessionId: string | null, +): boolean { + if (state.userId !== userId) return false + if (state.sessionId === null) return true + // The state is bound to a specific Supabase session. If the current session + // id cannot be resolved it is unknown, not a wildcard: report a mismatch so + // the caller mints a fresh state instead of accepting another session's. + if (sessionId === null) return false + return state.sessionId === sessionId +} + +export function apiRequestSkipsSessionTimeout( + pathname: string, + hasAuthorizationHeader: boolean, +): boolean { + if ( + pathname === '/api/health' || + pathname === '/api/log' || + pathname.startsWith('/api/mcp-oauth/') + ) { + return true + } + + return hasAuthorizationHeader && ( + pathname.startsWith('/api/v1/') || + pathname.startsWith('/api/extensions/ext/mcp-server/mcp') + ) +} + +export { SESSION_TIMEOUT_COOKIE } diff --git a/lib/supabase/__tests__/middleware.test.ts b/lib/supabase/__tests__/middleware.test.ts index 632dc8f5..c755a9a4 100644 --- a/lib/supabase/__tests__/middleware.test.ts +++ b/lib/supabase/__tests__/middleware.test.ts @@ -11,6 +11,7 @@ import { NextRequest } from 'next/server' const state = vi.hoisted(() => ({ user: null as null | { id: string; app_metadata?: Record }, + sessionId: 'session-1' as string | null, authError: null as unknown, aal: null as null | { currentLevel: string; nextLevel: string }, factors: null as null | { totp: Array<{ id: string; status: string }> }, @@ -25,6 +26,7 @@ const state = vi.hoisted(() => ({ }> error: unknown }, + signOut: vi.fn(async () => ({ error: null })), })) vi.mock('@supabase/ssr', () => ({ @@ -34,7 +36,10 @@ vi.mock('@supabase/ssr', () => ({ data: { user: state.user }, error: state.authError, })), - signOut: vi.fn(async () => ({ error: null })), + getClaims: vi.fn(async () => ({ + data: { claims: state.sessionId ? { session_id: state.sessionId } : {} }, + })), + signOut: state.signOut, mfa: { getAuthenticatorAssuranceLevel: vi.fn(async () => ({ data: state.aal })), listFactors: vi.fn(async () => ({ data: state.factors })), @@ -58,6 +63,12 @@ vi.mock('@supabase/ssr', () => ({ })) import { updateSession } from '../middleware' +import { + createSessionTimeoutState, + signSessionTimeoutState, + verifySessionTimeoutState, +} from '@/lib/auth/session-timeout' +import { SESSION_TIMEOUT_COOKIE } from '@/lib/auth/session-timeout-shared' const ORIGIN = 'http://localhost:3000' const SIGNED_IN = { id: 'user-1', app_metadata: {} } @@ -66,19 +77,24 @@ function locationOf(response: Response) { return response.headers.get('location') } -function run(path: string) { - return updateSession(new NextRequest(`${ORIGIN}${path}`)) +function run(path: string, init?: RequestInit) { + return updateSession(new NextRequest(`${ORIGIN}${path}`, init)) } describe('updateSession redirect destinations', () => { const envBackup = { require: process.env.NEXT_PUBLIC_REQUIRE_MFA, selfHosted: process.env.NEXT_PUBLIC_SELF_HOSTED, + signingSecret: process.env.SESSION_TIMEOUT_SECRET, + idleTimeout: process.env.NEXT_PUBLIC_SESSION_IDLE_TIMEOUT_MS, + absoluteTimeout: process.env.NEXT_PUBLIC_SESSION_ABSOLUTE_TIMEOUT_MS, + warning: process.env.NEXT_PUBLIC_SESSION_WARNING_MS, } beforeEach(() => { vi.clearAllMocks() state.user = null + state.sessionId = 'session-1' state.authError = null state.aal = null state.factors = null @@ -88,6 +104,10 @@ describe('updateSession redirect destinations', () => { } delete process.env.NEXT_PUBLIC_REQUIRE_MFA delete process.env.NEXT_PUBLIC_SELF_HOSTED + process.env.SESSION_TIMEOUT_SECRET = 'middleware-test-secret' + delete process.env.NEXT_PUBLIC_SESSION_IDLE_TIMEOUT_MS + delete process.env.NEXT_PUBLIC_SESSION_ABSOLUTE_TIMEOUT_MS + delete process.env.NEXT_PUBLIC_SESSION_WARNING_MS }) afterEach(() => { @@ -95,6 +115,138 @@ describe('updateSession redirect destinations', () => { else process.env.NEXT_PUBLIC_REQUIRE_MFA = envBackup.require if (envBackup.selfHosted === undefined) delete process.env.NEXT_PUBLIC_SELF_HOSTED else process.env.NEXT_PUBLIC_SELF_HOSTED = envBackup.selfHosted + if (envBackup.signingSecret === undefined) delete process.env.SESSION_TIMEOUT_SECRET + else process.env.SESSION_TIMEOUT_SECRET = envBackup.signingSecret + if (envBackup.idleTimeout === undefined) delete process.env.NEXT_PUBLIC_SESSION_IDLE_TIMEOUT_MS + else process.env.NEXT_PUBLIC_SESSION_IDLE_TIMEOUT_MS = envBackup.idleTimeout + if (envBackup.absoluteTimeout === undefined) delete process.env.NEXT_PUBLIC_SESSION_ABSOLUTE_TIMEOUT_MS + else process.env.NEXT_PUBLIC_SESSION_ABSOLUTE_TIMEOUT_MS = envBackup.absoluteTimeout + if (envBackup.warning === undefined) delete process.env.NEXT_PUBLIC_SESSION_WARNING_MS + else process.env.NEXT_PUBLIC_SESSION_WARNING_MS = envBackup.warning + }) + + describe('session timeout enforcement', () => { + beforeEach(() => { + state.user = SIGNED_IN + 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' + }) + + async function signedCookie(args?: { + startedAt?: number + lastActivityAt?: number + method?: 'password' | 'bankid' + userId?: string + sessionId?: string | null + }) { + const stateValue = { + ...createSessionTimeoutState({ + userId: args?.userId ?? 'user-1', + sessionId: args?.sessionId === undefined ? 'session-1' : args.sessionId, + method: args?.method ?? 'password', + now: args?.startedAt ?? Date.now(), + }), + ...(args?.lastActivityAt === undefined + ? {} + : { lastActivityAt: args.lastActivityAt }), + } + const signed = await signSessionTimeoutState(stateValue) + if (!signed) throw new Error('test signing secret missing') + return signed + } + + it('initializes a signed, session-bound cookie for an existing session', async () => { + const response = await run('/settings/tax', { + headers: { cookie: 'gnubok-auth-method=bankid' }, + }) + + expect(response.status).toBe(200) + const encoded = response.cookies.get(SESSION_TIMEOUT_COOKIE)?.value + expect(encoded).toBeTruthy() + await expect(verifySessionTimeoutState(encoded)).resolves.toMatchObject({ + userId: 'user-1', + sessionId: 'session-1', + method: 'bankid', + }) + expect(response.cookies.get('gnubok-auth-method')?.value).toBe('') + }) + + it('rejects a tampered cookie and revokes only the current session', async () => { + const response = await run('/settings/tax', { + headers: { cookie: `${SESSION_TIMEOUT_COOKIE}=tampered.value` }, + }) + + expect(response.status).toBe(307) + expect(new URL(locationOf(response)!).searchParams.get('reason')).toBe('absolute') + expect(state.signOut).toHaveBeenCalledWith({ scope: 'local' }) + expect(response.cookies.get(SESSION_TIMEOUT_COOKIE)?.value).toBe('') + }) + + it('redirects an idle session with its original method and deep link', async () => { + const now = Date.now() + const encoded = await signedCookie({ + startedAt: now - 40_000, + lastActivityAt: now - 30_000, + method: 'bankid', + }) + + const response = await run('/reports/vat?period=2026-01', { + headers: { cookie: `${SESSION_TIMEOUT_COOKIE}=${encoded}` }, + }) + + const url = new URL(locationOf(response)!) + expect(url.pathname).toBe('/login') + expect(url.searchParams.get('reason')).toBe('idle') + expect(url.searchParams.get('method')).toBe('bankid') + expect(url.searchParams.get('next')).toBe('/reports/vat?period=2026-01') + expect(state.signOut).toHaveBeenCalledWith({ scope: 'local' }) + }) + + it('gives absolute expiry precedence and returns structured API errors', async () => { + const now = Date.now() + const encoded = await signedCookie({ + startedAt: now - 60_000, + lastActivityAt: now - 30_000, + }) + + const response = await run('/api/invoices', { + headers: { cookie: `${SESSION_TIMEOUT_COOKIE}=${encoded}` }, + }) + + expect(response.status).toBe(401) + expect(response.headers.get('x-session-timeout-reason')).toBe('absolute') + await expect(response.json()).resolves.toMatchObject({ + error: { code: 'SESSION_EXPIRED', reason: 'absolute' }, + }) + }) + + it('does not let a forged Authorization header bypass normal APIs', async () => { + const now = Date.now() + const encoded = await signedCookie({ lastActivityAt: now - 30_000, startedAt: now - 40_000 }) + const headers = { + authorization: 'Bearer forged', + cookie: `${SESSION_TIMEOUT_COOKIE}=${encoded}`, + } + + expect((await run('/api/invoices', { headers })).status).toBe(401) + expect((await run('/api/v1/companies/c1/invoices', { headers })).status).toBe(200) + }) + + it('starts a new timeout window when the Supabase session changes', async () => { + const encoded = await signedCookie({ sessionId: 'old-session' }) + + const response = await run('/settings/tax', { + headers: { cookie: `${SESSION_TIMEOUT_COOKIE}=${encoded}` }, + }) + + expect(response.status).toBe(200) + const renewed = response.cookies.get(SESSION_TIMEOUT_COOKIE)?.value + await expect(verifySessionTimeoutState(renewed)).resolves.toMatchObject({ + sessionId: 'session-1', + }) + expect(state.signOut).not.toHaveBeenCalled() + }) }) // ── Site 1: protected-route bounce ──────────────────────────────────── diff --git a/lib/supabase/middleware.ts b/lib/supabase/middleware.ts index 3b4739f1..5b638f30 100644 --- a/lib/supabase/middleware.ts +++ b/lib/supabase/middleware.ts @@ -5,6 +5,24 @@ import { apiPathSkipsMfaGate } from '@/lib/auth/api-mfa-gate' import { DEFAULT_LOCALE, LOCALE_COOKIE, isLocale } from '@/i18n/config' import { userHasPassword } from '@/lib/auth/has-password' import { safeReturnTo } from '@/lib/auth/safe-return-to' +import { + apiRequestSkipsSessionTimeout, + createSessionTimeoutState, + evaluateSessionTimeout, + getSessionTimeoutConfig, + sessionStateMatchesUser, + sessionTimeoutClearCookieOptions, + sessionTimeoutCookieOptions, + signSessionTimeoutState, + verifySessionTimeoutState, +} from '@/lib/auth/session-timeout' +import { + isSessionAuthMethod, + SESSION_AUTH_METHOD_HINT_COOKIE, + SESSION_TIMEOUT_COOKIE, + type SessionAuthMethod, + type SessionTimeoutReason, +} from '@/lib/auth/session-timeout-shared' export async function updateSession(request: NextRequest) { let supabaseResponse = NextResponse.next({ @@ -62,6 +80,72 @@ export async function updateSession(request: NextRequest) { } } + const timeoutConfig = getSessionTimeoutConfig() + const hasAuthorizationHeader = request.headers.get('authorization') !== null + + if (!user) { + clearSessionTimeoutCookies(request, supabaseResponse) + } else if ( + timeoutConfig.enabled && + !apiRequestSkipsSessionTimeout(pathname, hasAuthorizationHeader) + ) { + const encodedState = request.cookies.get(SESSION_TIMEOUT_COOKIE)?.value + const sessionId = await getSupabaseSessionId(supabase) + const verifiedState = await verifySessionTimeoutState(encodedState) + + if (encodedState && !verifiedState) { + await signOutTimedOutSession(supabase) + return sessionTimeoutResponse( + request, + supabaseResponse, + 'absolute', + 'password', + ) + } + + if ( + !verifiedState || + !sessionStateMatchesUser(verifiedState, user.id, sessionId) + ) { + const hintedMethod = request.cookies.get( + SESSION_AUTH_METHOD_HINT_COOKIE, + )?.value + const method = isSessionAuthMethod(hintedMethod) + ? hintedMethod + : 'password' + const state = createSessionTimeoutState({ + userId: user.id, + sessionId, + method, + }) + 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( + verifiedState, + timeoutConfig, + ) + if (timeoutReason) { + await signOutTimedOutSession(supabase) + return sessionTimeoutResponse( + request, + supabaseResponse, + timeoutReason, + verifiedState.method, + ) + } + } + } + // ── API routes ────────────────────────────────────────────────────────── // API routes authenticate themselves (requireAuth, API-key Bearer, cron // secret, webhook signatures). Middleware runs on them for ONE reason: to @@ -78,7 +162,7 @@ export async function updateSession(request: NextRequest) { if (pathname.startsWith('/api')) { const skipMfaGate = apiPathSkipsMfaGate( pathname, - request.headers.get('authorization') !== null, + hasAuthorizationHeader, ) if (!skipMfaGate && user && shouldEnforceMfa(user)) { const { data: aal } = @@ -300,6 +384,112 @@ export async function updateSession(request: NextRequest) { return supabaseResponse } +async function getSupabaseSessionId( + supabase: ReturnType, +): Promise { + if (typeof supabase.auth.getClaims !== 'function') return null + + try { + const { data } = await supabase.auth.getClaims() + return typeof data?.claims?.session_id === 'string' + ? data.claims.session_id + : null + } catch (error) { + console.warn('[middleware] could not resolve Supabase session id', error) + return null + } +} + +async function signOutTimedOutSession( + supabase: ReturnType, +): Promise { + try { + await supabase.auth.signOut({ scope: 'local' }) + } catch (error) { + console.warn('[middleware] timed-out session revocation failed', error) + } +} + +function clearAuthMethodHint( + request: NextRequest, + response: NextResponse, +): void { + if (!request.cookies.has(SESSION_AUTH_METHOD_HINT_COOKIE)) return + request.cookies.delete(SESSION_AUTH_METHOD_HINT_COOKIE) + response.cookies.set(SESSION_AUTH_METHOD_HINT_COOKIE, '', { + path: '/', + maxAge: 0, + sameSite: 'lax', + secure: process.env.NODE_ENV === 'production', + }) +} + +function clearSessionTimeoutCookies( + request: NextRequest, + response: NextResponse, +): void { + if (request.cookies.has(SESSION_TIMEOUT_COOKIE)) { + request.cookies.delete(SESSION_TIMEOUT_COOKIE) + response.cookies.set( + SESSION_TIMEOUT_COOKIE, + '', + sessionTimeoutClearCookieOptions(), + ) + } + clearAuthMethodHint(request, response) +} + +function copyResponseCookies(from: NextResponse, to: NextResponse): void { + for (const cookie of from.cookies.getAll()) { + to.cookies.set(cookie) + } +} + +function sessionTimeoutResponse( + request: NextRequest, + authResponse: NextResponse, + reason: SessionTimeoutReason, + method: SessionAuthMethod, +): NextResponse { + clearSessionTimeoutCookies(request, authResponse) + + if (request.nextUrl.pathname.startsWith('/api')) { + const response = NextResponse.json( + { + error: { + code: 'SESSION_EXPIRED', + message: reason === 'idle' + ? 'Sessionen har upphört på grund av inaktivitet.' + : 'Sessionen har upphört av säkerhetsskäl.', + message_en: reason === 'idle' + ? 'The session expired due to inactivity.' + : 'The session expired for security reasons.', + reason, + }, + }, + { status: 401 }, + ) + response.headers.set('X-Session-Timeout-Reason', reason) + response.headers.set('Cache-Control', 'no-store') + copyResponseCookies(authResponse, response) + return response + } + + const url = new URL('/login', request.url) + url.searchParams.set('reason', reason) + url.searchParams.set('method', method) + const destination = safeReturnTo( + request.nextUrl.pathname + request.nextUrl.search, + '/', + ) + if (destination !== '/') url.searchParams.set('next', destination) + + const response = NextResponse.redirect(url) + response.headers.set('Cache-Control', 'no-store') + copyResponseCookies(authResponse, response) + return response +} + /** * Which query parameter each auth page reads its post-auth destination from. * /login reads `next` (app/(auth)/login/page.tsx), the MFA pages read diff --git a/messages/en.json b/messages/en.json index 9b471504..3b739150 100644 --- a/messages/en.json +++ b/messages/en.json @@ -270,6 +270,9 @@ "login_failed_title": "Sign in failed", "login_failed_bankid": "Could not complete BankID sign in.", "login_invalid_credentials": "Wrong email or password.", + "session_idle": "You were inactive. Sign in again.", + "session_absolute": "Your session expired for security reasons.", + "use_password_instead": "Sign in with email instead", "reset_title": "Reset password", "reset_subtitle": "Enter your email and we'll send you a reset link", "reset_button": "Send reset link", @@ -301,6 +304,13 @@ "terms_and": "and", "privacy_link": "privacy policy" }, + "session_timeout": { + "warning_title": "You will be signed out soon", + "idle_warning": "For your security, you will be signed out in {seconds} seconds. Continue to stay signed in.", + "absolute_warning": "This session reaches its security limit in {seconds} seconds. Sign in again to continue.", + "continue": "Continue", + "sign_in_again": "Sign in again" + }, "settings_intro": { "account": "Your personal details and security. Applies to you, not the company.", "billing": "Each company has its own subscription.", diff --git a/messages/sv.json b/messages/sv.json index 345ef093..fc0fd410 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -270,6 +270,9 @@ "login_failed_title": "Inloggning misslyckades", "login_failed_bankid": "Kunde inte slutföra BankID-inloggningen.", "login_invalid_credentials": "Fel e-post eller lösenord.", + "session_idle": "Du har varit inaktiv. Logga in igen.", + "session_absolute": "Sessionen har upphört av säkerhetsskäl.", + "use_password_instead": "Logga in med e-post i stället", "reset_title": "Återställ lösenord", "reset_subtitle": "Ange din e-postadress så skickar vi en återställningslänk", "reset_button": "Skicka återställningslänk", @@ -301,6 +304,13 @@ "terms_and": "och", "privacy_link": "integritetspolicy" }, + "session_timeout": { + "warning_title": "Du loggas snart ut", + "idle_warning": "Av säkerhetsskäl loggas du ut om {seconds} sekunder. Fortsätt för att vara inloggad.", + "absolute_warning": "Sessionens säkerhetsgräns nås om {seconds} sekunder. Logga in igen för att fortsätta.", + "continue": "Fortsätt", + "sign_in_again": "Logga in igen" + }, "settings_intro": { "account": "Dina personliga uppgifter och din säkerhet. Gäller dig, inte företaget.", "billing": "Varje företag har sitt eget abonnemang.",