Repository: auth0/auth0-spa-js
Pull request #724 (2021-03-25 14:39:43)

Add Popup cancelled event
### Description

Add support for PopupCancelledError

``` js
  try {
       await auth0.loginWithPopup(options);
    } catch(e) {
      if (e instanceof PopupCancelledError) {
          // Popup was closed before login completed
       }
    }
```
🥼  Extra enhancements:

- Replace deprecated **jest.runTimersToTime** with **jest.advanceTimersByTime**
- Replace **jest.runAllTimers** with **jest.runOnlyPendingTimers**

### References

Feature request via [https://github.com/auth0/auth0-spa-js/issues/599](https://github.com/auth0/auth0-spa-js/issues/599)

### Testing

1. Run the Auth0 SPA JS Playground (npm start)
2. Click Login popup
3. Close the Popup

You should see 

<img width="354" alt="error" src="https://user-images.githubusercontent.com/4001529/114947075-14c53300-9e12-11eb-9828-1300e14e042a.png">

- [x] 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
- [x] All active GitHub checks for tests, formatting, and security are passing
- [x] The correct base branch is being used, if not `master`



Patch:
diff --git a/src/Auth0Client.ts b/src/Auth0Client.ts
index 8fde49a..d54343b 100644
--- a/src/Auth0Client.ts
+++ b/src/Auth0Client.ts
@@ -320,7 +320,14 @@ export default class Auth0Client {
 
   /**
    * ```js
-   * await auth0.loginWithPopup(options);
+   * try {
+   *  await auth0.loginWithPopup(options);
+   * } catch(e) {
+   *  if (e instanceof PopupCancelledError) {
+   *    // Popup was closed before login completed
+   *  }
+   * }
+   *
    * ```
    *
    * Opens a popup with the `/authorize` URL using the parameters
diff --git a/src/errors.ts b/src/errors.ts
index e7c9702..1a2446d 100644
--- a/src/errors.ts
+++ b/src/errors.ts
@@ -58,3 +58,11 @@ export class PopupTimeoutError extends TimeoutError {
     Object.setPrototypeOf(this, PopupTimeoutError.prototype);
   }
 }
+
+export class PopupCancelledError extends GenericError {
+  constructor(public popup: Window) {
+    super('cancelled', 'Popup closed');
+    //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, PopupCancelledError.prototype);
+  }
+}
diff --git a/src/index.ts b/src/index.ts
index adae177..d29aa55 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -27,5 +27,6 @@ export {
   GenericError,
   AuthenticationError,
   TimeoutError,
-  PopupTimeoutError
+  PopupTimeoutError,
+  PopupCancelledError
 } from './errors';
diff --git a/src/utils.ts b/src/utils.ts
index 1498d37..22cd5a5 100644
--- a/src/utils.ts
+++ b/src/utils.ts
@@ -5,7 +5,12 @@ import {
   CLEANUP_IFRAME_TIMEOUT_IN_SECONDS
 } from './constants';
 
-import { PopupTimeoutError, TimeoutError, GenericError } from './errors';
+import {
+  PopupTimeoutError,
+  TimeoutError,
+  GenericError,
+  PopupCancelledError
+} from './errors';
 
 export const parseQueryResult = (queryString: string) => {
   if (queryString.indexOf('#') > -1) {
@@ -97,7 +102,18 @@ export const runPopup = (config: PopupConfigOptions) => {
   return new Promise<AuthenticationResult>((resolve, reject) => {
     let popupEventListener: EventListenerOrEventListenerObject;
 
+    // Check each second if the popup is closed triggering a PopupCancelledError
+    const popupTimer = setInterval(() => {
+      if (config.popup && config.popup.closed) {
+        clearInterval(popupTimer);
+        clearTimeout(timeoutId);
+        window.removeEventListener('message', popupEventListener, false);
+        reject(new PopupCancelledError(config.popup));
+      }
+    }, 1000);
+
     const timeoutId = setTimeout(() => {
+      clearInterval(popupTimer);
       reject(new PopupTimeoutError(config.popup));
       window.removeEventListener('message', popupEventListener, false);
     }, (config.timeoutInSeconds || DEFAULT_AUTHORIZE_TIMEOUT_IN_SECONDS) * 1000);
@@ -108,6 +124,7 @@ export const runPopup = (config: PopupConfigOptions) => {
       }
 
       clearTimeout(timeoutId);
+      clearInterval(popupTimer);
       window.removeEventListener('message', popupEventListener, false);
       config.popup.close();
 
diff --git a/static/index.html b/static/index.html
index f346788..57a8412 100644
--- a/static/index.html
+++ b/static/index.html
@@ -482,12 +482,17 @@
               options.organization = _self.organization;
             }
 
-            _self.auth0.loginWithPopup(options).then(function () {
-              auth0.isAuthenticated().then(function (isAuthenticated) {
-                _self.isAuthenticated = isAuthenticated;
-                _self.showAuth0Info();
+            _self.auth0
+              .loginWithPopup(options)
+              .then(function () {
+                auth0.isAuthenticated().then(function (isAuthenticated) {
+                  _self.isAuthenticated = isAuthenticated;
+                  _self.showAuth0Info();
+                });
+              })
+              .catch(err => {
+                _self.error = err;
               });
-            });
           },
           loginRedirect: function () {
             var _self = this;
@@ -553,6 +558,9 @@
               .getTokenWithPopup({ audience: audience, scope: scope })
               .then(function (token) {
                 access_tokens.push(token);
+              })
+              .catch(err => {
+                _self.error = err;
               });
           },
           logout: function () {


Test patch:
diff --git a/__tests__/utils.test.ts b/__tests__/utils.test.ts
index bdc7de9..c38383a 100644
--- a/__tests__/utils.test.ts
+++ b/__tests__/utils.test.ts
@@ -237,12 +237,18 @@ describe('utils', () => {
       message: 'Timeout'
     };
 
+    const POPUP_CANCEL_ERROR = {
+      error: 'cancelled',
+      error_description: 'Popup closed'
+    };
+
     const url = 'https://authorize.com';
 
     const setup = customMessage => {
       const popup = {
         location: { href: url },
-        close: jest.fn()
+        close: jest.fn(),
+        closed: false
       };
 
       window.addEventListener = <any>jest.fn((message, callback) => {
@@ -265,7 +271,9 @@ describe('utils', () => {
              * then using fake timers then rolling back to real timers
              */
             setTimeout(() => {
-              jest.runAllTimers();
+              // Since RunPopup function uses setInterval, we have a recursive timer,
+              // In order to prevent an enless loop we need to use runOnlyPendingTimers.
+              jest.runOnlyPendingTimers();
             }, 10);
             jest.useFakeTimers();
             await expect(runPopup({ popup })).rejects.toMatchObject(
@@ -328,7 +336,8 @@ describe('utils', () => {
        * then rolling back to real timers
        */
       setTimeout(() => {
-        jest.runTimersToTime(seconds * 1000);
+        jest.runOnlyPendingTimers();
+        jest.advanceTimersByTime(seconds * 1000);
       }, 10);
 
       jest.useFakeTimers();
@@ -352,7 +361,8 @@ describe('utils', () => {
        * then rolling back to real timers
        */
       setTimeout(() => {
-        jest.runTimersToTime(DEFAULT_AUTHORIZE_TIMEOUT_IN_SECONDS * 1000);
+        jest.runOnlyPendingTimers();
+        jest.advanceTimersByTime(DEFAULT_AUTHORIZE_TIMEOUT_IN_SECONDS * 1000);
       }, 10);
 
       jest.useFakeTimers();
@@ -361,6 +371,30 @@ describe('utils', () => {
 
       jest.useRealTimers();
     });
+
+    it('rejects with PopupCancelledError if popup is closed early', async () => {
+      const { popup, url } = setup('');
+      popup.closed = true;
+
+      /**
+       * We need to run the timers after we start `runPopup`, but we also
+       * need to use `jest.useFakeTimers` to trigger the timeout.
+       * That's why we're using a real `setTimeout`, then using fake timers
+       * then rolling back to real timers
+       */
+      setTimeout(() => {
+        jest.runOnlyPendingTimers();
+        jest.advanceTimersByTime(DEFAULT_AUTHORIZE_TIMEOUT_IN_SECONDS * 1000);
+      }, 10);
+
+      jest.useFakeTimers();
+
+      await expect(runPopup({ popup })).rejects.toMatchObject(
+        POPUP_CANCEL_ERROR
+      );
+
+      jest.useRealTimers();
+    });
   });
   describe('runIframe', () => {
     const TIMEOUT_ERROR = {
@@ -403,7 +437,7 @@ describe('utils', () => {
       const { iframe, url } = setup(message);
       jest.useFakeTimers();
       await runIframe(url, origin);
-      jest.runAllTimers();
+      jest.runOnlyPendingTimers();
       expect(message.source.close).toHaveBeenCalled();
       expect(window.document.body.appendChild).toHaveBeenCalledWith(iframe);
       expect(window.document.body.removeChild).toHaveBeenCalledWith(iframe);
@@ -426,7 +460,7 @@ describe('utils', () => {
           const { iframe, url, origin } = setup(m);
           jest.useFakeTimers();
           const promise = runIframe(url, origin);
-          jest.runAllTimers();
+          jest.runOnlyPendingTimers();
           await expect(promise).rejects.toMatchObject(TIMEOUT_ERROR);
           expect(window.document.body.removeChild).toHaveBeenCalledWith(iframe);
         });
@@ -447,7 +481,7 @@ describe('utils', () => {
       await expect(runIframe(url, origin)).resolves.toMatchObject(
         message.data.response
       );
-      jest.runAllTimers();
+      jest.runOnlyPendingTimers();
       expect(message.source.close).toHaveBeenCalled();
       expect(window.document.body.removeChild).toHaveBeenCalledWith(iframe);
     });
@@ -470,7 +504,7 @@ describe('utils', () => {
         ...message.data.response,
         message: 'error_description'
       });
-      jest.runAllTimers();
+      jest.runOnlyPendingTimers();
       expect(message.source.close).toHaveBeenCalled();
       expect(window.document.body.removeChild).toHaveBeenCalledWith(iframe);
       expect(window.removeEventListener).toBeCalled();