diff --git a/src/client/Microsoft.Identity.Client/Internal/ClaimsHelper.cs b/src/client/Microsoft.Identity.Client/Internal/ClaimsHelper.cs index 0245795b2e..b3770ac8c0 100644 --- a/src/client/Microsoft.Identity.Client/Internal/ClaimsHelper.cs +++ b/src/client/Microsoft.Identity.Client/Internal/ClaimsHelper.cs @@ -5,9 +5,12 @@ using System.Linq; using Microsoft.Identity.Client.Utils; #if SUPPORTS_SYSTEM_TEXT_JSON +using System.Text; using System.Text.Json; using System.Text.Json.Nodes; using JObject = System.Text.Json.Nodes.JsonObject; +using System.Buffers; +using System.Diagnostics; #else using Microsoft.Identity.Json; using Microsoft.Identity.Json.Linq; @@ -52,10 +55,8 @@ internal static JObject MergeClaimsIntoCapabilityJson(string claims, JObject cap ex); } #if SUPPORTS_SYSTEM_TEXT_JSON - foreach (var claim in claimsJson) - { - capabilitiesJson[claim.Key] = claim.Value != null ? JsonNode.Parse(claim.Value.ToJsonString()) : null; - } + + capabilitiesJson = JsonHelper.Merge(capabilitiesJson, claimsJson); #else capabilitiesJson.Merge(claimsJson, new JsonMergeSettings { diff --git a/src/client/Microsoft.Identity.Client/Utils/JsonHelper.cs b/src/client/Microsoft.Identity.Client/Utils/JsonHelper.cs index e0b44298ad..1812287c40 100644 --- a/src/client/Microsoft.Identity.Client/Utils/JsonHelper.cs +++ b/src/client/Microsoft.Identity.Client/Utils/JsonHelper.cs @@ -13,6 +13,7 @@ using System.Text.Json.Nodes; using JObject = System.Text.Json.Nodes.JsonObject; using JToken = System.Text.Json.Nodes.JsonNode; +using System.Buffers; #else using Microsoft.Identity.Json; using Microsoft.Identity.Json.Linq; @@ -155,6 +156,136 @@ internal static long ExtractParsedIntOrZero(JObject json, string key) internal static bool TryGetValue(JsonObject json, string propertyName, out JsonNode value) => json.TryGetPropertyValue(propertyName, out value); internal static T GetValue(JsonNode json) => json != null ? json.GetValue() : default; + + /// + /// Merges two JSON objects into a single JSON object. + /// + /// The original JSON object to merge. + /// The additional JSON object to merge. + /// A JObject representing the merged JSON. + /// + /// This method parses the original and new JSON objects, merges their elements, and returns + /// a JObject representing the merged JSON. + /// Original Code Reference: https://github.com/dotnet/runtime/issues/31433 + /// + internal static JObject Merge(JObject originalJson, JObject newContent) + { + // Output buffer to store the merged JSON + var outputBuffer = new ArrayBufferWriter(); + + // Parse the original and new JSON content + using (JsonDocument jDoc1 = JsonDocument.Parse(originalJson.ToJsonString())) + using (JsonDocument jDoc2 = JsonDocument.Parse(newContent.ToJsonString())) + using (var jsonWriter = new Utf8JsonWriter(outputBuffer, new JsonWriterOptions { Indented = true })) + { + // Merge the JSON elements + MergeJsonElements(jsonWriter, jDoc1.RootElement, jDoc2.RootElement); + } + + // Convert the merged JSON to a UTF-8 encoded string + string mergedJsonString = Encoding.UTF8.GetString(outputBuffer.WrittenSpan); + + // Parse the merged JSON string to a JObject + return ParseIntoJsonObject(mergedJsonString); + } + + // Merges two JSON elements based on their value kind + private static void MergeJsonElements(Utf8JsonWriter jsonWriter, JsonElement root1, JsonElement root2) + { + switch (root1.ValueKind) + { + case JsonValueKind.Object: + MergeObjects(jsonWriter, root1, root2); + break; + case JsonValueKind.Array: + MergeArrays(jsonWriter, root1, root2); + break; + default: + // If not an object or array, directly write the value to the output + root1.WriteTo(jsonWriter); + break; + } + } + + // Merges two JSON objects + private static void MergeObjects(Utf8JsonWriter jsonWriter, JsonElement root1, JsonElement root2) + { + // Start writing the merged object + jsonWriter.WriteStartObject(); + + // Create a HashSet to track processed property names + HashSet processedProperties = new HashSet(); + + // Iterate through properties of the first JSON object + foreach (JsonProperty property in root1.EnumerateObject()) + { + string propertyName = property.Name; + + JsonValueKind newValueKind; + + // Check if the second JSON object has a property with the same name + if (root2.TryGetProperty(propertyName, out JsonElement newValue) && (newValueKind = newValue.ValueKind) != JsonValueKind.Null) + { + // Write the property name + jsonWriter.WritePropertyName(propertyName); + processedProperties.Add(propertyName); + + JsonElement originalValue = property.Value; + JsonValueKind originalValueKind = originalValue.ValueKind; + + // Recursively merge objects or arrays, otherwise, write the new value + if ((newValueKind == JsonValueKind.Object && originalValueKind == JsonValueKind.Object) || + (newValueKind == JsonValueKind.Array && originalValueKind == JsonValueKind.Array)) + { + MergeJsonElements(jsonWriter, originalValue, newValue); + } + else + { + newValue.WriteTo(jsonWriter); + } + } + else + { + // If the second object does not have the property, write the original property + property.WriteTo(jsonWriter); + } + } + + // Iterate through properties unique to the second JSON object + foreach (JsonProperty property in root2.EnumerateObject()) + { + if (!processedProperties.Contains(property.Name)) + { + // Write properties unique to the second object + property.WriteTo(jsonWriter); + } + } + + // End writing the merged object + jsonWriter.WriteEndObject(); + } + + // Merges two JSON arrays + private static void MergeArrays(Utf8JsonWriter jsonWriter, JsonElement root1, JsonElement root2) + { + // Start writing the merged array + jsonWriter.WriteStartArray(); + + // Merge elements of the first array + for (int i = 0; i < root1.GetArrayLength(); i++) + { + root1[i].WriteTo(jsonWriter); + } + + // Merge elements of the second array + for (int i = 0; i < root2.GetArrayLength(); i++) + { + root2[i].WriteTo(jsonWriter); + } + + // End writing the merged array + jsonWriter.WriteEndArray(); + } #else internal static string JsonObjectToString(JObject jsonObject) => jsonObject.ToString(Formatting.None); @@ -167,4 +298,5 @@ internal static long ExtractParsedIntOrZero(JObject json, string key) internal static T GetValue(JToken json) => json.Value(); #endif } + } diff --git a/tests/Microsoft.Identity.Test.Common/TestConstants.cs b/tests/Microsoft.Identity.Test.Common/TestConstants.cs index 59de7a2cd2..4750680f80 100644 --- a/tests/Microsoft.Identity.Test.Common/TestConstants.cs +++ b/tests/Microsoft.Identity.Test.Common/TestConstants.cs @@ -152,6 +152,15 @@ public static HashSet s_scope public const string ClientCapabilitiesJson = @"{""access_token"":{""xms_cc"":{""values"":[""cp1"",""cp2""]}}}"; // this a JSON merge from Claims and ClientCapabilitiesJson public const string ClientCapabilitiesAndClaimsJson = @"{""access_token"":{""xms_cc"":{""values"":[""cp1"",""cp2""]}},""userinfo"":{""given_name"":{""essential"":true},""nickname"":null,""email"":{""essential"":true},""email_verified"":{""essential"":true},""picture"":null,""http://example.info/claims/groups"":null},""id_token"":{""auth_time"":{""essential"":true},""acr"":{""values"":[""urn:mace:incommon:iap:silver""]}}}"; + public const string ClaimsWithAccessToken = @"{""access_token"":{""nbf"":{""essential"":true, ""value"":""1701477303""}}}"; + public const string ClientCapabilitiesAndClaimsJsonWithAccessToken = @"{""access_token"":{""xms_cc"":{""values"":[""cp1"",""cp2""]},""nbf"":{""essential"":true,""value"":""1701477303""}}}"; + public const string EmptyClaimsJson = @"{}"; + public const string ClaimsWithAdditionalClaim = @"{""access_token"":{""nbf"":{""essential"":true, ""value"":""1701477303""},""additional_claim"":{""key"":""value""}}}"; + public const string MergedJsonWithAdditionalClaim = @"{""access_token"":{""xms_cc"":{""values"":[""cp1"",""cp2""]},""nbf"":{""essential"":true,""value"":""1701477303""},""additional_claim"":{""key"":""value""}}}"; + public const string ClaimWithAdditionalKey = @"{""access_token"":{""nbf"":{""essential"":true,""value"":""1701477303""},""additional_claim"":{""key"":""value""},""new_claim"":{""new_key"":""new_value""}},""some_other_key"":{""nbf"":{""essential"":true,""value"":""1701477303""},""additional_claim"":{""key"":""value""},""new_claim"":{""new_key"":""new_value""}}}"; + public const string MergedJsonWithAdditionalKey = @"{""access_token"":{""xms_cc"":{""values"":[""cp1"",""cp2""]},""nbf"":{""essential"":true,""value"":""1701477303""},""additional_claim"":{""key"":""value""},""new_claim"":{""new_key"":""new_value""}},""some_other_key"":{""nbf"":{""essential"":true,""value"":""1701477303""},""additional_claim"":{""key"":""value""},""new_claim"":{""new_key"":""new_value""}}}"; + public const string ClaimWithAdditionalKeyAndAccessKey = @"{""some_other_key"":{""nbf"":{""essential"":true,""value"":""1701477303""},""additional_claim"":{""key"":""value""},""new_claim"":{""new_key"":""new_value""}},""access_token"":{""nbf"":{""essential"":true,""value"":""1701477303""},""additional_claim"":{""key"":""value""},""new_claim"":{""new_key"":""new_value""}}}"; + public const string MergedJsonClaimWithAdditionalKeyAndAccessKey = @"{""access_token"":{""xms_cc"":{""values"":[""cp1"",""cp2""]},""nbf"":{""essential"":true,""value"":""1701477303""},""additional_claim"":{""key"":""value""},""new_claim"":{""new_key"":""new_value""}},""some_other_key"":{""nbf"":{""essential"":true,""value"":""1701477303""},""additional_claim"":{""key"":""value""},""new_claim"":{""new_key"":""new_value""}}}"; public const string DisplayableId = "displayable@id.com"; public const string RedirectUri = "urn:ietf:wg:oauth:2.0:oob"; diff --git a/tests/Microsoft.Identity.Test.Common/TestData.cs b/tests/Microsoft.Identity.Test.Common/TestData.cs index 47980e6d95..63127b8574 100644 --- a/tests/Microsoft.Identity.Test.Common/TestData.cs +++ b/tests/Microsoft.Identity.Test.Common/TestData.cs @@ -38,5 +38,48 @@ public static IEnumerable GetAuthorityWithExpectedTenantId() yield return new AuthorityWithExpectedTenantId { Authority = new Uri(TestConstants.AadAuthorityWithTestTenantId), ExpectedTenantId = TestConstants.AadTenantId }.ToObjectArray(); yield return new AuthorityWithExpectedTenantId { Authority = new Uri(TestConstants.AuthorityWindowsNet), ExpectedTenantId = TestConstants.Utid }.ToObjectArray(); } + + /// + /// Provides test data for scenarios involving the merging of claims and client capabilities. + /// + /// + /// Test cases include various combinations of claims, client capabilities, and the expected merged JSON result. + /// + /// Enumerable of Object Arrays with Index 0 = Claims, Index 1 = Client Capabilities, Index 2 = Expected Merged JSON + public static IEnumerable GetClaimsAndCapabilities() + { + // Test case with non-empty claims, non-empty capabilities, and the expected merged JSON + yield return new object[] { TestConstants.Claims, TestConstants.ClientCapabilities, TestConstants.ClientCapabilitiesAndClaimsJson }; + + // Test case with claims containing an access token, non-empty capabilities, and the expected merged JSON + yield return new object[] { TestConstants.ClaimsWithAccessToken, TestConstants.ClientCapabilities, TestConstants.ClientCapabilitiesAndClaimsJsonWithAccessToken }; + + // Test case with empty claims, non-empty capabilities, and the expected merged JSON being the capabilities alone + yield return new object[] { TestConstants.EmptyClaimsJson, TestConstants.ClientCapabilities, TestConstants.ClientCapabilitiesJson }; + + // Test case with claims containing an additional claim, non-empty capabilities, and the expected merged JSON + yield return new object[] { TestConstants.ClaimsWithAdditionalClaim, TestConstants.ClientCapabilities, TestConstants.MergedJsonWithAdditionalClaim }; + + // Test case with claims containing an additional key, non-empty capabilities, and the expected merged JSON + yield return new object[] { TestConstants.ClaimWithAdditionalKey, TestConstants.ClientCapabilities, TestConstants.MergedJsonWithAdditionalKey }; + + // Test case with claims containing an additional key, empty capabilities, and the expected merged JSON being the claims alone + yield return new object[] { TestConstants.ClaimWithAdditionalKey, new string[0], TestConstants.ClaimWithAdditionalKey }; + + // Test case with non-empty claims, empty capabilities, and the expected merged JSON being the claims alone + yield return new object[] { TestConstants.Claims, new string[0], TestConstants.Claims }; + + // Test case with null claims, non-empty capabilities, and the expected merged JSON being the capabilities alone + yield return new object[] { null, TestConstants.ClientCapabilities, TestConstants.ClientCapabilitiesJson }; + + // Test case with non-empty claims, null capabilities, and the expected merged JSON being the claims alone + yield return new object[] { TestConstants.Claims, null, TestConstants.Claims }; + + // Test case with claims containing an access token, null capabilities, and the expected merged JSON + yield return new object[] { TestConstants.ClaimsWithAccessToken, null, TestConstants.ClaimsWithAccessToken }; + + // Test case with claims containing an additional key and access key (different order), non-empty capabilities, and the expected merged JSON + yield return new object[] { TestConstants.ClaimWithAdditionalKeyAndAccessKey, TestConstants.ClientCapabilities, TestConstants.MergedJsonClaimWithAdditionalKeyAndAccessKey }; + } } } diff --git a/tests/Microsoft.Identity.Test.Unit/CoreTests/OAuth2Tests/ClaimsTest.cs b/tests/Microsoft.Identity.Test.Unit/CoreTests/OAuth2Tests/ClaimsTest.cs index de320d0ce4..0a013f4b90 100644 --- a/tests/Microsoft.Identity.Test.Unit/CoreTests/OAuth2Tests/ClaimsTest.cs +++ b/tests/Microsoft.Identity.Test.Unit/CoreTests/OAuth2Tests/ClaimsTest.cs @@ -16,6 +16,7 @@ using Microsoft.Identity.Test.Common.Core.Mocks; using Microsoft.Identity.Test.Common.Mocks; using Microsoft.VisualStudio.TestTools.UnitTesting; +using Microsoft.Identity.Test.Common; namespace Microsoft.Identity.Test.Unit.CoreTests.OAuth2Tests { @@ -241,34 +242,12 @@ public async Task Claims_Fail_WhenClaimsIsNotJson_Async() Assert.AreEqual(MsalError.InvalidJsonClaimsFormat, ex.ErrorCode); } - [TestMethod] - public void ClaimsMerge_Test() - { - var mergedJson = ClaimsHelper.GetMergedClaimsAndClientCapabilities( - TestConstants.Claims, - TestConstants.ClientCapabilities); - - Assert.AreEqual(TestConstants.ClientCapabilitiesAndClaimsJson, mergedJson); - } - - [TestMethod] - public void ClaimsMerge_NoCapabilities_Test() + [DataTestMethod] + [DynamicData(nameof(TestData.GetClaimsAndCapabilities), typeof(TestData), DynamicDataSourceType.Method)] + public void ClaimsMerge_Test(string claims, string[] capabilities, string expectedMergedJson) { - var mergedJson = ClaimsHelper.GetMergedClaimsAndClientCapabilities( - TestConstants.Claims, - new string[0]); - - Assert.AreEqual(TestConstants.Claims, mergedJson); - } - - [TestMethod] - public void ClaimsMerge_NoClaims_Test() - { - var mergedJson = ClaimsHelper.GetMergedClaimsAndClientCapabilities( - null, - TestConstants.ClientCapabilities); - - Assert.AreEqual(TestConstants.ClientCapabilitiesJson, mergedJson); + var mergedJson = ClaimsHelper.GetMergedClaimsAndClientCapabilities(claims, capabilities); + Assert.AreEqual(expectedMergedJson, mergedJson); } } }