Skip to content
Closed
Show file tree
Hide file tree
Changes from 5 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 @@ -265,15 +265,15 @@ private async Task<AuthenticationResult> AcquireFreshTokenAsync(
cancellationToken)
.ConfigureAwait(false);

// Apply PoP for this call only; no client-side persistence
// After SendTokenRequestForManagedIdentityAsync returns
if (_managedIdentityParameters.MtlsCertificate != null && popRequested)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why isn't the cert set on managedIdentityResponse?

{
AuthenticationRequestParameters.AuthenticationOperationOverride =
new MtlsPopAuthenticationOperation(_managedIdentityParameters.MtlsCertificate);
logger.Info("[ManagedIdentityRequest] Applied mTLS PoP operation for current request.");
}

// Drop our reference to the cert (IMDSv2 source stored it in user store already)
// Drop our reference (store already persisted it earlier in the source)
_managedIdentityParameters.MtlsCertificate = null;

var msalTokenResponse = MsalTokenResponse.CreateFromManagedIdentityResponse(managedIdentityResponse);
Expand Down Expand Up @@ -349,23 +349,30 @@ private void ApplyMtlsOverrideFromUserStoreIfAvailable(bool popRequested, ILogge
if (AuthenticationRequestParameters.AuthenticationOperationOverride != null)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ideally this method should not have to deal with this/

return;

// Identity key is MSAL client id (SAMI default or UAMI id)
var tokenType = popRequested ? Constants.MtlsPoPTokenType : Constants.BearerTokenType;
var identityKey = ServiceBundle.Config.ClientId;

if (ImdsV2ManagedIdentitySource.TryGetImdsV2BindingMetadata(identityKey, out _, out var subject) &&
!string.IsNullOrEmpty(subject))
// Try in-memory first; if not present, attempt store rehydration
if (!ImdsV2ManagedIdentitySource.TryGetImdsV2BindingMetadata(identityKey, tokenType, out var resp, out var subject, out var tp))
{
var cert = MtlsBindingStore.GetFreshestBySubject(
subject,
MtlsBindingStore.MinFreshRemaining,
logger);
BindingMetadataPersistence.TryRehydrateFromStore(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

THis is Windows only. Can you please add abstractions that allow us to run eveyrthing on Linux? Eveything should work with memory caching and the details of the caching should be hidden from this component.

identityKey, tokenType, logger, out resp, out subject, out tp);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: you can return a tuple


if (cert != null)
if (resp != null && !string.IsNullOrEmpty(subject) && !string.IsNullOrEmpty(tp))
{
ImdsV2ManagedIdentitySource.CacheImdsV2BindingMetadata(identityKey, resp, subject, tp, tokenType);
}
}

if (!string.IsNullOrEmpty(subject))
{
var cert = MtlsBindingStore.GetFreshestBySubject(subject, logger);
if (MtlsBindingStore.IsCurrentlyValid(cert))
{
AuthenticationRequestParameters.AuthenticationOperationOverride =
new MtlsPopAuthenticationOperation(cert);

logger.Info("[ManagedIdentityRequest] mTLS PoP requested. Using freshest user-store binding (>=5 min).");
logger.Info("[ManagedIdentityRequest] mTLS PoP requested. Applied operation using user-store binding (freshest, valid).");
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,16 +1,55 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

using System;
using System.Collections.Concurrent;
using Microsoft.Identity.Client.ManagedIdentity.V2;

namespace Microsoft.Identity.Client.ManagedIdentity
{
/// <summary>
/// Imds V2 binding metadata cached per certificate subject.
/// Stores and manages certificate binding metadata for Azure managed identities using IMDSv2.
/// This class caches certificate information and STS endpoints per identity (MSI client ID),
/// maintaining separate mappings for different token types to ensure proper security isolation.
/// </summary>
/// <remarks>
/// Each managed identity can have separate certificate bindings for different authentication methods:
/// - Bearer tokens: Standard OAuth2 bearer tokens
/// - PoP (Proof of Possession) tokens: Enhanced security tokens bound to a specific certificate
///
/// The Subject is set once (first-wins pattern) while thumbprints can rotate during certificate renewal.
/// This design allows proper certificate rotation while maintaining stable subject identities.
/// </remarks>
internal class ImdsV2BindingMetadata
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: you could have a key that includes the token type, so as to avoid storing {tokenType}Cert and {tokenType}Thumbprint

{
public CertificateRequestResponse Response { get; set; }
public string CertificateSubject { get; set; } // e.g., "CN=msal-imdsv2-binding-<id>"
/// <summary>
/// The X.509 certificate subject distinguished name used for this identity.
/// This value is set once (first-wins) and persists across certificate rotations.
/// </summary>
public string Subject { get; set; }

/// <summary>
/// Response data for Bearer token certificate authentication, including
/// certificate data and STS endpoint information.
/// </summary>
public CertificateRequestResponse BearerResponse { get; set; }

/// <summary>
/// Thumbprint of the certificate used for Bearer token authentication.
/// Updated during certificate rotation.
/// </summary>
public string BearerThumbprint { get; set; }

/// <summary>
/// Response data for PoP (Proof of Possession) token certificate authentication,
/// including certificate data and STS endpoint information.
/// </summary>
public CertificateRequestResponse PopResponse { get; set; }

/// <summary>
/// Thumbprint of the certificate used for PoP token authentication.
/// Updated during certificate rotation.
/// </summary>
public string PopThumbprint { get; set; }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,13 @@ internal class ManagedIdentityClient
private const string LinuxHimdsFilePath = "/opt/azcmagent/bin/himds";
internal static ManagedIdentitySource s_sourceName = ManagedIdentitySource.None;

// Per-identity, process-wide. Identity key = MSAL Config.ClientId (SAMI/UAMI).
internal static readonly ConcurrentDictionary<string, ImdsV2BindingMetadata> s_imdsV2Binding =
new ConcurrentDictionary<string, ImdsV2BindingMetadata>(StringComparer.Ordinal);
internal static readonly ConcurrentDictionary<string, ImdsV2BindingMetadata> s_identityToBindingMetadataMap
= new ConcurrentDictionary<string, ImdsV2BindingMetadata>(StringComparer.Ordinal);

internal static void ResetSourceAndBindingForTest()
{
s_sourceName = ManagedIdentitySource.None;
s_imdsV2Binding.Clear();
s_identityToBindingMetadataMap.Clear();
RemoveAllTestBindingCertsFromUserStoreForTest();
}

Expand Down Expand Up @@ -169,7 +168,7 @@ private static bool ValidateAzureArcEnvironment(string identityEndpoint, string
// Test only method to remove all test binding certs from user store.
internal static void RemoveAllTestBindingCertsFromUserStoreForTest()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is this part of the product? Does eSTS send these certificates?

{
MtlsBindingStore.RemoveBySubjectPrefixForTest("CN=Test");
MtlsBindingStore.RemoveAllBySubject("CN=Test");
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,244 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

using System;
using System.Linq;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using System.Text;
using Microsoft.Identity.Client.Core;

namespace Microsoft.Identity.Client.ManagedIdentity.V2
{
/// <summary>
/// Provides persistence mechanisms for certificate binding metadata in the Windows certificate store.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Everything that is Windows specific should be named "Windows" and should be used behind an interface, so as to be able to provide a Linux implementaiton.

/// This class enables MSAL to store and retrieve relationships between Managed Identities
/// and their associated certificates without requiring additional storage.
/// </summary>
/// <remarks>
/// This class uses the X509Certificate2.FriendlyName property to store encoded metadata that links
/// certificates to specific managed identities and token types. The metadata includes:
/// - Identity key (hashed for privacy)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

privacy of what?

/// - Token type (Bearer or PoP)
/// - Client ID
/// - Tenant ID
/// - MTLS authentication endpoint
///
/// The persistence mechanism enables MSAL to find previously created certificates for an identity
/// across application restarts, reducing the need to repeatedly mint new certificates.
/// </remarks>
internal static class BindingMetadataPersistence
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Think about SOLID and how much power there is in using interfaces and abstractions. Avoid using static everywhere.

{
// Prefix that identifies certificates managed by MSAL for Managed Identities
private const string Prefix = "MSAL_MI_MTLS|v1|";
private const char Sep = '|';

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This class should take in a logger and do some logging.

/// <summary>
/// Creates a structured FriendlyName value containing encoded binding metadata.
/// </summary>
/// <param name="identityKey">The identity key to associate with this certificate</param>
/// <param name="tokenType">The token type (Bearer or PoP)</param>
/// <param name="resp">The certificate response containing endpoint and identity information</param>
/// <returns>A formatted string for use as certificate FriendlyName</returns>
public static string BuildFriendlyName(string identityKey, string tokenType, CertificateRequestResponse resp)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

private

{
try
{
if (resp == null || string.IsNullOrEmpty(identityKey) || string.IsNullOrEmpty(tokenType))
return null;

// Hash the identity key for privacy while maintaining stable identification
string hid = HashId(identityKey);

// Encode the endpoint to avoid conflicts with separator character
string ep = Base64UrlNoPad(Encoding.UTF8.GetBytes(resp.MtlsAuthenticationEndpoint ?? string.Empty));
string tenant = resp.TenantId ?? string.Empty;
string client = resp.ClientId ?? string.Empty;

return string.Concat(Prefix, tokenType, Sep, hid, Sep, client, Sep, tenant, Sep, ep);
}
catch { return null; }
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please stop catching all errors. We will never know if smth is wrong. Let everything bubble up.

}

/// <summary>
/// Attempts to recover binding metadata from certificates in the store.
/// Finds the freshest valid certificate matching the identity key and token type.
/// </summary>
/// <param name="identityKey">The identity key to search for</param>
/// <param name="tokenType">The token type (Bearer or PoP)</param>
/// <param name="logger">Logger for diagnostic information</param>
/// <param name="resp">Output parameter for the recovered certificate response</param>
/// <param name="subject">Output parameter for the certificate subject</param>
/// <param name="thumbprint">Output parameter for the certificate thumbprint</param>
/// <returns>True if binding metadata was successfully recovered, false otherwise</returns>
public static bool TryRehydrateFromStore(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider using regular caching / persistance names like Get or TryGet

string identityKey,
string tokenType,
ILoggerAdapter logger,
out CertificateRequestResponse resp,
out string subject,
out string thumbprint)
{
resp = null;
subject = null;
thumbprint = null;

try
{
var hid = HashId(identityKey);
using var store = new X509Store(StoreName.My, StoreLocation.CurrentUser);
store.Open(OpenFlags.ReadOnly);

// Find all certificates with our prefix in the FriendlyName
var candidates = store.Certificates.OfType<X509Certificate2>()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As per copilot "Summary:
Due to .NET's API limitations, searching by FriendlyName requires enumerating and filtering all certificates. If you can use another property for more selective searching, do so. Otherwise, the current method is standard practice, but may not scale well with a large certificate store."

So this doesn't really scale. Have you tested the perf for this? Do you plan to add a memory cache in front of this?

.Where(c => !string.IsNullOrEmpty(c.FriendlyName) &&
c.FriendlyName.StartsWith(Prefix, StringComparison.Ordinal))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why can't you do a full match on the key?

.ToList();

X509Certificate2 freshest = null;
CertificateRequestResponse freshestResp = null;

// Find the freshest valid certificate matching our identity and token type
foreach (var c in candidates)
{
// Parse the FriendlyName to extract the encoded metadata
if (!TryParse(c.FriendlyName, out var tType, out var h, out var clientId, out var tenantId, out var ep))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pls stop using these cryptic short variable names, they are very hard to read.

continue;

// Must match the requested token type
if (!StringComparer.OrdinalIgnoreCase.Equals(tType, tokenType))
continue;

// Must match the hashed identity key
if (!StringComparer.Ordinal.Equals(h, hid))
continue;

// Certificate must be currently valid
if (!MtlsBindingStore.IsCurrentlyValid(c))
continue;

// Keep track of the freshest certificate (furthest expiration date)
if (freshest == null || c.NotAfter.ToUniversalTime() > freshest.NotAfter.ToUniversalTime())
{
freshest = c;
freshestResp = new CertificateRequestResponse
{
ClientId = clientId,
TenantId = tenantId,
MtlsAuthenticationEndpoint = ep
};
}
}

if (freshest == null || freshestResp == null)
return false;

resp = freshestResp;
subject = freshest.Subject;
thumbprint = freshest.Thumbprint;
logger?.Info("[IMDSv2] Rehydrated binding metadata from certificate store (FriendlyName tag).");
return true;
}
catch (Exception ex)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why don't you let this bubble up?

{
logger?.Verbose(() => $"[IMDSv2] Store rehydration failed: {ex.GetType().Name}: {ex.Message}");
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not logger.error? In any case, you should let it bubble up.

return false;
}
}

/// <summary>
/// Parses a FriendlyName value to extract the encoded binding metadata.
/// </summary>
private static bool TryParse(string friendlyName, out string tokenType, out string hid, out string clientId, out string tenantId, out string endpoint)
{
tokenType = hid = clientId = tenantId = endpoint = null;

if (string.IsNullOrEmpty(friendlyName) || !friendlyName.StartsWith(Prefix, StringComparison.Ordinal))
return false;

try
{
var payload = friendlyName.Substring(Prefix.Length);
var parts = payload.Split(Sep);
if (parts.Length < 5)
return false;

tokenType = parts[0];
hid = parts[1];
clientId = parts[2];
tenantId = parts[3];

// endpoint is base64-url-no-pad; join remainder in case it contained separators
var epEncoded = string.Join(Sep.ToString(), parts.Skip(4));
var epBytes = Base64UrlNoPadDecode(epEncoded);
endpoint = Encoding.UTF8.GetString(epBytes ?? Array.Empty<byte>());

return true;
}
catch { return false; }
}

/// <summary>
/// Creates a stable, shortened hash of an identity key for storage efficiency.
/// </summary>
private static string HashId(string id)
{
using var sha = SHA256.Create();
var h = sha.ComputeHash(Encoding.UTF8.GetBytes(id ?? string.Empty));
// 12 bytes (24 hex chars) is plenty for collision avoidance while keeping FriendlyName compact
return ToHex(h, 12);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why ToHex ?

}

/// <summary>
/// Converts bytes to a hexadecimal string representation.
/// </summary>
private static string ToHex(byte[] bytes, int takeBytes)
{
if (bytes == null)
return string.Empty;
int n = Math.Max(0, Math.Min(takeBytes, bytes.Length));
var sb = new StringBuilder(n * 2);
for (int i = 0; i < n; i++)
{
sb.Append(bytes[i].ToString("x2"));
}
return sb.ToString();
}

/// <summary>
/// Encodes binary data as base64url without padding to avoid separator conflicts.
/// </summary>
private static string Base64UrlNoPad(byte[] data)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's wrong with Base64Url encoding which we already use eveyrwhere ?

{
if (data == null || data.Length == 0)
return string.Empty;
var s = Convert.ToBase64String(data)
.TrimEnd('=')
.Replace('+', '-')
.Replace('/', '_');
return s;
}

/// <summary>
/// Decodes base64url-formatted string back to binary data.
/// </summary>
private static byte[] Base64UrlNoPadDecode(string s)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not use base64url encoding helpers?

{
if (string.IsNullOrEmpty(s))
return Array.Empty<byte>();
s = s.Replace('-', '+').Replace('_', '/');
switch (s.Length % 4)
{
case 2:
s += "==";
break;
case 3:
s += "=";
break;
}
try
{ return Convert.FromBase64String(s); }
catch { return Array.Empty<byte>(); }
}
}
}
Loading