fix impersonation issues

This commit is contained in:
Zimeng Xiong
2026-02-10 22:44:49 -08:00
parent 1c71a08bbe
commit 2cbd11cf0d
19 changed files with 1083 additions and 58 deletions
+14
View File
@@ -5,6 +5,7 @@ DATABASE_URL=file:/app/prisma/dev.db
FRONTEND_URL=http://localhost:6767
# Keep disabled unless traffic always comes through a trusted reverse proxy.
TRUST_PROXY=false
AUTH_MODE=local
JWT_SECRET=change-this-secret-in-production-min-32-chars
# Optional Feature Flags (all default to false for backward compatibility)
@@ -12,3 +13,16 @@ JWT_SECRET=change-this-secret-in-production-min-32-chars
# ENABLE_PASSWORD_RESET=false
# ENABLE_REFRESH_TOKEN_ROTATION=false
# ENABLE_AUDIT_LOGGING=false
# OIDC Configuration (required when AUTH_MODE=hybrid or AUTH_MODE=oidc_enforced)
# OIDC_PROVIDER_NAME=Authentik
# OIDC_ISSUER_URL=https://auth.example.com/application/o/excalidash/
# OIDC_CLIENT_ID=your-client-id
# OIDC_CLIENT_SECRET=your-client-secret
# OIDC_REDIRECT_URI=https://excalidash.example.com/api/auth/oidc/callback
# OIDC_SCOPES=openid profile email
# OIDC_EMAIL_CLAIM=email
# OIDC_EMAIL_VERIFIED_CLAIM=email_verified
# OIDC_REQUIRE_EMAIL_VERIFIED=true
# OIDC_JIT_PROVISIONING=true
# OIDC_FIRST_USER_ADMIN=true
+63 -2
View File
@@ -1,12 +1,12 @@
{
"name": "backend",
"version": "0.4.1",
"version": "0.4.6",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "backend",
"version": "0.4.1",
"version": "0.4.6",
"license": "ISC",
"dependencies": {
"@prisma/client": "^5.22.0",
@@ -24,6 +24,7 @@
"jszip": "^3.10.1",
"ms": "^2.1.3",
"multer": "^2.0.2",
"openid-client": "^5.7.1",
"prisma": "^5.22.0",
"socket.io": "^4.8.1",
"uuid": "^13.0.0",
@@ -3314,6 +3315,15 @@
"@pkgjs/parseargs": "^0.11.0"
}
},
"node_modules/jose": {
"version": "4.15.9",
"resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz",
"integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/jsdom": {
"version": "22.1.0",
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-22.1.0.tgz",
@@ -3909,6 +3919,15 @@
"node": ">=0.10.0"
}
},
"node_modules/object-hash": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz",
"integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==",
"license": "MIT",
"engines": {
"node": ">= 6"
}
},
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
@@ -3932,6 +3951,15 @@
],
"license": "MIT"
},
"node_modules/oidc-token-hash": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.2.0.tgz",
"integrity": "sha512-6gj2m8cJZ+iSW8bm0FXdGF0YhIQbKrfP4yWTNzxc31U6MOjfEmB1rHvlYvxI1B7t7BCi1F2vYTT6YhtQRG4hxw==",
"license": "MIT",
"engines": {
"node": "^10.13.0 || >=12.0.0"
}
},
"node_modules/on-finished": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
@@ -3953,6 +3981,33 @@
"wrappy": "1"
}
},
"node_modules/openid-client": {
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.7.1.tgz",
"integrity": "sha512-jDBPgSVfTnkIh71Hg9pRvtJc6wTwqjRkN88+gCFtYWrlP4Yx2Dsrow8uPi3qLr/aeymPF3o2+dS+wOpglK04ew==",
"license": "MIT",
"dependencies": {
"jose": "^4.15.9",
"lru-cache": "^6.0.0",
"object-hash": "^2.2.0",
"oidc-token-hash": "^5.0.3"
},
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/openid-client/node_modules/lru-cache": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
"license": "ISC",
"dependencies": {
"yallist": "^4.0.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/package-json-from-dist": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
@@ -5789,6 +5844,12 @@
"node": ">=0.4"
}
},
"node_modules/yallist": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
"license": "ISC"
},
"node_modules/yn": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz",
+1
View File
@@ -34,6 +34,7 @@
"jszip": "^3.10.1",
"ms": "^2.1.3",
"multer": "^2.0.2",
"openid-client": "^5.7.1",
"prisma": "^5.22.0",
"socket.io": "^4.8.1",
"uuid": "^13.0.0",
@@ -0,0 +1,22 @@
-- CreateTable
CREATE TABLE "AuthIdentity" (
"id" TEXT NOT NULL PRIMARY KEY,
"userId" TEXT NOT NULL,
"provider" TEXT NOT NULL,
"issuer" TEXT NOT NULL,
"subject" TEXT NOT NULL,
"emailAtLink" TEXT NOT NULL,
"lastLoginAt" DATETIME,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "AuthIdentity_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateIndex
CREATE UNIQUE INDEX "AuthIdentity_issuer_subject_key" ON "AuthIdentity"("issuer", "subject");
-- CreateIndex
CREATE UNIQUE INDEX "AuthIdentity_provider_userId_key" ON "AuthIdentity"("provider", "userId");
-- CreateIndex
CREATE INDEX "AuthIdentity_userId_idx" ON "AuthIdentity"("userId");
+18
View File
@@ -21,6 +21,7 @@ model User {
role String @default("USER")
mustResetPassword Boolean @default(false)
isActive Boolean @default(true)
authIdentities AuthIdentity[]
drawings Drawing[]
collections Collection[]
passwordResetTokens PasswordResetToken[]
@@ -111,3 +112,20 @@ model AuditLog {
details String? // JSON string for additional details
createdAt DateTime @default(now())
}
model AuthIdentity {
id String @id @default(uuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
provider String
issuer String
subject String
emailAtLink String
lastLoginAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([issuer, subject])
@@unique([provider, userId])
@@index([userId])
}
+16 -1
View File
@@ -14,6 +14,7 @@ import rateLimit, { MemoryStore } from "express-rate-limit";
import { registerAccountRoutes } from "./auth/accountRoutes";
import { registerAdminRoutes } from "./auth/adminRoutes";
import { registerCoreRoutes } from "./auth/coreRoutes";
import { registerOidcRoutes } from "./auth/oidcRoutes";
import { prisma as defaultPrisma } from "./db/prisma";
import {
BOOTSTRAP_USER_ID,
@@ -63,7 +64,9 @@ export const createAuthRouter = (deps: CreateAuthRouterDeps): express.Router =>
const ensureAuthEnabled = async (res: Response): Promise<boolean> => {
const systemConfig = await ensureSystemConfig();
if (!systemConfig.authEnabled) {
const authEnabled =
config.authMode !== "local" ? true : systemConfig.authEnabled;
if (!authEnabled) {
res.status(404).json({
error: "Not found",
message: "Authentication is disabled",
@@ -368,6 +371,18 @@ export const createAuthRouter = (deps: CreateAuthRouterDeps): express.Router =>
const getRefreshTokenExpiresAt = (): Date =>
resolveExpiresAt(config.jwtRefreshExpiresIn, 7 * 24 * 60 * 60 * 1000);
registerOidcRoutes({
router,
prisma,
ensureAuthEnabled,
sanitizeText,
generateTokens,
setAuthCookies,
getRefreshTokenExpiresAt,
isMissingRefreshTokenTableError,
config,
});
registerCoreRoutes({
router,
prisma,
+8 -1
View File
@@ -1,4 +1,5 @@
import { PrismaClient } from "../generated/client";
import { config } from "../config";
export const BOOTSTRAP_USER_ID = "bootstrap-admin";
export const DEFAULT_SYSTEM_CONFIG_ID = "default";
@@ -30,7 +31,7 @@ export const createAuthModeService = (
update: {},
create: {
id: DEFAULT_SYSTEM_CONFIG_ID,
authEnabled: false,
authEnabled: config.authMode !== "local",
authOnboardingCompleted: false,
registrationEnabled: false,
authLoginRateLimitEnabled: true,
@@ -41,6 +42,12 @@ export const createAuthModeService = (
};
const getAuthEnabled = async (): Promise<boolean> => {
if (config.authMode !== "local") {
const now = Date.now();
authEnabledCache = { value: true, fetchedAt: now };
return true;
}
const now = Date.now();
if (authEnabledCache && now - authEnabledCache.fetchedAt < authEnabledTtlMs) {
return authEnabledCache.value;
+50 -7
View File
@@ -44,10 +44,16 @@ type RegisterCoreRoutesDeps = {
impersonatorId?: string;
};
config: {
authMode: "local" | "hybrid" | "oidc_enforced";
jwtSecret: string;
jwtAccessExpiresIn: string;
enableRefreshTokenRotation: boolean;
enableAuditLogging: boolean;
oidc: {
enabled: boolean;
enforced: boolean;
providerName: string;
};
};
generateTokens: (
userId: string,
@@ -151,6 +157,12 @@ export const registerCoreRoutes = (deps: RegisterCoreRoutesDeps) => {
router.post("/register", loginAttemptRateLimiter, async (req: Request, res: Response) => {
try {
if (!(await ensureAuthEnabled(res))) return;
if (config.authMode === "oidc_enforced") {
return res.status(403).json({
error: "Forbidden",
message: "Local registration is disabled in OIDC enforced mode",
});
}
if (!requireCsrf(req, res)) return;
const parsed = registerSchema.safeParse(req.body);
@@ -415,6 +427,12 @@ export const registerCoreRoutes = (deps: RegisterCoreRoutesDeps) => {
router.post("/login", loginAttemptRateLimiter, async (req: Request, res: Response) => {
try {
if (!(await ensureAuthEnabled(res))) return;
if (config.authMode === "oidc_enforced") {
return res.status(403).json({
error: "Forbidden",
message: "Local login is disabled in OIDC enforced mode",
});
}
const parsed = loginSchema.safeParse(req.body);
if (!parsed.success) {
@@ -835,16 +853,24 @@ export const registerCoreRoutes = (deps: RegisterCoreRoutesDeps) => {
try {
const systemConfig = await ensureSystemConfig();
const onboarding = await getAuthOnboardingStatus(systemConfig);
if (!systemConfig.authEnabled) {
const effectiveAuthEnabled =
config.authMode !== "local" ? true : systemConfig.authEnabled;
const onboardingRequired = config.authMode === "local" ? onboarding.needsChoice : false;
const onboardingMode = config.authMode === "local" ? onboarding.mode : null;
if (!effectiveAuthEnabled) {
return res.json({
enabled: false,
authenticated: false,
authEnabled: false,
authMode: config.authMode,
oidcEnabled: config.oidc.enabled,
oidcEnforced: config.oidc.enforced,
oidcProvider: config.oidc.providerName,
registrationEnabled: false,
bootstrapRequired: false,
authOnboardingRequired: onboarding.needsChoice,
authOnboardingMode: onboarding.mode,
authOnboardingRecommended: onboarding.needsChoice ? "enable" : null,
authOnboardingRequired: onboardingRequired,
authOnboardingMode: onboardingMode,
authOnboardingRecommended: onboardingRequired ? "enable" : null,
user: null,
});
}
@@ -854,18 +880,23 @@ export const registerCoreRoutes = (deps: RegisterCoreRoutesDeps) => {
select: { id: true, isActive: true },
});
const bootstrapRequired =
!config.oidc.enforced &&
Boolean(bootstrapUser && bootstrapUser.isActive === false) &&
onboarding.activeUsers === 0;
res.json({
enabled: true,
authEnabled: true,
authMode: config.authMode,
oidcEnabled: config.oidc.enabled,
oidcEnforced: config.oidc.enforced,
oidcProvider: config.oidc.providerName,
authenticated: Boolean(req.user),
registrationEnabled: systemConfig.registrationEnabled,
bootstrapRequired,
authOnboardingRequired: onboarding.needsChoice,
authOnboardingMode: onboarding.mode,
authOnboardingRecommended: onboarding.needsChoice ? "enable" : null,
authOnboardingRequired: onboardingRequired,
authOnboardingMode: onboardingMode,
authOnboardingRecommended: onboardingRequired ? "enable" : null,
user: req.user
? {
id: req.user.id,
@@ -889,6 +920,12 @@ export const registerCoreRoutes = (deps: RegisterCoreRoutesDeps) => {
router.post("/onboarding-choice", optionalAuth, async (req: Request, res: Response) => {
try {
if (config.authMode !== "local") {
return res.status(409).json({
error: "Conflict",
message: "Onboarding choice is managed by AUTH_MODE configuration",
});
}
if (!requireCsrf(req, res)) return;
const parsed = authOnboardingChoiceSchema.safeParse(req.body);
if (!parsed.success) {
@@ -944,6 +981,12 @@ export const registerCoreRoutes = (deps: RegisterCoreRoutesDeps) => {
router.post("/auth-enabled", requireAuth, async (req: Request, res: Response) => {
try {
if (config.authMode === "oidc_enforced") {
return res.status(409).json({
error: "Conflict",
message: "Authentication mode is managed by AUTH_MODE=oidc_enforced",
});
}
if (!requireCsrf(req, res)) return;
if (!req.user) {
return res
File diff suppressed because it is too large Load Diff
+90
View File
@@ -12,22 +12,49 @@ interface Config {
nodeEnv: string;
databaseUrl?: string;
frontendUrl?: string;
authMode: AuthMode;
jwtSecret: string;
jwtAccessExpiresIn: string;
jwtRefreshExpiresIn: string;
rateLimitMaxRequests: number;
csrfMaxRequests: number;
csrfSecret: string | null;
oidc: OidcConfig;
// Feature flags - all default to false for backward compatibility
enablePasswordReset: boolean;
enableRefreshTokenRotation: boolean;
enableAuditLogging: boolean;
}
export type AuthMode = "local" | "hybrid" | "oidc_enforced";
interface OidcConfig {
enabled: boolean;
enforced: boolean;
providerName: string;
issuerUrl: string | null;
clientId: string | null;
clientSecret: string | null;
redirectUri: string | null;
scopes: string;
emailClaim: string;
emailVerifiedClaim: string;
requireEmailVerified: boolean;
jitProvisioning: boolean;
firstUserAdmin: boolean;
}
const getOptionalEnv = (key: string, defaultValue: string): string => {
return process.env[key] || defaultValue;
};
const getOptionalTrimmedEnv = (key: string): string | null => {
const raw = process.env[key];
if (!raw) return null;
const trimmed = raw.trim();
return trimmed.length > 0 ? trimmed : null;
};
const resolveJwtSecret = (nodeEnv: string): string => {
const provided = process.env.JWT_SECRET;
if (provided && provided.trim().length > 0) {
@@ -99,17 +126,77 @@ const getRequiredEnvNumber = (key: string, defaultValue: number): number => {
return parsed;
};
const parseAuthMode = (rawValue: string | undefined): AuthMode => {
const normalized = (rawValue || "local").trim().toLowerCase();
if (normalized === "local" || normalized === "hybrid" || normalized === "oidc_enforced") {
return normalized;
}
throw new Error(
"Invalid AUTH_MODE. Expected one of: local, hybrid, oidc_enforced"
);
};
const resolveOidcConfig = (authMode: AuthMode): OidcConfig => {
const issuerUrl = getOptionalTrimmedEnv("OIDC_ISSUER_URL");
const clientId = getOptionalTrimmedEnv("OIDC_CLIENT_ID");
const clientSecret = getOptionalTrimmedEnv("OIDC_CLIENT_SECRET");
const redirectUri = getOptionalTrimmedEnv("OIDC_REDIRECT_URI");
const requiredWhenEnabled = {
OIDC_ISSUER_URL: issuerUrl,
OIDC_CLIENT_ID: clientId,
OIDC_CLIENT_SECRET: clientSecret,
OIDC_REDIRECT_URI: redirectUri,
};
const enabled = authMode !== "local";
const missingRequired = Object.entries(requiredWhenEnabled)
.filter(([, value]) => !value)
.map(([key]) => key);
if (enabled && missingRequired.length > 0) {
throw new Error(
`AUTH_MODE=${authMode} requires OIDC configuration. Missing: ${missingRequired.join(", ")}`
);
}
if (!enabled) {
const hasOidcVars = Object.values(requiredWhenEnabled).some((value) => Boolean(value));
if (hasOidcVars) {
console.warn("[config] AUTH_MODE=local; ignoring OIDC_* provider settings.");
}
}
return {
enabled,
enforced: authMode === "oidc_enforced",
providerName: getOptionalEnv("OIDC_PROVIDER_NAME", "OIDC"),
issuerUrl,
clientId,
clientSecret,
redirectUri,
scopes: getOptionalEnv("OIDC_SCOPES", "openid profile email"),
emailClaim: getOptionalEnv("OIDC_EMAIL_CLAIM", "email"),
emailVerifiedClaim: getOptionalEnv("OIDC_EMAIL_VERIFIED_CLAIM", "email_verified"),
requireEmailVerified: getOptionalBoolean("OIDC_REQUIRE_EMAIL_VERIFIED", true),
jitProvisioning: getOptionalBoolean("OIDC_JIT_PROVISIONING", true),
firstUserAdmin: getOptionalBoolean("OIDC_FIRST_USER_ADMIN", true),
};
};
const resolvedAuthMode = parseAuthMode(process.env.AUTH_MODE);
export const config: Config = {
port: getRequiredEnvNumber("PORT", 8000),
nodeEnv: getOptionalEnv("NODE_ENV", "development"),
databaseUrl: process.env.DATABASE_URL,
frontendUrl: parseFrontendUrl(process.env.FRONTEND_URL),
authMode: resolvedAuthMode,
jwtSecret: resolveJwtSecret(getOptionalEnv("NODE_ENV", "development")),
jwtAccessExpiresIn: getOptionalEnv("JWT_ACCESS_EXPIRES_IN", "15m"),
jwtRefreshExpiresIn: getOptionalEnv("JWT_REFRESH_EXPIRES_IN", "7d"),
rateLimitMaxRequests: getRequiredEnvNumber("RATE_LIMIT_MAX_REQUESTS", 1000),
csrfMaxRequests: getRequiredEnvNumber("CSRF_MAX_REQUESTS", 60),
csrfSecret: process.env.CSRF_SECRET || null,
oidc: resolveOidcConfig(resolvedAuthMode),
// Feature flags - disabled by default for backward compatibility
enablePasswordReset: getOptionalBoolean("ENABLE_PASSWORD_RESET", false),
enableRefreshTokenRotation: getOptionalBoolean("ENABLE_REFRESH_TOKEN_ROTATION", false),
@@ -132,6 +219,9 @@ if (config.nodeEnv === "production") {
) {
throw new Error("JWT_SECRET must be changed from placeholder/default value in production");
}
if (config.oidc.enabled && config.oidc.redirectUri && !/^https:\/\//i.test(config.oidc.redirectUri)) {
throw new Error("OIDC_REDIRECT_URI must be HTTPS in production");
}
}
console.log("Configuration validated successfully");