SAMLHelper.mjs 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254
  1. import fs from 'node:fs'
  2. import path from 'node:path'
  3. import { SignedXml } from 'xml-crypto'
  4. import { SamlLog } from '../../../../app/src/models/SamlLog.mjs'
  5. import { expect } from 'chai'
  6. import zlib from 'node:zlib'
  7. import { fileURLToPath } from 'node:url'
  8. import xml2js from 'xml2js'
  9. const __dirname = fileURLToPath(new URL('.', import.meta.url))
  10. const samlDataDefaults = {
  11. firstName: 'first-name',
  12. hasEntitlement: 'Y',
  13. issuer: 'https://www.overleaf.test/saml/ukamf/sp/entity',
  14. lastName: 'last-name',
  15. requestId: 'dummy-request-id',
  16. }
  17. function samlValue(val) {
  18. if (!Array.isArray(val)) {
  19. val = [val]
  20. }
  21. return val
  22. .map(
  23. v =>
  24. `<saml:AttributeValue xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:type="xs:string">${v}</saml:AttributeValue>`
  25. )
  26. .join('')
  27. }
  28. function makeAttribute(attribute, value) {
  29. if (!value) {
  30. return ''
  31. }
  32. return `<saml:AttributeStatement>
  33. <saml:Attribute Name="${attribute}" NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:unspecified">
  34. ${samlValue(value)}
  35. </saml:Attribute>
  36. </saml:AttributeStatement>`
  37. }
  38. function createMockSamlAssertion(samlData = {}, opts = {}) {
  39. const {
  40. email,
  41. firstName,
  42. hasEntitlement,
  43. issuer,
  44. lastName,
  45. uniqueId,
  46. requestId,
  47. } = {
  48. ...samlDataDefaults,
  49. ...samlData,
  50. }
  51. const { signedAssertion = true } = opts
  52. const userIdAttributeName = samlData.userIdAttribute || 'uniqueId'
  53. const userIdAttribute =
  54. uniqueId &&
  55. `<saml:AttributeStatement>
  56. <saml:Attribute Name="${userIdAttributeName}" NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:unspecified">
  57. <saml:AttributeValue xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:type="xs:string">${uniqueId}</saml:AttributeValue>
  58. </saml:Attribute>
  59. </saml:AttributeStatement>`
  60. const userIdAttributeLegacy =
  61. samlData.userIdAttributeLegacy && samlData.uniqueIdLegacy
  62. ? `<saml:AttributeStatement><saml:Attribute Name="${samlData.userIdAttributeLegacy}" NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:unspecified"><saml:AttributeValue xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:type="xs:string">${samlData.uniqueIdLegacy}</saml:AttributeValue></saml:Attribute></saml:AttributeStatement>`
  63. : ''
  64. const nameId =
  65. userIdAttributeName && userIdAttributeName !== 'nameID'
  66. ? `<saml:NameID Format="urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified">mock@email.com</saml:NameID>`
  67. : ''
  68. const samlAssertion = `<saml:Assertion xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" ID="t835VaiI1fph1yk8yhdD4OtyBQ4" IssueInstant="2018-08-09T08:56:30.126Z" Version="2.0">
  69. <saml:Issuer>${issuer}</saml:Issuer>
  70. <saml:Subject>
  71. ${nameId}
  72. <saml:SubjectConfirmation Method="urn:oasis:names:tc:SAML:2.0:cm:bearer">
  73. <saml:SubjectConfirmationData Recipient="*******" NotOnOrAfter="2028-08-09T09:01:30.126Z" InResponseTo="${requestId}" />
  74. </saml:SubjectConfirmation>
  75. </saml:Subject>
  76. <saml:Conditions NotBefore="2008-08-09T08:51:30.126Z" NotOnOrAfter="2028-08-09T09:01:30.126Z">
  77. <saml:AudienceRestriction>
  78. <saml:Audience>${issuer}</saml:Audience>
  79. </saml:AudienceRestriction>
  80. </saml:Conditions>
  81. <saml:AuthnStatement SessionIndex="t835VaiI1fph1yk8yhdD4OtyBQ4" AuthnInstant="2018-08-09T08:56:30.118Z">
  82. <saml:AuthnContext>
  83. <saml:AuthnContextClassRef>urn:oasis:names:tc:SAML:2.0:ac:classes:unspecified</saml:AuthnContextClassRef>
  84. </saml:AuthnContext>
  85. </saml:AuthnStatement>
  86. ${makeAttribute('email', email)}
  87. ${makeAttribute('firstName', firstName)}
  88. <saml:AttributeStatement>
  89. <saml:Attribute Name="hasEntitlement" NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:unspecified">
  90. <saml:AttributeValue xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:type="xs:string">${hasEntitlement}</saml:AttributeValue>
  91. </saml:Attribute>
  92. </saml:AttributeStatement>
  93. <saml:AttributeStatement>
  94. <saml:Attribute Name="issuer" NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:unspecified">
  95. <saml:AttributeValue xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:type="xs:string">${issuer}</saml:AttributeValue>
  96. </saml:Attribute>
  97. </saml:AttributeStatement>
  98. ${makeAttribute('lastName', lastName)}
  99. ${userIdAttribute}
  100. ${userIdAttributeLegacy}
  101. </saml:Assertion>`
  102. if (!signedAssertion) {
  103. return samlAssertion
  104. }
  105. const sig = new SignedXml()
  106. sig.addReference(
  107. "//*[local-name(.)='Assertion']",
  108. [
  109. 'http://www.w3.org/2000/09/xmldsig#enveloped-signature',
  110. 'http://www.w3.org/2001/10/xml-exc-c14n#',
  111. ],
  112. 'http://www.w3.org/2000/09/xmldsig#sha1'
  113. )
  114. sig.signingKey = fs.readFileSync(
  115. path.resolve(__dirname, '../../files/saml-key.pem'),
  116. 'utf8'
  117. )
  118. sig.computeSignature(samlAssertion)
  119. return sig.getSignedXml()
  120. }
  121. function createMockSamlResponse(samlData = {}, opts = {}) {
  122. const { issuer, requestId } = {
  123. ...samlDataDefaults,
  124. ...samlData,
  125. }
  126. const { signedResponse = true } = opts
  127. const samlAssertion = createMockSamlAssertion(samlData, opts)
  128. let samlResponse = `
  129. <?xml version="1.0" encoding="UTF-8"?>
  130. <samlp:Response xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" Version="2.0" ID="WQMXUw8BBp4_XWzcuKgaN5tmxpT" IssueInstant="2018-08-09T08:56:30.106Z" InResponseTo="${requestId}">
  131. <saml:Issuer xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion">${issuer}</saml:Issuer>
  132. <samlp:Status>
  133. <samlp:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success" />
  134. </samlp:Status>
  135. ${samlAssertion}
  136. </samlp:Response>
  137. `
  138. if (signedResponse) {
  139. const sig = new SignedXml()
  140. sig.addReference(
  141. "//*[local-name(.)='Response']",
  142. [
  143. 'http://www.w3.org/2000/09/xmldsig#enveloped-signature',
  144. 'http://www.w3.org/2001/10/xml-exc-c14n#',
  145. ],
  146. 'http://www.w3.org/2000/09/xmldsig#sha1'
  147. )
  148. sig.signingKey = fs.readFileSync(
  149. path.resolve(__dirname, '../../files/saml-key.pem'),
  150. 'utf8'
  151. )
  152. sig.computeSignature(samlResponse)
  153. samlResponse = sig.getSignedXml()
  154. }
  155. return Buffer.from(samlResponse).toString('base64')
  156. }
  157. function samlUniversity(config = {}) {
  158. return {
  159. hostname: 'example-sso.com',
  160. sso_cert: fs
  161. .readFileSync(
  162. path.resolve(__dirname, '../../files/saml-cert.crt'),
  163. 'utf8'
  164. )
  165. .replace(/-----BEGIN CERTIFICATE-----/, '')
  166. .replace(/-----END CERTIFICATE-----/, '')
  167. .replace(/\n/g, ''),
  168. sso_enabled: true,
  169. sso_entry_point: 'http://example-sso.com/saml',
  170. sso_entity_id: 'http://example-sso.com/saml/idp',
  171. university_id: 9999,
  172. university_name: 'Example University',
  173. sso_user_email_attribute: 'email',
  174. sso_user_first_name_attribute: 'firstName',
  175. sso_user_id_attribute: 'uniqueId',
  176. sso_user_last_name_attribute: 'lastName',
  177. sso_license_entitlement_attribute: 'hasEntitlement',
  178. sso_license_entitlement_matcher: 'Y',
  179. sso_signature_algorithm: 'sha256',
  180. ...config,
  181. }
  182. }
  183. async function getParseAndDoChecksForSamlLogs(numberOfLog) {
  184. const logs = await SamlLog.find({}, {})
  185. .sort({ $natural: -1 })
  186. .limit(numberOfLog || 1)
  187. .exec()
  188. logs.forEach(log => {
  189. expect(log.sessionId).to.exist
  190. expect(log.sessionId.length).to.equal(8) // not full session ID
  191. expect(log.createdAt).to.exist
  192. expect(log.jsonData).to.exist
  193. log.parsedJsonData = JSON.parse(log.jsonData)
  194. if (log.samlAssertion) {
  195. log.parsedSamlAssertion = JSON.parse(log.samlAssertion)
  196. }
  197. })
  198. return logs
  199. }
  200. /**
  201. * Parses a SAML request from a redirect URI.
  202. *
  203. * @param {URL} redirectUri - The redirect URI containing the SAML request.
  204. * @returns {Promise<Object>} - A promise that resolves to the parsed SAML request object.
  205. */
  206. async function parseSamlRequest(redirectUri) {
  207. const decoded = redirectUri.searchParams.get('SAMLRequest')
  208. const base64Decoded = Buffer.from(decoded, 'base64')
  209. const inflated = zlib.inflateRawSync(base64Decoded)
  210. return xml2js.parseStringPromise(inflated.toString('utf8'))
  211. }
  212. /**
  213. * Parses the SAML request from the given redirect URI and returns the request ID.
  214. * @param {URL} redirectUri - The redirect URI containing the SAML request.
  215. * @returns {Promise<string>} - A Promise that resolves to the request ID.
  216. */
  217. async function getRequestId(redirectUri) {
  218. const samlRequest = await parseSamlRequest(redirectUri)
  219. return samlRequest['samlp:AuthnRequest'].$.ID
  220. }
  221. const SAMLHelper = {
  222. createMockSamlResponse,
  223. samlUniversity,
  224. getParseAndDoChecksForSamlLogs,
  225. parseSamlRequest,
  226. getRequestId,
  227. }
  228. export default SAMLHelper