From 46305ebc6732032ed9d675b44ac8545ae60fc64a Mon Sep 17 00:00:00 2001 From: Artiom Ciumac Date: Mon, 24 Jul 2023 14:12:24 +0100 Subject: [PATCH] feat: add support for per-participant bindings during SLO (#135) --- CHANGELOG.md | 14 ++ README.md | 5 +- lib/logout.js | 16 +- package.json | 2 +- test/samlp.logout.session_store.tests.js | 187 +++++++++++++++++++++++ 5 files changed, 218 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c12839a..ea639f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,20 @@ All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. +## [7.1.0](https://github.com/auth0/node-samlp/compare/v7.0.2...v7.1.0) (2023-07-24) + + +### Features + +* add support for per-participant bindings during SLO ([9f21610](https://github.com/auth0/node-samlp/commit/9f21610d18c685765d4cd5ac11deca39938d31ac)) + +### [7.0.2](https://github.com/auth0/node-samlp/compare/v7.0.1...v7.0.2) (2022-06-09) + + +### Bug Fixes + +* Update saml and ejs dependencies ([#132](https://github.com/auth0/node-samlp/issues/132)) ([26b8cbd](https://github.com/auth0/node-samlp/commit/26b8cbd50bde051e68bcb32fce61421641276b72)) + ### [7.0.1](https://github.com/auth0/node-samlp/compare/v7.0.0...v7.0.1) (2022-05-19) diff --git a/README.md b/README.md index 3f4820f..5ee6128 100644 --- a/README.md +++ b/README.md @@ -96,6 +96,7 @@ Options | store | an object that handles the HTTP Session. Check [this implementation](./test/in_memory_store/) | new SessionStore(options) Uses req.session to store the current state | #### Notes + - options.cert: This is the public certificate of the IdP - options.key: This is the private key of the IdP. The IdP will sign its SAML `LogoutRequest` and `LogoutResponse` with this key. - options.store: Since the logout flow will involve several requests/responses, we need to keep track of the transaction state. The default implementation uses req.session to store the transaction state via the 'flowstate' module @@ -108,10 +109,12 @@ var sessionParticipant = { nameIdFormat: 'urn:oasis:names:tc:SAML:2.0:nameid-format:transient', // Format of the NameId sessionIndex: '1', // The session index generated by the IdP serviceProviderLogoutURL: 'https://foobarsupport.zendesk.com/logout', // The logout URL of the Session Participant - cert: sp1_credentials.cert // The Session Participant public certificate, used to verify the signature of the SAML requests made by this SP + cert: sp1_credentials.cert, // The Session Participant public certificate, used to verify the signature of the SAML requests made by this SP + binding: 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST' // Optional, participant-specific binding to use during SLO, if not provided - will use "protocolBinding" from provided options }; ``` +In some situations it is possible for session participants to have mixed bindings during one Single Log Out (SLO) transaction. By default the library will use the binding specified in `options.protocolBinding`, however if mixed bindings must be used - each participant must have the binding specified as an additional field. If the binding value is invalid - it will fall back to `HTTP-POST`. Add the middleware as follows: diff --git a/lib/logout.js b/lib/logout.js index 77c4172..cb57dee 100644 --- a/lib/logout.js +++ b/lib/logout.js @@ -43,6 +43,7 @@ module.exports.logout = function (options) { options.destination = participant.serviceProviderLogoutURL; options.relayState = relayState; + options.participantBinding = participant.binding; // Send logout request prepareAndSendToken(req, res, 'LOGOUT_REQUEST', logoutRequest, options, next); }); @@ -81,6 +82,7 @@ module.exports.logout = function (options) { options.destination = data.serviceProviderLogoutURL || options.destination; // We stored the relay state of the initial request options.relayState = transaction.relayState; + options.participantBinding = transaction.binding; prepareAndSendToken(req, res, 'LOGOUT_RESPONSE', logoutResponse, options, next); }); }); @@ -185,9 +187,14 @@ module.exports.logout = function (options) { id: requestData.id, serviceProviderLogoutURL: (session|| {}).serviceProviderLogoutURL || options.destination }, - relayState: req.query.RelayState || (req.body && req.body.RelayState) + relayState: req.query.RelayState || (req.body && req.body.RelayState), }; + if (session && session.binding) { + // record the client-specific binding, if there is one. + spData.binding = session.binding; + } + options.store.save(req, spData, function (err, transactionId) { if (err) { return next(err); } @@ -297,10 +304,11 @@ function parseIncomingLogoutRequest(req, samlRequest, options, callback) { } function prepareAndSendToken(req, res, element_type, token, options, cb) { + const binding = options.participantBinding || options.protocolBinding; var type = constants.ELEMENTS[element_type].PROP; - + var send = function (params) { - if (options.protocolBinding !== BINDINGS.HTTP_REDIRECT) { + if (binding !== BINDINGS.HTTP_REDIRECT) { // HTTP-POST res.set('Content-Type', 'text/html'); return res.send(templates.form({ @@ -327,7 +335,7 @@ function prepareAndSendToken(req, res, element_type, token, options, cb) { // canonical request token = trim_xml(token); - if (options.protocolBinding !== BINDINGS.HTTP_REDIRECT || !options.deflate) { + if (binding !== BINDINGS.HTTP_REDIRECT || !options.deflate) { // HTTP-POST or HTTP-Redirect without deflate encoding try { token = signers.signXml(options, token); diff --git a/package.json b/package.json index 0646ebd..278517a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "samlp", - "version": "7.0.1", + "version": "7.1.0", "engines": { "node": ">=12" }, diff --git a/test/samlp.logout.session_store.tests.js b/test/samlp.logout.session_store.tests.js index 89106e5..9d59221 100644 --- a/test/samlp.logout.session_store.tests.js +++ b/test/samlp.logout.session_store.tests.js @@ -12,6 +12,7 @@ var fs = require('fs'); var path = require('path'); var SPs = require('../lib/sessionParticipants'); const timekeeper = require('timekeeper'); +const BINDINGS = require('../lib/constants').BINDINGS; var sp1_credentials = { cert: fs.readFileSync(path.join(__dirname, 'fixture', 'sp1.pem')), @@ -91,6 +92,91 @@ describe('samlp logout with Session Participants - Session Provider', function ( }); }); + function prepareOneParticipant(binding) { + sessions.splice(0); + sessions.push({ ...sessionParticipant1, binding: binding }); + } + + function prepareTwoParticipants(secondBinding) { + sessions.splice(0); + sessions.push(sessionParticipant1); + sessions.push({ ...sessionParticipant2, binding: secondBinding }); + } + + function logoutGetSPInitiated(callback) { + // SAMLRequest: base64 encoded + deflated + URLEncoded + // Signature: URLEncoded + // SigAlg: URLEncoded + + // + // https://foobarsupport.zendesk.com + // foo@example.com + // 1 + // + request.get( + { + followRedirect: false, + uri: 'http://localhost:5050/logout?SAMLRequest=fVFNS8NAEL0L%2Foew900zaa1xaIOFIgSqBysevG03Uw1md%2BPOBoq%2F3m1aoVZ0DnOY97WPnbEybYcr9%2Br68EgfPXFIdqa1jAMyF7236BQ3jFYZYgwa14v7FeZphp13wWnXihPJ%2FwrFTD40zoqkWs7FXuBlnmf6OrsiqSEuAJrKm0JNJOntZLPRNBlDEfnMPVWWg7JhLvIMphJyCeMnKDADhPxFJM%2FkOZpHOM1EeXmRHGe2D8LBwZdvIXSMo9HWuY3y3Hed8yH9JFsTv6famdnolH7u8hBLVcvkznmjwt9tIYXh0tRyO1CRjGraRV17YhZlTL%2BlnTJdSyeZB%2FNfmesoib2q%2BMRdCUfuj%2BO34oCd%2FWj5BQ%3D%3D&Signature=NkobB0DS0M4kfV89R%2Bma0wp0djNr4GW2ziVemwSvVYy2iF432qjs%2FC4Y1cZDXwuF5OxMgu4DuelS5mW3Z%2B46XXkoMVBizbd%2BIuJUFQcvLtiXHkoaEk8HVU0v5bA9TDoc9Ve7A0nUgKPciH7KTcFSr45vepyg0dMMQtarsUZeYSRPM0QlwxXKCWRQJDwGHLie5dMCZTRNUEcm9PtWZij714j11HI15u6Fp5GDnhp7mzKuAUdSIKHzNKAS2J4S8xZz9n9UTCl3uBbgfxZ3av6%2FMQf7HThxTl%2FIOmU%2FYCAN6DWWE%2BQ3Z11bgU06P39ZuLW2fRBOfIOO6iTEaAdORrdBOw%3D%3D&RelayState=123&SigAlg=http%3A%2F%2Fwww.w3.org%2F2000%2F09%2Fxmldsig%23rsa-sha1', + }, + function (err, response) { + if (err) return callback(err); + callback(null, response); + } + ); + } + + function logoutPostSPInitiated(callback) { + // SAMLRequest: base64 encoded + deflated + URLEncoded + // Signature: URLEncoded + // SigAlg: URLEncoded + + // + // https://foobarsupport.zendesk.com + // foo@example.com + // 1 + // + request.post({ + followRedirect: false, + uri: "http://localhost:5050/logout", + json: true, + body: { + SAMLRequest: "PD94bWwgdmVyc2lvbj0iMS4wIj8+DQo8c2FtbHA6TG9nb3V0UmVxdWVzdCB4bWxuczpzYW1scD0idXJuOm9hc2lzOm5hbWVzOnRjOlNBTUw6Mi4wOnByb3RvY29sIiB4bWxuczpzYW1sPSJ1cm46b2FzaXM6bmFtZXM6dGM6U0FNTDoyLjA6YXNzZXJ0aW9uIiBJRD0icGZ4NmZlNjU3ZTMtMWE3Zi04OTNlLWY2OTAtZjdmYzUxNjJlYTExIiBJc3N1ZUluc3RhbnQ9IjIwMTYtMTItMTNUMTg6MDE6MTJaIiBWZXJzaW9uPSIyLjAiPg0KICAgICAgICA8c2FtbDpJc3N1ZXI+aHR0cHM6Ly9mb29iYXJzdXBwb3J0LnplbmRlc2suY29tPC9zYW1sOklzc3Vlcj48ZHM6U2lnbmF0dXJlIHhtbG5zOmRzPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwLzA5L3htbGRzaWcjIj4NCiAgPGRzOlNpZ25lZEluZm8+PGRzOkNhbm9uaWNhbGl6YXRpb25NZXRob2QgQWxnb3JpdGhtPSJodHRwOi8vd3d3LnczLm9yZy8yMDAxLzEwL3htbC1leGMtYzE0biMiLz4NCiAgICA8ZHM6U2lnbmF0dXJlTWV0aG9kIEFsZ29yaXRobT0iaHR0cDovL3d3dy53My5vcmcvMjAwMC8wOS94bWxkc2lnI3JzYS1zaGExIi8+DQogIDxkczpSZWZlcmVuY2UgVVJJPSIjcGZ4NmZlNjU3ZTMtMWE3Zi04OTNlLWY2OTAtZjdmYzUxNjJlYTExIj48ZHM6VHJhbnNmb3Jtcz48ZHM6VHJhbnNmb3JtIEFsZ29yaXRobT0iaHR0cDovL3d3dy53My5vcmcvMjAwMC8wOS94bWxkc2lnI2VudmVsb3BlZC1zaWduYXR1cmUiLz48ZHM6VHJhbnNmb3JtIEFsZ29yaXRobT0iaHR0cDovL3d3dy53My5vcmcvMjAwMS8xMC94bWwtZXhjLWMxNG4jIi8+PC9kczpUcmFuc2Zvcm1zPjxkczpEaWdlc3RNZXRob2QgQWxnb3JpdGhtPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwLzA5L3htbGRzaWcjc2hhMSIvPjxkczpEaWdlc3RWYWx1ZT55SnpIbmRqL3NuaVJzTG1kcHFSZ0Yvdmp6L0k9PC9kczpEaWdlc3RWYWx1ZT48L2RzOlJlZmVyZW5jZT48L2RzOlNpZ25lZEluZm8+PGRzOlNpZ25hdHVyZVZhbHVlPk56bU42R0RLcHNpMVU4NndaTXNjWjY2aExHNDVhMzhhMGhvaCtpdFdCTWQzNS9RMnF1Y2N2NEJaTGhSbU1xYmFIL3l4VnZ4bWUvWXExR24xbEkrVlpwZkZsYURXQnZTcXUxdWJVemVEbEtVUDdHUmVnakNSTFErSkhxZnQ2aHRDdENQdkttQ0NTaVNEVlZydmcvc0ZLVXBuVDhPWEhkK25ENDBLSVQ4NHQ2OERiM2pTN3g2amx6VDMzYk1Vdm83dVNFUDVnSnFUbG9RMVVWY280WmszUGVxK0tDOWF6TUFkVHVnMWZZRDJXVWtXOEZCd084b1ZBUWpDMGo4VkVyVVpiUUpRS2hhdTMxcjNVcU1VUExNS0NJaFZxZ0tPRVd6MWt1a1NWY2MzdTJjR0owT1FJU093N0xQbkRDSTdPclVMaGU4NEJESTMzR01JMDNXazFMNG5Mdz09PC9kczpTaWduYXR1cmVWYWx1ZT4NCjxkczpLZXlJbmZvPjxkczpYNTA5RGF0YS8+PC9kczpLZXlJbmZvPjwvZHM6U2lnbmF0dXJlPg0KICAgICAgICA8c2FtbDpOYW1lSUQgRm9ybWF0PSJ1cm46b2FzaXM6bmFtZXM6dGM6U0FNTDoxLjE6bmFtZWlkLWZvcm1hdDplbWFpbEFkZHJlc3MiPmZvb0BleGFtcGxlLmNvbTwvc2FtbDpOYW1lSUQ+DQogICAgICAgIDxzYW1sOlNlc3Npb25JbmRleD4xPC9zYW1sOlNlc3Npb25JbmRleD4NCiAgICAgIDwvc2FtbHA6TG9nb3V0UmVxdWVzdD4=", + RelayState: "123", + }, + }, + function (err, response) { + if (err) return callback(err); + callback(null, response); + } + ); + } + + function logoutGetIDPInitiated(callback) { + request.get({ + followRedirect: false, + uri: 'http://localhost:5050/logout' + }, function (err, response) { + if(err) return callback(err); + + callback(null, response); + }); + } + + function assertPostResponse(response) { + // Ensure we get a POST response, + // this means responding with an HTML form that will self-submit. + // The rest is covered by other tests. + expect(response).to.be.ok; + expect(response.statusCode).to.equal(200); + } + + function assertRedirectResponse(response) { + // Ensure we get a Redirect response, + // The rest is covered by other tests. + expect(response).to.be.ok; + expect(response.statusCode).to.equal(302); + } + describe('HTTP Redirect', function () { describe('SP initiated - Should fail if No Issuer is present', function () { var logoutResultValue; @@ -800,6 +886,66 @@ describe('samlp logout with Session Participants - Session Provider', function ( }); }); }); + + describe('SP initiated - 1 Session Participant with POST binding', function () { + var logoutResponse; + + before(function () { + prepareOneParticipant(BINDINGS.HTTP_POST); + }); + + before(function (done) { + logoutGetSPInitiated(function(err, response){ + if (err) return done(err); + logoutResponse = response; + done(); + }); + }); + + it('Should return POST request', function () { + assertPostResponse(logoutResponse); + }); + }); + + describe('SP initiated - 2 Session Participants with POST binding', function() { + var logoutResponse; + + before(function () { + prepareTwoParticipants(BINDINGS.HTTP_POST); + }); + + before(function (done) { + logoutGetSPInitiated(function(err, response){ + if (err) return done(err); + logoutResponse = response; + done(); + }); + }); + + it('Should return POST request', function () { + assertPostResponse(logoutResponse); + }); + }); + + describe('IDP initiated - 1 Session Participant with POST binding', function() { + var logoutResponse; + + before(function () { + prepareOneParticipant(BINDINGS.HTTP_POST); + }); + + before(function (done) { + logoutGetIDPInitiated(function(err, response){ + if (err) return done(err); + logoutResponse = response; + done(); + }); + }); + + it('Should return POST request', function () { + assertPostResponse(logoutResponse); + }); + }); }); describe('HTTP POST', function () { @@ -1374,6 +1520,46 @@ describe('samlp logout with Session Participants - Session Provider', function ( expect(response.body).to.equal('Invalid Session Participant'); }); }); + + describe('SP initiated - 1 Session Participant with Redirect binding', function () { + var logoutResponse; + + before(function () { + prepareOneParticipant(BINDINGS.HTTP_REDIRECT); + }); + + before(function (done) { + logoutPostSPInitiated(function(err, response){ + if (err) return done(err); + logoutResponse = response; + done(); + }); + }); + + it('Should return Redirect request', function () { + assertRedirectResponse(logoutResponse); + }); + }); + + describe('SP initiated - 2 Session Participants with Redirect binding', function() { + var logoutResponse; + + before(function () { + prepareTwoParticipants(BINDINGS.HTTP_REDIRECT); + }); + + before(function (done) { + logoutPostSPInitiated(function(err, response){ + if (err) return done(err); + logoutResponse = response; + done(); + }); + }); + + it('Should return Redirect request', function () { + assertRedirectResponse(logoutResponse); + }); + }); }); }); @@ -1429,6 +1615,7 @@ describe('samlp logout with Session Participants - Session Provider', function ( } }, function (err, response){ if (err) { return done(err); } + expect(response.statusCode).to.equal(200); $ = cheerio.load(response.body); var SAMLResponse = $('input[name="SAMLResponse"]').attr('value');