diff --git a/src/client/Microsoft.Identity.Client/AppConfig/CacheOptions.cs b/src/client/Microsoft.Identity.Client/AppConfig/CacheOptions.cs index 6b5d873327..80d5e18f47 100644 --- a/src/client/Microsoft.Identity.Client/AppConfig/CacheOptions.cs +++ b/src/client/Microsoft.Identity.Client/AppConfig/CacheOptions.cs @@ -1,6 +1,8 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. +using System; + namespace Microsoft.Identity.Client { /// @@ -39,6 +41,17 @@ public CacheOptions(bool useSharedCache) UseSharedCache = useSharedCache; } + /// + /// Constructor + /// + /// Set to true to share the cache between all ClientApplication objects. The cache becomes static. for a detailed description. + /// Token cache size limit in bytes. for a detailed description. + public CacheOptions(bool useSharedCache, long sizeLimit) + { + UseSharedCache = useSharedCache; + SizeLimit = sizeLimit; + } + /// /// Share the cache between all ClientApplication objects. The cache becomes static. Defaults to false. /// @@ -50,5 +63,37 @@ public CacheOptions(bool useSharedCache) /// public bool UseSharedCache { get; set; } + private long? _sizeLimit; + + /// + /// Total token cache size limit in bytes for both app and user token caches. + /// + /// + /// Once the limit is reached, either the app or user cache will be fully cleared, depending on which was most recently used. + /// MSAL doesn't calculate the exact memory usage and uses approximations of the token sizes. + /// For instance, app token cache entry is approximately at least 4500 bytes; user access token entry - 6500 bytes, + /// user refresh token entry - 3700 bytes. + /// Using a MemoryCache via Microsoft.Identity.Web.TokenCache is more accurate but slower. + /// This size limit applies only to internal memory cache and is not a concern when distributed caching is used. + /// IMPORTANT: Monitor app health metrics (including memory usage) and cache performance () + /// and adjust size limit accordingly. + /// + public long? SizeLimit + { + get => _sizeLimit; + set + { + ValidateSizeLimit(value); + _sizeLimit = value; + } + } + + private void ValidateSizeLimit(long? sizeLimit) + { + if (sizeLimit < 0) + { + throw new ArgumentOutOfRangeException(nameof(sizeLimit), $"{nameof(sizeLimit)} must be a positive number."); + } + } } } diff --git a/src/client/Microsoft.Identity.Client/PlatformsCommon/Shared/InMemoryPartitionedAppTokenCacheAccessor.cs b/src/client/Microsoft.Identity.Client/PlatformsCommon/Shared/InMemoryPartitionedAppTokenCacheAccessor.cs index c171d51925..a71a652a63 100644 --- a/src/client/Microsoft.Identity.Client/PlatformsCommon/Shared/InMemoryPartitionedAppTokenCacheAccessor.cs +++ b/src/client/Microsoft.Identity.Client/PlatformsCommon/Shared/InMemoryPartitionedAppTokenCacheAccessor.cs @@ -5,6 +5,7 @@ using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; +using System.Threading; using Microsoft.Identity.Client.Cache; using Microsoft.Identity.Client.Cache.Items; using Microsoft.Identity.Client.Cache.Keys; @@ -35,6 +36,11 @@ internal class InMemoryPartitionedAppTokenCacheAccessor : ITokenCacheAccessor protected readonly ICoreLogger _logger; private readonly CacheOptions _tokenCacheAccessorOptions; + // Approximate size of cache item objects + private const long AccessTokenSizeInBytes = 4500; + + private long _appCacheSize; + public InMemoryPartitionedAppTokenCacheAccessor( ICoreLogger logger, CacheOptions tokenCacheAccessorOptions) @@ -57,12 +63,26 @@ public InMemoryPartitionedAppTokenCacheAccessor( #region Add public void SaveAccessToken(MsalAccessTokenCacheItem item) { + if (IsCacheOverCapacity(AccessTokenSizeInBytes)) + { + _logger.Always("[AppCache] Cache is over capacity."); + Compact(); + } + string itemKey = item.GetKey().ToString(); string partitionKey = CacheKeyFactory.GetClientCredentialKey(item.ClientId, item.TenantId, item.KeyId); + // Update cache size only if cache item is added, not updated + if (!AccessTokenCacheDictionary.TryGetValue(partitionKey, out var partition) || !partition.TryGetValue(itemKey, out _)) + { + Interlocked.Add(ref _appCacheSize, AccessTokenSizeInBytes); + Interlocked.Add(ref TokenCache.CacheSize, AccessTokenSizeInBytes); + } + // if a conflict occurs, pick the latest value AccessTokenCacheDictionary .GetOrAdd(partitionKey, new ConcurrentDictionary())[itemKey] = item; + _logger.Verbose($"[AppCache] Saved access token. App cache size: {Interlocked.Read(ref _appCacheSize)}."); } /// @@ -134,9 +154,14 @@ public void DeleteAccessToken(MsalAccessTokenCacheItem item) if (partition == null || !partition.TryRemove(item.GetKey().ToString(), out _)) { _logger.InfoPii( - $"Cannot delete access token because it was not found in the cache. Key {item.GetKey()}.", - "Cannot delete access token because it was not found in the cache."); + $"[AppCache] Cannot delete access token because it was not found in the cache. Key {item.GetKey()}.", + "[AppCache] Cannot delete access token because it was not found in the cache."); + return; } + + Interlocked.Add(ref _appCacheSize, -AccessTokenSizeInBytes); + Interlocked.Add(ref TokenCache.CacheSize, -AccessTokenSizeInBytes); + _logger.Verbose($"[AppCache] Removed access token. App cache size: {Interlocked.Read(ref _appCacheSize)}."); } /// @@ -175,7 +200,7 @@ public void DeleteAccount(MsalAccountCacheItem item) /// public virtual IReadOnlyList GetAllAccessTokens(string partitionKey = null) { - _logger.Always($"[GetAllAccessTokens] Total number of cache partitions found while getting access tokens: {AccessTokenCacheDictionary.Count}"); + _logger.Always($"[AppCache] Total number of cache partitions found while getting access tokens: {AccessTokenCacheDictionary.Count}"); if (string.IsNullOrEmpty(partitionKey)) { return AccessTokenCacheDictionary.SelectMany(dict => dict.Value).Select(kv => kv.Value).ToList(); @@ -216,7 +241,9 @@ public void SetiOSKeychainSecurityGroup(string keychainSecurityGroup) public virtual void Clear() { AccessTokenCacheDictionary.Clear(); - _logger.Always("[Clear] Clearing access token cache data."); + Interlocked.Add(ref TokenCache.CacheSize, -_appCacheSize); + Interlocked.Exchange(ref _appCacheSize, 0); + _logger.Always("[AppCache] Cleared access token cache data."); // app metadata isn't removable } @@ -224,5 +251,16 @@ public virtual bool HasAccessOrRefreshTokens() { return AccessTokenCacheDictionary.Any(partition => partition.Value.Any(token => !token.Value.IsExpiredWithBuffer())); } + + private bool IsCacheOverCapacity(long sizeToAdd) + { + return _tokenCacheAccessorOptions.SizeLimit.HasValue && (Interlocked.Read(ref TokenCache.CacheSize) + sizeToAdd) > _tokenCacheAccessorOptions.SizeLimit; + } + + private void Compact() + { + _logger.Always("[AppCache] Compacting cache."); + Clear(); + } } } diff --git a/src/client/Microsoft.Identity.Client/PlatformsCommon/Shared/InMemoryPartitionedUserTokenCacheAccessor.cs b/src/client/Microsoft.Identity.Client/PlatformsCommon/Shared/InMemoryPartitionedUserTokenCacheAccessor.cs index 5b6b84d3e1..320cd29c06 100644 --- a/src/client/Microsoft.Identity.Client/PlatformsCommon/Shared/InMemoryPartitionedUserTokenCacheAccessor.cs +++ b/src/client/Microsoft.Identity.Client/PlatformsCommon/Shared/InMemoryPartitionedUserTokenCacheAccessor.cs @@ -5,6 +5,7 @@ using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; +using System.Threading; using Microsoft.Identity.Client.Cache; using Microsoft.Identity.Client.Cache.Items; using Microsoft.Identity.Client.Cache.Keys; @@ -44,6 +45,14 @@ internal class InMemoryPartitionedUserTokenCacheAccessor : ITokenCacheAccessor protected readonly ICoreLogger _logger; private readonly CacheOptions _tokenCacheAccessorOptions; + // Approximate size of cache item objects + private const long AccessTokenSizeInBytes = 6500; + private const long RefreshTokenSizeInBytes = 3700; + private const long IDTokenSizeInBytes = 3300; + private const long AccountSizeInBytes = 1300; + + private long _userCacheSize; + public InMemoryPartitionedUserTokenCacheAccessor(ICoreLogger logger, CacheOptions tokenCacheAccessorOptions) { _logger = logger ?? throw new ArgumentNullException(nameof(logger)); @@ -70,11 +79,31 @@ public InMemoryPartitionedUserTokenCacheAccessor(ICoreLogger logger, CacheOption #region Add public void SaveAccessToken(MsalAccessTokenCacheItem item) { + // When saving tokens in SaveTokenResponseAsync, AT is saved first, then other tokens. + // For the user cache, checking size limit and compacting only when saving AT, + // because otherwise, if compact is run in other save methods, + // it could leave unassociated tokens in the cache. + // (For ex, compact in SaveAccount, would leave only account in the cache, without other tokens.) + if (IsCacheOverCapacity(AccessTokenSizeInBytes)) + { + _logger.Always("[UserCache] Cache is over capacity."); + Compact(); + } + string itemKey = item.GetKey().ToString(); string partitionKey = CacheKeyFactory.GetKeyFromCachedItem(item); + // Update cache size only if cache item is added, not updated + if (!AccessTokenCacheDictionary.TryGetValue(partitionKey, out var partition) || !partition.TryGetValue(itemKey, out _)) + { + Interlocked.Add(ref _userCacheSize, AccessTokenSizeInBytes); + Interlocked.Add(ref TokenCache.CacheSize, AccessTokenSizeInBytes); + } + + // if a conflict occurs, pick the latest value AccessTokenCacheDictionary - .GetOrAdd(partitionKey, new ConcurrentDictionary())[itemKey] = item; // if a conflict occurs, pick the latest value + .GetOrAdd(partitionKey, new ConcurrentDictionary())[itemKey] = item; + _logger.Verbose($"[UserCache] Saved access token. User cache size: {Interlocked.Read(ref _userCacheSize)}."); } public void SaveRefreshToken(MsalRefreshTokenCacheItem item) @@ -82,8 +111,16 @@ public void SaveRefreshToken(MsalRefreshTokenCacheItem item) string itemKey = item.GetKey().ToString(); string partitionKey = CacheKeyFactory.GetKeyFromCachedItem(item); + // Update cache size only if cache item is added, not updated + if (!RefreshTokenCacheDictionary.TryGetValue(partitionKey, out var partition) || !partition.TryGetValue(itemKey, out _)) + { + Interlocked.Add(ref _userCacheSize, RefreshTokenSizeInBytes); + Interlocked.Add(ref TokenCache.CacheSize, RefreshTokenSizeInBytes); + } + RefreshTokenCacheDictionary .GetOrAdd(partitionKey, new ConcurrentDictionary())[itemKey] = item; + _logger.Verbose($"[UserCache] Saved refresh token. User cache size: {Interlocked.Read(ref _userCacheSize)}."); } public void SaveIdToken(MsalIdTokenCacheItem item) @@ -91,8 +128,16 @@ public void SaveIdToken(MsalIdTokenCacheItem item) string itemKey = item.GetKey().ToString(); string partitionKey = CacheKeyFactory.GetKeyFromCachedItem(item); + // Update cache size only if cache item is added, not updated + if (!IdTokenCacheDictionary.TryGetValue(partitionKey, out var partition) || !partition.TryGetValue(itemKey, out _)) + { + Interlocked.Add(ref _userCacheSize, IDTokenSizeInBytes); + Interlocked.Add(ref TokenCache.CacheSize, IDTokenSizeInBytes); + } + IdTokenCacheDictionary .GetOrAdd(partitionKey, new ConcurrentDictionary())[itemKey] = item; + _logger.Verbose($"[UserCache] Saved ID token. User cache size: {Interlocked.Read(ref _userCacheSize)}."); } public void SaveAccount(MsalAccountCacheItem item) @@ -100,8 +145,16 @@ public void SaveAccount(MsalAccountCacheItem item) string itemKey = item.GetKey().ToString(); string partitionKey = CacheKeyFactory.GetKeyFromCachedItem(item); + // Update cache size only if cache item is added, not updated + if (!AccountCacheDictionary.TryGetValue(partitionKey, out var partition) || !partition.TryGetValue(itemKey, out _)) + { + Interlocked.Add(ref _userCacheSize, AccountSizeInBytes); + Interlocked.Add(ref TokenCache.CacheSize, AccountSizeInBytes); + } + AccountCacheDictionary .GetOrAdd(partitionKey, new ConcurrentDictionary())[itemKey] = item; + _logger.Verbose($"[UserCache] Saved account. User cache size: {Interlocked.Read(ref _userCacheSize)}."); } public void SaveAppMetadata(MsalAppMetadataCacheItem item) @@ -155,9 +208,14 @@ public void DeleteAccessToken(MsalAccessTokenCacheItem item) if (partition == null || !partition.TryRemove(item.GetKey().ToString(), out _)) { _logger.InfoPii( - $"Cannot delete access token because it was not found in the cache. Key {item.GetKey()}.", - "Cannot delete access token because it was not found in the cache."); + $"[UserCache] Cannot delete access token because it was not found in the cache. Key {item.GetKey()}.", + "[UserCache] Cannot delete access token because it was not found in the cache."); + return; } + + Interlocked.Add(ref _userCacheSize, -AccessTokenSizeInBytes); + Interlocked.Add(ref TokenCache.CacheSize, -AccessTokenSizeInBytes); + _logger.Verbose($"[UserCache] Removed access token. User cache size: {Interlocked.Read(ref _userCacheSize)}."); } public void DeleteRefreshToken(MsalRefreshTokenCacheItem item) @@ -168,9 +226,14 @@ public void DeleteRefreshToken(MsalRefreshTokenCacheItem item) if (partition == null || !partition.TryRemove(item.GetKey().ToString(), out _)) { _logger.InfoPii( - $"Cannot delete refresh token because it was not found in the cache. Key {item.GetKey()}.", - "Cannot delete refresh token because it was not found in the cache."); + $"[UserCache] Cannot delete refresh token because it was not found in the cache. Key {item.GetKey()}.", + "[UserCache] Cannot delete refresh token because it was not found in the cache."); + return; } + + Interlocked.Add(ref _userCacheSize, -RefreshTokenSizeInBytes); + Interlocked.Add(ref TokenCache.CacheSize, -RefreshTokenSizeInBytes); + _logger.Verbose($"[UserCache] Removed refresh token. User cache size: {Interlocked.Read(ref _userCacheSize)}."); } public void DeleteIdToken(MsalIdTokenCacheItem item) @@ -181,9 +244,14 @@ public void DeleteIdToken(MsalIdTokenCacheItem item) if (partition == null || !partition.TryRemove(item.GetKey().ToString(), out _)) { _logger.InfoPii( - $"Cannot delete ID token because it was not found in the cache. Key {item.GetKey()}.", - "Cannot delete ID token because it was not found in the cache."); + $"[UserCache] Cannot delete ID token because it was not found in the cache. Key {item.GetKey()}.", + "[UserCache] Cannot delete ID token because it was not found in the cache."); + return; } + + Interlocked.Add(ref _userCacheSize, -IDTokenSizeInBytes); + Interlocked.Add(ref TokenCache.CacheSize, -IDTokenSizeInBytes); + _logger.Verbose($"[UserCache] Removed ID token. User cache size: {Interlocked.Read(ref _userCacheSize)}."); } public void DeleteAccount(MsalAccountCacheItem item) @@ -194,9 +262,14 @@ public void DeleteAccount(MsalAccountCacheItem item) if (partition == null || !partition.TryRemove(item.GetKey().ToString(), out _)) { _logger.InfoPii( - $"Cannot delete account because it was not found in the cache. Key {item.GetKey()}.", - "Cannot delete account because it was not found in the cache"); + $"[UserCache] Cannot delete account because it was not found in the cache. Key {item.GetKey()}.", + "[UserCache] Cannot delete account because it was not found in the cache"); + return; } + + Interlocked.Add(ref _userCacheSize, -AccountSizeInBytes); + Interlocked.Add(ref TokenCache.CacheSize, -AccountSizeInBytes); + _logger.Verbose($"[UserCache] Removed account. User cache size: {Interlocked.Read(ref _userCacheSize)}."); } #endregion @@ -277,11 +350,13 @@ public void SetiOSKeychainSecurityGroup(string keychainSecurityGroup) public virtual void Clear() { - _logger.Always("[Clear] Clearing access token cache data."); AccessTokenCacheDictionary.Clear(); RefreshTokenCacheDictionary.Clear(); IdTokenCacheDictionary.Clear(); AccountCacheDictionary.Clear(); + Interlocked.Add(ref TokenCache.CacheSize, -_userCacheSize); + Interlocked.Exchange(ref _userCacheSize, 0); + _logger.Always("[UserCache] Cleared access token cache data."); // app metadata isn't removable } @@ -292,5 +367,16 @@ public virtual bool HasAccessOrRefreshTokens() return RefreshTokenCacheDictionary.Any(partition => partition.Value.Count > 0) || AccessTokenCacheDictionary.Any(partition => partition.Value.Any(token => !token.Value.IsExpiredWithBuffer())); } + + private bool IsCacheOverCapacity(long sizeToAdd) + { + return _tokenCacheAccessorOptions.SizeLimit.HasValue && (Interlocked.Read(ref TokenCache.CacheSize) + sizeToAdd) > _tokenCacheAccessorOptions.SizeLimit; + } + + private void Compact() + { + _logger.Always("[UserCache] Compacting cache."); + Clear(); + } } } diff --git a/src/client/Microsoft.Identity.Client/TokenCache.cs b/src/client/Microsoft.Identity.Client/TokenCache.cs index fde0946c18..43fe6fc483 100644 --- a/src/client/Microsoft.Identity.Client/TokenCache.cs +++ b/src/client/Microsoft.Identity.Client/TokenCache.cs @@ -47,6 +47,11 @@ public sealed partial class TokenCache : ITokenCacheInternal /// internal bool UsesDefaultSerialization { get; set; } = false; + /// + /// Static, used by both app and user caches to track approximate token cache size. + /// + internal static long CacheSize = 0L; + internal string ClientId => ServiceBundle.Config.ClientId; ITokenCacheAccessor ITokenCacheInternal.Accessor => Accessor; @@ -117,7 +122,7 @@ public void SetIosKeychainSecurityGroup(string securityGroup) (LegacyCachePersistence as Microsoft.Identity.Client.Platforms.iOS.iOSLegacyCachePersistence).SetKeychainSecurityGroup(securityGroup); #endif } - + private void UpdateAppMetadata(string clientId, string environment, string familyId) { if (_featureFlags.IsFociEnabled)