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:
1 parent
e02bde4a70
commit
65197ad11c
17 files changed
+1396
-166
No files matched your search
+32
-8
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
Reference in new issue
Block a user