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