diff --git a/lib/msal-common/src/error/ClientAuthError.ts b/lib/msal-common/src/error/ClientAuthError.ts index daf575dbc3..05b4f46ed0 100644 --- a/lib/msal-common/src/error/ClientAuthError.ts +++ b/lib/msal-common/src/error/ClientAuthError.ts @@ -102,6 +102,10 @@ export const ClientAuthErrorMessage = { DeviceCodeExpired: { code: "device_code_expired", desc: "Device code is expired." + }, + CachePluginError: { + code: "no cache plugin set on CacheManager", + desc: "ICachePlugin needs to be set before using readFromStorage or writeFromStorage" } }; @@ -297,4 +301,11 @@ export class ClientAuthError extends AuthError { static createDeviceCodeExpiredError(): ClientAuthError { return new ClientAuthError(ClientAuthErrorMessage.DeviceCodeExpired.code, `${ClientAuthErrorMessage.DeviceCodeExpired.desc}`); } + + /** + * Throws error if ICachePlugin not set on CacheManager + */ + static createCachePluginError(): ClientAuthError { + return new ClientAuthError(ClientAuthErrorMessage.CachePluginError.code, `${ClientAuthErrorMessage.CachePluginError.desc}`); + } } diff --git a/lib/msal-common/src/unifiedCache/utils/CacheTypes.ts b/lib/msal-common/src/unifiedCache/utils/CacheTypes.ts index 8786e6d758..c4f6feed38 100644 --- a/lib/msal-common/src/unifiedCache/utils/CacheTypes.ts +++ b/lib/msal-common/src/unifiedCache/utils/CacheTypes.ts @@ -18,6 +18,7 @@ export type RefreshTokenCache = { [key: string]: RefreshTokenEntity }; export type AppMetadataCache = { [key: string]: AppMetadataEntity }; export type JsonCache = { + [key: string]: StringDict; Account?: StringDict; IdToken?: StringDict; AccessToken?: StringDict; diff --git a/lib/msal-common/test/utils/StringConstants.ts b/lib/msal-common/test/utils/StringConstants.ts index e974adfc26..7b89eb5a4a 100644 --- a/lib/msal-common/test/utils/StringConstants.ts +++ b/lib/msal-common/test/utils/StringConstants.ts @@ -17,7 +17,7 @@ export const TEST_TOKENS = { SAMPLE_JWT_PAYLOAD: "eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ", SAMPLE_JWT_SIG: "SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c", SAMPLE_MALFORMED_JWT: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ", - CACHE_IDTOKEN: "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsIng1dCI6Imk2bEdrM0ZaenhSY1ViMkMzbkVRN3N5SEpsWSIsImtpZCI6Imk2bEdrM0ZaenhSY1ViMkMzbkVRN3N5SEpsWSJ9.eyJvaWQiOiAib2JqZWN0MTIzNCIsICJwcmVmZXJyZWRfdXNlcm5hbWUiOiAiSm9obiBEb2UiLCAic3ViIjogInN1YiJ9.D3H6pMUtQnoJAGq6AHd" + CACHE_IDTOKEN: "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsIng1dCI6Imk2bEdrM0ZaenhSY1ViMkMzbkVRN3N5SEpsWSIsImtpZCI6Imk2bEdrM0ZaenhSY1ViMkMzbkVRN3N5SEpsWSJ9.eyJvaWQiOiAib2JqZWN0MTIzNCIsICJwcmVmZXJyZWRfdXNlcm5hbWUiOiAiSm9obiBEb2UiLCAic3ViIjogInN1YiJ9.D3H6pMUtQnoJAGq6AHd", }; // Test Expiration Vals @@ -234,3 +234,4 @@ export const AUTHORIZATION_PENDING_RESPONSE = { error_uri: 'https://login.microsoftonline.com/error?code=70016' } }; + diff --git a/lib/msal-node/src/cache/CacheManager.ts b/lib/msal-node/src/cache/CacheManager.ts new file mode 100644 index 0000000000..422d8fb25b --- /dev/null +++ b/lib/msal-node/src/cache/CacheManager.ts @@ -0,0 +1,221 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { Storage } from './Storage'; +import { + Serializer, + Deserializer, + JsonCache, + ClientAuthError, + StringUtils +} from '@azure/msal-common'; +import { ICachePlugin } from './ICachePlugin'; + +const defaultSerializedCache: JsonCache = { + Account: {}, + IdToken: {}, + AccessToken: {}, + RefreshToken: {}, + AppMetadata: {}, +}; + +/** + * In-memory token cache manager + */ +export class CacheManager { + + private storage: Storage; + private hasChanged: boolean; + private cacheSnapshot: string; + private readonly persistence: ICachePlugin; + + constructor(storage: Storage, cachePlugin?: ICachePlugin) { + this.hasChanged = false; + this.storage = storage; + this.storage.registerChangeEmitter(this.handleChangeEvent.bind(this)); + if (cachePlugin) { + this.persistence = cachePlugin; + } + } + + /** + * Set to true if cache state has changed since last time serialized() or writeToPersistence was called + */ + cacheHasChanged(): boolean { + return this.hasChanged; + } + + /** + * Serializes in memory cache to JSON + */ + serialize(): string { + let finalState = Serializer.serializeAllCache(this.storage.getCache()); + + // if cacheSnapshot not null or empty, merge + if (!StringUtils.isEmpty(this.cacheSnapshot)) { + finalState = this.mergeState( + JSON.parse(this.cacheSnapshot), + finalState + ); + } + this.hasChanged = false; + + return JSON.stringify(finalState); + } + + /** + * Deserializes JSON to in-memory cache. JSON should be in MSAL cache schema format + * @param cache + */ + deserialize(cache: string): void { + this.cacheSnapshot = cache; + + if (!StringUtils.isEmpty(this.cacheSnapshot)) { + const deserializedCache = Deserializer.deserializeAllCache(this.overlayDefaults(JSON.parse(this.cacheSnapshot))); + this.storage.setCache(deserializedCache); + } + } + + /** + * Serializes cache into JSON and calls ICachePlugin.writeToStorage. ICachePlugin must be set on ClientApplication + */ + async writeToPersistence(): Promise { + if (this.persistence) { + let cache = Serializer.serializeAllCache(this.storage.getCache()); + + const getMergedState = (stateFromDisk: string) => { + + if (!StringUtils.isEmpty(stateFromDisk)) { + this.cacheSnapshot = stateFromDisk; + cache = this.mergeState(JSON.parse(stateFromDisk), cache); + } + + return JSON.stringify(cache); + }; + + await this.persistence.writeToStorage(getMergedState); + this.hasChanged = false; + } else { + throw ClientAuthError.createCachePluginError(); + } + } + + /** + * Calls ICachePlugin.readFromStorage and deserializes JSON to in-memory cache. + * ICachePlugin must be set on ClientApplication. + */ + async readFromPersistence(): Promise { + if (this.persistence) { + this.cacheSnapshot = await this.persistence.readFromStorage(); + + if (!StringUtils.isEmpty(this.cacheSnapshot)) { + const cache = this.overlayDefaults(JSON.parse(this.cacheSnapshot)); + const deserializedCache = Deserializer.deserializeAllCache(cache); + this.storage.setCache(deserializedCache); + } + } else { + throw ClientAuthError.createCachePluginError(); + } + } + + /** + * Called when the cache has changed state. + */ + private handleChangeEvent() { + this.hasChanged = true; + } + + /** + * Merge in memory cache with the cache snapshot. + * @param oldState + * @param currentState + */ + private mergeState(oldState: JsonCache, currentState: JsonCache): JsonCache { + let stateAfterRemoval = this.mergeRemovals(oldState, currentState); + return this.mergeUpdates(stateAfterRemoval, currentState); + } + + /** + * Deep update of oldState based on newState values + * @param oldState + * @param newState + */ + private mergeUpdates(oldState: any, newState: any): JsonCache { + Object.keys(newState).forEach((newKey) => { + let newValue = newState[newKey]; + + // if oldState does not contain value but newValue does, add it + if (!oldState.hasOwnProperty(newKey)) { + if (newValue != null) { + oldState[newKey] = newValue; + } + } else { + // both oldState and newState contain the key, do deep update + let newValueNotNull = newValue !== null; + let newValueIsObject = typeof newValue === 'object'; + let newValueIsNotArray = !Array.isArray(newValue); + + if (newValueNotNull && newValueIsObject && newValueIsNotArray) { + this.mergeUpdates(oldState[newKey], newValue); + } else { + oldState[newKey] = newValue; + } + } + }); + + return oldState; + } + + /** + * Removes entities in oldState that the were removed from newState. If there are any unknown values in root of + * oldState that are not recognized, they are left untouched. + * @param oldState + * @param newState + */ + private mergeRemovals(oldState: JsonCache, newState: JsonCache): JsonCache { + // set of entities created because we only want to remove these. If the oldState contains any other things, + // we leave them untouched + const entities = new Set(["Account", "AccessToken", "RefreshToken", "IdToken", "AppMetadata"]); + + entities.forEach((entity: string) => { + let oldEntries = oldState[entity]; + let newEntries = newState[entity]; + // if entity is in oldState but not in newState remove it + if (oldEntries != null) { + Object.keys(oldEntries).forEach((oldKey) => { + if (!newEntries || !(newEntries.hasOwnProperty(oldKey))) { + delete oldEntries[oldKey]; + } + }) + } + }); + return oldState; + } + + private overlayDefaults(passedInCache: JsonCache): JsonCache { + return { + Account: { + ...defaultSerializedCache.Account, + ...passedInCache.Account, + }, + IdToken: { + ...defaultSerializedCache.IdToken, + ...passedInCache.IdToken, + }, + AccessToken: { + ...defaultSerializedCache.AccessToken, + ...passedInCache.AccessToken, + }, + RefreshToken: { + ...defaultSerializedCache.RefreshToken, + ...passedInCache.RefreshToken, + }, + AppMetadata: { + ...defaultSerializedCache.AppMetadata, + ...passedInCache.AppMetadata, + }, + }; + } +} diff --git a/lib/msal-node/src/cache/ICachePlugin.ts b/lib/msal-node/src/cache/ICachePlugin.ts new file mode 100644 index 0000000000..8d2c8a12bf --- /dev/null +++ b/lib/msal-node/src/cache/ICachePlugin.ts @@ -0,0 +1,11 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +export interface ICachePlugin { + readFromStorage: () => Promise; + writeToStorage: ( + getMergedState: (oldState: string) => string + ) => Promise; +} diff --git a/lib/msal-node/src/cache/Storage.ts b/lib/msal-node/src/cache/Storage.ts index 845b428deb..75262744e8 100644 --- a/lib/msal-node/src/cache/Storage.ts +++ b/lib/msal-node/src/cache/Storage.ts @@ -9,7 +9,6 @@ import { CacheSchemaType, CacheHelper, } from '@azure/msal-common'; -import { CacheOptions } from '../config/Configuration'; import { AccountEntity } from '@azure/msal-common/dist/src/unifiedCache/entities/AccountEntity'; import { AccessTokenEntity } from '@azure/msal-common/dist/src/unifiedCache/entities/AccessTokenEntity'; import { RefreshTokenEntity } from '@azure/msal-common/dist/src/unifiedCache/entities/RefreshTokenEntity'; @@ -21,13 +20,21 @@ import { AppMetadataEntity } from '@azure/msal-common/dist/src/unifiedCache/enti */ export class Storage implements ICacheStorage { // Cache configuration, either set by user or default values. - private cacheConfig: CacheOptions; - private inMemoryCache: InMemoryCache; + private inMemoryCache: InMemoryCache = { + accounts: {}, + accessTokens: {}, + refreshTokens: {}, + appMetadata: {}, + idTokens: {}, + }; + private changeEmitters: Array = []; - constructor(cacheConfig: CacheOptions) { - this.cacheConfig = cacheConfig; - if (this.cacheConfig.cacheLocation! === 'fileCache') - this.inMemoryCache = this.cacheConfig.cacheInMemory!; + registerChangeEmitter(func: () => void): void { + this.changeEmitters.push(func); + } + + emitChange() { + this.changeEmitters.forEach(func => func.call(null)); } /** @@ -43,6 +50,7 @@ export class Storage implements ICacheStorage { */ setCache(inMemoryCache: InMemoryCache) { this.inMemoryCache = inMemoryCache; + this.emitChange(); } /** @@ -53,6 +61,7 @@ export class Storage implements ICacheStorage { */ setItem(key: string, value: string): void { if (key && value) { + this.emitChange(); return; } } @@ -103,6 +112,7 @@ export class Storage implements ICacheStorage { // update inMemoryCache this.setCache(cache); + this.emitChange(); } /** @@ -163,7 +173,11 @@ export class Storage implements ICacheStorage { * @param key */ removeItem(key: string): boolean { - return key ? true : false; + if (!key) { + return false; + } + this.emitChange(); + return true; } /** @@ -228,6 +242,7 @@ export class Storage implements ICacheStorage { // write to the cache after removal if (result) { this.setCache(cache); + this.emitChange(); } return result; } @@ -253,6 +268,7 @@ export class Storage implements ICacheStorage { * Clears all cache entries created by MSAL (except tokens). */ clear(): void { + this.emitChange(); return; } } diff --git a/lib/msal-node/src/client/ClientApplication.ts b/lib/msal-node/src/client/ClientApplication.ts index 994f17eb49..5cbe5fd5a8 100644 --- a/lib/msal-node/src/client/ClientApplication.ts +++ b/lib/msal-node/src/client/ClientApplication.ts @@ -16,22 +16,20 @@ import { ClientAuthError, Constants, B2cAuthority, - JsonCache, - Serializer, } from '@azure/msal-common'; import { Configuration, buildAppConfiguration } from '../config/Configuration'; import { CryptoProvider } from '../crypto/CryptoProvider'; import { Storage } from '../cache/Storage'; import { version } from '../../package.json'; import { Constants as NodeConstants } from './../utils/Constants'; -import { CacheContext } from '../cache/CacheContext'; +import { CacheManager } from '../cache/CacheManager'; export abstract class ClientApplication { private config: Configuration; private _authority: Authority; private readonly cryptoProvider: CryptoProvider; private storage: Storage; - private cacheContext: CacheContext; + private cacheManager: CacheManager; /** * @constructor @@ -39,11 +37,14 @@ export abstract class ClientApplication { */ protected constructor(configuration: Configuration) { this.config = buildAppConfiguration(configuration); + this.storage = new Storage(); + this.cacheManager = new CacheManager( + this.storage, + this.config.cache?.cachePlugin + ); this.cryptoProvider = new CryptoProvider(); - this.storage = new Storage(this.config.cache!); B2cAuthority.setKnownAuthorities(this.config.auth.knownAuthorities!); - this.cacheContext = new CacheContext(); } /** @@ -138,6 +139,10 @@ export abstract class ClientApplication { }; } + getCacheManager(): CacheManager { + return this.cacheManager; + } + /** * Create authority instance. If authority not passed in request, default to authority set on the application * object. If no authority set in application object, then default to common authority. @@ -177,19 +182,4 @@ export abstract class ClientApplication { return this._authority; } - - /** - * Initialize cache from a user provided Json file - * @param cacheObject - */ - initializeCache(cacheObject: JsonCache) { - this.cacheContext.setCurrentCache(this.storage, cacheObject); - } - - /** - * read the cache as a Json convertible object from memory - */ - readCache(): JsonCache { - return Serializer.serializeAllCache(this.storage.getCache()); - } } diff --git a/lib/msal-node/src/config/Configuration.ts b/lib/msal-node/src/config/Configuration.ts index 1461d862d1..df9dc951f2 100644 --- a/lib/msal-node/src/config/Configuration.ts +++ b/lib/msal-node/src/config/Configuration.ts @@ -6,11 +6,10 @@ import { LoggerOptions, INetworkModule, LogLevel, - InMemoryCache, } from '@azure/msal-common'; import { NetworkUtils } from '../utils/NetworkUtils'; -import { CACHE } from '../utils/Constants'; import debug from 'debug'; +import { ICachePlugin } from 'cache/ICachePlugin'; /** * - clientId - Client id of the application. @@ -26,14 +25,11 @@ export type NodeAuthOptions = { /** * Use this to configure the below cache configuration options: * - * - cacheLocation - Used to specify the cacheLocation user wants to set. Valid values are "localStorage" and "sessionStorage" - * - storeAuthStateInCookie - If set, MSAL store's the auth request state required for validation of the auth flows in the browser cookies. By default this flag is set to false. + * - cachePlugin for persistence provided to library */ // TODO Temporary placeholder - this will be rewritten by cache PR. export type CacheOptions = { - cacheLocation?: string; - storeAuthStateInCookie?: boolean; - cacheInMemory?: InMemoryCache; + cachePlugin?: ICachePlugin; }; /** @@ -66,17 +62,7 @@ const DEFAULT_AUTH_OPTIONS: NodeAuthOptions = { knownAuthorities: [], }; -const DEFAULT_CACHE_OPTIONS: CacheOptions = { - cacheLocation: CACHE.FILE_CACHE, - storeAuthStateInCookie: false, - cacheInMemory: { - accounts: {}, - idTokens: {}, - accessTokens: {}, - refreshTokens: {}, - appMetadata: {}, - }, -}; +const DEFAULT_CACHE_OPTIONS: CacheOptions = {}; const DEFAULT_LOGGER_OPTIONS: LoggerOptions = { loggerCallback: ( diff --git a/lib/msal-node/src/index.ts b/lib/msal-node/src/index.ts index 269b707501..afb89fc0c4 100644 --- a/lib/msal-node/src/index.ts +++ b/lib/msal-node/src/index.ts @@ -5,6 +5,8 @@ export { Storage } from './cache/Storage'; // crypto export { CryptoProvider } from './crypto/CryptoProvider'; +export { CacheManager } from './cache/CacheManager'; +export { ICachePlugin } from './cache/ICachePlugin'; // Common Object Formats export { diff --git a/lib/msal-node/test/config/ClientConfiguration.spec.ts b/lib/msal-node/test/config/ClientConfiguration.spec.ts index a604dd95d4..958355da8e 100644 --- a/lib/msal-node/test/config/ClientConfiguration.spec.ts +++ b/lib/msal-node/test/config/ClientConfiguration.spec.ts @@ -2,7 +2,6 @@ import { buildAppConfiguration, Configuration, } from '../../src/config/Configuration'; -import { CACHE } from '../../src/utils/Constants'; import { HttpClient } from '../../src/network/HttpClient'; import { TEST_CONSTANTS } from '../utils/TestConstants'; import { LogLevel, NetworkRequestOptions } from '@azure/msal-common'; @@ -68,9 +67,6 @@ describe('ClientConfiguration tests', () => { expect(config.auth!.authority).toEqual(''); expect(config.auth!.clientId).toEqual(TEST_CONSTANTS.CLIENT_ID); - // cache options - expect(config.cache!.cacheLocation).toEqual(CACHE.FILE_CACHE); - expect(config.cache!.storeAuthStateInCookie).toEqual(false); }); test('builds configuration and assigns default functions', () => { @@ -117,10 +113,6 @@ describe('ClientConfiguration tests', () => { piiLoggingEnabled: true, }, }, - cache: { - cacheLocation: TEST_CONSTANTS.CACHE_LOCATION, - storeAuthStateInCookie: true, - }, }; const testNetworkOptions = { @@ -150,10 +142,5 @@ describe('ClientConfiguration tests', () => { expect(config.auth!.authority).toEqual(TEST_CONSTANTS.AUTHORITY); expect(config.auth!.clientId).toEqual(TEST_CONSTANTS.CLIENT_ID); - // cache options - expect(config.cache!.cacheLocation).toEqual( - TEST_CONSTANTS.CACHE_LOCATION - ); - expect(config.cache!.storeAuthStateInCookie).toEqual(true); }); }); diff --git a/samples/msal-node-auth-code/index.js b/samples/msal-node-auth-code/index.js index 8654879775..04249fe70e 100644 --- a/samples/msal-node-auth-code/index.js +++ b/samples/msal-node-auth-code/index.js @@ -4,44 +4,50 @@ */ const express = require("express"); const msal = require('@azure/msal-node'); -const myLocalCache = require("./data/cache"); -const fs = require("fs"); +const {promises: fs} = require("fs"); const SERVER_PORT = process.env.PORT || 3000; -// initialize msal public client application +const readFromStorage = () => { + return fs.readFile("./data/cache.json", "utf-8"); +}; + +const writeToStorage = (cache) => { + return fs.writeFile("./data/cacheAfterWrite.json", cache) +}; + +const cachePlugin = { + readFromStorage, + writeToStorage +}; + const publicClientConfig = { auth: { clientId: "99cab759-2aab-420b-91d8-5e3d8d4f063b", - authority: - "https://login.microsoftonline.com/90b8faa8-cc95-460e-a618-ee770bee1759", + authority: "https://login.microsoftonline.com/90b8faa8-cc95-460e-a618-ee770bee1759", redirectUri: "http://localhost:3000/redirect", }, cache: { - cacheLocation: "fileCache", // This configures where your cache will be stored - storeAuthStateInCookie: false, // Set this to "true" if you are having issues on IE11 or Edge + cachePlugin: cachePlugin }, }; const pca = new msal.PublicClientApplication(publicClientConfig); -pca.initializeCache(myLocalCache); +const msalCacheManager = pca.getCacheManager(); // Create Express App and Routes const app = express(); -app.get('/', (req, res) => { +app.get('/', (req, res) => { const authCodeUrlParameters = { scopes: ["user.read"], redirectUri: ["http://localhost:3000/redirect"], - prompt: msal.Prompt.SELECT_ACCOUNT }; // get url to sign user in and consent to scopes needed for application - pca.getAuthCodeUrl(authCodeUrlParameters) - .then((response) => { - console.log(response); - res.redirect(response); - }) - .catch((error) => console.log(JSON.stringify(error))); + pca.getAuthCodeUrl(authCodeUrlParameters).then((response) => { + console.log(response); + res.redirect(response); + }).catch((error) => console.log(JSON.stringify(error))); }); app.get('/redirect', (req, res) => { @@ -49,18 +55,19 @@ app.get('/redirect', (req, res) => { code: req.query.code, redirectUri: "http://localhost:3000/redirect", scopes: ["user.read"], - // codeVerifier: "" }; pca.acquireTokenByCode(tokenRequest).then((response) => { console.log("\nResponse: \n:", response); res.send(200); - // uncomment this to show writing of cache, dont commit real tokens. - // fs.writeFileSync("./data/cache.json", JSON.stringify(pca.readCache()), null, 4); + return msalCacheManager.writeToPersistence(); }).catch((error) => { console.log(error); res.status(500).send(error); }); }); -app.listen(SERVER_PORT, () => console.log(`Msal Node Auth Code Sample app listening on port ${SERVER_PORT}!`)) +msalCacheManager.readFromPersistence().then(() => { + app.listen(SERVER_PORT, () => console.log(`Msal Node Auth Code Sample app listening on port ${SERVER_PORT}!`)) +}); +