passport-saml
Version:
SAML 2.0 authentication strategy for Passport
1,088 lines • 56.9 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.SAML = void 0;
const debug_1 = require("debug");
const debug = debug_1.default('passport-saml');
const zlib = require("zlib");
const xml2js = require("xml2js");
const xmlCrypto = require("xml-crypto");
const crypto = require("crypto");
const xmldom = require("xmldom");
const url = require("url");
const querystring = require("querystring");
const xmlbuilder = require("xmlbuilder");
const xmlenc = require("xml-encryption");
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 { xpath } = xmlCrypto;
function processValidlySignedPostRequest(self, doc, dom, callback) {
const request = doc.LogoutRequest;
if (request) {
const profile = {};
if (request.$.ID) {
profile.ID = request.$.ID;
}
else {
return callback(new Error('Missing SAML LogoutRequest ID'));
}
const issuer = request.Issuer;
if (issuer && issuer[0]._) {
profile.issuer = issuer[0]._;
}
else {
return callback(new Error('Missing SAML issuer'));
}
self.getNameID(self, dom, function (err, nameID) {
if (err) {
return callback(err);
}
if (nameID) {
profile.nameID = nameID.value;
if (nameID.format) {
profile.nameIDFormat = nameID.format;
}
}
else {
return callback(new Error('Missing SAML NameID'));
}
const sessionIndex = request.SessionIndex;
if (sessionIndex) {
profile.sessionIndex = sessionIndex[0]._;
}
callback(null, profile, true);
});
}
else {
return callback(new Error('Unknown SAML request message'));
}
}
function processValidlySignedSamlLogout(self, doc, dom, callback) {
const response = doc.LogoutResponse;
const request = doc.LogoutRequest;
if (response) {
return callback(null, null, true);
}
else if (request) {
processValidlySignedPostRequest(self, doc, dom, callback);
}
else {
throw new Error('Unknown SAML response message');
}
}
function callBackWithNameID(nameid, callback) {
const format = xmlCrypto.xpath(nameid, "@Format");
return callback(null, {
value: nameid.textContent,
format: format && format[0] && format[0].nodeValue
});
}
class SAML {
constructor(options) {
this.options = this.initialize(options);
this.cacheProvider = this.options.cacheProvider;
}
initialize(options) {
if (!options) {
options = {};
}
if (options.privateCert) {
console.warn("options.privateCert has been deprecated; use options.privateKey instead.");
if (options.privateKey == null) {
options.privateKey = options.privateCert;
}
}
if (options.RACComparison) {
console.warn("options.RACComparison has been deprecated; use options.racComparison instead.");
if (options.racComparison == null) {
options.racComparison = options.RACComparison;
}
}
if (options.disableRequestACSUrl) {
console.warn("options.disableRequestACSUrl has been deprecated; use options.disableRequestAcsUrl instead.");
if (options.disableRequestAcsUrl == null) {
options.disableRequestAcsUrl = options.disableRequestACSUrl;
}
}
if (Object.prototype.hasOwnProperty.call(options, 'cert') && !options.cert) {
throw new Error('Invalid property: cert must not be empty');
}
if (!options.path) {
options.path = '/saml/consume';
}
if (!options.host) {
options.host = 'localhost';
}
if (!options.issuer) {
options.issuer = 'onelogin_saml';
}
if (options.identifierFormat === undefined) {
options.identifierFormat = "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress";
}
if (options.authnContext === undefined) {
options.authnContext = "urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport";
}
if (!Array.isArray(options.authnContext)) {
options.authnContext = [options.authnContext];
}
if (!options.acceptedClockSkewMs) {
// default to no skew
options.acceptedClockSkewMs = 0;
}
if (!options.validateInResponseTo) {
options.validateInResponseTo = false;
}
if (!options.requestIdExpirationPeriodMs) {
options.requestIdExpirationPeriodMs = 28800000; // 8 hours
}
if (!options.cacheProvider) {
options.cacheProvider = new inmemory_cache_provider_1.CacheProvider({ keyExpirationPeriodMs: options.requestIdExpirationPeriodMs });
}
if (!options.logoutUrl) {
// Default to Entry Point
options.logoutUrl = options.entryPoint || '';
}
// sha1, sha256, or sha512
if (!options.signatureAlgorithm) {
options.signatureAlgorithm = 'sha1';
}
/**
* 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 (!options.racComparison || ['exact', 'minimum', 'maximum', 'better'].indexOf(options.racComparison) === -1) {
options.racComparison = 'exact';
}
return options;
}
getProtocol(req) {
return this.options.protocol || (req.protocol || 'http').concat('://');
}
getCallbackUrl(req) {
// Post-auth destination
if (this.options.callbackUrl) {
return this.options.callbackUrl;
}
else {
let host;
if (req.headers) {
host = req.headers.host;
}
else {
host = this.options.host;
}
return this.getProtocol(req) + host + this.options.path;
}
}
generateUniqueID() {
return crypto.randomBytes(10).toString('hex');
}
generateInstant() {
return new Date().toISOString();
}
signRequest(samlMessage) {
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');
}
generateAuthorizeRequest(req, isPassive, isHttpPostBinding, callback) {
const id = "_" + this.generateUniqueID();
const instant = this.generateInstant();
const forceAuthn = this.options.forceAuthn || false;
(async () => {
if (this.options.validateInResponseTo) {
return util.promisify(this.cacheProvider.save).bind(this.cacheProvider)(id, instant);
}
else {
return;
}
})()
.then(() => {
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 (forceAuthn) {
request['samlp:AuthnRequest']['@ForceAuthn'] = true;
}
if (!this.options.disableRequestAcsUrl) {
request['samlp:AuthnRequest']['@AssertionConsumerServiceURL'] = this.getCallbackUrl(req);
}
if (this.options.identifierFormat) {
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) {
request['samlp:AuthnRequest']['@ProviderName'] = this.options.providerName;
}
if (this.options.scoping) {
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 = xmlbuilder.create(request).end();
if (isHttpPostBinding && this.options.privateKey) {
stringRequest = saml_post_signing_1.signAuthnRequestPost(stringRequest, this.options);
}
callback(null, stringRequest);
})
.catch(function (err) {
callback(err);
});
}
generateLogoutRequest(req) {
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': req.user.nameIDFormat,
'#text': req.user.nameID
}
}
};
if (req.user.nameQualifier != null) {
request['samlp:LogoutRequest']['saml:NameID']['@NameQualifier'] = req.user.nameQualifier;
}
if (req.user.spNameQualifier != null) {
request['samlp:LogoutRequest']['saml:NameID']['@SPNameQualifier'] = req.user.spNameQualifier;
}
if (req.user.sessionIndex) {
request['samlp:LogoutRequest']['saml2p:SessionIndex'] = {
'@xmlns:saml2p': 'urn:oasis:names:tc:SAML:2.0:protocol',
'#text': req.user.sessionIndex
};
}
return util.promisify(this.cacheProvider.save).bind(this.cacheProvider)(id, instant)
.then(function () {
return xmlbuilder.create(request).end();
});
}
generateLogoutResponse(req, 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 xmlbuilder.create(request).end();
}
requestToUrl(request, response, operation, additionalParameters, callback) {
const requestToUrlHelper = (err, buffer) => {
if (err) {
return callback(err);
}
const base64 = buffer.toString('base64');
let target = url.parse(this.options.entryPoint, true);
if (operation === 'logout') {
if (this.options.logoutUrl) {
target = url.parse(this.options.logoutUrl, true);
}
}
else if (operation !== 'authorize') {
return callback(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) {
try {
if (!this.options.entryPoint) {
throw new Error('"entryPoint" config parameter is required for signed messages');
}
// sets .SigAlg and .Signature
this.signRequest(samlMessage);
}
catch (ex) {
return callback(ex);
}
}
Object.keys(samlMessage).forEach(k => {
target.query[k] = samlMessage[k];
});
// Delete 'search' to for pulling query string from 'query'
// https://nodejs.org/api/url.html#url_url_format_urlobj
target.search = null;
callback(null, url.format(target));
};
if (this.options.skipRequestCompression) {
requestToUrlHelper(null, Buffer.from((request || response), 'utf8'));
}
else {
zlib.deflateRaw((request || response), requestToUrlHelper);
}
}
getAdditionalParams(req, operation, overrideParams) {
const additionalParams = {};
const RelayState = req.query && req.query.RelayState || req.body && req.body.RelayState;
if (RelayState) {
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 || {};
Object.keys(overrideParams).forEach(function (k) {
additionalParams[k] = overrideParams[k];
});
return additionalParams;
}
getAuthorizeUrl(req, options, callback) {
this.generateAuthorizeRequest(req, this.options.passive, false, (err, request) => {
if (err)
return callback(err);
const operation = 'authorize';
const overrideParams = options ? options.additionalParams || {} : {};
this.requestToUrl(request, null, operation, this.getAdditionalParams(req, operation, overrideParams), callback);
});
}
getAuthorizeForm(req, callback) {
// 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 getAuthorizeFormHelper = (err, buffer) => {
if (err) {
return callback(err);
}
const operation = 'authorize';
const additionalParameters = this.getAdditionalParams(req, 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');
callback(null, [
'<!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'));
};
this.generateAuthorizeRequest(req, this.options.passive, true, (err, request) => {
if (err) {
return callback(err);
}
if (this.options.skipRequestCompression) {
getAuthorizeFormHelper(null, Buffer.from(request, 'utf8'));
}
else {
zlib.deflateRaw(request, getAuthorizeFormHelper);
}
});
}
getLogoutUrl(req, options, callback) {
return this.generateLogoutRequest(req)
.then(request => {
const operation = 'logout';
const overrideParams = options ? options.additionalParams || {} : {};
return this.requestToUrl(request, null, operation, this.getAdditionalParams(req, operation, overrideParams), callback);
});
}
getLogoutResponseUrl(req, options, callback) {
const response = this.generateLogoutResponse(req, req.samlLogoutRequest);
const operation = 'logout';
const overrideParams = options ? options.additionalParams || {} : {};
this.requestToUrl(null, response, operation, this.getAdditionalParams(req, operation, overrideParams), callback);
}
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;
}
certsToCheck() {
if (!this.options.cert) {
return Promise.resolve(undefined);
}
if (typeof (this.options.cert) === 'function') {
return util.promisify(this.options.cert)()
.then((certs) => {
if (!Array.isArray(certs)) {
certs = [certs];
}
return Promise.resolve(certs);
});
}
let certs = this.options.cert;
if (!Array.isArray(certs)) {
certs = [certs];
}
return Promise.resolve(certs);
}
// 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 = xmlCrypto.xpath(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 signature = signatures[0];
return certs.some(certToCheck => {
return this.validateSignatureForCert(signature, certToCheck, fullXml, currentNode);
});
}
// This function checks that the |signature| is signed with a given |cert|.
validateSignatureForCert(signature, cert, fullXml, currentNode) {
const sig = new xmlCrypto.SignedXml();
sig.keyInfoProvider = {
file: '',
getKeyInfo: key => "<X509Data></X509Data>",
getKey: keyInfo => Buffer.from(this.certToPEM(cert)),
};
signature = this.normalizeNewlines(signature.toString());
sig.loadSignature(signature);
// We expect each signature to contain exactly one reference to the top level of the xml we
// are validating, so if we see anything else, reject.
if (sig.references.length != 1)
return false;
const refUri = sig.references[0].uri;
const refId = (refUri[0] === '#') ? refUri.substring(1) : refUri;
// If we can't find the reference at the top level, reject
const idAttribute = currentNode.getAttribute('ID') ? 'ID' : 'Id';
if (currentNode.getAttribute(idAttribute) != refId)
return false;
// If we find any extra referenced nodes, reject. (xml-crypto only verifies one digest, so
// multiple candidate references is bad news)
const totalReferencedNodes = xmlCrypto.xpath(currentNode.ownerDocument, "//*[@" + idAttribute + "='" + refId + "']");
if (totalReferencedNodes.length > 1) {
return false;
}
// normalize XML to replace XML-encoded carriage returns with actual carriage returns
fullXml = this.normalizeXml(fullXml);
fullXml = this.normalizeNewlines(fullXml);
return sig.checkSignature(fullXml);
}
validatePostResponse(container, callback) {
let xml, doc, inResponseTo;
(async () => {
xml = Buffer.from(container.SAMLResponse, 'base64').toString('utf8');
doc = new xmldom.DOMParser({}).parseFromString(xml);
if (!Object.prototype.hasOwnProperty.call(doc, 'documentElement'))
throw new Error('SAMLResponse is not valid base64-encoded XML');
const inResponseToNodes = xmlCrypto.xpath(doc, "/*[local-name()='Response']/@InResponseTo");
if (inResponseToNodes) {
inResponseTo = inResponseToNodes.length ? inResponseToNodes[0].nodeValue : null;
return this.validateInResponseTo(inResponseTo);
}
})()
.then(() => this.certsToCheck())
.then(certs => {
// Check if this document has a valid top-level signature
let validSignature = false;
if (this.options.cert && this.validateSignature(xml, doc.documentElement, certs)) {
validSignature = true;
}
const assertions = xmlCrypto.xpath(doc, "/*[local-name()='Response']/*[local-name()='Assertion']");
const encryptedAssertions = xmlCrypto.xpath(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.cert &&
!validSignature &&
!this.validateSignature(xml, assertions[0], certs)) {
throw new Error('Invalid signature');
}
return this.processValidlySignedAssertion(assertions[0].toString(), xml, inResponseTo, callback);
}
if (encryptedAssertions.length == 1) {
if (!this.options.decryptionPvk)
throw new Error('No decryption key for encrypted SAML response');
const encryptedAssertionXml = encryptedAssertions[0].toString();
const xmlencOptions = { key: this.options.decryptionPvk };
return util.promisify(xmlenc.decrypt).bind(xmlenc)(encryptedAssertionXml, xmlencOptions)
.then((decryptedXml) => {
const decryptedDoc = new xmldom.DOMParser().parseFromString(decryptedXml);
const decryptedAssertions = xmlCrypto.xpath(decryptedDoc, "/*[local-name()='Assertion']");
if (decryptedAssertions.length != 1)
throw new Error('Invalid EncryptedAssertion content');
if (this.options.cert &&
!validSignature &&
!this.validateSignature(decryptedXml, decryptedAssertions[0], certs))
throw new Error('Invalid signature from encrypted assertion');
this.processValidlySignedAssertion(decryptedAssertions[0].toString(), xml, inResponseTo, callback);
});
}
// If there's no assertion, fall back on xml2js response parsing for the status &
// LogoutResponse code.
const parserConfig = {
explicitRoot: true,
explicitCharkey: true,
tagNameProcessors: [xml2js.processors.stripPrefix]
};
const parser = new xml2js.Parser(parserConfig);
return parser.parseStringPromise(xml)
.then(doc => {
const response = doc.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 (this.options.cert && !validSignature) {
throw new Error('Invalid signature: NoPassive');
}
return callback(null, null, 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 error = new Error('SAML provider returned ' + msgType + ' error: ' + msg);
const builderOpts = {
rootName: 'Status',
headless: true
};
// @ts-expect-error adding extra attr to default Error object
error.statusXml = new xml2js.Builder(builderOpts).buildObject(status[0]);
return Promise.reject(error);
}
}
}
throw new Error('Missing SAML assertion');
}
}
else {
if (this.options.cert && !validSignature) {
throw new Error('Invalid signature: No response found');
}
const logoutResponse = doc.LogoutResponse;
if (logoutResponse) {
return callback(null, null, true);
}
else {
throw new Error('Unknown SAML response message');
}
}
});
})
.catch(err => {
debug('validatePostResponse resulted in an error: %s', err);
if (this.options.validateInResponseTo) {
util.promisify(this.cacheProvider.remove).bind(this.cacheProvider)(inResponseTo)
.then(function () {
callback(err);
});
}
else {
callback(err);
}
});
}
validateInResponseTo(inResponseTo) {
if (this.options.validateInResponseTo) {
if (inResponseTo) {
return util.promisify(this.cacheProvider.get).bind(this.cacheProvider)(inResponseTo)
.then(result => {
if (!result)
throw new Error('InResponseTo is not valid');
return Promise.resolve();
});
}
else {
throw new Error('InResponseTo is missing from response');
}
}
else {
return Promise.resolve();
}
}
validateRedirect(container, originalQuery, callback) {
const samlMessageType = container.SAMLRequest ? 'SAMLRequest' : 'SAMLResponse';
const data = Buffer.from(container[samlMessageType], "base64");
zlib.inflateRaw(data, (err, inflated) => {
if (err) {
return callback(err);
}
const dom = new xmldom.DOMParser().parseFromString(inflated.toString());
const parserConfig = {
explicitRoot: true,
explicitCharkey: true,
tagNameProcessors: [xml2js.processors.stripPrefix]
};
const parser = new xml2js.Parser(parserConfig);
parser.parseString(inflated, (err, doc) => {
if (err) {
return callback(err);
}
(async () => {
return samlMessageType === 'SAMLResponse' ?
this.verifyLogoutResponse(doc) : this.verifyLogoutRequest(doc);
})()
.then(() => this.hasValidSignatureForRedirect(container, originalQuery))
.then(() => processValidlySignedSamlLogout(this, doc, dom, callback))
.catch(err => callback(err));
});
});
}
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 && this.options.cert) {
let urlString = getParam('SAMLRequest') || getParam('SAMLResponse');
if (getParam('RelayState')) {
urlString += '&' + getParam('RelayState');
}
urlString += '&' + getParam('SigAlg');
return this.certsToCheck()
.then(certs => {
const hasValidQuerySignature = certs.some(cert => {
return this.validateSignatureForRedirect(urlString, container.Signature, container.SigAlg, cert);
});
if (!hasValidQuerySignature) {
throw new Error('Invalid signature');
}
});
}
else {
return Promise.resolve(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;
}
}
verifyLogoutResponse(doc) {
return (async () => {
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 Promise.resolve(true);
})();
}
verifyIssuer(samlMessage) {
if (this.options.idpIssuer) {
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');
}
}
}
processValidlySignedAssertion(xml, samlResponseXml, inResponseTo, callback) {
let msg;
const parserConfig = {
explicitRoot: true,
explicitCharkey: true,
tagNameProcessors: [xml2js.processors.stripPrefix]
};
const nowMs = new Date().getTime();
const profile = {};
let assertion;
let parsedAssertion;
const parser = new xml2js.Parser(parserConfig);
parser.parseStringPromise(xml)
.then((doc) => {
parsedAssertion = doc;
assertion = doc.Assertion;
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 subjErr = this.checkTimestampsValidityError(nowMs, subjectNotBefore, subjectNotOnOrAfter);
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) {
return util.promisify(this.cacheProvider.remove).bind(this.cacheProvider)(inResponseTo)
.then(() => {
throw new Error('InResponseTo is not valid');
});
}
else if (subjectInResponseTo) {
let foundValidInResponseTo = false;
return util.promisify(this.cacheProvider.get).bind(this.cacheProvider)(subjectInResponseTo)
.then(result => {
if (result) {
const createdAt = new Date(result);
if (nowMs < createdAt.getTime() + this.options.requestIdExpirationPeriodMs)
foundValidInResponseTo = true;
}
return util.promisify(this.cacheProvider.remove).bind(this.cacheProvider)(inResponseTo);
})
.then(() => {
if (!foundValidInResponseTo) {
throw new Error('InResponseTo is not valid');
}
return Promise.resolve();
});
}
}
}
else {
util.promisify(this.cacheProvider.remove).bind(this.cacheProvider)(inResponseTo);
return Promise.resolve();
}
}
else {
return Promise.resolve();
}
})
.then(() => {
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 conErr = this.checkTimestampsValidityError(nowMs, conditions.$.NotBefore, conditions.$.NotOnOrAfter);
if (conErr)
throw conErr;
}
if (this.options.audience) {
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) {
attributes.forEach(attribute => {
if (!Object.prototype.hasOwnProperty.call(attribute, 'AttributeValue')) {
// if attributes has no AttributeValue child, continue
return;
}
const value = attribute.AttributeValue;
if (value.length === 1) {
profile[attribute.$.Name] = attrValueMapper(value[0]);
}
else {
profile[attribute.$.Name] = value.map(attrValueMapper);
}
});
}
}
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;
callback(null, profile, false);
})
.catch(err => callback(err));
}
checkTimestampsValidityError(nowMs, notBefore, notOnOrAfter) {
if (this.options.acceptedClockSkewMs == -1)
return null;
if (notBefore) {
const notBeforeMs = Date.parse(notBefore);
if (nowMs + this.options.acceptedClockSkewMs < notBeforeMs)
return new Error('SAML assertion not yet valid');
}
if (notOnOrAfter) {
const notOnOrAfterMs = Date.parse(notOnOrAfter);
if (nowMs - this.options.acceptedClockSkewMs >= notOnOrAfterMs)
return new Error('SAML assertion expired');
}
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;
}
validatePostRequest(container, callback) {
const xml = Buffer.from(container.SAMLRequest, 'base64').toString('utf8');
const dom = new xmldom.DOMParser().parseFromString(xml);
const parserConfig = {
explicitRoot: true,
explicitCharkey: true,
tagNameProcessors: [xml2js.processors.stripPrefix]
};
const parser = new xml2js.Parser(parserConfig);
parser.parseString(xml, (err, doc) => {
if (err) {
return callback(err);
}
this.certsToCheck()
.then(certs => {
// Check if this document has a valid top-level signature
if (this.options.cert && !this.validateSignature(xml, dom.documentElement, certs)) {
return callback(new Error('Invalid signature on do