├── .nvmrc ├── certs └── .gitkeep ├── .node-version ├── src ├── cert │ └── https │ │ └── .insertHereKeyAndCert ├── @types │ ├── mock-redis-client │ │ └── index.d.ts │ └── passport-saml │ │ └── index.d.ts ├── utils │ ├── logger.ts │ ├── response.ts │ ├── __tests__ │ │ ├── metadata.test.ts │ │ ├── middleware.test.ts │ │ └── saml.test.ts │ ├── __mocks__ │ │ ├── testenv-idp-metadata.ts │ │ ├── cie-idp-metadata.ts │ │ └── saml.ts │ ├── metadata.ts │ └── middleware.ts ├── types │ └── IDPEntityDescriptor.ts ├── __mocks__ │ ├── metadata.ts │ └── request.ts ├── strategy │ ├── __mocks__ │ │ └── passport-saml.ts │ ├── __tests__ │ │ ├── spid.test.ts │ │ ├── redis_cache_provider.test.ts │ │ └── saml_client.test.ts │ ├── saml_client.ts │ ├── redis_cache_provider.ts │ └── spid.ts ├── config.ts ├── environment.d.ts ├── bin │ └── startup-idps-metadata.ts ├── example.ts ├── __tests__ │ └── index.test.ts └── index.ts ├── .prettierrc ├── CODEOWNERS ├── .gitignore ├── .auto-changelog.json ├── tslint.json ├── CHANGELOG.md ├── Dangerfile.ts ├── tsconfig.json ├── preview.hbs ├── spid-testenv ├── users.json └── config.yaml ├── LICENSE ├── docker-compose.yml ├── .github └── workflows │ └── ci.yml ├── Dockerfile ├── package.json └── README.md /.nvmrc: -------------------------------------------------------------------------------- 1 | 10.14.1 2 | -------------------------------------------------------------------------------- /certs/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.node-version: -------------------------------------------------------------------------------- 1 | 10.14.1 2 | -------------------------------------------------------------------------------- /src/cert/https/.insertHereKeyAndCert: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | # .prettierrc 2 | parser: typescript 3 | -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | # see https://help.github.com/en/articles/about-code-owners#example-of-a-codeowners-file 2 | 3 | * @sebbalex @bfabio 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | dist/ 3 | node_modules/ 4 | npm-debug.log 5 | yarn-error.log 6 | coverage 7 | .vscode 8 | .idea/ 9 | *.pem 10 | .npmrc 11 | -------------------------------------------------------------------------------- /src/@types/mock-redis-client/index.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This files contains the typescript declaration of module mock-redis-client. 3 | */ 4 | 5 | declare module "mock-redis-client"; -------------------------------------------------------------------------------- /.auto-changelog.json: -------------------------------------------------------------------------------- 1 | { 2 | "issuePattern": "\\[#(\\d+)\\]", 3 | "issueUrl": "https://www.pivotaltracker.com/story/show/{id}", 4 | "breakingPattern": "BREAKING CHANGE:" 5 | } 6 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "defaultSeverity": "error", 3 | "extends": [ 4 | "italia-tslint-rules/strong" 5 | ], 6 | "jsRules": {}, 7 | "rules": {}, 8 | "rulesDirectory": [] 9 | } 10 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ### Changelog 2 | 3 | All notable changes to this project will be documented in this file. Dates are displayed in UTC. 4 | 5 | #### v1.0.0 6 | 7 | > 29 October 2020 8 | 9 | Initial release 10 | -------------------------------------------------------------------------------- /Dangerfile.ts: -------------------------------------------------------------------------------- 1 | // import custom DangerJS rules 2 | // see http://danger.systems/js 3 | // see https://github.com/teamdigitale/danger-plugin-digitalcitizenship/ 4 | // tslint:disable-next-line:prettier 5 | import checkDangers from 'danger-plugin-digitalcitizenship'; 6 | 7 | checkDangers(); 8 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es6", 4 | "rootDir": "src", 5 | "outDir": "dist", 6 | "module": "commonjs", 7 | "moduleResolution": "node", 8 | "strict": true, 9 | "noImplicitAny": true, 10 | "declaration": true, 11 | "sourceMap": true 12 | }, 13 | "lib": [], 14 | "include": ["src/**/*"], 15 | "exclude": ["node_modules", "src/**/__mocks__/*", "src/**/__tests__/*"] 16 | } 17 | -------------------------------------------------------------------------------- /preview.hbs: -------------------------------------------------------------------------------- 1 | {{#each releases}} 2 | {{#if @first}} 3 | {{#each merges}} 4 | - {{{message}}}{{#if href}} [`#{{id}}`]({{href}}){{/if}} 5 | {{/each}} 6 | {{#each fixes}} 7 | - {{{commit.subject}}}{{#each fixes}}{{#if href}} [`#{{id}}`]({{href}}){{/if}}{{/each}} 8 | {{/each}} 9 | {{#each commits}} 10 | - {{#if breaking}}**Breaking change:** {{/if}}{{{subject}}}{{#if href}} [`{{shorthash}}`]({{href}}){{/if}} 11 | {{/each}} 12 | {{/if}} 13 | {{/each}} 14 | -------------------------------------------------------------------------------- /src/utils/logger.ts: -------------------------------------------------------------------------------- 1 | import { createLogger, format, transports } from "winston"; 2 | const { combine, timestamp, label, printf } = format; 3 | 4 | export const logger = createLogger({ 5 | format: combine( 6 | label({ label: "spid-express" }), 7 | timestamp(), 8 | format.splat(), 9 | printf(info => { 10 | return `${info.timestamp} [${info.label}] ${info.level}: ${info.message}`; 11 | }) 12 | ), 13 | level: process.env.NODE_ENV !== "production" ? "debug" : "info", 14 | transports: [new transports.Console()] 15 | }); 16 | -------------------------------------------------------------------------------- /src/types/IDPEntityDescriptor.ts: -------------------------------------------------------------------------------- 1 | import * as t from "io-ts"; 2 | // tslint:disable-next-line: no-submodule-imports 3 | import { createNonEmptyArrayFromArray } from "io-ts-types/lib/fp-ts/createNonEmptyArrayFromArray"; 4 | import { NonEmptyString } from "italia-ts-commons/lib/strings"; 5 | 6 | export const IDPEntityDescriptor = t.interface({ 7 | cert: createNonEmptyArrayFromArray(NonEmptyString), 8 | 9 | entityID: t.string, 10 | 11 | entryPoint: t.string, 12 | 13 | logoutUrl: t.string 14 | }); 15 | 16 | export type IDPEntityDescriptor = t.TypeOf; 17 | -------------------------------------------------------------------------------- /src/@types/passport-saml/index.d.ts: -------------------------------------------------------------------------------- 1 | import { 2 | SamlConfig, 3 | VerifyWithRequest, 4 | VerifyWithoutRequest 5 | } from "passport-saml"; 6 | 7 | import * as express from "express"; 8 | 9 | declare module "passport-saml" { 10 | export class SAML { 11 | private options: unknown; 12 | constructor(config: SamlConfig); 13 | 14 | validatePostResponse( 15 | body: { SAMLResponse: string }, 16 | callback: (err: Error, profile?: unknown, loggedOut?: boolean) => void 17 | ): void; 18 | 19 | generateAuthorizeRequest( 20 | req: express.Request, 21 | isPassive: boolean, 22 | callback: (err: Error, xml?: string) => void 23 | ): void; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /spid-testenv/users.json: -------------------------------------------------------------------------------- 1 | { 2 | "carla.rossi": { 3 | "sp": "https://spid.agid.gov.it/cd", 4 | "attrs": { 5 | "fiscalNumber": "ISPXNB32R82Y766D", 6 | "name": "Carla", 7 | "gender": "F", 8 | "familyName": "Rossi", 9 | "dateOfBirth": "2000-12-12", 10 | "email": "cittadinanzadigitale@teamdigitale.governo.it", 11 | "spidCode": "7b09078d-989b-6e6d-87b9-aab53efd714f", 12 | "mobilePhone": "333333333", 13 | "address": "Via Roma 1, Roma" 14 | }, 15 | "pwd": "test" 16 | }, 17 | "mario.rossi": { 18 | "sp": null, 19 | "attrs": { 20 | "fiscalNumber": "GDNNWA12H81Y874F", 21 | "name": "Mario", 22 | "gender": "M", 23 | "familyName": "Bianchi", 24 | "dateOfBirth": "1991-12-12", 25 | "email": "info@agid.gov.it", 26 | "spidCode": "d4b9f692-a0f2-1100-7bb6-8650c02d75d5", 27 | "mobilePhone": "333333334", 28 | "address": "Via Roma 2, Roma" 29 | }, 30 | "pwd": "test" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2020 Dipartimento per la Trasformazione Digitale 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | 9 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "2.2" 2 | 3 | services: 4 | spid-express-app: 5 | build: 6 | context: ./ 7 | dockerfile: Dockerfile 8 | environment: 9 | - NODE_ENV=dev 10 | - REDIS_URL=redis://redis 11 | - NODE_TLS_REJECT_UNAUTHORIZED=0 12 | expose: 13 | - "3000" 14 | ports: 15 | - "3000:3000" 16 | image: node:10.14.2-alpine 17 | working_dir: /usr/src/app 18 | command: ["yarn", "dev"] 19 | networks: 20 | - spid-express-app 21 | depends_on: 22 | - spid-testenv2 23 | 24 | spid-testenv2: 25 | image: italia/spid-testenv2:1.1.0 26 | ports: 27 | - "8088:8088" 28 | volumes: 29 | - "./spid-testenv/config.yaml:/app/conf/config.yaml" 30 | - "./spid-testenv/users.json:/app/conf/users.json" 31 | networks: 32 | - spid-express-app 33 | 34 | redis: 35 | image: wodby/redis:3.2-2.1.5 36 | environment: 37 | REDIS_TIMEOUT: 300 38 | REDIS_TCP_KEEPALIVE: 60 39 | REDIS_MAXMEMORY: 182m 40 | networks: 41 | - spid-express-app 42 | 43 | # needed to make TSL work 44 | networks: 45 | spid-express-app: 46 | driver: bridge 47 | driver_opts: 48 | com.docker.network.driver.mtu: 1450 49 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: 4 | - master 5 | pull_request: 6 | branches: 7 | - master 8 | 9 | name: Pull request to master 10 | jobs: 11 | linters: 12 | name: Linters 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@master 16 | 17 | - name: Use Node.js 10.14.1 18 | uses: actions/setup-node@master 19 | with: 20 | node-version: 10.14.1 21 | 22 | - run: yarn install --frozen-lockfile 23 | 24 | - run: yarn lint 25 | 26 | tests: 27 | name: Tests 28 | runs-on: ubuntu-latest 29 | steps: 30 | - uses: actions/checkout@master 31 | 32 | - name: Use Node.js 10.14.1 33 | uses: actions/setup-node@master 34 | with: 35 | node-version: 10.14.1 36 | 37 | - run: yarn install --frozen-lockfile 38 | 39 | - run: yarn test 40 | 41 | spid_sp_test: 42 | name: SPID compliance test 43 | runs-on: ubuntu-latest 44 | steps: 45 | - uses: actions/checkout@master 46 | 47 | - run: docker-compose up -d 48 | - run: sudo apt-get update && sudo apt-get install xmlsec1 libxml2-dev libxmlsec1-dev libxmlsec1-openssl 49 | - run: pip3 install spid-sp-test 50 | - run: spid_sp_test --metadata-url http://localhost:3000/metadata --extra --debug ERROR --exit-zero 51 | -------------------------------------------------------------------------------- /src/__mocks__/metadata.ts: -------------------------------------------------------------------------------- 1 | import { IDPEntityDescriptor } from "../types/IDPEntityDescriptor"; 2 | 3 | import { NonEmptyArray } from "fp-ts/lib/NonEmptyArray"; 4 | 5 | import { NonEmptyString } from "italia-ts-commons/lib/strings"; 6 | 7 | export const mockIdpMetadata: Record = { 8 | intesaid: { 9 | cert: (["CERT"] as unknown) as NonEmptyArray, 10 | entityID: "https://spid.intesa.it", 11 | entryPoint: "https://spid.intesa.it/acs", 12 | logoutUrl: "https://spid.intesa.it/logout" 13 | } 14 | }; 15 | 16 | export const mockCIEIdpMetadata: Record = { 17 | xx_servizicie_test: { 18 | cert: (["CERT"] as unknown) as NonEmptyArray, 19 | entityID: 20 | "https://preproduzione.idserver.servizicie.interno.gov.it/idp/profile/SAML2/POST/SSO", 21 | entryPoint: 22 | "https://preproduzione.idserver.servizicie.interno.gov.it/idp/profile/SAML2/POST/SSO", 23 | logoutUrl: "" 24 | } 25 | }; 26 | 27 | export const mockTestenvIdpMetadata: Record = { 28 | xx_testenv2: { 29 | cert: (["CERT"] as unknown) as NonEmptyArray, 30 | entityID: "https://spid-testenv.dev.io.italia.it", 31 | entryPoint: "https://spid-testenv.dev.io.italia.it/sso", 32 | logoutUrl: "https://spid-testenv.dev.io.italia.it/slo" 33 | } 34 | }; 35 | -------------------------------------------------------------------------------- /src/strategy/__mocks__/passport-saml.ts: -------------------------------------------------------------------------------- 1 | // tslint:disable: no-any 2 | import * as express from "express"; 3 | import { SamlConfig } from "passport-saml"; 4 | // tslint:disable-next-line: no-var-requires 5 | const OriginalSAML = require("passport-saml").SAML; 6 | 7 | export const mockWrapCallback = jest.fn(); 8 | 9 | // tslint:disable: max-classes-per-file 10 | export class SAML { 11 | public options: any; 12 | public cacheProvider: any; 13 | 14 | private initialize = OriginalSAML.prototype.initialize; 15 | constructor(samlConfig: SamlConfig) { 16 | // tslint:disable-next-line: no-string-literal 17 | this.options = this.initialize(samlConfig); 18 | this.cacheProvider = this.options.cacheProvider; 19 | } 20 | 21 | public validatePostResponse( 22 | body: { SAMLResponse: string }, 23 | callback: ( 24 | err: Error | null, 25 | profile?: unknown, 26 | // tslint:disable-next-line: bool-param-default 27 | loggedOut?: boolean 28 | ) => void 29 | ): void { 30 | callback(null, {}, false); 31 | } 32 | 33 | public generateAuthorizeRequest( 34 | req: express.Request, 35 | isPassive: boolean, 36 | callback: (err: Error | null, xml?: string) => void 37 | ): void { 38 | mockWrapCallback(callback); 39 | } 40 | } 41 | 42 | export class Strategy { 43 | constructor() { 44 | // tslint:disable-next-line: no-console 45 | console.log("Mock Strategy: constructor was called"); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /spid-testenv/config.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | # CONFIGURAZIONE IDENTITY PROVIDER 3 | 4 | # Hostname da usare per generare l'entityID dell'IdP e gli URL degli endpoint 5 | # SAML indicati nel metadata dell'IdP 6 | hostname: "localhost" 7 | base_url: "https://spid-testenv2:8088" 8 | 9 | # Chiave e certificato necessari per la firma dei messaggi SAML 10 | key_file: "conf/idp.key" 11 | cert_file: "conf/idp.crt" 12 | 13 | 14 | # CONFIGURAZIONE SERVICE PROVIDER 15 | 16 | # Si possono configurare più Service Provider. Per leggere i metadati da un 17 | # file .xml è sufficiente inserirne il path sotto "local"; per leggerli da 18 | # un URL remoto bisogna invece inserirlo sotto "remote" (insieme al path di 19 | # una copia locale del certificato del Service Provider, che per sicurezza 20 | # deve coincidere con quello presente nei metadati). 21 | # cfr. https://pysaml2.readthedocs.io/en/latest/howto/config.html#metadata 22 | metadata: 23 | remote: 24 | - "http://spid-express-app:3000/metadata" 25 | 26 | # CONFIGURAZIONE TESTENV WEB SERVER 27 | 28 | # Abilita (true) o disabilita (false) la modalità debug 29 | debug: true 30 | 31 | # Indirizzo IP dell'interfaccia su cui esporre il server e porta 32 | # (0.0.0.0 per ascoltare su tutte le interfacce) 33 | host: 0.0.0.0 34 | port: 8088 35 | 36 | # Abilita (true) o disabilita (false) la modalità HTTPS per l'IdP 37 | https: true 38 | 39 | # Se si abilita HTTPS è necessario specificare chiave e certificato 40 | # (indipendenti da chiave e certificato SAML) 41 | https_key_file: "./conf/idp.key" 42 | https_cert_file: "./conf/idp.crt" 43 | 44 | # Endpoint del server IdP (path relativi) 45 | endpoints: 46 | single_sign_on_service: "/sso" 47 | single_logout_service: "/slo" 48 | -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- 1 | export const SPID_IDP_IDENTIFIERS = { 2 | "https://id.lepida.it/idp/shibboleth": "lepidaid", 3 | "https://identity.infocert.it": "infocertid", 4 | "https://identity.sieltecloud.it": "sielteid", 5 | "https://idp.namirialtsp.com/idp": "namirialid", 6 | "https://login.id.tim.it/affwebservices/public/saml2sso": "timid", 7 | "https://loginspid.aruba.it": "arubaid", 8 | "https://posteid.poste.it": "posteid", 9 | "https://spid.intesa.it": "intesaid", 10 | "https://spid.register.it": "spiditalia" 11 | }; 12 | 13 | export const CIE_IDP_IDENTIFIERS = { 14 | "https://idserver.servizicie.interno.gov.it/idp/profile/SAML2/POST/SSO": 15 | "xx_servizicie", 16 | "https://preproduzione.idserver.servizicie.interno.gov.it/idp/profile/SAML2/POST/SSO": 17 | "xx_servizicie_test" 18 | }; 19 | 20 | /* 21 | * @see https://www.agid.gov.it/sites/default/files/repository_files/regole_tecniche/tabella_attributi_idp.pdf 22 | */ 23 | export const SPID_USER_ATTRIBUTES = { 24 | address: "Indirizzo", 25 | companyName: "Nome azienda", 26 | dateOfBirth: "Data di nascita", 27 | digitalAddress: "Indirizzo elettronico", 28 | email: "Email", 29 | familyName: "Cognome", 30 | fiscalNumber: "Codice fiscale", 31 | gender: "Sesso", 32 | idCard: "Numero carta di identità", 33 | ivaCode: "Codice IVA", 34 | mobilePhone: "Numero di telefono", 35 | name: "Nome", 36 | placeOfBirth: "Luogo di nascita", 37 | registeredOffice: "Ufficio", 38 | spidCode: "Codice SPID" 39 | }; 40 | 41 | export const SPID_LEVELS = { 42 | SpidL1: "https://www.spid.gov.it/SpidL1", 43 | SpidL2: "https://www.spid.gov.it/SpidL2", 44 | SpidL3: "https://www.spid.gov.it/SpidL3" 45 | }; 46 | 47 | export const SPID_URLS = { 48 | "https://www.spid.gov.it/SpidL1": "SpidL1", 49 | "https://www.spid.gov.it/SpidL2": "SpidL2", 50 | "https://www.spid.gov.it/SpidL3": "SpidL3" 51 | }; 52 | -------------------------------------------------------------------------------- /src/environment.d.ts: -------------------------------------------------------------------------------- 1 | declare global { 2 | namespace NodeJS { 3 | interface ProcessEnv { 4 | METADATA_PUBLIC_CERT: string; 5 | METADATA_PRIVATE_CERT: string; 6 | ORG_ISSUER: string; 7 | ORG_URL: string; 8 | ORG_DISPLAY_NAME: string; 9 | ORG_NAME: string; 10 | AUTH_N_CONTEXT: string; 11 | SPID_ATTRIBUTES: string; 12 | ENDPOINT_ACS: string; 13 | ENDPOINT_ERROR: string; 14 | ENDPOINT_SUCCESS: string; 15 | ENDPOINT_LOGIN: string; 16 | ENDPOINT_METADATA: string; 17 | ENDPOINT_LOGOUT: string; 18 | SPID_VALIDATOR_URL: string; 19 | SPID_TESTENV_URL: string; 20 | NODE_ENV: 'development' | 'production'; 21 | PORT?: string; 22 | USE_HTTPS: string; 23 | HTTPS_KEY: string; 24 | HTTPS_CRT: string; 25 | SERVICE_PROVIDER_TYPE: string; 26 | CONTACT_PERSON_OTHER_VAT_NUMBER: string; 27 | CONTACT_PERSON_OTHER_FISCAL_CODE: string; 28 | CONTACT_PERSON_OTHER_EMAIL_ADDRESS: string; 29 | CONTACT_PERSON_OTHER_TELEPHONE_NUMBER: string; 30 | CONTACT_PERSON_BILLING_IVA_IDPAESE: string; 31 | CONTACT_PERSON_BILLING_IVA_IDCODICE: string; 32 | CONTACT_PERSON_BILLING_IVA_DENOMINAZIONE: string; 33 | CONTACT_PERSON_BILLING_SEDE_INDIRIZZO: string; 34 | CONTACT_PERSON_BILLING_SEDE_NUMEROCIVICO: string; 35 | CONTACT_PERSON_BILLING_SEDE_CAP: string; 36 | CONTACT_PERSON_BILLING_SEDE_COMUNE: string; 37 | CONTACT_PERSON_BILLING_SEDE_PROVINCIA: string; 38 | CONTACT_PERSON_BILLING_SEDE_NAZIONE: string; 39 | CONTACT_PERSON_BILLING_COMPANY: string; 40 | CONTACT_PERSON_BILLING_EMAIL_ADDRESS: string; 41 | CONTACT_PERSON_BILLING_TELEPHONE_NUMBER: string; 42 | } 43 | } 44 | } 45 | 46 | // If this file has no import/export statements (i.e. is a script) 47 | // convert it into a module by adding an empty export statement. 48 | export {} 49 | -------------------------------------------------------------------------------- /src/utils/response.ts: -------------------------------------------------------------------------------- 1 | import { DOMParser } from "xmldom"; 2 | 3 | import { NextFunction, Request, Response } from "express"; 4 | import { fromNullable, none, Option, some, tryCatch } from "fp-ts/lib/Option"; 5 | import { ResponseErrorInternal } from "italia-ts-commons/lib/responses"; 6 | import { SAML_NAMESPACE } from "./saml"; 7 | 8 | /** 9 | * Extract AuthnContextClassRef from SAML response. 10 | * 11 | * ie. for https://www.spid.gov.it/SpidL2 12 | * returns "https://www.spid.gov.it/SpidL2" 13 | */ 14 | export function getAuthnContextFromResponse(xml: string): Option { 15 | return fromNullable(xml) 16 | .chain(xmlStr => tryCatch(() => new DOMParser().parseFromString(xmlStr))) 17 | .chain(xmlResponse => 18 | xmlResponse 19 | ? some( 20 | xmlResponse.getElementsByTagNameNS( 21 | SAML_NAMESPACE.ASSERTION, 22 | "AuthnContextClassRef" 23 | ) 24 | ) 25 | : none 26 | ) 27 | .chain(responseAuthLevelEl => 28 | responseAuthLevelEl && 29 | responseAuthLevelEl[0] && 30 | responseAuthLevelEl[0].textContent 31 | ? some(responseAuthLevelEl[0].textContent.trim()) 32 | : none 33 | ); 34 | } 35 | 36 | export function middlewareCatchAsInternalError( 37 | f: (req: Request, res: Response, next: NextFunction) => unknown, 38 | message: string = "Exception while calling express middleware" 39 | ): (req: Request, res: Response, next: NextFunction) => void { 40 | return (req: Request, res: Response, next: NextFunction) => { 41 | try { 42 | f(req, res, next); 43 | } catch (_) { 44 | // Send a ResponseErrorInternal only if a response was not already sent to the client 45 | if (!res.headersSent) { 46 | return ResponseErrorInternal(`${message} [${_}]`).apply(res); 47 | } 48 | } 49 | }; 50 | } 51 | -------------------------------------------------------------------------------- /src/__mocks__/request.ts: -------------------------------------------------------------------------------- 1 | // tslint:disable:no-any 2 | 3 | /** 4 | * mockReq 5 | * @returns {{header, accepts, acceptsEncodings, acceptsEncoding, acceptsCharsets, acceptsCharset, acceptsLanguages, acceptsLanguage, range, param, is, reset: resetMock}} 6 | */ 7 | 8 | export default function mockReq(): any { 9 | const request = { 10 | accepts: jest.fn(), 11 | acceptsCharset: jest.fn(), 12 | acceptsCharsets: jest.fn(), 13 | acceptsEncoding: jest.fn(), 14 | acceptsEncodings: jest.fn(), 15 | acceptsLanguage: jest.fn(), 16 | acceptsLanguages: jest.fn(), 17 | header: jest.fn(), 18 | is: jest.fn(), 19 | param: jest.fn(), 20 | query: {}, 21 | range: jest.fn(), 22 | reset: resetMock 23 | }; 24 | 25 | request.header.mockImplementation(() => request); 26 | request.accepts.mockImplementation(() => request); 27 | request.acceptsEncodings.mockImplementation(() => request); 28 | request.acceptsEncoding.mockImplementation(() => request); 29 | request.acceptsCharsets.mockImplementation(() => request); 30 | request.acceptsCharset.mockImplementation(() => request); 31 | request.acceptsLanguages.mockImplementation(() => request); 32 | request.acceptsLanguage.mockImplementation(() => request); 33 | request.range.mockImplementation(() => request); 34 | request.param.mockImplementation(() => request); 35 | request.is.mockImplementation(() => request); 36 | 37 | return request; 38 | } 39 | 40 | /** 41 | * resetMock 42 | */ 43 | function resetMock(this: any): any { 44 | this.header.mockClear(); 45 | this.accepts.mockClear(); 46 | this.acceptsEncodings.mockClear(); 47 | this.acceptsEncoding.mockClear(); 48 | this.acceptsCharsets.mockClear(); 49 | this.acceptsCharset.mockClear(); 50 | this.acceptsLanguages.mockClear(); 51 | this.acceptsLanguage.mockClear(); 52 | this.range.mockClear(); 53 | this.param.mockClear(); 54 | this.is.mockClear(); 55 | } 56 | -------------------------------------------------------------------------------- /src/bin/startup-idps-metadata.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import { array } from "fp-ts/lib/Array"; 4 | import { fromNullable } from "fp-ts/lib/Option"; 5 | import { task } from "fp-ts/lib/Task"; 6 | import * as yargs from "yargs"; 7 | import { logger } from "../utils/logger"; 8 | import { fetchMetadataXML } from "../utils/metadata"; 9 | 10 | // 11 | // parse command line 12 | // 13 | const argv = yargs 14 | .option("idp-metadata-url-env", { 15 | demandOption: false, 16 | description: "ENV var name containing IDP Metadata URL", 17 | normalize: true, 18 | string: true 19 | }) 20 | .option("testenv-metadata-url-env", { 21 | demandOption: false, 22 | description: "ENV var name containing TestEnv2 Metadata URL", 23 | normalize: true, 24 | string: true 25 | }) 26 | .option("cie-metadata-url-env", { 27 | demandOption: false, 28 | description: "ENV var name containing CIE Metadata URL", 29 | normalize: true, 30 | string: true 31 | }) 32 | .help().argv; 33 | 34 | interface IIDPSMetadataXML { 35 | idps?: string; 36 | xx_testenv2?: string; 37 | xx_servizicie?: string; 38 | } 39 | 40 | function printIdpsMetadata( 41 | idpsMetadataENV: string | undefined, 42 | testEnv2MetadataENV: string | undefined, 43 | cieMetadataENV: string | undefined 44 | ): Promise { 45 | // tslint:disable: no-object-mutation no-any no-empty 46 | logger.info = (): any => {}; 47 | const maybeIdpsMetadataURL = fromNullable(idpsMetadataENV) 48 | .mapNullable(_ => process.env[_]) 49 | .map((_: string) => 50 | fetchMetadataXML(_) 51 | .map<{ idps?: string }>(_1 => ({ 52 | idps: _1 53 | })) 54 | .getOrElse({}) 55 | ) 56 | .getOrElse(task.of({})); 57 | const maybeTestEnvMetadataURL = fromNullable(testEnv2MetadataENV) 58 | .mapNullable(_ => process.env[_]) 59 | .map((_: string) => 60 | fetchMetadataXML(`${_}/metadata`) 61 | .map<{ xx_testenv2?: string }>(_1 => ({ 62 | xx_testenv2: _1 63 | })) 64 | .getOrElse({}) 65 | ) 66 | .getOrElse(task.of({})); 67 | const maybeCIEMetadataURL = fromNullable(cieMetadataENV) 68 | .mapNullable(_ => process.env[_]) 69 | .map((_: string) => 70 | fetchMetadataXML(_) 71 | .map<{ xx_servizicie?: string }>(_1 => ({ 72 | xx_servizicie: _1 73 | })) 74 | .getOrElse({}) 75 | ) 76 | .getOrElse(task.of({})); 77 | return array 78 | .sequence(task)([ 79 | maybeIdpsMetadataURL, 80 | maybeTestEnvMetadataURL, 81 | maybeCIEMetadataURL 82 | ]) 83 | .map(_ => _.reduce((prev, current) => ({ ...prev, ...current }), {})) 84 | .run(); 85 | } 86 | 87 | printIdpsMetadata( 88 | argv["idp-metadata-url-env"], 89 | argv["testenv-metadata-url-env"], 90 | argv["cie-metadata-url-env"] 91 | ) 92 | // tslint:disable-next-line: no-console 93 | .then(metadata => console.log(JSON.stringify(metadata, null, 2))) 94 | .catch(() => logger.error("Error fetching IDP metadata")); 95 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM circleci/node:10.14.2 as builder 2 | 3 | WORKDIR /home/circleci 4 | 5 | COPY src src 6 | COPY package.json package.json 7 | COPY tsconfig.json tsconfig.json 8 | COPY yarn.lock yarn.lock 9 | 10 | RUN mkdir certs \ 11 | && openssl req -nodes \ 12 | -new \ 13 | -x509 \ 14 | -sha256 \ 15 | -days 365 \ 16 | -newkey rsa:2048 \ 17 | -subj "/C=IT/ST=State/L=City/O=Acme Inc. /OU=IT Department/CN=spid-express.selfsigned.example" \ 18 | -keyout certs/key.pem \ 19 | -out certs/cert.pem \ 20 | && yarn install \ 21 | && yarn build 22 | 23 | FROM node:10.14.2-alpine 24 | LABEL maintainer="https://developers.italia.it" 25 | 26 | WORKDIR /usr/src/app 27 | 28 | COPY /package.json /usr/src/app/package.json 29 | COPY --from=builder /home/circleci/src /usr/src/app/src 30 | COPY --from=builder /home/circleci/dist /usr/src/app/dist 31 | COPY --from=builder /home/circleci/certs /usr/src/app/certs 32 | COPY --from=builder /home/circleci/node_modules /usr/src/app/node_modules 33 | 34 | EXPOSE 3000 35 | 36 | ENV METADATA_PUBLIC_CERT="./certs/cert.pem" 37 | ENV METADATA_PRIVATE_CERT="./certs/key.pem" 38 | 39 | ENV ORG_ISSUER="https://spid.agid.gov.it/cd" 40 | ENV ORG_URL="http://localhost:3000" 41 | ENV ORG_DISPLAY_NAME="Organization display name" 42 | ENV ORG_NAME="Organization name" 43 | 44 | ENV AUTH_N_CONTEXT="https://www.spid.gov.it/SpidL1" 45 | 46 | ENV SPID_ATTRIBUTES="address,email,name,familyName,fiscalNumber,mobilePhone" 47 | 48 | ENV ENDPOINT_ACS="/acs" 49 | ENV ENDPOINT_ERROR="/error" 50 | ENV ENDPOINT_SUCCESS="/success" 51 | ENV ENDPOINT_LOGIN="/login" 52 | ENV ENDPOINT_METADATA="/metadata" 53 | ENV ENDPOINT_LOGOUT="/logout" 54 | 55 | ENV SPID_VALIDATOR_URL="http://localhost:8080" 56 | ENV SPID_TESTENV_URL="https://spid-testenv2:8088" 57 | 58 | 59 | ENV USE_HTTPS="false" 60 | #ENV USE_HTTPS="true" 61 | #ENV HTTPS_KEY="/usr/src/app/src/cert/https/cert.key" 62 | #ENV HTTPS_CRT="/usr/src/app/src/cert/https/cert.crt" 63 | 64 | ENV SERVICE_PROVIDER_TYPE="public" 65 | #ENV SERVICE_PROVIDER_TYPE="private" 66 | #ENV CONTACT_PERSON_OTHER_VAT_NUMBER="vatNumber" 67 | #ENV CONTACT_PERSON_OTHER_FISCAL_CODE="fiscalCode" 68 | #ENV CONTACT_PERSON_OTHER_EMAIL_ADDRESS="emailAddress" 69 | #ENV CONTACT_PERSON_OTHER_TELEPHONE_NUMBER="telephoneNumber" 70 | #ENV CONTACT_PERSON_BILLING_IVA_IDPAESE="IT" 71 | #ENV CONTACT_PERSON_BILLING_IVA_IDCODICE="02468135791" 72 | #ENV CONTACT_PERSON_BILLING_IVA_DENOMINAZIONE="Destinatario_Fatturazione" 73 | #ENV CONTACT_PERSON_BILLING_SEDE_INDIRIZZO="via [...]" 74 | #ENV CONTACT_PERSON_BILLING_SEDE_NUMEROCIVICO="99" 75 | #ENV CONTACT_PERSON_BILLING_SEDE_CAP="12345" 76 | #ENV CONTACT_PERSON_BILLING_SEDE_COMUNE="nome_citta" 77 | #ENV CONTACT_PERSON_BILLING_SEDE_PROVINCIA="XY" 78 | #ENV CONTACT_PERSON_BILLING_SEDE_NAZIONE="IT" 79 | #ENV CONTACT_PERSON_BILLING_COMPANY="Destinatario_Fatturazione" 80 | #ENV CONTACT_PERSON_BILLING_EMAIL_ADDRESS="email@fatturazione.it" 81 | #ENV CONTACT_PERSON_BILLING_TELEPHONE_NUMBER="telefono_fatture" 82 | 83 | CMD ["yarn", "dev"] -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@italia/spid-express", 3 | "version": "1.0.0", 4 | "description": "SPID (Italian Public Digital Identity System) middleware for Express", 5 | "repository": { 6 | "type": "git", 7 | "url": "git+https://github.com/italia/spid-express.git" 8 | }, 9 | "bin": { 10 | "startup-idps-metadata": "dist/bin/startup-idps-metadata.js" 11 | }, 12 | "author": "https://pagopa.gov.it", 13 | "license": "MIT", 14 | "main": "dist/index.js", 15 | "types": "dist/index.d.ts", 16 | "scripts": { 17 | "prebuild": "shx rm -rf dist", 18 | "build": "tsc", 19 | "dev": "nodemon --inspect=0.0.0.0 dist/example.js", 20 | "postversion": "git push && git push --tags", 21 | "test": "jest -i", 22 | "lint": "tslint --project ." 23 | }, 24 | "devDependencies": { 25 | "@types/express": "4.17.0", 26 | "@types/jest": "^24.0.13", 27 | "@types/node": "10.14.1", 28 | "@types/node-fetch": "^2.1.2", 29 | "@types/node-forge": "^0.9.1", 30 | "@types/passport": "^1.0.2", 31 | "@types/passport-saml": "1.1.1", 32 | "@types/request-ip": "0.0.35", 33 | "@types/supertest": "^2.0.8", 34 | "@types/xml-crypto": "^1.4.1", 35 | "@types/xml2js": "^0.4.5", 36 | "@types/xmldom": "^0.1.29", 37 | "@types/yargs": "^15.0.4", 38 | "auto-changelog": "^2.2.1", 39 | "danger": "^7.0.0", 40 | "danger-plugin-digitalcitizenship": "*", 41 | "express": "4.17.0", 42 | "italia-tslint-rules": "*", 43 | "jest": "^24.8.0", 44 | "mock-redis-client": "^0.91.13", 45 | "nock": "^11.7.1", 46 | "nodemon": "^2.0.2", 47 | "prettier": "^1.12.1", 48 | "shx": "^0.3.2", 49 | "supertest": "^4.0.2", 50 | "ts-jest": "^24.0.2", 51 | "tslint": "^5.1.0", 52 | "typescript": "^3.7.0" 53 | }, 54 | "dependencies": { 55 | "@types/redis": "^2.8.14", 56 | "date-fns": "^1.30.1", 57 | "fp-ts": "1.17.0", 58 | "https": "1.0.0", 59 | "io-ts": "1.8.5", 60 | "io-ts-types": "^0.4.7", 61 | "italia-ts-commons": "^5.1.4", 62 | "node-fetch": "^2.6.1", 63 | "node-forge": "^0.10.0", 64 | "passport": "^0.4.1", 65 | "passport-saml": "1.2.0", 66 | "redis": "^3.1.1", 67 | "winston": "^3.0.0", 68 | "xml-crypto": "^2.0.0", 69 | "xml2js": "^0.4.23", 70 | "xmldom": "^0.5.0", 71 | "yargs": "^15.3.0" 72 | }, 73 | "jest": { 74 | "testEnvironment": "node", 75 | "collectCoverage": true, 76 | "testRegex": "(/__tests__/.*|(\\.|/)(test|spec))\\.(js|ts)$", 77 | "moduleFileExtensions": [ 78 | "js", 79 | "node", 80 | "ts" 81 | ], 82 | "preset": "ts-jest", 83 | "testMatch": null 84 | }, 85 | "resolutions": { 86 | "fp-ts": "1.17.0" 87 | }, 88 | "bugs": { 89 | "url": "https://github.com/italia/spid-express/issues" 90 | }, 91 | "homepage": "https://github.com/italia/spid-express#readme", 92 | "keywords": [ 93 | "spid", 94 | "node", 95 | "express" 96 | ], 97 | "publishConfig": { 98 | "registry": "https://registry.npmjs.org/", 99 | "access": "public" 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/strategy/__tests__/spid.test.ts: -------------------------------------------------------------------------------- 1 | import * as express from "express"; 2 | import { createMockRedis } from "mock-redis-client"; 3 | import { Profile, VerifiedCallback } from "passport-saml"; 4 | import { RedisClient } from "redis"; 5 | import { logger } from "../../utils/logger"; 6 | import { getSamlOptions } from "../../utils/saml"; 7 | import * as redisCacheProvider from "../redis_cache_provider"; 8 | import { SpidStrategy } from "../spid"; 9 | 10 | // tslint:disable-next-line: no-any 11 | const mockRedisClient: RedisClient = (createMockRedis() as any).createClient(); 12 | 13 | describe("SamlStrategy prototype arguments check", () => { 14 | // tslint:disable-next-line: no-let no-any 15 | let OriginalPassportSaml: any; 16 | beforeAll(() => { 17 | OriginalPassportSaml = jest.requireActual("passport-saml").Strategy; 18 | }); 19 | it("should SamlStrategy constructor has 2 parameters", () => { 20 | expect(OriginalPassportSaml.prototype.constructor).toHaveLength(2); 21 | }); 22 | it("should SamlStrategy authenticate has 2 parameters", () => { 23 | expect(OriginalPassportSaml.prototype.authenticate).toHaveLength(2); 24 | }); 25 | }); 26 | 27 | describe("SamlStrategy#constructor", () => { 28 | beforeAll(() => { 29 | jest.restoreAllMocks(); 30 | }); 31 | it("should SamlStrategy constructor has 2 parameters", () => { 32 | const expectedNoopCacheProvider = { 33 | // tslint:disable-next-line: no-empty 34 | get: () => () => { 35 | return; 36 | }, 37 | remove: () => () => { 38 | return; 39 | }, 40 | save: () => { 41 | return; 42 | } 43 | }; 44 | const mockNoopCacheProvider = jest 45 | .spyOn(redisCacheProvider, "noopCacheProvider") 46 | .mockImplementation(() => expectedNoopCacheProvider); 47 | const spidStrategy = new SpidStrategy( 48 | {}, 49 | getSamlOptions, 50 | (_: express.Request, profile: Profile, done: VerifiedCallback) => { 51 | // at this point SAML authentication is successful 52 | // `done` is a passport callback that signals success 53 | done(null, profile); 54 | }, 55 | mockRedisClient 56 | ); 57 | // tslint:disable-next-line: no-string-literal 58 | expect(spidStrategy["options"]).toHaveProperty( 59 | "requestIdExpirationPeriodMs", 60 | 900000 61 | ); 62 | // tslint:disable-next-line: no-string-literal 63 | expect(spidStrategy["options"]).toHaveProperty( 64 | "cacheProvider", 65 | expectedNoopCacheProvider 66 | ); 67 | expect(mockNoopCacheProvider).toBeCalledTimes(1); 68 | // tslint:disable-next-line: no-string-literal 69 | expect(spidStrategy["extendedRedisCacheProvider"]).toBeTruthy(); 70 | }); 71 | }); 72 | 73 | describe("loadFromRemote", () => { 74 | it("should reject if the fetch of IdP metadata fails", async () => { 75 | expect(true).toBeTruthy(); 76 | }); 77 | 78 | it("should reject if the IdP metadata are fetched from a wrong path", async () => { 79 | expect(true).toBeTruthy(); 80 | }); 81 | 82 | it("should reject an error if the fetch of IdP metadata returns no useful data", async () => { 83 | expect(true).toBeTruthy(); 84 | }); 85 | 86 | it("should resolve with the fetched IdP options", async () => { 87 | expect(true).toBeTruthy(); 88 | }); 89 | }); 90 | -------------------------------------------------------------------------------- /src/strategy/saml_client.ts: -------------------------------------------------------------------------------- 1 | import * as express from "express"; 2 | import { fromNullable } from "fp-ts/lib/Option"; 3 | import { SamlConfig } from "passport-saml"; 4 | import * as PassportSaml from "passport-saml"; 5 | import { IExtendedCacheProvider } from "./redis_cache_provider"; 6 | import { 7 | PreValidateResponseDoneCallbackT, 8 | PreValidateResponseT, 9 | XmlTamperer 10 | } from "./spid"; 11 | 12 | export class CustomSamlClient extends PassportSaml.SAML { 13 | constructor( 14 | private config: SamlConfig, 15 | private extededCacheProvider: IExtendedCacheProvider, 16 | private tamperAuthorizeRequest?: XmlTamperer, 17 | private preValidateResponse?: PreValidateResponseT, 18 | private doneCb?: PreValidateResponseDoneCallbackT 19 | ) { 20 | // validateInResponseTo must be set to false to disable 21 | // internal cacheProvider of passport-saml 22 | super({ 23 | ...config, 24 | validateInResponseTo: false 25 | }); 26 | } 27 | 28 | /** 29 | * Custom version of `validatePostResponse` which checks 30 | * the response XML to satisfy SPID protocol constrains 31 | */ 32 | public validatePostResponse( 33 | body: { SAMLResponse: string }, 34 | // tslint:disable-next-line: bool-param-default 35 | callback: (err: Error, profile?: unknown, loggedOut?: boolean) => void 36 | ): void { 37 | if (this.preValidateResponse) { 38 | return this.preValidateResponse( 39 | this.config, 40 | body, 41 | this.extededCacheProvider, 42 | this.doneCb, 43 | (err, isValid, AuthnRequestID) => { 44 | if (err) { 45 | return callback(err); 46 | } 47 | // go on with checks in case no error is found 48 | return super.validatePostResponse(body, (error, __, ___) => { 49 | if (!error && isValid && AuthnRequestID) { 50 | // tslint:disable-next-line: no-floating-promises 51 | this.extededCacheProvider 52 | .remove(AuthnRequestID) 53 | .map(_ => callback(error, __, ___)) 54 | .mapLeft(callback) 55 | .run(); 56 | } else { 57 | callback(error, __, ___); 58 | } 59 | }); 60 | } 61 | ); 62 | } 63 | super.validatePostResponse(body, callback); 64 | } 65 | 66 | /** 67 | * Custom version of `generateAuthorizeRequest` which tampers 68 | * the generated XML to satisfy SPID protocol constrains 69 | */ 70 | public generateAuthorizeRequest( 71 | req: express.Request, 72 | isPassive: boolean, 73 | callback: (err: Error, xml?: string) => void 74 | ): void { 75 | const newCallback = fromNullable(this.tamperAuthorizeRequest) 76 | .map(tamperAuthorizeRequest => (e: Error, xml?: string) => { 77 | xml 78 | ? tamperAuthorizeRequest(xml) 79 | .chain(tamperedXml => 80 | this.extededCacheProvider.save(tamperedXml, this.config) 81 | ) 82 | .mapLeft(error => callback(error)) 83 | .map(cache => 84 | callback((null as unknown) as Error, cache.RequestXML) 85 | ) 86 | .run() 87 | : callback(e); 88 | }) 89 | .getOrElse(callback); 90 | super.generateAuthorizeRequest(req, isPassive, newCallback); 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/utils/__tests__/metadata.test.ts: -------------------------------------------------------------------------------- 1 | import { isLeft, isRight, left } from "fp-ts/lib/Either"; 2 | import { NonEmptyArray } from "fp-ts/lib/NonEmptyArray"; 3 | import * as nock from "nock"; 4 | import { CIE_IDP_IDENTIFIERS, SPID_IDP_IDENTIFIERS } from "../../config"; 5 | import cieIdpMetadata from "../__mocks__/cie-idp-metadata"; 6 | import idpsMetadata from "../__mocks__/idps-metatata"; 7 | import testenvIdpMetadata from "../__mocks__/testenv-idp-metadata"; 8 | import { fetchIdpsMetadata } from "../metadata"; 9 | 10 | const mockedIdpsRegistryHost = "https://mocked.registry.net"; 11 | const expectedTestenvEntityId = "https://spid-testenv.dev.io.italia.it"; 12 | 13 | describe("fetchIdpsMetadata", () => { 14 | it("should reject if the IdP metadata are fetched from a wrong path", async () => { 15 | const notExistingPath = "/not-existing-path"; 16 | nock(mockedIdpsRegistryHost) 17 | .get(notExistingPath) 18 | .reply(404); 19 | const result = await fetchIdpsMetadata( 20 | mockedIdpsRegistryHost + notExistingPath, 21 | SPID_IDP_IDENTIFIERS 22 | ).run(); 23 | expect(isLeft(result)).toBeTruthy(); 24 | expect(result.value).toEqual(expect.any(Error)); 25 | }); 26 | 27 | it("should reject an error if the fetch of IdP metadata returns no useful data", async () => { 28 | const wrongIdpMetadataPath = "/wrong-path"; 29 | nock(mockedIdpsRegistryHost) 30 | .get(wrongIdpMetadataPath) 31 | .reply(200, { property: "same value" }); 32 | const result = await fetchIdpsMetadata( 33 | mockedIdpsRegistryHost + wrongIdpMetadataPath, 34 | SPID_IDP_IDENTIFIERS 35 | ).run(); 36 | expect(isLeft(result)).toBeTruthy(); 37 | expect(result.value).toEqual(expect.any(Error)); 38 | }); 39 | 40 | it("should reject an error if the fetch of IdP metadata returns an unparsable response", async () => { 41 | const wrongIdpMetadataPath = "/wrong-path"; 42 | nock(mockedIdpsRegistryHost) 43 | .get(wrongIdpMetadataPath) 44 | .reply(200, undefined); 45 | const result = await fetchIdpsMetadata( 46 | mockedIdpsRegistryHost + wrongIdpMetadataPath, 47 | SPID_IDP_IDENTIFIERS 48 | ).run(); 49 | expect(isLeft(result)).toBeTruthy(); 50 | expect(result.value).toEqual(expect.any(Error)); 51 | }); 52 | 53 | it("should resolve with the fetched IdP options", async () => { 54 | const validIdpMetadataPath = "/correct-path"; 55 | nock(mockedIdpsRegistryHost) 56 | .get(validIdpMetadataPath) 57 | .reply(200, idpsMetadata); 58 | const result = await fetchIdpsMetadata( 59 | mockedIdpsRegistryHost + validIdpMetadataPath, 60 | SPID_IDP_IDENTIFIERS 61 | ).run(); 62 | expect(isRight(result)).toBeTruthy(); 63 | }); 64 | 65 | it("should resolve with the fetched CIE IdP options", async () => { 66 | const validCieMetadataPath = "/mocked-cie-path"; 67 | nock(mockedIdpsRegistryHost) 68 | .get(validCieMetadataPath) 69 | .reply(200, cieIdpMetadata); 70 | const result = await fetchIdpsMetadata( 71 | mockedIdpsRegistryHost + validCieMetadataPath, 72 | CIE_IDP_IDENTIFIERS 73 | ).run(); 74 | expect(isRight(result)).toBeTruthy(); 75 | expect(result.value).toHaveProperty("xx_servizicie_test", { 76 | cert: expect.any(NonEmptyArray), 77 | entityID: 78 | "https://preproduzione.idserver.servizicie.interno.gov.it/idp/profile/SAML2/POST/SSO", 79 | entryPoint: 80 | "https://preproduzione.idserver.servizicie.interno.gov.it/idp/profile/SAML2/Redirect/SSO", 81 | logoutUrl: 82 | "https://preproduzione.idserver.servizicie.interno.gov.it/idp/profile/SAML2/Redirect/SLO" 83 | }); 84 | }); 85 | 86 | it("should resolve with the fetched TestEnv IdP options", async () => { 87 | const validTestenvMetadataPath = "/mocked-testenv-path"; 88 | nock(mockedIdpsRegistryHost) 89 | .get(validTestenvMetadataPath) 90 | .reply(200, testenvIdpMetadata); 91 | const result = await fetchIdpsMetadata( 92 | mockedIdpsRegistryHost + validTestenvMetadataPath, 93 | { 94 | [expectedTestenvEntityId]: "xx_testenv2" 95 | } 96 | ).run(); 97 | expect(isRight(result)).toBeTruthy(); 98 | expect(result.value).toHaveProperty("xx_testenv2", { 99 | cert: expect.any(NonEmptyArray), 100 | entityID: expectedTestenvEntityId, 101 | entryPoint: "https://spid-testenv.dev.io.italia.it/sso", 102 | logoutUrl: "https://spid-testenv.dev.io.italia.it/slo" 103 | }); 104 | }); 105 | }); 106 | -------------------------------------------------------------------------------- /src/strategy/redis_cache_provider.ts: -------------------------------------------------------------------------------- 1 | import { fromOption, parseJSON, toError } from "fp-ts/lib/Either"; 2 | import { fromNullable } from "fp-ts/lib/Option"; 3 | import { fromEither, TaskEither, taskify } from "fp-ts/lib/TaskEither"; 4 | import * as t from "io-ts"; 5 | import { UTCISODateFromString } from "italia-ts-commons/lib/dates"; 6 | import { readableReport } from "italia-ts-commons/lib/reporters"; 7 | import { Second } from "italia-ts-commons/lib/units"; 8 | import { CacheProvider, SamlConfig } from "passport-saml"; 9 | import * as redis from "redis"; 10 | import { getIDFromRequest } from "../utils/saml"; 11 | 12 | export type SAMLRequestCacheItem = t.TypeOf; 13 | const SAMLRequestCacheItem = t.interface({ 14 | RequestXML: t.string, 15 | createdAt: UTCISODateFromString, 16 | idpIssuer: t.string 17 | }); 18 | 19 | export interface IExtendedCacheProvider { 20 | save: ( 21 | RequestXML: string, 22 | samlConfig: SamlConfig 23 | ) => TaskEither; 24 | get: (AuthnRequestID: string) => TaskEither; 25 | remove: (AuthnRequestID: string) => TaskEither; 26 | } 27 | 28 | // those methods must never fail since there's 29 | // practically no error handling in passport-saml 30 | // (a very bad lot of spaghetti code) 31 | export const noopCacheProvider = (): CacheProvider => { 32 | return { 33 | // saves the key with the optional value 34 | // invokes the callback with the value saved 35 | save(_, value, callback): void { 36 | const v = { 37 | createdAt: new Date(), 38 | value 39 | }; 40 | callback(null, v); 41 | }, 42 | // invokes 'callback' and passes the value if found, null otherwise 43 | get(_, callback): void { 44 | callback(null, {}); 45 | }, 46 | // removes the key from the cache, invokes `callback` with the 47 | // key removed, null if no key is removed 48 | remove(key, callback): void { 49 | callback(null, key); 50 | } 51 | }; 52 | }; 53 | 54 | export const getExtendedRedisCacheProvider = ( 55 | redisClient: redis.RedisClient, 56 | // 1 hour by default 57 | keyExpirationPeriodSeconds: Second = 3600 as Second, 58 | keyPrefix: string = "SAML-EXT-" 59 | ): IExtendedCacheProvider => { 60 | return { 61 | save( 62 | RequestXML: string, 63 | samlConfig: SamlConfig 64 | ): TaskEither { 65 | return fromEither( 66 | fromOption( 67 | new Error(`SAML#ExtendedRedisCacheProvider: missing AuthnRequest ID`) 68 | )(getIDFromRequest(RequestXML)) 69 | ) 70 | .chain(AuthnRequestID => 71 | fromEither( 72 | fromOption(new Error("Missing idpIssuer inside configuration"))( 73 | fromNullable(samlConfig.idpIssuer) 74 | ) 75 | ).map(idpIssuer => ({ idpIssuer, AuthnRequestID })) 76 | ) 77 | .chain(_ => { 78 | const v: SAMLRequestCacheItem = { 79 | RequestXML, 80 | createdAt: new Date(), 81 | idpIssuer: _.idpIssuer 82 | }; 83 | return taskify( 84 | ( 85 | key: string, 86 | data: string, 87 | flag: "EX", 88 | expiration: number, 89 | callback: (err: Error | null, value: unknown) => void 90 | ) => redisClient.set(key, data, flag, expiration, callback) 91 | )( 92 | `${keyPrefix}${_.AuthnRequestID}`, 93 | JSON.stringify(v), 94 | "EX", 95 | keyExpirationPeriodSeconds 96 | ) 97 | .mapLeft( 98 | err => 99 | new Error(`SAML#ExtendedRedisCacheProvider: set() error ${err}`) 100 | ) 101 | .map(() => v); 102 | }); 103 | }, 104 | get(AuthnRequestID: string): TaskEither { 105 | return taskify( 106 | (key: string, callback: (e: Error | null, value?: string) => void) => { 107 | redisClient.get(key, callback); 108 | } 109 | )(`${keyPrefix}${AuthnRequestID}`) 110 | .mapLeft( 111 | err => 112 | new Error(`SAML#ExtendedRedisCacheProvider: get() error ${err}`) 113 | ) 114 | .chain(value => 115 | fromEither( 116 | parseJSON(value, toError).chain(_ => 117 | SAMLRequestCacheItem.decode(_).mapLeft( 118 | __ => 119 | new Error( 120 | `SAML#ExtendedRedisCacheProvider: get() error ${readableReport( 121 | __ 122 | )}` 123 | ) 124 | ) 125 | ) 126 | ) 127 | ); 128 | }, 129 | remove(AuthnRequestID): TaskEither { 130 | return taskify( 131 | (key: string, callback: (err: Error | null, value?: unknown) => void) => 132 | redisClient.del(key, callback) 133 | )(`${keyPrefix}${AuthnRequestID}`) 134 | .mapLeft( 135 | err => 136 | new Error(`SAML#ExtendedRedisCacheProvider: remove() error ${err}`) 137 | ) 138 | .map(() => AuthnRequestID); 139 | } 140 | }; 141 | }; 142 | -------------------------------------------------------------------------------- /src/utils/__mocks__/testenv-idp-metadata.ts: -------------------------------------------------------------------------------- 1 | export default ` 2 | 3 | 4 | 5 | 6 | MIIC7TCCAdWgAwIBAgIJAMbxPOoBth1LMA0GCSqGSIb3DQEBCwUAMA0xCzAJBgNV 7 | BAYTAklUMB4XDTE4MDkwNDE0MDAxM1oXDTE4MTAwNDE0MDAxM1owDTELMAkGA1UE 8 | BhMCSVQwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDJrW3y8Zd2jESP 9 | XGMRY04cHC4Qfo3302HEY1C6x1aDfW7aR/tXzNplfdw8ZtZugSSmHZBxVrR8aA08 10 | dUVbbtUw5qD0uAWKIeREqGfhM+J1STAMSI2/ZxA6t2fLmv8l1eRd1QGeRDm7yF9E 11 | EKGY9iUZD3LJf2mWdVBAzzYlG23M769k+9JuGZxuviNWMjojgYRiQFgzypUJJQz+ 12 | Ihh3q7LMjjiQiiULVb9vnJg7UdU9Wf3xGRkxk6uiGP9SzWigSObUekYYQ4ZAI/sp 13 | ILywgDxVMMtv/eVniUFKLABtljn5cE9zltECahPbm7wIuMJpDDu5GYHGdYO0j+K7 14 | fhjvF2mzAgMBAAGjUDBOMB0GA1UdDgQWBBQEVmzA/L1/fd70ok+6xtDRF8A3HjAf 15 | BgNVHSMEGDAWgBQEVmzA/L1/fd70ok+6xtDRF8A3HjAMBgNVHRMEBTADAQH/MA0G 16 | CSqGSIb3DQEBCwUAA4IBAQCRMo4M4PqS0iLTTRWfikMF4hYMapcpmuna6p8aee7C 17 | wTjS5y7y18RLvKTi9l8OI0dVkgokH8fq8/o13vMw4feGxro1hMeUilRtH52funrW 18 | C+FgPrqk3o/8cZOnq+CqnFFDfILLiEb/PVJMddvTXgv2f9O6u17f8GmMLzde1yvY 19 | Da1fG/Pi0fG2F0yw/CmtP8OTLSvxjPtJ+ZckGzZa9GotwHsoVJ+Od21OU2lOeCnO 20 | jJOAbewHgqwkCB4O4AT5RM4ThAQtoU8QibjD1XDk/ZbEHdKcofnziDyl0V8gglP2 21 | SxpzDaPX0hm4wgHk9BOtSikb72tfOw+pNfeSrZEr6ItQ 22 | 23 | 24 | 25 | 26 | 27 | 28 | urn:oasis:names:tc:SAML:2.0:nameid-format:transient 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | `; 51 | -------------------------------------------------------------------------------- /src/strategy/spid.ts: -------------------------------------------------------------------------------- 1 | import * as express from "express"; 2 | import { TaskEither } from "fp-ts/lib/TaskEither"; 3 | import { 4 | AuthenticateOptions, 5 | AuthorizeOptions, 6 | SamlConfig, 7 | VerifyWithoutRequest, 8 | VerifyWithRequest 9 | } from "passport-saml"; 10 | import { Strategy as SamlStrategy } from "passport-saml"; 11 | import { RedisClient } from "redis"; 12 | 13 | // tslint:disable-next-line: no-submodule-imports 14 | import { MultiSamlConfig } from "passport-saml/multiSamlStrategy"; 15 | 16 | import { Second } from "italia-ts-commons/lib/units"; 17 | import { DoneCallbackT } from ".."; 18 | import { 19 | getExtendedRedisCacheProvider, 20 | IExtendedCacheProvider, 21 | noopCacheProvider 22 | } from "./redis_cache_provider"; 23 | import { CustomSamlClient } from "./saml_client"; 24 | 25 | export type XmlTamperer = (xml: string) => TaskEither; 26 | 27 | export type PreValidateResponseDoneCallbackT = ( 28 | request: string, 29 | response: string 30 | ) => void; 31 | 32 | export type PreValidateResponseT = ( 33 | samlConfig: SamlConfig, 34 | body: unknown, 35 | extendedRedisCacheProvider: IExtendedCacheProvider, 36 | doneCb: PreValidateResponseDoneCallbackT | undefined, 37 | // tslint:disable-next-line: bool-param-default 38 | callback: ( 39 | err: Error | null, 40 | // tslint:disable-next-line: bool-param-default 41 | isValid?: boolean, 42 | InResponseTo?: string 43 | ) => void 44 | ) => void; 45 | 46 | export class SpidStrategy extends SamlStrategy { 47 | private extendedRedisCacheProvider: IExtendedCacheProvider; 48 | 49 | constructor( 50 | private options: SamlConfig, 51 | private getSamlOptions: MultiSamlConfig["getSamlOptions"], 52 | verify: VerifyWithRequest | VerifyWithoutRequest, 53 | private redisClient: RedisClient, 54 | private tamperAuthorizeRequest?: XmlTamperer, 55 | private tamperMetadata?: XmlTamperer, 56 | private preValidateResponse?: PreValidateResponseT, 57 | private doneCb?: DoneCallbackT 58 | ) { 59 | super(options, verify); 60 | if (!options.requestIdExpirationPeriodMs) { 61 | // 15 minutes 62 | options.requestIdExpirationPeriodMs = 15 * 60 * 1000; 63 | } 64 | 65 | // use our custom cache provider 66 | this.extendedRedisCacheProvider = getExtendedRedisCacheProvider( 67 | this.redisClient, 68 | Math.floor(options.requestIdExpirationPeriodMs / 1000) as Second 69 | ); 70 | 71 | // bypass passport-saml cache provider 72 | options.cacheProvider = noopCacheProvider(); 73 | } 74 | 75 | public authenticate( 76 | req: express.Request, 77 | options: AuthenticateOptions | AuthorizeOptions 78 | ): void { 79 | this.getSamlOptions(req, (err, samlOptions) => { 80 | if (err) { 81 | return this.error(err); 82 | } 83 | const samlService = new CustomSamlClient( 84 | { 85 | ...this.options, 86 | ...samlOptions 87 | }, 88 | this.extendedRedisCacheProvider, 89 | this.tamperAuthorizeRequest, 90 | this.preValidateResponse, 91 | (...args) => (this.doneCb ? this.doneCb(req.ip, ...args) : undefined) 92 | ); 93 | // we clone the original strategy to avoid race conditions 94 | // see https://github.com/bergie/passport-saml/pull/426/files 95 | const strategy = Object.setPrototypeOf( 96 | { 97 | ...this, 98 | _saml: samlService 99 | }, 100 | this 101 | ); 102 | super.authenticate.call(strategy, req, options); 103 | }); 104 | } 105 | 106 | public logout( 107 | req: express.Request, 108 | callback: (err: Error | null, url?: string) => void 109 | ): void { 110 | this.getSamlOptions(req, (err, samlOptions) => { 111 | if (err) { 112 | return this.error(err); 113 | } 114 | const samlService = new CustomSamlClient( 115 | { 116 | ...this.options, 117 | ...samlOptions 118 | }, 119 | this.extendedRedisCacheProvider 120 | ); 121 | // we clone the original strategy to avoid race conditions 122 | // see https://github.com/bergie/passport-saml/pull/426/files 123 | const strategy = Object.setPrototypeOf( 124 | { 125 | ...this, 126 | _saml: samlService 127 | }, 128 | this 129 | ); 130 | super.logout.call(strategy, req, callback); 131 | }); 132 | } 133 | 134 | public generateServiceProviderMetadataAsync( 135 | req: express.Request, 136 | decryptionCert: string | null, 137 | signingCert: string | null, 138 | callback: (err: Error | null, metadata?: string) => void 139 | ): void { 140 | return this.getSamlOptions(req, (err, samlOptions) => { 141 | if (err) { 142 | return this.error(err); 143 | } 144 | const samlService = new CustomSamlClient( 145 | { 146 | ...this.options, 147 | ...samlOptions 148 | }, 149 | this.extendedRedisCacheProvider 150 | ); 151 | 152 | // we clone the original strategy to avoid race conditions 153 | // see https://github.com/bergie/passport-saml/pull/426/files 154 | const strategy = Object.setPrototypeOf( 155 | { 156 | ...this, 157 | _saml: samlService 158 | }, 159 | this 160 | ); 161 | 162 | const originalXml = super.generateServiceProviderMetadata.call( 163 | strategy, 164 | decryptionCert, 165 | signingCert 166 | ); 167 | 168 | return this.tamperMetadata 169 | ? // Tamper the generated XML for service provider metadata 170 | this.tamperMetadata(originalXml) 171 | .fold( 172 | e => callback(e), 173 | tamperedXml => callback(null, tamperedXml) 174 | ) 175 | .run() 176 | : callback(null, originalXml); 177 | }); 178 | } 179 | } 180 | -------------------------------------------------------------------------------- /src/utils/metadata.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Methods to fetch and parse Identity service providers metadata. 3 | */ 4 | import { 5 | Either, 6 | fromPredicate as eitherFromPredicate, 7 | right, 8 | toError 9 | } from "fp-ts/lib/Either"; 10 | import { StrMap } from "fp-ts/lib/StrMap"; 11 | import { 12 | fromEither, 13 | fromPredicate, 14 | TaskEither, 15 | tryCatch 16 | } from "fp-ts/lib/TaskEither"; 17 | import { errorsToReadableMessages } from "italia-ts-commons/lib/reporters"; 18 | import nodeFetch from "node-fetch"; 19 | import { DOMParser } from "xmldom"; 20 | import { CIE_IDP_IDENTIFIERS, SPID_IDP_IDENTIFIERS } from "../config"; 21 | import { IDPEntityDescriptor } from "../types/IDPEntityDescriptor"; 22 | import { logger } from "./logger"; 23 | 24 | const EntityDescriptorTAG = "EntityDescriptor"; 25 | const X509CertificateTAG = "X509Certificate"; 26 | const SingleSignOnServiceTAG = "SingleSignOnService"; 27 | const SingleLogoutServiceTAG = "SingleLogoutService"; 28 | 29 | const METADATA_NAMESPACES = { 30 | METADATA: "urn:oasis:names:tc:SAML:2.0:metadata", 31 | XMLDSIG: "http://www.w3.org/2000/09/xmldsig#" 32 | }; 33 | 34 | /** 35 | * Parse a string that represents an XML file containing 36 | * the ipd Metadata and converts it into an array of IDPEntityDescriptor 37 | * 38 | * Required namespace definitions into the XML are 39 | * xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata" and xmlns:ds="http://www.w3.org/2000/09/xmldsig#" 40 | * 41 | * An example file is provided in /test_idps/spid-entities-idps.xml of this project. 42 | */ 43 | export function parseIdpMetadata( 44 | ipdMetadataPage: string 45 | ): Either> { 46 | return right( 47 | new DOMParser().parseFromString(ipdMetadataPage) 48 | ) 49 | .chain( 50 | eitherFromPredicate( 51 | domParser => 52 | domParser && !domParser.getElementsByTagName("parsererror").item(0), 53 | () => new Error("XML parser error") 54 | ) 55 | ) 56 | .chain(domParser => { 57 | const entityDescriptors = domParser.getElementsByTagNameNS( 58 | METADATA_NAMESPACES.METADATA, 59 | EntityDescriptorTAG 60 | ); 61 | return right( 62 | Array.from(entityDescriptors).reduce( 63 | (idps: ReadonlyArray, element: Element) => { 64 | const certs = Array.from( 65 | element.getElementsByTagNameNS( 66 | METADATA_NAMESPACES.XMLDSIG, 67 | X509CertificateTAG 68 | ) 69 | ).map(_ => 70 | _.textContent ? _.textContent.replace(/[\n\s]/g, "") : "" 71 | ); 72 | return IDPEntityDescriptor.decode({ 73 | cert: certs, 74 | entityID: element.getAttribute("entityID"), 75 | entryPoint: Array.from( 76 | element.getElementsByTagNameNS( 77 | METADATA_NAMESPACES.METADATA, 78 | SingleSignOnServiceTAG 79 | ) 80 | ) 81 | .filter( 82 | _ => 83 | _.getAttribute("Binding") === 84 | "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" 85 | )[0] 86 | ?.getAttribute("Location"), 87 | logoutUrl: 88 | Array.from( 89 | element.getElementsByTagNameNS( 90 | METADATA_NAMESPACES.METADATA, 91 | SingleLogoutServiceTAG 92 | ) 93 | ) 94 | .filter( 95 | _ => 96 | _.getAttribute("Binding") === 97 | "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" 98 | )[0] 99 | // If SingleLogoutService is missing will be return an empty string 100 | // Needed for CIE Metadata 101 | ?.getAttribute("Location") || "" 102 | }).fold( 103 | errs => { 104 | logger.warn( 105 | "Invalid md:EntityDescriptor. %s", 106 | errorsToReadableMessages(errs).join(" / ") 107 | ); 108 | return idps; 109 | }, 110 | elementInfo => [...idps, elementInfo] 111 | ); 112 | }, 113 | [] 114 | ) 115 | ); 116 | }); 117 | } 118 | 119 | /** 120 | * Map provided idpMetadata into an object with idp key whitelisted in ipdIds. 121 | * Mapping is based on entityID property 122 | */ 123 | export const mapIpdMetadata = ( 124 | idpMetadata: ReadonlyArray, 125 | idpIds: Record 126 | ): Record => 127 | idpMetadata.reduce>((prev, idp) => { 128 | const idpKey = idpIds[idp.entityID]; 129 | if (idpKey) { 130 | return { ...prev, [idpKey]: idp }; 131 | } 132 | logger.warn( 133 | `Unsupported SPID idp from metadata repository [${idp.entityID}]` 134 | ); 135 | return prev; 136 | }, {}); 137 | 138 | /** 139 | * Fetch an XML from a remote URL 140 | */ 141 | export function fetchMetadataXML( 142 | idpMetadataUrl: string 143 | ): TaskEither { 144 | return tryCatch(() => { 145 | logger.info("Fetching SPID metadata from [%s]...", idpMetadataUrl); 146 | return nodeFetch(idpMetadataUrl); 147 | }, toError) 148 | .chain( 149 | fromPredicate( 150 | p => p.status >= 200 && p.status < 300, 151 | () => { 152 | logger.warn("Error fetching remote metadata for %s", idpMetadataUrl); 153 | return new Error("Error fetching remote metadata"); 154 | } 155 | ) 156 | ) 157 | .chain(p => tryCatch(() => p.text(), toError)); 158 | } 159 | 160 | /** 161 | * Load idp Metadata from a remote url, parse infos and return a mapped and whitelisted idp options 162 | * for spidStrategy object. 163 | */ 164 | export function fetchIdpsMetadata( 165 | idpMetadataUrl: string, 166 | idpIds: Record 167 | ): TaskEither> { 168 | return fetchMetadataXML(idpMetadataUrl) 169 | .chain(idpMetadataXML => { 170 | logger.info("Parsing SPID metadata for %s", idpMetadataUrl); 171 | return fromEither(parseIdpMetadata(idpMetadataXML)); 172 | }) 173 | .chain( 174 | fromPredicate( 175 | idpMetadata => idpMetadata.length > 0, 176 | () => { 177 | logger.error("No SPID metadata found for %s", idpMetadataUrl); 178 | return new Error("No SPID metadata found"); 179 | } 180 | ) 181 | ) 182 | .map(idpMetadata => { 183 | if (!idpMetadata.length) { 184 | logger.warn("Missing SPID metadata on %s", idpMetadataUrl); 185 | } 186 | logger.info("Configuring IdPs for %s", idpMetadataUrl); 187 | return mapIpdMetadata(idpMetadata, idpIds); 188 | }); 189 | } 190 | 191 | /** 192 | * This method expects in input a Record where key are idp identifier 193 | * and values are an XML string (idp metadata). 194 | * Provided metadata are parsed and converted into IDP Entity Descriptor objects. 195 | */ 196 | export function parseStartupIdpsMetadata( 197 | idpsMetadata: Record 198 | ): Record { 199 | return mapIpdMetadata( 200 | new StrMap(idpsMetadata).reduce( 201 | [] as ReadonlyArray, 202 | (prev, metadataXML) => [ 203 | ...prev, 204 | ...parseIdpMetadata(metadataXML).getOrElse([]) 205 | ] 206 | ), 207 | { ...SPID_IDP_IDENTIFIERS, ...CIE_IDP_IDENTIFIERS } // TODO: Add TestEnv IDP identifier 208 | ); 209 | } 210 | -------------------------------------------------------------------------------- /src/utils/middleware.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * SPID Passport strategy 3 | */ 4 | import * as express from "express"; 5 | import { array } from "fp-ts/lib/Array"; 6 | import { Task, task } from "fp-ts/lib/Task"; 7 | import { NonEmptyString } from "italia-ts-commons/lib/strings"; 8 | import { Profile, SamlConfig, VerifiedCallback } from "passport-saml"; 9 | import { RedisClient } from "redis"; 10 | import { DoneCallbackT } from ".."; 11 | import { CIE_IDP_IDENTIFIERS, SPID_IDP_IDENTIFIERS } from "../config"; 12 | import { 13 | PreValidateResponseT, 14 | SpidStrategy, 15 | XmlTamperer 16 | } from "../strategy/spid"; 17 | import { IDPEntityDescriptor } from "../types/IDPEntityDescriptor"; 18 | import { fetchIdpsMetadata } from "./metadata"; 19 | import { logSamlCertExpiration, SamlAttributeT } from "./saml"; 20 | 21 | interface IServiceProviderOrganization { 22 | URL: string; 23 | displayName: string; 24 | name: string; 25 | } 26 | 27 | interface IServiceProviderContactPersonOther { 28 | vatNumber: string; 29 | fiscalCode: string; 30 | emailAddress: string; 31 | telephoneNumber: string; 32 | } 33 | 34 | interface IServiceProviderContactPersonBilling { 35 | IVAIdPaese: string; 36 | IVAIdCodice: string; 37 | IVADenominazione: string; 38 | sedeIndirizzo: string; 39 | sedeNumeroCivico: string; 40 | sedeCap: string; 41 | sedeComune: string; 42 | sedeProvincia: string; 43 | sedeNazione: string; 44 | company: string; 45 | emailAddress: string; 46 | telephoneNumber: string; 47 | } 48 | 49 | export interface IServiceProviderConfig { 50 | requiredAttributes: { 51 | attributes: ReadonlyArray; 52 | name: string; 53 | }; 54 | spidCieUrl?: string; 55 | spidTestEnvUrl?: string; 56 | spidValidatorUrl?: string; 57 | IDPMetadataUrl: string; 58 | organization: IServiceProviderOrganization; 59 | contactPersonOther: IServiceProviderContactPersonOther; 60 | contactPersonBilling: IServiceProviderContactPersonBilling; 61 | publicCert: string; 62 | strictResponseValidation?: StrictResponseValidationOptions; 63 | } 64 | 65 | export type StrictResponseValidationOptions = Record< 66 | string, 67 | boolean | undefined 68 | >; 69 | 70 | export interface ISpidStrategyOptions { 71 | idp: { [key: string]: IDPEntityDescriptor | undefined }; 72 | // tslint:disable-next-line: no-any 73 | sp: SamlConfig & { 74 | attributes: { 75 | attributes: { 76 | attributes: ReadonlyArray; 77 | name: string; 78 | }; 79 | name: string; 80 | }; 81 | } & { 82 | organization: IServiceProviderOrganization; 83 | }; 84 | } 85 | 86 | /** 87 | * This method create a Spid Strategy Options object 88 | * extending the provided SamlOption with the service provider configuration 89 | * and the idps Options 90 | */ 91 | export function makeSpidStrategyOptions( 92 | samlConfig: SamlConfig, 93 | serviceProviderConfig: IServiceProviderConfig, 94 | idpOptionsRecord: Record 95 | ): ISpidStrategyOptions { 96 | return { 97 | idp: idpOptionsRecord, 98 | sp: { 99 | ...samlConfig, 100 | attributes: { 101 | attributes: serviceProviderConfig.requiredAttributes, 102 | name: "Required attributes" 103 | }, 104 | identifierFormat: "urn:oasis:names:tc:SAML:2.0:nameid-format:transient", 105 | organization: serviceProviderConfig.organization, 106 | signatureAlgorithm: "sha256" 107 | } 108 | }; 109 | } 110 | 111 | /** 112 | * Merge strategy configuration with metadata from IDP. 113 | * 114 | * This is used to pass options to the SAML client 115 | * so it can discriminate between the IDP certificates. 116 | */ 117 | export const getSpidStrategyOptionsUpdater = ( 118 | samlConfig: SamlConfig, 119 | serviceProviderConfig: IServiceProviderConfig 120 | ): (() => Task) => () => { 121 | const idpOptionsTasks = [ 122 | fetchIdpsMetadata( 123 | serviceProviderConfig.IDPMetadataUrl, 124 | SPID_IDP_IDENTIFIERS 125 | ).getOrElse({}) 126 | ] 127 | .concat( 128 | NonEmptyString.is(serviceProviderConfig.spidValidatorUrl) 129 | ? [ 130 | fetchIdpsMetadata( 131 | `${serviceProviderConfig.spidValidatorUrl}/metadata.xml`, 132 | { 133 | // "https://validator.spid.gov.it" or "http://localhost:8080" 134 | [serviceProviderConfig.spidValidatorUrl]: "xx_validator" 135 | } 136 | ).getOrElse({}) 137 | ] 138 | : [] 139 | ) 140 | .concat( 141 | NonEmptyString.is(serviceProviderConfig.spidCieUrl) 142 | ? [ 143 | fetchIdpsMetadata( 144 | serviceProviderConfig.spidCieUrl, 145 | CIE_IDP_IDENTIFIERS 146 | ).getOrElse({}) 147 | ] 148 | : [] 149 | ) 150 | .concat( 151 | NonEmptyString.is(serviceProviderConfig.spidTestEnvUrl) 152 | ? [ 153 | fetchIdpsMetadata( 154 | `${serviceProviderConfig.spidTestEnvUrl}/metadata`, 155 | { 156 | [serviceProviderConfig.spidTestEnvUrl]: "xx_testenv2" 157 | } 158 | ).getOrElse({}) 159 | ] 160 | : [] 161 | ); 162 | return array 163 | .sequence(task)(idpOptionsTasks) 164 | .map(idpOptionsRecords => 165 | idpOptionsRecords.reduce((prev, current) => ({ ...prev, ...current }), {}) 166 | ) 167 | .map(idpOptionsRecord => { 168 | logSamlCertExpiration(serviceProviderConfig.publicCert); 169 | return makeSpidStrategyOptions( 170 | samlConfig, 171 | serviceProviderConfig, 172 | idpOptionsRecord 173 | ); 174 | }); 175 | }; 176 | 177 | const SPID_STRATEGY_OPTIONS_KEY = "spidStrategyOptions"; 178 | 179 | /** 180 | * SPID strategy calls getSamlOptions() for every 181 | * SAML request. It extracts the options from a 182 | * shared variable set into the express app. 183 | */ 184 | export const getSpidStrategyOption = ( 185 | app: express.Application 186 | ): ISpidStrategyOptions | undefined => { 187 | return app.get(SPID_STRATEGY_OPTIONS_KEY); 188 | }; 189 | 190 | /** 191 | * This method is called to set or update Spid Strategy Options. 192 | * A selective update is performed to replace only new configurations provided, 193 | * keeping the others already stored inside the express app. 194 | */ 195 | export const upsertSpidStrategyOption = ( 196 | app: express.Application, 197 | newSpidStrategyOpts: ISpidStrategyOptions 198 | ) => { 199 | const spidStrategyOptions = getSpidStrategyOption(app); 200 | app.set( 201 | SPID_STRATEGY_OPTIONS_KEY, 202 | spidStrategyOptions 203 | ? { 204 | idp: { 205 | ...spidStrategyOptions.idp, 206 | ...newSpidStrategyOpts.idp 207 | }, 208 | sp: newSpidStrategyOpts.sp 209 | } 210 | : newSpidStrategyOpts 211 | ); 212 | }; 213 | 214 | /** 215 | * SPID strategy factory function. 216 | */ 217 | export function makeSpidStrategy( 218 | options: ISpidStrategyOptions, 219 | getSamlOptions: SpidStrategy["getSamlOptions"], 220 | redisClient: RedisClient, 221 | tamperAuthorizeRequest?: XmlTamperer, 222 | tamperMetadata?: XmlTamperer, 223 | preValidateResponse?: PreValidateResponseT, 224 | doneCb?: DoneCallbackT 225 | ): SpidStrategy { 226 | return new SpidStrategy( 227 | { ...options, passReqToCallback: true }, 228 | getSamlOptions, 229 | (_: express.Request, profile: Profile, done: VerifiedCallback) => { 230 | // at this point SAML authentication is successful 231 | // `done` is a passport callback that signals success 232 | done(null, profile); 233 | }, 234 | redisClient, 235 | tamperAuthorizeRequest, 236 | tamperMetadata, 237 | preValidateResponse, 238 | doneCb 239 | ); 240 | } 241 | -------------------------------------------------------------------------------- /src/example.ts: -------------------------------------------------------------------------------- 1 | import * as bodyParser from "body-parser"; 2 | import * as express from "express"; 3 | import * as fs from "fs"; 4 | import * as https from "https"; 5 | import * as t from "io-ts"; 6 | import { ResponsePermanentRedirect } from "italia-ts-commons/lib/responses"; 7 | import { 8 | EmailString, 9 | FiscalCode, 10 | NonEmptyString 11 | } from "italia-ts-commons/lib/strings"; 12 | import passport = require("passport"); 13 | import { SamlConfig } from "passport-saml"; 14 | import * as redis from "redis"; 15 | import { 16 | AssertionConsumerServiceT, 17 | IApplicationConfig, 18 | LogoutT, 19 | withSpid 20 | } from "."; 21 | import { IServiceProviderConfig } from "./utils/middleware"; 22 | import { SamlAttributeT } from "./utils/saml"; 23 | 24 | export const SpidUser = t.intersection([ 25 | t.interface({ 26 | // the following values may be set 27 | // by the calling application: 28 | // authnContextClassRef: SpidLevel, 29 | // issuer: Issuer 30 | getAssertionXml: t.Function 31 | }), 32 | t.partial({ 33 | email: EmailString, 34 | familyName: t.string, 35 | fiscalNumber: FiscalCode, 36 | mobilePhone: NonEmptyString, 37 | name: t.string, 38 | nameID: t.string, 39 | nameIDFormat: t.string, 40 | sessionIndex: t.string 41 | }) 42 | ]); 43 | 44 | export type SpidUser = t.TypeOf; 45 | 46 | const appConfig: IApplicationConfig = { 47 | assertionConsumerServicePath: process.env.ENDPOINT_ACS, 48 | clientErrorRedirectionUrl: process.env.ENDPOINT_ERROR, 49 | clientLoginRedirectionUrl: process.env.ENDPOINT_ERROR, 50 | loginPath: process.env.ENDPOINT_LOGIN, 51 | metadataPath: process.env.ENDPOINT_METADATA, 52 | sloPath: process.env.ENDPOINT_LOGOUT 53 | }; 54 | 55 | /* tslint:disable:object-literal-sort-keys */ 56 | const serviceProviderConfig: IServiceProviderConfig = { 57 | IDPMetadataUrl: 58 | "https://registry.spid.gov.it/metadata/idp/spid-entities-idps.xml", // default - contiene tutti gli identity providers di produzione 59 | // "https://idp.spid.gov.it/metadata", // xml contenente l'identity provider di test governativo 60 | // "https://www.spid-validator.it/metadata.xml", // spid validator url 61 | organization: { 62 | URL: process.env.ORG_URL, 63 | displayName: process.env.ORG_DISPLAY_NAME, 64 | name: process.env.ORG_NAME 65 | }, 66 | contactPersonOther: { 67 | vatNumber: process.env.CONTACT_PERSON_OTHER_VAT_NUMBER, 68 | fiscalCode: process.env.CONTACT_PERSON_OTHER_FISCAL_CODE, 69 | emailAddress: process.env.CONTACT_PERSON_OTHER_EMAIL_ADDRESS, 70 | telephoneNumber: process.env.CONTACT_PERSON_OTHER_TELEPHONE_NUMBER 71 | }, 72 | contactPersonBilling: { 73 | IVAIdPaese: process.env.CONTACT_PERSON_BILLING_IVA_IDPAESE, 74 | IVAIdCodice: process.env.CONTACT_PERSON_BILLING_IVA_IDCODICE, 75 | IVADenominazione: process.env.CONTACT_PERSON_BILLING_IVA_DENOMINAZIONE, 76 | sedeIndirizzo: process.env.CONTACT_PERSON_BILLING_SEDE_INDIRIZZO, 77 | sedeNumeroCivico: process.env.CONTACT_PERSON_BILLING_SEDE_NUMEROCIVICO, 78 | sedeCap: process.env.CONTACT_PERSON_BILLING_SEDE_CAP, 79 | sedeComune: process.env.CONTACT_PERSON_BILLING_SEDE_COMUNE, 80 | sedeProvincia: process.env.CONTACT_PERSON_BILLING_SEDE_PROVINCIA, 81 | sedeNazione: process.env.CONTACT_PERSON_BILLING_SEDE_NAZIONE, 82 | company: process.env.CONTACT_PERSON_BILLING_COMPANY, 83 | emailAddress: process.env.CONTACT_PERSON_BILLING_EMAIL_ADDRESS, 84 | telephoneNumber: process.env.CONTACT_PERSON_BILLING_TELEPHONE_NUMBER 85 | }, 86 | publicCert: fs.readFileSync(process.env.METADATA_PUBLIC_CERT, "utf-8"), 87 | requiredAttributes: { 88 | attributes: process.env.SPID_ATTRIBUTES?.split(",").map( 89 | item => item as SamlAttributeT 90 | ), 91 | name: "Required attrs" 92 | }, 93 | spidCieUrl: 94 | "https://preproduzione.idserver.servizicie.interno.gov.it/idp/shibboleth?Metadata", 95 | spidTestEnvUrl: process.env.SPID_TESTENV_URL, 96 | spidValidatorUrl: process.env.SPID_VALIDATOR_URL, 97 | strictResponseValidation: { 98 | [process.env.SPID_VALIDATOR_URL]: true, 99 | [process.env.SPID_TESTENV_URL]: true 100 | } 101 | }; 102 | /* tslint:enable:object-literal-sort-keys */ 103 | 104 | const redisClient = redis.createClient({ 105 | host: "redis" 106 | }); 107 | 108 | const samlConfig: SamlConfig = { 109 | RACComparison: "minimum", 110 | acceptedClockSkewMs: 0, 111 | attributeConsumingServiceIndex: "0", 112 | authnContext: process.env.AUTH_N_CONTEXT, 113 | callbackUrl: `${process.env.ORG_URL}${process.env.ENDPOINT_ACS}`, 114 | // decryptionPvk: fs.readFileSync("./certs/key.pem", "utf-8"), 115 | identifierFormat: "urn:oasis:names:tc:SAML:2.0:nameid-format:transient", 116 | issuer: process.env.ORG_ISSUER, 117 | logoutCallbackUrl: `${process.env.ORG_URL}/slo`, 118 | privateCert: fs.readFileSync(process.env.METADATA_PRIVATE_CERT, "utf-8"), 119 | validateInResponseTo: true 120 | }; 121 | 122 | const acs: AssertionConsumerServiceT = async _ => { 123 | return ResponsePermanentRedirect({ 124 | href: `${process.env.ENDPOINT_SUCCESS}?acs` 125 | }); 126 | }; 127 | 128 | const logout: LogoutT = async () => 129 | ResponsePermanentRedirect({ 130 | href: `${process.env.ENDPOINT_SUCCESS}?logout` 131 | }); 132 | 133 | const app = express(); 134 | 135 | const serverOptions = 136 | process.env.USE_HTTPS === "true" 137 | ? { 138 | cert: fs.readFileSync(process.env.HTTPS_CRT), 139 | key: fs.readFileSync(process.env.HTTPS_KEY) 140 | } 141 | : {}; 142 | 143 | app.use(bodyParser.json()); 144 | app.use(bodyParser.urlencoded({ extended: true })); 145 | app.use(passport.initialize()); 146 | 147 | // Create a Proxy to forward local calls to spid validator container 148 | const proxyApp = express(); 149 | proxyApp.get("*", (req, res) => { 150 | // tslint:disable-next-line:no-console 151 | console.log( 152 | "############### REDIRECT from " + req.path + " to spid-saml-check" 153 | ); 154 | res.redirect("http://spid-saml-check:8080" + req.path); 155 | }); 156 | proxyApp.listen(8080); 157 | 158 | const doneCb = (ip: string | null, request: string, response: string) => { 159 | // tslint:disable-next-line: no-console 160 | console.log(`*************** Callback done: ${ip}`); 161 | // tslint:disable-next-line: no-console 162 | console.log(`request: ${request}`); 163 | // tslint:disable-next-line: no-console 164 | console.log(`response: ${response}`); 165 | }; 166 | 167 | withSpid({ 168 | acs, 169 | app, 170 | appConfig, 171 | doneCb, 172 | logout, 173 | redisClient, 174 | samlConfig, 175 | serviceProviderConfig 176 | }) 177 | .map(({ app: withSpidApp, idpMetadataRefresher }) => { 178 | withSpidApp.get("/success", (_, res) => { 179 | // tslint:disable-next-line:no-console 180 | console.log("example.ts: /success reply with JSON success"); 181 | res.json({ 182 | success: "success" 183 | }); 184 | }); 185 | withSpidApp.get("/error", (_, res) => { 186 | // tslint:disable-next-line:no-console 187 | console.log("example.ts: /error reply with JSON error"); 188 | res.status(500).send({ 189 | error: "error" 190 | }); 191 | }); 192 | withSpidApp.get("/refresh", async (_, res) => { 193 | // tslint:disable-next-line:no-console 194 | console.log( 195 | "example.ts: /refresh reply with JSON metadataUpdate: completed" 196 | ); 197 | await idpMetadataRefresher().run(); 198 | res.json({ 199 | metadataUpdate: "completed" 200 | }); 201 | }); 202 | withSpidApp.use( 203 | ( 204 | error: Error, 205 | _: express.Request, 206 | res: express.Response, 207 | ___: express.NextFunction 208 | ) => 209 | res.status(505).send({ 210 | error: error.message 211 | }) 212 | ); 213 | if (process.env.USE_HTTPS === "true") { 214 | https.createServer(serverOptions, withSpidApp).listen(3000, () => { 215 | // tslint:disable-next-line:no-console 216 | console.log("Server ready on 3000 port in HTTPS"); 217 | }); 218 | } else { 219 | withSpidApp.listen(3000, () => { 220 | // tslint:disable-next-line:no-console 221 | console.log("Server ready on 3000 port in HTTP"); 222 | }); 223 | } 224 | }) 225 | .run() 226 | // tslint:disable-next-line: no-console 227 | .catch(e => console.error("Application error: ", e)); 228 | -------------------------------------------------------------------------------- /src/utils/__tests__/middleware.test.ts: -------------------------------------------------------------------------------- 1 | // tslint:disable-next-line: ordered-imports 2 | import { left, right } from "fp-ts/lib/Either"; 3 | import { fromEither } from "fp-ts/lib/TaskEither"; 4 | import { SamlConfig } from "passport-saml"; 5 | import { CIE_IDP_IDENTIFIERS, SPID_IDP_IDENTIFIERS } from "../../config"; 6 | import { IDPEntityDescriptor } from "../../types/IDPEntityDescriptor"; 7 | import * as metadata from "../metadata"; 8 | import { 9 | getSpidStrategyOptionsUpdater, 10 | IServiceProviderConfig 11 | } from "../middleware"; 12 | 13 | import { 14 | mockCIEIdpMetadata, 15 | mockIdpMetadata, 16 | mockTestenvIdpMetadata 17 | } from "../../__mocks__/metadata"; 18 | 19 | const mockFetchIdpsMetadata = jest.spyOn(metadata, "fetchIdpsMetadata"); 20 | 21 | const idpMetadataUrl = "http://ipd.metadata.example/metadata.xml"; 22 | const cieMetadataUrl = 23 | "https://preproduzione.idserver.servizicie.interno.gov.it/idp/shibboleth?Metadata"; 24 | const spidTestEnvUrl = "https://spid-testenv2:8088"; 25 | 26 | const serviceProviderConfig: IServiceProviderConfig = { 27 | IDPMetadataUrl: idpMetadataUrl, 28 | organization: { 29 | URL: "https://example.com", 30 | displayName: "Organization display name", 31 | name: "Organization name" 32 | }, 33 | contactPersonOther:{ 34 | vatNumber: process.env.CONTACT_PERSON_OTHER_VAT_NUMBER, 35 | fiscalCode: process.env.CONTACT_PERSON_OTHER_FISCAL_CODE, 36 | emailAddress: process.env.CONTACT_PERSON_OTHER_EMAIL_ADDRESS, 37 | telephoneNumber: process.env.CONTACT_PERSON_OTHER_TELEPHONE_NUMBER, 38 | }, 39 | contactPersonBilling:{ 40 | IVAIdPaese: process.env.CONTACT_PERSON_BILLING_IVA_IDPAESE, 41 | IVAIdCodice: process.env.CONTACT_PERSON_BILLING_IVA_IDCODICE, 42 | IVADenominazione: process.env.CONTACT_PERSON_BILLING_IVA_DENOMINAZIONE, 43 | sedeIndirizzo: process.env.CONTACT_PERSON_BILLING_SEDE_INDIRIZZO, 44 | sedeNumeroCivico: process.env.CONTACT_PERSON_BILLING_SEDE_NUMEROCIVICO, 45 | sedeCap: process.env.CONTACT_PERSON_BILLING_SEDE_CAP, 46 | sedeComune: process.env.CONTACT_PERSON_BILLING_SEDE_COMUNE, 47 | sedeProvincia: process.env.CONTACT_PERSON_BILLING_SEDE_PROVINCIA, 48 | sedeNazione: process.env.CONTACT_PERSON_BILLING_SEDE_NAZIONE, 49 | company: process.env.CONTACT_PERSON_BILLING_COMPANY, 50 | emailAddress: process.env.CONTACT_PERSON_BILLING_EMAIL_ADDRESS, 51 | telephoneNumber: process.env.CONTACT_PERSON_BILLING_TELEPHONE_NUMBER, 52 | }, 53 | publicCert: "", 54 | requiredAttributes: { 55 | attributes: [ 56 | "address", 57 | "email", 58 | "name", 59 | "familyName", 60 | "fiscalNumber", 61 | "mobilePhone" 62 | ], 63 | name: "Required attrs" 64 | }, 65 | spidCieUrl: cieMetadataUrl, 66 | spidTestEnvUrl 67 | }; 68 | const expectedSamlConfig: SamlConfig = { 69 | callbackUrl: "http://localhost:3000/callback", 70 | entryPoint: "http://localhost:3000/acs", 71 | forceAuthn: true 72 | }; 73 | 74 | describe("getSpidStrategyOptionsUpdater", () => { 75 | const expectedSPProperty = { 76 | ...expectedSamlConfig, 77 | attributes: { 78 | attributes: serviceProviderConfig.requiredAttributes, 79 | name: "Required attributes" 80 | }, 81 | identifierFormat: "urn:oasis:names:tc:SAML:2.0:nameid-format:transient", 82 | organization: serviceProviderConfig.organization, 83 | signatureAlgorithm: "sha256" 84 | }; 85 | 86 | beforeEach(() => { 87 | jest.resetAllMocks(); 88 | }); 89 | afterAll(() => { 90 | jest.restoreAllMocks(); 91 | }); 92 | it("should returns updated spid options from remote idps metadata", async () => { 93 | mockFetchIdpsMetadata.mockImplementationOnce(() => { 94 | return fromEither( 95 | right>(mockIdpMetadata) 96 | ); 97 | }); 98 | mockFetchIdpsMetadata.mockImplementationOnce(() => { 99 | return fromEither( 100 | right>(mockCIEIdpMetadata) 101 | ); 102 | }); 103 | mockFetchIdpsMetadata.mockImplementationOnce(() => { 104 | return fromEither( 105 | right>( 106 | mockTestenvIdpMetadata 107 | ) 108 | ); 109 | }); 110 | 111 | const updatedSpidStrategyOption = await getSpidStrategyOptionsUpdater( 112 | expectedSamlConfig, 113 | serviceProviderConfig 114 | )().run(); 115 | expect(mockFetchIdpsMetadata).toBeCalledTimes(3); 116 | expect(mockFetchIdpsMetadata).toHaveBeenNthCalledWith( 117 | 1, 118 | idpMetadataUrl, 119 | SPID_IDP_IDENTIFIERS 120 | ); 121 | expect(mockFetchIdpsMetadata).toHaveBeenNthCalledWith( 122 | 2, 123 | cieMetadataUrl, 124 | CIE_IDP_IDENTIFIERS 125 | ); 126 | expect(mockFetchIdpsMetadata).toHaveBeenNthCalledWith( 127 | 3, 128 | `${spidTestEnvUrl}/metadata`, 129 | { 130 | [spidTestEnvUrl]: "xx_testenv2" 131 | } 132 | ); 133 | expect(updatedSpidStrategyOption).toHaveProperty("sp", expectedSPProperty); 134 | expect(updatedSpidStrategyOption).toHaveProperty("idp", { 135 | ...mockIdpMetadata, 136 | ...mockCIEIdpMetadata, 137 | ...mockTestenvIdpMetadata 138 | }); 139 | }); 140 | 141 | it("should returns an error if fetch of remote idp metadata fail", async () => { 142 | const expectedFetchError = new Error("fetch Error"); 143 | mockFetchIdpsMetadata.mockImplementationOnce(() => { 144 | return fromEither( 145 | left>(expectedFetchError) 146 | ); 147 | }); 148 | // tslint:disable-next-line: no-identical-functions 149 | mockFetchIdpsMetadata.mockImplementationOnce(() => { 150 | return fromEither( 151 | right>(mockCIEIdpMetadata) 152 | ); 153 | }); 154 | // tslint:disable-next-line: no-identical-functions 155 | mockFetchIdpsMetadata.mockImplementationOnce(() => { 156 | return fromEither( 157 | right>( 158 | mockTestenvIdpMetadata 159 | ) 160 | ); 161 | }); 162 | const updatedSpidStrategyOption = await getSpidStrategyOptionsUpdater( 163 | expectedSamlConfig, 164 | serviceProviderConfig 165 | )().run(); 166 | expect(mockFetchIdpsMetadata).toBeCalledTimes(3); 167 | expect(mockFetchIdpsMetadata).toHaveBeenNthCalledWith( 168 | 1, 169 | idpMetadataUrl, 170 | SPID_IDP_IDENTIFIERS 171 | ); 172 | expect(mockFetchIdpsMetadata).toHaveBeenNthCalledWith( 173 | 2, 174 | cieMetadataUrl, 175 | CIE_IDP_IDENTIFIERS 176 | ); 177 | expect(mockFetchIdpsMetadata).toHaveBeenNthCalledWith( 178 | 3, 179 | `${spidTestEnvUrl}/metadata`, 180 | { 181 | [spidTestEnvUrl]: "xx_testenv2" 182 | } 183 | ); 184 | expect(updatedSpidStrategyOption).toHaveProperty("sp", expectedSPProperty); 185 | expect(updatedSpidStrategyOption).toHaveProperty("idp", { 186 | ...mockCIEIdpMetadata, 187 | ...mockTestenvIdpMetadata 188 | }); 189 | }); 190 | 191 | it("should call fetchIdpsMetadata only one time if are missing CIE and TestEnv urls", async () => { 192 | const serviceProviderConfigWithoutOptional: IServiceProviderConfig = { 193 | IDPMetadataUrl: idpMetadataUrl, 194 | organization: { 195 | URL: "https://example.com", 196 | displayName: "Organization display name", 197 | name: "Organization name" 198 | }, 199 | contactPersonOther:{ 200 | vatNumber: process.env.CONTACT_PERSON_OTHER_VAT_NUMBER, 201 | fiscalCode: process.env.CONTACT_PERSON_OTHER_FISCAL_CODE, 202 | emailAddress: process.env.CONTACT_PERSON_OTHER_EMAIL_ADDRESS, 203 | telephoneNumber: process.env.CONTACT_PERSON_OTHER_TELEPHONE_NUMBER, 204 | }, 205 | contactPersonBilling:{ 206 | IVAIdPaese: process.env.CONTACT_PERSON_BILLING_IVA_IDPAESE, 207 | IVAIdCodice: process.env.CONTACT_PERSON_BILLING_IVA_IDCODICE, 208 | IVADenominazione: process.env.CONTACT_PERSON_BILLING_IVA_DENOMINAZIONE, 209 | sedeIndirizzo: process.env.CONTACT_PERSON_BILLING_SEDE_INDIRIZZO, 210 | sedeNumeroCivico: process.env.CONTACT_PERSON_BILLING_SEDE_NUMEROCIVICO, 211 | sedeCap: process.env.CONTACT_PERSON_BILLING_SEDE_CAP, 212 | sedeComune: process.env.CONTACT_PERSON_BILLING_SEDE_COMUNE, 213 | sedeProvincia: process.env.CONTACT_PERSON_BILLING_SEDE_PROVINCIA, 214 | sedeNazione: process.env.CONTACT_PERSON_BILLING_SEDE_NAZIONE, 215 | company: process.env.CONTACT_PERSON_BILLING_COMPANY, 216 | emailAddress: process.env.CONTACT_PERSON_BILLING_EMAIL_ADDRESS, 217 | telephoneNumber: process.env.CONTACT_PERSON_BILLING_TELEPHONE_NUMBER, 218 | }, 219 | publicCert: "", 220 | requiredAttributes: { 221 | attributes: [ 222 | "address", 223 | "email", 224 | "name", 225 | "familyName", 226 | "fiscalNumber", 227 | "mobilePhone" 228 | ], 229 | name: "Required attrs" 230 | } 231 | }; 232 | // tslint:disable-next-line: no-identical-functions 233 | mockFetchIdpsMetadata.mockImplementationOnce(() => { 234 | return fromEither( 235 | right>(mockIdpMetadata) 236 | ); 237 | }); 238 | await getSpidStrategyOptionsUpdater( 239 | expectedSamlConfig, 240 | serviceProviderConfigWithoutOptional 241 | )().run(); 242 | expect(mockFetchIdpsMetadata).toBeCalledTimes(1); 243 | expect(mockFetchIdpsMetadata).toBeCalledWith( 244 | idpMetadataUrl, 245 | SPID_IDP_IDENTIFIERS 246 | ); 247 | }); 248 | }); 249 | -------------------------------------------------------------------------------- /src/__tests__/index.test.ts: -------------------------------------------------------------------------------- 1 | import * as express from "express"; 2 | import { left, right } from "fp-ts/lib/Either"; 3 | import { fromEither } from "fp-ts/lib/TaskEither"; 4 | import { ResponsePermanentRedirect } from "italia-ts-commons/lib/responses"; 5 | import { createMockRedis } from "mock-redis-client"; 6 | import { RedisClient } from "redis"; 7 | import { 8 | IApplicationConfig, 9 | IServiceProviderConfig, 10 | SamlConfig, 11 | withSpid 12 | } from "../"; 13 | import { IDPEntityDescriptor } from "../types/IDPEntityDescriptor"; 14 | import * as metadata from "../utils/metadata"; 15 | import { getSpidStrategyOption } from "../utils/middleware"; 16 | 17 | import { 18 | mockCIEIdpMetadata, 19 | mockIdpMetadata, 20 | mockTestenvIdpMetadata 21 | } from "../__mocks__/metadata"; 22 | 23 | const mockFetchIdpsMetadata = jest.spyOn(metadata, "fetchIdpsMetadata"); 24 | 25 | // saml configuration vars 26 | const samlCert = ` 27 | -----BEGIN CERTIFICATE----- 28 | MIIDczCCAlqgAwIBAgIBADANBgkqhkiG9w0BAQ0FADBTMQswCQYDVQQGEwJpdDEN 29 | MAsGA1UECAwEUm9tZTEUMBIGA1UECgwLYWdpZC5nb3YuaXQxHzAdBgNVBAMMFmh0 30 | dHBzOi8vaXRhbGlhLWJhY2tlbmQwHhcNMTcxMDI2MTAzNTQwWhcNMTgxMDI2MTAz 31 | NTQwWjBTMQswCQYDVQQGEwJpdDENMAsGA1UECAwEUm9tZTEUMBIGA1UECgwLYWdp 32 | ZC5nb3YuaXQxHzAdBgNVBAMMFmh0dHBzOi8vaXRhbGlhLWJhY2tlbmQwggEjMA0G 33 | CSqGSIb3DQEBAQUAA4IBEAAwggELAoIBAgCXozdOvdlQhX2zyOvnpZJZWyhjmiRq 34 | kBW7jkZHcmFRceeoVkXGn4bAFGGcqESFMVmaigTEm1c6gJpRojo75smqyWxngEk1 35 | XLctn1+Qhb5SCbd2oHh0oLE5jpHyrxfxw8V+N2Hty26GavJE7i9jORbjeQCMkbgg 36 | t0FahmlmaZr20akK8wNGMHDcpnMslJPxHl6uKxjAfe6sbNqjWxfcnirm05Jh5gYN 37 | T4vkwC1vx6AZpS2G9pxOV1q5GapuvUBqwNu+EH1ufMRRXvu0+GtJ4WtsErOakSF4 38 | KMezrMqKCrVPoK5SGxQMD/kwEQ8HfUPpim3cdi3RVmqQjsi/on6DMn/xTQIDAQAB 39 | o1AwTjAdBgNVHQ4EFgQULOauBsRgsAudzlxzwEXYXd4uPyIwHwYDVR0jBBgwFoAU 40 | LOauBsRgsAudzlxzwEXYXd4uPyIwDAYDVR0TBAUwAwEB/zANBgkqhkiG9w0BAQ0F 41 | AAOCAQIAQOT5nIiAefn8FAWiVYu2uEsHpxUQ/lKWn1Trnj7MyQW3QA/jNaJHL/Ep 42 | szJ5GONOE0lVEG1on35kQOWR7qFWYhH9Llb8EAAAb5tbnCiA+WIx4wjRTE3CNLul 43 | L8MoscacIc/rqWf5WygZQcPDX1yVxmK4F3YGG2qDTD3fr4wPweYHxn95JidTwzW8 44 | Jv46ajSBvFJ95CoCYL3BUHaxPIlYkGbJFjQhuoxo2XM4iT6KFD4IGmdssS4NFgW+ 45 | OM+P8UsrYi2KZuyzSrHq5c0GJz0UzSs8cIDC/CPEajx2Uy+7TABwR4d20Hyo6WIm 46 | IFJiDanROwzoG0YNd8aCWE8ZM2y81Ww= 47 | -----END CERTIFICATE----- 48 | `; 49 | 50 | const samlKey = ` 51 | -----BEGIN PRIVATE KEY----- 52 | MIIEwQIBADANBgkqhkiG9w0BAQEFAASCBKswggSnAgEAAoIBAgCXozdOvdlQhX2z 53 | yOvnpZJZWyhjmiRqkBW7jkZHcmFRceeoVkXGn4bAFGGcqESFMVmaigTEm1c6gJpR 54 | ojo75smqyWxngEk1XLctn1+Qhb5SCbd2oHh0oLE5jpHyrxfxw8V+N2Hty26GavJE 55 | 7i9jORbjeQCMkbggt0FahmlmaZr20akK8wNGMHDcpnMslJPxHl6uKxjAfe6sbNqj 56 | Wxfcnirm05Jh5gYNT4vkwC1vx6AZpS2G9pxOV1q5GapuvUBqwNu+EH1ufMRRXvu0 57 | +GtJ4WtsErOakSF4KMezrMqKCrVPoK5SGxQMD/kwEQ8HfUPpim3cdi3RVmqQjsi/ 58 | on6DMn/xTQIDAQABAoIBAWo61Yw8Q/m9CwrgPyPRQm2HBwx/9/MPbaovSdzTrIm6 59 | Gmg7yDYVm/kETj3JQ/drUzKIbj6t9LXvUizOUa2VSMJ0yZTYsnDHuywi8nf0uhgO 60 | 5pAca0aJLJ792hEByOx+EeUSN3C3i35vfbn8gwYoAHjrVA8mJrAEsawRbdVpNj6j 61 | IWAKTmsZK0YLdcNzWshSYW9wkJNykeXHUgKk2YzGUIacMgC+fF3v3xL82xk+eLez 62 | dP5wlrzkPz8JKHMIomF5j/VLuggSZx0XdQRvZrkeQUbJqRy2iXa43B+OlEiNvd2Q 63 | 0AiXur/MhvID+Ni/hBIoeIDyvvoBoiCTZbVvyRnBds8BAoGBDIfqHTTcfXlzfipt 64 | 4+idRuuzzhIXzQOcB0+8ywqmgtE4g9EykC7fEcynOavv08rOSNSbYhjLX24xUSrd 65 | 19lckZIvH5U9nJxnyfwrMGrorCA2uPtck8N9sTB5UWif31w/eDVMv30jRUyMel7l 66 | tp96zxcPThT1O3N4zM2Otk5q2DvFAoGBDBngF4G9dJ5a511dyYco2agMJTvJLIqn 67 | kKw24KOTqZ5BZKyIea4yCy9cRmNN84ccOy3jBuzSFLNJMYqdDCzH46u0I4anft83 68 | aqnVa4jOwjZXoV9JCdFh3zKJUgPU4CW0MaTb30n3U4BAOgkHzRFt55tGT6xRU1J+ 69 | jX5s03BFfQ/pAoGBCsRqtUfrweEvDRT2MeR56Cu153cCfoYAdwPcDHeNVlDih9mk 70 | 4eF0ib3ZXyPPQqQ8FrahAWyeq9Rqif0UfFloQiljVncNZtm6EQQeNE9YuDZB7zcF 71 | eG59PViSlhIZdXq1itv5o3yqZux8tNV/+ykUBIgi/YvioH/7J7flTd8Zzc2lAoGB 72 | CqdVNRzSETPBUGRQx7Yo7scWOkmSaZaAw8v6XHdm7zQW2m1Tkd0czeAaWxXecQKI 73 | hkl10Ij6w6K8U9N3RFrAeN6YL5bDK92VSmDPNmcxsKZrK/VZtj0S74/sebpJ1jUb 74 | mYFM2h6ikm8dHHsK1S39FqULl+VbjAHazPN7GAOGCf7RAoGBAc0J9j+MHYm4M18K 75 | AW2UB26qvdc8PSXE6i4YQAsg2RBgtf6u4jVAQ8a8OA4vnGG9VIrR0bD/hFTfczg/ 76 | ZbWGZ+42VH2eDGouiadR4rWzJNQKjWd98Y5PzxdEjd6nneJ68iNqGbKOT6jXu8qj 77 | nCnxP/vK5rgVHU3nQfq+e/B6FVWZ 78 | -----END PRIVATE KEY----- 79 | `; 80 | 81 | const spidTestEnvUrl = "https://localhost:8088"; 82 | const IDPMetadataUrl = 83 | "https://registry.spid.gov.it/metadata/idp/spid-entities-idps.xml"; 84 | const spidCieUrl = 85 | "https://preproduzione.idserver.servizicie.interno.gov.it/idp/shibboleth?Metadata"; 86 | 87 | const expectedLoginPath = "/login"; 88 | const expectedSloPath = "/logout"; 89 | const expectedAssertionConsumerServicePath = "/assertionConsumerService"; 90 | const metadataPath = "/metadata"; 91 | 92 | const appConfig: IApplicationConfig = { 93 | assertionConsumerServicePath: expectedAssertionConsumerServicePath, 94 | clientErrorRedirectionUrl: "/error", 95 | clientLoginRedirectionUrl: "/success", 96 | loginPath: expectedLoginPath, 97 | metadataPath, 98 | sloPath: expectedSloPath 99 | }; 100 | 101 | const samlConfig: SamlConfig = { 102 | RACComparison: "minimum", 103 | acceptedClockSkewMs: 0, 104 | attributeConsumingServiceIndex: "0", 105 | authnContext: "https://www.spid.gov.it/SpidL1", 106 | callbackUrl: "http://localhost:3000" + appConfig.assertionConsumerServicePath, 107 | // decryptionPvk: fs.readFileSync("./certs/key.pem", "utf-8"), 108 | identifierFormat: "urn:oasis:names:tc:SAML:2.0:nameid-format:transient", 109 | issuer: "https://spid.agid.gov.it/cd", 110 | logoutCallbackUrl: "http://localhost:3000/slo", 111 | privateCert: samlKey, 112 | validateInResponseTo: true 113 | }; 114 | 115 | const serviceProviderConfig: IServiceProviderConfig = { 116 | IDPMetadataUrl, 117 | organization: { 118 | URL: "https://example.com", 119 | displayName: "Organization display name", 120 | name: "Organization name" 121 | }, 122 | contactPersonOther:{ 123 | vatNumber: process.env.CONTACT_PERSON_OTHER_VAT_NUMBER, 124 | fiscalCode: process.env.CONTACT_PERSON_OTHER_FISCAL_CODE, 125 | emailAddress: process.env.CONTACT_PERSON_OTHER_EMAIL_ADDRESS, 126 | telephoneNumber: process.env.CONTACT_PERSON_OTHER_TELEPHONE_NUMBER, 127 | }, 128 | contactPersonBilling:{ 129 | IVAIdPaese: process.env.CONTACT_PERSON_BILLING_IVA_IDPAESE, 130 | IVAIdCodice: process.env.CONTACT_PERSON_BILLING_IVA_IDCODICE, 131 | IVADenominazione: process.env.CONTACT_PERSON_BILLING_IVA_DENOMINAZIONE, 132 | sedeIndirizzo: process.env.CONTACT_PERSON_BILLING_SEDE_INDIRIZZO, 133 | sedeNumeroCivico: process.env.CONTACT_PERSON_BILLING_SEDE_NUMEROCIVICO, 134 | sedeCap: process.env.CONTACT_PERSON_BILLING_SEDE_CAP, 135 | sedeComune: process.env.CONTACT_PERSON_BILLING_SEDE_COMUNE, 136 | sedeProvincia: process.env.CONTACT_PERSON_BILLING_SEDE_PROVINCIA, 137 | sedeNazione: process.env.CONTACT_PERSON_BILLING_SEDE_NAZIONE, 138 | company: process.env.CONTACT_PERSON_BILLING_COMPANY, 139 | emailAddress: process.env.CONTACT_PERSON_BILLING_EMAIL_ADDRESS, 140 | telephoneNumber: process.env.CONTACT_PERSON_BILLING_TELEPHONE_NUMBER, 141 | }, 142 | publicCert: samlCert, 143 | requiredAttributes: { 144 | attributes: [ 145 | "address", 146 | "email", 147 | "name", 148 | "familyName", 149 | "fiscalNumber", 150 | "mobilePhone" 151 | ], 152 | name: "Required attrs" 153 | }, 154 | spidCieUrl, 155 | spidTestEnvUrl, 156 | strictResponseValidation: { 157 | "http://localhost:8080": true 158 | } 159 | }; 160 | 161 | // tslint:disable-next-line: no-any 162 | const mockRedisClient: RedisClient = (createMockRedis() as any).createClient(); 163 | 164 | describe("spid-express withSpid", () => { 165 | it("shoud idpMetadataRefresher refresh idps metadata from remote url", async () => { 166 | const app = express(); 167 | mockFetchIdpsMetadata.mockImplementation(() => 168 | fromEither( 169 | left>(new Error("Error.")) 170 | ) 171 | ); 172 | const spid = await withSpid({ 173 | appConfig, 174 | samlConfig, 175 | serviceProviderConfig, 176 | // tslint:disable-next-line: object-literal-sort-keys 177 | redisClient: mockRedisClient, 178 | app, 179 | acs: async () => ResponsePermanentRedirect({ href: "/success?acs" }), 180 | logout: async () => ResponsePermanentRedirect({ href: "/success?logout" }) 181 | }).run(); 182 | expect(mockFetchIdpsMetadata).toBeCalledTimes(3); 183 | const emptySpidStrategyOption = getSpidStrategyOption(spid.app); 184 | expect(emptySpidStrategyOption).toHaveProperty("idp", {}); 185 | 186 | jest.resetAllMocks(); 187 | 188 | mockFetchIdpsMetadata.mockImplementationOnce(() => { 189 | return fromEither( 190 | right>(mockIdpMetadata) 191 | ); 192 | }); 193 | mockFetchIdpsMetadata.mockImplementationOnce(() => { 194 | return fromEither( 195 | right>(mockCIEIdpMetadata) 196 | ); 197 | }); 198 | mockFetchIdpsMetadata.mockImplementationOnce(() => { 199 | return fromEither( 200 | right>( 201 | mockTestenvIdpMetadata 202 | ) 203 | ); 204 | }); 205 | await spid.idpMetadataRefresher().run(); 206 | expect(mockFetchIdpsMetadata).toHaveBeenNthCalledWith( 207 | 1, 208 | IDPMetadataUrl, 209 | expect.any(Object) 210 | ); 211 | expect(mockFetchIdpsMetadata).toHaveBeenNthCalledWith( 212 | 2, 213 | spidCieUrl, 214 | expect.any(Object) 215 | ); 216 | expect(mockFetchIdpsMetadata).toHaveBeenNthCalledWith( 217 | 3, 218 | `${spidTestEnvUrl}/metadata`, 219 | expect.any(Object) 220 | ); 221 | const spidStrategyOption = getSpidStrategyOption(spid.app); 222 | expect(spidStrategyOption).toHaveProperty("idp", { 223 | ...mockIdpMetadata, 224 | ...mockCIEIdpMetadata, 225 | ...mockTestenvIdpMetadata 226 | }); 227 | }); 228 | }); 229 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Exports a decorator function that applies 3 | * a SPID authentication middleware to an express application. 4 | * 5 | * Setups the endpoint to generate service provider metadata 6 | * and a scheduled process to refresh IDP metadata from providers. 7 | */ 8 | import * as express from "express"; 9 | import { constVoid } from "fp-ts/lib/function"; 10 | import { fromNullable } from "fp-ts/lib/Option"; 11 | import { Task, task } from "fp-ts/lib/Task"; 12 | import { toExpressHandler } from "italia-ts-commons/lib/express"; 13 | import { 14 | IResponseErrorForbiddenNotAuthorized, 15 | IResponseErrorInternal, 16 | IResponseErrorValidation, 17 | IResponsePermanentRedirect, 18 | IResponseSuccessXml, 19 | ResponseErrorInternal, 20 | ResponseSuccessXml 21 | } from "italia-ts-commons/lib/responses"; 22 | import * as passport from "passport"; 23 | import { SamlConfig } from "passport-saml"; 24 | import { RedisClient } from "redis"; 25 | import { Builder } from "xml2js"; 26 | import { noopCacheProvider } from "./strategy/redis_cache_provider"; 27 | import { logger } from "./utils/logger"; 28 | import { parseStartupIdpsMetadata } from "./utils/metadata"; 29 | import { 30 | getSpidStrategyOptionsUpdater, 31 | IServiceProviderConfig, 32 | makeSpidStrategy, 33 | makeSpidStrategyOptions, 34 | upsertSpidStrategyOption 35 | } from "./utils/middleware"; 36 | import { middlewareCatchAsInternalError } from "./utils/response"; 37 | import { 38 | getAuthorizeRequestTamperer, 39 | getErrorCodeFromResponse, 40 | getPreValidateResponse, 41 | getSamlIssuer, 42 | getSamlOptions, 43 | getXmlFromSamlResponse 44 | } from "./utils/saml"; 45 | import { getMetadataTamperer } from "./utils/saml"; 46 | 47 | // assertion consumer service express handler 48 | export type AssertionConsumerServiceT = ( 49 | userPayload: unknown 50 | ) => Promise< 51 | // tslint:disable-next-line: max-union-size 52 | | IResponseErrorInternal 53 | | IResponseErrorValidation 54 | | IResponsePermanentRedirect 55 | | IResponseErrorForbiddenNotAuthorized 56 | >; 57 | 58 | // logout express handler 59 | export type LogoutT = () => Promise; 60 | 61 | // invoked for each request / response 62 | // to pass SAML payload to the caller 63 | export type DoneCallbackT = ( 64 | sourceIp: string | null, 65 | request: string, 66 | response: string 67 | ) => void; 68 | 69 | export interface IEventInfo { 70 | name: string; 71 | type: "ERROR" | "INFO"; 72 | data: { 73 | message: string; 74 | [key: string]: string; 75 | }; 76 | } 77 | 78 | export type EventTracker = (params: IEventInfo) => void; 79 | 80 | // express endpoints configuration 81 | export interface IApplicationConfig { 82 | assertionConsumerServicePath: string; 83 | clientErrorRedirectionUrl: string; 84 | clientLoginRedirectionUrl: string; 85 | loginPath: string; 86 | metadataPath: string; 87 | sloPath: string; 88 | startupIdpsMetadata?: Record; 89 | eventTraker?: EventTracker; 90 | } 91 | 92 | // re-export 93 | export { noopCacheProvider, IServiceProviderConfig, SamlConfig }; 94 | 95 | /** 96 | * Wraps assertion consumer service handler 97 | * with SPID authentication and redirects. 98 | */ 99 | const withSpidAuthMiddleware = ( 100 | acs: AssertionConsumerServiceT, 101 | clientLoginRedirectionUrl: string, 102 | clientErrorRedirectionUrl: string 103 | ): (( 104 | req: express.Request, 105 | res: express.Response, 106 | next: express.NextFunction 107 | ) => void) => { 108 | return ( 109 | req: express.Request, 110 | res: express.Response, 111 | next: express.NextFunction 112 | ) => { 113 | passport.authenticate("spid", async (err, user) => { 114 | const maybeDoc = getXmlFromSamlResponse(req.body); 115 | const issuer = maybeDoc.chain(getSamlIssuer).getOrElse("UNKNOWN"); 116 | if (err) { 117 | const redirectionUrl = 118 | clientErrorRedirectionUrl + 119 | maybeDoc 120 | .chain(getErrorCodeFromResponse) 121 | .map(errorCode => `?errorCode=${errorCode}`) 122 | .getOrElse(`?errorMessage=${err}`); 123 | logger.error( 124 | "Spid Authentication|Authentication Error|ERROR=%s|ISSUER=%s|REDIRECT_TO=%s", 125 | err, 126 | issuer, 127 | redirectionUrl 128 | ); 129 | return res.redirect(redirectionUrl); 130 | } 131 | if (!user) { 132 | logger.error( 133 | "Spid Authentication|Authentication Error|ERROR=user_not_found|ISSUER=%s", 134 | issuer 135 | ); 136 | return res.redirect(clientLoginRedirectionUrl); 137 | } 138 | const response = await acs(user); 139 | response.apply(res); 140 | })(req, res, next); 141 | }; 142 | }; 143 | 144 | interface IWithSpidT { 145 | appConfig: IApplicationConfig; 146 | samlConfig: SamlConfig; 147 | serviceProviderConfig: IServiceProviderConfig; 148 | redisClient: RedisClient; 149 | app: express.Express; 150 | acs: AssertionConsumerServiceT; 151 | logout: LogoutT; 152 | doneCb?: DoneCallbackT; 153 | } 154 | 155 | /** 156 | * Apply SPID authentication middleware 157 | * to an express application. 158 | */ 159 | // tslint:disable-next-line: parameters-max-number 160 | export function withSpid({ 161 | acs, 162 | app, 163 | appConfig, 164 | doneCb = constVoid, 165 | logout, 166 | redisClient, 167 | samlConfig, 168 | serviceProviderConfig 169 | }: IWithSpidT): Task<{ 170 | app: express.Express; 171 | idpMetadataRefresher: () => Task; 172 | }> { 173 | const loadSpidStrategyOptions = getSpidStrategyOptionsUpdater( 174 | samlConfig, 175 | serviceProviderConfig 176 | ); 177 | 178 | const metadataTamperer = getMetadataTamperer( 179 | new Builder(), 180 | serviceProviderConfig, 181 | samlConfig 182 | ); 183 | const authorizeRequestTamperer = getAuthorizeRequestTamperer( 184 | // spid-testenv does not accept an xml header with utf8 encoding 185 | new Builder({ xmldec: { encoding: undefined, version: "1.0" } }), 186 | serviceProviderConfig, 187 | samlConfig 188 | ); 189 | 190 | const maybeStartupIdpsMetadata = fromNullable(appConfig.startupIdpsMetadata); 191 | // If `startupIdpsMetadata` is provided, IDP metadata 192 | // are initially taken from its value when the backend starts 193 | return maybeStartupIdpsMetadata 194 | .map(parseStartupIdpsMetadata) 195 | .map(idpOptionsRecord => 196 | task.of( 197 | makeSpidStrategyOptions( 198 | samlConfig, 199 | serviceProviderConfig, 200 | idpOptionsRecord 201 | ) 202 | ) 203 | ) 204 | .getOrElse(loadSpidStrategyOptions()) 205 | .map(spidStrategyOptions => { 206 | upsertSpidStrategyOption(app, spidStrategyOptions); 207 | return makeSpidStrategy( 208 | spidStrategyOptions, 209 | getSamlOptions, 210 | redisClient, 211 | authorizeRequestTamperer, 212 | metadataTamperer, 213 | getPreValidateResponse( 214 | serviceProviderConfig.strictResponseValidation, 215 | appConfig.eventTraker 216 | ), 217 | doneCb 218 | ); 219 | }) 220 | .map(spidStrategy => { 221 | // Even when `startupIdpsMetadata` is provided, we try to load 222 | // IDP metadata from the remote registries 223 | maybeStartupIdpsMetadata.map(() => { 224 | loadSpidStrategyOptions() 225 | .map(opts => upsertSpidStrategyOption(app, opts)) 226 | .run() 227 | .catch(e => { 228 | logger.error("loadSpidStrategyOptions|error:%s", e); 229 | }); 230 | }); 231 | // Fetch IDPs metadata from remote URL and update SPID passport strategy options 232 | const idpMetadataRefresher = () => 233 | loadSpidStrategyOptions().map(opts => 234 | upsertSpidStrategyOption(app, opts) 235 | ); 236 | 237 | // Initializes SpidStrategy for passport 238 | passport.use("spid", spidStrategy); 239 | 240 | const spidAuth = passport.authenticate("spid", { 241 | session: false 242 | }); 243 | 244 | // Setup SPID login handler 245 | app.get(appConfig.loginPath, middlewareCatchAsInternalError(spidAuth)); 246 | 247 | // Setup SPID metadata handler 248 | app.get( 249 | appConfig.metadataPath, 250 | toExpressHandler( 251 | async ( 252 | req 253 | ): Promise> => 254 | new Promise(resolve => 255 | spidStrategy.generateServiceProviderMetadataAsync( 256 | req, 257 | null, // certificate used for encryption / decryption 258 | serviceProviderConfig.publicCert, 259 | (err, metadata) => { 260 | if (err || !metadata) { 261 | resolve( 262 | ResponseErrorInternal( 263 | err 264 | ? err.message 265 | : `Error generating service provider metadata ${err}.` 266 | ) 267 | ); 268 | } else { 269 | resolve(ResponseSuccessXml(metadata)); 270 | } 271 | } 272 | ) 273 | ) 274 | ) 275 | ); 276 | 277 | // Setup SPID assertion consumer service. 278 | // This endpoint is called when the SPID IDP 279 | // redirects the authenticated user to our app 280 | app.post( 281 | appConfig.assertionConsumerServicePath, 282 | middlewareCatchAsInternalError( 283 | withSpidAuthMiddleware( 284 | acs, 285 | appConfig.clientLoginRedirectionUrl, 286 | appConfig.clientErrorRedirectionUrl 287 | ) 288 | ) 289 | ); 290 | 291 | // Setup logout handler 292 | app.post(appConfig.sloPath, toExpressHandler(logout)); 293 | 294 | return { app, idpMetadataRefresher }; 295 | }); 296 | } 297 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | SPID 10 | 11 | [![License](https://img.shields.io/github/license/italia/spid-express.svg)](https://github.com/italia/spid-express/blob/master/LICENSE) 12 | [![GitHub issues](https://img.shields.io/github/issues/italia/spid-express.svg)](https://github.com/italia/spid-express/issues) 13 | [![Join the #spid-express channel](https://img.shields.io/badge/Slack%20channel-%23spid--express-blue.svg)](https://app.slack.com/client/T6C27AXE0/C7ESTJS58) 14 | [![Get invited](https://slack.developers.italia.it/badge.svg)](https://slack.developers.italia.it/) 15 | [![SPID on forum.italia.it](https://img.shields.io/badge/Forum-spid-blue.svg)](https://forum.italia.it/c/spid/5) 16 | 17 | # spid-express 18 | 19 | spid-express è un middleware per [Express](https://expressjs.com) che implementa 20 | SPID e Entra con CIE (Carta d'identità Elettronica). 21 | 22 | Puoi usare questo pacchetto per integrare SPID o CIE in un'applicazione Express. 23 | 24 | ## Requisiti 25 | 26 | * Redis (per il salvataggio delle sessioni di autenticazione) 27 | * `passport-saml 1.2.0` (versione esatta) 28 | 29 | ## Uso 30 | 31 | La funzione `withSpid()` abilita SPID su un'app Express esistente. 32 | È disponibile un esempio dell'uso in [`src/example.ts`](src/example.ts). 33 | 34 | ```js 35 | withSpid({ 36 | acs, // Funzione che riceve i dati al login dell'utente SPID 37 | app, // App Express 38 | appConfig, // Endpoint dell'app 39 | doneCb, // Callback (facoltativo) 40 | logout, // Funzione da chiamare al logout SPID 41 | redisClient, // Client Redis 42 | samlConfig, // Configurazione del middleware 43 | serviceProviderConfig // Configurazione del Service Provider 44 | }) 45 | ``` 46 | 47 | ### `acs` 48 | 49 | La funzione `acs()` (Assertion Consumer Service) riceve i dati dell'utente SPID 50 | in `userPayload` se il login è avvenuto con successo. È definita come: 51 | 52 | ```js 53 | type AssertionConsumerServiceT = ( 54 | userPayload: unknown 55 | ) => Promise< 56 | | IResponseErrorInternal 57 | | IResponseErrorValidation 58 | | IResponsePermanentRedirect 59 | | IResponseErrorForbiddenNotAuthorized 60 | > 61 | ``` 62 | 63 | `userPayload` è un oggetto le cui chiavi sono gli attributi SPID richiesti in 64 | `requiredAttributes.attributes` (nell'oggetto [`serviceProviderConfig`](#serviceProviderConfig)). Es: 65 | 66 | ```yaml 67 | { 68 | name: 'Carla' 69 | familyName: 'Rossi' 70 | fiscalNumber: 'RSSCRL32R82Y766D', 71 | email: 'foobar@example.com', 72 | ... 73 | } 74 | ``` 75 | 76 | ### `app` 77 | 78 | L'istanza dell'app Express. 79 | 80 | ### `appConfig` 81 | 82 | L'oggetto `appConfig` configura gli endpoint dell'app. Es: 83 | 84 | ```js 85 | const appConfig: IApplicationConfig = { 86 | assertionConsumerServicePath: "/acs", 87 | clientErrorRedirectionUrl: "/error", 88 | clientLoginRedirectionUrl: "/error", 89 | loginPath: "/login", 90 | metadataPath: "/metadata", 91 | sloPath: "/logout" 92 | }; 93 | ``` 94 | 95 | * **`assertionConsumerServicePath`**: L'endpoint al quale verranno POSTati i 96 | dati dell'utente dopo un login avvenuto con successo. È l'endpoint della 97 | funzione `acs()` e viene creato automaticamente. 98 | * **`clientErrorRedirectionUrl`**: URL al quale redirigere in caso di errore interno. 99 | * **`clientLoginRedirectionUrl`**: URL al quale redirigere in caso di login SPID 100 | fallito. 101 | * **`loginPath`** L'endpoint che inizia la sessione SPID. Generalmente è l'endpoint 102 | chiamato da [spid-smart-button](https://github.com/italia/spid-smart-button). 103 | Viene creato automaticamente. 104 | * **`metadataPath`**: L'endpoint del metadata. Viene creato automaticamente. 105 | * **`sloPath`**: L'endpoint per il logout SPID. La [funzione collegata](#logout) 106 | è quella passata in `withSpid()`. 107 | 108 | ### `doneCb` 109 | 110 | La funzione chiamata dopo ogni risposta SAML (facoltativa). 111 | 112 | ### `logout` 113 | 114 | La funzione da chiamare al logout di SPID, definita come: 115 | 116 | ```js 117 | type LogoutT = () => Promise 118 | ``` 119 | 120 | ### `redisClient` 121 | 122 | L'istanza di `RedisClient` per connettersi a un server Redis. 123 | 124 | ### `samlConfig` 125 | 126 | L'oggetto `samlConfig` configura il middleware. Es: 127 | 128 | ```js 129 | const samlConfig: SamlConfig = { 130 | RACComparison: "minimum", 131 | acceptedClockSkewMs: 0, 132 | attributeConsumingServiceIndex: "0", 133 | authnContext: "https://www.spid.gov.it/SpidL1", 134 | callbackUrl: "http://localhost:3000/acs", 135 | identifierFormat: "urn:oasis:names:tc:SAML:2.0:nameid-format:transient", 136 | issuer: "https://spid.agid.gov.it/cd", 137 | logoutCallbackUrl: "http://localhost:3000/slo", 138 | privateCert: fs.readFileSync("./certs/key.pem", "utf-8"), 139 | validateInResponseTo: true 140 | }; 141 | ``` 142 | 143 | * **`RACComparison`**: Impostare a "`minimum`". 144 | * **`acceptedClockSkewMs`**: Impostare a `0`. 145 | * **`attributeConsumingServiceIndex`**: Impostare all'indice degli attributi richiesti 146 | definito nel metadata. Se l'app è l'unica applicazione SPID del Service Provider, 147 | impostare a "`0`". 148 | * **`authnContext`**: Livello SPID richiesto. "`https://www.spid.gov.it/SpidL1`", 149 | "`https://www.spid.gov.it/SpidL2`" o "`https://www.spid.gov.it/SpidL3`". 150 | * **`callbackUrl`**: L'URL completo di [`assertionConsumerServicePath`](#appConfig) 151 | * **`identifierFormat`**: Impostare a "`urn:oasis:names:tc:SAML:2.0:nameid-format:transient`" 152 | * **`issuer`**: URL del Service Provider. 153 | * **`logoutCallbackUrl`**: L'URL completo di [`sloPath`](#appConfig) 154 | * **`privateCert`**: Stringa con la chiave privata del Service Provider in 155 | formato PEM. 156 | * **`validateInResponseTo`**: Impostare a `true`. 157 | 158 | ### `serviceProviderConfig` 159 | 160 | L'oggetto `serviceProviderConfig` contiene i parametri del Service Provider. Es: 161 | 162 | ```js 163 | const serviceProviderConfig: IServiceProviderConfig = { 164 | IDPMetadataUrl: 165 | "https://registry.spid.gov.it/metadata/idp/spid-entities-idps.xml", 166 | organization: { 167 | URL: "https://example.com", 168 | displayName: "Organization display name", 169 | name: "Organization name" 170 | }, 171 | publicCert: fs.readFileSync("./certs/cert.pem", "utf-8"), 172 | requiredAttributes: { 173 | attributes: [ 174 | "address", 175 | "email", 176 | "name", 177 | "familyName", 178 | "fiscalNumber", 179 | "mobilePhone" 180 | ], 181 | name: "Required attrs" 182 | }, 183 | spidCieUrl: "https://preproduzione.idserver.servizicie.interno.gov.it/idp/shibboleth?Metadata", 184 | spidTestEnvUrl: "https://spid-testenv2:8088", 185 | spidValidatorUrl: "http://localhost:8080", 186 | strictResponseValidation: { 187 | "http://localhost:8080": true, 188 | "https://spid-testenv2:8088": true 189 | } 190 | }; 191 | ``` 192 | 193 | * **`IDPMetadataUrl`**: URL dei metadata degli IdP. Impostare a "`https://registry.spid.gov.it/metadata/idp/spid-entities-idps.xml`". 194 | * **`organization`**: Oggetto con i dati del Service Provider. 195 | * **`publicCert`**: Stringa con il certificato del Service Provider in formato PEM. 196 | * **`requiredAttributes`**: La lista, in `attributes`, degli attributi richiesti 197 | (identificativi in ). 198 | * **`spidCieUrl`**: URL per l'accesso con Carta d'Identità elettronica 199 | ("Entra con CIE"). 200 | Impostare a "`https://preproduzione.idserver.servizicie.interno.gov.it/idp/shibboleth?Metadata`" 201 | per lo sviluppo. 202 | * **`spidTestEnvUrl`**: URL dell'istanza di [spid-testenv2](https://github.com/italia/spid-testenv2). 203 | Lasciare vuoto per disabilitare. 204 | * **`spidValidatorUrl`**: URL dell'istanza di [spid-saml-check](https://github.com/italia/spid-saml-check). 205 | Lasciare vuoto per disabilitare. 206 | * **`strictResponseValidation`**: Impostare come da esempio con gli URL di 207 | `spid-testenv2` e `spid-saml-check` (se abilitati). 208 | 209 | ## Avvio dell'applicazione di esempio integrata 210 | 211 | L'applicazione di esempio (`src/example.ts`) può essere lanciata con: 212 | 213 | ```shell 214 | docker-compose up 215 | ``` 216 | 217 | Dopo il messaggio `[spid-express] info: samlCert expire in 12 months`) l'app sarà 218 | pronta e in ascolto su . 219 | 220 | Iniziare la sessione SPID con una GET su 221 | [`http://localhost:3000/login?entityID=xx_testenv2`](http://localhost:3000/login?entityID=xx_testenv2). 222 | `xx_testenv2` è l'`entityID` di sviluppo che redirigerà il login a spid-testenv2. 223 | 224 | Gli `entityID` che si possono usare in produzione sono "`lepidaid`", "`infocertid`", 225 | "`sielteid`", "`namirialid`", "`timid`", "`arubaid`", "`posteid`", "`intesaid`" 226 | e "`spiditalia`" (vedere [`src/config.ts`](src/config.ts)). 227 | 228 | ### Uso di Carta di Identità Elettronica (CIE) 229 | 230 | Il middleware permette anche di interagire con gli IDP di collaudo e produzione di 231 | "Entra con CIE" dell'Istituto Poligrafico Zecca dello Stato. Non è presente al momento 232 | un ambiente di sviluppo. 233 | 234 | Per poter utilizzare l'ambiente di collaudo è necessario federarsi inviando un 235 | modulo di richiesta come specificato nel 236 | [manuale operativo CIE](https://www.cartaidentita.interno.gov.it/CIE-ManualeOperativoperifornitoridiservizi.pdf). 237 | 238 | Una volta federati, è possibile utilizzare lo stesso endpoint utilizzato per SPID 239 | per l'accesso con CIE: l'entityID è "`xx_servizicie`" in produzione e 240 | "`xx_servizicie_test`" in collaudo. 241 | 242 | # Licenza 243 | 244 | spid-express è rilasciato con [licenza MIT](LICENSE). 245 | -------------------------------------------------------------------------------- /src/strategy/__tests__/redis_cache_provider.test.ts: -------------------------------------------------------------------------------- 1 | // tslint:disable: no-object-mutation 2 | import { isRight } from "fp-ts/lib/Either"; 3 | import { createMockRedis } from "mock-redis-client"; 4 | import { SamlConfig } from "passport-saml"; 5 | import { RedisClient } from "redis"; 6 | import { 7 | getExtendedRedisCacheProvider, 8 | noopCacheProvider, 9 | SAMLRequestCacheItem 10 | } from "../redis_cache_provider"; 11 | 12 | const mockSet = jest.fn(); 13 | const mockGet = jest.fn(); 14 | const mockDel = jest.fn(); 15 | 16 | // tslint:disable-next-line: no-any 17 | const mockRedisClient: RedisClient = (createMockRedis() as any).createClient(); 18 | mockRedisClient.set = mockSet; 19 | mockRedisClient.get = mockGet; 20 | mockRedisClient.del = mockDel; 21 | 22 | const keyExpirationPeriodSeconds = 3600; 23 | const expectedRequestID = "_ab0c7302158bde147963"; 24 | const SAMLRequest = ` 25 | 29 | https://spid.agid.gov.it/cd 31 | 32 | 33 | https://www.spid.gov.it/SpidL2 34 | 35 | `; 36 | 37 | const samlConfig: SamlConfig = { 38 | idpIssuer: "http://localhost:8080/" 39 | }; 40 | 41 | describe("noopCacheProvider", () => { 42 | const mockCallback = jest.fn(); 43 | const expectedKey = "SAML-XXX"; 44 | const expectedValue = "Value"; 45 | beforeEach(() => { 46 | jest.clearAllMocks(); 47 | }); 48 | it("should save method of noopCacheProvider do noting", () => { 49 | noopCacheProvider().save(expectedKey, expectedValue, mockCallback); 50 | expect(mockCallback).toBeCalledWith(null, { 51 | createdAt: expect.any(Date), 52 | value: expectedValue 53 | }); 54 | }); 55 | it("should get method of noopCacheProvider do noting", () => { 56 | noopCacheProvider().get(expectedKey, mockCallback); 57 | expect(mockCallback).toBeCalledWith(null, {}); 58 | }); 59 | it("should remove method of noopCacheProvider do noting", () => { 60 | noopCacheProvider().remove(expectedKey, mockCallback); 61 | expect(mockCallback).toBeCalledWith(null, expectedKey); 62 | }); 63 | }); 64 | 65 | describe("getExtendedRedisCacheProvider#save", () => { 66 | beforeEach(() => { 67 | jest.clearAllMocks(); 68 | }); 69 | it("should return the saved SAML Request data", async () => { 70 | mockSet.mockImplementation((_, __, ___, ____, callback) => 71 | callback(null, "OK") 72 | ); 73 | const redisCacheProvider = getExtendedRedisCacheProvider(mockRedisClient); 74 | const cacheSAMLResponse = await redisCacheProvider 75 | .save(SAMLRequest, samlConfig) 76 | .run(); 77 | expect(mockSet.mock.calls[0][0]).toBe(`SAML-EXT-${expectedRequestID}`); 78 | expect(mockSet.mock.calls[0][1]).toEqual(expect.any(String)); 79 | expect(mockSet.mock.calls[0][2]).toBe("EX"); 80 | expect(mockSet.mock.calls[0][3]).toBe(keyExpirationPeriodSeconds); 81 | expect(isRight(cacheSAMLResponse)).toBeTruthy(); 82 | expect(cacheSAMLResponse.value).toEqual({ 83 | RequestXML: SAMLRequest, 84 | createdAt: expect.any(Date), 85 | idpIssuer: samlConfig.idpIssuer 86 | }); 87 | }); 88 | it("should return an error if save on radis fail", async () => { 89 | const expectedRedisError = new Error("saveError"); 90 | mockSet.mockImplementation((_, __, ___, ____, callback) => 91 | callback(expectedRedisError) 92 | ); 93 | const redisCacheProvider = getExtendedRedisCacheProvider(mockRedisClient); 94 | const cacheSAMLResponse = await redisCacheProvider 95 | .save(SAMLRequest, samlConfig) 96 | .run(); 97 | expect(mockSet.mock.calls[0][0]).toBe(`SAML-EXT-${expectedRequestID}`); 98 | expect(mockSet.mock.calls[0][1]).toEqual(expect.any(String)); 99 | expect(mockSet.mock.calls[0][2]).toBe("EX"); 100 | expect(mockSet.mock.calls[0][3]).toBe(keyExpirationPeriodSeconds); 101 | expect(isRight(cacheSAMLResponse)).toBeFalsy(); 102 | expect(cacheSAMLResponse.value).toEqual( 103 | new Error( 104 | `SAML#ExtendedRedisCacheProvider: set() error ${expectedRedisError}` 105 | ) 106 | ); 107 | }); 108 | it("should return an error if idpIssuer is missing inside samlConfig", async () => { 109 | const redisCacheProvider = getExtendedRedisCacheProvider(mockRedisClient); 110 | const cacheSAMLResponse = await redisCacheProvider 111 | .save(SAMLRequest, {}) 112 | .run(); 113 | expect(isRight(cacheSAMLResponse)).toBeFalsy(); 114 | expect(cacheSAMLResponse.value).toEqual( 115 | new Error("Missing idpIssuer inside configuration") 116 | ); 117 | }); 118 | it("should return an error if Request ID is missing", async () => { 119 | const SAMLRequestWithoutID = SAMLRequest.replace( 120 | `ID="${expectedRequestID}"`, 121 | "" 122 | ); 123 | const redisCacheProvider = getExtendedRedisCacheProvider(mockRedisClient); 124 | const cacheSAMLResponse = await redisCacheProvider 125 | .save(SAMLRequestWithoutID, samlConfig) 126 | .run(); 127 | expect(isRight(cacheSAMLResponse)).toBeFalsy(); 128 | expect(cacheSAMLResponse.value).toEqual( 129 | new Error(`SAML#ExtendedRedisCacheProvider: missing AuthnRequest ID`) 130 | ); 131 | }); 132 | }); 133 | 134 | describe("getExtendedRedisCacheProvider#save", () => { 135 | it("should return the saved SAML Request data if exists", async () => { 136 | const expectedRequestData: SAMLRequestCacheItem = { 137 | RequestXML: SAMLRequest, 138 | createdAt: new Date(), 139 | // tslint:disable-next-line: no-useless-cast 140 | idpIssuer: samlConfig.idpIssuer as string 141 | }; 142 | mockGet.mockImplementation((_, callback) => 143 | callback(null, JSON.stringify(expectedRequestData)) 144 | ); 145 | const redisCacheProvider = getExtendedRedisCacheProvider(mockRedisClient); 146 | const cacheSAMLResponse = await redisCacheProvider 147 | .get(expectedRequestID) 148 | .run(); 149 | expect(mockGet.mock.calls[0][0]).toBe(`SAML-EXT-${expectedRequestID}`); 150 | expect(isRight(cacheSAMLResponse)).toBeTruthy(); 151 | expect(cacheSAMLResponse.value).toEqual(expectedRequestData); 152 | }); 153 | it("should return an error if the reading process on redis fail", async () => { 154 | const expectedRedisError = new Error("readError"); 155 | mockGet.mockImplementation((_, callback) => callback(expectedRedisError)); 156 | const redisCacheProvider = getExtendedRedisCacheProvider(mockRedisClient); 157 | const cacheSAMLResponse = await redisCacheProvider 158 | .get(expectedRequestID) 159 | .run(); 160 | expect(mockGet.mock.calls[0][0]).toBe(`SAML-EXT-${expectedRequestID}`); 161 | expect(isRight(cacheSAMLResponse)).toBeFalsy(); 162 | expect(cacheSAMLResponse.value).toEqual( 163 | new Error( 164 | `SAML#ExtendedRedisCacheProvider: get() error ${expectedRedisError}` 165 | ) 166 | ); 167 | }); 168 | it("should return an error cached Request is not compliant", async () => { 169 | const invalidCachedRequestData = { 170 | RequestXML: SAMLRequest, 171 | createdAt: new Date(), 172 | idpIssuer: undefined 173 | }; 174 | mockGet.mockImplementation((_, callback) => 175 | callback(null, JSON.stringify(invalidCachedRequestData)) 176 | ); 177 | const redisCacheProvider = getExtendedRedisCacheProvider(mockRedisClient); 178 | const cacheSAMLResponse = await redisCacheProvider 179 | .get(expectedRequestID) 180 | .run(); 181 | expect(mockGet.mock.calls[0][0]).toBe(`SAML-EXT-${expectedRequestID}`); 182 | expect(isRight(cacheSAMLResponse)).toBeFalsy(); 183 | expect(cacheSAMLResponse.value).toEqual(expect.any(Error)); 184 | }); 185 | it("should return an error is the cached Request is missing", async () => { 186 | mockGet.mockImplementation((_, callback) => callback(null, null)); 187 | const redisCacheProvider = getExtendedRedisCacheProvider(mockRedisClient); 188 | const cacheSAMLResponse = await redisCacheProvider 189 | .get(expectedRequestID) 190 | .run(); 191 | expect(mockGet.mock.calls[0][0]).toBe(`SAML-EXT-${expectedRequestID}`); 192 | expect(isRight(cacheSAMLResponse)).toBeFalsy(); 193 | expect(cacheSAMLResponse.value).toEqual(expect.any(Error)); 194 | }); 195 | }); 196 | 197 | describe("getExtendedRedisCacheProvider#remove", () => { 198 | it("should return the RequestID if the deletion process succeded", async () => { 199 | mockDel.mockImplementation((_, callback) => callback(null, 1)); 200 | const redisCacheProvider = getExtendedRedisCacheProvider(mockRedisClient); 201 | const maybeRequestId = await redisCacheProvider 202 | .remove(expectedRequestID) 203 | .run(); 204 | expect(mockDel.mock.calls[0][0]).toBe(`SAML-EXT-${expectedRequestID}`); 205 | expect(isRight(maybeRequestId)).toBeTruthy(); 206 | expect(maybeRequestId.value).toBe(expectedRequestID); 207 | }); 208 | 209 | it("should return an error if the cache's deletion fail", async () => { 210 | const expectedDelRedisError = new Error("delError"); 211 | mockDel.mockImplementation((_, callback) => 212 | callback(expectedDelRedisError) 213 | ); 214 | const redisCacheProvider = getExtendedRedisCacheProvider(mockRedisClient); 215 | const maybeRequestId = await redisCacheProvider 216 | .remove(expectedRequestID) 217 | .run(); 218 | expect(mockDel.mock.calls[0][0]).toBe(`SAML-EXT-${expectedRequestID}`); 219 | expect(isRight(maybeRequestId)).toBeFalsy(); 220 | expect(maybeRequestId.value).toEqual( 221 | new Error( 222 | `SAML#ExtendedRedisCacheProvider: remove() error ${expectedDelRedisError}` 223 | ) 224 | ); 225 | }); 226 | }); 227 | -------------------------------------------------------------------------------- /src/utils/__mocks__/cie-idp-metadata.ts: -------------------------------------------------------------------------------- 1 | export default ` 2 | 8 | 9 | 10 | 11 | 12 | 13 | gov.it 14 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | MIIDdTCCAl2gAwIBAgIUU79XEfveueyClDtLkqUlSPZ2o8owDQYJKoZIhvcNAQEL 30 | BQAwLTErMCkGA1UEAwwiaWRzZXJ2ZXIuc2Vydml6aWNpZS5pbnRlcm5vLmdvdi5p 31 | dDAeFw0xODEwMTkwODM1MDVaFw0zODEwMTkwODM1MDVaMC0xKzApBgNVBAMMImlk 32 | c2VydmVyLnNlcnZpemljaWUuaW50ZXJuby5nb3YuaXQwggEiMA0GCSqGSIb3DQEB 33 | AQUAA4IBDwAwggEKAoIBAQDHraj3iOTCIILTlOzicSEuFt03kKvQDqGWRd5o7s1W 34 | 7SP2EtcTmg3xron/sbrLEL/eMUQV/Biz6J4pEGoFpMZQHGxOVypmO7Nc8pkFot7y 35 | UTApr6Ikuy4cUtbx0g5fkQLNb3upIg0Vg1jSnRXEvUCygr/9EeKCUOi/2ptmOVSL 36 | ad+dT7TiRsZTwY3FvRWcleDfyYwcIMgz5dLSNLMZqwzQZK1DzvWeD6aGtBKCYPRf 37 | tacHoESD+6bhukHZ6w95foRMJLOaBpkp+XfugFQioYvrM0AB1YQZ5DCQRhhc8jej 38 | wdY+bOB3eZ1lJY7Oannfu6XPW2fcknelyPt7PGf22rNfAgMBAAGjgYwwgYkwHQYD 39 | VR0OBBYEFK3Ah+Do3/zB9XjZ66i4biDpUEbAMGgGA1UdEQRhMF+CImlkc2VydmVy 40 | LnNlcnZpemljaWUuaW50ZXJuby5nb3YuaXSGOWh0dHBzOi8vaWRzZXJ2ZXIuc2Vy 41 | dml6aWNpZS5pbnRlcm5vLmdvdi5pdC9pZHAvc2hpYmJvbGV0aDANBgkqhkiG9w0B 42 | AQsFAAOCAQEAVtpn/s+lYVf42pAtdgJnGTaSIy8KxHeZobKNYNFEY/XTaZEt9QeV 43 | 5efUMBVVhxKTTHN0046DR96WFYXs4PJ9Fpyq6Hmy3k/oUdmHJ1c2bwWF/nZ82CwO 44 | O081Yg0GBcfPEmKLUGOBK8T55ncW+RSZadvWTyhTtQhLUtLKcWyzKB5aS3kEE5LS 45 | zR8sw3owln9P41Mz+QtL3WeNESRHW0qoQkFotYXXW6Rvh69+GyzJLxvq2qd7D1qo 46 | JgOMrarshBKKPk+ABaLYoEf/cru4e0RDIp2mD0jkGOGDkn9XUl+3ddALq/osTki6 47 | CEawkhiZEo6ABEAjEWNkH9W3/ZzvJnWo6Q== 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | MIIDdTCCAl2gAwIBAgIUegfFpjtEsLaV0IL3qBEa0u81rGkwDQYJKoZIhvcNAQEL 58 | BQAwLTErMCkGA1UEAwwiaWRzZXJ2ZXIuc2Vydml6aWNpZS5pbnRlcm5vLmdvdi5p 59 | dDAeFw0xODEwMTkwODM1MDZaFw0zODEwMTkwODM1MDZaMC0xKzApBgNVBAMMImlk 60 | c2VydmVyLnNlcnZpemljaWUuaW50ZXJuby5nb3YuaXQwggEiMA0GCSqGSIb3DQEB 61 | AQUAA4IBDwAwggEKAoIBAQCe9W63GohPUaNbsoluWsVWfmtIyAIufqpmzYS4TiBv 62 | E6l9LlDITsmShVBpiLPU4IDdvoPPBlDqgotofCnSjQxRhGky7tiy+pBObo13lN6d 63 | 03GgXNPZqZ+vKJinf8AmNe2UZ1ZbuvUtgS6+vx6P52/KNKx6YuDNmR3lLDhKZVDb 64 | 2wwR5qfsdnJIAORbJVWd8kI6GGhmrsmha7zARd0W+ueDtd/WLuAg3G7QWRocHPlP 65 | TN/dPUbKS4O0cnJx0M5UERQ12PIdy641ps6P1v2OatpfSmZp/IlDLKJj9O9V49LM 66 | nxF3VBJkTep2UQsQUc3rlelN2rYAlhURQQzRwpWO5WJvAgMBAAGjgYwwgYkwHQYD 67 | VR0OBBYEFAQDr+o8YMapC4lje9upfeiwmFdtMGgGA1UdEQRhMF+CImlkc2VydmVy 68 | LnNlcnZpemljaWUuaW50ZXJuby5nb3YuaXSGOWh0dHBzOi8vaWRzZXJ2ZXIuc2Vy 69 | dml6aWNpZS5pbnRlcm5vLmdvdi5pdC9pZHAvc2hpYmJvbGV0aDANBgkqhkiG9w0B 70 | AQsFAAOCAQEAb7gRYzTPEMQjQKiwI4/NdhzzaoKQjp2tu3UPZwsUHruyCbI+B/0k 71 | C2SaSBaAKGT66yN9bPY2Vj4FuxtYmLSZZnatydF19hSu+lExCySKt16GBJ+D5HN7 72 | OmVizRvJNE4+RF0bajpeXnMottLrcL5Ry/BivpxdnIQ9th2sMc7ev0IZtIGYCxGg 73 | c5SAJCz4zuCcNiPANHDPdoxYEQ9EV9PNAUx8q9tjAhoRRiT2ovqT+Dowqax0AVOP 74 | hRY5rA8WMccWAedO8iSSO8DTWomtoOKS9vjWrQxnsHaT8GXohC2OYgSdKsBchvjS 75 | i1RIVkrqHoSHIK2XQapkl8YmD75JjrGNNA== 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | gov.it 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | MIIDdTCCAl2gAwIBAgIUU79XEfveueyClDtLkqUlSPZ2o8owDQYJKoZIhvcNAQEL 112 | BQAwLTErMCkGA1UEAwwiaWRzZXJ2ZXIuc2Vydml6aWNpZS5pbnRlcm5vLmdvdi5p 113 | dDAeFw0xODEwMTkwODM1MDVaFw0zODEwMTkwODM1MDVaMC0xKzApBgNVBAMMImlk 114 | c2VydmVyLnNlcnZpemljaWUuaW50ZXJuby5nb3YuaXQwggEiMA0GCSqGSIb3DQEB 115 | AQUAA4IBDwAwggEKAoIBAQDHraj3iOTCIILTlOzicSEuFt03kKvQDqGWRd5o7s1W 116 | 7SP2EtcTmg3xron/sbrLEL/eMUQV/Biz6J4pEGoFpMZQHGxOVypmO7Nc8pkFot7y 117 | UTApr6Ikuy4cUtbx0g5fkQLNb3upIg0Vg1jSnRXEvUCygr/9EeKCUOi/2ptmOVSL 118 | ad+dT7TiRsZTwY3FvRWcleDfyYwcIMgz5dLSNLMZqwzQZK1DzvWeD6aGtBKCYPRf 119 | tacHoESD+6bhukHZ6w95foRMJLOaBpkp+XfugFQioYvrM0AB1YQZ5DCQRhhc8jej 120 | wdY+bOB3eZ1lJY7Oannfu6XPW2fcknelyPt7PGf22rNfAgMBAAGjgYwwgYkwHQYD 121 | VR0OBBYEFK3Ah+Do3/zB9XjZ66i4biDpUEbAMGgGA1UdEQRhMF+CImlkc2VydmVy 122 | LnNlcnZpemljaWUuaW50ZXJuby5nb3YuaXSGOWh0dHBzOi8vaWRzZXJ2ZXIuc2Vy 123 | dml6aWNpZS5pbnRlcm5vLmdvdi5pdC9pZHAvc2hpYmJvbGV0aDANBgkqhkiG9w0B 124 | AQsFAAOCAQEAVtpn/s+lYVf42pAtdgJnGTaSIy8KxHeZobKNYNFEY/XTaZEt9QeV 125 | 5efUMBVVhxKTTHN0046DR96WFYXs4PJ9Fpyq6Hmy3k/oUdmHJ1c2bwWF/nZ82CwO 126 | O081Yg0GBcfPEmKLUGOBK8T55ncW+RSZadvWTyhTtQhLUtLKcWyzKB5aS3kEE5LS 127 | zR8sw3owln9P41Mz+QtL3WeNESRHW0qoQkFotYXXW6Rvh69+GyzJLxvq2qd7D1qo 128 | JgOMrarshBKKPk+ABaLYoEf/cru4e0RDIp2mD0jkGOGDkn9XUl+3ddALq/osTki6 129 | CEawkhiZEo6ABEAjEWNkH9W3/ZzvJnWo6Q== 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | MIIDdTCCAl2gAwIBAgIUegfFpjtEsLaV0IL3qBEa0u81rGkwDQYJKoZIhvcNAQEL 140 | BQAwLTErMCkGA1UEAwwiaWRzZXJ2ZXIuc2Vydml6aWNpZS5pbnRlcm5vLmdvdi5p 141 | dDAeFw0xODEwMTkwODM1MDZaFw0zODEwMTkwODM1MDZaMC0xKzApBgNVBAMMImlk 142 | c2VydmVyLnNlcnZpemljaWUuaW50ZXJuby5nb3YuaXQwggEiMA0GCSqGSIb3DQEB 143 | AQUAA4IBDwAwggEKAoIBAQCe9W63GohPUaNbsoluWsVWfmtIyAIufqpmzYS4TiBv 144 | E6l9LlDITsmShVBpiLPU4IDdvoPPBlDqgotofCnSjQxRhGky7tiy+pBObo13lN6d 145 | 03GgXNPZqZ+vKJinf8AmNe2UZ1ZbuvUtgS6+vx6P52/KNKx6YuDNmR3lLDhKZVDb 146 | 2wwR5qfsdnJIAORbJVWd8kI6GGhmrsmha7zARd0W+ueDtd/WLuAg3G7QWRocHPlP 147 | TN/dPUbKS4O0cnJx0M5UERQ12PIdy641ps6P1v2OatpfSmZp/IlDLKJj9O9V49LM 148 | nxF3VBJkTep2UQsQUc3rlelN2rYAlhURQQzRwpWO5WJvAgMBAAGjgYwwgYkwHQYD 149 | VR0OBBYEFAQDr+o8YMapC4lje9upfeiwmFdtMGgGA1UdEQRhMF+CImlkc2VydmVy 150 | LnNlcnZpemljaWUuaW50ZXJuby5nb3YuaXSGOWh0dHBzOi8vaWRzZXJ2ZXIuc2Vy 151 | dml6aWNpZS5pbnRlcm5vLmdvdi5pdC9pZHAvc2hpYmJvbGV0aDANBgkqhkiG9w0B 152 | AQsFAAOCAQEAb7gRYzTPEMQjQKiwI4/NdhzzaoKQjp2tu3UPZwsUHruyCbI+B/0k 153 | C2SaSBaAKGT66yN9bPY2Vj4FuxtYmLSZZnatydF19hSu+lExCySKt16GBJ+D5HN7 154 | OmVizRvJNE4+RF0bajpeXnMottLrcL5Ry/BivpxdnIQ9th2sMc7ev0IZtIGYCxGg 155 | c5SAJCz4zuCcNiPANHDPdoxYEQ9EV9PNAUx8q9tjAhoRRiT2ovqT+Dowqax0AVOP 156 | hRY5rA8WMccWAedO8iSSO8DTWomtoOKS9vjWrQxnsHaT8GXohC2OYgSdKsBchvjS 157 | i1RIVkrqHoSHIK2XQapkl8YmD75JjrGNNA== 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | `; 171 | -------------------------------------------------------------------------------- /src/strategy/__tests__/saml_client.test.ts: -------------------------------------------------------------------------------- 1 | // tslint:disable: no-object-mutation 2 | import { left, right } from "fp-ts/lib/Either"; 3 | import { fromEither } from "fp-ts/lib/TaskEither"; 4 | import { createMockRedis } from "mock-redis-client"; 5 | import { RedisClient } from "redis"; 6 | import { Builder } from "xml2js"; 7 | import mockReq from "../../__mocks__/request"; 8 | import { IServiceProviderConfig } from "../../utils/middleware"; 9 | import { getAuthorizeRequestTamperer } from "../../utils/saml"; 10 | import { mockWrapCallback } from "../__mocks__/passport-saml"; 11 | import { getExtendedRedisCacheProvider } from "../redis_cache_provider"; 12 | import { CustomSamlClient } from "../saml_client"; 13 | import { PreValidateResponseT } from "../spid"; 14 | 15 | const mockSet = jest.fn(); 16 | const mockGet = jest.fn(); 17 | const mockDel = jest.fn(); 18 | 19 | // tslint:disable-next-line: no-any 20 | const mockRedisClient: RedisClient = (createMockRedis() as any).createClient(); 21 | mockRedisClient.set = mockSet; 22 | mockRedisClient.get = mockGet; 23 | mockRedisClient.del = mockDel; 24 | 25 | const redisCacheProvider = getExtendedRedisCacheProvider(mockRedisClient); 26 | 27 | const serviceProviderConfig: IServiceProviderConfig = { 28 | IDPMetadataUrl: 29 | "https://registry.spid.gov.it/metadata/idp/spid-entities-idps.xml", 30 | organization: { 31 | URL: "https://example.com", 32 | displayName: "Organization display name", 33 | name: "Organization name" 34 | }, 35 | contactPersonOther:{ 36 | vatNumber: process.env.CONTACT_PERSON_OTHER_VAT_NUMBER, 37 | fiscalCode: process.env.CONTACT_PERSON_OTHER_FISCAL_CODE, 38 | emailAddress: process.env.CONTACT_PERSON_OTHER_EMAIL_ADDRESS, 39 | telephoneNumber: process.env.CONTACT_PERSON_OTHER_TELEPHONE_NUMBER, 40 | }, 41 | contactPersonBilling:{ 42 | IVAIdPaese: process.env.CONTACT_PERSON_BILLING_IVA_IDPAESE, 43 | IVAIdCodice: process.env.CONTACT_PERSON_BILLING_IVA_IDCODICE, 44 | IVADenominazione: process.env.CONTACT_PERSON_BILLING_IVA_DENOMINAZIONE, 45 | sedeIndirizzo: process.env.CONTACT_PERSON_BILLING_SEDE_INDIRIZZO, 46 | sedeNumeroCivico: process.env.CONTACT_PERSON_BILLING_SEDE_NUMEROCIVICO, 47 | sedeCap: process.env.CONTACT_PERSON_BILLING_SEDE_CAP, 48 | sedeComune: process.env.CONTACT_PERSON_BILLING_SEDE_COMUNE, 49 | sedeProvincia: process.env.CONTACT_PERSON_BILLING_SEDE_PROVINCIA, 50 | sedeNazione: process.env.CONTACT_PERSON_BILLING_SEDE_NAZIONE, 51 | company: process.env.CONTACT_PERSON_BILLING_COMPANY, 52 | emailAddress: process.env.CONTACT_PERSON_BILLING_EMAIL_ADDRESS, 53 | telephoneNumber: process.env.CONTACT_PERSON_BILLING_TELEPHONE_NUMBER, 54 | }, 55 | publicCert: "", 56 | requiredAttributes: { 57 | attributes: [ 58 | "address", 59 | "email", 60 | "name", 61 | "familyName", 62 | "fiscalNumber", 63 | "mobilePhone" 64 | ], 65 | name: "Required attrs" 66 | }, 67 | spidCieUrl: "https://preproduzione.idserver.servizicie.interno.gov.it/idp/shibboleth?Metadata", 68 | spidTestEnvUrl: "https://spid-testenv2:8088", 69 | spidValidatorUrl: "http://localhost:8080" 70 | }; 71 | const expectedRequestID = "123456"; 72 | const SAMLRequest = ` 73 | 77 | https://spid.agid.gov.it/cd 79 | 80 | 81 | https://www.spid.gov.it/SpidL2 82 | 83 | `; 84 | 85 | const authReqTampener = getAuthorizeRequestTamperer( 86 | // spid-testenv does not accept an xml header with utf8 encoding 87 | new Builder({ xmldec: { encoding: undefined, version: "1.0" } }), 88 | serviceProviderConfig, 89 | {} 90 | ); 91 | 92 | const mockedCallback = jest.fn(); 93 | 94 | describe("SAML prototype arguments check", () => { 95 | // tslint:disable-next-line: no-let no-any 96 | let OriginalPassportSaml: any; 97 | beforeAll(() => { 98 | OriginalPassportSaml = jest.requireActual("passport-saml"); 99 | }); 100 | afterAll(() => { 101 | jest.restoreAllMocks(); 102 | }); 103 | 104 | it("should SAML constructor has 2 parameters", () => { 105 | expect(OriginalPassportSaml.SAML.prototype.constructor).toHaveLength(1); 106 | }); 107 | it("should SAML validatePostResponse has 2 parameters", () => { 108 | expect( 109 | OriginalPassportSaml.SAML.prototype.validatePostResponse 110 | ).toHaveLength(2); 111 | }); 112 | it("should SAML generateAuthorizeRequest has 3 parameters", () => { 113 | expect( 114 | OriginalPassportSaml.SAML.prototype.generateAuthorizeRequest 115 | ).toHaveLength(3); 116 | }); 117 | }); 118 | 119 | describe("CustomSamlClient#constructor", () => { 120 | afterEach(() => { 121 | jest.resetAllMocks(); 122 | }); 123 | 124 | it("should CustomSamlClient constructor call SAML constructor with overrided validateInResponseTo", () => { 125 | const customSamlClient = new CustomSamlClient( 126 | { validateInResponseTo: true }, 127 | redisCacheProvider 128 | ); 129 | expect(customSamlClient).toBeTruthy(); 130 | // tslint:disable-next-line: no-string-literal 131 | expect(customSamlClient["options"]).toHaveProperty( 132 | "validateInResponseTo", 133 | false 134 | ); 135 | }); 136 | }); 137 | 138 | describe("CustomSamlClient#validatePostResponse", () => { 139 | afterEach(() => { 140 | jest.resetAllMocks(); 141 | }); 142 | 143 | it("should validatePostResponse call SAML validatePostResponse", () => { 144 | const customSamlClient = new CustomSamlClient( 145 | { validateInResponseTo: true }, 146 | redisCacheProvider 147 | ); 148 | expect(customSamlClient).toBeTruthy(); 149 | customSamlClient.validatePostResponse({ SAMLResponse: "" }, mockedCallback); 150 | expect(mockedCallback).toBeCalledTimes(1); 151 | }); 152 | 153 | it("should validatePostResponse calls preValidateResponse if provided into CustomSamlClient", () => { 154 | const mockPreValidate = jest 155 | .fn() 156 | .mockImplementation((_, __, ___, ____, callback) => { 157 | callback(); 158 | }); 159 | const customSamlClient = new CustomSamlClient( 160 | { validateInResponseTo: true }, 161 | redisCacheProvider, 162 | authReqTampener, 163 | mockPreValidate 164 | ); 165 | expect(customSamlClient).toBeTruthy(); 166 | customSamlClient.validatePostResponse({ SAMLResponse: "" }, mockedCallback); 167 | expect(mockPreValidate).toBeCalledWith( 168 | { validateInResponseTo: true }, 169 | { SAMLResponse: "" }, 170 | redisCacheProvider, 171 | undefined, 172 | expect.any(Function) 173 | ); 174 | expect(mockedCallback).toBeCalledTimes(1); 175 | }); 176 | 177 | it("should preValidateResponse forward the error when occours", () => { 178 | const expectedPreValidateError = new Error("PreValidateError"); 179 | const mockPreValidate = jest 180 | .fn() 181 | .mockImplementation((_, __, ___, ____, callback) => { 182 | callback(expectedPreValidateError); 183 | }); 184 | const customSamlClient = new CustomSamlClient( 185 | { validateInResponseTo: true }, 186 | redisCacheProvider, 187 | authReqTampener, 188 | mockPreValidate 189 | ); 190 | expect(customSamlClient).toBeTruthy(); 191 | customSamlClient.validatePostResponse({ SAMLResponse: "" }, mockedCallback); 192 | expect(mockPreValidate).toBeCalledWith( 193 | { validateInResponseTo: true }, 194 | { SAMLResponse: "" }, 195 | redisCacheProvider, 196 | undefined, 197 | expect.any(Function) 198 | ); 199 | expect(mockedCallback).toBeCalledWith(expectedPreValidateError); 200 | expect(mockedCallback).toBeCalledTimes(1); 201 | }); 202 | 203 | it("should remove cached Response if preValidateResponse and validatePostResponse succeded", async () => { 204 | const expectedAuthnRequestID = "123456"; 205 | const mockPreValidate = jest 206 | .fn() 207 | .mockImplementation((_, __, ___, ____, callback) => { 208 | callback(null, true, expectedAuthnRequestID); 209 | }); 210 | mockDel.mockImplementation((_, callback) => callback(null, 1)); 211 | const customSamlClient = new CustomSamlClient( 212 | { validateInResponseTo: true }, 213 | redisCacheProvider, 214 | authReqTampener, 215 | mockPreValidate 216 | ); 217 | expect(customSamlClient).toBeTruthy(); 218 | customSamlClient.validatePostResponse({ SAMLResponse: "" }, mockedCallback); 219 | expect(mockPreValidate).toBeCalledWith( 220 | { validateInResponseTo: true }, 221 | { SAMLResponse: "" }, 222 | redisCacheProvider, 223 | undefined, 224 | expect.any(Function) 225 | ); 226 | // Before checking the execution of the callback we must await that the TaskEither execution is completed. 227 | await new Promise(resolve => { 228 | setTimeout(() => { 229 | expect(mockDel).toBeCalledWith( 230 | `SAML-EXT-${expectedAuthnRequestID}`, 231 | expect.any(Function) 232 | ); 233 | expect(mockedCallback).toBeCalledWith(null, {}, false); 234 | resolve(); 235 | }, 100); 236 | }); 237 | }); 238 | 239 | it("should validatePostResponse return an error if an error occurs deleting the SAML Request", async () => { 240 | const expectedAuthnRequestID = "123456"; 241 | const expectedDelError = new Error("ErrorDel"); 242 | const mockPreValidate = jest 243 | .fn() 244 | .mockImplementation((_, __, ___, ____, callback) => { 245 | callback(null, true, expectedAuthnRequestID); 246 | }); 247 | mockDel.mockImplementation((_, callback) => callback(expectedDelError)); 248 | const customSamlClient = new CustomSamlClient( 249 | { validateInResponseTo: true }, 250 | redisCacheProvider, 251 | authReqTampener, 252 | mockPreValidate 253 | ); 254 | expect(customSamlClient).toBeTruthy(); 255 | customSamlClient.validatePostResponse({ SAMLResponse: "" }, mockedCallback); 256 | expect(mockPreValidate).toBeCalledWith( 257 | { validateInResponseTo: true }, 258 | { SAMLResponse: "" }, 259 | redisCacheProvider, 260 | undefined, 261 | expect.any(Function) 262 | ); 263 | // Before checking the execution of the callback we must await that the TaskEither execution is completed. 264 | await new Promise(resolve => { 265 | setTimeout(() => { 266 | expect(mockDel).toBeCalledWith( 267 | `SAML-EXT-${expectedAuthnRequestID}`, 268 | expect.any(Function) 269 | ); 270 | expect(mockedCallback).toBeCalledWith( 271 | new Error( 272 | `SAML#ExtendedRedisCacheProvider: remove() error ${expectedDelError}` 273 | ) 274 | ); 275 | resolve(); 276 | }, 100); 277 | }); 278 | }); 279 | }); 280 | 281 | describe("CustomSamlClient#generateAuthorizeRequest", () => { 282 | const mockCallback = jest.fn(); 283 | afterEach(() => { 284 | jest.resetAllMocks(); 285 | }); 286 | 287 | it("should generateAuthorizeRequest call super generateAuthorizeRequest if tamperAuthorizeRequest is not provided", () => { 288 | const req = mockReq(); 289 | const expectedXML = ""; 290 | mockWrapCallback.mockImplementation(callback => { 291 | callback(null, expectedXML); 292 | }); 293 | const customSamlClient = new CustomSamlClient( 294 | { validateInResponseTo: true }, 295 | redisCacheProvider 296 | ); 297 | customSamlClient.generateAuthorizeRequest(req, false, mockCallback); 298 | expect(mockCallback).toBeCalledWith(null, expectedXML); 299 | }); 300 | 301 | it("should generateAuthorizeRequest save the SAML Request if tamperAuthorizeRequest is not provided", async () => { 302 | const mockAuthReqTampener = jest.fn().mockImplementation(xml => { 303 | return fromEither(right(xml)); 304 | }); 305 | mockWrapCallback.mockImplementation(callback => { 306 | callback(null, SAMLRequest); 307 | }); 308 | const req = mockReq(); 309 | mockSet.mockImplementation((_, __, ___, ____, callback) => { 310 | callback(null, "OK"); 311 | }); 312 | const customSamlClient = new CustomSamlClient( 313 | { 314 | entryPoint: "https://localhost:3000/acs", 315 | idpIssuer: "https://localhost:8080", 316 | issuer: "https://localhost:3000", 317 | validateInResponseTo: true 318 | }, 319 | redisCacheProvider, 320 | mockAuthReqTampener 321 | ); 322 | customSamlClient.generateAuthorizeRequest(req, false, mockCallback); 323 | expect(mockAuthReqTampener).toBeCalledWith(SAMLRequest); 324 | // Before checking the execution of the callback we must await that the TaskEither execution is completed. 325 | await new Promise(resolve => { 326 | setTimeout(() => { 327 | expect(mockSet).toBeCalled(); 328 | expect(mockCallback).toBeCalledWith(null, SAMLRequest); 329 | resolve(); 330 | }, 100); 331 | }); 332 | }); 333 | 334 | it("should generateAuthorizeRequest return an error if tamperAuthorizeRequest fail", async () => { 335 | const expectedTamperError = new Error("tamperAuthorizeRequest Error"); 336 | const mockAuthReqTampener = jest.fn().mockImplementation(_ => { 337 | return fromEither(left(expectedTamperError)); 338 | }); 339 | mockWrapCallback.mockImplementation(callback => { 340 | callback(null, SAMLRequest); 341 | }); 342 | const req = mockReq(); 343 | mockSet.mockImplementation((_, __, ___, ____, callback) => { 344 | callback(null, "OK"); 345 | }); 346 | const customSamlClient = new CustomSamlClient( 347 | { 348 | entryPoint: "https://localhost:3000/acs", 349 | idpIssuer: "https://localhost:8080", 350 | issuer: "https://localhost:3000", 351 | validateInResponseTo: true 352 | }, 353 | redisCacheProvider, 354 | mockAuthReqTampener 355 | ); 356 | customSamlClient.generateAuthorizeRequest(req, false, mockCallback); 357 | expect(mockAuthReqTampener).toBeCalledWith(SAMLRequest); 358 | // Before checking the execution of the callback we must await that the TaskEither execution is completed. 359 | await new Promise(resolve => { 360 | setTimeout(() => { 361 | expect(mockSet).not.toBeCalled(); 362 | expect(mockCallback).toBeCalledWith(expectedTamperError); 363 | resolve(); 364 | }, 100); 365 | }); 366 | }); 367 | }); 368 | -------------------------------------------------------------------------------- /src/utils/__tests__/saml.test.ts: -------------------------------------------------------------------------------- 1 | import { right } from "fp-ts/lib/Either"; 2 | import { isSome, tryCatch } from "fp-ts/lib/Option"; 3 | import { fromEither } from "fp-ts/lib/TaskEither"; 4 | import { SamlConfig } from "passport-saml"; 5 | import { DOMParser } from "xmldom"; 6 | import { EventTracker } from "../../index"; 7 | import { 8 | getSamlAssertion, 9 | getSamlResponse, 10 | samlEncryptedAssertion, 11 | samlRequest, 12 | samlResponseCIE 13 | } from "../__mocks__/saml"; 14 | import { StrictResponseValidationOptions } from "../middleware"; 15 | import { getPreValidateResponse, getXmlFromSamlResponse } from "../saml"; 16 | import * as saml from "../saml"; 17 | 18 | const samlConfig: SamlConfig = ({ 19 | attributes: { 20 | attributes: { 21 | attributes: ["name", "fiscalNumber", "familyName", "mobilePhone", "email"] 22 | } 23 | }, 24 | authnContext: "https://www.spid.gov.it/SpidL2", 25 | callbackUrl: "https://app-backend.dev.io.italia.it/assertionConsumerService", 26 | issuer: "https://app-backend.dev.io.italia.it" 27 | } as unknown) as SamlConfig; 28 | 29 | const aResponseSignedWithHMAC = getSamlResponse({ 30 | signatureMethod: "http://www.w3.org/2000/09/xmldsig#hmac-sha1" 31 | }); 32 | const aResponseWithOneAssertionSignedWithHMAC = getSamlResponse({ 33 | customAssertion: getSamlAssertion( 34 | 0, 35 | "http://www.w3.org/2000/09/xmldsig#hmac-sha1" 36 | ) 37 | }); 38 | const aResponseSignedWithHMACWithOneAssertionSignedWithHMAC = getSamlResponse({ 39 | customAssertion: getSamlAssertion( 40 | 0, 41 | "http://www.w3.org/2000/09/xmldsig#hmac-sha1" 42 | ), 43 | signatureMethod: "http://www.w3.org/2000/09/xmldsig#hmac-sha1" 44 | }); 45 | 46 | describe("getXmlFromSamlResponse", () => { 47 | it("should parse a well formatted response body", () => { 48 | const expectedSAMLResponse = "Response"; 49 | const responseBody = { 50 | SAMLResponse: Buffer.from(expectedSAMLResponse).toString("base64") 51 | }; 52 | const responseDocument = getXmlFromSamlResponse(responseBody); 53 | expect(isSome(responseDocument)).toBeTruthy(); 54 | }); 55 | }); 56 | 57 | // tslint:disable-next-line: no-big-function 58 | describe("preValidateResponse", () => { 59 | const mockCallback = jest.fn(); 60 | const mockGetXmlFromSamlResponse = jest.spyOn(saml, "getXmlFromSamlResponse"); 61 | const mockGet = jest.fn(); 62 | const mockRedisCacheProvider = { 63 | get: mockGet, 64 | remove: jest.fn(), 65 | save: jest.fn() 66 | }; 67 | const mockBody = "MOCKED BODY"; 68 | const mockTestIdpIssuer = "http://localhost:8080"; 69 | 70 | const mockEventTracker = jest.fn() as EventTracker; 71 | 72 | const expectedDesynResponseValueMs = 2000; 73 | 74 | const expectedGenericEventName = "spid.error.generic"; 75 | const expectedSignatureErrorName = "spid.error.signature"; 76 | 77 | const asyncExpectOnCallback = (callback: jest.Mock, error?: Error) => 78 | new Promise(resolve => { 79 | setTimeout(() => { 80 | error 81 | ? expect(callback).toBeCalledWith(error) 82 | : expect(callback).toBeCalledWith(null, true, expect.any(String)); 83 | resolve(); 84 | }, 100); 85 | }); 86 | 87 | beforeEach(() => { 88 | jest.resetAllMocks(); 89 | mockGet.mockImplementation(() => { 90 | return fromEither( 91 | right({ 92 | RequestXML: samlRequest, 93 | createdAt: "2020-02-26T07:27:42Z", 94 | idpIssuer: mockTestIdpIssuer 95 | }) 96 | ); 97 | }); 98 | }); 99 | 100 | afterAll(() => { 101 | jest.restoreAllMocks(); 102 | }); 103 | 104 | it("should preValidate fail when saml Response has multiple Assertion elements", async () => { 105 | mockGetXmlFromSamlResponse.mockImplementation(() => 106 | tryCatch(() => 107 | new DOMParser().parseFromString( 108 | getSamlResponse({ customAssertion: getSamlAssertion().repeat(2) }) 109 | ) 110 | ) 111 | ); 112 | const strictValidationOption: StrictResponseValidationOptions = { 113 | mockTestIdpIssuer: true 114 | }; 115 | getPreValidateResponse(strictValidationOption, mockEventTracker)( 116 | { ...samlConfig, acceptedClockSkewMs: 0 }, 117 | mockBody, 118 | mockRedisCacheProvider, 119 | undefined, 120 | mockCallback 121 | ); 122 | expect(mockGetXmlFromSamlResponse).toBeCalledWith(mockBody); 123 | const expectedError = new Error( 124 | "SAML Response must have only one Assertion element" 125 | ); 126 | await asyncExpectOnCallback(mockCallback, expectedError); 127 | expect(mockEventTracker).toBeCalledWith({ 128 | data: { 129 | message: expectedError.message 130 | }, 131 | name: expectedGenericEventName, 132 | type: "ERROR" 133 | }); 134 | }); 135 | 136 | it("should preValidate fail when saml Response has EncryptedAssertion element", async () => { 137 | mockGetXmlFromSamlResponse.mockImplementation(() => 138 | tryCatch(() => 139 | new DOMParser().parseFromString( 140 | getSamlResponse({ customAssertion: samlEncryptedAssertion }) 141 | ) 142 | ) 143 | ); 144 | const strictValidationOption: StrictResponseValidationOptions = { 145 | mockTestIdpIssuer: true 146 | }; 147 | getPreValidateResponse(strictValidationOption, mockEventTracker)( 148 | { ...samlConfig, acceptedClockSkewMs: 0 }, 149 | mockBody, 150 | mockRedisCacheProvider, 151 | undefined, 152 | mockCallback 153 | ); 154 | expect(mockGetXmlFromSamlResponse).toBeCalledWith(mockBody); 155 | const expectedError = new Error("EncryptedAssertion element is forbidden"); 156 | await asyncExpectOnCallback(mockCallback, expectedError); 157 | expect(mockEventTracker).toBeCalledWith({ 158 | data: { 159 | message: expectedError.message 160 | }, 161 | name: expectedGenericEventName, 162 | type: "ERROR" 163 | }); 164 | }); 165 | 166 | it("should preValidate fail when saml Response has multiple Response elements", async () => { 167 | mockGetXmlFromSamlResponse.mockImplementation(() => 168 | tryCatch(() => 169 | new DOMParser().parseFromString(getSamlResponse().repeat(2)) 170 | ) 171 | ); 172 | const strictValidationOption: StrictResponseValidationOptions = { 173 | mockTestIdpIssuer: true 174 | }; 175 | getPreValidateResponse(strictValidationOption, mockEventTracker)( 176 | { ...samlConfig, acceptedClockSkewMs: 0 }, 177 | mockBody, 178 | mockRedisCacheProvider, 179 | undefined, 180 | mockCallback 181 | ); 182 | const expectedError = new Error( 183 | "SAML Response must have only one Response element" 184 | ); 185 | expect(mockGetXmlFromSamlResponse).toBeCalledWith(mockBody); 186 | await asyncExpectOnCallback(mockCallback, expectedError); 187 | expect(mockEventTracker).toBeCalledWith({ 188 | data: { 189 | message: expectedError.message 190 | }, 191 | name: expectedGenericEventName, 192 | type: "ERROR" 193 | }); 194 | }); 195 | 196 | it("should preValidate succeded with a valid saml Response", async () => { 197 | mockGetXmlFromSamlResponse.mockImplementation(() => 198 | tryCatch(() => new DOMParser().parseFromString(getSamlResponse())) 199 | ); 200 | const strictValidationOption: StrictResponseValidationOptions = { 201 | mockTestIdpIssuer: true 202 | }; 203 | getPreValidateResponse(strictValidationOption)( 204 | { ...samlConfig, acceptedClockSkewMs: 0 }, 205 | mockBody, 206 | mockRedisCacheProvider, 207 | undefined, 208 | mockCallback 209 | ); 210 | expect(mockGetXmlFromSamlResponse).toBeCalledWith(mockBody); 211 | await asyncExpectOnCallback(mockCallback); 212 | }); 213 | 214 | it("should preValidate succeded and send an Event on valid Response with missing Signature", async () => { 215 | mockGetXmlFromSamlResponse.mockImplementation(() => 216 | tryCatch(() => 217 | new DOMParser().parseFromString( 218 | getSamlResponse({ hasResponseSignature: false }) 219 | ) 220 | ) 221 | ); 222 | const strictValidationOption: StrictResponseValidationOptions = { 223 | mockTestIdpIssuer: true 224 | }; 225 | getPreValidateResponse(strictValidationOption, mockEventTracker)( 226 | { ...samlConfig, acceptedClockSkewMs: 0 }, 227 | mockBody, 228 | mockRedisCacheProvider, 229 | undefined, 230 | mockCallback 231 | ); 232 | expect(mockGetXmlFromSamlResponse).toBeCalledWith(mockBody); 233 | await asyncExpectOnCallback(mockCallback); 234 | expect(mockEventTracker).toBeCalledWith({ 235 | data: { 236 | idpIssuer: mockTestIdpIssuer, 237 | message: expect.any(String) 238 | }, 239 | name: expectedSignatureErrorName, 240 | type: "INFO" 241 | }); 242 | }); 243 | 244 | it("should preValidate succeed if timers are desynchronized and acceptedClockSkewMs is disabled", async () => { 245 | mockGetXmlFromSamlResponse.mockImplementation(() => 246 | tryCatch(() => 247 | new DOMParser().parseFromString( 248 | getSamlResponse({ clockSkewMs: expectedDesynResponseValueMs }) 249 | ) 250 | ) 251 | ); 252 | const strictValidationOption: StrictResponseValidationOptions = { 253 | mockTestIdpIssuer: true 254 | }; 255 | getPreValidateResponse(strictValidationOption)( 256 | { ...samlConfig, acceptedClockSkewMs: -1 }, 257 | mockBody, 258 | mockRedisCacheProvider, 259 | undefined, 260 | mockCallback 261 | ); 262 | expect(mockGetXmlFromSamlResponse).toBeCalledWith(mockBody); 263 | await asyncExpectOnCallback(mockCallback); 264 | }); 265 | 266 | it("should preValidate succeed if timers desync is less than acceptedClockSkewMs", async () => { 267 | mockGetXmlFromSamlResponse.mockImplementation(() => 268 | tryCatch(() => 269 | new DOMParser().parseFromString( 270 | getSamlResponse({ clockSkewMs: expectedDesynResponseValueMs }) 271 | ) 272 | ) 273 | ); 274 | const strictValidationOption: StrictResponseValidationOptions = { 275 | mockTestIdpIssuer: true 276 | }; 277 | getPreValidateResponse(strictValidationOption)( 278 | { ...samlConfig, acceptedClockSkewMs: 2000 }, 279 | mockBody, 280 | mockRedisCacheProvider, 281 | undefined, 282 | mockCallback 283 | ); 284 | expect(mockGetXmlFromSamlResponse).toBeCalledWith(mockBody); 285 | await asyncExpectOnCallback(mockCallback); 286 | }); 287 | 288 | it("should preValidate fail if timer desync exceeds acceptedClockSkewMs", async () => { 289 | mockGetXmlFromSamlResponse.mockImplementation(() => 290 | tryCatch(() => 291 | new DOMParser().parseFromString( 292 | getSamlResponse({ clockSkewMs: expectedDesynResponseValueMs }) 293 | ) 294 | ) 295 | ); 296 | const strictValidationOption: StrictResponseValidationOptions = { 297 | mockTestIdpIssuer: true 298 | }; 299 | getPreValidateResponse(strictValidationOption)( 300 | { 301 | ...samlConfig, 302 | acceptedClockSkewMs: expectedDesynResponseValueMs - 100 303 | }, 304 | mockBody, 305 | mockRedisCacheProvider, 306 | undefined, 307 | mockCallback 308 | ); 309 | expect(mockGetXmlFromSamlResponse).toBeCalledWith(mockBody); 310 | await asyncExpectOnCallback( 311 | mockCallback, 312 | new Error("IssueInstant must be in the past") 313 | ); 314 | }); 315 | 316 | it.each` 317 | title | response 318 | ${"uses HMAC as signature algorithm"} | ${aResponseSignedWithHMAC} 319 | ${"has an assertion that uses HMAC as signature algorithm"} | ${aResponseWithOneAssertionSignedWithHMAC} 320 | ${"uses HMAC as signature algorithm and has an assertion that uses HMAC as signature algorithm"} | ${aResponseSignedWithHMACWithOneAssertionSignedWithHMAC} 321 | `( 322 | "should preValidate fail when saml Response $title", 323 | async ({ response }) => { 324 | mockGetXmlFromSamlResponse.mockImplementation(() => 325 | tryCatch(() => new DOMParser().parseFromString(response)) 326 | ); 327 | const strictValidationOption: StrictResponseValidationOptions = { 328 | mockTestIdpIssuer: true 329 | }; 330 | getPreValidateResponse(strictValidationOption, mockEventTracker)( 331 | { ...samlConfig, acceptedClockSkewMs: 0 }, 332 | mockBody, 333 | mockRedisCacheProvider, 334 | undefined, 335 | mockCallback 336 | ); 337 | expect(mockGetXmlFromSamlResponse).toBeCalledWith(mockBody); 338 | const expectedError = new Error("HMAC Signature is forbidden"); 339 | await asyncExpectOnCallback(mockCallback, expectedError); 340 | expect(mockEventTracker).toBeCalledWith({ 341 | data: { 342 | message: expectedError.message 343 | }, 344 | name: expectedGenericEventName, 345 | type: "ERROR" 346 | }); 347 | } 348 | ); 349 | 350 | describe("preValidateResponse with CIE saml Response", () => { 351 | beforeEach(() => { 352 | jest.resetAllMocks(); 353 | }); 354 | 355 | it("should preValidate succeded with a valid CIE saml Response", async () => { 356 | mockGetXmlFromSamlResponse.mockImplementation(() => 357 | tryCatch(() => new DOMParser().parseFromString(samlResponseCIE)) 358 | ); 359 | // tslint:disable-next-line: no-identical-functions 360 | mockGet.mockImplementation(() => { 361 | return fromEither( 362 | right({ 363 | RequestXML: samlRequest, 364 | createdAt: "2020-02-26T07:27:42Z", 365 | idpIssuer: 366 | "https://preproduzione.idserver.servizicie.interno.gov.it/idp/profile/SAML2/POST/SSO" 367 | }) 368 | ); 369 | }); 370 | getPreValidateResponse()( 371 | samlConfig, 372 | mockBody, 373 | mockRedisCacheProvider, 374 | undefined, 375 | mockCallback 376 | ); 377 | expect(mockGetXmlFromSamlResponse).toBeCalledWith(mockBody); 378 | await asyncExpectOnCallback(mockCallback); 379 | }); 380 | }); 381 | 382 | it("should preValidate fail when saml Response uses HMAC as signature algorithm", async () => { 383 | mockGetXmlFromSamlResponse.mockImplementation(() => 384 | tryCatch(() => 385 | new DOMParser().parseFromString( 386 | getSamlResponse({ 387 | signatureMethod: "http://www.w3.org/2000/09/xmldsig#hmac-sha1" 388 | }) 389 | ) 390 | ) 391 | ); 392 | const strictValidationOption: StrictResponseValidationOptions = { 393 | mockTestIdpIssuer: true 394 | }; 395 | getPreValidateResponse(strictValidationOption, mockEventTracker)( 396 | { ...samlConfig, acceptedClockSkewMs: 0 }, 397 | mockBody, 398 | mockRedisCacheProvider, 399 | undefined, 400 | mockCallback 401 | ); 402 | expect(mockGetXmlFromSamlResponse).toBeCalledWith(mockBody); 403 | const expectedError = new Error("HMAC Signature is forbidden"); 404 | await asyncExpectOnCallback(mockCallback, expectedError); 405 | expect(mockEventTracker).toBeCalledWith({ 406 | data: { 407 | message: expectedError.message 408 | }, 409 | name: expectedGenericEventName, 410 | type: "ERROR" 411 | }); 412 | }); 413 | }); 414 | -------------------------------------------------------------------------------- /src/utils/__mocks__/saml.ts: -------------------------------------------------------------------------------- 1 | type SignatureMethod = 2 | | "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256" 3 | | "http://www.w3.org/2000/09/xmldsig#hmac-sha1"; 4 | 5 | export const getSamlAssertion = ( 6 | clockSkewMs: number = 0, 7 | signatureMethod: SignatureMethod = "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256" 8 | ) => ` 11 | 12 | http://localhost:8080 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | /VIQSQkyyNQkdlgLOCFcTAg0Oy78b2Sy9GcaWeO6hb8= 26 | 27 | 28 | 29 | 30 | ZwOttAyJZ774CPdnfjnvExwqtRXS3cvXqgo/nnPF2CsZmoBd0V11FqgXpj2hD5HkQ4fiwpNjn319SeId8s9M8RBfWPVzD52pgm1M3nT76+qDf+GWrCnK8CgkskPId798BugCmgPuHul9XKKDKC0Ajj4THtetRklvmxkpTzJy8CgwV79pbQwLMTHsiIRed3X25rjqhtuVUBWB2BKN0RC4bsoKBrDwa4UYDZ/4n68zm0AVNP8xpTOgGDm1sGeMwqdmccITDk17OLWUsgX2WGPdwFIAUsLfs1zw9z/lF5wwEuaz7GxS3pg5P3yGg6VqM7fj4HBuWop/oNxYUHixULxbQw== 31 | 32 | 33 | 34 | 35 | MIIEGDCCAwCgAwIBAgIJAOrYj9oLEJCwMA0GCSqGSIb3DQEBCwUAMGUxCzAJBgNVBAYTAklUMQ4wDAYDVQQIEwVJdGFseTENMAsGA1UEBxMEUm9tZTENMAsGA1UEChMEQWdJRDESMBAGA1UECxMJQWdJRCBURVNUMRQwEgYDVQQDEwthZ2lkLmdvdi5pdDAeFw0xOTA0MTExMDAyMDhaFw0yNTAzMDgxMDAyMDhaMGUxCzAJBgNVBAYTAklUMQ4wDAYDVQQIEwVJdGFseTENMAsGA1UEBxMEUm9tZTENMAsGA1UEChMEQWdJRDESMBAGA1UECxMJQWdJRCBURVNUMRQwEgYDVQQDEwthZ2lkLmdvdi5pdDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAK8kJVo+ugRrbbv9xhXCuVrqi4B7/MQzQc62ocwlFFujJNd4m1mXkUHFbgvwhRkQqo2DAmFeHiwCkJT3K1eeXIFhNFFroEzGPzONyekLpjNvmYIs1CFvirGOj0bkEiGaKEs+/umzGjxIhy5JQlqXE96y1+Izp2QhJimDK0/KNij8I1bzxseP0Ygc4SFveKS+7QO+PrLzWklEWGMs4DM5Zc3VRK7g4LWPWZhKdImC1rnS+/lEmHSvHisdVp/DJtbSrZwSYTRvTTz5IZDSq4kAzrDfpj16h7b3t3nFGc8UoY2Ro4tRZ3ahJ2r3b79yK6C5phY7CAANuW3gDdhVjiBNYs0CAwEAAaOByjCBxzAdBgNVHQ4EFgQU3/7kV2tbdFtphbSA4LH7+w8SkcwwgZcGA1UdIwSBjzCBjIAU3/7kV2tbdFtphbSA4LH7+w8SkcyhaaRnMGUxCzAJBgNVBAYTAklUMQ4wDAYDVQQIEwVJdGFseTENMAsGA1UEBxMEUm9tZTENMAsGA1UEChMEQWdJRDESMBAGA1UECxMJQWdJRCBURVNUMRQwEgYDVQQDEwthZ2lkLmdvdi5pdIIJAOrYj9oLEJCwMAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAJNFqXg/V3aimJKUmUaqmQEEoSc3qvXFITvT5f5bKw9yk/NVhR6wndL+z/24h1OdRqs76blgH8k116qWNkkDtt0AlSjQOx5qvFYh1UviOjNdRI4WkYONSw+vuavcx+fB6O5JDHNmMhMySKTnmRqTkyhjrch7zaFIWUSV7hsBuxpqmrWDoLWdXbV3eFH3mINA5AoIY/m0bZtzZ7YNgiFWzxQgekpxd0vcTseMnCcXnsAlctdir0FoCZztxMuZjlBjwLTtM6Ry3/48LMM8Z+lw7NMciKLLTGQyU8XmKKSSOh0dGh5Lrlt5GxIIJkH81C0YimWebz8464QPL3RbLnTKg+c= 36 | 37 | 38 | 39 | 40 | 41 | 42 | _61c0122d-5e8e-48e5-98ce-d43bb3903404 43 | 44 | 45 | 47 | 48 | 49 | 51 | 52 | 53 | https://app-backend.dev.io.italia.it 54 | 55 | 56 | 57 | 58 | 59 | 60 | https://www.spid.gov.it/SpidL2 61 | 62 | 63 | 64 | 65 | 66 | 67 | SpidValidator 68 | 69 | 70 | 71 | 72 | AgID 73 | 74 | 75 | 76 | 77 | TINIT-GDASDV00A01H501J 78 | 79 | 80 | 81 | 82 | +393331234567 83 | 84 | 85 | 86 | 87 | spid.tech@agid.gov.it 88 | 89 | 90 | 91 | 92 | Via Listz 21 00144 Roma 93 | 94 | 95 | 96 | `; 97 | 98 | export const samlEncryptedAssertion = ` 99 | 100 | KHZBiMKKxIwnGiBJ2PJfh8K988wOZgzmzEKQHgX31L5uBu/XO6FBMHJbWKsGSrtCxK+R508zqo2FpMcRmvx+MyAqPbryA4Sb+Qp4fCuilrqOzYADcEEyuFibtWXdiMZ+s7B787y7wifJQ2H3RZLQfY80hYznhVj6Iu4DFtTjwXk= 101 | 102 | ZYtxkR0cEKv9FdqDB84Mg0/8b9n/LNwEtkJhVT8U1L/e4WY4YRf+Y+4Zamw7AnY5nefOOYzQZ0S/Kkf/ucEP3iXDgAurNTYhmyXjumf1ETJdJoIBXs4DZGm6yPlUmNMOkj1ln32ekjKSbhk+i6w6E6AkD9+MiwzMqKyIkEvvVESdtxP77HKsc78UESvb0c8OYHUKNaqcBAoT3XP7HNYoIn8M8dMMEZxfETxKcKi2DPcdwN/NVyvL/GXJ7W2AjwAZZeiFRxXdDCBqFMOIMIWhRa7eS8GuYqZHVE1I/C6ORhgCxSWcS9IPIBTxILB0ukZ2KJ4xLaBxgl2GugdzVknLYHllUXFeNgb/JVPxsGZAzKA9nICz95wG97Lm3i0w1STX71WM2KvfTu3a/No9OwAD3qkWcwh4KOU5A303eEAAjUrp6y1zK0DBvPjp0tJ6FStQnWGQJYFhh6bT/KnZ5YKc+ETUPkM/He8uyNgJQswsHXuMSrJ1WzmA8pZ0Z7ZfqV2vr2N/5EMneJfqk3SDkkgZYFcF/faVOkjsYEKhiFap4BXRZEz/4oaydY3NNb0fAQJAFrjlUZb3p7x/mshkXNMNhnHIuednn74k9VDYnE0izPPjrA1LvCsWllIdnyRfWC4e5FXpHmwbCFI2Az8a5aGZ1qXviPWck9ArezjDpD/MscvQ5jMAG4wwLKDjcqDZWnPIKoY2WNH+BfJyuamWQprbsN/ZitOBRKbn6fgRQXl3FMTSPIP/xAvHQFcSebKrkrss1hOEEa195AU5KOxoX24WbbpGlMYzCiQb6Y7WEKVLrFZrrSTCdqilpdVVoFsq3S2BEBCOZJnCYOeY2DOcN79Rk2rCDHugOwq9kxWrICDM/ygq/6Y3P/L82xKM4CXU1t1xKOGsBmWr2U7NtfqoOx6kQiFAy2rFBZ1eyofajo3M1YIfj1OYfhZkOSP4c8I7TUmmo/SvWSweNyI/oxtzKuSVkC+jjabhtyS/ZpDS1xrGPNoqhfxhM0oWTdEYDAgl+F/txsStBVPSxnpF3TXi+XrkBazRkq2iSCLb7efM9W87LgV02hQ3glnNI8RGBg2ngY5w/8bG3FpPRkaIl0n5Ulp07oZMR2g0dGA3Jpc0j2Ww+jj1aabWAStz1od9wG5aVU9AZyAi+aTMMustlNHhv6GTpZa948EsM/cVr+cJiXItsBNJaSEzeguDO/k0x6LFSrH9VMAvUTlkIn0YRlFAUGInONKcXOyi/WCZ0nnTBWBa4SYrf7yMIV4gFwiswUa95f5KjioC/RBayIdF7861zOoEC0h7PmGr5psf60P54vDK+YSCgW1R2CsPTs5chZVp5ITIBgmEtCKYe5KVsWqoguTDkVHVaNd8S+0RpgJ7byInWJhQxFd1hiCFcHo/Cmqbxs7ijWKEWG2Tu7q9uEvzlrRH4cW0e7xMptHU6eip5+FE2Hm/9rKKHnGI0lmugXsJq4vkqTOjszfumFN50KHyat6d8/LHnWbOq4hrJEgbT/1SSoYpFFwFJ6KtudkD+VP54nSY5cighyNYseP4/bPJy1ac15r3e8SJbLgR4Ygz9BD/dWLcuSii8it2fFO9qP6Uu5IizILBaGRjOVKNIGdeQv1iO2jGfwkcKgCjJpzXZqZWks7pGw8Cq2UNsRN+I+CxbEuiQuTkjG37nuNnQH2aWsbGQp+APV4chVSJ3Uu/lAYEJX80HS/rS0y/GmCWIz+Bh6IbjJS+efDFAyK+zP4j+u1WNllygL7gozliCEJZNKg8Bi8lZTW15Ed7zhXdR3Wee4LYLQO7OMeEoXhvgrDCLQ5KGPXE9Lej+0+CI4nDhQ+yO00bQm/KQ8SXgNJpZcTBOIhqISHRehIusXX3KoRDZJEbBf1ekU8PKVlAtSD5U/+n5EIPR/vRoyF17v5BYqcRXJ1KG2//UDkXM54yx26li77nomnmsYNzBRnWAb2CjhXeY+zhAmjULQEgyqsWPm1AdUF6+336GsfcVoi0SKFTTZFKCykk6GRRnE3ZZEU9uhH/hTQkOqsRAJfFShA1Yj9+demwyn6StNgSQKGREIE728QS+5fkatJPP1eq9I9fpX/hW7Od8OUcJ0PbmQHi7PrltCnbfg4PLVb1faQeGSBc9Zv1MNyR7Sxs1ruGnP7FnbguHFyrjsjpZwVMkYtXJHZjV0RdvY/1sGueyOmcvHnTwf/rWT1r2tOoZWy3Zv33iWiLmdTdMhpeh21NdgMCQTyt8qEL5Cgf/aGW0XJ4RvC3bwvNKB8hdWl0DOtoJaLd+N+RKmiWMrJJA0imwq9fEQDV0HSgSPWRVmnGU+L6Jnhw/EqTYCwGUpcY4oCWy4IGsiiHRXsEsenOO7JDePjiG768VRKe8Ew0sAsD443IM61trYJOsY0lgQMp1OF1KFfS4PxnxbAk7acXuskN2R05+sdzqp5MwGFc1G/3CR/yhnenBUJP2jtb73LRkrXlj/gYueD6P0ynoRGJZwbLMyfksnms+Z+Vq1rHzXEY3zvQ3HUMIRh1BYrY/cDFZMeWr482tX7N/mgTn5BIqIXqVc8DtOvYrejX73Kyx6iV9+8lTAlGU/gJe3DCZ/JBmEiUFjt3UllTF1Rpm+GGXYiufN4i1RTcRERt9tRqlG+e8lBAEWYzuMXYdNcKTCrEGnFVgemdkiv2E6EWdIpl4PIf0g00dkJZ7R07xYQCzVRVbjfA3GDDcUe2TI8AT4t5iHxqw9hzEcT5QMJXgossy4eSdFIxgd0drS3IZ4JQaNf3c9KJvnsLDnfqJ5bL4kVqdLmV1ZCDl+oDv34K6olj0adw1bamO439SeKhIDTI6ImP/HXbXovFso35H4LqgK5rR/op52+UZoZd9rG3lRsv28iGl5dKnpIeRatLUdSHqu1ss/I/VkJlmQ1JgKLMds22sjoW/VOOO7HpAO4= 103 | 104 | 105 | `; 106 | 107 | interface IGetSAMLResponseParams { 108 | /** @default 0 */ 109 | clockSkewMs?: number; 110 | 111 | customAssertion?: string; 112 | 113 | /** @default true */ 114 | hasResponseSignature?: boolean; 115 | 116 | /** @default "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256" */ 117 | signatureMethod?: SignatureMethod; 118 | } 119 | 120 | export const getSamlResponse: (params?: IGetSAMLResponseParams) => string = ( 121 | params = {} 122 | ) => { 123 | const { clockSkewMs = 0, customAssertion, hasResponseSignature } = params; 124 | return ` 127 | 128 | http://localhost:8080 129 | 130 | ${ 131 | hasResponseSignature !== false 132 | ? // tslint:disable-next-line: no-nested-template-literals 133 | ` 134 | 135 | 136 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | TF1ulWGxyd1WLVdSAqBIamJFuM2asyon8TiXFA5MKRk= 146 | 147 | 148 | 149 | 150 | riM6BMi4m5VSG26SrSJ7oB5Sk2TYWAUWcQOeE0oeKEDvBbDSXYfMCed5RD8ZCaD340OdmYBNylW8WsegTR0Ejxlusjg/KbLfDFlTpFA6kQEI02A7LFjlWL1XR+t/jE2101zEcZQHp01R8oALxrMzicW591h12l8Y0HtoMCYTOoAThsyk7D+ce/+Jh4Ogn5xUtAm7NpXGuMRChIhVuhfvQ3l7rDxFU+N+CHc7mfLxRZFooQn1zmHS3Ccd/O8N1Tnx+ivCIzozDa9n35S5bzSqiVHBgoa3kEUsQB+ZEn38Y8gOWJgRpPi6txorjWj2+NAmzGH2DJ0tNQAuGc2B4Eu5uQ== 151 | 152 | 153 | 154 | 155 | MIIEGDCCAwCgAwIBAgIJAOrYj9oLEJCwMA0GCSqGSIb3DQEBCwUAMGUxCzAJBgNVBAYTAklUMQ4wDAYDVQQIEwVJdGFseTENMAsGA1UEBxMEUm9tZTENMAsGA1UEChMEQWdJRDESMBAGA1UECxMJQWdJRCBURVNUMRQwEgYDVQQDEwthZ2lkLmdvdi5pdDAeFw0xOTA0MTExMDAyMDhaFw0yNTAzMDgxMDAyMDhaMGUxCzAJBgNVBAYTAklUMQ4wDAYDVQQIEwVJdGFseTENMAsGA1UEBxMEUm9tZTENMAsGA1UEChMEQWdJRDESMBAGA1UECxMJQWdJRCBURVNUMRQwEgYDVQQDEwthZ2lkLmdvdi5pdDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAK8kJVo+ugRrbbv9xhXCuVrqi4B7/MQzQc62ocwlFFujJNd4m1mXkUHFbgvwhRkQqo2DAmFeHiwCkJT3K1eeXIFhNFFroEzGPzONyekLpjNvmYIs1CFvirGOj0bkEiGaKEs+/umzGjxIhy5JQlqXE96y1+Izp2QhJimDK0/KNij8I1bzxseP0Ygc4SFveKS+7QO+PrLzWklEWGMs4DM5Zc3VRK7g4LWPWZhKdImC1rnS+/lEmHSvHisdVp/DJtbSrZwSYTRvTTz5IZDSq4kAzrDfpj16h7b3t3nFGc8UoY2Ro4tRZ3ahJ2r3b79yK6C5phY7CAANuW3gDdhVjiBNYs0CAwEAAaOByjCBxzAdBgNVHQ4EFgQU3/7kV2tbdFtphbSA4LH7+w8SkcwwgZcGA1UdIwSBjzCBjIAU3/7kV2tbdFtphbSA4LH7+w8SkcyhaaRnMGUxCzAJBgNVBAYTAklUMQ4wDAYDVQQIEwVJdGFseTENMAsGA1UEBxMEUm9tZTENMAsGA1UEChMEQWdJRDESMBAGA1UECxMJQWdJRCBURVNUMRQwEgYDVQQDEwthZ2lkLmdvdi5pdIIJAOrYj9oLEJCwMAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAJNFqXg/V3aimJKUmUaqmQEEoSc3qvXFITvT5f5bKw9yk/NVhR6wndL+z/24h1OdRqs76blgH8k116qWNkkDtt0AlSjQOx5qvFYh1UviOjNdRI4WkYONSw+vuavcx+fB6O5JDHNmMhMySKTnmRqTkyhjrch7zaFIWUSV7hsBuxpqmrWDoLWdXbV3eFH3mINA5AoIY/m0bZtzZ7YNgiFWzxQgekpxd0vcTseMnCcXnsAlctdir0FoCZztxMuZjlBjwLTtM6Ry3/48LMM8Z+lw7NMciKLLTGQyU8XmKKSSOh0dGh5Lrlt5GxIIJkH81C0YimWebz8464QPL3RbLnTKg+c= 156 | 157 | 158 | 159 | ` 160 | : "" 161 | } 162 | 163 | 164 | 165 | ${customAssertion || getSamlAssertion(clockSkewMs)} 166 | `; 167 | }; 168 | 169 | export const samlRequest = ` 170 | 171 | 172 | https://spid.agid.gov.it/cd 173 | 174 | 175 | 176 | 177 | https://www.spid.gov.it/SpidL2 178 | 179 | 180 | `; 181 | 182 | export const samlResponseCIE = ` 183 | 186 | https://preproduzione.idserver.servizicie.interno.gov.it/idp/profile/SAML2/POST/SSO 187 | 188 | 189 | 190 | 191 | 192 | 193 | 194 | 195 | 197 | 198 | 199 | 200 | 3UGuOlUuag/oPOIif31jpuIJT829Eab+2dSEDegDlmU= 201 | 202 | 203 | 204 | AIa2vTA8uOKizFvCqNchj4Dby8eDOi5UaOEZYJ4NV0RorEj2wkSFbhX65FYLt68VUGY5YR1tqDfl d0ApvcdtkH4gucq2aCd1zTq8yk5dXp10IC49YdLXlDCRh3QcgulIDZhZs/K2nTEzrrfHC7dibYv/ vk/tY5AOih2jIqNslt1gxopuLREUTyG1NC7CcqfwhxCxxs1z5ngcN1D/cZv9sQT85lzwGCU65+5G ySdiSr0WzHEEcT1k9WnDwqW27i0tbCwC2NZ3xOHl0X7mKb35TzhdMpAz74ADnalk833EjZdVHu6x XdG5KqmjIW+mrddO71jDRXQ1eMrQBeCAfRQ0Mg== 205 | 206 | 207 | 208 | MIIDdTCCAl2gAwIBAgIUU79XEfveueyClDtLkqUlSPZ2o8owDQYJKoZIhvcNAQELBQAwLTErMCkG A1UEAwwiaWRzZXJ2ZXIuc2Vydml6aWNpZS5pbnRlcm5vLmdvdi5pdDAeFw0xODEwMTkwODM1MDVa Fw0zODEwMTkwODM1MDVaMC0xKzApBgNVBAMMImlkc2VydmVyLnNlcnZpemljaWUuaW50ZXJuby5n b3YuaXQwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDHraj3iOTCIILTlOzicSEuFt03 kKvQDqGWRd5o7s1W7SP2EtcTmg3xron/sbrLEL/eMUQV/Biz6J4pEGoFpMZQHGxOVypmO7Nc8pkF ot7yUTApr6Ikuy4cUtbx0g5fkQLNb3upIg0Vg1jSnRXEvUCygr/9EeKCUOi/2ptmOVSLad+dT7Ti RsZTwY3FvRWcleDfyYwcIMgz5dLSNLMZqwzQZK1DzvWeD6aGtBKCYPRftacHoESD+6bhukHZ6w95 foRMJLOaBpkp+XfugFQioYvrM0AB1YQZ5DCQRhhc8jejwdY+bOB3eZ1lJY7Oannfu6XPW2fcknel yPt7PGf22rNfAgMBAAGjgYwwgYkwHQYDVR0OBBYEFK3Ah+Do3/zB9XjZ66i4biDpUEbAMGgGA1Ud EQRhMF+CImlkc2VydmVyLnNlcnZpemljaWUuaW50ZXJuby5nb3YuaXSGOWh0dHBzOi8vaWRzZXJ2 ZXIuc2Vydml6aWNpZS5pbnRlcm5vLmdvdi5pdC9pZHAvc2hpYmJvbGV0aDANBgkqhkiG9w0BAQsF AAOCAQEAVtpn/s+lYVf42pAtdgJnGTaSIy8KxHeZobKNYNFEY/XTaZEt9QeV5efUMBVVhxKTTHN0 046DR96WFYXs4PJ9Fpyq6Hmy3k/oUdmHJ1c2bwWF/nZ82CwOO081Yg0GBcfPEmKLUGOBK8T55ncW +RSZadvWTyhTtQhLUtLKcWyzKB5aS3kEE5LSzR8sw3owln9P41Mz+QtL3WeNESRHW0qoQkFotYXX W6Rvh69+GyzJLxvq2qd7D1qoJgOMrarshBKKPk+ABaLYoEf/cru4e0RDIp2mD0jkGOGDkn9XUl+3 ddALq/osTki6CEawkhiZEo6ABEAjEWNkH9W3/ZzvJnWo6Q== 209 | 210 | 211 | 212 | 213 | 214 | 215 | 218 | https://preproduzione.idserver.servizicie.interno.gov.it/idp/profile/SAML2/POST/SSO 219 | 220 | 221 | 222 | 223 | 224 | 225 | 226 | 227 | 229 | 230 | 231 | 232 | 5nSMW/zHmyhaVE4vWyxZvHMBDQWgktouXeWl9fKe504= 233 | 234 | 235 | 236 | UJ23xMKOYhCcRVunnDgor2WLqHEgYeyaAhHr16+kkO6poPog2a9PoiqGUU0Dg+YMvHRJVq0h0sKz M1zeVN1eR3JHIB8HAYtWDDxqTe/rTZcQ1lPWEA+bGqUlLTVc2ukvC4NSB17FT1j7VDIBL3UcdlQc SvR7W6Xw/D+J9Row4iX+rmsJRTy0I+8xj3FdRxMRGR+mSPhpZ1NbINMcSwOV9b+NXbQKqbHhqfH7 SJTGbS/RBZTzFX42jmrAM57TCRG/hwyt6TZyCY29n4dsa0xHGD8sLOvQZ5Zk7qB0HD2DSp31Fjpw zyklYfmGoXrkjdUNnUVyWck+cQXHaXJyokaTNA== 237 | 238 | 239 | 240 | MIIDdTCCAl2gAwIBAgIUU79XEfveueyClDtLkqUlSPZ2o8owDQYJKoZIhvcNAQELBQAwLTErMCkG A1UEAwwiaWRzZXJ2ZXIuc2Vydml6aWNpZS5pbnRlcm5vLmdvdi5pdDAeFw0xODEwMTkwODM1MDVa Fw0zODEwMTkwODM1MDVaMC0xKzApBgNVBAMMImlkc2VydmVyLnNlcnZpemljaWUuaW50ZXJuby5n b3YuaXQwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDHraj3iOTCIILTlOzicSEuFt03 kKvQDqGWRd5o7s1W7SP2EtcTmg3xron/sbrLEL/eMUQV/Biz6J4pEGoFpMZQHGxOVypmO7Nc8pkF ot7yUTApr6Ikuy4cUtbx0g5fkQLNb3upIg0Vg1jSnRXEvUCygr/9EeKCUOi/2ptmOVSLad+dT7Ti RsZTwY3FvRWcleDfyYwcIMgz5dLSNLMZqwzQZK1DzvWeD6aGtBKCYPRftacHoESD+6bhukHZ6w95 foRMJLOaBpkp+XfugFQioYvrM0AB1YQZ5DCQRhhc8jejwdY+bOB3eZ1lJY7Oannfu6XPW2fcknel yPt7PGf22rNfAgMBAAGjgYwwgYkwHQYDVR0OBBYEFK3Ah+Do3/zB9XjZ66i4biDpUEbAMGgGA1Ud EQRhMF+CImlkc2VydmVyLnNlcnZpemljaWUuaW50ZXJuby5nb3YuaXSGOWh0dHBzOi8vaWRzZXJ2 ZXIuc2Vydml6aWNpZS5pbnRlcm5vLmdvdi5pdC9pZHAvc2hpYmJvbGV0aDANBgkqhkiG9w0BAQsF AAOCAQEAVtpn/s+lYVf42pAtdgJnGTaSIy8KxHeZobKNYNFEY/XTaZEt9QeV5efUMBVVhxKTTHN0 046DR96WFYXs4PJ9Fpyq6Hmy3k/oUdmHJ1c2bwWF/nZ82CwOO081Yg0GBcfPEmKLUGOBK8T55ncW +RSZadvWTyhTtQhLUtLKcWyzKB5aS3kEE5LSzR8sw3owln9P41Mz+QtL3WeNESRHW0qoQkFotYXX W6Rvh69+GyzJLxvq2qd7D1qoJgOMrarshBKKPk+ABaLYoEf/cru4e0RDIp2mD0jkGOGDkn9XUl+3 ddALq/osTki6CEawkhiZEo6ABEAjEWNkH9W3/ZzvJnWo6Q== 241 | 242 | 243 | 244 | 245 | AAdzZWNyZXQxqDU6XhTO1MGlMAoXjWFIOcPfK4AhIPsnBAoTNelku/jA7/XaogQJhOrgxCiAIqavL2GUQqQ7VMYPRryyteifD34fsyrHmbPNr1Tz2YJe8wgENUlDvaY31unC/P1kwqTZ17jQYw3qoVZs4neWi9ZUo9j8BoiDAHdoyOOoTiVbDA== 246 | 247 | 249 | 250 | 251 | 253 | 254 | https://app-backend.dev.io.italia.it 255 | 256 | 257 | 258 | 259 | 260 | https://www.spid.gov.it/SpidL3 261 | 262 | 263 | 264 | 265 | 1964-12-30 266 | 267 | 268 | TINIT-RSSBNC64T70G677R 269 | 270 | 271 | BIANCA 272 | 273 | 274 | ROSSI 275 | 276 | 277 | 278 | 279 | `; 280 | --------------------------------------------------------------------------------