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()); + } +}