diff --git a/lib/msal-common/src/error/ClientAuthError.ts b/lib/msal-common/src/error/ClientAuthError.ts index 761a4071051..cb5150b646f 100644 --- a/lib/msal-common/src/error/ClientAuthError.ts +++ b/lib/msal-common/src/error/ClientAuthError.ts @@ -118,6 +118,10 @@ export const ClientAuthErrorMessage = { noAccountFound: { code: "no_account_found", desc: "No account found in cache for given key." + }, + CachePluginError: { + code: "no cache plugin set on CacheManager", + desc: "ICachePlugin needs to be set before using readFromStorage or writeFromStorage" } }; @@ -259,7 +263,7 @@ export class ClientAuthError extends AuthError { return new ClientAuthError(ClientAuthErrorMessage.multipleMatchingTokens.code, `Cache error for scope ${scope}: ${ClientAuthErrorMessage.multipleMatchingTokens.desc}.`); } - + /** * Throws error when multiple tokens are in cache for the given scope. * @param scope @@ -342,4 +346,11 @@ export class ClientAuthError extends AuthError { static createNoAccountFoundError(): ClientAuthError { return new ClientAuthError(ClientAuthErrorMessage.noAccountFound.code, ClientAuthErrorMessage.noAccountFound.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-node/src/cache/CacheContext.ts b/lib/msal-node/src/cache/CacheContext.ts deleted file mode 100644 index 52d8de544d8..00000000000 --- a/lib/msal-node/src/cache/CacheContext.ts +++ /dev/null @@ -1,57 +0,0 @@ -/* - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. - */ - -import { Storage } from '../cache/Storage'; -import { JsonCache } from "./serializer/SerializerTypes"; -import { Deserializer } from "./serializer/Deserializer"; - -/** - * class managing sync between the persistent cache blob in the disk and the in memory cache of the node - */ -export class CacheContext { - private defaultSerializedCache: JsonCache = { - Account: {}, - IdToken: {}, - AccessToken: {}, - RefreshToken: {}, - AppMetadata: {}, - }; - - /** - * Update the library cache - * @param storage - */ - setCurrentCache(storage: Storage, cacheObject: JsonCache) { - const cacheWithOverlayedDefaults = this.overlayDefaults(cacheObject); - storage.setCache( - Deserializer.deserializeAllCache(cacheWithOverlayedDefaults) - ); - } - - overlayDefaults(passedInCache: JsonCache): JsonCache { - return { - Account: { - ...this.defaultSerializedCache.Account, - ...passedInCache.Account, - }, - IdToken: { - ...this.defaultSerializedCache.IdToken, - ...passedInCache.IdToken, - }, - AccessToken: { - ...this.defaultSerializedCache.AccessToken, - ...passedInCache.AccessToken, - }, - RefreshToken: { - ...this.defaultSerializedCache.RefreshToken, - ...passedInCache.RefreshToken, - }, - AppMetadata: { - ...this.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 00000000000..8d2c8a12bf8 --- /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 eee1b86ee40..578263debee 100644 --- a/lib/msal-node/src/cache/Storage.ts +++ b/lib/msal-node/src/cache/Storage.ts @@ -13,7 +13,6 @@ import { AppMetadataEntity, CacheManager } from '@azure/msal-common'; -import { CacheOptions } from '../config/Configuration'; import { Deserializer } from "./serializer/Deserializer"; import { Serializer } from "./serializer/Serializer"; import { InMemoryCache, JsonCache } from "./serializer/SerializerTypes"; @@ -23,14 +22,27 @@ import { InMemoryCache, JsonCache } from "./serializer/SerializerTypes"; */ export class Storage extends CacheManager { // Cache configuration, either set by user or default values. - private cacheConfig: CacheOptions; - private inMemoryCache: InMemoryCache; - constructor(cacheConfig: CacheOptions) { + constructor() { super(); - this.cacheConfig = cacheConfig; - if (this.cacheConfig.cacheLocation! === 'fileCache') - this.inMemoryCache = this.cacheConfig.cacheInMemory!; + } + + private inMemoryCache: InMemoryCache = { + accounts: {}, + accessTokens: {}, + refreshTokens: {}, + appMetadata: {}, + idTokens: {}, + }; + + private changeEmitters: Array = []; + + registerChangeEmitter(func: () => void): void { + this.changeEmitters.push(func); + } + + emitChange() { + this.changeEmitters.forEach(func => func.call(null)); } /** @@ -46,6 +58,7 @@ export class Storage extends CacheManager { */ setCache(inMemoryCache: InMemoryCache) { this.inMemoryCache = inMemoryCache; + this.emitChange(); } /** @@ -99,6 +112,7 @@ export class Storage extends CacheManager { // update inMemoryCache this.setCache(cache); + this.emitChange(); } /** @@ -209,6 +223,7 @@ export class Storage extends CacheManager { // write to the cache after removal if (result) { this.setCache(cache); + this.emitChange(); } return result; } @@ -253,6 +268,7 @@ export class Storage extends CacheManager { this.removeItem(internalKey); }); }); + this.emitChange(); } /** diff --git a/lib/msal-node/src/cache/TokenCache.ts b/lib/msal-node/src/cache/TokenCache.ts new file mode 100644 index 00000000000..baa5ddd8580 --- /dev/null +++ b/lib/msal-node/src/cache/TokenCache.ts @@ -0,0 +1,228 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { Storage } from './Storage'; +import { + ClientAuthError, StringDict, + StringUtils +} from '@azure/msal-common'; +import { InMemoryCache, JsonCache } from "cache/serializer/SerializerTypes"; +import { ICachePlugin } from './ICachePlugin'; +import { Deserializer } from "./serializer/Deserializer"; +import { Serializer } from "./serializer/Serializer"; + +const defaultSerializedCache: JsonCache = { + Account: {}, + IdToken: {}, + AccessToken: {}, + RefreshToken: {}, + AppMetadata: {}, +}; + +/** + * In-memory token cache manager + */ +export class TokenCache { + + 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() as InMemoryCache); + + // 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() as InMemoryCache); + 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 { + + let finalState = { ...oldState }; + Object.keys(newState).forEach((newKey: string) => { + let newValue = newState[newKey]; + + // if oldState does not contain value but newValue does, add it + if (!finalState.hasOwnProperty(newKey)) { + if (newValue !== null) { + finalState[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(finalState[newKey], newValue); + } else { + finalState[newKey] = newValue; + } + } + }); + + return finalState; + } + + /** + * 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 { + const accounts = oldState.Account !== null ? this.mergeRemovalsStringDict(oldState.Account, newState.Account) : oldState.Account; + const accessTokens = oldState.AccessToken !== null ? this.mergeRemovalsStringDict(oldState.AccessToken, newState.AccessToken) : oldState.AccessToken; + const refreshTokens = oldState.RefreshToken !== null ? this.mergeRemovalsStringDict(oldState.RefreshToken, newState.RefreshToken) : oldState.RefreshToken; + const idTokens = oldState.IdToken !== null ? this.mergeRemovalsStringDict(oldState.IdToken, newState.IdToken) : oldState.IdToken; + const appMetadata = oldState.AppMetadata !== null ? this.mergeRemovalsStringDict(oldState.AppMetadata, newState.AppMetadata) : oldState.AppMetadata; + + return { + Account: accounts, + AccessToken: accessTokens, + RefreshToken: refreshTokens, + IdToken: idTokens, + AppMetadata: appMetadata + } + } + + private mergeRemovalsStringDict(oldState: StringDict, newState?: StringDict): StringDict { + let finalState = { ...oldState }; + Object.keys(oldState).forEach((oldKey) => { + if (!newState || !(newState.hasOwnProperty(oldKey))) { + delete finalState[oldKey]; + } + }); + return finalState; + } + + 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/client/ClientApplication.ts b/lib/msal-node/src/client/ClientApplication.ts index 78e8c6c51d1..4acb6fa09ce 100644 --- a/lib/msal-node/src/client/ClientApplication.ts +++ b/lib/msal-node/src/client/ClientApplication.ts @@ -24,16 +24,14 @@ 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 { JsonCache, InMemoryCache } from "../cache/serializer/SerializerTypes"; -import { Serializer } from "../cache/serializer/Serializer"; +import { TokenCache } from '../cache/TokenCache'; export abstract class ClientApplication { private config: Configuration; private _authority: Authority; private readonly cryptoProvider: CryptoProvider; private storage: Storage; - private cacheContext: CacheContext; + private tokenCache: TokenCache; /** * @constructor @@ -41,11 +39,13 @@ export abstract class ClientApplication { */ protected constructor(configuration: Configuration) { this.config = buildAppConfiguration(configuration); - + this.storage = new Storage(); + this.tokenCache = new TokenCache( + 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(); } /** @@ -106,6 +106,10 @@ export abstract class ClientApplication { return refreshTokenClient.acquireToken(this.initializeRequestScopes(request) as RefreshTokenRequest); } + getCacheManager(): TokenCache { + return this.tokenCache; + } + protected async buildOauthClientConfiguration(authority?: string): Promise { // using null assertion operator as we ensure that all config values have default values in buildConfiguration() return { @@ -134,10 +138,10 @@ export abstract class ClientApplication { /** * Generates a request with the default scopes. - * @param authRequest + * @param authRequest */ protected initializeRequestScopes(authRequest: BaseAuthRequest): BaseAuthRequest { - const request: BaseAuthRequest = { ...authRequest }; + const request: BaseAuthRequest = {...authRequest}; if (!request.scopes) { request.scopes = [Constants.OPENID_SCOPE, Constants.PROFILE_SCOPE, Constants.OFFLINE_ACCESS_SCOPE]; } else { @@ -183,23 +187,6 @@ 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() as InMemoryCache - ); - } - getAllAccounts(): IAccount[] { return this.storage.getAllAccounts(); } diff --git a/lib/msal-node/src/config/Configuration.ts b/lib/msal-node/src/config/Configuration.ts index 4f47d22d266..cde7fe23aeb 100644 --- a/lib/msal-node/src/config/Configuration.ts +++ b/lib/msal-node/src/config/Configuration.ts @@ -8,9 +8,8 @@ import { LogLevel } from '@azure/msal-common'; import { NetworkUtils } from '../utils/NetworkUtils'; -import { CACHE } from '../utils/Constants'; import debug from 'debug'; -import { InMemoryCache } from "cache/serializer/SerializerTypes"; +import { ICachePlugin } from "cache/ICachePlugin"; /** * - clientId - Client id of the application. @@ -26,14 +25,10 @@ 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 - Plugin for reading and writing token cache to disk. */ -// TODO Temporary placeholder - this will be rewritten by cache PR. export type CacheOptions = { - cacheLocation?: string; - storeAuthStateInCookie?: boolean; - cacheInMemory?: InMemoryCache; + cachePlugin?: ICachePlugin; }; /** @@ -66,17 +61,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 269b707501f..652895106e9 100644 --- a/lib/msal-node/src/index.ts +++ b/lib/msal-node/src/index.ts @@ -2,6 +2,8 @@ export { PublicClientApplication } from './client/PublicClientApplication'; export { ConfidentialClientApplication } from './client/ConfidentialClientApplication'; export { Configuration, buildAppConfiguration } from './config/Configuration'; export { Storage } from './cache/Storage'; +export { TokenCache } from './cache/TokenCache'; +export { ICachePlugin } from './cache/ICachePlugin'; // crypto export { CryptoProvider } from './crypto/CryptoProvider'; diff --git a/lib/msal-node/test/config/ClientConfiguration.spec.ts b/lib/msal-node/test/config/ClientConfiguration.spec.ts index a604dd95d4c..9e38dcccdbc 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'; @@ -67,10 +66,6 @@ describe('ClientConfiguration tests', () => { // auth options 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 +112,7 @@ describe('ClientConfiguration tests', () => { piiLoggingEnabled: true, }, }, - cache: { - cacheLocation: TEST_CONSTANTS.CACHE_LOCATION, - storeAuthStateInCookie: true, - }, + cache: {}, }; const testNetworkOptions = { @@ -149,11 +141,5 @@ describe('ClientConfiguration tests', () => { // auth options 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 8654879775c..862daf9a14f 100644 --- a/samples/msal-node-auth-code/index.js +++ b/samples/msal-node-auth-code/index.js @@ -4,44 +4,53 @@ */ 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 = (getMergedState) => { + return readFromStorage().then(oldFile =>{ + const mergedState = getMergedState(oldFile); + return fs.writeFile("./data/cacheAfterWrite.json", mergedState); + }) +}; + +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 }, }; 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 +58,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}!`)) +}); +