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