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); + } + } + } +} + +