option to disable the session timeout

This commit is contained in:
Jarek Krochmalski committed 2026-08-27 07:34:37 +02:00
1 parent ef390e94ec
commit b04bc504f9
5 files changed
+109 -17

No files matched your search

+5 -9
View File
@@ -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() });
+31
View File
@@ -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);
}
+8 -4
View File
@@ -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);
+18 -4
View File
@@ -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 @@
<div class="space-y-1.5">
<Label class="text-sm">Session timeout</Label>
<p class="text-xs text-muted-foreground mb-2">
How long until inactive sessions expire
How long a session stays valid after sign-in
</p>
<div class="flex items-center gap-2 mb-2">
<ToggleGroup
value={neverExpire ? 'never' : 'timed'}
options={[{ value: 'timed', label: 'Timed' }, { value: 'never', label: 'Never expire' }]}
onchange={(v) => neverExpire = v === 'never'}
disabled={!$canAccess('settings', 'edit')}
/>
<span class="text-xs text-muted-foreground">
{neverExpire ? 'Sessions stay signed in until logout' : 'Sessions expire after the timeout below'}
</span>
</div>
<div class="flex items-center gap-2">
<Input
type="number"
@@ -270,7 +284,7 @@
e.currentTarget.value = String(sessionTimeout);
}}
class="w-32"
disabled={!$canAccess('settings', 'edit')}
disabled={neverExpire || !$canAccess('settings', 'edit')}
/>
<span class="text-sm text-muted-foreground">seconds</span>
<span class="text-xs text-muted-foreground">
+47
View File
@@ -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);
});
});