Repository: auth0/auth0-spa-js
Pull request #520 (2020-07-09 16:19:53)

[SDK-1650] Add `message` to errors that don't have one
### Description

All errors from the SDK should have a `message` property

### Testing

- [ ] This change adds test coverage for new/changed/fixed functionality

### Checklist

- [ ] I have added documentation for new/changed functionality in this PR or in auth0.com/docs
- [ ] All active GitHub checks for tests, formatting, and security are passing
- [ ] The correct base branch is being used, if not `master`



Patch:
diff --git a/src/errors.ts b/src/errors.ts
index 85c92b4..09c2307 100644
--- a/src/errors.ts
+++ b/src/errors.ts
@@ -4,7 +4,12 @@ export class GenericError extends Error {
 
     Object.setPrototypeOf(this, GenericError.prototype);
   }
+
+  static fromPayload({ error, error_description }) {
+    return new GenericError(error, error_description);
+  }
 }
+
 export class AuthenticationError extends GenericError {
   constructor(
     error: string,
@@ -17,3 +22,19 @@ export class AuthenticationError extends GenericError {
     Object.setPrototypeOf(this, AuthenticationError.prototype);
   }
 }
+
+export class TimeoutError extends GenericError {
+  constructor() {
+    super('timeout', 'Timeout');
+    //https://github.com/Microsoft/TypeScript-wiki/blob/master/Breaking-Changes.md#extending-built-ins-like-error-array-and-map-may-no-longer-work
+    Object.setPrototypeOf(this, TimeoutError.prototype);
+  }
+}
+
+export class PopupTimeoutError extends TimeoutError {
+  constructor(public popup: Window) {
+    super();
+    //https://github.com/Microsoft/TypeScript-wiki/blob/master/Breaking-Changes.md#extending-built-ins-like-error-array-and-map-may-no-longer-work
+    Object.setPrototypeOf(this, PopupTimeoutError.prototype);
+  }
+}
diff --git a/src/utils.ts b/src/utils.ts
index 3effe54..2edf3c6 100644
--- a/src/utils.ts
+++ b/src/utils.ts
@@ -13,7 +13,7 @@ import {
   CLEANUP_IFRAME_TIMEOUT_IN_SECONDS
 } from './constants';
 
-const TIMEOUT_ERROR = { error: 'timeout', error_description: 'Timeout' };
+import { PopupTimeoutError, TimeoutError, GenericError } from './errors';
 
 export const createAbortController = () => new AbortController();
 
@@ -54,7 +54,7 @@ export const runIframe = (
     };
 
     const timeoutSetTimeoutId = setTimeout(() => {
-      rej(TIMEOUT_ERROR);
+      rej(new TimeoutError());
       removeIframe();
     }, timeoutInSeconds * 1000);
 
@@ -65,7 +65,9 @@ export const runIframe = (
       if (eventSource) {
         (<any>eventSource).close();
       }
-      e.data.response.error ? rej(e.data.response) : res(e.data.response);
+      e.data.response.error
+        ? rej(GenericError.fromPayload(e.data.response))
+        : res(e.data.response);
       clearTimeout(timeoutSetTimeoutId);
       window.removeEventListener('message', iframeEventHandler, false);
       // Delay the removal of the iframe to prevent hanging loading status
@@ -106,7 +108,7 @@ export const runPopup = (authorizeUrl: string, config: PopupConfigOptions) => {
 
   return new Promise<AuthenticationResult>((resolve, reject) => {
     const timeoutId = setTimeout(() => {
-      reject({ ...TIMEOUT_ERROR, popup });
+      reject(new PopupTimeoutError(popup));
     }, (config.timeoutInSeconds || DEFAULT_AUTHORIZE_TIMEOUT_IN_SECONDS) * 1000);
     window.addEventListener('message', e => {
       if (!e.data || e.data.type !== 'authorization_response') {
@@ -115,7 +117,7 @@ export const runPopup = (authorizeUrl: string, config: PopupConfigOptions) => {
       clearTimeout(timeoutId);
       popup.close();
       if (e.data.response.error) {
-        return reject(e.data.response);
+        return reject(GenericError.fromPayload(e.data.response));
       }
       resolve(e.data.response);
     });
@@ -276,6 +278,9 @@ const getJSON = async (url, timeout, options, worker) => {
   }
 
   if (fetchError) {
+    // unfetch uses XMLHttpRequest under the hood which throws
+    // ProgressEvents on error, which don't have message properties
+    fetchError.message = fetchError.message || 'Failed to fetch';
     throw fetchError;
   }
 


Test patch:
diff --git a/__tests__/utils.test.ts b/__tests__/utils.test.ts
index a80da8d..3d6321c 100644
--- a/__tests__/utils.test.ts
+++ b/__tests__/utils.test.ts
@@ -395,6 +395,19 @@ describe('utils', () => {
       expect(abortController.abort).not.toHaveBeenCalled();
     });
 
+    it('throws a fetch error when the network is down', async () => {
+      mockUnfetch.mockReturnValue(Promise.reject(new ProgressEvent('error')));
+
+      await expect(
+        oauthToken({
+          baseUrl: 'https://test.com',
+          client_id: 'client_idIn',
+          code: 'codeIn',
+          code_verifier: 'code_verifierIn'
+        })
+      ).rejects.toMatchObject({ message: 'Failed to fetch' });
+    });
+
     it('surfaces a timeout error when the fetch continuously times out', async () => {
       const createPromise = () =>
         new Promise((resolve, _) => {
@@ -454,7 +467,11 @@ describe('utils', () => {
   });
 
   describe('runPopup', () => {
-    const TIMEOUT_ERROR = { error: 'timeout', error_description: 'Timeout' };
+    const TIMEOUT_ERROR = {
+      error: 'timeout',
+      error_description: 'Timeout',
+      message: 'Timeout'
+    };
 
     const url = 'https://authorize.com';
 
@@ -518,15 +535,19 @@ describe('utils', () => {
       const message = {
         data: {
           type: 'authorization_response',
-          response: { error: 'error' }
+          response: {
+            error: 'error',
+            error_description: 'error_description'
+          }
         }
       };
 
       const { popup, url } = setup(message);
 
-      await expect(runPopup(url, { popup })).rejects.toMatchObject(
-        message.data.response
-      );
+      await expect(runPopup(url, { popup })).rejects.toMatchObject({
+        ...message.data.response,
+        message: 'error_description'
+      });
 
       expect(popup.location.href).toBe(url);
       expect(popup.close).toHaveBeenCalled();
@@ -603,7 +624,11 @@ describe('utils', () => {
     });
   });
   describe('runIframe', () => {
-    const TIMEOUT_ERROR = { error: 'timeout', error_description: 'Timeout' };
+    const TIMEOUT_ERROR = {
+      error: 'timeout',
+      error_description: 'Timeout',
+      message: 'Timeout'
+    };
     const setup = customMessage => {
       const iframe = {
         setAttribute: jest.fn(),
@@ -694,14 +719,18 @@ describe('utils', () => {
         source: { close: jest.fn() },
         data: {
           type: 'authorization_response',
-          response: { error: 'error' }
+          response: {
+            error: 'error',
+            error_description: 'error_description'
+          }
         }
       };
       const { iframe, url } = setup(message);
       jest.useFakeTimers();
-      await expect(runIframe(url, origin)).rejects.toMatchObject(
-        message.data.response
-      );
+      await expect(runIframe(url, origin)).rejects.toMatchObject({
+        ...message.data.response,
+        message: 'error_description'
+      });
       jest.runAllTimers();
       expect(message.source.close).toHaveBeenCalled();
       expect(window.document.body.removeChild).toHaveBeenCalledWith(iframe);