diff --git a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/AcquireTokenByOnBehalfOfSupplier.java b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/AcquireTokenByOnBehalfOfSupplier.java index 2f438997..3d348004 100644 --- a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/AcquireTokenByOnBehalfOfSupplier.java +++ b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/AcquireTokenByOnBehalfOfSupplier.java @@ -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); diff --git a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/AcquireTokenByUserFederatedIdentityCredentialSupplier.java b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/AcquireTokenByUserFederatedIdentityCredentialSupplier.java index 22bb0072..e8513de1 100644 --- a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/AcquireTokenByUserFederatedIdentityCredentialSupplier.java +++ b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/AcquireTokenByUserFederatedIdentityCredentialSupplier.java @@ -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); diff --git a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/AcquireTokenSilentSupplier.java b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/AcquireTokenSilentSupplier.java index 84ef8071..1071d654 100644 --- a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/AcquireTokenSilentSupplier.java +++ b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/AcquireTokenSilentSupplier.java @@ -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); diff --git a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/AuthorizationCodeParameters.java b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/AuthorizationCodeParameters.java index 8b430080..66b0595f 100644 --- a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/AuthorizationCodeParameters.java +++ b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/AuthorizationCodeParameters.java @@ -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; @@ -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 cacheKeyComponents; + + // Memoized hash of cacheKeyComponents (computed once since parameters are immutable). + private String extCacheKeyHashCache; + private AuthorizationCodeParameters(String authorizationCode, URI redirectUri, Set scopes, ClaimsRequest claims, String codeVerifier, Map extraHttpHeaders, - Map extraQueryParameters, String tenant) { + Map extraQueryParameters, String tenant, + String clientClaims) { this.authorizationCode = authorizationCode; this.redirectUri = redirectUri; this.scopes = scopes; @@ -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() { @@ -104,6 +120,50 @@ public String tenant() { return this.tenant; } + /** + * Client-originated claims set via {@link AuthorizationCodeParametersBuilder#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. + */ + private SortedMap buildCacheKeyComponents() { + TreeMap 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 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; @@ -113,6 +173,7 @@ public static class AuthorizationCodeParametersBuilder { private Map extraHttpHeaders; private Map extraQueryParameters; private String tenant; + private String clientClaims; AuthorizationCodeParametersBuilder() { } @@ -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}. + *

+ * 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 both 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() { diff --git a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/ClientCredentialParameters.java b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/ClientCredentialParameters.java index 3146cb26..103ae1b8 100644 --- a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/ClientCredentialParameters.java +++ b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/ClientCredentialParameters.java @@ -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. @@ -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 scopes, Boolean skipCache, ClaimsRequest claims, Map extraHttpHeaders, Map extraQueryParameters, String tenant, IClientCredential clientCredential, String fmiPath) { + private ClientCredentialParameters(Set scopes, Boolean skipCache, ClaimsRequest claims, Map extraHttpHeaders, Map extraQueryParameters, String tenant, IClientCredential clientCredential, String fmiPath, String clientClaims) { this.scopes = scopes; this.skipCache = skipCache; this.claims = claims; @@ -49,6 +51,7 @@ private ClientCredentialParameters(Set 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(); @@ -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. @@ -127,6 +140,12 @@ private SortedMap 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; } @@ -145,7 +164,8 @@ SortedMap 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; } @@ -162,6 +182,7 @@ public static class ClientCredentialParametersBuilder { private String tenant; private IClientCredential clientCredential; private String fmiPath; + private String clientClaims; ClientCredentialParametersBuilder() { } @@ -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() { diff --git a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/IAcquireTokenParameters.java b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/IAcquireTokenParameters.java index 5c6acb9d..fd92fcaf 100644 --- a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/IAcquireTokenParameters.java +++ b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/IAcquireTokenParameters.java @@ -67,4 +67,30 @@ interface IAcquireTokenParameters { */ @Deprecated Map extraQueryParameters(); + + /** + * Gets the client-originated claims (a raw JSON string) set via the per-request + * {@code claimsFromClient(...)} builder method. + *

+ * 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 ""; + } } diff --git a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/JsonHelper.java b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/JsonHelper.java index d566cfb6..b033acd0 100644 --- a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/JsonHelper.java +++ b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/JsonHelper.java @@ -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() { } @@ -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 clientCapabilities) { if (clientCapabilities == null || clientCapabilities.isEmpty()) { return null; diff --git a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/OnBehalfOfParameters.java b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/OnBehalfOfParameters.java index f954c32b..f328d107 100644 --- a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/OnBehalfOfParameters.java +++ b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/OnBehalfOfParameters.java @@ -5,6 +5,8 @@ import java.util.Map; import java.util.Set; +import java.util.SortedMap; +import java.util.TreeMap; import static com.microsoft.aad.msal4j.ParameterValidationUtils.validateNotNull; @@ -23,8 +25,16 @@ public class OnBehalfOfParameters implements IAcquireTokenParameters { private Map extraHttpHeaders; private Map extraQueryParameters; private String tenant; + private String clientClaims; - private OnBehalfOfParameters(Set scopes, Boolean skipCache, IUserAssertion userAssertion, ClaimsRequest claims, Map extraHttpHeaders, Map extraQueryParameters, String tenant) { + // 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 cacheKeyComponents; + + // Memoized hash of cacheKeyComponents (computed once since parameters are immutable). + private String extCacheKeyHashCache; + + private OnBehalfOfParameters(Set scopes, Boolean skipCache, IUserAssertion userAssertion, ClaimsRequest claims, Map extraHttpHeaders, Map extraQueryParameters, String tenant, String clientClaims) { this.scopes = scopes; //Legacy code that made the public parameter take the Boolean class instead of the primitive, so we need a null check this.skipCache = skipCache != null && skipCache; @@ -33,6 +43,10 @@ private OnBehalfOfParameters(Set scopes, Boolean skipCache, IUserAsserti 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 OnBehalfOfParametersBuilder builder() { @@ -88,6 +102,50 @@ public String tenant() { return this.tenant; } + /** + * Client-originated claims set via {@link OnBehalfOfParametersBuilder#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. + */ + private SortedMap buildCacheKeyComponents() { + TreeMap 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 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 OnBehalfOfParametersBuilder { private Set scopes; private Boolean skipCache; @@ -96,6 +154,7 @@ public static class OnBehalfOfParametersBuilder { private Map extraHttpHeaders; private Map extraQueryParameters; private String tenant; + private String clientClaims; OnBehalfOfParametersBuilder() { } @@ -156,8 +215,31 @@ public OnBehalfOfParametersBuilder 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}. + * + * @param claimsJson a valid JSON object string containing the client claims + * @return this builder instance + */ + public OnBehalfOfParametersBuilder claimsFromClient(String claimsJson) { + if (StringHelper.isBlank(claimsJson)) { + return this; + } + + JsonHelper.validateJsonObjectFormat(claimsJson); + this.clientClaims = claimsJson; + return this; + } + public OnBehalfOfParameters build() { - return new OnBehalfOfParameters(this.scopes, this.skipCache, this.userAssertion, this.claims, this.extraHttpHeaders, this.extraQueryParameters, this.tenant); + return new OnBehalfOfParameters(this.scopes, this.skipCache, this.userAssertion, this.claims, this.extraHttpHeaders, this.extraQueryParameters, this.tenant, this.clientClaims); } public String toString() { diff --git a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/RefreshTokenRequest.java b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/RefreshTokenRequest.java index 3d431d01..c0d3adf7 100644 --- a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/RefreshTokenRequest.java +++ b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/RefreshTokenRequest.java @@ -39,6 +39,10 @@ private static AbstractMsalAuthorizationGrant createAuthenticationGrant(RefreshT return new OAuthAuthorizationGrant(params, parameters.scopes(), parameters.claims()); } + String extCacheKeyHash() { + return parentSilentRequest != null ? parentSilentRequest.extCacheKeyHash() : null; + } + String getFullThumbprint() { StringBuilder sb = new StringBuilder(); diff --git a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/TokenCache.java b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/TokenCache.java index e1c28700..59a7b903 100644 --- a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/TokenCache.java +++ b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/TokenCache.java @@ -339,12 +339,28 @@ private static AccessTokenCacheEntity createAccessTokenCacheEntity(TokenRequestE /** * Computes the extended cache key hash for a request, if applicable. - * Delegates to the generic cache key components on the parameters object. + * Delegates to the generic cache key components exposed by the request's parameters object + * ({@link IAcquireTokenParameters#computeExtCacheKeyHash()}), which covers client credentials, + * managed identity, on-behalf-of, user-FIC and authorization-code redemption. Parameter types + * without extended cache-key components return an empty string via the interface default. * The algorithm uses sorted key-value concatenation → SHA-256 → Base64URL (cross-SDK compatible). */ private static String computeExtCacheKeyHashForRequest(MsalRequest msalRequest) { - if (msalRequest instanceof ClientCredentialRequest) { - return ((ClientCredentialRequest) msalRequest).parameters.computeExtCacheKeyHash(); + // A RefreshTokenRequest inherits the parent silent request's RequestContext, whose + // apiParameters (SilentParameters) carries no client-originated claims and would therefore + // return an empty hash. Prefer the hash threaded onto the parent silent request so a refreshed + // token is written back to the same cache partition as the original (e.g. client_claims / + // user-FIC) instead of leaking into the default partition. + if (msalRequest instanceof RefreshTokenRequest) { + String parentHash = ((RefreshTokenRequest) msalRequest).extCacheKeyHash(); + if (!StringHelper.isBlank(parentHash)) { + return parentHash; + } + } + + IAcquireTokenParameters apiParameters = msalRequest.requestContext().apiParameters(); + if (apiParameters != null) { + return apiParameters.computeExtCacheKeyHash(); } return ""; } @@ -544,11 +560,22 @@ private Optional getAccessTokenCacheEntity( Set scopes, String clientId, Set environmentAliases) { + return getAccessTokenCacheEntity(account, authority, scopes, clientId, environmentAliases, null); + } + + private Optional getAccessTokenCacheEntity( + IAccount account, + Authority authority, + Set scopes, + String clientId, + Set environmentAliases, + String extCacheKeyHash) { return accessTokens.values().stream().filter( accessToken -> accessToken.homeAccountId != null && accessToken.homeAccountId.equals(account.homeAccountId()) && + extCacheKeyHashMatches(accessToken, extCacheKeyHash) && environmentAliases.contains(accessToken.environment) && accessToken.realm.equals(authority.tenant()) && accessToken.clientId.equals(clientId) && @@ -686,6 +713,15 @@ AuthenticationResult getCachedAuthenticationResult( Authority authority, Set scopes, String clientId) { + return getCachedAuthenticationResult(account, authority, scopes, clientId, null); + } + + AuthenticationResult getCachedAuthenticationResult( + IAccount account, + Authority authority, + Set scopes, + String clientId, + String extCacheKeyHash) { AuthenticationResult.AuthenticationResultBuilder builder = AuthenticationResult.builder(); @@ -704,7 +740,7 @@ AuthenticationResult getCachedAuthenticationResult( getAccountCacheEntity(account, environmentAliases); Optional atCacheEntity = - getAccessTokenCacheEntity(account, authority, scopes, clientId, environmentAliases); + getAccessTokenCacheEntity(account, authority, scopes, clientId, environmentAliases, extCacheKeyHash); Optional idTokenCacheEntity = getIdTokenCacheEntity(account, authority, clientId, environmentAliases); diff --git a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/TokenRequestExecutor.java b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/TokenRequestExecutor.java index 3c7e3519..8c3cc62b 100644 --- a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/TokenRequestExecutor.java +++ b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/TokenRequestExecutor.java @@ -63,6 +63,22 @@ OAuthHttpRequest createOauthHttpRequest() throws MalformedURLException { params.put("claims", claimsRequest); } + // Client-originated claims (claimsFromClient) are forwarded on the wire as a standard OAuth + // "claims" parameter. They are merged here, after the capabilities/server-claims merge above, + // because that logic rebuilds the "claims" param and would otherwise clobber an earlier value. + // This single point covers every flow whose parameters expose clientClaims(): the + // confidential-client flows (client credentials, OBO, user-FIC) and the authorization-code + // flow (web-app code redemption, on either a confidential or public client). Flows that do + // not set client claims return null/blank here and are unaffected. + String clientClaims = msalRequest.requestContext().apiParameters().clientClaims(); + if (!StringHelper.isBlank(clientClaims)) { + if (params.get("claims") != null) { + params.put("claims", JsonHelper.mergeJSONString(params.get("claims"), clientClaims)); + } else { + params.put("claims", clientClaims); + } + } + if(msalRequest.requestContext().apiParameters().extraQueryParameters() != null ){ for(String key: msalRequest.requestContext().apiParameters().extraQueryParameters().keySet()){ if(params.containsKey(key)){ diff --git a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/UserFederatedIdentityCredentialParameters.java b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/UserFederatedIdentityCredentialParameters.java index 8a0299dc..f071060e 100644 --- a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/UserFederatedIdentityCredentialParameters.java +++ b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/UserFederatedIdentityCredentialParameters.java @@ -5,6 +5,8 @@ import java.util.Map; import java.util.Set; +import java.util.SortedMap; +import java.util.TreeMap; import java.util.UUID; import static com.microsoft.aad.msal4j.ParameterValidationUtils.validateNotBlank; @@ -29,6 +31,14 @@ public class UserFederatedIdentityCredentialParameters implements IAcquireTokenP private Map extraHttpHeaders; private Map extraQueryParameters; 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 cacheKeyComponents; + + // Memoized hash of cacheKeyComponents (computed once since parameters are immutable). + private String extCacheKeyHashCache; private UserFederatedIdentityCredentialParameters( Set scopes, @@ -39,7 +49,8 @@ private UserFederatedIdentityCredentialParameters( ClaimsRequest claims, Map extraHttpHeaders, Map extraQueryParameters, - String tenant) { + String tenant, + String clientClaims) { this.scopes = scopes; this.username = username; this.userObjectId = userObjectId; @@ -49,6 +60,10 @@ private UserFederatedIdentityCredentialParameters( 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(); } /** @@ -145,6 +160,51 @@ public String tenant() { return this.tenant; } + /** + * Client-originated claims set via + * {@link UserFederatedIdentityCredentialParametersBuilder#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. + */ + private SortedMap buildCacheKeyComponents() { + TreeMap 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 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 UserFederatedIdentityCredentialParametersBuilder { private Set scopes; private String username; @@ -155,6 +215,7 @@ public static class UserFederatedIdentityCredentialParametersBuilder { private Map extraHttpHeaders; private Map extraQueryParameters; private String tenant; + private String clientClaims; UserFederatedIdentityCredentialParametersBuilder() { } @@ -225,11 +286,34 @@ public UserFederatedIdentityCredentialParametersBuilder 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}. + * + * @param claimsJson a valid JSON object string containing the client claims + * @return the builder + */ + public UserFederatedIdentityCredentialParametersBuilder claimsFromClient(String claimsJson) { + if (StringHelper.isBlank(claimsJson)) { + return this; + } + + JsonHelper.validateJsonObjectFormat(claimsJson); + this.clientClaims = claimsJson; + return this; + } + public UserFederatedIdentityCredentialParameters build() { return new UserFederatedIdentityCredentialParameters( this.scopes, this.username, this.userObjectId, this.assertion, this.forceRefresh, this.claims, this.extraHttpHeaders, - this.extraQueryParameters, this.tenant); + this.extraQueryParameters, this.tenant, this.clientClaims); } } } diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/ClientClaimsTest.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/ClientClaimsTest.java new file mode 100644 index 00000000..3fd5f699 --- /dev/null +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/ClientClaimsTest.java @@ -0,0 +1,336 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.aad.msal4j; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; + +import java.net.URI; +import java.util.Collections; +import java.util.HashMap; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +/** + * Tests for the per-request {@code claimsFromClient(String)} API across confidential-client flows + * (client credentials and on-behalf-of). Client-originated claims differ from server-issued + * {@code claims} challenges: they are forwarded on the wire as a standard OAuth {@code claims} + * parameter and cause cache isolation keyed on the claims value (rather than bypassing the cache). + * + * @see FmiTest for the analogous fmi_path cache-isolation tests + */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class ClientClaimsTest { + + private static final String CLAIMS_A = "{\"claimA\":{\"essential\":true}}"; + private static final String CLAIMS_B = "{\"claimB\":{\"values\":[\"v1\"]}}"; + + private ConfidentialClientApplication buildCca(DefaultHttpClient httpClientMock) throws Exception { + return ConfidentialClientApplication.builder("clientId", ClientCredentialFactory.createFromSecret("secret")) + .authority("https://login.microsoftonline.com/tenant/") + .instanceDiscovery(false) + .validateAuthority(false) + .httpClient(httpClientMock) + .build(); + } + + @SafeVarargs + private final DefaultHttpClient mockHttpClient(HashMap... responses) throws Exception { + DefaultHttpClient httpClientMock = mock(DefaultHttpClient.class); + if (responses.length == 0) { + when(httpClientMock.send(any(HttpRequest.class))).thenReturn( + TestHelper.expectedResponse(HttpStatus.HTTP_OK, + TestHelper.getSuccessfulTokenResponse(new HashMap<>()))); + } else { + org.mockito.stubbing.OngoingStubbing stub = + when(httpClientMock.send(any(HttpRequest.class))); + for (HashMap response : responses) { + stub = stub.thenReturn(TestHelper.expectedResponse(HttpStatus.HTTP_OK, + TestHelper.getSuccessfulTokenResponse(response))); + } + } + return httpClientMock; + } + + private HashMap tokenResponse(String accessToken) { + HashMap values = new HashMap<>(); + values.put("access_token", accessToken); + return values; + } + + // ======================================================================== + // JSON object validation (JsonHelper.validateJsonObjectFormat) + // ======================================================================== + + @Test + void validateJsonObjectFormat_acceptsSingleObject_rejectsEverythingElse() { + // Valid single JSON objects pass. + assertDoesNotThrow(() -> JsonHelper.validateJsonObjectFormat(CLAIMS_A)); + assertDoesNotThrow(() -> JsonHelper.validateJsonObjectFormat("{\"xms_az_nwperimid\":{\"essential\":true}}")); + + // Rejected: malformed input, valid-but-non-object JSON (array/scalar), and a valid object + // followed by trailing tokens (e.g. "{}{}") which is not a single well-formed JSON value. + for (String invalid : new String[]{ + "not json at all", "[1,2,3]", "\"justAString\"", + "{\"a\":1} garbage", "{}{}", "{},123"}) { + MsalClientException ex = assertThrows(MsalClientException.class, + () -> JsonHelper.validateJsonObjectFormat(invalid)); + assertEquals(AuthenticationErrorCode.INVALID_JSON, ex.errorCode()); + } + } + + // ======================================================================== + // Builder behavior — blank is a no-op, invalid JSON throws + // ======================================================================== + + @Test + void builders_blankClaims_areNoOp() { + ClientCredentialParameters cc = ClientCredentialParameters + .builder(Collections.singleton("scope")) + .claimsFromClient(" ") + .build(); + assertNull(cc.clientClaims()); + assertEquals("", cc.computeExtCacheKeyHash()); + + OnBehalfOfParameters obo = OnBehalfOfParameters + .builder(Collections.singleton("scope"), new UserAssertion(TestHelper.signedAssertion)) + .claimsFromClient(null) + .build(); + assertNull(obo.clientClaims()); + assertEquals("", obo.computeExtCacheKeyHash()); + + UserFederatedIdentityCredentialParameters fic = UserFederatedIdentityCredentialParameters + .builder(Collections.singleton("scope"), "user@contoso.com", "assertion") + .claimsFromClient(" ") + .build(); + assertNull(fic.clientClaims()); + assertEquals("", fic.computeExtCacheKeyHash()); + + AuthorizationCodeParameters ac = AuthorizationCodeParameters + .builder("code", URI.create("https://localhost/redirect")) + .scopes(Collections.singleton("scope")) + .claimsFromClient(null) + .build(); + assertNull(ac.clientClaims()); + assertEquals("", ac.computeExtCacheKeyHash()); + } + + @Test + void builders_invalidClaims_throwInvalidJson() { + assertEquals(AuthenticationErrorCode.INVALID_JSON, assertThrows(MsalClientException.class, () -> + ClientCredentialParameters.builder(Collections.singleton("scope")).claimsFromClient("nope")).errorCode()); + assertEquals(AuthenticationErrorCode.INVALID_JSON, assertThrows(MsalClientException.class, () -> + OnBehalfOfParameters.builder(Collections.singleton("scope"), new UserAssertion(TestHelper.signedAssertion)).claimsFromClient("[1]")).errorCode()); + assertEquals(AuthenticationErrorCode.INVALID_JSON, assertThrows(MsalClientException.class, () -> + UserFederatedIdentityCredentialParameters.builder(Collections.singleton("scope"), "user@contoso.com", "assertion").claimsFromClient("nope")).errorCode()); + assertEquals(AuthenticationErrorCode.INVALID_JSON, assertThrows(MsalClientException.class, () -> + AuthorizationCodeParameters.builder("code", URI.create("https://localhost/redirect")).claimsFromClient("nope")).errorCode()); + } + + @Test + void builders_invalidClaims_exceptionMessageDoesNotLeakPayload() { + // The claims payload may contain sensitive data and must never appear in the error message. + String secret = "{\"sensitive_secret_value\":"; + MsalClientException ex = assertThrows(MsalClientException.class, () -> + ClientCredentialParameters.builder(Collections.singleton("scope")).claimsFromClient(secret)); + assertFalse(ex.getMessage().contains("sensitive_secret_value")); + } + + @Test + void clientClaims_distinctValues_produceDistinctCacheKeyHashes() { + ClientCredentialParameters a = ClientCredentialParameters + .builder(Collections.singleton("scope")).claimsFromClient(CLAIMS_A).build(); + ClientCredentialParameters b = ClientCredentialParameters + .builder(Collections.singleton("scope")).claimsFromClient(CLAIMS_B).build(); + + assertNotEquals("", a.computeExtCacheKeyHash()); + assertNotEquals(a.computeExtCacheKeyHash(), b.computeExtCacheKeyHash()); + } + + // ======================================================================== + // Client credentials — wire and cache isolation + // ======================================================================== + + @Test + void clientCredential_clientClaims_sentAsClaimsBodyParam() throws Exception { + DefaultHttpClient httpClientMock = mockHttpClient(); + ConfidentialClientApplication cca = buildCca(httpClientMock); + + ClientCredentialParameters parameters = ClientCredentialParameters + .builder(Collections.singleton("https://graph.microsoft.com/.default")) + .claimsFromClient(CLAIMS_A) + .build(); + + cca.acquireToken(parameters).get(); + + verify(httpClientMock).send(argThat(request -> { + String body = request.body(); + return body.contains("claims=") && body.contains("claimA") + && body.contains("grant_type=client_credentials"); + })); + } + + @Test + void clientCredential_serverClaimsAndClientClaims_areMergedOnWire() throws Exception { + DefaultHttpClient httpClientMock = mockHttpClient(); + ConfidentialClientApplication cca = buildCca(httpClientMock); + + ClaimsRequest serverClaims = new ClaimsRequest(); + serverClaims.requestClaimInAccessToken("given_name", new RequestedClaimAdditionalInfo(true, null, null)); + + ClientCredentialParameters parameters = ClientCredentialParameters + .builder(Collections.singleton("scope")) + .claims(serverClaims) + .claimsFromClient(CLAIMS_A) + .build(); + + cca.acquireToken(parameters).get(); + + // Both the server-issued claim (given_name) and the client claim (claimA) appear in the single + // merged OAuth "claims" parameter — client claims do not replace server claims, they merge in. + verify(httpClientMock).send(argThat(request -> { + String body = request.body(); + return body.contains("claims=") && body.contains("given_name") && body.contains("claimA"); + })); + } + + @Test + void clientClaims_overrideServerClaims_onLeafConflict() { + // On a conflicting leaf key the client-supplied value wins; nested objects deep-merge. + // This is the precedence behavior used when claims() and claimsFromClient() overlap. + assertEquals("{\"a\":2}", JsonHelper.mergeJSONString("{\"a\":1}", "{\"a\":2}")); + assertTrue(JsonHelper.mergeJSONString("{\"o\":{\"x\":1}}", "{\"o\":{\"y\":2}}").contains("\"x\":1")); + assertTrue(JsonHelper.mergeJSONString("{\"o\":{\"x\":1}}", "{\"o\":{\"y\":2}}").contains("\"y\":2")); + } + + @Test + void clientCredential_distinctClaims_isolateCache() throws Exception { + DefaultHttpClient httpClientMock = mockHttpClient(tokenResponse("token_A"), tokenResponse("token_B")); + ConfidentialClientApplication cca = buildCca(httpClientMock); + + IAuthenticationResult resultA = cca.acquireToken(ClientCredentialParameters + .builder(Collections.singleton("scope")).claimsFromClient(CLAIMS_A).build()).get(); + IAuthenticationResult resultB = cca.acquireToken(ClientCredentialParameters + .builder(Collections.singleton("scope")).claimsFromClient(CLAIMS_B).build()).get(); + + assertEquals("token_A", resultA.accessToken()); + assertEquals("token_B", resultB.accessToken()); + assertEquals(2, cca.tokenCache.accessTokens.size()); + verify(httpClientMock, times(2)).send(any()); + } + + @Test + void clientCredential_sameClaims_cacheHit() throws Exception { + DefaultHttpClient httpClientMock = mockHttpClient(); + ConfidentialClientApplication cca = buildCca(httpClientMock); + + ClientCredentialParameters params = ClientCredentialParameters + .builder(Collections.singleton("scope")).claimsFromClient(CLAIMS_A).build(); + + IAuthenticationResult result1 = cca.acquireToken(params).get(); + IAuthenticationResult result2 = cca.acquireToken(params).get(); + + assertEquals(result1.accessToken(), result2.accessToken()); + assertEquals(1, cca.tokenCache.accessTokens.size()); + verify(httpClientMock, times(1)).send(any()); + } + + @Test + void clientCredential_claimsDoNotCollideWithNonClaimsTokens() throws Exception { + DefaultHttpClient httpClientMock = mockHttpClient(tokenResponse("regular"), tokenResponse("with_claims")); + ConfidentialClientApplication cca = buildCca(httpClientMock); + + cca.acquireToken(ClientCredentialParameters + .builder(Collections.singleton("scope")).build()).get(); + cca.acquireToken(ClientCredentialParameters + .builder(Collections.singleton("scope")).claimsFromClient(CLAIMS_A).build()).get(); + + assertEquals(2, cca.tokenCache.accessTokens.size()); + verify(httpClientMock, times(2)).send(any()); + } + + // ======================================================================== + // On-behalf-of — wire and cache isolation + // ======================================================================== + + @Test + void onBehalfOf_clientClaims_sentAsClaimsBodyParam() throws Exception { + DefaultHttpClient httpClientMock = mockHttpClient(); + ConfidentialClientApplication cca = buildCca(httpClientMock); + + OnBehalfOfParameters parameters = OnBehalfOfParameters + .builder(Collections.singleton("scope"), new UserAssertion(TestHelper.signedAssertion)) + .claimsFromClient(CLAIMS_A) + .build(); + + cca.acquireToken(parameters).get(); + + verify(httpClientMock).send(argThat(request -> { + String body = request.body(); + return body.contains("claims=") && body.contains("claimA"); + })); + } + + @Test + void onBehalfOf_distinctClaims_isolateCache() throws Exception { + DefaultHttpClient httpClientMock = mockHttpClient(tokenResponse("obo_A"), tokenResponse("obo_B")); + ConfidentialClientApplication cca = buildCca(httpClientMock); + + UserAssertion assertion = new UserAssertion(TestHelper.signedAssertion); + + IAuthenticationResult resultA = cca.acquireToken(OnBehalfOfParameters + .builder(Collections.singleton("scope"), assertion).claimsFromClient(CLAIMS_A).build()).get(); + IAuthenticationResult resultB = cca.acquireToken(OnBehalfOfParameters + .builder(Collections.singleton("scope"), assertion).claimsFromClient(CLAIMS_B).build()).get(); + + assertEquals("obo_A", resultA.accessToken()); + assertEquals("obo_B", resultB.accessToken()); + assertEquals(2, cca.tokenCache.accessTokens.size()); + verify(httpClientMock, times(2)).send(any()); + } + + @Test + void onBehalfOf_sameClaims_cacheHit() throws Exception { + DefaultHttpClient httpClientMock = mockHttpClient(); + ConfidentialClientApplication cca = buildCca(httpClientMock); + + OnBehalfOfParameters params = OnBehalfOfParameters + .builder(Collections.singleton("scope"), new UserAssertion(TestHelper.signedAssertion)) + .claimsFromClient(CLAIMS_A) + .build(); + + IAuthenticationResult result1 = cca.acquireToken(params).get(); + IAuthenticationResult result2 = cca.acquireToken(params).get(); + + assertEquals(result1.accessToken(), result2.accessToken()); + assertEquals(1, cca.tokenCache.accessTokens.size()); + verify(httpClientMock, times(1)).send(any()); + } + + // ======================================================================== + // Authorization code (confidential client / web app) — wire + // ======================================================================== + + @Test + void authorizationCode_clientClaims_sentAsClaimsBodyParam() throws Exception { + DefaultHttpClient httpClientMock = mockHttpClient(); + ConfidentialClientApplication cca = buildCca(httpClientMock); + + AuthorizationCodeParameters parameters = AuthorizationCodeParameters + .builder("auth-code-123", URI.create("https://localhost/redirect")) + .scopes(Collections.singleton("scope")) + .claimsFromClient(CLAIMS_A) + .build(); + + cca.acquireToken(parameters).get(); + + verify(httpClientMock).send(argThat(request -> { + String body = request.body(); + return body.contains("claims=") && body.contains("claimA") + && body.contains("grant_type=authorization_code"); + })); + } +} diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/UserFederatedIdentityCredentialTest.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/UserFederatedIdentityCredentialTest.java index cd5b8bc7..0e48a2ba 100644 --- a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/UserFederatedIdentityCredentialTest.java +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/UserFederatedIdentityCredentialTest.java @@ -616,4 +616,149 @@ void userFic_SingleAccountFallback_DoesNotReturnWrongUserToken() throws Exceptio // Assert: Both accounts now in cache assertEquals(2, cca.getAccounts().get().size(), "Both Alice and Bob should be in cache"); } + + // ======================================================================== + // Client claims (claimsFromClient) — wire and cache isolation + // ======================================================================== + + private static final String CLAIMS_A = "{\"claimA\":{\"essential\":true}}"; + private static final String CLAIMS_B = "{\"claimB\":{\"values\":[\"v1\"]}}"; + + @Test + void userFic_clientClaims_sentAsClaimsBodyParam() throws Exception { + // Arrange + String oid = "oid-user-claims"; + String tid = "f645ad92-e38d-4d1a-b510-d1b09a74a8ca"; + when(httpClientMock.send(any(HttpRequest.class))) + .thenReturn(createUserResponse(oid, TEST_UPN, "access-token", tid)); + + UserFederatedIdentityCredentialParameters parameters = UserFederatedIdentityCredentialParameters + .builder(SCOPES, TEST_UPN, FAKE_ASSERTION) + .claimsFromClient(CLAIMS_A) + .build(); + + // Act + cca.acquireToken(parameters).get(); + + // Assert — client claims are forwarded as a standard OAuth claims body parameter + verify(httpClientMock).send(argThat(request -> { + String body = request.body(); + return body.contains("claims=") && body.contains("claimA"); + })); + } + + @Test + void userFic_distinctClaims_isolateCache() throws Exception { + // Arrange — same user (same OID/UPN/tid); only the client claims differ + String oid = "oid-user-claims"; + String tid = "f645ad92-e38d-4d1a-b510-d1b09a74a8ca"; + AtomicInteger callCount = new AtomicInteger(0); + when(httpClientMock.send(any(HttpRequest.class))).thenAnswer(invocation -> { + int n = callCount.incrementAndGet(); + return createUserResponse(oid, TEST_UPN, "token-" + n, tid); + }); + + // Act 1 — claims A populates the cache + IAuthenticationResult resultA = cca.acquireToken(UserFederatedIdentityCredentialParameters + .builder(SCOPES, TEST_UPN, FAKE_ASSERTION).claimsFromClient(CLAIMS_A).build()).get(); + assertEquals(1, callCount.get(), "First call (claims A) should hit the IdP"); + + // Act 2 — claims B for the same user must NOT reuse the claims-A token + IAuthenticationResult resultB = cca.acquireToken(UserFederatedIdentityCredentialParameters + .builder(SCOPES, TEST_UPN, FAKE_ASSERTION).claimsFromClient(CLAIMS_B).build()).get(); + + // Assert — distinct claims produce distinct cache entries and a fresh network call + assertEquals(2, callCount.get(), "Second call (claims B) should hit the IdP, not reuse claims-A token"); + assertNotEquals(resultA.accessToken(), resultB.accessToken()); + assertEquals(2, cca.tokenCache.accessTokens.size(), "Distinct claims should yield two cached access tokens"); + } + + @Test + void userFic_sameClaims_cacheHit() throws Exception { + // Arrange — same user, same client claims on both calls + String oid = "oid-user-claims"; + String tid = "f645ad92-e38d-4d1a-b510-d1b09a74a8ca"; + AtomicInteger callCount = new AtomicInteger(0); + when(httpClientMock.send(any(HttpRequest.class))).thenAnswer(invocation -> { + callCount.incrementAndGet(); + return createUserResponse(oid, TEST_UPN, "token-cached", tid); + }); + + UserFederatedIdentityCredentialParameters params = UserFederatedIdentityCredentialParameters + .builder(SCOPES, TEST_UPN, FAKE_ASSERTION).claimsFromClient(CLAIMS_A).build(); + + // Act + cca.acquireToken(params).get(); + assertEquals(1, callCount.get(), "First call should hit the IdP"); + cca.acquireToken(params).get(); + + // Assert — identical claims hit the cache (no second network call) + assertEquals(1, callCount.get(), "Second call with identical claims should be served from cache"); + assertEquals(1, cca.tokenCache.accessTokens.size()); + } + + @Test + void userFic_refreshedToken_staysInClientClaimsPartition() throws Exception { + // Regression: a refreshed access token must be written back to the SAME ext-cache-key + // partition as the original. A RefreshTokenRequest inherits the silent request's + // SilentParameters, which carry no client claims, so unless the parent silent request's ext + // hash is threaded through, the refreshed token leaks into the default partition (leaving two + // cache entries and breaking isolation). + String oid = "oid-user-claims"; + String tid = "f645ad92-e38d-4d1a-b510-d1b09a74a8ca"; + AtomicInteger callCount = new AtomicInteger(0); + when(httpClientMock.send(any(HttpRequest.class))).thenAnswer(invocation -> { + int n = callCount.incrementAndGet(); + // First token is already due for refresh (expires within the refresh buffer); the + // refresh response is long-lived. + return n == 1 + ? createUserResponseWithExpiry(oid, TEST_UPN, "at-original", tid, 1) + : createUserResponseWithExpiry(oid, TEST_UPN, "at-refreshed", tid, 3600); + }); + + UserFederatedIdentityCredentialParameters params = UserFederatedIdentityCredentialParameters + .builder(SCOPES, TEST_UPN, FAKE_ASSERTION).claimsFromClient(CLAIMS_A).build(); + + // Act 1 — populate the cache with an (already-expiring) client-claims token + refresh token + cca.acquireToken(params).get(); + assertEquals(1, callCount.get()); + + // Act 2 — same claims; the expired access token forces a refresh-token request + cca.acquireToken(params).get(); + assertEquals(2, callCount.get(), "Expired access token should trigger a refresh"); + + // Assert — the refreshed token replaced the original in the same partition; it did not leak + // into the default partition (which would leave two cached access tokens). + String expectedHash = UserFederatedIdentityCredentialParameters + .builder(SCOPES, TEST_UPN, FAKE_ASSERTION).claimsFromClient(CLAIMS_A).build() + .computeExtCacheKeyHash(); + assertEquals(1, cca.tokenCache.accessTokens.size(), + "Refreshed token must stay in the client-claims partition, not leak into the default one"); + AccessTokenCacheEntity refreshed = cca.tokenCache.accessTokens.values().iterator().next(); + assertEquals(expectedHash, refreshed.extCacheKeyHash(), + "Refreshed access token must retain the client-claims ext-cache-key hash"); + assertEquals("at-refreshed", refreshed.secret(), "Cache should hold the refreshed access token"); + } + + private HttpResponse createUserResponseWithExpiry(String oid, String preferredUsername, + String accessToken, String tid, long expiresInSeconds) { + String clientInfoJson = String.format("{\"uid\":\"%s\",\"utid\":\"%s\"}", oid, tid); + String clientInfo = Base64.getUrlEncoder().withoutPadding() + .encodeToString(clientInfoJson.getBytes(StandardCharsets.UTF_8)); + + HashMap idTokenValues = new HashMap<>(); + idTokenValues.put("oid", oid); + idTokenValues.put("preferred_username", preferredUsername); + idTokenValues.put("tid", tid); + String idToken = TestHelper.createIdToken(idTokenValues); + + HashMap responseValues = new HashMap<>(); + responseValues.put("access_token", accessToken); + responseValues.put("id_token", idToken); + responseValues.put("client_info", clientInfo); + responseValues.put("expires_in", Long.toString(expiresInSeconds)); + + return TestHelper.expectedResponse(HttpStatus.HTTP_OK, + TestHelper.getSuccessfulTokenResponse(responseValues)); + } }