Compare commits

...

7 Commits

Author SHA1 Message Date
jubnl bf969ee80d 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.
2026-06-15 12:21:05 +02:00
Maurice 2d413c99cf build(deps): bump tsx's esbuild to 0.28.1 (GHSA-gv7w-rqvm-qjhr)
The production image's last image-scan finding was esbuild 0.28.0, pulled
in transitively by tsx. Pin tsx's esbuild to 0.28.1 (within tsx's ~0.28.0
range) to clear GHSA-gv7w-rqvm-qjhr. Lockfile-only; no runtime change.
2026-06-15 10:50:15 +02:00
Maurice 58c7bd831a build(docker): rebuild gosu with a current Go toolchain
Debian's apt gosu ships an old Go stdlib that the image CVE scan flags
(1 critical + several high, all in golang/stdlib). Build gosu from source
with a current Go toolchain and copy the static binary in instead; the
runtime behaviour is unchanged — gosu still drops root to node at startup.
2026-06-15 10:38:01 +02:00
Maurice 8d1e7dded0 ci(security): only fail Docker Scout on fixable CVEs
Add only-fixed so the scan no longer fails on vulnerabilities with no
upstream fix available (e.g. base-image OS packages), and only flags
actionable, fixable findings.
2026-06-15 10:21:39 +02:00
Maurice 127a92c8f5 Merge main into dev: back-merge wiki dev-env updates before the 3.1.0 release
# Conflicts:
#	wiki/Development-environment.md
2026-06-15 10:00:15 +02:00
jubnl b25eb18ea4 wiki: small precision in dev env 2026-05-25 22:16:16 +02:00
jubnl 8410d7c4a5 wiki: update dev env 2026-05-25 22:10:44 +02:00
38 changed files with 335 additions and 138 deletions
+1
View File
@@ -34,4 +34,5 @@ jobs:
command: cves
image: trek:scan
only-severities: critical,high
only-fixed: true
exit-code: true
+11 -1
View File
@@ -1,3 +1,10 @@
# ── Stage 0: gosu ────────────────────────────────────────────────────────────
# Rebuild gosu with a current Go toolchain so the runtime image ships no stale
# Go stdlib (Debian's apt gosu is built with an old Go that trips CVE scanners).
# The binary and its runtime behaviour are identical to the apt package.
FROM golang:1.25-alpine AS gosu-build
RUN CGO_ENABLED=0 GOBIN=/out go install github.com/tianon/gosu@latest
# ── Stage 1: shared ──────────────────────────────────────────────────────────
FROM node:24-alpine AS shared-builder
WORKDIR /app
@@ -44,7 +51,7 @@ COPY server/package.json ./server/
# amd64 — static binary from KDE CDN (glibc 2.17+; wget stays for healthcheck)
# arm64 — apt package (KDE publishes no arm64 static binary)
RUN apt-get update && \
apt-get install -y --no-install-recommends tzdata dumb-init gosu wget ca-certificates python3 build-essential && \
apt-get install -y --no-install-recommends tzdata dumb-init wget ca-certificates python3 build-essential && \
npm ci --workspace=server --omit=dev && \
ARCH=$(dpkg --print-architecture) && \
if [ "$ARCH" = "amd64" ]; then \
@@ -60,6 +67,9 @@ RUN apt-get update && \
apt-get autoremove -y && \
rm -rf /var/lib/apt/lists/* /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx
# gosu rebuilt with a current Go toolchain (stage 0) — used by CMD to drop to node.
COPY --from=gosu-build /out/gosu /usr/local/bin/gosu
ENV XDG_CACHE_HOME=/tmp/kf6-cache
# Prevent Qt from probing for a display in headless containers.
ENV QT_QPA_PLATFORM=offscreen
+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', () => {
it('shows a Register button to switch to registration mode', async () => {
// Default appConfig has allow_registration: true, has_users: true
+11 -2
View File
@@ -9,7 +9,7 @@ export default function LoginPage(): React.ReactElement {
const {
navigate,
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,
langDropdownOpen, setLangDropdownOpen, setLanguageLocal,
showTakeoff, mfaStep, setMfaStep, mfaToken, setMfaToken, mfaCode, setMfaCode,
@@ -572,7 +572,16 @@ export default function LoginPage(): React.ReactElement {
</button>
</div>
{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={{
background: 'none', border: 'none', cursor: 'pointer', padding: 0,
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 [email, setEmail] = useState<string>('')
const [password, setPassword] = useState<string>('')
const [rememberMe, setRememberMe] = useState<boolean>(false)
const [showPassword, setShowPassword] = useState<boolean>(false)
const [isLoading, setIsLoading] = useState<boolean>(false)
const [error, setError] = useState<string>('')
@@ -242,7 +243,7 @@ export function useLogin() {
setIsLoading(false)
return
}
const mfaResult = await completeMfaLogin(mfaToken, mfaCode)
const mfaResult = await completeMfaLogin(mfaToken, mfaCode, rememberMe)
if ('user' in mfaResult && mfaResult.user?.must_change_password) {
setSavedLoginPassword(password)
setPasswordChangeStep(true)
@@ -258,7 +259,7 @@ export function useLogin() {
if (password.length < 8) { setError(t('login.passwordMinLength')); setIsLoading(false); return }
await register(username, email, password, inviteToken || undefined)
} 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) {
setMfaToken(result.mfa_token)
setMfaStep(true)
@@ -289,7 +290,7 @@ export function useLogin() {
return {
navigate,
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,
langDropdownOpen, setLangDropdownOpen, setLanguageLocal,
showTakeoff, mfaStep, setMfaStep, mfaToken, setMfaToken, mfaCode, setMfaCode,
+6 -6
View File
@@ -39,8 +39,8 @@ interface AuthState {
placesAutocompleteEnabled: boolean
placesDetailsEnabled: boolean
login: (email: string, password: string) => Promise<LoginResult>
completeMfaLogin: (mfaToken: string, code: string) => Promise<AuthResponse>
login: (email: string, password: string, rememberMe?: boolean) => Promise<LoginResult>
completeMfaLogin: (mfaToken: string, code: string, rememberMe?: boolean) => Promise<AuthResponse>
register: (username: string, email: string, password: string, invite_token?: string) => Promise<AuthResponse>
logout: () => Promise<void>
/** 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,
placesDetailsEnabled: true,
login: async (email: string, password: string) => {
login: async (email: string, password: string, rememberMe?: boolean) => {
authSequence++
set({ isLoading: true, error: null })
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) {
set({ isLoading: false, error: null })
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++
set({ isLoading: true, error: null })
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({
user: data.user,
isAuthenticated: true,
+109 -105
View File
@@ -15231,9 +15231,9 @@
}
},
"node_modules/tsx/node_modules/@esbuild/aix-ppc64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz",
"integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
"integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
"cpu": [
"ppc64"
],
@@ -15247,9 +15247,9 @@
}
},
"node_modules/tsx/node_modules/@esbuild/android-arm": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz",
"integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
"integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
"cpu": [
"arm"
],
@@ -15263,9 +15263,9 @@
}
},
"node_modules/tsx/node_modules/@esbuild/android-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz",
"integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
"integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
"cpu": [
"arm64"
],
@@ -15279,9 +15279,9 @@
}
},
"node_modules/tsx/node_modules/@esbuild/android-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz",
"integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
"integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
"cpu": [
"x64"
],
@@ -15295,9 +15295,9 @@
}
},
"node_modules/tsx/node_modules/@esbuild/darwin-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz",
"integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
"integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
"cpu": [
"arm64"
],
@@ -15311,9 +15311,9 @@
}
},
"node_modules/tsx/node_modules/@esbuild/darwin-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz",
"integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
"integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
"cpu": [
"x64"
],
@@ -15327,9 +15327,9 @@
}
},
"node_modules/tsx/node_modules/@esbuild/freebsd-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz",
"integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
"integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
"cpu": [
"arm64"
],
@@ -15343,9 +15343,9 @@
}
},
"node_modules/tsx/node_modules/@esbuild/freebsd-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz",
"integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
"integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
"cpu": [
"x64"
],
@@ -15359,9 +15359,9 @@
}
},
"node_modules/tsx/node_modules/@esbuild/linux-arm": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz",
"integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
"integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
"cpu": [
"arm"
],
@@ -15375,9 +15375,9 @@
}
},
"node_modules/tsx/node_modules/@esbuild/linux-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz",
"integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
"integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
"cpu": [
"arm64"
],
@@ -15391,9 +15391,9 @@
}
},
"node_modules/tsx/node_modules/@esbuild/linux-ia32": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz",
"integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
"integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
"cpu": [
"ia32"
],
@@ -15407,9 +15407,9 @@
}
},
"node_modules/tsx/node_modules/@esbuild/linux-loong64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz",
"integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
"integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
"cpu": [
"loong64"
],
@@ -15423,9 +15423,9 @@
}
},
"node_modules/tsx/node_modules/@esbuild/linux-mips64el": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz",
"integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
"integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
"cpu": [
"mips64el"
],
@@ -15439,9 +15439,9 @@
}
},
"node_modules/tsx/node_modules/@esbuild/linux-ppc64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz",
"integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
"integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
"cpu": [
"ppc64"
],
@@ -15455,9 +15455,9 @@
}
},
"node_modules/tsx/node_modules/@esbuild/linux-riscv64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz",
"integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
"integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
"cpu": [
"riscv64"
],
@@ -15471,9 +15471,9 @@
}
},
"node_modules/tsx/node_modules/@esbuild/linux-s390x": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz",
"integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
"integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
"cpu": [
"s390x"
],
@@ -15487,9 +15487,9 @@
}
},
"node_modules/tsx/node_modules/@esbuild/linux-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz",
"integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
"integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
"cpu": [
"x64"
],
@@ -15503,9 +15503,9 @@
}
},
"node_modules/tsx/node_modules/@esbuild/netbsd-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz",
"integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
"integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
"cpu": [
"arm64"
],
@@ -15519,9 +15519,9 @@
}
},
"node_modules/tsx/node_modules/@esbuild/netbsd-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz",
"integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
"integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
"cpu": [
"x64"
],
@@ -15535,9 +15535,9 @@
}
},
"node_modules/tsx/node_modules/@esbuild/openbsd-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz",
"integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
"integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
"cpu": [
"arm64"
],
@@ -15551,9 +15551,9 @@
}
},
"node_modules/tsx/node_modules/@esbuild/openbsd-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz",
"integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
"integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
"cpu": [
"x64"
],
@@ -15567,9 +15567,9 @@
}
},
"node_modules/tsx/node_modules/@esbuild/openharmony-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz",
"integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
"integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
"cpu": [
"arm64"
],
@@ -15583,9 +15583,9 @@
}
},
"node_modules/tsx/node_modules/@esbuild/sunos-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz",
"integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
"integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
"cpu": [
"x64"
],
@@ -15599,9 +15599,9 @@
}
},
"node_modules/tsx/node_modules/@esbuild/win32-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz",
"integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
"integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
"cpu": [
"arm64"
],
@@ -15615,9 +15615,9 @@
}
},
"node_modules/tsx/node_modules/@esbuild/win32-ia32": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz",
"integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
"integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
"cpu": [
"ia32"
],
@@ -15631,7 +15631,7 @@
}
},
"node_modules/tsx/node_modules/@esbuild/win32-x64": {
"version": "0.28.0",
"version": "0.28.1",
"cpu": [
"x64"
],
@@ -15642,10 +15642,12 @@
],
"engines": {
"node": ">=18"
}
},
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
"integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A=="
},
"node_modules/tsx/node_modules/esbuild": {
"version": "0.28.0",
"version": "0.28.1",
"hasInstallScript": true,
"license": "MIT",
"bin": {
@@ -15655,33 +15657,35 @@
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.28.0",
"@esbuild/android-arm": "0.28.0",
"@esbuild/android-arm64": "0.28.0",
"@esbuild/android-x64": "0.28.0",
"@esbuild/darwin-arm64": "0.28.0",
"@esbuild/darwin-x64": "0.28.0",
"@esbuild/freebsd-arm64": "0.28.0",
"@esbuild/freebsd-x64": "0.28.0",
"@esbuild/linux-arm": "0.28.0",
"@esbuild/linux-arm64": "0.28.0",
"@esbuild/linux-ia32": "0.28.0",
"@esbuild/linux-loong64": "0.28.0",
"@esbuild/linux-mips64el": "0.28.0",
"@esbuild/linux-ppc64": "0.28.0",
"@esbuild/linux-riscv64": "0.28.0",
"@esbuild/linux-s390x": "0.28.0",
"@esbuild/linux-x64": "0.28.0",
"@esbuild/netbsd-arm64": "0.28.0",
"@esbuild/netbsd-x64": "0.28.0",
"@esbuild/openbsd-arm64": "0.28.0",
"@esbuild/openbsd-x64": "0.28.0",
"@esbuild/openharmony-arm64": "0.28.0",
"@esbuild/sunos-x64": "0.28.0",
"@esbuild/win32-arm64": "0.28.0",
"@esbuild/win32-ia32": "0.28.0",
"@esbuild/win32-x64": "0.28.0"
}
"@esbuild/aix-ppc64": "0.28.1",
"@esbuild/android-arm": "0.28.1",
"@esbuild/android-arm64": "0.28.1",
"@esbuild/android-x64": "0.28.1",
"@esbuild/darwin-arm64": "0.28.1",
"@esbuild/darwin-x64": "0.28.1",
"@esbuild/freebsd-arm64": "0.28.1",
"@esbuild/freebsd-x64": "0.28.1",
"@esbuild/linux-arm": "0.28.1",
"@esbuild/linux-arm64": "0.28.1",
"@esbuild/linux-ia32": "0.28.1",
"@esbuild/linux-loong64": "0.28.1",
"@esbuild/linux-mips64el": "0.28.1",
"@esbuild/linux-ppc64": "0.28.1",
"@esbuild/linux-riscv64": "0.28.1",
"@esbuild/linux-s390x": "0.28.1",
"@esbuild/linux-x64": "0.28.1",
"@esbuild/netbsd-arm64": "0.28.1",
"@esbuild/netbsd-x64": "0.28.1",
"@esbuild/openbsd-arm64": "0.28.1",
"@esbuild/openbsd-x64": "0.28.1",
"@esbuild/openharmony-arm64": "0.28.1",
"@esbuild/sunos-x64": "0.28.1",
"@esbuild/win32-arm64": "0.28.1",
"@esbuild/win32-ia32": "0.28.1",
"@esbuild/win32-x64": "0.28.1"
},
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
"integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="
},
"node_modules/tsyringe": {
"version": "4.10.0",
+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)!;
/** Session length in seconds — passed to `jwt.sign({ expiresIn })` (number = seconds). */
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) {
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 };
}
@@ -146,7 +146,7 @@ export class AuthPublicController {
throw new HttpException({ error: result.error }, result.status!);
}
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 };
}
+1 -1
View File
@@ -14,7 +14,7 @@ import type { User } from '../../types';
@Injectable()
export class AuthService {
// 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); }
// 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 { randomBytes, createHash } from 'crypto';
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 { encryptMfaSecret, decryptMfaSecret } from './mfaCrypto';
import { getAllPermissions } from './permissions';
@@ -181,14 +181,17 @@ export function isOidcOnlyMode(): boolean {
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'
? user.password_version
: ((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(
{ id: user.id, pv },
JWT_SECRET,
{ expiresIn: SESSION_DURATION_SECONDS, algorithm: 'HS256' }
{ expiresIn, algorithm: 'HS256' }
);
}
@@ -443,6 +446,7 @@ export function registerUser(body: {
export function loginUser(body: {
email?: string;
password?: string;
remember_me?: boolean;
}): {
error?: string;
status?: number;
@@ -450,6 +454,7 @@ export function loginUser(body: {
user?: Record<string, unknown>;
mfa_required?: boolean;
mfa_token?: string;
remember?: boolean;
auditUserId?: number | null;
auditAction?: string;
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 };
}
const { email, password } = body;
const { email, password, remember_me } = body;
const remember = remember_me === true;
if (!email || !password) {
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);
const token = generateToken(user);
const token = generateToken(user, remember);
const userSafe = stripUserForClient(user) as Record<string, unknown>;
return {
token,
user: { ...userSafe, avatar_url: avatarUrl(user) },
remember,
auditUserId: Number(user.id),
auditAction: 'user.login',
auditDetails: { email },
@@ -1066,14 +1073,17 @@ export function disableMfa(
export function verifyMfaLogin(body: {
mfa_token?: string;
code?: string;
remember_me?: boolean;
}): {
error?: string;
status?: number;
token?: string;
user?: Record<string, unknown>;
remember?: boolean;
auditUserId?: number;
} {
const { mfa_token, code } = body;
const { mfa_token, code, remember_me } = body;
const remember = remember_me === true;
if (!mfa_token || !code) {
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);
const sessionToken = generateToken(user);
const sessionToken = generateToken(user, remember);
const userSafe = stripUserForClient(user) as Record<string, unknown>;
return {
token: sessionToken,
user: { ...userSafe, avatar_url: avatarUrl(user) },
remember,
auditUserId: Number(user.id),
};
} catch {
+25 -8
View File
@@ -1,8 +1,17 @@
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';
/**
* 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.
*
@@ -18,27 +27,35 @@ const COOKIE_NAME = 'trek_session';
* on the outermost hop, the cookie is `Secure`. `COOKIE_SECURE=false`
* 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') {
return buildOptions(clear, false);
return buildOptions(clear, false, remember);
}
const envSecure = process.env.NODE_ENV?.toLowerCase() === 'production' || process.env.FORCE_HTTPS?.toLowerCase() === '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 {
httpOnly: true,
secure,
sameSite: 'lax' as const,
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 {
res.cookie(COOKIE_NAME, token, cookieOptions(false, req));
export function setAuthCookie(res: Response, token: string, req?: Request, remember?: RememberOption): void {
res.cookie(COOKIE_NAME, token, cookieOptions(false, req, remember));
}
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);
}, 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 () => {
const res = await request(server).post('/api/auth/logout');
expect(res.status).toBe(200);
@@ -82,9 +82,10 @@ describe('AuthPublicController', () => {
const setAuthCookie = vi.fn();
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' });
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(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());
expect(await thrownAsync(() => bad.login({}, req, res))).toEqual({ status: 401, body: { error: 'Bad creds' } });
}, 10000);
+13
View File
@@ -1,6 +1,7 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { cookieOptions } from '../../../src/services/cookie';
import { SESSION_DURATION_MS, SESSION_DURATION_REMEMBER_MS } from '../../../src/config';
describe('cookieOptions', () => {
afterEach(() => {
@@ -53,4 +54,16 @@ describe('cookieOptions', () => {
const opts = cookieOptions(true);
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({
email: 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>;
@@ -45,6 +49,9 @@ export type ChangePasswordRequest = z.infer<typeof changePasswordRequestSchema>;
export const mfaVerifyLoginRequestSchema = z.object({
mfa_token: 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>;
+1
View File
@@ -59,6 +59,7 @@ const login: TranslationStrings = {
'login.usernameRequired': 'اسم المستخدم مطلوب',
'login.passwordMinLength': 'يجب أن تكون كلمة المرور 8 أحرف على الأقل',
'login.forgotPassword': 'نسيت كلمة المرور؟',
'login.rememberMe': 'تذكرني',
'login.forgotPasswordTitle': 'إعادة تعيين كلمة المرور',
'login.forgotPasswordBody':
'أدخل عنوان البريد الإلكتروني المسجَّل. إذا كان الحساب موجودًا، سنرسل رابط إعادة التعيين.',
+1
View File
@@ -62,6 +62,7 @@ const login: TranslationStrings = {
'login.usernameRequired': 'Nome de usuário é obrigatório',
'login.passwordMinLength': 'A senha deve ter pelo menos 8 caracteres',
'login.forgotPassword': 'Esqueceu a senha?',
'login.rememberMe': 'Lembrar de mim',
'login.forgotPasswordTitle': 'Redefinir sua senha',
'login.forgotPasswordBody':
'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.passwordMinLength': 'Heslo musí mít alespoň 8 znaků',
'login.forgotPassword': 'Zapomenuté heslo?',
'login.rememberMe': 'Zapamatovat si mě',
'login.forgotPasswordTitle': 'Obnovení hesla',
'login.forgotPasswordBody':
'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.passwordMinLength': 'Das Passwort muss mindestens 8 Zeichen lang sein',
'login.forgotPassword': 'Passwort vergessen?',
'login.rememberMe': 'Angemeldet bleiben',
'login.forgotPasswordTitle': 'Passwort zurücksetzen',
'login.forgotPasswordBody':
'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.passwordMinLength': 'Password must be at least 8 characters',
'login.forgotPassword': 'Forgot password?',
'login.rememberMe': 'Remember me',
'login.forgotPasswordTitle': 'Reset your password',
'login.forgotPasswordBody':
"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.passwordMinLength': 'La contraseña debe tener al menos 8 caracteres',
'login.forgotPassword': '¿Olvidaste tu contraseña?',
'login.rememberMe': 'Recuérdame',
'login.forgotPasswordTitle': 'Restablecer tu contraseña',
'login.forgotPasswordBody':
'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':
'Le mot de passe doit comporter au moins 8 caractères',
'login.forgotPassword': 'Mot de passe oublié ?',
'login.rememberMe': 'Se souvenir de moi',
'login.forgotPasswordTitle': 'Réinitialiser votre mot de passe',
'login.forgotPasswordBody':
"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':
'Ο κωδικός πρέπει να έχει τουλάχιστον 8 χαρακτήρες',
'login.forgotPassword': 'Ξεχάσατε τον κωδικό;',
'login.rememberMe': 'Να με θυμάσαι',
'login.forgotPasswordTitle': 'Επαναφορά του κωδικού σας',
'login.forgotPasswordBody':
'Εισάγετε το email με το οποίο εγγραφήκατε. Αν υπάρχει λογαριασμός, θα στείλουμε έναν σύνδεσμο επαναφοράς.',
+1
View File
@@ -69,6 +69,7 @@ const login: TranslationStrings = {
'login.passwordMinLength':
'A jelszónak legalább 8 karakter hosszúnak kell lennie',
'login.forgotPassword': 'Elfelejtetted a jelszavad?',
'login.rememberMe': 'Emlékezz rám',
'login.forgotPasswordTitle': 'Jelszó visszaállítása',
'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.',
+1
View File
@@ -65,6 +65,7 @@ const login: TranslationStrings = {
'login.usernameRequired': 'Nama pengguna wajib diisi',
'login.passwordMinLength': 'Kata sandi minimal 8 karakter',
'login.forgotPassword': 'Lupa kata sandi?',
'login.rememberMe': 'Ingat saya',
'login.forgotPasswordTitle': 'Setel ulang kata sandi',
'login.forgotPasswordBody':
'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.passwordMinLength': 'La password deve contenere almeno 8 caratteri',
'login.forgotPassword': 'Password dimenticata?',
'login.rememberMe': 'Ricordami',
'login.forgotPasswordTitle': 'Reimposta la password',
'login.forgotPasswordBody':
'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.passwordMinLength': 'パスワードは8文字以上である必要があります',
'login.forgotPassword': 'パスワードを忘れた場合',
'login.rememberMe': 'ログイン状態を保持する',
'login.forgotPasswordTitle': 'パスワードをリセット',
'login.forgotPasswordBody':
'登録時のメールアドレスを入力してください。アカウントが存在する場合、リセット用リンクを送信します。',
+1
View File
@@ -62,6 +62,7 @@ const login: TranslationStrings = {
'login.usernameRequired': '사용자 이름을 입력하세요',
'login.passwordMinLength': '비밀번호는 최소 8자 이상이어야 합니다',
'login.forgotPassword': '비밀번호를 잊으셨나요?',
'login.rememberMe': '로그인 상태 유지',
'login.forgotPasswordTitle': '비밀번호 재설정',
'login.forgotPasswordBody':
'가입 시 사용한 이메일 주소를 입력하세요. 계정이 존재하면 재설정 링크를 보내드립니다.',
+1
View File
@@ -56,6 +56,7 @@ const login: TranslationStrings = {
'login.usernameRequired': 'Gebruikersnaam is vereist',
'login.passwordMinLength': 'Wachtwoord moet minimaal 8 tekens bevatten',
'login.forgotPassword': 'Wachtwoord vergeten?',
'login.rememberMe': 'Ingelogd blijven',
'login.forgotPasswordTitle': 'Wachtwoord resetten',
'login.forgotPasswordBody':
'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.passwordMinLength': 'Hasło musi mieć co najmniej 8 znaków',
'login.forgotPassword': 'Nie pamiętasz hasła?',
'login.rememberMe': 'Zapamiętaj mnie',
'login.forgotPasswordTitle': 'Zresetuj hasło',
'login.forgotPasswordBody':
'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.passwordMinLength': 'Пароль должен содержать не менее 8 символов',
'login.forgotPassword': 'Забыли пароль?',
'login.rememberMe': 'Запомнить меня',
'login.forgotPasswordTitle': 'Сброс пароля',
'login.forgotPasswordBody':
'Введите e-mail, с которым вы регистрировались. Если аккаунт найдём — отправим ссылку для сброса.',
+1
View File
@@ -67,6 +67,7 @@ const login: TranslationStrings = {
'login.usernameRequired': 'Kullanıcı adı gerekli',
'login.passwordMinLength': 'Şifre en az 8 karakter olmalıdır',
'login.forgotPassword': 'Parolanızı mı unuttunuz?',
'login.rememberMe': 'Beni hatırla',
'login.forgotPasswordTitle': 'Şifrenizi sıfırlayın',
'login.forgotPasswordBody':
"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.passwordMinLength': 'Пароль має містити щонайменше 8 символів',
'login.forgotPassword': 'Забули пароль?',
'login.rememberMe': "Запам'ятати мене",
'login.forgotPasswordTitle': 'Скидання пароля',
'login.forgotPasswordBody':
'Введіть електронну пошту, з якою ви реєструвалися. Якщо акаунт існує — буде надіслано посилання для скидання.',
+1
View File
@@ -51,6 +51,7 @@ const login: TranslationStrings = {
'login.usernameRequired': '使用者名稱為必填',
'login.passwordMinLength': '密碼至少需要8個字元',
'login.forgotPassword': '忘記密碼?',
'login.rememberMe': '記住我',
'login.forgotPasswordTitle': '重設密碼',
'login.forgotPasswordBody':
'請輸入您註冊時使用的電子郵件。若帳號存在,我們將傳送重設連結。',
+1
View File
@@ -51,6 +51,7 @@ const login: TranslationStrings = {
'login.usernameRequired': '用户名为必填项',
'login.passwordMinLength': '密码至少需要8个字符',
'login.forgotPassword': '忘记密码?',
'login.rememberMe': '记住我',
'login.forgotPasswordTitle': '重置密码',
'login.forgotPasswordBody':
'输入您注册时使用的邮箱地址。若账户存在,我们将发送重置链接。',
+30
View File
@@ -154,6 +154,36 @@ The `@trek/shared` package is the single source of truth for code shared between
| `npm run lint` | Lint source |
| `npm run format` | Format source |
### Root (`/`)
These commands run across all workspaces at once and are the recommended way to work:
| Command | Description |
|----------------------|---------------------------------------------------------------------|
| `npm run dev` | Build shared, then start shared (watch), server, and client together via `concurrently` |
| `npm run build` | Build shared → server → client in order |
| `npm test` | Run tests in shared, server, and client |
| `npm run test:cov` | Run coverage for server and client |
| `npm run test:e2e` | Run end-to-end tests (server) |
| `npm run lint` | Lint shared, server, and client |
| `npm run format` | Format shared, server, and client |
| `npm run format:check` | Check formatting across all workspaces |
### Shared (`/shared`)
The `@trek/shared` package is the single source of truth for code shared between the client and server. It currently holds **Zod schemas that define API contracts** (request/response shapes, common primitives, pagination). Both workspaces import from it so schema changes automatically propagate to both sides.
> **Upcoming:** the i18n translation layer will be migrated into this package so that translation keys and types are enforced across the stack from one place.
| Command | Description |
|------------------------|------------------------------------|
| `npm run build` | Compile shared package (tsup) |
| `npm run build:watch` | Compile in watch mode |
| `npm test` | Run tests |
| `npm run typecheck` | Type-check without emitting |
| `npm run lint` | Lint source |
| `npm run format` | Format source |
### Server (`/server`)
| Command | Description |
+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` |
| `LOG_LEVEL` | `info` = concise user actions; `debug` = verbose details | `info` |
| `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 |
| `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. | — |