Skip to content

Commit

Permalink
API-37717 IDP Metadata loads from Config as Fallback (#494)
Browse files Browse the repository at this point in the history
* IDP metadata trys url first, if failure then try from configs

* jsdoc update

* dev-config.base update

* logging errors when url get fails, or parsing errors occur

* index spIdpMetadata additions
  • Loading branch information
mwise-va authored Jul 18, 2024
1 parent aeff2ce commit e94a44b
Show file tree
Hide file tree
Showing 4 changed files with 151 additions and 96 deletions.
2 changes: 2 additions & 0 deletions dev-config.base.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
4 changes: 2 additions & 2 deletions src/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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;
Expand Down
10 changes: 10 additions & 0 deletions src/cli/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
231 changes: 137 additions & 94 deletions src/idpMetadata.js
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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);
}
});
}

0 comments on commit e94a44b

Please sign in to comment.