feat(enterprise): add SAML 2.0 SSO with SP-initiated login

Implements SAML SSO using @node-saml/node-saml, gated behind
SAML_ENABLED env var and the saml_sso enterprise license feature.

- SAML env vars (entity ID, callback URL, IdP SSO URL, IdP cert,
  auto-create/auto-link users, default role, provider name,
  username/email attribute mapping) with validation in superRefine
- SAML plugin with three routes: metadata (GET), login (GET),
  and ACS callback (POST with form-urlencoded content type parser)
- Callback uses the shared external-auth resolver for user
  resolution (same pattern as OIDC: match/link/create/deny)
- Auth config endpoint exposes samlEnabled and samlProviderName
- Session loginMethod detection updated for SAML auth provider
- Frontend login page shows SAML SSO button when enabled
- i18n strings for SAML error messages across all 21 locales
This commit is contained in:
SnapOtter
2026-06-13 22:27:56 +08:00
parent 6920035f5a
commit 54132d1833
27 changed files with 383 additions and 13 deletions
+10
View File
@@ -14,6 +14,8 @@ interface AuthState {
analyticsConsentRemindAt: number | null;
oidcEnabled: boolean;
oidcProviderName: string | null;
samlEnabled: boolean;
samlProviderName: string | null;
loginMethod: string | null;
hasLocalPassword: boolean;
}
@@ -48,6 +50,8 @@ export function useAuth() {
analyticsConsentRemindAt: null,
oidcEnabled: false,
oidcProviderName: null,
samlEnabled: false,
samlProviderName: null,
loginMethod: null,
hasLocalPassword: false,
});
@@ -74,6 +78,8 @@ export function useAuth() {
analyticsConsentRemindAt: null,
oidcEnabled: false,
oidcProviderName: null,
samlEnabled: false,
samlProviderName: null,
loginMethod: null,
hasLocalPassword: false,
});
@@ -102,6 +108,8 @@ export function useAuth() {
analyticsConsentRemindAt: session.user?.analyticsConsentRemindAt ?? null,
oidcEnabled: config.oidcEnabled ?? false,
oidcProviderName: config.oidcProviderName ?? null,
samlEnabled: config.samlEnabled ?? false,
samlProviderName: config.samlProviderName ?? null,
loginMethod: session.user?.loginMethod ?? null,
hasLocalPassword: session.user?.hasLocalPassword ?? false,
});
@@ -120,6 +128,8 @@ export function useAuth() {
analyticsConsentRemindAt: null,
oidcEnabled: config.oidcEnabled ?? false,
oidcProviderName: config.oidcProviderName ?? null,
samlEnabled: config.samlEnabled ?? false,
samlProviderName: config.samlProviderName ?? null,
loginMethod: null,
hasLocalPassword: false,
});
+24 -11
View File
@@ -127,7 +127,7 @@ function LanguageSelector() {
export function LoginPage() {
const { t } = useTranslation();
const { oidcEnabled, oidcProviderName } = useAuth();
const { oidcEnabled, oidcProviderName, samlEnabled, samlProviderName } = useAuth();
const [searchParams] = useSearchParams();
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
@@ -135,16 +135,19 @@ export function LoginPage() {
const [loading, setLoading] = useState(false);
useEffect(() => {
const oidcError = searchParams.get("error");
if (oidcError) {
const authError = searchParams.get("error");
if (authError) {
const errorMessages: Record<string, string> = {
oidc_auth_failed: t.auth.oidcAuthFailed,
oidc_provider_unreachable: t.auth.oidcProviderUnreachable,
oidc_session_expired: t.auth.oidcSessionExpired,
oidc_user_not_authorized: t.auth.oidcUserNotAuthorized,
oidc_user_limit_reached: t.auth.oidcUserLimitReached,
saml_auth_failed: t.auth.samlAuthFailed,
saml_user_not_authorized: t.auth.samlUserNotAuthorized,
saml_user_limit_reached: t.auth.samlUserLimitReached,
};
setError(errorMessages[oidcError] || t.auth.oidcGenericError);
setError(errorMessages[authError] || t.auth.oidcGenericError);
}
}, [searchParams, t]);
@@ -230,19 +233,29 @@ export function LoginPage() {
{loading ? t.auth.loggingIn : t.auth.loginButton}
</button>
</form>
{oidcEnabled && (
{(oidcEnabled || samlEnabled) && (
<>
<div className="flex items-center gap-3 my-4">
<div className="flex-1 border-t border-border" />
<span className="text-sm text-muted-foreground">{t.auth.or}</span>
<div className="flex-1 border-t border-border" />
</div>
<a
href="/api/auth/oidc/login"
className="w-full py-3 rounded-lg bg-secondary text-secondary-foreground font-medium hover:bg-secondary/80 transition-colors flex items-center justify-center gap-2"
>
{format(t.auth.signInWith, { provider: oidcProviderName || "SSO" })}
</a>
{oidcEnabled && (
<a
href="/api/auth/oidc/login"
className="w-full py-3 rounded-lg bg-secondary text-secondary-foreground font-medium hover:bg-secondary/80 transition-colors flex items-center justify-center gap-2"
>
{format(t.auth.signInWith, { provider: oidcProviderName || "SSO" })}
</a>
)}
{samlEnabled && (
<a
href="/api/auth/saml/login"
className="w-full py-3 rounded-lg bg-secondary text-secondary-foreground font-medium hover:bg-secondary/80 transition-colors flex items-center justify-center gap-2 mt-2"
>
{format(t.auth.signInWith, { provider: samlProviderName || "SSO" })}
</a>
)}
</>
)}
<div className="pt-2">