Skip to content
Open
Show file tree
Hide file tree
Changes from 16 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
fc57221
POC: Add WithClientClaims() across MSI, client credentials, and FIC f…
Robbie-Microsoft May 11, 2026
62a8f2f
Fix IMDS claims query param URL encoding in unit tests
Robbie-Microsoft May 11, 2026
8e27cf4
fix: address Gladwin's PR review feedback on WithClientClaims
Robbie-Microsoft May 12, 2026
1009976
Merge branch 'main' into rginsburg/poc-withclientclaims
Robbie-Microsoft May 13, 2026
ce9937b
fix: catch InvalidOperationException in ClaimsHelper for non-object JSON
Robbie-Microsoft May 13, 2026
dd19ed9
refactor: remove TODO comment from SortJsonObjectKeys
Robbie-Microsoft May 13, 2026
4f53fef
Merge remote-tracking branch 'origin/main' into rginsburg/poc-withcli…
Robbie-Microsoft May 13, 2026
ab13758
fix: address PR review comments on WithClientClaims POC
Robbie-Microsoft May 13, 2026
9ef28ea
fix: add missing 'using System' to JsonHelper.cs
Robbie-Microsoft May 13, 2026
8f697fb
fix: address Copilot review comments on ClaimsHelper, AbstractManaged…
Robbie-Microsoft May 13, 2026
0857d45
fix: revert accidental MSTest 4.2.2 bump, fix IsLessThan usage
Robbie-Microsoft May 13, 2026
80d3a94
fix: revert Assert.IsLessThan arg order to original (MSTest 4.0.2)
Robbie-Microsoft May 14, 2026
e12afa5
fix: address Copilot review threads 28, 30, 31
Robbie-Microsoft May 14, 2026
fc6156a
test: update NonImdsSource test comment to reflect execution-time throw
Robbie-Microsoft May 14, 2026
3b2e012
feat: add MSIv1 claim allowlist validation and fix test assertion
Robbie-Microsoft May 22, 2026
d7d7a4b
merge: resolve PublicAPI.Unshipped.txt conflicts from main
Robbie-Microsoft May 22, 2026
44756e6
Rename to WithClaimsFromClient and remove JSON normalization
Robbie-Microsoft May 27, 2026
dd03834
Merge branch 'main' into rginsburg/poc-withclientclaims
Robbie-Microsoft May 27, 2026
d6cf9c5
fix(tests): unblock failing CI tests after JSON-normalization removal
Robbie-Microsoft May 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
using System.Threading.Tasks;
using Microsoft.Identity.Client.ApiConfig.Executors;
using Microsoft.Identity.Client.ApiConfig.Parameters;
using Microsoft.Identity.Client.Internal;
using Microsoft.Identity.Client.ManagedIdentity;
using Microsoft.Identity.Client.TelemetryCore.Internal.Events;
using Microsoft.Identity.Client.Utils;
Expand Down Expand Up @@ -82,6 +83,32 @@ public AcquireTokenForManagedIdentityParameterBuilder WithClaims(string claims)
return this;
}

/// <summary>
/// Specifies client-originated claims to include in the token request.
/// Unlike <see cref="WithClaims(string)"/> (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.
/// </summary>
/// <param name="claimsJson">A JSON string containing the client claims. Must be valid JSON.</param>
/// <returns>The builder to chain .With methods.</returns>
public AcquireTokenForManagedIdentityParameterBuilder WithClientClaims(string claimsJson)
{
if (string.IsNullOrWhiteSpace(claimsJson))
Comment thread
Robbie-Microsoft marked this conversation as resolved.
{
return this;
}

ValidateUseOfExperimentalFeature();

string normalized = ClaimsHelper.NormalizeClaimsJson(claimsJson);
Comment thread
Robbie-Microsoft marked this conversation as resolved.
Outdated
CommonParameters.ClientClaims = normalized;

CommonParameters.CacheKeyComponents ??= new SortedList<string, Func<CancellationToken, Task<string>>>();
CommonParameters.CacheKeyComponents["client_claims"] = _ => Task.FromResult(normalized);

return this;
}

/// <inheritdoc/>
internal override Task<AuthenticationResult> ExecuteInternalAsync(CancellationToken cancellationToken)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ internal class AcquireTokenCommonParameters
public IEnumerable<string> Scopes { get; set; }
public IDictionary<string, string> 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<string, string> ExtraHttpHeaders { get; set; }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,12 @@ internal class AcquireTokenForManagedIdentityParameters : IAcquireTokenParameter

public string Claims { get; set; }

/// <summary>
/// Client-originated claims to be sent to the identity endpoint.
/// Unlike <see cref="Claims"/> (server-issued), these are cached and keyed on the claims value.
/// </summary>
public string ClientClaims { get; set; }

public string RevokedTokenHash { get; set; }

public bool IsMtlsPopRequested { get; set; }
Expand All @@ -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}
""");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Identity.Client.Core;
using Microsoft.Identity.Client.Internal;
using Microsoft.Identity.Client.OAuth2;

namespace Microsoft.Identity.Client.Extensibility
Expand All @@ -19,7 +20,71 @@ namespace Microsoft.Identity.Client.Extensibility
public static class AbstractConfidentialClientAcquireTokenParameterBuilderExtension
{
/// <summary>
/// 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 <see cref="AbstractAcquireTokenParameterBuilder{T}.WithClaims"/> (for server-issued
/// claims challenges), tokens acquired with client claims <b>are cached</b> and the cache entry
/// is keyed on the normalized claims value. Different claims values produce separate cache entries.
/// Use stable, non-dynamic values to avoid unbounded cache growth.
/// </summary>
/// <remarks>
/// This API is intended for MSI and cert/FIC flows (e.g., NSP claims for Azure Redis Cache).
/// Behavior for B2C, ADFS, and dSTS is undefined and unsupported.
/// </remarks>
/// <typeparam name="T">The concrete confidential client builder type.</typeparam>
/// <param name="builder">The builder to chain options to.</param>
/// <param name="claimsJson">A JSON string containing the client-originated claims. Must be valid JSON.</param>
/// <returns>The builder to chain the .With methods.</returns>
public static T WithClientClaims<T>(
Comment thread
Robbie-Microsoft marked this conversation as resolved.
Outdated
Comment thread
Robbie-Microsoft marked this conversation as resolved.
Outdated
this AbstractConfidentialClientAcquireTokenParameterBuilder<T> builder,
Comment thread
Robbie-Microsoft marked this conversation as resolved.
string claimsJson)
where T : AbstractConfidentialClientAcquireTokenParameterBuilder<T>
Comment thread
Robbie-Microsoft marked this conversation as resolved.
Outdated
Comment thread
Robbie-Microsoft marked this conversation as resolved.
Outdated
{
if (string.IsNullOrWhiteSpace(claimsJson))
Comment thread
Robbie-Microsoft marked this conversation as resolved.
{
return (T)builder;
}

// Client claims must not appear in front-channel authorization URLs because they can
// contain sensitive data and because the resulting cache key cannot be reproduced by
// silent token calls. Only token-acquisition flows (AcquireTokenForClient, OBO, etc.)
// are supported.
if (builder is GetAuthorizationRequestUrlParameterBuilder)
{
throw new MsalClientException(
MsalError.InvalidRequest,
"WithClientClaims is not supported for GetAuthorizationRequestUrl. " +
"Client claims are intended for token-acquisition flows (AcquireTokenForClient, AcquireTokenOnBehalfOf).");
}

// User-token flows (auth code, username/password, federated identity) cache tokens
// that AcquireTokenSilent would later retrieve — but AcquireTokenSilent has no
// WithClientClaims equivalent, so those tokens can never be found silently. Block
// these flows to avoid permanent cache pollution.
if (builder is AcquireTokenByAuthorizationCodeParameterBuilder ||
builder is AcquireTokenByUsernameAndPasswordConfidentialParameterBuilder ||
builder is AcquireTokenByUserFederatedIdentityCredentialParameterBuilder)
{
throw new MsalClientException(
MsalError.InvalidRequest,
"WithClientClaims is not supported for user-token flows (AcquireTokenByAuthorizationCode, " +
"AcquireTokenByUsernameAndPassword, AcquireTokenByUserFederatedIdentityCredential). " +
"Use WithClientClaims with AcquireTokenForClient or AcquireTokenOnBehalfOf.");
}

builder.ValidateUseOfExperimentalFeature();

string normalized = ClaimsHelper.NormalizeClaimsJson(claimsJson);
builder.CommonParameters.ClientClaims = normalized;

// Use indexer (not SortedList.Add) so repeated calls are last-write-wins rather than throwing.
builder.CommonParameters.CacheKeyComponents ??= new SortedList<string, Func<CancellationToken, Task<string>>>();
builder.CommonParameters.CacheKeyComponents["client_claims"] = _ => Task.FromResult(normalized);

return (T)builder;
Comment thread
Robbie-Microsoft marked this conversation as resolved.
}

/// <summary>
/// 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. <see cref="OnBeforeTokenRequestData"/>
/// </summary>
/// <typeparam name="T"></typeparam>
Expand Down
91 changes: 89 additions & 2 deletions src/client/Microsoft.Identity.Client/Internal/ClaimsHelper.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

using System;
using System.Buffers;
using System.Collections.Generic;
using System.Diagnostics;
Expand All @@ -18,6 +19,87 @@ internal static class ClaimsHelper
private const string AccessTokenClaim = "access_token";
private const string XmsClientCapability = "xms_cc";

/// <summary>
/// Normalizes a claims JSON string so that semantically identical claims always produce
/// the same string. This prevents cache key fragmentation when callers pass the same
/// logical claims in different whitespace or key-ordering variants.
/// </summary>
internal static string NormalizeClaimsJson(string claimsJson)
Comment thread
Robbie-Microsoft marked this conversation as resolved.
Outdated
{
if (string.IsNullOrWhiteSpace(claimsJson))
{
return claimsJson;
}

try
{
JObject parsed = JsonHelper.ParseIntoJsonObject(claimsJson);
JObject sorted = SortJsonObjectKeys(parsed);
return JsonHelper.JsonObjectToString(sorted);
}
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 or a scalar).
Comment thread
Robbie-Microsoft marked this conversation as resolved.
Outdated
// 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);
}
}

/// <summary>
/// Merges two JSON claims objects. If either is null/empty the other is returned as-is.
/// </summary>
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);
Comment thread
Robbie-Microsoft marked this conversation as resolved.
}
}

private static JObject SortJsonObjectKeys(JObject obj)
{
var sorted = new JObject();
foreach (var key in obj.Select(kvp => kvp.Key).OrderBy(k => k, StringComparer.Ordinal))
{
var value = obj[key];
if (value is JObject nestedObj)
{
sorted[key] = SortJsonObjectKeys(nestedObj);
}
else
{
// Array elements are cloned as-is. Per OIDC §5.5, array element *order* is
// semantically significant (e.g. acr.values preference order), so we must not
// reorder elements. NSP claims do not use arrays-of-objects, so there is no
// cache-fragmentation risk from not sorting inside array elements.
sorted[key] = value is null ? null : JsonNode.Parse(value.ToJsonString());
}
}
return sorted;
}

internal static string GetMergedClaimsAndClientCapabilities(
string claims,
IEnumerable<string> clientCapabilities)
Expand All @@ -42,11 +124,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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,8 +69,15 @@ public AuthenticationRequestParameters(
}
}

ClaimsAndClientCapabilities = ClaimsHelper.GetMergedClaimsAndClientCapabilities(
// Merge server-issued claims and client-originated claims before computing
// ClaimsAndClientCapabilities. Server claims drive cache bypass (handled by request handlers);
// client claims are stable and cached — they just need to appear in the ESTS body.
string mergedClaims = ClaimsHelper.MergeClaimsObjects(
_commonParameters.Claims,
_commonParameters.ClientClaims);
Comment thread
Robbie-Microsoft marked this conversation as resolved.
Outdated

ClaimsAndClientCapabilities = ClaimsHelper.GetMergedClaimsAndClientCapabilities(
mergedClaims,
_serviceBundle.Config.ClientCapabilities);

HomeAccountId = homeAccountId;
Expand Down Expand Up @@ -138,6 +145,12 @@ public string Claims
}
}

/// <summary>
/// Client-originated claims set via .WithClientClaims(). These are cached (no bypass) and
/// keyed on the normalized claims value.
/// </summary>
public string ClientClaims => _commonParameters.ClientClaims;

private IAuthenticationOperation _requestOverrideScheme;

/// <summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,14 @@ private async Task<AuthenticationResult> 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;
Comment thread
Robbie-Microsoft marked this conversation as resolved.
}

ManagedIdentityResponse managedIdentityResponse =
await _managedIdentityClient
.SendTokenRequestForManagedIdentityAsync(AuthenticationRequestParameters.RequestContext, _managedIdentityParameters, cancellationToken)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ protected AbstractManagedIdentity(RequestContext requestContext, ManagedIdentity
_sourceType = sourceType;
}

private const string XmsAzNwperimid = "xms_az_nwperimid";

public virtual async Task<ManagedIdentityResponse> AuthenticateAsync(
AcquireTokenForManagedIdentityParameters parameters,
CancellationToken cancellationToken)
Expand All @@ -57,6 +59,38 @@ public virtual async Task<ManagedIdentityResponse> 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,
$"WithClientClaims 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);
Comment thread
Robbie-Microsoft marked this conversation as resolved.
Comment thread
Robbie-Microsoft marked this conversation as resolved.
_requestContext.Logger.Info("[Managed Identity] Adding client claims to IMDS request as query parameter.");
}
else
{
request.BodyParameters["claims"] = parameters.ClientClaims;
Comment thread
Robbie-Microsoft marked this conversation as resolved.
Comment thread
Robbie-Microsoft marked this conversation as resolved.
_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
Expand Down Expand Up @@ -329,5 +363,26 @@ private static void CreateAndThrowException(string errorCode,

throw exception;
}

/// <summary>
/// MSIv1 (IMDS v1) only supports a single custom claim: <c>xms_az_nwperimid</c>.
/// 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.
/// </summary>
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 WithClientClaims with MSIv1.");
}
}
}
}
}
Loading
Loading