Skip to content
Open
Show file tree
Hide file tree
Changes from all 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 @@ -82,6 +82,31 @@ 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 WithClaimsFromClient(string claimsJson)
{
if (string.IsNullOrWhiteSpace(claimsJson))
Comment thread
Robbie-Microsoft marked this conversation as resolved.
{
return this;
}

ValidateUseOfExperimentalFeature();

CommonParameters.ClientClaims = claimsJson;

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

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 @@ -19,7 +19,39 @@ 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 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 WithClaimsFromClient<T>(
this AbstractConfidentialClientAcquireTokenParameterBuilder<T> builder,
Comment thread
Robbie-Microsoft marked this conversation as resolved.
string claimsJson)
where T : AbstractConfidentialClientAcquireTokenParameterBuilder<T>
{
if (string.IsNullOrWhiteSpace(claimsJson))
Comment thread
Robbie-Microsoft marked this conversation as resolved.
{
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<string, Func<CancellationToken, Task<string>>>();
builder.CommonParameters.CacheKeyComponents["client_claims"] = _ => Task.FromResult(claimsJson);

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
41 changes: 36 additions & 5 deletions src/client/Microsoft.Identity.Client/Internal/ClaimsHelper.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -18,6 +16,34 @@ internal static class ClaimsHelper
private const string AccessTokenClaim = "access_token";
private const string XmsClientCapability = "xms_cc";

/// <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.
}
}

internal static string GetMergedClaimsAndClientCapabilities(
string claims,
IEnumerable<string> clientCapabilities)
Expand All @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ internal class AuthenticationRequestParameters
private readonly IServiceBundle _serviceBundle;
private readonly AcquireTokenCommonParameters _commonParameters;
private string _loginHint;
private Lazy<string> _claimsAndClientCapabilities;

public AuthenticationRequestParameters(
IServiceBundle serviceBundle,
Expand Down Expand Up @@ -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<string>(() =>
ClaimsHelper.GetMergedClaimsAndClientCapabilities(
ClaimsHelper.MergeClaimsObjects(_commonParameters.Claims, _commonParameters.ClientClaims),
_serviceBundle.Config.ClientCapabilities));
}

public ApplicationConfiguration AppConfig => _serviceBundle.Config;
Expand Down Expand Up @@ -108,7 +112,7 @@ public AuthenticationRequestParameters(

public IDictionary<string, string> ExtraQueryParameters { get; }

public string ClaimsAndClientCapabilities { get; private set; }
public string ClaimsAndClientCapabilities => _claimsAndClientCapabilities.Value;

public Guid CorrelationId => _commonParameters.CorrelationId;

Expand Down Expand Up @@ -138,6 +142,12 @@ public string Claims
}
}

/// <summary>
/// Client-originated claims set via .WithClaimsFromClient(). These are cached (no bypass) and
/// keyed on the raw claims string as passed by the caller.
/// </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,
$"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);
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.
_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 WithClaimsFromClient with MSIv1.");
}
}
}
}
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
Microsoft.Identity.Client.AcquireTokenForManagedIdentityParameterBuilder.WithClaimsFromClient(string claimsJson) -> Microsoft.Identity.Client.AcquireTokenForManagedIdentityParameterBuilder
static Microsoft.Identity.Client.Extensibility.AbstractConfidentialClientAcquireTokenParameterBuilderExtension.WithClaimsFromClient<T>(this Microsoft.Identity.Client.AbstractConfidentialClientAcquireTokenParameterBuilder<T> 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<T>(this Microsoft.Identity.Client.BaseAbstractAcquireTokenParameterBuilder<T> 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
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
Microsoft.Identity.Client.AcquireTokenForManagedIdentityParameterBuilder.WithClaimsFromClient(string claimsJson) -> Microsoft.Identity.Client.AcquireTokenForManagedIdentityParameterBuilder
static Microsoft.Identity.Client.Extensibility.AbstractConfidentialClientAcquireTokenParameterBuilderExtension.WithClaimsFromClient<T>(this Microsoft.Identity.Client.AbstractConfidentialClientAcquireTokenParameterBuilder<T> 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<T>(this Microsoft.Identity.Client.BaseAbstractAcquireTokenParameterBuilder<T> 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
Loading
Loading