// 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: '{Issuer}{Issuer}{NameID}{Audience}{AuthnStatement}{AttributeStatement}', 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 };