contextAwareAssertionProvider) {
+ if (contextAwareAssertionProvider == null) {
+ throw new NullPointerException("contextAwareAssertionProvider");
+ }
+
+ this.assertion = null;
+ this.assertionProvider = null;
+ this.contextAwareAssertionProvider = contextAwareAssertionProvider;
}
/**
* Gets the JWT assertion for client authentication.
- * If this ClientAssertion was created with a Callable, the callable will be
- * invoked each time this method is called to generate a fresh assertion.
+ *
+ * Dispatch logic:
+ *
+ * - Context-aware provider: delegates to {@link #assertion(AssertionRequestOptions)} with empty context
+ * - Callable provider: invokes the callable to generate a fresh assertion
+ * - Static string: returns the stored assertion directly
+ *
*
* @return A JWT assertion string
* @throws MsalClientException if the assertion provider returns null/empty or throws an exception
*/
public String assertion() {
+ if (contextAwareAssertionProvider != null) {
+ return assertion(new AssertionRequestOptions(null, null, null));
+ }
+
if (assertionProvider != null) {
+ return invokeCallable();
+ }
+
+ return this.assertion;
+ }
+
+ /**
+ * Gets the JWT assertion for client authentication with context information.
+ *
+ * Dispatch logic:
+ *
+ * - Context-aware provider: invokes the Function with the provided options
+ * - Callable or static: context is ignored, falls back to {@link #assertion()}
+ *
+ *
+ * This method is the primary entry point used by {@code TokenRequestExecutor} when
+ * building token requests, as it can pass FMI path and token endpoint context.
+ *
+ * @param options context information for the assertion request (may contain nulls for non-FMI flows)
+ * @return A JWT assertion string
+ * @throws MsalClientException if the assertion provider returns null/empty or throws an exception
+ */
+ String assertion(AssertionRequestOptions options) {
+ if (contextAwareAssertionProvider != null) {
try {
- String generatedAssertion = assertionProvider.call();
+ String generatedAssertion = contextAwareAssertionProvider.apply(options);
if (StringHelper.isBlank(generatedAssertion)) {
throw new MsalClientException(
@@ -69,7 +124,33 @@ public String assertion() {
}
}
- return this.assertion;
+ // Fall back to non-context-aware assertion
+ return assertion();
+ }
+
+ /**
+ * Returns true if this assertion uses a context-aware provider.
+ */
+ boolean isContextAware() {
+ return contextAwareAssertionProvider != null;
+ }
+
+ private String invokeCallable() {
+ try {
+ String generatedAssertion = assertionProvider.call();
+
+ if (StringHelper.isBlank(generatedAssertion)) {
+ throw new MsalClientException(
+ "Assertion provider returned null or empty assertion",
+ AuthenticationErrorCode.INVALID_JWT);
+ }
+
+ return generatedAssertion;
+ } catch (MsalClientException ex) {
+ throw ex;
+ } catch (Exception ex) {
+ throw new MsalClientException(ex);
+ }
}
//These methods are based on those generated by Lombok's @EqualsAndHashCode annotation.
@@ -81,6 +162,11 @@ public boolean equals(Object o) {
ClientAssertion other = (ClientAssertion) o;
+ // For context-aware providers, we consider them equal if they're the same object
+ if (this.contextAwareAssertionProvider != null && other.contextAwareAssertionProvider != null) {
+ return this.contextAwareAssertionProvider == other.contextAwareAssertionProvider;
+ }
+
// For assertion providers, we consider them equal if they're the same object
if (this.assertionProvider != null && other.assertionProvider != null) {
return this.assertionProvider == other.assertionProvider;
@@ -92,6 +178,11 @@ public boolean equals(Object o) {
@Override
public int hashCode() {
+ // For context-aware providers, use the provider's identity hash code
+ if (contextAwareAssertionProvider != null) {
+ return System.identityHashCode(contextAwareAssertionProvider);
+ }
+
// For assertion providers, use the provider's identity hash code
if (assertionProvider != null) {
return System.identityHashCode(assertionProvider);
diff --git a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/ClientCredentialFactory.java b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/ClientCredentialFactory.java
index 5f3c34cc..43fbf8b5 100644
--- a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/ClientCredentialFactory.java
+++ b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/ClientCredentialFactory.java
@@ -14,6 +14,7 @@
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
+import java.util.function.Function;
import static com.microsoft.aad.msal4j.ParameterValidationUtils.validateNotNull;
@@ -105,4 +106,23 @@ public static IClientAssertion createFromCallback(Callable callable) {
return new ClientAssertion(callable);
}
+
+ /**
+ * Static method to create a {@link ClientAssertion} instance from a provided Function that
+ * receives {@link AssertionRequestOptions} context. The function will be invoked each time
+ * the assertion is needed, allowing for dynamic generation of assertions based on the
+ * request context (such as the FMI path in agent identity scenarios).
+ *
+ * @param assertionProvider Function that receives {@link AssertionRequestOptions} and produces
+ * a JWT token encoded as a base64 URL encoded string
+ * @return {@link ClientAssertion} that will invoke the function each time assertion() is called
+ * @throws NullPointerException if assertionProvider is null
+ */
+ public static IClientAssertion createFromCallback(Function assertionProvider) {
+ if (assertionProvider == null) {
+ throw new NullPointerException("assertionProvider");
+ }
+
+ return new ClientAssertion(assertionProvider);
+ }
}
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 c6168dfe..3146cb26 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
@@ -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;
@@ -28,7 +30,17 @@ public class ClientCredentialParameters implements IAcquireTokenParameters {
private IClientCredential clientCredential;
- private ClientCredentialParameters(Set scopes, Boolean skipCache, ClaimsRequest claims, Map extraHttpHeaders, Map extraQueryParameters, String tenant, IClientCredential clientCredential) {
+ private String fmiPath;
+
+ // 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.
+ private SortedMap cacheKeyComponents;
+
+ // 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) {
this.scopes = scopes;
this.skipCache = skipCache;
this.claims = claims;
@@ -36,6 +48,10 @@ private ClientCredentialParameters(Set scopes, Boolean skipCache, Claims
this.extraQueryParameters = extraQueryParameters;
this.tenant = tenant;
this.clientCredential = clientCredential;
+ this.fmiPath = fmiPath;
+
+ // Build cache key components from any parameters that require cache isolation.
+ this.cacheKeyComponents = buildCacheKeyComponents();
}
private static ClientCredentialParametersBuilder builder() {
@@ -87,6 +103,56 @@ public IClientCredential clientCredential() {
return this.clientCredential;
}
+ /**
+ * Gets the FMI (Federated Managed Identity) path for agent identity scenarios.
+ * When set, {@code fmi_path} is sent as a body parameter in the client credentials token request,
+ * which scopes the resulting token to a specific agent identity.
+ *
+ * @return the FMI path, or null if not set
+ */
+ public String fmiPath() {
+ return this.fmiPath;
+ }
+
+ /**
+ * Builds the sorted map of cache key components from the parameters that require
+ * cache isolation. Returns null if no components are present.
+ *
+ * This is the single place where parameters contribute to the extended cache key.
+ * To add a new cache key component, add an entry here.
+ */
+ private SortedMap buildCacheKeyComponents() {
+ TreeMap components = null;
+ if (!StringHelper.isBlank(fmiPath)) {
+ components = new TreeMap<>();
+ components.put("fmi_path", fmiPath);
+ }
+ 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.
+ * Returns an empty string if no components are present.
+ *
+ * The result is memoized since ClientCredentialParameters is immutable after construction.
+ * Used by both cache writes ({@link TokenCache}) and cache reads (silent lookup).
+ */
+ String computeExtCacheKeyHash() {
+ if (extCacheKeyHashCache != null) {
+ return extCacheKeyHashCache;
+ }
+ extCacheKeyHashCache = StringHelper.computeExtCacheKeyHash(cacheKeyComponents);
+ return extCacheKeyHashCache;
+ }
+
public static class ClientCredentialParametersBuilder {
private Set scopes;
private Boolean skipCache = false;
@@ -95,6 +161,7 @@ public static class ClientCredentialParametersBuilder {
private Map extraQueryParameters;
private String tenant;
private IClientCredential clientCredential;
+ private String fmiPath;
ClientCredentialParametersBuilder() {
}
@@ -162,12 +229,28 @@ public ClientCredentialParametersBuilder clientCredential(IClientCredential clie
return this;
}
+ /**
+ * Sets the FMI (Federated Managed Identity) path for agent identity scenarios.
+ * When set, {@code fmi_path} is sent as a body parameter in the client credentials token request,
+ * which tells Entra ID to scope the resulting token to a specific agent identity.
+ * The token is also cached with an extended cache key to prevent collisions between
+ * tokens for different agent identities.
+ *
+ * @param fmiPath the FMI path value (typically the agent application ID)
+ * @return builder that can be used to construct ClientCredentialParameters
+ */
+ public ClientCredentialParametersBuilder fmiPath(String fmiPath) {
+ ParameterValidationUtils.validateNotBlank("fmiPath", fmiPath);
+ this.fmiPath = fmiPath;
+ return this;
+ }
+
public ClientCredentialParameters build() {
- return new ClientCredentialParameters(this.scopes, this.skipCache, this.claims, this.extraHttpHeaders, this.extraQueryParameters, this.tenant, this.clientCredential);
+ return new ClientCredentialParameters(this.scopes, this.skipCache, this.claims, this.extraHttpHeaders, this.extraQueryParameters, this.tenant, this.clientCredential, this.fmiPath);
}
public String toString() {
- return "ClientCredentialParameters.ClientCredentialParametersBuilder(scopes=" + this.scopes + ", skipCache=" + this.skipCache + ", claims=" + this.claims + ", extraHttpHeaders=" + this.extraHttpHeaders + ", extraQueryParameters=" + this.extraQueryParameters + ", tenant=" + this.tenant + ", clientCredential=" + this.clientCredential + ")";
+ return "ClientCredentialParameters.ClientCredentialParametersBuilder(scopes=" + this.scopes + ", skipCache=" + this.skipCache + ", claims=" + this.claims + ", extraHttpHeaders=" + this.extraHttpHeaders + ", extraQueryParameters=" + this.extraQueryParameters + ", tenant=" + this.tenant + ", clientCredential=" + this.clientCredential + ", fmiPath=" + this.fmiPath + ")";
}
}
}
diff --git a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/ClientCredentialRequest.java b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/ClientCredentialRequest.java
index 6c9bdb03..8d60f82b 100644
--- a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/ClientCredentialRequest.java
+++ b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/ClientCredentialRequest.java
@@ -30,6 +30,10 @@ private static OAuthAuthorizationGrant createMsalGrant(ClientCredentialParameter
params.put(GrantConstants.GRANT_TYPE_PARAMETER, GrantConstants.CLIENT_CREDENTIALS);
+ if (!StringHelper.isBlank(parameters.fmiPath())) {
+ params.put("fmi_path", parameters.fmiPath());
+ }
+
return new OAuthAuthorizationGrant(params, parameters.scopes(), parameters.claims());
}
}
diff --git a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/CredentialTypeEnum.java b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/CredentialTypeEnum.java
index 12f9b016..c7bf2f29 100644
--- a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/CredentialTypeEnum.java
+++ b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/CredentialTypeEnum.java
@@ -6,6 +6,7 @@
enum CredentialTypeEnum {
ACCESS_TOKEN("AccessToken"),
+ ACCESS_TOKEN_EXTENDED("AText"),
REFRESH_TOKEN("RefreshToken"),
ID_TOKEN("IdToken");
diff --git a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/SilentRequest.java b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/SilentRequest.java
index 93d19f37..2d61d05e 100644
--- a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/SilentRequest.java
+++ b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/SilentRequest.java
@@ -11,6 +11,7 @@ class SilentRequest extends MsalRequest {
private SilentParameters parameters;
private IUserAssertion assertion;
private Authority requestAuthority;
+ private String extCacheKeyHash;
SilentRequest(SilentParameters parameters,
AbstractApplicationBase application,
@@ -42,4 +43,12 @@ IUserAssertion assertion() {
Authority requestAuthority() {
return this.requestAuthority;
}
+
+ String extCacheKeyHash() {
+ return this.extCacheKeyHash;
+ }
+
+ void extCacheKeyHash(String extCacheKeyHash) {
+ this.extCacheKeyHash = extCacheKeyHash;
+ }
}
diff --git a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/StringHelper.java b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/StringHelper.java
index d8b2d9e7..2deeb682 100644
--- a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/StringHelper.java
+++ b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/StringHelper.java
@@ -81,6 +81,28 @@ static boolean isNullOrBlank(final String str) {
return str == null || str.trim().isEmpty();
}
+ /**
+ * Computes an extended cache key hash from a sorted map of key-value components.
+ * Concatenates sorted key+value pairs, SHA-256 hashes, then Base64URL encodes without padding.
+ * This algorithm is cross-SDK compatible (same output for the same inputs in all MSAL SDKs).
+ *
+ * @param cacheKeyComponents a sorted map of component names to values
+ * @return Base64URL-encoded SHA-256 hash, or empty string if the map is null/empty
+ */
+ static String computeExtCacheKeyHash(SortedMap cacheKeyComponents) {
+ if (cacheKeyComponents == null || cacheKeyComponents.isEmpty()) {
+ return "";
+ }
+
+ StringBuilder sb = new StringBuilder();
+ for (Map.Entry entry : cacheKeyComponents.entrySet()) {
+ sb.append(entry.getKey());
+ sb.append(entry.getValue());
+ }
+
+ return createBase64EncodedSha256Hash(sb.toString());
+ }
+
//Converts a map of parameters into a URL query string
static String serializeQueryParameters(Map params) {
if (params != null && !params.isEmpty()) {
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 c54f75cf..e1c28700 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
@@ -295,7 +295,16 @@ private static AccessTokenCacheEntity createAccessTokenCacheEntity(TokenRequestE
AuthenticationResult authenticationResult,
String environmentAlias) {
AccessTokenCacheEntity at = new AccessTokenCacheEntity();
- at.credentialType(CredentialTypeEnum.ACCESS_TOKEN.value());
+
+ // Determine if extended cache key is needed (e.g., for fmi_path in agent identity scenarios)
+ String extCacheKeyHash = computeExtCacheKeyHashForRequest(tokenRequestExecutor.getMsalRequest());
+
+ if (!StringHelper.isBlank(extCacheKeyHash)) {
+ at.credentialType(CredentialTypeEnum.ACCESS_TOKEN_EXTENDED.value());
+ at.extCacheKeyHash(extCacheKeyHash);
+ } else {
+ at.credentialType(CredentialTypeEnum.ACCESS_TOKEN.value());
+ }
if (authenticationResult.account() != null) {
at.homeAccountId(authenticationResult.account().homeAccountId());
@@ -328,6 +337,18 @@ private static AccessTokenCacheEntity createAccessTokenCacheEntity(TokenRequestE
return at;
}
+ /**
+ * Computes the extended cache key hash for a request, if applicable.
+ * Delegates to the generic cache key components on the parameters object.
+ * 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();
+ }
+ return "";
+ }
+
private static IdTokenCacheEntity createIdTokenCacheEntity(TokenRequestExecutor tokenRequestExecutor,
AuthenticationResult authenticationResult,
String environmentAlias) {
@@ -541,11 +562,22 @@ private Optional getApplicationAccessTokenCacheEntity(
String clientId,
Set environmentAliases,
String userAssertionHash) {
+ return getApplicationAccessTokenCacheEntity(authority, scopes, clientId, environmentAliases, userAssertionHash, null);
+ }
+
+ private Optional getApplicationAccessTokenCacheEntity(
+ Authority authority,
+ Set scopes,
+ String clientId,
+ Set environmentAliases,
+ String userAssertionHash,
+ String extCacheKeyHash) {
long currTimeStampSec = new Date().getTime() / 1000;
return accessTokens.values().stream().filter(
accessToken ->
userAssertionHashMatches(accessToken, userAssertionHash) &&
+ extCacheKeyHashMatches(accessToken, extCacheKeyHash) &&
environmentAliases.contains(accessToken.environment) &&
Long.parseLong(accessToken.expiresOn()) > currTimeStampSec + MIN_ACCESS_TOKEN_EXPIRE_IN_SEC &&
accessToken.realm.equals(authority.tenant()) &&
@@ -554,6 +586,15 @@ private Optional getApplicationAccessTokenCacheEntity(
.findAny();
}
+ private boolean extCacheKeyHashMatches(AccessTokenCacheEntity accessToken, String expectedHash) {
+ String cachedHash = accessToken.extCacheKeyHash();
+ if (StringHelper.isBlank(expectedHash)) {
+ // When no fmi_path/ext key is expected, only match tokens that also have no ext key
+ return StringHelper.isBlank(cachedHash);
+ }
+ return expectedHash.equals(cachedHash);
+ }
+
private Optional getIdTokenCacheEntity(
IAccount account,
@@ -709,6 +750,15 @@ AuthenticationResult getCachedAuthenticationResult(
Set scopes,
String clientId,
IUserAssertion assertion) {
+ return getCachedAuthenticationResult(authority, scopes, clientId, assertion, null);
+ }
+
+ AuthenticationResult getCachedAuthenticationResult(
+ Authority authority,
+ Set scopes,
+ String clientId,
+ IUserAssertion assertion,
+ String extCacheKeyHash) {
AuthenticationResult.AuthenticationResultBuilder builder = AuthenticationResult.builder();
@@ -731,7 +781,7 @@ AuthenticationResult getCachedAuthenticationResult(
accountCacheEntity.ifPresent(builder::accountCacheEntity);
Optional atCacheEntity =
- getApplicationAccessTokenCacheEntity(authority, scopes, clientId, environmentAliases, userAssertionHash);
+ getApplicationAccessTokenCacheEntity(authority, scopes, clientId, environmentAliases, userAssertionHash, extCacheKeyHash);
if (atCacheEntity.isPresent()) {
builder.
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 c6e7ca56..3c7e3519 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
@@ -137,7 +137,29 @@ private void addCredentialToRequest(Map queryParameters,
queryParameters.put("client_secret", ((ClientSecret) credentialToUse).clientSecret());
} else if (credentialToUse instanceof ClientAssertion) {
// For client assertion, add client_assertion and client_assertion_type parameters
- addJWTBearerAssertionParams(queryParameters, ((ClientAssertion) credentialToUse).assertion());
+ ClientAssertion clientAssertion = (ClientAssertion) credentialToUse;
+ if (clientAssertion.isContextAware()) {
+ // Build assertion context with client assertion FMI path if available
+ String clientAssertionFmiPath = null;
+ if (msalRequest instanceof ClientCredentialRequest) {
+ clientAssertionFmiPath = ((ClientCredentialRequest) msalRequest).parameters.fmiPath();
+ }
+ String tokenEndpoint = null;
+ try {
+ tokenEndpoint = authorityToUse.tokenEndpointUrl() != null
+ ? authorityToUse.tokenEndpointUrl().toString() : null;
+ } catch (MalformedURLException e) {
+ LOG.warn("Could not resolve token endpoint URL for assertion context: {}", e.getMessage());
+ }
+ AssertionRequestOptions options = new AssertionRequestOptions(
+ application.clientId(),
+ tokenEndpoint,
+ clientAssertionFmiPath);
+
+ addJWTBearerAssertionParams(queryParameters, clientAssertion.assertion(options));
+ } else {
+ addJWTBearerAssertionParams(queryParameters, clientAssertion.assertion());
+ }
} else if (credentialToUse instanceof ClientCertificate) {
// For client certificate, generate a new assertion and add it to the request
ClientCertificate certificate = (ClientCertificate) credentialToUse;
diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/FmiTest.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/FmiTest.java
new file mode 100644
index 00000000..6ef00adf
--- /dev/null
+++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/FmiTest.java
@@ -0,0 +1,577 @@
+// 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.util.Collections;
+import java.util.HashMap;
+import java.util.TreeMap;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.function.Function;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.*;
+
+/**
+ * Tests for FMI (Federated Managed Identity) support in client credential flows.
+ * Covers fmi_path body parameter injection, cache key isolation via ext_cache_key,
+ * and assertion context (AssertionRequestOptions) propagation.
+ */
+@TestInstance(TestInstance.Lifecycle.PER_CLASS)
+class FmiTest {
+
+ // ========================================================================
+ // fmi_path body parameter
+ // ========================================================================
+
+ @Test
+ void fmiPath_IncludedInTokenRequestBody() throws Exception {
+ // Arrange
+ DefaultHttpClient httpClientMock = mock(DefaultHttpClient.class);
+
+ when(httpClientMock.send(any(HttpRequest.class))).thenReturn(
+ TestHelper.expectedResponse(HttpStatus.HTTP_OK,
+ TestHelper.getSuccessfulTokenResponse(new HashMap<>())));
+
+ ConfidentialClientApplication cca =
+ ConfidentialClientApplication.builder("clientId", ClientCredentialFactory.createFromSecret("secret"))
+ .authority("https://login.microsoftonline.com/tenant/")
+ .aadInstanceDiscoveryResponse(TestHelper.getInstanceDiscoveryResponse())
+ .httpClient(httpClientMock)
+ .build();
+
+ ClientCredentialParameters parameters = ClientCredentialParameters
+ .builder(Collections.singleton("api://AzureADTokenExchange/.default"))
+ .fmiPath("agentAppId123")
+ .build();
+
+ // Act
+ cca.acquireToken(parameters).get();
+
+ // Assert — verify the HTTP request body contains fmi_path
+ verify(httpClientMock).send(argThat(request -> {
+ String body = request.body();
+ return body.contains("fmi_path=agentAppId123")
+ && body.contains("grant_type=client_credentials");
+ }));
+ }
+
+ @Test
+ void fmiPath_NotIncludedWhenNull() throws Exception {
+ // Arrange
+ DefaultHttpClient httpClientMock = mock(DefaultHttpClient.class);
+
+ when(httpClientMock.send(any(HttpRequest.class))).thenReturn(
+ TestHelper.expectedResponse(HttpStatus.HTTP_OK,
+ TestHelper.getSuccessfulTokenResponse(new HashMap<>())));
+
+ ConfidentialClientApplication cca =
+ ConfidentialClientApplication.builder("clientId", ClientCredentialFactory.createFromSecret("secret"))
+ .authority("https://login.microsoftonline.com/tenant/")
+ .aadInstanceDiscoveryResponse(TestHelper.getInstanceDiscoveryResponse())
+ .httpClient(httpClientMock)
+ .build();
+
+ ClientCredentialParameters parameters = ClientCredentialParameters
+ .builder(Collections.singleton("scopes"))
+ .build();
+
+ // Act
+ cca.acquireToken(parameters).get();
+
+ // Assert — verify the HTTP request body does NOT contain fmi_path
+ verify(httpClientMock).send(argThat(request -> {
+ String body = request.body();
+ return !body.contains("fmi_path")
+ && body.contains("grant_type=client_credentials");
+ }));
+ }
+
+ // ========================================================================
+ // Cache key isolation (ext_cache_key)
+ // ========================================================================
+
+ @Test
+ void fmiPath_ExtendedCacheKeyIsolation() throws Exception {
+ // Arrange
+ DefaultHttpClient httpClientMock = mock(DefaultHttpClient.class);
+
+ HashMap responseA = new HashMap<>();
+ responseA.put("access_token", "token_for_agentA");
+
+ HashMap responseB = new HashMap<>();
+ responseB.put("access_token", "token_for_agentB");
+
+ when(httpClientMock.send(any(HttpRequest.class)))
+ .thenReturn(TestHelper.expectedResponse(HttpStatus.HTTP_OK,
+ TestHelper.getSuccessfulTokenResponse(responseA)))
+ .thenReturn(TestHelper.expectedResponse(HttpStatus.HTTP_OK,
+ TestHelper.getSuccessfulTokenResponse(responseB)));
+
+ ConfidentialClientApplication cca =
+ ConfidentialClientApplication.builder("clientId", ClientCredentialFactory.createFromSecret("secret"))
+ .authority("https://login.microsoftonline.com/tenant/")
+ .aadInstanceDiscoveryResponse(TestHelper.getInstanceDiscoveryResponse())
+ .httpClient(httpClientMock)
+ .build();
+
+ // Act — acquire tokens for two different fmi_paths with the same scopes
+ ClientCredentialParameters paramsA = ClientCredentialParameters
+ .builder(Collections.singleton("api://AzureADTokenExchange/.default"))
+ .fmiPath("agentA")
+ .build();
+ IAuthenticationResult resultA = cca.acquireToken(paramsA).get();
+
+ ClientCredentialParameters paramsB = ClientCredentialParameters
+ .builder(Collections.singleton("api://AzureADTokenExchange/.default"))
+ .fmiPath("agentB")
+ .build();
+ IAuthenticationResult resultB = cca.acquireToken(paramsB).get();
+
+ // Assert — two different tokens should be in cache (not the same cached entry)
+ assertEquals("token_for_agentA", resultA.accessToken());
+ assertEquals("token_for_agentB", resultB.accessToken());
+ assertNotEquals(resultA.accessToken(), resultB.accessToken());
+ assertEquals(2, cca.tokenCache.accessTokens.size());
+ // Both HTTP calls should have been made (no cache hit for different fmi_paths)
+ verify(httpClientMock, times(2)).send(any());
+ }
+
+ @Test
+ void fmiPath_CacheHitForSameFmiPath() throws Exception {
+ // Arrange
+ DefaultHttpClient httpClientMock = mock(DefaultHttpClient.class);
+
+ when(httpClientMock.send(any(HttpRequest.class))).thenReturn(
+ TestHelper.expectedResponse(HttpStatus.HTTP_OK,
+ TestHelper.getSuccessfulTokenResponse(new HashMap<>())));
+
+ ConfidentialClientApplication cca =
+ ConfidentialClientApplication.builder("clientId", ClientCredentialFactory.createFromSecret("secret"))
+ .authority("https://login.microsoftonline.com/tenant/")
+ .aadInstanceDiscoveryResponse(TestHelper.getInstanceDiscoveryResponse())
+ .httpClient(httpClientMock)
+ .build();
+
+ ClientCredentialParameters params = ClientCredentialParameters
+ .builder(Collections.singleton("api://AzureADTokenExchange/.default"))
+ .fmiPath("agentA")
+ .build();
+
+ // Act — acquire same fmi_path token twice
+ IAuthenticationResult result1 = cca.acquireToken(params).get();
+ IAuthenticationResult result2 = cca.acquireToken(params).get();
+
+ // Assert — should be a cache hit: only one HTTP call
+ assertEquals(result1.accessToken(), result2.accessToken());
+ assertEquals(1, cca.tokenCache.accessTokens.size());
+ verify(httpClientMock, times(1)).send(any());
+ }
+
+ @Test
+ void fmiPath_CacheDoesNotCollideWithNonFmiTokens() throws Exception {
+ // Arrange
+ DefaultHttpClient httpClientMock = mock(DefaultHttpClient.class);
+
+ HashMap responseNoFmi = new HashMap<>();
+ responseNoFmi.put("access_token", "regular_token");
+
+ HashMap responseFmi = new HashMap<>();
+ responseFmi.put("access_token", "fmi_token");
+
+ when(httpClientMock.send(any(HttpRequest.class)))
+ .thenReturn(TestHelper.expectedResponse(HttpStatus.HTTP_OK,
+ TestHelper.getSuccessfulTokenResponse(responseNoFmi)))
+ .thenReturn(TestHelper.expectedResponse(HttpStatus.HTTP_OK,
+ TestHelper.getSuccessfulTokenResponse(responseFmi)));
+
+ ConfidentialClientApplication cca =
+ ConfidentialClientApplication.builder("clientId", ClientCredentialFactory.createFromSecret("secret"))
+ .authority("https://login.microsoftonline.com/tenant/")
+ .aadInstanceDiscoveryResponse(TestHelper.getInstanceDiscoveryResponse())
+ .httpClient(httpClientMock)
+ .build();
+
+ // Act — acquire without fmi_path, then with fmi_path (same scopes)
+ ClientCredentialParameters regularParams = ClientCredentialParameters
+ .builder(Collections.singleton("api://AzureADTokenExchange/.default"))
+ .build();
+ IAuthenticationResult regularResult = cca.acquireToken(regularParams).get();
+
+ ClientCredentialParameters fmiParams = ClientCredentialParameters
+ .builder(Collections.singleton("api://AzureADTokenExchange/.default"))
+ .fmiPath("agentA")
+ .build();
+ IAuthenticationResult fmiResult = cca.acquireToken(fmiParams).get();
+
+ // Assert — both tokens should be in cache (different cache keys)
+ assertEquals("regular_token", regularResult.accessToken());
+ assertEquals("fmi_token", fmiResult.accessToken());
+ assertEquals(2, cca.tokenCache.accessTokens.size());
+ verify(httpClientMock, times(2)).send(any());
+ }
+
+ // ========================================================================
+ // ext_cache_key hash computation
+ // ========================================================================
+
+ @Test
+ void computeExtCacheKeyHash_EmptyMapReturnsEmpty() {
+ assertEquals("", StringHelper.computeExtCacheKeyHash(new TreeMap<>()));
+ assertEquals("", StringHelper.computeExtCacheKeyHash(null));
+ }
+
+ // ========================================================================
+ // Assertion context — AssertionRequestOptions
+ // ========================================================================
+
+ @Test
+ void assertionContext_FmiPathPassedToContextAwareCallback() throws Exception {
+ // Arrange
+ AtomicReference capturedOptions = new AtomicReference<>();
+
+ Function assertionProvider = options -> {
+ capturedOptions.set(options);
+ return TestHelper.signedAssertion;
+ };
+
+ DefaultHttpClient httpClientMock = mock(DefaultHttpClient.class);
+ when(httpClientMock.send(any(HttpRequest.class))).thenReturn(
+ TestHelper.expectedResponse(HttpStatus.HTTP_OK,
+ TestHelper.getSuccessfulTokenResponse(new HashMap<>())));
+
+ IClientCredential credential = ClientCredentialFactory.createFromCallback(assertionProvider);
+
+ ConfidentialClientApplication cca =
+ ConfidentialClientApplication.builder("myClientId", credential)
+ .authority("https://login.microsoftonline.com/tenant/")
+ .aadInstanceDiscoveryResponse(TestHelper.getInstanceDiscoveryResponse())
+ .httpClient(httpClientMock)
+ .build();
+
+ ClientCredentialParameters params = ClientCredentialParameters
+ .builder(Collections.singleton("api://AzureADTokenExchange/.default"))
+ .fmiPath("agentAppId456")
+ .build();
+
+ // Act
+ cca.acquireToken(params).get();
+
+ // Assert — the callback should have received the fmi_path
+ assertNotNull(capturedOptions.get(), "AssertionRequestOptions should have been passed to the callback");
+ assertEquals("agentAppId456", capturedOptions.get().clientAssertionFmiPath());
+ assertEquals("myClientId", capturedOptions.get().clientId());
+ assertNotNull(capturedOptions.get().tokenEndpoint());
+ }
+
+ @Test
+ void assertionContext_NullFmiPathWhenNotSet() throws Exception {
+ // Arrange
+ AtomicReference capturedOptions = new AtomicReference<>();
+
+ Function assertionProvider = options -> {
+ capturedOptions.set(options);
+ return TestHelper.signedAssertion;
+ };
+
+ DefaultHttpClient httpClientMock = mock(DefaultHttpClient.class);
+ when(httpClientMock.send(any(HttpRequest.class))).thenReturn(
+ TestHelper.expectedResponse(HttpStatus.HTTP_OK,
+ TestHelper.getSuccessfulTokenResponse(new HashMap<>())));
+
+ IClientCredential credential = ClientCredentialFactory.createFromCallback(assertionProvider);
+
+ ConfidentialClientApplication cca =
+ ConfidentialClientApplication.builder("myClientId", credential)
+ .authority("https://login.microsoftonline.com/tenant/")
+ .aadInstanceDiscoveryResponse(TestHelper.getInstanceDiscoveryResponse())
+ .httpClient(httpClientMock)
+ .build();
+
+ ClientCredentialParameters params = ClientCredentialParameters
+ .builder(Collections.singleton("scopes"))
+ .build();
+
+ // Act
+ cca.acquireToken(params).get();
+
+ // Assert — fmiPath should be null when not set
+ assertNotNull(capturedOptions.get());
+ assertNull(capturedOptions.get().clientAssertionFmiPath());
+ }
+
+ @Test
+ void assertionContext_LegacyCallableStillWorks() throws Exception {
+ // Arrange — verify that the existing Callable API still works
+ DefaultHttpClient httpClientMock = mock(DefaultHttpClient.class);
+ when(httpClientMock.send(any(HttpRequest.class))).thenReturn(
+ TestHelper.expectedResponse(HttpStatus.HTTP_OK,
+ TestHelper.getSuccessfulTokenResponse(new HashMap<>())));
+
+ IClientCredential credential = ClientCredentialFactory.createFromCallback(
+ () -> TestHelper.signedAssertion);
+
+ ConfidentialClientApplication cca =
+ ConfidentialClientApplication.builder("clientId", credential)
+ .authority("https://login.microsoftonline.com/tenant/")
+ .aadInstanceDiscoveryResponse(TestHelper.getInstanceDiscoveryResponse())
+ .httpClient(httpClientMock)
+ .build();
+
+ ClientCredentialParameters params = ClientCredentialParameters
+ .builder(Collections.singleton("scopes"))
+ .fmiPath("agentApp")
+ .build();
+
+ // Act — should not throw, even with fmiPath set (legacy callback ignores context)
+ IAuthenticationResult result = cca.acquireToken(params).get();
+
+ // Assert
+ assertNotNull(result.accessToken());
+ verify(httpClientMock, times(1)).send(any());
+ }
+
+ // ========================================================================
+ // Input validation
+ // ========================================================================
+
+ @Test
+ void fmiPath_BlankValueThrowsIllegalArgumentException() {
+ assertThrows(IllegalArgumentException.class, () ->
+ ClientCredentialParameters
+ .builder(Collections.singleton("scope"))
+ .fmiPath("")
+ .build());
+ }
+
+ @Test
+ void fmiPath_WhitespaceOnlyThrowsIllegalArgumentException() {
+ assertThrows(IllegalArgumentException.class, () ->
+ ClientCredentialParameters
+ .builder(Collections.singleton("scope"))
+ .fmiPath(" ")
+ .build());
+ }
+
+ @Test
+ void fmiPath_NullValueThrowsIllegalArgumentException() {
+ assertThrows(IllegalArgumentException.class, () ->
+ ClientCredentialParameters
+ .builder(Collections.singleton("scope"))
+ .fmiPath(null)
+ .build());
+ }
+
+ // ========================================================================
+ // Exact cache key string validation
+ // ========================================================================
+
+ @Test
+ void fmiPath_CacheKeyFormat_MatchesCrossSDKFormat() throws Exception {
+ // This test verifies that the internal cache key produced by Java uses the correct
+ // format: "-{env}-atext-{clientId}-{tenantId}-{scopes}-{hash}"
+ // Using the same fmi_path as other SDKs' integration tests: "SomeFmiPath/FmiCredentialPath"
+ // Expected hash (case-sensitive): zm2n0E62zwTsnNsozptLsoOoB_C7i-GfpxHYQQINJUw
+ // The full cache key is lowercased.
+ // Java resolves login.microsoftonline.com → login.windows.net (preferred alias).
+ DefaultHttpClient httpClientMock = mock(DefaultHttpClient.class);
+
+ when(httpClientMock.send(any(HttpRequest.class))).thenReturn(
+ TestHelper.expectedResponse(HttpStatus.HTTP_OK,
+ TestHelper.getSuccessfulTokenResponse(new HashMap<>())));
+
+ ConfidentialClientApplication cca =
+ ConfidentialClientApplication.builder("3bf56293-fbb5-42bd-a407-248ba7431a8c",
+ ClientCredentialFactory.createFromSecret("secret"))
+ .authority("https://login.microsoftonline.com/10c419d4-4a50-45b2-aa4e-919fb84df24f/")
+ .aadInstanceDiscoveryResponse(TestHelper.getInstanceDiscoveryResponse())
+ .httpClient(httpClientMock)
+ .build();
+
+ ClientCredentialParameters params = ClientCredentialParameters
+ .builder(Collections.singleton("api://AzureFMITokenExchange/.default"))
+ .fmiPath("SomeFmiPath/FmiCredentialPath")
+ .build();
+
+ cca.acquireToken(params).get();
+
+ // Verify the full cache key matches the expected format:
+ // "{homeAccountId}-{env}-{credType}-{clientId}-{tenantId}-{scopes}-{hash}" (all lowercased)
+ assertEquals(1, cca.tokenCache.accessTokens.size());
+ String cacheKey = cca.tokenCache.accessTokens.keySet().iterator().next();
+
+ String expectedKey = "-login.windows.net-atext-3bf56293-fbb5-42bd-a407-248ba7431a8c-10c419d4-4a50-45b2-aa4e-919fb84df24f-openid profile offline_access api://azurefmitokenexchange/.default-"
+ + "zm2n0E62zwTsnNsozptLsoOoB_C7i-GfpxHYQQINJUw".toLowerCase();
+ assertEquals(expectedKey, cacheKey, "Full cache key should match expected format");
+ }
+
+ @Test
+ void fmiPath_HashValueMatchesCrossSDK() {
+ // Verify that the hash computation produces expected values for known inputs
+ TreeMap components = new TreeMap<>();
+ components.put("fmi_path", "SomeFmiPath/FmiCredentialPath");
+
+ String hash = StringHelper.computeExtCacheKeyHash(components);
+ assertEquals("zm2n0E62zwTsnNsozptLsoOoB_C7i-GfpxHYQQINJUw", hash,
+ "Hash for 'SomeFmiPath/FmiCredentialPath' should match expected value");
+
+ // Second known value
+ TreeMap components2 = new TreeMap<>();
+ components2.put("fmi_path", "SomeFmiPath/Path");
+
+ String hash2 = StringHelper.computeExtCacheKeyHash(components2);
+ assertEquals("7CX57Q63os7benQ6ER0sxgJPtNQSv7TGb5zexcidFoI", hash2,
+ "Hash for 'SomeFmiPath/Path' should match expected value");
+ }
+
+ @Test
+ void fmiPath_NoFmiPath_CacheKeyUsesAccessTokenCredentialType() throws Exception {
+ // Without fmi_path, the cache key should use "AccessToken" (not "atext")
+ DefaultHttpClient httpClientMock = mock(DefaultHttpClient.class);
+
+ when(httpClientMock.send(any(HttpRequest.class))).thenReturn(
+ TestHelper.expectedResponse(HttpStatus.HTTP_OK,
+ TestHelper.getSuccessfulTokenResponse(new HashMap<>())));
+
+ ConfidentialClientApplication cca =
+ ConfidentialClientApplication.builder("clientId",
+ ClientCredentialFactory.createFromSecret("secret"))
+ .authority("https://login.microsoftonline.com/tenant/")
+ .aadInstanceDiscoveryResponse(TestHelper.getInstanceDiscoveryResponse())
+ .httpClient(httpClientMock)
+ .build();
+
+ ClientCredentialParameters params = ClientCredentialParameters
+ .builder(Collections.singleton("scope"))
+ .build();
+
+ cca.acquireToken(params).get();
+
+ // Verify the full cache key uses "accesstoken" (no ext_cache_key_hash appended)
+ assertEquals(1, cca.tokenCache.accessTokens.size());
+ String cacheKey = cca.tokenCache.accessTokens.keySet().iterator().next();
+
+ String expectedKey = "-login.windows.net-accesstoken-clientid-tenant-openid profile offline_access scope";
+ assertEquals(expectedKey, cacheKey,
+ "Cache key without fmi_path should use 'accesstoken' credential type and no hash suffix");
+ }
+
+ // ========================================================================
+ // Cache filter isolation: FMI tokens not returned for non-FMI requests (and vice versa)
+ // ========================================================================
+
+ @Test
+ void fmiPath_CacheIsolation_FmiTokenNotReturnedForNonFmiRequest() throws Exception {
+ // Seed cache with an FMI-tagged token, then verify a non-FMI request does NOT
+ // return it from cache (goes to IdP instead).
+ DefaultHttpClient httpClientMock = mock(DefaultHttpClient.class);
+
+ when(httpClientMock.send(any(HttpRequest.class))).thenReturn(
+ TestHelper.expectedResponse(HttpStatus.HTTP_OK,
+ TestHelper.getSuccessfulTokenResponse(new HashMap<>())));
+
+ ConfidentialClientApplication cca =
+ ConfidentialClientApplication.builder("clientId",
+ ClientCredentialFactory.createFromSecret("secret"))
+ .authority("https://login.microsoftonline.com/tenant/")
+ .aadInstanceDiscoveryResponse(TestHelper.getInstanceDiscoveryResponse())
+ .httpClient(httpClientMock)
+ .build();
+
+ // First request WITH fmi_path — seeds cache with an FMI-tagged token
+ ClientCredentialParameters fmiParams = ClientCredentialParameters
+ .builder(Collections.singleton("scope"))
+ .fmiPath("agentApp1")
+ .build();
+ cca.acquireToken(fmiParams).get();
+ assertEquals(1, cca.tokenCache.accessTokens.size());
+
+ // Second request WITHOUT fmi_path — should NOT get the FMI token from cache
+ ClientCredentialParameters nonFmiParams = ClientCredentialParameters
+ .builder(Collections.singleton("scope"))
+ .build();
+ cca.acquireToken(nonFmiParams).get();
+
+ // Both tokens should now be in cache (FMI miss → went to IdP → stored as non-FMI)
+ assertEquals(2, cca.tokenCache.accessTokens.size(),
+ "Non-FMI request should not match FMI-tagged cache entry; both tokens should exist");
+ }
+
+ @Test
+ void fmiPath_CacheIsolation_NonFmiTokenNotReturnedForFmiRequest() throws Exception {
+ // Seed cache with a non-FMI token, then verify an FMI request does NOT
+ // return it from cache (goes to IdP instead).
+ DefaultHttpClient httpClientMock = mock(DefaultHttpClient.class);
+
+ when(httpClientMock.send(any(HttpRequest.class))).thenReturn(
+ TestHelper.expectedResponse(HttpStatus.HTTP_OK,
+ TestHelper.getSuccessfulTokenResponse(new HashMap<>())));
+
+ ConfidentialClientApplication cca =
+ ConfidentialClientApplication.builder("clientId",
+ ClientCredentialFactory.createFromSecret("secret"))
+ .authority("https://login.microsoftonline.com/tenant/")
+ .aadInstanceDiscoveryResponse(TestHelper.getInstanceDiscoveryResponse())
+ .httpClient(httpClientMock)
+ .build();
+
+ // First request WITHOUT fmi_path — seeds cache with a non-FMI token
+ ClientCredentialParameters nonFmiParams = ClientCredentialParameters
+ .builder(Collections.singleton("scope"))
+ .build();
+ cca.acquireToken(nonFmiParams).get();
+ assertEquals(1, cca.tokenCache.accessTokens.size());
+
+ // Second request WITH fmi_path — should NOT get the non-FMI token from cache
+ ClientCredentialParameters fmiParams = ClientCredentialParameters
+ .builder(Collections.singleton("scope"))
+ .fmiPath("agentApp1")
+ .build();
+ cca.acquireToken(fmiParams).get();
+
+ // Both tokens should now be in cache (FMI miss → went to IdP → stored as FMI)
+ assertEquals(2, cca.tokenCache.accessTokens.size(),
+ "FMI request should not match non-FMI cache entry; both tokens should exist");
+ }
+
+ @Test
+ void fmiPath_CacheIsolation_DifferentFmiPathsNotShared() throws Exception {
+ // Two different fmi_path values should produce separate cache entries
+ DefaultHttpClient httpClientMock = mock(DefaultHttpClient.class);
+
+ when(httpClientMock.send(any(HttpRequest.class))).thenReturn(
+ TestHelper.expectedResponse(HttpStatus.HTTP_OK,
+ TestHelper.getSuccessfulTokenResponse(new HashMap<>())));
+
+ ConfidentialClientApplication cca =
+ ConfidentialClientApplication.builder("clientId",
+ ClientCredentialFactory.createFromSecret("secret"))
+ .authority("https://login.microsoftonline.com/tenant/")
+ .aadInstanceDiscoveryResponse(TestHelper.getInstanceDiscoveryResponse())
+ .httpClient(httpClientMock)
+ .build();
+
+ // Request with fmi_path "agentA"
+ ClientCredentialParameters paramsA = ClientCredentialParameters
+ .builder(Collections.singleton("scope"))
+ .fmiPath("agentA")
+ .build();
+ cca.acquireToken(paramsA).get();
+
+ // Request with fmi_path "agentB"
+ ClientCredentialParameters paramsB = ClientCredentialParameters
+ .builder(Collections.singleton("scope"))
+ .fmiPath("agentB")
+ .build();
+ cca.acquireToken(paramsB).get();
+
+ // Each fmi_path produces a different hash → different cache key → 2 entries
+ assertEquals(2, cca.tokenCache.accessTokens.size(),
+ "Different fmi_path values should produce separate cache entries");
+ }
+
+}
diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/TestHelper.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/TestHelper.java
index 6a01e86b..4112dceb 100644
--- a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/TestHelper.java
+++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/TestHelper.java
@@ -90,6 +90,16 @@ static String readResource(Class> classInstance, String resource) {
}
}
+ /**
+ * Returns a valid AAD instance discovery response JSON string suitable for use with
+ * {@code .aadInstanceDiscoveryResponse()} on application builders, avoiding the need
+ * to disable instance discovery in unit tests.
+ */
+ static String getInstanceDiscoveryResponse() {
+ return readResource(TestHelper.class,
+ "/instance_discovery_data/aad_instance_discovery_response_valid.json");
+ }
+
static void deleteFileContent(Class> classInstance, String resource)
throws URISyntaxException, IOException {
FileWriter fileWriter = new FileWriter(