Repository: auth0/auth0-spa-js
Pull request #133 (2019-08-02 20:10:28)

Add popup timeout config
fix https://github.com/auth0/auth0-spa-js/issues/92

### Description

Adds a config object to send popup configuration options. For now, only `timeoutInSeconds` is available.

### References

https://github.com/auth0/auth0-spa-js/issues/92

### Testing

- [x] This change adds test coverage for new/changed/fixed functionality

mwillbanks: Looks great to me.

Patch:
diff --git a/src/Auth0Client.ts b/src/Auth0Client.ts
index 8408a6e..50ac03e 100644
--- a/src/Auth0Client.ts
+++ b/src/Auth0Client.ts
@@ -17,8 +17,13 @@ import TransactionManager from './transaction-manager';
 import { verify as verifyIdToken } from './jwt';
 import { AuthenticationError } from './errors';
 import * as ClientStorage from './storage';
+import { DEFAULT_AUTHORIZE_TIMEOUT_IN_SECONDS } from './constants';
 import version from './version';
 
+const DEFAULT_POPUP_CONFIG_OPTIONS: PopupConfigOptions = {
+  timeoutInSeconds: DEFAULT_AUTHORIZE_TIMEOUT_IN_SECONDS
+};
+
 /**
  * Auth0 SDK for Single Page Applications using [Authorization Code Grant Flow with PKCE](https://auth0.com/docs/api-auth/tutorials/authorization-code-grant-pkce).
  */
@@ -98,7 +103,10 @@ export default class Auth0Client {
    *
    * @param options
    */
-  public async loginWithPopup(options: PopupLoginOptions = {}) {
+  public async loginWithPopup(
+    options: PopupLoginOptions = {},
+    config: PopupConfigOptions = DEFAULT_POPUP_CONFIG_OPTIONS
+  ) {
     const popup = await openPopup();
     const { ...authorizeOptions } = options;
     const stateIn = encodeState(createRandomString());
@@ -117,7 +125,7 @@ export default class Auth0Client {
       ...params,
       response_mode: 'web_message'
     });
-    const codeResult = await runPopup(popup, url);
+    const codeResult = await runPopup(popup, url, config);
     if (stateIn !== codeResult.state) {
       throw new Error('Invalid state');
     }
@@ -357,14 +365,15 @@ export default class Auth0Client {
     options: GetTokenWithPopupOptions = {
       audience: this.options.audience,
       scope: this.options.scope || this.DEFAULT_SCOPE
-    }
+    },
+    config: PopupConfigOptions = DEFAULT_POPUP_CONFIG_OPTIONS
   ) {
     options.scope = getUniqueScopes(
       this.DEFAULT_SCOPE,
       this.options.scope,
       options.scope
     );
-    await this.loginWithPopup(options);
+    await this.loginWithPopup(options, config);
     const cache = this.cache.get({
       scope: options.scope,
       audience: options.audience || 'default'
diff --git a/src/constants.ts b/src/constants.ts
new file mode 100644
index 0000000..c3ad2c9
--- /dev/null
+++ b/src/constants.ts
@@ -0,0 +1,1 @@
+export const DEFAULT_AUTHORIZE_TIMEOUT_IN_SECONDS = 60;
diff --git a/src/global.ts b/src/global.ts
index e5ba593..691e5ea 100644
--- a/src/global.ts
+++ b/src/global.ts
@@ -118,6 +118,14 @@ interface RedirectLoginResult {
 
 interface PopupLoginOptions extends BaseLoginOptions {}
 
+interface PopupConfigOptions {
+  /**
+   * The number of seconds to wait for a popup response before
+   * throwing a timeout error. Defaults to 60s
+   */
+  timeoutInSeconds?: number;
+}
+
 interface GetUserOptions {
   /**
    * The scope that was used in the authentication request
diff --git a/src/utils.ts b/src/utils.ts
index dff38c0..d05096c 100644
--- a/src/utils.ts
+++ b/src/utils.ts
@@ -1,4 +1,6 @@
 import * as qs from 'qs';
+import { DEFAULT_AUTHORIZE_TIMEOUT_IN_SECONDS } from './constants';
+
 const TIMEOUT_ERROR = { error: 'timeout', error_description: 'Timeout' };
 export const getUniqueScopes = (...scopes: string[]) => {
   const scopeString = scopes.filter(Boolean).join();
@@ -54,12 +56,16 @@ export const openPopup = () => {
   return popup;
 };
 
-export const runPopup = (popup: any, authorizeUrl: string) => {
+export const runPopup = (
+  popup: any,
+  authorizeUrl: string,
+  config: PopupConfigOptions
+) => {
   popup.location.href = authorizeUrl;
   return new Promise<AuthenticationResult>((resolve, reject) => {
     const timeoutId = setTimeout(() => {
-      reject(TIMEOUT_ERROR);
-    }, 60 * 1000);
+      reject({ ...TIMEOUT_ERROR, popup });
+    }, config.timeoutInSeconds || DEFAULT_AUTHORIZE_TIMEOUT_IN_SECONDS * 1000);
     window.addEventListener('message', e => {
       if (!e.data || e.data.type !== 'authorization_response') {
         return;
diff --git a/static/index.html b/static/index.html
index adb2f32..b5b9ea7 100644
--- a/static/index.html
+++ b/static/index.html
@@ -36,9 +36,19 @@
           });
         });
         $('#login_popup').click(async () => {
-          await auth0.loginWithPopup({
-            redirect_uri: 'http://localhost:3000/callback.html'
-          });
+          try {
+            await auth0.loginWithPopup(
+              {
+                redirect_uri: 'http://localhost:3000/callback.html'
+              },
+              {
+                timeoutInSeconds: 20
+              }
+            );
+          } catch (error) {
+            console.error(error);
+            error.popup.close();
+          }
         });
         $('#login_redirect_callback').click(async () => {
           await auth0.handleRedirectCallback();


Test patch:
diff --git a/__tests__/index.test.ts b/__tests__/index.test.ts
index 8f7c9b3..c84b8c9 100644
--- a/__tests__/index.test.ts
+++ b/__tests__/index.test.ts
@@ -8,6 +8,7 @@ import Auth0Client from '../src/Auth0Client';
 import createAuth0Client from '../src/index';
 import { AuthenticationError } from '../src/errors';
 import version from '../src/version';
+import { DEFAULT_AUTHORIZE_TIMEOUT_IN_SECONDS } from '../src/constants';
 
 const TEST_DOMAIN = 'test.auth0.com';
 const TEST_CLIENT_ID = 'test-client-id';
@@ -32,6 +33,10 @@ const TEST_TELEMETRY_QUERY_STRING = `&auth0Client=${encodeURIComponent(
   )
 )}`;
 
+const DEFAULT_POPUP_CONFIG_OPTIONS: PopupConfigOptions = {
+  timeoutInSeconds: DEFAULT_AUTHORIZE_TIMEOUT_IN_SECONDS
+};
+
 const setup = async (options = {}) => {
   const auth0 = await createAuth0Client({
     domain: TEST_DOMAIN,
@@ -194,14 +199,26 @@ describe('Auth0', () => {
         code_challenge_method: 'S256'
       });
     });
-    it('opens popup with correct popup and url', async () => {
+    it('opens popup with correct popup, url and default config', async () => {
       const { auth0, utils } = await setup();
       const popup = {};
       utils.openPopup.mockReturnValue(popup);
-      await auth0.loginWithPopup({});
+      await auth0.loginWithPopup();
+      expect(utils.runPopup).toHaveBeenCalledWith(
+        popup,
+        `https://test.auth0.com/authorize?${TEST_QUERY_PARAMS}${TEST_TELEMETRY_QUERY_STRING}`,
+        DEFAULT_POPUP_CONFIG_OPTIONS
+      );
+    });
+    it('opens popup with correct popup, url and custom config', async () => {
+      const { auth0, utils } = await setup();
+      const popup = {};
+      utils.openPopup.mockReturnValue(popup);
+      await auth0.loginWithPopup({}, { timeoutInSeconds: 1 });
       expect(utils.runPopup).toHaveBeenCalledWith(
         popup,
-        `https://test.auth0.com/authorize?${TEST_QUERY_PARAMS}${TEST_TELEMETRY_QUERY_STRING}`
+        `https://test.auth0.com/authorize?${TEST_QUERY_PARAMS}${TEST_TELEMETRY_QUERY_STRING}`,
+        { timeoutInSeconds: 1 }
       );
     });
     it('throws error if state from popup response is different from the provided state', async () => {
@@ -902,27 +919,49 @@ describe('Auth0', () => {
       result.cache.get.mockReturnValue({ access_token: TEST_ACCESS_TOKEN });
       return result;
     };
-    it('calls `loginWithPopup` with the correct options', async () => {
+    it('calls `loginWithPopup` with the correct default options', async () => {
       const { auth0, utils } = await localSetup();
 
       await auth0.getTokenWithPopup();
-      expect(auth0.loginWithPopup).toHaveBeenCalledWith({
-        audience: undefined,
-        scope: 'unique:scopes'
-      });
+      expect(auth0.loginWithPopup).toHaveBeenCalledWith(
+        {
+          audience: undefined,
+          scope: TEST_SCOPES
+        },
+        DEFAULT_POPUP_CONFIG_OPTIONS
+      );
       expect(utils.getUniqueScopes).toHaveBeenCalledWith(
         'openid profile email',
         undefined,
         'openid profile email'
       );
     });
+    it('calls `loginWithPopup` with the correct custom options', async () => {
+      const { auth0, utils } = await localSetup();
+      const loginOptions = {
+        audience: 'other-audience',
+        scope: 'other-scope'
+      };
+      const configOptions = { timeoutInSeconds: 1 };
+
+      await auth0.getTokenWithPopup(loginOptions, configOptions);
+      expect(auth0.loginWithPopup).toHaveBeenCalledWith(
+        loginOptions,
+        configOptions
+      );
+      expect(utils.getUniqueScopes).toHaveBeenCalledWith(
+        'openid profile email',
+        undefined,
+        'other-scope'
+      );
+    });
     it('calls `cache.get` with the correct options', async () => {
       const { auth0, cache, utils } = await localSetup();
 
       await auth0.getTokenWithPopup();
       expect(cache.get).toHaveBeenCalledWith({
         audience: 'default',
-        scope: 'unique:scopes'
+        scope: TEST_SCOPES
       });
       expect(utils.getUniqueScopes).toHaveBeenCalledWith(
         'openid profile email',
diff --git a/__tests__/utils.test.ts b/__tests__/utils.test.ts
index f8dea19..cbfde7e 100644
--- a/__tests__/utils.test.ts
+++ b/__tests__/utils.test.ts
@@ -14,6 +14,7 @@ import {
   runIframe,
   urlDecodeB64
 } from '../src/utils';
+import { DEFAULT_AUTHORIZE_TIMEOUT_IN_SECONDS } from '../src/constants';
 
 (<any>global).TextEncoder = TextEncoder;
 
@@ -267,7 +268,7 @@ describe('utils', () => {
               jest.runAllTimers();
             }, 10);
             jest.useFakeTimers();
-            await expect(runPopup(popup, url)).rejects.toMatchObject(
+            await expect(runPopup(popup, url, {})).rejects.toMatchObject(
               TIMEOUT_ERROR
             );
             jest.useRealTimers();
@@ -283,7 +284,7 @@ describe('utils', () => {
         }
       };
       const { popup, url } = setup(message);
-      await expect(runPopup(popup, url)).resolves.toMatchObject(
+      await expect(runPopup(popup, url, {})).resolves.toMatchObject(
         message.data.response
       );
       expect(popup.location.href).toBe(url);
@@ -297,13 +298,33 @@ describe('utils', () => {
         }
       };
       const { popup, url } = setup(message);
-      await expect(runPopup(popup, url)).rejects.toMatchObject(
+      await expect(runPopup(popup, url, {})).rejects.toMatchObject(
         message.data.response
       );
       expect(popup.location.href).toBe(url);
       expect(popup.close).toHaveBeenCalled();
     });
-    it('times out after 60s', async () => {
+    it('times out after config.timeoutInSeconds', async () => {
+      const { popup, url } = setup('');
+      const seconds = 10;
+      /**
+       * 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.runTimersToTime(seconds * 1000);
+      }, 10);
+      jest.useFakeTimers();
+      await expect(
+        runPopup(popup, url, {
+          timeoutInSeconds: seconds
+        })
+      ).rejects.toMatchObject({ ...TIMEOUT_ERROR, popup });
+      jest.useRealTimers();
+    });
+    it('times out after DEFAULT_AUTHORIZE_TIMEOUT_IN_SECONDS if config is not defined', async () => {
       const { popup, url } = setup('');
       /**
        * We need to run the timers after we start `runPopup`, but we also
@@ -312,10 +333,12 @@ describe('utils', () => {
        * then rolling back to real timers
        */
       setTimeout(() => {
-        jest.runTimersToTime(60 * 1000);
+        jest.runTimersToTime(DEFAULT_AUTHORIZE_TIMEOUT_IN_SECONDS * 1000);
       }, 10);
       jest.useFakeTimers();
-      await expect(runPopup(popup, url)).rejects.toMatchObject(TIMEOUT_ERROR);
+      await expect(runPopup(popup, url, {})).rejects.toMatchObject(
+        TIMEOUT_ERROR
+      );
       jest.useRealTimers();
     });
   });