diff --git a/src/client/Microsoft.Identity.Client/ApiConfig/AcquireTokenForManagedIdentityParameterBuilder.cs b/src/client/Microsoft.Identity.Client/ApiConfig/AcquireTokenForManagedIdentityParameterBuilder.cs
index 8b8fad6e49..36211981af 100644
--- a/src/client/Microsoft.Identity.Client/ApiConfig/AcquireTokenForManagedIdentityParameterBuilder.cs
+++ b/src/client/Microsoft.Identity.Client/ApiConfig/AcquireTokenForManagedIdentityParameterBuilder.cs
@@ -82,6 +82,31 @@ public AcquireTokenForManagedIdentityParameterBuilder WithClaims(string claims)
return this;
}
+ ///
+ /// Specifies client-originated claims to include in the token request.
+ /// Unlike (for server-issued claims challenges), tokens acquired
+ /// with client claims are cached and keyed on the claims value. Different claim values produce
+ /// separate cache entries. Use stable, non-dynamic claim values to avoid cache fragmentation.
+ ///
+ /// A JSON string containing the client claims. Must be valid JSON.
+ /// The builder to chain .With methods.
+ public AcquireTokenForManagedIdentityParameterBuilder WithClaimsFromClient(string claimsJson)
+ {
+ if (string.IsNullOrWhiteSpace(claimsJson))
+ {
+ return this;
+ }
+
+ ValidateUseOfExperimentalFeature();
+
+ CommonParameters.ClientClaims = claimsJson;
+
+ CommonParameters.CacheKeyComponents ??= new SortedList>>();
+ CommonParameters.CacheKeyComponents["client_claims"] = _ => Task.FromResult(claimsJson);
+
+ return this;
+ }
+
///
internal override Task ExecuteInternalAsync(CancellationToken cancellationToken)
{
diff --git a/src/client/Microsoft.Identity.Client/ApiConfig/Parameters/AcquireTokenCommonParameters.cs b/src/client/Microsoft.Identity.Client/ApiConfig/Parameters/AcquireTokenCommonParameters.cs
index 13d7137b47..92d23211fe 100644
--- a/src/client/Microsoft.Identity.Client/ApiConfig/Parameters/AcquireTokenCommonParameters.cs
+++ b/src/client/Microsoft.Identity.Client/ApiConfig/Parameters/AcquireTokenCommonParameters.cs
@@ -31,6 +31,7 @@ internal class AcquireTokenCommonParameters
public IEnumerable Scopes { get; set; }
public IDictionary ExtraQueryParameters { get; set; }
public string Claims { get; set; }
+ public string ClientClaims { get; internal set; }
public AuthorityInfo AuthorityOverride { get; set; }
public IAuthenticationOperation AuthenticationOperation { get; set; } = new BearerAuthenticationOperation();
public IDictionary ExtraHttpHeaders { get; set; }
diff --git a/src/client/Microsoft.Identity.Client/ApiConfig/Parameters/AcquireTokenForManagedIdentityParameters.cs b/src/client/Microsoft.Identity.Client/ApiConfig/Parameters/AcquireTokenForManagedIdentityParameters.cs
index 57c48e9599..e00d53797c 100644
--- a/src/client/Microsoft.Identity.Client/ApiConfig/Parameters/AcquireTokenForManagedIdentityParameters.cs
+++ b/src/client/Microsoft.Identity.Client/ApiConfig/Parameters/AcquireTokenForManagedIdentityParameters.cs
@@ -22,6 +22,12 @@ internal class AcquireTokenForManagedIdentityParameters : IAcquireTokenParameter
public string Claims { get; set; }
+ ///
+ /// Client-originated claims to be sent to the identity endpoint.
+ /// Unlike (server-issued), these are cached and keyed on the claims value.
+ ///
+ public string ClientClaims { get; set; }
+
public string RevokedTokenHash { get; set; }
public bool IsMtlsPopRequested { get; set; }
@@ -45,6 +51,7 @@ public void LogParameters(ILoggerAdapter logger)
ForceRefresh: {ForceRefresh}
Resource: {Resource}
Claims: {!string.IsNullOrEmpty(Claims)}
+ ClientClaims: {!string.IsNullOrEmpty(ClientClaims)}
RevokedTokenHash: {!string.IsNullOrEmpty(RevokedTokenHash)}
IsMtlsPopRequested: {IsMtlsPopRequested}
""");
diff --git a/src/client/Microsoft.Identity.Client/Extensibility/AbstractConfidentialClientAcquireTokenParameterBuilderExtension.cs b/src/client/Microsoft.Identity.Client/Extensibility/AbstractConfidentialClientAcquireTokenParameterBuilderExtension.cs
index 425c38e1d1..3dac5d387d 100644
--- a/src/client/Microsoft.Identity.Client/Extensibility/AbstractConfidentialClientAcquireTokenParameterBuilderExtension.cs
+++ b/src/client/Microsoft.Identity.Client/Extensibility/AbstractConfidentialClientAcquireTokenParameterBuilderExtension.cs
@@ -19,7 +19,39 @@ namespace Microsoft.Identity.Client.Extensibility
public static class AbstractConfidentialClientAcquireTokenParameterBuilderExtension
{
///
- /// Intervenes in the request pipeline, by executing a user provided delegate before MSAL makes the token request.
+ /// Specifies client-originated claims to include in the token request.
+ /// Unlike (for server-issued
+ /// claims challenges), tokens acquired with client claims are cached and the cache entry
+ /// is keyed on the claims value. Different claims values produce separate cache entries.
+ /// Use stable, non-dynamic values to avoid unbounded cache growth.
+ ///
+ /// The concrete confidential client builder type.
+ /// The builder to chain options to.
+ /// A JSON string containing the client-originated claims. Must be valid JSON.
+ /// The builder to chain the .With methods.
+ public static T WithClaimsFromClient(
+ this AbstractConfidentialClientAcquireTokenParameterBuilder builder,
+ string claimsJson)
+ where T : AbstractConfidentialClientAcquireTokenParameterBuilder
+ {
+ if (string.IsNullOrWhiteSpace(claimsJson))
+ {
+ return (T)builder;
+ }
+
+ builder.ValidateUseOfExperimentalFeature();
+
+ builder.CommonParameters.ClientClaims = claimsJson;
+
+ // Use indexer (not SortedList.Add) so repeated calls are last-write-wins rather than throwing.
+ builder.CommonParameters.CacheKeyComponents ??= new SortedList>>();
+ builder.CommonParameters.CacheKeyComponents["client_claims"] = _ => Task.FromResult(claimsJson);
+
+ return (T)builder;
+ }
+
+ ///
+ /// Intervenes in the request pipeline, by executing a user provided delegate before MSAL makes the token request.
/// The delegate can modify the request payload by adding or removing body parameters and headers.
///
///
diff --git a/src/client/Microsoft.Identity.Client/Internal/ClaimsHelper.cs b/src/client/Microsoft.Identity.Client/Internal/ClaimsHelper.cs
index 35e27c427a..d7a8ca9f7f 100644
--- a/src/client/Microsoft.Identity.Client/Internal/ClaimsHelper.cs
+++ b/src/client/Microsoft.Identity.Client/Internal/ClaimsHelper.cs
@@ -1,11 +1,9 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.
-using System.Buffers;
+using System;
using System.Collections.Generic;
-using System.Diagnostics;
using System.Linq;
-using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using Microsoft.Identity.Client.Utils;
@@ -18,6 +16,34 @@ internal static class ClaimsHelper
private const string AccessTokenClaim = "access_token";
private const string XmsClientCapability = "xms_cc";
+ ///
+ /// Merges two JSON claims objects. If either is null/empty the other is returned as-is.
+ ///
+ internal static string MergeClaimsObjects(string claims1, string claims2)
+ {
+ if (string.IsNullOrEmpty(claims1)) return claims2;
+ if (string.IsNullOrEmpty(claims2)) return claims1;
+
+ try
+ {
+ JObject obj1 = JsonHelper.ParseIntoJsonObject(claims1);
+ JObject obj2 = JsonHelper.ParseIntoJsonObject(claims2);
+ JObject merged = JsonHelper.Merge(obj1, obj2);
+ return JsonHelper.JsonObjectToString(merged);
+ }
+ catch (Exception ex) when (ex is JsonException || ex is InvalidOperationException)
+ {
+ // InvalidOperationException is thrown by JsonNode.AsObject() when the root token is
+ // valid JSON but not an object (e.g. an array, a scalar, or the literal 'null').
+ // Do not include the raw claimsJson in the message — it may contain sensitive data.
+ throw new MsalClientException(
+ MsalError.InvalidJsonClaimsFormat,
+ "The claims value is not a valid JSON object. Inspect the inner exception for parsing details. " +
+ "See https://openid.net/specs/openid-connect-core-1_0.html#ClaimsParameter.",
+ ex);
+ }
+ }
+
internal static string GetMergedClaimsAndClientCapabilities(
string claims,
IEnumerable clientCapabilities)
@@ -42,11 +68,16 @@ internal static JObject MergeClaimsIntoCapabilityJson(string claims, JObject cap
{
claimsJson = JsonHelper.ParseIntoJsonObject(claims);
}
- catch (JsonException ex)
+ catch (Exception ex) when (ex is JsonException || ex is InvalidOperationException)
{
+ // InvalidOperationException is thrown by JsonNode.AsObject() when the root token is
+ // valid JSON but not an object (e.g. an array, a scalar, or the literal 'null').
+ // This method also handles server-issued claims from .WithClaims(), so use a neutral
+ // message rather than naming client_claims specifically.
throw new MsalClientException(
MsalError.InvalidJsonClaimsFormat,
- MsalErrorMessage.InvalidJsonClaimsFormat(claims),
+ "The claims value is not a valid JSON object. Inspect the inner exception for parsing details. " +
+ "See https://openid.net/specs/openid-connect-core-1_0.html#ClaimsParameter.",
ex);
}
capabilitiesJson = JsonHelper.Merge(capabilitiesJson, claimsJson);
diff --git a/src/client/Microsoft.Identity.Client/Internal/Requests/AuthenticationRequestParameters.cs b/src/client/Microsoft.Identity.Client/Internal/Requests/AuthenticationRequestParameters.cs
index 24a5d5c9bf..b00bbdf061 100644
--- a/src/client/Microsoft.Identity.Client/Internal/Requests/AuthenticationRequestParameters.cs
+++ b/src/client/Microsoft.Identity.Client/Internal/Requests/AuthenticationRequestParameters.cs
@@ -28,6 +28,7 @@ internal class AuthenticationRequestParameters
private readonly IServiceBundle _serviceBundle;
private readonly AcquireTokenCommonParameters _commonParameters;
private string _loginHint;
+ private Lazy _claimsAndClientCapabilities;
public AuthenticationRequestParameters(
IServiceBundle serviceBundle,
@@ -69,13 +70,16 @@ public AuthenticationRequestParameters(
}
}
- ClaimsAndClientCapabilities = ClaimsHelper.GetMergedClaimsAndClientCapabilities(
- _commonParameters.Claims,
- _serviceBundle.Config.ClientCapabilities);
-
HomeAccountId = homeAccountId;
CacheKeyComponents = cacheKeyComponents;
SendOfflineAccessScope = commonParameters.SendOfflineAccessScope;
+
+ // Defer JSON merge to first access — cache hits never read ClaimsAndClientCapabilities,
+ // so we avoid parsing on the hot path.
+ _claimsAndClientCapabilities = new Lazy(() =>
+ ClaimsHelper.GetMergedClaimsAndClientCapabilities(
+ ClaimsHelper.MergeClaimsObjects(_commonParameters.Claims, _commonParameters.ClientClaims),
+ _serviceBundle.Config.ClientCapabilities));
}
public ApplicationConfiguration AppConfig => _serviceBundle.Config;
@@ -108,7 +112,7 @@ public AuthenticationRequestParameters(
public IDictionary ExtraQueryParameters { get; }
- public string ClaimsAndClientCapabilities { get; private set; }
+ public string ClaimsAndClientCapabilities => _claimsAndClientCapabilities.Value;
public Guid CorrelationId => _commonParameters.CorrelationId;
@@ -138,6 +142,12 @@ public string Claims
}
}
+ ///
+ /// Client-originated claims set via .WithClaimsFromClient(). These are cached (no bypass) and
+ /// keyed on the raw claims string as passed by the caller.
+ ///
+ public string ClientClaims => _commonParameters.ClientClaims;
+
private IAuthenticationOperation _requestOverrideScheme;
///
diff --git a/src/client/Microsoft.Identity.Client/Internal/Requests/ManagedIdentityAuthRequest.cs b/src/client/Microsoft.Identity.Client/Internal/Requests/ManagedIdentityAuthRequest.cs
index 531c5ec203..eec0728a53 100644
--- a/src/client/Microsoft.Identity.Client/Internal/Requests/ManagedIdentityAuthRequest.cs
+++ b/src/client/Microsoft.Identity.Client/Internal/Requests/ManagedIdentityAuthRequest.cs
@@ -216,6 +216,14 @@ private async Task SendTokenRequestForManagedIdentityAsync
_managedIdentityParameters.IsMtlsPopRequested = AuthenticationRequestParameters.IsMtlsPopRequested;
+ // Propagate client-originated claims to the MI parameters for transport.
+ // Unlike server-issued Claims (which bypass the cache), ClientClaims participate in caching
+ // via CacheKeyComponents set on the builder — tokens are keyed per distinct claims value.
+ if (!string.IsNullOrEmpty(AuthenticationRequestParameters.ClientClaims))
+ {
+ _managedIdentityParameters.ClientClaims = AuthenticationRequestParameters.ClientClaims;
+ }
+
ManagedIdentityResponse managedIdentityResponse =
await _managedIdentityClient
.SendTokenRequestForManagedIdentityAsync(AuthenticationRequestParameters.RequestContext, _managedIdentityParameters, cancellationToken)
diff --git a/src/client/Microsoft.Identity.Client/ManagedIdentity/AbstractManagedIdentity.cs b/src/client/Microsoft.Identity.Client/ManagedIdentity/AbstractManagedIdentity.cs
index 8b937aaefd..1bdea5cd9b 100644
--- a/src/client/Microsoft.Identity.Client/ManagedIdentity/AbstractManagedIdentity.cs
+++ b/src/client/Microsoft.Identity.Client/ManagedIdentity/AbstractManagedIdentity.cs
@@ -38,6 +38,8 @@ protected AbstractManagedIdentity(RequestContext requestContext, ManagedIdentity
_sourceType = sourceType;
}
+ private const string XmsAzNwperimid = "xms_az_nwperimid";
+
public virtual async Task AuthenticateAsync(
AcquireTokenForManagedIdentityParameters parameters,
CancellationToken cancellationToken)
@@ -57,6 +59,38 @@ public virtual async Task AuthenticateAsync(
ManagedIdentityRequest request = await CreateRequestAsync(resource).ConfigureAwait(false);
+ // Forward client-originated claims to the correct location for IMDS/MSIv2 only.
+ // Other MI sources (App Service, Azure Arc, Service Fabric, etc.) do not have a
+ // confirmed contract for the "claims" parameter; fail fast rather than silently
+ // ignoring the value and polluting the cache with keys the endpoint never saw.
+ if (!string.IsNullOrEmpty(parameters.ClientClaims))
+ {
+ if (_sourceType != ManagedIdentitySource.Imds && _sourceType != ManagedIdentitySource.ImdsV2)
+ {
+ throw new MsalClientException(
+ MsalError.InvalidRequest,
+ $"WithClaimsFromClient is only supported for IMDS-based managed identity sources. " +
+ $"The detected source is {_sourceType}. " +
+ "Only ManagedIdentitySource.Imds and ManagedIdentitySource.ImdsV2 support the 'claims' parameter.");
+ }
+
+ if (_sourceType == ManagedIdentitySource.Imds)
+ {
+ ValidateMsiv1Claims(parameters.ClientClaims);
+ }
+
+ if (request.Method == System.Net.Http.HttpMethod.Get)
+ {
+ request.QueryParameters["claims"] = Uri.EscapeDataString(parameters.ClientClaims);
+ _requestContext.Logger.Info("[Managed Identity] Adding client claims to IMDS request as query parameter.");
+ }
+ else
+ {
+ request.BodyParameters["claims"] = parameters.ClientClaims;
+ _requestContext.Logger.Info("[Managed Identity] Adding client claims to ESTS POST body.");
+ }
+ }
+
// When IMDSv2 mints a binding certificate during this request (via CSR),
// it's exposed via request.MtlsCertificate. Bubble it up so the request
// layer can set the mtls_pop scheme
@@ -329,5 +363,26 @@ private static void CreateAndThrowException(string errorCode,
throw exception;
}
+
+ ///
+ /// MSIv1 (IMDS v1) only supports a single custom claim: xms_az_nwperimid.
+ /// Any other top-level key in the claims JSON will cause IMDS to return HTTP 400 Bad Request
+ /// with no useful diagnostic. Validate early so the caller gets a clear MSAL error.
+ ///
+ private static void ValidateMsiv1Claims(string claimsJson)
+ {
+ var parsed = JsonHelper.ParseIntoJsonObject(claimsJson);
+ foreach (var kvp in parsed)
+ {
+ if (!string.Equals(kvp.Key, XmsAzNwperimid, StringComparison.Ordinal))
+ {
+ throw new MsalClientException(
+ MsalError.InvalidRequest,
+ $"MSIv1 (IMDS v1) only supports the `{XmsAzNwperimid}` custom claim. " +
+ $"The claims JSON contained the unsupported key `{kvp.Key}`. " +
+ $"Remove all keys other than `{XmsAzNwperimid}` when using WithClaimsFromClient with MSIv1.");
+ }
+ }
+ }
}
}
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 99e725f55b..c08c06c96f 100644
--- a/src/client/Microsoft.Identity.Client/PublicApi/net462/PublicAPI.Unshipped.txt
+++ b/src/client/Microsoft.Identity.Client/PublicApi/net462/PublicAPI.Unshipped.txt
@@ -1,7 +1,9 @@
+Microsoft.Identity.Client.AcquireTokenForManagedIdentityParameterBuilder.WithClaimsFromClient(string claimsJson) -> Microsoft.Identity.Client.AcquireTokenForManagedIdentityParameterBuilder
+static Microsoft.Identity.Client.Extensibility.AbstractConfidentialClientAcquireTokenParameterBuilderExtension.WithClaimsFromClient(this Microsoft.Identity.Client.AbstractConfidentialClientAcquireTokenParameterBuilder builder, string claimsJson) -> T
Microsoft.Identity.Client.AuthScheme.IAuthenticationOperation3
Microsoft.Identity.Client.AuthScheme.IAuthenticationOperation3.AfterCredentialEvaluationAsync(Microsoft.Identity.Client.AuthScheme.CredentialEvaluationContext context, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task
Microsoft.Identity.Client.AuthScheme.CredentialEvaluationContext
Microsoft.Identity.Client.AuthScheme.CredentialEvaluationContext.CredentialEvaluationContext(System.Security.Cryptography.X509Certificates.X509Certificate2 mtlsCertificate) -> void
Microsoft.Identity.Client.AuthScheme.CredentialEvaluationContext.MtlsCertificate.get -> System.Security.Cryptography.X509Certificates.X509Certificate2
static Microsoft.Identity.Client.Extensibility.AcquireTokenParameterBuilderExtensions.WithCachePartitionKey(this Microsoft.Identity.Client.BaseAbstractAcquireTokenParameterBuilder builder, string key, string value) -> T
-static Microsoft.Identity.Client.Extensibility.AcquireTokenParameterBuilderExtensions.WithReservedScopes(this Microsoft.Identity.Client.AcquireTokenByAuthorizationCodeParameterBuilder builder, bool offlineAccessScope) -> Microsoft.Identity.Client.AcquireTokenByAuthorizationCodeParameterBuilder
+static Microsoft.Identity.Client.Extensibility.AcquireTokenParameterBuilderExtensions.WithReservedScopes(this Microsoft.Identity.Client.AcquireTokenByAuthorizationCodeParameterBuilder builder, bool offlineAccessScope) -> Microsoft.Identity.Client.AcquireTokenByAuthorizationCodeParameterBuilder
\ No newline at end of file
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 99e725f55b..c08c06c96f 100644
--- a/src/client/Microsoft.Identity.Client/PublicApi/net472/PublicAPI.Unshipped.txt
+++ b/src/client/Microsoft.Identity.Client/PublicApi/net472/PublicAPI.Unshipped.txt
@@ -1,7 +1,9 @@
+Microsoft.Identity.Client.AcquireTokenForManagedIdentityParameterBuilder.WithClaimsFromClient(string claimsJson) -> Microsoft.Identity.Client.AcquireTokenForManagedIdentityParameterBuilder
+static Microsoft.Identity.Client.Extensibility.AbstractConfidentialClientAcquireTokenParameterBuilderExtension.WithClaimsFromClient(this Microsoft.Identity.Client.AbstractConfidentialClientAcquireTokenParameterBuilder builder, string claimsJson) -> T
Microsoft.Identity.Client.AuthScheme.IAuthenticationOperation3
Microsoft.Identity.Client.AuthScheme.IAuthenticationOperation3.AfterCredentialEvaluationAsync(Microsoft.Identity.Client.AuthScheme.CredentialEvaluationContext context, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task
Microsoft.Identity.Client.AuthScheme.CredentialEvaluationContext
Microsoft.Identity.Client.AuthScheme.CredentialEvaluationContext.CredentialEvaluationContext(System.Security.Cryptography.X509Certificates.X509Certificate2 mtlsCertificate) -> void
Microsoft.Identity.Client.AuthScheme.CredentialEvaluationContext.MtlsCertificate.get -> System.Security.Cryptography.X509Certificates.X509Certificate2
static Microsoft.Identity.Client.Extensibility.AcquireTokenParameterBuilderExtensions.WithCachePartitionKey(this Microsoft.Identity.Client.BaseAbstractAcquireTokenParameterBuilder builder, string key, string value) -> T
-static Microsoft.Identity.Client.Extensibility.AcquireTokenParameterBuilderExtensions.WithReservedScopes(this Microsoft.Identity.Client.AcquireTokenByAuthorizationCodeParameterBuilder builder, bool offlineAccessScope) -> Microsoft.Identity.Client.AcquireTokenByAuthorizationCodeParameterBuilder
+static Microsoft.Identity.Client.Extensibility.AcquireTokenParameterBuilderExtensions.WithReservedScopes(this Microsoft.Identity.Client.AcquireTokenByAuthorizationCodeParameterBuilder builder, bool offlineAccessScope) -> Microsoft.Identity.Client.AcquireTokenByAuthorizationCodeParameterBuilder
\ No newline at end of file
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 99e725f55b..c08c06c96f 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
@@ -1,7 +1,9 @@
+Microsoft.Identity.Client.AcquireTokenForManagedIdentityParameterBuilder.WithClaimsFromClient(string claimsJson) -> Microsoft.Identity.Client.AcquireTokenForManagedIdentityParameterBuilder
+static Microsoft.Identity.Client.Extensibility.AbstractConfidentialClientAcquireTokenParameterBuilderExtension.WithClaimsFromClient(this Microsoft.Identity.Client.AbstractConfidentialClientAcquireTokenParameterBuilder builder, string claimsJson) -> T
Microsoft.Identity.Client.AuthScheme.IAuthenticationOperation3
Microsoft.Identity.Client.AuthScheme.IAuthenticationOperation3.AfterCredentialEvaluationAsync(Microsoft.Identity.Client.AuthScheme.CredentialEvaluationContext context, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task
Microsoft.Identity.Client.AuthScheme.CredentialEvaluationContext
Microsoft.Identity.Client.AuthScheme.CredentialEvaluationContext.CredentialEvaluationContext(System.Security.Cryptography.X509Certificates.X509Certificate2 mtlsCertificate) -> void
Microsoft.Identity.Client.AuthScheme.CredentialEvaluationContext.MtlsCertificate.get -> System.Security.Cryptography.X509Certificates.X509Certificate2
static Microsoft.Identity.Client.Extensibility.AcquireTokenParameterBuilderExtensions.WithCachePartitionKey(this Microsoft.Identity.Client.BaseAbstractAcquireTokenParameterBuilder builder, string key, string value) -> T
-static Microsoft.Identity.Client.Extensibility.AcquireTokenParameterBuilderExtensions.WithReservedScopes(this Microsoft.Identity.Client.AcquireTokenByAuthorizationCodeParameterBuilder builder, bool offlineAccessScope) -> Microsoft.Identity.Client.AcquireTokenByAuthorizationCodeParameterBuilder
+static Microsoft.Identity.Client.Extensibility.AcquireTokenParameterBuilderExtensions.WithReservedScopes(this Microsoft.Identity.Client.AcquireTokenByAuthorizationCodeParameterBuilder builder, bool offlineAccessScope) -> Microsoft.Identity.Client.AcquireTokenByAuthorizationCodeParameterBuilder
\ No newline at end of file
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 99e725f55b..c08c06c96f 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
@@ -1,7 +1,9 @@
+Microsoft.Identity.Client.AcquireTokenForManagedIdentityParameterBuilder.WithClaimsFromClient(string claimsJson) -> Microsoft.Identity.Client.AcquireTokenForManagedIdentityParameterBuilder
+static Microsoft.Identity.Client.Extensibility.AbstractConfidentialClientAcquireTokenParameterBuilderExtension.WithClaimsFromClient(this Microsoft.Identity.Client.AbstractConfidentialClientAcquireTokenParameterBuilder builder, string claimsJson) -> T
Microsoft.Identity.Client.AuthScheme.IAuthenticationOperation3
Microsoft.Identity.Client.AuthScheme.IAuthenticationOperation3.AfterCredentialEvaluationAsync(Microsoft.Identity.Client.AuthScheme.CredentialEvaluationContext context, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task
Microsoft.Identity.Client.AuthScheme.CredentialEvaluationContext
Microsoft.Identity.Client.AuthScheme.CredentialEvaluationContext.CredentialEvaluationContext(System.Security.Cryptography.X509Certificates.X509Certificate2 mtlsCertificate) -> void
Microsoft.Identity.Client.AuthScheme.CredentialEvaluationContext.MtlsCertificate.get -> System.Security.Cryptography.X509Certificates.X509Certificate2
static Microsoft.Identity.Client.Extensibility.AcquireTokenParameterBuilderExtensions.WithCachePartitionKey(this Microsoft.Identity.Client.BaseAbstractAcquireTokenParameterBuilder builder, string key, string value) -> T
-static Microsoft.Identity.Client.Extensibility.AcquireTokenParameterBuilderExtensions.WithReservedScopes(this Microsoft.Identity.Client.AcquireTokenByAuthorizationCodeParameterBuilder builder, bool offlineAccessScope) -> Microsoft.Identity.Client.AcquireTokenByAuthorizationCodeParameterBuilder
+static Microsoft.Identity.Client.Extensibility.AcquireTokenParameterBuilderExtensions.WithReservedScopes(this Microsoft.Identity.Client.AcquireTokenByAuthorizationCodeParameterBuilder builder, bool offlineAccessScope) -> Microsoft.Identity.Client.AcquireTokenByAuthorizationCodeParameterBuilder
\ No newline at end of file
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 99e725f55b..c08c06c96f 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
@@ -1,7 +1,9 @@
+Microsoft.Identity.Client.AcquireTokenForManagedIdentityParameterBuilder.WithClaimsFromClient(string claimsJson) -> Microsoft.Identity.Client.AcquireTokenForManagedIdentityParameterBuilder
+static Microsoft.Identity.Client.Extensibility.AbstractConfidentialClientAcquireTokenParameterBuilderExtension.WithClaimsFromClient(this Microsoft.Identity.Client.AbstractConfidentialClientAcquireTokenParameterBuilder builder, string claimsJson) -> T
Microsoft.Identity.Client.AuthScheme.IAuthenticationOperation3
Microsoft.Identity.Client.AuthScheme.IAuthenticationOperation3.AfterCredentialEvaluationAsync(Microsoft.Identity.Client.AuthScheme.CredentialEvaluationContext context, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task
Microsoft.Identity.Client.AuthScheme.CredentialEvaluationContext
Microsoft.Identity.Client.AuthScheme.CredentialEvaluationContext.CredentialEvaluationContext(System.Security.Cryptography.X509Certificates.X509Certificate2 mtlsCertificate) -> void
Microsoft.Identity.Client.AuthScheme.CredentialEvaluationContext.MtlsCertificate.get -> System.Security.Cryptography.X509Certificates.X509Certificate2
static Microsoft.Identity.Client.Extensibility.AcquireTokenParameterBuilderExtensions.WithCachePartitionKey(this Microsoft.Identity.Client.BaseAbstractAcquireTokenParameterBuilder builder, string key, string value) -> T
-static Microsoft.Identity.Client.Extensibility.AcquireTokenParameterBuilderExtensions.WithReservedScopes(this Microsoft.Identity.Client.AcquireTokenByAuthorizationCodeParameterBuilder builder, bool offlineAccessScope) -> Microsoft.Identity.Client.AcquireTokenByAuthorizationCodeParameterBuilder
+static Microsoft.Identity.Client.Extensibility.AcquireTokenParameterBuilderExtensions.WithReservedScopes(this Microsoft.Identity.Client.AcquireTokenByAuthorizationCodeParameterBuilder builder, bool offlineAccessScope) -> Microsoft.Identity.Client.AcquireTokenByAuthorizationCodeParameterBuilder
\ No newline at end of file
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 99e725f55b..c08c06c96f 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
@@ -1,7 +1,9 @@
+Microsoft.Identity.Client.AcquireTokenForManagedIdentityParameterBuilder.WithClaimsFromClient(string claimsJson) -> Microsoft.Identity.Client.AcquireTokenForManagedIdentityParameterBuilder
+static Microsoft.Identity.Client.Extensibility.AbstractConfidentialClientAcquireTokenParameterBuilderExtension.WithClaimsFromClient(this Microsoft.Identity.Client.AbstractConfidentialClientAcquireTokenParameterBuilder builder, string claimsJson) -> T
Microsoft.Identity.Client.AuthScheme.IAuthenticationOperation3
Microsoft.Identity.Client.AuthScheme.IAuthenticationOperation3.AfterCredentialEvaluationAsync(Microsoft.Identity.Client.AuthScheme.CredentialEvaluationContext context, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task
Microsoft.Identity.Client.AuthScheme.CredentialEvaluationContext
Microsoft.Identity.Client.AuthScheme.CredentialEvaluationContext.CredentialEvaluationContext(System.Security.Cryptography.X509Certificates.X509Certificate2 mtlsCertificate) -> void
Microsoft.Identity.Client.AuthScheme.CredentialEvaluationContext.MtlsCertificate.get -> System.Security.Cryptography.X509Certificates.X509Certificate2
static Microsoft.Identity.Client.Extensibility.AcquireTokenParameterBuilderExtensions.WithCachePartitionKey(this Microsoft.Identity.Client.BaseAbstractAcquireTokenParameterBuilder builder, string key, string value) -> T
-static Microsoft.Identity.Client.Extensibility.AcquireTokenParameterBuilderExtensions.WithReservedScopes(this Microsoft.Identity.Client.AcquireTokenByAuthorizationCodeParameterBuilder builder, bool offlineAccessScope) -> Microsoft.Identity.Client.AcquireTokenByAuthorizationCodeParameterBuilder
+static Microsoft.Identity.Client.Extensibility.AcquireTokenParameterBuilderExtensions.WithReservedScopes(this Microsoft.Identity.Client.AcquireTokenByAuthorizationCodeParameterBuilder builder, bool offlineAccessScope) -> Microsoft.Identity.Client.AcquireTokenByAuthorizationCodeParameterBuilder
\ No newline at end of file
diff --git a/src/client/Microsoft.Identity.Client/Utils/JsonHelper.cs b/src/client/Microsoft.Identity.Client/Utils/JsonHelper.cs
index c5cee4d0ed..2d5cc34b22 100644
--- a/src/client/Microsoft.Identity.Client/Utils/JsonHelper.cs
+++ b/src/client/Microsoft.Identity.Client/Utils/JsonHelper.cs
@@ -1,6 +1,7 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.
+using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
@@ -124,7 +125,18 @@ internal static long ExtractParsedIntOrZero(JObject json, string key)
internal static string JsonObjectToString(JsonObject jsonObject) => jsonObject.ToJsonString();
- internal static JsonObject ParseIntoJsonObject(string json) => JsonNode.Parse(json).AsObject();
+ internal static JsonObject ParseIntoJsonObject(string json)
+ {
+ var node = JsonNode.Parse(json);
+ if (node is null)
+ {
+ // JsonNode.Parse("null") returns null — treat the JSON literal 'null' the same as
+ // any other non-object value so callers get InvalidOperationException, not NRE.
+ throw new InvalidOperationException("The JSON value is the literal 'null', not a JSON object.");
+ }
+
+ return node.AsObject();
+ }
internal static JsonObject ToJsonObject(JsonNode jsonNode) => jsonNode.AsObject();
diff --git a/tests/Microsoft.Identity.Test.Unit/CoreTests/OAuth2Tests/ClaimsHelperTests.cs b/tests/Microsoft.Identity.Test.Unit/CoreTests/OAuth2Tests/ClaimsHelperTests.cs
new file mode 100644
index 0000000000..9180db97fd
--- /dev/null
+++ b/tests/Microsoft.Identity.Test.Unit/CoreTests/OAuth2Tests/ClaimsHelperTests.cs
@@ -0,0 +1,128 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+using System.Text.Json;
+using Microsoft.Identity.Client;
+using Microsoft.Identity.Client.Internal;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+namespace Microsoft.Identity.Test.Unit.CoreTests.OAuth2Tests
+{
+ [TestClass]
+ public class ClaimsHelperTests
+ {
+ #region MergeClaimsObjects
+
+ [TestMethod]
+ public void MergeClaimsObjects_BothNull_ReturnsNull()
+ {
+ // Act
+ string result = ClaimsHelper.MergeClaimsObjects(null, null);
+
+ // Assert
+ Assert.IsNull(result);
+ }
+
+ [TestMethod]
+ public void MergeClaimsObjects_FirstNull_ReturnsSecond()
+ {
+ // Arrange
+ string claims2 = @"{""a"":1}";
+
+ // Act
+ string result = ClaimsHelper.MergeClaimsObjects(null, claims2);
+
+ // Assert
+ Assert.AreEqual(claims2, result);
+ }
+
+ [TestMethod]
+ public void MergeClaimsObjects_SecondNull_ReturnsFirst()
+ {
+ // Arrange
+ string claims1 = @"{""a"":1}";
+
+ // Act
+ string result = ClaimsHelper.MergeClaimsObjects(claims1, null);
+
+ // Assert
+ Assert.AreEqual(claims1, result);
+ }
+
+ [TestMethod]
+ public void MergeClaimsObjects_NonOverlapping_ReturnsMergedObject()
+ {
+ // Arrange
+ string claims1 = @"{""nsp"":{""essential"":true}}";
+ string claims2 = @"{""userinfo"":{""given_name"":{""essential"":true}}}";
+
+ // Act
+ string result = ClaimsHelper.MergeClaimsObjects(claims1, claims2);
+
+ // Assert — both top-level keys must be present
+ using var doc = JsonDocument.Parse(result);
+ Assert.IsTrue(doc.RootElement.TryGetProperty("nsp", out _), "nsp key should be present");
+ Assert.IsTrue(doc.RootElement.TryGetProperty("userinfo", out _), "userinfo key should be present");
+ }
+
+ [TestMethod]
+ public void MergeClaimsObjects_OverlappingKeys_SecondObjectWins()
+ {
+ // Arrange — both have 'nsp' but with different values
+ string claims1 = @"{""nsp"":{""value"":""v1""}}";
+ string claims2 = @"{""nsp"":{""value"":""v2""}}";
+
+ // Act
+ string result = ClaimsHelper.MergeClaimsObjects(claims1, claims2);
+
+ // Assert — second value wins
+ using var doc = JsonDocument.Parse(result);
+ string nspValue = doc.RootElement.GetProperty("nsp").GetProperty("value").GetString();
+ Assert.AreEqual("v2", nspValue);
+ }
+
+ [TestMethod]
+ public void MergeClaimsObjects_EmptyStrings_TreatedAsNull()
+ {
+ // Arrange
+ string claims1 = @"{""a"":1}";
+
+ // Act
+ string result = ClaimsHelper.MergeClaimsObjects(claims1, "");
+
+ // Assert — empty string is treated as null, so first is returned
+ Assert.AreEqual(claims1, result);
+ }
+
+ [TestMethod]
+ public void MergeClaimsObjects_InvalidJson_ThrowsMsalClientException()
+ {
+ // Arrange — one side is invalid JSON
+ string valid = @"{""a"":1}";
+ string invalid = "not-json";
+
+ // Act & Assert
+ MsalClientException ex = Assert.ThrowsExactly(
+ () => ClaimsHelper.MergeClaimsObjects(invalid, valid));
+
+ Assert.AreEqual(MsalError.InvalidJsonClaimsFormat, ex.ErrorCode);
+ }
+
+ [TestMethod]
+ [DataRow("[]")]
+ [DataRow("\"string\"")]
+ public void MergeClaimsObjects_ValidJsonButNotObject_ThrowsMsalClientException(string nonObjectJson)
+ {
+ // Arrange — valid JSON that is not an object triggers InvalidOperationException in ParseIntoJsonObject
+ string valid = @"{""a"":1}";
+
+ // Act & Assert — must be translated to MsalClientException, not leak InvalidOperationException
+ MsalClientException ex = Assert.ThrowsExactly(
+ () => ClaimsHelper.MergeClaimsObjects(nonObjectJson, valid));
+
+ Assert.AreEqual(MsalError.InvalidJsonClaimsFormat, ex.ErrorCode);
+ }
+
+ #endregion
+ }
+}
\ No newline at end of file
diff --git a/tests/Microsoft.Identity.Test.Unit/CoreTests/OAuth2Tests/ClaimsTest.cs b/tests/Microsoft.Identity.Test.Unit/CoreTests/OAuth2Tests/ClaimsTest.cs
index 3c39e6e614..91e4592267 100644
--- a/tests/Microsoft.Identity.Test.Unit/CoreTests/OAuth2Tests/ClaimsTest.cs
+++ b/tests/Microsoft.Identity.Test.Unit/CoreTests/OAuth2Tests/ClaimsTest.cs
@@ -228,8 +228,13 @@ public async Task ClaimsAndClientCapabilities_AreMerged_And_AreSentTo_Authorizat
[TestMethod]
public async Task Claims_Fail_WhenClaimsIsNotJson_Async()
{
+ // Use a loopback redirect URI so that the AcquireTokenInteractive path on .NET 8
+ // passes its loopback validation and reaches the claims merge -- otherwise the
+ // loopback check would throw MsalError.LoopbackRedirectUri before any claims
+ // parsing runs (since ClaimsAndClientCapabilities is computed lazily).
var app = PublicClientApplicationBuilder.Create(TestConstants.ClientId)
.WithClientCapabilities(TestConstants.s_clientCapabilities)
+ .WithRedirectUri("http://localhost")
.BuildConcrete();
var ex = await AssertException.TaskThrowsAsync(
diff --git a/tests/Microsoft.Identity.Test.Unit/ManagedIdentityTests/WithClientClaimsTests.cs b/tests/Microsoft.Identity.Test.Unit/ManagedIdentityTests/WithClientClaimsTests.cs
new file mode 100644
index 0000000000..13c4d5439c
--- /dev/null
+++ b/tests/Microsoft.Identity.Test.Unit/ManagedIdentityTests/WithClientClaimsTests.cs
@@ -0,0 +1,723 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+using System;
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using Microsoft.Identity.Client;
+using Microsoft.Identity.Client.AppConfig;
+using Microsoft.Identity.Client.Extensibility;
+using Microsoft.Identity.Client.ManagedIdentity;
+using Microsoft.Identity.Client.OAuth2;
+using Microsoft.Identity.Test.Common.Core.Helpers;
+using Microsoft.Identity.Test.Common.Core.Mocks;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using static Microsoft.Identity.Test.Common.Core.Helpers.ManagedIdentityTestUtil;
+
+namespace Microsoft.Identity.Test.Unit.ManagedIdentityTests
+{
+ ///
+ /// Unit tests for WithClaimsFromClient() across all three auth flows:
+ /// 1. MSIv1 (IMDS GET — claims as query parameter)
+ /// 2. Confidential Client / AcquireTokenForClient (claims merged into ESTS POST body)
+ /// 3. Cache-key isolation — different claims values produce separate cache entries
+ ///
+ [TestClass]
+ public class WithClaimsFromClientTests : TestBase
+ {
+ // A simple NSP-style claims payload used across tests. MSIv1 only allows the `xms_az_nwperimid` key.
+ private const string NspClaims = @"{""xms_az_nwperimid"":{""essential"":true}}";
+
+ // A second, distinct claims value used to exercise separate-cache-entry behaviour.
+ private const string OtherClaims = @"{""xms_az_nwperimid"":{""values"":[""eastus""]}}";
+
+ // ---------------------------------------------------------------------------------
+ // Builder-level unit tests (no HTTP)
+ // ---------------------------------------------------------------------------------
+
+ [TestMethod]
+ [DataRow(null)]
+ [DataRow("")]
+ [DataRow(" ")]
+ public void WithClaimsFromClient_NullOrWhitespace_IsNoOp(string emptyClaims)
+ {
+ // Arrange
+ using (new EnvVariableContext())
+ {
+ SetEnvironmentVariables(ManagedIdentitySource.Imds, ManagedIdentityTests.ImdsEndpoint);
+ var mi = ManagedIdentityApplicationBuilder
+ .Create(ManagedIdentityId.SystemAssigned)
+ .Build();
+
+ // Act — should not throw
+ var builder = mi.AcquireTokenForManagedIdentity(ManagedIdentityTests.Resource)
+ .WithClaimsFromClient(emptyClaims);
+
+ // Assert — ClientClaims must remain unset (no cache component added)
+ Assert.IsNull(builder.CommonParameters.ClientClaims,
+ "Empty/null claims should not set ClientClaims.");
+ Assert.IsNull(builder.CommonParameters.CacheKeyComponents,
+ "Empty/null claims should not add cache key components.");
+ }
+ }
+
+ [TestMethod]
+ public void WithClaimsFromClient_SetsClientClaimsOnCommonParameters()
+ {
+ // Arrange
+ using (new EnvVariableContext())
+ {
+ SetEnvironmentVariables(ManagedIdentitySource.Imds, ManagedIdentityTests.ImdsEndpoint);
+ var mi = ManagedIdentityApplicationBuilder
+ .Create(ManagedIdentityId.SystemAssigned)
+ .WithExperimentalFeatures(true)
+ .Build();
+
+ // Act
+ var builder = mi.AcquireTokenForManagedIdentity(ManagedIdentityTests.Resource)
+ .WithClaimsFromClient(NspClaims);
+
+ // Assert — normalized claims are stored
+ Assert.IsNotNull(builder.CommonParameters.ClientClaims,
+ "ClientClaims must be set.");
+ Assert.IsNotNull(builder.CommonParameters.CacheKeyComponents,
+ "CacheKeyComponents must be populated.");
+ Assert.IsTrue(builder.CommonParameters.CacheKeyComponents.ContainsKey("client_claims"),
+ "client_claims cache key component must be present.");
+ }
+ }
+
+ [TestMethod]
+ public void WithClaimsFromClient_DoesNotSetCommonParametersClaims()
+ {
+ // WithClaimsFromClient must NOT touch CommonParameters.Claims — doing so would
+ // incorrectly bypass the token cache (Claims is the server-issued bypass signal).
+ using (new EnvVariableContext())
+ {
+ SetEnvironmentVariables(ManagedIdentitySource.Imds, ManagedIdentityTests.ImdsEndpoint);
+ var mi = ManagedIdentityApplicationBuilder
+ .Create(ManagedIdentityId.SystemAssigned)
+ .WithExperimentalFeatures(true)
+ .Build();
+
+ // Act
+ var builder = mi.AcquireTokenForManagedIdentity(ManagedIdentityTests.Resource)
+ .WithClaimsFromClient(NspClaims);
+
+ // Assert — CommonParameters.Claims (the cache-bypass property) must be null
+ Assert.IsNull(builder.CommonParameters.Claims,
+ "WithClaimsFromClient must NOT set CommonParameters.Claims — that would bypass the cache.");
+ }
+ }
+
+ // ---------------------------------------------------------------------------------
+ // MSIv1 (IMDS GET) — claims forwarded as a query parameter
+ // ---------------------------------------------------------------------------------
+
+ [TestMethod]
+ public async Task WithClaimsFromClient_Imds_ForwardsClaimsAsQueryParameterAsync()
+ {
+ // Arrange
+ using (new EnvVariableContext())
+ using (var httpManager = new MockHttpManager())
+ {
+ SetEnvironmentVariables(ManagedIdentitySource.Imds, ManagedIdentityTests.ImdsEndpoint);
+
+ var mi = ManagedIdentityApplicationBuilder
+ .Create(ManagedIdentityId.SystemAssigned)
+ .WithHttpManager(httpManager)
+ .WithExperimentalFeatures(true)
+
+ .Build();
+
+ // The mock handler is set up to expect claims= in the query string.
+ // If the MSAL code does NOT send the parameter, the handler will not match and the
+ // test will throw InvalidOperationException (no handler matched).
+ string normalizedClaims = NspClaims;
+ httpManager.AddManagedIdentityMockHandler(
+ ManagedIdentityTests.ImdsEndpoint,
+ ManagedIdentityTests.Resource,
+ MockHelpers.GetMsiSuccessfulResponse(),
+ ManagedIdentitySource.Imds,
+ extraQueryParameters: new Dictionary { { "claims", Uri.EscapeDataString(normalizedClaims) } });
+
+ // Act
+ var result = await mi.AcquireTokenForManagedIdentity(ManagedIdentityTests.Resource)
+ .WithClaimsFromClient(NspClaims)
+ .ExecuteAsync()
+ .ConfigureAwait(false);
+
+ // Assert
+ Assert.AreEqual(TokenSource.IdentityProvider, result.AuthenticationResultMetadata.TokenSource);
+ }
+ }
+
+ [TestMethod]
+ public async Task WithClaimsFromClient_Imds_TokenIsCached_SecondCallDoesNotHitNetworkAsync()
+ {
+ // Arrange
+ using (new EnvVariableContext())
+ using (var httpManager = new MockHttpManager())
+ {
+ SetEnvironmentVariables(ManagedIdentitySource.Imds, ManagedIdentityTests.ImdsEndpoint);
+
+ var mi = ManagedIdentityApplicationBuilder
+ .Create(ManagedIdentityId.SystemAssigned)
+ .WithHttpManager(httpManager)
+ .WithExperimentalFeatures(true)
+
+ .Build();
+
+ // Only one network mock — second call must come from cache.
+ string normalizedClaims = NspClaims;
+ httpManager.AddManagedIdentityMockHandler(
+ ManagedIdentityTests.ImdsEndpoint,
+ ManagedIdentityTests.Resource,
+ MockHelpers.GetMsiSuccessfulResponse(),
+ ManagedIdentitySource.Imds,
+ extraQueryParameters: new Dictionary { { "claims", Uri.EscapeDataString(normalizedClaims) } });
+
+ // Act — first call
+ var result1 = await mi.AcquireTokenForManagedIdentity(ManagedIdentityTests.Resource)
+ .WithClaimsFromClient(NspClaims)
+ .ExecuteAsync()
+ .ConfigureAwait(false);
+
+ // Act — second call (no new mock handler added)
+ var result2 = await mi.AcquireTokenForManagedIdentity(ManagedIdentityTests.Resource)
+ .WithClaimsFromClient(NspClaims)
+ .ExecuteAsync()
+ .ConfigureAwait(false);
+
+ // Assert
+ Assert.AreEqual(TokenSource.IdentityProvider, result1.AuthenticationResultMetadata.TokenSource,
+ "First call should hit the network.");
+ Assert.AreEqual(TokenSource.Cache, result2.AuthenticationResultMetadata.TokenSource,
+ "Second call with identical claims must be served from cache.");
+ }
+ }
+
+ [TestMethod]
+ public async Task WithClaimsFromClient_Imds_DifferentClaims_ProduceSeparateCacheEntriesAsync()
+ {
+ // Two calls with distinct claims values must each produce a separate network call.
+ using (new EnvVariableContext())
+ using (var httpManager = new MockHttpManager())
+ {
+ SetEnvironmentVariables(ManagedIdentitySource.Imds, ManagedIdentityTests.ImdsEndpoint);
+
+ var mi = ManagedIdentityApplicationBuilder
+ .Create(ManagedIdentityId.SystemAssigned)
+ .WithHttpManager(httpManager)
+ .WithExperimentalFeatures(true)
+
+ .Build();
+
+ string normalizedNsp = NspClaims;
+ string normalizedOther = OtherClaims;
+
+ // Two distinct network mocks — each must be consumed
+ httpManager.AddManagedIdentityMockHandler(
+ ManagedIdentityTests.ImdsEndpoint,
+ ManagedIdentityTests.Resource,
+ MockHelpers.GetMsiSuccessfulResponse(),
+ ManagedIdentitySource.Imds,
+ extraQueryParameters: new Dictionary { { "claims", Uri.EscapeDataString(normalizedNsp) } });
+
+ httpManager.AddManagedIdentityMockHandler(
+ ManagedIdentityTests.ImdsEndpoint,
+ ManagedIdentityTests.Resource,
+ MockHelpers.GetMsiSuccessfulResponse(),
+ ManagedIdentitySource.Imds,
+ extraQueryParameters: new Dictionary { { "claims", Uri.EscapeDataString(normalizedOther) } });
+
+ // Act
+ var result1 = await mi.AcquireTokenForManagedIdentity(ManagedIdentityTests.Resource)
+ .WithClaimsFromClient(NspClaims)
+ .ExecuteAsync()
+ .ConfigureAwait(false);
+
+ var result2 = await mi.AcquireTokenForManagedIdentity(ManagedIdentityTests.Resource)
+ .WithClaimsFromClient(OtherClaims)
+ .ExecuteAsync()
+ .ConfigureAwait(false);
+
+ // Assert — both calls must have hit the network (different cache partitions)
+ Assert.AreEqual(TokenSource.IdentityProvider, result1.AuthenticationResultMetadata.TokenSource,
+ "First claims value should hit the network.");
+ Assert.AreEqual(TokenSource.IdentityProvider, result2.AuthenticationResultMetadata.TokenSource,
+ "Different claims value should produce a separate cache entry and hit the network.");
+ }
+ }
+
+ [TestMethod]
+ public async Task WithClaimsFromClient_Imds_DoesNotBypassCache_UnlikeWithClaimsAsync()
+ {
+ // WithClaims() bypasses the cache on every call.
+ // WithClaimsFromClient() must NOT bypass the cache — second call should be a cache hit.
+ using (new EnvVariableContext())
+ using (var httpManager = new MockHttpManager())
+ {
+ SetEnvironmentVariables(ManagedIdentitySource.Imds, ManagedIdentityTests.ImdsEndpoint);
+
+ var mi = ManagedIdentityApplicationBuilder
+ .Create(ManagedIdentityId.SystemAssigned)
+ .WithHttpManager(httpManager)
+ .WithExperimentalFeatures(true)
+
+ .Build();
+
+ string normalizedClaims = NspClaims;
+
+ // Only one mock handler — if the second call also hits the network it will throw
+ httpManager.AddManagedIdentityMockHandler(
+ ManagedIdentityTests.ImdsEndpoint,
+ ManagedIdentityTests.Resource,
+ MockHelpers.GetMsiSuccessfulResponse(),
+ ManagedIdentitySource.Imds,
+ extraQueryParameters: new Dictionary { { "claims", Uri.EscapeDataString(normalizedClaims) } });
+
+ // Act
+ await mi.AcquireTokenForManagedIdentity(ManagedIdentityTests.Resource)
+ .WithClaimsFromClient(NspClaims)
+ .ExecuteAsync()
+ .ConfigureAwait(false);
+
+ var result = await mi.AcquireTokenForManagedIdentity(ManagedIdentityTests.Resource)
+ .WithClaimsFromClient(NspClaims)
+ .ExecuteAsync()
+ .ConfigureAwait(false);
+
+ // Assert — second call must be a cache hit, not a network call
+ Assert.AreEqual(TokenSource.Cache, result.AuthenticationResultMetadata.TokenSource,
+ "WithClaimsFromClient must use the cache (unlike WithClaims which always bypasses).");
+ }
+ }
+
+ [TestMethod]
+ public async Task WithClaimsFromClient_Imds_NoClaims_ClaimsParamAbsentFromRequestAsync()
+ {
+ // When no client claims are specified, the `claims` query parameter must be absent.
+ using (new EnvVariableContext())
+ using (var httpManager = new MockHttpManager())
+ {
+ SetEnvironmentVariables(ManagedIdentitySource.Imds, ManagedIdentityTests.ImdsEndpoint);
+
+ var mi = ManagedIdentityApplicationBuilder
+ .Create(ManagedIdentityId.SystemAssigned)
+ .WithHttpManager(httpManager)
+ .WithExperimentalFeatures(true)
+
+ .Build();
+
+ // Standard mock handler with no claims expectation
+ httpManager.AddManagedIdentityMockHandler(
+ ManagedIdentityTests.ImdsEndpoint,
+ ManagedIdentityTests.Resource,
+ MockHelpers.GetMsiSuccessfulResponse(),
+ ManagedIdentitySource.Imds);
+
+ // Act — no WithClaimsFromClient call
+ var result = await mi.AcquireTokenForManagedIdentity(ManagedIdentityTests.Resource)
+ .ExecuteAsync()
+ .ConfigureAwait(false);
+
+ // Assert — should succeed normally
+ Assert.AreEqual(TokenSource.IdentityProvider, result.AuthenticationResultMetadata.TokenSource);
+ }
+ }
+
+ // ---------------------------------------------------------------------------------
+ // Confidential Client / AcquireTokenForClient — claims merged into ESTS POST body
+ // ---------------------------------------------------------------------------------
+
+ [TestMethod]
+ public async Task WithClaimsFromClient_ConfidentialClient_SendsClaimsInEstsBodyAsync()
+ {
+ // Arrange
+ using (var harness = CreateTestHarness())
+ {
+ harness.HttpManager.AddInstanceDiscoveryMockHandler();
+
+ var app = ConfidentialClientApplicationBuilder
+ .Create(TestConstants.ClientId)
+ .WithAuthority(AzureCloudInstance.AzurePublic, TestConstants.Utid)
+ .WithClientSecret(TestConstants.ClientSecret)
+ .WithHttpManager(harness.HttpManager)
+ .WithExperimentalFeatures(true)
+ .BuildConcrete();
+
+ string normalizedClaims = NspClaims;
+
+ // The POST body must contain claims=
+ harness.HttpManager.AddSuccessTokenResponseMockHandlerForPost(
+ TestConstants.AuthorityUtidTenant,
+ bodyParameters: new Dictionary
+ {
+ { OAuth2Parameter.Claims, normalizedClaims }
+ },
+ responseMessage: MockHelpers.CreateSuccessfulClientCredentialTokenResponseMessage());
+
+ // Act
+ var result = await app.AcquireTokenForClient(TestConstants.s_scope)
+ .WithClaimsFromClient(normalizedClaims)
+ .ExecuteAsync()
+ .ConfigureAwait(false);
+
+ // Assert
+ Assert.IsNotNull(result);
+ Assert.AreEqual(TokenSource.IdentityProvider, result.AuthenticationResultMetadata.TokenSource);
+ }
+ }
+
+ [TestMethod]
+ public async Task WithClaimsFromClient_ConfidentialClient_TokenIsCached_SecondCallFromCacheAsync()
+ {
+ // Arrange
+ using (var harness = CreateTestHarness())
+ {
+ harness.HttpManager.AddInstanceDiscoveryMockHandler();
+
+ var app = ConfidentialClientApplicationBuilder
+ .Create(TestConstants.ClientId)
+ .WithAuthority(AzureCloudInstance.AzurePublic, TestConstants.Utid)
+ .WithClientSecret(TestConstants.ClientSecret)
+ .WithHttpManager(harness.HttpManager)
+ .WithExperimentalFeatures(true)
+ .BuildConcrete();
+
+ string normalizedClaims = NspClaims;
+
+ // Only one mock — second call must come from cache
+ harness.HttpManager.AddSuccessTokenResponseMockHandlerForPost(
+ TestConstants.AuthorityUtidTenant,
+ responseMessage: MockHelpers.CreateSuccessfulClientCredentialTokenResponseMessage());
+
+ // Act
+ var result1 = await app.AcquireTokenForClient(TestConstants.s_scope)
+ .WithClaimsFromClient(normalizedClaims)
+ .ExecuteAsync()
+ .ConfigureAwait(false);
+
+ var result2 = await app.AcquireTokenForClient(TestConstants.s_scope)
+ .WithClaimsFromClient(normalizedClaims)
+ .ExecuteAsync()
+ .ConfigureAwait(false);
+
+ // Assert
+ Assert.AreEqual(TokenSource.IdentityProvider, result1.AuthenticationResultMetadata.TokenSource,
+ "First call should hit the network.");
+ Assert.AreEqual(TokenSource.Cache, result2.AuthenticationResultMetadata.TokenSource,
+ "Second call with identical claims must be served from cache.");
+ }
+ }
+
+ [TestMethod]
+ public async Task WithClaimsFromClient_ConfidentialClient_DifferentClaims_SeparateCacheEntriesAsync()
+ {
+ // Arrange
+ using (var harness = CreateTestHarness())
+ {
+ harness.HttpManager.AddInstanceDiscoveryMockHandler();
+
+ var app = ConfidentialClientApplicationBuilder
+ .Create(TestConstants.ClientId)
+ .WithAuthority(AzureCloudInstance.AzurePublic, TestConstants.Utid)
+ .WithClientSecret(TestConstants.ClientSecret)
+ .WithHttpManager(harness.HttpManager)
+ .WithExperimentalFeatures(true)
+ .BuildConcrete();
+
+ string normalizedNsp = NspClaims;
+ string normalizedOther = OtherClaims;
+
+ // Two distinct network mocks
+ harness.HttpManager.AddSuccessTokenResponseMockHandlerForPost(
+ TestConstants.AuthorityUtidTenant,
+ responseMessage: MockHelpers.CreateSuccessfulClientCredentialTokenResponseMessage());
+
+ harness.HttpManager.AddSuccessTokenResponseMockHandlerForPost(
+ TestConstants.AuthorityUtidTenant,
+ responseMessage: MockHelpers.CreateSuccessfulClientCredentialTokenResponseMessage());
+
+ // Act
+ var result1 = await app.AcquireTokenForClient(TestConstants.s_scope)
+ .WithClaimsFromClient(normalizedNsp)
+ .ExecuteAsync()
+ .ConfigureAwait(false);
+
+ var result2 = await app.AcquireTokenForClient(TestConstants.s_scope)
+ .WithClaimsFromClient(normalizedOther)
+ .ExecuteAsync()
+ .ConfigureAwait(false);
+
+ // Assert
+ Assert.AreEqual(TokenSource.IdentityProvider, result1.AuthenticationResultMetadata.TokenSource,
+ "First claims value should hit the network.");
+ Assert.AreEqual(TokenSource.IdentityProvider, result2.AuthenticationResultMetadata.TokenSource,
+ "Different claims value should produce a separate cache entry and hit the network.");
+ }
+ }
+
+ [TestMethod]
+ public async Task WithClaimsFromClient_ConfidentialClient_DoesNotBypassCacheAsync()
+ {
+ // Arrange
+ using (var harness = CreateTestHarness())
+ {
+ harness.HttpManager.AddInstanceDiscoveryMockHandler();
+
+ var app = ConfidentialClientApplicationBuilder
+ .Create(TestConstants.ClientId)
+ .WithAuthority(AzureCloudInstance.AzurePublic, TestConstants.Utid)
+ .WithClientSecret(TestConstants.ClientSecret)
+ .WithHttpManager(harness.HttpManager)
+ .WithExperimentalFeatures(true)
+ .BuildConcrete();
+
+ string normalizedClaims = NspClaims;
+
+ // Only one mock — if second call also hits the network it will throw
+ harness.HttpManager.AddSuccessTokenResponseMockHandlerForPost(
+ TestConstants.AuthorityUtidTenant,
+ responseMessage: MockHelpers.CreateSuccessfulClientCredentialTokenResponseMessage());
+
+ // Act
+ await app.AcquireTokenForClient(TestConstants.s_scope)
+ .WithClaimsFromClient(normalizedClaims)
+ .ExecuteAsync()
+ .ConfigureAwait(false);
+
+ var result = await app.AcquireTokenForClient(TestConstants.s_scope)
+ .WithClaimsFromClient(normalizedClaims)
+ .ExecuteAsync()
+ .ConfigureAwait(false);
+
+ // Assert
+ Assert.AreEqual(TokenSource.Cache, result.AuthenticationResultMetadata.TokenSource,
+ "WithClaimsFromClient must not bypass the cache on repeated calls.");
+ }
+ }
+
+ [TestMethod]
+ public async Task WithClaimsFromClient_ConfidentialClient_WithServerClaims_ServerClaimsBypassesCacheAsync()
+ {
+ // WithClaims (server-issued) always bypasses the cache.
+ // WithClaimsFromClient (client-originated) does not.
+ // When both are used together, the server claim should still bypass the cache.
+ using (var harness = CreateTestHarness())
+ {
+ harness.HttpManager.AddInstanceDiscoveryMockHandler();
+
+ var app = ConfidentialClientApplicationBuilder
+ .Create(TestConstants.ClientId)
+ .WithAuthority(AzureCloudInstance.AzurePublic, TestConstants.Utid)
+ .WithClientSecret(TestConstants.ClientSecret)
+ .WithHttpManager(harness.HttpManager)
+ .WithExperimentalFeatures(true)
+ .BuildConcrete();
+
+ string normalizedClientClaims = NspClaims;
+
+ // First call — populate cache with client claims
+ harness.HttpManager.AddSuccessTokenResponseMockHandlerForPost(
+ TestConstants.AuthorityUtidTenant,
+ responseMessage: MockHelpers.CreateSuccessfulClientCredentialTokenResponseMessage());
+
+ await app.AcquireTokenForClient(TestConstants.s_scope)
+ .WithClaimsFromClient(normalizedClientClaims)
+ .ExecuteAsync()
+ .ConfigureAwait(false);
+
+ // Second call — with WithClaims (server bypass) in addition to WithClaimsFromClient
+ harness.HttpManager.AddSuccessTokenResponseMockHandlerForPost(
+ TestConstants.AuthorityUtidTenant,
+ responseMessage: MockHelpers.CreateSuccessfulClientCredentialTokenResponseMessage());
+
+ var result = await app.AcquireTokenForClient(TestConstants.s_scope)
+ .WithClaimsFromClient(normalizedClientClaims)
+ .WithClaims(TestConstants.Claims) // server-issued → bypasses cache
+ .ExecuteAsync()
+ .ConfigureAwait(false);
+
+ // Assert — server claims bypass forces a network call even though the token is cached
+ Assert.AreEqual(TokenSource.IdentityProvider, result.AuthenticationResultMetadata.TokenSource,
+ "WithClaims (server-issued) must always bypass the cache.");
+ }
+ }
+
+ [TestMethod]
+ public async Task WithClaimsFromClient_ConfidentialClient_NoClaims_ClaimsParamAbsentFromBodyAsync()
+ {
+ // When no client claims are specified, the `claims` body parameter must not appear.
+ using (var harness = CreateTestHarness())
+ {
+ harness.HttpManager.AddInstanceDiscoveryMockHandler();
+
+ var app = ConfidentialClientApplicationBuilder
+ .Create(TestConstants.ClientId)
+ .WithAuthority(AzureCloudInstance.AzurePublic, TestConstants.Utid)
+ .WithClientSecret(TestConstants.ClientSecret)
+ .WithHttpManager(harness.HttpManager)
+ .WithExperimentalFeatures(true)
+ .BuildConcrete();
+
+ // Standard success response — no body parameter expectation
+ harness.HttpManager.AddSuccessTokenResponseMockHandlerForPost(
+ TestConstants.AuthorityUtidTenant,
+ responseMessage: MockHelpers.CreateSuccessfulClientCredentialTokenResponseMessage());
+
+ // Act — no WithClaimsFromClient
+ var result = await app.AcquireTokenForClient(TestConstants.s_scope)
+ .ExecuteAsync()
+ .ConfigureAwait(false);
+
+ // Assert — normal token acquisition succeeds
+ Assert.IsNotNull(result);
+ }
+ }
+
+ // ---------------------------------------------------------------------------------
+ // Invalid JSON
+ // ---------------------------------------------------------------------------------
+ //
+ // Note: WithClaimsFromClient intentionally does NOT validate the JSON at builder time.
+ // Per reviewer feedback (Bogdan), MSAL stores the raw caller string verbatim and does no
+ // parsing on the hot path. Invalid JSON (e.g. "not-valid-json", "null") is forwarded as-is
+ // and will surface as an MsalServiceException from the wire when IMDS/ESTS rejects it, or
+ // as an MsalClientException from MergeClaimsObjects on cache miss when a server-issued
+ // claims challenge is also present. Builder-time fail-fast tests were removed when the
+ // NormalizeClaimsJson code path was deleted.
+ // ---------------------------------------------------------------------------------
+
+ // ---------------------------------------------------------------------------------
+ // Non-IMDS sources — builder behavior
+ // ---------------------------------------------------------------------------------
+
+ [TestMethod]
+ public void WithClaimsFromClient_NonImdsSource_SetsBuilderParameterButThrowsOnExecution()
+ {
+ // WithClaimsFromClient() sets the builder parameter for any MI source — the guard that
+ // rejects non-IMDS sources fires at request-execution time (in AbstractManagedIdentity),
+ // not at builder construction time. This test verifies the builder state; a full
+ // execution-level test requires mocking the App Service endpoint and is deferred.
+ using (new EnvVariableContext())
+ {
+ SetEnvironmentVariables(ManagedIdentitySource.AppService, "http://127.0.0.1:41564/msi/token");
+ var mi = ManagedIdentityApplicationBuilder
+ .Create(ManagedIdentityId.SystemAssigned)
+ .WithExperimentalFeatures(true)
+ .Build();
+
+ // Act
+ var builder = mi.AcquireTokenForManagedIdentity(ManagedIdentityTests.Resource)
+ .WithClaimsFromClient(NspClaims);
+
+ // Assert — parameter is stored on the builder regardless of source;
+ // MsalClientException is thrown later when the request is executed.
+ Assert.IsNotNull(builder.CommonParameters.ClientClaims,
+ "ClientClaims must be set on the builder even for non-IMDS sources.");
+ Assert.IsTrue(builder.CommonParameters.CacheKeyComponents.ContainsKey("client_claims"),
+ "Cache key component must be registered.");
+ }
+ }
+
+ // ---------------------------------------------------------------------------------
+ // MSIv1 claim allowlist validation — only xms_az_nwperimid is permitted
+ // ---------------------------------------------------------------------------------
+
+ private const string ValidNspClaim = @"{""xms_az_nwperimid"":{""values"":[""perimid-1234""]}}";
+ private const string UnsupportedClaim = @"{""custom_claim"":{""essential"":true}}";
+ private const string MixedClaims = @"{""xms_az_nwperimid"":{""values"":[""perimid-1234""]},""other_claim"":{""essential"":true}}";
+
+ [TestMethod]
+ public async Task WithClaimsFromClient_Imds_ValidXmsAzNwperimid_SucceedsAsync()
+ {
+ // xms_az_nwperimid is the only allowed claim for MSIv1; a request carrying it must succeed.
+ using (new EnvVariableContext())
+ using (var httpManager = new MockHttpManager())
+ {
+ SetEnvironmentVariables(ManagedIdentitySource.Imds, ManagedIdentityTests.ImdsEndpoint);
+
+ var mi = ManagedIdentityApplicationBuilder
+ .Create(ManagedIdentityId.SystemAssigned)
+ .WithHttpManager(httpManager)
+ .WithExperimentalFeatures(true)
+ .Build();
+
+ string normalizedClaims = ValidNspClaim;
+ httpManager.AddManagedIdentityMockHandler(
+ ManagedIdentityTests.ImdsEndpoint,
+ ManagedIdentityTests.Resource,
+ MockHelpers.GetMsiSuccessfulResponse(),
+ ManagedIdentitySource.Imds,
+ extraQueryParameters: new Dictionary { { "claims", Uri.EscapeDataString(normalizedClaims) } });
+
+ // Act
+ var result = await mi.AcquireTokenForManagedIdentity(ManagedIdentityTests.Resource)
+ .WithClaimsFromClient(ValidNspClaim)
+ .ExecuteAsync()
+ .ConfigureAwait(false);
+
+ // Assert
+ Assert.AreEqual(TokenSource.IdentityProvider, result.AuthenticationResultMetadata.TokenSource);
+ }
+ }
+
+ [TestMethod]
+ public async Task WithClaimsFromClient_Imds_UnsupportedClaim_ThrowsMsalClientExceptionAsync()
+ {
+ // Any claim key other than xms_az_nwperimid must be rejected before the network call,
+ // so the caller gets a clear error instead of an opaque HTTP 400 from IMDS.
+ using (new EnvVariableContext())
+ using (var httpManager = new MockHttpManager())
+ {
+ SetEnvironmentVariables(ManagedIdentitySource.Imds, ManagedIdentityTests.ImdsEndpoint);
+
+ var mi = ManagedIdentityApplicationBuilder
+ .Create(ManagedIdentityId.SystemAssigned)
+ .WithHttpManager(httpManager)
+ .WithExperimentalFeatures(true)
+ .Build();
+
+ // Act & Assert — MsalClientException must be thrown before any HTTP request is made
+ MsalClientException ex = await Assert.ThrowsExactlyAsync(
+ () => mi.AcquireTokenForManagedIdentity(ManagedIdentityTests.Resource)
+ .WithClaimsFromClient(UnsupportedClaim)
+ .ExecuteAsync())
+ .ConfigureAwait(false);
+
+ Assert.AreEqual(MsalError.InvalidRequest, ex.ErrorCode);
+ Assert.Contains("xms_az_nwperimid", ex.Message, "Error message should name the only allowed claim.");
+ }
+ }
+
+ [TestMethod]
+ public async Task WithClaimsFromClient_Imds_MixedClaims_ThrowsMsalClientExceptionAsync()
+ {
+ // Even if xms_az_nwperimid is present, any additional claims must be rejected.
+ using (new EnvVariableContext())
+ using (var httpManager = new MockHttpManager())
+ {
+ SetEnvironmentVariables(ManagedIdentitySource.Imds, ManagedIdentityTests.ImdsEndpoint);
+
+ var mi = ManagedIdentityApplicationBuilder
+ .Create(ManagedIdentityId.SystemAssigned)
+ .WithHttpManager(httpManager)
+ .WithExperimentalFeatures(true)
+ .Build();
+
+ // Act & Assert
+ MsalClientException ex = await Assert.ThrowsExactlyAsync(
+ () => mi.AcquireTokenForManagedIdentity(ManagedIdentityTests.Resource)
+ .WithClaimsFromClient(MixedClaims)
+ .ExecuteAsync())
+ .ConfigureAwait(false);
+
+ Assert.AreEqual(MsalError.InvalidRequest, ex.ErrorCode);
+ }
+ }
+ }
+}
+
+