option to disable the session timeout
This commit is contained in:
5 files changed
+109
-17
No files matched your search
@@ -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() });
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user