Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
23 changes: 17 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -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};
Expand All @@ -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}.");
});
});

Expand Down
83 changes: 42 additions & 41 deletions lib/saml2.coffee
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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 <ds:Signature> 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.
Expand Down Expand Up @@ -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
Expand All @@ -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]])
Expand Down Expand Up @@ -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:
Expand All @@ -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?
Expand Down Expand Up @@ -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)
Expand Down
19 changes: 10 additions & 9 deletions package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -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"
}
}
}
56 changes: 56 additions & 0 deletions test/data/good_assertion_commented_out_digest.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
<Assertion xmlns="urn:oasis:names:tc:SAML:2.0:assertion" ID="_3" IssueInstant="2014-03-12T21:35:05.392Z" Version="2.0">
<Issuer>http://idp.example.com/metadata.xml</Issuer>
<Signature xmlns="http://www.w3.org/2000/09/xmldsig#">
<SignedInfo>
<CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>
<SignatureMethod Algorithm="http://www.w3.org/2000/09/xmldsig#rsa-sha1"/>
<Reference URI="">
<Transforms>
<Transform Algorithm="http://www.w3.org/2000/09/xmldsig#enveloped-signature"/>
</Transforms>
<DigestMethod Algorithm="http://www.w3.org/2000/09/xmldsig#sha1"/>
<DigestValue><!--should not see this value-->/otGbaGvPsxvHXpaxzS5QXWvJRQ=</DigestValue>
</Reference>
</SignedInfo>
<SignatureValue>BOeWxdgnJw1hx2Vvwja947xDb8/5T+zFI1o8xw4aPH7lxxWVXk9s8UOYP99yjH1c+2iFNzm5VNaZG5op08bxZSHRsgU4DQ35jMO/D8Ra42zKRXHOd0TunjZ8WQhYV8RoESHKjsUGyjLhlavFMyUDMr+O4d3GSn+r5lBlw40zwyn7f7j+or8gyemp618vlTXrT31L9+xZaLRKgF8I0ZC4WpfJZGWOj7x4u/X1xfggf0jtDmbLrY8aMIXrH1cn46VnMqIrEwMC1zIJfPU+GeBQRDgcGhzq2ttaA2v3rGMzamk3qlqgMOEbI3kBWXSTuddPRkn5GVhQkb9nGa/0neAWzQ==</SignatureValue>
<KeyInfo>
<KeyName/>
</KeyInfo>
</Signature>
<Subject xmlns="urn:oasis:names:tc:SAML:2.0:assertion">
<NameID Format="urn:oasis:names:tc:SAML:2.0:nameid-format:transient">tstudent</NameID>
<SubjectConfirmation Method="urn:oasis:names:tc:SAML:2.0:cm:bearer">
<SubjectConfirmationData InResponseTo="_4" NotOnOrAfter="2014-03-12T21:40:05.392Z" Recipient="https://sp.example.com/assert"/>
</SubjectConfirmation>
</Subject>
<Conditions NotBefore="2014-03-12T21:35:05.387Z" NotOnOrAfter="2014-03-12T22:35:05.387Z">
<AudienceRestriction>
<Audience>https://sp.example.com/metadata.xml</Audience>
</AudienceRestriction>
</Conditions>
<AttributeStatement>
<Attribute Name="http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname">
<AttributeValue>Test</AttributeValue>
</Attribute>
<Attribute Name="http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress">
<AttributeValue>tstudent@example.com</AttributeValue>
</Attribute>
<Attribute Name="http://schemas.xmlsoap.org/ws/2005/05/identity/claims/privatepersonalidentifier">
<AttributeValue>tstudent</AttributeValue>
</Attribute>
<Attribute Name="http://schemas.xmlsoap.org/claims/Group">
<AttributeValue>CN=Students,CN=Users,DC=idp,DC=example,DC=com</AttributeValue>
</Attribute>
<Attribute Name="http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname">
<AttributeValue>Student</AttributeValue>
</Attribute>
<Attribute Name="http://schemas.xmlsoap.org/claims/CommonName">
<AttributeValue>Test Student</AttributeValue>
</Attribute>
</AttributeStatement>
<AuthnStatement AuthnInstant="2014-03-12T21:35:05.354Z" SessionIndex="_3" SessionNotOnOrAfter="2014-03-13T22:35:05.387Z">
<AuthnContext>
<AuthnContextClassRef>urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport</AuthnContextClassRef>
</AuthnContext>
</AuthnStatement>
</Assertion>
10 changes: 7 additions & 3 deletions test/saml2.coffee
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand Down Expand Up @@ -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()
Expand All @@ -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()
Expand Down Expand Up @@ -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 =
Expand Down