From 1916fbec849de23150adb76a83660677944841d5 Mon Sep 17 00:00:00 2001 From: Jon Lindsey <49694086+jonlindsey@users.noreply.github.com> Date: Thu, 7 May 2020 21:27:31 -0500 Subject: [PATCH 1/3] WIP: Library: Enhance Saml package (saml20) - Adds 2 new return products for SAML 2.0 messaging. A full Signed SAML response with assertion ( encrypted assertion option). --- lib/saml20.js | 231 ++++++++++++++++++++++++------------ lib/saml20Response.template | 6 + test/saml20.tests.js | 170 +++++++++++++++++++++++++- test/utils.js | 8 +- 4 files changed, 334 insertions(+), 81 deletions(-) create mode 100644 lib/saml20Response.template diff --git a/lib/saml20.js b/lib/saml20.js index 21c1f278..00d5301d 100644 --- a/lib/saml20.js +++ b/lib/saml20.js @@ -1,11 +1,11 @@ var utils = require('./utils'), - Parser = require('xmldom').DOMParser, - SignedXml = require('xml-crypto').SignedXml, - xmlenc = require('xml-encryption'), - moment = require('moment'), - xmlNameValidator = require('xml-name-validator'), - is_uri = require('valid-url').is_uri; + Parser = require('xmldom').DOMParser, + SignedXml = require('xml-crypto').SignedXml, + xmlenc = require('xml-encryption'), + moment = require('moment'), + xmlNameValidator = require('xml-name-validator'), + is_uri = require('valid-url').is_uri; var fs = require('fs'); var path = require('path'); @@ -16,7 +16,7 @@ var NAMESPACE = 'urn:oasis:names:tc:SAML:2.0:assertion'; var algorithms = { signature: { 'rsa-sha256': 'http://www.w3.org/2001/04/xmldsig-more#rsa-sha256', - 'rsa-sha1': 'http://www.w3.org/2000/09/xmldsig#rsa-sha1' + 'rsa-sha1': 'http://www.w3.org/2000/09/xmldsig#rsa-sha1' }, digest: { 'sha256': 'http://www.w3.org/2001/04/xmlenc#sha256', @@ -24,8 +24,8 @@ var algorithms = { } }; -function getAttributeType(value){ - switch(typeof value) { +function getAttributeType(value) { + switch (typeof value) { case "string": return 'xs:string'; case "boolean": @@ -38,16 +38,16 @@ function getAttributeType(value){ } } -function getNameFormat(name){ - if (is_uri(name)){ +function getNameFormat(name) { + if (is_uri(name)) { return 'urn:oasis:names:tc:SAML:2.0:attrname-format:uri'; } // Check that the name is a valid xs:Name -> https://www.w3.org/TR/xmlschema-2/#Name - // xmlNameValidate.name takes a string and will return an object of the form { success, error }, - // where success is a boolean + // xmlNameValidate.name takes a string and will return an object of the form { success, error }, + // where success is a boolean // if it is false, then error is a string containing some hint as to where the match went wrong. - if (xmlNameValidator.name(name).success){ + if (xmlNameValidator.name(name).success) { return 'urn:oasis:names:tc:SAML:2.0:attrname-format:basic'; } @@ -55,32 +55,59 @@ function getNameFormat(name){ return 'urn:oasis:names:tc:SAML:2.0:attrname-format:unspecified'; } -exports.create = function(options, callback) { - if (!options.key) - throw new Error('Expect a private key in pem format'); +/** +* Gets the complere SAML Response merged with assertion (encrypted optional) and uses the +* saml20 argument options to set parts of the response utilizing the saml20Response.template file. +* @param assertion - the SAML assertion to add to the SAML response. +* @param options - The saml20 class options argument. +*/ +function getSamlResponseXml(assertion, options) { + var issueTime = new Date().toISOString(); - if (!options.cert) - throw new Error('Expect a public key cert in pem format'); + var assertionXml = new Parser().parseFromString(assertion); + var saml20Response = fs.readFileSync(path.join(__dirname, 'saml20Response.template')).toString(); - options.signatureAlgorithm = options.signatureAlgorithm || 'rsa-sha256'; - options.digestAlgorithm = options.digestAlgorithm || 'sha256'; + var doc = new Parser().parseFromString(saml20Response.toString()); - options.includeAttributeNameFormat = (typeof options.includeAttributeNameFormat !== 'undefined') ? options.includeAttributeNameFormat : true; - options.typedAttributes = (typeof options.typedAttributes !== 'undefined') ? options.typedAttributes : true; + doc.documentElement.setAttribute('ID', '_' + (options.uid || utils.uid(32))); + doc.documentElement.setAttribute('IssueInstant', moment.utc().format('YYYY-MM-DDTHH:mm:ss.SSS[Z]')); + doc.documentElement.setAttribute('Destination', options.destination); + if (options.issuer) { + var issuer = doc.documentElement.getElementsByTagName('saml:Issuer'); + issuer[0].textContent = options.issuer; + } + doc.lastChild.appendChild(assertionXml.documentElement); + return doc.toString(); +} + +/** +* Signs the SAML XML at the Assertion level (default) or the Response Level (optional) using private key and cert. +* @param xmlToSign - The XML in string form containing the XML assertion or response. +* @param options - The saml20 class options argument. +*/ +function signXml(xmlToSign, options) { // 0.10.1 added prefix, but we want to name it signatureNamespacePrefix - This is just to keep supporting prefix options.signatureNamespacePrefix = options.signatureNamespacePrefix || options.prefix; - options.signatureNamespacePrefix = typeof options.signatureNamespacePrefix === 'string' ? options.signatureNamespacePrefix : '' ; + options.signatureNamespacePrefix = typeof options.signatureNamespacePrefix === 'string' ? options.signatureNamespacePrefix : ''; var cert = utils.pemToCert(options.cert); - var sig = new SignedXml(null, { signatureAlgorithm: algorithms.signature[options.signatureAlgorithm], idAttribute: 'ID' }); - sig.addReference("//*[local-name(.)='Assertion']", - ["http://www.w3.org/2000/09/xmldsig#enveloped-signature", "http://www.w3.org/2001/10/xml-exc-c14n#"], - algorithms.digest[options.digestAlgorithm]); + var signingLocation = options.createSignedSamlResponse ? 'Response' : 'Assertion'; + sig.addReference("//*[local-name(.)='" + signingLocation + "']", + ["http://www.w3.org/2000/09/xmldsig#enveloped-signature", "http://www.w3.org/2001/10/xml-exc-c14n#"], + algorithms.digest[options.digestAlgorithm]); sig.signingKey = options.key; - + + var opts = { + location: { + reference: options.xpathToNodeBeforeSignature || "//*[local-name(.)='Issuer']", + action: 'after' + }, + prefix: options.signatureNamespacePrefix + }; + sig.keyInfoProvider = { getKeyInfo: function (key, prefix) { prefix = prefix ? prefix + ':' : prefix; @@ -88,10 +115,53 @@ exports.create = function(options, callback) { } }; + sig.computeSignature(xmlToSign, opts); + + return sig.getSignedXml(); +} + +/** +* Encrypts s SAML assertion and formats with EncryptedAssertion wrapper using with provided cert. +* @param assertionToEncrypt - The SAML assertion to encrypt. +* @param options - The saml20 class options argument. +* @param callback - The callback function for ASYNC processing completion. +*/ +function encryptAssertionXml(assertionToEncrypt, options, callback) { + var encryptOptions = { + rsa_pub: options.encryptionPublicKey, + pem: options.encryptionCert, + encryptionAlgorithm: options.encryptionAlgorithm || 'http://www.w3.org/2001/04/xmlenc#aes256-cbc', + keyEncryptionAlgorighm: options.keyEncryptionAlgorighm || 'http://www.w3.org/2001/04/xmlenc#rsa-oaep-mgf1p' + }; + + xmlenc.encrypt(assertionToEncrypt, encryptOptions, function (err, encrypted) { + if (err) return callback(err); + var assertion = '' + encrypted + ''; + return callback(null, assertion); + }) +} + +exports.create = function (options, callback) { + if (!options.key) + throw new Error('Expect a private key in pem format'); + + if (!options.cert) + throw new Error('Expect a public key cert in pem format'); + + if (options.createSignedSamlResponse && + (!options.destination || options.destination.length < 1)) + throw new Error('Expect a SAML Response destination for message to be valid.') + + options.signatureAlgorithm = options.signatureAlgorithm || 'rsa-sha256'; + options.digestAlgorithm = options.digestAlgorithm || 'sha256'; + + options.includeAttributeNameFormat = (typeof options.includeAttributeNameFormat !== 'undefined') ? options.includeAttributeNameFormat : true; + options.typedAttributes = (typeof options.typedAttributes !== 'undefined') ? options.typedAttributes : true; + var doc; try { doc = new Parser().parseFromString(saml20.toString()); - } catch(err){ + } catch (err) { return utils.reportError(err, callback); } @@ -109,10 +179,10 @@ exports.create = function(options, callback) { if (options.lifetimeInSeconds) { conditions[0].setAttribute('NotBefore', now.format('YYYY-MM-DDTHH:mm:ss.SSS[Z]')); conditions[0].setAttribute('NotOnOrAfter', now.clone().add(options.lifetimeInSeconds, 'seconds').format('YYYY-MM-DDTHH:mm:ss.SSS[Z]')); - - confirmationData[0].setAttribute('NotOnOrAfter', now.clone().add(options.lifetimeInSeconds, 'seconds').format('YYYY-MM-DDTHH:mm:ss.SSS[Z]')); + + confirmationData[0].setAttribute('NotOnOrAfter', now.clone().add(options.lifetimeInSeconds, 'seconds').format('YYYY-MM-DDTHH:mm:ss.SSS[Z]')); } - + if (options.audiences) { var audienceRestriction = doc.createElementNS(NAMESPACE, 'saml:AudienceRestriction'); var audiences = options.audiences instanceof Array ? options.audiences : [options.audiences]; @@ -122,7 +192,7 @@ exports.create = function(options, callback) { audienceRestriction.appendChild(element); }); - conditions[0].appendChild(audienceRestriction); + conditions[0].appendChild(audienceRestriction); } if (options.recipient) @@ -136,16 +206,16 @@ exports.create = function(options, callback) { statement.setAttribute('xmlns:xs', 'http://www.w3.org/2001/XMLSchema'); statement.setAttribute('xmlns:xsi', 'http://www.w3.org/2001/XMLSchema-instance'); doc.documentElement.appendChild(statement); - Object.keys(options.attributes).forEach(function(prop) { - if(typeof options.attributes[prop] === 'undefined') return; + Object.keys(options.attributes).forEach(function (prop) { + if (typeof options.attributes[prop] === 'undefined') return; // // Foo Bar // var attributeElement = doc.createElementNS(NAMESPACE, 'saml:Attribute'); attributeElement.setAttribute('Name', prop); - if (options.includeAttributeNameFormat){ - attributeElement.setAttribute('NameFormat', getNameFormat(prop)); + if (options.includeAttributeNameFormat) { + attributeElement.setAttribute('NameFormat', getNameFormat(prop)); } var values = options.attributes[prop] instanceof Array ? options.attributes[prop] : [options.attributes[prop]]; @@ -160,7 +230,7 @@ exports.create = function(options, callback) { } }); - if (values && values.filter(function(i){ return typeof i !== 'undefined'; }).length > 0) { + if (values && values.filter(function (i) { return typeof i !== 'undefined'; }).length > 0) { // saml:Attribute must have at least one saml:AttributeValue statement.appendChild(attributeElement); } @@ -176,7 +246,7 @@ exports.create = function(options, callback) { } var nameID = doc.documentElement.getElementsByTagNameNS(NAMESPACE, 'NameID')[0]; - + if (options.nameIdentifier) { nameID.textContent = options.nameIdentifier; } @@ -184,48 +254,55 @@ exports.create = function(options, callback) { if (options.nameIdentifierFormat) { nameID.setAttribute('Format', options.nameIdentifierFormat); } - - if( options.authnContextClassRef ) { + + if (options.authnContextClassRef) { var authnCtxClassRef = doc.getElementsByTagName('saml:AuthnContextClassRef')[0]; authnCtxClassRef.textContent = options.authnContextClassRef; } - var token = utils.removeWhitespace(doc.toString()); - var signed; - try { - var opts = { - location: { - reference: options.xpathToNodeBeforeSignature || "//*[local-name(.)='Issuer']", - action: 'after' - }, - prefix: options.signatureNamespacePrefix - }; - - sig.computeSignature(token, opts); - signed = sig.getSignedXml(); - } catch(err){ - return utils.reportError(err, callback); + var assertion = utils.removeWhitespace(doc.toString()); + + // NEW: Option: build a complete signed SAML response with embedded (option encrypted) assertion + if (options.createSignedSamlResponse) { + try { + // IF SAML response assertion is set to be encrypted + if (options.encryptionCert) { + encryptAssertionXml(assertion, options, function (err, encryptedAssertion) { + if (err) return callback(err); + var signedResponse = signSamlResponse(encryptedAssertion); + return callback(null, signedResponse); + }); + } else { + // Do not encrypt assertion and send back + var signedPlainResponse = signSamlResponse(assertion); + return (callback) ? callback(null, signedPlainResponse) : signedPlainResponse; + } + } catch (err) { + return (callback) ? callback(err) : err; + } + } else { + try { + // Sign the assertion always for both options + var signedAssertion = signXml(utils.removeWhitespace(assertion), options); + if (options.encryptionCert) { + // If assertion is set to be encrypted + encryptAssertionXml(signedAssertion, options, function (err, encryptedAssertion) { + if (err) return callback(err); + return callback(null, encryptedAssertion) + }); + } else { + // If assertion encryption not set just send back + return (callback) ? callback(null, signedAssertion) : signedAssertion; + } + } catch (err) { + return (callback) ? callback(err) : err; + } } - if (!options.encryptionCert) { - if (callback) - return callback(null, signed); - else - return signed; + // Generates response with inserted assertion (or encrypted assertion) and signs + function signSamlResponse(assertion) { + var samlResponse = getSamlResponseXml(assertion, options); + return signXml(utils.removeWhitespace(samlResponse), options); } - - var encryptOptions = { - rsa_pub: options.encryptionPublicKey, - pem: options.encryptionCert, - encryptionAlgorithm: options.encryptionAlgorithm || 'http://www.w3.org/2001/04/xmlenc#aes256-cbc', - keyEncryptionAlgorighm: options.keyEncryptionAlgorighm || 'http://www.w3.org/2001/04/xmlenc#rsa-oaep-mgf1p' - }; - - xmlenc.encrypt(signed, encryptOptions, function(err, encrypted) { - if (err) return callback(err); - encrypted = '' + encrypted + ''; - callback(null, utils.removeWhitespace(encrypted)); - }); -}; - +}; diff --git a/lib/saml20Response.template b/lib/saml20Response.template new file mode 100644 index 00000000..837b62a7 --- /dev/null +++ b/lib/saml20Response.template @@ -0,0 +1,6 @@ + + + + + + diff --git a/test/saml20.tests.js b/test/saml20.tests.js index e351cfa3..bb694803 100644 --- a/test/saml20.tests.js +++ b/test/saml20.tests.js @@ -484,6 +484,100 @@ describe('saml 2.0', function () { assert.equal(attributeStatement.length, 0); }); + describe('saml 2.0 full SAML response', function () { + + it('should create a saml 2.0 signed response including plain assertion', function (done) { + var options = { + cert: fs.readFileSync(__dirname + '/test-auth0.pem'), + key: fs.readFileSync(__dirname + '/test-auth0.key'), + xpathToNodeBeforeSignature: "//*[local-name(.)='Issuer']", + createSignedSamlResponse: true, + destination: 'https:/foo.com' + }; + + var samlResponse = saml.create(options); + + var isValid = utils.isValidSignature(samlResponse, options.cert); + assert.equal(true, isValid); + + done(); + }); + + it('...with attributes', function (done) { + var options = { + cert: fs.readFileSync(__dirname + '/test-auth0.pem'), + key: fs.readFileSync(__dirname + '/test-auth0.key'), + xpathToNodeBeforeSignature: "//*[local-name(.)='Issuer']", + createSignedSamlResponse: true, + destination: 'https:/foo.com', + attributes: { + 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress': 'foo@bar.com', + 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name': 'Foo Bar', + 'http://example.org/claims/testaccent': 'fóo', // should supports accents + 'http://undefinedattribute/ws/com.com': undefined + } + }; + + var samlResponse = saml.create(options); + + var isValid = utils.isValidSignature(samlResponse, options.cert); + assert.equal(true, isValid); + + var attributes = utils.getAttributes(samlResponse); + assert.equal(3, attributes.length); + assert.equal('http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress', attributes[0].getAttribute('Name')); + assert.equal('foo@bar.com', attributes[0].textContent); + assert.equal('http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name', attributes[1].getAttribute('Name')); + assert.equal('Foo Bar', attributes[1].textContent); + assert.equal('http://example.org/claims/testaccent', attributes[2].getAttribute('Name')); + assert.equal('fóo', attributes[2].textContent); + + done(); + }); + + it('should insure SAML response attribute [ID] matches signature reference attribute [URI]', function (done) { + var options = { + cert: fs.readFileSync(__dirname + '/test-auth0.pem'), + key: fs.readFileSync(__dirname + '/test-auth0.key'), + xpathToNodeBeforeSignature: "//*[local-name(.)='Issuer']", + createSignedSamlResponse: true, + destination: 'https:/foo.com' + }; + + var samlResponse = saml.create(options); + + var isValid = utils.isValidSignature(samlResponse, options.cert); + assert.equal(true, isValid); + + var responseData = utils.getResponseData(samlResponse); + var responseId = responseData.getAttribute('ID'); + var referenceUri = (responseData.getElementsByTagName('Reference')[0].getAttribute('URI')); + assert.equal(referenceUri, '#' + responseId); + + done(); + }); + + it('should require a [Destination] attribute on SAML Response element', function (done) { + var options = { + cert: fs.readFileSync(__dirname + '/test-auth0.pem'), + key: fs.readFileSync(__dirname + '/test-auth0.key'), + xpathToNodeBeforeSignature: "//*[local-name(.)='Issuer']", + createSignedSamlResponse: true, + destination: '' + }; + + try{ + var samlResponse = saml.create(options); + }catch(err){ + assert(err.message.includes('Expect a SAML Response destination for message to be valid.')); + done(); + } + + throw "Error did not throw as expected!"; + done(); + }); + }); + describe('encryption', function () { it('should create a saml 2.0 signed and encrypted assertion', function (done) { @@ -508,7 +602,7 @@ describe('saml 2.0', function () { }); }); - it('should set attributes', function (done) { + it('...with assertion attributes', function (done) { var options = { cert: fs.readFileSync(__dirname + '/test-auth0.pem'), key: fs.readFileSync(__dirname + '/test-auth0.key'), @@ -546,7 +640,77 @@ describe('saml 2.0', function () { }); }); }); - - }); + describe('encryption full SAML response', function () { + + it('should create a saml 2.0 signed response including encrypted assertion', function (done) { + var options = { + cert: fs.readFileSync(__dirname + '/test-auth0.pem'), + key: fs.readFileSync(__dirname + '/test-auth0.key'), + encryptionPublicKey: fs.readFileSync(__dirname + '/test-auth0_rsa.pub'), + encryptionCert: fs.readFileSync(__dirname + '/test-auth0.pem'), + xpathToNodeBeforeSignature: "//*[local-name(.)='Issuer']", + createSignedSamlResponse: true, + destination: 'https:/foo.com' + }; + + saml.create(options, function(err, encrypted) { + if (err) return done(err); + + var isValid = utils.isValidSignature(encrypted, options.cert); + assert.equal(true, isValid); + + var encryptedData = utils.getEncryptedData(encrypted); + + xmlenc.decrypt(encryptedData.toString(), { key: fs.readFileSync(__dirname + '/test-auth0.key')}, function(err, decrypted) { + if (err) return done(err); + + done(); + }); + }); + }); + + it('...with assertion attributes', function (done) { + var options = { + cert: fs.readFileSync(__dirname + '/test-auth0.pem'), + key: fs.readFileSync(__dirname + '/test-auth0.key'), + encryptionPublicKey: fs.readFileSync(__dirname + '/test-auth0_rsa.pub'), + encryptionCert: fs.readFileSync(__dirname + '/test-auth0.pem'), + xpathToNodeBeforeSignature: "//*[local-name(.)='Issuer']", + createSignedSamlResponse: true, + destination: 'https:/foo.com', + attributes: { + 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress': 'foo@bar.com', + 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name': 'Foo Bar', + 'http://example.org/claims/testaccent': 'fóo', // should supports accents + 'http://undefinedattribute/ws/com.com': undefined + } + }; + + saml.create(options, function(err, encrypted) { + if (err) return done(err); + + var isValid = utils.isValidSignature(encrypted, options.cert); + assert.equal(true, isValid); + + var encryptedData = utils.getEncryptedData(encrypted); + + xmlenc.decrypt(encryptedData.toString(), { key: fs.readFileSync(__dirname + '/test-auth0.key')}, function(err, decrypted) { + if (err) return done(err); + + var attributes = utils.getAttributes(decrypted); + assert.equal(3, attributes.length); + assert.equal('http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress', attributes[0].getAttribute('Name')); + assert.equal('foo@bar.com', attributes[0].textContent); + assert.equal('http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name', attributes[1].getAttribute('Name')); + assert.equal('Foo Bar', attributes[1].textContent); + assert.equal('http://example.org/claims/testaccent', attributes[2].getAttribute('Name')); + assert.equal('fóo', attributes[2].textContent); + + done(); + }); + }); + }); + }); + }); }); diff --git a/test/utils.js b/test/utils.js index 4d7e0426..463a8a3f 100644 --- a/test/utils.js +++ b/test/utils.js @@ -94,5 +94,11 @@ exports.getSubjectConfirmation = function(assertion) { exports.getEncryptedData = function(encryptedAssertion) { var doc = new xmldom.DOMParser().parseFromString(encryptedAssertion); return doc.documentElement - .getElementsByTagName('xenc:EncryptedData')[0]; + .getElementsByTagName('xenc:EncryptedData')[0]; }; + +exports.getResponseData = function(assertion) { + var doc = new xmldom.DOMParser().parseFromString(assertion); + return doc.getElementsByTagName('samlp:Response')[0]; +}; + From e032128b04881516cd8fddd486bc093e256f2a30 Mon Sep 17 00:00:00 2001 From: Jon Lindsey <49694086+jonlindsey@users.noreply.github.com> Date: Fri, 8 May 2020 12:54:53 -0500 Subject: [PATCH 2/3] =?UTF-8?q?feat:=20add=20SAML=20signed=20response=20wi?= =?UTF-8?q?th=20assertion=20and=20encrypted=C2=A0option.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/saml20.js | 231 ++++++++++++------------------------ lib/saml20Response.template | 4 +- test/saml20.tests.js | 2 +- test/utils.js | 5 +- 4 files changed, 82 insertions(+), 160 deletions(-) diff --git a/lib/saml20.js b/lib/saml20.js index 00d5301d..d03c1351 100644 --- a/lib/saml20.js +++ b/lib/saml20.js @@ -1,11 +1,11 @@ var utils = require('./utils'), - Parser = require('xmldom').DOMParser, - SignedXml = require('xml-crypto').SignedXml, - xmlenc = require('xml-encryption'), - moment = require('moment'), - xmlNameValidator = require('xml-name-validator'), - is_uri = require('valid-url').is_uri; + Parser = require('xmldom').DOMParser, + SignedXml = require('xml-crypto').SignedXml, + xmlenc = require('xml-encryption'), + moment = require('moment'), + xmlNameValidator = require('xml-name-validator'), + is_uri = require('valid-url').is_uri; var fs = require('fs'); var path = require('path'); @@ -16,7 +16,7 @@ var NAMESPACE = 'urn:oasis:names:tc:SAML:2.0:assertion'; var algorithms = { signature: { 'rsa-sha256': 'http://www.w3.org/2001/04/xmldsig-more#rsa-sha256', - 'rsa-sha1': 'http://www.w3.org/2000/09/xmldsig#rsa-sha1' + 'rsa-sha1': 'http://www.w3.org/2000/09/xmldsig#rsa-sha1' }, digest: { 'sha256': 'http://www.w3.org/2001/04/xmlenc#sha256', @@ -24,8 +24,8 @@ var algorithms = { } }; -function getAttributeType(value) { - switch (typeof value) { +function getAttributeType(value){ + switch(typeof value) { case "string": return 'xs:string'; case "boolean": @@ -38,16 +38,16 @@ function getAttributeType(value) { } } -function getNameFormat(name) { - if (is_uri(name)) { +function getNameFormat(name){ + if (is_uri(name)){ return 'urn:oasis:names:tc:SAML:2.0:attrname-format:uri'; } // Check that the name is a valid xs:Name -> https://www.w3.org/TR/xmlschema-2/#Name - // xmlNameValidate.name takes a string and will return an object of the form { success, error }, - // where success is a boolean + // xmlNameValidate.name takes a string and will return an object of the form { success, error }, + // where success is a boolean // if it is false, then error is a string containing some hint as to where the match went wrong. - if (xmlNameValidator.name(name).success) { + if (xmlNameValidator.name(name).success){ return 'urn:oasis:names:tc:SAML:2.0:attrname-format:basic'; } @@ -55,59 +55,32 @@ function getNameFormat(name) { return 'urn:oasis:names:tc:SAML:2.0:attrname-format:unspecified'; } -/** -* Gets the complere SAML Response merged with assertion (encrypted optional) and uses the -* saml20 argument options to set parts of the response utilizing the saml20Response.template file. -* @param assertion - the SAML assertion to add to the SAML response. -* @param options - The saml20 class options argument. -*/ -function getSamlResponseXml(assertion, options) { - var issueTime = new Date().toISOString(); - - var assertionXml = new Parser().parseFromString(assertion); - var saml20Response = fs.readFileSync(path.join(__dirname, 'saml20Response.template')).toString(); +exports.create = function(options, callback) { + if (!options.key) + throw new Error('Expect a private key in pem format'); - var doc = new Parser().parseFromString(saml20Response.toString()); + if (!options.cert) + throw new Error('Expect a public key cert in pem format'); - doc.documentElement.setAttribute('ID', '_' + (options.uid || utils.uid(32))); - doc.documentElement.setAttribute('IssueInstant', moment.utc().format('YYYY-MM-DDTHH:mm:ss.SSS[Z]')); - doc.documentElement.setAttribute('Destination', options.destination); - if (options.issuer) { - var issuer = doc.documentElement.getElementsByTagName('saml:Issuer'); - issuer[0].textContent = options.issuer; - } - doc.lastChild.appendChild(assertionXml.documentElement); + options.signatureAlgorithm = options.signatureAlgorithm || 'rsa-sha256'; + options.digestAlgorithm = options.digestAlgorithm || 'sha256'; - return doc.toString(); -} + options.includeAttributeNameFormat = (typeof options.includeAttributeNameFormat !== 'undefined') ? options.includeAttributeNameFormat : true; + options.typedAttributes = (typeof options.typedAttributes !== 'undefined') ? options.typedAttributes : true; -/** -* Signs the SAML XML at the Assertion level (default) or the Response Level (optional) using private key and cert. -* @param xmlToSign - The XML in string form containing the XML assertion or response. -* @param options - The saml20 class options argument. -*/ -function signXml(xmlToSign, options) { // 0.10.1 added prefix, but we want to name it signatureNamespacePrefix - This is just to keep supporting prefix options.signatureNamespacePrefix = options.signatureNamespacePrefix || options.prefix; - options.signatureNamespacePrefix = typeof options.signatureNamespacePrefix === 'string' ? options.signatureNamespacePrefix : ''; + options.signatureNamespacePrefix = typeof options.signatureNamespacePrefix === 'string' ? options.signatureNamespacePrefix : '' ; var cert = utils.pemToCert(options.cert); + var sig = new SignedXml(null, { signatureAlgorithm: algorithms.signature[options.signatureAlgorithm], idAttribute: 'ID' }); - var signingLocation = options.createSignedSamlResponse ? 'Response' : 'Assertion'; - sig.addReference("//*[local-name(.)='" + signingLocation + "']", - ["http://www.w3.org/2000/09/xmldsig#enveloped-signature", "http://www.w3.org/2001/10/xml-exc-c14n#"], - algorithms.digest[options.digestAlgorithm]); + sig.addReference("//*[local-name(.)='Assertion']", + ["http://www.w3.org/2000/09/xmldsig#enveloped-signature", "http://www.w3.org/2001/10/xml-exc-c14n#"], + algorithms.digest[options.digestAlgorithm]); sig.signingKey = options.key; - - var opts = { - location: { - reference: options.xpathToNodeBeforeSignature || "//*[local-name(.)='Issuer']", - action: 'after' - }, - prefix: options.signatureNamespacePrefix - }; - + sig.keyInfoProvider = { getKeyInfo: function (key, prefix) { prefix = prefix ? prefix + ':' : prefix; @@ -115,53 +88,10 @@ function signXml(xmlToSign, options) { } }; - sig.computeSignature(xmlToSign, opts); - - return sig.getSignedXml(); -} - -/** -* Encrypts s SAML assertion and formats with EncryptedAssertion wrapper using with provided cert. -* @param assertionToEncrypt - The SAML assertion to encrypt. -* @param options - The saml20 class options argument. -* @param callback - The callback function for ASYNC processing completion. -*/ -function encryptAssertionXml(assertionToEncrypt, options, callback) { - var encryptOptions = { - rsa_pub: options.encryptionPublicKey, - pem: options.encryptionCert, - encryptionAlgorithm: options.encryptionAlgorithm || 'http://www.w3.org/2001/04/xmlenc#aes256-cbc', - keyEncryptionAlgorighm: options.keyEncryptionAlgorighm || 'http://www.w3.org/2001/04/xmlenc#rsa-oaep-mgf1p' - }; - - xmlenc.encrypt(assertionToEncrypt, encryptOptions, function (err, encrypted) { - if (err) return callback(err); - var assertion = '' + encrypted + ''; - return callback(null, assertion); - }) -} - -exports.create = function (options, callback) { - if (!options.key) - throw new Error('Expect a private key in pem format'); - - if (!options.cert) - throw new Error('Expect a public key cert in pem format'); - - if (options.createSignedSamlResponse && - (!options.destination || options.destination.length < 1)) - throw new Error('Expect a SAML Response destination for message to be valid.') - - options.signatureAlgorithm = options.signatureAlgorithm || 'rsa-sha256'; - options.digestAlgorithm = options.digestAlgorithm || 'sha256'; - - options.includeAttributeNameFormat = (typeof options.includeAttributeNameFormat !== 'undefined') ? options.includeAttributeNameFormat : true; - options.typedAttributes = (typeof options.typedAttributes !== 'undefined') ? options.typedAttributes : true; - var doc; try { doc = new Parser().parseFromString(saml20.toString()); - } catch (err) { + } catch(err){ return utils.reportError(err, callback); } @@ -179,10 +109,10 @@ exports.create = function (options, callback) { if (options.lifetimeInSeconds) { conditions[0].setAttribute('NotBefore', now.format('YYYY-MM-DDTHH:mm:ss.SSS[Z]')); conditions[0].setAttribute('NotOnOrAfter', now.clone().add(options.lifetimeInSeconds, 'seconds').format('YYYY-MM-DDTHH:mm:ss.SSS[Z]')); - - confirmationData[0].setAttribute('NotOnOrAfter', now.clone().add(options.lifetimeInSeconds, 'seconds').format('YYYY-MM-DDTHH:mm:ss.SSS[Z]')); + + confirmationData[0].setAttribute('NotOnOrAfter', now.clone().add(options.lifetimeInSeconds, 'seconds').format('YYYY-MM-DDTHH:mm:ss.SSS[Z]')); } - + if (options.audiences) { var audienceRestriction = doc.createElementNS(NAMESPACE, 'saml:AudienceRestriction'); var audiences = options.audiences instanceof Array ? options.audiences : [options.audiences]; @@ -192,7 +122,7 @@ exports.create = function (options, callback) { audienceRestriction.appendChild(element); }); - conditions[0].appendChild(audienceRestriction); + conditions[0].appendChild(audienceRestriction); } if (options.recipient) @@ -206,16 +136,16 @@ exports.create = function (options, callback) { statement.setAttribute('xmlns:xs', 'http://www.w3.org/2001/XMLSchema'); statement.setAttribute('xmlns:xsi', 'http://www.w3.org/2001/XMLSchema-instance'); doc.documentElement.appendChild(statement); - Object.keys(options.attributes).forEach(function (prop) { - if (typeof options.attributes[prop] === 'undefined') return; + Object.keys(options.attributes).forEach(function(prop) { + if(typeof options.attributes[prop] === 'undefined') return; // // Foo Bar // var attributeElement = doc.createElementNS(NAMESPACE, 'saml:Attribute'); attributeElement.setAttribute('Name', prop); - if (options.includeAttributeNameFormat) { - attributeElement.setAttribute('NameFormat', getNameFormat(prop)); + if (options.includeAttributeNameFormat){ + attributeElement.setAttribute('NameFormat', getNameFormat(prop)); } var values = options.attributes[prop] instanceof Array ? options.attributes[prop] : [options.attributes[prop]]; @@ -230,7 +160,7 @@ exports.create = function (options, callback) { } }); - if (values && values.filter(function (i) { return typeof i !== 'undefined'; }).length > 0) { + if (values && values.filter(function(i){ return typeof i !== 'undefined'; }).length > 0) { // saml:Attribute must have at least one saml:AttributeValue statement.appendChild(attributeElement); } @@ -246,7 +176,7 @@ exports.create = function (options, callback) { } var nameID = doc.documentElement.getElementsByTagNameNS(NAMESPACE, 'NameID')[0]; - + if (options.nameIdentifier) { nameID.textContent = options.nameIdentifier; } @@ -254,55 +184,48 @@ exports.create = function (options, callback) { if (options.nameIdentifierFormat) { nameID.setAttribute('Format', options.nameIdentifierFormat); } - - if (options.authnContextClassRef) { + + if( options.authnContextClassRef ) { var authnCtxClassRef = doc.getElementsByTagName('saml:AuthnContextClassRef')[0]; authnCtxClassRef.textContent = options.authnContextClassRef; } - var assertion = utils.removeWhitespace(doc.toString()); - - // NEW: Option: build a complete signed SAML response with embedded (option encrypted) assertion - if (options.createSignedSamlResponse) { - try { - // IF SAML response assertion is set to be encrypted - if (options.encryptionCert) { - encryptAssertionXml(assertion, options, function (err, encryptedAssertion) { - if (err) return callback(err); - var signedResponse = signSamlResponse(encryptedAssertion); - return callback(null, signedResponse); - }); - } else { - // Do not encrypt assertion and send back - var signedPlainResponse = signSamlResponse(assertion); - return (callback) ? callback(null, signedPlainResponse) : signedPlainResponse; - } - } catch (err) { - return (callback) ? callback(err) : err; - } - } else { - try { - // Sign the assertion always for both options - var signedAssertion = signXml(utils.removeWhitespace(assertion), options); - if (options.encryptionCert) { - // If assertion is set to be encrypted - encryptAssertionXml(signedAssertion, options, function (err, encryptedAssertion) { - if (err) return callback(err); - return callback(null, encryptedAssertion) - }); - } else { - // If assertion encryption not set just send back - return (callback) ? callback(null, signedAssertion) : signedAssertion; - } - } catch (err) { - return (callback) ? callback(err) : err; - } + var token = utils.removeWhitespace(doc.toString()); + var signed; + try { + var opts = { + location: { + reference: options.xpathToNodeBeforeSignature || "//*[local-name(.)='Issuer']", + action: 'after' + }, + prefix: options.signatureNamespacePrefix + }; + + sig.computeSignature(token, opts); + signed = sig.getSignedXml(); + } catch(err){ + return utils.reportError(err, callback); } - // Generates response with inserted assertion (or encrypted assertion) and signs - function signSamlResponse(assertion) { - var samlResponse = getSamlResponseXml(assertion, options); - return signXml(utils.removeWhitespace(samlResponse), options); + if (!options.encryptionCert) { + if (callback) + return callback(null, signed); + else + return signed; } -}; + var encryptOptions = { + rsa_pub: options.encryptionPublicKey, + pem: options.encryptionCert, + encryptionAlgorithm: options.encryptionAlgorithm || 'http://www.w3.org/2001/04/xmlenc#aes256-cbc', + keyEncryptionAlgorighm: options.keyEncryptionAlgorighm || 'http://www.w3.org/2001/04/xmlenc#rsa-oaep-mgf1p' + }; + + xmlenc.encrypt(signed, encryptOptions, function(err, encrypted) { + if (err) return callback(err); + + encrypted = '' + encrypted + ''; + callback(null, utils.removeWhitespace(encrypted)); + }); +}; + diff --git a/lib/saml20Response.template b/lib/saml20Response.template index 837b62a7..49f2db2f 100644 --- a/lib/saml20Response.template +++ b/lib/saml20Response.template @@ -1,6 +1,6 @@ - + - + \ No newline at end of file diff --git a/test/saml20.tests.js b/test/saml20.tests.js index bb694803..0befa001 100644 --- a/test/saml20.tests.js +++ b/test/saml20.tests.js @@ -484,7 +484,7 @@ describe('saml 2.0', function () { assert.equal(attributeStatement.length, 0); }); - describe('saml 2.0 full SAML response', function () { + describe('saml 2.0 test full SAML response', function () { it('should create a saml 2.0 signed response including plain assertion', function (done) { var options = { diff --git a/test/utils.js b/test/utils.js index 463a8a3f..8780e049 100644 --- a/test/utils.js +++ b/test/utils.js @@ -97,8 +97,7 @@ exports.getEncryptedData = function(encryptedAssertion) { .getElementsByTagName('xenc:EncryptedData')[0]; }; -exports.getResponseData = function(assertion) { - var doc = new xmldom.DOMParser().parseFromString(assertion); +exports.getResponseData = function(samlResponse) { + var doc = new xmldom.DOMParser().parseFromString(samlResponse); return doc.getElementsByTagName('samlp:Response')[0]; }; - From bd6667a284b886d173ac33c28a6bcaab98718491 Mon Sep 17 00:00:00 2001 From: Jon Lindsey <49694086+jonlindsey@users.noreply.github.com> Date: Fri, 8 May 2020 14:19:24 -0500 Subject: [PATCH 3/3] feat: add SAML signed response with assertion - formatting redos. --- lib/saml20.js | 178 +++++++++++++++++++++++++----------- lib/saml20Response.template | 4 +- test/saml20.tests.js | 4 +- test/utils.js | 4 +- 4 files changed, 133 insertions(+), 57 deletions(-) diff --git a/lib/saml20.js b/lib/saml20.js index d03c1351..2ada2a7d 100644 --- a/lib/saml20.js +++ b/lib/saml20.js @@ -55,32 +55,59 @@ function getNameFormat(name){ return 'urn:oasis:names:tc:SAML:2.0:attrname-format:unspecified'; } -exports.create = function(options, callback) { - if (!options.key) - throw new Error('Expect a private key in pem format'); +/** +* Gets the complere SAML Response merged with assertion (encrypted optional) and uses the +* saml20 argument options to set parts of the response utilizing the saml20Response.template file. +* @param assertion - the SAML assertion to add to the SAML response. +* @param options - The saml20 class options argument. +*/ +function getSamlResponseXml(assertion, options) { + var issueTime = new Date().toISOString(); - if (!options.cert) - throw new Error('Expect a public key cert in pem format'); + var assertionXml = new Parser().parseFromString(assertion); + var saml20Response = fs.readFileSync(path.join(__dirname, 'saml20Response.template')).toString(); - options.signatureAlgorithm = options.signatureAlgorithm || 'rsa-sha256'; - options.digestAlgorithm = options.digestAlgorithm || 'sha256'; + var doc = new Parser().parseFromString(saml20Response.toString()); - options.includeAttributeNameFormat = (typeof options.includeAttributeNameFormat !== 'undefined') ? options.includeAttributeNameFormat : true; - options.typedAttributes = (typeof options.typedAttributes !== 'undefined') ? options.typedAttributes : true; + doc.documentElement.setAttribute('ID', '_' + (options.uid || utils.uid(32))); + doc.documentElement.setAttribute('IssueInstant', moment.utc().format('YYYY-MM-DDTHH:mm:ss.SSS[Z]')); + doc.documentElement.setAttribute('Destination', options.destination); + if (options.issuer) { + var issuer = doc.documentElement.getElementsByTagName('saml:Issuer'); + issuer[0].textContent = options.issuer; + } + doc.lastChild.appendChild(assertionXml.documentElement); + + return doc.toString(); +} +/** +* Signs the SAML XML at the Assertion level (default) or the Response Level (optional) using private key and cert. +* @param xmlToSign - The XML in string form containing the XML assertion or response. +* @param options - The saml20 class options argument. +*/ +function signXml(xmlToSign, options) { // 0.10.1 added prefix, but we want to name it signatureNamespacePrefix - This is just to keep supporting prefix options.signatureNamespacePrefix = options.signatureNamespacePrefix || options.prefix; options.signatureNamespacePrefix = typeof options.signatureNamespacePrefix === 'string' ? options.signatureNamespacePrefix : '' ; var cert = utils.pemToCert(options.cert); - var sig = new SignedXml(null, { signatureAlgorithm: algorithms.signature[options.signatureAlgorithm], idAttribute: 'ID' }); - sig.addReference("//*[local-name(.)='Assertion']", - ["http://www.w3.org/2000/09/xmldsig#enveloped-signature", "http://www.w3.org/2001/10/xml-exc-c14n#"], - algorithms.digest[options.digestAlgorithm]); + var signingLocation = options.createSignedSamlResponse ? 'Response' : 'Assertion'; + sig.addReference("//*[local-name(.)='" + signingLocation + "']", + ["http://www.w3.org/2000/09/xmldsig#enveloped-signature", "http://www.w3.org/2001/10/xml-exc-c14n#"], + algorithms.digest[options.digestAlgorithm]); sig.signingKey = options.key; - + + var opts = { + location: { + reference: options.xpathToNodeBeforeSignature || "//*[local-name(.)='Issuer']", + action: 'after' + }, + prefix: options.signatureNamespacePrefix + }; + sig.keyInfoProvider = { getKeyInfo: function (key, prefix) { prefix = prefix ? prefix + ':' : prefix; @@ -88,6 +115,49 @@ exports.create = function(options, callback) { } }; + sig.computeSignature(xmlToSign, opts); + + return sig.getSignedXml(); +} + +/** +* Encrypts s SAML assertion and formats with EncryptedAssertion wrapper using provided cert. +* @param assertionToEncrypt - The SAML assertion to encrypt. +* @param options - The saml20 class options argument. +* @param callback - The callback function for ASYNC processing completion. +*/ +function encryptAssertionXml(assertionToEncrypt, options, callback) { + var encryptOptions = { + rsa_pub: options.encryptionPublicKey, + pem: options.encryptionCert, + encryptionAlgorithm: options.encryptionAlgorithm || 'http://www.w3.org/2001/04/xmlenc#aes256-cbc', + keyEncryptionAlgorighm: options.keyEncryptionAlgorighm || 'http://www.w3.org/2001/04/xmlenc#rsa-oaep-mgf1p' + }; + + xmlenc.encrypt(assertionToEncrypt, encryptOptions, function (err, encrypted) { + if (err) return callback(err); + var assertion = '' + encrypted + ''; + return callback(null, assertion); + }) +} + +exports.create = function (options, callback) { + if (!options.key) + throw new Error('Expect a private key in pem format'); + + if (!options.cert) + throw new Error('Expect a public key cert in pem format'); + + if (options.createSignedSamlResponse && + (!options.destination || options.destination.length < 1)) + throw new Error('Expect a SAML Response destination for message to be valid.') + + options.signatureAlgorithm = options.signatureAlgorithm || 'rsa-sha256'; + options.digestAlgorithm = options.digestAlgorithm || 'sha256'; + + options.includeAttributeNameFormat = (typeof options.includeAttributeNameFormat !== 'undefined') ? options.includeAttributeNameFormat : true; + options.typedAttributes = (typeof options.typedAttributes !== 'undefined') ? options.typedAttributes : true; + var doc; try { doc = new Parser().parseFromString(saml20.toString()); @@ -184,48 +254,54 @@ exports.create = function(options, callback) { if (options.nameIdentifierFormat) { nameID.setAttribute('Format', options.nameIdentifierFormat); } - + if( options.authnContextClassRef ) { var authnCtxClassRef = doc.getElementsByTagName('saml:AuthnContextClassRef')[0]; authnCtxClassRef.textContent = options.authnContextClassRef; } - var token = utils.removeWhitespace(doc.toString()); - var signed; - try { - var opts = { - location: { - reference: options.xpathToNodeBeforeSignature || "//*[local-name(.)='Issuer']", - action: 'after' - }, - prefix: options.signatureNamespacePrefix - }; - - sig.computeSignature(token, opts); - signed = sig.getSignedXml(); - } catch(err){ - return utils.reportError(err, callback); + var assertion = utils.removeWhitespace(doc.toString()); + + // NEW: Option: build a complete signed SAML response with embedded (option encrypted) assertion + if (options.createSignedSamlResponse) { + try { + // IF SAML response assertion is set to be encrypted + if (options.encryptionCert) { + encryptAssertionXml(assertion, options, function (err, encryptedAssertion) { + if (err) return callback(err); + var signedResponse = signSamlResponse(encryptedAssertion); + return callback(null, signedResponse); + }); + } else { + // Do not encrypt assertion and send back + var signedPlainResponse = signSamlResponse(assertion); + return (callback) ? callback(null, signedPlainResponse) : signedPlainResponse; + } + } catch (err) { + return (callback) ? callback(err) : err; + } + } else { + try { + // Sign the assertion always for both options + var signedAssertion = signXml(utils.removeWhitespace(assertion), options); + if (options.encryptionCert) { + // If assertion is set to be encrypted + encryptAssertionXml(signedAssertion, options, function (err, encryptedAssertion) { + if (err) return callback(err); + return callback(null, encryptedAssertion) + }); + } else { + // If assertion encryption not set just send back + return (callback) ? callback(null, signedAssertion) : signedAssertion; + } + } catch (err) { + return (callback) ? callback(err) : err; + } } - if (!options.encryptionCert) { - if (callback) - return callback(null, signed); - else - return signed; + // Generates response with inserted assertion (or encrypted assertion) and signs + function signSamlResponse(assertion) { + var samlResponse = getSamlResponseXml(assertion, options); + return signXml(utils.removeWhitespace(samlResponse), options); } - - var encryptOptions = { - rsa_pub: options.encryptionPublicKey, - pem: options.encryptionCert, - encryptionAlgorithm: options.encryptionAlgorithm || 'http://www.w3.org/2001/04/xmlenc#aes256-cbc', - keyEncryptionAlgorighm: options.keyEncryptionAlgorighm || 'http://www.w3.org/2001/04/xmlenc#rsa-oaep-mgf1p' - }; - - xmlenc.encrypt(signed, encryptOptions, function(err, encrypted) { - if (err) return callback(err); - - encrypted = '' + encrypted + ''; - callback(null, utils.removeWhitespace(encrypted)); - }); -}; - +}; diff --git a/lib/saml20Response.template b/lib/saml20Response.template index 49f2db2f..837b62a7 100644 --- a/lib/saml20Response.template +++ b/lib/saml20Response.template @@ -1,6 +1,6 @@ - + - \ No newline at end of file + diff --git a/test/saml20.tests.js b/test/saml20.tests.js index 0befa001..c90244a3 100644 --- a/test/saml20.tests.js +++ b/test/saml20.tests.js @@ -484,7 +484,7 @@ describe('saml 2.0', function () { assert.equal(attributeStatement.length, 0); }); - describe('saml 2.0 test full SAML response', function () { + describe('saml 2.0 full SAML response', function () { it('should create a saml 2.0 signed response including plain assertion', function (done) { var options = { @@ -713,4 +713,4 @@ describe('saml 2.0', function () { }); }); }); -}); +}); \ No newline at end of file diff --git a/test/utils.js b/test/utils.js index 8780e049..009cc067 100644 --- a/test/utils.js +++ b/test/utils.js @@ -97,7 +97,7 @@ exports.getEncryptedData = function(encryptedAssertion) { .getElementsByTagName('xenc:EncryptedData')[0]; }; -exports.getResponseData = function(samlResponse) { - var doc = new xmldom.DOMParser().parseFromString(samlResponse); +exports.getResponseData = function(assertion) { + var doc = new xmldom.DOMParser().parseFromString(assertion); return doc.getElementsByTagName('samlp:Response')[0]; };