UNPKG

@auth0/auth0-spa-js

Version:

Auth0 SDK for Single Page Applications using Authorization Code Grant Flow with PKCE

300 lines (247 loc) 8.73 kB
import { DEFAULT_NOW_PROVIDER } from '../constants'; import { CacheKeyManifest } from './key-manifest'; import { CacheEntry, ICache, CacheKey, CACHE_KEY_PREFIX, WrappedCacheEntry, DecodedToken, CACHE_KEY_ID_TOKEN_SUFFIX, IdTokenEntry } from './shared'; const DEFAULT_EXPIRY_ADJUSTMENT_SECONDS = 0; export class CacheManager { private nowProvider: () => number | Promise<number>; constructor( private cache: ICache, private keyManifest?: CacheKeyManifest, nowProvider?: () => number | Promise<number> ) { this.nowProvider = nowProvider || DEFAULT_NOW_PROVIDER; } async setIdToken( clientId: string, idToken: string, decodedToken: DecodedToken ): Promise<void> { const cacheKey = this.getIdTokenCacheKey(clientId); await this.cache.set(cacheKey, { id_token: idToken, decodedToken }); await this.keyManifest?.add(cacheKey); } async getIdToken(cacheKey: CacheKey): Promise<IdTokenEntry | undefined> { const entry = await this.cache.get<IdTokenEntry>( this.getIdTokenCacheKey(cacheKey.clientId) ); if (!entry && cacheKey.scope && cacheKey.audience) { const entryByScope = await this.get(cacheKey); if (!entryByScope) { return; } if (!entryByScope.id_token || !entryByScope.decodedToken) { return; } return { id_token: entryByScope.id_token, decodedToken: entryByScope.decodedToken }; } if (!entry) { return; } return { id_token: entry.id_token, decodedToken: entry.decodedToken }; } async get( cacheKey: CacheKey, expiryAdjustmentSeconds = DEFAULT_EXPIRY_ADJUSTMENT_SECONDS, useMrrt = false, cacheMode?: string ): Promise<Partial<CacheEntry> | undefined> { let wrappedEntry = await this.cache.get<WrappedCacheEntry>( cacheKey.toKey() ); if (!wrappedEntry) { const keys = await this.getCacheKeys(); if (!keys) return; const matchedKey = this.matchExistingCacheKey(cacheKey, keys); if (matchedKey) { wrappedEntry = await this.cache.get<WrappedCacheEntry>(matchedKey); } // To refresh using MRRT we need to send a request to the server // If cacheMode is 'cache-only', this will make us unable to call the server // so it won't be needed to find a valid refresh token if (!matchedKey && useMrrt && cacheMode !== 'cache-only') { return this.getEntryWithRefreshToken(cacheKey, keys); } } // If we still don't have an entry, exit. if (!wrappedEntry) { return; } const now = await this.nowProvider(); const nowSeconds = Math.floor(now / 1000); if (wrappedEntry.expiresAt - expiryAdjustmentSeconds < nowSeconds) { if (wrappedEntry.body.refresh_token) { return this.modifiedCachedEntry(wrappedEntry, cacheKey); } await this.cache.remove(cacheKey.toKey()); await this.keyManifest?.remove(cacheKey.toKey()); return; } return wrappedEntry.body; } private async modifiedCachedEntry(wrappedEntry: WrappedCacheEntry, cacheKey: CacheKey): Promise<Partial<CacheEntry>> { // We need to keep audience and scope in order to check them later when doing refresh // using MRRT. See getScopeToRequest method. wrappedEntry.body = { refresh_token: wrappedEntry.body.refresh_token, audience: wrappedEntry.body.audience, scope: wrappedEntry.body.scope, }; await this.cache.set(cacheKey.toKey(), wrappedEntry); return { refresh_token: wrappedEntry.body.refresh_token, audience: wrappedEntry.body.audience, scope: wrappedEntry.body.scope, }; } async set(entry: CacheEntry): Promise<void> { const cacheKey = new CacheKey({ clientId: entry.client_id, scope: entry.scope, audience: entry.audience }); const wrappedEntry = await this.wrapCacheEntry(entry); await this.cache.set(cacheKey.toKey(), wrappedEntry); await this.keyManifest?.add(cacheKey.toKey()); } async remove( client_id: string, audience?: string, scope?: string, ): Promise<void> { const cacheKey = new CacheKey({ clientId: client_id, scope: scope, audience: audience }); await this.cache.remove(cacheKey.toKey()); } async clear(clientId?: string): Promise<void> { const keys = await this.getCacheKeys(); /* c8 ignore next */ if (!keys) return; await keys .filter(key => (clientId ? key.includes(clientId) : true)) .reduce(async (memo, key) => { await memo; await this.cache.remove(key); }, Promise.resolve()); await this.keyManifest?.clear(); } private async wrapCacheEntry(entry: CacheEntry): Promise<WrappedCacheEntry> { const now = await this.nowProvider(); const expiresInTime = Math.floor(now / 1000) + entry.expires_in; return { body: entry, expiresAt: expiresInTime }; } private async getCacheKeys(): Promise<string[] | undefined> { if (this.keyManifest) { return (await this.keyManifest.get())?.keys; } else if (this.cache.allKeys) { return this.cache.allKeys(); } } /** * Returns the cache key to be used to store the id token * @param clientId The client id used to link to the id token * @returns The constructed cache key, as a string, to store the id token */ private getIdTokenCacheKey(clientId: string) { return new CacheKey( { clientId }, CACHE_KEY_PREFIX, CACHE_KEY_ID_TOKEN_SUFFIX ).toKey(); } /** * Finds the corresponding key in the cache based on the provided cache key. * The keys inside the cache are in the format {prefix}::{clientId}::{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` * - `clientId` is strict equal to the `cacheKey.clientId` * - `audience` is strict equal to the `cacheKey.audience` * - `scope` contains at least all the `cacheKey.scope` values * * * @param keyToMatch The provided cache key * @param allKeys A list of existing cache keys */ private matchExistingCacheKey(keyToMatch: CacheKey, allKeys: Array<string>) { return allKeys.filter(key => { const cacheKey = CacheKey.fromKey(key); const scopeSet = new Set(cacheKey.scope && cacheKey.scope.split(' ')); const scopesToMatch = keyToMatch.scope?.split(' ') || []; const hasAllScopes = cacheKey.scope && scopesToMatch.reduce( (acc, current) => acc && scopeSet.has(current), true ); return ( cacheKey.prefix === CACHE_KEY_PREFIX && cacheKey.clientId === keyToMatch.clientId && cacheKey.audience === keyToMatch.audience && hasAllScopes ); })[0]; } /** * Returns the first entry that contains a refresh_token that satisfies the following conditions * The keys inside the cache are in the format {prefix}::{clientId}::{audience}::{scope}. * - `prefix` is strict equal to Auth0's internally configured `keyPrefix` * - `clientId` is strict equal to the `cacheKey.clientId` * @param keyToMatch The provided cache key * @param allKeys A list of existing cache keys */ private async getEntryWithRefreshToken(keyToMatch: CacheKey, allKeys: Array<string>): Promise<Partial<CacheEntry> | undefined> { for (const key of allKeys) { const cacheKey = CacheKey.fromKey(key); if (cacheKey.prefix === CACHE_KEY_PREFIX && cacheKey.clientId === keyToMatch.clientId) { const cachedEntry = await this.cache.get<WrappedCacheEntry>(key); if (cachedEntry?.body?.refresh_token) { return this.modifiedCachedEntry(cachedEntry, keyToMatch); } } } return undefined; } /** * Updates in the cache all entries that has a match with previous refresh_token with the * new refresh_token obtained from the server * @param oldRefreshToken Old refresh_token used on refresh * @param newRefreshToken New refresh_token obtained from the server after refresh */ async updateEntry( oldRefreshToken: string, newRefreshToken: string, ): Promise<void> { const allKeys = await this.getCacheKeys(); if (!allKeys) return; for (const key of allKeys) { const entry = await this.cache.get<WrappedCacheEntry>(key); if (entry?.body?.refresh_token === oldRefreshToken) { const cacheEntry = { ...entry.body, refresh_token: newRefreshToken, } as CacheEntry; await this.set(cacheEntry); } } } }