From c5babe1b41d24dca9dfcc162169582303ab6e9d4 Mon Sep 17 00:00:00 2001 From: avdunn Date: Wed, 15 Apr 2026 13:31:57 -0700 Subject: [PATCH 1/7] First draft of FMI support --- .../aad/msal4j/AccessTokenCacheEntity.java | 19 + ...cquireTokenByClientCredentialSupplier.java | 7 + .../msal4j/AcquireTokenSilentSupplier.java | 3 +- .../aad/msal4j/AssertionRequestOptions.java | 53 +++ .../microsoft/aad/msal4j/ClientAssertion.java | 84 +++- .../aad/msal4j/ClientCredentialFactory.java | 20 + .../msal4j/ClientCredentialParameters.java | 36 +- .../aad/msal4j/ClientCredentialRequest.java | 4 + .../aad/msal4j/CredentialTypeEnum.java | 1 + .../microsoft/aad/msal4j/SilentRequest.java | 9 + .../microsoft/aad/msal4j/StringHelper.java | 22 + .../com/microsoft/aad/msal4j/TokenCache.java | 59 ++- .../aad/msal4j/TokenRequestExecutor.java | 25 +- .../com/microsoft/aad/msal4j/FmiTest.java | 438 ++++++++++++++++++ 14 files changed, 771 insertions(+), 9 deletions(-) create mode 100644 msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/AssertionRequestOptions.java create mode 100644 msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/FmiTest.java diff --git a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/AccessTokenCacheEntity.java b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/AccessTokenCacheEntity.java index 92904da8..d195ccc6 100644 --- a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/AccessTokenCacheEntity.java +++ b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/AccessTokenCacheEntity.java @@ -21,6 +21,7 @@ class AccessTokenCacheEntity extends Credential implements JsonSerializable keyParts = new ArrayList<>(); @@ -32,6 +33,10 @@ String getKey() { keyParts.add(realm); keyParts.add(target); + if (!StringHelper.isBlank(extCacheKeyHash)) { + keyParts.add(extCacheKeyHash); + } + return String.join(Constants.CACHE_KEY_SEPARATOR, keyParts).toLowerCase(); } @@ -80,6 +85,9 @@ static AccessTokenCacheEntity fromJson(JsonReader jsonReader) throws IOException case "user_assertion_hash": entity.userAssertionHash = reader.getString(); break; + case "ext_cache_key_hash": + entity.extCacheKeyHash = reader.getString(); + break; default: reader.skipChildren(); break; @@ -105,6 +113,9 @@ public JsonWriter toJson(JsonWriter jsonWriter) throws IOException { jsonWriter.writeStringField("extended_expires_on", extExpiresOn); jsonWriter.writeStringField("refresh_on", refreshOn); jsonWriter.writeStringField("user_assertion_hash", userAssertionHash); + if (!StringHelper.isBlank(extCacheKeyHash)) { + jsonWriter.writeStringField("ext_cache_key_hash", extCacheKeyHash); + } jsonWriter.writeEndObject(); @@ -158,4 +169,12 @@ void extExpiresOn(String extExpiresOn) { void refreshOn(String refreshOn) { this.refreshOn = refreshOn; } + + String extCacheKeyHash() { + return this.extCacheKeyHash; + } + + void extCacheKeyHash(String extCacheKeyHash) { + this.extCacheKeyHash = extCacheKeyHash; + } } \ No newline at end of file diff --git a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/AcquireTokenByClientCredentialSupplier.java b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/AcquireTokenByClientCredentialSupplier.java index 4a376db7..8d7088b6 100644 --- a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/AcquireTokenByClientCredentialSupplier.java +++ b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/AcquireTokenByClientCredentialSupplier.java @@ -40,6 +40,13 @@ AuthenticationResult execute() throws Exception { context, null); + // Propagate ext_cache_key_hash for fmi_path-based cache isolation + if (!StringHelper.isBlank(this.clientCredentialRequest.parameters.fmiPath())) { + java.util.TreeMap components = new java.util.TreeMap<>(); + components.put("fmi_path", this.clientCredentialRequest.parameters.fmiPath()); + silentRequest.extCacheKeyHash(StringHelper.computeExtCacheKeyHash(components)); + } + 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 00f97621..84ef8071 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 @@ -32,7 +32,8 @@ AuthenticationResult execute() throws Exception { requestAuthority, silentRequest.parameters().scopes(), clientApplication.clientId(), - silentRequest.assertion()); + silentRequest.assertion(), + silentRequest.extCacheKeyHash()); } else { res = clientApplication.tokenCache.getCachedAuthenticationResult( silentRequest.parameters().account(), diff --git a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/AssertionRequestOptions.java b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/AssertionRequestOptions.java new file mode 100644 index 00000000..ac0a5774 --- /dev/null +++ b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/AssertionRequestOptions.java @@ -0,0 +1,53 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.aad.msal4j; + +/** + * Context information passed to assertion provider callbacks when MSAL needs a fresh client assertion. + * This allows assertion providers to generate context-aware assertions, such as including the FMI path + * in Federated Managed Identity (agent identity) scenarios. + * + *

For more details on agent identity scenarios, see the MSAL documentation on FMI/FIC flows.

+ */ +public class AssertionRequestOptions { + + private final String clientId; + private final String tokenEndpoint; + private final String fmiPath; + + AssertionRequestOptions(String clientId, String tokenEndpoint, String fmiPath) { + this.clientId = clientId; + this.tokenEndpoint = tokenEndpoint; + this.fmiPath = fmiPath; + } + + /** + * Gets the client ID of the application requesting the assertion. + * + * @return the client ID + */ + public String clientId() { + return clientId; + } + + /** + * Gets the token endpoint URL that the assertion will be sent to. + * + * @return the token endpoint URL + */ + public String tokenEndpoint() { + return tokenEndpoint; + } + + /** + * Gets the FMI (Federated Managed Identity) path for agent identity scenarios. + * When set, this indicates which agent identity the assertion is being requested for. + * The assertion provider can use this to include the FMI path in its token acquisition logic. + * + * @return the FMI path, or null if not applicable + */ + public String fmiPath() { + return fmiPath; + } +} diff --git a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/ClientAssertion.java b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/ClientAssertion.java index 1126b461..7eb1315a 100644 --- a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/ClientAssertion.java +++ b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/ClientAssertion.java @@ -5,12 +5,14 @@ import java.util.Objects; import java.util.concurrent.Callable; +import java.util.function.Function; final class ClientAssertion implements IClientAssertion { static final String ASSERTION_TYPE_JWT_BEARER = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"; private final String assertion; private final Callable assertionProvider; + private final Function contextAwareAssertionProvider; /** * Constructor that accepts a static assertion string @@ -25,6 +27,7 @@ final class ClientAssertion implements IClientAssertion { this.assertion = assertion; this.assertionProvider = null; + this.contextAwareAssertionProvider = null; } /** @@ -40,20 +43,61 @@ final class ClientAssertion implements IClientAssertion { this.assertion = null; this.assertionProvider = assertionProvider; + this.contextAwareAssertionProvider = null; + } + + /** + * Constructor that accepts a context-aware function that provides the assertion string. + * The function receives {@link AssertionRequestOptions} containing context such as + * the client ID, token endpoint, and FMI path. + * + * @param contextAwareAssertionProvider A function that receives context and returns a JWT assertion string + * @throws NullPointerException if contextAwareAssertionProvider is null + */ + ClientAssertion(final Function 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. + * If created with a context-aware Function, it is invoked with a default (empty) context. * * @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. + * If this ClientAssertion was created with a context-aware Function, the function will receive + * the provided context. If created with a Callable or static string, the context is ignored. + * + * @param options context information for the assertion request + * @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 +113,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 +151,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 +167,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..47f8e3fc 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 @@ -28,7 +28,9 @@ 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; + + 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 +38,7 @@ private ClientCredentialParameters(Set scopes, Boolean skipCache, Claims this.extraQueryParameters = extraQueryParameters; this.tenant = tenant; this.clientCredential = clientCredential; + this.fmiPath = fmiPath; } private static ClientCredentialParametersBuilder builder() { @@ -87,6 +90,17 @@ 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; + } + public static class ClientCredentialParametersBuilder { private Set scopes; private Boolean skipCache = false; @@ -95,6 +109,7 @@ public static class ClientCredentialParametersBuilder { private Map extraQueryParameters; private String tenant; private IClientCredential clientCredential; + private String fmiPath; ClientCredentialParametersBuilder() { } @@ -162,12 +177,27 @@ 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) { + 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..cd75592e 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..c07dea41 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. + * Uses the same algorithm as MSAL .NET, Go, and Python: concatenate sorted key+value pairs, + * SHA-256 hash, then Base64URL encode without padding. + * + * @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..652e0495 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,23 @@ private static AccessTokenCacheEntity createAccessTokenCacheEntity(TokenRequestE return at; } + /** + * Computes the extended cache key hash for a request, if applicable. + * Currently, this is used for client credential requests with an fmi_path parameter. + * The algorithm matches MSAL .NET/Go/Python: sorted key-value concatenation → SHA-256 → Base64URL. + */ + private static String computeExtCacheKeyHashForRequest(MsalRequest msalRequest) { + if (msalRequest instanceof ClientCredentialRequest) { + ClientCredentialParameters parameters = ((ClientCredentialRequest) msalRequest).parameters; + if (!StringHelper.isBlank(parameters.fmiPath())) { + TreeMap components = new TreeMap<>(); + components.put("fmi_path", parameters.fmiPath()); + return StringHelper.computeExtCacheKeyHash(components); + } + } + return ""; + } + private static IdTokenCacheEntity createIdTokenCacheEntity(TokenRequestExecutor tokenRequestExecutor, AuthenticationResult authenticationResult, String environmentAlias) { @@ -541,11 +567,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 +591,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 +755,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 +786,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..8af0b803 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,30 @@ 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; + String assertionValue; + if (clientAssertion.isContextAware()) { + // Build assertion context with fmi_path if available + String fmiPath = null; + if (msalRequest instanceof ClientCredentialRequest) { + fmiPath = ((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, + fmiPath); + assertionValue = clientAssertion.assertion(options); + } else { + assertionValue = clientAssertion.assertion(); + } + addJWTBearerAssertionParams(queryParameters, assertionValue); } 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..b46ea1d7 --- /dev/null +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/FmiTest.java @@ -0,0 +1,438 @@ +// 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. + * + * These tests correspond to MSAL .NET's FmiIntegrationTests (§1-§3) and + * UserFederatedIdentityCredentialTests (§4-§5) in the AgentIDs_ComponentsReference. + */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class FmiTest { + + // ======================================================================== + // §2: 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/") + .instanceDiscovery(false) + .validateAuthority(false) + .httpClient(httpClientMock) + .build(); + + ClientCredentialParameters parameters = ClientCredentialParameters + .builder(Collections.singleton("api://AzureADTokenExchange/.default")) + .fmiPath("agentAppId123") + .skipCache(true) + .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/") + .instanceDiscovery(false) + .validateAuthority(false) + .httpClient(httpClientMock) + .build(); + + ClientCredentialParameters parameters = ClientCredentialParameters + .builder(Collections.singleton("scopes")) + .skipCache(true) + .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"); + })); + } + + // ======================================================================== + // §3: 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/") + .instanceDiscovery(false) + .validateAuthority(false) + .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/") + .instanceDiscovery(false) + .validateAuthority(false) + .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_ExtendedCredentialTypeInCacheKey() 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/") + .instanceDiscovery(false) + .validateAuthority(false) + .httpClient(httpClientMock) + .build(); + + // Act — acquire with fmi_path + ClientCredentialParameters params = ClientCredentialParameters + .builder(Collections.singleton("api://AzureADTokenExchange/.default")) + .fmiPath("agentA") + .build(); + cca.acquireToken(params).get(); + + // Assert — cache entry should use "atext" credential type (matching Go/Python convention) + assertEquals(1, cca.tokenCache.accessTokens.size()); + String cacheKey = cca.tokenCache.accessTokens.keySet().iterator().next(); + assertTrue(cacheKey.contains("-atext-"), + "Cache key should contain 'atext' credential type for extended tokens, got: " + cacheKey); + } + + @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/") + .instanceDiscovery(false) + .validateAuthority(false) + .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()); + } + + // ======================================================================== + // §3: ext_cache_key hash computation + // ======================================================================== + + @Test + void computeExtCacheKeyHash_MatchesCrossSDKAlgorithm() { + // Arrange — the same input should produce the same hash across .NET, Go, Python, and Java + TreeMap components = new TreeMap<>(); + components.put("fmi_path", "agentAppId123"); + + // Act + String hash = StringHelper.computeExtCacheKeyHash(components); + + // Assert — hash should be a non-empty Base64URL-encoded SHA-256 + assertNotNull(hash); + assertFalse(hash.isEmpty()); + // Base64URL characters only (no + / = padding) + assertTrue(hash.matches("[A-Za-z0-9_-]+"), "Hash should be Base64URL encoded: " + hash); + // SHA-256 produces 32 bytes → 43 Base64URL characters (without padding) + assertEquals(43, hash.length(), "Base64URL-encoded SHA-256 should be 43 chars: " + hash); + } + + @Test + void computeExtCacheKeyHash_DifferentValuesProduceDifferentHashes() { + TreeMap componentsA = new TreeMap<>(); + componentsA.put("fmi_path", "agentA"); + + TreeMap componentsB = new TreeMap<>(); + componentsB.put("fmi_path", "agentB"); + + String hashA = StringHelper.computeExtCacheKeyHash(componentsA); + String hashB = StringHelper.computeExtCacheKeyHash(componentsB); + + assertNotEquals(hashA, hashB, "Different fmi_path values should produce different hashes"); + } + + @Test + void computeExtCacheKeyHash_EmptyMapReturnsEmpty() { + assertEquals("", StringHelper.computeExtCacheKeyHash(new TreeMap<>())); + assertEquals("", StringHelper.computeExtCacheKeyHash(null)); + } + + // ======================================================================== + // §5: 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/") + .instanceDiscovery(false) + .validateAuthority(false) + .httpClient(httpClientMock) + .build(); + + ClientCredentialParameters params = ClientCredentialParameters + .builder(Collections.singleton("api://AzureADTokenExchange/.default")) + .fmiPath("agentAppId456") + .skipCache(true) + .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().fmiPath()); + 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/") + .instanceDiscovery(false) + .validateAuthority(false) + .httpClient(httpClientMock) + .build(); + + ClientCredentialParameters params = ClientCredentialParameters + .builder(Collections.singleton("scopes")) + .skipCache(true) + .build(); + + // Act + cca.acquireToken(params).get(); + + // Assert — fmiPath should be null when not set + assertNotNull(capturedOptions.get()); + assertNull(capturedOptions.get().fmiPath()); + } + + @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/") + .instanceDiscovery(false) + .validateAuthority(false) + .httpClient(httpClientMock) + .build(); + + ClientCredentialParameters params = ClientCredentialParameters + .builder(Collections.singleton("scopes")) + .fmiPath("agentApp") + .skipCache(true) + .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()); + } + + // ======================================================================== + // §5: AssertionRequestOptions model + // ======================================================================== + + @Test + void assertionRequestOptions_PropertiesAccessible() { + AssertionRequestOptions options = new AssertionRequestOptions( + "clientId123", + "https://login.microsoftonline.com/tenant/oauth2/v2.0/token", + "agentAppId"); + + assertEquals("clientId123", options.clientId()); + assertEquals("https://login.microsoftonline.com/tenant/oauth2/v2.0/token", options.tokenEndpoint()); + assertEquals("agentAppId", options.fmiPath()); + } + + @Test + void assertionRequestOptions_NullFmiPath() { + AssertionRequestOptions options = new AssertionRequestOptions("clientId", "endpoint", null); + assertNull(options.fmiPath()); + } +} From f09f8064c774b38a4fe6d26e2f655cd9fa494b99 Mon Sep 17 00:00:00 2001 From: avdunn Date: Mon, 4 May 2026 11:47:25 -0700 Subject: [PATCH 2/7] Improve test coverage to match MSAL .NET --- .../com/microsoft/aad/msal4j/AgenticIT.java | 258 +++++++++++++++ .../java/com/microsoft/aad/msal4j/FmiIT.java | 313 ++++++++++++++++++ .../msal4j/ClientCredentialParameters.java | 3 + .../com/microsoft/aad/msal4j/FmiTest.java | 195 ++++++----- 4 files changed, 693 insertions(+), 76 deletions(-) create mode 100644 msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/AgenticIT.java create mode 100644 msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/FmiIT.java diff --git a/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/AgenticIT.java b/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/AgenticIT.java new file mode 100644 index 00000000..7eabe1c6 --- /dev/null +++ b/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/AgenticIT.java @@ -0,0 +1,258 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.aad.msal4j; + +import com.microsoft.aad.msal4j.labapi.KeyVaultSecretsProvider; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; + +import static org.junit.jupiter.api.Assertions.*; + +import java.io.IOException; +import java.security.*; +import java.security.cert.CertificateException; +import java.security.cert.X509Certificate; +import java.util.Collections; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Function; + +/** + * Integration tests for agentic (agent identity) scenarios using MSAL Java APIs. + * Corresponds to .NET's Agentic.cs — tests the MSAL-level APIs for the agent identity flow + * (specifically the FMI portions that are available on this branch). + * + *

These tests use MSAL token acquisition APIs (unlike AgenticRawHttpIT which uses raw HTTP). + * + *

Test configuration (same as .NET Agentic.cs): + *

    + *
  • Blueprint app: {@link #BLUEPRINT_CLIENT_ID}
  • + *
  • Agent app: {@link #AGENT_APP_ID}
  • + *
  • Tenant: {@link #TENANT_ID}
  • + *
+ * + *

Flows tested (FMI-only, no FIC/user_fic on this branch): + *

    + *
  • Agent gets app token using FMI-sourced assertion (Leg 2 of agent identity)
  • + *
  • Assertion callback receives correct context (AssertionRequestOptions)
  • + *
  • Cache isolation between different assertion-based flows
  • + *
+ */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class AgenticIT { + + // Same config as .NET Agentic.cs + private static final String BLUEPRINT_CLIENT_ID = "aab5089d-e764-47e3-9f28-cc11c2513821"; + private static final String TENANT_ID = "10c419d4-4a50-45b2-aa4e-919fb84df24f"; + private static final String AGENT_APP_ID = "ab18ca07-d139-4840-8b3b-4be9610c6ed5"; + private static final String TOKEN_EXCHANGE_SCOPE = "api://AzureADTokenExchange/.default"; + private static final String GRAPH_SCOPE = "https://graph.microsoft.com/.default"; + private static final String AZURE_REGION = "westus3"; + + private static final String AUTHORITY = "https://login.microsoftonline.com/" + TENANT_ID + "/"; + + private PrivateKey privateKey; + private X509Certificate certificate; + + @BeforeAll + void init() throws KeyStoreException, NoSuchProviderException, + IOException, NoSuchAlgorithmException, CertificateException, + UnrecoverableKeyException { + KeyStore keystore = CertificateHelper.createKeyStore(); + keystore.load(null, null); + + privateKey = (PrivateKey) keystore.getKey(KeyVaultSecretsProvider.CERTIFICATE_ALIAS, null); + certificate = (X509Certificate) keystore.getCertificate(KeyVaultSecretsProvider.CERTIFICATE_ALIAS); + + assertNotNull(privateKey, "Lab private key not found. Ensure the lab cert is installed."); + assertNotNull(certificate, "Lab certificate not found. Ensure the lab cert is installed."); + } + + /** + * Agent gets an app-only token for Graph using an FMI-sourced client assertion. + * This tests Leg 2 of the agent identity flow: + * 1. Blueprint CCA acquires FMI credential (fmi_path = agentAppId) + * 2. Agent CCA uses that credential as client_assertion to get Graph token + * + * Corresponds to .NET's AgentGetsAppTokenForGraphTest. + */ + @Test + void agentGetsAppToken_UsingFmiAssertion() throws Exception { + // The assertion callback simulates what an SDK or middleware would do: + // it calls the blueprint app to get an FMI credential for the agent + Function assertionProvider = options -> { + try { + return acquireFmiCredentialForAgent(AGENT_APP_ID); + } catch (Exception e) { + throw new RuntimeException("Failed to acquire FMI credential", e); + } + }; + + IClientCredential credential = ClientCredentialFactory.createFromCallback(assertionProvider); + + ConfidentialClientApplication agentCca = ConfidentialClientApplication.builder(AGENT_APP_ID, credential) + .authority(AUTHORITY) + .build(); + + IAuthenticationResult result = agentCca.acquireToken(ClientCredentialParameters + .builder(Collections.singleton(GRAPH_SCOPE)) + .build()) + .get(); + + assertNotNull(result, "Auth result should not be null"); + assertNotNull(result.accessToken(), "Access token should not be null"); + assertFalse(result.accessToken().isEmpty(), "Access token should not be empty"); + } + + /** + * Verifies that the context-aware assertion callback receives the correct fmiPath + * when the ClientCredentialParameters include an fmiPath. + * + * This tests the assertion context propagation: when acquiring an FMI credential + * using a context-aware callback, the fmiPath from the parameters flows to the callback. + */ + @Test + void assertionCallback_ReceivesFmiPathContext() throws Exception { + AtomicReference capturedOptions = new AtomicReference<>(); + + Function assertionProvider = options -> { + capturedOptions.set(options); + try { + return acquireFmiCredentialForAgent(options.fmiPath()); + } catch (Exception e) { + throw new RuntimeException("Failed to acquire FMI credential", e); + } + }; + + IClientCredential credential = ClientCredentialFactory.createFromCallback(assertionProvider); + + ConfidentialClientApplication cca = ConfidentialClientApplication.builder( + "urn:microsoft:identity:fmi", credential) + .authority(AUTHORITY) + .azureRegion(AZURE_REGION) + .build(); + + ClientCredentialParameters params = ClientCredentialParameters + .builder(Collections.singleton(TOKEN_EXCHANGE_SCOPE)) + .fmiPath(AGENT_APP_ID) + .skipCache(true) + .build(); + + IAuthenticationResult result = cca.acquireToken(params).get(); + + // Verify assertion callback received the correct context + assertNotNull(capturedOptions.get(), "AssertionRequestOptions should have been passed to callback"); + assertEquals(AGENT_APP_ID, capturedOptions.get().fmiPath(), + "fmiPath in callback should match the one set in parameters"); + assertEquals("urn:microsoft:identity:fmi", capturedOptions.get().clientId(), + "clientId in callback should match the CCA client ID"); + assertNotNull(capturedOptions.get().tokenEndpoint(), + "tokenEndpoint should be available in callback"); + + // Verify token was acquired + assertNotNull(result.accessToken(), "Access token should not be null"); + } + + /** + * Verifies that the agent CCA can acquire a token and it gets cached, + * then the second request is a cache hit. + */ + @Test + void agentAppToken_CacheHit() throws Exception { + Function assertionProvider = options -> { + try { + return acquireFmiCredentialForAgent(AGENT_APP_ID); + } catch (Exception e) { + throw new RuntimeException("Failed to acquire FMI credential", e); + } + }; + + IClientCredential credential = ClientCredentialFactory.createFromCallback(assertionProvider); + + ConfidentialClientApplication agentCca = ConfidentialClientApplication.builder(AGENT_APP_ID, credential) + .authority(AUTHORITY) + .build(); + + ClientCredentialParameters params = ClientCredentialParameters + .builder(Collections.singleton(GRAPH_SCOPE)) + .build(); + + IAuthenticationResult result1 = agentCca.acquireToken(params).get(); + IAuthenticationResult result2 = agentCca.acquireToken(params).get(); + + // Second call should be a cache hit + assertEquals(result1.accessToken(), result2.accessToken(), + "Second request should be a cache hit returning the same token"); + assertEquals(1, agentCca.tokenCache.accessTokens.size(), + "Should have only one cache entry"); + } + + /** + * Verifies that tokens acquired with different fmi_paths are isolated in cache + * even when using the same agent CCA. + */ + @Test + void agentFmiToken_CacheIsolation_DifferentFmiPaths() throws Exception { + Function assertionProvider = options -> { + try { + // Use the fmiPath from the context if available, otherwise use default agent ID + String targetPath = options.fmiPath() != null ? options.fmiPath() : AGENT_APP_ID; + return acquireFmiCredentialForAgent(targetPath); + } catch (Exception e) { + throw new RuntimeException("Failed to acquire FMI credential", e); + } + }; + + IClientCredential credential = ClientCredentialFactory.createFromCallback(assertionProvider); + + ConfidentialClientApplication cca = ConfidentialClientApplication.builder( + "urn:microsoft:identity:fmi", credential) + .authority(AUTHORITY) + .azureRegion(AZURE_REGION) + .build(); + + // Acquire with first fmi_path + ClientCredentialParameters params1 = ClientCredentialParameters + .builder(Collections.singleton(TOKEN_EXCHANGE_SCOPE)) + .fmiPath(AGENT_APP_ID) + .build(); + IAuthenticationResult result1 = cca.acquireToken(params1).get(); + + // Acquire with different fmi_path + ClientCredentialParameters params2 = ClientCredentialParameters + .builder(Collections.singleton(TOKEN_EXCHANGE_SCOPE)) + .fmiPath("SomeFmiPath/DifferentAgent") + .build(); + IAuthenticationResult result2 = cca.acquireToken(params2).get(); + + // Should have separate cache entries + assertEquals(2, cca.tokenCache.accessTokens.size(), + "Different fmi_paths should produce separate cache entries"); + assertNotEquals(result1.accessToken(), result2.accessToken(), + "Tokens for different fmi_paths should be different"); + } + + /** + * Helper: acquires an FMI credential from the blueprint app for the given agent app ID. + * This is Leg 1 of the agent identity flow. + */ + private String acquireFmiCredentialForAgent(String agentAppId) throws Exception { + IClientCertificate clientCert = ClientCredentialFactory.createFromCertificate(privateKey, certificate); + + ConfidentialClientApplication blueprintCca = ConfidentialClientApplication.builder( + BLUEPRINT_CLIENT_ID, clientCert) + .authority(AUTHORITY) + .sendX5c(true) + .azureRegion(AZURE_REGION) + .build(); + + ClientCredentialParameters params = ClientCredentialParameters + .builder(Collections.singleton(TOKEN_EXCHANGE_SCOPE)) + .fmiPath(agentAppId) + .build(); + + IAuthenticationResult result = blueprintCca.acquireToken(params).get(); + return result.accessToken(); + } +} diff --git a/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/FmiIT.java b/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/FmiIT.java new file mode 100644 index 00000000..f802b4df --- /dev/null +++ b/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/FmiIT.java @@ -0,0 +1,313 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.aad.msal4j; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; + +import static org.junit.jupiter.api.Assertions.*; + +import java.io.IOException; +import java.security.*; +import java.security.cert.CertificateException; +import java.security.cert.X509Certificate; +import java.util.Collections; +import java.util.TreeMap; +import java.util.function.Function; + +import com.microsoft.aad.msal4j.labapi.KeyVaultSecretsProvider; + +/** + * Integration tests for FMI (Federated Managed Identity) support. + * Corresponds to .NET's FmiIntegrationTests.cs — validates real Entra ID interactions + * for the FMI token acquisition flows described in the FMI protocol spec v1.0 Section 3.2. + * + *

Test apps are in MSID Lab 4: + *

    + *
  • RMA (Resource Management Application): {@link #RMA_CLIENT_ID}
  • + *
  • Web API resource: {@link #WEB_API_SCOPE}
  • + *
+ * + *

Flows tested: + *

    + *
  • Flow 1: RMA gets FMI credential from cert (scope: api://AzureFMITokenExchange/.default)
  • + *
  • Flow 2: RMA gets FMI token for a resource (scope: webapi/.default)
  • + *
  • Flow 3: Sub-RMA gets FMI credential using assertion callback
  • + *
  • Flow 5: Sub-RMA gets FMI token for a resource using assertion callback
  • + *
+ */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class FmiIT { + + // Same configuration as .NET's FmiIntegrationTests.cs + private static final String TENANT_ID = "10c419d4-4a50-45b2-aa4e-919fb84df24f"; + private static final String RMA_CLIENT_ID = "3bf56293-fbb5-42bd-a407-248ba7431a8c"; + private static final String WEB_API_SCOPE = "api://aa464f73-2868-4f67-b0e7-fc2f749e757f/.default"; + private static final String FMI_EXCHANGE_SCOPE = "api://AzureFMITokenExchange/.default"; + private static final String AZURE_REGION = "westus3"; + + private static final String AUTHORITY = "https://login.microsoftonline.com/" + TENANT_ID + "/"; + + private PrivateKey privateKey; + private X509Certificate certificate; + + @BeforeAll + void init() throws KeyStoreException, NoSuchProviderException, + IOException, NoSuchAlgorithmException, CertificateException, + UnrecoverableKeyException { + KeyStore keystore = CertificateHelper.createKeyStore(); + keystore.load(null, null); + + privateKey = (PrivateKey) keystore.getKey(KeyVaultSecretsProvider.CERTIFICATE_ALIAS, null); + certificate = (X509Certificate) keystore.getCertificate(KeyVaultSecretsProvider.CERTIFICATE_ALIAS); + + assertNotNull(privateKey, "Lab private key not found. Ensure the lab cert is installed."); + assertNotNull(certificate, "Lab certificate not found. Ensure the lab cert is installed."); + } + + /** + * Flow 1: RMA getting FMI credential for a leaf entity or sub-RMA. + * Uses certificate with SN+I (sendX5c=true) and fmi_path to acquire a credential + * scoped to api://AzureFMITokenExchange/.default. + */ + @Test + void flow1_FmiCredential_FromCert() throws Exception { + IClientCertificate clientCert = ClientCredentialFactory.createFromCertificate(privateKey, certificate); + + ConfidentialClientApplication cca = ConfidentialClientApplication.builder(RMA_CLIENT_ID, clientCert) + .authority(AUTHORITY) + .sendX5c(true) + .azureRegion(AZURE_REGION) + .build(); + + ClientCredentialParameters params = ClientCredentialParameters + .builder(Collections.singleton(FMI_EXCHANGE_SCOPE)) + .fmiPath("SomeFmiPath/FmiCredentialPath") + .build(); + + IAuthenticationResult result = cca.acquireToken(params).get(); + + // Verify token + assertNotNull(result, "Auth result should not be null"); + assertNotNull(result.accessToken(), "Access token should not be null"); + assertFalse(result.accessToken().isEmpty(), "Access token should not be empty"); + + // Verify cache uses "atext" credential type with fmi_path hash + assertEquals(1, cca.tokenCache.accessTokens.size()); + String cacheKey = cca.tokenCache.accessTokens.keySet().iterator().next(); + assertTrue(cacheKey.contains("-atext-"), + "Cache key should use 'atext' credential type for FMI tokens, got: " + cacheKey); + + // Verify expected hash for "SomeFmiPath/FmiCredentialPath" matches cross-SDK value + String expectedHash = "zm2n0E62zwTsnNsozptLsoOoB_C7i-GfpxHYQQINJUw".toLowerCase(); + assertTrue(cacheKey.endsWith(expectedHash), + "Cache key should end with the expected fmi_path hash, got: " + cacheKey); + } + + /** + * Flow 2: RMA getting FMI token for a leaf entity (resource scope, not exchange scope). + * Validates that fmi_path works with any resource scope, not just the exchange scope. + */ + @Test + void flow2_FmiToken_FromCert() throws Exception { + IClientCertificate clientCert = ClientCredentialFactory.createFromCertificate(privateKey, certificate); + + ConfidentialClientApplication cca = ConfidentialClientApplication.builder(RMA_CLIENT_ID, clientCert) + .authority(AUTHORITY) + .sendX5c(true) + .azureRegion(AZURE_REGION) + .build(); + + ClientCredentialParameters params = ClientCredentialParameters + .builder(Collections.singleton(WEB_API_SCOPE)) + .fmiPath("SomeFmiPath/FmiCredentialPath") + .build(); + + IAuthenticationResult result = cca.acquireToken(params).get(); + + // Verify token + assertNotNull(result, "Auth result should not be null"); + assertNotNull(result.accessToken(), "Access token should not be null"); + assertFalse(result.accessToken().isEmpty(), "Access token should not be empty"); + + // Verify cache isolation + assertEquals(1, cca.tokenCache.accessTokens.size()); + String cacheKey = cca.tokenCache.accessTokens.keySet().iterator().next(); + assertTrue(cacheKey.contains("-atext-"), + "Cache key should use 'atext' credential type"); + } + + /** + * Flow 3: Sub-RMA getting FMI credential from another FMI credential (assertion callback). + * Uses context-aware assertion callback where fmi_path is available in AssertionRequestOptions. + */ + @Test + void flow3_FmiCredential_FromAnotherFmiCredential() throws Exception { + // Context-aware assertion callback that acquires an FMI credential from the RMA + Function assertionProvider = options -> { + try { + assertNotNull(options.fmiPath(), "fmiPath should be available in assertion context"); + return acquireFmiCredentialFromRma(); + } catch (Exception e) { + throw new RuntimeException("Failed to get FMI credential from RMA", e); + } + }; + + IClientCredential credential = ClientCredentialFactory.createFromCallback(assertionProvider); + + ConfidentialClientApplication cca = ConfidentialClientApplication.builder( + "urn:microsoft:identity:fmi", credential) + .authority(AUTHORITY) + .azureRegion(AZURE_REGION) + .build(); + + ClientCredentialParameters params = ClientCredentialParameters + .builder(Collections.singleton(FMI_EXCHANGE_SCOPE)) + .fmiPath("SomeFmiPath/Path") + .build(); + + IAuthenticationResult result = cca.acquireToken(params).get(); + + // Verify token + assertNotNull(result, "Auth result should not be null"); + assertNotNull(result.accessToken(), "Access token should not be null"); + assertFalse(result.accessToken().isEmpty(), "Access token should not be empty"); + + // Verify cache key uses expected hash for "SomeFmiPath/Path" + assertEquals(1, cca.tokenCache.accessTokens.size()); + String cacheKey = cca.tokenCache.accessTokens.keySet().iterator().next(); + assertTrue(cacheKey.contains("-atext-"), + "Cache key should use 'atext' credential type"); + String expectedHash = "7CX57Q63os7benQ6ER0sxgJPtNQSv7TGb5zexcidFoI".toLowerCase(); + assertTrue(cacheKey.endsWith(expectedHash), + "Cache key should end with expected fmi_path hash for 'SomeFmiPath/Path', got: " + cacheKey); + } + + /** + * Flow 5: Sub-RMA getting FMI token for leaf entity using assertion callback. + * Uses a resource scope (WebAPI) rather than exchange scope. + */ + @Test + void flow5_FmiToken_FromFmiCredential() throws Exception { + Function assertionProvider = options -> { + try { + return acquireFmiCredentialFromRma(); + } catch (Exception e) { + throw new RuntimeException("Failed to get FMI credential from RMA", e); + } + }; + + IClientCredential credential = ClientCredentialFactory.createFromCallback(assertionProvider); + + ConfidentialClientApplication cca = ConfidentialClientApplication.builder( + "urn:microsoft:identity:fmi", credential) + .authority(AUTHORITY) + .azureRegion(AZURE_REGION) + .build(); + + ClientCredentialParameters params = ClientCredentialParameters + .builder(Collections.singleton(WEB_API_SCOPE)) + .fmiPath("SomeFmiPath/Path") + .build(); + + IAuthenticationResult result = cca.acquireToken(params).get(); + + // Verify token + assertNotNull(result, "Auth result should not be null"); + assertNotNull(result.accessToken(), "Access token should not be null"); + assertFalse(result.accessToken().isEmpty(), "Access token should not be empty"); + + // Verify cache isolation + assertEquals(1, cca.tokenCache.accessTokens.size()); + String cacheKey = cca.tokenCache.accessTokens.keySet().iterator().next(); + assertTrue(cacheKey.contains("-atext-"), + "Cache key should use 'atext' credential type"); + } + + /** + * Validates that cache correctly isolates tokens for different fmi_paths. + * Acquires tokens with two different fmi_path values and verifies cache isolation. + */ + @Test + void fmiPath_CacheIsolation_Integration() throws Exception { + IClientCertificate clientCert = ClientCredentialFactory.createFromCertificate(privateKey, certificate); + + ConfidentialClientApplication cca = ConfidentialClientApplication.builder(RMA_CLIENT_ID, clientCert) + .authority(AUTHORITY) + .sendX5c(true) + .azureRegion(AZURE_REGION) + .build(); + + // Acquire with first fmi_path + ClientCredentialParameters params1 = ClientCredentialParameters + .builder(Collections.singleton(FMI_EXCHANGE_SCOPE)) + .fmiPath("SomeFmiPath/FmiCredentialPath") + .build(); + IAuthenticationResult result1 = cca.acquireToken(params1).get(); + + // Acquire with different fmi_path (same scope) + ClientCredentialParameters params2 = ClientCredentialParameters + .builder(Collections.singleton(FMI_EXCHANGE_SCOPE)) + .fmiPath("SomeFmiPath/Path") + .build(); + IAuthenticationResult result2 = cca.acquireToken(params2).get(); + + // Should have 2 separate cache entries + assertEquals(2, cca.tokenCache.accessTokens.size(), + "Different fmi_paths should produce separate cache entries"); + assertNotEquals(result1.accessToken(), result2.accessToken(), + "Tokens for different fmi_paths should be different"); + } + + /** + * Validates that the same fmi_path results in a cache hit on second request. + */ + @Test + void fmiPath_CacheHit_Integration() throws Exception { + IClientCertificate clientCert = ClientCredentialFactory.createFromCertificate(privateKey, certificate); + + ConfidentialClientApplication cca = ConfidentialClientApplication.builder(RMA_CLIENT_ID, clientCert) + .authority(AUTHORITY) + .sendX5c(true) + .azureRegion(AZURE_REGION) + .build(); + + ClientCredentialParameters params = ClientCredentialParameters + .builder(Collections.singleton(FMI_EXCHANGE_SCOPE)) + .fmiPath("SomeFmiPath/FmiCredentialPath") + .build(); + + IAuthenticationResult result1 = cca.acquireToken(params).get(); + IAuthenticationResult result2 = cca.acquireToken(params).get(); + + // Second call should be a cache hit (same token) + assertEquals(result1.accessToken(), result2.accessToken(), + "Same fmi_path should produce a cache hit"); + assertEquals(1, cca.tokenCache.accessTokens.size(), + "Should have only one cache entry"); + } + + /** + * Helper: acquires an FMI credential from the RMA using certificate + SN+I. + * This is the Leg 1 operation that produces a token usable as client_assertion. + */ + private String acquireFmiCredentialFromRma() throws Exception { + IClientCertificate clientCert = ClientCredentialFactory.createFromCertificate(privateKey, certificate); + + ConfidentialClientApplication rma = ConfidentialClientApplication.builder(RMA_CLIENT_ID, clientCert) + .authority(AUTHORITY) + .sendX5c(true) + .azureRegion(AZURE_REGION) + .build(); + + ClientCredentialParameters params = ClientCredentialParameters + .builder(Collections.singleton(FMI_EXCHANGE_SCOPE)) + .fmiPath("SomeFmiPath/FmiCredentialPath") + .build(); + + IAuthenticationResult result = rma.acquireToken(params).get(); + return result.accessToken(); + } +} 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 47f8e3fc..cc8ef709 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 @@ -188,6 +188,9 @@ public ClientCredentialParametersBuilder clientCredential(IClientCredential clie * @return builder that can be used to construct ClientCredentialParameters */ public ClientCredentialParametersBuilder fmiPath(String fmiPath) { + if (fmiPath != null && fmiPath.trim().isEmpty()) { + throw new IllegalArgumentException("fmiPath cannot be empty or blank"); + } this.fmiPath = fmiPath; return this; } 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 index b46ea1d7..151c71d4 100644 --- a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/FmiTest.java +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/FmiTest.java @@ -181,37 +181,6 @@ void fmiPath_CacheHitForSameFmiPath() throws Exception { verify(httpClientMock, times(1)).send(any()); } - @Test - void fmiPath_ExtendedCredentialTypeInCacheKey() 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/") - .instanceDiscovery(false) - .validateAuthority(false) - .httpClient(httpClientMock) - .build(); - - // Act — acquire with fmi_path - ClientCredentialParameters params = ClientCredentialParameters - .builder(Collections.singleton("api://AzureADTokenExchange/.default")) - .fmiPath("agentA") - .build(); - cca.acquireToken(params).get(); - - // Assert — cache entry should use "atext" credential type (matching Go/Python convention) - assertEquals(1, cca.tokenCache.accessTokens.size()); - String cacheKey = cca.tokenCache.accessTokens.keySet().iterator().next(); - assertTrue(cacheKey.contains("-atext-"), - "Cache key should contain 'atext' credential type for extended tokens, got: " + cacheKey); - } - @Test void fmiPath_CacheDoesNotCollideWithNonFmiTokens() throws Exception { // Arrange @@ -260,38 +229,6 @@ void fmiPath_CacheDoesNotCollideWithNonFmiTokens() throws Exception { // §3: ext_cache_key hash computation // ======================================================================== - @Test - void computeExtCacheKeyHash_MatchesCrossSDKAlgorithm() { - // Arrange — the same input should produce the same hash across .NET, Go, Python, and Java - TreeMap components = new TreeMap<>(); - components.put("fmi_path", "agentAppId123"); - - // Act - String hash = StringHelper.computeExtCacheKeyHash(components); - - // Assert — hash should be a non-empty Base64URL-encoded SHA-256 - assertNotNull(hash); - assertFalse(hash.isEmpty()); - // Base64URL characters only (no + / = padding) - assertTrue(hash.matches("[A-Za-z0-9_-]+"), "Hash should be Base64URL encoded: " + hash); - // SHA-256 produces 32 bytes → 43 Base64URL characters (without padding) - assertEquals(43, hash.length(), "Base64URL-encoded SHA-256 should be 43 chars: " + hash); - } - - @Test - void computeExtCacheKeyHash_DifferentValuesProduceDifferentHashes() { - TreeMap componentsA = new TreeMap<>(); - componentsA.put("fmi_path", "agentA"); - - TreeMap componentsB = new TreeMap<>(); - componentsB.put("fmi_path", "agentB"); - - String hashA = StringHelper.computeExtCacheKeyHash(componentsA); - String hashB = StringHelper.computeExtCacheKeyHash(componentsB); - - assertNotEquals(hashA, hashB, "Different fmi_path values should produce different hashes"); - } - @Test void computeExtCacheKeyHash_EmptyMapReturnsEmpty() { assertEquals("", StringHelper.computeExtCacheKeyHash(new TreeMap<>())); @@ -415,24 +352,130 @@ void assertionContext_LegacyCallableStillWorks() throws Exception { } // ======================================================================== - // §5: AssertionRequestOptions model + // Input validation // ======================================================================== @Test - void assertionRequestOptions_PropertiesAccessible() { - AssertionRequestOptions options = new AssertionRequestOptions( - "clientId123", - "https://login.microsoftonline.com/tenant/oauth2/v2.0/token", - "agentAppId"); - - assertEquals("clientId123", options.clientId()); - assertEquals("https://login.microsoftonline.com/tenant/oauth2/v2.0/token", options.tokenEndpoint()); - assertEquals("agentAppId", options.fmiPath()); + 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()); } + // ======================================================================== + // §3: Exact cache key string validation (cross-SDK compatibility) + // ======================================================================== + @Test - void assertionRequestOptions_NullFmiPath() { - AssertionRequestOptions options = new AssertionRequestOptions("clientId", "endpoint", null); - assertNull(options.fmiPath()); + void fmiPath_CacheKeyFormat_MatchesDotNetFormat() 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 .NET's Flow1 test: "SomeFmiPath/FmiCredentialPath" + // Expected hash (case-sensitive): zm2n0E62zwTsnNsozptLsoOoB_C7i-GfpxHYQQINJUw + // The full cache key is lowercased (both .NET and Java do this). + // 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/") + .instanceDiscovery(false) + .validateAuthority(false) + .httpClient(httpClientMock) + .build(); + + ClientCredentialParameters params = ClientCredentialParameters + .builder(Collections.singleton("api://AzureFMITokenExchange/.default")) + .fmiPath("SomeFmiPath/FmiCredentialPath") + .build(); + + cca.acquireToken(params).get(); + + // Verify the cache key structure + assertEquals(1, cca.tokenCache.accessTokens.size()); + String cacheKey = cca.tokenCache.accessTokens.keySet().iterator().next(); + + // Verify key uses "atext" credential type + assertTrue(cacheKey.contains("-atext-"), + "Cache key should contain 'atext' credential type, got: " + cacheKey); + // Verify key contains the clientId and tenantId + assertTrue(cacheKey.contains("3bf56293-fbb5-42bd-a407-248ba7431a8c"), + "Cache key should contain client ID"); + assertTrue(cacheKey.contains("10c419d4-4a50-45b2-aa4e-919fb84df24f"), + "Cache key should contain tenant ID"); + // Verify key ends with the lowercased hash of the fmi_path + String expectedHashLower = "zm2n0E62zwTsnNsozptLsoOoB_C7i-GfpxHYQQINJUw".toLowerCase(); + assertTrue(cacheKey.endsWith(expectedHashLower), + "Cache key should end with the fmi_path hash, got: " + cacheKey); + // Verify scope is in the key + assertTrue(cacheKey.contains("api://azurefmitokenexchange/.default"), + "Cache key should contain the requested scope (lowercased)"); + } + + @Test + void fmiPath_HashValueMatchesCrossSDK() { + // Verify that the Java hash computation matches .NET 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 .NET/Go/Python"); + + // Second known value from .NET tests + TreeMap components2 = new TreeMap<>(); + components2.put("fmi_path", "SomeFmiPath/Path"); + + String hash2 = StringHelper.computeExtCacheKeyHash(components2); + assertEquals("7CX57Q63os7benQ6ER0sxgJPtNQSv7TGb5zexcidFoI", hash2, + "Hash for 'SomeFmiPath/Path' should match .NET"); + } + + @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/") + .instanceDiscovery(false) + .validateAuthority(false) + .httpClient(httpClientMock) + .build(); + + ClientCredentialParameters params = ClientCredentialParameters + .builder(Collections.singleton("scope")) + .build(); + + cca.acquireToken(params).get(); + + assertEquals(1, cca.tokenCache.accessTokens.size()); + String cacheKey = cca.tokenCache.accessTokens.keySet().iterator().next(); + assertTrue(cacheKey.contains("-accesstoken-"), + "Cache key without fmi_path should use 'accesstoken' credential type, got: " + cacheKey); + assertFalse(cacheKey.contains("-atext-"), + "Cache key without fmi_path should NOT use 'atext' credential type"); } } From dd8c51a0d3aaae88ebd0b8a40aa581ec89dac822 Mon Sep 17 00:00:00 2001 From: avdunn Date: Wed, 6 May 2026 09:03:25 -0700 Subject: [PATCH 3/7] Clean up comments --- .../com/microsoft/aad/msal4j/AgenticIT.java | 8 +++--- .../java/com/microsoft/aad/msal4j/FmiIT.java | 5 ++-- .../microsoft/aad/msal4j/StringHelper.java | 4 +-- .../com/microsoft/aad/msal4j/TokenCache.java | 2 +- .../com/microsoft/aad/msal4j/FmiTest.java | 27 +++++++++---------- 5 files changed, 20 insertions(+), 26 deletions(-) diff --git a/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/AgenticIT.java b/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/AgenticIT.java index 7eabe1c6..366c6556 100644 --- a/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/AgenticIT.java +++ b/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/AgenticIT.java @@ -20,12 +20,12 @@ /** * Integration tests for agentic (agent identity) scenarios using MSAL Java APIs. - * Corresponds to .NET's Agentic.cs — tests the MSAL-level APIs for the agent identity flow + * Tests the MSAL-level APIs for the agent identity flow * (specifically the FMI portions that are available on this branch). * *

These tests use MSAL token acquisition APIs (unlike AgenticRawHttpIT which uses raw HTTP). * - *

Test configuration (same as .NET Agentic.cs): + *

Test configuration: *

    *
  • Blueprint app: {@link #BLUEPRINT_CLIENT_ID}
  • *
  • Agent app: {@link #AGENT_APP_ID}
  • @@ -42,7 +42,7 @@ @TestInstance(TestInstance.Lifecycle.PER_CLASS) class AgenticIT { - // Same config as .NET Agentic.cs + // Lab test configuration private static final String BLUEPRINT_CLIENT_ID = "aab5089d-e764-47e3-9f28-cc11c2513821"; private static final String TENANT_ID = "10c419d4-4a50-45b2-aa4e-919fb84df24f"; private static final String AGENT_APP_ID = "ab18ca07-d139-4840-8b3b-4be9610c6ed5"; @@ -74,8 +74,6 @@ void init() throws KeyStoreException, NoSuchProviderException, * This tests Leg 2 of the agent identity flow: * 1. Blueprint CCA acquires FMI credential (fmi_path = agentAppId) * 2. Agent CCA uses that credential as client_assertion to get Graph token - * - * Corresponds to .NET's AgentGetsAppTokenForGraphTest. */ @Test void agentGetsAppToken_UsingFmiAssertion() throws Exception { diff --git a/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/FmiIT.java b/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/FmiIT.java index f802b4df..d55d1c85 100644 --- a/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/FmiIT.java +++ b/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/FmiIT.java @@ -21,8 +21,7 @@ /** * Integration tests for FMI (Federated Managed Identity) support. - * Corresponds to .NET's FmiIntegrationTests.cs — validates real Entra ID interactions - * for the FMI token acquisition flows described in the FMI protocol spec v1.0 Section 3.2. + * Validates real Entra ID interactions for the FMI token acquisition flows. * *

    Test apps are in MSID Lab 4: *

      @@ -41,7 +40,7 @@ @TestInstance(TestInstance.Lifecycle.PER_CLASS) class FmiIT { - // Same configuration as .NET's FmiIntegrationTests.cs + // Lab test configuration private static final String TENANT_ID = "10c419d4-4a50-45b2-aa4e-919fb84df24f"; private static final String RMA_CLIENT_ID = "3bf56293-fbb5-42bd-a407-248ba7431a8c"; private static final String WEB_API_SCOPE = "api://aa464f73-2868-4f67-b0e7-fc2f749e757f/.default"; 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 c07dea41..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 @@ -83,8 +83,8 @@ static boolean isNullOrBlank(final String str) { /** * Computes an extended cache key hash from a sorted map of key-value components. - * Uses the same algorithm as MSAL .NET, Go, and Python: concatenate sorted key+value pairs, - * SHA-256 hash, then Base64URL encode without padding. + * 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 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 652e0495..6f72de4d 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 @@ -340,7 +340,7 @@ private static AccessTokenCacheEntity createAccessTokenCacheEntity(TokenRequestE /** * Computes the extended cache key hash for a request, if applicable. * Currently, this is used for client credential requests with an fmi_path parameter. - * The algorithm matches MSAL .NET/Go/Python: sorted key-value concatenation → SHA-256 → Base64URL. + * The algorithm uses sorted key-value concatenation → SHA-256 → Base64URL (cross-SDK compatible). */ private static String computeExtCacheKeyHashForRequest(MsalRequest msalRequest) { if (msalRequest instanceof ClientCredentialRequest) { 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 index 151c71d4..eaade479 100644 --- a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/FmiTest.java +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/FmiTest.java @@ -20,15 +20,12 @@ * 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. - * - * These tests correspond to MSAL .NET's FmiIntegrationTests (§1-§3) and - * UserFederatedIdentityCredentialTests (§4-§5) in the AgentIDs_ComponentsReference. */ @TestInstance(TestInstance.Lifecycle.PER_CLASS) class FmiTest { // ======================================================================== - // §2: fmi_path body parameter + // fmi_path body parameter // ======================================================================== @Test @@ -99,7 +96,7 @@ void fmiPath_NotIncludedWhenNull() throws Exception { } // ======================================================================== - // §3: Cache key isolation (ext_cache_key) + // Cache key isolation (ext_cache_key) // ======================================================================== @Test @@ -226,7 +223,7 @@ void fmiPath_CacheDoesNotCollideWithNonFmiTokens() throws Exception { } // ======================================================================== - // §3: ext_cache_key hash computation + // ext_cache_key hash computation // ======================================================================== @Test @@ -236,7 +233,7 @@ void computeExtCacheKeyHash_EmptyMapReturnsEmpty() { } // ======================================================================== - // §5: Assertion context — AssertionRequestOptions + // Assertion context — AssertionRequestOptions // ======================================================================== @Test @@ -374,16 +371,16 @@ void fmiPath_WhitespaceOnlyThrowsIllegalArgumentException() { } // ======================================================================== - // §3: Exact cache key string validation (cross-SDK compatibility) + // Exact cache key string validation (cross-SDK compatibility) // ======================================================================== @Test - void fmiPath_CacheKeyFormat_MatchesDotNetFormat() throws Exception { + 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 .NET's Flow1 test: "SomeFmiPath/FmiCredentialPath" + // 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 (both .NET and Java do this). + // The full cache key is lowercased. // Java resolves login.microsoftonline.com → login.windows.net (preferred alias). DefaultHttpClient httpClientMock = mock(DefaultHttpClient.class); @@ -430,21 +427,21 @@ void fmiPath_CacheKeyFormat_MatchesDotNetFormat() throws Exception { @Test void fmiPath_HashValueMatchesCrossSDK() { - // Verify that the Java hash computation matches .NET for known inputs + // Verify that the Java hash computation matches other MSAL SDKs 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 .NET/Go/Python"); + "Hash for 'SomeFmiPath/FmiCredentialPath' should match cross-SDK value"); - // Second known value from .NET tests + // 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 .NET"); + "Hash for 'SomeFmiPath/Path' should match cross-SDK value"); } @Test From 7ef0518ce11579b15c9204459095b48713a50f95 Mon Sep 17 00:00:00 2001 From: avdunn Date: Wed, 6 May 2026 09:14:33 -0700 Subject: [PATCH 4/7] Fix tests --- .../com/microsoft/aad/msal4j/AgenticIT.java | 39 +++++++++++++++---- 1 file changed, 31 insertions(+), 8 deletions(-) diff --git a/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/AgenticIT.java b/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/AgenticIT.java index 366c6556..1df31bca 100644 --- a/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/AgenticIT.java +++ b/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/AgenticIT.java @@ -44,9 +44,11 @@ class AgenticIT { // Lab test configuration private static final String BLUEPRINT_CLIENT_ID = "aab5089d-e764-47e3-9f28-cc11c2513821"; + private static final String RMA_CLIENT_ID = "3bf56293-fbb5-42bd-a407-248ba7431a8c"; private static final String TENANT_ID = "10c419d4-4a50-45b2-aa4e-919fb84df24f"; private static final String AGENT_APP_ID = "ab18ca07-d139-4840-8b3b-4be9610c6ed5"; private static final String TOKEN_EXCHANGE_SCOPE = "api://AzureADTokenExchange/.default"; + private static final String FMI_EXCHANGE_SCOPE = "api://AzureFMITokenExchange/.default"; private static final String GRAPH_SCOPE = "https://graph.microsoft.com/.default"; private static final String AZURE_REGION = "westus3"; @@ -117,7 +119,7 @@ void assertionCallback_ReceivesFmiPathContext() throws Exception { Function assertionProvider = options -> { capturedOptions.set(options); try { - return acquireFmiCredentialForAgent(options.fmiPath()); + return acquireFmiCredentialFromRma(); } catch (Exception e) { throw new RuntimeException("Failed to acquire FMI credential", e); } @@ -132,7 +134,7 @@ void assertionCallback_ReceivesFmiPathContext() throws Exception { .build(); ClientCredentialParameters params = ClientCredentialParameters - .builder(Collections.singleton(TOKEN_EXCHANGE_SCOPE)) + .builder(Collections.singleton(FMI_EXCHANGE_SCOPE)) .fmiPath(AGENT_APP_ID) .skipCache(true) .build(); @@ -194,9 +196,7 @@ void agentAppToken_CacheHit() throws Exception { void agentFmiToken_CacheIsolation_DifferentFmiPaths() throws Exception { Function assertionProvider = options -> { try { - // Use the fmiPath from the context if available, otherwise use default agent ID - String targetPath = options.fmiPath() != null ? options.fmiPath() : AGENT_APP_ID; - return acquireFmiCredentialForAgent(targetPath); + return acquireFmiCredentialFromRma(); } catch (Exception e) { throw new RuntimeException("Failed to acquire FMI credential", e); } @@ -212,14 +212,14 @@ void agentFmiToken_CacheIsolation_DifferentFmiPaths() throws Exception { // Acquire with first fmi_path ClientCredentialParameters params1 = ClientCredentialParameters - .builder(Collections.singleton(TOKEN_EXCHANGE_SCOPE)) + .builder(Collections.singleton(FMI_EXCHANGE_SCOPE)) .fmiPath(AGENT_APP_ID) .build(); IAuthenticationResult result1 = cca.acquireToken(params1).get(); // Acquire with different fmi_path ClientCredentialParameters params2 = ClientCredentialParameters - .builder(Collections.singleton(TOKEN_EXCHANGE_SCOPE)) + .builder(Collections.singleton(FMI_EXCHANGE_SCOPE)) .fmiPath("SomeFmiPath/DifferentAgent") .build(); IAuthenticationResult result2 = cca.acquireToken(params2).get(); @@ -233,7 +233,7 @@ void agentFmiToken_CacheIsolation_DifferentFmiPaths() throws Exception { /** * Helper: acquires an FMI credential from the blueprint app for the given agent app ID. - * This is Leg 1 of the agent identity flow. + * Uses the agent token exchange scope (api://AzureADTokenExchange). */ private String acquireFmiCredentialForAgent(String agentAppId) throws Exception { IClientCertificate clientCert = ClientCredentialFactory.createFromCertificate(privateKey, certificate); @@ -253,4 +253,27 @@ private String acquireFmiCredentialForAgent(String agentAppId) throws Exception IAuthenticationResult result = blueprintCca.acquireToken(params).get(); return result.accessToken(); } + + /** + * Helper: acquires an FMI credential from the RMA using a certificate. + * Uses the FMI-specific exchange scope (api://AzureFMITokenExchange). + */ + private String acquireFmiCredentialFromRma() throws Exception { + IClientCertificate clientCert = ClientCredentialFactory.createFromCertificate(privateKey, certificate); + + ConfidentialClientApplication rmaCca = ConfidentialClientApplication.builder( + RMA_CLIENT_ID, clientCert) + .authority(AUTHORITY) + .sendX5c(true) + .azureRegion(AZURE_REGION) + .build(); + + ClientCredentialParameters params = ClientCredentialParameters + .builder(Collections.singleton(FMI_EXCHANGE_SCOPE)) + .fmiPath("SomeFmiPath/FmiCredentialPath") + .build(); + + IAuthenticationResult result = rmaCca.acquireToken(params).get(); + return result.accessToken(); + } } From 3c3abfc3021d8e062a9d2d003b8657f69287f962 Mon Sep 17 00:00:00 2001 From: avdunn Date: Wed, 6 May 2026 10:05:13 -0700 Subject: [PATCH 5/7] Clean up unit tests --- .../com/microsoft/aad/msal4j/FmiTest.java | 35 ++++++------------- 1 file changed, 10 insertions(+), 25 deletions(-) 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 index eaade479..4cbaf6eb 100644 --- a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/FmiTest.java +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/FmiTest.java @@ -48,7 +48,6 @@ void fmiPath_IncludedInTokenRequestBody() throws Exception { ClientCredentialParameters parameters = ClientCredentialParameters .builder(Collections.singleton("api://AzureADTokenExchange/.default")) .fmiPath("agentAppId123") - .skipCache(true) .build(); // Act @@ -81,7 +80,6 @@ void fmiPath_NotIncludedWhenNull() throws Exception { ClientCredentialParameters parameters = ClientCredentialParameters .builder(Collections.singleton("scopes")) - .skipCache(true) .build(); // Act @@ -264,7 +262,6 @@ void assertionContext_FmiPathPassedToContextAwareCallback() throws Exception { ClientCredentialParameters params = ClientCredentialParameters .builder(Collections.singleton("api://AzureADTokenExchange/.default")) .fmiPath("agentAppId456") - .skipCache(true) .build(); // Act @@ -304,7 +301,6 @@ void assertionContext_NullFmiPathWhenNotSet() throws Exception { ClientCredentialParameters params = ClientCredentialParameters .builder(Collections.singleton("scopes")) - .skipCache(true) .build(); // Act @@ -337,7 +333,6 @@ void assertionContext_LegacyCallableStillWorks() throws Exception { ClientCredentialParameters params = ClientCredentialParameters .builder(Collections.singleton("scopes")) .fmiPath("agentApp") - .skipCache(true) .build(); // Act — should not throw, even with fmiPath set (legacy callback ignores context) @@ -404,25 +399,14 @@ void fmiPath_CacheKeyFormat_MatchesCrossSDKFormat() throws Exception { cca.acquireToken(params).get(); - // Verify the cache key structure + // Verify the full cache key matches the expected cross-SDK format: + // "{homeAccountId}-{env}-{credType}-{clientId}-{tenantId}-{scopes}-{hash}" (all lowercased) assertEquals(1, cca.tokenCache.accessTokens.size()); String cacheKey = cca.tokenCache.accessTokens.keySet().iterator().next(); - // Verify key uses "atext" credential type - assertTrue(cacheKey.contains("-atext-"), - "Cache key should contain 'atext' credential type, got: " + cacheKey); - // Verify key contains the clientId and tenantId - assertTrue(cacheKey.contains("3bf56293-fbb5-42bd-a407-248ba7431a8c"), - "Cache key should contain client ID"); - assertTrue(cacheKey.contains("10c419d4-4a50-45b2-aa4e-919fb84df24f"), - "Cache key should contain tenant ID"); - // Verify key ends with the lowercased hash of the fmi_path - String expectedHashLower = "zm2n0E62zwTsnNsozptLsoOoB_C7i-GfpxHYQQINJUw".toLowerCase(); - assertTrue(cacheKey.endsWith(expectedHashLower), - "Cache key should end with the fmi_path hash, got: " + cacheKey); - // Verify scope is in the key - assertTrue(cacheKey.contains("api://azurefmitokenexchange/.default"), - "Cache key should contain the requested scope (lowercased)"); + 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 cross-SDK format"); } @Test @@ -468,11 +452,12 @@ void fmiPath_NoFmiPath_CacheKeyUsesAccessTokenCredentialType() throws Exception 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(); - assertTrue(cacheKey.contains("-accesstoken-"), - "Cache key without fmi_path should use 'accesstoken' credential type, got: " + cacheKey); - assertFalse(cacheKey.contains("-atext-"), - "Cache key without fmi_path should NOT use 'atext' credential type"); + + 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"); } } From 4bf9dff2349dc4d0230caccd5a55c254197c1bc5 Mon Sep 17 00:00:00 2001 From: avdunn Date: Wed, 6 May 2026 10:13:51 -0700 Subject: [PATCH 6/7] Remove tests that rely on agent identity flow behavior --- .../com/microsoft/aad/msal4j/AgenticIT.java | 102 +----------------- 1 file changed, 3 insertions(+), 99 deletions(-) diff --git a/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/AgenticIT.java b/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/AgenticIT.java index 1df31bca..86a4f5a1 100644 --- a/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/AgenticIT.java +++ b/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/AgenticIT.java @@ -20,36 +20,31 @@ /** * Integration tests for agentic (agent identity) scenarios using MSAL Java APIs. - * Tests the MSAL-level APIs for the agent identity flow - * (specifically the FMI portions that are available on this branch). + * Tests FMI credential acquisition via assertion callbacks and cache isolation. * *

      These tests use MSAL token acquisition APIs (unlike AgenticRawHttpIT which uses raw HTTP). * *

      Test configuration: *

        - *
      • Blueprint app: {@link #BLUEPRINT_CLIENT_ID}
      • + *
      • RMA app: {@link #RMA_CLIENT_ID}
      • *
      • Agent app: {@link #AGENT_APP_ID}
      • *
      • Tenant: {@link #TENANT_ID}
      • *
      * *

      Flows tested (FMI-only, no FIC/user_fic on this branch): *

        - *
      • Agent gets app token using FMI-sourced assertion (Leg 2 of agent identity)
      • *
      • Assertion callback receives correct context (AssertionRequestOptions)
      • - *
      • Cache isolation between different assertion-based flows
      • + *
      • Cache isolation between different fmi_path values
      • *
      */ @TestInstance(TestInstance.Lifecycle.PER_CLASS) class AgenticIT { // Lab test configuration - private static final String BLUEPRINT_CLIENT_ID = "aab5089d-e764-47e3-9f28-cc11c2513821"; private static final String RMA_CLIENT_ID = "3bf56293-fbb5-42bd-a407-248ba7431a8c"; private static final String TENANT_ID = "10c419d4-4a50-45b2-aa4e-919fb84df24f"; private static final String AGENT_APP_ID = "ab18ca07-d139-4840-8b3b-4be9610c6ed5"; - private static final String TOKEN_EXCHANGE_SCOPE = "api://AzureADTokenExchange/.default"; private static final String FMI_EXCHANGE_SCOPE = "api://AzureFMITokenExchange/.default"; - private static final String GRAPH_SCOPE = "https://graph.microsoft.com/.default"; private static final String AZURE_REGION = "westus3"; private static final String AUTHORITY = "https://login.microsoftonline.com/" + TENANT_ID + "/"; @@ -71,40 +66,6 @@ void init() throws KeyStoreException, NoSuchProviderException, assertNotNull(certificate, "Lab certificate not found. Ensure the lab cert is installed."); } - /** - * Agent gets an app-only token for Graph using an FMI-sourced client assertion. - * This tests Leg 2 of the agent identity flow: - * 1. Blueprint CCA acquires FMI credential (fmi_path = agentAppId) - * 2. Agent CCA uses that credential as client_assertion to get Graph token - */ - @Test - void agentGetsAppToken_UsingFmiAssertion() throws Exception { - // The assertion callback simulates what an SDK or middleware would do: - // it calls the blueprint app to get an FMI credential for the agent - Function assertionProvider = options -> { - try { - return acquireFmiCredentialForAgent(AGENT_APP_ID); - } catch (Exception e) { - throw new RuntimeException("Failed to acquire FMI credential", e); - } - }; - - IClientCredential credential = ClientCredentialFactory.createFromCallback(assertionProvider); - - ConfidentialClientApplication agentCca = ConfidentialClientApplication.builder(AGENT_APP_ID, credential) - .authority(AUTHORITY) - .build(); - - IAuthenticationResult result = agentCca.acquireToken(ClientCredentialParameters - .builder(Collections.singleton(GRAPH_SCOPE)) - .build()) - .get(); - - assertNotNull(result, "Auth result should not be null"); - assertNotNull(result.accessToken(), "Access token should not be null"); - assertFalse(result.accessToken().isEmpty(), "Access token should not be empty"); - } - /** * Verifies that the context-aware assertion callback receives the correct fmiPath * when the ClientCredentialParameters include an fmiPath. @@ -154,40 +115,6 @@ void assertionCallback_ReceivesFmiPathContext() throws Exception { assertNotNull(result.accessToken(), "Access token should not be null"); } - /** - * Verifies that the agent CCA can acquire a token and it gets cached, - * then the second request is a cache hit. - */ - @Test - void agentAppToken_CacheHit() throws Exception { - Function assertionProvider = options -> { - try { - return acquireFmiCredentialForAgent(AGENT_APP_ID); - } catch (Exception e) { - throw new RuntimeException("Failed to acquire FMI credential", e); - } - }; - - IClientCredential credential = ClientCredentialFactory.createFromCallback(assertionProvider); - - ConfidentialClientApplication agentCca = ConfidentialClientApplication.builder(AGENT_APP_ID, credential) - .authority(AUTHORITY) - .build(); - - ClientCredentialParameters params = ClientCredentialParameters - .builder(Collections.singleton(GRAPH_SCOPE)) - .build(); - - IAuthenticationResult result1 = agentCca.acquireToken(params).get(); - IAuthenticationResult result2 = agentCca.acquireToken(params).get(); - - // Second call should be a cache hit - assertEquals(result1.accessToken(), result2.accessToken(), - "Second request should be a cache hit returning the same token"); - assertEquals(1, agentCca.tokenCache.accessTokens.size(), - "Should have only one cache entry"); - } - /** * Verifies that tokens acquired with different fmi_paths are isolated in cache * even when using the same agent CCA. @@ -231,29 +158,6 @@ void agentFmiToken_CacheIsolation_DifferentFmiPaths() throws Exception { "Tokens for different fmi_paths should be different"); } - /** - * Helper: acquires an FMI credential from the blueprint app for the given agent app ID. - * Uses the agent token exchange scope (api://AzureADTokenExchange). - */ - private String acquireFmiCredentialForAgent(String agentAppId) throws Exception { - IClientCertificate clientCert = ClientCredentialFactory.createFromCertificate(privateKey, certificate); - - ConfidentialClientApplication blueprintCca = ConfidentialClientApplication.builder( - BLUEPRINT_CLIENT_ID, clientCert) - .authority(AUTHORITY) - .sendX5c(true) - .azureRegion(AZURE_REGION) - .build(); - - ClientCredentialParameters params = ClientCredentialParameters - .builder(Collections.singleton(TOKEN_EXCHANGE_SCOPE)) - .fmiPath(agentAppId) - .build(); - - IAuthenticationResult result = blueprintCca.acquireToken(params).get(); - return result.accessToken(); - } - /** * Helper: acquires an FMI credential from the RMA using a certificate. * Uses the FMI-specific exchange scope (api://AzureFMITokenExchange). From a5b5b014f2c0fbbe54058e749032ba33b302d58d Mon Sep 17 00:00:00 2001 From: avdunn Date: Wed, 6 May 2026 14:17:18 -0700 Subject: [PATCH 7/7] Improve assertion callback options --- .../aad/msal4j/AssertionResponse.java | 63 ++++++ .../microsoft/aad/msal4j/ClientAssertion.java | 79 +++++++- .../aad/msal4j/ClientCredentialFactory.java | 25 +++ .../aad/msal4j/TokenRequestExecutor.java | 30 ++- .../com/microsoft/aad/msal4j/FmiTest.java | 191 ++++++++++++++++++ 5 files changed, 382 insertions(+), 6 deletions(-) create mode 100644 msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/AssertionResponse.java diff --git a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/AssertionResponse.java b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/AssertionResponse.java new file mode 100644 index 00000000..51724245 --- /dev/null +++ b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/AssertionResponse.java @@ -0,0 +1,63 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.aad.msal4j; + +import java.security.cert.X509Certificate; + +/** + * Container returned from context-aware assertion provider callbacks. + * Allows the callback to supply both the client assertion JWT and an optional + * token-binding certificate for mutual-TLS Proof-of-Possession (mTLS PoP) scenarios. + * + *

      When a {@link #tokenBindingCertificate()} is provided, MSAL sets the + * {@code client_assertion_type} to {@code urn:ietf:params:oauth:client-assertion-type:jwt-pop} + * instead of the default {@code jwt-bearer}.

      + * + * @see ClientCredentialFactory#createFromCallback(java.util.function.Function) + * @see AssertionRequestOptions + */ +public class AssertionResponse { + + private final String assertion; + private final X509Certificate tokenBindingCertificate; + + /** + * Creates an AssertionResponse with just an assertion string (no token binding certificate). + * + * @param assertion the JWT assertion string + */ + public AssertionResponse(String assertion) { + this(assertion, null); + } + + /** + * Creates an AssertionResponse with an assertion string and an optional token-binding certificate. + * + * @param assertion the JWT assertion string + * @param tokenBindingCertificate optional certificate for mTLS PoP binding, or null for standard jwt-bearer + */ + public AssertionResponse(String assertion, X509Certificate tokenBindingCertificate) { + this.assertion = assertion; + this.tokenBindingCertificate = tokenBindingCertificate; + } + + /** + * Gets the JWT assertion string to use as the {@code client_assertion} parameter. + * + * @return the JWT assertion string + */ + public String assertion() { + return assertion; + } + + /** + * Gets the optional token-binding certificate for mutual-TLS Proof-of-Possession (mTLS PoP). + * When present, MSAL uses {@code client_assertion_type=jwt-pop} instead of {@code jwt-bearer}. + * + * @return the binding certificate, or null if not using mTLS PoP + */ + public X509Certificate tokenBindingCertificate() { + return tokenBindingCertificate; + } +} diff --git a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/ClientAssertion.java b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/ClientAssertion.java index 7eb1315a..aaad393b 100644 --- a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/ClientAssertion.java +++ b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/ClientAssertion.java @@ -10,9 +10,11 @@ final class ClientAssertion implements IClientAssertion { static final String ASSERTION_TYPE_JWT_BEARER = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"; + static final String ASSERTION_TYPE_JWT_POP = "urn:ietf:params:oauth:client-assertion-type:jwt-pop"; private final String assertion; private final Callable assertionProvider; private final Function contextAwareAssertionProvider; + private final Function contextAwareResponseProvider; /** * Constructor that accepts a static assertion string @@ -28,6 +30,7 @@ final class ClientAssertion implements IClientAssertion { this.assertion = assertion; this.assertionProvider = null; this.contextAwareAssertionProvider = null; + this.contextAwareResponseProvider = null; } /** @@ -44,6 +47,7 @@ final class ClientAssertion implements IClientAssertion { this.assertion = null; this.assertionProvider = assertionProvider; this.contextAwareAssertionProvider = null; + this.contextAwareResponseProvider = null; } /** @@ -62,6 +66,27 @@ final class ClientAssertion implements IClientAssertion { this.assertion = null; this.assertionProvider = null; this.contextAwareAssertionProvider = contextAwareAssertionProvider; + this.contextAwareResponseProvider = null; + } + + /** + * Constructor that accepts a context-aware function returning an {@link AssertionResponse}. + * This allows the callback to supply both the assertion JWT and an optional token-binding + * certificate for mTLS PoP scenarios. + * + * @param contextAwareResponseProvider A function that receives context and returns an AssertionResponse + * @throws NullPointerException if contextAwareResponseProvider is null + */ + ClientAssertion(final Function contextAwareResponseProvider, + boolean responseProvider) { + if (contextAwareResponseProvider == null) { + throw new NullPointerException("contextAwareResponseProvider"); + } + + this.assertion = null; + this.assertionProvider = null; + this.contextAwareAssertionProvider = null; + this.contextAwareResponseProvider = contextAwareResponseProvider; } /** @@ -74,6 +99,11 @@ final class ClientAssertion implements IClientAssertion { * @throws MsalClientException if the assertion provider returns null/empty or throws an exception */ public String assertion() { + if (contextAwareResponseProvider != null) { + AssertionResponse response = assertionResponse(new AssertionRequestOptions(null, null, null)); + return response.assertion(); + } + if (contextAwareAssertionProvider != null) { return assertion(new AssertionRequestOptions(null, null, null)); } @@ -95,6 +125,11 @@ public String assertion() { * @throws MsalClientException if the assertion provider returns null/empty or throws an exception */ String assertion(AssertionRequestOptions options) { + if (contextAwareResponseProvider != null) { + AssertionResponse response = assertionResponse(options); + return response.assertion(); + } + if (contextAwareAssertionProvider != null) { try { String generatedAssertion = contextAwareAssertionProvider.apply(options); @@ -118,10 +153,40 @@ String assertion(AssertionRequestOptions options) { } /** - * Returns true if this assertion uses a context-aware provider. + * Gets the full AssertionResponse from the context-aware response provider. + * Returns null if this ClientAssertion does not use a response provider. + * + * @param options context information for the assertion request + * @return An AssertionResponse, or null if not using a response provider + * @throws MsalClientException if the provider returns null or throws an exception + */ + AssertionResponse assertionResponse(AssertionRequestOptions options) { + if (contextAwareResponseProvider == null) { + return null; + } + + try { + AssertionResponse response = contextAwareResponseProvider.apply(options); + + if (response == null || StringHelper.isBlank(response.assertion())) { + throw new MsalClientException( + "Assertion provider returned null or empty assertion", + AuthenticationErrorCode.INVALID_JWT); + } + + return response; + } catch (MsalClientException ex) { + throw ex; + } catch (Exception ex) { + throw new MsalClientException(ex); + } + } + + /** + * Returns true if this assertion uses a context-aware provider (either string or response). */ boolean isContextAware() { - return contextAwareAssertionProvider != null; + return contextAwareAssertionProvider != null || contextAwareResponseProvider != null; } private String invokeCallable() { @@ -151,6 +216,11 @@ public boolean equals(Object o) { ClientAssertion other = (ClientAssertion) o; + // For context-aware response providers, we consider them equal if they're the same object + if (this.contextAwareResponseProvider != null && other.contextAwareResponseProvider != null) { + return this.contextAwareResponseProvider == other.contextAwareResponseProvider; + } + // 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; @@ -167,6 +237,11 @@ public boolean equals(Object o) { @Override public int hashCode() { + // For context-aware response providers, use the provider's identity hash code + if (contextAwareResponseProvider != null) { + return System.identityHashCode(contextAwareResponseProvider); + } + // For context-aware providers, use the provider's identity hash code if (contextAwareAssertionProvider != null) { return System.identityHashCode(contextAwareAssertionProvider); 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 43fbf8b5..96e61b98 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 @@ -125,4 +125,29 @@ public static IClientAssertion createFromCallback(FunctionWhen the returned {@link AssertionResponse} includes a + * {@link AssertionResponse#tokenBindingCertificate()}, MSAL uses + * {@code client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-pop} + * instead of the default {@code jwt-bearer}.

      + * + * @param assertionProvider Function that receives {@link AssertionRequestOptions} and produces + * an {@link AssertionResponse} containing the assertion and optional certificate + * @return {@link ClientAssertion} that will invoke the function each time assertion() is called + * @throws NullPointerException if assertionProvider is null + */ + public static IClientAssertion createFromAssertionResponseCallback( + Function assertionProvider) { + if (assertionProvider == null) { + throw new NullPointerException("assertionProvider"); + } + + return new ClientAssertion(assertionProvider, true); + } } 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 8af0b803..67835499 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 @@ -138,7 +138,6 @@ private void addCredentialToRequest(Map queryParameters, } else if (credentialToUse instanceof ClientAssertion) { // For client assertion, add client_assertion and client_assertion_type parameters ClientAssertion clientAssertion = (ClientAssertion) credentialToUse; - String assertionValue; if (clientAssertion.isContextAware()) { // Build assertion context with fmi_path if available String fmiPath = null; @@ -156,11 +155,17 @@ private void addCredentialToRequest(Map queryParameters, application.clientId(), tokenEndpoint, fmiPath); - assertionValue = clientAssertion.assertion(options); + + // Try to get the full AssertionResponse first + AssertionResponse response = clientAssertion.assertionResponse(options); + if (response != null) { + addAssertionResponseParams(queryParameters, response); + } else { + addJWTBearerAssertionParams(queryParameters, clientAssertion.assertion(options)); + } } else { - assertionValue = clientAssertion.assertion(); + addJWTBearerAssertionParams(queryParameters, clientAssertion.assertion()); } - addJWTBearerAssertionParams(queryParameters, assertionValue); } else if (credentialToUse instanceof ClientCertificate) { // For client certificate, generate a new assertion and add it to the request ClientCertificate certificate = (ClientCertificate) credentialToUse; @@ -183,6 +188,23 @@ private void addJWTBearerAssertionParams(Map queryParameters, St queryParameters.put("client_assertion_type", ClientAssertion.ASSERTION_TYPE_JWT_BEARER); } + /** + * Adds assertion parameters from an AssertionResponse, using jwt-pop assertion type + * when a token-binding certificate is present, or jwt-bearer otherwise. + * + * @param queryParameters The map of query parameters to add to + * @param response The AssertionResponse containing the assertion and optional certificate + */ + private void addAssertionResponseParams(Map queryParameters, AssertionResponse response) { + queryParameters.put("client_assertion", response.assertion()); + + if (response.tokenBindingCertificate() != null) { + queryParameters.put("client_assertion_type", ClientAssertion.ASSERTION_TYPE_JWT_POP); + } else { + queryParameters.put("client_assertion_type", ClientAssertion.ASSERTION_TYPE_JWT_BEARER); + } + } + private AuthenticationResult createAuthenticationResultFromOauthHttpResponse(HttpResponse oauthHttpResponse) { AuthenticationResult result; 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 index 4cbaf6eb..8047e86d 100644 --- a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/FmiTest.java +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/FmiTest.java @@ -460,4 +460,195 @@ void fmiPath_NoFmiPath_CacheKeyUsesAccessTokenCredentialType() throws Exception assertEquals(expectedKey, cacheKey, "Cache key without fmi_path should use 'accesstoken' credential type and no hash suffix"); } + + // ======================================================================== + // AssertionResponse (object-returning callback) + // ======================================================================== + + @Test + void assertionResponseCallback_WithoutCert_UsesJwtBearerType() throws Exception { + // Arrange: callback returns AssertionResponse with no certificate + DefaultHttpClient httpClientMock = mock(DefaultHttpClient.class); + + when(httpClientMock.send(any(HttpRequest.class))).thenReturn( + TestHelper.expectedResponse(HttpStatus.HTTP_OK, + TestHelper.getSuccessfulTokenResponse(new HashMap<>()))); + + Function responseProvider = + options -> new AssertionResponse("my-test-assertion-jwt"); + + ConfidentialClientApplication cca = + ConfidentialClientApplication.builder("clientId", + ClientCredentialFactory.createFromAssertionResponseCallback(responseProvider)) + .authority("https://login.microsoftonline.com/tenant/") + .instanceDiscovery(false) + .validateAuthority(false) + .httpClient(httpClientMock) + .build(); + + ClientCredentialParameters params = ClientCredentialParameters + .builder(Collections.singleton("scope")) + .build(); + + // Act + cca.acquireToken(params).get(); + + // Assert: verify the request used jwt-bearer assertion type + verify(httpClientMock).send(argThat(request -> { + String body = request.body(); + return body.contains("client_assertion=my-test-assertion-jwt") + && body.contains("client_assertion_type=urn%3Aietf%3Aparams%3Aoauth%3Aclient-assertion-type%3Ajwt-bearer"); + })); + } + + @Test + void assertionResponseCallback_WithCert_UsesJwtPopType() throws Exception { + // Arrange: callback returns AssertionResponse with a certificate + DefaultHttpClient httpClientMock = mock(DefaultHttpClient.class); + + when(httpClientMock.send(any(HttpRequest.class))).thenReturn( + TestHelper.expectedResponse(HttpStatus.HTTP_OK, + TestHelper.getSuccessfulTokenResponse(new HashMap<>()))); + + java.security.cert.X509Certificate mockCert = mock(java.security.cert.X509Certificate.class); + + Function responseProvider = + options -> new AssertionResponse("my-pop-assertion-jwt", mockCert); + + ConfidentialClientApplication cca = + ConfidentialClientApplication.builder("clientId", + ClientCredentialFactory.createFromAssertionResponseCallback(responseProvider)) + .authority("https://login.microsoftonline.com/tenant/") + .instanceDiscovery(false) + .validateAuthority(false) + .httpClient(httpClientMock) + .build(); + + ClientCredentialParameters params = ClientCredentialParameters + .builder(Collections.singleton("scope")) + .build(); + + // Act + cca.acquireToken(params).get(); + + // Assert: verify the request used jwt-pop assertion type + verify(httpClientMock).send(argThat(request -> { + String body = request.body(); + return body.contains("client_assertion=my-pop-assertion-jwt") + && body.contains("client_assertion_type=urn%3Aietf%3Aparams%3Aoauth%3Aclient-assertion-type%3Ajwt-pop"); + })); + } + + @Test + void assertionResponseCallback_ReceivesCorrectContext() throws Exception { + // Arrange: track the options passed to the callback + DefaultHttpClient httpClientMock = mock(DefaultHttpClient.class); + + when(httpClientMock.send(any(HttpRequest.class))).thenReturn( + TestHelper.expectedResponse(HttpStatus.HTTP_OK, + TestHelper.getSuccessfulTokenResponse(new HashMap<>()))); + + AtomicReference capturedOptions = new AtomicReference<>(); + + Function responseProvider = options -> { + capturedOptions.set(options); + return new AssertionResponse("context-assertion"); + }; + + ConfidentialClientApplication cca = + ConfidentialClientApplication.builder("myClientId", + ClientCredentialFactory.createFromAssertionResponseCallback(responseProvider)) + .authority("https://login.microsoftonline.com/myTenant/") + .instanceDiscovery(false) + .validateAuthority(false) + .httpClient(httpClientMock) + .build(); + + ClientCredentialParameters params = ClientCredentialParameters + .builder(Collections.singleton("scope")) + .fmiPath("myAgent/path") + .build(); + + // Act + cca.acquireToken(params).get(); + + // Assert: callback received the correct context + assertNotNull(capturedOptions.get()); + assertEquals("myClientId", capturedOptions.get().clientId()); + assertEquals("myAgent/path", capturedOptions.get().fmiPath()); + assertNotNull(capturedOptions.get().tokenEndpoint()); + } + + @Test + void assertionResponseCallback_NullAssertion_ThrowsException() { + // Arrange: callback returns AssertionResponse with null assertion + Function responseProvider = + options -> new AssertionResponse(null); + + ClientAssertion clientAssertion = new ClientAssertion(responseProvider, true); + + // Act & Assert + assertThrows(MsalClientException.class, () -> { + clientAssertion.assertionResponse(new AssertionRequestOptions("clientId", "endpoint", null)); + }); + } + + @Test + void assertionResponseCallback_NullResponse_ThrowsException() { + // Arrange: callback returns null + Function responseProvider = + options -> null; + + ClientAssertion clientAssertion = new ClientAssertion(responseProvider, true); + + // Act & Assert + assertThrows(MsalClientException.class, () -> { + clientAssertion.assertionResponse(new AssertionRequestOptions("clientId", "endpoint", null)); + }); + } + + @Test + void assertionResponseCallback_NullProvider_ThrowsNullPointerException() { + // Act & Assert + assertThrows(NullPointerException.class, () -> { + ClientCredentialFactory.createFromAssertionResponseCallback(null); + }); + } + + @Test + void assertionResponseCallback_WithFmiPath_CacheKeyUsesExtendedType() throws Exception { + // Arrange: AssertionResponse callback with fmiPath should use atext credential type + DefaultHttpClient httpClientMock = mock(DefaultHttpClient.class); + + when(httpClientMock.send(any(HttpRequest.class))).thenReturn( + TestHelper.expectedResponse(HttpStatus.HTTP_OK, + TestHelper.getSuccessfulTokenResponse(new HashMap<>()))); + + Function responseProvider = + options -> new AssertionResponse("fmi-assertion"); + + ConfidentialClientApplication cca = + ConfidentialClientApplication.builder("clientId", + ClientCredentialFactory.createFromAssertionResponseCallback(responseProvider)) + .authority("https://login.microsoftonline.com/tenant/") + .instanceDiscovery(false) + .validateAuthority(false) + .httpClient(httpClientMock) + .build(); + + ClientCredentialParameters params = ClientCredentialParameters + .builder(Collections.singleton("api://AzureADTokenExchange/.default")) + .fmiPath("SomeFmiPath/FmiCredentialPath") + .build(); + + // Act + cca.acquireToken(params).get(); + + // Assert: cache key uses "atext" credential type with hash + assertEquals(1, cca.tokenCache.accessTokens.size()); + String cacheKey = cca.tokenCache.accessTokens.keySet().iterator().next(); + + String expectedKey = "-login.windows.net-atext-clientid-tenant-api://azureadtokenexchange/.default openid profile offline_access-zm2n0e62zwtsnnsozptlsooob_c7i-gfpxhyqqinjuw"; + assertEquals(expectedKey, cacheKey); + } }