Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion src/app/api/settings/auth-credentials/route.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)) {
Expand All @@ -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({
Expand Down
64 changes: 64 additions & 0 deletions src/lib/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> = {};

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
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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();
}

/**
Expand All @@ -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();
}

/**
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down