Skip to content

Add Federated Managed Identity (FMI) support for client credentials flow#1025

Open
Avery-Dunn wants to merge 7 commits intodevfrom
avdunn/fmi-support
Open

Add Federated Managed Identity (FMI) support for client credentials flow#1025
Avery-Dunn wants to merge 7 commits intodevfrom
avdunn/fmi-support

Conversation

@Avery-Dunn
Copy link
Copy Markdown
Contributor

@Avery-Dunn Avery-Dunn commented Apr 15, 2026

This PR adds Federated Managed Identity (FMI) support to MSAL Java's client credentials flow, enabling Leg 1 and Leg 2 of the agent identity protocol. FMI allows a blueprint application to acquire tokens scoped to a specific agent identity by providing an fmi_path parameter in the token request.

What's included

New public APIs:

  • ClientCredentialParameters.fmiPath(String) — sets the fmi_path body parameter sent in the client credentials POST request
  • ClientCredentialFactory.createFromCallback(Function<AssertionRequestOptions, String>) — creates a client assertion from a context-aware callback function (needed for Leg 2, where the assertion callback must know the FMI path to acquire the correct credential)
  • ClientCredentialFactory.createFromAssertionResponseCallback(Function<AssertionRequestOptions, AssertionResponse>) — creates a client assertion from a callback that returns an AssertionResponse object, supporting both the assertion JWT and an optional token-binding certificate for mTLS PoP scenarios
  • AssertionRequestOptions — context object passed to assertion callbacks, providing clientId, tokenEndpoint, and fmiPath
  • AssertionResponse — container returned from assertion callbacks, holding the assertion string and an optional X509Certificate for token binding. When a certificate is present, MSAL uses client_assertion_type=jwt-pop instead of jwt-bearer

Cache key isolation (ext_cache_key):

  • Tokens acquired with different fmi_path values are isolated in the cache using an extended cache key hash
  • Hash algorithm matches other MSALs: SHA-256 of sorted key+value concatenation, Base64URL-encoded
  • Credential type changes from "AccessToken" to "atext" (AccessToken_Extended) for tokens with additional cache key components
  • Cache lookups correctly filter: tokens with a hash only match when the same hash is expected, and vice versa

Internal changes:

  • ClientCredentialRequest injects fmi_path into the OAuth2 POST body
  • TokenRequestExecutor passes fmiPath to the assertion callback via AssertionRequestOptions, and selects client_assertion_type (jwt-bearer or jwt-pop) based on whether the AssertionResponse includes a token-binding certificate
  • ClientAssertion extended to support Function<AssertionRequestOptions, String> and Function<AssertionRequestOptions, AssertionResponse> callbacks (in addition to existing Callable<String>)
  • TokenCache extended with extCacheKeyHash matching logic
  • AccessTokenCacheEntity extended with extCacheKeyHash field
  • StringHelper.computeExtCacheKeyHash() implements the SHA-256 hash algorithm
  • AcquireTokenByClientCredentialSupplier propagates extCacheKeyHash through the silent request path

Tests:

  • FmiTest.java — 21 unit tests covering input validation, cache key computation, assertion callback context, cache isolation between different FMI paths, and AssertionResponse behavior (jwt-bearer vs jwt-pop assertion type selection, context propagation, error handling)
  • FmiIT.java — 6 integration tests covering FMI credential acquisition (from cert, from RMA, chained FMI credentials, token from FMI credential, multi-FMI-path cache isolation)
  • AgenticIT.java — 2 integration tests covering FMI scenarios using RMA (FMI credential acquisition from RMA, token cache hit for repeated requests)

Alignment with MSAL .NET

This implementation matches the FMI behavior available on MSAL .NET's main branch. Key equivalences:

.NET Java
WithFmiPath(string) ClientCredentialParameters.fmiPath(String)
WithClientAssertion(Func<AssertionRequestOptions, Task<string>>) ClientCredentialFactory.createFromCallback(Function<AssertionRequestOptions, String>)
WithClientAssertion(Func<AssertionRequestOptions, CancellationToken, Task<ClientSignedAssertion>>) ClientCredentialFactory.createFromAssertionResponseCallback(Function<AssertionRequestOptions, AssertionResponse>)
ClientSignedAssertion (Assertion + TokenBindingCertificate) AssertionResponse (assertion + tokenBindingCertificate)
CoreHelpers.ComputeAccessTokenExtCacheKey() StringHelper.computeExtCacheKeyHash()
"AccessToken_Extended" credential type "atext" credential type (same as Go/Python)

Known differences from .NET (intentional)

  • Java's AssertionRequestOptions exposes clientId, tokenEndpoint, fmiPath. .NET additionally exposes TenantId, Authority, Claims, ClientCapabilities, CorrelationId. The additional fields can be added in a future PR if needed.
  • Java does not yet have WithFmiPathForClientAssertion (a separate FMI path passed only to the assertion callback, distinct from the body parameter). This is infrastructure for the composite AcquireTokenForAgent API which will be added in a subsequent PR.
  • Java does not have CancellationToken semantics. The AssertionResponse callback uses Function<AssertionRequestOptions, AssertionResponse> (synchronous) rather than .NET's async Func<..., CancellationToken, Task<ClientSignedAssertion>>.

* @param contextAwareAssertionProvider A function that receives context and returns a JWT assertion string
* @throws NullPointerException if contextAwareAssertionProvider is null
*/
ClientAssertion(final Function<AssertionRequestOptions, String> contextAwareAssertionProvider) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we go with this callback with input object, we should also future proof things and have an output object. It will future proof mtls pop scenarios.

Function<AssertionRequestOptions, AssertionResponse>

ConfidentialClientApplication cca =
ConfidentialClientApplication.builder("clientId", ClientCredentialFactory.createFromSecret("secret"))
.authority("https://login.microsoftonline.com/tenant/")
.instanceDiscovery(false)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why does this need to be turned off? Same for validate authority.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Skipping instance discovery and authority validation is just a convenience used in a lot of Java's unit tests: unless the test is meant to cover authority behavior it's easier to turn it all off than deal with an extra HTTP mock or worrying about correct authority formats.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In .Net we add the mocks, I think we should add the mocks so that it mimics the common scenario

Comment thread msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/FmiTest.java
ClientCredentialParameters parameters = ClientCredentialParameters
.builder(Collections.singleton("api://AzureADTokenExchange/.default"))
.fmiPath("agentAppId123")
.skipCache(true)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why skip cache?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure why the agent added it, maybe to ensure the tests are clean? Regardless it wasn't needed, and I just removed it in the latest commit.

// 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-"),
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pls assert the full cache key against a constant.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the latest commit the test now check the full cache key

@Avery-Dunn Avery-Dunn changed the title First draft of FMI support Add Federated Managed Identity (FMI) support for client credentials flow May 6, 2026
@Avery-Dunn Avery-Dunn marked this pull request as ready for review May 6, 2026 20:06
@Avery-Dunn Avery-Dunn requested a review from a team as a code owner May 6, 2026 20:06
* <li>Tenant: {@link #TENANT_ID}</li>
* </ul>
*
* <p>Flows tested (FMI-only, no FIC/user_fic on this branch):
Copy link
Copy Markdown
Contributor

@neha-bhargava neha-bhargava May 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Probably AI generated this comment because of the 2 PRs but its a bit weird to have it in the code. "on this branch"

// Propagate ext_cache_key_hash for fmi_path-based cache isolation
if (!StringHelper.isBlank(this.clientCredentialRequest.parameters.fmiPath())) {
java.util.TreeMap<String, String> components = new java.util.TreeMap<>();
components.put("fmi_path", this.clientCredentialRequest.parameters.fmiPath());
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

extCacheKeyHash is computed here and again in TokenCache.computeExtCacheKeyHashForRequest — same TreeMap + computeExtCacheKeyHash logic in two places. If a second component is added to one location and not the other, cache reads and writes will silently diverge. The fully-qualified java.util.TreeMap here (instead of an import) suggests this was added in a hurry. Recommend computing the hash once — either in ClientCredentialParameters or ClientCredentialRequest — and passing it through.

* @return the binding certificate, or null if not using mTLS PoP
*/
public X509Certificate tokenBindingCertificate() {
return tokenBindingCertificate;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

tokenBindingCertificate() is read in exactly one place (TokenRequestExecutor.addAssertionResponseParams) — to decide between jwt-pop and jwt-bearer. The certificate is never passed to MtlsSslContextHelper or any TLS layer. Is this intentional? If the cert will be wired to actual mutual-TLS in a follow-up PR, that should be documented here. If the only purpose is to signal which assertion type to use, the API is misleading — callers will expect MSAL to do something with the certificate, but it doesn't. A boolean or enum would be more honest about what MSAL actually uses it for.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants