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
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user