passport-wsfed-saml2
Version:
SAML2 Protocol and WS-Fed library
463 lines (377 loc) • 17.9 kB
JavaScript
const crypto = require('crypto');
const xpath = require('xpath');
const xmlCrypto = require('xml-crypto');
const EventEmitter = require('events');
const forge = require('node-forge');
const utils = require('./utils');
const xmldom = require('@xmldom/xmldom');
const ELEMENT_NODE = 1;
const domParser = new xmldom.DOMParser();
const getAuthContext20 = (samlAssertion) => {
const authnContext = xpath.select("//*[local-name(.)='Assertion'][1]/*[local-name(.)='AuthnStatement']/*[local-name(.)='AuthnContext']/*[local-name(.)='AuthnContextClassRef']", samlAssertion);
if (authnContext.length === 0) {
return;
}
return authnContext[0].textContent;
};
const getAttributeValues = (attribute) => {
if (!attribute || attribute.childNodes.length === 0) {
return;
}
const attributeValues = [];
for (let i = 0; i < attribute.childNodes.length; i++) {
if (attribute.childNodes[i].nodeType !== ELEMENT_NODE) {
continue;
}
attributeValues.push(attribute.childNodes[i].textContent);
}
if (attributeValues.length === 1) {
return attributeValues[0];
}
return attributeValues;
};
const getSessionIndex = (samlAssertion) => {
const authnStatement = xpath.select("//*[local-name(.)='Assertion'][1]/*[local-name(.)='AuthnStatement']", samlAssertion);
const sessionIndex = authnStatement.length > 0 && authnStatement[0].attributes.length > 0 ?
authnStatement[0].getAttribute('SessionIndex') : undefined;
return sessionIndex || undefined;
};
const getAttributes = (samlAssertion) => {
return xpath.select("//*[local-name(.)='Assertion'][1]/*[local-name(.)='AttributeStatement']/*[local-name(.)='Attribute']", samlAssertion);
};
const getNameID11 = (samlAssertion) => {
let nameId = xpath.select("//*[local-name(.)='Assertion'][1]/*[local-name(.)='AuthenticationStatement']/*[local-name(.)='Subject']/*[local-name(.)='NameIdentifier']", samlAssertion);
if (nameId.length === 0) {
// only for backward compatibility with adfs
nameId = xpath.select("//*[local-name(.)='Assertion'][1]/*[local-name(.)='AttributeStatement']/*[local-name(.)='Subject']/*[local-name(.)='NameIdentifier']", samlAssertion);
if (nameId.length === 0) {
return;
}
}
return nameId[0].textContent;
};
const getNameID20 = (samlAssertion) => {
const nameId = xpath.select("//*[local-name(.)='Assertion'][1]/*[local-name(.)='Subject']/*[local-name(.)='NameID']", samlAssertion);
if (nameId.length === 0) {
return;
}
const element = nameId[0];
const result = {
value: element.textContent,
};
[
'NameQualifier',
'SPNameQualifier',
'Format',
'SPProvidedID'
].forEach(function(key) {
const value = element.getAttribute(key);
if (!value) {
return;
}
result[key] = element.getAttribute(key);
});
return result;
};
function getKeyFn(options) {
return function (keyInfo) {
//If there's no embedded signing cert, use the configured cert through options
if (!keyInfo || keyInfo.length === 0 ){
if (!options.cert) {
throw new Error('options.cert must be specified for SAMLResponses with no embedded signing certificate');
}
return utils.certToPEM(options.cert);
}
//If there's an embedded signature and thumbprints are provided check that
if (options.thumbprints && options.thumbprints.length > 0) {
const embeddedSignature = keyInfo.getElementsByTagNameNS('http://www.w3.org/2000/09/xmldsig#', 'X509Certificate');
if (embeddedSignature.length > 0) {
const base64cer = embeddedSignature[0].firstChild.toString();
const shasum = crypto.createHash('sha1');
const der = new Buffer(base64cer, 'base64').toString('binary');
shasum.update(der, 'latin1');
const calculatedThumbprint = shasum.digest('hex').toUpperCase();
const validThumbprint = options.thumbprints.some(function (thumbprint) {
return calculatedThumbprint === thumbprint.toUpperCase();
});
if (!validThumbprint) {
throw new Error('Invalid thumbprint (configured: ' + options.thumbprints.join(', ').toUpperCase() + '. calculated: ' + calculatedThumbprint + ')' );
}
// using embedded cert, so options.cert is not used anymore
delete options.cert;
return utils.certToPEM(base64cer);
}
}
// If there's an embedded signature, but no thumbprints are supplied, use options.cert
// either options.cert or options.thumbprints must be specified so at this point there
// must be an options.cert
return utils.certToPEM(options.cert);
}
}
class SAML {
constructor(options) {
this.options = options || {};
if (this.options.thumbprint) {
this.options.thumbprints = (this.options.thumbprints || []).concat([this.options.thumbprint]);
}
if (!this.options.cert && (!this.options.thumbprints || this.options.thumbprints.length === 0)) {
throw new Error('You should set either a base64 encoded certificate or the thumbprints of the signing certificates');
}
this.options.checkExpiration = (typeof this.options.checkExpiration !== 'undefined') ? this.options.checkExpiration : true;
// Note! It would be best to set this to true. But it's defaulting to false so as not to break login for expired certs.
this.options.checkCertExpiration = (typeof this.options.checkCertExpiration !== 'undefined') ? this.options.checkCertExpiration : false;
// clockskew in minutes
this.options.clockSkew = (typeof this.options.clockSkew === 'number' && this.options.clockSkew >= 0) ? this.options.clockSkew : 3;
this.options.checkAudience = (typeof this.options.checkAudience !== 'undefined') ? this.options.checkAudience : true;
this.options.checkRecipient = (typeof this.options.checkRecipient !== 'undefined') ? this.options.checkRecipient : true;
this.options.checkNameQualifier = (typeof this.options.checkNameQualifier !== 'undefined') ? this.options.checkNameQualifier : true;
this.options.checkSPNameQualifier = (typeof this.options.checkSPNameQualifier !== 'undefined') ? this.options.checkSPNameQualifier : true;
this.eventEmitter = this.options.eventEmitter || new EventEmitter();
this.getUseTextContentDigestValue = options.getUseTextContentDigestValue;
this.parser = domParser;
}
buildSignatureValidator (options) {
const sig = new xmlCrypto.SignedXml({ idAttribute: 'AssertionID', getCertFromKeyInfo: getKeyFn(options) });
return sig;
}
validateSignature (str, options, callback) {
const xml = utils.parseSamlResponse(str, this.parser);
const signaturePath = this.options.signaturePath || options.signaturePath;
const signatures = xpath.select(signaturePath, xml);
if (signatures.length === 0) {
return callback(new Error('Signature is missing (xpath: ' + signaturePath + ')'));
} else if (signatures.length > 1) {
return callback(new Error('Signature was found more than one time (xpath: ' + signaturePath + ')'));
}
const signature = signatures[0];
let valid;
const opts = Object.assign({}, options, { cert: this.options.cert, thumbprints: this.options.thumbprints });
const sig = this.buildSignatureValidator(opts);
try {
sig.loadSignature(signature);
valid = sig.checkSignature(utils.crlf2lf(str));
// TODO: this shouldn't be done until we have determined its completely valid, it should happen in `parseAssertion`
if (!this.extractAndValidateCertExpiration(xml, this.options.cert) && this.options.checkCertExpiration) {
return callback(new Error('The signing certificate is not currently valid.'), null);
}
} catch (e) {
if (e.message === 'PEM_read_bio_PUBKEY failed') {
return callback(new Error('The signing certificate is invalid (' + e.message + ')'));
}
if (e.opensslErrorStack !== undefined) {
const err = new Error(`The signing certificate is invalid (${e.opensslErrorStack.join(', ')})`);
err.originalError = e;
return callback(err);
}
return callback(e);
}
if (!valid) {
return callback(new Error('Signature check errors: ' + sig.references[0].validationError.message));
}
if (!sig.getSignedReferences().length) {
return callback(new Error('Could not validate Signature(s)'));
}
return callback(null, sig.getSignedReferences()[0]);
}
extractAndValidateCertExpiration (validatedSamlAssertion, optionsCert) {
// This accepts a validated SAML assertion and checks current time against the valid cert dates
const certNodes = validatedSamlAssertion.getElementsByTagNameNS('http://www.w3.org/2000/09/xmldsig#', 'X509Certificate');
const cert = certNodes.length > 0 ? certNodes[0].textContent : optionsCert;
if (!cert) { return false; }
const parsedCert = forge.pki.certificateFromPem(utils.certToPEM(cert));
const nowDate = new Date();
// true if current date is before expiry AND after cert start date
if ( ! (nowDate > parsedCert.validity.notBefore && nowDate < parsedCert.validity.notAfter)) {
this.eventEmitter.emit('certificateExpirationValidationFailed', {});
return false;
}
return true;
}
validateExpiration (samlAssertion) {
const conditions = xpath.select("//*[local-name(.)='Assertion'][1]/*[local-name(.)='Conditions']", samlAssertion);
if (!conditions || conditions.length === 0) {
return true;
}
const condition = conditions[0];
const notBefore = condition.getAttribute('NotBefore');
const notOnOrAfter = condition.getAttribute('NotOnOrAfter');
// no expiration defined.
if (!notBefore && !notOnOrAfter) {
return true
}
const now = new Date();
if (notBefore) {
const notBeforeDate = new Date(notBefore);
notBeforeDate.setMinutes(notBeforeDate.getMinutes() - this.options.clockSkew);
if (now < notBefore) {
return false;
}
}
if (notOnOrAfter) {
const notOnOrAfterDate = new Date(notOnOrAfter);
notOnOrAfterDate.setMinutes(notOnOrAfterDate.getMinutes() + this.options.clockSkew);
if (now > notOnOrAfterDate) {
return false;
}
}
return true;
}
validateAudience (samlAssertion, realm, version) {
let audience;
if (version === '2.0') {
audience = xpath.select("//*[local-name(.)='Assertion'][1]/*[local-name(.)='Conditions']/*[local-name(.)='AudienceRestriction']/*[local-name(.)='Audience']", samlAssertion);
} else {
audience = xpath.select("//*[local-name(.)='Assertion'][1]/*[local-name(.)='Conditions']/*[local-name(.)='AudienceRestrictionCondition']/*[local-name(.)='Audience']", samlAssertion);
}
if (!audience || audience.length === 0) {return false;}
return utils.stringCompare(audience[0].textContent, realm);
}
validateNameQualifier (samlAssertion, issuer) {
const nameID = getNameID20(samlAssertion);
// NameQualifier is optional. Only validate if exists
if (!nameID || !nameID.Format || !nameID.NameQualifier) {
return true;
}
if ([
'urn:oasis:names:tc:SAML:2.0:nameid-format:persistent',
'urn:oasis:names:tc:SAML:2.0:nameid-format:transient'
].indexOf(nameID.Format) === -1){
// Ignore validation if the format is not persistent or transient
return true;
}
return nameID.NameQualifier === issuer;
}
validateSPNameQualifier (samlAssertion, audience) {
const nameID = getNameID20(samlAssertion);
// SPNameQualifier is optional. Only validate if exists
if (!nameID || !nameID.Format || !nameID.SPNameQualifier) {return true;}
if ([
'urn:oasis:names:tc:SAML:2.0:nameid-format:persistent',
'urn:oasis:names:tc:SAML:2.0:nameid-format:transient'
].indexOf(nameID.Format) === -1){
// Ignore validation if the format is not persistent or transient
return true;
}
return nameID.SPNameQualifier === audience;
}
// https://www.oasis-open.org/committees/download.php/35711/sstc-saml-core-errata-2.0-wd-06-diff.pdf
// Page 19 of 91
// Recipient [Optional]
// A URI specifying the entity or location to which an attesting entity can present the assertion. For
// example, this attribute might indicate that the assertion must be delivered to a particular network
// endpoint in order to prevent an intermediary from redirecting it someplace else.
validateRecipient (samlAssertion, recipientUrl){
const subjectConfirmationData = xpath.select("//*[local-name(.)='Assertion'][1]/*[local-name(.)='Subject']/*[local-name(.)='SubjectConfirmation']/*[local-name(.)='SubjectConfirmationData']", samlAssertion);
// subjectConfirmationData is optional in the spec. Only validate if the assertion contains a recipient
if (!subjectConfirmationData || subjectConfirmationData.length === 0){
return true;
}
const recipient = subjectConfirmationData[0].getAttribute('Recipient');
const valid = !recipient || recipient === recipientUrl;
if (!valid){
this.eventEmitter.emit('recipientValidationFailed', {
configuredRecipient: recipientUrl,
assertionRecipient: recipient
});
}
return valid;
}
parseAttributes (samlAssertion, version) {
const profile = {};
const attributes = getAttributes(samlAssertion);
profile.sessionIndex = getSessionIndex(samlAssertion);
if (version === '2.0') {
for (let index in attributes) {
const attribute = attributes[index];
profile[attribute.getAttribute('Name')] = getAttributeValues(attribute);
}
const nameId = getNameID20(samlAssertion);
if (nameId) {
profile['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier'] = nameId.value;
if(Object.keys(nameId).length > 1) {
profile['nameIdAttributes'] = nameId;
}
}
const authContext = getAuthContext20(samlAssertion);
if (authContext) {
profile['http://schemas.microsoft.com/ws/2008/06/identity/claims/authenticationmethod'] = authContext;
}
} else {
if (attributes) {
for (let index in attributes) {
const attribute = attributes[index];
profile[attribute.getAttribute('AttributeNamespace') + '/' + attribute.getAttribute('AttributeName')] = getAttributeValues(attribute);
}
}
const nameId = getNameID11(samlAssertion);
if (nameId) {
profile['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier'] = typeof nameId === 'string' ? nameId : nameId['#'];
}
}
return profile;
}
validateSamlAssertion (samlAssertionStr, options, callback) {
this.validateSignature(samlAssertionStr, {
meta: options.meta,
signaturePath: "//*[local-name(.)='Assertion'][1]/*[local-name(.)='Signature' and namespace-uri(.)='http://www.w3.org/2000/09/xmldsig#']" }, (err, signed) => {
if (err) {return callback(err);}
this.parseAssertion(signed, callback);
});
}
parseAssertion (samlAssertion, callback) {
if (this.options.extractSAMLAssertion){
samlAssertion = this.options.extractSAMLAssertion(samlAssertion);
}
samlAssertion = utils.parseSamlAssertion(samlAssertion, this.parser);
if (!samlAssertion.getAttribute) {
samlAssertion = samlAssertion.documentElement;
}
if (samlAssertion.localName !== 'Assertion') {
return callback(new Error('saml response does not contain an Assertion element'));
}
const version = utils.getSamlAssertionVersion(samlAssertion);
if (!version){
// Note that this assumes any version returned by getSamlAssertionVersion is supported.
return callback(new Error('SAML Assertion version not supported, or not defined'), null);
}
if (this.options.checkExpiration && !this.validateExpiration(samlAssertion, version)) {
return callback(new Error('assertion has expired.'), null);
}
if (this.options.checkAudience && !this.validateAudience(samlAssertion, this.options.realm, version)) {
return callback(new Error('Audience is invalid. Configured: ' + this.options.realm), null);
}
if (!this.validateRecipient(samlAssertion, this.options.recipientUrl)) {
if (this.options.checkRecipient){
return callback(new Error('Recipient is invalid. Configured: ' + this.options.recipientUrl), null);
}
}
const profile = this.parseAttributes(samlAssertion, version);
let issuer;
if (version === '2.0') {
const issuerNode = xpath.select("//*[local-name(.)='Assertion'][1]/*[local-name(.)='Issuer']", samlAssertion);
if (issuerNode.length > 0) {
issuer = issuerNode[0].textContent;
}
} else {
issuer = samlAssertion.getAttribute('Issuer');
}
this.eventEmitter.emit('parseAssertion', {
issuer: issuer,
version: version,
});
profile.issuer = issuer;
// Validate the name qualifier in the NameID element if found with the audience
if (this.options.checkNameQualifier && !this.validateNameQualifier(samlAssertion, issuer)) {
return callback(new Error('NameQualifier attribute in the NameID element does not match ' + issuer), null);
}
// Validate the SP name qualifier in the NameID element if found with the issuer
if (this.options.checkSPNameQualifier && !this.validateSPNameQualifier(samlAssertion, this.options.realm)){
return callback(new Error('SPNameQualifier attribute in the NameID element does not match ' + this.options.realm), null);
}
if (!profile.email && profile['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress']) {
profile.email = profile['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress'];
}
return callback(null, profile);
}
}
exports.SAML = SAML;