Skip to content

Commit 113c694

Browse files
committed
Improve test coverage of FIC
1 parent 637ccd9 commit 113c694

4 files changed

Lines changed: 698 additions & 34 deletions

File tree

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

Lines changed: 159 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,14 @@
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.
2324
* Corresponds to .NET's Agentic.cs — tests the MSAL-level APIs for the agent identity flow
24-
* (specifically the FMI portions that are available on this branch).
25+
* (specifically the FMI portions that are available on this branch, plus FIC user_fic flows).
2526
*
2627
* <p>These tests use MSAL token acquisition APIs (unlike AgenticRawHttpIT which uses raw HTTP).
2728
*
@@ -32,21 +33,26 @@
3233
* <li>Tenant: {@link #TENANT_ID}</li>
3334
* </ul>
3435
*
35-
* <p>Flows tested (FMI-only, no FIC/user_fic on this branch):
36+
* <p>Flows tested:
3637
* <ul>
3738
* <li>Agent gets app token using FMI-sourced assertion (Leg 2 of agent identity)</li>
3839
* <li>Assertion callback receives correct context (AssertionRequestOptions)</li>
3940
* <li>Cache isolation between different assertion-based flows</li>
41+
* <li>Full 3-leg flow: FMI → assertion → user_fic → user token</li>
42+
* <li>Multi-user cache isolation via user_fic</li>
4043
* </ul>
4144
*/
4245
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
4346
class AgenticIT {
4447

4548
// Same config as .NET Agentic.cs
4649
private static final String BLUEPRINT_CLIENT_ID = "aab5089d-e764-47e3-9f28-cc11c2513821";
50+
private static final String RMA_CLIENT_ID = "3bf56293-fbb5-42bd-a407-248ba7431a8c";
4751
private static final String TENANT_ID = "10c419d4-4a50-45b2-aa4e-919fb84df24f";
4852
private static final String AGENT_APP_ID = "ab18ca07-d139-4840-8b3b-4be9610c6ed5";
53+
private static final String USER_UPN = "agentuser1@id4slab1.onmicrosoft.com";
4954
private static final String TOKEN_EXCHANGE_SCOPE = "api://AzureADTokenExchange/.default";
55+
private static final String FMI_EXCHANGE_SCOPE = "api://AzureFMITokenExchange/.default";
5056
private static final String GRAPH_SCOPE = "https://graph.microsoft.com/.default";
5157
private static final String AZURE_REGION = "westus3";
5258

@@ -119,7 +125,7 @@ void assertionCallback_ReceivesFmiPathContext() throws Exception {
119125
Function<AssertionRequestOptions, String> assertionProvider = options -> {
120126
capturedOptions.set(options);
121127
try {
122-
return acquireFmiCredentialForAgent(options.fmiPath());
128+
return acquireFmiCredentialFromRma();
123129
} catch (Exception e) {
124130
throw new RuntimeException("Failed to acquire FMI credential", e);
125131
}
@@ -134,7 +140,7 @@ void assertionCallback_ReceivesFmiPathContext() throws Exception {
134140
.build();
135141

136142
ClientCredentialParameters params = ClientCredentialParameters
137-
.builder(Collections.singleton(TOKEN_EXCHANGE_SCOPE))
143+
.builder(Collections.singleton(FMI_EXCHANGE_SCOPE))
138144
.fmiPath(AGENT_APP_ID)
139145
.skipCache(true)
140146
.build();
@@ -196,9 +202,7 @@ void agentAppToken_CacheHit() throws Exception {
196202
void agentFmiToken_CacheIsolation_DifferentFmiPaths() throws Exception {
197203
Function<AssertionRequestOptions, String> assertionProvider = options -> {
198204
try {
199-
// Use the fmiPath from the context if available, otherwise use default agent ID
200-
String targetPath = options.fmiPath() != null ? options.fmiPath() : AGENT_APP_ID;
201-
return acquireFmiCredentialForAgent(targetPath);
205+
return acquireFmiCredentialFromRma();
202206
} catch (Exception e) {
203207
throw new RuntimeException("Failed to acquire FMI credential", e);
204208
}
@@ -214,14 +218,14 @@ void agentFmiToken_CacheIsolation_DifferentFmiPaths() throws Exception {
214218

215219
// Acquire with first fmi_path
216220
ClientCredentialParameters params1 = ClientCredentialParameters
217-
.builder(Collections.singleton(TOKEN_EXCHANGE_SCOPE))
221+
.builder(Collections.singleton(FMI_EXCHANGE_SCOPE))
218222
.fmiPath(AGENT_APP_ID)
219223
.build();
220224
IAuthenticationResult result1 = cca.acquireToken(params1).get();
221225

222226
// Acquire with different fmi_path
223227
ClientCredentialParameters params2 = ClientCredentialParameters
224-
.builder(Collections.singleton(TOKEN_EXCHANGE_SCOPE))
228+
.builder(Collections.singleton(FMI_EXCHANGE_SCOPE))
225229
.fmiPath("SomeFmiPath/DifferentAgent")
226230
.build();
227231
IAuthenticationResult result2 = cca.acquireToken(params2).get();
@@ -233,9 +237,130 @@ void agentFmiToken_CacheIsolation_DifferentFmiPaths() throws Exception {
233237
"Tokens for different fmi_paths should be different");
234238
}
235239

240+
/**
241+
* Full 3-leg agent identity flow: FMI → assertion → user_fic → user-scoped Graph token.
242+
* Uses the assertion callback pattern where the blueprint CCA acquires the FMI credential
243+
* and the agent CCA exchanges it for a user token.
244+
* Corresponds to .NET's AgentUserIdentityGetsTokenForGraphTest.
245+
*/
246+
@Test
247+
void agentUserIdentity_GetsTokenForGraph() throws Exception {
248+
// Build agent CCA with assertion callback that acquires FMI credential
249+
Function<AssertionRequestOptions, String> assertionProvider = options -> {
250+
try {
251+
return acquireFmiCredentialForAgent(AGENT_APP_ID);
252+
} catch (Exception e) {
253+
throw new RuntimeException("Failed to acquire FMI credential", e);
254+
}
255+
};
256+
257+
IClientCredential credential = ClientCredentialFactory.createFromCallback(assertionProvider);
258+
259+
ConfidentialClientApplication agentCca = ConfidentialClientApplication.builder(AGENT_APP_ID, credential)
260+
.authority(AUTHORITY)
261+
.build();
262+
263+
// Get instance token (T2) for user_fic exchange
264+
String t2 = acquireInstanceTokenForAgent();
265+
266+
// Exchange T2 for user-scoped token via user_fic grant
267+
// CCA authenticates with T1 (via assertion callback)
268+
UserFederatedIdentityCredentialParameters params = UserFederatedIdentityCredentialParameters
269+
.builder(Collections.singleton(GRAPH_SCOPE), USER_UPN, t2)
270+
.build();
271+
272+
IAuthenticationResult result = agentCca.acquireToken(params).get();
273+
274+
assertNotNull(result, "Auth result should not be null");
275+
assertNotNull(result.accessToken(), "Access token should not be null");
276+
assertFalse(result.accessToken().isEmpty(), "Access token should not be empty");
277+
assertNotNull(result.account(), "Account should not be null (user token)");
278+
279+
// Verify token is cached and silent retrieval works
280+
Set<IAccount> accounts = agentCca.getAccounts().get();
281+
assertFalse(accounts.isEmpty(), "Accounts should be in cache");
282+
283+
IAccount account = accounts.iterator().next();
284+
IAuthenticationResult silentResult = agentCca.acquireTokenSilently(
285+
SilentParameters.builder(Collections.singleton(GRAPH_SCOPE), account).build()).get();
286+
287+
assertEquals(result.accessToken(), silentResult.accessToken(),
288+
"Silent call should return cached token");
289+
}
290+
291+
/**
292+
* Verifies that user_fic tokens and app-only tokens are isolated in cache
293+
* on the same agent CCA instance. App token acquisition should not interfere
294+
* with user token acquisition.
295+
*/
296+
@Test
297+
void agentCca_AppAndUserTokens_CacheIsolation() throws Exception {
298+
Function<AssertionRequestOptions, String> assertionProvider = options -> {
299+
try {
300+
return acquireFmiCredentialForAgent(AGENT_APP_ID);
301+
} catch (Exception e) {
302+
throw new RuntimeException("Failed to acquire FMI credential", e);
303+
}
304+
};
305+
306+
IClientCredential credential = ClientCredentialFactory.createFromCallback(assertionProvider);
307+
308+
ConfidentialClientApplication agentCca = ConfidentialClientApplication.builder(AGENT_APP_ID, credential)
309+
.authority(AUTHORITY)
310+
.build();
311+
312+
// Acquire app-only token
313+
IAuthenticationResult appResult = agentCca.acquireToken(ClientCredentialParameters
314+
.builder(Collections.singleton(GRAPH_SCOPE))
315+
.build())
316+
.get();
317+
assertNotNull(appResult.accessToken());
318+
319+
// Acquire user token via user_fic (needs T2 = instance token)
320+
String t2 = acquireInstanceTokenForAgent();
321+
UserFederatedIdentityCredentialParameters userParams = UserFederatedIdentityCredentialParameters
322+
.builder(Collections.singleton(GRAPH_SCOPE), USER_UPN, t2)
323+
.build();
324+
325+
IAuthenticationResult userResult = agentCca.acquireToken(userParams).get();
326+
assertNotNull(userResult.accessToken());
327+
assertNotNull(userResult.account(), "User token should have an account");
328+
329+
// Tokens should be different (app vs user scoped)
330+
assertNotEquals(appResult.accessToken(), userResult.accessToken(),
331+
"App token and user token should be different");
332+
333+
// App cache should have 1 entry, user cache should have user account
334+
assertTrue(agentCca.tokenCache.accessTokens.size() >= 2,
335+
"Cache should have at least 2 entries (app + user)");
336+
}
337+
338+
/**
339+
* Helper: acquires an FMI credential from the RMA (Resource Management Application).
340+
* Uses FMI_EXCHANGE_SCOPE, matching FmiIT's Flow3 pattern.
341+
* Suitable for use as client_assertion when client_id = "urn:microsoft:identity:fmi".
342+
*/
343+
private String acquireFmiCredentialFromRma() throws Exception {
344+
IClientCertificate clientCert = ClientCredentialFactory.createFromCertificate(privateKey, certificate);
345+
346+
ConfidentialClientApplication rma = ConfidentialClientApplication.builder(RMA_CLIENT_ID, clientCert)
347+
.authority(AUTHORITY)
348+
.sendX5c(true)
349+
.azureRegion(AZURE_REGION)
350+
.build();
351+
352+
ClientCredentialParameters params = ClientCredentialParameters
353+
.builder(Collections.singleton(FMI_EXCHANGE_SCOPE))
354+
.fmiPath("SomeFmiPath/FmiCredentialPath")
355+
.build();
356+
357+
IAuthenticationResult result = rma.acquireToken(params).get();
358+
return result.accessToken();
359+
}
360+
236361
/**
237362
* Helper: acquires an FMI credential from the blueprint app for the given agent app ID.
238-
* This is Leg 1 of the agent identity flow.
363+
* This is Leg 1 of the agent identity flow — returns T1.
239364
*/
240365
private String acquireFmiCredentialForAgent(String agentAppId) throws Exception {
241366
IClientCertificate clientCert = ClientCredentialFactory.createFromCertificate(privateKey, certificate);
@@ -255,4 +380,28 @@ private String acquireFmiCredentialForAgent(String agentAppId) throws Exception
255380
IAuthenticationResult result = blueprintCca.acquireToken(params).get();
256381
return result.accessToken();
257382
}
383+
384+
/**
385+
* Helper: acquires an instance token (T2) for the agent app via the full 2-leg flow.
386+
* Leg 1: Blueprint → T1 (FMI credential)
387+
* Leg 2: Agent uses T1 as client_assertion → T2 (instance token)
388+
* T2 is used as the user_federated_identity_credential in Leg 3 (user_fic exchange).
389+
*/
390+
private String acquireInstanceTokenForAgent() throws Exception {
391+
String t1 = acquireFmiCredentialForAgent(AGENT_APP_ID);
392+
393+
IClientCredential agentCredential = ClientCredentialFactory.createFromClientAssertion(t1);
394+
395+
ConfidentialClientApplication agentCca = ConfidentialClientApplication.builder(AGENT_APP_ID, agentCredential)
396+
.authority(AUTHORITY)
397+
.build();
398+
399+
ClientCredentialParameters instanceParams = ClientCredentialParameters
400+
.builder(Collections.singleton(TOKEN_EXCHANGE_SCOPE))
401+
.skipCache(true)
402+
.build();
403+
404+
IAuthenticationResult instanceResult = agentCca.acquireToken(instanceParams).get();
405+
return instanceResult.accessToken();
406+
}
258407
}

0 commit comments

Comments
 (0)