diff --git a/change/@azure-msal-browser-bc000619-d2ad-4f08-986b-01703073260c.json b/change/@azure-msal-browser-bc000619-d2ad-4f08-986b-01703073260c.json new file mode 100644 index 0000000000..5d5a2096b0 --- /dev/null +++ b/change/@azure-msal-browser-bc000619-d2ad-4f08-986b-01703073260c.json @@ -0,0 +1,7 @@ +{ + "type": "prerelease", + "comment": "KMSI Support #8123", + "packageName": "@azure/msal-browser", + "email": "thomas.norling@microsoft.com", + "dependentChangeType": "patch" +} diff --git a/change/@azure-msal-common-75d9438e-0ded-43bb-8ced-0974f70816b4.json b/change/@azure-msal-common-75d9438e-0ded-43bb-8ced-0974f70816b4.json new file mode 100644 index 0000000000..b86c91dfd1 --- /dev/null +++ b/change/@azure-msal-common-75d9438e-0ded-43bb-8ced-0974f70816b4.json @@ -0,0 +1,7 @@ +{ + "type": "prerelease", + "comment": "KMSI Support #8123", + "packageName": "@azure/msal-common", + "email": "thomas.norling@microsoft.com", + "dependentChangeType": "patch" +} diff --git a/lib/msal-browser/apiReview/msal-browser.api.md b/lib/msal-browser/apiReview/msal-browser.api.md index 954bb26bf5..de67b9c7c7 100644 --- a/lib/msal-browser/apiReview/msal-browser.api.md +++ b/lib/msal-browser/apiReview/msal-browser.api.md @@ -960,7 +960,7 @@ export interface IWindowStorage { // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen setItem(key: string, value: T): void; - setUserData(key: string, value: T, correlationId: string, timestamp: string): Promise; + setUserData(key: string, value: T, correlationId: string, timestamp: string, kmsi: boolean): Promise; } // Warning: (ae-missing-release-tag) "JsonWebTokenTypes" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) @@ -1016,7 +1016,7 @@ export class LocalStorage implements IWindowStorage { // (undocumented) setItem(key: string, value: string): void; // (undocumented) - setUserData(key: string, value: string, correlationId: string, timestamp: string): Promise; + setUserData(key: string, value: string, correlationId: string, timestamp: string, kmsi: boolean): Promise; } // Warning: (ae-missing-release-tag) "LocalStorageUpdated" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) @@ -1592,9 +1592,9 @@ export type WrapperSKU = (typeof WrapperSKU)[keyof typeof WrapperSKU]; // src/app/PublicClientNext.ts:87:79 - (tsdoc-malformed-inline-tag) Expecting a TSDoc tag starting with "{@" // src/app/PublicClientNext.ts:90:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen // src/app/PublicClientNext.ts:91:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// src/cache/LocalStorage.ts:358:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// src/cache/LocalStorage.ts:416:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// src/cache/LocalStorage.ts:447:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// src/cache/LocalStorage.ts:366:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// src/cache/LocalStorage.ts:429:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// src/cache/LocalStorage.ts:460:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen // src/config/Configuration.ts:211:5 - (ae-forgotten-export) The symbol "InternalAuthOptions" needs to be exported by the entry point index.d.ts // src/event/EventHandler.ts:114:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen // src/event/EventHandler.ts:141:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen diff --git a/lib/msal-browser/docs/caching.md b/lib/msal-browser/docs/caching.md index 4dad6c6e62..707a113141 100644 --- a/lib/msal-browser/docs/caching.md +++ b/lib/msal-browser/docs/caching.md @@ -23,11 +23,11 @@ const pca = new PublicClientApplication({ By default, MSAL stores the various authentication artifacts it obtains from the IdP in browser storage using the [Web Storage API](https://developer.mozilla.org/docs/Web/API/Web_Storage_API) supported by all modern browsers. Accordingly, MSAL offers two methods of persistent storage: `sessionStorage` (default) and `localStorage`. In addition, MSAL provides `memoryStorage` option which allows you to opt-out of storing the cache in browser storage. -| Cache Location | Cleared on | Shared between windows/tabs | Redirect flow supported | -|------------------|-------------------------|-----------------------------|-------------------------| -| `sessionStorage` | window/tab close | No | Yes | -| `localStorage` | browser close | Yes | Yes | -| `memoryStorage` | page refresh/navigation | No | No | +| Cache Location | Cleared on | Shared between windows/tabs | Redirect flow supported | +|------------------|--------------------------------------------------------|-----------------------------|-------------------------| +| `sessionStorage` | window/tab close | No | Yes | +| `localStorage` | browser close (unless user selected keep me signed in) | Yes | Yes | +| `memoryStorage` | page refresh/navigation | No | No | > :bulb: While the authentication state may be lost in session and memory storage due to window/tab close or page refresh/navigation, respectively, users will still have an active session with the IdP as long as the session cookie is not expired and might be able to re-authenticate without any prompts. @@ -35,9 +35,9 @@ The choice between different storage locations reflects a trade-off between bett ### LocalStorage Notes -Starting in v4, if you are using the `localStorage` cache location, auth artifacts will be encrypted with [AES-GCM](https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/encrypt#aes-gcm) using [HKDF](https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/deriveKey#hkdf) to derive the key. The base key is stored in a session cookie titled `msal.cache.encryption`. +Starting in v4, if you are using the `localStorage` cache location, auth artifacts will be encrypted unless the user selects "Keep me signed in" during sign in. The encryption algorithm used is [AES-GCM](https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/encrypt#aes-gcm) using [HKDF](https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/deriveKey#hkdf) to derive the key. The base key is stored in a session cookie titled `msal.cache.encryption`. -This cookie will be automatically removed when the browser instance (not tab) is closed, thus making it impossible to decrypt any auth artifacts after the session has ended. These expired auth artifacts will be removed the next time MSAL is initialized and the user may need to reauthenticate. The `localStorage` location still provides cross-tab cache persistence but will no longer persist across browser sessions. +This cookie will be automatically removed when the browser instance (not tab) is closed, thus making it impossible to decrypt any auth artifacts after the session has ended. These expired auth artifacts will be removed the next time MSAL is initialized and the user may need to reauthenticate. The `localStorage` location still provides cross-tab cache persistence for all users but will only persist across browser sessions for users who selected "Keep me signed in" (KMSI). > [!Important] The purpose of this encryption is to reduce the persistence of auth artifacts, **not** to provide additional security. If a bad actor gains access to browser storage they would also have access to the key or have the ability to request tokens on your behalf without the need for cache at all. It is your responsibility to ensure your application is not vulnerable to XSS attacks [see below](#security) diff --git a/lib/msal-browser/src/cache/BrowserCacheManager.ts b/lib/msal-browser/src/cache/BrowserCacheManager.ts index 9d10366673..463f22ef77 100644 --- a/lib/msal-browser/src/cache/BrowserCacheManager.ts +++ b/lib/msal-browser/src/cache/BrowserCacheManager.ts @@ -35,6 +35,9 @@ import { TimeUtils, TokenKeys, CredentialEntity, + AuthToken, + getTenantIdFromIdTokenClaims, + buildTenantProfile, } from "@azure/msal-common/browser"; import { CacheOptions } from "../config/Configuration.js"; import { @@ -69,6 +72,8 @@ 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. */ @@ -141,200 +146,637 @@ export class BrowserCacheManager extends CacheManager { * Migrates any existing cache data from previous versions of MSAL.js into the current cache structure. */ async migrateExistingCache(correlationId: string): Promise { - const accountKeys0 = getAccountKeys(this.browserStorage, 0); - const tokenKeys0 = getTokenKeys(this.clientId, this.browserStorage, 0); + let accountKeys = getAccountKeys(this.browserStorage); + let tokenKeys = getTokenKeys(this.clientId, this.browserStorage); this.performanceClient.addFields( { - oldAccountCount: accountKeys0.length, - oldAccessCount: tokenKeys0.accessToken.length, - oldIdCount: tokenKeys0.idToken.length, - oldRefreshCount: tokenKeys0.refreshToken.length, + preMigrateAcntCount: accountKeys.length, + preMigrateATCount: tokenKeys.accessToken.length, + preMigrateITCount: tokenKeys.idToken.length, + preMigrateRTCount: tokenKeys.refreshToken.length, }, correlationId ); - const accountKeys1 = getAccountKeys(this.browserStorage, 1); - const tokenKeys1 = getTokenKeys(this.clientId, this.browserStorage, 1); + 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( { - currAccountCount: accountKeys1.length, - currAccessCount: tokenKeys1.accessToken.length, - currIdCount: tokenKeys1.idToken.length, - currRefreshCount: tokenKeys1.refreshToken.length, + postMigrateAcntCount: accountKeys.length, + postMigrateATCount: tokenKeys.accessToken.length, + postMigrateITCount: tokenKeys.idToken.length, + postMigrateRTCount: tokenKeys.refreshToken.length, }, correlationId ); + } - await Promise.all([ - this.updateV0ToCurrent( - CacheKeys.ACCOUNT_SCHEMA_VERSION, - accountKeys0, - accountKeys1, - correlationId - ), - this.updateV0ToCurrent( - CacheKeys.CREDENTIAL_SCHEMA_VERSION, - tokenKeys0.idToken, - tokenKeys1.idToken, - correlationId - ), - this.updateV0ToCurrent( - CacheKeys.CREDENTIAL_SCHEMA_VERSION, - tokenKeys0.accessToken, - tokenKeys1.accessToken, - correlationId - ), - this.updateV0ToCurrent( - CacheKeys.CREDENTIAL_SCHEMA_VERSION, - tokenKeys0.refreshToken, - tokenKeys1.refreshToken, + /** + * 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 { + 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; + } - if (accountKeys0.length > 0) { - this.browserStorage.setItem( - CacheKeys.getAccountKeysCacheKey(0), - JSON.stringify(accountKeys0) + const decryptedData = isEncrypted(parsedValue) + ? await this.browserStorage.decryptData( + key, + parsedValue, + correlationId + ) + : parsedValue; + if (!decryptedData || !CacheHelpers.isCredentialEntity(decryptedData)) { + this.performanceClient.incrementFields( + { invalidCacheCount: 1 }, + correlationId ); - } else { - this.browserStorage.removeItem(CacheKeys.getAccountKeysCacheKey(0)); + return null; } - if (accountKeys1.length > 0) { - this.browserStorage.setItem( - CacheKeys.getAccountKeysCacheKey(1), - JSON.stringify(accountKeys1) + 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 ); - } else { - this.browserStorage.removeItem(CacheKeys.getAccountKeysCacheKey(1)); + return null; } - this.setTokenKeys(tokenKeys0, correlationId, 0); - this.setTokenKeys(tokenKeys1, correlationId, 1); + return decryptedData; } - async updateV0ToCurrent( - currentSchema: number, - v0Keys: Array, - v1Keys: Array, + /** + * 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 { - const upgradePromises: Array> = []; - - for (const v0Key of [...v0Keys]) { - const rawV0Value = this.browserStorage.getItem(v0Key); - const parsedV0Value = this.validateAndParseJson( - rawV0Value || "" - ) as CredentialEntity | AccountEntity | EncryptedData | null; + ): Promise { + const accountKeysToCheck = getAccountKeys( + this.browserStorage, + accountSchema + ); + if (accountKeysToCheck.length === 0) { + return; + } - if (!parsedV0Value) { - removeElementFromArray(v0Keys, v0Key); + 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 (!parsedV0Value.lastUpdatedAt) { - // Add lastUpdatedAt to the existing v0 entry if it doesnt exist so we know when it's safe to remove it - parsedV0Value.lastUpdatedAt = Date.now().toString(); + 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( - v0Key, - JSON.stringify(parsedV0Value), + 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 { + 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 + ); - const decryptedData = isEncrypted(parsedV0Value) - ? await this.browserStorage.decryptData( - v0Key, - parsedV0Value, - correlationId - ) - : parsedV0Value; - let expirationTime; - if (decryptedData) { - if (CacheHelpers.isAccessTokenEntity(decryptedData)) { - expirationTime = decryptedData.expiresOn; - } else if (CacheHelpers.isRefreshTokenEntity(decryptedData)) { - expirationTime = decryptedData.expiresOn; + 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); } } - if ( - !decryptedData || - TimeUtils.isCacheExpired( - parsedV0Value.lastUpdatedAt, - this.cacheConfig.cacheRetentionDays - ) || - (expirationTime && - TimeUtils.isTokenExpired( - expirationTime, - Constants.DEFAULT_TOKEN_RENEWAL_OFFSET_SEC - )) - ) { - this.browserStorage.removeItem(v0Key); - removeElementFromArray(v0Keys, v0Key); + } + 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 { + 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( - { expiredCacheRemovedCount: 1 }, + { 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 ( - this.cacheConfig.cacheLocation !== - BrowserCacheLocation.LocalStorage || - isEncrypted(parsedV0Value) + !currentIdToken || + (oldSchemaData.lastUpdatedAt > currentIdToken.lastUpdatedAt && + (oldTokenHasSignInState || !currentTokenHasSignInState)) ) { - const v1Key = `${CacheKeys.PREFIX}.${currentSchema}${CacheKeys.CACHE_KEY_SEPARATOR}${v0Key}`; - const rawV1Entry = this.browserStorage.getItem(v1Key); - if (!rawV1Entry) { - upgradePromises.push( - this.setUserData( - v1Key, - JSON.stringify(decryptedData), - correlationId, - parsedV0Value.lastUpdatedAt - ).then(() => { - v1Keys.push(v1Key); - this.performanceClient.incrementFields( - { upgradedCacheCount: 1 }, - correlationId - ); - }) + 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 { + 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 { + 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 ); - continue; - } else { - const parsedV1Entry = this.validateAndParseJson( - rawV1Entry - ) as CredentialEntity | AccountEntity | EncryptedData; - // If the entry already exists but is older than the v0 entry, replace it - if ( - Number(parsedV0Value.lastUpdatedAt) > - Number(parsedV1Entry.lastUpdatedAt) - ) { - upgradePromises.push( - this.setUserData( - v1Key, - JSON.stringify(decryptedData), - correlationId, - parsedV0Value.lastUpdatedAt - ).then(() => { - this.performanceClient.incrementFields( - { updatedCacheFromV0Count: 1 }, - correlationId - ); - }) - ); - continue; - } } } - /* - * Note: If we reach here for unencrypted localStorage data, we continue without migrating - * as we can't migrate unencrypted localStorage data right now since we can't guarantee KMSI=no - */ } - return Promise.all(upgradePromises); + this.setTokenKeys( + credentialKeysToMigrate, + correlationId, + credentialSchema + ); + this.setTokenKeys(currentCredentialKeys, correlationId); } /** @@ -390,30 +832,45 @@ export class BrowserCacheManager extends CacheManager { * @param value */ setItem(key: string, value: string, correlationId: string): void { - let tokenKeysV0Count = 0; - let accessTokenKeys: Array = []; + 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 = []; // 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) { - // Finally update the token keys array with the tokens removed - if (i <= tokenKeysV0Count) { - this.removeAccessTokenKeys( - accessTokenKeys.slice(0, i), - correlationId, - 0 - ); - } else { - this.removeAccessTokenKeys( - accessTokenKeys.slice(0, tokenKeysV0Count), - correlationId, - 0 - ); - this.removeAccessTokenKeys( - accessTokenKeys.slice(tokenKeysV0Count, i), - correlationId - ); + // 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 @@ -426,18 +883,27 @@ export class BrowserCacheManager extends CacheManager { ) { if (!accessTokenKeys.length) { // If we are currently trying to set the token keys, use the value we're trying to set - const tokenKeys0 = - key === - CacheKeys.getTokenKeysCacheKey(this.clientId, 0) - ? (JSON.parse(value) as TokenKeys).accessToken - : this.getTokenKeys(0).accessToken; - const tokenKeys1 = - key === - CacheKeys.getTokenKeysCacheKey(this.clientId) - ? (JSON.parse(value) as TokenKeys).accessToken - : this.getTokenKeys().accessToken; - accessTokenKeys = [...tokenKeys0, ...tokenKeys1]; - tokenKeysV0Count = tokenKeys0.length; + 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 @@ -467,38 +933,54 @@ export class BrowserCacheManager extends CacheManager { key: string, value: string, correlationId: string, - timestamp: string + timestamp: string, + kmsi: boolean ): Promise { - let tokenKeysV0Count = 0; - let accessTokenKeys: Array = []; + 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 = []; // 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); + )(key, value, correlationId, timestamp, kmsi); if (i > 0) { - // Finally update the token keys array with the tokens removed - if (i <= tokenKeysV0Count) { - this.removeAccessTokenKeys( - accessTokenKeys.slice(0, i), - correlationId, - 0 - ); - } else { - this.removeAccessTokenKeys( - accessTokenKeys.slice(0, tokenKeysV0Count), - correlationId, - 0 - ); - this.removeAccessTokenKeys( - accessTokenKeys.slice(tokenKeysV0Count, i), - correlationId - ); + // 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 @@ -510,10 +992,16 @@ export class BrowserCacheManager extends CacheManager { i < maxRetries ) { if (!accessTokenKeys.length) { - const tokenKeys0 = this.getTokenKeys(0).accessToken; - const tokenKeys1 = this.getTokenKeys().accessToken; - accessTokenKeys = [...tokenKeys0, ...tokenKeys1]; - tokenKeysV0Count = tokenKeys0.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 @@ -573,7 +1061,8 @@ export class BrowserCacheManager extends CacheManager { */ async setAccount( account: AccountEntity, - correlationId: string + correlationId: string, + kmsi: boolean ): Promise { this.logger.trace( "BrowserCacheManager.setAccount called", @@ -588,9 +1077,27 @@ export class BrowserCacheManager extends CacheManager { key, JSON.stringify(account), correlationId, - timestamp + timestamp, + kmsi ); this.addAccountKeyToMap(key, correlationId); + this.performanceClient.addFields({ kmsi: kmsi }, correlationId); + } + + setAccountKeys( + accountKeys: Array, + 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 + ); + } } /** @@ -654,21 +1161,7 @@ export class BrowserCacheManager extends CacheManager { const removalIndex = accountKeys.indexOf(key); if (removalIndex > -1) { accountKeys.splice(removalIndex, 1); - if (accountKeys.length === 0) { - // If no keys left, remove the map - this.removeItem(CacheKeys.getAccountKeysCacheKey()); - return; - } else { - this.setItem( - CacheKeys.getAccountKeysCacheKey(), - JSON.stringify(accountKeys), - correlationId - ); - } - this.logger.trace( - "BrowserCacheManager.removeAccountKeyFromMap account key removed", - correlationId - ); + this.setAccountKeys(accountKeys, correlationId); } else { this.logger.trace( "BrowserCacheManager.removeAccountKeyFromMap key not found in existing map", @@ -868,7 +1361,8 @@ export class BrowserCacheManager extends CacheManager { */ async setIdTokenCredential( idToken: IdTokenEntity, - correlationId: string + correlationId: string, + kmsi: boolean ): Promise { this.logger.trace( "BrowserCacheManager.setIdTokenCredential called", @@ -882,7 +1376,8 @@ export class BrowserCacheManager extends CacheManager { idTokenKey, JSON.stringify(idToken), correlationId, - timestamp + timestamp, + kmsi ); const tokenKeys = this.getTokenKeys(); @@ -938,7 +1433,8 @@ export class BrowserCacheManager extends CacheManager { */ async setAccessTokenCredential( accessToken: AccessTokenEntity, - correlationId: string + correlationId: string, + kmsi: boolean ): Promise { this.logger.trace( "BrowserCacheManager.setAccessTokenCredential called", @@ -952,7 +1448,8 @@ export class BrowserCacheManager extends CacheManager { accessTokenKey, JSON.stringify(accessToken), correlationId, - timestamp + timestamp, + kmsi ); const tokenKeys = this.getTokenKeys(); @@ -1010,7 +1507,8 @@ export class BrowserCacheManager extends CacheManager { */ async setRefreshTokenCredential( refreshToken: RefreshTokenEntity, - correlationId: string + correlationId: string, + kmsi: boolean ): Promise { this.logger.trace( "BrowserCacheManager.setRefreshTokenCredential called", @@ -1024,7 +1522,8 @@ export class BrowserCacheManager extends CacheManager { refreshTokenKey, JSON.stringify(refreshToken), correlationId, - timestamp + timestamp, + kmsi ); const tokenKeys = this.getTokenKeys(); @@ -1795,7 +2294,13 @@ export class BrowserCacheManager extends CacheManager { idToken: idTokenEntity, accessToken: accessTokenEntity, }; - return this.saveCacheRecord(cacheRecord, result.correlationId); + return this.saveCacheRecord( + cacheRecord, + result.correlationId, + AuthToken.isKmsi( + AuthToken.extractTokenClaims(result.idToken, base64Decode) + ) + ); } /** @@ -1807,12 +2312,14 @@ export class BrowserCacheManager extends CacheManager { async saveCacheRecord( cacheRecord: CacheRecord, correlationId: string, + kmsi: boolean, storeInCache?: StoreInCache ): Promise { try { await super.saveCacheRecord( cacheRecord, correlationId, + kmsi, storeInCache ); } catch (e) { diff --git a/lib/msal-browser/src/cache/CacheKeys.ts b/lib/msal-browser/src/cache/CacheKeys.ts index 46499ec78d..b7b56f05f6 100644 --- a/lib/msal-browser/src/cache/CacheKeys.ts +++ b/lib/msal-browser/src/cache/CacheKeys.ts @@ -5,9 +5,9 @@ export const PREFIX = "msal"; const BROWSER_PREFIX = "browser"; -export const CACHE_KEY_SEPARATOR = "-"; -export const CREDENTIAL_SCHEMA_VERSION = 1; -export const ACCOUNT_SCHEMA_VERSION = 1; +export const CACHE_KEY_SEPARATOR = "|"; +export const CREDENTIAL_SCHEMA_VERSION = 2; +export const ACCOUNT_SCHEMA_VERSION = 2; export const LOG_LEVEL_CACHE_KEY = `${PREFIX}.${BROWSER_PREFIX}.log.level`; export const LOG_PII_CACHE_KEY = `${PREFIX}.${BROWSER_PREFIX}.log.pii`; diff --git a/lib/msal-browser/src/cache/IWindowStorage.ts b/lib/msal-browser/src/cache/IWindowStorage.ts index 5c6dd2d03f..0b2e949403 100644 --- a/lib/msal-browser/src/cache/IWindowStorage.ts +++ b/lib/msal-browser/src/cache/IWindowStorage.ts @@ -35,7 +35,8 @@ export interface IWindowStorage { key: string, value: T, correlationId: string, - timestamp: string + timestamp: string, + kmsi: boolean ): Promise; /** diff --git a/lib/msal-browser/src/cache/LocalStorage.ts b/lib/msal-browser/src/cache/LocalStorage.ts index 8d1e426d8d..19f406a753 100644 --- a/lib/msal-browser/src/cache/LocalStorage.ts +++ b/lib/msal-browser/src/cache/LocalStorage.ts @@ -208,7 +208,10 @@ export class LocalStorage implements IWindowStorage { } try { - return JSON.parse(decryptedData); + return { + ...JSON.parse(decryptedData), + lastUpdatedAt: data.lastUpdatedAt, + }; } catch (e) { this.performanceClient.incrementFields( { encryptedCacheCorruptionCount: 1 }, @@ -226,7 +229,8 @@ export class LocalStorage implements IWindowStorage { key: string, value: string, correlationId: string, - timestamp: string + timestamp: string, + kmsi: boolean ): Promise { if (!this.initialized || !this.encryptionCookie) { throw createBrowserAuthError( @@ -234,22 +238,26 @@ export class LocalStorage implements IWindowStorage { ); } - const { data, nonce } = await invokeAsync( - encrypt, - BrowserPerformanceEvents.Encrypt, - this.logger, - this.performanceClient, - correlationId - )(this.encryptionCookie.key, value, this.getContext(key)); - const encryptedData: EncryptedData = { - id: this.encryptionCookie.id, - nonce: nonce, - data: data, - lastUpdatedAt: timestamp, - }; + if (kmsi) { + this.setItem(key, value); + } else { + const { data, nonce } = await invokeAsync( + encrypt, + BrowserPerformanceEvents.Encrypt, + this.logger, + this.performanceClient, + correlationId + )(this.encryptionCookie.key, value, this.getContext(key)); + const encryptedData: EncryptedData = { + id: this.encryptionCookie.id, + nonce: nonce, + data: data, + lastUpdatedAt: timestamp, + }; + this.setItem(key, JSON.stringify(encryptedData)); + } this.memoryStorage.setItem(key, value); - this.setItem(key, JSON.stringify(encryptedData)); // Notify other frames to update their in-memory cache this.broadcast.postMessage({ @@ -385,7 +393,7 @@ export class LocalStorage implements IWindowStorage { { unencryptedCacheCount: 1 }, correlationId ); - return encObj; + return rawCache; } if (encObj.id !== this.encryptionCookie.id) { @@ -397,6 +405,11 @@ export class LocalStorage implements IWindowStorage { return null; } + this.performanceClient.incrementFields( + { encryptedCacheCount: 1 }, + correlationId + ); + return invokeAsync( decrypt, BrowserPerformanceEvents.Decrypt, diff --git a/lib/msal-browser/src/cache/TokenCache.ts b/lib/msal-browser/src/cache/TokenCache.ts index 84301d7950..38424a18ae 100644 --- a/lib/msal-browser/src/cache/TokenCache.ts +++ b/lib/msal-browser/src/cache/TokenCache.ts @@ -66,6 +66,7 @@ export async function loadExternalTokens( const idTokenClaims = response.id_token ? AuthToken.extractTokenClaims(response.id_token, base64Decode) : undefined; + const kmsi = AuthToken.isKmsi(idTokenClaims || {}); const authorityOptions: AuthorityOptions = { protocolMode: browserConfig.system.protocolMode, @@ -117,6 +118,7 @@ export async function loadExternalTokens( cacheRecordAccount.homeAccountId, cacheRecordAccount.environment, cacheRecordAccount.realm, + kmsi, correlationId, storage, logger, @@ -129,6 +131,7 @@ export async function loadExternalTokens( cacheRecordAccount.homeAccountId, cacheRecordAccount.environment, cacheRecordAccount.realm, + kmsi, options, correlationId, storage, @@ -140,6 +143,7 @@ export async function loadExternalTokens( response, cacheRecordAccount.homeAccountId, cacheRecordAccount.environment, + kmsi, correlationId, storage, logger, @@ -185,7 +189,11 @@ async function loadAccount( AccountEntityUtils.createAccountEntityFromAccountInfo( request.account ); - await storage.setAccount(accountEntity, correlationId); + await storage.setAccount( + accountEntity, + correlationId, + AuthToken.isKmsi(idTokenClaims || {}) + ); return accountEntity; } else if (!authority || (!clientInfo && !idTokenClaims)) { logger.error( @@ -221,7 +229,11 @@ async function loadAccount( logger ); - await storage.setAccount(cachedAccount, correlationId); + await storage.setAccount( + cachedAccount, + correlationId, + AuthToken.isKmsi(idTokenClaims || {}) + ); return cachedAccount; } @@ -238,6 +250,7 @@ async function loadIdToken( homeAccountId: string, environment: string, tenantId: string, + kmsi: boolean, correlationId: string, storage: BrowserCacheManager, logger: Logger, @@ -260,7 +273,7 @@ async function loadIdToken( tenantId ); - await storage.setIdTokenCredential(idTokenEntity, correlationId); + await storage.setIdTokenCredential(idTokenEntity, correlationId, kmsi); return idTokenEntity; } @@ -279,6 +292,7 @@ async function loadAccessToken( homeAccountId: string, environment: string, tenantId: string, + kmsi: boolean, options: LoadTokenOptions, correlationId: string, storage: BrowserCacheManager, @@ -330,7 +344,11 @@ async function loadAccessToken( base64Decode ); - await storage.setAccessTokenCredential(accessTokenEntity, correlationId); + await storage.setAccessTokenCredential( + accessTokenEntity, + correlationId, + kmsi + ); return accessTokenEntity; } @@ -346,6 +364,7 @@ async function loadRefreshToken( response: ExternalTokenResponse, homeAccountId: string, environment: string, + kmsi: boolean, correlationId: string, storage: BrowserCacheManager, logger: Logger, @@ -370,7 +389,11 @@ async function loadRefreshToken( response.refresh_token_expires_in ); - await storage.setRefreshTokenCredential(refreshTokenEntity, correlationId); + await storage.setRefreshTokenCredential( + refreshTokenEntity, + correlationId, + kmsi + ); return refreshTokenEntity; } diff --git a/lib/msal-browser/src/controllers/NestedAppAuthController.ts b/lib/msal-browser/src/controllers/NestedAppAuthController.ts index 60e44a7835..0c057bf26e 100644 --- a/lib/msal-browser/src/controllers/NestedAppAuthController.ts +++ b/lib/msal-browser/src/controllers/NestedAppAuthController.ts @@ -19,6 +19,7 @@ import { AccountFilter, AuthError, AccountEntityUtils, + AuthToken, } from "@azure/msal-common/browser"; import * as RootPerformanceEvents from "../telemetry/BrowserRootPerformanceEvents.js"; import { BrowserConfiguration } from "../config/Configuration.js"; @@ -921,7 +922,8 @@ export class NestedAppAuthController implements IController { ); await this.browserStorage.setAccount( accountEntity, - result.correlationId + result.correlationId, + AuthToken.isKmsi(result.idTokenClaims) ); return this.browserStorage.hydrateCache(result, request); } diff --git a/lib/msal-browser/src/controllers/StandardController.ts b/lib/msal-browser/src/controllers/StandardController.ts index 1d2a36e6fd..ef94533c55 100644 --- a/lib/msal-browser/src/controllers/StandardController.ts +++ b/lib/msal-browser/src/controllers/StandardController.ts @@ -27,6 +27,7 @@ import { PkceCodes, AccountEntityUtils, Constants, + AuthToken, } from "@azure/msal-common/browser"; import * as BrowserPerformanceEvents from "../telemetry/BrowserPerformanceEvents.js"; import * as BrowserRootPerformanceEvents from "../telemetry/BrowserRootPerformanceEvents.js"; @@ -1489,7 +1490,8 @@ export class StandardController implements IController { ); await this.browserStorage.setAccount( accountEntity, - result.correlationId + result.correlationId, + AuthToken.isKmsi(result.idTokenClaims) ); if (result.fromPlatformBroker) { diff --git a/lib/msal-browser/src/interaction_client/PlatformAuthInteractionClient.ts b/lib/msal-browser/src/interaction_client/PlatformAuthInteractionClient.ts index 887dc70140..0ffa3cec8b 100644 --- a/lib/msal-browser/src/interaction_client/PlatformAuthInteractionClient.ts +++ b/lib/msal-browser/src/interaction_client/PlatformAuthInteractionClient.ts @@ -562,7 +562,7 @@ export class PlatformAuthInteractionClient extends BaseInteractionClient { ); // cache accounts and tokens in the appropriate storage - await this.cacheAccount(baseAccount); + await this.cacheAccount(baseAccount, AuthToken.isKmsi(idTokenClaims)); await this.cacheNativeTokens( response, request, @@ -757,9 +757,16 @@ export class PlatformAuthInteractionClient extends BaseInteractionClient { * cache the account entity in browser storage * @param accountEntity */ - async cacheAccount(accountEntity: AccountEntity): Promise { + async cacheAccount( + accountEntity: AccountEntity, + kmsi: boolean + ): Promise { // Store the account info and hence `nativeAccountId` in browser cache - await this.browserStorage.setAccount(accountEntity, this.correlationId); + await this.browserStorage.setAccount( + accountEntity, + this.correlationId, + kmsi + ); // Remove any existing cached tokens for this account in browser storage this.browserStorage.removeAccountContext( AccountEntityUtils.getAccountInfo(accountEntity), @@ -833,6 +840,7 @@ export class PlatformAuthInteractionClient extends BaseInteractionClient { return this.nativeStorageManager.saveCacheRecord( nativeCacheRecord, this.correlationId, + AuthToken.isKmsi(idTokenClaims), request.storeInCache ); } diff --git a/lib/msal-browser/test/app/PublicClientApplication.spec.ts b/lib/msal-browser/test/app/PublicClientApplication.spec.ts index 9a083d5ad8..92e4cd0649 100644 --- a/lib/msal-browser/test/app/PublicClientApplication.spec.ts +++ b/lib/msal-browser/test/app/PublicClientApplication.spec.ts @@ -6995,7 +6995,7 @@ describe("PublicClientApplication.ts Class Unit Tests", () => { ]); secondBrowserStorageInstance - .setAccount(accountEntity, TEST_CONFIG.CORRELATION_ID) + .setAccount(accountEntity, TEST_CONFIG.CORRELATION_ID, true) .then(async () => { // Create a second PCA instance to simulate another tab const pca2 = new PublicClientApplication({ @@ -7033,7 +7033,7 @@ describe("PublicClientApplication.ts Class Unit Tests", () => { ]); secondBrowserStorageInstance - .setAccount(accountEntity, TEST_CONFIG.CORRELATION_ID) + .setAccount(accountEntity, TEST_CONFIG.CORRELATION_ID, true) .then(() => { // Ensure account is present in the cache before setting it as active secondBrowserStorageInstance.setActiveAccount( diff --git a/lib/msal-browser/test/cache/BrowserCacheManager.spec.ts b/lib/msal-browser/test/cache/BrowserCacheManager.spec.ts index c8ed7cdef9..2592e9a8a3 100644 --- a/lib/msal-browser/test/cache/BrowserCacheManager.spec.ts +++ b/lib/msal-browser/test/cache/BrowserCacheManager.spec.ts @@ -14,6 +14,10 @@ import { RANDOM_TEST_GUID, TEST_URIS, DEFAULT_OPENID_CONFIG_RESPONSE, + TEST_ACCESS_TOKEN_ENTITY, + TEST_ID_TOKEN_ENTITY, + TEST_ACCOUNT_ENTITY, + TEST_REFRESH_TOKEN_ENTITY, } from "../utils/StringConstants.js"; import { CacheOptions } from "../../src/config/Configuration.js"; import { @@ -35,7 +39,7 @@ import { AccountEntityUtils, Constants, CredentialEntity, -} from "@azure/msal-common"; +} from "@azure/msal-common/browser"; import { BrowserCacheLocation, INTERACTION_TYPE, @@ -44,12 +48,13 @@ import { import { CryptoOps } from "../../src/crypto/CryptoOps.js"; import { DatabaseStorage } from "../../src/cache/DatabaseStorage.js"; import { BrowserCacheManager } from "../../src/cache/BrowserCacheManager.js"; -import { EncryptedData } from "../../src/cache/EncryptedData.js"; import { base64Decode } from "../../src/encode/Base64Decode.js"; import { BrowserPerformanceClient } from "../../src/telemetry/BrowserPerformanceClient.js"; import { EventHandler } from "../../src/event/EventHandler.js"; import { version } from "../../src/packageMetadata.js"; import * as CacheKeys from "../../src/cache/CacheKeys.js"; +import { isEncrypted } from "../../src/cache/EncryptedData.js"; +import { SessionStorage } from "../../src/cache/SessionStorage.js"; describe("BrowserCacheManager tests", () => { let cacheConfig: Required; @@ -238,39 +243,76 @@ describe("BrowserCacheManager tests", () => { }); describe("migrateExistingCache", () => { - it("should collect performance telemetry for old and current cache counts", async () => { + it("should migrate v0 tokens to v2 in localStorage", async () => { + await browserCacheManager.initialize( + TEST_CONFIG.CORRELATION_ID + ); + + // Setup some current cache entries + await browserCacheManager.saveCacheRecord( + { + account: TEST_ACCOUNT_ENTITY, + accessToken: TEST_ACCESS_TOKEN_ENTITY, + idToken: TEST_ID_TOKEN_ENTITY, + refreshToken: TEST_REFRESH_TOKEN_ENTITY, + }, + TEST_CONFIG.CORRELATION_ID, + true + ); + // Setup some v0 cache entries const v0AccountKey = "msal.account.keys"; const v0TokenKey = `msal.token.keys.${TEST_CONFIG.MSAL_CLIENT_ID}`; + const v0Account = { + ...TEST_ACCOUNT_ENTITY, + homeAccountId: "different-uid.different-utid", + }; + const accountKey = `${v0Account.homeAccountId}-${v0Account.environment}-${v0Account.realm}`; window.localStorage.setItem( v0AccountKey, - JSON.stringify(["acc1", "acc2"]) + JSON.stringify([accountKey]) ); window.localStorage.setItem( - v0TokenKey, - JSON.stringify({ - idToken: ["id1"], - accessToken: ["at1", "at2"], - refreshToken: ["rt1"], - }) + accountKey, + JSON.stringify(v0Account) ); - // Setup some v1 cache entries - const v1AccountKey = CacheKeys.getAccountKeysCacheKey(1); - const v1TokenKey = CacheKeys.getTokenKeysCacheKey( - TEST_CONFIG.MSAL_CLIENT_ID, - 1 + const v0IdToken = { + ...TEST_ID_TOKEN_ENTITY, + homeAccountId: "different-uid.different-utid", + }; + const idTokenKey = `${v0IdToken.homeAccountId}-${v0IdToken.environment}-idtoken-${v0IdToken.clientId}-${v0IdToken.realm}`; + window.localStorage.setItem( + idTokenKey, + JSON.stringify(v0IdToken) ); + + const v0AccessToken = { + ...TEST_ACCESS_TOKEN_ENTITY, + homeAccountId: "different-uid.different-utid", + }; + const accessTokenKey = `${v0AccessToken.homeAccountId}-${v0AccessToken.environment}-accesstoken-${v0AccessToken.clientId}-${v0AccessToken.realm}`; window.localStorage.setItem( - v1AccountKey, - JSON.stringify(["acc1_v1"]) + accessTokenKey, + JSON.stringify(v0AccessToken) ); + + const v0RefreshToken = { + ...TEST_REFRESH_TOKEN_ENTITY, + homeAccountId: "different-uid.different-utid", + }; + const refreshTokenKey = `${v0RefreshToken.homeAccountId}-${v0RefreshToken.environment}-refreshtoken-${v0RefreshToken.clientId}-${v0RefreshToken.realm}`; window.localStorage.setItem( - v1TokenKey, + refreshTokenKey, + JSON.stringify(v0RefreshToken) + ); + + window.localStorage.setItem( + v0TokenKey, JSON.stringify({ - idToken: ["id1_v1"], - accessToken: [], - refreshToken: ["rt1_v1"], + idToken: [idTokenKey], + accessToken: [accessTokenKey], + refreshToken: [refreshTokenKey], }) ); @@ -282,168 +324,682 @@ describe("BrowserCacheManager tests", () => { expect(addFieldsSpy).toHaveBeenCalledWith( { - oldAccountCount: 2, - oldAccessCount: 2, - oldIdCount: 1, - oldRefreshCount: 1, + preMigrateATCount: 1, + preMigrateAcntCount: 1, + preMigrateITCount: 1, + preMigrateRTCount: 1, }, TEST_CONFIG.CORRELATION_ID ); expect(addFieldsSpy).toHaveBeenCalledWith( { - currAccountCount: 1, - currAccessCount: 0, - currIdCount: 1, - currRefreshCount: 1, + postMigrateATCount: 2, + postMigrateAcntCount: 2, + postMigrateITCount: 2, + postMigrateRTCount: 2, }, TEST_CONFIG.CORRELATION_ID ); }); - it("should migrate encrypted v0 tokens to encrypted v1 format in localStorage", async () => { + it("should migrate v1 tokens to v2 in localStorage", async () => { await browserCacheManager.initialize( TEST_CONFIG.CORRELATION_ID ); - // Mock the decryptData method to handle our fake encrypted data - const localStorage = (browserCacheManager as any) - .browserStorage; + // Setup some current cache entries + await browserCacheManager.saveCacheRecord( + { + account: TEST_ACCOUNT_ENTITY, + accessToken: TEST_ACCESS_TOKEN_ENTITY, + idToken: TEST_ID_TOKEN_ENTITY, + refreshToken: TEST_REFRESH_TOKEN_ENTITY, + }, + TEST_CONFIG.CORRELATION_ID, + true + ); - const v0TokenKeysKey = `msal.token.keys.${TEST_CONFIG.MSAL_CLIENT_ID}`; - const v0TokenKeys = { - idToken: ["v0-id-token-1"], - accessToken: ["v0-access-token-1"], - refreshToken: ["v0-refresh-token-1"], + // Setup some v1 cache entries + const v1AccountKey = "msal.1.account.keys"; + const v1TokenKey = `msal.1.token.keys.${TEST_CONFIG.MSAL_CLIENT_ID}`; + const v1Account = { + ...TEST_ACCOUNT_ENTITY, + homeAccountId: "different-uid.different-utid", }; - + const accountKey = `msal.1-${v1Account.homeAccountId}-${v1Account.environment}-${v1Account.realm}`; window.localStorage.setItem( - v0TokenKeysKey, - JSON.stringify(v0TokenKeys) + v1AccountKey, + JSON.stringify([accountKey]) + ); + window.localStorage.setItem( + accountKey, + JSON.stringify(v1Account) ); - // Add v0 token data as encrypted entries - const accessToken = { - credentialType: "AccessToken", - secret: "access-token-1", - expiresOn: Math.floor(Date.now() / 1000) + 3600, // 1 hour from now + const v1IdToken = { + ...TEST_ID_TOKEN_ENTITY, + homeAccountId: "different-uid.different-utid", }; - await localStorage.setUserData( - "v0-access-token-1", - JSON.stringify(accessToken) + const idTokenKey = `msal.1-${v1IdToken.homeAccountId}-${v1IdToken.environment}-idtoken-${v1IdToken.clientId}-${v1IdToken.realm}`; + window.localStorage.setItem( + idTokenKey, + JSON.stringify(v1IdToken) ); - const idToken = { - credentialType: "IdToken", - secret: "id-token-1", + const v1AccessToken = { + ...TEST_ACCESS_TOKEN_ENTITY, + homeAccountId: "different-uid.different-utid", }; - await localStorage.setUserData( - "v0-id-token-1", - JSON.stringify(idToken) + const accessTokenKey = `msal.1-${v1AccessToken.homeAccountId}-${v1AccessToken.environment}-accesstoken-${v1AccessToken.clientId}-${v1AccessToken.realm}`; + window.localStorage.setItem( + accessTokenKey, + JSON.stringify(v1AccessToken) ); - const refreshToken = { - credentialType: "RefreshToken", - secret: "refresh-token-1", + const v1RefreshToken = { + ...TEST_REFRESH_TOKEN_ENTITY, + homeAccountId: "different-uid.different-utid", }; - await localStorage.setUserData( - "v0-refresh-token-1", - JSON.stringify(refreshToken) + const refreshTokenKey = `msal.1-${v1RefreshToken.homeAccountId}-${v1RefreshToken.environment}-refreshtoken-${v1RefreshToken.clientId}-${v1RefreshToken.realm}`; + window.localStorage.setItem( + refreshTokenKey, + JSON.stringify(v1RefreshToken) ); - const setTokenKeysSpy = jest.spyOn( - browserCacheManager, - "setTokenKeys" + window.localStorage.setItem( + v1TokenKey, + JSON.stringify({ + idToken: [idTokenKey], + accessToken: [accessTokenKey], + refreshToken: [refreshTokenKey], + }) ); - const v1TokenKeysKey = `msal.1.token.keys.${TEST_CONFIG.MSAL_CLIENT_ID}`; + const addFieldsSpy = jest.spyOn(performanceClient, "addFields"); await browserCacheManager.migrateExistingCache( TEST_CONFIG.CORRELATION_ID ); - // Verify v0 token keys were processed and migrated - expect(setTokenKeysSpy).toHaveBeenCalledWith( - expect.objectContaining({ - idToken: expect.arrayContaining(["v0-id-token-1"]), - accessToken: expect.arrayContaining([ - "v0-access-token-1", - ]), - refreshToken: expect.arrayContaining([ - "v0-refresh-token-1", - ]), - }), - TEST_CONFIG.CORRELATION_ID, - 0 + expect(addFieldsSpy).toHaveBeenCalledWith( + { + preMigrateATCount: 1, + preMigrateAcntCount: 1, + preMigrateITCount: 1, + preMigrateRTCount: 1, + }, + TEST_CONFIG.CORRELATION_ID ); - // Verify v1 token keys were updated (should include migrated tokens) - expect(setTokenKeysSpy).toHaveBeenCalledWith( - expect.objectContaining({ - idToken: expect.arrayContaining([ - "msal.1-v0-id-token-1", - ]), - accessToken: expect.arrayContaining([ - "msal.1-v0-access-token-1", - ]), - refreshToken: expect.arrayContaining([ - "msal.1-v0-refresh-token-1", - ]), - }), - TEST_CONFIG.CORRELATION_ID, - 1 + expect(addFieldsSpy).toHaveBeenCalledWith( + { + postMigrateATCount: 2, + postMigrateAcntCount: 2, + postMigrateITCount: 2, + postMigrateRTCount: 2, + }, + TEST_CONFIG.CORRELATION_ID ); + }); - // Verify the final v1 keys contain both original v1 data and migrated data - const finalV1Keys = JSON.parse( - window.localStorage.getItem(v1TokenKeysKey) || "{}" - ); - expect(finalV1Keys.idToken).toEqual( - expect.arrayContaining(["msal.1-v0-id-token-1"]) - ); - expect(finalV1Keys.accessToken).toEqual( - expect.arrayContaining(["msal.1-v0-access-token-1"]) - ); + describe("getKMSIValues", () => { + it("should return empty map when there are no idTokens", () => { + const kmsiMap = browserCacheManager.getKMSIValues(); + expect(kmsiMap).toEqual({}); + }); + + it("should handle idToken with no signin_state claim", async () => { + await browserCacheManager.initialize( + TEST_CONFIG.CORRELATION_ID + ); + + // Use existing IDTOKEN_V2 which doesn't have signin_state claim + const idTokenWithoutKmsi = { + ...TEST_ID_TOKEN_ENTITY, + secret: TEST_TOKENS.IDTOKEN_V2, + }; + await browserCacheManager.setIdTokenCredential( + idTokenWithoutKmsi, + TEST_CONFIG.CORRELATION_ID, + false + ); + + const kmsiMap = browserCacheManager.getKMSIValues(); + // IDTOKEN_V2 doesn't have signin_state, so KMSI should be false + expect(kmsiMap[idTokenWithoutKmsi.homeAccountId]).toBe( + false + ); + }); + + it("should return correct KMSI values for multiple accounts", async () => { + await browserCacheManager.initialize( + TEST_CONFIG.CORRELATION_ID + ); + + // Account 1 - Use IDTOKEN_V2 which doesn't have signin_state + const idToken1 = { + ...TEST_ID_TOKEN_ENTITY, + homeAccountId: "account1.tenant1", + secret: TEST_TOKENS.IDTOKEN_V2, + }; + await browserCacheManager.setIdTokenCredential( + idToken1, + TEST_CONFIG.CORRELATION_ID, + false + ); - // Verify tokens now exist under both v0 and v1 cache keys - // Check v0 tokens still exist (originals should be preserved) - const v0AccessToken1 = - localStorage.getUserData("v0-access-token-1"); - const v0IdToken1 = localStorage.getUserData("v0-id-token-1"); - const v0RefreshToken1 = - localStorage.getUserData("v0-refresh-token-1"); + // Account 2 - Use IDTOKEN_V2_ALT which also doesn't have signin_state + const idToken2 = { + ...TEST_ID_TOKEN_ENTITY, + homeAccountId: "account2.tenant2", + secret: TEST_TOKENS.IDTOKEN_V2_ALT, + }; + await browserCacheManager.setIdTokenCredential( + idToken2, + TEST_CONFIG.CORRELATION_ID, + false + ); + + const kmsiMap = browserCacheManager.getKMSIValues(); + expect(Object.keys(kmsiMap).length).toBe(2); + expect(kmsiMap["account1.tenant1"]).toBe(false); + expect(kmsiMap["account2.tenant2"]).toBe(false); + }); + + it("should return KMSI=true for idToken with signin_state claim", async () => { + await browserCacheManager.initialize( + TEST_CONFIG.CORRELATION_ID + ); + + // Create an idToken entity with signin_state in the claims + // We'll mock the getUserData to return a token with the right structure + const idTokenWithKmsi = { + ...TEST_ID_TOKEN_ENTITY, + homeAccountId: "kmsi-account.tenant", + secret: TEST_TOKENS.IDTOKEN_V2, + }; + + // Store the token first + await browserCacheManager.setIdTokenCredential( + idTokenWithKmsi, + TEST_CONFIG.CORRELATION_ID, + true + ); - expect(v0AccessToken1).toBe(JSON.stringify(accessToken)); - expect(v0IdToken1).toBe(JSON.stringify(idToken)); - expect(v0RefreshToken1).toBe(JSON.stringify(refreshToken)); + // Mock getUserData to inject signin_state into the decoded claims + // We need to mock at the point where getKMSIValues reads the token + const originalGetUserData = browserCacheManager[ + "browserStorage" + ].getUserData.bind(browserCacheManager["browserStorage"]); + jest.spyOn( + browserCacheManager["browserStorage"], + "getUserData" + ).mockImplementation((key: string) => { + const data = originalGetUserData(key); + if (data) { + const parsed = JSON.parse(data); + if ( + parsed.homeAccountId === "kmsi-account.tenant" + ) { + // Create a mock token with signin_state + // Base64 encode a payload with signin_state: ["kmsi"] + const header = + "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9"; // {"typ":"JWT","alg":"RS256"} + const payload = Buffer.from( + JSON.stringify({ + oid: "00000000-0000-0000-66f3-3332eca7ea81", + sub: "sub", + signin_state: ["kmsi"], + }) + ).toString("base64"); + const signature = "signature"; + parsed.secret = `${header}.${payload}.${signature}`; + } + return JSON.stringify(parsed); + } + return data; + }); - // Check that v1 migrated tokens exist (should have v1 prefix) - const v1AccessTokenKey = `${CacheKeys.PREFIX}.${CacheKeys.CREDENTIAL_SCHEMA_VERSION}-v0-access-token-1`; - const v1IdTokenKey = `${CacheKeys.PREFIX}.${CacheKeys.CREDENTIAL_SCHEMA_VERSION}-v0-id-token-1`; - const v1RefreshTokenKey = `${CacheKeys.PREFIX}.${CacheKeys.CREDENTIAL_SCHEMA_VERSION}-v0-refresh-token-1`; + const kmsiMap = browserCacheManager.getKMSIValues(); + expect(kmsiMap["kmsi-account.tenant"]).toBe(true); - const v1AccessToken1 = - localStorage.getUserData(v1AccessTokenKey); - const v1IdToken1 = localStorage.getUserData(v1IdTokenKey); - const v1RefreshToken1 = - localStorage.getUserData(v1RefreshTokenKey); + // Restore the original method + jest.restoreAllMocks(); + }); + + it("should skip invalid idToken entries", async () => { + await browserCacheManager.initialize( + TEST_CONFIG.CORRELATION_ID + ); + + // Create a valid idToken first + await browserCacheManager.setIdTokenCredential( + TEST_ID_TOKEN_ENTITY, + TEST_CONFIG.CORRELATION_ID, + true + ); - expect(v1AccessToken1).toBe(JSON.stringify(accessToken)); - expect(v1IdToken1).toBe(JSON.stringify(idToken)); - expect(v1RefreshToken1).toBe(JSON.stringify(refreshToken)); + // Manually add an invalid entry + const tokenKeys = browserCacheManager.getTokenKeys(); + tokenKeys.idToken.push("invalid-key"); + browserCacheManager.setTokenKeys( + tokenKeys, + TEST_CONFIG.CORRELATION_ID + ); + + const kmsiMap = browserCacheManager.getKMSIValues(); + // Should only have the valid token + expect(Object.keys(kmsiMap).length).toBe(1); + expect( + kmsiMap[TEST_ID_TOKEN_ENTITY.homeAccountId] + ).toBeDefined(); + }); + }); + + describe("migrateIdTokens - KMSI edge cases", () => { + it("should migrate when old token has signin_state and is newer", async () => { + await browserCacheManager.initialize( + TEST_CONFIG.CORRELATION_ID + ); + + // Create a v0 token WITH signin_state (KMSI) - different account to avoid conflicts + // Use recent timestamp to avoid cache expiration + const recentTimestamp = (Date.now() - 1000).toString(); // 1 second ago + const v0IdToken = { + ...TEST_ID_TOKEN_ENTITY, + homeAccountId: "v0-account.tenant", + secret: TEST_TOKENS.IDTOKEN_V2, // Has signin_state + lastUpdatedAt: recentTimestamp, + }; + const v0IdTokenKey = `${v0IdToken.homeAccountId}-${v0IdToken.environment}-idtoken-${v0IdToken.clientId}-${v0IdToken.realm}`; + window.localStorage.setItem( + v0IdTokenKey, + JSON.stringify(v0IdToken) + ); + + // Setup v0 account + const v0Account = { + ...TEST_ACCOUNT_ENTITY, + homeAccountId: "v0-account.tenant", + }; + const v0AccountKey = `${v0Account.homeAccountId}-${v0Account.environment}-${v0Account.realm}`; + window.localStorage.setItem( + v0AccountKey, + JSON.stringify(v0Account) + ); + window.localStorage.setItem( + "msal.account.keys", + JSON.stringify([v0AccountKey]) + ); + + // Setup v0 token keys + window.localStorage.setItem( + `msal.token.keys.${TEST_CONFIG.MSAL_CLIENT_ID}`, + JSON.stringify({ + idToken: [v0IdTokenKey], + accessToken: [], + refreshToken: [], + }) + ); + + const performanceIncrement = jest.spyOn( + performanceClient, + "incrementFields" + ); + + await browserCacheManager.migrateExistingCache( + TEST_CONFIG.CORRELATION_ID + ); + + // Should have migrated successfully + expect(performanceIncrement).toHaveBeenCalledWith( + { migratedITCount: 1 }, + TEST_CONFIG.CORRELATION_ID + ); + }); + + it("should NOT overwrite newer tokens with KMSI during migration", async () => { + await browserCacheManager.initialize( + TEST_CONFIG.CORRELATION_ID + ); + + // Setup a fresh account and idToken (this will be the "current" v2 state) + const currentAccount = { + ...TEST_ACCOUNT_ENTITY, + homeAccountId: "test-account.tenant", + lastUpdatedAt: "3000", // Newest + }; + + const currentIdToken = { + ...TEST_ID_TOKEN_ENTITY, + homeAccountId: "test-account.tenant", + secret: TEST_TOKENS.IDTOKEN_V2, // Has signin_state + lastUpdatedAt: "3000", // Newest + }; + await browserCacheManager.setAccount( + currentAccount, + TEST_CONFIG.CORRELATION_ID, + true + ); + await browserCacheManager.setIdTokenCredential( + currentIdToken, + TEST_CONFIG.CORRELATION_ID, + true + ); + + // Now setup an older v0 token - use IDTOKEN_V2_ALT as a different token + const v0IdToken = { + ...TEST_ID_TOKEN_ENTITY, + homeAccountId: "test-account.tenant", + secret: TEST_TOKENS.IDTOKEN_V2_ALT, + lastUpdatedAt: "1000", // Older + }; + const v0IdTokenKey = `${v0IdToken.homeAccountId}-${v0IdToken.environment}-idtoken-${v0IdToken.clientId}-${v0IdToken.realm}`; + window.localStorage.setItem( + v0IdTokenKey, + JSON.stringify(v0IdToken) + ); + + // Setup v0 account + const v0Account = { + ...TEST_ACCOUNT_ENTITY, + homeAccountId: "test-account.tenant", + lastUpdatedAt: "1000", + }; + const v0AccountKey = `${v0Account.homeAccountId}-${v0Account.environment}-${v0Account.realm}`; + window.localStorage.setItem( + v0AccountKey, + JSON.stringify(v0Account) + ); + window.localStorage.setItem( + "msal.account.keys", + JSON.stringify([v0AccountKey]) + ); + + // Setup v0 token keys + window.localStorage.setItem( + `msal.token.keys.${TEST_CONFIG.MSAL_CLIENT_ID}`, + JSON.stringify({ + idToken: [v0IdTokenKey], + accessToken: [], + refreshToken: [], + }) + ); + + await browserCacheManager.migrateExistingCache( + TEST_CONFIG.CORRELATION_ID + ); + + // Should NOT have overwritten - current token with signin_state should remain + const currentToken = + browserCacheManager.getIdTokenCredential( + browserCacheManager.generateCredentialKey( + currentIdToken + ), + TEST_CONFIG.CORRELATION_ID + ); + expect(currentToken?.secret).toBe(TEST_TOKENS.IDTOKEN_V2); + }); + + it("should skip migration when account is missing", async () => { + await browserCacheManager.initialize( + TEST_CONFIG.CORRELATION_ID + ); + + // Create v0 idToken without corresponding account + const v0IdToken = { + ...TEST_ID_TOKEN_ENTITY, + homeAccountId: "orphaned-account", + }; + const v0IdTokenKey = `${v0IdToken.homeAccountId}-${v0IdToken.environment}-idtoken-${v0IdToken.clientId}-${v0IdToken.realm}`; + window.localStorage.setItem( + v0IdTokenKey, + JSON.stringify(v0IdToken) + ); + + // Setup v0 token keys but NO account keys + window.localStorage.setItem( + `msal.token.keys.${TEST_CONFIG.MSAL_CLIENT_ID}`, + JSON.stringify({ + idToken: [v0IdTokenKey], + accessToken: [], + refreshToken: [], + }) + ); + + const performanceIncrement = jest.spyOn( + performanceClient, + "incrementFields" + ); + + await browserCacheManager.migrateExistingCache( + TEST_CONFIG.CORRELATION_ID + ); + + // Should have skipped the migration + expect(performanceIncrement).toHaveBeenCalledWith( + { skipITMigrateCount: 1 }, + TEST_CONFIG.CORRELATION_ID + ); + }); + }); + + describe("migrateAccessTokens/RefreshTokens - KMSI edge cases", () => { + it("should skip access token migration when kmsiMap doesn't contain homeAccountId", async () => { + await browserCacheManager.initialize( + TEST_CONFIG.CORRELATION_ID + ); + + // Create v0 access token without corresponding idToken + const v0AccessToken = { + ...TEST_ACCESS_TOKEN_ENTITY, + homeAccountId: "orphaned-account", + }; + const v0AccessTokenKey = `${v0AccessToken.homeAccountId}-${v0AccessToken.environment}-accesstoken-${v0AccessToken.clientId}-${v0AccessToken.realm}`; + window.localStorage.setItem( + v0AccessTokenKey, + JSON.stringify(v0AccessToken) + ); + + // Setup v0 token keys + window.localStorage.setItem( + `msal.token.keys.${TEST_CONFIG.MSAL_CLIENT_ID}`, + JSON.stringify({ + idToken: [], + accessToken: [v0AccessTokenKey], + refreshToken: [], + }) + ); + + const performanceIncrement = jest.spyOn( + performanceClient, + "incrementFields" + ); + + await browserCacheManager.migrateExistingCache( + TEST_CONFIG.CORRELATION_ID + ); + + // Should have skipped the migration + expect(performanceIncrement).toHaveBeenCalledWith( + { skipATMigrateCount: 1 }, + TEST_CONFIG.CORRELATION_ID + ); + }); + + it("should skip refresh token migration when kmsiMap doesn't contain homeAccountId", async () => { + await browserCacheManager.initialize( + TEST_CONFIG.CORRELATION_ID + ); + + // Create v0 refresh token without corresponding idToken + const v0RefreshToken = { + ...TEST_REFRESH_TOKEN_ENTITY, + homeAccountId: "orphaned-account", + }; + const v0RefreshTokenKey = `${v0RefreshToken.homeAccountId}-${v0RefreshToken.environment}-refreshtoken-${v0RefreshToken.clientId}----`; + window.localStorage.setItem( + v0RefreshTokenKey, + JSON.stringify(v0RefreshToken) + ); + + // Setup v0 token keys + window.localStorage.setItem( + `msal.token.keys.${TEST_CONFIG.MSAL_CLIENT_ID}`, + JSON.stringify({ + idToken: [], + accessToken: [], + refreshToken: [v0RefreshTokenKey], + }) + ); + + const performanceIncrement = jest.spyOn( + performanceClient, + "incrementFields" + ); + + await browserCacheManager.migrateExistingCache( + TEST_CONFIG.CORRELATION_ID + ); + + // Should have skipped the migration + expect(performanceIncrement).toHaveBeenCalledWith( + { skipRTMigrateCount: 1 }, + TEST_CONFIG.CORRELATION_ID + ); + }); + + it("should migrate access tokens with correct KMSI value from kmsiMap", async () => { + await browserCacheManager.initialize( + TEST_CONFIG.CORRELATION_ID + ); + + // Setup idToken with KMSI=true + const idToken = { + ...TEST_ID_TOKEN_ENTITY, + secret: TEST_TOKENS.IDTOKEN_V2, // Has signin_state with kmsi + }; + await browserCacheManager.setIdTokenCredential( + idToken, + TEST_CONFIG.CORRELATION_ID, + true + ); + + // Create v0 access token for same account + const v0AccessToken = { + ...TEST_ACCESS_TOKEN_ENTITY, + homeAccountId: idToken.homeAccountId, + }; + const v0AccessTokenKey = `${v0AccessToken.homeAccountId}-${v0AccessToken.environment}-accesstoken-${v0AccessToken.clientId}-${v0AccessToken.realm}`; + window.localStorage.setItem( + v0AccessTokenKey, + JSON.stringify(v0AccessToken) + ); + + // Setup v0 token keys + window.localStorage.setItem( + `msal.token.keys.${TEST_CONFIG.MSAL_CLIENT_ID}`, + JSON.stringify({ + idToken: [], + accessToken: [v0AccessTokenKey], + refreshToken: [], + }) + ); + + const performanceIncrement = jest.spyOn( + performanceClient, + "incrementFields" + ); + + await browserCacheManager.migrateExistingCache( + TEST_CONFIG.CORRELATION_ID + ); + + // Should have migrated successfully + expect(performanceIncrement).toHaveBeenCalledWith( + { migratedATCount: 1 }, + TEST_CONFIG.CORRELATION_ID + ); + }); + + it("should only migrate access tokens when newer than existing", async () => { + await browserCacheManager.initialize( + TEST_CONFIG.CORRELATION_ID + ); + + // Use recent timestamp to avoid cache expiration + const recentTimestamp = (Date.now() - 1000).toString(); // 1 second ago + + // Setup idToken first for KMSI map + const idToken = { + ...TEST_ID_TOKEN_ENTITY, + lastUpdatedAt: recentTimestamp, + }; + await browserCacheManager.setIdTokenCredential( + idToken, + TEST_CONFIG.CORRELATION_ID, + true + ); + + // Create v0 access token (older) with specific target + const v0AccessToken = { + ...TEST_ACCESS_TOKEN_ENTITY, + lastUpdatedAt: recentTimestamp, + target: "scope1 scope2", + }; + const v0AccessTokenKey = `${v0AccessToken.homeAccountId}-${v0AccessToken.environment}-accesstoken-${v0AccessToken.clientId}-${v0AccessToken.realm}-${v0AccessToken.target}--`; + window.localStorage.setItem( + v0AccessTokenKey, + JSON.stringify(v0AccessToken) + ); + + // Setup v0 token keys + window.localStorage.setItem( + `msal.token.keys.${TEST_CONFIG.MSAL_CLIENT_ID}`, + JSON.stringify({ + idToken: [], + accessToken: [v0AccessTokenKey], + refreshToken: [], + }) + ); + + const performanceIncrement = jest.spyOn( + performanceClient, + "incrementFields" + ); + + await browserCacheManager.migrateExistingCache( + TEST_CONFIG.CORRELATION_ID + ); + + // Should have migrated the old token + expect(performanceIncrement).toHaveBeenCalledWith( + { migratedATCount: 1 }, + TEST_CONFIG.CORRELATION_ID + ); + + // Verify the token was migrated + const migratedToken = + browserCacheManager.getAccessTokenCredential( + browserCacheManager.generateCredentialKey( + v0AccessToken + ), + TEST_CONFIG.CORRELATION_ID + ); + expect(migratedToken).toBeDefined(); + }); }); }); - describe("updateV0ToCurrent", () => { + describe("updateOldEntry", () => { it("should add lastUpdatedAt to v0 entries that don't have it", async () => { const v0Key = "test-v0-key"; const v0Value = { someProperty: "value" }; window.localStorage.setItem(v0Key, JSON.stringify(v0Value)); - await browserCacheManager.updateV0ToCurrent( - CacheKeys.ACCOUNT_SCHEMA_VERSION, - [v0Key], - [], + await browserCacheManager.updateOldEntry( + v0Key, TEST_CONFIG.CORRELATION_ID ); @@ -470,16 +1026,12 @@ describe("BrowserCacheManager tests", () => { "incrementFields" ); - const v0Keys = [v0Key]; - await browserCacheManager.updateV0ToCurrent( - CacheKeys.ACCOUNT_SCHEMA_VERSION, - v0Keys, - [], + await browserCacheManager.updateOldEntry( + v0Key, TEST_CONFIG.CORRELATION_ID ); expect(window.localStorage.getItem(v0Key)).toBeNull(); - expect(v0Keys).not.toContain(v0Key); expect(incrementFieldsSpy).toHaveBeenCalledWith( { expiredCacheRemovedCount: 1 }, TEST_CONFIG.CORRELATION_ID @@ -507,228 +1059,284 @@ describe("BrowserCacheManager tests", () => { "incrementFields" ); - const v0Keys = [v0Key]; - await browserCacheManager.updateV0ToCurrent( - CacheKeys.CREDENTIAL_SCHEMA_VERSION, - v0Keys, - [], + await browserCacheManager.updateOldEntry( + v0Key, TEST_CONFIG.CORRELATION_ID ); expect(window.localStorage.getItem(v0Key)).toBeNull(); - expect(v0Keys).not.toContain(v0Key); expect(incrementFieldsSpy).toHaveBeenCalledWith( { expiredCacheRemovedCount: 1 }, TEST_CONFIG.CORRELATION_ID ); }); - it("should not migrate unencrypted localStorage data to v1 format", async () => { - const v0Key = "test-migration-key"; - const v0Value = { - someProperty: "value", - lastUpdatedAt: Date.now().toString(), - }; - window.localStorage.setItem(v0Key, JSON.stringify(v0Value)); - const setUserDataSpy = jest.spyOn( - browserCacheManager, - "setUserData" + it("should return decrypted value if cached entry is encrypted", async () => { + const encryptedKey = "test-encrypted-key"; + await browserCacheManager.initialize( + TEST_CONFIG.CORRELATION_ID ); - const incrementFieldsSpy = jest.spyOn( - performanceClient, - "incrementFields" + await browserCacheManager.setUserData( + encryptedKey, + JSON.stringify(TEST_ACCESS_TOKEN_ENTITY), + TEST_CONFIG.CORRELATION_ID, + TEST_ACCESS_TOKEN_ENTITY.lastUpdatedAt, + false ); - const v1Keys: string[] = []; - await browserCacheManager.updateV0ToCurrent( - CacheKeys.ACCOUNT_SCHEMA_VERSION, - [v0Key], - v1Keys, + const result = await browserCacheManager.updateOldEntry( + encryptedKey, TEST_CONFIG.CORRELATION_ID ); - // For localStorage with unencrypted data, no migration to v1 should occur - expect(setUserDataSpy).not.toHaveBeenCalled(); - expect(v1Keys).toHaveLength(0); - expect(incrementFieldsSpy).not.toHaveBeenCalledWith( - expect.objectContaining({ - upgradedCacheCount: expect.any(Number), - }), + expect( + isEncrypted( + JSON.parse( + window.localStorage.getItem(encryptedKey) || "{}" + ) + ) + ).toBe(true); + expect(result).toEqual(TEST_ACCESS_TOKEN_ENTITY); + }); + + it("should handle missing cache entries gracefully", async () => { + const missingKey = "non-existent-key"; + expect( + await browserCacheManager.updateOldEntry( + missingKey, + TEST_CONFIG.CORRELATION_ID + ) + ).toBeNull(); + }); + }); + + describe("KMSI (Keep Me Signed In) Storage Tests", () => { + beforeEach(async () => { + await browserCacheManager.initialize( TEST_CONFIG.CORRELATION_ID ); + }); + + it("should NOT encrypt idToken when KMSI is true", async () => { + const idToken = { + ...TEST_ID_TOKEN_ENTITY, + secret: TEST_TOKENS.IDTOKEN_V2, + }; + + await browserCacheManager.setIdTokenCredential( + idToken, + TEST_CONFIG.CORRELATION_ID, + true // KMSI = true, NO encryption (user wants to stay signed in) + ); + + const key = browserCacheManager.generateCredentialKey(idToken); + const rawValue = window.localStorage.getItem(key); + expect(rawValue).toBeDefined(); + + const parsedValue = JSON.parse(rawValue!); + expect(isEncrypted(parsedValue)).toBe(false); + expect(parsedValue).toStrictEqual(idToken); + }); + + it("should encrypt idToken when KMSI is false", async () => { + const idToken = { + ...TEST_ID_TOKEN_ENTITY, + secret: TEST_TOKENS.IDTOKEN_V2, + }; + + await browserCacheManager.setIdTokenCredential( + idToken, + TEST_CONFIG.CORRELATION_ID, + false // KMSI = false, encryption for additional security + ); + + const key = browserCacheManager.generateCredentialKey(idToken); + const rawValue = window.localStorage.getItem(key); + expect(rawValue).toBeDefined(); + + const parsedValue = JSON.parse(rawValue!); + expect(isEncrypted(parsedValue)).toBe(true); + }); + + it("should NOT encrypt accessToken when KMSI is true", async () => { + const accessToken = { + ...TEST_ACCESS_TOKEN_ENTITY, + }; + + await browserCacheManager.setAccessTokenCredential( + accessToken, + TEST_CONFIG.CORRELATION_ID, + true // KMSI = true, NO encryption + ); + + const key = + browserCacheManager.generateCredentialKey(accessToken); + const rawValue = window.localStorage.getItem(key); + expect(rawValue).toBeDefined(); + + const parsedValue = JSON.parse(rawValue!); + expect(isEncrypted(parsedValue)).toBe(false); + expect(parsedValue).toStrictEqual(accessToken); + }); + + it("should encrypt accessToken when KMSI is false", async () => { + const accessToken = { + ...TEST_ACCESS_TOKEN_ENTITY, + }; + + await browserCacheManager.setAccessTokenCredential( + accessToken, + TEST_CONFIG.CORRELATION_ID, + false // KMSI = false, encryption for security + ); + + const key = + browserCacheManager.generateCredentialKey(accessToken); + const rawValue = window.localStorage.getItem(key); + expect(rawValue).toBeDefined(); - // Clean up - window.localStorage.clear(); + const parsedValue = JSON.parse(rawValue!); + expect(isEncrypted(parsedValue)).toBe(true); }); - it("should handle missing cache entries gracefully", async () => { - const missingKey = "non-existent-key"; - const v0Keys = [missingKey]; + it("should NOT encrypt refreshToken when KMSI is true", async () => { + const refreshToken = { + ...TEST_REFRESH_TOKEN_ENTITY, + }; - await browserCacheManager.updateV0ToCurrent( - CacheKeys.ACCOUNT_SCHEMA_VERSION, - v0Keys, - [], - TEST_CONFIG.CORRELATION_ID + await browserCacheManager.setRefreshTokenCredential( + refreshToken, + TEST_CONFIG.CORRELATION_ID, + true // KMSI = true, NO encryption ); - expect(v0Keys).not.toContain(missingKey); + const key = + browserCacheManager.generateCredentialKey(refreshToken); + const rawValue = window.localStorage.getItem(key); + expect(rawValue).toBeDefined(); + + const parsedValue = JSON.parse(rawValue!); + expect(isEncrypted(parsedValue)).toBe(false); + expect(parsedValue).toStrictEqual(refreshToken); }); - it("should process all keys", async () => { - // Create a new browser cache manager with sessionStorage for this test - const sessionStorageCacheManager = new BrowserCacheManager( - TEST_CONFIG.MSAL_CLIENT_ID, - { - ...cacheConfig, - cacheLocation: BrowserCacheLocation.SessionStorage, - }, - browserCrypto, - logger, - performanceClient, - new EventHandler() - ); - - // Create mock localStorage implementation - const mockStorage = { - getItem: jest.fn(), - setItem: jest.fn(), - removeItem: jest.fn(), - getUserData: jest.fn(), - setUserData: jest.fn(), - decryptData: jest.fn(), - initialize: jest.fn(), - getKeys: jest.fn(), + it("should encrypt refreshToken when KMSI is false", async () => { + const refreshToken = { + ...TEST_REFRESH_TOKEN_ENTITY, }; - // Replace browser storage with mock - // @ts-ignore - sessionStorageCacheManager.browserStorage = mockStorage; - - // Setup test data - multiple keys with different scenarios - const v0Keys = [ - "key1-missing", // Should be removed from array - "key2-expired", // Should be removed due to expiration - "key3-migrate", // Should be migrated successfully - "key4-update", // Should update existing v1 entry - ]; - const v1Keys: string[] = ["msal.1-key4-update"]; // Existing v1 entry to update - - const now = Date.now(); - const oldTimestamp = (now - 7 * 24 * 60 * 60 * 1000).toString(); // 7 days ago - const currentTimestamp = now.toString(); - - // Mock responses for getItem calls - mockStorage.getItem.mockImplementation((key: string) => { - switch (key) { - case "key1-missing": - return null; // Missing key - case "key2-expired": - return JSON.stringify({ - lastUpdatedAt: oldTimestamp, - credentialType: - Constants.CredentialType.ACCESS_TOKEN, - expiresOn: (now - 1000).toString(), // Already expired - }); - case "key3-migrate": - return JSON.stringify({ - lastUpdatedAt: currentTimestamp, - credentialType: - Constants.CredentialType.ACCESS_TOKEN, - expiresOn: (now + 3600 * 1000).toString(), // Valid for 1 hour - }); - case "key4-update": - return JSON.stringify({ - lastUpdatedAt: currentTimestamp, - credentialType: - Constants.CredentialType.ACCESS_TOKEN, - expiresOn: (now + 3600 * 1000).toString(), - }); - case "msal.1-key4-update": // Existing v1 entry with older timestamp - return JSON.stringify({ - lastUpdatedAt: oldTimestamp, - }); - default: - return null; - } - }); - - // Mock decryptData to return the same data - mockStorage.decryptData.mockImplementation( - async (key: string, data: any) => { - return data; - } + await browserCacheManager.setRefreshTokenCredential( + refreshToken, + TEST_CONFIG.CORRELATION_ID, + false // KMSI = false, encryption for security ); - // Mock setUserData - mockStorage.setUserData.mockResolvedValue(undefined); + const key = + browserCacheManager.generateCredentialKey(refreshToken); + const rawValue = window.localStorage.getItem(key); + expect(rawValue).toBeDefined(); - // Spy on performance client - const incrementFieldsSpy = jest.spyOn( - performanceClient, - "incrementFields" - ); + const parsedValue = JSON.parse(rawValue!); + expect(isEncrypted(parsedValue)).toBe(true); + }); - // Execute the function - await sessionStorageCacheManager.updateV0ToCurrent( - CacheKeys.CREDENTIAL_SCHEMA_VERSION, - v0Keys, - v1Keys, - TEST_CONFIG.CORRELATION_ID + it("should NOT encrypt account when KMSI is true", async () => { + const account = { ...TEST_ACCOUNT_ENTITY }; + + await browserCacheManager.setAccount( + account, + TEST_CONFIG.CORRELATION_ID, + true // KMSI = true, NO encryption ); - // Verify all keys were processed - // key1-missing should be removed from v0Keys array - expect(v0Keys).not.toContain("key1-missing"); + const accountInfo = { + homeAccountId: account.homeAccountId, + environment: account.environment, + tenantId: account.realm, + username: account.username, + localAccountId: account.localAccountId, + }; + const key = browserCacheManager.generateAccountKey(accountInfo); + const rawValue = window.localStorage.getItem(key); + expect(rawValue).toBeDefined(); - // key2-expired should be removed from storage and v0Keys array - expect(mockStorage.removeItem).toHaveBeenCalledWith( - "key2-expired" - ); - expect(v0Keys).not.toContain("key2-expired"); + const parsedValue = JSON.parse(rawValue!); + expect(isEncrypted(parsedValue)).toBe(false); + }); - // key3-migrate should be migrated to v1 - expect(mockStorage.setUserData).toHaveBeenCalledWith( - "msal.1-key3-migrate", - expect.any(String), + it("should encrypt account when KMSI is false", async () => { + const account = { ...TEST_ACCOUNT_ENTITY }; + + await browserCacheManager.setAccount( + account, TEST_CONFIG.CORRELATION_ID, - currentTimestamp + false // KMSI = false, encryption for security ); - expect(v1Keys).toContain("msal.1-key3-migrate"); - // key4-update should update existing v1 entry (since v0 timestamp is newer) - expect(mockStorage.setUserData).toHaveBeenCalledWith( - "msal.1-key4-update", - expect.any(String), + const accountInfo = { + homeAccountId: account.homeAccountId, + environment: account.environment, + tenantId: account.realm, + username: account.username, + localAccountId: account.localAccountId, + }; + const key = browserCacheManager.generateAccountKey(accountInfo); + const rawValue = window.localStorage.getItem(key); + expect(rawValue).toBeDefined(); + + const parsedValue = JSON.parse(rawValue!); + expect(isEncrypted(parsedValue)).toBe(true); + }); + + it("should retrieve idToken without decryption when KMSI is true", async () => { + const idToken = { + ...TEST_ID_TOKEN_ENTITY, + secret: TEST_TOKENS.IDTOKEN_V2, + }; + + // Store without encryption (KMSI=true) + await browserCacheManager.setIdTokenCredential( + idToken, TEST_CONFIG.CORRELATION_ID, - currentTimestamp + true ); - // Verify performance counters were incremented correctly - expect(incrementFieldsSpy).toHaveBeenCalledWith( - { expiredCacheRemovedCount: 1 }, + // Retrieve and verify no decryption needed + const key = browserCacheManager.generateCredentialKey(idToken); + const retrieved = browserCacheManager.getIdTokenCredential( + key, TEST_CONFIG.CORRELATION_ID ); - expect(incrementFieldsSpy).toHaveBeenCalledWith( - { upgradedCacheCount: 1 }, - TEST_CONFIG.CORRELATION_ID + + expect(retrieved).toBeDefined(); + expect(retrieved?.secret).toBe(idToken.secret); + expect(retrieved?.homeAccountId).toBe(idToken.homeAccountId); + }); + + it("should retrieve and decrypt accessToken when KMSI is false", async () => { + const accessToken = { + ...TEST_ACCESS_TOKEN_ENTITY, + }; + + // Store with encryption (KMSI=false) + await browserCacheManager.setAccessTokenCredential( + accessToken, + TEST_CONFIG.CORRELATION_ID, + false ); - expect(incrementFieldsSpy).toHaveBeenCalledWith( - { updatedCacheFromV0Count: 1 }, + + // Retrieve and verify decryption works + const key = + browserCacheManager.generateCredentialKey(accessToken); + const retrieved = browserCacheManager.getAccessTokenCredential( + key, TEST_CONFIG.CORRELATION_ID ); - // Verify the function processed all keys and didn't exit early - // Should have called getItem for each v0 key plus v1 entry checks for encrypted keys - expect(mockStorage.getItem).toHaveBeenCalledTimes(6); // 4 v0 keys + 2 v1 key checks (for key3, key4) - - // Verify all valid keys were processed (1 successful new migrations, 1 update) - expect(v1Keys).toHaveLength(2); - expect(v1Keys).toEqual( - expect.arrayContaining([ - "msal.1-key3-migrate", - "msal.1-key4-update", - ]) + expect(retrieved).toBeDefined(); + expect(retrieved?.secret).toBe(accessToken.secret); + expect(retrieved?.homeAccountId).toBe( + accessToken.homeAccountId ); }); }); @@ -1092,11 +1700,13 @@ describe("BrowserCacheManager tests", () => { browserCacheManager.generateCredentialKey(accessToken2); await browserCacheManager.setAccessTokenCredential( accessToken1, - RANDOM_TEST_GUID + RANDOM_TEST_GUID, + true ); await browserCacheManager.setAccessTokenCredential( accessToken2, - RANDOM_TEST_GUID + RANDOM_TEST_GUID, + true ); expect(window.sessionStorage.getItem(atKey1)).toBe( JSON.stringify(accessToken1) @@ -1204,15 +1814,18 @@ describe("BrowserCacheManager tests", () => { browserCacheManager.generateCredentialKey(accessToken3); await browserCacheManager.setAccessTokenCredential( accessToken1, - RANDOM_TEST_GUID + RANDOM_TEST_GUID, + true ); await browserCacheManager.setAccessTokenCredential( accessToken2, - RANDOM_TEST_GUID + RANDOM_TEST_GUID, + true ); await browserCacheManager.setAccessTokenCredential( accessToken3, - RANDOM_TEST_GUID + RANDOM_TEST_GUID, + true ); expect(window.sessionStorage.getItem(atKey1)).toBe( JSON.stringify(accessToken1) @@ -1292,7 +1905,8 @@ describe("BrowserCacheManager tests", () => { atKeys.push(atKey); await browserCacheManager.setAccessTokenCredential( accessToken, - RANDOM_TEST_GUID + RANDOM_TEST_GUID, + true ); expect(window.sessionStorage.getItem(atKey)).toBe( JSON.stringify(accessToken) @@ -1381,11 +1995,13 @@ describe("BrowserCacheManager tests", () => { browserCacheManager.generateCredentialKey(accessToken2); await browserCacheManager.setAccessTokenCredential( accessToken1, - RANDOM_TEST_GUID + RANDOM_TEST_GUID, + true ); await browserCacheManager.setAccessTokenCredential( accessToken2, - RANDOM_TEST_GUID + RANDOM_TEST_GUID, + true ); expect(window.sessionStorage.getItem(atKey1)).toBe( JSON.stringify(accessToken1) @@ -1417,7 +2033,8 @@ describe("BrowserCacheManager tests", () => { newCacheKey, newCacheVal, RANDOM_TEST_GUID, - Date.now().toString() + Date.now().toString(), + true ); // The access token should have been removed from storage @@ -1466,7 +2083,8 @@ describe("BrowserCacheManager tests", () => { atKeys.push(atKey); await browserCacheManager.setAccessTokenCredential( accessToken, - RANDOM_TEST_GUID + RANDOM_TEST_GUID, + true ); expect(window.sessionStorage.getItem(atKey)).toBe( JSON.stringify(accessToken) @@ -1493,7 +2111,8 @@ describe("BrowserCacheManager tests", () => { newCacheKey, newCacheVal, RANDOM_TEST_GUID, - Date.now().toString() + Date.now().toString(), + true ) ).rejects.toEqual( new CacheError(CacheErrorCodes.cacheQuotaExceeded) @@ -1570,15 +2189,18 @@ describe("BrowserCacheManager tests", () => { browserCacheManager.generateCredentialKey(accessToken3); await browserCacheManager.setAccessTokenCredential( accessToken1, - RANDOM_TEST_GUID + RANDOM_TEST_GUID, + true ); await browserCacheManager.setAccessTokenCredential( accessToken2, - RANDOM_TEST_GUID + RANDOM_TEST_GUID, + true ); await browserCacheManager.setAccessTokenCredential( accessToken3, - RANDOM_TEST_GUID + RANDOM_TEST_GUID, + true ); expect(window.sessionStorage.getItem(atKey1)).toBe( JSON.stringify(accessToken1) @@ -1613,7 +2235,8 @@ describe("BrowserCacheManager tests", () => { newCacheKey, newCacheVal, RANDOM_TEST_GUID, - Date.now().toString() + Date.now().toString(), + true ) ).rejects.toEqual( new CacheError(CacheErrorCodes.cacheQuotaExceeded) @@ -1630,7 +2253,7 @@ describe("BrowserCacheManager tests", () => { expect(spy).toHaveBeenCalledTimes(4); // First attempt + 3 attempts after each access token removed }); - it("setItem prioritizes removing v0 tokens before v1 tokens when cache quota is exceeded", async () => { + it("setItem prioritizes removing oldest schema tokens first when cache quota is exceeded", async () => { const browserCacheManager = new BrowserCacheManager( TEST_CONFIG.MSAL_CLIENT_ID, cacheConfig, @@ -1641,70 +2264,41 @@ describe("BrowserCacheManager tests", () => { ); // Create v0 access tokens - const v0AccessToken1 = CacheHelpers.createAccessTokenEntity( - "homeAccountId1", - "environment1", - TEST_TOKENS.ACCESS_TOKEN, - TEST_CONFIG.MSAL_CLIENT_ID, - "tenantId1", - "openid", - 1000, - 1000, - browserCrypto.base64Decode, - 500, - Constants.AuthenticationScheme.BEARER - ); - const v0AccessToken2 = CacheHelpers.createAccessTokenEntity( - "homeAccountId2", - "environment2", - TEST_TOKENS.ACCESS_TOKEN, - TEST_CONFIG.MSAL_CLIENT_ID, - "tenantId2", - "openid", - 1000, - 1000, - browserCrypto.base64Decode, - 500, - Constants.AuthenticationScheme.BEARER - ); + const v0AccessToken1 = TEST_ACCESS_TOKEN_ENTITY; + const v0AccessToken2 = { + ...TEST_ACCESS_TOKEN_ENTITY, + target: "different-scope", + }; // Create v1 access tokens - const v1AccessToken1 = CacheHelpers.createAccessTokenEntity( - "homeAccountId3", - "environment3", - TEST_TOKENS.ACCESS_TOKEN, - TEST_CONFIG.MSAL_CLIENT_ID, - "tenantId3", - "openid", - 1000, - 1000, - browserCrypto.base64Decode, - 500, - Constants.AuthenticationScheme.BEARER - ); - const v1AccessToken2 = CacheHelpers.createAccessTokenEntity( - "homeAccountId4", - "environment4", - TEST_TOKENS.ACCESS_TOKEN, - TEST_CONFIG.MSAL_CLIENT_ID, - "tenantId4", - "openid", - 1000, - 1000, - browserCrypto.base64Decode, - 500, - Constants.AuthenticationScheme.BEARER - ); + const v1AccessToken1 = { + ...TEST_ACCESS_TOKEN_ENTITY, + homeAccountId: "v1-home-account-id", + }; + const v1AccessToken2 = { + ...TEST_ACCESS_TOKEN_ENTITY, + homeAccountId: "v1-home-account-id", + target: "different-scope", + }; + + // Create v2 access tokens + const v2AccessToken1 = { + ...TEST_ACCESS_TOKEN_ENTITY, + homeAccountId: "v2-home-account-id", + }; + const v2AccessToken2 = { + ...TEST_ACCESS_TOKEN_ENTITY, + homeAccountId: "v2-home-account-id", + target: "different-scope", + }; // Generate keys with schema versions - const v0AtKey1 = - browserCacheManager.generateCredentialKey(v0AccessToken1); - const v0AtKey2 = - browserCacheManager.generateCredentialKey(v0AccessToken2); - const v1AtKey1 = - browserCacheManager.generateCredentialKey(v1AccessToken1); - const v1AtKey2 = - browserCacheManager.generateCredentialKey(v1AccessToken2); + const v0AtKey1 = `${v0AccessToken1.homeAccountId}-${v0AccessToken1.environment}-${v0AccessToken1.credentialType}-${v0AccessToken1.clientId}-${v0AccessToken1.target}`; + const v0AtKey2 = `${v0AccessToken2.homeAccountId}-${v0AccessToken2.environment}-${v0AccessToken2.credentialType}-${v0AccessToken2.clientId}-${v0AccessToken2.target}`; + const v1AtKey1 = `msal.1-${v1AccessToken1.homeAccountId}-${v1AccessToken1.environment}-${v1AccessToken1.credentialType}-${v1AccessToken1.clientId}-${v1AccessToken1.target}`; + const v1AtKey2 = `msal.1-${v1AccessToken2.homeAccountId}-${v1AccessToken2.environment}-${v1AccessToken2.credentialType}-${v1AccessToken2.clientId}-${v1AccessToken2.target}`; + const v2AtKey1 = `msal.2|${v2AccessToken1.homeAccountId}|${v2AccessToken1.environment}|${v2AccessToken1.credentialType}|${v2AccessToken1.clientId}|${v2AccessToken1.target}`; + const v2AtKey2 = `msal.2|${v2AccessToken2.homeAccountId}|${v2AccessToken2.environment}|${v2AccessToken2.credentialType}|${v2AccessToken2.clientId}|${v2AccessToken2.target}`; // Store tokens directly in cache window.sessionStorage.setItem( @@ -1723,6 +2317,14 @@ describe("BrowserCacheManager tests", () => { v1AtKey2, JSON.stringify(v1AccessToken2) ); + window.sessionStorage.setItem( + v2AtKey1, + JSON.stringify(v2AccessToken1) + ); + window.sessionStorage.setItem( + v2AtKey2, + JSON.stringify(v2AccessToken2) + ); // Set token keys with schema versions const v0TokenKeys = { @@ -1735,8 +2337,14 @@ describe("BrowserCacheManager tests", () => { accessToken: [v1AtKey1, v1AtKey2], refreshToken: [], }; + const v2TokenKeys = { + idToken: [], + accessToken: [v2AtKey1, v2AtKey2], + refreshToken: [], + }; browserCacheManager.setTokenKeys(v0TokenKeys, RANDOM_TEST_GUID, 0); browserCacheManager.setTokenKeys(v1TokenKeys, RANDOM_TEST_GUID, 1); + browserCacheManager.setTokenKeys(v2TokenKeys, RANDOM_TEST_GUID, 2); // Verify tokens are in cache expect(window.sessionStorage.getItem(v0AtKey1)).toBe( @@ -1751,162 +2359,36 @@ describe("BrowserCacheManager tests", () => { expect(window.sessionStorage.getItem(v1AtKey2)).toBe( JSON.stringify(v1AccessToken2) ); + expect(window.sessionStorage.getItem(v2AtKey1)).toBe( + JSON.stringify(v2AccessToken1) + ); + expect(window.sessionStorage.getItem(v2AtKey2)).toBe( + JSON.stringify(v2AccessToken2) + ); // Verify token keys are set correctly const initialV0TokenKeys = browserCacheManager.getTokenKeys(0); const initialV1TokenKeys = browserCacheManager.getTokenKeys(1); + const initialV2TokenKeys = browserCacheManager.getTokenKeys(2); expect(initialV0TokenKeys.accessToken).toEqual([ v0AtKey1, v0AtKey2, - ]); - expect(initialV1TokenKeys.accessToken).toEqual([ - v1AtKey1, - v1AtKey2, - ]); - - const newCacheKey = "test-cache-entry"; - const newCacheVal = "test-cache-value"; - - // Mock storage to throw quota error twice, then succeed - let callCount = 0; - jest.spyOn(Storage.prototype, "setItem").mockImplementation( - (key, value) => { - if (key === newCacheKey && callCount < 2) { - callCount++; - const error: any = new DOMException( - "The quota has been exceeded", - "QuotaExceededError" - ); - throw error; - } - // Call the original implementation for other keys or after quota errors - jest.restoreAllMocks(); // Restore before calling the original - return window.sessionStorage.setItem(key, value); - } - ); - - browserCacheManager.setItem( - newCacheKey, - newCacheVal, - RANDOM_TEST_GUID - ); - - // First v0 token should be removed, second v0 token should be removed, v1 tokens should remain - expect(window.sessionStorage.getItem(v0AtKey1)).toBeNull(); - expect(window.sessionStorage.getItem(v0AtKey2)).toBeNull(); - expect(window.sessionStorage.getItem(v1AtKey1)).toBe( - JSON.stringify(v1AccessToken1) - ); - expect(window.sessionStorage.getItem(v1AtKey2)).toBe( - JSON.stringify(v1AccessToken2) - ); - - // The new item should be set - expect(window.sessionStorage.getItem(newCacheKey)).toBe( - newCacheVal - ); - - // Token keys should be updated correctly - v0 tokens removed, v1 tokens remain - const updatedV0Keys = browserCacheManager.getTokenKeys(0); - const updatedV1Keys = browserCacheManager.getTokenKeys(1); - expect(updatedV0Keys.accessToken).toEqual([]); - expect(updatedV1Keys.accessToken).toEqual([v1AtKey1, v1AtKey2]); - }); - - it("setItem removes v1 tokens only after all v0 tokens are removed", async () => { - const browserCacheManager = new BrowserCacheManager( - TEST_CONFIG.MSAL_CLIENT_ID, - cacheConfig, - browserCrypto, - logger, - new StubPerformanceClient(), - new EventHandler() - ); - - // Create one v0 token and multiple v1 tokens - const v0AccessToken = CacheHelpers.createAccessTokenEntity( - "homeAccountId1", - "environment1", - TEST_TOKENS.ACCESS_TOKEN, - TEST_CONFIG.MSAL_CLIENT_ID, - "tenantId1", - "openid", - 1000, - 1000, - browserCrypto.base64Decode, - 500, - Constants.AuthenticationScheme.BEARER - ); - - const v1AccessToken1 = CacheHelpers.createAccessTokenEntity( - "homeAccountId2", - "environment2", - TEST_TOKENS.ACCESS_TOKEN, - TEST_CONFIG.MSAL_CLIENT_ID, - "tenantId2", - "openid", - 1000, - 1000, - browserCrypto.base64Decode, - 500, - Constants.AuthenticationScheme.BEARER - ); - - const v1AccessToken2 = CacheHelpers.createAccessTokenEntity( - "homeAccountId3", - "environment3", - TEST_TOKENS.ACCESS_TOKEN, - TEST_CONFIG.MSAL_CLIENT_ID, - "tenantId3", - "openid", - 1000, - 1000, - browserCrypto.base64Decode, - 500, - Constants.AuthenticationScheme.BEARER - ); - - const v0AtKey = - browserCacheManager.generateCredentialKey(v0AccessToken); - const v1AtKey1 = - browserCacheManager.generateCredentialKey(v1AccessToken1); - const v1AtKey2 = - browserCacheManager.generateCredentialKey(v1AccessToken2); - - // Store tokens directly in cache - window.sessionStorage.setItem( - v0AtKey, - JSON.stringify(v0AccessToken) - ); - window.sessionStorage.setItem( + ]); + expect(initialV1TokenKeys.accessToken).toEqual([ v1AtKey1, - JSON.stringify(v1AccessToken1) - ); - window.sessionStorage.setItem( v1AtKey2, - JSON.stringify(v1AccessToken2) - ); - - // Set token keys with schema versions - const v0TokenKeys = { - idToken: [], - accessToken: [v0AtKey], - refreshToken: [], - }; - const v1TokenKeys = { - idToken: [], - accessToken: [v1AtKey1, v1AtKey2], - refreshToken: [], - }; - browserCacheManager.setTokenKeys(v0TokenKeys, RANDOM_TEST_GUID, 0); - browserCacheManager.setTokenKeys(v1TokenKeys, RANDOM_TEST_GUID, 1); + ]); + expect(initialV2TokenKeys.accessToken).toEqual([ + v2AtKey1, + v2AtKey2, + ]); const newCacheKey = "test-cache-entry"; const newCacheVal = "test-cache-value"; - // Mock storage to throw quota error 2 times, then succeed + // Mock storage to throw quota error twice, then succeed let callCount = 0; - jest.spyOn(Storage.prototype, "setItem").mockImplementation( + jest.spyOn(SessionStorage.prototype, "setItem").mockImplementation( (key, value) => { if (key === newCacheKey && callCount < 2) { callCount++; @@ -1916,7 +2398,7 @@ describe("BrowserCacheManager tests", () => { ); throw error; } - jest.restoreAllMocks(); // Restore before calling the original + // Call the original implementation for other keys or after quota errors return window.sessionStorage.setItem(key, value); } ); @@ -1927,130 +2409,92 @@ describe("BrowserCacheManager tests", () => { RANDOM_TEST_GUID ); - // With 2 quota errors, v0 token and first v1 token are removed, second v1 token remains - expect(window.sessionStorage.getItem(v0AtKey)).toBeNull(); - expect(window.sessionStorage.getItem(v1AtKey1)).toBeNull(); + // First v0 tokens should be removed, v1 and v2 tokens should remain + expect(window.sessionStorage.getItem(v0AtKey1)).toBeNull(); + expect(window.sessionStorage.getItem(v0AtKey2)).toBeNull(); + expect(window.sessionStorage.getItem(v1AtKey1)).toBe( + JSON.stringify(v1AccessToken1) + ); expect(window.sessionStorage.getItem(v1AtKey2)).toBe( JSON.stringify(v1AccessToken2) ); + expect(window.sessionStorage.getItem(v2AtKey1)).toBe( + JSON.stringify(v2AccessToken1) + ); + expect(window.sessionStorage.getItem(v2AtKey2)).toBe( + JSON.stringify(v2AccessToken2) + ); - // Token keys should reflect the removal priority + // The new item should be set + expect(window.sessionStorage.getItem(newCacheKey)).toBe( + newCacheVal + ); + + // Token keys should be updated correctly - v0 tokens removed, v1 and v2 tokens remain const updatedV0Keys = browserCacheManager.getTokenKeys(0); const updatedV1Keys = browserCacheManager.getTokenKeys(1); + const updatedV2Keys = browserCacheManager.getTokenKeys(2); expect(updatedV0Keys.accessToken).toEqual([]); - expect(updatedV1Keys.accessToken).toEqual([v1AtKey2]); - }); + expect(updatedV1Keys.accessToken).toEqual([v1AtKey1, v1AtKey2]); + expect(updatedV2Keys.accessToken).toEqual([v2AtKey1, v2AtKey2]); - it("setUserData prioritizes removing v0 tokens before v1 tokens when cache quota is exceeded", async () => { - const browserCacheManager = new BrowserCacheManager( - TEST_CONFIG.MSAL_CLIENT_ID, - cacheConfig, - browserCrypto, - logger, - new StubPerformanceClient(), - new EventHandler() + // Reset callCount to check v1 tokens get removed next + callCount = 0; + const newCacheVal2 = "test-cache-value-2"; + browserCacheManager.setItem( + newCacheKey, + newCacheVal2, + RANDOM_TEST_GUID ); - // Create v0 and v1 access tokens - const v0AccessToken1 = CacheHelpers.createAccessTokenEntity( - "homeAccountId1", - "environment1", - TEST_TOKENS.ACCESS_TOKEN, - TEST_CONFIG.MSAL_CLIENT_ID, - "tenantId1", - "openid", - 1000, - 1000, - browserCrypto.base64Decode, - 500, - Constants.AuthenticationScheme.BEARER + // Now v1 tokens should be removed, v2 tokens should remain + expect(window.sessionStorage.getItem(v1AtKey1)).toBeNull(); + expect(window.sessionStorage.getItem(v1AtKey2)).toBeNull(); + expect(window.sessionStorage.getItem(v2AtKey1)).toBe( + JSON.stringify(v2AccessToken1) ); - - const v1AccessToken1 = CacheHelpers.createAccessTokenEntity( - "homeAccountId2", - "environment2", - TEST_TOKENS.ACCESS_TOKEN, - TEST_CONFIG.MSAL_CLIENT_ID, - "tenantId2", - "openid", - 1000, - 1000, - browserCrypto.base64Decode, - 500, - Constants.AuthenticationScheme.BEARER + expect(window.sessionStorage.getItem(v2AtKey2)).toBe( + JSON.stringify(v2AccessToken2) ); - const v0AtKey1 = - browserCacheManager.generateCredentialKey(v0AccessToken1); - const v1AtKey1 = - browserCacheManager.generateCredentialKey(v1AccessToken1); - - // Store tokens directly in cache - window.sessionStorage.setItem( - v0AtKey1, - JSON.stringify(v0AccessToken1) - ); - window.sessionStorage.setItem( - v1AtKey1, - JSON.stringify(v1AccessToken1) + // The new item should be updated + expect(window.sessionStorage.getItem(newCacheKey)).toBe( + newCacheVal2 ); - // Set token keys with schema versions - const v0TokenKeys = { - idToken: [], - accessToken: [v0AtKey1], - refreshToken: [], - }; - const v1TokenKeys = { - idToken: [], - accessToken: [v1AtKey1], - refreshToken: [], - }; - browserCacheManager.setTokenKeys(v0TokenKeys, RANDOM_TEST_GUID, 0); - browserCacheManager.setTokenKeys(v1TokenKeys, RANDOM_TEST_GUID, 1); - - const newCacheKey = "test-cache-entry"; - const newCacheVal = "test-cache-value"; - - // Mock setUserData to throw quota error once, then succeed - let callCount = 0; - jest.spyOn( - // @ts-ignore - browserCacheManager.browserStorage, - "setUserData" - ).mockImplementation(async () => { - if (callCount < 1) { - callCount++; - const error: any = new DOMException( - "The quota has been exceeded", - "QuotaExceededError" - ); - throw error; - } - return Promise.resolve(); - }); - - await browserCacheManager.setUserData( + // Token keys should be updated correctly - v0 and v1 tokens removed, v2 tokens remain + const finalV0Keys = browserCacheManager.getTokenKeys(0); + const finalV1Keys = browserCacheManager.getTokenKeys(1); + const finalV2Keys = browserCacheManager.getTokenKeys(2); + expect(finalV0Keys.accessToken).toEqual([]); + expect(finalV1Keys.accessToken).toEqual([]); + expect(finalV2Keys.accessToken).toEqual([v2AtKey1, v2AtKey2]); + + // Reset callCount again to test v2 token removal + callCount = 0; + const newCacheVal3 = "test-cache-value-3"; + browserCacheManager.setItem( newCacheKey, - newCacheVal, - RANDOM_TEST_GUID, - Date.now().toString() + newCacheVal3, + RANDOM_TEST_GUID ); - // v0 token should be removed first, v1 token should remain - expect(window.sessionStorage.getItem(v0AtKey1)).toBeNull(); - expect(window.sessionStorage.getItem(v1AtKey1)).toBe( - JSON.stringify(v1AccessToken1) + // Now v2 tokens should be removed as well + expect(window.sessionStorage.getItem(v2AtKey1)).toBeNull(); + expect(window.sessionStorage.getItem(v2AtKey2)).toBeNull(); + + // The new item should be updated + expect(window.sessionStorage.getItem(newCacheKey)).toBe( + newCacheVal3 ); - // Token keys should be updated correctly - const updatedV0Keys = browserCacheManager.getTokenKeys(0); - const updatedV1Keys = browserCacheManager.getTokenKeys(1); - expect(updatedV0Keys.accessToken).toEqual([]); - expect(updatedV1Keys.accessToken).toEqual([v1AtKey1]); + // All token keys should be cleared + const finalV2KeysAfterAllRemovals = + browserCacheManager.getTokenKeys(2); + expect(finalV2KeysAfterAllRemovals.accessToken).toEqual([]); }); - it("setUserData removes v1 tokens only after all v0 tokens are exhausted", async () => { + it("setUserData prioritizes removing oldest schema tokens first when cache quota is exceeded", async () => { const browserCacheManager = new BrowserCacheManager( TEST_CONFIG.MSAL_CLIENT_ID, cacheConfig, @@ -2060,55 +2504,42 @@ describe("BrowserCacheManager tests", () => { new EventHandler() ); - // Create mixed v0 and v1 tokens - const v0AccessToken1 = CacheHelpers.createAccessTokenEntity( - "homeAccountId1", - "environment1", - TEST_TOKENS.ACCESS_TOKEN, - TEST_CONFIG.MSAL_CLIENT_ID, - "tenantId1", - "openid", - 1000, - 1000, - browserCrypto.base64Decode, - 500, - Constants.AuthenticationScheme.BEARER - ); + // Create v0 access tokens + const v0AccessToken1 = TEST_ACCESS_TOKEN_ENTITY; + const v0AccessToken2 = { + ...TEST_ACCESS_TOKEN_ENTITY, + target: "different-scope", + }; - const v0AccessToken2 = CacheHelpers.createAccessTokenEntity( - "homeAccountId2", - "environment2", - TEST_TOKENS.ACCESS_TOKEN, - TEST_CONFIG.MSAL_CLIENT_ID, - "tenantId2", - "openid", - 1000, - 1000, - browserCrypto.base64Decode, - 500, - Constants.AuthenticationScheme.BEARER - ); + // Create v1 access tokens + const v1AccessToken1 = { + ...TEST_ACCESS_TOKEN_ENTITY, + homeAccountId: "v1-home-account-id", + }; + const v1AccessToken2 = { + ...TEST_ACCESS_TOKEN_ENTITY, + homeAccountId: "v1-home-account-id", + target: "different-scope", + }; - const v1AccessToken1 = CacheHelpers.createAccessTokenEntity( - "homeAccountId3", - "environment3", - TEST_TOKENS.ACCESS_TOKEN, - TEST_CONFIG.MSAL_CLIENT_ID, - "tenantId3", - "openid", - 1000, - 1000, - browserCrypto.base64Decode, - 500, - Constants.AuthenticationScheme.BEARER - ); + // Create v2 access tokens + const v2AccessToken1 = { + ...TEST_ACCESS_TOKEN_ENTITY, + homeAccountId: "v2-home-account-id", + }; + const v2AccessToken2 = { + ...TEST_ACCESS_TOKEN_ENTITY, + homeAccountId: "v2-home-account-id", + target: "different-scope", + }; - const v0AtKey1 = - browserCacheManager.generateCredentialKey(v0AccessToken1); - const v0AtKey2 = - browserCacheManager.generateCredentialKey(v0AccessToken2); - const v1AtKey1 = - browserCacheManager.generateCredentialKey(v1AccessToken1); + // Generate keys with schema versions + const v0AtKey1 = `${v0AccessToken1.homeAccountId}-${v0AccessToken1.environment}-${v0AccessToken1.credentialType}-${v0AccessToken1.clientId}-${v0AccessToken1.target}`; + const v0AtKey2 = `${v0AccessToken2.homeAccountId}-${v0AccessToken2.environment}-${v0AccessToken2.credentialType}-${v0AccessToken2.clientId}-${v0AccessToken2.target}`; + const v1AtKey1 = `msal.1-${v1AccessToken1.homeAccountId}-${v1AccessToken1.environment}-${v1AccessToken1.credentialType}-${v1AccessToken1.clientId}-${v1AccessToken1.target}`; + const v1AtKey2 = `msal.1-${v1AccessToken2.homeAccountId}-${v1AccessToken2.environment}-${v1AccessToken2.credentialType}-${v1AccessToken2.clientId}-${v1AccessToken2.target}`; + const v2AtKey1 = `msal.2|${v2AccessToken1.homeAccountId}|${v2AccessToken1.environment}|${v2AccessToken1.credentialType}|${v2AccessToken1.clientId}|${v2AccessToken1.target}`; + const v2AtKey2 = `msal.2|${v2AccessToken2.homeAccountId}|${v2AccessToken2.environment}|${v2AccessToken2.credentialType}|${v2AccessToken2.clientId}|${v2AccessToken2.target}`; // Store tokens directly in cache window.sessionStorage.setItem( @@ -2123,6 +2554,18 @@ describe("BrowserCacheManager tests", () => { v1AtKey1, JSON.stringify(v1AccessToken1) ); + window.sessionStorage.setItem( + v1AtKey2, + JSON.stringify(v1AccessToken2) + ); + window.sessionStorage.setItem( + v2AtKey1, + JSON.stringify(v2AccessToken1) + ); + window.sessionStorage.setItem( + v2AtKey2, + JSON.stringify(v2AccessToken2) + ); // Set token keys with schema versions const v0TokenKeys = { @@ -2132,50 +2575,133 @@ describe("BrowserCacheManager tests", () => { }; const v1TokenKeys = { idToken: [], - accessToken: [v1AtKey1], + accessToken: [v1AtKey1, v1AtKey2], + refreshToken: [], + }; + const v2TokenKeys = { + idToken: [], + accessToken: [v2AtKey1, v2AtKey2], refreshToken: [], }; browserCacheManager.setTokenKeys(v0TokenKeys, RANDOM_TEST_GUID, 0); browserCacheManager.setTokenKeys(v1TokenKeys, RANDOM_TEST_GUID, 1); + browserCacheManager.setTokenKeys(v2TokenKeys, RANDOM_TEST_GUID, 2); const newCacheKey = "test-cache-entry"; const newCacheVal = "test-cache-value"; - // Mock setUserData to throw quota error 3 times, then succeed + // Mock setUserData to throw quota error once, then succeed let callCount = 0; - jest.spyOn( - // @ts-ignore - browserCacheManager.browserStorage, - "setUserData" - ).mockImplementation(async () => { - if (callCount < 3) { - callCount++; - const error: any = new DOMException( - "The quota has been exceeded", - "QuotaExceededError" - ); - throw error; + jest.spyOn(SessionStorage.prototype, "setItem").mockImplementation( + (key, value) => { + if (key === newCacheKey && callCount < 2) { + callCount++; + const error: any = new DOMException( + "The quota has been exceeded", + "QuotaExceededError" + ); + throw error; + } + // Call the original implementation for other keys or after quota errors + return window.sessionStorage.setItem(key, value); } - return Promise.resolve(); - }); + ); await browserCacheManager.setUserData( newCacheKey, newCacheVal, RANDOM_TEST_GUID, - Date.now().toString() + Date.now().toString(), + true ); - // All v0 tokens should be removed first, then v1 tokens + // First v0 tokens should be removed, v1 and v2 tokens should remain expect(window.sessionStorage.getItem(v0AtKey1)).toBeNull(); expect(window.sessionStorage.getItem(v0AtKey2)).toBeNull(); - expect(window.sessionStorage.getItem(v1AtKey1)).toBeNull(); + expect(window.sessionStorage.getItem(v1AtKey1)).toBe( + JSON.stringify(v1AccessToken1) + ); + expect(window.sessionStorage.getItem(v1AtKey2)).toBe( + JSON.stringify(v1AccessToken2) + ); + expect(window.sessionStorage.getItem(v2AtKey1)).toBe( + JSON.stringify(v2AccessToken1) + ); + expect(window.sessionStorage.getItem(v2AtKey2)).toBe( + JSON.stringify(v2AccessToken2) + ); + + // The new item should be set + expect(window.sessionStorage.getItem(newCacheKey)).toBe( + newCacheVal + ); - // Token keys should be updated to reflect removals + // Token keys should be updated correctly - v0 tokens removed, v1 and v2 tokens remain const updatedV0Keys = browserCacheManager.getTokenKeys(0); const updatedV1Keys = browserCacheManager.getTokenKeys(1); + const updatedV2Keys = browserCacheManager.getTokenKeys(2); expect(updatedV0Keys.accessToken).toEqual([]); - expect(updatedV1Keys.accessToken).toEqual([]); + expect(updatedV1Keys.accessToken).toEqual([v1AtKey1, v1AtKey2]); + expect(updatedV2Keys.accessToken).toEqual([v2AtKey1, v2AtKey2]); + + // Reset callCount to check v1 tokens get removed next + callCount = 0; + const newCacheVal2 = "test-cache-value-2"; + await browserCacheManager.setUserData( + newCacheKey, + newCacheVal2, + RANDOM_TEST_GUID, + Date.now().toString(), + true + ); + + // Now v1 tokens should be removed, v2 tokens should remain + expect(window.sessionStorage.getItem(v1AtKey1)).toBeNull(); + expect(window.sessionStorage.getItem(v1AtKey2)).toBeNull(); + expect(window.sessionStorage.getItem(v2AtKey1)).toBe( + JSON.stringify(v2AccessToken1) + ); + expect(window.sessionStorage.getItem(v2AtKey2)).toBe( + JSON.stringify(v2AccessToken2) + ); + + // The new item should be updated + expect(window.sessionStorage.getItem(newCacheKey)).toBe( + newCacheVal2 + ); + + // Token keys should be updated correctly - v0 and v1 tokens removed, v2 tokens remain + const finalV0Keys = browserCacheManager.getTokenKeys(0); + const finalV1Keys = browserCacheManager.getTokenKeys(1); + const finalV2Keys = browserCacheManager.getTokenKeys(2); + expect(finalV0Keys.accessToken).toEqual([]); + expect(finalV1Keys.accessToken).toEqual([]); + expect(finalV2Keys.accessToken).toEqual([v2AtKey1, v2AtKey2]); + + // Reset callCount again to test v2 token removal + callCount = 0; + const newCacheVal3 = "test-cache-value-3"; + await browserCacheManager.setUserData( + newCacheKey, + newCacheVal3, + RANDOM_TEST_GUID, + Date.now().toString(), + true + ); + + // Now v2 tokens should be removed as well + expect(window.sessionStorage.getItem(v2AtKey1)).toBeNull(); + expect(window.sessionStorage.getItem(v2AtKey2)).toBeNull(); + + // The new item should be updated + expect(window.sessionStorage.getItem(newCacheKey)).toBe( + newCacheVal3 + ); + + // All token keys should be cleared + const finalV2KeysAfterAllRemovals = + browserCacheManager.getTokenKeys(2); + expect(finalV2KeysAfterAllRemovals.accessToken).toEqual([]); }); it("removeItem()", () => { @@ -2286,7 +2812,8 @@ describe("BrowserCacheManager tests", () => { await browserLocalStorage.setAccount( testAccount, - TEST_CONFIG.CORRELATION_ID + TEST_CONFIG.CORRELATION_ID, + true ); expect( browserLocalStorage.getAccount( @@ -2299,7 +2826,8 @@ describe("BrowserCacheManager tests", () => { await browserSessionStorage.setAccount( testAccount, - TEST_CONFIG.CORRELATION_ID + TEST_CONFIG.CORRELATION_ID, + true ); expect( @@ -2389,7 +2917,8 @@ describe("BrowserCacheManager tests", () => { await browserLocalStorage.setIdTokenCredential( testIdToken, - TEST_CONFIG.CORRELATION_ID + TEST_CONFIG.CORRELATION_ID, + true ); expect( browserLocalStorage.getIdTokenCredential( @@ -2402,7 +2931,8 @@ describe("BrowserCacheManager tests", () => { await browserSessionStorage.setIdTokenCredential( testIdToken, - TEST_CONFIG.CORRELATION_ID + TEST_CONFIG.CORRELATION_ID, + true ); expect( @@ -2500,7 +3030,8 @@ describe("BrowserCacheManager tests", () => { await browserLocalStorage.setAccessTokenCredential( testAccessToken, - TEST_CONFIG.CORRELATION_ID + TEST_CONFIG.CORRELATION_ID, + true ); expect( browserLocalStorage.getAccessTokenCredential( @@ -2513,7 +3044,8 @@ describe("BrowserCacheManager tests", () => { await browserSessionStorage.setAccessTokenCredential( testAccessToken, - TEST_CONFIG.CORRELATION_ID + TEST_CONFIG.CORRELATION_ID, + true ); expect( @@ -2560,11 +3092,13 @@ describe("BrowserCacheManager tests", () => { // Cache bearer token await browserLocalStorage.setAccessTokenCredential( testAccessTokenWithoutAuthScheme, - TEST_CONFIG.CORRELATION_ID + TEST_CONFIG.CORRELATION_ID, + true ); await browserLocalStorage.setAccessTokenCredential( testAccessTokenWithAuthScheme, - TEST_CONFIG.CORRELATION_ID + TEST_CONFIG.CORRELATION_ID, + true ); expect( browserLocalStorage.getAccessTokenCredential( @@ -2618,11 +3152,13 @@ describe("BrowserCacheManager tests", () => { // Cache bearer token await browserLocalStorage.setAccessTokenCredential( testAccessTokenWithoutAuthScheme, - TEST_CONFIG.CORRELATION_ID + TEST_CONFIG.CORRELATION_ID, + true ); await browserLocalStorage.setAccessTokenCredential( testAccessTokenWithAuthScheme, - TEST_CONFIG.CORRELATION_ID + TEST_CONFIG.CORRELATION_ID, + true ); expect( browserLocalStorage.getAccessTokenCredential( @@ -2643,11 +3179,13 @@ describe("BrowserCacheManager tests", () => { await browserSessionStorage.setAccessTokenCredential( testAccessTokenWithoutAuthScheme, - TEST_CONFIG.CORRELATION_ID + TEST_CONFIG.CORRELATION_ID, + true ); await browserSessionStorage.setAccessTokenCredential( testAccessTokenWithAuthScheme, - TEST_CONFIG.CORRELATION_ID + TEST_CONFIG.CORRELATION_ID, + true ); expect( @@ -2702,11 +3240,13 @@ describe("BrowserCacheManager tests", () => { // Cache bearer token await browserLocalStorage.setAccessTokenCredential( testAccessTokenWithoutAuthScheme, - TEST_CONFIG.CORRELATION_ID + TEST_CONFIG.CORRELATION_ID, + true ); await browserLocalStorage.setAccessTokenCredential( testAccessTokenWithAuthScheme, - TEST_CONFIG.CORRELATION_ID + TEST_CONFIG.CORRELATION_ID, + true ); expect( browserLocalStorage.getAccessTokenCredential( @@ -2729,11 +3269,13 @@ describe("BrowserCacheManager tests", () => { await browserSessionStorage.setAccessTokenCredential( testAccessTokenWithoutAuthScheme, - TEST_CONFIG.CORRELATION_ID + TEST_CONFIG.CORRELATION_ID, + true ); await browserSessionStorage.setAccessTokenCredential( testAccessTokenWithAuthScheme, - TEST_CONFIG.CORRELATION_ID + TEST_CONFIG.CORRELATION_ID, + true ); expect( @@ -2793,11 +3335,13 @@ describe("BrowserCacheManager tests", () => { await browserLocalStorage.setAccessTokenCredential( accessToken1, - TEST_CONFIG.CORRELATION_ID + TEST_CONFIG.CORRELATION_ID, + true ); await browserLocalStorage.setAccessTokenCredential( accessToken2, - TEST_CONFIG.CORRELATION_ID + TEST_CONFIG.CORRELATION_ID, + true ); // At this point, order should be [accessTokenKey, anotherAccessTokenKey] @@ -2808,7 +3352,8 @@ describe("BrowserCacheManager tests", () => { // Set the first token again, it should move to the end await browserLocalStorage.setAccessTokenCredential( accessToken1, - TEST_CONFIG.CORRELATION_ID + TEST_CONFIG.CORRELATION_ID, + true ); // Now the order should be [anotherAccessTokenKey, accessTokenKey] @@ -2896,7 +3441,8 @@ describe("BrowserCacheManager tests", () => { await browserLocalStorage.setRefreshTokenCredential( testRefreshToken, - TEST_CONFIG.CORRELATION_ID + TEST_CONFIG.CORRELATION_ID, + true ); expect( browserLocalStorage.getRefreshTokenCredential( @@ -2909,7 +3455,8 @@ describe("BrowserCacheManager tests", () => { await browserSessionStorage.setRefreshTokenCredential( testRefreshToken, - TEST_CONFIG.CORRELATION_ID + TEST_CONFIG.CORRELATION_ID, + true ); expect( @@ -3464,13 +4011,15 @@ describe("BrowserCacheManager tests", () => { cacheManager .setAccessTokenCredential( testAccessToken, - TEST_CONFIG.CORRELATION_ID + TEST_CONFIG.CORRELATION_ID, + true ) .then(() => cacheManager .saveCacheRecord( {}, "test-correlation-id", + true, undefined ) .then(() => { diff --git a/lib/msal-browser/test/cache/LocalStorage.spec.ts b/lib/msal-browser/test/cache/LocalStorage.spec.ts index 6a6e7429ea..1cf7d415a8 100644 --- a/lib/msal-browser/test/cache/LocalStorage.spec.ts +++ b/lib/msal-browser/test/cache/LocalStorage.spec.ts @@ -30,25 +30,29 @@ describe("LocalStorage tests", () => { idTokenKey, idTokenVal, TEST_CONFIG.CORRELATION_ID, - Date.now().toString() + Date.now().toString(), + false ); await localStorageInstance.setUserData( accessTokenKey, accessTokenVal, TEST_CONFIG.CORRELATION_ID, - Date.now().toString() + Date.now().toString(), + false ); await localStorageInstance.setUserData( refreshTokenKey, refreshTokenVal, TEST_CONFIG.CORRELATION_ID, - Date.now().toString() + Date.now().toString(), + false ); await localStorageInstance.setUserData( accountKey, accountVal, TEST_CONFIG.CORRELATION_ID, - Date.now().toString() + Date.now().toString(), + false ); localStorage.setItem( @@ -169,7 +173,8 @@ describe("LocalStorage tests", () => { "testKey", "testVal", TEST_CONFIG.CORRELATION_ID, - Date.now().toString() + Date.now().toString(), + false ); expect(localStorage.getItem("testKey")).toBeTruthy(); // Encrypted expect(localStorageInstance.getUserData("testKey")).toBe("testVal"); // From in-memory @@ -218,7 +223,8 @@ describe("LocalStorage tests", () => { "testKey", "testVal", TEST_CONFIG.CORRELATION_ID, - Date.now().toString() + Date.now().toString(), + false ); const encrypted = localStorage.getItem("testKey") || ""; @@ -228,6 +234,26 @@ describe("LocalStorage tests", () => { expect(localStorageInstance.getUserData("testKey")).toBe("testVal"); }); + it("setUserData stores unencrypted when KMSI is true", async () => { + const localStorageInstance = new LocalStorage( + TEST_CONFIG.MSAL_CLIENT_ID, + logger, + performanceClient + ); + await localStorageInstance.initialize(TEST_CONFIG.CORRELATION_ID); + + await localStorageInstance.setUserData( + "testKey", + "testVal", + TEST_CONFIG.CORRELATION_ID, + Date.now().toString(), + true + ); + + expect(localStorage.getItem("testKey")).toBe("testVal"); + expect(localStorageInstance.getUserData("testKey")).toBe("testVal"); + }); + it("setUserData broadcasts cache update to other LocalStorage instances", async () => { const localStorageInstance1 = new LocalStorage( TEST_CONFIG.MSAL_CLIENT_ID, @@ -252,7 +278,8 @@ describe("LocalStorage tests", () => { "testKey", "testVal", TEST_CONFIG.CORRELATION_ID, - Date.now().toString() + Date.now().toString(), + false ); expect(localStorageInstance1.getUserData("testKey")).toBe("testVal"); diff --git a/lib/msal-browser/test/cache/TokenCache.spec.ts b/lib/msal-browser/test/cache/TokenCache.spec.ts index 393b5c3e3a..3e409cf424 100644 --- a/lib/msal-browser/test/cache/TokenCache.spec.ts +++ b/lib/msal-browser/test/cache/TokenCache.spec.ts @@ -168,7 +168,8 @@ describe("TokenCache tests", () => { expect(result.idToken).toEqual(testIdToken); expect(setSpy).toHaveBeenCalledWith( expect.objectContaining({ secret: testIdToken }), - expect.anything() + expect.anything(), + false ); }); @@ -198,7 +199,8 @@ describe("TokenCache tests", () => { expect(result.idToken).toEqual(testIdToken); expect(setSpy).toHaveBeenCalledWith( expect.objectContaining({ secret: testIdToken }), - expect.anything() + expect.anything(), + false ); }); @@ -236,7 +238,8 @@ describe("TokenCache tests", () => { expect(result.account).toEqual(testAccountInfo); expect(setSpy).toHaveBeenCalledWith( expect.objectContaining({ secret: testIdToken }), - expect.anything() + expect.anything(), + false ); expect( browserStorage.getAccount(testAccountKey, RANDOM_TEST_GUID) @@ -268,7 +271,8 @@ describe("TokenCache tests", () => { expect(result.idToken).toEqual(testIdToken); expect(setSpy).toHaveBeenCalledWith( expect.objectContaining({ secret: testIdToken }), - expect.anything() + expect.anything(), + false ); }); @@ -351,7 +355,8 @@ describe("TokenCache tests", () => { expect(result.idToken).toEqual(TEST_TOKENS.IDTOKEN_V2); expect(idSpy).toHaveBeenCalledWith( expect.objectContaining({ secret: testIdToken }), - expect.anything() + expect.anything(), + false ); expect(result.accessToken).toEqual(""); expect(accessSpy).not.toHaveBeenCalled(); @@ -392,7 +397,8 @@ describe("TokenCache tests", () => { expect(result.accessToken).toEqual(testAccessToken); expect(accessSpy).toHaveBeenCalledWith( expect.objectContaining({ secret: testAccessToken }), - expect.anything() + expect.anything(), + false ); }); @@ -444,7 +450,8 @@ describe("TokenCache tests", () => { expect(refreshSpy).toHaveBeenCalledWith( expect.objectContaining({ secret: testRefreshToken }), - expect.anything() + expect.anything(), + false ); }); @@ -486,7 +493,8 @@ describe("TokenCache tests", () => { // Validate tokens can be retrieved expect(refreshSpy).toHaveBeenCalledWith( expect.objectContaining({ secret: testRefreshToken }), - expect.anything() + expect.anything(), + false ); }); @@ -528,11 +536,13 @@ describe("TokenCache tests", () => { expect(result.idToken).toEqual(TEST_TOKENS.IDTOKEN_V2); expect(idSpy).toHaveBeenCalledWith( expect.objectContaining({ secret: testIdToken }), - expect.anything() + expect.anything(), + false ); expect(refreshSpy).toHaveBeenCalledWith( expect.objectContaining({ secret: testRefreshToken }), - expect.anything() + expect.anything(), + false ); }); }); diff --git a/lib/msal-browser/test/custom_auth/get_account/interaction_client/CustomAuthSilentCacheClient.spec.ts b/lib/msal-browser/test/custom_auth/get_account/interaction_client/CustomAuthSilentCacheClient.spec.ts index 3a81b73bec..9e8adf0435 100644 --- a/lib/msal-browser/test/custom_auth/get_account/interaction_client/CustomAuthSilentCacheClient.spec.ts +++ b/lib/msal-browser/test/custom_auth/get_account/interaction_client/CustomAuthSilentCacheClient.spec.ts @@ -410,18 +410,20 @@ async function saveTokensIntoCache( refreshTokenEntity?: RefreshTokenEntity ): Promise { accountEntity - ? await cacheManager.setAccount(accountEntity, correlationId) + ? await cacheManager.setAccount(accountEntity, correlationId, true) : null; accessTokenEntity ? await cacheManager.setAccessTokenCredential( accessTokenEntity, - correlationId + correlationId, + true ) : null; refreshTokenEntity ? await cacheManager.setRefreshTokenCredential( refreshTokenEntity, - correlationId + correlationId, + true ) : null; } diff --git a/lib/msal-browser/test/interaction_client/RedirectClient.spec.ts b/lib/msal-browser/test/interaction_client/RedirectClient.spec.ts index 07344e0d66..20491e8013 100644 --- a/lib/msal-browser/test/interaction_client/RedirectClient.spec.ts +++ b/lib/msal-browser/test/interaction_client/RedirectClient.spec.ts @@ -45,10 +45,9 @@ import { InProgressPerformanceEvent, StubPerformanceClient, ProtocolMode, - AccessTokenEntity, AccountEntityUtils, Constants, -} from "@azure/msal-common"; +} from "@azure/msal-common/browser"; import * as BrowserUtils from "../../src/utils/BrowserUtils.js"; import { TemporaryCacheKeys, @@ -67,7 +66,6 @@ import { import { CryptoOps } from "../../src/crypto/CryptoOps.js"; import * as BrowserCrypto from "../../src/crypto/BrowserCrypto.js"; import * as PkceGenerator from "../../src/crypto/PkceGenerator.js"; -import * as AuthorizeProtocol from "../../src/protocol/Authorize.js"; import { BrowserCacheManager } from "../../src/cache/BrowserCacheManager.js"; import { RedirectRequest } from "../../src/request/RedirectRequest.js"; import { NavigationClient } from "../../src/navigation/NavigationClient.js"; @@ -2482,7 +2480,7 @@ describe("RedirectClient", () => { } ); browserStorage - .setAccount(testAccount, TEST_CONFIG.CORRELATION_ID) + .setAccount(testAccount, TEST_CONFIG.CORRELATION_ID, true) .then(() => redirectClient.logout({ account: testAccountInfo }) ); @@ -2544,7 +2542,7 @@ describe("RedirectClient", () => { } ); browserStorage - .setAccount(testAccount, TEST_CONFIG.CORRELATION_ID) + .setAccount(testAccount, TEST_CONFIG.CORRELATION_ID, true) .then(() => redirectClient.logout({ account: testAccountInfo, @@ -2634,11 +2632,13 @@ describe("RedirectClient", () => { await browserStorage.setAccount( testAccountEntity, - TEST_CONFIG.CORRELATION_ID + TEST_CONFIG.CORRELATION_ID, + true ); await browserStorage.setIdTokenCredential( testIdToken, - TEST_CONFIG.CORRELATION_ID + TEST_CONFIG.CORRELATION_ID, + true ); pca.setActiveAccount(testAccountInfo); @@ -2828,7 +2828,7 @@ describe("RedirectClient", () => { browserStorage2.setInteractionInProgress(true); browserStorage2 - .setAccount(testAccount, TEST_CONFIG.CORRELATION_ID) + .setAccount(testAccount, TEST_CONFIG.CORRELATION_ID, true) .then(() => redirectClient2 .logout({ @@ -2941,7 +2941,7 @@ describe("RedirectClient", () => { browserStorage3.setInteractionInProgress(true); browserStorage3 - .setAccount(testAccount, TEST_CONFIG.CORRELATION_ID) + .setAccount(testAccount, TEST_CONFIG.CORRELATION_ID, true) .then(() => redirectClient3 .logout({ diff --git a/lib/msal-browser/test/utils/StringConstants.ts b/lib/msal-browser/test/utils/StringConstants.ts index bd5fa0b7fc..601c1a356f 100644 --- a/lib/msal-browser/test/utils/StringConstants.ts +++ b/lib/msal-browser/test/utils/StringConstants.ts @@ -14,6 +14,12 @@ import { version } from "../../src/packageMetadata.js"; import { base64Decode, base64DecToArr } from "../../src/encode/Base64Decode.js"; import { urlEncodeArr } from "../../src/encode/Base64Encode.js"; import { AuthenticationResult } from "../../src/response/AuthenticationResult.js"; +import { + AccessTokenEntity, + AccountEntity, + IdTokenEntity, + RefreshTokenEntity, +} from "@azure/msal-common/browser"; /** * This file contains the string constants used by the test classes. @@ -624,3 +630,51 @@ export const PlatformDOMTestErrorResponseObject = { protocolError: "", }, }; + +export const TEST_ID_TOKEN_ENTITY: IdTokenEntity = { + homeAccountId: TEST_DATA_CLIENT_INFO.TEST_HOME_ACCOUNT_ID, + environment: "login.windows.net", + credentialType: Constants.CredentialType.ID_TOKEN, + clientId: TEST_CONFIG.MSAL_CLIENT_ID, + realm: ID_TOKEN_CLAIMS.tid, + secret: TEST_TOKENS.IDTOKEN_V2, + lastUpdatedAt: Date.now().toString(), +}; + +export const TEST_ACCESS_TOKEN_ENTITY: AccessTokenEntity = { + homeAccountId: TEST_DATA_CLIENT_INFO.TEST_HOME_ACCOUNT_ID, + environment: "login.windows.net", + credentialType: Constants.CredentialType.ACCESS_TOKEN, + clientId: TEST_CONFIG.MSAL_CLIENT_ID, + realm: ID_TOKEN_CLAIMS.tid, + target: "user.read mail.read", + secret: TEST_TOKENS.ACCESS_TOKEN, + expiresOn: (TimeUtils.nowSeconds() + 3600).toString(), + cachedAt: Date.now().toString(), + tokenType: "Bearer", + lastUpdatedAt: Date.now().toString(), +}; + +export const TEST_REFRESH_TOKEN_ENTITY: RefreshTokenEntity = { + homeAccountId: TEST_DATA_CLIENT_INFO.TEST_HOME_ACCOUNT_ID, + environment: "login.windows.net", + credentialType: Constants.CredentialType.REFRESH_TOKEN, + clientId: TEST_CONFIG.MSAL_CLIENT_ID, + realm: ID_TOKEN_CLAIMS.tid, + secret: TEST_TOKENS.REFRESH_TOKEN, + expiresOn: (TimeUtils.nowSeconds() + 3600).toString(), + lastUpdatedAt: Date.now().toString(), +}; + +export const TEST_ACCOUNT_ENTITY: AccountEntity = { + homeAccountId: TEST_DATA_CLIENT_INFO.TEST_HOME_ACCOUNT_ID, + environment: "login.windows.net", + realm: ID_TOKEN_CLAIMS.tid, + localAccountId: TEST_DATA_CLIENT_INFO.TEST_LOCAL_ACCOUNT_ID, + username: ID_TOKEN_CLAIMS.preferred_username, + authorityType: "MSSTS", + name: ID_TOKEN_CLAIMS.name, + clientInfo: TEST_DATA_CLIENT_INFO.TEST_RAW_CLIENT_INFO, + lastUpdatedAt: Date.now().toString(), + tenantProfiles: [testTenantProfilesMap.get(ID_TOKEN_CLAIMS.tid)], +}; diff --git a/lib/msal-common/apiReview/msal-common.api.md b/lib/msal-common/apiReview/msal-common.api.md index 64e8bcf836..f8277db35a 100644 --- a/lib/msal-common/apiReview/msal-common.api.md +++ b/lib/msal-common/apiReview/msal-common.api.md @@ -883,6 +883,7 @@ const authTimeNotFound = "auth_time_not_found"; declare namespace AuthToken { export { extractTokenClaims, + isKmsi, getJWSPayload, checkMaxAge } @@ -1242,13 +1243,13 @@ export abstract class CacheManager implements ICacheManager { // Warning: (tsdoc-param-tag-with-invalid-type) The @param block should not include a JSDoc-style '{type}' // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen // Warning: (tsdoc-param-tag-with-invalid-type) The @param block should not include a JSDoc-style '{type}' - saveCacheRecord(cacheRecord: CacheRecord, correlationId: string, storeInCache?: StoreInCache): Promise; + saveCacheRecord(cacheRecord: CacheRecord, correlationId: string, kmsi: boolean, storeInCache?: StoreInCache): Promise; // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen - abstract setAccessTokenCredential(accessToken: AccessTokenEntity, correlationId: string): Promise; + abstract setAccessTokenCredential(accessToken: AccessTokenEntity, correlationId: string, kmsi: boolean): Promise; // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen - abstract setAccount(account: AccountEntity, correlationId: string): Promise; + abstract setAccount(account: AccountEntity, correlationId: string, kmsi: boolean): Promise; // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen abstract setAppMetadata(appMetadata: AppMetadataEntity, correlationId: string): void; // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen @@ -1257,10 +1258,10 @@ export abstract class CacheManager implements ICacheManager { abstract setAuthorityMetadata(key: string, value: AuthorityMetadataEntity, correlationId: string): void; // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen - abstract setIdTokenCredential(idToken: IdTokenEntity, correlationId: string): Promise; + abstract setIdTokenCredential(idToken: IdTokenEntity, correlationId: string, kmsi: boolean): Promise; // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen - abstract setRefreshTokenCredential(refreshToken: RefreshTokenEntity, correlationId: string): Promise; + abstract setRefreshTokenCredential(refreshToken: RefreshTokenEntity, correlationId: string, kmsi: boolean): Promise; // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen @@ -2798,6 +2799,12 @@ export interface ISerializableTokenCache { // @public function isIdTokenEntity(entity: object): entity is IdTokenEntity; +// Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// Warning: (ae-missing-release-tag) "isKmsi" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +function isKmsi(idTokenClaims: TokenClaims): boolean; + // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen // Warning: (ae-missing-release-tag) "isRefreshTokenEntity" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // @@ -4346,6 +4353,7 @@ type TokenClaims = { upn?: string; preferred_username?: string; login_hint?: string; + signin_state?: Array; emails?: string[]; name?: string; nonce?: string; @@ -4608,42 +4616,42 @@ const X_MS_LIB_CAPABILITY_VALUE: string; // src/authority/Authority.ts:802:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen // src/authority/Authority.ts:1000:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen // src/authority/AuthorityOptions.ts:25:5 - (ae-forgotten-export) The symbol "CloudInstanceDiscoveryResponse" needs to be exported by the entry point index.d.ts -// src/cache/CacheManager.ts:336:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// src/cache/CacheManager.ts:337:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// src/cache/CacheManager.ts:605:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// src/cache/CacheManager.ts:1568:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// src/cache/CacheManager.ts:1569:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// src/cache/CacheManager.ts:1583:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// src/cache/CacheManager.ts:1584:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// src/cache/CacheManager.ts:1604:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// src/cache/CacheManager.ts:1605:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// src/cache/CacheManager.ts:340:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// src/cache/CacheManager.ts:341:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// src/cache/CacheManager.ts:613:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// src/cache/CacheManager.ts:1577:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// src/cache/CacheManager.ts:1578:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// src/cache/CacheManager.ts:1592:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// src/cache/CacheManager.ts:1593:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// src/cache/CacheManager.ts:1613:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen // src/cache/CacheManager.ts:1614:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// src/cache/CacheManager.ts:1615:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// src/cache/CacheManager.ts:1631:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// src/cache/CacheManager.ts:1632:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// src/cache/CacheManager.ts:1646:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// src/cache/CacheManager.ts:1647:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// src/cache/CacheManager.ts:1685:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// src/cache/CacheManager.ts:1686:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// src/cache/CacheManager.ts:1700:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// src/cache/CacheManager.ts:1701:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// src/cache/CacheManager.ts:1712:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// src/cache/CacheManager.ts:1713:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// src/cache/CacheManager.ts:1724:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// src/cache/CacheManager.ts:1725:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// src/cache/CacheManager.ts:1736:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// src/cache/CacheManager.ts:1737:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// src/cache/CacheManager.ts:1754:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// src/cache/CacheManager.ts:1755:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// src/cache/CacheManager.ts:1779:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// src/cache/CacheManager.ts:1780:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// src/cache/CacheManager.ts:1799:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// src/cache/CacheManager.ts:1800:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// src/cache/CacheManager.ts:1819:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// src/cache/CacheManager.ts:1820:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// src/cache/CacheManager.ts:1831:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// src/cache/CacheManager.ts:1832:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// src/cache/CacheManager.ts:1623:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// src/cache/CacheManager.ts:1624:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// src/cache/CacheManager.ts:1640:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// src/cache/CacheManager.ts:1641:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// src/cache/CacheManager.ts:1655:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// src/cache/CacheManager.ts:1656:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// src/cache/CacheManager.ts:1694:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// src/cache/CacheManager.ts:1695:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// src/cache/CacheManager.ts:1709:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// src/cache/CacheManager.ts:1710:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// src/cache/CacheManager.ts:1721:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// src/cache/CacheManager.ts:1722:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// src/cache/CacheManager.ts:1733:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// src/cache/CacheManager.ts:1734:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// src/cache/CacheManager.ts:1745:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// src/cache/CacheManager.ts:1746:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// src/cache/CacheManager.ts:1763:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// src/cache/CacheManager.ts:1764:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// src/cache/CacheManager.ts:1788:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// src/cache/CacheManager.ts:1789:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// src/cache/CacheManager.ts:1808:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// src/cache/CacheManager.ts:1809:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// src/cache/CacheManager.ts:1828:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// src/cache/CacheManager.ts:1829:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen // src/cache/CacheManager.ts:1840:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// src/cache/CacheManager.ts:1841:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// src/cache/CacheManager.ts:1849:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen // src/cache/entities/AccountEntity.ts:49:5 - (ae-forgotten-export) The symbol "DataBoundary" needs to be exported by the entry point index.d.ts // src/cache/utils/CacheTypes.ts:93:53 - (tsdoc-escape-greater-than) The ">" character should be escaped using a backslash to avoid confusion with an HTML tag // src/cache/utils/CacheTypes.ts:93:43 - (tsdoc-malformed-html-name) Invalid HTML element: An HTML name must be an ASCII letter followed by zero or more letters, digits, or hyphens @@ -4661,9 +4669,9 @@ const X_MS_LIB_CAPABILITY_VALUE: string; // src/index.ts:8:12 - (tsdoc-characters-after-block-tag) The token "@azure" looks like a TSDoc tag but contains an invalid character "/"; if it is not a tag, use a backslash to escape the "@" // src/index.ts:8:4 - (tsdoc-undefined-tag) The TSDoc tag "@module" is not defined in this configuration // src/request/AuthenticationHeaderParser.ts:74:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// src/response/ResponseHandler.ts:340:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// src/response/ResponseHandler.ts:341:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// src/response/ResponseHandler.ts:342:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// src/response/ResponseHandler.ts:345:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// src/response/ResponseHandler.ts:346:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// src/response/ResponseHandler.ts:347:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen // src/telemetry/performance/PerformanceClient.ts:714:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen // src/telemetry/performance/PerformanceClient.ts:714:15 - (tsdoc-param-tag-with-invalid-type) The @param block should not include a JSDoc-style '{type}' // src/telemetry/performance/PerformanceClient.ts:726:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen diff --git a/lib/msal-common/src/account/AuthToken.ts b/lib/msal-common/src/account/AuthToken.ts index 1aff6e0d24..220220b340 100644 --- a/lib/msal-common/src/account/AuthToken.ts +++ b/lib/msal-common/src/account/AuthToken.ts @@ -30,6 +30,28 @@ export function extractTokenClaims( } } +/** + * Check if the signin_state claim contains "kmsi" + * @param idTokenClaims + * @returns + */ +export function isKmsi(idTokenClaims: TokenClaims): boolean { + if (!idTokenClaims.signin_state) { + return false; + } + /** + * Signin_state claim known values: + * dvc_mngd - device is managed + * dvc_dmjd - device is domain joined + * kmsi - user opted to "keep me signed in" + * inknownntwk - Request made inside a known network. Don't use this, use CAE instead. + */ + const kmsiClaims = ["kmsi", "dvc_dmjd"]; // There are some cases where kmsi may not be returned but persistent storage is still OK - allow dvc_dmjd as well + return idTokenClaims.signin_state.some((value) => + kmsiClaims.includes(value.trim().toLowerCase()) + ); +} + /** * decode a JWT * diff --git a/lib/msal-common/src/account/TokenClaims.ts b/lib/msal-common/src/account/TokenClaims.ts index f8cc1bde29..52cb9cf9cc 100644 --- a/lib/msal-common/src/account/TokenClaims.ts +++ b/lib/msal-common/src/account/TokenClaims.ts @@ -47,6 +47,10 @@ export type TokenClaims = { upn?: string; preferred_username?: string; login_hint?: string; + /** + * Contains KMSI (Keep Me Signed In) status among other things + */ + signin_state?: Array; emails?: string[]; name?: string; nonce?: string; diff --git a/lib/msal-common/src/cache/CacheManager.ts b/lib/msal-common/src/cache/CacheManager.ts index 26a818d3b7..628898f3d7 100644 --- a/lib/msal-common/src/cache/CacheManager.ts +++ b/lib/msal-common/src/cache/CacheManager.ts @@ -90,7 +90,8 @@ export abstract class CacheManager implements ICacheManager { */ abstract setAccount( account: AccountEntity, - correlationId: string + correlationId: string, + kmsi: boolean ): Promise; /** @@ -109,7 +110,8 @@ export abstract class CacheManager implements ICacheManager { */ abstract setIdTokenCredential( idToken: IdTokenEntity, - correlationId: string + correlationId: string, + kmsi: boolean ): Promise; /** @@ -128,7 +130,8 @@ export abstract class CacheManager implements ICacheManager { */ abstract setAccessTokenCredential( accessToken: AccessTokenEntity, - correlationId: string + correlationId: string, + kmsi: boolean ): Promise; /** @@ -147,7 +150,8 @@ export abstract class CacheManager implements ICacheManager { */ abstract setRefreshTokenCredential( refreshToken: RefreshTokenEntity, - correlationId: string + correlationId: string, + kmsi: boolean ): Promise; /** @@ -544,6 +548,7 @@ export abstract class CacheManager implements ICacheManager { async saveCacheRecord( cacheRecord: CacheRecord, correlationId: string, + kmsi: boolean, storeInCache?: StoreInCache ): Promise { if (!cacheRecord) { @@ -554,13 +559,14 @@ export abstract class CacheManager implements ICacheManager { try { if (!!cacheRecord.account) { - await this.setAccount(cacheRecord.account, correlationId); + await this.setAccount(cacheRecord.account, correlationId, kmsi); } if (!!cacheRecord.idToken && storeInCache?.idToken !== false) { await this.setIdTokenCredential( cacheRecord.idToken, - correlationId + correlationId, + kmsi ); } @@ -570,7 +576,8 @@ export abstract class CacheManager implements ICacheManager { ) { await this.saveAccessToken( cacheRecord.accessToken, - correlationId + correlationId, + kmsi ); } @@ -580,7 +587,8 @@ export abstract class CacheManager implements ICacheManager { ) { await this.setRefreshTokenCredential( cacheRecord.refreshToken, - correlationId + correlationId, + kmsi ); } @@ -606,7 +614,8 @@ export abstract class CacheManager implements ICacheManager { */ private async saveAccessToken( credential: AccessTokenEntity, - correlationId: string + correlationId: string, + kmsi: boolean ): Promise { const accessTokenFilter: CredentialFilter = { clientId: credential.clientId, @@ -646,7 +655,7 @@ export abstract class CacheManager implements ICacheManager { } } }); - await this.setAccessTokenCredential(credential, correlationId); + await this.setAccessTokenCredential(credential, correlationId, kmsi); } /** diff --git a/lib/msal-common/src/cache/interface/ICacheManager.ts b/lib/msal-common/src/cache/interface/ICacheManager.ts index d74e2e2986..408e859e47 100644 --- a/lib/msal-common/src/cache/interface/ICacheManager.ts +++ b/lib/msal-common/src/cache/interface/ICacheManager.ts @@ -29,7 +29,11 @@ export interface ICacheManager { * @param account * @param correlationId */ - setAccount(account: AccountEntity, correlationId: string): Promise; + setAccount( + account: AccountEntity, + correlationId: string, + kmsi: boolean + ): Promise; /** * fetch the idToken entity from the platform cache @@ -48,7 +52,8 @@ export interface ICacheManager { */ setIdTokenCredential( idToken: IdTokenEntity, - correlationId: string + correlationId: string, + kmsi: boolean ): Promise; /** @@ -68,7 +73,8 @@ export interface ICacheManager { */ setAccessTokenCredential( accessToken: AccessTokenEntity, - correlationId: string + correlationId: string, + kmsi: boolean ): Promise; /** @@ -88,7 +94,8 @@ export interface ICacheManager { */ setRefreshTokenCredential( refreshToken: RefreshTokenEntity, - correlationId: string + correlationId: string, + kmsi: boolean ): Promise; /** @@ -216,6 +223,7 @@ export interface ICacheManager { saveCacheRecord( cacheRecord: CacheRecord, correlationId: string, + kmsi: boolean, storeInCache?: StoreInCache ): Promise; diff --git a/lib/msal-common/src/response/ResponseHandler.ts b/lib/msal-common/src/response/ResponseHandler.ts index 1d327c3d38..9bdfc8eb86 100644 --- a/lib/msal-common/src/response/ResponseHandler.ts +++ b/lib/msal-common/src/response/ResponseHandler.ts @@ -33,7 +33,11 @@ import { TokenCacheContext } from "../cache/persistence/TokenCacheContext.js"; import { ISerializableTokenCache } from "../cache/interface/ISerializableTokenCache.js"; import { AuthorizationCodePayload } from "./AuthorizationCodePayload.js"; import { BaseAuthRequest } from "../request/BaseAuthRequest.js"; -import { checkMaxAge, extractTokenClaims } from "../account/AuthToken.js"; +import { + checkMaxAge, + extractTokenClaims, + isKmsi, +} from "../account/AuthToken.js"; import { TokenClaims, getTenantIdFromIdTokenClaims, @@ -305,6 +309,7 @@ export class ResponseHandler { await this.cacheStorage.saveCacheRecord( cacheRecord, request.correlationId, + isKmsi(idTokenClaims || {}), request.storeInCache ); } finally { diff --git a/lib/msal-common/test/account/AuthToken.spec.ts b/lib/msal-common/test/account/AuthToken.spec.ts index 41e0072633..61a7a86a45 100644 --- a/lib/msal-common/test/account/AuthToken.spec.ts +++ b/lib/msal-common/test/account/AuthToken.spec.ts @@ -130,4 +130,50 @@ describe("AuthToken.ts Class Unit Tests", () => { ).toEqual(ID_TOKEN_CLAIMS); }); }); + + describe("isKmsi()", () => { + it("Returns false if claims don't contain signin_state", () => { + expect(AuthToken.isKmsi({})).toBe(false); + }); + + it("Returns false if signin_state claim does not contain kmsi", () => { + expect( + AuthToken.isKmsi({ + signin_state: ["not-kmsi", "other-value"], + }) + ).toBe(false); + }); + + it("Returns true if signin_state claim contains kmsi", () => { + expect( + AuthToken.isKmsi({ + signin_state: ["kmsi", "other-value"], + }) + ).toBe(true); + }); + + it("Returns true if signin_state contains kmsi in uppercase", () => { + expect( + AuthToken.isKmsi({ + signin_state: ["KMSI"], + }) + ).toBe(true); + }); + + it("Returns true if signin_state contains kmsi with leading/trailing whitespace", () => { + expect( + AuthToken.isKmsi({ + signin_state: [" kmsi ", "other-value"], + }) + ).toBe(true); + }); + + it("Returns true if signin_state claim contains dvc_dmjd", () => { + expect( + AuthToken.isKmsi({ + signin_state: ["dvc_dmjd", "other-value"], + }) + ).toBe(true); + }); + }); }); diff --git a/lib/msal-common/test/cache/CacheManager.spec.ts b/lib/msal-common/test/cache/CacheManager.spec.ts index 526953bfce..f931d3198b 100644 --- a/lib/msal-common/test/cache/CacheManager.spec.ts +++ b/lib/msal-common/test/cache/CacheManager.spec.ts @@ -104,7 +104,8 @@ describe("CacheManager.ts test cases", () => { cacheRecord.account = ac; await mockCache.cacheManager.saveCacheRecord( cacheRecord, - TEST_CONFIG.CORRELATION_ID + TEST_CONFIG.CORRELATION_ID, + true ); const mockCacheAccount = mockCache.cacheManager.getAccount( accountKey @@ -138,7 +139,8 @@ describe("CacheManager.ts test cases", () => { cacheRecord.accessToken = at; await mockCache.cacheManager.saveCacheRecord( cacheRecord, - TEST_CONFIG.CORRELATION_ID + TEST_CONFIG.CORRELATION_ID, + true ); const mockCacheAT = mockCache.cacheManager.getAccessTokenCredential( atKey @@ -174,6 +176,7 @@ describe("CacheManager.ts test cases", () => { await mockCache.cacheManager.saveCacheRecord( cacheRecord, TEST_CONFIG.CORRELATION_ID, + true, { accessToken: false, } @@ -205,7 +208,8 @@ describe("CacheManager.ts test cases", () => { cacheRecord.accessToken = at; await mockCache.cacheManager.saveCacheRecord( cacheRecord, - TEST_CONFIG.CORRELATION_ID + TEST_CONFIG.CORRELATION_ID, + true ); const mockCacheAT = mockCache.cacheManager.getAccessTokenCredential( atKey @@ -238,6 +242,7 @@ describe("CacheManager.ts test cases", () => { await mockCache.cacheManager.saveCacheRecord( cacheRecord, TEST_CONFIG.CORRELATION_ID, + true, { idToken: false, } @@ -311,6 +316,7 @@ describe("CacheManager.ts test cases", () => { await mockCache.cacheManager.saveCacheRecord( cacheRecord, TEST_CONFIG.CORRELATION_ID, + true, { refreshToken: false, } @@ -806,7 +812,8 @@ describe("CacheManager.ts test cases", () => { cacheRecord.account = ac; await mockCache.cacheManager.saveCacheRecord( cacheRecord, - TEST_CONFIG.CORRELATION_ID + TEST_CONFIG.CORRELATION_ID, + true ); const cacheAccount = mockCache.cacheManager.getAccount( @@ -835,7 +842,8 @@ describe("CacheManager.ts test cases", () => { cacheRecord.accessToken = accessTokenEntity; await mockCache.cacheManager.saveCacheRecord( cacheRecord, - TEST_CONFIG.CORRELATION_ID + TEST_CONFIG.CORRELATION_ID, + true ); const cachedAccessToken = @@ -867,7 +875,8 @@ describe("CacheManager.ts test cases", () => { cacheRecord.accessToken = accessTokenEntity; await mockCache.cacheManager.saveCacheRecord( cacheRecord, - TEST_CONFIG.CORRELATION_ID + TEST_CONFIG.CORRELATION_ID, + true ); const cachedAccessToken = diff --git a/lib/msal-common/test/client/RefreshTokenClient.spec.ts b/lib/msal-common/test/client/RefreshTokenClient.spec.ts index c07ffe1c3f..f280e0de96 100644 --- a/lib/msal-common/test/client/RefreshTokenClient.spec.ts +++ b/lib/msal-common/test/client/RefreshTokenClient.spec.ts @@ -356,15 +356,18 @@ describe("RefreshTokenClient unit tests", () => { config = await ClientTestUtils.createTestClientConfiguration(); await config.storageInterface!.setAccount( testAccountEntity, - TEST_CONFIG.CORRELATION_ID + TEST_CONFIG.CORRELATION_ID, + true ); await config.storageInterface!.setRefreshTokenCredential( testRefreshTokenEntity, - TEST_CONFIG.CORRELATION_ID + TEST_CONFIG.CORRELATION_ID, + true ); await config.storageInterface!.setRefreshTokenCredential( testFamilyRefreshTokenEntity, - TEST_CONFIG.CORRELATION_ID + TEST_CONFIG.CORRELATION_ID, + true ); config.storageInterface!.setAppMetadata( testAppMetadata, @@ -1083,15 +1086,18 @@ describe("RefreshTokenClient unit tests", () => { config = await ClientTestUtils.createTestClientConfiguration(); await config.storageInterface!.setAccount( testAccountEntity, - TEST_CONFIG.CORRELATION_ID + TEST_CONFIG.CORRELATION_ID, + true ); await config.storageInterface!.setRefreshTokenCredential( testRefreshTokenEntity, - TEST_CONFIG.CORRELATION_ID + TEST_CONFIG.CORRELATION_ID, + true ); await config.storageInterface!.setRefreshTokenCredential( testFamilyRefreshTokenEntity, - TEST_CONFIG.CORRELATION_ID + TEST_CONFIG.CORRELATION_ID, + true ); config.storageInterface!.setAppMetadata( testAppMetadata, @@ -1335,7 +1341,8 @@ describe("RefreshTokenClient unit tests", () => { ...testRefreshTokenEntity, expiresOn: rtExpiresOn.toString(), // Set expiration to yesterday }, - TEST_CONFIG.CORRELATION_ID + TEST_CONFIG.CORRELATION_ID, + true ); const mockPerfClient = new MockPerformanceClient(); const client = new RefreshTokenClient(config, mockPerfClient); @@ -1376,7 +1383,8 @@ describe("RefreshTokenClient unit tests", () => { ...testRefreshTokenEntity, expiresOn: (TimeUtils.nowSeconds() + 30 * 60).toString(), // Set expiration to 30 minutes from now }, - TEST_CONFIG.CORRELATION_ID + TEST_CONFIG.CORRELATION_ID, + true ); const client = new RefreshTokenClient( config, @@ -1396,7 +1404,8 @@ describe("RefreshTokenClient unit tests", () => { await ClientTestUtils.createTestClientConfiguration(); await config.storageInterface!.setAccount( testAccountEntity, - TEST_CONFIG.CORRELATION_ID + TEST_CONFIG.CORRELATION_ID, + true ); const rtExpiresOn = TimeUtils.nowSeconds() + 60 * 60; const rtEntity = { @@ -1405,7 +1414,8 @@ describe("RefreshTokenClient unit tests", () => { }; await config.storageInterface!.setRefreshTokenCredential( rtEntity, - TEST_CONFIG.CORRELATION_ID + TEST_CONFIG.CORRELATION_ID, + true ); config.storageInterface!.setAppMetadata( testAppMetadata, diff --git a/lib/msal-common/test/config/ClientConfiguration.spec.ts b/lib/msal-common/test/config/ClientConfiguration.spec.ts index fac90793cd..74bdd31f8f 100644 --- a/lib/msal-common/test/config/ClientConfiguration.spec.ts +++ b/lib/msal-common/test/config/ClientConfiguration.spec.ts @@ -75,7 +75,8 @@ describe("ClientConfiguration.ts Class Unit Tests", () => { expect(() => emptyConfig.storageInterface.setAccount( MockCache.acc, - TEST_CONFIG.CORRELATION_ID + TEST_CONFIG.CORRELATION_ID, + true ) ).rejects.toEqual( createClientAuthError(ClientAuthErrorCodes.methodNotImplemented) diff --git a/lib/msal-common/test/protocol/Authorize.spec.ts b/lib/msal-common/test/protocol/Authorize.spec.ts index 4711dce2fd..73b0b8691d 100644 --- a/lib/msal-common/test/protocol/Authorize.spec.ts +++ b/lib/msal-common/test/protocol/Authorize.spec.ts @@ -890,32 +890,7 @@ describe("Authorize Protocol Tests", () => { it("Uses loginHint param instead of sid from account prompt!=None when account does not include loginHint", async () => { const testAccount = { ...TEST_ACCOUNT_INFO, loginHint: undefined }; - const testTokenClaims: Required< - Omit< - TokenClaims, - | "home_oid" - | "upn" - | "cloud_instance_host_name" - | "cnf" - | "emails" - | "iat" - | "x5c_ca" - | "ts" - | "at" - | "u" - | "p" - | "m" - | "login_hint" - | "aud" - | "nbf" - | "roles" - | "amr" - | "idp" - | "auth_time" - | "tfp" - | "acr" - > - > = { + const testTokenClaims: TokenClaims = { ver: "2.0", iss: `${TEST_URIS.DEFAULT_INSTANCE}9188040d-6c67-4c5b-b112-36a304b66dad/v2.0`, sub: "AAAAAAAAAAAAAAAAAAAAAIkzqFVrSaSaFHy782bbtaQ", @@ -971,33 +946,7 @@ describe("Authorize Protocol Tests", () => { it("Uses login_hint param instead of username if sid is not present in token claims for account or request and account does not have loginHint", async () => { const testAccount = { ...TEST_ACCOUNT_INFO, loginHint: undefined }; - const testTokenClaims: Required< - Omit< - TokenClaims, - | "home_oid" - | "upn" - | "cloud_instance_host_name" - | "cnf" - | "emails" - | "sid" - | "iat" - | "x5c_ca" - | "ts" - | "at" - | "u" - | "p" - | "m" - | "login_hint" - | "aud" - | "nbf" - | "roles" - | "amr" - | "idp" - | "auth_time" - | "tfp" - | "acr" - > - > = { + const testTokenClaims: TokenClaims = { ver: "2.0", iss: `${TEST_URIS.DEFAULT_INSTANCE}9188040d-6c67-4c5b-b112-36a304b66dad/v2.0`, sub: "AAAAAAAAAAAAAAAAAAAAAIkzqFVrSaSaFHy782bbtaQ", diff --git a/samples/e2eTestUtils/src/BrowserCacheTestUtils.ts b/samples/e2eTestUtils/src/BrowserCacheTestUtils.ts index c25c081abc..b3364ef516 100644 --- a/samples/e2eTestUtils/src/BrowserCacheTestUtils.ts +++ b/samples/e2eTestUtils/src/BrowserCacheTestUtils.ts @@ -159,7 +159,7 @@ export class BrowserCacheUtils { async getAccountFromCache(): Promise | null> { const storage = await this.getWindowStorage(); - const accountKeys = storage["msal.1.account.keys"]; + const accountKeys = storage["msal.2.account.keys"]; return JSON.parse(accountKeys); } diff --git a/samples/msal-browser-samples/ExpressSample/test/upgrade-downgrade.spec.ts b/samples/msal-browser-samples/ExpressSample/test/upgrade-downgrade.spec.ts index 535944aff3..95ffaf59c2 100644 --- a/samples/msal-browser-samples/ExpressSample/test/upgrade-downgrade.spec.ts +++ b/samples/msal-browser-samples/ExpressSample/test/upgrade-downgrade.spec.ts @@ -62,8 +62,8 @@ describe("Upgrade/Downgrade Tests", () => { */ test("Verify Schema Version", async () => { // DO NOT UPDATE THESE CONSTANTS UNTIL TESTS HAVE BEEN ADDED!! - const currentAccountSchemaVersion = 1; - const currentTokenSchemaVersion = 1; + const currentAccountSchemaVersion = 2; + const currentTokenSchemaVersion = 2; const testName = "schemaVersion"; const screenshot = new Screenshot( @@ -122,6 +122,20 @@ describe("Upgrade/Downgrade Tests", () => { await verifyCacheWasUsed(page, screenshot); }); + test("acquireTokenSilent can return tokens from the cache after upgrading from 4.25.0 (cache schema v1)", async () => { + const testName = "upgradeV4-25-0"; + const screenshot = new Screenshot( + `${SCREENSHOT_BASE_FOLDER_NAME}/${testName}` + ); + await screenshot.takeScreenshot(page, "Page loaded"); + + await switchToVersion("4.25.0", page, screenshot); + await signIn(page, screenshot, username, accountPwd); + await switchToVersion("local", page, screenshot); + + await verifyCacheWasUsed(page, screenshot); + }); + test("acquireTokenSilent can return tokens from the cache after upgrading from 4.18.0 (cache schema v0)", async () => { const testName = "upgradeV4-18-0"; const screenshot = new Screenshot( @@ -199,6 +213,23 @@ describe("Upgrade/Downgrade Tests", () => { await verifyCacheWasUsed(page, screenshot); }); + test("acquireTokenSilent can return tokens from the cache after downgrading back to 4.25.0 (cache schema v1)", async () => { + const testName = "downgradeLatestTo4-25-0"; + const screenshot = new Screenshot( + `${SCREENSHOT_BASE_FOLDER_NAME}/${testName}` + ); + await screenshot.takeScreenshot(page, "Page loaded"); + + await switchToVersion("4.25.0", page, screenshot); + await signIn(page, screenshot, username, accountPwd); + + await switchToVersion("local", page, screenshot); + await verifyCacheWasUsed(page, screenshot); + + await switchToVersion("4.25.0", page, screenshot); + await verifyCacheWasUsed(page, screenshot); + }); + test("acquireTokenSilent can return tokens from the cache after downgrading back to 4.18.0 (cache schema v0)", async () => { const testName = "downgradeLatestTo4-18-0"; const screenshot = new Screenshot( @@ -227,8 +258,6 @@ describe("Upgrade/Downgrade Tests", () => { await signIn(page, screenshot, username, accountPwd); await switchToVersion("local", page, screenshot); - // v4 can't read the v3 cache so we need to sign back in via SSO - await signIn(page, screenshot, username, accountPwd, true); await verifyCacheWasUsed(page, screenshot); await switchToVersion("latest-v3", page, screenshot);