-
Couldn't load subscription status.
- Fork 378
[MSI v2 - Feature] Add cert cache and Auth Operation to IMDS V2 #5509
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 5 commits
77e22ca
a1776a0
f331cbe
7a61f5c
1a89431
8584d4e
9ebec69
40e4356
61a0b70
c7e0b75
186b98e
e5c11b6
22e1c30
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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) | ||
| { | ||
| 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); | ||
|
|
@@ -349,23 +349,30 @@ private void ApplyMtlsOverrideFromUserStoreIfAvailable(bool popRequested, ILogge | |
| if (AuthenticationRequestParameters.AuthenticationOperationOverride != null) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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( | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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)."); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| 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 | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
|---|---|---|
|
|
@@ -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(); | ||
| } | ||
|
|
||
|
|
@@ -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() | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 = '|'; | ||
|
|
||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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; } | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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( | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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>() | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. As per copilot "Summary: 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)) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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)) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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}"); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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>(); } | ||
| } | ||
| } | ||
| } | ||
There was a problem hiding this comment.
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?