Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import java.net.HttpURLConnection;
import java.net.SocketException;
import java.net.URISyntaxException;
import java.util.HashMap;

//base class for all sources that support managed identity
abstract class AbstractManagedIdentitySource {
Expand Down Expand Up @@ -42,6 +43,7 @@ public ManagedIdentityResponse getManagedIdentityResponse(

createManagedIdentityRequest(parameters.resource);
managedIdentityRequest.addTokenRevocationParametersToQuery(parameters);
addClientClaimsToRequest(parameters);
IHttpResponse response;

try {
Expand All @@ -62,6 +64,60 @@ public ManagedIdentityResponse getManagedIdentityResponse(
return handleResponse(parameters, response);
}

/**
* Forwards client-originated claims (set via
* {@link ManagedIdentityParameters.ManagedIdentityParametersBuilder#claimsFromClient(String)}) to
* the managed identity endpoint. Only IMDS-based managed identity is supported; other sources fail
* fast rather than silently dropping the value (which would also pollute the cache with a key the
* endpoint never saw). For IMDS (a GET request) the claims are added as a query parameter; for any
* POST-based source they would be added to the body.
*/
private void addClientClaimsToRequest(ManagedIdentityParameters parameters) {
if (StringHelper.isNullOrBlank(parameters.clientClaims)) {
return;
}

// Defense-in-depth: AcquireTokenByManagedIdentitySupplier already rejects non-IMDS sources
// before the cache read. Re-check here in case the transport path is reached directly. The
// claims object is forwarded to IMDS as-is; IMDS decides which keys it accepts.
validateClientClaimsSource(managedIdentitySourceType, parameters.clientClaims);

if (managedIdentityRequest.method == HttpMethod.GET) {
if (managedIdentityRequest.queryParameters == null) {
managedIdentityRequest.queryParameters = new HashMap<>();
}
// The value is URL-encoded later by StringHelper.serializeQueryParameters.
managedIdentityRequest.queryParameters.put("claims", parameters.clientClaims);
LOG.info("[Managed Identity] Adding client claims to IMDS request as query parameter.");
} else {
if (managedIdentityRequest.bodyParameters == null) {
managedIdentityRequest.bodyParameters = new HashMap<>();
}
managedIdentityRequest.bodyParameters.put("claims", parameters.clientClaims);
LOG.info("[Managed Identity] Adding client claims to request body.");
}
}

/**
* Validates that client-originated claims are only used with IMDS (MSIv1) managed identity. Other
* sources fail fast rather than silently dropping the value (which would also pollute the cache
* with a key the endpoint never saw). MSAL does not otherwise restrict the claim contents — the
* JSON object is forwarded to IMDS as-is, and IMDS accepts or rejects it. A blank value is a no-op.
* Shared by the pre-cache guard and the transport layer so the rule has a single definition.
*/
static void validateClientClaimsSource(ManagedIdentitySourceType source, String clientClaims) {
if (StringHelper.isNullOrBlank(clientClaims)) {
return;
}

if (source != ManagedIdentitySourceType.IMDS && source != ManagedIdentitySourceType.DEFAULT_TO_IMDS) {
throw new MsalClientException(
String.format("claimsFromClient is only supported for IMDS-based managed identity sources. "
+ "The detected source is %s.", source),
AuthenticationErrorCode.INVALID_REQUEST);
}
}

public ManagedIdentityResponse handleResponse(
ManagedIdentityParameters parameters,
IHttpResponse response) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,12 @@ AuthenticationResult execute() throws Exception {
MsalErrorMessage.SCOPES_REQUIRED);
}

// Fail fast before the cache lookup: client-originated claims are only supported on IMDS.
// Validating the source here (not just on the network path) ensures an unsupported source can
// never return a cached token and never reaches the wire.
AbstractManagedIdentitySource.validateClientClaimsSource(
ManagedIdentityClient.getManagedIdentitySource(), managedIdentityParameters.clientClaims);

TokenRequestExecutor tokenRequestExecutor = new TokenRequestExecutor(
clientApplication.authenticationAuthority,
msalRequest,
Expand Down Expand Up @@ -67,6 +73,12 @@ AuthenticationResult execute() throws Exception {
context,
null);

// Propagate ext_cache_key_hash for cache isolation (e.g., client_claims)
String extCacheKeyHash = managedIdentityParameters.computeExtCacheKeyHash();
if (!StringHelper.isBlank(extCacheKeyHash)) {
silentRequest.extCacheKeyHash(extCacheKeyHash);
}

AcquireTokenSilentSupplier supplier = new AcquireTokenSilentSupplier(
this.clientApplication,
silentRequest);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,12 @@ AuthenticationResult execute() throws Exception {
context,
onBehalfOfRequest.parameters.userAssertion());

// Propagate ext_cache_key_hash for cache isolation (e.g., client_claims)
String extCacheKeyHash = this.onBehalfOfRequest.parameters.computeExtCacheKeyHash();
if (!StringHelper.isBlank(extCacheKeyHash)) {
silentRequest.extCacheKeyHash(extCacheKeyHash);
}

AcquireTokenSilentSupplier supplier = new AcquireTokenSilentSupplier(
this.clientApplication,
silentRequest);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,14 @@ AuthenticationResult execute() throws Exception {
context,
null);

// Propagate ext_cache_key_hash for cache isolation (e.g., client_claims).
// User-FIC tokens are account-scoped, so the user-token read path in TokenCache
// must also filter on this hash for the isolation to take effect.
String extCacheKeyHash = this.userFicRequest.parameters.computeExtCacheKeyHash();
if (!StringHelper.isBlank(extCacheKeyHash)) {
silentRequest.extCacheKeyHash(extCacheKeyHash);
}

AcquireTokenSilentSupplier supplier = new AcquireTokenSilentSupplier(
this.clientApplication,
silentRequest);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,8 @@ AuthenticationResult execute() throws Exception {
silentRequest.parameters().account(),
requestAuthority,
silentRequest.parameters().scopes(),
clientApplication.clientId());
clientApplication.clientId(),
silentRequest.extCacheKeyHash());

if (res == null) {
throw new MsalClientException(AuthenticationErrorMessage.NO_TOKEN_IN_CACHE, AuthenticationErrorCode.CACHE_MISS);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -166,4 +166,11 @@ public class AuthenticationErrorCode {
* This is returned by the instance discovery endpoint when the provided authority host is unknown.
*/
public static final String INVALID_INSTANCE = "invalid_instance";

/**
* Indicates that the request is malformed or uses an unsupported parameter combination, for
* example when client-originated claims are supplied to a managed identity source that does not
* support them.
*/
public static final String INVALID_REQUEST = "invalid_request";
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
import java.net.URI;
import java.util.Map;
import java.util.Set;
import java.util.SortedMap;
import java.util.TreeMap;

import static com.microsoft.aad.msal4j.ParameterValidationUtils.validateNotBlank;
import static com.microsoft.aad.msal4j.ParameterValidationUtils.validateNotNull;
Expand Down Expand Up @@ -33,10 +35,20 @@ public class AuthorizationCodeParameters implements IAcquireTokenParameters {

private String tenant;

private String clientClaims;

// Generic extended cache key components. The hash of these components isolates cache
// entries so that requests with different client-claims values do not collide.
private SortedMap<String, String> cacheKeyComponents;

// Memoized hash of cacheKeyComponents (computed once since parameters are immutable).
private String extCacheKeyHashCache;

private AuthorizationCodeParameters(String authorizationCode, URI redirectUri,
Set<String> scopes, ClaimsRequest claims,
String codeVerifier, Map<String, String> extraHttpHeaders,
Map<String, String> extraQueryParameters, String tenant) {
Map<String, String> extraQueryParameters, String tenant,
String clientClaims) {
this.authorizationCode = authorizationCode;
this.redirectUri = redirectUri;
this.scopes = scopes;
Expand All @@ -45,6 +57,10 @@ private AuthorizationCodeParameters(String authorizationCode, URI redirectUri,
this.extraHttpHeaders = extraHttpHeaders;
this.extraQueryParameters = extraQueryParameters;
this.tenant = tenant;
this.clientClaims = clientClaims;

// Build cache key components from any parameters that require cache isolation.
this.cacheKeyComponents = buildCacheKeyComponents();
}

private static AuthorizationCodeParametersBuilder builder() {
Expand Down Expand Up @@ -104,6 +120,50 @@ public String tenant() {
return this.tenant;
}

/**
* Client-originated claims set via {@link AuthorizationCodeParametersBuilder#claimsFromClient(String)}.
Comment thread
Robbie-Microsoft marked this conversation as resolved.
* Forwarded to the token endpoint as the OAuth {@code claims} parameter and used as part of the
* extended cache key so that distinct claim values are cached separately.
*/
@Override
public String clientClaims() {
return this.clientClaims;
}

/**
* Builds the sorted map of cache key components from the parameters that require cache isolation.
* Returns null if no components are present.
*/
private SortedMap<String, String> buildCacheKeyComponents() {
TreeMap<String, String> components = null;
if (!StringHelper.isBlank(clientClaims)) {
components = new TreeMap<>();
components.put("client_claims", clientClaims);
}
return components;
}

/**
* Returns the extended cache key components for this request, if any.
* Used by {@link TokenCache} for both cache writes and reads.
*/
SortedMap<String, String> cacheKeyComponents() {
return this.cacheKeyComponents;
}

/**
* Computes the extended cache key hash from all cache key components, or an empty string when
* there are none. The result is memoized since the parameters are immutable after construction.
*/
@Override
public String computeExtCacheKeyHash() {
if (extCacheKeyHashCache != null) {
return extCacheKeyHashCache;
}
extCacheKeyHashCache = StringHelper.computeExtCacheKeyHash(cacheKeyComponents);
return extCacheKeyHashCache;
}

public static class AuthorizationCodeParametersBuilder {
private String authorizationCode;
private URI redirectUri;
Expand All @@ -113,6 +173,7 @@ public static class AuthorizationCodeParametersBuilder {
private Map<String, String> extraHttpHeaders;
private Map<String, String> extraQueryParameters;
private String tenant;
private String clientClaims;

AuthorizationCodeParametersBuilder() {
}
Expand Down Expand Up @@ -193,8 +254,40 @@ public AuthorizationCodeParametersBuilder tenant(String tenant) {
return this;
}

/**
* Specifies client-originated claims (a raw JSON object string) to forward to the token
* endpoint as the OAuth {@code claims} request parameter. Unlike {@link #claims(ClaimsRequest)}
* (server-issued claims challenges, which bypass the cache), tokens acquired with client claims
* are cached and the cache entry is keyed on the claims value, so distinct claim values produce
* separate cache entries. Use stable, non-dynamic values to avoid cache fragmentation. Send the identical value on every
* request for a given token; because the raw value is part of the cache key, changing or
* omitting it routes the request to a different cache partition.
* A blank value is ignored; an invalid JSON object throws {@link MsalClientException}.
* <p>
* Client claims are primarily intended for confidential-client web apps, but
* {@code AuthorizationCodeParameters} is also accepted by {@link PublicClientApplication}, so this
* method is visible there too. The same cache caveat applies to <em>both</em> application types: a
* token acquired with client claims is stored under the extended cache key, while a later
* {@code acquireTokenSilently} call (which uses {@link SilentParameters} and cannot carry client
* claims) will not match that entry and will instead refresh without the client claims. The claims
* are applied only on the acquire/redemption call that sets them; to apply them again, redeem
* through this builder rather than relying on a silent refresh.
*
* @param claimsJson a valid JSON object string containing the client claims
* @return this builder instance
*/
public AuthorizationCodeParametersBuilder claimsFromClient(String claimsJson) {
if (StringHelper.isBlank(claimsJson)) {
return this;
}

JsonHelper.validateJsonObjectFormat(claimsJson);
this.clientClaims = claimsJson;
return this;
}

public AuthorizationCodeParameters build() {
return new AuthorizationCodeParameters(this.authorizationCode, this.redirectUri, this.scopes, this.claims, this.codeVerifier, this.extraHttpHeaders, this.extraQueryParameters, this.tenant);
return new AuthorizationCodeParameters(this.authorizationCode, this.redirectUri, this.scopes, this.claims, this.codeVerifier, this.extraHttpHeaders, this.extraQueryParameters, this.tenant, this.clientClaims);
}

public String toString() {
Expand Down
Loading
Loading