Skip to content

Commit 6062d6d

Browse files
authored
Merge pull request #1025 from AzureAD/avdunn/fmi-support
Add Federated Managed Identity (FMI) support for client credentials flow
2 parents 453eab5 + 4ad17ed commit 6062d6d

17 files changed

Lines changed: 1474 additions & 11 deletions
Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT License.
3+
4+
package com.microsoft.aad.msal4j;
5+
6+
import com.microsoft.aad.msal4j.labapi.KeyVaultSecretsProvider;
7+
import org.junit.jupiter.api.BeforeAll;
8+
import org.junit.jupiter.api.Test;
9+
import org.junit.jupiter.api.TestInstance;
10+
11+
import static org.junit.jupiter.api.Assertions.*;
12+
13+
import java.io.IOException;
14+
import java.security.*;
15+
import java.security.cert.CertificateException;
16+
import java.security.cert.X509Certificate;
17+
import java.util.Collections;
18+
import java.util.concurrent.atomic.AtomicReference;
19+
import java.util.function.Function;
20+
21+
/**
22+
* Integration tests for agentic (agent identity) scenarios using MSAL Java APIs.
23+
* Tests FMI credential acquisition via assertion callbacks and cache isolation.
24+
*
25+
* <p>These tests use MSAL token acquisition APIs (unlike AgenticRawHttpIT which uses raw HTTP).
26+
*
27+
* <p>Test configuration:
28+
* <ul>
29+
* <li>RMA app: {@link #RMA_CLIENT_ID}</li>
30+
* <li>Agent app: {@link #AGENT_APP_ID}</li>
31+
* <li>Tenant: {@link #TENANT_ID}</li>
32+
* </ul>
33+
*
34+
* <p>Flows tested:
35+
* <ul>
36+
* <li>Assertion callback receives correct context (AssertionRequestOptions)</li>
37+
* <li>Cache isolation between different fmi_path values</li>
38+
* </ul>
39+
*/
40+
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
41+
class AgenticIT {
42+
43+
// Lab test configuration
44+
private static final String RMA_CLIENT_ID = "3bf56293-fbb5-42bd-a407-248ba7431a8c";
45+
private static final String TENANT_ID = "10c419d4-4a50-45b2-aa4e-919fb84df24f";
46+
private static final String AGENT_APP_ID = "ab18ca07-d139-4840-8b3b-4be9610c6ed5";
47+
private static final String FMI_EXCHANGE_SCOPE = "api://AzureFMITokenExchange/.default";
48+
private static final String AZURE_REGION = "westus3";
49+
50+
private static final String AUTHORITY = "https://login.microsoftonline.com/" + TENANT_ID + "/";
51+
52+
private PrivateKey privateKey;
53+
private X509Certificate certificate;
54+
55+
@BeforeAll
56+
void init() throws KeyStoreException, NoSuchProviderException,
57+
IOException, NoSuchAlgorithmException, CertificateException,
58+
UnrecoverableKeyException {
59+
KeyStore keystore = CertificateHelper.createKeyStore();
60+
keystore.load(null, null);
61+
62+
privateKey = (PrivateKey) keystore.getKey(KeyVaultSecretsProvider.CERTIFICATE_ALIAS, null);
63+
certificate = (X509Certificate) keystore.getCertificate(KeyVaultSecretsProvider.CERTIFICATE_ALIAS);
64+
65+
assertNotNull(privateKey, "Lab private key not found. Ensure the lab cert is installed.");
66+
assertNotNull(certificate, "Lab certificate not found. Ensure the lab cert is installed.");
67+
}
68+
69+
/**
70+
* Verifies that the context-aware assertion callback receives the correct fmiPath
71+
* when the ClientCredentialParameters include an fmiPath.
72+
*
73+
* This tests the assertion context propagation: when acquiring an FMI credential
74+
* using a context-aware callback, the fmiPath from the parameters flows to the callback.
75+
*/
76+
@Test
77+
void assertionCallback_ReceivesFmiPathContext() throws Exception {
78+
AtomicReference<AssertionRequestOptions> capturedOptions = new AtomicReference<>();
79+
80+
Function<AssertionRequestOptions, String> assertionProvider = options -> {
81+
capturedOptions.set(options);
82+
try {
83+
return acquireFmiCredentialFromRma();
84+
} catch (Exception e) {
85+
throw new RuntimeException("Failed to acquire FMI credential", e);
86+
}
87+
};
88+
89+
IClientCredential credential = ClientCredentialFactory.createFromCallback(assertionProvider);
90+
91+
ConfidentialClientApplication cca = ConfidentialClientApplication.builder(
92+
"urn:microsoft:identity:fmi", credential)
93+
.authority(AUTHORITY)
94+
.azureRegion(AZURE_REGION)
95+
.build();
96+
97+
ClientCredentialParameters params = ClientCredentialParameters
98+
.builder(Collections.singleton(FMI_EXCHANGE_SCOPE))
99+
.fmiPath(AGENT_APP_ID)
100+
.skipCache(true)
101+
.build();
102+
103+
IAuthenticationResult result = cca.acquireToken(params).get();
104+
105+
// Verify assertion callback received the correct context
106+
assertNotNull(capturedOptions.get(), "AssertionRequestOptions should have been passed to callback");
107+
assertEquals(AGENT_APP_ID, capturedOptions.get().clientAssertionFmiPath(),
108+
"clientAssertionFmiPath in callback should match the one set in parameters");
109+
assertEquals("urn:microsoft:identity:fmi", capturedOptions.get().clientId(),
110+
"clientId in callback should match the CCA client ID");
111+
assertNotNull(capturedOptions.get().tokenEndpoint(),
112+
"tokenEndpoint should be available in callback");
113+
114+
// Verify token was acquired
115+
assertNotNull(result.accessToken(), "Access token should not be null");
116+
}
117+
118+
/**
119+
* Verifies that tokens acquired with different fmi_paths are isolated in cache
120+
* even when using the same agent CCA.
121+
*/
122+
@Test
123+
void agentFmiToken_CacheIsolation_DifferentFmiPaths() throws Exception {
124+
Function<AssertionRequestOptions, String> assertionProvider = options -> {
125+
try {
126+
return acquireFmiCredentialFromRma();
127+
} catch (Exception e) {
128+
throw new RuntimeException("Failed to acquire FMI credential", e);
129+
}
130+
};
131+
132+
IClientCredential credential = ClientCredentialFactory.createFromCallback(assertionProvider);
133+
134+
ConfidentialClientApplication cca = ConfidentialClientApplication.builder(
135+
"urn:microsoft:identity:fmi", credential)
136+
.authority(AUTHORITY)
137+
.azureRegion(AZURE_REGION)
138+
.build();
139+
140+
// Acquire with first fmi_path
141+
ClientCredentialParameters params1 = ClientCredentialParameters
142+
.builder(Collections.singleton(FMI_EXCHANGE_SCOPE))
143+
.fmiPath(AGENT_APP_ID)
144+
.build();
145+
IAuthenticationResult result1 = cca.acquireToken(params1).get();
146+
147+
// Acquire with different fmi_path
148+
ClientCredentialParameters params2 = ClientCredentialParameters
149+
.builder(Collections.singleton(FMI_EXCHANGE_SCOPE))
150+
.fmiPath("SomeFmiPath/DifferentAgent")
151+
.build();
152+
IAuthenticationResult result2 = cca.acquireToken(params2).get();
153+
154+
// Should have separate cache entries
155+
assertEquals(2, cca.tokenCache.accessTokens.size(),
156+
"Different fmi_paths should produce separate cache entries");
157+
assertNotEquals(result1.accessToken(), result2.accessToken(),
158+
"Tokens for different fmi_paths should be different");
159+
}
160+
161+
/**
162+
* Helper: acquires an FMI credential from the RMA using a certificate.
163+
* Uses the FMI-specific exchange scope (api://AzureFMITokenExchange).
164+
*/
165+
private String acquireFmiCredentialFromRma() throws Exception {
166+
IClientCertificate clientCert = ClientCredentialFactory.createFromCertificate(privateKey, certificate);
167+
168+
ConfidentialClientApplication rmaCca = ConfidentialClientApplication.builder(
169+
RMA_CLIENT_ID, clientCert)
170+
.authority(AUTHORITY)
171+
.sendX5c(true)
172+
.azureRegion(AZURE_REGION)
173+
.build();
174+
175+
ClientCredentialParameters params = ClientCredentialParameters
176+
.builder(Collections.singleton(FMI_EXCHANGE_SCOPE))
177+
.fmiPath("SomeFmiPath/FmiCredentialPath")
178+
.build();
179+
180+
IAuthenticationResult result = rmaCca.acquireToken(params).get();
181+
return result.accessToken();
182+
}
183+
}

0 commit comments

Comments
 (0)