fix impersonation issues
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Generated
+63
-2
@@ -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",
|
||||||
|
|||||||
@@ -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");
|
||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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");
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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') {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
Reference in New Issue
Block a user