Plan OIDC integration and audit

This commit is contained in:
Zimeng Xiong
2026-02-10 14:45:34 -08:00
parent bb028ef2db
commit 1c71a08bbe
26 changed files with 1338 additions and 135 deletions
+2 -1
View File
@@ -3,7 +3,8 @@ PORT=8000
NODE_ENV=production
DATABASE_URL=file:/app/prisma/dev.db
FRONTEND_URL=http://localhost:6767
TRUST_PROXY=1
# Keep disabled unless traffic always comes through a trusted reverse proxy.
TRUST_PROXY=false
JWT_SECRET=change-this-secret-in-production-min-32-chars
# Optional Feature Flags (all default to false for backward compatibility)
+3
View File
@@ -7,6 +7,9 @@
"predev": "node scripts/predev-migrate.cjs",
"dev": "nodemon src/index.ts",
"admin:recover": "node scripts/admin-recover.cjs",
"dev:simulate-auth-onboarding:fresh": "node scripts/simulate-auth-onboarding.cjs --scenario fresh",
"dev:simulate-auth-onboarding:migration": "node scripts/simulate-auth-onboarding.cjs --scenario migration",
"dev:simulate-auth-onboarding:dry-run": "node scripts/simulate-auth-onboarding.cjs --scenario migration --dry-run",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage"
@@ -0,0 +1,2 @@
-- Track whether initial auth mode choice has been explicitly completed.
ALTER TABLE "SystemConfig" ADD COLUMN "authOnboardingCompleted" BOOLEAN NOT NULL DEFAULT false;
+1
View File
@@ -33,6 +33,7 @@ model User {
model SystemConfig {
id String @id @default("default")
authEnabled Boolean @default(false)
authOnboardingCompleted Boolean @default(false)
registrationEnabled Boolean @default(false)
authLoginRateLimitEnabled Boolean @default(true)
authLoginRateLimitWindowMs Int @default(900000) // 15 minutes
+330
View File
@@ -0,0 +1,330 @@
#!/usr/bin/env node
require("dotenv").config();
const path = require("path");
const { execSync } = require("child_process");
const { PrismaClient } = require("../src/generated/client");
const BOOTSTRAP_USER_ID = "bootstrap-admin";
const DEFAULT_SYSTEM_CONFIG_ID = "default";
const backendRoot = path.resolve(__dirname, "..");
const resolveDatabaseUrl = (rawUrl) => {
const backendRoot = path.resolve(__dirname, "..");
const defaultDbPath = path.resolve(backendRoot, "prisma/dev.db");
if (!rawUrl || String(rawUrl).trim().length === 0) {
return `file:${defaultDbPath}`;
}
if (!String(rawUrl).startsWith("file:")) {
return String(rawUrl);
}
const filePath = String(rawUrl).replace(/^file:/, "");
const prismaDir = path.resolve(backendRoot, "prisma");
const normalizedRelative = filePath.replace(/^\.\/?/, "");
const hasLeadingPrismaDir =
normalizedRelative === "prisma" || normalizedRelative.startsWith("prisma/");
const absolutePath = path.isAbsolute(filePath)
? filePath
: path.resolve(hasLeadingPrismaDir ? backendRoot : prismaDir, normalizedRelative);
return `file:${absolutePath}`;
};
process.env.DATABASE_URL = resolveDatabaseUrl(process.env.DATABASE_URL);
const parseArgs = (argv) => {
const parsed = {
scenario: "",
dryRun: false,
allowProd: false,
};
for (let i = 0; i < argv.length; i += 1) {
const token = argv[i];
if (token === "--scenario") {
parsed.scenario = String(argv[i + 1] || "").trim().toLowerCase();
i += 1;
continue;
}
if (token === "--dry-run") {
parsed.dryRun = true;
continue;
}
if (token === "--allow-production") {
parsed.allowProd = true;
continue;
}
if (token === "--help" || token === "-h") {
parsed.help = true;
continue;
}
}
return parsed;
};
const usage = () => {
console.log(`Usage:
node scripts/simulate-auth-onboarding.cjs --scenario fresh
node scripts/simulate-auth-onboarding.cjs --scenario migration
Options:
--dry-run Show what would change without modifying data
--allow-production Override production safety check (not recommended)
--help, -h Show this help
`);
};
const assertScenario = (scenario) => {
if (scenario !== "fresh" && scenario !== "migration") {
throw new Error("Invalid --scenario. Use 'fresh' or 'migration'.");
}
};
const nowIso = () => new Date().toISOString();
const run = async () => {
const args = parseArgs(process.argv.slice(2));
if (args.help) {
usage();
return;
}
assertScenario(args.scenario);
const nodeEnv = process.env.NODE_ENV || "development";
if (nodeEnv === "production" && !args.allowProd) {
throw new Error(
"Refusing to run in production. Pass --allow-production only if you explicitly intend this."
);
}
// Keep migration history authoritative to avoid drift between db push and deploy.
// Includes a self-heal path for the known duplicate-column failure on
// 20260210153000_add_auth_onboarding_completed in local dev databases.
if (nodeEnv !== "production") {
const runDeploy = () =>
execSync("npx prisma migrate deploy", {
cwd: backendRoot,
stdio: "pipe",
env: {
...process.env,
DATABASE_URL: process.env.DATABASE_URL,
},
});
try {
runDeploy();
} catch (error) {
const stdout =
error && error.stdout
? Buffer.isBuffer(error.stdout)
? error.stdout.toString("utf8")
: String(error.stdout)
: "";
const stderr =
error && error.stderr
? Buffer.isBuffer(error.stderr)
? error.stderr.toString("utf8")
: String(error.stderr)
: "";
const combined = `${stdout}\n${stderr}`;
const canAutoResolve =
combined.includes("Error: P3009") &&
combined.includes("20260210153000_add_auth_onboarding_completed") &&
combined.includes("duplicate column name: authOnboardingCompleted");
if (!canAutoResolve) {
throw error;
}
execSync(
"npx prisma migrate resolve --applied 20260210153000_add_auth_onboarding_completed",
{
cwd: backendRoot,
stdio: "pipe",
env: {
...process.env,
DATABASE_URL: process.env.DATABASE_URL,
},
}
);
runDeploy();
}
}
const prisma = new PrismaClient();
try {
const before = {
activeUsers: await prisma.user.count({ where: { isActive: true } }),
users: await prisma.user.count(),
drawings: await prisma.drawing.count(),
collections: await prisma.collection.count(),
auth: await prisma.systemConfig.findUnique({
where: { id: DEFAULT_SYSTEM_CONFIG_ID },
select: {
authEnabled: true,
authOnboardingCompleted: true,
registrationEnabled: true,
},
}),
};
console.log(`[simulate-auth-onboarding] DATABASE_URL=${process.env.DATABASE_URL}`);
console.log(`[simulate-auth-onboarding] NODE_ENV=${nodeEnv}`);
console.log(`[simulate-auth-onboarding] scenario=${args.scenario}`);
console.log("[simulate-auth-onboarding] before:", before);
if (args.dryRun) {
console.log("[simulate-auth-onboarding] dry-run only. No data changed.");
return;
}
await prisma.$transaction(async (tx) => {
await tx.systemConfig.upsert({
where: { id: DEFAULT_SYSTEM_CONFIG_ID },
update: {
authEnabled: false,
authOnboardingCompleted: false,
registrationEnabled: false,
},
create: {
id: DEFAULT_SYSTEM_CONFIG_ID,
authEnabled: false,
authOnboardingCompleted: false,
registrationEnabled: false,
authLoginRateLimitEnabled: true,
authLoginRateLimitWindowMs: 15 * 60 * 1000,
authLoginRateLimitMax: 20,
},
});
await tx.user.updateMany({
data: {
isActive: false,
mustResetPassword: true,
},
});
await tx.user.upsert({
where: { id: BOOTSTRAP_USER_ID },
update: {
email: "bootstrap@excalidash.local",
username: null,
passwordHash: "",
name: "Bootstrap Admin",
role: "ADMIN",
mustResetPassword: true,
isActive: false,
},
create: {
id: BOOTSTRAP_USER_ID,
email: "bootstrap@excalidash.local",
username: null,
passwordHash: "",
name: "Bootstrap Admin",
role: "ADMIN",
mustResetPassword: true,
isActive: false,
},
});
if (args.scenario === "fresh") {
await tx.drawing.deleteMany({});
await tx.collection.deleteMany({});
await tx.library.deleteMany({});
await tx.user.deleteMany({
where: {
id: {
not: BOOTSTRAP_USER_ID,
},
},
});
return;
}
// Migration simulation:
// 1) Reassign existing data ownership to bootstrap user
// 2) Ensure at least one drawing+collection exists so UI shows migration messaging
await tx.collection.updateMany({
data: { userId: BOOTSTRAP_USER_ID },
});
await tx.drawing.updateMany({
data: { userId: BOOTSTRAP_USER_ID },
});
const collectionCount = await tx.collection.count();
let targetCollectionId = null;
if (collectionCount === 0) {
targetCollectionId = `sim-migration-col-${Date.now()}`;
await tx.collection.create({
data: {
id: targetCollectionId,
name: "Migrated Collection",
userId: BOOTSTRAP_USER_ID,
},
});
} else {
const existing = await tx.collection.findFirst({
where: { userId: BOOTSTRAP_USER_ID },
select: { id: true },
orderBy: { createdAt: "asc" },
});
targetCollectionId = existing ? existing.id : null;
}
const drawingCount = await tx.drawing.count();
if (drawingCount === 0) {
await tx.drawing.create({
data: {
id: `sim-migration-draw-${Date.now()}`,
name: "Migrated Drawing",
elements: "[]",
appState: "{}",
files: "{}",
preview: null,
version: 1,
userId: BOOTSTRAP_USER_ID,
collectionId: targetCollectionId,
},
});
}
});
const after = {
activeUsers: await prisma.user.count({ where: { isActive: true } }),
users: await prisma.user.count(),
drawings: await prisma.drawing.count(),
collections: await prisma.collection.count(),
auth: await prisma.systemConfig.findUnique({
where: { id: DEFAULT_SYSTEM_CONFIG_ID },
select: {
authEnabled: true,
authOnboardingCompleted: true,
registrationEnabled: true,
},
}),
};
console.log("[simulate-auth-onboarding] after:", after);
console.log(`[simulate-auth-onboarding] completed at ${nowIso()}`);
console.log(
"[simulate-auth-onboarding] If your backend is already running, wait ~5 seconds (auth cache TTL) or restart before refreshing the UI."
);
} finally {
await prisma.$disconnect().catch(() => {});
}
};
run().catch((error) => {
console.error("simulate-auth-onboarding failed:", error.message || error);
process.exitCode = 1;
});
@@ -0,0 +1,164 @@
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import request from "supertest";
import { PrismaClient } from "../generated/client";
import { getTestPrisma, setupTestDb } from "./testUtils";
import { BOOTSTRAP_USER_ID } from "../auth/authMode";
describe("Auth onboarding decision", () => {
const userAgent = "vitest-auth-onboarding";
let prisma: PrismaClient;
let app: any;
let agent: any;
let csrfHeaderName: string;
let csrfToken: string;
beforeAll(async () => {
setupTestDb();
prisma = getTestPrisma();
({ app } = await import("../index"));
agent = request.agent(app);
const csrfRes = await agent.get("/csrf-token").set("User-Agent", userAgent);
csrfHeaderName = csrfRes.body.header;
csrfToken = csrfRes.body.token;
});
afterAll(async () => {
await prisma.$disconnect();
});
it("reports migration onboarding mode when no active users and legacy data exists", async () => {
await prisma.user.upsert({
where: { id: BOOTSTRAP_USER_ID },
update: {},
create: {
id: BOOTSTRAP_USER_ID,
email: "bootstrap@excalidash.local",
username: null,
passwordHash: "",
name: "Bootstrap Admin",
role: "ADMIN",
mustResetPassword: true,
isActive: false,
},
});
await prisma.systemConfig.upsert({
where: { id: "default" },
update: { authEnabled: false, authOnboardingCompleted: false },
create: {
id: "default",
authEnabled: false,
authOnboardingCompleted: false,
registrationEnabled: false,
},
});
await prisma.collection.upsert({
where: { id: "legacy-collection" },
update: {},
create: {
id: "legacy-collection",
name: "Legacy",
userId: BOOTSTRAP_USER_ID,
},
});
await prisma.drawing.upsert({
where: { id: "legacy-drawing" },
update: {},
create: {
id: "legacy-drawing",
name: "Legacy Drawing",
elements: "[]",
appState: "{}",
files: "{}",
userId: BOOTSTRAP_USER_ID,
collectionId: "legacy-collection",
},
});
const response = await request(app).get("/auth/status").set("User-Agent", userAgent);
expect(response.status).toBe(200);
expect(response.body?.authEnabled).toBe(false);
expect(response.body?.authOnboardingRequired).toBe(true);
expect(response.body?.authOnboardingMode).toBe("migration");
});
it("persists a single-user onboarding choice", async () => {
await prisma.systemConfig.update({
where: { id: "default" },
data: { authEnabled: false, authOnboardingCompleted: false },
});
const choiceResponse = await agent
.post("/auth/onboarding-choice")
.set("User-Agent", userAgent)
.set(csrfHeaderName, csrfToken)
.send({ enableAuth: false });
expect(choiceResponse.status).toBe(200);
expect(choiceResponse.body?.authEnabled).toBe(false);
expect(choiceResponse.body?.authOnboardingCompleted).toBe(true);
const statusResponse = await request(app).get("/auth/status").set("User-Agent", userAgent);
expect(statusResponse.status).toBe(200);
expect(statusResponse.body?.authOnboardingRequired).toBe(false);
});
it("enables auth and bootstrap flow from onboarding choice", async () => {
await prisma.drawing.deleteMany({});
await prisma.collection.deleteMany({ where: { id: { not: `trash:${BOOTSTRAP_USER_ID}` } } });
await prisma.systemConfig.update({
where: { id: "default" },
data: { authEnabled: false, authOnboardingCompleted: false },
});
const choiceResponse = await agent
.post("/auth/onboarding-choice")
.set("User-Agent", userAgent)
.set(csrfHeaderName, csrfToken)
.send({ enableAuth: true });
expect(choiceResponse.status).toBe(200);
expect(choiceResponse.body?.authEnabled).toBe(true);
expect(choiceResponse.body?.bootstrapRequired).toBe(true);
expect(choiceResponse.body?.authOnboardingCompleted).toBe(true);
const statusResponse = await request(app).get("/auth/status").set("User-Agent", userAgent);
expect(statusResponse.status).toBe(200);
expect(statusResponse.body?.authEnabled).toBe(true);
expect(statusResponse.body?.bootstrapRequired).toBe(true);
expect(statusResponse.body?.authOnboardingRequired).toBe(false);
});
it("requires CSRF token for bootstrap registration", async () => {
const noCsrfResponse = await agent
.post("/auth/register")
.set("User-Agent", userAgent)
.send({
email: "bootstrap-admin@test.local",
password: "StrongPass1!",
name: "Bootstrap Admin",
});
expect(noCsrfResponse.status).toBe(403);
expect(noCsrfResponse.body?.error).toBe("CSRF token missing");
const bootstrapResponse = await agent
.post("/auth/register")
.set("User-Agent", userAgent)
.set(csrfHeaderName, csrfToken)
.send({
email: "bootstrap-admin@test.local",
password: "StrongPass1!",
name: "Bootstrap Admin",
});
expect(bootstrapResponse.status).toBe(201);
expect(bootstrapResponse.body?.bootstrapped).toBe(true);
expect(bootstrapResponse.body?.user?.email).toBe("bootstrap-admin@test.local");
});
});
+90 -4
View File
@@ -96,6 +96,48 @@ export const registerAdminRoutes = (deps: RegisterAdminRoutesDeps) => {
requireCsrf,
} = deps;
const resolveImpersonationAdmin = async (req: Request, res: Response) => {
if (!req.user) {
res.status(401).json({ error: "Unauthorized", message: "User not authenticated" });
return null;
}
if (req.user.role === "ADMIN") {
return {
id: req.user.id,
email: req.user.email,
name: req.user.name,
};
}
if (!req.user.impersonatorId) {
res.status(403).json({ error: "Forbidden", message: "Admin access required" });
return null;
}
const impersonator = await prisma.user.findUnique({
where: { id: req.user.impersonatorId },
select: {
id: true,
email: true,
name: true,
role: true,
isActive: true,
},
});
if (!impersonator || !impersonator.isActive || impersonator.role !== "ADMIN") {
res.status(403).json({ error: "Forbidden", message: "Admin access required" });
return null;
}
return {
id: impersonator.id,
email: impersonator.email,
name: impersonator.name,
};
};
router.post("/registration/toggle", requireAuth, async (req: Request, res: Response) => {
try {
if (!(await ensureAuthEnabled(res))) return;
@@ -210,6 +252,42 @@ export const registerAdminRoutes = (deps: RegisterAdminRoutesDeps) => {
}
});
router.get("/impersonation-targets", requireAuth, async (req: Request, res: Response) => {
try {
if (!(await ensureAuthEnabled(res))) return;
const actingAdmin = await resolveImpersonationAdmin(req, res);
if (!actingAdmin) return;
const users = await prisma.user.findMany({
where: { isActive: true, id: { not: actingAdmin.id } },
orderBy: [{ name: "asc" }, { email: "asc" }],
select: {
id: true,
username: true,
email: true,
name: true,
role: true,
isActive: true,
},
});
res.json({
users,
impersonator: {
id: actingAdmin.id,
email: actingAdmin.email,
name: actingAdmin.name,
},
});
} catch (error) {
console.error("List impersonation targets error:", error);
res.status(500).json({
error: "Internal server error",
message: "Failed to list impersonation targets",
});
}
});
router.get("/rate-limit/login", requireAuth, async (req: Request, res: Response) => {
try {
if (!(await ensureAuthEnabled(res))) return;
@@ -599,7 +677,8 @@ export const registerAdminRoutes = (deps: RegisterAdminRoutesDeps) => {
try {
if (!(await ensureAuthEnabled(res))) return;
if (!requireCsrf(req, res)) return;
if (!requireAdmin(req, res)) return;
const actingAdmin = await resolveImpersonationAdmin(req, res);
if (!actingAdmin) return;
const parsed = impersonateSchema.safeParse(req.body);
if (!parsed.success) {
@@ -615,12 +694,19 @@ export const registerAdminRoutes = (deps: RegisterAdminRoutesDeps) => {
return res.status(404).json({ error: "Not found", message: "User not found" });
}
if (target.id === actingAdmin.id) {
return res.status(409).json({
error: "Conflict",
message: "Already using the admin account. Use stop impersonation to return.",
});
}
if (!target.isActive) {
return res.status(403).json({ error: "Forbidden", message: "Target user is inactive" });
}
const { accessToken, refreshToken } = generateTokens(target.id, target.email, {
impersonatorId: req.user.id,
impersonatorId: actingAdmin.id,
});
setAuthCookies(req, res, { accessToken, refreshToken });
@@ -639,12 +725,12 @@ export const registerAdminRoutes = (deps: RegisterAdminRoutesDeps) => {
if (config.enableAuditLogging) {
await logAuditEvent({
userId: req.user.id,
userId: actingAdmin.id,
action: "impersonation_started",
resource: `user:${target.id}`,
ipAddress: req.ip || req.connection.remoteAddress || undefined,
userAgent: req.headers["user-agent"] || undefined,
details: { targetUserId: target.id },
details: { targetUserId: target.id, initiatedFromImpersonation: Boolean(req.user?.impersonatorId) },
});
}
+1
View File
@@ -31,6 +31,7 @@ export const createAuthModeService = (
create: {
id: DEFAULT_SYSTEM_CONFIG_ID,
authEnabled: false,
authOnboardingCompleted: false,
registrationEnabled: false,
authLoginRateLimitEnabled: true,
authLoginRateLimitWindowMs: 15 * 60 * 1000,
+173 -22
View File
@@ -1,10 +1,11 @@
import express, { Request, Response } from "express";
import bcrypt from "bcrypt";
import jwt, { SignOptions } from "jsonwebtoken";
import { PrismaClient } from "../generated/client";
import { Prisma, PrismaClient } from "../generated/client";
import { StringValue } from "ms";
import { logAuditEvent } from "../utils/audit";
import {
authOnboardingChoiceSchema,
authEnabledToggleSchema,
loginSchema,
registerSchema,
@@ -21,6 +22,7 @@ type RegisterCoreRoutesDeps = {
ensureSystemConfig: () => Promise<{
id: string;
authEnabled: boolean;
authOnboardingCompleted: boolean;
registrationEnabled: boolean;
}>;
findUserByIdentifier: (identifier: string) => Promise<{
@@ -102,10 +104,54 @@ export const registerCoreRoutes = (deps: RegisterCoreRoutesDeps) => {
readRefreshTokenFromRequest,
} = deps;
const getUserTrashCollectionId = (userId: string): string => `trash:${userId}`;
const getAuthOnboardingStatus = async (systemConfig: {
authEnabled: boolean;
authOnboardingCompleted: boolean;
}) => {
const [activeUsers, drawingsCount, collectionsCount] = await Promise.all([
prisma.user.count({ where: { isActive: true } }),
prisma.drawing.count(),
prisma.collection.count(),
]);
const hasLegacyData = drawingsCount > 0 || collectionsCount > 0;
const needsChoice =
!systemConfig.authEnabled &&
activeUsers === 0 &&
!systemConfig.authOnboardingCompleted;
return {
activeUsers,
hasLegacyData,
needsChoice,
mode: hasLegacyData ? "migration" : "fresh",
} as const;
};
const ensureBootstrapUserExists = async (): Promise<void> => {
const bootstrap = await prisma.user.findUnique({
where: { id: bootstrapUserId },
select: { id: true },
});
if (bootstrap) return;
await prisma.user.create({
data: {
id: bootstrapUserId,
email: "bootstrap@excalidash.local",
username: null,
passwordHash: "",
name: "Bootstrap Admin",
role: "ADMIN",
mustResetPassword: true,
isActive: false,
},
});
};
router.post("/register", loginAttemptRateLimiter, async (req: Request, res: Response) => {
try {
if (!(await ensureAuthEnabled(res))) return;
if (!requireCsrf(req, res)) return;
const parsed = registerSchema.safeParse(req.body);
if (!parsed.success) {
@@ -135,25 +181,66 @@ export const registerCoreRoutes = (deps: RegisterCoreRoutesDeps) => {
const passwordHash = await bcrypt.hash(password, saltRounds);
const sanitizedName = sanitizeText(name, 100);
const user = await prisma.user.update({
where: { id: bootstrapUserId },
data: {
email,
username: username ?? null,
passwordHash,
name: sanitizedName,
role: "ADMIN",
mustResetPassword: false,
isActive: true,
},
select: {
id: true,
email: true,
name: true,
role: true,
mustResetPassword: true,
},
const existingEmailUser = await prisma.user.findUnique({
where: { email },
select: { id: true },
});
if (existingEmailUser && existingEmailUser.id !== bootstrapUserId) {
return res.status(409).json({
error: "Conflict",
message: "User with this email already exists",
});
}
if (username) {
const existingUsernameUser = await prisma.user.findFirst({
where: { username },
select: { id: true },
});
if (existingUsernameUser && existingUsernameUser.id !== bootstrapUserId) {
return res.status(409).json({
error: "Conflict",
message: "User with this username already exists",
});
}
}
let user: {
id: string;
email: string;
name: string;
role: string;
mustResetPassword: boolean;
};
try {
user = await prisma.user.update({
where: { id: bootstrapUserId },
data: {
email,
username: username ?? null,
passwordHash,
name: sanitizedName,
role: "ADMIN",
mustResetPassword: false,
isActive: true,
},
select: {
id: true,
email: true,
name: true,
role: true,
mustResetPassword: true,
},
});
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") {
return res.status(409).json({
error: "Conflict",
message: "User with this email or username already exists",
});
}
throw error;
}
const trashCollectionId = getUserTrashCollectionId(user.id);
const existingTrash = await prisma.collection.findFirst({
@@ -747,6 +834,7 @@ export const registerCoreRoutes = (deps: RegisterCoreRoutesDeps) => {
router.get("/status", optionalAuth, async (req: Request, res: Response) => {
try {
const systemConfig = await ensureSystemConfig();
const onboarding = await getAuthOnboardingStatus(systemConfig);
if (!systemConfig.authEnabled) {
return res.json({
enabled: false,
@@ -754,6 +842,9 @@ export const registerCoreRoutes = (deps: RegisterCoreRoutesDeps) => {
authEnabled: false,
registrationEnabled: false,
bootstrapRequired: false,
authOnboardingRequired: onboarding.needsChoice,
authOnboardingMode: onboarding.mode,
authOnboardingRecommended: onboarding.needsChoice ? "enable" : null,
user: null,
});
}
@@ -762,8 +853,9 @@ export const registerCoreRoutes = (deps: RegisterCoreRoutesDeps) => {
where: { id: bootstrapUserId },
select: { id: true, isActive: true },
});
const activeUsers = await prisma.user.count({ where: { isActive: true } });
const bootstrapRequired = Boolean(bootstrapUser && bootstrapUser.isActive === false) && activeUsers === 0;
const bootstrapRequired =
Boolean(bootstrapUser && bootstrapUser.isActive === false) &&
onboarding.activeUsers === 0;
res.json({
enabled: true,
@@ -771,6 +863,9 @@ export const registerCoreRoutes = (deps: RegisterCoreRoutesDeps) => {
authenticated: Boolean(req.user),
registrationEnabled: systemConfig.registrationEnabled,
bootstrapRequired,
authOnboardingRequired: onboarding.needsChoice,
authOnboardingMode: onboarding.mode,
authOnboardingRecommended: onboarding.needsChoice ? "enable" : null,
user: req.user
? {
id: req.user.id,
@@ -792,6 +887,61 @@ export const registerCoreRoutes = (deps: RegisterCoreRoutesDeps) => {
}
});
router.post("/onboarding-choice", optionalAuth, async (req: Request, res: Response) => {
try {
if (!requireCsrf(req, res)) return;
const parsed = authOnboardingChoiceSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({
error: "Bad request",
message: "Invalid onboarding choice payload",
});
}
const systemConfig = await ensureSystemConfig();
const onboarding = await getAuthOnboardingStatus(systemConfig);
if (!onboarding.needsChoice) {
return res.status(409).json({
error: "Conflict",
message: "Authentication onboarding is already completed",
});
}
const nextAuthEnabled = parsed.data.enableAuth;
if (nextAuthEnabled) {
await ensureBootstrapUserExists();
}
const updated = await prisma.systemConfig.upsert({
where: { id: defaultSystemConfigId },
update: {
authEnabled: nextAuthEnabled,
authOnboardingCompleted: true,
},
create: {
id: defaultSystemConfigId,
authEnabled: nextAuthEnabled,
authOnboardingCompleted: true,
registrationEnabled: systemConfig.registrationEnabled,
},
});
clearAuthEnabledCache();
return res.json({
authEnabled: updated.authEnabled,
authOnboardingCompleted: updated.authOnboardingCompleted,
bootstrapRequired: Boolean(nextAuthEnabled),
});
} catch (error) {
console.error("Auth onboarding choice error:", error);
return res.status(500).json({
error: "Internal server error",
message: "Failed to apply authentication onboarding choice",
});
}
});
router.post("/auth-enabled", requireAuth, async (req: Request, res: Response) => {
try {
if (!requireCsrf(req, res)) return;
@@ -840,10 +990,11 @@ export const registerCoreRoutes = (deps: RegisterCoreRoutesDeps) => {
const updated = await prisma.systemConfig.upsert({
where: { id: defaultSystemConfigId },
update: { authEnabled: next },
update: { authEnabled: next, authOnboardingCompleted: true },
create: {
id: defaultSystemConfigId,
authEnabled: next,
authOnboardingCompleted: true,
registrationEnabled: systemConfig.registrationEnabled,
},
});
+4
View File
@@ -47,6 +47,10 @@ export const authEnabledToggleSchema = z.object({
enabled: z.boolean(),
});
export const authOnboardingChoiceSchema = z.object({
enableAuth: z.boolean(),
});
export const adminCreateUserSchema = z.object({
username: z.string().trim().min(3).max(50).optional(),
email: z.string().email().toLowerCase().trim(),
+1 -2
View File
@@ -128,8 +128,7 @@ if (config.nodeEnv === "production") {
throw new Error("JWT_SECRET must be at least 32 characters long in production");
}
if (
insecureJwtSecretPlaceholders.has(normalizedSecret) ||
normalizedSecret.toLowerCase().includes("change-this-secret")
insecureJwtSecretPlaceholders.has(normalizedSecret)
) {
throw new Error("JWT_SECRET must be changed from placeholder/default value in production");
}