feat(auth): add "Remember me" checkbox to extend session lifetime (#1189)

Adds a "Remember me" checkbox to the login form (single responsive page,
covers mobile + desktop). Unchecked (default) issues the existing
SESSION_DURATION JWT with a browser-session cookie (no maxAge); checked
issues a longer-lived JWT plus a persistent cookie sized by the new
SESSION_DURATION_REMEMBER env var (default 30d). The choice is threaded
through the MFA verify leg so it survives the step-up.

Register/demo logins keep their current persistent behaviour.
This commit is contained in:
jubnl
2026-06-15 12:21:05 +02:00
committed by GitHub
parent 2d413c99cf
commit bf969ee80d
34 changed files with 184 additions and 32 deletions
+32
View File
@@ -103,6 +103,38 @@ describe('LoginPage', () => {
}); });
}); });
describe('FE-PAGE-LOGIN-007: Remember me sends remember_me to the API', () => {
it('renders an unchecked checkbox and forwards remember_me: true when ticked', async () => {
let capturedBody: Record<string, unknown> | null = null;
server.use(
http.post('/api/auth/login', async ({ request }) => {
capturedBody = (await request.json()) as Record<string, unknown>;
return HttpResponse.json({ user: { id: 1, username: 'test', email: 'test@example.com', role: 'user' } });
}),
);
const user = userEvent.setup();
render(<LoginPage />);
await waitFor(() => {
expect(screen.getByPlaceholderText(EMAIL_PLACEHOLDER)).toBeInTheDocument();
});
const checkbox = screen.getByRole('checkbox', { name: /remember me/i });
expect(checkbox).not.toBeChecked();
await user.type(screen.getByPlaceholderText(EMAIL_PLACEHOLDER), 'user@example.com');
await user.type(screen.getByPlaceholderText(PASSWORD_PLACEHOLDER), 'password123');
await user.click(checkbox);
expect(checkbox).toBeChecked();
await user.click(screen.getByRole('button', { name: /sign in/i }));
await waitFor(() => {
expect(capturedBody).toEqual(expect.objectContaining({ remember_me: true }));
});
});
});
describe('FE-PAGE-LOGIN-005: Registration toggle visible', () => { describe('FE-PAGE-LOGIN-005: Registration toggle visible', () => {
it('shows a Register button to switch to registration mode', async () => { it('shows a Register button to switch to registration mode', async () => {
// Default appConfig has allow_registration: true, has_users: true // Default appConfig has allow_registration: true, has_users: true
+11 -2
View File
@@ -9,7 +9,7 @@ export default function LoginPage(): React.ReactElement {
const { const {
navigate, navigate,
mode, setMode, mode, setMode,
username, setUsername, email, setEmail, password, setPassword, showPassword, setShowPassword, username, setUsername, email, setEmail, password, setPassword, rememberMe, setRememberMe, showPassword, setShowPassword,
isLoading, error, setError, appConfig, inviteToken, isLoading, error, setError, appConfig, inviteToken,
langDropdownOpen, setLangDropdownOpen, setLanguageLocal, langDropdownOpen, setLangDropdownOpen, setLanguageLocal,
showTakeoff, mfaStep, setMfaStep, mfaToken, setMfaToken, mfaCode, setMfaCode, showTakeoff, mfaStep, setMfaStep, mfaToken, setMfaToken, mfaCode, setMfaCode,
@@ -572,7 +572,16 @@ export default function LoginPage(): React.ReactElement {
</button> </button>
</div> </div>
{mode === 'login' && ( {mode === 'login' && (
<div style={{ textAlign: 'right', marginTop: 6 }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, marginTop: 8 }}>
<label style={{ display: 'flex', alignItems: 'center', gap: 7, cursor: 'pointer', color: '#374151', fontSize: 12.5, fontWeight: 500 }}>
<input
type="checkbox"
checked={rememberMe}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setRememberMe(e.target.checked)}
style={{ width: 15, height: 15, accentColor: '#111827', cursor: 'pointer', flexShrink: 0 }}
/>
{t('login.rememberMe')}
</label>
<button type="button" onClick={() => navigate('/forgot-password')} style={{ <button type="button" onClick={() => navigate('/forgot-password')} style={{
background: 'none', border: 'none', cursor: 'pointer', padding: 0, background: 'none', border: 'none', cursor: 'pointer', padding: 0,
color: '#6b7280', fontSize: 12.5, fontWeight: 500, fontFamily: 'inherit', color: '#6b7280', fontSize: 12.5, fontWeight: 500, fontFamily: 'inherit',
+4 -3
View File
@@ -37,6 +37,7 @@ export function useLogin() {
const [username, setUsername] = useState<string>('') const [username, setUsername] = useState<string>('')
const [email, setEmail] = useState<string>('') const [email, setEmail] = useState<string>('')
const [password, setPassword] = useState<string>('') const [password, setPassword] = useState<string>('')
const [rememberMe, setRememberMe] = useState<boolean>(false)
const [showPassword, setShowPassword] = useState<boolean>(false) const [showPassword, setShowPassword] = useState<boolean>(false)
const [isLoading, setIsLoading] = useState<boolean>(false) const [isLoading, setIsLoading] = useState<boolean>(false)
const [error, setError] = useState<string>('') const [error, setError] = useState<string>('')
@@ -242,7 +243,7 @@ export function useLogin() {
setIsLoading(false) setIsLoading(false)
return return
} }
const mfaResult = await completeMfaLogin(mfaToken, mfaCode) const mfaResult = await completeMfaLogin(mfaToken, mfaCode, rememberMe)
if ('user' in mfaResult && mfaResult.user?.must_change_password) { if ('user' in mfaResult && mfaResult.user?.must_change_password) {
setSavedLoginPassword(password) setSavedLoginPassword(password)
setPasswordChangeStep(true) setPasswordChangeStep(true)
@@ -258,7 +259,7 @@ export function useLogin() {
if (password.length < 8) { setError(t('login.passwordMinLength')); setIsLoading(false); return } if (password.length < 8) { setError(t('login.passwordMinLength')); setIsLoading(false); return }
await register(username, email, password, inviteToken || undefined) await register(username, email, password, inviteToken || undefined)
} else { } else {
const result = await login(email, password) const result = await login(email, password, rememberMe)
if ('mfa_required' in result && result.mfa_required && 'mfa_token' in result) { if ('mfa_required' in result && result.mfa_required && 'mfa_token' in result) {
setMfaToken(result.mfa_token) setMfaToken(result.mfa_token)
setMfaStep(true) setMfaStep(true)
@@ -289,7 +290,7 @@ export function useLogin() {
return { return {
navigate, navigate,
mode, setMode, mode, setMode,
username, setUsername, email, setEmail, password, setPassword, showPassword, setShowPassword, username, setUsername, email, setEmail, password, setPassword, rememberMe, setRememberMe, showPassword, setShowPassword,
isLoading, error, setError, appConfig, inviteToken, isLoading, error, setError, appConfig, inviteToken,
langDropdownOpen, setLangDropdownOpen, setLanguageLocal, langDropdownOpen, setLangDropdownOpen, setLanguageLocal,
showTakeoff, mfaStep, setMfaStep, mfaToken, setMfaToken, mfaCode, setMfaCode, showTakeoff, mfaStep, setMfaStep, mfaToken, setMfaToken, mfaCode, setMfaCode,
+6 -6
View File
@@ -39,8 +39,8 @@ interface AuthState {
placesAutocompleteEnabled: boolean placesAutocompleteEnabled: boolean
placesDetailsEnabled: boolean placesDetailsEnabled: boolean
login: (email: string, password: string) => Promise<LoginResult> login: (email: string, password: string, rememberMe?: boolean) => Promise<LoginResult>
completeMfaLogin: (mfaToken: string, code: string) => Promise<AuthResponse> completeMfaLogin: (mfaToken: string, code: string, rememberMe?: boolean) => Promise<AuthResponse>
register: (username: string, email: string, password: string, invite_token?: string) => Promise<AuthResponse> register: (username: string, email: string, password: string, invite_token?: string) => Promise<AuthResponse>
logout: () => Promise<void> logout: () => Promise<void>
/** Pass `{ silent: true }` to refresh the user without toggling global isLoading (avoids unmounting protected routes). */ /** Pass `{ silent: true }` to refresh the user without toggling global isLoading (avoids unmounting protected routes). */
@@ -99,11 +99,11 @@ export const useAuthStore = create<AuthState>()(
placesAutocompleteEnabled: true, placesAutocompleteEnabled: true,
placesDetailsEnabled: true, placesDetailsEnabled: true,
login: async (email: string, password: string) => { login: async (email: string, password: string, rememberMe?: boolean) => {
authSequence++ authSequence++
set({ isLoading: true, error: null }) set({ isLoading: true, error: null })
try { try {
const data = await authApi.login({ email, password }) as AuthResponse & { mfa_required?: boolean; mfa_token?: string } const data = await authApi.login({ email, password, remember_me: rememberMe }) as AuthResponse & { mfa_required?: boolean; mfa_token?: string }
if (data.mfa_required && data.mfa_token) { if (data.mfa_required && data.mfa_token) {
set({ isLoading: false, error: null }) set({ isLoading: false, error: null })
return { mfa_required: true as const, mfa_token: data.mfa_token } return { mfa_required: true as const, mfa_token: data.mfa_token }
@@ -128,11 +128,11 @@ export const useAuthStore = create<AuthState>()(
} }
}, },
completeMfaLogin: async (mfaToken: string, code: string) => { completeMfaLogin: async (mfaToken: string, code: string, rememberMe?: boolean) => {
authSequence++ authSequence++
set({ isLoading: true, error: null }) set({ isLoading: true, error: null })
try { try {
const data = await authApi.verifyMfaLogin({ mfa_token: mfaToken, code: code.replace(/\s/g, '') }) const data = await authApi.verifyMfaLogin({ mfa_token: mfaToken, code: code.replace(/\s/g, ''), remember_me: rememberMe })
set({ set({
user: data.user, user: data.user,
isAuthenticated: true, isAuthenticated: true,
+18
View File
@@ -136,3 +136,21 @@ export const SESSION_DURATION = parsedSessionMs == null ? DEFAULT_SESSION_DURATI
export const SESSION_DURATION_MS = parsedSessionMs ?? parseDurationMs(DEFAULT_SESSION_DURATION)!; export const SESSION_DURATION_MS = parsedSessionMs ?? parseDurationMs(DEFAULT_SESSION_DURATION)!;
/** Session length in seconds — passed to `jwt.sign({ expiresIn })` (number = seconds). */ /** Session length in seconds — passed to `jwt.sign({ expiresIn })` (number = seconds). */
export const SESSION_DURATION_SECONDS = Math.floor(SESSION_DURATION_MS / 1000); export const SESSION_DURATION_SECONDS = Math.floor(SESSION_DURATION_MS / 1000);
// SESSION_DURATION_REMEMBER is the session length used when the user ticks
// "Remember me" on the login form: a longer-lived JWT `exp` claim plus a
// persistent `trek_session` cookie `maxAge`. An unticked login keeps
// SESSION_DURATION and a browser-session cookie (no `maxAge`). Same ms-style
// format and fallback behavior as SESSION_DURATION.
const DEFAULT_SESSION_DURATION_REMEMBER = '30d';
const rawRememberDuration = process.env.SESSION_DURATION_REMEMBER?.trim() || DEFAULT_SESSION_DURATION_REMEMBER;
const parsedRememberMs = parseDurationMs(rawRememberDuration);
if (parsedRememberMs == null) {
console.warn(`SESSION_DURATION_REMEMBER="${rawRememberDuration}" is not a valid duration (use e.g. 7d, 30d, 90d). Falling back to "${DEFAULT_SESSION_DURATION_REMEMBER}".`);
}
/** Human-readable "remember me" session length actually in effect (for logs/diagnostics). */
export const SESSION_DURATION_REMEMBER = parsedRememberMs == null ? DEFAULT_SESSION_DURATION_REMEMBER : rawRememberDuration;
/** "Remember me" session length in milliseconds — used for the persistent cookie `maxAge`. */
export const SESSION_DURATION_REMEMBER_MS = parsedRememberMs ?? parseDurationMs(DEFAULT_SESSION_DURATION_REMEMBER)!;
/** "Remember me" session length in seconds — passed to `jwt.sign({ expiresIn })`. */
export const SESSION_DURATION_REMEMBER_SECONDS = Math.floor(SESSION_DURATION_REMEMBER_MS / 1000);
@@ -87,7 +87,7 @@ export class AuthPublicController {
if (result.mfa_required) { if (result.mfa_required) {
return { mfa_required: true, mfa_token: result.mfa_token }; return { mfa_required: true, mfa_token: result.mfa_token };
} }
this.auth.setAuthCookie(res, result.token!, req); this.auth.setAuthCookie(res, result.token!, req, result.remember);
return { token: result.token, user: result.user }; return { token: result.token, user: result.user };
} }
@@ -146,7 +146,7 @@ export class AuthPublicController {
throw new HttpException({ error: result.error }, result.status!); throw new HttpException({ error: result.error }, result.status!);
} }
writeAudit({ userId: result.auditUserId!, action: 'user.login', ip: getClientIp(req), details: { mfa: true } }); writeAudit({ userId: result.auditUserId!, action: 'user.login', ip: getClientIp(req), details: { mfa: true } });
this.auth.setAuthCookie(res, result.token!, req); this.auth.setAuthCookie(res, result.token!, req, result.remember);
return { token: result.token, user: result.user }; return { token: result.token, user: result.user };
} }
+1 -1
View File
@@ -14,7 +14,7 @@ import type { User } from '../../types';
@Injectable() @Injectable()
export class AuthService { export class AuthService {
// Cookie // Cookie
setAuthCookie(res: Response, token: string, req: Request) { setAuthCookie(res, token, req); } setAuthCookie(res: Response, token: string, req: Request, remember?: boolean) { setAuthCookie(res, token, req, remember); }
clearAuthCookie(res: Response, req: Request) { clearAuthCookie(res, req); } clearAuthCookie(res: Response, req: Request) { clearAuthCookie(res, req); }
// Reset-email delivery (canonical app URL, never request headers) // Reset-email delivery (canonical app URL, never request headers)
+18 -7
View File
@@ -7,7 +7,7 @@ import { authenticator } from 'otplib';
import QRCode from 'qrcode'; import QRCode from 'qrcode';
import { randomBytes, createHash } from 'crypto'; import { randomBytes, createHash } from 'crypto';
import { db } from '../db/database'; import { db } from '../db/database';
import { JWT_SECRET, SESSION_DURATION_SECONDS } from '../config'; import { JWT_SECRET, SESSION_DURATION_SECONDS, SESSION_DURATION_REMEMBER_SECONDS } from '../config';
import { validatePassword } from './passwordPolicy'; import { validatePassword } from './passwordPolicy';
import { encryptMfaSecret, decryptMfaSecret } from './mfaCrypto'; import { encryptMfaSecret, decryptMfaSecret } from './mfaCrypto';
import { getAllPermissions } from './permissions'; import { getAllPermissions } from './permissions';
@@ -181,14 +181,17 @@ export function isOidcOnlyMode(): boolean {
return !resolveAuthToggles().password_login; return !resolveAuthToggles().password_login;
} }
export function generateToken(user: { id: number | bigint; password_version?: number }) { export function generateToken(user: { id: number | bigint; password_version?: number }, rememberMe = false) {
const pv = typeof user.password_version === 'number' const pv = typeof user.password_version === 'number'
? user.password_version ? user.password_version
: ((db.prepare('SELECT password_version FROM users WHERE id = ?').get(user.id) as { password_version?: number } | undefined)?.password_version ?? 0); : ((db.prepare('SELECT password_version FROM users WHERE id = ?').get(user.id) as { password_version?: number } | undefined)?.password_version ?? 0);
// "Remember me" extends the JWT lifetime to match the persistent cookie maxAge;
// the cookie service decides session-vs-persistent off the same flag.
const expiresIn = rememberMe ? SESSION_DURATION_REMEMBER_SECONDS : SESSION_DURATION_SECONDS;
return jwt.sign( return jwt.sign(
{ id: user.id, pv }, { id: user.id, pv },
JWT_SECRET, JWT_SECRET,
{ expiresIn: SESSION_DURATION_SECONDS, algorithm: 'HS256' } { expiresIn, algorithm: 'HS256' }
); );
} }
@@ -443,6 +446,7 @@ export function registerUser(body: {
export function loginUser(body: { export function loginUser(body: {
email?: string; email?: string;
password?: string; password?: string;
remember_me?: boolean;
}): { }): {
error?: string; error?: string;
status?: number; status?: number;
@@ -450,6 +454,7 @@ export function loginUser(body: {
user?: Record<string, unknown>; user?: Record<string, unknown>;
mfa_required?: boolean; mfa_required?: boolean;
mfa_token?: string; mfa_token?: string;
remember?: boolean;
auditUserId?: number | null; auditUserId?: number | null;
auditAction?: string; auditAction?: string;
auditDetails?: Record<string, unknown>; auditDetails?: Record<string, unknown>;
@@ -458,7 +463,8 @@ export function loginUser(body: {
return { error: 'Password authentication is disabled. Please sign in with SSO.', status: 403 }; return { error: 'Password authentication is disabled. Please sign in with SSO.', status: 403 };
} }
const { email, password } = body; const { email, password, remember_me } = body;
const remember = remember_me === true;
if (!email || !password) { if (!email || !password) {
return { error: 'Email and password are required', status: 400 }; return { error: 'Email and password are required', status: 400 };
} }
@@ -500,12 +506,13 @@ export function loginUser(body: {
} }
db.prepare('UPDATE users SET last_login = CURRENT_TIMESTAMP, login_count = login_count + 1 WHERE id = ?').run(user.id); db.prepare('UPDATE users SET last_login = CURRENT_TIMESTAMP, login_count = login_count + 1 WHERE id = ?').run(user.id);
const token = generateToken(user); const token = generateToken(user, remember);
const userSafe = stripUserForClient(user) as Record<string, unknown>; const userSafe = stripUserForClient(user) as Record<string, unknown>;
return { return {
token, token,
user: { ...userSafe, avatar_url: avatarUrl(user) }, user: { ...userSafe, avatar_url: avatarUrl(user) },
remember,
auditUserId: Number(user.id), auditUserId: Number(user.id),
auditAction: 'user.login', auditAction: 'user.login',
auditDetails: { email }, auditDetails: { email },
@@ -1066,14 +1073,17 @@ export function disableMfa(
export function verifyMfaLogin(body: { export function verifyMfaLogin(body: {
mfa_token?: string; mfa_token?: string;
code?: string; code?: string;
remember_me?: boolean;
}): { }): {
error?: string; error?: string;
status?: number; status?: number;
token?: string; token?: string;
user?: Record<string, unknown>; user?: Record<string, unknown>;
remember?: boolean;
auditUserId?: number; auditUserId?: number;
} { } {
const { mfa_token, code } = body; const { mfa_token, code, remember_me } = body;
const remember = remember_me === true;
if (!mfa_token || !code) { if (!mfa_token || !code) {
return { error: 'Verification token and code are required', status: 400 }; return { error: 'Verification token and code are required', status: 400 };
} }
@@ -1104,11 +1114,12 @@ export function verifyMfaLogin(body: {
); );
} }
db.prepare('UPDATE users SET last_login = CURRENT_TIMESTAMP, login_count = login_count + 1 WHERE id = ?').run(user.id); db.prepare('UPDATE users SET last_login = CURRENT_TIMESTAMP, login_count = login_count + 1 WHERE id = ?').run(user.id);
const sessionToken = generateToken(user); const sessionToken = generateToken(user, remember);
const userSafe = stripUserForClient(user) as Record<string, unknown>; const userSafe = stripUserForClient(user) as Record<string, unknown>;
return { return {
token: sessionToken, token: sessionToken,
user: { ...userSafe, avatar_url: avatarUrl(user) }, user: { ...userSafe, avatar_url: avatarUrl(user) },
remember,
auditUserId: Number(user.id), auditUserId: Number(user.id),
}; };
} catch { } catch {
+25 -8
View File
@@ -1,8 +1,17 @@
import { Request, Response } from 'express'; import { Request, Response } from 'express';
import { SESSION_DURATION_MS } from '../config'; import { SESSION_DURATION_MS, SESSION_DURATION_REMEMBER_MS } from '../config';
const COOKIE_NAME = 'trek_session'; const COOKIE_NAME = 'trek_session';
/**
* Controls the cookie lifetime for a login:
* - `undefined` → persistent `maxAge: SESSION_DURATION_MS` (the historical
* default, used by register/demo and anything that doesn't opt in).
* - `true` → persistent `maxAge: SESSION_DURATION_REMEMBER_MS` ("Remember me").
* - `false` → no `maxAge` — a browser-session cookie cleared on browser close.
*/
export type RememberOption = boolean | undefined;
/** /**
* Decide whether the session cookie should carry the `Secure` flag. * Decide whether the session cookie should carry the `Secure` flag.
* *
@@ -18,27 +27,35 @@ const COOKIE_NAME = 'trek_session';
* on the outermost hop, the cookie is `Secure`. `COOKIE_SECURE=false` * on the outermost hop, the cookie is `Secure`. `COOKIE_SECURE=false`
* remains the explicit escape hatch for plain-HTTP LAN testing. * remains the explicit escape hatch for plain-HTTP LAN testing.
*/ */
export function cookieOptions(clear = false, req?: Request) { export function cookieOptions(clear = false, req?: Request, remember?: RememberOption) {
if (process.env.COOKIE_SECURE?.toLowerCase() === 'false') { if (process.env.COOKIE_SECURE?.toLowerCase() === 'false') {
return buildOptions(clear, false); return buildOptions(clear, false, remember);
} }
const envSecure = process.env.NODE_ENV?.toLowerCase() === 'production' || process.env.FORCE_HTTPS?.toLowerCase() === 'true'; const envSecure = process.env.NODE_ENV?.toLowerCase() === 'production' || process.env.FORCE_HTTPS?.toLowerCase() === 'true';
const requestSecure = req?.secure === true; const requestSecure = req?.secure === true;
return buildOptions(clear, envSecure || requestSecure); return buildOptions(clear, envSecure || requestSecure, remember);
} }
function buildOptions(clear: boolean, secure: boolean) { function resolveMaxAge(remember: RememberOption): { maxAge: number } | Record<string, never> {
// false → session cookie (omit maxAge); true → the longer "remember me"
// window; undefined → the historical default. Each maxAge matches the JWT exp.
if (remember === false) return {};
if (remember === true) return { maxAge: SESSION_DURATION_REMEMBER_MS };
return { maxAge: SESSION_DURATION_MS };
}
function buildOptions(clear: boolean, secure: boolean, remember?: RememberOption) {
return { return {
httpOnly: true, httpOnly: true,
secure, secure,
sameSite: 'lax' as const, sameSite: 'lax' as const,
path: '/', path: '/',
...(clear ? {} : { maxAge: SESSION_DURATION_MS }), // matches the JWT expiry (SESSION_DURATION) ...(clear ? {} : resolveMaxAge(remember)),
}; };
} }
export function setAuthCookie(res: Response, token: string, req?: Request): void { export function setAuthCookie(res: Response, token: string, req?: Request, remember?: RememberOption): void {
res.cookie(COOKIE_NAME, token, cookieOptions(false, req)); res.cookie(COOKIE_NAME, token, cookieOptions(false, req, remember));
} }
export function clearAuthCookie(res: Response, req?: Request): void { export function clearAuthCookie(res: Response, req?: Request): void {
+22
View File
@@ -98,6 +98,28 @@ describe('Auth e2e (real auth guard + real cookie service + temp SQLite)', () =>
expect(setCookie.some((c) => c.startsWith('trek_session=') && /HttpOnly/i.test(c))).toBe(true); expect(setCookie.some((c) => c.startsWith('trek_session=') && /HttpOnly/i.test(c))).toBe(true);
}, 10000); }, 10000);
it('POST /login with remember_me sets a persistent cookie (Max-Age present)', async () => {
authSvc.loginUser.mockReturnValue({ token: 'jwt.token.value', user: { id: 1 }, remember: true });
const res = await request(server).post('/api/auth/login').send({ email: 'u@example.test', password: 'pw', remember_me: true });
expect(res.status).toBe(200);
const setCookie = res.headers['set-cookie'] as unknown as string[];
const cookie = setCookie.find((c) => c.startsWith('trek_session='))!;
expect(cookie).toMatch(/Max-Age=\d+/i);
// 30d default — well above the 24h (86400s) non-remember window.
const maxAge = Number(/Max-Age=(\d+)/i.exec(cookie)?.[1]);
expect(maxAge).toBeGreaterThan(86_400);
}, 10000);
it('POST /login without remember_me sets a session cookie (no Max-Age)', async () => {
authSvc.loginUser.mockReturnValue({ token: 'jwt.token.value', user: { id: 1 }, remember: false });
const res = await request(server).post('/api/auth/login').send({ email: 'u@example.test', password: 'pw' });
expect(res.status).toBe(200);
const setCookie = res.headers['set-cookie'] as unknown as string[];
const cookie = setCookie.find((c) => c.startsWith('trek_session='))!;
expect(cookie).not.toMatch(/Max-Age/i);
expect(cookie).not.toMatch(/Expires/i);
}, 10000);
it('POST /logout clears the session cookie', async () => { it('POST /logout clears the session cookie', async () => {
const res = await request(server).post('/api/auth/logout'); const res = await request(server).post('/api/auth/logout');
expect(res.status).toBe(200); expect(res.status).toBe(200);
@@ -82,9 +82,10 @@ describe('AuthPublicController', () => {
const setAuthCookie = vi.fn(); const setAuthCookie = vi.fn();
const mfa = new AuthPublicController(asvc({ loginUser: vi.fn().mockReturnValue({ mfa_required: true, mfa_token: 'mt' }) } as Partial<AuthService>), rl()); const mfa = new AuthPublicController(asvc({ loginUser: vi.fn().mockReturnValue({ mfa_required: true, mfa_token: 'mt' }) } as Partial<AuthService>), rl());
expect(await mfa.login({}, req, res)).toEqual({ mfa_required: true, mfa_token: 'mt' }); expect(await mfa.login({}, req, res)).toEqual({ mfa_required: true, mfa_token: 'mt' });
const ok = new AuthPublicController(asvc({ loginUser: vi.fn().mockReturnValue({ token: 'tk', user }), setAuthCookie } as Partial<AuthService>), rl()); const ok = new AuthPublicController(asvc({ loginUser: vi.fn().mockReturnValue({ token: 'tk', user, remember: true }), setAuthCookie } as Partial<AuthService>), rl());
expect(await ok.login({}, req, res)).toEqual({ token: 'tk', user }); expect(await ok.login({}, req, res)).toEqual({ token: 'tk', user });
expect(setAuthCookie).toHaveBeenCalled(); // The "remember me" flag from the service rides through to the cookie service.
expect(setAuthCookie).toHaveBeenCalledWith(res, 'tk', req, true);
const bad = new AuthPublicController(asvc({ loginUser: vi.fn().mockReturnValue({ error: 'Bad creds', status: 401, auditAction: 'user.login_fail' }) } as Partial<AuthService>), rl()); const bad = new AuthPublicController(asvc({ loginUser: vi.fn().mockReturnValue({ error: 'Bad creds', status: 401, auditAction: 'user.login_fail' }) } as Partial<AuthService>), rl());
expect(await thrownAsync(() => bad.login({}, req, res))).toEqual({ status: 401, body: { error: 'Bad creds' } }); expect(await thrownAsync(() => bad.login({}, req, res))).toEqual({ status: 401, body: { error: 'Bad creds' } });
}, 10000); }, 10000);
+13
View File
@@ -1,6 +1,7 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { cookieOptions } from '../../../src/services/cookie'; import { cookieOptions } from '../../../src/services/cookie';
import { SESSION_DURATION_MS, SESSION_DURATION_REMEMBER_MS } from '../../../src/config';
describe('cookieOptions', () => { describe('cookieOptions', () => {
afterEach(() => { afterEach(() => {
@@ -53,4 +54,16 @@ describe('cookieOptions', () => {
const opts = cookieOptions(true); const opts = cookieOptions(true);
expect(opts).not.toHaveProperty('maxAge'); expect(opts).not.toHaveProperty('maxAge');
}); });
it('keeps the default SESSION_DURATION maxAge when remember is undefined', () => {
expect(cookieOptions(false, undefined)).toHaveProperty('maxAge', SESSION_DURATION_MS);
});
it('uses the longer SESSION_DURATION_REMEMBER maxAge when remember is true', () => {
expect(cookieOptions(false, undefined, true)).toHaveProperty('maxAge', SESSION_DURATION_REMEMBER_MS);
});
it('omits maxAge (session cookie) when remember is false', () => {
expect(cookieOptions(false, undefined, false)).not.toHaveProperty('maxAge');
});
}); });
+7
View File
@@ -19,6 +19,10 @@ export type RegisterRequest = z.infer<typeof registerRequestSchema>;
export const loginRequestSchema = z.object({ export const loginRequestSchema = z.object({
email: z.string(), email: z.string(),
password: z.string(), password: z.string(),
// "Remember me" — when true the server issues a longer-lived
// (SESSION_DURATION_REMEMBER) JWT + persistent cookie; when false/absent the
// session lasts SESSION_DURATION and the cookie is a browser-session cookie.
remember_me: z.boolean().optional(),
}); });
export type LoginRequest = z.infer<typeof loginRequestSchema>; export type LoginRequest = z.infer<typeof loginRequestSchema>;
@@ -45,6 +49,9 @@ export type ChangePasswordRequest = z.infer<typeof changePasswordRequestSchema>;
export const mfaVerifyLoginRequestSchema = z.object({ export const mfaVerifyLoginRequestSchema = z.object({
mfa_token: z.string(), mfa_token: z.string(),
code: z.string(), code: z.string(),
// Carries the login-form "Remember me" choice through the second (MFA) leg,
// since the session token is only minted once the MFA code is verified.
remember_me: z.boolean().optional(),
}); });
export type MfaVerifyLoginRequest = z.infer<typeof mfaVerifyLoginRequestSchema>; export type MfaVerifyLoginRequest = z.infer<typeof mfaVerifyLoginRequestSchema>;
+1
View File
@@ -59,6 +59,7 @@ const login: TranslationStrings = {
'login.usernameRequired': 'اسم المستخدم مطلوب', 'login.usernameRequired': 'اسم المستخدم مطلوب',
'login.passwordMinLength': 'يجب أن تكون كلمة المرور 8 أحرف على الأقل', 'login.passwordMinLength': 'يجب أن تكون كلمة المرور 8 أحرف على الأقل',
'login.forgotPassword': 'نسيت كلمة المرور؟', 'login.forgotPassword': 'نسيت كلمة المرور؟',
'login.rememberMe': 'تذكرني',
'login.forgotPasswordTitle': 'إعادة تعيين كلمة المرور', 'login.forgotPasswordTitle': 'إعادة تعيين كلمة المرور',
'login.forgotPasswordBody': 'login.forgotPasswordBody':
'أدخل عنوان البريد الإلكتروني المسجَّل. إذا كان الحساب موجودًا، سنرسل رابط إعادة التعيين.', 'أدخل عنوان البريد الإلكتروني المسجَّل. إذا كان الحساب موجودًا، سنرسل رابط إعادة التعيين.',
+1
View File
@@ -62,6 +62,7 @@ const login: TranslationStrings = {
'login.usernameRequired': 'Nome de usuário é obrigatório', 'login.usernameRequired': 'Nome de usuário é obrigatório',
'login.passwordMinLength': 'A senha deve ter pelo menos 8 caracteres', 'login.passwordMinLength': 'A senha deve ter pelo menos 8 caracteres',
'login.forgotPassword': 'Esqueceu a senha?', 'login.forgotPassword': 'Esqueceu a senha?',
'login.rememberMe': 'Lembrar de mim',
'login.forgotPasswordTitle': 'Redefinir sua senha', 'login.forgotPasswordTitle': 'Redefinir sua senha',
'login.forgotPasswordBody': 'login.forgotPasswordBody':
'Digite o e-mail cadastrado. Se houver uma conta, enviaremos um link de redefinição.', 'Digite o e-mail cadastrado. Se houver uma conta, enviaremos um link de redefinição.',
+1
View File
@@ -64,6 +64,7 @@ const login: TranslationStrings = {
'login.usernameRequired': 'Uživatelské jméno je povinné', 'login.usernameRequired': 'Uživatelské jméno je povinné',
'login.passwordMinLength': 'Heslo musí mít alespoň 8 znaků', 'login.passwordMinLength': 'Heslo musí mít alespoň 8 znaků',
'login.forgotPassword': 'Zapomenuté heslo?', 'login.forgotPassword': 'Zapomenuté heslo?',
'login.rememberMe': 'Zapamatovat si mě',
'login.forgotPasswordTitle': 'Obnovení hesla', 'login.forgotPasswordTitle': 'Obnovení hesla',
'login.forgotPasswordBody': 'login.forgotPasswordBody':
'Zadej e-mail použitý při registraci. Pokud účet existuje, pošleme odkaz pro obnovení.', 'Zadej e-mail použitý při registraci. Pokud účet existuje, pošleme odkaz pro obnovení.',
+1
View File
@@ -65,6 +65,7 @@ const login: TranslationStrings = {
'login.usernameRequired': 'Benutzername ist erforderlich', 'login.usernameRequired': 'Benutzername ist erforderlich',
'login.passwordMinLength': 'Das Passwort muss mindestens 8 Zeichen lang sein', 'login.passwordMinLength': 'Das Passwort muss mindestens 8 Zeichen lang sein',
'login.forgotPassword': 'Passwort vergessen?', 'login.forgotPassword': 'Passwort vergessen?',
'login.rememberMe': 'Angemeldet bleiben',
'login.forgotPasswordTitle': 'Passwort zurücksetzen', 'login.forgotPasswordTitle': 'Passwort zurücksetzen',
'login.forgotPasswordBody': 'login.forgotPasswordBody':
'Gib die E-Mail-Adresse deines Kontos ein. Falls ein Konto existiert, schicken wir dir einen Reset-Link.', 'Gib die E-Mail-Adresse deines Kontos ein. Falls ein Konto existiert, schicken wir dir einen Reset-Link.',
+1
View File
@@ -63,6 +63,7 @@ const login: TranslationStrings = {
'login.usernameRequired': 'Username is required', 'login.usernameRequired': 'Username is required',
'login.passwordMinLength': 'Password must be at least 8 characters', 'login.passwordMinLength': 'Password must be at least 8 characters',
'login.forgotPassword': 'Forgot password?', 'login.forgotPassword': 'Forgot password?',
'login.rememberMe': 'Remember me',
'login.forgotPasswordTitle': 'Reset your password', 'login.forgotPasswordTitle': 'Reset your password',
'login.forgotPasswordBody': 'login.forgotPasswordBody':
"Enter the email address you signed up with. If an account exists, we'll send a reset link.", "Enter the email address you signed up with. If an account exists, we'll send a reset link.",
+1
View File
@@ -57,6 +57,7 @@ const login: TranslationStrings = {
'login.usernameRequired': 'El nombre de usuario es obligatorio', 'login.usernameRequired': 'El nombre de usuario es obligatorio',
'login.passwordMinLength': 'La contraseña debe tener al menos 8 caracteres', 'login.passwordMinLength': 'La contraseña debe tener al menos 8 caracteres',
'login.forgotPassword': '¿Olvidaste tu contraseña?', 'login.forgotPassword': '¿Olvidaste tu contraseña?',
'login.rememberMe': 'Recuérdame',
'login.forgotPasswordTitle': 'Restablecer tu contraseña', 'login.forgotPasswordTitle': 'Restablecer tu contraseña',
'login.forgotPasswordBody': 'login.forgotPasswordBody':
'Introduce la dirección de correo con la que te registraste. Si existe una cuenta, enviaremos un enlace.', 'Introduce la dirección de correo con la que te registraste. Si existe una cuenta, enviaremos un enlace.',
+1
View File
@@ -60,6 +60,7 @@ const login: TranslationStrings = {
'login.passwordMinLength': 'login.passwordMinLength':
'Le mot de passe doit comporter au moins 8 caractères', 'Le mot de passe doit comporter au moins 8 caractères',
'login.forgotPassword': 'Mot de passe oublié ?', 'login.forgotPassword': 'Mot de passe oublié ?',
'login.rememberMe': 'Se souvenir de moi',
'login.forgotPasswordTitle': 'Réinitialiser votre mot de passe', 'login.forgotPasswordTitle': 'Réinitialiser votre mot de passe',
'login.forgotPasswordBody': 'login.forgotPasswordBody':
"Entrez l'adresse e-mail associée à votre compte. Si un compte existe, nous enverrons un lien de réinitialisation.", "Entrez l'adresse e-mail associée à votre compte. Si un compte existe, nous enverrons un lien de réinitialisation.",
+1
View File
@@ -70,6 +70,7 @@ const login: TranslationStrings = {
'login.passwordMinLength': 'login.passwordMinLength':
'Ο κωδικός πρέπει να έχει τουλάχιστον 8 χαρακτήρες', 'Ο κωδικός πρέπει να έχει τουλάχιστον 8 χαρακτήρες',
'login.forgotPassword': 'Ξεχάσατε τον κωδικό;', 'login.forgotPassword': 'Ξεχάσατε τον κωδικό;',
'login.rememberMe': 'Να με θυμάσαι',
'login.forgotPasswordTitle': 'Επαναφορά του κωδικού σας', 'login.forgotPasswordTitle': 'Επαναφορά του κωδικού σας',
'login.forgotPasswordBody': 'login.forgotPasswordBody':
'Εισάγετε το email με το οποίο εγγραφήκατε. Αν υπάρχει λογαριασμός, θα στείλουμε έναν σύνδεσμο επαναφοράς.', 'Εισάγετε το email με το οποίο εγγραφήκατε. Αν υπάρχει λογαριασμός, θα στείλουμε έναν σύνδεσμο επαναφοράς.',
+1
View File
@@ -69,6 +69,7 @@ const login: TranslationStrings = {
'login.passwordMinLength': 'login.passwordMinLength':
'A jelszónak legalább 8 karakter hosszúnak kell lennie', 'A jelszónak legalább 8 karakter hosszúnak kell lennie',
'login.forgotPassword': 'Elfelejtetted a jelszavad?', 'login.forgotPassword': 'Elfelejtetted a jelszavad?',
'login.rememberMe': 'Emlékezz rám',
'login.forgotPasswordTitle': 'Jelszó visszaállítása', 'login.forgotPasswordTitle': 'Jelszó visszaállítása',
'login.forgotPasswordBody': 'login.forgotPasswordBody':
'Írd be a regisztrációnál használt e-mail-címet. Ha létezik fiók, küldünk egy visszaállítási linket.', 'Írd be a regisztrációnál használt e-mail-címet. Ha létezik fiók, küldünk egy visszaállítási linket.',
+1
View File
@@ -65,6 +65,7 @@ const login: TranslationStrings = {
'login.usernameRequired': 'Nama pengguna wajib diisi', 'login.usernameRequired': 'Nama pengguna wajib diisi',
'login.passwordMinLength': 'Kata sandi minimal 8 karakter', 'login.passwordMinLength': 'Kata sandi minimal 8 karakter',
'login.forgotPassword': 'Lupa kata sandi?', 'login.forgotPassword': 'Lupa kata sandi?',
'login.rememberMe': 'Ingat saya',
'login.forgotPasswordTitle': 'Setel ulang kata sandi', 'login.forgotPasswordTitle': 'Setel ulang kata sandi',
'login.forgotPasswordBody': 'login.forgotPasswordBody':
'Masukkan alamat email akunmu. Jika akun ada, kami akan mengirim tautan reset.', 'Masukkan alamat email akunmu. Jika akun ada, kami akan mengirim tautan reset.',
+1
View File
@@ -64,6 +64,7 @@ const login: TranslationStrings = {
'login.usernameRequired': 'Il nome utente è obbligatorio', 'login.usernameRequired': 'Il nome utente è obbligatorio',
'login.passwordMinLength': 'La password deve contenere almeno 8 caratteri', 'login.passwordMinLength': 'La password deve contenere almeno 8 caratteri',
'login.forgotPassword': 'Password dimenticata?', 'login.forgotPassword': 'Password dimenticata?',
'login.rememberMe': 'Ricordami',
'login.forgotPasswordTitle': 'Reimposta la password', 'login.forgotPasswordTitle': 'Reimposta la password',
'login.forgotPasswordBody': 'login.forgotPasswordBody':
'Inserisci lindirizzo email del tuo account. Se esiste un account, invieremo un link per reimpostarla.', 'Inserisci lindirizzo email del tuo account. Se esiste un account, invieremo un link per reimpostarla.',
+1
View File
@@ -63,6 +63,7 @@ const login: TranslationStrings = {
'login.usernameRequired': 'ユーザー名を入力してください', 'login.usernameRequired': 'ユーザー名を入力してください',
'login.passwordMinLength': 'パスワードは8文字以上である必要があります', 'login.passwordMinLength': 'パスワードは8文字以上である必要があります',
'login.forgotPassword': 'パスワードを忘れた場合', 'login.forgotPassword': 'パスワードを忘れた場合',
'login.rememberMe': 'ログイン状態を保持する',
'login.forgotPasswordTitle': 'パスワードをリセット', 'login.forgotPasswordTitle': 'パスワードをリセット',
'login.forgotPasswordBody': 'login.forgotPasswordBody':
'登録時のメールアドレスを入力してください。アカウントが存在する場合、リセット用リンクを送信します。', '登録時のメールアドレスを入力してください。アカウントが存在する場合、リセット用リンクを送信します。',
+1
View File
@@ -62,6 +62,7 @@ const login: TranslationStrings = {
'login.usernameRequired': '사용자 이름을 입력하세요', 'login.usernameRequired': '사용자 이름을 입력하세요',
'login.passwordMinLength': '비밀번호는 최소 8자 이상이어야 합니다', 'login.passwordMinLength': '비밀번호는 최소 8자 이상이어야 합니다',
'login.forgotPassword': '비밀번호를 잊으셨나요?', 'login.forgotPassword': '비밀번호를 잊으셨나요?',
'login.rememberMe': '로그인 상태 유지',
'login.forgotPasswordTitle': '비밀번호 재설정', 'login.forgotPasswordTitle': '비밀번호 재설정',
'login.forgotPasswordBody': 'login.forgotPasswordBody':
'가입 시 사용한 이메일 주소를 입력하세요. 계정이 존재하면 재설정 링크를 보내드립니다.', '가입 시 사용한 이메일 주소를 입력하세요. 계정이 존재하면 재설정 링크를 보내드립니다.',
+1
View File
@@ -56,6 +56,7 @@ const login: TranslationStrings = {
'login.usernameRequired': 'Gebruikersnaam is vereist', 'login.usernameRequired': 'Gebruikersnaam is vereist',
'login.passwordMinLength': 'Wachtwoord moet minimaal 8 tekens bevatten', 'login.passwordMinLength': 'Wachtwoord moet minimaal 8 tekens bevatten',
'login.forgotPassword': 'Wachtwoord vergeten?', 'login.forgotPassword': 'Wachtwoord vergeten?',
'login.rememberMe': 'Ingelogd blijven',
'login.forgotPasswordTitle': 'Wachtwoord resetten', 'login.forgotPasswordTitle': 'Wachtwoord resetten',
'login.forgotPasswordBody': 'login.forgotPasswordBody':
'Voer het e-mailadres van je account in. Als er een account bestaat, sturen we een resetlink.', 'Voer het e-mailadres van je account in. Als er een account bestaat, sturen we een resetlink.',
+1
View File
@@ -65,6 +65,7 @@ const login: TranslationStrings = {
'login.usernameRequired': 'Nazwa użytkownika jest wymagana', 'login.usernameRequired': 'Nazwa użytkownika jest wymagana',
'login.passwordMinLength': 'Hasło musi mieć co najmniej 8 znaków', 'login.passwordMinLength': 'Hasło musi mieć co najmniej 8 znaków',
'login.forgotPassword': 'Nie pamiętasz hasła?', 'login.forgotPassword': 'Nie pamiętasz hasła?',
'login.rememberMe': 'Zapamiętaj mnie',
'login.forgotPasswordTitle': 'Zresetuj hasło', 'login.forgotPasswordTitle': 'Zresetuj hasło',
'login.forgotPasswordBody': 'login.forgotPasswordBody':
'Wpisz adres e-mail użyty przy rejestracji. Jeśli konto istnieje, wyślemy link do resetu.', 'Wpisz adres e-mail użyty przy rejestracji. Jeśli konto istnieje, wyślemy link do resetu.',
+1
View File
@@ -56,6 +56,7 @@ const login: TranslationStrings = {
'login.usernameRequired': 'Имя пользователя обязательно', 'login.usernameRequired': 'Имя пользователя обязательно',
'login.passwordMinLength': 'Пароль должен содержать не менее 8 символов', 'login.passwordMinLength': 'Пароль должен содержать не менее 8 символов',
'login.forgotPassword': 'Забыли пароль?', 'login.forgotPassword': 'Забыли пароль?',
'login.rememberMe': 'Запомнить меня',
'login.forgotPasswordTitle': 'Сброс пароля', 'login.forgotPasswordTitle': 'Сброс пароля',
'login.forgotPasswordBody': 'login.forgotPasswordBody':
'Введите e-mail, с которым вы регистрировались. Если аккаунт найдём — отправим ссылку для сброса.', 'Введите e-mail, с которым вы регистрировались. Если аккаунт найдём — отправим ссылку для сброса.',
+1
View File
@@ -67,6 +67,7 @@ const login: TranslationStrings = {
'login.usernameRequired': 'Kullanıcı adı gerekli', 'login.usernameRequired': 'Kullanıcı adı gerekli',
'login.passwordMinLength': 'Şifre en az 8 karakter olmalıdır', 'login.passwordMinLength': 'Şifre en az 8 karakter olmalıdır',
'login.forgotPassword': 'Parolanızı mı unuttunuz?', 'login.forgotPassword': 'Parolanızı mı unuttunuz?',
'login.rememberMe': 'Beni hatırla',
'login.forgotPasswordTitle': 'Şifrenizi sıfırlayın', 'login.forgotPasswordTitle': 'Şifrenizi sıfırlayın',
'login.forgotPasswordBody': 'login.forgotPasswordBody':
"Enter the email address you signed up with. If an account exists, we'll send a reset link.", "Enter the email address you signed up with. If an account exists, we'll send a reset link.",
+1
View File
@@ -57,6 +57,7 @@ const login: TranslationStrings = {
'login.usernameRequired': 'Ім’я користувача обов’язкове', 'login.usernameRequired': 'Ім’я користувача обов’язкове',
'login.passwordMinLength': 'Пароль має містити щонайменше 8 символів', 'login.passwordMinLength': 'Пароль має містити щонайменше 8 символів',
'login.forgotPassword': 'Забули пароль?', 'login.forgotPassword': 'Забули пароль?',
'login.rememberMe': "Запам'ятати мене",
'login.forgotPasswordTitle': 'Скидання пароля', 'login.forgotPasswordTitle': 'Скидання пароля',
'login.forgotPasswordBody': 'login.forgotPasswordBody':
'Введіть електронну пошту, з якою ви реєструвалися. Якщо акаунт існує — буде надіслано посилання для скидання.', 'Введіть електронну пошту, з якою ви реєструвалися. Якщо акаунт існує — буде надіслано посилання для скидання.',
+1
View File
@@ -51,6 +51,7 @@ const login: TranslationStrings = {
'login.usernameRequired': '使用者名稱為必填', 'login.usernameRequired': '使用者名稱為必填',
'login.passwordMinLength': '密碼至少需要8個字元', 'login.passwordMinLength': '密碼至少需要8個字元',
'login.forgotPassword': '忘記密碼?', 'login.forgotPassword': '忘記密碼?',
'login.rememberMe': '記住我',
'login.forgotPasswordTitle': '重設密碼', 'login.forgotPasswordTitle': '重設密碼',
'login.forgotPasswordBody': 'login.forgotPasswordBody':
'請輸入您註冊時使用的電子郵件。若帳號存在,我們將傳送重設連結。', '請輸入您註冊時使用的電子郵件。若帳號存在,我們將傳送重設連結。',
+1
View File
@@ -51,6 +51,7 @@ const login: TranslationStrings = {
'login.usernameRequired': '用户名为必填项', 'login.usernameRequired': '用户名为必填项',
'login.passwordMinLength': '密码至少需要8个字符', 'login.passwordMinLength': '密码至少需要8个字符',
'login.forgotPassword': '忘记密码?', 'login.forgotPassword': '忘记密码?',
'login.rememberMe': '记住我',
'login.forgotPasswordTitle': '重置密码', 'login.forgotPasswordTitle': '重置密码',
'login.forgotPasswordBody': 'login.forgotPasswordBody':
'输入您注册时使用的邮箱地址。若账户存在,我们将发送重置链接。', '输入您注册时使用的邮箱地址。若账户存在,我们将发送重置链接。',
+2 -1
View File
@@ -22,7 +22,8 @@ Complete reference for all environment variables TREK reads.
| `TZ` | Timezone for logs, reminders, and cron jobs (e.g. `Europe/Berlin`) | `UTC` | | `TZ` | Timezone for logs, reminders, and cron jobs (e.g. `Europe/Berlin`) | `UTC` |
| `LOG_LEVEL` | `info` = concise user actions; `debug` = verbose details | `info` | | `LOG_LEVEL` | `info` = concise user actions; `debug` = verbose details | `info` |
| `DEFAULT_LANGUAGE` | Default language on the login page — see supported codes below | `en` | | `DEFAULT_LANGUAGE` | Default language on the login page — see supported codes below | `en` |
| `SESSION_DURATION` | How long a login session stays valid before re-login is required. Applies to both the `trek_session` JWT `exp` claim and the cookie `maxAge`, so they never drift apart. Accepts `ms`-style strings: `1h`, `12h`, `7d`, `30d`, `90d`. Invalid values warn at startup and fall back to the default. Does not affect the short-lived MFA challenge token or MCP OAuth tokens (those keep their own TTL). | `24h` | | `SESSION_DURATION` | How long a login session stays valid before re-login is required. Used when **"Remember me" is unchecked** on the login form (the default): applies to the `trek_session` JWT `exp` claim, and the cookie is issued as a **browser-session cookie** (no `maxAge`, cleared when the browser closes). Accepts `ms`-style strings: `1h`, `12h`, `7d`, `30d`, `90d`. Invalid values warn at startup and fall back to the default. Does not affect the short-lived MFA challenge token or MCP OAuth tokens (those keep their own TTL). | `24h` |
| `SESSION_DURATION_REMEMBER` | Session length used when the user **ticks "Remember me"** on login: a longer-lived JWT `exp` claim plus a **persistent** `trek_session` cookie whose `maxAge` matches, so the session survives browser restarts. Same `ms`-style format and startup-fallback behaviour as `SESSION_DURATION`. | `30d` |
| `ALLOWED_ORIGINS` | Comma-separated origins for CORS and email notification links | same-origin | | `ALLOWED_ORIGINS` | Comma-separated origins for CORS and email notification links | same-origin |
| `ALLOW_INTERNAL_NETWORK` | Allow outbound requests to private/RFC-1918 IPs. Set `true` if Immich or other integrated services are on your local network. Loopback (`127.x`) and link-local (`169.254.x`) addresses remain blocked regardless. | `false` | | `ALLOW_INTERNAL_NETWORK` | Allow outbound requests to private/RFC-1918 IPs. Set `true` if Immich or other integrated services are on your local network. Loopback (`127.x`) and link-local (`169.254.x`) addresses remain blocked regardless. | `false` |
| `APP_URL` | Public base URL (e.g. `https://trek.example.com`). Required when OIDC is enabled — must match the redirect URI registered with your IdP. Also used as the base URL for email notification links. | — | | `APP_URL` | Public base URL (e.g. `https://trek.example.com`). Required when OIDC is enabled — must match the redirect URI registered with your IdP. Also used as the base URL for email notification links. | — |