Add Federated Managed Identity (FMI) support for client credentials flow#1025
Add Federated Managed Identity (FMI) support for client credentials flow#1025Avery-Dunn wants to merge 7 commits intodevfrom
Conversation
| * @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) { |
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
Why does this need to be turned off? Same for validate authority.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
In .Net we add the mocks, I think we should add the mocks so that it mimics the common scenario
| ClientCredentialParameters parameters = ClientCredentialParameters | ||
| .builder(Collections.singleton("api://AzureADTokenExchange/.default")) | ||
| .fmiPath("agentAppId123") | ||
| .skipCache(true) |
There was a problem hiding this comment.
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-"), |
There was a problem hiding this comment.
Pls assert the full cache key against a constant.
There was a problem hiding this comment.
In the latest commit the test now check the full cache key
| * <li>Tenant: {@link #TENANT_ID}</li> | ||
| * </ul> | ||
| * | ||
| * <p>Flows tested (FMI-only, no FIC/user_fic on this branch): |
There was a problem hiding this comment.
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()); |
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
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.
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_pathparameter in the token request.What's included
New public APIs:
ClientCredentialParameters.fmiPath(String)— sets thefmi_pathbody parameter sent in the client credentials POST requestClientCredentialFactory.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 anAssertionResponseobject, supporting both the assertion JWT and an optional token-binding certificate for mTLS PoP scenariosAssertionRequestOptions— context object passed to assertion callbacks, providingclientId,tokenEndpoint, andfmiPathAssertionResponse— container returned from assertion callbacks, holding the assertion string and an optionalX509Certificatefor token binding. When a certificate is present, MSAL usesclient_assertion_type=jwt-popinstead ofjwt-bearerCache key isolation (
ext_cache_key):fmi_pathvalues are isolated in the cache using an extended cache key hash"AccessToken"to"atext"(AccessToken_Extended) for tokens with additional cache key componentsInternal changes:
ClientCredentialRequestinjectsfmi_pathinto the OAuth2 POST bodyTokenRequestExecutorpassesfmiPathto the assertion callback viaAssertionRequestOptions, and selectsclient_assertion_type(jwt-bearerorjwt-pop) based on whether theAssertionResponseincludes a token-binding certificateClientAssertionextended to supportFunction<AssertionRequestOptions, String>andFunction<AssertionRequestOptions, AssertionResponse>callbacks (in addition to existingCallable<String>)TokenCacheextended withextCacheKeyHashmatching logicAccessTokenCacheEntityextended withextCacheKeyHashfieldStringHelper.computeExtCacheKeyHash()implements the SHA-256 hash algorithmAcquireTokenByClientCredentialSupplierpropagatesextCacheKeyHashthrough the silent request pathTests:
FmiTest.java— 21 unit tests covering input validation, cache key computation, assertion callback context, cache isolation between different FMI paths, andAssertionResponsebehavior (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
mainbranch. Key equivalences: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)
AssertionRequestOptionsexposesclientId,tokenEndpoint,fmiPath. .NET additionally exposesTenantId,Authority,Claims,ClientCapabilities,CorrelationId. The additional fields can be added in a future PR if needed.WithFmiPathForClientAssertion(a separate FMI path passed only to the assertion callback, distinct from the body parameter). This is infrastructure for the compositeAcquireTokenForAgentAPI which will be added in a subsequent PR.CancellationTokensemantics. TheAssertionResponsecallback usesFunction<AssertionRequestOptions, AssertionResponse>(synchronous) rather than .NET's asyncFunc<..., CancellationToken, Task<ClientSignedAssertion>>.