passport-saml
Version:
SAML 2.0 authentication strategy for Passport
1,027 lines • 55.1 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.SAML = void 0;
const debug_1 = require("debug");
const debug = (0, debug_1.default)("node-saml");
const zlib = require("zlib");
const crypto = require("crypto");
const url_1 = require("url");
const querystring = require("querystring");
const util = require("util");
const inmemory_cache_provider_1 = require("./inmemory-cache-provider");
const algorithms = require("./algorithms");
const saml_post_signing_1 = require("./saml-post-signing");
const types_1 = require("./types");
const types_2 = require("../passport-saml/types");
const utility_1 = require("./utility");
const xml_1 = require("./xml");
const inflateRawAsync = util.promisify(zlib.inflateRaw);
const deflateRawAsync = util.promisify(zlib.deflateRaw);
async function processValidlySignedPostRequestAsync(self, doc, dom) {
const request = doc.LogoutRequest;
if (request) {
const profile = {};
if (request.$.ID) {
profile.ID = request.$.ID;
}
else {
throw new Error("Missing SAML LogoutRequest ID");
}
const issuer = request.Issuer;
if (issuer && issuer[0]._) {
profile.issuer = issuer[0]._;
}
else {
throw new Error("Missing SAML issuer");
}
const nameID = await self._getNameIdAsync(self, dom);
if (nameID) {
profile.nameID = nameID.value;
if (nameID.format) {
profile.nameIDFormat = nameID.format;
}
}
else {
throw new Error("Missing SAML NameID");
}
const sessionIndex = request.SessionIndex;
if (sessionIndex) {
profile.sessionIndex = sessionIndex[0]._;
}
return { profile, loggedOut: true };
}
else {
throw new Error("Unknown SAML request message");
}
}
async function processValidlySignedSamlLogoutAsync(self, doc, dom) {
const response = doc.LogoutResponse;
const request = doc.LogoutRequest;
if (response) {
return { profile: null, loggedOut: true };
}
else if (request) {
return await processValidlySignedPostRequestAsync(self, doc, dom);
}
else {
throw new Error("Unknown SAML response message");
}
}
async function promiseWithNameID(nameid) {
const format = xml_1.xpath.selectAttributes(nameid, "@Format");
return {
value: nameid.textContent,
format: format && format[0] && format[0].nodeValue,
};
}
class SAML {
constructor(ctorOptions) {
this.options = this.initialize(ctorOptions);
this.cacheProvider = this.options.cacheProvider;
}
initialize(ctorOptions) {
var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o, _p, _q, _r, _s, _t, _u, _v, _w, _x, _y;
if (!ctorOptions) {
throw new TypeError("SamlOptions required on construction");
}
const options = {
...ctorOptions,
passive: (_a = ctorOptions.passive) !== null && _a !== void 0 ? _a : false,
disableRequestedAuthnContext: (_b = ctorOptions.disableRequestedAuthnContext) !== null && _b !== void 0 ? _b : false,
additionalParams: (_c = ctorOptions.additionalParams) !== null && _c !== void 0 ? _c : {},
additionalAuthorizeParams: (_d = ctorOptions.additionalAuthorizeParams) !== null && _d !== void 0 ? _d : {},
additionalLogoutParams: (_e = ctorOptions.additionalLogoutParams) !== null && _e !== void 0 ? _e : {},
forceAuthn: (_f = ctorOptions.forceAuthn) !== null && _f !== void 0 ? _f : false,
skipRequestCompression: (_g = ctorOptions.skipRequestCompression) !== null && _g !== void 0 ? _g : false,
disableRequestAcsUrl: (_h = ctorOptions.disableRequestAcsUrl) !== null && _h !== void 0 ? _h : false,
acceptedClockSkewMs: (_j = ctorOptions.acceptedClockSkewMs) !== null && _j !== void 0 ? _j : 0,
maxAssertionAgeMs: (_k = ctorOptions.maxAssertionAgeMs) !== null && _k !== void 0 ? _k : 0,
path: (_l = ctorOptions.path) !== null && _l !== void 0 ? _l : "/saml/consume",
host: (_m = ctorOptions.host) !== null && _m !== void 0 ? _m : "localhost",
issuer: (_o = ctorOptions.issuer) !== null && _o !== void 0 ? _o : "onelogin_saml",
identifierFormat: ctorOptions.identifierFormat === undefined
? "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress"
: ctorOptions.identifierFormat,
wantAssertionsSigned: (_p = ctorOptions.wantAssertionsSigned) !== null && _p !== void 0 ? _p : false,
authnContext: (_q = ctorOptions.authnContext) !== null && _q !== void 0 ? _q : [
"urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport",
],
validateInResponseTo: (_r = ctorOptions.validateInResponseTo) !== null && _r !== void 0 ? _r : false,
cert: (0, utility_1.assertRequired)(ctorOptions.cert, "cert is required"),
requestIdExpirationPeriodMs: (_s = ctorOptions.requestIdExpirationPeriodMs) !== null && _s !== void 0 ? _s : 28800000,
cacheProvider: (_t = ctorOptions.cacheProvider) !== null && _t !== void 0 ? _t : new inmemory_cache_provider_1.CacheProvider({
keyExpirationPeriodMs: ctorOptions.requestIdExpirationPeriodMs,
}),
logoutUrl: (_v = (_u = ctorOptions.logoutUrl) !== null && _u !== void 0 ? _u : ctorOptions.entryPoint) !== null && _v !== void 0 ? _v : "",
signatureAlgorithm: (_w = ctorOptions.signatureAlgorithm) !== null && _w !== void 0 ? _w : "sha1",
authnRequestBinding: (_x = ctorOptions.authnRequestBinding) !== null && _x !== void 0 ? _x : "HTTP-Redirect",
racComparison: (_y = ctorOptions.racComparison) !== null && _y !== void 0 ? _y : "exact",
};
/**
* List of possible values:
* - exact : Assertion context must exactly match a context in the list
* - minimum: Assertion context must be at least as strong as a context in the list
* - maximum: Assertion context must be no stronger than a context in the list
* - better: Assertion context must be stronger than all contexts in the list
*/
if (!["exact", "minimum", "maximum", "better"].includes(options.racComparison)) {
throw new TypeError("racComparison must be one of ['exact', 'minimum', 'maximum', 'better']");
}
return options;
}
getCallbackUrl(host) {
// Post-auth destination
if (this.options.callbackUrl) {
return this.options.callbackUrl;
}
else {
const url = new url_1.URL("http://localhost");
if (host) {
url.host = host;
}
else {
url.host = this.options.host;
}
if (this.options.protocol) {
url.protocol = this.options.protocol;
}
url.pathname = this.options.path;
return url.toString();
}
}
_generateUniqueID() {
return crypto.randomBytes(10).toString("hex");
}
generateInstant() {
return new Date().toISOString();
}
signRequest(samlMessage) {
this.options.privateKey = (0, utility_1.assertRequired)(this.options.privateKey, "privateKey is required");
const samlMessageToSign = {};
samlMessage.SigAlg = algorithms.getSigningAlgorithm(this.options.signatureAlgorithm);
const signer = algorithms.getSigner(this.options.signatureAlgorithm);
if (samlMessage.SAMLRequest) {
samlMessageToSign.SAMLRequest = samlMessage.SAMLRequest;
}
if (samlMessage.SAMLResponse) {
samlMessageToSign.SAMLResponse = samlMessage.SAMLResponse;
}
if (samlMessage.RelayState) {
samlMessageToSign.RelayState = samlMessage.RelayState;
}
if (samlMessage.SigAlg) {
samlMessageToSign.SigAlg = samlMessage.SigAlg;
}
signer.update(querystring.stringify(samlMessageToSign));
samlMessage.Signature = signer.sign(this._keyToPEM(this.options.privateKey), "base64");
}
async generateAuthorizeRequestAsync(isPassive, isHttpPostBinding, host) {
this.options.entryPoint = (0, utility_1.assertRequired)(this.options.entryPoint, "entryPoint is required");
const id = "_" + this._generateUniqueID();
const instant = this.generateInstant();
if (this.options.validateInResponseTo) {
await this.cacheProvider.saveAsync(id, instant);
}
const request = {
"samlp:AuthnRequest": {
"@xmlns:samlp": "urn:oasis:names:tc:SAML:2.0:protocol",
"@ID": id,
"@Version": "2.0",
"@IssueInstant": instant,
"@ProtocolBinding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
"@Destination": this.options.entryPoint,
"saml:Issuer": {
"@xmlns:saml": "urn:oasis:names:tc:SAML:2.0:assertion",
"#text": this.options.issuer,
},
},
};
if (isPassive)
request["samlp:AuthnRequest"]["@IsPassive"] = true;
if (this.options.forceAuthn) {
request["samlp:AuthnRequest"]["@ForceAuthn"] = true;
}
if (!this.options.disableRequestAcsUrl) {
request["samlp:AuthnRequest"]["@AssertionConsumerServiceURL"] = this.getCallbackUrl(host);
}
if (this.options.identifierFormat != null) {
request["samlp:AuthnRequest"]["samlp:NameIDPolicy"] = {
"@xmlns:samlp": "urn:oasis:names:tc:SAML:2.0:protocol",
"@Format": this.options.identifierFormat,
"@AllowCreate": "true",
};
}
if (!this.options.disableRequestedAuthnContext) {
const authnContextClassRefs = [];
this.options.authnContext.forEach(function (value) {
authnContextClassRefs.push({
"@xmlns:saml": "urn:oasis:names:tc:SAML:2.0:assertion",
"#text": value,
});
});
request["samlp:AuthnRequest"]["samlp:RequestedAuthnContext"] = {
"@xmlns:samlp": "urn:oasis:names:tc:SAML:2.0:protocol",
"@Comparison": this.options.racComparison,
"saml:AuthnContextClassRef": authnContextClassRefs,
};
}
if (this.options.attributeConsumingServiceIndex != null) {
request["samlp:AuthnRequest"]["@AttributeConsumingServiceIndex"] =
this.options.attributeConsumingServiceIndex;
}
if (this.options.providerName != null) {
request["samlp:AuthnRequest"]["@ProviderName"] = this.options.providerName;
}
if (this.options.scoping != null) {
const scoping = {
"@xmlns:samlp": "urn:oasis:names:tc:SAML:2.0:protocol",
};
if (typeof this.options.scoping.proxyCount === "number") {
scoping["@ProxyCount"] = this.options.scoping.proxyCount;
}
if (this.options.scoping.idpList) {
scoping["samlp:IDPList"] = this.options.scoping.idpList.map((idpListItem) => {
const formattedIdpListItem = {
"@xmlns:samlp": "urn:oasis:names:tc:SAML:2.0:protocol",
};
if (idpListItem.entries) {
formattedIdpListItem["samlp:IDPEntry"] = idpListItem.entries.map((entry) => {
const formattedEntry = {
"@xmlns:samlp": "urn:oasis:names:tc:SAML:2.0:protocol",
};
formattedEntry["@ProviderID"] = entry.providerId;
if (entry.name) {
formattedEntry["@Name"] = entry.name;
}
if (entry.loc) {
formattedEntry["@Loc"] = entry.loc;
}
return formattedEntry;
});
}
if (idpListItem.getComplete) {
formattedIdpListItem["samlp:GetComplete"] = idpListItem.getComplete;
}
return formattedIdpListItem;
});
}
if (this.options.scoping.requesterId) {
scoping["samlp:RequesterID"] = this.options.scoping.requesterId;
}
request["samlp:AuthnRequest"]["samlp:Scoping"] = scoping;
}
let stringRequest = (0, xml_1.buildXmlBuilderObject)(request, false);
// TODO: maybe we should always sign here
if (isHttpPostBinding && (0, types_1.isValidSamlSigningOptions)(this.options)) {
stringRequest = (0, saml_post_signing_1.signAuthnRequestPost)(stringRequest, this.options);
}
return stringRequest;
}
async _generateLogoutRequest(user) {
const id = "_" + this._generateUniqueID();
const instant = this.generateInstant();
const request = {
"samlp:LogoutRequest": {
"@xmlns:samlp": "urn:oasis:names:tc:SAML:2.0:protocol",
"@xmlns:saml": "urn:oasis:names:tc:SAML:2.0:assertion",
"@ID": id,
"@Version": "2.0",
"@IssueInstant": instant,
"@Destination": this.options.logoutUrl,
"saml:Issuer": {
"@xmlns:saml": "urn:oasis:names:tc:SAML:2.0:assertion",
"#text": this.options.issuer,
},
"saml:NameID": {
"@Format": user.nameIDFormat,
"#text": user.nameID,
},
},
};
if (user.nameQualifier != null) {
request["samlp:LogoutRequest"]["saml:NameID"]["@NameQualifier"] = user.nameQualifier;
}
if (user.spNameQualifier != null) {
request["samlp:LogoutRequest"]["saml:NameID"]["@SPNameQualifier"] = user.spNameQualifier;
}
if (user.sessionIndex) {
request["samlp:LogoutRequest"]["saml2p:SessionIndex"] = {
"@xmlns:saml2p": "urn:oasis:names:tc:SAML:2.0:protocol",
"#text": user.sessionIndex,
};
}
await this.cacheProvider.saveAsync(id, instant);
return (0, xml_1.buildXmlBuilderObject)(request, false);
}
_generateLogoutResponse(logoutRequest) {
const id = "_" + this._generateUniqueID();
const instant = this.generateInstant();
const request = {
"samlp:LogoutResponse": {
"@xmlns:samlp": "urn:oasis:names:tc:SAML:2.0:protocol",
"@xmlns:saml": "urn:oasis:names:tc:SAML:2.0:assertion",
"@ID": id,
"@Version": "2.0",
"@IssueInstant": instant,
"@Destination": this.options.logoutUrl,
"@InResponseTo": logoutRequest.ID,
"saml:Issuer": {
"#text": this.options.issuer,
},
"samlp:Status": {
"samlp:StatusCode": {
"@Value": "urn:oasis:names:tc:SAML:2.0:status:Success",
},
},
},
};
return (0, xml_1.buildXmlBuilderObject)(request, false);
}
async _requestToUrlAsync(request, response, operation, additionalParameters) {
this.options.entryPoint = (0, utility_1.assertRequired)(this.options.entryPoint, "entryPoint is required");
let buffer;
if (this.options.skipRequestCompression) {
buffer = Buffer.from((request || response), "utf8");
}
else {
buffer = await deflateRawAsync((request || response));
}
const base64 = buffer.toString("base64");
let target = new url_1.URL(this.options.entryPoint);
if (operation === "logout") {
if (this.options.logoutUrl) {
target = new url_1.URL(this.options.logoutUrl);
}
}
else if (operation !== "authorize") {
throw new Error("Unknown operation: " + operation);
}
const samlMessage = request
? {
SAMLRequest: base64,
}
: {
SAMLResponse: base64,
};
Object.keys(additionalParameters).forEach((k) => {
samlMessage[k] = additionalParameters[k];
});
if (this.options.privateKey != null) {
if (!this.options.entryPoint) {
throw new Error('"entryPoint" config parameter is required for signed messages');
}
// sets .SigAlg and .Signature
this.signRequest(samlMessage);
}
Object.keys(samlMessage).forEach((k) => {
target.searchParams.set(k, samlMessage[k]);
});
return target.toString();
}
_getAdditionalParams(RelayState, operation, overrideParams) {
const additionalParams = {};
if (typeof RelayState === "string" && RelayState.length > 0) {
additionalParams.RelayState = RelayState;
}
const optionsAdditionalParams = this.options.additionalParams;
Object.keys(optionsAdditionalParams).forEach(function (k) {
additionalParams[k] = optionsAdditionalParams[k];
});
let optionsAdditionalParamsForThisOperation = {};
if (operation == "authorize") {
optionsAdditionalParamsForThisOperation = this.options.additionalAuthorizeParams;
}
if (operation == "logout") {
optionsAdditionalParamsForThisOperation = this.options.additionalLogoutParams;
}
Object.keys(optionsAdditionalParamsForThisOperation).forEach(function (k) {
additionalParams[k] = optionsAdditionalParamsForThisOperation[k];
});
overrideParams = overrideParams !== null && overrideParams !== void 0 ? overrideParams : {};
Object.keys(overrideParams).forEach(function (k) {
additionalParams[k] = overrideParams[k];
});
return additionalParams;
}
async getAuthorizeUrlAsync(RelayState, host, options) {
const request = await this.generateAuthorizeRequestAsync(this.options.passive, false, host);
const operation = "authorize";
const overrideParams = options ? options.additionalParams || {} : {};
return await this._requestToUrlAsync(request, null, operation, this._getAdditionalParams(RelayState, operation, overrideParams));
}
async getAuthorizeFormAsync(RelayState, host) {
this.options.entryPoint = (0, utility_1.assertRequired)(this.options.entryPoint, "entryPoint is required");
// The quoteattr() function is used in a context, where the result will not be evaluated by javascript
// but must be interpreted by an XML or HTML parser, and it must absolutely avoid breaking the syntax
// of an element attribute.
const quoteattr = function (s, preserveCR) {
const preserveCRChar = preserveCR ? " " : "\n";
return (("" + s) // Forces the conversion to string.
.replace(/&/g, "&") // This MUST be the 1st replacement.
.replace(/'/g, "'") // The 4 other predefined entities, required.
.replace(/"/g, """)
.replace(/</g, "<")
.replace(/>/g, ">")
// Add other replacements here for HTML only
// Or for XML, only if the named entities are defined in its DTD.
.replace(/\r\n/g, preserveCRChar) // Must be before the next replacement.
.replace(/[\r\n]/g, preserveCRChar));
};
const request = await this.generateAuthorizeRequestAsync(this.options.passive, true, host);
let buffer;
if (this.options.skipRequestCompression) {
buffer = Buffer.from(request, "utf8");
}
else {
buffer = await deflateRawAsync(request);
}
const operation = "authorize";
const additionalParameters = this._getAdditionalParams(RelayState, operation);
const samlMessage = {
SAMLRequest: buffer.toString("base64"),
};
Object.keys(additionalParameters).forEach((k) => {
samlMessage[k] = additionalParameters[k] || "";
});
const formInputs = Object.keys(samlMessage)
.map((k) => {
return '<input type="hidden" name="' + k + '" value="' + quoteattr(samlMessage[k]) + '" />';
})
.join("\r\n");
return [
"<!DOCTYPE html>",
"<html>",
"<head>",
'<meta charset="utf-8">',
'<meta http-equiv="x-ua-compatible" content="ie=edge">',
"</head>",
'<body onload="document.forms[0].submit()">',
"<noscript>",
"<p><strong>Note:</strong> Since your browser does not support JavaScript, you must press the button below once to proceed.</p>",
"</noscript>",
'<form method="post" action="' + encodeURI(this.options.entryPoint) + '">',
formInputs,
'<input type="submit" value="Submit" />',
"</form>",
'<script>document.forms[0].style.display="none";</script>',
"</body>",
"</html>",
].join("\r\n");
}
async getLogoutUrlAsync(user, RelayState, options) {
const request = await this._generateLogoutRequest(user);
const operation = "logout";
const overrideParams = options ? options.additionalParams || {} : {};
return await this._requestToUrlAsync(request, null, operation, this._getAdditionalParams(RelayState, operation, overrideParams));
}
getLogoutResponseUrl(samlLogoutRequest, RelayState, options, callback) {
util.callbackify(() => this.getLogoutResponseUrlAsync(samlLogoutRequest, RelayState, options))(callback);
}
async getLogoutResponseUrlAsync(samlLogoutRequest, RelayState, options // add RelayState,
) {
const response = this._generateLogoutResponse(samlLogoutRequest);
const operation = "logout";
const overrideParams = options ? options.additionalParams || {} : {};
return await this._requestToUrlAsync(null, response, operation, this._getAdditionalParams(RelayState, operation, overrideParams));
}
_certToPEM(cert) {
cert = cert.match(/.{1,64}/g).join("\n");
if (cert.indexOf("-BEGIN CERTIFICATE-") === -1)
cert = "-----BEGIN CERTIFICATE-----\n" + cert;
if (cert.indexOf("-END CERTIFICATE-") === -1)
cert = cert + "\n-----END CERTIFICATE-----\n";
return cert;
}
async certsToCheck() {
let checkedCerts;
if (typeof this.options.cert === "function") {
checkedCerts = await util
.promisify(this.options.cert)()
.then((certs) => {
certs = (0, utility_1.assertRequired)(certs, "callback didn't return cert");
if (!Array.isArray(certs)) {
certs = [certs];
}
return certs;
});
}
else if (Array.isArray(this.options.cert)) {
checkedCerts = this.options.cert;
}
else {
checkedCerts = [this.options.cert];
}
checkedCerts.forEach((cert) => {
(0, utility_1.assertRequired)(cert, "unknown cert found");
});
return checkedCerts;
}
// This function checks that the |currentNode| in the |fullXml| document contains exactly 1 valid
// signature of the |currentNode|.
//
// See https://github.com/bergie/passport-saml/issues/19 for references to some of the attack
// vectors against SAML signature verification.
validateSignature(fullXml, currentNode, certs) {
const xpathSigQuery = ".//*[" +
"local-name(.)='Signature' and " +
"namespace-uri(.)='http://www.w3.org/2000/09/xmldsig#' and " +
"descendant::*[local-name(.)='Reference' and @URI='#" +
currentNode.getAttribute("ID") +
"']" +
"]";
const signatures = xml_1.xpath.selectElements(currentNode, xpathSigQuery);
// This function is expecting to validate exactly one signature, so if we find more or fewer
// than that, reject.
if (signatures.length !== 1) {
return false;
}
const xpathTransformQuery = ".//*[" +
"local-name(.)='Transform' and " +
"namespace-uri(.)='http://www.w3.org/2000/09/xmldsig#' and " +
"ancestor::*[local-name(.)='Reference' and @URI='#" +
currentNode.getAttribute("ID") +
"']" +
"]";
const transforms = xml_1.xpath.selectElements(currentNode, xpathTransformQuery);
// Reject also XMLDSIG with more than 2 Transform
if (transforms.length > 2) {
// do not return false, throw an error so that it can be caught by tests differently
throw new Error("Invalid signature, too many transforms");
}
const signature = signatures[0];
return certs.some((certToCheck) => {
return (0, xml_1.validateXmlSignatureForCert)(signature, this._certToPEM(certToCheck), fullXml, currentNode);
});
}
async validatePostResponseAsync(container) {
let xml, doc, inResponseTo;
try {
xml = Buffer.from(container.SAMLResponse, "base64").toString("utf8");
doc = (0, xml_1.parseDomFromString)(xml);
if (!Object.prototype.hasOwnProperty.call(doc, "documentElement"))
throw new Error("SAMLResponse is not valid base64-encoded XML");
const inResponseToNodes = xml_1.xpath.selectAttributes(doc, "/*[local-name()='Response']/@InResponseTo");
if (inResponseToNodes) {
inResponseTo = inResponseToNodes.length ? inResponseToNodes[0].nodeValue : null;
await this.validateInResponseTo(inResponseTo);
}
const certs = await this.certsToCheck();
// Check if this document has a valid top-level signature which applies to the entire XML document
let validSignature = false;
if (this.validateSignature(xml, doc.documentElement, certs) &&
Array.from(doc.childNodes).filter((n) => n.tagName != null && n.childNodes != null).length === 1) {
validSignature = true;
}
const assertions = xml_1.xpath.selectElements(doc, "/*[local-name()='Response']/*[local-name()='Assertion']");
const encryptedAssertions = xml_1.xpath.selectElements(doc, "/*[local-name()='Response']/*[local-name()='EncryptedAssertion']");
if (assertions.length + encryptedAssertions.length > 1) {
// There's no reason I know of that we want to handle multiple assertions, and it seems like a
// potential risk vector for signature scope issues, so treat this as an invalid signature
throw new Error("Invalid signature: multiple assertions");
}
if (assertions.length == 1) {
if ((this.options.wantAssertionsSigned || !validSignature) &&
!this.validateSignature(xml, assertions[0], certs)) {
throw new Error("Invalid signature");
}
return await this.processValidlySignedAssertionAsync(assertions[0].toString(), xml, inResponseTo);
}
if (encryptedAssertions.length == 1) {
this.options.decryptionPvk = (0, utility_1.assertRequired)(this.options.decryptionPvk, "No decryption key for encrypted SAML response");
const encryptedAssertionXml = encryptedAssertions[0].toString();
const decryptedXml = await (0, xml_1.decryptXml)(encryptedAssertionXml, this.options.decryptionPvk);
const decryptedDoc = (0, xml_1.parseDomFromString)(decryptedXml);
const decryptedAssertions = xml_1.xpath.selectElements(decryptedDoc, "/*[local-name()='Assertion']");
if (decryptedAssertions.length != 1)
throw new Error("Invalid EncryptedAssertion content");
if ((this.options.wantAssertionsSigned || !validSignature) &&
!this.validateSignature(decryptedXml, decryptedAssertions[0], certs)) {
throw new Error("Invalid signature from encrypted assertion");
}
return await this.processValidlySignedAssertionAsync(decryptedAssertions[0].toString(), xml, inResponseTo);
}
// If there's no assertion, fall back on xml2js response parsing for the status &
// LogoutResponse code.
const xmljsDoc = await (0, xml_1.parseXml2JsFromString)(xml);
const response = xmljsDoc.Response;
if (response) {
const assertion = response.Assertion;
if (!assertion) {
const status = response.Status;
if (status) {
const statusCode = status[0].StatusCode;
if (statusCode &&
statusCode[0].$.Value === "urn:oasis:names:tc:SAML:2.0:status:Responder") {
const nestedStatusCode = statusCode[0].StatusCode;
if (nestedStatusCode &&
nestedStatusCode[0].$.Value === "urn:oasis:names:tc:SAML:2.0:status:NoPassive") {
if (!validSignature) {
throw new Error("Invalid signature: NoPassive");
}
return { profile: null, loggedOut: false };
}
}
// Note that we're not requiring a valid signature before this logic -- since we are
// throwing an error in any case, and some providers don't sign error results,
// let's go ahead and give the potentially more helpful error.
if (statusCode && statusCode[0].$.Value) {
const msgType = statusCode[0].$.Value.match(/[^:]*$/)[0];
if (msgType != "Success") {
let msg = "unspecified";
if (status[0].StatusMessage) {
msg = status[0].StatusMessage[0]._;
}
else if (statusCode[0].StatusCode) {
msg = statusCode[0].StatusCode[0].$.Value.match(/[^:]*$/)[0];
}
const statusXml = (0, xml_1.buildXml2JsObject)("Status", status[0]);
throw new types_2.ErrorWithXmlStatus("SAML provider returned " + msgType + " error: " + msg, statusXml);
}
}
}
}
throw new Error("Missing SAML assertion");
}
else {
if (!validSignature) {
throw new Error("Invalid signature: No response found");
}
const logoutResponse = xmljsDoc.LogoutResponse;
if (logoutResponse) {
return { profile: null, loggedOut: true };
}
else {
throw new Error("Unknown SAML response message");
}
}
}
catch (err) {
debug("validatePostResponse resulted in an error: %s", err);
if (this.options.validateInResponseTo) {
await this.cacheProvider.removeAsync(inResponseTo);
}
throw err;
}
}
async validateInResponseTo(inResponseTo) {
if (this.options.validateInResponseTo) {
if (inResponseTo) {
const result = await this.cacheProvider.getAsync(inResponseTo);
if (!result)
throw new Error("InResponseTo is not valid");
return;
}
else {
throw new Error("InResponseTo is missing from response");
}
}
else {
return;
}
}
async validateRedirectAsync(container, originalQuery) {
const samlMessageType = container.SAMLRequest ? "SAMLRequest" : "SAMLResponse";
const data = Buffer.from(container[samlMessageType], "base64");
const inflated = await inflateRawAsync(data);
const dom = (0, xml_1.parseDomFromString)(inflated.toString());
const doc = await (0, xml_1.parseXml2JsFromString)(inflated);
samlMessageType === "SAMLResponse"
? await this.verifyLogoutResponse(doc)
: this.verifyLogoutRequest(doc);
await this.hasValidSignatureForRedirect(container, originalQuery);
return await processValidlySignedSamlLogoutAsync(this, doc, dom);
}
async hasValidSignatureForRedirect(container, originalQuery) {
const tokens = originalQuery.split("&");
const getParam = (key) => {
const exists = tokens.filter((t) => {
return new RegExp(key).test(t);
});
return exists[0];
};
if (container.Signature) {
let urlString = getParam("SAMLRequest") || getParam("SAMLResponse");
if (getParam("RelayState")) {
urlString += "&" + getParam("RelayState");
}
urlString += "&" + getParam("SigAlg");
const certs = await this.certsToCheck();
const hasValidQuerySignature = certs.some((cert) => {
return this.validateSignatureForRedirect(urlString, container.Signature, container.SigAlg, cert);
});
if (!hasValidQuerySignature) {
throw new Error("Invalid query signature");
}
}
else {
return true;
}
}
validateSignatureForRedirect(urlString, signature, alg, cert) {
// See if we support a matching algorithm, case-insensitive. Otherwise, throw error.
function hasMatch(ourAlgo) {
// The incoming algorithm is forwarded as a URL.
// We trim everything before the last # get something we can compare to the Node.js list
const algFromURI = alg.toLowerCase().replace(/.*#(.*)$/, "$1");
return ourAlgo.toLowerCase() === algFromURI;
}
const i = crypto.getHashes().findIndex(hasMatch);
let matchingAlgo;
if (i > -1) {
matchingAlgo = crypto.getHashes()[i];
}
else {
throw new Error(alg + " is not supported");
}
const verifier = crypto.createVerify(matchingAlgo);
verifier.update(urlString);
return verifier.verify(this._certToPEM(cert), signature, "base64");
}
verifyLogoutRequest(doc) {
this.verifyIssuer(doc.LogoutRequest);
const nowMs = new Date().getTime();
const conditions = doc.LogoutRequest.$;
const conErr = this.checkTimestampsValidityError(nowMs, conditions.NotBefore, conditions.NotOnOrAfter);
if (conErr) {
throw conErr;
}
}
async verifyLogoutResponse(doc) {
const statusCode = doc.LogoutResponse.Status[0].StatusCode[0].$.Value;
if (statusCode !== "urn:oasis:names:tc:SAML:2.0:status:Success")
throw new Error("Bad status code: " + statusCode);
this.verifyIssuer(doc.LogoutResponse);
const inResponseTo = doc.LogoutResponse.$.InResponseTo;
if (inResponseTo) {
return this.validateInResponseTo(inResponseTo);
}
return true;
}
verifyIssuer(samlMessage) {
if (this.options.idpIssuer != null) {
const issuer = samlMessage.Issuer;
if (issuer) {
if (issuer[0]._ !== this.options.idpIssuer)
throw new Error("Unknown SAML issuer. Expected: " + this.options.idpIssuer + " Received: " + issuer[0]._);
}
else {
throw new Error("Missing SAML issuer");
}
}
}
async processValidlySignedAssertionAsync(xml, samlResponseXml, inResponseTo) {
let msg;
const nowMs = new Date().getTime();
const profile = {};
const doc = await (0, xml_1.parseXml2JsFromString)(xml);
const parsedAssertion = doc;
const assertion = doc.Assertion;
getInResponseTo: {
const issuer = assertion.Issuer;
if (issuer && issuer[0]._) {
profile.issuer = issuer[0]._;
}
if (inResponseTo) {
profile.inResponseTo = inResponseTo;
}
const authnStatement = assertion.AuthnStatement;
if (authnStatement) {
if (authnStatement[0].$ && authnStatement[0].$.SessionIndex) {
profile.sessionIndex = authnStatement[0].$.SessionIndex;
}
}
const subject = assertion.Subject;
let subjectConfirmation, confirmData;
if (subject) {
const nameID = subject[0].NameID;
if (nameID && nameID[0]._) {
profile.nameID = nameID[0]._;
if (nameID[0].$ && nameID[0].$.Format) {
profile.nameIDFormat = nameID[0].$.Format;
profile.nameQualifier = nameID[0].$.NameQualifier;
profile.spNameQualifier = nameID[0].$.SPNameQualifier;
}
}
subjectConfirmation = subject[0].SubjectConfirmation
? subject[0].SubjectConfirmation[0]
: null;
confirmData =
subjectConfirmation && subjectConfirmation.SubjectConfirmationData
? subjectConfirmation.SubjectConfirmationData[0]
: null;
if (subject[0].SubjectConfirmation && subject[0].SubjectConfirmation.length > 1) {
msg = "Unable to process multiple SubjectConfirmations in SAML assertion";
throw new Error(msg);
}
if (subjectConfirmation) {
if (confirmData && confirmData.$) {
const subjectNotBefore = confirmData.$.NotBefore;
const subjectNotOnOrAfter = confirmData.$.NotOnOrAfter;
const maxTimeLimitMs = this.processMaxAgeAssertionTime(this.options.maxAssertionAgeMs, subjectNotOnOrAfter, assertion.$.IssueInstant);
const subjErr = this.checkTimestampsValidityError(nowMs, subjectNotBefore, subjectNotOnOrAfter, maxTimeLimitMs);
if (subjErr) {
throw subjErr;
}
}
}
}
// Test to see that if we have a SubjectConfirmation InResponseTo that it matches
// the 'InResponseTo' attribute set in the Response
if (this.options.validateInResponseTo) {
if (subjectConfirmation) {
if (confirmData && confirmData.$) {
const subjectInResponseTo = confirmData.$.InResponseTo;
if (inResponseTo && subjectInResponseTo && subjectInResponseTo != inResponseTo) {
await this.cacheProvider.removeAsync(inResponseTo);
throw new Error("InResponseTo is not valid");
}
else if (subjectInResponseTo) {
let foundValidInResponseTo = false;
const result = await this.cacheProvider.getAsync(subjectInResponseTo);
if (result) {
const createdAt = new Date(result);
if (nowMs < createdAt.getTime() + this.options.requestIdExpirationPeriodMs)
foundValidInResponseTo = true;
}
await this.cacheProvider.removeAsync(inResponseTo);
if (!foundValidInResponseTo) {
throw new Error("InResponseTo is not valid");
}
break getInResponseTo;
}
}
}
else {
await this.cacheProvider.removeAsync(inResponseTo);
break getInResponseTo;
}
}
else {
break getInResponseTo;
}
}
const conditions = assertion.Conditions ? assertion.Conditions[0] : null;
if (assertion.Conditions && assertion.Conditions.length > 1) {
msg = "Unable to process multiple conditions in SAML assertion";
throw new Error(msg);
}
if (conditions && conditions.$) {
const maxTimeLimitMs = this.processMaxAgeAssertionTime(this.options.maxAssertionAgeMs, conditions.$.NotOnOrAfter, assertion.$.IssueInstant);
const conErr = this.checkTimestampsValidityError(nowMs, conditions.$.NotBefore, conditions.$.NotOnOrAfter, maxTimeLimitMs);
if (conErr)
throw conErr;
}
if (this.options.audience != null) {
const audienceErr = this.checkAudienceValidityError(this.options.audience, conditions.AudienceRestriction);
if (audienceErr)
throw audienceErr;
}
const attributeStatement = assertion.AttributeStatement;
if (attributeStatement) {
const attributes = [].concat(...attributeStatement
.filter((attr) => Array.isArray(attr.Attribute))
.map((attr) => attr.Attribute));
const attrValueMapper = (value) => {
const hasChildren = Object.keys(value).some((cur) => {
return cur !== "_" && cur !== "$";
});
return hasChildren ? value : value._;
};
if (attributes) {
const profileAttributes = {};
attributes.forEach((attribute) => {
if (!Object.prototype.hasOwnProperty.call(attribute, "AttributeValue")) {
// if attributes has no AttributeValue child, continue
return;
}
const name = attribute.$.Name;
const value = attribute.AttributeValue.length === 1
? attrValueMapper(attribute.AttributeValue[0])
: attribute.AttributeValue.map(attrValueMapper);
profileAttributes[name] = value;
// If any property is already present in profile and is also present
// in attributes, then skip the one from attributes. Handle this
// conflict gracefully without returning any error
if (Object.prototype.hasOwnProperty.call(profile, name)) {
return;
}
profile[name] = value;
});
profile.attributes = profileAttributes;
}
}
if (!profile.mail && profile["urn:oid:0.9.2342.19200300.100.1.3"]) {
// See https://spaces.internet2.edu/display/InCFederation/Supported+Attribute+Summary
// for definition of attribute OIDs
profile.mail = profile["urn:oid:0.9.2342.19200300.100.1.3"];
}
if (!profile.email && profile.mail) {
profile.email = profile.mail;
}
profile.getAssertionXml = () => xml.toString();
profile.getAssertion = () => parsedAssertion;
profile.getSamlResponseXml = () => samlResponseXml;
return { profile, loggedOut: false };
}
checkTimestampsValidityError(nowMs, notBefore, notOnOrAfter, maxTimeLimitMs) {
if (this.options.acceptedClockSkewMs == -1)
return null;
if (notBefore) {
const notBeforeMs = this.dateStringToTimestamp(notBefore, "NotBefore");
if (nowMs + this.options.acceptedClockSkewMs < notBeforeMs)
return new Error("SAML assertion not yet valid");
}
if (notOnOrAfter) {
const notOnOrAfterMs = this.dateStringToTimestamp(notOnOrAfter, "NotOnOrAfter");
if (nowMs - this.options.acceptedClockSkewMs >= notOnOrAfterMs)
return new Error("SAML assertion expired: clocks skewed too much");
}
if (maxTimeLimitMs) {
if (nowMs - this.options.acceptedClockSkewMs >= maxTimeLimitMs)
return new Error("SAML assertion expired: assertion too old");
}
return null;
}
checkAudienceValidityError(expectedAudience, audienceRestrictions) {
if (!audienceRestrictions || audienceRestrictions.length < 1) {
return new Error("SAML assertion has no AudienceRestriction");
}
const errors = audienceRestrictions
.map((restriction) => {
if (!restriction.Audience || !restriction.Audience[0] || !restriction.Audience[0]._) {
return new Error("SAML assertion AudienceRestriction has no Audience value");
}
if (restriction.Audience[0]._ !== expectedAudience) {
return new Error("SAML assertion audience mismatch");
}
return null;
})
.filter((result) => {
return result !== null;
});
if (errors.length > 0) {
return errors[0];
}
return null;
}
async validatePostRequestAsync(container) {
const xml = Buffer.from(container.SAMLRequest, "base64").toString("utf8");
const dom = (0, xml_1.parseDomFromString)(xml);
const doc = await (0, xml_1.parseXml2JsFromString)(xml);
const certs = await this.certsToCheck();
if (!this.validateSignature(xml, dom.documentElement, certs)) {
throw new Error("Invalid signature on documentElement");
}
return await processValidlySignedPostRequestAsync(this, doc, dom);
}
async _getNameIdAsync(self, doc) {
const nameIds = xml_1.xpath.selectElements(doc, "/*[local-name()='LogoutRequest']/*[local-name()='NameID']");
const encryptedIds = xml_1.xpath.selectElements(doc, "/*[local-name()='LogoutRequest']/*[local-name()='EncryptedID']");
if (nameIds.length + encryptedIds.length > 1) {
throw new Error("Invalid LogoutRequest");
}
if (nameIds.length === 1) {
return promiseWithNameID(nameIds[0]);
}
if (encryptedIds.length === 1) {
self.options.decryptionPvk = (0, utility_1.assertRequired)(self.options.decryptionPvk, "No decryption key found getting name ID for encrypted SAML response");
const encryptedDatas = xml_1.xpath.selectElements(encryptedIds[0], "./*[local-name()='EncryptedData']");
if (encryptedDatas.length !== 1) {
throw new Error("Invalid LogoutRequest");
}
const encryptedDataXml = encryptedDatas[0].toString();
const decryptedXml = await (0, xml_1.decryptXml)(encryptedDataXml, self.options.decryptionPvk);
const decryptedDoc = (0, xml_1.parseDomFromString)(decryptedXml);
const decryptedIds = xml_1.xpath.selectElements(decryptedDoc, "/*[local-name()='NameID']");
if (decryptedIds.length !== 1) {
throw new Error("Invalid EncryptedAssertion content");
}
return await promiseWithNameID(decryptedIds[0]);
}
throw new Error("Missing SAML NameID");
}
generateServiceProviderMetadata(decryptionCert, signingCert) {
const metadata = {
EntityDescriptor: {
"@xmlns": "urn:oasis:names:tc:SAML:2.0:metadata",
"@xmlns:ds": "http://www.w3.org/2000/09/xmldsig#",
"@entityID": this.options.issuer,
"@ID": this.options.issuer.replace(/\W/g, "_"),
SPSSODescriptor: {
"@protocolSupportEnumeration": "urn:oasis:names:tc:SAML:2.0:protocol",
},
},
};
if (this.options.decryptionPvk != null) {
if (!decryptionCert) {
throw new Error("Missing decryptionCert while generating metadata for decrypting service provider");
}
}
if (this.options.privateKey != null) {
if (!signingCert) {
throw new Error("Missing signingCert while generating metadata for signing service provider messages");
}
}
if (this.options.decryptionPvk != null || this.options.privateKey != null) {
metadata.EntityDescriptor.SPSSODescriptor.KeyDescriptor = [];
if (this.opt