Repository: auth0/auth0-spa-js
Pull request #796 (2021-09-15 11:45:53)

[SDK-2759] Re-scoping cookies and transactions to client ID
### Description

This PR makes some tweaks to the way cookies are handled with the intention of supporting using multiple instances of the client within the same app. Tweaks have also been made to the cache manager so that only cache items for a particular client ID can be cleared.

Mostly this involves scoping of:

- transaction cookie
- `is.authenticated` cookie for `checkSession`. This is automatically migrated if the old cookie is found on the system

I've added an E2E test that tries to cover this scenario as well as a new playground page that the tests run against.

### References

SDK-2759

### Testing

- [x] This change adds test coverage for new/changed/fixed functionality

### Checklist

- [x] 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`


lgtm-com[bot]: This pull request **introduces 1 alert** when merging 402cbbce16fe40d03a16ee980b5b08930797e8a1 into e0c69fae809255efdb37c220a4460ce95dcd6839 - [view on LGTM.com](https://lgtm.com/projects/g/auth0/auth0-spa-js/rev/pr-8b4fd21353a788dd60b8e28d2ce3218d913a2e9c)

**new alerts:**

* 1 for Unused variable, import, function or class

Patch:
diff --git a/.vscode/launch.json b/.vscode/launch.json
index c53101a..1c13928 100644
--- a/.vscode/launch.json
+++ b/.vscode/launch.json
@@ -12,7 +12,8 @@
       "args": ["${fileBasenameNoExtension}"],
       "console": "integratedTerminal",
       "internalConsoleOptions": "neverOpen",
-      "disableOptimisticBPs": true
+      "disableOptimisticBPs": true,
+      "skipFiles": ["<node_internals>/**"]
     },
     {
       "type": "node",
@@ -22,7 +23,8 @@
       "args": ["--runInBand"],
       "console": "integratedTerminal",
       "internalConsoleOptions": "neverOpen",
-      "disableOptimisticBPs": true
+      "disableOptimisticBPs": true,
+      "skipFiles": ["<node_internals>/**"]
     },
     {
       "type": "node",
diff --git a/cypress/integration/loginWithRedirect.js b/cypress/integration/loginWithRedirect.js
index 6f240a6..ef27f12 100644
--- a/cypress/integration/loginWithRedirect.js
+++ b/cypress/integration/loginWithRedirect.js
@@ -1,4 +1,4 @@
-import { whenReady, shouldInclude, tolerance } from '../support/utils';
+import { whenReady, shouldInclude, tolerance, get } from '../support/utils';
 
 describe('loginWithRedirect', function () {
   beforeEach(cy.resetTests);
@@ -11,10 +11,16 @@ describe('loginWithRedirect', function () {
       cy.url().should(url => shouldInclude(url, 'http://127.0.0.1:3000'));
 
       whenReady().then(win => {
-        expect(win.sessionStorage.getItem('a0.spajs.txs')).to.exist;
-
-        cy.handleRedirectCallback().then(() => {
-          expect(win.sessionStorage.getItem('a0.spajs.txs')).to.not.exist;
+        get('client-id').then($clientIdBox => {
+          expect(
+            win.sessionStorage.getItem(`a0.spajs.txs.${$clientIdBox.val()}`)
+          ).to.exist;
+
+          cy.handleRedirectCallback().then(() => {
+            expect(
+              win.sessionStorage.getItem(`a0.spajs.txs.${$clientIdBox.val()}`)
+            ).to.not.exist;
+          });
         });
       });
     });
@@ -30,16 +36,18 @@ describe('loginWithRedirect', function () {
     cy.url().should(url => shouldInclude(url, 'http://127.0.0.1:3000'));
     whenReady();
 
-    cy.getCookie('a0.spajs.txs')
-      .should('exist')
-      .should(cookie => {
-        // Check that the cookie value is at least within a second of what we expect, to make
-        // the test a little less brittle.
-        expect(tolerance(cookie.expiry, tomorrowInSeconds, 1)).to.be.true;
-      });
+    get('client-id').then($clientIdBox => {
+      cy.getCookie(`a0.spajs.txs.${$clientIdBox.val()}`)
+        .should('exist')
+        .should(cookie => {
+          // Check that the cookie value is at least within a second of what we expect, to make
+          // the test a little less brittle.
+          expect(tolerance(cookie.expiry, tomorrowInSeconds, 1)).to.be.true;
+        });
 
-    cy.handleRedirectCallback().then(() => {
-      cy.getCookie('a0.spajs.txs').should('not.exist');
+      cy.handleRedirectCallback().then(() => {
+        cy.getCookie(`a0.spajs.txs.${$clientIdBox.val()}`).should('not.exist');
+      });
     });
   });
 });
diff --git a/cypress/integration/multiple_clients.js b/cypress/integration/multiple_clients.js
new file mode 100644
index 0000000..0a95288
--- /dev/null
+++ b/cypress/integration/multiple_clients.js
@@ -0,0 +1,44 @@
+import { whenReady, shouldInclude, tolerance, get } from '../support/utils';
+
+const login = instanceId => {
+  get(`client-login-${instanceId}`).click();
+  cy.get('.login-card input[name=login]').clear().type('test');
+  cy.get('.login-card input[name=password]').clear().type('test');
+
+  cy.get('.login-submit').click();
+  // Need to click one more time to give consent.
+  // It is actually a different button with the same class.
+  cy.get('.login-submit').click();
+};
+
+describe('using multiple clients in the app', () => {
+  beforeEach(() => {
+    cy.server();
+    cy.visit('http://127.0.0.1:3000/multiple_clients.html');
+    get('client-logout-1').click();
+    cy.window().then(win => win.localStorage.clear());
+    cy.visit('http://127.0.0.1:3000/multiple_clients.html');
+  });
+
+  afterEach(cy.fixCookies);
+
+  it('can log into just one client', () => {
+    whenReady();
+
+    // Get a token for the exact client we log into and no more
+    login(1);
+    get('client-access-token-1').should('not.be.empty');
+    get('client-access-token-2').should('be.empty');
+    get('client-access-token-3').should('be.empty');
+
+    // Logging into a second client should not work
+    get('client-token-2').click();
+
+    shouldInclude(get('client-error-2'), 'requested scopes not granted');
+
+    // Verify check session
+    cy.reload();
+    whenReady();
+    get('client-access-token-1').should('not.be.empty');
+  });
+});
diff --git a/cypress/support/utils.js b/cypress/support/utils.js
index eaf3319..24d0889 100644
--- a/cypress/support/utils.js
+++ b/cypress/support/utils.js
@@ -5,6 +5,9 @@ module.exports.shouldBe = (expected, actual) => expect(actual).to.eq(expected);
 module.exports.shouldInclude = (expected, actual) =>
   expect(actual).to.include(actual);
 
+// Gets an element using its `data-cy` attribute name
+module.exports.get = attrId => cy.get(`[data-cy=${attrId}]`);
+
 module.exports.shouldNotBe = (expected, actual) =>
   expect(actual).to.not.eq(expected);
 
diff --git a/scripts/oidc-provider.js b/scripts/oidc-provider.js
index c7eb574..eb4aaab 100644
--- a/scripts/oidc-provider.js
+++ b/scripts/oidc-provider.js
@@ -23,6 +23,33 @@ const config = {
       redirect_uris: ['http://127.0.0.1:3000', 'http://localhost:3000'],
       token_endpoint_auth_method: 'none',
       grant_types: ['authorization_code', 'refresh_token']
+    },
+    {
+      client_id: 'multi-client-1',
+      redirect_uris: [
+        'http://127.0.0.1:3000/multiple_clients.html',
+        'http://localhost:3000/multiple_clients.html'
+      ],
+      token_endpoint_auth_method: 'none',
+      grant_types: ['authorization_code', 'refresh_token']
+    },
+    {
+      client_id: 'multi-client-2',
+      redirect_uris: [
+        'http://127.0.0.1:3000/multiple_clients.html',
+        'http://localhost:3000/multiple_clients.html'
+      ],
+      token_endpoint_auth_method: 'none',
+      grant_types: ['authorization_code', 'refresh_token']
+    },
+    {
+      client_id: 'multi-client-3',
+      redirect_uris: [
+        'http://127.0.0.1:3000/multiple_clients.html',
+        'http://localhost:3000/multiple_clients.html'
+      ],
+      token_endpoint_auth_method: 'none',
+      grant_types: ['authorization_code', 'refresh_token']
     }
   ],
   claims: {
diff --git a/src/Auth0Client.ts b/src/Auth0Client.ts
index 53864b9..6ae2850 100644
--- a/src/Auth0Client.ts
+++ b/src/Auth0Client.ts
@@ -73,6 +73,7 @@ import {
 import TokenWorker from './worker/token.worker.ts';
 import { isIE11 } from './user-agent';
 import { singlePromise, retryPromise } from './promise-utils';
+import { CacheKeyManifest } from './cache/key-manifest';
 
 /**
  * @ignore
@@ -83,7 +84,6 @@ const lock = new Lock();
  * @ignore
  */
 const GET_TOKEN_SILENTLY_LOCK_KEY = 'auth0.lock.getTokenSilently';
-const COOKIE_IS_AUTHENTICATED_HINT = 'auth0.is.authenticated';
 
 /**
  * @ignore
@@ -91,6 +91,17 @@ const COOKIE_IS_AUTHENTICATED_HINT = 'auth0.is.authenticated';
 const buildOrganizationHintCookieName = (clientId: string) =>
   `auth0.${clientId}.organization_hint`;
 
+/**
+ * @ignore
+ */
+const OLD_IS_AUTHENTICATED_COOKIE_NAME = 'auth0.is.authenticated';
+
+/**
+ * @ignore
+ */
+const buildIsAuthenticatedCookieName = (clientId: string) =>
+  `auth0.${clientId}.is.authenticated`;
+
 /**
  * @ignore
  */
@@ -174,6 +185,7 @@ export default class Auth0Client {
   private cookieStorage: ClientStorage;
   private sessionCheckExpiryDays: number;
   private orgHintCookieName: string;
+  private isAuthenticatedCookieName: string;
 
   cacheLocation: CacheLocation;
   private worker: Worker;
@@ -210,6 +222,10 @@ export default class Auth0Client {
       this.options.client_id
     );
 
+    this.isAuthenticatedCookieName = buildIsAuthenticatedCookieName(
+      this.options.client_id
+    );
+
     this.sessionCheckExpiryDays =
       options.sessionCheckExpiryDays || DEFAULT_SESSION_CHECK_EXPIRY_DAYS;
 
@@ -218,8 +234,19 @@ export default class Auth0Client {
       : SessionStorage;
 
     this.scope = this.options.scope;
-    this.transactionManager = new TransactionManager(transactionStorage);
-    this.cacheManager = new CacheManager(cache, this.options.client_id);
+
+    this.transactionManager = new TransactionManager(
+      transactionStorage,
+      this.options.client_id
+    );
+
+    this.cacheManager = new CacheManager(
+      cache,
+      !cache.allKeys
+        ? new CacheKeyManifest(cache, this.options.client_id)
+        : null
+    );
+
     this.domainUrl = getDomain(this.options.domain);
     this.tokenIssuer = getTokenIssuer(this.options.issuer, this.domainUrl);
 
@@ -478,7 +505,7 @@ export default class Auth0Client {
 
     await this.cacheManager.set(cacheEntry);
 
-    this.cookieStorage.save(COOKIE_IS_AUTHENTICATED_HINT, true, {
+    this.cookieStorage.save(this.isAuthenticatedCookieName, true, {
       daysUntilExpire: this.sessionCheckExpiryDays
     });
 
@@ -636,7 +663,7 @@ export default class Auth0Client {
 
     await this.cacheManager.set(cacheEntry);
 
-    this.cookieStorage.save(COOKIE_IS_AUTHENTICATED_HINT, true, {
+    this.cookieStorage.save(this.isAuthenticatedCookieName, true, {
       daysUntilExpire: this.sessionCheckExpiryDays
     });
 
@@ -656,7 +683,7 @@ export default class Auth0Client {
    * with `getTokenSilently` is that this doesn't return a token, but it will
    * pre-fill the token cache.
    *
-   * This method also heeds the `auth0.is.authenticated` cookie, as an optimization
+   * This method also heeds the `auth0.{clientId}.is.authenticated` cookie, as an optimization
    *  to prevent calling Auth0 unnecessarily. If the cookie is not present because
    * there was no previous login (or it has expired) then tokens will not be refreshed.
    *
@@ -667,8 +694,17 @@ export default class Auth0Client {
    * @param options
    */
   public async checkSession(options?: GetTokenSilentlyOptions) {
-    if (!this.cookieStorage.get(COOKIE_IS_AUTHENTICATED_HINT)) {
-      return;
+    if (!this.cookieStorage.get(this.isAuthenticatedCookieName)) {
+      if (!this.cookieStorage.get(OLD_IS_AUTHENTICATED_COOKIE_NAME)) {
+        return;
+      } else {
+        // Migrate the existing cookie to the new name scoped by client ID
+        this.cookieStorage.save(this.isAuthenticatedCookieName, true, {
+          daysUntilExpire: this.sessionCheckExpiryDays
+        });
+
+        this.cookieStorage.remove(OLD_IS_AUTHENTICATED_COOKIE_NAME);
+      }
     }
 
     try {
@@ -776,7 +812,7 @@ export default class Auth0Client {
           ...authResult
         });
 
-        this.cookieStorage.save(COOKIE_IS_AUTHENTICATED_HINT, true, {
+        this.cookieStorage.save(this.isAuthenticatedCookieName, true, {
           daysUntilExpire: this.sessionCheckExpiryDays
         });
 
@@ -895,8 +931,8 @@ export default class Auth0Client {
     }
 
     const postCacheClear = () => {
-      this.cookieStorage.remove(COOKIE_IS_AUTHENTICATED_HINT);
       this.cookieStorage.remove(this.orgHintCookieName);
+      this.cookieStorage.remove(this.isAuthenticatedCookieName);
 
       if (localOnly) {
         return;
diff --git a/src/cache/cache-manager.ts b/src/cache/cache-manager.ts
index 9b7c6c8..70fd105 100644
--- a/src/cache/cache-manager.ts
+++ b/src/cache/cache-manager.ts
@@ -11,15 +11,7 @@ import {
 const DEFAULT_EXPIRY_ADJUSTMENT_SECONDS = 0;
 
 export class CacheManager {
-  private readonly keyManifest?: CacheKeyManifest;
-
-  constructor(private cache: ICache, clientId: string) {
-    // If the cache implementation doesn't provide an `allKeys` method,
-    // use a built-in key manifest.
-    if (!cache.allKeys) {
-      this.keyManifest = new CacheKeyManifest(this.cache, clientId);
-    }
-  }
+  constructor(private cache: ICache, private keyManifest?: CacheKeyManifest) {}
 
   async get(
     cacheKey: CacheKey,
@@ -77,15 +69,18 @@ export class CacheManager {
     await this.keyManifest?.add(cacheKey.toKey());
   }
 
-  async clear(): Promise<void> {
+  async clear(clientId?: string): Promise<void> {
     const keys = await this.getCacheKeys();
 
     /* istanbul ignore next */
     if (!keys) return;
 
-    keys.forEach(async key => {
-      await this.cache.remove(key);
-    });
+    await keys
+      .filter(key => (clientId ? key.includes(clientId) : true))
+      .reduce(async (memo, key) => {
+        await memo;
+        await this.cache.remove(key);
+      }, Promise.resolve());
 
     await this.keyManifest?.clear();
   }
@@ -93,15 +88,17 @@ export class CacheManager {
   /**
    * Note: only call this if you're sure one of our internal (synchronous) caches are being used.
    */
-  clearSync(): void {
+  clearSync(clientId?: string): void {
     const keys = this.cache.allKeys() as string[];
 
     /* istanbul ignore next */
     if (!keys) return;
 
-    keys.forEach(key => {
-      this.cache.remove(key);
-    });
+    keys
+      .filter(key => (clientId ? key.includes(clientId) : true))
+      .forEach(key => {
+        this.cache.remove(key);
+      });
   }
 
   private wrapCacheEntry(entry: CacheEntry): WrappedCacheEntry {
diff --git a/src/cache/key-manifest.ts b/src/cache/key-manifest.ts
index 92ae553..b9a46fa 100644
--- a/src/cache/key-manifest.ts
+++ b/src/cache/key-manifest.ts
@@ -9,7 +9,7 @@ export class CacheKeyManifest {
   private readonly manifestKey: string;
 
   constructor(private cache: ICache, private clientId: string) {
-    this.manifestKey = this.createManifestKeyFrom(clientId);
+    this.manifestKey = this.createManifestKeyFrom(this.clientId);
   }
 
   async add(key: string): Promise<void> {
diff --git a/src/transaction-manager.ts b/src/transaction-manager.ts
index be45f67..e81a220 100644
--- a/src/transaction-manager.ts
+++ b/src/transaction-manager.ts
@@ -1,6 +1,6 @@
 import { ClientStorage } from './storage';
 
-const TRANSACTION_STORAGE_KEY = 'a0.spajs.txs';
+const TRANSACTION_STORAGE_KEY_PREFIX = 'a0.spajs.txs';
 
 interface Transaction {
   nonce: string;
@@ -14,15 +14,17 @@ interface Transaction {
 
 export default class TransactionManager {
   private transaction: Transaction;
+  private storageKey: string;
 
-  constructor(private storage: ClientStorage) {
-    this.transaction = this.storage.get(TRANSACTION_STORAGE_KEY);
+  constructor(private storage: ClientStorage, private clientId: string) {
+    this.storageKey = `${TRANSACTION_STORAGE_KEY_PREFIX}.${clientId}`;
+    this.transaction = this.storage.get(this.storageKey);
   }
 
   public create(transaction: Transaction) {
     this.transaction = transaction;
 
-    this.storage.save(TRANSACTION_STORAGE_KEY, transaction, {
+    this.storage.save(this.storageKey, transaction, {
       daysUntilExpire: 1
     });
   }
@@ -33,6 +35,6 @@ export default class TransactionManager {
 
   public remove() {
     delete this.transaction;
-    this.storage.remove(TRANSACTION_STORAGE_KEY);
+    this.storage.remove(this.storageKey);
   }
 }
diff --git a/static/index.html b/static/index.html
index bcce3be..fd61df0 100644
--- a/static/index.html
+++ b/static/index.html
@@ -203,6 +203,7 @@
             class="form-control"
             id="client_id"
             v-model="clientId"
+            data-cy="client-id"
           />
         </div>
 
diff --git a/static/multiple_clients.html b/static/multiple_clients.html
new file mode 100644
index 0000000..5df64bc
--- /dev/null
+++ b/static/multiple_clients.html
@@ -0,0 +1,212 @@
+<!DOCTYPE html>
+<html lang="en">
+  <head>
+    <meta charset="UTF-8" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <link
+      href="https://unpkg.com/tailwindcss@^2/dist/tailwind.min.css"
+      rel="stylesheet"
+    />
+    <title>Document</title>
+  </head>
+  <body>
+    <div id="app" class="container mx-auto p-5">
+      <div v-if="!loading" style="visibility: hidden">
+        <span id="loaded">loaded</span>
+      </div>
+
+      <header class="mb-10">
+        <h1 class="text-4xl mb-2">Multiple Client Test</h1>
+        <p>
+          <a
+            href="http://localhost:3000/multiple_clients.html"
+            class="text-gray-400 underline"
+            >Reset URL</a
+          >
+        </p>
+      </header>
+
+      <div x-data="clientData">
+        <div class="mb-5" v-for="client in clients" :key="client.id">
+          <h2 v-text="client.name" class="text-2xl"></h2>
+          <p class="text-gray-400" v-text="client.description"></p>
+          <p class="text-gray-400 mb-5" v-text="client.options.client_id"></p>
+          <button
+            type="submit"
+            class="p-2 rounded bg-blue-700 text-white"
+            @click="loginWith(client)"
+            :data-cy="`client-login-${client.id}`"
+          >
+            Log in
+          </button>
+          <button
+            class="p-2 rounded bg-red-700 text-white"
+            :data-cy="`client-token-${client.id}`"
+            @click="getTokenWith(client)"
+          >
+            Get Token
+          </button>
+          <button
+            class="p-2 rounded bg-gray-700 text-white"
+            :data-cy="`client-logout-${client.id}`"
+            @click="logoutOf(client)"
+          >
+            Log out
+          </button>
+          <div class="mt-5">
+            <div>
+              <p
+                v-text="client.access_token"
+                :data-cy="`client-access-token-${client.id}`"
+              ></p>
+            </div>
+            <template v-if="client.error !== null">
+              <p
+                class="text-red-500"
+                v-text="client.error"
+                :data-cy="`client-error-${client.id}`"
+              ></p>
+            </template>
+          </div>
+        </div>
+      </div>
+    </div>
+
+    <script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.js"></script>
+    <script src="/auth0-spa-js.development.js"></script>
+    <script>
+      new Vue({
+        el: '#app',
+        data() {
+          const useAuth0 = false;
+
+          const commonOptions = {
+            redirect_uri: `${window.location.origin}/multiple_clients.html`,
+            cacheLocation: 'localstorage',
+            useFormData: true,
+            domain: useAuth0 ? 'brucke.auth0.com' : window.location.origin,
+            tokenIssuer: window.location.origin
+          };
+
+          return {
+            loading: true,
+            clients: [
+              {
+                name: 'Client 1',
+                get description() {
+                  return `Using ${commonOptions.domain} and iframe with prompt=none`;
+                },
+                id: 1,
+                options: {
+                  ...commonOptions,
+                  client_id: useAuth0
+                    ? 'IRLdsf27vEPiV5NNQjlNK7fwgmRuwyJY'
+                    : 'multi-client-1'
+                },
+                id_token: '',
+                access_token: '',
+                error: null
+              },
+              {
+                name: 'Client 2',
+                get description() {
+                  return `Using ${this.options.domain} and refresh tokens`;
+                },
+                id: 2,
+                options: {
+                  ...commonOptions,
+                  client_id: useAuth0
+                    ? 'Q4TgHIYRcUBh2xe6l049ZR7X5JAeLzwI'
+                    : 'multi-client-2',
+                  useRefreshTokens: true
+                },
+                id_token: '',
+                access_token: '',
+                error: null
+              },
+              {
+                name: 'Client 3',
+                get description() {
+                  return `Using ${this.options.domain} and cookies for transactions`;
+                },
+                id: 3,
+                options: {
+                  ...commonOptions,
+                  client_id: useAuth0
+                    ? 'Y7VQRCOOVeU7b0Y36cLA5OS4fUbIJfwV'
+                    : 'multi-client-3',
+                  useCookiesForTransactions: true
+                },
+                id_token: '',
+                access_token: '',
+                error: null
+              }
+            ]
+          };
+        },
+        async created() {
+          const authFn = async client => {
+            if (await client.auth0.isAuthenticated()) {
+              client.id_token = (await client.auth0.getIdTokenClaims()).__raw;
+              client.access_token = await client.auth0.getTokenSilently();
+            }
+          };
+
+          this.clients.map(client => {
+            client.auth0 = new Auth0Client(client.options);
+          });
+
+          if (
+            window.location.search.includes('code=') &&
+            window.location.search.includes('state=')
+          ) {
+            const id = localStorage.getItem('login_id');
+
+            if (id) {
+              const client = this.clients.find(c => c.id === parseInt(id));
+
+              if (client) {
+                console.log('Handling callback');
+                await client.auth0.handleRedirectCallback();
+                history.pushState({}, null, '/multiple_clients.html');
+                await authFn(client);
+              }
+            }
+          }
+
+          await Promise.all(
+            this.clients.map(async client => {
+              await client.auth0.checkSession();
+              await authFn(client);
+            })
+          );
+
+          this.loading = false;
+        },
+        methods: {
+          loginWith(client) {
+            localStorage.setItem('login_id', client.id);
+            client.auth0.loginWithRedirect();
+          },
+          async getTokenWith(client) {
+            try {
+              client.access_token = await client.auth0.getTokenSilently({
+                ignoreCache: true
+              });
+
+              client.error = null;
+            } catch (e) {
+              client.error = e.error_description;
+            }
+          },
+          logoutOf(client) {
+            client.auth0.logout({
+              returnTo: `${window.location.origin}/multiple_clients.html`,
+              client_id: client.options.client_id
+            });
+          }
+        }
+      });
+    </script>
+  </body>
+</html>


Test patch:
diff --git a/__tests__/Auth0Client/checkSession.test.ts b/__tests__/Auth0Client/checkSession.test.ts
index 8b77b74..902d063 100644
--- a/__tests__/Auth0Client/checkSession.test.ts
+++ b/__tests__/Auth0Client/checkSession.test.ts
@@ -121,11 +121,11 @@ describe('Auth0Client', () => {
       await checkSession(auth0);
 
       expect(<jest.Mock>esCookie.get).toHaveBeenCalledWith(
-        'auth0.is.authenticated'
+        `auth0.${TEST_CLIENT_ID}.is.authenticated`
       );
 
       expect(<jest.Mock>esCookie.get).toHaveBeenCalledWith(
-        '_legacy_auth0.is.authenticated'
+        `_legacy_auth0.${TEST_CLIENT_ID}.is.authenticated`
       );
     });
 
@@ -137,14 +137,43 @@ describe('Auth0Client', () => {
       await checkSession(auth0);
 
       expect(<jest.Mock>esCookie.get).toHaveBeenCalledWith(
-        'auth0.is.authenticated'
+        `auth0.${TEST_CLIENT_ID}.is.authenticated`
       );
 
       expect(<jest.Mock>esCookie.get).not.toHaveBeenCalledWith(
-        '_legacy_auth0.is.authenticated'
+        `_legacy_auth0.${TEST_CLIENT_ID}.is.authenticated`
       );
     });
 
+    it('migrates the old is.authenticated cookie to the new name', async () => {
+      const auth0 = setup();
+
+      (esCookie.get as jest.Mock).mockImplementation(name => {
+        switch (name) {
+          case 'auth0.is.authenticated':
+            return true;
+          case `auth0.${TEST_CLIENT_ID}.is.authenticated`:
+            return;
+        }
+      });
+
+      await checkSession(auth0);
+
+      expect(esCookie.get).toHaveBeenCalledWith(
+        `auth0.${TEST_CLIENT_ID}.is.authenticated`
+      );
+
+      expect(esCookie.get).toHaveBeenCalledWith(`auth0.is.authenticated`);
+
+      expect(esCookie.set).toHaveBeenCalledWith(
+        `auth0.${TEST_CLIENT_ID}.is.authenticated`,
+        'true',
+        { expires: 1 }
+      );
+
+      expect(esCookie.remove).toHaveBeenCalledWith('auth0.is.authenticated');
+    });
+
     it('uses the organization hint cookie if available', async () => {
       const auth0 = setup();
 
diff --git a/__tests__/Auth0Client/getTokenSilently.test.ts b/__tests__/Auth0Client/getTokenSilently.test.ts
index 1cb9d56..320ab6a 100644
--- a/__tests__/Auth0Client/getTokenSilently.test.ts
+++ b/__tests__/Auth0Client/getTokenSilently.test.ts
@@ -1337,7 +1337,7 @@ describe('Auth0Client', () => {
       await getTokenSilently(auth0);
 
       expect(<jest.Mock>esCookie.set).toHaveBeenCalledWith(
-        '_legacy_auth0.is.authenticated',
+        `_legacy_auth0.${TEST_CLIENT_ID}.is.authenticated`,
         'true',
         {
           expires: 1
@@ -1345,7 +1345,7 @@ describe('Auth0Client', () => {
       );
 
       expect(<jest.Mock>esCookie.set).toHaveBeenCalledWith(
-        'auth0.is.authenticated',
+        `auth0.${TEST_CLIENT_ID}.is.authenticated`,
         'true',
         {
           expires: 1
@@ -1366,14 +1366,15 @@ describe('Auth0Client', () => {
       await getTokenSilently(auth0);
 
       expect(<jest.Mock>esCookie.set).toHaveBeenCalledWith(
-        '_legacy_auth0.is.authenticated',
+        `_legacy_auth0.${TEST_CLIENT_ID}.is.authenticated`,
         'true',
         {
           expires: 2
         }
       );
+
       expect(<jest.Mock>esCookie.set).toHaveBeenCalledWith(
-        'auth0.is.authenticated',
+        `auth0.${TEST_CLIENT_ID}.is.authenticated`,
         'true',
         {
           expires: 2
diff --git a/__tests__/Auth0Client/loginWithPopup.test.ts b/__tests__/Auth0Client/loginWithPopup.test.ts
index 2242097..4014465 100644
--- a/__tests__/Auth0Client/loginWithPopup.test.ts
+++ b/__tests__/Auth0Client/loginWithPopup.test.ts
@@ -580,7 +580,7 @@ describe('Auth0Client', () => {
       await loginWithPopup(auth0);
 
       expect(<jest.Mock>esCookie.set).toHaveBeenCalledWith(
-        '_legacy_auth0.is.authenticated',
+        `_legacy_auth0.${TEST_CLIENT_ID}.is.authenticated`,
         'true',
         {
           expires: 1
@@ -588,7 +588,7 @@ describe('Auth0Client', () => {
       );
 
       expect(<jest.Mock>esCookie.set).toHaveBeenCalledWith(
-        'auth0.is.authenticated',
+        `auth0.${TEST_CLIENT_ID}.is.authenticated`,
         'true',
         {
           expires: 1
@@ -636,14 +636,14 @@ describe('Auth0Client', () => {
       await loginWithPopup(auth0);
 
       expect(<jest.Mock>esCookie.set).toHaveBeenCalledWith(
-        '_legacy_auth0.is.authenticated',
+        `_legacy_auth0.${TEST_CLIENT_ID}.is.authenticated`,
         'true',
         {
           expires: 2
         }
       );
       expect(<jest.Mock>esCookie.set).toHaveBeenCalledWith(
-        'auth0.is.authenticated',
+        `auth0.${TEST_CLIENT_ID}.is.authenticated`,
         'true',
         {
           expires: 2
diff --git a/__tests__/Auth0Client/loginWithRedirect.test.ts b/__tests__/Auth0Client/loginWithRedirect.test.ts
index f28f223..a74b40c 100644
--- a/__tests__/Auth0Client/loginWithRedirect.test.ts
+++ b/__tests__/Auth0Client/loginWithRedirect.test.ts
@@ -290,7 +290,7 @@ describe('Auth0Client', () => {
       await auth0.loginWithRedirect();
 
       expect((sessionStorage.setItem as jest.Mock).mock.calls[0][0]).toBe(
-        'a0.spajs.txs'
+        `a0.spajs.txs.${TEST_CLIENT_ID}`
       );
     });
 
@@ -302,7 +302,7 @@ describe('Auth0Client', () => {
       // Don't necessarily need to check the contents of the cookie (the storage tests are doing that),
       // just that cookies were used when I set the correct option.
       expect((mockCookies.set as jest.Mock).mock.calls[1][0]).toEqual(
-        'a0.spajs.txs'
+        `a0.spajs.txs.${TEST_CLIENT_ID}`
       );
     });
 
@@ -415,7 +415,7 @@ describe('Auth0Client', () => {
       await loginWithRedirect(auth0);
 
       expect(<jest.Mock>esCookie.set).toHaveBeenCalledWith(
-        '_legacy_auth0.is.authenticated',
+        `_legacy_auth0.${TEST_CLIENT_ID}.is.authenticated`,
         'true',
         {
           expires: 1
@@ -423,7 +423,7 @@ describe('Auth0Client', () => {
       );
 
       expect(<jest.Mock>esCookie.set).toHaveBeenCalledWith(
-        'auth0.is.authenticated',
+        `auth0.${TEST_CLIENT_ID}.is.authenticated`,
         'true',
         {
           expires: 1
@@ -431,7 +431,7 @@ describe('Auth0Client', () => {
       );
     });
 
-    it('saves `auth0.is.authenticated` key in storage for an extended period', async () => {
+    it('saves authenticated cookie key in storage for an extended period', async () => {
       const auth0 = setup({
         sessionCheckExpiryDays: 2
       });
@@ -439,14 +439,14 @@ describe('Auth0Client', () => {
       await loginWithRedirect(auth0);
 
       expect(<jest.Mock>esCookie.set).toHaveBeenCalledWith(
-        '_legacy_auth0.is.authenticated',
+        `_legacy_auth0.${TEST_CLIENT_ID}.is.authenticated`,
         'true',
         {
           expires: 2
         }
       );
       expect(<jest.Mock>esCookie.set).toHaveBeenCalledWith(
-        'auth0.is.authenticated',
+        `auth0.${TEST_CLIENT_ID}.is.authenticated`,
         'true',
         {
           expires: 2
diff --git a/__tests__/Auth0Client/logout.test.ts b/__tests__/Auth0Client/logout.test.ts
index 20a6acb..9b59c52 100644
--- a/__tests__/Auth0Client/logout.test.ts
+++ b/__tests__/Auth0Client/logout.test.ts
@@ -5,15 +5,11 @@ import { verify } from '../../src/jwt';
 import { MessageChannel } from 'worker_threads';
 import * as utils from '../../src/utils';
 import * as scope from '../../src/scope';
-
 import { expectToHaveBeenCalledWithAuth0ClientParam } from '../helpers';
-
 import { TEST_AUTH0_CLIENT_QUERY_STRING } from '../constants';
 
 // @ts-ignore
-
-import { loginWithPopupFn, loginWithRedirectFn, setupFn } from './helpers';
-
+import { loginWithRedirectFn, setupFn } from './helpers';
 import { TEST_CLIENT_ID, TEST_CODE_CHALLENGE, TEST_DOMAIN } from '../constants';
 import { InMemoryAsyncCacheNoKeys } from '../cache/shared';
 
@@ -76,11 +72,13 @@ describe('Auth0Client', () => {
   });
 
   describe('logout()', () => {
-    it('removes `auth0.is.authenticated` key from storage', async () => {
+    it('removes authenticated cookie from storage', async () => {
       const auth0 = setup();
       auth0.logout();
 
-      expect(esCookie.remove).toHaveBeenCalledWith('auth0.is.authenticated');
+      expect(esCookie.remove).toHaveBeenCalledWith(
+        `auth0.${TEST_CLIENT_ID}.is.authenticated`
+      );
     });
 
     it('removes the organization hint cookie from storage', async () => {
@@ -126,6 +124,7 @@ describe('Auth0Client', () => {
 
     it('clears the cache', async () => {
       const auth0 = setup();
+
       jest
         .spyOn(auth0['cacheManager'], 'clearSync')
         .mockReturnValueOnce(undefined);
@@ -135,12 +134,14 @@ describe('Auth0Client', () => {
       expect(auth0['cacheManager']['clearSync']).toHaveBeenCalled();
     });
 
-    it('removes `auth0.is.authenticated` key from storage when `options.localOnly` is true', async () => {
+    it('removes authenticated cookie from storage when `options.localOnly` is true', async () => {
       const auth0 = setup();
 
       auth0.logout({ localOnly: true });
 
-      expect(esCookie.remove).toHaveBeenCalledWith('auth0.is.authenticated');
+      expect(esCookie.remove).toHaveBeenCalledWith(
+        `auth0.${TEST_CLIENT_ID}.is.authenticated`
+      );
     });
 
     it('removes the organization hint cookie from storage when `options.localOnly` is true', async () => {
diff --git a/__tests__/cache/cache-manager.test.ts b/__tests__/cache/cache-manager.test.ts
index 39b87c3..cfa00c3 100644
--- a/__tests__/cache/cache-manager.test.ts
+++ b/__tests__/cache/cache-manager.test.ts
@@ -3,6 +3,7 @@ import {
   InMemoryCache,
   LocalStorageCache
 } from '../../src/cache';
+import { CacheKeyManifest } from '../../src/cache/key-manifest';
 
 import {
   CacheEntry,
@@ -62,14 +63,18 @@ cacheFactories.forEach(cacheFactory => {
   describe(`CacheManager using ${cacheFactory.name}`, () => {
     let manager: CacheManager;
     let cache: ICache;
-    let withKeyManifest: boolean;
+    let keyManifest: CacheKeyManifest;
 
     beforeEach(() => {
       cache = cacheFactory.new();
-      manager = new CacheManager(cache, TEST_CLIENT_ID);
-      withKeyManifest = !!!cache.allKeys;
 
-      if (withKeyManifest) {
+      keyManifest = !!!cache.allKeys
+        ? new CacheKeyManifest(cache, TEST_CLIENT_ID)
+        : undefined;
+
+      manager = new CacheManager(cache, keyManifest);
+
+      if (keyManifest) {
         ['get', 'add', 'clear'].forEach((method: any) =>
           jest.spyOn(manager['keyManifest'], method)
         );
@@ -128,7 +133,7 @@ cacheFactories.forEach(cacheFactory => {
       expect(await manager.get(key)).toStrictEqual(data);
     });
 
-    if (withKeyManifest) {
+    if (keyManifest) {
       it('should update the key manifest when the key has only been added to the underlying cache', async () => {
         const manifestKey = `${CACHE_KEY_PREFIX}::${defaultData.client_id}`;
 
@@ -265,7 +270,7 @@ cacheFactories.forEach(cacheFactory => {
       expect(result).toBeFalsy();
 
       // And that the data has been removed from the key manifest
-      if (withKeyManifest) {
+      if (keyManifest) {
         expect(cacheRemoveSpy).toHaveBeenCalledWith(
           `@@auth0spajs@@::${data.client_id}`
         );
@@ -301,19 +306,21 @@ cacheFactories.forEach(cacheFactory => {
       expect(result).toBeFalsy();
 
       // And that the data has been removed from the key manifest
-      if (withKeyManifest) {
+      if (keyManifest) {
         expect(cacheRemoveSpy).toHaveBeenCalledWith(
           `@@auth0spajs@@::${data.client_id}`
         );
       }
     });
 
-    it('clears the cache', async () => {
+    it('clears all keys from the cache', async () => {
       const entry1 = { ...defaultData };
       const entry2 = { ...defaultData, scope: 'scope-1' };
+      const entry3 = { ...defaultData, client_id: 'some-other-client' };
 
       await manager.set(entry1);
       await manager.set(entry2);
+      await manager.set(entry3);
 
       expect(await manager.get(CacheKey.fromCacheEntry(entry1))).toStrictEqual(
         entry1
@@ -323,9 +330,107 @@ cacheFactories.forEach(cacheFactory => {
         entry2
       );
 
+      expect(await manager.get(CacheKey.fromCacheEntry(entry3))).toStrictEqual(
+        entry3
+      );
+
       await manager.clear();
       expect(await manager.get(CacheKey.fromCacheEntry(entry1))).toBeFalsy();
       expect(await manager.get(CacheKey.fromCacheEntry(entry2))).toBeFalsy();
+      expect(await manager.get(CacheKey.fromCacheEntry(entry3))).toBeFalsy();
+    });
+
+    it('clears only the keys relating to a specific client ID from the cache', async () => {
+      const entry1 = { ...defaultData };
+      const entry2 = { ...defaultData, scope: 'scope-1' };
+      const entry3 = { ...defaultData, client_id: 'some-other-client' };
+
+      await manager.set(entry1);
+      await manager.set(entry2);
+      await manager.set(entry3);
+
+      expect(await manager.get(CacheKey.fromCacheEntry(entry1))).toStrictEqual(
+        entry1
+      );
+
+      expect(await manager.get(CacheKey.fromCacheEntry(entry2))).toStrictEqual(
+        entry2
+      );
+
+      expect(await manager.get(CacheKey.fromCacheEntry(entry3))).toStrictEqual(
+        entry3
+      );
+
+      await manager.clear(TEST_CLIENT_ID);
+      expect(await manager.get(CacheKey.fromCacheEntry(entry1))).toBeFalsy();
+      expect(await manager.get(CacheKey.fromCacheEntry(entry2))).toBeFalsy();
+
+      // Should not be removed as it has a different client ID from the manager instance
+      expect(await manager.get(CacheKey.fromCacheEntry(entry3))).toStrictEqual(
+        entry3
+      );
+    });
+
+    it('clears all keys from the cache (sync)', async () => {
+      const manager = new CacheManager(new LocalStorageCache());
+      const entry1 = { ...defaultData };
+      const entry2 = { ...defaultData, scope: 'scope-1' };
+      const entry3 = { ...defaultData, client_id: 'some-other-client' };
+
+      await manager.set(entry1);
+      await manager.set(entry2);
+      await manager.set(entry3);
+
+      expect(await manager.get(CacheKey.fromCacheEntry(entry1))).toStrictEqual(
+        entry1
+      );
+
+      expect(await manager.get(CacheKey.fromCacheEntry(entry2))).toStrictEqual(
+        entry2
+      );
+
+      expect(await manager.get(CacheKey.fromCacheEntry(entry3))).toStrictEqual(
+        entry3
+      );
+
+      manager.clearSync();
+
+      expect(await manager.get(CacheKey.fromCacheEntry(entry1))).toBeFalsy();
+      expect(await manager.get(CacheKey.fromCacheEntry(entry2))).toBeFalsy();
+      expect(await manager.get(CacheKey.fromCacheEntry(entry3))).toBeFalsy();
+    });
+
+    it('clears only the keys relating to a specific client ID from the cache (sync)', async () => {
+      const manager = new CacheManager(new LocalStorageCache());
+      const entry1 = { ...defaultData };
+      const entry2 = { ...defaultData, scope: 'scope-1' };
+      const entry3 = { ...defaultData, client_id: 'some-other-client' };
+
+      await manager.set(entry1);
+      await manager.set(entry2);
+      await manager.set(entry3);
+
+      expect(await manager.get(CacheKey.fromCacheEntry(entry1))).toStrictEqual(
+        entry1
+      );
+
+      expect(await manager.get(CacheKey.fromCacheEntry(entry2))).toStrictEqual(
+        entry2
+      );
+
+      expect(await manager.get(CacheKey.fromCacheEntry(entry3))).toStrictEqual(
+        entry3
+      );
+
+      manager.clearSync(TEST_CLIENT_ID);
+
+      expect(await manager.get(CacheKey.fromCacheEntry(entry1))).toBeFalsy();
+      expect(await manager.get(CacheKey.fromCacheEntry(entry2))).toBeFalsy();
+
+      // Should not be removed as it has a different client ID from the manager instance
+      expect(await manager.get(CacheKey.fromCacheEntry(entry3))).toStrictEqual(
+        entry3
+      );
     });
   });
 });
diff --git a/__tests__/index.test.ts b/__tests__/index.test.ts
index ae797f5..69cda90 100644
--- a/__tests__/index.test.ts
+++ b/__tests__/index.test.ts
@@ -4,25 +4,6 @@ import * as scope from '../src/scope';
 // @ts-ignore
 
 jest.mock('../src/jwt');
-
-jest.mock('../src/storage', () => ({
-  SessionStorage: {
-    get: jest.fn(),
-    save: jest.fn(),
-    remove: jest.fn()
-  },
-  CookieStorage: {
-    get: jest.fn(),
-    save: jest.fn(),
-    remove: jest.fn()
-  },
-  CookieStorageWithLegacySameSite: {
-    get: jest.fn(),
-    save: jest.fn(),
-    remove: jest.fn()
-  }
-}));
-
 jest.mock('../src/transaction-manager');
 jest.mock('../src/utils');
 jest.mock('../src/api');
@@ -42,10 +23,22 @@ import {
   TEST_RANDOM_STRING,
   TEST_USER_ID
 } from './constants';
+import { CookieStorage } from '../src/storage';
 
 jest.mock('../src/worker/token.worker');
 
-const setup = async (clientOptions: Partial<Auth0ClientOptions> = {}) => {
+jest.mock('../src/storage', () => ({
+  CookieStorageWithLegacySameSite: {
+    get: jest.fn(),
+    save: jest.fn(),
+    remove: jest.fn()
+  }
+}));
+
+const setup = async (
+  clientOptions: Partial<Auth0ClientOptions> = {},
+  callConstructor = true
+) => {
   const getDefaultInstance = m => require(m).default.mock.instances[0];
   const tokenVerifier = require('../src/jwt').verify;
   const utils = require('../src/utils');
@@ -92,11 +85,14 @@ const setup = async (clientOptions: Partial<Auth0ClientOptions> = {}) => {
     close: jest.fn()
   };
 
-  const auth0 = await createAuth0Client({
-    domain: TEST_DOMAIN,
-    client_id: TEST_CLIENT_ID,
-    ...clientOptions
-  });
+  const auth0 = callConstructor
+    ? await createAuth0Client({
+        domain: TEST_DOMAIN,
+        client_id: TEST_CLIENT_ID,
+        ...clientOptions
+      })
+    : undefined;
+
   const transactionManager = getDefaultInstance('../src/transaction-manager');
 
   return {
@@ -144,6 +140,11 @@ describe('Auth0', () => {
     jest.clearAllMocks();
     getUniqueScopesSpy.mockRestore();
     window.location = oldWindowLocation;
+
+    const storage = require('../src/storage').CookieStorageWithLegacySameSite;
+    storage.get.mockClear();
+    storage.save.mockClear();
+    storage.remove.mockClear();
   });
 
   describe('createAuth0Client()', () => {
@@ -239,106 +240,88 @@ describe('Auth0', () => {
       });
     });
   });
-});
 
-describe('default creation function', () => {
-  it('does nothing if there is nothing in storage', async () => {
-    jest.spyOn(Auth0Client.prototype, 'getTokenSilently');
-    const getSpy = jest
-      .spyOn(require('../src/storage').CookieStorageWithLegacySameSite, 'get')
-      .mockReturnValueOnce(false);
+  describe('default creation function', () => {
+    it('does nothing if there is nothing in storage', async () => {
+      const { cookieStorage } = await setup(null, false);
 
-    const auth0 = await createAuth0Client({
-      domain: TEST_DOMAIN,
-      client_id: TEST_CLIENT_ID
-    });
+      jest.spyOn(Auth0Client.prototype, 'getTokenSilently');
+      cookieStorage.get.mockReturnValue(undefined);
 
-    expect(getSpy).toHaveBeenCalledWith('auth0.is.authenticated');
-
-    expect(auth0.getTokenSilently).not.toHaveBeenCalled();
-  });
-
-  it('calls getTokenSilently if there is a storage item with key `auth0.is.authenticated`', async () => {
-    Auth0Client.prototype.getTokenSilently = jest.fn();
+      const auth0 = await createAuth0Client({
+        domain: TEST_DOMAIN,
+        client_id: TEST_CLIENT_ID
+      });
 
-    require('../src/storage').CookieStorage.get.mockReturnValue(true);
+      expect(cookieStorage.get).toHaveBeenCalledWith(
+        `auth0.${TEST_CLIENT_ID}.is.authenticated`
+      );
 
-    const auth0 = await createAuth0Client({
-      domain: TEST_DOMAIN,
-      client_id: TEST_CLIENT_ID
+      expect(auth0.getTokenSilently).not.toHaveBeenCalled();
     });
 
-    expect(auth0.getTokenSilently).toHaveBeenCalledWith(undefined);
-  });
-
-  describe('when refresh tokens are not used', () => {
-    it('calls getTokenSilently', async () => {
-      const utils = require('../src/utils');
-
-      const options = {
-        audience: 'the-audience',
-        scope: 'the-scope'
-      };
-
+    it('calls getTokenSilently if the authentication hint cookie is available`', async () => {
       Auth0Client.prototype.getTokenSilently = jest.fn();
 
-      require('../src/storage').get = () => true;
+      const { cookieStorage } = await setup(null, false);
+
+      cookieStorage.get.mockReturnValue(true);
 
       const auth0 = await createAuth0Client({
         domain: TEST_DOMAIN,
-        client_id: TEST_CLIENT_ID,
-        ...options
+        client_id: TEST_CLIENT_ID
       });
 
       expect(auth0.getTokenSilently).toHaveBeenCalledWith(undefined);
     });
-  });
 
-  describe('when refresh tokens are used', () => {
-    it('creates the client with the correct scopes', async () => {
-      const options = {
-        audience: 'the-audience',
-        scope: 'the-scope',
-        useRefreshTokens: true
-      };
+    describe('when refresh tokens are not used', () => {
+      it('calls getTokenSilently', async () => {
+        const { utils, cookieStorage } = await setup(null, false);
 
-      Auth0Client.prototype.getTokenSilently = jest.fn();
+        const options = {
+          audience: 'the-audience',
+          scope: 'the-scope'
+        };
 
-      require('../src/storage').get = () => true;
+        Auth0Client.prototype.getTokenSilently = jest.fn();
 
-      const auth0 = await createAuth0Client({
-        domain: TEST_DOMAIN,
-        client_id: TEST_CLIENT_ID,
-        ...options
-      });
+        cookieStorage.get.mockReturnValue(true);
 
-      expect((<any>auth0).scope).toBe('the-scope offline_access');
+        const auth0 = await createAuth0Client({
+          domain: TEST_DOMAIN,
+          client_id: TEST_CLIENT_ID,
+          ...options
+        });
 
-      expect(auth0.getTokenSilently).toHaveBeenCalledWith(undefined);
+        expect(auth0.getTokenSilently).toHaveBeenCalledWith(undefined);
+      });
     });
-  });
 
-  describe('when localstorage is used', () => {
-    it('refreshes token state regardless of isauthenticated cookie', async () => {
-      const cacheLocation: CacheLocation = 'localstorage';
+    describe('when refresh tokens are used', () => {
+      it('creates the client with the correct scopes', async () => {
+        const { cookieStorage } = await setup(null, false);
 
-      const options = {
-        audience: 'the-audience',
-        scope: 'the-scope',
-        cacheLocation
-      };
+        const options = {
+          audience: 'the-audience',
+          scope: 'the-scope',
+          useRefreshTokens: true
+        };
 
-      Auth0Client.prototype.getTokenSilently = jest.fn();
+        cookieStorage.get.mockReturnValue(true);
 
-      require('../src/storage').get = () => false;
+        Auth0Client.prototype.getTokenSilently = jest.fn();
 
-      const auth0 = await createAuth0Client({
-        domain: TEST_DOMAIN,
-        client_id: TEST_CLIENT_ID,
-        ...options
-      });
+        const auth0 = await createAuth0Client({
+          domain: TEST_DOMAIN,
+          client_id: TEST_CLIENT_ID,
+          ...options
+        });
 
-      expect(auth0.getTokenSilently).toHaveBeenCalledWith(undefined);
+        expect((<any>auth0).scope).toBe('the-scope offline_access');
+
+        expect(auth0.getTokenSilently).toHaveBeenCalledWith(undefined);
+      });
     });
   });
 });
diff --git a/__tests__/transaction-manager.test.ts b/__tests__/transaction-manager.test.ts
index 251222f..41aea35 100644
--- a/__tests__/transaction-manager.test.ts
+++ b/__tests__/transaction-manager.test.ts
@@ -1,8 +1,9 @@
 import TransactionManager from '../src/transaction-manager';
 import { SessionStorage } from '../src/storage';
+import { TEST_CLIENT_ID } from './constants';
 import { mocked } from 'ts-jest/utils';
 
-const TRANSACTION_KEY = 'a0.spajs.txs';
+const TRANSACTION_KEY_PREFIX = 'a0.spajs.txs';
 
 const transaction = {
   nonce: 'nonceIn',
@@ -15,6 +16,9 @@ const transaction = {
 
 const transactionJson = JSON.stringify(transaction);
 
+const transactionKey = (clientId = TEST_CLIENT_ID) =>
+  `${TRANSACTION_KEY_PREFIX}.${clientId}`;
+
 describe('transaction manager', () => {
   let tm: TransactionManager;
 
@@ -24,15 +28,14 @@ describe('transaction manager', () => {
 
   describe('constructor', () => {
     it('loads transactions from storage (per key)', () => {
-      tm = new TransactionManager(SessionStorage);
-
-      expect(sessionStorage.getItem).toHaveBeenCalledWith(TRANSACTION_KEY);
+      tm = new TransactionManager(SessionStorage, TEST_CLIENT_ID);
+      expect(sessionStorage.getItem).toHaveBeenCalledWith(transactionKey());
     });
   });
 
   describe('with empty transactions', () => {
     beforeEach(() => {
-      tm = new TransactionManager(SessionStorage);
+      tm = new TransactionManager(SessionStorage, TEST_CLIENT_ID);
     });
 
     it('`create` creates the transaction', () => {
@@ -45,7 +48,7 @@ describe('transaction manager', () => {
       tm.create(transaction);
 
       expect(sessionStorage.setItem).toHaveBeenCalledWith(
-        TRANSACTION_KEY,
+        transactionKey(),
         transactionJson
       );
     });
@@ -67,12 +70,14 @@ describe('transaction manager', () => {
 
     it('`remove` removes transaction from storage', () => {
       tm.create(transaction);
+
       expect(sessionStorage.setItem).toHaveBeenCalledWith(
-        TRANSACTION_KEY,
+        transactionKey(),
         transactionJson
       );
+
       tm.remove();
-      expect(sessionStorage.removeItem).toHaveBeenCalledWith(TRANSACTION_KEY);
+      expect(sessionStorage.removeItem).toHaveBeenCalledWith(transactionKey());
     });
   });
 });