From c2e0f820b24b04f2c8c8c6cbe0815413a8e57825 Mon Sep 17 00:00:00 2001 From: Robbie Ginsburg Date: Wed, 10 Jun 2026 16:35:13 -0400 Subject: [PATCH 1/2] Enforce mTLS PoP minimum binding strength for managed identity (#6049 Phase 2) Add PoPOptions with a MinStrength floor and a managed identity WithMtlsProofOfPossession(PoPOptions) overload. When MinStrength is greater than None, the token request runs host capability discovery and fails fast with MsalError.MinStrengthNotMet if the host's maximum binding strength is below the requested floor. The parameterless overload now delegates to the new overload with a default (None) floor. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../ManagedIdentityAttestationExtensions.cs | 2 +- ...TokenForManagedIdentityParameterBuilder.cs | 1 + .../AcquireTokenCommonParameters.cs | 7 + ...cquireTokenForManagedIdentityParameters.cs | 8 ++ .../AppConfig/PoPOptions.cs | 33 +++++ .../AuthenticationRequestParameters.cs | 1 + .../ManagedIdentity/ManagedIdentityClient.cs | 17 +++ .../ManagedIdentityPopExtensions.cs | 27 ++++ .../Microsoft.Identity.Client/MsalError.cs | 10 ++ .../MsalErrorMessage.cs | 3 + .../PublicApi/net462/PublicAPI.Unshipped.txt | 6 + .../PublicApi/net472/PublicAPI.Unshipped.txt | 6 + .../net8.0-android/PublicAPI.Unshipped.txt | 6 + .../net8.0-ios/PublicAPI.Unshipped.txt | 6 + .../PublicApi/net8.0/PublicAPI.Unshipped.txt | 6 + .../netstandard2.0/PublicAPI.Unshipped.txt | 6 + .../ManagedIdentityTests/ImdsV2Tests.cs | 124 ++++++++++++++++++ 17 files changed, 268 insertions(+), 1 deletion(-) create mode 100644 src/client/Microsoft.Identity.Client/AppConfig/PoPOptions.cs diff --git a/src/client/Microsoft.Identity.Client.KeyAttestation/ManagedIdentityAttestationExtensions.cs b/src/client/Microsoft.Identity.Client.KeyAttestation/ManagedIdentityAttestationExtensions.cs index 21848ae129..4a2bcc7e63 100644 --- a/src/client/Microsoft.Identity.Client.KeyAttestation/ManagedIdentityAttestationExtensions.cs +++ b/src/client/Microsoft.Identity.Client.KeyAttestation/ManagedIdentityAttestationExtensions.cs @@ -14,7 +14,7 @@ public static class ManagedIdentityAttestationExtensions { /// /// Enables Credential Guard attestation support for managed identity mTLS Proof-of-Possession flows. - /// This method should be called after . + /// This method should be called after . /// /// The AcquireTokenForManagedIdentityParameterBuilder instance. /// The builder to chain .With methods. diff --git a/src/client/Microsoft.Identity.Client/ApiConfig/AcquireTokenForManagedIdentityParameterBuilder.cs b/src/client/Microsoft.Identity.Client/ApiConfig/AcquireTokenForManagedIdentityParameterBuilder.cs index 36211981af..99aa4a1aff 100644 --- a/src/client/Microsoft.Identity.Client/ApiConfig/AcquireTokenForManagedIdentityParameterBuilder.cs +++ b/src/client/Microsoft.Identity.Client/ApiConfig/AcquireTokenForManagedIdentityParameterBuilder.cs @@ -130,6 +130,7 @@ private static void ApplyMtlsPopAndAttestation( AcquireTokenForManagedIdentityParameters acquireTokenForManagedIdentityParameters) { acquireTokenForManagedIdentityParameters.IsMtlsPopRequested = acquireTokenCommonParameters.IsMtlsPopRequested; + acquireTokenForManagedIdentityParameters.MtlsPopMinStrength = acquireTokenCommonParameters.MtlsPopMinStrength; acquireTokenForManagedIdentityParameters.AttestationTokenProvider = acquireTokenCommonParameters.AttestationTokenProvider; // PoP requests should be partitioned by attestation-support mode. diff --git a/src/client/Microsoft.Identity.Client/ApiConfig/Parameters/AcquireTokenCommonParameters.cs b/src/client/Microsoft.Identity.Client/ApiConfig/Parameters/AcquireTokenCommonParameters.cs index 92d23211fe..61dfbf0d80 100644 --- a/src/client/Microsoft.Identity.Client/ApiConfig/Parameters/AcquireTokenCommonParameters.cs +++ b/src/client/Microsoft.Identity.Client/ApiConfig/Parameters/AcquireTokenCommonParameters.cs @@ -44,6 +44,13 @@ internal class AcquireTokenCommonParameters public string FmiPathSuffix { get; internal set; } public string ClientAssertionFmiPath { get; internal set; } public bool IsMtlsPopRequested { get; set; } + + /// + /// The minimum mTLS binding strength the host must support for the request to succeed. + /// Set via . Defaults to + /// (no floor). + /// + public MtlsBindingStrength MtlsPopMinStrength { get; set; } = MtlsBindingStrength.None; public string ExtraClientAssertionClaims { get; internal set; } /// diff --git a/src/client/Microsoft.Identity.Client/ApiConfig/Parameters/AcquireTokenForManagedIdentityParameters.cs b/src/client/Microsoft.Identity.Client/ApiConfig/Parameters/AcquireTokenForManagedIdentityParameters.cs index e00d53797c..55630525f4 100644 --- a/src/client/Microsoft.Identity.Client/ApiConfig/Parameters/AcquireTokenForManagedIdentityParameters.cs +++ b/src/client/Microsoft.Identity.Client/ApiConfig/Parameters/AcquireTokenForManagedIdentityParameters.cs @@ -10,6 +10,7 @@ using System.Threading; using System.Threading.Tasks; using Microsoft.Identity.Client.Core; +using Microsoft.Identity.Client.AppConfig; using Microsoft.Identity.Client.ManagedIdentity; namespace Microsoft.Identity.Client.ApiConfig.Parameters @@ -32,6 +33,12 @@ internal class AcquireTokenForManagedIdentityParameters : IAcquireTokenParameter public bool IsMtlsPopRequested { get; set; } + /// + /// The minimum mTLS binding strength the host must support for the request to succeed. + /// Defaults to (no floor). + /// + public MtlsBindingStrength MtlsPopMinStrength { get; set; } = MtlsBindingStrength.None; + internal X509Certificate2 MtlsCertificate { get; set; } /// @@ -54,6 +61,7 @@ public void LogParameters(ILoggerAdapter logger) ClientClaims: {!string.IsNullOrEmpty(ClientClaims)} RevokedTokenHash: {!string.IsNullOrEmpty(RevokedTokenHash)} IsMtlsPopRequested: {IsMtlsPopRequested} + MtlsPopMinStrength: {MtlsPopMinStrength} """); } } diff --git a/src/client/Microsoft.Identity.Client/AppConfig/PoPOptions.cs b/src/client/Microsoft.Identity.Client/AppConfig/PoPOptions.cs new file mode 100644 index 0000000000..138f2f66dd --- /dev/null +++ b/src/client/Microsoft.Identity.Client/AppConfig/PoPOptions.cs @@ -0,0 +1,33 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +namespace Microsoft.Identity.Client.AppConfig +{ + /// + /// Options that control mTLS Proof-of-Possession (PoP) token acquisition. This type is the + /// extensibility point for PoP-related knobs so future settings can be added without growing + /// the builder surface. + /// + /// + /// This type is shared by managed identity and confidential client mTLS Proof-of-Possession + /// scenarios. + /// + public class PoPOptions + { + /// + /// Gets or sets the minimum binding strength the host must be able to produce for the + /// request to succeed. This is a floor assertion, not a downgrade selector: MSAL + /// always uses the host's maximum binding strength, and the request fails when the host + /// cannot meet this floor. + /// + /// + /// The minimum required . The default is + /// , which imposes no floor and behaves identically + /// to the parameterless mTLS PoP request. When set to a value greater than + /// , the request fails with + /// if the host's maximum binding strength is + /// lower than this value. + /// + public MtlsBindingStrength MinStrength { get; set; } = MtlsBindingStrength.None; + } +} diff --git a/src/client/Microsoft.Identity.Client/Internal/Requests/AuthenticationRequestParameters.cs b/src/client/Microsoft.Identity.Client/Internal/Requests/AuthenticationRequestParameters.cs index 737a53ed23..256957b79a 100644 --- a/src/client/Microsoft.Identity.Client/Internal/Requests/AuthenticationRequestParameters.cs +++ b/src/client/Microsoft.Identity.Client/Internal/Requests/AuthenticationRequestParameters.cs @@ -122,6 +122,7 @@ public X509Certificate2 MtlsCertificate } public bool IsMtlsPopRequested => _commonParameters.IsMtlsPopRequested; + public MtlsBindingStrength MtlsPopMinStrength => _commonParameters.MtlsPopMinStrength; public bool? SendOfflineAccessScope => _commonParameters.SendOfflineAccessScope; /// diff --git a/src/client/Microsoft.Identity.Client/ManagedIdentity/ManagedIdentityClient.cs b/src/client/Microsoft.Identity.Client/ManagedIdentity/ManagedIdentityClient.cs index df1f7e4102..528b9e71ce 100644 --- a/src/client/Microsoft.Identity.Client/ManagedIdentity/ManagedIdentityClient.cs +++ b/src/client/Microsoft.Identity.Client/ManagedIdentity/ManagedIdentityClient.cs @@ -51,6 +51,23 @@ internal async Task SendTokenRequestForManagedIdentityA AcquireTokenForManagedIdentityParameters parameters, CancellationToken cancellationToken) { + // Enforce a minimum binding strength floor when requested via PoPOptions.MinStrength. + // The token-request path normally routes mTLS PoP straight to IMDSv2 without probing, + // so explicitly run discovery to learn the host's maximum binding strength and fail + // fast if the host cannot meet the required floor. + if (parameters.MtlsPopMinStrength > MtlsBindingStrength.None) + { + ManagedIdentityDiscoveryResult discovery = + await GetManagedIdentityCapabilitiesAsync(requestContext, cancellationToken).ConfigureAwait(false); + + if (discovery.MaxSupportedBindingStrength < parameters.MtlsPopMinStrength) + { + throw new MsalClientException( + MsalError.MinStrengthNotMet, + MsalErrorMessage.MinStrengthNotMet(discovery.MaxSupportedBindingStrength, parameters.MtlsPopMinStrength)); + } + } + AbstractManagedIdentity msi = await GetOrSelectManagedIdentitySourceAsync(requestContext, parameters.IsMtlsPopRequested, cancellationToken).ConfigureAwait(false); return await msi.AuthenticateAsync(parameters, cancellationToken).ConfigureAwait(false); } diff --git a/src/client/Microsoft.Identity.Client/ManagedIdentity/ManagedIdentityPopExtensions.cs b/src/client/Microsoft.Identity.Client/ManagedIdentity/ManagedIdentityPopExtensions.cs index 61e220fb4b..99f29d5e7b 100644 --- a/src/client/Microsoft.Identity.Client/ManagedIdentity/ManagedIdentityPopExtensions.cs +++ b/src/client/Microsoft.Identity.Client/ManagedIdentity/ManagedIdentityPopExtensions.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. +using Microsoft.Identity.Client.AppConfig; using Microsoft.Identity.Client.PlatformsCommon.Shared; namespace Microsoft.Identity.Client @@ -20,6 +21,31 @@ public static class ManagedIdentityPopExtensions public static AcquireTokenForManagedIdentityParameterBuilder WithMtlsProofOfPossession( this AcquireTokenForManagedIdentityParameterBuilder builder) { + return builder.WithMtlsProofOfPossession(new PoPOptions()); + } + + /// + /// Enables mTLS Proof-of-Possession for managed identity token acquisition with additional + /// PoP options, such as a minimum required binding strength. + /// When attestation is required (KeyGuard scenarios), use the Msal.KeyAttestation package + /// and call .WithAttestationSupport() after this method. + /// + /// The AcquireTokenForManagedIdentityParameterBuilder instance. + /// + /// PoP options controlling the request. Use to require + /// a minimum host binding strength; the request fails with + /// when the host cannot meet the floor. + /// + /// The builder to chain .With methods. + public static AcquireTokenForManagedIdentityParameterBuilder WithMtlsProofOfPossession( + this AcquireTokenForManagedIdentityParameterBuilder builder, + PoPOptions options) + { + if (options == null) + { + throw new System.ArgumentNullException(nameof(options)); + } + if (!DesktopOsHelper.IsWindows()) { throw new MsalClientException( @@ -33,6 +59,7 @@ public static AcquireTokenForManagedIdentityParameterBuilder WithMtlsProofOfPoss MsalErrorMessage.MtlsNotSupportedForManagedIdentityMessage); #else builder.CommonParameters.IsMtlsPopRequested = true; + builder.CommonParameters.MtlsPopMinStrength = options.MinStrength; return builder; #endif } diff --git a/src/client/Microsoft.Identity.Client/MsalError.cs b/src/client/Microsoft.Identity.Client/MsalError.cs index eb26f51c32..e96efcba96 100644 --- a/src/client/Microsoft.Identity.Client/MsalError.cs +++ b/src/client/Microsoft.Identity.Client/MsalError.cs @@ -1246,6 +1246,16 @@ public static class MsalError /// public const string MtlsPopTokenNotSupportedinImdsV1 = "mtls_pop_token_not_supported_in_imds_v1"; + /// + /// What happened? A minimum mTLS binding strength was requested via + /// PoPOptions.MinStrength, but the current host cannot produce a key binding that + /// meets the required floor. + /// Mitigation: Deploy on a host capable of the required binding strength + /// (for example, a Trusted Launch or Confidential VM for KeyGuard), or lower the requested + /// minimum strength. + /// + public const string MinStrengthNotMet = "min_strength_not_met"; + /// /// All managed identity sources are unavailable. /// diff --git a/src/client/Microsoft.Identity.Client/MsalErrorMessage.cs b/src/client/Microsoft.Identity.Client/MsalErrorMessage.cs index 9656e5dfa9..4963c5b3bb 100644 --- a/src/client/Microsoft.Identity.Client/MsalErrorMessage.cs +++ b/src/client/Microsoft.Identity.Client/MsalErrorMessage.cs @@ -455,6 +455,9 @@ public static string InvalidTokenProviderResponseValue(string invalidValueName) public const string InvalidCertificate = "The certificate received from the Imds server is invalid."; public const string CannotSwitchBetweenImdsVersionsForPreview = "ImdsV2 is currently experimental - A Bearer token has already been received; Please restart the application to receive a mTLS PoP token."; public const string MtlsPopTokenNotSupportedinImdsV1 = "mTLS Proof of Possession with managed identity is currently in private preview and is not supported on this VM. Ensure you're running on a supported VM image."; + + public static string MinStrengthNotMet(object actualStrength, object requiredStrength) => + $"The host's maximum mTLS binding strength '{actualStrength}' does not meet the required minimum binding strength '{requiredStrength}' specified via PoPOptions.MinStrength."; public const string ManagedIdentityAllSourcesUnavailable = "All Managed Identity sources are unavailable."; public const string SendCertificateOverMtlsRequiresCertificate = "CertificateOptions.SendCertificateOverMtls is only valid with a certificate-based credential " + diff --git a/src/client/Microsoft.Identity.Client/PublicApi/net462/PublicAPI.Unshipped.txt b/src/client/Microsoft.Identity.Client/PublicApi/net462/PublicAPI.Unshipped.txt index e69de29bb2..0e4610510d 100644 --- a/src/client/Microsoft.Identity.Client/PublicApi/net462/PublicAPI.Unshipped.txt +++ b/src/client/Microsoft.Identity.Client/PublicApi/net462/PublicAPI.Unshipped.txt @@ -0,0 +1,6 @@ +Microsoft.Identity.Client.AppConfig.PoPOptions +Microsoft.Identity.Client.AppConfig.PoPOptions.MinStrength.get -> Microsoft.Identity.Client.AppConfig.MtlsBindingStrength +Microsoft.Identity.Client.AppConfig.PoPOptions.MinStrength.set -> void +Microsoft.Identity.Client.AppConfig.PoPOptions.PoPOptions() -> void +const Microsoft.Identity.Client.MsalError.MinStrengthNotMet = "min_strength_not_met" -> string +static Microsoft.Identity.Client.ManagedIdentityPopExtensions.WithMtlsProofOfPossession(this Microsoft.Identity.Client.AcquireTokenForManagedIdentityParameterBuilder builder, Microsoft.Identity.Client.AppConfig.PoPOptions options) -> Microsoft.Identity.Client.AcquireTokenForManagedIdentityParameterBuilder diff --git a/src/client/Microsoft.Identity.Client/PublicApi/net472/PublicAPI.Unshipped.txt b/src/client/Microsoft.Identity.Client/PublicApi/net472/PublicAPI.Unshipped.txt index e69de29bb2..0e4610510d 100644 --- a/src/client/Microsoft.Identity.Client/PublicApi/net472/PublicAPI.Unshipped.txt +++ b/src/client/Microsoft.Identity.Client/PublicApi/net472/PublicAPI.Unshipped.txt @@ -0,0 +1,6 @@ +Microsoft.Identity.Client.AppConfig.PoPOptions +Microsoft.Identity.Client.AppConfig.PoPOptions.MinStrength.get -> Microsoft.Identity.Client.AppConfig.MtlsBindingStrength +Microsoft.Identity.Client.AppConfig.PoPOptions.MinStrength.set -> void +Microsoft.Identity.Client.AppConfig.PoPOptions.PoPOptions() -> void +const Microsoft.Identity.Client.MsalError.MinStrengthNotMet = "min_strength_not_met" -> string +static Microsoft.Identity.Client.ManagedIdentityPopExtensions.WithMtlsProofOfPossession(this Microsoft.Identity.Client.AcquireTokenForManagedIdentityParameterBuilder builder, Microsoft.Identity.Client.AppConfig.PoPOptions options) -> Microsoft.Identity.Client.AcquireTokenForManagedIdentityParameterBuilder diff --git a/src/client/Microsoft.Identity.Client/PublicApi/net8.0-android/PublicAPI.Unshipped.txt b/src/client/Microsoft.Identity.Client/PublicApi/net8.0-android/PublicAPI.Unshipped.txt index e69de29bb2..0e4610510d 100644 --- a/src/client/Microsoft.Identity.Client/PublicApi/net8.0-android/PublicAPI.Unshipped.txt +++ b/src/client/Microsoft.Identity.Client/PublicApi/net8.0-android/PublicAPI.Unshipped.txt @@ -0,0 +1,6 @@ +Microsoft.Identity.Client.AppConfig.PoPOptions +Microsoft.Identity.Client.AppConfig.PoPOptions.MinStrength.get -> Microsoft.Identity.Client.AppConfig.MtlsBindingStrength +Microsoft.Identity.Client.AppConfig.PoPOptions.MinStrength.set -> void +Microsoft.Identity.Client.AppConfig.PoPOptions.PoPOptions() -> void +const Microsoft.Identity.Client.MsalError.MinStrengthNotMet = "min_strength_not_met" -> string +static Microsoft.Identity.Client.ManagedIdentityPopExtensions.WithMtlsProofOfPossession(this Microsoft.Identity.Client.AcquireTokenForManagedIdentityParameterBuilder builder, Microsoft.Identity.Client.AppConfig.PoPOptions options) -> Microsoft.Identity.Client.AcquireTokenForManagedIdentityParameterBuilder diff --git a/src/client/Microsoft.Identity.Client/PublicApi/net8.0-ios/PublicAPI.Unshipped.txt b/src/client/Microsoft.Identity.Client/PublicApi/net8.0-ios/PublicAPI.Unshipped.txt index e69de29bb2..0e4610510d 100644 --- a/src/client/Microsoft.Identity.Client/PublicApi/net8.0-ios/PublicAPI.Unshipped.txt +++ b/src/client/Microsoft.Identity.Client/PublicApi/net8.0-ios/PublicAPI.Unshipped.txt @@ -0,0 +1,6 @@ +Microsoft.Identity.Client.AppConfig.PoPOptions +Microsoft.Identity.Client.AppConfig.PoPOptions.MinStrength.get -> Microsoft.Identity.Client.AppConfig.MtlsBindingStrength +Microsoft.Identity.Client.AppConfig.PoPOptions.MinStrength.set -> void +Microsoft.Identity.Client.AppConfig.PoPOptions.PoPOptions() -> void +const Microsoft.Identity.Client.MsalError.MinStrengthNotMet = "min_strength_not_met" -> string +static Microsoft.Identity.Client.ManagedIdentityPopExtensions.WithMtlsProofOfPossession(this Microsoft.Identity.Client.AcquireTokenForManagedIdentityParameterBuilder builder, Microsoft.Identity.Client.AppConfig.PoPOptions options) -> Microsoft.Identity.Client.AcquireTokenForManagedIdentityParameterBuilder diff --git a/src/client/Microsoft.Identity.Client/PublicApi/net8.0/PublicAPI.Unshipped.txt b/src/client/Microsoft.Identity.Client/PublicApi/net8.0/PublicAPI.Unshipped.txt index e69de29bb2..0e4610510d 100644 --- a/src/client/Microsoft.Identity.Client/PublicApi/net8.0/PublicAPI.Unshipped.txt +++ b/src/client/Microsoft.Identity.Client/PublicApi/net8.0/PublicAPI.Unshipped.txt @@ -0,0 +1,6 @@ +Microsoft.Identity.Client.AppConfig.PoPOptions +Microsoft.Identity.Client.AppConfig.PoPOptions.MinStrength.get -> Microsoft.Identity.Client.AppConfig.MtlsBindingStrength +Microsoft.Identity.Client.AppConfig.PoPOptions.MinStrength.set -> void +Microsoft.Identity.Client.AppConfig.PoPOptions.PoPOptions() -> void +const Microsoft.Identity.Client.MsalError.MinStrengthNotMet = "min_strength_not_met" -> string +static Microsoft.Identity.Client.ManagedIdentityPopExtensions.WithMtlsProofOfPossession(this Microsoft.Identity.Client.AcquireTokenForManagedIdentityParameterBuilder builder, Microsoft.Identity.Client.AppConfig.PoPOptions options) -> Microsoft.Identity.Client.AcquireTokenForManagedIdentityParameterBuilder diff --git a/src/client/Microsoft.Identity.Client/PublicApi/netstandard2.0/PublicAPI.Unshipped.txt b/src/client/Microsoft.Identity.Client/PublicApi/netstandard2.0/PublicAPI.Unshipped.txt index e69de29bb2..0e4610510d 100644 --- a/src/client/Microsoft.Identity.Client/PublicApi/netstandard2.0/PublicAPI.Unshipped.txt +++ b/src/client/Microsoft.Identity.Client/PublicApi/netstandard2.0/PublicAPI.Unshipped.txt @@ -0,0 +1,6 @@ +Microsoft.Identity.Client.AppConfig.PoPOptions +Microsoft.Identity.Client.AppConfig.PoPOptions.MinStrength.get -> Microsoft.Identity.Client.AppConfig.MtlsBindingStrength +Microsoft.Identity.Client.AppConfig.PoPOptions.MinStrength.set -> void +Microsoft.Identity.Client.AppConfig.PoPOptions.PoPOptions() -> void +const Microsoft.Identity.Client.MsalError.MinStrengthNotMet = "min_strength_not_met" -> string +static Microsoft.Identity.Client.ManagedIdentityPopExtensions.WithMtlsProofOfPossession(this Microsoft.Identity.Client.AcquireTokenForManagedIdentityParameterBuilder builder, Microsoft.Identity.Client.AppConfig.PoPOptions options) -> Microsoft.Identity.Client.AcquireTokenForManagedIdentityParameterBuilder diff --git a/tests/Microsoft.Identity.Test.Unit/ManagedIdentityTests/ImdsV2Tests.cs b/tests/Microsoft.Identity.Test.Unit/ManagedIdentityTests/ImdsV2Tests.cs index ea586bc078..c7cfb46271 100644 --- a/tests/Microsoft.Identity.Test.Unit/ManagedIdentityTests/ImdsV2Tests.cs +++ b/tests/Microsoft.Identity.Test.Unit/ManagedIdentityTests/ImdsV2Tests.cs @@ -354,6 +354,130 @@ public async Task mTLSPopTokenIsReAcquiredWhenCertificateIsExpired( */ } } + + [TestMethod] + public async Task MinStrength_KeyGuardFloorMetByKeyGuardHost_SucceedsAsync() + { + using (new EnvVariableContext()) + using (var httpManager = new MockHttpManager()) + { + // Arrange + SetEnvironmentVariables(ManagedIdentitySource.Imds, TestConstants.ImdsEndpoint); + + var managedIdentityApp = await CreateManagedIdentityAsync(httpManager, managedIdentityKeyType: ManagedIdentityKeyType.KeyGuard).ConfigureAwait(false); + + AddMocksToGetEntraToken(httpManager); + + // Act + var result = await managedIdentityApp.AcquireTokenForManagedIdentity(ManagedIdentityTests.Resource) + .WithMtlsProofOfPossession(new PoPOptions { MinStrength = MtlsBindingStrength.KeyGuard }) + .WithAttestationSupport() + .ExecuteAsync().ConfigureAwait(false); + + // Assert + Assert.IsNotNull(result.AccessToken); + Assert.AreEqual(MTLSPoP, result.TokenType); + Assert.IsNotNull(result.BindingCertificate); + } + } + + [TestMethod] + public async Task MinStrength_SoftwareFloorMetByKeyGuardHost_SucceedsAsync() + { + using (new EnvVariableContext()) + using (var httpManager = new MockHttpManager()) + { + // Arrange + SetEnvironmentVariables(ManagedIdentitySource.Imds, TestConstants.ImdsEndpoint); + + var managedIdentityApp = await CreateManagedIdentityAsync(httpManager, managedIdentityKeyType: ManagedIdentityKeyType.KeyGuard).ConfigureAwait(false); + + AddMocksToGetEntraToken(httpManager); + + // Act + var result = await managedIdentityApp.AcquireTokenForManagedIdentity(ManagedIdentityTests.Resource) + .WithMtlsProofOfPossession(new PoPOptions { MinStrength = MtlsBindingStrength.Software }) + .WithAttestationSupport() + .ExecuteAsync().ConfigureAwait(false); + + // Assert + Assert.IsNotNull(result.AccessToken); + Assert.AreEqual(MTLSPoP, result.TokenType); + Assert.IsNotNull(result.BindingCertificate); + } + } + + [TestMethod] + public async Task MinStrength_DefaultNoneFloor_BehavesLikeParameterlessAsync() + { + using (new EnvVariableContext()) + using (var httpManager = new MockHttpManager()) + { + // Arrange + SetEnvironmentVariables(ManagedIdentitySource.Imds, TestConstants.ImdsEndpoint); + + var managedIdentityApp = await CreateManagedIdentityAsync(httpManager, managedIdentityKeyType: ManagedIdentityKeyType.KeyGuard).ConfigureAwait(false); + + AddMocksToGetEntraToken(httpManager); + + // Act: default PoPOptions imposes no floor (MinStrength == None). + var result = await managedIdentityApp.AcquireTokenForManagedIdentity(ManagedIdentityTests.Resource) + .WithMtlsProofOfPossession(new PoPOptions()) + .WithAttestationSupport() + .ExecuteAsync().ConfigureAwait(false); + + // Assert + Assert.IsNotNull(result.AccessToken); + Assert.AreEqual(MTLSPoP, result.TokenType); + Assert.IsNotNull(result.BindingCertificate); + } + } + + [TestMethod] + public async Task MinStrength_KeyGuardFloorNotMetBySoftwareHost_ThrowsMinStrengthNotMetAsync() + { + using (new EnvVariableContext()) + using (var httpManager = new MockHttpManager()) + { + // Arrange: an in-memory key provider caps the host at the Software tier. + SetEnvironmentVariables(ManagedIdentitySource.Imds, TestConstants.ImdsEndpoint); + + var managedIdentityApp = await CreateManagedIdentityAsync(httpManager, managedIdentityKeyType: ManagedIdentityKeyType.InMemory).ConfigureAwait(false); + + // Act: requesting a KeyGuard floor on a Software host fails fast before any token request. + var ex = await Assert.ThrowsAsync(async () => + await managedIdentityApp.AcquireTokenForManagedIdentity(ManagedIdentityTests.Resource) + .WithMtlsProofOfPossession(new PoPOptions { MinStrength = MtlsBindingStrength.KeyGuard }) + .ExecuteAsync().ConfigureAwait(false) + ).ConfigureAwait(false); + + // Assert + Assert.AreEqual(MsalError.MinStrengthNotMet, ex.ErrorCode); + StringAssert.Contains(ex.Message, MtlsBindingStrength.KeyGuard.ToString()); + StringAssert.Contains(ex.Message, MtlsBindingStrength.Software.ToString()); + } + } + + [TestMethod] + public async Task MinStrength_NullPoPOptions_ThrowsArgumentNullExceptionAsync() + { + using (new EnvVariableContext()) + using (var httpManager = new MockHttpManager()) + { + // Arrange + SetEnvironmentVariables(ManagedIdentitySource.Imds, TestConstants.ImdsEndpoint); + + var managedIdentityApp = await CreateManagedIdentityAsync( + httpManager, + addProbeMock: false, + addSourceCheck: false).ConfigureAwait(false); + + // Act + Assert + Assert.Throws(() => + managedIdentityApp.AcquireTokenForManagedIdentity(ManagedIdentityTests.Resource) + .WithMtlsProofOfPossession(null)); + } + } #endregion Acceptance Tests #region Failure Tests From 89c4cc71f7efecc92e35b68c24e0de86a5eaf9f1 Mon Sep 17 00:00:00 2001 From: Robbie Ginsburg Date: Wed, 10 Jun 2026 16:48:14 -0400 Subject: [PATCH 2/2] Partition MI token cache by mTLS PoP MinStrength floor Include MtlsPopMinStrength in the managed identity token cache key so a higher-floor request cannot be satisfied from a cache entry created by a lower/no-floor request, closing a latent gap where the MinStrength floor (enforced only on the acquisition path) could be bypassed on a cache hit. Mirrors the existing attestation-mode cache partitioning. Adds a regression test proving a KeyGuard-floor request does not reuse a no-floor token entry. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...TokenForManagedIdentityParameterBuilder.cs | 6 +++ .../ManagedIdentityTests/ImdsV2Tests.cs | 43 +++++++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/src/client/Microsoft.Identity.Client/ApiConfig/AcquireTokenForManagedIdentityParameterBuilder.cs b/src/client/Microsoft.Identity.Client/ApiConfig/AcquireTokenForManagedIdentityParameterBuilder.cs index 99aa4a1aff..70a453d14e 100644 --- a/src/client/Microsoft.Identity.Client/ApiConfig/AcquireTokenForManagedIdentityParameterBuilder.cs +++ b/src/client/Microsoft.Identity.Client/ApiConfig/AcquireTokenForManagedIdentityParameterBuilder.cs @@ -25,6 +25,7 @@ public sealed class AcquireTokenForManagedIdentityParameterBuilder : AbstractManagedIdentityAcquireTokenParameterBuilder { private const string MiAttCacheKeyComponent = "mi_att"; + private const string MiMinStrengthCacheKeyComponent = "mi_minstrength"; private static readonly Task s_att0 = Task.FromResult("0"); private static readonly Task s_att1 = Task.FromResult("1"); @@ -141,6 +142,11 @@ private static void ApplyMtlsPopAndAttestation( acquireTokenCommonParameters.CacheKeyComponents[MiAttCacheKeyComponent] = _ => acquireTokenCommonParameters.AttestationTokenProvider != null ? s_att1 : s_att0; + + // Partition by the requested minimum binding strength so a higher-floor request + // cannot be satisfied from a cache entry created by a lower/no-floor request. + acquireTokenCommonParameters.CacheKeyComponents[MiMinStrengthCacheKeyComponent] = + _ => Task.FromResult(acquireTokenCommonParameters.MtlsPopMinStrength.ToString()); } } } diff --git a/tests/Microsoft.Identity.Test.Unit/ManagedIdentityTests/ImdsV2Tests.cs b/tests/Microsoft.Identity.Test.Unit/ManagedIdentityTests/ImdsV2Tests.cs index c7cfb46271..6e5b0c4cd3 100644 --- a/tests/Microsoft.Identity.Test.Unit/ManagedIdentityTests/ImdsV2Tests.cs +++ b/tests/Microsoft.Identity.Test.Unit/ManagedIdentityTests/ImdsV2Tests.cs @@ -458,6 +458,49 @@ await managedIdentityApp.AcquireTokenForManagedIdentity(ManagedIdentityTests.Res } } + [TestMethod] + public async Task MinStrength_FloorIsPartOfCacheKey_HigherFloorDoesNotReuseLowerFloorTokenAsync() + { + using (new EnvVariableContext()) + using (var httpManager = new MockHttpManager()) + { + // Arrange + SetEnvironmentVariables(ManagedIdentitySource.Imds, TestConstants.ImdsEndpoint); + + var managedIdentityApp = await CreateManagedIdentityAsync(httpManager, managedIdentityKeyType: ManagedIdentityKeyType.KeyGuard).ConfigureAwait(false); + + // Act 1: no floor → mints and caches a token. + AddMocksToGetEntraToken(httpManager); + var noFloor = await managedIdentityApp.AcquireTokenForManagedIdentity(ManagedIdentityTests.Resource) + .WithMtlsProofOfPossession(new PoPOptions()) + .WithAttestationSupport() + .ExecuteAsync().ConfigureAwait(false); + Assert.AreEqual(TokenSource.IdentityProvider, noFloor.AuthenticationResultMetadata.TokenSource); + + // Act 2: KeyGuard floor → distinct cache key → token cache miss → fresh token + // acquisition (the issued binding certificate is reused from cache, so only the + // CSR-metadata and token endpoints are hit), proving the higher-floor request does + // not silently reuse the no-floor token entry. + httpManager.AddMockHandler(MockHelpers.MockCsrResponse()); + httpManager.AddMockHandler(MockHelpers.MockImdsV2EntraTokenRequestResponse(_identityLoggerAdapter)); + var keyGuardFloor = await managedIdentityApp.AcquireTokenForManagedIdentity(ManagedIdentityTests.Resource) + .WithMtlsProofOfPossession(new PoPOptions { MinStrength = MtlsBindingStrength.KeyGuard }) + .WithAttestationSupport() + .ExecuteAsync().ConfigureAwait(false); + + // Assert + Assert.AreEqual(MTLSPoP, keyGuardFloor.TokenType); + Assert.AreEqual(TokenSource.IdentityProvider, keyGuardFloor.AuthenticationResultMetadata.TokenSource); + + // Act 3: repeat the KeyGuard-floor request → now served from cache under its own key. + var keyGuardCached = await managedIdentityApp.AcquireTokenForManagedIdentity(ManagedIdentityTests.Resource) + .WithMtlsProofOfPossession(new PoPOptions { MinStrength = MtlsBindingStrength.KeyGuard }) + .WithAttestationSupport() + .ExecuteAsync().ConfigureAwait(false); + Assert.AreEqual(TokenSource.Cache, keyGuardCached.AuthenticationResultMetadata.TokenSource); + } + } + [TestMethod] public async Task MinStrength_NullPoPOptions_ThrowsArgumentNullExceptionAsync() {