oauth.js 6.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242
  1. import {
  2. getLocalConfig,
  3. setLocalConfig,
  4. removeLocalConfig,
  5. } from "~/newstore/localpersistence"
  6. const redirectUri = `${window.location.origin}/`
  7. // GENERAL HELPER FUNCTIONS
  8. /**
  9. * Makes a POST request and parse the response as JSON
  10. *
  11. * @param {String} url - The resource
  12. * @param {Object} params - Configuration options
  13. * @returns {Object}
  14. */
  15. const sendPostRequest = async (url, params) => {
  16. const body = Object.keys(params)
  17. .map((key) => `${key}=${params[key]}`)
  18. .join("&")
  19. const options = {
  20. method: "post",
  21. headers: {
  22. "Content-type": "application/x-www-form-urlencoded; charset=UTF-8",
  23. },
  24. body,
  25. }
  26. try {
  27. const response = await fetch(url, options)
  28. const data = await response.json()
  29. return data
  30. } catch (e) {
  31. console.error(e)
  32. }
  33. }
  34. /**
  35. * Parse a query string into an object
  36. *
  37. * @param {String} searchQuery - The search query params
  38. * @returns {Object}
  39. */
  40. const parseQueryString = (searchQuery) => {
  41. if (searchQuery === "") {
  42. return {}
  43. }
  44. const segments = searchQuery.split("&").map((s) => s.split("="))
  45. const queryString = segments.reduce(
  46. (obj, el) => ({ ...obj, [el[0]]: el[1] }),
  47. {}
  48. )
  49. return queryString
  50. }
  51. /**
  52. * Get OAuth configuration from OpenID Discovery endpoint
  53. *
  54. * @returns {Object}
  55. */
  56. const getTokenConfiguration = async (endpoint) => {
  57. const options = {
  58. method: "GET",
  59. headers: {
  60. "Content-type": "application/json",
  61. },
  62. }
  63. try {
  64. const response = await fetch(endpoint, options)
  65. const config = await response.json()
  66. return config
  67. } catch (e) {
  68. console.error(e)
  69. }
  70. }
  71. // PKCE HELPER FUNCTIONS
  72. /**
  73. * Generates a secure random string using the browser crypto functions
  74. *
  75. * @returns {Object}
  76. */
  77. const generateRandomString = () => {
  78. const array = new Uint32Array(28)
  79. window.crypto.getRandomValues(array)
  80. return Array.from(array, (dec) => `0${dec.toString(16)}`.substr(-2)).join("")
  81. }
  82. /**
  83. * Calculate the SHA256 hash of the input text
  84. *
  85. * @returns {Promise<ArrayBuffer>}
  86. */
  87. const sha256 = (plain) => {
  88. const encoder = new TextEncoder()
  89. const data = encoder.encode(plain)
  90. return window.crypto.subtle.digest("SHA-256", data)
  91. }
  92. /**
  93. * Encodes the input string into Base64 format
  94. *
  95. * @param {String} str - The string to be converted
  96. * @returns {Promise<ArrayBuffer>}
  97. */
  98. const base64urlencode = (
  99. str // Converts the ArrayBuffer to string using Uint8 array to convert to what btoa accepts.
  100. ) =>
  101. // btoa accepts chars only within ascii 0-255 and base64 encodes them.
  102. // Then convert the base64 encoded to base64url encoded
  103. // (replace + with -, replace / with _, trim trailing =)
  104. btoa(String.fromCharCode.apply(null, new Uint8Array(str)))
  105. .replace(/\+/g, "-")
  106. .replace(/\//g, "_")
  107. .replace(/=+$/, "")
  108. /**
  109. * Return the base64-urlencoded sha256 hash for the PKCE challenge
  110. *
  111. * @param {String} v - The randomly generated string
  112. * @returns {String}
  113. */
  114. const pkceChallengeFromVerifier = async (v) => {
  115. const hashed = await sha256(v)
  116. return base64urlencode(hashed)
  117. }
  118. // OAUTH REQUEST
  119. /**
  120. * Initiates PKCE Auth Code flow when requested
  121. *
  122. * @param {Object} - The necessary params
  123. * @returns {Void}
  124. */
  125. const tokenRequest = async ({
  126. oidcDiscoveryUrl,
  127. grantType,
  128. authUrl,
  129. accessTokenUrl,
  130. clientId,
  131. scope,
  132. }) => {
  133. // Check oauth configuration
  134. if (oidcDiscoveryUrl !== "") {
  135. // eslint-disable-next-line camelcase
  136. const { authorization_endpoint, token_endpoint } =
  137. await getTokenConfiguration(oidcDiscoveryUrl)
  138. // eslint-disable-next-line camelcase
  139. authUrl = authorization_endpoint
  140. // eslint-disable-next-line camelcase
  141. accessTokenUrl = token_endpoint
  142. }
  143. // Store oauth information
  144. setLocalConfig("tokenEndpoint", accessTokenUrl)
  145. setLocalConfig("client_id", clientId)
  146. // Create and store a random state value
  147. const state = generateRandomString()
  148. setLocalConfig("pkce_state", state)
  149. // Create and store a new PKCE codeVerifier (the plaintext random secret)
  150. const codeVerifier = generateRandomString()
  151. setLocalConfig("pkce_codeVerifier", codeVerifier)
  152. // Hash and base64-urlencode the secret to use as the challenge
  153. const codeChallenge = await pkceChallengeFromVerifier(codeVerifier)
  154. // Build the authorization URL
  155. const buildUrl = () =>
  156. `${authUrl + `?response_type=${grantType}`}&client_id=${encodeURIComponent(
  157. clientId
  158. )}&state=${encodeURIComponent(state)}&scope=${encodeURIComponent(
  159. scope
  160. )}&redirect_uri=${encodeURIComponent(
  161. redirectUri
  162. )}&code_challenge=${encodeURIComponent(
  163. codeChallenge
  164. )}&code_challenge_method=S256`
  165. // Redirect to the authorization server
  166. window.location = buildUrl()
  167. }
  168. // OAUTH REDIRECT HANDLING
  169. /**
  170. * Handle the redirect back from the authorization server and
  171. * get an access token from the token endpoint
  172. *
  173. * @returns {Promise<any | void>}
  174. */
  175. const oauthRedirect = () => {
  176. let tokenResponse = ""
  177. const q = parseQueryString(window.location.search.substring(1))
  178. // Check if the server returned an error string
  179. if (q.error) {
  180. alert(`Error returned from authorization server: ${q.error}`)
  181. }
  182. // If the server returned an authorization code, attempt to exchange it for an access token
  183. if (q.code) {
  184. // Verify state matches what we set at the beginning
  185. if (getLocalConfig("pkce_state") !== q.state) {
  186. alert("Invalid state")
  187. Promise.reject(tokenResponse)
  188. } else {
  189. try {
  190. // Exchange the authorization code for an access token
  191. tokenResponse = sendPostRequest(getLocalConfig("tokenEndpoint"), {
  192. grant_type: "authorization_code",
  193. code: q.code,
  194. client_id: getLocalConfig("client_id"),
  195. redirect_uri: redirectUri,
  196. code_verifier: getLocalConfig("pkce_codeVerifier"),
  197. })
  198. } catch (e) {
  199. console.error(e)
  200. return Promise.reject(tokenResponse)
  201. }
  202. }
  203. // Clean these up since we don't need them anymore
  204. removeLocalConfig("pkce_state")
  205. removeLocalConfig("pkce_codeVerifier")
  206. removeLocalConfig("tokenEndpoint")
  207. removeLocalConfig("client_id")
  208. return tokenResponse
  209. }
  210. return Promise.reject(tokenResponse)
  211. }
  212. export { tokenRequest, oauthRedirect }