var zlib = Npm.require('zlib'); var xml2js = Npm.require('xml2js'); var xmlCrypto = Npm.require('xml-crypto'); var crypto = Npm.require('crypto'); var xmldom = Npm.require('xmldom'); var querystring = Npm.require('querystring'); var xmlbuilder = Npm.require('xmlbuilder'); var xmlenc = Npm.require('xml-encryption'); var xpath = xmlCrypto.xpath; var Dom = xmldom.DOMParser; 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 () { var chars = "abcdef0123456789"; var uniqueID = ""; for (var 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) { var signer = crypto.createSign('RSA-SHA1'); signer.update(xml); return signer.sign(this.options.privateKey, 'base64'); } SAML.prototype.generateAuthorizeRequest = function (req) { var id = "_" + this.generateUniqueID(); var instant = this.generateInstant(); // Post-auth destination if (this.options.callbackUrl) { callbackUrl = this.options.callbackUrl; } else { var callbackUrl = this.options.protocol + req.headers.host + this.options.path; } if (this.options.id) id = this.options.id; var 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: request, id: id }; } SAML.prototype.requestToUrl = function (request, operation, callback) { var self = this; var result; zlib.deflateRaw(request, function (err, buffer) { if (err) { return callback(err); } var base64 = buffer.toString('base64'); var target = self.options.entryPoint; if (operation === 'logout') { if (self.options.idpSLORedirectURL) { target = self.options.idpSLORedirectURL; } } if (target.indexOf('?') > 0) target += '&'; else target += '?'; var samlRequest = { SAMLRequest: base64 }; if (self.options.privateCert) { samlRequest.SigAlg = 'http://www.w3.org/2000/09/xmldsig#rsa-sha1'; samlRequest.Signature = self.signRequest(querystring.stringify(samlRequest)); } // TBD. We should really include a proper RelayState here if (operation === 'logout') { // in case of logout we want to be redirected back to the Meteor app. var relayState = Meteor.absoluteUrl(); } else { var relayState = self.options.provider; } target += querystring.stringify(samlRequest) + "&RelayState=" + relayState; 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. result = target; return callback(null, target); } else { callback(null, target); } }); } SAML.prototype.getAuthorizeUrl = function (req, callback) { var request = this.generateAuthorizeRequest(req); this.requestToUrl(request, 'authorize', callback); }; SAML.prototype.getLogoutUrl = function (req, callback) { var 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; }; function findChilds(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.validateSignature = function (xml, cert) { var self = this; var doc = new xmldom.DOMParser().parseFromString(xml); var signature = xmlCrypto.xpath(doc, "//*[local-name(.)='Signature' and namespace-uri(.)='http://www.w3.org/2000/09/xmldsig#']")[0]; var sig = new xmlCrypto.SignedXml(); sig.keyInfoProvider = { getKeyInfo: function (key) { return "" }, getKey: function (keyInfo) { return self.certToPEM(cert); } }; sig.loadSignature(signature); return sig.checkSignature(xml); }; SAML.prototype.getElement = function (parentElement, elementName) { if (parentElement['saml:' + elementName]) { return parentElement['saml:' + elementName]; } else if (parentElement['samlp:' + elementName]) { return parentElement['samlp:' + elementName]; } else if (parentElement['saml2p:' + elementName]) { return parentElement['saml2p:' + elementName]; } else if (parentElement['saml2:' + elementName]) { return parentElement['saml2:' + elementName]; } return parentElement[elementName]; } SAML.prototype.validateLogoutResponse = function (samlResponse, callback) { var self = this; var compressedSAMLResponse = new Buffer(samlResponse, 'base64'); zlib.inflateRaw(compressedSAMLResponse, function (err, decoded) { if (err) { if (Meteor.settings.debug) { console.log(err) } } else { var parser = new xml2js.Parser({ explicitRoot: true }); parser.parseString(decoded, function (err, doc) { var response = self.getElement(doc, 'LogoutResponse'); if (response) { // TBD. Check if this msg corresponds to one we sent var inResponseTo = response['$'].InResponseTo; if (Meteor.settings.debug) { console.log("In Response to: " + inResponseTo); } var status = self.getElement(response, 'Status'); var statusCode = self.getElement(status[0], 'StatusCode')[0]['$'].Value; if (Meteor.settings.debug) { console.log("StatusCode: " + JSON.stringify(statusCode)); } if (statusCode === 'urn:oasis:names:tc:SAML:2.0:status:Success') { // In case of a successful logout at IDP we return inResponseTo value. // This is the only way how we can identify the Meteor user (as we don't use Session Cookies) 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) { var self = this; var xml = new Buffer(samlResponse, 'base64').toString('ascii'); // We currently use RelayState to save SAML provider if (Meteor.settings.debug) { console.log("Validating response with relay state: " + xml); } var parser = new xml2js.Parser({ explicitRoot: true }); parser.parseString(xml, function (err, doc) { // 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"); } var response = self.getElement(doc, 'Response'); if (Meteor.settings.debug) { console.log("Got response"); } if (response) { var assertion = self.getElement(response, 'Assertion'); if (!assertion) { return callback(new Error('Missing SAML assertion'), null, false); } profile = {}; if (response['$'] && response['$']['InResponseTo']) { profile.inResponseToId = response['$']['InResponseTo']; } var issuer = self.getElement(assertion[0], 'Issuer'); if (issuer) { profile.issuer = issuer[0]; } var subject = self.getElement(assertion[0], 'Subject'); if (subject) { var nameID = self.getElement(subject[0], 'NameID'); if (nameID) { profile.nameID = nameID[0]["_"]; if (nameID[0]['$'].Format) { profile.nameIDFormat = nameID[0]['$'].Format; } } } var authnStatement = self.getElement(assertion[0], 'AuthnStatement'); if (authnStatement) { if (authnStatement[0]['$'].SessionIndex) { profile.sessionIndex = authnStatement[0]['$'].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"); } } var attributeStatement = self.getElement(assertion[0], 'AttributeStatement'); if (attributeStatement) { var attributes = self.getElement(attributeStatement[0], 'Attribute'); if (attributes) { attributes.forEach(function (attribute) { var value = self.getElement(attribute, 'AttributeValue'); if (typeof value[0] === 'string') { profile[attribute['$'].Name] = value[0]; } else { profile[attribute['$'].Name] = value[0]['_']; } }); } 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; } } 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 { var logoutResponse = self.getElement(doc, 'LogoutResponse'); if (logoutResponse) { callback(null, null, true); } else { return callback(new Error('Unknown SAML response message'), null, false); } } }); }; SAML.prototype.generateServiceProviderMetadata = function (callbackUrl) { var keyDescriptor = null; if (!decryptionCert) { decryptionCert = this.options.privateCert; } 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'); keyDescriptor = { 'ds:KeyInfo': { 'ds:X509Data': { 'ds:X509Certificate': { '#text': decryptionCert } } }, '#list': [ // this should be the set that the xmlenc library supports { 'EncryptionMethod': { '@Algorithm': 'http://www.w3.org/2001/04/xmlenc#aes256-cbc' } }, { 'EncryptionMethod': { '@Algorithm': 'http://www.w3.org/2001/04/xmlenc#aes128-cbc' } }, { 'EncryptionMethod': { '@Algorithm': 'http://www.w3.org/2001/04/xmlenc#tripledes-cbc' } }, ] }; } if (!this.options.callbackUrl && !callbackUrl) { throw new Error( "Unable to generate service provider metadata when callbackUrl option is not set"); } var 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', 'KeyDescriptor': keyDescriptor, '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 } } } }; return xmlbuilder.create(metadata).end({ pretty: true, indent: ' ', newline: '\n' }); };