suomifi-passport-saml
Version:
SAML 2.0 authentication strategy for Passport with Vetuma language selection extension
1,330 lines (1,170 loc) • 57.8 kB
JavaScript
var debug = require('debug')('passport-saml');
var zlib = require('zlib');
var xml2js = require('xml2js');
var xmlCrypto = require('xml-crypto');
var crypto = require('crypto');
var xmldom = require('@xmldom/xmldom');
var url = require('url');
var querystring = require('querystring');
var xmlbuilder = require('xmlbuilder');
var xmlenc = require('xml-encryption');
var xpath = xmlCrypto.xpath;
var InMemoryCacheProvider = require('./inmemory-cache-provider.js').CacheProvider;
var algorithms = require('./algorithms');
var signAuthnRequestPost = require('./saml-post-signing').signAuthnRequestPost;
var Q = require('q');
var SAML = function (options) {
this.options = this.initialize(options);
this.cacheProvider = this.options.cacheProvider;
};
SAML.prototype.initialize = function (options) {
if (!options) {
options = {};
}
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;
}
// Start suomifi additions configuration parameters
//
// Default values must be set so that with default configuration (i.e. if there are no values
// given in configuration) default behaviour is to enable following checks / enforcements (i.e.
// suomifiAdditions.disable*
// options default values must be false
if (!options.suomifiAdditions) {
options.suomifiAdditions = {};
}
if (!options.suomifiAdditions.disableEncryptedAssertionsOnlyPolicyEnforcementForUnitTestPurposes) {
options.suomifiAdditions.disableEncryptedAssertionsOnlyPolicyEnforcementForUnitTestPurposes = false;
}
if (!options.suomifiAdditions.disableValidateInResponseEnforcementForUnitTestingPurposes) {
options.suomifiAdditions.disableValidateInResponseEnforcementForUnitTestingPurposes = false;
}
if (!options.suomifiAdditions.disablePostResponseTopLevelSignatureValidationEnforcementForUnitTestPurposes) {
options.suomifiAdditions.disablePostResponseTopLevelSignatureValidationEnforcementForUnitTestPurposes = false;
}
if (!options.suomifiAdditions.disableAssertionSignatureVerificationEnforcementForUnitTestPurposes) {
options.suomifiAdditions.disableAssertionSignatureVerificationEnforcementForUnitTestPurposes = false;
}
if (!options.suomifiAdditions.disableAudienceCheckEnforcementForUnitTestPurposes) {
options.suomifiAdditions.disableAudienceCheckEnforcementForUnitTestPurposes = false;
}
if (!options.suomifiAdditions.usePublicKeyAsCertParamForSignatureValidation) {
options.suomifiAdditions.usePublicKeyAsCertParamForSignatureValidation = false;
}
// End suomifi additions configuration parameters
if(!options.validateInResponseTo){
options.validateInResponseTo = false;
}
if(!options.requestIdExpirationPeriodMs){
options.requestIdExpirationPeriodMs = 28800000; // 8 hours
}
if(!options.cacheProvider){
options.cacheProvider = new InMemoryCacheProvider(
{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;
};
SAML.prototype.getProtocol = function (req) {
return this.options.protocol || (req.protocol || 'http').concat('://');
};
SAML.prototype.getCallbackUrl = function (req) {
// Post-auth destination
if (this.options.callbackUrl) {
return this.options.callbackUrl;
} else {
var host;
if (req.headers) {
host = req.headers.host;
} else {
host = this.options.host;
}
return this.getProtocol(req) + host + this.options.path;
}
};
SAML.prototype.generateUniqueID = function () {
return crypto.randomBytes(10).toString('hex');
};
SAML.prototype.generateInstant = function () {
return new Date().toISOString();
};
SAML.prototype.signRequest = function (samlMessage) {
var signer;
var samlMessageToSign = {};
samlMessage.SigAlg = algorithms.getSigningAlgorithm(this.options.signatureAlgorithm);
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.privateCert), 'base64');
};
SAML.prototype.generateAuthorizeRequest = function (req, isPassive, isHttpPostBinding, reqOptions, callback) {
var id = "_" + this.generateUniqueID();
var instant = this.generateInstant();
var forceAuthn = this.options.forceAuthn || false;
Q.fcall(() => {
if(this.options.validateInResponseTo) {
return Q.ninvoke(this.cacheProvider, 'save', id, instant);
} else {
return Q();
}
})
.then(() => {
var 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) {
var 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 (reqOptions && reqOptions.vetumaLang) {
addVetumaLangExtension(request['samlp:AuthnRequest'], reqOptions.vetumaLang);
}
var stringRequest = xmlbuilder.create(request).end();
if (isHttpPostBinding && this.options.privateCert) {
stringRequest = signAuthnRequestPost(stringRequest, this.options);
}
callback(null, stringRequest);
})
.fail(function(err){
callback(err);
})
.done();
};
/*jshint -W069 */
function addVetumaLangExtension(parentTag, lang) {
if (!parentTag['samlp:Extensions']) {
parentTag['samlp:Extensions'] = {};
}
var extensionsTag = parentTag['samlp:Extensions'];
var entry = extensionsTag['vetuma'] = {};
entry['LG'] = {};
entry['LG']['#text'] = lang;
entry['@xmlns'] = 'urn:vetuma:SAML:2.0:extensions';
}
/*jshint +W069 */
SAML.prototype.generateLogoutRequest = function (req, options) {
var id = "_" + this.generateUniqueID();
var instant = this.generateInstant();
var 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
};
}
if (options && options.vetumaLang) {
addVetumaLangExtension(request['samlp:LogoutRequest'], options.vetumaLang);
}
return Q.ninvoke(this.cacheProvider, 'save', id, instant)
.then(function() {
return xmlbuilder.create(request).end();
});
};
SAML.prototype.generateLogoutResponse = function (req, logoutRequest) {
var id = "_" + this.generateUniqueID();
var instant = this.generateInstant();
var 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();
};
SAML.prototype.requestToUrl = function (request, response, operation, additionalParameters, callback) {
const requestToUrlHelper = (err, buffer) => {
if (err) {
return callback(err);
}
var base64 = buffer.toString('base64');
var 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));
}
var samlMessage = request ? {
SAMLRequest: base64
} : {
SAMLResponse: base64
};
Object.keys(additionalParameters).forEach(k => {
samlMessage[k] = additionalParameters[k];
});
if (this.options.privateCert) {
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
delete target.search;
callback(null, url.format(target));
};
if (this.options.skipRequestCompression) {
requestToUrlHelper(null, Buffer.from(request || response, 'utf8'));
}
else {
zlib.deflateRaw(request || response, requestToUrlHelper);
}
};
SAML.prototype.getAdditionalParams = function (req, operation, overrideParams) {
var additionalParams = {};
var RelayState = req.query && req.query.RelayState || req.body && req.body.RelayState;
if (RelayState) {
additionalParams.RelayState = RelayState;
}
var optionsAdditionalParams = this.options.additionalParams || {};
Object.keys(optionsAdditionalParams).forEach(function(k) {
additionalParams[k] = optionsAdditionalParams[k];
});
var 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;
};
SAML.prototype.getAuthorizeUrl = function (req, options, callback) {
this.generateAuthorizeRequest(req, this.options.passive, false, options,(err, request) => {
if (err)
return callback(err);
var operation = 'authorize';
var overrideParams = options ? options.additionalParams || {} : {};
this.requestToUrl(request, null, operation, this.getAdditionalParams(req, operation, overrideParams), callback);
});
};
SAML.prototype.getAuthorizeForm = function (req, reqOptions, 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.
var quoteattr = function(s, preserveCR) {
preserveCR = 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, preserveCR) // Must be before the next replacement.
.replace(/[\r\n]/g, preserveCR);
};
const getAuthorizeFormHelper = (err, buffer) => {
if (err) {
return callback(err);
}
var operation = 'authorize';
var additionalParameters = this.getAdditionalParams(req, operation);
var samlMessage = {
SAMLRequest: buffer.toString('base64')
};
Object.keys(additionalParameters).forEach(k => {
samlMessage[k] = additionalParameters[k] || '';
});
var 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>', // Hide the form if JavaScript is enabled
'</body>',
'</html>'
].join('\r\n'));
};
this.generateAuthorizeRequest(req, this.options.passive, true, reqOptions,(err, request) => {
if (err) {
return callback(err);
}
if (this.options.skipRequestCompression) {
getAuthorizeFormHelper(null, Buffer.from(request, 'utf8'));
} else {
zlib.deflateRaw(request, getAuthorizeFormHelper);
}
});
};
SAML.prototype.getLogoutUrl = function(req, options, callback) {
return this.generateLogoutRequest(req, options)
.then(request => {
const operation = 'logout';
const overrideParams = options ? options.additionalParams || {} : {};
return this.requestToUrl(request, null, operation, this.getAdditionalParams(req, operation, overrideParams), callback);
});
};
SAML.prototype.getLogoutResponseUrl = function(req, options, callback) {
var response = this.generateLogoutResponse(req, req.samlLogoutRequest);
var operation = 'logout';
var overrideParams = options ? options.additionalParams || {} : {};
this.requestToUrl(null, response, operation, this.getAdditionalParams(req, operation, overrideParams), callback);
};
SAML.prototype.certToPEM = function (cert) {
cert = cert.match(/.{1,64}/g).join('\n');
// Start suomifi additions
var self = this;
if (self.options.suomifiAdditions.usePublicKeyAsCertParamForSignatureValidation)
return this.keyToPEMSuomifi(cert);
// End suomifi additions
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;
};
// Start suomifi additions
SAML.prototype.keyToPEMSuomifi = function(key) {
if (key.indexOf('-BEGIN PUBLIC KEY-') === -1)
key = "-----BEGIN PUBLIC KEY-----\n" + key;
if (key.indexOf('-END PUBLIC KEY-') === -1)
key = key + "\n-----END PUBLIC KEY-----\n";
return key;
};
// End suomifi additions
SAML.prototype.certsToCheck = function () {
if (!this.options.cert) {
return Q();
}
if (typeof(this.options.cert) === 'function') {
return Q.nfcall(this.options.cert)
.then(certs => {
if (!Array.isArray(certs)) {
certs = [certs];
}
return Q(certs);
});
}
var certs = this.options.cert;
if (!Array.isArray(certs)) {
certs = [certs];
}
return Q(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.
SAML.prototype.validateSignature = function (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 = 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 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 = xpath(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 this.validateSignatureForCert(signature, certToCheck, fullXml, currentNode);
});
};
// This function checks that the |signature| is signed with a given |cert|.
SAML.prototype.validateSignatureForCert = function (signature, cert, fullXml, currentNode) {
const sig = new xmlCrypto.SignedXml();
sig.keyInfoProvider = {
getKeyInfo: key => "<X509Data></X509Data>",
getKey: keyInfo => this.certToPEM(cert),
};
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;
var refUri = sig.references[0].uri;
var refId = (refUri[0] === '#') ? refUri.substring(1) : refUri;
// If we can't find the reference at the top level, reject
var 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)
var totalReferencedNodes = xpath(currentNode.ownerDocument,
"//*[@" + idAttribute + "='" + refId + "']");
if (totalReferencedNodes.length > 1) {
return false;
}
return sig.checkSignature(fullXml);
};
SAML.prototype.validatePostResponse = function (container, callback) {
var xml, doc, inResponseTo;
Q.fcall(() => {
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');
inResponseTo = xpath(doc, "/*[local-name()='Response']/@InResponseTo");
if (inResponseTo) {
inResponseTo = inResponseTo.length ? inResponseTo[0].nodeValue : null;
return this.validateInResponseTo(inResponseTo);
}
})
.then(() => this.certsToCheck())
.then(certs => {
// Check if this document has a valid top-level signature which applies to the entire XML document
var validSignature = false;
if (this.options.cert &&
this.validateSignature(xml, doc.documentElement, certs) &&
Array.from(doc.childNodes).filter(
(n) => n.tagName != null && n.childNodes != null
).length === 1
) {
validSignature = true;
}
// start suomifi additions
if (validSignature !== true &&
this.options.suomifiAdditions.disablePostResponseTopLevelSignatureValidationEnforcementForUnitTestPurposes !== true)
{
// Enforce document level signature checking.
//
// According to this comment ( https://github.com/bergie/passport-saml/issues/180#issuecomment-289998792 ):
// "Signatures are optional, and may not be provided by some IDPs..."
// But this is not the case with the IdP being used at the moment (so there should not be any reason
// to silently allow unsigned top responses).
// Thus throw error if response documents' signature is not valid AND/OR if
// options.cert was not configured (in which case signature was not even checked).
//
// NOTE: baseline passport-saml (1.0.0) does not cause hard failure in following situations:
//
// 1) top/message level signature does not match with content
// 2) validateSignature method encounters more than one Signature elements when it tries to process message (i.e.
// when it tries to validate message level signature)
//
// About (1): passport-saml continues to process response and if response contains assertions it validates
// assertions signature (if IdP certificates were configured)
//
// About (2): this situation can happen e.g. when IdP sends SAML login response so that message AND assertion
// are signed BUT assertion is not encrypted. I.e. in this particular case this:
// https://github.com/bergie/passport-saml/blob/d2c89947fca1fa79365f5819a0e7326ebc94728a/lib/passport-saml/saml.js#L519-L525
// code blocks xpath picks up two Signature elements (during message's signature is verification) and ends up
// returning false (due reasons described in code commens above that particular method).
//
// What this means from suomi.fi point of view is that unencrypted assertions shall NOT work (because suomi.fi
// IdP signs message and assertion) BUT this is not a problem because SP should / must not accept unencrypted
// assertions (see EncryptedAssertionsOnlyPolicyEnforcement)
throw new Error('Invalid top level signature');
}
// end suomifi additions
var assertions = xpath(doc, "/*[local-name()='Response']/*[local-name()='Assertion']");
var encryptedAssertions = 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');
}
// start suomifi additions
if (assertions.length > 0 &&
this.options.suomifiAdditions.disableEncryptedAssertionsOnlyPolicyEnforcementForUnitTestPurposes !== true )
{
// There should not be any reason to process unencrypted assertion.
// I.e. if assertion(s) are not encrypted there must be some configuration
// error either with SAML SP's metadata or in IdP
//
// That being said unencrypted assertions are allowed if passport-saml
// is explicitly configured to allow those (for example to debugging purposes)
//
// "encrypted assertion(s) only" policy enforcement is one piece in the puzzle of preventing
// SAML login response replay attacks. Other pieces of the puzzle are "audience check" policy
// enforcement and "validateInResponseTo" feature. For more comments about these check
// code comments of "audience check" policy enforcement code block from the assertion processing
// section of this passport-saml fork...starting with "// Audience validation is one piece in...").
//
throw new Error('Unencrypted assertion(s) are not allowed');
}
// end suomifi additions
if (assertions.length == 1) {
// start suomifi addition
if (this.options.suomifiAdditions.disableAssertionSignatureVerificationEnforcementForUnitTestPurposes !== true)
{
// At the time of writing this comment passport-saml's (0.32.1) default behaviour is/was that if
// documents top level signature is valid then validation of assertion's signature is skipped.
//
// Validity of assertion's signature must also be done thus this sparated code block which
// performs validateSignature check regardless of value of "validSignature" variable.
//
// NOTE: SAML SP metadata MUST have WantAssertionsSigned="true" in SPSSODescriptor in
// order to receive signed assertions
// NOTE2: if you change this code block keep in mind that validateSignature check
// is done in two places (when handling unencrypted assertions and when handling encrypted
// assertions)
// NOTE3: if - e.g. due some passport-saml upgrade merge - signature validation code
// outside of this suomifi addition code block is changed check that same changes
// are reflected inside this code block also
if (this.validateSignature(xml, assertions[0], certs) !== true) {
throw new Error('Invalid assertion signature');
}
}
// end suomifi addition...passport-saml's default code block is executed also...
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');
var encryptedAssertionXml = encryptedAssertions[0].toString();
var xmlencOptions = { key: this.options.decryptionPvk };
return Q.ninvoke(xmlenc, 'decrypt', encryptedAssertionXml, xmlencOptions)
.then(decryptedXml => {
var decryptedDoc = new xmldom.DOMParser().parseFromString(decryptedXml);
var decryptedAssertions = xpath(decryptedDoc, "/*[local-name()='Assertion']");
if (decryptedAssertions.length != 1)
throw new Error('Invalid EncryptedAssertion content');
// start suomifi addition
if (this.options.suomifiAdditions.disableAssertionSignatureVerificationEnforcementForUnitTestPurposes !== true)
{
// At the time of writing this comment passport-saml's (0.32.1) default behaviour is/was that if
// documents top level signature is valid then validation of assertion's signature is skipped.
//
// Validity of assertion's signature must also be done thus this sparated code block which
// performs validateSignature check regardless of value of "validSignature" variable.
//
// NOTE: SAML SP metadata MUST have WantAssertionsSigned="true" in SPSSODescriptor in
// order to receive signed assertions
// NOTE2: if you change this code block keep in mind that validateSignature check
// is done in two places (when handling unencrypted assertions and when handling encrypted
// assertions)
// NOTE3: if - e.g. due some passport-saml upgrade merge - signature validation code
// outside of this suomifi addition code block is changed check that same changes
// are reflected inside this code block also
// NOTE4: this processes decryptedXml and decryptedAssertions (just a side note if following
// check is copy pasted at some point in the future due some change from unencrypted
// assertions code block)
if (this.validateSignature(decryptedXml, decryptedAssertions[0], certs) !== true) {
throw new Error('Invalid assertion signature');
}
}
// end suomifi addition...passport-saml's default code block is executed also...
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.
var parserConfig = {
explicitRoot: true,
explicitCharkey: true,
tagNameProcessors: [xml2js.processors.stripPrefix]
};
var parser = new xml2js.Parser(parserConfig);
return Q.ninvoke( parser, 'parseString', xml)
.then(doc => {
var response = doc.Response;
if (response) {
var assertion = response.Assertion;
if (!assertion) {
var status = response.Status;
if (status) {
var statusCode = status[0].StatusCode;
if (statusCode && statusCode[0].$.Value === "urn:oasis:names:tc:SAML:2.0:status:Responder") {
var 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) {
var msgType = statusCode[0].$.Value.match(/[^:]*$/)[0];
if (msgType != 'Success') {
var msg = 'unspecified';
if (status[0].StatusMessage) {
msg = status[0].StatusMessage[0]._;
} else if (statusCode[0].StatusCode) {
msg = statusCode[0].StatusCode[0].$.Value.match(/[^:]*$/)[0];
}
var error = new Error('SAML provider returned ' + msgType + ' error: ' + msg);
var builderOpts = {
rootName: 'Status',
headless: true
};
error.statusXml = new xml2js.Builder(builderOpts).buildObject(status[0]);
throw error;
}
}
}
throw new Error('Missing SAML assertion');
}
} else {
if (this.options.cert && !validSignature) {
throw new Error('Invalid signature: No response found');
}
var logoutResponse = doc.LogoutResponse;
if (logoutResponse){
return callback(null, null, true);
} else {
throw new Error('Unknown SAML response message');
}
}
});
})
.fail(err => {
debug('validatePostResponse resulted in an error: %s', err);
if (this.options.validateInResponseTo) {
Q.ninvoke(this.cacheProvider, 'remove', inResponseTo)
.then(function() {
callback(err);
});
} else {
callback(err);
}
})
.done();
};
SAML.prototype.validateInResponseTo = function (inResponseTo) {
if (this.options.validateInResponseTo) {
if (inResponseTo) {
return Q.ninvoke(this.cacheProvider, 'get', inResponseTo)
.then(result => {
if (!result)
throw new Error('InResponseTo is not valid');
return Q();
});
} else {
throw new Error('InResponseTo is missing from response');
}
} else {
return Q();
}
};
SAML.prototype.validateRedirect = function(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);
}
Q.fcall(() => {
return samlMessageType === 'SAMLResponse' ?
this.verifyLogoutResponse(doc) : this.verifyLogoutRequest(doc);
})
.then(() => this.hasValidSignatureForRedirect(container, originalQuery))
.then(() => processValidlySignedSamlLogout(this, doc, dom, callback))
.fail(err => callback(err));
});
});
};
function processValidlySignedSamlLogout(self, doc, dom, callback) {
var response = doc.LogoutResponse;
var 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');
}
}
SAML.prototype.hasValidSignatureForRedirect = function (container, originalQuery) {
const tokens = originalQuery.split('&');
var getParam = key => {
var exists = tokens.filter(t => { return new RegExp(key).test(t); });
return exists[0];
};
if (container.Signature && this.options.cert) {
var urlString = getParam('SAMLRequest') || getParam('SAMLResponse');
if (getParam('RelayState')) {
urlString += '&' + getParam('RelayState');
}
urlString += '&' + getParam('SigAlg');
return this.certsToCheck()
.then(certs => {
var hasValidQuerySignature = certs.some(cert => {
return this.validateSignatureForRedirect(
urlString, container.Signature, container.SigAlg, cert
);
});
if (!hasValidQuerySignature) {
throw 'Invalid signature';
}
});
} else {
return Q(true);
}
};
SAML.prototype.validateSignatureForRedirect = function (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;
}
var i = crypto.getHashes().findIndex(hasMatch);
var matchingAlgo;
if (i > -1) {
matchingAlgo = crypto.getHashes()[i];
}
else {
throw alg + ' is not supported';
}
var verifier = crypto.createVerify(matchingAlgo);
verifier.update(urlString);
return verifier.verify(this.certToPEM(cert), signature, 'base64');
};
SAML.prototype.verifyLogoutRequest = function (doc) {
this.verifyIssuer(doc.LogoutRequest);
var nowMs = new Date().getTime();
var conditions = doc.LogoutRequest.$;
var conErr = this.checkTimestampsValidityError(
nowMs, conditions.NotBefore, conditions.NotOnOrAfter
);
if (conErr) {
throw conErr;
}
};
SAML.prototype.verifyLogoutResponse = function (doc) {
return Q.fcall(() => {
var statusCode = doc.LogoutResponse.Status[0].StatusCode[0].$.Value;
if (statusCode !== "urn:oasis:names:tc:SAML:2.0:status:Success")
throw 'Bad status code: ' + statusCode;
this.verifyIssuer(doc.LogoutResponse);
var inResponseTo = doc.LogoutResponse.$.InResponseTo;
if (inResponseTo) {
return this.validateInResponseTo(inResponseTo);
}
return Q(true);
});
};
SAML.prototype.verifyIssuer = function (samlMessage) {
if(this.options.idpIssuer) {
var issuer = samlMessage.Issuer;
if (issuer) {
if (issuer[0]._ !== this.options.idpIssuer)
throw 'Unknown SAML issuer. Expected: ' + this.options.idpIssuer + ' Received: ' + issuer[0]._;
} else {
throw 'Missing SAML issuer';
}
}
};
SAML.prototype.processValidlySignedAssertion = function(xml, samlResponseXml, inResponseTo, callback) {
var msg;
var parserConfig = {
explicitRoot: true,
explicitCharkey: true,
tagNameProcessors: [xml2js.processors.stripPrefix]
};
var nowMs = new Date().getTime();
var profile = {};
var assertion;
var parsedAssertion;
var parser = new xml2js.Parser(parserConfig);
Q.ninvoke(parser, 'parseString', xml)
.then(doc => {
parsedAssertion = doc;
assertion = doc.Assertion;
var issuer = assertion.Issuer;
if (issuer && issuer[0]._) {
profile.issuer = issuer[0]._;
}
if (inResponseTo) {
profile.inResponseTo = inResponseTo;
}
var authnStatement = assertion.AuthnStatement;
if (authnStatement) {
if (authnStatement[0].$ && authnStatement[0].$.SessionIndex) {
profile.sessionIndex = authnStatement[0].$.SessionIndex;
}
}
var subject = assertion.Subject;
var subjectConfirmation, confirmData;
if (subject) {
var 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.$) {
var subjectNotBefore = confirmData.$.NotBefore;
var subjectNotOnOrAfter = confirmData.$.NotOnOrAfter;
var subjErr = this.checkTimestampsValidityError(
nowMs, subjectNotBefore, subjectNotOnOrAfter);
if (subjErr) {
throw subjErr;
}
}
}
}
// start suomifi additions
if ( ! this.options.validateInResponseTo &&
this.options.suomifiAdditions.disableValidateInResponseEnforcementForUnitTestingPurposes !== true )
{
// Assertions must not be processed if validateInResponseTo check is switched off due e.g.
// configuration error.
//
// "validateInResponseTo" feature is one piece in the puzzle of preventing replay attacks.
// Other pieces are "audience checking" and "enforce encrypted asserion(s) only" policy (see
// code comment about these from "audience check" enforcement part of this passport-saml fork (see few
// lines below starting with "// Audience validation is one piece...")).
//
// That being said disableValidateInResponseEnforcementForUnitTestingPurposes flag
// is used to control whether this enforcing is active e.g. in unit tests in order
// to be able to test this stack with predefined SAML responsens
throw new Error('validateInResponseTo feature is not configured on');
}
// end suomifi additions
// 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.$) {
var subjectInResponseTo = confirmData.$.InResponseTo;
if (inResponseTo && subjectInResponseTo && subjectInResponseTo != inResponseTo) {
return Q.ninvoke(this.cacheProvider, 'remove', inResponseTo)
.then(() => {
throw new Error('InResponseTo is not valid');
});
} else if (subjectInResponseTo) {
var foundValidInResponseTo = false;
return Q.ninvoke(this.cacheProvider, 'get', subjectInResponseTo)
.then(result => {
if (result) {
var createdAt = new Date(result);
if (nowMs < createdAt.getTime() + this.options.requestIdExpirationPeriodMs)
foundValidInResponseTo = true;
}
return Q.ninvoke(this.cacheProvider, 'remove', inResponseTo );
})
.then(() => {
if (!foundValidInResponseTo) {
throw new Error('InResponseTo is not valid');
}
return Q();
});
}
}
} else {
return Q.ninvoke(this.cacheProvider, 'remove', inResponseTo);
}
} else {
return Q();
}
})
.then(() => {
var 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.$) {
var conErr = this.checkTimestampsValidityError(
nowMs, conditions.$.NotBefore, conditions.$.NotOnOrAfter);
if(conErr)
throw conErr;
}
if (this.options.audience) {
var audienceErr = this.checkAudienceValidityError(
this.options.audience, conditions.AudienceRestriction);
if(audienceErr)
throw audienceErr;
}
// start suomifi additions
if ( ! this.options.audience &&
this.options.suomifiAdditions.disableAudienceCheckEnforcementForUnitTestPurposes !== true )
{
// Enforce that audience check was done. I.e. if options.audience was not configured
// (and thus audience checking is not performed) throw exception.
//
// Audience validation is one piece in the puzzle of preventing replay attacks. E.g.
// capturing and replaying SAML login response (signed with same IdP's certificate which is used
// by the application that is using this passport-saml fork) but targeted to some other SAML SP (i.e.
// to another audience).
//
// Other pieces in the mentioned puzzle are:
// - "validateInResponseTo" feature which is meant to prevent replaying any SAML response including but not
// limited to SAML response that was actually a response to SAML request initiated by the application
// that is using instance of this passport-saml fork (i.e. regardless of audience)
// - "allow only encrypted assertion(s)" policy enforcement. I.e. by not allowing plain text assertion(s)
// instances of the application using this passport-saml fork are not able to process assertion(s) which
// cannot be decrypted with this passport-saml fork instance's private key (i.e. in order to perform
// replay attack with SAML response captured from another SAML SP that particular SAML SP would have to
// have same private key). "Allow only encrypted assertion" policy makes it also hard
// to replay SAML login response targeted for the application using this passport-saml fork to another
// SAML SP (because that another SAML SP would have to have same private key in order to decrypt assertion)
//
// So...audience check enforcement somewhat overlaps with "allow only encrypted assertion" policy but there
// might be times when "encrypted assertion(s) only" policy must be turned off e.g. for debugging purposes.
//
// NOTE: passport-saml has issue https://github.com/bergie/passport-saml/issues/137
// which is not marked as closed (at the time of writing this code comment) even though commit
// https://github.com/bergie/passport-saml/commit/c2ce79d51d93b68e34911e74bb06cf915d8a754b
// has introduced audience checking to passport-saml (starting from passport-saml 0.32.0)
// (at the time of writing this code comment baseline for this passport-saml fork is passport-saml 0.32.1 )
//
throw new Error('options.audience was not configured');
}
// end suomifi additions
var attributeStatement = assertion.AttributeStatement;
if (attributeStatement) {
var attributes = [].concat
.apply([],
attributeStatement.filter(attr => Array.isArray(attr.Attribute))
.map(attr => attr.Attribute)
);
var attrValueMapper = function(value) {
return typeof value === 'string' ? value : value._;
};
if (attributes) {
attributes.forEach(attribute => {
if(!Object.prototype.hasOwnProperty.call(attribute, 'AttributeValue')) {
// if attributes has no AttributeValue child, continue
return;
}
var 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;
profile.getAssertion = () => parsedAssertion;
profile.getSamlResponseXml = () => samlResponseXml;
callback(null, profile, false);
})
.fail(err => callback(err))
.done();
};
SAML.prototype.checkTimestampsValidityError = function(nowMs, notBefore, notOnOrAfter) {
if (this.options.acceptedClockSkewMs == -1)
return null;
if (notBefore) {
var notBeforeMs = Date.parse(notBefore);
if (nowMs + this.options.acceptedClockSkewMs < notBeforeMs)
return new Error('SAML assertion not yet valid');
}
if (notOnOrAfter) {
var notOnOrAfterMs = Date.parse(notOnOrAfter);
if (nowMs - this.options.acceptedClockSkewMs >= notOnOrAfterMs)
return new Error('SAML assertion expired');
}
return null;
};
SAML.prototype.checkAudienceValidityError = function(expectedAudience, audienceRestrictions) {
if (