diff --git a/docs/images/saml-admin-dashboard.png b/docs/images/saml-admin-dashboard.png new file mode 100644 index 00000000..53d0466e Binary files /dev/null and b/docs/images/saml-admin-dashboard.png differ diff --git a/docs/images/saml-login-screen.png b/docs/images/saml-login-screen.png new file mode 100644 index 00000000..b1995af1 Binary files /dev/null and b/docs/images/saml-login-screen.png differ diff --git a/package.json b/package.json index 2b25bdbf..542a70b6 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "@dnd-kit/utilities": "^3.2.2", "@monaco-editor/react": "^4.7.0", "@next/third-parties": "^16.2.9", + "@node-saml/node-saml": "^5.1.0", "@xyflow/react": "^12.10.1", "bcryptjs": "^3.0.3", "chalk": "^5.6.2", diff --git a/src/app/(dashboard)/dashboard/profile/page.js b/src/app/(dashboard)/dashboard/profile/page.js index 4df8cbef..2a70e19c 100644 --- a/src/app/(dashboard)/dashboard/profile/page.js +++ b/src/app/(dashboard)/dashboard/profile/page.js @@ -21,7 +21,7 @@ function getLocaleFromCookie() { export default function ProfilePage() { const { theme, setTheme, isDark } = useTheme(); - const [locale, setLocale] = useState("en"); + const [locale, setLocale] = useState(() => getLocaleFromCookie()); const [langOpen, setLangOpen] = useState(false); const [shutdownOpen, setShutdownOpen] = useState(false); const [isShuttingDown, setIsShuttingDown] = useState(false); @@ -46,8 +46,31 @@ export default function ProfilePage() { const [oidcLoading, setOidcLoading] = useState(false); const [oidcTestLoading, setOidcTestLoading] = useState(false); const [oidcTestStatus, setOidcTestStatus] = useState({ type: "", message: "" }); - const [oidcRedirectUri, setOidcRedirectUri] = useState("/api/auth/oidc/callback"); const [oidcExpanded, setOidcExpanded] = useState(false); + + const origin = typeof window !== "undefined" ? window.location.origin : ""; + const oidcRedirectUri = origin ? `${origin}/api/auth/oidc/callback` : "/api/auth/oidc/callback"; + const samlAcsUrl = origin ? `${origin}/api/auth/saml/acs` : "/api/auth/saml/acs"; + const samlMetadataUrl = origin ? `${origin}/api/auth/saml/metadata` : "/api/auth/saml/metadata"; + + // SAML State + const [ssoTypeTab, setSsoTypeTab] = useState("saml"); + const [samlForm, setSamlForm] = useState({ + samlEntryPoint: "", + samlIssuer: "urn:9router:sp", + samlCert: "", + samlLoginLabel: "Sign in with SAML SSO", + samlAttributeEmail: "email", + samlAttributeName: "name", + }); + const [samlStatus, setSamlStatus] = useState({ type: "", message: "" }); + const [samlLoading, setSamlLoading] = useState(false); + const [samlTestLoading, setSamlTestLoading] = useState(false); + const [samlTestStatus, setSamlTestStatus] = useState({ type: "", message: "" }); + const [showSamlGuide, setShowSamlGuide] = useState(false); + const idpMetadataFileRef = useRef(null); + const certFileRef = useRef(null); + const importFileRef = useRef(null); const [proxyForm, setProxyForm] = useState({ outboundProxyEnabled: false, @@ -58,10 +81,6 @@ export default function ProfilePage() { const [proxyLoading, setProxyLoading] = useState(false); const [proxyTestLoading, setProxyTestLoading] = useState(false); - useEffect(() => { - setLocale(getLocaleFromCookie()); - }, [langOpen]); - useEffect(() => { fetch("/api/settings") .then((res) => res.json()) @@ -75,7 +94,23 @@ export default function ProfilePage() { oidcLoginLabel: data?.oidcLoginLabel || "Sign in with OIDC", }); setOidcClientSecret(""); - if (data?.authMode === "oidc" || data?.authMode === "both") setOidcExpanded(true); + setSsoTypeTab(data?.ssoType || "saml"); + setSamlForm({ + samlEntryPoint: data?.samlEntryPoint || "", + samlIssuer: data?.samlIssuer || "urn:9router:sp", + samlCert: data?.samlCert || "", + samlLoginLabel: data?.samlLoginLabel || "Sign in with SAML SSO", + samlAttributeEmail: data?.samlAttributeEmail || "email", + samlAttributeName: data?.samlAttributeName || "name", + }); + if ( + data?.authMode === "sso" || + data?.authMode === "saml" || + data?.authMode === "oidc" || + data?.authMode === "both" + ) { + setOidcExpanded(true); + } setProxyForm({ outboundProxyEnabled: data?.outboundProxyEnabled === true, outboundProxyUrl: data?.outboundProxyUrl || "", @@ -89,12 +124,6 @@ export default function ProfilePage() { }); }, []); - useEffect(() => { - if (typeof window !== "undefined") { - setOidcRedirectUri(`${window.location.origin}/api/auth/oidc/callback`); - } - }, []); - const updateOutboundProxy = async (e) => { e.preventDefault(); if (settings.outboundProxyEnabled !== true) return; @@ -331,6 +360,7 @@ export default function ProfilePage() { try { const payload = { authMode, + ssoType: "oidc", oidcIssuerUrl: issuerUrl, oidcClientId: clientId, oidcScopes: scopes || "openid profile email", @@ -445,6 +475,159 @@ export default function ProfilePage() { } }; + const updateSamlForm = (field, value) => { + setSamlForm((prev) => ({ ...prev, [field]: value })); + }; + + const handleIdpMetadataUpload = (event) => { + const file = event.target.files?.[0]; + if (idpMetadataFileRef.current) idpMetadataFileRef.current.value = ""; + if (!file) return; + + const reader = new FileReader(); + reader.onload = (e) => { + try { + const xmlText = e.target?.result || ""; + const parser = new DOMParser(); + const doc = parser.parseFromString(xmlText, "text/xml"); + const parserError = doc.querySelector("parsererror"); + if (parserError) { + setSamlStatus({ type: "error", message: "Unable to parse valid SAML IdP metadata from XML file" }); + return; + } + + const entityID = doc.documentElement.getAttribute("entityID") || ""; + const ssoNodes = Array.from(doc.querySelectorAll("SingleSignOnService, *|SingleSignOnService")); + let ssoUrl = ""; + for (const node of ssoNodes) { + const binding = node.getAttribute("Binding") || ""; + const location = node.getAttribute("Location") || ""; + if (location) { + ssoUrl = location; + if (binding.includes("HTTP-Redirect")) break; + } + } + + const certNodes = Array.from(doc.querySelectorAll("X509Certificate, *|X509Certificate")); + let certStr = ""; + if (certNodes.length > 0) { + certStr = certNodes[0].textContent.trim(); + } + + setSamlForm((prev) => ({ + ...prev, + samlEntryPoint: ssoUrl || prev.samlEntryPoint, + samlIssuer: prev.samlIssuer || "urn:9router:sp", + samlCert: certStr || prev.samlCert, + })); + + setSamlStatus({ + type: "success", + message: `IdP Metadata imported! (SSO URL: ${ssoUrl ? "found" : "not found"}, EntityID: ${entityID ? "found" : "not found"}, Cert: ${certStr ? "found" : "not found"})`, + }); + } catch (err) { + setSamlStatus({ type: "error", message: "Error reading IdP Metadata XML file" }); + } + }; + reader.readAsText(file); + }; + + const handleCertFileUpload = (event) => { + const file = event.target.files?.[0]; + if (certFileRef.current) certFileRef.current.value = ""; + if (!file) return; + + const reader = new FileReader(); + reader.onload = (e) => { + const text = e.target?.result || ""; + setSamlForm((prev) => ({ ...prev, samlCert: text.trim() })); + setSamlStatus({ type: "success", message: "Certificate file loaded into configuration." }); + }; + reader.readAsText(file); + }; + + const saveSamlSettings = async (targetAuthMode = oidcForm.authMode || "password") => { + setSamlLoading(true); + setSamlStatus({ type: "", message: "" }); + setSamlTestStatus({ type: "", message: "" }); + + try { + const payload = { + authMode: targetAuthMode, + ssoType: "saml", + samlEntryPoint: samlForm.samlEntryPoint.trim(), + samlIssuer: samlForm.samlIssuer.trim() || "urn:9router:sp", + samlCert: samlForm.samlCert.trim(), + samlLoginLabel: samlForm.samlLoginLabel.trim() || "Sign in with SAML SSO", + samlAttributeEmail: samlForm.samlAttributeEmail.trim() || "email", + samlAttributeName: samlForm.samlAttributeName.trim() || "name", + }; + + const res = await fetch("/api/settings", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + + const data = await res.json(); + if (res.ok) { + setSettings((prev) => ({ ...prev, ...data })); + setSamlForm({ + samlEntryPoint: data?.samlEntryPoint || payload.samlEntryPoint, + samlIssuer: data?.samlIssuer || payload.samlIssuer, + samlCert: data?.samlCert || payload.samlCert, + samlLoginLabel: data?.samlLoginLabel || payload.samlLoginLabel, + samlAttributeEmail: data?.samlAttributeEmail || payload.samlAttributeEmail, + samlAttributeName: data?.samlAttributeName || payload.samlAttributeName, + }); + setSamlStatus({ + type: "success", + message: + targetAuthMode === "sso" || targetAuthMode === "saml" + ? "SAML SSO login enabled" + : targetAuthMode === "both" + ? "Password and SAML SSO login enabled" + : "SAML 2.0 settings saved", + }); + } else { + setSamlStatus({ type: "error", message: data.error || "Failed to save SAML settings" }); + } + } catch { + setSamlStatus({ type: "error", message: "An error occurred while saving SAML settings" }); + } finally { + setSamlLoading(false); + } + }; + + const testSamlConnection = async () => { + setSamlTestLoading(true); + setSamlStatus({ type: "", message: "" }); + setSamlTestStatus({ type: "", message: "" }); + + try { + const res = await fetch("/api/auth/saml/test", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + samlEntryPoint: samlForm.samlEntryPoint.trim(), + samlIssuer: samlForm.samlIssuer.trim(), + samlCert: samlForm.samlCert.trim(), + }), + }); + + const data = await res.json(); + if (res.ok && data.ok) { + setSamlTestStatus({ type: "success", message: data.message || "SAML configuration verified!" }); + } else { + setSamlTestStatus({ type: "error", message: data.error || "SAML configuration test failed" }); + } + } catch { + setSamlTestStatus({ type: "error", message: "An error occurred while testing SAML configuration" }); + } finally { + setSamlTestLoading(false); + } + }; + const updateObservabilityEnabled = async (enabled) => { try { const res = await fetch("/api/settings", { @@ -752,7 +935,7 @@ export default function ProfilePage() { - {/* OIDC */} + {/* Single Sign-On (SSO) */} {oidcExpanded && ( -
-

- Use Authentik or any OIDC provider to sign in to the dashboard. You can enable password-only, OIDC-only, or both for the dashboard; model API access still uses API keys. -

+
+

+ Configure enterprise Single Sign-On (SSO) for dashboard access using SAML 2.0 or OIDC. +

-
- -
- {[ - { - value: "password", - title: "Password only", - desc: "Keep the legacy password login.", - }, - { - value: "oidc", - title: "OIDC only", - desc: "Require OIDC for dashboard access.", - }, - { - value: "both", - title: "Both", - desc: "Allow either password or OIDC.", - }, - ].map((option) => { - const active = oidcForm.authMode === option.value; - return ( + {/* SSO Protocol Switcher Tabs */} +
+ +
+ + +
+
+ + {/* Auth Mode selection */} +
+ +
+ {[ + { + value: "password", + title: "Password only", + desc: "Keep legacy password login.", + }, + { + value: "sso", + title: `${ssoTypeTab === "saml" ? "SAML" : "OIDC"} only`, + desc: "Require SSO for dashboard access.", + }, + { + value: "both", + title: "Both", + desc: "Allow password or SSO login.", + }, + ].map((option) => { + const currentMode = oidcForm.authMode; + const active = + option.value === "password" + ? currentMode === "password" + : option.value === "sso" + ? currentMode === "sso" || currentMode === "saml" || currentMode === "oidc" + : currentMode === "both"; + return ( + + ); + })} +
+
+ + {ssoTypeTab === "saml" ? ( + /* SAML Configuration Panel */ +
+ {/* IdP Setup Guidelines Banner & Collapsible Drawer */} +
- ); - })} -
+ + {showSamlGuide && ( +
+
+

🔑 Required Service Provider (SP) Values for your IdP Setup:

+
    +
  • + Assertion Consumer Service (ACS) URL:{" "} + {samlAcsUrl} +
  • +
  • + SP Entity ID / Audience URI:{" "} + {samlForm.samlIssuer || "urn:9router:sp"} +
  • +
  • + NameID Format:{" "} + EmailAddress or Unspecified +
  • +
+
+ +
+
+

+ ☁️ AWS IAM Identity Center +

+
    +
  1. Applications → Add application → Select Add custom SAML 2.0 application.
  2. +
  3. Set Application ACS URL to {samlAcsUrl}.
  4. +
  5. Set Application SAML audience to {samlForm.samlIssuer || "urn:9router:sp"}.
  6. +
  7. Under Attribute mappings, map Subject or email to ${`{user:email}`}.
  8. +
  9. Download IAM Identity Center SAML metadata XML file and use 1-Click Import below!
  10. +
+
+ +
+

+ 🔷 Microsoft Entra ID (Azure AD) +

+
    +
  1. Enterprise Applications → New applicationCreate your own application.
  2. +
  3. Select Single sign-onSAML.
  4. +
  5. Identifier (Entity ID): {samlForm.samlIssuer || "urn:9router:sp"}
  6. +
  7. Reply URL (ACS): {samlAcsUrl}
  8. +
  9. Download Federation Metadata XML and import or copy X.509 Certificate.
  10. +
+
+ +
+

+ 🟢 Okta / Auth0 +

+
    +
  1. Applications → Create App Integration → Select SAML 2.0.
  2. +
  3. Single Sign-On URL: {samlAcsUrl}
  4. +
  5. Audience URI (SP Entity ID): {samlForm.samlIssuer || "urn:9router:sp"}
  6. +
  7. Name ID format: EmailAddress.
  8. +
  9. Download Identity Provider metadata XML or copy the X.509 cert.
  10. +
+
+ +
+

+ 🛡️ Keycloak / Authentik +

+
    +
  1. Clients → Create client → Select SAML.
  2. +
  3. Client ID: {samlForm.samlIssuer || "urn:9router:sp"}
  4. +
  5. Master SAML Processing URL: {samlAcsUrl}
  6. +
  7. Export SAML Descriptor XML or copy IDP Certificate PEM.
  8. +
+
+
+
+ )} +
+ + {/* Quick Import Card */} +
+
+

1-Click IdP Metadata XML Import

+

Auto-fill SSO URL, Issuer & Cert from XML metadata

+
+ + +
+ +
+
+ + updateSamlForm("samlEntryPoint", e.target.value)} + disabled={loading || samlLoading} + /> +
+ +
+ + updateSamlForm("samlIssuer", e.target.value)} + disabled={loading || samlLoading} + /> +
+ +
+
+ + + +
+