diff --git a/package.json b/package.json index d9c693e0..68070870 100644 --- a/package.json +++ b/package.json @@ -56,12 +56,12 @@ "typescript": "^3.7.0" }, "dependencies": { + "@pagopa/ts-commons": "^10.0.0", "@types/redis": "^2.8.14", "date-fns": "^1.30.1", - "fp-ts": "1.17.0", - "io-ts": "1.8.5", - "io-ts-types": "^0.4.7", - "italia-ts-commons": "^5.1.4", + "fp-ts": "^2.11.1", + "io-ts": "^2.2.16", + "io-ts-types": "^0.5.16", "node-fetch": "^2.2.0", "node-forge": "^0.10.0", "passport": "^0.4.1", @@ -85,9 +85,6 @@ "preset": "ts-jest", "testMatch": null }, - "resolutions": { - "fp-ts": "1.17.0" - }, "bugs": { "url": "https://github.com/pagopa/io-spid-commons/issues" }, diff --git a/src/__mocks__/metadata.ts b/src/__mocks__/metadata.ts index f9704357..8964fdd9 100644 --- a/src/__mocks__/metadata.ts +++ b/src/__mocks__/metadata.ts @@ -2,7 +2,8 @@ import { IDPEntityDescriptor } from "../types/IDPEntityDescriptor"; import { NonEmptyArray } from "fp-ts/lib/NonEmptyArray"; -import { NonEmptyString } from "italia-ts-commons/lib/strings"; +// tslint:disable-next-line: no-submodule-imports +import { NonEmptyString } from "@pagopa/ts-commons/lib/strings"; export const mockIdpMetadata: Record = { intesaid: { diff --git a/src/__tests__/index.test.ts b/src/__tests__/index.test.ts index 1ec78a33..53ba8bdf 100644 --- a/src/__tests__/index.test.ts +++ b/src/__tests__/index.test.ts @@ -1,7 +1,8 @@ +// tslint:disable-next-line: no-submodule-imports +import { ResponsePermanentRedirect } from "@pagopa/ts-commons/lib/responses"; import * as express from "express"; import { left, right } from "fp-ts/lib/Either"; import { fromEither } from "fp-ts/lib/TaskEither"; -import { ResponsePermanentRedirect } from "italia-ts-commons/lib/responses"; import { createMockRedis } from "mock-redis-client"; import { RedisClient } from "redis"; import * as request from "supertest"; @@ -178,7 +179,7 @@ describe("io-spid-commons withSpid", () => { app, acs: async () => ResponsePermanentRedirect({ href: "/success?acs" }), logout: async () => ResponsePermanentRedirect({ href: "/success?logout" }) - }).run(); + })(); expect(mockFetchIdpsMetadata).toBeCalledTimes(3); const emptySpidStrategyOption = getSpidStrategyOption(spid.app); expect(emptySpidStrategyOption).toHaveProperty("idp", {}); @@ -186,7 +187,7 @@ describe("io-spid-commons withSpid", () => { jest.resetAllMocks(); initMockFetchIDPMetadata(); - await spid.idpMetadataRefresher().run(); + await spid.idpMetadataRefresher()(); expect(mockFetchIdpsMetadata).toHaveBeenNthCalledWith( 1, IDPMetadataUrl, @@ -221,7 +222,7 @@ describe("io-spid-commons withSpid", () => { app, acs: async () => ResponsePermanentRedirect({ href: "/success?acs" }), logout: async () => ResponsePermanentRedirect({ href: "/success?logout" }) - }).run(); + })(); return request(spid.app) .get(`${appConfig.loginPath}?authLevel=SpidL1`) .expect(400); diff --git a/src/bin/startup-idps-metadata.ts b/src/bin/startup-idps-metadata.ts index 0592d713..b6b3e00e 100644 --- a/src/bin/startup-idps-metadata.ts +++ b/src/bin/startup-idps-metadata.ts @@ -1,8 +1,11 @@ #!/usr/bin/env node -import { array } from "fp-ts/lib/Array"; -import { fromNullable } from "fp-ts/lib/Option"; -import { task } from "fp-ts/lib/Task"; +import * as AP from "fp-ts/lib/Apply"; +import * as A from "fp-ts/lib/Array"; +import { pipe } from "fp-ts/lib/function"; +import * as O from "fp-ts/lib/Option"; +import * as T from "fp-ts/lib/Task"; +import * as TE from "fp-ts/lib/TaskEither"; import * as yargs from "yargs"; import { logger } from "../utils/logger"; import { fetchMetadataXML } from "../utils/metadata"; @@ -44,44 +47,51 @@ function printIdpsMetadata( ): Promise { // tslint:disable: no-object-mutation no-any no-empty logger.info = (): any => {}; - const maybeIdpsMetadataURL = fromNullable(idpsMetadataENV) - .mapNullable(_ => process.env[_]) - .map((_: string) => - fetchMetadataXML(_) - .map<{ idps?: string }>(_1 => ({ - idps: _1 - })) - .getOrElse({}) - ) - .getOrElse(task.of({})); - const maybeTestEnvMetadataURL = fromNullable(testEnv2MetadataENV) - .mapNullable(_ => process.env[_]) - .map((_: string) => - fetchMetadataXML(`${_}/metadata`) - .map<{ xx_testenv2?: string }>(_1 => ({ - xx_testenv2: _1 - })) - .getOrElse({}) - ) - .getOrElse(task.of({})); - const maybeCIEMetadataURL = fromNullable(cieMetadataENV) - .mapNullable(_ => process.env[_]) - .map((_: string) => - fetchMetadataXML(_) - .map<{ xx_servizicie?: string }>(_1 => ({ - xx_servizicie: _1 - })) - .getOrElse({}) - ) - .getOrElse(task.of({})); - return array - .sequence(task)([ + const maybeIdpsMetadataURL = pipe( + O.fromNullable(idpsMetadataENV), + O.chainNullableK(_ => process.env[_]), + O.map((_: string) => + pipe( + TE.Do, + TE.bind("idps", () => fetchMetadataXML(_)), + TE.getOrElseW(() => T.of({})) + ) + ), + O.getOrElseW(() => T.of({})) + ); + const maybeTestEnvMetadataURL = pipe( + O.fromNullable(testEnv2MetadataENV), + O.chainNullableK(_ => process.env[_]), + O.map((_: string) => + pipe( + TE.Do, + TE.bind("xx_testenv2", () => fetchMetadataXML(`${_}/metadata`)), + TE.getOrElseW(() => T.of({})) + ) + ), + O.getOrElseW(() => T.of({})) + ); + const maybeCIEMetadataURL = pipe( + O.fromNullable(cieMetadataENV), + O.chainNullableK(_ => process.env[_]), + O.map((_: string) => + pipe( + TE.Do, + TE.bind("xx_servizicie", () => fetchMetadataXML(_)), + TE.getOrElseW(() => T.of({})) + ) + ), + O.getOrElseW(() => T.of({})) + ); + return pipe( + AP.sequenceT(T.ApplicativePar)( maybeIdpsMetadataURL, maybeTestEnvMetadataURL, maybeCIEMetadataURL - ]) - .map(_ => _.reduce((prev, current) => ({ ...prev, ...current }), {})) - .run(); + ), + // tslint:disable-next-line: no-inferred-empty-object-type + T.map(A.reduce({}, (prev, current) => ({ ...prev, ...current }))) + )(); } printIdpsMetadata( diff --git a/src/example.ts b/src/example.ts index 2ebc2983..e4677504 100644 --- a/src/example.ts +++ b/src/example.ts @@ -1,13 +1,17 @@ -import * as bodyParser from "body-parser"; -import * as express from "express"; -import * as fs from "fs"; -import * as t from "io-ts"; -import { ResponsePermanentRedirect } from "italia-ts-commons/lib/responses"; +// tslint:disable-next-line: no-submodule-imports +import { ResponsePermanentRedirect } from "@pagopa/ts-commons/lib/responses"; import { EmailString, FiscalCode, NonEmptyString -} from "italia-ts-commons/lib/strings"; + // tslint:disable-next-line: no-submodule-imports +} from "@pagopa/ts-commons/lib/strings"; +import * as bodyParser from "body-parser"; +import * as express from "express"; +import { pipe } from "fp-ts/lib/function"; +import * as T from "fp-ts/lib/Task"; +import * as fs from "fs"; +import * as t from "io-ts"; import passport = require("passport"); import { SamlConfig } from "passport-saml"; import * as redis from "redis"; @@ -152,17 +156,18 @@ const doneCb = (ip: string | null, request: string, response: string) => { console.log(response); }; -withSpid({ - acs, - app, - appConfig, - doneCb, - logout, - redisClient, - samlConfig, - serviceProviderConfig -}) - .map(({ app: withSpidApp, idpMetadataRefresher }) => { +pipe( + withSpid({ + acs, + app, + appConfig, + doneCb, + logout, + redisClient, + samlConfig, + serviceProviderConfig + }), + T.map(({ app: withSpidApp, idpMetadataRefresher }) => { withSpidApp.get("/success", (_, res) => res.json({ success: "success" @@ -176,7 +181,7 @@ withSpid({ .status(400) ); withSpidApp.get("/refresh", async (_, res) => { - await idpMetadataRefresher().run(); + await idpMetadataRefresher()(); res.json({ metadataUpdate: "completed" }); @@ -194,6 +199,6 @@ withSpid({ ); withSpidApp.listen(3000); }) - .run() +)() // tslint:disable-next-line: no-console .catch(e => console.error("Application error: ", e)); diff --git a/src/index.ts b/src/index.ts index f745f88d..64710cad 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,12 +5,8 @@ * Setups the endpoint to generate service provider metadata * and a scheduled process to refresh IDP metadata from providers. */ -import * as express from "express"; -import { constVoid } from "fp-ts/lib/function"; -import { fromNullable, fromPredicate } from "fp-ts/lib/Option"; -import { Task, task } from "fp-ts/lib/Task"; -import * as t from "io-ts"; -import { toExpressHandler } from "italia-ts-commons/lib/express"; +// tslint:disable-next-line: no-submodule-imports +import { toExpressHandler } from "@pagopa/ts-commons/lib/express"; import { IResponseErrorForbiddenNotAuthorized, IResponseErrorInternal, @@ -20,7 +16,13 @@ import { ResponseErrorInternal, ResponseErrorValidation, ResponseSuccessXml -} from "italia-ts-commons/lib/responses"; + // tslint:disable-next-line: no-submodule-imports +} from "@pagopa/ts-commons/lib/responses"; +import * as express from "express"; +import { constVoid, pipe } from "fp-ts/lib/function"; +import * as O from "fp-ts/lib/Option"; +import * as T from "fp-ts/lib/Task"; +import * as t from "io-ts"; import * as passport from "passport"; import { SamlConfig } from "passport-saml"; import { RedisClient } from "redis"; @@ -116,14 +118,20 @@ const withSpidAuthMiddleware = ( ) => { passport.authenticate("spid", async (err, user) => { const maybeDoc = getXmlFromSamlResponse(req.body); - const issuer = maybeDoc.chain(getSamlIssuer).getOrElse("UNKNOWN"); + const issuer = pipe( + maybeDoc, + O.chain(getSamlIssuer), + O.getOrElse(() => "UNKNOWN") + ); if (err) { const redirectionUrl = clientErrorRedirectionUrl + - maybeDoc - .chain(getErrorCodeFromResponse) - .map(errorCode => `?errorCode=${errorCode}`) - .getOrElse(`?errorMessage=${err}`); + pipe( + maybeDoc, + O.chain(getErrorCodeFromResponse), + O.map(errorCode => `?errorCode=${errorCode}`), + O.getOrElse(() => `?errorMessage=${err}`) + ); logger.error( "Spid Authentication|Authentication Error|ERROR=%s|ISSUER=%s|REDIRECT_TO=%s", err, @@ -170,9 +178,9 @@ export function withSpid({ redisClient, samlConfig, serviceProviderConfig -}: IWithSpidT): Task<{ +}: IWithSpidT): T.Task<{ app: express.Express; - idpMetadataRefresher: () => Task; + idpMetadataRefresher: () => T.Task; }> { const loadSpidStrategyOptions = getSpidStrategyOptionsUpdater( samlConfig, @@ -191,22 +199,25 @@ export function withSpid({ samlConfig ); - const maybeStartupIdpsMetadata = fromNullable(appConfig.startupIdpsMetadata); + const maybeStartupIdpsMetadata = O.fromNullable( + appConfig.startupIdpsMetadata + ); // If `startupIdpsMetadata` is provided, IDP metadata // are initially taken from its value when the backend starts - return maybeStartupIdpsMetadata - .map(parseStartupIdpsMetadata) - .map(idpOptionsRecord => - task.of( + return pipe( + maybeStartupIdpsMetadata, + O.map(parseStartupIdpsMetadata), + O.map(idpOptionsRecord => + T.of( makeSpidStrategyOptions( samlConfig, serviceProviderConfig, idpOptionsRecord ) ) - ) - .getOrElse(loadSpidStrategyOptions()) - .map(spidStrategyOptions => { + ), + O.getOrElse(loadSpidStrategyOptions), + T.map(spidStrategyOptions => { upsertSpidStrategyOption(app, spidStrategyOptions); return makeSpidStrategy( spidStrategyOptions, @@ -220,22 +231,26 @@ export function withSpid({ ), doneCb ); - }) - .map(spidStrategy => { + }), + T.map(spidStrategy => { // Even when `startupIdpsMetadata` is provided, we try to load // IDP metadata from the remote registries - maybeStartupIdpsMetadata.map(() => { - loadSpidStrategyOptions() - .map(opts => upsertSpidStrategyOption(app, opts)) - .run() - .catch(e => { + pipe( + maybeStartupIdpsMetadata, + O.map(() => { + pipe( + loadSpidStrategyOptions(), + T.map(opts => upsertSpidStrategyOption(app, opts)) + )().catch(e => { logger.error("loadSpidStrategyOptions|error:%s", e); }); - }); + }) + ); // Fetch IDPs metadata from remote URL and update SPID passport strategy options const idpMetadataRefresher = () => - loadSpidStrategyOptions().map(opts => - upsertSpidStrategyOption(app, opts) + pipe( + loadSpidStrategyOptions(), + T.map(opts => upsertSpidStrategyOption(app, opts)) ); // Initializes SpidStrategy for passport @@ -249,15 +264,16 @@ export function withSpid({ app.get( appConfig.loginPath, middlewareCatchAsInternalError((req, res, next) => { - fromNullable(req.query) - .mapNullable(q => q.authLevel) - .filter(t.keyof(SPID_LEVELS).is) - .chain( - fromPredicate(authLevel => + pipe( + O.fromNullable(req.query), + O.chainNullableK(q => q.authLevel), + O.filter(t.keyof(SPID_LEVELS).is), + O.chain( + O.fromPredicate(authLevel => appConfig.spidLevelsWhitelist.includes(authLevel) ) - ) - .foldL( + ), + O.fold( () => { logger.error( `Missing or invalid authLevel [${req?.query?.authLevel}]` @@ -267,8 +283,9 @@ export function withSpid({ "Missing or invalid authLevel" ).apply(res); }, - () => next() - ); + _ => next() + ) + ); }), middlewareCatchAsInternalError(spidAuth) ); @@ -321,5 +338,6 @@ export function withSpid({ app.post(appConfig.sloPath, toExpressHandler(logout)); return { app, idpMetadataRefresher }; - }); + }) + ); } diff --git a/src/strategy/__tests__/redis_cache_provider.test.ts b/src/strategy/__tests__/redis_cache_provider.test.ts index e443f985..4ad5ffa2 100644 --- a/src/strategy/__tests__/redis_cache_provider.test.ts +++ b/src/strategy/__tests__/redis_cache_provider.test.ts @@ -1,5 +1,5 @@ // tslint:disable: no-object-mutation -import { isRight } from "fp-ts/lib/Either"; +import { isLeft, isRight } from "fp-ts/lib/Either"; import { createMockRedis } from "mock-redis-client"; import { SamlConfig } from "passport-saml"; import { RedisClient } from "redis"; @@ -71,19 +71,22 @@ describe("getExtendedRedisCacheProvider#save", () => { callback(null, "OK") ); const redisCacheProvider = getExtendedRedisCacheProvider(mockRedisClient); - const cacheSAMLResponse = await redisCacheProvider - .save(SAMLRequest, samlConfig) - .run(); + const cacheSAMLResponse = await redisCacheProvider.save( + SAMLRequest, + samlConfig + )(); expect(mockSet.mock.calls[0][0]).toBe(`SAML-EXT-${expectedRequestID}`); expect(mockSet.mock.calls[0][1]).toEqual(expect.any(String)); expect(mockSet.mock.calls[0][2]).toBe("EX"); expect(mockSet.mock.calls[0][3]).toBe(keyExpirationPeriodSeconds); expect(isRight(cacheSAMLResponse)).toBeTruthy(); - expect(cacheSAMLResponse.value).toEqual({ - RequestXML: SAMLRequest, - createdAt: expect.any(Date), - idpIssuer: samlConfig.idpIssuer - }); + if (isRight(cacheSAMLResponse)) { + expect(cacheSAMLResponse.right).toEqual({ + RequestXML: SAMLRequest, + createdAt: expect.any(Date), + idpIssuer: samlConfig.idpIssuer + }); + } }); it("should return an error if save on radis fail", async () => { const expectedRedisError = new Error("saveError"); @@ -91,29 +94,32 @@ describe("getExtendedRedisCacheProvider#save", () => { callback(expectedRedisError) ); const redisCacheProvider = getExtendedRedisCacheProvider(mockRedisClient); - const cacheSAMLResponse = await redisCacheProvider - .save(SAMLRequest, samlConfig) - .run(); + const cacheSAMLResponse = await redisCacheProvider.save( + SAMLRequest, + samlConfig + )(); expect(mockSet.mock.calls[0][0]).toBe(`SAML-EXT-${expectedRequestID}`); expect(mockSet.mock.calls[0][1]).toEqual(expect.any(String)); expect(mockSet.mock.calls[0][2]).toBe("EX"); expect(mockSet.mock.calls[0][3]).toBe(keyExpirationPeriodSeconds); expect(isRight(cacheSAMLResponse)).toBeFalsy(); - expect(cacheSAMLResponse.value).toEqual( - new Error( - `SAML#ExtendedRedisCacheProvider: set() error ${expectedRedisError}` - ) - ); + if (isLeft(cacheSAMLResponse)) { + expect(cacheSAMLResponse.left).toEqual( + new Error( + `SAML#ExtendedRedisCacheProvider: set() error ${expectedRedisError}` + ) + ); + } }); it("should return an error if idpIssuer is missing inside samlConfig", async () => { const redisCacheProvider = getExtendedRedisCacheProvider(mockRedisClient); - const cacheSAMLResponse = await redisCacheProvider - .save(SAMLRequest, {}) - .run(); + const cacheSAMLResponse = await redisCacheProvider.save(SAMLRequest, {})(); expect(isRight(cacheSAMLResponse)).toBeFalsy(); - expect(cacheSAMLResponse.value).toEqual( - new Error("Missing idpIssuer inside configuration") - ); + if (isLeft(cacheSAMLResponse)) { + expect(cacheSAMLResponse.left).toEqual( + new Error("Missing idpIssuer inside configuration") + ); + } }); it("should return an error if Request ID is missing", async () => { const SAMLRequestWithoutID = SAMLRequest.replace( @@ -121,13 +127,16 @@ describe("getExtendedRedisCacheProvider#save", () => { "" ); const redisCacheProvider = getExtendedRedisCacheProvider(mockRedisClient); - const cacheSAMLResponse = await redisCacheProvider - .save(SAMLRequestWithoutID, samlConfig) - .run(); + const cacheSAMLResponse = await redisCacheProvider.save( + SAMLRequestWithoutID, + samlConfig + )(); expect(isRight(cacheSAMLResponse)).toBeFalsy(); - expect(cacheSAMLResponse.value).toEqual( - new Error(`SAML#ExtendedRedisCacheProvider: missing AuthnRequest ID`) - ); + if (isLeft(cacheSAMLResponse)) { + expect(cacheSAMLResponse.left).toEqual( + new Error(`SAML#ExtendedRedisCacheProvider: missing AuthnRequest ID`) + ); + } }); }); @@ -143,27 +152,27 @@ describe("getExtendedRedisCacheProvider#save", () => { callback(null, JSON.stringify(expectedRequestData)) ); const redisCacheProvider = getExtendedRedisCacheProvider(mockRedisClient); - const cacheSAMLResponse = await redisCacheProvider - .get(expectedRequestID) - .run(); + const cacheSAMLResponse = await redisCacheProvider.get(expectedRequestID)(); expect(mockGet.mock.calls[0][0]).toBe(`SAML-EXT-${expectedRequestID}`); expect(isRight(cacheSAMLResponse)).toBeTruthy(); - expect(cacheSAMLResponse.value).toEqual(expectedRequestData); + if (isRight(cacheSAMLResponse)) { + expect(cacheSAMLResponse.right).toEqual(expectedRequestData); + } }); it("should return an error if the reading process on redis fail", async () => { const expectedRedisError = new Error("readError"); mockGet.mockImplementation((_, callback) => callback(expectedRedisError)); const redisCacheProvider = getExtendedRedisCacheProvider(mockRedisClient); - const cacheSAMLResponse = await redisCacheProvider - .get(expectedRequestID) - .run(); + const cacheSAMLResponse = await redisCacheProvider.get(expectedRequestID)(); expect(mockGet.mock.calls[0][0]).toBe(`SAML-EXT-${expectedRequestID}`); expect(isRight(cacheSAMLResponse)).toBeFalsy(); - expect(cacheSAMLResponse.value).toEqual( - new Error( - `SAML#ExtendedRedisCacheProvider: get() error ${expectedRedisError}` - ) - ); + if (isLeft(cacheSAMLResponse)) { + expect(cacheSAMLResponse.left).toEqual( + new Error( + `SAML#ExtendedRedisCacheProvider: get() error ${expectedRedisError}` + ) + ); + } }); it("should return an error cached Request is not compliant", async () => { const invalidCachedRequestData = { @@ -175,22 +184,22 @@ describe("getExtendedRedisCacheProvider#save", () => { callback(null, JSON.stringify(invalidCachedRequestData)) ); const redisCacheProvider = getExtendedRedisCacheProvider(mockRedisClient); - const cacheSAMLResponse = await redisCacheProvider - .get(expectedRequestID) - .run(); + const cacheSAMLResponse = await redisCacheProvider.get(expectedRequestID)(); expect(mockGet.mock.calls[0][0]).toBe(`SAML-EXT-${expectedRequestID}`); expect(isRight(cacheSAMLResponse)).toBeFalsy(); - expect(cacheSAMLResponse.value).toEqual(expect.any(Error)); + if (isLeft(cacheSAMLResponse)) { + expect(cacheSAMLResponse.left).toEqual(expect.any(Error)); + } }); it("should return an error is the cached Request is missing", async () => { mockGet.mockImplementation((_, callback) => callback(null, null)); const redisCacheProvider = getExtendedRedisCacheProvider(mockRedisClient); - const cacheSAMLResponse = await redisCacheProvider - .get(expectedRequestID) - .run(); + const cacheSAMLResponse = await redisCacheProvider.get(expectedRequestID)(); expect(mockGet.mock.calls[0][0]).toBe(`SAML-EXT-${expectedRequestID}`); expect(isRight(cacheSAMLResponse)).toBeFalsy(); - expect(cacheSAMLResponse.value).toEqual(expect.any(Error)); + if (isLeft(cacheSAMLResponse)) { + expect(cacheSAMLResponse.left).toEqual(expect.any(Error)); + } }); }); @@ -198,12 +207,12 @@ describe("getExtendedRedisCacheProvider#remove", () => { it("should return the RequestID if the deletion process succeded", async () => { mockDel.mockImplementation((_, callback) => callback(null, 1)); const redisCacheProvider = getExtendedRedisCacheProvider(mockRedisClient); - const maybeRequestId = await redisCacheProvider - .remove(expectedRequestID) - .run(); + const maybeRequestId = await redisCacheProvider.remove(expectedRequestID)(); expect(mockDel.mock.calls[0][0]).toBe(`SAML-EXT-${expectedRequestID}`); expect(isRight(maybeRequestId)).toBeTruthy(); - expect(maybeRequestId.value).toBe(expectedRequestID); + if (isRight(maybeRequestId)) { + expect(maybeRequestId.right).toBe(expectedRequestID); + } }); it("should return an error if the cache's deletion fail", async () => { @@ -212,15 +221,15 @@ describe("getExtendedRedisCacheProvider#remove", () => { callback(expectedDelRedisError) ); const redisCacheProvider = getExtendedRedisCacheProvider(mockRedisClient); - const maybeRequestId = await redisCacheProvider - .remove(expectedRequestID) - .run(); + const maybeRequestId = await redisCacheProvider.remove(expectedRequestID)(); expect(mockDel.mock.calls[0][0]).toBe(`SAML-EXT-${expectedRequestID}`); expect(isRight(maybeRequestId)).toBeFalsy(); - expect(maybeRequestId.value).toEqual( - new Error( - `SAML#ExtendedRedisCacheProvider: remove() error ${expectedDelRedisError}` - ) - ); + if (isLeft(maybeRequestId)) { + expect(maybeRequestId.left).toEqual( + new Error( + `SAML#ExtendedRedisCacheProvider: remove() error ${expectedDelRedisError}` + ) + ); + } }); }); diff --git a/src/strategy/redis_cache_provider.ts b/src/strategy/redis_cache_provider.ts index ac979869..7263f848 100644 --- a/src/strategy/redis_cache_provider.ts +++ b/src/strategy/redis_cache_provider.ts @@ -1,10 +1,15 @@ -import { fromOption, parseJSON, toError } from "fp-ts/lib/Either"; -import { fromNullable } from "fp-ts/lib/Option"; -import { fromEither, TaskEither, taskify } from "fp-ts/lib/TaskEither"; +// tslint:disable-next-line: no-submodule-imports +import { UTCISODateFromString } from "@pagopa/ts-commons/lib/dates"; +// tslint:disable-next-line: no-submodule-imports +import { readableReport } from "@pagopa/ts-commons/lib/reporters"; +// tslint:disable-next-line: no-submodule-imports +import { Second } from "@pagopa/ts-commons/lib/units"; +import * as E from "fp-ts/lib/Either"; +import { pipe } from "fp-ts/lib/function"; +import * as O from "fp-ts/lib/Option"; +import * as TE from "fp-ts/lib/TaskEither"; +import { TaskEither } from "fp-ts/lib/TaskEither"; import * as t from "io-ts"; -import { UTCISODateFromString } from "italia-ts-commons/lib/dates"; -import { readableReport } from "italia-ts-commons/lib/reporters"; -import { Second } from "italia-ts-commons/lib/units"; import { CacheProvider, SamlConfig } from "passport-saml"; import * as redis from "redis"; import { getIDFromRequest } from "../utils/saml"; @@ -62,84 +67,109 @@ export const getExtendedRedisCacheProvider = ( RequestXML: string, samlConfig: SamlConfig ): TaskEither { - return fromEither( - fromOption( - new Error(`SAML#ExtendedRedisCacheProvider: missing AuthnRequest ID`) - )(getIDFromRequest(RequestXML)) - ) - .chain(AuthnRequestID => - fromEither( - fromOption(new Error("Missing idpIssuer inside configuration"))( - fromNullable(samlConfig.idpIssuer) - ) - ).map(idpIssuer => ({ idpIssuer, AuthnRequestID })) - ) - .chain(_ => { + return pipe( + TE.fromEither( + E.fromOption( + () => + new Error( + `SAML#ExtendedRedisCacheProvider: missing AuthnRequest ID` + ) + )(getIDFromRequest(RequestXML)) + ), + TE.chain(AuthnRequestID => + pipe( + TE.fromEither( + E.fromOption( + () => new Error("Missing idpIssuer inside configuration") + )(O.fromNullable(samlConfig.idpIssuer)) + ), + TE.map(idpIssuer => ({ idpIssuer, AuthnRequestID })) + ) + ), + TE.chain(_ => { const v: SAMLRequestCacheItem = { RequestXML, createdAt: new Date(), idpIssuer: _.idpIssuer }; - return taskify( - ( - key: string, - data: string, - flag: "EX", - expiration: number, - callback: (err: Error | null, value: unknown) => void - ) => redisClient.set(key, data, flag, expiration, callback) - )( - `${keyPrefix}${_.AuthnRequestID}`, - JSON.stringify(v), - "EX", - keyExpirationPeriodSeconds - ) - .mapLeft( + return pipe( + TE.taskify( + ( + key: string, + data: string, + flag: "EX", + expiration: number, + callback: (err: Error | null, value: unknown) => void + ) => redisClient.set(key, data, flag, expiration, callback) + )( + `${keyPrefix}${_.AuthnRequestID}`, + JSON.stringify(v), + "EX", + keyExpirationPeriodSeconds + ), + TE.mapLeft( err => new Error(`SAML#ExtendedRedisCacheProvider: set() error ${err}`) - ) - .map(() => v); - }); + ), + TE.map(() => v) + ); + }) + ); }, get(AuthnRequestID: string): TaskEither { - return taskify( - (key: string, callback: (e: Error | null, value?: string) => void) => { - redisClient.get(key, (e, v) => - // redis callbacks consider empty value as null instead of undefined, - // hence the need for the following wrapper to convert nulls to undefined - callback(e, v === null ? undefined : v) - ); - } - )(`${keyPrefix}${AuthnRequestID}`) - .mapLeft( + return pipe( + TE.taskify( + ( + key: string, + callback: (e: Error | null, value?: string) => void + ) => { + redisClient.get(key, (e, v) => + // redis callbacks consider empty value as null instead of undefined, + // hence the need for the following wrapper to convert nulls to undefined + callback(e, v === null ? undefined : v) + ); + } + )(`${keyPrefix}${AuthnRequestID}`), + TE.mapLeft( err => new Error(`SAML#ExtendedRedisCacheProvider: get() error ${err}`) - ) - .chain(value => - fromEither( - parseJSON(value, toError).chain(_ => - SAMLRequestCacheItem.decode(_).mapLeft( - __ => - new Error( - `SAML#ExtendedRedisCacheProvider: get() error ${readableReport( - __ - )}` + ), + TE.chain(value => + TE.fromEither( + pipe( + E.parseJSON(value, E.toError), + E.chain(_ => + pipe( + SAMLRequestCacheItem.decode(_), + E.mapLeft( + __ => + new Error( + `SAML#ExtendedRedisCacheProvider: get() error ${readableReport( + __ + )}` + ) ) + ) ) ) ) - ); + ) + ); }, remove(AuthnRequestID): TaskEither { - return taskify( - (key: string, callback: (err: Error | null, value?: unknown) => void) => - redisClient.del(key, callback) - )(`${keyPrefix}${AuthnRequestID}`) - .mapLeft( + return pipe( + TE.taskify( + ( + key: string, + callback: (err: Error | null, value?: unknown) => void + ) => redisClient.del(key, callback) + )(`${keyPrefix}${AuthnRequestID}`), + TE.mapLeft( err => new Error(`SAML#ExtendedRedisCacheProvider: remove() error ${err}`) - ) - .map(() => AuthnRequestID); + ), + TE.map(() => AuthnRequestID) + ); } }; }; diff --git a/src/strategy/saml_client.ts b/src/strategy/saml_client.ts index 322be265..aedd3864 100644 --- a/src/strategy/saml_client.ts +++ b/src/strategy/saml_client.ts @@ -1,5 +1,7 @@ import * as express from "express"; -import { fromNullable } from "fp-ts/lib/Option"; +import { pipe } from "fp-ts/lib/function"; +import * as O from "fp-ts/lib/Option"; +import * as TE from "fp-ts/lib/TaskEither"; import { SamlConfig } from "passport-saml"; import * as PassportSaml from "passport-saml"; import { IExtendedCacheProvider } from "./redis_cache_provider"; @@ -48,11 +50,11 @@ export class CustomSamlClient extends PassportSaml.SAML { return super.validatePostResponse(body, (error, __, ___) => { if (!error && isValid && AuthnRequestID) { // tslint:disable-next-line: no-floating-promises - this.extededCacheProvider - .remove(AuthnRequestID) - .map(_ => callback(error, __, ___)) - .mapLeft(callback) - .run(); + pipe( + this.extededCacheProvider.remove(AuthnRequestID), + TE.map(_ => callback(error, __, ___)), + TE.mapLeft(callback) + )(); } else { callback(error, __, ___); } @@ -73,21 +75,24 @@ export class CustomSamlClient extends PassportSaml.SAML { isHttpPostBinding: boolean, callback: (err: Error, xml?: string) => void ): void { - const newCallback = fromNullable(this.tamperAuthorizeRequest) - .map(tamperAuthorizeRequest => (e: Error, xml?: string) => { + const newCallback = pipe( + O.fromNullable(this.tamperAuthorizeRequest), + O.map(tamperAuthorizeRequest => (e: Error, xml?: string) => { xml - ? tamperAuthorizeRequest(xml) - .chain(tamperedXml => + ? pipe( + tamperAuthorizeRequest(xml), + TE.chain(tamperedXml => this.extededCacheProvider.save(tamperedXml, this.config) - ) - .mapLeft(error => callback(error)) - .map(cache => + ), + TE.mapLeft(error => callback(error)), + TE.map(cache => callback((null as unknown) as Error, cache.RequestXML) ) - .run() + )() : callback(e); - }) - .getOrElse(callback); + }), + O.getOrElse(() => callback) + ); super.generateAuthorizeRequest( req, isPassive, diff --git a/src/strategy/spid.ts b/src/strategy/spid.ts index f9569e47..f10451db 100644 --- a/src/strategy/spid.ts +++ b/src/strategy/spid.ts @@ -1,5 +1,6 @@ import * as express from "express"; import { TaskEither } from "fp-ts/lib/TaskEither"; +import * as TE from "fp-ts/lib/TaskEither"; import { AuthenticateOptions, AuthorizeOptions, @@ -13,7 +14,9 @@ import { RedisClient } from "redis"; // tslint:disable-next-line: no-submodule-imports import { MultiSamlConfig } from "passport-saml/multiSamlStrategy"; -import { Second } from "italia-ts-commons/lib/units"; +// tslint:disable-next-line: no-submodule-imports +import { Second } from "@pagopa/ts-commons/lib/units"; +import { pipe } from "fp-ts/lib/function"; import { DoneCallbackT } from ".."; import { getExtendedRedisCacheProvider, @@ -167,12 +170,12 @@ export class SpidStrategy extends SamlStrategy { return this.tamperMetadata ? // Tamper the generated XML for service provider metadata - this.tamperMetadata(originalXml) - .fold( - e => callback(e), - tamperedXml => callback(null, tamperedXml) - ) - .run() + pipe( + this.tamperMetadata(originalXml), + TE.map(tamperedXml => callback(null, tamperedXml)), + TE.mapLeft(callback), + TE.toUnion + )() : callback(null, originalXml); }); } diff --git a/src/types/IDPEntityDescriptor.ts b/src/types/IDPEntityDescriptor.ts index 81bda0e9..964f3561 100644 --- a/src/types/IDPEntityDescriptor.ts +++ b/src/types/IDPEntityDescriptor.ts @@ -1,7 +1,8 @@ +// tslint:disable-next-line: no-submodule-imports +import { NonEmptyString } from "@pagopa/ts-commons/lib/strings"; import * as t from "io-ts"; // tslint:disable-next-line: no-submodule-imports -import { createNonEmptyArrayFromArray } from "io-ts-types/lib/fp-ts/createNonEmptyArrayFromArray"; -import { NonEmptyString } from "italia-ts-commons/lib/strings"; +import { nonEmptyArray as createNonEmptyArrayFromArray } from "io-ts-types/nonEmptyArray"; export const IDPEntityDescriptor = t.interface({ cert: createNonEmptyArrayFromArray(NonEmptyString), diff --git a/src/utils/__tests__/metadata.test.ts b/src/utils/__tests__/metadata.test.ts index 42299a41..e572b842 100644 --- a/src/utils/__tests__/metadata.test.ts +++ b/src/utils/__tests__/metadata.test.ts @@ -1,5 +1,5 @@ import { isLeft, isRight, left } from "fp-ts/lib/Either"; -import { NonEmptyArray } from "fp-ts/lib/NonEmptyArray"; +// tslint:disable-next-line: no-submodule-imports import * as nock from "nock"; import { CIE_IDP_IDENTIFIERS, SPID_IDP_IDENTIFIERS } from "../../config"; import cieIdpMetadata from "../__mocks__/cie-idp-metadata"; @@ -19,9 +19,11 @@ describe("fetchIdpsMetadata", () => { const result = await fetchIdpsMetadata( mockedIdpsRegistryHost + notExistingPath, SPID_IDP_IDENTIFIERS - ).run(); + )(); expect(isLeft(result)).toBeTruthy(); - expect(result.value).toEqual(expect.any(Error)); + if (isLeft(result)) { + expect(result.left).toEqual(expect.any(Error)); + } }); it("should reject an error if the fetch of IdP metadata returns no useful data", async () => { @@ -32,9 +34,11 @@ describe("fetchIdpsMetadata", () => { const result = await fetchIdpsMetadata( mockedIdpsRegistryHost + wrongIdpMetadataPath, SPID_IDP_IDENTIFIERS - ).run(); + )(); expect(isLeft(result)).toBeTruthy(); - expect(result.value).toEqual(expect.any(Error)); + if (isLeft(result)) { + expect(result.left).toEqual(expect.any(Error)); + } }); it("should reject an error if the fetch of IdP metadata returns an unparsable response", async () => { @@ -45,9 +49,11 @@ describe("fetchIdpsMetadata", () => { const result = await fetchIdpsMetadata( mockedIdpsRegistryHost + wrongIdpMetadataPath, SPID_IDP_IDENTIFIERS - ).run(); + )(); expect(isLeft(result)).toBeTruthy(); - expect(result.value).toEqual(expect.any(Error)); + if (isLeft(result)) { + expect(result.left).toEqual(expect.any(Error)); + } }); it("should resolve with the fetched IdP options", async () => { @@ -58,7 +64,7 @@ describe("fetchIdpsMetadata", () => { const result = await fetchIdpsMetadata( mockedIdpsRegistryHost + validIdpMetadataPath, SPID_IDP_IDENTIFIERS - ).run(); + )(); expect(isRight(result)).toBeTruthy(); }); @@ -70,16 +76,18 @@ describe("fetchIdpsMetadata", () => { const result = await fetchIdpsMetadata( mockedIdpsRegistryHost + validCieMetadataPath, CIE_IDP_IDENTIFIERS - ).run(); + )(); expect(isRight(result)).toBeTruthy(); - expect(result.value).toHaveProperty("xx_servizicie_test", { - cert: expect.any(NonEmptyArray), - entityID: - "https://idserver.servizicie.interno.gov.it:8443/idp/profile/SAML2/POST/SSO", - entryPoint: - "https://idserver.servizicie.interno.gov.it:8443/idp/profile/SAML2/Redirect/SSO", - logoutUrl: "" - }); + if (isRight(result)) { + expect(result.right).toHaveProperty("xx_servizicie_test", { + cert: expect.any(Array), + entityID: + "https://idserver.servizicie.interno.gov.it:8443/idp/profile/SAML2/POST/SSO", + entryPoint: + "https://idserver.servizicie.interno.gov.it:8443/idp/profile/SAML2/Redirect/SSO", + logoutUrl: "" + }); + } }); it("should resolve with the fetched TestEnv IdP options", async () => { @@ -92,13 +100,15 @@ describe("fetchIdpsMetadata", () => { { [expectedTestenvEntityId]: "xx_testenv2" } - ).run(); + )(); expect(isRight(result)).toBeTruthy(); - expect(result.value).toHaveProperty("xx_testenv2", { - cert: expect.any(NonEmptyArray), - entityID: expectedTestenvEntityId, - entryPoint: "https://spid-testenv.dev.io.italia.it/sso", - logoutUrl: "https://spid-testenv.dev.io.italia.it/slo" - }); + if (isRight(result)) { + expect(result.right).toHaveProperty("xx_testenv2", { + cert: expect.any(Array), + entityID: expectedTestenvEntityId, + entryPoint: "https://spid-testenv.dev.io.italia.it/sso", + logoutUrl: "https://spid-testenv.dev.io.italia.it/slo" + }); + } }); }); diff --git a/src/utils/__tests__/middleware.test.ts b/src/utils/__tests__/middleware.test.ts index ca27ff1c..f38ab0af 100644 --- a/src/utils/__tests__/middleware.test.ts +++ b/src/utils/__tests__/middleware.test.ts @@ -91,7 +91,7 @@ describe("getSpidStrategyOptionsUpdater", () => { const updatedSpidStrategyOption = await getSpidStrategyOptionsUpdater( expectedSamlConfig, serviceProviderConfig - )().run(); + )()(); expect(mockFetchIdpsMetadata).toBeCalledTimes(3); expect(mockFetchIdpsMetadata).toHaveBeenNthCalledWith( 1, @@ -142,7 +142,7 @@ describe("getSpidStrategyOptionsUpdater", () => { const updatedSpidStrategyOption = await getSpidStrategyOptionsUpdater( expectedSamlConfig, serviceProviderConfig - )().run(); + )()(); expect(mockFetchIdpsMetadata).toBeCalledTimes(3); expect(mockFetchIdpsMetadata).toHaveBeenNthCalledWith( 1, @@ -198,7 +198,7 @@ describe("getSpidStrategyOptionsUpdater", () => { await getSpidStrategyOptionsUpdater( expectedSamlConfig, serviceProviderConfigWithoutOptional - )().run(); + )()(); expect(mockFetchIdpsMetadata).toBeCalledTimes(1); expect(mockFetchIdpsMetadata).toBeCalledWith( idpMetadataUrl, diff --git a/src/utils/__tests__/saml.test.ts b/src/utils/__tests__/saml.test.ts index 4ebff86c..3a103d88 100644 --- a/src/utils/__tests__/saml.test.ts +++ b/src/utils/__tests__/saml.test.ts @@ -17,7 +17,7 @@ import { getXmlFromSamlResponse, TransformError } from "../saml"; -import * as saml from "../saml"; +import * as saml from "../samlUtils"; const samlConfig: SamlConfig = ({ attributes: { diff --git a/src/utils/metadata.ts b/src/utils/metadata.ts index 470f8d95..b3d969c1 100644 --- a/src/utils/metadata.ts +++ b/src/utils/metadata.ts @@ -1,20 +1,15 @@ /** * Methods to fetch and parse Identity service providers metadata. */ -import { - Either, - fromPredicate as eitherFromPredicate, - right, - toError -} from "fp-ts/lib/Either"; -import { StrMap } from "fp-ts/lib/StrMap"; -import { - fromEither, - fromPredicate, - TaskEither, - tryCatch -} from "fp-ts/lib/TaskEither"; -import { errorsToReadableMessages } from "italia-ts-commons/lib/reporters"; +// tslint:disable-next-line: no-submodule-imports +import { errorsToReadableMessages } from "@pagopa/ts-commons/lib/reporters"; +import * as E from "fp-ts/lib/Either"; +import { Either } from "fp-ts/lib/Either"; +import { pipe } from "fp-ts/lib/function"; +import * as R from "fp-ts/lib/Record"; +import { Ord } from "fp-ts/lib/string"; +import * as TE from "fp-ts/lib/TaskEither"; +import { TaskEither } from "fp-ts/lib/TaskEither"; import nodeFetch from "node-fetch"; import { DOMParser } from "xmldom"; import { CIE_IDP_IDENTIFIERS, SPID_IDP_IDENTIFIERS } from "../config"; @@ -43,22 +38,21 @@ const METADATA_NAMESPACES = { export function parseIdpMetadata( ipdMetadataPage: string ): Either> { - return right( - new DOMParser().parseFromString(ipdMetadataPage) - ) - .chain( - eitherFromPredicate( + return pipe( + E.right(new DOMParser().parseFromString(ipdMetadataPage)), + E.chain( + E.fromPredicate( domParser => domParser && !domParser.getElementsByTagName("parsererror").item(0), () => new Error("XML parser error") ) - ) - .chain(domParser => { + ), + E.chain(domParser => { const entityDescriptors = domParser.getElementsByTagNameNS( METADATA_NAMESPACES.METADATA, EntityDescriptorTAG ); - return right( + return E.right( Array.from(entityDescriptors).reduce( (idps: ReadonlyArray, element: Element) => { const certs = Array.from( @@ -69,26 +63,14 @@ export function parseIdpMetadata( ).map(_ => _.textContent ? _.textContent.replace(/[\n\s]/g, "") : "" ); - return IDPEntityDescriptor.decode({ - cert: certs, - entityID: element.getAttribute("entityID"), - entryPoint: Array.from( - element.getElementsByTagNameNS( - METADATA_NAMESPACES.METADATA, - SingleSignOnServiceTAG - ) - ) - .filter( - _ => - _.getAttribute("Binding") === - "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" - )[0] - ?.getAttribute("Location"), - logoutUrl: - Array.from( + return pipe( + IDPEntityDescriptor.decode({ + cert: certs, + entityID: element.getAttribute("entityID"), + entryPoint: Array.from( element.getElementsByTagNameNS( METADATA_NAMESPACES.METADATA, - SingleLogoutServiceTAG + SingleSignOnServiceTAG ) ) .filter( @@ -96,24 +78,40 @@ export function parseIdpMetadata( _.getAttribute("Binding") === "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" )[0] - // If SingleLogoutService is missing will be return an empty string - // Needed for CIE Metadata - ?.getAttribute("Location") || "" - }).fold( - errs => { - logger.warn( - "Invalid md:EntityDescriptor. %s", - errorsToReadableMessages(errs).join(" / ") - ); - return idps; - }, - elementInfo => [...idps, elementInfo] + ?.getAttribute("Location"), + logoutUrl: + Array.from( + element.getElementsByTagNameNS( + METADATA_NAMESPACES.METADATA, + SingleLogoutServiceTAG + ) + ) + .filter( + _ => + _.getAttribute("Binding") === + "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" + )[0] + // If SingleLogoutService is missing will be return an empty string + // Needed for CIE Metadata + ?.getAttribute("Location") || "" + }), + E.fold( + errs => { + logger.warn( + "Invalid md:EntityDescriptor. %s", + errorsToReadableMessages(errs).join(" / ") + ); + return idps; + }, + elementInfo => [...idps, elementInfo] + ) ); }, [] ) ); - }); + }) + ); } /** @@ -135,26 +133,38 @@ export const mapIpdMetadata = ( return prev; }, {}); +/** + * Lazy version of mapIpdMetadata() + */ +export const mapIpdMetadataL = ( + idpIds: Record +): (( + idpMetadata: ReadonlyArray +) => Record) => idpMetadata => + mapIpdMetadata(idpMetadata, idpIds); + /** * Fetch an XML from a remote URL */ export function fetchMetadataXML( idpMetadataUrl: string ): TaskEither { - return tryCatch(() => { - logger.info("Fetching SPID metadata from [%s]...", idpMetadataUrl); - return nodeFetch(idpMetadataUrl); - }, toError) - .chain( - fromPredicate( + return pipe( + TE.tryCatch(() => { + logger.info("Fetching SPID metadata from [%s]...", idpMetadataUrl); + return nodeFetch(idpMetadataUrl); + }, E.toError), + TE.chain( + TE.fromPredicate( p => p.status >= 200 && p.status < 300, () => { logger.warn("Error fetching remote metadata for %s", idpMetadataUrl); return new Error("Error fetching remote metadata"); } ) - ) - .chain(p => tryCatch(() => p.text(), toError)); + ), + TE.chain(p => TE.tryCatch(() => p.text(), E.toError)) + ); } /** @@ -165,27 +175,29 @@ export function fetchIdpsMetadata( idpMetadataUrl: string, idpIds: Record ): TaskEither> { - return fetchMetadataXML(idpMetadataUrl) - .chain(idpMetadataXML => { + return pipe( + fetchMetadataXML(idpMetadataUrl), + TE.chain(idpMetadataXML => { logger.info("Parsing SPID metadata for %s", idpMetadataUrl); - return fromEither(parseIdpMetadata(idpMetadataXML)); - }) - .chain( - fromPredicate( + return TE.fromEither(parseIdpMetadata(idpMetadataXML)); + }), + TE.chain( + TE.fromPredicate( idpMetadata => idpMetadata.length > 0, () => { logger.error("No SPID metadata found for %s", idpMetadataUrl); return new Error("No SPID metadata found"); } ) - ) - .map(idpMetadata => { + ), + TE.map(idpMetadata => { if (!idpMetadata.length) { logger.warn("Missing SPID metadata on %s", idpMetadataUrl); } logger.info("Configuring IdPs for %s", idpMetadataUrl); return mapIpdMetadata(idpMetadata, idpIds); - }); + }) + ); } /** @@ -196,14 +208,18 @@ export function fetchIdpsMetadata( export function parseStartupIdpsMetadata( idpsMetadata: Record ): Record { - return mapIpdMetadata( - new StrMap(idpsMetadata).reduce( + return pipe( + idpsMetadata, + R.reduce(Ord)( [] as ReadonlyArray, (prev, metadataXML) => [ ...prev, - ...parseIdpMetadata(metadataXML).getOrElse([]) + ...pipe( + parseIdpMetadata(metadataXML), + E.getOrElseW(() => []) + ) ] ), - { ...SPID_IDP_IDENTIFIERS, ...CIE_IDP_IDENTIFIERS } // TODO: Add TestEnv IDP identifier + mapIpdMetadataL({ ...SPID_IDP_IDENTIFIERS, ...CIE_IDP_IDENTIFIERS }) ); } diff --git a/src/utils/middleware.ts b/src/utils/middleware.ts index ff19d164..b3106773 100644 --- a/src/utils/middleware.ts +++ b/src/utils/middleware.ts @@ -1,11 +1,14 @@ /** * SPID Passport strategy */ +// tslint:disable-next-line: no-submodule-imports +import { EmailString, NonEmptyString } from "@pagopa/ts-commons/lib/strings"; import * as express from "express"; -import { array } from "fp-ts/lib/Array"; -import { Task, task } from "fp-ts/lib/Task"; +import * as A from "fp-ts/lib/Array"; +import { pipe } from "fp-ts/lib/function"; +import * as T from "fp-ts/lib/Task"; +import * as TE from "fp-ts/lib/TaskEither"; import * as t from "io-ts"; -import { EmailString, NonEmptyString } from "italia-ts-commons/lib/strings"; import { Profile, SamlConfig, VerifiedCallback } from "passport-saml"; import { RedisClient } from "redis"; import { DoneCallbackT } from ".."; @@ -135,61 +138,75 @@ export function makeSpidStrategyOptions( export const getSpidStrategyOptionsUpdater = ( samlConfig: SamlConfig, serviceProviderConfig: IServiceProviderConfig -): (() => Task) => () => { +): (() => T.Task) => () => { const idpOptionsTasks = [ - fetchIdpsMetadata( - serviceProviderConfig.IDPMetadataUrl, - SPID_IDP_IDENTIFIERS - ).getOrElse({}) + pipe( + fetchIdpsMetadata( + serviceProviderConfig.IDPMetadataUrl, + SPID_IDP_IDENTIFIERS + ), + TE.getOrElseW(() => T.of({})) + ) ] .concat( - NonEmptyString.is(serviceProviderConfig.spidValidatorUrl) - ? [ - fetchIdpsMetadata( - `${serviceProviderConfig.spidValidatorUrl}/metadata.xml`, - { - // "https://validator.spid.gov.it" or "http://localhost:8080" - [serviceProviderConfig.spidValidatorUrl]: "xx_validator" - } - ).getOrElse({}) - ] - : [] + pipe( + NonEmptyString.is(serviceProviderConfig.spidValidatorUrl) + ? [ + pipe( + fetchIdpsMetadata( + `${serviceProviderConfig.spidValidatorUrl}/metadata.xml`, + { + // "https://validator.spid.gov.it" or "http://localhost:8080" + [serviceProviderConfig.spidValidatorUrl]: "xx_validator" + } + ), + TE.getOrElseW(() => T.of({})) + ) + ] + : [] + ) ) .concat( NonEmptyString.is(serviceProviderConfig.spidCieUrl) ? [ - fetchIdpsMetadata( - serviceProviderConfig.spidCieUrl, - CIE_IDP_IDENTIFIERS - ).getOrElse({}) + pipe( + fetchIdpsMetadata( + serviceProviderConfig.spidCieUrl, + CIE_IDP_IDENTIFIERS + ), + TE.getOrElseW(() => T.of({})) + ) ] : [] ) .concat( NonEmptyString.is(serviceProviderConfig.spidTestEnvUrl) ? [ - fetchIdpsMetadata( - `${serviceProviderConfig.spidTestEnvUrl}/metadata`, - { - [serviceProviderConfig.spidTestEnvUrl]: "xx_testenv2" - } - ).getOrElse({}) + pipe( + fetchIdpsMetadata( + `${serviceProviderConfig.spidTestEnvUrl}/metadata`, + { + [serviceProviderConfig.spidTestEnvUrl]: "xx_testenv2" + } + ), + TE.getOrElseW(() => T.of({})) + ) ] : [] ); - return array - .sequence(task)(idpOptionsTasks) - .map(idpOptionsRecords => - idpOptionsRecords.reduce((prev, current) => ({ ...prev, ...current }), {}) - ) - .map(idpOptionsRecord => { + return pipe( + A.sequence(T.ApplicativePar)(idpOptionsTasks), + // tslint:disable-next-line: no-inferred-empty-object-type + T.map(A.reduce({}, (prev, current) => ({ ...prev, ...current }))), + T.map(idpOptionsRecord => { logSamlCertExpiration(serviceProviderConfig.publicCert); return makeSpidStrategyOptions( samlConfig, serviceProviderConfig, idpOptionsRecord ); - }); + }) + ); }; const SPID_STRATEGY_OPTIONS_KEY = "spidStrategyOptions"; diff --git a/src/utils/response.ts b/src/utils/response.ts index a4f99f8c..a3717383 100644 --- a/src/utils/response.ts +++ b/src/utils/response.ts @@ -1,8 +1,11 @@ import { DOMParser } from "xmldom"; +// tslint:disable-next-line: no-submodule-imports +import { ResponseErrorInternal } from "@pagopa/ts-commons/lib/responses"; import { NextFunction, Request, Response } from "express"; -import { fromNullable, none, Option, some, tryCatch } from "fp-ts/lib/Option"; -import { ResponseErrorInternal } from "italia-ts-commons/lib/responses"; +import { pipe } from "fp-ts/lib/function"; +import * as O from "fp-ts/lib/Option"; +import { Option } from "fp-ts/lib/Option"; import { SAML_NAMESPACE } from "./saml"; /** @@ -12,25 +15,29 @@ import { SAML_NAMESPACE } from "./saml"; * returns "https://www.spid.gov.it/SpidL2" */ export function getAuthnContextFromResponse(xml: string): Option { - return fromNullable(xml) - .chain(xmlStr => tryCatch(() => new DOMParser().parseFromString(xmlStr))) - .chain(xmlResponse => + return pipe( + O.fromNullable(xml), + O.chain(xmlStr => + O.tryCatch(() => new DOMParser().parseFromString(xmlStr)) + ), + O.chain(xmlResponse => xmlResponse - ? some( + ? O.some( xmlResponse.getElementsByTagNameNS( SAML_NAMESPACE.ASSERTION, "AuthnContextClassRef" ) ) - : none - ) - .chain(responseAuthLevelEl => + : O.none + ), + O.chain(responseAuthLevelEl => responseAuthLevelEl && responseAuthLevelEl[0] && responseAuthLevelEl[0].textContent - ? some(responseAuthLevelEl[0].textContent.trim()) - : none - ); + ? O.some(responseAuthLevelEl[0].textContent.trim()) + : O.none + ) + ); } export function middlewareCatchAsInternalError( diff --git a/src/utils/saml.ts b/src/utils/saml.ts index b5a6bb7b..39965df0 100644 --- a/src/utils/saml.ts +++ b/src/utils/saml.ts @@ -4,1095 +4,59 @@ * SPID protocol has some peculiarities that need to be addressed * to make request, metadata and responses compliant. */ -import { distanceInWordsToNow, isAfter, subDays } from "date-fns"; -import { Request as ExpressRequest } from "express"; -import { difference, flatten } from "fp-ts/lib/Array"; -import { - Either, - fromOption, - fromPredicate, - left, - right, - toError -} from "fp-ts/lib/Either"; -import { identity, not } from "fp-ts/lib/function"; -import { - fromEither, - fromNullable, - fromPredicate as fromPredicateOption, - isNone, - none, - Option, - some, - tryCatch as optionTryCatch -} from "fp-ts/lib/Option"; -import { collect, lookup } from "fp-ts/lib/Record"; -import { setoidString } from "fp-ts/lib/Setoid"; -import { - fromEither as fromEitherToTaskEither, - TaskEither, - tryCatch -} from "fp-ts/lib/TaskEither"; -import * as t from "io-ts"; -import { UTCISODateFromString } from "italia-ts-commons/lib/dates"; -import { NonEmptyString } from "italia-ts-commons/lib/strings"; -import { pki } from "node-forge"; -import { SamlConfig } from "passport-saml"; // tslint:disable-next-line: no-submodule-imports -import { MultiSamlConfig } from "passport-saml/multiSamlStrategy"; -import * as xmlCrypto from "xml-crypto"; -import { Builder, parseStringPromise } from "xml2js"; +import { UTCISODateFromString } from "@pagopa/ts-commons/lib/dates"; +// tslint:disable-next-line: no-submodule-imports +import { NonEmptyString } from "@pagopa/ts-commons/lib/strings"; +import { predicate as PR } from "fp-ts"; +import { difference } from "fp-ts/lib/Array"; +import * as E from "fp-ts/lib/Either"; +import { not, pipe } from "fp-ts/lib/function"; +import * as O from "fp-ts/lib/Option"; +import { Eq } from "fp-ts/lib/string"; +import * as TE from "fp-ts/lib/TaskEither"; +import { TaskEither } from "fp-ts/lib/TaskEither"; import { DOMParser, XMLSerializer } from "xmldom"; -import { SPID_LEVELS, SPID_URLS, SPID_USER_ATTRIBUTES } from "../config"; +import { SPID_LEVELS, SPID_USER_ATTRIBUTES } from "../config"; import { EventTracker } from "../index"; import { PreValidateResponseT } from "../strategy/spid"; -import { logger } from "./logger"; +import { StrictResponseValidationOptions } from "./middleware"; import { - ContactType, - EntityType, - getSpidStrategyOption, - IServiceProviderConfig, - ISpidStrategyOptions, - StrictResponseValidationOptions -} from "./middleware"; - -export type SamlAttributeT = keyof typeof SPID_USER_ATTRIBUTES; - -interface IEntrypointCerts { - // tslint:disable-next-line: readonly-array - cert: NonEmptyString[]; - entryPoint?: string; - idpIssuer?: string; -} - -export const 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#" -}; - -const ISSUER_FORMAT = "urn:oasis:names:tc:SAML:2.0:nameid-format:entity"; - -const decodeBase64 = (s: string) => Buffer.from(s, "base64").toString("utf8"); - -/** - * Remove prefix and suffix from x509 certificate. - */ -const cleanCert = (cert: string) => - cert - .replace(/-+BEGIN CERTIFICATE-+\r?\n?/, "") - .replace(/-+END CERTIFICATE-+\r?\n?/, "") - .replace(/\r\n/g, "\n"); - -const SAMLResponse = t.type({ - SAMLResponse: t.string -}); - -/** - * True if the element contains at least one element signed using hamc - * @param e - */ -const isSignedWithHmac = (e: Element): boolean => { - const signatures = e.getElementsByTagNameNS( - SAML_NAMESPACE.XMLDSIG, - "SignatureMethod" - ); - return Array.from({ length: signatures.length }) - .map((_, i) => signatures.item(i)) - .some( - item => - item?.getAttribute("Algorithm")?.valueOf() === - "http://www.w3.org/2000/09/xmldsig#hmac-sha1" - ); -}; - -const notSignedWithHmacPredicate = fromPredicate( - not(isSignedWithHmac), - _ => new Error("HMAC Signature is forbidden") -); - -export const getXmlFromSamlResponse = (body: unknown): Option => - fromEither(SAMLResponse.decode(body)) - .map(_ => decodeBase64(_.SAMLResponse)) - .chain(_ => optionTryCatch(() => new DOMParser().parseFromString(_))); - -/** - * Extract StatusMessage from SAML response - * - * ie. for ErrorCode nr22 - * returns "22" - */ -export function getErrorCodeFromResponse(doc: Document): Option { - return fromNullable( - doc.getElementsByTagNameNS(SAML_NAMESPACE.PROTOCOL, "StatusMessage") - ) - .chain(responseStatusMessageEl => { - return responseStatusMessageEl && - responseStatusMessageEl[0] && - responseStatusMessageEl[0].textContent - ? some(responseStatusMessageEl[0].textContent.trim()) - : none; - }) - .chain(errorString => { - const indexString = "ErrorCode nr"; - const errorCode = errorString.slice( - errorString.indexOf(indexString) + indexString.length - ); - return errorCode !== "" ? some(errorCode) : none; - }); -} - -/** - * Extracts the issuer field from the response body. - */ -export const getSamlIssuer = (doc: Document): Option => { - return fromNullable( - doc.getElementsByTagNameNS(SAML_NAMESPACE.ASSERTION, "Issuer").item(0) - ).mapNullable(_ => _.textContent?.trim()); -}; - -/** - * 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: ExpressRequest, - idps: ISpidStrategyOptions["idp"] -): Option => { - return fromNullable(req) - .mapNullable(r => r.query) - .mapNullable(q => q.entityID) - .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" - ? fromNullable(idps[entityID]).map( - (idp): IEntrypointCerts => ({ - cert: idp.cert.toArray(), - entryPoint: idp.entryPoint, - idpIssuer: idp.entityID - }) - ) - : none - ) - .alt( - // collect all IDP certificates in case no entityID is provided - some({ - cert: flatten( - collect(idps, (_, idp) => (idp && idp.cert ? idp.cert.toArray() : [])) - ), - // TODO: leave entryPoint undefined when this gets fixed - // @see https://github.com/bergie/passport-saml/issues/415 - entryPoint: "" - } as IEntrypointCerts) - ); -}; - -export const getIDFromRequest = (requestXML: string): Option => { - const xmlRequest = new DOMParser().parseFromString(requestXML, "text/xml"); - return fromNullable( - xmlRequest - .getElementsByTagNameNS(SAML_NAMESPACE.PROTOCOL, "AuthnRequest") - .item(0) - ).chain(AuthnRequest => - fromEither(NonEmptyString.decode(AuthnRequest.getAttribute("ID"))) - ); -}; - -const getAuthnContextValueFromResponse = (response: string): Option => { - const xmlResponse = new DOMParser().parseFromString(response, "text/xml"); - // ie. https://www.spid.gov.it/SpidL2 - const responseAuthLevelEl = xmlResponse.getElementsByTagNameNS( - SAML_NAMESPACE.ASSERTION, - "AuthnContextClassRef" - ); - return responseAuthLevelEl[0] && responseAuthLevelEl[0].textContent - ? some(responseAuthLevelEl[0].textContent.trim()) - : none; -}; - -/** - * Extracts the correct SPID level from response. - */ -const getAuthSalmOptions = ( - req: ExpressRequest, - decodedResponse?: string -): Option> => { - return ( - fromNullable(req) - .mapNullable(r => r.query) - .mapNullable(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 - .filter((e): e is string => typeof e === "string") - .chain(authLevel => - lookup(authLevel, SPID_LEVELS) - .map(authnContext => ({ - authnContext, - forceAuthn: authLevel !== "SpidL1" - })) - .orElse(() => { - logger.error( - "SPID cannot find a valid authnContext for given authLevel: %s", - authLevel - ); - return none; - }) - ) - .alt( - fromNullable(decodedResponse) - .chain(response => getAuthnContextValueFromResponse(response)) - .chain(authnContext => - lookup(authnContext, SPID_URLS) - // check if the parsed value is a valid SPID AuthLevel - .map(authLevel => { - return { - authnContext, - forceAuthn: authLevel !== "SpidL1" - }; - }) - .orElse(() => { - logger.error( - "SPID cannot find a valid authLevel for given authnContext: %s", - authnContext - ); - return none; - }) - ) - ) - ); -}; - -/** - * Reads dates information in x509 certificate - * and logs remaining time to its expiration date. - * - * @param samlCert x509 certificate as string - */ -export function logSamlCertExpiration(samlCert: string): void { - try { - const out = pki.certificateFromPem(samlCert); - if (out.validity.notAfter) { - const timeDiff = distanceInWordsToNow(out.validity.notAfter); - const warningDate = subDays(new Date(), 60); - if (isAfter(out.validity.notAfter, warningDate)) { - logger.info("samlCert expire in %s", timeDiff); - } else if (isAfter(out.validity.notAfter, new Date())) { - logger.warn("samlCert expire in %s", timeDiff); - } else { - logger.error("samlCert expired from %s", timeDiff); - } - } else { - logger.error("Missing expiration date on saml certificate."); - } - } catch (e) { - logger.error("Error calculating saml cert expiration: %s", e); - } -} - -/** - * 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. - */ -export const getSamlOptions: MultiSamlConfig["getSamlOptions"] = ( - req, - done -) => { - try { - // Get decoded response - const decodedResponse = - req.body && req.body.SAMLResponse - ? decodeBase64(req.body.SAMLResponse) - : undefined; - - // Get SPID strategy options with IDPs metadata - const maybeSpidStrategyOptions = fromNullable( - getSpidStrategyOption(req.app) - ); - if (isNone(maybeSpidStrategyOptions)) { - throw new Error( - "Missing Spid Strategy Option configuration inside express App" - ); - } - - // Get the correct entry within the IDP metadata object - const maybeEntrypointCerts = maybeSpidStrategyOptions.chain( - spidStrategyOptions => getEntrypointCerts(req, spidStrategyOptions.idp) - ); - if (isNone(maybeEntrypointCerts)) { - logger.debug( - `SPID cannot find a valid idp in spidOptions for given entityID: ${req.query.entityID}` - ); - } - const entrypointCerts = maybeEntrypointCerts.getOrElse( - {} as IEntrypointCerts - ); - - // Get authnContext (SPID level) and forceAuthn from request payload - const maybeAuthOptions = getAuthSalmOptions(req, decodedResponse); - if (isNone(maybeAuthOptions)) { - logger.debug( - "SPID cannot find authnContext in response %s", - decodedResponse - ); - } - const authOptions = maybeAuthOptions.getOrElse({}); - const options = { - ...maybeSpidStrategyOptions.value.sp, - ...authOptions, - ...entrypointCerts - }; - return done(null, options); - } catch (e) { - return done(e); - } -}; - -// -// Service Provider Metadata -// - -const getSpidAttributesMetadata = ( - serviceProviderConfig: IServiceProviderConfig -) => { - return serviceProviderConfig.requiredAttributes - ? serviceProviderConfig.requiredAttributes.attributes.map(item => ({ - $: { - FriendlyName: SPID_USER_ATTRIBUTES[item] || "", - Name: item, - NameFormat: "urn:oasis:names:tc:SAML:2.0:attrname-format:basic" - } - })) - : []; -}; - -const getSpidOrganizationMetadata = ( - serviceProviderConfig: IServiceProviderConfig -) => { - return serviceProviderConfig.organization - ? { - Organization: { - OrganizationName: { - $: { "xml:lang": "it" }, - _: serviceProviderConfig.organization.name - }, - // must appear after organization name - // tslint:disable-next-line: object-literal-sort-keys - OrganizationDisplayName: { - $: { "xml:lang": "it" }, - _: serviceProviderConfig.organization.displayName - }, - OrganizationURL: { - $: { "xml:lang": "it" }, - _: serviceProviderConfig.organization.URL - } - } - } - : {}; -}; - -const getSpidContactPersonMetadata = ( - serviceProviderConfig: IServiceProviderConfig -) => { - return serviceProviderConfig.contacts - ? serviceProviderConfig.contacts - .map(item => { - const contact = { - $: { - contactType: item.contactType - }, - Company: item.company, - EmailAddress: item.email, - ...(item.phone ? { TelephoneNumber: item.phone } : {}) - }; - if (item.contactType === ContactType.OTHER) { - return { - Extensions: { - ...(item.extensions.IPACode - ? { "spid:IPACode": item.extensions.IPACode } - : {}), - ...(item.extensions.VATNumber - ? { "spid:VATNumber": item.extensions.VATNumber } - : {}), - ...(item.extensions?.FiscalCode - ? { "spid:FiscalCode": item.extensions.FiscalCode } - : {}), - ...(item.entityType === EntityType.AGGREGATOR - ? { [`spid:${item.extensions.aggregatorType}`]: {} } - : {}) - }, - ...contact, - $: { - ...contact.$, - "spid:entityType": item.entityType - } - }; - } - return contact; - }) - // Contacts array is limited to 3 elements - .slice(0, 3) - : {}; -}; - -const getKeyInfoForMetadata = (publicCert: string, privateKey: string) => ({ - file: privateKey, - getKey: () => Buffer.from(privateKey), - getKeyInfo: () => - `${publicCert}` -}); - -export const getMetadataTamperer = ( - xmlBuilder: Builder, - serviceProviderConfig: IServiceProviderConfig, - samlConfig: SamlConfig -) => (generateXml: string): TaskEither => { - return tryCatch(() => parseStringPromise(generateXml), toError) - .chain(o => - tryCatch(async () => { - // it is safe to mutate object here since it is - // deserialized and serialized locally in this method - const sso = o.EntityDescriptor.SPSSODescriptor[0]; - // tslint:disable-next-line: no-object-mutation - sso.$ = { - ...sso.$, - AuthnRequestsSigned: true, - WantAssertionsSigned: true - }; - // tslint:disable-next-line: no-object-mutation - sso.AssertionConsumerService[0].$.index = 0; - // tslint:disable-next-line: no-object-mutation - sso.AttributeConsumingService = { - $: { - index: samlConfig.attributeConsumingServiceIndex - }, - ServiceName: { - $: { - "xml:lang": "it" - }, - _: serviceProviderConfig.requiredAttributes.name - }, - // must appear after attributes - // tslint:disable-next-line: object-literal-sort-keys - RequestedAttribute: getSpidAttributesMetadata(serviceProviderConfig) - }; - // tslint:disable-next-line: no-object-mutation - o.EntityDescriptor = { - ...o.EntityDescriptor, - ...getSpidOrganizationMetadata(serviceProviderConfig) - }; - if (serviceProviderConfig.contacts) { - // tslint:disable-next-line: no-object-mutation - o.EntityDescriptor = { - ...o.EntityDescriptor, - $: { - ...o.EntityDescriptor.$, - "xmlns:spid": SAML_NAMESPACE.SPID - }, - // tslint:disable-next-line: no-inferred-empty-object-type - ContactPerson: getSpidContactPersonMetadata(serviceProviderConfig) - }; - } - return o; - }, toError) - ) - .chain(_ => tryCatch(async () => xmlBuilder.buildObject(_), toError)) - .chain(xml => - tryCatch(async () => { - // 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); - // tslint:disable-next-line: no-object-mutation - sig.keyInfoProvider = getKeyInfoForMetadata( - publicCert, - samlConfig.privateCert - ); - // tslint:disable-next-line: no-object-mutation - sig.signatureAlgorithm = - "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"; - // tslint:disable-next-line: no-object-mutation - 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 - location: { reference: "", action: "prepend" } - }); - return sig.getSignedXml(); - }, toError) - ); -}; - -// -// Authorize request -// - -export const getAuthorizeRequestTamperer = ( - xmlBuilder: Builder, - _: IServiceProviderConfig, - samlConfig: SamlConfig -) => (generateXml: string): TaskEither => { - return tryCatch(() => parseStringPromise(generateXml), toError) - .chain(o => - tryCatch(async () => { - // it is safe to mutate object here since it is - // deserialized and serialized locally in this method - // tslint:disable-next-line: no-any - const authnRequest = o["samlp:AuthnRequest"]; - // tslint:disable-next-line: no-object-mutation no-delete - delete authnRequest["samlp:NameIDPolicy"][0].$.AllowCreate; - // tslint:disable-next-line: no-object-mutation - authnRequest["saml:Issuer"][0].$.NameQualifier = samlConfig.issuer; - // tslint:disable-next-line: no-object-mutation - authnRequest["saml:Issuer"][0].$.Format = ISSUER_FORMAT; - return o; - }, toError) - ) - .chain(obj => tryCatch(async () => xmlBuilder.buildObject(obj), toError)); -}; - -// -// Validate response -// - -const utcStringToDate = (value: string, tag: string): Either => - UTCISODateFromString.decode(value).mapLeft( - () => new Error(`${tag} must be an UTCISO format date string`) - ); - -const validateIssuer = ( - fatherElement: Element, - idpIssuer: string -): Either => - fromOption(new Error("Issuer element must be present"))( - fromNullable( - fatherElement - .getElementsByTagNameNS(SAML_NAMESPACE.ASSERTION, "Issuer") - .item(0) - ) - ).chain(Issuer => - NonEmptyString.decode(Issuer.textContent?.trim()) - .mapLeft(() => new Error("Issuer element must be not empty")) - .chain( - fromPredicate( - IssuerTextContent => { - return IssuerTextContent === idpIssuer; - }, - () => new Error(`Invalid Issuer. Expected value is ${idpIssuer}`) - ) - ) - .map(() => Issuer) - ); - -const mainAttributeValidation = ( - requestOrAssertion: Element, - acceptedClockSkewMs: number = 0 -): Either => { - return NonEmptyString.decode(requestOrAssertion.getAttribute("ID")) - .mapLeft(() => new Error("Assertion must contain a non empty ID")) - .map(() => requestOrAssertion.getAttribute("Version")) - .chain( - fromPredicate( - Version => Version === "2.0", - () => new Error("Version version must be 2.0") - ) - ) - .chain(() => - fromOption(new Error("Assertion must contain a non empty IssueInstant"))( - fromNullable(requestOrAssertion.getAttribute("IssueInstant")) - ) - ) - .chain(IssueInstant => utcStringToDate(IssueInstant, "IssueInstant")) - .chain( - fromPredicate( - _ => - _.getTime() < - (acceptedClockSkewMs === -1 - ? Infinity - : Date.now() + acceptedClockSkewMs), - () => new Error("IssueInstant must be in the past") - ) - ); -}; - -const isEmptyNode = (element: Element): boolean => { - if (element.childNodes.length > 1) { - return false; - } else if ( - element.firstChild && - element.firstChild.nodeType === element.ELEMENT_NODE - ) { - return false; - } else if ( - element.textContent && - element.textContent.replace(/[\r\n\ ]+/g, "") !== "" - ) { - return false; - } - return true; -}; - -const isOverflowNumberOf = ( - elemArray: readonly Element[], - maxNumberOfChildren: number -): boolean => - elemArray.filter(e => e.nodeType === e.ELEMENT_NODE).length > - maxNumberOfChildren; - -export const TransformError = t.interface({ - idpIssuer: t.string, - message: t.string, - numberOfTransforms: t.number -}); -export type TransformError = t.TypeOf; - -const transformsValidation = ( - targetElement: Element, - idpIssuer: string -): Either => { - return fromPredicateOption( - (elements: readonly Element[]) => elements.length > 0 - )( - Array.from( - targetElement.getElementsByTagNameNS(SAML_NAMESPACE.XMLDSIG, "Transform") - ) - ).foldL( - () => right(targetElement), - transformElements => - fromPredicate( - (_: readonly Element[]) => !isOverflowNumberOf(_, 4), - _ => - TransformError.encode({ - idpIssuer, - message: "Transform element cannot occurs more than 4 times", - numberOfTransforms: _.length - }) - )(transformElements).map(() => targetElement) - ); -}; - -const notOnOrAfterValidation = ( - element: Element, - acceptedClockSkewMs: number = 0 -) => { - return NonEmptyString.decode(element.getAttribute("NotOnOrAfter")) - .mapLeft( - () => new Error("NotOnOrAfter attribute must be a non empty string") - ) - .chain(NotOnOrAfter => utcStringToDate(NotOnOrAfter, "NotOnOrAfter")) - .chain( - fromPredicate( - NotOnOrAfter => - NotOnOrAfter.getTime() > - (acceptedClockSkewMs === -1 - ? -Infinity - : Date.now() - acceptedClockSkewMs), - () => new Error("NotOnOrAfter must be in the future") - ) - ); + assertionValidation, + ISSUER_FORMAT, + notSignedWithHmacPredicate, + TransformError, + transformsValidation, + validateIssuer +} from "./samlUtils"; +import { + getAuthorizeRequestTamperer, + getErrorCodeFromResponse, + getIDFromRequest, + getMetadataTamperer, + getSamlIssuer, + getSamlOptions, + getXmlFromSamlResponse, + isEmptyNode, + logSamlCertExpiration, + mainAttributeValidation, + SAML_NAMESPACE +} from "./samlUtils"; + +export { + SAML_NAMESPACE, + logSamlCertExpiration, + getIDFromRequest, + getMetadataTamperer, + getXmlFromSamlResponse, + getSamlOptions, + getErrorCodeFromResponse, + getAuthorizeRequestTamperer, + getSamlIssuer, + TransformError }; -const assertionValidation = ( - Assertion: Element, - samlConfig: SamlConfig, - InResponseTo: string, - requestAuthnContextClassRef: string - // tslint:disable-next-line: no-big-function -): Either> => { - const acceptedClockSkewMs = samlConfig.acceptedClockSkewMs || 0; - return ( - fromOption(new Error("Assertion must be signed"))( - fromNullable( - Assertion.getElementsByTagNameNS( - SAML_NAMESPACE.XMLDSIG, - "Signature" - ).item(0) - ) - ) - .chain(notSignedWithHmacPredicate) - // tslint:disable-next-line: no-big-function - .chain(() => - fromOption(new Error("Subject element must be present"))( - fromNullable( - Assertion.getElementsByTagNameNS( - SAML_NAMESPACE.ASSERTION, - "Subject" - ).item(0) - ) - ) - .chain( - fromPredicate( - not(isEmptyNode), - () => new Error("Subject element must be not empty") - ) - ) - .chain(Subject => - fromOption(new Error("NameID element must be present"))( - fromNullable( - Subject.getElementsByTagNameNS( - SAML_NAMESPACE.ASSERTION, - "NameID" - ).item(0) - ) - ) - .chain( - fromPredicate( - not(isEmptyNode), - () => new Error("NameID element must be not empty") - ) - ) - .chain(NameID => - NonEmptyString.decode(NameID.getAttribute("Format")) - .mapLeft( - () => - new Error( - "Format attribute of NameID element must be a non empty string" - ) - ) - .chain( - fromPredicate( - Format => - Format === - "urn:oasis:names:tc:SAML:2.0:nameid-format:transient", - () => - new Error( - "Format attribute of NameID element is invalid" - ) - ) - ) - .map(() => NameID) - ) - .chain(NameID => - NonEmptyString.decode( - NameID.getAttribute("NameQualifier") - ).mapLeft( - () => - new Error( - "NameQualifier attribute of NameID element must be a non empty string" - ) - ) - ) - .map(() => Subject) - ) - .chain(Subject => - fromOption( - new Error("SubjectConfirmation element must be present") - )( - fromNullable( - Subject.getElementsByTagNameNS( - SAML_NAMESPACE.ASSERTION, - "SubjectConfirmation" - ).item(0) - ) - ) - .chain( - fromPredicate( - not(isEmptyNode), - () => - new Error("SubjectConfirmation element must be not empty") - ) - ) - .chain(SubjectConfirmation => - NonEmptyString.decode( - SubjectConfirmation.getAttribute("Method") - ) - .mapLeft( - () => - new Error( - "Method attribute of SubjectConfirmation element must be a non empty string" - ) - ) - .chain( - fromPredicate( - Method => - Method === "urn:oasis:names:tc:SAML:2.0:cm:bearer", - () => - new Error( - "Method attribute of SubjectConfirmation element is invalid" - ) - ) - ) - .map(() => SubjectConfirmation) - ) - .chain(SubjectConfirmation => - fromOption( - new Error("SubjectConfirmationData element must be provided") - )( - fromNullable( - SubjectConfirmation.getElementsByTagNameNS( - SAML_NAMESPACE.ASSERTION, - "SubjectConfirmationData" - ).item(0) - ) - ) - .chain(SubjectConfirmationData => - NonEmptyString.decode( - SubjectConfirmationData.getAttribute("Recipient") - ) - .mapLeft( - () => - new Error( - "Recipient attribute of SubjectConfirmationData element must be a non empty string" - ) - ) - .chain( - fromPredicate( - Recipient => Recipient === samlConfig.callbackUrl, - () => - new Error( - "Recipient attribute of SubjectConfirmationData element must be equal to AssertionConsumerServiceURL" - ) - ) - ) - .map(() => SubjectConfirmationData) - ) - .chain(SubjectConfirmationData => - notOnOrAfterValidation( - SubjectConfirmationData, - acceptedClockSkewMs - ).map(() => SubjectConfirmationData) - ) - .chain(SubjectConfirmationData => - NonEmptyString.decode( - SubjectConfirmationData.getAttribute("InResponseTo") - ) - .mapLeft( - () => - new Error( - "InResponseTo attribute of SubjectConfirmationData element must be a non empty string" - ) - ) - .chain( - fromPredicate( - inResponseTo => inResponseTo === InResponseTo, - () => - new Error( - "InResponseTo attribute of SubjectConfirmationData element must be equal to Response InResponseTo" - ) - ) - ) - ) - ) - ) - .chain(() => - fromOption(new Error("Conditions element must be provided"))( - fromNullable( - Assertion.getElementsByTagNameNS( - SAML_NAMESPACE.ASSERTION, - "Conditions" - ).item(0) - ) - ) - .chain( - fromPredicate( - not(isEmptyNode), - () => new Error("Conditions element must be provided") - ) - ) - .chain(Conditions => - notOnOrAfterValidation(Conditions, acceptedClockSkewMs).map( - () => Conditions - ) - ) - .chain(Conditions => - NonEmptyString.decode(Conditions.getAttribute("NotBefore")) - .mapLeft( - () => new Error("NotBefore must be a non empty string") - ) - .chain(NotBefore => utcStringToDate(NotBefore, "NotBefore")) - .chain( - fromPredicate( - NotBefore => - NotBefore.getTime() <= - (acceptedClockSkewMs === -1 - ? Infinity - : Date.now() + acceptedClockSkewMs), - () => new Error("NotBefore must be in the past") - ) - ) - .map(() => Conditions) - ) - .chain(Conditions => - fromOption( - new Error( - "AudienceRestriction element must be present and not empty" - ) - )( - fromNullable( - Conditions.getElementsByTagNameNS( - SAML_NAMESPACE.ASSERTION, - "AudienceRestriction" - ).item(0) - ) - ) - .chain( - fromPredicate( - not(isEmptyNode), - () => - new Error( - "AudienceRestriction element must be present and not empty" - ) - ) - ) - .chain(AudienceRestriction => - fromOption(new Error("Audience missing"))( - fromNullable( - AudienceRestriction.getElementsByTagNameNS( - SAML_NAMESPACE.ASSERTION, - "Audience" - ).item(0) - ) - ).chain( - fromPredicate( - Audience => - Audience.textContent?.trim() === samlConfig.issuer, - () => new Error("Audience invalid") - ) - ) - ) - ) - .chain(() => - fromOption(new Error("Missing AuthnStatement"))( - fromNullable( - Assertion.getElementsByTagNameNS( - SAML_NAMESPACE.ASSERTION, - "AuthnStatement" - ).item(0) - ) - ) - .chain( - fromPredicate( - not(isEmptyNode), - () => new Error("Empty AuthnStatement") - ) - ) - .chain(AuthnStatement => - fromOption(new Error("Missing AuthnContext"))( - fromNullable( - AuthnStatement.getElementsByTagNameNS( - SAML_NAMESPACE.ASSERTION, - "AuthnContext" - ).item(0) - ) - ) - .chain( - fromPredicate( - not(isEmptyNode), - () => new Error("Empty AuthnContext") - ) - ) - .chain(AuthnContext => - fromOption(new Error("Missing AuthnContextClassRef"))( - fromNullable( - AuthnContext.getElementsByTagNameNS( - SAML_NAMESPACE.ASSERTION, - "AuthnContextClassRef" - ).item(0) - ) - ) - .chain( - fromPredicate( - not(isEmptyNode), - () => new Error("Empty AuthnContextClassRef") - ) - ) - .chain( - fromPredicate( - AuthnContextClassRef => - AuthnContextClassRef.textContent?.trim() === - SPID_LEVELS.SpidL1 || - AuthnContextClassRef.textContent?.trim() === - SPID_LEVELS.SpidL2 || - AuthnContextClassRef.textContent?.trim() === - SPID_LEVELS.SpidL3, - () => - new Error("Invalid AuthnContextClassRef value") - ) - ) - .map(AuthnContextClassRef => - AuthnContextClassRef.textContent?.trim() - ) - .chain( - fromPredicate( - AuthnContextClassRef => { - return requestAuthnContextClassRef === - SPID_LEVELS.SpidL2 - ? AuthnContextClassRef === - SPID_LEVELS.SpidL2 || - AuthnContextClassRef === - SPID_LEVELS.SpidL3 - : requestAuthnContextClassRef === - SPID_LEVELS.SpidL1 - ? AuthnContextClassRef === - SPID_LEVELS.SpidL1 || - AuthnContextClassRef === - SPID_LEVELS.SpidL2 || - AuthnContextClassRef === SPID_LEVELS.SpidL3 - : requestAuthnContextClassRef === - AuthnContextClassRef; - }, - () => - new Error( - "AuthnContextClassRef value not expected" - ) - ) - ) - ) - ) - ) - .chain(() => - fromOption( - new Error("AttributeStatement must contains Attributes") - )( - fromNullable( - Assertion.getElementsByTagNameNS( - SAML_NAMESPACE.ASSERTION, - "AttributeStatement" - ).item(0) - ).map(AttributeStatement => - AttributeStatement.getElementsByTagNameNS( - SAML_NAMESPACE.ASSERTION, - "Attribute" - ) - ) - ).chain( - fromPredicate( - Attributes => - Attributes.length > 0 && - !Array.from(Attributes).some(isEmptyNode), - () => - new Error( - "Attribute element must be present and not empty" - ) - ) - ) - ) - ) - ) - ); -}; +export type SamlAttributeT = keyof typeof SPID_USER_ATTRIBUTES; export const getPreValidateResponse = ( strictValidationOptions?: StrictResponseValidationOptions, @@ -1108,7 +72,7 @@ export const getPreValidateResponse = ( ) => { const maybeDoc = getXmlFromSamlResponse(body); - if (isNone(maybeDoc)) { + if (O.isNone(maybeDoc)) { throw new Error("Empty SAML response"); } const doc = maybeDoc.value; @@ -1118,94 +82,151 @@ export const getPreValidateResponse = ( "Response" ); - const hasStrictValidation = fromNullable(strictValidationOptions) - .chain(_ => getSamlIssuer(doc).mapNullable(issuer => _[issuer])) - .getOrElse(false); - - fromEitherToTaskEither( - fromPredicate>( - _ => _.length < 2, - _ => new Error("SAML Response must have only one Response element") - )(responsesCollection) - .map(_ => _.item(0)) - .chain(Response => - fromOption(new Error("Missing Reponse element inside SAML Response"))( - fromNullable(Response) - ) + const hasStrictValidation = pipe( + O.fromNullable(strictValidationOptions), + O.chain(_ => + pipe( + getSamlIssuer(doc), + O.chainNullableK(issuer => _[issuer]) ) - .chain(Response => - mainAttributeValidation(Response, samlConfig.acceptedClockSkewMs).map( - IssueInstant => ({ + ), + O.getOrElse(() => false) + ); + + interface IBaseOutput { + InResponseTo: NonEmptyString; + Assertion: Element; + IssueInstant: Date; + Response: Element; + AssertionIssueInstant: Date; + } + + interface ISamlCacheType { + RequestXML: string; + createdAt: Date; + idpIssuer: string; + } + + type IRequestAndResponseStep = IBaseOutput & { + SAMLRequestCache: ISamlCacheType; + }; + + type ISAMLRequest = IRequestAndResponseStep & { Request: Document }; + + type IIssueInstant = ISAMLRequest & { + RequestIssueInstant: Date; + RequestAuthnRequest: Element; + }; + + type IIssueInstantWithAuthnContextCR = IIssueInstant & { + RequestAuthnContextClassRef: NonEmptyString; + }; + + interface ITransformValidation { + idpIssuer: string; + message: string; + numberOfTransforms: number; + } + + const responseElementValidationStep: TaskEither< + Error, + IBaseOutput + > = TE.fromEither( + pipe( + responsesCollection, + E.fromPredicate( + _ => _.length < 2, + _ => new Error("SAML Response must have only one Response element") + ), + E.map(_ => _.item(0)), + E.chain(Response => + E.fromOption( + () => new Error("Missing Reponse element inside SAML Response") + )(O.fromNullable(Response)) + ), + E.chain(Response => + pipe( + mainAttributeValidation(Response, samlConfig.acceptedClockSkewMs), + E.map(IssueInstant => ({ IssueInstant, Response - }) + })) ) - ) - .chain(_ => - NonEmptyString.decode(_.Response.getAttribute("Destination")) - .mapLeft( + ), + E.chain(_ => + pipe( + NonEmptyString.decode(_.Response.getAttribute("Destination")), + E.mapLeft( () => new Error("Response must contain a non empty Destination") - ) - .chain( - fromPredicate( + ), + E.chain( + E.fromPredicate( Destination => Destination === samlConfig.callbackUrl, () => new Error( "Destination must be equal to AssertionConsumerServiceURL" ) ) - ) - .map(() => _) - ) - .chain(_ => - fromOption(new Error("Status element must be present"))( - fromNullable( - _.Response.getElementsByTagNameNS( - SAML_NAMESPACE.PROTOCOL, - "Status" - ).item(0) - ) + ), + E.map(() => _) ) - .mapLeft( + ), + E.chain(_ => + pipe( + E.fromOption(() => new Error("Status element must be present"))( + O.fromNullable( + _.Response.getElementsByTagNameNS( + SAML_NAMESPACE.PROTOCOL, + "Status" + ).item(0) + ) + ), + E.mapLeft( () => new Error("Status element must be present into Response") - ) - .chain( - fromPredicate( + ), + E.chain( + E.fromPredicate( not(isEmptyNode), () => new Error("Status element must be present not empty") ) - ) - .chain(Status => - fromOption(new Error("StatusCode element must be present"))( - fromNullable( + ), + E.chain(Status => + E.fromOption(() => new Error("StatusCode element must be present"))( + O.fromNullable( Status.getElementsByTagNameNS( SAML_NAMESPACE.PROTOCOL, "StatusCode" ).item(0) ) ) - ) - .chain(StatusCode => - fromOption(new Error("StatusCode must contain a non empty Value"))( - fromNullable(StatusCode.getAttribute("Value")) - ) - .chain(statusCode => { + ), + E.chain(StatusCode => + 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) - return fromPredicate( - Value => - Value.toLowerCase() === - "urn:oasis:names:tc:SAML:2.0:status:Success".toLowerCase(), - () => - new Error( - `Value attribute of StatusCode is invalid: ${statusCode}` - ) - )(statusCode); - }) - .map(() => _) + return 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(() => _) + ) ) - ) - .chain( - fromPredicate( + ) + ), + E.chain( + E.fromPredicate( predicate => predicate.Response.getElementsByTagNameNS( SAML_NAMESPACE.ASSERTION, @@ -1213,10 +234,15 @@ export const getPreValidateResponse = ( ).length === 0, _ => new Error("EncryptedAssertion element is forbidden") ) - ) - .chain(p => notSignedWithHmacPredicate(p.Response).map(_ => p)) - .chain( - fromPredicate( + ), + E.chain(p => + pipe( + notSignedWithHmacPredicate(p.Response), + E.map(_ => p) + ) + ), + E.chain( + E.fromPredicate( predicate => predicate.Response.getElementsByTagNameNS( SAML_NAMESPACE.ASSERTION, @@ -1224,289 +250,381 @@ export const getPreValidateResponse = ( ).length < 2, _ => new Error("SAML Response must have only one Assertion element") ) - ) - .chain(_ => - fromOption(new Error("Assertion element must be present"))( - fromNullable( - _.Response.getElementsByTagNameNS( - SAML_NAMESPACE.ASSERTION, - "Assertion" - ).item(0) - ) - ).map(assertion => ({ ..._, Assertion: assertion })) - ) - .chain(_ => - NonEmptyString.decode(_.Response.getAttribute("InResponseTo")) - .mapLeft( + ), + E.chain(_ => + pipe( + E.fromOption(() => new Error("Assertion element must be present"))( + O.fromNullable( + _.Response.getElementsByTagNameNS( + SAML_NAMESPACE.ASSERTION, + "Assertion" + ).item(0) + ) + ), + E.map(assertion => ({ ..._, Assertion: assertion })) + ) + ), + E.chain(_ => + pipe( + NonEmptyString.decode(_.Response.getAttribute("InResponseTo")), + E.mapLeft( () => new Error("InResponseTo must contain a non empty string") - ) - .map(inResponseTo => ({ ..._, InResponseTo: inResponseTo })) - ) - .chain(_ => - mainAttributeValidation( - _.Assertion, - samlConfig.acceptedClockSkewMs - ).map(IssueInstant => ({ - AssertionIssueInstant: IssueInstant, - ..._ - })) - ) - ) - .chain(_ => - extendedCacheProvider - .get(_.InResponseTo) - .map(SAMLRequestCache => ({ ..._, SAMLRequestCache })) - .map( - __ => ( - doneCb && - optionTryCatch(() => - doneCb( - __.SAMLRequestCache.RequestXML, - new XMLSerializer().serializeToString(doc) - ) - ), - __ - ) + ), + E.map(inResponseTo => ({ ..._, InResponseTo: inResponseTo })) + ) + ), + E.chain(_ => + pipe( + mainAttributeValidation(_.Assertion, samlConfig.acceptedClockSkewMs), + E.map(IssueInstant => ({ + AssertionIssueInstant: IssueInstant, + ..._ + })) ) + ) ) - .chain(_ => - fromEitherToTaskEither( - fromOption( - new Error("An error occurs parsing the cached SAML Request") + ); + + const returnRequestAndResponseStep = ( + _: IBaseOutput + ): TaskEither => + pipe( + extendedCacheProvider.get(_.InResponseTo), + TE.map(SAMLRequestCache => ({ ..._, SAMLRequestCache })), + TE.map( + __ => ( + doneCb && + O.tryCatch(() => + doneCb( + __.SAMLRequestCache.RequestXML, + new XMLSerializer().serializeToString(doc) + ) + ), + __ + ) + ) + ); + + const parseSAMLRequestStep = ( + _: IRequestAndResponseStep + ): TaskEither => + pipe( + TE.fromEither( + E.fromOption( + () => new Error("An error occurs parsing the cached SAML Request") )( - optionTryCatch(() => + O.tryCatch(() => new DOMParser().parseFromString(_.SAMLRequestCache.RequestXML) ) ) - ).map(Request => ({ ..._, Request })) - ) - .chain(_ => - fromEitherToTaskEither( - fromOption(new Error("Missing AuthnRequest into Cached Request"))( - fromNullable( + ), + TE.map(Request => ({ ..._, Request })) + ); + + const getIssueInstantFromRequestStep = ( + _: ISAMLRequest + ): TaskEither => + pipe( + TE.fromEither( + E.fromOption( + () => new Error("Missing AuthnRequest into Cached Request") + )( + O.fromNullable( _.Request.getElementsByTagNameNS( SAML_NAMESPACE.PROTOCOL, "AuthnRequest" ).item(0) ) ) - ) - .map(RequestAuthnRequest => ({ ..._, RequestAuthnRequest })) - .chain(__ => - fromEitherToTaskEither( - UTCISODateFromString.decode( - __.RequestAuthnRequest.getAttribute("IssueInstant") - ).mapLeft( - () => - new Error( - "IssueInstant into the Request must be a valid UTC string" - ) + ), + TE.map(RequestAuthnRequest => ({ ..._, RequestAuthnRequest })), + TE.chain(__ => + pipe( + TE.fromEither( + pipe( + UTCISODateFromString.decode( + __.RequestAuthnRequest.getAttribute("IssueInstant") + ), + E.mapLeft( + () => + new Error( + "IssueInstant into the Request must be a valid UTC string" + ) + ) ) - ).map(RequestIssueInstant => ({ ...__, RequestIssueInstant })) + ), + TE.map(RequestIssueInstant => ({ ...__, RequestIssueInstant })) ) - ) - .chain(_ => - fromEitherToTaskEither( - fromPredicate( - _1 => _1.getTime() <= _.IssueInstant.getTime(), - () => - new Error("Request IssueInstant must after Request IssueInstant") - )(_.RequestIssueInstant) - ).map(() => _) - ) - .chain(_ => - fromEitherToTaskEither( - fromPredicate( - _1 => _1.getTime() <= _.AssertionIssueInstant.getTime(), + ) + ); + + const issueInstantValidationStep = ( + _: IIssueInstant + ): TaskEither => + pipe( + TE.fromEither( + pipe( + _.RequestIssueInstant, + E.fromPredicate( + _1 => _1.getTime() <= _.IssueInstant.getTime(), + () => + new Error("Response IssueInstant must after Request IssueInstant") + ) + ) + ), + TE.map(() => _) + ); + + const assertionIssueInstantValidationStep = ( + _: IIssueInstant + ): TaskEither => + pipe( + TE.fromEither( + pipe( + _.RequestIssueInstant, + E.fromPredicate( + _1 => _1.getTime() <= _.AssertionIssueInstant.getTime(), + () => + new Error( + "Assertion IssueInstant must after Request IssueInstant" + ) + ) + ) + ), + TE.map(() => _) + ); + + const authnContextClassRefValidationStep = ( + _: IIssueInstant + ): TaskEither => + TE.fromEither( + pipe( + E.fromOption( () => - new Error("Assertion IssueInstant must after Request IssueInstant") - )(_.RequestIssueInstant) - ).map(() => _) - ) - .chain(_ => - fromEitherToTaskEither( - fromOption( - new Error("Missing AuthnContextClassRef inside cached SAML Response") + new Error( + "Missing AuthnContextClassRef inside cached SAML Response" + ) )( - fromNullable( + O.fromNullable( _.RequestAuthnRequest.getElementsByTagNameNS( SAML_NAMESPACE.ASSERTION, "AuthnContextClassRef" ).item(0) ) - ) - .chain( - fromPredicate( - not(isEmptyNode), - () => new Error("Subject element must be not empty") - ) + ), + E.chain( + E.fromPredicate( + PR.not(isEmptyNode), + () => new Error("Subject element must be not empty") ) - .chain(RequestAuthnContextClassRef => + ), + E.chain(RequestAuthnContextClassRef => + pipe( NonEmptyString.decode( RequestAuthnContextClassRef.textContent?.trim() - ).mapLeft( + ), + E.mapLeft( () => new Error( "AuthnContextClassRef inside cached Request must be a non empty string" ) ) ) - .chain( - fromPredicate( - reqAuthnContextClassRef => - reqAuthnContextClassRef === SPID_LEVELS.SpidL1 || - reqAuthnContextClassRef === SPID_LEVELS.SpidL2 || - reqAuthnContextClassRef === SPID_LEVELS.SpidL3, - () => new Error("Unexpected Request authnContextClassRef value") - ) + ), + E.chain( + E.fromPredicate( + reqAuthnContextClassRef => + reqAuthnContextClassRef === SPID_LEVELS.SpidL1 || + reqAuthnContextClassRef === SPID_LEVELS.SpidL2 || + reqAuthnContextClassRef === SPID_LEVELS.SpidL3, + () => new Error("Unexpected Request authnContextClassRef value") ) - .map(rACCR => ({ - ..._, - RequestAuthnContextClassRef: rACCR - })) + ), + E.map(rACCR => ({ + ..._, + RequestAuthnContextClassRef: rACCR + })) ) - ) - .chain(_ => - fromEitherToTaskEither( + ); + + const attributesValidationStep = ( + _: IIssueInstantWithAuthnContextCR + ): TaskEither => + pipe( + TE.fromEither( assertionValidation( _.Assertion, samlConfig, _.InResponseTo, _.RequestAuthnContextClassRef ) - ) - .chain(Attributes => { - if (!hasStrictValidation) { - // Skip Attribute validation if IDP has non-strict validation option - return fromEitherToTaskEither( - right>(Attributes) - ); - } - const missingAttributes = difference(setoidString)( - // tslint:disable-next-line: no-any - (samlConfig as any).attributes?.attributes?.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 fromEitherToTaskEither( - fromPredicate>( - () => missingAttributes.length === 0, - () => - new Error( - `Missing required Attributes: ${missingAttributes.toString()}` - ) - )(Attributes) - ); - }) - .map(() => _) - ) - .chain(_ => - fromEitherToTaskEither( - validateIssuer(_.Response, _.SAMLRequestCache.idpIssuer).chain(Issuer => - fromOption("Format missing")( - fromNullable(Issuer.getAttribute("Format")) - ).fold( - () => right(_), - _1 => - fromPredicate( - FormatValue => !FormatValue || FormatValue === ISSUER_FORMAT, - () => new Error("Format attribute of Issuer element is invalid") - )(_1) + ), + TE.chain(Attributes => { + if (!hasStrictValidation) { + // Skip Attribute validation if IDP has non-strict validation option + return TE.right(Attributes); + } + const missingAttributes = difference(Eq)( + // tslint:disable-next-line: no-any + (samlConfig as any).attributes?.attributes?.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 = ( + _: IIssueInstantWithAuthnContextCR + ): TaskEither => + pipe( + TE.fromEither( + pipe( + validateIssuer(_.Response, _.SAMLRequestCache.idpIssuer), + E.chainW(Issuer => + pipe( + E.fromOption(() => "Format missing")( + O.fromNullable(Issuer.getAttribute("Format")) + ), + E.mapLeft(() => E.right(_)), + E.map(_1 => + E.fromPredicate( + FormatValue => !FormatValue || FormatValue === ISSUER_FORMAT, + () => + new Error("Format attribute of Issuer element is invalid") + )(_1) + ), + E.map(() => E.right(_)), + E.toUnion + ) ) ) - ).map(() => _) - ) - .chain(_ => - fromEitherToTaskEither( - validateIssuer(_.Assertion, _.SAMLRequestCache.idpIssuer).chain( - Issuer => - NonEmptyString.decode(Issuer.getAttribute("Format")) - .mapLeft( + ), + TE.map(() => _) + ); + + const assertionIssuerValidationStep = ( + _: IIssueInstantWithAuthnContextCR + ): TaskEither => + pipe( + TE.fromEither( + pipe( + validateIssuer(_.Assertion, _.SAMLRequestCache.idpIssuer), + E.chain(Issuer => + pipe( + NonEmptyString.decode(Issuer.getAttribute("Format")), + E.mapLeft( () => new Error( "Format attribute of Issuer element must be a non empty string into Assertion" ) - ) - .chain( - fromPredicate( + ), + E.chain( + E.fromPredicate( Format => Format === ISSUER_FORMAT, () => new Error("Format attribute of Issuer element is invalid") ) - ) - .fold( + ), + E.fold( err => // Skip Issuer Format validation if IDP has non-strict validation option - !hasStrictValidation ? right(_) : left(err), - _1 => right(_) + !hasStrictValidation ? E.right(_) : E.left(err), + _1 => E.right(_) ) + ) + ) ) - ).map(() => _) - ) - .mapLeft(identity) - // check for Transform over SAML Response - .chain(_ => - fromEitherToTaskEither( + ), + TE.map(() => _) + ); + + const transformValidationStep = ( + _: IIssueInstantWithAuthnContextCR + ): TaskEither => + pipe( + TE.fromEither( transformsValidation(_.Response, _.SAMLRequestCache.idpIssuer) - ).map(() => _) - ) - .bimap( - error => { - if (eventHandler) { - TransformError.is(error) - ? eventHandler({ - data: { - idpIssuer: error.idpIssuer, - message: error.message, - numberOfTransforms: String(error.numberOfTransforms) - }, - name: "spid.error.transformOccurenceOverflow", - type: "ERROR" - }) - : eventHandler({ - data: { - message: error.message - }, - name: "spid.error.generic", - type: "ERROR" - }); - } - return callback(toError(error.message)); - }, - _ => { - // 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(SAML_NAMESPACE.XMLDSIG, "Signature") - .length - - _.Assertion.getElementsByTagNameNS( - 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({ + ), + TE.map(() => _) + ); + + const validationFailure = (error: Error | ITransformValidation): void => { + if (eventHandler) { + TransformError.is(error) + ? eventHandler({ data: { - idpIssuer: _.SAMLRequestCache.idpIssuer, - message: "Missing Request signature" + idpIssuer: error.idpIssuer, + message: error.message, + numberOfTransforms: String(error.numberOfTransforms) }, - name: "spid.error.signature", - type: "INFO" + name: "spid.error.transformOccurenceOverflow", + type: "ERROR" + }) + : eventHandler({ + data: { + message: error.message + }, + name: "spid.error.generic", + type: "ERROR" }); - } - return callback(null, true, _.InResponseTo); - } - ) - .run() - .catch(callback); + } + return callback(E.toError(error.message)); + }; + + const validationSuccess = (_: IIssueInstantWithAuthnContextCR): void => { + // 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(SAML_NAMESPACE.XMLDSIG, "Signature") + .length - + _.Assertion.getElementsByTagNameNS(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" + }, + name: "spid.error.signature", + type: "INFO" + }); + } + return callback(null, true, _.InResponseTo); + }; + + return pipe( + responseElementValidationStep, + TE.chain(returnRequestAndResponseStep), + TE.chain(parseSAMLRequestStep), + TE.chain(getIssueInstantFromRequestStep), + TE.chain(issueInstantValidationStep), + TE.chain(assertionIssueInstantValidationStep), + TE.chain(authnContextClassRefValidationStep), + TE.chain(attributesValidationStep), + TE.chain(responseIssuerValidationStep), + TE.chain(assertionIssuerValidationStep), + TE.chainW(transformValidationStep), + TE.bimap(validationFailure, validationSuccess) + )().catch(callback); }; diff --git a/src/utils/samlUtils.ts b/src/utils/samlUtils.ts new file mode 100644 index 00000000..63a73d90 --- /dev/null +++ b/src/utils/samlUtils.ts @@ -0,0 +1,1173 @@ +/** + * 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. + */ +// tslint:disable-next-line: no-submodule-imports +import { UTCISODateFromString } from "@pagopa/ts-commons/lib/dates"; +// tslint:disable-next-line: no-submodule-imports +import { NonEmptyString } from "@pagopa/ts-commons/lib/strings"; +import { distanceInWordsToNow, isAfter, subDays } from "date-fns"; +import { Request as ExpressRequest } from "express"; +import { predicate as PR } from "fp-ts"; +import { flatten } from "fp-ts/lib/Array"; +import * as E from "fp-ts/lib/Either"; +import { pipe } from "fp-ts/lib/function"; +import * as O from "fp-ts/lib/Option"; +import { collect, lookup } from "fp-ts/lib/Record"; +import { Ord } from "fp-ts/lib/string"; +import * as TE from "fp-ts/lib/TaskEither"; +import * as t from "io-ts"; +import { pki } from "node-forge"; +import { SamlConfig } from "passport-saml"; +// tslint:disable-next-line: no-submodule-imports +import { MultiSamlConfig } from "passport-saml/multiSamlStrategy"; +import * as xmlCrypto from "xml-crypto"; +import { Builder, parseStringPromise } from "xml2js"; +import { DOMParser } from "xmldom"; +import { SPID_LEVELS, SPID_URLS, SPID_USER_ATTRIBUTES } from "../config"; +import { logger } from "./logger"; +import { + ContactType, + EntityType, + getSpidStrategyOption, + IServiceProviderConfig, + ISpidStrategyOptions +} from "./middleware"; + +export type SamlAttributeT = keyof typeof SPID_USER_ATTRIBUTES; + +interface IEntrypointCerts { + // tslint:disable-next-line: readonly-array + cert: NonEmptyString[]; + entryPoint?: string; + idpIssuer?: string; +} + +export const 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#" +}; + +export const ISSUER_FORMAT = "urn:oasis:names:tc:SAML:2.0:nameid-format:entity"; + +const decodeBase64 = (s: string) => Buffer.from(s, "base64").toString("utf8"); + +/** + * Remove prefix and suffix from x509 certificate. + */ +const cleanCert = (cert: string) => + cert + .replace(/-+BEGIN CERTIFICATE-+\r?\n?/, "") + .replace(/-+END CERTIFICATE-+\r?\n?/, "") + .replace(/\r\n/g, "\n"); + +const SAMLResponse = t.type({ + SAMLResponse: t.string +}); + +/** + * True if the element contains at least one element signed using hamc + * @param e + */ +const isSignedWithHmac = (e: Element): boolean => { + const signatures = e.getElementsByTagNameNS( + SAML_NAMESPACE.XMLDSIG, + "SignatureMethod" + ); + return Array.from({ length: signatures.length }) + .map((_, i) => signatures.item(i)) + .some( + item => + item?.getAttribute("Algorithm")?.valueOf() === + "http://www.w3.org/2000/09/xmldsig#hmac-sha1" + ); +}; + +export const notSignedWithHmacPredicate = E.fromPredicate( + PR.not(isSignedWithHmac), + _ => new Error("HMAC Signature is forbidden") +); + +export const getXmlFromSamlResponse = (body: unknown): O.Option => + pipe( + O.fromEither(SAMLResponse.decode(body)), + O.map(_ => decodeBase64(_.SAMLResponse)), + O.chain(_ => O.tryCatch(() => new DOMParser().parseFromString(_))) + ); + +/** + * Extract StatusMessage from SAML response + * + * ie. for ErrorCode nr22 + * returns "22" + */ +export function getErrorCodeFromResponse(doc: Document): O.Option { + return pipe( + O.fromNullable( + doc.getElementsByTagNameNS(SAML_NAMESPACE.PROTOCOL, "StatusMessage") + ), + O.chain(responseStatusMessageEl => { + return responseStatusMessageEl && + responseStatusMessageEl[0] && + responseStatusMessageEl[0].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; + }) + ); +} + +/** + * Extracts the issuer field from the response body. + */ +export const getSamlIssuer = (doc: Document): O.Option => { + return pipe( + O.fromNullable( + doc.getElementsByTagNameNS(SAML_NAMESPACE.ASSERTION, "Issuer").item(0) + ), + O.chainNullableK(_ => _.textContent?.trim()) + ); +}; + +/** + * 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: ExpressRequest, + idps: ISpidStrategyOptions["idp"] +): O.Option => { + return 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" + ? pipe( + O.fromNullable(idps[entityID]), + O.map( + (idp): IEntrypointCerts => ({ + 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: pipe( + idps, + collect(Ord)((_, idp) => (idp && idp.cert ? idp.cert : [])), + flatten + ), + // TODO: leave entryPoint undefined when this gets fixed + // @see https://github.com/bergie/passport-saml/issues/415 + entryPoint: "" + } as IEntrypointCerts) + ) + ); +}; + +export const getIDFromRequest = (requestXML: string): O.Option => { + const xmlRequest = new DOMParser().parseFromString(requestXML, "text/xml"); + return pipe( + O.fromNullable( + xmlRequest + .getElementsByTagNameNS(SAML_NAMESPACE.PROTOCOL, "AuthnRequest") + .item(0) + ), + O.chain(AuthnRequest => + O.fromEither(NonEmptyString.decode(AuthnRequest.getAttribute("ID"))) + ) + ); +}; + +const getAuthnContextValueFromResponse = ( + response: string +): O.Option => { + const xmlResponse = new DOMParser().parseFromString(response, "text/xml"); + // ie. https://www.spid.gov.it/SpidL2 + const responseAuthLevelEl = xmlResponse.getElementsByTagNameNS( + SAML_NAMESPACE.ASSERTION, + "AuthnContextClassRef" + ); + return responseAuthLevelEl[0] && responseAuthLevelEl[0].textContent + ? O.some(responseAuthLevelEl[0].textContent.trim()) + : O.none; +}; + +/** + * Extracts the correct SPID level from response. + */ +const getAuthSalmOptions = ( + req: ExpressRequest, + decodedResponse?: string +): O.Option> => { + return 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): e is string => typeof e === "string"), + O.chain((authLevel: string) => + pipe( + lookup(authLevel, SPID_LEVELS), + O.map(authnContext => ({ + authnContext, + forceAuthn: authLevel !== "SpidL1" + })), + O.altW(() => { + logger.error( + "SPID cannot find a valid authnContext for given authLevel: %s", + authLevel + ); + return O.none; + }) + ) + ), + O.alt(() => + pipe( + O.fromNullable(decodedResponse), + O.chain(response => getAuthnContextValueFromResponse(response)), + O.chain(authnContext => + pipe( + lookup(authnContext, SPID_URLS), + // check if the parsed value is a valid SPID AuthLevel + O.map(authLevel => { + return { + authnContext, + forceAuthn: authLevel !== "SpidL1" + }; + }), + O.altW(() => { + 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 + */ +export function logSamlCertExpiration(samlCert: string): void { + try { + const out = pki.certificateFromPem(samlCert); + if (out.validity.notAfter) { + const timeDiff = distanceInWordsToNow(out.validity.notAfter); + const warningDate = subDays(new Date(), 60); + if (isAfter(out.validity.notAfter, warningDate)) { + logger.info("samlCert expire in %s", timeDiff); + } else if (isAfter(out.validity.notAfter, new Date())) { + logger.warn("samlCert expire in %s", timeDiff); + } else { + logger.error("samlCert expired from %s", timeDiff); + } + } else { + logger.error("Missing expiration date on saml certificate."); + } + } catch (e) { + logger.error("Error calculating saml cert expiration: %s", e); + } +} + +/** + * 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. + */ +export const getSamlOptions: MultiSamlConfig["getSamlOptions"] = ( + req, + done +) => { + try { + // Get decoded response + const decodedResponse = + req.body && req.body.SAMLResponse + ? decodeBase64(req.body.SAMLResponse) + : undefined; + + // Get SPID strategy options with IDPs metadata + const maybeSpidStrategyOptions = O.fromNullable( + 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 = pipe( + maybeSpidStrategyOptions, + O.chain(spidStrategyOptions => + getEntrypointCerts(req, spidStrategyOptions.idp) + ) + ); + if (O.isNone(maybeEntrypointCerts)) { + logger.debug( + `SPID cannot find a valid idp in spidOptions for given entityID: ${req.query.entityID}` + ); + } + const entrypointCerts = pipe( + maybeEntrypointCerts, + O.getOrElse(() => ({} as IEntrypointCerts)) + ); + + // Get authnContext (SPID level) and forceAuthn from request payload + const maybeAuthOptions = getAuthSalmOptions(req, decodedResponse); + if (O.isNone(maybeAuthOptions)) { + logger.debug( + "SPID cannot find authnContext in response %s", + decodedResponse + ); + } + const authOptions = pipe( + maybeAuthOptions, + O.getOrElseW(() => ({})) + ); + const options = { + ...maybeSpidStrategyOptions.value.sp, + ...authOptions, + ...entrypointCerts + }; + return done(null, options); + } catch (e) { + return done(e); + } +}; + +// +// Service Provider Metadata +// + +const getSpidAttributesMetadata = ( + serviceProviderConfig: IServiceProviderConfig +) => { + return serviceProviderConfig.requiredAttributes + ? serviceProviderConfig.requiredAttributes.attributes.map(item => ({ + $: { + FriendlyName: SPID_USER_ATTRIBUTES[item] || "", + Name: item, + NameFormat: "urn:oasis:names:tc:SAML:2.0:attrname-format:basic" + } + })) + : []; +}; + +const getSpidOrganizationMetadata = ( + serviceProviderConfig: IServiceProviderConfig +) => { + return serviceProviderConfig.organization + ? { + Organization: { + OrganizationName: { + $: { "xml:lang": "it" }, + _: serviceProviderConfig.organization.name + }, + // must appear after organization name + // tslint:disable-next-line: object-literal-sort-keys + OrganizationDisplayName: { + $: { "xml:lang": "it" }, + _: serviceProviderConfig.organization.displayName + }, + OrganizationURL: { + $: { "xml:lang": "it" }, + _: serviceProviderConfig.organization.URL + } + } + } + : {}; +}; + +const getSpidContactPersonMetadata = ( + serviceProviderConfig: IServiceProviderConfig +) => { + return serviceProviderConfig.contacts + ? serviceProviderConfig.contacts + .map(item => { + const contact = { + $: { + contactType: item.contactType + }, + Company: item.company, + EmailAddress: item.email, + ...(item.phone ? { TelephoneNumber: item.phone } : {}) + }; + if (item.contactType === ContactType.OTHER) { + return { + Extensions: { + ...(item.extensions.IPACode + ? { "spid:IPACode": item.extensions.IPACode } + : {}), + ...(item.extensions.VATNumber + ? { "spid:VATNumber": item.extensions.VATNumber } + : {}), + ...(item.extensions?.FiscalCode + ? { "spid:FiscalCode": item.extensions.FiscalCode } + : {}), + ...(item.entityType === EntityType.AGGREGATOR + ? { [`spid:${item.extensions.aggregatorType}`]: {} } + : {}) + }, + ...contact, + $: { + ...contact.$, + "spid:entityType": item.entityType + } + }; + } + return contact; + }) + // Contacts array is limited to 3 elements + .slice(0, 3) + : {}; +}; + +const getKeyInfoForMetadata = (publicCert: string, privateKey: string) => ({ + file: privateKey, + getKey: () => Buffer.from(privateKey), + getKeyInfo: () => + `${publicCert}` +}); + +export const getMetadataTamperer = ( + xmlBuilder: Builder, + serviceProviderConfig: IServiceProviderConfig, + samlConfig: SamlConfig +) => (generateXml: string): TE.TaskEither => { + return pipe( + TE.tryCatch(() => parseStringPromise(generateXml), E.toError), + TE.chain(o => + TE.tryCatch(async () => { + // it is safe to mutate object here since it is + // deserialized and serialized locally in this method + const sso = o.EntityDescriptor.SPSSODescriptor[0]; + // tslint:disable-next-line: no-object-mutation + sso.$ = { + ...sso.$, + AuthnRequestsSigned: true, + WantAssertionsSigned: true + }; + // tslint:disable-next-line: no-object-mutation + sso.AssertionConsumerService[0].$.index = 0; + // tslint:disable-next-line: no-object-mutation + sso.AttributeConsumingService = { + $: { + index: samlConfig.attributeConsumingServiceIndex + }, + ServiceName: { + $: { + "xml:lang": "it" + }, + _: serviceProviderConfig.requiredAttributes.name + }, + // must appear after attributes + // tslint:disable-next-line: object-literal-sort-keys + RequestedAttribute: getSpidAttributesMetadata(serviceProviderConfig) + }; + // tslint:disable-next-line: no-object-mutation + o.EntityDescriptor = { + ...o.EntityDescriptor, + ...getSpidOrganizationMetadata(serviceProviderConfig) + }; + if (serviceProviderConfig.contacts) { + // tslint:disable-next-line: no-object-mutation + o.EntityDescriptor = { + ...o.EntityDescriptor, + $: { + ...o.EntityDescriptor.$, + "xmlns:spid": SAML_NAMESPACE.SPID + }, + // tslint:disable-next-line: no-inferred-empty-object-type + ContactPerson: getSpidContactPersonMetadata(serviceProviderConfig) + }; + } + return o; + }, E.toError) + ), + TE.chain(_ => + TE.tryCatch(async () => xmlBuilder.buildObject(_), E.toError) + ), + TE.chain(xml => + TE.tryCatch(async () => { + // 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); + // tslint:disable-next-line: no-object-mutation + sig.keyInfoProvider = getKeyInfoForMetadata( + publicCert, + samlConfig.privateCert + ); + // tslint:disable-next-line: no-object-mutation + sig.signatureAlgorithm = + "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"; + // tslint:disable-next-line: no-object-mutation + 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 + location: { reference: "", action: "prepend" } + }); + return sig.getSignedXml(); + }, E.toError) + ) + ); +}; + +// +// Authorize request +// + +export const getAuthorizeRequestTamperer = ( + xmlBuilder: Builder, + _: IServiceProviderConfig, + samlConfig: SamlConfig +) => (generateXml: string): TE.TaskEither => { + return pipe( + TE.tryCatch(() => parseStringPromise(generateXml), E.toError), + TE.chain(o => + TE.tryCatch(async () => { + // it is safe to mutate object here since it is + // deserialized and serialized locally in this method + // tslint:disable-next-line: no-any + const authnRequest = o["samlp:AuthnRequest"]; + // tslint:disable-next-line: no-object-mutation no-delete + delete authnRequest["samlp:NameIDPolicy"][0].$.AllowCreate; + // tslint:disable-next-line: no-object-mutation + authnRequest["saml:Issuer"][0].$.NameQualifier = samlConfig.issuer; + // tslint:disable-next-line: no-object-mutation + authnRequest["saml:Issuer"][0].$.Format = ISSUER_FORMAT; + return o; + }, E.toError) + ), + TE.chain(obj => + TE.tryCatch(async () => xmlBuilder.buildObject(obj), E.toError) + ) + ); +}; + +// +// Validate response +// + +const utcStringToDate = (value: string, tag: string): E.Either => + pipe( + UTCISODateFromString.decode(value), + E.mapLeft(() => new Error(`${tag} must be an UTCISO format date string`)) + ); + +export const validateIssuer = ( + fatherElement: Element, + idpIssuer: string +): E.Either => + pipe( + E.fromOption(() => new Error("Issuer element must be present"))( + O.fromNullable( + fatherElement + .getElementsByTagNameNS(SAML_NAMESPACE.ASSERTION, "Issuer") + .item(0) + ) + ), + E.chain(Issuer => + pipe( + NonEmptyString.decode(Issuer.textContent?.trim()), + E.mapLeft(() => new Error("Issuer element must be not empty")), + E.chain( + E.fromPredicate( + IssuerTextContent => { + return IssuerTextContent === idpIssuer; + }, + () => new Error(`Invalid Issuer. Expected value is ${idpIssuer}`) + ) + ), + E.map(() => Issuer) + ) + ) + ); + +export const mainAttributeValidation = ( + requestOrAssertion: Element, + acceptedClockSkewMs: number = 0 +): E.Either => { + return pipe( + 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( + _ => + _.getTime() < + (acceptedClockSkewMs === -1 + ? Infinity + : Date.now() + acceptedClockSkewMs), + () => new Error("IssueInstant must be in the past") + ) + ) + ); +}; + +export const isEmptyNode = (element: Element): boolean => { + if (element.childNodes.length > 1) { + return false; + } else if ( + element.firstChild && + element.firstChild.nodeType === element.ELEMENT_NODE + ) { + return false; + } else if ( + element.textContent && + element.textContent.replace(/[\r\n\ ]+/g, "") !== "" + ) { + return false; + } + return true; +}; + +const isOverflowNumberOf = ( + elemArray: readonly Element[], + maxNumberOfChildren: number +): boolean => + elemArray.filter(e => e.nodeType === e.ELEMENT_NODE).length > + maxNumberOfChildren; + +export const TransformError = t.interface({ + idpIssuer: t.string, + message: t.string, + numberOfTransforms: t.number +}); +export type TransformError = t.TypeOf; + +export const transformsValidation = ( + targetElement: Element, + idpIssuer: string +): E.Either => { + return pipe( + O.fromPredicate((elements: readonly Element[]) => elements.length > 0)( + Array.from( + targetElement.getElementsByTagNameNS( + SAML_NAMESPACE.XMLDSIG, + "Transform" + ) + ) + ), + O.fold( + () => E.right(targetElement), + transformElements => + pipe( + E.fromPredicate( + (_: readonly Element[]) => !isOverflowNumberOf(_, 4), + _ => + TransformError.encode({ + idpIssuer, + message: "Transform element cannot occurs more than 4 times", + numberOfTransforms: _.length + }) + )(transformElements), + E.map(() => targetElement) + ) + ) + ); +}; + +const notOnOrAfterValidation = ( + element: Element, + acceptedClockSkewMs: number = 0 +) => { + return pipe( + 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 + : Date.now() - acceptedClockSkewMs), + () => new Error("NotOnOrAfter must be in the future") + ) + ) + ); +}; + +export const assertionValidation = ( + Assertion: Element, + samlConfig: SamlConfig, + InResponseTo: string, + requestAuthnContextClassRef: string + // tslint:disable-next-line: no-big-function +): E.Either> => { + const acceptedClockSkewMs = samlConfig.acceptedClockSkewMs || 0; + return pipe( + E.fromOption(() => new Error("Assertion must be signed"))( + O.fromNullable( + Assertion.getElementsByTagNameNS( + SAML_NAMESPACE.XMLDSIG, + "Signature" + ).item(0) + ) + ), + E.chain(notSignedWithHmacPredicate), + // tslint:disable-next-line: no-big-function + E.chain(() => + pipe( + E.fromOption(() => new Error("Subject element must be present"))( + O.fromNullable( + Assertion.getElementsByTagNameNS( + SAML_NAMESPACE.ASSERTION, + "Subject" + ).item(0) + ) + ), + E.chain( + E.fromPredicate( + PR.not(isEmptyNode), + () => new Error("Subject element must be not empty") + ) + ), + E.chain(Subject => + pipe( + E.fromOption(() => new Error("NameID element must be present"))( + O.fromNullable( + Subject.getElementsByTagNameNS( + SAML_NAMESPACE.ASSERTION, + "NameID" + ).item(0) + ) + ), + E.chain( + E.fromPredicate( + PR.not(isEmptyNode), + () => new Error("NameID element must be not empty") + ) + ), + E.chain(NameID => + pipe( + 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 => + pipe( + 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 => + pipe( + E.fromOption( + () => new Error("SubjectConfirmation element must be present") + )( + O.fromNullable( + Subject.getElementsByTagNameNS( + SAML_NAMESPACE.ASSERTION, + "SubjectConfirmation" + ).item(0) + ) + ), + E.chain( + E.fromPredicate( + PR.not(isEmptyNode), + () => new Error("SubjectConfirmation element must be not empty") + ) + ), + E.chain(SubjectConfirmation => + pipe( + 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 => + pipe( + E.fromOption( + () => + new Error( + "SubjectConfirmationData element must be provided" + ) + )( + O.fromNullable( + SubjectConfirmation.getElementsByTagNameNS( + SAML_NAMESPACE.ASSERTION, + "SubjectConfirmationData" + ).item(0) + ) + ), + E.chain(SubjectConfirmationData => + pipe( + 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 => + pipe( + notOnOrAfterValidation( + SubjectConfirmationData, + acceptedClockSkewMs + ), + E.map(() => SubjectConfirmationData) + ) + ), + E.chain(SubjectConfirmationData => + pipe( + 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" + ) + ) + ) + ) + ) + ) + ) + ) + ), + // tslint:disable-next-line: no-big-function + E.chain(() => + pipe( + E.fromOption( + () => new Error("Conditions element must be provided") + )( + O.fromNullable( + Assertion.getElementsByTagNameNS( + SAML_NAMESPACE.ASSERTION, + "Conditions" + ).item(0) + ) + ), + E.chain( + E.fromPredicate( + PR.not(isEmptyNode), + () => new Error("Conditions element must be provided") + ) + ), + E.chain(Conditions => + pipe( + notOnOrAfterValidation(Conditions, acceptedClockSkewMs), + E.map(() => Conditions) + ) + ), + E.chain(Conditions => + pipe( + 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 + : Date.now() + acceptedClockSkewMs), + () => new Error("NotBefore must be in the past") + ) + ), + E.map(() => Conditions) + ) + ), + E.chain(Conditions => + pipe( + E.fromOption( + () => + new Error( + "AudienceRestriction element must be present and not empty" + ) + )( + O.fromNullable( + Conditions.getElementsByTagNameNS( + SAML_NAMESPACE.ASSERTION, + "AudienceRestriction" + ).item(0) + ) + ), + E.chain( + E.fromPredicate( + PR.not(isEmptyNode), + () => + new Error( + "AudienceRestriction element must be present and not empty" + ) + ) + ), + E.chain(AudienceRestriction => + pipe( + E.fromOption(() => new Error("Audience missing"))( + O.fromNullable( + AudienceRestriction.getElementsByTagNameNS( + SAML_NAMESPACE.ASSERTION, + "Audience" + ).item(0) + ) + ), + E.chain( + E.fromPredicate( + Audience => + Audience.textContent?.trim() === samlConfig.issuer, + () => new Error("Audience invalid") + ) + ) + ) + ) + ) + ), + E.chain(() => + pipe( + E.fromOption(() => new Error("Missing AuthnStatement"))( + O.fromNullable( + Assertion.getElementsByTagNameNS( + SAML_NAMESPACE.ASSERTION, + "AuthnStatement" + ).item(0) + ) + ), + E.chain( + E.fromPredicate( + PR.not(isEmptyNode), + () => new Error("Empty AuthnStatement") + ) + ), + E.chain(AuthnStatement => + pipe( + E.fromOption(() => new Error("Missing AuthnContext"))( + O.fromNullable( + AuthnStatement.getElementsByTagNameNS( + SAML_NAMESPACE.ASSERTION, + "AuthnContext" + ).item(0) + ) + ), + E.chain( + E.fromPredicate( + PR.not(isEmptyNode), + () => new Error("Empty AuthnContext") + ) + ), + E.chain(AuthnContext => + pipe( + E.fromOption( + () => new Error("Missing AuthnContextClassRef") + )( + O.fromNullable( + AuthnContext.getElementsByTagNameNS( + SAML_NAMESPACE.ASSERTION, + "AuthnContextClassRef" + ).item(0) + ) + ), + E.chain( + E.fromPredicate( + PR.not(isEmptyNode), + () => new Error("Empty AuthnContextClassRef") + ) + ), + E.map(AuthnContextClassRef => + AuthnContextClassRef.textContent?.trim() + ), + E.chain( + E.fromPredicate( + AuthnContextClassRef => + AuthnContextClassRef === SPID_LEVELS.SpidL1 || + AuthnContextClassRef === SPID_LEVELS.SpidL2 || + AuthnContextClassRef === SPID_LEVELS.SpidL3, + () => + new Error("Invalid AuthnContextClassRef value") + ) + ), + E.chain( + E.fromPredicate( + AuthnContextClassRef => { + return requestAuthnContextClassRef === + SPID_LEVELS.SpidL2 + ? AuthnContextClassRef === SPID_LEVELS.SpidL2 || + AuthnContextClassRef === SPID_LEVELS.SpidL3 + : requestAuthnContextClassRef === + SPID_LEVELS.SpidL1 + ? AuthnContextClassRef === SPID_LEVELS.SpidL1 || + AuthnContextClassRef === SPID_LEVELS.SpidL2 || + AuthnContextClassRef === SPID_LEVELS.SpidL3 + : requestAuthnContextClassRef === + AuthnContextClassRef; + }, + () => + new Error( + "AuthnContextClassRef value not expected" + ) + ) + ) + ) + ) + ) + ) + ) + ), + E.chain(() => + pipe( + E.fromOption( + () => new Error("AttributeStatement must contains Attributes") + )( + pipe( + O.fromNullable( + Assertion.getElementsByTagNameNS( + SAML_NAMESPACE.ASSERTION, + "AttributeStatement" + ).item(0) + ), + O.map(AttributeStatement => + AttributeStatement.getElementsByTagNameNS( + SAML_NAMESPACE.ASSERTION, + "Attribute" + ) + ) + ) + ), + E.chain( + E.fromPredicate( + Attributes => + Attributes.length > 0 && + !Array.from(Attributes).some(isEmptyNode), + () => + new Error( + "Attribute element must be present and not empty" + ) + ) + ) + ) + ) + ) + ) + ) + ) + ); +}; diff --git a/yarn.lock b/yarn.lock index 6beb9645..7596e059 100644 --- a/yarn.lock +++ b/yarn.lock @@ -504,6 +504,19 @@ integrity sha512-XIB2XbzHTN6ieIjfIMV9hlVcfPU26s2vafYWQcZHWXHOxiaRZYEDKEwdl129Zyg50+foYV2jCgtrqSA6qNuNSA== dependencies: defer-to-connect "^1.0.1" +"@pagopa/ts-commons@^10.0.0": + version "10.0.0" + resolved "https://registry.yarnpkg.com/@pagopa/ts-commons/-/ts-commons-10.0.0.tgz#20b5f5c9940019c697c87f652619a4ebc5c4193b" + integrity sha512-ruYm04X7pnQvkznDl6xH9XjuAkoUnjz7dKU2svkiO/iPiFhqMWoGxSTGwZIi/12PeTHSgZlorEOWgxZtxJolHQ== + dependencies: + abort-controller "^3.0.0" + agentkeepalive "^4.1.4" + applicationinsights "^1.8.10" + fp-ts "^2.10.5" + io-ts "^2.2.16" + json-set-map "^1.1.2" + node-fetch "^2.6.0" + validator "^10.1.0" "@types/babel__core@^7.1.0": version "7.1.15" @@ -759,6 +772,13 @@ abbrev@1: resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-1.1.1.tgz#f8f2c887ad10bf67f634f005b6987fed3179aac8" integrity sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q== +abort-controller@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/abort-controller/-/abort-controller-3.0.0.tgz#eaf54d53b62bae4138e809ca225c8439a6efb392" + integrity sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg== + dependencies: + event-target-shim "^5.0.0" + accepts@~1.3.7: version "1.3.7" resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.7.tgz#531bc726517a3b2b41f850021c6cc15eaab507cd" @@ -801,6 +821,15 @@ ajv@^6.12.3: version "6.12.6" resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.12.6.tgz#baf5a62e802b07d977034586f8c3baf5adf26df4" integrity sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g== +agentkeepalive@^4.1.4: + version "4.1.4" + resolved "https://registry.yarnpkg.com/agentkeepalive/-/agentkeepalive-4.1.4.tgz#d928028a4862cb11718e55227872e842a44c945b" + integrity sha512-+V/rGa3EuU74H6wR04plBb7Ks10FbtUQgRj/FQOG7uUIEuaINI+AiqJR1k6t3SVNs7o7ZjIdus6706qqzVq8jQ== + dependencies: + debug "^4.1.0" + depd "^1.1.2" + humanize-ms "^1.2.1" + dependencies: fast-deep-equal "^3.1.1" fast-json-stable-stringify "^2.0.0" @@ -869,6 +898,16 @@ anymatch@~3.1.2: normalize-path "^3.0.0" picomatch "^2.0.4" +applicationinsights@^1.8.10: + version "1.8.10" + resolved "https://registry.yarnpkg.com/applicationinsights/-/applicationinsights-1.8.10.tgz#fffa482cd1519880fb888536a87081ac05130667" + integrity sha512-ZLDA7mShh4mP2Z/HlFolmvhBPX1LfnbIWXrselyYVA7EKjHhri1fZzpu2EiWAmfbRxNBY6fRjoPJWbx5giKy4A== + dependencies: + cls-hooked "^4.2.2" + continuation-local-storage "^3.2.1" + diagnostic-channel "0.3.1" + diagnostic-channel-publishers "0.4.4" + argparse@^1.0.7: version "1.0.10" resolved "https://registry.yarnpkg.com/argparse/-/argparse-1.0.10.tgz#bcd6791ea5ae09725e17e5ad988134cd40b3d911" @@ -928,16 +967,32 @@ astral-regex@^1.0.0: resolved "https://registry.yarnpkg.com/astral-regex/-/astral-regex-1.0.0.tgz#6c8c3fb827dd43ee3918f27b82782ab7658a6fd9" integrity sha512-+Ryf6g3BKoRc7jfp7ad8tM4TtMiaWvbF/1/sQcZPkkS7ag3D5nMBCe2UfOTONtAkaG0tO0ij3C5Lwmf1EiyjHg== +async-hook-jl@^1.7.6: + version "1.7.6" + resolved "https://registry.yarnpkg.com/async-hook-jl/-/async-hook-jl-1.7.6.tgz#4fd25c2f864dbaf279c610d73bf97b1b28595e68" + integrity sha512-gFaHkFfSxTjvoxDMYqDuGHlcRyUuamF8s+ZTtJdDzqjws4mCt7v0vuV79/E2Wr2/riMQgtG4/yUtXWs1gZ7JMg== + dependencies: + stack-chain "^1.3.7" + async-limiter@~1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/async-limiter/-/async-limiter-1.0.1.tgz#dd379e94f0db8310b08291f9d64c3209766617fd" integrity sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ== + async@^3.1.0: version "3.2.1" resolved "https://registry.yarnpkg.com/async/-/async-3.2.1.tgz#d3274ec66d107a47476a4c49136aacdb00665fc8" integrity sha512-XdD5lRO/87udXCMC9meWdYiR+Nq6ZjUfXidViUZGu2F1MO4T3XwZ1et0hb2++BgLfhyJwy44BGB/yx80ABx8hg== +async-listener@^0.6.0: + version "0.6.10" + resolved "https://registry.yarnpkg.com/async-listener/-/async-listener-0.6.10.tgz#a7c97abe570ba602d782273c0de60a51e3e17cbc" + integrity sha512-gpuo6xOyF4D5DE5WvyqZdPA3NGhiT6Qf07l7DCB0wwDEsLvDIbCr6j9S5aj5Ch96dLace5tXVzWBZkxU/c5ohw== + dependencies: + semver "^5.3.0" + shimmer "^1.1.0" + asynckit@^0.4.0: version "0.4.0" resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79" @@ -1330,6 +1385,14 @@ clone-response@^1.0.2: integrity sha1-0dyXOSAxTfZ/vrlCI7TuNQI56Ws= dependencies: mimic-response "^1.0.0" +cls-hooked@^4.2.2: + version "4.2.2" + resolved "https://registry.yarnpkg.com/cls-hooked/-/cls-hooked-4.2.2.tgz#ad2e9a4092680cdaffeb2d3551da0e225eae1908" + integrity sha512-J4Xj5f5wq/4jAvcdgoGsL3G103BtWpZrMo8NEinRltN+xpTZdI+M38pyQqhuFU/P792xkMFvnKSf+Lm81U1bxw== + dependencies: + async-hook-jl "^1.7.6" + emitter-listener "^1.0.1" + semver "^5.4.1" co@^4.6.0: version "4.6.0" @@ -1458,6 +1521,14 @@ content-type@~1.0.4: resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.4.tgz#e138cc75e040c727b1966fe5e5f8c9aee256fe3b" integrity sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA== +continuation-local-storage@^3.2.1: + version "3.2.1" + resolved "https://registry.yarnpkg.com/continuation-local-storage/-/continuation-local-storage-3.2.1.tgz#11f613f74e914fe9b34c92ad2d28fe6ae1db7ffb" + integrity sha512-jx44cconVqkCEEyLSKWwkvUXwO561jXMa3LPjTPsm5QR22PA0/mhe33FT4Xb5y74JDvt/Cq+5lm8S8rskLv9ZA== + dependencies: + async-listener "^0.6.0" + emitter-listener "^1.1.1" + convert-source-map@^1.4.0, convert-source-map@^1.7.0: version "1.8.0" resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-1.8.0.tgz#f3373c32d21b4d780dd8004514684fb791ca4369" @@ -1697,7 +1768,7 @@ delayed-stream@~1.0.0: resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619" integrity sha1-3zrhmayt+31ECqrgsp4icrJOxhk= -depd@~1.1.2: +depd@^1.1.2, depd@~1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/depd/-/depd-1.1.2.tgz#9bcd52e14c097763e749b274c4346ed2e560b5a9" integrity sha1-m81S4UwJd2PnSbJ0xDRu0uVgtak= @@ -1717,6 +1788,27 @@ detect-newline@^2.1.0: resolved "https://registry.yarnpkg.com/detect-newline/-/detect-newline-2.1.0.tgz#f41f1c10be4b00e87b5f13da680759f2c5bfd3e2" integrity sha1-9B8cEL5LAOh7XxPaaAdZ8sW/0+I= +diagnostic-channel-publishers@0.4.4: + version "0.4.4" + resolved "https://registry.yarnpkg.com/diagnostic-channel-publishers/-/diagnostic-channel-publishers-0.4.4.tgz#57c3b80b7e7f576f95be3a257d5e94550f0082d6" + integrity sha512-l126t01d2ZS9EreskvEtZPrcgstuvH3rbKy82oUhUrVmBaGx4hO9wECdl3cvZbKDYjMF3QJDB5z5dL9yWAjvZQ== + +diagnostic-channel@0.3.1: + version "0.3.1" + resolved "https://registry.yarnpkg.com/diagnostic-channel/-/diagnostic-channel-0.3.1.tgz#7faa143e107f861be3046539eb4908faab3f53fd" + integrity sha512-6eb9YRrimz8oTr5+JDzGmSYnXy5V7YnK5y/hd8AUDK1MssHjQKm9LlD6NSrHx4vMDF3+e/spI2hmWTviElgWZA== + dependencies: + semver "^5.3.0" + +diagnostics@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/diagnostics/-/diagnostics-1.1.1.tgz#cab6ac33df70c9d9a727490ae43ac995a769b22a" + integrity sha512-8wn1PmdunLJ9Tqbx+Fx/ZEuHfJf4NKSN2ZBj7SJC/OWRWha843+WsTjqMe1B5E3p28jqBlp+mJ2fPVxPyNgYKQ== + dependencies: + colorspace "1.1.x" + enabled "1.0.x" + kuler "1.0.x" + diff-sequences@^24.9.0: version "24.9.0" resolved "https://registry.yarnpkg.com/diff-sequences/-/diff-sequences-24.9.0.tgz#5715d6244e2aa65f48bba0bc972db0b0b11e95b5" @@ -1784,6 +1876,20 @@ electron-to-chromium@^1.3.793: resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.810.tgz#23e340507e13e48debdb7445d2f8fbfab681c4df" integrity sha512-NteznMlGtkIZCJNM2X6AVm3oMqWAdq7TjqagZhmVLPwd9mtrIq+rRxGHerjFAOFIqQJYQUMT72ncd/lVcH1cOw== +emitter-listener@^1.0.1, emitter-listener@^1.1.1: + version "1.1.2" + resolved "https://registry.yarnpkg.com/emitter-listener/-/emitter-listener-1.1.2.tgz#56b140e8f6992375b3d7cb2cab1cc7432d9632e8" + integrity sha512-Bt1sBAGFHY9DKY+4/2cV6izcKJUf5T7/gkdmkxzX/qv9CcGH8xSwVRW5mtX03SWJtRTWSOpzCuWN9rBFYZepZQ== + dependencies: + shimmer "^1.2.0" + +emitter-listener@^1.0.1, emitter-listener@^1.1.1: + version "1.1.2" + resolved "https://registry.yarnpkg.com/emitter-listener/-/emitter-listener-1.1.2.tgz#56b140e8f6992375b3d7cb2cab1cc7432d9632e8" + integrity sha512-Bt1sBAGFHY9DKY+4/2cV6izcKJUf5T7/gkdmkxzX/qv9CcGH8xSwVRW5mtX03SWJtRTWSOpzCuWN9rBFYZepZQ== + dependencies: + shimmer "^1.2.0" + emoji-regex@^7.0.1: version "7.0.3" resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-7.0.3.tgz#933a04052860c85e83c122479c4748a8e4c72156" @@ -1939,6 +2045,11 @@ etag@~1.8.1: resolved "https://registry.yarnpkg.com/etag/-/etag-1.8.1.tgz#41ae2eeb65efa62268aebfea83ac7d79299b0887" integrity sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc= +event-target-shim@^5.0.0: + version "5.0.1" + resolved "https://registry.yarnpkg.com/event-target-shim/-/event-target-shim-5.0.1.tgz#5d4d3ebdf9583d63a5333ce2deb7480ab2b05789" + integrity sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ== + exec-sh@^0.3.2: version "0.3.6" resolved "https://registry.yarnpkg.com/exec-sh/-/exec-sh-0.3.6.tgz#ff264f9e325519a60cb5e273692943483cca63bc" @@ -2227,10 +2338,10 @@ forwarded@0.2.0: resolved "https://registry.yarnpkg.com/forwarded/-/forwarded-0.2.0.tgz#2269936428aad4c15c7ebe9779a84bf0b2a81811" integrity sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow== -fp-ts@1.12.0, fp-ts@1.17.0, fp-ts@^1.0.0: - version "1.17.0" - resolved "https://registry.yarnpkg.com/fp-ts/-/fp-ts-1.17.0.tgz#289127353ddbb4622ada1920d4ad6643182c1f1f" - integrity sha512-nBq25aCAMbCwVLobUUuM/MZihPKyjn0bCVBf6xMAGriHlf8W8Ze9UhyfLnbmfp0ekFTxMuTfLXrCzpJ34px7PQ== +fp-ts@^2.10.5, fp-ts@^2.11.1: + version "2.11.1" + resolved "https://registry.yarnpkg.com/fp-ts/-/fp-ts-2.11.1.tgz#b1eeb2540728b6328542664888442f8f805d2443" + integrity sha512-CJOfs+Heq/erkE5mqH2mhpsxCKABGmcLyeEwPxtbTlkLkItGUs6bmk2WqjB2SgoVwNwzTE5iKjPQJiq06CPs5g== fragment-cache@^0.2.1: version "0.2.1" @@ -2570,6 +2681,13 @@ https-proxy-agent@^2.2.1: agent-base "^4.3.0" debug "^3.1.0" +humanize-ms@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/humanize-ms/-/humanize-ms-1.2.1.tgz#c46e3159a293f6b896da29316d8b6fe8bb79bbed" + integrity sha1-xG4xWaKT9riW2ikxbYtv6Lt5u+0= + dependencies: + ms "^2.0.0" + hyperlinker@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/hyperlinker/-/hyperlinker-1.0.0.tgz#23dc9e38a206b208ee49bc2d6c8ef47027df0c0e" @@ -2671,17 +2789,15 @@ invert-kv@^2.0.0: resolved "https://registry.yarnpkg.com/invert-kv/-/invert-kv-2.0.0.tgz#7393f5afa59ec9ff5f67a27620d11c226e3eec02" integrity sha512-wPVv/y/QQ/Uiirj/vh3oP+1Ww+AWehmi1g5fFWGPF6IpCBCDVrhgHRMvrLfdYcwDh3QJbGXDW4JAuzxElLSqKA== -io-ts-types@^0.4.7: - version "0.4.8" - resolved "https://registry.yarnpkg.com/io-ts-types/-/io-ts-types-0.4.8.tgz#e5fd8940ce7a5ddd780083c33989b3f8978918c2" - integrity sha512-E/o4y8I/yP5sVcC731K3T2Zi6BTvJljyJLqhHYkxzkc9fr9vAC2cU0y0xZ5aJqEDfl76zMeEiXkAkf/3gQYlvA== +io-ts-types@^0.5.16: + version "0.5.16" + resolved "https://registry.yarnpkg.com/io-ts-types/-/io-ts-types-0.5.16.tgz#e9eed75371e217c97050cc507915e8eedc250946" + integrity sha512-h9noYVfY9rlbmKI902SJdnV/06jgiT2chxG6lYDxaYNp88HscPi+SBCtmcU+m0E7WT5QSwt7sIMj93+qu0FEwQ== -io-ts@1.8.5: - version "1.8.5" - resolved "https://registry.yarnpkg.com/io-ts/-/io-ts-1.8.5.tgz#2e102f7f518abe17b0f7e7ede0db54a4c4ddc188" - integrity sha512-4HzDeg7mTygFjFIKh7ajBSanoVaFryYSFI0ocdwndSWl3eWQXhg3wVR0WI+Li5Vq11TIsoIngQszVbN4dy//9A== - dependencies: - fp-ts "^1.0.0" +io-ts@^2.2.16: + version "2.2.16" + resolved "https://registry.yarnpkg.com/io-ts/-/io-ts-2.2.16.tgz#597dffa03db1913fc318c9c6df6931cb4ed808b2" + integrity sha512-y5TTSa6VP6le0hhmIyN0dqEXkrZeJLeC5KApJq6VLci3UEKF80lZ+KuoUs02RhBxNWlrqSNxzfI7otLX1Euv8Q== ipaddr.js@1.9.1: version "1.9.1" @@ -2949,11 +3065,6 @@ is-wsl@^1.1.0: resolved "https://registry.yarnpkg.com/is-wsl/-/is-wsl-1.1.0.tgz#1f16e4aa22b04d1336b66188a66af3c600c3a66d" integrity sha1-HxbkqiKwTRM2tmGIpmrzxgDDpm0= -is-yarn-global@^0.3.0: - version "0.3.0" - resolved "https://registry.yarnpkg.com/is-yarn-global/-/is-yarn-global-0.3.0.tgz#d502d3382590ea3004893746754c89139973e232" - integrity sha512-VjSeb/lHmkoyd8ryPVIKvOCn4D1koMqY+vqyjjUfc3xyKtP4dYOxM44sZrnqQSzSds3xyOrUTLTC9LVCVgLngw== - is_js@^0.9.0: version "0.9.0" resolved "https://registry.yarnpkg.com/is_js/-/is_js-0.9.0.tgz#0ab94540502ba7afa24c856aa985561669e9c52d" @@ -3036,16 +3147,6 @@ istanbul-reports@^2.2.6: dependencies: html-escaper "^2.0.0" -italia-ts-commons@^5.1.4: - version "5.1.13" - resolved "https://registry.yarnpkg.com/italia-ts-commons/-/italia-ts-commons-5.1.13.tgz#7ae919374f9c03c04e38603b7f7e9681ebee79b7" - integrity sha512-mXtrNG/HR6lRCOTtBrEzsxGmkHccoDXKqoc9i+J2+yGhhxcznASu+0KMC05ZR1yJP4bWliC9rXJ3hnCw0rMSnQ== - dependencies: - fp-ts "1.12.0" - io-ts "1.8.5" - json-set-map "^1.0.2" - validator "^10.1.0" - italia-tslint-rules@*: version "1.1.3" resolved "https://registry.yarnpkg.com/italia-tslint-rules/-/italia-tslint-rules-1.1.3.tgz#efac0c9638d14cef6cc907be74f0799b8bab0976" @@ -3502,7 +3603,7 @@ json-schema@0.2.3: resolved "https://registry.yarnpkg.com/json-schema/-/json-schema-0.2.3.tgz#b480c892e59a2f05954ce727bd3f2a4e882f9e13" integrity sha1-tIDIkuWaLwWVTOcnvT8qTogvnhM= -json-set-map@^1.0.2: +json-set-map@^1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/json-set-map/-/json-set-map-1.1.2.tgz#536cbc6549d06e8af11f76cb4fbd441ed2389e6e" integrity sha512-x0TGwgcOG21jOa8wV1PWXDpSXUAKa1WuhMSHPBQhXU5Pb+4DdMrxOw5HMAWztVLP8KhSG5Kl5BoAOVF0aW63wA== @@ -3974,7 +4075,12 @@ ms@2.1.1: resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.1.tgz#30a5864eb3ebb0a66f2ebe6d727af06a09d86e0a" integrity sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg== -ms@2.1.2: +ms@^2.0.0: + version "2.1.3" + resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" + integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== + +ms@^2.1.1: version "2.1.2" resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009" integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== @@ -5063,6 +5169,11 @@ shellwords@^0.1.1: resolved "https://registry.yarnpkg.com/shellwords/-/shellwords-0.1.1.tgz#d6b9181c1a48d397324c84871efbcfc73fc0654b" integrity sha512-vFwSUfQvqybiICwZY5+DAWIPLKsWO31Q91JSKl3UYv+K5c2QRPzn0qzec6QPu1Qc9eHYItiP3NdJqNVqetYAww== +shimmer@^1.1.0, shimmer@^1.2.0: + version "1.2.1" + resolved "https://registry.yarnpkg.com/shimmer/-/shimmer-1.2.1.tgz#610859f7de327b587efebf501fb43117f9aff337" + integrity sha512-sQTKC1Re/rM6XyFM6fIAGHRPVGvyXfgzIDvzoq608vM+jeyVD0Tu1E6Np0Kc2zAIFWIj963V2800iF/9LPieQw== + shx@^0.3.2: version "0.3.3" resolved "https://registry.yarnpkg.com/shx/-/shx-0.3.3.tgz#681a88c7c10db15abe18525349ed474f0f1e7b9f" @@ -5219,6 +5330,11 @@ sshpk@^1.7.0: safer-buffer "^2.0.2" tweetnacl "~0.14.0" +stack-chain@^1.3.7: + version "1.3.7" + resolved "https://registry.yarnpkg.com/stack-chain/-/stack-chain-1.3.7.tgz#d192c9ff4ea6a22c94c4dd459171e3f00cea1285" + integrity sha1-0ZLJ/06moiyUxN1FkXHj8AzqEoU= + stack-trace@0.0.x: version "0.0.10" resolved "https://registry.yarnpkg.com/stack-trace/-/stack-trace-0.0.10.tgz#547c70b347e8d32b4e108ea1a2a159e5fdde19c0"