sc-idp/lib/saml/idp.js
2026-06-01 16:40:54 -05:00

217 lines
9 KiB
JavaScript

// SAML 2.0 Identity Provider built on samlify (CommonJS). Signs assertions with
// RSA-SHA256 using a per-tenant self-signed cert (persisted in _idp_saml, sealed
// key). One IdP entity is cached per issuer (host-derived), mirroring the OIDC
// provider cache, so multi-tenant deployments get per-tenant signing material.
// The AttributeStatement (email + groups) reuses claims.samlAttributes.
//
// Supports SP-initiated SSO, IdP-initiated SSO, and Single Logout. Relying-party
// SPs are registered + validated against an ACS allow-list (lib/saml/sps.js), so
// an arbitrary request-supplied ACS is never trusted. xml-crypto is pinned
// >=6.1.2 (2025 SAML signature-verification CVEs patched) and asserted at load.
const saml = require("samlify");
const validator = require("@authenio/samlify-node-xmllint");
const db = require("@saltcorn/data/db");
const selfsigned = require("selfsigned");
const idpCrypto = require("../crypto");
const constants = require("../constants");
// Fail closed at load if xml-crypto is below the patched floor (2025 SAML
// signature-verification CVEs). One source of truth: constants.XML_CRYPTO_MIN.
const assertXmlCryptoFloor = () => {
const min = constants.XML_CRYPTO_MIN.split(".").map((n) => parseInt(n, 10));
let cur;
try {
cur = String(require("xml-crypto/package.json").version).split(".").map((n) => parseInt(n, 10));
} catch (e) {
throw new Error("saltcorn-idp: cannot resolve xml-crypto version");
}
for (let i = 0; i < min.length; i++) {
const c = cur[i] || 0;
if (c > min[i]) {
return;
}
if (c < min[i]) {
throw new Error("saltcorn-idp: xml-crypto " + cur.join(".") + " < required " + constants.XML_CRYPTO_MIN);
}
}
};
assertXmlCryptoFloor();
saml.setSchemaValidator(validator);
// Re-assert samlify's XXE-safe DOMParser options from our own code so a future
// samlify default change cannot silently re-enable entity expansion. Passing {}
// merges in the throwing error/fatalError handlers (samlify api.js).
if (typeof saml.setDOMParserOptions === "function") {
saml.setDOMParserOptions({});
}
const idpCache = new Map();
// Response template carrying an AttributeStatement; samlify fills the standard
// tokens, the attributes[] generate the AttributeStatement with {Email}/{Groups}
// value tags, and createLoginResponse's customTagReplacement fills those.
const LOGIN_RESPONSE_TEMPLATE = {
context: '<samlp:Response xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" ID="{ID}" Version="2.0" IssueInstant="{IssueInstant}" Destination="{Destination}" InResponseTo="{InResponseTo}"><saml:Issuer>{Issuer}</saml:Issuer><samlp:Status><samlp:StatusCode Value="{StatusCode}"/></samlp:Status><saml:Assertion xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xs="http://www.w3.org/2001/XMLSchema" ID="{AssertionID}" Version="2.0" IssueInstant="{IssueInstant}"><saml:Issuer>{Issuer}</saml:Issuer><saml:Subject><saml:NameID Format="{NameIDFormat}">{NameID}</saml:NameID><saml:SubjectConfirmation Method="urn:oasis:names:tc:SAML:2.0:cm:bearer"><saml:SubjectConfirmationData NotOnOrAfter="{SubjectConfirmationDataNotOnOrAfter}" Recipient="{SubjectRecipient}" InResponseTo="{InResponseTo}"/></saml:SubjectConfirmation></saml:Subject><saml:Conditions NotBefore="{ConditionsNotBefore}" NotOnOrAfter="{ConditionsNotOnOrAfter}"><saml:AudienceRestriction><saml:Audience>{Audience}</saml:Audience></saml:AudienceRestriction></saml:Conditions>{AuthnStatement}{AttributeStatement}</saml:Assertion></samlp:Response>',
attributes: [
{ name: "email", nameFormat: "urn:oasis:names:tc:SAML:2.0:attrname-format:basic", valueXsiType: "xs:string", valueTag: "Email" },
{ name: "groups", nameFormat: "urn:oasis:names:tc:SAML:2.0:attrname-format:basic", valueXsiType: "xs:string", valueTag: "Groups" }
]
};
const ensureSamlCert = async () => {
const existing = await db.selectMaybeOne(constants.TABLE_SAML, { id: "default" });
if (existing) {
return;
}
const pems = await selfsigned.generate(
[{ name: "commonName", value: "saltcorn-idp-saml" }],
{ keyType: "rsa", keySize: 2048, algorithm: "sha256" }
);
const sealed = idpCrypto.sealText(pems.private);
await db.insert(constants.TABLE_SAML, {
id: "default",
cert: pems.cert,
private_ciphertext: sealed.ciphertext,
private_iv: sealed.iv,
private_tag: sealed.tag,
created_at: new Date().toISOString()
}, { noid: true });
};
const getSamlCert = async () => {
const row = await db.selectMaybeOne(constants.TABLE_SAML, { id: "default" });
if (!row) {
return null;
}
const key = idpCrypto.openText({
ciphertext: row.private_ciphertext,
iv: row.private_iv,
tag: row.private_tag
}).toString("utf8");
return { cert: row.cert, key: key };
};
// Construct an IdP entity for an issuer. The signed flag drives whether samlify
// cryptographically verifies inbound AuthnRequest AND LogoutRequest signatures
// (only used for SPs that registered a signing cert). singleLogoutService
// advertises SLO in the published metadata.
const buildIdp = (issuer, c, signed) => {
return saml.IdentityProvider({
entityID: issuer + "/saml",
privateKey: c.key,
signingCert: c.cert,
wantAuthnRequestsSigned: !!signed,
wantLogoutRequestSigned: !!signed,
singleSignOnService: [
{ Binding: saml.Constants.namespace.binding.post, Location: issuer + "/saml/sso" },
{ Binding: saml.Constants.namespace.binding.redirect, Location: issuer + "/saml/sso" }
],
singleLogoutService: [
{ Binding: saml.Constants.namespace.binding.post, Location: issuer + "/saml/slo" },
{ Binding: saml.Constants.namespace.binding.redirect, Location: issuer + "/saml/slo" }
],
nameIDFormat: [saml.Constants.namespace.format.emailAddress],
loginResponseTemplate: LOGIN_RESPONSE_TEMPLATE
});
};
const getIdp = async (issuer) => {
if (idpCache.has(issuer)) {
return idpCache.get(issuer);
}
const c = await getSamlCert();
if (!c) {
throw new Error("saltcorn-idp: no SAML signing cert for issuer " + issuer);
}
const idp = buildIdp(issuer, c, false);
idpCache.set(issuer, idp);
return idp;
};
// IdP variant that REQUIRES + verifies AuthnRequest signatures. Used per-request
// for SPs registered with want_authn_requests_signed; cached under a distinct
// key so the default (unsigned) IdP is unaffected.
const getSignedIdp = async (issuer) => {
const cacheKey = issuer + "#signed";
if (idpCache.has(cacheKey)) {
return idpCache.get(cacheKey);
}
const c = await getSamlCert();
if (!c) {
throw new Error("saltcorn-idp: no SAML signing cert for issuer " + issuer);
}
const idp = buildIdp(issuer, c, true);
idpCache.set(cacheKey, idp);
return idp;
};
const getMetadata = async (issuer) => {
const idp = await getIdp(issuer);
return idp.getMetadata();
};
// An SP entity for a REGISTERED relying party. entityID + acsUrl come from the
// registry (validated against the SP's allow-list), never trusted verbatim from
// the request. When opts.signingCert is supplied the SP's signing cert is set so
// samlify can verify the AuthnRequest signature against it.
const buildSp = (entityID, acsUrl, opts) => {
opts = opts || {};
const settings = {
entityID: entityID,
assertionConsumerService: [
{ Binding: saml.Constants.namespace.binding.post, Location: acsUrl }
]
};
if (opts.signingCert) {
settings.signingCert = opts.signingCert;
settings.authnRequestsSigned = true;
}
return saml.ServiceProvider(settings);
};
// SP entity used when building a LogoutResponse: wantLogoutResponseSigned makes
// samlify sign the outbound LogoutResponse (the target/SP setting governs this).
const buildSpForLogout = (entityID, sloUrl, opts) => {
opts = opts || {};
const settings = {
entityID: entityID,
assertionConsumerService: [
{ Binding: saml.Constants.namespace.binding.post, Location: sloUrl }
],
singleLogoutService: [
{ Binding: saml.Constants.namespace.binding.post, Location: sloUrl },
{ Binding: saml.Constants.namespace.binding.redirect, Location: sloUrl }
],
wantLogoutResponseSigned: true
};
if (opts.signingCert) {
settings.signingCert = opts.signingCert;
}
return saml.ServiceProvider(settings);
};
module.exports = {
// samlify's flow methods (parseLoginRequest/createLoginResponse) take the
// SHORT binding names; the full URNs are only for the metadata settings above.
POST_BINDING: "post",
REDIRECT_BINDING: "redirect",
ensureSamlCert,
getIdp,
getSignedIdp,
getMetadata,
buildSp,
buildSpForLogout
};