PageRenderTime 48ms CodeModel.GetById 17ms RepoModel.GetById 0ms app.codeStats 1ms

/packages/meteor-accounts-saml/saml_utils.js

https://gitlab.com/brandnewcampaign/townsquare-chat
JavaScript | 519 lines | 403 code | 83 blank | 33 comment | 84 complexity | 6134b1a76105b2cf1c7e79c119c66172 MD5 | raw file
  1. /* globals SAML:true */
  2. var zlib = Npm.require('zlib');
  3. var xml2js = Npm.require('xml2js');
  4. var xmlCrypto = Npm.require('xml-crypto');
  5. var crypto = Npm.require('crypto');
  6. var xmldom = Npm.require('xmldom');
  7. var querystring = Npm.require('querystring');
  8. var xmlbuilder = Npm.require('xmlbuilder');
  9. // var xmlenc = Npm.require('xml-encryption');
  10. // var xpath = xmlCrypto.xpath;
  11. // var Dom = xmldom.DOMParser;
  12. // var prefixMatch = new RegExp(/(?!xmlns)^.*:/);
  13. SAML = function (options) {
  14. this.options = this.initialize(options);
  15. };
  16. // var stripPrefix = function (str) {
  17. // return str.replace(prefixMatch, '');
  18. // };
  19. SAML.prototype.initialize = function (options) {
  20. if (!options) {
  21. options = {};
  22. }
  23. if (!options.protocol) {
  24. options.protocol = 'https://';
  25. }
  26. if (!options.path) {
  27. options.path = '/saml/consume';
  28. }
  29. if (!options.issuer) {
  30. options.issuer = 'onelogin_saml';
  31. }
  32. if (options.identifierFormat === undefined) {
  33. options.identifierFormat = 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress';
  34. }
  35. if (options.authnContext === undefined) {
  36. options.authnContext = 'urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport';
  37. }
  38. return options;
  39. };
  40. SAML.prototype.generateUniqueID = function () {
  41. var chars = 'abcdef0123456789';
  42. var uniqueID = '';
  43. for (var i = 0; i < 20; i++) {
  44. uniqueID += chars.substr(Math.floor((Math.random() * 15)), 1);
  45. }
  46. return uniqueID;
  47. };
  48. SAML.prototype.generateInstant = function () {
  49. return new Date().toISOString();
  50. };
  51. SAML.prototype.signRequest = function (xml) {
  52. var signer = crypto.createSign('RSA-SHA1');
  53. signer.update(xml);
  54. return signer.sign(this.options.privateKey, 'base64');
  55. };
  56. SAML.prototype.generateAuthorizeRequest = function (req) {
  57. var id = '_' + this.generateUniqueID();
  58. var instant = this.generateInstant();
  59. // Post-auth destination
  60. var callbackUrl;
  61. if (this.options.callbackUrl) {
  62. callbackUrl = this.options.callbackUrl;
  63. } else {
  64. callbackUrl = this.options.protocol + req.headers.host + this.options.path;
  65. }
  66. if (this.options.id) {
  67. id = this.options.id;
  68. }
  69. var request =
  70. '<samlp:AuthnRequest xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" ID="' + id + '" Version="2.0" IssueInstant="' + instant +
  71. '" ProtocolBinding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" AssertionConsumerServiceURL="' + callbackUrl + '" Destination="' +
  72. this.options.entryPoint + '">' +
  73. '<saml:Issuer xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion">' + this.options.issuer + '</saml:Issuer>\n';
  74. if (this.options.identifierFormat) {
  75. request += '<samlp:NameIDPolicy xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" Format="' + this.options.identifierFormat +
  76. '" AllowCreate="true"></samlp:NameIDPolicy>\n';
  77. }
  78. request +=
  79. '<samlp:RequestedAuthnContext xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" Comparison="exact">' +
  80. '<saml:AuthnContextClassRef xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion">urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport</saml:AuthnContextClassRef></samlp:RequestedAuthnContext>\n' +
  81. '</samlp:AuthnRequest>';
  82. return request;
  83. };
  84. SAML.prototype.generateLogoutRequest = function (options) {
  85. // options should be of the form
  86. // nameId: <nameId as submitted during SAML SSO>
  87. // sessionIndex: sessionIndex
  88. // --- NO SAMLsettings: <Meteor.setting.saml entry for the provider you want to SLO from
  89. var id = '_' + this.generateUniqueID();
  90. var instant = this.generateInstant();
  91. var request = '<samlp:LogoutRequest xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" ' +
  92. 'xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" ID="' + id + '" Version="2.0" IssueInstant="' + instant +
  93. '" Destination="' + this.options.idpSLORedirectURL + '">' +
  94. '<saml:Issuer xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion">' + this.options.issuer + '</saml:Issuer>' +
  95. '<saml:NameID Format="' + this.options.identifierFormat + '">' + options.nameID + '</saml:NameID>' +
  96. '</samlp:LogoutRequest>';
  97. request = '<samlp:LogoutRequest xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" ' +
  98. 'ID="' + id + '" ' +
  99. 'Version="2.0" ' +
  100. 'IssueInstant="' + instant + '" ' +
  101. 'Destination="' + this.options.idpSLORedirectURL + '" ' +
  102. '>' +
  103. '<saml:Issuer xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion">' + this.options.issuer + '</saml:Issuer>' +
  104. '<saml:NameID xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" ' +
  105. 'NameQualifier="http://id.init8.net:8080/openam" ' +
  106. 'SPNameQualifier="' + this.options.issuer + '" ' +
  107. 'Format="' + this.options.identifierFormat + '">' +
  108. options.nameID + '</saml:NameID>' +
  109. '<samlp:SessionIndex xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol">' + options.sessionIndex + '</samlp:SessionIndex>' +
  110. '</samlp:LogoutRequest>';
  111. if (Meteor.settings.debug) {
  112. console.log('------- SAML Logout request -----------');
  113. console.log(request);
  114. }
  115. return {
  116. request: request,
  117. id: id
  118. };
  119. };
  120. SAML.prototype.requestToUrl = function (request, operation, callback) {
  121. var self = this;
  122. zlib.deflateRaw(request, function (err, buffer) {
  123. if (err) {
  124. return callback(err);
  125. }
  126. var base64 = buffer.toString('base64');
  127. var target = self.options.entryPoint;
  128. if (operation === 'logout') {
  129. if (self.options.idpSLORedirectURL) {
  130. target = self.options.idpSLORedirectURL;
  131. }
  132. }
  133. if (target.indexOf('?') > 0) {
  134. target += '&';
  135. } else {
  136. target += '?';
  137. }
  138. var samlRequest = {
  139. SAMLRequest: base64
  140. };
  141. if (self.options.privateCert) {
  142. samlRequest.SigAlg = 'http://www.w3.org/2000/09/xmldsig#rsa-sha1';
  143. samlRequest.Signature = self.signRequest(querystring.stringify(samlRequest));
  144. }
  145. // TBD. We should really include a proper RelayState here
  146. var relayState;
  147. if (operation === 'logout') {
  148. // in case of logout we want to be redirected back to the Meteor app.
  149. relayState = Meteor.absoluteUrl();
  150. } else {
  151. relayState = self.options.provider;
  152. }
  153. target += querystring.stringify(samlRequest) + '&RelayState=' + relayState;
  154. if (Meteor.settings.debug) {
  155. console.log('requestToUrl: ' + target);
  156. }
  157. if (operation === 'logout') {
  158. // in case of logout we want to be redirected back to the Meteor app.
  159. return callback(null, target);
  160. } else {
  161. callback(null, target);
  162. }
  163. });
  164. };
  165. SAML.prototype.getAuthorizeUrl = function (req, callback) {
  166. var request = this.generateAuthorizeRequest(req);
  167. this.requestToUrl(request, 'authorize', callback);
  168. };
  169. SAML.prototype.getLogoutUrl = function (req, callback) {
  170. var request = this.generateLogoutRequest(req);
  171. this.requestToUrl(request, 'logout', callback);
  172. };
  173. SAML.prototype.certToPEM = function (cert) {
  174. cert = cert.match(/.{1,64}/g).join('\n');
  175. cert = '-----BEGIN CERTIFICATE-----\n' + cert;
  176. cert = cert + '\n-----END CERTIFICATE-----\n';
  177. return cert;
  178. };
  179. // function findChilds(node, localName, namespace) {
  180. // var res = [];
  181. // for (var i = 0; i < node.childNodes.length; i++) {
  182. // var child = node.childNodes[i];
  183. // if (child.localName === localName && (child.namespaceURI === namespace || !namespace)) {
  184. // res.push(child);
  185. // }
  186. // }
  187. // return res;
  188. // }
  189. SAML.prototype.validateSignature = function (xml, cert) {
  190. var self = this;
  191. var doc = new xmldom.DOMParser().parseFromString(xml);
  192. var signature = xmlCrypto.xpath(doc, '//*[local-name(.)=\'Signature\' and namespace-uri(.)=\'http://www.w3.org/2000/09/xmldsig#\']')[0];
  193. var sig = new xmlCrypto.SignedXml();
  194. sig.keyInfoProvider = {
  195. getKeyInfo: function (/*key*/) {
  196. return '<X509Data></X509Data>';
  197. },
  198. getKey: function (/*keyInfo*/) {
  199. return self.certToPEM(cert);
  200. }
  201. };
  202. sig.loadSignature(signature);
  203. return sig.checkSignature(xml);
  204. };
  205. SAML.prototype.getElement = function (parentElement, elementName) {
  206. if (parentElement['saml:' + elementName]) {
  207. return parentElement['saml:' + elementName];
  208. } else if (parentElement['samlp:' + elementName]) {
  209. return parentElement['samlp:' + elementName];
  210. } else if (parentElement['saml2p:' + elementName]) {
  211. return parentElement['saml2p:' + elementName];
  212. } else if (parentElement['saml2:' + elementName]) {
  213. return parentElement['saml2:' + elementName];
  214. }
  215. return parentElement[elementName];
  216. };
  217. SAML.prototype.validateLogoutResponse = function (samlResponse, callback) {
  218. var self = this;
  219. var compressedSAMLResponse = new Buffer(samlResponse, 'base64');
  220. zlib.inflateRaw(compressedSAMLResponse, function (err, decoded) {
  221. if (err) {
  222. if (Meteor.settings.debug) {
  223. console.log(err);
  224. }
  225. } else {
  226. var parser = new xml2js.Parser({
  227. explicitRoot: true
  228. });
  229. parser.parseString(decoded, function (err, doc) {
  230. var response = self.getElement(doc, 'LogoutResponse');
  231. if (response) {
  232. // TBD. Check if this msg corresponds to one we sent
  233. var inResponseTo = response.$.InResponseTo;
  234. if (Meteor.settings.debug) {
  235. console.log('In Response to: ' + inResponseTo);
  236. }
  237. var status = self.getElement(response, 'Status');
  238. var statusCode = self.getElement(status[0], 'StatusCode')[0].$.Value;
  239. if (Meteor.settings.debug) {
  240. console.log('StatusCode: ' + JSON.stringify(statusCode));
  241. }
  242. if (statusCode === 'urn:oasis:names:tc:SAML:2.0:status:Success') {
  243. // In case of a successful logout at IDP we return inResponseTo value.
  244. // This is the only way how we can identify the Meteor user (as we don't use Session Cookies)
  245. callback(null, inResponseTo);
  246. } else {
  247. callback('Error. Logout not confirmed by IDP', null);
  248. }
  249. } else {
  250. callback('No Response Found', null);
  251. }
  252. });
  253. }
  254. });
  255. };
  256. SAML.prototype.validateResponse = function (samlResponse, relayState, callback) {
  257. var self = this;
  258. var xml = new Buffer(samlResponse, 'base64').toString('ascii');
  259. // We currently use RelayState to save SAML provider
  260. if (Meteor.settings.debug) {
  261. console.log('Validating response with relay state: ' + xml);
  262. }
  263. var parser = new xml2js.Parser({
  264. explicitRoot: true
  265. });
  266. parser.parseString(xml, function (err, doc) {
  267. // Verify signature
  268. if (Meteor.settings.debug) {
  269. console.log('Verify signature');
  270. }
  271. if (self.options.cert && !self.validateSignature(xml, self.options.cert)) {
  272. if (Meteor.settings.debug) {
  273. console.log('Signature WRONG');
  274. }
  275. return callback(new Error('Invalid signature'), null, false);
  276. }
  277. if (Meteor.settings.debug) {
  278. console.log('Signature OK');
  279. }
  280. var response = self.getElement(doc, 'Response');
  281. if (Meteor.settings.debug) {
  282. console.log('Got response');
  283. }
  284. if (response) {
  285. var assertion = self.getElement(response, 'Assertion');
  286. if (!assertion) {
  287. return callback(new Error('Missing SAML assertion'), null, false);
  288. }
  289. var profile = {};
  290. if (response.$ && response.$.InResponseTo) {
  291. profile.inResponseToId = response.$.InResponseTo;
  292. }
  293. var issuer = self.getElement(assertion[0], 'Issuer');
  294. if (issuer) {
  295. profile.issuer = issuer[0];
  296. }
  297. var subject = self.getElement(assertion[0], 'Subject');
  298. if (subject) {
  299. var nameID = self.getElement(subject[0], 'NameID');
  300. if (nameID) {
  301. profile.nameID = nameID[0]._;
  302. if (nameID[0].$.Format) {
  303. profile.nameIDFormat = nameID[0].$.Format;
  304. }
  305. }
  306. }
  307. var authnStatement = self.getElement(assertion[0], 'AuthnStatement');
  308. if (authnStatement) {
  309. if (authnStatement[0].$.SessionIndex) {
  310. profile.sessionIndex = authnStatement[0].$.SessionIndex;
  311. if (Meteor.settings.debug) {
  312. console.log('Session Index: ' + profile.sessionIndex);
  313. }
  314. } else {
  315. if (Meteor.settings.debug) {
  316. console.log('No Session Index Found');
  317. }
  318. }
  319. } else {
  320. if (Meteor.settings.debug) {
  321. console.log('No AuthN Statement found');
  322. }
  323. }
  324. var attributeStatement = self.getElement(assertion[0], 'AttributeStatement');
  325. if (attributeStatement) {
  326. var attributes = self.getElement(attributeStatement[0], 'Attribute');
  327. if (attributes) {
  328. attributes.forEach(function (attribute) {
  329. var value = self.getElement(attribute, 'AttributeValue');
  330. if (typeof value[0] === 'string') {
  331. profile[attribute.$.Name] = value[0];
  332. } else {
  333. profile[attribute.$.Name] = value[0]._;
  334. }
  335. });
  336. }
  337. if (!profile.mail && profile['urn:oid:0.9.2342.19200300.100.1.3']) {
  338. // See http://www.incommonfederation.org/attributesummary.html for definition of attribute OIDs
  339. profile.mail = profile['urn:oid:0.9.2342.19200300.100.1.3'];
  340. }
  341. if (!profile.email && profile.mail) {
  342. profile.email = profile.mail;
  343. }
  344. }
  345. if (!profile.email && profile.nameID && profile.nameIDFormat && profile.nameIDFormat.indexOf('emailAddress') >= 0) {
  346. profile.email = profile.nameID;
  347. }
  348. if (Meteor.settings.debug) {
  349. console.log('NameID: ' + JSON.stringify(profile));
  350. }
  351. callback(null, profile, false);
  352. } else {
  353. var logoutResponse = self.getElement(doc, 'LogoutResponse');
  354. if (logoutResponse) {
  355. callback(null, null, true);
  356. } else {
  357. return callback(new Error('Unknown SAML response message'), null, false);
  358. }
  359. }
  360. });
  361. };
  362. var decryptionCert;
  363. SAML.prototype.generateServiceProviderMetadata = function (callbackUrl) {
  364. var keyDescriptor = null;
  365. if (!decryptionCert) {
  366. decryptionCert = this.options.privateCert;
  367. }
  368. if (this.options.privateKey) {
  369. if (!decryptionCert) {
  370. throw new Error(
  371. 'Missing decryptionCert while generating metadata for decrypting service provider');
  372. }
  373. decryptionCert = decryptionCert.replace(/-+BEGIN CERTIFICATE-+\r?\n?/, '');
  374. decryptionCert = decryptionCert.replace(/-+END CERTIFICATE-+\r?\n?/, '');
  375. decryptionCert = decryptionCert.replace(/\r\n/g, '\n');
  376. keyDescriptor = {
  377. 'ds:KeyInfo': {
  378. 'ds:X509Data': {
  379. 'ds:X509Certificate': {
  380. '#text': decryptionCert
  381. }
  382. }
  383. },
  384. '#list': [
  385. // this should be the set that the xmlenc library supports
  386. {
  387. 'EncryptionMethod': {
  388. '@Algorithm': 'http://www.w3.org/2001/04/xmlenc#aes256-cbc'
  389. }
  390. },
  391. {
  392. 'EncryptionMethod': {
  393. '@Algorithm': 'http://www.w3.org/2001/04/xmlenc#aes128-cbc'
  394. }
  395. },
  396. {
  397. 'EncryptionMethod': {
  398. '@Algorithm': 'http://www.w3.org/2001/04/xmlenc#tripledes-cbc'
  399. }
  400. },
  401. ]
  402. };
  403. }
  404. if (!this.options.callbackUrl && !callbackUrl) {
  405. throw new Error(
  406. 'Unable to generate service provider metadata when callbackUrl option is not set');
  407. }
  408. var metadata = {
  409. 'EntityDescriptor': {
  410. '@xmlns': 'urn:oasis:names:tc:SAML:2.0:metadata',
  411. '@xmlns:ds': 'http://www.w3.org/2000/09/xmldsig#',
  412. '@entityID': this.options.issuer,
  413. 'SPSSODescriptor': {
  414. '@protocolSupportEnumeration': 'urn:oasis:names:tc:SAML:2.0:protocol',
  415. 'KeyDescriptor': keyDescriptor,
  416. 'SingleLogoutService': {
  417. '@Binding': 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect',
  418. '@Location': Meteor.absoluteUrl() + '_saml/logout/' + this.options.provider + '/',
  419. '@ResponseLocation': Meteor.absoluteUrl() + '_saml/logout/' + this.options.provider + '/'
  420. },
  421. 'NameIDFormat': this.options.identifierFormat,
  422. 'AssertionConsumerService': {
  423. '@index': '1',
  424. '@isDefault': 'true',
  425. '@Binding': 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST',
  426. '@Location': callbackUrl
  427. }
  428. }
  429. }
  430. };
  431. return xmlbuilder.create(metadata).end({
  432. pretty: true,
  433. indent: ' ',
  434. newline: '\n'
  435. });
  436. };