From 2cbd11cf0d8f4189eea5f273438ba7edeef86895 Mon Sep 17 00:00:00 2001 From: Zimeng Xiong Date: Tue, 10 Feb 2026 22:44:49 -0800 Subject: [PATCH] fix impersonation issues --- README.md | 25 + backend/.env.example | 14 + backend/package-lock.json | 65 ++- backend/package.json | 1 + .../migration.sql | 22 + backend/prisma/schema.prisma | 18 + backend/src/auth.ts | 17 +- backend/src/auth/authMode.ts | 9 +- backend/src/auth/coreRoutes.ts | 57 +- backend/src/auth/oidcRoutes.ts | 543 ++++++++++++++++++ backend/src/config.ts | 90 +++ docker-compose.prod.yml | 7 + docker-compose.yml | 7 + frontend/src/api/index.ts | 21 + .../src/components/ImpersonationBanner.tsx | 88 ++- frontend/src/components/ProtectedRoute.tsx | 20 +- frontend/src/context/AuthContext.tsx | 28 + frontend/src/pages/Login.tsx | 102 +++- frontend/src/pages/Register.tsx | 7 +- 19 files changed, 1083 insertions(+), 58 deletions(-) create mode 100644 backend/prisma/migrations/20260210190000_add_auth_identity/migration.sql create mode 100644 backend/src/auth/oidcRoutes.ts diff --git a/README.md b/README.md index e038d7b..6b220a2 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/backend/.env.example b/backend/.env.example index 054e3cb..71c20bf 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -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 diff --git a/backend/package-lock.json b/backend/package-lock.json index 0c9d44f..c7dd977 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -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", diff --git a/backend/package.json b/backend/package.json index af2e214..527df4a 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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", diff --git a/backend/prisma/migrations/20260210190000_add_auth_identity/migration.sql b/backend/prisma/migrations/20260210190000_add_auth_identity/migration.sql new file mode 100644 index 0000000..5c44fa2 --- /dev/null +++ b/backend/prisma/migrations/20260210190000_add_auth_identity/migration.sql @@ -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"); diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index fd36384..2794012 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -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]) +} diff --git a/backend/src/auth.ts b/backend/src/auth.ts index 99749e7..0dd348d 100644 --- a/backend/src/auth.ts +++ b/backend/src/auth.ts @@ -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 => { 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, diff --git a/backend/src/auth/authMode.ts b/backend/src/auth/authMode.ts index f2ecd43..264ac24 100644 --- a/backend/src/auth/authMode.ts +++ b/backend/src/auth/authMode.ts @@ -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 => { + 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; diff --git a/backend/src/auth/coreRoutes.ts b/backend/src/auth/coreRoutes.ts index 99eaa2a..f7caaf7 100644 --- a/backend/src/auth/coreRoutes.ts +++ b/backend/src/auth/coreRoutes.ts @@ -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 diff --git a/backend/src/auth/oidcRoutes.ts b/backend/src/auth/oidcRoutes.ts new file mode 100644 index 0000000..2a23dc9 --- /dev/null +++ b/backend/src/auth/oidcRoutes.ts @@ -0,0 +1,543 @@ +import crypto from "crypto"; +import express, { Request, Response } from "express"; +import { Prisma, PrismaClient } from "../generated/client"; +import { generators, Issuer } from "openid-client"; +import { logAuditEvent } from "../utils/audit"; +import { hashTokenForStorage } from "./tokenSecurity"; + +const OIDC_FLOW_COOKIE_NAME = "excalidash-oidc-flow"; +const OIDC_PROVIDER_KEY = "oidc"; +const OIDC_FLOW_TTL_MS = 10 * 60 * 1000; + +type OidcFlowPayload = { + state: string; + nonce: string; + codeVerifier: string; + returnTo: string; + expiresAt: number; +}; + +type OidcUser = { + id: string; + username: string | null; + email: string; + name: string; + role: string; + mustResetPassword: boolean; + isActive: boolean; +}; + +type RegisterOidcRoutesDeps = { + router: express.Router; + prisma: PrismaClient; + ensureAuthEnabled: (res: Response) => Promise; + sanitizeText: (input: unknown, maxLength?: number) => string; + generateTokens: ( + userId: string, + email: string, + options?: { impersonatorId?: string } + ) => { accessToken: string; refreshToken: string }; + setAuthCookies: ( + req: Request, + res: Response, + tokens: { accessToken: string; refreshToken: string } + ) => void; + getRefreshTokenExpiresAt: () => Date; + isMissingRefreshTokenTableError: (error: unknown) => boolean; + config: { + authMode: "local" | "hybrid" | "oidc_enforced"; + jwtSecret: string; + enableRefreshTokenRotation: boolean; + enableAuditLogging: boolean; + oidc: { + 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 requestUsesHttps = (req: Request): boolean => { + if (req.secure) return true; + const forwardedProto = req.headers["x-forwarded-proto"]; + const raw = Array.isArray(forwardedProto) ? forwardedProto[0] : forwardedProto; + const firstHop = String(raw || "") + .split(",")[0] + .trim() + .toLowerCase(); + return firstHop === "https"; +}; + +const normalizeEmail = (value: string): string => value.trim().toLowerCase(); + +const sanitizeReturnTo = (rawValue: unknown): string => { + if (typeof rawValue !== "string") return "/"; + const value = rawValue.trim(); + if (!value.startsWith("/")) return "/"; + if (value.startsWith("//")) return "/"; + if (/[\r\n]/.test(value)) return "/"; + if (value.length > 2048) return "/"; + return value; +}; + +const base64UrlEncode = (value: Buffer | string): string => { + const buffer = typeof value === "string" ? Buffer.from(value, "utf8") : value; + return buffer + .toString("base64") + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/g, ""); +}; + +const base64UrlDecode = (value: string): Buffer => { + const normalized = value.replace(/-/g, "+").replace(/_/g, "/"); + const padded = normalized + "=".repeat((4 - (normalized.length % 4)) % 4); + return Buffer.from(padded, "base64"); +}; + +const signFlowPayload = (encodedPayload: string, secret: string): string => + base64UrlEncode( + crypto.createHmac("sha256", secret).update(encodedPayload, "utf8").digest() + ); + +const encodeFlowPayload = (payload: OidcFlowPayload, secret: string): string => { + const encodedPayload = base64UrlEncode(JSON.stringify(payload)); + const signature = signFlowPayload(encodedPayload, secret); + return `${encodedPayload}.${signature}`; +}; + +const decodeFlowPayload = ( + cookieValue: string | null, + secret: string +): OidcFlowPayload | null => { + if (!cookieValue) return null; + const [encodedPayload, providedSignature] = cookieValue.split("."); + if (!encodedPayload || !providedSignature) return null; + + try { + const expectedSignature = signFlowPayload(encodedPayload, secret); + const expectedBuffer = Buffer.from(expectedSignature, "utf8"); + const providedBuffer = Buffer.from(providedSignature, "utf8"); + if (expectedBuffer.length !== providedBuffer.length) return null; + if (!crypto.timingSafeEqual(expectedBuffer, providedBuffer)) return null; + + const parsed = JSON.parse(base64UrlDecode(encodedPayload).toString("utf8")) as Partial; + if ( + typeof parsed.state !== "string" || + typeof parsed.nonce !== "string" || + typeof parsed.codeVerifier !== "string" || + typeof parsed.returnTo !== "string" || + typeof parsed.expiresAt !== "number" + ) { + return null; + } + + if (Date.now() > parsed.expiresAt) return null; + return { + state: parsed.state, + nonce: parsed.nonce, + codeVerifier: parsed.codeVerifier, + returnTo: sanitizeReturnTo(parsed.returnTo), + expiresAt: parsed.expiresAt, + }; + } catch { + return null; + } +}; + +const readStringClaim = (claims: Record, key: string): string | null => { + const value = claims[key]; + if (typeof value !== "string") return null; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +}; + +const readBooleanClaim = (claims: Record, key: string): boolean | null => { + const value = claims[key]; + if (typeof value === "boolean") return value; + if (typeof value === "string") { + const normalized = value.trim().toLowerCase(); + if (normalized === "true" || normalized === "1") return true; + if (normalized === "false" || normalized === "0") return false; + } + return null; +}; + +const getOidcErrorMessage = (errorCode: string): string => { + switch (errorCode) { + case "missing_flow": + return "Missing or expired OIDC login flow. Please try again."; + case "provider_error": + return "OIDC provider returned an error."; + case "missing_subject": + return "OIDC response is missing required subject claim."; + case "missing_email": + return "OIDC response is missing required email claim."; + case "unverified_email": + return "OIDC account email is not verified."; + case "account_inactive": + return "Your account is inactive."; + case "provisioning_disabled": + return "No account found and automatic provisioning is disabled."; + case "callback_failed": + return "OIDC callback validation failed."; + default: + return "OIDC sign-in failed."; + } +}; + +export const registerOidcRoutes = (deps: RegisterOidcRoutesDeps) => { + const { + router, + prisma, + ensureAuthEnabled, + sanitizeText, + generateTokens, + setAuthCookies, + getRefreshTokenExpiresAt, + isMissingRefreshTokenTableError, + config, + } = deps; + + if (!config.oidc.enabled) { + return; + } + + let oidcClientPromise: Promise | null = null; + + const getOidcClient = async () => { + if (!config.oidc.issuerUrl || !config.oidc.clientId || !config.oidc.clientSecret) { + throw new Error("OIDC is enabled but provider configuration is incomplete"); + } + if (!oidcClientPromise) { + oidcClientPromise = (async () => { + const issuer = await Issuer.discover(config.oidc.issuerUrl as string); + return new issuer.Client({ + client_id: config.oidc.clientId as string, + client_secret: config.oidc.clientSecret as string, + redirect_uris: [config.oidc.redirectUri as string], + response_types: ["code"], + }); + })(); + } + + try { + return await oidcClientPromise; + } catch (error) { + oidcClientPromise = null; + throw error; + } + }; + + const clearOidcFlowCookie = (req: Request, res: Response) => { + res.clearCookie(OIDC_FLOW_COOKIE_NAME, { + httpOnly: true, + sameSite: "lax", + secure: requestUsesHttps(req), + path: "/", + }); + }; + + const setOidcFlowCookie = (req: Request, res: Response, payload: OidcFlowPayload) => { + const encoded = encodeFlowPayload(payload, config.jwtSecret); + res.cookie(OIDC_FLOW_COOKIE_NAME, encoded, { + httpOnly: true, + sameSite: "lax", + secure: requestUsesHttps(req), + path: "/", + maxAge: OIDC_FLOW_TTL_MS, + }); + }; + + const redirectToLoginWithError = ( + req: Request, + res: Response, + errorCode: string, + returnTo?: string + ) => { + const search = new URLSearchParams(); + search.set("oidcError", errorCode); + search.set("oidcErrorMessage", getOidcErrorMessage(errorCode)); + if (returnTo) { + search.set("returnTo", sanitizeReturnTo(returnTo)); + } + + clearOidcFlowCookie(req, res); + return res.redirect(`/login?${search.toString()}`); + }; + + const userSelect = { + id: true, + username: true, + email: true, + name: true, + role: true, + mustResetPassword: true, + isActive: true, + } as const; + + const ensureTrashCollection = async ( + tx: Prisma.TransactionClient, + userId: string + ) => { + const trashCollectionId = `trash:${userId}`; + const existingTrash = await tx.collection.findFirst({ + where: { id: trashCollectionId, userId }, + select: { id: true }, + }); + if (!existingTrash) { + await tx.collection.create({ + data: { + id: trashCollectionId, + name: "Trash", + userId, + }, + }); + } + }; + + router.get("/oidc/start", async (req: Request, res: Response) => { + try { + if (!(await ensureAuthEnabled(res))) return; + const client = await getOidcClient(); + const state = generators.state(); + const nonce = generators.nonce(); + const codeVerifier = generators.codeVerifier(); + const codeChallenge = generators.codeChallenge(codeVerifier); + const returnTo = sanitizeReturnTo(req.query.returnTo); + + setOidcFlowCookie(req, res, { + state, + nonce, + codeVerifier, + returnTo, + expiresAt: Date.now() + OIDC_FLOW_TTL_MS, + }); + + const authorizationUrl = client.authorizationUrl({ + scope: config.oidc.scopes, + response_type: "code", + state, + nonce, + code_challenge: codeChallenge, + code_challenge_method: "S256", + }); + + return res.redirect(authorizationUrl); + } catch (error) { + console.error("OIDC start error:", error); + return redirectToLoginWithError(req, res, "callback_failed"); + } + }); + + router.get("/oidc/callback", async (req: Request, res: Response) => { + const cookieValue = (() => { + const cookieHeader = req.headers.cookie; + if (!cookieHeader) return null; + for (const part of cookieHeader.split(";")) { + const [rawKey, ...rawValueParts] = part.split("="); + if (!rawKey || rawValueParts.length === 0) continue; + if (rawKey.trim() !== OIDC_FLOW_COOKIE_NAME) continue; + const rawValue = rawValueParts.join("=").trim(); + try { + return decodeURIComponent(rawValue); + } catch { + return rawValue; + } + } + return null; + })(); + const flow = decodeFlowPayload(cookieValue, config.jwtSecret); + clearOidcFlowCookie(req, res); + + if (!flow) { + return redirectToLoginWithError(req, res, "missing_flow"); + } + + try { + if (!(await ensureAuthEnabled(res))) return; + + if (typeof req.query.error === "string") { + return redirectToLoginWithError(req, res, "provider_error", flow.returnTo); + } + + const client = await getOidcClient(); + const params = client.callbackParams(req); + const tokenSet = await client.callback( + config.oidc.redirectUri as string, + params, + { + state: flow.state, + nonce: flow.nonce, + code_verifier: flow.codeVerifier, + } + ); + const claims = tokenSet.claims() as Record; + const issuer = client.issuer.issuer; + const subject = readStringClaim(claims, "sub"); + if (!subject) { + return redirectToLoginWithError(req, res, "missing_subject", flow.returnTo); + } + + const rawEmail = + readStringClaim(claims, config.oidc.emailClaim) ?? + readStringClaim(claims, "email"); + if (!rawEmail) { + return redirectToLoginWithError(req, res, "missing_email", flow.returnTo); + } + const normalizedEmail = normalizeEmail(rawEmail); + + const emailVerified = readBooleanClaim(claims, config.oidc.emailVerifiedClaim); + if (config.oidc.requireEmailVerified && emailVerified !== true) { + return redirectToLoginWithError(req, res, "unverified_email", flow.returnTo); + } + + const user = await prisma.$transaction(async (tx) => { + const linkedIdentity = await tx.authIdentity.findUnique({ + where: { + issuer_subject: { + issuer, + subject, + }, + }, + include: { + user: { + select: userSelect, + }, + }, + }); + if (linkedIdentity) { + await tx.authIdentity.update({ + where: { id: linkedIdentity.id }, + data: { + lastLoginAt: new Date(), + emailAtLink: normalizedEmail, + }, + }); + return linkedIdentity.user; + } + + const existingUser = await tx.user.findUnique({ + where: { email: normalizedEmail }, + select: userSelect, + }); + + if (existingUser && !existingUser.isActive) { + return existingUser; + } + + let resolvedUser: OidcUser; + if (existingUser) { + resolvedUser = existingUser; + } else { + if (!config.oidc.jitProvisioning) { + throw new Error("OIDC provisioning disabled"); + } + + const activeUsers = await tx.user.count({ where: { isActive: true } }); + const defaultName = + readStringClaim(claims, "name") ?? + readStringClaim(claims, "preferred_username") ?? + normalizedEmail.split("@")[0] ?? + "User"; + const sanitizedName = sanitizeText(defaultName, 100) || "User"; + const role = + activeUsers === 0 && config.oidc.firstUserAdmin ? "ADMIN" : "USER"; + + resolvedUser = await tx.user.create({ + data: { + email: normalizedEmail, + username: null, + passwordHash: "", + name: sanitizedName, + role, + mustResetPassword: false, + isActive: true, + }, + select: userSelect, + }); + + await ensureTrashCollection(tx, resolvedUser.id); + } + + await tx.authIdentity.create({ + data: { + userId: resolvedUser.id, + provider: OIDC_PROVIDER_KEY, + issuer, + subject, + emailAtLink: normalizedEmail, + lastLoginAt: new Date(), + }, + }); + + return resolvedUser; + }); + + if (!user.isActive) { + return redirectToLoginWithError(req, res, "account_inactive", flow.returnTo); + } + + const { accessToken, refreshToken } = generateTokens(user.id, user.email); + setAuthCookies(req, res, { accessToken, refreshToken }); + + if (config.enableRefreshTokenRotation) { + const expiresAt = getRefreshTokenExpiresAt(); + try { + await prisma.refreshToken.create({ + data: { + userId: user.id, + token: hashTokenForStorage(refreshToken), + expiresAt, + }, + }); + } catch (error) { + if (isMissingRefreshTokenTableError(error)) { + return redirectToLoginWithError(req, res, "callback_failed", flow.returnTo); + } + throw error; + } + } + + if (config.enableAuditLogging) { + await logAuditEvent({ + userId: user.id, + action: "oidc_login", + ipAddress: req.ip || req.connection.remoteAddress || undefined, + userAgent: req.headers["user-agent"] || undefined, + details: { + provider: config.oidc.providerName, + issuer, + }, + }); + } + + return res.redirect(flow.returnTo || "/"); + } catch (error) { + if ( + error instanceof Error && + /OIDC provisioning disabled/i.test(error.message) + ) { + return redirectToLoginWithError(req, res, "provisioning_disabled", flow.returnTo); + } + + if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") { + // Retry path for identity creation race: redirect and let user retry once. + return redirectToLoginWithError(req, res, "callback_failed", flow.returnTo); + } + + console.error("OIDC callback error:", error); + return redirectToLoginWithError(req, res, "callback_failed", flow.returnTo); + } + }); +}; diff --git a/backend/src/config.ts b/backend/src/config.ts index 7aa0118..99d395c 100644 --- a/backend/src/config.ts +++ b/backend/src/config.ts @@ -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"); diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index ec3ba12..0a6a8f7 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -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: diff --git a/docker-compose.yml b/docker-compose.yml index 48b458b..bc75cc3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 329525d..7338c1a 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -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 => { 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 => { }; 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') { diff --git a/frontend/src/components/ImpersonationBanner.tsx b/frontend/src/components/ImpersonationBanner.tsx index f685fae..bce0493 100644 --- a/frontend/src/components/ImpersonationBanner.tsx +++ b/frontend/src/components/ImpersonationBanner.tsx @@ -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('/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 ( -
-
-
-
- - Impersonating: +
+
+
+
+ + Impersonating
-
- {impersonation.target.name} ({impersonation.target.email}) -
-
- Acting as this account. Stop to return to {impersonation.impersonator.email}. +
+ + {impersonation.target.name} + + + {impersonation.target.email} +
-
- +
+
+ Switch: +
@@ -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" > - - Stop + + Stop
{(loadingTargets || error) && ( -
+
{loadingTargets ? ( - - - Loading users... + + + Syncing targets... ) : null} - {error ? {error} : null} + {error ? {error} : null} {error ? ( diff --git a/frontend/src/components/ProtectedRoute.tsx b/frontend/src/components/ProtectedRoute.tsx index 07b66f2..541a8cf 100644 --- a/frontend/src/components/ProtectedRoute.tsx +++ b/frontend/src/components/ProtectedRoute.tsx @@ -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 = ({ children }) => { isAuthenticated, loading, authEnabled, + oidcEnforced, bootstrapRequired, authOnboardingRequired, user, } = useAuth(); + const OidcRedirect: React.FC<{ returnTo: string }> = ({ returnTo }) => { + useEffect(() => { + startOidcSignIn(returnTo); + }, [returnTo]); + + return ( +
+
Redirecting to sign-in...
+
+ ); + }; + if (loading || authEnabled === null) { return (
@@ -39,6 +53,10 @@ export const ProtectedRoute: React.FC = ({ children }) => { if (bootstrapRequired) { return ; } + if (oidcEnforced) { + const returnTo = `${location.pathname}${location.search}${location.hash}`; + return ; + } return ; } diff --git a/frontend/src/context/AuthContext.tsx b/frontend/src/context/AuthContext.tsx index b9efb26..4f27bdc 100644 --- a/frontend/src/context/AuthContext.tsx +++ b/frontend/src/context/AuthContext.tsx @@ -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(null); const [loading, setLoading] = useState(true); const [authEnabled, setAuthEnabled] = useState(null); + const [authMode, setAuthMode] = useState<'local' | 'hybrid' | 'oidc_enforced'>('local'); + const [oidcEnabled, setOidcEnabled] = useState(false); + const [oidcEnforced, setOidcEnforced] = useState(false); + const [oidcProvider, setOidcProvider] = useState(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, diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx index dd0fb8c..36064d6 100644 --- a/frontend/src/pages/Login.tsx +++ b/frontend/src/pages/Login.tsx @@ -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 = () => {

- {mustReset ? 'Reset your password' : 'Sign in to your account'} + {mustReset + ? 'Reset your password' + : oidcEnforced + ? `Sign in with ${oidcProvider || 'OIDC'}` + : 'Sign in to your account'}

- {!mustReset ? ( + {!mustReset && !oidcEnforced ? (

Or{' '} { create a new account

- ) : ( + ) : mustReset ? (

Your admin requires you to set a new password before using ExcaliDash.

+ ) : ( +

+ You will be redirected to {oidcProvider || 'your identity provider'}. +

)}
@@ -138,8 +174,19 @@ export const Login: React.FC = () => {
{error}
)} -
- {!mustReset ? ( + {oidcEnforced && !mustReset ? ( +
+ +
+ ) : ( +
+ {!mustReset ? ( <>
- ) : ( + ) : ( <>
- )} -
+ )} +
+ )} - {!mustReset && ( + {!mustReset && !oidcEnforced && (
{
)} -
- -
+ {(!oidcEnforced || mustReset) && ( +
+ +
+ )} + + {!mustReset && oidcEnabled && !oidcEnforced && ( +
+ +
+ )} {mustReset && (
diff --git a/frontend/src/pages/Register.tsx b/frontend/src/pages/Register.tsx index 7af07a6..dfa7a6e 100644 --- a/frontend/src/pages/Register.tsx +++ b/frontend/src/pages/Register.tsx @@ -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();