diff --git a/src/client/Microsoft.Identity.Client/ManagedIdentity/V2/HybridCache.cs b/src/client/Microsoft.Identity.Client/ManagedIdentity/V2/HybridCache.cs new file mode 100644 index 0000000000..e0fe177833 --- /dev/null +++ b/src/client/Microsoft.Identity.Client/ManagedIdentity/V2/HybridCache.cs @@ -0,0 +1,663 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.IO; +#if SUPPORTS_SYSTEM_TEXT_JSON + using JsonProperty = System.Text.Json.Serialization.JsonPropertyNameAttribute; +#else +using Microsoft.Identity.Json; +#endif +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Identity.Client.Core; +using Microsoft.Identity.Client.Utils; + +namespace Microsoft.Identity.Client.ManagedIdentity.V2 +{ + /// + /// Hybrid cache implementation for attestation tokens with in-memory primary and file-based fallback. + /// Uses JSON serialization for storage and named OS mutex for cross-process synchronization. + /// + /// + /// This implementation provides: + /// - In-memory cache as primary storage for fast access within the process + /// - File-based cache as fallback for persistence across application restarts + /// - Automatic synchronization between in-memory and file caches + /// - Cross-process synchronization using named OS mutex + /// - Automatic cleanup of expired tokens + /// - Atomic write operations to prevent cache corruption + /// - Graceful error handling with fallback behavior + /// + /// Cache Strategy: + /// 1. Check in-memory cache first (fastest) + /// 2. If not found, check file cache and populate in-memory cache + /// 3. New tokens are stored in both caches simultaneously + /// 4. File cache provides persistence and cross-process sharing + /// + /// The cache file is stored in the user's local application data directory by default. + /// Multiple processes can safely access the same cache file simultaneously. + /// + /// Thread Safety: This class is thread-safe and can be used concurrently from multiple threads. + /// Process Safety: Uses named mutex to coordinate access across multiple processes. + /// + internal class HybridCache : IHybridCache, IDisposable + { + /// + /// In-memory cache for fast access within the current process. + /// Key: Cache key as string representation. + /// Value: Cache entry containing token data and metadata. + /// + private static readonly ConcurrentDictionary s_memoryCache = + new ConcurrentDictionary(); + + /// + /// Per-key semaphores for thread-safe access to cache entries. + /// Allows concurrent access to different keys while serializing access to the same key. + /// + private static readonly ConcurrentDictionary s_keySemaphores = + new ConcurrentDictionary(); + + /// + /// Logger instance for diagnostics and debugging information. + /// + private readonly ILoggerAdapter _logger; + + /// + /// The file path where the cache data is persisted. + /// + private readonly string _cacheFilePath; + + /// + /// Named OS mutex used for cross-process synchronization when accessing the cache file. + /// + private readonly Mutex _namedMutex; + + /// + /// The name of the mutex used for cross-process synchronization. + /// + private readonly string _mutexName; + + /// + /// Flag indicating whether this instance has been disposed. + /// + private bool _disposed; + + /// + /// Timeout for mutex acquisition operations. Configurable for unit tests. + /// + private readonly TimeSpan _mutexTimeout; + + /// + /// Buffer time subtracted from token expiration to account for clock skew. + /// Configurable for unit tests. + /// + private readonly TimeSpan _expirySkew; + + /// + /// Initializes a new instance of the class. + /// Uses a fixed cache directory in the user's local application data folder. + /// + /// Logger instance for diagnostics and debugging information. + /// Timeout for mutex acquisition operations. Defaults to 30 seconds. Use shorter values for unit tests. + /// Buffer time for token expiry calculations. Defaults to 2 minutes. Use shorter values for unit tests. + /// + /// The constructor: + /// - Creates the cache directory if it doesn't exist + /// - Sets up the cache file path using the default location: %LocalAppData%\Microsoft\MSAL\AttestationTokenCache + /// - Creates a named mutex for cross-process synchronization + /// - Uses a deterministic mutex name based on the cache file path hash + /// + /// The mutex name format is: "Global\MSAL_AttestationCache_{hash}" where hash is a + /// hexadecimal representation of the cache file path's hash code. + /// + /// + /// Thrown when is null. + /// + /// + /// Thrown when the application doesn't have permission to create the cache directory. + /// + /// + /// Thrown when the cache directory path is invalid. + /// + public HybridCache( + ILoggerAdapter logger, + TimeSpan? mutexTimeout = null, + TimeSpan? expirySkew = null) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + + // Configure timeouts - use shorter values for unit tests to improve test performance + _mutexTimeout = mutexTimeout ?? TimeSpan.FromSeconds(30); + _expirySkew = expirySkew ?? TimeSpan.FromMinutes(2); + + // Fixed cache location in user's local app data + var cacheDirectory = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "Microsoft", "MSAL", "AttestationTokenCache"); + + _logger.Info(() => $"[HybridCache] Initializing cache with directory: {cacheDirectory}"); + + Directory.CreateDirectory(cacheDirectory); + _cacheFilePath = Path.Combine(cacheDirectory, "attestation_tokens.json"); + + // Create named mutex for cross-process synchronization + // Using a deterministic name based on cache file path to ensure same mutex across processes + _mutexName = $"Global\\MSAL_AttestationCache_{_cacheFilePath.GetHashCode():X8}"; + _namedMutex = new Mutex(false, _mutexName); + + _logger.Info(() => $"[HybridCache] Cache initialized. File path: {_cacheFilePath}, Mutex: {_mutexName}"); + } + + /// + /// Retrieves a cached attestation token if available and not expired. + /// Uses hybrid strategy: check in-memory first, then file cache as fallback. + /// + /// + /// The cache key used to identify the token entry. + /// + /// + /// A cancellation token that can be used to cancel the operation. + /// + /// + /// A task containing the cached token response if found and valid, otherwise null. + /// + /// + /// This method implements the hybrid caching strategy: + /// 1. Check in-memory cache first for fastest access + /// 2. If not found in memory, check file cache + /// 3. If found in file cache, populate in-memory cache for future requests + /// 4. Automatically removes expired tokens during lookup + /// 5. Returns null if no valid token is found + /// + /// The expiry check includes a buffer time (s_expirySkew) to account for clock skew. + /// + /// + /// Thrown when this cache instance has been disposed. + /// + /// + /// Thrown when the mutex cannot be acquired within the timeout period (30 seconds). + /// + /// + /// Thrown when the operation is canceled via the cancellation token. + /// + public async Task GetAsync(long key, CancellationToken ct) + { + if (_disposed) + { + throw new ObjectDisposedException(nameof(HybridCache)); + } + + _logger.Verbose(() => $"[HybridCache] GetAsync called for key: {key}"); + + var keyString = key.ToString(); + var semaphore = s_keySemaphores.GetOrAdd(keyString, k => new SemaphoreSlim(1, 1)); + + await semaphore.WaitAsync(ct).ConfigureAwait(false); + try + { + var now = DateTimeOffset.UtcNow; + + // Step 1: Check in-memory cache first (fastest path) + if (s_memoryCache.TryGetValue(keyString, out var memoryEntry)) + { + if (!string.IsNullOrEmpty(memoryEntry.Token) && now + _expirySkew < memoryEntry.ExpiresOnUtc) + { + _logger.Info(() => $"[HybridCache] Cache hit in memory for key: {key}"); + return new AttestationTokenResponse { AttestationToken = memoryEntry.Token }; + } + + // Token expired in memory, remove it + s_memoryCache.TryRemove(keyString, out _); + _logger.Info(() => $"[HybridCache] Expired token removed from memory cache for key: {key}"); + } + + _logger.Verbose(() => $"[HybridCache] Memory cache miss for key: {key}, checking file cache"); + + // Step 2: Check file cache as fallback + return await ExecuteWithMutexAsync(async () => + { + var fileCache = await LoadFileCacheAsync().ConfigureAwait(false); + + if (fileCache.TryGetValue(keyString, out var fileEntry)) + { + if (!string.IsNullOrEmpty(fileEntry.Token) && now + _expirySkew < fileEntry.ExpiresOnUtc) + { + // Step 3: Found valid token in file cache, populate in-memory cache + s_memoryCache.TryAdd(keyString, fileEntry); + _logger.Info(() => $"[HybridCache] Cache hit in file for key: {key}, populated memory cache"); + return new AttestationTokenResponse { AttestationToken = fileEntry.Token }; + } + + // Token expired in file cache, remove it and persist the change + fileCache.Remove(keyString); + await SaveFileCacheAsync(fileCache).ConfigureAwait(false); + _logger.Info(() => $"[HybridCache] Expired token removed from file cache for key: {key}"); + } + + _logger.Info(() => $"[HybridCache] Cache miss for key: {key}"); + return null; + }, ct).ConfigureAwait(false); + } + finally + { + semaphore.Release(); + } + } + + /// + /// Stores an attestation token in both in-memory and file caches with the specified expiration time. + /// + /// + /// The cache key used to identify the token entry. + /// + /// + /// The attestation token to cache. Must not be null or empty. + /// + /// + /// The UTC date and time when the token expires. + /// + /// + /// A cancellation token that can be used to cancel the operation. + /// + /// + /// A task representing the asynchronous set operation. + /// + /// + /// This method: + /// - Validates the input parameters + /// - Stores the token in in-memory cache immediately + /// - Updates the file cache for persistence and cross-process sharing + /// - Uses cross-process synchronization for file operations + /// + /// The operation overwrites any existing entry with the same key in both caches. + /// If file cache operations fail, the in-memory cache is still updated. + /// + /// + /// Thrown when this cache instance has been disposed. + /// + /// + /// Thrown when is null or empty. + /// + /// + /// Thrown when the mutex cannot be acquired within the timeout period. + /// + /// + /// Thrown when the operation is canceled via the cancellation token. + /// + public async Task SetAsync(long key, string token, DateTimeOffset expiresOnUtc, CancellationToken ct) + { + if (_disposed) + { + throw new ObjectDisposedException(nameof(HybridCache)); + } + + if (string.IsNullOrEmpty(token)) + { + throw new ArgumentNullException(nameof(token)); + } + + _logger.Info(() => $"[HybridCache] SetAsync called for key: {key}, expires: {expiresOnUtc}"); + + var keyString = key.ToString(); + var semaphore = s_keySemaphores.GetOrAdd(keyString, k => new SemaphoreSlim(1, 1)); + + await semaphore.WaitAsync(ct).ConfigureAwait(false); + try + { + var entry = new CacheEntry + { + Token = token, + ExpiresOnUtc = expiresOnUtc, + CachedOnUtc = DateTimeOffset.UtcNow + }; + + // Step 1: Update in-memory cache immediately (fast operation) + s_memoryCache.AddOrUpdate(keyString, entry, (k, v) => entry); + _logger.Verbose(() => $"[HybridCache] Token stored in memory cache for key: {key}"); + + // Step 2: Update file cache for persistence (may be slower) + await ExecuteWithMutexAsync(async () => + { + var fileCache = await LoadFileCacheAsync().ConfigureAwait(false); + fileCache[keyString] = entry; + await SaveFileCacheAsync(fileCache).ConfigureAwait(false); + _logger.Verbose(() => $"[HybridCache] Token stored in file cache for key: {key}"); + return Task.CompletedTask; + }, ct).ConfigureAwait(false); + + _logger.Info(() => $"[HybridCache] Token successfully cached for key: {key}"); + } + finally + { + semaphore.Release(); + } + } + + /// + /// Removes a token entry from both in-memory and file caches. + /// + /// + /// The cache key identifying the token entry to remove. + /// + /// + /// A cancellation token that can be used to cancel the operation. + /// + /// + /// A task representing the asynchronous remove operation. + /// + /// + /// This method: + /// - Removes the entry from in-memory cache immediately + /// - Updates the file cache to remove the entry + /// - Uses cross-process synchronization for file operations + /// + /// The operation is idempotent - removing a non-existent key is not an error. + /// If file cache operations fail, the in-memory cache is still updated. + /// + /// + /// Thrown when this cache instance has been disposed. + /// + /// + /// Thrown when the mutex cannot be acquired within the timeout period. + /// + /// + /// Thrown when the operation is canceled via the cancellation token. + /// + public async Task RemoveAsync(long key, CancellationToken ct) + { + if (_disposed) + { + throw new ObjectDisposedException(nameof(HybridCache)); + } + + _logger.Info(() => $"[HybridCache] RemoveAsync called for key: {key}"); + + var keyString = key.ToString(); + var semaphore = s_keySemaphores.GetOrAdd(keyString, k => new SemaphoreSlim(1, 1)); + + await semaphore.WaitAsync(ct).ConfigureAwait(false); + try + { + // Step 1: Remove from in-memory cache immediately + var removedFromMemory = s_memoryCache.TryRemove(keyString, out _); + if (removedFromMemory) + { + _logger.Verbose(() => $"[HybridCache] Token removed from memory cache for key: {key}"); + } + + // Step 2: Remove from file cache for persistence + await ExecuteWithMutexAsync(async () => + { + var fileCache = await LoadFileCacheAsync().ConfigureAwait(false); + + // Only save if we actually removed something + if (fileCache.Remove(keyString)) + { + await SaveFileCacheAsync(fileCache).ConfigureAwait(false); + _logger.Verbose(() => $"[HybridCache] Token removed from file cache for key: {key}"); + } + + return Task.CompletedTask; + }, ct).ConfigureAwait(false); + + _logger.Info(() => $"[HybridCache] Remove operation completed for key: {key}"); + } + finally + { + semaphore.Release(); + } + } + + /// + /// Executes an action while holding the named mutex for cross-process synchronization. + /// + /// The return type of the action. + /// The asynchronous action to execute under mutex protection. + /// A cancellation token that can be used to cancel the operation. + /// A task containing the result of the action. + /// + /// This method: + /// - Attempts to acquire the named mutex with a 30-second timeout + /// - Executes the provided action while holding the mutex + /// - Ensures the mutex is always released, even if the action throws an exception + /// - Provides cancellation support through the cancellation token + /// + /// The mutex timeout prevents deadlocks in case of abandoned mutexes or long-running operations. + /// + /// + /// Thrown when the mutex cannot be acquired within the 30-second timeout period. + /// + /// + /// Thrown when the operation is canceled via the cancellation token. + /// + private async Task ExecuteWithMutexAsync(Func> action, CancellationToken ct) + { + bool mutexAcquired = false; + try + { + _logger.Verbose(() => $"[HybridCache] Attempting to acquire mutex: {_mutexName}"); + + // Try to acquire mutex with timeout to avoid deadlocks + mutexAcquired = _namedMutex.WaitOne(_mutexTimeout); + if (!mutexAcquired) + { + _logger.Warning($"[HybridCache] Failed to acquire mutex within timeout of {_mutexTimeout.TotalSeconds} seconds: {_mutexName}"); + throw new TimeoutException($"Failed to acquire cache mutex within timeout period of {_mutexTimeout.TotalSeconds} seconds"); + } + + _logger.Verbose(() => $"[HybridCache] Mutex acquired successfully: {_mutexName}"); + + ct.ThrowIfCancellationRequested(); + return await action().ConfigureAwait(false); + } + finally + { + if (mutexAcquired) + { + try + { + _namedMutex.ReleaseMutex(); + _logger.Verbose(() => $"[HybridCache] Mutex released: {_mutexName}"); + } + catch (Exception ex) + { + _logger.Warning($"[HybridCache] Error releasing mutex {_mutexName}: {ex.Message}"); + /* ignore release errors to prevent masking original exceptions */ + } + } + } + } + + /// + /// Loads the file cache data from the persistent storage file. + /// + /// + /// A task containing a dictionary representing the file cache contents. + /// Returns an empty dictionary if the cache file doesn't exist or is corrupted. + /// + /// + /// This method: + /// - Checks if the cache file exists + /// - Reads and deserializes the JSON content + /// - Performs automatic cleanup of expired entries during load + /// - Returns an empty cache if the file is missing, empty, or corrupted + /// - Uses defensive programming to handle JSON deserialization errors + /// + /// The automatic cleanup during load helps keep the cache size manageable + /// and removes stale entries that would never be used again. + /// + private async Task> LoadFileCacheAsync() + { + try + { + if (!File.Exists(_cacheFilePath)) + { + _logger.Verbose(() => $"[HybridCache] Cache file does not exist: {_cacheFilePath}. Created a new file."); + return new Dictionary(); + } + + _logger.Verbose(() => $"[HybridCache] Loading cache from file: {_cacheFilePath}"); + + var json = await Task.Run(() => File.ReadAllText(_cacheFilePath)).ConfigureAwait(false); + if (string.IsNullOrWhiteSpace(json)) + { + _logger.Verbose(() => $"[HybridCache] Cache file is empty: {_cacheFilePath}. Created a new file."); + return new Dictionary(); + } + + var cache = JsonHelper.DeserializeFromJson>(json) ?? + new Dictionary(); + + // Clean up expired entries during load to keep cache size manageable + var now = DateTimeOffset.UtcNow; + var keysToRemove = new List(); + + foreach (var kvp in cache) + { + if (now + _expirySkew >= kvp.Value.ExpiresOnUtc) + { + keysToRemove.Add(kvp.Key); + } + } + + if (keysToRemove.Count > 0) + { + foreach (var key in keysToRemove) + { + cache.Remove(key); + } + _logger.Info(() => $"[HybridCache] Cleaned up {keysToRemove.Count} expired entries from file cache"); + } + + _logger.Verbose(() => $"[HybridCache] Loaded {cache.Count} entries from file cache"); + return cache; + } + catch (Exception ex) + { + _logger.Warning($"[HybridCache] Error loading file cache from {_cacheFilePath}: {ex.Message}. Created a new file."); + // If cache is corrupted or unreadable, start with a fresh cache + // This is better than failing the entire authentication flow + return new Dictionary(); + } + } + + /// + /// Saves the file cache data to the persistent storage file using atomic operations. + /// + /// The cache dictionary to persist. + /// A task representing the asynchronous save operation. + /// + /// This method: + /// - Serializes the cache to JSON format + /// - Uses atomic write operations to prevent corruption (write to temp file, then move) + /// - Handles write errors gracefully without throwing exceptions + /// - Uses compact JSON formatting to minimize file size + /// + /// The atomic write pattern (write to temporary file, then move) ensures that the cache + /// file is never left in a partially written state, even if the process is terminated + /// during the write operation. + /// + /// Errors during save operations are swallowed because the cache is a performance + /// optimization, not critical functionality. Authentication should not fail due to + /// cache persistence issues. + /// + private async Task SaveFileCacheAsync(Dictionary cache) + { + try + { + _logger.Verbose(() => $"[HybridCache] Saving {cache.Count} entries to file cache: {_cacheFilePath}"); + + var json = JsonHelper.SerializeToJson(cache); + + // Write to temp file first, then move to avoid corruption during write + var tempFile = _cacheFilePath + ".tmp"; + await Task.Run(() => File.WriteAllText(tempFile, json)).ConfigureAwait(false); + + // Atomic move operation - ensures consistency + if (File.Exists(_cacheFilePath)) + { + File.Delete(_cacheFilePath); + } + File.Move(tempFile, _cacheFilePath); + + _logger.Verbose(() => $"[HybridCache] Successfully saved cache to file: {_cacheFilePath}"); + } + catch (Exception ex) + { + _logger.Warning($"[HybridCache] Error saving file cache to {_cacheFilePath}: {ex.Message}"); + // Swallow save errors - cache is a performance optimization, not critical functionality + // The authentication flow should continue even if cache persistence fails + } + } + + /// + /// Releases all resources used by the . + /// + /// + /// This method disposes of the named mutex and marks the instance as disposed. + /// After calling Dispose, no further operations should be performed on this instance. + /// + /// The method is safe to call multiple times and will not throw exceptions + /// during the disposal process. + /// + /// Note: The static in-memory cache and semaphores are not disposed as they may be + /// shared across multiple instances and processes. + /// + public void Dispose() + { + if (!_disposed) + { + try + { + _namedMutex?.Dispose(); + _logger.Verbose(() => $"[HybridCache] Cache disposed successfully"); + } + catch (Exception ex) + { + _logger.Warning($"[HybridCache] Error during disposal: {ex.Message}"); + /* ignore disposal errors */ + } + _disposed = true; + } + } + + /// + /// Represents a single entry in the attestation token cache. + /// + /// + /// This class stores all the necessary information for a cached attestation token: + /// - The actual token value + /// - When the token expires + /// - When the token was originally cached + /// + /// The CachedOnUtc property can be useful for cache analytics and debugging, + /// allowing administrators to understand cache hit patterns and token lifetime usage. + /// + /// This entry structure is used for both in-memory and file cache storage. + /// + private class CacheEntry + { + /// + /// Gets or sets the attestation token value. + /// This is typically a JWT (JSON Web Token) in string format. + /// + public string Token { get; set; } + + /// + /// Gets or sets the UTC date and time when the token expires. + /// The cache will consider tokens expired when the current time plus + /// expiry skew exceeds this value. + /// + public DateTimeOffset ExpiresOnUtc { get; set; } + + /// + /// Gets or sets the UTC date and time when this entry was added to the cache. + /// This can be useful for debugging and understanding cache behavior. + /// + public DateTimeOffset CachedOnUtc { get; set; } + } + } +} diff --git a/src/client/Microsoft.Identity.Client/ManagedIdentity/V2/IHybridCache.cs b/src/client/Microsoft.Identity.Client/ManagedIdentity/V2/IHybridCache.cs new file mode 100644 index 0000000000..afaa28e36e --- /dev/null +++ b/src/client/Microsoft.Identity.Client/ManagedIdentity/V2/IHybridCache.cs @@ -0,0 +1,120 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Identity.Client.ManagedIdentity.V2 +{ + /// + /// Interface for attestation token caching implementations. + /// Supports hybrid caching strategies combining in-memory and persistent storage + /// for attestation tokens used in managed identity authentication scenarios. + /// + /// + /// This interface abstracts the caching mechanism to allow for different storage strategies: + /// - Hybrid caching with in-memory primary and file-based fallback for optimal performance + /// - Pure in-memory caching for single-process scenarios + /// - Pure persistent file-based caching for cross-process scenarios + /// - Custom implementations for specific requirements + /// + /// Implementations should be thread-safe and handle concurrent access gracefully. + /// For persistent implementations, cross-process synchronization should be considered. + /// Hybrid implementations should synchronize between memory and persistent storage when possible. + /// + internal interface IHybridCache + { + /// + /// Retrieves a cached attestation token if available and valid. + /// + /// + /// The cache key used to identify the token entry. Typically derived from the KeyHandle pointer value + /// to ensure uniqueness per cryptographic key. + /// + /// + /// A cancellation token that can be used to cancel the asynchronous operation. + /// + /// + /// A task that represents the asynchronous get operation. The task result contains: + /// - An if a valid token is found in the cache + /// - null if no token is found, the token has expired, or the token is invalid + /// + /// + /// This method should: + /// - Check token expiration before returning cached values + /// - Return null for expired tokens rather than throwing exceptions + /// - Handle storage errors gracefully (e.g., corrupted cache files, memory issues) + /// - Be thread-safe for concurrent access + /// - For hybrid implementations: check in-memory cache first, then persistent storage + /// - Synchronize between cache tiers when possible + /// + /// + /// Thrown when the operation is canceled via the cancellation token. + /// + Task GetAsync(long key, CancellationToken ct); + + /// + /// Stores an attestation token in the cache with the specified expiration time. + /// + /// + /// The cache key used to identify the token entry. Should be the same key used in . + /// + /// + /// The attestation token to cache. This is typically a JWT (JSON Web Token) in string format. + /// Must not be null or empty. + /// + /// + /// The UTC date and time when the token expires. The cache implementation may add additional + /// buffer time to account for clock skew and ensure tokens are refreshed before actual expiration. + /// + /// + /// A cancellation token that can be used to cancel the asynchronous operation. + /// + /// + /// A task that represents the asynchronous set operation. + /// + /// + /// This method should: + /// - Overwrite existing entries for the same key + /// - Handle storage errors gracefully (cache failures should not fail the authentication flow) + /// - Be thread-safe for concurrent access + /// - Validate input parameters + /// - For hybrid implementations: update both in-memory and persistent storage when possible + /// - Prioritize fast in-memory updates over slower persistent operations + /// + /// + /// Thrown when is null or empty. + /// + /// + /// Thrown when the operation is canceled via the cancellation token. + /// + Task SetAsync(long key, string token, DateTimeOffset expiresOnUtc, CancellationToken ct); + + /// + /// Removes an expired or invalid token from the cache. + /// + /// + /// The cache key identifying the token entry to remove. + /// + /// + /// A cancellation token that can be used to cancel the asynchronous operation. + /// + /// + /// A task that represents the asynchronous remove operation. + /// + /// + /// This method should: + /// - Gracefully handle cases where the key doesn't exist (no-op) + /// - Handle storage errors gracefully + /// - Be thread-safe for concurrent access + /// - Not throw exceptions for missing entries + /// - For hybrid implementations: remove from both in-memory and persistent storage + /// - Continue operation even if one storage tier fails + /// + /// + /// Thrown when the operation is canceled via the cancellation token. + /// + Task RemoveAsync(long key, CancellationToken ct); + } +} diff --git a/src/client/Microsoft.Identity.Client/ManagedIdentity/V2/ImdsV2ManagedIdentitySource.cs b/src/client/Microsoft.Identity.Client/ManagedIdentity/V2/ImdsV2ManagedIdentitySource.cs index 50f41881ac..ee6d8f7da8 100644 --- a/src/client/Microsoft.Identity.Client/ManagedIdentity/V2/ImdsV2ManagedIdentitySource.cs +++ b/src/client/Microsoft.Identity.Client/ManagedIdentity/V2/ImdsV2ManagedIdentitySource.cs @@ -288,7 +288,7 @@ protected override async Task CreateRequestAsync(string ManagedIdentityKeyInfo keyInfo = await keyProvider .GetOrCreateKeyAsync( - _requestContext.Logger, + _requestContext.Logger, _requestContext.UserCancellationToken) .ConfigureAwait(false); @@ -338,7 +338,7 @@ private static string ImdsV2QueryParamsHelper(RequestContext requestContext) requestContext.ServiceBundle.Config.ManagedIdentityId.IdType, requestContext.ServiceBundle.Config.ManagedIdentityId.UserAssignedId, requestContext.Logger); - + if (userAssignedIdQueryParam != null) { queryParams += $"&{userAssignedIdQueryParam.Value.Key}={userAssignedIdQueryParam.Value.Value}"; @@ -349,7 +349,7 @@ private static string ImdsV2QueryParamsHelper(RequestContext requestContext) /// /// Obtains an attestation JWT for the KeyGuard/CSR payload using the configured - /// attestation provider and normalized endpoint. + /// attestation provider and HybridCache for performance optimization. /// /// Client ID to be sent to the attestation provider. /// The attestation endpoint. @@ -357,17 +357,12 @@ private static string ImdsV2QueryParamsHelper(RequestContext requestContext) /// Cancellation token. /// JWT string suitable for the IMDSv2 attested POP flow. /// Wraps client/network failures. - private async Task GetAttestationJwtAsync( - string clientId, - Uri attestationEndpoint, - ManagedIdentityKeyInfo keyInfo, + string clientId, + Uri attestationEndpoint, + ManagedIdentityKeyInfo keyInfo, CancellationToken cancellationToken) { - // Provider is a local dependency; missing provider is a client error - var provider = _requestContext.AttestationTokenProvider; - - // KeyGuard requires RSACng on Windows if (keyInfo.Type == ManagedIdentityKeyType.KeyGuard && keyInfo.Key is not System.Security.Cryptography.RSACng rsaCng) { @@ -376,7 +371,25 @@ private async Task GetAttestationJwtAsync( "[ImdsV2] KeyGuard attestation currently supports only RSA CNG keys on Windows."); } - // Attestation token input + // Extract cache key from KeyHandle + long cacheKey = GetCacheKeyFromKeyInfo(keyInfo); + + _requestContext.Logger.Verbose(() => $"[ImdsV2] GetAttestationJwtAsync called for cache key: {cacheKey}"); + + var cache = new HybridCache(_requestContext.Logger); + + // Step 1: Check cache first + var cached = await cache.GetAsync(cacheKey, cancellationToken).ConfigureAwait(false); + if (cached != null) + { + _requestContext.Logger.Info(() => $"[ImdsV2] Attestation token cache hit for key: {cacheKey}"); + return cached.AttestationToken; + } + + _requestContext.Logger.Info(() => $"[ImdsV2] Attestation token cache miss for key: {cacheKey}, minting new token"); + + // Step 2: Cache miss - mint new token via provider + var provider = _requestContext.AttestationTokenProvider; var input = new AttestationTokenInput { ClientId = clientId, @@ -384,19 +397,69 @@ private async Task GetAttestationJwtAsync( KeyHandle = (keyInfo.Key as System.Security.Cryptography.RSACng)?.Key.Handle }; - // response from provider - var response = await provider(input, cancellationToken).ConfigureAwait(false); - - // Validate response - if (response == null || string.IsNullOrWhiteSpace(response.AttestationToken)) + var minted = await provider(input, cancellationToken).ConfigureAwait(false); + if (minted == null || string.IsNullOrWhiteSpace(minted.AttestationToken)) { throw new MsalClientException( "attestation_failed", "[ImdsV2] Attestation provider failed to return an attestation token."); } - // Return the JWT - return response.AttestationToken; + // Step 3: Cache the new token for 8 hours (MAA default TTL) + var expiresOn = DateTimeOffset.UtcNow + TimeSpan.FromHours(8); + try + { + await cache.SetAsync(cacheKey, minted.AttestationToken, expiresOn, cancellationToken).ConfigureAwait(false); + _requestContext.Logger.Info(() => $"[ImdsV2] Attestation token successfully cached for key: {cacheKey}"); + } + catch (Exception ex) + { + _requestContext.Logger.Warning($"[ImdsV2] Error caching attestation token for key {cacheKey}: {ex.Message}"); + // Cache failure is not critical - return the token anyway + } + + return minted.AttestationToken; + } + + /// + /// Extracts a cache key from the managed identity key information. + /// + /// The managed identity key information containing the KeyHandle. + /// + /// A long integer representing the KeyHandle pointer value, or 0 if extraction fails. + /// The returned value is used as a unique identifier for cache entries across processes. + /// + /// + /// This method: + /// - Safely extracts the pointer value from the KeyHandle using DangerousGetHandle() + /// - Converts the pointer to a 64-bit integer for use as a cache key + /// - Returns 0 as a fallback key if extraction fails for any reason + /// - Uses defensive programming to handle invalid handles gracefully + /// + /// The use of DangerousGetHandle() is acceptable here because: + /// - The handle lifetime is managed by the caller + /// - We only extract the pointer value, not use it for memory access + /// - The extracted value is used immediately for cache key generation + /// + /// Cross-Process Behavior: + /// - Different KeyHandle instances with the same underlying pointer value + /// will produce the same cache key, enabling cross-process cache sharing + /// - Cache key 0 is used as a fallback when handle extraction fails + /// - The same key will be generated across different application instances + /// + private static long GetCacheKeyFromKeyInfo(ManagedIdentityKeyInfo keyInfo) + { + try + { + if (keyInfo.Key is System.Security.Cryptography.RSACng rsaCng && + rsaCng.Key.Handle != null && + !rsaCng.Key.Handle.IsInvalid) + { + return rsaCng.Key.Handle.DangerousGetHandle().ToInt64(); + } + } + catch { /* ignore extraction errors and use fallback key */ } + return 0L; } //To-do : Remove this method once IMDS team start returning full URI diff --git a/tests/Microsoft.Identity.Test.Unit/ManagedIdentityTests/HybridCacheTests.cs b/tests/Microsoft.Identity.Test.Unit/ManagedIdentityTests/HybridCacheTests.cs new file mode 100644 index 0000000000..ed0c02fcb8 --- /dev/null +++ b/tests/Microsoft.Identity.Test.Unit/ManagedIdentityTests/HybridCacheTests.cs @@ -0,0 +1,550 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Identity.Client.Core; +using Microsoft.Identity.Client.Internal.Logger; +using Microsoft.Identity.Client.ManagedIdentity; +using Microsoft.Identity.Client.ManagedIdentity.V2; +using Microsoft.Identity.Test.Unit.PublicApiTests; +using Microsoft.IdentityModel.Abstractions; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Microsoft.Identity.Test.Unit.ManagedIdentityTests +{ + [TestClass] + public class HybridCacheTests : TestBase + { + private ILoggerAdapter _logger; + private string _tempCacheDirectory; + + private HybridCache CreateHybridCache() + { + return new HybridCache( + _logger, + mutexTimeout: TimeSpan.FromSeconds(3), // Fast for tests + expirySkew: TimeSpan.FromMilliseconds(500)); // Short skew for tests + } + + [TestInitialize] + public override void TestInitialize() + { + base.TestInitialize(); + + _logger = new IdentityLoggerAdapter( + new TestIdentityLogger(EventLogLevel.LogAlways), + Guid.NewGuid(), + "TestClient", + "1.0.0", + enablePiiLogging: false + ); + + // Create a temporary directory for cache tests + _tempCacheDirectory = Path.Combine(Path.GetTempPath(), "MSALHybridCacheTests", Guid.NewGuid().ToString()); + Directory.CreateDirectory(_tempCacheDirectory); + } + + [TestCleanup] + public override void TestCleanup() + { + base.TestCleanup(); + + // Clean up temporary cache directory + if (Directory.Exists(_tempCacheDirectory)) + { + try + { + Directory.Delete(_tempCacheDirectory, recursive: true); + } + catch + { + // Ignore cleanup errors + } + } + } + + #region Constructor Tests + + [TestMethod] + public void Constructor_WithValidLogger_Success() + { + using var cache = CreateHybridCache(); + Assert.IsNotNull(cache); + } + + [TestMethod] + public void Constructor_WithNullLogger_ThrowsArgumentNullException() + { + Assert.ThrowsException(() => new HybridCache(null)); + } + + #endregion + + #region GetAsync Tests + + [TestMethod] + public async Task GetAsync_EmptyCache_ReturnsNull() + { + using var cache = CreateHybridCache(); + const long testKey = 12345L; + + var result = await cache.GetAsync(testKey, CancellationToken.None).ConfigureAwait(false); + + Assert.IsNull(result); + } + + [TestMethod] + public async Task GetAsync_ValidCachedToken_ReturnsToken() + { + using var cache = CreateHybridCache(); + const long testKey = 12345L; + const string testToken = "test_attestation_token"; + var expiresOn = DateTimeOffset.UtcNow.AddHours(1); + + await cache.SetAsync(testKey, testToken, expiresOn, CancellationToken.None).ConfigureAwait(false); + + var result = await cache.GetAsync(testKey, CancellationToken.None).ConfigureAwait(false); + + Assert.IsNotNull(result); + Assert.AreEqual(testToken, result.AttestationToken); + } + + [TestMethod] + public async Task GetAsync_ExpiredToken_ReturnsNullAndRemovesFromCache() + { + using var cache = CreateHybridCache(); + const long testKey = 12345L; + const string testToken = "expired_token"; + var expiredTime = DateTimeOffset.UtcNow.AddMinutes(-10); // 10 minutes ago + + await cache.SetAsync(testKey, testToken, expiredTime, CancellationToken.None).ConfigureAwait(false); + + var result = await cache.GetAsync(testKey, CancellationToken.None).ConfigureAwait(false); + + Assert.IsNull(result); + + // Verify token was removed from cache + var secondResult = await cache.GetAsync(testKey, CancellationToken.None).ConfigureAwait(false); + + Assert.IsNull(secondResult); + } + + [TestMethod] + public async Task GetAsync_TokenNearExpiry_ReturnsNullDueToSkewBuffer() + { + using var cache = CreateHybridCache(); + const long testKey = 12345L; + const string testToken = "near_expiry_token"; + // Token expires in 1 minute, but skew buffer is 2 minutes + var nearExpiryTime = DateTimeOffset.UtcNow.AddMinutes(1); + + await cache.SetAsync(testKey, testToken, nearExpiryTime, CancellationToken.None).ConfigureAwait(false); + + var result = await cache.GetAsync(testKey, CancellationToken.None).ConfigureAwait(false); + + Assert.IsNull(result); // Should be null due to expiry skew buffer + } + + [TestMethod] + public async Task GetAsync_DisposedCache_ThrowsObjectDisposedException() + { + var cache = CreateHybridCache(); + cache.Dispose(); + + await Assert.ThrowsExceptionAsync(async () => + await cache.GetAsync(12345L, CancellationToken.None).ConfigureAwait(false)).ConfigureAwait(false); + } + + [TestMethod] + public async Task GetAsync_CancellationRequested_ThrowsOperationCanceledException() + { + using var cache = CreateHybridCache(); + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + await Assert.ThrowsExceptionAsync(async () => + await cache.GetAsync(12345L, cts.Token).ConfigureAwait(false)).ConfigureAwait(false); + } + + #endregion + + #region SetAsync Tests + + [TestMethod] + public async Task SetAsync_ValidToken_StoresSuccessfully() + { + using var cache = CreateHybridCache(); + const long testKey = 12345L; + const string testToken = "test_token"; + var expiresOn = DateTimeOffset.UtcNow.AddHours(1); + + await cache.SetAsync(testKey, testToken, expiresOn, CancellationToken.None).ConfigureAwait(false); + + var result = await cache.GetAsync(testKey, CancellationToken.None).ConfigureAwait(false); + + Assert.IsNotNull(result); + Assert.AreEqual(testToken, result.AttestationToken); + } + + [TestMethod] + public async Task SetAsync_OverwriteExistingToken_UpdatesSuccessfully() + { + using var cache = CreateHybridCache(); + const long testKey = 12345L; + const string originalToken = "original_token"; + const string updatedToken = "updated_token"; + var expiresOn = DateTimeOffset.UtcNow.AddHours(1); + + await cache.SetAsync(testKey, originalToken, expiresOn, CancellationToken.None).ConfigureAwait(false); + + await cache.SetAsync(testKey, updatedToken, expiresOn, CancellationToken.None).ConfigureAwait(false); + + var result = await cache.GetAsync(testKey, CancellationToken.None).ConfigureAwait(false); + + Assert.IsNotNull(result); + Assert.AreEqual(updatedToken, result.AttestationToken); + } + + [TestMethod] + public async Task SetAsync_NullToken_ThrowsArgumentNullException() + { + using var cache = CreateHybridCache(); + const long testKey = 12345L; + var expiresOn = DateTimeOffset.UtcNow.AddHours(1); + + await Assert.ThrowsExceptionAsync(async () => + await cache.SetAsync(testKey, null, expiresOn, CancellationToken.None).ConfigureAwait(false)).ConfigureAwait(false); + } + + [TestMethod] + public async Task SetAsync_EmptyToken_ThrowsArgumentNullException() + { + using var cache = CreateHybridCache(); + const long testKey = 12345L; + var expiresOn = DateTimeOffset.UtcNow.AddHours(1); + + await Assert.ThrowsExceptionAsync(async () => + await cache.SetAsync(testKey, "", expiresOn, CancellationToken.None).ConfigureAwait(false)).ConfigureAwait(false); + } + + [TestMethod] + public async Task SetAsync_DisposedCache_ThrowsObjectDisposedException() + { + var cache = CreateHybridCache(); + cache.Dispose(); + + await Assert.ThrowsExceptionAsync(async () => + await cache.SetAsync(12345L, "token", DateTimeOffset.UtcNow.AddHours(1), CancellationToken.None).ConfigureAwait(false)).ConfigureAwait(false); + } + + [TestMethod] + public async Task SetAsync_CancellationRequested_ThrowsOperationCanceledException() + { + using var cache = CreateHybridCache(); + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + await Assert.ThrowsExceptionAsync(async () => + await cache.SetAsync(12345L, "token", DateTimeOffset.UtcNow.AddHours(1), cts.Token).ConfigureAwait(false)).ConfigureAwait(false); + } + + #endregion + + #region RemoveAsync Tests + + [TestMethod] + public async Task RemoveAsync_ExistingToken_RemovesSuccessfully() + { + using var cache = CreateHybridCache(); + const long testKey = 12345L; + const string testToken = "test_token"; + var expiresOn = DateTimeOffset.UtcNow.AddHours(1); + + await cache.SetAsync(testKey, testToken, expiresOn, CancellationToken.None).ConfigureAwait(false); + + await cache.RemoveAsync(testKey, CancellationToken.None).ConfigureAwait(false); + + var result = await cache.GetAsync(testKey, CancellationToken.None).ConfigureAwait(false); + Assert.IsNull(result); + } + + [TestMethod] + public async Task RemoveAsync_NonExistentToken_DoesNotThrow() + { + using var cache = CreateHybridCache(); + const long testKey = 12345L; + + // Should not throw + await cache.RemoveAsync(testKey, CancellationToken.None).ConfigureAwait(false); + } + + [TestMethod] + public async Task RemoveAsync_DisposedCache_ThrowsObjectDisposedException() + { + var cache = CreateHybridCache(); + cache.Dispose(); + + await Assert.ThrowsExceptionAsync(async () => + await cache.RemoveAsync(12345L, CancellationToken.None).ConfigureAwait(false)).ConfigureAwait(false); + } + + [TestMethod] + public async Task RemoveAsync_CancellationRequested_ThrowsOperationCanceledException() + { + using var cache = CreateHybridCache(); + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + await Assert.ThrowsExceptionAsync(async () => + await cache.RemoveAsync(12345L, cts.Token).ConfigureAwait(false)).ConfigureAwait(false); + } + + #endregion + + #region Concurrency Tests + + [TestMethod] + public async Task ConcurrentAccess_MultipleThreadsSetGet_NoDataRaces() + { + using var cache = CreateHybridCache(); + const int threadCount = 10; + const int operationsPerThread = 50; + var tasks = new Task[threadCount]; + + for (int i = 0; i < threadCount; i++) + { + int threadId = i; + tasks[i] = Task.Run(async () => + { + for (int j = 0; j < operationsPerThread; j++) + { + long key = threadId * 1000 + j; + string token = $"token_{threadId}_{j}"; + var expiresOn = DateTimeOffset.UtcNow.AddHours(1); + + await cache.SetAsync(key, token, expiresOn, CancellationToken.None).ConfigureAwait(false); + var result = await cache.GetAsync(key, CancellationToken.None).ConfigureAwait(false); + + Assert.IsNotNull(result); + Assert.AreEqual(token, result.AttestationToken); + } + }); + } + + await Task.WhenAll(tasks).ConfigureAwait(false); + } + + [TestMethod] + public async Task ConcurrentAccess_SameKey_LastWriteWins() + { + using var cache = CreateHybridCache(); + const long testKey = 12345L; + const int threadCount = 10; + var expiresOn = DateTimeOffset.UtcNow.AddHours(1); + var tasks = new Task[threadCount]; + + for (int i = 0; i < threadCount; i++) + { + int threadId = i; + tasks[i] = Task.Run(async () => + { + string token = $"token_from_thread_{threadId}"; + await cache.SetAsync(testKey, token, expiresOn, CancellationToken.None).ConfigureAwait(false); + }); + } + + await Task.WhenAll(tasks).ConfigureAwait(false); + + var result = await cache.GetAsync(testKey, CancellationToken.None).ConfigureAwait(false); + + Assert.IsNotNull(result); + Assert.IsTrue(result.AttestationToken.StartsWith("token_from_thread_")); + } + + #endregion + + #region File Persistence Tests + + [TestMethod] + public async Task FilePersistence_TokenSurvivesInstanceRecreation() + { + const long testKey = 12345L; + const string testToken = "persistent_token"; + var expiresOn = DateTimeOffset.UtcNow.AddHours(1); + + // Store token in first cache instance + using (var cache1 = CreateHybridCache()) + { + await cache1.SetAsync(testKey, testToken, expiresOn, CancellationToken.None).ConfigureAwait(false); + } + + // Retrieve token from second cache instance + AttestationTokenResponse result; + using (var cache2 = CreateHybridCache()) + { + result = await cache2.GetAsync(testKey, CancellationToken.None).ConfigureAwait(false); + } + + Assert.IsNotNull(result); + Assert.AreEqual(testToken, result.AttestationToken); + } + + [TestMethod] + public async Task FilePersistence_ExpiredTokenRemovedFromFile() + { + const long testKey = 12345L; + const string testToken = "expired_persistent_token"; + var expiredTime = DateTimeOffset.UtcNow.AddMinutes(-10); + + // Store expired token + using (var cache1 = CreateHybridCache()) + { + await cache1.SetAsync(testKey, testToken, expiredTime, CancellationToken.None).ConfigureAwait(false); + } + + // Try to retrieve from new instance + AttestationTokenResponse result; + using (var cache2 = CreateHybridCache()) + { + result = await cache2.GetAsync(testKey, CancellationToken.None).ConfigureAwait(false); + } + + Assert.IsNull(result); + } + + #endregion + + #region Memory Cache Tests + + [TestMethod] + public async Task MemoryCache_FastPath_NoFileAccess() + { + using var cache = CreateHybridCache(); + const long testKey = 12345L; + const string testToken = "memory_token"; + var expiresOn = DateTimeOffset.UtcNow.AddHours(1); + + await cache.SetAsync(testKey, testToken, expiresOn, CancellationToken.None).ConfigureAwait(false); + + // Multiple gets should hit memory cache + var result1 = await cache.GetAsync(testKey, CancellationToken.None).ConfigureAwait(false); + var result2 = await cache.GetAsync(testKey, CancellationToken.None).ConfigureAwait(false); + var result3 = await cache.GetAsync(testKey, CancellationToken.None).ConfigureAwait(false); + + Assert.IsNotNull(result1); + Assert.IsNotNull(result2); + Assert.IsNotNull(result3); + Assert.AreEqual(testToken, result1.AttestationToken); + Assert.AreEqual(testToken, result2.AttestationToken); + Assert.AreEqual(testToken, result3.AttestationToken); + } + + [TestMethod] + public async Task MemoryCache_ExpiredEntry_RemovedFromMemory() + { + using var cache = CreateHybridCache(); + const long testKey = 12345L; + const string testToken = "memory_expired_token"; + var expiredTime = DateTimeOffset.UtcNow.AddMinutes(-10); + + await cache.SetAsync(testKey, testToken, expiredTime, CancellationToken.None).ConfigureAwait(false); + + var result = await cache.GetAsync(testKey, CancellationToken.None).ConfigureAwait(false); + + Assert.IsNull(result); + } + + #endregion + + #region Error Handling Tests + + [TestMethod] + public async Task ErrorHandling_FileSystemErrors_GracefulDegradation() + { + using var cache = CreateHybridCache(); + const long testKey = 12345L; + const string testToken = "resilient_token"; + var expiresOn = DateTimeOffset.UtcNow.AddHours(1); + + // This should work even if file operations have issues + await cache.SetAsync(testKey, testToken, expiresOn, CancellationToken.None).ConfigureAwait(false); + var result = await cache.GetAsync(testKey, CancellationToken.None).ConfigureAwait(false); + + // Memory cache should still work + Assert.IsNotNull(result); + Assert.AreEqual(testToken, result.AttestationToken); + } + + #endregion + + #region Dispose Tests + + [TestMethod] + public void Dispose_MultipleDispose_DoesNotThrow() + { + var cache = CreateHybridCache(); + + cache.Dispose(); + cache.Dispose(); + cache.Dispose(); + } + + [TestMethod] + public async Task Dispose_OperationsAfterDispose_ThrowObjectDisposedException() + { + var cache = CreateHybridCache(); + cache.Dispose(); + + await Assert.ThrowsExceptionAsync(async () => + await cache.GetAsync(123L, CancellationToken.None).ConfigureAwait(false)).ConfigureAwait(false); + + await Assert.ThrowsExceptionAsync(async () => + await cache.SetAsync(123L, "token", DateTimeOffset.UtcNow.AddHours(1), CancellationToken.None).ConfigureAwait(false)).ConfigureAwait(false); + + await Assert.ThrowsExceptionAsync(async () => + await cache.RemoveAsync(123L, CancellationToken.None).ConfigureAwait(false)).ConfigureAwait(false); + } + + #endregion + + #region Performance and Load Tests + + [TestMethod] + public async Task Performance_LargeNumberOfEntries_HandlesProperly() + { + using var cache = CreateHybridCache(); + const int entryCount = 1000; + var expiresOn = DateTimeOffset.UtcNow.AddHours(1); + + // Add many entries + var setTasks = new Task[entryCount]; + for (int i = 0; i < entryCount; i++) + { + int index = i; + setTasks[i] = cache.SetAsync(index, $"token_{index}", expiresOn, CancellationToken.None); + } + await Task.WhenAll(setTasks).ConfigureAwait(false); + + // Retrieve all entries + var getTasks = new Task[entryCount]; + for (int i = 0; i < entryCount; i++) + { + int index = i; + getTasks[i] = cache.GetAsync(index, CancellationToken.None); + } + var results = await Task.WhenAll(getTasks).ConfigureAwait(false); + + Assert.AreEqual(entryCount, results.Length); + for (int i = 0; i < entryCount; i++) + { + Assert.IsNotNull(results[i]); + Assert.AreEqual($"token_{i}", results[i].AttestationToken); + } + } + + #endregion + } +}