Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -221,8 +221,6 @@ public override async Task<ManagedIdentityResponse> AuthenticateAsync(
/// Detects if the exception was caused by a SCHANNEL failure during mTLS authentication,
/// which can occur if the client certificate becomes invalid.
/// </summary>
/// <param name="ex"></param>
/// <returns></returns>
private static bool IsSchanelFailure(MsalServiceException ex)
{
for (Exception e = ex; e != null; e = e.InnerException)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@

using System;
using System.Collections.Concurrent;
using System.Globalization;
using System.Security.Cryptography.X509Certificates;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Identity.Client.Core;
Expand All @@ -21,6 +23,11 @@ namespace Microsoft.Identity.Client.ManagedIdentity.V2
/// </summary>
internal sealed class MtlsBindingCache : IMtlsCertificateCache
{
// OID 1.3.6.1.4.1.311.90.2.1 is the Microsoft-defined token_not_after X.509 extension.
// ESS embeds this in issued credentials to indicate when the cert can no longer be used
// to acquire tokens (even while the X.509 NotAfter is still in the future for renewal).
// MSAL reads this OID to proactively evict a cached cert before it would be rejected.
internal const string TokenNotAfterOid = "1.3.6.1.4.1.311.90.2.1";
private readonly KeyedSemaphorePool _gates = new();
private readonly ICertificateCache _memory;
private readonly IPersistentCertificateCache _persisted;
Expand Down Expand Up @@ -66,13 +73,21 @@ public async Task<MtlsBindingInfo> GetOrCreateAsync(
// 1) In-memory cache first
if (!forceMint && _memory.TryGet(cacheKey, out var cachedEntry, logger))
{
logger.Verbose(() =>
$"[PersistentCert] mTLS binding cache HIT (memory) for '{cacheKey}'.");
if (!IsCertTokenExpiredForTokenRequests(cachedEntry.Certificate, logger))
{
logger.Verbose(() =>
$"[PersistentCert] mTLS binding cache HIT (memory) for '{cacheKey}'.");

return new MtlsBindingInfo(
cachedEntry.Certificate,
cachedEntry.Endpoint,
cachedEntry.ClientId);
}

return new MtlsBindingInfo(
cachedEntry.Certificate,
cachedEntry.Endpoint,
cachedEntry.ClientId);
// Cert is past its token_not_after window; evict and fall through to mint.
logger.Verbose(() =>
$"[PersistentCert] mTLS binding cache HIT (memory) for '{cacheKey}' but cert is past token validity window. Evicting and minting fresh.");
_memory.Remove(cacheKey, logger);
}

// 2) Per-key gate (dedupe concurrent mint)
Expand All @@ -85,13 +100,20 @@ public async Task<MtlsBindingInfo> GetOrCreateAsync(
// Re-check after acquiring the gate
if (!forceMint && _memory.TryGet(cacheKey, out cachedEntry, logger))
{
logger.Verbose(() =>
$"[PersistentCert] mTLS binding cache HIT (memory-after-gate) for '{cacheKey}'.");
if (!IsCertTokenExpiredForTokenRequests(cachedEntry.Certificate, logger))
{
logger.Verbose(() =>
$"[PersistentCert] mTLS binding cache HIT (memory-after-gate) for '{cacheKey}'.");

return new MtlsBindingInfo(
cachedEntry.Certificate,
cachedEntry.Endpoint,
cachedEntry.ClientId);
return new MtlsBindingInfo(
cachedEntry.Certificate,
cachedEntry.Endpoint,
cachedEntry.ClientId);
}

logger.Verbose(() =>
$"[PersistentCert] mTLS binding (memory-after-gate) for '{cacheKey}' is past token validity window. Evicting and minting fresh.");
_memory.Remove(cacheKey, logger);
}

// 3) Persistent cache (best-effort)
Expand All @@ -100,7 +122,8 @@ public async Task<MtlsBindingInfo> GetOrCreateAsync(
logger.Verbose(() =>
$"[PersistentCert] mTLS binding cache HIT (persistent) for '{cacheKey}'.");

if (persistedEntry.Certificate.HasPrivateKey)
if (persistedEntry.Certificate.HasPrivateKey &&
!IsCertTokenExpiredForTokenRequests(persistedEntry.Certificate, logger))
{
Comment on lines 122 to 127
var memoryEntry = new CertificateCacheValue(
persistedEntry.Certificate,
Expand Down Expand Up @@ -155,6 +178,124 @@ public async Task<MtlsBindingInfo> GetOrCreateAsync(
}
}

/// <summary>
/// Returns <see langword="true"/> if the cached cert's token validity window has closed
/// and the cert should therefore be treated as a cache miss so a fresh cert is minted.
/// </summary>
/// <remarks>
/// ESS-issued credentials embed a <c>token_not_after</c> X.509 extension (OID
/// <see cref="TokenNotAfterOid"/>) that Entra uses to reject token requests even while
/// the X.509 <c>NotAfter</c> is still in the future (the cert remains usable for renewal
/// after the token window closes). MSAL reads the OID directly so the check is always
/// accurate regardless of the configured validity window. For certs that do not carry the
/// OID (old format or CSK certs where token validity == cert validity), the standard
/// X.509 <c>NotAfter</c> is used as the fallback boundary.
/// </remarks>
internal static bool IsCertTokenExpiredForTokenRequests(X509Certificate2 cert, ILoggerAdapter logger)
{
if (cert is null)
{
return true;
}

DateTimeOffset tokenNotAfter;
var oidExt = cert.Extensions[TokenNotAfterOid];

if (oidExt is not null && TryParseTokenNotAfterExtension(oidExt.RawData, out DateTimeOffset parsedNotAfter))
{
tokenNotAfter = parsedNotAfter;
logger?.Verbose(() =>
$"[PersistentCert] Read token_not_after OID: {tokenNotAfter:u}.");
}
else
{
// OID absent or unparseable — fall back to the X.509 NotAfter as the token boundary.
tokenNotAfter = new DateTimeOffset(cert.NotAfter, TimeSpan.Zero);
logger?.Verbose(() =>
$"[PersistentCert] token_not_after OID not present; using cert NotAfter {tokenNotAfter:u} as boundary.");
}

var now = DateTimeOffset.UtcNow;
if (now >= tokenNotAfter)
{
logger?.Verbose(() =>
$"[PersistentCert] Cert token validity window has closed. " +
$"token_not_after={tokenNotAfter:u}, now={now:u}. Evicting.");
return true;
}

return false;
}

/// <summary>
/// Attempts to parse a DER-encoded <c>GeneralizedTime</c> or <c>UTCTime</c> value from the
/// raw bytes of the <c>token_not_after</c> X.509 extension (OID <see cref="TokenNotAfterOid"/>).
/// Returns <see langword="false"/> on any parse failure so callers can apply a safe fallback.
/// </summary>
internal static bool TryParseTokenNotAfterExtension(byte[] rawData, out DateTimeOffset result)
{
result = default;
try
{
if (rawData is null || rawData.Length < 4)
return false;

int pos = 0;

// Skip an optional outer SEQUENCE wrapper (0x30 tag).
if (rawData[pos] == 0x30)
{
pos++; // skip tag
if (pos >= rawData.Length) return false;
if ((rawData[pos] & 0x80) != 0)
pos += 1 + (rawData[pos] & 0x7F); // long-form length
else
pos++; // short-form length
}

if (pos + 2 > rawData.Length) return false;

byte tag = rawData[pos++];
if (tag != 0x18 && tag != 0x17) return false; // must be GeneralizedTime or UTCTime

int len = rawData[pos++];
if ((len & 0x80) != 0)
{
int extraBytes = len & 0x7F;
len = 0;
for (int i = 0; i < extraBytes; i++)
len = (len << 8) | rawData[pos++];
}

if (pos + len > rawData.Length) return false;

string timeStr = Encoding.ASCII.GetString(rawData, pos, len).TrimEnd('Z');

// DER GeneralizedTime: YYYYMMDDHHMMSS[.fff]
if (tag == 0x18)
{
return DateTimeOffset.TryParseExact(
timeStr,
new[] { "yyyyMMddHHmmss", "yyyyMMddHHmmss.fff" },
CultureInfo.InvariantCulture,
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal,
out result);
}
Comment on lines +272 to +283

// DER UTCTime: YYMMDDHHMMSS
return DateTimeOffset.TryParseExact(
timeStr,
"yyMMddHHmmss",
CultureInfo.InvariantCulture,
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal,
out result);
Comment on lines +285 to +291
}
catch
{
return false;
}
}

/// <summary>
/// Removes a certificate from both in-memory and persistent cache when SCHANNEL rejects it.
/// </summary>
Expand Down
11 changes: 11 additions & 0 deletions tests/Microsoft.Identity.Test.Integration.netfx/app.config
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<runtime>
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
<dependentAssembly>
<assemblyIdentity name="System.Memory" publicKeyToken="cc7b13ffcd2ddd51" culture="neutral" />
<bindingRedirect oldVersion="0.0.0.0-4.0.5.0" newVersion="4.0.5.0" />
</dependentAssembly>
</assemblyBinding>
</runtime>
</configuration>
Loading
Loading