1515import java .security .cert .CertificateException ;
1616import java .security .cert .X509Certificate ;
1717import java .util .Collections ;
18+ import java .util .Set ;
1819import java .util .concurrent .atomic .AtomicReference ;
1920import 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 *
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 )
4346class 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