Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[msal-node] hone surface for msal extension library #1687

Closed
wants to merge 10 commits into from
11 changes: 11 additions & 0 deletions lib/msal-common/src/error/ClientAuthError.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
};

Expand Down Expand Up @@ -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}`);
}
}
1 change: 1 addition & 0 deletions lib/msal-common/src/unifiedCache/utils/CacheTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export type RefreshTokenCache = { [key: string]: RefreshTokenEntity };
export type AppMetadataCache = { [key: string]: AppMetadataEntity };

export type JsonCache = {
[key: string]: StringDict;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is this? Is this allowing arbitrary nested StrictDict object?

Account?: StringDict;
IdToken?: StringDict;
AccessToken?: StringDict;
Expand Down
3 changes: 2 additions & 1 deletion lib/msal-common/test/utils/StringConstants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -234,3 +234,4 @@ export const AUTHORIZATION_PENDING_RESPONSE = {
error_uri: 'https://login.microsoftonline.com/error?code=70016'
}
};

221 changes: 221 additions & 0 deletions lib/msal-node/src/cache/CacheManager.ts
Original file line number Diff line number Diff line change
@@ -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 {
DarylThayil marked this conversation as resolved.
Show resolved Hide resolved

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<void> {
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<void> {
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;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of mutating the passed in parameter (which lint rules will not allow in the future), can you construct a new object?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep, will update.

}

/**
* 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;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same comment about mutating passed in value.

}

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,
},
};
}
}
11 changes: 11 additions & 0 deletions lib/msal-node/src/cache/ICachePlugin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
/*
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License.
*/

export interface ICachePlugin {
readFromStorage: () => Promise<string>;
writeToStorage: (
getMergedState: (oldState: string) => string
) => Promise<void>;
}
32 changes: 24 additions & 8 deletions lib/msal-node/src/cache/Storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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<Function> = [];

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));
}

/**
Expand All @@ -43,6 +50,7 @@ export class Storage implements ICacheStorage {
*/
setCache(inMemoryCache: InMemoryCache) {
this.inMemoryCache = inMemoryCache;
this.emitChange();
}

/**
Expand All @@ -53,6 +61,7 @@ export class Storage implements ICacheStorage {
*/
setItem(key: string, value: string): void {
if (key && value) {
this.emitChange();
return;
}
}
Expand Down Expand Up @@ -103,6 +112,7 @@ export class Storage implements ICacheStorage {

// update inMemoryCache
this.setCache(cache);
this.emitChange();
sameerag marked this conversation as resolved.
Show resolved Hide resolved
}

/**
Expand Down Expand Up @@ -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();
sameerag marked this conversation as resolved.
Show resolved Hide resolved
return true;
}

/**
Expand Down Expand Up @@ -228,6 +242,7 @@ export class Storage implements ICacheStorage {
// write to the cache after removal
if (result) {
this.setCache(cache);
this.emitChange();
}
return result;
}
Expand All @@ -253,6 +268,7 @@ export class Storage implements ICacheStorage {
* Clears all cache entries created by MSAL (except tokens).
*/
clear(): void {
this.emitChange();
return;
}
}