diff --git a/src/client/Microsoft.Identity.Client/ManagedIdentity/V2/FriendlyNameCodec.cs b/src/client/Microsoft.Identity.Client/ManagedIdentity/V2/FriendlyNameCodec.cs
new file mode 100644
index 0000000000..2cf152c755
--- /dev/null
+++ b/src/client/Microsoft.Identity.Client/ManagedIdentity/V2/FriendlyNameCodec.cs
@@ -0,0 +1,107 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+using System;
+
+namespace Microsoft.Identity.Client.ManagedIdentity.V2
+{
+ ///
+ /// Encodes/decodes the persisted X.509 FriendlyName for MSAL mTLS certs.
+ /// Format: "MSAL|alias=cacheKey|ep=endpointBase"
+ /// Open the cert store and look at FriendlyName to see examples.
+ /// Wish we could paste a screenshot here... Maybe I can show it in code walkthroughs.
+ ///
+ internal static class FriendlyNameCodec
+ {
+ public const string Prefix = "MSAL|";
+ public const string TagAlias = "alias";
+ public const string TagEp = "ep";
+
+ ///
+ /// Encodes alias and endpointBase into friendly name.
+ ///
+ ///
+ ///
+ ///
+ ///
+ 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.
+ ///
+ ///
+ ///
+ ///
+ ///
+ 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=|ep=
+ 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.
+ ///
+ ///
+ ///
+ 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/ImdsV2ManagedIdentitySource.cs b/src/client/Microsoft.Identity.Client/ManagedIdentity/V2/ImdsV2ManagedIdentitySource.cs
index aa98c98121..974f91b164 100644
--- a/src/client/Microsoft.Identity.Client/ManagedIdentity/V2/ImdsV2ManagedIdentitySource.cs
+++ b/src/client/Microsoft.Identity.Client/ManagedIdentity/V2/ImdsV2ManagedIdentitySource.cs
@@ -440,8 +440,6 @@ 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.
@@ -457,21 +455,13 @@ private static async Task> GetOrCreateMt
if (factory is null)
throw new ArgumentNullException(nameof(factory));
- X509Certificate2 cachedCertificate;
- string cachedEndpointBase;
- string cachedClientId;
-
- // 1) Only lookup by cacheKey
+ // 1) In-memory cache first
if (s_mtlsCertificateCache.TryGet(cacheKey, out var cached, logger))
{
- cachedCertificate = cached.Certificate;
- cachedEndpointBase = cached.Endpoint;
- cachedClientId = cached.ClientId;
-
- return Tuple.Create(cachedCertificate, cachedEndpointBase, cachedClientId);
+ return Tuple.Create(cached.Certificate, cached.Endpoint, cached.ClientId);
}
- // 2) Gate per cacheKey
+ // 2) Per-key gate
var gate = s_perKeyGates.GetOrAdd(cacheKey, _ => new SemaphoreSlim(1, 1));
await gate.WaitAsync(cancellationToken).ConfigureAwait(false);
@@ -480,18 +470,37 @@ private static async Task> GetOrCreateMt
// 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);
+ return Tuple.Create(cached.Certificate, cached.Endpoint, cached.ClientId);
+ }
+
+ // 3) Persistent store (best-effort).
+ if (PersistentCertificateStore.TryFind(cacheKey, out var persisted, logger))
+ {
+ if (persisted.Certificate.HasPrivateKey)
+ {
+ var v = new CertificateCacheValue(persisted.Certificate, persisted.Endpoint, persisted.ClientId);
+ s_mtlsCertificateCache.Set(cacheKey, in v, logger);
+ return Tuple.Create(v.Certificate, v.Endpoint, v.ClientId);
+ }
+ else
+ {
+ // Not usable for mTLS; dispose clone and mint a new one
+ persisted.Certificate.Dispose();
+ logger?.Verbose(() => "[PersistentCert] Skipping persisted cert without private key; minting new.");
+ }
}
- // 3) Mint + cache under the provided cacheKey
+ // 4) Mint + back-fill caches
var created = await factory().ConfigureAwait(false);
- s_mtlsCertificateCache.Set(cacheKey,
- new CertificateCacheValue(created.Item1, created.Item2, created.Item3),
- logger);
+ var createdValue = new CertificateCacheValue(created.Item1, created.Item2, created.Item3);
+ s_mtlsCertificateCache.Set(cacheKey, in createdValue, logger);
+
+ // 5) Best-effort persist for future runs (mutex & dedup inside)
+ PersistentCertificateStore.TryPersist(cacheKey, created.Item1, created.Item2, created.Item3, logger);
+
+ // 6) Keep store tidy
+ PersistentCertificateStore.TryPruneAliasOlderThan(cacheKey, created.Item1.NotAfter.ToUniversalTime(), logger);
return created;
}
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..5d555da27a
--- /dev/null
+++ b/src/client/Microsoft.Identity.Client/ManagedIdentity/V2/InterprocessLock.cs
@@ -0,0 +1,95 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+using System;
+using System.Security.Cryptography;
+using System.Text;
+using System.Threading;
+using Microsoft.Identity.Client.PlatformsCommon.Shared;
+
+namespace Microsoft.Identity.Client.ManagedIdentity.V2
+{
+ ///
+ /// Cross-process lock based on a per-alias named mutex.
+ ///
+ internal static class InterprocessLock
+ {
+ public static bool TryWithAliasLock(
+ string alias,
+ TimeSpan timeout,
+ Action action,
+ Action logVerbose = null)
+ {
+ var nameGlobal = GetMutexNameForAlias(alias, preferGlobal: true);
+ var nameLocal = GetMutexNameForAlias(alias, preferGlobal: false);
+
+ foreach (var name in new[] { nameGlobal, nameLocal })
+ {
+ try
+ {
+ using var m = new Mutex(false, name);
+ bool entered;
+ try
+ {
+ entered = m.WaitOne(timeout);
+ }
+ catch (AbandonedMutexException)
+ {
+ entered = true; // prior holder crashed
+ }
+
+ if (!entered)
+ {
+ logVerbose?.Invoke($"[PersistentCert] Skip persist (lock busy '{name}').");
+ return false;
+ }
+
+ try
+ { action(); }
+ finally
+ {
+ try
+ { m.ReleaseMutex(); }
+ catch { /* best-effort */ }
+ }
+
+ return true;
+ }
+ catch (UnauthorizedAccessException)
+ {
+ logVerbose?.Invoke($"[PersistentCert] No access to mutex scope '{name}', trying next.");
+ continue; // try Local if Global blocked
+ }
+ catch (Exception ex)
+ {
+ logVerbose?.Invoke($"[PersistentCert] Lock failure '{name}': {ex.Message}");
+ return false;
+ }
+ }
+
+ 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/PersistentCertificateStore.cs b/src/client/Microsoft.Identity.Client/ManagedIdentity/V2/PersistentCertificateStore.cs
new file mode 100644
index 0000000000..9957dd875a
--- /dev/null
+++ b/src/client/Microsoft.Identity.Client/ManagedIdentity/V2/PersistentCertificateStore.cs
@@ -0,0 +1,259 @@
+// 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 certs in CurrentUser\My.
+ ///
+ /// Selection:
+ /// 1) Filter by FriendlyName "MSAL|alias=<cacheKey>|ep=<base>" (scopes to *our* alias).
+ /// 2) Enforce remaining lifetime (≥ 24h) and require HasPrivateKey; pick newest NotAfter.
+ /// 3) On that single winner, read Subject CN and require a GUID (canonical client_id).
+ ///
+ /// Notes:
+ /// - We never reattach private keys. If HasPrivateKey == false, caller will mint.
+ /// - No throws; persistence must not block token acquisition.
+ /// - Windows-only; FriendlyName semantics are undefined elsewhere.
+ ///
+ internal static class PersistentCertificateStore
+ {
+ public static bool TryFind(
+ string aliasCacheKey,
+ out CertificateCacheValue value,
+ ILoggerAdapter logger = null)
+ {
+ value = default;
+
+ if (!DesktopOsHelper.IsWindows())
+ return false;
+
+ try
+ {
+ using var store = new X509Store(StoreName.My, StoreLocation.CurrentUser);
+ store.Open(OpenFlags.OpenExistingOnly | OpenFlags.ReadOnly);
+
+ // Snapshot to avoid "collection modified" during enumeration on some providers
+ X509Certificate2[] items;
+ try
+ {
+ items = new X509Certificate2[store.Certificates.Count];
+ store.Certificates.CopyTo(items, 0);
+ }
+ catch
+ {
+ items = store.Certificates.Cast().ToArray();
+ }
+
+ X509Certificate2 best = null;
+ string bestEndpoint = null;
+ DateTime bestNotAfter = DateTime.MinValue;
+
+ foreach (var c in items)
+ {
+ try
+ {
+ if (!FriendlyNameCodec.TryDecode(c.FriendlyName, out var alias, out var epBase))
+ continue;
+ if (!StringComparer.Ordinal.Equals(alias, aliasCacheKey))
+ continue;
+
+ // 24h+ remaining
+ if (c.NotAfter.ToUniversalTime() <= DateTime.UtcNow + CertificateCacheEntry.MinRemainingLifetime)
+ continue;
+
+ if (!c.HasPrivateKey)
+ {
+ logger?.Verbose(() => "[PersistentCert] Candidate skipped: no private key.");
+ continue;
+ }
+
+ if (c.NotAfter > bestNotAfter)
+ {
+ best?.Dispose();
+ best = new X509Certificate2(c); // caller-owned clone (preserves private key link)
+ bestEndpoint = epBase;
+ bestNotAfter = c.NotAfter;
+ }
+ }
+ finally
+ {
+ c.Dispose();
+ }
+ }
+
+ if (best != null)
+ {
+ // CN (GUID) → canonical client_id
+ string cn = null;
+ try
+ { cn = best.GetNameInfo(X509NameType.SimpleName, false); }
+ catch { }
+ if (!Guid.TryParse(cn, out var g))
+ {
+ best.Dispose();
+ logger?.Verbose(() => "[PersistentCert] Selected entry CN is not a GUID; skipping.");
+ return false;
+ }
+
+ value = new CertificateCacheValue(best, bestEndpoint, g.ToString("D"));
+ logger?.Verbose(() => "[PersistentCert] Reused certificate from CurrentUser/My.");
+ return true;
+ }
+ }
+ catch (Exception ex)
+ {
+ logger?.Verbose(() => "[PersistentCert] Store lookup failed: " + ex.Message);
+ }
+
+ return false;
+ }
+
+ public static void TryPersist(
+ string aliasCacheKey,
+ X509Certificate2 cert,
+ string endpointBase,
+ string clientId, // unused; CN is the source of truth on read
+ ILoggerAdapter logger = null)
+ {
+ if (!DesktopOsHelper.IsWindows() || cert == null)
+ return;
+
+ // We only persist certs that can actually be used for mTLS later.
+ if (!cert.HasPrivateKey)
+ {
+ logger?.Verbose(() => "[PersistentCert] Not persisting: certificate has no private key.");
+ return;
+ }
+
+ if (!FriendlyNameCodec.TryEncode(aliasCacheKey, endpointBase, out var friendlyName))
+ {
+ logger?.Verbose(() => "[PersistentCert] FriendlyName encode failed; skipping persist.");
+ return;
+ }
+
+ // Best-effort: short lock, skip if busy
+ InterprocessLock.TryWithAliasLock(
+ aliasCacheKey,
+ 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();
+
+ // If a newer (or equal) non-expired entry for this alias already exists, skip.
+ DateTime newestForAliasUtc = DateTime.MinValue;
+
+ X509Certificate2[] present;
+ try
+ {
+ present = new X509Certificate2[store.Certificates.Count];
+ store.Certificates.CopyTo(present, 0);
+ }
+ catch
+ {
+ present = store.Certificates.Cast().ToArray();
+ }
+
+ foreach (var existing in present)
+ {
+ try
+ {
+ if (!FriendlyNameCodec.TryDecode(existing.FriendlyName, out var a, out _))
+ continue;
+ if (!StringComparer.Ordinal.Equals(a, aliasCacheKey))
+ continue;
+
+ var existUtc = existing.NotAfter.ToUniversalTime();
+ if (existUtc > newestForAliasUtc)
+ {
+ newestForAliasUtc = existUtc;
+ }
+ }
+ finally
+ {
+ existing.Dispose();
+ }
+ }
+
+ if (newestForAliasUtc != DateTime.MinValue &&
+ newestForAliasUtc >= newNotAfterUtc &&
+ newestForAliasUtc > nowUtc)
+ {
+ logger?.Verbose(() => "[PersistentCert] Newer/equal cert already present; skipping add.");
+ return;
+ }
+
+ // === CHANGE: set FriendlyName BEFORE add, and add the ORIGINAL instance that has the private key ===
+ try
+ {
+ try
+ {
+ cert.FriendlyName = friendlyName;
+ }
+ catch
+ {
+ logger?.Verbose(() => "[PersistentCert] Could not set FriendlyName; skipping persist.");
+ return;
+ }
+
+ // Add the original instance (carries private key)
+ store.Add(cert);
+
+ logger?.Verbose(() => "[PersistentCert] Persisted certificate to CurrentUser/My.");
+
+ // Conservative cleanup: remove expired entries for this alias only
+ StorePruner.PruneExpiredForAlias(store, aliasCacheKey, nowUtc, logger);
+ }
+ catch (Exception ex)
+ {
+ logger?.Verbose(() => "[PersistentCert] Persist failed: " + ex.Message);
+ }
+ }
+ catch (Exception exOuter)
+ {
+ logger?.Verbose(() => "[PersistentCert] Persist failed: " + exOuter.Message);
+ }
+ },
+ logVerbose: s => logger?.Verbose(() => s));
+ }
+
+ public static void TryPruneAliasOlderThan(
+ string aliasCacheKey,
+ DateTimeOffset baselineNotAfterUtc, // kept for API stability; we prune expired only
+ ILoggerAdapter logger = null)
+ {
+ if (!DesktopOsHelper.IsWindows())
+ return;
+
+ InterprocessLock.TryWithAliasLock(
+ aliasCacheKey,
+ timeout: TimeSpan.FromMilliseconds(300),
+ action: () =>
+ {
+ try
+ {
+ using var store = new X509Store(StoreName.My, StoreLocation.CurrentUser);
+ store.Open(OpenFlags.ReadWrite);
+ StorePruner.PruneExpiredForAlias(store, aliasCacheKey, DateTime.UtcNow, logger);
+ }
+ catch (Exception ex)
+ {
+ logger?.Verbose(() => "[PersistentCert] Prune failed: " + ex.Message);
+ }
+ },
+ logVerbose: s => logger?.Verbose(() => s));
+ }
+ }
+}
diff --git a/src/client/Microsoft.Identity.Client/ManagedIdentity/V2/StorePruner.cs b/src/client/Microsoft.Identity.Client/ManagedIdentity/V2/StorePruner.cs
new file mode 100644
index 0000000000..8f94d43f53
--- /dev/null
+++ b/src/client/Microsoft.Identity.Client/ManagedIdentity/V2/StorePruner.cs
@@ -0,0 +1,66 @@
+// 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;
+
+namespace Microsoft.Identity.Client.ManagedIdentity.V2
+{
+ ///
+ /// Removes expired entries (NotAfter less than now) for an alias from an open X509 store.
+ ///
+ internal static class StorePruner
+ {
+ ///
+ /// Deletes only certificates that are actually expired (NotAfter less than nowUtc),
+ /// scoped to the given alias (cache key) via FriendlyName.
+ /// This ensures we only delete certificates that we created for that alias,
+ /// i.e. MSAL specific mTLS certs.
+ ///
+ internal static void PruneExpiredForAlias(
+ X509Store store,
+ string aliasCacheKey,
+ DateTime nowUtc,
+ ILoggerAdapter logger)
+ {
+ X509Certificate2[] items;
+ try
+ {
+ items = new X509Certificate2[store.Certificates.Count];
+ // Safe snapshot for .NET Framework when removing
+ store.Certificates.CopyTo(items, 0);
+ }
+ catch
+ {
+ // Fallback for providers/runtimes where CopyTo fails
+ items = store.Certificates.Cast().ToArray();
+ }
+
+ int removed = 0;
+
+ foreach (var existing in items)
+ {
+ try
+ {
+ if (!FriendlyNameCodec.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/tests/Microsoft.Identity.Test.Unit/ManagedIdentityTests/FriendlyNameCodecTests.cs b/tests/Microsoft.Identity.Test.Unit/ManagedIdentityTests/FriendlyNameCodecTests.cs
new file mode 100644
index 0000000000..4d132c6b7b
--- /dev/null
+++ b/tests/Microsoft.Identity.Test.Unit/ManagedIdentityTests/FriendlyNameCodecTests.cs
@@ -0,0 +1,86 @@
+// 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(FriendlyNameCodec.TryEncode(alias, ep, out var fn));
+ Assert.IsNotNull(fn);
+ StringAssert.StartsWith(fn, FriendlyNameCodec.Prefix);
+
+ Assert.IsTrue(FriendlyNameCodec.TryDecode(fn, out var a2, out var ep2));
+ Assert.AreEqual(alias, a2);
+ Assert.AreEqual(ep, ep2);
+ }
+
+ [TestMethod]
+ public void TryEncode_Rejects_IllegalChars()
+ {
+ // '|' and newline are disallowed
+ Assert.IsFalse(FriendlyNameCodec.TryEncode("foo|bar", "https://x", out _));
+ Assert.IsFalse(FriendlyNameCodec.TryEncode("foo\nbar", "https://x", out _));
+ Assert.IsFalse(FriendlyNameCodec.TryEncode("foo", "https://x|y", out _));
+ Assert.IsFalse(FriendlyNameCodec.TryEncode("foo", "https://x\ny", out _));
+
+ // Null/whitespace rejected
+ Assert.IsFalse(FriendlyNameCodec.TryEncode(null, "https://x", out _));
+ Assert.IsFalse(FriendlyNameCodec.TryEncode(" ", "https://x", out _));
+ Assert.IsFalse(FriendlyNameCodec.TryEncode("foo", null, out _));
+ Assert.IsFalse(FriendlyNameCodec.TryEncode("foo", " ", out _));
+ }
+
+ [TestMethod]
+ public void TryDecode_InvalidPrefix_ReturnsFalse()
+ {
+ // Wrong prefix
+ var bad = "NOTMSAL|alias=a|ep=b";
+ Assert.IsFalse(FriendlyNameCodec.TryDecode(bad, out _, out _));
+
+ // Missing tags
+ var missing = FriendlyNameCodec.Prefix + "alias=a";
+ Assert.IsFalse(FriendlyNameCodec.TryDecode(missing, out _, out _));
+ }
+
+ [TestMethod]
+ public void EncodeDecode_Roundtrip()
+ {
+ string alias = "my-alias-123";
+ string ep = "https://ep/base";
+ Assert.IsTrue(FriendlyNameCodec.TryEncode(alias, ep, out var fn));
+ Assert.IsTrue(FriendlyNameCodec.TryDecode(fn, out var a2, out var e2));
+ Assert.AreEqual(alias, a2);
+ Assert.AreEqual(ep, e2);
+ }
+
+ [TestMethod]
+ public void Encode_Rejects_Illegal()
+ {
+ // '|' is illegal by design
+ Assert.IsFalse(FriendlyNameCodec.TryEncode("bad|alias", "https://ok", out _));
+ Assert.IsFalse(FriendlyNameCodec.TryEncode("ok", "https://bad|ep", out _));
+ }
+
+ [TestMethod]
+ public void Decode_Ignores_Unknown_Tags_LastWins()
+ {
+ var fn = FriendlyNameCodec.Prefix +
+ FriendlyNameCodec.TagAlias + "=a|" +
+ "xtra=foo|" +
+ FriendlyNameCodec.TagEp + "=E";
+ Assert.IsTrue(FriendlyNameCodec.TryDecode(fn, out var a, out var e));
+ Assert.AreEqual("a", a);
+ Assert.AreEqual("E", e);
+ }
+ }
+}
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..57775e08b9
--- /dev/null
+++ b/tests/Microsoft.Identity.Test.Unit/ManagedIdentityTests/ImdsV2TestStoreCleaner.cs
@@ -0,0 +1,238 @@
+// 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 (safe for .NET Framework when removing)
+ X509Certificate2[] items;
+ try
+ {
+ items = new X509Certificate2[store.Certificates.Count];
+ store.Certificates.CopyTo(items, 0);
+ }
+ catch
+ {
+ items = store.Certificates.Cast().ToArray();
+ }
+
+ 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;
+ try
+ {
+ items = new X509Certificate2[store.Certificates.Count];
+ store.Certificates.CopyTo(items, 0);
+ }
+ catch
+ {
+ items = store.Certificates.Cast().ToArray();
+ }
+
+ 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 ----------------
+
+ 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;
+ }
+
+ 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");
+ }
+
+ private static string GetDc(X509Certificate2 cert)
+ {
+ return ReadRdn(cert, "DC");
+ }
+
+ 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 e8cd8f8b44..45bee185fa 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 g = InterprocessLock.GetMutexNameForAlias(aliasRaw, preferGlobal: true);
+ var l = InterprocessLock.GetMutexNameForAlias(aliasRaw, preferGlobal: false);
+
+ StringAssert.StartsWith(g, @"Global\MSAL_MI_P_");
+ StringAssert.StartsWith(l, @"Local\MSAL_MI_P_");
+
+ // Same alias after canonicalization should produce same suffix across scopes (ignoring prefix)
+ var g2 = InterprocessLock.GetMutexNameForAlias("MY-ALIAS", true);
+ Assert.AreEqual(g.Substring(@"Global\".Length), g2.Substring(@"Global\".Length));
+ }
+
+ [TestMethod]
+ public void TryWithAliasLock_Executes_Action()
+ {
+ if (!IsWindows)
+ { Assert.Inconclusive("Windows-only"); return; }
+
+ var alias = "lock-test-" + Guid.NewGuid().ToString("N");
+ var called = 0;
+
+ var ok = InterprocessLock.TryWithAliasLock(
+ alias,
+ timeout: TimeSpan.FromMilliseconds(250),
+ action: () => Interlocked.Increment(ref called));
+
+ 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
+ });
+ });
+ 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"));
+
+ Assert.IsFalse(got);
+
+ t.Join();
+ }
+ }
+}
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..e184f02405
--- /dev/null
+++ b/tests/Microsoft.Identity.Test.Unit/ManagedIdentityTests/PersistentCertificateStoreUnitTests.cs
@@ -0,0 +1,548 @@
+// 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();
+
+ [TestInitialize]
+ public void ImdsV2Tests_Init()
+ {
+ // 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();
+ }
+ }
+
+ // --- 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 c in items)
+ {
+ try
+ {
+ if (FriendlyNameCodec.TryDecode(c.FriendlyName, out var a, out _)
+ && StringComparer.Ordinal.Equals(a, alias))
+ {
+ try
+ { store.Remove(c); }
+ catch { /* best-effort */ }
+ }
+ }
+ finally
+ {
+ c.Dispose();
+ }
+ }
+ }
+
+ // Small polling helper to absorb store-write propagation timing
+ private static bool WaitForFind(string alias, out CertificateCacheValue value, int retries = 10, int delayMs = 50)
+ {
+ for (int i = 0; i < retries; i++)
+ {
+ if (PersistentCertificateStore.TryFind(alias, out value, Logger))
+ return true;
+
+ Thread.Sleep(delayMs);
+ }
+
+ value = default;
+ return false;
+ }
+
+ // --- tests ---
+
+ [TestMethod]
+ public void TryPersist_Then_TryFind_HappyPath()
+ {
+ if (!IsWindows)
+ { Assert.Inconclusive("Windows-only"); return; }
+
+ 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));
+
+ PersistentCertificateStore.TryPersist(alias, cert, ep, clientId: "ignored", logger: 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 TryPersist_NewestWins_SkipOlder()
+ {
+ if (!IsWindows)
+ { Assert.Inconclusive("Windows-only"); return; }
+
+ 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
+ PersistentCertificateStore.TryPersist(alias, older, ep, "ignored", Logger);
+ PersistentCertificateStore.TryPersist(alias, newer, ep, "ignored", 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 TryPersist_Skip_Add_When_NewerOrEqual_AlreadyPresent()
+ {
+ if (!IsWindows)
+ { Assert.Inconclusive("Windows-only"); return; }
+
+ 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
+ PersistentCertificateStore.TryPersist(alias, newer, ep, "ignored", Logger);
+
+ // Attempt to add older (should be skipped)
+ PersistentCertificateStore.TryPersist(alias, older, ep, "ignored", Logger);
+
+ // TryFind 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 TryFind_Rejects_NonGuid_CN()
+ {
+ if (!IsWindows)
+ { Assert.Inconclusive("Windows-only"); return; }
+
+ var alias = "alias-nonguid-" + Guid.NewGuid().ToString("N");
+ var ep = "https://fake_mtls/tenant1";
+
+ try
+ {
+ using var cert = CreateSelfSignedWithKey("CN=Test", TimeSpan.FromDays(3));
+
+ PersistentCertificateStore.TryPersist(alias, cert, ep, "ignored", Logger);
+
+ // Should not return non-GUID CN entries
+ Assert.IsFalse(PersistentCertificateStore.TryFind(alias, out _, Logger));
+ }
+ finally
+ {
+ RemoveAliasFromStore(alias);
+ }
+ }
+
+ [TestMethod]
+ public void TryFind_Rejects_Short_Lifetime_Less_Than_24h()
+ {
+ if (!IsWindows)
+ { Assert.Inconclusive("Windows-only"); return; }
+
+ 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
+
+ PersistentCertificateStore.TryPersist(alias, shortLived, ep, "ignored", Logger);
+
+ // Selection policy should reject it
+ Assert.IsFalse(PersistentCertificateStore.TryFind(alias, out _, Logger));
+ }
+ finally
+ {
+ RemoveAliasFromStore(alias);
+ }
+ }
+
+ [TestMethod]
+ public void PruneExpired_Removes_Only_Expired()
+ {
+ if (!IsWindows)
+ { Assert.Inconclusive("Windows-only"); return; }
+
+ 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)
+ var now = DateTimeOffset.UtcNow;
+ using var expired = CreateSelfSignedWithKey("CN=" + guid, TimeSpan.FromSeconds(-30));
+
+ PersistentCertificateStore.TryPersist(alias, expired, ep, "ignored", Logger);
+
+ // Ensure it is (potentially) present, then prune
+ PersistentCertificateStore.TryPruneAliasOlderThan(alias, now, 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 => FriendlyNameCodec.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 TryPersist_Skips_When_Mutex_Busy_Then_Succeeds_After_Release()
+ {
+ if (!IsWindows)
+ { Assert.Inconclusive("Windows-only"); return; }
+
+ 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
+ });
+ done.Set();
+ });
+ t.IsBackground = true;
+ t.Start();
+
+ // Wait until the lock is held
+ Assert.IsTrue(hold.Wait(2000));
+
+ // First persist should *skip* due to contention (best-effort)
+ PersistentCertificateStore.TryPersist(alias, cert, ep, "ignored", Logger);
+
+ // Verify not added yet
+ Assert.IsFalse(PersistentCertificateStore.TryFind(alias, out _, Logger));
+
+ // After lock released, try again => should persist
+ Assert.IsTrue(done.Wait(5000));
+ PersistentCertificateStore.TryPersist(alias, cert, ep, "ignored", 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 TryPersist_DoesNotPersist_When_NoPrivateKey()
+ {
+ if (!IsWindows)
+ { Assert.Inconclusive("Windows-only"); return; }
+
+ 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.");
+
+ // Persist should no-op for no-private-key
+ PersistentCertificateStore.TryPersist(alias, pubOnly, ep, "ignored", Logger);
+
+ // Should not find anything for alias
+ Assert.IsFalse(PersistentCertificateStore.TryFind(alias, out _, Logger));
+ }
+ finally
+ {
+ RemoveAliasFromStore(alias);
+ }
+ }
+
+ [TestMethod]
+ public void TryFind_Boundary_Exactly24h_IsRejected()
+ {
+ if (!IsWindows)
+ { Assert.Inconclusive("Windows-only"); return; }
+
+ 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)));
+
+ PersistentCertificateStore.TryPersist(alias, exactly24h, ep, "ignored", Logger);
+ Assert.IsFalse(PersistentCertificateStore.TryFind(alias, out _, Logger),
+ "Exactly-24h remaining should be rejected by policy.");
+ }
+ finally
+ {
+ RemoveAliasFromStore(alias);
+ }
+ }
+
+ [TestMethod]
+ public void TryFind_Boundary_JustOver24h_IsAccepted()
+ {
+ if (!IsWindows)
+ { Assert.Inconclusive("Windows-only"); return; }
+
+ 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)));
+
+ PersistentCertificateStore.TryPersist(alias, over24h, ep, "ignored", Logger);
+ Assert.IsTrue(PersistentCertificateStore.TryFind(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 TryFind_Returns_Newest_Endpoint_And_ClientId()
+ {
+ if (!IsWindows)
+ { Assert.Inconclusive("Windows-only"); return; }
+
+ 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));
+
+ PersistentCertificateStore.TryPersist(alias, older, epOld, "ignored", Logger);
+ PersistentCertificateStore.TryPersist(alias, newer, epNew, "ignored", Logger);
+
+ Assert.IsTrue(PersistentCertificateStore.TryFind(alias, out var v, Logger), "Expected find 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 TryFind_Isolated_Per_Alias_No_Cross_Talk()
+ {
+ if (!IsWindows)
+ { Assert.Inconclusive("Windows-only"); return; }
+
+ 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));
+
+ PersistentCertificateStore.TryPersist(alias1, c1, ep1, "ignored", Logger);
+ PersistentCertificateStore.TryPersist(alias2, c2, ep2, "ignored", Logger);
+
+ Assert.IsTrue(PersistentCertificateStore.TryFind(alias1, out var v1, Logger));
+ Assert.AreEqual(ep1, v1.Endpoint);
+ Assert.AreEqual(guid1, v1.ClientId);
+
+ Assert.IsTrue(PersistentCertificateStore.TryFind(alias2, out var v2, Logger));
+ Assert.AreEqual(ep2, v2.Endpoint);
+ Assert.AreEqual(guid2, v2.ClientId);
+ }
+ finally
+ {
+ RemoveAliasFromStore(alias1);
+ RemoveAliasFromStore(alias2);
+ }
+ }
+
+ [TestMethod]
+ public void TryFind_Prefers_Newest_Among_Many()
+ {
+ if (!IsWindows)
+ { Assert.Inconclusive("Windows-only"); return; }
+
+ 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
+
+ PersistentCertificateStore.TryPersist(alias, c1, ep1, "ignored", Logger);
+ PersistentCertificateStore.TryPersist(alias, c2, ep2, "ignored", Logger);
+ PersistentCertificateStore.TryPersist(alias, c3, ep3, "ignored", Logger);
+
+ Assert.IsTrue(PersistentCertificateStore.TryFind(alias, out var v, Logger), "Expected find.");
+ Assert.AreEqual(g3, v.ClientId);
+ Assert.AreEqual(ep3, v.Endpoint);
+ }
+ finally
+ {
+ RemoveAliasFromStore(alias);
+ }
+ }
+
+ #endregion
+ }
+}