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 b57782e1..119aecce 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