diff --git a/src/client/Microsoft.Identity.Client/ManagedIdentity/V2/IMtlsCertificateCache.cs b/src/client/Microsoft.Identity.Client/ManagedIdentity/V2/IMtlsCertificateCache.cs
new file mode 100644
index 0000000000..88bd76f151
--- /dev/null
+++ b/src/client/Microsoft.Identity.Client/ManagedIdentity/V2/IMtlsCertificateCache.cs
@@ -0,0 +1,27 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+using System;
+using System.Security.Cryptography.X509Certificates;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Identity.Client.Core;
+
+namespace Microsoft.Identity.Client.ManagedIdentity.V2
+{
+ ///
+ /// Abstraction over the in-memory + persisted cache for IMDSv2 mTLS binding certificates.
+ ///
+ internal interface IMtlsCertificateCache
+ {
+ ///
+ /// Returns a cached binding certificate for the given ,
+ /// or uses to create, persist and return one when needed.
+ ///
+ Task GetOrCreateAsync(
+ string cacheKey,
+ Func> factory,
+ CancellationToken cancellationToken,
+ ILoggerAdapter logger);
+ }
+}
diff --git a/src/client/Microsoft.Identity.Client/ManagedIdentity/V2/IPersistentCertificateCache.cs b/src/client/Microsoft.Identity.Client/ManagedIdentity/V2/IPersistentCertificateCache.cs
new file mode 100644
index 0000000000..3f48ce2560
--- /dev/null
+++ b/src/client/Microsoft.Identity.Client/ManagedIdentity/V2/IPersistentCertificateCache.cs
@@ -0,0 +1,36 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+using System.Security.Cryptography.X509Certificates;
+using Microsoft.Identity.Client.Core;
+
+namespace Microsoft.Identity.Client.ManagedIdentity.V2
+{
+ ///
+ /// Persistence interface for IMDSv2 mTLS binding certificates.
+ /// Implementations must be best-effort and non-throwing so that
+ /// certificate persistence never blocks authentication.
+ ///
+ internal interface IPersistentCertificateCache
+ {
+ ///
+ /// Reads the newest valid (≥24h remaining, has private key) entry for the alias.
+ /// Returns true on cache hit, false otherwise.
+ ///
+ bool Read(string alias, out CertificateCacheValue value, ILoggerAdapter logger);
+
+ ///
+ /// Persists the certificate for the alias (best-effort).
+ /// Implementations should log failures but must not throw; callers do not
+ /// depend on persistence succeeding and fall back to in-memory cache only.
+ ///
+ void Write(string alias, X509Certificate2 cert, string endpointBase, ILoggerAdapter logger);
+
+ ///
+ /// Prunes expired entries for the alias (best-effort).
+ /// Implementations should remove stale/expired entries while leaving the
+ /// latest valid binding for the alias in place.
+ ///
+ void Delete(string alias, ILoggerAdapter logger);
+ }
+}
diff --git a/src/client/Microsoft.Identity.Client/ManagedIdentity/V2/ImdsV2ManagedIdentitySource.cs b/src/client/Microsoft.Identity.Client/ManagedIdentity/V2/ImdsV2ManagedIdentitySource.cs
index aa98c98121..404c619d8b 100644
--- a/src/client/Microsoft.Identity.Client/ManagedIdentity/V2/ImdsV2ManagedIdentitySource.cs
+++ b/src/client/Microsoft.Identity.Client/ManagedIdentity/V2/ImdsV2ManagedIdentitySource.cs
@@ -26,9 +26,7 @@ internal class ImdsV2ManagedIdentitySource : AbstractManagedIdentity
// Central, process-local cache for mTLS binding (cert + endpoint + canonical client_id).
internal static readonly ICertificateCache s_mtlsCertificateCache = new InMemoryCertificateCache();
- // Per-key async de-duplication so concurrent callers don’t double-mint.
- internal static readonly ConcurrentDictionary s_perKeyGates =
- new ConcurrentDictionary(StringComparer.Ordinal);
+ private readonly IMtlsCertificateCache _mtlsCache;
// used in unit tests
public const string ImdsV2ApiVersion = "2.0";
@@ -193,9 +191,20 @@ public static AbstractManagedIdentity Create(RequestContext requestContext)
return new ImdsV2ManagedIdentitySource(requestContext);
}
- internal ImdsV2ManagedIdentitySource(RequestContext requestContext) :
- base(requestContext, ManagedIdentitySource.ImdsV2)
- { }
+ internal ImdsV2ManagedIdentitySource(RequestContext requestContext)
+ : this(requestContext,
+ new MtlsBindingCache(s_mtlsCertificateCache, PersistentCertificateCacheFactory
+ .Create(requestContext.Logger)))
+ {
+ }
+
+ internal ImdsV2ManagedIdentitySource(
+ RequestContext requestContext,
+ IMtlsCertificateCache mtlsCache)
+ : base(requestContext, ManagedIdentitySource.ImdsV2)
+ {
+ _mtlsCache = mtlsCache ?? throw new ArgumentNullException(nameof(mtlsCache));
+ }
private async Task ExecuteCertificateRequestAsync(
string clientId,
@@ -291,11 +300,11 @@ private async Task ExecuteCertificateRequestAsync(
protected override async Task CreateRequestAsync(string resource)
{
- var csrMetadata = await GetCsrMetadataAsync(_requestContext, false).ConfigureAwait(false);
+ CsrMetadata csrMetadata = await GetCsrMetadataAsync(_requestContext, false).ConfigureAwait(false);
string certCacheKey = _requestContext.ServiceBundle.Config.ClientId;
- var certEndpointAndClientId = await GetOrCreateMtlsBindingAsync(
+ MtlsBindingInfo mtlsBinding = await GetOrCreateMtlsBindingAsync(
cacheKey: certCacheKey,
async () =>
{
@@ -333,15 +342,16 @@ protected override async Task CreateRequestAsync(string
// Canonical GUID to use as client_id in the token call
string clientIdGuid = certificateRequestResponse.ClientId;
- return Tuple.Create(mtlsCertificate, endpointBase, clientIdGuid);
+ return new MtlsBindingInfo(mtlsCertificate, endpointBase, clientIdGuid);
+
},
- _requestContext.UserCancellationToken,
+ _requestContext.UserCancellationToken,
_requestContext.Logger)
.ConfigureAwait(false);
- X509Certificate2 bindingCertificate = certEndpointAndClientId.Item1;
- string endpointBaseForToken = certEndpointAndClientId.Item2;
- string clientIdForToken = certEndpointAndClientId.Item3;
+ X509Certificate2 bindingCertificate = mtlsBinding.Certificate;
+ string endpointBaseForToken = mtlsBinding.Endpoint;
+ string clientIdForToken = mtlsBinding.ClientId;
ManagedIdentityRequest request = new ManagedIdentityRequest(
HttpMethod.Post,
@@ -440,65 +450,13 @@ private async Task GetAttestationJwtAsync(
return response.AttestationToken;
}
- // ...unchanged usings and class header...
-
- ///
- /// Read-through cache: try cache; if missing, run async factory once (per key),
- /// store the result, and return it. Thread-safe for the given cacheKey.
- ///
- private static async Task> GetOrCreateMtlsBindingAsync(
+ private Task GetOrCreateMtlsBindingAsync(
string cacheKey,
- Func>> factory,
+ Func> factory,
CancellationToken cancellationToken,
ILoggerAdapter logger)
{
- if (string.IsNullOrWhiteSpace(cacheKey))
- throw new ArgumentException("cacheKey must be non-empty.", nameof(cacheKey));
- if (factory is null)
- throw new ArgumentNullException(nameof(factory));
-
- X509Certificate2 cachedCertificate;
- string cachedEndpointBase;
- string cachedClientId;
-
- // 1) Only lookup by cacheKey
- if (s_mtlsCertificateCache.TryGet(cacheKey, out var cached, logger))
- {
- cachedCertificate = cached.Certificate;
- cachedEndpointBase = cached.Endpoint;
- cachedClientId = cached.ClientId;
-
- return Tuple.Create(cachedCertificate, cachedEndpointBase, cachedClientId);
- }
-
- // 2) Gate per cacheKey
- var gate = s_perKeyGates.GetOrAdd(cacheKey, _ => new SemaphoreSlim(1, 1));
- await gate.WaitAsync(cancellationToken).ConfigureAwait(false);
-
- try
- {
- // Re-check after acquiring the gate
- if (s_mtlsCertificateCache.TryGet(cacheKey, out cached, logger))
- {
- cachedCertificate = cached.Certificate;
- cachedEndpointBase = cached.Endpoint;
- cachedClientId = cached.ClientId;
- return Tuple.Create(cachedCertificate, cachedEndpointBase, cachedClientId);
- }
-
- // 3) Mint + cache under the provided cacheKey
- var created = await factory().ConfigureAwait(false);
-
- s_mtlsCertificateCache.Set(cacheKey,
- new CertificateCacheValue(created.Item1, created.Item2, created.Item3),
- logger);
-
- return created;
- }
- finally
- {
- gate.Release();
- }
+ return _mtlsCache.GetOrCreateAsync(cacheKey, factory, cancellationToken, logger);
}
internal static void ResetCertCacheForTest()
@@ -508,14 +466,6 @@ internal static void ResetCertCacheForTest()
{
s_mtlsCertificateCache.Clear();
}
-
- foreach (var gate in s_perKeyGates.Values)
- {
- try
- { gate.Dispose(); }
- catch { }
- }
- s_perKeyGates.Clear();
}
}
}
diff --git a/src/client/Microsoft.Identity.Client/ManagedIdentity/V2/InterprocessLock.cs b/src/client/Microsoft.Identity.Client/ManagedIdentity/V2/InterprocessLock.cs
new file mode 100644
index 0000000000..4728a8a142
--- /dev/null
+++ b/src/client/Microsoft.Identity.Client/ManagedIdentity/V2/InterprocessLock.cs
@@ -0,0 +1,149 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+using System;
+using System.Diagnostics;
+using System.Threading;
+using Microsoft.Identity.Client.PlatformsCommon.Shared;
+
+namespace Microsoft.Identity.Client.ManagedIdentity.V2
+{
+ ///
+ /// Executes paramref name="action"/ under a cross-process, per-alias mutex.
+ /// We attempt 2 namespaces, in order:
+ /// 1) Global\ — preferred so we dedupe across all sessions on the machine
+ /// (e.g., service + user session). This can be denied by OS policy or missing
+ /// SeCreateGlobalPrivilege in some contexts.
+ /// 2) Local\ — fallback to still dedupe within the current session when
+ /// Global\ is not permitted.
+ /// Using both ensures we never throw (persistence is best-effort) while getting
+ /// machine-wide dedupe when allowed and session-local dedupe otherwise.
+ /// Notes:
+ /// - The mutex name is derived from alias (= cacheKey) via SHA-256 hex (truncated)
+ /// to avoid invalid characters / length issues.
+ /// - On non-Windows runtimes the Global/Local prefixes are treated as part of the name;
+ /// behavior remains correct but dedupe scope is platform-defined.
+ /// - Abandoned mutexes are treated as acquired to avoid blocking after a crash.
+ ///
+ internal static class InterprocessLock
+ {
+ // Prefer Global\ for cross-session dedupe; fall back to Local\
+ // if ACLs block Global\ to remain non-throwing.
+ public static bool TryWithAliasLock(
+ string alias,
+ TimeSpan timeout,
+ Action action,
+ Action logVerbose)
+ {
+ var globalName = GetMutexNameForAlias(alias, preferGlobal: true);
+ var localName = GetMutexNameForAlias(alias, preferGlobal: false);
+
+ // Try to acquire and run under the named mutex scope.
+ // Returns true if action ran, false if lock busy or failure.
+ // first try Global\, then Local\ if Global\ unauthorized.
+ bool TryScope(string name, out bool unauthorized)
+ {
+ unauthorized = false;
+ try
+ {
+ using var mutex = new Mutex(initiallyOwned: false, name);
+
+ bool entered;
+ var waitTimer = Stopwatch.StartNew();
+ try
+ {
+ entered = mutex.WaitOne(timeout);
+ }
+ catch (AbandonedMutexException ex)
+ {
+ entered = true;
+ logVerbose.Invoke($"[PersistentCert] Abandoned mutex '{name}', treating as acquired. {ex.Message}");
+ }
+ finally
+ {
+ waitTimer.Stop();
+ }
+
+ if (!entered)
+ {
+ logVerbose.Invoke(
+ $"[PersistentCert] Skip persist (lock busy '{name}', waited {waitTimer.Elapsed.TotalMilliseconds:F0} ms).");
+ return false;
+ }
+
+ try
+ {
+ action();
+ }
+ catch (Exception ex)
+ {
+ logVerbose.Invoke($"[PersistentCert] Action failed under '{name}': {ex.Message}");
+ return false;
+ }
+ finally
+ {
+ try
+ { mutex.ReleaseMutex(); }
+ catch { /* best-effort */ }
+ }
+
+ return true;
+ }
+ catch (UnauthorizedAccessException)
+ {
+ logVerbose.Invoke($"[PersistentCert] No access to mutex scope '{name}', trying next.");
+ unauthorized = true;
+ return false;
+ }
+ catch (Exception ex)
+ {
+ logVerbose.Invoke($"[PersistentCert] Lock failure '{name}': {ex.Message}");
+ return false;
+ }
+ }
+
+ // Try Global\ first; only fallback to Local\ if Global\ is unauthorized
+ if (TryScope(globalName, out var unauthorizedGlobal))
+ {
+ return true;
+ }
+
+ // Fallback is only appropriate when Global\ is disallowed by ACLs.
+ // If Global\ was just busy or the action failed, do not try Local
+ if (unauthorizedGlobal)
+ {
+ if (TryScope(localName, out _))
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ public static string GetMutexNameForAlias(string alias, bool preferGlobal = true)
+ {
+ string suffix = HashAlias(Canonicalize(alias));
+ return (preferGlobal ? @"Global\" : @"Local\") + "MSAL_MI_P_" + suffix;
+ }
+
+ private static string Canonicalize(string alias) =>
+ (alias ?? string.Empty).Trim().ToUpperInvariant();
+
+ private static string HashAlias(string s)
+ {
+ try
+ {
+ var hex = new CommonCryptographyManager().CreateSha256HashHex(s);
+ // Truncate to 32 chars to fit mutex name length limits
+ return string.IsNullOrEmpty(hex)
+ ? "0"
+ : (hex.Length > 32 ? hex.Substring(0, 32) : hex);
+ }
+ catch
+ {
+ return "0";
+ }
+ }
+ }
+}
diff --git a/src/client/Microsoft.Identity.Client/ManagedIdentity/V2/MsiCertificateFriendlyNameEncoder.cs b/src/client/Microsoft.Identity.Client/ManagedIdentity/V2/MsiCertificateFriendlyNameEncoder.cs
new file mode 100644
index 0000000000..80b89d2442
--- /dev/null
+++ b/src/client/Microsoft.Identity.Client/ManagedIdentity/V2/MsiCertificateFriendlyNameEncoder.cs
@@ -0,0 +1,146 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+using System;
+
+namespace Microsoft.Identity.Client.ManagedIdentity.V2
+{
+ ///
+ /// Encodes/decodes the X.509 FriendlyName used by MSAL for mTLS-bound certificates.
+ /// Best-effort only: methods are non-throwing so certificate persistence never blocks auth.
+ ///
+ ///
+ ///
+ /// Format (v1):
+ /// MSAL|alias=<alias>|ep=<scheme>://<host>[/<tenant>]
+ ///
+ ///
+ /// -
+ ///
+ /// Values are unescaped and must not contain |, carriage return, line feed, or NULL.
+ /// (If any are present, the encoder returns false and persistence is skipped.)
+ ///
+ ///
+ /// -
+ ///
+ /// Keys are lowercase as shown. Unknown key=value pairs may appear after ep=
+ /// and are ignored by the decoder for forward compatibility.
+ ///
+ ///
+ /// -
+ ///
+ /// Case is preserved for values. No whitespace is added around separators.
+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
+ /// // User-assigned MI via full ARM resource ID + tenant GUID
+ /// MSAL|alias=/subscriptions/11111111-1111-1111-1111-111111111111/resourceGroups/rg/providers/Microsoft.ManagedIdentity/userAssignedIdentities/UAMI-2|ep=https://mtls.login/72f988bf-86f1-41af-91ab-2d7cd011db47
+ ///
+ ///
+ ///
+ ///
+ /// // User-assigned MI expressed as ClientId/ObjectId
+ /// MSAL|alias=8f123456-1f2e-4f3d-9e5b-9b9b9b9b9b9b|ep=https://mtls.login/contoso.onmicrosoft.com
+ ///
+ ///
+ internal static class MsiCertificateFriendlyNameEncoder
+ {
+ public const string Prefix = "MSAL|";
+ public const string TagAlias = "alias";
+ public const string TagEp = "ep";
+
+ ///
+ /// Encodes alias and endpointBase into friendly name.
+ /// Returns false on invalid input. We do not want to throw from here.
+ /// Because persistent store is best-effort.
+ ///
+ ///
+ ///
+ ///
+ ///
+ public static bool TryEncode(string alias, string endpointBase, out string friendlyName)
+ {
+ friendlyName = null;
+
+ if (string.IsNullOrWhiteSpace(alias) || string.IsNullOrWhiteSpace(endpointBase))
+ return false;
+
+ alias = alias.Trim();
+ endpointBase = endpointBase.Trim();
+
+ // Forbid characters that would break our simple delimiter-based grammar.
+ if (ContainsIllegal(alias) || ContainsIllegal(endpointBase))
+ return false;
+
+ friendlyName = Prefix + TagAlias + "=" + alias + "|" + TagEp + "=" + endpointBase;
+ return true;
+ }
+
+ ///
+ /// Decodes friendly name into alias and endpointBase.
+ /// Returns false on invalid input. We do not want to throw from here.
+ /// Because persistent store is best-effort.
+ ///
+ ///
+ ///
+ ///
+ ///
+ public static bool TryDecode(string friendlyName, out string alias, out string endpointBase)
+ {
+ alias = null;
+ endpointBase = null;
+
+ if (string.IsNullOrEmpty(friendlyName) ||
+ !friendlyName.StartsWith(Prefix, StringComparison.Ordinal))
+ {
+ return false;
+ }
+
+ // Example: MSAL|alias=ManagedIdentityId|ep=https://mtls.login/1234-tenant
+ var payload = friendlyName.Substring(Prefix.Length);
+ var parts = payload.Split(new[] { '|' }, StringSplitOptions.RemoveEmptyEntries);
+
+ // Parse key-value pairs
+ foreach (var part in parts)
+ {
+ var kv = part.Split(new[] { '=' }, 2);
+ if (kv.Length != 2)
+ continue;
+
+ var k = kv[0].Trim();
+ var v = kv[1].Trim();
+
+ if (k.Equals(TagAlias, StringComparison.Ordinal))
+ {
+ alias = v; // minimal: last-wins
+ }
+ else if (k.Equals(TagEp, StringComparison.Ordinal))
+ {
+ endpointBase = v;
+ }
+ }
+
+ return !string.IsNullOrWhiteSpace(alias) && !string.IsNullOrWhiteSpace(endpointBase);
+ }
+
+ ///
+ /// Checks for illegal characters in alias/endpointBase.
+ /// Endpoint itself comes from IMDS and is well-formed, but we still validate.
+ /// Returns true if illegal characters are found.
+ ///
+ ///
+ private static bool ContainsIllegal(string value)
+ {
+ for (int i = 0; i < value.Length; i++)
+ {
+ char c = value[i];
+ if (c == '|' || c == '\r' || c == '\n' || c == '\0')
+ return true;
+ }
+ return false;
+ }
+ }
+}
diff --git a/src/client/Microsoft.Identity.Client/ManagedIdentity/V2/MtlsBindingInfo.cs b/src/client/Microsoft.Identity.Client/ManagedIdentity/V2/MtlsBindingInfo.cs
new file mode 100644
index 0000000000..c05fb3a508
--- /dev/null
+++ b/src/client/Microsoft.Identity.Client/ManagedIdentity/V2/MtlsBindingInfo.cs
@@ -0,0 +1,38 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+using System;
+using System.Security.Cryptography.X509Certificates;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Identity.Client.Core;
+
+namespace Microsoft.Identity.Client.ManagedIdentity.V2
+{
+ ///
+ /// mTLS binding information: certificate, endpoint, client ID.
+ ///
+ internal sealed class MtlsBindingInfo
+ {
+ ///
+ /// mTLS binding info constructor.
+ ///
+ ///
+ ///
+ ///
+ ///
+ public MtlsBindingInfo(
+ X509Certificate2 certificate,
+ string endpoint,
+ string clientId)
+ {
+ Certificate = certificate ?? throw new ArgumentNullException(nameof(certificate));
+ Endpoint = endpoint ?? throw new ArgumentNullException(nameof(endpoint));
+ ClientId = clientId ?? throw new ArgumentNullException(nameof(clientId));
+ }
+
+ public X509Certificate2 Certificate { get; }
+ public string Endpoint { get; }
+ public string ClientId { get; }
+ }
+}
diff --git a/src/client/Microsoft.Identity.Client/ManagedIdentity/V2/MtlsCertificateCache.cs b/src/client/Microsoft.Identity.Client/ManagedIdentity/V2/MtlsCertificateCache.cs
new file mode 100644
index 0000000000..fbea8e00d4
--- /dev/null
+++ b/src/client/Microsoft.Identity.Client/ManagedIdentity/V2/MtlsCertificateCache.cs
@@ -0,0 +1,147 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+using System;
+using System.Security.Cryptography.X509Certificates;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Identity.Client.Core;
+using Microsoft.Identity.Client.PlatformsCommon.Shared;
+
+namespace Microsoft.Identity.Client.ManagedIdentity.V2
+{
+ ///
+ /// Orchestrates mTLS binding retrieval:
+ /// 1) local in-memory cache
+ /// 2) per-key async gate (dedup concurrent mint)
+ /// 3) persisted cache (best-effort)
+ /// 4) factory mint + back-fill
+ /// Persistence is best-effort and non-throwing.
+ ///
+ internal sealed class MtlsBindingCache : IMtlsCertificateCache
+ {
+ private readonly KeyedSemaphorePool _gates = new();
+ private readonly ICertificateCache _memory;
+ private readonly IPersistentCertificateCache _persisted;
+
+ ///
+ /// Inject both caches to avoid global state and enable testing.
+ ///
+ public MtlsBindingCache(ICertificateCache memory, IPersistentCertificateCache persisted)
+ {
+ _memory = memory ?? throw new ArgumentNullException(nameof(memory));
+ _persisted = persisted ?? throw new ArgumentNullException(nameof(persisted));
+ }
+
+ ///
+ /// Get or create mTLS binding info
+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
+ public async Task GetOrCreateAsync(
+ string cacheKey,
+ Func> factory,
+ CancellationToken cancellationToken,
+ ILoggerAdapter logger)
+ {
+ if (string.IsNullOrWhiteSpace(cacheKey))
+ {
+ throw new ArgumentException("cacheKey must be non-empty.", nameof(cacheKey));
+ }
+
+ if (factory is null)
+ {
+ throw new ArgumentNullException(nameof(factory));
+ }
+
+ // 1) In-memory cache first
+ if (_memory.TryGet(cacheKey, out var cachedEntry, logger))
+ {
+ logger.Verbose(() =>
+ $"[PersistentCert] mTLS binding cache HIT (memory) for '{cacheKey}'.");
+
+ return new MtlsBindingInfo(
+ cachedEntry.Certificate,
+ cachedEntry.Endpoint,
+ cachedEntry.ClientId);
+ }
+
+ // 2) Per-key gate (dedupe concurrent mint)
+ await _gates.EnterAsync(cacheKey, cancellationToken).ConfigureAwait(false);
+
+ try
+ {
+ // Re-check after acquiring the gate
+ if (_memory.TryGet(cacheKey, out cachedEntry, logger))
+ {
+ logger.Verbose(() =>
+ $"[PersistentCert] mTLS binding cache HIT (memory-after-gate) for '{cacheKey}'.");
+
+ return new MtlsBindingInfo(
+ cachedEntry.Certificate,
+ cachedEntry.Endpoint,
+ cachedEntry.ClientId);
+ }
+
+ // 3) Persistent cache (best-effort)
+ if (_persisted.Read(cacheKey, out var persistedEntry, logger))
+ {
+ logger.Verbose(() =>
+ $"[PersistentCert] mTLS binding cache HIT (persistent) for '{cacheKey}'.");
+
+ if (persistedEntry.Certificate.HasPrivateKey)
+ {
+ var memoryEntry = new CertificateCacheValue(
+ persistedEntry.Certificate,
+ persistedEntry.Endpoint,
+ persistedEntry.ClientId);
+
+ _memory.Set(cacheKey, in memoryEntry, logger);
+
+ return new MtlsBindingInfo(
+ memoryEntry.Certificate,
+ memoryEntry.Endpoint,
+ memoryEntry.ClientId);
+ }
+
+ // Defensive: persisted entry is unusable; dispose and mint new
+ persistedEntry.Certificate.Dispose();
+ logger.Verbose(() =>
+ "[PersistentCert] Skipping persisted cert without private key; minting new.");
+ }
+
+ // 4) Mint + back-fill mem + best-effort persist + prune
+ var mintedBinding = await factory().ConfigureAwait(false);
+
+ logger.Verbose(() =>
+ $"[PersistentCert] mTLS binding cache MISS -> minted new binding for '{cacheKey}'.");
+
+ var createdEntry = new CertificateCacheValue(
+ mintedBinding.Certificate,
+ mintedBinding.Endpoint,
+ mintedBinding.ClientId);
+
+ _memory.Set(cacheKey, in createdEntry, logger);
+
+ // Persist newest binding for this alias (best-effort; failures are logged by the implementation).
+ _persisted.Write(cacheKey, mintedBinding.Certificate, mintedBinding.Endpoint, logger);
+
+ // Then prune older/expired entries for this alias to keep the store bounded.
+ // This is also best-effort and must not throw.
+ _persisted.Delete(cacheKey, logger);
+
+ // Pass through the factory result (already an MtlsBindingInfo)
+ return mintedBinding;
+ }
+ finally
+ {
+ _gates.Release(cacheKey);
+ }
+ }
+ }
+}
diff --git a/src/client/Microsoft.Identity.Client/ManagedIdentity/V2/NoOpPersistentCertificateCache.cs b/src/client/Microsoft.Identity.Client/ManagedIdentity/V2/NoOpPersistentCertificateCache.cs
new file mode 100644
index 0000000000..18ae4d99cc
--- /dev/null
+++ b/src/client/Microsoft.Identity.Client/ManagedIdentity/V2/NoOpPersistentCertificateCache.cs
@@ -0,0 +1,30 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+using System.Security.Cryptography.X509Certificates;
+using Microsoft.Identity.Client.Core;
+
+namespace Microsoft.Identity.Client.ManagedIdentity.V2
+{
+ ///
+ /// Disabled persistence for platforms where FriendlyName tagging is unsupported.
+ ///
+ internal sealed class NoOpPersistentCertificateCache : IPersistentCertificateCache
+ {
+ public bool Read(string alias, out CertificateCacheValue value, ILoggerAdapter logger)
+ {
+ value = default;
+ return false;
+ }
+
+ public void Write(string alias, X509Certificate2 cert, string endpointBase, ILoggerAdapter logger)
+ {
+ // no-op
+ }
+
+ public void Delete(string alias, ILoggerAdapter logger)
+ {
+ // no-op
+ }
+ }
+}
diff --git a/src/client/Microsoft.Identity.Client/ManagedIdentity/V2/PersistentCertificateCacheFactory.cs b/src/client/Microsoft.Identity.Client/ManagedIdentity/V2/PersistentCertificateCacheFactory.cs
new file mode 100644
index 0000000000..9545cd6ca5
--- /dev/null
+++ b/src/client/Microsoft.Identity.Client/ManagedIdentity/V2/PersistentCertificateCacheFactory.cs
@@ -0,0 +1,31 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+using System;
+using Microsoft.Identity.Client.Core;
+using Microsoft.Identity.Client.PlatformsCommon.Shared;
+
+namespace Microsoft.Identity.Client.ManagedIdentity.V2
+{
+ internal static class PersistentCertificateCacheFactory
+ {
+ private const string DisableEnvVar = "MSAL_MI_DISABLE_PERSISTENT_CERT_CACHE";
+
+ public static IPersistentCertificateCache Create(ILoggerAdapter logger)
+ {
+ var disable = Environment.GetEnvironmentVariable(DisableEnvVar);
+ if (!string.IsNullOrEmpty(disable) &&
+ (disable.Equals("1", StringComparison.OrdinalIgnoreCase) ||
+ disable.Equals("true", StringComparison.OrdinalIgnoreCase)))
+ {
+ logger.Info(() => "[PersistentCert] No-op persistent cache enabled via " + DisableEnvVar + ".");
+ return new NoOpPersistentCertificateCache();
+ }
+
+ // We persist only on Windows because FriendlyName tagging is required.
+ return DesktopOsHelper.IsWindows()
+ ? new WindowsPersistentCertificateCache()
+ : new NoOpPersistentCertificateCache();
+ }
+ }
+}
diff --git a/src/client/Microsoft.Identity.Client/ManagedIdentity/V2/WindowsPersistentCertificateCache.cs b/src/client/Microsoft.Identity.Client/ManagedIdentity/V2/WindowsPersistentCertificateCache.cs
new file mode 100644
index 0000000000..718a209828
--- /dev/null
+++ b/src/client/Microsoft.Identity.Client/ManagedIdentity/V2/WindowsPersistentCertificateCache.cs
@@ -0,0 +1,322 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+using System;
+using System.Linq;
+using System.Security.Cryptography.X509Certificates;
+using Microsoft.Identity.Client.Core;
+using Microsoft.Identity.Client.PlatformsCommon.Shared;
+
+namespace Microsoft.Identity.Client.ManagedIdentity.V2
+{
+ ///
+ /// Best-effort persistence for IMDSv2 mTLS binding certificates in the
+ /// CurrentUser\My store on Windows.
+ ///
+ ///
+ /// Selection:
+ ///
+ /// -
+ /// Filter by FriendlyName: MSAL|alias=<cacheKey>|ep=<base>.
+ ///
+ /// -
+ /// Require HasPrivateKey and remaining lifetime>=24h; pick newest NotAfter.
+ ///
+ /// -
+ /// Canonical client_id is the GUID in the certificate CN.
+ ///
+ ///
+ /// Notes: operations are non-throwing and must not block token acquisition.
+ /// FriendlyName tagging semantics are Windows-only.
+ ///
+ internal sealed class WindowsPersistentCertificateCache : IPersistentCertificateCache
+ {
+ public bool Read(string alias, out CertificateCacheValue value, ILoggerAdapter logger)
+ {
+ value = default;
+
+ try
+ {
+ using var store = new X509Store(StoreName.My, StoreLocation.CurrentUser);
+ store.Open(OpenFlags.OpenExistingOnly | OpenFlags.ReadOnly);
+
+ // Snapshot first to avoid provider quirks ("collection modified" during enumeration).
+ X509Certificate2[] items;
+ try
+ {
+ items = new X509Certificate2[store.Certificates.Count];
+ store.Certificates.CopyTo(items, 0);
+ }
+ catch (Exception ex)
+ {
+ logger.Verbose(() => "[PersistentCert] Store snapshot via CopyTo failed; falling back to enumeration. Details: " + ex.Message);
+ items = store.Certificates.Cast().ToArray();
+ }
+
+ X509Certificate2 best = null;
+ string bestEndpoint = null;
+ DateTime bestNotAfter = DateTime.MinValue;
+
+ foreach (var candidate in items)
+ {
+ try
+ {
+ if (!MsiCertificateFriendlyNameEncoder.TryDecode(candidate.FriendlyName, out var decodedAlias, out var endpointBase))
+ {
+ continue;
+ }
+
+ if (!StringComparer.Ordinal.Equals(decodedAlias, alias))
+ {
+ continue;
+ }
+
+ // ≥ 24h remaining
+ if (candidate.NotAfter.ToUniversalTime() <= DateTime.UtcNow + CertificateCacheEntry.MinRemainingLifetime)
+ {
+ continue;
+ }
+
+ // Defensive read-time check: only usable entries
+ if (!candidate.HasPrivateKey)
+ {
+ logger.Verbose(() => "[PersistentCert] Candidate skipped at read: no private key.");
+ continue;
+ }
+
+ if (candidate.NotAfter > bestNotAfter)
+ {
+ best?.Dispose();
+ best = new X509Certificate2(candidate); // caller-owned clone (preserves private key link)
+ bestEndpoint = endpointBase;
+ bestNotAfter = candidate.NotAfter;
+ }
+ }
+ finally
+ {
+ candidate.Dispose();
+ }
+ }
+
+ if (best != null)
+ {
+ // CN (GUID) → canonical client_id
+ string cn = null;
+ try
+ {
+ cn = best.GetNameInfo(X509NameType.SimpleName, false);
+ }
+ catch (Exception ex)
+ {
+ logger.Verbose(() => "[PersistentCert] Failed to read CN from selected certificate: " + ex.Message);
+ }
+
+ if (!Guid.TryParse(cn, out var clientIdGuid))
+ {
+ best.Dispose();
+ logger.Verbose(() => "[PersistentCert] Selected entry CN is not a GUID; skipping.");
+ return false;
+ }
+
+ value = new CertificateCacheValue(best, bestEndpoint, clientIdGuid.ToString("D"));
+ logger.Info(() => "[PersistentCert] Reused certificate from CurrentUser/My.");
+ return true;
+ }
+ }
+ catch (Exception ex)
+ {
+ logger.Verbose(() => "[PersistentCert] Store lookup failed: " + ex.Message);
+ }
+
+ return false;
+ }
+
+ public void Write(string alias, X509Certificate2 cert, string endpointBase, ILoggerAdapter logger)
+ {
+ if (cert == null)
+ return;
+
+ // IMDSv2 attaches the private key earlier (will throw if it cannot).
+ // We do not block here; log defensively if we see a public-only cert.
+ if (!cert.HasPrivateKey)
+ {
+ logger.Verbose(() => "[PersistentCert] Unexpected: Write() received a cert without a private key. Continuing best-effort.");
+ }
+
+ if (!MsiCertificateFriendlyNameEncoder.TryEncode(alias, endpointBase, out var friendlyName))
+ {
+ logger.Verbose(() => "[PersistentCert] FriendlyName encode failed; skipping persist.");
+ return;
+ }
+
+ // Best-effort: short, non-configurable timeout. We intentionally do not retry here:
+ // if the lock is busy we skip persistence and fall back to in-memory cache only,
+ // so token acquisition is never blocked on certificate store operations.
+ InterprocessLock.TryWithAliasLock(
+ alias,
+ timeout: TimeSpan.FromMilliseconds(300),
+ action: () =>
+ {
+ try
+ {
+ using var store = new X509Store(StoreName.My, StoreLocation.CurrentUser);
+ store.Open(OpenFlags.ReadWrite);
+
+ var nowUtc = DateTime.UtcNow;
+ var newNotAfterUtc = cert.NotAfter.ToUniversalTime();
+
+ // Skip write if a newer/equal, non-expired binding for this alias already exists.
+ DateTime newestForAliasUtc = DateTime.MinValue;
+
+ X509Certificate2[] present;
+ try
+ {
+ present = new X509Certificate2[store.Certificates.Count];
+ store.Certificates.CopyTo(present, 0);
+ }
+ catch (Exception ex)
+ {
+ logger.Verbose(() => "[PersistentCert] Store snapshot via CopyTo failed; falling back to enumeration. Details: " + ex.Message);
+ present = store.Certificates.Cast().ToArray();
+ }
+
+ foreach (var existing in present)
+ {
+ try
+ {
+ if (!MsiCertificateFriendlyNameEncoder.TryDecode(existing.FriendlyName, out var existingAlias, out _))
+ {
+ continue;
+ }
+
+ if (!StringComparer.Ordinal.Equals(existingAlias, alias))
+ {
+ continue;
+ }
+
+ var existingNotAfterUtc = existing.NotAfter.ToUniversalTime();
+ if (existingNotAfterUtc > newestForAliasUtc)
+ {
+ newestForAliasUtc = existingNotAfterUtc;
+ }
+ }
+ finally
+ {
+ existing.Dispose();
+ }
+ }
+
+ if (newestForAliasUtc != DateTime.MinValue &&
+ newestForAliasUtc >= newNotAfterUtc &&
+ newestForAliasUtc > nowUtc)
+ {
+ logger.Verbose(() => "[PersistentCert] Newer/equal cert already present; skipping add.");
+ return;
+ }
+
+ try
+ {
+ try
+ {
+ cert.FriendlyName = friendlyName;
+ }
+ catch (Exception exSet)
+ {
+ logger.Verbose(() => "[PersistentCert] Could not set FriendlyName; skipping persist. " + exSet.Message);
+ return;
+ }
+
+ // Add the original instance (carries private key if present)
+ store.Add(cert);
+ logger.Info(() => "[PersistentCert] Persisted certificate to CurrentUser/My.");
+
+ // Conservative cleanup: remove expired entries for this alias only
+ PruneExpiredForAlias(store, alias, nowUtc, logger);
+ }
+ catch (Exception exAdd)
+ {
+ logger.Verbose(() => "[PersistentCert] Persist failed: " + exAdd.Message);
+ }
+ }
+ catch (Exception exOuter)
+ {
+ logger.Verbose(() => "[PersistentCert] Persist failed: " + exOuter.Message);
+ }
+ },
+ logVerbose: s => logger.Verbose(() => s));
+ }
+
+ public void Delete(string alias, ILoggerAdapter logger)
+ {
+ // Best-effort: short, non-configurable timeout. We intentionally do not retry here:
+ // if the lock is busy we skip persistence and fall back to in-memory cache only,
+ // so token acquisition is never blocked on certificate store operations.
+ InterprocessLock.TryWithAliasLock(
+ alias,
+ timeout: TimeSpan.FromMilliseconds(300),
+ action: () =>
+ {
+ try
+ {
+ using var store = new X509Store(StoreName.My, StoreLocation.CurrentUser);
+ store.Open(OpenFlags.ReadWrite);
+ PruneExpiredForAlias(store, alias, DateTime.UtcNow, logger);
+ }
+ catch (Exception ex)
+ {
+ logger.Verbose(() => "[PersistentCert] Delete (prune) failed: " + ex.Message);
+ }
+ },
+ logVerbose: s => logger.Verbose(() => s));
+ }
+
+ ///
+ /// Deletes only certificates that are actually expired (NotAfter < nowUtc),
+ /// scoped to the given alias (cache key) via FriendlyName.
+ ///
+ private static void PruneExpiredForAlias(
+ X509Store store,
+ string aliasCacheKey,
+ DateTime nowUtc,
+ ILoggerAdapter logger)
+ {
+ X509Certificate2[] items;
+ try
+ {
+ items = new X509Certificate2[store.Certificates.Count];
+ // Safe snapshot for providers that throw if removing while iterating
+ store.Certificates.CopyTo(items, 0);
+ }
+ catch (Exception ex)
+ {
+ logger.Verbose(() => "[PersistentCert] Prune snapshot via CopyTo failed; falling back to enumeration. Details: " + ex.Message);
+ items = store.Certificates.Cast().ToArray();
+ }
+
+ int removed = 0;
+
+ foreach (var existing in items)
+ {
+ try
+ {
+ if (!MsiCertificateFriendlyNameEncoder.TryDecode(existing.FriendlyName, out var alias, out _))
+ continue;
+ if (!StringComparer.Ordinal.Equals(alias, aliasCacheKey))
+ continue;
+
+ if (existing.NotAfter.ToUniversalTime() <= nowUtc)
+ {
+ store.Remove(existing);
+ removed++;
+ }
+ }
+ finally
+ {
+ existing.Dispose();
+ }
+ }
+
+ logger.Verbose(() => "[PersistentCert] PruneExpired completed for alias '" + aliasCacheKey + "'. Removed=" + removed + ".");
+ }
+ }
+}
diff --git a/src/client/Microsoft.Identity.Client/PlatformsCommon/Shared/KeyedSemaphorePool.cs b/src/client/Microsoft.Identity.Client/PlatformsCommon/Shared/KeyedSemaphorePool.cs
new file mode 100644
index 0000000000..d0e49015c4
--- /dev/null
+++ b/src/client/Microsoft.Identity.Client/PlatformsCommon/Shared/KeyedSemaphorePool.cs
@@ -0,0 +1,41 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+using System.Collections.Concurrent;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Microsoft.Identity.Client.PlatformsCommon.Shared
+{
+ ///
+ /// Provides per-key async gates using .
+ /// Call and ensure is called in a finally block.
+ /// Semaphores are kept for the lifetime of the pool; we do not remove or dispose them
+ /// per key to avoid races with concurrent callers.
+ ///
+ internal sealed class KeyedSemaphorePool
+ {
+ private readonly ConcurrentDictionary _gates = new();
+
+ ///
+ /// Enters the gate for . Await and pair with .
+ ///
+ public async Task EnterAsync(string key, CancellationToken cancellationToken)
+ {
+ var gate = _gates.GetOrAdd(key, static _ => new SemaphoreSlim(1, 1));
+ await gate.WaitAsync(cancellationToken).ConfigureAwait(false);
+ }
+
+ ///
+ /// Releases the gate for . Safe to call even if the key
+ /// is missing; a missing key is a no-op.
+ ///
+ public void Release(string key)
+ {
+ if (_gates.TryGetValue(key, out var gate))
+ {
+ gate.Release();
+ }
+ }
+ }
+}
diff --git a/tests/Microsoft.Identity.Test.Unit/ManagedIdentityTests/FriendlyNameCodecTests.cs b/tests/Microsoft.Identity.Test.Unit/ManagedIdentityTests/FriendlyNameCodecTests.cs
new file mode 100644
index 0000000000..8d86534f2a
--- /dev/null
+++ b/tests/Microsoft.Identity.Test.Unit/ManagedIdentityTests/FriendlyNameCodecTests.cs
@@ -0,0 +1,111 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+using Microsoft.Identity.Client.ManagedIdentity.V2;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+namespace Microsoft.Identity.Test.Unit.ManagedIdentityTests
+{
+ [TestClass]
+ public class FriendlyNameCodecTests
+ {
+ [TestMethod]
+ public void EncodeDecode_RoundTrip_Succeeds()
+ {
+ var alias = "alias-" + System.Guid.NewGuid().ToString("N");
+ var ep = "https://example.test/tenant";
+
+ Assert.IsTrue(MsiCertificateFriendlyNameEncoder.TryEncode(alias, ep, out var fn));
+ Assert.IsNotNull(fn);
+ StringAssert.StartsWith(fn, MsiCertificateFriendlyNameEncoder.Prefix);
+
+ Assert.IsTrue(MsiCertificateFriendlyNameEncoder.TryDecode(fn, out var a2, out var ep2));
+ Assert.AreEqual(alias, a2);
+ Assert.AreEqual(ep, ep2);
+ }
+
+ [DataTestMethod]
+ [DataRow("foo|bar", "https://x")]
+ [DataRow("foo\nbar", "https://x")]
+ [DataRow("foo", "https://x|y")]
+ [DataRow("foo", "https://x\ny")]
+ [DataRow(null, "https://x")]
+ [DataRow(" ", "https://x")]
+ [DataRow("foo", null)]
+ [DataRow("foo", " ")]
+ [DataRow("bad|alias", "https://ok")]
+ [DataRow("ok", "https://bad|ep")]
+ public void TryEncode_Rejects_IllegalInputs(string alias, string endpointBase)
+ {
+ Assert.IsFalse(MsiCertificateFriendlyNameEncoder.TryEncode(alias, endpointBase, out _));
+ }
+
+ [TestMethod]
+ public void TryDecode_InvalidPrefix_ReturnsFalse()
+ {
+ // Wrong prefix
+ var bad = "NOTMSAL|alias=a|ep=b";
+ Assert.IsFalse(MsiCertificateFriendlyNameEncoder.TryDecode(bad, out _, out _));
+
+ // Missing tags
+ var missing = MsiCertificateFriendlyNameEncoder.Prefix + "alias=a";
+ Assert.IsFalse(MsiCertificateFriendlyNameEncoder.TryDecode(missing, out _, out _));
+ }
+
+ [TestMethod]
+ public void Decode_Ignores_Unknown_Tags_LastWins()
+ {
+ var fn = MsiCertificateFriendlyNameEncoder.Prefix +
+ MsiCertificateFriendlyNameEncoder.TagAlias + "=a|" +
+ "xtra=foo|" +
+ MsiCertificateFriendlyNameEncoder.TagEp + "=E";
+ Assert.IsTrue(MsiCertificateFriendlyNameEncoder.TryDecode(fn, out var a, out var e));
+ Assert.AreEqual("a", a);
+ Assert.AreEqual("E", e);
+ }
+
+ [TestMethod]
+ public void EncodeDecode_VeryLongAliasAndEndpoint_Succeeds()
+ {
+ var alias = new string('a', 2048);
+ var ep = "https://example.test/" + new string('b', 2048);
+
+ Assert.IsTrue(MsiCertificateFriendlyNameEncoder.TryEncode(alias, ep, out var fn));
+ Assert.IsNotNull(fn);
+
+ Assert.IsTrue(MsiCertificateFriendlyNameEncoder.TryDecode(fn, out var a2, out var ep2));
+ Assert.AreEqual(alias, a2);
+ Assert.AreEqual(ep, ep2);
+ }
+
+ [TestMethod]
+ public void EncodeDecode_UnicodeAliasAndEndpoint_Succeeds()
+ {
+ var alias = "uami-ümläüt-用户-🔐";
+ var ep = "https://例え.テスト/路径/ресурс";
+
+ Assert.IsTrue(MsiCertificateFriendlyNameEncoder.TryEncode(alias, ep, out var fn));
+ Assert.IsTrue(MsiCertificateFriendlyNameEncoder.TryDecode(fn, out var a2, out var ep2));
+
+ Assert.AreEqual(alias, a2);
+ Assert.AreEqual(ep, ep2);
+ }
+
+ [TestMethod]
+ public void TryDecode_DoublePrefixedString_IsResilient()
+ {
+ // First, build a normal friendly name.
+ var alias = "alias-double";
+ var ep = "https://ep/base";
+ Assert.IsTrue(MsiCertificateFriendlyNameEncoder.TryEncode(alias, ep, out var inner));
+
+ // Now create a "double-prefixed" string: "MSAL|MSAL|alias=...|ep=..."
+ var doublePrefixed = MsiCertificateFriendlyNameEncoder.Prefix + inner;
+
+ // Decoder should not throw and should still recover alias/endpoint.
+ Assert.IsTrue(MsiCertificateFriendlyNameEncoder.TryDecode(doublePrefixed, out var a2, out var ep2));
+ Assert.AreEqual(alias, a2);
+ Assert.AreEqual(ep, ep2);
+ }
+ }
+}
diff --git a/tests/Microsoft.Identity.Test.Unit/ManagedIdentityTests/ImdsV2TestStoreCleaner.cs b/tests/Microsoft.Identity.Test.Unit/ManagedIdentityTests/ImdsV2TestStoreCleaner.cs
new file mode 100644
index 0000000000..c2a6643292
--- /dev/null
+++ b/tests/Microsoft.Identity.Test.Unit/ManagedIdentityTests/ImdsV2TestStoreCleaner.cs
@@ -0,0 +1,255 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+using System;
+using System.Linq;
+using System.Runtime.InteropServices;
+using System.Security.Cryptography.X509Certificates;
+
+namespace Microsoft.Identity.Test.Unit.ManagedIdentityTests
+{
+ internal static class ImdsV2TestStoreCleaner
+ {
+ // Keep independent of product internals
+ private const string FriendlyPrefix = "MSAL|";
+
+ // DC (tenant) used by tests
+ private const string TestTenantId = "751a212b-4003-416e-b600-e1f48e40db9f";
+
+ // Subject CNs to clean unconditionally
+ private static readonly string[] UnconditionalCnTrash = new[]
+ {
+ "UAMI-20Y",
+ "SAMI-20Y",
+ "Test"
+ };
+
+ // Subject CNs to clean only when DC == TestTenantId
+ private static readonly string[] ConditionalCnTrash = new[]
+ {
+ "system_assigned_managed_identity",
+ "d3adb33f-c0de-ed0c-c0de-deadb33fc0d3"
+ };
+
+ public static bool IsWindows => RuntimeInformation.IsOSPlatform(OSPlatform.Windows);
+
+ ///
+ /// Remove all persisted entries that look like test artifacts:
+ /// - FriendlyName starts with "MSAL|" and either has a fake endpoint or is expired-by-policy
+ /// - Subject CN matches test CNs (UAMI-20Y, SAMI-20Y, Test)
+ /// - Subject CN matches certain values AND subject DC equals the test tenant
+ /// Best-effort, no-throw.
+ ///
+ public static void RemoveAllTestArtifacts()
+ {
+ if (!IsWindows)
+ return;
+
+ try
+ {
+ using var store = new X509Store(StoreName.My, StoreLocation.CurrentUser);
+ store.Open(OpenFlags.ReadWrite);
+
+ var nowUtc = DateTime.UtcNow;
+
+ // Snapshot once to safely enumerate while removing
+ X509Certificate2[] items = SnapshotCertificates(store);
+
+ foreach (var c in items)
+ {
+ try
+ {
+ var fn = c.FriendlyName ?? string.Empty;
+
+ // Case 1: Our persisted entries with fake endpoints or expired-by-policy
+ if (fn.StartsWith(FriendlyPrefix, StringComparison.Ordinal))
+ {
+ bool looksFakeEp =
+ fn.IndexOf("|ep=http://", StringComparison.OrdinalIgnoreCase) >= 0 ||
+ fn.IndexOf("localhost", StringComparison.OrdinalIgnoreCase) >= 0 ||
+ fn.IndexOf("fake", StringComparison.OrdinalIgnoreCase) >= 0;
+
+ bool expiredByPolicy =
+ c.NotAfter.ToUniversalTime() <= nowUtc +
+ Microsoft.Identity.Client.ManagedIdentity.V2.CertificateCacheEntry.MinRemainingLifetime;
+
+ if (looksFakeEp || expiredByPolicy)
+ {
+ try
+ { store.Remove(c); }
+ catch { /* ignore */ }
+ continue;
+ }
+ }
+
+ // Case 2: Subject-based cleanup for test certs
+ var cn = GetCn(c);
+ var dc = GetDc(c);
+
+ if (MatchesSubjectTrash(cn, dc))
+ {
+ try
+ { store.Remove(c); }
+ catch { /* ignore */ }
+ continue;
+ }
+ }
+ finally
+ {
+ c.Dispose();
+ }
+ }
+ }
+ catch
+ {
+ // best-effort
+ }
+ }
+
+ ///
+ /// Remove all entries for a specific alias (cache key) based on FriendlyName.
+ /// Best-effort, no-throw.
+ ///
+ public static void RemoveAlias(string alias)
+ {
+ if (!IsWindows || string.IsNullOrWhiteSpace(alias))
+ return;
+
+ try
+ {
+ using var store = new X509Store(StoreName.My, StoreLocation.CurrentUser);
+ store.Open(OpenFlags.ReadWrite);
+
+ // Snapshot for safe removal
+ X509Certificate2[] items = SnapshotCertificates(store);
+
+ foreach (var c in items)
+ {
+ try
+ {
+ var fn = c.FriendlyName ?? string.Empty;
+ if (fn.StartsWith(FriendlyPrefix, StringComparison.Ordinal) &&
+ fn.Contains("alias="))
+ {
+ try
+ { store.Remove(c); }
+ catch { /* ignore */ }
+ }
+ }
+ finally
+ {
+ c.Dispose();
+ }
+ }
+ }
+ catch
+ {
+ // best-effort
+ }
+ }
+
+ // ---------------- helpers ----------------
+
+ ///
+ /// Takes a safe snapshot of the certificates in the given store so we can
+ /// enumerate and remove without running into "collection modified" issues.
+ ///
+ private static X509Certificate2[] SnapshotCertificates(X509Store store)
+ {
+ try
+ {
+ var items = new X509Certificate2[store.Certificates.Count];
+ store.Certificates.CopyTo(items, 0);
+ return items;
+ }
+ catch
+ {
+ // Fallback for providers that don't like CopyTo while removing.
+ return store.Certificates.Cast().ToArray();
+ }
+ }
+
+ ///
+ /// Determines whether the given subject CN/DC pair should be treated as a test
+ /// artifact and removed from the store.
+ ///
+ private static bool MatchesSubjectTrash(string cn, string dc)
+ {
+ if (string.IsNullOrEmpty(cn))
+ return false;
+
+ // Unconditional CNs
+ foreach (var name in UnconditionalCnTrash)
+ {
+ if (string.Equals(cn, name, StringComparison.Ordinal))
+ return true;
+ }
+
+ // Conditional CNs (require test tenant DC)
+ if (!string.IsNullOrEmpty(dc) &&
+ string.Equals(dc, TestTenantId, StringComparison.OrdinalIgnoreCase))
+ {
+ foreach (var name in ConditionalCnTrash)
+ {
+ if (string.Equals(cn, name, StringComparison.OrdinalIgnoreCase))
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ ///
+ /// Extracts the subject common name (CN) from the certificate, using
+ /// when possible and falling back
+ /// to manual DN parsing.
+ ///
+ private static string GetCn(X509Certificate2 cert)
+ {
+ try
+ {
+ var simple = cert.GetNameInfo(X509NameType.SimpleName, forIssuer: false);
+ if (!string.IsNullOrEmpty(simple))
+ return simple;
+ }
+ catch
+ {
+ // fall through to manual parse
+ }
+
+ return ReadRdn(cert, "CN");
+ }
+
+ ///
+ /// Extracts the subject DC (domain component) from the certificate, if present.
+ ///
+ private static string GetDc(X509Certificate2 cert)
+ {
+ return ReadRdn(cert, "DC");
+ }
+
+ ///
+ /// Parses a specific RDN (e.g. "CN", "DC") out of the certificate subject.
+ /// Returns null if the RDN is not present.
+ ///
+ private static string ReadRdn(X509Certificate2 cert, string rdn)
+ {
+ var dn = cert?.SubjectName?.Name ?? cert?.Subject ?? string.Empty;
+ if (string.IsNullOrEmpty(dn))
+ return null;
+
+ // Simple, robust split: "CN=..., DC=..." etc.
+ var parts = dn.Split(',');
+ foreach (var part in parts)
+ {
+ var kv = part.Split('=');
+ if (kv.Length == 2 &&
+ kv[0].Trim().Equals(rdn, StringComparison.OrdinalIgnoreCase))
+ {
+ return kv[1].Trim().Trim('"');
+ }
+ }
+ return null;
+ }
+ }
+}
diff --git a/tests/Microsoft.Identity.Test.Unit/ManagedIdentityTests/ImdsV2Tests.cs b/tests/Microsoft.Identity.Test.Unit/ManagedIdentityTests/ImdsV2Tests.cs
index ab9a5d2a4a..da89c2c3fa 100644
--- a/tests/Microsoft.Identity.Test.Unit/ManagedIdentityTests/ImdsV2Tests.cs
+++ b/tests/Microsoft.Identity.Test.Unit/ManagedIdentityTests/ImdsV2Tests.cs
@@ -54,6 +54,17 @@ private static readonly Func RuntimeInformation.IsOSPlatform(OSPlatform.Windows);
+
+ [TestMethod]
+ public void GetMutexName_Format_And_Canonicalization()
+ {
+ if (!IsWindows)
+ { Assert.Inconclusive("Windows-only"); return; }
+
+ var aliasRaw = " my-alias ";
+ var globalName = InterprocessLock.GetMutexNameForAlias(aliasRaw, preferGlobal: true);
+ var localName = InterprocessLock.GetMutexNameForAlias(aliasRaw, preferGlobal: false);
+
+ StringAssert.StartsWith(globalName, @"Global\MSAL_MI_P_");
+ StringAssert.StartsWith(localName, @"Local\MSAL_MI_P_");
+
+ // Same alias after canonicalization should produce same suffix across scopes (ignoring prefix)
+ var globalName2 = InterprocessLock.GetMutexNameForAlias("MY-ALIAS", preferGlobal: true);
+ Assert.AreEqual(
+ globalName.Substring(@"Global\".Length),
+ globalName2.Substring(@"Global\".Length),
+ "Canonicalized alias should produce the same hashed suffix.");
+ }
+
+ [TestMethod]
+ public void TryWithAliasLock_Executes_Action()
+ {
+ if (!IsWindows)
+ { Assert.Inconclusive("Windows-only"); return; }
+
+ var alias = "lock-test-" + Guid.NewGuid().ToString("N");
+ var called = 0;
+
+ // Best-effort: short, non-configurable timeout. We intentionally do not retry here:
+ // if the lock is busy we skip persistence and fall back to in-memory cache only,
+ // so token acquisition is never blocked on certificate store operations.
+ var ok = InterprocessLock.TryWithAliasLock(
+ alias,
+ timeout: TimeSpan.FromMilliseconds(250),
+ action: () => Interlocked.Increment(ref called),
+ logVerbose: _ => { });
+
+ Assert.IsTrue(ok);
+ Assert.AreEqual(1, called);
+ }
+
+ [TestMethod]
+ public void TryWithAliasLock_Contention_Skips_IfBusy()
+ {
+ if (!IsWindows)
+ { Assert.Inconclusive("Windows-only"); return; }
+
+ var alias = "lock-busy-" + Guid.NewGuid().ToString("N");
+ using var gate = new ManualResetEventSlim(false);
+
+ // Thread A: hold the lock for ~500ms
+ var t = new Thread(() =>
+ {
+ InterprocessLock.TryWithAliasLock(
+ alias,
+ timeout: TimeSpan.FromMilliseconds(250),
+ action: () =>
+ {
+ gate.Set(); // signal ready
+ Thread.Sleep(500); // hold the lock
+ },
+ logVerbose: _ => { });
+ });
+ t.IsBackground = true;
+ t.Start();
+
+ // Wait until A holds the lock
+ Assert.IsTrue(gate.Wait(2000));
+
+ // Thread B: attempt with small timeout, expect "busy" (returns false)
+ var got = InterprocessLock.TryWithAliasLock(
+ alias,
+ timeout: TimeSpan.FromMilliseconds(50),
+ action: () => Assert.Fail("Should not enter under contention"),
+ logVerbose: _ => { });
+
+ Assert.IsFalse(got);
+
+ t.Join();
+ }
+
+ [TestMethod]
+ public void TryWithAliasLock_NullAndEmptyAlias_DoNotThrow()
+ {
+ // null alias
+ int nullCalls = 0;
+ bool nullResult = InterprocessLock.TryWithAliasLock(
+ null,
+ TimeSpan.FromSeconds(2),
+ () => Interlocked.Increment(ref nullCalls),
+ logVerbose: _ => { });
+
+ // empty/whitespace alias
+ int emptyCalls = 0;
+ bool emptyResult = InterprocessLock.TryWithAliasLock(
+ " ",
+ TimeSpan.FromSeconds(2),
+ () => Interlocked.Increment(ref emptyCalls),
+ logVerbose: _ => { });
+
+ Assert.IsTrue(nullResult, "Null alias should still execute the action.");
+ Assert.AreEqual(1, nullCalls);
+
+ Assert.IsTrue(emptyResult, "Whitespace alias should still execute the action.");
+ Assert.AreEqual(1, emptyCalls);
+ }
+
+ [TestMethod]
+ public void TryWithAliasLock_VeryLongAlias_DoesNotThrow()
+ {
+ string veryLongAlias = new string('a', 10_000);
+ int calls = 0;
+
+ bool result = InterprocessLock.TryWithAliasLock(
+ veryLongAlias,
+ TimeSpan.FromSeconds(2),
+ () => Interlocked.Increment(ref calls),
+ logVerbose: _ => { });
+
+ Assert.IsTrue(result);
+ Assert.AreEqual(1, calls);
+ }
+
+ [TestMethod]
+ public void TryWithAliasLock_MultipleConcurrentAttempts_AreSerialized()
+ {
+ const string alias = "concurrent-alias";
+ int inCritical = 0;
+ int maxInCritical = 0;
+ int executed = 0;
+
+ var tasks = new List();
+
+ for (int i = 0; i < 8; i++)
+ {
+ tasks.Add(Task.Run(() =>
+ {
+ bool acquired = InterprocessLock.TryWithAliasLock(
+ alias,
+ TimeSpan.FromSeconds(5),
+ () =>
+ {
+ var current = Interlocked.Increment(ref inCritical);
+ maxInCritical = Math.Max(maxInCritical, current);
+
+ // simulate some work under the lock
+ Thread.Sleep(50);
+
+ Interlocked.Decrement(ref inCritical);
+ Interlocked.Increment(ref executed);
+ },
+ logVerbose: _ => { });
+
+ Assert.IsTrue(acquired, "Each caller should acquire the alias lock within timeout.");
+ }));
+ }
+
+ Task.WaitAll(tasks.ToArray());
+
+ Assert.AreEqual(8, executed, "All actions should have executed.");
+ Assert.AreEqual(1, maxInCritical, "At most one action should be in the critical section at a time.");
+ }
+
+ [TestMethod]
+ public void TryWithAliasLock_ActionThrows_ReturnsFalse_AndLockReleased()
+ {
+ if (!IsWindows)
+ { Assert.Inconclusive("Windows-only"); return; }
+
+ const string alias = "exception-alias";
+ int attempts = 0;
+
+ // First call: action throws; InterprocessLock should catch it and return false.
+ bool firstResult = InterprocessLock.TryWithAliasLock(
+ alias,
+ TimeSpan.FromSeconds(2),
+ () =>
+ {
+ Interlocked.Increment(ref attempts);
+ throw new InvalidOperationException("boom");
+ },
+ logVerbose: _ => { });
+
+ Assert.IsFalse(firstResult, "TryWithAliasLock should return false when the action delegate throws.");
+ Assert.AreEqual(1, attempts, "Action should have executed exactly once.");
+
+ // Second call: lock must be usable again even after the exception.
+ int secondAttempts = 0;
+ bool secondResult = InterprocessLock.TryWithAliasLock(
+ alias,
+ TimeSpan.FromSeconds(2),
+ () => Interlocked.Increment(ref secondAttempts),
+ logVerbose: _ => { });
+
+ Assert.IsTrue(secondResult, "Lock should be usable again after an exception in the action.");
+ Assert.AreEqual(1, secondAttempts, "Second call should execute exactly once.");
+ }
+ }
+}
diff --git a/tests/Microsoft.Identity.Test.Unit/ManagedIdentityTests/PersistentCertificateCacheFactoryTests.cs b/tests/Microsoft.Identity.Test.Unit/ManagedIdentityTests/PersistentCertificateCacheFactoryTests.cs
new file mode 100644
index 0000000000..5b26ec1ece
--- /dev/null
+++ b/tests/Microsoft.Identity.Test.Unit/ManagedIdentityTests/PersistentCertificateCacheFactoryTests.cs
@@ -0,0 +1,58 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+using System;
+using System.Runtime.InteropServices;
+using Microsoft.Identity.Client.Core;
+using Microsoft.Identity.Client.ManagedIdentity.V2;
+using Microsoft.Identity.Test.Common.Core.Helpers;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using NSubstitute;
+
+namespace Microsoft.Identity.Test.Unit.ManagedIdentityTests
+{
+ [TestClass]
+ public class PersistentCertificateCacheFactoryTests
+ {
+ private const string DisableEnvVar = "MSAL_MI_DISABLE_PERSISTENT_CERT_CACHE";
+
+ [DataTestMethod]
+ [DataRow("true")]
+ [DataRow("TRUE")]
+ [DataRow("1")]
+ public void Factory_Disabled_ByEnvVar(string environmentVariableValue)
+ {
+ using (new EnvVariableContext())
+ {
+ Environment.SetEnvironmentVariable(DisableEnvVar, environmentVariableValue);
+
+ var logger = Substitute.For();
+ var cache = PersistentCertificateCacheFactory.Create(logger);
+
+ Assert.IsInstanceOfType(cache, typeof(NoOpPersistentCertificateCache));
+ }
+ }
+
+ [TestMethod]
+ public void Factory_Defaults_To_Platform_When_EnvVar_Unset()
+ {
+ using (new EnvVariableContext())
+ {
+ // Ensure unset
+ Environment.SetEnvironmentVariable(DisableEnvVar, null);
+
+ var logger = Substitute.For();
+ var cache = PersistentCertificateCacheFactory.Create(logger);
+
+ if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
+ {
+ Assert.IsInstanceOfType(cache, typeof(WindowsPersistentCertificateCache));
+ }
+ else
+ {
+ Assert.IsInstanceOfType(cache, typeof(NoOpPersistentCertificateCache));
+ }
+ }
+ }
+ }
+}
diff --git a/tests/Microsoft.Identity.Test.Unit/ManagedIdentityTests/PersistentCertificateStoreUnitTests.cs b/tests/Microsoft.Identity.Test.Unit/ManagedIdentityTests/PersistentCertificateStoreUnitTests.cs
new file mode 100644
index 0000000000..7079c4b490
--- /dev/null
+++ b/tests/Microsoft.Identity.Test.Unit/ManagedIdentityTests/PersistentCertificateStoreUnitTests.cs
@@ -0,0 +1,625 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+using System;
+using System.Linq;
+using System.Runtime.InteropServices;
+using System.Security.Cryptography;
+using System.Security.Cryptography.X509Certificates;
+using System.Threading;
+using Microsoft.Identity.Client.Core;
+using Microsoft.Identity.Client.ManagedIdentity.V2;
+using Microsoft.Identity.Client.PlatformsCommon.Shared;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using NSubstitute; // for ILoggerAdapter substitute
+
+namespace Microsoft.Identity.Test.Unit.ManagedIdentityTests
+{
+ [TestClass]
+ public class PersistentCertificateStoreUnitTests
+ {
+ private static bool IsWindows => RuntimeInformation.IsOSPlatform(OSPlatform.Windows);
+
+ private static ILoggerAdapter Logger => Substitute.For();
+
+ private IPersistentCertificateCache _cache;
+
+ [TestInitialize]
+ public void ImdsV2Tests_Init()
+ {
+ // Create the platform cache once per test run.
+ // It's safe to instantiate on non-Windows; methods no-op internally.
+ _cache = new WindowsPersistentCertificateCache();
+
+ // Clean persisted store so prior DataRows/runs don't leak into this test
+ if (ImdsV2TestStoreCleaner.IsWindows)
+ {
+ // A broad sweep is simplest and safe for our fake endpoints/certs
+ ImdsV2TestStoreCleaner.RemoveAllTestArtifacts();
+ }
+ }
+
+ private static void WindowsOnly()
+ {
+ if (!IsWindows)
+ {
+ Assert.Inconclusive("Windows-only");
+ }
+ }
+
+ private static void NonWindowsOnly()
+ {
+ if (IsWindows)
+ {
+ Assert.Inconclusive("Non-Windows-only");
+ }
+ }
+
+ // --- helpers ---
+
+ private static X509Certificate2 CreateSelfSignedWithKey(string subject, TimeSpan lifetime)
+ {
+ using var rsa = RSA.Create(2048);
+
+ var req = new System.Security.Cryptography.X509Certificates.CertificateRequest(
+ new X500DistinguishedName(subject),
+ rsa,
+ HashAlgorithmName.SHA256,
+ RSASignaturePadding.Pkcs1);
+
+ DateTimeOffset notBefore, notAfter;
+
+ if (lifetime <= TimeSpan.Zero)
+ {
+ // produce an expired cert safely (notAfter < now, but still > notBefore)
+ var now = DateTimeOffset.UtcNow;
+ notBefore = now.AddDays(-2);
+ notAfter = now.AddSeconds(-30);
+ }
+ else
+ {
+ notBefore = DateTimeOffset.UtcNow.AddMinutes(-2);
+ notAfter = notBefore.Add(lifetime);
+ }
+
+ using var ephemeral = req.CreateSelfSigned(notBefore, notAfter);
+
+ // Re-import as PFX so the private key is persisted and usable across TFMs
+ var pfx = ephemeral.Export(X509ContentType.Pfx, "");
+ return new X509Certificate2(
+ pfx,
+ "",
+ X509KeyStorageFlags.Exportable | X509KeyStorageFlags.PersistKeySet);
+ }
+
+ private static void RemoveAliasFromStore(string alias)
+ {
+ using var store = new X509Store(StoreName.My, StoreLocation.CurrentUser);
+ store.Open(OpenFlags.ReadWrite);
+
+ X509Certificate2[] items;
+ try
+ {
+ items = new X509Certificate2[store.Certificates.Count];
+ store.Certificates.CopyTo(items, 0);
+ }
+ catch
+ {
+ items = store.Certificates.Cast().ToArray();
+ }
+
+ foreach (var cert in items)
+ {
+ try
+ {
+ if (MsiCertificateFriendlyNameEncoder.TryDecode(cert.FriendlyName, out var decodedAlias, out _)
+ && StringComparer.Ordinal.Equals(decodedAlias, alias))
+ {
+ try
+ { store.Remove(cert); }
+ catch { /* best-effort */ }
+ }
+ }
+ finally
+ {
+ cert.Dispose();
+ }
+ }
+ }
+
+ // Small polling helper to absorb store-write propagation timing
+ private bool WaitForFind(string alias, out CertificateCacheValue value, int retries = 10, int delayMs = 50)
+ {
+ for (int i = 0; i < retries; i++)
+ {
+ if (_cache.Read(alias, out value, Logger))
+ return true;
+
+ Thread.Sleep(delayMs);
+ }
+
+ value = default;
+ return false;
+ }
+
+ // --- tests ---
+
+ [TestMethod]
+ public void Write_Then_Read_HappyPath()
+ {
+ WindowsOnly();
+
+ var alias = "alias-happy-" + Guid.NewGuid().ToString("N");
+ var ep = "https://fake_mtls/tenantX";
+ var guid = Guid.NewGuid().ToString("D");
+
+ try
+ {
+ using var cert = CreateSelfSignedWithKey("CN=" + guid, TimeSpan.FromDays(3));
+
+ _cache.Write(alias, cert, ep, Logger);
+
+ // Verify we can find it (with a small retry to avoid timing flakes)
+ Assert.IsTrue(WaitForFind(alias, out var value), "Persisted cert should be found.");
+ Assert.IsNotNull(value.Certificate);
+ Assert.AreEqual(ep, value.Endpoint);
+ Assert.AreEqual(guid, value.ClientId);
+ Assert.IsTrue(value.Certificate.HasPrivateKey);
+ }
+ finally
+ {
+ RemoveAliasFromStore(alias);
+ }
+ }
+
+ [TestMethod]
+ public void Write_NewestWins_SkipOlder()
+ {
+ WindowsOnly();
+
+ var alias = "alias-newest-" + Guid.NewGuid().ToString("N");
+ var ep = "https://fake_mtls/tenantY";
+ var guid = Guid.NewGuid().ToString("D");
+
+ try
+ {
+ using var older = CreateSelfSignedWithKey("CN=" + guid, TimeSpan.FromDays(2));
+ using var newer = CreateSelfSignedWithKey("CN=" + guid, TimeSpan.FromDays(3));
+
+ // Persist older first, then newer
+ _cache.Write(alias, older, ep, Logger);
+ _cache.Write(alias, newer, ep, Logger);
+
+ // Selection should return the newer one (by NotAfter)
+ Assert.IsTrue(WaitForFind(alias, out var value), "Expected to find persisted cert.");
+ var delta = Math.Abs((value.Certificate.NotAfter - newer.NotAfter).TotalSeconds);
+ Assert.IsTrue(delta <= 2, "Newest persisted cert should be selected.");
+ }
+ finally
+ {
+ RemoveAliasFromStore(alias);
+ }
+ }
+
+ [TestMethod]
+ public void Write_Skip_Add_When_NewerOrEqual_AlreadyPresent()
+ {
+ WindowsOnly();
+
+ var alias = "alias-skip-old-" + Guid.NewGuid().ToString("N");
+ var ep = "https://fake_mtls/tenantZ";
+ var guid = Guid.NewGuid().ToString("D");
+
+ try
+ {
+ using var newer = CreateSelfSignedWithKey("CN=" + guid, TimeSpan.FromDays(3));
+ using var older = CreateSelfSignedWithKey("CN=" + guid, TimeSpan.FromDays(2));
+
+ // Add newer first
+ _cache.Write(alias, newer, ep, Logger);
+
+ // Attempt to add older (should be skipped)
+ _cache.Write(alias, older, ep, Logger);
+
+ // Read returns the newer
+ Assert.IsTrue(WaitForFind(alias, out var value), "Expected to find persisted cert.");
+ var delta = Math.Abs((value.Certificate.NotAfter - newer.NotAfter).TotalSeconds);
+ Assert.IsTrue(delta <= 2);
+ }
+ finally
+ {
+ RemoveAliasFromStore(alias);
+ }
+ }
+
+ [TestMethod]
+ public void Read_Rejects_NonGuid_CN()
+ {
+ WindowsOnly();
+
+ var alias = "alias-nonguid-" + Guid.NewGuid().ToString("N");
+ var ep = "https://fake_mtls/tenant1";
+
+ try
+ {
+ using var cert = CreateSelfSignedWithKey("CN=Test", TimeSpan.FromDays(3));
+
+ _cache.Write(alias, cert, ep, Logger);
+
+ // Should not return non-GUID CN entries
+ Assert.IsFalse(_cache.Read(alias, out _, Logger));
+ }
+ finally
+ {
+ RemoveAliasFromStore(alias);
+ }
+ }
+
+ [TestMethod]
+ public void Read_Rejects_Short_Lifetime_Less_Than_24h()
+ {
+ WindowsOnly();
+
+ var alias = "alias-short-" + Guid.NewGuid().ToString("N");
+ var ep = "https://fake_mtls/tenant2";
+ var guid = Guid.NewGuid().ToString("D");
+
+ try
+ {
+ using var shortLived = CreateSelfSignedWithKey("CN=" + guid, TimeSpan.FromHours(23)); // < 24h
+
+ _cache.Write(alias, shortLived, ep, Logger);
+
+ // Selection policy should reject it
+ Assert.IsFalse(_cache.Read(alias, out _, Logger));
+ }
+ finally
+ {
+ RemoveAliasFromStore(alias);
+ }
+ }
+
+ [TestMethod]
+ public void Delete_Prunes_Expired_Only()
+ {
+ WindowsOnly();
+
+ var alias = "alias-prune-" + Guid.NewGuid().ToString("N");
+ var ep = "https://fake_mtls/tenant3";
+ var guid = Guid.NewGuid().ToString("D");
+
+ try
+ {
+ // Expired cert (NotAfter in the past)
+ using var expired = CreateSelfSignedWithKey("CN=" + guid, TimeSpan.FromSeconds(-30));
+
+ _cache.Write(alias, expired, ep, Logger);
+
+ // Ensure it is (potentially) present, then prune
+ _cache.Delete(alias, Logger);
+
+ // Verify no entries remain for alias
+ using var store = new X509Store(StoreName.My, StoreLocation.CurrentUser);
+ store.Open(OpenFlags.OpenExistingOnly | OpenFlags.ReadOnly);
+
+ var any = store.Certificates
+ .Cast()
+ .Any(c => MsiCertificateFriendlyNameEncoder.TryDecode(c.FriendlyName, out var a, out _)
+ && StringComparer.Ordinal.Equals(a, alias));
+
+ foreach (var c in store.Certificates)
+ c.Dispose();
+
+ Assert.IsFalse(any, "Expired entries for alias should be pruned.");
+ }
+ finally
+ {
+ RemoveAliasFromStore(alias);
+ }
+ }
+
+ [TestMethod]
+ public void Write_Skips_When_Mutex_Busy_Then_Succeeds_After_Release()
+ {
+ WindowsOnly();
+
+ var alias = "alias-mutex-" + Guid.NewGuid().ToString("N");
+ var ep = "https://fake_mtls/tenant4";
+ var guid = Guid.NewGuid().ToString("D");
+
+ using var cert = CreateSelfSignedWithKey("CN=" + guid, TimeSpan.FromDays(2));
+
+ try
+ {
+ using var hold = new ManualResetEventSlim(false);
+ using var done = new ManualResetEventSlim(false);
+
+ // Hold the alias lock from a background thread for ~400ms
+ var t = new Thread(() =>
+ {
+ InterprocessLock.TryWithAliasLock(
+ alias,
+ timeout: TimeSpan.FromMilliseconds(250),
+ action: () =>
+ {
+ hold.Set(); // signal that lock is held
+ Thread.Sleep(400); // hold lock for a bit
+ },
+ logVerbose: _ => { });
+ done.Set();
+ });
+ t.IsBackground = true;
+ t.Start();
+
+ // Wait until the lock is held
+ Assert.IsTrue(hold.Wait(2000));
+
+ // First write should *skip* due to contention (best-effort)
+ _cache.Write(alias, cert, ep, Logger);
+
+ // Verify not added yet
+ Assert.IsFalse(_cache.Read(alias, out _, Logger));
+
+ // After lock released, try again => should persist
+ Assert.IsTrue(done.Wait(5000));
+ _cache.Write(alias, cert, ep, Logger);
+
+ Assert.IsTrue(WaitForFind(alias, out var v), "Expected to find after lock released.");
+ Assert.AreEqual(ep, v.Endpoint);
+ Assert.AreEqual(guid, v.ClientId);
+ }
+ finally
+ {
+ RemoveAliasFromStore(alias);
+ }
+ }
+
+ #region Additional tests
+
+ [TestMethod]
+ public void Write_DoesNotPersist_When_NoPrivateKey()
+ {
+ WindowsOnly();
+
+ var alias = "alias-nokey-" + Guid.NewGuid().ToString("N");
+ var ep = "https://fake_mtls/tenantX";
+ var guid = Guid.NewGuid().ToString("D");
+
+ try
+ {
+ // Create a cert WITH key, then strip the key by exporting only the public part
+ using var withKey = CreateSelfSignedWithKey("CN=" + guid, TimeSpan.FromDays(2));
+ using var pubOnly = new X509Certificate2(withKey.Export(X509ContentType.Cert)); // public-only
+ Assert.IsFalse(pubOnly.HasPrivateKey, "Test setup must produce a public-only cert.");
+
+ // Write should no-op from a usability standpoint (read won't return it)
+ _cache.Write(alias, pubOnly, ep, Logger);
+
+ // Should not find anything usable for alias
+ Assert.IsFalse(_cache.Read(alias, out _, Logger));
+ }
+ finally
+ {
+ RemoveAliasFromStore(alias);
+ }
+ }
+
+ [TestMethod]
+ public void Read_Boundary_Exactly24h_IsRejected()
+ {
+ WindowsOnly();
+
+ var alias = "alias-24h-exact-" + Guid.NewGuid().ToString("N");
+ var ep = "https://fake_mtls/tenantY";
+ var guid = Guid.NewGuid().ToString("D");
+
+ try
+ {
+ // Our CreateSelfSignedWithKey uses notBefore = now-2m, so lifetime of (24h + 2m)
+ // yields NotAfter ≈ (now + 24h). That should be rejected by policy (<= 24h is insufficient).
+ using var exactly24h = CreateSelfSignedWithKey("CN=" + guid, TimeSpan.FromHours(24).Add(TimeSpan.FromMinutes(2)));
+
+ _cache.Write(alias, exactly24h, ep, Logger);
+ Assert.IsFalse(_cache.Read(alias, out _, Logger),
+ "Exactly-24h remaining should be rejected by policy.");
+ }
+ finally
+ {
+ RemoveAliasFromStore(alias);
+ }
+ }
+
+ [TestMethod]
+ public void Read_Boundary_JustOver24h_IsAccepted()
+ {
+ WindowsOnly();
+
+ var alias = "alias-24h-plus-" + Guid.NewGuid().ToString("N");
+ var ep = "https://fake_mtls/tenantY";
+ var guid = Guid.NewGuid().ToString("D");
+
+ try
+ {
+ // 24h + 3m lifetime (with notBefore = now-2m) → NotAfter ≈ now + 24h + 1m → acceptable
+ using var over24h = CreateSelfSignedWithKey("CN=" + guid, TimeSpan.FromHours(24).Add(TimeSpan.FromMinutes(3)));
+
+ _cache.Write(alias, over24h, ep, Logger);
+ Assert.IsTrue(_cache.Read(alias, out var v, Logger),
+ "Slightly-over-24h remaining should be accepted.");
+ Assert.AreEqual(ep, v.Endpoint);
+ Assert.AreEqual(guid, v.ClientId);
+ }
+ finally
+ {
+ RemoveAliasFromStore(alias);
+ }
+ }
+
+ [TestMethod]
+ public void Read_Returns_Newest_Endpoint_And_ClientId()
+ {
+ WindowsOnly();
+
+ var alias = "alias-newest-ep-" + Guid.NewGuid().ToString("N");
+ var epOld = "https://fake_mtls/tenant/OLD";
+ var epNew = "https://fake_mtls/tenant/NEW";
+ var guidOld = Guid.NewGuid().ToString("D");
+ var guidNew = Guid.NewGuid().ToString("D");
+
+ try
+ {
+ using var older = CreateSelfSignedWithKey("CN=" + guidOld, TimeSpan.FromDays(2));
+ using var newer = CreateSelfSignedWithKey("CN=" + guidNew, TimeSpan.FromDays(3));
+
+ _cache.Write(alias, older, epOld, Logger);
+ _cache.Write(alias, newer, epNew, Logger);
+
+ Assert.IsTrue(_cache.Read(alias, out var v, Logger), "Expected read for alias.");
+ Assert.AreEqual(guidNew, v.ClientId, "ClientId must reflect the newest NotAfter entry.");
+ Assert.AreEqual(epNew, v.Endpoint, "Endpoint must come from the newest NotAfter entry.");
+ }
+ finally
+ {
+ RemoveAliasFromStore(alias);
+ }
+ }
+
+ [TestMethod]
+ public void Read_Isolated_Per_Alias_No_Cross_Talk()
+ {
+ WindowsOnly();
+
+ var alias1 = "alias-a-" + Guid.NewGuid().ToString("N");
+ var alias2 = "alias-b-" + Guid.NewGuid().ToString("N");
+ var ep1 = "https://fake_mtls/tenantA";
+ var ep2 = "https://fake_mtls/tenantB";
+ var guid1 = Guid.NewGuid().ToString("D");
+ var guid2 = Guid.NewGuid().ToString("D");
+
+ try
+ {
+ using var c1 = CreateSelfSignedWithKey("CN=" + guid1, TimeSpan.FromDays(3));
+ using var c2 = CreateSelfSignedWithKey("CN=" + guid2, TimeSpan.FromDays(3));
+
+ _cache.Write(alias1, c1, ep1, Logger);
+ _cache.Write(alias2, c2, ep2, Logger);
+
+ Assert.IsTrue(_cache.Read(alias1, out var v1, Logger));
+ Assert.AreEqual(ep1, v1.Endpoint);
+ Assert.AreEqual(guid1, v1.ClientId);
+
+ Assert.IsTrue(_cache.Read(alias2, out var v2, Logger));
+ Assert.AreEqual(ep2, v2.Endpoint);
+ Assert.AreEqual(guid2, v2.ClientId);
+ }
+ finally
+ {
+ RemoveAliasFromStore(alias1);
+ RemoveAliasFromStore(alias2);
+ }
+ }
+
+ [TestMethod]
+ public void Read_Prefers_Newest_Among_Many()
+ {
+ WindowsOnly();
+
+ var alias = "alias-many-" + Guid.NewGuid().ToString("N");
+ var ep1 = "https://fake_mtls/ep1";
+ var ep2 = "https://fake_mtls/ep2";
+ var ep3 = "https://fake_mtls/ep3";
+ var g1 = Guid.NewGuid().ToString("D");
+ var g2 = Guid.NewGuid().ToString("D");
+ var g3 = Guid.NewGuid().ToString("D");
+
+ try
+ {
+ using var c1 = CreateSelfSignedWithKey("CN=" + g1, TimeSpan.FromDays(1));
+ using var c2 = CreateSelfSignedWithKey("CN=" + g2, TimeSpan.FromDays(2));
+ using var c3 = CreateSelfSignedWithKey("CN=" + g3, TimeSpan.FromDays(3)); // newest
+
+ _cache.Write(alias, c1, ep1, Logger);
+ _cache.Write(alias, c2, ep2, Logger);
+ _cache.Write(alias, c3, ep3, Logger);
+
+ Assert.IsTrue(_cache.Read(alias, out var v, Logger), "Expected read.");
+ Assert.AreEqual(g3, v.ClientId);
+ Assert.AreEqual(ep3, v.Endpoint);
+ }
+ finally
+ {
+ RemoveAliasFromStore(alias);
+ }
+ }
+
+ [TestMethod]
+ public void NonWindows_WindowsPersistentCertificateCache_IsNoOp()
+ {
+ NonWindowsOnly();
+
+ var alias = "alias-nonwindows-" + Guid.NewGuid().ToString("N");
+ var ep = "https://fake_mtls/nonwindows";
+ var guid = Guid.NewGuid().ToString("D");
+
+ using var cert = CreateSelfSignedWithKey("CN=" + guid, TimeSpan.FromDays(2));
+
+ // On non-Windows, WindowsPersistentCertificateCache should behave as a no-op:
+ // Write() and Read() return without touching any real store.
+ _cache.Write(alias, cert, ep, Logger);
+
+ Assert.IsFalse(_cache.Read(alias, out _, Logger),
+ "On non-Windows the persistent cache should effectively be disabled.");
+ }
+
+ [TestMethod]
+ public void Write_And_Read_Handle_Alias_EdgeCases()
+ {
+ WindowsOnly();
+
+ var ep = "https://fake_mtls/alias-edge";
+ using var cert = CreateSelfSignedWithKey("CN=" + Guid.NewGuid().ToString("D"),
+ TimeSpan.FromDays(3));
+
+ // Aliases that should be valid and round-trip through persistence
+ string[] goodAliases =
+ {
+ new string('a', 2048), // very long alias
+ "alias-ümläüt-用户-🔐" // unicode + special characters (no illegal delimiters)
+ };
+
+ foreach (var alias in goodAliases)
+ {
+ try
+ {
+ _cache.Write(alias, cert, ep, Logger);
+
+ Assert.IsTrue(WaitForFind(alias, out var value),
+ $"Expected alias '{alias}' to be persisted.");
+ Assert.AreEqual(ep, value.Endpoint);
+ }
+ finally
+ {
+ RemoveAliasFromStore(alias);
+ }
+ }
+
+ // Aliases that should be rejected by the FriendlyName encoder and not persisted
+ string[] badAliases =
+ {
+ null,
+ string.Empty,
+ " ",
+ "bad|alias" // '|' is illegal for our FriendlyName grammar
+ };
+
+ foreach (var alias in badAliases)
+ {
+ _cache.Write(alias, cert, ep, Logger);
+ Assert.IsFalse(_cache.Read(alias, out _, Logger),
+ $"Alias '{alias ?? ""}' should not be persisted.");
+ }
+ }
+
+ #endregion
+ }
+}