Skip to content
Open
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
57 changes: 57 additions & 0 deletions index.d.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}

214 changes: 155 additions & 59 deletions lib/saml20.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -24,6 +25,11 @@ var algorithms = {
}
};

var ResponseSigningLevel = {
ResponseOnly: 'ResponseOnly',
AssertionAndResponse: 'AssertionAndResponse'
}

function getAttributeType(value){
switch(typeof value) {
case "string":
Expand All @@ -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';
Expand All @@ -55,39 +61,130 @@ 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;
return "<" + prefix + "X509Data><" + prefix + "X509Certificate>" + cert + "</" + prefix + "X509Certificate></" + prefix + "X509Data>";
}
};

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 = `<saml:EncryptedAssertion xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion">${encryptedAssertion}</saml: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());
Expand All @@ -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];
Expand All @@ -122,7 +219,7 @@ exports.create = function(options, callback) {
audienceRestriction.appendChild(element);
});

conditions[0].appendChild(audienceRestriction);
conditions[0].appendChild(audienceRestriction);
}

if (options.recipient)
Expand All @@ -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]];
Expand Down Expand Up @@ -176,56 +273,55 @@ exports.create = function(options, callback) {
}

var nameID = doc.documentElement.getElementsByTagNameNS(NAMESPACE, 'NameID')[0];

if (options.nameIdentifier) {
nameID.textContent = options.nameIdentifier;
}

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 = '<saml:EncryptedAssertion xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion">' + encrypted + '</saml:EncryptedAssertion>';
callback(null, utils.removeWhitespace(encrypted));
});
};

};
6 changes: 6 additions & 0 deletions lib/saml20Response.template
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<samlp:Response xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" Version="2.0" IssueInstant="" ID="" Destination="">
<saml:Issuer Format="urn:oasis:names:tc:SAML:2.0:nameid-format:entity"></saml:Issuer>
<samlp:Status>
<samlp:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"/>
</samlp:Status>
</samlp:Response>
Loading