Skip to content

Commit

Permalink
[SDK-2555] Extensible Cache (#743)
Browse files Browse the repository at this point in the history
* Add CacheManager and refactor LocalStorageCache

* Refactor in-memory cache to use new interface

* Move refresh token logic to CacheManager

* Refactor Auth0Client to use async CacheManager

* Refactor structure of cache objects into their own files

* Add cache property to Auth0Client

* Add example of custom cache to playground (session storage)

* Add some basic tests for cache manager

* Fix cache tests

* Fixed up remaining tests

* Split tests for memory and local storage, increase coverage

* Add test file for both localstorage + memory

* Fix tests for local storage

* Rename localstorage cache module file

* Export cache objects from primary module

* Add test coverage for Auth0Client

* Use constant for audience in cache tests

* Use Promise.resolve everywhere rather than two patterns

* Use Promise.resolve in playground sample

* Make cache interface generic

* Wrap cache key creation in a helper method

* Initial tests for cache manifest

* Add key removal + tests

* Testing various scenarios around scope matching

* Removing the key from the manifest if the cache item was not found

* Remove key from manifest when expiry has been hit

* Remove findExistingKey from local storage cache and simplify

* Refactored findExistingCacheKey into cache manager

* Remove unused import

* Key migration into the manifest when keys do not exist

* Run npm audit fix

* Prefer exact match on cache before loose matching on scope

* Don't really need to remove from key manifest if item wasn't found there

* Add button for checkSession to the playground

* Remove unused import

* Add custom cache info to the readme

* Rename some symbols

* Add strong typing to ICache over using unknown types

* Remove clear(), refactor manifest to take key string over CacheKey

* Remove clear() from cache mock

* Remove unused import

* Add tests for cache shared utility funcs

* Remove cache entry type guards

* Use types instead of interfaces for various cache objects

More semantically correct than interfaces since these types just define
shapes of objects, they are not designed to be implemented by classes.

* Fix up Promise.resolve examples based on feedback

* Fix use of arrow function in playground, which breaks IE11

* Move Array.split outside of the loop

* Refactor CacheKeyManifest#add to use Set

* Prefer this.manifestKey

* Prefer using Set over includes. Also rename vars for readability

* Prefer Set in CacheKeyManifest#remove

* Add optional allKeys to cache and refactor manager to prefer this

* Consider optional keyManifest and update tests

* Added LocalStorageCache.allKeys, refined tests

* Remove unused import

* Add docs for allKeys in the readme

* Remove marketing blurb for Organizations

* Cache manager no longer adds to manifest on read

* Rename cacheDescriptors to cacheFactories in tests

* Implement allKeys on InMemoryCache

* Refactor CacheManager#clear so that coverage can be ignored
  • Loading branch information
Steve Hobbs authored Jul 5, 2021
1 parent 19103a6 commit e6d2507
Show file tree
Hide file tree
Showing 29 changed files with 16,250 additions and 1,062 deletions.
65 changes: 51 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,57 @@ await createAuth0Client({

**Important:** This feature will allow the caching of data **such as ID and access tokens** to be stored in local storage. Exercising this option changes the security characteristics of your application and **should not be used lightly**. Extra care should be taken to mitigate against XSS attacks and minimize the risk of tokens being stolen from local storage.

#### Creating a custom cache

The SDK can be configured to use a custom cache store that is implemented by your application. This is useful if you are using this SDK in an environment where more secure token storage is available, such as potentially a hybrid mobile app.

To do this, provide an object to the `cache` property of the SDK configuration.

The object should implement the following functions:

| Signature | Description |
| ---------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `async get(key)` | Returns the item from the cache with the specified key, or `undefined` if it was not found |
| `async set(key: string, object: any): Promise<void>` | Sets an item into the cache |
| `async remove(key)` | Removes a single item from the cache at the specified key, or no-op if the item was not found |
| `async allKeys()` | (optional) Implement this if your cache has the ability to return a list of all keys. Otherwise, the SDK internally records its own key manifest using your cache. **Note**: if you only want to ensure you only return keys used by this SDK, the keys we use are prefixed with `@@auth0spajs@@` |

Here's an example of a custom cache implementation that uses `sessionStorage` to store tokens and apply it to the Auth0 SPA SDK:

```js
const sessionStorageCache = {
get: function (key) {
return Promise.resolve(JSON.parse(sessionStorage.getItem(key)));
},

set: function (key, value) {
sessionStorage.setItem(key, JSON.stringify(value));
return Promise.resolve();
},

remove: function (key) {
sessionStorage.removeItem(key);
return Promise.resolve();
},

// Optional
allKeys: function () {
return Promise.resolve(Object.keys(sessionStorage));
}
};

await createAuth0Client({
domain: '<AUTH0_DOMAIN>',
client_id: '<AUTH0_CLIENT_ID>',
redirect_uri: '<MY_CALLBACK_URL>',
cache: sessionStorageCache
});
```

**Note:** The `cache` property takes precedence over the `cacheLocation` property if both are set. A warning is displayed in the console if this scenario occurs.

We also export the internal `InMemoryCache` and `LocalStorageCache` implementations, so you can wrap your custom cache around these implementations if you wish.

### Refresh Tokens

Refresh tokens can be used to request new access tokens. [Read more about how our refresh tokens work for browser-based applications](https://auth0.com/docs/tokens/concepts/refresh-token-rotation) to help you decide whether or not you need to use them.
Expand Down Expand Up @@ -241,20 +292,6 @@ If the fallback mechanism fails, a `login_required` error will be thrown and cou

[Organizations](https://auth0.com/docs/organizations) is a set of features that provide better support for developers who build and maintain SaaS and Business-to-Business (B2B) applications.

Using Organizations, you can:

- Represent teams, business customers, partner companies, or any logical grouping of users that should have different ways of accessing your applications, as organizations.

- Manage their membership in a variety of ways, including user invitation.

- Configure branded, federated login flows for each organization.

- Implement role-based access control, such that users can have different roles when authenticating in the context of different organizations.

- Build administration capabilities into your products, using Organizations APIs, so that those businesses can manage their own organizations.

Note that Organizations is currently only available to customers on our Enterprise and Startup subscription plans.

#### Log in to an organization

Log in to an organization by specifying the `organization` parameter when setting up the client:
Expand Down
30 changes: 29 additions & 1 deletion __tests__/Auth0Client/constructor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,10 @@ import * as scope from '../../src/scope';

// @ts-ignore

import { setupFn } from './helpers';
import { loginWithRedirectFn, setupFn } from './helpers';

import { TEST_CLIENT_ID, TEST_CODE_CHALLENGE, TEST_DOMAIN } from '../constants';
import { ICache } from '../../src/cache';

jest.mock('unfetch');
jest.mock('es-cookie');
Expand All @@ -20,6 +21,12 @@ const mockWindow = <any>global;
const mockFetch = (mockWindow.fetch = <jest.Mock>unfetch);
const mockVerify = <jest.Mock>verify;

const mockCache: ICache = {
set: jest.fn().mockResolvedValue(null),
get: jest.fn().mockResolvedValue(null),
remove: jest.fn().mockResolvedValue(null)
};

jest
.spyOn(utils, 'bufferToBase64UrlEncoded')
.mockReturnValue(TEST_CODE_CHALLENGE);
Expand Down Expand Up @@ -130,6 +137,27 @@ describe('Auth0Client', () => {

expect((<any>auth0).tokenIssuer).toEqual('https://some.issuer.com/');
});

it('uses a custom cache if one was given in the configuration', async () => {
const auth0 = setup({
cache: mockCache
});

await loginWithRedirectFn(mockWindow, mockFetch)(auth0);

expect(mockCache.set).toHaveBeenCalled();
});

it('uses a custom cache if both `cache` and `cacheLocation` were specified', async () => {
const auth0 = setup({
cache: mockCache,
cacheLocation: 'localstorage'
});

await loginWithRedirectFn(mockWindow, mockFetch)(auth0);

expect(mockCache.set).toHaveBeenCalled();
});
});

describe('buildLogoutUrl', () => {
Expand Down
4 changes: 1 addition & 3 deletions __tests__/Auth0Client/getIdTokenClaims.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,10 +73,8 @@ describe('Auth0Client', () => {
describe('getIdTokenClaims', () => {
it('returns undefined if there is no cache', async () => {
const auth0 = setup();

jest.spyOn(auth0['cache'], 'get').mockReturnValueOnce(undefined);

const decodedToken = await auth0.getIdTokenClaims();

expect(decodedToken).toBeUndefined();
});

Expand Down
16 changes: 11 additions & 5 deletions __tests__/Auth0Client/getTokenSilently.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import * as api from '../../src/api';

import { expectToHaveBeenCalledWithAuth0ClientParam } from '../helpers';

import { GET_TOKEN_SILENTLY_LOCK_KEY } from '../constants';
import { GET_TOKEN_SILENTLY_LOCK_KEY, TEST_AUDIENCE } from '../constants';

// @ts-ignore
import { acquireLockSpy } from 'browser-tabs-lock';
Expand Down Expand Up @@ -45,6 +45,7 @@ import {
INVALID_REFRESH_TOKEN_ERROR_MESSAGE
} from '../../src/constants';
import { GenericError } from '../../src/errors';
import { CacheKey } from '../../src/cache';

jest.mock('unfetch');
jest.mock('es-cookie');
Expand Down Expand Up @@ -973,23 +974,27 @@ describe('Auth0Client', () => {
try {
const auth0 = setup();
await loginWithRedirect(auth0);
(auth0 as any).cache.clear();
await (auth0 as any).cacheManager.clear();

jest.spyOn(<any>utils, 'runIframe').mockResolvedValue({
access_token: TEST_ACCESS_TOKEN,
state: TEST_STATE
});

mockFetch.mockResolvedValue(
fetchResponse(true, {
id_token: TEST_ID_TOKEN,
access_token: TEST_ACCESS_TOKEN,
expires_in: 86400
})
);
let [access_token] = await Promise.all([

const [access_token] = await Promise.all([
auth0.getTokenSilently(),
auth0.getTokenSilently(),
auth0.getTokenSilently()
]);

expect(access_token).toEqual(TEST_ACCESS_TOKEN);
expect(utils.runIframe).toHaveBeenCalledTimes(1);
} finally {
Expand Down Expand Up @@ -1264,15 +1269,16 @@ describe('Auth0Client', () => {
it('saves into cache', async () => {
const auth0 = setup();

jest.spyOn(auth0['cache'], 'save');
jest.spyOn(auth0['cacheManager'], 'set');

jest.spyOn(<any>utils, 'runIframe').mockResolvedValue({
access_token: TEST_ACCESS_TOKEN,
state: TEST_STATE
});

await getTokenSilently(auth0);

expect(auth0['cache']['save']).toHaveBeenCalledWith(
expect(auth0['cacheManager']['set']).toHaveBeenCalledWith(
expect.objectContaining({
client_id: TEST_CLIENT_ID,
access_token: TEST_ACCESS_TOKEN,
Expand Down
6 changes: 2 additions & 4 deletions __tests__/Auth0Client/getUser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,12 +69,10 @@ describe('Auth0Client', () => {
});

describe('getUser', () => {
it('returns undefined if there is no cache', async () => {
it('returns undefined if there is no user in the cache', async () => {
const auth0 = setup();

jest.spyOn(auth0['cache'], 'get').mockReturnValueOnce(undefined);

const decodedToken = await auth0.getUser();

expect(decodedToken).toBeUndefined();
});
});
Expand Down
18 changes: 13 additions & 5 deletions __tests__/Auth0Client/isAuthenticated.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ describe('Auth0Client', () => {

mockWindow.open = jest.fn();
mockWindow.addEventListener = jest.fn();

mockWindow.crypto = {
subtle: {
digest: () => 'foo'
Expand All @@ -58,9 +59,12 @@ describe('Auth0Client', () => {
return '123';
}
};

mockWindow.MessageChannel = MessageChannel;
mockWindow.Worker = {};

jest.spyOn(scope, 'getUniqueScopes');

sessionStorage.clear();
});

Expand All @@ -72,7 +76,7 @@ describe('Auth0Client', () => {

describe('isAuthenticated', () => {
describe('loginWithRedirect', () => {
it('returns true if there is an user', async () => {
it('returns true if there is a user', async () => {
const auth0 = setup();
await loginWithRedirect(auth0);

Expand All @@ -82,14 +86,17 @@ describe('Auth0Client', () => {

it('returns false if error was returned', async () => {
const auth0 = setup();

try {
await loginWithRedirect(auth0, undefined, {
authorize: {
error: 'some-error'
}
});
} catch {}

const result = await auth0.isAuthenticated();

expect(result).toBe(false);
});

Expand All @@ -106,7 +113,7 @@ describe('Auth0Client', () => {
});

describe('loginWithPopup', () => {
it('returns true if there is an user', async () => {
it('returns true if there is a user', async () => {
const auth0 = setup();
await loginWithPopup(auth0);

Expand All @@ -117,6 +124,7 @@ describe('Auth0Client', () => {

it('returns false if code not part of URL', async () => {
const auth0 = setup();

try {
await loginWithPopup(auth0, undefined, undefined, {
authorize: {
Expand All @@ -126,16 +134,16 @@ describe('Auth0Client', () => {
}
});
} catch {}

const result = await auth0.isAuthenticated();

expect(result).toBe(false);
});

it('returns false if there is no user', async () => {
const auth0 = setup();

jest.spyOn(auth0['cache'], 'get').mockReturnValueOnce(undefined);

const result = await auth0.isAuthenticated();

expect(result).toBe(false);
});
});
Expand Down
20 changes: 10 additions & 10 deletions __tests__/Auth0Client/loginWithPopup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -475,11 +475,11 @@ describe('Auth0Client', () => {
it('saves into cache', async () => {
const auth0 = setup();

jest.spyOn(auth0['cache'], 'save');
jest.spyOn(auth0['cacheManager'], 'set');

await loginWithPopup(auth0);

expect(auth0['cache']['save']).toHaveBeenCalledWith(
expect(auth0['cacheManager']['set']).toHaveBeenCalledWith(
expect.objectContaining({
client_id: TEST_CLIENT_ID,
access_token: TEST_ACCESS_TOKEN,
Expand All @@ -500,11 +500,11 @@ describe('Auth0Client', () => {
};
tokenVerifier.mockReturnValue(mockDecodedToken);

jest.spyOn(auth0['cache'], 'save');
jest.spyOn(auth0['cacheManager'], 'set');

await loginWithPopup(auth0);

expect(auth0['cache']['save']).toHaveBeenCalledWith(
expect(auth0['cacheManager']['set']).toHaveBeenCalledWith(
expect.objectContaining({
decodedToken: mockDecodedToken
})
Expand All @@ -516,12 +516,12 @@ describe('Auth0Client', () => {
useRefreshTokens: true
});

jest.spyOn(auth0['cache'], 'save');

jest.spyOn(auth0['cacheManager'], 'set');
await loginWithPopup(auth0);

expect(auth0['cache']['save']).toHaveBeenCalled();
expect(auth0['cache']['save']).not.toHaveBeenCalledWith(
expect(auth0['cacheManager']['set']).toHaveBeenCalled();

expect(auth0['cacheManager']['set']).not.toHaveBeenCalledWith(
expect.objectContaining({
refresh_token: TEST_REFRESH_TOKEN
})
Expand All @@ -534,11 +534,11 @@ describe('Auth0Client', () => {
cacheLocation: 'localstorage'
});

jest.spyOn(auth0['cache'], 'save');
jest.spyOn(auth0['cacheManager'], 'set');

await loginWithPopup(auth0);

expect(auth0['cache']['save']).toHaveBeenCalledWith(
expect(auth0['cacheManager']['set']).toHaveBeenCalledWith(
expect.objectContaining({
refresh_token: TEST_REFRESH_TOKEN
})
Expand Down
4 changes: 2 additions & 2 deletions __tests__/Auth0Client/loginWithRedirect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -360,11 +360,11 @@ describe('Auth0Client', () => {
it('saves into cache', async () => {
const auth0 = setup();

jest.spyOn(auth0['cache'], 'save');
jest.spyOn(auth0['cacheManager'], 'set');

await loginWithRedirect(auth0);

expect(auth0['cache']['save']).toHaveBeenCalledWith(
expect(auth0['cacheManager']['set']).toHaveBeenCalledWith(
expect.objectContaining({
client_id: TEST_CLIENT_ID,
access_token: TEST_ACCESS_TOKEN,
Expand Down
4 changes: 2 additions & 2 deletions __tests__/Auth0Client/logout.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,11 +115,11 @@ describe('Auth0Client', () => {

it('clears the cache', async () => {
const auth0 = setup();
jest.spyOn(auth0['cache'], 'clear').mockReturnValueOnce(undefined);
jest.spyOn(auth0['cacheManager'], 'clear').mockReturnValueOnce(undefined);

auth0.logout();

expect(auth0['cache']['clear']).toHaveBeenCalled();
expect(auth0['cacheManager']['clear']).toHaveBeenCalled();
});

it('removes `auth0.is.authenticated` key from storage when `options.localOnly` is true', async () => {
Expand Down
Loading

0 comments on commit e6d2507

Please sign in to comment.