UNPKG

@pagopa/io-spid-commons

Version:

Common code for integrating SPID authentication

190 lines 18.6 kB
"use strict"; var __rest = (this && this.__rest) || function (s, e) { var t = {}; for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p) && e.indexOf(p) < 0) t[p] = s[p]; if (s != null && typeof Object.getOwnPropertySymbols === "function") for (var i = 0, p = Object.getOwnPropertySymbols(s); i < p.length; i++) { if (e.indexOf(p[i]) < 0 && Object.prototype.propertyIsEnumerable.call(s, p[i])) t[p[i]] = s[p[i]]; } return t; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.getPreValidateResponse = exports.TransformError = exports.getSamlIssuer = exports.getAuthorizeRequestTamperer = exports.getErrorCodeFromResponse = exports.getSamlOptions = exports.getXmlFromSamlResponse = exports.getMetadataTamperer = exports.getIDFromRequest = exports.logSamlCertExpiration = exports.SAML_NAMESPACE = void 0; /* eslint-disable max-lines-per-function */ /** * 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 dates_1 = require("@pagopa/ts-commons/lib/dates"); const strings_1 = require("@pagopa/ts-commons/lib/strings"); 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 string_1 = require("fp-ts/lib/string"); const TE = require("fp-ts/lib/TaskEither"); const xmldom_1 = require("@xmldom/xmldom"); const config_1 = require("../config"); const samlUtils_1 = require("./samlUtils"); Object.defineProperty(exports, "TransformError", { enumerable: true, get: function () { return samlUtils_1.TransformError; } }); const samlUtils_2 = require("./samlUtils"); Object.defineProperty(exports, "getAuthorizeRequestTamperer", { enumerable: true, get: function () { return samlUtils_2.getAuthorizeRequestTamperer; } }); Object.defineProperty(exports, "getErrorCodeFromResponse", { enumerable: true, get: function () { return samlUtils_2.getErrorCodeFromResponse; } }); Object.defineProperty(exports, "getIDFromRequest", { enumerable: true, get: function () { return samlUtils_2.getIDFromRequest; } }); Object.defineProperty(exports, "getMetadataTamperer", { enumerable: true, get: function () { return samlUtils_2.getMetadataTamperer; } }); Object.defineProperty(exports, "getSamlIssuer", { enumerable: true, get: function () { return samlUtils_2.getSamlIssuer; } }); Object.defineProperty(exports, "getSamlOptions", { enumerable: true, get: function () { return samlUtils_2.getSamlOptions; } }); Object.defineProperty(exports, "getXmlFromSamlResponse", { enumerable: true, get: function () { return samlUtils_2.getXmlFromSamlResponse; } }); Object.defineProperty(exports, "logSamlCertExpiration", { enumerable: true, get: function () { return samlUtils_2.logSamlCertExpiration; } }); Object.defineProperty(exports, "SAML_NAMESPACE", { enumerable: true, get: function () { return samlUtils_2.SAML_NAMESPACE; } }); const ISSUER_FORMAT_ERROR = new Error("Format attribute of Issuer element is invalid"); const hasExtraParams = (t) => Object.keys(t).length > 0; const getExtraParamsOrUndefined = (t) => (hasExtraParams(t) ? t : undefined); const getPreValidateResponse = // eslint-disable-next-line prettier/prettier (strictValidationOptions, eventHandler, hasClockSkewLoggingEvent) => (samlConfig, body, extendedCacheProvider, doneCb, callback // eslint-disable-next-line sonarjs/cognitive-complexity ) => { const maybeDoc = (0, samlUtils_2.getXmlFromSamlResponse)(body); const startTime = Date.now(); if (O.isNone(maybeDoc)) { throw new Error("Empty SAML response"); } const doc = maybeDoc.value; const responsesCollection = doc.getElementsByTagNameNS(samlUtils_2.SAML_NAMESPACE.PROTOCOL, "Response"); const maybeIdpIssuer = (0, samlUtils_2.getSamlIssuer)(doc); const hasStrictValidation = (0, function_1.pipe)(O.fromNullable(strictValidationOptions), O.chain((validationOptions) => (0, function_1.pipe)(maybeIdpIssuer, O.chainNullableK((issuer) => validationOptions[issuer]))), O.getOrElse(() => false)); const idpIssuer = (0, function_1.pipe)(maybeIdpIssuer, O.getOrElse(() => samlUtils_2.InfoNotAvailable)); // here we are partially validating the response just to obtain a requestId (InResponseTo) before doing any more step. // this is needed if we want to have to log the requestId at any step further const errorOrPartiallyValidatedResponse = (0, function_1.pipe)(responsesCollection.item(0), // this check is bound to the next one, because we can receive no Response based on the official validator guidelines E.fromNullable(new Error("Missing Response element inside SAML Response")), E.chainFirst(E.fromPredicate( // updated versions of xmldom will convert any additional root node in a text node(https://github.com/advisories/GHSA-crh6-fp67-6883) // to ensure if more than one samlp:Response node was provided, we must check if the sibling node is present, returning an error afterwards ({ nextSibling }) => nextSibling === null || nextSibling === undefined, (_) => new Error("SAML Response must have only one Response element"))), E.chain((Response) => (0, function_1.pipe)(strings_1.NonEmptyString.decode(Response.getAttribute("InResponseTo")), E.mapLeft(() => new Error("InResponseTo must contain a non empty string")), E.map((InResponseTo) => ({ InResponseTo, Response }))))); const requestId = (0, function_1.pipe)(errorOrPartiallyValidatedResponse, E.map(({ InResponseTo }) => InResponseTo), E.getOrElse(() => samlUtils_2.InfoNotAvailable)); const responseElementValidationStep = TE.fromEither((0, function_1.pipe)(errorOrPartiallyValidatedResponse, E.chainW(({ Response, InResponseTo }) => (0, function_1.pipe)((0, samlUtils_2.mainAttributeValidation)(startTime)(Response, samlConfig.acceptedClockSkewMs), E.map((IssueInstant) => ({ InResponseTo, IssueInstant, Response, })))), E.chain((_) => (0, function_1.pipe)(strings_1.NonEmptyString.decode(_.Response.getAttribute("Destination")), E.mapLeft(() => new Error("Response must contain a non empty Destination")), E.chain(E.fromPredicate((Destination) => Destination === samlConfig.callbackUrl, () => new Error("Destination must be equal to AssertionConsumerServiceURL"))), E.map(() => _))), E.chain((_) => (0, function_1.pipe)(E.fromOption(() => new Error("Status element must be present"))(O.fromNullable(_.Response.getElementsByTagNameNS(samlUtils_2.SAML_NAMESPACE.PROTOCOL, "Status").item(0))), E.mapLeft(() => new Error("Status element must be present into Response")), E.chain(E.fromPredicate((0, function_1.not)(samlUtils_2.isEmptyNode), () => new Error("Status element must be present not empty"))), E.chain((Status) => E.fromOption(() => new Error("StatusCode element must be present"))(O.fromNullable(Status.getElementsByTagNameNS(samlUtils_2.SAML_NAMESPACE.PROTOCOL, "StatusCode").item(0)))), E.chain((StatusCode) => (0, function_1.pipe)(E.fromOption(() => new Error("StatusCode must contain a non empty Value"))(O.fromNullable(StatusCode.getAttribute("Value"))), E.chain((statusCode) => // TODO: Must show an error page to the user (26) (0, function_1.pipe)(statusCode, E.fromPredicate((Value) => Value.toLowerCase() === "urn:oasis:names:tc:SAML:2.0:status:Success".toLowerCase(), () => new Error(`Value attribute of StatusCode is invalid: ${statusCode}`)))), E.map(() => _))))), E.chain(E.fromPredicate((predicate) => predicate.Response.getElementsByTagNameNS(samlUtils_2.SAML_NAMESPACE.ASSERTION, "EncryptedAssertion").length === 0, (_) => new Error("EncryptedAssertion element is forbidden"))), E.chain((p) => (0, function_1.pipe)((0, samlUtils_1.notSignedWithHmacPredicate)(p.Response), E.map((_) => p))), E.chain(E.fromPredicate((predicate) => predicate.Response.getElementsByTagNameNS(samlUtils_2.SAML_NAMESPACE.ASSERTION, "Assertion").length < 2, (_) => new Error("SAML Response must have only one Assertion element"))), E.chain((_) => (0, function_1.pipe)(E.fromOption(() => new Error("Assertion element must be present"))(O.fromNullable(_.Response.getElementsByTagNameNS(samlUtils_2.SAML_NAMESPACE.ASSERTION, "Assertion").item(0))), E.map((assertion) => (Object.assign(Object.assign({}, _), { Assertion: assertion }))))), E.chain((_) => (0, function_1.pipe)((0, samlUtils_2.mainAttributeValidation)(startTime)(_.Assertion, samlConfig.acceptedClockSkewMs), E.map((IssueInstant) => (Object.assign({ AssertionIssueInstant: IssueInstant }, _))))))); const returnRequestAndResponseStep = (_) => (0, function_1.pipe)(extendedCacheProvider.get(_.InResponseTo), TE.map((SAMLRequestCache) => { const { // eslint-disable-next-line @typescript-eslint/no-unused-vars RequestXML, // eslint-disable-next-line @typescript-eslint/no-unused-vars createdAt, // eslint-disable-next-line @typescript-eslint/no-unused-vars idpIssuer: _idpIssuer } = SAMLRequestCache, extraLoginRequestParams = __rest(SAMLRequestCache, ["RequestXML", "createdAt", "idpIssuer"]); return Object.assign(Object.assign({}, _), { SAMLRequestCache, // Cast needed to bypass Omit type inference extraLoginRequestParams: extraLoginRequestParams }); }), TE.map((__) => (doneCb && O.tryCatch(() => doneCb(__.SAMLRequestCache.RequestXML, new xmldom_1.XMLSerializer().serializeToString(doc), getExtraParamsOrUndefined(__.extraLoginRequestParams))), __))); const parseSAMLRequestStep = (_) => (0, function_1.pipe)(TE.fromEither((0, function_1.pipe)(_.SAMLRequestCache.RequestXML, samlUtils_1.safeXMLParseFromString, E.fromOption(() => new Error("An error occurs parsing the cached SAML Request")))), TE.map((Request) => (Object.assign(Object.assign({}, _), { Request })))); const getIssueInstantFromRequestStep = (_) => (0, function_1.pipe)(TE.fromEither(E.fromOption(() => new Error("Missing AuthnRequest into Cached Request"))(O.fromNullable(_.Request.getElementsByTagNameNS(samlUtils_2.SAML_NAMESPACE.PROTOCOL, "AuthnRequest").item(0)))), TE.map((RequestAuthnRequest) => (Object.assign(Object.assign({}, _), { RequestAuthnRequest }))), TE.chain((__) => (0, function_1.pipe)(TE.fromEither((0, function_1.pipe)(dates_1.UTCISODateFromString.decode(__.RequestAuthnRequest.getAttribute("IssueInstant")), E.mapLeft(() => new Error("IssueInstant into the Request must be a valid UTC string")))), TE.map((RequestIssueInstant) => (Object.assign(Object.assign({}, __), { RequestIssueInstant })))))); const issueInstantValidationStep = (_) => (0, function_1.pipe)(TE.fromEither((0, function_1.pipe)(_.RequestIssueInstant, E.fromPredicate((_1) => _1.getTime() <= _.IssueInstant.getTime(), () => new Error("Response IssueInstant must after Request IssueInstant")))), TE.map(() => _)); const assertionIssueInstantValidationStep = (_) => (0, function_1.pipe)(TE.fromEither((0, function_1.pipe)(_.RequestIssueInstant, E.fromPredicate((_1) => _1.getTime() <= _.AssertionIssueInstant.getTime(), () => new Error("Assertion IssueInstant must after Request IssueInstant")))), TE.map(() => _)); const authnContextClassRefValidationStep = (_) => TE.fromEither((0, function_1.pipe)(E.fromOption(() => new Error("Missing AuthnContextClassRef inside cached SAML Response"))(O.fromNullable(_.RequestAuthnRequest.getElementsByTagNameNS(samlUtils_2.SAML_NAMESPACE.ASSERTION, "AuthnContextClassRef").item(0))), E.chain(E.fromPredicate(fp_ts_1.predicate.not(samlUtils_2.isEmptyNode), () => new Error("Subject element must be not empty"))), E.chain((RequestAuthnContextClassRef) => { var _a; return (0, function_1.pipe)(strings_1.NonEmptyString.decode((_a = RequestAuthnContextClassRef.textContent) === null || _a === void 0 ? void 0 : _a.trim()), E.mapLeft(() => new Error("AuthnContextClassRef inside cached Request must be a non empty string"))); }), E.chain(E.fromPredicate((reqAuthnContextClassRef) => reqAuthnContextClassRef === config_1.SPID_LEVELS.SpidL1 || reqAuthnContextClassRef === config_1.SPID_LEVELS.SpidL2 || reqAuthnContextClassRef === config_1.SPID_LEVELS.SpidL3, () => new Error("Unexpected Request authnContextClassRef value"))), E.map((rACCR) => (Object.assign(Object.assign({}, _), { RequestAuthnContextClassRef: rACCR }))))); const attributesValidationStep = (_) => (0, function_1.pipe)(TE.fromEither((0, samlUtils_1.assertionValidation)(startTime)(_.Assertion, samlConfig, _.InResponseTo, _.RequestAuthnContextClassRef)), TE.chain((Attributes) => { var _a, _b; if (!hasStrictValidation) { // Skip Attribute validation if IDP has non-strict validation option return TE.right(Attributes); } const missingAttributes = (0, Array_1.difference)(string_1.Eq)( // eslint-disable-next-line @typescript-eslint/no-explicit-any ((_b = (_a = samlConfig.attributes) === null || _a === void 0 ? void 0 : _a.attributes) === null || _b === void 0 ? void 0 : _b.attributes) || [ "Request attributes must be defined", ], Array.from(Attributes).reduce((prev, attr) => { const attribute = attr.getAttribute("Name"); if (attribute) { return [...prev, attribute]; } return prev; }, new Array())); return TE.fromEither(E.fromPredicate(() => missingAttributes.length === 0, () => new Error(`Missing required Attributes: ${missingAttributes.toString()}`))(Attributes)); }), TE.map(() => _)); const responseIssuerValidationStep = (_) => (0, function_1.pipe)(TE.fromEither((0, function_1.pipe)((0, samlUtils_1.validateIssuer)(_.Response, _.SAMLRequestCache.idpIssuer), E.chainW((Issuer) => (0, function_1.pipe)(E.fromOption(() => "Format missing")(O.fromNullable(Issuer.getAttribute("Format"))), E.mapLeft(() => E.right(_)), E.map((_1) => E.fromPredicate((FormatValue) => !FormatValue || FormatValue === samlUtils_1.ISSUER_FORMAT, () => ISSUER_FORMAT_ERROR)(_1)), E.map(() => E.right(_)), E.toUnion)))), TE.map(() => _)); const assertionIssuerValidationStep = (_) => (0, function_1.pipe)(TE.fromEither((0, function_1.pipe)((0, samlUtils_1.validateIssuer)(_.Assertion, _.SAMLRequestCache.idpIssuer), E.chain((Issuer) => (0, function_1.pipe)(strings_1.NonEmptyString.decode(Issuer.getAttribute("Format")), E.mapLeft(() => new Error("Format attribute of Issuer element must be a non empty string into Assertion")), E.chain(E.fromPredicate((Format) => Format === samlUtils_1.ISSUER_FORMAT, () => ISSUER_FORMAT_ERROR)), E.fold((err) => // Skip Issuer Format validation if IDP has non-strict validation option !hasStrictValidation ? E.right(_) : E.left(err), (_1) => E.right(_)))))), TE.map(() => _)); const transformValidationStep = (_) => (0, function_1.pipe)(TE.fromEither((0, samlUtils_1.transformsValidation)(_.Response, _.SAMLRequestCache.idpIssuer)), TE.map(() => _)); /* LOGGING INFOS: * having the idpIssuer and requestId as data here we leverage multiple advantages: * 1. we can query based on the idp and display graphs about errors/usage * 2. we know what idp is causing the error * 3. having the requestId it's possible to analyze further the problem encountered */ const validationFailure = (error) => { if (eventHandler) { if (samlUtils_1.TransformError.is(error)) { eventHandler({ data: { idpIssuer: error.idpIssuer, message: error.message, numberOfTransforms: String(error.numberOfTransforms), requestId, }, name: "spid.error.transformOccurenceOverflow", type: "ERROR", }); } else { eventHandler({ data: { idpIssuer, message: error.message, requestId, }, name: "spid.error.generic", type: "ERROR", }); } } return callback(E.toError(error.message)); }; const validationSuccess = (_) => { // Number of the Response signature. // Calculated as number of the Signature elements inside the document minus number of the Signature element of the Assertion. const signatureOfResponseCount = _.Response.getElementsByTagNameNS(samlUtils_2.SAML_NAMESPACE.XMLDSIG, "Signature") .length - _.Assertion.getElementsByTagNameNS(samlUtils_2.SAML_NAMESPACE.XMLDSIG, "Signature").length; // For security reasons it is preferable that the Response be signed. // According to the technical rules of SPID, the signature of the Response is optional @ref https://docs.italia.it/italia/spid/spid-regole-tecniche/it/stabile/single-sign-on.html#response. // Here we collect data when an IDP sends an unsigned Response. // If all IDPs sign it, we can safely request it as mandatory @ref https://www.pivotaltracker.com/story/show/174710289. if (eventHandler && signatureOfResponseCount === 0) { eventHandler({ data: { idpIssuer: _.SAMLRequestCache.idpIssuer, message: "Missing Request signature", requestId: _.InResponseTo, }, name: "spid.error.signature", type: "INFO", }); } return callback(null, true, _.InResponseTo); }; (0, function_1.pipe)(responseElementValidationStep, TE.chain(returnRequestAndResponseStep), TE.chain(parseSAMLRequestStep), TE.chain(getIssueInstantFromRequestStep), TE.chainFirst(issueInstantValidationStep), TE.chainFirst(assertionIssueInstantValidationStep), TE.chain(authnContextClassRefValidationStep), TE.chainFirst(attributesValidationStep), TE.chainFirst(responseIssuerValidationStep), TE.chainFirst(assertionIssuerValidationStep), TE.chainFirstW(transformValidationStep), // log timings infos TE.chainFirstW((0, samlUtils_1.extractAndLogTimings)(startTime, idpIssuer, requestId, samlConfig.acceptedClockSkewMs, eventHandler, hasClockSkewLoggingEvent)), TE.bimap(validationFailure, validationSuccess))().catch(callback); }; exports.getPreValidateResponse = getPreValidateResponse; //# sourceMappingURL=saml.js.map