@pagopa/io-spid-commons
Version:
Common code for integrating SPID authentication
190 lines • 18.6 kB
JavaScript
;
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