diff --git a/README.md b/README.md index 74589396..d97e90a4 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,20 @@ Create SAML assertions. -NOTE: currently supports SAML 1.1 tokens +NOTE: currently supports SAML 1.1 and SAML 2.0 tokens [![Build Status](https://travis-ci.org/auth0/node-saml.png)](https://travis-ci.org/auth0/node-saml) ### Usage ```js -var saml11 = require('saml').Saml11; var options = { + // Required cert: fs.readFileSync(__dirname + '/test-auth0.pem'), key: fs.readFileSync(__dirname + '/test-auth0.key'), + // Optional issuer: 'urn:issuer', + issueInstantSkewInSeconds: 60, lifetimeInSeconds: 600, audiences: 'urn:myapp', attributes: { @@ -23,10 +25,15 @@ var options = { sessionIndex: '_faed468a-15a0-4668-aed6-3d9c478cc8fa' }; +// SAML 1.1 +var saml11 = require('saml').Saml11; var signedAssertion = saml11.create(options); -``` -Everything except the cert and key is optional. +// SAML 2.0 +var saml20 = require('saml').Saml20; +var signedAssertion = saml20.create(options); + +``` ## Issue Reporting diff --git a/lib/saml11.js b/lib/saml11.js index f359aa54..889a9603 100644 --- a/lib/saml11.js +++ b/lib/saml11.js @@ -12,6 +12,7 @@ var path = require('path'); var saml11 = fs.readFileSync(path.join(__dirname, 'saml11.template')).toString(); var NAMESPACE = 'urn:oasis:names:tc:SAML:1.0:assertion'; +var TIME_FORMAT = 'YYYY-MM-DDTHH:mm:ss.SSS[Z]'; var algorithms = { signature: { @@ -61,12 +62,18 @@ exports.create = function(options, callback) { doc.documentElement.setAttribute('Issuer', options.issuer); var now = moment.utc(); - doc.documentElement.setAttribute('IssueInstant', now.format('YYYY-MM-DDTHH:mm:ss.SSS[Z]')); + + // Optionally, back up the issue instant to accommodate for clock skew in the assertion consumer + if (!isNaN(options.issueInstantSkewInSeconds)) { + now.subtract(options.issueInstantSkewInSeconds, 'seconds'); + } + + doc.documentElement.setAttribute('IssueInstant', now.format(TIME_FORMAT)); var conditions = doc.documentElement.getElementsByTagName('saml:Conditions'); if (options.lifetimeInSeconds) { - conditions[0].setAttribute('NotBefore', now.format('YYYY-MM-DDTHH:mm:ss.SSS[Z]')); - conditions[0].setAttribute('NotOnOrAfter', now.add(options.lifetimeInSeconds, 'seconds').format('YYYY-MM-DDTHH:mm:ss.SSS[Z]')); + conditions[0].setAttribute('NotBefore', now.format(TIME_FORMAT)); + conditions[0].setAttribute('NotOnOrAfter', now.clone().add(options.lifetimeInSeconds, 'seconds').format(TIME_FORMAT)); } if (options.audiences) { @@ -107,7 +114,7 @@ exports.create = function(options, callback) { } doc.getElementsByTagName('saml:AuthenticationStatement')[0] - .setAttribute('AuthenticationInstant', now.format('YYYY-MM-DDTHH:mm:ss.SSS[Z]')); + .setAttribute('AuthenticationInstant', now.format(TIME_FORMAT)); var nameID = doc.documentElement.getElementsByTagNameNS(NAMESPACE, 'NameIdentifier')[0]; diff --git a/lib/saml20.js b/lib/saml20.js index 21c1f278..714f0439 100644 --- a/lib/saml20.js +++ b/lib/saml20.js @@ -12,6 +12,7 @@ var path = require('path'); var saml20 = fs.readFileSync(path.join(__dirname, 'saml20.template')).toString(); var NAMESPACE = 'urn:oasis:names:tc:SAML:2.0:assertion'; +var TIME_FORMAT = 'YYYY-MM-DDTHH:mm:ss.SSS[Z]'; var algorithms = { signature: { @@ -102,15 +103,21 @@ exports.create = function(options, callback) { } var now = moment.utc(); - doc.documentElement.setAttribute('IssueInstant', now.format('YYYY-MM-DDTHH:mm:ss.SSS[Z]')); + + // Optionally, back up the issue instant to accommodate for clock skew in the assertion consumer + if (!isNaN(options.issueInstantSkewInSeconds)) { + now.subtract(options.issueInstantSkewInSeconds, 'seconds'); + } + + doc.documentElement.setAttribute('IssueInstant', now.format(TIME_FORMAT)); var conditions = doc.documentElement.getElementsByTagName('saml:Conditions'); var confirmationData = doc.documentElement.getElementsByTagName('saml:SubjectConfirmationData'); 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]')); + conditions[0].setAttribute('NotBefore', now.format(TIME_FORMAT)); + conditions[0].setAttribute('NotOnOrAfter', now.clone().add(options.lifetimeInSeconds, 'seconds').format(TIME_FORMAT)); - 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(TIME_FORMAT)); } if (options.audiences) { @@ -168,7 +175,7 @@ exports.create = function(options, callback) { } doc.getElementsByTagName('saml:AuthnStatement')[0] - .setAttribute('AuthnInstant', now.format('YYYY-MM-DDTHH:mm:ss.SSS[Z]')); + .setAttribute('AuthnInstant', now.format(TIME_FORMAT)); if (options.sessionIndex) { doc.getElementsByTagName('saml:AuthnStatement')[0] diff --git a/test/saml11.tests.js b/test/saml11.tests.js index d924c278..193f2229 100644 --- a/test/saml11.tests.js +++ b/test/saml11.tests.js @@ -81,6 +81,34 @@ describe('saml 1.1', function () { assert.equal(600, lifetime); }); + it('should skew the issue instant if requested', function () { + + var options = { + cert: fs.readFileSync(__dirname + '/test-auth0.pem'), + key: fs.readFileSync(__dirname + '/test-auth0.key'), + lifetimeInSeconds: 600, + issueInstantSkewInSeconds: 60, + }; + + var signedAssertion = saml11.create(options); + var isValid = utils.isValidSignature(signedAssertion, options.cert); + assert.equal(true, isValid); + + var conditions = utils.getConditions(signedAssertion); + assert.equal(1, conditions.length); + var notBefore = conditions[0].getAttribute('NotBefore'); + var notOnOrAfter = conditions[0].getAttribute('NotOnOrAfter'); + + should.ok(notBefore); + should.ok(notOnOrAfter); + + var skew = Math.round((moment.utc() - moment(notBefore).utc()) / 1000); + assert.equal(60, skew); + + var lifetime = Math.round((moment(notOnOrAfter).utc() - moment(notBefore).utc()) / 1000); + assert.equal(600, lifetime); + }); + it('should set audience restriction', function () { var options = { cert: fs.readFileSync(__dirname + '/test-auth0.pem'), diff --git a/test/saml20.tests.js b/test/saml20.tests.js index e351cfa3..406806bd 100644 --- a/test/saml20.tests.js +++ b/test/saml20.tests.js @@ -340,6 +340,34 @@ describe('saml 2.0', function () { assert.equal('specific', authnContextClassRef.textContent); }); + it('should skew the issue instant if requested', function () { + + var options = { + cert: fs.readFileSync(__dirname + '/test-auth0.pem'), + key: fs.readFileSync(__dirname + '/test-auth0.key'), + lifetimeInSeconds: 600, + issueInstantSkewInSeconds: 60, + }; + + var signedAssertion = saml.create(options); + var isValid = utils.isValidSignature(signedAssertion, options.cert); + assert.equal(true, isValid); + + var conditions = utils.getConditions(signedAssertion); + assert.equal(1, conditions.length); + var notBefore = conditions[0].getAttribute('NotBefore'); + var notOnOrAfter = conditions[0].getAttribute('NotOnOrAfter'); + + should.ok(notBefore); + should.ok(notOnOrAfter); + + var skew = Math.round((moment.utc() - moment(notBefore).utc()) / 1000); + assert.equal(60, skew); + + var lifetime = Math.round((moment(notOnOrAfter).utc() - moment(notBefore).utc()) / 1000); + assert.equal(600, lifetime); + }); + it('should place signature where specified', function () { var options = { cert: fs.readFileSync(__dirname + '/test-auth0.pem'),