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:
Duc Nguyen authored and decolua committed 2026-08-13 17:56:34 +07:00
1 parent e02bde4a70
commit 65197ad11c
17 files changed
+1396 -166

No files matched your search

+9 -2
View File
@@ -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
View 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
View 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
View 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
View 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 });
}
}
+25 -2
View File
@@ -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,
});
}
}