From 62fcb335986eb2aa5d89da2af73bc91839d4fd28 Mon Sep 17 00:00:00 2001 From: avdunn Date: Fri, 6 Jun 2025 14:49:49 -0700 Subject: [PATCH 1/7] First draft of new test structure --- .../microsoft/aad/msal4j/RunnerHelper.java | 163 +++++++++++++++++ .../aad/msal4j/RunnerJsonHelper.java | 104 +++++++++++ .../com/microsoft/aad/msal4j/RunnerTest.java | 29 ++++ .../com/microsoft/aad/msal4j/Shortcuts.java | 164 ++++++++++++++++++ 4 files changed, 460 insertions(+) create mode 100644 msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/RunnerHelper.java create mode 100644 msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/RunnerJsonHelper.java create mode 100644 msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/RunnerTest.java create mode 100644 msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/Shortcuts.java diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/RunnerHelper.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/RunnerHelper.java new file mode 100644 index 00000000..5054b0e3 --- /dev/null +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/RunnerHelper.java @@ -0,0 +1,163 @@ +package com.microsoft.aad.msal4j; + +import com.fasterxml.jackson.databind.JsonNode; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static com.microsoft.aad.msal4j.ManagedIdentitySourceType.SERVICE_FABRIC; +import static org.junit.jupiter.api.Assertions.assertEquals; + +import com.microsoft.aad.msal4j.Shortcuts.TestConfig; +import com.microsoft.aad.msal4j.Shortcuts.TestObject; +import com.microsoft.aad.msal4j.Shortcuts.TestAction; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class RunnerHelper { + private static final Logger LOG = LoggerFactory.getLogger(RunnerHelper.class); + + /** + * Create Managed Identity applications from the test configuration. + * This method processes the "arrange" section of the test configuration. + */ + static Map createAppsFromConfig(TestConfig config) { + Map apps = new HashMap<>(); + + for (String appName : config.getAllArrangeObjects()) { + TestObject appObject = config.getArrangeObject(appName); + if ("ManagedIdentityClient".equals(appObject.getType())) { + ManagedIdentityId identityId = createManagedIdentityId(appObject); + List capabilities = extractClientCapabilities(appObject); + IEnvironmentVariables envVars = createEnvironmentVariables(config); + // TODO: other application properties + + ManagedIdentityApplication app = ManagedIdentityApplication.builder(identityId) + .clientCapabilities(capabilities) + .build(); + + ManagedIdentityApplication.setEnvironmentVariables(envVars); + + apps.put(appName, app); + } //TODO: Confidential and public clients + } + + return apps; + } + + /** + * Execute an action and return the result + * This method uses the "act" section of the test configuration. + */ + static IAuthenticationResult executeAction(ManagedIdentityApplication app, TestAction action) throws Exception { + if (action.getMethodName().equals("AcquireTokenForManagedIdentity")) { + LOG.info(String.format("Executing action: %s", action.getMethodName())); + + ManagedIdentityParameters params = buildManagedIdentityParameters(action); + + IAuthenticationResult result = app.acquireTokenForManagedIdentity(params).get(); + + LOG.info("Action result:"); + LOG.info(String.format("Access Token: %s", result.accessToken())); + LOG.info(String.format("ID Token : %s", result.idToken())); + LOG.info(String.format("Account : %s", result.account())); + LOG.info(String.format("Token Source: %s", result.metadata().tokenSource())); + + return result; + } else { + //TODO: other token calls and apps + throw new UnsupportedOperationException("Unsupported action: " + action.getMethodName()); + } + } + + /** + * Validate assertions against a result. + * This method uses the "assert" section of the test configuration. + */ + static void validateAssertions(IAuthenticationResult result, Map assertions) { + assertions.forEach((key, value) -> { + switch (key) { + case "token_source": + LOG.info("Validating token source"); + validateTokenSource(value.asText(), result); + break; + //TODO: other assertions + default: + // Optional: Handle unknown assertion types + break; + } + }); + } + + /** + * Create managed identity ID from test object + */ + static ManagedIdentityId createManagedIdentityId(TestObject appObject) { + String idType = appObject.getProperty("managed_identity").get("ManagedIdentityIdType").asText(); + + if ("SystemAssigned".equals(idType)) { + return ManagedIdentityId.systemAssigned(); + } else { + // TODO: handle user assertions + return null; + } + } + + /** + * Extract client capabilities from test object + */ + static List extractClientCapabilities(TestObject testObject) { + List capabilities = new ArrayList<>(); + JsonNode capabilitiesNode = testObject.getProperty("client_capabilities"); + + if (capabilitiesNode != null && capabilitiesNode.isArray()) { + capabilitiesNode.forEach(node -> capabilities.add(node.asText())); + } + + LOG.info(String.format("Extracted client capabilities: %s", capabilities)); + + return capabilities; + } + + //TODO: Re-used from other Managed Identity tests, specific to this proof-of-concept but should be more generic + static IEnvironmentVariables createEnvironmentVariables(TestConfig config) { + return new EnvironmentVariablesHelper( + SERVICE_FABRIC, + config.getEnvironmentVariable("IDENTITY_ENDPOINT")); + } + + /** + * Build parameters for token acquisition + */ + static ManagedIdentityParameters buildManagedIdentityParameters(TestAction action) { + String resource = action.getParameter("resource").asText(); + + LOG.info(String.format("Building ManagedIdentityParameters with resource: %s", resource)); + + ManagedIdentityParameters.ManagedIdentityParametersBuilder builder = + ManagedIdentityParameters.builder(resource); + + // Add optional claims challenge + if (action.hasParameter("claims_challenge")) { + builder.claims(action.getParameter("claims_challenge").asText()); + } + + //TODO: other parameters + + return builder.build(); + } + + /** + * Validate token source assertion, either cache or identity provider + */ + static void validateTokenSource(String expectedSource, IAuthenticationResult result) { + TokenSource expected = "identity_provider".equals(expectedSource) ? + TokenSource.IDENTITY_PROVIDER : TokenSource.CACHE; + LOG.info(String.format("Expected token source: %s", expected)); + LOG.info(String.format("Actual token source : %s", result.metadata().tokenSource())); + + assertEquals(expected, result.metadata().tokenSource()); + } +} diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/RunnerJsonHelper.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/RunnerJsonHelper.java new file mode 100644 index 00000000..67df806a --- /dev/null +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/RunnerJsonHelper.java @@ -0,0 +1,104 @@ +package com.microsoft.aad.msal4j; + +import com.fasterxml.jackson.databind.JsonNode; + +import java.util.HashMap; +import java.util.Map; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.microsoft.aad.msal4j.Shortcuts.TestConfig; +import com.microsoft.aad.msal4j.Shortcuts.TestObject; +import com.microsoft.aad.msal4j.Shortcuts.TestStep; +import com.microsoft.aad.msal4j.Shortcuts.TestAction; + +//TODO: Too specific for the test case used in this proof-of-concept, should be able to reuse the regular JsonHelper class +class RunnerJsonHelper { + private static final ObjectMapper mapper = new ObjectMapper(); + + /** + * Parse test configuration from JSON string + */ + static TestConfig parseTestConfig(String jsonContent) { + try { + return JsonParser.parseConfig(mapper.readTree(jsonContent)); + } catch (Exception e) { + throw new RuntimeException("Failed to parse test configuration: " + e.getMessage(), e); + } + } + + /** + * Helper class for parsing JSON into test configuration + */ + private static class JsonParser { + static TestConfig parseConfig(JsonNode rootNode) { + TestConfig.Builder builder = new TestConfig.Builder() + .type(rootNode.path("type").asText()) + .version(rootNode.path("ver").asInt()); + + parseEnvironment(rootNode.path("env"), builder); + parseArrangement(rootNode.path("arrange"), builder); + parseSteps(rootNode.path("steps"), builder); + + return builder.build(); + } + + private static void parseEnvironment(JsonNode envNode, TestConfig.Builder builder) { + envNode.fields().forEachRemaining(entry -> + builder.addEnvironmentVariable(entry.getKey(), entry.getValue().asText())); + } + + private static void parseArrangement(JsonNode arrangeNode, TestConfig.Builder builder) { + arrangeNode.fields().forEachRemaining(appEntry -> { + String appName = appEntry.getKey(); + JsonNode appNode = appEntry.getValue(); + + appNode.fields().forEachRemaining(classEntry -> { + String classType = classEntry.getKey(); + JsonNode classNode = classEntry.getValue(); + + Map properties = new HashMap<>(); + classNode.fields().forEachRemaining(prop -> + properties.put(prop.getKey(), prop.getValue())); + + builder.addArrangedObject(appName, + new TestObject(appName, classType, properties)); + }); + }); + } + + private static void parseSteps(JsonNode stepsNode, TestConfig.Builder builder) { + for (JsonNode stepNode : stepsNode) { + TestAction action = parseAction(stepNode.path("act")); + Map assertions = parseAssertions(stepNode.path("assert")); + + if (action != null) { + builder.addStep(new TestStep(action, assertions)); + } + } + } + + private static TestAction parseAction(JsonNode actNode) { + if (actNode.isMissingNode()) return null; + + String actorKey = actNode.fieldNames().next(); + String[] actorParts = actorKey.split("\\.", 2); + + Map parameters = new HashMap<>(); + actNode.get(actorKey).fields().forEachRemaining(entry -> + parameters.put(entry.getKey(), entry.getValue())); + + return new TestAction(actorParts[0], actorParts[1], parameters); + } + + private static Map parseAssertions(JsonNode assertNode) { + Map assertions = new HashMap<>(); + + if (!assertNode.isMissingNode()) { + assertNode.fields().forEachRemaining(entry -> + assertions.put(entry.getKey(), entry.getValue())); + } + + return assertions; + } + } +} diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/RunnerTest.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/RunnerTest.java new file mode 100644 index 00000000..f4d541b6 --- /dev/null +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/RunnerTest.java @@ -0,0 +1,29 @@ +package com.microsoft.aad.msal4j; + +import org.junit.jupiter.api.Test; + +import com.microsoft.aad.msal4j.Shortcuts.TestConfig; +import com.microsoft.aad.msal4j.Shortcuts.TestStep; + +import java.util.Map; + +class RunnerTest { + + @Test + void testManagedIdentityWithJsonConfig() throws Exception { + //TODO: get test cases list from the server + TestConfig config = RunnerJsonHelper.parseTestConfig(Shortcuts.getTestConfigJson()); + Map apps = RunnerHelper.createAppsFromConfig(config); + + for (ManagedIdentityApplication app : apps.values()) { + //Execute the "steps" section of the test config + for (TestStep step : config.getSteps()) { + //Execute the "act" section of the test config + IAuthenticationResult result = RunnerHelper.executeAction(app, step.getAction()); + + //Execute the "assert" section of the test config + RunnerHelper.validateAssertions(result, step.getAssertions()); + } + } + } +} diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/Shortcuts.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/Shortcuts.java new file mode 100644 index 00000000..ee798ec6 --- /dev/null +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/Shortcuts.java @@ -0,0 +1,164 @@ +package com.microsoft.aad.msal4j; + +import com.fasterxml.jackson.databind.JsonNode; + +import java.util.*; + +class Shortcuts { + //Various helpers and utilities for simplifying the first proof-of-concept implementation. + //They don't follow any design doc, and would not be part of the final implementation. + + //Represents test cases that are stored in the server. JSON is used here because the library already knows how to parse JSON, + // but the 'real' implementation could use another format. + private static final String MI_CAPABILITY_SML = "{\n" + + " \"type\": \"MSAL Test\",\n" + + " \"ver\": 1,\n" + + " \"env\": {\n" + + " \"IDENTITY_ENDPOINT\": \"fill in\",\n" + + " \"IDENTITY_HEADER\": \"foo\",\n" + + " \"IDENTITY_SERVER_THUMBPRINT\": \"bar\"\n" + + " },\n" + + " \"arrange\": {\n" + + " \"app1\": {\n" + + " \"ManagedIdentityClient\": {\n" + + " \"managed_identity\": {\n" + + " \"ManagedIdentityIdType\": \"SystemAssigned\",\n" + + " \"Id\": null\n" + + " },\n" + + " \"client_capabilities\": [\"cp1\", \"cp2\"]\n" + + " }\n" + + " }\n" + + " },\n" + + " \"steps\": [\n" + + " {\n" + + " \"act\": {\n" + + " \"app1.AcquireTokenForManagedIdentity\": {\n" + + " \"resource\": \"R\"\n" + + " }\n" + + " },\n" + + " \"assert\": {\n" + + " \"token_type\": \"Bearer\",\n" + + " \"token_source\": \"identity_provider\"\n" + + " }\n" + + " },\n" + + " {\n" + + " \"act\": {\n" + + " \"app1.AcquireTokenForManagedIdentity\": {\n" + + " \"resource\": \"R\"\n" + + " }\n" + + " },\n" + + " \"assert\": {\n" + + " \"token_type\": \"Bearer\",\n" + + " \"token_source\": \"cache\"\n" + + " }\n" + + " },\n" + + " {\n" + + " \"act\": {\n" + + " \"app1.AcquireTokenForManagedIdentity\": {\n" + + " \"resource\": \"R\",\n" + + " \"claims_challenge\": \"{\\\"capability test case likely needs\\\": \\\"a valid json object\\\"}\"\n" + + " }\n" + + " },\n" + + " \"assert\": {\n" + + " \"token_type\": \"Bearer\",\n" + + " \"token_source\": \"identity_provider\"\n" + + " }\n" + + " }\n" + + " ]\n" + + "}"; + + //Represents a response from the tests cases server, would be + static String getTestConfigJson() { + return MI_CAPABILITY_SML; + } + + //The following static classes would ideally extend Azure JSON's JsonSerializable or some equivalent YAML parsing interface, + // but for now they are simple classes representing the structure of a test configuration. + static class TestConfig { + private final String type; + private final int version; + private final Map environment; + private final Map arrangeObjects; + private final List steps; + + private TestConfig(Shortcuts.TestConfig.Builder builder) { + this.type = builder.type; + this.version = builder.version; + this.environment = Collections.unmodifiableMap(builder.environment); + this.arrangeObjects = Collections.unmodifiableMap(builder.arrangeObjects); + this.steps = Collections.unmodifiableList(builder.steps); + } + + String getType() { return type; } + int getVersion() { return version; } + String getEnvironmentVariable(String name) { return environment.get(name); } + Map getAllEnvironmentVariables() { return environment; } + Shortcuts.TestObject getArrangeObject(String name) { return arrangeObjects.get(name); } + List getAllArrangeObjects() { return new ArrayList<>(arrangeObjects.keySet()); } + List getSteps() { return steps; } + + static class Builder { + private String type; + private int version; + private final Map environment = new HashMap<>(); + private final Map arrangeObjects = new HashMap<>(); + private final List steps = new ArrayList<>(); + + Shortcuts.TestConfig.Builder type(String type) { this.type = type; return this; } + Shortcuts.TestConfig.Builder version(int version) { this.version = version; return this; } + Shortcuts.TestConfig.Builder addEnvironmentVariable(String name, String value) { environment.put(name, value); return this; } + Shortcuts.TestConfig.Builder addArrangedObject(String name, Shortcuts.TestObject object) { arrangeObjects.put(name, object); return this; } + Shortcuts.TestConfig.Builder addStep(Shortcuts.TestStep step) { steps.add(step); return this; } + + Shortcuts.TestConfig build() { return new Shortcuts.TestConfig(this); } + } + } + + static class TestObject { + private final String name; + private final String type; + private final Map properties; + + TestObject(String name, String type, Map properties) { + this.name = name; + this.type = type; + this.properties = Collections.unmodifiableMap(new HashMap<>(properties)); + } + + String getName() { return name; } + String getType() { return type; } + JsonNode getProperty(String name) { return properties.get(name); } + } + + static class TestStep { + private final Shortcuts.TestAction action; + private final Map assertions; + + TestStep(Shortcuts.TestAction action, Map assertions) { + this.action = action; + this.assertions = Collections.unmodifiableMap(new HashMap<>(assertions)); + } + + Shortcuts.TestAction getAction() { return action; } + JsonNode getAssertion(String key) { return assertions.get(key); } + Map getAssertions() { return assertions; } + } + + static class TestAction { + private final String targetObject; + private final String methodName; + private final Map parameters; + + TestAction(String targetObject, String methodName, Map parameters) { + this.targetObject = targetObject; + this.methodName = methodName; + this.parameters = Collections.unmodifiableMap(new HashMap<>(parameters)); + } + + String getTargetObject() { return targetObject; } + String getMethodName() { return methodName; } + JsonNode getParameter(String name) { return parameters.get(name); } + boolean hasParameter(String name) { return parameters.containsKey(name); } + Map getParameters() { return parameters; } + } +} From 11abc43952e479cb0a4ca8a603646900bc22732a Mon Sep 17 00:00:00 2001 From: avdunn Date: Sun, 8 Jun 2025 12:44:09 -0700 Subject: [PATCH 2/7] Use configs from server --- .../microsoft/aad/msal4j/RunnerHelper.java | 114 +++++-- .../aad/msal4j/RunnerJsonHelper.java | 81 +++++ .../com/microsoft/aad/msal4j/RunnerTest.java | 50 ++- .../com/microsoft/aad/msal4j/Shortcuts.java | 313 ++++++++++-------- 4 files changed, 383 insertions(+), 175 deletions(-) diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/RunnerHelper.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/RunnerHelper.java index 5054b0e3..cd792552 100644 --- a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/RunnerHelper.java +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/RunnerHelper.java @@ -2,12 +2,11 @@ import com.fasterxml.jackson.databind.JsonNode; +import java.io.IOException; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; - -import static com.microsoft.aad.msal4j.ManagedIdentitySourceType.SERVICE_FABRIC; import static org.junit.jupiter.api.Assertions.assertEquals; import com.microsoft.aad.msal4j.Shortcuts.TestConfig; @@ -31,7 +30,7 @@ static Map createAppsFromConfig(TestConfig c if ("ManagedIdentityClient".equals(appObject.getType())) { ManagedIdentityId identityId = createManagedIdentityId(appObject); List capabilities = extractClientCapabilities(appObject); - IEnvironmentVariables envVars = createEnvironmentVariables(config); + IEnvironmentVariables envVars = setEnvironmentVariables(config); // TODO: other application properties ManagedIdentityApplication app = ManagedIdentityApplication.builder(identityId) @@ -53,21 +52,19 @@ static Map createAppsFromConfig(TestConfig c */ static IAuthenticationResult executeAction(ManagedIdentityApplication app, TestAction action) throws Exception { if (action.getMethodName().equals("AcquireTokenForManagedIdentity")) { - LOG.info(String.format("Executing action: %s", action.getMethodName())); - + LOG.info(String.format("===Executing action: %s", action.getMethodName())); ManagedIdentityParameters params = buildManagedIdentityParameters(action); - IAuthenticationResult result = app.acquireTokenForManagedIdentity(params).get(); - LOG.info("Action result:"); - LOG.info(String.format("Access Token: %s", result.accessToken())); - LOG.info(String.format("ID Token : %s", result.idToken())); - LOG.info(String.format("Account : %s", result.account())); - LOG.info(String.format("Token Source: %s", result.metadata().tokenSource())); + LOG.info("---Action result"); + LOG.info(String.format("-Access Token: %s", result.accessToken())); + LOG.info(String.format("-ID Token : %s", result.idToken())); + LOG.info(String.format("-Account : %s", result.account())); + LOG.info(String.format("-Token Source: %s", result.metadata().tokenSource())); return result; } else { - //TODO: other token calls and apps + //TODO: other token calls and confidential/public client apps throw new UnsupportedOperationException("Unsupported action: " + action.getMethodName()); } } @@ -80,10 +77,10 @@ static void validateAssertions(IAuthenticationResult result, Map { switch (key) { case "token_source": - LOG.info("Validating token source"); + LOG.info("===Validating token source"); validateTokenSource(value.asText(), result); break; - //TODO: other assertions + //TODO: other assertions, such as exceptions checks, token content, etc. default: // Optional: Handle unknown assertion types break; @@ -95,13 +92,23 @@ static void validateAssertions(IAuthenticationResult result, Map extractClientCapabilities(TestObject testObject) { capabilitiesNode.forEach(node -> capabilities.add(node.asText())); } - LOG.info(String.format("Extracted client capabilities: %s", capabilities)); + LOG.info(String.format("---Extracted client capabilities: %s", capabilities)); return capabilities; } - //TODO: Re-used from other Managed Identity tests, specific to this proof-of-concept but should be more generic - static IEnvironmentVariables createEnvironmentVariables(TestConfig config) { - return new EnvironmentVariablesHelper( - SERVICE_FABRIC, - config.getEnvironmentVariable("IDENTITY_ENDPOINT")); + /** + * Creates provider for mocked environment variables using the test configuration. + * + * @param config The test configuration containing the environment variables + * @return An IEnvironmentVariables implementation with the configured variables + */ + static IEnvironmentVariables setEnvironmentVariables(TestConfig config) { + // Get all environment variables from the config + final Map envVars = config.getAllEnvironmentVariables(); + + LOG.info(String.format("---Configured environment variables: %s", envVars.keySet())); + + return new IEnvironmentVariables() { + @Override + public String getEnvironmentVariable(String envVariable) { + return envVars.get(envVariable); + } + }; } /** @@ -141,7 +161,8 @@ static ManagedIdentityParameters buildManagedIdentityParameters(TestAction actio // Add optional claims challenge if (action.hasParameter("claims_challenge")) { - builder.claims(action.getParameter("claims_challenge").asText()); + String validatedClaimsChallenge = Shortcuts.validateAndGetClaimsChallenge(action); + builder.claims(validatedClaimsChallenge); } //TODO: other parameters @@ -155,9 +176,42 @@ static ManagedIdentityParameters buildManagedIdentityParameters(TestAction actio static void validateTokenSource(String expectedSource, IAuthenticationResult result) { TokenSource expected = "identity_provider".equals(expectedSource) ? TokenSource.IDENTITY_PROVIDER : TokenSource.CACHE; - LOG.info(String.format("Expected token source: %s", expected)); - LOG.info(String.format("Actual token source : %s", result.metadata().tokenSource())); + LOG.info(String.format("---Expected token source: %s", expected)); + LOG.info(String.format("---Actual token source : %s", result.metadata().tokenSource())); assertEquals(expected, result.metadata().tokenSource()); } + + /** + * Complete workflow to get all test case configs + * + * @param indexEndpoint The URL of the index containing test case URLs + * @return Map of test case names to their JSON configurations + */ + static Map getAllTestCaseConfigs(String indexEndpoint) throws IOException { + // Get list of SML test case URLs + List smlUrls = RunnerJsonHelper.getTestCaseUrlsFromEndpoint(indexEndpoint); + + // Convert SML URLs to JSON URLs + List jsonUrls = Shortcuts.convertSmlUrlsToJsonUrls(smlUrls); + + // Fetch content for each JSON URL + Map testCaseConfigs = new HashMap<>(); + for (String jsonUrl : jsonUrls) { + String testCaseName = extractTestCaseName(jsonUrl); + JsonNode config = RunnerJsonHelper.fetchJsonContent(jsonUrl); + testCaseConfigs.put(testCaseName, config); + } + + return testCaseConfigs; + } + + /** + * Extract test case name from URL + */ + private static String extractTestCaseName(String url) { + String[] parts = url.split("/"); + String fileName = parts[parts.length - 1]; + return fileName.substring(0, fileName.lastIndexOf('.')); + } } diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/RunnerJsonHelper.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/RunnerJsonHelper.java index 67df806a..ebaf181c 100644 --- a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/RunnerJsonHelper.java +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/RunnerJsonHelper.java @@ -2,7 +2,13 @@ import com.fasterxml.jackson.databind.JsonNode; +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.net.URL; +import java.util.ArrayList; import java.util.HashMap; +import java.util.List; import java.util.Map; import com.fasterxml.jackson.databind.ObjectMapper; @@ -11,6 +17,8 @@ import com.microsoft.aad.msal4j.Shortcuts.TestStep; import com.microsoft.aad.msal4j.Shortcuts.TestAction; +import javax.net.ssl.HttpsURLConnection; + //TODO: Too specific for the test case used in this proof-of-concept, should be able to reuse the regular JsonHelper class class RunnerJsonHelper { private static final ObjectMapper mapper = new ObjectMapper(); @@ -101,4 +109,77 @@ private static Map parseAssertions(JsonNode assertNode) { return assertions; } } + + /** + * Fetches test case URLs from an endpoint containing JSON with a "testcases" array. + * + * @param endpointUrl The URL to fetch test cases from + * @return List of test case URLs + */ + static List getTestCaseUrlsFromEndpoint(String endpointUrl) throws IOException { + List testCaseUrls = new ArrayList<>(); + + URL url = new URL(endpointUrl); + HttpsURLConnection connection = (HttpsURLConnection) url.openConnection(); + connection.setRequestMethod("GET"); + connection.setRequestProperty("Content-Type", "application/json"); + + int responseCode = connection.getResponseCode(); + if (responseCode == HttpsURLConnection.HTTP_OK) { + try (BufferedReader reader = new BufferedReader( + new InputStreamReader(connection.getInputStream()))) { + StringBuilder response = new StringBuilder(); + String line; + while ((line = reader.readLine()) != null) { + response.append(line); + } + + ObjectMapper mapper = new ObjectMapper(); + JsonNode rootNode = mapper.readTree(response.toString()); + + if (rootNode.has("testcases") && rootNode.get("testcases").isArray()) { + JsonNode testcasesNode = rootNode.get("testcases"); + for (JsonNode testcase : testcasesNode) { + testCaseUrls.add(testcase.asText()); + } + } else { + throw new IllegalStateException("JSON response does not contain a 'testcases' array"); + } + } + } else { + throw new IOException("HTTP request failed with status code: " + responseCode); + } + + return testCaseUrls; + } + + /** + * Fetches JSON content from a URL + * + * @param jsonUrl The URL to fetch JSON content from + * @return The JSON content parsed as a JsonNode + */ + static JsonNode fetchJsonContent(String jsonUrl) throws IOException { + URL url = new URL(jsonUrl); + HttpsURLConnection connection = (HttpsURLConnection) url.openConnection(); + connection.setRequestMethod("GET"); + connection.setRequestProperty("Content-Type", "application/json"); + + int responseCode = connection.getResponseCode(); + if (responseCode == HttpsURLConnection.HTTP_OK) { + try (BufferedReader reader = new BufferedReader( + new InputStreamReader(connection.getInputStream()))) { + StringBuilder response = new StringBuilder(); + String line; + while ((line = reader.readLine()) != null) { + response.append(line); + } + + ObjectMapper mapper = new ObjectMapper(); + return mapper.readTree(response.toString()); + } + } else { + throw new IOException("Failed to fetch JSON content. HTTP status: " + responseCode); + } + } } diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/RunnerTest.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/RunnerTest.java index f4d541b6..9765f954 100644 --- a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/RunnerTest.java +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/RunnerTest.java @@ -1,29 +1,59 @@ package com.microsoft.aad.msal4j; -import org.junit.jupiter.api.Test; - +import com.fasterxml.jackson.databind.JsonNode; import com.microsoft.aad.msal4j.Shortcuts.TestConfig; import com.microsoft.aad.msal4j.Shortcuts.TestStep; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import java.util.Map; +import java.util.stream.Stream; class RunnerTest { + private static final Logger LOG = LoggerFactory.getLogger(RunnerTest.class); + + /** + * Defines a set of test cases for a single unit test to run. + */ + static Stream managedIdentityTestsProvider() { + return Stream.of( + "mi_capability", + "token_sha256_to_refresh", + "mi_vm_pod" + ); + } + + @ParameterizedTest + @MethodSource("managedIdentityTestsProvider") + void runManagedIdentityTest(String testCaseName) throws Exception { + LOG.info("==========Executing Test Case=========="); + + // Get all test configurations + Map configs = RunnerHelper.getAllTestCaseConfigs("https://smile-test.azurewebsites.net/testcases.json"); - @Test - void testManagedIdentityWithJsonConfig() throws Exception { - //TODO: get test cases list from the server - TestConfig config = RunnerJsonHelper.parseTestConfig(Shortcuts.getTestConfigJson()); + LOG.info(String.format("---Found test case: %s", configs.get(testCaseName).toString())); + + TestConfig config = RunnerJsonHelper.parseTestConfig(configs.get(testCaseName).toString()); + + // Create applications from the configuration Map apps = RunnerHelper.createAppsFromConfig(config); + // For each application, execute all steps for (ManagedIdentityApplication app : apps.values()) { - //Execute the "steps" section of the test config + app.tokenCache.accessTokens.clear(); // Clear the static token cache for each test run + + // Execute each step in the test configuration for (TestStep step : config.getSteps()) { - //Execute the "act" section of the test config + LOG.info("----------Executing step----------"); + + // Execute the action IAuthenticationResult result = RunnerHelper.executeAction(app, step.getAction()); - //Execute the "assert" section of the test config + // Validate assertions RunnerHelper.validateAssertions(result, step.getAssertions()); } } } -} +} \ No newline at end of file diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/Shortcuts.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/Shortcuts.java index ee798ec6..de607980 100644 --- a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/Shortcuts.java +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/Shortcuts.java @@ -1,153 +1,149 @@ package com.microsoft.aad.msal4j; import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; import java.util.*; +import java.util.stream.Collectors; class Shortcuts { //Various helpers and utilities for simplifying the first proof-of-concept implementation. - //They don't follow any design doc, and would not be part of the final implementation. - - //Represents test cases that are stored in the server. JSON is used here because the library already knows how to parse JSON, - // but the 'real' implementation could use another format. - private static final String MI_CAPABILITY_SML = "{\n" + - " \"type\": \"MSAL Test\",\n" + - " \"ver\": 1,\n" + - " \"env\": {\n" + - " \"IDENTITY_ENDPOINT\": \"fill in\",\n" + - " \"IDENTITY_HEADER\": \"foo\",\n" + - " \"IDENTITY_SERVER_THUMBPRINT\": \"bar\"\n" + - " },\n" + - " \"arrange\": {\n" + - " \"app1\": {\n" + - " \"ManagedIdentityClient\": {\n" + - " \"managed_identity\": {\n" + - " \"ManagedIdentityIdType\": \"SystemAssigned\",\n" + - " \"Id\": null\n" + - " },\n" + - " \"client_capabilities\": [\"cp1\", \"cp2\"]\n" + - " }\n" + - " }\n" + - " },\n" + - " \"steps\": [\n" + - " {\n" + - " \"act\": {\n" + - " \"app1.AcquireTokenForManagedIdentity\": {\n" + - " \"resource\": \"R\"\n" + - " }\n" + - " },\n" + - " \"assert\": {\n" + - " \"token_type\": \"Bearer\",\n" + - " \"token_source\": \"identity_provider\"\n" + - " }\n" + - " },\n" + - " {\n" + - " \"act\": {\n" + - " \"app1.AcquireTokenForManagedIdentity\": {\n" + - " \"resource\": \"R\"\n" + - " }\n" + - " },\n" + - " \"assert\": {\n" + - " \"token_type\": \"Bearer\",\n" + - " \"token_source\": \"cache\"\n" + - " }\n" + - " },\n" + - " {\n" + - " \"act\": {\n" + - " \"app1.AcquireTokenForManagedIdentity\": {\n" + - " \"resource\": \"R\",\n" + - " \"claims_challenge\": \"{\\\"capability test case likely needs\\\": \\\"a valid json object\\\"}\"\n" + - " }\n" + - " },\n" + - " \"assert\": {\n" + - " \"token_type\": \"Bearer\",\n" + - " \"token_source\": \"identity_provider\"\n" + - " }\n" + - " }\n" + - " ]\n" + - "}"; - - //Represents a response from the tests cases server, would be - static String getTestConfigJson() { - return MI_CAPABILITY_SML; - } - - //The following static classes would ideally extend Azure JSON's JsonSerializable or some equivalent YAML parsing interface, - // but for now they are simple classes representing the structure of a test configuration. - static class TestConfig { - private final String type; - private final int version; - private final Map environment; - private final Map arrangeObjects; - private final List steps; - - private TestConfig(Shortcuts.TestConfig.Builder builder) { - this.type = builder.type; - this.version = builder.version; - this.environment = Collections.unmodifiableMap(builder.environment); - this.arrangeObjects = Collections.unmodifiableMap(builder.arrangeObjects); - this.steps = Collections.unmodifiableList(builder.steps); - } + //They don't follow any design doc or language conventions, and would not be part of the final implementation. + + //=====The following static classes would ideally extend Azure JSON's JsonSerializable or some equivalent YAML parsing interface, + // but for now they are simple classes representing the structure of a test configuration. + static class TestConfig { + private final String type; + private final int version; + private final Map environment; + private final Map arrangeObjects; + private final List steps; + private static final ObjectMapper mapper = new ObjectMapper(); + + private TestConfig(Shortcuts.TestConfig.Builder builder) { + this.type = builder.type; + this.version = builder.version; + this.environment = Collections.unmodifiableMap(builder.environment); + this.arrangeObjects = Collections.unmodifiableMap(builder.arrangeObjects); + this.steps = Collections.unmodifiableList(builder.steps); + } - String getType() { return type; } - int getVersion() { return version; } - String getEnvironmentVariable(String name) { return environment.get(name); } - Map getAllEnvironmentVariables() { return environment; } - Shortcuts.TestObject getArrangeObject(String name) { return arrangeObjects.get(name); } - List getAllArrangeObjects() { return new ArrayList<>(arrangeObjects.keySet()); } - List getSteps() { return steps; } - - static class Builder { - private String type; - private int version; - private final Map environment = new HashMap<>(); - private final Map arrangeObjects = new HashMap<>(); - private final List steps = new ArrayList<>(); - - Shortcuts.TestConfig.Builder type(String type) { this.type = type; return this; } - Shortcuts.TestConfig.Builder version(int version) { this.version = version; return this; } - Shortcuts.TestConfig.Builder addEnvironmentVariable(String name, String value) { environment.put(name, value); return this; } - Shortcuts.TestConfig.Builder addArrangedObject(String name, Shortcuts.TestObject object) { arrangeObjects.put(name, object); return this; } - Shortcuts.TestConfig.Builder addStep(Shortcuts.TestStep step) { steps.add(step); return this; } - - Shortcuts.TestConfig build() { return new Shortcuts.TestConfig(this); } - } + String getType() { + return type; + } + + int getVersion() { + return version; + } + + String getEnvironmentVariable(String name) { + return environment.get(name); + } + + Map getAllEnvironmentVariables() { + return environment; + } + + Shortcuts.TestObject getArrangeObject(String name) { + return arrangeObjects.get(name); + } + + List getAllArrangeObjects() { + return new ArrayList<>(arrangeObjects.keySet()); + } + + List getSteps() { + return steps; } - static class TestObject { - private final String name; - private final String type; - private final Map properties; + static class Builder { + private String type; + private int version; + private final Map environment = new HashMap<>(); + private final Map arrangeObjects = new HashMap<>(); + private final List steps = new ArrayList<>(); - TestObject(String name, String type, Map properties) { - this.name = name; + Shortcuts.TestConfig.Builder type(String type) { this.type = type; - this.properties = Collections.unmodifiableMap(new HashMap<>(properties)); + return this; } - String getName() { return name; } - String getType() { return type; } - JsonNode getProperty(String name) { return properties.get(name); } - } + Shortcuts.TestConfig.Builder version(int version) { + this.version = version; + return this; + } + + Shortcuts.TestConfig.Builder addEnvironmentVariable(String name, String value) { + environment.put(name, value); + return this; + } - static class TestStep { - private final Shortcuts.TestAction action; - private final Map assertions; + Shortcuts.TestConfig.Builder addArrangedObject(String name, Shortcuts.TestObject object) { + arrangeObjects.put(name, object); + return this; + } - TestStep(Shortcuts.TestAction action, Map assertions) { - this.action = action; - this.assertions = Collections.unmodifiableMap(new HashMap<>(assertions)); + Shortcuts.TestConfig.Builder addStep(Shortcuts.TestStep step) { + steps.add(step); + return this; } - Shortcuts.TestAction getAction() { return action; } - JsonNode getAssertion(String key) { return assertions.get(key); } - Map getAssertions() { return assertions; } + Shortcuts.TestConfig build() { + return new Shortcuts.TestConfig(this); + } } + } - static class TestAction { - private final String targetObject; - private final String methodName; - private final Map parameters; + static class TestObject { + private final String name; + private final String type; + private final Map properties; + + TestObject(String name, String type, Map properties) { + this.name = name; + this.type = type; + this.properties = Collections.unmodifiableMap(new HashMap<>(properties)); + } + + String getName() { + return name; + } + + String getType() { + return type; + } + + JsonNode getProperty(String name) { + return properties.get(name); + } + } + + static class TestStep { + private final Shortcuts.TestAction action; + private final Map assertions; + + TestStep(Shortcuts.TestAction action, Map assertions) { + this.action = action; + this.assertions = Collections.unmodifiableMap(new HashMap<>(assertions)); + } + + Shortcuts.TestAction getAction() { + return action; + } + + JsonNode getAssertion(String key) { + return assertions.get(key); + } + + Map getAssertions() { + return assertions; + } + } + + static class TestAction { + private final String targetObject; + private final String methodName; + private final Map parameters; TestAction(String targetObject, String methodName, Map parameters) { this.targetObject = targetObject; @@ -155,10 +151,57 @@ static class TestAction { this.parameters = Collections.unmodifiableMap(new HashMap<>(parameters)); } - String getTargetObject() { return targetObject; } - String getMethodName() { return methodName; } - JsonNode getParameter(String name) { return parameters.get(name); } - boolean hasParameter(String name) { return parameters.containsKey(name); } - Map getParameters() { return parameters; } + String getTargetObject() { + return targetObject; + } + + String getMethodName() { + return methodName; + } + + JsonNode getParameter(String name) { + return parameters.get(name); + } + + boolean hasParameter(String name) { + return parameters.containsKey(name); + } + + Map getParameters() { + return parameters; + } + } + + //=====The following methods are small fixes for issues in the test configuration JSONs + + //Some URLs in the test configurations are malformed and are missing a slash before "test". + static String fixAzureWebsiteUrls(String jsonString) { + return jsonString.replace( + "https://smile-test.azurewebsites.nettest/token", + "https://smile-test.azurewebsites.net/test/token"); + } + + //Some test configurations use a claims challenge that is not valid JSON. + static String validateAndGetClaimsChallenge(TestAction action) { + if (!action.hasParameter("claims_challenge")) { + return null; + } + + String claimsChallenge = action.getParameter("claims_challenge").asText(); + try { + // Try to parse the claims challenge as JSON + new ObjectMapper().readTree(claimsChallenge); + return claimsChallenge; + } catch (Exception e) { + return TestConfiguration.CLAIMS_CHALLENGE; + } + } + + //The server has a public list of endpoints leading to .sml files, but there are JSON equivalents for those files at endpoints ending with .json + static List convertSmlUrlsToJsonUrls(List smlUrls) { + return smlUrls.stream() + .filter(url -> url.endsWith(".sml")) + .map(url -> url.substring(0, url.length() - 4) + ".json") + .collect(Collectors.toList()); } -} +} \ No newline at end of file From 10e66ff5dbf87c9b8e3eb96c0e96272965da1504 Mon Sep 17 00:00:00 2001 From: avdunn Date: Fri, 13 Jun 2025 16:14:02 -0700 Subject: [PATCH 3/7] Add OIDC issuer validation and new testing style --- .../msal4j/AbstractClientApplicationBase.java | 4 + .../microsoft/aad/msal4j/OidcAuthority.java | 45 ++ .../aad/msal4j/OidcDiscoveryResponse.java | 8 + .../microsoft/aad/msal4j/RunnerHelper.java | 507 ++++++++++++++---- .../aad/msal4j/RunnerJsonHelper.java | 185 ------- .../com/microsoft/aad/msal4j/RunnerTest.java | 78 ++- .../com/microsoft/aad/msal4j/Shortcuts.java | 207 ------- .../microsoft/aad/msal4j/testcase/Act.java | 124 +++++ .../aad/msal4j/testcase/Arrange.java | 226 ++++++++ .../microsoft/aad/msal4j/testcase/Assert.java | 86 +++ .../microsoft/aad/msal4j/testcase/Env.java | 86 +++ .../aad/msal4j/testcase/ParsingTest.java | 141 +++++ .../microsoft/aad/msal4j/testcase/Steps.java | 135 +++++ .../aad/msal4j/testcase/TestCase.java | 119 ++++ .../aad/msal4j/testcase/TestCaseHelper.java | 188 +++++++ 15 files changed, 1611 insertions(+), 528 deletions(-) delete mode 100644 msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/RunnerJsonHelper.java delete mode 100644 msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/Shortcuts.java create mode 100644 msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/testcase/Act.java create mode 100644 msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/testcase/Arrange.java create mode 100644 msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/testcase/Assert.java create mode 100644 msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/testcase/Env.java create mode 100644 msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/testcase/ParsingTest.java create mode 100644 msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/testcase/Steps.java create mode 100644 msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/testcase/TestCase.java create mode 100644 msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/testcase/TestCaseHelper.java diff --git a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/AbstractClientApplicationBase.java b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/AbstractClientApplicationBase.java index 4197256e..2815ed06 100644 --- a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/AbstractClientApplicationBase.java +++ b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/AbstractClientApplicationBase.java @@ -579,6 +579,10 @@ public T correlationId(String val) { ((OidcAuthority) authenticationAuthority).setAuthorityProperties( OidcDiscoveryProvider.performOidcDiscovery( (OidcAuthority) authenticationAuthority, this)); + + if (!((OidcAuthority) authenticationAuthority).isIssuerValid()) { + throw new MsalClientException(String.format("Invalid issuer from OIDC discovery: %s", ((OidcAuthority) authenticationAuthority).issuerFromOidcDiscovery), "issuer_validation"); + } } } } \ No newline at end of file diff --git a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/OidcAuthority.java b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/OidcAuthority.java index 63759f3b..e8dcfcf7 100644 --- a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/OidcAuthority.java +++ b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/OidcAuthority.java @@ -10,6 +10,7 @@ public class OidcAuthority extends Authority { //Part of the OpenIdConnect standard, this is appended to the authority to create the endpoint that has OIDC metadata static final String WELL_KNOWN_OPENID_CONFIGURATION = ".well-known/openid-configuration"; private static final String AUTHORITY_FORMAT = "https://%s/%s/"; + String issuerFromOidcDiscovery; OidcAuthority(URL authorityUrl) throws MalformedURLException { super(createOidcDiscoveryUrl(authorityUrl), AuthorityType.OIDC); @@ -29,5 +30,49 @@ void setAuthorityProperties(OidcDiscoveryResponse instanceDiscoveryResponse) { this.tokenEndpoint = instanceDiscoveryResponse.tokenEndpoint(); this.deviceCodeEndpoint = instanceDiscoveryResponse.deviceCodeEndpoint(); this.selfSignedJwtAudience = this.tokenEndpoint; + this.issuerFromOidcDiscovery = instanceDiscoveryResponse.issuer(); + } + + /** + * Validates the issuer from OIDC discovery. + * Issuer is valid if it matches the authority URL (without the well-known segment) + * or if it follows the CIAM issuer format. + * + * @return true if the issuer is valid, false otherwise + */ + boolean isIssuerValid() { + if (issuerFromOidcDiscovery == null) { + return false; + } + + // Normalize issuer by removing trailing slashes + String normalizedIssuer = issuerFromOidcDiscovery; + while (normalizedIssuer.endsWith("/")) { + normalizedIssuer = normalizedIssuer.substring(0, normalizedIssuer.length() - 1); + } + + // Case 1: Check against canonicalAuthorityUrl without the well-known segment + String authorityWithoutWellKnown = canonicalAuthorityUrl.toString(); + if (authorityWithoutWellKnown.endsWith(WELL_KNOWN_OPENID_CONFIGURATION)) { + authorityWithoutWellKnown = authorityWithoutWellKnown.substring(0, + authorityWithoutWellKnown.length() - WELL_KNOWN_OPENID_CONFIGURATION.length()); + + // Remove trailing slash if present + if (authorityWithoutWellKnown.endsWith("/")) { + authorityWithoutWellKnown = authorityWithoutWellKnown.substring(0, authorityWithoutWellKnown.length() - 1); + } + + if (normalizedIssuer.equals(authorityWithoutWellKnown)) { + return true; + } + } + + // Case 2: Check CIAM format: "https://{tenant}.ciamlogin.com/{tenant}/" + if (tenant != null && !tenant.isEmpty()) { + String ciamPattern = "https://" + tenant + ".ciamlogin.com/" + tenant; + return normalizedIssuer.startsWith(ciamPattern); + } + + return false; } } diff --git a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/OidcDiscoveryResponse.java b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/OidcDiscoveryResponse.java index 0e8d6fbb..d0e961b4 100644 --- a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/OidcDiscoveryResponse.java +++ b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/OidcDiscoveryResponse.java @@ -15,6 +15,7 @@ class OidcDiscoveryResponse implements JsonSerializable { private String authorizationEndpoint; private String tokenEndpoint; private String deviceCodeEndpoint; + private String issuer; public static OidcDiscoveryResponse fromJson(JsonReader jsonReader) throws IOException { OidcDiscoveryResponse response = new OidcDiscoveryResponse(); @@ -32,6 +33,9 @@ public static OidcDiscoveryResponse fromJson(JsonReader jsonReader) throws IOExc case "device_authorization_endpoint": response.deviceCodeEndpoint = reader.getString(); break; + case "issuer": + response.issuer = reader.getString(); + break; default: reader.skipChildren(); break; @@ -61,4 +65,8 @@ String tokenEndpoint() { String deviceCodeEndpoint() { return this.deviceCodeEndpoint; } + + String issuer() { + return this.issuer; + } } diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/RunnerHelper.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/RunnerHelper.java index cd792552..45f74c17 100644 --- a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/RunnerHelper.java +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/RunnerHelper.java @@ -1,144 +1,438 @@ -package com.microsoft.aad.msal4j; +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. -import com.fasterxml.jackson.databind.JsonNode; +package com.microsoft.aad.msal4j; -import java.io.IOException; import java.util.ArrayList; import java.util.HashMap; +import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Set; + import static org.junit.jupiter.api.Assertions.assertEquals; -import com.microsoft.aad.msal4j.Shortcuts.TestConfig; -import com.microsoft.aad.msal4j.Shortcuts.TestObject; -import com.microsoft.aad.msal4j.Shortcuts.TestAction; +import com.microsoft.aad.msal4j.testcase.Act; +import com.microsoft.aad.msal4j.testcase.Arrange; +import com.microsoft.aad.msal4j.testcase.Assert; +import com.microsoft.aad.msal4j.testcase.Steps; +import com.microsoft.aad.msal4j.testcase.TestCase; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +/** + * Helper class for running MSAL4J test cases. + * Provides functionality for creating applications, executing test steps, and validating results. + */ public class RunnerHelper { private static final Logger LOG = LoggerFactory.getLogger(RunnerHelper.class); /** - * Create Managed Identity applications from the test configuration. - * This method processes the "arrange" section of the test configuration. + * Represents the result of attempting to create an application. + * Can either hold the created application or an exception if creation failed. */ - static Map createAppsFromConfig(TestConfig config) { - Map apps = new HashMap<>(); + static class AppCreationResult { + private final IApplicationBase application; + private final Exception exception; + + private AppCreationResult(IApplicationBase application) { + this.application = application; + this.exception = null; + } + + private AppCreationResult(Exception exception) { + this.application = null; + this.exception = exception; + } + + public boolean isSuccessful() { + return application != null; + } - for (String appName : config.getAllArrangeObjects()) { - TestObject appObject = config.getArrangeObject(appName); - if ("ManagedIdentityClient".equals(appObject.getType())) { - ManagedIdentityId identityId = createManagedIdentityId(appObject); - List capabilities = extractClientCapabilities(appObject); - IEnvironmentVariables envVars = setEnvironmentVariables(config); - // TODO: other application properties + @SuppressWarnings("unchecked") + public T getApplication() { + return (T) application; + } - ManagedIdentityApplication app = ManagedIdentityApplication.builder(identityId) - .clientCapabilities(capabilities) - .build(); + public Exception getException() { + return exception; + } - ManagedIdentityApplication.setEnvironmentVariables(envVars); + public static AppCreationResult success(IApplicationBase application) { + return new AppCreationResult(application); + } - apps.put(appName, app); - } //TODO: Confidential and public clients + public static AppCreationResult failure(Exception exception) { + return new AppCreationResult(exception); + } + } + + /** + * Creates Managed Identity applications from the test configuration. + */ + static Map createMangedIdentityAppsFromConfig(TestCase testCase) { + Map appResults = new HashMap<>(); + Arrange arrange = testCase.getArrange(); + IEnvironmentVariables env = setEnvironmentVariables(testCase); + + for (Map.Entry entry : arrange.getObjects().entrySet()) { + String appName = entry.getKey(); + Arrange.ObjectProperties appObjectProperties = entry.getValue(); + + if ("ManagedIdentityClient".equals(appObjectProperties.getType())) { + try { + ManagedIdentityId identityId = createManagedIdentityId(appObjectProperties); + List capabilities = extractClientCapabilities(appObjectProperties); + + ManagedIdentityApplication app = ManagedIdentityApplication.builder(identityId) + .clientCapabilities(capabilities) + .build(); + + ManagedIdentityApplication.setEnvironmentVariables(env); + + appResults.put(appName, AppCreationResult.success(app)); + LOG.info("Created app: {}", appName); + } catch (Exception e) { + LOG.info("Failed to create app {}: {}", appName, e.getMessage()); + appResults.put(appName, AppCreationResult.failure(e)); + } + } } + return appResults; + } + + /** + * Creates Confidential Client applications from the test configuration. + */ + static Map createConfidentialClientAppsFromConfig(TestCase testCase) { + Map appResults = new HashMap<>(); + Arrange arrange = testCase.getArrange(); + + for (Map.Entry entry : arrange.getObjects().entrySet()) { + String appName = entry.getKey(); + Arrange.ObjectProperties appConfig = entry.getValue(); + + if ("ConfidentialClientApplication".equals(appConfig.getType())) { + try { + String clientId = (String) appConfig.getProperty("client_id"); + String oidcAuthority = (String) appConfig.getProperty("oidc_authority"); + IClientCredential credential = ClientCredentialFactory.createFromSecret( + (String) appConfig.getProperty("client_credential")); + + ConfidentialClientApplication app = ConfidentialClientApplication.builder(clientId, credential) + .oidcAuthority(oidcAuthority) + .build(); + + appResults.put(appName, AppCreationResult.success(app)); + LOG.info("Created app: {}", appName); + } catch (Exception e) { + LOG.info("Failed to create app {}: {}", appName, e.getMessage()); + appResults.put(appName, AppCreationResult.failure(e)); + } + } + } + return appResults; + } + + /** + * Executes all steps in a test case using the created applications. + */ + static void executeTestCase(TestCase testCase, Map appResults) throws Exception { + Map> stepsByApp = groupStepsByApplication(testCase); + + for (String appId : stepsByApp.keySet()) { + AppCreationResult appResult = appResults.get(appId); + if (appResult == null) { + throw new IllegalStateException("No app result found for ID: " + appId); + } + + if (appResult.isSuccessful()) { + executeStepsForSuccessfulApp(appResult.getApplication(), stepsByApp.get(appId)); + } else { + validateFailedAppResults(appResult.getException(), stepsByApp.get(appId)); + } + } + } + + /** + * Groups test steps by the application they target. + */ + private static Map> groupStepsByApplication(TestCase testCase) { + Map> stepsByApp = new HashMap<>(); + + for (Steps.Step step : testCase.getSteps().getSteps()) { + String appId = step.getAction().getTargetObject(); + if (!stepsByApp.containsKey(appId)) { + stepsByApp.put(appId, new ArrayList<>()); + } + stepsByApp.get(appId).add(step); + } + + return stepsByApp; + } + + /** + * Executes test steps for a successfully created application. + */ + private static void executeStepsForSuccessfulApp(IApplicationBase app, List steps) throws Exception { + if (app instanceof ManagedIdentityApplication) { + executeTestSteps((ManagedIdentityApplication) app, steps); + } else if (app instanceof ConfidentialClientApplication) { + executeTestSteps((ConfidentialClientApplication) app, steps); + } else { + throw new UnsupportedOperationException("Unsupported application type: " + app.getClass().getName()); + } + } + + /** + * Validates test assertions for each step when application creation failed. + */ + private static void validateFailedAppResults(Exception exception, List steps) { + LOG.info("Validating app creation exception"); + for (Steps.Step step : steps) { + validateAssertions(null, exception, step.getAssertion()); + } + } + + /** + * Execute all test steps for a ManagedIdentityApplication. + */ + static void executeTestSteps(ManagedIdentityApplication app, List steps) throws Exception { + // Clear token cache once at the start for this app + app.tokenCache.accessTokens.clear(); + + for (Steps.Step step : steps) { + LOG.info("Executing step for ManagedIdentityApplication"); + executeStep(app, step); + } + } + + /** + * Execute all test steps for a ConfidentialClientApplication. + */ + static void executeTestSteps(ConfidentialClientApplication app, List steps) throws Exception { + // Clear token cache once at the start for this app + app.tokenCache.accessTokens.clear(); + + for (Steps.Step step : steps) { + LOG.info("Executing step for ConfidentialClientApplication"); + executeStep(app, step); + } + } + + /** + * Executes a single test step and validates its assertions. + */ + private static void executeStep(T app, Steps.Step step) throws Exception { + try { + IAuthenticationResult result; + if (app instanceof ManagedIdentityApplication) { + result = executeAction((ManagedIdentityApplication) app, step.getAction()); + } else if (app instanceof ConfidentialClientApplication) { + result = executeAction((ConfidentialClientApplication) app, step.getAction()); + } else { + throw new UnsupportedOperationException("Unsupported application type: " + app.getClass().getName()); + } - return apps; + validateAssertions(result, null, step.getAssertion()); + } catch (Exception e) { + LOG.info("Exception executing action: {}", e.getMessage()); + validateAssertions(null, e, step.getAssertion()); + } } /** - * Execute an action and return the result - * This method uses the "act" section of the test configuration. + * Execute an action on a ManagedIdentityApplication and return the result. */ - static IAuthenticationResult executeAction(ManagedIdentityApplication app, TestAction action) throws Exception { + static IAuthenticationResult executeAction(ManagedIdentityApplication app, Act action) throws Exception { if (action.getMethodName().equals("AcquireTokenForManagedIdentity")) { - LOG.info(String.format("===Executing action: %s", action.getMethodName())); + LOG.info("Executing action: {}", action.getMethodName()); ManagedIdentityParameters params = buildManagedIdentityParameters(action); IAuthenticationResult result = app.acquireTokenForManagedIdentity(params).get(); + logAuthResult(result); + return result; + } else { + throw new UnsupportedOperationException("Unsupported action: " + action.getMethodName()); + } + } - LOG.info("---Action result"); - LOG.info(String.format("-Access Token: %s", result.accessToken())); - LOG.info(String.format("-ID Token : %s", result.idToken())); - LOG.info(String.format("-Account : %s", result.account())); - LOG.info(String.format("-Token Source: %s", result.metadata().tokenSource())); - + /** + * Execute an action on a ConfidentialClientApplication and return the result. + */ + static IAuthenticationResult executeAction(ConfidentialClientApplication app, Act action) throws Exception { + if (action.getMethodName().equals("AcquireTokenForClient")) { + LOG.info("Executing action: {}", action.getMethodName()); + //TODO: Handle other acquire token methods + ClientCredentialParameters params = buildClientCredentialParameters(action); + IAuthenticationResult result = app.acquireToken(params).get(); + logAuthResult(result); return result; } else { - //TODO: other token calls and confidential/public client apps throw new UnsupportedOperationException("Unsupported action: " + action.getMethodName()); } } + /** + * Logs the details of an authentication result. + */ + private static void logAuthResult(IAuthenticationResult result) { + if (LOG.isDebugEnabled()) { + LOG.debug("Action result:"); + LOG.debug("Access Token: {}", result.accessToken()); + LOG.debug("ID Token : {}", result.idToken()); + LOG.debug("Account : {}", result.account()); + LOG.debug("Token Source: {}", result.metadata().tokenSource()); + } + } + /** * Validate assertions against a result. - * This method uses the "assert" section of the test configuration. */ - static void validateAssertions(IAuthenticationResult result, Map assertions) { + static void validateAssertions(IAuthenticationResult result, Exception exception, Assert assertion) { + if (assertion == null) { + LOG.info("No assertions to validate"); + return; + } + + Map assertions = assertion.getAssertions(); assertions.forEach((key, value) -> { switch (key) { case "token_source": - LOG.info("===Validating token source"); - validateTokenSource(value.asText(), result); + LOG.info("Validating token source"); + validateTokenSource((String) value, result); + break; + case "error": + LOG.info("Validating exception"); + validateException((String) value, exception); break; - //TODO: other assertions, such as exceptions checks, token content, etc. default: - // Optional: Handle unknown assertion types + LOG.info("Unknown assertion type: {}", key); break; } }); } /** - * Create managed identity ID from test object + * Validates that an exception matches expected criteria. */ - static ManagedIdentityId createManagedIdentityId(TestObject appObject) { - JsonNode managedIdentityNode = appObject.getProperty("managed_identity"); - String idType = managedIdentityNode.get("ManagedIdentityIdType").asText(); + static void validateException(String expectedExceptionType, Exception actualException) { + if (StringHelper.isNullOrBlank(expectedExceptionType)) { + assertEquals(null, actualException, "Exception was not expected but one was thrown"); + return; + } + + if (actualException == null) { + throw new AssertionError("Expected exception of type " + expectedExceptionType + " but none was thrown"); + } + + ExceptionExpectation expectation = getExceptionExpectation(expectedExceptionType); + + LOG.info("Validating exception - Expected type: {}, Actual: {}", + expectation.type, actualException.getClass().getSimpleName()); + + if (expectation.type != null) { + boolean matchesType = actualException.getClass().getSimpleName().equals(expectation.type); + assertEquals(true, matchesType, + "Expected exception of type " + expectation.type + " but got " + actualException.getClass().getSimpleName()); + } + + if (expectation.message != null && actualException.getMessage() != null) { + boolean containsMessage = actualException.getMessage().contains(expectation.message); + assertEquals(true, containsMessage, + "Expected exception message to contain '" + expectation.message + "' but got: " + actualException.getMessage()); + } + + if (expectation.errorCode != null && actualException instanceof MsalClientException) { + MsalClientException msalException = (MsalClientException) actualException; + assertEquals(expectation.errorCode, msalException.errorCode(), + "Expected error code '" + expectation.errorCode + "' but got: " + msalException.errorCode()); + } + } + + /** + * Maps exception type identifiers to expected exception details. + */ + private static class ExceptionExpectation { + final String type; + final String message; + final String errorCode; + + ExceptionExpectation(String type, String message, String errorCode) { + this.type = type; + this.message = message; + this.errorCode = errorCode; + } + } + + /** + * Gets the expected exception details for a given exception type identifier. + */ + private static ExceptionExpectation getExceptionExpectation(String exceptionType) { + switch (exceptionType) { + case "invalid_issuer": + return new ExceptionExpectation( + "MsalClientException", + "Invalid issuer from OIDC discovery", + "issuer_validation"); + // Add more mappings as needed for different test cases + default: + LOG.info("Unknown exception type mapping: {}", exceptionType); + return new ExceptionExpectation(null, null, null); + } + } + + /** + * Creates managed identity ID from test object properties. + */ + static ManagedIdentityId createManagedIdentityId(Arrange.ObjectProperties appObjectProperties) { + Map managedIdentityMap = (Map) appObjectProperties.getProperty("managed_identity"); + String idType = (String) managedIdentityMap.get("ManagedIdentityIdType"); + String id = (String) managedIdentityMap.get("Id"); switch (idType) { case "SystemAssigned": return ManagedIdentityId.systemAssigned(); case "ClientId": - String clientId = managedIdentityNode.get("Id").asText(); - return ManagedIdentityId.userAssignedClientId(clientId); + return ManagedIdentityId.userAssignedClientId(id); case "ObjectId": - String objectId = managedIdentityNode.get("Id").asText(); - return ManagedIdentityId.userAssignedObjectId(objectId); + return ManagedIdentityId.userAssignedObjectId(id); case "ResourceId": - String resourceId = managedIdentityNode.get("Id").asText(); - return ManagedIdentityId.userAssignedResourceId(resourceId); + return ManagedIdentityId.userAssignedResourceId(id); default: throw new IllegalArgumentException("Unsupported ManagedIdentityIdType: " + idType); } } /** - * Extract client capabilities from test object + * Extracts client capabilities from application object properties. */ - static List extractClientCapabilities(TestObject testObject) { + static List extractClientCapabilities(Arrange.ObjectProperties testObjectProperties) { List capabilities = new ArrayList<>(); - JsonNode capabilitiesNode = testObject.getProperty("client_capabilities"); + Object capabilitiesObj = testObjectProperties.getProperty("client_capabilities"); - if (capabilitiesNode != null && capabilitiesNode.isArray()) { - capabilitiesNode.forEach(node -> capabilities.add(node.asText())); + if (capabilitiesObj instanceof List) { + List capabilitiesList = (List) capabilitiesObj; + for (Object capability : capabilitiesList) { + capabilities.add((String) capability); + } } - LOG.info(String.format("---Extracted client capabilities: %s", capabilities)); - return capabilities; } /** - * Creates provider for mocked environment variables using the test configuration. - * - * @param config The test configuration containing the environment variables - * @return An IEnvironmentVariables implementation with the configured variables + * Creates a provider for environment variables using the test configuration. */ - static IEnvironmentVariables setEnvironmentVariables(TestConfig config) { - // Get all environment variables from the config - final Map envVars = config.getAllEnvironmentVariables(); + static IEnvironmentVariables setEnvironmentVariables(TestCase testCase) { + final Map envVarsObj = testCase.getEnv().getVariables(); + final Map envVars = new HashMap<>(); + + // Convert Object values to String + for (Map.Entry entry : envVarsObj.entrySet()) { + envVars.put(entry.getKey(), entry.getValue().toString()); + } - LOG.info(String.format("---Configured environment variables: %s", envVars.keySet())); + LOG.info("Configured environment variables: {}", envVars.keySet()); return new IEnvironmentVariables() { @Override @@ -149,69 +443,58 @@ public String getEnvironmentVariable(String envVariable) { } /** - * Build parameters for token acquisition + * Builds parameters for managed identity token acquisition */ - static ManagedIdentityParameters buildManagedIdentityParameters(TestAction action) { - String resource = action.getParameter("resource").asText(); - - LOG.info(String.format("Building ManagedIdentityParameters with resource: %s", resource)); + static ManagedIdentityParameters buildManagedIdentityParameters(Act action) { + String resource = (String) action.getParameters().get("resource"); + LOG.info("Building ManagedIdentityParameters with resource: {}", resource); ManagedIdentityParameters.ManagedIdentityParametersBuilder builder = ManagedIdentityParameters.builder(resource); // Add optional claims challenge - if (action.hasParameter("claims_challenge")) { - String validatedClaimsChallenge = Shortcuts.validateAndGetClaimsChallenge(action); - builder.claims(validatedClaimsChallenge); - } - - //TODO: other parameters + if (action.getParameters().containsKey("claims_challenge")) { + String claimsChallenge = (String) action.getParameters().get("claims_challenge"); - return builder.build(); - } + // Convert simple string to JSON format if needed + if (!claimsChallenge.startsWith("{") && !claimsChallenge.endsWith("}")) { + claimsChallenge = "{\"" + claimsChallenge + "\":\"" + claimsChallenge + "\"}"; + LOG.info("Converting simple string claim to JSON format: {}", claimsChallenge); + } - /** - * Validate token source assertion, either cache or identity provider - */ - static void validateTokenSource(String expectedSource, IAuthenticationResult result) { - TokenSource expected = "identity_provider".equals(expectedSource) ? - TokenSource.IDENTITY_PROVIDER : TokenSource.CACHE; - LOG.info(String.format("---Expected token source: %s", expected)); - LOG.info(String.format("---Actual token source : %s", result.metadata().tokenSource())); + builder.claims(claimsChallenge); + } - assertEquals(expected, result.metadata().tokenSource()); + return builder.build(); } /** - * Complete workflow to get all test case configs - * - * @param indexEndpoint The URL of the index containing test case URLs - * @return Map of test case names to their JSON configurations + * Builds parameters for client credential token acquisition. */ - static Map getAllTestCaseConfigs(String indexEndpoint) throws IOException { - // Get list of SML test case URLs - List smlUrls = RunnerJsonHelper.getTestCaseUrlsFromEndpoint(indexEndpoint); - - // Convert SML URLs to JSON URLs - List jsonUrls = Shortcuts.convertSmlUrlsToJsonUrls(smlUrls); - - // Fetch content for each JSON URL - Map testCaseConfigs = new HashMap<>(); - for (String jsonUrl : jsonUrls) { - String testCaseName = extractTestCaseName(jsonUrl); - JsonNode config = RunnerJsonHelper.fetchJsonContent(jsonUrl); - testCaseConfigs.put(testCaseName, config); + static ClientCredentialParameters buildClientCredentialParameters(Act action) { + Set scopes = new HashSet<>(); + if (action.getParameters().containsKey("scopes")) { + Object scopesObj = action.getParameters().get("scopes"); + if (scopesObj instanceof List) { + List scopesList = (List) scopesObj; + for (Object scope : scopesList) { + scopes.add((String) scope); + } + } } - return testCaseConfigs; + LOG.info("Building ClientCredentialParameters with scopes: {}", scopes); + return ClientCredentialParameters.builder(scopes).build(); } /** - * Extract test case name from URL + * Validates token source assertion, either cache or identity provider. */ - private static String extractTestCaseName(String url) { - String[] parts = url.split("/"); - String fileName = parts[parts.length - 1]; - return fileName.substring(0, fileName.lastIndexOf('.')); + static void validateTokenSource(String expectedSource, IAuthenticationResult result) { + TokenSource expected = "identity_provider".equals(expectedSource) ? + TokenSource.IDENTITY_PROVIDER : TokenSource.CACHE; + + LOG.info("Token source - Expected: {}, Actual: {}", expected, result.metadata().tokenSource()); + assertEquals(expected, result.metadata().tokenSource()); } -} +} \ No newline at end of file diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/RunnerJsonHelper.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/RunnerJsonHelper.java deleted file mode 100644 index ebaf181c..00000000 --- a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/RunnerJsonHelper.java +++ /dev/null @@ -1,185 +0,0 @@ -package com.microsoft.aad.msal4j; - -import com.fasterxml.jackson.databind.JsonNode; - -import java.io.BufferedReader; -import java.io.IOException; -import java.io.InputStreamReader; -import java.net.URL; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; - -import com.fasterxml.jackson.databind.ObjectMapper; -import com.microsoft.aad.msal4j.Shortcuts.TestConfig; -import com.microsoft.aad.msal4j.Shortcuts.TestObject; -import com.microsoft.aad.msal4j.Shortcuts.TestStep; -import com.microsoft.aad.msal4j.Shortcuts.TestAction; - -import javax.net.ssl.HttpsURLConnection; - -//TODO: Too specific for the test case used in this proof-of-concept, should be able to reuse the regular JsonHelper class -class RunnerJsonHelper { - private static final ObjectMapper mapper = new ObjectMapper(); - - /** - * Parse test configuration from JSON string - */ - static TestConfig parseTestConfig(String jsonContent) { - try { - return JsonParser.parseConfig(mapper.readTree(jsonContent)); - } catch (Exception e) { - throw new RuntimeException("Failed to parse test configuration: " + e.getMessage(), e); - } - } - - /** - * Helper class for parsing JSON into test configuration - */ - private static class JsonParser { - static TestConfig parseConfig(JsonNode rootNode) { - TestConfig.Builder builder = new TestConfig.Builder() - .type(rootNode.path("type").asText()) - .version(rootNode.path("ver").asInt()); - - parseEnvironment(rootNode.path("env"), builder); - parseArrangement(rootNode.path("arrange"), builder); - parseSteps(rootNode.path("steps"), builder); - - return builder.build(); - } - - private static void parseEnvironment(JsonNode envNode, TestConfig.Builder builder) { - envNode.fields().forEachRemaining(entry -> - builder.addEnvironmentVariable(entry.getKey(), entry.getValue().asText())); - } - - private static void parseArrangement(JsonNode arrangeNode, TestConfig.Builder builder) { - arrangeNode.fields().forEachRemaining(appEntry -> { - String appName = appEntry.getKey(); - JsonNode appNode = appEntry.getValue(); - - appNode.fields().forEachRemaining(classEntry -> { - String classType = classEntry.getKey(); - JsonNode classNode = classEntry.getValue(); - - Map properties = new HashMap<>(); - classNode.fields().forEachRemaining(prop -> - properties.put(prop.getKey(), prop.getValue())); - - builder.addArrangedObject(appName, - new TestObject(appName, classType, properties)); - }); - }); - } - - private static void parseSteps(JsonNode stepsNode, TestConfig.Builder builder) { - for (JsonNode stepNode : stepsNode) { - TestAction action = parseAction(stepNode.path("act")); - Map assertions = parseAssertions(stepNode.path("assert")); - - if (action != null) { - builder.addStep(new TestStep(action, assertions)); - } - } - } - - private static TestAction parseAction(JsonNode actNode) { - if (actNode.isMissingNode()) return null; - - String actorKey = actNode.fieldNames().next(); - String[] actorParts = actorKey.split("\\.", 2); - - Map parameters = new HashMap<>(); - actNode.get(actorKey).fields().forEachRemaining(entry -> - parameters.put(entry.getKey(), entry.getValue())); - - return new TestAction(actorParts[0], actorParts[1], parameters); - } - - private static Map parseAssertions(JsonNode assertNode) { - Map assertions = new HashMap<>(); - - if (!assertNode.isMissingNode()) { - assertNode.fields().forEachRemaining(entry -> - assertions.put(entry.getKey(), entry.getValue())); - } - - return assertions; - } - } - - /** - * Fetches test case URLs from an endpoint containing JSON with a "testcases" array. - * - * @param endpointUrl The URL to fetch test cases from - * @return List of test case URLs - */ - static List getTestCaseUrlsFromEndpoint(String endpointUrl) throws IOException { - List testCaseUrls = new ArrayList<>(); - - URL url = new URL(endpointUrl); - HttpsURLConnection connection = (HttpsURLConnection) url.openConnection(); - connection.setRequestMethod("GET"); - connection.setRequestProperty("Content-Type", "application/json"); - - int responseCode = connection.getResponseCode(); - if (responseCode == HttpsURLConnection.HTTP_OK) { - try (BufferedReader reader = new BufferedReader( - new InputStreamReader(connection.getInputStream()))) { - StringBuilder response = new StringBuilder(); - String line; - while ((line = reader.readLine()) != null) { - response.append(line); - } - - ObjectMapper mapper = new ObjectMapper(); - JsonNode rootNode = mapper.readTree(response.toString()); - - if (rootNode.has("testcases") && rootNode.get("testcases").isArray()) { - JsonNode testcasesNode = rootNode.get("testcases"); - for (JsonNode testcase : testcasesNode) { - testCaseUrls.add(testcase.asText()); - } - } else { - throw new IllegalStateException("JSON response does not contain a 'testcases' array"); - } - } - } else { - throw new IOException("HTTP request failed with status code: " + responseCode); - } - - return testCaseUrls; - } - - /** - * Fetches JSON content from a URL - * - * @param jsonUrl The URL to fetch JSON content from - * @return The JSON content parsed as a JsonNode - */ - static JsonNode fetchJsonContent(String jsonUrl) throws IOException { - URL url = new URL(jsonUrl); - HttpsURLConnection connection = (HttpsURLConnection) url.openConnection(); - connection.setRequestMethod("GET"); - connection.setRequestProperty("Content-Type", "application/json"); - - int responseCode = connection.getResponseCode(); - if (responseCode == HttpsURLConnection.HTTP_OK) { - try (BufferedReader reader = new BufferedReader( - new InputStreamReader(connection.getInputStream()))) { - StringBuilder response = new StringBuilder(); - String line; - while ((line = reader.readLine()) != null) { - response.append(line); - } - - ObjectMapper mapper = new ObjectMapper(); - return mapper.readTree(response.toString()); - } - } else { - throw new IOException("Failed to fetch JSON content. HTTP status: " + responseCode); - } - } -} diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/RunnerTest.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/RunnerTest.java index 9765f954..f1dd3ef9 100644 --- a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/RunnerTest.java +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/RunnerTest.java @@ -1,21 +1,37 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + package com.microsoft.aad.msal4j; -import com.fasterxml.jackson.databind.JsonNode; -import com.microsoft.aad.msal4j.Shortcuts.TestConfig; -import com.microsoft.aad.msal4j.Shortcuts.TestStep; +import com.microsoft.aad.msal4j.testcase.TestCase; +import com.microsoft.aad.msal4j.testcase.TestCaseHelper; +import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.MethodSource; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.io.IOException; import java.util.Map; import java.util.stream.Stream; +/** + * Test runner for executing MSAL4J integration tests from external test case definitions. + * Uses parameterized tests to run different test cases through a common execution path. + */ class RunnerTest { private static final Logger LOG = LoggerFactory.getLogger(RunnerTest.class); + private static Map allTestConfigs; + + @BeforeAll + static void setup() throws IOException { + LOG.info("Loading all test configurations"); + allTestConfigs = TestCaseHelper.getAllTestCaseConfigs("https://smile-test.azurewebsites.net/testcases.json"); + LOG.info("Loaded {} test configurations", allTestConfigs.size()); + } /** - * Defines a set of test cases for a single unit test to run. + * Defines a set of test cases that cover managed identity scenarios. */ static Stream managedIdentityTestsProvider() { return Stream.of( @@ -25,35 +41,49 @@ static Stream managedIdentityTestsProvider() { ); } + /** + * Defines a set of test cases that cover OIDC authority scenarios. + */ + static Stream confidentialClientOidcTestsProvider() { + return Stream.of( + "issuer_validation" + ); + } + @ParameterizedTest @MethodSource("managedIdentityTestsProvider") void runManagedIdentityTest(String testCaseName) throws Exception { - LOG.info("==========Executing Test Case=========="); - - // Get all test configurations - Map configs = RunnerHelper.getAllTestCaseConfigs("https://smile-test.azurewebsites.net/testcases.json"); + LOG.info("========== Executing Managed Identity Test: {} ==========", testCaseName); - LOG.info(String.format("---Found test case: %s", configs.get(testCaseName).toString())); + TestCase testCase = getTestCase(testCaseName); + Map appResults = + RunnerHelper.createMangedIdentityAppsFromConfig(testCase); - TestConfig config = RunnerJsonHelper.parseTestConfig(configs.get(testCaseName).toString()); - - // Create applications from the configuration - Map apps = RunnerHelper.createAppsFromConfig(config); + LOG.info("Created {} application(s) for test execution", appResults.size()); + RunnerHelper.executeTestCase(testCase, appResults); + } - // For each application, execute all steps - for (ManagedIdentityApplication app : apps.values()) { - app.tokenCache.accessTokens.clear(); // Clear the static token cache for each test run + @ParameterizedTest + @MethodSource("confidentialClientOidcTestsProvider") + void runConfidentialClientOidcTests(String testCaseName) throws Exception { + LOG.info("========== Executing Confidential Client OIDC Test: {} ==========", testCaseName); - // Execute each step in the test configuration - for (TestStep step : config.getSteps()) { - LOG.info("----------Executing step----------"); + TestCase testCase = getTestCase(testCaseName); + Map appResults = + RunnerHelper.createConfidentialClientAppsFromConfig(testCase); - // Execute the action - IAuthenticationResult result = RunnerHelper.executeAction(app, step.getAction()); + LOG.info("Created {} application(s) for test execution", appResults.size()); + RunnerHelper.executeTestCase(testCase, appResults); + } - // Validate assertions - RunnerHelper.validateAssertions(result, step.getAssertions()); - } + /** + * Gets a test case by name from the loaded configurations + */ + private static TestCase getTestCase(String testCaseName) { + TestCase testCase = allTestConfigs.get(testCaseName); + if (testCase == null) { + throw new IllegalArgumentException("Test case not found: " + testCaseName); } + return testCase; } } \ No newline at end of file diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/Shortcuts.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/Shortcuts.java deleted file mode 100644 index de607980..00000000 --- a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/Shortcuts.java +++ /dev/null @@ -1,207 +0,0 @@ -package com.microsoft.aad.msal4j; - -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; - -import java.util.*; -import java.util.stream.Collectors; - -class Shortcuts { - //Various helpers and utilities for simplifying the first proof-of-concept implementation. - //They don't follow any design doc or language conventions, and would not be part of the final implementation. - - //=====The following static classes would ideally extend Azure JSON's JsonSerializable or some equivalent YAML parsing interface, - // but for now they are simple classes representing the structure of a test configuration. - static class TestConfig { - private final String type; - private final int version; - private final Map environment; - private final Map arrangeObjects; - private final List steps; - private static final ObjectMapper mapper = new ObjectMapper(); - - private TestConfig(Shortcuts.TestConfig.Builder builder) { - this.type = builder.type; - this.version = builder.version; - this.environment = Collections.unmodifiableMap(builder.environment); - this.arrangeObjects = Collections.unmodifiableMap(builder.arrangeObjects); - this.steps = Collections.unmodifiableList(builder.steps); - } - - String getType() { - return type; - } - - int getVersion() { - return version; - } - - String getEnvironmentVariable(String name) { - return environment.get(name); - } - - Map getAllEnvironmentVariables() { - return environment; - } - - Shortcuts.TestObject getArrangeObject(String name) { - return arrangeObjects.get(name); - } - - List getAllArrangeObjects() { - return new ArrayList<>(arrangeObjects.keySet()); - } - - List getSteps() { - return steps; - } - - static class Builder { - private String type; - private int version; - private final Map environment = new HashMap<>(); - private final Map arrangeObjects = new HashMap<>(); - private final List steps = new ArrayList<>(); - - Shortcuts.TestConfig.Builder type(String type) { - this.type = type; - return this; - } - - Shortcuts.TestConfig.Builder version(int version) { - this.version = version; - return this; - } - - Shortcuts.TestConfig.Builder addEnvironmentVariable(String name, String value) { - environment.put(name, value); - return this; - } - - Shortcuts.TestConfig.Builder addArrangedObject(String name, Shortcuts.TestObject object) { - arrangeObjects.put(name, object); - return this; - } - - Shortcuts.TestConfig.Builder addStep(Shortcuts.TestStep step) { - steps.add(step); - return this; - } - - Shortcuts.TestConfig build() { - return new Shortcuts.TestConfig(this); - } - } - } - - static class TestObject { - private final String name; - private final String type; - private final Map properties; - - TestObject(String name, String type, Map properties) { - this.name = name; - this.type = type; - this.properties = Collections.unmodifiableMap(new HashMap<>(properties)); - } - - String getName() { - return name; - } - - String getType() { - return type; - } - - JsonNode getProperty(String name) { - return properties.get(name); - } - } - - static class TestStep { - private final Shortcuts.TestAction action; - private final Map assertions; - - TestStep(Shortcuts.TestAction action, Map assertions) { - this.action = action; - this.assertions = Collections.unmodifiableMap(new HashMap<>(assertions)); - } - - Shortcuts.TestAction getAction() { - return action; - } - - JsonNode getAssertion(String key) { - return assertions.get(key); - } - - Map getAssertions() { - return assertions; - } - } - - static class TestAction { - private final String targetObject; - private final String methodName; - private final Map parameters; - - TestAction(String targetObject, String methodName, Map parameters) { - this.targetObject = targetObject; - this.methodName = methodName; - this.parameters = Collections.unmodifiableMap(new HashMap<>(parameters)); - } - - String getTargetObject() { - return targetObject; - } - - String getMethodName() { - return methodName; - } - - JsonNode getParameter(String name) { - return parameters.get(name); - } - - boolean hasParameter(String name) { - return parameters.containsKey(name); - } - - Map getParameters() { - return parameters; - } - } - - //=====The following methods are small fixes for issues in the test configuration JSONs - - //Some URLs in the test configurations are malformed and are missing a slash before "test". - static String fixAzureWebsiteUrls(String jsonString) { - return jsonString.replace( - "https://smile-test.azurewebsites.nettest/token", - "https://smile-test.azurewebsites.net/test/token"); - } - - //Some test configurations use a claims challenge that is not valid JSON. - static String validateAndGetClaimsChallenge(TestAction action) { - if (!action.hasParameter("claims_challenge")) { - return null; - } - - String claimsChallenge = action.getParameter("claims_challenge").asText(); - try { - // Try to parse the claims challenge as JSON - new ObjectMapper().readTree(claimsChallenge); - return claimsChallenge; - } catch (Exception e) { - return TestConfiguration.CLAIMS_CHALLENGE; - } - } - - //The server has a public list of endpoints leading to .sml files, but there are JSON equivalents for those files at endpoints ending with .json - static List convertSmlUrlsToJsonUrls(List smlUrls) { - return smlUrls.stream() - .filter(url -> url.endsWith(".sml")) - .map(url -> url.substring(0, url.length() - 4) + ".json") - .collect(Collectors.toList()); - } -} \ No newline at end of file diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/testcase/Act.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/testcase/Act.java new file mode 100644 index 00000000..92c3e137 --- /dev/null +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/testcase/Act.java @@ -0,0 +1,124 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.aad.msal4j.testcase; + +import com.azure.json.JsonReader; +import com.azure.json.JsonSerializable; +import com.azure.json.JsonToken; +import com.azure.json.JsonWriter; + +import java.io.IOException; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public class Act implements JsonSerializable { + private String targetObject; + private String methodName; + private final Map parameters = new HashMap<>(); + + public static Act fromJson(JsonReader jsonReader) throws IOException { + Act action = new Act(); + + jsonReader.readObject(reader -> { + while (reader.nextToken() != JsonToken.END_OBJECT) { + String key = reader.getFieldName(); + reader.nextToken(); + + // Split "objectName.methodName" + int dotIndex = key.indexOf('.'); + if (dotIndex == -1) { + throw new IOException("Invalid act key: " + key); + } + action.targetObject = key.substring(0, dotIndex); + action.methodName = key.substring(dotIndex + 1); + + // Read parameters object + reader.readObject(paramReader -> { + while (paramReader.nextToken() != JsonToken.END_OBJECT) { + String paramName = paramReader.getFieldName(); + paramReader.nextToken(); + switch (paramReader.currentToken()) { + case STRING: + action.parameters.put(paramName, paramReader.getString()); + break; + case NUMBER: + action.parameters.put(paramName, paramReader.getInt()); + break; + case START_ARRAY: + if ("scopes".equals(paramName)) { + action.parameters.put(paramName, paramReader.readArray(JsonReader::getString)); + } else { + paramReader.skipChildren(); + } + break; + default: + paramReader.skipChildren(); + break; + } + } + return null; + }); + } + return null; + }); + + return action; + } + + @Override + public JsonWriter toJson(JsonWriter jsonWriter) throws IOException { + jsonWriter.writeStartObject(); + writeContent(jsonWriter); + return jsonWriter.writeEndObject(); + } + + public void writeContent(JsonWriter jsonWriter) throws IOException { + jsonWriter.writeFieldName("act"); + jsonWriter.writeStartObject(); + jsonWriter.writeFieldName(targetObject + "." + methodName); + jsonWriter.writeStartObject(); + + for (Map.Entry entry : parameters.entrySet()) { + String key = entry.getKey(); + Object value = entry.getValue(); + + if (value instanceof String) { + jsonWriter.writeStringField(key, (String) value); + } else if (value instanceof Integer) { + jsonWriter.writeIntField(key, (Integer) value); + } else if (value instanceof List) { + jsonWriter.writeFieldName(key); + jsonWriter.writeStartArray(); + for (String val : (List) value) { + jsonWriter.writeString(val); + } + jsonWriter.writeEndArray(); + } + } + + jsonWriter.writeEndObject(); + jsonWriter.writeEndObject(); + } + + public String getTargetObject() { + return targetObject; + } + + public String getMethodName() { + return methodName; + } + + public Object getParameter(String name) { + return parameters.get(name); + } + + public boolean hasParameter(String name) { + return parameters.containsKey(name); + } + + public Map getParameters() { + return parameters; + } +} \ No newline at end of file diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/testcase/Arrange.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/testcase/Arrange.java new file mode 100644 index 00000000..e85828a0 --- /dev/null +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/testcase/Arrange.java @@ -0,0 +1,226 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.aad.msal4j.testcase; + +import com.azure.json.JsonReader; +import com.azure.json.JsonSerializable; +import com.azure.json.JsonToken; +import com.azure.json.JsonWriter; + +import java.io.IOException; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +public class Arrange implements JsonSerializable { + private final Map objects = new LinkedHashMap<>(); + + public static Arrange fromJson(JsonReader jsonReader) throws IOException { + Arrange arrange = new Arrange(); + + jsonReader.readObject(reader -> { + while (reader.nextToken() != JsonToken.END_OBJECT) { + String objectName = reader.getFieldName(); + System.out.println("Reading object: " + objectName); + reader.nextToken(); + arrange.objects.put(objectName, ObjectProperties.fromJson(reader)); + } + return null; + }); + + return arrange; + } + + @Override + public JsonWriter toJson(JsonWriter jsonWriter) throws IOException { + jsonWriter.writeStartObject(); + writeContent(jsonWriter); + return jsonWriter.writeEndObject(); + } + + public void writeContent(JsonWriter jsonWriter) throws IOException { + jsonWriter.writeFieldName("arrange"); + jsonWriter.writeStartObject(); + + for (Map.Entry entry : objects.entrySet()) { + jsonWriter.writeFieldName(entry.getKey()); + entry.getValue().toJson(jsonWriter); + } + + jsonWriter.writeEndObject(); + } + + public ObjectProperties getObject(String name) { + return objects.get(name); + } + + public Map getObjects() { + return objects; + } + + public void addObject(String name, ObjectProperties objectProperties) { + objects.put(name, objectProperties); + } + + public static class ObjectProperties implements JsonSerializable { + private String type; + private final Map properties = new HashMap<>(); + + public static ObjectProperties fromJson(JsonReader jsonReader) throws IOException { + ObjectProperties objectProperties = new ObjectProperties(); + + jsonReader.readObject(reader -> { + while (reader.nextToken() != JsonToken.END_OBJECT) { + String className = reader.getFieldName(); + objectProperties.type = className; + reader.nextToken(); + + reader.readObject(classReader -> { + while (classReader.nextToken() != JsonToken.END_OBJECT) { + String propertyGroup = classReader.getFieldName(); + classReader.nextToken(); + + switch (classReader.currentToken()) { + case START_ARRAY: + objectProperties.properties.put(propertyGroup, + classReader.readArray(JsonReader::getString)); + break; + case START_OBJECT: + objectProperties.properties.put(propertyGroup, + parseNestedObject(classReader)); + break; + case STRING: + objectProperties.properties.put(propertyGroup, classReader.getString()); + break; + case NUMBER: + objectProperties.properties.put(propertyGroup, classReader.getInt()); + break; + case NULL: + objectProperties.properties.put(propertyGroup, null); + break; + default: + classReader.skipChildren(); + break; + } + } + return null; + }); + } + return null; + }); + + return objectProperties; + } + + private static Map parseNestedObject(JsonReader reader) throws IOException { + Map nestedProps = new HashMap<>(); + + reader.readObject(propsReader -> { + while (propsReader.nextToken() != JsonToken.END_OBJECT) { + String propName = propsReader.getFieldName(); + propsReader.nextToken(); + + switch (propsReader.currentToken()) { + case STRING: + nestedProps.put(propName, propsReader.getString()); + break; + case NUMBER: + nestedProps.put(propName, propsReader.getInt()); + break; + default: + propsReader.skipChildren(); + break; + } + } + return null; + }); + + return nestedProps; + } + + @Override + public JsonWriter toJson(JsonWriter jsonWriter) throws IOException { + jsonWriter.writeStartObject(); + writeContent(jsonWriter); + return jsonWriter.writeEndObject(); + } + + public void writeContent(JsonWriter jsonWriter) throws IOException { + jsonWriter.writeFieldName(type); + jsonWriter.writeStartObject(); + + for (Map.Entry entry : properties.entrySet()) { + jsonWriter.writeFieldName(entry.getKey()); + java.lang.Object value = entry.getValue(); + + if (value instanceof List) { + writeArray(jsonWriter, (List) value); + } else if (value instanceof Map) { + writeNestedObject(jsonWriter, (Map) value); + } else if (value instanceof String) { + jsonWriter.writeString((String) value); + } else if (value instanceof Integer) { + jsonWriter.writeNumber((Integer) value); + } else if (value == null) { + jsonWriter.writeNull(); + } + } + + jsonWriter.writeEndObject(); + } + + private void writeArray(JsonWriter jsonWriter, List values) throws IOException { + jsonWriter.writeStartArray(); + for (String value : values) { + jsonWriter.writeString(value); + } + jsonWriter.writeEndArray(); + } + + private void writeNestedObject(JsonWriter jsonWriter, Map nestedProps) throws IOException { + jsonWriter.writeStartObject(); + + for (Map.Entry prop : nestedProps.entrySet()) { + String key = prop.getKey(); + java.lang.Object value = prop.getValue(); + + if (value instanceof String) { + jsonWriter.writeStringField(key, (String) value); + } else if (value instanceof Integer) { + jsonWriter.writeIntField(key, (Integer) value); + } + } + + jsonWriter.writeEndObject(); + } + + public String getType() { + return type; + } + + public void setType(String type) { + this.type = type; + } + + public java.lang.Object getProperty(String name) { + return properties.get(name); + } + + public Map getProperties() { + return properties; + } + + public void addProperty(String name, java.lang.Object value) { + if (value instanceof String || value instanceof List || + value instanceof Integer || value instanceof Boolean || + value instanceof Map) { + properties.put(name, value); + } else { + throw new IllegalArgumentException("Unsupported property type: " + + (value != null ? value.getClass().getName() : "null")); + } + } + } +} \ No newline at end of file diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/testcase/Assert.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/testcase/Assert.java new file mode 100644 index 00000000..6f090b91 --- /dev/null +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/testcase/Assert.java @@ -0,0 +1,86 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.aad.msal4j.testcase; + +import com.azure.json.JsonReader; +import com.azure.json.JsonSerializable; +import com.azure.json.JsonToken; +import com.azure.json.JsonWriter; + +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; + +public class Assert implements JsonSerializable { + private final Map assertions = new HashMap<>(); + + public static Assert fromJson(JsonReader jsonReader) throws IOException { + Assert assertion = new Assert(); + + jsonReader.readObject(reader -> { + while (reader.nextToken() != JsonToken.END_OBJECT) { + String key = reader.getFieldName(); + reader.nextToken(); + + switch (reader.currentToken()) { + case STRING: + assertion.assertions.put(key, reader.getString()); + break; + case NUMBER: + assertion.assertions.put(key, reader.getInt()); + break; + default: + reader.skipChildren(); + break; + } + } + return null; + }); + + return assertion; + } + + @Override + public JsonWriter toJson(JsonWriter jsonWriter) throws IOException { + jsonWriter.writeStartObject(); + writeContent(jsonWriter); + return jsonWriter.writeEndObject(); + } + + public void writeContent(JsonWriter jsonWriter) throws IOException { + jsonWriter.writeFieldName("assert"); + jsonWriter.writeStartObject(); + + for (Map.Entry entry : assertions.entrySet()) { + String key = entry.getKey(); + Object value = entry.getValue(); + + if (value instanceof String) { + jsonWriter.writeStringField(key, (String) value); + } else if (value instanceof Integer) { + jsonWriter.writeIntField(key, (Integer) value); + } else if (value instanceof Boolean) { + jsonWriter.writeBooleanField(key, (Boolean) value); + } + } + + jsonWriter.writeEndObject(); + } + + public Map getAssertions() { + return assertions; + } + + public Object getAssertion(String key) { + return assertions.get(key); + } + + public void addAssertion(String key, Object value) { + if (value instanceof String || value instanceof Integer || value instanceof Boolean) { + assertions.put(key, value); + } else { + throw new IllegalArgumentException("Assertion value must be String or Integer"); + } + } +} \ No newline at end of file diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/testcase/Env.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/testcase/Env.java new file mode 100644 index 00000000..4b3e0698 --- /dev/null +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/testcase/Env.java @@ -0,0 +1,86 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.aad.msal4j.testcase; + +import com.azure.json.JsonReader; +import com.azure.json.JsonSerializable; +import com.azure.json.JsonToken; +import com.azure.json.JsonWriter; + +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; + +public class Env implements JsonSerializable { + private final Map variables = new HashMap<>(); + + public static Env fromJson(JsonReader jsonReader) throws IOException { + Env env = new Env(); + + jsonReader.readObject(reader -> { + while (reader.nextToken() != JsonToken.END_OBJECT) { + String key = reader.getFieldName(); + reader.nextToken(); // Move to the value + + switch (reader.currentToken()) { + case STRING: + env.variables.put(key, reader.getString()); + break; + case NUMBER: + env.variables.put(key, reader.getInt()); + break; + default: + reader.skipChildren(); + break; + } + } + return null; + }); + + return env; + } + + @Override + public JsonWriter toJson(JsonWriter jsonWriter) throws IOException { + jsonWriter.writeStartObject(); + writeContent(jsonWriter); + return jsonWriter.writeEndObject(); + } + + public void writeContent(JsonWriter jsonWriter) throws IOException { + jsonWriter.writeFieldName("env"); + jsonWriter.writeStartObject(); + + for (Map.Entry entry : variables.entrySet()) { + String key = entry.getKey(); + Object value = entry.getValue(); + + if (value instanceof String) { + jsonWriter.writeStringField(key, (String) value); + } else if (value instanceof Integer) { + jsonWriter.writeIntField(key, (Integer) value); + } else if (value instanceof Boolean) { + jsonWriter.writeBooleanField(key, (Boolean) value); + } + } + + jsonWriter.writeEndObject(); + } + + public Map getVariables() { + return variables; + } + + public Object getVariable(String name) { + return variables.get(name); + } + + public void setVariable(String name, Object value) { + if (value instanceof String || value instanceof Integer || value instanceof Boolean) { + variables.put(name, value); + } else { + throw new IllegalArgumentException("Variable value must be String, Integer, or Boolean"); + } + } +} \ No newline at end of file diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/testcase/ParsingTest.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/testcase/ParsingTest.java new file mode 100644 index 00000000..6cedafbd --- /dev/null +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/testcase/ParsingTest.java @@ -0,0 +1,141 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.aad.msal4j.testcase; + +import com.azure.json.JsonProviders; +import com.azure.json.JsonReader; +import com.azure.json.JsonWriter; +import org.junit.jupiter.api.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +class ParsingTest { + private static final Logger LOG = LoggerFactory.getLogger(ParsingTest.class); + private static final String MI_TEST_CASE_EXAMPLE = "{\"arrange\":{\"app1\":{\"ManagedIdentityClient\":{\"client_capabilities\":[\"cp1\",\"cp2\"],\"managed_identity\":{\"Id\":null,\"ManagedIdentityIdType\":\"SystemAssigned\"}}}},\"env\":{\"IDENTITY_ENDPOINT\":\"https://smile-test.azurewebsites.net/test/token_sha256_to_refresh/cp2,cp1\",\"IDENTITY_HEADER\":\"foo\",\"IDENTITY_SERVER_THUMBPRINT\":\"bar\"},\"steps\":[{\"act\":{\"app1.AcquireTokenForManagedIdentity\":{\"resource\":\"R\"}},\"assert\":{\"token_source\":\"identity_provider\",\"token_type\":\"Bearer\"}},{\"act\":{\"app1.AcquireTokenForManagedIdentity\":{\"resource\":\"R\"}},\"assert\":{\"token_source\":\"cache\",\"token_type\":\"Bearer\"}},{\"act\":{\"app1.AcquireTokenForManagedIdentity\":{\"claims_challenge\":\"{\\\"capability test case likely needs\\\": \\\"a valid json object\\\"}\",\"resource\":\"R\"}},\"assert\":{\"token_source\":\"identity_provider\",\"token_type\":\"Bearer\"}}],\"type\":\"MSAL Test\",\"ver\":1}"; + private static final String CONFIDENTIAL_TEST_CASE_EXAMPLE = "{\"arrange\":{\"cca1\":{\"ConfidentialClientApplication\":{\"client_credential\":\"fake-credential\",\"client_id\":\"foo\",\"oidc_authority\":\"https://smile-test.azurewebsites.net/wrong_issuer\"}},\"cca2\":{\"ConfidentialClientApplication\":{\"client_credential\":\"fake-credential\",\"client_id\":\"foo\",\"oidc_authority\":\"https://smile-test.azurewebsites.net/right_issuer\"}}},\"env\":{},\"steps\":[{\"act\":{\"cca1.AcquireTokenForClient\":{\"scopes\":[\"scope1\"]}},\"assert\":{\"error\":\"invalid_issuer\"}},{\"act\":{\"cca2.AcquireTokenForClient\":{\"scopes\":[\"scope2\"]}},\"assert\":{\"token_source\":\"identity_provider\",\"token_type\":\"Bearer\"}}],\"type\":\"MSAL Test\",\"ver\":1}"; + + @Test + void testFullJsonSerialization() throws Exception { + // Parse the test case + TestCase testCase = parseTestCase(MI_TEST_CASE_EXAMPLE); + + // Verify basic structure + assertNotNull(testCase.getType(), "Test case type should not be null"); + LOG.info("Deserialized TestCase: type={}, version={}", testCase.getType(), testCase.getVersion()); + + // Verify sections + verifyArrangeSection(testCase); + verifyEnvSection(testCase); + verifyStepsSection(testCase); + + // Test round-trip serialization + String json = serializeTestCase(testCase); + TestCase reparsedTestCase = parseTestCase(json); + + // Verify key properties match after round-trip + assertEquals(testCase.getType(), reparsedTestCase.getType()); + assertEquals(testCase.getVersion(), reparsedTestCase.getVersion()); + + // Verify sections match after round-trip + verifySectionsMatchAfterRoundTrip(testCase, reparsedTestCase); + } + + @Test + void testRealServerJsonConsistency() throws Exception { + // First round: Parse and serialize + TestCase testCase = parseTestCase(MI_TEST_CASE_EXAMPLE); + String firstSerialization = serializeTestCase(testCase); + + // Second round: Parse and serialize again + TestCase secondTestCase = parseTestCase(firstSerialization); + String secondSerialization = serializeTestCase(secondTestCase); + + // Third round: One more cycle + TestCase thirdTestCase = parseTestCase(secondSerialization); + String thirdSerialization = serializeTestCase(thirdTestCase); + + // Verify consistency between serializations + assertEquals(firstSerialization, secondSerialization, "First and second serializations should match"); + assertEquals(secondSerialization, thirdSerialization, "Second and third serializations should match"); + LOG.info("All three serialization rounds produced identical JSON"); + + // Verify object equality in depth + verifySectionsMatchAfterRoundTrip(testCase, secondTestCase); + } + + private TestCase parseTestCase(String json) throws Exception { + JsonReader reader = JsonProviders.createReader( + new ByteArrayInputStream(json.getBytes(StandardCharsets.UTF_8))); + return TestCase.fromJson(reader); + } + + private String serializeTestCase(TestCase testCase) throws Exception { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + JsonWriter writer = JsonProviders.createWriter(out); + testCase.toJson(writer); + writer.flush(); + return out.toString(); + } + + private void verifyArrangeSection(TestCase testCase) { + if (testCase.getArrange() != null) { + LOG.info("Arrange section contains {} object(s)", testCase.getArrange().getObjects().size()); + assertFalse(testCase.getArrange().getObjects().isEmpty(), "Arrange objects should not be empty"); + + for (Map.Entry entry : testCase.getArrange().getObjects().entrySet()) { + LOG.info(" Object: {}, Type: {}", entry.getKey(), entry.getValue().getType()); + } + } + } + + private void verifyEnvSection(TestCase testCase) { + if (testCase.getEnv() != null) { + LOG.info("Environment section contains {} variable(s)", testCase.getEnv().getVariables().size()); + assertFalse(testCase.getEnv().getVariables().isEmpty(), "Environment variables should not be empty"); + } + } + + private void verifyStepsSection(TestCase testCase) { + if (testCase.getSteps() != null) { + LOG.info("Steps section contains {} step(s)", testCase.getSteps().getSteps().size()); + assertFalse(testCase.getSteps().getSteps().isEmpty(), "Steps should not be empty"); + } + } + + private void verifySectionsMatchAfterRoundTrip(TestCase original, TestCase reparsed) { + // Check arrange section + if (original.getArrange() != null && reparsed.getArrange() != null) { + assertEquals(original.getArrange().getObjects().size(), reparsed.getArrange().getObjects().size(), + "Arrange objects count should match"); + } else { + assertTrue((original.getArrange() == null && reparsed.getArrange() == null), + "Both arrange sections should be null or non-null"); + } + + // Check env section + if (original.getEnv() != null && reparsed.getEnv() != null) { + assertEquals(original.getEnv().getVariables().size(), reparsed.getEnv().getVariables().size(), + "Environment variables count should match"); + } else { + assertTrue((original.getEnv() == null && reparsed.getEnv() == null), + "Both env sections should be null or non-null"); + } + + // Check steps section + if (original.getSteps() != null && reparsed.getSteps() != null) { + assertEquals(original.getSteps().getSteps().size(), reparsed.getSteps().getSteps().size(), + "Steps count should match"); + } else { + assertTrue((original.getSteps() == null && reparsed.getSteps() == null), + "Both steps sections should be null or non-null"); + } + } +} \ No newline at end of file diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/testcase/Steps.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/testcase/Steps.java new file mode 100644 index 00000000..23b2abe7 --- /dev/null +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/testcase/Steps.java @@ -0,0 +1,135 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.aad.msal4j.testcase; + +import com.azure.json.JsonReader; +import com.azure.json.JsonSerializable; +import com.azure.json.JsonToken; +import com.azure.json.JsonWriter; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +public class Steps implements JsonSerializable { + private final List steps = new ArrayList<>(); + + public static Steps fromJson(JsonReader jsonReader) throws IOException { + Steps steps = new Steps(); + + if (jsonReader.currentToken() == JsonToken.START_OBJECT) { + jsonReader.readObject(reader -> { + while (reader.nextToken() != JsonToken.END_OBJECT) { + String field = reader.getFieldName(); + if ("steps".equals(field)) { + reader.nextToken(); // Move to START_ARRAY + parseStepsArray(reader, steps); + } else { + reader.nextToken(); + reader.skipChildren(); + } + } + return null; + }); + } else if (jsonReader.currentToken() == JsonToken.START_ARRAY) { + parseStepsArray(jsonReader, steps); + } + + return steps; + } + + private static void parseStepsArray(JsonReader reader, Steps steps) throws IOException { + while (reader.nextToken() != JsonToken.END_ARRAY) { + if (reader.currentToken() == JsonToken.START_OBJECT) { + steps.addStep(Step.fromJson(reader)); + } + } + } + + @Override + public JsonWriter toJson(JsonWriter jsonWriter) throws IOException { + jsonWriter.writeStartObject(); + writeContent(jsonWriter); + jsonWriter.writeEndObject(); + return jsonWriter; + } + + public void writeContent(JsonWriter jsonWriter) throws IOException { + jsonWriter.writeFieldName("steps"); + jsonWriter.writeStartArray(); + for (Step step : steps) { + step.toJson(jsonWriter); + } + jsonWriter.writeEndArray(); + } + + public List getSteps() { + return steps; + } + + public void addStep(Step step) { + steps.add(step); + } + + public static class Step implements JsonSerializable { + private Act action; + private Assert assertion; + + public static Step fromJson(JsonReader jsonReader) throws IOException { + Step step = new Step(); + + jsonReader.readObject(reader -> { + while (reader.nextToken() != JsonToken.END_OBJECT) { + String field = reader.getFieldName(); + reader.nextToken(); + + switch (field) { + case "act": + step.action = Act.fromJson(reader); + break; + case "assert": + step.assertion = Assert.fromJson(reader); + break; + default: + reader.skipChildren(); + } + } + return null; + }); + + return step; + } + + @Override + public JsonWriter toJson(JsonWriter jsonWriter) throws IOException { + jsonWriter.writeStartObject(); + + if (action != null) { + action.writeContent(jsonWriter); + } + + if (assertion != null) { + assertion.writeContent(jsonWriter); + } + + return jsonWriter.writeEndObject(); + } + + public Act getAction() { + return action; + } + + public void setAction(Act action) { + this.action = action; + } + + public Assert getAssertion() { + return assertion; + } + + public void setAssertion(Assert assertion) { + this.assertion = assertion; + } + } +} \ No newline at end of file diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/testcase/TestCase.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/testcase/TestCase.java new file mode 100644 index 00000000..311481bc --- /dev/null +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/testcase/TestCase.java @@ -0,0 +1,119 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.aad.msal4j.testcase; + +import com.azure.json.JsonReader; +import com.azure.json.JsonSerializable; +import com.azure.json.JsonToken; +import com.azure.json.JsonWriter; + +import java.io.IOException; + +public class TestCase implements JsonSerializable { + private String type; + private int version; + private Arrange arrange; + private Env env; + private Steps steps; + + public static TestCase fromJson(JsonReader jsonReader) throws IOException { + TestCase testCase = new TestCase(); + + jsonReader.readObject(reader -> { + while (reader.nextToken() != JsonToken.END_OBJECT) { + String field = reader.getFieldName(); + reader.nextToken(); // Move to value + + switch (field) { + case "type": + testCase.type = reader.getString(); + break; + case "ver": + testCase.version = reader.getInt(); + break; + case "arrange": + testCase.arrange = Arrange.fromJson(reader); + break; + case "env": + testCase.env = Env.fromJson(reader); + break; + case "steps": + testCase.steps = Steps.fromJson(reader); + break; + default: + reader.skipChildren(); + break; + } + } + return null; + }); + + return testCase; + } + + @Override + public JsonWriter toJson(JsonWriter jsonWriter) throws IOException { + jsonWriter.writeStartObject(); + writeContent(jsonWriter); + return jsonWriter.writeEndObject(); + } + + public void writeContent(JsonWriter jsonWriter) throws IOException { + jsonWriter.writeStringField("type", type); + jsonWriter.writeIntField("ver", version); + + if (arrange != null) { + arrange.writeContent(jsonWriter); + } + + if (env != null) { + env.writeContent(jsonWriter); + } + + if (steps != null) { + steps.writeContent(jsonWriter); + } + } + + // Getters and setters + public String getType() { + return type; + } + + public void setType(String type) { + this.type = type; + } + + public int getVersion() { + return version; + } + + public void setVersion(int version) { + this.version = version; + } + + public Arrange getArrange() { + return arrange; + } + + public void setArrange(Arrange arrange) { + this.arrange = arrange; + } + + public Env getEnv() { + return env; + } + + public void setEnv(Env env) { + this.env = env; + } + + public Steps getSteps() { + return steps; + } + + public void setSteps(Steps steps) { + this.steps = steps; + } +} \ No newline at end of file diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/testcase/TestCaseHelper.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/testcase/TestCaseHelper.java new file mode 100644 index 00000000..6cfa9932 --- /dev/null +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/testcase/TestCaseHelper.java @@ -0,0 +1,188 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.aad.msal4j.testcase; + +import com.azure.json.*; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStreamReader; +import java.net.HttpURLConnection; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.util.*; + +public class TestCaseHelper implements JsonSerializable { + private static final Logger LOG = LoggerFactory.getLogger(TestCaseHelper.class); + private final List testcases; + + /** + * Creates a new TestCaseHelper with the given list of test case URLs. + * @param testcases List of test case URLs + */ + public TestCaseHelper(List testcases) { + this.testcases = testcases; + } + + /** + * Returns the list of test case URLs. + * @return List of test case URLs + */ + public List getTestcases() { + return testcases; + } + + /** + * Creates a TestCaseHelper from a JSON reader. + * @param jsonReader The JSON reader + * @return A new TestCaseHelper instance + * @throws IOException If there's an error reading the JSON + */ + public static TestCaseHelper fromJson(JsonReader jsonReader) throws IOException { + List urls = new ArrayList<>(); + + return jsonReader.readObject(reader -> { + while (reader.nextToken() != JsonToken.END_OBJECT) { + String fieldName = reader.getFieldName(); + + if ("testcases".equals(fieldName)) { + reader.nextToken(); // Move to START_ARRAY + + // Read array elements until END_ARRAY + while (reader.nextToken() != JsonToken.END_ARRAY) { + urls.add(reader.getString()); + } + } else { + reader.nextToken(); // Move to the value + reader.skipChildren(); + } + } + + return new TestCaseHelper(urls); + }); + } + + /** + * Fetches test case URLs from an endpoint containing JSON with a "testcases" array. + * + * @param endpointUrl The URL to fetch test cases from + * @return List of test case URLs + * @throws IOException If there's an error fetching or parsing the JSON + */ + public static List getTestCaseUrlsFromEndpoint(String endpointUrl) throws IOException { + String jsonContent = fetchRawContent(endpointUrl); + JsonReader reader = JsonProviders.createReader( + new ByteArrayInputStream(jsonContent.getBytes(StandardCharsets.UTF_8))); + + TestCaseHelper helper = fromJson(reader); + return helper.getTestcases(); + } + + /** + * Fetches raw content from a URL as a string + * + * @param url The URL to fetch content from + * @return The content as a string + * @throws IOException If there's an error fetching the content + */ + private static String fetchRawContent(String url) throws IOException { + URL requestUrl = new URL(url); + HttpURLConnection connection = (HttpURLConnection) requestUrl.openConnection(); + connection.setRequestMethod("GET"); + connection.setRequestProperty("Content-Type", "application/json"); + + int responseCode = connection.getResponseCode(); + if (responseCode == HttpURLConnection.HTTP_OK) { + try (InputStreamReader reader = new InputStreamReader(connection.getInputStream(), StandardCharsets.UTF_8)) { + StringBuilder response = new StringBuilder(); + char[] buffer = new char[1024]; + int charsRead; + while ((charsRead = reader.read(buffer)) != -1) { + response.append(buffer, 0, charsRead); + } + return response.toString(); + } + } else { + throw new IOException("HTTP request failed with status code: " + responseCode); + } + } + + /** + * Fetches JSON content from a URL + * + * @param jsonUrl The URL to fetch JSON content from + * @return The JSON content as a string + * @throws IOException If there's an error fetching the content + */ + public static String fetchJsonContent(String jsonUrl) throws IOException { + return fetchRawContent(jsonUrl); + } + + /** + * Complete workflow to get all test case configs + * + * @param indexEndpoint The URL of the index containing test case URLs + * @return Map of test case names to their TestCase instances + * @throws IOException If there's an error fetching or parsing the JSON + */ + public static Map getAllTestCaseConfigs(String indexEndpoint) throws IOException { + // Get list of SML test case URLs + List smlUrls = getTestCaseUrlsFromEndpoint(indexEndpoint); + + // Convert SML URLs to JSON URLs + List jsonUrls = convertSmlUrlsToJsonUrls(smlUrls); + + // Fetch content for each JSON URL + Map testCaseConfigs = new HashMap<>(); + for (String jsonUrl : jsonUrls) { + String testCaseName = extractTestCaseName(jsonUrl); + String jsonContent = fetchJsonContent(jsonUrl); + + JsonReader reader = JsonProviders.createReader( + new ByteArrayInputStream(jsonContent.getBytes(StandardCharsets.UTF_8))); + TestCase testCase = TestCase.fromJson(reader); + testCaseConfigs.put(testCaseName, testCase); + } + + return testCaseConfigs; + } + + /** + * Convert SML URLs to JSON URLs + */ + private static List convertSmlUrlsToJsonUrls(List smlUrls) { + List jsonUrls = new ArrayList<>(); + for (String smlUrl : smlUrls) { + jsonUrls.add(smlUrl.replace(".sml", ".json")); + } + return jsonUrls; + } + + /** + * Extract test case name from URL + */ + private static String extractTestCaseName(String url) { + String[] parts = url.split("/"); + String fileName = parts[parts.length - 1]; + return fileName.substring(0, fileName.lastIndexOf('.')); + } + + @Override + public JsonWriter toJson(JsonWriter jsonWriter) throws IOException { + jsonWriter.writeStartObject(); + jsonWriter.writeStartArray("testcases"); + + for (String testcase : testcases) { + jsonWriter.writeString(testcase); + } + + jsonWriter.writeEndArray(); + jsonWriter.writeEndObject(); + + return jsonWriter; + } +} \ No newline at end of file From 502127ab243e831b453d5384b34fedd9f844bcf5 Mon Sep 17 00:00:00 2001 From: avdunn Date: Mon, 16 Jun 2025 08:48:59 -0700 Subject: [PATCH 4/7] Fix test issue caused by static HttpClient and cleanup validation logic --- .../microsoft/aad/msal4j/OidcAuthority.java | 25 ++++++++----------- .../ServiceFabricManagedIdentitySource.java | 5 ++++ .../aad/msal4j/ManagedIdentityTests.java | 5 ++++ 3 files changed, 20 insertions(+), 15 deletions(-) diff --git a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/OidcAuthority.java b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/OidcAuthority.java index e8dcfcf7..331289ec 100644 --- a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/OidcAuthority.java +++ b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/OidcAuthority.java @@ -10,6 +10,8 @@ public class OidcAuthority extends Authority { //Part of the OpenIdConnect standard, this is appended to the authority to create the endpoint that has OIDC metadata static final String WELL_KNOWN_OPENID_CONFIGURATION = ".well-known/openid-configuration"; private static final String AUTHORITY_FORMAT = "https://%s/%s/"; + private static final String CIAM_AUTHORITY_FORMAT = "https://%s.ciamlogin.com/%s"; + String issuerFromOidcDiscovery; OidcAuthority(URL authorityUrl) throws MalformedURLException { @@ -45,32 +47,25 @@ boolean isIssuerValid() { return false; } - // Normalize issuer by removing trailing slashes - String normalizedIssuer = issuerFromOidcDiscovery; - while (normalizedIssuer.endsWith("/")) { - normalizedIssuer = normalizedIssuer.substring(0, normalizedIssuer.length() - 1); - } - // Case 1: Check against canonicalAuthorityUrl without the well-known segment String authorityWithoutWellKnown = canonicalAuthorityUrl.toString(); if (authorityWithoutWellKnown.endsWith(WELL_KNOWN_OPENID_CONFIGURATION)) { authorityWithoutWellKnown = authorityWithoutWellKnown.substring(0, authorityWithoutWellKnown.length() - WELL_KNOWN_OPENID_CONFIGURATION.length()); - // Remove trailing slash if present - if (authorityWithoutWellKnown.endsWith("/")) { - authorityWithoutWellKnown = authorityWithoutWellKnown.substring(0, authorityWithoutWellKnown.length() - 1); - } + // Normalize both URLs to ensure consistent comparison + String normalizedAuthority = Authority.enforceTrailingSlash(authorityWithoutWellKnown); + String normalizedIssuer = Authority.enforceTrailingSlash(issuerFromOidcDiscovery); - if (normalizedIssuer.equals(authorityWithoutWellKnown)) { + if (normalizedIssuer.equals(normalizedAuthority)) { return true; } } - // Case 2: Check CIAM format: "https://{tenant}.ciamlogin.com/{tenant}/" - if (tenant != null && !tenant.isEmpty()) { - String ciamPattern = "https://" + tenant + ".ciamlogin.com/" + tenant; - return normalizedIssuer.startsWith(ciamPattern); + // Case 2: Check CIAM format: "https://{tenant}.ciamlogin.com/{tenant}" + if (!StringHelper.isNullOrBlank(tenant)) { + String ciamPattern = String.format(CIAM_AUTHORITY_FORMAT, tenant, tenant); + return issuerFromOidcDiscovery.startsWith(ciamPattern); } return false; diff --git a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/ServiceFabricManagedIdentitySource.java b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/ServiceFabricManagedIdentitySource.java index e7fc85f4..714f3947 100644 --- a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/ServiceFabricManagedIdentitySource.java +++ b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/ServiceFabricManagedIdentitySource.java @@ -124,4 +124,9 @@ static void setHttpClient(IHttpClient client) { httpClient = client; httpHelper = new HttpHelper(httpClient, new ManagedIdentityRetryPolicy()); } + + static void resetHttpClient() { + httpClient = new DefaultHttpClientManagedIdentity(null, null, null, null); + httpHelper = new HttpHelper(httpClient, new ManagedIdentityRetryPolicy()); + } } \ No newline at end of file diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/ManagedIdentityTests.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/ManagedIdentityTests.java index 1f982dbb..5a3fc90e 100644 --- a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/ManagedIdentityTests.java +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/ManagedIdentityTests.java @@ -52,6 +52,11 @@ static void resetRetryPolicies() { IMDSRetryPolicy.resetToDefaults(); } + @AfterAll + static void resetServiceFabricHttpClient() { + ServiceFabricManagedIdentitySource.resetHttpClient(); + } + private String getSuccessfulResponse(String resource) { long expiresOn = (System.currentTimeMillis() / 1000) + (24 * 3600);//A long-lived, 24 hour token return "{\"access_token\":\"accesstoken\",\"expires_on\":\"" + expiresOn + "\",\"resource\":\"" + resource + "\",\"token_type\":" + From 5aed8d577267a24a7cbc38bbdf80110fe12a85d5 Mon Sep 17 00:00:00 2001 From: avdunn Date: Tue, 8 Jul 2025 13:19:54 -0700 Subject: [PATCH 5/7] Address PR feedback --- .../msal4j/AbstractClientApplicationBase.java | 12 +- .../microsoft/aad/msal4j/OidcAuthority.java | 16 ++- .../aad/msal4j/OidcAuthorityTest.java | 107 ++++++++++++++++++ 3 files changed, 123 insertions(+), 12 deletions(-) create mode 100644 msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/OidcAuthorityTest.java diff --git a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/AbstractClientApplicationBase.java b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/AbstractClientApplicationBase.java index 2815ed06..e99f77c8 100644 --- a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/AbstractClientApplicationBase.java +++ b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/AbstractClientApplicationBase.java @@ -575,14 +575,8 @@ public T correlationId(String val) { aadAadInstanceDiscoveryResponse); } - if (authenticationAuthority.authorityType == AuthorityType.OIDC) { - ((OidcAuthority) authenticationAuthority).setAuthorityProperties( - OidcDiscoveryProvider.performOidcDiscovery( - (OidcAuthority) authenticationAuthority, this)); - - if (!((OidcAuthority) authenticationAuthority).isIssuerValid()) { - throw new MsalClientException(String.format("Invalid issuer from OIDC discovery: %s", ((OidcAuthority) authenticationAuthority).issuerFromOidcDiscovery), "issuer_validation"); - } - } + ((OidcAuthority) authenticationAuthority).setAuthorityProperties( + OidcDiscoveryProvider.performOidcDiscovery( + (OidcAuthority) authenticationAuthority, this)); } } \ No newline at end of file diff --git a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/OidcAuthority.java b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/OidcAuthority.java index 331289ec..9207e5de 100644 --- a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/OidcAuthority.java +++ b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/OidcAuthority.java @@ -8,11 +8,11 @@ public class OidcAuthority extends Authority { //Part of the OpenIdConnect standard, this is appended to the authority to create the endpoint that has OIDC metadata - static final String WELL_KNOWN_OPENID_CONFIGURATION = ".well-known/openid-configuration"; + private static final String WELL_KNOWN_OPENID_CONFIGURATION = ".well-known/openid-configuration"; private static final String AUTHORITY_FORMAT = "https://%s/%s/"; private static final String CIAM_AUTHORITY_FORMAT = "https://%s.ciamlogin.com/%s"; - String issuerFromOidcDiscovery; + private String issuerFromOidcDiscovery; OidcAuthority(URL authorityUrl) throws MalformedURLException { super(createOidcDiscoveryUrl(authorityUrl), AuthorityType.OIDC); @@ -33,6 +33,16 @@ void setAuthorityProperties(OidcDiscoveryResponse instanceDiscoveryResponse) { this.deviceCodeEndpoint = instanceDiscoveryResponse.deviceCodeEndpoint(); this.selfSignedJwtAudience = this.tokenEndpoint; this.issuerFromOidcDiscovery = instanceDiscoveryResponse.issuer(); + + validateIssuer(); + } + + private void validateIssuer() { + if (!isIssuerValid()) { + throw new MsalClientException( + String.format("Invalid issuer from OIDC discovery. Issuer %s does not match authority %s, or is in an unexpected format", issuerFromOidcDiscovery, canonicalAuthorityUrl), + "issuer_validation"); + } } /** @@ -42,7 +52,7 @@ void setAuthorityProperties(OidcDiscoveryResponse instanceDiscoveryResponse) { * * @return true if the issuer is valid, false otherwise */ - boolean isIssuerValid() { + private boolean isIssuerValid() { if (issuerFromOidcDiscovery == null) { return false; } diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/OidcAuthorityTest.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/OidcAuthorityTest.java new file mode 100644 index 00000000..d412d862 --- /dev/null +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/OidcAuthorityTest.java @@ -0,0 +1,107 @@ +package com.microsoft.aad.msal4j; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.BeforeEach; +import org.mockito.Mockito; + +import java.net.MalformedURLException; +import java.net.URL; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class OidcAuthorityTest { + + private OidcDiscoveryResponse mockDiscoveryResponse; + + @BeforeEach + void setup() { + mockDiscoveryResponse = Mockito.mock(OidcDiscoveryResponse.class); + when(mockDiscoveryResponse.authorizationEndpoint()).thenReturn("https://login.example.com/authorize"); + when(mockDiscoveryResponse.tokenEndpoint()).thenReturn("https://login.example.com/token"); + when(mockDiscoveryResponse.deviceCodeEndpoint()).thenReturn("https://login.example.com/devicecode"); + } + + @Test + void testSetAuthorityProperties_IssuerMatchesAuthority() throws MalformedURLException { + // Arrange + URL authorityUrl = new URL("https://login.example.com/tenant1/"); + OidcAuthority authority = new OidcAuthority(authorityUrl); + + // Match the issuer to the authority URL (without the well-known segment) + when(mockDiscoveryResponse.issuer()).thenReturn("https://login.example.com/tenant1/"); + + // Act & Assert - Should not throw exception + assertDoesNotThrow(() -> authority.setAuthorityProperties(mockDiscoveryResponse)); + + // Verify properties were set + assertEquals("https://login.example.com/authorize", authority.authorizationEndpoint()); + assertEquals("https://login.example.com/token", authority.tokenEndpoint()); + assertEquals("https://login.example.com/devicecode", authority.deviceCodeEndpoint()); + assertEquals("https://login.example.com/token", authority.selfSignedJwtAudience); + } + + @Test + void testSetAuthorityProperties_IssuerFollowsCiamPattern() throws MalformedURLException { + // Arrange + String tenant = "contoso"; + URL authorityUrl = new URL("https://" + tenant + ".ciamlogin.com/" + tenant + "/"); + OidcAuthority authority = new OidcAuthority(authorityUrl); + + // Set an issuer that follows CIAM pattern but doesn't exactly match the authority + String ciamIssuer = "https://" + tenant + ".ciamlogin.com/" + tenant + "/v2.0"; + when(mockDiscoveryResponse.issuer()).thenReturn(ciamIssuer); + + // Act & Assert - Should not throw exception + assertDoesNotThrow(() -> authority.setAuthorityProperties(mockDiscoveryResponse)); + } + + @Test + void testSetAuthorityProperties_IssuerInvalid() throws MalformedURLException { + // Arrange + URL authorityUrl = new URL("https://login.example.com/tenant1/"); + OidcAuthority authority = new OidcAuthority(authorityUrl); + + // Set an issuer that doesn't match the authority and doesn't follow CIAM pattern + when(mockDiscoveryResponse.issuer()).thenReturn("https://login.different.com/tenant1/"); + + // Act & Assert - Should throw MsalClientException + MsalClientException exception = assertThrows(MsalClientException.class, + () -> authority.setAuthorityProperties(mockDiscoveryResponse)); + + // Verify exception details + assertEquals("issuer_validation", exception.errorCode()); + assertTrue(exception.getMessage().contains("Invalid issuer from OIDC discovery")); + } + + @Test + void testSetAuthorityProperties_IssuerIsNull() throws MalformedURLException { + // Arrange + URL authorityUrl = new URL("https://login.example.com/tenant1/"); + OidcAuthority authority = new OidcAuthority(authorityUrl); + + // Set null issuer + when(mockDiscoveryResponse.issuer()).thenReturn(null); + + // Act & Assert - Should throw MsalClientException + MsalClientException exception = assertThrows(MsalClientException.class, + () -> authority.setAuthorityProperties(mockDiscoveryResponse)); + + // Verify exception details + assertEquals("issuer_validation", exception.errorCode()); + assertTrue(exception.getMessage().contains("Invalid issuer from OIDC discovery")); + } + + @Test + void testSetAuthorityProperties_TrailingSlashNormalization() throws MalformedURLException { + // Arrange + URL authorityUrl = new URL("https://login.example.com/tenant1/"); + OidcAuthority authority = new OidcAuthority(authorityUrl); + + // Match the issuer to the authority but without trailing slash + when(mockDiscoveryResponse.issuer()).thenReturn("https://login.example.com/tenant1"); + + // Act & Assert - Should not throw exception because normalization happens + assertDoesNotThrow(() -> authority.setAuthorityProperties(mockDiscoveryResponse)); + } +} \ No newline at end of file From 5dc78c3f6f7b2eefaa2dfe10abbfc8d37d22fa5d Mon Sep 17 00:00:00 2001 From: avdunn Date: Tue, 8 Jul 2025 13:20:21 -0700 Subject: [PATCH 6/7] Remove POC test classes --- .../microsoft/aad/msal4j/RunnerHelper.java | 500 ------------------ .../com/microsoft/aad/msal4j/RunnerTest.java | 89 ---- .../microsoft/aad/msal4j/testcase/Act.java | 124 ----- .../aad/msal4j/testcase/Arrange.java | 226 -------- .../microsoft/aad/msal4j/testcase/Assert.java | 86 --- .../microsoft/aad/msal4j/testcase/Env.java | 86 --- .../aad/msal4j/testcase/ParsingTest.java | 141 ----- .../microsoft/aad/msal4j/testcase/Steps.java | 135 ----- .../aad/msal4j/testcase/TestCase.java | 119 ----- .../aad/msal4j/testcase/TestCaseHelper.java | 188 ------- 10 files changed, 1694 deletions(-) delete mode 100644 msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/RunnerHelper.java delete mode 100644 msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/RunnerTest.java delete mode 100644 msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/testcase/Act.java delete mode 100644 msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/testcase/Arrange.java delete mode 100644 msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/testcase/Assert.java delete mode 100644 msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/testcase/Env.java delete mode 100644 msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/testcase/ParsingTest.java delete mode 100644 msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/testcase/Steps.java delete mode 100644 msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/testcase/TestCase.java delete mode 100644 msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/testcase/TestCaseHelper.java diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/RunnerHelper.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/RunnerHelper.java deleted file mode 100644 index 45f74c17..00000000 --- a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/RunnerHelper.java +++ /dev/null @@ -1,500 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -package com.microsoft.aad.msal4j; - -import java.util.ArrayList; -import java.util.HashMap; -import java.util.HashSet; -import java.util.List; -import java.util.Map; -import java.util.Set; - -import static org.junit.jupiter.api.Assertions.assertEquals; - -import com.microsoft.aad.msal4j.testcase.Act; -import com.microsoft.aad.msal4j.testcase.Arrange; -import com.microsoft.aad.msal4j.testcase.Assert; -import com.microsoft.aad.msal4j.testcase.Steps; -import com.microsoft.aad.msal4j.testcase.TestCase; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -/** - * Helper class for running MSAL4J test cases. - * Provides functionality for creating applications, executing test steps, and validating results. - */ -public class RunnerHelper { - private static final Logger LOG = LoggerFactory.getLogger(RunnerHelper.class); - - /** - * Represents the result of attempting to create an application. - * Can either hold the created application or an exception if creation failed. - */ - static class AppCreationResult { - private final IApplicationBase application; - private final Exception exception; - - private AppCreationResult(IApplicationBase application) { - this.application = application; - this.exception = null; - } - - private AppCreationResult(Exception exception) { - this.application = null; - this.exception = exception; - } - - public boolean isSuccessful() { - return application != null; - } - - @SuppressWarnings("unchecked") - public T getApplication() { - return (T) application; - } - - public Exception getException() { - return exception; - } - - public static AppCreationResult success(IApplicationBase application) { - return new AppCreationResult(application); - } - - public static AppCreationResult failure(Exception exception) { - return new AppCreationResult(exception); - } - } - - /** - * Creates Managed Identity applications from the test configuration. - */ - static Map createMangedIdentityAppsFromConfig(TestCase testCase) { - Map appResults = new HashMap<>(); - Arrange arrange = testCase.getArrange(); - IEnvironmentVariables env = setEnvironmentVariables(testCase); - - for (Map.Entry entry : arrange.getObjects().entrySet()) { - String appName = entry.getKey(); - Arrange.ObjectProperties appObjectProperties = entry.getValue(); - - if ("ManagedIdentityClient".equals(appObjectProperties.getType())) { - try { - ManagedIdentityId identityId = createManagedIdentityId(appObjectProperties); - List capabilities = extractClientCapabilities(appObjectProperties); - - ManagedIdentityApplication app = ManagedIdentityApplication.builder(identityId) - .clientCapabilities(capabilities) - .build(); - - ManagedIdentityApplication.setEnvironmentVariables(env); - - appResults.put(appName, AppCreationResult.success(app)); - LOG.info("Created app: {}", appName); - } catch (Exception e) { - LOG.info("Failed to create app {}: {}", appName, e.getMessage()); - appResults.put(appName, AppCreationResult.failure(e)); - } - } - } - return appResults; - } - - /** - * Creates Confidential Client applications from the test configuration. - */ - static Map createConfidentialClientAppsFromConfig(TestCase testCase) { - Map appResults = new HashMap<>(); - Arrange arrange = testCase.getArrange(); - - for (Map.Entry entry : arrange.getObjects().entrySet()) { - String appName = entry.getKey(); - Arrange.ObjectProperties appConfig = entry.getValue(); - - if ("ConfidentialClientApplication".equals(appConfig.getType())) { - try { - String clientId = (String) appConfig.getProperty("client_id"); - String oidcAuthority = (String) appConfig.getProperty("oidc_authority"); - IClientCredential credential = ClientCredentialFactory.createFromSecret( - (String) appConfig.getProperty("client_credential")); - - ConfidentialClientApplication app = ConfidentialClientApplication.builder(clientId, credential) - .oidcAuthority(oidcAuthority) - .build(); - - appResults.put(appName, AppCreationResult.success(app)); - LOG.info("Created app: {}", appName); - } catch (Exception e) { - LOG.info("Failed to create app {}: {}", appName, e.getMessage()); - appResults.put(appName, AppCreationResult.failure(e)); - } - } - } - return appResults; - } - - /** - * Executes all steps in a test case using the created applications. - */ - static void executeTestCase(TestCase testCase, Map appResults) throws Exception { - Map> stepsByApp = groupStepsByApplication(testCase); - - for (String appId : stepsByApp.keySet()) { - AppCreationResult appResult = appResults.get(appId); - if (appResult == null) { - throw new IllegalStateException("No app result found for ID: " + appId); - } - - if (appResult.isSuccessful()) { - executeStepsForSuccessfulApp(appResult.getApplication(), stepsByApp.get(appId)); - } else { - validateFailedAppResults(appResult.getException(), stepsByApp.get(appId)); - } - } - } - - /** - * Groups test steps by the application they target. - */ - private static Map> groupStepsByApplication(TestCase testCase) { - Map> stepsByApp = new HashMap<>(); - - for (Steps.Step step : testCase.getSteps().getSteps()) { - String appId = step.getAction().getTargetObject(); - if (!stepsByApp.containsKey(appId)) { - stepsByApp.put(appId, new ArrayList<>()); - } - stepsByApp.get(appId).add(step); - } - - return stepsByApp; - } - - /** - * Executes test steps for a successfully created application. - */ - private static void executeStepsForSuccessfulApp(IApplicationBase app, List steps) throws Exception { - if (app instanceof ManagedIdentityApplication) { - executeTestSteps((ManagedIdentityApplication) app, steps); - } else if (app instanceof ConfidentialClientApplication) { - executeTestSteps((ConfidentialClientApplication) app, steps); - } else { - throw new UnsupportedOperationException("Unsupported application type: " + app.getClass().getName()); - } - } - - /** - * Validates test assertions for each step when application creation failed. - */ - private static void validateFailedAppResults(Exception exception, List steps) { - LOG.info("Validating app creation exception"); - for (Steps.Step step : steps) { - validateAssertions(null, exception, step.getAssertion()); - } - } - - /** - * Execute all test steps for a ManagedIdentityApplication. - */ - static void executeTestSteps(ManagedIdentityApplication app, List steps) throws Exception { - // Clear token cache once at the start for this app - app.tokenCache.accessTokens.clear(); - - for (Steps.Step step : steps) { - LOG.info("Executing step for ManagedIdentityApplication"); - executeStep(app, step); - } - } - - /** - * Execute all test steps for a ConfidentialClientApplication. - */ - static void executeTestSteps(ConfidentialClientApplication app, List steps) throws Exception { - // Clear token cache once at the start for this app - app.tokenCache.accessTokens.clear(); - - for (Steps.Step step : steps) { - LOG.info("Executing step for ConfidentialClientApplication"); - executeStep(app, step); - } - } - - /** - * Executes a single test step and validates its assertions. - */ - private static void executeStep(T app, Steps.Step step) throws Exception { - try { - IAuthenticationResult result; - if (app instanceof ManagedIdentityApplication) { - result = executeAction((ManagedIdentityApplication) app, step.getAction()); - } else if (app instanceof ConfidentialClientApplication) { - result = executeAction((ConfidentialClientApplication) app, step.getAction()); - } else { - throw new UnsupportedOperationException("Unsupported application type: " + app.getClass().getName()); - } - - validateAssertions(result, null, step.getAssertion()); - } catch (Exception e) { - LOG.info("Exception executing action: {}", e.getMessage()); - validateAssertions(null, e, step.getAssertion()); - } - } - - /** - * Execute an action on a ManagedIdentityApplication and return the result. - */ - static IAuthenticationResult executeAction(ManagedIdentityApplication app, Act action) throws Exception { - if (action.getMethodName().equals("AcquireTokenForManagedIdentity")) { - LOG.info("Executing action: {}", action.getMethodName()); - ManagedIdentityParameters params = buildManagedIdentityParameters(action); - IAuthenticationResult result = app.acquireTokenForManagedIdentity(params).get(); - logAuthResult(result); - return result; - } else { - throw new UnsupportedOperationException("Unsupported action: " + action.getMethodName()); - } - } - - /** - * Execute an action on a ConfidentialClientApplication and return the result. - */ - static IAuthenticationResult executeAction(ConfidentialClientApplication app, Act action) throws Exception { - if (action.getMethodName().equals("AcquireTokenForClient")) { - LOG.info("Executing action: {}", action.getMethodName()); - //TODO: Handle other acquire token methods - ClientCredentialParameters params = buildClientCredentialParameters(action); - IAuthenticationResult result = app.acquireToken(params).get(); - logAuthResult(result); - return result; - } else { - throw new UnsupportedOperationException("Unsupported action: " + action.getMethodName()); - } - } - - /** - * Logs the details of an authentication result. - */ - private static void logAuthResult(IAuthenticationResult result) { - if (LOG.isDebugEnabled()) { - LOG.debug("Action result:"); - LOG.debug("Access Token: {}", result.accessToken()); - LOG.debug("ID Token : {}", result.idToken()); - LOG.debug("Account : {}", result.account()); - LOG.debug("Token Source: {}", result.metadata().tokenSource()); - } - } - - /** - * Validate assertions against a result. - */ - static void validateAssertions(IAuthenticationResult result, Exception exception, Assert assertion) { - if (assertion == null) { - LOG.info("No assertions to validate"); - return; - } - - Map assertions = assertion.getAssertions(); - assertions.forEach((key, value) -> { - switch (key) { - case "token_source": - LOG.info("Validating token source"); - validateTokenSource((String) value, result); - break; - case "error": - LOG.info("Validating exception"); - validateException((String) value, exception); - break; - default: - LOG.info("Unknown assertion type: {}", key); - break; - } - }); - } - - /** - * Validates that an exception matches expected criteria. - */ - static void validateException(String expectedExceptionType, Exception actualException) { - if (StringHelper.isNullOrBlank(expectedExceptionType)) { - assertEquals(null, actualException, "Exception was not expected but one was thrown"); - return; - } - - if (actualException == null) { - throw new AssertionError("Expected exception of type " + expectedExceptionType + " but none was thrown"); - } - - ExceptionExpectation expectation = getExceptionExpectation(expectedExceptionType); - - LOG.info("Validating exception - Expected type: {}, Actual: {}", - expectation.type, actualException.getClass().getSimpleName()); - - if (expectation.type != null) { - boolean matchesType = actualException.getClass().getSimpleName().equals(expectation.type); - assertEquals(true, matchesType, - "Expected exception of type " + expectation.type + " but got " + actualException.getClass().getSimpleName()); - } - - if (expectation.message != null && actualException.getMessage() != null) { - boolean containsMessage = actualException.getMessage().contains(expectation.message); - assertEquals(true, containsMessage, - "Expected exception message to contain '" + expectation.message + "' but got: " + actualException.getMessage()); - } - - if (expectation.errorCode != null && actualException instanceof MsalClientException) { - MsalClientException msalException = (MsalClientException) actualException; - assertEquals(expectation.errorCode, msalException.errorCode(), - "Expected error code '" + expectation.errorCode + "' but got: " + msalException.errorCode()); - } - } - - /** - * Maps exception type identifiers to expected exception details. - */ - private static class ExceptionExpectation { - final String type; - final String message; - final String errorCode; - - ExceptionExpectation(String type, String message, String errorCode) { - this.type = type; - this.message = message; - this.errorCode = errorCode; - } - } - - /** - * Gets the expected exception details for a given exception type identifier. - */ - private static ExceptionExpectation getExceptionExpectation(String exceptionType) { - switch (exceptionType) { - case "invalid_issuer": - return new ExceptionExpectation( - "MsalClientException", - "Invalid issuer from OIDC discovery", - "issuer_validation"); - // Add more mappings as needed for different test cases - default: - LOG.info("Unknown exception type mapping: {}", exceptionType); - return new ExceptionExpectation(null, null, null); - } - } - - /** - * Creates managed identity ID from test object properties. - */ - static ManagedIdentityId createManagedIdentityId(Arrange.ObjectProperties appObjectProperties) { - Map managedIdentityMap = (Map) appObjectProperties.getProperty("managed_identity"); - String idType = (String) managedIdentityMap.get("ManagedIdentityIdType"); - String id = (String) managedIdentityMap.get("Id"); - - switch (idType) { - case "SystemAssigned": - return ManagedIdentityId.systemAssigned(); - case "ClientId": - return ManagedIdentityId.userAssignedClientId(id); - case "ObjectId": - return ManagedIdentityId.userAssignedObjectId(id); - case "ResourceId": - return ManagedIdentityId.userAssignedResourceId(id); - default: - throw new IllegalArgumentException("Unsupported ManagedIdentityIdType: " + idType); - } - } - - /** - * Extracts client capabilities from application object properties. - */ - static List extractClientCapabilities(Arrange.ObjectProperties testObjectProperties) { - List capabilities = new ArrayList<>(); - Object capabilitiesObj = testObjectProperties.getProperty("client_capabilities"); - - if (capabilitiesObj instanceof List) { - List capabilitiesList = (List) capabilitiesObj; - for (Object capability : capabilitiesList) { - capabilities.add((String) capability); - } - } - - return capabilities; - } - - /** - * Creates a provider for environment variables using the test configuration. - */ - static IEnvironmentVariables setEnvironmentVariables(TestCase testCase) { - final Map envVarsObj = testCase.getEnv().getVariables(); - final Map envVars = new HashMap<>(); - - // Convert Object values to String - for (Map.Entry entry : envVarsObj.entrySet()) { - envVars.put(entry.getKey(), entry.getValue().toString()); - } - - LOG.info("Configured environment variables: {}", envVars.keySet()); - - return new IEnvironmentVariables() { - @Override - public String getEnvironmentVariable(String envVariable) { - return envVars.get(envVariable); - } - }; - } - - /** - * Builds parameters for managed identity token acquisition - */ - static ManagedIdentityParameters buildManagedIdentityParameters(Act action) { - String resource = (String) action.getParameters().get("resource"); - LOG.info("Building ManagedIdentityParameters with resource: {}", resource); - - ManagedIdentityParameters.ManagedIdentityParametersBuilder builder = - ManagedIdentityParameters.builder(resource); - - // Add optional claims challenge - if (action.getParameters().containsKey("claims_challenge")) { - String claimsChallenge = (String) action.getParameters().get("claims_challenge"); - - // Convert simple string to JSON format if needed - if (!claimsChallenge.startsWith("{") && !claimsChallenge.endsWith("}")) { - claimsChallenge = "{\"" + claimsChallenge + "\":\"" + claimsChallenge + "\"}"; - LOG.info("Converting simple string claim to JSON format: {}", claimsChallenge); - } - - builder.claims(claimsChallenge); - } - - return builder.build(); - } - - /** - * Builds parameters for client credential token acquisition. - */ - static ClientCredentialParameters buildClientCredentialParameters(Act action) { - Set scopes = new HashSet<>(); - if (action.getParameters().containsKey("scopes")) { - Object scopesObj = action.getParameters().get("scopes"); - if (scopesObj instanceof List) { - List scopesList = (List) scopesObj; - for (Object scope : scopesList) { - scopes.add((String) scope); - } - } - } - - LOG.info("Building ClientCredentialParameters with scopes: {}", scopes); - return ClientCredentialParameters.builder(scopes).build(); - } - - /** - * Validates token source assertion, either cache or identity provider. - */ - static void validateTokenSource(String expectedSource, IAuthenticationResult result) { - TokenSource expected = "identity_provider".equals(expectedSource) ? - TokenSource.IDENTITY_PROVIDER : TokenSource.CACHE; - - LOG.info("Token source - Expected: {}, Actual: {}", expected, result.metadata().tokenSource()); - assertEquals(expected, result.metadata().tokenSource()); - } -} \ No newline at end of file diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/RunnerTest.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/RunnerTest.java deleted file mode 100644 index f1dd3ef9..00000000 --- a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/RunnerTest.java +++ /dev/null @@ -1,89 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -package com.microsoft.aad.msal4j; - -import com.microsoft.aad.msal4j.testcase.TestCase; -import com.microsoft.aad.msal4j.testcase.TestCaseHelper; -import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.MethodSource; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.io.IOException; -import java.util.Map; -import java.util.stream.Stream; - -/** - * Test runner for executing MSAL4J integration tests from external test case definitions. - * Uses parameterized tests to run different test cases through a common execution path. - */ -class RunnerTest { - private static final Logger LOG = LoggerFactory.getLogger(RunnerTest.class); - private static Map allTestConfigs; - - @BeforeAll - static void setup() throws IOException { - LOG.info("Loading all test configurations"); - allTestConfigs = TestCaseHelper.getAllTestCaseConfigs("https://smile-test.azurewebsites.net/testcases.json"); - LOG.info("Loaded {} test configurations", allTestConfigs.size()); - } - - /** - * Defines a set of test cases that cover managed identity scenarios. - */ - static Stream managedIdentityTestsProvider() { - return Stream.of( - "mi_capability", - "token_sha256_to_refresh", - "mi_vm_pod" - ); - } - - /** - * Defines a set of test cases that cover OIDC authority scenarios. - */ - static Stream confidentialClientOidcTestsProvider() { - return Stream.of( - "issuer_validation" - ); - } - - @ParameterizedTest - @MethodSource("managedIdentityTestsProvider") - void runManagedIdentityTest(String testCaseName) throws Exception { - LOG.info("========== Executing Managed Identity Test: {} ==========", testCaseName); - - TestCase testCase = getTestCase(testCaseName); - Map appResults = - RunnerHelper.createMangedIdentityAppsFromConfig(testCase); - - LOG.info("Created {} application(s) for test execution", appResults.size()); - RunnerHelper.executeTestCase(testCase, appResults); - } - - @ParameterizedTest - @MethodSource("confidentialClientOidcTestsProvider") - void runConfidentialClientOidcTests(String testCaseName) throws Exception { - LOG.info("========== Executing Confidential Client OIDC Test: {} ==========", testCaseName); - - TestCase testCase = getTestCase(testCaseName); - Map appResults = - RunnerHelper.createConfidentialClientAppsFromConfig(testCase); - - LOG.info("Created {} application(s) for test execution", appResults.size()); - RunnerHelper.executeTestCase(testCase, appResults); - } - - /** - * Gets a test case by name from the loaded configurations - */ - private static TestCase getTestCase(String testCaseName) { - TestCase testCase = allTestConfigs.get(testCaseName); - if (testCase == null) { - throw new IllegalArgumentException("Test case not found: " + testCaseName); - } - return testCase; - } -} \ No newline at end of file diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/testcase/Act.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/testcase/Act.java deleted file mode 100644 index 92c3e137..00000000 --- a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/testcase/Act.java +++ /dev/null @@ -1,124 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -package com.microsoft.aad.msal4j.testcase; - -import com.azure.json.JsonReader; -import com.azure.json.JsonSerializable; -import com.azure.json.JsonToken; -import com.azure.json.JsonWriter; - -import java.io.IOException; -import java.util.HashMap; -import java.util.List; -import java.util.Map; - -public class Act implements JsonSerializable { - private String targetObject; - private String methodName; - private final Map parameters = new HashMap<>(); - - public static Act fromJson(JsonReader jsonReader) throws IOException { - Act action = new Act(); - - jsonReader.readObject(reader -> { - while (reader.nextToken() != JsonToken.END_OBJECT) { - String key = reader.getFieldName(); - reader.nextToken(); - - // Split "objectName.methodName" - int dotIndex = key.indexOf('.'); - if (dotIndex == -1) { - throw new IOException("Invalid act key: " + key); - } - action.targetObject = key.substring(0, dotIndex); - action.methodName = key.substring(dotIndex + 1); - - // Read parameters object - reader.readObject(paramReader -> { - while (paramReader.nextToken() != JsonToken.END_OBJECT) { - String paramName = paramReader.getFieldName(); - paramReader.nextToken(); - switch (paramReader.currentToken()) { - case STRING: - action.parameters.put(paramName, paramReader.getString()); - break; - case NUMBER: - action.parameters.put(paramName, paramReader.getInt()); - break; - case START_ARRAY: - if ("scopes".equals(paramName)) { - action.parameters.put(paramName, paramReader.readArray(JsonReader::getString)); - } else { - paramReader.skipChildren(); - } - break; - default: - paramReader.skipChildren(); - break; - } - } - return null; - }); - } - return null; - }); - - return action; - } - - @Override - public JsonWriter toJson(JsonWriter jsonWriter) throws IOException { - jsonWriter.writeStartObject(); - writeContent(jsonWriter); - return jsonWriter.writeEndObject(); - } - - public void writeContent(JsonWriter jsonWriter) throws IOException { - jsonWriter.writeFieldName("act"); - jsonWriter.writeStartObject(); - jsonWriter.writeFieldName(targetObject + "." + methodName); - jsonWriter.writeStartObject(); - - for (Map.Entry entry : parameters.entrySet()) { - String key = entry.getKey(); - Object value = entry.getValue(); - - if (value instanceof String) { - jsonWriter.writeStringField(key, (String) value); - } else if (value instanceof Integer) { - jsonWriter.writeIntField(key, (Integer) value); - } else if (value instanceof List) { - jsonWriter.writeFieldName(key); - jsonWriter.writeStartArray(); - for (String val : (List) value) { - jsonWriter.writeString(val); - } - jsonWriter.writeEndArray(); - } - } - - jsonWriter.writeEndObject(); - jsonWriter.writeEndObject(); - } - - public String getTargetObject() { - return targetObject; - } - - public String getMethodName() { - return methodName; - } - - public Object getParameter(String name) { - return parameters.get(name); - } - - public boolean hasParameter(String name) { - return parameters.containsKey(name); - } - - public Map getParameters() { - return parameters; - } -} \ No newline at end of file diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/testcase/Arrange.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/testcase/Arrange.java deleted file mode 100644 index e85828a0..00000000 --- a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/testcase/Arrange.java +++ /dev/null @@ -1,226 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -package com.microsoft.aad.msal4j.testcase; - -import com.azure.json.JsonReader; -import com.azure.json.JsonSerializable; -import com.azure.json.JsonToken; -import com.azure.json.JsonWriter; - -import java.io.IOException; -import java.util.HashMap; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; - -public class Arrange implements JsonSerializable { - private final Map objects = new LinkedHashMap<>(); - - public static Arrange fromJson(JsonReader jsonReader) throws IOException { - Arrange arrange = new Arrange(); - - jsonReader.readObject(reader -> { - while (reader.nextToken() != JsonToken.END_OBJECT) { - String objectName = reader.getFieldName(); - System.out.println("Reading object: " + objectName); - reader.nextToken(); - arrange.objects.put(objectName, ObjectProperties.fromJson(reader)); - } - return null; - }); - - return arrange; - } - - @Override - public JsonWriter toJson(JsonWriter jsonWriter) throws IOException { - jsonWriter.writeStartObject(); - writeContent(jsonWriter); - return jsonWriter.writeEndObject(); - } - - public void writeContent(JsonWriter jsonWriter) throws IOException { - jsonWriter.writeFieldName("arrange"); - jsonWriter.writeStartObject(); - - for (Map.Entry entry : objects.entrySet()) { - jsonWriter.writeFieldName(entry.getKey()); - entry.getValue().toJson(jsonWriter); - } - - jsonWriter.writeEndObject(); - } - - public ObjectProperties getObject(String name) { - return objects.get(name); - } - - public Map getObjects() { - return objects; - } - - public void addObject(String name, ObjectProperties objectProperties) { - objects.put(name, objectProperties); - } - - public static class ObjectProperties implements JsonSerializable { - private String type; - private final Map properties = new HashMap<>(); - - public static ObjectProperties fromJson(JsonReader jsonReader) throws IOException { - ObjectProperties objectProperties = new ObjectProperties(); - - jsonReader.readObject(reader -> { - while (reader.nextToken() != JsonToken.END_OBJECT) { - String className = reader.getFieldName(); - objectProperties.type = className; - reader.nextToken(); - - reader.readObject(classReader -> { - while (classReader.nextToken() != JsonToken.END_OBJECT) { - String propertyGroup = classReader.getFieldName(); - classReader.nextToken(); - - switch (classReader.currentToken()) { - case START_ARRAY: - objectProperties.properties.put(propertyGroup, - classReader.readArray(JsonReader::getString)); - break; - case START_OBJECT: - objectProperties.properties.put(propertyGroup, - parseNestedObject(classReader)); - break; - case STRING: - objectProperties.properties.put(propertyGroup, classReader.getString()); - break; - case NUMBER: - objectProperties.properties.put(propertyGroup, classReader.getInt()); - break; - case NULL: - objectProperties.properties.put(propertyGroup, null); - break; - default: - classReader.skipChildren(); - break; - } - } - return null; - }); - } - return null; - }); - - return objectProperties; - } - - private static Map parseNestedObject(JsonReader reader) throws IOException { - Map nestedProps = new HashMap<>(); - - reader.readObject(propsReader -> { - while (propsReader.nextToken() != JsonToken.END_OBJECT) { - String propName = propsReader.getFieldName(); - propsReader.nextToken(); - - switch (propsReader.currentToken()) { - case STRING: - nestedProps.put(propName, propsReader.getString()); - break; - case NUMBER: - nestedProps.put(propName, propsReader.getInt()); - break; - default: - propsReader.skipChildren(); - break; - } - } - return null; - }); - - return nestedProps; - } - - @Override - public JsonWriter toJson(JsonWriter jsonWriter) throws IOException { - jsonWriter.writeStartObject(); - writeContent(jsonWriter); - return jsonWriter.writeEndObject(); - } - - public void writeContent(JsonWriter jsonWriter) throws IOException { - jsonWriter.writeFieldName(type); - jsonWriter.writeStartObject(); - - for (Map.Entry entry : properties.entrySet()) { - jsonWriter.writeFieldName(entry.getKey()); - java.lang.Object value = entry.getValue(); - - if (value instanceof List) { - writeArray(jsonWriter, (List) value); - } else if (value instanceof Map) { - writeNestedObject(jsonWriter, (Map) value); - } else if (value instanceof String) { - jsonWriter.writeString((String) value); - } else if (value instanceof Integer) { - jsonWriter.writeNumber((Integer) value); - } else if (value == null) { - jsonWriter.writeNull(); - } - } - - jsonWriter.writeEndObject(); - } - - private void writeArray(JsonWriter jsonWriter, List values) throws IOException { - jsonWriter.writeStartArray(); - for (String value : values) { - jsonWriter.writeString(value); - } - jsonWriter.writeEndArray(); - } - - private void writeNestedObject(JsonWriter jsonWriter, Map nestedProps) throws IOException { - jsonWriter.writeStartObject(); - - for (Map.Entry prop : nestedProps.entrySet()) { - String key = prop.getKey(); - java.lang.Object value = prop.getValue(); - - if (value instanceof String) { - jsonWriter.writeStringField(key, (String) value); - } else if (value instanceof Integer) { - jsonWriter.writeIntField(key, (Integer) value); - } - } - - jsonWriter.writeEndObject(); - } - - public String getType() { - return type; - } - - public void setType(String type) { - this.type = type; - } - - public java.lang.Object getProperty(String name) { - return properties.get(name); - } - - public Map getProperties() { - return properties; - } - - public void addProperty(String name, java.lang.Object value) { - if (value instanceof String || value instanceof List || - value instanceof Integer || value instanceof Boolean || - value instanceof Map) { - properties.put(name, value); - } else { - throw new IllegalArgumentException("Unsupported property type: " + - (value != null ? value.getClass().getName() : "null")); - } - } - } -} \ No newline at end of file diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/testcase/Assert.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/testcase/Assert.java deleted file mode 100644 index 6f090b91..00000000 --- a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/testcase/Assert.java +++ /dev/null @@ -1,86 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -package com.microsoft.aad.msal4j.testcase; - -import com.azure.json.JsonReader; -import com.azure.json.JsonSerializable; -import com.azure.json.JsonToken; -import com.azure.json.JsonWriter; - -import java.io.IOException; -import java.util.HashMap; -import java.util.Map; - -public class Assert implements JsonSerializable { - private final Map assertions = new HashMap<>(); - - public static Assert fromJson(JsonReader jsonReader) throws IOException { - Assert assertion = new Assert(); - - jsonReader.readObject(reader -> { - while (reader.nextToken() != JsonToken.END_OBJECT) { - String key = reader.getFieldName(); - reader.nextToken(); - - switch (reader.currentToken()) { - case STRING: - assertion.assertions.put(key, reader.getString()); - break; - case NUMBER: - assertion.assertions.put(key, reader.getInt()); - break; - default: - reader.skipChildren(); - break; - } - } - return null; - }); - - return assertion; - } - - @Override - public JsonWriter toJson(JsonWriter jsonWriter) throws IOException { - jsonWriter.writeStartObject(); - writeContent(jsonWriter); - return jsonWriter.writeEndObject(); - } - - public void writeContent(JsonWriter jsonWriter) throws IOException { - jsonWriter.writeFieldName("assert"); - jsonWriter.writeStartObject(); - - for (Map.Entry entry : assertions.entrySet()) { - String key = entry.getKey(); - Object value = entry.getValue(); - - if (value instanceof String) { - jsonWriter.writeStringField(key, (String) value); - } else if (value instanceof Integer) { - jsonWriter.writeIntField(key, (Integer) value); - } else if (value instanceof Boolean) { - jsonWriter.writeBooleanField(key, (Boolean) value); - } - } - - jsonWriter.writeEndObject(); - } - - public Map getAssertions() { - return assertions; - } - - public Object getAssertion(String key) { - return assertions.get(key); - } - - public void addAssertion(String key, Object value) { - if (value instanceof String || value instanceof Integer || value instanceof Boolean) { - assertions.put(key, value); - } else { - throw new IllegalArgumentException("Assertion value must be String or Integer"); - } - } -} \ No newline at end of file diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/testcase/Env.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/testcase/Env.java deleted file mode 100644 index 4b3e0698..00000000 --- a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/testcase/Env.java +++ /dev/null @@ -1,86 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -package com.microsoft.aad.msal4j.testcase; - -import com.azure.json.JsonReader; -import com.azure.json.JsonSerializable; -import com.azure.json.JsonToken; -import com.azure.json.JsonWriter; - -import java.io.IOException; -import java.util.HashMap; -import java.util.Map; - -public class Env implements JsonSerializable { - private final Map variables = new HashMap<>(); - - public static Env fromJson(JsonReader jsonReader) throws IOException { - Env env = new Env(); - - jsonReader.readObject(reader -> { - while (reader.nextToken() != JsonToken.END_OBJECT) { - String key = reader.getFieldName(); - reader.nextToken(); // Move to the value - - switch (reader.currentToken()) { - case STRING: - env.variables.put(key, reader.getString()); - break; - case NUMBER: - env.variables.put(key, reader.getInt()); - break; - default: - reader.skipChildren(); - break; - } - } - return null; - }); - - return env; - } - - @Override - public JsonWriter toJson(JsonWriter jsonWriter) throws IOException { - jsonWriter.writeStartObject(); - writeContent(jsonWriter); - return jsonWriter.writeEndObject(); - } - - public void writeContent(JsonWriter jsonWriter) throws IOException { - jsonWriter.writeFieldName("env"); - jsonWriter.writeStartObject(); - - for (Map.Entry entry : variables.entrySet()) { - String key = entry.getKey(); - Object value = entry.getValue(); - - if (value instanceof String) { - jsonWriter.writeStringField(key, (String) value); - } else if (value instanceof Integer) { - jsonWriter.writeIntField(key, (Integer) value); - } else if (value instanceof Boolean) { - jsonWriter.writeBooleanField(key, (Boolean) value); - } - } - - jsonWriter.writeEndObject(); - } - - public Map getVariables() { - return variables; - } - - public Object getVariable(String name) { - return variables.get(name); - } - - public void setVariable(String name, Object value) { - if (value instanceof String || value instanceof Integer || value instanceof Boolean) { - variables.put(name, value); - } else { - throw new IllegalArgumentException("Variable value must be String, Integer, or Boolean"); - } - } -} \ No newline at end of file diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/testcase/ParsingTest.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/testcase/ParsingTest.java deleted file mode 100644 index 6cedafbd..00000000 --- a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/testcase/ParsingTest.java +++ /dev/null @@ -1,141 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -package com.microsoft.aad.msal4j.testcase; - -import com.azure.json.JsonProviders; -import com.azure.json.JsonReader; -import com.azure.json.JsonWriter; -import org.junit.jupiter.api.Test; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.io.ByteArrayInputStream; -import java.io.ByteArrayOutputStream; -import java.nio.charset.StandardCharsets; -import java.util.Map; - -import static org.junit.jupiter.api.Assertions.*; - -class ParsingTest { - private static final Logger LOG = LoggerFactory.getLogger(ParsingTest.class); - private static final String MI_TEST_CASE_EXAMPLE = "{\"arrange\":{\"app1\":{\"ManagedIdentityClient\":{\"client_capabilities\":[\"cp1\",\"cp2\"],\"managed_identity\":{\"Id\":null,\"ManagedIdentityIdType\":\"SystemAssigned\"}}}},\"env\":{\"IDENTITY_ENDPOINT\":\"https://smile-test.azurewebsites.net/test/token_sha256_to_refresh/cp2,cp1\",\"IDENTITY_HEADER\":\"foo\",\"IDENTITY_SERVER_THUMBPRINT\":\"bar\"},\"steps\":[{\"act\":{\"app1.AcquireTokenForManagedIdentity\":{\"resource\":\"R\"}},\"assert\":{\"token_source\":\"identity_provider\",\"token_type\":\"Bearer\"}},{\"act\":{\"app1.AcquireTokenForManagedIdentity\":{\"resource\":\"R\"}},\"assert\":{\"token_source\":\"cache\",\"token_type\":\"Bearer\"}},{\"act\":{\"app1.AcquireTokenForManagedIdentity\":{\"claims_challenge\":\"{\\\"capability test case likely needs\\\": \\\"a valid json object\\\"}\",\"resource\":\"R\"}},\"assert\":{\"token_source\":\"identity_provider\",\"token_type\":\"Bearer\"}}],\"type\":\"MSAL Test\",\"ver\":1}"; - private static final String CONFIDENTIAL_TEST_CASE_EXAMPLE = "{\"arrange\":{\"cca1\":{\"ConfidentialClientApplication\":{\"client_credential\":\"fake-credential\",\"client_id\":\"foo\",\"oidc_authority\":\"https://smile-test.azurewebsites.net/wrong_issuer\"}},\"cca2\":{\"ConfidentialClientApplication\":{\"client_credential\":\"fake-credential\",\"client_id\":\"foo\",\"oidc_authority\":\"https://smile-test.azurewebsites.net/right_issuer\"}}},\"env\":{},\"steps\":[{\"act\":{\"cca1.AcquireTokenForClient\":{\"scopes\":[\"scope1\"]}},\"assert\":{\"error\":\"invalid_issuer\"}},{\"act\":{\"cca2.AcquireTokenForClient\":{\"scopes\":[\"scope2\"]}},\"assert\":{\"token_source\":\"identity_provider\",\"token_type\":\"Bearer\"}}],\"type\":\"MSAL Test\",\"ver\":1}"; - - @Test - void testFullJsonSerialization() throws Exception { - // Parse the test case - TestCase testCase = parseTestCase(MI_TEST_CASE_EXAMPLE); - - // Verify basic structure - assertNotNull(testCase.getType(), "Test case type should not be null"); - LOG.info("Deserialized TestCase: type={}, version={}", testCase.getType(), testCase.getVersion()); - - // Verify sections - verifyArrangeSection(testCase); - verifyEnvSection(testCase); - verifyStepsSection(testCase); - - // Test round-trip serialization - String json = serializeTestCase(testCase); - TestCase reparsedTestCase = parseTestCase(json); - - // Verify key properties match after round-trip - assertEquals(testCase.getType(), reparsedTestCase.getType()); - assertEquals(testCase.getVersion(), reparsedTestCase.getVersion()); - - // Verify sections match after round-trip - verifySectionsMatchAfterRoundTrip(testCase, reparsedTestCase); - } - - @Test - void testRealServerJsonConsistency() throws Exception { - // First round: Parse and serialize - TestCase testCase = parseTestCase(MI_TEST_CASE_EXAMPLE); - String firstSerialization = serializeTestCase(testCase); - - // Second round: Parse and serialize again - TestCase secondTestCase = parseTestCase(firstSerialization); - String secondSerialization = serializeTestCase(secondTestCase); - - // Third round: One more cycle - TestCase thirdTestCase = parseTestCase(secondSerialization); - String thirdSerialization = serializeTestCase(thirdTestCase); - - // Verify consistency between serializations - assertEquals(firstSerialization, secondSerialization, "First and second serializations should match"); - assertEquals(secondSerialization, thirdSerialization, "Second and third serializations should match"); - LOG.info("All three serialization rounds produced identical JSON"); - - // Verify object equality in depth - verifySectionsMatchAfterRoundTrip(testCase, secondTestCase); - } - - private TestCase parseTestCase(String json) throws Exception { - JsonReader reader = JsonProviders.createReader( - new ByteArrayInputStream(json.getBytes(StandardCharsets.UTF_8))); - return TestCase.fromJson(reader); - } - - private String serializeTestCase(TestCase testCase) throws Exception { - ByteArrayOutputStream out = new ByteArrayOutputStream(); - JsonWriter writer = JsonProviders.createWriter(out); - testCase.toJson(writer); - writer.flush(); - return out.toString(); - } - - private void verifyArrangeSection(TestCase testCase) { - if (testCase.getArrange() != null) { - LOG.info("Arrange section contains {} object(s)", testCase.getArrange().getObjects().size()); - assertFalse(testCase.getArrange().getObjects().isEmpty(), "Arrange objects should not be empty"); - - for (Map.Entry entry : testCase.getArrange().getObjects().entrySet()) { - LOG.info(" Object: {}, Type: {}", entry.getKey(), entry.getValue().getType()); - } - } - } - - private void verifyEnvSection(TestCase testCase) { - if (testCase.getEnv() != null) { - LOG.info("Environment section contains {} variable(s)", testCase.getEnv().getVariables().size()); - assertFalse(testCase.getEnv().getVariables().isEmpty(), "Environment variables should not be empty"); - } - } - - private void verifyStepsSection(TestCase testCase) { - if (testCase.getSteps() != null) { - LOG.info("Steps section contains {} step(s)", testCase.getSteps().getSteps().size()); - assertFalse(testCase.getSteps().getSteps().isEmpty(), "Steps should not be empty"); - } - } - - private void verifySectionsMatchAfterRoundTrip(TestCase original, TestCase reparsed) { - // Check arrange section - if (original.getArrange() != null && reparsed.getArrange() != null) { - assertEquals(original.getArrange().getObjects().size(), reparsed.getArrange().getObjects().size(), - "Arrange objects count should match"); - } else { - assertTrue((original.getArrange() == null && reparsed.getArrange() == null), - "Both arrange sections should be null or non-null"); - } - - // Check env section - if (original.getEnv() != null && reparsed.getEnv() != null) { - assertEquals(original.getEnv().getVariables().size(), reparsed.getEnv().getVariables().size(), - "Environment variables count should match"); - } else { - assertTrue((original.getEnv() == null && reparsed.getEnv() == null), - "Both env sections should be null or non-null"); - } - - // Check steps section - if (original.getSteps() != null && reparsed.getSteps() != null) { - assertEquals(original.getSteps().getSteps().size(), reparsed.getSteps().getSteps().size(), - "Steps count should match"); - } else { - assertTrue((original.getSteps() == null && reparsed.getSteps() == null), - "Both steps sections should be null or non-null"); - } - } -} \ No newline at end of file diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/testcase/Steps.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/testcase/Steps.java deleted file mode 100644 index 23b2abe7..00000000 --- a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/testcase/Steps.java +++ /dev/null @@ -1,135 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -package com.microsoft.aad.msal4j.testcase; - -import com.azure.json.JsonReader; -import com.azure.json.JsonSerializable; -import com.azure.json.JsonToken; -import com.azure.json.JsonWriter; - -import java.io.IOException; -import java.util.ArrayList; -import java.util.List; - -public class Steps implements JsonSerializable { - private final List steps = new ArrayList<>(); - - public static Steps fromJson(JsonReader jsonReader) throws IOException { - Steps steps = new Steps(); - - if (jsonReader.currentToken() == JsonToken.START_OBJECT) { - jsonReader.readObject(reader -> { - while (reader.nextToken() != JsonToken.END_OBJECT) { - String field = reader.getFieldName(); - if ("steps".equals(field)) { - reader.nextToken(); // Move to START_ARRAY - parseStepsArray(reader, steps); - } else { - reader.nextToken(); - reader.skipChildren(); - } - } - return null; - }); - } else if (jsonReader.currentToken() == JsonToken.START_ARRAY) { - parseStepsArray(jsonReader, steps); - } - - return steps; - } - - private static void parseStepsArray(JsonReader reader, Steps steps) throws IOException { - while (reader.nextToken() != JsonToken.END_ARRAY) { - if (reader.currentToken() == JsonToken.START_OBJECT) { - steps.addStep(Step.fromJson(reader)); - } - } - } - - @Override - public JsonWriter toJson(JsonWriter jsonWriter) throws IOException { - jsonWriter.writeStartObject(); - writeContent(jsonWriter); - jsonWriter.writeEndObject(); - return jsonWriter; - } - - public void writeContent(JsonWriter jsonWriter) throws IOException { - jsonWriter.writeFieldName("steps"); - jsonWriter.writeStartArray(); - for (Step step : steps) { - step.toJson(jsonWriter); - } - jsonWriter.writeEndArray(); - } - - public List getSteps() { - return steps; - } - - public void addStep(Step step) { - steps.add(step); - } - - public static class Step implements JsonSerializable { - private Act action; - private Assert assertion; - - public static Step fromJson(JsonReader jsonReader) throws IOException { - Step step = new Step(); - - jsonReader.readObject(reader -> { - while (reader.nextToken() != JsonToken.END_OBJECT) { - String field = reader.getFieldName(); - reader.nextToken(); - - switch (field) { - case "act": - step.action = Act.fromJson(reader); - break; - case "assert": - step.assertion = Assert.fromJson(reader); - break; - default: - reader.skipChildren(); - } - } - return null; - }); - - return step; - } - - @Override - public JsonWriter toJson(JsonWriter jsonWriter) throws IOException { - jsonWriter.writeStartObject(); - - if (action != null) { - action.writeContent(jsonWriter); - } - - if (assertion != null) { - assertion.writeContent(jsonWriter); - } - - return jsonWriter.writeEndObject(); - } - - public Act getAction() { - return action; - } - - public void setAction(Act action) { - this.action = action; - } - - public Assert getAssertion() { - return assertion; - } - - public void setAssertion(Assert assertion) { - this.assertion = assertion; - } - } -} \ No newline at end of file diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/testcase/TestCase.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/testcase/TestCase.java deleted file mode 100644 index 311481bc..00000000 --- a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/testcase/TestCase.java +++ /dev/null @@ -1,119 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -package com.microsoft.aad.msal4j.testcase; - -import com.azure.json.JsonReader; -import com.azure.json.JsonSerializable; -import com.azure.json.JsonToken; -import com.azure.json.JsonWriter; - -import java.io.IOException; - -public class TestCase implements JsonSerializable { - private String type; - private int version; - private Arrange arrange; - private Env env; - private Steps steps; - - public static TestCase fromJson(JsonReader jsonReader) throws IOException { - TestCase testCase = new TestCase(); - - jsonReader.readObject(reader -> { - while (reader.nextToken() != JsonToken.END_OBJECT) { - String field = reader.getFieldName(); - reader.nextToken(); // Move to value - - switch (field) { - case "type": - testCase.type = reader.getString(); - break; - case "ver": - testCase.version = reader.getInt(); - break; - case "arrange": - testCase.arrange = Arrange.fromJson(reader); - break; - case "env": - testCase.env = Env.fromJson(reader); - break; - case "steps": - testCase.steps = Steps.fromJson(reader); - break; - default: - reader.skipChildren(); - break; - } - } - return null; - }); - - return testCase; - } - - @Override - public JsonWriter toJson(JsonWriter jsonWriter) throws IOException { - jsonWriter.writeStartObject(); - writeContent(jsonWriter); - return jsonWriter.writeEndObject(); - } - - public void writeContent(JsonWriter jsonWriter) throws IOException { - jsonWriter.writeStringField("type", type); - jsonWriter.writeIntField("ver", version); - - if (arrange != null) { - arrange.writeContent(jsonWriter); - } - - if (env != null) { - env.writeContent(jsonWriter); - } - - if (steps != null) { - steps.writeContent(jsonWriter); - } - } - - // Getters and setters - public String getType() { - return type; - } - - public void setType(String type) { - this.type = type; - } - - public int getVersion() { - return version; - } - - public void setVersion(int version) { - this.version = version; - } - - public Arrange getArrange() { - return arrange; - } - - public void setArrange(Arrange arrange) { - this.arrange = arrange; - } - - public Env getEnv() { - return env; - } - - public void setEnv(Env env) { - this.env = env; - } - - public Steps getSteps() { - return steps; - } - - public void setSteps(Steps steps) { - this.steps = steps; - } -} \ No newline at end of file diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/testcase/TestCaseHelper.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/testcase/TestCaseHelper.java deleted file mode 100644 index 6cfa9932..00000000 --- a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/testcase/TestCaseHelper.java +++ /dev/null @@ -1,188 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -package com.microsoft.aad.msal4j.testcase; - -import com.azure.json.*; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.io.ByteArrayInputStream; -import java.io.IOException; -import java.io.InputStreamReader; -import java.net.HttpURLConnection; -import java.net.URL; -import java.nio.charset.StandardCharsets; -import java.util.*; - -public class TestCaseHelper implements JsonSerializable { - private static final Logger LOG = LoggerFactory.getLogger(TestCaseHelper.class); - private final List testcases; - - /** - * Creates a new TestCaseHelper with the given list of test case URLs. - * @param testcases List of test case URLs - */ - public TestCaseHelper(List testcases) { - this.testcases = testcases; - } - - /** - * Returns the list of test case URLs. - * @return List of test case URLs - */ - public List getTestcases() { - return testcases; - } - - /** - * Creates a TestCaseHelper from a JSON reader. - * @param jsonReader The JSON reader - * @return A new TestCaseHelper instance - * @throws IOException If there's an error reading the JSON - */ - public static TestCaseHelper fromJson(JsonReader jsonReader) throws IOException { - List urls = new ArrayList<>(); - - return jsonReader.readObject(reader -> { - while (reader.nextToken() != JsonToken.END_OBJECT) { - String fieldName = reader.getFieldName(); - - if ("testcases".equals(fieldName)) { - reader.nextToken(); // Move to START_ARRAY - - // Read array elements until END_ARRAY - while (reader.nextToken() != JsonToken.END_ARRAY) { - urls.add(reader.getString()); - } - } else { - reader.nextToken(); // Move to the value - reader.skipChildren(); - } - } - - return new TestCaseHelper(urls); - }); - } - - /** - * Fetches test case URLs from an endpoint containing JSON with a "testcases" array. - * - * @param endpointUrl The URL to fetch test cases from - * @return List of test case URLs - * @throws IOException If there's an error fetching or parsing the JSON - */ - public static List getTestCaseUrlsFromEndpoint(String endpointUrl) throws IOException { - String jsonContent = fetchRawContent(endpointUrl); - JsonReader reader = JsonProviders.createReader( - new ByteArrayInputStream(jsonContent.getBytes(StandardCharsets.UTF_8))); - - TestCaseHelper helper = fromJson(reader); - return helper.getTestcases(); - } - - /** - * Fetches raw content from a URL as a string - * - * @param url The URL to fetch content from - * @return The content as a string - * @throws IOException If there's an error fetching the content - */ - private static String fetchRawContent(String url) throws IOException { - URL requestUrl = new URL(url); - HttpURLConnection connection = (HttpURLConnection) requestUrl.openConnection(); - connection.setRequestMethod("GET"); - connection.setRequestProperty("Content-Type", "application/json"); - - int responseCode = connection.getResponseCode(); - if (responseCode == HttpURLConnection.HTTP_OK) { - try (InputStreamReader reader = new InputStreamReader(connection.getInputStream(), StandardCharsets.UTF_8)) { - StringBuilder response = new StringBuilder(); - char[] buffer = new char[1024]; - int charsRead; - while ((charsRead = reader.read(buffer)) != -1) { - response.append(buffer, 0, charsRead); - } - return response.toString(); - } - } else { - throw new IOException("HTTP request failed with status code: " + responseCode); - } - } - - /** - * Fetches JSON content from a URL - * - * @param jsonUrl The URL to fetch JSON content from - * @return The JSON content as a string - * @throws IOException If there's an error fetching the content - */ - public static String fetchJsonContent(String jsonUrl) throws IOException { - return fetchRawContent(jsonUrl); - } - - /** - * Complete workflow to get all test case configs - * - * @param indexEndpoint The URL of the index containing test case URLs - * @return Map of test case names to their TestCase instances - * @throws IOException If there's an error fetching or parsing the JSON - */ - public static Map getAllTestCaseConfigs(String indexEndpoint) throws IOException { - // Get list of SML test case URLs - List smlUrls = getTestCaseUrlsFromEndpoint(indexEndpoint); - - // Convert SML URLs to JSON URLs - List jsonUrls = convertSmlUrlsToJsonUrls(smlUrls); - - // Fetch content for each JSON URL - Map testCaseConfigs = new HashMap<>(); - for (String jsonUrl : jsonUrls) { - String testCaseName = extractTestCaseName(jsonUrl); - String jsonContent = fetchJsonContent(jsonUrl); - - JsonReader reader = JsonProviders.createReader( - new ByteArrayInputStream(jsonContent.getBytes(StandardCharsets.UTF_8))); - TestCase testCase = TestCase.fromJson(reader); - testCaseConfigs.put(testCaseName, testCase); - } - - return testCaseConfigs; - } - - /** - * Convert SML URLs to JSON URLs - */ - private static List convertSmlUrlsToJsonUrls(List smlUrls) { - List jsonUrls = new ArrayList<>(); - for (String smlUrl : smlUrls) { - jsonUrls.add(smlUrl.replace(".sml", ".json")); - } - return jsonUrls; - } - - /** - * Extract test case name from URL - */ - private static String extractTestCaseName(String url) { - String[] parts = url.split("/"); - String fileName = parts[parts.length - 1]; - return fileName.substring(0, fileName.lastIndexOf('.')); - } - - @Override - public JsonWriter toJson(JsonWriter jsonWriter) throws IOException { - jsonWriter.writeStartObject(); - jsonWriter.writeStartArray("testcases"); - - for (String testcase : testcases) { - jsonWriter.writeString(testcase); - } - - jsonWriter.writeEndArray(); - jsonWriter.writeEndObject(); - - return jsonWriter; - } -} \ No newline at end of file From 9bb0ff6dc151eaf3ff57927b30cb4b7d3255d0cf Mon Sep 17 00:00:00 2001 From: avdunn Date: Tue, 8 Jul 2025 13:34:34 -0700 Subject: [PATCH 7/7] Add missing if --- .../aad/msal4j/AbstractClientApplicationBase.java | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/AbstractClientApplicationBase.java b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/AbstractClientApplicationBase.java index e99f77c8..4197256e 100644 --- a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/AbstractClientApplicationBase.java +++ b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/AbstractClientApplicationBase.java @@ -575,8 +575,10 @@ public T correlationId(String val) { aadAadInstanceDiscoveryResponse); } - ((OidcAuthority) authenticationAuthority).setAuthorityProperties( - OidcDiscoveryProvider.performOidcDiscovery( - (OidcAuthority) authenticationAuthority, this)); + if (authenticationAuthority.authorityType == AuthorityType.OIDC) { + ((OidcAuthority) authenticationAuthority).setAuthorityProperties( + OidcDiscoveryProvider.performOidcDiscovery( + (OidcAuthority) authenticationAuthority, this)); + } } } \ No newline at end of file