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 @@ -10,6 +10,7 @@
using System.Security.Cryptography.X509Certificates;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Identity.Client.AppConfig;
using Microsoft.Identity.Client.Core;
using Microsoft.Identity.Client.Http;
using Microsoft.Identity.Client.Http.Retry;
Expand Down Expand Up @@ -91,15 +92,31 @@ public static async Task<CsrMetadata> GetCsrMetadataAsync(
{
if (probeMode)
Copy link
Contributor

Choose a reason for hiding this comment

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

IMDS throttles very aggressively on 200s, but is much more forgiving of 400s.
For that reason we want the IMDSv2 probe to have a single responsibility:

“Does the endpoint exist and respond?”

and nothing else.


Right now GetPlatformMetadata ends up hitting IMDSv2 twice, and the probe path behaves too much like a “real” call:

  • It can return 200.
  • It runs identity-specific logic (UAMI / IdentityNotFound) that we only need on the second call.

We’d like to align the probe with the way Azure.Identity does it in ImdsManagedIdentityProbeSource:

  • The probe sends a GET to the IMDS endpoint without the Metadata: true header.
  • IMDS responds with 400 very quickly.
  • A 400 of that specific shape is treated as “probe succeeded, endpoint exists”
    (they throw an internal ProbeRequestResponseException and then do the real call through MsalManagedIdentityClient).
  • Only the second call is a full, valid IMDS request that can return 200 and surface identity errors.

Concretely, for MSAL

Probe call

  • Build the request so IMDS returns a predictable 400 (e.g., omit the Metadata header).
  • Treat that 400 as “IMDSv2 endpoint is available.”
  • Do not rely on 200 responses or run UAMI / IdentityNotFound logic here — the probe is not an identity call.

Second (real) call

  • Send the fully valid IMDSv2 request (with Metadata: true + identity hints).
  • This is the only call that should be able to return 200.
  • This is also the only place where we classify UAMI vs system-assigned and map
    IdentityNotFound vs ManagedIdentityRequestFailed.

That keeps the probe cheap and stable (one responsibility: endpoint exists)
and ensures we’re not burning extra 200s against IMDS just to detect IMDSv2.

IMDS V2 probe spec - https://github.com/AzureAD/microsoft-authentication-library-for-dotnet/blob/main/docs/msi_v2/vm_vmss_credential_probe.md

Copy link
Contributor Author

@Robbie-Microsoft Robbie-Microsoft Nov 17, 2025

Choose a reason for hiding this comment

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

I think this should be tracked on a separate GitHub issue. This PR fixes the linked bug, that you were pushing me to fix, so that people using public preview are no longer blocked.

Do you remember our discussions where we decided to combine the probe and the metadata endpoint? We had that discussion very early on.

Copy link
Member

Choose a reason for hiding this comment

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

If there are partners blocked by this, agreed to take this fix, but I am not sure there are?

Otherwise, it does seem like the 400 error is the correct fix and this can be done alongisde MSIv1 probing

Copy link
Contributor

Choose a reason for hiding this comment

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

While onboarding the partner we found they are using UAMI, and UAMI itself is not working in the environment. So no new partner onboarding till end of month.

{
if (IsUamiConfigurationError(response, requestContext.ServiceBundle.Config.ManagedIdentityId))
{
requestContext.Logger.Info("[Managed Identity] IMDSv2 endpoint is available but there is a UAMI configuration error. Returning empty CsrMetadata to indicate endpoint availability.");
return new CsrMetadata();
}

requestContext.Logger.Info(() => $"[Managed Identity] IMDSv2 managed identity is not available. Status code: {response.StatusCode}, Body: {response.Body}");
return null;
}
else
{
string errorCode = MsalError.ManagedIdentityRequestFailed;
string errorMessage = "ImdsV2ManagedIdentitySource.GetCsrMetadataAsync failed due to HTTP error.";

if (IsUamiConfigurationError(response, requestContext.ServiceBundle.Config.ManagedIdentityId))
{
errorCode = MsalError.IdentityNotFound;
errorMessage = MsalErrorMessage.IdentityNotFound;
}

ThrowProbeFailedException(
$"ImdsV2ManagedIdentitySource.GetCsrMetadataAsync failed due to HTTP error. Status code: {response.StatusCode} Body: {response.Body}",
$"{errorMessage} Status code: {response.StatusCode} Body: {response.Body}",
null,
(int)response.StatusCode);
(int)response.StatusCode,
errorCode);
}
}

Expand All @@ -112,13 +129,31 @@ public static async Task<CsrMetadata> GetCsrMetadataAsync(
#endif
}

/// <summary>
/// Determines if the HTTP response indicates a User Assigned Managed Identity (UAMI) configuration error rather than an endpoint availability issue.
/// </summary>
/// <param name="response">The HTTP response from IMDS</param>
/// <param name="managedIdentityId">The managed identity configuration</param>
/// <returns>True if this is a UAMI configuration error, false otherwise</returns>
private static bool IsUamiConfigurationError(HttpResponse response, ManagedIdentityId managedIdentityId)
{
if (managedIdentityId.IdType == ManagedIdentityIdType.SystemAssigned)
{
return false;
}

return (response.StatusCode == HttpStatusCode.BadRequest) &&
(response.Body?.Contains(MsalError.IdentityNotFound) == true);
}

private static void ThrowProbeFailedException(
String errorMessage,
string errorMessage,
Exception ex = null,
int? statusCode = null)
int? statusCode = null,
string errorCode = MsalError.ManagedIdentityRequestFailed)
{
throw MsalServiceExceptionFactory.CreateManagedIdentityException(
MsalError.ManagedIdentityRequestFailed,
errorCode,
$"[ImdsV2] {errorMessage}",
ex,
ManagedIdentitySource.ImdsV2,
Expand Down
5 changes: 5 additions & 0 deletions src/client/Microsoft.Identity.Client/MsalError.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1227,5 +1227,10 @@ public static class MsalError
/// mTLS PoP tokens are not supported in IMDS V1.
/// </summary>
public const string MtlsPopTokenNotSupportedinImdsV1 = "mtls_pop_token_not_supported_in_imds_v1";

/// <summary>
/// The provided user-assigned managed identity was not found on the VM.
/// </summary>
public const string IdentityNotFound = "identity_not_found";
}
}
2 changes: 2 additions & 0 deletions src/client/Microsoft.Identity.Client/MsalErrorMessage.cs
Original file line number Diff line number Diff line change
Expand Up @@ -450,5 +450,7 @@ 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 = "A mTLS PoP token cannot be requested because the application\'s source was determined to be ImdsV1.";
public const string IdentityNotFound = "Managed Identity not found. To request credentials for this identity, please assign it first. Please see aka.ms/ManagedIdentityNotFound for more details";
Copy link
Contributor

Choose a reason for hiding this comment

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

IMDS already returns the IdentityNotFound error message, and MSAL simply bubbles up the IMDS payload without rewriting or overriding it. Adding our own string introduces duplication and may cause divergence from IMDS' authoritative error.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This is the exact error message from Imds - It's used multiple times in this PR, including test mocks, so I made it a constant.

I could return the error message from Imds, but I would still keep this constant for the test mocks.

Copy link
Member

Choose a reason for hiding this comment

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

Agreed with @gladjohn - we can depend on the error code, but the error messages should from the server.


}
}
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
const Microsoft.Identity.Client.MsalError.IdentityNotFound = "identity_not_found" -> string
Microsoft.Identity.Client.AbstractApplicationBuilder<T>.WithExtraQueryParameters(System.Collections.Generic.IDictionary<string, (string Value, bool IncludeInCacheKey)> extraQueryParameters) -> T
Microsoft.Identity.Client.BaseAbstractAcquireTokenParameterBuilder<T>.WithExtraQueryParameters(System.Collections.Generic.IDictionary<string, (string Value, bool IncludeInCacheKey)> extraQueryParameters) -> T
const Microsoft.Identity.Client.MsalError.CannotSwitchBetweenImdsVersionsForPreview = "cannot_switch_between_imds_versions_for_preview" -> string
Expand All @@ -9,4 +10,4 @@ Microsoft.Identity.Client.IMsalMtlsHttpClientFactory.GetHttpClient(System.Securi
Microsoft.Identity.Client.ManagedIdentityApplication.GetManagedIdentitySourceAsync() -> System.Threading.Tasks.Task<Microsoft.Identity.Client.ManagedIdentity.ManagedIdentitySource>
Microsoft.Identity.Client.ManagedIdentity.ManagedIdentitySource.ImdsV2 = 8 -> Microsoft.Identity.Client.ManagedIdentity.ManagedIdentitySource
Microsoft.Identity.Client.ManagedIdentityApplicationBuilder.WithExtraQueryParameters(System.Collections.Generic.IDictionary<string, string> extraQueryParameters) -> Microsoft.Identity.Client.ManagedIdentityApplicationBuilder
static Microsoft.Identity.Client.ApplicationBase.ResetStateForTest() -> void
static Microsoft.Identity.Client.ApplicationBase.ResetStateForTest() -> void
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
const Microsoft.Identity.Client.MsalError.IdentityNotFound = "identity_not_found" -> string
Microsoft.Identity.Client.AbstractApplicationBuilder<T>.WithExtraQueryParameters(System.Collections.Generic.IDictionary<string, (string Value, bool IncludeInCacheKey)> extraQueryParameters) -> T
Microsoft.Identity.Client.BaseAbstractAcquireTokenParameterBuilder<T>.WithExtraQueryParameters(System.Collections.Generic.IDictionary<string, (string Value, bool IncludeInCacheKey)> extraQueryParameters) -> T
const Microsoft.Identity.Client.MsalError.CannotSwitchBetweenImdsVersionsForPreview = "cannot_switch_between_imds_versions_for_preview" -> string
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
const Microsoft.Identity.Client.MsalError.IdentityNotFound = "identity_not_found" -> string
Microsoft.Identity.Client.AbstractApplicationBuilder<T>.WithExtraQueryParameters(System.Collections.Generic.IDictionary<string, (string Value, bool IncludeInCacheKey)> extraQueryParameters) -> T
Microsoft.Identity.Client.BaseAbstractAcquireTokenParameterBuilder<T>.WithExtraQueryParameters(System.Collections.Generic.IDictionary<string, (string Value, bool IncludeInCacheKey)> extraQueryParameters) -> T
const Microsoft.Identity.Client.MsalError.CannotSwitchBetweenImdsVersionsForPreview = "cannot_switch_between_imds_versions_for_preview" -> string
Expand All @@ -9,4 +10,4 @@ Microsoft.Identity.Client.IMsalMtlsHttpClientFactory.GetHttpClient(System.Securi
Microsoft.Identity.Client.ManagedIdentityApplication.GetManagedIdentitySourceAsync() -> System.Threading.Tasks.Task<Microsoft.Identity.Client.ManagedIdentity.ManagedIdentitySource>
Microsoft.Identity.Client.ManagedIdentity.ManagedIdentitySource.ImdsV2 = 8 -> Microsoft.Identity.Client.ManagedIdentity.ManagedIdentitySource
Microsoft.Identity.Client.ManagedIdentityApplicationBuilder.WithExtraQueryParameters(System.Collections.Generic.IDictionary<string, string> extraQueryParameters) -> Microsoft.Identity.Client.ManagedIdentityApplicationBuilder
static Microsoft.Identity.Client.ApplicationBase.ResetStateForTest() -> void
static Microsoft.Identity.Client.ApplicationBase.ResetStateForTest() -> void
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
const Microsoft.Identity.Client.MsalError.IdentityNotFound = "identity_not_found" -> string
Microsoft.Identity.Client.AbstractApplicationBuilder<T>.WithExtraQueryParameters(System.Collections.Generic.IDictionary<string, (string Value, bool IncludeInCacheKey)> extraQueryParameters) -> T
Microsoft.Identity.Client.BaseAbstractAcquireTokenParameterBuilder<T>.WithExtraQueryParameters(System.Collections.Generic.IDictionary<string, (string Value, bool IncludeInCacheKey)> extraQueryParameters) -> T
const Microsoft.Identity.Client.MsalError.CannotSwitchBetweenImdsVersionsForPreview = "cannot_switch_between_imds_versions_for_preview" -> string
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
const Microsoft.Identity.Client.MsalError.IdentityNotFound = "identity_not_found" -> string
Microsoft.Identity.Client.AbstractApplicationBuilder<T>.WithExtraQueryParameters(System.Collections.Generic.IDictionary<string, (string Value, bool IncludeInCacheKey)> extraQueryParameters) -> T
Microsoft.Identity.Client.BaseAbstractAcquireTokenParameterBuilder<T>.WithExtraQueryParameters(System.Collections.Generic.IDictionary<string, (string Value, bool IncludeInCacheKey)> extraQueryParameters) -> T
const Microsoft.Identity.Client.MsalError.CannotSwitchBetweenImdsVersionsForPreview = "cannot_switch_between_imds_versions_for_preview" -> string
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
const Microsoft.Identity.Client.MsalError.IdentityNotFound = "identity_not_found" -> string
Microsoft.Identity.Client.AbstractApplicationBuilder<T>.WithExtraQueryParameters(System.Collections.Generic.IDictionary<string, (string Value, bool IncludeInCacheKey)> extraQueryParameters) -> T
Microsoft.Identity.Client.BaseAbstractAcquireTokenParameterBuilder<T>.WithExtraQueryParameters(System.Collections.Generic.IDictionary<string, (string Value, bool IncludeInCacheKey)> extraQueryParameters) -> T
const Microsoft.Identity.Client.MsalError.CannotSwitchBetweenImdsVersionsForPreview = "cannot_switch_between_imds_versions_for_preview" -> string
Expand Down
36 changes: 22 additions & 14 deletions tests/Microsoft.Identity.Test.Common/Core/Mocks/MockHelpers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -621,7 +621,8 @@ public static MockHttpMessageHandler MockCsrResponse(
string userAssignedId = null,
string clientIdOverride = null,
string tenantIdOverride = null,
string attestationEndpointOverride = null)
string attestationEndpointOverride = null,
string contentOverride = null)
{
IDictionary<string, string> expectedQueryParams = new Dictionary<string, string>();
IDictionary<string, string> expectedRequestHeaders = new Dictionary<string, string>();
Expand All @@ -639,7 +640,7 @@ public static MockHttpMessageHandler MockCsrResponse(
expectedQueryParams.Add("cred-api-version", "2.0");
expectedRequestHeaders.Add("Metadata", "true");

string content =
string content = contentOverride ??
"{" +
"\"cuId\": { \"vmId\": \"fake_vmId\" }," +
"\"clientId\": \"" + (clientIdOverride ?? TestConstants.ClientId) + "\"," +
Expand All @@ -666,27 +667,34 @@ public static MockHttpMessageHandler MockCsrResponse(
return handler;
}

// used for unit tests in ManagedIdentityTests.cs
public static MockHttpMessageHandler MockCsrResponseFailure()
public static MockHttpMessageHandler MockCsrResponseFailure(
HttpStatusCode statusCode = HttpStatusCode.BadRequest,
string content = null,
UserAssignedIdentityId userAssignedIdentityId = UserAssignedIdentityId.None,
string userAssignedId = null)
{
// 400 doesn't trigger the retry policy
return MockCsrResponse(HttpStatusCode.BadRequest);
return MockCsrResponse(
statusCode,
userAssignedIdentityId: userAssignedIdentityId,
userAssignedId: userAssignedId,
contentOverride: content);
}

public static MockHttpMessageHandler MockCertificateRequestResponse(
UserAssignedIdentityId userAssignedIdentityId = UserAssignedIdentityId.None,
string userAssignedId = null,
string certificate = TestConstants.ValidRawCertificate,
string clientIdOverride = null,
string tenantIdOverride = null,
string mtlsEndpointOverride = null)
UserAssignedIdentityId userAssignedIdentityId = UserAssignedIdentityId.None,
string userAssignedId = null,
string certificate = TestConstants.ValidRawCertificate,
string clientIdOverride = null,
string tenantIdOverride = null,
string mtlsEndpointOverride = null)
{
IDictionary<string, string> expectedQueryParams = new Dictionary<string, string>();
IDictionary<string, string> expectedRequestHeaders = new Dictionary<string, string>();
IList<string> presentRequestHeaders = new List<string>
{
OAuth2Header.XMsCorrelationId
};
{
OAuth2Header.XMsCorrelationId
};

if (userAssignedIdentityId != UserAssignedIdentityId.None && userAssignedId != null)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -562,6 +562,36 @@ public async Task ProbeDoesNotFireWhenMtlsPopNotRequested(
}
}

[DataTestMethod]
[DataRow(UserAssignedIdentityId.ClientId, TestConstants.ClientId)]
[DataRow(UserAssignedIdentityId.ResourceId, TestConstants.MiResourceId)]
[DataRow(UserAssignedIdentityId.ObjectId, TestConstants.ObjectId)]
public async Task ImdsV2IsDetectedEvenWhenUamiNotFound(
UserAssignedIdentityId userAssignedIdentityId,
string userAssignedId)
{
using (new EnvVariableContext())
using (var httpManager = new MockHttpManager())
{
SetEnvironmentVariables(ManagedIdentitySource.ImdsV2, TestConstants.ImdsEndpoint);

string errorContent = $"{{\"error\":\"{MsalError.IdentityNotFound}\",\"error_description\":\"{MsalErrorMessage.IdentityNotFound}\"}}";
httpManager.AddMockHandler(MockHelpers.MockCsrResponseFailure(HttpStatusCode.BadRequest, errorContent, userAssignedIdentityId, userAssignedId));

var managedIdentityApp = await CreateManagedIdentityAsync(httpManager, userAssignedIdentityId, userAssignedId, addProbeMock: false).ConfigureAwait(false);

httpManager.AddMockHandler(MockHelpers.MockCsrResponseFailure(HttpStatusCode.BadRequest, errorContent, userAssignedIdentityId, userAssignedId));

var ex = await Assert.ThrowsExceptionAsync<MsalServiceException>(async () =>
await managedIdentityApp.AcquireTokenForManagedIdentity(ManagedIdentityTests.Resource)
.WithMtlsProofOfPossession()
.ExecuteAsync().ConfigureAwait(false)
).ConfigureAwait(false);

Assert.AreEqual(MsalError.IdentityNotFound, ex.ErrorCode);
}
}

#region Cuid Tests
[TestMethod]
public void TestCsrGeneration_OnlyVmId()
Expand Down