diff --git a/src/lib/server/auth.ts b/src/lib/server/auth.ts index bd2654b..0a2b4b6 100644 --- a/src/lib/server/auth.ts +++ b/src/lib/server/auth.ts @@ -42,6 +42,7 @@ import { type OidcConfig } from './db'; import { Client as LdapClient } from 'ldapts'; +import { resolveSessionTimeout, cookieMaxAge } from '$lib/utils/session-timeout'; import { escapeLdapFilterValue } from './ldap-filter'; import { isEnterprise } from './license'; import { secureRandomBytes } from './crypto-fallback'; @@ -205,21 +206,16 @@ export async function createUserSession( // Generate secure token const sessionId = generateSessionToken(); - // Get session timeout from settings + // Get session timeout from settings (0 = never expire, see resolveSessionTimeout). const settings = await getAuthSettings(); - // Safety: ensure sessionTimeout is valid (1 second to 30 days), default to 24h if invalid - const MAX_SESSION_TIMEOUT = 2592000; // 30 days in seconds - const DEFAULT_SESSION_TIMEOUT = 86400; // 24 hours in seconds - const sessionTimeout = (settings?.sessionTimeout > 0 && settings?.sessionTimeout <= MAX_SESSION_TIMEOUT) - ? settings.sessionTimeout - : DEFAULT_SESSION_TIMEOUT; + const sessionTimeout = resolveSessionTimeout(settings?.sessionTimeout); const expiresAt = new Date(Date.now() + sessionTimeout * 1000).toISOString(); // Create session in database const session = await dbCreateSession(sessionId, userId, provider, expiresAt); - // Set secure cookie - setSessionCookie(cookies, sessionId, sessionTimeout, request); + // Cookie max-age is capped at the browser's ~400-day limit; the DB session outlives it. + setSessionCookie(cookies, sessionId, cookieMaxAge(sessionTimeout), request); // Update user's last login time await updateUser(userId, { lastLogin: new Date().toISOString() }); diff --git a/src/lib/utils/session-timeout.ts b/src/lib/utils/session-timeout.ts new file mode 100644 index 0000000..01e49c4 --- /dev/null +++ b/src/lib/utils/session-timeout.ts @@ -0,0 +1,31 @@ +/** + * Resolve the effective session lifetime (seconds) from the configured value. + * 0 is the "never expire" sentinel (#1302): the session still lives in the DB + * (logout, password change and deactivation delete it) but is dated ~10 years out + * so time never ends it. Any invalid value falls back to the 24h default. Pure so + * it can be unit-tested without the DB. + */ + +export const MAX_SESSION_TIMEOUT = 2592000; // 30 days in seconds +export const DEFAULT_SESSION_TIMEOUT = 86400; // 24 hours in seconds +export const NEVER_EXPIRE_TIMEOUT = 315360000; // 10 years in seconds +// Browsers (Chrome 104+, Firefox) clamp Set-Cookie max-age to ~400 days (RFC 6265bis), +// so a longer cookie is pointless. The DB session can outlive this; the cookie caps here. +export const MAX_COOKIE_MAX_AGE = 34560000; // 400 days in seconds + +export function resolveSessionTimeout(configured: number | null | undefined): number { + if (configured === 0) return NEVER_EXPIRE_TIMEOUT; + if (typeof configured === 'number' && configured > 0 && configured <= MAX_SESSION_TIMEOUT) { + return configured; + } + return DEFAULT_SESSION_TIMEOUT; +} + +/** + * The cookie max-age to use for a given server-side session lifetime. Capped at the + * browser's ~400-day limit so cookie and DB lifetimes don't silently diverge for a + * never-expire session (the DB row still lives its full lifetime). + */ +export function cookieMaxAge(sessionTimeout: number): number { + return Math.min(sessionTimeout, MAX_COOKIE_MAX_AGE); +} diff --git a/src/routes/api/auth/settings/+server.ts b/src/routes/api/auth/settings/+server.ts index 8c3f04c..208ea0c 100644 --- a/src/routes/api/auth/settings/+server.ts +++ b/src/routes/api/auth/settings/+server.ts @@ -42,8 +42,9 @@ export const GET: RequestHandler = async ({ cookies }) => { /** * @openapi * summary: Update the global authentication settings (enabling auth requires at least one admin/user to exist) - * body: {authEnabled:boolean, defaultProvider:string} - * body-example: {"authEnabled":true,"defaultProvider":"local"} + * description: sessionTimeout is in seconds (clamped 3600..604800); 0 disables expiry so sessions never time out. + * body: {authEnabled:boolean, defaultProvider:string, sessionTimeout:integer} + * body-example: {"authEnabled":true,"defaultProvider":"local","sessionTimeout":86400} * resp-200: {authEnabled:boolean!, defaultProvider:string} * resp-400: Cannot enable authentication without an existing user/admin (response includes requiresUser:true) * resp-401: Authentication required (auth is enabled and the caller is not authenticated) @@ -82,9 +83,12 @@ export const PUT: RequestHandler = async ({ request, cookies }) => { } } - // Enforce minimum session timeout of 1 hour + // 0 means "never expire" (#1302); otherwise enforce 1 hour .. 7 days. if (data.sessionTimeout !== undefined) { - data.sessionTimeout = Math.max(3600, Math.min(604800, parseInt(data.sessionTimeout) || 86400)); + const parsed = parseInt(data.sessionTimeout); + data.sessionTimeout = parsed === 0 + ? 0 + : Math.max(3600, Math.min(604800, parsed || 86400)); } const settings = await updateAuthSettings(data); diff --git a/src/routes/settings/auth/AuthTab.svelte b/src/routes/settings/auth/AuthTab.svelte index 21f889f..75f0e37 100644 --- a/src/routes/settings/auth/AuthTab.svelte +++ b/src/routes/settings/auth/AuthTab.svelte @@ -16,7 +16,7 @@ RefreshCw, Save } from 'lucide-svelte'; - import { TogglePill } from '$lib/components/ui/toggle-pill'; + import { TogglePill, ToggleGroup } from '$lib/components/ui/toggle-pill'; import { canAccess, authStore } from '$lib/stores/auth'; import { licenseStore } from '$lib/stores/license'; @@ -48,6 +48,7 @@ let authEnabled = $state(false); let authLoading = $state(true); let sessionTimeout = $state(86400); + let neverExpire = $state(false); let authSaving = $state(false); // Roles state (shared with sub-tabs that need it) @@ -61,6 +62,8 @@ if (response.ok) { const data = await response.json(); authEnabled = data.authEnabled; + // 0 is the "never expire" sentinel; keep the last real timeout for the input. + neverExpire = data.sessionTimeout === 0; sessionTimeout = data.sessionTimeout || 86400; } } catch (error) { @@ -116,7 +119,7 @@ const response = await fetch('/api/auth/settings', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ sessionTimeout: sessionTimeout }) + body: JSON.stringify({ sessionTimeout: neverExpire ? 0 : sessionTimeout }) }); if (response.ok) { toast.success('Settings saved'); @@ -256,8 +259,19 @@

- How long until inactive sessions expire + How long a session stays valid after sign-in

+
+ neverExpire = v === 'never'} + disabled={!$canAccess('settings', 'edit')} + /> + + {neverExpire ? 'Sessions stay signed in until logout' : 'Sessions expire after the timeout below'} + +
seconds diff --git a/tests/session-timeout.test.ts b/tests/session-timeout.test.ts new file mode 100644 index 0000000..7220f2b --- /dev/null +++ b/tests/session-timeout.test.ts @@ -0,0 +1,47 @@ +/** + * resolveSessionTimeout: 0 is the "never expire" sentinel (#1302), a valid value + * passes through, and anything out of range falls back to the 24h default. + */ +import { describe, it, expect } from 'bun:test'; +import { + resolveSessionTimeout, + cookieMaxAge, + DEFAULT_SESSION_TIMEOUT, + NEVER_EXPIRE_TIMEOUT, + MAX_SESSION_TIMEOUT, + MAX_COOKIE_MAX_AGE +} from '../src/lib/utils/session-timeout'; + +describe('resolveSessionTimeout', () => { + it('maps 0 to the never-expire (~10 years) lifetime', () => { + expect(resolveSessionTimeout(0)).toBe(NEVER_EXPIRE_TIMEOUT); + }); + + it('passes a valid in-range value through unchanged', () => { + expect(resolveSessionTimeout(3600)).toBe(3600); + expect(resolveSessionTimeout(MAX_SESSION_TIMEOUT)).toBe(MAX_SESSION_TIMEOUT); + }); + + it('falls back to the default for out-of-range / invalid values', () => { + expect(resolveSessionTimeout(-5)).toBe(DEFAULT_SESSION_TIMEOUT); + expect(resolveSessionTimeout(MAX_SESSION_TIMEOUT + 1)).toBe(DEFAULT_SESSION_TIMEOUT); + expect(resolveSessionTimeout(null)).toBe(DEFAULT_SESSION_TIMEOUT); + expect(resolveSessionTimeout(undefined)).toBe(DEFAULT_SESSION_TIMEOUT); + }); + + it('never-expire is far enough out to not practically time out', () => { + // ~10 years in seconds - a session created now would outlast any real deployment + expect(NEVER_EXPIRE_TIMEOUT).toBeGreaterThan(MAX_SESSION_TIMEOUT * 100); + }); +}); + +describe('cookieMaxAge', () => { + it('caps the never-expire lifetime at the browser ~400-day limit', () => { + expect(cookieMaxAge(NEVER_EXPIRE_TIMEOUT)).toBe(MAX_COOKIE_MAX_AGE); + }); + + it('passes a normal timeout through unchanged (below the cap)', () => { + expect(cookieMaxAge(86400)).toBe(86400); + expect(cookieMaxAge(MAX_SESSION_TIMEOUT)).toBe(MAX_SESSION_TIMEOUT); + }); +});