diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cd381f3..0cf9aaf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,9 +12,9 @@ jobs: steps: - name: checkout uses: actions/checkout@main - - name: use node 12 + - name: use node 24 uses: actions/setup-node@v2 with: - node-version: 12.x + node-version: 24.x - run: npm i - run: npm test diff --git a/README.md b/README.md index 3462568..7742ede 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,14 @@ +# Maintenance Notice + +This library is currently in maintenance mode. Until further notice, the primary directive is to handle bug reports and security issues with this library. + +Any library alternatives and suggestions can be filed under an issue. + # SAML2-js [![CircleCI](https://circleci.com/gh/Clever/saml2/tree/master.svg?style=svg)](https://circleci.com/gh/Clever/saml2/tree/master) -`saml2-js` is a node module that abstracts away the complexities of the SAML protocol behind an easy to use interface. +`saml2-js` is a node module that abstracts away the complexities of the SAML protocol behind an easy to use interface. It achieves this this by helping you implement a service provider for the SAML protocol. It currently does not implement the features to act as an identity provider. ## Usage @@ -230,10 +236,12 @@ var saml2 = require('saml2-js'); var fs = require('fs'); var express = require('express'); var app = express(); -var bodyParser = require('body-parser'); -app.use(bodyParser.urlencoded({ - extended: true -})); +// If you're using express <4.0: +// var bodyParser = require('body-parser'); +// app.use(bodyParser.urlencoded({ +// extended: true +// })); +app.use(express.urlencoded()); // Create service provider var sp_options = { @@ -269,6 +277,9 @@ app.get("/login", function(req, res) { }); }); +// Variables used in login/logout process +var name_id, session_index; + // Assert endpoint for when login completes app.post("/assert", function(req, res) { var options = {request_body: req.body}; @@ -281,7 +292,7 @@ app.post("/assert", function(req, res) { name_id = saml_response.user.name_id; session_index = saml_response.user.session_index; - res.send("Hello #{saml_response.user.name_id}!"); + res.send("Hello #{name_id}! session_index: #{session_index}."); }); }); diff --git a/lib/saml2.coffee b/lib/saml2.coffee index 8f2712b..64c9cd5 100644 --- a/lib/saml2.coffee +++ b/lib/saml2.coffee @@ -5,7 +5,7 @@ debug = require('debug') 'saml2' url = require 'url' util = require 'util' xmlbuilder = require 'xmlbuilder2' -xmlcrypto = require 'xml-crypto' +xpath = require 'xpath' xmldom = require '@xmldom/xmldom' xmlenc = require 'xml-encryption' zlib = require 'zlib' @@ -51,10 +51,19 @@ create_authn_request = (issuer, assert_endpoint, destination, force_authn, conte # Adds an embedded signature to a previously generated AuthnRequest sign_authn_request = (xml, private_key, options) -> - signer = new SignedXml null, options - signer.addReference "//*[local-name(.)='AuthnRequest']", ['http://www.w3.org/2000/09/xmldsig#enveloped-signature','http://www.w3.org/2001/10/xml-exc-c14n#'] - signer.signingKey = private_key + signer = new SignedXml options + signer.addReference({ + xpath: "//*[local-name(.)='AuthnRequest']", + transforms: ['http://www.w3.org/2000/09/xmldsig#enveloped-signature', 'http://www.w3.org/2001/10/xml-exc-c14n#'] + digestAlgorithm: "http://www.w3.org/2001/04/xmlenc#sha256" + }) + signer.privateKey = private_key + signer.canonicalizationAlgorithm = 'http://www.w3.org/2001/10/xml-exc-c14n#'; + signer.signatureAlgorithm = options?.signatureAlgorithm || 'http://www.w3.org/2001/04/xmldsig-more#rsa-sha256' + signer.computeSignature xml + + return signer.getSignedXml() # Creates metadata and returns it as a string of XML. The metadata has one POST assertion endpoint. @@ -238,46 +247,39 @@ decrypt_assertion = (dom, private_keys, cb) -> # This checks the signature of a saml document and returns either array containing the signed data if valid, or null # if the signature is invalid. Comparing the result against null is NOT sufficient for signature checks as it doesn't # verify the signature is signing the important content, nor is it preventing the parsing of unsigned content. -check_saml_signature = (_xml, certificate) -> - # xml-crypto requires that whitespace is normalized as such: - # https://github.com/yaronn/xml-crypto/commit/17f75c538674c0afe29e766b058004ad23bd5136#diff-5dfe38baf287dcf756a17c2dd63483781b53bf4b669e10efdd01e74bcd8e780aL69 - xml = _xml.replace(/\r\n?/g, '\n') +check_saml_signature = (xml, certificate) -> doc = (new xmldom.DOMParser()).parseFromString(xml) # xpath failed to capture nodes of direct descendents of the root. # Call documentElement to explicitly start from the root element of the document. - signature = xmlcrypto.xpath(doc.documentElement, "./*[local-name(.)='Signature' and namespace-uri(.)='http://www.w3.org/2000/09/xmldsig#']") + signature = xpath.select("./*[local-name(.)='Signature' and namespace-uri(.)='http://www.w3.org/2000/09/xmldsig#']", doc.documentElement) return null unless signature.length is 1 - sig = new xmlcrypto.SignedXml() - sig.keyInfoProvider = getKey: -> format_pem(certificate, 'CERTIFICATE') + sig = new SignedXml( + { + publicCert: format_pem(certificate, 'CERTIFICATE') + } + ) + # sig.keyInfoProvider = getKey: -> format_pem(certificate, 'CERTIFICATE') sig.loadSignature signature[0] - valid = sig.checkSignature xml - if valid - return get_signed_data(doc, sig) - else - return null + try + valid = sig.checkSignature xml + if valid + return get_signed_data(doc, sig) + else + return null + catch e + # temporary hack + if (e.message.indexOf("invalid signature") != -1) + return null; # returns null for incorrect signature + else + throw e; # throw back the error + # Gets the data that is actually signed according to xml-crypto. This function should mirror the way xml-crypto finds # elements for security reasons. +# deprecate get_signed_data = (doc, sig) -> - _.map sig.references, (ref) -> - uri = ref.uri - if uri[0] is '#' - uri = uri.substring(1) - - elem = [] - if uri is "" - elem = xmlcrypto.xpath(doc, "//*") - else - for idAttribute in ["Id", "ID"] - elem = xmlcrypto.xpath(doc, "//*[@*[local-name(.)='" + idAttribute + "']='" + uri + "']") - if elem.length > 0 - break - - unless elem.length > 0 - throw new Error("Invalid signature; must be a reference to '#{ref.uri}'") - sig.getCanonXml ref.transforms, elem[0], { inclusiveNamespacesPrefixList: ref.inclusiveNamespacesPrefixList } - + return sig.getSignedReferences(); # use new API # Takes in an xml @dom of an object containing a SAML Response and returns an object containing the Destination and # InResponseTo attributes of the Response if present. It will throw an error if the Response is missing or does not # appear to be valid. @@ -353,8 +355,8 @@ parse_assertion_attributes = (dom) -> attribute_name = get_attribute_value attribute, 'Name' throw new Error("Invalid attribute without name") unless attribute_name? attribute_values = attribute.getElementsByTagNameNS(XMLNS.SAML, 'AttributeValue') - assertion_attributes[attribute_name] = _(attribute_values).map (attribute_value) -> - attribute_value.childNodes[0]?.data or '' + assertion_attributes[attribute_name] = _.map(attribute_values, (attribute_value) -> + attribute_value.childNodes[0]?.data or '') assertion_attributes # Takes in an object containing SAML Assertion Attributes and returns an object with certain common attributes changed @@ -381,8 +383,7 @@ pretty_assertion_attributes = (assertion_attributes) -> "http://schemas.microsoft.com/ws/2008/06/identity/claims/primarysid": "primary_sid" "http://schemas.microsoft.com/ws/2008/06/identity/claims/windowsaccountname": "windows_account_name" - _(assertion_attributes) - .chain() + _.chain(assertion_attributes) .pairs() .filter(([k, v]) -> (claim_map[k]? and v.length > 0)) .map(([k, v]) -> [claim_map[k], v[0]]) @@ -548,7 +549,7 @@ module.exports.ServiceProvider = @alt_private_keys = [].concat(@alt_private_keys or []) @alt_certs = [].concat(@alt_certs or []) - @shared_options = _(options).pick( + @shared_options = _.pick(options, "force_authn", "auth_context", "nameid_format", "sign_get_request", "allow_unencrypted_assertion", "audience", "notbefore_skew") # Returns: @@ -570,7 +571,7 @@ module.exports.ServiceProvider = return cb ex delete uri.search # If you provide search and query search overrides query :/ if options.sign_get_request - _(uri.query).extend sign_request(deflated.toString('base64'), @private_key, options.relay_state) + _.extend(uri.query, sign_request(deflated.toString('base64'), @private_key, options.relay_state)) else uri.query.SAMLRequest = deflated.toString 'base64' uri.query.RelayState = options.relay_state if options.relay_state? @@ -624,7 +625,7 @@ module.exports.ServiceProvider = async.waterfall [ (cb_wf) -> - raw = new Buffer(options.request_body.SAMLResponse or options.request_body.SAMLRequest, 'base64') + raw = Buffer.from(options.request_body.SAMLResponse or options.request_body.SAMLRequest, 'base64') # Inflate response for redirect requests before parsing it. if (options.get_request) diff --git a/package.json b/package.json index cfcc9e1..9d1c113 100644 --- a/package.json +++ b/package.json @@ -1,17 +1,17 @@ { "name": "@flourish/saml2-js", - "version": "4.1.2", + "version": "4.2.0", "description": "SAML 2.0 node helpers", "author": "Clever", "license": "Apache-2.0", "main": "index.js", "engines": { - "node": ">=12" + "node": ">=14" }, "scripts": { "build": "coffee --bare -c -o lib-js lib", - "test": "NODE_ENV=test mocha --require coffee-script/register test/*.coffee", - "test-cov": "NODE_ENV=test nyc --extension .coffee -r html -r text mocha --require coffee-script/register test/*.coffee", + "test": "NODE_ENV=test mocha --require coffeescript/register test/*.coffee", + "test-cov": "NODE_ENV=test nyc --extension .coffee -r html -r text mocha --require coffeescript/register test/*.coffee", "prepare": "npm run build" }, "repository": "github:kiln/saml2", @@ -23,17 +23,18 @@ "url": "https://github.com/Clever/saml2/issues" }, "devDependencies": { - "coffee-script": "^1.12.7", + "coffeescript": "^1.12.7", "mocha": "^9.2.0", "nyc": "^15.1.0" }, "dependencies": { - "@xmldom/xmldom": "^0.8.3", + "@xmldom/xmldom": "^0.8.6", "async": "^3.2.0", "debug": "^4.3.0", "underscore": "^1.8.0", - "xml-crypto": "^2.0.0", + "xml-crypto": "^6.1.2", "xml-encryption": "^3.0.2", - "xmlbuilder2": "^2.4.0" + "xmlbuilder2": "^2.4.0", + "xpath": "^0.0.34" } -} \ No newline at end of file +} diff --git a/test/data/good_assertion_commented_out_digest.xml b/test/data/good_assertion_commented_out_digest.xml new file mode 100644 index 0000000..ecab1ca --- /dev/null +++ b/test/data/good_assertion_commented_out_digest.xml @@ -0,0 +1,56 @@ + + http://idp.example.com/metadata.xml + + + + + + + + + + /otGbaGvPsxvHXpaxzS5QXWvJRQ= + + + BOeWxdgnJw1hx2Vvwja947xDb8/5T+zFI1o8xw4aPH7lxxWVXk9s8UOYP99yjH1c+2iFNzm5VNaZG5op08bxZSHRsgU4DQ35jMO/D8Ra42zKRXHOd0TunjZ8WQhYV8RoESHKjsUGyjLhlavFMyUDMr+O4d3GSn+r5lBlw40zwyn7f7j+or8gyemp618vlTXrT31L9+xZaLRKgF8I0ZC4WpfJZGWOj7x4u/X1xfggf0jtDmbLrY8aMIXrH1cn46VnMqIrEwMC1zIJfPU+GeBQRDgcGhzq2ttaA2v3rGMzamk3qlqgMOEbI3kBWXSTuddPRkn5GVhQkb9nGa/0neAWzQ== + + + + + + tstudent + + + + + + + https://sp.example.com/metadata.xml + + + + + Test + + + tstudent@example.com + + + tstudent + + + CN=Students,CN=Users,DC=idp,DC=example,DC=com + + + Student + + + Test Student + + + + + urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport + + + \ No newline at end of file diff --git a/test/saml2.coffee b/test/saml2.coffee index cc11c2b..6220603 100644 --- a/test/saml2.coffee +++ b/test/saml2.coffee @@ -198,6 +198,10 @@ describe 'saml2', -> result = saml2.check_saml_signature(get_test_file("good_response_twice_signed_dsig_ns_at_top.xml"), get_test_file("test.crt")) assert.notEqual null, result + it 'correctly ignores commented-out digest', -> + result = saml2.check_saml_signature(get_test_file("good_assertion_commented_out_digest.xml"), get_test_file("test.crt")) + assert.deepEqual result, [get_test_file("good_assertion_signed_data.xml")] + describe 'check_status_success', => it 'accepts a valid success status', => assert saml2.check_status_success(@good_response_dom), "Did not get 'true' for valid response." @@ -1117,7 +1121,7 @@ describe 'saml2', -> sp.create_login_request_url idp, request_options, (err, login_url, id) -> assert not err?, "Error creating login URL: #{err}" parsed_url = url.parse login_url, true - saml_request = new Buffer(parsed_url.query?.SAMLRequest, 'base64') + saml_request = Buffer.from(parsed_url.query?.SAMLRequest, 'base64') zlib.inflateRaw saml_request, (err, result) -> assert.notEqual result.toString('utf8').indexOf("urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress"), -1 done() @@ -1142,7 +1146,7 @@ describe 'saml2', -> sp.create_login_request_url idp, request_options, (err, login_url, id) -> assert not err?, "Error creating login URL: #{err}" parsed_url = url.parse login_url, true - saml_request = new Buffer(parsed_url.query?.SAMLRequest, 'base64') + saml_request = Buffer.from(parsed_url.query?.SAMLRequest, 'base64') zlib.inflateRaw saml_request, (err, result) -> assert.notEqual result.toString('utf8').indexOf("urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified"), -1 done() @@ -1314,7 +1318,7 @@ describe 'saml2', -> xml = sp.create_authn_request_xml(idp) dom = (new xmldom.DOMParser()).parseFromString xml method = dom.getElementsByTagName('SignatureMethod')[0] - assert.equal method.attributes[0].value, 'http://www.w3.org/2000/09/xmldsig#rsa-sha1' + assert.equal method.attributes[0].value, "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256" it 'can create a signed AuthnRequest xml document with sha256 signature', () -> sp_options =