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
+25
View File
@@ -165,6 +165,31 @@ backend:
Without this, each container generates its own ephemeral CSRF secret, causing token validation failures when requests are routed to different replicas. Single-container deployments work without this setting.
### Authentication Modes (Local + OIDC)
ExcaliDash supports three auth modes via backend `AUTH_MODE`:
- `local` (default): native email/password login only.
- `hybrid`: native login + OIDC login.
- `oidc_enforced`: OIDC-only login (native login/register disabled).
For OIDC modes (`hybrid` or `oidc_enforced`), set:
```yaml
backend:
environment:
- 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
```
In `oidc_enforced` mode, unauthenticated users are automatically redirected to `/api/auth/oidc/start`.
Users are linked by `(issuer, sub)` first, then by verified email, and optionally auto-provisioned.
# Development
## Clone the Repository
+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");
+7
View File
@@ -6,6 +6,7 @@ services:
- DATABASE_URL=file:/app/prisma/dev.db
- PORT=8000
- NODE_ENV=production
- AUTH_MODE=${AUTH_MODE:-local}
# Keep disabled by default; only enable when a trusted proxy sanitizes forwarded headers.
- TRUST_PROXY=false
# Optional for single-instance deployments:
@@ -13,6 +14,12 @@ services:
# Recommended to set explicitly for portability and multi-instance setups.
- JWT_SECRET=${JWT_SECRET}
- CSRF_SECRET=${CSRF_SECRET}
# Optional OIDC settings (required for AUTH_MODE=hybrid or 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
volumes:
- backend-data:/app/prisma
networks:
+7
View File
@@ -8,6 +8,7 @@ services:
- DATABASE_URL=file:/app/prisma/dev.db
- PORT=8000
- NODE_ENV=production
- AUTH_MODE=${AUTH_MODE:-local}
# Keep disabled by default; only enable when a trusted proxy sanitizes forwarded headers.
- TRUST_PROXY=false
# Optional for single-instance deployments:
@@ -15,6 +16,12 @@ services:
# Recommended to set explicitly for portability and multi-instance setups.
- JWT_SECRET=${JWT_SECRET}
- CSRF_SECRET=${CSRF_SECRET}
# Optional OIDC settings (required for AUTH_MODE=hybrid or 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
volumes:
- backend-data:/app/prisma
networks:
+21
View File
@@ -69,6 +69,10 @@ export const clearCsrfToken = (): void => {
export interface AuthStatusResponse {
authEnabled?: boolean;
enabled?: boolean;
authMode?: "local" | "hybrid" | "oidc_enforced";
oidcEnabled?: boolean;
oidcEnforced?: boolean;
oidcProvider?: string;
bootstrapRequired?: boolean;
authOnboardingRequired?: boolean;
authOnboardingMode?: "migration" | "fresh";
@@ -92,6 +96,13 @@ export const authStatus = async (): Promise<AuthStatusResponse> => {
return response.data;
};
export const startOidcSignIn = (returnTo?: string): void => {
const fallbackPath = `${window.location.pathname}${window.location.search}${window.location.hash}`;
const requestedPath = typeof returnTo === "string" && returnTo.startsWith("/") ? returnTo : fallbackPath;
const safeReturnTo = requestedPath.startsWith("/") ? requestedPath : "/";
window.location.href = `/api/auth/oidc/start?returnTo=${encodeURIComponent(safeReturnTo)}`;
};
export const authMe = async (): Promise<{ user: AuthUser }> => {
const response = await axios.get<{ user: AuthUser }>(`${API_URL}/auth/me`, {
withCredentials: true,
@@ -204,6 +215,16 @@ const getAuthEnabledStatus = async (): Promise<boolean | null> => {
};
const redirectToLogin = async () => {
try {
const status = await authStatus();
if (status?.oidcEnforced) {
startOidcSignIn();
return;
}
} catch {
// Best-effort status probe; fall through to legacy behavior.
}
const authEnabled = await getAuthEnabledStatus();
if (authEnabled === false) return;
if (window.location.pathname !== '/login') {
+62 -26
View File
@@ -22,6 +22,13 @@ type ImpersonationTargetsResponse = {
users: ImpersonationTarget[];
};
type AuthStatusResponse = {
authenticated?: boolean;
user?: {
impersonatorId?: string;
} | null;
};
type ImpersonateResponse = {
user: {
id: string;
@@ -46,6 +53,11 @@ export const ImpersonationBanner: React.FC = () => {
const [error, setError] = useState('');
const [busy, setBusy] = useState(false);
const clearLocalImpersonation = () => {
localStorage.removeItem(IMPERSONATION_KEY);
setImpersonation(null);
};
useEffect(() => {
if (!authEnabled) {
setImpersonation(null);
@@ -54,6 +66,20 @@ export const ImpersonationBanner: React.FC = () => {
const sync = () => setImpersonation(readImpersonationState());
sync();
const verifyServerImpersonationState = async () => {
try {
const response = await api.get<AuthStatusResponse>('/auth/status');
const serverImpersonating = Boolean(response.data?.authenticated && response.data?.user?.impersonatorId);
if (!serverImpersonating && readImpersonationState()) {
clearLocalImpersonation();
}
} catch {
// Ignore probe failures; retry on next render/event.
}
};
void verifyServerImpersonationState();
window.addEventListener('storage', sync);
return () => window.removeEventListener('storage', sync);
}, [authEnabled]);
@@ -116,6 +142,14 @@ export const ImpersonationBanner: React.FC = () => {
let message = 'Failed to stop impersonation';
if (isAxiosError(err)) {
message = err.response?.data?.message || err.response?.data?.error || message;
if (
err.response?.status === 409 &&
/not currently impersonating/i.test(message)
) {
clearLocalImpersonation();
window.location.reload();
return;
}
}
setError(message);
setBusy(false);
@@ -159,36 +193,38 @@ export const ImpersonationBanner: React.FC = () => {
}
return (
<div className="mb-4 rounded-2xl border-2 border-amber-200 dark:border-amber-700 bg-amber-50 dark:bg-amber-900/20 p-3 sm:p-4 shadow-[2px_2px_0px_0px_rgba(0,0,0,0.18)] dark:shadow-[2px_2px_0px_0px_rgba(255,255,255,0.12)]">
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div className="min-w-0">
<div className="flex items-center gap-2 text-amber-900 dark:text-amber-200">
<LogIn size={16} />
<span className="text-sm font-bold uppercase tracking-wide">Impersonating:</span>
<div className="sticky top-0 z-[45] -mt-2 mb-6 rounded-xl border border-red-200 dark:border-red-800/50 bg-red-50/80 dark:bg-red-950/30 backdrop-blur-md px-3 py-2 shadow-sm transition-all duration-200">
<div className="flex flex-wrap items-center gap-x-4 gap-y-2">
<div className="flex items-center gap-3 min-w-0 flex-1">
<div className="flex items-center gap-1.5 text-red-700 dark:text-red-400 flex-shrink-0">
<LogIn size={14} strokeWidth={2.5} />
<span className="text-[10px] font-black uppercase tracking-wider">Impersonating</span>
</div>
<div className="mt-1 text-sm font-semibold text-amber-900 dark:text-amber-200 truncate">
{impersonation.target.name} ({impersonation.target.email})
</div>
<div className="text-xs text-amber-800/90 dark:text-amber-200/80 truncate">
Acting as this account. Stop to return to {impersonation.impersonator.email}.
<div className="flex items-center gap-2 min-w-0">
<span className="text-sm font-bold text-red-900 dark:text-red-100 truncate">
{impersonation.target.name}
</span>
<span className="hidden sm:inline text-xs font-medium text-red-800/60 dark:text-red-200/40 truncate">
{impersonation.target.email}
</span>
</div>
</div>
<div className="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-3 lg:flex-shrink-0 lg:justify-end">
<label className="text-xs font-bold uppercase tracking-wide text-amber-900 dark:text-amber-200">
Switch user:
</label>
<div className="flex items-center gap-2 ml-auto">
<div className="hidden lg:flex items-center gap-1.5 text-[10px] font-black uppercase tracking-wider text-red-700/60 dark:text-red-400/40">
Switch:
</div>
<select
value={impersonation.target.id}
onChange={(e) => {
void switchTarget(e.target.value);
}}
disabled={busy || loadingTargets || options.length === 0}
className="min-w-[220px] max-w-[320px] px-3 py-2 rounded-xl border-2 border-amber-300 dark:border-amber-700 bg-white dark:bg-neutral-900 text-sm font-semibold text-slate-900 dark:text-neutral-100 outline-none disabled:opacity-70"
className="h-8 min-w-[140px] max-w-[200px] px-2 rounded-lg border border-red-200 dark:border-red-800/50 bg-white/50 dark:bg-neutral-900/50 text-xs font-bold text-red-900 dark:text-red-100 outline-none hover:border-red-300 dark:hover:border-red-700 transition-colors disabled:opacity-50"
>
{options.map((target) => (
<option key={target.id} value={target.id}>
{target.name} ({target.email})
{target.name}
</option>
))}
</select>
@@ -196,28 +232,28 @@ export const ImpersonationBanner: React.FC = () => {
type="button"
onClick={stop}
disabled={busy}
className="inline-flex items-center justify-center gap-2 px-3 py-2 rounded-xl border-2 border-amber-300 dark:border-amber-700 bg-white dark:bg-neutral-900 text-sm font-bold text-amber-900 dark:text-amber-200 hover:bg-amber-100 dark:hover:bg-amber-900/30 transition-all disabled:opacity-70"
className="h-8 flex items-center justify-center gap-1.5 px-3 rounded-lg bg-red-600 dark:bg-red-600/80 text-[11px] font-black uppercase tracking-wider text-white hover:bg-red-700 dark:hover:bg-red-500 transition-all disabled:opacity-50 shadow-sm shadow-red-900/10"
>
<XCircle size={15} />
Stop
<XCircle size={14} strokeWidth={2.5} />
<span className="hidden sm:inline">Stop</span>
</button>
</div>
</div>
{(loadingTargets || error) && (
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs font-medium text-amber-900 dark:text-amber-200">
<div className="mt-1.5 pt-1.5 border-t border-red-200/50 dark:border-red-800/20 flex items-center gap-3 text-[10px] font-bold text-red-800 dark:text-red-300">
{loadingTargets ? (
<span className="inline-flex items-center gap-1">
<RefreshCw size={12} className="animate-spin" />
Loading users...
<span className="inline-flex items-center gap-1.5">
<RefreshCw size={10} className="animate-spin" />
Syncing targets...
</span>
) : null}
{error ? <span>{error}</span> : null}
{error ? <span className="truncate">{error}</span> : null}
{error ? (
<button
type="button"
onClick={() => void loadTargets()}
className="inline-flex items-center gap-1 rounded-lg border border-amber-300 dark:border-amber-700 px-2 py-1"
className="px-1.5 py-0.5 rounded bg-red-100 dark:bg-red-900/40 border border-red-200 dark:border-red-700/50 hover:bg-red-200 transition-colors"
>
Retry
</button>
+19 -1
View File
@@ -1,6 +1,7 @@
import React from 'react';
import React, { useEffect } from 'react';
import { Navigate, useLocation } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import { startOidcSignIn } from '../api';
interface ProtectedRouteProps {
children: React.ReactNode;
@@ -12,11 +13,24 @@ export const ProtectedRoute: React.FC<ProtectedRouteProps> = ({ children }) => {
isAuthenticated,
loading,
authEnabled,
oidcEnforced,
bootstrapRequired,
authOnboardingRequired,
user,
} = useAuth();
const OidcRedirect: React.FC<{ returnTo: string }> = ({ returnTo }) => {
useEffect(() => {
startOidcSignIn(returnTo);
}, [returnTo]);
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-gray-600 dark:text-gray-400">Redirecting to sign-in...</div>
</div>
);
};
if (loading || authEnabled === null) {
return (
<div className="min-h-screen flex items-center justify-center">
@@ -39,6 +53,10 @@ export const ProtectedRoute: React.FC<ProtectedRouteProps> = ({ children }) => {
if (bootstrapRequired) {
return <Navigate to="/register" replace />;
}
if (oidcEnforced) {
const returnTo = `${location.pathname}${location.search}${location.hash}`;
return <OidcRedirect returnTo={returnTo} />;
}
return <Navigate to="/login" replace />;
}
+28
View File
@@ -24,6 +24,10 @@ interface AuthContextType {
user: User | null;
loading: boolean;
authEnabled: boolean | null;
authMode: 'local' | 'hybrid' | 'oidc_enforced';
oidcEnabled: boolean;
oidcEnforced: boolean;
oidcProvider: string | null;
bootstrapRequired: boolean;
authOnboardingRequired: boolean;
authOnboardingMode: 'migration' | 'fresh' | null;
@@ -42,6 +46,10 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
const [authEnabled, setAuthEnabled] = useState<boolean | null>(null);
const [authMode, setAuthMode] = useState<'local' | 'hybrid' | 'oidc_enforced'>('local');
const [oidcEnabled, setOidcEnabled] = useState(false);
const [oidcEnforced, setOidcEnforced] = useState(false);
const [oidcProvider, setOidcProvider] = useState<string | null>(null);
const [bootstrapRequired, setBootstrapRequired] = useState(false);
const [authOnboardingRequired, setAuthOnboardingRequired] = useState(false);
const [authOnboardingMode, setAuthOnboardingMode] = useState<'migration' | 'fresh' | null>(null);
@@ -60,6 +68,14 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
: true;
setAuthEnabled(enabled);
localStorage.setItem(AUTH_ENABLED_CACHE_KEY, String(enabled));
const nextAuthMode =
statusResponse?.authMode === 'hybrid' || statusResponse?.authMode === 'oidc_enforced'
? statusResponse.authMode
: 'local';
setAuthMode(nextAuthMode);
setOidcEnabled(Boolean(statusResponse?.oidcEnabled));
setOidcEnforced(Boolean(statusResponse?.oidcEnforced));
setOidcProvider(typeof statusResponse?.oidcProvider === 'string' ? statusResponse.oidcProvider : null);
setBootstrapRequired(Boolean(statusResponse?.bootstrapRequired));
setAuthOnboardingRequired(Boolean(statusResponse?.authOnboardingRequired));
setAuthOnboardingMode(
@@ -77,6 +93,10 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
const cachedAuthEnabled = localStorage.getItem(AUTH_ENABLED_CACHE_KEY);
if (cachedAuthEnabled === "false") {
setAuthEnabled(false);
setAuthMode('local');
setOidcEnabled(false);
setOidcEnforced(false);
setOidcProvider(null);
setBootstrapRequired(false);
setAuthOnboardingRequired(false);
setAuthOnboardingMode(null);
@@ -85,6 +105,10 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
return;
}
setAuthEnabled(true);
setAuthMode('local');
setOidcEnabled(false);
setOidcEnforced(false);
setOidcProvider(null);
setBootstrapRequired(false);
setAuthOnboardingRequired(false);
setAuthOnboardingMode(null);
@@ -192,6 +216,10 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
user,
loading,
authEnabled,
authMode,
oidcEnabled,
oidcEnforced,
oidcProvider,
bootstrapRequired,
authOnboardingRequired,
authOnboardingMode,
+70 -6
View File
@@ -16,6 +16,9 @@ export const Login: React.FC = () => {
login,
logout,
authEnabled,
oidcEnabled,
oidcEnforced,
oidcProvider,
bootstrapRequired,
authOnboardingRequired,
isAuthenticated,
@@ -25,8 +28,16 @@ export const Login: React.FC = () => {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const queryMustReset = searchParams.get('mustReset') === '1';
const oidcErrorCode = searchParams.get('oidcError');
const oidcErrorMessage = searchParams.get('oidcErrorMessage');
const oidcReturnTo = searchParams.get('returnTo') || '/';
const mustReset = Boolean(user?.mustResetPassword) || queryMustReset;
useEffect(() => {
if (!oidcErrorCode) return;
setError(oidcErrorMessage || 'OIDC sign-in failed');
}, [oidcErrorCode, oidcErrorMessage]);
useEffect(() => {
if (authLoading || authEnabled === null) return;
if (authOnboardingRequired) {
@@ -41,11 +52,28 @@ export const Login: React.FC = () => {
navigate('/register', { replace: true });
return;
}
if (oidcEnforced && !mustReset) {
if (!oidcErrorCode) {
api.startOidcSignIn(oidcReturnTo);
}
return;
}
if (isAuthenticated) {
if (mustReset) return;
navigate('/', { replace: true });
}
}, [authEnabled, authLoading, authOnboardingRequired, bootstrapRequired, isAuthenticated, mustReset, navigate]);
}, [
authEnabled,
authLoading,
authOnboardingRequired,
bootstrapRequired,
isAuthenticated,
mustReset,
navigate,
oidcEnforced,
oidcErrorCode,
oidcReturnTo,
]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
@@ -114,9 +142,13 @@ export const Login: React.FC = () => {
<div className="text-center">
<Logo className="mx-auto h-12 w-auto" />
<h2 className="mt-6 text-3xl font-extrabold text-gray-900 dark:text-white">
{mustReset ? 'Reset your password' : 'Sign in to your account'}
{mustReset
? 'Reset your password'
: oidcEnforced
? `Sign in with ${oidcProvider || 'OIDC'}`
: 'Sign in to your account'}
</h2>
{!mustReset ? (
{!mustReset && !oidcEnforced ? (
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">
Or{' '}
<Link
@@ -126,10 +158,14 @@ export const Login: React.FC = () => {
create a new account
</Link>
</p>
) : (
) : mustReset ? (
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">
Your admin requires you to set a new password before using ExcaliDash.
</p>
) : (
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">
You will be redirected to {oidcProvider || 'your identity provider'}.
</p>
)}
</div>
<form className="mt-8 space-y-6" onSubmit={mustReset ? handleMustReset : handleSubmit}>
@@ -138,6 +174,17 @@ export const Login: React.FC = () => {
<div className="text-sm text-red-800 dark:text-red-200">{error}</div>
</div>
)}
{oidcEnforced && !mustReset ? (
<div>
<button
type="button"
onClick={() => api.startOidcSignIn(oidcReturnTo)}
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
Continue with {oidcProvider || 'OIDC'}
</button>
</div>
) : (
<div className="rounded-md shadow-sm -space-y-px">
{!mustReset ? (
<>
@@ -213,8 +260,9 @@ export const Login: React.FC = () => {
</>
)}
</div>
)}
{!mustReset && (
{!mustReset && !oidcEnforced && (
<div className="flex justify-end">
<Link
to="/reset-password"
@@ -225,15 +273,31 @@ export const Login: React.FC = () => {
</div>
)}
{(!oidcEnforced || mustReset) && (
<div>
<button
type="submit"
disabled={loading}
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
>
{mustReset ? (loading ? 'Updating...' : 'Set new password') : (loading ? 'Signing in...' : 'Sign in')}
{mustReset
? (loading ? 'Updating...' : 'Set new password')
: (loading ? 'Signing in...' : 'Sign in')}
</button>
</div>
)}
{!mustReset && oidcEnabled && !oidcEnforced && (
<div>
<button
type="button"
onClick={() => api.startOidcSignIn('/')}
className="group relative w-full flex justify-center py-2 px-4 border border-gray-300 dark:border-gray-700 text-sm font-medium rounded-md text-gray-900 dark:text-gray-100 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
Continue with {oidcProvider || 'OIDC'}
</button>
</div>
)}
{mustReset && (
<div className="text-center">
+6 -1
View File
@@ -12,6 +12,7 @@ export const Register: React.FC = () => {
const {
register,
authEnabled,
oidcEnforced,
bootstrapRequired,
authOnboardingRequired,
isAuthenticated,
@@ -25,6 +26,10 @@ export const Register: React.FC = () => {
navigate('/auth-setup', { replace: true });
return;
}
if (oidcEnforced) {
navigate('/login', { replace: true });
return;
}
if (!authEnabled) {
navigate('/', { replace: true });
return;
@@ -32,7 +37,7 @@ export const Register: React.FC = () => {
if (isAuthenticated) {
navigate('/', { replace: true });
}
}, [authEnabled, authLoading, authOnboardingRequired, isAuthenticated, navigate]);
}, [authEnabled, authLoading, authOnboardingRequired, isAuthenticated, navigate, oidcEnforced]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();