passport-saml-encrypted
Version:
A Passport strategy for handling encrypted SAML authentication responses
426 lines (355 loc) • 14.6 kB
JavaScript
var zlib = require('zlib');
var xml2js = require('xml2js');
var xmlCrypto = require('xml-crypto');
var decryptSAML = require('./decryptSAML');
var crypto = require('crypto');
var xmldom = require('@xmldom/xmldom');
var querystring = require('querystring');
var SAML = function (options) {
this.options = this.initialize(options);
};
SAML.prototype.initialize = function (options) {
if (!options) {
options = {};
}
if (!options.protocol) {
options.protocol = 'https://';
}
if (!options.path) {
options.path = '/saml/consume';
}
if (!options.issuer) {
options.issuer = 'onelogin_saml';
}
if (options.identifierFormat === undefined) {
options.identifierFormat = "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress";
}
return options;
};
SAML.prototype.generateUniqueID = function () {
var chars = "abcdef0123456789";
var uniqueID = "";
var idLength = (Math.floor(Math.random() * 9)) + 32
for (var i = 0; i < idLength; i++) {
uniqueID += chars.substr(Math.floor((Math.random()*15)), 1);
}
return uniqueID;
};
/*SAML.prototype.generateInstant = function () {
var date = new Date();
return date.getUTCFullYear() + '-' + ('0' + (date.getUTCMonth()+1)).slice(-2) + '-' + ('0' + date.getUTCDate()).slice(-2) + 'T' + ('0' + (date.getUTCHours()+2)).slice(-2) + ":" + ('0' + date.getUTCMinutes()).slice(-2) + ":" + ('0' + date.getUTCSeconds()).slice(-2) + "Z";
};
*/
//from old passport-saml plugin
SAML.prototype.generateInstant = function () {
return new Date().toISOString();
};
SAML.prototype.signRequest = function (xml) {
var signer = crypto.createSign('RSA-SHA1');
signer.update(xml);
return signer.sign(this.options.privateCert, 'base64');
}
SAML.prototype.generateAuthorizeRequest = function (req) {
var id = "_" + this.generateUniqueID();
var instant = this.generateInstant();
// Post-auth destination
if (this.options.callbackUrl) {
callbackUrl = this.options.callbackUrl;
} else {
var callbackUrl = this.options.protocol + req.headers.host + this.options.path;
}
var request;
if (this.options.customBuildAuthorizeRequestCallback) {
request = this.options.customBuildAuthorizeRequestCallback({
id:id,
instant:instant,
req:req,
options:this.options
});
} else {
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\" AssertionConsumerServiceURL=\"" + callbackUrl + "\" Destination=\"" +
this.options.entryPoint + "\">" +
"<saml:Issuer xmlns:saml=\"urn:oasis:names:tc:SAML:2.0:assertion\">" + this.options.issuer + "</saml:Issuer>\n";
if (this.options.identifierFormat) {
request += "<samlp:NameIDPolicy xmlns:samlp=\"urn:oasis:names:tc:SAML:2.0:protocol\" Format=\"" + this.options.identifierFormat +
"\" AllowCreate=\"true\"></samlp:NameIDPolicy>\n";
}
/* request +=
"<samlp:RequestedAuthnContext xmlns:samlp=\"urn:oasis:names:tc:SAML:2.0:protocol\" Comparison=\"exact\">" +
"<saml:AuthnContextClassRef xmlns:saml=\"urn:oasis:names:tc:SAML:2.0:assertion\">urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport</saml:AuthnContextClassRef></samlp:RequestedAuthnContext>\n" +
"</samlp:AuthnRequest>";
*/
request += "</samlp:AuthnRequest>";
}
return request;
};
SAML.prototype.generateLogoutRequest = function (req) {
var id = "_" + this.generateUniqueID();
var instant = this.generateInstant();
//samlp:LogoutRequest xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion"
// ID="_135ad2fd-b275-4428-b5d6-3ac3361c3a7f" Version="2.0" Destination="https://idphost/adfs/ls/"
//IssueInstant="2008-06-03T12:59:57Z"><saml:Issuer>myhost</saml:Issuer><NameID Format="urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress"
//NameQualifier="https://idphost/adfs/ls/">myemail@mydomain.com</NameID<samlp:SessionIndex>_0628125f-7f95-42cc-ad8e-fde86ae90bbe
//</samlp:SessionIndex></samlp:LogoutRequest>
var request;
if (this.options.customBuildLogoutRequestCallback) {
request = this.options.customBuildLogoutRequestCallback({
id:id,
instant:instant,
req:req,
options:this.options
});
} else {
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.entryPoint + "\">" +
"<saml:Issuer xmlns:saml=\"urn:oasis:names:tc:SAML:2.0:assertion\">" + this.options.issuer + "</saml:Issuer>"+
"<saml:NameID Format=\""+req.user.nameIDFormat+"\">"+req.user.nameID+"</saml:NameID>"+
"</samlp:LogoutRequest>";
}
return request;
}
SAML.prototype.requestToUrl = function (request, operation, callback) {
var self = this;
zlib.deflateRaw(request, function(err, buffer) {
if (err) {
return callback(err);
}
var base64 = buffer.toString('base64');
var target = self.options.entryPoint + '?';
if (operation === 'logout') {
if (self.options.logoutUrl) {
target = self.options.logoutUrl + '?';
}
}
var samlRequest = {
SAMLRequest: base64
};
if (self.options.privateCert) {
samlRequest.SigAlg = 'http://www.w3.org/2000/09/xmldsig#rsa-sha1';
samlRequest.Signature = self.signRequest(querystring.stringify(samlRequest));
}
target += querystring.stringify(samlRequest);
callback(null, target);
});
}
SAML.prototype.getAuthorizeUrl = function (req, callback) {
var request = this.generateAuthorizeRequest(req);
this.requestToUrl(request, 'authorize', callback);
};
SAML.prototype.getLogoutUrl = function(req, callback) {
var request = this.generateLogoutRequest(req);
this.requestToUrl(request, 'logout', callback);
}
SAML.prototype.certToPEM = function (cert) {
cert = cert.match(/.{1,64}/g).join('\n');
cert = "-----BEGIN CERTIFICATE-----\n" + cert;
cert = cert + "\n-----END CERTIFICATE-----\n";
return cert;
};
SAML.prototype.checkSAMLStatus = function (xmlDomDoc, callback) {
var status = {StatusCodeValue:null, StatusMessage:null, StatusDetail:null}, statusCodeValueNode = null, statusMessageNode = null, statusDetailNode = null;
try{
statusCodeValueNode = xmlCrypto.xpath(xmlDomDoc, "//*[local-name(.)='StatusCode']")[0];
}catch(err){
// Error Handling
return callback(new Error('Failed to set statusCodeValueNode'), null, false);
}
if(typeof statusCodeValueNode != 'undefined'){
status.StatusCodeValue = statusCodeValueNode.getAttribute('Value');
}
try{
statusMessageNode = xmlCrypto.xpath(xmlDomDoc, "//*[local-name(.)='StatusMessage']")[0];
}catch(err){
// Error Handling
return callback(new Error('Failed to set statusMessageNode'), null, false);
}
if(statusMessageNode == true || typeof statusMessageNode != 'undefined'){
status.StatusMessage = statusMessageNode.childNodes[0].nodeValue;
}
try{
statusDetailNode = xmlCrypto.xpath(xmlDomDoc, "//*[local-name(.)='StatusDetail']/*[local-name(.)='Cause']")[0];
}catch(err){
// Error Handling
return callback(new Error('Failed to set statusDetailNode'), null, false);
}
if(statusDetailNode == true || typeof statusDetailNode != 'undefined'){
status.StatusDetail = statusDetailNode.childNodes[0].nodeValue;
}
//status.StatusMessage = xmlCrypto.xpath(xmlDomDoc, "//*[local-name(.)='StatusMessage']")[0].childNodes[0].nodeValue;
//status.StatusDetail = xmlCrypto.xpath(xmlDomDoc, "//*[local-name(.)='StatusDetail']/*[local-name(.)='Cause']")[0].childNodes[0].nodeValue;
return status;
};
SAML.prototype.decryptSAMLAssertion = function (xmlDomDoc, privateCert) {
var encryptedDataNode = null;
try{
encryptedDataNode = xmlCrypto.xpath(xmlDomDoc, "//*[local-name(.)='EncryptedData' and namespace-uri(.)='http://www.w3.org/2001/04/xmlenc#']")[0];
}catch(err) {
// Error Handling
return callback(new Error('Failed to set encryptedDataNode'), null, false);
}
var encryptedData = encryptedDataNode.toString();
//console.log(encryptedData);
var decryptOptions = { key: privateCert };
var resultObj = decryptSAML(encryptedData,decryptOptions);
if(resultObj.result)
{
return resultObj.result;
}
else
{
return resultObj.err;
}
};
// Validates the given `xml` using `cert` and returns true if successsful
// Used for validating both the top level SAML response as well as assertions
// We always expect a signature to be found at the top level of the XML passed in,
// using the first signature and ignoring others.
SAML.prototype.validateSignature = function (xml, cert) {
var self = this;
var doc = new xmldom.DOMParser().parseFromString(xml);
var signature = null, sig = null;
try{
signature = xmlCrypto.xpath(doc, "//*[local-name(.)='Signature' and namespace-uri(.)='http://www.w3.org/2000/09/xmldsig#']")[0];
}catch(err){
// Error Handling
return callback(new Error('Failed to get Signature'), null, false);
}
try{
sig = new xmlCrypto.SignedXml();
}catch(err){
// Error Handling
return callback(new Error('Failed to create new SignedXml'), null, false);
}
sig.keyInfoProvider = {
getKeyInfo: function (key) {
return "<X509Data></X509Data>"
},
getKey: function (keyInfo) {
return self.certToPEM(cert);
}
};
sig.loadSignature(signature.toString());
return sig.checkSignature(xml);
};
SAML.prototype.getElement = function (parentElement, elementName) {
if (parentElement['saml:' + elementName]) {
return parentElement['saml:' + elementName];
} else if (parentElement['samlp:'+elementName]) {
return parentElement['samlp:'+elementName];
}
return parentElement[elementName];
}
SAML.prototype.validateResponse = function (samlResponse, callback) {
var self = this;
var xml = new Buffer(samlResponse, 'base64').toString();
var samlAssertion = null;
// Verify signature on the response
if (self.options.cert && !self.validateSignature(xml, self.options.cert)) {
return callback(new Error('Invalid signature'), null, false);
}
var xmlDomDoc = new xmldom.DOMParser().parseFromString(xml);
//Check Status code in the SAML Response
var statusObj = self.checkSAMLStatus(xmlDomDoc, callback);
if(statusObj.StatusCodeValue != "urn:oasis:names:tc:SAML:2.0:status:Success")
return callback(new Error('SAML Error Response:\nStatusCodeValue = '+ statusObj.StatusCodeValue + '\nStatusMessage = ' + statusObj.StatusMessage + '\nStatusDetail = ' + statusObj.StatusDetail + '\n'), null, false);
// Decrypt and Retrieve SAML Assertion
if (self.options.encryptedSAML && self.options.privateCert)
{
samlAssertion = self.decryptSAMLAssertion(xmlDomDoc, self.options.privateCert);
if (!samlAssertion){
return callback(new Error('Decryption Failed'), null, false);
}
else //trim the unwanted characters after closing </saml:Assertion>
{
var nIndex = samlAssertion.indexOf("</saml:Assertion>");
var validStringLen = nIndex + 17;
samlAssertion = samlAssertion.slice(0, validStringLen);
}
// console.log(samlAssertion);
// Verify signature on the decrypted assertion
if (self.options.cert && !self.validateSignature(samlAssertion, self.options.cert)) {
return callback(new Error('Invalid signature'), null, false);
}
}
else //Retrieve SAML Assertion
{
try{
samlAssertionNode = xmlCrypto.xpath(xmlDomDoc, "//*[local-name(.)='Assertion']")[0];
}catch(err){
// Error Handling
return callback(new Error('Failed to get samlAssertionNode'), null, false);
}
if (samlAssertionNode)
{
samlAssertion = samlAssertionNode.toString();
}
else
{
return callback(new Error('Missing Assertion'), null, false);
}
}
var parser = new xml2js.Parser({explicitRoot:true});
parser.parseString(samlAssertion, function (err, doc) {
var assertion = self.getElement(doc, 'Assertion');
if (assertion) {
var expires = new Date(self.getElement(assertion, 'Conditions')[0]['$'].NotOnOrAfter);
if (expires < Date.now()) {
return callback(new Error('Expired SAML assertion'), null, false);
}
profile = {};
var issuer = self.getElement(assertion, 'Issuer');
if (issuer) {
profile.issuer = issuer[0];
}
var authnStatement = self.getElement(assertion, "AuthnStatement");
if (authnStatement.constructor === Array && authnStatement.length > 0 && authnStatement[0]['$']) {
profile._authnStatement = authnStatement[0]['$'];
}
var subject = self.getElement(assertion, 'Subject');
if (subject) {
var nameID = self.getElement(subject[0], 'NameID');
if (nameID) {
profile.nameID = nameID[0]["_"];
if (nameID[0]['$'].Format) {
profile.nameIDFormat = nameID[0]['$'].Format;
}
}
}
var attributeStatement = self.getElement(assertion, 'AttributeStatement');
if (!attributeStatement) {
return callback(new Error('Missing AttributeStatement'), null, false);
}
var attributes = self.getElement(attributeStatement[0], 'Attribute');
if (attributes) {
attributes.forEach(function (attribute) {
var attributeValues = self.getElement(attribute, 'AttributeValue');
//Extract the text of all the values for this attribute into an array.
var textValues = attributeValues.map(function (value) {
return (typeof value === 'string') ? value : value['_'];
});
//If it's only one entry, append it directly. Otherwise pass the array.
profile[attribute['$'].Name] = (textValues.length === 1) ? textValues[0] : textValues;
});
}
if (!profile.mail && profile['urn:oid:0.9.2342.19200300.100.1.3']) {
// See http://www.incommonfederation.org/attributesummary.html 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;
}
callback(null, profile, false);
} else {
var logoutResponse = self.getElement(doc, 'LogoutResponse');
if (logoutResponse){
callback(null, null, true);
} else {
return callback(new Error('Unknown SAML response message'), null, false);
}
}
});
};
exports.SAML = SAML;