From 96b3ade2593716ff8af6b32418c2c63313b38c42 Mon Sep 17 00:00:00 2001 From: avdunn Date: Wed, 15 Apr 2026 14:06:26 -0700 Subject: [PATCH 1/3] First draft FIC support --- .../aad/msal4j/AbstractApplicationBase.java | 4 + ...erFederatedIdentityCredentialSupplier.java | 65 +++ .../msal4j/ConfidentialClientApplication.java | 18 + .../microsoft/aad/msal4j/GrantConstants.java | 5 + .../IConfidentialClientApplication.java | 13 + .../com/microsoft/aad/msal4j/PublicApi.java | 1 + ...FederatedIdentityCredentialParameters.java | 226 ++++++++++ ...serFederatedIdentityCredentialRequest.java | 46 +++ .../UserFederatedIdentityCredentialTest.java | 389 ++++++++++++++++++ 9 files changed, 767 insertions(+) create mode 100644 msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/AcquireTokenByUserFederatedIdentityCredentialSupplier.java create mode 100644 msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/UserFederatedIdentityCredentialParameters.java create mode 100644 msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/UserFederatedIdentityCredentialRequest.java create mode 100644 msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/UserFederatedIdentityCredentialTest.java diff --git a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/AbstractApplicationBase.java b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/AbstractApplicationBase.java index aab323f4..5cc20f1a 100644 --- a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/AbstractApplicationBase.java +++ b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/AbstractApplicationBase.java @@ -111,6 +111,10 @@ private AuthenticationResultSupplier getAuthenticationResultSupplier(MsalRequest supplier = new AcquireTokenByOnBehalfOfSupplier( (ConfidentialClientApplication) this, (OnBehalfOfRequest) msalRequest); + } else if (msalRequest instanceof UserFederatedIdentityCredentialRequest) { + supplier = new AcquireTokenByUserFederatedIdentityCredentialSupplier( + (ConfidentialClientApplication) this, + (UserFederatedIdentityCredentialRequest) msalRequest); } else if (msalRequest instanceof ManagedIdentityRequest) { supplier = new AcquireTokenByManagedIdentitySupplier( (ManagedIdentityApplication) this, diff --git a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/AcquireTokenByUserFederatedIdentityCredentialSupplier.java b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/AcquireTokenByUserFederatedIdentityCredentialSupplier.java new file mode 100644 index 00000000..05154d25 --- /dev/null +++ b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/AcquireTokenByUserFederatedIdentityCredentialSupplier.java @@ -0,0 +1,65 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.aad.msal4j; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +class AcquireTokenByUserFederatedIdentityCredentialSupplier extends AuthenticationResultSupplier { + + private static final Logger LOG = LoggerFactory.getLogger(AcquireTokenByUserFederatedIdentityCredentialSupplier.class); + private UserFederatedIdentityCredentialRequest userFicRequest; + + AcquireTokenByUserFederatedIdentityCredentialSupplier(ConfidentialClientApplication clientApplication, + UserFederatedIdentityCredentialRequest userFicRequest) { + super(clientApplication, userFicRequest); + this.userFicRequest = userFicRequest; + } + + @Override + AuthenticationResult execute() throws Exception { + if (!userFicRequest.parameters.forceRefresh()) { + LOG.debug("ForceRefresh is false. Attempting cache lookup"); + try { + SilentParameters parameters = SilentParameters + .builder(this.userFicRequest.parameters.scopes()) + .claims(this.userFicRequest.parameters.claims()) + .tenant(this.userFicRequest.parameters.tenant()) + .build(); + + RequestContext context = new RequestContext( + this.clientApplication, + PublicApi.ACQUIRE_TOKEN_SILENTLY, + parameters); + + SilentRequest silentRequest = new SilentRequest( + parameters, + this.clientApplication, + context, + null); + + AcquireTokenSilentSupplier supplier = new AcquireTokenSilentSupplier( + this.clientApplication, + silentRequest); + + return supplier.execute(); + } catch (MsalClientException ex) { + LOG.debug("Cache lookup failed: {}", ex.getMessage()); + return acquireTokenByUserFic(); + } + } + + LOG.debug("ForceRefresh is true. Skipping cache lookup"); + return acquireTokenByUserFic(); + } + + private AuthenticationResult acquireTokenByUserFic() throws Exception { + AcquireTokenByAuthorizationGrantSupplier supplier = new AcquireTokenByAuthorizationGrantSupplier( + this.clientApplication, + userFicRequest, + null); + + return supplier.execute(); + } +} diff --git a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/ConfidentialClientApplication.java b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/ConfidentialClientApplication.java index 51fd4610..50df4466 100644 --- a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/ConfidentialClientApplication.java +++ b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/ConfidentialClientApplication.java @@ -63,6 +63,24 @@ public CompletableFuture acquireToken(OnBehalfOfParameter return this.executeRequest(oboRequest); } + @Override + public CompletableFuture acquireToken(UserFederatedIdentityCredentialParameters parameters) { + validateNotNull("parameters", parameters); + + RequestContext context = new RequestContext( + this, + PublicApi.ACQUIRE_TOKEN_BY_USER_FEDERATED_IDENTITY_CREDENTIAL, + parameters); + + UserFederatedIdentityCredentialRequest userFicRequest = + new UserFederatedIdentityCredentialRequest( + parameters, + this, + context); + + return this.executeRequest(userFicRequest); + } + private ConfidentialClientApplication(Builder builder) { super(builder); sendX5c = builder.sendX5c; diff --git a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/GrantConstants.java b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/GrantConstants.java index 1e632245..713f4d2b 100644 --- a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/GrantConstants.java +++ b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/GrantConstants.java @@ -14,9 +14,14 @@ class GrantConstants { static final String USERNAME_PARAMETER = "username"; static final String PASSWORD_PARAMETER = "password"; + //Parameter names for user_fic flow + static final String USER_FEDERATED_IDENTITY_CREDENTIAL = "user_federated_identity_credential"; + static final String USER_ID_PARAMETER = "user_id"; + //Grant types static final String AUTHORIZATION_CODE = "authorization_code"; static final String CLIENT_CREDENTIALS = "client_credentials"; + static final String USER_FIC = "user_fic"; static final String PASSWORD = "password"; static final String SAML_2_BEARER = "urn:ietf:params:oauth:grant-type:saml2-bearer"; static final String SAML_1_1_BEARER = "urn:ietf:params:oauth:grant-type:saml1_1-bearer"; diff --git a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/IConfidentialClientApplication.java b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/IConfidentialClientApplication.java index f3bb8414..eaa7cd2c 100644 --- a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/IConfidentialClientApplication.java +++ b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/IConfidentialClientApplication.java @@ -49,4 +49,17 @@ public interface IConfidentialClientApplication extends IClientApplicationBase { * @return {@link CompletableFuture} containing an {@link IAuthenticationResult} */ CompletableFuture 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..570b2d14 --- /dev/null +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/UserFederatedIdentityCredentialTest.java @@ -0,0 +1,389 @@ +// 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.util.Collections; +import java.util.HashMap; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicInteger; + +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. + * Covers §6 (user_fic grant type), §7 (user_federated_identity_credential body param), + * §8 (user_id/username body params), and §11 (primitive API) from AgentIDs_ComponentsReference. + */ +@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); + } + + // ======================================================================== + // §6: user_fic grant type + // ======================================================================== + + @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"); + })); + } + + // ======================================================================== + // §7: 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"); + })); + } + + // ======================================================================== + // §8: 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="); + })); + } + + // ======================================================================== + // §6+§7+§8 combined: 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"); + })); + } + + // ======================================================================== + // §11: 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"); + } + + // ======================================================================== + // §11: 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"); + } + + // ======================================================================== + // §11: 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()); + } +} From 0306432e162cfec0b7cc4cd166a5374f959fd876 Mon Sep 17 00:00:00 2001 From: avdunn Date: Tue, 5 May 2026 11:53:06 -0700 Subject: [PATCH 2/3] Improve test coverage of FIC --- .../com/microsoft/aad/msal4j/AgenticIT.java | 155 ++++++++- .../java/com/microsoft/aad/msal4j/FicIT.java | 293 ++++++++++++++++++ ...erFederatedIdentityCredentialSupplier.java | 98 ++++-- .../UserFederatedIdentityCredentialTest.java | 172 ++++++++++ 4 files changed, 692 insertions(+), 26 deletions(-) create mode 100644 msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/FicIT.java diff --git a/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/AgenticIT.java b/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/AgenticIT.java index 86a4f5a1..68260cb2 100644 --- a/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/AgenticIT.java +++ b/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/AgenticIT.java @@ -15,12 +15,14 @@ import java.security.cert.CertificateException; import java.security.cert.X509Certificate; import java.util.Collections; +import java.util.Set; import java.util.concurrent.atomic.AtomicReference; import java.util.function.Function; /** * Integration tests for agentic (agent identity) scenarios using MSAL Java APIs. - * Tests FMI credential acquisition via assertion callbacks and cache isolation. + * Tests FMI credential acquisition via assertion callbacks and cache isolation, + * plus FIC user_fic flows for the full 3-leg agent identity protocol. * *

These tests use MSAL token acquisition APIs (unlike AgenticRawHttpIT which uses raw HTTP). * @@ -31,20 +33,26 @@ *

  • Tenant: {@link #TENANT_ID}
  • * * - *

    Flows tested (FMI-only, no FIC/user_fic on this branch): + *

    Flows tested: *

      *
    • Assertion callback receives correct context (AssertionRequestOptions)
    • *
    • Cache isolation between different fmi_path values
    • + *
    • Full 3-leg flow: FMI → assertion → user_fic → user token
    • + *
    • Multi-user cache isolation via user_fic
    • *
    */ @TestInstance(TestInstance.Lifecycle.PER_CLASS) class AgenticIT { // Lab test configuration + private static final String BLUEPRINT_CLIENT_ID = "aab5089d-e764-47e3-9f28-cc11c2513821"; private static final String RMA_CLIENT_ID = "3bf56293-fbb5-42bd-a407-248ba7431a8c"; private static final String TENANT_ID = "10c419d4-4a50-45b2-aa4e-919fb84df24f"; private static final String AGENT_APP_ID = "ab18ca07-d139-4840-8b3b-4be9610c6ed5"; + private static final String USER_UPN = "agentuser1@id4slab1.onmicrosoft.com"; + private static final String TOKEN_EXCHANGE_SCOPE = "api://AzureADTokenExchange/.default"; private static final String FMI_EXCHANGE_SCOPE = "api://AzureFMITokenExchange/.default"; + private static final String GRAPH_SCOPE = "https://graph.microsoft.com/.default"; private static final String AZURE_REGION = "westus3"; private static final String AUTHORITY = "https://login.microsoftonline.com/" + TENANT_ID + "/"; @@ -158,6 +166,102 @@ void agentFmiToken_CacheIsolation_DifferentFmiPaths() throws Exception { "Tokens for different fmi_paths should be different"); } + /** + * Full 3-leg agent identity flow: FMI → assertion → user_fic → user-scoped Graph token. + * Uses the assertion callback pattern where the blueprint CCA acquires the FMI credential + * and the agent CCA exchanges it for a user token. + */ + @Test + void agentUserIdentity_GetsTokenForGraph() throws Exception { + // Build agent CCA with assertion callback that acquires FMI credential + Function assertionProvider = options -> { + try { + return acquireFmiCredentialForAgent(AGENT_APP_ID); + } catch (Exception e) { + throw new RuntimeException("Failed to acquire FMI credential", e); + } + }; + + IClientCredential credential = ClientCredentialFactory.createFromCallback(assertionProvider); + + ConfidentialClientApplication agentCca = ConfidentialClientApplication.builder(AGENT_APP_ID, credential) + .authority(AUTHORITY) + .build(); + + // Get instance token (T2) for user_fic exchange + String t2 = acquireInstanceTokenForAgent(); + + // Exchange T2 for user-scoped token via user_fic grant + UserFederatedIdentityCredentialParameters params = UserFederatedIdentityCredentialParameters + .builder(Collections.singleton(GRAPH_SCOPE), USER_UPN, t2) + .build(); + + IAuthenticationResult result = agentCca.acquireToken(params).get(); + + assertNotNull(result, "Auth result should not be null"); + assertNotNull(result.accessToken(), "Access token should not be null"); + assertFalse(result.accessToken().isEmpty(), "Access token should not be empty"); + assertNotNull(result.account(), "Account should not be null (user token)"); + + // Verify token is cached and silent retrieval works + Set accounts = agentCca.getAccounts().get(); + assertFalse(accounts.isEmpty(), "Accounts should be in cache"); + + IAccount account = accounts.iterator().next(); + IAuthenticationResult silentResult = agentCca.acquireTokenSilently( + SilentParameters.builder(Collections.singleton(GRAPH_SCOPE), account).build()).get(); + + assertEquals(result.accessToken(), silentResult.accessToken(), + "Silent call should return cached token"); + } + + /** + * Verifies that user_fic tokens and app-only tokens are isolated in cache + * on the same agent CCA instance. App token acquisition should not interfere + * with user token acquisition. + */ + @Test + void agentCca_AppAndUserTokens_CacheIsolation() throws Exception { + Function assertionProvider = options -> { + try { + return acquireFmiCredentialForAgent(AGENT_APP_ID); + } catch (Exception e) { + throw new RuntimeException("Failed to acquire FMI credential", e); + } + }; + + IClientCredential credential = ClientCredentialFactory.createFromCallback(assertionProvider); + + ConfidentialClientApplication agentCca = ConfidentialClientApplication.builder(AGENT_APP_ID, credential) + .authority(AUTHORITY) + .build(); + + // Acquire app-only token + IAuthenticationResult appResult = agentCca.acquireToken(ClientCredentialParameters + .builder(Collections.singleton(GRAPH_SCOPE)) + .build()) + .get(); + assertNotNull(appResult.accessToken()); + + // Acquire user token via user_fic (needs T2 = instance token) + String t2 = acquireInstanceTokenForAgent(); + UserFederatedIdentityCredentialParameters userParams = UserFederatedIdentityCredentialParameters + .builder(Collections.singleton(GRAPH_SCOPE), USER_UPN, t2) + .build(); + + IAuthenticationResult userResult = agentCca.acquireToken(userParams).get(); + assertNotNull(userResult.accessToken()); + assertNotNull(userResult.account(), "User token should have an account"); + + // Tokens should be different (app vs user scoped) + assertNotEquals(appResult.accessToken(), userResult.accessToken(), + "App token and user token should be different"); + + // App cache should have 1 entry, user cache should have user account + assertTrue(agentCca.tokenCache.accessTokens.size() >= 2, + "Cache should have at least 2 entries (app + user)"); + } + /** * Helper: acquires an FMI credential from the RMA using a certificate. * Uses the FMI-specific exchange scope (api://AzureFMITokenExchange). @@ -180,4 +284,51 @@ private String acquireFmiCredentialFromRma() throws Exception { IAuthenticationResult result = rmaCca.acquireToken(params).get(); return result.accessToken(); } + + /** + * Helper: acquires an FMI credential from the blueprint app for the given agent app ID. + * This is Leg 1 of the agent identity flow — returns T1. + */ + private String acquireFmiCredentialForAgent(String agentAppId) throws Exception { + IClientCertificate clientCert = ClientCredentialFactory.createFromCertificate(privateKey, certificate); + + ConfidentialClientApplication blueprintCca = ConfidentialClientApplication.builder( + BLUEPRINT_CLIENT_ID, clientCert) + .authority(AUTHORITY) + .sendX5c(true) + .azureRegion(AZURE_REGION) + .build(); + + ClientCredentialParameters params = ClientCredentialParameters + .builder(Collections.singleton(TOKEN_EXCHANGE_SCOPE)) + .fmiPath(agentAppId) + .build(); + + IAuthenticationResult result = blueprintCca.acquireToken(params).get(); + return result.accessToken(); + } + + /** + * Helper: acquires an instance token (T2) for the agent app via the full 2-leg flow. + * Leg 1: Blueprint → T1 (FMI credential) + * Leg 2: Agent uses T1 as client_assertion → T2 (instance token) + * T2 is used as the user_federated_identity_credential in Leg 3 (user_fic exchange). + */ + private String acquireInstanceTokenForAgent() throws Exception { + String t1 = acquireFmiCredentialForAgent(AGENT_APP_ID); + + IClientCredential agentCredential = ClientCredentialFactory.createFromClientAssertion(t1); + + ConfidentialClientApplication agentCca = ConfidentialClientApplication.builder(AGENT_APP_ID, agentCredential) + .authority(AUTHORITY) + .build(); + + ClientCredentialParameters instanceParams = ClientCredentialParameters + .builder(Collections.singleton(TOKEN_EXCHANGE_SCOPE)) + .skipCache(true) + .build(); + + IAuthenticationResult instanceResult = agentCca.acquireToken(instanceParams).get(); + return instanceResult.accessToken(); + } } diff --git a/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/FicIT.java b/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/FicIT.java new file mode 100644 index 00000000..02b21002 --- /dev/null +++ b/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/FicIT.java @@ -0,0 +1,293 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.aad.msal4j; + +import com.microsoft.aad.msal4j.labapi.KeyVaultSecretsProvider; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; + +import static org.junit.jupiter.api.Assertions.*; + +import java.io.IOException; +import java.security.*; +import java.security.cert.CertificateException; +import java.security.cert.X509Certificate; +import java.util.Collections; +import java.util.Set; +import java.util.UUID; +import java.util.function.Function; + +/** + * Integration tests for FIC (Federated Identity Credential) / user_fic grant support. + * Corresponds to .NET's Agentic.cs UserFIC-related tests. + * + *

    Tests the low-level UserFIC primitive: acquires an FMI-sourced assertion, + * then exchanges it for a user-scoped token using the user_fic grant type. + * + *

    Test configuration (same as .NET Agentic.cs): + *

      + *
    • Blueprint app: {@link #BLUEPRINT_CLIENT_ID}
    • + *
    • Agent app: {@link #AGENT_APP_ID}
    • + *
    • Tenant: {@link #TENANT_ID}
    • + *
    • User UPN: {@link #USER_UPN}
    • + *
    + * + *

    Flows tested: + *

      + *
    • Full 3-leg: FMI → assertion → user_fic → user token (UPN-based)
    • + *
    • OID-based user_fic (Guid overload)
    • + *
    • Cache hit: second call returns cached user token
    • + *
    • Force refresh: bypasses cache
    • + *
    + */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class FicIT { + + // Same config as .NET Agentic.cs + private static final String BLUEPRINT_CLIENT_ID = "aab5089d-e764-47e3-9f28-cc11c2513821"; + private static final String TENANT_ID = "10c419d4-4a50-45b2-aa4e-919fb84df24f"; + private static final String AGENT_APP_ID = "ab18ca07-d139-4840-8b3b-4be9610c6ed5"; + private static final String USER_UPN = "agentuser1@id4slab1.onmicrosoft.com"; + private static final String TOKEN_EXCHANGE_SCOPE = "api://AzureADTokenExchange/.default"; + private static final String GRAPH_SCOPE = "https://graph.microsoft.com/.default"; + private static final String AZURE_REGION = "westus3"; + + private static final String AUTHORITY = "https://login.microsoftonline.com/" + TENANT_ID + "/"; + + private PrivateKey privateKey; + private X509Certificate certificate; + + @BeforeAll + void init() throws KeyStoreException, NoSuchProviderException, + IOException, NoSuchAlgorithmException, CertificateException, + UnrecoverableKeyException { + KeyStore keystore = CertificateHelper.createKeyStore(); + keystore.load(null, null); + + privateKey = (PrivateKey) keystore.getKey(KeyVaultSecretsProvider.CERTIFICATE_ALIAS, null); + certificate = (X509Certificate) keystore.getCertificate(KeyVaultSecretsProvider.CERTIFICATE_ALIAS); + + assertNotNull(privateKey, "Lab private key not found. Ensure the lab cert is installed."); + assertNotNull(certificate, "Lab certificate not found. Ensure the lab cert is installed."); + } + + /** + * Full 3-leg flow using UPN: FMI credential → assertion → user_fic → user-scoped Graph token. + * Then verifies the token is cached and can be retrieved silently. + * Corresponds to .NET's AgentUserIdentityGetsTokenForGraphTest. + */ + @Test + void userFic_FullFlow_WithUpn_GetsUserToken() throws Exception { + // Leg 1+2: Get instance token (T2) for the agent + String t2 = acquireInstanceToken(); + + // Leg 3: Exchange T2 for user-scoped token via user_fic grant + // CCA authenticates with T1 (via callback in buildAgentCca) + ConfidentialClientApplication cca = buildAgentCca(); + + UserFederatedIdentityCredentialParameters params = UserFederatedIdentityCredentialParameters + .builder(Collections.singleton(GRAPH_SCOPE), USER_UPN, t2) + .build(); + + IAuthenticationResult result = cca.acquireToken(params).get(); + + // Assert: got a user token + assertNotNull(result, "Auth result should not be null"); + assertNotNull(result.accessToken(), "Access token should not be null"); + assertFalse(result.accessToken().isEmpty(), "Access token should not be empty"); + assertNotNull(result.account(), "Account should not be null (user token cache)"); + + // Verify silent retrieval works (token is cached) + Set accounts = cca.getAccounts().get(); + assertFalse(accounts.isEmpty(), "Accounts should be present in cache"); + + IAccount account = accounts.iterator().next(); + IAuthenticationResult silentResult = cca.acquireTokenSilently( + SilentParameters.builder(Collections.singleton(GRAPH_SCOPE), account).build()).get(); + + assertNotNull(silentResult.accessToken(), "Silent token should not be null"); + assertEquals(result.accessToken(), silentResult.accessToken(), + "Silent call should return cached token"); + } + + /** + * OID-based user_fic: discovers user's OID via UPN flow, then uses UUID overload. + * Corresponds to .NET's UserFic_WithGuidObjectId_Test. + */ + @Test + void userFic_WithGuidObjectId_GetsUserToken() throws Exception { + // Step 1: Get instance token and acquire token via UPN to discover the user's OID + String t2_1 = acquireInstanceToken(); + + ConfidentialClientApplication cca = buildAgentCca(); + + UserFederatedIdentityCredentialParameters upnParams = UserFederatedIdentityCredentialParameters + .builder(Collections.singleton(GRAPH_SCOPE), USER_UPN, t2_1) + .build(); + + IAuthenticationResult upnResult = cca.acquireToken(upnParams).get(); + assertNotNull(upnResult.account(), "Account should not be null"); + + // Extract OID from the account's home account ID (format: oid.tid) + String homeAccountId = upnResult.account().homeAccountId(); + assertNotNull(homeAccountId, "Home account ID should not be null"); + String oidString = homeAccountId.split("\\.")[0]; + UUID userOid = UUID.fromString(oidString); + + // Step 2: Get a fresh instance token + String t2_2 = acquireInstanceToken(); + + // Step 3: Use the UUID overload + UserFederatedIdentityCredentialParameters oidParams = UserFederatedIdentityCredentialParameters + .builder(Collections.singleton(GRAPH_SCOPE), userOid, t2_2) + .forceRefresh(true) + .build(); + + IAuthenticationResult oidResult = cca.acquireToken(oidParams).get(); + + // Assert + assertNotNull(oidResult, "Result should not be null"); + assertNotNull(oidResult.accessToken(), "Access token should not be null"); + assertFalse(oidResult.accessToken().isEmpty(), "Access token should not be empty"); + assertTrue(oidResult.account().homeAccountId().startsWith(oidString), + "OID should match in home account ID"); + } + + /** + * Verifies that the user_fic token is cached and a second call without forceRefresh + * returns the same cached token. + */ + @Test + void userFic_CacheHit_SecondCallReturnsCachedToken() throws Exception { + String t2 = acquireInstanceToken(); + + ConfidentialClientApplication cca = buildAgentCca(); + + UserFederatedIdentityCredentialParameters params = UserFederatedIdentityCredentialParameters + .builder(Collections.singleton(GRAPH_SCOPE), USER_UPN, t2) + .build(); + + IAuthenticationResult result1 = cca.acquireToken(params).get(); + assertNotNull(result1.accessToken()); + + // Second call without forceRefresh should be a cache hit + UserFederatedIdentityCredentialParameters params2 = UserFederatedIdentityCredentialParameters + .builder(Collections.singleton(GRAPH_SCOPE), USER_UPN, "stale-assertion-should-not-be-used") + .forceRefresh(false) + .build(); + + IAuthenticationResult result2 = cca.acquireToken(params2).get(); + + assertEquals(result1.accessToken(), result2.accessToken(), + "Second call should return cached token"); + } + + /** + * Verifies that forceRefresh bypasses the cache and acquires a fresh token. + */ + @Test + void userFic_ForceRefresh_BypassesCache() throws Exception { + String t2_1 = acquireInstanceToken(); + + ConfidentialClientApplication cca = buildAgentCca(); + + UserFederatedIdentityCredentialParameters params1 = UserFederatedIdentityCredentialParameters + .builder(Collections.singleton(GRAPH_SCOPE), USER_UPN, t2_1) + .build(); + + IAuthenticationResult result1 = cca.acquireToken(params1).get(); + assertNotNull(result1.accessToken()); + + // Get a fresh instance token for force refresh + String t2_2 = acquireInstanceToken(); + + UserFederatedIdentityCredentialParameters params2 = UserFederatedIdentityCredentialParameters + .builder(Collections.singleton(GRAPH_SCOPE), USER_UPN, t2_2) + .forceRefresh(true) + .build(); + + IAuthenticationResult result2 = cca.acquireToken(params2).get(); + assertNotNull(result2.accessToken()); + + // Force refresh should have acquired a new token (may or may not be different value, + // but the call should have gone to the IdP rather than returning from cache) + assertNotNull(result2.accessToken(), "Force refresh should produce a valid token"); + } + + // ======================================================================== + // Helpers + // ======================================================================== + + /** + * Leg 1: Blueprint CCA acquires FMI credential (T1) for the agent app. + * Equivalent to .NET's GetAppCredentialAsync(fmiPath). + * T1 is used as client_assertion to authenticate the agent CCA. + */ + private String acquireFmiCredential(String fmiPath) throws Exception { + IClientCertificate clientCert = ClientCredentialFactory.createFromCertificate(privateKey, certificate); + + ConfidentialClientApplication blueprintCca = ConfidentialClientApplication.builder( + BLUEPRINT_CLIENT_ID, clientCert) + .authority(AUTHORITY) + .sendX5c(true) + .azureRegion(AZURE_REGION) + .build(); + + ClientCredentialParameters fmiParams = ClientCredentialParameters + .builder(Collections.singleton(TOKEN_EXCHANGE_SCOPE)) + .fmiPath(fmiPath) + .build(); + + IAuthenticationResult fmiResult = blueprintCca.acquireToken(fmiParams).get(); + assertNotNull(fmiResult.accessToken(), "FMI credential (T1) should not be null"); + return fmiResult.accessToken(); + } + + /** + * Leg 1+2: Acquires an instance token (T2) for the agent app. + * Leg 1: Blueprint → T1 (FMI credential) + * Leg 2: Agent uses T1 as client_assertion → T2 (instance token) + * T2 is used as the user_federated_identity_credential in Leg 3. + */ + private String acquireInstanceToken() throws Exception { + String t1 = acquireFmiCredential(AGENT_APP_ID); + + IClientCredential agentCredential = ClientCredentialFactory.createFromClientAssertion(t1); + + ConfidentialClientApplication agentCca = ConfidentialClientApplication.builder(AGENT_APP_ID, agentCredential) + .authority(AUTHORITY) + .build(); + + ClientCredentialParameters instanceParams = ClientCredentialParameters + .builder(Collections.singleton(TOKEN_EXCHANGE_SCOPE)) + .skipCache(true) + .build(); + + IAuthenticationResult instanceResult = agentCca.acquireToken(instanceParams).get(); + assertNotNull(instanceResult.accessToken(), "Instance token (T2) should not be null"); + return instanceResult.accessToken(); + } + + /** + * Builds an agent CCA whose credential callback produces T1 (FMI credential). + * This matches .NET's pattern: the CCA authenticates with T1 as client_assertion. + * Equivalent to .NET's WithClientAssertion(_ => GetAppCredentialAsync(AgentIdentity)). + */ + private ConfidentialClientApplication buildAgentCca() throws Exception { + Function assertionProvider = options -> { + try { + return acquireFmiCredential(AGENT_APP_ID); + } catch (Exception e) { + throw new RuntimeException("Failed to acquire FMI credential", e); + } + }; + + IClientCredential credential = ClientCredentialFactory.createFromCallback(assertionProvider); + + return ConfidentialClientApplication.builder(AGENT_APP_ID, credential) + .authority(AUTHORITY) + .build(); + } +} diff --git a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/AcquireTokenByUserFederatedIdentityCredentialSupplier.java b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/AcquireTokenByUserFederatedIdentityCredentialSupplier.java index 05154d25..091acb3d 100644 --- a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/AcquireTokenByUserFederatedIdentityCredentialSupplier.java +++ b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/AcquireTokenByUserFederatedIdentityCredentialSupplier.java @@ -6,6 +6,8 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.util.Set; + class AcquireTokenByUserFederatedIdentityCredentialSupplier extends AuthenticationResultSupplier { private static final Logger LOG = LoggerFactory.getLogger(AcquireTokenByUserFederatedIdentityCredentialSupplier.class); @@ -22,38 +24,86 @@ AuthenticationResult execute() throws Exception { if (!userFicRequest.parameters.forceRefresh()) { LOG.debug("ForceRefresh is false. Attempting cache lookup"); try { - SilentParameters parameters = SilentParameters - .builder(this.userFicRequest.parameters.scopes()) - .claims(this.userFicRequest.parameters.claims()) - .tenant(this.userFicRequest.parameters.tenant()) - .build(); - - RequestContext context = new RequestContext( - this.clientApplication, - PublicApi.ACQUIRE_TOKEN_SILENTLY, - parameters); - - SilentRequest silentRequest = new SilentRequest( - parameters, - this.clientApplication, - context, - null); - - AcquireTokenSilentSupplier supplier = new AcquireTokenSilentSupplier( - this.clientApplication, - silentRequest); - - return supplier.execute(); + // Look up the user's account from the cache by username or OID. + // User_fic tokens are user-scoped (have homeAccountId), so we must use the + // account-aware cache lookup path to avoid collisions with app-only tokens. + IAccount account = findCachedAccount(); + if (account != null) { + SilentParameters parameters = SilentParameters + .builder(this.userFicRequest.parameters.scopes(), account) + .claims(this.userFicRequest.parameters.claims()) + .tenant(this.userFicRequest.parameters.tenant()) + .build(); + + RequestContext context = new RequestContext( + this.clientApplication, + PublicApi.ACQUIRE_TOKEN_SILENTLY, + parameters); + + SilentRequest silentRequest = new SilentRequest( + parameters, + this.clientApplication, + context, + null); + + AcquireTokenSilentSupplier supplier = new AcquireTokenSilentSupplier( + this.clientApplication, + silentRequest); + + return supplier.execute(); + } else { + LOG.debug("No cached account found for user. Going to IdP."); + } } catch (MsalClientException ex) { LOG.debug("Cache lookup failed: {}", ex.getMessage()); - return acquireTokenByUserFic(); } + } else { + LOG.debug("ForceRefresh is true. Skipping cache lookup"); } - LOG.debug("ForceRefresh is true. Skipping cache lookup"); return acquireTokenByUserFic(); } + /** + * Finds a cached account matching the user_fic request's username or userObjectId. + * Returns null if no matching account is in the cache (first call scenario). + */ + private IAccount findCachedAccount() { + try { + Set accounts = ((ConfidentialClientApplication) this.clientApplication).getAccounts().get(); + if (accounts == null || accounts.isEmpty()) { + return null; + } + + String username = userFicRequest.parameters.username(); + java.util.UUID userObjectId = userFicRequest.parameters.userObjectId(); + + for (IAccount account : accounts) { + // Match by OID — homeAccountId format is "oid.tid" + if (userObjectId != null && account.homeAccountId() != null + && account.homeAccountId().startsWith(userObjectId.toString())) { + return account; + } + // Match by username (UPN) — case-insensitive + if (username != null && !username.isEmpty() + && account.username() != null + && username.equalsIgnoreCase(account.username())) { + return account; + } + } + + // If no exact match but there's only one account, use it. + // This handles cases where the IdP returns a slightly different username format + // (e.g., preferred_username vs UPN) but the token is still for the same user. + if (accounts.size() == 1) { + return accounts.iterator().next(); + } + } catch (Exception ex) { + LOG.debug("Error looking up cached accounts: {}", ex.getMessage()); + } + return null; + } + private AuthenticationResult acquireTokenByUserFic() throws Exception { AcquireTokenByAuthorizationGrantSupplier supplier = new AcquireTokenByAuthorizationGrantSupplier( this.clientApplication, 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 index 570b2d14..3a23bc26 100644 --- a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/UserFederatedIdentityCredentialTest.java +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/UserFederatedIdentityCredentialTest.java @@ -6,11 +6,14 @@ 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; @@ -386,4 +389,173 @@ void userFic_Parameters_OidBuilder_SetsFieldsCorrectly() { assertEquals(FAKE_ASSERTION, params.assertion()); assertFalse(params.forceRefresh()); } + + // ======================================================================== + // Multi-user cache isolation (matches .NET TwoUpns/TwoOids tests) + // ======================================================================== + + /** + * 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 UserFIC on the same CCA, + * and AcquireTokenSilent returns the correct cached token for each user. + * Matches .NET's AcquireTokenByUserFic_TwoUpns_SilentReturnsCorrectToken_Async. + */ + @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 UserFIC on the same CCA, + * and AcquireTokenSilent resolves the correct account by OID. + * Matches .NET's AcquireTokenByUserFic_TwoOids_SilentReturnsCorrectToken_Async. + */ + @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"); + } } From 07bdbb73c971e55e720f0d227db3256d744b5765 Mon Sep 17 00:00:00 2001 From: avdunn Date: Wed, 6 May 2026 08:52:29 -0700 Subject: [PATCH 3/3] Clean up comments --- .../com/microsoft/aad/msal4j/AgenticIT.java | 45 +++++++++++++++++-- .../java/com/microsoft/aad/msal4j/FicIT.java | 15 +++---- .../UserFederatedIdentityCredentialTest.java | 24 +++++----- 3 files changed, 56 insertions(+), 28 deletions(-) diff --git a/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/AgenticIT.java b/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/AgenticIT.java index 68260cb2..8dd97753 100644 --- a/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/AgenticIT.java +++ b/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/AgenticIT.java @@ -21,8 +21,8 @@ /** * Integration tests for agentic (agent identity) scenarios using MSAL Java APIs. - * Tests FMI credential acquisition via assertion callbacks and cache isolation, - * plus FIC user_fic flows for the full 3-leg agent identity protocol. + * Corresponds to .NET's Agentic.cs — tests the MSAL-level APIs for the agent identity flow + * (specifically the FMI portions that are available on this branch, plus FIC user_fic flows). * *

    These tests use MSAL token acquisition APIs (unlike AgenticRawHttpIT which uses raw HTTP). * @@ -74,6 +74,42 @@ void init() throws KeyStoreException, NoSuchProviderException, assertNotNull(certificate, "Lab certificate not found. Ensure the lab cert is installed."); } + /** + * Agent gets an app-only token for Graph using an FMI-sourced client assertion. + * This tests Leg 2 of the agent identity flow: + * 1. Blueprint CCA acquires FMI credential (fmi_path = agentAppId) + * 2. Agent CCA uses that credential as client_assertion to get Graph token + * + * Corresponds to .NET's AgentGetsAppTokenForGraphTest. + */ + @Test + void agentGetsAppToken_UsingFmiAssertion() throws Exception { + // The assertion callback simulates what an SDK or middleware would do: + // it calls the blueprint app to get an FMI credential for the agent + Function assertionProvider = options -> { + try { + return acquireFmiCredentialForAgent(AGENT_APP_ID); + } catch (Exception e) { + throw new RuntimeException("Failed to acquire FMI credential", e); + } + }; + + IClientCredential credential = ClientCredentialFactory.createFromCallback(assertionProvider); + + ConfidentialClientApplication agentCca = ConfidentialClientApplication.builder(AGENT_APP_ID, credential) + .authority(AUTHORITY) + .build(); + + IAuthenticationResult result = agentCca.acquireToken(ClientCredentialParameters + .builder(Collections.singleton(GRAPH_SCOPE)) + .build()) + .get(); + + assertNotNull(result, "Auth result should not be null"); + assertNotNull(result.accessToken(), "Access token should not be null"); + assertFalse(result.accessToken().isEmpty(), "Access token should not be empty"); + } + /** * Verifies that the context-aware assertion callback receives the correct fmiPath * when the ClientCredentialParameters include an fmiPath. @@ -263,8 +299,9 @@ void agentCca_AppAndUserTokens_CacheIsolation() throws Exception { } /** - * Helper: acquires an FMI credential from the RMA using a certificate. - * Uses the FMI-specific exchange scope (api://AzureFMITokenExchange). + * Helper: acquires an FMI credential from the RMA (Resource Management Application). + * Uses FMI_EXCHANGE_SCOPE, matching FmiIT's Flow3 pattern. + * Suitable for use as client_assertion when client_id = "urn:microsoft:identity:fmi". */ private String acquireFmiCredentialFromRma() throws Exception { IClientCertificate clientCert = ClientCredentialFactory.createFromCertificate(privateKey, certificate); diff --git a/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/FicIT.java b/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/FicIT.java index 02b21002..4ce9b438 100644 --- a/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/FicIT.java +++ b/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/FicIT.java @@ -21,12 +21,11 @@ /** * Integration tests for FIC (Federated Identity Credential) / user_fic grant support. - * Corresponds to .NET's Agentic.cs UserFIC-related tests. * - *

    Tests the low-level UserFIC primitive: acquires an FMI-sourced assertion, + *

    Tests the user_fic primitive: acquires an FMI-sourced assertion, * then exchanges it for a user-scoped token using the user_fic grant type. * - *

    Test configuration (same as .NET Agentic.cs): + *

    Test configuration: *

      *
    • Blueprint app: {@link #BLUEPRINT_CLIENT_ID}
    • *
    • Agent app: {@link #AGENT_APP_ID}
    • @@ -37,7 +36,7 @@ *

      Flows tested: *

        *
      • Full 3-leg: FMI → assertion → user_fic → user token (UPN-based)
      • - *
      • OID-based user_fic (Guid overload)
      • + *
      • OID-based user_fic (UUID overload)
      • *
      • Cache hit: second call returns cached user token
      • *
      • Force refresh: bypasses cache
      • *
      @@ -45,7 +44,7 @@ @TestInstance(TestInstance.Lifecycle.PER_CLASS) class FicIT { - // Same config as .NET Agentic.cs + // Same config as AgenticIT private static final String BLUEPRINT_CLIENT_ID = "aab5089d-e764-47e3-9f28-cc11c2513821"; private static final String TENANT_ID = "10c419d4-4a50-45b2-aa4e-919fb84df24f"; private static final String AGENT_APP_ID = "ab18ca07-d139-4840-8b3b-4be9610c6ed5"; @@ -76,7 +75,6 @@ void init() throws KeyStoreException, NoSuchProviderException, /** * Full 3-leg flow using UPN: FMI credential → assertion → user_fic → user-scoped Graph token. * Then verifies the token is cached and can be retrieved silently. - * Corresponds to .NET's AgentUserIdentityGetsTokenForGraphTest. */ @Test void userFic_FullFlow_WithUpn_GetsUserToken() throws Exception { @@ -114,7 +112,6 @@ void userFic_FullFlow_WithUpn_GetsUserToken() throws Exception { /** * OID-based user_fic: discovers user's OID via UPN flow, then uses UUID overload. - * Corresponds to .NET's UserFic_WithGuidObjectId_Test. */ @Test void userFic_WithGuidObjectId_GetsUserToken() throws Exception { @@ -222,7 +219,6 @@ void userFic_ForceRefresh_BypassesCache() throws Exception { /** * Leg 1: Blueprint CCA acquires FMI credential (T1) for the agent app. - * Equivalent to .NET's GetAppCredentialAsync(fmiPath). * T1 is used as client_assertion to authenticate the agent CCA. */ private String acquireFmiCredential(String fmiPath) throws Exception { @@ -272,8 +268,7 @@ private String acquireInstanceToken() throws Exception { /** * Builds an agent CCA whose credential callback produces T1 (FMI credential). - * This matches .NET's pattern: the CCA authenticates with T1 as client_assertion. - * Equivalent to .NET's WithClientAssertion(_ => GetAppCredentialAsync(AgentIdentity)). + * The CCA authenticates with T1 as client_assertion for Leg 2 and Leg 3 requests. */ private ConfidentialClientApplication buildAgentCca() throws Exception { Function assertionProvider = options -> { 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 index 3a23bc26..6b4fb31c 100644 --- a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/UserFederatedIdentityCredentialTest.java +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/UserFederatedIdentityCredentialTest.java @@ -21,8 +21,6 @@ /** * Tests for the User Federated Identity Credential (user_fic) flow. - * Covers §6 (user_fic grant type), §7 (user_federated_identity_credential body param), - * §8 (user_id/username body params), and §11 (primitive API) from AgentIDs_ComponentsReference. */ @TestInstance(TestInstance.Lifecycle.PER_CLASS) class UserFederatedIdentityCredentialTest { @@ -59,7 +57,7 @@ private HttpResponse createSuccessResponseWithIdToken() { } // ======================================================================== - // §6: user_fic grant type + // Grant type and body parameters // ======================================================================== @Test @@ -86,7 +84,7 @@ void userFic_SendsCorrectGrantType() throws Exception { } // ======================================================================== - // §7: user_federated_identity_credential body parameter + // user_federated_identity_credential body parameter // ======================================================================== @Test @@ -113,7 +111,7 @@ void userFic_SendsAssertionInBody() throws Exception { } // ======================================================================== - // §8: user_id / username body parameters — mutual exclusion + // user_id / username body parameters — mutual exclusion // ======================================================================== @Test @@ -165,7 +163,7 @@ void userFic_WithOid_SendsUserIdNotUsername() throws Exception { } // ======================================================================== - // §6+§7+§8 combined: all parameters sent together + // All parameters sent together // ======================================================================== @Test @@ -224,7 +222,7 @@ void userFic_ScopeIncludesOidcScopes() throws Exception { } // ======================================================================== - // §11: Token stored in user cache + // Token stored in user cache // ======================================================================== @Test @@ -251,7 +249,7 @@ void userFic_TokenStoredInUserCache() throws Exception { } // ======================================================================== - // §11: Force refresh bypasses cache + // Force refresh bypasses cache // ======================================================================== @Test @@ -285,7 +283,7 @@ void userFic_ForceRefresh_BypassesCache() throws Exception { } // ======================================================================== - // §11: Cache hit when not force-refreshing + // Cache hit when not force-refreshing // ======================================================================== @Test @@ -391,7 +389,7 @@ void userFic_Parameters_OidBuilder_SetsFieldsCorrectly() { } // ======================================================================== - // Multi-user cache isolation (matches .NET TwoUpns/TwoOids tests) + // Multi-user cache isolation // ======================================================================== /** @@ -422,9 +420,8 @@ private HttpResponse createUserResponse(String oid, String preferredUsername, St } /** - * Verifies that two different users (by UPN) acquire tokens via UserFIC on the same CCA, + * 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. - * Matches .NET's AcquireTokenByUserFic_TwoUpns_SilentReturnsCorrectToken_Async. */ @Test void userFic_TwoUpns_SilentReturnsCorrectToken() throws Exception { @@ -492,9 +489,8 @@ void userFic_TwoUpns_SilentReturnsCorrectToken() throws Exception { } /** - * Verifies that two different users (by OID) acquire tokens via UserFIC on the same CCA, + * Verifies that two different users (by OID) acquire tokens via user_fic on the same CCA, * and AcquireTokenSilent resolves the correct account by OID. - * Matches .NET's AcquireTokenByUserFic_TwoOids_SilentReturnsCorrectToken_Async. */ @Test void userFic_TwoOids_SilentReturnsCorrectToken() throws Exception {