From f9052eb23f81b0f12a417ff77f7ff545745e6f1d Mon Sep 17 00:00:00 2001 From: Pascal Dietrich Date: Tue, 7 Apr 2026 19:56:27 +0200 Subject: [PATCH] feat: add SESSION_SECRET_FILE and OAUTH_CLIENT_SECRET_FILE env vars --- app/api/auth/sso/start/route.ts | 3 ++- app/api/auth/totp-token-exchange/route.ts | 3 ++- app/api/config/route.ts | 5 ++-- app/api/settings/route.ts | 3 ++- lib/__tests__/config-route.test.ts | 32 +++++++++++++++++++++++ lib/admin/session.ts | 3 ++- lib/auth/crypto.ts | 3 ++- lib/oauth/token-exchange.ts | 3 ++- lib/read-file-env.ts | 13 +++++++++ lib/settings-sync.ts | 3 ++- 10 files changed, 62 insertions(+), 9 deletions(-) create mode 100644 lib/read-file-env.ts diff --git a/app/api/auth/sso/start/route.ts b/app/api/auth/sso/start/route.ts index 07240ebe..1c0b1bdd 100644 --- a/app/api/auth/sso/start/route.ts +++ b/app/api/auth/sso/start/route.ts @@ -7,13 +7,14 @@ import { getRequiredConfig } from '@/lib/oauth/token-exchange'; import { discoverOAuth } from '@/lib/oauth/discovery'; import { OAUTH_SCOPES } from '@/lib/oauth/tokens'; import { getCookieOptions } from '@/lib/oauth/cookie-config'; +import { readFileEnv } from '@/lib/read-file-env'; const SSO_PENDING_COOKIE = 'sso_pending'; const SSO_PENDING_MAX_AGE = 300; // 5 minutes export async function POST(request: NextRequest) { try { - if (!process.env.SESSION_SECRET) { + if (!process.env.SESSION_SECRET && !readFileEnv(process.env.SESSION_SECRET_FILE)) { return NextResponse.json({ error: 'SESSION_SECRET is required for SSO' }, { status: 500 }); } diff --git a/app/api/auth/totp-token-exchange/route.ts b/app/api/auth/totp-token-exchange/route.ts index 7ea840e4..b16e6631 100644 --- a/app/api/auth/totp-token-exchange/route.ts +++ b/app/api/auth/totp-token-exchange/route.ts @@ -4,6 +4,7 @@ import { logger } from '@/lib/logger'; import { discoverOAuth } from '@/lib/oauth/discovery'; import { refreshTokenCookieName } from '@/lib/oauth/tokens'; import { getCookieOptions } from '@/lib/oauth/cookie-config'; +import { readFileEnv } from '@/lib/read-file-env'; /** * Exchange basic auth credentials (with TOTP appended) for OAuth tokens. @@ -113,7 +114,7 @@ async function attemptAllStrategies( logger.info('TOTP token exchange: found token endpoint', { tokenEndpoint }); const clientId = process.env.OAUTH_CLIENT_ID; - const clientSecret = process.env.OAUTH_CLIENT_SECRET; + const clientSecret = process.env.OAUTH_CLIENT_SECRET || readFileEnv(process.env.OAUTH_CLIENT_SECRET_FILE); const basicAuth = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`; const attempts: Array<{ strategy: string; error: string }> = []; diff --git a/app/api/config/route.ts b/app/api/config/route.ts index 0b3f3bb8..bd930719 100644 --- a/app/api/config/route.ts +++ b/app/api/config/route.ts @@ -1,6 +1,7 @@ import { NextResponse } from 'next/server'; import { logger } from '@/lib/logger'; import { configManager } from '@/lib/admin/config-manager'; +import { readFileEnv } from '@/lib/read-file-env'; /** * Runtime configuration endpoint @@ -33,8 +34,8 @@ export async function GET() { oauthOnly, oauthClientId: configManager.get('oauthClientId', ''), oauthIssuerUrl: configManager.get('oauthIssuerUrl', ''), - rememberMeEnabled: !!process.env.SESSION_SECRET, - settingsSyncEnabled: configManager.get('settingsSyncEnabled', false) && !!process.env.SESSION_SECRET, + rememberMeEnabled: !!process.env.SESSION_SECRET || !!readFileEnv(process.env.SESSION_SECRET_FILE), + settingsSyncEnabled: configManager.get('settingsSyncEnabled', false) && (!!process.env.SESSION_SECRET || !!readFileEnv(process.env.SESSION_SECRET_FILE)), stalwartFeaturesEnabled, devMode: configManager.get('devMode', false), faviconUrl: configManager.get('faviconUrl', '/branding/Bulwark_Favicon.svg'), diff --git a/app/api/settings/route.ts b/app/api/settings/route.ts index 72a34ca4..5636e439 100644 --- a/app/api/settings/route.ts +++ b/app/api/settings/route.ts @@ -6,6 +6,7 @@ import { sessionCookieName } from '@/lib/auth/session-cookie'; import { readStalwartAuthContextFromStore } from '@/lib/stalwart/auth-context'; import { saveUserSettings, loadUserSettings, deleteUserSettings } from '@/lib/settings-sync'; import { configManager } from '@/lib/admin/config-manager'; +import { readFileEnv } from '@/lib/read-file-env'; function classifyError(error: unknown): { message: string; status: number } { const code = (error as NodeJS.ErrnoException).code; @@ -48,7 +49,7 @@ function classifyError(error: unknown): { message: string; status: number } { } function isEnabled(): boolean { - return process.env.SETTINGS_SYNC_ENABLED === 'true' && !!process.env.SESSION_SECRET; + return process.env.SETTINGS_SYNC_ENABLED === 'true' && (!!process.env.SESSION_SECRET || !!readFileEnv(process.env.SESSION_SECRET_FILE)); } /** Strip trailing slashes so differently-formatted URLs still match. */ diff --git a/lib/__tests__/config-route.test.ts b/lib/__tests__/config-route.test.ts index 3450cbf0..414ae7ae 100644 --- a/lib/__tests__/config-route.test.ts +++ b/lib/__tests__/config-route.test.ts @@ -1,3 +1,4 @@ +import { unlink, writeFileSync } from "fs"; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; // Mock NextResponse before importing the route @@ -24,6 +25,7 @@ describe('config API route', () => { delete process.env.OAUTH_CLIENT_ID; delete process.env.OAUTH_ISSUER_URL; delete process.env.SESSION_SECRET; + delete process.env.SESSION_SECRET_FILE; delete process.env.SETTINGS_SYNC_ENABLED; delete process.env.STALWART_FEATURES; delete process.env.DEV_MOCK_JMAP; @@ -128,6 +130,19 @@ describe('config API route', () => { const config = await getConfig(); + expect(config.rememberMeEnabled).toBe(true); + }); + + it('should enable rememberMe when SESSION_SECRET_FILE is set', async () => { + writeFileSync('./session-secret', 'test-secret'); + process.env.SESSION_SECRET_FILE = './session-secret'; + + const config = await getConfig(); + + unlink('./session-secret', (err) => { + if (err) throw err; + }); + expect(config.rememberMeEnabled).toBe(true); }); @@ -138,6 +153,23 @@ describe('config API route', () => { process.env.SESSION_SECRET = 'test-secret'; const config2 = await getConfig(); + expect(config2.settingsSyncEnabled).toBe(true); + }); + + it('should enable settingsSync only when both SESSION_SECRET_FILE and SETTINGS_SYNC_ENABLED are set', async () => { + process.env.SETTINGS_SYNC_ENABLED = 'true'; + const config1 = await getConfig(); + expect(config1.settingsSyncEnabled).toBe(false); + + writeFileSync('./session-secret', 'test-secret'); + process.env.SESSION_SECRET_FILE = './session-secret'; + + const config2 = await getConfig(); + + unlink('./session-secret', (err) => { + if (err) throw err; + }); + expect(config2.settingsSyncEnabled).toBe(true); }); diff --git a/lib/admin/session.ts b/lib/admin/session.ts index 3d91369f..ecde855e 100644 --- a/lib/admin/session.ts +++ b/lib/admin/session.ts @@ -1,6 +1,7 @@ import { cookies } from 'next/headers'; import { NextResponse } from 'next/server'; import { createCipheriv, createDecipheriv, randomBytes, createHash } from 'node:crypto'; +import { readFileEnv } from '@/lib/read-file-env'; import { ADMIN_SESSION_COOKIE, DEFAULT_ADMIN_SESSION_TTL } from './types'; import type { AdminSessionPayload } from './types'; @@ -11,7 +12,7 @@ const TAG_LENGTH = 16; const MIN_SECRET_LENGTH = 32; function getKey(): Buffer { - const secret = process.env.SESSION_SECRET; + const secret = process.env.SESSION_SECRET || readFileEnv(process.env.SESSION_SECRET_FILE); if (!secret) throw new Error('SESSION_SECRET not configured'); if (secret.length < MIN_SECRET_LENGTH) { throw new Error( diff --git a/lib/auth/crypto.ts b/lib/auth/crypto.ts index ca0506c9..670bcc68 100644 --- a/lib/auth/crypto.ts +++ b/lib/auth/crypto.ts @@ -1,5 +1,6 @@ import { createCipheriv, createDecipheriv, randomBytes, createHash } from 'node:crypto'; import { logger } from '@/lib/logger'; +import { readFileEnv } from '@/lib/read-file-env'; const ALGORITHM = 'aes-256-gcm'; const IV_LENGTH = 12; @@ -8,7 +9,7 @@ const TAG_LENGTH = 16; const MIN_SECRET_LENGTH = 32; function getKey(): Buffer { - const secret = process.env.SESSION_SECRET; + const secret = process.env.SESSION_SECRET || readFileEnv(process.env.SESSION_SECRET_FILE); if (!secret) throw new Error('SESSION_SECRET not configured'); if (secret.length < MIN_SECRET_LENGTH) { throw new Error( diff --git a/lib/oauth/token-exchange.ts b/lib/oauth/token-exchange.ts index c1cbb443..abeb2348 100644 --- a/lib/oauth/token-exchange.ts +++ b/lib/oauth/token-exchange.ts @@ -1,8 +1,9 @@ import { logger } from '@/lib/logger'; import { discoverOAuth } from '@/lib/oauth/discovery'; import type { OAuthMetadata } from '@/lib/oauth/discovery'; +import { readFileEnv } from '@/lib/read-file-env'; -const CLIENT_SECRET = process.env.OAUTH_CLIENT_SECRET || ''; +const CLIENT_SECRET = process.env.OAUTH_CLIENT_SECRET || readFileEnv(process.env.OAUTH_CLIENT_SECRET_FILE) || ''; export function getRequiredConfig() { const clientId = process.env.OAUTH_CLIENT_ID; diff --git a/lib/read-file-env.ts b/lib/read-file-env.ts new file mode 100644 index 00000000..72a8cc8c --- /dev/null +++ b/lib/read-file-env.ts @@ -0,0 +1,13 @@ +import { readFileSync } from "fs"; + +export function readFileEnv(path: string | undefined): string | null { + if (!path) { + return null; + } + + try { + return readFileSync(path, "utf-8").trim(); + } catch { + return null; + } +} diff --git a/lib/settings-sync.ts b/lib/settings-sync.ts index 47404e8f..297baa29 100644 --- a/lib/settings-sync.ts +++ b/lib/settings-sync.ts @@ -3,13 +3,14 @@ import { readFile, writeFile, unlink, mkdir, rename } from 'node:fs/promises'; import { existsSync } from 'node:fs'; import path from 'node:path'; import { logger } from '@/lib/logger'; +import { readFileEnv } from '@/lib/read-file-env'; const ALGORITHM = 'aes-256-gcm'; const IV_LENGTH = 12; const TAG_LENGTH = 16; function getKey(): Buffer { - const secret = process.env.SESSION_SECRET; + const secret = process.env.SESSION_SECRET || readFileEnv(process.env.SESSION_SECRET_FILE); if (!secret) throw new Error('SESSION_SECRET not configured'); return createHash('sha256').update(secret).digest(); }