diff --git a/index.d.ts b/index.d.ts new file mode 100644 index 00000000..4e0c25b4 --- /dev/null +++ b/index.d.ts @@ -0,0 +1,57 @@ +/** + * Interface for lib/saml (Banno/node-saml). It differs from the original + * designed for auth0/node-saml to include the enhancements for supporting + * a full SAML 2.0 Response in all signing modes. + */ + declare module '@banno/saml' { + export interface SamlAttributes { + [key: string]: string; + } + + export interface KeyInfoProvider { + getKeyInfo(key: string, prefix: string): string; + } + + export interface SamlOpts { + authnContextClassRef?: string; + attributes?: SamlAttributes; + audiences?: string | string[]; + cert: Buffer; + digestAlgorithm?: string; + encryptionAlgorithm?: string; + encryptionCert?: Buffer; + encryptionPublicKey?: Buffer; + holderOfKeyProofSecret?: string; + includeAttributeNameFormat?: boolean; + inResponseTo?: string; + issuer?: string; + key: Buffer; + keyEncryptionAlgorighm?: string; // sic https://github.com/auth0/node-xml-encryption/issues/17 + keyInfoProvider?: KeyInfoProvider; + lifetimeInSeconds?: number; + nameIdentifier?: string; + nameIdentifierFormat?: string; + prefix?: string; + recipient?: string; + sessionIndex?: string; + signatureAlgorithm?: string; + signatureNamespacePrefix?: string; + subjectConfirmationMethod?: string; + typedAttributes?: boolean; + uid?: string; + responseUid?: string; + xpathToNodeBeforeSignature?: string; + createSignedSamlResponse?: boolean; + responseSigningLevel?: string; + destination?: string; + } + + export namespace Saml11 { + function create(opts: SamlOpts, cb?: (err: Error | null, result: any[], proofSecret: Buffer) => void): any; + } + + export namespace Saml20 { + function create(opts: SamlOpts, cb?: (err: Error | null, signed: string) => void): any; + } + } + \ No newline at end of file diff --git a/lib/saml20.js b/lib/saml20.js index 21c1f278..e59c252a 100644 --- a/lib/saml20.js +++ b/lib/saml20.js @@ -10,6 +10,7 @@ var utils = require('./utils'), var fs = require('fs'); var path = require('path'); var saml20 = fs.readFileSync(path.join(__dirname, 'saml20.template')).toString(); +var documentSigningLocation = 'Assertion'; var NAMESPACE = 'urn:oasis:names:tc:SAML:2.0:assertion'; @@ -24,6 +25,11 @@ var algorithms = { } }; +var ResponseSigningLevel = { + ResponseOnly: 'ResponseOnly', + AssertionAndResponse: 'AssertionAndResponse' +} + function getAttributeType(value){ switch(typeof value) { case "string": @@ -44,8 +50,8 @@ function getNameFormat(name){ } // 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){ return 'urn:oasis:names:tc:SAML:2.0:attrname-format:basic'; @@ -55,32 +61,72 @@ 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 complete SAML20 response embedding the assertion (encrypted optional) and +* options argument to set attributes for response utilizing the saml20Response.template file. +* @param assertion - the SAML assertion to add to the SAML response. +* @param options - The saml20 class options argument. +* @returns string - SAML20 Full Response with embedded assertion XML. +* @throws assertion argument null or empty error. +*/ +function getSamlResponseXml(assertion, options) { - if (!options.cert) - throw new Error('Expect a public key cert in pem format'); + var issueTime = new Date().toISOString(); - options.signatureAlgorithm = options.signatureAlgorithm || 'rsa-sha256'; - options.digestAlgorithm = options.digestAlgorithm || 'sha256'; + if (!assertion || assertion.length < 1) + throw new ReferenceError('Assertion XML cannot be empty for parsing while creating SAML20 Response.') - options.includeAttributeNameFormat = (typeof options.includeAttributeNameFormat !== 'undefined') ? options.includeAttributeNameFormat : true; - options.typedAttributes = (typeof options.typedAttributes !== 'undefined') ? options.typedAttributes : true; + var assertionXml = new Parser().parseFromString(assertion); + var saml20Response = fs.readFileSync(path.join(__dirname, 'saml20Response.template')).toString(); + + var doc = new Parser().parseFromString(saml20Response.toString()); + + doc.documentElement.setAttribute('ID', '_' + (options.responseUid)); + 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. +* @returns string - Signed SAML assertion or response depending on option. +* @throws ReferenceError if xml argument sent for signing is null or empty. +*/ +function signXml(xmlToSign, options, documentSigningLocation) { + + if (!xmlToSign || xmlToSign.length < 1) + throw new ReferenceError('XML to sign cannot be null or empty.') // 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 = documentSigningLocation || '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 +134,57 @@ 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. +* @returns Promise (resolve, reject) for the embedded ASYNC encrypt function callback wrapper. +*/ +var encryptAssertionXml = (assertionToEncrypt, options) => + new Promise((resolve, reject) => { + 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, encryptedAssertion) { + if (err) reject(err); + encryptedAssertion = `${encryptedAssertion}`; + + resolve(encryptedAssertion); + }); +}); + +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) { + + if (!options.destination || options.destination.length < 1) { + throw new Error('Expect a SAML Response destination for message to be valid.'); + } + + options.responseSigningLevel = options.responseSigningLevel || ResponseSigningLevel.ResponseOnly; + options.responseUid = options.responseUid || utils.uid(32); + } + + 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()); @@ -109,10 +206,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 +219,7 @@ exports.create = function(options, callback) { audienceRestriction.appendChild(element); }); - conditions[0].appendChild(audienceRestriction); + conditions[0].appendChild(audienceRestriction); } if (options.recipient) @@ -145,7 +242,7 @@ exports.create = function(options, callback) { attributeElement.setAttribute('Name', prop); if (options.includeAttributeNameFormat){ - attributeElement.setAttribute('NameFormat', getNameFormat(prop)); + attributeElement.setAttribute('NameFormat', getNameFormat(prop)); } var values = options.attributes[prop] instanceof Array ? options.attributes[prop] : [options.attributes[prop]]; @@ -176,7 +273,7 @@ exports.create = function(options, callback) { } var nameID = doc.documentElement.getElementsByTagNameNS(NAMESPACE, 'NameID')[0]; - + if (options.nameIdentifier) { nameID.textContent = options.nameIdentifier; } @@ -184,48 +281,47 @@ 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()); + + // Enhanced path - construct a SAML20 response signed at the response + // with embedded (encrypted option) (signed option) assertion + if (options.createSignedSamlResponse) { + if (options.responseSigningLevel === ResponseSigningLevel.AssertionAndResponse) { + assertion = signXml(utils.removeWhitespace(assertion), options, 'Assertion'); + } + + if (options.encryptionCert) { + encryptAssertionXml(assertion, options) + .then(encryptedAssertion => (callback(null, signSamlResponse(encryptedAssertion)))) + .catch((err) => callback(err)); + } else { + var signedPlainResponse = signSamlResponse(assertion); + return (callback) ? callback(null, signedPlainResponse) : signedPlainResponse; + } + // Original path - create a simple signed (encrypted option) assertion. + } else { + var signedAssertion = signXml(utils.removeWhitespace(assertion), options, 'Assertion'); + + if (options.encryptionCert) { + encryptAssertionXml(signedAssertion, options) + .then(encryptedAssertion => callback(null, encryptedAssertion)) + .catch((err) => callback(err)); + } else { + // Send back signed if not set for encryption + return (callback) ? callback(null, signedAssertion) : signedAssertion; + } } - if (!options.encryptionCert) { - if (callback) - return callback(null, signed); - else - return signed; + // Sign response with inserted assertion (or encrypted assertion) + function signSamlResponse(assertion) { + var samlResponse = getSamlResponseXml(assertion, options); + return signXml(utils.removeWhitespace(samlResponse), options, 'Response'); } - - 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/package-lock.json b/package-lock.json new file mode 100644 index 00000000..b6aba431 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,379 @@ +{ + "name": "saml", + "version": "0.17.0", + "lockfileVersion": 1, + "requires": true, + "dependencies": { + "async": { + "version": "0.2.10", + "resolved": "https://registry.npmjs.org/async/-/async-0.2.10.tgz", + "integrity": "sha1-trvgsGdLnXGXCMo43owjfLUmw9E=" + }, + "balanced-match": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz", + "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=", + "dev": true + }, + "brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "requires": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "browser-stdout": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/browser-stdout/-/browser-stdout-1.3.0.tgz", + "integrity": "sha1-81HTKWnTL6XXpVZxVCY9korjvR8=", + "dev": true + }, + "commander": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.9.0.tgz", + "integrity": "sha1-nJkJQXbhIkDLItbFFGCYQA/g99Q=", + "dev": true, + "requires": { + "graceful-readlink": ">= 1.0.0" + } + }, + "concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=", + "dev": true + }, + "debug": { + "version": "2.6.8", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.8.tgz", + "integrity": "sha1-5zFTHKLt4n0YgiJCfaF4IdaP9Pw=", + "dev": true, + "requires": { + "ms": "2.0.0" + } + }, + "diff": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-3.2.0.tgz", + "integrity": "sha1-yc45Okt8vQsFinJck98pkCeGj/k=", + "dev": true + }, + "ejs": { + "version": "2.7.4", + "resolved": "https://registry.npmjs.org/ejs/-/ejs-2.7.4.tgz", + "integrity": "sha512-7vmuyh5+kuUyJKePhQfRQBhXV5Ce+RnaeeQArKu1EAMpL3WbgMt5WG6uQZpEVvYSSsxMXRKOewtDk9RaTKXRlA==" + }, + "escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=", + "dev": true + }, + "fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=", + "dev": true + }, + "glob": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.1.tgz", + "integrity": "sha1-gFIR3wT6rxxjo2ADBs31reULLsg=", + "dev": true, + "requires": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.2", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + } + }, + "graceful-readlink": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/graceful-readlink/-/graceful-readlink-1.0.1.tgz", + "integrity": "sha1-TK+tdrxi8C+gObL5Tpo906ORpyU=", + "dev": true + }, + "growl": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/growl/-/growl-1.9.2.tgz", + "integrity": "sha1-Dqd0NxXbjY3ixe3hd14bRayFwC8=", + "dev": true + }, + "has-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-1.0.0.tgz", + "integrity": "sha1-nZ55MWXOAXoA8AQYxD+UKnsdEfo=", + "dev": true + }, + "he": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/he/-/he-1.1.1.tgz", + "integrity": "sha1-k0EP0hsAlzUVH4howvJx80J+I/0=", + "dev": true + }, + "inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=", + "dev": true, + "requires": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true + }, + "json3": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/json3/-/json3-3.3.2.tgz", + "integrity": "sha1-PAQ0dD35Pi9cQq7nsZvLSDV19OE=", + "dev": true + }, + "lodash": { + "version": "4.17.15", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.15.tgz", + "integrity": "sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A==" + }, + "lodash._baseassign": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/lodash._baseassign/-/lodash._baseassign-3.2.0.tgz", + "integrity": "sha1-jDigmVAPIVrQnlnxci/QxSv+Ck4=", + "dev": true, + "requires": { + "lodash._basecopy": "^3.0.0", + "lodash.keys": "^3.0.0" + } + }, + "lodash._basecopy": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/lodash._basecopy/-/lodash._basecopy-3.0.1.tgz", + "integrity": "sha1-jaDmqHbPNEwK2KVIghEd08XHyjY=", + "dev": true + }, + "lodash._basecreate": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash._basecreate/-/lodash._basecreate-3.0.3.tgz", + "integrity": "sha1-G8ZhYU2qf8MRt9A78WgGoCE8+CE=", + "dev": true + }, + "lodash._getnative": { + "version": "3.9.1", + "resolved": "https://registry.npmjs.org/lodash._getnative/-/lodash._getnative-3.9.1.tgz", + "integrity": "sha1-VwvH3t5G1hzc3mh9ZdPuy6o6r/U=", + "dev": true + }, + "lodash._isiterateecall": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/lodash._isiterateecall/-/lodash._isiterateecall-3.0.9.tgz", + "integrity": "sha1-UgOte6Ql+uhCRg5pbbnPPmqsBXw=", + "dev": true + }, + "lodash.create": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/lodash.create/-/lodash.create-3.1.1.tgz", + "integrity": "sha1-1/KEnw29p+BGgruM1yqwIkYd6+c=", + "dev": true, + "requires": { + "lodash._baseassign": "^3.0.0", + "lodash._basecreate": "^3.0.0", + "lodash._isiterateecall": "^3.0.0" + } + }, + "lodash.isarguments": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz", + "integrity": "sha1-L1c9hcaiQon/AGY7SRwdM4/zRYo=", + "dev": true + }, + "lodash.isarray": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/lodash.isarray/-/lodash.isarray-3.0.4.tgz", + "integrity": "sha1-eeTriMNqgSKvhvhEqpvNhRtfu1U=", + "dev": true + }, + "lodash.keys": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/lodash.keys/-/lodash.keys-3.1.2.tgz", + "integrity": "sha1-TbwEcrFWvlCgsoaFXRvQsMZWCYo=", + "dev": true, + "requires": { + "lodash._getnative": "^3.0.0", + "lodash.isarguments": "^3.0.0", + "lodash.isarray": "^3.0.0" + } + }, + "minimatch": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz", + "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==", + "dev": true, + "requires": { + "brace-expansion": "^1.1.7" + } + }, + "minimist": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz", + "integrity": "sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0=", + "dev": true + }, + "mkdirp": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz", + "integrity": "sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=", + "dev": true, + "requires": { + "minimist": "0.0.8" + } + }, + "mocha": { + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/mocha/-/mocha-3.5.3.tgz", + "integrity": "sha512-/6na001MJWEtYxHOV1WLfsmR4YIynkUEhBwzsb+fk2qmQ3iqsi258l/Q2MWHJMImAcNpZ8DEdYAK72NHoIQ9Eg==", + "dev": true, + "requires": { + "browser-stdout": "1.3.0", + "commander": "2.9.0", + "debug": "2.6.8", + "diff": "3.2.0", + "escape-string-regexp": "1.0.5", + "glob": "7.1.1", + "growl": "1.9.2", + "he": "1.1.1", + "json3": "3.3.2", + "lodash.create": "3.1.1", + "mkdirp": "0.5.1", + "supports-color": "3.1.2" + } + }, + "moment": { + "version": "2.19.3", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.19.3.tgz", + "integrity": "sha1-vbmdJw1tf9p4zA+6zoVeJ/59pp8=" + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=", + "dev": true + }, + "node-forge": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-0.7.6.tgz", + "integrity": "sha512-sol30LUpz1jQFBjOKwbjxijiE3b6pjd74YwfD0fJOKPjF+fONKb2Yg8rYgS6+bK6VDl+/wfr4IYpC7jDzLUIfw==" + }, + "once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", + "dev": true, + "requires": { + "wrappy": "1" + } + }, + "path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=", + "dev": true + }, + "should": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/should/-/should-1.2.2.tgz", + "integrity": "sha1-DwP3dQZtnqJjJpDJF7EoJPzB1YI=", + "dev": true + }, + "supports-color": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-3.1.2.tgz", + "integrity": "sha1-cqJiiU2dQIuVbKBf83su2KbiotU=", + "dev": true, + "requires": { + "has-flag": "^1.0.0" + } + }, + "valid-url": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/valid-url/-/valid-url-1.0.9.tgz", + "integrity": "sha1-HBRHm0DxOXp1eC8RXkCGRHQzogA=" + }, + "wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=", + "dev": true + }, + "xml-crypto": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/xml-crypto/-/xml-crypto-1.0.2.tgz", + "integrity": "sha512-bDQkgu1yuwl+QoJbi4GBP9MWxpmYkXc8a9iSHbZ7lKqcxzGlDqMRugcl7qK7TsMI0ydU66GG8/eLNvRUk5T2fw==", + "requires": { + "xmldom": "0.1.27", + "xpath.js": ">=0.0.3" + }, + "dependencies": { + "xmldom": { + "version": "0.1.27", + "resolved": "https://registry.npmjs.org/xmldom/-/xmldom-0.1.27.tgz", + "integrity": "sha1-1QH5ezvbQDr4757MIFcxh6rawOk=" + } + } + }, + "xml-encryption": { + "version": "0.11.2", + "resolved": "https://registry.npmjs.org/xml-encryption/-/xml-encryption-0.11.2.tgz", + "integrity": "sha512-jVvES7i5ovdO7N+NjgncA326xYKjhqeAnnvIgRnY7ROLCfFqEDLwP0Sxp/30SHG0AXQV1048T5yinOFyvwGFzg==", + "requires": { + "async": "^2.1.5", + "ejs": "^2.5.6", + "node-forge": "^0.7.0", + "xmldom": "~0.1.15", + "xpath": "0.0.27" + }, + "dependencies": { + "async": { + "version": "2.6.3", + "resolved": "https://registry.npmjs.org/async/-/async-2.6.3.tgz", + "integrity": "sha512-zflvls11DCy+dQWzTW2dzuilv8Z5X/pjfmZOWba6TNIVDm+2UDaJmXSOXlasHKfNBs8oo3M0aT50fDEWfKZjXg==", + "requires": { + "lodash": "^4.17.14" + } + }, + "xpath": { + "version": "0.0.27", + "resolved": "https://registry.npmjs.org/xpath/-/xpath-0.0.27.tgz", + "integrity": "sha512-fg03WRxtkCV6ohClePNAECYsmpKKTv5L8y/X3Dn1hQrec3POx2jHZ/0P2qQ6HvsrU1BmeqXcof3NGGueG6LxwQ==" + } + } + }, + "xml-name-validator": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-2.0.1.tgz", + "integrity": "sha1-TYuPHszTQZqjYgYb7O9RXh5VljU=" + }, + "xmldom": { + "version": "0.1.15", + "resolved": "https://registry.npmjs.org/xmldom/-/xmldom-0.1.15.tgz", + "integrity": "sha1-swSAYvG91S7cQhQkRZ8G3O6y+U0=" + }, + "xpath": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/xpath/-/xpath-0.0.5.tgz", + "integrity": "sha1-RUA29u8PPfWvXUukoRn7dWdLPmw=" + }, + "xpath.js": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/xpath.js/-/xpath.js-1.1.0.tgz", + "integrity": "sha512-jg+qkfS4K8E7965sqaUl8mRngXiKb3WZGfONgE18pr03FUQiuSV6G+Ej4tS55B+rIQSFEIw3phdVAQ4pPqNWfQ==" + } + } +} diff --git a/package.json b/package.json index 83c47b14..6e4b5582 100644 --- a/package.json +++ b/package.json @@ -1,12 +1,12 @@ { - "name": "saml", - "version": "0.14.0", + "name": "@banno/saml", + "version": "0.17.0", "devDependencies": { "mocha": "3.5.3", "should": "~1.2.1" }, "main": "./lib", - "repository": "https://github.com/auth0/node-saml", + "repository": "https://github.com/banno/node-saml", "keywords": [ "saml", "authentication" diff --git a/test/saml20.tests.js b/test/saml20.tests.js index e351cfa3..a727d075 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,162 @@ describe('saml 2.0', function () { }); }); }); - - }); -}); + describe('full signed SAML 2.0 response with encrypted assertion', 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(); + }); + }); + }); + }); + + describe('full signed SAML 2.0 response with encryped signed assertion', function () { + + it('should create a saml 2.0 signed response including encrypted signed 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, + responseSigningLevel: 'AssertionAndResponse', + destination: 'https:/foo.com' + }; + var isValid = false; + saml.create(options, function(err, responseData) { + if (err) return done(err); + + // Response Signature + isValid = utils.isValidResponseSignature(responseData, options.cert); + assert.equal(true, isValid); + + // Assertion Signature + var isValid = utils.isValidSignature(responseData, options.cert); + assert.equal(true, isValid); + + var encryptedData = utils.getEncryptedData(responseData); + + 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, + responseSigningLevel: 'AssertionAndResponse', + 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, responseData) { + if (err) return done(err); + + // Response Signature + isValid = utils.isValidResponseSignature(responseData, options.cert); + assert.equal(true, isValid); + + // Assertion Signature + var isValid = utils.isValidSignature(responseData, options.cert); + assert.equal(true, isValid); + + var encryptedData = utils.getEncryptedData(responseData); + + 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(); + }); + }); + }); + }); + + }); +}); \ No newline at end of file diff --git a/test/utils.js b/test/utils.js index 4d7e0426..15dfb0ce 100644 --- a/test/utils.js +++ b/test/utils.js @@ -18,6 +18,24 @@ exports.isValidSignature = function(assertion, cert) { return sig.checkSignature(assertion); }; +exports.isValidResponseSignature = function(response, cert) { + var doc = new xmldom.DOMParser().parseFromString(response); + var signature = doc.documentElement + .getElementsByTagName('Signature')[0] + + var sig = new xmlCrypto.SignedXml(null, { idAttribute: 'ResponseID' }); + sig.keyInfoProvider = { + getKeyInfo: function (key) { + return ""; + }, + getKey: function (keyInfo) { + return cert; + } + }; + sig.loadSignature(signature.toString()); + return sig.checkSignature(response); +}; + exports.getIssuer = function(assertion) { var doc = new xmldom.DOMParser().parseFromString(assertion); return doc.documentElement.getAttribute('Issuer'); @@ -94,5 +112,10 @@ 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]; };