From aba04e7425386049797835ad54fc4913402ac58d Mon Sep 17 00:00:00 2001 From: Vivelis <¨maceo.jalbert@gmail.com¨> Date: Fri, 12 Jun 2026 23:25:42 +0200 Subject: [PATCH 1/2] feat: add JWT expiry validation and refactor magic numbers - Decode JWT in WebSocket auth to reject expired tokens and log auth failures. - Introduce constants `OUTPUT_BUFFER_MAX_LENGTH`, `BACKUP_UPDATE_DELAY_MS`, and `JWT_EXP_MS_FACTOR`. - Replace hardcoded buffer length and delay values with the new constants. - Coerce `AUTH_ENABLED` and `AUTH_SETUP_COMPLETED` to boolean and add `AUTH_SESSION_DURATION_DAYS` env. --- server.js | 44 ++++++++++++++++------- src/env.js | 6 ++-- src/lib/auth.ts | 93 +++++++++++++------------------------------------ 3 files changed, 61 insertions(+), 82 deletions(-) diff --git a/server.js b/server.js index 0aec7440..24369a8b 100644 --- a/server.js +++ b/server.js @@ -9,7 +9,7 @@ import stripAnsi from 'strip-ansi'; import { spawn as ptySpawn } from 'node-pty'; import { getSSHExecutionService } from './src/server/ssh-execution-service.js'; import { getDatabase } from './src/server/database-prisma.js'; -import { getAuthConfig, verifyToken } from './src/lib/auth.js'; +import { getAuthConfig, verifyToken, decodeToken } from './src/lib/auth.js'; import dotenv from 'dotenv'; // Dynamic import for auto sync init to avoid tsx caching issues @@ -31,6 +31,13 @@ function registerGlobalErrorHandlers() { } registerGlobalErrorHandlers._registered = false; +// JWT `exp` claim is a UNIX timestamp in seconds; Date.now() is milliseconds. +const JWT_EXP_MS_FACTOR = 1000; +// Maximum number of characters retained in the output buffer per execution. +const OUTPUT_BUFFER_MAX_LENGTH = 1000; +// Delay (ms) between backup completion and update start. +const BACKUP_UPDATE_DELAY_MS = 1000; + const dev = process.env.NODE_ENV !== 'production'; const hostname = '::'; const port = parseInt(process.env.PORT || '3000', 10); @@ -76,10 +83,23 @@ function isWebSocketUpgradeAuthorized(request) { const token = getCookieValue(request, 'auth-token'); if (!token) { + console.warn('ws_auth_rejected: missing auth-token cookie'); + return false; + } + + const decoded = decodeToken(token); + if (decoded && typeof decoded.exp === 'number' && decoded.exp * JWT_EXP_MS_FACTOR < Date.now()) { + console.warn('ws_auth_rejected: token expired'); + return false; + } + + const verified = verifyToken(token); + if (!verified) { + console.warn('ws_auth_rejected: token verification failed'); return false; } - return !!verifyToken(token); + return true; } // WebSocket handler for script execution @@ -530,9 +550,9 @@ class ScriptExecutionHandler { const execution = this.activeExecutions.get(executionId); if (execution) { execution.outputBuffer += output; - // Keep only last 1000 characters to avoid memory issues - if (execution.outputBuffer.length > 1000) { - execution.outputBuffer = execution.outputBuffer.slice(-1000); + // Trim buffer to maximum allowed length to avoid memory issues + if (execution.outputBuffer.length > OUTPUT_BUFFER_MAX_LENGTH) { + execution.outputBuffer = execution.outputBuffer.slice(-OUTPUT_BUFFER_MAX_LENGTH); } } @@ -791,9 +811,9 @@ class ScriptExecutionHandler { const exec = this.activeExecutions.get(executionId); if (exec) { exec.outputBuffer += data; - // Keep only last 1000 characters to avoid memory issues - if (exec.outputBuffer.length > 1000) { - exec.outputBuffer = exec.outputBuffer.slice(-1000); + // Trim buffer to maximum allowed length to avoid memory issues + if (exec.outputBuffer.length > OUTPUT_BUFFER_MAX_LENGTH) { + exec.outputBuffer = exec.outputBuffer.slice(-OUTPUT_BUFFER_MAX_LENGTH); } } @@ -827,9 +847,9 @@ class ScriptExecutionHandler { const exec = this.activeExecutions.get(executionId); if (exec) { exec.outputBuffer += error; - // Keep only last 1000 characters to avoid memory issues - if (exec.outputBuffer.length > 1000) { - exec.outputBuffer = exec.outputBuffer.slice(-1000); + // Trim buffer to maximum allowed length to avoid memory issues + if (exec.outputBuffer.length > OUTPUT_BUFFER_MAX_LENGTH) { + exec.outputBuffer = exec.outputBuffer.slice(-OUTPUT_BUFFER_MAX_LENGTH); } } @@ -1564,7 +1584,7 @@ class ScriptExecutionHandler { } // Small delay before starting update - await new Promise(resolve => setTimeout(resolve, 1000)); + await new Promise(resolve => setTimeout(resolve, BACKUP_UPDATE_DELAY_MS)); } // Send start message for update (only if we're actually starting an update) diff --git a/src/env.js b/src/env.js index 34b24531..386753b0 100644 --- a/src/env.js +++ b/src/env.js @@ -32,8 +32,9 @@ export const env = createEnv({ // Authentication Configuration AUTH_USERNAME: z.string().optional(), AUTH_PASSWORD_HASH: z.string().optional(), - AUTH_ENABLED: z.string().optional(), - AUTH_SETUP_COMPLETED: z.string().optional(), + AUTH_ENABLED: z.coerce.boolean().optional(), + AUTH_SETUP_COMPLETED: z.coerce.boolean().optional(), + AUTH_SESSION_DURATION_DAYS: z.coerce.number().int().positive().optional(), JWT_SECRET: z.string().optional(), // Server Color Coding Configuration SERVER_COLOR_CODING_ENABLED: z.string().optional(), @@ -78,6 +79,7 @@ export const env = createEnv({ AUTH_PASSWORD_HASH: process.env.AUTH_PASSWORD_HASH, AUTH_ENABLED: process.env.AUTH_ENABLED, AUTH_SETUP_COMPLETED: process.env.AUTH_SETUP_COMPLETED, + AUTH_SESSION_DURATION_DAYS: process.env.AUTH_SESSION_DURATION_DAYS, JWT_SECRET: process.env.JWT_SECRET, // Server Color Coding Configuration SERVER_COLOR_CODING_ENABLED: process.env.SERVER_COLOR_CODING_ENABLED, diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 99f4124c..514a1fe0 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -11,41 +11,33 @@ const DEFAULT_JWT_EXPIRY_DAYS = 7; // Default 7 days let jwtSecretCache: string | null = null; /** - * Get or generate JWT secret + * Get or generate JWT secret. + * Reads from process.env (loaded by dotenv). Auto-generates and persists + * a new secret only when none is present in the environment. */ export function getJwtSecret(): string { - // Return cached secret if available if (jwtSecretCache) { return jwtSecretCache; } + const envSecret = process.env.JWT_SECRET?.trim(); + if (envSecret) { + jwtSecretCache = envSecret; + return jwtSecretCache; + } + + // No secret in process.env — generate and persist a new one + const newSecret = randomBytes(64).toString('hex'); + const envPath = path.join(process.cwd(), '.env'); - - // Read existing .env file let envContent = ''; if (fs.existsSync(envPath)) { envContent = fs.readFileSync(envPath, 'utf8'); } - - // Check if JWT_SECRET already exists - const jwtSecretRegex = /^JWT_SECRET=(.*)$/m; - const jwtSecretMatch = jwtSecretRegex.exec(envContent); - - if (jwtSecretMatch?.[1]?.trim()) { - jwtSecretCache = jwtSecretMatch[1].trim(); - return jwtSecretCache; - } - - // Generate new secret - const newSecret = randomBytes(64).toString('hex'); - - // Add to .env file envContent += (envContent.endsWith('\n') ? '' : '\n') + `JWT_SECRET=${newSecret}\n`; fs.writeFileSync(envPath, envContent); - - // Cache the new secret + jwtSecretCache = newSecret; - return newSecret; } @@ -98,7 +90,9 @@ export function verifyToken(token: string): { username: string; exp?: number; ia } /** - * Read auth configuration from .env + * Read auth configuration from process.env (loaded by dotenv). + * Typed schema is defined in ~/env.js; here we read the raw env values + * because server.js imports this module before dotenv.config() runs. */ export function getAuthConfig(): { username: string | null; @@ -108,53 +102,17 @@ export function getAuthConfig(): { setupCompleted: boolean; sessionDurationDays: number; } { - const envPath = path.join(process.cwd(), '.env'); - - if (!fs.existsSync(envPath)) { - return { - username: null, - passwordHash: null, - enabled: false, - hasCredentials: false, - setupCompleted: false, - sessionDurationDays: DEFAULT_JWT_EXPIRY_DAYS, - }; - } - - const envContent = fs.readFileSync(envPath, 'utf8'); - - // Extract AUTH_USERNAME - const usernameRegex = /^AUTH_USERNAME=(.*)$/m; - const usernameMatch = usernameRegex.exec(envContent); - const username = usernameMatch ? usernameMatch[1]?.trim() : null; - - // Extract AUTH_PASSWORD_HASH - const passwordHashRegex = /^AUTH_PASSWORD_HASH=(.*)$/m; - const passwordHashMatch = passwordHashRegex.exec(envContent); - const passwordHash = passwordHashMatch ? passwordHashMatch[1]?.trim() : null; - - // Extract AUTH_ENABLED - const enabledRegex = /^AUTH_ENABLED=(.*)$/m; - const enabledMatch = enabledRegex.exec(envContent); - const enabled = enabledMatch ? enabledMatch[1]?.trim().toLowerCase() === 'true' : false; - - // Extract AUTH_SETUP_COMPLETED - const setupCompletedRegex = /^AUTH_SETUP_COMPLETED=(.*)$/m; - const setupCompletedMatch = setupCompletedRegex.exec(envContent); - const setupCompleted = setupCompletedMatch ? setupCompletedMatch[1]?.trim().toLowerCase() === 'true' : false; - - // Extract AUTH_SESSION_DURATION_DAYS - const sessionDurationRegex = /^AUTH_SESSION_DURATION_DAYS=(.*)$/m; - const sessionDurationMatch = sessionDurationRegex.exec(envContent); - const sessionDurationDays = sessionDurationMatch - ? parseInt(sessionDurationMatch[1]?.trim() ?? String(DEFAULT_JWT_EXPIRY_DAYS), 10) || DEFAULT_JWT_EXPIRY_DAYS - : DEFAULT_JWT_EXPIRY_DAYS; - + const username = process.env.AUTH_USERNAME?.trim() || null; + const passwordHash = process.env.AUTH_PASSWORD_HASH?.trim() || null; + const enabled = (process.env.AUTH_ENABLED?.toLowerCase() ?? '') === 'true'; + const setupCompleted = (process.env.AUTH_SETUP_COMPLETED?.toLowerCase() ?? '') === 'true'; + const parsed = parseInt(process.env.AUTH_SESSION_DURATION_DAYS ?? '', 10); + const sessionDurationDays = Number.isNaN(parsed) ? DEFAULT_JWT_EXPIRY_DAYS : parsed; const hasCredentials = !!(username && passwordHash); - + return { - username: username ?? null, - passwordHash: passwordHash ?? null, + username, + passwordHash, enabled, hasCredentials, setupCompleted, @@ -287,4 +245,3 @@ export function updateSessionDuration(days: number): void { // Write back to .env file fs.writeFileSync(envPath, envContent); } - From cf71b0faac93af4042a649d5de89300388f332e4 Mon Sep 17 00:00:00 2001 From: Vivelis <¨maceo.jalbert@gmail.com¨> Date: Sun, 14 Jun 2026 15:16:11 +0200 Subject: [PATCH 2/2] feat(auth): sync process.env and mark setup completed after credentials update Add a `syncProcessEnvFromFile()` function that reads the .env file and updates process.env and the cached JWT secret, ensuring that in-memory state matches the persisted environment without requiring a server restart. Call this function after writing credentials or toggling authentication settings to keep the running server in sync. Also set the setup completed flag when credentials are updated, and clear it when disabling auth to force the fresh setup flow on re-enable. --- .../api/settings/auth-credentials/route.ts | 13 +++- src/lib/auth.ts | 64 +++++++++++++++++++ 2 files changed, 76 insertions(+), 1 deletion(-) diff --git a/src/app/api/settings/auth-credentials/route.ts b/src/app/api/settings/auth-credentials/route.ts index 35688473..0c6a56b2 100644 --- a/src/app/api/settings/auth-credentials/route.ts +++ b/src/app/api/settings/auth-credentials/route.ts @@ -1,6 +1,6 @@ import type { NextRequest } from 'next/server'; import { NextResponse } from 'next/server'; -import { getAuthConfig, updateAuthCredentials, updateAuthEnabled, updateSessionDuration } from '~/lib/auth'; +import { getAuthConfig, updateAuthCredentials, updateAuthEnabled, updateSessionDuration, setSetupCompleted, syncProcessEnvFromFile } from '~/lib/auth'; import fs from 'fs'; import path from 'path'; import { withApiLogging } from '../../../../server/logging/withApiLogging'; @@ -68,6 +68,9 @@ export const POST = withApiLogging(async function POST(request: NextRequest) { } await updateAuthCredentials(username, password, enabled ?? false); + // Mark setup as completed so requireApiAuth actually enforces credentials + // after a fresh install (mirrors the behavior of /api/auth/setup). + setSetupCompleted(); return NextResponse.json({ success: true, @@ -116,6 +119,10 @@ export const PATCH = withApiLogging(async function PATCH(request: NextRequest) { envContent = envContent.replace(/^AUTH_USERNAME=.*$/m, ''); envContent = envContent.replace(/^AUTH_PASSWORD_HASH=.*$/m, ''); + // Also clear AUTH_SETUP_COMPLETED so the next re-enable flow behaves + // like a fresh setup (requires /api/auth/setup to be run again). + envContent = envContent.replace(/^AUTH_SETUP_COMPLETED=.*$/m, ''); + // Update or add AUTH_ENABLED const enabledRegex = /^AUTH_ENABLED=.*$/m; if (enabledRegex.test(envContent)) { @@ -128,6 +135,10 @@ export const PATCH = withApiLogging(async function PATCH(request: NextRequest) { envContent = envContent.replace(/\n\n+/g, '\n'); fs.writeFileSync(envPath, envContent); + + // Refresh in-memory process.env so subsequent getAuthConfig() reads + // see the disabled state without a restart. + syncProcessEnvFromFile(); } return NextResponse.json({ diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 514a1fe0..99936aba 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -10,6 +10,51 @@ const DEFAULT_JWT_EXPIRY_DAYS = 7; // Default 7 days // Cache for JWT secret to avoid multiple file reads let jwtSecretCache: string | null = null; +/** + * Sync the in-memory process.env (and jwtSecretCache) with values just + * written to the .env file. Keys not in the file are left untouched. + * + * This is required because Next.js routes read from process.env at request + * time, and dotenv is only loaded once at server startup. Without this, + * writes via updateAuthCredentials/enabled/etc. are visible on disk but + * the running server keeps returning the pre-write values until restart. + */ +export function syncProcessEnvFromFile(): void { + const envPath = path.join(process.cwd(), '.env'); + if (!fs.existsSync(envPath)) return; + + const envContent = fs.readFileSync(envPath, 'utf8'); + const updates: Record = {}; + + for (const line of envContent.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + const eqIdx = trimmed.indexOf('='); + if (eqIdx === -1) continue; + const key = trimmed.slice(0, eqIdx).trim(); + let value = trimmed.slice(eqIdx + 1).trim(); + // Strip a single pair of surrounding double or single quotes + if ( + value.length >= 2 && + ((value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'"))) + ) { + value = value.slice(1, -1); + } + if (key) updates[key] = value; + } + + for (const [key, value] of Object.entries(updates)) { + process.env[key] = value; + } + + // If JWT_SECRET changed on disk, force a re-read on the next getJwtSecret() + // call so verify/generate pick up the new value without a restart. + if ('JWT_SECRET' in updates) { + jwtSecretCache = null; + } +} + /** * Get or generate JWT secret. * Reads from process.env (loaded by dotenv). Auto-generates and persists @@ -37,6 +82,9 @@ export function getJwtSecret(): string { envContent += (envContent.endsWith('\n') ? '' : '\n') + `JWT_SECRET=${newSecret}\n`; fs.writeFileSync(envPath, envContent); + // Keep the running process in sync with the value we just persisted so + // subsequent verifications/issuances use it without a restart. + process.env.JWT_SECRET = newSecret; jwtSecretCache = newSecret; return newSecret; } @@ -169,6 +217,10 @@ export async function updateAuthCredentials( // Write back to .env file fs.writeFileSync(envPath, envContent); + + // Refresh in-memory process.env (and jwtSecretCache) from disk so the + // running server reflects the new credentials without a restart. + syncProcessEnvFromFile(); } /** @@ -193,6 +245,10 @@ export function setSetupCompleted(): void { // Write back to .env file fs.writeFileSync(envPath, envContent); + + // Refresh in-memory process.env so subsequent getAuthConfig() reads see + // the updated setupCompleted flag without a restart. + syncProcessEnvFromFile(); } /** @@ -213,6 +269,10 @@ export function updateAuthEnabled(enabled: boolean): void { envContent = envContent.replace(enabledRegex, `AUTH_ENABLED=${enabled}`); } else { envContent += (envContent.endsWith('\n') ? '' : '\n') + `AUTH_ENABLED=${enabled}\n`; + + // Refresh in-memory process.env so subsequent getAuthConfig() reads see + // the updated AUTH_ENABLED flag without a restart. + syncProcessEnvFromFile(); } // Write back to .env file @@ -240,6 +300,10 @@ export function updateSessionDuration(days: number): void { envContent = envContent.replace(sessionDurationRegex, `AUTH_SESSION_DURATION_DAYS=${validDays}`); } else { envContent += (envContent.endsWith('\n') ? '' : '\n') + `AUTH_SESSION_DURATION_DAYS=${validDays}\n`; + + // Refresh in-memory process.env so subsequent getAuthConfig() reads see + // the updated session duration without a restart. + syncProcessEnvFromFile(); } // Write back to .env file