Repository: auth0/auth0-spa-js
Pull request #1058 (2022-12-06 12:45:51)

Add openUrl and deprecate onRedirect
### Changes

This PR adds `openUrl` as a replacement for `onRedirect`, with support for also setting it to `false` to signal localOnly logout.

This deprecation is done as we made a mistake with naming it initially with the v2 release. We have no intention to drop this method before releasing a new major, but we have no current plans for a v3.

Regardless, we encourage everyone to use `openUrl`, and have also updated the MIGRATION_GUIDE accordingly.

### Testing

<!--
Please describe how this can be tested by reviewers. Be specific about anything not tested and reasons why. If this library has unit and/or integration testing, tests should be added for new functionality and existing tests should complete without errors.
-->

- [x] This change adds unit test coverage
- [x] This change adds integration test coverage
- [x] This change has been tested on the latest version of the platform/language

### Checklist

- [x] I have read the [Auth0 general contribution guidelines](https://github.com/auth0/open-source-template/blob/master/GENERAL-CONTRIBUTING.md)
- [x] I have read the [Auth0 Code of Conduct](https://github.com/auth0/open-source-template/blob/master/CODE-OF-CONDUCT.md)
- [x] All code quality tools/guidelines have been run/followed


frederikprijck: Keeping in draft until the discovery is final, incase we want to rename it still.

Patch:
diff --git a/MIGRATION_GUIDE.md b/MIGRATION_GUIDE.md
index b75b525..0d1740b 100644
--- a/MIGRATION_GUIDE.md
+++ b/MIGRATION_GUIDE.md
@@ -11,7 +11,7 @@ With the v2 release of Auth0-SPA-JS, we have improved both performance and devel
   - [Introduction of logoutParams](#introduction-of-logoutparams)
   - [buildAuthorizeUrl has been removed](#buildauthorizeurl-has-been-removed)
   - [buildLogoutUrl has been removed](#buildlogouturl-has-been-removed)
-  - [localOnly logout has been removed, and replaced by onRedirect](#localonly-logout-has-been-removed-and-replaced-by-onredirect)
+  - [localOnly logout has been removed, and replaced by openUrl](#localonly-logout-has-been-removed-and-replaced-by-openurl)
   - [redirectMethod has been removed from loginWithRedirect](#redirectmethod-has-been-removed-from-loginwithredirect)
   - [ignoreCache on getTokenSilentlyhas been replaced by cacheMode](#ignorecache-on-gettokensilently-has-been-replaced-by-cachemode)
   - [application/x-www-form-urlencoded is used by default instead of application/json](#applicationx-www-form-urlencoded-is-used-by-default-instead-of-applicationjson)
@@ -148,11 +148,11 @@ const url = await client.buildAuthorizeUrl();
 await Browser.open({ url });
 ```
 
-With v2, we have removed `buildAuthorizeUrl`. This means that the snippet above will no longer work, and you should update your code by using `onRedirect` instead.
+With v2, we have removed `buildAuthorizeUrl`. This means that the snippet above will no longer work, and you should update your code by using `openUrl` instead.
 
 ```ts
 await client.loginWithRedirect({
-  async onRedirect(url) {
+  async openUrl(url) {
     // Redirect using Capacitor's Browser plugin
     await Browser.open({ url });
   }
@@ -172,11 +172,11 @@ const url = await client.buildLogoutUrl();
 await Browser.open({ url });
 ```
 
-With v2, `buildLogoutUrl` has been removed and you should update any code that is not able to rely on `window.location.assign` to use `onRedirect` when calling `logout`:
+With v2, `buildLogoutUrl` has been removed and you should update any code that is not able to rely on `window.location.assign` to use `openUrl` when calling `logout`:
 
 ```ts
 client.logout({
-  async onRedirect(url) {
+  async openUrl(url) {
     // Redirect using Capacitor's Browser plugin
     await Browser.open({ url });
   }
@@ -185,15 +185,15 @@ client.logout({
 
 This method was removed because, when using our SDK, the logout method is expected to be called regardless of the browser used. Instead of calling both `logout` and `buildLogoutUrl`, you can now change the redirect behaviour when calling `logout`.
 
-### `localOnly` logout has been removed, and replaced by `onRedirect`
+### `localOnly` logout has been removed, and replaced by `openUrl: false`
 
 When calling the SDK's `logout` method, v1 supports the ability to specify `localOnly: true`, ensuring our SDK does not redirect to Auth0 but only clears the user state from the application.
 
-With v2, we have removed `localOnly`, but instead provided a way for developers to take control of the redirect behavior by setting `onRedirect`. In order to achieve localOnly logout with v2, you should set `onRedirect` to a noop function.
+With v2, we have removed `localOnly`, but instead provided a way for developers to take control of the redirect behavior by setting `openUrl`. In order to achieve localOnly logout with v2, you should set `openUrl` to false.
 
 ```ts
 client.logout({
-  async onRedirect() {}
+  openUrl: false
 });
 ```
 
@@ -207,11 +207,11 @@ await client.loginWithRedirect({
 });
 ```
 
-With the release of v2, we have removed `redirectMethod`. If you want to use anything but `window.location.assign` to handle the redirect to Auth0, you should implement `onRedirect`:
+With the release of v2, we have removed `redirectMethod`. If you want to use anything but `window.location.assign` to handle the redirect to Auth0, you should implement `openUrl`:
 
 ```ts
 await client.loginWithRedirect({
-  async onRedirect(url) {
+  async openUrl(url) {
     window.location.replace(url);
   }
 });
diff --git a/src/Auth0Client.ts b/src/Auth0Client.ts
index d9fd72f..7389fc7 100644
--- a/src/Auth0Client.ts
+++ b/src/Auth0Client.ts
@@ -89,7 +89,8 @@ import {
   cacheFactory,
   getAuthorizeParams,
   GET_TOKEN_SILENTLY_LOCK_KEY,
-  OLD_IS_AUTHENTICATED_COOKIE_NAME
+  OLD_IS_AUTHENTICATED_COOKIE_NAME,
+  patchOpenUrlWithOnRedirect
 } from './Auth0Client.utils';
 
 /**
@@ -444,7 +445,8 @@ export class Auth0Client {
   public async loginWithRedirect<TAppState = any>(
     options: RedirectLoginOptions<TAppState> = {}
   ) {
-    const { onRedirect, fragment, appState, ...urlOptions } = options;
+    const { openUrl, fragment, appState, ...urlOptions } =
+      patchOpenUrlWithOnRedirect(options);
 
     const organizationId =
       urlOptions.authorizationParams?.organization ||
@@ -462,8 +464,8 @@ export class Auth0Client {
 
     const urlWithFragment = fragment ? `${url}#${fragment}` : url;
 
-    if (onRedirect) {
-      await onRedirect(urlWithFragment);
+    if (openUrl) {
+      await openUrl(urlWithFragment);
     } else {
       window.location.assign(urlWithFragment);
     }
@@ -824,7 +826,7 @@ export class Auth0Client {
    * @param options
    */
   public async logout(options: LogoutOptions = {}): Promise<void> {
-    const { onRedirect, ...logoutOptions } = options;
+    const { openUrl, ...logoutOptions } = patchOpenUrlWithOnRedirect(options);
 
     await this.cacheManager.clear();
 
@@ -838,9 +840,9 @@ export class Auth0Client {
 
     const url = this._buildLogoutUrl(logoutOptions);
 
-    if (onRedirect) {
-      await onRedirect(url);
-    } else {
+    if (openUrl) {
+      await openUrl(url);
+    } else if (openUrl !== false) {
       window.location.assign(url);
     }
   }
@@ -918,7 +920,7 @@ export class Auth0Client {
     } catch (e) {
       if (e.error === 'login_required') {
         this.logout({
-          onRedirect: async () => {}
+          openUrl: false
         });
       }
       throw e;
diff --git a/src/Auth0Client.utils.ts b/src/Auth0Client.utils.ts
index 0ea54de..f148cd7 100644
--- a/src/Auth0Client.utils.ts
+++ b/src/Auth0Client.utils.ts
@@ -2,7 +2,8 @@ import { ICache, InMemoryCache, LocalStorageCache } from './cache';
 import {
   Auth0ClientOptions,
   AuthorizationParams,
-  AuthorizeOptions
+  AuthorizeOptions,
+  LogoutOptions
 } from './global';
 import { getUniqueScopes } from './scope';
 
@@ -73,3 +74,23 @@ export const getAuthorizeParams = (
     code_challenge_method: 'S256'
   };
 };
+
+/**
+ * @ignore
+ *
+ * Function used to provide support for the deprecated onRedirect through openUrl.
+ */
+export const patchOpenUrlWithOnRedirect = <
+  T extends Pick<LogoutOptions, 'openUrl' | 'onRedirect'>
+>(
+  options: T
+) => {
+  const { openUrl, onRedirect, ...originalOptions } = options;
+
+  const result = {
+    ...originalOptions,
+    openUrl: openUrl === false || openUrl ? openUrl : onRedirect
+  };
+
+  return result as T;
+};
diff --git a/src/global.ts b/src/global.ts
index f24f262..b48a39c 100644
--- a/src/global.ts
+++ b/src/global.ts
@@ -296,8 +296,21 @@ export interface RedirectLoginOptions<TAppState = any>
    *     window.location.replace(url);
    *   }
    * });
+   * @deprecated since v2.0.1, use `openUrl` instead.
    */
   onRedirect?: (url: string) => Promise<void>;
+
+  /**
+   * Used to control the redirect and not rely on the SDK to do the actual redirect.
+   *
+   * @example
+   * const client = new Auth0Client({
+   *   async openUrl(url) {
+   *     window.location.replace(url);
+   *   }
+   * });
+   */
+  openUrl?: (url: string) => Promise<void>;
 }
 
 export interface RedirectLoginResult<TAppState = any> {
@@ -442,8 +455,23 @@ export interface LogoutOptions extends LogoutUrlOptions {
    *     window.location.replace(url);
    *   }
    * });
+   * @deprecated since v2.0.1, use `openUrl` instead.
    */
   onRedirect?: (url: string) => Promise<void>;
+
+  /**
+   * Used to control the redirect and not rely on the SDK to do the actual redirect.
+   *
+   * Set to `false` to disable the redirect, or provide a function to handle the actual redirect yourself.
+   *
+   * @example
+   * await auth0.logout({
+   *   async openUrl(url) {
+   *     window.location.replace(url);
+   *   }
+   * });
+   */
+  openUrl?: false | ((url: string) => Promise<void>);
 }
 
 /**


Test patch:
diff --git a/__tests__/Auth0Client/helpers.ts b/__tests__/Auth0Client/helpers.ts
index eb997a1..794282c 100644
--- a/__tests__/Auth0Client/helpers.ts
+++ b/__tests__/Auth0Client/helpers.ts
@@ -22,6 +22,7 @@ import {
   TEST_STATE
 } from '../constants';
 import { expect } from '@jest/globals';
+import { patchOpenUrlWithOnRedirect } from '../../src/Auth0Client.utils';
 
 const authorizationResponse: AuthenticationResult = {
   code: 'my_code',
@@ -194,7 +195,9 @@ export const loginWithRedirectFn = (mockWindow, mockFetch) => {
     } = processDefaultLoginWithRedirectOptions(testConfig);
     await auth0.loginWithRedirect(options);
 
-    if (!options?.onRedirect) {
+    const patchesOptions = options && patchOpenUrlWithOnRedirect(options);
+
+    if (!patchesOptions || patchesOptions.openUrl == null) {
       expect(mockWindow.location.assign).toHaveBeenCalled();
     }
 
diff --git a/__tests__/Auth0Client/loginWithRedirect.test.ts b/__tests__/Auth0Client/loginWithRedirect.test.ts
index 5eb1fd6..45363a9 100644
--- a/__tests__/Auth0Client/loginWithRedirect.test.ts
+++ b/__tests__/Auth0Client/loginWithRedirect.test.ts
@@ -279,6 +279,42 @@ describe('Auth0Client', () => {
       );
     });
 
+    it('should log the user in by calling window.location.replace when specifying it as openUrl', async () => {
+      const auth0 = setup();
+
+      await loginWithRedirect(auth0, {
+        authorizationParams: {
+          audience: 'test_audience'
+        },
+        openUrl: async url => window.location.replace(url)
+      });
+
+      const url = new URL(mockWindow.location.replace.mock.calls[0][0]);
+
+      assertUrlEquals(
+        url,
+        TEST_DOMAIN,
+        '/authorize',
+        {
+          audience: 'test_audience'
+        },
+        false
+      );
+    });
+
+    it('skips `window.location.assign` when `options.openUrl` is provided', async () => {
+      const auth0 = setup();
+
+      await loginWithRedirect(auth0, {
+        authorizationParams: {
+          audience: 'test_audience'
+        },
+        openUrl: async () => {}
+      });
+
+      expect(window.location.assign).not.toHaveBeenCalled();
+    });
+
     it('should log the user in with custom params', async () => {
       const auth0 = setup();
 
@@ -456,11 +492,13 @@ describe('Auth0Client', () => {
       await loginWithRedirect(auth0);
 
       expect(<jest.Mock>esCookie.remove).toHaveBeenCalledWith(
-        `auth0.${TEST_CLIENT_ID}.organization_hint`, {}
+        `auth0.${TEST_CLIENT_ID}.organization_hint`,
+        {}
       );
 
       expect(<jest.Mock>esCookie.remove).toHaveBeenCalledWith(
-        `_legacy_auth0.${TEST_CLIENT_ID}.organization_hint`, {}
+        `_legacy_auth0.${TEST_CLIENT_ID}.organization_hint`,
+        {}
       );
     });
 
diff --git a/__tests__/Auth0Client/logout.test.ts b/__tests__/Auth0Client/logout.test.ts
index 06553aa..8d30166 100644
--- a/__tests__/Auth0Client/logout.test.ts
+++ b/__tests__/Auth0Client/logout.test.ts
@@ -155,10 +155,21 @@ describe('Auth0Client', () => {
       );
     });
 
-    it('removes the organization hint cookie from storage when `options.onRedirect` is set', async () => {
+    it('removes authenticated cookie from storage when `options.openUrl` is set', async () => {
       const auth0 = setup();
 
-      await auth0.logout({ onRedirect: async () => {} });
+      await auth0.logout({ openUrl: async () => {} });
+
+      expect(esCookie.remove).toHaveBeenCalledWith(
+        `auth0.${TEST_CLIENT_ID}.is.authenticated`,
+        {}
+      );
+    });
+
+    it('removes the organization hint cookie from storage when `options.openUrl` is set', async () => {
+      const auth0 = setup();
+
+      await auth0.logout({ openUrl: async () => {} });
 
       expect(esCookie.remove).toHaveBeenCalledWith(
         `auth0.${TEST_CLIENT_ID}.organization_hint`,
@@ -179,6 +190,19 @@ describe('Auth0Client', () => {
       );
     });
 
+    it('skips `window.location.assign` when `options.openUrl` is provided', async () => {
+      const auth0 = setup();
+      const openUrl = jest.fn();
+      await auth0.logout({ openUrl });
+
+      expect(window.location.assign).not.toHaveBeenCalled();
+      expect(openUrl).toHaveBeenCalledWith(
+        expect.stringContaining(
+          'https://auth0_domain/v2/logout?client_id=auth0_client_id'
+        )
+      );
+    });
+
     it('calls `window.location.assign` when `options.onRedirect` is not provided', async () => {
       const auth0 = setup();
 
@@ -186,6 +210,13 @@ describe('Auth0Client', () => {
       expect(window.location.assign).toHaveBeenCalled();
     });
 
+    it('calls `window.location.assign` when `options.openUrl` is not provided', async () => {
+      const auth0 = setup();
+
+      await auth0.logout();
+      expect(window.location.assign).toHaveBeenCalled();
+    });
+
     it('can access isAuthenticated immediately after local logout', async () => {
       const auth0 = setup();
 
@@ -196,6 +227,16 @@ describe('Auth0Client', () => {
       expect(await auth0.isAuthenticated()).toBe(false);
     });
 
+    it('can access isAuthenticated immediately after local logout', async () => {
+      const auth0 = setup();
+
+      await loginWithRedirect(auth0);
+      expect(await auth0.isAuthenticated()).toBe(true);
+      await auth0.logout({ openUrl: async () => {} });
+
+      expect(await auth0.isAuthenticated()).toBe(false);
+    });
+
     it('can access isAuthenticated immediately after local logout when using a custom async cache', async () => {
       const auth0 = setup({
         cache: new InMemoryAsyncCacheNoKeys()
@@ -208,6 +249,18 @@ describe('Auth0Client', () => {
       expect(await auth0.isAuthenticated()).toBe(false);
     });
 
+    it('can access isAuthenticated immediately after local logout when using a custom async cache - using openUrl', async () => {
+      const auth0 = setup({
+        cache: new InMemoryAsyncCacheNoKeys()
+      });
+
+      await loginWithRedirect(auth0);
+      expect(await auth0.isAuthenticated()).toBe(true);
+      await auth0.logout({ openUrl: async () => {} });
+
+      expect(await auth0.isAuthenticated()).toBe(false);
+    });
+
     it('can access isAuthenticated immediately after local logout when using a custom async cache', async () => {
       const auth0 = setup({
         cache: new InMemoryAsyncCacheNoKeys()
@@ -220,6 +273,18 @@ describe('Auth0Client', () => {
       expect(await auth0.isAuthenticated()).toBe(false);
     });
 
+    it('can access isAuthenticated immediately after local logout when using a custom async cache - using openUrl', async () => {
+      const auth0 = setup({
+        cache: new InMemoryAsyncCacheNoKeys()
+      });
+
+      await loginWithRedirect(auth0);
+      expect(await auth0.isAuthenticated()).toBe(true);
+      await auth0.logout({ openUrl: async () => {} });
+
+      expect(await auth0.isAuthenticated()).toBe(false);
+    });
+
     it('correctly handles a null clientId value', async () => {
       const auth0 = setup();
       await auth0.logout({ clientId: null });