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. 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 # Development
## Clone the Repository ## Clone the Repository
+14
View File
@@ -5,6 +5,7 @@ DATABASE_URL=file:/app/prisma/dev.db
FRONTEND_URL=http://localhost:6767 FRONTEND_URL=http://localhost:6767
# Keep disabled unless traffic always comes through a trusted reverse proxy. # Keep disabled unless traffic always comes through a trusted reverse proxy.
TRUST_PROXY=false TRUST_PROXY=false
AUTH_MODE=local
JWT_SECRET=change-this-secret-in-production-min-32-chars JWT_SECRET=change-this-secret-in-production-min-32-chars
# Optional Feature Flags (all default to false for backward compatibility) # 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_PASSWORD_RESET=false
# ENABLE_REFRESH_TOKEN_ROTATION=false # ENABLE_REFRESH_TOKEN_ROTATION=false
# ENABLE_AUDIT_LOGGING=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", "name": "backend",
"version": "0.4.1", "version": "0.4.6",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "backend", "name": "backend",
"version": "0.4.1", "version": "0.4.6",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"@prisma/client": "^5.22.0", "@prisma/client": "^5.22.0",
@@ -24,6 +24,7 @@
"jszip": "^3.10.1", "jszip": "^3.10.1",
"ms": "^2.1.3", "ms": "^2.1.3",
"multer": "^2.0.2", "multer": "^2.0.2",
"openid-client": "^5.7.1",
"prisma": "^5.22.0", "prisma": "^5.22.0",
"socket.io": "^4.8.1", "socket.io": "^4.8.1",
"uuid": "^13.0.0", "uuid": "^13.0.0",
@@ -3314,6 +3315,15 @@
"@pkgjs/parseargs": "^0.11.0" "@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": { "node_modules/jsdom": {
"version": "22.1.0", "version": "22.1.0",
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-22.1.0.tgz", "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-22.1.0.tgz",
@@ -3909,6 +3919,15 @@
"node": ">=0.10.0" "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": { "node_modules/object-inspect": {
"version": "1.13.4", "version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
@@ -3932,6 +3951,15 @@
], ],
"license": "MIT" "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": { "node_modules/on-finished": {
"version": "2.4.1", "version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
@@ -3953,6 +3981,33 @@
"wrappy": "1" "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": { "node_modules/package-json-from-dist": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", "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": ">=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": { "node_modules/yn": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz",
+1
View File
@@ -34,6 +34,7 @@
"jszip": "^3.10.1", "jszip": "^3.10.1",
"ms": "^2.1.3", "ms": "^2.1.3",
"multer": "^2.0.2", "multer": "^2.0.2",
"openid-client": "^5.7.1",
"prisma": "^5.22.0", "prisma": "^5.22.0",
"socket.io": "^4.8.1", "socket.io": "^4.8.1",
"uuid": "^13.0.0", "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") role String @default("USER")
mustResetPassword Boolean @default(false) mustResetPassword Boolean @default(false)
isActive Boolean @default(true) isActive Boolean @default(true)
authIdentities AuthIdentity[]
drawings Drawing[] drawings Drawing[]
collections Collection[] collections Collection[]
passwordResetTokens PasswordResetToken[] passwordResetTokens PasswordResetToken[]
@@ -111,3 +112,20 @@ model AuditLog {
details String? // JSON string for additional details details String? // JSON string for additional details
createdAt DateTime @default(now()) 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 { registerAccountRoutes } from "./auth/accountRoutes";
import { registerAdminRoutes } from "./auth/adminRoutes"; import { registerAdminRoutes } from "./auth/adminRoutes";
import { registerCoreRoutes } from "./auth/coreRoutes"; import { registerCoreRoutes } from "./auth/coreRoutes";
import { registerOidcRoutes } from "./auth/oidcRoutes";
import { prisma as defaultPrisma } from "./db/prisma"; import { prisma as defaultPrisma } from "./db/prisma";
import { import {
BOOTSTRAP_USER_ID, BOOTSTRAP_USER_ID,
@@ -63,7 +64,9 @@ export const createAuthRouter = (deps: CreateAuthRouterDeps): express.Router =>
const ensureAuthEnabled = async (res: Response): Promise<boolean> => { const ensureAuthEnabled = async (res: Response): Promise<boolean> => {
const systemConfig = await ensureSystemConfig(); const systemConfig = await ensureSystemConfig();
if (!systemConfig.authEnabled) { const authEnabled =
config.authMode !== "local" ? true : systemConfig.authEnabled;
if (!authEnabled) {
res.status(404).json({ res.status(404).json({
error: "Not found", error: "Not found",
message: "Authentication is disabled", message: "Authentication is disabled",
@@ -368,6 +371,18 @@ export const createAuthRouter = (deps: CreateAuthRouterDeps): express.Router =>
const getRefreshTokenExpiresAt = (): Date => const getRefreshTokenExpiresAt = (): Date =>
resolveExpiresAt(config.jwtRefreshExpiresIn, 7 * 24 * 60 * 60 * 1000); resolveExpiresAt(config.jwtRefreshExpiresIn, 7 * 24 * 60 * 60 * 1000);
registerOidcRoutes({
router,
prisma,
ensureAuthEnabled,
sanitizeText,
generateTokens,
setAuthCookies,
getRefreshTokenExpiresAt,
isMissingRefreshTokenTableError,
config,
});
registerCoreRoutes({ registerCoreRoutes({
router, router,
prisma, prisma,
+8 -1
View File
@@ -1,4 +1,5 @@
import { PrismaClient } from "../generated/client"; import { PrismaClient } from "../generated/client";
import { config } from "../config";
export const BOOTSTRAP_USER_ID = "bootstrap-admin"; export const BOOTSTRAP_USER_ID = "bootstrap-admin";
export const DEFAULT_SYSTEM_CONFIG_ID = "default"; export const DEFAULT_SYSTEM_CONFIG_ID = "default";
@@ -30,7 +31,7 @@ export const createAuthModeService = (
update: {}, update: {},
create: { create: {
id: DEFAULT_SYSTEM_CONFIG_ID, id: DEFAULT_SYSTEM_CONFIG_ID,
authEnabled: false, authEnabled: config.authMode !== "local",
authOnboardingCompleted: false, authOnboardingCompleted: false,
registrationEnabled: false, registrationEnabled: false,
authLoginRateLimitEnabled: true, authLoginRateLimitEnabled: true,
@@ -41,6 +42,12 @@ export const createAuthModeService = (
}; };
const getAuthEnabled = async (): Promise<boolean> => { const getAuthEnabled = async (): Promise<boolean> => {
if (config.authMode !== "local") {
const now = Date.now();
authEnabledCache = { value: true, fetchedAt: now };
return true;
}
const now = Date.now(); const now = Date.now();
if (authEnabledCache && now - authEnabledCache.fetchedAt < authEnabledTtlMs) { if (authEnabledCache && now - authEnabledCache.fetchedAt < authEnabledTtlMs) {
return authEnabledCache.value; return authEnabledCache.value;
+50 -7
View File
@@ -44,10 +44,16 @@ type RegisterCoreRoutesDeps = {
impersonatorId?: string; impersonatorId?: string;
}; };
config: { config: {
authMode: "local" | "hybrid" | "oidc_enforced";
jwtSecret: string; jwtSecret: string;
jwtAccessExpiresIn: string; jwtAccessExpiresIn: string;
enableRefreshTokenRotation: boolean; enableRefreshTokenRotation: boolean;
enableAuditLogging: boolean; enableAuditLogging: boolean;
oidc: {
enabled: boolean;
enforced: boolean;
providerName: string;
};
}; };
generateTokens: ( generateTokens: (
userId: string, userId: string,
@@ -151,6 +157,12 @@ export const registerCoreRoutes = (deps: RegisterCoreRoutesDeps) => {
router.post("/register", loginAttemptRateLimiter, async (req: Request, res: Response) => { router.post("/register", loginAttemptRateLimiter, async (req: Request, res: Response) => {
try { try {
if (!(await ensureAuthEnabled(res))) return; 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; if (!requireCsrf(req, res)) return;
const parsed = registerSchema.safeParse(req.body); const parsed = registerSchema.safeParse(req.body);
@@ -415,6 +427,12 @@ export const registerCoreRoutes = (deps: RegisterCoreRoutesDeps) => {
router.post("/login", loginAttemptRateLimiter, async (req: Request, res: Response) => { router.post("/login", loginAttemptRateLimiter, async (req: Request, res: Response) => {
try { try {
if (!(await ensureAuthEnabled(res))) return; 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); const parsed = loginSchema.safeParse(req.body);
if (!parsed.success) { if (!parsed.success) {
@@ -835,16 +853,24 @@ export const registerCoreRoutes = (deps: RegisterCoreRoutesDeps) => {
try { try {
const systemConfig = await ensureSystemConfig(); const systemConfig = await ensureSystemConfig();
const onboarding = await getAuthOnboardingStatus(systemConfig); 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({ return res.json({
enabled: false, enabled: false,
authenticated: false, authenticated: false,
authEnabled: false, authEnabled: false,
authMode: config.authMode,
oidcEnabled: config.oidc.enabled,
oidcEnforced: config.oidc.enforced,
oidcProvider: config.oidc.providerName,
registrationEnabled: false, registrationEnabled: false,
bootstrapRequired: false, bootstrapRequired: false,
authOnboardingRequired: onboarding.needsChoice, authOnboardingRequired: onboardingRequired,
authOnboardingMode: onboarding.mode, authOnboardingMode: onboardingMode,
authOnboardingRecommended: onboarding.needsChoice ? "enable" : null, authOnboardingRecommended: onboardingRequired ? "enable" : null,
user: null, user: null,
}); });
} }
@@ -854,18 +880,23 @@ export const registerCoreRoutes = (deps: RegisterCoreRoutesDeps) => {
select: { id: true, isActive: true }, select: { id: true, isActive: true },
}); });
const bootstrapRequired = const bootstrapRequired =
!config.oidc.enforced &&
Boolean(bootstrapUser && bootstrapUser.isActive === false) && Boolean(bootstrapUser && bootstrapUser.isActive === false) &&
onboarding.activeUsers === 0; onboarding.activeUsers === 0;
res.json({ res.json({
enabled: true, enabled: true,
authEnabled: true, authEnabled: true,
authMode: config.authMode,
oidcEnabled: config.oidc.enabled,
oidcEnforced: config.oidc.enforced,
oidcProvider: config.oidc.providerName,
authenticated: Boolean(req.user), authenticated: Boolean(req.user),
registrationEnabled: systemConfig.registrationEnabled, registrationEnabled: systemConfig.registrationEnabled,
bootstrapRequired, bootstrapRequired,
authOnboardingRequired: onboarding.needsChoice, authOnboardingRequired: onboardingRequired,
authOnboardingMode: onboarding.mode, authOnboardingMode: onboardingMode,
authOnboardingRecommended: onboarding.needsChoice ? "enable" : null, authOnboardingRecommended: onboardingRequired ? "enable" : null,
user: req.user user: req.user
? { ? {
id: req.user.id, id: req.user.id,
@@ -889,6 +920,12 @@ export const registerCoreRoutes = (deps: RegisterCoreRoutesDeps) => {
router.post("/onboarding-choice", optionalAuth, async (req: Request, res: Response) => { router.post("/onboarding-choice", optionalAuth, async (req: Request, res: Response) => {
try { 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; if (!requireCsrf(req, res)) return;
const parsed = authOnboardingChoiceSchema.safeParse(req.body); const parsed = authOnboardingChoiceSchema.safeParse(req.body);
if (!parsed.success) { if (!parsed.success) {
@@ -944,6 +981,12 @@ export const registerCoreRoutes = (deps: RegisterCoreRoutesDeps) => {
router.post("/auth-enabled", requireAuth, async (req: Request, res: Response) => { router.post("/auth-enabled", requireAuth, async (req: Request, res: Response) => {
try { 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 (!requireCsrf(req, res)) return;
if (!req.user) { if (!req.user) {
return res return res
File diff suppressed because it is too large Load Diff
+90
View File
@@ -12,22 +12,49 @@ interface Config {
nodeEnv: string; nodeEnv: string;
databaseUrl?: string; databaseUrl?: string;
frontendUrl?: string; frontendUrl?: string;
authMode: AuthMode;
jwtSecret: string; jwtSecret: string;
jwtAccessExpiresIn: string; jwtAccessExpiresIn: string;
jwtRefreshExpiresIn: string; jwtRefreshExpiresIn: string;
rateLimitMaxRequests: number; rateLimitMaxRequests: number;
csrfMaxRequests: number; csrfMaxRequests: number;
csrfSecret: string | null; csrfSecret: string | null;
oidc: OidcConfig;
// Feature flags - all default to false for backward compatibility // Feature flags - all default to false for backward compatibility
enablePasswordReset: boolean; enablePasswordReset: boolean;
enableRefreshTokenRotation: boolean; enableRefreshTokenRotation: boolean;
enableAuditLogging: 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 => { const getOptionalEnv = (key: string, defaultValue: string): string => {
return process.env[key] || defaultValue; 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 resolveJwtSecret = (nodeEnv: string): string => {
const provided = process.env.JWT_SECRET; const provided = process.env.JWT_SECRET;
if (provided && provided.trim().length > 0) { if (provided && provided.trim().length > 0) {
@@ -99,17 +126,77 @@ const getRequiredEnvNumber = (key: string, defaultValue: number): number => {
return parsed; 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 = { export const config: Config = {
port: getRequiredEnvNumber("PORT", 8000), port: getRequiredEnvNumber("PORT", 8000),
nodeEnv: getOptionalEnv("NODE_ENV", "development"), nodeEnv: getOptionalEnv("NODE_ENV", "development"),
databaseUrl: process.env.DATABASE_URL, databaseUrl: process.env.DATABASE_URL,
frontendUrl: parseFrontendUrl(process.env.FRONTEND_URL), frontendUrl: parseFrontendUrl(process.env.FRONTEND_URL),
authMode: resolvedAuthMode,
jwtSecret: resolveJwtSecret(getOptionalEnv("NODE_ENV", "development")), jwtSecret: resolveJwtSecret(getOptionalEnv("NODE_ENV", "development")),
jwtAccessExpiresIn: getOptionalEnv("JWT_ACCESS_EXPIRES_IN", "15m"), jwtAccessExpiresIn: getOptionalEnv("JWT_ACCESS_EXPIRES_IN", "15m"),
jwtRefreshExpiresIn: getOptionalEnv("JWT_REFRESH_EXPIRES_IN", "7d"), jwtRefreshExpiresIn: getOptionalEnv("JWT_REFRESH_EXPIRES_IN", "7d"),
rateLimitMaxRequests: getRequiredEnvNumber("RATE_LIMIT_MAX_REQUESTS", 1000), rateLimitMaxRequests: getRequiredEnvNumber("RATE_LIMIT_MAX_REQUESTS", 1000),
csrfMaxRequests: getRequiredEnvNumber("CSRF_MAX_REQUESTS", 60), csrfMaxRequests: getRequiredEnvNumber("CSRF_MAX_REQUESTS", 60),
csrfSecret: process.env.CSRF_SECRET || null, csrfSecret: process.env.CSRF_SECRET || null,
oidc: resolveOidcConfig(resolvedAuthMode),
// Feature flags - disabled by default for backward compatibility // Feature flags - disabled by default for backward compatibility
enablePasswordReset: getOptionalBoolean("ENABLE_PASSWORD_RESET", false), enablePasswordReset: getOptionalBoolean("ENABLE_PASSWORD_RESET", false),
enableRefreshTokenRotation: getOptionalBoolean("ENABLE_REFRESH_TOKEN_ROTATION", 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"); 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"); console.log("Configuration validated successfully");
+7
View File
@@ -6,6 +6,7 @@ services:
- DATABASE_URL=file:/app/prisma/dev.db - DATABASE_URL=file:/app/prisma/dev.db
- PORT=8000 - PORT=8000
- NODE_ENV=production - NODE_ENV=production
- AUTH_MODE=${AUTH_MODE:-local}
# Keep disabled by default; only enable when a trusted proxy sanitizes forwarded headers. # Keep disabled by default; only enable when a trusted proxy sanitizes forwarded headers.
- TRUST_PROXY=false - TRUST_PROXY=false
# Optional for single-instance deployments: # Optional for single-instance deployments:
@@ -13,6 +14,12 @@ services:
# Recommended to set explicitly for portability and multi-instance setups. # Recommended to set explicitly for portability and multi-instance setups.
- JWT_SECRET=${JWT_SECRET} - JWT_SECRET=${JWT_SECRET}
- CSRF_SECRET=${CSRF_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: volumes:
- backend-data:/app/prisma - backend-data:/app/prisma
networks: networks:
+7
View File
@@ -8,6 +8,7 @@ services:
- DATABASE_URL=file:/app/prisma/dev.db - DATABASE_URL=file:/app/prisma/dev.db
- PORT=8000 - PORT=8000
- NODE_ENV=production - NODE_ENV=production
- AUTH_MODE=${AUTH_MODE:-local}
# Keep disabled by default; only enable when a trusted proxy sanitizes forwarded headers. # Keep disabled by default; only enable when a trusted proxy sanitizes forwarded headers.
- TRUST_PROXY=false - TRUST_PROXY=false
# Optional for single-instance deployments: # Optional for single-instance deployments:
@@ -15,6 +16,12 @@ services:
# Recommended to set explicitly for portability and multi-instance setups. # Recommended to set explicitly for portability and multi-instance setups.
- JWT_SECRET=${JWT_SECRET} - JWT_SECRET=${JWT_SECRET}
- CSRF_SECRET=${CSRF_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: volumes:
- backend-data:/app/prisma - backend-data:/app/prisma
networks: networks:
+21
View File
@@ -69,6 +69,10 @@ export const clearCsrfToken = (): void => {
export interface AuthStatusResponse { export interface AuthStatusResponse {
authEnabled?: boolean; authEnabled?: boolean;
enabled?: boolean; enabled?: boolean;
authMode?: "local" | "hybrid" | "oidc_enforced";
oidcEnabled?: boolean;
oidcEnforced?: boolean;
oidcProvider?: string;
bootstrapRequired?: boolean; bootstrapRequired?: boolean;
authOnboardingRequired?: boolean; authOnboardingRequired?: boolean;
authOnboardingMode?: "migration" | "fresh"; authOnboardingMode?: "migration" | "fresh";
@@ -92,6 +96,13 @@ export const authStatus = async (): Promise<AuthStatusResponse> => {
return response.data; 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 }> => { export const authMe = async (): Promise<{ user: AuthUser }> => {
const response = await axios.get<{ user: AuthUser }>(`${API_URL}/auth/me`, { const response = await axios.get<{ user: AuthUser }>(`${API_URL}/auth/me`, {
withCredentials: true, withCredentials: true,
@@ -204,6 +215,16 @@ const getAuthEnabledStatus = async (): Promise<boolean | null> => {
}; };
const redirectToLogin = async () => { 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(); const authEnabled = await getAuthEnabledStatus();
if (authEnabled === false) return; if (authEnabled === false) return;
if (window.location.pathname !== '/login') { if (window.location.pathname !== '/login') {
+62 -26
View File
@@ -22,6 +22,13 @@ type ImpersonationTargetsResponse = {
users: ImpersonationTarget[]; users: ImpersonationTarget[];
}; };
type AuthStatusResponse = {
authenticated?: boolean;
user?: {
impersonatorId?: string;
} | null;
};
type ImpersonateResponse = { type ImpersonateResponse = {
user: { user: {
id: string; id: string;
@@ -46,6 +53,11 @@ export const ImpersonationBanner: React.FC = () => {
const [error, setError] = useState(''); const [error, setError] = useState('');
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const clearLocalImpersonation = () => {
localStorage.removeItem(IMPERSONATION_KEY);
setImpersonation(null);
};
useEffect(() => { useEffect(() => {
if (!authEnabled) { if (!authEnabled) {
setImpersonation(null); setImpersonation(null);
@@ -54,6 +66,20 @@ export const ImpersonationBanner: React.FC = () => {
const sync = () => setImpersonation(readImpersonationState()); const sync = () => setImpersonation(readImpersonationState());
sync(); 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); window.addEventListener('storage', sync);
return () => window.removeEventListener('storage', sync); return () => window.removeEventListener('storage', sync);
}, [authEnabled]); }, [authEnabled]);
@@ -116,6 +142,14 @@ export const ImpersonationBanner: React.FC = () => {
let message = 'Failed to stop impersonation'; let message = 'Failed to stop impersonation';
if (isAxiosError(err)) { if (isAxiosError(err)) {
message = err.response?.data?.message || err.response?.data?.error || message; 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); setError(message);
setBusy(false); setBusy(false);
@@ -159,36 +193,38 @@ export const ImpersonationBanner: React.FC = () => {
} }
return ( 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="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-col gap-3 lg:flex-row lg:items-center lg:justify-between"> <div className="flex flex-wrap items-center gap-x-4 gap-y-2">
<div className="min-w-0"> <div className="flex items-center gap-3 min-w-0 flex-1">
<div className="flex items-center gap-2 text-amber-900 dark:text-amber-200"> <div className="flex items-center gap-1.5 text-red-700 dark:text-red-400 flex-shrink-0">
<LogIn size={16} /> <LogIn size={14} strokeWidth={2.5} />
<span className="text-sm font-bold uppercase tracking-wide">Impersonating:</span> <span className="text-[10px] font-black uppercase tracking-wider">Impersonating</span>
</div> </div>
<div className="mt-1 text-sm font-semibold text-amber-900 dark:text-amber-200 truncate"> <div className="flex items-center gap-2 min-w-0">
{impersonation.target.name} ({impersonation.target.email}) <span className="text-sm font-bold text-red-900 dark:text-red-100 truncate">
</div> {impersonation.target.name}
<div className="text-xs text-amber-800/90 dark:text-amber-200/80 truncate"> </span>
Acting as this account. Stop to return to {impersonation.impersonator.email}. <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> </div>
<div className="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-3 lg:flex-shrink-0 lg:justify-end"> <div className="flex items-center gap-2 ml-auto">
<label className="text-xs font-bold uppercase tracking-wide text-amber-900 dark:text-amber-200"> <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 user: Switch:
</label> </div>
<select <select
value={impersonation.target.id} value={impersonation.target.id}
onChange={(e) => { onChange={(e) => {
void switchTarget(e.target.value); void switchTarget(e.target.value);
}} }}
disabled={busy || loadingTargets || options.length === 0} 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) => ( {options.map((target) => (
<option key={target.id} value={target.id}> <option key={target.id} value={target.id}>
{target.name} ({target.email}) {target.name}
</option> </option>
))} ))}
</select> </select>
@@ -196,28 +232,28 @@ export const ImpersonationBanner: React.FC = () => {
type="button" type="button"
onClick={stop} onClick={stop}
disabled={busy} 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} /> <XCircle size={14} strokeWidth={2.5} />
Stop <span className="hidden sm:inline">Stop</span>
</button> </button>
</div> </div>
</div> </div>
{(loadingTargets || error) && ( {(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 ? ( {loadingTargets ? (
<span className="inline-flex items-center gap-1"> <span className="inline-flex items-center gap-1.5">
<RefreshCw size={12} className="animate-spin" /> <RefreshCw size={10} className="animate-spin" />
Loading users... Syncing targets...
</span> </span>
) : null} ) : null}
{error ? <span>{error}</span> : null} {error ? <span className="truncate">{error}</span> : null}
{error ? ( {error ? (
<button <button
type="button" type="button"
onClick={() => void loadTargets()} 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 Retry
</button> </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 { Navigate, useLocation } from 'react-router-dom';
import { useAuth } from '../context/AuthContext'; import { useAuth } from '../context/AuthContext';
import { startOidcSignIn } from '../api';
interface ProtectedRouteProps { interface ProtectedRouteProps {
children: React.ReactNode; children: React.ReactNode;
@@ -12,11 +13,24 @@ export const ProtectedRoute: React.FC<ProtectedRouteProps> = ({ children }) => {
isAuthenticated, isAuthenticated,
loading, loading,
authEnabled, authEnabled,
oidcEnforced,
bootstrapRequired, bootstrapRequired,
authOnboardingRequired, authOnboardingRequired,
user, user,
} = useAuth(); } = 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) { if (loading || authEnabled === null) {
return ( return (
<div className="min-h-screen flex items-center justify-center"> <div className="min-h-screen flex items-center justify-center">
@@ -39,6 +53,10 @@ export const ProtectedRoute: React.FC<ProtectedRouteProps> = ({ children }) => {
if (bootstrapRequired) { if (bootstrapRequired) {
return <Navigate to="/register" replace />; return <Navigate to="/register" replace />;
} }
if (oidcEnforced) {
const returnTo = `${location.pathname}${location.search}${location.hash}`;
return <OidcRedirect returnTo={returnTo} />;
}
return <Navigate to="/login" replace />; return <Navigate to="/login" replace />;
} }
+28
View File
@@ -24,6 +24,10 @@ interface AuthContextType {
user: User | null; user: User | null;
loading: boolean; loading: boolean;
authEnabled: boolean | null; authEnabled: boolean | null;
authMode: 'local' | 'hybrid' | 'oidc_enforced';
oidcEnabled: boolean;
oidcEnforced: boolean;
oidcProvider: string | null;
bootstrapRequired: boolean; bootstrapRequired: boolean;
authOnboardingRequired: boolean; authOnboardingRequired: boolean;
authOnboardingMode: 'migration' | 'fresh' | null; authOnboardingMode: 'migration' | 'fresh' | null;
@@ -42,6 +46,10 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
const [user, setUser] = useState<User | null>(null); const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [authEnabled, setAuthEnabled] = useState<boolean | null>(null); 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 [bootstrapRequired, setBootstrapRequired] = useState(false);
const [authOnboardingRequired, setAuthOnboardingRequired] = useState(false); const [authOnboardingRequired, setAuthOnboardingRequired] = useState(false);
const [authOnboardingMode, setAuthOnboardingMode] = useState<'migration' | 'fresh' | null>(null); const [authOnboardingMode, setAuthOnboardingMode] = useState<'migration' | 'fresh' | null>(null);
@@ -60,6 +68,14 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
: true; : true;
setAuthEnabled(enabled); setAuthEnabled(enabled);
localStorage.setItem(AUTH_ENABLED_CACHE_KEY, String(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)); setBootstrapRequired(Boolean(statusResponse?.bootstrapRequired));
setAuthOnboardingRequired(Boolean(statusResponse?.authOnboardingRequired)); setAuthOnboardingRequired(Boolean(statusResponse?.authOnboardingRequired));
setAuthOnboardingMode( setAuthOnboardingMode(
@@ -77,6 +93,10 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
const cachedAuthEnabled = localStorage.getItem(AUTH_ENABLED_CACHE_KEY); const cachedAuthEnabled = localStorage.getItem(AUTH_ENABLED_CACHE_KEY);
if (cachedAuthEnabled === "false") { if (cachedAuthEnabled === "false") {
setAuthEnabled(false); setAuthEnabled(false);
setAuthMode('local');
setOidcEnabled(false);
setOidcEnforced(false);
setOidcProvider(null);
setBootstrapRequired(false); setBootstrapRequired(false);
setAuthOnboardingRequired(false); setAuthOnboardingRequired(false);
setAuthOnboardingMode(null); setAuthOnboardingMode(null);
@@ -85,6 +105,10 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
return; return;
} }
setAuthEnabled(true); setAuthEnabled(true);
setAuthMode('local');
setOidcEnabled(false);
setOidcEnforced(false);
setOidcProvider(null);
setBootstrapRequired(false); setBootstrapRequired(false);
setAuthOnboardingRequired(false); setAuthOnboardingRequired(false);
setAuthOnboardingMode(null); setAuthOnboardingMode(null);
@@ -192,6 +216,10 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
user, user,
loading, loading,
authEnabled, authEnabled,
authMode,
oidcEnabled,
oidcEnforced,
oidcProvider,
bootstrapRequired, bootstrapRequired,
authOnboardingRequired, authOnboardingRequired,
authOnboardingMode, authOnboardingMode,
+70 -6
View File
@@ -16,6 +16,9 @@ export const Login: React.FC = () => {
login, login,
logout, logout,
authEnabled, authEnabled,
oidcEnabled,
oidcEnforced,
oidcProvider,
bootstrapRequired, bootstrapRequired,
authOnboardingRequired, authOnboardingRequired,
isAuthenticated, isAuthenticated,
@@ -25,8 +28,16 @@ export const Login: React.FC = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const queryMustReset = searchParams.get('mustReset') === '1'; 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; const mustReset = Boolean(user?.mustResetPassword) || queryMustReset;
useEffect(() => {
if (!oidcErrorCode) return;
setError(oidcErrorMessage || 'OIDC sign-in failed');
}, [oidcErrorCode, oidcErrorMessage]);
useEffect(() => { useEffect(() => {
if (authLoading || authEnabled === null) return; if (authLoading || authEnabled === null) return;
if (authOnboardingRequired) { if (authOnboardingRequired) {
@@ -41,11 +52,28 @@ export const Login: React.FC = () => {
navigate('/register', { replace: true }); navigate('/register', { replace: true });
return; return;
} }
if (oidcEnforced && !mustReset) {
if (!oidcErrorCode) {
api.startOidcSignIn(oidcReturnTo);
}
return;
}
if (isAuthenticated) { if (isAuthenticated) {
if (mustReset) return; if (mustReset) return;
navigate('/', { replace: true }); 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) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
@@ -114,9 +142,13 @@ export const Login: React.FC = () => {
<div className="text-center"> <div className="text-center">
<Logo className="mx-auto h-12 w-auto" /> <Logo className="mx-auto h-12 w-auto" />
<h2 className="mt-6 text-3xl font-extrabold text-gray-900 dark:text-white"> <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> </h2>
{!mustReset ? ( {!mustReset && !oidcEnforced ? (
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400"> <p className="mt-2 text-sm text-gray-600 dark:text-gray-400">
Or{' '} Or{' '}
<Link <Link
@@ -126,10 +158,14 @@ export const Login: React.FC = () => {
create a new account create a new account
</Link> </Link>
</p> </p>
) : ( ) : mustReset ? (
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400"> <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. Your admin requires you to set a new password before using ExcaliDash.
</p> </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> </div>
<form className="mt-8 space-y-6" onSubmit={mustReset ? handleMustReset : handleSubmit}> <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 className="text-sm text-red-800 dark:text-red-200">{error}</div>
</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"> <div className="rounded-md shadow-sm -space-y-px">
{!mustReset ? ( {!mustReset ? (
<> <>
@@ -213,8 +260,9 @@ export const Login: React.FC = () => {
</> </>
)} )}
</div> </div>
)}
{!mustReset && ( {!mustReset && !oidcEnforced && (
<div className="flex justify-end"> <div className="flex justify-end">
<Link <Link
to="/reset-password" to="/reset-password"
@@ -225,15 +273,31 @@ export const Login: React.FC = () => {
</div> </div>
)} )}
{(!oidcEnforced || mustReset) && (
<div> <div>
<button <button
type="submit" type="submit"
disabled={loading} 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" 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> </button>
</div> </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 && ( {mustReset && (
<div className="text-center"> <div className="text-center">
+6 -1
View File
@@ -12,6 +12,7 @@ export const Register: React.FC = () => {
const { const {
register, register,
authEnabled, authEnabled,
oidcEnforced,
bootstrapRequired, bootstrapRequired,
authOnboardingRequired, authOnboardingRequired,
isAuthenticated, isAuthenticated,
@@ -25,6 +26,10 @@ export const Register: React.FC = () => {
navigate('/auth-setup', { replace: true }); navigate('/auth-setup', { replace: true });
return; return;
} }
if (oidcEnforced) {
navigate('/login', { replace: true });
return;
}
if (!authEnabled) { if (!authEnabled) {
navigate('/', { replace: true }); navigate('/', { replace: true });
return; return;
@@ -32,7 +37,7 @@ export const Register: React.FC = () => {
if (isAuthenticated) { if (isAuthenticated) {
navigate('/', { replace: true }); navigate('/', { replace: true });
} }
}, [authEnabled, authLoading, authOnboardingRequired, isAuthenticated, navigate]); }, [authEnabled, authLoading, authOnboardingRequired, isAuthenticated, navigate, oidcEnforced]);
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();