Skip to content

Commit 3e000a0

Browse files
authored
Merge pull request #1026 from AzureAD/avdunn/fic-support
Add User Federated Identity Credential (`user_fic`) grant type support
2 parents 6062d6d + e60cb4f commit 3e000a0

13 files changed

Lines changed: 1568 additions & 55 deletions

msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/AgenticIT.java

Lines changed: 202 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -15,39 +15,36 @@
1515
import java.security.cert.CertificateException;
1616
import java.security.cert.X509Certificate;
1717
import java.util.Collections;
18+
import java.util.Set;
1819
import java.util.concurrent.atomic.AtomicReference;
1920
import java.util.function.Function;
2021

2122
/**
2223
* Integration tests for agentic (agent identity) scenarios using MSAL Java APIs.
23-
* Tests FMI credential acquisition via assertion callbacks and cache isolation.
24+
* Corresponds to .NET's Agentic.cs — tests the MSAL-level APIs for the agent identity flow
25+
* (specifically the FMI portions that are available on this branch, plus FIC user_fic flows).
2426
*
2527
* <p>These tests use MSAL token acquisition APIs (unlike AgenticRawHttpIT which uses raw HTTP).
2628
*
2729
* <p>Test configuration:
2830
* <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>
31+
* <li>RMA app: see {@link TestConstants#AGENTIC_RMA_CLIENT_ID}</li>
32+
* <li>Agent app: see {@link TestConstants#AGENTIC_AGENT_APP_ID}</li>
33+
* <li>Tenant: see {@link TestConstants#AGENTIC_TENANT_ID}</li>
3234
* </ul>
3335
*
3436
* <p>Flows tested:
3537
* <ul>
3638
* <li>Assertion callback receives correct context (AssertionRequestOptions)</li>
3739
* <li>Cache isolation between different fmi_path values</li>
40+
* <li>Full 3-leg flow: FMI → assertion → user_fic → user token</li>
41+
* <li>Multi-user cache isolation via user_fic</li>
3842
* </ul>
3943
*/
4044
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
4145
class AgenticIT {
4246

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 + "/";
47+
private static final String AUTHORITY = "https://login.microsoftonline.com/" + TestConstants.AGENTIC_TENANT_ID + "/";
5148

5249
private PrivateKey privateKey;
5350
private X509Certificate certificate;
@@ -66,6 +63,42 @@ void init() throws KeyStoreException, NoSuchProviderException,
6663
assertNotNull(certificate, "Lab certificate not found. Ensure the lab cert is installed.");
6764
}
6865

66+
/**
67+
* Agent gets an app-only token for Graph using an FMI-sourced client assertion.
68+
* This tests Leg 2 of the agent identity flow:
69+
* 1. Blueprint CCA acquires FMI credential (fmi_path = agentAppId)
70+
* 2. Agent CCA uses that credential as client_assertion to get Graph token
71+
*
72+
* Corresponds to .NET's AgentGetsAppTokenForGraphTest.
73+
*/
74+
@Test
75+
void agentGetsAppToken_UsingFmiAssertion() throws Exception {
76+
// The assertion callback simulates what an SDK or middleware would do:
77+
// it calls the blueprint app to get an FMI credential for the agent
78+
Function<AssertionRequestOptions, String> assertionProvider = options -> {
79+
try {
80+
return acquireFmiCredentialForAgent(TestConstants.AGENTIC_AGENT_APP_ID);
81+
} catch (Exception e) {
82+
throw new RuntimeException("Failed to acquire FMI credential", e);
83+
}
84+
};
85+
86+
IClientCredential credential = ClientCredentialFactory.createFromCallback(assertionProvider);
87+
88+
ConfidentialClientApplication agentCca = ConfidentialClientApplication.builder(TestConstants.AGENTIC_AGENT_APP_ID, credential)
89+
.authority(AUTHORITY)
90+
.build();
91+
92+
IAuthenticationResult result = agentCca.acquireToken(ClientCredentialParameters
93+
.builder(Collections.singleton(TestConstants.AGENTIC_GRAPH_SCOPE))
94+
.build())
95+
.get();
96+
97+
assertNotNull(result, "Auth result should not be null");
98+
assertNotNull(result.accessToken(), "Access token should not be null");
99+
assertFalse(result.accessToken().isEmpty(), "Access token should not be empty");
100+
}
101+
69102
/**
70103
* Verifies that the context-aware assertion callback receives the correct fmiPath
71104
* when the ClientCredentialParameters include an fmiPath.
@@ -91,20 +124,20 @@ void assertionCallback_ReceivesFmiPathContext() throws Exception {
91124
ConfidentialClientApplication cca = ConfidentialClientApplication.builder(
92125
"urn:microsoft:identity:fmi", credential)
93126
.authority(AUTHORITY)
94-
.azureRegion(AZURE_REGION)
127+
.azureRegion(TestConstants.AGENTIC_AZURE_REGION)
95128
.build();
96129

97130
ClientCredentialParameters params = ClientCredentialParameters
98-
.builder(Collections.singleton(FMI_EXCHANGE_SCOPE))
99-
.fmiPath(AGENT_APP_ID)
131+
.builder(Collections.singleton(TestConstants.AGENTIC_FMI_EXCHANGE_SCOPE))
132+
.fmiPath(TestConstants.AGENTIC_AGENT_APP_ID)
100133
.skipCache(true)
101134
.build();
102135

103136
IAuthenticationResult result = cca.acquireToken(params).get();
104137

105138
// Verify assertion callback received the correct context
106139
assertNotNull(capturedOptions.get(), "AssertionRequestOptions should have been passed to callback");
107-
assertEquals(AGENT_APP_ID, capturedOptions.get().clientAssertionFmiPath(),
140+
assertEquals(TestConstants.AGENTIC_AGENT_APP_ID, capturedOptions.get().clientAssertionFmiPath(),
108141
"clientAssertionFmiPath in callback should match the one set in parameters");
109142
assertEquals("urn:microsoft:identity:fmi", capturedOptions.get().clientId(),
110143
"clientId in callback should match the CCA client ID");
@@ -134,19 +167,19 @@ void agentFmiToken_CacheIsolation_DifferentFmiPaths() throws Exception {
134167
ConfidentialClientApplication cca = ConfidentialClientApplication.builder(
135168
"urn:microsoft:identity:fmi", credential)
136169
.authority(AUTHORITY)
137-
.azureRegion(AZURE_REGION)
170+
.azureRegion(TestConstants.AGENTIC_AZURE_REGION)
138171
.build();
139172

140173
// Acquire with first fmi_path
141174
ClientCredentialParameters params1 = ClientCredentialParameters
142-
.builder(Collections.singleton(FMI_EXCHANGE_SCOPE))
143-
.fmiPath(AGENT_APP_ID)
175+
.builder(Collections.singleton(TestConstants.AGENTIC_FMI_EXCHANGE_SCOPE))
176+
.fmiPath(TestConstants.AGENTIC_AGENT_APP_ID)
144177
.build();
145178
IAuthenticationResult result1 = cca.acquireToken(params1).get();
146179

147180
// Acquire with different fmi_path
148181
ClientCredentialParameters params2 = ClientCredentialParameters
149-
.builder(Collections.singleton(FMI_EXCHANGE_SCOPE))
182+
.builder(Collections.singleton(TestConstants.AGENTIC_FMI_EXCHANGE_SCOPE))
150183
.fmiPath("SomeFmiPath/DifferentAgent")
151184
.build();
152185
IAuthenticationResult result2 = cca.acquireToken(params2).get();
@@ -159,25 +192,169 @@ void agentFmiToken_CacheIsolation_DifferentFmiPaths() throws Exception {
159192
}
160193

161194
/**
162-
* Helper: acquires an FMI credential from the RMA using a certificate.
163-
* Uses the FMI-specific exchange scope (api://AzureFMITokenExchange).
195+
* Full 3-leg agent identity flow: FMI → assertion → user_fic → user-scoped Graph token.
196+
* Uses the assertion callback pattern where the blueprint CCA acquires the FMI credential
197+
* and the agent CCA exchanges it for a user token.
198+
*/
199+
@Test
200+
void agentUserIdentity_GetsTokenForGraph() throws Exception {
201+
// Build agent CCA with assertion callback that acquires FMI credential
202+
Function<AssertionRequestOptions, String> assertionProvider = options -> {
203+
try {
204+
return acquireFmiCredentialForAgent(TestConstants.AGENTIC_AGENT_APP_ID);
205+
} catch (Exception e) {
206+
throw new RuntimeException("Failed to acquire FMI credential", e);
207+
}
208+
};
209+
210+
IClientCredential credential = ClientCredentialFactory.createFromCallback(assertionProvider);
211+
212+
ConfidentialClientApplication agentCca = ConfidentialClientApplication.builder(TestConstants.AGENTIC_AGENT_APP_ID, credential)
213+
.authority(AUTHORITY)
214+
.build();
215+
216+
// Get instance token (T2) for user_fic exchange
217+
String t2 = acquireInstanceTokenForAgent();
218+
219+
// Exchange T2 for user-scoped token via user_fic grant
220+
UserFederatedIdentityCredentialParameters params = UserFederatedIdentityCredentialParameters
221+
.builder(Collections.singleton(TestConstants.AGENTIC_GRAPH_SCOPE), TestConstants.AGENTIC_USER_UPN, t2)
222+
.build();
223+
224+
IAuthenticationResult result = agentCca.acquireToken(params).get();
225+
226+
assertNotNull(result, "Auth result should not be null");
227+
assertNotNull(result.accessToken(), "Access token should not be null");
228+
assertFalse(result.accessToken().isEmpty(), "Access token should not be empty");
229+
assertNotNull(result.account(), "Account should not be null (user token)");
230+
231+
// Verify token is cached and silent retrieval works
232+
Set<IAccount> accounts = agentCca.getAccounts().get();
233+
assertFalse(accounts.isEmpty(), "Accounts should be in cache");
234+
235+
IAccount account = accounts.iterator().next();
236+
IAuthenticationResult silentResult = agentCca.acquireTokenSilently(
237+
SilentParameters.builder(Collections.singleton(TestConstants.AGENTIC_GRAPH_SCOPE), account).build()).get();
238+
239+
assertEquals(result.accessToken(), silentResult.accessToken(),
240+
"Silent call should return cached token");
241+
}
242+
243+
/**
244+
* Verifies that user_fic tokens and app-only tokens are isolated in cache
245+
* on the same agent CCA instance. App token acquisition should not interfere
246+
* with user token acquisition.
247+
*/
248+
@Test
249+
void agentCca_AppAndUserTokens_CacheIsolation() throws Exception {
250+
Function<AssertionRequestOptions, String> assertionProvider = options -> {
251+
try {
252+
return acquireFmiCredentialForAgent(TestConstants.AGENTIC_AGENT_APP_ID);
253+
} catch (Exception e) {
254+
throw new RuntimeException("Failed to acquire FMI credential", e);
255+
}
256+
};
257+
258+
IClientCredential credential = ClientCredentialFactory.createFromCallback(assertionProvider);
259+
260+
ConfidentialClientApplication agentCca = ConfidentialClientApplication.builder(TestConstants.AGENTIC_AGENT_APP_ID, credential)
261+
.authority(AUTHORITY)
262+
.build();
263+
264+
// Acquire app-only token
265+
IAuthenticationResult appResult = agentCca.acquireToken(ClientCredentialParameters
266+
.builder(Collections.singleton(TestConstants.AGENTIC_GRAPH_SCOPE))
267+
.build())
268+
.get();
269+
assertNotNull(appResult.accessToken());
270+
271+
// Acquire user token via user_fic (needs T2 = instance token)
272+
String t2 = acquireInstanceTokenForAgent();
273+
UserFederatedIdentityCredentialParameters userParams = UserFederatedIdentityCredentialParameters
274+
.builder(Collections.singleton(TestConstants.AGENTIC_GRAPH_SCOPE), TestConstants.AGENTIC_USER_UPN, t2)
275+
.build();
276+
277+
IAuthenticationResult userResult = agentCca.acquireToken(userParams).get();
278+
assertNotNull(userResult.accessToken());
279+
assertNotNull(userResult.account(), "User token should have an account");
280+
281+
// Tokens should be different (app vs user scoped)
282+
assertNotEquals(appResult.accessToken(), userResult.accessToken(),
283+
"App token and user token should be different");
284+
285+
// App cache should have 1 entry, user cache should have user account
286+
assertEquals(2, agentCca.tokenCache.accessTokens.size(),
287+
"Cache should have exactly 2 entries (app + user)");
288+
}
289+
290+
/**
291+
* Helper: acquires an FMI credential from the RMA (Resource Management Application).
292+
* Uses TestConstants.AGENTIC_FMI_EXCHANGE_SCOPE, matching FmiIT's Flow3 pattern.
293+
* Suitable for use as client_assertion when client_id = "urn:microsoft:identity:fmi".
164294
*/
165295
private String acquireFmiCredentialFromRma() throws Exception {
166296
IClientCertificate clientCert = ClientCredentialFactory.createFromCertificate(privateKey, certificate);
167297

168298
ConfidentialClientApplication rmaCca = ConfidentialClientApplication.builder(
169-
RMA_CLIENT_ID, clientCert)
299+
TestConstants.AGENTIC_RMA_CLIENT_ID, clientCert)
170300
.authority(AUTHORITY)
171301
.sendX5c(true)
172-
.azureRegion(AZURE_REGION)
302+
.azureRegion(TestConstants.AGENTIC_AZURE_REGION)
173303
.build();
174304

175305
ClientCredentialParameters params = ClientCredentialParameters
176-
.builder(Collections.singleton(FMI_EXCHANGE_SCOPE))
306+
.builder(Collections.singleton(TestConstants.AGENTIC_FMI_EXCHANGE_SCOPE))
177307
.fmiPath("SomeFmiPath/FmiCredentialPath")
178308
.build();
179309

180310
IAuthenticationResult result = rmaCca.acquireToken(params).get();
181311
return result.accessToken();
182312
}
313+
314+
/**
315+
* Helper: acquires an FMI credential from the blueprint app for the given agent app ID.
316+
* This is Leg 1 of the agent identity flow — returns T1.
317+
*/
318+
private String acquireFmiCredentialForAgent(String agentAppId) throws Exception {
319+
IClientCertificate clientCert = ClientCredentialFactory.createFromCertificate(privateKey, certificate);
320+
321+
ConfidentialClientApplication blueprintCca = ConfidentialClientApplication.builder(
322+
TestConstants.AGENTIC_BLUEPRINT_CLIENT_ID, clientCert)
323+
.authority(AUTHORITY)
324+
.sendX5c(true)
325+
.azureRegion(TestConstants.AGENTIC_AZURE_REGION)
326+
.build();
327+
328+
ClientCredentialParameters params = ClientCredentialParameters
329+
.builder(Collections.singleton(TestConstants.AGENTIC_TOKEN_EXCHANGE_SCOPE))
330+
.fmiPath(agentAppId)
331+
.build();
332+
333+
IAuthenticationResult result = blueprintCca.acquireToken(params).get();
334+
return result.accessToken();
335+
}
336+
337+
/**
338+
* Helper: acquires an instance token (T2) for the agent app via the full 2-leg flow.
339+
* Leg 1: Blueprint → T1 (FMI credential)
340+
* Leg 2: Agent uses T1 as client_assertion → T2 (instance token)
341+
* T2 is used as the user_federated_identity_credential in Leg 3 (user_fic exchange).
342+
*/
343+
private String acquireInstanceTokenForAgent() throws Exception {
344+
String t1 = acquireFmiCredentialForAgent(TestConstants.AGENTIC_AGENT_APP_ID);
345+
346+
IClientCredential agentCredential = ClientCredentialFactory.createFromClientAssertion(t1);
347+
348+
ConfidentialClientApplication agentCca = ConfidentialClientApplication.builder(TestConstants.AGENTIC_AGENT_APP_ID, agentCredential)
349+
.authority(AUTHORITY)
350+
.build();
351+
352+
ClientCredentialParameters instanceParams = ClientCredentialParameters
353+
.builder(Collections.singleton(TestConstants.AGENTIC_TOKEN_EXCHANGE_SCOPE))
354+
.skipCache(true)
355+
.build();
356+
357+
IAuthenticationResult instanceResult = agentCca.acquireToken(instanceParams).get();
358+
return instanceResult.accessToken();
359+
}
183360
}

0 commit comments

Comments
 (0)