Skip to content

Commit 637ccd9

Browse files
committed
Merge branch 'avdunn/fmi-support' of https://github.com/AzureAD/microsoft-authentication-library-for-java into avdunn/fic-support
2 parents e96cf86 + f09f806 commit 637ccd9

4 files changed

Lines changed: 693 additions & 76 deletions

File tree

Lines changed: 258 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,258 @@
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+
* 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+
*
26+
* <p>These tests use MSAL token acquisition APIs (unlike AgenticRawHttpIT which uses raw HTTP).
27+
*
28+
* <p>Test configuration (same as .NET Agentic.cs):
29+
* <ul>
30+
* <li>Blueprint app: {@link #BLUEPRINT_CLIENT_ID}</li>
31+
* <li>Agent app: {@link #AGENT_APP_ID}</li>
32+
* <li>Tenant: {@link #TENANT_ID}</li>
33+
* </ul>
34+
*
35+
* <p>Flows tested (FMI-only, no FIC/user_fic on this branch):
36+
* <ul>
37+
* <li>Agent gets app token using FMI-sourced assertion (Leg 2 of agent identity)</li>
38+
* <li>Assertion callback receives correct context (AssertionRequestOptions)</li>
39+
* <li>Cache isolation between different assertion-based flows</li>
40+
* </ul>
41+
*/
42+
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
43+
class AgenticIT {
44+
45+
// Same config as .NET Agentic.cs
46+
private static final String BLUEPRINT_CLIENT_ID = "aab5089d-e764-47e3-9f28-cc11c2513821";
47+
private static final String TENANT_ID = "10c419d4-4a50-45b2-aa4e-919fb84df24f";
48+
private static final String AGENT_APP_ID = "ab18ca07-d139-4840-8b3b-4be9610c6ed5";
49+
private static final String TOKEN_EXCHANGE_SCOPE = "api://AzureADTokenExchange/.default";
50+
private static final String GRAPH_SCOPE = "https://graph.microsoft.com/.default";
51+
private static final String AZURE_REGION = "westus3";
52+
53+
private static final String AUTHORITY = "https://login.microsoftonline.com/" + TENANT_ID + "/";
54+
55+
private PrivateKey privateKey;
56+
private X509Certificate certificate;
57+
58+
@BeforeAll
59+
void init() throws KeyStoreException, NoSuchProviderException,
60+
IOException, NoSuchAlgorithmException, CertificateException,
61+
UnrecoverableKeyException {
62+
KeyStore keystore = CertificateHelper.createKeyStore();
63+
keystore.load(null, null);
64+
65+
privateKey = (PrivateKey) keystore.getKey(KeyVaultSecretsProvider.CERTIFICATE_ALIAS, null);
66+
certificate = (X509Certificate) keystore.getCertificate(KeyVaultSecretsProvider.CERTIFICATE_ALIAS);
67+
68+
assertNotNull(privateKey, "Lab private key not found. Ensure the lab cert is installed.");
69+
assertNotNull(certificate, "Lab certificate not found. Ensure the lab cert is installed.");
70+
}
71+
72+
/**
73+
* Agent gets an app-only token for Graph using an FMI-sourced client assertion.
74+
* This tests Leg 2 of the agent identity flow:
75+
* 1. Blueprint CCA acquires FMI credential (fmi_path = agentAppId)
76+
* 2. Agent CCA uses that credential as client_assertion to get Graph token
77+
*
78+
* Corresponds to .NET's AgentGetsAppTokenForGraphTest.
79+
*/
80+
@Test
81+
void agentGetsAppToken_UsingFmiAssertion() throws Exception {
82+
// The assertion callback simulates what an SDK or middleware would do:
83+
// it calls the blueprint app to get an FMI credential for the agent
84+
Function<AssertionRequestOptions, String> assertionProvider = options -> {
85+
try {
86+
return acquireFmiCredentialForAgent(AGENT_APP_ID);
87+
} catch (Exception e) {
88+
throw new RuntimeException("Failed to acquire FMI credential", e);
89+
}
90+
};
91+
92+
IClientCredential credential = ClientCredentialFactory.createFromCallback(assertionProvider);
93+
94+
ConfidentialClientApplication agentCca = ConfidentialClientApplication.builder(AGENT_APP_ID, credential)
95+
.authority(AUTHORITY)
96+
.build();
97+
98+
IAuthenticationResult result = agentCca.acquireToken(ClientCredentialParameters
99+
.builder(Collections.singleton(GRAPH_SCOPE))
100+
.build())
101+
.get();
102+
103+
assertNotNull(result, "Auth result should not be null");
104+
assertNotNull(result.accessToken(), "Access token should not be null");
105+
assertFalse(result.accessToken().isEmpty(), "Access token should not be empty");
106+
}
107+
108+
/**
109+
* Verifies that the context-aware assertion callback receives the correct fmiPath
110+
* when the ClientCredentialParameters include an fmiPath.
111+
*
112+
* This tests the assertion context propagation: when acquiring an FMI credential
113+
* using a context-aware callback, the fmiPath from the parameters flows to the callback.
114+
*/
115+
@Test
116+
void assertionCallback_ReceivesFmiPathContext() throws Exception {
117+
AtomicReference<AssertionRequestOptions> capturedOptions = new AtomicReference<>();
118+
119+
Function<AssertionRequestOptions, String> assertionProvider = options -> {
120+
capturedOptions.set(options);
121+
try {
122+
return acquireFmiCredentialForAgent(options.fmiPath());
123+
} catch (Exception e) {
124+
throw new RuntimeException("Failed to acquire FMI credential", e);
125+
}
126+
};
127+
128+
IClientCredential credential = ClientCredentialFactory.createFromCallback(assertionProvider);
129+
130+
ConfidentialClientApplication cca = ConfidentialClientApplication.builder(
131+
"urn:microsoft:identity:fmi", credential)
132+
.authority(AUTHORITY)
133+
.azureRegion(AZURE_REGION)
134+
.build();
135+
136+
ClientCredentialParameters params = ClientCredentialParameters
137+
.builder(Collections.singleton(TOKEN_EXCHANGE_SCOPE))
138+
.fmiPath(AGENT_APP_ID)
139+
.skipCache(true)
140+
.build();
141+
142+
IAuthenticationResult result = cca.acquireToken(params).get();
143+
144+
// Verify assertion callback received the correct context
145+
assertNotNull(capturedOptions.get(), "AssertionRequestOptions should have been passed to callback");
146+
assertEquals(AGENT_APP_ID, capturedOptions.get().fmiPath(),
147+
"fmiPath in callback should match the one set in parameters");
148+
assertEquals("urn:microsoft:identity:fmi", capturedOptions.get().clientId(),
149+
"clientId in callback should match the CCA client ID");
150+
assertNotNull(capturedOptions.get().tokenEndpoint(),
151+
"tokenEndpoint should be available in callback");
152+
153+
// Verify token was acquired
154+
assertNotNull(result.accessToken(), "Access token should not be null");
155+
}
156+
157+
/**
158+
* Verifies that the agent CCA can acquire a token and it gets cached,
159+
* then the second request is a cache hit.
160+
*/
161+
@Test
162+
void agentAppToken_CacheHit() throws Exception {
163+
Function<AssertionRequestOptions, String> assertionProvider = options -> {
164+
try {
165+
return acquireFmiCredentialForAgent(AGENT_APP_ID);
166+
} catch (Exception e) {
167+
throw new RuntimeException("Failed to acquire FMI credential", e);
168+
}
169+
};
170+
171+
IClientCredential credential = ClientCredentialFactory.createFromCallback(assertionProvider);
172+
173+
ConfidentialClientApplication agentCca = ConfidentialClientApplication.builder(AGENT_APP_ID, credential)
174+
.authority(AUTHORITY)
175+
.build();
176+
177+
ClientCredentialParameters params = ClientCredentialParameters
178+
.builder(Collections.singleton(GRAPH_SCOPE))
179+
.build();
180+
181+
IAuthenticationResult result1 = agentCca.acquireToken(params).get();
182+
IAuthenticationResult result2 = agentCca.acquireToken(params).get();
183+
184+
// Second call should be a cache hit
185+
assertEquals(result1.accessToken(), result2.accessToken(),
186+
"Second request should be a cache hit returning the same token");
187+
assertEquals(1, agentCca.tokenCache.accessTokens.size(),
188+
"Should have only one cache entry");
189+
}
190+
191+
/**
192+
* Verifies that tokens acquired with different fmi_paths are isolated in cache
193+
* even when using the same agent CCA.
194+
*/
195+
@Test
196+
void agentFmiToken_CacheIsolation_DifferentFmiPaths() throws Exception {
197+
Function<AssertionRequestOptions, String> assertionProvider = options -> {
198+
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);
202+
} catch (Exception e) {
203+
throw new RuntimeException("Failed to acquire FMI credential", e);
204+
}
205+
};
206+
207+
IClientCredential credential = ClientCredentialFactory.createFromCallback(assertionProvider);
208+
209+
ConfidentialClientApplication cca = ConfidentialClientApplication.builder(
210+
"urn:microsoft:identity:fmi", credential)
211+
.authority(AUTHORITY)
212+
.azureRegion(AZURE_REGION)
213+
.build();
214+
215+
// Acquire with first fmi_path
216+
ClientCredentialParameters params1 = ClientCredentialParameters
217+
.builder(Collections.singleton(TOKEN_EXCHANGE_SCOPE))
218+
.fmiPath(AGENT_APP_ID)
219+
.build();
220+
IAuthenticationResult result1 = cca.acquireToken(params1).get();
221+
222+
// Acquire with different fmi_path
223+
ClientCredentialParameters params2 = ClientCredentialParameters
224+
.builder(Collections.singleton(TOKEN_EXCHANGE_SCOPE))
225+
.fmiPath("SomeFmiPath/DifferentAgent")
226+
.build();
227+
IAuthenticationResult result2 = cca.acquireToken(params2).get();
228+
229+
// Should have separate cache entries
230+
assertEquals(2, cca.tokenCache.accessTokens.size(),
231+
"Different fmi_paths should produce separate cache entries");
232+
assertNotEquals(result1.accessToken(), result2.accessToken(),
233+
"Tokens for different fmi_paths should be different");
234+
}
235+
236+
/**
237+
* 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.
239+
*/
240+
private String acquireFmiCredentialForAgent(String agentAppId) throws Exception {
241+
IClientCertificate clientCert = ClientCredentialFactory.createFromCertificate(privateKey, certificate);
242+
243+
ConfidentialClientApplication blueprintCca = ConfidentialClientApplication.builder(
244+
BLUEPRINT_CLIENT_ID, clientCert)
245+
.authority(AUTHORITY)
246+
.sendX5c(true)
247+
.azureRegion(AZURE_REGION)
248+
.build();
249+
250+
ClientCredentialParameters params = ClientCredentialParameters
251+
.builder(Collections.singleton(TOKEN_EXCHANGE_SCOPE))
252+
.fmiPath(agentAppId)
253+
.build();
254+
255+
IAuthenticationResult result = blueprintCca.acquireToken(params).get();
256+
return result.accessToken();
257+
}
258+
}

0 commit comments

Comments
 (0)