feat(auth): add native SAML 2.0 SSO integration
Add SAML 2.0 as a second SSO protocol alongside OIDC under a unified authMode/ssoType model. SP flows via @node-saml/node-saml: AuthnRequest generation, ACS POST assertion handling, SP metadata export, and admin config test endpoint. Replay-protected via saml_state cookie (httpOnly, SameSite=Lax) matched against InResponseTo; wantAssertionsSigned enforced. - src/lib/auth/saml.js: SAML instance builder, X.509 cert formatter, claim pickers - 4 routes under src/app/api/auth/saml/: start, acs, metadata, test - settingsRepo: ssoType + saml* defaults; login/status routes dispatch by type - profile page: SSO protocol switcher, IdP metadata XML + cert uploaders - login page: dynamic SAML sign-in button; Header: SAML user badge
This commit is contained in:
BIN
docs/images/saml-admin-dashboard.png
Normal file
BIN
docs/images/saml-admin-dashboard.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 103 KiB |
BIN
docs/images/saml-login-screen.png
Normal file
BIN
docs/images/saml-login-screen.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
@@ -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",
|
||||
|
||||
@@ -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() {
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* OIDC */}
|
||||
{/* Single Sign-On (SSO) */}
|
||||
<Card>
|
||||
<button
|
||||
type="button"
|
||||
@@ -763,9 +946,13 @@ export default function ProfilePage() {
|
||||
<span className="material-symbols-outlined text-[20px]">lock_open</span>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-base sm:text-lg font-semibold">OIDC Dashboard Login</h3>
|
||||
<h3 className="text-base sm:text-lg font-semibold">Single Sign-On (SSO)</h3>
|
||||
<p className="text-xs text-text-muted">
|
||||
{settings.authMode === "oidc" ? "OIDC active" : settings.authMode === "both" ? "Password + OIDC active" : "Optional SSO via Authentik/Keycloak/Google"}
|
||||
{settings.authMode === "sso" || settings.authMode === "oidc" || settings.authMode === "saml"
|
||||
? `${settings.ssoType === "saml" ? "SAML 2.0" : "OIDC"} SSO active`
|
||||
: settings.authMode === "both"
|
||||
? `Password + ${settings.ssoType === "saml" ? "SAML 2.0" : "OIDC"} active`
|
||||
: "Optional SSO via Okta, Entra ID, Keycloak, or OIDC"}
|
||||
</p>
|
||||
</div>
|
||||
<span className="material-symbols-outlined text-text-muted shrink-0">
|
||||
@@ -773,145 +960,472 @@ export default function ProfilePage() {
|
||||
</span>
|
||||
</button>
|
||||
{oidcExpanded && (
|
||||
<div className="flex flex-col gap-4 mt-4">
|
||||
<p className="text-xs sm:text-sm text-text-muted">
|
||||
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.
|
||||
</p>
|
||||
<div className="flex flex-col gap-4 mt-4">
|
||||
<p className="text-xs sm:text-sm text-text-muted">
|
||||
Configure enterprise Single Sign-On (SSO) for dashboard access using SAML 2.0 or OIDC.
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="font-medium text-sm sm:text-base">Auth Mode</label>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
|
||||
{[
|
||||
{
|
||||
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 */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="font-medium text-sm sm:text-base">SSO Protocol</label>
|
||||
<div className="flex p-1 rounded-lg bg-black/5 dark:bg-white/5 border border-border">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSsoTypeTab("saml")}
|
||||
className={cn(
|
||||
"flex-1 py-1.5 px-3 rounded-md font-medium text-xs sm:text-sm transition-all text-center",
|
||||
ssoTypeTab === "saml"
|
||||
? "bg-white dark:bg-white/10 text-text-main shadow-sm"
|
||||
: "text-text-muted hover:text-text-main"
|
||||
)}
|
||||
>
|
||||
SAML 2.0
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSsoTypeTab("oidc")}
|
||||
className={cn(
|
||||
"flex-1 py-1.5 px-3 rounded-md font-medium text-xs sm:text-sm transition-all text-center",
|
||||
ssoTypeTab === "oidc"
|
||||
? "bg-white dark:bg-white/10 text-text-main shadow-sm"
|
||||
: "text-text-muted hover:text-text-main"
|
||||
)}
|
||||
>
|
||||
OIDC
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Auth Mode selection */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="font-medium text-sm sm:text-base">Auth Mode</label>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
|
||||
{[
|
||||
{
|
||||
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 (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => updateOidcForm("authMode", option.value)}
|
||||
className={cn(
|
||||
"text-left rounded-lg border p-3 transition-colors",
|
||||
active
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-border bg-bg hover:bg-black/5 dark:hover:bg-white/5"
|
||||
)}
|
||||
disabled={loading || oidcLoading || samlLoading}
|
||||
>
|
||||
<p className="font-medium text-sm sm:text-base">{option.title}</p>
|
||||
<p className="text-xs sm:text-sm text-text-muted mt-1">{option.desc}</p>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{ssoTypeTab === "saml" ? (
|
||||
/* SAML Configuration Panel */
|
||||
<div className="flex flex-col gap-4 pt-2 border-t border-border/50">
|
||||
{/* IdP Setup Guidelines Banner & Collapsible Drawer */}
|
||||
<div className="rounded-lg border border-border bg-bg/80 overflow-hidden">
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => updateOidcForm("authMode", option.value)}
|
||||
className={cn(
|
||||
"text-left rounded-lg border p-3 transition-colors",
|
||||
active
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-border bg-bg hover:bg-black/5 dark:hover:bg-white/5"
|
||||
)}
|
||||
disabled={loading || oidcLoading}
|
||||
onClick={() => setShowSamlGuide((prev) => !prev)}
|
||||
className="w-full p-3 flex items-center justify-between gap-2 text-left hover:bg-surface/50 transition-colors"
|
||||
>
|
||||
<p className="font-medium text-sm sm:text-base">{option.title}</p>
|
||||
<p className="text-xs sm:text-sm text-text-muted mt-1">{option.desc}</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-primary text-lg">menu_book</span>
|
||||
<div>
|
||||
<p className="font-semibold text-xs sm:text-sm text-text-main">
|
||||
IdP Setup Guidelines & Provider Configuration Instructions
|
||||
</p>
|
||||
<p className="text-[11px] text-text-muted">
|
||||
Click to view setup steps for AWS IAM Identity Center, Okta, Entra ID, Keycloak, & Authentik
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className="material-symbols-outlined text-text-muted transition-transform text-lg"
|
||||
style={{ transform: showSamlGuide ? "rotate(180deg)" : "none" }}
|
||||
>
|
||||
expand_more
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{showSamlGuide && (
|
||||
<div className="p-4 border-t border-border bg-surface/30 text-xs text-text-main flex flex-col gap-3">
|
||||
<div className="p-2.5 rounded border border-primary/20 bg-primary/5 text-primary text-xs">
|
||||
<p className="font-semibold mb-1">🔑 Required Service Provider (SP) Values for your IdP Setup:</p>
|
||||
<ul className="list-disc pl-4 space-y-1 font-mono text-[11px]">
|
||||
<li>
|
||||
<b>Assertion Consumer Service (ACS) URL:</b>{" "}
|
||||
<code className="bg-bg px-1 py-0.5 rounded break-all">{samlAcsUrl}</code>
|
||||
</li>
|
||||
<li>
|
||||
<b>SP Entity ID / Audience URI:</b>{" "}
|
||||
<code className="bg-bg px-1 py-0.5 rounded break-all">{samlForm.samlIssuer || "urn:9router:sp"}</code>
|
||||
</li>
|
||||
<li>
|
||||
<b>NameID Format:</b>{" "}
|
||||
<code className="bg-bg px-1 py-0.5 rounded">EmailAddress</code> or <code className="bg-bg px-1 py-0.5 rounded">Unspecified</code>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 pt-1">
|
||||
<div className="p-3 rounded border border-border bg-bg/50 flex flex-col gap-1.5">
|
||||
<p className="font-semibold text-text-main flex items-center gap-1.5">
|
||||
<span>☁️</span> AWS IAM Identity Center
|
||||
</p>
|
||||
<ol className="list-decimal pl-4 text-text-muted space-y-1">
|
||||
<li>Applications → <b>Add application</b> → Select <b>Add custom SAML 2.0 application</b>.</li>
|
||||
<li>Set <b>Application ACS URL</b> to <code className="text-text-main font-mono">{samlAcsUrl}</code>.</li>
|
||||
<li>Set <b>Application SAML audience</b> to <code className="text-text-main font-mono">{samlForm.samlIssuer || "urn:9router:sp"}</code>.</li>
|
||||
<li>Under <i>Attribute mappings</i>, map <code className="text-text-main font-mono">Subject</code> or <code className="text-text-main font-mono">email</code> to <code className="text-text-main font-mono">${`{user:email}`}</code>.</li>
|
||||
<li>Download <b>IAM Identity Center SAML metadata XML</b> file and use 1-Click Import below!</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div className="p-3 rounded border border-border bg-bg/50 flex flex-col gap-1.5">
|
||||
<p className="font-semibold text-text-main flex items-center gap-1.5">
|
||||
<span>🔷</span> Microsoft Entra ID (Azure AD)
|
||||
</p>
|
||||
<ol className="list-decimal pl-4 text-text-muted space-y-1">
|
||||
<li>Enterprise Applications → <b>New application</b> → <b>Create your own application</b>.</li>
|
||||
<li>Select <b>Single sign-on</b> → <b>SAML</b>.</li>
|
||||
<li><b>Identifier (Entity ID):</b> <code className="text-text-main font-mono">{samlForm.samlIssuer || "urn:9router:sp"}</code></li>
|
||||
<li><b>Reply URL (ACS):</b> <code className="text-text-main font-mono">{samlAcsUrl}</code></li>
|
||||
<li>Download <b>Federation Metadata XML</b> and import or copy X.509 Certificate.</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div className="p-3 rounded border border-border bg-bg/50 flex flex-col gap-1.5">
|
||||
<p className="font-semibold text-text-main flex items-center gap-1.5">
|
||||
<span>🟢</span> Okta / Auth0
|
||||
</p>
|
||||
<ol className="list-decimal pl-4 text-text-muted space-y-1">
|
||||
<li>Applications → <b>Create App Integration</b> → Select <b>SAML 2.0</b>.</li>
|
||||
<li><b>Single Sign-On URL:</b> <code className="text-text-main font-mono">{samlAcsUrl}</code></li>
|
||||
<li><b>Audience URI (SP Entity ID):</b> <code className="text-text-main font-mono">{samlForm.samlIssuer || "urn:9router:sp"}</code></li>
|
||||
<li>Name ID format: <i>EmailAddress</i>.</li>
|
||||
<li>Download Identity Provider metadata XML or copy the X.509 cert.</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div className="p-3 rounded border border-border bg-bg/50 flex flex-col gap-1.5">
|
||||
<p className="font-semibold text-text-main flex items-center gap-1.5">
|
||||
<span>🛡️</span> Keycloak / Authentik
|
||||
</p>
|
||||
<ol className="list-decimal pl-4 text-text-muted space-y-1">
|
||||
<li>Clients → <b>Create client</b> → Select <b>SAML</b>.</li>
|
||||
<li><b>Client ID:</b> <code className="text-text-main font-mono">{samlForm.samlIssuer || "urn:9router:sp"}</code></li>
|
||||
<li><b>Master SAML Processing URL:</b> <code className="text-text-main font-mono">{samlAcsUrl}</code></li>
|
||||
<li>Export SAML Descriptor XML or copy IDP Certificate PEM.</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Quick Import Card */}
|
||||
<div className="p-3 rounded-lg border border-dashed border-primary/40 bg-primary/5 flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="font-medium text-sm text-text-main">1-Click IdP Metadata XML Import</p>
|
||||
<p className="text-xs text-text-muted">Auto-fill SSO URL, Issuer & Cert from XML metadata</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
icon="upload_file"
|
||||
onClick={() => idpMetadataFileRef.current?.click()}
|
||||
>
|
||||
Upload Metadata XML
|
||||
</Button>
|
||||
<input
|
||||
ref={idpMetadataFileRef}
|
||||
type="file"
|
||||
accept=".xml,application/xml,text/xml"
|
||||
className="hidden"
|
||||
onChange={handleIdpMetadataUpload}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="font-medium text-sm sm:text-base">Single Sign-On Service URL (samlEntryPoint)</label>
|
||||
<Input
|
||||
placeholder="https://idp.example.com/app/saml/sso/..."
|
||||
value={samlForm.samlEntryPoint}
|
||||
onChange={(e) => updateSamlForm("samlEntryPoint", e.target.value)}
|
||||
disabled={loading || samlLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="font-medium text-sm sm:text-base">SP Entity ID / Audience (samlIssuer)</label>
|
||||
<Input
|
||||
placeholder="urn:9router:sp"
|
||||
value={samlForm.samlIssuer}
|
||||
onChange={(e) => updateSamlForm("samlIssuer", e.target.value)}
|
||||
disabled={loading || samlLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="font-medium text-sm sm:text-base">IdP X.509 Certificate (samlCert)</label>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
icon="file_upload"
|
||||
onClick={() => certFileRef.current?.click()}
|
||||
>
|
||||
Upload Certificate
|
||||
</Button>
|
||||
<input
|
||||
ref={certFileRef}
|
||||
type="file"
|
||||
accept=".crt,.pem,.cer,text/plain"
|
||||
className="hidden"
|
||||
onChange={handleCertFileUpload}
|
||||
/>
|
||||
</div>
|
||||
<textarea
|
||||
rows={4}
|
||||
placeholder="-----BEGIN CERTIFICATE----- MIIC... -----END CERTIFICATE-----"
|
||||
value={samlForm.samlCert}
|
||||
onChange={(e) => updateSamlForm("samlCert", e.target.value)}
|
||||
className="w-full p-2.5 rounded-lg border border-border bg-bg text-xs font-mono text-text-main focus:outline-none focus:border-primary"
|
||||
disabled={loading || samlLoading}
|
||||
/>
|
||||
<p className="text-xs text-text-muted">Paste raw Base64 certificate or PEM block.</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="font-medium text-sm sm:text-base">Login Button Label</label>
|
||||
<Input
|
||||
placeholder="Sign in with SAML SSO"
|
||||
value={samlForm.samlLoginLabel}
|
||||
onChange={(e) => updateSamlForm("samlLoginLabel", e.target.value)}
|
||||
disabled={loading || samlLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="font-medium text-sm sm:text-base">Email Claim Attribute</label>
|
||||
<Input
|
||||
placeholder="email"
|
||||
value={samlForm.samlAttributeEmail}
|
||||
onChange={(e) => updateSamlForm("samlAttributeEmail", e.target.value)}
|
||||
disabled={loading || samlLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="font-medium text-sm sm:text-base">Display Name Claim</label>
|
||||
<Input
|
||||
placeholder="name"
|
||||
value={samlForm.samlAttributeName}
|
||||
onChange={(e) => updateSamlForm("samlAttributeName", e.target.value)}
|
||||
disabled={loading || samlLoading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 p-3 rounded-lg border border-border bg-bg text-xs sm:text-sm text-text-muted">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div>
|
||||
<p className="font-medium text-text-main">ACS Callback URL</p>
|
||||
<code className="block break-all font-mono text-xs">{samlAcsUrl}</code>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
icon="content_copy"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(samlAcsUrl);
|
||||
setSamlStatus({ type: "success", message: "ACS URL copied to clipboard!" });
|
||||
}}
|
||||
>
|
||||
Copy
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2 pt-2 border-t border-border/50">
|
||||
<div>
|
||||
<p className="font-medium text-text-main">SP XML Metadata</p>
|
||||
<code className="block break-all font-mono text-xs">{samlMetadataUrl}</code>
|
||||
</div>
|
||||
<a
|
||||
href={samlMetadataUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
download="9router-sp-metadata.xml"
|
||||
className="inline-flex items-center gap-1 text-xs font-medium text-primary hover:underline"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px]">download</span>
|
||||
Download XML
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-2 pt-2 border-t border-border/50">
|
||||
<Button
|
||||
type="button"
|
||||
variant="primary"
|
||||
loading={samlLoading}
|
||||
onClick={() => saveSamlSettings(oidcForm.authMode)}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
Save SAML settings
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
loading={samlTestLoading}
|
||||
onClick={testSamlConnection}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
Test SAML settings
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{samlTestStatus.message && (
|
||||
<p className={`text-xs sm:text-sm ${samlTestStatus.type === "error" ? "text-red-500" : "text-green-500"}`}>
|
||||
{samlTestStatus.message}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{samlStatus.message && (
|
||||
<p className={`text-xs sm:text-sm ${samlStatus.type === "error" ? "text-red-500" : "text-green-500"}`}>
|
||||
{samlStatus.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
/* OIDC Panel */
|
||||
<div className="flex flex-col gap-4 pt-2 border-t border-border/50">
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="font-medium text-sm sm:text-base">Issuer URL</label>
|
||||
<Input
|
||||
placeholder="https://auth.example.com/application/o/9router/"
|
||||
value={oidcForm.oidcIssuerUrl}
|
||||
onChange={(e) => updateOidcForm("oidcIssuerUrl", e.target.value)}
|
||||
disabled={loading || oidcLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="font-medium text-sm sm:text-base">Client ID</label>
|
||||
<Input
|
||||
placeholder="9router-dashboard"
|
||||
value={oidcForm.oidcClientId}
|
||||
onChange={(e) => updateOidcForm("oidcClientId", e.target.value)}
|
||||
disabled={loading || oidcLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="font-medium text-sm sm:text-base">Client Secret</label>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="Leave blank to keep existing secret"
|
||||
value={oidcClientSecret}
|
||||
onChange={(e) => setOidcClientSecret(e.target.value)}
|
||||
disabled={loading || oidcLoading}
|
||||
/>
|
||||
<p className="text-xs sm:text-sm text-text-muted">This value is write-only after saving.</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="font-medium text-sm sm:text-base">Scopes</label>
|
||||
<Input
|
||||
placeholder="openid profile email"
|
||||
value={oidcForm.oidcScopes}
|
||||
onChange={(e) => updateOidcForm("oidcScopes", e.target.value)}
|
||||
disabled={loading || oidcLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="font-medium text-sm sm:text-base">Login Button Label</label>
|
||||
<Input
|
||||
placeholder="Sign in with OIDC"
|
||||
value={oidcForm.oidcLoginLabel}
|
||||
onChange={(e) => updateOidcForm("oidcLoginLabel", e.target.value)}
|
||||
disabled={loading || oidcLoading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-border bg-bg p-3 text-xs sm:text-sm text-text-muted">
|
||||
<p className="font-medium text-text-main mb-1">Redirect URI</p>
|
||||
<code className="block break-all font-mono">{oidcRedirectUri}</code>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-2 pt-2 border-t border-border/50">
|
||||
<Button type="button" variant="primary" loading={oidcLoading} onClick={() => saveOidcSettings()} className="w-full sm:w-auto">
|
||||
Save OIDC settings
|
||||
</Button>
|
||||
<Button type="button" variant="outline" loading={oidcTestLoading} onClick={testOidcConnection} className="w-full sm:w-auto">
|
||||
Test connection
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{oidcTestStatus.message && (
|
||||
<p className={`text-xs sm:text-sm ${oidcTestStatus.type === "error" ? "text-red-500" : "text-green-500"}`}>
|
||||
{oidcTestStatus.message}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{oidcStatus.message && (
|
||||
<p className={`text-xs sm:text-sm ${oidcStatus.type === "error" ? "text-red-500" : "text-green-500"}`}>
|
||||
{oidcStatus.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{settings.authMode === "oidc" || settings.authMode === "saml" || settings.authMode === "sso" ? (
|
||||
<p className="text-xs sm:text-sm text-amber-600 dark:text-amber-400">
|
||||
SSO login ({settings.ssoType === "saml" ? "SAML 2.0" : "OIDC"}) is currently active. Password login is disabled until you switch back.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{settings.authMode === "both" && (
|
||||
<p className="text-xs sm:text-sm text-amber-600 dark:text-amber-400">
|
||||
Password and SSO login ({settings.ssoType === "saml" ? "SAML 2.0" : "OIDC"}) are both active.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="font-medium text-sm sm:text-base">Issuer URL</label>
|
||||
<Input
|
||||
placeholder="https://auth.example.com/application/o/9router/"
|
||||
value={oidcForm.oidcIssuerUrl}
|
||||
onChange={(e) => updateOidcForm("oidcIssuerUrl", e.target.value)}
|
||||
disabled={loading || oidcLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="font-medium text-sm sm:text-base">Client ID</label>
|
||||
<Input
|
||||
placeholder="9router-dashboard"
|
||||
value={oidcForm.oidcClientId}
|
||||
onChange={(e) => updateOidcForm("oidcClientId", e.target.value)}
|
||||
disabled={loading || oidcLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="font-medium text-sm sm:text-base">Client Secret</label>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="Leave blank to keep existing secret"
|
||||
value={oidcClientSecret}
|
||||
onChange={(e) => setOidcClientSecret(e.target.value)}
|
||||
disabled={loading || oidcLoading}
|
||||
/>
|
||||
<p className="text-xs sm:text-sm text-text-muted">This value is write-only after saving.</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="font-medium text-sm sm:text-base">Scopes</label>
|
||||
<Input
|
||||
placeholder="openid profile email"
|
||||
value={oidcForm.oidcScopes}
|
||||
onChange={(e) => updateOidcForm("oidcScopes", e.target.value)}
|
||||
disabled={loading || oidcLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="font-medium text-sm sm:text-base">Login Button Label</label>
|
||||
<Input
|
||||
placeholder="Sign in with OIDC"
|
||||
value={oidcForm.oidcLoginLabel}
|
||||
onChange={(e) => updateOidcForm("oidcLoginLabel", e.target.value)}
|
||||
disabled={loading || oidcLoading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-border bg-bg p-3 text-xs sm:text-sm text-text-muted">
|
||||
<p className="font-medium text-text-main mb-1">Redirect URI</p>
|
||||
<code className="block break-all font-mono">{oidcRedirectUri}</code>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-2 pt-2 border-t border-border/50">
|
||||
<Button type="button" variant="primary" loading={oidcLoading} onClick={() => saveOidcSettings()} className="w-full sm:w-auto">
|
||||
Save auth mode
|
||||
</Button>
|
||||
<Button type="button" variant="outline" loading={oidcTestLoading} onClick={testOidcConnection} className="w-full sm:w-auto">
|
||||
Test connection
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{oidcTestStatus.message && (
|
||||
<p className={`text-xs sm:text-sm ${oidcTestStatus.type === "error" ? "text-red-500" : "text-green-500"}`}>
|
||||
{oidcTestStatus.message}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{oidcStatus.message && (
|
||||
<p className={`text-xs sm:text-sm ${oidcStatus.type === "error" ? "text-red-500" : "text-green-500"}`}>
|
||||
{oidcStatus.message}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{settings.authMode === "oidc" && (
|
||||
<p className="text-xs sm:text-sm text-amber-600 dark:text-amber-400">
|
||||
OIDC login is currently active. Password login is disabled until you switch back.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{settings.authMode === "both" && (
|
||||
<p className="text-xs sm:text-sm text-amber-600 dark:text-amber-400">
|
||||
Password and OIDC login are both active.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import bcrypt from "bcryptjs";
|
||||
import { cookies } from "next/headers";
|
||||
import { setDashboardAuthCookie } from "@/lib/auth/dashboardSession";
|
||||
import { isOidcConfigured } from "@/lib/auth/oidc";
|
||||
import { isSamlConfigured } from "@/lib/auth/saml.js";
|
||||
import { checkLock, recordFail, recordSuccess, getClientIp } from "@/lib/auth/loginLimiter";
|
||||
import { isLocalRequest } from "@/dashboardGuard";
|
||||
|
||||
@@ -39,8 +40,14 @@ export async function POST(request) {
|
||||
// Default password is '123456' if not set
|
||||
const storedHash = settings.password;
|
||||
|
||||
if (settings.authMode === "oidc" && isOidcConfigured(settings)) {
|
||||
return NextResponse.json({ error: "Password login is disabled. Use OIDC sign in." }, { status: 403 });
|
||||
if (settings.authMode === "sso" || settings.authMode === "saml" || settings.authMode === "oidc") {
|
||||
const ssoType = settings.ssoType || (settings.authMode === "saml" ? "saml" : "oidc");
|
||||
if (ssoType === "saml" && isSamlConfigured(settings)) {
|
||||
return NextResponse.json({ error: "Password login is disabled. Use SAML SSO sign in." }, { status: 403 });
|
||||
}
|
||||
if (ssoType === "oidc" && isOidcConfigured(settings)) {
|
||||
return NextResponse.json({ error: "Password login is disabled. Use OIDC sign in." }, { status: 403 });
|
||||
}
|
||||
}
|
||||
|
||||
let isValid = false;
|
||||
|
||||
69
src/app/api/auth/saml/acs/route.js
Normal file
69
src/app/api/auth/saml/acs/route.js
Normal file
@@ -0,0 +1,69 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { cookies } from "next/headers";
|
||||
import { getSettings } from "@/lib/localDb";
|
||||
import {
|
||||
getSamlBaseUrl,
|
||||
isSamlConfigured,
|
||||
pickSamlDisplayName,
|
||||
pickSamlEmail,
|
||||
validateSamlResponse,
|
||||
} from "@/lib/auth/saml.js";
|
||||
import { setDashboardAuthCookie } from "@/lib/auth/dashboardSession";
|
||||
import { checkLock, recordFail, recordSuccess, getClientIp } from "@/lib/auth/loginLimiter";
|
||||
|
||||
export async function POST(request) {
|
||||
const settings = await getSettings();
|
||||
const origin = getSamlBaseUrl(request, settings);
|
||||
const ip = getClientIp(request);
|
||||
|
||||
const lock = checkLock(ip);
|
||||
if (lock.locked) {
|
||||
return NextResponse.redirect(
|
||||
new URL(
|
||||
`/login?error=${encodeURIComponent(`Too many failed attempts. Try again in ${lock.retryAfter}s.`)}`,
|
||||
origin
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const cookieStore = await cookies();
|
||||
const storedRequestId = cookieStore.get("saml_state")?.value || "";
|
||||
|
||||
// Always clear saml_state cookie after attempt
|
||||
cookieStore.delete("saml_state");
|
||||
|
||||
try {
|
||||
const formData = await request.formData();
|
||||
const SAMLResponse = formData.get("SAMLResponse");
|
||||
|
||||
if (!SAMLResponse) {
|
||||
recordFail(ip);
|
||||
return NextResponse.redirect(new URL("/login?error=saml_missing_response", origin));
|
||||
}
|
||||
|
||||
if (!isSamlConfigured(settings)) {
|
||||
recordFail(ip);
|
||||
return NextResponse.redirect(new URL("/login?error=saml_not_configured", origin));
|
||||
}
|
||||
|
||||
const profile = await validateSamlResponse(request, { SAMLResponse }, storedRequestId, settings);
|
||||
|
||||
const samlEmail = pickSamlEmail(profile, settings) || null;
|
||||
const samlName = pickSamlDisplayName(profile, settings) || "SAML user";
|
||||
|
||||
recordSuccess(ip);
|
||||
|
||||
await setDashboardAuthCookie(cookieStore, request, {
|
||||
saml: true,
|
||||
samlEmail,
|
||||
samlName,
|
||||
});
|
||||
|
||||
return NextResponse.redirect(new URL("/dashboard", origin));
|
||||
} catch (error) {
|
||||
recordFail(ip);
|
||||
return NextResponse.redirect(
|
||||
new URL(`/login?error=${encodeURIComponent(error.message || "saml_acs_failed")}`, origin)
|
||||
);
|
||||
}
|
||||
}
|
||||
25
src/app/api/auth/saml/metadata/route.js
Normal file
25
src/app/api/auth/saml/metadata/route.js
Normal file
@@ -0,0 +1,25 @@
|
||||
import { getSettings } from "@/lib/localDb";
|
||||
import { generateSamlMetadata } from "@/lib/auth/saml";
|
||||
|
||||
export async function GET(request) {
|
||||
try {
|
||||
const settings = await getSettings();
|
||||
const origin = new URL(request.url).origin;
|
||||
const metadataXml = generateSamlMetadata(origin, settings);
|
||||
|
||||
return new Response(metadataXml, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/xml",
|
||||
"Cache-Control": "no-cache",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return new Response(`<?xml version="1.0"?><Error>${error.message || "Failed to generate metadata"}</Error>`, {
|
||||
status: 500,
|
||||
headers: {
|
||||
"Content-Type": "application/xml",
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
32
src/app/api/auth/saml/start/route.js
Normal file
32
src/app/api/auth/saml/start/route.js
Normal file
@@ -0,0 +1,32 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { cookies } from "next/headers";
|
||||
import { getSettings } from "@/lib/localDb";
|
||||
import { buildSamlAuthorizeUrl, getSamlBaseUrl, isSamlConfigured } from "@/lib/auth/saml.js";
|
||||
import { shouldUseSecureCookie } from "@/lib/auth/dashboardSession";
|
||||
|
||||
export async function GET(request) {
|
||||
const settings = await getSettings();
|
||||
const origin = getSamlBaseUrl(request, settings);
|
||||
try {
|
||||
if (!isSamlConfigured(settings)) {
|
||||
return NextResponse.redirect(new URL("/login?error=saml_not_configured", origin));
|
||||
}
|
||||
|
||||
const { authorizeUrl, requestId } = await buildSamlAuthorizeUrl(request, settings);
|
||||
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.set("saml_state", requestId, {
|
||||
httpOnly: true,
|
||||
secure: shouldUseSecureCookie(request),
|
||||
sameSite: "lax",
|
||||
path: "/",
|
||||
maxAge: 10 * 60,
|
||||
});
|
||||
|
||||
return NextResponse.redirect(authorizeUrl);
|
||||
} catch (error) {
|
||||
return NextResponse.redirect(
|
||||
new URL(`/login?error=${encodeURIComponent(error.message || "saml_start_failed")}`, origin)
|
||||
);
|
||||
}
|
||||
}
|
||||
72
src/app/api/auth/saml/test/route.js
Normal file
72
src/app/api/auth/saml/test/route.js
Normal file
@@ -0,0 +1,72 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { cookies } from "next/headers";
|
||||
import { getSettings } from "@/lib/localDb";
|
||||
import { formatX509Certificate } from "@/lib/auth/saml.js";
|
||||
import { verifyDashboardAuthToken } from "@/lib/auth/dashboardSession";
|
||||
|
||||
async function canAccessTestRoute() {
|
||||
const settings = await getSettings();
|
||||
if (settings.requireLogin === false) return true;
|
||||
|
||||
const cookieStore = await cookies();
|
||||
const token = cookieStore.get("auth_token")?.value;
|
||||
return await verifyDashboardAuthToken(token);
|
||||
}
|
||||
|
||||
export async function POST(request) {
|
||||
try {
|
||||
if (!(await canAccessTestRoute())) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const settings = await getSettings();
|
||||
|
||||
const samlEntryPoint = String(body.samlEntryPoint || settings.samlEntryPoint || "").trim();
|
||||
const samlIssuer = String(body.samlIssuer || settings.samlIssuer || "urn:9router:sp").trim();
|
||||
const samlCert = String(
|
||||
Object.prototype.hasOwnProperty.call(body, "samlCert")
|
||||
? body.samlCert
|
||||
: settings.samlCert || ""
|
||||
).trim();
|
||||
|
||||
if (!samlEntryPoint) {
|
||||
return NextResponse.json({ error: "Single Sign-On Service URL (samlEntryPoint) is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
new URL(samlEntryPoint);
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Single Sign-On Service URL must be a valid URL" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!samlIssuer) {
|
||||
return NextResponse.json({ error: "SP Entity ID / Issuer (samlIssuer) is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!samlCert) {
|
||||
return NextResponse.json({ error: "IdP X.509 Certificate (samlCert) is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
const formattedCert = formatX509Certificate(samlCert);
|
||||
if (!formattedCert) {
|
||||
return NextResponse.json({ error: "Invalid IdP X.509 Certificate format" }, { status: 400 });
|
||||
}
|
||||
|
||||
const origin = new URL(request.url).origin;
|
||||
const acsUrl = `${origin}/api/auth/saml/acs`;
|
||||
const metadataUrl = `${origin}/api/auth/saml/metadata`;
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
samlEntryPoint,
|
||||
samlIssuer,
|
||||
certValid: true,
|
||||
acsUrl,
|
||||
metadataUrl,
|
||||
message: "SAML 2.0 configuration verified successfully.",
|
||||
});
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: error.message || "SAML test failed" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
|
||||
import { cookies } from "next/headers";
|
||||
import { getSettings } from "@/lib/localDb";
|
||||
import { isOidcConfigured } from "@/lib/auth/oidc";
|
||||
import { isSamlConfigured } from "@/lib/auth/saml.js";
|
||||
import { getDashboardAuthSession } from "@/lib/auth/dashboardSession";
|
||||
|
||||
export async function GET() {
|
||||
@@ -11,16 +12,29 @@ export async function GET() {
|
||||
const session = await getDashboardAuthSession(cookieStore.get("auth_token")?.value);
|
||||
const requireLogin = settings.requireLogin !== false;
|
||||
const authMode = settings.authMode || "password";
|
||||
const ssoType = settings.ssoType || "oidc";
|
||||
const oidcName = String(session?.oidcName || "").trim();
|
||||
const oidcEmail = String(session?.oidcEmail || "").trim();
|
||||
const displayName = oidcName || oidcEmail || (session?.oidc ? "OIDC user" : "Password user");
|
||||
const loginMethod = session?.oidc ? "OIDC" : "Password";
|
||||
const samlName = String(session?.samlName || "").trim();
|
||||
const samlEmail = String(session?.samlEmail || "").trim();
|
||||
|
||||
const displayName =
|
||||
samlName ||
|
||||
samlEmail ||
|
||||
oidcName ||
|
||||
oidcEmail ||
|
||||
(session?.saml ? "SAML user" : session?.oidc ? "OIDC user" : "Password user");
|
||||
|
||||
const loginMethod = session?.saml ? "SAML" : session?.oidc ? "OIDC" : "Password";
|
||||
|
||||
return NextResponse.json({
|
||||
requireLogin,
|
||||
authMode,
|
||||
ssoType,
|
||||
oidcConfigured: isOidcConfigured(settings),
|
||||
oidcLoginLabel: (settings.oidcLoginLabel || "Sign in with OIDC").trim() || "Sign in with OIDC",
|
||||
samlConfigured: isSamlConfigured(settings),
|
||||
samlLoginLabel: (settings.samlLoginLabel || "Sign in with SAML SSO").trim() || "Sign in with SAML SSO",
|
||||
hasPassword: !!settings.password,
|
||||
displayName,
|
||||
loginMethod,
|
||||
@@ -28,13 +42,19 @@ export async function GET() {
|
||||
oidcName: oidcName || null,
|
||||
oidcEmail: oidcEmail || null,
|
||||
oidcLogin: !!session?.oidc,
|
||||
samlName: samlName || null,
|
||||
samlEmail: samlEmail || null,
|
||||
samlLogin: !!session?.saml,
|
||||
});
|
||||
} catch {
|
||||
return NextResponse.json({
|
||||
requireLogin: true,
|
||||
authMode: "password",
|
||||
ssoType: "oidc",
|
||||
oidcConfigured: false,
|
||||
oidcLoginLabel: "Sign in with OIDC",
|
||||
samlConfigured: false,
|
||||
samlLoginLabel: "Sign in with SAML SSO",
|
||||
hasPassword: false,
|
||||
displayName: "Password user",
|
||||
loginMethod: "Password",
|
||||
@@ -42,6 +62,9 @@ export async function GET() {
|
||||
oidcName: null,
|
||||
oidcEmail: null,
|
||||
oidcLogin: false,
|
||||
samlName: null,
|
||||
samlEmail: null,
|
||||
samlLogin: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,8 +11,11 @@ export default function LoginPage() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [hasPassword, setHasPassword] = useState(null);
|
||||
const [authMode, setAuthMode] = useState("password");
|
||||
const [ssoType, setSsoType] = useState("oidc");
|
||||
const [oidcConfigured, setOidcConfigured] = useState(false);
|
||||
const [oidcLoginLabel, setOidcLoginLabel] = useState("Sign in with OIDC");
|
||||
const [samlConfigured, setSamlConfigured] = useState(false);
|
||||
const [samlLoginLabel, setSamlLoginLabel] = useState("Sign in with SAML SSO");
|
||||
const [mustChange, setMustChange] = useState(false);
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
|
||||
@@ -43,8 +46,11 @@ export default function LoginPage() {
|
||||
}
|
||||
setHasPassword(!!data.hasPassword);
|
||||
setAuthMode(data.authMode || "password");
|
||||
setSsoType(data.ssoType || "oidc");
|
||||
setOidcConfigured(data.oidcConfigured === true);
|
||||
setOidcLoginLabel(data.oidcLoginLabel || "Sign in with OIDC");
|
||||
setSamlConfigured(data.samlConfigured === true);
|
||||
setSamlLoginLabel(data.samlLoginLabel || "Sign in with SAML SSO");
|
||||
} else {
|
||||
// Safe fallback on non-OK response to avoid infinite loading state.
|
||||
setHasPassword(true);
|
||||
@@ -118,8 +124,18 @@ export default function LoginPage() {
|
||||
window.location.href = "/api/auth/oidc/start";
|
||||
};
|
||||
|
||||
const oidcAvailable = oidcConfigured && ["oidc", "both"].includes(authMode);
|
||||
const passwordAvailable = authMode !== "oidc" || !oidcConfigured;
|
||||
const handleSamlLogin = () => {
|
||||
window.location.href = "/api/auth/saml/start";
|
||||
};
|
||||
|
||||
const isSsoEnabled = ["sso", "oidc", "saml", "both"].includes(authMode);
|
||||
const activeSsoType = ssoType || (authMode === "saml" ? "saml" : "oidc");
|
||||
|
||||
const samlAvailable = isSsoEnabled && activeSsoType === "saml" && samlConfigured;
|
||||
const oidcAvailable = isSsoEnabled && activeSsoType === "oidc" && oidcConfigured;
|
||||
const ssoAvailable = samlAvailable || oidcAvailable;
|
||||
|
||||
const passwordAvailable = authMode === "password" || authMode === "both" || !ssoAvailable;
|
||||
|
||||
// Show loading state while checking password
|
||||
if (hasPassword === null) {
|
||||
@@ -141,7 +157,9 @@ export default function LoginPage() {
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-3xl font-bold text-primary mb-2">9Router</h1>
|
||||
<p className="text-text-muted">
|
||||
{authMode === "oidc" && oidcConfigured
|
||||
{samlAvailable
|
||||
? "Sign in with SAML 2.0 Single Sign-On"
|
||||
: oidcAvailable
|
||||
? "Sign in with your OIDC provider to access the dashboard"
|
||||
: "Enter your password to access the dashboard"}
|
||||
</p>
|
||||
@@ -171,25 +189,31 @@ export default function LoginPage() {
|
||||
</form>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4">
|
||||
{samlAvailable && (
|
||||
<Button type="button" variant="primary" className="w-full" onClick={handleSamlLogin}>
|
||||
{samlLoginLabel}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{oidcAvailable && (
|
||||
<Button type="button" variant="primary" className="w-full" onClick={handleOidcLogin}>
|
||||
{oidcLoginLabel}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{oidcAvailable && passwordAvailable && <div className="h-px bg-border/60" />}
|
||||
{ssoAvailable && passwordAvailable && <div className="h-px bg-border/60" />}
|
||||
|
||||
{passwordAvailable ? (
|
||||
<form onSubmit={handleLogin} className="flex flex-col gap-4">
|
||||
{((authMode === "oidc" && !oidcConfigured) || (authMode === "both" && !oidcConfigured)) && (
|
||||
{isSsoEnabled && !ssoAvailable && (
|
||||
<p className="text-xs text-amber-600 dark:text-amber-400 text-center">
|
||||
OIDC login is enabled, but the issuer/client fields are not configured yet. Password login is still available for recovery.
|
||||
{activeSsoType === "saml" ? "SAML SSO" : "OIDC"} login is enabled, but configuration is incomplete. Password login is still available for recovery.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{authMode === "both" && oidcConfigured && (
|
||||
{authMode === "both" && ssoAvailable && (
|
||||
<p className="text-xs text-text-muted text-center">
|
||||
Password and OIDC login are both enabled.
|
||||
Password and {activeSsoType === "saml" ? "SAML SSO" : "OIDC"} login are both enabled.
|
||||
</p>
|
||||
)}
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ const PUBLIC_API_PATHS = [
|
||||
"/api/auth/logout",
|
||||
"/api/auth/status",
|
||||
"/api/auth/oidc",
|
||||
"/api/auth/saml",
|
||||
"/api/version",
|
||||
"/api/settings/require-login",
|
||||
];
|
||||
|
||||
268
src/lib/auth/saml.js
Normal file
268
src/lib/auth/saml.js
Normal file
@@ -0,0 +1,268 @@
|
||||
import { SAML } from "@node-saml/node-saml";
|
||||
import { getSettings } from "../db/repos/settingsRepo.js";
|
||||
|
||||
/**
|
||||
* Formats a raw Base64 string or unformatted X.509 certificate into standard PEM format.
|
||||
* @param {string} certStr
|
||||
* @returns {string}
|
||||
*/
|
||||
export function formatX509Certificate(certStr) {
|
||||
if (!certStr || typeof certStr !== "string") return "";
|
||||
const clean = certStr
|
||||
.replace(/-----BEGIN CERTIFICATE-----/gi, "")
|
||||
.replace(/-----END CERTIFICATE-----/gi, "")
|
||||
.replace(/[^A-Za-z0-9+/=]/g, "");
|
||||
|
||||
if (!clean) return "";
|
||||
|
||||
const lines = clean.match(/.{1,64}/g) || [];
|
||||
return `-----BEGIN CERTIFICATE-----\n${lines.join("\n")}\n-----END CERTIFICATE-----`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether SAML configuration has essential parameters (entryPoint & cert).
|
||||
* @param {object} settings
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isSamlConfigured(settings) {
|
||||
return Boolean(settings?.samlEntryPoint && settings?.samlCert);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches settings and returns runtime status + settings.
|
||||
* @returns {Promise<{ configured: boolean, settings: object }>}
|
||||
*/
|
||||
export async function getSamlRuntimeConfig() {
|
||||
const settings = await getSettings();
|
||||
return {
|
||||
configured: isSamlConfigured(settings),
|
||||
settings,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a configured `@node-saml/node-saml` SAML instance with security defaults.
|
||||
* @param {object} settings
|
||||
* @param {string} origin
|
||||
* @returns {SAML}
|
||||
*/
|
||||
const DUMMY_FALLBACK_CERT =
|
||||
"-----BEGIN CERTIFICATE-----\nMIIC...DUMMY...\n-----END CERTIFICATE-----";
|
||||
|
||||
function trimTrailingSlashes(str) {
|
||||
return (str || "").replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the public Base URL / Origin for SAML requests.
|
||||
* Respects settings.baseUrl, process.env.BASE_URL, x-forwarded-proto, and x-forwarded-host.
|
||||
* @param {Request} request
|
||||
* @param {object} settings
|
||||
* @returns {string}
|
||||
*/
|
||||
export function getSamlBaseUrl(request, settings) {
|
||||
const configuredBaseUrl =
|
||||
(settings?.baseUrl || "").trim() ||
|
||||
process.env.BASE_URL ||
|
||||
process.env.NEXT_PUBLIC_BASE_URL ||
|
||||
"";
|
||||
|
||||
if (configuredBaseUrl) {
|
||||
return trimTrailingSlashes(configuredBaseUrl);
|
||||
}
|
||||
|
||||
if (request) {
|
||||
const forwardedProto = request?.headers?.get?.("x-forwarded-proto") || "";
|
||||
const forwardedHost = request?.headers?.get?.("x-forwarded-host") || "";
|
||||
const host = forwardedHost || request?.headers?.get?.("host") || "";
|
||||
if (host) {
|
||||
const protocol = (forwardedProto || new URL(request.url).protocol || "http:").replace(/:$/, "");
|
||||
return `${protocol}://${host}`.replace(/\/+$/, "");
|
||||
}
|
||||
if (request.url) {
|
||||
return trimTrailingSlashes(new URL(request.url).origin);
|
||||
}
|
||||
}
|
||||
|
||||
return "http://localhost:20128";
|
||||
}
|
||||
|
||||
export function createSamlInstance(settings, origin) {
|
||||
const cert = formatX509Certificate(settings?.samlCert || "") || DUMMY_FALLBACK_CERT;
|
||||
const callbackUrl = `${origin}/api/auth/saml/acs`;
|
||||
return new SAML({
|
||||
entryPoint: settings?.samlEntryPoint || "https://example.com/sso",
|
||||
issuer: settings?.samlIssuer || "urn:9router:sp",
|
||||
idpCert: cert,
|
||||
cert: cert,
|
||||
callbackUrl: callbackUrl,
|
||||
acceptedClockSkewMs: 60000,
|
||||
wantAssertionsSigned: true,
|
||||
validateInResponseTo: "never",
|
||||
requestIdExpirationMs: 28800000, // 8 hours
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds SAML AuthnRequest redirect URL and returns { authorizeUrl, requestId }.
|
||||
* @param {Request} request
|
||||
* @param {object} settings
|
||||
* @returns {Promise<{ authorizeUrl: string, requestId: string }>}
|
||||
*/
|
||||
export async function buildSamlAuthorizeUrl(request, settings) {
|
||||
const origin = getSamlBaseUrl(request, settings);
|
||||
const samlInstance = createSamlInstance(settings, origin);
|
||||
|
||||
const xml = await samlInstance.generateAuthorizeRequestAsync(false, false);
|
||||
const match = xml.match(/ID="([^"]+)"/);
|
||||
const requestId = match ? match[1] : "";
|
||||
|
||||
const authorizeUrl = await samlInstance._requestToUrlAsync(xml, null, "authorize", {});
|
||||
|
||||
return { authorizeUrl, requestId };
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates SAML POST response from IdP ACS callback and returns user profile.
|
||||
* @param {Request} request
|
||||
* @param {object} body - Parsed form body or object containing SAMLResponse
|
||||
* @param {string} expectedRequestId - Request ID stored in saml_state cookie
|
||||
* @param {object} settings
|
||||
* @returns {Promise<object>}
|
||||
*/
|
||||
export async function validateSamlResponse(request, body, expectedRequestId, settings) {
|
||||
if (!settings?.samlCert) {
|
||||
throw new Error("IdP X.509 Certificate (samlCert) is missing or not configured");
|
||||
}
|
||||
|
||||
const origin = getSamlBaseUrl(request, settings);
|
||||
const samlInstance = createSamlInstance(settings, origin);
|
||||
|
||||
const container = typeof body === "object" && body !== null ? body : { SAMLResponse: body };
|
||||
const rawSamlResponse = container.SAMLResponse;
|
||||
|
||||
if (!rawSamlResponse) {
|
||||
throw new Error("Missing SAMLResponse parameter in assertion POST body");
|
||||
}
|
||||
|
||||
// Parse response XML to inspect InResponseTo for replay protection
|
||||
if (expectedRequestId) {
|
||||
const xml = Buffer.from(rawSamlResponse, "base64").toString("utf8");
|
||||
const match = xml.match(/InResponseTo=["']([^"']+)["']/i);
|
||||
const inResponseTo = match ? match[1] : null;
|
||||
|
||||
if (!inResponseTo || inResponseTo !== expectedRequestId) {
|
||||
throw new Error(`InResponseTo mismatch: expected ${expectedRequestId}, received ${inResponseTo || "none"}`);
|
||||
}
|
||||
}
|
||||
|
||||
const result = await samlInstance.validatePostResponseAsync({ SAMLResponse: rawSamlResponse });
|
||||
const profile = result?.profile || result;
|
||||
|
||||
return profile;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates standard SP XML Metadata.
|
||||
* @param {string} origin
|
||||
* @param {object} settings
|
||||
* @returns {string}
|
||||
*/
|
||||
export function generateSamlMetadata(origin, settings) {
|
||||
const samlInstance = createSamlInstance(settings, origin);
|
||||
return samlInstance.generateServiceProviderMetadata();
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts email claim from SAML profile assertion.
|
||||
* @param {object} profile
|
||||
* @param {object} settings
|
||||
* @returns {string}
|
||||
*/
|
||||
export function pickSamlEmail(profile = {}, settings = {}) {
|
||||
if (!profile) return "";
|
||||
|
||||
// 1. Configured custom attribute
|
||||
const customAttr = settings.samlAttributeEmail;
|
||||
if (customAttr && profile[customAttr]) {
|
||||
const val = profile[customAttr];
|
||||
return Array.isArray(val) ? val[0] : String(val);
|
||||
}
|
||||
|
||||
// 2. Common email claims
|
||||
const emailKeys = [
|
||||
"email",
|
||||
"emailAddress",
|
||||
"mail",
|
||||
"nameID",
|
||||
"nameId",
|
||||
"upn",
|
||||
"http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress",
|
||||
"http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier",
|
||||
"http://schemas.xmlsoap.org/ws/2005/05/identity/claims/upn",
|
||||
];
|
||||
|
||||
for (const key of emailKeys) {
|
||||
if (profile[key]) {
|
||||
const val = profile[key];
|
||||
return Array.isArray(val) ? val[0] : String(val);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Fallback: check attributes object if present
|
||||
if (profile.attributes) {
|
||||
for (const key of emailKeys) {
|
||||
if (profile.attributes[key]) {
|
||||
const val = profile.attributes[key];
|
||||
return Array.isArray(val) ? val[0] : String(val);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts display name claim from SAML profile assertion.
|
||||
* @param {object} profile
|
||||
* @param {object} settings
|
||||
* @returns {string}
|
||||
*/
|
||||
export function pickSamlDisplayName(profile = {}, settings = {}) {
|
||||
if (!profile) return "";
|
||||
|
||||
// 1. Configured custom attribute
|
||||
const customAttr = settings.samlAttributeName;
|
||||
if (customAttr && profile[customAttr]) {
|
||||
const val = profile[customAttr];
|
||||
return Array.isArray(val) ? val[0] : String(val);
|
||||
}
|
||||
|
||||
// 2. Common name claims
|
||||
const nameKeys = [
|
||||
"displayName",
|
||||
"name",
|
||||
"cn",
|
||||
"commonName",
|
||||
"http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name",
|
||||
"http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname",
|
||||
];
|
||||
|
||||
for (const key of nameKeys) {
|
||||
if (profile[key]) {
|
||||
const val = profile[key];
|
||||
return Array.isArray(val) ? val[0] : String(val);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Combined givenName + surname
|
||||
if (profile.givenName || profile.sn || profile.surname) {
|
||||
const given = profile.givenName || "";
|
||||
const surname = profile.sn || profile.surname || "";
|
||||
const combined = `${given} ${surname}`.trim();
|
||||
if (combined) return combined;
|
||||
}
|
||||
|
||||
// 4. Fallback to email
|
||||
return pickSamlEmail(profile, settings);
|
||||
}
|
||||
@@ -27,11 +27,18 @@ const DEFAULT_SETTINGS = {
|
||||
requireApiKey: true,
|
||||
tunnelDashboardAccess: true,
|
||||
authMode: "password",
|
||||
ssoType: "oidc",
|
||||
oidcIssuerUrl: "",
|
||||
oidcClientId: "",
|
||||
oidcClientSecret: "",
|
||||
oidcScopes: "openid profile email",
|
||||
oidcLoginLabel: "Sign in with OIDC",
|
||||
samlEntryPoint: "",
|
||||
samlIssuer: "urn:9router:sp",
|
||||
samlCert: "",
|
||||
samlLoginLabel: "Sign in with SAML SSO",
|
||||
samlAttributeEmail: "email",
|
||||
samlAttributeName: "name",
|
||||
enableObservability: false,
|
||||
observabilityMaxRecords: 1000,
|
||||
observabilityBatchSize: 20,
|
||||
@@ -63,7 +70,7 @@ async function readRaw() {
|
||||
}
|
||||
|
||||
// Merge raw settings with defaults; backward-compat for missing keys
|
||||
function mergeWithDefaults(raw) {
|
||||
export function mergeWithDefaults(raw) {
|
||||
const merged = { ...DEFAULT_SETTINGS, ...(raw || {}) };
|
||||
for (const [key, defVal] of Object.entries(DEFAULT_SETTINGS)) {
|
||||
if (merged[key] === undefined) {
|
||||
|
||||
@@ -198,7 +198,7 @@ export default function Header({ onMenuClick, showMenuButton = true }) {
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
if (!cancelled) {
|
||||
setDisplayName(data?.displayName || data?.oidcName || data?.oidcEmail || "");
|
||||
setDisplayName(data?.displayName || data?.samlName || data?.samlEmail || data?.oidcName || data?.oidcEmail || "");
|
||||
setLoginMethod(data?.loginMethod || "");
|
||||
}
|
||||
} catch {
|
||||
@@ -303,12 +303,15 @@ export default function Header({ onMenuClick, showMenuButton = true }) {
|
||||
|
||||
{/* Right actions */}
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{displayName && loginMethod === "OIDC" && (
|
||||
<div className="hidden sm:flex items-center max-w-[220px] px-3 py-1.5 rounded-full border border-border bg-surface/70 text-xs text-text-muted truncate">
|
||||
{displayName && (loginMethod === "OIDC" || loginMethod === "SAML") && (
|
||||
<div
|
||||
className="hidden sm:flex items-center max-w-[220px] px-3 py-1.5 rounded-full border border-border bg-surface/70 text-xs text-text-muted truncate"
|
||||
title={displayName}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px] mr-1.5 text-primary">person</span>
|
||||
<span className="truncate">{displayName}</span>
|
||||
<span className="ml-2 shrink-0 rounded-full bg-primary/10 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-primary">
|
||||
OIDC
|
||||
{loginMethod}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
40
tests/auth/saml.test.js
Normal file
40
tests/auth/saml.test.js
Normal file
@@ -0,0 +1,40 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
formatX509Certificate,
|
||||
isSamlConfigured,
|
||||
generateSamlMetadata,
|
||||
pickSamlEmail,
|
||||
pickSamlDisplayName,
|
||||
} from "../../src/lib/auth/saml.js";
|
||||
|
||||
test("formatX509Certificate normalizes Base64 strings into PEM blocks", () => {
|
||||
const rawBase64 = "MIIC1234567890123456789012345678901234567890123456789012345678901234567890";
|
||||
const formatted = formatX509Certificate(rawBase64);
|
||||
assert.match(formatted, /-----BEGIN CERTIFICATE-----/);
|
||||
assert.match(formatted, /-----END CERTIFICATE-----/);
|
||||
assert.equal(formatX509Certificate(""), "");
|
||||
});
|
||||
|
||||
test("isSamlConfigured checks required fields", () => {
|
||||
assert.equal(isSamlConfigured({ samlEntryPoint: "https://idp.com/sso", samlCert: "cert" }), true);
|
||||
assert.equal(isSamlConfigured({ samlEntryPoint: "https://idp.com/sso" }), false);
|
||||
assert.equal(isSamlConfigured({}), false);
|
||||
});
|
||||
|
||||
test("generateSamlMetadata produces valid SP XML", () => {
|
||||
const settings = {
|
||||
samlEntryPoint: "https://idp.example.com/sso",
|
||||
samlIssuer: "urn:9router:sp",
|
||||
samlCert: "MIIC123456789012345678901234567890123456789012345678901234567890",
|
||||
};
|
||||
const xml = generateSamlMetadata("https://localhost:20127", settings);
|
||||
assert.match(xml, /entityID="urn:9router:sp"/);
|
||||
assert.match(xml, /Location="https:\/\/localhost:20127\/api\/auth\/saml\/acs"/);
|
||||
});
|
||||
|
||||
test("Claims Extraction pickSamlEmail & pickSamlDisplayName", () => {
|
||||
const profile = { email: "test@example.com", name: "Test User" };
|
||||
assert.equal(pickSamlEmail(profile, {}), "test@example.com");
|
||||
assert.equal(pickSamlDisplayName(profile, {}), "Test User");
|
||||
});
|
||||
144
tests/unit/saml.test.js
Normal file
144
tests/unit/saml.test.js
Normal file
@@ -0,0 +1,144 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
formatX509Certificate,
|
||||
isSamlConfigured,
|
||||
generateSamlMetadata,
|
||||
pickSamlEmail,
|
||||
pickSamlDisplayName,
|
||||
validateSamlResponse,
|
||||
} from "../../src/lib/auth/saml.js";
|
||||
import { mergeWithDefaults } from "../../src/lib/db/repos/settingsRepo.js";
|
||||
|
||||
describe("SAML 2.0 Auth Engine Utilities", () => {
|
||||
describe("formatX509Certificate", () => {
|
||||
it("formats raw Base64 string into standard 64-column PEM block", () => {
|
||||
const rawBase64 = "MIIC1234567890123456789012345678901234567890123456789012345678901234567890";
|
||||
const formatted = formatX509Certificate(rawBase64);
|
||||
expect(formatted).toContain("-----BEGIN CERTIFICATE-----");
|
||||
expect(formatted).toContain("-----END CERTIFICATE-----");
|
||||
expect(formatted).toContain("MIIC123456789012345678901234567890123456789012345678901234567890");
|
||||
expect(formatted).toContain("\n1234567890\n");
|
||||
});
|
||||
|
||||
it("cleans existing PEM header/footer and extra whitespace", () => {
|
||||
const rawPem = `
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIC123456789012345678901234567890123456789012345678901234567890
|
||||
1234567890
|
||||
-----END CERTIFICATE-----
|
||||
`;
|
||||
const formatted = formatX509Certificate(rawPem);
|
||||
expect(formatted).toContain("-----BEGIN CERTIFICATE-----");
|
||||
expect(formatted.match(/BEGIN CERTIFICATE/g)?.length).toBe(1);
|
||||
});
|
||||
|
||||
it("returns empty string for null, undefined, or invalid inputs", () => {
|
||||
expect(formatX509Certificate(null)).toBe("");
|
||||
expect(formatX509Certificate(undefined)).toBe("");
|
||||
expect(formatX509Certificate(" ")).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isSamlConfigured", () => {
|
||||
it("returns true when entryPoint and cert are non-empty", () => {
|
||||
expect(
|
||||
isSamlConfigured({
|
||||
samlEntryPoint: "https://idp.example.com/sso",
|
||||
samlCert: "dummy-cert",
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false if entryPoint or cert is missing", () => {
|
||||
expect(isSamlConfigured({ samlEntryPoint: "https://idp.example.com/sso" })).toBe(false);
|
||||
expect(isSamlConfigured({ samlCert: "dummy-cert" })).toBe(false);
|
||||
expect(isSamlConfigured({})).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("generateSamlMetadata", () => {
|
||||
it("generates valid SP XML metadata with Entity ID and ACS binding", () => {
|
||||
const settings = {
|
||||
samlEntryPoint: "https://idp.example.com/sso",
|
||||
samlIssuer: "urn:9router:sp",
|
||||
samlCert: "MIIC123456789012345678901234567890123456789012345678901234567890",
|
||||
};
|
||||
const xml = generateSamlMetadata("https://localhost:20127", settings);
|
||||
expect(xml).toContain('entityID="urn:9router:sp"');
|
||||
expect(xml).toContain('Location="https://localhost:20127/api/auth/saml/acs"');
|
||||
expect(xml).toContain('WantAssertionsSigned="true"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("InResponseTo Replay Validation", () => {
|
||||
it("throws error when expectedRequestId is supplied but InResponseTo is missing", async () => {
|
||||
const settings = { samlCert: "dummy-cert" };
|
||||
const rawXml = Buffer.from('<Response ID="123"></Response>').toString("base64");
|
||||
await expect(
|
||||
validateSamlResponse(null, { SAMLResponse: rawXml }, "req-123", settings)
|
||||
).rejects.toThrow(/InResponseTo mismatch/);
|
||||
});
|
||||
|
||||
it("throws error when expectedRequestId is supplied but InResponseTo does not match", async () => {
|
||||
const settings = { samlCert: "dummy-cert" };
|
||||
const rawXml = Buffer.from('<Response InResponseTo="wrong-id"></Response>').toString("base64");
|
||||
await expect(
|
||||
validateSamlResponse(null, { SAMLResponse: rawXml }, "req-123", settings)
|
||||
).rejects.toThrow(/InResponseTo mismatch/);
|
||||
});
|
||||
|
||||
it("throws error if samlCert is not configured", async () => {
|
||||
const rawXml = Buffer.from('<Response ID="123"></Response>').toString("base64");
|
||||
await expect(
|
||||
validateSamlResponse(null, { SAMLResponse: rawXml }, "req-123", {})
|
||||
).rejects.toThrow(/Certificate/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Claims Extraction", () => {
|
||||
const mockProfile = {
|
||||
email: "user@example.com",
|
||||
displayName: "Jane Doe",
|
||||
"http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress": ["custom@example.com"],
|
||||
customEmail: "custom-email@example.com",
|
||||
customName: "Custom User",
|
||||
};
|
||||
|
||||
it("pickSamlEmail extracts custom attribute or common claims", () => {
|
||||
expect(pickSamlEmail(mockProfile, {})).toBe("user@example.com");
|
||||
expect(
|
||||
pickSamlEmail(mockProfile, { samlAttributeEmail: "customEmail" })
|
||||
).toBe("custom-email@example.com");
|
||||
expect(
|
||||
pickSamlEmail(
|
||||
{ "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress": ["custom@example.com"] },
|
||||
{}
|
||||
)
|
||||
).toBe("custom@example.com");
|
||||
});
|
||||
|
||||
it("pickSamlDisplayName extracts custom attribute, common names, or falls back to email", () => {
|
||||
expect(pickSamlDisplayName(mockProfile, {})).toBe("Jane Doe");
|
||||
expect(
|
||||
pickSamlDisplayName(mockProfile, { samlAttributeName: "customName" })
|
||||
).toBe("Custom User");
|
||||
expect(
|
||||
pickSamlDisplayName({ email: "user@example.com" }, {})
|
||||
).toBe("user@example.com");
|
||||
expect(
|
||||
pickSamlDisplayName({ givenName: "Alice", surname: "Smith" }, {})
|
||||
).toBe("Alice Smith");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Settings Repository Defaults", () => {
|
||||
it("mergeWithDefaults safely populates SAML defaults for existing installations", () => {
|
||||
const merged = mergeWithDefaults({ authMode: "password" });
|
||||
expect(merged.ssoType).toBe("oidc");
|
||||
expect(merged.samlIssuer).toBe("urn:9router:sp");
|
||||
expect(merged.samlLoginLabel).toBe("Sign in with SAML SSO");
|
||||
expect(merged.samlAttributeEmail).toBe("email");
|
||||
expect(merged.samlAttributeName).toBe("name");
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user