Source

lib/client/HttpClient.ts

  1. import { RequestTimeoutError, TechnicalError } from "../Errors";
  2. import { Dispatcher } from "../events/Dispatcher";
  3. import { Cookie } from "../Cookie";
  4. import { SessionStorage } from "../SessionStorage";
  5. import { CookieAttributes } from "js-cookie";
  6. import { HankoOptions } from "../../Hanko";
  7. export type SessionTokenLocation = "cookie" | "sessionStorage";
  8. /**
  9. * This class wraps an XMLHttpRequest to maintain compatibility with the fetch API.
  10. *
  11. * @category SDK
  12. * @subcategory Internal
  13. * @param {XMLHttpRequest} xhr - The request to be wrapped.
  14. * @see HttpClient
  15. */
  16. class Headers {
  17. _xhr: XMLHttpRequest;
  18. // eslint-disable-next-line require-jsdoc
  19. constructor(xhr: XMLHttpRequest) {
  20. this._xhr = xhr;
  21. }
  22. /**
  23. * Returns the response header with the given name.
  24. *
  25. * @param {string} name
  26. * @return {string}
  27. */
  28. getResponseHeader(name: string) {
  29. return this._xhr.getResponseHeader(name);
  30. }
  31. }
  32. /**
  33. * This class wraps an XMLHttpRequest to maintain compatibility with the fetch API.
  34. *
  35. * @category SDK
  36. * @subcategory Internal
  37. * @param {XMLHttpRequest} xhr - The request to be wrapped.
  38. * @see HttpClient
  39. */
  40. class Response {
  41. headers: Headers;
  42. ok: boolean;
  43. status: number;
  44. statusText: string;
  45. url: string;
  46. _decodedJSON: any;
  47. xhr: XMLHttpRequest;
  48. // eslint-disable-next-line require-jsdoc
  49. constructor(xhr: XMLHttpRequest) {
  50. /**
  51. * @public
  52. * @type {Headers}
  53. */
  54. this.headers = new Headers(xhr);
  55. /**
  56. * @public
  57. * @type {boolean}
  58. */
  59. this.ok = xhr.status >= 200 && xhr.status <= 299;
  60. /**
  61. * @public
  62. * @type {number}
  63. */
  64. this.status = xhr.status;
  65. /**
  66. * @public
  67. * @type {string}
  68. */
  69. this.statusText = xhr.statusText;
  70. /**
  71. * @public
  72. * @type {string}
  73. */
  74. this.url = xhr.responseURL;
  75. /**
  76. * @private
  77. * @type {XMLHttpRequest}
  78. */
  79. this.xhr = xhr;
  80. }
  81. /**
  82. * Returns the JSON decoded response.
  83. *
  84. * @return {any}
  85. */
  86. json() {
  87. if (!this._decodedJSON) {
  88. this._decodedJSON = JSON.parse(this.xhr.response);
  89. }
  90. return this._decodedJSON;
  91. }
  92. /**
  93. * Returns the response header value with the given `name` as a number. When the value is not a number the return
  94. * value will be 0.
  95. *
  96. * @param {string} name - The name of the header field
  97. * @return {number}
  98. */
  99. parseNumericHeader(name: string): number {
  100. const result = parseInt(this.headers.getResponseHeader(name), 10);
  101. return isNaN(result) ? 0 : result;
  102. }
  103. }
  104. /**
  105. * Options for the HttpClient
  106. *
  107. * @category SDK
  108. * @subcategory Internal
  109. * @property {number=} timeout - The http request timeout in milliseconds.
  110. * @property {string} cookieName - The name of the session cookie set from the SDK.
  111. * @property {string=} cookieDomain - The domain where cookie set from the SDK is available. Defaults to the domain of the page where the cookie was created.
  112. * @property {string?} lang - The language used by the client(s) to convey to the Hanko API the language to use -
  113. * e.g. for translating outgoing emails - in a custom header (X-Language).
  114. */
  115. export interface HttpClientOptions {
  116. timeout?: number;
  117. cookieName?: string;
  118. cookieDomain?: string;
  119. lang?: string;
  120. sessionTokenLocation?: SessionTokenLocation;
  121. }
  122. /**
  123. * Internally used for communication with the Hanko API. It also handles authorization tokens to enable authorized
  124. * requests.
  125. *
  126. * Currently, there is an issue with Safari and on iOS 15 devices where decoding a JSON response via the fetch API
  127. * breaks the user gesture and the user is not able to use the authenticator. Therefore, this class uses XMLHttpRequests
  128. * instead of the fetch API, but maintains compatibility by wrapping the XMLHttpRequests. So, if the issues are fixed,
  129. * we can easily return to the fetch API.
  130. *
  131. * @category SDK
  132. * @subcategory Internal
  133. * @param {string} api - The URL of your Hanko API instance
  134. * @param {HttpClientOptions} options - The options the HttpClient must be provided
  135. */
  136. class HttpClient {
  137. timeout: number;
  138. api: string;
  139. dispatcher: Dispatcher;
  140. cookie: Cookie;
  141. sessionTokenStorage: SessionStorage;
  142. lang: string;
  143. sessionTokenLocation: SessionTokenLocation;
  144. // eslint-disable-next-line require-jsdoc
  145. constructor(api: string, options: HankoOptions) {
  146. this.api = api;
  147. this.timeout = options.timeout ?? 13000;
  148. this.dispatcher = new Dispatcher();
  149. this.cookie = new Cookie({ ...options });
  150. this.sessionTokenStorage = new SessionStorage({
  151. keyName: options.cookieName,
  152. });
  153. this.lang = options.lang;
  154. this.sessionTokenLocation = options.sessionTokenLocation;
  155. }
  156. // eslint-disable-next-line require-jsdoc
  157. _fetch(path: string, options: RequestInit, xhr = new XMLHttpRequest()) {
  158. const self = this;
  159. const url = this.api + path;
  160. const timeout = this.timeout;
  161. const bearerToken = this.getAuthToken();
  162. const lang = this.lang;
  163. return new Promise<Response>(function (resolve, reject) {
  164. xhr.open(options.method, url, true);
  165. xhr.setRequestHeader("Accept", "application/json");
  166. xhr.setRequestHeader("Content-Type", "application/json");
  167. xhr.setRequestHeader("X-Language", lang);
  168. if (bearerToken) {
  169. xhr.setRequestHeader("Authorization", `Bearer ${bearerToken}`);
  170. }
  171. xhr.timeout = timeout;
  172. xhr.withCredentials = true;
  173. xhr.onload = () => {
  174. self.processHeaders(xhr);
  175. resolve(new Response(xhr));
  176. };
  177. xhr.onerror = () => {
  178. reject(new TechnicalError());
  179. };
  180. xhr.ontimeout = () => {
  181. reject(new RequestTimeoutError());
  182. };
  183. xhr.send(options.body ? options.body.toString() : null);
  184. });
  185. }
  186. /**
  187. * Processes the response headers on login and extracts the JWT and expiration time.
  188. *
  189. * @param {XMLHttpRequest} xhr - The xhr object.
  190. */
  191. processHeaders(xhr: XMLHttpRequest) {
  192. let jwt = "";
  193. let expirationSeconds = 0;
  194. let retention = "";
  195. xhr
  196. .getAllResponseHeaders()
  197. .split("\r\n")
  198. .forEach((h) => {
  199. const header = h.toLowerCase();
  200. if (header.startsWith("x-auth-token")) {
  201. jwt = xhr.getResponseHeader("X-Auth-Token");
  202. } else if (header.startsWith("x-session-lifetime")) {
  203. expirationSeconds = parseInt(
  204. xhr.getResponseHeader("X-Session-Lifetime"),
  205. 10,
  206. );
  207. } else if (header.startsWith("x-session-retention")) {
  208. retention = xhr.getResponseHeader("X-Session-Retention");
  209. }
  210. });
  211. if (jwt) {
  212. const https = new RegExp("^https://");
  213. const secure =
  214. !!this.api.match(https) && !!window.location.href.match(https);
  215. const expires =
  216. retention === "session"
  217. ? undefined
  218. : new Date(new Date().getTime() + expirationSeconds * 1000);
  219. this.setAuthToken(jwt, { secure, expires });
  220. }
  221. }
  222. /**
  223. * Performs a GET request.
  224. *
  225. * @param {string} path - The path to the requested resource.
  226. * @return {Promise<Response>}
  227. * @throws {RequestTimeoutError}
  228. * @throws {TechnicalError}
  229. */
  230. get(path: string) {
  231. return this._fetch(path, { method: "GET" });
  232. }
  233. /**
  234. * Performs a POST request.
  235. *
  236. * @param {string} path - The path to the requested resource.
  237. * @param {any=} body - The request body.
  238. * @return {Promise<Response>}
  239. * @throws {RequestTimeoutError}
  240. * @throws {TechnicalError}
  241. */
  242. post(path: string, body?: any) {
  243. return this._fetch(path, {
  244. method: "POST",
  245. body: JSON.stringify(body),
  246. });
  247. }
  248. /**
  249. * Performs a PUT request.
  250. *
  251. * @param {string} path - The path to the requested resource.
  252. * @param {any=} body - The request body.
  253. * @return {Promise<Response>}
  254. * @throws {RequestTimeoutError}
  255. * @throws {TechnicalError}
  256. */
  257. put(path: string, body?: any) {
  258. return this._fetch(path, {
  259. method: "PUT",
  260. body: JSON.stringify(body),
  261. });
  262. }
  263. /**
  264. * Performs a PATCH request.
  265. *
  266. * @param {string} path - The path to the requested resource.
  267. * @param {any=} body - The request body.
  268. * @return {Promise<Response>}
  269. * @throws {RequestTimeoutError}
  270. * @throws {TechnicalError}
  271. */
  272. patch(path: string, body?: any) {
  273. return this._fetch(path, {
  274. method: "PATCH",
  275. body: JSON.stringify(body),
  276. });
  277. }
  278. /**
  279. * Performs a DELETE request.
  280. *
  281. * @param {string} path - The path to the requested resource.
  282. * @return {Promise<Response>}
  283. * @throws {RequestTimeoutError}
  284. * @throws {TechnicalError}
  285. */
  286. delete(path: string) {
  287. return this._fetch(path, {
  288. method: "DELETE",
  289. });
  290. }
  291. /**
  292. * Returns the session token either from the cookie or the sessionStorage.
  293. * @private
  294. * @return {string}
  295. */
  296. private getAuthToken(): string {
  297. let token = "";
  298. switch (this.sessionTokenLocation) {
  299. case "cookie":
  300. token = this.cookie.getAuthCookie();
  301. break;
  302. case "sessionStorage":
  303. token = this.sessionTokenStorage.getSessionToken();
  304. break;
  305. default:
  306. token = this.cookie.getAuthCookie();
  307. break;
  308. }
  309. return token;
  310. }
  311. /**
  312. * Stores the session token either in a cookie or in the sessionStorage depending on the configuration.
  313. * @param {string} token - The session token to be stored.
  314. * @param {CookieAttributes} options - Options for setting the auth cookie.
  315. * @private
  316. */
  317. private setAuthToken(token: string, options: CookieAttributes) {
  318. switch (this.sessionTokenLocation) {
  319. case "cookie":
  320. return this.cookie.setAuthCookie(token, options);
  321. case "sessionStorage":
  322. return this.sessionTokenStorage.setSessionToken(token);
  323. default:
  324. return this.cookie.setAuthCookie(token, options);
  325. }
  326. }
  327. }
  328. export { Headers, Response, HttpClient };