feat: generate a session secret on first start when none is configured

This commit is contained in:
Linus Rath
2026-10-13 13:17:34 +02:00
parent 3da46b5a0d
commit 8ad072e458
5 changed files with 104 additions and 3 deletions
+3 -1
View File
@@ -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.
+4 -1
View File
@@ -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;
+11
View File
@@ -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
@@ -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');
});
});
+35 -1
View File
@@ -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<string>('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<boolean> {
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;
}
}