UNPKG

@pagopa/io-spid-commons

Version:

Common code for integrating SPID authentication

508 lines 37.1 kB
"use strict"; var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } return new (P || (P = Promise))(function (resolve, reject) { function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } step((generator = generator.apply(thisArg, _arguments || [])).next()); }); }; Object.defineProperty(exports, "__esModule", { value: true }); exports.assertionValidation = exports.transformsValidation = exports.TransformError = exports.isEmptyNode = exports.mainAttributeValidation = exports.validateIssuer = exports.getAuthorizeRequestTamperer = exports.getMetadataTamperer = exports.getSamlOptions = exports.logSamlCertExpiration = exports.getIDFromRequest = exports.getSamlIssuer = exports.getErrorCodeFromResponse = exports.getXmlFromSamlResponse = exports.safeXMLParseFromString = exports.notSignedWithHmacPredicate = exports.extractAndLogTimings = exports.InfoNotAvailable = exports.ERROR_SAML_RESPONSE_MISSING = exports.ISSUER_FORMAT = exports.SPID_TAGS = exports.XML_TAGS = exports.SAML_NAMESPACE = void 0; /** * Methods used to tamper passport-saml generated SAML XML. * * SPID protocol has some peculiarities that need to be addressed * to make request, metadata and responses compliant. */ const jose = require("jose"); const dates_1 = require("@pagopa/ts-commons/lib/dates"); const strings_1 = require("@pagopa/ts-commons/lib/strings"); const date_fns_1 = require("date-fns"); const fp_ts_1 = require("fp-ts"); const Array_1 = require("fp-ts/lib/Array"); const E = require("fp-ts/lib/Either"); const function_1 = require("fp-ts/lib/function"); const O = require("fp-ts/lib/Option"); const Record_1 = require("fp-ts/lib/Record"); const string_1 = require("fp-ts/lib/string"); const TE = require("fp-ts/lib/TaskEither"); const t = require("io-ts"); const node_forge_1 = require("node-forge"); const xmlCrypto = require("xml-crypto"); const xml2js_1 = require("xml2js"); const xmldom_1 = require("@xmldom/xmldom"); const config_1 = require("../config"); const lollipop_1 = require("../types/lollipop"); const logger_1 = require("./logger"); const middleware_1 = require("./middleware"); exports.SAML_NAMESPACE = { ASSERTION: "urn:oasis:names:tc:SAML:2.0:assertion", PROTOCOL: "urn:oasis:names:tc:SAML:2.0:protocol", SPID: "https://spid.gov.it/saml-extensions", XMLDSIG: "http://www.w3.org/2000/09/xmldsig#", }; exports.XML_TAGS = { LANG: "xml:lang", }; exports.SPID_TAGS = { ENTITY_TYPE: "spid:EntityType", FISCAL_CODE: "spid:FiscalCode", IPA_CODE: "spid:IPACode", VAT_NUMBER: "spid:VATNumber", }; exports.ISSUER_FORMAT = "urn:oasis:names:tc:SAML:2.0:nameid-format:entity"; const decodeBase64 = (s) => Buffer.from(s, "base64").toString("utf8"); /** * Remove prefix and suffix from x509 certificate. */ const cleanCert = (cert) => cert .replace(/-+BEGIN CERTIFICATE-+\r?\n?/, "") .replace(/-+END CERTIFICATE-+\r?\n?/, "") .replace(/\r\n/g, "\n"); exports.ERROR_SAML_RESPONSE_MISSING = "Missing SAMLResponse in ACS"; const SAMLResponse = t.type({ SAMLResponse: t.string, }); exports.InfoNotAvailable = "NOT AVAILABLE"; /** * If an eventHandler and a feature flag are provided this function logs the timing deltas. * This is useful to monitor the timings and to adjust the clockSkewMs variable */ const extractAndLogTimings = (startTime, idpIssuer, requestId, clockSkewMs = 0, eventHandler, hasClockSkewLoggingEvent // eslint-disable-next-line max-params ) => (info) => { // when clockSkewMs is set to -1 the validations are always true, so we skip the logs in that case if (eventHandler && hasClockSkewLoggingEvent && clockSkewMs !== -1) { const extractNotOnOrAfterDelta = (element) => (0, function_1.pipe)(strings_1.NonEmptyString.decode(element.getAttribute("NotOnOrAfter")), E.chain(dates_1.UTCISODateFromString.decode), E.mapLeft(() => new Error("Could not find/convert NotOnOrAfter")), E.map((NotOnOrAfter) => String(NotOnOrAfter.getTime() - (startTime - clockSkewMs)))); const ResponseIssueInstantClockSkew = String(startTime + clockSkewMs - info.IssueInstant.getTime()); const AssertionIssueInstantClockSkew = String(startTime + clockSkewMs - info.AssertionIssueInstant.getTime()); const AssertionNotBeforeClockSkew = (0, function_1.pipe)(info.Assertion.getAttribute("NotBefore"), strings_1.NonEmptyString.decode, E.chain(dates_1.UTCISODateFromString.decode), E.map((NotBefore) => String(startTime + clockSkewMs - NotBefore.getTime())), E.getOrElseW(() => exports.InfoNotAvailable)); const AssertionSubjectNotOnOrAfterClockSkew = (0, function_1.pipe)(info.Assertion.getElementsByTagNameNS(exports.SAML_NAMESPACE.ASSERTION, "Subject").item(0), O.fromNullable, O.chainNullableK((Subject) => Subject.getElementsByTagNameNS(exports.SAML_NAMESPACE.ASSERTION, "SubjectConfirmation").item(0)), O.chainNullableK((SubjectConfirmation) => SubjectConfirmation.getElementsByTagNameNS(exports.SAML_NAMESPACE.ASSERTION, "SubjectConfirmationData").item(0)), E.fromOption(() => new Error("Could not find elements")), E.chainW(extractNotOnOrAfterDelta), E.getOrElseW(() => exports.InfoNotAvailable)); const AssertionConditionsNotOnOrAfterClockSkew = (0, function_1.pipe)(info.Assertion.getElementsByTagNameNS(exports.SAML_NAMESPACE.ASSERTION, "Conditions").item(0), O.fromNullable, E.fromOption(() => new Error("Could not find elements")), E.chainW(extractNotOnOrAfterDelta), E.getOrElseW(() => exports.InfoNotAvailable)); const timings = { AssertionConditionsNotOnOrAfterClockSkew, AssertionIssueInstantClockSkew, AssertionNotBeforeClockSkew, AssertionSubjectNotOnOrAfterClockSkew, ResponseIssueInstantClockSkew, }; eventHandler({ data: Object.assign({ idpIssuer, message: "Clockskew validations logging", requestId }, timings), name: "spid.info.clockskew", type: "INFO", }); } return TE.right(void 0); }; exports.extractAndLogTimings = extractAndLogTimings; /** * True if the element contains at least one element signed using hamc * * @param e */ const isSignedWithHmac = (e) => { const signatures = e.getElementsByTagNameNS(exports.SAML_NAMESPACE.XMLDSIG, "SignatureMethod"); return Array.from({ length: signatures.length }) .map((_, i) => signatures.item(i)) .some((item) => { var _a; return ((_a = item === null || item === void 0 ? void 0 : item.getAttribute("Algorithm")) === null || _a === void 0 ? void 0 : _a.valueOf()) === "http://www.w3.org/2000/09/xmldsig#hmac-sha1"; }); }; exports.notSignedWithHmacPredicate = E.fromPredicate(fp_ts_1.predicate.not(isSignedWithHmac), (_) => new Error("HMAC Signature is forbidden")); const safeXMLParseFromString = (doc) => (0, function_1.pipe)(O.tryCatch(() => new xmldom_1.DOMParser().parseFromString(doc, "text/xml")), O.chain(O.fromNullable)); exports.safeXMLParseFromString = safeXMLParseFromString; const getXmlFromSamlResponse = (body) => (0, function_1.pipe)(O.fromEither(SAMLResponse.decode(body)), O.map((_) => decodeBase64(_.SAMLResponse)), O.chain(exports.safeXMLParseFromString)); exports.getXmlFromSamlResponse = getXmlFromSamlResponse; /** * Extract StatusMessage from SAML response * * ie. for <StatusMessage>ErrorCode nr22</StatusMessage> * returns "22" */ const getErrorCodeFromResponse = (doc) => (0, function_1.pipe)(O.fromNullable(doc.getElementsByTagNameNS(exports.SAML_NAMESPACE.PROTOCOL, "StatusMessage")), O.chain((responseStatusMessageEl) => { var _a; return ((_a = responseStatusMessageEl === null || responseStatusMessageEl === void 0 ? void 0 : responseStatusMessageEl[0]) === null || _a === void 0 ? void 0 : _a.textContent) ? O.some(responseStatusMessageEl[0].textContent.trim()) : O.none; }), O.chain((errorString) => { const indexString = "ErrorCode nr"; const errorCode = errorString.slice(errorString.indexOf(indexString) + indexString.length); return errorCode !== "" ? O.some(errorCode) : O.none; })); exports.getErrorCodeFromResponse = getErrorCodeFromResponse; /** * Extracts the issuer field from the response body. */ const getSamlIssuer = (doc) => (0, function_1.pipe)(O.fromNullable(doc.getElementsByTagNameNS(exports.SAML_NAMESPACE.ASSERTION, "Issuer").item(0)), O.chainNullableK((_) => { var _a; return (_a = _.textContent) === null || _a === void 0 ? void 0 : _a.trim(); })); exports.getSamlIssuer = getSamlIssuer; /** * Extracts IDP entityID from query parameter (if any). * * @returns * - the certificates (and entrypoint) for the IDP that matches the provided entityID * - all IDP certificates if no entityID is provided (and no entrypoint) * - none if no IDP matches the provided entityID */ const getEntrypointCerts = (req, idps) => (0, function_1.pipe)(O.fromNullable(req), O.chainNullableK((r) => r.query), O.chainNullableK((q) => q.entityID), O.chain((entityID) => // As only strings can be key of an object (other than number and Symbol), // we have to narrow type to have the compiler accept it // In the unlikely case entityID is not a string, an empty value is returned typeof entityID === "string" ? (0, function_1.pipe)(O.fromNullable(idps[entityID]), O.map((idp) => ({ cert: idp.cert, entryPoint: idp.entryPoint, idpIssuer: idp.entityID, }))) : O.none), O.alt(() => // collect all IDP certificates in case no entityID is provided O.some({ cert: (0, function_1.pipe)(idps, (0, Record_1.collect)(string_1.Ord)((_, idp) => (idp === null || idp === void 0 ? void 0 : idp.cert) || []), Array_1.flatten), // TODO: leave entryPoint undefined when this gets fixed // @see https://github.com/bergie/passport-saml/issues/415 entryPoint: "", }))); const getIDFromRequest = (requestXML) => (0, function_1.pipe)((0, exports.safeXMLParseFromString)(requestXML), O.chain((xmlRequest) => O.fromNullable(xmlRequest .getElementsByTagNameNS(exports.SAML_NAMESPACE.PROTOCOL, "AuthnRequest") .item(0))), O.chain((AuthnRequest) => O.fromEither(strings_1.NonEmptyString.decode(AuthnRequest.getAttribute("ID"))))); exports.getIDFromRequest = getIDFromRequest; const getAuthnContextValueFromResponse = (response) => (0, function_1.pipe)(response, exports.safeXMLParseFromString, // ie. <saml2:AuthnContextClassRef>https://www.spid.gov.it/SpidL2</saml2:AuthnContextClassRef> O.map((XmlResponse) => XmlResponse.getElementsByTagNameNS(exports.SAML_NAMESPACE.ASSERTION, "AuthnContextClassRef")), O.chain((0, function_1.flow)((responseAuthLevelEl) => { var _a; return (_a = responseAuthLevelEl[0]) === null || _a === void 0 ? void 0 : _a.textContent; }, O.fromNullable, O.map((textContent) => textContent.trim())))); /** * Extracts the correct SPID level from response. */ const getAuthSalmOptions = (req, decodedResponse) => (0, function_1.pipe)(O.fromNullable(req), O.chainNullableK((r) => r.query), O.chainNullableK((q) => q.authLevel), // As only strings can be key of SPID_LEVELS record, // we have to narrow type to have the compiler accept it // In the unlikely case authLevel is not a string, an empty value is returned O.filter((e) => typeof e === "string"), O.chain((authLevel) => (0, function_1.pipe)((0, Record_1.lookup)(authLevel, config_1.SPID_LEVELS), O.map((authnContext) => ({ authnContext, forceAuthn: authLevel !== "SpidL1", })), O.altW(() => { logger_1.logger.error("SPID cannot find a valid authnContext for given authLevel: %s", authLevel); return O.none; }))), O.alt(() => (0, function_1.pipe)(O.fromNullable(decodedResponse), O.chain((response) => getAuthnContextValueFromResponse(response)), O.chain((authnContext) => (0, function_1.pipe)((0, Record_1.lookup)(authnContext, config_1.SPID_URLS), // check if the parsed value is a valid SPID AuthLevel O.map((authLevel) => ({ authnContext, forceAuthn: authLevel !== "SpidL1", })), O.altW(() => { logger_1.logger.error("SPID cannot find a valid authLevel for given authnContext: %s", authnContext); return O.none; })))))); /** * Reads dates information in x509 certificate * and logs remaining time to its expiration date. * * @param samlCert x509 certificate as string */ const logSamlCertExpiration = (samlCert) => { try { const out = node_forge_1.pki.certificateFromPem(samlCert); if (out.validity.notAfter) { const timeDiff = (0, date_fns_1.distanceInWordsToNow)(out.validity.notAfter); const warningDate = (0, date_fns_1.subDays)(new Date(), 60); if ((0, date_fns_1.isAfter)(out.validity.notAfter, warningDate)) { logger_1.logger.info("samlCert expire in %s", timeDiff); } else if ((0, date_fns_1.isAfter)(out.validity.notAfter, new Date())) { logger_1.logger.warn("samlCert expire in %s", timeDiff); } else { logger_1.logger.error("samlCert expired from %s", timeDiff); } } else { logger_1.logger.error("Missing expiration date on saml certificate."); } } catch (e) { logger_1.logger.error("Error calculating saml cert expiration: %s", e); } }; exports.logSamlCertExpiration = logSamlCertExpiration; /** * This method extracts the correct IDP metadata * from the passport strategy options. * * It's executed for every SPID login (when passport * middleware is configured) and when generating * the Service Provider metadata. */ const getSamlOptions = (req, done) => { var _a; try { // Get decoded response const decodedResponse = ((_a = req.body) === null || _a === void 0 ? void 0 : _a.SAMLResponse) ? decodeBase64(req.body.SAMLResponse) : undefined; // Get SPID strategy options with IDPs metadata const maybeSpidStrategyOptions = O.fromNullable((0, middleware_1.getSpidStrategyOption)(req.app)); if (O.isNone(maybeSpidStrategyOptions)) { throw new Error("Missing Spid Strategy Option configuration inside express App"); } // Get the correct entry within the IDP metadata object const maybeEntrypointCerts = (0, function_1.pipe)(maybeSpidStrategyOptions, O.chain((spidStrategyOptions) => getEntrypointCerts(req, spidStrategyOptions.idp))); if (O.isNone(maybeEntrypointCerts)) { logger_1.logger.debug(`SPID cannot find a valid idp in spidOptions for given entityID: ${req.query.entityID}`); } const entrypointCerts = (0, function_1.pipe)(maybeEntrypointCerts, O.getOrElse(() => ({ cert: [] }))); // Get authnContext (SPID level) and forceAuthn from request payload const maybeAuthOptions = getAuthSalmOptions(req, decodedResponse); if (O.isNone(maybeAuthOptions)) { logger_1.logger.debug("SPID cannot find authnContext in response %s", decodedResponse); } const authOptions = (0, function_1.pipe)(maybeAuthOptions, O.getOrElseW(() => ({}))); const options = Object.assign(Object.assign(Object.assign(Object.assign({}, maybeSpidStrategyOptions.value.sp), authOptions), entrypointCerts), { cert: Array.from(entrypointCerts.cert) }); return done(null, options); } catch (e) { return done(e); } }; exports.getSamlOptions = getSamlOptions; // // Service Provider Metadata // // eslint-disable-next-line @typescript-eslint/explicit-function-return-type const getSpidAttributesMetadata = (serviceProviderConfig) => serviceProviderConfig.requiredAttributes ? serviceProviderConfig.requiredAttributes.attributes.map((item) => ({ $: { FriendlyName: config_1.SPID_USER_ATTRIBUTES[item] || "", Name: item, NameFormat: "urn:oasis:names:tc:SAML:2.0:attrname-format:basic", }, })) : []; // eslint-disable-next-line @typescript-eslint/explicit-function-return-type const getSpidOrganizationMetadata = (serviceProviderConfig) => serviceProviderConfig.organization ? { Organization: { OrganizationName: { $: { [exports.XML_TAGS.LANG]: "it" }, _: serviceProviderConfig.organization.name, }, // must appear after organization name // eslint-disable-next-line sort-keys OrganizationDisplayName: { $: { [exports.XML_TAGS.LANG]: "it" }, _: serviceProviderConfig.organization.displayName, }, OrganizationURL: { $: { [exports.XML_TAGS.LANG]: "it" }, _: serviceProviderConfig.organization.URL, }, }, } : {}; // eslint-disable-next-line @typescript-eslint/explicit-function-return-type const getSpidContactPersonMetadata = (serviceProviderConfig // eslint-disable-next-line sonarjs/cognitive-complexity ) => serviceProviderConfig.contacts ? serviceProviderConfig.contacts .map((item) => { var _a; const contact = Object.assign({ $: { contactType: item.contactType, }, Company: item.company, EmailAddress: item.email }, (item.phone ? { TelephoneNumber: item.phone } : {})); if (item.contactType === middleware_1.ContactType.OTHER) { return Object.assign(Object.assign({ Extensions: Object.assign(Object.assign(Object.assign(Object.assign({}, (item.extensions.IPACode ? { [exports.SPID_TAGS.IPA_CODE]: item.extensions.IPACode } : {})), (item.extensions.VATNumber ? { [exports.SPID_TAGS.VAT_NUMBER]: item.extensions.VATNumber } : {})), (((_a = item.extensions) === null || _a === void 0 ? void 0 : _a.FiscalCode) ? { [exports.SPID_TAGS.FISCAL_CODE]: item.extensions.FiscalCode } : {})), (item.entityType === middleware_1.EntityType.AGGREGATOR ? { [`spid:${item.extensions.aggregatorType}`]: {} } : {})) }, contact), { $: Object.assign(Object.assign({}, contact.$), { [exports.SPID_TAGS.ENTITY_TYPE]: item.entityType }) }); } return contact; }) // Contacts array is limited to 3 elements .slice(0, 3) : {}; // eslint-disable-next-line @typescript-eslint/explicit-function-return-type const getKeyInfoForMetadata = (publicCert, privateKey) => ({ file: privateKey, getKey: () => Buffer.from(privateKey), getKeyInfo: () => `<X509Data><X509Certificate>${publicCert}</X509Certificate></X509Data>`, }); const getMetadataTamperer = (xmlBuilder, serviceProviderConfig, samlConfig) => (generateXml) => (0, function_1.pipe)(TE.tryCatch(() => (0, xml2js_1.parseStringPromise)(generateXml), E.toError), TE.chain((o) => TE.tryCatch(() => __awaiter(void 0, void 0, void 0, function* () { // it is safe to mutate object here since it is // deserialized and serialized locally in this method const sso = o.EntityDescriptor.SPSSODescriptor[0]; // eslint-disable-next-line functional/immutable-data sso.$ = Object.assign(Object.assign({}, sso.$), { AuthnRequestsSigned: true, WantAssertionsSigned: true }); // eslint-disable-next-line functional/immutable-data sso.AssertionConsumerService[0].$.index = 0; // eslint-disable-next-line functional/immutable-data sso.AttributeConsumingService = { $: { index: samlConfig.attributeConsumingServiceIndex, }, ServiceName: { $: { [exports.XML_TAGS.LANG]: "it", }, _: serviceProviderConfig.requiredAttributes.name, }, // must appear after attributes // eslint-disable-next-line sort-keys RequestedAttribute: getSpidAttributesMetadata(serviceProviderConfig), }; // eslint-disable-next-line functional/immutable-data o.EntityDescriptor = Object.assign(Object.assign({}, o.EntityDescriptor), getSpidOrganizationMetadata(serviceProviderConfig)); if (serviceProviderConfig.contacts) { // eslint-disable-next-line functional/immutable-data o.EntityDescriptor = Object.assign(Object.assign({}, o.EntityDescriptor), { $: Object.assign(Object.assign({}, o.EntityDescriptor.$), { // eslint-disable-next-line @typescript-eslint/naming-convention "xmlns:spid": exports.SAML_NAMESPACE.SPID }), ContactPerson: getSpidContactPersonMetadata(serviceProviderConfig) }); } return o; }), E.toError)), TE.chain((_) => TE.tryCatch(() => __awaiter(void 0, void 0, void 0, function* () { return xmlBuilder.buildObject(_); }), E.toError)), TE.chain((xml) => TE.tryCatch(() => __awaiter(void 0, void 0, void 0, function* () { // sign xml metadata if (!samlConfig.privateCert) { throw new Error("You must provide a private key to sign SPID service provider metadata."); } const sig = new xmlCrypto.SignedXml(); const publicCert = cleanCert(serviceProviderConfig.publicCert); // eslint-disable-next-line functional/immutable-data sig.keyInfoProvider = getKeyInfoForMetadata(publicCert, samlConfig.privateCert); // eslint-disable-next-line functional/immutable-data sig.signatureAlgorithm = "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"; // eslint-disable-next-line functional/immutable-data sig.signingKey = samlConfig.privateCert; sig.addReference("//*[local-name(.)='EntityDescriptor']", [ "http://www.w3.org/2000/09/xmldsig#enveloped-signature", "http://www.w3.org/2001/10/xml-exc-c14n#", ], "http://www.w3.org/2001/04/xmlenc#sha256"); sig.computeSignature(xml, { // Place the signature tag before all other tags // eslint-disable-next-line sort-keys location: { reference: "", action: "prepend" }, }); return sig.getSignedXml(); }), E.toError))); exports.getMetadataTamperer = getMetadataTamperer; // // Authorize request // const getAuthorizeRequestTamperer = (xmlBuilder, samlConfig) => (generateXml, lollipopParams) => (0, function_1.pipe)(TE.tryCatch(() => (0, xml2js_1.parseStringPromise)(generateXml), E.toError), TE.chain((o) => (0, function_1.pipe)(lollipopParams, O.fromNullable, E.fromOption(() => o), E.fold(TE.right, (lParams) => (0, function_1.pipe)(lParams.hashAlgorithm, O.fromNullable, O.getOrElse(() => lollipop_1.DEFAULT_LOLLIPOP_HASH_ALGORITHM), TE.of, TE.bindTo("hashingAlgo"), TE.bind("jwkThumbprint", ({ hashingAlgo }) => TE.tryCatch(() => jose.calculateJwkThumbprint(lParams.pubKey, hashingAlgo), E.toError)), TE.map(({ hashingAlgo, jwkThumbprint }) => `${hashingAlgo}-${jwkThumbprint}`), TE.chain((requestId) => TE.tryCatch(() => __awaiter(void 0, void 0, void 0, function* () { // eslint-disable-next-line functional/immutable-data o["samlp:AuthnRequest"].$.ID = requestId; return o; }), E.toError)))))), TE.chain((o) => TE.tryCatch(() => __awaiter(void 0, void 0, void 0, function* () { // it is safe to mutate object here since it is // deserialized and serialized locally in this method const authnRequest = o["samlp:AuthnRequest"]; // eslint-disable-next-line fp/no-delete, functional/immutable-data delete authnRequest["samlp:NameIDPolicy"][0].$.AllowCreate; // eslint-disable-next-line functional/immutable-data authnRequest["saml:Issuer"][0].$.NameQualifier = samlConfig.issuer; // eslint-disable-next-line functional/immutable-data authnRequest["saml:Issuer"][0].$.Format = exports.ISSUER_FORMAT; return o; }), E.toError)), TE.chain((obj) => TE.tryCatch(() => __awaiter(void 0, void 0, void 0, function* () { return xmlBuilder.buildObject(obj); }), E.toError))); exports.getAuthorizeRequestTamperer = getAuthorizeRequestTamperer; // // Validate response // const utcStringToDate = (value, tag) => (0, function_1.pipe)(dates_1.UTCISODateFromString.decode(value), E.mapLeft(() => new Error(`${tag} must be an UTCISO format date string`))); const validateIssuer = (fatherElement, idpIssuer) => (0, function_1.pipe)(E.fromOption(() => new Error("Issuer element must be present"))(O.fromNullable(fatherElement .getElementsByTagNameNS(exports.SAML_NAMESPACE.ASSERTION, "Issuer") .item(0))), E.chain((Issuer) => { var _a; return (0, function_1.pipe)(strings_1.NonEmptyString.decode((_a = Issuer.textContent) === null || _a === void 0 ? void 0 : _a.trim()), E.mapLeft(() => new Error("Issuer element must be not empty")), E.chain(E.fromPredicate((IssuerTextContent) => IssuerTextContent === idpIssuer, () => new Error(`Invalid Issuer. Expected value is ${idpIssuer}`))), E.map(() => Issuer)); })); exports.validateIssuer = validateIssuer; const mainAttributeValidation = (validationTimestamp) => (requestOrAssertion, acceptedClockSkewMs = 0) => (0, function_1.pipe)(strings_1.NonEmptyString.decode(requestOrAssertion.getAttribute("ID")), E.mapLeft(() => new Error("Assertion must contain a non empty ID")), E.map(() => requestOrAssertion.getAttribute("Version")), E.chain(E.fromPredicate((Version) => Version === "2.0", () => new Error("Version version must be 2.0"))), E.chain(() => E.fromOption(() => new Error("Assertion must contain a non empty IssueInstant"))(O.fromNullable(requestOrAssertion.getAttribute("IssueInstant")))), E.chain((IssueInstant) => utcStringToDate(IssueInstant, "IssueInstant")), E.chain(E.fromPredicate((IssueInstant) => IssueInstant.getTime() < (acceptedClockSkewMs === -1 ? Infinity : validationTimestamp + acceptedClockSkewMs), () => new Error("IssueInstant must be in the past")))); exports.mainAttributeValidation = mainAttributeValidation; const isEmptyNode = (element) => { if (element.childNodes.length > 1) { return false; } else if (element.firstChild && element.firstChild.nodeType === element.ELEMENT_NODE // eslint-disable-next-line sonarjs/no-duplicated-branches ) { return false; } else if (element.textContent && // eslint-disable-next-line no-useless-escape element.textContent.replace(/[\r\n\ ]+/g, "") !== "" // eslint-disable-next-line sonarjs/no-duplicated-branches ) { return false; } return true; }; exports.isEmptyNode = isEmptyNode; const isOverflowNumberOf = (elemArray, maxNumberOfChildren) => elemArray.filter((e) => e.nodeType === e.ELEMENT_NODE).length > maxNumberOfChildren; exports.TransformError = t.interface({ idpIssuer: t.string, message: t.string, numberOfTransforms: t.number, }); const transformsValidation = (targetElement, idpIssuer) => (0, function_1.pipe)(O.fromPredicate((elements) => elements.length > 0)(Array.from(targetElement.getElementsByTagNameNS(exports.SAML_NAMESPACE.XMLDSIG, "Transform"))), O.fold(() => E.right(targetElement), (transformElements) => (0, function_1.pipe)(E.fromPredicate((_) => !isOverflowNumberOf(_, 4), (_) => exports.TransformError.encode({ idpIssuer, message: "Transform element cannot occurs more than 4 times", numberOfTransforms: _.length, }))(transformElements), E.map(() => targetElement)))); exports.transformsValidation = transformsValidation; const notOnOrAfterValidation = (validationTimestamp) => (element, acceptedClockSkewMs = 0) => (0, function_1.pipe)(strings_1.NonEmptyString.decode(element.getAttribute("NotOnOrAfter")), E.mapLeft(() => new Error("NotOnOrAfter attribute must be a non empty string")), E.chain((NotOnOrAfter) => utcStringToDate(NotOnOrAfter, "NotOnOrAfter")), E.chain(E.fromPredicate((NotOnOrAfter) => NotOnOrAfter.getTime() > (acceptedClockSkewMs === -1 ? -Infinity : validationTimestamp - acceptedClockSkewMs), () => new Error("NotOnOrAfter must be in the future")))); const assertionValidation = // eslint-disable-next-line max-lines-per-function, prettier/prettier (validationTimestamp) => // eslint-disable-next-line max-lines-per-function (Assertion, samlConfig, InResponseTo, requestAuthnContextClassRef // eslint-disable-next-line sonarjs/cognitive-complexity ) => { const acceptedClockSkewMs = samlConfig.acceptedClockSkewMs || 0; return (0, function_1.pipe)(E.fromOption(() => new Error("Assertion must be signed"))(O.fromNullable(Assertion.getElementsByTagNameNS(exports.SAML_NAMESPACE.XMLDSIG, "Signature").item(0))), E.chain(exports.notSignedWithHmacPredicate), // eslint-disable-next-line max-lines-per-function E.chain(() => (0, function_1.pipe)(E.fromOption(() => new Error("Subject element must be present"))(O.fromNullable(Assertion.getElementsByTagNameNS(exports.SAML_NAMESPACE.ASSERTION, "Subject").item(0))), E.chain(E.fromPredicate(fp_ts_1.predicate.not(exports.isEmptyNode), () => new Error("Subject element must be not empty"))), E.chain((Subject) => (0, function_1.pipe)(E.fromOption(() => new Error("NameID element must be present"))(O.fromNullable(Subject.getElementsByTagNameNS(exports.SAML_NAMESPACE.ASSERTION, "NameID").item(0))), E.chain(E.fromPredicate(fp_ts_1.predicate.not(exports.isEmptyNode), () => new Error("NameID element must be not empty"))), E.chain((NameID) => (0, function_1.pipe)(strings_1.NonEmptyString.decode(NameID.getAttribute("Format")), E.mapLeft(() => new Error("Format attribute of NameID element must be a non empty string")), E.chain(E.fromPredicate((Format) => Format === "urn:oasis:names:tc:SAML:2.0:nameid-format:transient", () => new Error("Format attribute of NameID element is invalid"))), E.map(() => NameID))), E.chain((NameID) => (0, function_1.pipe)(strings_1.NonEmptyString.decode(NameID.getAttribute("NameQualifier")), E.mapLeft(() => new Error("NameQualifier attribute of NameID element must be a non empty string")))), E.map(() => Subject))), E.chain((Subject) => (0, function_1.pipe)(E.fromOption(() => new Error("SubjectConfirmation element must be present"))(O.fromNullable(Subject.getElementsByTagNameNS(exports.SAML_NAMESPACE.ASSERTION, "SubjectConfirmation").item(0))), E.chain(E.fromPredicate(fp_ts_1.predicate.not(exports.isEmptyNode), () => new Error("SubjectConfirmation element must be not empty"))), E.chain((SubjectConfirmation) => (0, function_1.pipe)(strings_1.NonEmptyString.decode(SubjectConfirmation.getAttribute("Method")), E.mapLeft(() => new Error("Method attribute of SubjectConfirmation element must be a non empty string")), E.chain(E.fromPredicate((Method) => Method === "urn:oasis:names:tc:SAML:2.0:cm:bearer", () => new Error("Method attribute of SubjectConfirmation element is invalid"))), E.map(() => SubjectConfirmation))), E.chain((SubjectConfirmation) => (0, function_1.pipe)(E.fromOption(() => new Error("SubjectConfirmationData element must be provided"))(O.fromNullable(SubjectConfirmation.getElementsByTagNameNS(exports.SAML_NAMESPACE.ASSERTION, "SubjectConfirmationData").item(0))), E.chain((SubjectConfirmationData) => (0, function_1.pipe)(strings_1.NonEmptyString.decode(SubjectConfirmationData.getAttribute("Recipient")), E.mapLeft(() => new Error("Recipient attribute of SubjectConfirmationData element must be a non empty string")), E.chain(E.fromPredicate((Recipient) => Recipient === samlConfig.callbackUrl, () => new Error("Recipient attribute of SubjectConfirmationData element must be equal to AssertionConsumerServiceURL"))), E.map(() => SubjectConfirmationData))), E.chain((SubjectConfirmationData) => (0, function_1.pipe)(notOnOrAfterValidation(validationTimestamp)(SubjectConfirmationData, acceptedClockSkewMs), E.map(() => SubjectConfirmationData))), E.chain((SubjectConfirmationData) => (0, function_1.pipe)(strings_1.NonEmptyString.decode(SubjectConfirmationData.getAttribute("InResponseTo")), E.mapLeft(() => new Error("InResponseTo attribute of SubjectConfirmationData element must be a non empty string")), E.chain(E.fromPredicate((inResponseTo) => inResponseTo === InResponseTo, () => new Error("InResponseTo attribute of SubjectConfirmationData element must be equal to Response InResponseTo"))))))))), // eslint-disable-next-line max-lines-per-function E.chain(() => (0, function_1.pipe)(E.fromOption(() => new Error("Conditions element must be provided"))(O.fromNullable(Assertion.getElementsByTagNameNS(exports.SAML_NAMESPACE.ASSERTION, "Conditions").item(0))), E.chain(E.fromPredicate(fp_ts_1.predicate.not(exports.isEmptyNode), () => new Error("Conditions element must be provided"))), E.chain((Conditions) => (0, function_1.pipe)(notOnOrAfterValidation(validationTimestamp)(Conditions, acceptedClockSkewMs), E.map(() => Conditions))), E.chainFirst((Conditions) => (0, function_1.pipe)(strings_1.NonEmptyString.decode(Conditions.getAttribute("NotBefore")), E.mapLeft(() => new Error("NotBefore must be a non empty string")), E.chain((NotBefore) => utcStringToDate(NotBefore, "NotBefore")), E.chain(E.fromPredicate((NotBefore) => NotBefore.getTime() <= (acceptedClockSkewMs === -1 ? Infinity : validationTimestamp + acceptedClockSkewMs), () => new Error("NotBefore must be in the past"))))), E.chain((Conditions) => (0, function_1.pipe)(E.fromOption(() => new Error("AudienceRestriction element must be present and not empty"))(O.fromNullable(Conditions.getElementsByTagNameNS(exports.SAML_NAMESPACE.ASSERTION, "AudienceRestriction").item(0))), E.chain(E.fromPredicate(fp_ts_1.predicate.not(exports.isEmptyNode), // eslint-disable-next-line sonarjs/no-identical-functions () => new Error("AudienceRestriction element must be present and not empty"))), E.chain((AudienceRestriction) => (0, function_1.pipe)(E.fromOption(() => new Error("Audience missing"))(O.fromNullable(AudienceRestriction.getElementsByTagNameNS(exports.SAML_NAMESPACE.ASSERTION, "Audience").item(0))), E.chain(E.fromPredicate((Audience) => { var _a; return ((_a = Audience.textContent) === null || _a === void 0 ? void 0 : _a.trim()) === samlConfig.issuer; }, () => new Error("Audience invalid"))))))), E.chain(() => (0, function_1.pipe)(E.fromOption(() => new Error("Missing AuthnStatement"))(O.fromNullable(Assertion.getElementsByTagNameNS(exports.SAML_NAMESPACE.ASSERTION, "AuthnStatement").item(0))), E.chain(E.fromPredicate(fp_ts_1.predicate.not(exports.isEmptyNode), () => new Error("Empty AuthnStatement"))), E.chain((AuthnStatement) => (0, function_1.pipe)(E.fromOption(() => new Error("Missing AuthnContext"))(O.fromNullable(AuthnStatement.getElementsByTagNameNS(exports.SAML_NAMESPACE.ASSERTION, "AuthnContext").item(0))), E.chain(E.fromPredicate(fp_ts_1.predicate.not(exports.isEmptyNode), () => new Error("Empty AuthnContext"))), E.chain((AuthnContext) => (0, function_1.pipe)(E.fromOption(() => new Error("Missing AuthnContextClassRef"))(O.fromNullable(AuthnContext.getElementsByTagNameNS(exports.SAML_NAMESPACE.ASSERTION, "AuthnContextClassRef").item(0))), E.chain(E.fromPredicate(fp_ts_1.predicate.not(exports.isEmptyNode), () => new Error("Empty AuthnContextClassRef"))), E.map((AuthnContextClassRef) => { var _a; return (_a = AuthnContextClassRef.textContent) === null || _a === void 0 ? void 0 : _a.trim(); }), E.chain(E.fromPredicate((AuthnContextClassRef) => AuthnContextClassRef === config_1.SPID_LEVELS.SpidL1 || AuthnContextClassRef === config_1.SPID_LEVELS.SpidL2 || AuthnContextClassRef === config_1.SPID_LEVELS.SpidL3, () => new Error("Invalid AuthnContextClassRef value"))), E.chain(E.fromPredicate((AuthnContextClassRef) => requestAuthnContextClassRef === config_1.SPID_LEVELS.SpidL2 ? AuthnContextClassRef === config_1.SPID_LEVELS.SpidL2 || AuthnContextClassRef === config_1.SPID_LEVELS.SpidL3 : requestAuthnContextClassRef === config_1.SPID_LEVELS.SpidL1 ? AuthnContextClassRef === config_1.SPID_LEVELS.SpidL1 || AuthnContextClassRef === config_1.SPID_LEVELS.SpidL2 || AuthnContextClassRef === config_1.SPID_LEVELS.SpidL3 : requestAuthnContextClassRef === AuthnContextClassRef, () => new Error("AuthnContextClassRef value not expected"))))))))), E.chain(() => (0, function_1.pipe)(E.fromOption(() => new Error("AttributeStatement must contains Attributes"))((0, function_1.pipe)(O.fromNullable(Assertion.getElementsByTagNameNS(exports.SAML_NAMESPACE.ASSERTION, "AttributeStatement").item(0)), O.map((AttributeStatement) => AttributeStatement.getElementsByTagNameNS(exports.SAML_NAMESPACE.ASSERTION, "Attribute")))), E.chain(E.fromPredicate((Attributes) => Attributes.length > 0 && !Array.from(Attributes).some(exports.isEmptyNode), () => new Error("Attribute element must be present and not empty")))))))))); }; exports.assertionValidation = assertionValidation; //# sourceMappingURL=samlUtils.js.map