/* globals SAML:true */ 'use strict'; /* globals SAML:true */ const zlib = Npm.require('zlib'); const xml2js = Npm.require('xml2js'); const xmlCrypto = Npm.require('xml-crypto'); const crypto = Npm.require('crypto'); const xmldom = Npm.require('xmldom'); const querystring = Npm.require('querystring'); const xmlbuilder = Npm.require('xmlbuilder'); const array2string = Npm.require('arraybuffer-to-string'); // var prefixMatch = new RegExp(/(?!xmlns)^.*:/); SAML = function(options) { this.options = this.initialize(options); }; // var stripPrefix = function(str) { // return str.replace(prefixMatch, ''); // }; SAML.prototype.initialize = function(options) { if (!options) { options = {}; } if (!options.protocol) { options.protocol = 'https://'; } if (!options.path) { options.path = '/saml/consume'; } if (!options.issuer) { options.issuer = 'onelogin_saml'; } if (options.identifierFormat === undefined) { options.identifierFormat = 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress'; } if (options.authnContext === undefined) { options.authnContext = 'urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport'; } return options; }; SAML.prototype.generateUniqueID = function() { const chars = 'abcdef0123456789'; let uniqueID = 'id-'; for (let i = 0; i < 20; i++) { uniqueID += chars.substr(Math.floor((Math.random() * 15)), 1); } return uniqueID; }; SAML.prototype.generateInstant = function() { return new Date().toISOString(); }; SAML.prototype.signRequest = function(xml) { const signer = crypto.createSign('RSA-SHA1'); signer.update(xml); return signer.sign(this.options.privateKey, 'base64'); }; SAML.prototype.generateAuthorizeRequest = function(req) { let id = `_${ this.generateUniqueID() }`; const instant = this.generateInstant(); // Post-auth destination let callbackUrl; if (this.options.callbackUrl) { callbackUrl = this.options.callbackUrl; } else { callbackUrl = this.options.protocol + req.headers.host + this.options.path; } if (this.options.id) { id = this.options.id; } let request = `` + `${ this.options.issuer }\n`; if (this.options.identifierFormat) { request += `\n`; } request += '' + 'urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport\n' + ''; return request; }; SAML.prototype.generateLogoutRequest = function(options) { // options should be of the form // nameId: // sessionIndex: sessionIndex // --- NO SAMLsettings: ` + `${ this.options.issuer }` + `${ options.nameID }` + ''; request = `${ '' + `${ this.options.issuer }` + '${ options.nameID }` + `${ options.sessionIndex }` + ''; if (Meteor.settings.debug) { console.log('------- SAML Logout request -----------'); console.log(request); } return { request, id }; }; SAML.prototype.requestToUrl = function(request, operation, callback) { const self = this; zlib.deflateRaw(request, function(err, buffer) { if (err) { return callback(err); } const base64 = buffer.toString('base64'); let target = self.options.entryPoint; if (operation === 'logout') { if (self.options.idpSLORedirectURL) { target = self.options.idpSLORedirectURL; } } if (target.indexOf('?') > 0) { target += '&'; } else { target += '?'; } // TBD. We should really include a proper RelayState here let relayState; if (operation === 'logout') { // in case of logout we want to be redirected back to the Meteor app. relayState = Meteor.absoluteUrl(); } else { relayState = self.options.provider; } const samlRequest = { SAMLRequest: base64, RelayState: relayState }; if (self.options.privateCert) { samlRequest.SigAlg = 'http://www.w3.org/2000/09/xmldsig#rsa-sha1'; samlRequest.Signature = self.signRequest(querystring.stringify(samlRequest)); } target += querystring.stringify(samlRequest); if (Meteor.settings.debug) { console.log(`requestToUrl: ${ target }`); } if (operation === 'logout') { // in case of logout we want to be redirected back to the Meteor app. return callback(null, target); } else { callback(null, target); } }); }; SAML.prototype.getAuthorizeUrl = function(req, callback) { const request = this.generateAuthorizeRequest(req); this.requestToUrl(request, 'authorize', callback); }; SAML.prototype.getLogoutUrl = function(req, callback) { const request = this.generateLogoutRequest(req); this.requestToUrl(request, 'logout', callback); }; SAML.prototype.certToPEM = function(cert) { cert = cert.match(/.{1,64}/g).join('\n'); cert = `-----BEGIN CERTIFICATE-----\n${ cert }`; cert = `${ cert }\n-----END CERTIFICATE-----\n`; return cert; }; // functionfindChilds(node, localName, namespace) { // var res = []; // for (var i = 0; i < node.childNodes.length; i++) { // var child = node.childNodes[i]; // if (child.localName === localName && (child.namespaceURI === namespace || !namespace)) { // res.push(child); // } // } // return res; // } SAML.prototype.validateStatus = function(doc) { let successStatus = false; let status = ''; let messageText = ''; const statusNodes = doc.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:protocol', 'StatusCode'); if (statusNodes.length) { const statusNode = statusNodes[0]; const statusMessage = doc.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:protocol', 'StatusMessage')[0]; if (statusMessage) { messageText = statusMessage.firstChild.textContent; } status = statusNode.getAttribute('Value'); if (status === 'urn:oasis:names:tc:SAML:2.0:status:Success') { successStatus = true; } } return { success: successStatus, message: messageText, statusCode: status }; }; SAML.prototype.validateSignature = function(xml, cert) { const self = this; const doc = new xmldom.DOMParser().parseFromString(xml); const signature = xmlCrypto.xpath(doc, '//*[local-name(.)=\'Signature\' and namespace-uri(.)=\'http://www.w3.org/2000/09/xmldsig#\']')[0]; const sig = new xmlCrypto.SignedXml(); sig.keyInfoProvider = { getKeyInfo( /*key*/ ) { return ''; }, getKey( /*keyInfo*/ ) { return self.certToPEM(cert); } }; sig.loadSignature(signature); return sig.checkSignature(xml); }; SAML.prototype.validateLogoutResponse = function(samlResponse, callback) { const self = this; const compressedSAMLResponse = new Buffer(samlResponse, 'base64'); zlib.inflateRaw(compressedSAMLResponse, function(err, decoded) { if (err) { if (Meteor.settings.debug) { console.log("Error while inflating." + err); } } else { console.log("construvting new DOM parser: " + Object.prototype.toString.call(decoded)); console.log(">>>>" + decoded); const doc = new xmldom.DOMParser().parseFromString(array2string(decoded), 'text/xml'); if (doc) { const response = doc.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:protocol', 'LogoutResponse')[0]; if (response) { // TBD. Check if this msg corresponds to one we sent var inResponseTo; try { inResponseTo = response.getAttribute('InResponseTo'); if (Meteor.settings.debug) { console.log(`In Response to: ${ inResponseTo }`); } } catch (e) { if (Meteor.settings.debug) { console.log("Caught error: " + e); const msg = doc.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:protocol', 'StatusMessage'); console.log("Unexpected msg from IDP. Does your session still exist at IDP? Idp returned: \n" + msg); } } let statusValidateObj = self.validateStatus(doc); if (statusValidateObj.success) { callback(null, inResponseTo); } else { callback('Error. Logout not confirmed by IDP', null); } } else { callback('No Response Found', null); } } } }); }; SAML.prototype.validateResponse = function(samlResponse, relayState, callback) { const self = this; const xml = new Buffer(samlResponse, 'base64').toString('utf8'); // We currently use RelayState to save SAML provider if (Meteor.settings.debug) { console.log(`Validating response with relay state: ${ xml }`); } const parser = new xml2js.Parser({ explicitRoot: true }); const doc = new xmldom.DOMParser().parseFromString(xml, 'text/xml'); if (doc) { if (Meteor.settings.debug) { console.log('Verify status'); } let statusValidateObj = self.validateStatus(doc); if (statusValidateObj.success) { if (Meteor.settings.debug) { console.log('Status ok'); } // Verify signature if (Meteor.settings.debug) { console.log('Verify signature'); } if (self.options.cert && !self.validateSignature(xml, self.options.cert)) { if (Meteor.settings.debug) { console.log('Signature WRONG'); } return callback(new Error('Invalid signature'), null, false); } if (Meteor.settings.debug) { console.log('Signature OK'); } const response = doc.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:protocol', 'Response')[0]; if (response) { if (Meteor.settings.debug) { console.log('Got response'); } var assertion = response.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:assertion', 'Assertion')[0]; const encAssertion = response.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:assertion', 'EncryptedAssertion')[0]; var xmlenc = Npm.require('xml-encryption'); var options = { key: this.options.privateKey}; if (typeof encAssertion !== 'undefined') { xmlenc.decrypt(encAssertion.getElementsByTagNameNS('*', 'EncryptedData')[0], options, function(err, result) { assertion = new xmldom.DOMParser().parseFromString(result, 'text/xml'); }); } if (!assertion) { return callback(new Error('Missing SAML assertion'), null, false); } const profile = {}; if (response.hasAttribute('InResponseTo')) { profile.inResponseToId = response.getAttribute('InResponseTo'); } const issuer = assertion.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:assertion', 'Issuer')[0]; if (issuer) { profile.issuer = issuer.textContent; } var subject = assertion.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:assertion', 'Subject')[0]; const encSubject = assertion.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:assertion', 'EncryptedID')[0]; if (typeof encSubject !== 'undefined') { xmlenc.decrypt(encSubject.getElementsByTagNameNS('*', 'EncryptedData')[0], options, function(err, result) { subject = new xmldom.DOMParser().parseFromString(result, 'text/xml'); }); } if (subject) { const nameID = subject.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:assertion', 'NameID')[0]; if (nameID) { profile.nameID = nameID.textContent; if (nameID.hasAttribute('Format')) { profile.nameIDFormat = nameID.getAttribute('Format'); } } } const authnStatement = assertion.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:assertion', 'AuthnStatement')[0]; if (authnStatement) { if (authnStatement.hasAttribute('SessionIndex')) { profile.sessionIndex = authnStatement.getAttribute('SessionIndex'); if (Meteor.settings.debug) { console.log(`Session Index: ${ profile.sessionIndex }`); } } else if (Meteor.settings.debug) { console.log('No Session Index Found'); } } else if (Meteor.settings.debug) { console.log('No AuthN Statement found'); } const attributeStatement = assertion.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:assertion', 'AttributeStatement')[0]; if (attributeStatement) { if (Meteor.settings.debug) { console.log("Attribute Statement found in SAML response: " + attributeStatement); } const attributes = attributeStatement.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:assertion', 'Attribute'); if (Meteor.settings.debug) { console.log("Attributes will be processed: " + attributes.length); } if (attributes) { for (let i = 0; i < attributes.length; i++) { const values = attributes[i].getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:assertion', 'AttributeValue'); let value; if (values.length === 1) { value = values[0].textContent; } else { value = []; for (var attributeValue of values) { value.push(attributeValue.textContent); } } if (Meteor.settings.debug) { console.log("Name: " + attributes[i]); console.log(`Adding attrinute from SAML response to profile:` + attributes[i].getAttribute('Name') + " = " + value.textContent); } profile[attributes[i].getAttribute('Name')] = value.textContent; } } else { if (Meteor.settings.debug) { console.log("No Attributes found in SAML attribute statement."); } } if (!profile.mail && profile['urn:oid:0.9.2342.19200300.100.1.3']) { // See http://www.incommonfederation.org/attributesummary.html for definition of attribute OIDs profile.mail = profile['urn:oid:0.9.2342.19200300.100.1.3']; } if (!profile.email && profile.mail) { profile.email = profile.mail; } } else { if (Meteor.settings.debug) { console.log("No Attribute Statement found in SAML response."); } } if (!profile.email && profile.nameID && profile.nameIDFormat && profile.nameIDFormat.indexOf('emailAddress') >= 0) { profile.email = profile.nameID; } if (Meteor.settings.debug) { console.log(`NameID: ${ JSON.stringify(profile) }`); } callback(null, profile, false); } else { const logoutResponse = doc.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:protocol', 'LogoutResponse'); if (logoutResponse) { callback(null, null, true); } else { return callback(new Error('Unknown SAML response message'), null, false); } } } else { return callback(new Error(`Status is: ${ statusValidateObj.statusCode }`), null, false); } } }; let decryptionCert; SAML.prototype.generateServiceProviderMetadata = function(callbackUrl) { if (!decryptionCert) { decryptionCert = this.options.privateCert; } if (!this.options.callbackUrl && !callbackUrl) { throw new Error( 'Unable to generate service provider metadata when callbackUrl option is not set'); } const metadata = { 'EntityDescriptor': { '@xmlns': 'urn:oasis:names:tc:SAML:2.0:metadata', '@xmlns:ds': 'http://www.w3.org/2000/09/xmldsig#', '@entityID': this.options.issuer, 'SPSSODescriptor': { '@protocolSupportEnumeration': 'urn:oasis:names:tc:SAML:2.0:protocol', 'SingleLogoutService': { '@Binding': 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect', '@Location': `${ Meteor.absoluteUrl() }_saml/logout/${ this.options.provider }/`, '@ResponseLocation': `${ Meteor.absoluteUrl() }_saml/logout/${ this.options.provider }/` }, 'NameIDFormat': this.options.identifierFormat, 'AssertionConsumerService': { '@index': '1', '@isDefault': 'true', '@Binding': 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST', '@Location': callbackUrl } } } }; if (this.options.privateKey) { if (!decryptionCert) { throw new Error( 'Missing decryptionCert while generating metadata for decrypting service provider'); } decryptionCert = decryptionCert.replace(/-+BEGIN CERTIFICATE-+\r?\n?/, ''); decryptionCert = decryptionCert.replace(/-+END CERTIFICATE-+\r?\n?/, ''); decryptionCert = decryptionCert.replace(/\r\n/g, '\n'); metadata['EntityDescriptor']['SPSSODescriptor']['KeyDescriptor'] = { 'ds:KeyInfo': { 'ds:X509Data': { 'ds:X509Certificate': { '#text': decryptionCert } } }, 'EncryptionMethod': [ // this should be the set that the xmlenc library supports { '@Algorithm': 'http://www.w3.org/2001/04/xmlenc#aes256-cbc' }, { '@Algorithm': 'http://www.w3.org/2001/04/xmlenc#aes128-cbc' }, { '@Algorithm': 'http://www.w3.org/2001/04/xmlenc#tripledes-cbc' } ] }; } return xmlbuilder.create(metadata).end({ pretty: true, indent: ' ', newline: '\n' }); };