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

Issue:
Ability to define custom date/time provider
We continually have the problem of customer's clocks not being in sync which results in an error when their time is more than the allowed `leeway`. 

Rather than increase the `leeway` we'd prefer if we could provide a custom implementation to initialise the current time instead of it being hardcoded to `Date.now()`.

### Describe the ideal solution

A new option `Auth0ClientOptions.currentDateProvider` which is an optional function: `() => Promise<Date>`. If not set, it defaults to `() => Promise.resolve(new Date(Date.now()))`.

This option is then used in the `verify()` function within `jwt.ts`.

This allows developers to use a custom implementation of retrieving the current date, i.e by contacting an API to get the current date.

## Alternatives and current work-arounds

I notice this is going to cause `verify()` to become an async function, an alternative is to provide the current date as an argument to `verify()` so that it remains synchronous, and use the above approach further up the call stack.

---

I don't mind providing a PR for this functionality, but would like for some discussion/approval before spending time on an implementation.

Pull request:
[SDK-2793] Ability to define a custom now provider
### Description

Adds a custom `nowProvider`, allowing developers to register a function returning a promise, indicating the current time (in milliseconds) used to validate the token expirations:

```ts
const client = new Auth0Client({
  nowProvider: () => Promise.resolve(Date.now())
});
```
This allows to integrate a time-server where needed:
```ts
async function getCurrentTime() {
  const response = await fetch('URL_HERE');
  const data = await response.json();
  return data.now;
}
const client = new Auth0Client({
  nowProvider: () => getCurrentTime()
});
```


### References
Closes #650


### Testing

- [x] This change adds test coverage for new/changed/fixed functionality

### Checklist

- [ ] 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 2ea04d9..f4fe2f7 100644
--- a/src/Auth0Client.ts
+++ b/src/Auth0Client.ts
@@ -45,7 +45,8 @@ import {
   RECOVERABLE_ERRORS,
   DEFAULT_SESSION_CHECK_EXPIRY_DAYS,
   DEFAULT_AUTH0_CLIENT,
-  INVALID_REFRESH_TOKEN_ERROR_MESSAGE
+  INVALID_REFRESH_TOKEN_ERROR_MESSAGE,
+  DEFAULT_NOW_PROVIDER
 } from './constants';
 
 import {
@@ -186,6 +187,7 @@ export default class Auth0Client {
   private sessionCheckExpiryDays: number;
   private orgHintCookieName: string;
   private isAuthenticatedCookieName: string;
+  private nowProvider: () => number | Promise<number>;
 
   cacheLocation: CacheLocation;
   private worker: Worker;
@@ -240,11 +242,14 @@ export default class Auth0Client {
       this.options.client_id
     );
 
+    this.nowProvider = this.options.nowProvider || DEFAULT_NOW_PROVIDER;
+
     this.cacheManager = new CacheManager(
       cache,
       !cache.allKeys
         ? new CacheKeyManifest(cache, this.options.client_id)
-        : null
+        : null,
+      this.nowProvider
     );
 
     this.domainUrl = getDomain(this.options.domain);
@@ -325,11 +330,13 @@ export default class Auth0Client {
     return this._url(`/authorize?${createQueryParams(authorizeOptions)}`);
   }
 
-  private _verifyIdToken(
+  private async _verifyIdToken(
     id_token: string,
     nonce?: string,
     organizationId?: string
   ) {
+    const now = await this.nowProvider();
+
     return verifyIdToken({
       iss: this.tokenIssuer,
       aud: this.options.client_id,
@@ -337,7 +344,8 @@ export default class Auth0Client {
       nonce,
       organizationId,
       leeway: this.options.leeway,
-      max_age: this._parseNumber(this.options.max_age)
+      max_age: this._parseNumber(this.options.max_age),
+      now
     });
   }
 
@@ -489,7 +497,7 @@ export default class Auth0Client {
 
     const organizationId = options.organization || this.options.organization;
 
-    const decodedToken = this._verifyIdToken(
+    const decodedToken = await this._verifyIdToken(
       authResult.id_token,
       nonceIn,
       organizationId
@@ -647,7 +655,7 @@ export default class Auth0Client {
 
     const authResult = await oauthToken(tokenOptions, this.worker);
 
-    const decodedToken = this._verifyIdToken(
+    const decodedToken = await this._verifyIdToken(
       authResult.id_token,
       transaction.nonce,
       transaction.organizationId
@@ -1033,7 +1041,10 @@ export default class Auth0Client {
         this.worker
       );
 
-      const decodedToken = this._verifyIdToken(tokenResult.id_token, nonceIn);
+      const decodedToken = await this._verifyIdToken(
+        tokenResult.id_token,
+        nonceIn
+      );
 
       this._processOrgIdHint(decodedToken.claims.org_id);
 
@@ -1126,7 +1137,7 @@ export default class Auth0Client {
       throw e;
     }
 
-    const decodedToken = this._verifyIdToken(tokenResult.id_token);
+    const decodedToken = await this._verifyIdToken(tokenResult.id_token);
 
     return {
       ...tokenResult,
diff --git a/src/cache/cache-manager.ts b/src/cache/cache-manager.ts
index 70fd105..381e3d0 100644
--- a/src/cache/cache-manager.ts
+++ b/src/cache/cache-manager.ts
@@ -1,3 +1,4 @@
+import { DEFAULT_NOW_PROVIDER } from '../constants';
 import { CacheKeyManifest } from './key-manifest';
 
 import {
@@ -11,7 +12,13 @@ import {
 const DEFAULT_EXPIRY_ADJUSTMENT_SECONDS = 0;
 
 export class CacheManager {
-  constructor(private cache: ICache, private keyManifest?: CacheKeyManifest) {}
+  constructor(
+    private cache: ICache,
+    private keyManifest?: CacheKeyManifest,
+    private nowProvider?: () => number | Promise<number>
+  ) {
+    this.nowProvider = this.nowProvider || DEFAULT_NOW_PROVIDER;
+  }
 
   async get(
     cacheKey: CacheKey,
@@ -35,7 +42,8 @@ export class CacheManager {
       return;
     }
 
-    const nowSeconds = Math.floor(Date.now() / 1000);
+    const now = await this.nowProvider();
+    const nowSeconds = Math.floor(now / 1000);
 
     if (wrappedEntry.expiresAt - expiryAdjustmentSeconds < nowSeconds) {
       if (wrappedEntry.body.refresh_token) {
@@ -63,7 +71,7 @@ export class CacheManager {
       audience: entry.audience
     });
 
-    const wrappedEntry = this.wrapCacheEntry(entry);
+    const wrappedEntry = await this.wrapCacheEntry(entry);
 
     await this.cache.set(cacheKey.toKey(), wrappedEntry);
     await this.keyManifest?.add(cacheKey.toKey());
@@ -101,8 +109,9 @@ export class CacheManager {
       });
   }
 
-  private wrapCacheEntry(entry: CacheEntry): WrappedCacheEntry {
-    const expiresInTime = Math.floor(Date.now() / 1000) + entry.expires_in;
+  private async wrapCacheEntry(entry: CacheEntry): Promise<WrappedCacheEntry> {
+    const now = await this.nowProvider();
+    const expiresInTime = Math.floor(now / 1000) + entry.expires_in;
 
     const expirySeconds = Math.min(
       expiresInTime,
diff --git a/src/constants.ts b/src/constants.ts
index 0df045a..3af2d32 100644
--- a/src/constants.ts
+++ b/src/constants.ts
@@ -76,3 +76,5 @@ export const DEFAULT_AUTH0_CLIENT = {
   name: 'auth0-spa-js',
   version: version
 };
+
+export const DEFAULT_NOW_PROVIDER = () => Date.now();
diff --git a/src/global.ts b/src/global.ts
index 509c269..24ce8fd 100644
--- a/src/global.ts
+++ b/src/global.ts
@@ -212,6 +212,13 @@ export interface Auth0ClientOptions extends BaseLoginOptions {
    * continue to work as intended.
    */
   useFormData?: boolean;
+
+  /**
+   * Modify the value used as the current time during the token validation.
+   *
+   * **Note**: Using this improperly can potentially compromise the token validation.
+   */
+  nowProvider?: () => Promise<number> | number;
 }
 
 /**
@@ -484,6 +491,7 @@ export interface JWTVerifyOptions {
   leeway?: number;
   max_age?: number;
   organizationId?: string;
+  now?: number;
 }
 
 /**
diff --git a/src/jwt.ts b/src/jwt.ts
index 40dcb54..e2b9af6 100644
--- a/src/jwt.ts
+++ b/src/jwt.ts
@@ -159,7 +159,7 @@ export const verify = (options: JWTVerifyOptions) => {
   }
 
   const leeway = options.leeway || 60;
-  const now = new Date(Date.now());
+  const now = new Date(options.now || Date.now());
   const expDate = new Date(0);
   const nbfDate = new Date(0);
   const authTimeDate = new Date(0);


Test patch:
diff --git a/__tests__/cache/cache-manager.test.ts b/__tests__/cache/cache-manager.test.ts
index cfa00c3..ac1deb0 100644
--- a/__tests__/cache/cache-manager.test.ts
+++ b/__tests__/cache/cache-manager.test.ts
@@ -178,7 +178,7 @@ cacheFactories.forEach(cacheFactory => {
         await manager.get(
           new CacheKey({
             client_id: TEST_CLIENT_ID,
-            audience: 'the_audience',
+            audience: TEST_AUDIENCE,
             scope: TEST_SCOPES
           }),
           60
@@ -233,6 +233,54 @@ cacheFactories.forEach(cacheFactory => {
       });
     });
 
+    it('reads from the cache when expires_in > date.now', async () => {
+      const data = {
+        ...defaultData,
+        expires_in: 70
+      };
+
+      await manager.set(data);
+
+      const cacheKey = CacheKey.fromCacheEntry(data);
+
+      // Test that the cache state is normal before we expire the data
+      expect(await manager.get(cacheKey)).toStrictEqual(data);
+
+      const result = await manager.get(cacheKey, 60);
+
+      // And test that the cache has been emptied
+      expect(result).toBeTruthy();
+    });
+
+    it('reads from the cache when expires_in > date.now using custom now provider', async () => {
+      const now = Date.now();
+      const data = {
+        ...defaultData,
+        expires_in: 50
+      };
+      const expiryAdjustmentSeconds = 60;
+
+      const provider = jest.fn().mockResolvedValue(Date.now());
+      const manager = new CacheManager(cache, keyManifest, provider);
+
+      await manager.set(data);
+
+      const cacheKey = CacheKey.fromCacheEntry(data);
+
+      // Test that the cache state is normal before we expire the data
+      expect(await manager.get(cacheKey)).toStrictEqual(data);
+
+      // Move back in time to ensure the token is valid
+      provider.mockResolvedValue(
+        now - (expiryAdjustmentSeconds - data.expires_in) * 1000
+      );
+
+      const result = await manager.get(cacheKey, expiryAdjustmentSeconds);
+
+      // And test that the cache has been emptied
+      expect(result).toBeTruthy();
+    });
+
     it('expires the cache on read when the date.now > expires_in', async () => {
       const now = Date.now();
       const realDateNow = Date.now.bind(global.Date);
@@ -277,6 +325,90 @@ cacheFactories.forEach(cacheFactory => {
       }
     });
 
+    it('expires the cache on read when the date.now > expires_in when using custom now provider with a promise', async () => {
+      const now = Date.now();
+      const cacheRemoveSpy = jest.spyOn(cache, 'remove');
+
+      const data = {
+        ...defaultData,
+        decodedToken: {
+          claims: {
+            __raw: TEST_ID_TOKEN,
+            name: 'Test',
+            exp: nowSeconds() + dayInSeconds * 2
+          },
+          user: { name: 'Test' }
+        }
+      };
+
+      const provider = jest.fn().mockResolvedValue(now);
+      const manager = new CacheManager(cache, keyManifest, provider);
+
+      await manager.set(data);
+
+      const cacheKey = CacheKey.fromCacheEntry(data);
+
+      // Test that the cache state is normal before we expire the data
+      expect(await manager.get(cacheKey)).toStrictEqual(data);
+
+      // Advance the time to just past the expiry..
+      provider.mockResolvedValue((now + dayInSeconds + 100) * 1000);
+
+      const result = await manager.get(cacheKey);
+
+      // And test that the cache has been emptied
+      expect(result).toBeFalsy();
+
+      // And that the data has been removed from the key manifest
+      if (keyManifest) {
+        expect(cacheRemoveSpy).toHaveBeenCalledWith(
+          `@@auth0spajs@@::${data.client_id}`
+        );
+      }
+    });
+
+    it('expires the cache on read when the date.now > expires_in when using custom now provider', async () => {
+      const now = Date.now();
+      const cacheRemoveSpy = jest.spyOn(cache, 'remove');
+
+      const data = {
+        ...defaultData,
+        decodedToken: {
+          claims: {
+            __raw: TEST_ID_TOKEN,
+            name: 'Test',
+            exp: nowSeconds() + dayInSeconds * 2
+          },
+          user: { name: 'Test' }
+        }
+      };
+
+      const provider = jest.fn().mockReturnValue(now);
+      const manager = new CacheManager(cache, keyManifest, provider);
+
+      await manager.set(data);
+
+      const cacheKey = CacheKey.fromCacheEntry(data);
+
+      // Test that the cache state is normal before we expire the data
+      expect(await manager.get(cacheKey)).toStrictEqual(data);
+
+      // Advance the time to just past the expiry..
+      provider.mockReturnValue((now + dayInSeconds + 100) * 1000);
+
+      const result = await manager.get(cacheKey);
+
+      // And test that the cache has been emptied
+      expect(result).toBeFalsy();
+
+      // And that the data has been removed from the key manifest
+      if (keyManifest) {
+        expect(cacheRemoveSpy).toHaveBeenCalledWith(
+          `@@auth0spajs@@::${data.client_id}`
+        );
+      }
+    });
+
     it('expires the cache on read when the date.now > token.exp', async () => {
       const now = Date.now();
       const realDateNow = Date.now.bind(global.Date);
@@ -313,6 +445,76 @@ cacheFactories.forEach(cacheFactory => {
       }
     });
 
+    it('expires the cache on read when the date.now > token.exp when using custom now provider with a promise', async () => {
+      const now = Date.now();
+      const cacheRemoveSpy = jest.spyOn(cache, 'remove');
+
+      const data = {
+        ...defaultData,
+        expires_in: dayInSeconds * 120
+      };
+
+      const provider = jest.fn().mockResolvedValue(now);
+      const manager = new CacheManager(cache, keyManifest, provider);
+
+      await manager.set(data);
+
+      const cacheKey = CacheKey.fromCacheEntry(data);
+
+      // Test that the cache state is normal before we expire the data
+      expect(await manager.get(cacheKey)).toStrictEqual(data);
+
+      // Advance the time to just past the expiry..
+      provider.mockResolvedValue((now + dayInSeconds + 100) * 1000);
+
+      const result = await manager.get(cacheKey);
+
+      // And test that the cache has been emptied
+      expect(result).toBeFalsy();
+
+      // And that the data has been removed from the key manifest
+      if (keyManifest) {
+        expect(cacheRemoveSpy).toHaveBeenCalledWith(
+          `@@auth0spajs@@::${data.client_id}`
+        );
+      }
+    });
+
+    it('expires the cache on read when the date.now > token.exp when using custom now provider', async () => {
+      const now = Date.now();
+      const cacheRemoveSpy = jest.spyOn(cache, 'remove');
+
+      const data = {
+        ...defaultData,
+        expires_in: dayInSeconds * 120
+      };
+
+      const provider = jest.fn().mockReturnValue(now);
+      const manager = new CacheManager(cache, keyManifest, provider);
+
+      await manager.set(data);
+
+      const cacheKey = CacheKey.fromCacheEntry(data);
+
+      // Test that the cache state is normal before we expire the data
+      expect(await manager.get(cacheKey)).toStrictEqual(data);
+
+      // Advance the time to just past the expiry..
+      provider.mockReturnValue((now + dayInSeconds + 100) * 1000);
+
+      const result = await manager.get(cacheKey);
+
+      // And test that the cache has been emptied
+      expect(result).toBeFalsy();
+
+      // And that the data has been removed from the key manifest
+      if (keyManifest) {
+        expect(cacheRemoveSpy).toHaveBeenCalledWith(
+          `@@auth0spajs@@::${data.client_id}`
+        );
+      }
+    });
+
     it('clears all keys from the cache', async () => {
       const entry1 = { ...defaultData };
       const entry2 = { ...defaultData, scope: 'scope-1' };
diff --git a/__tests__/jwt.test.ts b/__tests__/jwt.test.ts
index 7e414be..69aedd3 100644
--- a/__tests__/jwt.test.ts
+++ b/__tests__/jwt.test.ts
@@ -229,6 +229,14 @@ describe('jwt', () => {
       `Expiration Time (exp) claim error in the ID token`
     );
   });
+  it('validates exp using custom now', async () => {
+    const id_token = await createJWT(DEFAULT_PAYLOAD, {
+      expiresIn: '-1h'
+    });
+    expect(() =>
+      verify({ ...verifyOptions, id_token, now: Date.now() - 3600 * 1000 })
+    ).not.toThrow();
+  });
   it('validates nbf', async () => {
     const id_token = await createJWT(DEFAULT_PAYLOAD, {
       notBefore: '1h'
@@ -336,6 +344,30 @@ describe('jwt', () => {
     );
   });
 
+  it('validate auth_time + max_age is in the future using custom now', async () => {
+    const yesterday = new Date();
+    yesterday.setDate(yesterday.getDate() - 1);
+
+    const maxAge = 1;
+    const leeway = 60;
+    const authTime = Math.floor(yesterday.getTime() / 1000);
+
+    const id_token = await createJWT({
+      ...DEFAULT_PAYLOAD,
+      auth_time: authTime
+    });
+
+    expect(() =>
+      verify({
+        ...verifyOptions,
+        id_token,
+        max_age: maxAge,
+        leeway,
+        now: Math.floor(yesterday.getTime())
+      })
+    ).not.toThrow();
+  });
+
   it('validate org_id is present when organizationId is provided', async () => {
     const id_token = await createJWT({ ...DEFAULT_PAYLOAD });