feat: add SESSION_SECRET_FILE and OAUTH_CLIENT_SECRET_FILE env vars

This commit is contained in:
Pascal Dietrich
2026-04-09 22:13:08 +02:00
committed by Linus Rath
parent 9c5daa3918
commit f9052eb23f
10 changed files with 62 additions and 9 deletions
+2 -1
View File
@@ -7,13 +7,14 @@ import { getRequiredConfig } from '@/lib/oauth/token-exchange';
import { discoverOAuth } from '@/lib/oauth/discovery'; import { discoverOAuth } from '@/lib/oauth/discovery';
import { OAUTH_SCOPES } from '@/lib/oauth/tokens'; import { OAUTH_SCOPES } from '@/lib/oauth/tokens';
import { getCookieOptions } from '@/lib/oauth/cookie-config'; import { getCookieOptions } from '@/lib/oauth/cookie-config';
import { readFileEnv } from '@/lib/read-file-env';
const SSO_PENDING_COOKIE = 'sso_pending'; const SSO_PENDING_COOKIE = 'sso_pending';
const SSO_PENDING_MAX_AGE = 300; // 5 minutes const SSO_PENDING_MAX_AGE = 300; // 5 minutes
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { 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 }); return NextResponse.json({ error: 'SESSION_SECRET is required for SSO' }, { status: 500 });
} }
+2 -1
View File
@@ -4,6 +4,7 @@ import { logger } from '@/lib/logger';
import { discoverOAuth } from '@/lib/oauth/discovery'; import { discoverOAuth } from '@/lib/oauth/discovery';
import { refreshTokenCookieName } from '@/lib/oauth/tokens'; import { refreshTokenCookieName } from '@/lib/oauth/tokens';
import { getCookieOptions } from '@/lib/oauth/cookie-config'; import { getCookieOptions } from '@/lib/oauth/cookie-config';
import { readFileEnv } from '@/lib/read-file-env';
/** /**
* Exchange basic auth credentials (with TOTP appended) for OAuth tokens. * 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 }); logger.info('TOTP token exchange: found token endpoint', { tokenEndpoint });
const clientId = process.env.OAUTH_CLIENT_ID; 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 basicAuth = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
const attempts: Array<{ strategy: string; error: string }> = []; const attempts: Array<{ strategy: string; error: string }> = [];
+3 -2
View File
@@ -1,6 +1,7 @@
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { logger } from '@/lib/logger'; import { logger } from '@/lib/logger';
import { configManager } from '@/lib/admin/config-manager'; import { configManager } from '@/lib/admin/config-manager';
import { readFileEnv } from '@/lib/read-file-env';
/** /**
* Runtime configuration endpoint * Runtime configuration endpoint
@@ -33,8 +34,8 @@ export async function GET() {
oauthOnly, oauthOnly,
oauthClientId: configManager.get<string>('oauthClientId', ''), oauthClientId: configManager.get<string>('oauthClientId', ''),
oauthIssuerUrl: configManager.get<string>('oauthIssuerUrl', ''), oauthIssuerUrl: configManager.get<string>('oauthIssuerUrl', ''),
rememberMeEnabled: !!process.env.SESSION_SECRET, rememberMeEnabled: !!process.env.SESSION_SECRET || !!readFileEnv(process.env.SESSION_SECRET_FILE),
settingsSyncEnabled: configManager.get<boolean>('settingsSyncEnabled', false) && !!process.env.SESSION_SECRET, settingsSyncEnabled: configManager.get<boolean>('settingsSyncEnabled', false) && (!!process.env.SESSION_SECRET || !!readFileEnv(process.env.SESSION_SECRET_FILE)),
stalwartFeaturesEnabled, stalwartFeaturesEnabled,
devMode: configManager.get<boolean>('devMode', false), devMode: configManager.get<boolean>('devMode', false),
faviconUrl: configManager.get<string>('faviconUrl', '/branding/Bulwark_Favicon.svg'), faviconUrl: configManager.get<string>('faviconUrl', '/branding/Bulwark_Favicon.svg'),
+2 -1
View File
@@ -6,6 +6,7 @@ import { sessionCookieName } from '@/lib/auth/session-cookie';
import { readStalwartAuthContextFromStore } from '@/lib/stalwart/auth-context'; import { readStalwartAuthContextFromStore } from '@/lib/stalwart/auth-context';
import { saveUserSettings, loadUserSettings, deleteUserSettings } from '@/lib/settings-sync'; import { saveUserSettings, loadUserSettings, deleteUserSettings } from '@/lib/settings-sync';
import { configManager } from '@/lib/admin/config-manager'; import { configManager } from '@/lib/admin/config-manager';
import { readFileEnv } from '@/lib/read-file-env';
function classifyError(error: unknown): { message: string; status: number } { function classifyError(error: unknown): { message: string; status: number } {
const code = (error as NodeJS.ErrnoException).code; const code = (error as NodeJS.ErrnoException).code;
@@ -48,7 +49,7 @@ function classifyError(error: unknown): { message: string; status: number } {
} }
function isEnabled(): boolean { 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. */ /** Strip trailing slashes so differently-formatted URLs still match. */
+32
View File
@@ -1,3 +1,4 @@
import { unlink, writeFileSync } from "fs";
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
// Mock NextResponse before importing the route // Mock NextResponse before importing the route
@@ -24,6 +25,7 @@ describe('config API route', () => {
delete process.env.OAUTH_CLIENT_ID; delete process.env.OAUTH_CLIENT_ID;
delete process.env.OAUTH_ISSUER_URL; delete process.env.OAUTH_ISSUER_URL;
delete process.env.SESSION_SECRET; delete process.env.SESSION_SECRET;
delete process.env.SESSION_SECRET_FILE;
delete process.env.SETTINGS_SYNC_ENABLED; delete process.env.SETTINGS_SYNC_ENABLED;
delete process.env.STALWART_FEATURES; delete process.env.STALWART_FEATURES;
delete process.env.DEV_MOCK_JMAP; delete process.env.DEV_MOCK_JMAP;
@@ -131,6 +133,19 @@ describe('config API route', () => {
expect(config.rememberMeEnabled).toBe(true); 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);
});
it('should enable settingsSync only when both SESSION_SECRET and SETTINGS_SYNC_ENABLED are set', async () => { it('should enable settingsSync only when both SESSION_SECRET and SETTINGS_SYNC_ENABLED are set', async () => {
process.env.SETTINGS_SYNC_ENABLED = 'true'; process.env.SETTINGS_SYNC_ENABLED = 'true';
const config1 = await getConfig(); const config1 = await getConfig();
@@ -141,6 +156,23 @@ describe('config API route', () => {
expect(config2.settingsSyncEnabled).toBe(true); 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);
});
it('should disable stalwart features when explicitly set to false', async () => { it('should disable stalwart features when explicitly set to false', async () => {
process.env.STALWART_FEATURES = 'false'; process.env.STALWART_FEATURES = 'false';
+2 -1
View File
@@ -1,6 +1,7 @@
import { cookies } from 'next/headers'; import { cookies } from 'next/headers';
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { createCipheriv, createDecipheriv, randomBytes, createHash } from 'node:crypto'; 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 { ADMIN_SESSION_COOKIE, DEFAULT_ADMIN_SESSION_TTL } from './types';
import type { AdminSessionPayload } from './types'; import type { AdminSessionPayload } from './types';
@@ -11,7 +12,7 @@ const TAG_LENGTH = 16;
const MIN_SECRET_LENGTH = 32; const MIN_SECRET_LENGTH = 32;
function getKey(): Buffer { 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) throw new Error('SESSION_SECRET not configured');
if (secret.length < MIN_SECRET_LENGTH) { if (secret.length < MIN_SECRET_LENGTH) {
throw new Error( throw new Error(
+2 -1
View File
@@ -1,5 +1,6 @@
import { createCipheriv, createDecipheriv, randomBytes, createHash } from 'node:crypto'; import { createCipheriv, createDecipheriv, randomBytes, createHash } from 'node:crypto';
import { logger } from '@/lib/logger'; import { logger } from '@/lib/logger';
import { readFileEnv } from '@/lib/read-file-env';
const ALGORITHM = 'aes-256-gcm'; const ALGORITHM = 'aes-256-gcm';
const IV_LENGTH = 12; const IV_LENGTH = 12;
@@ -8,7 +9,7 @@ const TAG_LENGTH = 16;
const MIN_SECRET_LENGTH = 32; const MIN_SECRET_LENGTH = 32;
function getKey(): Buffer { 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) throw new Error('SESSION_SECRET not configured');
if (secret.length < MIN_SECRET_LENGTH) { if (secret.length < MIN_SECRET_LENGTH) {
throw new Error( throw new Error(
+2 -1
View File
@@ -1,8 +1,9 @@
import { logger } from '@/lib/logger'; import { logger } from '@/lib/logger';
import { discoverOAuth } from '@/lib/oauth/discovery'; import { discoverOAuth } from '@/lib/oauth/discovery';
import type { OAuthMetadata } 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() { export function getRequiredConfig() {
const clientId = process.env.OAUTH_CLIENT_ID; const clientId = process.env.OAUTH_CLIENT_ID;
+13
View File
@@ -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;
}
}
+2 -1
View File
@@ -3,13 +3,14 @@ import { readFile, writeFile, unlink, mkdir, rename } from 'node:fs/promises';
import { existsSync } from 'node:fs'; import { existsSync } from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import { logger } from '@/lib/logger'; import { logger } from '@/lib/logger';
import { readFileEnv } from '@/lib/read-file-env';
const ALGORITHM = 'aes-256-gcm'; const ALGORITHM = 'aes-256-gcm';
const IV_LENGTH = 12; const IV_LENGTH = 12;
const TAG_LENGTH = 16; const TAG_LENGTH = 16;
function getKey(): Buffer { 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) throw new Error('SESSION_SECRET not configured');
return createHash('sha256').update(secret).digest(); return createHash('sha256').update(secret).digest();
} }