acquireToken(OnBehalfOfParameters parameters);
+
+ /**
+ * Acquires a token using the User Federated Identity Credential (user_fic) flow.
+ * This is Leg 3 of the agent identity protocol, where a federated identity credential
+ * (obtained from Leg 2) is exchanged for a user-scoped token.
+ *
+ * The user can be identified by either UPN (username) or Object ID, as specified
+ * in the {@link UserFederatedIdentityCredentialParameters}.
+ *
+ * @param parameters instance of {@link UserFederatedIdentityCredentialParameters}
+ * @return {@link CompletableFuture} containing an {@link IAuthenticationResult}
+ */
+ CompletableFuture acquireToken(UserFederatedIdentityCredentialParameters parameters);
}
diff --git a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/PublicApi.java b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/PublicApi.java
index 41e694b1..faf19722 100644
--- a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/PublicApi.java
+++ b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/PublicApi.java
@@ -13,6 +13,7 @@ enum PublicApi {
ACQUIRE_TOKEN_BY_DEVICE_CODE_FLOW(620),
ACQUIRE_TOKEN_FOR_CLIENT(729),
ACQUIRE_TOKEN_BY_AUTHORIZATION_CODE(831),
+ ACQUIRE_TOKEN_BY_USER_FEDERATED_IDENTITY_CREDENTIAL(900),
ACQUIRE_TOKEN_SILENTLY(800),
GET_ACCOUNTS(801),
REMOVE_ACCOUNTS(802),
diff --git a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/UserFederatedIdentityCredentialParameters.java b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/UserFederatedIdentityCredentialParameters.java
new file mode 100644
index 00000000..8263d6d0
--- /dev/null
+++ b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/UserFederatedIdentityCredentialParameters.java
@@ -0,0 +1,226 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+package com.microsoft.aad.msal4j;
+
+import java.util.Map;
+import java.util.Set;
+import java.util.UUID;
+
+import static com.microsoft.aad.msal4j.ParameterValidationUtils.validateNotBlank;
+import static com.microsoft.aad.msal4j.ParameterValidationUtils.validateNotNull;
+
+/**
+ * Object containing parameters for the User Federated Identity Credential (user_fic) flow.
+ * This is used for Leg 3 of the agent identity protocol, where a federated identity credential
+ * (obtained from Leg 2) is exchanged for a user-scoped token.
+ *
+ * Can be used as parameter to
+ * {@link ConfidentialClientApplication#acquireToken(UserFederatedIdentityCredentialParameters)}
+ */
+public class UserFederatedIdentityCredentialParameters implements IAcquireTokenParameters {
+
+ private Set scopes;
+ private String username;
+ private UUID userObjectId;
+ private String assertion;
+ private boolean forceRefresh;
+ private ClaimsRequest claims;
+ private Map extraHttpHeaders;
+ private Map extraQueryParameters;
+ private String tenant;
+
+ private UserFederatedIdentityCredentialParameters(
+ Set scopes,
+ String username,
+ UUID userObjectId,
+ String assertion,
+ boolean forceRefresh,
+ ClaimsRequest claims,
+ Map extraHttpHeaders,
+ Map extraQueryParameters,
+ String tenant) {
+ this.scopes = scopes;
+ this.username = username;
+ this.userObjectId = userObjectId;
+ this.assertion = assertion;
+ this.forceRefresh = forceRefresh;
+ this.claims = claims;
+ this.extraHttpHeaders = extraHttpHeaders;
+ this.extraQueryParameters = extraQueryParameters;
+ this.tenant = tenant;
+ }
+
+ /**
+ * Builder for {@link UserFederatedIdentityCredentialParameters} using a UPN (User Principal Name).
+ *
+ * @param scopes scopes application is requesting access to
+ * @param username the UPN of the target user (e.g., "user@contoso.com")
+ * @param assertion the federated identity credential assertion (JWT) obtained from Leg 2
+ * @return builder that can be used to construct UserFederatedIdentityCredentialParameters
+ */
+ public static UserFederatedIdentityCredentialParametersBuilder builder(
+ Set scopes, String username, String assertion) {
+ validateNotNull("scopes", scopes);
+ validateNotBlank("username", username);
+ validateNotBlank("assertion", assertion);
+
+ return new UserFederatedIdentityCredentialParametersBuilder()
+ .scopes(scopes)
+ .username(username)
+ .assertion(assertion);
+ }
+
+ /**
+ * Builder for {@link UserFederatedIdentityCredentialParameters} using a user Object ID.
+ *
+ * @param scopes scopes application is requesting access to
+ * @param userObjectId the Object ID (OID) of the target user
+ * @param assertion the federated identity credential assertion (JWT) obtained from Leg 2
+ * @return builder that can be used to construct UserFederatedIdentityCredentialParameters
+ */
+ public static UserFederatedIdentityCredentialParametersBuilder builder(
+ Set scopes, UUID userObjectId, String assertion) {
+ validateNotNull("scopes", scopes);
+ validateNotNull("userObjectId", userObjectId);
+ validateNotBlank("assertion", assertion);
+
+ return new UserFederatedIdentityCredentialParametersBuilder()
+ .scopes(scopes)
+ .userObjectId(userObjectId)
+ .assertion(assertion);
+ }
+
+ public Set scopes() {
+ return this.scopes;
+ }
+
+ /**
+ * @return the UPN of the target user, or null if user was identified by Object ID
+ */
+ public String username() {
+ return this.username;
+ }
+
+ /**
+ * @return the Object ID of the target user, or null if user was identified by UPN
+ */
+ public UUID userObjectId() {
+ return this.userObjectId;
+ }
+
+ /**
+ * @return the federated identity credential assertion (JWT)
+ */
+ public String assertion() {
+ return this.assertion;
+ }
+
+ /**
+ * @return whether to bypass the token cache and force a fresh token request
+ */
+ public boolean forceRefresh() {
+ return this.forceRefresh;
+ }
+
+ public ClaimsRequest claims() {
+ return this.claims;
+ }
+
+ public Map extraHttpHeaders() {
+ return this.extraHttpHeaders;
+ }
+
+ public Map extraQueryParameters() {
+ return this.extraQueryParameters;
+ }
+
+ public String tenant() {
+ return this.tenant;
+ }
+
+ public static class UserFederatedIdentityCredentialParametersBuilder {
+ private Set scopes;
+ private String username;
+ private UUID userObjectId;
+ private String assertion;
+ private boolean forceRefresh;
+ private ClaimsRequest claims;
+ private Map extraHttpHeaders;
+ private Map extraQueryParameters;
+ private String tenant;
+
+ UserFederatedIdentityCredentialParametersBuilder() {
+ }
+
+ UserFederatedIdentityCredentialParametersBuilder scopes(Set scopes) {
+ this.scopes = scopes;
+ return this;
+ }
+
+ UserFederatedIdentityCredentialParametersBuilder username(String username) {
+ this.username = username;
+ return this;
+ }
+
+ UserFederatedIdentityCredentialParametersBuilder userObjectId(UUID userObjectId) {
+ this.userObjectId = userObjectId;
+ return this;
+ }
+
+ UserFederatedIdentityCredentialParametersBuilder assertion(String assertion) {
+ this.assertion = assertion;
+ return this;
+ }
+
+ /**
+ * Forces MSAL to refresh the token from the identity provider even if a cached token is available.
+ *
+ * @param forceRefresh true to bypass the cache; otherwise false. Default is false.
+ * @return the builder
+ */
+ public UserFederatedIdentityCredentialParametersBuilder forceRefresh(boolean forceRefresh) {
+ this.forceRefresh = forceRefresh;
+ return this;
+ }
+
+ /**
+ * Claims to be requested through the OIDC claims request parameter.
+ */
+ public UserFederatedIdentityCredentialParametersBuilder claims(ClaimsRequest claims) {
+ this.claims = claims;
+ return this;
+ }
+
+ /**
+ * Adds additional headers to the token request.
+ */
+ public UserFederatedIdentityCredentialParametersBuilder extraHttpHeaders(Map extraHttpHeaders) {
+ this.extraHttpHeaders = extraHttpHeaders;
+ return this;
+ }
+
+ /**
+ * Adds additional parameters to the token request.
+ */
+ public UserFederatedIdentityCredentialParametersBuilder extraQueryParameters(Map extraQueryParameters) {
+ this.extraQueryParameters = extraQueryParameters;
+ return this;
+ }
+
+ /**
+ * Overrides the tenant value in the authority URL for this request.
+ */
+ public UserFederatedIdentityCredentialParametersBuilder tenant(String tenant) {
+ this.tenant = tenant;
+ return this;
+ }
+
+ public UserFederatedIdentityCredentialParameters build() {
+ return new UserFederatedIdentityCredentialParameters(
+ this.scopes, this.username, this.userObjectId, this.assertion,
+ this.forceRefresh, this.claims, this.extraHttpHeaders,
+ this.extraQueryParameters, this.tenant);
+ }
+ }
+}
diff --git a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/UserFederatedIdentityCredentialRequest.java b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/UserFederatedIdentityCredentialRequest.java
new file mode 100644
index 00000000..c9d633a6
--- /dev/null
+++ b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/UserFederatedIdentityCredentialRequest.java
@@ -0,0 +1,46 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+package com.microsoft.aad.msal4j;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+class UserFederatedIdentityCredentialRequest extends MsalRequest {
+
+ UserFederatedIdentityCredentialParameters parameters;
+
+ UserFederatedIdentityCredentialRequest(UserFederatedIdentityCredentialParameters parameters,
+ ConfidentialClientApplication application,
+ RequestContext requestContext) {
+ super(application, createMsalGrant(parameters), requestContext);
+ this.parameters = parameters;
+ }
+
+ private static OAuthAuthorizationGrant createMsalGrant(UserFederatedIdentityCredentialParameters parameters) {
+ Map params = new LinkedHashMap<>();
+
+ params.put(GrantConstants.GRANT_TYPE_PARAMETER, GrantConstants.USER_FIC);
+ params.put(GrantConstants.USER_FEDERATED_IDENTITY_CREDENTIAL, parameters.assertion());
+
+ // Mutually exclusive: user_id (by OID) or username (by UPN)
+ if (parameters.userObjectId() != null) {
+ params.put(GrantConstants.USER_ID_PARAMETER, parameters.userObjectId().toString());
+ } else {
+ params.put(GrantConstants.USERNAME_PARAMETER, parameters.username());
+ }
+
+ if (parameters.claims() != null) {
+ params.put("claims", parameters.claims().formatAsJSONString());
+ }
+
+ // OAuthAuthorizationGrant constructor automatically adds:
+ // - scope augmented with openid, offline_access, profile (COMMON_SCOPES)
+ // - client_info=1
+ return new OAuthAuthorizationGrant(params, parameters.scopes());
+ }
+
+ UserFederatedIdentityCredentialParameters parameters() {
+ return this.parameters;
+ }
+}
diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/UserFederatedIdentityCredentialTest.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/UserFederatedIdentityCredentialTest.java
new file mode 100644
index 00000000..6b4fb31c
--- /dev/null
+++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/UserFederatedIdentityCredentialTest.java
@@ -0,0 +1,557 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+package com.microsoft.aad.msal4j;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.TestInstance;
+
+import java.nio.charset.StandardCharsets;
+import java.util.Base64;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Set;
+import java.util.UUID;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.*;
+
+/**
+ * Tests for the User Federated Identity Credential (user_fic) flow.
+ */
+@TestInstance(TestInstance.Lifecycle.PER_CLASS)
+class UserFederatedIdentityCredentialTest {
+
+ private static final String CLIENT_ID = "test-client-id";
+ private static final String AUTHORITY = "https://login.microsoftonline.com/tenant/";
+ private static final Set SCOPES = Collections.singleton("https://graph.microsoft.com/.default");
+ private static final String FAKE_ASSERTION = "fake.assertion.jwt";
+ private static final String TEST_UPN = "user@contoso.com";
+ private static final UUID TEST_OID = UUID.fromString("597f86cd-13f3-44c0-bece-a1e77ba43228");
+
+ private ConfidentialClientApplication createCca(DefaultHttpClient httpClientMock) throws Exception {
+ return ConfidentialClientApplication.builder(CLIENT_ID, ClientCredentialFactory.createFromSecret("secret"))
+ .authority(AUTHORITY)
+ .instanceDiscovery(false)
+ .validateAuthority(false)
+ .httpClient(httpClientMock)
+ .build();
+ }
+
+ private HttpResponse createSuccessResponse() {
+ return createSuccessResponse(new HashMap<>());
+ }
+
+ private HttpResponse createSuccessResponse(HashMap responseValues) {
+ return TestHelper.expectedResponse(HttpStatus.HTTP_OK,
+ TestHelper.getSuccessfulTokenResponse(responseValues));
+ }
+
+ private HttpResponse createSuccessResponseWithIdToken() {
+ HashMap responseValues = new HashMap<>();
+ responseValues.put("id_token", TestHelper.ENCODED_JWT);
+ return createSuccessResponse(responseValues);
+ }
+
+ // ========================================================================
+ // Grant type and body parameters
+ // ========================================================================
+
+ @Test
+ void userFic_SendsCorrectGrantType() throws Exception {
+ // Arrange
+ DefaultHttpClient httpClientMock = mock(DefaultHttpClient.class);
+ when(httpClientMock.send(any(HttpRequest.class))).thenReturn(createSuccessResponse());
+
+ ConfidentialClientApplication cca = createCca(httpClientMock);
+
+ UserFederatedIdentityCredentialParameters parameters = UserFederatedIdentityCredentialParameters
+ .builder(SCOPES, TEST_UPN, FAKE_ASSERTION)
+ .forceRefresh(true)
+ .build();
+
+ // Act
+ cca.acquireToken(parameters).get();
+
+ // Assert — verify grant_type=user_fic in POST body
+ verify(httpClientMock).send(argThat(request -> {
+ String body = request.body();
+ return body.contains("grant_type=user_fic");
+ }));
+ }
+
+ // ========================================================================
+ // user_federated_identity_credential body parameter
+ // ========================================================================
+
+ @Test
+ void userFic_SendsAssertionInBody() throws Exception {
+ // Arrange
+ DefaultHttpClient httpClientMock = mock(DefaultHttpClient.class);
+ when(httpClientMock.send(any(HttpRequest.class))).thenReturn(createSuccessResponse());
+
+ ConfidentialClientApplication cca = createCca(httpClientMock);
+
+ UserFederatedIdentityCredentialParameters parameters = UserFederatedIdentityCredentialParameters
+ .builder(SCOPES, TEST_UPN, FAKE_ASSERTION)
+ .forceRefresh(true)
+ .build();
+
+ // Act
+ cca.acquireToken(parameters).get();
+
+ // Assert — verify user_federated_identity_credential in POST body
+ verify(httpClientMock).send(argThat(request -> {
+ String body = request.body();
+ return body.contains("user_federated_identity_credential=fake.assertion.jwt");
+ }));
+ }
+
+ // ========================================================================
+ // user_id / username body parameters — mutual exclusion
+ // ========================================================================
+
+ @Test
+ void userFic_WithUpn_SendsUsernameNotUserId() throws Exception {
+ // Arrange
+ DefaultHttpClient httpClientMock = mock(DefaultHttpClient.class);
+ when(httpClientMock.send(any(HttpRequest.class))).thenReturn(createSuccessResponse());
+
+ ConfidentialClientApplication cca = createCca(httpClientMock);
+
+ UserFederatedIdentityCredentialParameters parameters = UserFederatedIdentityCredentialParameters
+ .builder(SCOPES, TEST_UPN, FAKE_ASSERTION)
+ .forceRefresh(true)
+ .build();
+
+ // Act
+ cca.acquireToken(parameters).get();
+
+ // Assert — username present, user_id absent
+ verify(httpClientMock).send(argThat(request -> {
+ String body = request.body();
+ return body.contains("username=user%40contoso.com")
+ && !body.contains("user_id=");
+ }));
+ }
+
+ @Test
+ void userFic_WithOid_SendsUserIdNotUsername() throws Exception {
+ // Arrange
+ DefaultHttpClient httpClientMock = mock(DefaultHttpClient.class);
+ when(httpClientMock.send(any(HttpRequest.class))).thenReturn(createSuccessResponse());
+
+ ConfidentialClientApplication cca = createCca(httpClientMock);
+
+ UserFederatedIdentityCredentialParameters parameters = UserFederatedIdentityCredentialParameters
+ .builder(SCOPES, TEST_OID, FAKE_ASSERTION)
+ .forceRefresh(true)
+ .build();
+
+ // Act
+ cca.acquireToken(parameters).get();
+
+ // Assert — user_id present, username absent (as grant param, may appear in common params)
+ verify(httpClientMock).send(argThat(request -> {
+ String body = request.body();
+ return body.contains("user_id=" + TEST_OID.toString())
+ && !body.contains("username=");
+ }));
+ }
+
+ // ========================================================================
+ // All parameters sent together
+ // ========================================================================
+
+ @Test
+ void userFic_SendsAllOAuthParametersTogether() throws Exception {
+ // Arrange
+ DefaultHttpClient httpClientMock = mock(DefaultHttpClient.class);
+ when(httpClientMock.send(any(HttpRequest.class))).thenReturn(createSuccessResponse());
+
+ ConfidentialClientApplication cca = createCca(httpClientMock);
+
+ UserFederatedIdentityCredentialParameters parameters = UserFederatedIdentityCredentialParameters
+ .builder(SCOPES, TEST_UPN, FAKE_ASSERTION)
+ .forceRefresh(true)
+ .build();
+
+ // Act
+ cca.acquireToken(parameters).get();
+
+ // Assert — all three key parameters present
+ verify(httpClientMock).send(argThat(request -> {
+ String body = request.body();
+ return body.contains("grant_type=user_fic")
+ && body.contains("user_federated_identity_credential=fake.assertion.jwt")
+ && body.contains("username=user%40contoso.com")
+ && body.contains("client_info=1");
+ }));
+ }
+
+ // ========================================================================
+ // Scope augmentation: openid, offline_access, profile added
+ // ========================================================================
+
+ @Test
+ void userFic_ScopeIncludesOidcScopes() throws Exception {
+ // Arrange
+ DefaultHttpClient httpClientMock = mock(DefaultHttpClient.class);
+ when(httpClientMock.send(any(HttpRequest.class))).thenReturn(createSuccessResponse());
+
+ ConfidentialClientApplication cca = createCca(httpClientMock);
+
+ UserFederatedIdentityCredentialParameters parameters = UserFederatedIdentityCredentialParameters
+ .builder(SCOPES, TEST_UPN, FAKE_ASSERTION)
+ .forceRefresh(true)
+ .build();
+
+ // Act
+ cca.acquireToken(parameters).get();
+
+ // Assert — scope includes OIDC scopes added by OAuthAuthorizationGrant
+ verify(httpClientMock).send(argThat(request -> {
+ String body = request.body();
+ return body.contains("openid")
+ && body.contains("offline_access")
+ && body.contains("profile");
+ }));
+ }
+
+ // ========================================================================
+ // Token stored in user cache
+ // ========================================================================
+
+ @Test
+ void userFic_TokenStoredInUserCache() throws Exception {
+ // Arrange
+ DefaultHttpClient httpClientMock = mock(DefaultHttpClient.class);
+ when(httpClientMock.send(any(HttpRequest.class))).thenReturn(createSuccessResponseWithIdToken());
+
+ ConfidentialClientApplication cca = createCca(httpClientMock);
+
+ UserFederatedIdentityCredentialParameters parameters = UserFederatedIdentityCredentialParameters
+ .builder(SCOPES, TEST_UPN, FAKE_ASSERTION)
+ .forceRefresh(true)
+ .build();
+
+ // Act
+ IAuthenticationResult result = cca.acquireToken(parameters).get();
+
+ // Assert — account is present (token stored in user cache)
+ assertNotNull(result.account(), "Result should have an account (user token cache)");
+
+ Set accounts = cca.getAccounts().get();
+ assertFalse(accounts.isEmpty(), "Accounts should be present in the cache");
+ }
+
+ // ========================================================================
+ // Force refresh bypasses cache
+ // ========================================================================
+
+ @Test
+ void userFic_ForceRefresh_BypassesCache() throws Exception {
+ // Arrange
+ AtomicInteger callCount = new AtomicInteger(0);
+
+ DefaultHttpClient httpClientMock = mock(DefaultHttpClient.class);
+ when(httpClientMock.send(any(HttpRequest.class))).thenAnswer(invocation -> {
+ callCount.incrementAndGet();
+ return createSuccessResponseWithIdToken();
+ });
+
+ ConfidentialClientApplication cca = createCca(httpClientMock);
+
+ // First call — populates cache
+ UserFederatedIdentityCredentialParameters params1 = UserFederatedIdentityCredentialParameters
+ .builder(SCOPES, TEST_UPN, FAKE_ASSERTION)
+ .forceRefresh(true)
+ .build();
+ cca.acquireToken(params1).get();
+ assertEquals(1, callCount.get(), "First call should hit IdP");
+
+ // Second call with forceRefresh — should bypass cache
+ UserFederatedIdentityCredentialParameters params2 = UserFederatedIdentityCredentialParameters
+ .builder(SCOPES, TEST_UPN, FAKE_ASSERTION)
+ .forceRefresh(true)
+ .build();
+ cca.acquireToken(params2).get();
+ assertEquals(2, callCount.get(), "Second call with forceRefresh should hit IdP again");
+ }
+
+ // ========================================================================
+ // Cache hit when not force-refreshing
+ // ========================================================================
+
+ @Test
+ void userFic_CacheHit_WhenNotForceRefreshing() throws Exception {
+ // Arrange
+ AtomicInteger callCount = new AtomicInteger(0);
+
+ DefaultHttpClient httpClientMock = mock(DefaultHttpClient.class);
+ when(httpClientMock.send(any(HttpRequest.class))).thenAnswer(invocation -> {
+ callCount.incrementAndGet();
+ return createSuccessResponseWithIdToken();
+ });
+
+ ConfidentialClientApplication cca = createCca(httpClientMock);
+
+ // First call — populates cache
+ UserFederatedIdentityCredentialParameters params1 = UserFederatedIdentityCredentialParameters
+ .builder(SCOPES, TEST_UPN, FAKE_ASSERTION)
+ .forceRefresh(true)
+ .build();
+ cca.acquireToken(params1).get();
+ assertEquals(1, callCount.get(), "First call should hit IdP");
+
+ // Second call without forceRefresh — should use cache
+ UserFederatedIdentityCredentialParameters params2 = UserFederatedIdentityCredentialParameters
+ .builder(SCOPES, TEST_UPN, FAKE_ASSERTION)
+ .forceRefresh(false)
+ .build();
+ cca.acquireToken(params2).get();
+ assertEquals(1, callCount.get(), "Second call without forceRefresh should use cache");
+ }
+
+ // ========================================================================
+ // Input validation
+ // ========================================================================
+
+ @Test
+ void userFic_NullUsername_Throws() {
+ assertThrows(IllegalArgumentException.class, () ->
+ UserFederatedIdentityCredentialParameters.builder(SCOPES, (String) null, FAKE_ASSERTION));
+ }
+
+ @Test
+ void userFic_EmptyUsername_Throws() {
+ assertThrows(IllegalArgumentException.class, () ->
+ UserFederatedIdentityCredentialParameters.builder(SCOPES, "", FAKE_ASSERTION));
+ }
+
+ @Test
+ void userFic_NullAssertion_Throws() {
+ assertThrows(IllegalArgumentException.class, () ->
+ UserFederatedIdentityCredentialParameters.builder(SCOPES, TEST_UPN, null));
+ }
+
+ @Test
+ void userFic_EmptyAssertion_Throws() {
+ assertThrows(IllegalArgumentException.class, () ->
+ UserFederatedIdentityCredentialParameters.builder(SCOPES, TEST_UPN, ""));
+ }
+
+ @Test
+ void userFic_NullScopes_Throws() {
+ assertThrows(IllegalArgumentException.class, () ->
+ UserFederatedIdentityCredentialParameters.builder(null, TEST_UPN, FAKE_ASSERTION));
+ }
+
+ @Test
+ void userFic_NullOid_Throws() {
+ assertThrows(IllegalArgumentException.class, () ->
+ UserFederatedIdentityCredentialParameters.builder(SCOPES, (UUID) null, FAKE_ASSERTION));
+ }
+
+ // ========================================================================
+ // Parameters object validation
+ // ========================================================================
+
+ @Test
+ void userFic_Parameters_UpnBuilder_SetsFieldsCorrectly() {
+ UserFederatedIdentityCredentialParameters params = UserFederatedIdentityCredentialParameters
+ .builder(SCOPES, TEST_UPN, FAKE_ASSERTION)
+ .forceRefresh(true)
+ .build();
+
+ assertEquals(SCOPES, params.scopes());
+ assertEquals(TEST_UPN, params.username());
+ assertNull(params.userObjectId());
+ assertEquals(FAKE_ASSERTION, params.assertion());
+ assertTrue(params.forceRefresh());
+ }
+
+ @Test
+ void userFic_Parameters_OidBuilder_SetsFieldsCorrectly() {
+ UserFederatedIdentityCredentialParameters params = UserFederatedIdentityCredentialParameters
+ .builder(SCOPES, TEST_OID, FAKE_ASSERTION)
+ .forceRefresh(false)
+ .build();
+
+ assertEquals(SCOPES, params.scopes());
+ assertNull(params.username());
+ assertEquals(TEST_OID, params.userObjectId());
+ assertEquals(FAKE_ASSERTION, params.assertion());
+ assertFalse(params.forceRefresh());
+ }
+
+ // ========================================================================
+ // Multi-user cache isolation
+ // ========================================================================
+
+ /**
+ * Creates a token response with a specific user identity (oid + preferred_username).
+ * This allows simulating different users in the token cache.
+ */
+ private HttpResponse createUserResponse(String oid, String preferredUsername, String accessToken, String tid) {
+ // Build client_info: Base64URL({"uid":"","utid":""})
+ String clientInfoJson = String.format("{\"uid\":\"%s\",\"utid\":\"%s\"}", oid, tid);
+ String clientInfo = Base64.getUrlEncoder().withoutPadding()
+ .encodeToString(clientInfoJson.getBytes(StandardCharsets.UTF_8));
+
+ // Build id_token with the user's identity
+ HashMap idTokenValues = new HashMap<>();
+ idTokenValues.put("oid", oid);
+ idTokenValues.put("preferred_username", preferredUsername);
+ idTokenValues.put("tid", tid);
+ String idToken = TestHelper.createIdToken(idTokenValues);
+
+ // Build full response
+ HashMap responseValues = new HashMap<>();
+ responseValues.put("access_token", accessToken);
+ responseValues.put("id_token", idToken);
+ responseValues.put("client_info", clientInfo);
+
+ return TestHelper.expectedResponse(HttpStatus.HTTP_OK,
+ TestHelper.getSuccessfulTokenResponse(responseValues));
+ }
+
+ /**
+ * Verifies that two different users (by UPN) acquire tokens via user_fic on the same CCA,
+ * and AcquireTokenSilent returns the correct cached token for each user.
+ */
+ @Test
+ void userFic_TwoUpns_SilentReturnsCorrectToken() throws Exception {
+ // Arrange
+ String alice_oid = "oid-alice-1111";
+ String alice_upn = "alice@contoso.com";
+ String alice_token = "access-token-alice";
+
+ String bob_oid = "oid-bob-2222";
+ String bob_upn = "bob@contoso.com";
+ String bob_token = "access-token-bob";
+
+ String tid = "f645ad92-e38d-4d1a-b510-d1b09a74a8ca";
+
+ AtomicReference nextResponse = new AtomicReference<>();
+
+ DefaultHttpClient httpClientMock = mock(DefaultHttpClient.class);
+ when(httpClientMock.send(any(HttpRequest.class))).thenAnswer(invocation -> nextResponse.get());
+
+ ConfidentialClientApplication cca = createCca(httpClientMock);
+
+ // Act: Acquire token for Alice
+ nextResponse.set(createUserResponse(alice_oid, alice_upn, alice_token, tid));
+
+ UserFederatedIdentityCredentialParameters aliceParams = UserFederatedIdentityCredentialParameters
+ .builder(SCOPES, alice_upn, FAKE_ASSERTION)
+ .forceRefresh(true)
+ .build();
+ IAuthenticationResult aliceResult = cca.acquireToken(aliceParams).get();
+ assertEquals(alice_token, aliceResult.accessToken());
+ assertNotNull(aliceResult.account());
+
+ // Act: Acquire token for Bob
+ nextResponse.set(createUserResponse(bob_oid, bob_upn, bob_token, tid));
+
+ UserFederatedIdentityCredentialParameters bobParams = UserFederatedIdentityCredentialParameters
+ .builder(SCOPES, bob_upn, FAKE_ASSERTION)
+ .forceRefresh(true)
+ .build();
+ IAuthenticationResult bobResult = cca.acquireToken(bobParams).get();
+ assertEquals(bob_token, bobResult.accessToken());
+ assertNotNull(bobResult.account());
+
+ // Assert: Both accounts in cache
+ Set accounts = cca.getAccounts().get();
+ assertEquals(2, accounts.size(), "Two accounts should be cached");
+
+ // Silent for Alice → should return Alice's token
+ IAccount aliceAccount = accounts.stream()
+ .filter(a -> alice_upn.equalsIgnoreCase(a.username()))
+ .findFirst().orElse(null);
+ assertNotNull(aliceAccount, "Alice account should be in cache");
+ IAuthenticationResult silentAlice = cca.acquireTokenSilently(
+ SilentParameters.builder(SCOPES, aliceAccount).build()).get();
+ assertEquals(alice_token, silentAlice.accessToken(), "Silent for Alice should return Alice's token");
+
+ // Silent for Bob → should return Bob's token
+ IAccount bobAccount = accounts.stream()
+ .filter(a -> bob_upn.equalsIgnoreCase(a.username()))
+ .findFirst().orElse(null);
+ assertNotNull(bobAccount, "Bob account should be in cache");
+ IAuthenticationResult silentBob = cca.acquireTokenSilently(
+ SilentParameters.builder(SCOPES, bobAccount).build()).get();
+ assertEquals(bob_token, silentBob.accessToken(), "Silent for Bob should return Bob's token");
+ }
+
+ /**
+ * Verifies that two different users (by OID) acquire tokens via user_fic on the same CCA,
+ * and AcquireTokenSilent resolves the correct account by OID.
+ */
+ @Test
+ void userFic_TwoOids_SilentReturnsCorrectToken() throws Exception {
+ // Arrange
+ String carol_oid = "oid-carol-3333";
+ String carol_upn = "carol@contoso.com";
+ String carol_token = "access-token-carol";
+
+ String dave_oid = "oid-dave-4444";
+ String dave_upn = "dave@contoso.com";
+ String dave_token = "access-token-dave";
+
+ String tid = "f645ad92-e38d-4d1a-b510-d1b09a74a8ca";
+
+ AtomicReference nextResponse = new AtomicReference<>();
+
+ DefaultHttpClient httpClientMock = mock(DefaultHttpClient.class);
+ when(httpClientMock.send(any(HttpRequest.class))).thenAnswer(invocation -> nextResponse.get());
+
+ ConfidentialClientApplication cca = createCca(httpClientMock);
+
+ // Act: Acquire token for Carol (using UPN)
+ nextResponse.set(createUserResponse(carol_oid, carol_upn, carol_token, tid));
+
+ UserFederatedIdentityCredentialParameters carolParams = UserFederatedIdentityCredentialParameters
+ .builder(SCOPES, carol_upn, FAKE_ASSERTION)
+ .forceRefresh(true)
+ .build();
+ IAuthenticationResult carolResult = cca.acquireToken(carolParams).get();
+ assertEquals(carol_token, carolResult.accessToken());
+
+ // Act: Acquire token for Dave (using UPN)
+ nextResponse.set(createUserResponse(dave_oid, dave_upn, dave_token, tid));
+
+ UserFederatedIdentityCredentialParameters daveParams = UserFederatedIdentityCredentialParameters
+ .builder(SCOPES, dave_upn, FAKE_ASSERTION)
+ .forceRefresh(true)
+ .build();
+ IAuthenticationResult daveResult = cca.acquireToken(daveParams).get();
+ assertEquals(dave_token, daveResult.accessToken());
+
+ // Assert: Both accounts in cache
+ Set accounts = cca.getAccounts().get();
+ assertEquals(2, accounts.size(), "Two accounts should be cached");
+
+ // Lookup by OID for Carol
+ IAccount carolAccount = accounts.stream()
+ .filter(a -> a.homeAccountId().contains(carol_oid))
+ .findFirst().orElse(null);
+ assertNotNull(carolAccount, "Carol account should be in cache");
+ IAuthenticationResult silentCarol = cca.acquireTokenSilently(
+ SilentParameters.builder(SCOPES, carolAccount).build()).get();
+ assertEquals(carol_token, silentCarol.accessToken(), "OID-based lookup for Carol should return Carol's token");
+
+ // Lookup by OID for Dave
+ IAccount daveAccount = accounts.stream()
+ .filter(a -> a.homeAccountId().contains(dave_oid))
+ .findFirst().orElse(null);
+ assertNotNull(daveAccount, "Dave account should be in cache");
+ IAuthenticationResult silentDave = cca.acquireTokenSilently(
+ SilentParameters.builder(SCOPES, daveAccount).build()).get();
+ assertEquals(dave_token, silentDave.accessToken(), "OID-based lookup for Dave should return Dave's token");
+ }
+}