diff --git a/msal4j-sdk/pom.xml b/msal4j-sdk/pom.xml index cd0af114a..e25b4781c 100644 --- a/msal4j-sdk/pom.xml +++ b/msal4j-sdk/pom.xml @@ -60,7 +60,7 @@ org.slf4j slf4j-simple - 1.6.2 + 1.7.36 test @@ -96,7 +96,7 @@ net.bytebuddy byte-buddy - 1.14.5 + 1.17.5 test @@ -114,7 +114,19 @@ org.seleniumhq.selenium selenium-java - 3.14.0 + 4.13.0 + test + + + com.squareup.okhttp3 + okhttp + 3.14.9 + test + + + org.apache.httpcomponents + httpclient + 4.5.14 test @@ -126,7 +138,7 @@ commons-io commons-io - 2.14.0 + 2.22.0 test @@ -169,7 +181,7 @@ org.revapi revapi-maven-plugin - 0.15.0 + 0.15.1 @@ -183,7 +195,7 @@ org.revapi revapi-java - 0.28.1 + 0.28.4 @@ -199,7 +211,7 @@ org.apache.maven.plugins maven-jar-plugin - 2.5 + 3.5.0 @@ -213,7 +225,7 @@ org.apache.maven.plugins maven-surefire-plugin - 3.5.2 + 3.5.6 @{argLine} -noverify ${skip.unit.tests} @@ -222,7 +234,7 @@ org.apache.maven.plugins maven-javadoc-plugin - 3.1.0 + 3.12.0 src/main/java @@ -238,7 +250,7 @@ org.apache.maven.plugins maven-source-plugin - 2.2.1 + 3.4.0 attach-sources @@ -251,12 +263,12 @@ com.github.spotbugs spotbugs-maven-plugin - 3.1.11 + 4.2.3 org.apache.maven.plugins maven-compiler-plugin - 3.7.0 + 3.15.0 8 8 @@ -265,7 +277,7 @@ org.codehaus.mojo build-helper-maven-plugin - 1.10 + 3.6.0 add-test-source @@ -284,7 +296,7 @@ org.apache.maven.plugins maven-failsafe-plugin - 3.5.0 + 3.5.6 @@ -295,6 +307,7 @@ ${skip.integration.tests} + 1 ${adfs.disabled} @@ -303,7 +316,7 @@ biz.aQute.bnd bnd-maven-plugin - 5.2.0 + 6.4.0 @@ -315,7 +328,7 @@ org.jacoco jacoco-maven-plugin - 0.8.12 + 0.8.13 @@ -329,11 +342,38 @@ report + + jacoco-check + verify + + check + + + false + + + BUNDLE + + + LINE + COVEREDRATIO + 0.65 + + + BRANCH + COVEREDRATIO + 0.50 + + + + + + maven-dependency-plugin - 3.1.2 + 3.11.0 diff --git a/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/AcquireTokenInteractiveIT.java b/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/AcquireTokenInteractiveIT.java index 12da8ee35..afd3aa70c 100644 --- a/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/AcquireTokenInteractiveIT.java +++ b/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/AcquireTokenInteractiveIT.java @@ -62,22 +62,6 @@ void acquireTokenInteractive_ADFSv2022() { assertAcquireTokenCommon(user, app.getAppId(), app.getAuthority() + "organizations/", TestConstants.ADFS_SCOPE); } - @Test - void acquireTokenWithAuthorizationCode_B2C_Local() { - AppConfig app = LabResponseHelper.getAppConfig(APP_B2C); - UserConfig user = LabResponseHelper.getUserConfig(USER_B2C); - - assertAcquireTokenB2C(user, TestConstants.B2C_AUTHORITY, app.getAppId()); - } - - @Test - void acquireTokenWithAuthorizationCode_B2C_LegacyFormat() { - AppConfig app = LabResponseHelper.getAppConfig(APP_B2C); - UserConfig user = LabResponseHelper.getUserConfig(USER_B2C); - - assertAcquireTokenB2C(user, TestConstants.B2C_AUTHORITY_LEGACY_FORMAT, app.getAppId()); - } - @Test void acquireTokenInteractive_ManagedUser_InstanceAware() { AppConfig app = LabResponseHelper.getAppConfig(APP_ARLINGTON); @@ -86,7 +70,7 @@ void acquireTokenInteractive_ManagedUser_InstanceAware() { assertAcquireTokenInstanceAware(user, app.getAppId(), TestConstants.ARLINGTON_TENANT_ID); } - //@Test -disabled to avoid test failures, HTML page seems to have changed as this test cannot find the username input element + @Test void acquireTokenInteractive_Ciam() { AppConfig app = LabResponseHelper.getAppConfig(APP_CIAM); UserConfig user = LabResponseHelper.getUserConfig(USER_CIAM); @@ -115,7 +99,7 @@ void acquireTokenInteractive_Ciam() { InteractiveRequestParameters parameters = InteractiveRequestParameters .builder(url) - .scopes(Collections.singleton("TestConstants.USER_READ_SCOPE")) + .scopes(Collections.singleton(TestConstants.USER_READ_SCOPE)) .extraQueryParameters(extraQueryParameters) .systemBrowserOptions(browserOptions) .build(); @@ -143,22 +127,6 @@ private void assertAcquireTokenCommon(UserConfig user, String appId, String auth assertEquals(user.getUpn(), result.account().username()); } - private void assertAcquireTokenB2C(UserConfig user, String authority, String appId) { - - PublicClientApplication pca; - try { - pca = PublicClientApplication.builder( - appId). - b2cAuthority(authority + TestConstants.B2C_SIGN_IN_POLICY). - build(); - } catch (MalformedURLException ex) { - throw new RuntimeException(ex.getMessage()); - } - - IAuthenticationResult result = acquireTokenInteractive(user, pca, appId); - IntegrationTestHelper.assertAccessAndIdTokensNotNull(result); - } - private void assertAcquireTokenInstanceAware(UserConfig user, String appId, String tenantId) { PublicClientApplication pca = IntegrationTestHelper.createPublicApp(appId, TestConstants.MICROSOFT_AUTHORITY_HOST + tenantId); diff --git a/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/AcquireTokenSilentIT.java b/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/AcquireTokenSilentIT.java index c2b95926f..36379e2c7 100644 --- a/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/AcquireTokenSilentIT.java +++ b/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/AcquireTokenSilentIT.java @@ -29,7 +29,7 @@ void acquireTokenSilent_OrganizationAuthority_TokenRefreshed() throws Exception IAccount account = pca.getAccounts().join().iterator().next(); IAuthenticationResult result = acquireTokenSilently(pca, account, TestConstants.GRAPH_DEFAULT_SCOPE, false); - assertResultNotNull(result); + IntegrationTestHelper.assertAccessAndIdTokensNotNull(result); } @Test @@ -47,7 +47,7 @@ void acquireTokenSilent_LabAuthority_TokenNotRefreshed() throws Exception { IAccount account = pca.getAccounts().join().iterator().next(); IAuthenticationResult acquireSilentResult = acquireTokenSilently(pca, account, TestConstants.GRAPH_DEFAULT_SCOPE, false); - assertResultNotNull(result); + IntegrationTestHelper.assertAccessAndIdTokensNotNull(result); // Check that access and id tokens are coming from cache assertEquals(result.accessToken(), acquireSilentResult.accessToken()); @@ -67,14 +67,14 @@ void acquireTokenSilent_ForceRefresh() throws Exception { build(); IAuthenticationResult result = acquireTokenUsernamePassword(user, pca, TestConstants.GRAPH_DEFAULT_SCOPE); - assertResultNotNull(result); + IntegrationTestHelper.assertAccessAndIdTokensNotNull(result); IAccount account = pca.getAccounts().join().iterator().next(); IAuthenticationResult resultAfterRefresh = acquireTokenSilently(pca, account, TestConstants.GRAPH_DEFAULT_SCOPE, true); - assertResultNotNull(resultAfterRefresh); + IntegrationTestHelper.assertAccessAndIdTokensNotNull(resultAfterRefresh); // Check that new refresh and id tokens are being returned - assertTokensAreNotEqual(result, resultAfterRefresh); + IntegrationTestHelper.assertTokensAreNotEqual(result, resultAfterRefresh); assertEquals(TokenSource.IDENTITY_PROVIDER, result.metadata().tokenSource()); assertEquals(TokenSource.IDENTITY_PROVIDER, resultAfterRefresh.metadata().tokenSource()); } @@ -107,8 +107,7 @@ void acquireTokenSilent_ConfidentialClient_acquireTokenSilent() throws Exception .build()) .get(); - assertNotNull(result); - assertNotNull(result.accessToken()); + IntegrationTestHelper.assertAccessTokenNotNull(result); String cachedAt = result.accessToken(); @@ -133,8 +132,7 @@ void acquireTokenSilent_ConfidentialClient_acquireTokenSilentDifferentScopeThrow .build()) .get(); - assertNotNull(result); - assertNotNull(result.accessToken()); + IntegrationTestHelper.assertAccessTokenNotNull(result); //Acquiring token for different scope, expect exception to be thrown assertThrows(ExecutionException.class, () -> cca.acquireTokenSilently(SilentParameters @@ -154,11 +152,11 @@ void acquireTokenSilent_WithRefreshOn() throws Exception { build(); IAuthenticationResult resultOriginal = acquireTokenUsernamePassword(user, pca, TestConstants.GRAPH_DEFAULT_SCOPE); - assertResultNotNull(resultOriginal); + IntegrationTestHelper.assertAccessAndIdTokensNotNull(resultOriginal); IAuthenticationResult resultSilent = acquireTokenSilently(pca, resultOriginal.account(), TestConstants.GRAPH_DEFAULT_SCOPE, false); assertNotNull(resultSilent); - assertTokensAreEqual(resultOriginal, resultSilent); + IntegrationTestHelper.assertTokensAreEqual(resultOriginal, resultSilent); //When this test was made, token responses did not contain the refresh_in field needed for an end-to-end test. //In order to test silent flow behavior as though the service returned refresh_in, we manually change a cached @@ -174,7 +172,7 @@ void acquireTokenSilent_WithRefreshOn() throws Exception { //Current time is before refreshOn, so token should not have been refreshed assertNotNull(resultSilentWithRefreshOn); assertEquals(pca.tokenCache.accessTokens.get(key).refreshOn(), Long.toString(currTimestampSec + 60)); - assertTokensAreEqual(resultSilent, resultSilentWithRefreshOn); + IntegrationTestHelper.assertTokensAreEqual(resultSilent, resultSilentWithRefreshOn); token = pca.tokenCache.accessTokens.get(key); token.refreshOn(Long.toString(currTimestampSec - 60)); @@ -183,7 +181,7 @@ void acquireTokenSilent_WithRefreshOn() throws Exception { resultSilentWithRefreshOn = acquireTokenSilently(pca, resultOriginal.account(), TestConstants.GRAPH_DEFAULT_SCOPE, false); //Current time is after refreshOn, so token should be refreshed assertNotNull(resultSilentWithRefreshOn); - assertTokensAreNotEqual(resultSilent, resultSilentWithRefreshOn); + IntegrationTestHelper.assertTokensAreNotEqual(resultSilent, resultSilentWithRefreshOn); assertEquals(TokenSource.CACHE, resultSilent.metadata().tokenSource()); assertEquals(TokenSource.IDENTITY_PROVIDER, resultSilentWithRefreshOn.metadata().tokenSource()); } @@ -203,19 +201,19 @@ void acquireTokenSilent_TenantAsParameter() throws Exception { user.getUpn(), user.getPassword().toCharArray()) .build()).get(); - assertResultNotNull(result); + IntegrationTestHelper.assertAccessAndIdTokensNotNull(result); IAccount account = pca.getAccounts().join().iterator().next(); IAuthenticationResult silentResult = acquireTokenSilently(pca, account, TestConstants.GRAPH_DEFAULT_SCOPE, false); - assertResultNotNull(silentResult); - assertTokensAreEqual(result, silentResult); + IntegrationTestHelper.assertAccessAndIdTokensNotNull(silentResult); + IntegrationTestHelper.assertTokensAreEqual(result, silentResult); IAuthenticationResult resultWithTenantParam = pca.acquireTokenSilently(SilentParameters. builder(Collections.singleton(TestConstants.GRAPH_DEFAULT_SCOPE), account). tenant(user.getTenantId()). build()).get(); - assertResultNotNull(resultWithTenantParam); - assertTokensAreNotEqual(result, resultWithTenantParam); + IntegrationTestHelper.assertAccessAndIdTokensNotNull(resultWithTenantParam); + IntegrationTestHelper.assertTokensAreNotEqual(result, resultWithTenantParam); } @Test @@ -230,11 +228,11 @@ void acquireTokenSilent_emptyStringScope() throws Exception { String emptyScope = StringHelper.EMPTY_STRING; IAuthenticationResult result = acquireTokenUsernamePassword(user, pca, emptyScope); - assertResultNotNull(result); + IntegrationTestHelper.assertAccessAndIdTokensNotNull(result); IAccount account = pca.getAccounts().join().iterator().next(); IAuthenticationResult silentResult = acquireTokenSilently(pca, account, emptyScope, false); - assertResultNotNull(silentResult); + IntegrationTestHelper.assertAccessAndIdTokensNotNull(silentResult); assertEquals(result.accessToken(), silentResult.accessToken()); } @@ -255,7 +253,7 @@ void acquireTokenSilent_emptyScopeSet() throws Exception { user.getPassword().toCharArray()) .build()) .get(); - assertResultNotNull(result); + IntegrationTestHelper.assertAccessAndIdTokensNotNull(result); IAccount account = pca.getAccounts().join().iterator().next(); IAuthenticationResult silentResult = pca.acquireTokenSilently(SilentParameters. @@ -263,7 +261,7 @@ void acquireTokenSilent_emptyScopeSet() throws Exception { .build()) .get(); - assertResultNotNull(silentResult); + IntegrationTestHelper.assertAccessAndIdTokensNotNull(silentResult); assertEquals(result.accessToken(), silentResult.accessToken()); } @@ -285,14 +283,14 @@ public void acquireTokenSilent_ClaimsForceRefresh() throws Exception { .build()) .get(); - assertResultNotNull(result); + IntegrationTestHelper.assertAccessAndIdTokensNotNull(result); IAuthenticationResult silentResultWithoutClaims = pca.acquireTokenSilently(SilentParameters. builder(scopes, result.account()) .build()) .get(); - assertResultNotNull(silentResultWithoutClaims); + IntegrationTestHelper.assertAccessAndIdTokensNotNull(silentResultWithoutClaims); assertEquals(result.accessToken(), silentResultWithoutClaims.accessToken()); //If claims are added to a silent request, it should trigger the refresh flow and return a new token @@ -305,7 +303,7 @@ public void acquireTokenSilent_ClaimsForceRefresh() throws Exception { .build()) .get(); - assertResultNotNull(silentResultWithClaims); + IntegrationTestHelper.assertAccessAndIdTokensNotNull(silentResultWithClaims); assertNotEquals(result.accessToken(), silentResultWithClaims.accessToken()); } @@ -376,19 +374,4 @@ private IAuthenticationResult acquireTokenUsernamePassword(UserConfig user, IPub .get(); } - private void assertResultNotNull(IAuthenticationResult result) { - assertNotNull(result); - assertNotNull(result.accessToken()); - assertNotNull(result.idToken()); - } - - private void assertTokensAreNotEqual(IAuthenticationResult result, IAuthenticationResult secondResult) { - assertNotEquals(result.accessToken(), secondResult.accessToken()); - assertNotEquals(result.idToken(), secondResult.idToken()); - } - - private void assertTokensAreEqual(IAuthenticationResult result, IAuthenticationResult secondResult) { - assertEquals(result.accessToken(), secondResult.accessToken()); - assertEquals(result.idToken(), secondResult.idToken()); - } } diff --git a/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/ApacheHttpClientAdapter.java b/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/ApacheHttpClientAdapter.java index 89d6a2440..545b86d11 100644 --- a/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/ApacheHttpClientAdapter.java +++ b/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/ApacheHttpClientAdapter.java @@ -41,7 +41,7 @@ public IHttpResponse send(HttpRequest httpRequest) throws Exception { private HttpRequestBase buildApacheRequestFromMsalRequest(HttpRequest httpRequest) { if (httpRequest.httpMethod() == HttpMethod.GET) { - return builGetRequest(httpRequest); + return buildGetRequest(httpRequest); } else if (httpRequest.httpMethod() == HttpMethod.POST) { return buildPostRequest(httpRequest); } else { @@ -49,7 +49,7 @@ private HttpRequestBase buildApacheRequestFromMsalRequest(HttpRequest httpReques } } - private HttpGet builGetRequest(HttpRequest httpRequest) { + private HttpGet buildGetRequest(HttpRequest httpRequest) { HttpGet httpGet = new HttpGet(httpRequest.url().toString()); for (Map.Entry entry : httpRequest.headers().entrySet()) { diff --git a/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/AuthorizationCodeIT.java b/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/AuthorizationCodeIT.java index 58bc72a57..ff7c96fc9 100644 --- a/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/AuthorizationCodeIT.java +++ b/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/AuthorizationCodeIT.java @@ -45,15 +45,7 @@ public void acquireTokenWithAuthorizationCode_ManagedUser() { assertAcquireTokenAAD(user, app.getAppId(), null); } - //Temporarily disabling: no change in the library, but started seeing "The service has encountered an internal error. Please reauthenticate and try again." - //Needs investigation, tracked in https://github.com/AzureAD/microsoft-authentication-library-for-java/issues/1023 - //@Test - public void acquireTokenWithAuthorizationCode_B2C_Local() { - UserConfig user = LabResponseHelper.getUserConfig(USER_B2C); - assertAcquireTokenB2C(user); - } - - // @Test Disabled, the browser automation suddenly started failing without underlying code changes and needs investigation: https://github.com/AzureAD/microsoft-authentication-library-for-java/issues/1010 + @Test public void acquireTokenWithAuthorizationCode_CiamCud() throws Exception { String authorityCud = "https://login.msidlabsciam.com/fe362aec-5d43-45d1-b730-9755e60dc3b9/v2.0/"; @@ -131,28 +123,6 @@ private void assertAcquireTokenAAD(UserConfig user, String appId, Map", TestHelper.ENCODED_JWT); - ITokenCacheAccessAspect persistenceAspect = new TokenPersistence(dataToInitCache); + ITokenCacheAccessAspect persistenceAspect = new TestTokenCachePersistence(dataToInitCache); PublicClientApplication app = PublicClientApplication.builder("my_client_id") .setTokenCacheAccessAspect(persistenceAspect).build(); diff --git a/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/ClientCredentialsIT.java b/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/ClientCredentialsIT.java index 954c3b505..e2f970ce5 100644 --- a/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/ClientCredentialsIT.java +++ b/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/ClientCredentialsIT.java @@ -75,8 +75,7 @@ void acquireTokenClientCredentials_Certificate_CiamCud() throws Exception { .build()) .get(); - assertNotNull(result); - assertNotNull(result.accessToken()); + IntegrationTestHelper.assertAccessTokenNotNull(result); } @Test @@ -117,8 +116,7 @@ void acquireTokenClientCredentials_DefaultCacheLookup() throws Exception { .build()) .get(); - assertNotNull(result1); - assertNotNull(result1.accessToken()); + IntegrationTestHelper.assertAccessTokenNotNull(result1); IAuthenticationResult result2 = cca.acquireToken(ClientCredentialParameters .builder(Collections.singleton(KEYVAULT_DEFAULT_SCOPE)) @@ -133,8 +131,7 @@ void acquireTokenClientCredentials_DefaultCacheLookup() throws Exception { .build()) .get(); - assertNotNull(result3); - assertNotNull(result3.accessToken()); + IntegrationTestHelper.assertAccessTokenNotNull(result3); assertNotEquals(result2.accessToken(), result3.accessToken()); } @@ -164,8 +161,7 @@ private void assertAcquireTokenCommon(String clientId, IClientCredential credent .build()) .get(); - assertNotNull(result); - assertNotNull(result.accessToken()); + IntegrationTestHelper.assertAccessTokenNotNull(result); } private void assertAcquireTokenCommon_withParameters(AppConfig app, IClientCredential credential, IClientCredential credentialParam) throws Exception { @@ -180,8 +176,7 @@ private void assertAcquireTokenCommon_withParameters(AppConfig app, IClientCrede .build()) .get(); - assertNotNull(result); - assertNotNull(result.accessToken()); + IntegrationTestHelper.assertAccessTokenNotNull(result); } private void assertAcquireTokenCommon_withRegion(AppConfig app, IClientCredential credential, String region, String regionalAuthority) throws Exception { @@ -202,8 +197,7 @@ private void assertAcquireTokenCommon_withRegion(AppConfig app, IClientCredentia .build()) .get(); - assertNotNull(resultNoRegion); - assertNotNull(resultNoRegion.accessToken()); + IntegrationTestHelper.assertAccessTokenNotNull(resultNoRegion); assertEquals(TestConstants.MICROSOFT_AUTHORITY_BASIC_HOST, resultNoRegion.environment()); //Ensure regional tokens are properly cached and retrievable @@ -212,17 +206,15 @@ private void assertAcquireTokenCommon_withRegion(AppConfig app, IClientCredentia .build()) .get(); - assertNotNull(resultRegion); - assertNotNull(resultRegion.accessToken()); - assertEquals(resultRegion.environment(), regionalAuthority); + IntegrationTestHelper.assertAccessTokenNotNull(resultRegion); + assertEquals(regionalAuthority, resultRegion.environment()); IAuthenticationResult resultRegionCached = ccaRegion.acquireToken(ClientCredentialParameters .builder(Collections.singleton(KEYVAULT_DEFAULT_SCOPE)) .build()) .get(); - assertNotNull(resultRegionCached); - assertNotNull(resultRegionCached.accessToken()); + IntegrationTestHelper.assertAccessTokenNotNull(resultRegionCached); assertEquals(resultRegionCached.accessToken(), resultRegion.accessToken()); //Tokens retrieved from regional endpoints should be interchangeable with non-regional, and vice-versa @@ -233,8 +225,7 @@ private void assertAcquireTokenCommon_withRegion(AppConfig app, IClientCredentia .build()) .get(); - assertNotNull(resultNoRegion); - assertNotNull(resultNoRegion.accessToken()); + IntegrationTestHelper.assertAccessTokenNotNull(resultNoRegion); assertEquals(resultNoRegion.accessToken(), resultRegion.accessToken()); } } diff --git a/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/DeviceCodeIT.java b/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/DeviceCodeIT.java index 0f205053b..eb5bd3b12 100644 --- a/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/DeviceCodeIT.java +++ b/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/DeviceCodeIT.java @@ -6,6 +6,9 @@ import com.microsoft.aad.msal4j.labapi.*; import static com.microsoft.aad.msal4j.labapi.KeyVaultSecrets.*; import infrastructure.SeleniumExtensions; +import infrastructure.SeleniumTestWatcher; +import infrastructure.WebDriverProvider; +import org.junit.jupiter.api.extension.ExtendWith; import org.openqa.selenium.WebDriver; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -19,7 +22,8 @@ import java.util.function.Consumer; @TestInstance(TestInstance.Lifecycle.PER_CLASS) -class DeviceCodeIT { +@ExtendWith(SeleniumTestWatcher.class) +class DeviceCodeIT implements WebDriverProvider { private static final Logger LOG = LoggerFactory.getLogger(DeviceCodeIT.class); private WebDriver seleniumDriver; @@ -29,9 +33,7 @@ void setUp() { seleniumDriver = SeleniumExtensions.createDefaultWebDriver(); } - //Temporarily disabling: timeout occuring after 15 minutes, likely either a server-side issue or a UI change - //Needs investigation, tracked in https://github.com/AzureAD/microsoft-authentication-library-for-java/issues/1023 - //@Test + @Test void DeviceCodeFlowADTest() throws Exception { AppConfig app = LabResponseHelper.getAppConfig(APP_PCACLIENT); UserConfig user = LabResponseHelper.getUserConfig(USER_PUBLIC_CLOUD); @@ -57,10 +59,15 @@ private void runAutomatedDeviceCodeFlow(DeviceCode deviceCode, UserConfig user) user); } + @Override + public WebDriver getWebDriver() { + return seleniumDriver; + } + @AfterAll void cleanUp() { if (seleniumDriver != null) { - seleniumDriver.close(); + seleniumDriver.quit(); } } } diff --git a/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/HttpClientIT.java b/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/HttpClientIT.java index fd67946fe..674db9bd9 100644 --- a/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/HttpClientIT.java +++ b/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/HttpClientIT.java @@ -12,7 +12,6 @@ import java.util.concurrent.ExecutionException; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -50,16 +49,10 @@ private void assertAcquireTokenCommon(UserConfig user, String appId, IHttpClient httpClient(httpClient). build(); - IAuthenticationResult result = pca.acquireToken(UserNamePasswordParameters. - builder(Collections.singleton(TestConstants.GRAPH_DEFAULT_SCOPE), - user.getUpn(), - user.getPassword().toCharArray()) - .build()) - .get(); + IAuthenticationResult result = IntegrationTestHelper.acquireTokenByRopc( + pca, user, TestConstants.GRAPH_DEFAULT_SCOPE); - assertNotNull(result); - assertNotNull(result.accessToken()); - assertNotNull(result.idToken()); + IntegrationTestHelper.assertAccessAndIdTokensNotNull(result); assertEquals(user.getUpn(), result.account().username()); } diff --git a/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/IntegrationTestHelper.java b/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/IntegrationTestHelper.java index 04e437d2d..19f02ce18 100644 --- a/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/IntegrationTestHelper.java +++ b/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/IntegrationTestHelper.java @@ -3,26 +3,112 @@ package com.microsoft.aad.msal4j; +import com.microsoft.aad.msal4j.labapi.UserConfig; + import java.net.MalformedURLException; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; +import java.util.concurrent.ExecutionException; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; class IntegrationTestHelper { - static PublicClientApplication createPublicApp(String appID, String authority) { + // --- Application builders --- + + static PublicClientApplication createPublicApp(String appId, String authority) { try { - return PublicClientApplication.builder( - appID). - authority(authority). - build(); + return PublicClientApplication.builder(appId) + .authority(authority) + .build(); } catch (MalformedURLException e) { throw new RuntimeException(e); } } + static ConfidentialClientApplication createCca(String clientId, IClientCredential credential, String authority) { + try { + return ConfidentialClientApplication.builder(clientId, credential) + .authority(authority) + .build(); + } catch (MalformedURLException e) { + throw new RuntimeException(e); + } + } + + // --- Token acquisition shortcuts --- + + static IAuthenticationResult acquireTokenByRopc( + PublicClientApplication pca, UserConfig user, Set scopes) + throws ExecutionException, InterruptedException { + return pca.acquireToken(UserNamePasswordParameters + .builder(scopes, user.getUpn(), user.getPassword().toCharArray()) + .build()) + .get(); + } + + static IAuthenticationResult acquireTokenByRopc( + PublicClientApplication pca, UserConfig user, String... scopes) + throws ExecutionException, InterruptedException { + return acquireTokenByRopc(pca, user, toScopeSet(scopes)); + } + + static IAuthenticationResult acquireTokenSilently( + IPublicClientApplication pca, IAccount account, Set scopes) + throws ExecutionException, InterruptedException, MalformedURLException { + return pca.acquireTokenSilently(SilentParameters + .builder(scopes, account) + .build()) + .get(); + } + + static IAuthenticationResult acquireTokenSilently( + IPublicClientApplication pca, IAccount account, String... scopes) + throws ExecutionException, InterruptedException, MalformedURLException { + return acquireTokenSilently(pca, account, toScopeSet(scopes)); + } + + // --- Assertions --- + static void assertAccessAndIdTokensNotNull(IAuthenticationResult result) { assertNotNull(result); assertNotNull(result.accessToken()); assertNotNull(result.idToken()); } + + static void assertAccessTokenNotNull(IAuthenticationResult result) { + assertNotNull(result); + assertNotNull(result.accessToken()); + } + + static void assertAccessTokensEqual(IAuthenticationResult result1, IAuthenticationResult result2) { + assertEquals(result1.accessToken(), result2.accessToken()); + } + + static void assertAccessTokensNotEqual(IAuthenticationResult result1, IAuthenticationResult result2) { + assertNotEquals(result1.accessToken(), result2.accessToken()); + } + + static void assertTokensAreEqual(IAuthenticationResult result1, IAuthenticationResult result2) { + assertEquals(result1.accessToken(), result2.accessToken()); + assertEquals(result1.idToken(), result2.idToken()); + } + + static void assertTokensAreNotEqual(IAuthenticationResult result1, IAuthenticationResult result2) { + assertNotEquals(result1.accessToken(), result2.accessToken()); + assertNotEquals(result1.idToken(), result2.idToken()); + } + + // --- Utilities --- + + private static Set toScopeSet(String... scopes) { + if (scopes.length == 1) { + return Collections.singleton(scopes[0]); + } + return new HashSet<>(Arrays.asList(scopes)); + } } diff --git a/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/OnBehalfOfIT.java b/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/OnBehalfOfIT.java index 597bf4863..15921b2e4 100644 --- a/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/OnBehalfOfIT.java +++ b/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/OnBehalfOfIT.java @@ -8,9 +8,6 @@ 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.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; import java.util.Collections; @@ -41,7 +38,7 @@ void acquireTokenWithOBO_Managed() throws Exception { new UserAssertion(accessToken)).build()). get(); - assertResultNotNull(result); + IntegrationTestHelper.assertAccessTokenNotNull(result); } @Test @@ -62,7 +59,7 @@ void acquireTokenWithOBO_testCache() throws Exception { new UserAssertion(accessToken)).build()). get(); - assertResultNotNull(result1); + IntegrationTestHelper.assertAccessTokenNotNull(result1); // Same scope and userAssertion, should return cached tokens IAuthenticationResult result2 = @@ -71,7 +68,7 @@ void acquireTokenWithOBO_testCache() throws Exception { new UserAssertion(accessToken)).build()). get(); - assertEquals(result1.accessToken(), result2.accessToken()); + IntegrationTestHelper.assertAccessTokensEqual(result1, result2); // Scope 2, should return new token IAuthenticationResult result3 = @@ -80,8 +77,8 @@ void acquireTokenWithOBO_testCache() throws Exception { new UserAssertion(accessToken)).build()). get(); - assertResultNotNull(result3); - assertNotEquals(result2.accessToken(), result3.accessToken()); + IntegrationTestHelper.assertAccessTokenNotNull(result3); + IntegrationTestHelper.assertAccessTokensNotEqual(result2, result3); // Scope 2, should return cached token IAuthenticationResult result4 = @@ -90,7 +87,7 @@ void acquireTokenWithOBO_testCache() throws Exception { new UserAssertion(accessToken)).build()). get(); - assertEquals(result3.accessToken(), result4.accessToken()); + IntegrationTestHelper.assertAccessTokensEqual(result3, result4); // skipCache=true, should return new token IAuthenticationResult result5 = @@ -102,9 +99,9 @@ void acquireTokenWithOBO_testCache() throws Exception { .build()). get(); - assertResultNotNull(result5); - assertNotEquals(result5.accessToken(), result4.accessToken()); - assertNotEquals(result5.accessToken(), result2.accessToken()); + IntegrationTestHelper.assertAccessTokenNotNull(result5); + IntegrationTestHelper.assertAccessTokensNotEqual(result5, result4); + IntegrationTestHelper.assertAccessTokensNotEqual(result5, result2); String newAccessToken = this.getAccessToken(); @@ -117,15 +114,10 @@ void acquireTokenWithOBO_testCache() throws Exception { .build()). get(); - assertResultNotNull(result6); - assertNotEquals(result6.accessToken(), result5.accessToken()); - assertNotEquals(result6.accessToken(), result4.accessToken()); - assertNotEquals(result6.accessToken(), result2.accessToken()); - } - - private void assertResultNotNull(IAuthenticationResult result) { - assertNotNull(result); - assertNotNull(result.accessToken()); + IntegrationTestHelper.assertAccessTokenNotNull(result6); + IntegrationTestHelper.assertAccessTokensNotEqual(result6, result5); + IntegrationTestHelper.assertAccessTokensNotEqual(result6, result4); + IntegrationTestHelper.assertAccessTokensNotEqual(result6, result2); } private String getAccessToken() throws Exception { diff --git a/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/RefreshTokenIT.java b/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/RefreshTokenIT.java index a0e14db64..a41132288 100644 --- a/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/RefreshTokenIT.java +++ b/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/RefreshTokenIT.java @@ -7,7 +7,6 @@ import static com.microsoft.aad.msal4j.labapi.KeyVaultSecrets.*; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.TestInstance; -import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertThrows; import java.util.Collections; @@ -47,9 +46,7 @@ void acquireTokenWithRefreshToken() throws Exception { .build()) .get(); - assertNotNull(result); - assertNotNull(result.accessToken()); - assertNotNull(result.idToken()); + IntegrationTestHelper.assertAccessAndIdTokensNotNull(result); } @Test diff --git a/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/SeleniumTest.java b/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/SeleniumTest.java index 922c03c44..a9a6f3965 100644 --- a/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/SeleniumTest.java +++ b/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/SeleniumTest.java @@ -5,11 +5,15 @@ import com.microsoft.aad.msal4j.labapi.UserConfig; import infrastructure.SeleniumExtensions; +import infrastructure.SeleniumTestWatcher; +import infrastructure.WebDriverProvider; +import org.junit.jupiter.api.extension.ExtendWith; import org.openqa.selenium.WebDriver; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -abstract class SeleniumTest { +@ExtendWith(SeleniumTestWatcher.class) +abstract class SeleniumTest implements WebDriverProvider { private static final Logger LOG = LoggerFactory.getLogger(SeleniumTest.class); WebDriver seleniumDriver; @@ -36,14 +40,16 @@ public void startUpBrowser() { seleniumDriver = SeleniumExtensions.createDefaultWebDriver(); } + @Override + public WebDriver getWebDriver() { + return seleniumDriver; + } + void runSeleniumAutomatedLogin(UserConfig user, AbstractClientApplicationBase app) { AuthorityType authorityType = app.authenticationAuthority.authorityType; try { switch (authorityType) { - case B2C: - SeleniumExtensions.performLocalLogin(seleniumDriver, user); - break; case AAD: SeleniumExtensions.performADOrCiamLogin(seleniumDriver, user); break; @@ -51,8 +57,17 @@ void runSeleniumAutomatedLogin(UserConfig user, AbstractClientApplicationBase ap SeleniumExtensions.performADFSLogin(seleniumDriver, user); break; case CIAM: + SeleniumExtensions.performCiamLogin(seleniumDriver, user); + break; case OIDC: - SeleniumExtensions.performADOrCiamLogin(seleniumDriver, user); + // OIDC authorities may use CIAM or AAD login pages depending on the host. + // Check the authority host to determine which page object to use. + if (app.authenticationAuthority.host.contains("ciam") || + app.authenticationAuthority.host.contains("msidlabsciam")) { + SeleniumExtensions.performCiamLogin(seleniumDriver, user); + } else { + SeleniumExtensions.performADOrCiamLogin(seleniumDriver, user); + } break; default: throw new IllegalArgumentException("Unsupported authority type: " + authorityType); diff --git a/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/TestConstants.java b/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/TestConstants.java index ce06f8d81..f2f2d63b3 100644 --- a/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/TestConstants.java +++ b/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/TestConstants.java @@ -8,9 +8,6 @@ public class TestConstants { public static final String GRAPH_DEFAULT_SCOPE = "https://graph.windows.net/.default"; public static final String USER_READ_SCOPE = "user.read"; public static final String DEFAULT_SCOPE = ".default"; - public static final String B2C_LAB_SCOPE = "https://msidlabb2c.onmicrosoft.com/msaapp/user_impersonation"; - public static final String B2C_CONFIDENTIAL_CLIENT_APP_SECRETID = "MSIDLABB2C-MSAapp-AppSecret"; - public static final String B2C_CONFIDENTIAL_CLIENT_LAB_APP_ID = "MSIDLABB2C-MSAapp-AppID"; public static final String MICROSOFT_AUTHORITY_HOST = "https://login.microsoftonline.com/"; public static final String MICROSOFT_AUTHORITY_BASIC_HOST = "login.microsoftonline.com"; @@ -23,16 +20,12 @@ public class TestConstants { public static final String REGIONAL_MICROSOFT_AUTHORITY_BASIC_HOST_WESTUS = "westus.login.microsoft.com"; public static final String B2C_AUTHORITY = "https://msidlabb2c.b2clogin.com/msidlabb2c.onmicrosoft.com/"; - public static final String B2C_AUTHORITY_LEGACY_FORMAT = "https://msidlabb2c.b2clogin.com/tfp/msidlabb2c.onmicrosoft.com/"; public static final String B2C_ROPC_POLICY = "B2C_1_ROPC_Auth"; - public static final String B2C_SIGN_IN_POLICY = "B2C_1_SignInPolicy"; - public static final String B2C_AUTHORITY_SIGN_IN = B2C_AUTHORITY + B2C_SIGN_IN_POLICY; public static final String B2C_AUTHORITY_ROPC = B2C_AUTHORITY + B2C_ROPC_POLICY; public static final String B2C_READ_SCOPE = "https://msidlabb2c.onmicrosoft.com/msidlabb2capi/read"; public static final String B2C_MICROSOFTLOGIN_AUTHORITY = "https://msidlabb2c.b2clogin.com/tfp/msidlabb2c.onmicrosoft.com/"; public static final String B2C_MICROSOFTLOGIN_ROPC = B2C_MICROSOFTLOGIN_AUTHORITY + B2C_ROPC_POLICY; - public static final String B2C_UPN = "b2clocal@msidlabb2c.onmicrosoft.com"; public static final String LOCALHOST = "http://localhost:"; diff --git a/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/TestTokenCachePersistence.java b/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/TestTokenCachePersistence.java new file mode 100644 index 000000000..f6955ea77 --- /dev/null +++ b/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/TestTokenCachePersistence.java @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.aad.msal4j; + +/** + * Simple in-memory token cache persistence for integration tests. + * Implements ITokenCacheAccessAspect to serialize/deserialize cache data + * across application instances. + */ +class TestTokenCachePersistence implements ITokenCacheAccessAspect { + String data; + + TestTokenCachePersistence(String data) { + this.data = data; + } + + @Override + public void beforeCacheAccess(ITokenCacheAccessContext iTokenCacheAccessContext) { + iTokenCacheAccessContext.tokenCache().deserialize(data); + } + + @Override + public void afterCacheAccess(ITokenCacheAccessContext iTokenCacheAccessContext) { + data = iTokenCacheAccessContext.tokenCache().serialize(); + } +} diff --git a/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/TokenCacheIT.java b/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/TokenCacheIT.java index 570c289a7..2295a43e4 100644 --- a/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/TokenCacheIT.java +++ b/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/TokenCacheIT.java @@ -29,7 +29,7 @@ void singleAccountInCache_RemoveAccountTest() throws Exception { build(); // Check that cache is empty - assertEquals(pca.getAccounts().join().size(), 0); + assertEquals(0, pca.getAccounts().join().size()); Map extraQueryParameters = new HashMap<>(); extraQueryParameters.put("test", "test"); @@ -43,12 +43,12 @@ void singleAccountInCache_RemoveAccountTest() throws Exception { .get(); // Check that cache contains one account - assertEquals(pca.getAccounts().join().size(), 1); + assertEquals(1, pca.getAccounts().join().size()); pca.removeAccount(pca.getAccounts().join().iterator().next()).join(); // Check that account has been removed - assertEquals(pca.getAccounts().join().size(), 0); + assertEquals(0, pca.getAccounts().join().size()); } @Test @@ -65,7 +65,7 @@ void twoAccountsInCache_SameUserDifferentTenants_RemoveAccountTest() throws Exce // check that cache is empty assertEquals(dataToInitCache, ""); - ITokenCacheAccessAspect persistenceAspect = new TokenPersistence(dataToInitCache); + ITokenCacheAccessAspect persistenceAspect = new TestTokenCachePersistence(dataToInitCache); // acquire tokens for home tenant, and serialize cache PublicClientApplication pca = PublicClientApplication.builder( @@ -112,22 +112,4 @@ void twoAccountsInCache_SameUserDifferentTenants_RemoveAccountTest() throws Exce this.getClass(), "/cache_data/remove-account-test-cache.json"); } - - private static class TokenPersistence implements ITokenCacheAccessAspect { - String data; - - TokenPersistence(String data) { - this.data = data; - } - - @Override - public void beforeCacheAccess(ITokenCacheAccessContext iTokenCacheAccessContext) { - iTokenCacheAccessContext.tokenCache().deserialize(data); - } - - @Override - public void afterCacheAccess(ITokenCacheAccessContext iTokenCacheAccessContext) { - data = iTokenCacheAccessContext.tokenCache().serialize(); - } - } } diff --git a/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/UsernamePasswordIT.java b/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/UsernamePasswordIT.java index f2ee9121c..9b45dc41e 100644 --- a/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/UsernamePasswordIT.java +++ b/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/UsernamePasswordIT.java @@ -59,17 +59,9 @@ void acquireTokenWithUsernamePassword_Ciam() throws Exception { private void assertAcquireTokenCommon(UserConfig user, String authority, String scope, String appId) throws Exception { - PublicClientApplication pca = PublicClientApplication.builder( - appId). - authority(authority). - build(); + PublicClientApplication pca = IntegrationTestHelper.createPublicApp(appId, authority); - IAuthenticationResult result = pca.acquireToken(UserNamePasswordParameters. - builder(Collections.singleton(scope), - user.getUpn(), - user.getPassword().toCharArray()) - .build()) - .get(); + IAuthenticationResult result = IntegrationTestHelper.acquireTokenByRopc(pca, user, scope); IntegrationTestHelper.assertAccessAndIdTokensNotNull(result); assertEquals(user.getUpn(), result.account().username()); @@ -95,7 +87,6 @@ void acquireTokenWithUsernamePassword_B2C_CustomAuthority() throws Exception { IntegrationTestHelper.assertAccessAndIdTokensNotNull(result); IAccount account = pca.getAccounts().join().iterator().next(); - SilentParameters.builder(Collections.singleton(TestConstants.B2C_READ_SCOPE), account); result = pca.acquireTokenSilently( SilentParameters.builder(Collections.singleton(TestConstants.B2C_READ_SCOPE), account) @@ -125,7 +116,6 @@ void acquireTokenWithUsernamePassword_B2C_LoginMicrosoftOnline() throws Exceptio IntegrationTestHelper.assertAccessAndIdTokensNotNull(result); IAccount account = pca.getAccounts().join().iterator().next(); - SilentParameters.builder(Collections.singleton(TestConstants.B2C_READ_SCOPE), account); result = pca.acquireTokenSilently( SilentParameters.builder(Collections.singleton(TestConstants.B2C_READ_SCOPE), account) diff --git a/msal4j-sdk/src/integrationtest/java/infrastructure/SeleniumDiagnostics.java b/msal4j-sdk/src/integrationtest/java/infrastructure/SeleniumDiagnostics.java new file mode 100644 index 000000000..cdae235d3 --- /dev/null +++ b/msal4j-sdk/src/integrationtest/java/infrastructure/SeleniumDiagnostics.java @@ -0,0 +1,187 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package infrastructure; + +import org.openqa.selenium.OutputType; +import org.openqa.selenium.TakesScreenshot; +import org.openqa.selenium.WebDriver; +import org.openqa.selenium.logging.LogEntry; +import org.openqa.selenium.logging.LogType; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardCopyOption; +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.List; + +/** + * Utility for capturing diagnostic information from Selenium WebDriver on test failure. + *

+ * Captures screenshots, page source (HTML), and browser console logs to help diagnose + * failures in browser-based integration tests. All capture methods are fail-safe and will + * not throw exceptions, so they can be called safely during test teardown without masking + * the original test failure. + *

+ * Output is written to {@code target/selenium-diagnostics/} with filenames that include + * the test name and a timestamp for uniqueness. + */ +public final class SeleniumDiagnostics { + + private static final Logger LOG = LoggerFactory.getLogger(SeleniumDiagnostics.class); + private static final String OUTPUT_DIR = "target/selenium-diagnostics"; + + private SeleniumDiagnostics() { + } + + /** + * Capture all available diagnostics (screenshot, page source, browser logs) for a failed test. + * + * @param driver the WebDriver instance (may be null) + * @param testName the name of the test method that failed + */ + public static void captureAll(WebDriver driver, String testName) { + if (driver == null) { + LOG.warn("Cannot capture diagnostics: WebDriver is null"); + return; + } + + String sanitizedName = sanitizeFileName(testName); + String timestamp = new SimpleDateFormat("yyyyMMdd-HHmmss").format(new Date()); + String filePrefix = sanitizedName + "-" + timestamp; + + captureScreenshot(driver, filePrefix); + capturePageSource(driver, filePrefix); + captureBrowserLogs(driver, filePrefix); + } + + /** + * Capture a screenshot of the current browser state. + * + * @param driver the WebDriver instance + * @param filePrefix the file name prefix (test name + timestamp) + */ + static void captureScreenshot(WebDriver driver, String filePrefix) { + try { + if (!(driver instanceof TakesScreenshot)) { + LOG.warn("WebDriver does not support screenshots"); + return; + } + + File screenshot = ((TakesScreenshot) driver).getScreenshotAs(OutputType.FILE); + Path destination = getOutputPath(filePrefix + ".png"); + Files.copy(screenshot.toPath(), destination, StandardCopyOption.REPLACE_EXISTING); + LOG.info("Screenshot saved: {}", destination); + } catch (Exception e) { + LOG.warn("Failed to capture screenshot: {}", e.getMessage()); + } + } + + /** + * Capture the current page source (HTML) for element inspection. + *

+ * Sensitive parameters (auth codes, tokens, etc.) in URLs and form actions are redacted. + * + * @param driver the WebDriver instance + * @param filePrefix the file name prefix (test name + timestamp) + */ + static void capturePageSource(WebDriver driver, String filePrefix) { + try { + String pageSource = driver.getPageSource(); + if (pageSource == null || pageSource.isEmpty()) { + LOG.warn("Page source is empty"); + return; + } + + String currentUrl = driver.getCurrentUrl(); + String redactedUrl = redactSensitiveParams(currentUrl); + + StringBuilder content = new StringBuilder(); + content.append("\n"); + content.append("\n"); + content.append(pageSource); + + Path destination = getOutputPath(filePrefix + ".html"); + Files.write(destination, content.toString().getBytes(StandardCharsets.UTF_8)); + LOG.info("Page source saved: {}", destination); + } catch (Exception e) { + LOG.warn("Failed to capture page source: {}", e.getMessage()); + } + } + + /** + * Capture browser console logs (JavaScript errors, network issues, etc.). + * + * @param driver the WebDriver instance + * @param filePrefix the file name prefix (test name + timestamp) + */ + static void captureBrowserLogs(WebDriver driver, String filePrefix) { + try { + List logs = driver.manage().logs().get(LogType.BROWSER).getAll(); + + if (logs.isEmpty()) { + LOG.debug("No browser console logs to capture"); + return; + } + + StringBuilder content = new StringBuilder(); + content.append("Browser Console Logs\n"); + content.append("====================\n\n"); + + for (LogEntry entry : logs) { + content.append("[").append(entry.getLevel()).append("] "); + content.append(new SimpleDateFormat("HH:mm:ss.SSS").format(new Date(entry.getTimestamp()))); + content.append(" - ").append(redactSensitiveParams(entry.getMessage())); + content.append("\n"); + } + + Path destination = getOutputPath(filePrefix + "-console.log"); + Files.write(destination, content.toString().getBytes(StandardCharsets.UTF_8)); + LOG.info("Browser logs saved: {}", destination); + } catch (Exception e) { + LOG.warn("Failed to capture browser logs: {} (this may be expected if logging prefs are not supported)", e.getMessage()); + } + } + + /** + * Get the output path for a diagnostic file, creating the directory if needed. + */ + private static Path getOutputPath(String fileName) throws IOException { + Path dir = Paths.get(OUTPUT_DIR); + if (!Files.exists(dir)) { + Files.createDirectories(dir); + } + return dir.resolve(fileName); + } + + /** + * Sanitize a test name to be safe for use as a file name on all platforms. + * Removes/replaces characters that are invalid in Windows file paths. + */ + private static String sanitizeFileName(String name) { + if (name == null) { + return "unknown"; + } + return name.replaceAll("[^a-zA-Z0-9._-]", "_"); + } + + /** + * Redact sensitive OAuth parameters from URLs and log messages. + * Replaces values of known sensitive query parameters with "[REDACTED]". + */ + public static String redactSensitiveParams(String text) { + if (text == null) { + return ""; + } + return text.replaceAll( + "(?i)(code|access_token|id_token|refresh_token|client_secret|password|assertion)=([^&\\s\"']*)", + "$1=[REDACTED]"); + } +} diff --git a/msal4j-sdk/src/integrationtest/java/infrastructure/SeleniumExtensions.java b/msal4j-sdk/src/integrationtest/java/infrastructure/SeleniumExtensions.java index 7a171b05b..25bcb8da2 100644 --- a/msal4j-sdk/src/integrationtest/java/infrastructure/SeleniumExtensions.java +++ b/msal4j-sdk/src/integrationtest/java/infrastructure/SeleniumExtensions.java @@ -6,18 +6,21 @@ import com.microsoft.aad.msal4j.labapi.UserConfig; import infrastructure.pageobjects.ADFSLoginPage; import infrastructure.pageobjects.AzureADLoginPage; -import infrastructure.pageobjects.B2CLocalLoginPage; +import infrastructure.pageobjects.CIAMLoginPage; import org.openqa.selenium.By; import org.openqa.selenium.WebDriver; import org.openqa.selenium.WebElement; import org.openqa.selenium.chrome.ChromeDriver; import org.openqa.selenium.chrome.ChromeOptions; +import org.openqa.selenium.logging.LogType; +import org.openqa.selenium.logging.LoggingPreferences; import org.openqa.selenium.support.ui.ExpectedConditions; import org.openqa.selenium.support.ui.WebDriverWait; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.time.Duration; +import java.util.logging.Level; public class SeleniumExtensions { @@ -28,17 +31,67 @@ private SeleniumExtensions() { public static WebDriver createDefaultWebDriver() { ChromeOptions options = new ChromeOptions(); - options.addArguments("--headless"); + options.addArguments("--headless=new"); options.addArguments("--incognito"); + // Enable browser console logging for diagnostic capture on failure + LoggingPreferences logPrefs = new LoggingPreferences(); + logPrefs.enable(LogType.BROWSER, Level.ALL); + options.setCapability("goog:loggingPrefs", logPrefs); + return new ChromeDriver(options); } public static WebElement waitForElementToBeVisibleAndEnabled(WebDriver driver, By by, Duration timeout) { - WebDriverWait wait = new WebDriverWait(driver, timeout.getSeconds()); + WebDriverWait wait = new WebDriverWait(driver, timeout); return wait.until(ExpectedConditions.elementToBeClickable(by)); } + /** + * Wait for any one of several locators to match a clickable element, returning the first match. + *

+ * This uses a single wait loop that checks all locators on each poll cycle, so the total + * wait time is bounded by {@code timeout} regardless of how many locators are provided. + * When a fallback locator matches (not the primary), a warning is logged to flag that + * the page structure may have changed. + * + * @param driver the WebDriver instance + * @param timeout maximum time to wait for any locator to match + * @param locators one or more locators to try, in priority order (primary first) + * @return the first clickable WebElement found + * @throws org.openqa.selenium.TimeoutException if no locator matches within the timeout + */ + public static WebElement findWithFallback(WebDriver driver, Duration timeout, By... locators) { + if (locators.length == 0) { + throw new IllegalArgumentException("At least one locator must be provided"); + } + + if (locators.length == 1) { + return waitForElementToBeVisibleAndEnabled(driver, locators[0], timeout); + } + + WebDriverWait wait = new WebDriverWait(driver, timeout); + final By primaryLocator = locators[0]; + + return wait.until(d -> { + for (By locator : locators) { + try { + WebElement element = d.findElement(locator); + if (element != null && element.isDisplayed() && element.isEnabled()) { + if (!locator.equals(primaryLocator)) { + LOG.warn("Primary locator {} not found, matched fallback: {}", + primaryLocator, locator); + } + return element; + } + } catch (Exception ignored) { + // Element not found with this locator, try next + } + } + return null; + }); + } + public static void performADOrCiamLogin(WebDriver driver, UserConfig user) { LOG.info("performADOrCiamLogin for user: {}", user.getUpn()); @@ -53,10 +106,10 @@ public static void performADFSLogin(WebDriver driver, UserConfig user) { loginPage.login(user.getUpn(), user.getPassword()); } - public static void performLocalLogin(WebDriver driver, UserConfig user) { - LOG.info("performLocalLogin"); + public static void performCiamLogin(WebDriver driver, UserConfig user) { + LOG.info("performCiamLogin for user: {}", user.getUpn()); - B2CLocalLoginPage loginPage = new B2CLocalLoginPage(driver); + CIAMLoginPage loginPage = new CIAMLoginPage(driver); loginPage.login(user.getUpn(), user.getPassword()); } diff --git a/msal4j-sdk/src/integrationtest/java/infrastructure/SeleniumTestWatcher.java b/msal4j-sdk/src/integrationtest/java/infrastructure/SeleniumTestWatcher.java new file mode 100644 index 000000000..972054dc5 --- /dev/null +++ b/msal4j-sdk/src/integrationtest/java/infrastructure/SeleniumTestWatcher.java @@ -0,0 +1,70 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package infrastructure; + +import org.junit.jupiter.api.extension.AfterTestExecutionCallback; +import org.junit.jupiter.api.extension.ExtensionContext; +import org.openqa.selenium.WebDriver; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * JUnit 5 extension that captures Selenium diagnostics (screenshot, page source, browser logs) + * when a test fails. + *

+ * This extension uses {@link AfterTestExecutionCallback} which runs after the test method + * but before {@code @AfterEach} teardown. This ordering is critical because it ensures + * the WebDriver is still alive when diagnostics are captured — the driver is typically closed + * in {@code @AfterEach}. + *

+ * Test classes must implement {@link WebDriverProvider} to expose their WebDriver instance. + * If the test class does not implement the interface, or the driver is null, diagnostics + * are silently skipped. + *

+ * Usage: + *

+ * {@literal @}ExtendWith(SeleniumTestWatcher.class)
+ * class MySeleniumTest implements WebDriverProvider {
+ *     WebDriver driver;
+ *
+ *     public WebDriver getWebDriver() { return driver; }
+ * }
+ * 
+ * + * @see SeleniumDiagnostics + * @see WebDriverProvider + */ +public class SeleniumTestWatcher implements AfterTestExecutionCallback { + + private static final Logger LOG = LoggerFactory.getLogger(SeleniumTestWatcher.class); + + @Override + public void afterTestExecution(ExtensionContext context) { + // Only capture diagnostics if the test failed + if (!context.getExecutionException().isPresent()) { + return; + } + + Object testInstance = context.getTestInstance().orElse(null); + if (!(testInstance instanceof WebDriverProvider)) { + LOG.debug("Test class does not implement WebDriverProvider, skipping diagnostics"); + return; + } + + WebDriver driver = ((WebDriverProvider) testInstance).getWebDriver(); + if (driver == null) { + LOG.warn("WebDriver is null, cannot capture diagnostics for failed test: {}", + context.getDisplayName()); + return; + } + + String testName = context.getTestClass() + .map(Class::getSimpleName) + .orElse("UnknownClass") + + "." + context.getDisplayName(); + + LOG.info("Test failed: {} — capturing diagnostics", testName); + SeleniumDiagnostics.captureAll(driver, testName); + } +} diff --git a/msal4j-sdk/src/integrationtest/java/infrastructure/WebDriverProvider.java b/msal4j-sdk/src/integrationtest/java/infrastructure/WebDriverProvider.java new file mode 100644 index 000000000..dec688016 --- /dev/null +++ b/msal4j-sdk/src/integrationtest/java/infrastructure/WebDriverProvider.java @@ -0,0 +1,21 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package infrastructure; + +import org.openqa.selenium.WebDriver; + +/** + * Interface for test classes that manage a WebDriver instance. + * Used by {@link SeleniumTestWatcher} to access the driver for diagnostics on test failure. + */ +public interface WebDriverProvider { + + /** + * Returns the current WebDriver instance, or null if the driver has not been initialized + * or has already been closed. + * + * @return the WebDriver instance, or null + */ + WebDriver getWebDriver(); +} diff --git a/msal4j-sdk/src/integrationtest/java/infrastructure/pageobjects/ADFSLoginPage.java b/msal4j-sdk/src/integrationtest/java/infrastructure/pageobjects/ADFSLoginPage.java index ec7ccd0ad..aaa30a4e8 100644 --- a/msal4j-sdk/src/integrationtest/java/infrastructure/pageobjects/ADFSLoginPage.java +++ b/msal4j-sdk/src/integrationtest/java/infrastructure/pageobjects/ADFSLoginPage.java @@ -3,10 +3,9 @@ package infrastructure.pageobjects; +import infrastructure.SeleniumExtensions; import org.openqa.selenium.By; import org.openqa.selenium.WebDriver; -import org.openqa.selenium.support.ui.ExpectedConditions; -import org.openqa.selenium.support.ui.WebDriverWait; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -15,24 +14,37 @@ /** * Page Object Model for ADFS login page. * Represents the Active Directory Federation Services authentication flow. + *

+ * Uses fallback locators to handle ADFS version differences where element IDs + * may vary between ADFS 2019, 2022, and other versions. */ public class ADFSLoginPage { private static final Logger LOG = LoggerFactory.getLogger(ADFSLoginPage.class); private final WebDriver driver; - private final WebDriverWait wait; - // Element locators - private static final By USERNAME_INPUT = By.id("userNameInput"); - private static final By PASSWORD_INPUT = By.id("passwordInput"); - private static final By SUBMIT_BUTTON = By.id("submitButton"); + // Element locators with fallbacks for different ADFS versions + private static final By[] USERNAME_LOCATORS = { + By.id("userNameInput"), + By.id("ContentPlaceHolder1_UsernameTextBox"), + By.cssSelector("input[type='text'][name='UserName']"), + }; + private static final By[] PASSWORD_LOCATORS = { + By.id("passwordInput"), + By.id("ContentPlaceHolder1_PasswordTextBox"), + By.cssSelector("input[type='password'][name='Password']"), + }; + private static final By[] SUBMIT_LOCATORS = { + By.id("submitButton"), + By.id("ContentPlaceHolder1_SubmitButton"), + By.cssSelector("span[id='submitButton']"), + }; private static final Duration DEFAULT_TIMEOUT = Duration.ofSeconds(15); public ADFSLoginPage(WebDriver driver) { this.driver = driver; - this.wait = new WebDriverWait(driver, DEFAULT_TIMEOUT.getSeconds()); } /** @@ -43,7 +55,7 @@ public ADFSLoginPage(WebDriver driver) { */ public ADFSLoginPage enterUsername(String username) { LOG.info("Entering username: {}", username); - wait.until(ExpectedConditions.elementToBeClickable(USERNAME_INPUT)) + SeleniumExtensions.findWithFallback(driver, DEFAULT_TIMEOUT, USERNAME_LOCATORS) .sendKeys(username); return this; } @@ -56,7 +68,7 @@ public ADFSLoginPage enterUsername(String username) { */ public ADFSLoginPage enterPassword(String password) { LOG.info("Entering password"); - wait.until(ExpectedConditions.elementToBeClickable(PASSWORD_INPUT)) + SeleniumExtensions.findWithFallback(driver, DEFAULT_TIMEOUT, PASSWORD_LOCATORS) .sendKeys(password); return this; } @@ -68,7 +80,7 @@ public ADFSLoginPage enterPassword(String password) { */ public ADFSLoginPage clickSubmit() { LOG.info("Clicking submit button"); - wait.until(ExpectedConditions.elementToBeClickable(SUBMIT_BUTTON)) + SeleniumExtensions.findWithFallback(driver, DEFAULT_TIMEOUT, SUBMIT_LOCATORS) .click(); return this; } diff --git a/msal4j-sdk/src/integrationtest/java/infrastructure/pageobjects/AzureADLoginPage.java b/msal4j-sdk/src/integrationtest/java/infrastructure/pageobjects/AzureADLoginPage.java index dcaab6359..69b609ade 100644 --- a/msal4j-sdk/src/integrationtest/java/infrastructure/pageobjects/AzureADLoginPage.java +++ b/msal4j-sdk/src/integrationtest/java/infrastructure/pageobjects/AzureADLoginPage.java @@ -3,6 +3,7 @@ package infrastructure.pageobjects; +import infrastructure.SeleniumExtensions; import org.openqa.selenium.By; import org.openqa.selenium.TimeoutException; import org.openqa.selenium.WebDriver; @@ -16,6 +17,9 @@ /** * Page Object Model for Azure AD login page. * Represents the standard Azure AD authentication flow. + *

+ * Uses fallback locators for username/password fields to handle variations + * in the Azure AD login page across different tenants and configurations. */ public class AzureADLoginPage { @@ -24,9 +28,17 @@ public class AzureADLoginPage { private final WebDriver driver; private final WebDriverWait wait; - // Element locators - private static final By USERNAME_INPUT = By.id("i0116"); - private static final By PASSWORD_INPUT = By.id("i0118"); + // Element locators with fallbacks + private static final By[] USERNAME_LOCATORS = { + By.id("i0116"), + By.name("loginfmt"), + By.cssSelector("input[type='email'][name='loginfmt']"), + }; + private static final By[] PASSWORD_LOCATORS = { + By.id("i0118"), + By.name("passwd"), + By.cssSelector("input[type='password'][name='passwd']"), + }; private static final By NEXT_BUTTON = By.id("idSIButton9"); private static final By SUBMIT_BUTTON = By.id("idSIButton9"); @@ -43,7 +55,7 @@ public class AzureADLoginPage { public AzureADLoginPage(WebDriver driver) { this.driver = driver; - this.wait = new WebDriverWait(driver, DEFAULT_TIMEOUT.getSeconds()); + this.wait = new WebDriverWait(driver, DEFAULT_TIMEOUT); } /** @@ -54,7 +66,7 @@ public AzureADLoginPage(WebDriver driver) { */ public AzureADLoginPage enterUsername(String username) { LOG.info("Entering username: {}", username); - wait.until(ExpectedConditions.elementToBeClickable(USERNAME_INPUT)) + SeleniumExtensions.findWithFallback(driver, DEFAULT_TIMEOUT, USERNAME_LOCATORS) .sendKeys(username); return this; } @@ -79,7 +91,7 @@ public AzureADLoginPage clickNext() { */ public AzureADLoginPage enterPassword(String password) { LOG.info("Entering password"); - wait.until(ExpectedConditions.elementToBeClickable(PASSWORD_INPUT)) + SeleniumExtensions.findWithFallback(driver, DEFAULT_TIMEOUT, PASSWORD_LOCATORS) .sendKeys(password); return this; } @@ -103,7 +115,7 @@ public AzureADLoginPage clickSubmit() { */ public boolean isAuthenticationComplete() { try { - WebDriverWait shortWait = new WebDriverWait(driver, SHORT_TIMEOUT.getSeconds()); + WebDriverWait shortWait = new WebDriverWait(driver, SHORT_TIMEOUT); shortWait.until(ExpectedConditions.textToBePresentInElementLocated( AUTH_COMPLETE_BODY, AUTH_COMPLETE_TEXT)); LOG.info("Authentication complete page detected"); @@ -150,7 +162,7 @@ private void handleOptionalPrompts() { private void handleAreYouTryingToSignInPrompt() { try { LOG.info("Checking for 'Are you trying to sign in' prompt"); - WebDriverWait shortWait = new WebDriverWait(driver, SHORT_TIMEOUT.getSeconds()); + WebDriverWait shortWait = new WebDriverWait(driver, SHORT_TIMEOUT); shortWait.until(ExpectedConditions.elementToBeClickable(ARE_YOU_TRYING_TO_SIGN_IN_BUTTON)) .click(); LOG.info("Clicked Continue on 'Are you trying to sign in' prompt"); @@ -165,7 +177,7 @@ private void handleAreYouTryingToSignInPrompt() { private void handleStaySignedInPrompt() { try { LOG.info("Checking for 'Stay signed in' prompt"); - WebDriverWait shortWait = new WebDriverWait(driver, SHORT_TIMEOUT.getSeconds()); + WebDriverWait shortWait = new WebDriverWait(driver, SHORT_TIMEOUT); shortWait.until(ExpectedConditions.elementToBeClickable(STAY_SIGNED_IN_NO_BUTTON)) .click(); LOG.info("Clicked No on 'Stay signed in' prompt"); diff --git a/msal4j-sdk/src/integrationtest/java/infrastructure/pageobjects/B2CLocalLoginPage.java b/msal4j-sdk/src/integrationtest/java/infrastructure/pageobjects/B2CLocalLoginPage.java deleted file mode 100644 index 98bbf1820..000000000 --- a/msal4j-sdk/src/integrationtest/java/infrastructure/pageobjects/B2CLocalLoginPage.java +++ /dev/null @@ -1,105 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -package infrastructure.pageobjects; - -import com.microsoft.aad.msal4j.TestConstants; -import org.openqa.selenium.By; -import org.openqa.selenium.WebDriver; -import org.openqa.selenium.support.ui.ExpectedConditions; -import org.openqa.selenium.support.ui.WebDriverWait; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.time.Duration; - -/** - * Page Object Model for B2C Local Account login page. - * Represents the Azure AD B2C local account authentication flow. - */ -public class B2CLocalLoginPage { - - private static final Logger LOG = LoggerFactory.getLogger(B2CLocalLoginPage.class); - - private final WebDriver driver; - private final WebDriverWait wait; - - // Element locators - private static final By LOCAL_ACCOUNT_BUTTON = By.id("SignInWithLogonNameExchange"); - private static final By USERNAME_INPUT = By.id("cred_userid_inputtext"); - private static final By PASSWORD_INPUT = By.id("cred_password_inputtext"); - private static final By SIGN_IN_BUTTON = By.id("cred_sign_in_button"); - - private static final Duration DEFAULT_TIMEOUT = Duration.ofSeconds(15); - - public B2CLocalLoginPage(WebDriver driver) { - this.driver = driver; - this.wait = new WebDriverWait(driver, DEFAULT_TIMEOUT.getSeconds()); - } - - /** - * Click the local account sign-in option. - * - * @return This page object for method chaining - */ - public B2CLocalLoginPage clickLocalAccount() { - LOG.info("Clicking local account button"); - wait.until(ExpectedConditions.elementToBeClickable(LOCAL_ACCOUNT_BUTTON)) - .click(); - return this; - } - - /** - * Enter the username in the B2C login page. - * - * @param username The username to enter - * @return This page object for method chaining - */ - public B2CLocalLoginPage enterUsername(String username) { - LOG.info("Entering username: {}", TestConstants.B2C_UPN); - wait.until(ExpectedConditions.elementToBeClickable(USERNAME_INPUT)) - .sendKeys(TestConstants.B2C_UPN); - return this; - } - - /** - * Enter the password in the B2C login page. - * - * @param password The password to enter - * @return This page object for method chaining - */ - public B2CLocalLoginPage enterPassword(String password) { - LOG.info("Entering password"); - wait.until(ExpectedConditions.elementToBeClickable(PASSWORD_INPUT)) - .sendKeys(password); - return this; - } - - /** - * Click the Sign in button to complete login. - * - * @return This page object for method chaining - */ - public B2CLocalLoginPage clickSignIn() { - LOG.info("Clicking sign in button"); - wait.until(ExpectedConditions.elementToBeClickable(SIGN_IN_BUTTON)) - .click(); - return this; - } - - /** - * Perform a complete B2C local account login flow. - * This is a convenience method that chains all the necessary steps. - * - * @param username The username - * @param password The password - */ - public void login(String username, String password) { - clickLocalAccount() - .enterUsername(username) - .enterPassword(password) - .clickSignIn(); - - LOG.info("B2C local login completed"); - } -} diff --git a/msal4j-sdk/src/integrationtest/java/infrastructure/pageobjects/CIAMLoginPage.java b/msal4j-sdk/src/integrationtest/java/infrastructure/pageobjects/CIAMLoginPage.java new file mode 100644 index 000000000..6fc26cede --- /dev/null +++ b/msal4j-sdk/src/integrationtest/java/infrastructure/pageobjects/CIAMLoginPage.java @@ -0,0 +1,128 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package infrastructure.pageobjects; + +import infrastructure.SeleniumExtensions; +import org.openqa.selenium.By; +import org.openqa.selenium.WebDriver; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.time.Duration; + +/** + * Page Object Model for CIAM (Customer Identity and Access Management) login page. + * Represents the Azure AD CIAM authentication flow, which uses a multi-step form + * with different element IDs than the standard Azure AD login page. + *

+ * CIAM login flow: + * 1. Enter email address + * 2. Click Next + * 3. Enter password + * 4. Click Sign in + *

+ * Uses fallback locators to handle variations in CIAM tenant configurations. + */ +public class CIAMLoginPage { + + private static final Logger LOG = LoggerFactory.getLogger(CIAMLoginPage.class); + + private final WebDriver driver; + + // Username step locators + private static final By[] USERNAME_LOCATORS = { + By.name("username"), + By.cssSelector("input[type='text'][autocomplete*='username']"), + By.cssSelector("input[type='email']"), + }; + private static final By[] NEXT_BUTTON_LOCATORS = { + By.id("usernamePrimaryButton"), + By.cssSelector("button[type='submit'][aria-label='Next']"), + By.cssSelector("button.ext-primary[type='submit']"), + }; + + // Password step locators + private static final By[] PASSWORD_LOCATORS = { + By.name("password"), + By.id("password"), + By.cssSelector("input[type='password']"), + }; + private static final By[] SIGN_IN_BUTTON_LOCATORS = { + By.id("passwordPrimaryButton"), + By.cssSelector("button[type='submit'][aria-label='Sign in']"), + By.cssSelector("button.ext-primary[type='submit']"), + }; + + private static final Duration DEFAULT_TIMEOUT = Duration.ofSeconds(15); + + public CIAMLoginPage(WebDriver driver) { + this.driver = driver; + } + + /** + * Enter the username/email in the CIAM login page. + * + * @param username The email address to enter + * @return This page object for method chaining + */ + public CIAMLoginPage enterUsername(String username) { + LOG.info("Entering username: {}", username); + SeleniumExtensions.findWithFallback(driver, DEFAULT_TIMEOUT, USERNAME_LOCATORS) + .sendKeys(username); + return this; + } + + /** + * Click the Next button after entering the username. + * + * @return This page object for method chaining + */ + public CIAMLoginPage clickNext() { + LOG.info("Clicking Next button"); + SeleniumExtensions.findWithFallback(driver, DEFAULT_TIMEOUT, NEXT_BUTTON_LOCATORS) + .click(); + return this; + } + + /** + * Enter the password in the CIAM login page. + * + * @param password The password to enter + * @return This page object for method chaining + */ + public CIAMLoginPage enterPassword(String password) { + LOG.info("Entering password"); + SeleniumExtensions.findWithFallback(driver, DEFAULT_TIMEOUT, PASSWORD_LOCATORS) + .sendKeys(password); + return this; + } + + /** + * Click the Sign in button to complete login. + * + * @return This page object for method chaining + */ + public CIAMLoginPage clickSignIn() { + LOG.info("Clicking Sign in button"); + SeleniumExtensions.findWithFallback(driver, DEFAULT_TIMEOUT, SIGN_IN_BUTTON_LOCATORS) + .click(); + return this; + } + + /** + * Perform a complete CIAM login flow. + * This is a convenience method that chains all the necessary steps. + * + * @param username The email address + * @param password The password + */ + public void login(String username, String password) { + enterUsername(username) + .clickNext() + .enterPassword(password) + .clickSignIn(); + + LOG.info("CIAM login completed"); + } +} diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/AadInstanceDiscoveryTest.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/AadInstanceDiscoveryTest.java index f28aec53d..12123d93b 100644 --- a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/AadInstanceDiscoveryTest.java +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/AadInstanceDiscoveryTest.java @@ -138,9 +138,9 @@ void aadInstanceDiscoveryTest_AutoDetectRegion_NoRegionDetected() throws Excepti } void assertValidResponse(InstanceDiscoveryMetadataEntry entry) { - assertEquals(entry.preferredNetwork(), "login.microsoftonline.com"); - assertEquals(entry.preferredCache(), "login.windows.net"); - assertEquals(entry.aliases().size(), 4); + assertEquals("login.microsoftonline.com", entry.preferredNetwork()); + assertEquals("login.windows.net", entry.preferredCache()); + assertEquals(4, entry.aliases().size()); assertTrue(entry.aliases().contains("login.microsoftonline.com")); assertTrue(entry.aliases().contains("login.windows.net")); assertTrue(entry.aliases().contains("login.microsoft.com")); diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/AccountTest.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/AccountTest.java index 108078171..05e5e99e7 100644 --- a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/AccountTest.java +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/AccountTest.java @@ -4,32 +4,19 @@ package com.microsoft.aad.msal4j; import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.TestInstance; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; -import java.io.IOException; -import java.net.URISyntaxException; import java.nio.charset.StandardCharsets; import java.util.Base64; -import java.util.Iterator; import java.util.Map; import java.util.Set; import static com.microsoft.aad.msal4j.Constants.POINT_DELIMITER; -@TestInstance(TestInstance.Lifecycle.PER_CLASS) class AccountTest { - String getEmptyBase64EncodedJson() { - return new String(Base64.getEncoder().encode("{}".getBytes())); - } - - String getJWTHeaderBase64EncodedJson() { - return new String(Base64.getEncoder().encode("{\"alg\": \"HS256\", \"typ\": \"JWT\"}".getBytes())); - } - - private String getTestIdToken(String environment, String tenant) throws IOException, URISyntaxException { + private static String getTestIdToken(String environment, String tenant) { String claims = "{\n" + " \"iss\": \"" + environment + "\",\n" + " \"tid\": \"" + tenant + "\"\n" + @@ -37,15 +24,15 @@ private String getTestIdToken(String environment, String tenant) throws IOExcept String encodedIdToken = new String(Base64.getEncoder().encode(claims.getBytes()), StandardCharsets.UTF_8); - encodedIdToken = getJWTHeaderBase64EncodedJson() + POINT_DELIMITER + + encodedIdToken = TestHelper.getJWTHeaderBase64EncodedJson() + POINT_DELIMITER + encodedIdToken + POINT_DELIMITER + - getEmptyBase64EncodedJson(); + TestHelper.getEmptyBase64EncodedJson(); return encodedIdToken; } @Test - void multiCloudAccount_aggregatedInGetAccountsRemoveAccountApis() throws IOException, URISyntaxException { + void multiCloudAccount_aggregatedInGetAccountsRemoveAccountApis() throws Exception { String BLACK_FORESRT_TENANT = "de_tid"; String WW_TENTANT = "tid"; String BLACK_FOREST_ENV = "login.microsoftonline.de"; @@ -91,22 +78,22 @@ ITokenCacheAccessAspect init(String data) { Set accounts = pca.getAccounts().join(); - assertEquals(accounts.size(), 1); + assertEquals(1, accounts.size()); IAccount account = accounts.iterator().next(); Map tenantProfiles = account.getTenantProfiles(); - assertEquals(tenantProfiles.size(), 2); + assertEquals(2, tenantProfiles.size()); assertTrue(tenantProfiles.containsKey(BLACK_FORESRT_TENANT)); assertTrue(tenantProfiles.containsKey(WW_TENTANT)); pca.removeAccount(account).join(); accounts = pca.getAccounts().join(); - assertEquals(accounts.size(), 0); + assertEquals(0, accounts.size()); - assertEquals(pca.tokenCache.accounts.size(), 0); - assertEquals(pca.tokenCache.idTokens.size(), 0); - assertEquals(pca.tokenCache.refreshTokens.size(), 0); - assertEquals(pca.tokenCache.accessTokens.size(), 0); + assertEquals(0, pca.tokenCache.accounts.size()); + assertEquals(0, pca.tokenCache.idTokens.size()); + assertEquals(0, pca.tokenCache.refreshTokens.size()); + assertEquals(0, pca.tokenCache.accessTokens.size()); } } diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/AcquireTokenSilentlyTest.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/AcquireTokenSilentlyTest.java index f957a079a..332a79fc6 100644 --- a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/AcquireTokenSilentlyTest.java +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/AcquireTokenSilentlyTest.java @@ -4,31 +4,23 @@ package com.microsoft.aad.msal4j; import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.TestInstance; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.*; import static org.mockito.Mockito.*; -import java.io.IOException; -import java.net.URISyntaxException; -import java.nio.file.Files; -import java.nio.file.Paths; import java.util.Collections; import java.util.HashMap; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeUnit; +import org.slf4j.Logger; + -@TestInstance(TestInstance.Lifecycle.PER_CLASS) class AcquireTokenSilentlyTest { Account basicAccount = new Account("home_account_id", "login.windows.net", "username", null); - String cache = readResource("/AAD_cache_data/full_cache.json"); + String cache = TestHelper.readResource(this.getClass(), "/AAD_cache_data/full_cache.json"); @Test void publicAppAcquireTokenSilently_emptyCache_MsalClientException() throws Throwable { @@ -123,13 +115,7 @@ void confidentialAppAcquireTokenSilently_claimsSkipCache() throws Throwable { void testTokenRefreshReasons() throws Exception { DefaultHttpClient httpClientMock = mock(DefaultHttpClient.class); - ConfidentialClientApplication cca = - ConfidentialClientApplication.builder("clientId", ClientCredentialFactory.createFromSecret("password")) - .authority("https://login.microsoftonline.com/tenant/") - .instanceDiscovery(false) - .validateAuthority(false) - .httpClient(httpClientMock) - .build(); + ConfidentialClientApplication cca = TestHelper.buildCca(httpClientMock); HashMap responseParameters = new HashMap<>(); @@ -210,11 +196,95 @@ private void assertRefreshedToken(IAuthenticationResult result, String expectedT assertEquals(expectedReason, result.metadata().cacheRefreshReason()); } - String readResource(String resource) { - try { - return new String(Files.readAllBytes(Paths.get(getClass().getResource(resource).toURI()))); - } catch (IOException | URISyntaxException e) { - throw new RuntimeException(e); - } + // ========== SilentRequestHelper ========== + + @Test + void getCacheRefreshReason_claimsPresent_returnsClaims() { + SilentParameters params = SilentParameters.builder( + Collections.singleton("scope"), + mock(IAccount.class)) + .claims(new ClaimsRequest()) + .build(); + + AuthenticationResult cachedResult = mock(AuthenticationResult.class); + when(cachedResult.accessToken()).thenReturn("valid-token"); + when(cachedResult.expiresOn()).thenReturn(System.currentTimeMillis() / 1000 + 3600); + + Logger log = mock(Logger.class); + + assertEquals(CacheRefreshReason.CLAIMS, + SilentRequestHelper.getCacheRefreshReasonIfApplicable(params, cachedResult, log)); + } + + @Test + void getCacheRefreshReason_expiredToken_returnsExpired() { + SilentParameters params = SilentParameters.builder( + Collections.singleton("scope"), + mock(IAccount.class)) + .build(); + + AuthenticationResult cachedResult = mock(AuthenticationResult.class); + when(cachedResult.accessToken()).thenReturn("expired-token"); + when(cachedResult.expiresOn()).thenReturn(System.currentTimeMillis() / 1000 - 600); + + Logger log = mock(Logger.class); + + assertEquals(CacheRefreshReason.EXPIRED, + SilentRequestHelper.getCacheRefreshReasonIfApplicable(params, cachedResult, log)); + } + + @Test + void getCacheRefreshReason_proactiveRefresh_returnsProactiveRefresh() { + SilentParameters params = SilentParameters.builder( + Collections.singleton("scope"), + mock(IAccount.class)) + .build(); + + long now = System.currentTimeMillis() / 1000; + AuthenticationResult cachedResult = mock(AuthenticationResult.class); + when(cachedResult.accessToken()).thenReturn("valid-token"); + when(cachedResult.expiresOn()).thenReturn(now + 3600); + when(cachedResult.refreshOn()).thenReturn(now - 600); + + Logger log = mock(Logger.class); + + assertEquals(CacheRefreshReason.PROACTIVE_REFRESH, + SilentRequestHelper.getCacheRefreshReasonIfApplicable(params, cachedResult, log)); + } + + @Test + void getCacheRefreshReason_noAccessTokenWithRefreshToken_returnsNoCachedAccessToken() { + SilentParameters params = SilentParameters.builder( + Collections.singleton("scope"), + mock(IAccount.class)) + .build(); + + AuthenticationResult cachedResult = mock(AuthenticationResult.class); + when(cachedResult.accessToken()).thenReturn(null); + when(cachedResult.refreshToken()).thenReturn("refresh-token-value"); + + Logger log = mock(Logger.class); + + assertEquals(CacheRefreshReason.NO_CACHED_ACCESS_TOKEN, + SilentRequestHelper.getCacheRefreshReasonIfApplicable(params, cachedResult, log)); + } + + @Test + void getCacheRefreshReason_validToken_returnsNotApplicable() { + SilentParameters params = SilentParameters.builder( + Collections.singleton("scope"), + mock(IAccount.class)) + .build(); + + long now = System.currentTimeMillis() / 1000; + AuthenticationResult cachedResult = mock(AuthenticationResult.class); + when(cachedResult.accessToken()).thenReturn("valid-token"); + when(cachedResult.expiresOn()).thenReturn(now + 3600); + when(cachedResult.refreshOn()).thenReturn(null); + + Logger log = mock(Logger.class); + + assertEquals(CacheRefreshReason.NOT_APPLICABLE, + SilentRequestHelper.getCacheRefreshReasonIfApplicable(params, cachedResult, log)); } } diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/AppTokenProviderTest.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/AppTokenProviderTest.java new file mode 100644 index 000000000..665ecb02f --- /dev/null +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/AppTokenProviderTest.java @@ -0,0 +1,245 @@ +// 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.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import java.util.Collections; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.function.Consumer; +import java.util.function.Function; +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +/** + * Tests for token acquisition via the app token provider path + * (AcquireTokenByAppProviderSupplier and the appTokenProvider branch + * of AcquireTokenByClientCredentialSupplier). + */ +class AppTokenProviderTest { + + private static final String VALID_ACCESS_TOKEN = "app-provider-access-token"; + private static final String TENANT_ID = "test-tenant"; + private static final long ONE_HOUR_SECONDS = 3600; + private static final long TWO_HOURS_SECONDS = 7200; + + // ======================================================================== + // Helpers + // ======================================================================== + + private static TokenProviderResult validTokenProviderResult() { + TokenProviderResult result = new TokenProviderResult(); + result.setAccessToken(VALID_ACCESS_TOKEN); + result.setExpiresInSeconds(ONE_HOUR_SECONDS); + result.setTenantId(TENANT_ID); + return result; + } + + private static Function> providerReturning( + TokenProviderResult result) { + return params -> CompletableFuture.completedFuture(result); + } + + private static TokenProviderResult invalidResult(Consumer mutator) { + TokenProviderResult result = validTokenProviderResult(); + mutator.accept(result); + return result; + } + + // ======================================================================== + // Valid provider result + // ======================================================================== + + @Test + void appTokenProvider_ValidResult_ReturnsToken() throws Exception { + ConfidentialClientApplication cca = TestHelper.buildCcaWithAppTokenProvider( + providerReturning(validTokenProviderResult())); + + IAuthenticationResult result = cca.acquireToken( + ClientCredentialParameters.builder(TestHelper.TEST_SCOPE_SET).build()).get(); + + assertEquals(VALID_ACCESS_TOKEN, result.accessToken()); + } + + @Test + void appTokenProvider_ReceivesCorrectParameters() throws Exception { + @SuppressWarnings("unchecked") + Function> provider = + mock(Function.class); + + when(provider.apply(any())).thenReturn( + CompletableFuture.completedFuture(validTokenProviderResult())); + + ConfidentialClientApplication cca = TestHelper.buildCcaWithAppTokenProvider(provider); + + ClientCredentialParameters parameters = ClientCredentialParameters + .builder(TestHelper.TEST_SCOPE_SET) + .tenant("override-tenant") + .build(); + + cca.acquireToken(parameters).get(); + + verify(provider).apply(argThat(params -> { + assertTrue(params.getScopes().contains(TestHelper.TEST_SCOPE)); + assertEquals("override-tenant", params.getTenantId()); + assertNotNull(params.getCorrelationId()); + return true; + })); + } + + @Test + void appTokenProvider_DefaultSkipCache_CallsProviderEachTime() throws Exception { + @SuppressWarnings("unchecked") + Function> provider = + mock(Function.class); + + when(provider.apply(any())).thenReturn( + CompletableFuture.completedFuture(validTokenProviderResult())); + + ConfidentialClientApplication cca = TestHelper.buildCcaWithAppTokenProvider(provider); + + ClientCredentialParameters parameters = ClientCredentialParameters + .builder(TestHelper.TEST_SCOPE_SET).build(); + + // Default skipCache (null) bypasses cache lookup, so provider is called each time + cca.acquireToken(parameters).get(); + cca.acquireToken(parameters).get(); + + verify(provider, times(2)).apply(any()); + } + + @Test + void appTokenProvider_SkipCache_BypassesCacheLookup() throws Exception { + @SuppressWarnings("unchecked") + Function> provider = + mock(Function.class); + + when(provider.apply(any())).thenReturn( + CompletableFuture.completedFuture(validTokenProviderResult())); + + ConfidentialClientApplication cca = TestHelper.buildCcaWithAppTokenProvider(provider); + + ClientCredentialParameters parameters = ClientCredentialParameters + .builder(TestHelper.TEST_SCOPE_SET) + .skipCache(true) + .build(); + + cca.acquireToken(parameters).get(); + cca.acquireToken(parameters).get(); + + // With skipCache=true, provider should be called each time + verify(provider, times(2)).apply(any()); + } + + // ======================================================================== + // Validation: invalid TokenProviderResult fields + // ======================================================================== + + @ParameterizedTest(name = "appTokenProvider_InvalidResult_{0}_Throws") + @MethodSource("invalidTokenProviderResults") + void appTokenProvider_InvalidResult_Throws(String scenario, TokenProviderResult result) throws Exception { + ConfidentialClientApplication cca = TestHelper.buildCcaWithAppTokenProvider(providerReturning(result)); + + ClientCredentialParameters parameters = ClientCredentialParameters + .builder(TestHelper.TEST_SCOPE_SET).build(); + + ExecutionException ex = assertThrows(ExecutionException.class, + () -> cca.acquireToken(parameters).get()); + assertInstanceOf(MsalClientException.class, ex.getCause()); + } + + private static Stream invalidTokenProviderResults() { + return Stream.of( + Arguments.of("NullAccessToken", invalidResult(r -> r.setAccessToken(null))), + Arguments.of("EmptyAccessToken", invalidResult(r -> r.setAccessToken(""))), + Arguments.of("ZeroExpiry", invalidResult(r -> r.setExpiresInSeconds(0))), + Arguments.of("NegativeExpiry", invalidResult(r -> r.setExpiresInSeconds(-1))), + Arguments.of("NullTenantId", invalidResult(r -> r.setTenantId(null))), + Arguments.of("EmptyTenantId", invalidResult(r -> r.setTenantId(""))) + ); + } + + // ======================================================================== + // refreshInSeconds auto-calculation + // ======================================================================== + + @Test + void appTokenProvider_ExpiryAtLeastTwoHours_AutoCalculatesRefreshIn() throws Exception { + TokenProviderResult providerResult = validTokenProviderResult(); + providerResult.setExpiresInSeconds(TWO_HOURS_SECONDS); + providerResult.setRefreshInSeconds(0); + + ConfidentialClientApplication cca = TestHelper.buildCcaWithAppTokenProvider( + providerReturning(providerResult)); + + IAuthenticationResult result = cca.acquireToken( + ClientCredentialParameters.builder(TestHelper.TEST_SCOPE_SET).build()).get(); + + assertEquals(VALID_ACCESS_TOKEN, result.accessToken()); + assertTrue(result.metadata().refreshOn() > 0, + "refreshOn should be auto-calculated when expiresIn >= 2 hours"); + } + + @Test + void appTokenProvider_ExpiryLessThanTwoHours_NoAutoRefreshIn() throws Exception { + TokenProviderResult providerResult = validTokenProviderResult(); + providerResult.setExpiresInSeconds(TWO_HOURS_SECONDS - 1); + providerResult.setRefreshInSeconds(0); + + ConfidentialClientApplication cca = TestHelper.buildCcaWithAppTokenProvider( + providerReturning(providerResult)); + + IAuthenticationResult result = cca.acquireToken( + ClientCredentialParameters.builder(TestHelper.TEST_SCOPE_SET).build()).get(); + + assertEquals(VALID_ACCESS_TOKEN, result.accessToken()); + assertEquals(0, result.metadata().refreshOn(), + "refreshOn should not be auto-calculated when expiresIn < 2 hours"); + } + + @Test + void appTokenProvider_ExplicitRefreshIn_NotOverridden() throws Exception { + TokenProviderResult providerResult = validTokenProviderResult(); + providerResult.setExpiresInSeconds(TWO_HOURS_SECONDS); + providerResult.setRefreshInSeconds(1800); // explicit 30-minute refresh + + ConfidentialClientApplication cca = TestHelper.buildCcaWithAppTokenProvider( + providerReturning(providerResult)); + + IAuthenticationResult result = cca.acquireToken( + ClientCredentialParameters.builder(TestHelper.TEST_SCOPE_SET).build()).get(); + + assertEquals(VALID_ACCESS_TOKEN, result.accessToken()); + // Auto-calculation only triggers when refreshInSeconds == 0 + assertTrue(result.metadata().refreshOn() > 0); + } + + // ======================================================================== + // Provider exception wrapping + // ======================================================================== + + @Test + void appTokenProvider_ProviderThrows_WrappedInMsalAzureSDKException() throws Exception { + Function> throwingProvider = + params -> { + CompletableFuture future = new CompletableFuture<>(); + future.completeExceptionally(new RuntimeException("Provider failed")); + return future; + }; + + ConfidentialClientApplication cca = TestHelper.buildCcaWithAppTokenProvider(throwingProvider); + + ExecutionException ex = assertThrows(ExecutionException.class, + () -> cca.acquireToken( + ClientCredentialParameters.builder(TestHelper.TEST_SCOPE_SET).build()).get()); + assertInstanceOf(MsalAzureSDKException.class, ex.getCause()); + } +} diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/ApplicationBuilderTest.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/ApplicationBuilderTest.java new file mode 100644 index 000000000..ff8d71f2c --- /dev/null +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/ApplicationBuilderTest.java @@ -0,0 +1,541 @@ +// 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.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import javax.net.ssl.SSLSocketFactory; +import java.net.InetSocketAddress; +import java.net.Proxy; +import java.net.URI; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class ApplicationBuilderTest { + + private static final String CLIENT_ID = TestHelper.TEST_CLIENT_ID; + private static final String DEFAULT_AUTHORITY = "https://login.microsoftonline.com/common/"; + + // ========== PublicClientApplication Tests ========== + + @Test + void publicClientApplication_MinimalBuild_HasExpectedDefaults() { + PublicClientApplication pca = PublicClientApplication.builder(CLIENT_ID).build(); + + assertEquals(CLIENT_ID, pca.clientId()); + assertEquals(DEFAULT_AUTHORITY, pca.authority()); + assertTrue(pca.validateAuthority()); + assertTrue(pca.instanceDiscovery()); + assertFalse(pca.autoDetectRegion()); + assertNull(pca.azureRegion()); + assertNull(pca.applicationName()); + assertNull(pca.applicationVersion()); + assertNull(pca.clientCapabilities()); + assertNull(pca.aadAadInstanceDiscoveryResponse()); + assertNotNull(pca.tokenCache()); + } + + @Test + void publicClientApplication_BlankClientId_Throws() { + assertThrows(IllegalArgumentException.class, + () -> PublicClientApplication.builder("").build()); + assertThrows(IllegalArgumentException.class, + () -> PublicClientApplication.builder(" ").build()); + } + + @Test + void publicClientApplication_NullClientId_Throws() { + assertThrows(IllegalArgumentException.class, + () -> PublicClientApplication.builder(null).build()); + } + + @Test + void publicClientApplication_AadAuthority_SetsCorrectType() throws Exception { + PublicClientApplication pca = PublicClientApplication.builder(CLIENT_ID) + .authority("https://login.microsoftonline.com/my-tenant/") + .build(); + + assertEquals("https://login.microsoftonline.com/my-tenant/", pca.authority()); + } + + @Test + void publicClientApplication_AdfsAuthority_SetsCorrectType() throws Exception { + PublicClientApplication pca = PublicClientApplication.builder(CLIENT_ID) + .authority("https://adfs.contoso.com/adfs/") + .build(); + + assertEquals("https://adfs.contoso.com/adfs/", pca.authority()); + // ADFS authority type is set, but validateAuthority is not automatically disabled + // (unlike B2C which explicitly sets validateAuthority=false) + } + + @Test + void publicClientApplication_CiamAuthority_SetsCorrectType() throws Exception { + PublicClientApplication pca = PublicClientApplication.builder(CLIENT_ID) + .authority("https://contoso.ciamlogin.com/contoso.onmicrosoft.com/") + .build(); + + assertEquals("https://contoso.ciamlogin.com/contoso.onmicrosoft.com/", pca.authority()); + } + + @Test + void publicClientApplication_B2cAuthority_SetsCorrectType() throws Exception { + PublicClientApplication pca = PublicClientApplication.builder(CLIENT_ID) + .b2cAuthority("https://contoso.b2clogin.com/contoso.onmicrosoft.com/B2C_1_signIn/") + .build(); + + // B2C lowercases the authority path + assertEquals("https://contoso.b2clogin.com/contoso.onmicrosoft.com/b2c_1_signin/", pca.authority()); + assertFalse(pca.validateAuthority(), "B2C sets validateAuthority to false"); + } + + @Test + void publicClientApplication_B2cAuthorityViaRegularAuthority_Throws() { + // Using the regular authority() method with a B2C URL should throw since it's not + // AAD/ADFS/CIAM — it requires b2cAuthority() instead + assertThrows(IllegalArgumentException.class, () -> + PublicClientApplication.builder(CLIENT_ID) + .authority("https://contoso.b2clogin.com/contoso.onmicrosoft.com/B2C_1_signIn/") + .build()); + } + + @Test + void publicClientApplication_DeviceCodeWithB2C_Throws() throws Exception { + PublicClientApplication pca = PublicClientApplication.builder(CLIENT_ID) + .b2cAuthority("https://contoso.b2clogin.com/contoso.onmicrosoft.com/B2C_1_signIn/") + .build(); + + DeviceCodeFlowParameters params = DeviceCodeFlowParameters.builder( + Collections.singleton("scope"), + deviceCode -> {}).build(); + + assertThrows(IllegalArgumentException.class, () -> pca.acquireToken(params)); + } + + @Test + void publicClientApplication_PopWithoutBroker_Interactive_Throws() throws Exception { + PublicClientApplication pca = PublicClientApplication.builder(CLIENT_ID).build(); + + InteractiveRequestParameters params = InteractiveRequestParameters.builder(new URI("http://localhost")) + .scopes(Collections.singleton("scope")) + .proofOfPossession(HttpMethod.GET, new URI("https://resource.com"), "nonce") + .build(); + + MsalClientException ex = assertThrows(MsalClientException.class, () -> pca.acquireToken(params)); + assertTrue(ex.getMessage().contains("proofOfPossession")); + } + + @Test + void publicClientApplication_PopWithoutBroker_UsernamePassword_Throws() throws Exception { + PublicClientApplication pca = PublicClientApplication.builder(CLIENT_ID).build(); + + UserNamePasswordParameters params = UserNamePasswordParameters.builder( + Collections.singleton("scope"), "user@example.com", "password".toCharArray()) + .proofOfPossession(HttpMethod.GET, new URI("https://resource.com"), "nonce") + .build(); + + MsalClientException ex = assertThrows(MsalClientException.class, () -> pca.acquireToken(params)); + assertTrue(ex.getMessage().contains("proofOfPossession")); + } + + @Test + void publicClientApplication_PopWithoutBroker_Silent_Throws() throws Exception { + PublicClientApplication pca = PublicClientApplication.builder(CLIENT_ID).build(); + + SilentParameters params = SilentParameters.builder(Collections.singleton("scope")) + .proofOfPossession(HttpMethod.GET, new URI("https://resource.com"), "nonce") + .build(); + + MsalClientException ex = assertThrows(MsalClientException.class, + () -> pca.acquireTokenSilently(params)); + assertTrue(ex.getMessage().contains("proofOfPossession")); + } + + @Test + void publicClientApplication_BrokerEnabled_BuildsSuccessfully() { + IBroker mockBroker = mock(IBroker.class); + when(mockBroker.isBrokerAvailable()).thenReturn(true); + + // Exercises Builder.broker() which sets brokerEnabled = broker.isBrokerAvailable() + PublicClientApplication pca = PublicClientApplication.builder(CLIENT_ID) + .broker(mockBroker) + .build(); + + // brokerEnabled is private, so we verify the builder path was exercised + // by confirming construction succeeded and the broker's availability was checked + assertNotNull(pca); + } + + // ========== ConfidentialClientApplication Tests ========== + + @Test + void confidentialClientApplication_MinimalBuild_HasExpectedDefaults() { + IClientCredential credential = ClientCredentialFactory.createFromSecret("test-secret"); + + ConfidentialClientApplication cca = ConfidentialClientApplication.builder(CLIENT_ID, credential) + .build(); + + assertEquals(CLIENT_ID, cca.clientId()); + assertEquals(DEFAULT_AUTHORITY, cca.authority()); + assertTrue(cca.sendX5c(), "sendX5c defaults to true"); + assertNotNull(cca.tokenCache()); + } + + @Test + void confidentialClientApplication_NullCredential_Throws() { + assertThrows(IllegalArgumentException.class, + () -> ConfidentialClientApplication.builder(CLIENT_ID, null)); + } + + @Test + void confidentialClientApplication_SendX5c_SetToFalse() { + IClientCredential credential = ClientCredentialFactory.createFromSecret("test-secret"); + + ConfidentialClientApplication cca = ConfidentialClientApplication.builder(CLIENT_ID, credential) + .sendX5c(false) + .build(); + + assertFalse(cca.sendX5c()); + } + + @Test + void confidentialClientApplication_AppTokenProvider_Valid() { + IClientCredential credential = ClientCredentialFactory.createFromSecret("test-secret"); + + ConfidentialClientApplication cca = ConfidentialClientApplication.builder(CLIENT_ID, credential) + .appTokenProvider(params -> { + TokenProviderResult result = new TokenProviderResult(); + result.setAccessToken("token"); + result.setExpiresInSeconds(3600); + return CompletableFuture.completedFuture(result); + }) + .build(); + + assertNotNull(cca.appTokenProvider); + } + + @Test + void confidentialClientApplication_AppTokenProvider_Null_Throws() { + IClientCredential credential = ClientCredentialFactory.createFromSecret("test-secret"); + + assertThrows(NullPointerException.class, () -> + ConfidentialClientApplication.builder(CLIENT_ID, credential) + .appTokenProvider(null)); + } + + // ========== ManagedIdentityApplication Tests ========== + + @Test + void managedIdentityApplication_SystemAssigned_Build() { + ManagedIdentityApplication mia = ManagedIdentityApplication.builder( + ManagedIdentityId.systemAssigned()).build(); + + assertNotNull(mia.getManagedIdentityId()); + assertEquals(ManagedIdentityIdType.SYSTEM_ASSIGNED, mia.getManagedIdentityId().getIdType()); + } + + @Test + void managedIdentityApplication_GetSharedTokenCache_ReturnsCache() { + assertNotNull(ManagedIdentityApplication.getSharedTokenCache()); + } + + @Test + void managedIdentityApplication_GetEnvironmentVariables_ReturnsValue() { + // getEnvironmentVariables() returns whatever was last set via setEnvironmentVariables() + IEnvironmentVariables original = ManagedIdentityApplication.getEnvironmentVariables(); + try { + IEnvironmentVariables mockEnv = mock(IEnvironmentVariables.class); + ManagedIdentityApplication.setEnvironmentVariables(mockEnv); + assertSame(mockEnv, ManagedIdentityApplication.getEnvironmentVariables()); + } finally { + ManagedIdentityApplication.setEnvironmentVariables(original); + } + } + + @Test + void managedIdentityApplication_DeprecatedResource_DoesNotThrow() { + // deprecated resource() is a no-op, should not throw + ManagedIdentityApplication mia = ManagedIdentityApplication.builder( + ManagedIdentityId.systemAssigned()) + .resource("https://resource") + .build(); + + assertNotNull(mia); + } + + @Test + void managedIdentityApplication_ClientCapabilities_Set() { + ManagedIdentityApplication mia = ManagedIdentityApplication.builder( + ManagedIdentityId.systemAssigned()) + .clientCapabilities(Arrays.asList("cp1", "cp2")) + .build(); + + assertEquals(Arrays.asList("cp1", "cp2"), mia.getClientCapabilities()); + } + + // ========== Builder Option Propagation Tests ========== + + @Test + void builder_LogPii_PropagatedToApp() { + PublicClientApplication pca = PublicClientApplication.builder(CLIENT_ID) + .logPii(true) + .build(); + + assertTrue(pca.logPii()); + } + + @Test + void builder_CorrelationId_PropagatedToApp() { + PublicClientApplication pca = PublicClientApplication.builder(CLIENT_ID) + .correlationId("test-correlation-id") + .build(); + + assertEquals("test-correlation-id", pca.correlationId()); + } + + @Test + void builder_Proxy_PropagatedToApp() { + Proxy proxy = new Proxy(Proxy.Type.HTTP, new InetSocketAddress("proxy.example.com", 8080)); + + PublicClientApplication pca = PublicClientApplication.builder(CLIENT_ID) + .proxy(proxy) + .build(); + + assertEquals(proxy, pca.proxy()); + } + + @Test + void builder_SslSocketFactory_PropagatedToApp() { + SSLSocketFactory sslFactory = mock(SSLSocketFactory.class); + + PublicClientApplication pca = PublicClientApplication.builder(CLIENT_ID) + .sslSocketFactory(sslFactory) + .build(); + + assertSame(sslFactory, pca.sslSocketFactory()); + } + + @Test + void builder_ExecutorService_PropagatedToApp() { + ExecutorService executor = Executors.newSingleThreadExecutor(); + try { + PublicClientApplication pca = PublicClientApplication.builder(CLIENT_ID) + .executorService(executor) + .build(); + + assertNotNull(pca.serviceBundle().getExecutorService()); + } finally { + executor.shutdown(); + } + } + + @Test + void builder_Timeouts_PropagatedToApp() { + PublicClientApplication pca = PublicClientApplication.builder(CLIENT_ID) + .connectTimeoutForDefaultHttpClient(5000) + .readTimeoutForDefaultHttpClient(10000) + .build(); + + assertEquals(5000, pca.connectTimeoutForDefaultHttpClient()); + assertEquals(10000, pca.readTimeoutForDefaultHttpClient()); + } + + @Test + void builder_ApplicationNameAndVersion_PropagatedToApp() { + PublicClientApplication pca = PublicClientApplication.builder(CLIENT_ID) + .applicationName("TestApp") + .applicationVersion("1.0.0") + .build(); + + assertEquals("TestApp", pca.applicationName()); + assertEquals("1.0.0", pca.applicationVersion()); + } + + @Test + void builder_ValidateAuthority_PropagatedToApp() { + PublicClientApplication pca = PublicClientApplication.builder(CLIENT_ID) + .validateAuthority(false) + .build(); + + assertFalse(pca.validateAuthority()); + } + + @Test + void builder_AutoDetectRegion_PropagatedToApp() { + PublicClientApplication pca = PublicClientApplication.builder(CLIENT_ID) + .autoDetectRegion(true) + .build(); + + assertTrue(pca.autoDetectRegion()); + } + + @Test + void builder_AzureRegion_PropagatedToApp() { + PublicClientApplication pca = PublicClientApplication.builder(CLIENT_ID) + .azureRegion("westus") + .build(); + + assertEquals("westus", pca.azureRegion()); + } + + // ======================================================================== + // InteractiveRequest redirect URI validation + // Only rejection cases are testable as unit tests — valid URIs proceed + // to open a real browser, making them integration test territory. + // ======================================================================== + + @Test + void interactiveRequest_HttpsScheme_ThrowsMsalClientException() throws Exception { + PublicClientApplication pca = PublicClientApplication.builder(CLIENT_ID) + .instanceDiscovery(false) + .build(); + + InteractiveRequestParameters params = InteractiveRequestParameters + .builder(new URI("https://localhost")) + .scopes(Collections.singleton("scope")) + .build(); + + MsalClientException ex = assertThrows(MsalClientException.class, () -> pca.acquireToken(params)); + assertEquals(AuthenticationErrorCode.LOOPBACK_REDIRECT_URI, ex.errorCode()); + } + + @Test + void interactiveRequest_CustomScheme_ThrowsMsalClientException() throws Exception { + PublicClientApplication pca = PublicClientApplication.builder(CLIENT_ID) + .instanceDiscovery(false) + .build(); + + InteractiveRequestParameters params = InteractiveRequestParameters + .builder(new URI("myapp://callback")) + .scopes(Collections.singleton("scope")) + .build(); + + MsalClientException ex = assertThrows(MsalClientException.class, () -> pca.acquireToken(params)); + assertEquals(AuthenticationErrorCode.LOOPBACK_REDIRECT_URI, ex.errorCode()); + } + + @Test + void interactiveRequest_NonLoopbackHost_ThrowsMsalClientException() throws Exception { + PublicClientApplication pca = PublicClientApplication.builder(CLIENT_ID) + .instanceDiscovery(false) + .build(); + + InteractiveRequestParameters params = InteractiveRequestParameters + .builder(new URI("http://example.com")) + .scopes(Collections.singleton("scope")) + .build(); + + MsalClientException ex = assertThrows(MsalClientException.class, () -> pca.acquireToken(params)); + assertEquals(AuthenticationErrorCode.LOOPBACK_REDIRECT_URI, ex.errorCode()); + } + + @Test + void builder_InstanceDiscovery_PropagatedToApp() { + PublicClientApplication pca = PublicClientApplication.builder(CLIENT_ID) + .instanceDiscovery(false) + .build(); + + assertFalse(pca.instanceDiscovery()); + } + + @Test + void builder_ClientCapabilities_PropagatedToApp() { + PublicClientApplication pca = PublicClientApplication.builder(CLIENT_ID) + .clientCapabilities(new HashSet<>(Arrays.asList("cp1", "cp2"))) + .build(); + + assertNotNull(pca.clientCapabilities()); + assertTrue(pca.clientCapabilities().contains("cp1")); + } + + @Test + void builder_AadInstanceDiscoveryResponse_PropagatedToApp() { + String discoveryResponse = TestHelper.getInstanceDiscoveryResponse(); + + PublicClientApplication pca = PublicClientApplication.builder(CLIENT_ID) + .aadInstanceDiscoveryResponse(discoveryResponse) + .build(); + + assertNotNull(pca.aadAadInstanceDiscoveryResponse()); + } + + @Test + void builder_HttpClient_PropagatedToApp() { + IHttpClient httpClient = mock(IHttpClient.class); + + PublicClientApplication pca = PublicClientApplication.builder(CLIENT_ID) + .httpClient(httpClient) + .build(); + + assertSame(httpClient, pca.httpClient()); + } + + @Test + void builder_DisableInternalRetries_PropagatedToApp() { + PublicClientApplication pca = PublicClientApplication.builder(CLIENT_ID) + .disableInternalRetries() + .build(); + + assertTrue(pca.isRetryDisabled()); + } + + @Test + void builder_TokenCacheAccessAspect_PropagatedToApp() { + ITokenCacheAccessAspect aspect = mock(ITokenCacheAccessAspect.class); + + PublicClientApplication pca = PublicClientApplication.builder(CLIENT_ID) + .setTokenCacheAccessAspect(aspect) + .build(); + + assertNotNull(pca.tokenCache()); + } + + // ========== Builder Null Validation Tests ========== + + @ParameterizedTest(name = "builder.{0}(null) throws") + @MethodSource("nullValidationProvider") + void builder_NullArgument_Throws(String methodName, Runnable action) { + assertThrows(Exception.class, action::run, + "Builder." + methodName + "(null) should throw"); + } + + private static Stream nullValidationProvider() { + return Stream.of( + Arguments.of("executorService", (Runnable) () -> + PublicClientApplication.builder(CLIENT_ID).executorService(null)), + Arguments.of("proxy", (Runnable) () -> + PublicClientApplication.builder(CLIENT_ID).proxy(null)), + Arguments.of("sslSocketFactory", (Runnable) () -> + PublicClientApplication.builder(CLIENT_ID).sslSocketFactory(null)), + Arguments.of("httpClient", (Runnable) () -> + PublicClientApplication.builder(CLIENT_ID).httpClient(null)), + Arguments.of("connectTimeoutForDefaultHttpClient", (Runnable) () -> + PublicClientApplication.builder(CLIENT_ID).connectTimeoutForDefaultHttpClient(null)), + Arguments.of("readTimeoutForDefaultHttpClient", (Runnable) () -> + PublicClientApplication.builder(CLIENT_ID).readTimeoutForDefaultHttpClient(null)), + Arguments.of("applicationName", (Runnable) () -> + PublicClientApplication.builder(CLIENT_ID).applicationName(null)), + Arguments.of("applicationVersion", (Runnable) () -> + PublicClientApplication.builder(CLIENT_ID).applicationVersion(null)), + Arguments.of("setTokenCacheAccessAspect", (Runnable) () -> + PublicClientApplication.builder(CLIENT_ID).setTokenCacheAccessAspect(null)), + Arguments.of("aadInstanceDiscoveryResponse", (Runnable) () -> + PublicClientApplication.builder(CLIENT_ID).aadInstanceDiscoveryResponse(null)), + Arguments.of("correlationId (blank)", (Runnable) () -> + PublicClientApplication.builder(CLIENT_ID).correlationId("")) + ); + } +} diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/AuthenticationResultTest.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/AuthenticationResultTest.java new file mode 100644 index 000000000..22ab16806 --- /dev/null +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/AuthenticationResultTest.java @@ -0,0 +1,468 @@ +// 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.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import java.util.Date; +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.*; + +class AuthenticationResultTest { + + // Shared test constants + private static final String ACCESS_TOKEN = "test-access-token"; + private static final long EXPIRES_ON = 1735689600L; + private static final long EXT_EXPIRES_ON = 1735693200L; + private static final String REFRESH_TOKEN = "test-refresh-token"; + private static final Long REFRESH_ON = 1735686000L; + private static final String FAMILY_ID = "1"; + private static final String ENVIRONMENT = "login.microsoftonline.com"; + private static final String SCOPES = "user.read openid"; + + // Metadata is shared across equals/hashCode tests because AuthenticationResultMetadata + // does not implement equals(), so two separately-constructed instances will fail + // reference equality even if all fields match. + private static final AuthenticationResultMetadata SHARED_METADATA = + AuthenticationResultMetadata.builder() + .tokenSource(TokenSource.IDENTITY_PROVIDER) + .refreshOn(REFRESH_ON) + .cacheRefreshReason(CacheRefreshReason.NOT_APPLICABLE) + .build(); + + private static AccountCacheEntity buildAccountCacheEntity() { + AccountCacheEntity entity = new AccountCacheEntity(); + entity.homeAccountId = "uid.utid"; + entity.environment = ENVIRONMENT; + entity.realm = "test-tenant"; + entity.localAccountId = "local-oid"; + entity.username = "user@example.com"; + entity.authorityType = AccountCacheEntity.MSSTS_ACCOUNT_TYPE; + return entity; + } + + /** + * Builds a result with no idToken (and thus no idTokenObject or tenantProfile), which + * is needed for equals/hashCode tests because IdToken and TenantProfile do not implement + * equals(). This ensures equals() comparisons are based on value equality of all fields. + */ + private static AuthenticationResult buildBaselineResult() { + return AuthenticationResult.builder() + .accessToken(ACCESS_TOKEN) + .expiresOn(EXPIRES_ON) + .extExpiresOn(EXT_EXPIRES_ON) + .refreshToken(REFRESH_TOKEN) + .refreshOn(REFRESH_ON) + .familyId(FAMILY_ID) + .accountCacheEntity(buildAccountCacheEntity()) + .environment(ENVIRONMENT) + .scopes(SCOPES) + .metadata(SHARED_METADATA) + .isPopAuthorization(false) + .build(); + } + + // ========== Builder and Getter Tests ========== + + @Test + void build_AllFieldsSet_GettersReturnExpectedValues() { + AccountCacheEntity accountEntity = buildAccountCacheEntity(); + AuthenticationResultMetadata metadata = AuthenticationResultMetadata.builder() + .tokenSource(TokenSource.CACHE) + .refreshOn(REFRESH_ON) + .cacheRefreshReason(CacheRefreshReason.PROACTIVE_REFRESH) + .build(); + + AuthenticationResult result = AuthenticationResult.builder() + .accessToken(ACCESS_TOKEN) + .expiresOn(EXPIRES_ON) + .extExpiresOn(EXT_EXPIRES_ON) + .refreshToken(REFRESH_TOKEN) + .refreshOn(REFRESH_ON) + .familyId(FAMILY_ID) + .idToken(TestHelper.ENCODED_JWT) + .accountCacheEntity(accountEntity) + .environment(ENVIRONMENT) + .scopes(SCOPES) + .metadata(metadata) + .isPopAuthorization(true) + .build(); + + assertEquals(ACCESS_TOKEN, result.accessToken()); + assertEquals(EXPIRES_ON, result.expiresOn()); + assertEquals(EXT_EXPIRES_ON, result.extExpiresOn()); + assertEquals(REFRESH_TOKEN, result.refreshToken()); + assertEquals(REFRESH_ON, result.refreshOn()); + assertEquals(FAMILY_ID, result.familyId()); + assertEquals(TestHelper.ENCODED_JWT, result.idToken()); + assertEquals(accountEntity, result.accountCacheEntity()); + assertEquals(ENVIRONMENT, result.environment()); + assertEquals(SCOPES, result.scopes()); + assertSame(metadata, result.metadata()); + assertTrue(result.isPopAuthorization()); + } + + @Test + void build_MinimalFields_NullableFieldsReturnNull() { + AuthenticationResult result = AuthenticationResult.builder() + .expiresOn(EXPIRES_ON) + .build(); + + assertNull(result.accessToken()); + assertEquals(EXPIRES_ON, result.expiresOn()); + assertEquals(0, result.extExpiresOn()); + assertNull(result.refreshToken()); + assertNull(result.refreshOn()); + assertNull(result.familyId()); + assertNull(result.idToken()); + assertNull(result.accountCacheEntity()); + assertNull(result.environment()); + assertNull(result.scopes()); + assertNull(result.isPopAuthorization()); + assertNull(result.account()); + } + + @Test + void build_NullMetadata_DefaultsToEmptyMetadata() { + AuthenticationResult result = AuthenticationResult.builder() + .metadata(null) + .build(); + + assertNotNull(result.metadata()); + assertNull(result.metadata().tokenSource()); + assertEquals(CacheRefreshReason.NOT_APPLICABLE, result.metadata().cacheRefreshReason()); + } + + // ========== Derived Field Tests ========== + + @Test + void build_WithIdToken_IdTokenObjectIsNull_DueToInitOrder() { + // Documents a known issue: field initializers run before the constructor body, + // so idTokenObject = getIdTokenObj() executes when this.idToken is still null. + // This means idTokenObject() always returns null regardless of the idToken value. + AuthenticationResult result = AuthenticationResult.builder() + .idToken(TestHelper.ENCODED_JWT) + .build(); + + assertNull(result.idTokenObject(), + "idTokenObject is always null due to field initialization order"); + // The idToken string itself is stored correctly + assertEquals(TestHelper.ENCODED_JWT, result.idToken()); + } + + @Test + void build_WithBlankIdToken_IdTokenObjectIsNull() { + AuthenticationResult result = AuthenticationResult.builder() + .idToken("") + .build(); + + assertNull(result.idTokenObject()); + } + + @Test + void build_WithAccountCacheEntity_PublicGetterRecomputesAccount() { + // The public account() getter calls getAccount() each time, so it works correctly + // despite the field initialization order bug (the private 'account' field is always null). + AccountCacheEntity entity = buildAccountCacheEntity(); + + AuthenticationResult result = AuthenticationResult.builder() + .accountCacheEntity(entity) + .build(); + + IAccount account = result.account(); + assertNotNull(account); + assertEquals("uid.utid", account.homeAccountId()); + assertEquals(ENVIRONMENT, account.environment()); + assertEquals("user@example.com", account.username()); + } + + @Test + void build_WithNullAccountCacheEntity_AccountIsNull() { + AuthenticationResult result = AuthenticationResult.builder() + .accountCacheEntity(null) + .build(); + + assertNull(result.account()); + } + + @Test + void build_WithIdTokenAndAccount_TenantProfileIsNull_DueToInitOrder() { + // Documents same initialization order issue: tenantProfile = getTenantProfile() + // runs before this.idToken is set, so StringHelper.isBlank(idToken) returns true + // and tenantProfile is always null. + AccountCacheEntity entity = buildAccountCacheEntity(); + + AuthenticationResult result = AuthenticationResult.builder() + .idToken(TestHelper.ENCODED_JWT) + .accountCacheEntity(entity) + .build(); + + assertNull(result.tenantProfile(), + "tenantProfile is always null due to field initialization order"); + } + + @Test + void build_ExpiresOn_ExpiresOnDateConvertedCorrectly() { + AuthenticationResult result = AuthenticationResult.builder() + .expiresOn(EXPIRES_ON) + .build(); + + Date expectedDate = new Date(EXPIRES_ON * 1000); + assertEquals(expectedDate, result.expiresOnDate()); + } + + @Test + void build_WithIdTokenButNullAccount_DoesNotThrowNPE_DueToInitOrder() { + // If the field initialization order bug were fixed, this scenario would throw NPE: + // getTenantProfile() calls getAccount().environment(), and getAccount() returns null + // when accountCacheEntity is null. However, since idToken is null at init time, + // getTenantProfile() returns null before reaching the getAccount() call. + AuthenticationResult result = AuthenticationResult.builder() + .idToken(TestHelper.ENCODED_JWT) + .accountCacheEntity(null) + .build(); + + assertNull(result.tenantProfile()); + assertEquals(TestHelper.ENCODED_JWT, result.idToken()); + } + + // ========== equals() Tests ========== + + @Test + void equals_SameInstance_ReturnsTrue() { + AuthenticationResult result = buildBaselineResult(); + assertEquals(result, result); + } + + @Test + void equals_WrongType_ReturnsFalse() { + AuthenticationResult result = buildBaselineResult(); + assertFalse(result.equals("not an AuthenticationResult")); + } + + @Test + void equals_Null_ReturnsFalse() { + AuthenticationResult result = buildBaselineResult(); + assertFalse(result.equals(null)); + } + + @Test + void equals_IdenticalFields_NoIdToken_ReturnsTrue() { + // Two independently-constructed results with identical fields and shared metadata + // should be equal when idToken is blank (avoiding IdToken/TenantProfile reference + // equality issues). This is the only scenario where equals() works for separately + // constructed instances, because AuthenticationResultMetadata also lacks equals(). + AuthenticationResult result1 = buildBaselineResult(); + AuthenticationResult result2 = buildBaselineResult(); + assertEquals(result1, result2); + } + + /** + * Parameterized test that verifies each field in equals() by varying one field at a time + * from the baseline. Uses blank idToken and shared metadata to ensure reference equality + * issues in IdToken/TenantProfile/Metadata don't interfere. + * + * Each argument set contains: field name (for display), and a result with that field changed. + */ + @ParameterizedTest(name = "equals returns false when {0} differs") + @MethodSource("differentFieldProvider") + void equals_SingleFieldDiffers_ReturnsFalse(String fieldName, AuthenticationResult modified) { + AuthenticationResult baseline = buildBaselineResult(); + assertNotEquals(baseline, modified, "Results should differ when " + fieldName + " differs"); + } + + private static Stream differentFieldProvider() { + AccountCacheEntity differentAccount = buildAccountCacheEntity(); + differentAccount.homeAccountId = "different.account"; + + return Stream.of( + Arguments.of("accessToken", buildWithOverride().accessToken("different-token").build()), + Arguments.of("expiresOn", buildWithOverride().expiresOn(999L).build()), + Arguments.of("extExpiresOn", buildWithOverride().extExpiresOn(999L).build()), + Arguments.of("refreshToken", buildWithOverride().refreshToken("different-rt").build()), + Arguments.of("refreshOn", buildWithOverride().refreshOn(999L).build()), + Arguments.of("familyId", buildWithOverride().familyId("2").build()), + Arguments.of("environment", buildWithOverride().environment("different.env").build()), + Arguments.of("scopes", buildWithOverride().scopes("different.scope").build()), + Arguments.of("isPopAuthorization", buildWithOverride().isPopAuthorization(true).build()), + Arguments.of("accountCacheEntity", buildWithOverride().accountCacheEntity(differentAccount).build()) + ); + } + + /** + * Returns a builder pre-populated with baseline values, ready for one field to be overridden. + */ + private static AuthenticationResult.AuthenticationResultBuilder buildWithOverride() { + return AuthenticationResult.builder() + .accessToken(ACCESS_TOKEN) + .expiresOn(EXPIRES_ON) + .extExpiresOn(EXT_EXPIRES_ON) + .refreshToken(REFRESH_TOKEN) + .refreshOn(REFRESH_ON) + .familyId(FAMILY_ID) + .accountCacheEntity(buildAccountCacheEntity()) + .environment(ENVIRONMENT) + .scopes(SCOPES) + .metadata(SHARED_METADATA) + .isPopAuthorization(false); + } + + @Test + void equals_IdTokenSetOnBoth_ReturnsTrue_BecauseIdTokenObjectAlwaysNull() { + // Despite IdToken lacking equals(), this comparison passes because the field + // initialization order bug means idTokenObject is always null for both results. + // The idToken string comparison (line 238) passes, and the idTokenObject comparison + // (line 239) passes because both are null. + AccountCacheEntity entity = buildAccountCacheEntity(); + AuthenticationResult result1 = AuthenticationResult.builder() + .idToken(TestHelper.ENCODED_JWT) + .accountCacheEntity(entity) + .metadata(SHARED_METADATA) + .build(); + AuthenticationResult result2 = AuthenticationResult.builder() + .idToken(TestHelper.ENCODED_JWT) + .accountCacheEntity(entity) + .metadata(SHARED_METADATA) + .build(); + + assertEquals(result1, result2, + "Results with same idToken are equal because idTokenObject is always null"); + } + + @Test + void equals_DifferentMetadataInstances_ReturnsFalse() { + // Documents that AuthenticationResultMetadata also lacks equals(), so two results + // with equivalent but separately-constructed metadata instances will not be equal. + AuthenticationResultMetadata meta1 = AuthenticationResultMetadata.builder() + .tokenSource(TokenSource.CACHE).build(); + AuthenticationResultMetadata meta2 = AuthenticationResultMetadata.builder() + .tokenSource(TokenSource.CACHE).build(); + + AuthenticationResult result1 = AuthenticationResult.builder() + .expiresOn(EXPIRES_ON) + .metadata(meta1) + .build(); + AuthenticationResult result2 = AuthenticationResult.builder() + .expiresOn(EXPIRES_ON) + .metadata(meta2) + .build(); + + assertNotEquals(result1, result2, + "Two results with equivalent metadata instances are not equal because AuthenticationResultMetadata lacks equals()"); + } + + @Test + void equals_NullVsNonNullRefreshOn_ReturnsFalse() { + AuthenticationResult withRefreshOn = buildBaselineResult(); + AuthenticationResult withoutRefreshOn = buildWithOverride().refreshOn(null).build(); + + assertNotEquals(withRefreshOn, withoutRefreshOn); + } + + // ========== hashCode() Tests ========== + + @Test + void hashCode_EqualObjects_ReturnSameHash() { + AuthenticationResult result1 = buildBaselineResult(); + AuthenticationResult result2 = buildBaselineResult(); + + // Verify the contract: equal objects must have equal hash codes + assertEquals(result1, result2, "Precondition: results must be equal"); + assertEquals(result1.hashCode(), result2.hashCode()); + } + + @Test + void hashCode_AllNullableFieldsNull_DoesNotThrow() { + AuthenticationResult result = AuthenticationResult.builder().build(); + + // Should not throw NPE — all null branches use the constant 43 + int hash = result.hashCode(); + assertNotEquals(0, hash, "Hash should be computed even with all-null fields"); + } + + @Test + void hashCode_AllFieldsSet_DoesNotThrow() { + AuthenticationResult result = AuthenticationResult.builder() + .accessToken(ACCESS_TOKEN) + .expiresOn(EXPIRES_ON) + .extExpiresOn(EXT_EXPIRES_ON) + .refreshToken(REFRESH_TOKEN) + .refreshOn(REFRESH_ON) + .familyId(FAMILY_ID) + .idToken(TestHelper.ENCODED_JWT) + .accountCacheEntity(buildAccountCacheEntity()) + .environment(ENVIRONMENT) + .scopes(SCOPES) + .metadata(SHARED_METADATA) + .isPopAuthorization(true) + .build(); + + int hash = result.hashCode(); + assertNotEquals(0, hash); + } + + // ========== AuthenticationResultMetadata Tests ========== + + @Test + void metadata_BuilderDefaults_CacheRefreshReasonNotApplicable() { + AuthenticationResultMetadata metadata = AuthenticationResultMetadata.builder().build(); + + assertNull(metadata.tokenSource()); + assertNull(metadata.refreshOn()); + // CacheRefreshReason defaults to NOT_APPLICABLE in the constructor + assertEquals(CacheRefreshReason.NOT_APPLICABLE, metadata.cacheRefreshReason()); + } + + @Test + void metadata_AllFieldsSet_GettersReturnExpectedValues() { + AuthenticationResultMetadata metadata = AuthenticationResultMetadata.builder() + .tokenSource(TokenSource.CACHE) + .refreshOn(1735686000L) + .cacheRefreshReason(CacheRefreshReason.EXPIRED) + .build(); + + assertEquals(TokenSource.CACHE, metadata.tokenSource()); + assertEquals(1735686000L, metadata.refreshOn()); + assertEquals(CacheRefreshReason.EXPIRED, metadata.cacheRefreshReason()); + } + + @Test + void metadata_NullCacheRefreshReason_DefaultsToNotApplicable() { + AuthenticationResultMetadata metadata = AuthenticationResultMetadata.builder() + .cacheRefreshReason(null) + .build(); + + assertEquals(CacheRefreshReason.NOT_APPLICABLE, metadata.cacheRefreshReason()); + } + + @Test + void metadata_Setters_UpdateValues() { + AuthenticationResultMetadata metadata = AuthenticationResultMetadata.builder().build(); + + metadata.tokenSource(TokenSource.IDENTITY_PROVIDER); + metadata.refreshOn(5000L); + metadata.cacheRefreshReason(CacheRefreshReason.PROACTIVE_REFRESH); + + assertEquals(TokenSource.IDENTITY_PROVIDER, metadata.tokenSource()); + assertEquals(5000L, metadata.refreshOn()); + assertEquals(CacheRefreshReason.PROACTIVE_REFRESH, metadata.cacheRefreshReason()); + } + + @Test + void metadata_ToString_ContainsFieldValues() { + String toString = AuthenticationResultMetadata.builder() + .tokenSource(TokenSource.CACHE) + .refreshOn(100L) + .cacheRefreshReason(CacheRefreshReason.EXPIRED) + .toString(); + + assertTrue(toString.contains("CACHE")); + assertTrue(toString.contains("100")); + assertTrue(toString.contains("EXPIRED")); + } +} diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/AuthorityTest.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/AuthorityTest.java index 326e933f6..3af3e7947 100644 --- a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/AuthorityTest.java +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/AuthorityTest.java @@ -19,31 +19,31 @@ class AuthorityTest { @Test void testDetectAuthorityType_AAD() throws Exception { URL url = new URL(TestConfiguration.AAD_TENANT_ENDPOINT); - assertEquals(Authority.detectAuthorityType(url), AuthorityType.AAD); + assertEquals(AuthorityType.AAD, Authority.detectAuthorityType(url)); } @Test void testDetectAuthorityType_ADFS() throws Exception { URL url = new URL(TestConfiguration.ADFS_TENANT_ENDPOINT); - assertEquals(Authority.detectAuthorityType(url), AuthorityType.ADFS); + assertEquals(AuthorityType.ADFS, Authority.detectAuthorityType(url)); } @Test void testDetectAuthorityType_B2C() throws Exception { URL url = new URL(TestConfiguration.B2C_AUTHORITY); - assertEquals(Authority.detectAuthorityType(url), AuthorityType.B2C); + assertEquals(AuthorityType.B2C, Authority.detectAuthorityType(url)); } @ParameterizedTest @MethodSource("com.microsoft.aad.msal4j.AuthorityTest#ciamAuthorities") void testDetectAuthorityType_CIAM(URL authority) throws Exception { - assertEquals(Authority.detectAuthorityType(authority), AuthorityType.CIAM); + assertEquals(AuthorityType.CIAM, Authority.detectAuthorityType(authority)); } @ParameterizedTest @MethodSource("com.microsoft.aad.msal4j.AuthorityTest#validCiamAuthoritiesAndTransformedAuthority") void testCiamAuthorityTransformation(URL authority, URL transformedAuthority) throws Exception { - assertEquals(CIAMAuthority.transformAuthority(authority), transformedAuthority); + assertEquals(transformedAuthority, CIAMAuthority.transformAuthority(authority)); } @Test @@ -100,35 +100,35 @@ void testValidateAuthorityEmptyPath() { void testConstructor_AADAuthority() throws MalformedURLException { final AADAuthority aa = new AADAuthority(new URL(TestConfiguration.AAD_TENANT_ENDPOINT)); assertNotNull(aa); - assertEquals(aa.authority(), - TestConfiguration.AAD_TENANT_ENDPOINT); - assertEquals(aa.host(), TestConfiguration.AAD_HOST_NAME); - assertEquals(aa.tokenEndpoint(), - TestConfiguration.AAD_TENANT_ENDPOINT + "oauth2/v2.0/token"); - assertEquals(aa.selfSignedJwtAudience(), - TestConfiguration.AAD_TENANT_ENDPOINT + "oauth2/v2.0/token"); - assertEquals(aa.tokenEndpoint(), - TestConfiguration.AAD_TENANT_ENDPOINT + "oauth2/v2.0/token"); - assertEquals(aa.authorityType(), AuthorityType.AAD); + assertEquals(TestConfiguration.AAD_TENANT_ENDPOINT, + aa.authority()); + assertEquals(TestConfiguration.AAD_HOST_NAME, aa.host()); + assertEquals(TestConfiguration.AAD_TENANT_ENDPOINT + "oauth2/v2.0/token", + aa.tokenEndpoint()); + assertEquals(TestConfiguration.AAD_TENANT_ENDPOINT + "oauth2/v2.0/token", + aa.selfSignedJwtAudience()); + assertEquals(TestConfiguration.AAD_TENANT_ENDPOINT + "oauth2/v2.0/token", + aa.tokenEndpoint()); + assertEquals(AuthorityType.AAD, aa.authorityType()); assertFalse(aa.isTenantless()); - assertEquals(aa.deviceCodeEndpoint(), - TestConfiguration.AAD_TENANT_ENDPOINT + "oauth2/v2.0/devicecode"); + assertEquals(TestConfiguration.AAD_TENANT_ENDPOINT + "oauth2/v2.0/devicecode", + aa.deviceCodeEndpoint()); } @Test void testConstructor_B2CAuthority() throws MalformedURLException { final B2CAuthority aa = new B2CAuthority(new URL(TestConfiguration.B2C_AUTHORITY)); assertNotNull(aa); - assertEquals(aa.authority(), - TestConfiguration.B2C_AUTHORITY + "/"); - assertEquals(aa.host(), TestConfiguration.B2C_HOST_NAME); - assertEquals(aa.selfSignedJwtAudience(), - TestConfiguration.B2C_AUTHORITY_ENDPOINT + "/oauth2/v2.0/token?p=" + TestConfiguration.B2C_SIGN_IN_POLICY); - assertEquals(aa.tokenEndpoint(), - TestConfiguration.B2C_AUTHORITY_ENDPOINT + "/oauth2/v2.0/token?p=" + TestConfiguration.B2C_SIGN_IN_POLICY); - assertEquals(aa.authorityType(), AuthorityType.B2C); - assertEquals(aa.tokenEndpoint(), - TestConfiguration.B2C_AUTHORITY_ENDPOINT + "/oauth2/v2.0/token?p=" + TestConfiguration.B2C_SIGN_IN_POLICY); + assertEquals(TestConfiguration.B2C_AUTHORITY + "/", + aa.authority()); + assertEquals(TestConfiguration.B2C_HOST_NAME, aa.host()); + assertEquals(TestConfiguration.B2C_AUTHORITY_ENDPOINT + "/oauth2/v2.0/token?p=" + TestConfiguration.B2C_SIGN_IN_POLICY, + aa.selfSignedJwtAudience()); + assertEquals(TestConfiguration.B2C_AUTHORITY_ENDPOINT + "/oauth2/v2.0/token?p=" + TestConfiguration.B2C_SIGN_IN_POLICY, + aa.tokenEndpoint()); + assertEquals(AuthorityType.B2C, aa.authorityType()); + assertEquals(TestConfiguration.B2C_AUTHORITY_ENDPOINT + "/oauth2/v2.0/token?p=" + TestConfiguration.B2C_SIGN_IN_POLICY, + aa.tokenEndpoint()); assertFalse(aa.isTenantless()); } @@ -136,15 +136,15 @@ void testConstructor_B2CAuthority() throws MalformedURLException { void testConstructor_ADFSAuthority() throws MalformedURLException { final ADFSAuthority a = new ADFSAuthority(new URL(TestConfiguration.ADFS_TENANT_ENDPOINT)); assertNotNull(a); - assertEquals(a.authority(), TestConfiguration.ADFS_TENANT_ENDPOINT); - assertEquals(a.host(), TestConfiguration.ADFS_HOST_NAME); - assertEquals(a.selfSignedJwtAudience(), - TestConfiguration.ADFS_TENANT_ENDPOINT + ADFSAuthority.TOKEN_ENDPOINT); + assertEquals(TestConfiguration.ADFS_TENANT_ENDPOINT, a.authority()); + assertEquals(TestConfiguration.ADFS_HOST_NAME, a.host()); + assertEquals(TestConfiguration.ADFS_TENANT_ENDPOINT + ADFSAuthority.TOKEN_ENDPOINT, + a.selfSignedJwtAudience()); - assertEquals(a.authorityType(), AuthorityType.ADFS); + assertEquals(AuthorityType.ADFS, a.authorityType()); - assertEquals(a.tokenEndpoint(), - TestConfiguration.ADFS_TENANT_ENDPOINT + ADFSAuthority.TOKEN_ENDPOINT); + assertEquals(TestConfiguration.ADFS_TENANT_ENDPOINT + ADFSAuthority.TOKEN_ENDPOINT, + a.tokenEndpoint()); assertFalse(a.isTenantless()); } diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/AuthorizationRequestUrlParametersTest.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/AuthorizationRequestUrlParametersTest.java index 034ffc0ec..90ef70380 100644 --- a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/AuthorizationRequestUrlParametersTest.java +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/AuthorizationRequestUrlParametersTest.java @@ -4,22 +4,21 @@ package com.microsoft.aad.msal4j; import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.TestInstance; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; -import java.io.UnsupportedEncodingException; import java.net.URL; import java.net.URLDecoder; import java.util.*; -@TestInstance(TestInstance.Lifecycle.PER_CLASS) class AuthorizationRequestUrlParametersTest { @Test - void testBuilder_onlyRequiredParameters() throws UnsupportedEncodingException { + void testBuilder_onlyRequiredParameters() throws Exception { PublicClientApplication app = PublicClientApplication.builder("client_id").build(); String redirectUri = "http://localhost:8080"; @@ -35,10 +34,10 @@ void testBuilder_onlyRequiredParameters() throws UnsupportedEncodingException { .extraQueryParameters(extraParameters) .build(); - assertEquals(parameters.responseMode(), ResponseMode.FORM_POST); - assertEquals(parameters.redirectUri(), redirectUri); - assertEquals(parameters.scopes().size(), 4); - assertEquals(parameters.extraQueryParameters.size(), 2); + assertEquals(ResponseMode.FORM_POST, parameters.responseMode()); + assertEquals(redirectUri, parameters.redirectUri()); + assertEquals(4, parameters.scopes().size()); + assertEquals(2, parameters.extraQueryParameters.size()); assertNull(parameters.loginHint()); assertNull(parameters.codeChallenge()); @@ -50,26 +49,17 @@ void testBuilder_onlyRequiredParameters() throws UnsupportedEncodingException { URL authorizationUrl = app.getAuthorizationRequestUrl(parameters); - assertEquals(authorizationUrl.getHost(), "login.microsoftonline.com"); - assertEquals(authorizationUrl.getPath(), "/common/oauth2/v2.0/authorize"); + assertEquals("login.microsoftonline.com", authorizationUrl.getHost()); + assertEquals("/common/oauth2/v2.0/authorize", authorizationUrl.getPath()); - Map queryParameters = new HashMap<>(); - String query = authorizationUrl.getQuery(); + Map queryParameters = parseQueryParameters(authorizationUrl); - String[] queryPairs = query.split("&"); - for (String pair : queryPairs) { - int idx = pair.indexOf("="); - queryParameters.put( - URLDecoder.decode(pair.substring(0, idx), "UTF-8"), - URLDecoder.decode(pair.substring(idx + 1), "UTF-8")); - } - - assertEquals(queryParameters.get("scope"), "openid profile offline_access scope"); - assertEquals(queryParameters.get("response_type"), "code"); - assertEquals(queryParameters.get("redirect_uri"), "http://localhost:8080"); - assertEquals(queryParameters.get("client_id"), "client_id"); - assertEquals(queryParameters.get("response_mode"), "form_post"); - assertEquals(queryParameters.get("id_token_hint"),"test"); + assertEquals("openid profile offline_access scope", queryParameters.get("scope")); + assertEquals("code", queryParameters.get("response_type")); + assertEquals("http://localhost:8080", queryParameters.get("redirect_uri")); + assertEquals("client_id", queryParameters.get("client_id")); + assertEquals("form_post", queryParameters.get("response_mode")); + assertEquals("test", queryParameters.get("id_token_hint")); } @Test @@ -85,6 +75,8 @@ void testBuilder_invalidRequiredParameters() { @Test void testBuilder_conflictingParameters() { + // Verifies that duplicate parameter keys (extra query params overriding built-in params) + // don't throw an exception — they log a warning and the extra value overwrites the built-in. String redirectUri = "http://localhost:8080"; Set scope = Collections.singleton("scope"); @@ -98,7 +90,7 @@ void testBuilder_conflictingParameters() { } @Test - void testBuilder_responseMode() throws UnsupportedEncodingException { + void testBuilder_responseMode() throws Exception { PublicClientApplication app = PublicClientApplication.builder("client_id").build(); String redirectUri = "http://localhost:8080"; @@ -110,9 +102,9 @@ void testBuilder_responseMode() throws UnsupportedEncodingException { .responseMode(ResponseMode.QUERY) // This should be overridden to FORM_POST .build(); - assertEquals(parameters.responseMode(), ResponseMode.FORM_POST); - assertEquals(parameters.redirectUri(), redirectUri); - assertEquals(parameters.scopes().size(), 4); + assertEquals(ResponseMode.FORM_POST, parameters.responseMode()); + assertEquals(redirectUri, parameters.redirectUri()); + assertEquals(4, parameters.scopes().size()); assertNull(parameters.loginHint()); assertNull(parameters.codeChallenge()); @@ -124,12 +116,156 @@ void testBuilder_responseMode() throws UnsupportedEncodingException { URL authorizationUrl = app.getAuthorizationRequestUrl(parameters); - assertEquals(authorizationUrl.getHost(), "login.microsoftonline.com"); - assertEquals(authorizationUrl.getPath(), "/common/oauth2/v2.0/authorize"); + assertEquals("login.microsoftonline.com", authorizationUrl.getHost()); + assertEquals("/common/oauth2/v2.0/authorize", authorizationUrl.getPath()); - Map queryParameters = new HashMap<>(); - String query = authorizationUrl.getQuery(); + Map queryParameters = parseQueryParameters(authorizationUrl); + + assertEquals("openid profile offline_access scope", queryParameters.get("scope")); + assertEquals("code", queryParameters.get("response_type")); + assertEquals("http://localhost:8080", queryParameters.get("redirect_uri")); + assertEquals("client_id", queryParameters.get("client_id")); + assertEquals("form_post", queryParameters.get("response_mode")); + } + + @Test + void testBuilder_allOptionalParams() throws Exception { + PublicClientApplication app = PublicClientApplication.builder("client_id").build(); + + String redirectUri = "http://localhost:8080"; + Set scope = Collections.singleton("scope"); + + AuthorizationRequestUrlParameters parameters = + AuthorizationRequestUrlParameters + .builder(redirectUri, scope) + .codeChallenge("challenge-value") + .codeChallengeMethod("S256") + .state("state-123") + .nonce("nonce-456") + .loginHint("user@contoso.com") + .domainHint("contoso.com") + .correlationId("corr-789") + .instanceAware(true) + .prompt(Prompt.CONSENT) + .responseMode(ResponseMode.FORM_POST) + .build(); + + assertEquals("challenge-value", parameters.codeChallenge()); + assertEquals("S256", parameters.codeChallengeMethod()); + assertEquals("state-123", parameters.state()); + assertEquals("nonce-456", parameters.nonce()); + assertEquals("corr-789", parameters.correlationId()); + assertTrue(parameters.instanceAware()); + assertEquals(Prompt.CONSENT, parameters.prompt()); + assertEquals(ResponseMode.FORM_POST, parameters.responseMode()); + + URL authorizationUrl = app.getAuthorizationRequestUrl(parameters); + Map queryParameters = parseQueryParameters(authorizationUrl); + + assertEquals("challenge-value", queryParameters.get("code_challenge")); + assertEquals("S256", queryParameters.get("code_challenge_method")); + assertEquals("state-123", queryParameters.get("state")); + assertEquals("nonce-456", queryParameters.get("nonce")); + assertEquals("user@contoso.com", queryParameters.get("login_hint")); + assertEquals("contoso.com", queryParameters.get("domain_hint")); + assertEquals("corr-789", queryParameters.get("correlation_id")); + assertEquals("true", queryParameters.get("instance_aware")); + assertEquals("consent", queryParameters.get("prompt")); + } + + @Test + void testBuilder_nullScopes_Throws() { + assertThrows(IllegalArgumentException.class, () -> + AuthorizationRequestUrlParameters.builder("http://localhost:8080", null)); + } + + @Test + void testBuilder_extraScopesToConsent() throws Exception { + PublicClientApplication app = PublicClientApplication.builder("client_id").build(); + + Set scope = Collections.singleton("User.Read"); + Set extraScopes = new HashSet<>(Arrays.asList("Mail.Read", "Calendars.Read")); + + AuthorizationRequestUrlParameters parameters = + AuthorizationRequestUrlParameters + .builder("http://localhost:8080", scope) + .extraScopesToConsent(extraScopes) + .build(); + + // Scopes should include common scopes + requested + extra + Set resultScopes = parameters.scopes(); + assertTrue(resultScopes.contains("User.Read")); + assertTrue(resultScopes.contains("Mail.Read")); + assertTrue(resultScopes.contains("Calendars.Read")); + assertTrue(resultScopes.contains("openid")); + URL authorizationUrl = app.getAuthorizationRequestUrl(parameters); + Map queryParameters = parseQueryParameters(authorizationUrl); + + String scopeParam = queryParameters.get("scope"); + assertTrue(scopeParam.contains("User.Read")); + assertTrue(scopeParam.contains("Mail.Read")); + assertTrue(scopeParam.contains("Calendars.Read")); + } + + @Test + void testBuilder_loginHint_SetsAnchorMailbox() throws Exception { + PublicClientApplication app = PublicClientApplication.builder("client_id").build(); + + AuthorizationRequestUrlParameters parameters = + AuthorizationRequestUrlParameters + .builder("http://localhost:8080", Collections.singleton("scope")) + .loginHint("user@contoso.com") + .build(); + + URL authorizationUrl = app.getAuthorizationRequestUrl(parameters); + Map queryParameters = parseQueryParameters(authorizationUrl); + + assertEquals("user@contoso.com", queryParameters.get("login_hint")); + // X-AnchorMailbox should be set for CCS routing with UPN format + assertEquals("upn:user@contoso.com", queryParameters.get("X-AnchorMailbox")); + } + + @Test + void testBuilder_formPostResponseMode_ExplicitlySet() throws Exception { + PublicClientApplication app = PublicClientApplication.builder("client_id").build(); + + AuthorizationRequestUrlParameters parameters = + AuthorizationRequestUrlParameters + .builder("http://localhost:8080", Collections.singleton("scope")) + .responseMode(ResponseMode.FORM_POST) + .build(); + + assertEquals(ResponseMode.FORM_POST, parameters.responseMode()); + + URL authorizationUrl = app.getAuthorizationRequestUrl(parameters); + Map queryParameters = parseQueryParameters(authorizationUrl); + + assertEquals("form_post", queryParameters.get("response_mode")); + } + + @Test + void testBuilder_claimsChallenge() throws Exception { + PublicClientApplication app = PublicClientApplication.builder("client_id").build(); + + String claimsChallenge = "{\"access_token\":{\"nbf\":{\"essential\":true}}}"; + + AuthorizationRequestUrlParameters parameters = + AuthorizationRequestUrlParameters + .builder("http://localhost:8080", Collections.singleton("scope")) + .claimsChallenge(claimsChallenge) + .build(); + + URL authorizationUrl = app.getAuthorizationRequestUrl(parameters); + Map queryParameters = parseQueryParameters(authorizationUrl); + + assertNotNull(queryParameters.get("claims")); + assertTrue(queryParameters.get("claims").contains("nbf")); + } + + private static Map parseQueryParameters(URL url) throws Exception { + Map queryParameters = new HashMap<>(); + String query = url.getQuery(); String[] queryPairs = query.split("&"); for (String pair : queryPairs) { int idx = pair.indexOf("="); @@ -137,11 +273,6 @@ void testBuilder_responseMode() throws UnsupportedEncodingException { URLDecoder.decode(pair.substring(0, idx), "UTF-8"), URLDecoder.decode(pair.substring(idx + 1), "UTF-8")); } - - assertEquals(queryParameters.get("scope"), "openid profile offline_access scope"); - assertEquals(queryParameters.get("response_type"), "code"); - assertEquals(queryParameters.get("redirect_uri"), "http://localhost:8080"); - assertEquals(queryParameters.get("client_id"), "client_id"); - assertEquals(queryParameters.get("response_mode"), "form_post"); + return queryParameters; } } diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/CacheFormatTests.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/CacheFormatTest.java similarity index 65% rename from msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/CacheFormatTests.java rename to msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/CacheFormatTest.java index a30a39513..0672e91a3 100644 --- a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/CacheFormatTests.java +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/CacheFormatTest.java @@ -9,7 +9,6 @@ import org.skyscreamer.jsonassert.JSONCompareResult; import org.skyscreamer.jsonassert.comparator.DefaultComparator; import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.TestInstance; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.mock; @@ -17,48 +16,43 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.junit.jupiter.MockitoExtension; -import java.io.IOException; import java.net.URI; -import java.net.URISyntaxException; import java.net.URL; import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Paths; import java.util.*; import static com.microsoft.aad.msal4j.Constants.POINT_DELIMITER; @ExtendWith(MockitoExtension.class) -@TestInstance(TestInstance.Lifecycle.PER_CLASS) -class CacheFormatTests { - String TOKEN_RESPONSE = "/token_response.json"; - String TOKEN_RESPONSE_ID_TOKEN = "/token_response_id_token.json"; +class CacheFormatTest { + private static final String TOKEN_RESPONSE = "/token_response.json"; + private static final String TOKEN_RESPONSE_ID_TOKEN = "/token_response_id_token.json"; - String AT_CACHE_ENTITY_KEY = "/at_cache_entity_key.txt"; - String AT_CACHE_ENTITY = "/at_cache_entity.json"; + private static final String AT_CACHE_ENTITY_KEY = "/at_cache_entity_key.txt"; + private static final String AT_CACHE_ENTITY = "/at_cache_entity.json"; - String RT_CACHE_ENTITY_KEY = "/rt_cache_entity_key.txt"; - String RT_CACHE_ENTITY = "/rt_cache_entity.json"; + private static final String RT_CACHE_ENTITY_KEY = "/rt_cache_entity_key.txt"; + private static final String RT_CACHE_ENTITY = "/rt_cache_entity.json"; - String ID_TOKEN_CACHE_ENTITY_KEY = "/id_token_cache_entity_key.txt"; - String ID_TOKEN_CACHE_ENTITY = "/id_token_cache_entity.json"; + private static final String ID_TOKEN_CACHE_ENTITY_KEY = "/id_token_cache_entity_key.txt"; + private static final String ID_TOKEN_CACHE_ENTITY = "/id_token_cache_entity.json"; - String ACCOUNT_CACHE_ENTITY_KEY = "/account_cache_entity_key.txt"; - String ACCOUNT_CACHE_ENTITY = "/account_cache_entity.json"; + private static final String ACCOUNT_CACHE_ENTITY_KEY = "/account_cache_entity_key.txt"; + private static final String ACCOUNT_CACHE_ENTITY = "/account_cache_entity.json"; - String APP_METADATA_ENTITY_KEY = "/app_metadata_cache_entity_key.txt"; - String APP_METADATA_CACHE_ENTITY = "/app_metadata_cache_entity.json"; + private static final String APP_METADATA_ENTITY_KEY = "/app_metadata_cache_entity_key.txt"; + private static final String APP_METADATA_CACHE_ENTITY = "/app_metadata_cache_entity.json"; - String ID_TOKEN_PLACEHOLDER = ""; - String CACHED_AT_PLACEHOLDER = ""; - String EXPIRES_ON_PLACEHOLDER = ""; - String EXTENDED_EXPIRES_ON_PLACEHOLDER = ""; + private static final String ID_TOKEN_PLACEHOLDER = ""; + private static final String CACHED_AT_PLACEHOLDER = ""; + private static final String EXPIRES_ON_PLACEHOLDER = ""; + private static final String EXTENDED_EXPIRES_ON_PLACEHOLDER = ""; @Test - void cacheDeserializationSerializationTest() throws IOException, URISyntaxException, JSONException { + void cacheDeserializationSerializationTest() throws JSONException { ITokenCache tokenCache = new TokenCache(null); - String previouslyStoredCache = readResource("/cache_data/serialized_cache.json"); + String previouslyStoredCache = TestHelper.readResource(this.getClass(), "/cache_data/serialized_cache.json"); tokenCache.deserialize(previouslyStoredCache); @@ -67,16 +61,6 @@ void cacheDeserializationSerializationTest() throws IOException, URISyntaxExcept JSONAssert.assertEquals(previouslyStoredCache, serializedCache, JSONCompareMode.STRICT); } - String readResource(String resource) throws IOException, URISyntaxException { - return new String( - Files.readAllBytes( - Paths.get(getClass().getResource(resource).toURI()))); - } - - boolean doesResourceExist(String resource) { - return getClass().getResource(resource) != null; - } - public class DynamicTimestampsComparator extends DefaultComparator { private Map expectations = new HashMap<>(); @@ -110,21 +94,21 @@ public void compareValues(String s, Object o, Object o1, JSONCompareResult jsonC } @Test - void AADTokenCacheEntitiesFormatTest() throws JSONException, IOException, URISyntaxException { + void AADTokenCacheEntitiesFormatTest() throws Exception { tokenCacheEntitiesFormatTest("/AAD_cache_data"); } @Test - void MSATokenCacheEntitiesFormatTest() throws JSONException, IOException, URISyntaxException { + void MSATokenCacheEntitiesFormatTest() throws Exception { tokenCacheEntitiesFormatTest("/MSA_cache_data"); } @Test - void FociTokenCacheEntitiesFormatTest() throws JSONException, IOException, URISyntaxException { + void FociTokenCacheEntitiesFormatTest() throws Exception { tokenCacheEntitiesFormatTest("/Foci_cache_data"); } - public void tokenCacheEntitiesFormatTest(String folder) throws URISyntaxException, IOException, JSONException { + private void tokenCacheEntitiesFormatTest(String folder) throws Exception { String CLIENT_ID = "b6c69a37-df96-4db0-9088-2ab96e1d8215"; String AUTHORIZE_REQUEST_URL = "https://login.microsoftonline.com/common/oauth2/v2.0/authorize"; @@ -171,16 +155,16 @@ public void tokenCacheEntitiesFormatTest(String folder) throws URISyntaxExceptio } private void validateAccessTokenCacheEntity(String folder, String tokenResponse, TokenCache tokenCache) - throws IOException, URISyntaxException, JSONException { + throws JSONException { assertEquals(1, tokenCache.accessTokens.size()); String keyActual = tokenCache.accessTokens.keySet().stream().findFirst().get(); - String keyExpected = readResource(folder + AT_CACHE_ENTITY_KEY); - assertEquals(keyActual, keyExpected); + String keyExpected = TestHelper.readResource(this.getClass(), folder + AT_CACHE_ENTITY_KEY); + assertEquals(keyExpected, keyActual); String valueActual = JsonHelper.convertJsonSerializableObjectToString(tokenCache.accessTokens.get(keyActual)); - String valueExpected = readResource(folder + AT_CACHE_ENTITY); + String valueExpected = TestHelper.readResource(this.getClass(), folder + AT_CACHE_ENTITY); Map tokenResponseMap = JsonHelper.convertJsonToMap(tokenResponse); @@ -189,23 +173,23 @@ private void validateAccessTokenCacheEntity(String folder, String tokenResponse, } private void validateRefreshTokenCacheEntity(String folder, TokenCache tokenCache) - throws IOException, URISyntaxException, JSONException { + throws JSONException { assertEquals(1, tokenCache.refreshTokens.size()); String actualKey = tokenCache.refreshTokens.keySet().stream().findFirst().get(); - String keyExpected = readResource(folder + RT_CACHE_ENTITY_KEY); - assertEquals(actualKey, keyExpected); + String keyExpected = TestHelper.readResource(this.getClass(), folder + RT_CACHE_ENTITY_KEY); + assertEquals(keyExpected, actualKey); String actualValue = JsonHelper.convertJsonSerializableObjectToString(tokenCache.refreshTokens.get(actualKey)); - String valueExpected = readResource(folder + RT_CACHE_ENTITY); + String valueExpected = TestHelper.readResource(this.getClass(), folder + RT_CACHE_ENTITY); JSONAssert.assertEquals(valueExpected, actualValue, JSONCompareMode.STRICT); } public class IdTokenComparator extends DefaultComparator { private String idToken; - public IdTokenComparator(JSONCompareMode mode, String folder) throws IOException, URISyntaxException { + public IdTokenComparator(JSONCompareMode mode, String folder) { super(mode); idToken = getIdToken(folder); } @@ -215,7 +199,7 @@ public void compareValues(String s, Object o, Object o1, JSONCompareResult jsonC if (ID_TOKEN_PLACEHOLDER.equals(o.toString())) { if (!idToken.equals(o1.toString())) { - jsonCompareResult.fail("idTokens don not match "); + jsonCompareResult.fail("idTokens do not match"); return; } return; @@ -225,76 +209,68 @@ public void compareValues(String s, Object o, Object o1, JSONCompareResult jsonC } private void validateIdTokenCacheEntity(String folder, TokenCache tokenCache) - throws IOException, URISyntaxException, JSONException { + throws JSONException { assertEquals(1, tokenCache.idTokens.size()); String actualKey = tokenCache.idTokens.keySet().stream().findFirst().get(); - String keyExpected = readResource(folder + ID_TOKEN_CACHE_ENTITY_KEY); - assertEquals(actualKey, keyExpected); + String keyExpected = TestHelper.readResource(this.getClass(), folder + ID_TOKEN_CACHE_ENTITY_KEY); + assertEquals(keyExpected, actualKey); String actualValue = JsonHelper.convertJsonSerializableObjectToString(tokenCache.idTokens.get(actualKey)); - String valueExpected = readResource(folder + ID_TOKEN_CACHE_ENTITY); + String valueExpected = TestHelper.readResource(this.getClass(), folder + ID_TOKEN_CACHE_ENTITY); JSONAssert.assertEquals(valueExpected, actualValue, new IdTokenComparator(JSONCompareMode.STRICT, folder)); } private void validateAccountCacheEntity(String folder, TokenCache tokenCache) - throws IOException, URISyntaxException, JSONException { + throws JSONException { assertEquals(1, tokenCache.accounts.size()); String actualKey = tokenCache.accounts.keySet().stream().findFirst().get(); - String keyExpected = readResource(folder + ACCOUNT_CACHE_ENTITY_KEY); - assertEquals(actualKey, keyExpected); + String keyExpected = TestHelper.readResource(this.getClass(), folder + ACCOUNT_CACHE_ENTITY_KEY); + assertEquals(keyExpected, actualKey); String actualValue = JsonHelper.convertJsonSerializableObjectToString(tokenCache.accounts.get(actualKey)); - String valueExpected = readResource(folder + ACCOUNT_CACHE_ENTITY); + String valueExpected = TestHelper.readResource(this.getClass(), folder + ACCOUNT_CACHE_ENTITY); JSONAssert.assertEquals(valueExpected, actualValue, JSONCompareMode.STRICT); } private void validateAppMetadataCacheEntity(String folder, TokenCache tokenCache) - throws IOException, URISyntaxException, JSONException { + throws JSONException { - if (!doesResourceExist(folder + APP_METADATA_CACHE_ENTITY)) { + if (this.getClass().getResource(folder + APP_METADATA_CACHE_ENTITY) == null) { return; } assertEquals(1, tokenCache.appMetadata.size()); String actualKey = tokenCache.appMetadata.keySet().stream().findFirst().get(); - String keyExpected = readResource(folder + APP_METADATA_ENTITY_KEY); - assertEquals(actualKey, keyExpected); + String keyExpected = TestHelper.readResource(this.getClass(), folder + APP_METADATA_ENTITY_KEY); + assertEquals(keyExpected, actualKey); String actualValue = JsonHelper.convertJsonSerializableObjectToString(tokenCache.appMetadata.get(actualKey)); - String valueExpected = readResource(folder + APP_METADATA_CACHE_ENTITY); + String valueExpected = TestHelper.readResource(this.getClass(), folder + APP_METADATA_CACHE_ENTITY); JSONAssert.assertEquals(valueExpected, actualValue, JSONCompareMode.STRICT); } - String getEmptyBase64EncodedJson() { - return new String(Base64.getEncoder().encode("{}".getBytes())); - } - - String getJWTHeaderBase64EncodedJson() { - return new String(Base64.getEncoder().encode("{\"alg\": \"HS256\", \"typ\": \"JWT\"}".getBytes())); - } - - private String getTokenResponse(String folder) throws IOException, URISyntaxException { - String tokenResponse = readResource(folder + TOKEN_RESPONSE); + private String getTokenResponse(String folder) { + String tokenResponse = TestHelper.readResource(this.getClass(), folder + TOKEN_RESPONSE); return tokenResponse.replace(ID_TOKEN_PLACEHOLDER, getIdToken(folder)); } - private String getIdToken(String folder) throws IOException, URISyntaxException { - String tokenResponseIdToken = readResource(folder + TOKEN_RESPONSE_ID_TOKEN); + private String getIdToken(String folder) { + String tokenResponseIdToken = TestHelper.readResource(this.getClass(), folder + TOKEN_RESPONSE_ID_TOKEN); String encodedIdToken = new String(Base64.getEncoder().encode(tokenResponseIdToken.getBytes()), StandardCharsets.UTF_8); - encodedIdToken = getJWTHeaderBase64EncodedJson() + POINT_DELIMITER + + encodedIdToken = TestHelper.getJWTHeaderBase64EncodedJson() + POINT_DELIMITER + encodedIdToken + POINT_DELIMITER + - getEmptyBase64EncodedJson(); + TestHelper.getEmptyBase64EncodedJson(); return encodedIdToken; } diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/CacheTest.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/CacheTest.java new file mode 100644 index 000000000..163d3aeeb --- /dev/null +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/CacheTest.java @@ -0,0 +1,656 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.aad.msal4j; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.net.URL; +import java.util.Collections; +import java.util.HashMap; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +class CacheTest { + + private TokenCache tokenCache; + + @BeforeEach + void setUp() { + tokenCache = new TokenCache(); + } + + @Test + void cacheLookup_MixAccountBasedAndAssertionBasedSilentFlows() throws Exception { + DefaultHttpClient httpClientMock = mock(DefaultHttpClient.class); + + ConfidentialClientApplication cca = TestHelper.buildCca(httpClientMock); + + HashMap responseParameters = new HashMap<>(); + //Acquire a token with no ID token/account associated with it + responseParameters.put("access_token", "accessTokenNoAccount"); + + ClientCredentialParameters clientCredentialParameters = ClientCredentialParameters.builder(Collections.singleton("someScopes")).build(); + TestHelper.mockSuccessfulTokenResponse(httpClientMock, responseParameters); + IAuthenticationResult resultNoAccount = cca.acquireToken(clientCredentialParameters).get(); + + //Ensure there is one token in the cache, and the result had no account + assertEquals(1, cca.tokenCache.accessTokens.size()); + assertNull(resultNoAccount.account()); + verify(httpClientMock, times(1)).send(any()); + + //Acquire a second token, this time with an ID token/account + responseParameters.put("access_token", "accessTokenWithAccount"); + responseParameters.put("id_token", TestHelper.createIdToken(new HashMap<>())); + + TestHelper.mockSuccessfulTokenResponse(httpClientMock, responseParameters); + OnBehalfOfParameters onBehalfOfParameters = OnBehalfOfParameters.builder(Collections.singleton("someOtherScopes"), new UserAssertion(TestHelper.signedAssertion)).build(); + IAuthenticationResult resultWithAccount = cca.acquireToken(onBehalfOfParameters).get(); + + //Ensure there are now two tokens in the cache, and the result has an account + assertEquals(2, cca.tokenCache.accessTokens.size()); + assertNull(resultNoAccount.account()); + verify(httpClientMock, times(2)).send(any()); + + //Make two silent calls, one with the account and one without + SilentParameters silentParametersNoAccount = SilentParameters.builder(Collections.singleton("someScopes")).build(); + SilentParameters silentParametersWithAccount = SilentParameters.builder(Collections.singleton("someOtherScopes"), resultWithAccount.account()).build(); + + resultNoAccount = cca.acquireTokenSilently(silentParametersNoAccount).get(); + resultWithAccount = cca.acquireTokenSilently(silentParametersWithAccount).get(); + + //Ensure the correct access tokens were returned from each silent call + assertEquals("accessTokenNoAccount", resultNoAccount.accessToken()); + assertEquals("accessTokenWithAccount", resultWithAccount.accessToken()); + } + + @Test + void serializeDeserialize_EmptyCache_Success() { + // Serialize empty cache + String serializedCache = tokenCache.serialize(); + + // Should have all required sections but be empty + assertTrue(serializedCache.contains("\"Account\":{}")); + assertTrue(serializedCache.contains("\"AccessToken\":{}")); + assertTrue(serializedCache.contains("\"RefreshToken\":{}")); + assertTrue(serializedCache.contains("\"IdToken\":{}")); + assertTrue(serializedCache.contains("\"AppMetadata\":{}")); + + // Create new cache and deserialize + TokenCache newCache = new TokenCache(); + newCache.deserialize(serializedCache); + + // Verify all collections are empty + assertTrue(newCache.accessTokens.isEmpty()); + assertTrue(newCache.refreshTokens.isEmpty()); + assertTrue(newCache.idTokens.isEmpty()); + assertTrue(newCache.accounts.isEmpty()); + assertTrue(newCache.appMetadata.isEmpty()); + } + + @Test + void serializeDeserialize_WithAccountData_PreservesData() { + // Add an account to the cache + AccountCacheEntity account = new AccountCacheEntity(); + account.homeAccountId("home-account-id"); + account.environment("login.microsoftonline.com"); + account.realm("tenant-id"); + account.localAccountId("local-id"); + account.username("user@example.com"); + account.name("Test User"); + + tokenCache.accounts.put(account.getKey(), account); + + // Serialize the cache + String serializedCache = tokenCache.serialize(); + + // Create new cache and deserialize + TokenCache newCache = new TokenCache(); + newCache.deserialize(serializedCache); + + // Verify account was preserved + assertEquals(1, newCache.accounts.size()); + AccountCacheEntity deserializedAccount = newCache.accounts.get(account.getKey()); + assertNotNull(deserializedAccount); + assertEquals("home-account-id", deserializedAccount.homeAccountId()); + assertEquals("login.microsoftonline.com", deserializedAccount.environment()); + assertEquals("tenant-id", deserializedAccount.realm()); + assertEquals("user@example.com", deserializedAccount.username()); + assertEquals("Test User", deserializedAccount.name()); + } + + @Test + void removeAccount_RemovesAllRelatedEntities() { + // Setup test data + String homeAccountId = "home-account-id"; + String environment = "login.microsoftonline.com"; + String clientId = "client-id"; + String realm = "tenant-id"; + String username = "user@example.com"; + + // Create account + AccountCacheEntity account = new AccountCacheEntity(); + account.homeAccountId(homeAccountId); + account.environment(environment); + account.realm(realm); + account.username(username); + tokenCache.accounts.put(account.getKey(), account); + + // Create access token + AccessTokenCacheEntity accessToken = new AccessTokenCacheEntity(); + accessToken.homeAccountId(homeAccountId); + accessToken.environment(environment); + accessToken.clientId(clientId); + accessToken.realm(realm); + accessToken.target("scope1 scope2"); + accessToken.secret("access-token-secret"); + accessToken.cachedAt("1600000000"); + accessToken.expiresOn("1600100000"); + tokenCache.accessTokens.put(accessToken.getKey(), accessToken); + + // Create refresh token + RefreshTokenCacheEntity refreshToken = new RefreshTokenCacheEntity(); + refreshToken.homeAccountId(homeAccountId); + refreshToken.environment(environment); + refreshToken.clientId(clientId); + refreshToken.secret("refresh-token-secret"); + tokenCache.refreshTokens.put(refreshToken.getKey(), refreshToken); + + // Create ID token + IdTokenCacheEntity idToken = new IdTokenCacheEntity(); + idToken.homeAccountId(homeAccountId); + idToken.environment(environment); + idToken.clientId(clientId); + idToken.realm(realm); + idToken.secret("id-token-secret"); + tokenCache.idTokens.put(idToken.getKey(), idToken); + + // Remove the account + tokenCache.removeAccount(clientId, new Account(homeAccountId, environment, username, null)); + + // Verify all related entities are removed + assertTrue(tokenCache.accounts.isEmpty()); + assertTrue(tokenCache.accessTokens.isEmpty()); + assertTrue(tokenCache.refreshTokens.isEmpty()); + assertTrue(tokenCache.idTokens.isEmpty()); + } + + @Test + void removeAccount_WithMultipleAccounts_OnlyRemovesSpecificAccount() { + // Create two accounts with different homeAccountIds + String homeAccountId1 = "home-account-id-1"; + String homeAccountId2 = "home-account-id-2"; + String environment = "login.microsoftonline.com"; + String clientId = "client-id"; + String username = "user@example.com"; + + // Account 1 + AccountCacheEntity account1 = new AccountCacheEntity(); + account1.homeAccountId(homeAccountId1); + account1.environment(environment); + tokenCache.accounts.put(account1.getKey(), account1); + + // Account 2 + AccountCacheEntity account2 = new AccountCacheEntity(); + account2.homeAccountId(homeAccountId2); + account2.environment(environment); + tokenCache.accounts.put(account2.getKey(), account2); + + // Add tokens for both accounts + AccessTokenCacheEntity accessToken1 = new AccessTokenCacheEntity(); + accessToken1.homeAccountId(homeAccountId1); + accessToken1.environment(environment); + accessToken1.clientId(clientId); + accessToken1.expiresOn("1600100000"); + tokenCache.accessTokens.put(accessToken1.getKey(), accessToken1); + + AccessTokenCacheEntity accessToken2 = new AccessTokenCacheEntity(); + accessToken2.homeAccountId(homeAccountId2); + accessToken2.environment(environment); + accessToken2.clientId(clientId); + accessToken2.expiresOn("1600100000"); + tokenCache.accessTokens.put(accessToken2.getKey(), accessToken2); + + // Remove account1 + tokenCache.removeAccount(clientId, new Account(homeAccountId1, environment, username, null)); + + // Verify only account1 and its tokens are removed + assertEquals(1, tokenCache.accounts.size()); + assertEquals(1, tokenCache.accessTokens.size()); + assertTrue(tokenCache.accounts.containsKey(account2.getKey())); + assertFalse(tokenCache.accounts.containsKey(account1.getKey())); + } + + @Test + void mergeCache_PreservesRemovals() { + // Setup initial cache with two accounts + String homeAccountId1 = "home-account-id-1"; + String homeAccountId2 = "home-account-id-2"; + String environment = "login.microsoftonline.com"; + String clientId = "client-id"; + String username = "user@example.com"; + + // Account 1 + AccountCacheEntity account1 = new AccountCacheEntity(); + account1.homeAccountId(homeAccountId1); + account1.environment(environment); + tokenCache.accounts.put(account1.getKey(), account1); + + // Account 2 + AccountCacheEntity account2 = new AccountCacheEntity(); + account2.homeAccountId(homeAccountId2); + account2.environment(environment); + tokenCache.accounts.put(account2.getKey(), account2); + + // Serialize the cache with both accounts + String serializedWithTwoAccounts = tokenCache.serialize(); + + // Create a new cache with this serialized data + TokenCache deserializedCache = new TokenCache(); + deserializedCache.deserialize(serializedWithTwoAccounts); + assertEquals(2, deserializedCache.accounts.size()); + + // Now remove account1 from the original cache + tokenCache.removeAccount(clientId, new Account(homeAccountId1, environment, username, null)); + + // Serialize the cache with just one account + String serializedWithOneAccount = tokenCache.serialize(); + + // Deserialize into a new cache + TokenCache finalCache = new TokenCache(); + finalCache.deserialize(serializedWithOneAccount); + + // Verify only one account exists + assertEquals(1, finalCache.accounts.size()); + assertTrue(finalCache.accounts.containsKey(account2.getKey())); + assertFalse(finalCache.accounts.containsKey(account1.getKey())); + } + + @Test + void getAccounts_ReturnsCorrectAccounts() { + // Setup multiple accounts in the cache + String homeAccountId1 = "home-account-id-1"; + String localAccountId1 = "local-id-1"; + String homeAccountId2 = "home-account-id-2"; + String environment = "login.microsoftonline.com"; + String clientId = "client-id"; + String realm = "tenant-id"; + + // Account 1 + AccountCacheEntity account1 = new AccountCacheEntity(); + account1.homeAccountId(homeAccountId1); + account1.localAccountId(localAccountId1); + account1.environment(environment); + account1.realm(realm); + account1.username("user1@example.com"); + tokenCache.accounts.put(account1.getKey(), account1); + + // Account 2 + AccountCacheEntity account2 = new AccountCacheEntity(); + account2.homeAccountId(homeAccountId2); + account2.environment(environment); + account2.realm(realm); + account2.username("user2@example.com"); + tokenCache.accounts.put(account2.getKey(), account2); + + // Get accounts + Set accounts = tokenCache.getAccounts(clientId); + + // Verify correct accounts are returned + assertEquals(2, accounts.size()); + + // Verify account details + for (IAccount account : accounts) { + if (account.homeAccountId().equals(homeAccountId1)) { + assertEquals("user1@example.com", account.username()); + } else if (account.homeAccountId().equals(homeAccountId2)) { + assertEquals("user2@example.com", account.username()); + } else { + fail("Unexpected account returned"); + } + } + } + + // --- Deserialize edge cases --- + + @Test + void deserialize_NullData_NoOp() { + // Add some data first to verify it's not cleared + AccountCacheEntity account = new AccountCacheEntity(); + account.homeAccountId("existing"); + account.environment("login.microsoftonline.com"); + tokenCache.accounts.put(account.getKey(), account); + + tokenCache.deserialize(null); + + // Existing data should still be there + assertEquals(1, tokenCache.accounts.size()); + } + + @Test + void deserialize_BlankData_NoOp() { + AccountCacheEntity account = new AccountCacheEntity(); + account.homeAccountId("existing"); + account.environment("login.microsoftonline.com"); + tokenCache.accounts.put(account.getKey(), account); + + tokenCache.deserialize(""); + assertEquals(1, tokenCache.accounts.size()); + + tokenCache.deserialize(" "); + assertEquals(1, tokenCache.accounts.size()); + } + + // --- CacheAspect lifecycle tests --- + + @Test + void cacheAccessAspect_CalledDuringGetAccounts() { + ITokenCacheAccessAspect aspect = mock(ITokenCacheAccessAspect.class); + TokenCache cache = new TokenCache(aspect); + + cache.getAccounts("client-id"); + + verify(aspect, times(1)).beforeCacheAccess(any(ITokenCacheAccessContext.class)); + verify(aspect, times(1)).afterCacheAccess(any(ITokenCacheAccessContext.class)); + } + + @Test + void cacheAccessAspect_CalledDuringRemoveAccount() { + ITokenCacheAccessAspect aspect = mock(ITokenCacheAccessAspect.class); + TokenCache cache = new TokenCache(aspect); + + Account account = new Account("home-id", "login.microsoftonline.com", "user@example.com", null); + cache.removeAccount("client-id", account); + + verify(aspect, times(1)).beforeCacheAccess(any(ITokenCacheAccessContext.class)); + verify(aspect, times(1)).afterCacheAccess(any(ITokenCacheAccessContext.class)); + } + + @Test + void cacheAccessAspect_NotCalledWhenNull() { + // Default constructor — no aspect + TokenCache cache = new TokenCache(); + + // Should not throw even without an aspect + cache.getAccounts("client-id"); + cache.removeAccount("client-id", new Account("id", "env", "user", null)); + } + + @Test + void cacheAccessAspect_DeserializesBeforeAccess() { + // Simulate a persistence aspect that populates cache on beforeCacheAccess + AccountCacheEntity account = new AccountCacheEntity(); + account.homeAccountId("aspect-home-id"); + account.environment("login.microsoftonline.com"); + account.realm("tenant"); + account.username("aspect-user@example.com"); + tokenCache.accounts.put(account.getKey(), account); + String serializedWithAccount = tokenCache.serialize(); + + ITokenCacheAccessAspect persistenceAspect = new ITokenCacheAccessAspect() { + @Override + public void beforeCacheAccess(ITokenCacheAccessContext context) { + context.tokenCache().deserialize(serializedWithAccount); + } + + @Override + public void afterCacheAccess(ITokenCacheAccessContext context) { + // no-op + } + }; + + // Create a fresh cache with the persistence aspect + TokenCache freshCache = new TokenCache(persistenceAspect); + assertTrue(freshCache.accounts.isEmpty()); + + // getAccounts should trigger beforeCacheAccess, which populates the cache + Set accounts = freshCache.getAccounts("client-id"); + assertEquals(1, accounts.size()); + assertEquals("aspect-user@example.com", accounts.iterator().next().username()); + } + + // --- Family RT / FOCI cache lookup tests --- + + @Test + void getCachedAuthenticationResult_FamilyApp_PrefersAnyFamilyRT() throws Exception { + String homeAccountId = "home-id"; + String environment = "login.microsoftonline.com"; + String clientId = "family-client"; + String realm = "tenant-id"; + + // Add account + AccountCacheEntity account = new AccountCacheEntity(); + account.homeAccountId(homeAccountId); + account.environment(environment); + account.realm(realm); + tokenCache.accounts.put(account.getKey(), account); + + // Add a regular (non-family) refresh token for this client + RefreshTokenCacheEntity regularRt = new RefreshTokenCacheEntity(); + regularRt.homeAccountId(homeAccountId); + regularRt.environment(environment); + regularRt.clientId(clientId); + regularRt.credentialType(CredentialTypeEnum.REFRESH_TOKEN.value()); + regularRt.secret("regular-rt-secret"); + tokenCache.refreshTokens.put(regularRt.getKey(), regularRt); + + // Add a family refresh token (different client, but family) + RefreshTokenCacheEntity familyRt = new RefreshTokenCacheEntity(); + familyRt.homeAccountId(homeAccountId); + familyRt.environment(environment); + familyRt.clientId("other-family-client"); + familyRt.credentialType(CredentialTypeEnum.REFRESH_TOKEN.value()); + familyRt.secret("family-rt-secret"); + familyRt.family_id("1"); + tokenCache.refreshTokens.put(familyRt.getKey(), familyRt); + + // Add app metadata marking this client as a family app + AppMetadataCacheEntity appMeta = new AppMetadataCacheEntity(); + appMeta.clientId(clientId); + appMeta.environment(environment); + appMeta.familyId("1"); + tokenCache.appMetadata.put(appMeta.getKey(), appMeta); + + // Look up cached result — family app should prefer family RT + IAccount iAccount = new Account(homeAccountId, environment, "user", null); + Authority authority = new AADAuthority(new URL("https://login.microsoftonline.com/tenant-id/")); + + AuthenticationResult result = tokenCache.getCachedAuthenticationResult( + iAccount, authority, Collections.singleton("scope"), clientId); + + assertEquals("family-rt-secret", result.refreshToken()); + } + + @Test + void getCachedAuthenticationResult_NonFamilyApp_FallsBackToFamilyRT() throws Exception { + String homeAccountId = "home-id"; + String environment = "login.microsoftonline.com"; + String clientId = "non-family-client"; + String realm = "tenant-id"; + + // Add account + AccountCacheEntity account = new AccountCacheEntity(); + account.homeAccountId(homeAccountId); + account.environment(environment); + account.realm(realm); + tokenCache.accounts.put(account.getKey(), account); + + // No regular RT for this client — only a family RT from another client + RefreshTokenCacheEntity familyRt = new RefreshTokenCacheEntity(); + familyRt.homeAccountId(homeAccountId); + familyRt.environment(environment); + familyRt.clientId("family-client"); + familyRt.credentialType(CredentialTypeEnum.REFRESH_TOKEN.value()); + familyRt.secret("family-rt-fallback"); + familyRt.family_id("1"); + tokenCache.refreshTokens.put(familyRt.getKey(), familyRt); + + // No app metadata for non-family-client (it's not a known family member) + IAccount iAccount = new Account(homeAccountId, environment, "user", null); + Authority authority = new AADAuthority(new URL("https://login.microsoftonline.com/tenant-id/")); + + AuthenticationResult result = tokenCache.getCachedAuthenticationResult( + iAccount, authority, Collections.singleton("scope"), clientId); + + // Non-family app has no regular RT, so should fall back to family RT + assertEquals("family-rt-fallback", result.refreshToken()); + } + + @Test + void getCachedAuthenticationResult_WithRefreshOn_IncludedInResult() throws Exception { + String homeAccountId = "home-id"; + String environment = "login.microsoftonline.com"; + String clientId = "client-id"; + String realm = "tenant-id"; + + // Add account + AccountCacheEntity account = new AccountCacheEntity(); + account.homeAccountId(homeAccountId); + account.environment(environment); + account.realm(realm); + tokenCache.accounts.put(account.getKey(), account); + + // Add access token with refreshOn + long futureExpiry = (System.currentTimeMillis() / 1000) + 3600; + long refreshOn = (System.currentTimeMillis() / 1000) + 1800; + + AccessTokenCacheEntity at = new AccessTokenCacheEntity(); + at.homeAccountId(homeAccountId); + at.environment(environment); + at.clientId(clientId); + at.credentialType(CredentialTypeEnum.ACCESS_TOKEN.value()); + at.realm(realm); + at.target("scope"); + at.secret("access-token-secret"); + at.cachedAt(Long.toString(System.currentTimeMillis() / 1000)); + at.expiresOn(Long.toString(futureExpiry)); + at.refreshOn(Long.toString(refreshOn)); + tokenCache.accessTokens.put(at.getKey(), at); + + IAccount iAccount = new Account(homeAccountId, environment, "user", null); + Authority authority = new AADAuthority(new URL("https://login.microsoftonline.com/tenant-id/")); + + AuthenticationResult result = tokenCache.getCachedAuthenticationResult( + iAccount, authority, Collections.singleton("scope"), clientId); + + assertEquals("access-token-secret", result.accessToken()); + assertEquals(refreshOn, result.refreshOn()); + } + + @Test + void getCachedAuthenticationResult_NoAccessToken_FallsBackToAuthorityHost() throws Exception { + String homeAccountId = "home-id"; + String environment = "login.microsoftonline.com"; + String clientId = "client-id"; + String realm = "tenant-id"; + + // Add account but no access token + AccountCacheEntity account = new AccountCacheEntity(); + account.homeAccountId(homeAccountId); + account.environment(environment); + account.realm(realm); + tokenCache.accounts.put(account.getKey(), account); + + IAccount iAccount = new Account(homeAccountId, environment, "user", null); + Authority authority = new AADAuthority(new URL("https://login.microsoftonline.com/tenant-id/")); + + AuthenticationResult result = tokenCache.getCachedAuthenticationResult( + iAccount, authority, Collections.singleton("scope"), clientId); + + // No AT in cache, so environment should come from authority host + assertNull(result.accessToken()); + assertEquals("login.microsoftonline.com", result.environment()); + } + + @Test + void getCachedAuthenticationResult_AssertionBased_WithIdTokenAndRefreshToken() throws Exception { + String environment = "login.microsoftonline.com"; + String clientId = "client-id"; + String realm = "tenant-id"; + + // Create the assertion and get its computed hash + UserAssertion assertion = new UserAssertion(TestHelper.signedAssertion); + String userAssertionHash = assertion.getAssertionHash(); + + // Add access token with assertion hash + long futureExpiry = (System.currentTimeMillis() / 1000) + 3600; + AccessTokenCacheEntity at = new AccessTokenCacheEntity(); + at.environment(environment); + at.clientId(clientId); + at.credentialType(CredentialTypeEnum.ACCESS_TOKEN.value()); + at.realm(realm); + at.target("scope"); + at.secret("at-with-assertion"); + at.cachedAt(Long.toString(System.currentTimeMillis() / 1000)); + at.expiresOn(Long.toString(futureExpiry)); + at.userAssertionHash(userAssertionHash); + tokenCache.accessTokens.put(at.getKey(), at); + + // Add ID token with assertion hash + IdTokenCacheEntity idToken = new IdTokenCacheEntity(); + idToken.environment(environment); + idToken.clientId(clientId); + idToken.credentialType(CredentialTypeEnum.ID_TOKEN.value()); + idToken.realm(realm); + idToken.secret("id-token-secret"); + idToken.userAssertionHash(userAssertionHash); + tokenCache.idTokens.put(idToken.getKey(), idToken); + + // Add refresh token with assertion hash + RefreshTokenCacheEntity rt = new RefreshTokenCacheEntity(); + rt.environment(environment); + rt.clientId(clientId); + rt.credentialType(CredentialTypeEnum.REFRESH_TOKEN.value()); + rt.secret("rt-with-assertion"); + rt.userAssertionHash(userAssertionHash); + tokenCache.refreshTokens.put(rt.getKey(), rt); + + Authority authority = new AADAuthority(new URL("https://login.microsoftonline.com/tenant-id/")); + + AuthenticationResult result = tokenCache.getCachedAuthenticationResult( + authority, Collections.singleton("scope"), clientId, assertion); + + assertEquals("at-with-assertion", result.accessToken()); + assertEquals("id-token-secret", result.idToken()); + assertEquals("rt-with-assertion", result.refreshToken()); + } + + // --- Serialize with merge behavior tests --- + + @Test + void serialize_WithSnapshot_MergesWithExistingData() { + // Set up a cache with initial data, then serialize to create a snapshot + AccountCacheEntity account1 = new AccountCacheEntity(); + account1.homeAccountId("account-1"); + account1.environment("login.microsoftonline.com"); + account1.realm("tenant-1"); + tokenCache.accounts.put(account1.getKey(), account1); + + // Deserialize from an existing snapshot to set serializedCachedSnapshot + String snapshot = tokenCache.serialize(); + + TokenCache cacheWithSnapshot = new TokenCache(); + cacheWithSnapshot.deserialize(snapshot); + + // Add new data to the cache (simulating new token acquisition) + AccountCacheEntity account2 = new AccountCacheEntity(); + account2.homeAccountId("account-2"); + account2.environment("login.microsoftonline.com"); + account2.realm("tenant-2"); + cacheWithSnapshot.accounts.put(account2.getKey(), account2); + + // Serialize — should merge new data with snapshot + String mergedSerialized = cacheWithSnapshot.serialize(); + + // Verify both accounts are in the merged result + TokenCache verifyCache = new TokenCache(); + verifyCache.deserialize(mergedSerialized); + assertEquals(2, verifyCache.accounts.size()); + } +} diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/CacheTests.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/CacheTests.java deleted file mode 100644 index ca2cf4f2e..000000000 --- a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/CacheTests.java +++ /dev/null @@ -1,329 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -package com.microsoft.aad.msal4j; - -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; - -import java.util.Collections; -import java.util.HashMap; -import java.util.Set; - -import static org.junit.jupiter.api.Assertions.*; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.junit.jupiter.api.Assertions.fail; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -class CacheTests { - - private TokenCache tokenCache; - - @BeforeEach - void setUp() { - tokenCache = new TokenCache(); - } - - @Test - void cacheLookup_MixAccountBasedAndAssertionBasedSilentFlows() throws Exception { - DefaultHttpClient httpClientMock = mock(DefaultHttpClient.class); - - ConfidentialClientApplication cca = - ConfidentialClientApplication.builder("clientId", ClientCredentialFactory.createFromSecret("password")) - .authority("https://login.microsoftonline.com/tenant/") - .instanceDiscovery(false) - .validateAuthority(false) - .httpClient(httpClientMock) - .build(); - - HashMap responseParameters = new HashMap<>(); - //Acquire a token with no ID token/account associated with it - responseParameters.put("access_token", "accessTokenNoAccount"); - - ClientCredentialParameters clientCredentialParameters = ClientCredentialParameters.builder(Collections.singleton("someScopes")).build(); - when(httpClientMock.send(any(HttpRequest.class))).thenReturn(TestHelper.expectedResponse(HttpStatus.HTTP_OK, TestHelper.getSuccessfulTokenResponse(responseParameters))); - IAuthenticationResult resultNoAccount = cca.acquireToken(clientCredentialParameters).get(); - - //Ensure there is one token in the cache, and the result had no account - assertEquals(1, cca.tokenCache.accessTokens.size()); - assertNull(resultNoAccount.account()); - verify(httpClientMock, times(1)).send(any()); - - //Acquire a second token, this time with an ID token/account - responseParameters.put("access_token", "accessTokenWithAccount"); - responseParameters.put("id_token", TestHelper.createIdToken(new HashMap<>())); - - when(httpClientMock.send(any(HttpRequest.class))).thenReturn(TestHelper.expectedResponse(HttpStatus.HTTP_OK, TestHelper.getSuccessfulTokenResponse(responseParameters))); - OnBehalfOfParameters onBehalfOfParametersarameters = OnBehalfOfParameters.builder(Collections.singleton("someOtherScopes"), new UserAssertion(TestHelper.signedAssertion)).build(); - IAuthenticationResult resultWithAccount = cca.acquireToken(onBehalfOfParametersarameters).get(); - - //Ensure there are now two tokens in the cache, and the result has an account - assertEquals(2, cca.tokenCache.accessTokens.size()); - assertNull(resultNoAccount.account()); - verify(httpClientMock, times(2)).send(any()); - - //Make two silent calls, one with the account and one without - SilentParameters silentParametersNoAccount = SilentParameters.builder(Collections.singleton("someScopes")).build(); - SilentParameters silentParametersWithAccount = SilentParameters.builder(Collections.singleton("someOtherScopes"), resultWithAccount.account()).build(); - - resultNoAccount = cca.acquireTokenSilently(silentParametersNoAccount).get(); - resultWithAccount = cca.acquireTokenSilently(silentParametersWithAccount).get(); - - //Ensure the correct access tokens were returned from each silent call - assertEquals("accessTokenNoAccount", resultNoAccount.accessToken()); - assertEquals("accessTokenWithAccount", resultWithAccount.accessToken()); - } - - @Test - void serializeDeserialize_EmptyCache_Success() { - // Serialize empty cache - String serializedCache = tokenCache.serialize(); - - // Should have all required sections but be empty - assertTrue(serializedCache.contains("\"Account\":{}")); - assertTrue(serializedCache.contains("\"AccessToken\":{}")); - assertTrue(serializedCache.contains("\"RefreshToken\":{}")); - assertTrue(serializedCache.contains("\"IdToken\":{}")); - assertTrue(serializedCache.contains("\"AppMetadata\":{}")); - - // Create new cache and deserialize - TokenCache newCache = new TokenCache(); - newCache.deserialize(serializedCache); - - // Verify all collections are empty - assertTrue(newCache.accessTokens.isEmpty()); - assertTrue(newCache.refreshTokens.isEmpty()); - assertTrue(newCache.idTokens.isEmpty()); - assertTrue(newCache.accounts.isEmpty()); - assertTrue(newCache.appMetadata.isEmpty()); - } - - @Test - void serializeDeserialize_WithAccountData_PreservesData() { - // Add an account to the cache - AccountCacheEntity account = new AccountCacheEntity(); - account.homeAccountId("home-account-id"); - account.environment("login.microsoftonline.com"); - account.realm("tenant-id"); - account.localAccountId("local-id"); - account.username("user@example.com"); - account.name("Test User"); - - tokenCache.accounts.put(account.getKey(), account); - - // Serialize the cache - String serializedCache = tokenCache.serialize(); - - // Create new cache and deserialize - TokenCache newCache = new TokenCache(); - newCache.deserialize(serializedCache); - - // Verify account was preserved - assertEquals(1, newCache.accounts.size()); - AccountCacheEntity deserializedAccount = newCache.accounts.get(account.getKey()); - assertNotNull(deserializedAccount); - assertEquals("home-account-id", deserializedAccount.homeAccountId()); - assertEquals("login.microsoftonline.com", deserializedAccount.environment()); - assertEquals("tenant-id", deserializedAccount.realm()); - assertEquals("user@example.com", deserializedAccount.username()); - assertEquals("Test User", deserializedAccount.name()); - } - - @Test - void removeAccount_RemovesAllRelatedEntities() { - // Setup test data - String homeAccountId = "home-account-id"; - String environment = "login.microsoftonline.com"; - String clientId = "client-id"; - String realm = "tenant-id"; - String username = "user@example.com"; - - // Create account - AccountCacheEntity account = new AccountCacheEntity(); - account.homeAccountId(homeAccountId); - account.environment(environment); - account.realm(realm); - account.username(username); - tokenCache.accounts.put(account.getKey(), account); - - // Create access token - AccessTokenCacheEntity accessToken = new AccessTokenCacheEntity(); - accessToken.homeAccountId(homeAccountId); - accessToken.environment(environment); - accessToken.clientId(clientId); - accessToken.realm(realm); - accessToken.target("scope1 scope2"); - accessToken.secret("access-token-secret"); - accessToken.cachedAt("1600000000"); - accessToken.expiresOn("1600100000"); - tokenCache.accessTokens.put(accessToken.getKey(), accessToken); - - // Create refresh token - RefreshTokenCacheEntity refreshToken = new RefreshTokenCacheEntity(); - refreshToken.homeAccountId(homeAccountId); - refreshToken.environment(environment); - refreshToken.clientId(clientId); - refreshToken.secret("refresh-token-secret"); - tokenCache.refreshTokens.put(refreshToken.getKey(), refreshToken); - - // Create ID token - IdTokenCacheEntity idToken = new IdTokenCacheEntity(); - idToken.homeAccountId(homeAccountId); - idToken.environment(environment); - idToken.clientId(clientId); - idToken.realm(realm); - idToken.secret("id-token-secret"); - tokenCache.idTokens.put(idToken.getKey(), idToken); - - // Remove the account - tokenCache.removeAccount(clientId, new Account(homeAccountId, environment, username, null)); - - // Verify all related entities are removed - assertTrue(tokenCache.accounts.isEmpty()); - assertTrue(tokenCache.accessTokens.isEmpty()); - assertTrue(tokenCache.refreshTokens.isEmpty()); - assertTrue(tokenCache.idTokens.isEmpty()); - } - - @Test - void removeAccount_WithMultipleAccounts_OnlyRemovesSpecificAccount() { - // Create two accounts with different homeAccountIds - String homeAccountId1 = "home-account-id-1"; - String homeAccountId2 = "home-account-id-2"; - String environment = "login.microsoftonline.com"; - String clientId = "client-id"; - String username = "user@example.com"; - - // Account 1 - AccountCacheEntity account1 = new AccountCacheEntity(); - account1.homeAccountId(homeAccountId1); - account1.environment(environment); - tokenCache.accounts.put(account1.getKey(), account1); - - // Account 2 - AccountCacheEntity account2 = new AccountCacheEntity(); - account2.homeAccountId(homeAccountId2); - account2.environment(environment); - tokenCache.accounts.put(account2.getKey(), account2); - - // Add tokens for both accounts - AccessTokenCacheEntity accessToken1 = new AccessTokenCacheEntity(); - accessToken1.homeAccountId(homeAccountId1); - accessToken1.environment(environment); - accessToken1.clientId(clientId); - accessToken1.expiresOn("1600100000"); - tokenCache.accessTokens.put(accessToken1.getKey(), accessToken1); - - AccessTokenCacheEntity accessToken2 = new AccessTokenCacheEntity(); - accessToken2.homeAccountId(homeAccountId2); - accessToken2.environment(environment); - accessToken2.clientId(clientId); - accessToken2.expiresOn("1600100000"); - tokenCache.accessTokens.put(accessToken2.getKey(), accessToken2); - - // Remove account1 - tokenCache.removeAccount(clientId, new Account(homeAccountId1, environment, username, null)); - - // Verify only account1 and its tokens are removed - assertEquals(1, tokenCache.accounts.size()); - assertEquals(1, tokenCache.accessTokens.size()); - assertTrue(tokenCache.accounts.containsKey(account2.getKey())); - assertFalse(tokenCache.accounts.containsKey(account1.getKey())); - } - - @Test - void mergeCache_PreservesRemovals() { - // Setup initial cache with two accounts - String homeAccountId1 = "home-account-id-1"; - String homeAccountId2 = "home-account-id-2"; - String environment = "login.microsoftonline.com"; - String clientId = "client-id"; - String username = "user@example.com"; - - // Account 1 - AccountCacheEntity account1 = new AccountCacheEntity(); - account1.homeAccountId(homeAccountId1); - account1.environment(environment); - tokenCache.accounts.put(account1.getKey(), account1); - - // Account 2 - AccountCacheEntity account2 = new AccountCacheEntity(); - account2.homeAccountId(homeAccountId2); - account2.environment(environment); - tokenCache.accounts.put(account2.getKey(), account2); - - // Serialize the cache with both accounts - String serializedWithTwoAccounts = tokenCache.serialize(); - - // Create a new cache with this serialized data - TokenCache deserializedCache = new TokenCache(); - deserializedCache.deserialize(serializedWithTwoAccounts); - assertEquals(2, deserializedCache.accounts.size()); - - // Now remove account1 from the original cache - tokenCache.removeAccount(clientId, new Account(homeAccountId1, environment, username, null)); - - // Serialize the cache with just one account - String serializedWithOneAccount = tokenCache.serialize(); - - // Deserialize into a new cache - TokenCache finalCache = new TokenCache(); - finalCache.deserialize(serializedWithOneAccount); - - // Verify only one account exists - assertEquals(1, finalCache.accounts.size()); - assertTrue(finalCache.accounts.containsKey(account2.getKey())); - assertFalse(finalCache.accounts.containsKey(account1.getKey())); - } - - @Test - void getAccounts_ReturnsCorrectAccounts() { - // Setup multiple accounts in the cache - String homeAccountId1 = "home-account-id-1"; - String localAccountId1 = "local-id-1"; - String homeAccountId2 = "home-account-id-2"; - String environment = "login.microsoftonline.com"; - String clientId = "client-id"; - String realm = "tenant-id"; - - // Account 1 - AccountCacheEntity account1 = new AccountCacheEntity(); - account1.homeAccountId(homeAccountId1); - account1.localAccountId(localAccountId1); - account1.environment(environment); - account1.realm(realm); - account1.username("user1@example.com"); - tokenCache.accounts.put(account1.getKey(), account1); - - // Account 2 - AccountCacheEntity account2 = new AccountCacheEntity(); - account2.homeAccountId(homeAccountId2); - account2.environment(environment); - account2.realm(realm); - account2.username("user2@example.com"); - tokenCache.accounts.put(account2.getKey(), account2); - - // Get accounts - Set accounts = tokenCache.getAccounts(clientId); - - // Verify correct accounts are returned - assertEquals(2, accounts.size()); - - // Verify account details - for (IAccount account : accounts) { - if (account.homeAccountId().equals(homeAccountId1)) { - assertEquals("user1@example.com", account.username()); - } else if (account.homeAccountId().equals(homeAccountId2)) { - assertEquals("user2@example.com", account.username()); - } else { - fail("Unexpected account returned"); - } - } - } -} diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/ClientCertificatePkcs12Test.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/ClientCertificatePkcs12Test.java index 5af514ac2..5dd5a8d83 100644 --- a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/ClientCertificatePkcs12Test.java +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/ClientCertificatePkcs12Test.java @@ -5,7 +5,6 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.TestInstance; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -21,7 +20,6 @@ import java.util.Collections; @ExtendWith(MockitoExtension.class) -@TestInstance(TestInstance.Lifecycle.PER_CLASS) class ClientCertificatePkcs12Test { private KeyStoreSpi keyStoreSpi; diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/ClientCertificateTest.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/ClientCertificateTest.java index ba9c34efd..3398e64e0 100644 --- a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/ClientCertificateTest.java +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/ClientCertificateTest.java @@ -5,9 +5,8 @@ import com.nimbusds.jwt.SignedJWT; import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.TestInstance; - import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; @@ -20,11 +19,11 @@ import java.security.*; import java.security.cert.CertificateException; import java.security.interfaces.RSAPrivateKey; +import java.security.cert.X509Certificate; import java.util.*; import java.net.URLDecoder; import java.nio.charset.StandardCharsets; -@TestInstance(TestInstance.Lifecycle.PER_CLASS) class ClientCertificateTest { @Test @@ -338,4 +337,84 @@ public List getEncodedPublicKeyCertificateChain() { return Collections.emptyList(); } } + + // ========== ClientCertificate: SHA-1 Hash ========== + + @Test + void testPublicCertificateHash_Sha1() throws Exception { + IClientCertificate cert = ClientCredentialFactory.createFromCertificate( + TestHelper.getPrivateKey(), TestHelper.getX509Cert()); + + String sha1Hash = cert.publicCertificateHash(); + + assertNotNull(sha1Hash, "SHA-1 hash should not be null"); + assertFalse(sha1Hash.isEmpty(), "SHA-1 hash should not be empty"); + // Base64-encoded SHA-1 is 28 characters + assertEquals(28, sha1Hash.length(), "Base64-encoded SHA-1 should be 28 chars"); + } + + @Test + void testPublicCertificateHash_Sha256DiffersFromSha1() throws Exception { + IClientCertificate cert = ClientCredentialFactory.createFromCertificate( + TestHelper.getPrivateKey(), TestHelper.getX509Cert()); + + String sha1Hash = cert.publicCertificateHash(); + String sha256Hash = cert.publicCertificateHash256(); + + assertNotEquals(sha1Hash, sha256Hash, + "SHA-1 and SHA-256 hashes should be different"); + } + + // ========== ClientCertificate: Certificate Chain Encoding ========== + + @Test + void testGetEncodedPublicKeyCertificateChain_singleCert() throws Exception { + ClientCertificate cert = ClientCertificate.create( + TestHelper.getPrivateKey(), TestHelper.getX509Cert()); + + List chain = cert.getEncodedPublicKeyCertificateChain(); + + assertNotNull(chain); + assertEquals(1, chain.size(), "Single cert should produce chain of length 1"); + assertFalse(chain.get(0).isEmpty(), "Encoded cert should not be empty"); + } + + @Test + void testGetEncodedPublicKeyCertificateChain_multiCert() throws Exception { + // Create a chain with the same cert repeated (simulates a CA chain) + List certChain = Arrays.asList( + TestHelper.getX509Cert(), TestHelper.getX509Cert()); + ClientCertificate cert = new ClientCertificate(TestHelper.getPrivateKey(), certChain); + + List chain = cert.getEncodedPublicKeyCertificateChain(); + + assertEquals(2, chain.size(), "Chain with 2 certs should produce 2 encoded entries"); + } + + // ========== ClientCertificate: getAssertion ========== + + @Test + void testGetAssertion_nullAuthority_throwsNullPointerException() { + ClientCertificate cert = ClientCertificate.create( + TestHelper.getPrivateKey(), TestHelper.getX509Cert()); + + assertThrows(NullPointerException.class, + () -> cert.getAssertion(null, "client-id", false)); + } + + @Test + void testGetAssertion_aadAuthority_usesSha256() throws Exception { + ClientCertificate cert = ClientCertificate.create( + TestHelper.getPrivateKey(), TestHelper.getX509Cert()); + + Authority authority = Authority.createAuthority( + new java.net.URL("https://login.microsoftonline.com/tenant/")); + + String assertion = cert.getAssertion(authority, "client-id", false); + + assertNotNull(assertion, "Assertion should not be null"); + // Verify it's a valid JWT (3 dot-separated parts) + String[] parts = assertion.split("\\."); + assertEquals(3, parts.length, "JWT assertion should have 3 parts"); + } } diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/ClientCredentialTest.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/ClientCredentialTest.java index 5c8187570..b4fbf27a6 100644 --- a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/ClientCredentialTest.java +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/ClientCredentialTest.java @@ -7,11 +7,13 @@ import java.util.HashMap; import java.util.concurrent.Callable; import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Function; import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.TestInstance; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; @@ -20,7 +22,6 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; -@TestInstance(TestInstance.Lifecycle.PER_CLASS) class ClientCredentialTest { @Test @@ -47,46 +48,34 @@ void testSecretNullAndEmpty() { } @Test - void OnBehalfOf_InternalCacheLookup_Success() throws Exception { + void clientCredential_InternalCacheLookup_Success() throws Exception { DefaultHttpClient httpClientMock = mock(DefaultHttpClient.class); - when(httpClientMock.send(any(HttpRequest.class))).thenReturn(TestHelper.expectedResponse(HttpStatus.HTTP_OK, TestHelper.getSuccessfulTokenResponse(new HashMap<>()))); + TestHelper.mockSuccessfulTokenResponse(httpClientMock); - ConfidentialClientApplication cca = - ConfidentialClientApplication.builder("clientId", ClientCredentialFactory.createFromSecret("password")) - .authority("https://login.microsoftonline.com/tenant/") - .instanceDiscovery(false) - .validateAuthority(false) - .httpClient(httpClientMock) - .build(); + ConfidentialClientApplication cca = TestHelper.buildCca(httpClientMock); - ClientCredentialParameters parameters = ClientCredentialParameters.builder(Collections.singleton("scopes")).build(); + ClientCredentialParameters parameters = ClientCredentialParameters.builder(TestHelper.TEST_SCOPE_SET).build(); IAuthenticationResult result = cca.acquireToken(parameters).get(); IAuthenticationResult result2 = cca.acquireToken(parameters).get(); - //OBO flow should perform an internal cache lookup, so similar parameters should only cause one HTTP client call + //Client credential flow should perform an internal cache lookup, so similar parameters should only cause one HTTP client call assertEquals(result.accessToken(), result2.accessToken()); verify(httpClientMock, times(1)).send(any()); } @Test - void OnBehalfOf_TenantOverride() throws Exception { + void clientCredential_TenantOverride() throws Exception { DefaultHttpClient httpClientMock = mock(DefaultHttpClient.class); - ConfidentialClientApplication cca = - ConfidentialClientApplication.builder("clientId", ClientCredentialFactory.createFromSecret("password")) - .authority("https://login.microsoftonline.com/tenant") - .instanceDiscovery(false) - .validateAuthority(false) - .httpClient(httpClientMock) - .build(); + ConfidentialClientApplication cca = TestHelper.buildCca(httpClientMock); HashMap tokenResponseValues = new HashMap<>(); tokenResponseValues.put("access_token", "accessTokenFirstCall"); - when(httpClientMock.send(any(HttpRequest.class))).thenReturn(TestHelper.expectedResponse(HttpStatus.HTTP_OK, TestHelper.getSuccessfulTokenResponse(tokenResponseValues))); - ClientCredentialParameters parameters = ClientCredentialParameters.builder(Collections.singleton("scopes")).build(); + TestHelper.mockSuccessfulTokenResponse(httpClientMock, tokenResponseValues); + ClientCredentialParameters parameters = ClientCredentialParameters.builder(TestHelper.TEST_SCOPE_SET).build(); //The two acquireToken calls have the same parameters... IAuthenticationResult resultAppLevelTenant = cca.acquireToken(parameters).get(); @@ -98,8 +87,8 @@ void OnBehalfOf_TenantOverride() throws Exception { tokenResponseValues.put("access_token", "accessTokenSecondCall"); - when(httpClientMock.send(any(HttpRequest.class))).thenReturn(TestHelper.expectedResponse(HttpStatus.HTTP_OK, TestHelper.getSuccessfulTokenResponse(tokenResponseValues))); - parameters = ClientCredentialParameters.builder(Collections.singleton("scopes")).tenant("otherTenant").build(); + TestHelper.mockSuccessfulTokenResponse(httpClientMock, tokenResponseValues); + parameters = ClientCredentialParameters.builder(TestHelper.TEST_SCOPE_SET).tenant("otherTenant").build(); //Overriding the tenant parameter in the request should lead to a new token call being made... IAuthenticationResult resultRequestLevelTenant = cca.acquireToken(parameters).get(); @@ -218,4 +207,252 @@ void acquireTokenClientCredentials_Callback() { assertNotEquals(assertion1, assertion2, "First and second assertions should be different"); assertNotEquals(assertion2, assertion3, "Second and third assertions should be different"); } + + // ========== ClientAssertion: Context-Aware Provider ========== + + @Test + void clientAssertion_contextAwareProvider_returnsAssertion() { + Function provider = options -> "context-assertion"; + ClientAssertion assertion = new ClientAssertion(provider); + + AssertionRequestOptions options = new AssertionRequestOptions("client-id", "https://endpoint", "/fmi"); + assertEquals("context-assertion", assertion.assertion(options)); + } + + @Test + void clientAssertion_contextAwareProvider_nullReturnThrows() { + Function provider = options -> null; + ClientAssertion assertion = new ClientAssertion(provider); + + AssertionRequestOptions options = new AssertionRequestOptions("client-id", "https://endpoint", null); + MsalClientException ex = assertThrows(MsalClientException.class, + () -> assertion.assertion(options)); + assertEquals(AuthenticationErrorCode.INVALID_JWT, ex.errorCode()); + } + + @Test + void clientAssertion_contextAwareProvider_emptyReturnThrows() { + Function provider = options -> ""; + ClientAssertion assertion = new ClientAssertion(provider); + + MsalClientException ex = assertThrows(MsalClientException.class, + () -> assertion.assertion(new AssertionRequestOptions(null, null, null))); + assertEquals(AuthenticationErrorCode.INVALID_JWT, ex.errorCode()); + } + + @Test + void clientAssertion_contextAwareProvider_exceptionWrapped() { + Function provider = options -> { + throw new RuntimeException("provider failed"); + }; + ClientAssertion assertion = new ClientAssertion(provider); + + MsalClientException ex = assertThrows(MsalClientException.class, + () -> assertion.assertion(new AssertionRequestOptions(null, null, null))); + assertTrue(ex.getCause() instanceof RuntimeException); + } + + @Test + void clientAssertion_contextAwareProvider_noArgAssertionDelegatesToOptions() { + // assertion() with no args should delegate to assertion(options) with empty options + Function provider = options -> "from-no-arg"; + ClientAssertion assertion = new ClientAssertion(provider); + + assertEquals("from-no-arg", assertion.assertion()); + } + + @Test + void clientAssertion_isContextAware_trueForFunctionProvider() { + Function provider = options -> "test"; + ClientAssertion assertion = new ClientAssertion(provider); + assertTrue(assertion.isContextAware()); + } + + @Test + void clientAssertion_isContextAware_falseForCallable() { + ClientAssertion assertion = new ClientAssertion((Callable) () -> "test"); + assertFalse(assertion.isContextAware()); + } + + @Test + void clientAssertion_isContextAware_falseForStaticString() { + ClientAssertion assertion = new ClientAssertion("static-assertion"); + assertFalse(assertion.isContextAware()); + } + + // ========== ClientAssertion: Callable Error Paths ========== + + @Test + void clientAssertion_callableReturnsNull_throwsMsalClientException() { + ClientAssertion assertion = new ClientAssertion((Callable) () -> null); + + MsalClientException ex = assertThrows(MsalClientException.class, assertion::assertion); + assertEquals(AuthenticationErrorCode.INVALID_JWT, ex.errorCode()); + } + + @Test + void clientAssertion_callableReturnsEmpty_throwsMsalClientException() { + ClientAssertion assertion = new ClientAssertion((Callable) () -> ""); + + MsalClientException ex = assertThrows(MsalClientException.class, assertion::assertion); + assertEquals(AuthenticationErrorCode.INVALID_JWT, ex.errorCode()); + } + + @Test + void clientAssertion_callableThrowsException_wrappedInMsalClientException() { + ClientAssertion assertion = new ClientAssertion((Callable) () -> { + throw new Exception("callable failed"); + }); + + MsalClientException ex = assertThrows(MsalClientException.class, assertion::assertion); + assertTrue(ex.getCause().getMessage().contains("callable failed")); + } + + @Test + void clientAssertion_nullCallable_throwsNullPointerException() { + assertThrows(NullPointerException.class, + () -> new ClientAssertion((Callable) null)); + } + + @Test + void clientAssertion_nullFunction_throwsNullPointerException() { + assertThrows(NullPointerException.class, + () -> new ClientAssertion((Function) null)); + } + + // ========== ClientAssertion: Equals & HashCode ========== + + @Test + void clientAssertion_equals_sameStaticAssertion() { + ClientAssertion a = new ClientAssertion("test-jwt"); + ClientAssertion b = new ClientAssertion("test-jwt"); + + assertEquals(a, b); + assertEquals(a.hashCode(), b.hashCode()); + } + + @Test + void clientAssertion_equals_differentStaticAssertion() { + ClientAssertion a = new ClientAssertion("jwt-1"); + ClientAssertion b = new ClientAssertion("jwt-2"); + + assertNotEquals(a, b); + } + + @Test + void clientAssertion_equals_sameCallable() { + Callable callable = () -> "test"; + ClientAssertion a = new ClientAssertion(callable); + ClientAssertion b = new ClientAssertion(callable); + + assertEquals(a, b); + assertEquals(a.hashCode(), b.hashCode()); + } + + @Test + void clientAssertion_equals_differentCallables() { + ClientAssertion a = new ClientAssertion((Callable) () -> "test"); + ClientAssertion b = new ClientAssertion((Callable) () -> "test"); + + // Different callable instances are compared by identity, so they're not equal + assertNotEquals(a, b); + } + + @Test + void clientAssertion_equals_sameContextAwareProvider() { + Function provider = opts -> "test"; + ClientAssertion a = new ClientAssertion(provider); + ClientAssertion b = new ClientAssertion(provider); + + assertEquals(a, b); + assertEquals(a.hashCode(), b.hashCode()); + } + + @Test + void clientAssertion_equals_differentContextAwareProviders() { + ClientAssertion a = new ClientAssertion((Function) opts -> "test"); + ClientAssertion b = new ClientAssertion((Function) opts -> "test"); + + assertNotEquals(a, b); + } + + @Test + void clientAssertion_equals_selfAndNull() { + ClientAssertion a = new ClientAssertion("jwt"); + assertEquals(a, a); + assertFalse(a.equals(null)); + assertFalse(a.equals("not-a-ClientAssertion")); + } + + // ========== ClientSecret: Equals & HashCode ========== + + @Test + void clientSecret_equals_sameSecret() { + ClientSecret a = new ClientSecret("secret-1"); + ClientSecret b = new ClientSecret("secret-1"); + + assertEquals(a, b); + assertEquals(a.hashCode(), b.hashCode()); + } + + @Test + void clientSecret_equals_differentSecret() { + ClientSecret a = new ClientSecret("secret-1"); + ClientSecret b = new ClientSecret("secret-2"); + + assertNotEquals(a, b); + } + + @Test + void clientSecret_equals_selfAndNull() { + ClientSecret a = new ClientSecret("secret"); + assertEquals(a, a); + assertFalse(a.equals(null)); + assertFalse(a.equals("not-a-ClientSecret")); + } + + // ========== ClientCredentialFactory ========== + + @Test + void clientCredentialFactory_createFromCertificateChain_validInput() { + ClientCertificate cert = ClientCertificate.create( + TestHelper.getPrivateKey(), TestHelper.getX509Cert()); + + assertNotNull(cert); + assertNotNull(cert.privateKey()); + } + + @Test + void clientCredentialFactory_createFromCertificateChain_nullKey() { + assertThrows(IllegalArgumentException.class, + () -> ClientCredentialFactory.createFromCertificateChain(null, + Collections.singletonList(TestHelper.getX509Cert()))); + } + + @Test + void clientCredentialFactory_createFromCertificateChain_nullChain() { + assertThrows(IllegalArgumentException.class, + () -> ClientCredentialFactory.createFromCertificateChain( + TestHelper.getPrivateKey(), null)); + } + + @Test + void clientCredentialFactory_createFromCertificateChain_emptyChain() { + assertThrows(IllegalArgumentException.class, + () -> ClientCredentialFactory.createFromCertificateChain( + TestHelper.getPrivateKey(), Collections.emptyList())); + } + + @Test + void clientCredentialFactory_createFromCallback_nullCallable() { + assertThrows(NullPointerException.class, + () -> ClientCredentialFactory.createFromCallback((Callable) null)); + } + + @Test + void clientCredentialFactory_createFromCallback_nullFunction() { + assertThrows(NullPointerException.class, + () -> ClientCredentialFactory.createFromCallback( + (Function) null)); + } } diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/DateTimeTests.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/DateTimeTest.java similarity index 99% rename from msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/DateTimeTests.java rename to msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/DateTimeTest.java index 4b3cce13e..7d682ba06 100644 --- a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/DateTimeTests.java +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/DateTimeTest.java @@ -13,7 +13,7 @@ import static org.junit.jupiter.api.Assertions.*; -class DateTimeTests { +class DateTimeTest { @Test void parseUnixTimestampInSeconds() { diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/FmiTest.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/FmiTest.java index 6ef00adf2..58abdf46f 100644 --- a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/FmiTest.java +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/FmiTest.java @@ -4,7 +4,6 @@ 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; @@ -21,7 +20,6 @@ * Covers fmi_path body parameter injection, cache key isolation via ext_cache_key, * and assertion context (AssertionRequestOptions) propagation. */ -@TestInstance(TestInstance.Lifecycle.PER_CLASS) class FmiTest { // ======================================================================== diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/HelperAndUtilityTests.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/HelperAndUtilityTest.java similarity index 99% rename from msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/HelperAndUtilityTests.java rename to msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/HelperAndUtilityTest.java index 08ca2e399..180d0a146 100644 --- a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/HelperAndUtilityTests.java +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/HelperAndUtilityTest.java @@ -14,7 +14,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.*; -class HelperAndUtilityTests { +class HelperAndUtilityTest { @Test void StringHelper_serializeQueryParameters_ValidUrlQueryStrings() { diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/HttpHeaderTest.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/HttpHeaderTest.java index 60ae060f7..3c5706596 100644 --- a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/HttpHeaderTest.java +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/HttpHeaderTest.java @@ -40,12 +40,12 @@ void testHttpHeaderConstructor() { Map httpHeaderMap = httpHeaders.getReadonlyHeaderMap(); - assertEquals(httpHeaderMap.get(HttpHeaders.PRODUCT_HEADER_NAME), HttpHeaders.PRODUCT_HEADER_VALUE); - assertEquals(httpHeaderMap.get(HttpHeaders.PRODUCT_VERSION_HEADER_NAME), HttpHeaders.PRODUCT_VERSION_HEADER_VALUE); - assertEquals(httpHeaderMap.get(HttpHeaders.OS_HEADER_NAME), HttpHeaders.OS_HEADER_VALUE); - assertEquals(httpHeaderMap.get(HttpHeaders.APPLICATION_NAME_HEADER_NAME), "app-name"); - assertEquals(httpHeaderMap.get(HttpHeaders.APPLICATION_VERSION_HEADER_NAME), "app-version"); - assertEquals(httpHeaderMap.get(HttpHeaders.CORRELATION_ID_HEADER_NAME), "correlation-id"); + assertEquals(HttpHeaders.PRODUCT_HEADER_VALUE, httpHeaderMap.get(HttpHeaders.PRODUCT_HEADER_NAME)); + assertEquals(HttpHeaders.PRODUCT_VERSION_HEADER_VALUE, httpHeaderMap.get(HttpHeaders.PRODUCT_VERSION_HEADER_NAME)); + assertEquals(HttpHeaders.OS_HEADER_VALUE, httpHeaderMap.get(HttpHeaders.OS_HEADER_NAME)); + assertEquals("app-name", httpHeaderMap.get(HttpHeaders.APPLICATION_NAME_HEADER_NAME)); + assertEquals("app-version", httpHeaderMap.get(HttpHeaders.APPLICATION_VERSION_HEADER_NAME)); + assertEquals("correlation-id", httpHeaderMap.get(HttpHeaders.CORRELATION_ID_HEADER_NAME)); } @Test @@ -66,9 +66,9 @@ void testHttpHeaderConstructor_valuesNotSet() { Map httpHeaderMap = httpHeaders.getReadonlyHeaderMap(); - assertEquals(httpHeaderMap.get(HttpHeaders.PRODUCT_HEADER_NAME), HttpHeaders.PRODUCT_HEADER_VALUE); - assertEquals(httpHeaderMap.get(HttpHeaders.PRODUCT_VERSION_HEADER_NAME), HttpHeaders.PRODUCT_VERSION_HEADER_VALUE); - assertEquals(httpHeaderMap.get(HttpHeaders.OS_HEADER_NAME), HttpHeaders.OS_HEADER_VALUE); + assertEquals(HttpHeaders.PRODUCT_HEADER_VALUE, httpHeaderMap.get(HttpHeaders.PRODUCT_HEADER_NAME)); + assertEquals(HttpHeaders.PRODUCT_VERSION_HEADER_VALUE, httpHeaderMap.get(HttpHeaders.PRODUCT_VERSION_HEADER_NAME)); + assertEquals(HttpHeaders.OS_HEADER_VALUE, httpHeaderMap.get(HttpHeaders.OS_HEADER_NAME)); assertNull(httpHeaderMap.get(HttpHeaders.APPLICATION_NAME_HEADER_NAME)); assertNull(httpHeaderMap.get(HttpHeaders.APPLICATION_VERSION_HEADER_NAME)); assertNotNull(httpHeaderMap.get(HttpHeaders.CORRELATION_ID_HEADER_NAME)); @@ -97,7 +97,7 @@ void testHttpHeaderConstructor_userIdentifierUPN() { Map httpHeaderMap = httpHeaders.getReadonlyHeaderMap(); String expectedValue = String.format(HttpHeaders.X_ANCHOR_MAILBOX_UPN_FORMAT, upn); - assertEquals(httpHeaderMap.get(HttpHeaders.X_ANCHOR_MAILBOX), expectedValue); + assertEquals(expectedValue, httpHeaderMap.get(HttpHeaders.X_ANCHOR_MAILBOX)); } @Test @@ -122,7 +122,7 @@ void testHttpHeaderConstructor_userIdentifierHomeAccountId() { Map httpHeaderMap = httpHeaders.getReadonlyHeaderMap(); - assertEquals(httpHeaderMap.get(HttpHeaders.X_ANCHOR_MAILBOX), "oid:userObjectId@userTenantId"); + assertEquals("oid:userObjectId@userTenantId", httpHeaderMap.get(HttpHeaders.X_ANCHOR_MAILBOX)); } @Test @@ -158,16 +158,16 @@ void testHttpHeaderConstructor_extraHttpHeadersOverwriteLibraryHeaders() { Map httpHeaderMap = httpHeaders.getReadonlyHeaderMap(); // Standard headers - assertEquals(httpHeaderMap.get(HttpHeaders.PRODUCT_HEADER_NAME), HttpHeaders.PRODUCT_HEADER_VALUE); - assertEquals(httpHeaderMap.get(HttpHeaders.PRODUCT_VERSION_HEADER_NAME), HttpHeaders.PRODUCT_VERSION_HEADER_VALUE); - assertEquals(httpHeaderMap.get(HttpHeaders.OS_HEADER_NAME), HttpHeaders.OS_HEADER_VALUE); - assertEquals(httpHeaderMap.get(HttpHeaders.APPLICATION_VERSION_HEADER_NAME), "app-version"); - assertEquals(httpHeaderMap.get(HttpHeaders.CORRELATION_ID_HEADER_NAME), "correlation-id"); + assertEquals(HttpHeaders.PRODUCT_HEADER_VALUE, httpHeaderMap.get(HttpHeaders.PRODUCT_HEADER_NAME)); + assertEquals(HttpHeaders.PRODUCT_VERSION_HEADER_VALUE, httpHeaderMap.get(HttpHeaders.PRODUCT_VERSION_HEADER_NAME)); + assertEquals(HttpHeaders.OS_HEADER_VALUE, httpHeaderMap.get(HttpHeaders.OS_HEADER_NAME)); + assertEquals("app-version", httpHeaderMap.get(HttpHeaders.APPLICATION_VERSION_HEADER_NAME)); + assertEquals("correlation-id", httpHeaderMap.get(HttpHeaders.CORRELATION_ID_HEADER_NAME)); // Overwritten standard header - assertEquals(httpHeaderMap.get(HttpHeaders.APPLICATION_NAME_HEADER_NAME), uniqueAppName); + assertEquals(uniqueAppName, httpHeaderMap.get(HttpHeaders.APPLICATION_NAME_HEADER_NAME)); // Extra header - assertEquals(httpHeaderMap.get(uniqueHeaderKey), uniqueHeaderValue); + assertEquals(uniqueHeaderValue, httpHeaderMap.get(uniqueHeaderKey)); } } diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/InstanceDiscoveryParsingTest.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/InstanceDiscoveryParsingTest.java new file mode 100644 index 000000000..80ee135da --- /dev/null +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/InstanceDiscoveryParsingTest.java @@ -0,0 +1,192 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.aad.msal4j; + +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.util.Arrays; +import java.util.HashSet; + +import static org.junit.jupiter.api.Assertions.*; + +class InstanceDiscoveryParsingTest { + + // ========== AadInstanceDiscoveryResponse ========== + + @Test + void aadInstanceDiscoveryResponse_fromJson_allFields() throws IOException { + String json = "{" + + "\"tenant_discovery_endpoint\":\"https://login.microsoftonline.com/tenant/.well-known/openid-configuration\"," + + "\"metadata\":[{" + + "\"preferred_network\":\"login.microsoftonline.com\"," + + "\"preferred_cache\":\"login.windows.net\"," + + "\"aliases\":[\"login.microsoftonline.com\",\"login.windows.net\"]" + + "}]," + + "\"error_description\":null," + + "\"error_codes\":null," + + "\"error\":null," + + "\"correlation_id\":\"corr-123\"" + + "}"; + + AadInstanceDiscoveryResponse response = TestHelper.parseJson(json, AadInstanceDiscoveryResponse::fromJson); + + assertEquals("https://login.microsoftonline.com/tenant/.well-known/openid-configuration", + response.tenantDiscoveryEndpoint()); + assertNotNull(response.metadata()); + assertEquals(1, response.metadata().size()); + assertEquals("login.microsoftonline.com", response.metadata().get(0).preferredNetwork()); + assertEquals("login.windows.net", response.metadata().get(0).preferredCache()); + assertEquals("corr-123", response.correlationId()); + } + + @Test + void aadInstanceDiscoveryResponse_fromJson_errorResponse() throws IOException { + String json = "{" + + "\"error\":\"invalid_instance\"," + + "\"error_description\":\"AADSTS50049: Unknown or invalid instance.\"," + + "\"error_codes\":[50049]," + + "\"correlation_id\":\"corr-err\"" + + "}"; + + AadInstanceDiscoveryResponse response = TestHelper.parseJson(json, AadInstanceDiscoveryResponse::fromJson); + + assertEquals("invalid_instance", response.error()); + assertEquals("AADSTS50049: Unknown or invalid instance.", response.errorDescription()); + assertNotNull(response.errorCodes()); + assertEquals(1, response.errorCodes().size()); + assertEquals(50049L, response.errorCodes().get(0).longValue()); + assertEquals("corr-err", response.correlationId()); + assertNull(response.tenantDiscoveryEndpoint()); + assertNull(response.metadata()); + } + + @Test + void aadInstanceDiscoveryResponse_fromJson_multipleMetadataEntries() throws IOException { + String json = "{" + + "\"tenant_discovery_endpoint\":\"https://endpoint\"," + + "\"metadata\":[" + + "{\"preferred_network\":\"login.microsoftonline.com\",\"preferred_cache\":\"login.windows.net\",\"aliases\":[\"login.microsoftonline.com\"]}," + + "{\"preferred_network\":\"login.chinacloudapi.cn\",\"preferred_cache\":\"login.chinacloudapi.cn\",\"aliases\":[\"login.chinacloudapi.cn\"]}" + + "]" + + "}"; + + AadInstanceDiscoveryResponse response = TestHelper.parseJson(json, AadInstanceDiscoveryResponse::fromJson); + + assertEquals(2, response.metadata().size()); + assertEquals("login.microsoftonline.com", response.metadata().get(0).preferredNetwork()); + assertEquals("login.chinacloudapi.cn", response.metadata().get(1).preferredNetwork()); + } + + @Test + void aadInstanceDiscoveryResponse_fromJson_unknownFieldsSkipped() throws IOException { + String json = "{\"error\":\"test\",\"api-version\":\"1.1\",\"extra\":true}"; + + AadInstanceDiscoveryResponse response = TestHelper.parseJson(json, AadInstanceDiscoveryResponse::fromJson); + + assertEquals("test", response.error()); + } + + @Test + void aadInstanceDiscoveryResponse_toJson_roundTrip() throws IOException { + String json = "{" + + "\"tenant_discovery_endpoint\":\"https://endpoint\"," + + "\"metadata\":[{" + + "\"preferred_network\":\"login.microsoftonline.com\"," + + "\"preferred_cache\":\"login.windows.net\"," + + "\"aliases\":[\"login.microsoftonline.com\"]" + + "}]," + + "\"error\":null," + + "\"correlation_id\":\"c-1\"" + + "}"; + + AadInstanceDiscoveryResponse original = TestHelper.parseJson(json, AadInstanceDiscoveryResponse::fromJson); + String serialized = TestHelper.writeToJson(original); + + assertTrue(serialized.contains("tenant_discovery_endpoint")); + assertTrue(serialized.contains("login.microsoftonline.com")); + assertTrue(serialized.contains("correlation_id")); + } + + @Test + void aadInstanceDiscoveryResponse_getters_defaultNull() throws IOException { + String json = "{}"; + + AadInstanceDiscoveryResponse response = TestHelper.parseJson(json, AadInstanceDiscoveryResponse::fromJson); + + assertNull(response.tenantDiscoveryEndpoint()); + assertNull(response.metadata()); + assertNull(response.errorDescription()); + assertNull(response.errorCodes()); + assertNull(response.error()); + assertNull(response.correlationId()); + } + + // ========== InstanceDiscoveryMetadataEntry ========== + + @Test + void instanceDiscoveryMetadataEntry_fromJson_allFields() throws IOException { + String json = "{" + + "\"preferred_network\":\"login.microsoftonline.com\"," + + "\"preferred_cache\":\"login.windows.net\"," + + "\"aliases\":[\"login.microsoftonline.com\",\"login.windows.net\",\"login.microsoft.com\"]" + + "}"; + + InstanceDiscoveryMetadataEntry entry = TestHelper.parseJson(json, InstanceDiscoveryMetadataEntry::fromJson); + + assertEquals("login.microsoftonline.com", entry.preferredNetwork()); + assertEquals("login.windows.net", entry.preferredCache()); + assertEquals(3, entry.aliases().size()); + assertTrue(entry.aliases().contains("login.microsoftonline.com")); + assertTrue(entry.aliases().contains("login.windows.net")); + assertTrue(entry.aliases().contains("login.microsoft.com")); + } + + @Test + void instanceDiscoveryMetadataEntry_constructorAndGetters() { + HashSet aliases = new HashSet<>(Arrays.asList("host1", "host2")); + InstanceDiscoveryMetadataEntry entry = new InstanceDiscoveryMetadataEntry( + "preferred-net", "preferred-cache", aliases); + + assertEquals("preferred-net", entry.preferredNetwork()); + assertEquals("preferred-cache", entry.preferredCache()); + assertEquals(aliases, entry.aliases()); + } + + @Test + void instanceDiscoveryMetadataEntry_defaultConstructor() { + InstanceDiscoveryMetadataEntry entry = new InstanceDiscoveryMetadataEntry(); + + assertNull(entry.preferredNetwork()); + assertNull(entry.preferredCache()); + assertNull(entry.aliases()); + } + + @Test + void instanceDiscoveryMetadataEntry_toJson_roundTrip() throws IOException { + String json = "{" + + "\"preferred_network\":\"login.microsoftonline.com\"," + + "\"preferred_cache\":\"login.windows.net\"," + + "\"aliases\":[\"login.microsoftonline.com\"]" + + "}"; + + InstanceDiscoveryMetadataEntry original = TestHelper.parseJson(json, InstanceDiscoveryMetadataEntry::fromJson); + String serialized = TestHelper.writeToJson(original); + + InstanceDiscoveryMetadataEntry roundTripped = TestHelper.parseJson(serialized, InstanceDiscoveryMetadataEntry::fromJson); + + assertEquals(original.preferredNetwork(), roundTripped.preferredNetwork()); + assertEquals(original.preferredCache(), roundTripped.preferredCache()); + assertEquals(original.aliases(), roundTripped.aliases()); + } + + @Test + void instanceDiscoveryMetadataEntry_fromJson_unknownFieldsSkipped() throws IOException { + String json = "{\"preferred_network\":\"host\",\"extra_field\":123}"; + + InstanceDiscoveryMetadataEntry entry = TestHelper.parseJson(json, InstanceDiscoveryMetadataEntry::fromJson); + + assertEquals("host", entry.preferredNetwork()); + } +} diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/JsonCompatibilityTests.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/JsonCompatibilityTest.java similarity index 98% rename from msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/JsonCompatibilityTests.java rename to msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/JsonCompatibilityTest.java index cc15ee94c..9c60f0aec 100644 --- a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/JsonCompatibilityTests.java +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/JsonCompatibilityTest.java @@ -12,14 +12,14 @@ //These tests were added to ensure the new usages of com.azure.json are functionally the same as the old usages of com.nimbusds packages. //Once we are confident in the new behavior these should no longer be necessary. -class JsonCompatibilityTests { +class JsonCompatibilityTest { //New style, using helper methods in JsonHelper that use com.azure.json private final Map newStyleParsedClaims = JsonHelper.parseJsonToMap(JsonHelper.getTokenPayloadClaims(TestHelper.ENCODED_JWT)); //Old style, using com.nimbusds methods private final Map oldStyleParsedClaims = JWTParser.parse(TestHelper.ENCODED_JWT).getJWTClaimsSet().getClaims(); - JsonCompatibilityTests() throws ParseException { + JsonCompatibilityTest() throws ParseException { } @Test diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/ManagedIdentityParsingTest.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/ManagedIdentityParsingTest.java new file mode 100644 index 000000000..ea39bb27c --- /dev/null +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/ManagedIdentityParsingTest.java @@ -0,0 +1,207 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.aad.msal4j; + +import org.junit.jupiter.api.Test; + +import java.io.IOException; + +import static org.junit.jupiter.api.Assertions.*; + +class ManagedIdentityParsingTest { + + // ========== ManagedIdentityResponse ========== + + @Test + void managedIdentityResponse_fromJson_allFields() throws IOException { + String json = "{" + + "\"token_type\":\"Bearer\"," + + "\"access_token\":\"eyJ0eXAiOiJKV1QiLCJhbGciOi...\"," + + "\"expires_on\":\"1717430400\"," + + "\"resource\":\"https://management.azure.com/\"," + + "\"client_id\":\"00000000-0000-0000-0000-000000000000\"" + + "}"; + + ManagedIdentityResponse response = TestHelper.parseJson(json, ManagedIdentityResponse::fromJson); + + assertEquals("Bearer", response.getTokenType()); + assertEquals("eyJ0eXAiOiJKV1QiLCJhbGciOi...", response.getAccessToken()); + assertEquals("1717430400", response.getExpiresOn()); + assertEquals("https://management.azure.com/", response.getResource()); + assertEquals("00000000-0000-0000-0000-000000000000", response.getClientId()); + } + + @Test + void managedIdentityResponse_fromJson_partialFields() throws IOException { + String json = "{\"access_token\":\"token\",\"expires_on\":\"123456\"}"; + + ManagedIdentityResponse response = TestHelper.parseJson(json, ManagedIdentityResponse::fromJson); + + assertEquals("token", response.getAccessToken()); + assertEquals("123456", response.getExpiresOn()); + assertNull(response.getTokenType()); + assertNull(response.getResource()); + assertNull(response.getClientId()); + } + + @Test + void managedIdentityResponse_fromJson_unknownFieldsSkipped() throws IOException { + String json = "{\"access_token\":\"tok\",\"extra_field\":\"ignored\",\"nested\":{\"a\":1}}"; + + ManagedIdentityResponse response = TestHelper.parseJson(json, ManagedIdentityResponse::fromJson); + + assertEquals("tok", response.getAccessToken()); + } + + @Test + void managedIdentityResponse_toJson_allFields() throws IOException { + ManagedIdentityResponse response = new ManagedIdentityResponse(); + response.tokenType = "Bearer"; + response.accessToken = "test-token"; + response.expiresOn = "9999999"; + response.resource = "https://vault.azure.net/"; + response.clientId = "client-123"; + + String output = TestHelper.writeToJson(response); + + assertTrue(output.contains("\"token_type\":\"Bearer\"")); + assertTrue(output.contains("\"access_token\":\"test-token\"")); + assertTrue(output.contains("\"expires_on\":\"9999999\"")); + assertTrue(output.contains("\"resource\":\"https://vault.azure.net/\"")); + assertTrue(output.contains("\"client_id\":\"client-123\"")); + } + + @Test + void managedIdentityResponse_toJson_roundTrip() throws IOException { + String json = "{" + + "\"token_type\":\"Bearer\"," + + "\"access_token\":\"round-trip-token\"," + + "\"expires_on\":\"12345\"," + + "\"resource\":\"https://api.example.com/\"," + + "\"client_id\":\"cid-456\"" + + "}"; + + ManagedIdentityResponse original = TestHelper.parseJson(json, ManagedIdentityResponse::fromJson); + String serialized = TestHelper.writeToJson(original); + ManagedIdentityResponse roundTripped = TestHelper.parseJson(serialized, ManagedIdentityResponse::fromJson); + + assertEquals(original.getTokenType(), roundTripped.getTokenType()); + assertEquals(original.getAccessToken(), roundTripped.getAccessToken()); + assertEquals(original.getExpiresOn(), roundTripped.getExpiresOn()); + assertEquals(original.getResource(), roundTripped.getResource()); + assertEquals(original.getClientId(), roundTripped.getClientId()); + } + + @Test + void managedIdentityResponse_defaultConstructor_allNull() { + ManagedIdentityResponse response = new ManagedIdentityResponse(); + + assertNull(response.getTokenType()); + assertNull(response.getAccessToken()); + assertNull(response.getExpiresOn()); + assertNull(response.getResource()); + assertNull(response.getClientId()); + } + + // ========== ManagedIdentityErrorResponse ========== + + @Test + void managedIdentityErrorResponse_fromJson_simpleError() throws IOException { + String json = "{" + + "\"error\":\"invalid_resource\"," + + "\"error_description\":\"The resource requested is invalid.\"," + + "\"message\":\"Identity not found\"," + + "\"correlationId\":\"corr-789\"" + + "}"; + + ManagedIdentityErrorResponse response = TestHelper.parseJson(json, ManagedIdentityErrorResponse::fromJson); + + assertEquals("invalid_resource", response.getError()); + assertEquals("The resource requested is invalid.", response.getErrorDescription()); + assertEquals("Identity not found", response.getMessage()); + assertEquals("corr-789", response.getCorrelationId()); + } + + @Test + void managedIdentityErrorResponse_fromJson_nestedErrorObject() throws IOException { + // Some MI endpoints return error as a nested JSON object with code/message + String json = "{" + + "\"error\":{\"code\":\"ManagedIdentityCredential\",\"message\":\"Managed identity unavailable\"}," + + "\"correlationId\":\"corr-nested\"" + + "}"; + + ManagedIdentityErrorResponse response = TestHelper.parseJson(json, ManagedIdentityErrorResponse::fromJson); + + assertEquals("ManagedIdentityCredential", response.getError()); + assertEquals("Managed identity unavailable", response.getMessage()); + assertEquals("corr-nested", response.getCorrelationId()); + } + + @Test + void managedIdentityErrorResponse_fromJson_errorAsString() throws IOException { + String json = "{\"error\":\"unauthorized_client\"}"; + + ManagedIdentityErrorResponse response = TestHelper.parseJson(json, ManagedIdentityErrorResponse::fromJson); + + assertEquals("unauthorized_client", response.getError()); + } + + @Test + void managedIdentityErrorResponse_fromJson_unknownFieldsSkipped() throws IOException { + String json = "{\"error\":\"test\",\"unknown_field\":42,\"nested_unknown\":{\"a\":1}}"; + + ManagedIdentityErrorResponse response = TestHelper.parseJson(json, ManagedIdentityErrorResponse::fromJson); + + assertEquals("test", response.getError()); + } + + @Test + void managedIdentityErrorResponse_fromJson_emptyObject() throws IOException { + String json = "{}"; + + ManagedIdentityErrorResponse response = TestHelper.parseJson(json, ManagedIdentityErrorResponse::fromJson); + + assertNull(response.getError()); + assertNull(response.getErrorDescription()); + assertNull(response.getMessage()); + assertNull(response.getCorrelationId()); + } + + @Test + void managedIdentityErrorResponse_toJson_allFields() throws IOException { + String json = "{" + + "\"message\":\"Identity not found\"," + + "\"correlationId\":\"c-1\"," + + "\"error\":\"not_found\"," + + "\"error_description\":\"desc\"" + + "}"; + + ManagedIdentityErrorResponse response = TestHelper.parseJson(json, ManagedIdentityErrorResponse::fromJson); + String output = TestHelper.writeToJson(response); + + assertTrue(output.contains("\"message\":\"Identity not found\"")); + assertTrue(output.contains("\"correlationId\":\"c-1\"")); + assertTrue(output.contains("\"error\":\"not_found\"")); + assertTrue(output.contains("\"error_description\":\"desc\"")); + } + + @Test + void managedIdentityErrorResponse_toJson_roundTrip() throws IOException { + String json = "{" + + "\"message\":\"msg\"," + + "\"correlationId\":\"corr\"," + + "\"error\":\"err\"," + + "\"error_description\":\"desc\"" + + "}"; + + ManagedIdentityErrorResponse original = TestHelper.parseJson(json, ManagedIdentityErrorResponse::fromJson); + String serialized = TestHelper.writeToJson(original); + ManagedIdentityErrorResponse roundTripped = TestHelper.parseJson(serialized, ManagedIdentityErrorResponse::fromJson); + + assertEquals(original.getError(), roundTripped.getError()); + assertEquals(original.getErrorDescription(), roundTripped.getErrorDescription()); + assertEquals(original.getMessage(), roundTripped.getMessage()); + assertEquals(original.getCorrelationId(), roundTripped.getCorrelationId()); + } +} diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/ManagedIdentityTests.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/ManagedIdentityTest.java similarity index 99% rename from msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/ManagedIdentityTests.java rename to msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/ManagedIdentityTest.java index f5a8ccc00..d0197cb8f 100644 --- a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/ManagedIdentityTests.java +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/ManagedIdentityTest.java @@ -37,7 +37,7 @@ @ExtendWith(MockitoExtension.class) @TestInstance(TestInstance.Lifecycle.PER_METHOD) -class ManagedIdentityTests { +class ManagedIdentityTest { private static final String EXPECTED_SKU = "MSAL.Java"; private static final String TEST_CORRELATION_ID = "00000000-0000-0000-0000-000000000001"; diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/MexParserTest.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/MexParserTest.java index 131357add..f988563cc 100644 --- a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/MexParserTest.java +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/MexParserTest.java @@ -48,8 +48,8 @@ void testMexParsingWs13() throws Exception { } BindingPolicy endpoint = MexParser.getWsTrustEndpointFromMexResponse(sb.toString(), false); - assertEquals(endpoint.getUrl(), - "https://msft.sts.microsoft.com/adfs/services/trust/13/usernamemixed"); + assertEquals("https://msft.sts.microsoft.com/adfs/services/trust/13/usernamemixed", + endpoint.getUrl()); } @Test @@ -69,7 +69,7 @@ void testMexParsingWs2005() throws Exception { } BindingPolicy endpoint = MexParser.getWsTrustEndpointFromMexResponse(sb .toString(), false); - assertEquals(endpoint.getUrl(), "https://msft.sts.microsoft.com/adfs/services/trust/2005/usernamemixed"); + assertEquals("https://msft.sts.microsoft.com/adfs/services/trust/2005/usernamemixed", endpoint.getUrl()); } @Test @@ -89,7 +89,7 @@ void testMexParsingIntegrated() throws Exception { } BindingPolicy endpoint = MexParser.getPolicyFromMexResponseForIntegrated(sb .toString(), false); - assertEquals(endpoint.getUrl(), - "https://msft.sts.microsoft.com/adfs/services/trust/13/windowstransport"); + assertEquals("https://msft.sts.microsoft.com/adfs/services/trust/13/windowstransport", + endpoint.getUrl()); } } \ No newline at end of file diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/MsalOauthAuthorizatonGrantTest.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/MsalOauthAuthorizationGrantTest.java similarity index 95% rename from msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/MsalOauthAuthorizatonGrantTest.java rename to msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/MsalOauthAuthorizationGrantTest.java index a8d8f3dea..278df934c 100644 --- a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/MsalOauthAuthorizatonGrantTest.java +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/MsalOauthAuthorizationGrantTest.java @@ -15,7 +15,7 @@ import java.util.Map; @TestInstance(TestInstance.Lifecycle.PER_CLASS) -class MsalOauthAuthorizatonGrantTest { +class MsalOauthAuthorizationGrantTest { @Test void testToParameters() { diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/OnBehalfOfTests.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/OnBehalfOfTest.java similarity index 62% rename from msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/OnBehalfOfTests.java rename to msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/OnBehalfOfTest.java index 5aeb9f279..0c0a34ba2 100644 --- a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/OnBehalfOfTests.java +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/OnBehalfOfTest.java @@ -3,7 +3,6 @@ package com.microsoft.aad.msal4j; -import java.util.Collections; import java.util.HashMap; import org.junit.jupiter.api.Test; @@ -15,26 +14,19 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) -class OnBehalfOfTests { +class OnBehalfOfTest { @Test void OnBehalfOf_InternalCacheLookup_Success() throws Exception { DefaultHttpClient httpClientMock = mock(DefaultHttpClient.class); - when(httpClientMock.send(any(HttpRequest.class))).thenReturn(TestHelper.expectedResponse(HttpStatus.HTTP_OK, TestHelper.getSuccessfulTokenResponse(new HashMap<>()))); + TestHelper.mockSuccessfulTokenResponse(httpClientMock); - ConfidentialClientApplication cca = - ConfidentialClientApplication.builder("clientId", ClientCredentialFactory.createFromSecret("password")) - .authority("https://login.microsoftonline.com/tenant/") - .instanceDiscovery(false) - .validateAuthority(false) - .httpClient(httpClientMock) - .build(); + ConfidentialClientApplication cca = TestHelper.buildCca(httpClientMock); - OnBehalfOfParameters parameters = OnBehalfOfParameters.builder(Collections.singleton("scopes"), new UserAssertion(TestHelper.signedAssertion)).build(); + OnBehalfOfParameters parameters = OnBehalfOfParameters.builder(TestHelper.TEST_SCOPE_SET, new UserAssertion(TestHelper.signedAssertion)).build(); IAuthenticationResult result = cca.acquireToken(parameters).get(); IAuthenticationResult result2 = cca.acquireToken(parameters).get(); @@ -48,19 +40,13 @@ void OnBehalfOf_InternalCacheLookup_Success() throws Exception { void OnBehalfOf_TenantOverride() throws Exception { DefaultHttpClient httpClientMock = mock(DefaultHttpClient.class); - ConfidentialClientApplication cca = - ConfidentialClientApplication.builder("clientId", ClientCredentialFactory.createFromSecret("password")) - .authority("https://login.microsoftonline.com/tenant") - .instanceDiscovery(false) - .validateAuthority(false) - .httpClient(httpClientMock) - .build(); + ConfidentialClientApplication cca = TestHelper.buildCca(httpClientMock); HashMap tokenResponseValues = new HashMap<>(); tokenResponseValues.put("access_token", "accessTokenFirstCall"); - when(httpClientMock.send(any(HttpRequest.class))).thenReturn(TestHelper.expectedResponse(HttpStatus.HTTP_OK, TestHelper.getSuccessfulTokenResponse(tokenResponseValues))); - OnBehalfOfParameters parameters = OnBehalfOfParameters.builder(Collections.singleton("scopes"), new UserAssertion(TestHelper.signedAssertion)).build(); + TestHelper.mockSuccessfulTokenResponse(httpClientMock, tokenResponseValues); + OnBehalfOfParameters parameters = OnBehalfOfParameters.builder(TestHelper.TEST_SCOPE_SET, new UserAssertion(TestHelper.signedAssertion)).build(); //The two acquireToken calls have the same parameters... IAuthenticationResult resultAppLevelTenant = cca.acquireToken(parameters).get(); @@ -72,8 +58,8 @@ void OnBehalfOf_TenantOverride() throws Exception { tokenResponseValues.put("access_token", "accessTokenSecondCall"); - when(httpClientMock.send(any(HttpRequest.class))).thenReturn(TestHelper.expectedResponse(HttpStatus.HTTP_OK, TestHelper.getSuccessfulTokenResponse(tokenResponseValues))); - parameters = OnBehalfOfParameters.builder(Collections.singleton("scopes"), new UserAssertion(TestHelper.signedAssertion)).tenant("otherTenant").build(); + TestHelper.mockSuccessfulTokenResponse(httpClientMock, tokenResponseValues); + parameters = OnBehalfOfParameters.builder(TestHelper.TEST_SCOPE_SET, new UserAssertion(TestHelper.signedAssertion)).tenant("otherTenant").build(); //Overriding the tenant parameter in the request should lead to a new token call being made... IAuthenticationResult resultRequestLevelTenant = cca.acquireToken(parameters).get(); diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/ParameterBuilderTest.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/ParameterBuilderTest.java new file mode 100644 index 000000000..b5ac5533f --- /dev/null +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/ParameterBuilderTest.java @@ -0,0 +1,882 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.aad.msal4j; + +import org.junit.jupiter.api.Test; + +import java.net.URI; +import java.util.Collections; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.function.Consumer; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Tests for all IAcquireTokenParameters builder classes. + * Each section covers: required params build, optional params, validation, and special logic. + */ +class ParameterBuilderTest { + + private static final Set SCOPES = Collections.singleton("User.Read"); + private static final String TENANT = "contoso.onmicrosoft.com"; + private static final Map EXTRA_HEADERS = Collections.singletonMap("x-custom", "value"); + private static final Map EXTRA_QUERY_PARAMS = Collections.singletonMap("param1", "value1"); + + // ========== DeviceCodeFlowParameters ========== + + @Test + void deviceCodeFlow_RequiredParams_GettersReturnExpected() { + Consumer consumer = dc -> {}; + + DeviceCodeFlowParameters params = DeviceCodeFlowParameters + .builder(SCOPES, consumer) + .build(); + + assertEquals(SCOPES, params.scopes()); + assertSame(consumer, params.deviceCodeConsumer()); + assertNull(params.claims()); + assertNull(params.extraHttpHeaders()); + assertNull(params.extraQueryParameters()); + assertNull(params.tenant()); + } + + @Test + void deviceCodeFlow_AllOptionalParams_GettersReturnExpected() { + Consumer consumer = dc -> {}; + ClaimsRequest claims = new ClaimsRequest(); + + DeviceCodeFlowParameters params = DeviceCodeFlowParameters + .builder(SCOPES, consumer) + .claims(claims) + .extraHttpHeaders(EXTRA_HEADERS) + .extraQueryParameters(EXTRA_QUERY_PARAMS) + .tenant(TENANT) + .build(); + + assertSame(claims, params.claims()); + assertEquals(EXTRA_HEADERS, params.extraHttpHeaders()); + assertEquals(EXTRA_QUERY_PARAMS, params.extraQueryParameters()); + assertEquals(TENANT, params.tenant()); + } + + @Test + void deviceCodeFlow_NullScopes_Throws() { + Consumer consumer = dc -> {}; + + assertThrows(IllegalArgumentException.class, () -> + DeviceCodeFlowParameters.builder(null, consumer)); + } + + @Test + void deviceCodeFlow_DeviceCodeConsumerValidation_ValidatesWrongField() { + // BUG: DeviceCodeFlowParameters.Builder.deviceCodeConsumer() validates scopes + // instead of the deviceCodeConsumer parameter (copy-paste bug on line 118). + // Since scopes was already set by builder(scopes, consumer), passing null consumer + // does NOT throw — it silently accepts null. + DeviceCodeFlowParameters params = DeviceCodeFlowParameters + .builder(SCOPES, dc -> {}) + .deviceCodeConsumer(null) + .build(); + + assertNull(params.deviceCodeConsumer()); + } + + // ========== IntegratedWindowsAuthenticationParameters ========== + + @Test + void iwa_RequiredParams_GettersReturnExpected() { + IntegratedWindowsAuthenticationParameters params = + IntegratedWindowsAuthenticationParameters + .builder(SCOPES, "user@contoso.com") + .build(); + + assertEquals(SCOPES, params.scopes()); + assertEquals("user@contoso.com", params.username()); + assertNull(params.claims()); + assertNull(params.extraHttpHeaders()); + assertNull(params.extraQueryParameters()); + assertNull(params.tenant()); + } + + @Test + void iwa_AllOptionalParams_GettersReturnExpected() { + ClaimsRequest claims = new ClaimsRequest(); + + IntegratedWindowsAuthenticationParameters params = + IntegratedWindowsAuthenticationParameters + .builder(SCOPES, "user@contoso.com") + .claims(claims) + .extraHttpHeaders(EXTRA_HEADERS) + .extraQueryParameters(EXTRA_QUERY_PARAMS) + .tenant(TENANT) + .build(); + + assertSame(claims, params.claims()); + assertEquals(EXTRA_HEADERS, params.extraHttpHeaders()); + assertEquals(EXTRA_QUERY_PARAMS, params.extraQueryParameters()); + assertEquals(TENANT, params.tenant()); + } + + @Test + void iwa_NullScopes_Throws() { + assertThrows(IllegalArgumentException.class, () -> + IntegratedWindowsAuthenticationParameters.builder(null, "user@contoso.com")); + } + + @Test + void iwa_BlankUsername_Throws() { + assertThrows(IllegalArgumentException.class, () -> + IntegratedWindowsAuthenticationParameters.builder(SCOPES, "")); + } + + // ========== AppTokenProviderParameters ========== + + @Test + void appTokenProvider_Constructor_GettersReturnExpected() { + Set scopes = Collections.singleton("https://graph.microsoft.com/.default"); + String correlationId = "corr-123"; + String claims = "{\"access_token\":{}}"; + String tenantId = "tenant-456"; + + AppTokenProviderParameters params = new AppTokenProviderParameters( + scopes, correlationId, claims, tenantId); + + assertEquals(scopes, params.getScopes()); + assertEquals(correlationId, params.getCorrelationId()); + assertEquals(claims, params.getClaims()); + assertEquals(tenantId, params.getTenantId()); + } + + @Test + void appTokenProvider_Setters_UpdateValues() { + AppTokenProviderParameters params = new AppTokenProviderParameters( + SCOPES, "corr-1", null, "tenant-1"); + + Set newScopes = Collections.singleton("Mail.Read"); + params.setScopes(newScopes); + params.setCorrelationId("corr-2"); + params.setClaims("new-claims"); + params.setTenantId("tenant-2"); + + assertEquals(newScopes, params.getScopes()); + assertEquals("corr-2", params.getCorrelationId()); + assertEquals("new-claims", params.getClaims()); + assertEquals("tenant-2", params.getTenantId()); + } + + // ========== PopParameters ========== + + @Test + void pop_ValidParams_GettersReturnExpected() throws Exception { + URI uri = new URI("https://graph.microsoft.com/v1.0/me"); + + PopParameters params = new PopParameters(HttpMethod.GET, uri, "test-nonce"); + + assertEquals(HttpMethod.GET, params.getHttpMethod()); + assertEquals(uri, params.getUri()); + assertEquals("test-nonce", params.getNonce()); + } + + @Test + void pop_NullHttpMethod_Throws() throws Exception { + URI uri = new URI("https://graph.microsoft.com/v1.0/me"); + + MsalClientException ex = assertThrows(MsalClientException.class, () -> + new PopParameters(null, uri, "nonce")); + assertTrue(ex.getMessage().contains("HTTP method")); + } + + @Test + void pop_NullUri_Throws() { + MsalClientException ex = assertThrows(MsalClientException.class, () -> + new PopParameters(HttpMethod.GET, null, "nonce")); + assertTrue(ex.getMessage().contains("HTTP method")); + } + + @Test + void pop_UriWithNullHost_Throws() throws Exception { + // A URI like "urn:example" has no host component + URI noHostUri = new URI("urn:example"); + assertNull(noHostUri.getHost()); + + MsalClientException ex = assertThrows(MsalClientException.class, () -> + new PopParameters(HttpMethod.GET, noHostUri, "nonce")); + assertTrue(ex.getMessage().contains("HTTP method")); + } + + @Test + void pop_NullNonce_DoesNotThrow() throws Exception { + URI uri = new URI("https://graph.microsoft.com/v1.0/me"); + + PopParameters params = new PopParameters(HttpMethod.POST, uri, null); + + assertEquals(HttpMethod.POST, params.getHttpMethod()); + assertNull(params.getNonce()); + } + + // ========== InteractiveRequestParameters ========== + + @Test + void interactive_RequiredParams_GettersReturnExpected() throws Exception { + URI redirectUri = new URI("http://localhost:8080"); + + InteractiveRequestParameters params = InteractiveRequestParameters + .builder(redirectUri) + .build(); + + assertEquals(redirectUri, params.redirectUri()); + assertNull(params.scopes()); + assertNull(params.claims()); + assertNull(params.prompt()); + assertNull(params.loginHint()); + assertNull(params.domainHint()); + assertNull(params.systemBrowserOptions()); + assertNull(params.claimsChallenge()); + assertNull(params.extraHttpHeaders()); + assertNull(params.extraQueryParameters()); + assertNull(params.tenant()); + assertEquals(120, params.httpPollingTimeoutInSeconds()); + assertFalse(params.instanceAware()); + assertEquals(0L, params.windowHandle()); + assertNull(params.proofOfPossession()); + } + + @Test + void interactive_AllOptionalParams_GettersReturnExpected() throws Exception { + URI redirectUri = new URI("http://localhost:8080"); + ClaimsRequest claims = new ClaimsRequest(); + SystemBrowserOptions browserOptions = SystemBrowserOptions.builder().build(); + + InteractiveRequestParameters params = InteractiveRequestParameters + .builder(redirectUri) + .scopes(SCOPES) + .claims(claims) + .prompt(Prompt.CONSENT) + .loginHint("user@contoso.com") + .domainHint("contoso.com") + .systemBrowserOptions(browserOptions) + .claimsChallenge("{\"access_token\":{}}") + .extraHttpHeaders(EXTRA_HEADERS) + .extraQueryParameters(EXTRA_QUERY_PARAMS) + .tenant(TENANT) + .httpPollingTimeoutInSeconds(60) + .instanceAware(true) + .windowHandle(12345L) + .build(); + + assertEquals(SCOPES, params.scopes()); + assertSame(claims, params.claims()); + assertEquals(Prompt.CONSENT, params.prompt()); + assertEquals("user@contoso.com", params.loginHint()); + assertEquals("contoso.com", params.domainHint()); + assertSame(browserOptions, params.systemBrowserOptions()); + assertEquals("{\"access_token\":{}}", params.claimsChallenge()); + assertEquals(EXTRA_HEADERS, params.extraHttpHeaders()); + assertEquals(EXTRA_QUERY_PARAMS, params.extraQueryParameters()); + assertEquals(TENANT, params.tenant()); + assertEquals(60, params.httpPollingTimeoutInSeconds()); + assertTrue(params.instanceAware()); + assertEquals(12345L, params.windowHandle()); + } + + @Test + void interactive_NullRedirectUri_Throws() { + assertThrows(IllegalArgumentException.class, () -> + InteractiveRequestParameters.builder(null)); + } + + @Test + void interactive_ProofOfPossession_CreatesPopParameters() throws Exception { + URI redirectUri = new URI("http://localhost:8080"); + URI resourceUri = new URI("https://graph.microsoft.com/v1.0/me"); + + InteractiveRequestParameters params = InteractiveRequestParameters + .builder(redirectUri) + .proofOfPossession(HttpMethod.GET, resourceUri, "nonce-value") + .build(); + + PopParameters pop = params.proofOfPossession(); + assertNotNull(pop); + assertEquals(HttpMethod.GET, pop.getHttpMethod()); + assertEquals(resourceUri, pop.getUri()); + assertEquals("nonce-value", pop.getNonce()); + } + + // ========== SilentParameters ========== + + @Test + void silent_WithAccount_GettersReturnExpected() { + IAccount mockAccount = new IAccount() { + public String homeAccountId() { return "uid.utid"; } + public String environment() { return "login.microsoftonline.com"; } + public String username() { return "user@contoso.com"; } + public Map getTenantProfiles() { return null; } + }; + + SilentParameters params = SilentParameters + .builder(SCOPES, mockAccount) + .build(); + + assertEquals(SCOPES, params.scopes()); + assertSame(mockAccount, params.account()); + assertFalse(params.forceRefresh()); + assertNull(params.claims()); + assertNull(params.authorityUrl()); + assertNull(params.extraHttpHeaders()); + assertNull(params.extraQueryParameters()); + assertNull(params.tenant()); + assertNull(params.proofOfPossession()); + } + + @Test + void silent_WithoutAccount_GettersReturnExpected() { + SilentParameters params = SilentParameters + .builder(SCOPES) + .forceRefresh(true) + .tenant(TENANT) + .build(); + + assertEquals(SCOPES, params.scopes()); + assertNull(params.account()); + assertTrue(params.forceRefresh()); + assertEquals(TENANT, params.tenant()); + } + + @Test + void silent_RemoveEmptyScope_FiltersEmptyStrings() { + Set scopesWithEmpty = new HashSet<>(); + scopesWithEmpty.add("User.Read"); + scopesWithEmpty.add(""); + scopesWithEmpty.add("Mail.Read"); + + SilentParameters params = SilentParameters + .builder(scopesWithEmpty) + .build(); + + Set resultScopes = params.scopes(); + assertEquals(2, resultScopes.size()); + assertTrue(resultScopes.contains("User.Read")); + assertTrue(resultScopes.contains("Mail.Read")); + assertFalse(resultScopes.contains("")); + } + + @Test + void silent_NullScopes_Throws() { + assertThrows(IllegalArgumentException.class, () -> + SilentParameters.builder(null)); + } + + @Test + void silent_NullAccount_Throws() { + assertThrows(IllegalArgumentException.class, () -> + SilentParameters.builder(SCOPES, null)); + } + + @Test + void silent_ProofOfPossession_CreatesPopParameters() throws Exception { + URI resourceUri = new URI("https://graph.microsoft.com/v1.0/me"); + + SilentParameters params = SilentParameters + .builder(SCOPES) + .proofOfPossession(HttpMethod.POST, resourceUri, "nonce") + .build(); + + PopParameters pop = params.proofOfPossession(); + assertNotNull(pop); + assertEquals(HttpMethod.POST, pop.getHttpMethod()); + assertEquals(resourceUri, pop.getUri()); + } + + // ========== OnBehalfOfParameters ========== + + @Test + void obo_RequiredParams_GettersReturnExpected() { + UserAssertion assertion = new UserAssertion("test-jwt-assertion"); + + OnBehalfOfParameters params = OnBehalfOfParameters + .builder(SCOPES, assertion) + .build(); + + assertEquals(SCOPES, params.scopes()); + assertSame(assertion, params.userAssertion()); + assertFalse(params.skipCache()); + assertNull(params.claims()); + assertNull(params.extraHttpHeaders()); + assertNull(params.extraQueryParameters()); + assertNull(params.tenant()); + } + + @Test + void obo_AllOptionalParams_GettersReturnExpected() { + UserAssertion assertion = new UserAssertion("test-jwt-assertion"); + ClaimsRequest claims = new ClaimsRequest(); + + OnBehalfOfParameters params = OnBehalfOfParameters + .builder(SCOPES, assertion) + .skipCache(true) + .claims(claims) + .extraHttpHeaders(EXTRA_HEADERS) + .extraQueryParameters(EXTRA_QUERY_PARAMS) + .tenant(TENANT) + .build(); + + assertTrue(params.skipCache()); + assertSame(claims, params.claims()); + assertEquals(EXTRA_HEADERS, params.extraHttpHeaders()); + assertEquals(EXTRA_QUERY_PARAMS, params.extraQueryParameters()); + assertEquals(TENANT, params.tenant()); + } + + @Test + void obo_NullScopes_Throws() { + UserAssertion assertion = new UserAssertion("test-jwt-assertion"); + + assertThrows(IllegalArgumentException.class, () -> + OnBehalfOfParameters.builder(null, assertion)); + } + + @Test + void obo_SkipCacheNull_DefaultsToFalse() { + UserAssertion assertion = new UserAssertion("test-jwt-assertion"); + + OnBehalfOfParameters params = OnBehalfOfParameters + .builder(SCOPES, assertion) + .skipCache(null) + .build(); + + // OnBehalfOfParameters constructor: skipCache = skipCache != null && skipCache + assertFalse(params.skipCache()); + } + + @Test + void obo_SkipCacheTrue_ReturnsTrueValue() { + UserAssertion assertion = new UserAssertion("test-jwt-assertion"); + + OnBehalfOfParameters params = OnBehalfOfParameters + .builder(SCOPES, assertion) + .skipCache(Boolean.TRUE) + .build(); + + assertTrue(params.skipCache()); + } + + // ========== RefreshTokenParameters ========== + + @Test + void refreshToken_RequiredParams_GettersReturnExpected() { + RefreshTokenParameters params = RefreshTokenParameters + .builder(SCOPES, "refresh-token-value") + .build(); + + assertEquals(SCOPES, params.scopes()); + assertEquals("refresh-token-value", params.refreshToken()); + assertNull(params.claims()); + assertNull(params.extraHttpHeaders()); + assertNull(params.extraQueryParameters()); + assertNull(params.tenant()); + } + + @Test + void refreshToken_AllOptionalParams_GettersReturnExpected() { + ClaimsRequest claims = new ClaimsRequest(); + + RefreshTokenParameters params = RefreshTokenParameters + .builder(SCOPES, "refresh-token-value") + .claims(claims) + .extraHttpHeaders(EXTRA_HEADERS) + .extraQueryParameters(EXTRA_QUERY_PARAMS) + .tenant(TENANT) + .build(); + + assertSame(claims, params.claims()); + assertEquals(EXTRA_HEADERS, params.extraHttpHeaders()); + assertEquals(EXTRA_QUERY_PARAMS, params.extraQueryParameters()); + assertEquals(TENANT, params.tenant()); + } + + @Test + void refreshToken_BlankToken_Throws() { + assertThrows(IllegalArgumentException.class, () -> + RefreshTokenParameters.builder(SCOPES, "")); + } + + @Test + void refreshToken_BuilderRefreshTokenValidation_ValidatesWrongField() { + // BUG: RefreshTokenParameters.Builder.refreshToken() validates scopes + // instead of the refreshToken parameter (copy-paste bug on line 116). + // Since scopes was already set by builder(scopes, token), passing null + // to the builder setter does NOT throw. + RefreshTokenParameters params = RefreshTokenParameters + .builder(SCOPES, "initial-token") + .refreshToken(null) + .build(); + + assertNull(params.refreshToken()); + } + + // ========== AuthorizationCodeParameters ========== + + @Test + void authCode_RequiredParams_GettersReturnExpected() throws Exception { + URI redirectUri = new URI("http://localhost:8080"); + + AuthorizationCodeParameters params = AuthorizationCodeParameters + .builder("auth-code-123", redirectUri) + .build(); + + assertEquals("auth-code-123", params.authorizationCode()); + assertEquals(redirectUri, params.redirectUri()); + assertNull(params.scopes()); + assertNull(params.claims()); + assertNull(params.codeVerifier()); + assertNull(params.extraHttpHeaders()); + assertNull(params.extraQueryParameters()); + assertNull(params.tenant()); + } + + @Test + void authCode_AllOptionalParams_GettersReturnExpected() throws Exception { + URI redirectUri = new URI("http://localhost:8080"); + ClaimsRequest claims = new ClaimsRequest(); + + AuthorizationCodeParameters params = AuthorizationCodeParameters + .builder("auth-code-123", redirectUri) + .scopes(SCOPES) + .claims(claims) + .codeVerifier("pkce-verifier") + .extraHttpHeaders(EXTRA_HEADERS) + .extraQueryParameters(EXTRA_QUERY_PARAMS) + .tenant(TENANT) + .build(); + + assertEquals(SCOPES, params.scopes()); + assertSame(claims, params.claims()); + assertEquals("pkce-verifier", params.codeVerifier()); + assertEquals(EXTRA_HEADERS, params.extraHttpHeaders()); + assertEquals(EXTRA_QUERY_PARAMS, params.extraQueryParameters()); + assertEquals(TENANT, params.tenant()); + } + + @Test + void authCode_BlankCode_Throws() throws Exception { + URI redirectUri = new URI("http://localhost:8080"); + + assertThrows(IllegalArgumentException.class, () -> + AuthorizationCodeParameters.builder("", redirectUri)); + } + + // ========== ClientCredentialParameters ========== + + @Test + void clientCredential_RequiredParams_GettersReturnExpected() { + ClientCredentialParameters params = ClientCredentialParameters + .builder(SCOPES) + .build(); + + assertEquals(SCOPES, params.scopes()); + assertFalse(params.skipCache()); + assertNull(params.claims()); + assertNull(params.extraHttpHeaders()); + assertNull(params.extraQueryParameters()); + assertNull(params.tenant()); + assertNull(params.clientCredential()); + assertNull(params.fmiPath()); + } + + @Test + void clientCredential_AllOptionalParams_GettersReturnExpected() { + ClaimsRequest claims = new ClaimsRequest(); + IClientCredential credential = ClientCredentialFactory.createFromSecret("test-secret"); + + ClientCredentialParameters params = ClientCredentialParameters + .builder(SCOPES) + .skipCache(true) + .claims(claims) + .extraHttpHeaders(EXTRA_HEADERS) + .extraQueryParameters(EXTRA_QUERY_PARAMS) + .tenant(TENANT) + .clientCredential(credential) + .fmiPath("agent-app-id") + .build(); + + assertTrue(params.skipCache()); + assertSame(claims, params.claims()); + assertEquals(EXTRA_HEADERS, params.extraHttpHeaders()); + assertEquals(EXTRA_QUERY_PARAMS, params.extraQueryParameters()); + assertEquals(TENANT, params.tenant()); + assertSame(credential, params.clientCredential()); + assertEquals("agent-app-id", params.fmiPath()); + } + + @Test + void clientCredential_NullScopes_Throws() { + assertThrows(IllegalArgumentException.class, () -> + ClientCredentialParameters.builder(null)); + } + + @Test + void clientCredential_FmiPath_ComputesExtCacheKeyHash() { + ClientCredentialParameters params = ClientCredentialParameters + .builder(SCOPES) + .fmiPath("agent-app-id") + .build(); + + // cacheKeyComponents should contain fmi_path + assertNotNull(params.cacheKeyComponents()); + assertEquals("agent-app-id", params.cacheKeyComponents().get("fmi_path")); + + // computeExtCacheKeyHash should return a non-empty string + String hash = params.computeExtCacheKeyHash(); + assertNotNull(hash); + assertFalse(hash.isEmpty()); + + // Second call should return memoized value (same instance) + assertSame(hash, params.computeExtCacheKeyHash()); + } + + @Test + void clientCredential_NoFmiPath_NullCacheKeyComponents() { + ClientCredentialParameters params = ClientCredentialParameters + .builder(SCOPES) + .build(); + + assertNull(params.cacheKeyComponents()); + assertEquals("", params.computeExtCacheKeyHash()); + } + + @Test + void clientCredential_BlankFmiPath_Throws() { + assertThrows(IllegalArgumentException.class, () -> + ClientCredentialParameters.builder(SCOPES).fmiPath("")); + } + + // ========== UserNamePasswordParameters ========== + + @Test + void usernamePassword_RequiredParams_GettersReturnExpected() { + char[] password = "P@ssw0rd".toCharArray(); + + UserNamePasswordParameters params = UserNamePasswordParameters + .builder(SCOPES, "user@contoso.com", password) + .build(); + + assertEquals(SCOPES, params.scopes()); + assertEquals("user@contoso.com", params.username()); + assertArrayEquals(password, params.password()); + assertNull(params.claims()); + assertNull(params.extraHttpHeaders()); + assertNull(params.extraQueryParameters()); + assertNull(params.tenant()); + assertNull(params.proofOfPossession()); + } + + @Test + void usernamePassword_AllOptionalParams_GettersReturnExpected() throws Exception { + ClaimsRequest claims = new ClaimsRequest(); + URI resourceUri = new URI("https://graph.microsoft.com/v1.0/me"); + + UserNamePasswordParameters params = UserNamePasswordParameters + .builder(SCOPES, "user@contoso.com", "P@ssw0rd".toCharArray()) + .claims(claims) + .extraHttpHeaders(EXTRA_HEADERS) + .extraQueryParameters(EXTRA_QUERY_PARAMS) + .tenant(TENANT) + .proofOfPossession(HttpMethod.GET, resourceUri, "nonce") + .build(); + + assertSame(claims, params.claims()); + assertEquals(EXTRA_HEADERS, params.extraHttpHeaders()); + assertEquals(EXTRA_QUERY_PARAMS, params.extraQueryParameters()); + assertEquals(TENANT, params.tenant()); + assertNotNull(params.proofOfPossession()); + } + + @Test + void usernamePassword_BlankUsername_Throws() { + assertThrows(IllegalArgumentException.class, () -> + UserNamePasswordParameters.builder(SCOPES, "", "pass".toCharArray())); + } + + @Test + void usernamePassword_EmptyPassword_Throws() { + assertThrows(IllegalArgumentException.class, () -> + UserNamePasswordParameters.builder(SCOPES, "user@contoso.com", new char[0])); + } + + @Test + void usernamePassword_PasswordCloned_NotSameArray() { + char[] original = "P@ssw0rd".toCharArray(); + + UserNamePasswordParameters params = UserNamePasswordParameters + .builder(SCOPES, "user@contoso.com", original) + .build(); + + // password() returns a clone, not the same array + char[] returned = params.password(); + assertArrayEquals(original, returned); + assertNotSame(original, returned); + } + + // ========== UserFederatedIdentityCredentialParameters ========== + + @Test + void userFic_BuilderWithUsername_GettersReturnExpected() { + UserFederatedIdentityCredentialParameters params = + UserFederatedIdentityCredentialParameters + .builder(SCOPES, "user@contoso.com", "jwt-assertion") + .build(); + + assertEquals(SCOPES, params.scopes()); + assertEquals("user@contoso.com", params.username()); + assertNull(params.userObjectId()); + assertEquals("jwt-assertion", params.assertion()); + assertFalse(params.forceRefresh()); + assertNull(params.claims()); + assertNull(params.extraHttpHeaders()); + assertNull(params.extraQueryParameters()); + assertNull(params.tenant()); + } + + @Test + void userFic_BuilderWithObjectId_GettersReturnExpected() { + UUID objectId = UUID.randomUUID(); + + UserFederatedIdentityCredentialParameters params = + UserFederatedIdentityCredentialParameters + .builder(SCOPES, objectId, "jwt-assertion") + .build(); + + assertEquals(SCOPES, params.scopes()); + assertNull(params.username()); + assertEquals(objectId, params.userObjectId()); + assertEquals("jwt-assertion", params.assertion()); + } + + @Test + void userFic_AllOptionalParams_GettersReturnExpected() { + ClaimsRequest claims = new ClaimsRequest(); + + UserFederatedIdentityCredentialParameters params = + UserFederatedIdentityCredentialParameters + .builder(SCOPES, "user@contoso.com", "jwt-assertion") + .forceRefresh(true) + .claims(claims) + .extraHttpHeaders(EXTRA_HEADERS) + .extraQueryParameters(EXTRA_QUERY_PARAMS) + .tenant(TENANT) + .build(); + + assertTrue(params.forceRefresh()); + assertSame(claims, params.claims()); + assertEquals(EXTRA_HEADERS, params.extraHttpHeaders()); + assertEquals(EXTRA_QUERY_PARAMS, params.extraQueryParameters()); + assertEquals(TENANT, params.tenant()); + } + + @Test + void userFic_NullScopes_Throws() { + assertThrows(IllegalArgumentException.class, () -> + UserFederatedIdentityCredentialParameters.builder(null, "user@contoso.com", "assertion")); + } + + @Test + void userFic_BlankAssertion_Throws() { + assertThrows(IllegalArgumentException.class, () -> + UserFederatedIdentityCredentialParameters.builder(SCOPES, "user@contoso.com", "")); + } + + @Test + void userFic_NullObjectId_Throws() { + assertThrows(IllegalArgumentException.class, () -> + UserFederatedIdentityCredentialParameters.builder(SCOPES, (UUID) null, "assertion")); + } + + // ========== ManagedIdentityParameters ========== + + @Test + void managedIdentity_RequiredParams_GettersReturnExpected() { + ManagedIdentityParameters params = ManagedIdentityParameters + .builder("https://management.azure.com") + .build(); + + assertEquals("https://management.azure.com", params.resource()); + assertFalse(params.forceRefresh()); + assertNull(params.claims()); + assertNull(params.scopes()); + assertNull(params.extraHttpHeaders()); + assertNull(params.extraQueryParameters()); + assertEquals(Constants.MANAGED_IDENTITY_DEFAULT_TENTANT, params.tenant()); + assertNull(params.revokedTokenHash()); + } + + @Test + void managedIdentity_ForceRefresh_SetCorrectly() { + ManagedIdentityParameters params = ManagedIdentityParameters + .builder("https://management.azure.com") + .forceRefresh(true) + .build(); + + assertTrue(params.forceRefresh()); + } + + @Test + void managedIdentity_ValidClaims_ParsedAsClaimsRequest() { + String claimsJson = "{\"access_token\":{\"nbf\":{\"essential\":true}}}"; + + ManagedIdentityParameters params = ManagedIdentityParameters + .builder("https://management.azure.com") + .claims(claimsJson) + .build(); + + ClaimsRequest parsedClaims = params.claims(); + assertNotNull(parsedClaims); + } + + @Test + void managedIdentity_NullClaims_ReturnsNull() { + ManagedIdentityParameters params = ManagedIdentityParameters + .builder("https://management.azure.com") + .build(); + + assertNull(params.claims()); + } + + @Test + void managedIdentity_EmptyClaims_Throws() { + // The builder validates claims are not blank, so empty claims can only + // happen if constructed directly. Testing the claims() method's own + // empty-string guard (line 33: if claims == null || claims.isEmpty()). + // Since builder prevents this, we verify the builder validation instead. + assertThrows(IllegalArgumentException.class, () -> + ManagedIdentityParameters.builder("https://management.azure.com").claims("")); + } + + @Test + void managedIdentity_BlankClaims_Throws() { + assertThrows(IllegalArgumentException.class, () -> + ManagedIdentityParameters.builder("https://management.azure.com").claims(" ")); + } + + // ========== ParameterValidationUtils ========== + + @Test + void validateNotEmpty_set_nullThrows() { + assertThrows(IllegalArgumentException.class, + () -> ParameterValidationUtils.validateNotEmpty("scopes", (Set) null)); + } + + @Test + void validateNotEmpty_set_emptyThrows() { + assertThrows(IllegalArgumentException.class, + () -> ParameterValidationUtils.validateNotEmpty("scopes", new HashSet<>())); + } + + @Test + void validateNotEmpty_set_nonEmptyPasses() { + ParameterValidationUtils.validateNotEmpty("scopes", Collections.singleton("openid")); + } +} diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/ResponseParsingTest.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/ResponseParsingTest.java new file mode 100644 index 000000000..52ce2730b --- /dev/null +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/ResponseParsingTest.java @@ -0,0 +1,657 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.aad.msal4j; + +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Base64; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class ResponseParsingTest { + + // ========== ErrorResponse ========== + + @Test + void errorResponse_fromJson_allFieldsPopulated() throws IOException { + String json = "{\"error\":\"invalid_grant\"," + + "\"error_description\":\"Token expired\"," + + "\"error_codes\":[50076,70008]," + + "\"suberror\":\"basic_action\"," + + "\"trace_id\":\"abc-123\"," + + "\"timestamp\":\"2024-01-15 12:00:00Z\"," + + "\"correlation_id\":\"corr-456\"," + + "\"claims\":\"{\\\"access_token\\\":{\\\"nbf\\\":{\\\"essential\\\":true}}}\"}"; + + ErrorResponse response = TestHelper.parseJson(json, ErrorResponse::fromJson); + + assertEquals("invalid_grant", response.error()); + assertEquals("Token expired", response.errorDescription()); + assertArrayEquals(new long[]{50076, 70008}, response.errorCodes()); + assertEquals("basic_action", response.subError()); + assertEquals("abc-123", response.traceId()); + assertEquals("2024-01-15 12:00:00Z", response.timestamp()); + assertEquals("corr-456", response.correlation_id()); + assertTrue(response.claims().contains("access_token")); + } + + @Test + void errorResponse_fromJson_minimalFields() throws IOException { + String json = "{\"error\":\"server_error\"}"; + + ErrorResponse response = TestHelper.parseJson(json, ErrorResponse::fromJson); + + assertEquals("server_error", response.error()); + assertNull(response.errorDescription()); + assertNull(response.errorCodes()); + assertNull(response.subError()); + assertNull(response.traceId()); + assertNull(response.timestamp()); + assertNull(response.correlation_id()); + assertNull(response.claims()); + } + + @Test + void errorResponse_fromJson_unknownFieldsSkipped() throws IOException { + String json = "{\"error\":\"invalid_request\",\"unknown_field\":\"value\",\"another\":123}"; + + ErrorResponse response = TestHelper.parseJson(json, ErrorResponse::fromJson); + + assertEquals("invalid_request", response.error()); + } + + @Test + void errorResponse_fromJson_emptyErrorCodes() throws IOException { + String json = "{\"error\":\"test\",\"error_codes\":[]}"; + + ErrorResponse response = TestHelper.parseJson(json, ErrorResponse::fromJson); + + assertNotNull(response.errorCodes()); + assertEquals(0, response.errorCodes().length); + } + + @Test + void errorResponse_settersAndGetters_fullCoverage() { + ErrorResponse response = new ErrorResponse(); + + response.statusCode(401); + response.statusMessage("Unauthorized"); + response.error("invalid_token"); + response.errorDescription("The token is expired"); + response.errorCodes(new long[]{50013}); + response.subError("token_expired"); + response.traceId("trace-789"); + response.timestamp("2024-06-01"); + response.correlation_id("corr-id"); + response.claims("{\"id_token\":{}}"); + + assertEquals(401, response.statusCode().intValue()); + assertEquals("Unauthorized", response.statusMessage()); + assertEquals("invalid_token", response.error()); + assertEquals("The token is expired", response.errorDescription()); + assertArrayEquals(new long[]{50013}, response.errorCodes()); + assertEquals("token_expired", response.subError()); + assertEquals("trace-789", response.traceId()); + assertEquals("2024-06-01", response.timestamp()); + assertEquals("corr-id", response.correlation_id()); + assertEquals("{\"id_token\":{}}", response.claims()); + } + + @Test + void errorResponse_toJson_throwsDueToDoubleStartObject() { + // Bug: ErrorResponse.toJson() calls writeStartObject() twice (lines 68, 70) + // which causes an IllegalStateException from the JSON writer + ErrorResponse response = new ErrorResponse(); + response.statusCode(400); + response.error("invalid_grant"); + + assertThrows(IllegalStateException.class, () -> TestHelper.writeToJson(response), + "toJson has a double writeStartObject bug that causes IllegalStateException"); + } + + @Test + void errorResponse_toJson_nullErrorCodes_throwsDueToDoubleStartObject() { + // Same double writeStartObject bug affects all toJson calls + ErrorResponse response = new ErrorResponse(); + response.statusCode(500); + response.error("server_error"); + + assertThrows(IllegalStateException.class, () -> TestHelper.writeToJson(response)); + } + + // ========== UserDiscoveryResponse ========== + + @Test + void userDiscoveryResponse_fromJson_federatedAccount() throws IOException { + String json = "{\"ver\":\"1.0\"," + + "\"account_type\":\"Federated\"," + + "\"federation_metadata_url\":\"https://adfs.example.com/metadata\"," + + "\"federation_protocol\":\"WSTrust\"," + + "\"federation_active_auth_url\":\"https://adfs.example.com/active\"," + + "\"cloud_audience_urn\":\"urn:federation:MicrosoftOnline\"}"; + + UserDiscoveryResponse response = TestHelper.parseJson(json, UserDiscoveryResponse::fromJson); + + assertEquals(1.0f, response.version(), 0.01f); + assertEquals("Federated", response.accountType()); + assertEquals("https://adfs.example.com/metadata", response.federationMetadataUrl()); + assertEquals("WSTrust", response.federationProtocol()); + assertEquals("https://adfs.example.com/active", response.federationActiveAuthUrl()); + assertEquals("urn:federation:MicrosoftOnline", response.cloudAudienceUrn()); + assertTrue(response.isAccountFederated()); + assertFalse(response.isAccountManaged()); + } + + @Test + void userDiscoveryResponse_fromJson_managedAccount() throws IOException { + String json = "{\"ver\":\"1.0\",\"account_type\":\"Managed\"}"; + + UserDiscoveryResponse response = TestHelper.parseJson(json, UserDiscoveryResponse::fromJson); + + assertTrue(response.isAccountManaged()); + assertFalse(response.isAccountFederated()); + } + + @Test + void userDiscoveryResponse_fromJson_unknownAccountType() throws IOException { + String json = "{\"ver\":\"2.0\",\"account_type\":\"Unknown\"}"; + + UserDiscoveryResponse response = TestHelper.parseJson(json, UserDiscoveryResponse::fromJson); + + assertFalse(response.isAccountFederated()); + assertFalse(response.isAccountManaged()); + } + + @Test + void userDiscoveryResponse_isAccountFederated_nullAccountType() { + // Default-constructed response has null accountType + UserDiscoveryResponse response = new UserDiscoveryResponse(); + + assertFalse(response.isAccountFederated()); + assertFalse(response.isAccountManaged()); + } + + @Test + void userDiscoveryResponse_isAccountFederated_caseInsensitive() throws IOException { + String json = "{\"ver\":\"1.0\",\"account_type\":\"fEdErAtEd\"}"; + + UserDiscoveryResponse response = TestHelper.parseJson(json, UserDiscoveryResponse::fromJson); + + assertTrue(response.isAccountFederated()); + } + + @Test + void userDiscoveryResponse_isAccountManaged_caseInsensitive() throws IOException { + String json = "{\"ver\":\"1.0\",\"account_type\":\"MANAGED\"}"; + + UserDiscoveryResponse response = TestHelper.parseJson(json, UserDiscoveryResponse::fromJson); + + assertTrue(response.isAccountManaged()); + } + + @Test + void userDiscoveryResponse_toJson_roundTrip() throws IOException { + String json = "{\"ver\":\"1.0\"," + + "\"account_type\":\"Federated\"," + + "\"federation_metadata_url\":\"https://adfs.example.com/metadata\"," + + "\"federation_protocol\":\"WSTrust\"," + + "\"federation_active_auth_url\":\"https://adfs.example.com/active\"," + + "\"cloud_audience_urn\":\"urn:federation:MicrosoftOnline\"}"; + + UserDiscoveryResponse original = TestHelper.parseJson(json, UserDiscoveryResponse::fromJson); + + String serialized = TestHelper.writeToJson(original); + + UserDiscoveryResponse roundTripped = TestHelper.parseJson(serialized, UserDiscoveryResponse::fromJson); + + assertEquals(original.accountType(), roundTripped.accountType()); + assertEquals(original.federationProtocol(), roundTripped.federationProtocol()); + assertEquals(original.federationMetadataUrl(), roundTripped.federationMetadataUrl()); + assertEquals(original.federationActiveAuthUrl(), roundTripped.federationActiveAuthUrl()); + assertEquals(original.cloudAudienceUrn(), roundTripped.cloudAudienceUrn()); + } + + @Test + void userDiscoveryResponse_fromJson_unknownFieldsSkipped() throws IOException { + String json = "{\"ver\":\"1.0\",\"account_type\":\"Managed\",\"extra_field\":\"ignored\"}"; + + UserDiscoveryResponse response = TestHelper.parseJson(json, UserDiscoveryResponse::fromJson); + + assertTrue(response.isAccountManaged()); + } + + // ========== OidcDiscoveryResponse ========== + + @Test + void oidcDiscoveryResponse_fromJson_allEndpoints() throws IOException { + String json = "{\"authorization_endpoint\":\"https://login.microsoftonline.com/common/oauth2/v2.0/authorize\"," + + "\"token_endpoint\":\"https://login.microsoftonline.com/common/oauth2/v2.0/token\"," + + "\"device_authorization_endpoint\":\"https://login.microsoftonline.com/common/oauth2/v2.0/devicecode\"," + + "\"issuer\":\"https://login.microsoftonline.com/{tenantid}/v2.0\"}"; + + OidcDiscoveryResponse response = TestHelper.parseJson(json, OidcDiscoveryResponse::fromJson); + + assertEquals("https://login.microsoftonline.com/common/oauth2/v2.0/authorize", response.authorizationEndpoint()); + assertEquals("https://login.microsoftonline.com/common/oauth2/v2.0/token", response.tokenEndpoint()); + assertEquals("https://login.microsoftonline.com/common/oauth2/v2.0/devicecode", response.deviceCodeEndpoint()); + assertEquals("https://login.microsoftonline.com/{tenantid}/v2.0", response.issuer()); + } + + @Test + void oidcDiscoveryResponse_fromJson_partialFields() throws IOException { + String json = "{\"authorization_endpoint\":\"https://example.com/auth\"," + + "\"token_endpoint\":\"https://example.com/token\"}"; + + OidcDiscoveryResponse response = TestHelper.parseJson(json, OidcDiscoveryResponse::fromJson); + + assertEquals("https://example.com/auth", response.authorizationEndpoint()); + assertEquals("https://example.com/token", response.tokenEndpoint()); + assertNull(response.deviceCodeEndpoint()); + assertNull(response.issuer()); + } + + @Test + void oidcDiscoveryResponse_fromJson_unknownFieldsSkipped() throws IOException { + String json = "{\"authorization_endpoint\":\"https://example.com/auth\"," + + "\"jwks_uri\":\"https://example.com/keys\"," + + "\"response_types_supported\":[\"code\"]}"; + + OidcDiscoveryResponse response = TestHelper.parseJson(json, OidcDiscoveryResponse::fromJson); + + assertEquals("https://example.com/auth", response.authorizationEndpoint()); + } + + @Test + void oidcDiscoveryResponse_toJson_doesNotIncludeIssuer() throws IOException { + // Note: toJson() intentionally omits the issuer field — this test documents that behavior + String json = "{\"authorization_endpoint\":\"https://example.com/auth\"," + + "\"token_endpoint\":\"https://example.com/token\"," + + "\"device_authorization_endpoint\":\"https://example.com/device\"," + + "\"issuer\":\"https://example.com/issuer\"}"; + + OidcDiscoveryResponse response = TestHelper.parseJson(json, OidcDiscoveryResponse::fromJson); + + String output = TestHelper.writeToJson(response); + + assertTrue(output.contains("authorization_endpoint")); + assertTrue(output.contains("token_endpoint")); + assertTrue(output.contains("device_authorization_endpoint")); + // toJson does not write the issuer field + assertFalse(output.contains("\"issuer\"")); + } + + // ========== RequestedClaimAdditionalInfo ========== + + @Test + void requestedClaimAdditionalInfo_constructorAndGetters() { + List values = Arrays.asList("val1", "val2"); + RequestedClaimAdditionalInfo info = new RequestedClaimAdditionalInfo(true, "single", values); + + assertTrue(info.isEssential()); + assertEquals("single", info.getValue()); + assertEquals(Arrays.asList("val1", "val2"), info.getValues()); + } + + @Test + void requestedClaimAdditionalInfo_setters() { + RequestedClaimAdditionalInfo info = new RequestedClaimAdditionalInfo(false, null, null); + + info.setEssential(true); + info.setValue("updated"); + info.setValues(Arrays.asList("a", "b")); + + assertTrue(info.isEssential()); + assertEquals("updated", info.getValue()); + assertEquals(Arrays.asList("a", "b"), info.getValues()); + } + + @Test + void requestedClaimAdditionalInfo_fromJson_allFields() throws IOException { + // Wrap in outer object since fromJson expects to be positioned in a field context + String json = "{\"essential\":true,\"value\":\"test-value\",\"values\":[\"v1\",\"v2\"]}"; + + RequestedClaimAdditionalInfo info = TestHelper.parseJson(json, + reader -> new RequestedClaimAdditionalInfo(false, null, null).fromJson(reader)); + + assertTrue(info.isEssential()); + assertEquals("test-value", info.getValue()); + assertEquals(Arrays.asList("v1", "v2"), info.getValues()); + } + + @Test + void requestedClaimAdditionalInfo_fromJson_essentialOnly() throws IOException { + String json = "{\"essential\":true}"; + + RequestedClaimAdditionalInfo info = TestHelper.parseJson(json, + reader -> new RequestedClaimAdditionalInfo(false, null, null).fromJson(reader)); + + assertTrue(info.isEssential()); + assertNull(info.getValue()); + assertNull(info.getValues()); + } + + @Test + void requestedClaimAdditionalInfo_toJson_essentialTrue() throws IOException { + RequestedClaimAdditionalInfo info = new RequestedClaimAdditionalInfo(true, null, null); + + String output = TestHelper.writeToJson(info); + + assertTrue(output.contains("\"essential\":true")); + assertFalse(output.contains("\"value\"")); + assertFalse(output.contains("\"values\"")); + } + + @Test + void requestedClaimAdditionalInfo_toJson_essentialFalseOmitted() throws IOException { + RequestedClaimAdditionalInfo info = new RequestedClaimAdditionalInfo(false, "v", null); + + String output = TestHelper.writeToJson(info); + + // essential=false is omitted from serialization + assertFalse(output.contains("essential")); + assertTrue(output.contains("\"value\":\"v\"")); + } + + @Test + void requestedClaimAdditionalInfo_toJson_withValues() throws IOException { + RequestedClaimAdditionalInfo info = new RequestedClaimAdditionalInfo( + false, null, Arrays.asList("x", "y")); + + String output = TestHelper.writeToJson(info); + + assertTrue(output.contains("\"values\"")); + assertTrue(output.contains("\"x\"")); + assertTrue(output.contains("\"y\"")); + } + + @Test + void requestedClaimAdditionalInfo_toJson_emptyValuesOmitted() throws IOException { + RequestedClaimAdditionalInfo info = new RequestedClaimAdditionalInfo( + false, null, Arrays.asList()); + + String output = TestHelper.writeToJson(info); + + // Empty list should be omitted (values != null but isEmpty()) + assertFalse(output.contains("values")); + } + + // ========== RequestedClaim ========== + + @Test + void requestedClaim_constructorAndGetter() { + RequestedClaimAdditionalInfo info = new RequestedClaimAdditionalInfo(true, "val", null); + RequestedClaim claim = new RequestedClaim("acr", info); + + assertEquals("acr", claim.name); + assertEquals(info, claim.getRequestedClaimAdditionalInfo()); + } + + @Test + void requestedClaim_defaultConstructor() { + RequestedClaim claim = new RequestedClaim(); + + assertNull(claim.name); + assertNull(claim.getRequestedClaimAdditionalInfo()); + } + + @Test + void requestedClaim_setter() { + RequestedClaim claim = new RequestedClaim(); + RequestedClaimAdditionalInfo info = new RequestedClaimAdditionalInfo(false, null, null); + + claim.setRequestedClaimAdditionalInfo(info); + + assertEquals(info, claim.getRequestedClaimAdditionalInfo()); + } + + @Test + void requestedClaim_any_returnsCorrectMap() { + RequestedClaimAdditionalInfo info = new RequestedClaimAdditionalInfo(true, "v", null); + RequestedClaim claim = new RequestedClaim("email", info); + + Map result = claim.any(); + + assertEquals(1, result.size()); + assertTrue(result.containsKey("email")); + assertEquals(info, result.get("email")); + } + + @Test + void requestedClaim_any_nullName() { + RequestedClaim claim = new RequestedClaim(null, null); + + Map result = claim.any(); + + assertEquals(1, result.size()); + assertTrue(result.containsKey(null)); + } + + @Test + void requestedClaim_toJson_withNameAndInfo_throwsDueToBadStringWrite() { + // Bug: RequestedClaim.toJson() calls writeString(name) inside an object context, + // which is invalid JSON (a raw string value where a field name is expected) + RequestedClaimAdditionalInfo info = new RequestedClaimAdditionalInfo(true, null, null); + RequestedClaim claim = new RequestedClaim("sub", info); + + assertThrows(IllegalStateException.class, () -> TestHelper.writeToJson(claim), + "toJson writes a raw string in object context, causing IllegalStateException"); + } + + @Test + void requestedClaim_toJson_nullNameAndInfo_writesEmptyObject() throws IOException { + RequestedClaim claim = new RequestedClaim(null, null); + + String output = TestHelper.writeToJson(claim); + + // When name or info is null, toJson skips writing the content + assertEquals("{}", output); + } + + // ========== ClientInfo ========== + + @Test + void clientInfo_createFromJson_validBase64() { + String json = "{\"uid\":\"user-id\",\"utid\":\"tenant-id\"}"; + String base64 = Base64.getUrlEncoder().withoutPadding() + .encodeToString(json.getBytes(StandardCharsets.UTF_8)); + + ClientInfo info = ClientInfo.createFromJson(base64); + + assertNotNull(info); + assertEquals("user-id", info.getUniqueIdentifier()); + assertEquals("tenant-id", info.getUniqueTenantIdentifier()); + assertEquals("user-id.tenant-id", info.toAccountIdentifier()); + } + + @Test + void clientInfo_createFromJson_blankInput_returnsNull() { + assertNull(ClientInfo.createFromJson(null)); + assertNull(ClientInfo.createFromJson("")); + assertNull(ClientInfo.createFromJson(" ")); + } + + @Test + void clientInfo_toJson_roundTrip() throws IOException { + String json = "{\"uid\":\"uid-1\",\"utid\":\"utid-1\"}"; + ClientInfo original = TestHelper.parseJson(json, ClientInfo::fromJson); + + String serialized = TestHelper.writeToJson(original); + + assertTrue(serialized.contains("\"uid\":\"uid-1\"")); + assertTrue(serialized.contains("\"utid\":\"utid-1\"")); + + ClientInfo roundTripped = TestHelper.parseJson(serialized, ClientInfo::fromJson); + assertEquals(original.getUniqueIdentifier(), roundTripped.getUniqueIdentifier()); + assertEquals(original.getUniqueTenantIdentifier(), roundTripped.getUniqueTenantIdentifier()); + } + + @Test + void clientInfo_fromJson_unknownFieldsSkipped() throws IOException { + String json = "{\"uid\":\"u\",\"utid\":\"t\",\"extra\":\"ignored\"}"; + + ClientInfo info = TestHelper.parseJson(json, ClientInfo::fromJson); + + assertEquals("u", info.getUniqueIdentifier()); + assertEquals("t", info.getUniqueTenantIdentifier()); + } + + // ========== MsalServiceException ========== + + @Test + void msalServiceException_errorResponseConstructor() { + ErrorResponse errorResponse = new ErrorResponse(); + errorResponse.statusCode(401); + errorResponse.statusMessage("Unauthorized"); + errorResponse.error("invalid_token"); + errorResponse.errorDescription("Token is expired"); + errorResponse.subError("token_expired"); + errorResponse.correlation_id("corr-123"); + errorResponse.claims("{\"access_token\":{}}"); + + Map> headers = new HashMap<>(); + headers.put("WWW-Authenticate", Collections.singletonList("Bearer")); + + MsalServiceException ex = new MsalServiceException(errorResponse, headers); + + assertEquals(401, ex.statusCode().intValue()); + assertEquals("Unauthorized", ex.statusMessage()); + assertEquals("invalid_token", ex.errorCode()); + assertEquals("Token is expired", ex.getMessage()); + assertEquals("token_expired", ex.subError()); + assertEquals("corr-123", ex.correlationId()); + assertEquals("{\"access_token\":{}}", ex.claims()); + assertNotNull(ex.headers()); + assertTrue(ex.headers().containsKey("WWW-Authenticate")); + } + + @Test + void msalServiceException_discoveryResponseConstructor() throws IOException { + String json = "{\"error\":\"invalid_instance\"," + + "\"error_description\":\"AADSTS50049: Unknown instance.\"," + + "\"correlation_id\":\"disc-corr\"}"; + + AadInstanceDiscoveryResponse discoveryResponse = + TestHelper.parseJson(json, AadInstanceDiscoveryResponse::fromJson); + + MsalServiceException ex = new MsalServiceException(discoveryResponse); + + assertEquals("invalid_instance", ex.errorCode()); + assertEquals("AADSTS50049: Unknown instance.", ex.getMessage()); + assertEquals("disc-corr", ex.correlationId()); + assertNull(ex.statusCode()); + assertNull(ex.headers()); + } + + @Test + void msalServiceException_managedIdentityConstructor() { + MsalServiceException ex = new MsalServiceException( + "MI error", "managed_identity_error", + ManagedIdentitySourceType.APP_SERVICE); + + assertEquals("MI error", ex.getMessage()); + assertEquals("managed_identity_error", ex.errorCode()); + assertEquals("APP_SERVICE", ex.managedIdentitySource()); + } + + // ========== MsalServiceExceptionFactory ========== + + private IHttpResponse mockHttpResponse(String body, int statusCode) { + return mockHttpResponse(body, statusCode, null); + } + + private IHttpResponse mockHttpResponse(String body, int statusCode, + Map> headers) { + IHttpResponse response = mock(IHttpResponse.class); + when(response.body()).thenReturn(body); + when(response.statusCode()).thenReturn(statusCode); + if (headers != null) { + when(response.headers()).thenReturn(headers); + } + return response; + } + + private Map> jsonContentTypeHeaders() { + Map> headers = new HashMap<>(); + headers.put("Content-Type", Collections.singletonList("application/json")); + return headers; + } + + @Test + void msalServiceExceptionFactory_blankBody_returnsUnknownError() { + MsalServiceException ex = MsalServiceExceptionFactory.fromHttpResponse( + mockHttpResponse("", 500)); + + assertEquals(AuthenticationErrorCode.UNKNOWN, ex.errorCode()); + assertTrue(ex.getMessage().contains("500")); + assertTrue(ex.getMessage().contains("no response body")); + } + + @Test + void msalServiceExceptionFactory_nullBody_returnsUnknownError() { + MsalServiceException ex = MsalServiceExceptionFactory.fromHttpResponse( + mockHttpResponse(null, 503)); + + assertEquals(AuthenticationErrorCode.UNKNOWN, ex.errorCode()); + assertTrue(ex.getMessage().contains("503")); + } + + @Test + void msalServiceExceptionFactory_invalidGrant_interactionRequired() { + String body = "{\"error\":\"invalid_grant\"," + + "\"error_description\":\"AADSTS50076: needs MFA\"," + + "\"suberror\":\"basic_action\"}"; + + MsalServiceException ex = MsalServiceExceptionFactory.fromHttpResponse( + mockHttpResponse(body, 400, jsonContentTypeHeaders())); + + assertTrue(ex instanceof MsalInteractionRequiredException, + "invalid_grant with UI-required suberror should return MsalInteractionRequiredException"); + } + + @Test + void msalServiceExceptionFactory_invalidGrant_clientMismatch_notInteractionRequired() { + String body = "{\"error\":\"invalid_grant\"," + + "\"error_description\":\"Client mismatch\"," + + "\"suberror\":\"client_mismatch\"}"; + + MsalServiceException ex = MsalServiceExceptionFactory.fromHttpResponse( + mockHttpResponse(body, 400, jsonContentTypeHeaders())); + + assertFalse(ex instanceof MsalInteractionRequiredException, + "client_mismatch suberror should NOT return MsalInteractionRequiredException"); + assertEquals(400, ex.statusCode().intValue()); + } + + @Test + void msalServiceExceptionFactory_invalidGrant_protectionPolicyRequired_notInteractionRequired() { + String body = "{\"error\":\"invalid_grant\"," + + "\"error_description\":\"Protection policy required\"," + + "\"suberror\":\"protection_policy_required\"}"; + + MsalServiceException ex = MsalServiceExceptionFactory.fromHttpResponse( + mockHttpResponse(body, 400, jsonContentTypeHeaders())); + + assertFalse(ex instanceof MsalInteractionRequiredException, + "protection_policy_required suberror should NOT return MsalInteractionRequiredException"); + } + + @Test + void msalServiceExceptionFactory_noErrorInBody_returnsUnknownError() { + MsalServiceException ex = MsalServiceExceptionFactory.fromHttpResponse( + mockHttpResponse("{\"some_field\":\"some_value\"}", 500)); + + assertEquals(AuthenticationErrorCode.UNKNOWN, ex.errorCode()); + assertTrue(ex.getMessage().contains("500")); + } + +} diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/ServerTelemetryTests.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/ServerTelemetryTest.java similarity index 99% rename from msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/ServerTelemetryTests.java rename to msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/ServerTelemetryTest.java index 8f809962f..f9d7a394b 100644 --- a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/ServerTelemetryTests.java +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/ServerTelemetryTest.java @@ -20,7 +20,7 @@ import java.util.concurrent.Executors; @TestInstance(TestInstance.Lifecycle.PER_CLASS) -class ServerTelemetryTests { +class ServerTelemetryTest { private static final String SCHEMA_VERSION = "5"; private static final String CURRENT_REQUEST_HEADER_NAME = "x-client-current-telemetry"; diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/SovereignCloudInstanceDiscoveryTest.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/SovereignCloudInstanceDiscoveryTest.java index 4f45515b9..9b802289a 100644 --- a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/SovereignCloudInstanceDiscoveryTest.java +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/SovereignCloudInstanceDiscoveryTest.java @@ -5,7 +5,6 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.TestInstance; import org.mockito.ArgumentCaptor; import java.util.ArrayList; @@ -24,7 +23,6 @@ * mock only the HTTP layer, and verify that all HTTP requests are routed to the * correct sovereign host — not to login.microsoftonline.com or any other host. */ -@TestInstance(TestInstance.Lifecycle.PER_CLASS) class SovereignCloudInstanceDiscoveryTest { private static final String SOVEREIGN_HOST = "login.sovcloud-identity.fr"; diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/TelemetryTests.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/TelemetryTest.java similarity index 87% rename from msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/TelemetryTests.java rename to msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/TelemetryTest.java index 695816419..ac3d64285 100644 --- a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/TelemetryTests.java +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/TelemetryTest.java @@ -21,7 +21,7 @@ import java.util.function.Consumer; @TestInstance(TestInstance.Lifecycle.PER_CLASS) -class TelemetryTests { +class TelemetryTest { private List> eventsReceived = new ArrayList<>(); private String tenantId = "tenantId123"; @@ -32,11 +32,6 @@ private class MyTelemetryConsumer { Consumer>> telemetryConsumer = (List> telemetryEvents) -> { eventsReceived.addAll(telemetryEvents); - System.out.println("Received " + telemetryEvents.size() + " events"); - telemetryEvents.forEach(event -> { - System.out.print("Event Name: " + event.get("event_name")); - event.entrySet().forEach(entry -> System.out.println(" " + entry)); - }); }; } @@ -74,7 +69,7 @@ void telemetryManagerFlush_EventCountTest() { telemetryManager.flush(reqId, clientId); // 1 Default event, 1 API event, 1 Http event - assertEquals(eventsReceived.size(), 3); + assertEquals(3, eventsReceived.size()); } @Test @@ -99,7 +94,7 @@ void onSendFailureTrue_SkipEventsIfSuccessfulTest() { telemetryManager.flush(reqId, clientId); // API event was successful, so count should be 0 - assertEquals(eventsReceived.size(), 0); + assertEquals(0, eventsReceived.size()); eventsReceived.clear(); String reqId2 = telemetryManager.generateRequestId(); @@ -116,19 +111,19 @@ void onSendFailureTrue_SkipEventsIfSuccessfulTest() { telemetryManager.flush(reqId2, clientId); // API event failed, so count should be 3 (1 default, 1 Api, 1 http) - assertEquals(eventsReceived.size(), 3); + assertEquals(3, eventsReceived.size()); } @Test void telemetryInternalApi_ScrubTenantFromUriTest() throws Exception { - assertEquals(Event.scrubTenant(new URI("https://login.microsoftonline.com/common/oauth2/v2.0/token")), - "https://login.microsoftonline.com//oauth2/v2.0/token"); + assertEquals("https://login.microsoftonline.com//oauth2/v2.0/token", + Event.scrubTenant(new URI("https://login.microsoftonline.com/common/oauth2/v2.0/token"))); - assertEquals(Event.scrubTenant(new URI("https://login.microsoftonline.com/common")), - "https://login.microsoftonline.com/"); + assertEquals("https://login.microsoftonline.com/", + Event.scrubTenant(new URI("https://login.microsoftonline.com/common"))); - assertEquals(Event.scrubTenant(new URI("https://login.microsoftonline.com/tfp/msidlabb2c.onmicrosoft.com/B2C_1_ROPC_Auth")), - "https://login.microsoftonline.com/tfp//B2C_1_ROPC_Auth"); + assertEquals("https://login.microsoftonline.com/tfp//B2C_1_ROPC_Auth", + Event.scrubTenant(new URI("https://login.microsoftonline.com/tfp/msidlabb2c.onmicrosoft.com/B2C_1_ROPC_Auth"))); assertNull(Event.scrubTenant(new URI("https://msidlabb2c.b2clogin.com/tfp/msidlabb2c.onmicrosoft.com/B2C_1_ROPC_Auth"))); @@ -155,7 +150,7 @@ void telemetryContainsDefaultEventTest() { telemetryManager.flush(reqId, clientId); - assertEquals(eventsReceived.get(0).get("event_name"), "msal.default_event"); + assertEquals("msal.default_event", eventsReceived.get(0).get("event_name")); } @Test @@ -177,7 +172,7 @@ void telemetryFlushEventWithoutStopping_OrphanedEventIncludedTest() { telemetryManager.stopEvent(reqId, apiEvent1); telemetryManager.flush(reqId, clientId); - assertEquals(eventsReceived.size(), 3); + assertEquals(3, eventsReceived.size()); assertTrue(eventsReceived.stream().anyMatch(event -> event.get("event_name").equals("msal.http_event"))); } @@ -202,7 +197,7 @@ void telemetryStopEventWithoutStarting_NoExceptionThrownTest() { telemetryManager.flush(reqId, clientId); - assertEquals(eventsReceived.size(), 2); + assertEquals(2, eventsReceived.size()); assertFalse(eventsReceived.stream().anyMatch(event -> event.get("event_name").equals("msal.http_event"))); } @@ -222,7 +217,7 @@ void piiLoggingEnabled_ApiEventHashTest() { telemetryManager.stopEvent(reqId, apiEvent); assertNotNull(apiEvent.get("msal.tenant_id")); - assertNotEquals(apiEvent.get("msal.tenant_id"), tenantId); + assertNotEquals(tenantId, apiEvent.get("msal.tenant_id")); } @Test @@ -256,7 +251,7 @@ void authorityNotInTrustedHostList_AuthorityIsNullTest() throws URISyntaxExcepti apiEvent.setWasSuccessful(true); telemetryManager.stopEvent(reqId, apiEvent); - assertEquals(apiEvent.get("msal.authority"), "https://login.microsoftonline.com"); + assertEquals("https://login.microsoftonline.com", apiEvent.get("msal.authority")); ApiEvent apiEvent2 = new ApiEvent(false); @@ -273,10 +268,10 @@ void xmsCliTelemetryTest_CorrectFormatTest() { String responseHeader = "1,0,0,,"; XmsClientTelemetryInfo info = XmsClientTelemetryInfo.parseXmsTelemetryInfo(responseHeader); - assertEquals(info.getServerErrorCode(), "0"); - assertEquals(info.getServerSubErrorCode(), "0"); - assertEquals(info.getTokenAge(), ""); - assertEquals(info.getSpeInfo(), ""); + assertEquals("0", info.getServerErrorCode()); + assertEquals("0", info.getServerSubErrorCode()); + assertEquals("", info.getTokenAge()); + assertEquals("", info.getSpeInfo()); } @Test diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/TestHelper.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/TestHelper.java index 4112dcebd..5852454a3 100644 --- a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/TestHelper.java +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/TestHelper.java @@ -3,9 +3,17 @@ package com.microsoft.aad.msal4j; +import com.azure.json.JsonProviders; +import com.azure.json.JsonReader; +import com.azure.json.JsonSerializable; +import com.azure.json.JsonWriter; + import java.io.File; import java.io.FileWriter; import java.io.IOException; +import java.io.StringReader; +import java.io.StringWriter; +import java.net.MalformedURLException; import java.net.URISyntaxException; import java.nio.charset.StandardCharsets; import java.nio.file.Files; @@ -17,12 +25,87 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.function.Function; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.argThat; import static org.mockito.Mockito.when; class TestHelper { + // --- Common test constants --- + + static final String TEST_CLIENT_ID = "test-client-id"; + static final String TEST_SECRET = "test-secret"; + static final String TEST_AUTHORITY = "https://login.microsoftonline.com/test-tenant/"; + static final String TEST_SCOPE = "scope/.default"; + static final Set TEST_SCOPE_SET = Collections.singleton(TEST_SCOPE); + + // --- Application builder helpers --- + + /** + * Builds a CCA with common test defaults: fixed client ID/secret, test authority, + * instanceDiscovery=false, validateAuthority=false, and a mocked HTTP client. + */ + static ConfidentialClientApplication buildCca(IHttpClient httpClientMock) { + try { + return ConfidentialClientApplication + .builder(TEST_CLIENT_ID, ClientCredentialFactory.createFromSecret(TEST_SECRET)) + .authority(TEST_AUTHORITY) + .instanceDiscovery(false) + .validateAuthority(false) + .httpClient(httpClientMock) + .build(); + } catch (MalformedURLException e) { + throw new RuntimeException(e); + } + } + + /** + * Builds a CCA with a custom credential and mocked HTTP client. + * Use when the test needs to verify which credential is sent (e.g., credential precedence tests). + */ + static ConfidentialClientApplication buildCca(IClientCredential credential, IHttpClient httpClientMock) { + try { + return ConfidentialClientApplication + .builder(TEST_CLIENT_ID, credential) + .authority(TEST_AUTHORITY) + .instanceDiscovery(false) + .validateAuthority(false) + .httpClient(httpClientMock) + .build(); + } catch (MalformedURLException e) { + throw new RuntimeException(e); + } + } + + /** + * Builds a CCA with an appTokenProvider for testing the app token provider flow. + */ + static ConfidentialClientApplication buildCcaWithAppTokenProvider( + Function> provider) { + try { + return ConfidentialClientApplication + .builder(TEST_CLIENT_ID, ClientCredentialFactory.createFromSecret(TEST_SECRET)) + .appTokenProvider(provider) + .authority(TEST_AUTHORITY) + .instanceDiscovery(false) + .validateAuthority(false) + .build(); + } catch (MalformedURLException e) { + throw new RuntimeException(e); + } + } + + /** Builds a PCA with common test defaults: fixed client ID, instanceDiscovery=false. */ + static PublicClientApplication buildPca() { + return PublicClientApplication.builder(TEST_CLIENT_ID) + .instanceDiscovery(false) + .build(); + } + //Signed JWT which should be enough to pass the parsing/validation in the library, useful if a unit test needs an // assertion but that is not the focus of the test static String signedAssertion = generateToken(); @@ -74,6 +157,14 @@ class TestHelper { "\"tid\": \"%s\"," + "\"ver\": \"2.0\"}"; + static String getEmptyBase64EncodedJson() { + return new String(Base64.getEncoder().encode("{}".getBytes())); + } + + static String getJWTHeaderBase64EncodedJson() { + return new String(Base64.getEncoder().encode("{\"alg\": \"HS256\", \"typ\": \"JWT\"}".getBytes())); + } + static X509Certificate x509Cert = getX509Cert(); static PrivateKey privateKey = getPrivateKey(); @@ -153,7 +244,7 @@ static String getSuccessfulTokenResponse(HashMap responseValues) Long.parseLong(responseValues.get("expires_in")) : 3600; long expiresOn = responseValues.containsKey("expires_on") - ? Long.parseLong(responseValues.get("expires_0n")) : + ? Long.parseLong(responseValues.get("expires_on")) : (System.currentTimeMillis() / 1000) + expiresIn; long refreshIn = responseValues.containsKey("refresh_in") ? Long.parseLong(responseValues.get("refresh_in")) : @@ -194,6 +285,28 @@ static void createTokenRequestMock(IHttpClient httpClientMock, String expectedRe } } + /** + * Sets up a mocked HTTP client to return a successful token response for any request. + * Use when the test doesn't need to customize the response values. + */ + static void mockSuccessfulTokenResponse(IHttpClient httpClientMock) { + mockSuccessfulTokenResponse(httpClientMock, new HashMap<>()); + } + + /** + * Sets up a mocked HTTP client to return a successful token response with custom values. + * Replaces the common 3-line pattern: + * {@code when(httpClientMock.send(any())).thenReturn(TestHelper.expectedResponse(...))} + */ + static void mockSuccessfulTokenResponse(IHttpClient httpClientMock, HashMap responseValues) { + try { + when(httpClientMock.send(any(HttpRequest.class))) + .thenReturn(expectedResponse(HttpStatus.HTTP_OK, getSuccessfulTokenResponse(responseValues))); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + //Maps various values to the idTokenFormat string static String createIdToken(HashMap idTokenValues) { String tokenValues = String.format(idTokenFormat, @@ -245,4 +358,32 @@ static PrivateKey getPrivateKey() { return privateKey; } + + // --- JSON parsing/serialization helpers --- + + @FunctionalInterface + interface JsonParser { + T parse(JsonReader reader) throws IOException; + } + + /** + * Parses a JSON string into an object using the provided parser function. + * Handles JsonReader lifecycle automatically. + */ + static T parseJson(String json, JsonParser parser) throws IOException { + try (JsonReader reader = JsonProviders.createReader(new StringReader(json))) { + return parser.parse(reader); + } + } + + /** + * Serializes a JsonSerializable object to a JSON string. + */ + static > String writeToJson(T serializable) throws IOException { + StringWriter sw = new StringWriter(); + try (JsonWriter writer = JsonProviders.createWriter(sw)) { + serializable.toJson(writer); + } + return sw.toString(); + } } diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/TokenRequestExecutorTest.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/TokenRequestExecutorTest.java index ac7437559..4742459ce 100644 --- a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/TokenRequestExecutorTest.java +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/TokenRequestExecutorTest.java @@ -65,7 +65,7 @@ void executeOAuthRequest_SCBadRequestErrorInvalidGrant_InteractionRequiredExcept fail("Expected MsalServiceException was not thrown"); } catch (MsalInteractionRequiredException ex) { assertEquals(claims.replace("\\", ""), ex.claims()); - assertEquals(ex.reason(), InteractionRequiredExceptionReason.BASIC_ACTION); + assertEquals(InteractionRequiredExceptionReason.BASIC_ACTION, ex.reason()); } } @@ -239,7 +239,7 @@ void testExecuteOAuth_Success() throws MsalException, IOException, URISyntaxExce assertNotNull(result.account()); assertNotNull(result.account().homeAccountId()); - assertEquals(result.account().username(), "idlab@msidlab4.onmicrosoft.com"); + assertEquals("idlab@msidlab4.onmicrosoft.com", result.account().username()); assertFalse(StringHelper.isBlank(result.accessToken())); assertFalse(StringHelper.isBlank(result.refreshToken())); diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/WSTrustRequestTest.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/WSTrustRequestTest.java index dd020db81..53d080a59 100644 --- a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/WSTrustRequestTest.java +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/WSTrustRequestTest.java @@ -5,11 +5,9 @@ import org.apache.commons.text.StringEscapeUtils; import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.TestInstance; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; -@TestInstance(TestInstance.Lifecycle.PER_CLASS) class WSTrustRequestTest { @Test @@ -50,8 +48,66 @@ void escapeXMLElementDataTest() { String DATA_TO_ESCAPE = "o_!as & a34~'fe<> \" a1"; String XML_ESCAPED_DATA = "o_!as & a34~'fe<> " a1"; - assertEquals(WSTrustRequest.escapeXMLElementData(DATA_TO_ESCAPE), XML_ESCAPED_DATA); - assertEquals(WSTrustRequest.escapeXMLElementData(DATA_TO_ESCAPE), - StringEscapeUtils.escapeXml10(DATA_TO_ESCAPE)); + assertEquals(XML_ESCAPED_DATA, WSTrustRequest.escapeXMLElementData(DATA_TO_ESCAPE)); + assertEquals(StringEscapeUtils.escapeXml10(DATA_TO_ESCAPE), + WSTrustRequest.escapeXMLElementData(DATA_TO_ESCAPE)); + } + + @Test + void buildMessage_wsTrust13_usesTrust13Namespaces() { + String msg = WSTrustRequest.buildMessage("https://adfs.example.com", "user", + "pass", WSTrustVersion.WSTRUST13, "urn:federation:MicrosoftOnline").toString(); + + assertTrue(msg.contains("http://docs.oasis-open.org/ws-sx/ws-trust/200512/RST/Issue"), + "WSTrust 1.3 should use trust/200512 action"); + assertTrue(msg.contains("xmlns:trust='http://docs.oasis-open.org/ws-sx/ws-trust/200512'"), + "WSTrust 1.3 should use trust/200512 namespace"); + assertTrue(msg.contains("http://docs.oasis-open.org/ws-sx/ws-trust/200512/Bearer"), + "WSTrust 1.3 should use trust/200512 Bearer key type"); + } + + @Test + void buildMessage_wsTrust2005_uses2005Namespaces() { + String msg = WSTrustRequest.buildMessage("https://adfs.example.com", "user", + "pass", WSTrustVersion.WSTRUST2005, "urn:federation:MicrosoftOnline").toString(); + + assertTrue(msg.contains("http://schemas.xmlsoap.org/ws/2005/02/trust/RST/Issue"), + "WSTrust 2005 should use ws/2005 action"); + assertTrue(msg.contains("xmlns:trust='http://schemas.xmlsoap.org/ws/2005/02/trust'"), + "WSTrust 2005 should use ws/2005 namespace"); + assertTrue(msg.contains("http://schemas.xmlsoap.org/ws/2005/05/identity/NoProofKey"), + "WSTrust 2005 should use NoProofKey"); + } + + @Test + void buildMessage_withCredentials_includesSecurityHeader() { + String msg = WSTrustRequest.buildMessage("address", "testuser", + "testpass", WSTrustVersion.WSTRUST13, null).toString(); + + assertTrue(msg.contains("testuser")); + assertTrue(msg.contains("testpass")); + assertTrue(msg.contains("")); + assertTrue(msg.contains("")); + } + + @Test + void buildMessage_withSpecialCharsInCredentials_escapesXml() { + String msg = WSTrustRequest.buildMessage("address", "user&<>", + "pass'\"", WSTrustVersion.WSTRUST13, null).toString(); + + assertTrue(msg.contains("user&<>"), "Username should be XML-escaped"); + assertTrue(msg.contains("pass'""), "Password should be XML-escaped"); + } + + @Test + void escapeXMLElementData_noSpecialChars_returnsUnchanged() { + assertEquals("simple text 123", WSTrustRequest.escapeXMLElementData("simple text 123")); + } + + @Test + void escapeXMLElementData_emptyString() { + assertEquals("", WSTrustRequest.escapeXMLElementData("")); } } diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/WSTrustResponseTest.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/WSTrustResponseTest.java index e8bbc9e70..c8f84ffa0 100644 --- a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/WSTrustResponseTest.java +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/WSTrustResponseTest.java @@ -3,43 +3,116 @@ package com.microsoft.aad.msal4j; -import java.io.BufferedReader; -import java.io.FileReader; - +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.TestInstance; -import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.AfterAll; -import static org.junit.jupiter.api.Assertions.assertNotNull; -@TestInstance(TestInstance.Lifecycle.PER_CLASS) +import static org.junit.jupiter.api.Assertions.*; + class WSTrustResponseTest { - @BeforeAll - void setup() { - System.setProperty("javax.xml.parsers.DocumentBuilderFactory", "com.sun.org.apache.xerces.internal.jaxp.DocumentBuilderFactoryImpl"); + private static final String AAD_TOKEN_ERROR_FILE = "/token-error.xml"; + + private String successXml; + private String errorXml; + private WSTrustResponse successResponse; + + @BeforeEach + void setup() throws Exception { + System.setProperty("javax.xml.parsers.DocumentBuilderFactory", + "com.sun.org.apache.xerces.internal.jaxp.DocumentBuilderFactoryImpl"); + + successXml = TestHelper.readResource(WSTrustResponseTest.class, + TestConfiguration.AAD_TOKEN_SUCCESS_FILE); + errorXml = TestHelper.readResource(WSTrustResponseTest.class, AAD_TOKEN_ERROR_FILE); + successResponse = WSTrustResponse.parse(successXml, WSTrustVersion.WSTRUST13); } - @AfterAll + @AfterEach void cleanup() { System.clearProperty("javax.xml.parsers.DocumentBuilderFactory"); } @Test - void testWSTrustResponseParseSuccess() throws Exception { - StringBuilder sb = new StringBuilder(); - try (BufferedReader br = new BufferedReader(new FileReader((this - .getClass().getResource( - TestConfiguration.AAD_TOKEN_SUCCESS_FILE).getFile())))) { - String line = br.readLine(); + void parse_ValidToken_ReturnsResponseWithToken() { + assertNotNull(successResponse); + assertNotNull(successResponse.getToken(), "Parsed response should contain a token"); + assertNotNull(successResponse.getTokenType(), "Parsed response should contain a token type"); + assertFalse(successResponse.isErrorFound(), "Successful parse should not have error"); + } + + @Test + void parse_ValidToken_TokenTypeIsSaml1() { + assertEquals(WSTrustResponse.SAML1_ASSERTION, successResponse.getTokenType()); + assertFalse(successResponse.isTokenSaml2(), "SAML 1.0 token should not be detected as SAML 2"); + } + + @Test + void parse_ValidToken_TokenContainsSamlAssertion() { + assertTrue(successResponse.getToken().contains("saml:Assertion"), + "Token should contain SAML assertion XML"); + } + + @Test + void parse_ErrorResponse_ThrowsMsalServiceException() { + MsalServiceException ex = assertThrows(MsalServiceException.class, + () -> WSTrustResponse.parse(errorXml, WSTrustVersion.WSTRUST13)); + + assertEquals(AuthenticationErrorCode.WSTRUST_SERVICE_ERROR, ex.errorCode()); + assertTrue(ex.getMessage().contains("ErrorCode:"), + "Exception message should contain error code info"); + assertTrue(ex.getMessage().contains("FaultMessage:"), + "Exception message should contain fault message info"); + } + + @Test + void parse_ErrorResponse_ContainsReasonAndSubcode() { + MsalServiceException ex = assertThrows(MsalServiceException.class, + () -> WSTrustResponse.parse(errorXml, WSTrustVersion.WSTRUST13)); + + assertTrue(ex.getMessage().contains("RequestFailed"), + "Exception should contain parsed error subcode"); + assertTrue(ex.getMessage().contains("MSIS3127"), + "Exception should contain the SOAP fault reason text"); + } + + @Test + void parse_UndefinedVersion_ThrowsException() { + // UNDEFINED version has empty XPaths which cause XPathExpressionException + assertThrows(Exception.class, + () -> WSTrustResponse.parse(successXml, WSTrustVersion.UNDEFINED)); + } + + @Test + void isTokenSaml2_nonSaml1Type_returnsTrue() { + // Token from test XML is SAML 1.0 + assertFalse(successResponse.isTokenSaml2()); + + // Verify the constant value + assertEquals("urn:oasis:names:tc:SAML:1.0:assertion", WSTrustResponse.SAML1_ASSERTION); + } + + @Test + void parse_ValidToken_GettersReturnExpectedValues() { + assertFalse(successResponse.isErrorFound()); + assertNull(successResponse.getFaultMessage()); + assertNull(successResponse.getErrorCode()); + assertNotNull(successResponse.getToken()); + assertFalse(successResponse.getToken().isEmpty()); + } + + @Test + void innerXml_extractsChildContent() throws Exception { + // innerXml is package-private static, test it directly with a simple XML node + javax.xml.parsers.DocumentBuilder builder = + javax.xml.parsers.DocumentBuilderFactory.newInstance().newDocumentBuilder(); + org.w3c.dom.Document doc = builder.parse( + new java.io.ByteArrayInputStream("text".getBytes())); + org.w3c.dom.Node root = doc.getDocumentElement(); + + String inner = WSTrustResponse.innerXml(root); - while (line != null) { - sb.append(line); - sb.append(System.lineSeparator()); - line = br.readLine(); - } - } - WSTrustResponse response = WSTrustResponse.parse(sb.toString(), WSTrustVersion.WSTRUST13); - assertNotNull(response); + assertTrue(inner.contains("text"), "innerXml should extract child content"); + assertTrue(inner.contains("child"), "innerXml should include child element tags"); } } diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/WsTrustFederationTest.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/WsTrustFederationTest.java new file mode 100644 index 000000000..98388e510 --- /dev/null +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/WsTrustFederationTest.java @@ -0,0 +1,321 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.aad.msal4j; + +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.util.Collections; + +import static org.junit.jupiter.api.Assertions.*; + +class WsTrustFederationTest { + + // ========== Credential ========== + + @Test + void credential_fromJson_allFields() throws IOException { + String json = "{" + + "\"home_account_id\":\"uid.utid\"," + + "\"environment\":\"login.microsoftonline.com\"," + + "\"client_id\":\"client-123\"," + + "\"secret\":\"secret-token\"," + + "\"user_assertion_hash\":\"hash-abc\"" + + "}"; + + Credential cred = TestHelper.parseJson(json, Credential::fromJson); + + assertEquals("uid.utid", cred.homeAccountId()); + assertEquals("login.microsoftonline.com", cred.environment()); + assertEquals("client-123", cred.clientId()); + assertEquals("secret-token", cred.secret()); + assertEquals("hash-abc", cred.userAssertionHash()); + } + + @Test + void credential_fromJson_partialFields() throws IOException { + String json = "{\"home_account_id\":\"uid\",\"environment\":\"env\"}"; + + Credential cred = TestHelper.parseJson(json, Credential::fromJson); + + assertEquals("uid", cred.homeAccountId()); + assertEquals("env", cred.environment()); + assertNull(cred.clientId()); + assertNull(cred.secret()); + assertNull(cred.userAssertionHash()); + } + + @Test + void credential_fromJson_unknownFieldsSkipped() throws IOException { + String json = "{\"home_account_id\":\"uid\",\"extra\":123}"; + + Credential cred = TestHelper.parseJson(json, Credential::fromJson); + + assertEquals("uid", cred.homeAccountId()); + } + + @Test + void credential_settersAndGetters() { + Credential cred = new Credential(); + + cred.homeAccountId("home-1"); + cred.environment("env-1"); + cred.clientId("client-1"); + cred.secret("secret-1"); + cred.userAssertionHash("hash-1"); + + assertEquals("home-1", cred.homeAccountId()); + assertEquals("env-1", cred.environment()); + assertEquals("client-1", cred.clientId()); + assertEquals("secret-1", cred.secret()); + assertEquals("hash-1", cred.userAssertionHash()); + } + + @Test + void credential_toJson_allFields() throws IOException { + Credential cred = new Credential(); + cred.homeAccountId("uid.utid"); + cred.environment("login.microsoftonline.com"); + cred.clientId("cid"); + cred.secret("sec"); + cred.userAssertionHash("hash"); + + String output = TestHelper.writeToJson(cred); + + assertTrue(output.contains("\"home_account_id\":\"uid.utid\"")); + assertTrue(output.contains("\"environment\":\"login.microsoftonline.com\"")); + assertTrue(output.contains("\"client_id\":\"cid\"")); + assertTrue(output.contains("\"secret\":\"sec\"")); + assertTrue(output.contains("\"user_assertion_hash\":\"hash\"")); + } + + @Test + void credential_toJson_roundTrip() throws IOException { + String json = "{" + + "\"home_account_id\":\"uid\"," + + "\"environment\":\"env\"," + + "\"client_id\":\"cid\"," + + "\"secret\":\"sec\"," + + "\"user_assertion_hash\":\"hash\"" + + "}"; + + Credential original = TestHelper.parseJson(json, Credential::fromJson); + String serialized = TestHelper.writeToJson(original); + Credential roundTripped = TestHelper.parseJson(serialized, Credential::fromJson); + + assertEquals(original.homeAccountId(), roundTripped.homeAccountId()); + assertEquals(original.environment(), roundTripped.environment()); + assertEquals(original.clientId(), roundTripped.clientId()); + assertEquals(original.secret(), roundTripped.secret()); + assertEquals(original.userAssertionHash(), roundTripped.userAssertionHash()); + } + + // ========== BindingPolicy ========== + + @Test + void bindingPolicy_singleArgConstructor() { + BindingPolicy policy = new BindingPolicy("policy-value"); + + assertEquals("policy-value", policy.getValue()); + assertNull(policy.getUrl()); + assertNull(policy.getVersion()); + } + + @Test + void bindingPolicy_twoArgConstructor() { + BindingPolicy policy = new BindingPolicy("https://adfs.example.com/trust", WSTrustVersion.WSTRUST13); + + assertEquals("https://adfs.example.com/trust", policy.getUrl()); + assertEquals(WSTrustVersion.WSTRUST13, policy.getVersion()); + assertNull(policy.getValue()); + } + + @Test + void bindingPolicy_settersAndGetters() { + BindingPolicy policy = new BindingPolicy("initial"); + + policy.setValue("updated-value"); + policy.setUrl("https://new-url"); + policy.setVersion(WSTrustVersion.WSTRUST2005); + + assertEquals("updated-value", policy.getValue()); + assertEquals("https://new-url", policy.getUrl()); + assertEquals(WSTrustVersion.WSTRUST2005, policy.getVersion()); + } + + @Test + void bindingPolicy_versionUndefined() { + BindingPolicy policy = new BindingPolicy("https://url", WSTrustVersion.UNDEFINED); + + assertEquals(WSTrustVersion.UNDEFINED, policy.getVersion()); + } + + // ========== IntegratedWindowsAuthorizationGrant ========== + + @Test + void integratedWindowsAuthGrant_constructor() { + IntegratedWindowsAuthorizationGrant grant = new IntegratedWindowsAuthorizationGrant( + Collections.singleton("openid"), "user@domain.com", null); + + assertEquals("user@domain.com", grant.getUserName()); + assertNull(grant.toParameters()); + } + + @Test + void integratedWindowsAuthGrant_withClaims() { + ClaimsRequest claims = new ClaimsRequest(); + IntegratedWindowsAuthorizationGrant grant = new IntegratedWindowsAuthorizationGrant( + Collections.singleton("profile"), "admin@corp.net", claims); + + assertEquals("admin@corp.net", grant.getUserName()); + } + + // ========== IdToken ========== + + @Test + void idToken_fromJson_allFields() throws IOException { + String json = "{" + + "\"iss\":\"https://login.microsoftonline.com/tenant/v2.0\"," + + "\"sub\":\"sub-123\"," + + "\"aud\":\"client-id\"," + + "\"exp\":1717430400," + + "\"iat\":1717426800," + + "\"nbf\":1717426800," + + "\"name\":\"Test User\"," + + "\"preferred_username\":\"testuser@example.com\"," + + "\"oid\":\"oid-456\"," + + "\"tid\":\"tid-789\"," + + "\"upn\":\"testuser@example.com\"," + + "\"unique_name\":\"testuser\"" + + "}"; + + IdToken idToken = TestHelper.parseJson(json, IdToken::fromJson); + + assertEquals("https://login.microsoftonline.com/tenant/v2.0", idToken.issuer); + assertEquals("sub-123", idToken.subject); + assertEquals("client-id", idToken.audience); + assertEquals(1717430400L, idToken.expirationTime.longValue()); + assertEquals(1717426800L, idToken.issuedAt.longValue()); + assertEquals(1717426800L, idToken.notBefore.longValue()); + assertEquals("Test User", idToken.name); + assertEquals("testuser@example.com", idToken.preferredUsername); + assertEquals("oid-456", idToken.objectIdentifier); + assertEquals("tid-789", idToken.tenantIdentifier); + assertEquals("testuser@example.com", idToken.upn); + assertEquals("testuser", idToken.uniqueName); + } + + @Test + void idToken_fromJson_partialFields() throws IOException { + String json = "{\"iss\":\"issuer\",\"sub\":\"subject\",\"aud\":\"audience\"}"; + + IdToken idToken = TestHelper.parseJson(json, IdToken::fromJson); + + assertEquals("issuer", idToken.issuer); + assertEquals("subject", idToken.subject); + assertEquals("audience", idToken.audience); + assertNull(idToken.expirationTime); + assertNull(idToken.issuedAt); + assertNull(idToken.notBefore); + assertNull(idToken.name); + assertNull(idToken.preferredUsername); + assertNull(idToken.objectIdentifier); + assertNull(idToken.tenantIdentifier); + assertNull(idToken.upn); + assertNull(idToken.uniqueName); + } + + @Test + void idToken_fromJson_unknownFieldsSkipped() throws IOException { + String json = "{\"iss\":\"issuer\",\"nonce\":\"abc\",\"email\":\"user@test.com\"}"; + + IdToken idToken = TestHelper.parseJson(json, IdToken::fromJson); + + assertEquals("issuer", idToken.issuer); + } + + @Test + void idToken_toJson_allFields() throws IOException { + IdToken idToken = new IdToken(); + idToken.issuer = "iss"; + idToken.subject = "sub"; + idToken.audience = "aud"; + idToken.expirationTime = 99999L; + idToken.issuedAt = 11111L; + idToken.notBefore = 11111L; + idToken.name = "User Name"; + idToken.preferredUsername = "user@example.com"; + idToken.objectIdentifier = "oid"; + idToken.tenantIdentifier = "tid"; + idToken.upn = "user@example.com"; + idToken.uniqueName = "unique"; + + String output = TestHelper.writeToJson(idToken); + + assertTrue(output.contains("\"iss\":\"iss\"")); + assertTrue(output.contains("\"sub\":\"sub\"")); + assertTrue(output.contains("\"aud\":\"aud\"")); + assertTrue(output.contains("\"name\":\"User Name\"")); + assertTrue(output.contains("\"preferred_username\":\"user@example.com\"")); + assertTrue(output.contains("\"oid\":\"oid\"")); + assertTrue(output.contains("\"tid\":\"tid\"")); + assertTrue(output.contains("\"upn\":\"user@example.com\"")); + assertTrue(output.contains("\"unique_name\":\"unique\"")); + } + + @Test + void idToken_toJson_roundTrip() throws IOException { + String json = "{" + + "\"iss\":\"issuer\"," + + "\"sub\":\"subject\"," + + "\"aud\":\"audience\"," + + "\"exp\":1234567890," + + "\"iat\":1234567800," + + "\"nbf\":1234567800," + + "\"name\":\"Test\"," + + "\"preferred_username\":\"test@test.com\"," + + "\"oid\":\"oid-1\"," + + "\"tid\":\"tid-1\"," + + "\"upn\":\"upn@test.com\"," + + "\"unique_name\":\"unique-1\"" + + "}"; + + IdToken original = TestHelper.parseJson(json, IdToken::fromJson); + String serialized = TestHelper.writeToJson(original); + IdToken roundTripped = TestHelper.parseJson(serialized, IdToken::fromJson); + + assertEquals(original.issuer, roundTripped.issuer); + assertEquals(original.subject, roundTripped.subject); + assertEquals(original.audience, roundTripped.audience); + assertEquals(original.expirationTime, roundTripped.expirationTime); + assertEquals(original.name, roundTripped.name); + assertEquals(original.preferredUsername, roundTripped.preferredUsername); + assertEquals(original.objectIdentifier, roundTripped.objectIdentifier); + assertEquals(original.tenantIdentifier, roundTripped.tenantIdentifier); + assertEquals(original.upn, roundTripped.upn); + assertEquals(original.uniqueName, roundTripped.uniqueName); + } + + // ========== WSTrustVersion ========== + + @Test + void wsTrustVersion_pathValues() { + assertFalse(WSTrustVersion.WSTRUST13.responseTokenTypePath().isEmpty()); + assertFalse(WSTrustVersion.WSTRUST13.responseSecurityTokenPath().isEmpty()); + + assertFalse(WSTrustVersion.WSTRUST2005.responseTokenTypePath().isEmpty()); + assertFalse(WSTrustVersion.WSTRUST2005.responseSecurityTokenPath().isEmpty()); + + assertTrue(WSTrustVersion.UNDEFINED.responseTokenTypePath().isEmpty()); + assertTrue(WSTrustVersion.UNDEFINED.responseSecurityTokenPath().isEmpty()); + } + + @Test + void wsTrustVersion_wsTrust13_containsCorrectPaths() { + assertTrue(WSTrustVersion.WSTRUST13.responseTokenTypePath() + .contains("RequestSecurityTokenResponseCollection")); + assertTrue(WSTrustVersion.WSTRUST13.responseSecurityTokenPath() + .contains("RequestedSecurityToken")); + } +}