UNPKG

@azure/msal-browser

Version:
1,386 lines (1,305 loc) 83.2 kB
/* * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. */ import { AccessTokenEntity, AccountEntity, AccountEntityUtils, AccountInfo, ActiveAccountFilters, AppMetadataEntity, AuthorityMetadataEntity, CacheError, CacheErrorCodes, CacheHelpers, CacheManager, CacheRecord, CommonAuthorizationUrlRequest, Constants, createCacheError, DEFAULT_CRYPTO_IMPLEMENTATION, ICrypto, IdTokenEntity, invokeAsync, IPerformanceClient, Logger, PerformanceEvents, RefreshTokenEntity, ServerTelemetryEntity, StaticAuthorityOptions, StoreInCache, StringUtils, ThrottlingEntity, TimeUtils, TokenKeys, CredentialEntity, AuthToken, getTenantIdFromIdTokenClaims, buildTenantProfile, } from "@azure/msal-common/browser"; import { CacheOptions } from "../config/Configuration.js"; import { BrowserAuthErrorCodes, createBrowserAuthError, } from "../error/BrowserAuthError.js"; import { BrowserCacheLocation, InMemoryCacheKeys, INTERACTION_TYPE, TemporaryCacheKeys, ApiId, apiIdToName, } from "../utils/BrowserConstants.js"; import * as CacheKeys from "./CacheKeys.js"; import { LocalStorage } from "./LocalStorage.js"; import { SessionStorage } from "./SessionStorage.js"; import { MemoryStorage } from "./MemoryStorage.js"; import { IWindowStorage } from "./IWindowStorage.js"; import { PlatformAuthRequest } from "../broker/nativeBroker/PlatformAuthRequest.js"; import { AuthenticationResult } from "../response/AuthenticationResult.js"; import { SilentRequest } from "../request/SilentRequest.js"; import { SsoSilentRequest } from "../request/SsoSilentRequest.js"; import { RedirectRequest } from "../request/RedirectRequest.js"; import { PopupRequest } from "../request/PopupRequest.js"; import { base64Decode } from "../encode/Base64Decode.js"; import { base64Encode } from "../encode/Base64Encode.js"; import { CookieStorage } from "./CookieStorage.js"; import { getAccountKeys, getTokenKeys } from "./CacheHelpers.js"; import { EventType } from "../event/EventType.js"; import * as BrowserUtils from "../utils/BrowserUtils.js"; import { EventHandler } from "../event/EventHandler.js"; import { clearHash } from "../utils/BrowserUtils.js"; import { version } from "../packageMetadata.js"; import { removeElementFromArray } from "../utils/Helpers.js"; import { EncryptedData, isEncrypted } from "./EncryptedData.js"; type KmsiMap = { [homeAccountId: string]: boolean }; /** * This class implements the cache storage interface for MSAL through browser local or session storage. */ export class BrowserCacheManager extends CacheManager { // Cache configuration, either set by user or default values. protected cacheConfig: Required<CacheOptions>; // Window storage object (either local or sessionStorage) protected browserStorage: IWindowStorage<string>; // Internal in-memory storage object used for data used by msal that does not need to persist across page loads protected internalStorage: MemoryStorage<string>; // Temporary cache protected temporaryCacheStorage: IWindowStorage<string>; // Cookie storage protected cookieStorage: CookieStorage; // Logger instance protected logger: Logger; // Event Handler private eventHandler: EventHandler; constructor( clientId: string, cacheConfig: Required<CacheOptions>, cryptoImpl: ICrypto, logger: Logger, performanceClient: IPerformanceClient, eventHandler: EventHandler, staticAuthorityOptions?: StaticAuthorityOptions ) { super( clientId, cryptoImpl, logger, performanceClient, staticAuthorityOptions ); this.cacheConfig = cacheConfig; this.logger = logger; this.internalStorage = new MemoryStorage(); this.browserStorage = getStorageImplementation( clientId, cacheConfig.cacheLocation, logger, performanceClient ); this.temporaryCacheStorage = getStorageImplementation( clientId, BrowserCacheLocation.SessionStorage, logger, performanceClient ); this.cookieStorage = new CookieStorage(); this.eventHandler = eventHandler; } async initialize(correlationId: string): Promise<void> { this.performanceClient.addFields( { cacheLocation: this.cacheConfig.cacheLocation, cacheRetentionDays: this.cacheConfig.cacheRetentionDays, }, correlationId ); await this.browserStorage.initialize(correlationId); await this.migrateExistingCache(correlationId); this.trackVersionChanges(correlationId); } /** * Migrates any existing cache data from previous versions of MSAL.js into the current cache structure. */ async migrateExistingCache(correlationId: string): Promise<void> { let accountKeys = getAccountKeys(this.browserStorage); let tokenKeys = getTokenKeys(this.clientId, this.browserStorage); this.performanceClient.addFields( { preMigrateAcntCount: accountKeys.length, preMigrateATCount: tokenKeys.accessToken.length, preMigrateITCount: tokenKeys.idToken.length, preMigrateRTCount: tokenKeys.refreshToken.length, }, correlationId ); for (let i = 0; i < CacheKeys.ACCOUNT_SCHEMA_VERSION; i++) { const credentialSchema = i; // For now account and credential schemas are the same, but may diverge in future await this.removeStaleAccounts(i, credentialSchema, correlationId); } // Must migrate idTokens first to ensure we have KMSI info for the rest for (let i = 0; i < CacheKeys.CREDENTIAL_SCHEMA_VERSION; i++) { const accountSchema = i; // For now account and credential schemas are the same, but may diverge in future await this.migrateIdTokens(i, accountSchema, correlationId); } const kmsiMap = this.getKMSIValues(); for (let i = 0; i < CacheKeys.CREDENTIAL_SCHEMA_VERSION; i++) { await this.migrateAccessTokens(i, kmsiMap, correlationId); await this.migrateRefreshTokens(i, kmsiMap, correlationId); } accountKeys = getAccountKeys(this.browserStorage); tokenKeys = getTokenKeys(this.clientId, this.browserStorage); this.performanceClient.addFields( { postMigrateAcntCount: accountKeys.length, postMigrateATCount: tokenKeys.accessToken.length, postMigrateITCount: tokenKeys.idToken.length, postMigrateRTCount: tokenKeys.refreshToken.length, }, correlationId ); } /** * Parses entry, adds lastUpdatedAt if it doesn't exist, removes entry if expired or invalid * @param key * @param correlationId * @returns */ async updateOldEntry( key: string, correlationId: string ): Promise<CredentialEntity | null> { const rawValue = this.browserStorage.getItem(key); const parsedValue = this.validateAndParseJson(rawValue || "") as | CredentialEntity | EncryptedData | null; if (!parsedValue) { this.browserStorage.removeItem(key); return null; } if (!parsedValue.lastUpdatedAt) { // Add lastUpdatedAt to the existing v0 entry if it doesnt exist so we know when it's safe to remove it parsedValue.lastUpdatedAt = Date.now().toString(); this.setItem(key, JSON.stringify(parsedValue), correlationId); } else if ( TimeUtils.isCacheExpired( parsedValue.lastUpdatedAt, this.cacheConfig.cacheRetentionDays ) ) { this.browserStorage.removeItem(key); this.performanceClient.incrementFields( { expiredCacheRemovedCount: 1 }, correlationId ); return null; } const decryptedData = isEncrypted(parsedValue) ? await this.browserStorage.decryptData( key, parsedValue, correlationId ) : parsedValue; if (!decryptedData || !CacheHelpers.isCredentialEntity(decryptedData)) { this.performanceClient.incrementFields( { invalidCacheCount: 1 }, correlationId ); return null; } if ( (CacheHelpers.isAccessTokenEntity(decryptedData) || CacheHelpers.isRefreshTokenEntity(decryptedData)) && decryptedData.expiresOn && TimeUtils.isTokenExpired( decryptedData.expiresOn, Constants.DEFAULT_TOKEN_RENEWAL_OFFSET_SEC ) ) { this.browserStorage.removeItem(key); this.performanceClient.incrementFields( { expiredCacheRemovedCount: 1 }, correlationId ); return null; } return decryptedData; } /** * Remove accounts from the cache for older schema versions if they have not been updated in the last cacheRetentionDays * @param accountSchema * @param credentialSchema * @param correlationId * @returns */ async removeStaleAccounts( accountSchema: number, credentialSchema: number, correlationId: string ): Promise<void> { const accountKeysToCheck = getAccountKeys( this.browserStorage, accountSchema ); if (accountKeysToCheck.length === 0) { return; } for (const accountKey of [...accountKeysToCheck]) { this.performanceClient.incrementFields( { oldAcntCount: 1 }, correlationId ); const rawValue = this.browserStorage.getItem(accountKey); const parsedValue = this.validateAndParseJson(rawValue || "") as | AccountEntity | EncryptedData | null; if (!parsedValue) { removeElementFromArray(accountKeysToCheck, accountKey); continue; } if (!parsedValue.lastUpdatedAt) { // Add lastUpdatedAt to the existing entry if it doesnt exist so we know when it's safe to remove it parsedValue.lastUpdatedAt = Date.now().toString(); this.setItem( accountKey, JSON.stringify(parsedValue), correlationId ); continue; } else if ( TimeUtils.isCacheExpired( parsedValue.lastUpdatedAt, this.cacheConfig.cacheRetentionDays ) ) { // Cache expired remove account and associated tokens await this.removeAccountOldSchema( accountKey, parsedValue, credentialSchema, correlationId ); removeElementFromArray(accountKeysToCheck, accountKey); } } this.setAccountKeys(accountKeysToCheck, correlationId, accountSchema); } /** * Remove the given account and all associated tokens from the cache * @param accountKey * @param rawObject * @param credentialSchema * @param correlationId */ async removeAccountOldSchema( accountKey: string, rawObject: AccountEntity | EncryptedData, credentialSchema: number, correlationId: string ): Promise<void> { const decryptedData = isEncrypted(rawObject) ? ((await this.browserStorage.decryptData( accountKey, rawObject, correlationId )) as AccountEntity | null) : rawObject; const homeAccountId = decryptedData?.homeAccountId; if (homeAccountId) { const tokenKeys = this.getTokenKeys(credentialSchema); [...tokenKeys.idToken] .filter((key) => key.includes(homeAccountId)) .forEach((key) => { this.browserStorage.removeItem(key); removeElementFromArray(tokenKeys.idToken, key); }); [...tokenKeys.accessToken] .filter((key) => key.includes(homeAccountId)) .forEach((key) => { this.browserStorage.removeItem(key); removeElementFromArray(tokenKeys.accessToken, key); }); [...tokenKeys.refreshToken] .filter((key) => key.includes(homeAccountId)) .forEach((key) => { this.browserStorage.removeItem(key); removeElementFromArray(tokenKeys.refreshToken, key); }); this.setTokenKeys(tokenKeys, correlationId, credentialSchema); } this.performanceClient.incrementFields( { expiredAcntRemovedCount: 1 }, correlationId ); this.browserStorage.removeItem(accountKey); } /** * Gets key value pair mapping homeAccountId to KMSI value * @returns */ getKMSIValues(): KmsiMap { const kmsiMap: KmsiMap = {}; const tokenKeys = this.getTokenKeys().idToken; for (const key of tokenKeys) { const rawValue = this.browserStorage.getUserData(key); if (rawValue) { const idToken = JSON.parse(rawValue) as IdTokenEntity; const claims = AuthToken.extractTokenClaims( idToken.secret, base64Decode ); if (claims) { kmsiMap[idToken.homeAccountId] = AuthToken.isKmsi(claims); } } } return kmsiMap; } /** * Migrates id tokens from the old schema to the new schema, also migrates associated account object if it doesn't already exist in the new schema * @param credentialSchema * @param accountSchema * @param correlationId * @returns */ async migrateIdTokens( credentialSchema: number, accountSchema: number, correlationId: string ): Promise<void> { const credentialKeysToMigrate = getTokenKeys( this.clientId, this.browserStorage, credentialSchema ); if (credentialKeysToMigrate.idToken.length === 0) { return; } const currentCredentialKeys = getTokenKeys( this.clientId, this.browserStorage, CacheKeys.CREDENTIAL_SCHEMA_VERSION ); const currentAccountKeys = getAccountKeys(this.browserStorage); const previousAccountKeys = getAccountKeys( this.browserStorage, accountSchema ); for (const idTokenKey of [...credentialKeysToMigrate.idToken]) { this.performanceClient.incrementFields( { oldITCount: 1 }, correlationId ); const oldSchemaData = (await this.updateOldEntry( idTokenKey, correlationId )) as IdTokenEntity | null; if (!oldSchemaData) { removeElementFromArray( credentialKeysToMigrate.idToken, idTokenKey ); continue; } const currentAccountKey = currentAccountKeys.find((key) => key.includes(oldSchemaData.homeAccountId) ); const previousAccountKey = previousAccountKeys.find((key) => key.includes(oldSchemaData.homeAccountId) ); let account: AccountEntity | null = null; if (currentAccountKey) { account = this.getAccount(currentAccountKey, correlationId); } else if (previousAccountKey) { const rawValue = this.browserStorage.getItem(previousAccountKey); const parsedValue = this.validateAndParseJson( rawValue || "" ) as AccountEntity | EncryptedData | null; account = parsedValue && isEncrypted(parsedValue) ? ((await this.browserStorage.decryptData( previousAccountKey, parsedValue, correlationId )) as AccountEntity | null) : parsedValue; } if (!account) { // Don't migrate idToken if we don't have an account for it this.performanceClient.incrementFields( { skipITMigrateCount: 1 }, correlationId ); continue; } const claims = AuthToken.extractTokenClaims( oldSchemaData.secret, base64Decode ); const newIdTokenKey = this.generateCredentialKey(oldSchemaData); const currentIdToken = this.getIdTokenCredential( newIdTokenKey, correlationId ); const oldTokenHasSignInState = Object.keys(claims).includes("signin_state"); const currentTokenHasSignInState = currentIdToken && Object.keys( AuthToken.extractTokenClaims( currentIdToken.secret, base64Decode ) || {} ).includes("signin_state"); /** * Only migrate if: * 1. Token doesn't yet exist in current schema * 2. Old schema token has been updated more recently than the current one AND migrating it won't result in loss of KMSI state */ if ( !currentIdToken || (oldSchemaData.lastUpdatedAt > currentIdToken.lastUpdatedAt && (oldTokenHasSignInState || !currentTokenHasSignInState)) ) { const tenantProfiles = account.tenantProfiles || []; const tenantId = getTenantIdFromIdTokenClaims(claims) || account.realm; if ( tenantId && !tenantProfiles.find((tenantProfile) => { return tenantProfile.tenantId === tenantId; }) ) { const newTenantProfile = buildTenantProfile( account.homeAccountId, account.localAccountId, tenantId, claims ); tenantProfiles.push(newTenantProfile); } account.tenantProfiles = tenantProfiles; const newAccountKey = this.generateAccountKey( AccountEntityUtils.getAccountInfo(account) ); const kmsi = AuthToken.isKmsi(claims); await this.setUserData( newAccountKey, JSON.stringify(account), correlationId, account.lastUpdatedAt, kmsi ); if (!currentAccountKeys.includes(newAccountKey)) { currentAccountKeys.push(newAccountKey); } await this.setUserData( newIdTokenKey, JSON.stringify(oldSchemaData), correlationId, oldSchemaData.lastUpdatedAt, kmsi ); this.performanceClient.incrementFields( { migratedITCount: 1 }, correlationId ); currentCredentialKeys.idToken.push(newIdTokenKey); } } this.setTokenKeys( credentialKeysToMigrate, correlationId, credentialSchema ); this.setTokenKeys(currentCredentialKeys, correlationId); this.setAccountKeys(currentAccountKeys, correlationId); } /** * Migrates access tokens from old cache schema to current schema * @param credentialSchema * @param kmsiMap * @param correlationId * @returns */ async migrateAccessTokens( credentialSchema: number, kmsiMap: KmsiMap, correlationId: string ): Promise<void> { const credentialKeysToMigrate = getTokenKeys( this.clientId, this.browserStorage, credentialSchema ); if (credentialKeysToMigrate.accessToken.length === 0) { return; } const currentCredentialKeys = getTokenKeys( this.clientId, this.browserStorage, CacheKeys.CREDENTIAL_SCHEMA_VERSION ); for (const accessTokenKey of [...credentialKeysToMigrate.accessToken]) { this.performanceClient.incrementFields( { oldATCount: 1 }, correlationId ); const oldSchemaData = (await this.updateOldEntry( accessTokenKey, correlationId )) as AccessTokenEntity | null; if (!oldSchemaData) { removeElementFromArray( credentialKeysToMigrate.accessToken, accessTokenKey ); continue; } if (!(oldSchemaData.homeAccountId in kmsiMap)) { // Don't migrate tokens if we don't have an idToken for them this.performanceClient.incrementFields( { skipATMigrateCount: 1 }, correlationId ); continue; } const newKey = this.generateCredentialKey(oldSchemaData); const kmsi = kmsiMap[oldSchemaData.homeAccountId]; if (!currentCredentialKeys.accessToken.includes(newKey)) { await this.setUserData( newKey, JSON.stringify(oldSchemaData), correlationId, oldSchemaData.lastUpdatedAt, kmsi ); this.performanceClient.incrementFields( { migratedATCount: 1 }, correlationId ); currentCredentialKeys.accessToken.push(newKey); } else { const currentToken = this.getAccessTokenCredential( newKey, correlationId ); if ( !currentToken || oldSchemaData.lastUpdatedAt > currentToken.lastUpdatedAt ) { // If the token already exists, only overwrite it if the old token has a more recent lastUpdatedAt await this.setUserData( newKey, JSON.stringify(oldSchemaData), correlationId, oldSchemaData.lastUpdatedAt, kmsi ); this.performanceClient.incrementFields( { migratedATCount: 1 }, correlationId ); } } } this.setTokenKeys( credentialKeysToMigrate, correlationId, credentialSchema ); this.setTokenKeys(currentCredentialKeys, correlationId); } /** * Migrates refresh tokens from old cache schema to current schema * @param credentialSchema * @param kmsiMap * @param correlationId * @returns */ async migrateRefreshTokens( credentialSchema: number, kmsiMap: KmsiMap, correlationId: string ): Promise<void> { const credentialKeysToMigrate = getTokenKeys( this.clientId, this.browserStorage, credentialSchema ); if (credentialKeysToMigrate.refreshToken.length === 0) { return; } const currentCredentialKeys = getTokenKeys( this.clientId, this.browserStorage, CacheKeys.CREDENTIAL_SCHEMA_VERSION ); for (const refreshTokenKey of [ ...credentialKeysToMigrate.refreshToken, ]) { this.performanceClient.incrementFields( { oldRTCount: 1 }, correlationId ); const oldSchemaData = (await this.updateOldEntry( refreshTokenKey, correlationId )) as RefreshTokenEntity | null; if (!oldSchemaData) { removeElementFromArray( credentialKeysToMigrate.refreshToken, refreshTokenKey ); continue; } if (!(oldSchemaData.homeAccountId in kmsiMap)) { // Don't migrate tokens if we don't have an idToken for them this.performanceClient.incrementFields( { skipRTMigrateCount: 1 }, correlationId ); continue; } const newKey = this.generateCredentialKey(oldSchemaData); const kmsi = kmsiMap[oldSchemaData.homeAccountId]; if (!currentCredentialKeys.refreshToken.includes(newKey)) { await this.setUserData( newKey, JSON.stringify(oldSchemaData), correlationId, oldSchemaData.lastUpdatedAt, kmsi ); this.performanceClient.incrementFields( { migratedRTCount: 1 }, correlationId ); currentCredentialKeys.refreshToken.push(newKey); } else { const currentToken = this.getRefreshTokenCredential( newKey, correlationId ); if ( !currentToken || oldSchemaData.lastUpdatedAt > currentToken.lastUpdatedAt ) { // If the token already exists, only overwrite it if the old token has a more recent lastUpdatedAt await this.setUserData( newKey, JSON.stringify(oldSchemaData), correlationId, oldSchemaData.lastUpdatedAt, kmsi ); this.performanceClient.incrementFields( { migratedRTCount: 1 }, correlationId ); } } } this.setTokenKeys( credentialKeysToMigrate, correlationId, credentialSchema ); this.setTokenKeys(currentCredentialKeys, correlationId); } /** * Tracks upgrades and downgrades for telemetry and debugging purposes */ private trackVersionChanges(correlationId: string): void { const previousVersion = this.browserStorage.getItem( CacheKeys.VERSION_CACHE_KEY ); if (previousVersion) { this.logger.info( `MSAL.js was last initialized by version: '${previousVersion}'`, correlationId ); this.performanceClient.addFields( { previousLibraryVersion: previousVersion }, correlationId ); } if (previousVersion !== version) { this.setItem(CacheKeys.VERSION_CACHE_KEY, version, correlationId); } } /** * Parses passed value as JSON object, JSON.parse() will throw an error. * @param input */ protected validateAndParseJson(jsonValue: string): object | null { if (!jsonValue) { return null; } try { const parsedJson = JSON.parse(jsonValue); /** * There are edge cases in which JSON.parse will successfully parse a non-valid JSON object * (e.g. JSON.parse will parse an escaped string into an unescaped string), so adding a type check * of the parsed value is necessary in order to be certain that the string represents a valid JSON object. * */ return parsedJson && typeof parsedJson === "object" ? parsedJson : null; } catch (error) { return null; } } /** * Helper to setItem in browser storage, with cleanup in case of quota errors * @param key * @param value */ setItem(key: string, value: string, correlationId: string): void { const tokenKeysCount = new Array( CacheKeys.CREDENTIAL_SCHEMA_VERSION + 1 ).fill(0); // Array mapping schema version to number of token keys stored for that version const accessTokenKeys: Array<string> = []; // Flat map of all access token keys stored, ordered by schema version const maxRetries = 20; for (let i = 0; i <= maxRetries; i++) { // Attempt to store item in cache, if cache is full this call will throw and we'll attempt to clear space by removing access tokens from the cache one by one, starting with tokens stored by previous versions of MSAL.js try { this.browserStorage.setItem(key, value); if (i > 0) { // If any tokens were removed in order to store this item update the token keys array with the tokens removed for ( let schemaVersion = 0; schemaVersion <= CacheKeys.CREDENTIAL_SCHEMA_VERSION; schemaVersion++ ) { // Get the sum of all previous token counts to use as start index for this schema version const startIndex = tokenKeysCount .slice(0, schemaVersion) .reduce((sum, count) => sum + count, 0); if (startIndex >= i) { // Done removing tokens break; } const endIndex = i > startIndex + tokenKeysCount[schemaVersion] ? startIndex + tokenKeysCount[schemaVersion] : i; if ( i > startIndex && tokenKeysCount[schemaVersion] > 0 ) { this.removeAccessTokenKeys( accessTokenKeys.slice(startIndex, endIndex), correlationId, schemaVersion ); } } } break; // If setItem succeeds, exit the loop } catch (e) { const cacheError = createCacheError(e); if ( cacheError.errorCode === CacheErrorCodes.cacheQuotaExceeded && i < maxRetries ) { if (!accessTokenKeys.length) { // If we are currently trying to set the token keys, use the value we're trying to set for ( let i = 0; i <= CacheKeys.CREDENTIAL_SCHEMA_VERSION; i++ ) { if ( key === CacheKeys.getTokenKeysCacheKey(this.clientId, i) ) { const tokenKeys = ( JSON.parse(value) as TokenKeys ).accessToken; accessTokenKeys.push(...tokenKeys); tokenKeysCount[i] = tokenKeys.length; } else { const tokenKeys = this.getTokenKeys(i).accessToken; accessTokenKeys.push(...tokenKeys); tokenKeysCount[i] = tokenKeys.length; } } } if (accessTokenKeys.length <= i) { // Nothing to remove, rethrow the error throw cacheError; } // When cache quota is exceeded, start removing access tokens until we can successfully set the item this.removeAccessToken( accessTokenKeys[i], correlationId, false // Don't save token keys yet, do it at the end ); } else { // If the error is not a quota exceeded error, rethrow it throw cacheError; } } } } /** * Helper to setUserData in browser storage, with cleanup in case of quota errors * @param key * @param value * @param correlationId */ async setUserData( key: string, value: string, correlationId: string, timestamp: string, kmsi: boolean ): Promise<void> { const tokenKeysCount = new Array( CacheKeys.CREDENTIAL_SCHEMA_VERSION + 1 ).fill(0); // Array mapping schema version to number of token keys stored for that version const accessTokenKeys: Array<string> = []; // Flat map of all access token keys stored, ordered by schema version const maxRetries = 20; for (let i = 0; i <= maxRetries; i++) { try { // Attempt to store item in cache, if cache is full this call will throw and we'll attempt to clear space by removing access tokens from the cache one by one, starting with tokens stored by previous versions of MSAL.js await invokeAsync( this.browserStorage.setUserData.bind(this.browserStorage), PerformanceEvents.SetUserData, this.logger, this.performanceClient, correlationId )(key, value, correlationId, timestamp, kmsi); if (i > 0) { // If any tokens were removed in order to store this item update the token keys array with the tokens removed for ( let schemaVersion = 0; schemaVersion <= CacheKeys.CREDENTIAL_SCHEMA_VERSION; schemaVersion++ ) { // Get the sum of all previous token counts to use as start index for this schema version const startIndex = tokenKeysCount .slice(0, schemaVersion) .reduce((sum, count) => sum + count, 0); if (startIndex >= i) { // Done removing tokens break; } const endIndex = i > startIndex + tokenKeysCount[schemaVersion] ? startIndex + tokenKeysCount[schemaVersion] : i; if ( i > startIndex && tokenKeysCount[schemaVersion] > 0 ) { this.removeAccessTokenKeys( accessTokenKeys.slice(startIndex, endIndex), correlationId, schemaVersion ); } } } break; // If setItem succeeds, exit the loop } catch (e) { const cacheError = createCacheError(e); if ( cacheError.errorCode === CacheErrorCodes.cacheQuotaExceeded && i < maxRetries ) { if (!accessTokenKeys.length) { // If we are currently trying to set the token keys, use the value we're trying to set for ( let i = 0; i <= CacheKeys.CREDENTIAL_SCHEMA_VERSION; i++ ) { const tokenKeys = this.getTokenKeys(i).accessToken; accessTokenKeys.push(...tokenKeys); tokenKeysCount[i] = tokenKeys.length; } } if (accessTokenKeys.length <= i) { // Nothing left to remove, rethrow the error throw cacheError; } // When cache quota is exceeded, start removing access tokens until we can successfully set the item this.removeAccessToken( accessTokenKeys[i], correlationId, false // Don't save token keys yet, do it at the end ); } else { // If the error is not a quota exceeded error, rethrow it throw cacheError; } } } } /** * Reads account from cache, deserializes it into an account entity and returns it. * If account is not found from the key, returns null and removes key from map. * @param accountKey * @returns */ getAccount( accountKey: string, correlationId: string ): AccountEntity | null { this.logger.trace( "BrowserCacheManager.getAccount called", correlationId ); const serializedAccount = this.browserStorage.getUserData(accountKey); if (!serializedAccount) { this.removeAccountKeyFromMap(accountKey, correlationId); return null; } const parsedAccount = this.validateAndParseJson(serializedAccount); if ( !parsedAccount || !AccountEntityUtils.isAccountEntity(parsedAccount) ) { return null; } const account = CacheManager.toObject<AccountEntity>( {} as AccountEntity, parsedAccount ); this.performanceClient.addFields( { accountCachedBy: apiIdToName(account.cachedByApiId), }, correlationId ); return account; } /** * set account entity in the platform cache * @param account */ async setAccount( account: AccountEntity, correlationId: string, kmsi: boolean, apiId: number ): Promise<void> { this.logger.trace( "BrowserCacheManager.setAccount called", correlationId ); const key = this.generateAccountKey( AccountEntityUtils.getAccountInfo(account) ); const timestamp = Date.now().toString(); account.lastUpdatedAt = timestamp; account.cachedByApiId = apiId; await this.setUserData( key, JSON.stringify(account), correlationId, timestamp, kmsi ); this.addAccountKeyToMap(key, correlationId); this.performanceClient.addFields({ kmsi: kmsi }, correlationId); } setAccountKeys( accountKeys: Array<string>, correlationId: string, schemaVersion: number = CacheKeys.ACCOUNT_SCHEMA_VERSION ): void { if (accountKeys.length === 0) { this.removeItem(CacheKeys.getAccountKeysCacheKey(schemaVersion)); } else { this.setItem( CacheKeys.getAccountKeysCacheKey(schemaVersion), JSON.stringify(accountKeys), correlationId ); } } /** * Returns the array of account keys currently cached * @returns */ getAccountKeys(): Array<string> { return getAccountKeys(this.browserStorage); } /** * Add a new account to the key map * @param key */ addAccountKeyToMap(key: string, correlationId: string): boolean { this.logger.trace( "BrowserCacheManager.addAccountKeyToMap called", correlationId ); this.logger.tracePii( `BrowserCacheManager.addAccountKeyToMap called with key: '${key}'`, correlationId ); const accountKeys = this.getAccountKeys(); if (accountKeys.indexOf(key) === -1) { // Only add key if it does not already exist in the map accountKeys.push(key); this.setItem( CacheKeys.getAccountKeysCacheKey(), JSON.stringify(accountKeys), correlationId ); this.logger.verbose( "BrowserCacheManager.addAccountKeyToMap account key added", correlationId ); return true; } else { this.logger.verbose( "BrowserCacheManager.addAccountKeyToMap account key already exists in map", correlationId ); return false; } } /** * Remove an account from the key map * @param key */ removeAccountKeyFromMap(key: string, correlationId: string): void { this.logger.trace( "BrowserCacheManager.removeAccountKeyFromMap called", correlationId ); this.logger.tracePii( `BrowserCacheManager.removeAccountKeyFromMap called with key: '${key}'`, correlationId ); const accountKeys = this.getAccountKeys(); const removalIndex = accountKeys.indexOf(key); if (removalIndex > -1) { accountKeys.splice(removalIndex, 1); this.setAccountKeys(accountKeys, correlationId); } else { this.logger.trace( "BrowserCacheManager.removeAccountKeyFromMap key not found in existing map", correlationId ); } } /** * Extends inherited removeAccount function to include removal of the account key from the map * @param key */ removeAccount(account: AccountInfo, correlationId: string): void { const activeAccount = this.getActiveAccount(correlationId); if ( activeAccount?.homeAccountId === account.homeAccountId && activeAccount?.environment === account.environment ) { this.setActiveAccount(null, correlationId); } super.removeAccount(account, correlationId); this.removeAccountKeyFromMap( this.generateAccountKey(account), correlationId ); // Remove all other associated cache items this.browserStorage.getKeys().forEach((key) => { if ( key.includes(account.homeAccountId) && key.includes(account.environment) ) { this.browserStorage.removeItem(key); } }); } /** * Removes given idToken from the cache and from the key map * @param key */ removeIdToken(key: string, correlationId: string): void { super.removeIdToken(key, correlationId); const tokenKeys = this.getTokenKeys(); const idRemoval = tokenKeys.idToken.indexOf(key); if (idRemoval > -1) { this.logger.info( "idToken removed from tokenKeys map", correlationId ); tokenKeys.idToken.splice(idRemoval, 1); this.setTokenKeys(tokenKeys, correlationId); } } /** * Removes given accessToken from the cache and from the key map * @param key */ removeAccessToken( key: string, correlationId: string, updateTokenKeys: boolean = true ): void { super.removeAccessToken(key, correlationId); updateTokenKeys && this.removeAccessTokenKeys([key], correlationId); } /** * Remove access token key from the key map * @param key * @param correlationId * @param tokenKeys */ removeAccessTokenKeys( keys: Array<string>, correlationId: string, schemaVersion: number = CacheKeys.CREDENTIAL_SCHEMA_VERSION ): void { this.logger.trace("removeAccessTokenKey called", correlationId); const tokenKeys = this.getTokenKeys(schemaVersion); let keysRemoved = 0; keys.forEach((key) => { const accessRemoval = tokenKeys.accessToken.indexOf(key); if (accessRemoval > -1) { tokenKeys.accessToken.splice(accessRemoval, 1); keysRemoved++; } }); if (keysRemoved > 0) { this.logger.info( `removed '${keysRemoved}' accessToken keys from tokenKeys map`, correlationId ); this.setTokenKeys(tokenKeys, correlationId, schemaVersion); return; } } /** * Removes given refreshToken from the cache and from the key map * @param key */ removeRefreshToken(key: string, correlationId: string): void { super.removeRefreshToken(key, correlationId); const tokenKeys = this.getTokenKeys(); const refreshRemoval = tokenKeys.refreshToken.indexOf(key); if (refreshRemoval > -1) { this.logger.info( "refreshToken removed from tokenKeys map", correlationId ); tokenKeys.refreshToken.splice(refreshRemoval, 1); this.setTokenKeys(tokenKeys, correlationId); } } /** * Gets the keys for the cached tokens associated with this clientId * @returns */ getTokenKeys( schemaVersion: number = CacheKeys.CREDENTIAL_SCHEMA_VERSION ): TokenKeys { return getTokenKeys(this.clientId, this.browserStorage, schemaVersion); } /** * Sets the token keys in the cache * @param tokenKeys * @param correlationId * @returns */ setTokenKeys( tokenKeys: TokenKeys, correlationId: string, schemaVersion: number = CacheKeys.CREDENTIAL_SCHEMA_VERSION ): void { if ( tokenKeys.idToken.length === 0 && tokenKeys.accessToken.length === 0 && tokenKeys.refreshToken.length === 0 ) { // If no keys left, remove the map this.removeItem( CacheKeys.getTokenKeysCacheKey(this.clientId, schemaVersion) ); return; } else { this.setItem( CacheKeys.getTokenKeysCacheKey(this.clientId, schemaVersion), JSON.stringify(tokenKeys), correlationId ); } } /** * generates idToken entity from a string * @param idTokenKey */ getIdTokenCredential( idTokenKey: string, correlationId: string ): IdTokenEntity | null { const value = this.browserStorage.getUserData(idTokenKey); if (!value) { this.logger.trace( "BrowserCacheManager.getIdTokenCredential: called, no cache hit", correlationId ); this.removeIdToken(idTokenKey, correlationId); return null; } const parsedIdToken = this.validateAndParseJson(value); if (!parsedIdToken || !CacheHelpers.isIdTokenEntity(parsedIdToken)) { this.logger.trace( "BrowserCacheManager.getIdTokenCredential: called, no cache hit", correlationId ); return null; } this.logger.trace( "BrowserCacheManager.getIdTokenCredential: cache hit", correlationId ); return parsedIdToken as IdTokenEntity; } /** * set IdToken credential to the platform cache * @param idToken */ async setIdTokenCredential( idToken: IdTokenEntity, correlationId: string, kmsi: boolean ): Promise<void> { this.logger.trace( "BrowserCacheManager.setIdTokenCredential called", correlationId ); const idTokenKey = this.generateCredentialKey(idToken); const timest