From 0c4468a004c228e860912dd8e3e662d270d3d113 Mon Sep 17 00:00:00 2001 From: SnapOtter Date: Sat, 13 Jun 2026 22:32:16 +0800 Subject: [PATCH] feat(enterprise): add SSO enforcement mode with break-glass admin --- apps/api/src/index.ts | 3 +++ apps/api/src/plugins/auth.ts | 22 ++++++++++++++++++++ apps/web/src/hooks/use-auth.ts | 5 +++++ apps/web/src/pages/login-page.tsx | 34 ++++++++++++++++++++++++++++--- packages/shared/src/i18n/ar.ts | 1 + packages/shared/src/i18n/de.ts | 1 + packages/shared/src/i18n/en.ts | 1 + packages/shared/src/i18n/es.ts | 1 + packages/shared/src/i18n/fr.ts | 1 + packages/shared/src/i18n/hi.ts | 1 + packages/shared/src/i18n/id.ts | 1 + packages/shared/src/i18n/it.ts | 1 + packages/shared/src/i18n/ja.ts | 1 + packages/shared/src/i18n/ko.ts | 1 + packages/shared/src/i18n/nl.ts | 1 + packages/shared/src/i18n/pl.ts | 1 + packages/shared/src/i18n/pt-BR.ts | 1 + packages/shared/src/i18n/ru.ts | 1 + packages/shared/src/i18n/sv.ts | 1 + packages/shared/src/i18n/th.ts | 1 + packages/shared/src/i18n/tr.ts | 1 + packages/shared/src/i18n/uk.ts | 1 + packages/shared/src/i18n/vi.ts | 1 + packages/shared/src/i18n/zh-CN.ts | 1 + packages/shared/src/i18n/zh-TW.ts | 1 + 25 files changed, 82 insertions(+), 3 deletions(-) diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 7f148cb5..25a6e6ee 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -21,6 +21,7 @@ import { shouldRunStartupCleanup } from "./lib/cleanup.js"; import { buildCsp } from "./lib/csp.js"; import { ensureAiDirs, recoverInterruptedInstalls } from "./lib/feature-status.js"; +import { getSettingString } from "./lib/settings-helpers.js"; import { requirePermission } from "./permissions.js"; import { authMiddleware, @@ -438,6 +439,8 @@ app.get("/api/v1/config/auth", async () => { config.samlLoginUrl = "/api/auth/saml/login"; } + config.ssoEnforced = (await getSettingString("ssoEnforcement", "false")) === "true"; + return config; }); diff --git a/apps/api/src/plugins/auth.ts b/apps/api/src/plugins/auth.ts index 67dc6d6e..7a14732b 100644 --- a/apps/api/src/plugins/auth.ts +++ b/apps/api/src/plugins/auth.ts @@ -283,6 +283,28 @@ export async function authRoutes(app: FastifyInstance): Promise { return reply.status(403).send({ error: "Authentication is disabled" }); } + // SSO enforcement check + const ssoEnforced = await getSettingString("ssoEnforcement", "false"); + if (ssoEnforced === "true") { + let isEnabled = false; + try { + const { isFeatureEnabled } = await import("@snapotter/enterprise"); + isEnabled = isFeatureEnabled("sso_enforcement"); + } catch {} + + if (isEnabled) { + const breakGlassUsername = await getSettingString("ssoBreakGlassUsername", ""); + const { username } = loginSchema.parse(request.body); + + if (username !== breakGlassUsername) { + return reply.status(403).send({ + error: "Local password login is disabled. Please use SSO.", + code: "SSO_ENFORCED", + }); + } + } + } + const parsed = loginSchema.safeParse(request.body); if (!parsed.success) { return reply.status(400).send({ error: "Username and password are required" }); diff --git a/apps/web/src/hooks/use-auth.ts b/apps/web/src/hooks/use-auth.ts index a46ee62a..c97b214b 100644 --- a/apps/web/src/hooks/use-auth.ts +++ b/apps/web/src/hooks/use-auth.ts @@ -16,6 +16,7 @@ interface AuthState { oidcProviderName: string | null; samlEnabled: boolean; samlProviderName: string | null; + ssoEnforced: boolean; loginMethod: string | null; hasLocalPassword: boolean; } @@ -52,6 +53,7 @@ export function useAuth() { oidcProviderName: null, samlEnabled: false, samlProviderName: null, + ssoEnforced: false, loginMethod: null, hasLocalPassword: false, }); @@ -80,6 +82,7 @@ export function useAuth() { oidcProviderName: null, samlEnabled: false, samlProviderName: null, + ssoEnforced: false, loginMethod: null, hasLocalPassword: false, }); @@ -110,6 +113,7 @@ export function useAuth() { oidcProviderName: config.oidcProviderName ?? null, samlEnabled: config.samlEnabled ?? false, samlProviderName: config.samlProviderName ?? null, + ssoEnforced: config.ssoEnforced ?? false, loginMethod: session.user?.loginMethod ?? null, hasLocalPassword: session.user?.hasLocalPassword ?? false, }); @@ -130,6 +134,7 @@ export function useAuth() { oidcProviderName: config.oidcProviderName ?? null, samlEnabled: config.samlEnabled ?? false, samlProviderName: config.samlProviderName ?? null, + ssoEnforced: config.ssoEnforced ?? false, loginMethod: null, hasLocalPassword: false, }); diff --git a/apps/web/src/pages/login-page.tsx b/apps/web/src/pages/login-page.tsx index da85a24a..3c541c89 100644 --- a/apps/web/src/pages/login-page.tsx +++ b/apps/web/src/pages/login-page.tsx @@ -127,7 +127,7 @@ function LanguageSelector() { export function LoginPage() { const { t } = useTranslation(); - const { oidcEnabled, oidcProviderName, samlEnabled, samlProviderName } = useAuth(); + const { oidcEnabled, oidcProviderName, samlEnabled, samlProviderName, ssoEnforced } = useAuth(); const [searchParams] = useSearchParams(); const [username, setUsername] = useState(""); const [password, setPassword] = useState(""); @@ -191,7 +191,35 @@ export function LoginPage() {

{t.auth.login}

-
+ {ssoEnforced && (oidcEnabled || samlEnabled) && ( +
+ {oidcEnabled && ( + + {format(t.auth.signInWith, { provider: oidcProviderName || "SSO" })} + + )} + {samlEnabled && ( + + {format(t.auth.signInWith, { provider: samlProviderName || "SSO" })} + + )} +
+
+ {t.auth.or} +
+
+

+ {t.auth.ssoEnforcedLocalRestricted} +

+
+ )} +