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