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();
}