Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
Show all changes
17 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
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,30 @@ 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;
}

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,41 @@ 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>
/// <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;
}

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

var cacheKey = new SortedList<string, Func<CancellationToken, Task<string>>>
{
{ "client_claims", _ => Task.FromResult(normalized) }
};

WithAdditionalCacheKeyComponents(builder, cacheKey);
Comment thread
Robbie-Microsoft marked this conversation as resolved.
Outdated

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
61 changes: 61 additions & 0 deletions src/client/Microsoft.Identity.Client/Internal/ClaimsHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
using System.Text.Json.Nodes;
using Microsoft.Identity.Client.Utils;
using JObject = System.Text.Json.Nodes.JsonObject;
using System;

namespace Microsoft.Identity.Client.Internal
{
Expand All @@ -18,6 +19,66 @@ 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 (JsonException ex)
Comment thread
Robbie-Microsoft marked this conversation as resolved.
Outdated
{
throw new MsalClientException(
MsalError.InvalidJsonClaimsFormat,
MsalErrorMessage.InvalidJsonClaimsFormat(claimsJson),
Comment thread
Robbie-Microsoft marked this conversation as resolved.
Outdated
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;

JObject obj1 = JsonHelper.ParseIntoJsonObject(claims1);
JObject obj2 = JsonHelper.ParseIntoJsonObject(claims2);
JObject merged = JsonHelper.Merge(obj1, obj2);
return JsonHelper.JsonObjectToString(merged);
Comment thread
Robbie-Microsoft marked this conversation as resolved.
Outdated
}

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
{
// JsonNode.DeepClone is .NET 6+; use Parse(ToJsonString()) for portability.
sorted[key] = value is null ? null : JsonNode.Parse(value.ToJsonString());
}
}
return sorted;
}

Comment thread
Robbie-Microsoft marked this conversation as resolved.
Outdated
internal static string GetMergedClaimsAndClientCapabilities(
string claims,
IEnumerable<string> clientCapabilities)
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 @@ -137,6 +144,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,13 @@ 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 are cached normally.
Comment thread
Robbie-Microsoft marked this conversation as resolved.
Outdated
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 @@ -57,6 +57,23 @@ public virtual async Task<ManagedIdentityResponse> AuthenticateAsync(

ManagedIdentityRequest request = await CreateRequestAsync(resource).ConfigureAwait(false);

// Forward client-originated claims to the correct location:
// - GET requests (IMDS/MSIv1): append as "claims" query parameter
// - POST requests (ImdsV2 / ESTS): append as "claims" body parameter
if (!string.IsNullOrEmpty(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
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Microsoft.Identity.Client.AcquireTokenForManagedIdentityParameterBuilder.WithClientClaims(string claimsJson) -> Microsoft.Identity.Client.AcquireTokenForManagedIdentityParameterBuilder
static Microsoft.Identity.Client.Extensibility.AbstractConfidentialClientAcquireTokenParameterBuilderExtension.WithClientClaims<T>(this Microsoft.Identity.Client.AbstractConfidentialClientAcquireTokenParameterBuilder<T> builder, string claimsJson) -> T
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Microsoft.Identity.Client.AcquireTokenForManagedIdentityParameterBuilder.WithClientClaims(string claimsJson) -> Microsoft.Identity.Client.AcquireTokenForManagedIdentityParameterBuilder
static Microsoft.Identity.Client.Extensibility.AbstractConfidentialClientAcquireTokenParameterBuilderExtension.WithClientClaims<T>(this Microsoft.Identity.Client.AbstractConfidentialClientAcquireTokenParameterBuilder<T> builder, string claimsJson) -> T
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Microsoft.Identity.Client.AcquireTokenForManagedIdentityParameterBuilder.WithClientClaims(string claimsJson) -> Microsoft.Identity.Client.AcquireTokenForManagedIdentityParameterBuilder
static Microsoft.Identity.Client.Extensibility.AbstractConfidentialClientAcquireTokenParameterBuilderExtension.WithClientClaims<T>(this Microsoft.Identity.Client.AbstractConfidentialClientAcquireTokenParameterBuilder<T> builder, string claimsJson) -> T
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Microsoft.Identity.Client.AcquireTokenForManagedIdentityParameterBuilder.WithClientClaims(string claimsJson) -> Microsoft.Identity.Client.AcquireTokenForManagedIdentityParameterBuilder
static Microsoft.Identity.Client.Extensibility.AbstractConfidentialClientAcquireTokenParameterBuilderExtension.WithClientClaims<T>(this Microsoft.Identity.Client.AbstractConfidentialClientAcquireTokenParameterBuilder<T> builder, string claimsJson) -> T
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Microsoft.Identity.Client.AcquireTokenForManagedIdentityParameterBuilder.WithClientClaims(string claimsJson) -> Microsoft.Identity.Client.AcquireTokenForManagedIdentityParameterBuilder
static Microsoft.Identity.Client.Extensibility.AbstractConfidentialClientAcquireTokenParameterBuilderExtension.WithClientClaims<T>(this Microsoft.Identity.Client.AbstractConfidentialClientAcquireTokenParameterBuilder<T> builder, string claimsJson) -> T
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Microsoft.Identity.Client.AcquireTokenForManagedIdentityParameterBuilder.WithClientClaims(string claimsJson) -> Microsoft.Identity.Client.AcquireTokenForManagedIdentityParameterBuilder
static Microsoft.Identity.Client.Extensibility.AbstractConfidentialClientAcquireTokenParameterBuilderExtension.WithClientClaims<T>(this Microsoft.Identity.Client.AbstractConfidentialClientAcquireTokenParameterBuilder<T> builder, string claimsJson) -> T
Loading
Loading