217 lines
9 KiB
JavaScript
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
|
|
};
|