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:
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) {
|
||||
|
||||
Reference in New Issue
Block a user