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
+ }
+}