diff --git a/dev-config.base.json b/dev-config.base.json index aa37159d..60692467 100644 --- a/dev-config.base.json +++ b/dev-config.base.json @@ -4,6 +4,7 @@ "idpAudience": "https://idp.example.com/audience", "idpBaseUrl": "https://idp.example.com/base/url", "spIdpMetaUrl": "https://sp.example.com/idp/meta/url", + "spIdpMetadata": "./spIdpMetadataExample.xml", "spNameIDFormat": "urn:oasis:names:tc:SAML:2.0:nameid-format:persistent", "spAudience": "sp-audience.example.com", "spIdpIssuer": "sp-idp-issuer.example.com", @@ -33,6 +34,7 @@ { "category": "example2SamlIdp", "spIdpMetaUrl": "https://saml-idp.example2.com/metadata", + "spIdpMetadata": "./spIdpMetadataExample2.xml", "spNameIDFormat": "urn:oasis:names:tc:SAML:2.0:nameid-format:persistent", "spAudience": "https://saml-idp.example2.com", "spAuthnContextClassRef": "http://sp.example2.com/authn/context/class/ref", diff --git a/src/app.js b/src/app.js index 817460a1..7b22ab80 100644 --- a/src/app.js +++ b/src/app.js @@ -72,7 +72,7 @@ const handleMetadata = (argv) => { */ function runServer(argv) { const strategies = new Map(); - IdPMetadata.fetch(argv.spIdpMetaUrl) + IdPMetadata.fetch(argv.spIdpMetaUrl, argv.spIdpMetadata) .then(handleMetadata(argv)) .then(() => { const app = express(); @@ -83,7 +83,7 @@ function runServer(argv) { app.use(passport.initialize()); if (argv.idpSamlLoginsEnabled) { argv.idpSamlLogins.forEach((spIdpEntry) => { - IdPMetadata.fetch(spIdpEntry.spIdpMetaUrl) + IdPMetadata.fetch(spIdpEntry.spIdpMetaUrl, spIdpEntry.spIdpMetadata) .then(handleMetadata(spIdpEntry)) .then(() => { spIdpEntry.spKey = argv.spKey; diff --git a/src/cli/index.js b/src/cli/index.js index 704da48d..d6f3c573 100644 --- a/src/cli/index.js +++ b/src/cli/index.js @@ -230,6 +230,11 @@ export function processArgs() { required: false, string: true, }, + spIdpMetadata: { + description: "IdP SAML Metadata URL", + required: false, + string: true, + }, spAudience: { description: "SP Audience URI / RP Realm", required: false, @@ -477,6 +482,11 @@ export function processArgs() { required: false, string: true, }, + spIdpMetadata: { + description: "IdP SAML Metadata URL", + required: false, + string: true, + }, spAudience: { description: "SP Audience URI / RP Realm", required: false, diff --git a/src/idpMetadata.js b/src/idpMetadata.js index 85dccb13..e71477ab 100644 --- a/src/idpMetadata.js +++ b/src/idpMetadata.js @@ -1,8 +1,15 @@ "use strict"; - +const fs = require("fs"); const xml2js = require("xml2js"); -const logger = require("./logger"); const axios = require("axios"); + +// XML parser configuration +const parserConfig = { + explicitRoot: true, + explicitCharkey: true, + tagNameProcessors: [xml2js.processors.stripPrefix], +}; + /** * Creates a check to receive the binding location * using serviceEl and the bindingUri @@ -43,118 +50,154 @@ function getFirstCert(keyEl) { } return null; } + +/** + * Read and parse XML file + * + * @param {string} filePath Path to the XML file + * @returns {*} Parsed XML object + */ +function parseXML(filePath) { + const parser = new xml2js.Parser(parserConfig); + + return new Promise((resolve, reject) => { + fs.readFile(filePath, "utf8", (err, data) => { + if (err) { + return reject(err); + } + + parser.parseString(data, (err, result) => { + if (err) { + return reject(err); + } + resolve(result); + }); + }); + }); +} + /** * Creates the check for fetching url by requesting the url, * parsing the config parameters, getting the binding location and * parsing the RoleDescriptor metadata * * @param {*} url fetch url + * @param {*} idpMetadataFallback Path to the fallback IDP metadata XML file * @returns {*} returns the RoleDescriptor metadata with parameters sso, slo, nameIdFormat and signingKeys */ -export function fetch(url) { +export function fetch(url, idpMetadataFallback) { return new Promise((resolve, reject) => { const metadata = { sso: {}, slo: {}, nameIdFormats: [], signingKeys: [] }; - if (typeof url === "undefined" || url === null) { - return resolve(metadata); - } - axios .get(url) .then((response) => { const responseData = response.data; - { - const parserConfig = { - explicitRoot: true, - explicitCharkey: true, - tagNameProcessors: [xml2js.processors.stripPrefix], - }, - parser = new xml2js.Parser(parserConfig); - - parser.parseString(responseData, (err, docEl) => { - if (err) { - return reject(err); - } + const parser = new xml2js.Parser(parserConfig); - if (docEl.EntityDescriptor) { - metadata.issuer = docEl.EntityDescriptor.$.entityID; - - if ( - docEl.EntityDescriptor.IDPSSODescriptor && - docEl.EntityDescriptor.IDPSSODescriptor.length === 1 - ) { - metadata.protocol = "samlp"; - let ssoEl = docEl.EntityDescriptor.IDPSSODescriptor[0]; - metadata.signRequest = ssoEl.$.WantAuthnRequestsSigned; - - ssoEl.KeyDescriptor.forEach((keyEl) => { - if ( - keyEl.$.use && - keyEl.$.use.toLowerCase() !== "encryption" - ) { - const signingKey = {}; - signingKey.cert = getFirstCert(keyEl); - if (keyEl.$.active && keyEl.$.active === "true") { - signingKey.active = true; - } - metadata.signingKeys.push(signingKey); - } - }); - - if (ssoEl.NameIDFormat) { - ssoEl.NameIDFormat.forEach((element) => { - if (element._) { - metadata.nameIdFormats.push(element._); - } - }); - } - - metadata.sso.redirectUrl = getBindingLocation( - ssoEl.SingleSignOnService, - "urn:oasis:names:tc:saml:2.0:bindings:http-redirect" - ); - metadata.sso.postUrl = getBindingLocation( - ssoEl.SingleSignOnService, - "urn:oasis:names:tc:saml:2.0:bindings:http-post" - ); - - metadata.slo.redirectUrl = getBindingLocation( - ssoEl.SingleLogoutService, - "urn:oasis:names:tc:saml:2.0:bindings:http-redirect" - ); - metadata.slo.postUrl = getBindingLocation( - ssoEl.SingleLogoutService, - "urn:oasis:names:tc:saml:2.0:bindings:http-post" - ); - } - } + parser.parseString(responseData, (err, docEl) => { + if (err || !docEl) { + console.error( + "Error parsing response data, fallback to idpMetadataFallback" + ); + parseXML(idpMetadataFallback) + .then((xmlData) => processMetadata(xmlData, metadata)) + .then(resolve) + .catch(reject); + } else { + processMetadata(docEl, metadata).then(resolve).catch(reject); + } + }); + }) + .catch(() => { + console.error( + "Error fetching metadata from URL, fallback to idpMetadataFallback" + ); + parseXML(idpMetadataFallback) + .then((xmlData) => processMetadata(xmlData, metadata)) + .then(resolve) + .catch(reject); + }); + }); +} + +/** + * Process IDP XML metadata + * + * @param {*} docEl Parsed XML object + * @param {*} metadata Metadata object to populate + * @returns {*} RoleDescriptor metadata with parameters sso, slo, nameIdFormat, and signingKeys + */ +async function processMetadata(docEl, metadata) { + return new Promise((resolve, reject) => { + try { + if (docEl.EntityDescriptor) { + metadata.issuer = docEl.EntityDescriptor.$.entityID; + + if ( + docEl.EntityDescriptor.IDPSSODescriptor && + docEl.EntityDescriptor.IDPSSODescriptor.length === 1 + ) { + metadata.protocol = "samlp"; + let ssoEl = docEl.EntityDescriptor.IDPSSODescriptor[0]; + metadata.signRequest = ssoEl.$.WantAuthnRequestsSigned; - if (docEl.EntityDescriptor.RoleDescriptor) { - metadata.protocol = "wsfed"; - try { - let roleEl = docEl.EntityDescriptor.RoleDescriptor.find( - (el) => { - return el.$["xsi:type"].endsWith( - ":SecurityTokenServiceType" - ); - } - ); - metadata.sso.redirectUrl = - roleEl.PassiveRequestorEndpoint[0].EndpointReference[0].Address[0]._; - - roleEl.KeyDescriptor.forEach((keyEl) => { - metadata.signingKeys.push(getFirstCert(keyEl)); - }); - } catch (e) { - logger.error("unable to parse RoleDescriptor metadata", e); + ssoEl.KeyDescriptor.forEach((keyEl) => { + if (keyEl.$.use && keyEl.$.use.toLowerCase() !== "encryption") { + const signingKey = {}; + signingKey.cert = getFirstCert(keyEl); + if (keyEl.$.active && keyEl.$.active === "true") { + signingKey.active = true; } + metadata.signingKeys.push(signingKey); } - return resolve(metadata); }); + + if (ssoEl.NameIDFormat) { + ssoEl.NameIDFormat.forEach((element) => { + if (element._) { + metadata.nameIdFormats.push(element._); + } + }); + } + + metadata.sso.redirectUrl = getBindingLocation( + ssoEl.SingleSignOnService, + "urn:oasis:names:tc:saml:2.0:bindings:http-redirect" + ); + metadata.sso.postUrl = getBindingLocation( + ssoEl.SingleSignOnService, + "urn:oasis:names:tc:saml:2.0:bindings:http-post" + ); + + metadata.slo.redirectUrl = getBindingLocation( + ssoEl.SingleLogoutService, + "urn:oasis:names:tc:saml:2.0:bindings:http-redirect" + ); + metadata.slo.postUrl = getBindingLocation( + ssoEl.SingleLogoutService, + "urn:oasis:names:tc:saml:2.0:bindings:http-post" + ); } - }) - .catch(() => { - logger.error("Error receiving metadata"); - }); + } + + if (docEl.EntityDescriptor.RoleDescriptor) { + metadata.protocol = "wsfed"; + let roleEl = docEl.EntityDescriptor.RoleDescriptor.find((el) => + el.$["xsi:type"].endsWith(":SecurityTokenServiceType") + ); + + metadata.sso.redirectUrl = + roleEl.PassiveRequestorEndpoint[0].EndpointReference[0].Address[0]._; + + roleEl.KeyDescriptor.forEach((keyEl) => { + metadata.signingKeys.push(getFirstCert(keyEl)); + }); + } + + resolve(metadata); + } catch (error) { + reject(error); + } }); }