diff --git a/.env.example b/.env.example index 777871b6..9e714ad5 100644 --- a/.env.example +++ b/.env.example @@ -48,6 +48,8 @@ JMAP_SERVER_URL=https://your-jmap-server.com # OAuth client secret (server-side only, never exposed to the browser) # OAUTH_CLIENT_SECRET=your-client-secret +# Alternatively, you can specify the path to a file containing the OAuth client secret. +# OAUTH_CLIENT_SECRET_FILE=/oauth-client-secret # OpenID Connect issuer URL for discovery # OAUTH_ISSUER_URL=https://your-idp.example.com @@ -60,6 +62,8 @@ JMAP_SERVER_URL=https://your-jmap-server.com # Required for both "Remember me" and settings sync features. # 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. +# SESSION_SECRET_FILE=/session-secret # ============================================================================= # Settings Sync diff --git a/README.md b/README.md index 3778ab25..ac4b2966 100644 --- a/README.md +++ b/README.md @@ -271,6 +271,7 @@ PORT=3000 # Default listen port OAUTH_ENABLED=true OAUTH_CLIENT_ID=webmail OAUTH_CLIENT_SECRET= # optional, for confidential clients +OAUTH_CLIENT_SECRET_FILE= # Path to a file containing the client secret OAUTH_ISSUER_URL= # optional, for external IdPs (Keycloak, Authentik) ``` @@ -282,7 +283,8 @@ Endpoints are auto-discovered via `.well-known/oauth-authorization-server` or `. Remember Me ```env -SESSION_SECRET=your-secret-key # Generate with: openssl rand -base64 32 +SESSION_SECRET=your-secret-key # Generate with: openssl rand -base64 32 +SESSION_SECRET_FILE=/session-secret # Path to a file containing the session secret ``` Credentials encrypted with AES-256-GCM, stored in an httpOnly cookie (30-day expiry). 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(); }