Repository: auth0/auth0-spa-js
Pull request #652 (2020-11-19 15:20:39)

[SDK-2039] Change cache lookup mechanism
Given a token is stored in the cache with scopes of read:books write:books and an audience of http://my-api,
I should be able to retrieve that token by only asking for one of those scopes + audience

- Added a function to find any existing key in the cache based on the provided data (client id, audience, scopes)


frederikprijck: > I need to review this further, and I have left some comments as well, but I'm not sure about this approach.
> 
> We'll need to check this but in its current state (in general, not this PR), the scopes that are present in the key might not accurately reflect what is stored in the cache, and I think that's a bug. This scenario might occur if you ask for scopes that are not registered in the API client on Auth0. So you can _ask_ for a set of scopes, but Auth0 may not return them. In which case, the cache key _might_ include the scopes you asked for, but _might_ not necessarily appear in the cached data. Does that make sense? That's the part we need to check. What's the behaviour of the SDK today?
> 
> In which case, I'm not sure the approach taken in this PR will be accurate. Instead of looking at the scopes that are present in the cache key, we might want to look at the scopes that are actually in each cached item.



So let us take the following scenario:

- I ask for a token for `Scope:A`, `Scope:B` and `Scope:C` for `AudienceX`
- The given API only has `Scope A` and `Scope B` registered, so `C` is **not** returned.
- This will result in a cache value with the keys containing `Scope:A`, `Scope:B` and `Scope:C` (which represents the call we made) and the data containing `Scope:A` and `Scope:B` (which represents the response we got).

Now imagine some time later we do the exact same call again:
- I ask for a token for `Scope:A`, `Scope:B` and `Scope:C` for `AudienceX`
- The cache tells me I already did this call earlier and I have a token for this combination of scopes and audience. Even though the data does not contain the Scopes I am asking for, the data is the response for the request using the same scopes that I am asking for now.

Imagine in the above scenario, we decide to use the data to lookup the scopes and not the keys, in this keys we will **never** use the cache because the requested scopes differ from the data.

I also think that using the data to do the lookup is a change in behavior as to the current scenario. Because currently it is also using the key to match. The only difference is that this PR now also ensures that it doesn't have to be a strict match, but it will also use the cache if we ask for a token only for `Scope A`.

With the above, I would argue that we should not lookup based on the data but lookup based on the key.

Patch:
diff --git a/src/Auth0Client.ts b/src/Auth0Client.ts
index 6e5240d..6669d65 100644
--- a/src/Auth0Client.ts
+++ b/src/Auth0Client.ts
@@ -15,7 +15,7 @@ import {
 import { oauthToken, TokenEndpointResponse } from './api';
 
 import { getUniqueScopes } from './scope';
-import { InMemoryCache, ICache, LocalStorageCache } from './cache';
+import { InMemoryCache, ICache, LocalStorageCache, CacheKey } from './cache';
 import TransactionManager from './transaction-manager';
 import { verify as verifyIdToken } from './jwt';
 import { AuthenticationError, TimeoutError } from './errors';
@@ -423,11 +423,13 @@ export default class Auth0Client {
     const audience = options.audience || this.options.audience || 'default';
     const scope = getUniqueScopes(this.defaultScope, this.scope, options.scope);
 
-    const cache = this.cache.get({
-      client_id: this.options.client_id,
-      audience,
-      scope
-    });
+    const cache = this.cache.get(
+      new CacheKey({
+        client_id: this.options.client_id,
+        audience,
+        scope
+      })
+    );
 
     return cache && cache.decodedToken && (cache.decodedToken.user as TUser);
   }
@@ -445,11 +447,13 @@ export default class Auth0Client {
     const audience = options.audience || this.options.audience || 'default';
     const scope = getUniqueScopes(this.defaultScope, this.scope, options.scope);
 
-    const cache = this.cache.get({
-      client_id: this.options.client_id,
-      audience,
-      scope
-    });
+    const cache = this.cache.get(
+      new CacheKey({
+        client_id: this.options.client_id,
+        audience,
+        scope
+      })
+    );
 
     return cache && cache.decodedToken && cache.decodedToken.claims;
   }
@@ -632,11 +636,11 @@ export default class Auth0Client {
 
     const getAccessTokenFromCache = () => {
       const cache = this.cache.get(
-        {
+        new CacheKey({
           scope: getTokenOptions.scope,
           audience: getTokenOptions.audience || 'default',
           client_id: this.options.client_id
-        },
+        }),
         60 // get a new token if within 60 seconds of expiring
       );
 
@@ -718,11 +722,13 @@ export default class Auth0Client {
 
     await this.loginWithPopup(options, config);
 
-    const cache = this.cache.get({
-      scope: options.scope,
-      audience: options.audience || 'default',
-      client_id: this.options.client_id
-    });
+    const cache = this.cache.get(
+      new CacheKey({
+        scope: options.scope,
+        audience: options.audience || 'default',
+        client_id: this.options.client_id
+      })
+    );
 
     return cache.access_token;
   }
@@ -875,11 +881,13 @@ export default class Auth0Client {
       options.scope
     );
 
-    const cache = this.cache.get({
-      scope: options.scope,
-      audience: options.audience || 'default',
-      client_id: this.options.client_id
-    });
+    const cache = this.cache.get(
+      new CacheKey({
+        scope: options.scope,
+        audience: options.audience || 'default',
+        client_id: this.options.client_id
+      })
+    );
 
     // If you don't have a refresh token in memory
     // and you don't have a refresh token in web worker memory
diff --git a/src/cache.ts b/src/cache.ts
index 0b9480b..0a6b9f3 100644
--- a/src/cache.ts
+++ b/src/cache.ts
@@ -6,6 +6,28 @@ interface CacheKeyData {
   client_id: string;
 }
 
+export class CacheKey {
+  public client_id: string;
+  public scope: string;
+  public audience: string;
+
+  constructor(data: CacheKeyData, public prefix: string = keyPrefix) {
+    this.client_id = data.client_id;
+    this.scope = data.scope;
+    this.audience = data.audience;
+  }
+
+  toKey(): string {
+    return `${this.prefix}::${this.client_id}::${this.audience}::${this.scope}`;
+  }
+
+  static fromKey(key: string): CacheKey {
+    const [prefix, client_id, audience, scope] = key.split('::');
+
+    return new CacheKey({ client_id, scope, audience }, prefix);
+  }
+}
+
 interface DecodedToken {
   claims: IdToken;
   user: User;
@@ -25,7 +47,7 @@ interface CacheEntry {
 export interface ICache {
   save(entry: CacheEntry): void;
   get(
-    key: CacheKeyData,
+    key: CacheKey,
     expiryAdjustmentSeconds?: number
   ): Partial<CacheEntry> | undefined;
   clear(): void;
@@ -34,9 +56,6 @@ export interface ICache {
 const keyPrefix = '@@auth0spajs@@';
 const DEFAULT_EXPIRY_ADJUSTMENT_SECONDS = 0;
 
-const createKey = (e: CacheKeyData) =>
-  `${keyPrefix}::${e.client_id}::${e.audience}::${e.scope}`;
-
 type CachePayload = {
   body: Partial<CacheEntry>;
   expiresAt: number;
@@ -56,19 +75,66 @@ const wrapCacheEntry = (entry: CacheEntry): CachePayload => {
   };
 };
 
+/**
+ * Finds the corresponding key in the cache based on the provided cache key.
+ * The keys inside the cache are in the format {prefix}::{client_id}::{audience}::{scope}.
+ * The first key in the cache that satisfies the following conditions is returned
+ *  - `prefix` is strict equal to Auth0's internally configured `keyPrefix`
+ *  - `client_id` is strict equal to the `cacheKey.client_id`
+ *  - `audience` is strict equal to the `cacheKey.audience`
+ *  - `scope` contains at least all the `cacheKey.scope` values
+ *  *
+ * @param cacheKey The provided cache key
+ * @param existingCacheKeys A list of existing cache keys
+ */
+const findExistingCacheKey = (
+  cacheKey: CacheKey,
+  existingCacheKeys: Array<string>
+) => {
+  const { client_id, audience, scope } = cacheKey;
+
+  return existingCacheKeys.filter(key => {
+    const {
+      prefix: currentPrefix,
+      client_id: currentClientId,
+      audience: currentAudience,
+      scope: currentScopes
+    } = CacheKey.fromKey(key);
+    const currentScopesArr = currentScopes && currentScopes.split(' ');
+    const hasAllScopes =
+      currentScopes &&
+      scope
+        .split(' ')
+        .reduce(
+          (acc, current) => acc && currentScopesArr.includes(current),
+          true
+        );
+
+    return (
+      currentPrefix === keyPrefix &&
+      currentClientId === client_id &&
+      currentAudience === audience &&
+      hasAllScopes
+    );
+  })[0];
+};
+
 export class LocalStorageCache implements ICache {
   public save(entry: CacheEntry): void {
-    const cacheKey = createKey(entry);
+    const cacheKey = new CacheKey({
+      client_id: entry.client_id,
+      scope: entry.scope,
+      audience: entry.audience
+    });
     const payload = wrapCacheEntry(entry);
 
-    window.localStorage.setItem(cacheKey, JSON.stringify(payload));
+    window.localStorage.setItem(cacheKey.toKey(), JSON.stringify(payload));
   }
 
   public get(
-    key: CacheKeyData,
+    cacheKey: CacheKey,
     expiryAdjustmentSeconds = DEFAULT_EXPIRY_ADJUSTMENT_SECONDS
   ): Partial<CacheEntry> | undefined {
-    const cacheKey = createKey(key);
     const payload = this.readJson(cacheKey);
     const nowSeconds = Math.floor(Date.now() / 1000);
 
@@ -77,12 +143,12 @@ export class LocalStorageCache implements ICache {
     if (payload.expiresAt - expiryAdjustmentSeconds < nowSeconds) {
       if (payload.body.refresh_token) {
         const newPayload = this.stripData(payload);
-        this.writeJson(cacheKey, newPayload);
+        this.writeJson(cacheKey.toKey(), newPayload);
 
         return newPayload.body;
       }
 
-      localStorage.removeItem(cacheKey);
+      localStorage.removeItem(cacheKey.toKey());
       return;
     }
 
@@ -101,8 +167,14 @@ export class LocalStorageCache implements ICache {
    * Retrieves data from local storage and parses it into the correct format
    * @param cacheKey The cache key
    */
-  private readJson(cacheKey: string): CachePayload {
-    const json = window.localStorage.getItem(cacheKey);
+  private readJson(cacheKey: CacheKey): CachePayload {
+    const existingCacheKey = findExistingCacheKey(
+      cacheKey,
+      Object.keys(window.localStorage)
+    );
+    const json =
+      existingCacheKey && window.localStorage.getItem(existingCacheKey);
+
     let payload;
 
     if (!json) {
@@ -149,18 +221,25 @@ export class InMemoryCache {
 
     return {
       save(entry: CacheEntry) {
-        const key = createKey(entry);
+        const cacheKey = new CacheKey({
+          client_id: entry.client_id,
+          scope: entry.scope,
+          audience: entry.audience
+        });
         const payload = wrapCacheEntry(entry);
 
-        cache[key] = payload;
+        cache[cacheKey.toKey()] = payload;
       },
 
       get(
-        key: CacheKeyData,
+        cacheKey: CacheKey,
         expiryAdjustmentSeconds = DEFAULT_EXPIRY_ADJUSTMENT_SECONDS
       ): Partial<CacheEntry> | undefined {
-        const cacheKey = createKey(key);
-        const wrappedEntry: CachePayload = cache[cacheKey];
+        const existingCacheKey = findExistingCacheKey(
+          cacheKey,
+          Object.keys(cache)
+        );
+        const wrappedEntry: CachePayload = cache[existingCacheKey];
         const nowSeconds = Math.floor(Date.now() / 1000);
 
         if (!wrappedEntry) {
@@ -176,7 +255,7 @@ export class InMemoryCache {
             return wrappedEntry.body;
           }
 
-          delete cache[cacheKey];
+          delete cache[cacheKey.toKey()];
 
           return;
         }


Test patch:
diff --git a/__tests__/cache.test.ts b/__tests__/cache.test.ts
index 05805b1..7468d4e 100644
--- a/__tests__/cache.test.ts
+++ b/__tests__/cache.test.ts
@@ -1,4 +1,17 @@
-import { InMemoryCache, LocalStorageCache, ICache } from '../src/cache';
+import {
+  InMemoryCache,
+  LocalStorageCache,
+  ICache,
+  CacheKey
+} from '../src/cache';
+import {
+  TEST_ACCESS_TOKEN,
+  TEST_AUDIENCE,
+  TEST_CLIENT_ID,
+  TEST_ID_TOKEN,
+  TEST_REFRESH_TOKEN,
+  TEST_SCOPES
+} from './constants';
 
 const nowSeconds = () => Math.floor(Date.now() / 1000);
 const dayInSeconds = 86400;
@@ -38,25 +51,27 @@ describe('InMemoryCache', () => {
 
   it('returns undefined when there is no data', () => {
     expect(
-      cache.get({
-        client_id: 'test-client',
-        audience: 'a',
-        scope: 's'
-      })
+      cache.get(
+        new CacheKey({
+          client_id: TEST_CLIENT_ID,
+          audience: 'a',
+          scope: 's'
+        })
+      )
     ).toBeUndefined();
   });
 
   it('retrieves values from the cache', () => {
     const data = {
-      client_id: 'test-client',
+      client_id: TEST_CLIENT_ID,
       audience: 'the_audience',
-      scope: 'the_scope',
-      id_token: 'idtoken',
-      access_token: 'accesstoken',
+      scope: TEST_SCOPES,
+      id_token: TEST_ID_TOKEN,
+      access_token: TEST_ACCESS_TOKEN,
       expires_in: dayInSeconds,
       decodedToken: {
         claims: {
-          __raw: 'idtoken',
+          __raw: TEST_ID_TOKEN,
           exp: nowSeconds() + dayInSeconds,
           name: 'Test'
         },
@@ -67,25 +82,27 @@ describe('InMemoryCache', () => {
     cache.save(data);
 
     expect(
-      cache.get({
-        client_id: 'test-client',
-        audience: 'the_audience',
-        scope: 'the_scope'
-      })
+      cache.get(
+        new CacheKey({
+          client_id: TEST_CLIENT_ID,
+          audience: 'the_audience',
+          scope: TEST_SCOPES
+        })
+      )
     ).toStrictEqual(data);
   });
 
-  it('returns undefined from the cache when expires_in < expiryAdjustmentSeconds', () => {
+  it('retrieves values from the cache when scopes do not match', () => {
     const data = {
-      client_id: 'test-client',
+      client_id: TEST_CLIENT_ID,
       audience: 'the_audience',
-      scope: 'the_scope',
-      id_token: 'idtoken',
-      access_token: 'accesstoken',
-      expires_in: 40,
+      scope: 'the_scope the_scope2',
+      id_token: TEST_ID_TOKEN,
+      access_token: TEST_ACCESS_TOKEN,
+      expires_in: dayInSeconds,
       decodedToken: {
         claims: {
-          __raw: 'idtoken',
+          __raw: TEST_ID_TOKEN,
           exp: nowSeconds() + dayInSeconds,
           name: 'Test'
         },
@@ -97,11 +114,104 @@ describe('InMemoryCache', () => {
 
     expect(
       cache.get(
-        {
-          client_id: 'test-client',
+        new CacheKey({
+          client_id: TEST_CLIENT_ID,
           audience: 'the_audience',
           scope: 'the_scope'
+        })
+      )
+    ).toStrictEqual(data);
+  });
+
+  it('retrieves values from the cache when scopes do not match and multiple scopes are provided in a different order', () => {
+    const data = {
+      client_id: TEST_CLIENT_ID,
+      audience: 'the_audience',
+      scope: 'the_scope the_scope2 the_scope3',
+      id_token: TEST_ID_TOKEN,
+      access_token: TEST_ACCESS_TOKEN,
+      expires_in: dayInSeconds,
+      decodedToken: {
+        claims: {
+          __raw: TEST_ID_TOKEN,
+          exp: nowSeconds() + dayInSeconds,
+          name: 'Test'
         },
+        user: { name: 'Test' }
+      }
+    };
+
+    cache.save(data);
+
+    expect(
+      cache.get(
+        new CacheKey({
+          client_id: TEST_CLIENT_ID,
+          audience: 'the_audience',
+          scope: 'the_scope3 the_scope'
+        })
+      )
+    ).toStrictEqual(data);
+  });
+
+  it('returns undefined when not all scopes match', () => {
+    const data = {
+      client_id: TEST_CLIENT_ID,
+      audience: 'the_audience',
+      scope: 'the_scope the_scope2 the_scope3',
+      id_token: TEST_ID_TOKEN,
+      access_token: TEST_ACCESS_TOKEN,
+      expires_in: dayInSeconds,
+      decodedToken: {
+        claims: {
+          __raw: TEST_ID_TOKEN,
+          exp: nowSeconds() + dayInSeconds,
+          name: 'Test'
+        },
+        user: { name: 'Test' }
+      }
+    };
+
+    cache.save(data);
+
+    expect(
+      cache.get(
+        new CacheKey({
+          client_id: TEST_CLIENT_ID,
+          audience: 'the_audience',
+          scope: 'the_scope4 the_scope'
+        })
+      )
+    ).toBeUndefined();
+  });
+
+  it('returns undefined from the cache when expires_in < expiryAdjustmentSeconds', () => {
+    const data = {
+      client_id: TEST_CLIENT_ID,
+      audience: 'the_audience',
+      scope: TEST_SCOPES,
+      id_token: TEST_ID_TOKEN,
+      access_token: TEST_ACCESS_TOKEN,
+      expires_in: 40,
+      decodedToken: {
+        claims: {
+          __raw: TEST_ID_TOKEN,
+          exp: nowSeconds() + dayInSeconds,
+          name: 'Test'
+        },
+        user: { name: 'Test' }
+      }
+    };
+
+    cache.save(data);
+
+    expect(
+      cache.get(
+        new CacheKey({
+          client_id: TEST_CLIENT_ID,
+          audience: 'the_audience',
+          scope: TEST_SCOPES
+        }),
         60
       )
     ).toBeUndefined();
@@ -113,16 +223,16 @@ describe('InMemoryCache', () => {
       const realDateNow = Date.now.bind(global.Date);
 
       const data = {
-        client_id: 'test-client',
+        client_id: TEST_CLIENT_ID,
         audience: 'the_audience',
-        scope: 'the_scope',
-        id_token: 'idtoken',
-        access_token: 'accesstoken',
+        scope: TEST_SCOPES,
+        id_token: TEST_ID_TOKEN,
+        access_token: TEST_ACCESS_TOKEN,
         refresh_token: 'refreshtoken',
         expires_in: dayInSeconds,
         decodedToken: {
           claims: {
-            __raw: 'idtoken',
+            __raw: TEST_ID_TOKEN,
             name: 'Test',
             exp: nowSeconds() + dayInSeconds * 2
           },
@@ -132,20 +242,20 @@ describe('InMemoryCache', () => {
 
       cache.save(data);
 
-      const cacheEntry = {
-        client_id: 'test-client',
+      const cacheKey = new CacheKey({
+        client_id: TEST_CLIENT_ID,
         audience: 'the_audience',
-        scope: 'the_scope'
-      };
+        scope: TEST_SCOPES
+      });
 
       // Test that the cache state is normal up until just before the expiry time..
-      expect(cache.get(cacheEntry)).toStrictEqual(data);
+      expect(cache.get(cacheKey)).toStrictEqual(data);
 
       // Advance the time to just past the expiry..
       const dateNowStub = jest.fn(() => now + (dayInSeconds + 60) * 1000);
       global.Date.now = dateNowStub;
 
-      expect(cache.get(cacheEntry)).toStrictEqual({
+      expect(cache.get(cacheKey)).toStrictEqual({
         refresh_token: 'refreshtoken'
       });
 
@@ -158,15 +268,15 @@ describe('InMemoryCache', () => {
     const realDateNow = Date.now.bind(global.Date);
 
     const data = {
-      client_id: 'test-client',
+      client_id: TEST_CLIENT_ID,
       audience: 'the_audience',
-      scope: 'the_scope',
-      id_token: 'idtoken',
-      access_token: 'accesstoken',
+      scope: TEST_SCOPES,
+      id_token: TEST_ID_TOKEN,
+      access_token: TEST_ACCESS_TOKEN,
       expires_in: dayInSeconds,
       decodedToken: {
         claims: {
-          __raw: 'idtoken',
+          __raw: TEST_ID_TOKEN,
           name: 'Test',
           exp: nowSeconds() + dayInSeconds * 2
         },
@@ -176,21 +286,21 @@ describe('InMemoryCache', () => {
 
     cache.save(data);
 
-    const cacheEntry = {
-      client_id: 'test-client',
+    const cacheKey = new CacheKey({
+      client_id: TEST_CLIENT_ID,
       audience: 'the_audience',
-      scope: 'the_scope'
-    };
+      scope: TEST_SCOPES
+    });
 
     // Test that the cache state is normal before we expire the data
-    expect(cache.get(cacheEntry)).toStrictEqual(data);
+    expect(cache.get(cacheKey)).toStrictEqual(data);
 
     // Advance the time to just past the expiry..
     const dateNowStub = jest.fn(() => (now + dayInSeconds + 100) * 1000);
     global.Date.now = dateNowStub;
 
     // And test that the cache has been emptied
-    expect(cache.get(cacheEntry)).toBeUndefined();
+    expect(cache.get(cacheKey)).toBeUndefined();
 
     global.Date.now = realDateNow;
   });
@@ -200,15 +310,15 @@ describe('InMemoryCache', () => {
     const realDateNow = Date.now.bind(global.Date);
 
     const data = {
-      client_id: 'test-client',
+      client_id: TEST_CLIENT_ID,
       audience: 'the_audience',
-      scope: 'the_scope',
-      id_token: 'idtoken',
-      access_token: 'accesstoken',
+      scope: TEST_SCOPES,
+      id_token: TEST_ID_TOKEN,
+      access_token: TEST_ACCESS_TOKEN,
       expires_in: dayInSeconds * 2,
       decodedToken: {
         claims: {
-          __raw: 'idtoken',
+          __raw: TEST_ID_TOKEN,
           name: 'Test',
           exp: nowSeconds() + dayInSeconds
         },
@@ -218,21 +328,21 @@ describe('InMemoryCache', () => {
 
     cache.save(data);
 
-    const cacheEntry = {
-      client_id: 'test-client',
+    const cacheKey = new CacheKey({
+      client_id: TEST_CLIENT_ID,
       audience: 'the_audience',
-      scope: 'the_scope'
-    };
+      scope: TEST_SCOPES
+    });
 
     // Test that the cache state is normal before we expire the data
-    expect(cache.get(cacheEntry)).toStrictEqual(data);
+    expect(cache.get(cacheKey)).toStrictEqual(data);
 
     // Advance the time to just past the expiry..
     const dateNowStub = jest.fn(() => (now + dayInSeconds + 100) * 1000);
     global.Date.now = dateNowStub;
 
     // And test that the cache has been emptied
-    expect(cache.get(cacheEntry)).toBeUndefined();
+    expect(cache.get(cacheKey)).toBeUndefined();
 
     global.Date.now = realDateNow;
   });
@@ -258,15 +368,15 @@ describe('LocalStorageCache', () => {
     global.Date.now = dateStub;
 
     defaultEntry = {
-      client_id: '__TEST_CLIENT_ID__',
-      audience: '__TEST_AUDIENCE__',
-      scope: '__TEST_SCOPE__',
-      id_token: '__ID_TOKEN__',
-      access_token: '__ACCESS_TOKEN__',
+      client_id: TEST_CLIENT_ID,
+      audience: TEST_AUDIENCE,
+      scope: TEST_SCOPES,
+      id_token: TEST_ID_TOKEN,
+      access_token: TEST_ACCESS_TOKEN,
       expires_in: dayInSeconds,
       decodedToken: {
         claims: {
-          __raw: 'idtoken',
+          __raw: TEST_ID_TOKEN,
           exp: nowSeconds() + dayInSeconds + 100,
           name: 'Test'
         },
@@ -284,7 +394,27 @@ describe('LocalStorageCache', () => {
   describe('cache.get', () => {
     it('can retrieve an item from the cache', () => {
       localStorage.setItem(
-        '@@auth0spajs@@::__TEST_CLIENT_ID__::__TEST_AUDIENCE__::__TEST_SCOPE__',
+        `@@auth0spajs@@::${TEST_CLIENT_ID}::${TEST_AUDIENCE}::${TEST_SCOPES}`,
+        JSON.stringify({
+          body: defaultEntry,
+          expiresAt: nowSeconds() + dayInSeconds
+        })
+      );
+
+      expect(
+        cache.get(
+          new CacheKey({
+            client_id: TEST_CLIENT_ID,
+            audience: TEST_AUDIENCE,
+            scope: TEST_SCOPES
+          })
+        )
+      ).toStrictEqual(defaultEntry);
+    });
+
+    it('can retrieve an item from the cache when scopes do not match', () => {
+      localStorage.setItem(
+        `@@auth0spajs@@::${TEST_CLIENT_ID}::${TEST_AUDIENCE}::__TEST_SCOPE__ __TEST_SCOPE2__`,
         JSON.stringify({
           body: defaultEntry,
           expiresAt: nowSeconds() + dayInSeconds
@@ -292,17 +422,59 @@ describe('LocalStorageCache', () => {
       );
 
       expect(
-        cache.get({
-          client_id: '__TEST_CLIENT_ID__',
-          audience: '__TEST_AUDIENCE__',
-          scope: '__TEST_SCOPE__'
+        cache.get(
+          new CacheKey({
+            client_id: TEST_CLIENT_ID,
+            audience: TEST_AUDIENCE,
+            scope: '__TEST_SCOPE__'
+          })
+        )
+      ).toStrictEqual(defaultEntry);
+    });
+
+    it('can retrieve an item from the cache when scopes do not match and multiple scopes are provided in a different order', () => {
+      localStorage.setItem(
+        `@@auth0spajs@@::${TEST_CLIENT_ID}::${TEST_AUDIENCE}::__TEST_SCOPE__ __TEST_SCOPE2__ __TEST_SCOPE3__`,
+        JSON.stringify({
+          body: defaultEntry,
+          expiresAt: nowSeconds() + dayInSeconds
         })
+      );
+
+      expect(
+        cache.get(
+          new CacheKey({
+            client_id: TEST_CLIENT_ID,
+            audience: TEST_AUDIENCE,
+            scope: '__TEST_SCOPE3__ __TEST_SCOPE__'
+          })
+        )
       ).toStrictEqual(defaultEntry);
     });
 
+    it('returns undefined when not all scopes match', () => {
+      localStorage.setItem(
+        `@@auth0spajs@@::${TEST_CLIENT_ID}::${TEST_AUDIENCE}::__TEST_SCOPE__ __TEST_SCOPE2__ __TEST_SCOPE3__`,
+        JSON.stringify({
+          body: defaultEntry,
+          expiresAt: nowSeconds() + dayInSeconds
+        })
+      );
+
+      expect(
+        cache.get(
+          new CacheKey({
+            client_id: TEST_CLIENT_ID,
+            audience: TEST_AUDIENCE,
+            scope: '__TEST_SCOPE4__ __TEST_SCOPE__'
+          })
+        )
+      ).toBeUndefined();
+    });
+
     it('returns undefined when expires_in < expiryAdjustmentSeconds', () => {
       localStorage.setItem(
-        '@@auth0spajs@@::__TEST_CLIENT_ID__::__TEST_AUDIENCE__::__TEST_SCOPE__',
+        `@@auth0spajs@@::${TEST_CLIENT_ID}::${TEST_AUDIENCE}::${TEST_SCOPES}`,
         JSON.stringify({
           body: defaultEntry,
           expiresAt: nowSeconds() + 40
@@ -311,11 +483,11 @@ describe('LocalStorageCache', () => {
 
       expect(
         cache.get(
-          {
-            client_id: '__TEST_CLIENT_ID__',
-            audience: '__TEST_AUDIENCE__',
-            scope: '__TEST_SCOPE__'
-          },
+          new CacheKey({
+            client_id: TEST_CLIENT_ID,
+            audience: TEST_AUDIENCE,
+            scope: TEST_SCOPES
+          }),
           60
         )
       ).toBeUndefined();
@@ -323,11 +495,11 @@ describe('LocalStorageCache', () => {
 
     it('strips the cache data when expires_in < expiryAdjustmentSeconds and refresh tokens are being used', () => {
       localStorage.setItem(
-        '@@auth0spajs@@::__TEST_CLIENT_ID__::__TEST_AUDIENCE__::__TEST_SCOPE__',
+        `@@auth0spajs@@::${TEST_CLIENT_ID}::${TEST_AUDIENCE}::${TEST_SCOPES}`,
         JSON.stringify({
           body: {
             ...defaultEntry,
-            refresh_token: '__REFRESH_TOKEN__'
+            refresh_token: TEST_REFRESH_TOKEN
           },
           expiresAt: nowSeconds() + 40
         })
@@ -335,15 +507,15 @@ describe('LocalStorageCache', () => {
 
       expect(
         cache.get(
-          {
-            client_id: '__TEST_CLIENT_ID__',
-            audience: '__TEST_AUDIENCE__',
-            scope: '__TEST_SCOPE__'
-          },
+          new CacheKey({
+            client_id: TEST_CLIENT_ID,
+            audience: TEST_AUDIENCE,
+            scope: TEST_SCOPES
+          }),
           60
         )
       ).toStrictEqual({
-        refresh_token: '__REFRESH_TOKEN__'
+        refresh_token: TEST_REFRESH_TOKEN
       });
     });
 
@@ -353,19 +525,19 @@ describe('LocalStorageCache', () => {
 
     it('strips the data, leaving the refresh token, when the expiry has been reached', () => {
       localStorage.setItem(
-        '@@auth0spajs@@::__TEST_CLIENT_ID__::__TEST_AUDIENCE__::__TEST_SCOPE__',
+        `@@auth0spajs@@::${TEST_CLIENT_ID}::${TEST_AUDIENCE}::${TEST_SCOPES}`,
         JSON.stringify({
           body: {
-            client_id: '__TEST_CLIENT_ID__',
-            audience: '__TEST_AUDIENCE__',
-            scope: '__TEST_SCOPE__',
-            id_token: '__ID_TOKEN__',
-            access_token: '__ACCESS_TOKEN__',
-            refresh_token: '__REFRESH_TOKEN__',
+            client_id: TEST_CLIENT_ID,
+            audience: TEST_AUDIENCE,
+            scope: TEST_SCOPES,
+            id_token: TEST_ID_TOKEN,
+            access_token: TEST_ACCESS_TOKEN,
+            refresh_token: TEST_REFRESH_TOKEN,
             expires_in: 10,
             decodedToken: {
               claims: {
-                __raw: 'idtoken',
+                __raw: TEST_ID_TOKEN,
                 exp: nowSeconds() + 15,
                 name: 'Test'
               },
@@ -380,31 +552,33 @@ describe('LocalStorageCache', () => {
       global.Date.now = jest.fn(() => (now + 30) * 1000);
 
       expect(
-        cache.get({
-          client_id: '__TEST_CLIENT_ID__',
-          audience: '__TEST_AUDIENCE__',
-          scope: '__TEST_SCOPE__'
-        })
+        cache.get(
+          new CacheKey({
+            client_id: TEST_CLIENT_ID,
+            audience: TEST_AUDIENCE,
+            scope: TEST_SCOPES
+          })
+        )
       ).toStrictEqual({
-        refresh_token: '__REFRESH_TOKEN__'
+        refresh_token: TEST_REFRESH_TOKEN
       });
     });
   });
 
   it('expires after cache `expiresAt` when expiresAt < current time', () => {
     localStorage.setItem(
-      '@@auth0spajs@@::__TEST_CLIENT_ID__::__TEST_AUDIENCE__::__TEST_SCOPE__',
+      `@@auth0spajs@@::${TEST_CLIENT_ID}::${TEST_AUDIENCE}::${TEST_SCOPES}`,
       JSON.stringify({
         body: {
-          client_id: '__TEST_CLIENT_ID__',
-          audience: '__TEST_AUDIENCE__',
-          scope: '__TEST_SCOPE__',
-          id_token: '__ID_TOKEN__',
-          access_token: '__ACCESS_TOKEN__',
+          client_id: TEST_CLIENT_ID,
+          audience: TEST_AUDIENCE,
+          scope: TEST_SCOPES,
+          id_token: TEST_ID_TOKEN,
+          access_token: TEST_ACCESS_TOKEN,
           expires_in: 10,
           decodedToken: {
             claims: {
-              __raw: 'idtoken',
+              __raw: TEST_ID_TOKEN,
               exp: nowSeconds() + 15,
               name: 'Test'
             },
@@ -419,15 +593,17 @@ describe('LocalStorageCache', () => {
     global.Date.now = jest.fn(() => (now + 30) * 1000);
 
     expect(
-      cache.get({
-        client_id: '__TEST_CLIENT_ID__',
-        audience: '__TEST_AUDIENCE__',
-        scope: '__TEST_SCOPE__'
-      })
+      cache.get(
+        new CacheKey({
+          client_id: TEST_CLIENT_ID,
+          audience: TEST_AUDIENCE,
+          scope: TEST_SCOPES
+        })
+      )
     ).toBeUndefined();
 
     expect(localStorage.removeItem).toHaveBeenCalledWith(
-      '@@auth0spajs@@::__TEST_CLIENT_ID__::__TEST_AUDIENCE__::__TEST_SCOPE__'
+      `@@auth0spajs@@::${TEST_CLIENT_ID}::${TEST_AUDIENCE}::${TEST_SCOPES}`
     );
   });
 
@@ -436,7 +612,7 @@ describe('LocalStorageCache', () => {
       cache.save(defaultEntry);
 
       expect(localStorage.setItem).toHaveBeenCalledWith(
-        '@@auth0spajs@@::__TEST_CLIENT_ID__::__TEST_AUDIENCE__::__TEST_SCOPE__',
+        `@@auth0spajs@@::${TEST_CLIENT_ID}::${TEST_AUDIENCE}::${TEST_SCOPES}`,
         JSON.stringify({
           body: defaultEntry,
           expiresAt: nowSeconds() + dayInSeconds
@@ -457,7 +633,7 @@ describe('LocalStorageCache', () => {
       cache.save(entry);
 
       expect(localStorage.setItem).toHaveBeenCalledWith(
-        '@@auth0spajs@@::__TEST_CLIENT_ID__::__TEST_AUDIENCE__::__TEST_SCOPE__',
+        `@@auth0spajs@@::${TEST_CLIENT_ID}::${TEST_AUDIENCE}::${TEST_SCOPES}`,
         JSON.stringify({
           body: entry,
           expiresAt: nowSeconds() + 100
diff --git a/__tests__/constants.ts b/__tests__/constants.ts
index ddac074..71398eb 100644
--- a/__tests__/constants.ts
+++ b/__tests__/constants.ts
@@ -13,6 +13,7 @@ export const TEST_AUTH0_CLIENT_QUERY_STRING = `&auth0Client=${encodeURIComponent
 export const TEST_DOMAIN = 'auth0_domain';
 export const TEST_CLIENT_ID = 'auth0_client_id';
 export const TEST_REDIRECT_URI = 'my_callback_url';
+export const TEST_AUDIENCE = 'my_audience';
 export const TEST_ID_TOKEN = 'my_id_token';
 export const TEST_ACCESS_TOKEN = 'my_access_token';
 export const TEST_REFRESH_TOKEN = 'my_refresh_token';
diff --git a/__tests__/index.test.ts b/__tests__/index.test.ts
index 550c9f4..292a616 100644
--- a/__tests__/index.test.ts
+++ b/__tests__/index.test.ts
@@ -2,7 +2,6 @@ import { CacheLocation, Auth0ClientOptions } from '../src/global';
 import * as scope from '../src/scope';
 
 // @ts-ignore
-import { acquireLockSpy, releaseLockSpy } from 'browser-tabs-lock';
 
 jest.mock('../src/jwt');
 jest.mock('../src/storage', () => ({
@@ -50,13 +49,16 @@ const mockEnclosedCache = {
   save: jest.fn(),
   clear: jest.fn()
 };
-
-jest.mock('../src/cache', () => ({
-  InMemoryCache: () => ({
-    enclosedCache: mockEnclosedCache
-  }),
-  LocalStorageCache: () => mockEnclosedCache
-}));
+jest.mock('../src/cache', () => {
+  const { CacheKey } = jest.requireActual('../src/cache');
+  return {
+    InMemoryCache: () => ({
+      enclosedCache: mockEnclosedCache
+    }),
+    LocalStorageCache: () => mockEnclosedCache,
+    CacheKey: CacheKey
+  };
+});
 
 jest.mock('../src/worker/token.worker');