diff --git a/__tests__/cache.test.ts b/__tests__/cache.test.ts index d05b5fbe8..59c98ea92 100644 --- a/__tests__/cache.test.ts +++ b/__tests__/cache.test.ts @@ -1,6 +1,7 @@ import { InMemoryCache, LocalStorageCache } from '../src/cache'; const nowSeconds = () => Math.floor(Date.now() / 1000); +const dayInSeconds = 86400; describe('InMemoryCache', () => { let cache: InMemoryCache; @@ -54,17 +55,56 @@ describe('InMemoryCache', () => { user: { name: 'Test' } } }); + + // Test that the cache state is normal up until just before the expiry time.. jest.advanceTimersByTime(799); expect(Object.keys(cache.cache).length).toBe(1); + // Advance the time to match the expiry time.. jest.advanceTimersByTime(1); + + // and test that the cache has been emptied. expect(Object.keys(cache.cache).length).toBe(0); }); + it('strips everything except the refresh token when expiry has been reached', () => { + cache.save({ + client_id: 'test-client', + audience: 'the_audience', + scope: 'the_scope', + id_token: 'idtoken', + access_token: 'accesstoken', + refresh_token: 'refreshtoken', + expires_in: 1, + decodedToken: { + claims: { + __raw: 'idtoken', + name: 'Test', + exp: new Date().getTime() / 1000 + 2 + }, + user: { name: 'Test' } + } + }); + + // Test that the cache state is normal up until just before the expiry time.. + jest.advanceTimersByTime(799); + expect(Object.keys(cache.cache).length).toBe(1); + + // Advance the time to just past the expiry.. + jest.advanceTimersByTime(1); + + // And test that the cache has been emptied, except for the refresh token + expect(cache.cache).toStrictEqual({ + '@@auth0spajs@@::test-client::the_audience::the_scope': { + refresh_token: 'refreshtoken' + } + }); + }); + it('expires after `user.exp` when `user.exp` < `expires_in`', () => { cache.save({ client_id: 'test-client', - audience: 'the_audiene', + audience: 'the_audience', scope: 'the_scope', id_token: 'idtoken', access_token: 'accesstoken', @@ -78,9 +118,15 @@ describe('InMemoryCache', () => { user: { name: 'Test' } } }); + + // Test that the cache state is normal up until just before the expiry time.. jest.advanceTimersByTime(799); expect(Object.keys(cache.cache).length).toBe(1); + + // Advance the time to just past the expiry.. jest.advanceTimersByTime(1); + + // And test that the cache has been emptied expect(Object.keys(cache.cache).length).toBe(0); }); }); @@ -93,6 +139,7 @@ describe('LocalStorageCache', () => { beforeEach(() => { cache = new LocalStorageCache(); + jest.clearAllMocks(); jest.useFakeTimers(); localStorage.clear(); (localStorage.removeItem).mockClear(); @@ -109,11 +156,11 @@ describe('LocalStorageCache', () => { scope: '__TEST_SCOPE__', id_token: '__ID_TOKEN__', access_token: '__ACCESS_TOKEN__', - expires_in: 86400, + expires_in: dayInSeconds, decodedToken: { claims: { __raw: 'idtoken', - exp: nowSeconds() + 86500, + exp: nowSeconds() + dayInSeconds + 100, name: 'Test' }, user: { name: 'Test' } @@ -127,59 +174,67 @@ describe('LocalStorageCache', () => { global.Date.now = realDateNow; }); - it('can set a value into the cache when expires_in < exp', () => { - cache.save(defaultEntry); - - expect(localStorage.setItem).toHaveBeenCalledWith( - '@@auth0spajs@@::__TEST_CLIENT_ID__::__TEST_AUDIENCE__::__TEST_SCOPE__', - JSON.stringify({ - body: defaultEntry, - expiresAt: nowSeconds() + 86400 - 60 - }) - ); - }); - - it('can set a value into the cache when exp < expires_in', () => { - const entry = Object.assign({}, defaultEntry, { - expires_in: 86500, - decodedToken: { - claims: { - exp: nowSeconds() + 100 - } - } + describe('cache.get', () => { + it('can retrieve an item from the cache', () => { + localStorage.setItem( + '@@auth0spajs@@::__TEST_CLIENT_ID__::__TEST_AUDIENCE__::__TEST_SCOPE__', + JSON.stringify({ + body: defaultEntry, + expiresAt: nowSeconds() + dayInSeconds + }) + ); + + expect( + cache.get({ + client_id: '__TEST_CLIENT_ID__', + audience: '__TEST_AUDIENCE__', + scope: '__TEST_SCOPE__' + }) + ).toStrictEqual(defaultEntry); }); - cache.save(entry); - - expect(localStorage.setItem).toHaveBeenCalledWith( - '@@auth0spajs@@::__TEST_CLIENT_ID__::__TEST_AUDIENCE__::__TEST_SCOPE__', - JSON.stringify({ - body: entry, - expiresAt: nowSeconds() + 40 - }) - ); - }); - - it('can retrieve an item from the cache', () => { - localStorage.setItem( - '@@auth0spajs@@::__TEST_CLIENT_ID__::__TEST_AUDIENCE__::__TEST_SCOPE__', - JSON.stringify({ - body: defaultEntry, - expiresAt: nowSeconds() + 86400 - }) - ); - - expect( - cache.get({ - client_id: '__TEST_CLIENT_ID__', - audience: '__TEST_AUDIENCE__', - scope: '__TEST_SCOPE__' - }) - ).toStrictEqual(defaultEntry); - }); + it('returns undefined when there is no data', () => { + expect(cache.get({ scope: '', audience: '' })).toBeUndefined(); + }); - it('returns undefined when there is no data', () => { - expect(cache.get({ scope: '', audience: '' })).toBeUndefined(); + it('strips the data, leaving the refresh token, when the expiry has been reached', () => { + localStorage.setItem( + '@@auth0spajs@@::__TEST_CLIENT_ID__::__TEST_AUDIENCE__::__TEST_SCOPE__', + 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__', + expires_in: 10, + decodedToken: { + claims: { + __raw: 'idtoken', + exp: nowSeconds() + 15, + name: 'Test' + }, + user: { name: 'Test' } + } + }, + expiresAt: nowSeconds() + 10 + }) + ); + + const now = nowSeconds(); + global.Date.now = jest.fn(() => (now + 30) * 1000); + + expect( + cache.get({ + client_id: '__TEST_CLIENT_ID__', + audience: '__TEST_AUDIENCE__', + scope: '__TEST_SCOPE__' + }) + ).toStrictEqual({ + refresh_token: '__REFRESH_TOKEN__' + }); + }); }); it('expires after cache `expiresAt` when expiresAt < current time', () => { @@ -192,20 +247,23 @@ describe('LocalStorageCache', () => { scope: '__TEST_SCOPE__', id_token: '__ID_TOKEN__', access_token: '__ACCESS_TOKEN__', - expires_in: -10, + expires_in: 10, decodedToken: { claims: { __raw: 'idtoken', - exp: nowSeconds() - 5, + exp: nowSeconds() + 15, name: 'Test' }, user: { name: 'Test' } } }, - expiresAt: nowSeconds() - 10 + expiresAt: nowSeconds() + 10 }) ); + const now = nowSeconds(); + global.Date.now = jest.fn(() => (now + 30) * 1000); + expect( cache.get({ client_id: '__TEST_CLIENT_ID__', @@ -219,22 +277,85 @@ describe('LocalStorageCache', () => { ); }); - it('deletes the cache item once the timeout has been reached', () => { - const entry = Object.assign({}, defaultEntry, { - expires_in: 120, - decodedToken: { - claims: { - exp: nowSeconds() + 240 + describe('cache.save', () => { + it('can set a value into the cache when expires_in < exp', () => { + cache.save(defaultEntry); + + expect(localStorage.setItem).toHaveBeenCalledWith( + '@@auth0spajs@@::__TEST_CLIENT_ID__::__TEST_AUDIENCE__::__TEST_SCOPE__', + JSON.stringify({ + body: defaultEntry, + expiresAt: nowSeconds() + dayInSeconds - 60 + }) + ); + }); + + it('can set a value into the cache when exp < expires_in', () => { + const entry = Object.assign({}, defaultEntry, { + expires_in: dayInSeconds + 100, + decodedToken: { + claims: { + exp: nowSeconds() + 100 + } } - } + }); + + cache.save(entry); + + expect(localStorage.setItem).toHaveBeenCalledWith( + '@@auth0spajs@@::__TEST_CLIENT_ID__::__TEST_AUDIENCE__::__TEST_SCOPE__', + JSON.stringify({ + body: entry, + expiresAt: nowSeconds() + 40 + }) + ); }); - cache.save(entry); + it('deletes the cache item once the timeout has been reached', () => { + const entry = Object.assign({}, defaultEntry, { + expires_in: 120, + decodedToken: { + claims: { + exp: nowSeconds() + 240 + } + } + }); - // 96000, because the timeout time will be calculated at expires_in * 1000 * 0.8 - jest.advanceTimersByTime(96000); + cache.save(entry); - expect(localStorage.removeItem).toHaveBeenCalled(); + // 96000, because the timeout time will be calculated at expires_in * 1000 * 0.8 + jest.advanceTimersByTime(96000); + + expect(localStorage.removeItem).toHaveBeenCalled(); + }); + + it('strips the cache data, leaving the refresh token, once the timeout has been reached', () => { + const exp = nowSeconds() + 240; + const expiresIn = nowSeconds() + 120; + + const entry = Object.assign({}, defaultEntry, { + expires_in: 120, + refresh_token: 'refresh-token', + decodedToken: { + claims: { + exp + } + } + }); + + cache.save(entry); + + // 96000, because the timeout time will be calculated at expires_in * 1000 * 0.8 + jest.advanceTimersByTime(96000); + + const payload = JSON.parse( + localStorage.getItem( + '@@auth0spajs@@::__TEST_CLIENT_ID__::__TEST_AUDIENCE__::__TEST_SCOPE__' + ) + ); + + expect(payload.body).toStrictEqual({ refresh_token: 'refresh-token' }); + }); }); it('removes the correct items when the cache is cleared', () => { diff --git a/__tests__/index.test.ts b/__tests__/index.test.ts index ab7ac6455..d4a4ffc75 100644 --- a/__tests__/index.test.ts +++ b/__tests__/index.test.ts @@ -1267,7 +1267,19 @@ describe('Auth0', () => { await auth0.getTokenSilently(); - //we only evaluate that the code didn't bail out because of the cache + // we only evaluate that the code didn't bail out because of the cache + expect(utils.encode).toHaveBeenCalledWith(TEST_RANDOM_STRING); + }); + + it('continues method execution when there is a value from the cache but no access token', async () => { + const { auth0, utils, cache } = await setup(); + + cache.get.mockReturnValue({}); + + await auth0.getTokenSilently(); + + // we only evaluate that the code didn't bail out because the cache didn't return + // an access token expect(utils.encode).toHaveBeenCalledWith(TEST_RANDOM_STRING); }); }); diff --git a/cypress/integration/getTokenSilently.js b/cypress/integration/getTokenSilently.js index d11ac8e8f..63733889f 100644 --- a/cypress/integration/getTokenSilently.js +++ b/cypress/integration/getTokenSilently.js @@ -55,7 +55,7 @@ describe('getTokenSilently', function() { cy.toggleSwitch('local-storage'); cy.login().then(() => { - cy.reload(); + cy.reload().wait(5000); cy.get('[data-cy=get-token]') .click() @@ -82,7 +82,8 @@ describe('getTokenSilently', function() { cy.toggleSwitch('use-cache'); cy.login().then(() => { - cy.toggleSwitch('refresh-tokens').wait(250); + cy.toggleSwitch('refresh-tokens').wait(1000); + win.localStorage.clear(); cy.get('[data-cy=get-token]') .click() diff --git a/src/Auth0Client.ts b/src/Auth0Client.ts index 376ad59bd..a2850d753 100644 --- a/src/Auth0Client.ts +++ b/src/Auth0Client.ts @@ -268,7 +268,7 @@ export default class Auth0Client { ...options }); - return cache && cache.decodedToken.user; + return cache && cache.decodedToken && cache.decodedToken.user; } /** @@ -297,7 +297,7 @@ export default class Auth0Client { ...options }); - return cache && cache.decodedToken.claims; + return cache && cache.decodedToken && cache.decodedToken.claims; } /** @@ -409,7 +409,7 @@ export default class Auth0Client { client_id: this.options.client_id }); - if (cache) { + if (cache && cache.access_token) { await lock.releaseLock(GET_TOKEN_SILENTLY_LOCK_KEY); return cache.access_token; } @@ -562,6 +562,12 @@ export default class Auth0Client { private async _getTokenUsingRefreshToken( options: GetTokenSilentlyOptions ): Promise { + options.scope = getUniqueScopes( + this.DEFAULT_SCOPE, + this.options.scope, + options.scope + ); + const cache = this.cache.get({ scope: options.scope, audience: options.audience || 'default', diff --git a/src/cache.ts b/src/cache.ts index 6cba73f6c..1e7644d88 100644 --- a/src/cache.ts +++ b/src/cache.ts @@ -21,12 +21,12 @@ interface CacheEntry { } interface CachedTokens { - [key: string]: CacheEntry; + [key: string]: Partial; } export interface ICache { save(entry: CacheEntry): void; - get(key: CacheKeyData): CacheEntry; + get(key: CacheKeyData): Partial; clear(): void; } @@ -40,6 +40,11 @@ const getExpirationTimeoutInMilliseconds = (expiresIn: number, exp: number) => { return Math.min(expiresIn, expTime) * 1000 * 0.8; }; +type LocalStorageCachePayload = { + body: Partial; + expiresAt: number; +}; + export class LocalStorageCache implements ICache { public save(entry: CacheEntry): void { const cacheKey = createKey(entry); @@ -48,7 +53,7 @@ export class LocalStorageCache implements ICache { const expirySeconds = Math.min(expiresInTime, entry.decodedToken.claims.exp) - 60; // take off a small leeway - const payload = { + const payload: LocalStorageCachePayload = { body: entry, expiresAt: expirySeconds }; @@ -59,27 +64,41 @@ export class LocalStorageCache implements ICache { ); setTimeout(() => { - window.localStorage.removeItem(cacheKey); + const payload = this.getPayload(cacheKey); + + if (!payload || !payload.body) { + return; + } + + if (payload.body.refresh_token) { + const newPayload = this.stripPayload(payload); + localStorage.setItem(cacheKey, JSON.stringify(newPayload)); + + return; + } + + localStorage.removeItem(cacheKey); }, timeout); window.localStorage.setItem(cacheKey, JSON.stringify(payload)); } - public get(key: CacheKeyData): CacheEntry { + public get(key: CacheKeyData): Partial { const cacheKey = createKey(key); - const json = window.localStorage.getItem(cacheKey); - let payload; - - if (!json) return; - - payload = JSON.parse(json); + const payload = this.getPayload(cacheKey); + const nowSeconds = Math.floor(Date.now() / 1000); if (!payload) return; - const nowSeconds = Math.floor(Date.now() / 1000); - if (payload.expiresAt < nowSeconds) { - window.localStorage.removeItem(cacheKey); + if (payload.body.refresh_token) { + const newPayload = this.stripPayload(payload); + localStorage.setItem(cacheKey, JSON.stringify(newPayload)); + + return newPayload.body; + } + + localStorage.removeItem(cacheKey); return; } @@ -93,6 +112,44 @@ export class LocalStorageCache implements ICache { } } } + + /** + * Retrieves data from local storage and parses it into the correct format + * @param cacheKey The cache key + */ + private getPayload(cacheKey: string): LocalStorageCachePayload { + const json = window.localStorage.getItem(cacheKey); + let payload; + + if (!json) { + return; + } + + payload = JSON.parse(json); + + if (!payload) { + return; + } + + return payload; + } + + /** + * Produce a copy of the payload with everything removed except the refresh token + * @param payload The payload + */ + private stripPayload( + payload: LocalStorageCachePayload + ): LocalStorageCachePayload { + const { refresh_token } = payload.body; + + const newPayload: LocalStorageCachePayload = { + body: { refresh_token: refresh_token }, + expiresAt: payload.expiresAt + }; + + return newPayload; + } } export class InMemoryCache implements ICache { @@ -108,6 +165,15 @@ export class InMemoryCache implements ICache { ); setTimeout(() => { + const payload = this.cache[key]; + + if (!payload) return; + + if (payload.refresh_token) { + this.cache[key] = { refresh_token: payload.refresh_token }; + return; + } + delete this.cache[key]; }, timeout); } diff --git a/static/index.html b/static/index.html index 024e961dc..fa56e4d74 100644 --- a/static/index.html +++ b/static/index.html @@ -19,6 +19,8 @@

Auth0 SPA JS Playground

+

Is authenticated: {{ isAuthenticated }}

+
+
+ + +
+
Last error