Skip to content
Open
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 @@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ public class ClientCredentialParameters implements IAcquireTokenParameters {

private String fmiPath;

private String clientClaims;

// Generic extended cache key components. Any optional or flow-specific parameters
// that should influence token cache isolation adds an entry here. The hash of these
// components is used as part of the cache key in relevant scenarios entries.
Expand All @@ -40,7 +42,7 @@ public class ClientCredentialParameters implements IAcquireTokenParameters {
// Memoized hash of cacheKeyComponents (computed once since parameters are immutable).
private String extCacheKeyHashCache;

private ClientCredentialParameters(Set<String> scopes, Boolean skipCache, ClaimsRequest claims, Map<String, String> extraHttpHeaders, Map<String, String> extraQueryParameters, String tenant, IClientCredential clientCredential, String fmiPath) {
private ClientCredentialParameters(Set<String> scopes, Boolean skipCache, ClaimsRequest claims, Map<String, String> extraHttpHeaders, Map<String, String> extraQueryParameters, String tenant, IClientCredential clientCredential, String fmiPath, String clientClaims) {
this.scopes = scopes;
this.skipCache = skipCache;
this.claims = claims;
Expand All @@ -49,6 +51,7 @@ private ClientCredentialParameters(Set<String> scopes, Boolean skipCache, Claims
this.tenant = tenant;
this.clientCredential = clientCredential;
this.fmiPath = fmiPath;
this.clientClaims = clientClaims;

// Build cache key components from any parameters that require cache isolation.
this.cacheKeyComponents = buildCacheKeyComponents();
Expand Down Expand Up @@ -114,6 +117,16 @@ public String fmiPath() {
return this.fmiPath;
}

/**
* Client-originated claims set via {@link ClientCredentialParametersBuilder#claimsFromClient(String)}.
* 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.
Expand All @@ -127,6 +140,12 @@ private SortedMap<String, String> buildCacheKeyComponents() {
components = new TreeMap<>();
components.put("fmi_path", fmiPath);
}
if (!StringHelper.isBlank(clientClaims)) {
if (components == null) {
components = new TreeMap<>();
}
components.put("client_claims", clientClaims);
}
return components;
}

Expand All @@ -145,7 +164,8 @@ SortedMap<String, String> cacheKeyComponents() {
* The result is memoized since ClientCredentialParameters is immutable after construction.
* Used by both cache writes ({@link TokenCache}) and cache reads (silent lookup).
*/
String computeExtCacheKeyHash() {
@Override
public String computeExtCacheKeyHash() {
if (extCacheKeyHashCache != null) {
return extCacheKeyHashCache;
}
Expand All @@ -162,6 +182,7 @@ public static class ClientCredentialParametersBuilder {
private String tenant;
private IClientCredential clientCredential;
private String fmiPath;
private String clientClaims;

ClientCredentialParametersBuilder() {
}
Expand Down Expand Up @@ -245,8 +266,31 @@ public ClientCredentialParametersBuilder fmiPath(String fmiPath) {
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}.
*
* @param claimsJson a valid JSON object string containing the client claims
* @return builder that can be used to construct ClientCredentialParameters
*/
public ClientCredentialParametersBuilder claimsFromClient(String claimsJson) {
if (StringHelper.isBlank(claimsJson)) {
return this;
}

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

public ClientCredentialParameters build() {
return new ClientCredentialParameters(this.scopes, this.skipCache, this.claims, this.extraHttpHeaders, this.extraQueryParameters, this.tenant, this.clientCredential, this.fmiPath);
return new ClientCredentialParameters(this.scopes, this.skipCache, this.claims, this.extraHttpHeaders, this.extraQueryParameters, this.tenant, this.clientCredential, this.fmiPath, this.clientClaims);
}

public String toString() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,4 +67,30 @@ interface IAcquireTokenParameters {
*/
@Deprecated
Map<String, String> extraQueryParameters();

/**
* Gets the client-originated claims (a raw JSON string) set via the per-request
* {@code claimsFromClient(...)} builder method.
* <p>
* Unlike {@link #claims()} (server-issued claims challenges, which bypass/refresh the cache),
* client claims are cached and the cache entry is keyed on the claims value, and they are sent on
* the wire as a standard OAuth {@code claims} parameter.
*
* @return the client-originated claims JSON, or null if none were provided.
*/
default String clientClaims() {
return null;
}

/**
* Computes the extended cache-key hash contributed by this request's parameters (for example
* {@code fmi_path} or {@code client_claims}). The hash isolates cache entries so that requests
* with different component values do not collide. Returns an empty string when there are no
* extended cache-key components.
*
* @return the Base64URL-encoded SHA-256 hash of the cache-key components, or an empty string.
*/
default String computeExtCacheKeyHash() {
return "";
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,12 @@
class JsonHelper {
private static final Logger LOG = LoggerFactory.getLogger(JsonHelper.class);

// Constant, payload-free message for client-claims validation failures. The raw claims value
// and parser details are deliberately excluded since the payload may contain sensitive data.
private static final String INVALID_CLAIMS_OBJECT_MESSAGE =
"The claims value is not a valid JSON object. " +
"See https://openid.net/specs/openid-connect-core-1_0.html#ClaimsParameter.";

private JsonHelper() {
}

Expand Down Expand Up @@ -157,6 +163,29 @@ static void validateJsonFormat(String jsonString) {
}
}

/**
* Validates that the supplied string is a well-formed JSON object (the root token is
* {@code {}}) and that there are no trailing tokens after it. Throws {@link MsalClientException}
* with {@link AuthenticationErrorCode#INVALID_JSON} otherwise. The raw value and the underlying
* parser message are never included in the exception message, since claims payloads may contain
* sensitive data.
*/
static void validateJsonObjectFormat(String jsonString) {
try (JsonReader reader = JsonProviders.createReader(jsonString)) {
if (reader.nextToken() != JsonToken.START_OBJECT) {
throw new MsalClientException(INVALID_CLAIMS_OBJECT_MESSAGE, AuthenticationErrorCode.INVALID_JSON);
}
reader.skipChildren();
// Ensure the object is the entire document; reject a valid object followed by any
// trailing tokens (e.g. "{}{}" or "{} garbage"), which is not a single JSON value.
if (reader.nextToken() != JsonToken.END_DOCUMENT) {
throw new MsalClientException(INVALID_CLAIMS_OBJECT_MESSAGE, AuthenticationErrorCode.INVALID_JSON);
}
} catch (IOException e) {
throw new MsalClientException(INVALID_CLAIMS_OBJECT_MESSAGE, AuthenticationErrorCode.INVALID_JSON);
}
}

public static String formCapabilitiesJson(Set<String> clientCapabilities) {
if (clientCapabilities == null || clientCapabilities.isEmpty()) {
return null;
Expand Down
Loading
Loading