From 8ad072e458a979759ed329cdff2f9627da87be00 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 13 Oct 2026 13:17:34 +0200 Subject: [PATCH] feat: generate a session secret on first start when none is configured --- .env.example | 4 +- app/api/admin/config/route.ts | 5 +- instrumentation.node.ts | 11 ++++ lib/__tests__/session-secret-generate.test.ts | 51 +++++++++++++++++++ lib/auth/session-secret.ts | 36 ++++++++++++- 5 files changed, 104 insertions(+), 3 deletions(-) create mode 100644 lib/__tests__/session-secret-generate.test.ts diff --git a/.env.example b/.env.example index dd3e00b8e..96245b1a0 100644 --- a/.env.example +++ b/.env.example @@ -109,7 +109,9 @@ JMAP_SERVER_URL=https://your-jmap-server.com # ============================================================================= # Secret key for encrypting "Remember me" sessions and settings sync data. -# Required for both "Remember me" and settings sync features. +# Required for both "Remember me" and settings sync features. Left unset, +# one is generated on first start and kept in the admin state dir +# (data/admin-state), so keep that dir on a persistent volume. # Generate with: openssl rand -base64 32 # SESSION_SECRET=your-secret-key-here # Alternatively, you can specify the path to a file containing the session secret. diff --git a/app/api/admin/config/route.ts b/app/api/admin/config/route.ts index 979a563a3..c65dcb10a 100644 --- a/app/api/admin/config/route.ts +++ b/app/api/admin/config/route.ts @@ -7,6 +7,7 @@ import { parseJmapServers } from '@/lib/admin/jmap-servers'; import { parseDomainBranding } from '@/lib/admin/domain-branding'; import { initAdminPassword, isAdminEnabled } from '@/lib/admin/password'; import { logger } from '@/lib/logger'; +import { hasSessionSecret } from '@/lib/auth/session-secret'; // Strings that count as "no real secret configured" - used so the dashboard // can warn about a placeholder session secret without us ever returning the @@ -35,7 +36,9 @@ export async function GET(request: NextRequest) { if (SENSITIVE_CONFIG_KEYS.has(key)) { const v = entry.value; const hasValue = - typeof v === 'string' && v.length > 0 && !SENSITIVE_PLACEHOLDERS.has(v); + (typeof v === 'string' && v.length > 0 && !SENSITIVE_PLACEHOLDERS.has(v)) || + // Nothing configured, but the secret generated on first start is there. + (key === 'sessionSecret' && entry.source === 'default' && hasSessionSecret()); safe[key] = { source: entry.source, hasValue }; } else { safe[key] = entry; diff --git a/instrumentation.node.ts b/instrumentation.node.ts index 365427d9d..1f9b94e33 100644 --- a/instrumentation.node.ts +++ b/instrumentation.node.ts @@ -4,6 +4,7 @@ import { initAdminPassword } from "./lib/admin/password"; import { migrateLegacyAdminLayout, migratePolicyUnifiedMailbox } from "./lib/admin/migrate"; import { detectSetupState } from "./lib/setup/state"; import { ensureSetupToken } from "./lib/setup/token"; +import { ensureSessionSecret } from "./lib/auth/session-secret"; const pkg = JSON.parse( readFileSync(`${process.cwd()}/package.json`, "utf-8") @@ -19,6 +20,16 @@ migrateLegacyAdminLayout() .then(() => initAdminPassword()) .then(async () => { console.info("Admin dashboard initialized"); + try { + if (await ensureSessionSecret()) { + console.info( + "Generated a session secret in the admin state dir. Keep that dir on a " + + "persistent volume, or set SESSION_SECRET, so restarts don't sign everyone out.", + ); + } + } catch (err) { + console.warn("Failed to generate a session secret:", err instanceof Error ? err.message : err); + } // If we're in bootstrap state (no JMAP_SERVER_URL env and no // setupComplete in config.json), generate/refresh the setup token and // print it to the logs so the operator can complete the web wizard diff --git a/lib/__tests__/session-secret-generate.test.ts b/lib/__tests__/session-secret-generate.test.ts new file mode 100644 index 000000000..d0ab1a7c6 --- /dev/null +++ b/lib/__tests__/session-secret-generate.test.ts @@ -0,0 +1,51 @@ +import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// An empty config dir so no developer admin override supplies a secret. +process.env.ADMIN_CONFIG_DIR = mkdtempSync(path.join(tmpdir(), 'bw-secret-config-')); + +const { ensureSessionSecret, getSessionSecret, hasSessionSecret } = await import('@/lib/auth/session-secret'); + +describe('ensureSessionSecret', () => { + let stateDir: string; + + beforeEach(() => { + stateDir = mkdtempSync(path.join(tmpdir(), 'bw-secret-state-')); + vi.stubEnv('ADMIN_STATE_DIR', stateDir); + vi.stubEnv('SESSION_SECRET', undefined); + vi.stubEnv('SESSION_SECRET_FILE', undefined); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + rmSync(stateDir, { recursive: true, force: true }); + }); + + it('generates a secret on first start when none is configured', async () => { + expect(hasSessionSecret()).toBe(false); + + expect(await ensureSessionSecret()).toBe(true); + + const secret = getSessionSecret(); + expect(Buffer.from(secret, 'base64')).toHaveLength(32); + expect(readFileSync(path.join(stateDir, '.session-secret'), 'utf-8')).toBe(secret); + }); + + it('keeps the generated secret across restarts', async () => { + await ensureSessionSecret(); + const first = getSessionSecret(); + + expect(await ensureSessionSecret()).toBe(false); + expect(getSessionSecret()).toBe(first); + }); + + it('writes nothing when SESSION_SECRET is set', async () => { + vi.stubEnv('SESSION_SECRET', 'from-env'); + + expect(await ensureSessionSecret()).toBe(false); + expect(existsSync(path.join(stateDir, '.session-secret'))).toBe(false); + expect(getSessionSecret()).toBe('from-env'); + }); +}); diff --git a/lib/auth/session-secret.ts b/lib/auth/session-secret.ts index 4bc94a6c6..f2c3738a5 100644 --- a/lib/auth/session-secret.ts +++ b/lib/auth/session-secret.ts @@ -1,12 +1,19 @@ +import { randomBytes } from 'node:crypto'; +import { writeFile } from 'node:fs/promises'; import { configManager } from '@/lib/admin/config-manager'; +import { ensureStateDir, getStatePath } from '@/lib/admin/paths'; import { readFileEnv } from '@/lib/read-file-env'; +/** Written on first start when no secret is configured (see ensureSessionSecret). */ +const GENERATED_SECRET_FILE = '.session-secret'; + /** * Resolve the session secret from any of the supported sources, in priority * order: * 1. SESSION_SECRET env var * 2. SESSION_SECRET_FILE-pointed file * 3. Admin override in config.json (set by the setup wizard) + * 4. The secret generated on first start, in the admin state dir * * Returns an empty string when nothing is configured. Callers must treat * empty as "feature disabled" rather than crashing. @@ -28,9 +35,36 @@ export function getSessionSecret(): string { const fromAdminFile = readFileEnv( configManager.get('sessionSecretFile', ''), ); - return fromAdminFile || ""; + if (fromAdminFile) return fromAdminFile; + + return readFileEnv(getStatePath(GENERATED_SECRET_FILE)) || ""; } export function hasSessionSecret(): boolean { return getSessionSecret().length > 0; } + +/** + * Generate a session secret when none is configured, so remember me and + * settings sync work on installs that skip the setup wizard (JMAP_SERVER_URL + * set in the environment). It lives in the state dir, which is always + * writable and a volume in docker-compose.yml; without a persistent volume a + * restart issues a new one and signs everyone out. + * + * Returns true when this call wrote a new secret. + */ +export async function ensureSessionSecret(): Promise { + if (hasSessionSecret()) return false; + await ensureStateDir(); + try { + // 'wx' so two processes starting at once can't overwrite each other's. + await writeFile(getStatePath(GENERATED_SECRET_FILE), randomBytes(32).toString('base64'), { + flag: 'wx', + mode: 0o600, + }); + return true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'EEXIST') return false; + throw error; + } +}