Repository: auth0/auth0-spa-js (ts)

Issue:
isAuthenticated method returns always false
### Description
I am trying to setup refresh token rotation in a react application. I manage to create the `Auth0Client`, login and get to token with `getTokenSilently` but `isAuthenticated`, `getUser` or `getIdTokenClaims` are not working

### Reproduction
I was making my tests with the [react sample app](https://auth0.com/docs/quickstart/spa/react). Everything works fine below the version 1.7.0. I start to have the issues described above after the refresh token rotation implementation.

Is it an issue with the auth0 configuration? I did make the change for the rotating refresh token behavior

Calling getTokenSilently in sequence intermittently makes multiple requests
### Description

When calling getTokenSilently in sequence one would expect the library to initiate an access token request on the first call and (assuming that the access token expiry isn't exceeded) for that same access token to be returned for the subsequent calls. This does not appear to be working correctly. We are seeing that multiple requests are being made to the token endpoint resulting in different access tokens. Furthermore, we suspect that this could be the reason for the significant number of Failed Exchange and Failed Silent Auth log entries, assuming if multiple requests are triggered at once that this opens up the possibility of re-using refresh tokens, and then invalidated tokens. Not only is this an issue for our customers who need to repeatedly re-authenticate but it makes tracking of reused refresh tokens less meaningful, in terms of being aware of possible attacks. This needs validating, of course. 


### Reproduction

This issue has been occurring in two different live codebases and I have now been able to reproduce this issue after making very limited changes to the React Sample Project application:

<img width="793" alt="Screen Shot 2020-05-26 at 1 39 10 pm" src="https://user-images.githubusercontent.com/1634826/82858169-6ba1ef80-9f56-11ea-87b2-c5cf92341677.png">

Note that we are using Rotating Refresh Tokens with a cacheLocation of "localstorage", and a specified audience. 

As you can see in the screenshot below, the sequence of requests initially results in the same access token being returned, and later returning three different access tokens:

<img width="1416" alt="Screen Shot 2020-05-26 at 11 54 29 am" src="https://user-images.githubusercontent.com/1634826/82907122-8a7da180-9fa9-11ea-8f07-5bfe92ad8196.png">


This appears to be an intermittent issue and is not reproducible every time. I have found that leaving the application in a window and returning to it after some time can result in it repeatedly make multiple token requests, and then later returns to expected operation, making only a single request. 


### Environment

- auth0-spa-js library: 1.8.1
- React: 16.8.6
- Chrome: Version 81.0.4044.138 (but seems to occur in at least Safari and Firefox as well)
- mac0S Mojave 10.14.6 


Pull request:
[SDK-1696] Allow caller of cache.get to specify an expiry time adjustment
## Description

The adjustment value used on the expiry time from the cache can now be configured per call to `cache.get`. The default is 0.

This allows:

* Cache `expires_at` to be the same time as `now() + expires_in` (no longer includes an adjustment built in)
* `getTokenSilently` can itself make a decision about whether to use the cached value, or refresh the token if `expires_in - adjustment < now`
* functions like `isAuthenticated` to return `true` right up until the `expires_in` time is reached

### References

Fixes #480 
Fixes #474 

### 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`


Patch:
diff --git a/src/Auth0Client.ts b/src/Auth0Client.ts
index 26107d7..0b92df9 100644
--- a/src/Auth0Client.ts
+++ b/src/Auth0Client.ts
@@ -545,11 +545,14 @@ export default class Auth0Client {
 
     try {
       if (!ignoreCache) {
-        const cache = this.cache.get({
-          scope: getTokenOptions.scope,
-          audience: getTokenOptions.audience || 'default',
-          client_id: this.options.client_id
-        });
+        const cache = this.cache.get(
+          {
+            scope: getTokenOptions.scope,
+            audience: getTokenOptions.audience || 'default',
+            client_id: this.options.client_id
+          },
+          60 // get a new token if within 60 seconds of expiring
+        );
 
         if (cache && cache.access_token) {
           return cache.access_token;
diff --git a/src/cache.ts b/src/cache.ts
index 217f0fb..169a2a2 100644
--- a/src/cache.ts
+++ b/src/cache.ts
@@ -24,11 +24,13 @@ interface CacheEntry {
 
 export interface ICache {
   save(entry: CacheEntry): void;
-  get(key: CacheKeyData): Partial<CacheEntry>;
+  get(key: CacheKeyData, expiryAdjustmentSeconds?: number): Partial<CacheEntry>;
   clear(): void;
 }
 
 const keyPrefix = '@@auth0spajs@@';
+const DEFAULT_EXPIRY_ADJUSTMENT_SECONDS = 0;
+
 const createKey = (e: CacheKeyData) =>
   `${keyPrefix}::${e.client_id}::${e.audience}::${e.scope}`;
 
@@ -43,8 +45,7 @@ type CachePayload = {
  */
 const wrapCacheEntry = (entry: CacheEntry): CachePayload => {
   const expiresInTime = Math.floor(Date.now() / 1000) + entry.expires_in;
-  const expirySeconds =
-    Math.min(expiresInTime, entry.decodedToken.claims.exp) - 60; // take off a small leeway
+  const expirySeconds = Math.min(expiresInTime, entry.decodedToken.claims.exp);
 
   return {
     body: entry,
@@ -60,14 +61,17 @@ export class LocalStorageCache implements ICache {
     window.localStorage.setItem(cacheKey, JSON.stringify(payload));
   }
 
-  public get(key: CacheKeyData): Partial<CacheEntry> {
+  public get(
+    key: CacheKeyData,
+    expiryAdjustmentSeconds = DEFAULT_EXPIRY_ADJUSTMENT_SECONDS
+  ): Partial<CacheEntry> {
     const cacheKey = createKey(key);
     const payload = this.readJson(cacheKey);
     const nowSeconds = Math.floor(Date.now() / 1000);
 
     if (!payload) return;
 
-    if (payload.expiresAt < nowSeconds) {
+    if (payload.expiresAt - expiryAdjustmentSeconds < nowSeconds) {
       if (payload.body.refresh_token) {
         const newPayload = this.stripData(payload);
         this.writeJson(cacheKey, newPayload);
@@ -151,7 +155,10 @@ export class InMemoryCache {
         cache[key] = payload;
       },
 
-      get(key: CacheKeyData) {
+      get(
+        key: CacheKeyData,
+        expiryAdjustmentSeconds = DEFAULT_EXPIRY_ADJUSTMENT_SECONDS
+      ) {
         const cacheKey = createKey(key);
         const wrappedEntry: CachePayload = cache[cacheKey];
         const nowSeconds = Math.floor(Date.now() / 1000);
@@ -160,7 +167,7 @@ export class InMemoryCache {
           return;
         }
 
-        if (wrappedEntry.expiresAt < nowSeconds) {
+        if (wrappedEntry.expiresAt - expiryAdjustmentSeconds < nowSeconds) {
           if (wrappedEntry.body.refresh_token) {
             wrappedEntry.body = {
               refresh_token: wrappedEntry.body.refresh_token
diff --git a/static/index.html b/static/index.html
index eb96a61..6801f3c 100644
--- a/static/index.html
+++ b/static/index.html
@@ -84,7 +84,10 @@
         <template v-if="error">
           <hr />
           <h3>Last error</h3>
-          <pre><code data-cy="error">{{JSON.stringify(error, null, 2)}}</code></pre>
+          <pre><code data-cy="error">
+{{JSON.stringify(error, null, 2)}}
+              </code>
+          </pre>
         </template>
 
         <hr />
@@ -94,7 +97,7 @@
           <div class="card-body">
             <pre>
               <code>
-                {{ profile | json }}
+{{ JSON.stringify(profile, null, 2) }}
               </code>
             </pre>
           </div>
@@ -365,14 +368,14 @@
 
             _self.auth0.getTokenSilently().then(function (token) {
               _self.access_tokens.push(token);
-            });
 
-            _self.auth0.getUser().then(function (user) {
-              _self.profile = user;
-            });
+              _self.auth0.getUser().then(function (user) {
+                _self.profile = user;
+              });
 
-            _self.auth0.getIdTokenClaims().then(function (claims) {
-              _self.id_token = claims.__raw;
+              _self.auth0.getIdTokenClaims().then(function (claims) {
+                _self.id_token = claims.__raw;
+              });
             });
           },
           loginPopup: function () {
@@ -415,8 +418,7 @@
 
             _self.auth0
               .getTokenSilently({
-                ignoreCache: !_self.useCache,
-                aCustomOption: 'hello world'
+                ignoreCache: !_self.useCache
               })
               .then(function (token) {
                 _self.access_tokens.push(token);


Test patch:
diff --git a/__tests__/Auth0Client.test.ts b/__tests__/Auth0Client.test.ts
index bf6f316..1eb13b1 100644
--- a/__tests__/Auth0Client.test.ts
+++ b/__tests__/Auth0Client.test.ts
@@ -69,7 +69,7 @@ const setup = (
 const login: any = async (
   auth0,
   tokenSuccess = true,
-  tokenResponse?,
+  tokenResponse = {},
   code = 'my_code',
   state = 'MTIz'
 ) => {
@@ -176,6 +176,86 @@ describe('Auth0Client', () => {
     );
   });
 
+  it('uses the cache when expires_in > constant leeway', async () => {
+    const auth0 = setup();
+    await login(auth0, true, { expires_in: 70 });
+
+    jest.spyOn(<any>utils, 'runIframe').mockResolvedValue({
+      access_token: 'my_access_token',
+      state: 'MTIz'
+    });
+
+    mockFetch.mockReset();
+
+    await auth0.getTokenSilently();
+    expect(mockFetch).not.toHaveBeenCalled();
+  });
+
+  it('refreshes the token when expires_in < constant leeway', async () => {
+    const auth0 = setup();
+    await login(auth0, true, { expires_in: 50 });
+
+    jest.spyOn(<any>utils, 'runIframe').mockResolvedValue({
+      access_token: 'my_access_token',
+      state: 'MTIz'
+    });
+
+    mockFetch.mockReset();
+    mockFetch.mockResolvedValue(
+      fetchResponse(true, {
+        id_token: 'my_id_token',
+        refresh_token: 'my_refresh_token',
+        access_token: 'my_access_token',
+        expires_in: 86400
+      })
+    );
+
+    await auth0.getTokenSilently();
+    expect(mockFetch).toHaveBeenCalledTimes(1);
+  });
+
+  it('uses the cache when expires_in > constant leeway & refresh tokens are used', async () => {
+    const auth0 = setup({
+      useRefreshTokens: true
+    });
+
+    await login(auth0, true, { expires_in: 70 });
+
+    mockFetch.mockReset();
+    mockFetch.mockResolvedValue(
+      fetchResponse(true, {
+        id_token: 'my_id_token',
+        refresh_token: 'my_refresh_token',
+        access_token: 'my_access_token',
+        expires_in: 86400
+      })
+    );
+
+    await auth0.getTokenSilently();
+    expect(mockFetch).not.toHaveBeenCalled();
+  });
+
+  it('refreshes the token when expires_in < constant leeway & refresh tokens are used', async () => {
+    const auth0 = setup({
+      useRefreshTokens: true
+    });
+
+    await login(auth0, true, { expires_in: 50 });
+
+    mockFetch.mockReset();
+    mockFetch.mockResolvedValue(
+      fetchResponse(true, {
+        id_token: 'my_id_token',
+        refresh_token: 'my_refresh_token',
+        access_token: 'my_access_token',
+        expires_in: 86400
+      })
+    );
+
+    await auth0.getTokenSilently();
+    expect(mockFetch).toHaveBeenCalledTimes(1);
+  });
+
   it('refreshes the token from a web worker', async () => {
     const auth0 = setup({
       useRefreshTokens: true
diff --git a/__tests__/cache.test.ts b/__tests__/cache.test.ts
index 6c59291..05805b1 100644
--- a/__tests__/cache.test.ts
+++ b/__tests__/cache.test.ts
@@ -75,6 +75,38 @@ describe('InMemoryCache', () => {
     ).toStrictEqual(data);
   });
 
+  it('returns undefined from the cache when expires_in < expiryAdjustmentSeconds', () => {
+    const data = {
+      client_id: 'test-client',
+      audience: 'the_audience',
+      scope: 'the_scope',
+      id_token: 'idtoken',
+      access_token: 'accesstoken',
+      expires_in: 40,
+      decodedToken: {
+        claims: {
+          __raw: 'idtoken',
+          exp: nowSeconds() + dayInSeconds,
+          name: 'Test'
+        },
+        user: { name: 'Test' }
+      }
+    };
+
+    cache.save(data);
+
+    expect(
+      cache.get(
+        {
+          client_id: 'test-client',
+          audience: 'the_audience',
+          scope: 'the_scope'
+        },
+        60
+      )
+    ).toBeUndefined();
+  });
+
   describe('when refresh tokens are used', () => {
     it('strips everything except the refresh token when expiry has been reached', () => {
       const now = Date.now();
@@ -268,6 +300,53 @@ describe('LocalStorageCache', () => {
       ).toStrictEqual(defaultEntry);
     });
 
+    it('returns undefined when expires_in < expiryAdjustmentSeconds', () => {
+      localStorage.setItem(
+        '@@auth0spajs@@::__TEST_CLIENT_ID__::__TEST_AUDIENCE__::__TEST_SCOPE__',
+        JSON.stringify({
+          body: defaultEntry,
+          expiresAt: nowSeconds() + 40
+        })
+      );
+
+      expect(
+        cache.get(
+          {
+            client_id: '__TEST_CLIENT_ID__',
+            audience: '__TEST_AUDIENCE__',
+            scope: '__TEST_SCOPE__'
+          },
+          60
+        )
+      ).toBeUndefined();
+    });
+
+    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__',
+        JSON.stringify({
+          body: {
+            ...defaultEntry,
+            refresh_token: '__REFRESH_TOKEN__'
+          },
+          expiresAt: nowSeconds() + 40
+        })
+      );
+
+      expect(
+        cache.get(
+          {
+            client_id: '__TEST_CLIENT_ID__',
+            audience: '__TEST_AUDIENCE__',
+            scope: '__TEST_SCOPE__'
+          },
+          60
+        )
+      ).toStrictEqual({
+        refresh_token: '__REFRESH_TOKEN__'
+      });
+    });
+
     it('returns undefined when there is no data', () => {
       expect(cache.get({ scope: '', audience: '' })).toBeUndefined();
     });
@@ -360,7 +439,7 @@ describe('LocalStorageCache', () => {
         '@@auth0spajs@@::__TEST_CLIENT_ID__::__TEST_AUDIENCE__::__TEST_SCOPE__',
         JSON.stringify({
           body: defaultEntry,
-          expiresAt: nowSeconds() + dayInSeconds - 60
+          expiresAt: nowSeconds() + dayInSeconds
         })
       );
     });
@@ -381,7 +460,7 @@ describe('LocalStorageCache', () => {
         '@@auth0spajs@@::__TEST_CLIENT_ID__::__TEST_AUDIENCE__::__TEST_SCOPE__',
         JSON.stringify({
           body: entry,
-          expiresAt: nowSeconds() + 40
+          expiresAt: nowSeconds() + 100
         })
       );
     });
diff --git a/__tests__/index.test.ts b/__tests__/index.test.ts
index 7960048..d620a72 100644
--- a/__tests__/index.test.ts
+++ b/__tests__/index.test.ts
@@ -1498,11 +1498,14 @@ describe('Auth0', () => {
 
           await auth0.getTokenSilently();
 
-          expect(cache.get).toHaveBeenCalledWith({
-            audience: 'default',
-            scope: TEST_SCOPES,
-            client_id: TEST_CLIENT_ID
-          });
+          expect(cache.get).toHaveBeenCalledWith(
+            {
+              audience: 'default',
+              scope: TEST_SCOPES,
+              client_id: TEST_CLIENT_ID
+            },
+            60
+          );
         });
 
         it('returns cached access_token when there is a cache', async () => {
@@ -1546,11 +1549,14 @@ describe('Auth0', () => {
 
           await auth0.getTokenSilently();
 
-          expect(cache.get).toHaveBeenCalledWith({
-            audience: 'default',
-            scope: 'openid email',
-            client_id: TEST_CLIENT_ID
-          });
+          expect(cache.get).toHaveBeenCalledWith(
+            {
+              audience: 'default',
+              scope: 'openid email',
+              client_id: TEST_CLIENT_ID
+            },
+            60
+          );
         });
       });
 
@@ -1564,11 +1570,14 @@ describe('Auth0', () => {
 
           await auth0.getTokenSilently();
 
-          expect(cache.get).toHaveBeenCalledWith({
-            audience: 'default',
-            scope: `${TEST_SCOPES} offline_access`,
-            client_id: TEST_CLIENT_ID
-          });
+          expect(cache.get).toHaveBeenCalledWith(
+            {
+              audience: 'default',
+              scope: `${TEST_SCOPES} offline_access`,
+              client_id: TEST_CLIENT_ID
+            },
+            60
+          );
         });
 
         it('calls the token endpoint with the correct params', async () => {