diff --git a/src/client/Microsoft.Identity.Client.ApiConfig/PublicAPI.Unshipped.txt b/src/client/Microsoft.Identity.Client.ApiConfig/PublicAPI.Unshipped.txt new file mode 100644 index 0000000000..954b8b89aa --- /dev/null +++ b/src/client/Microsoft.Identity.Client.ApiConfig/PublicAPI.Unshipped.txt @@ -0,0 +1 @@ +Microsoft.Identity.Client.AcquireTokenForClientParameterBuilder.WithExtraClientAssertionClaims(System.String) -> Microsoft.Identity.Client.AcquireTokenForClientParameterBuilder diff --git a/src/client/Microsoft.Identity.Client/ApiConfig/Parameters/AcquireTokenCommonParameters.cs b/src/client/Microsoft.Identity.Client/ApiConfig/Parameters/AcquireTokenCommonParameters.cs index 362653e3c3..28e732efa6 100644 --- a/src/client/Microsoft.Identity.Client/ApiConfig/Parameters/AcquireTokenCommonParameters.cs +++ b/src/client/Microsoft.Identity.Client/ApiConfig/Parameters/AcquireTokenCommonParameters.cs @@ -41,6 +41,7 @@ internal class AcquireTokenCommonParameters public string FmiPathSuffix { get; internal set; } public string ClientAssertionFmiPath { get; internal set; } public bool IsMtlsPopRequested { get; set; } + public string ExtraClientAssertionClaims { get; internal set; } /// /// Optional delegate for obtaining attestation JWT for Credential Guard keys. diff --git a/src/client/Microsoft.Identity.Client/AppConfig/ConfidentialClientApplicationBuilder.cs b/src/client/Microsoft.Identity.Client/AppConfig/ConfidentialClientApplicationBuilder.cs index cf9e22b9c2..f724eacf93 100644 --- a/src/client/Microsoft.Identity.Client/AppConfig/ConfidentialClientApplicationBuilder.cs +++ b/src/client/Microsoft.Identity.Client/AppConfig/ConfidentialClientApplicationBuilder.cs @@ -142,6 +142,8 @@ public ConfidentialClientApplicationBuilder WithCertificate(X509Certificate2 cer /// You should use certificates with a private key size of at least 2048 bytes. Future versions of this library might reject certificates with smaller keys. /// Does not send the certificate (as x5c parameter) with the request by default. /// + [Obsolete("This method is obsolete. Use the WithExtraClientAssertionClaims method on AcquireTokenForClientParameterBuilder", false)] + [EditorBrowsable(EditorBrowsableState.Never)] public ConfidentialClientApplicationBuilder WithClientClaims(X509Certificate2 certificate, IDictionary claimsToSign, bool mergeWithDefaultClaims) { return WithClientClaims(certificate, claimsToSign, mergeWithDefaultClaims, false); @@ -157,6 +159,8 @@ public ConfidentialClientApplicationBuilder WithClientClaims(X509Certificate2 ce /// Determines whether or not to merge with the default claims required for authentication. /// To send X5C with every request or not. /// You should use certificates with a private key size of at least 2048 bytes. Future versions of this library might reject certificates with smaller keys. + [Obsolete("This method is obsolete. Use the WithExtraClientAssertionClaims method on AcquireTokenForClientParameterBuilder", false)] + [EditorBrowsable(EditorBrowsableState.Never)] public ConfidentialClientApplicationBuilder WithClientClaims(X509Certificate2 certificate, IDictionary claimsToSign, bool mergeWithDefaultClaims = true, bool sendX5C = false) { if (certificate == null) diff --git a/src/client/Microsoft.Identity.Client/Extensibility/AbstractConfidentialClientAcquireTokenParameterBuilderExtension.cs b/src/client/Microsoft.Identity.Client/Extensibility/AbstractConfidentialClientAcquireTokenParameterBuilderExtension.cs index be3c53c80c..1657e2f257 100644 --- a/src/client/Microsoft.Identity.Client/Extensibility/AbstractConfidentialClientAcquireTokenParameterBuilderExtension.cs +++ b/src/client/Microsoft.Identity.Client/Extensibility/AbstractConfidentialClientAcquireTokenParameterBuilderExtension.cs @@ -204,5 +204,39 @@ public static AbstractAcquireTokenParameterBuilder WithFmiPathForClientAssert return builder; } + + /// + /// Specifies extra claims to be included in the client assertion. + /// These claims will be merged with default claims when the client assertion is generated. + /// This lets higher level APIs like Microsoft.Identity.Web provide additional claims for the client assertion. + /// Important: tokens are associated with the extra client assertion claims, which impacts cache lookups. + /// This is an extensibility API and should not be used by applications directly. + /// + /// The builder to chain options to + /// Additional claims in JSON format to be signed in the client assertion. + /// The builder to chain the .With methods + /// Thrown when clientAssertionClaims is null or whitespace. + public static AbstractAcquireTokenParameterBuilder WithExtraClientAssertionClaims( + this AbstractAcquireTokenParameterBuilder builder, + string clientAssertionClaims) + where T : AbstractAcquireTokenParameterBuilder + { + if (string.IsNullOrWhiteSpace(clientAssertionClaims)) + { + throw new ArgumentNullException(nameof(clientAssertionClaims)); + } + + builder.CommonParameters.ExtraClientAssertionClaims = clientAssertionClaims; + + // Add the extra claims to the cache key so different claims result in different cache entries + var cacheKey = new SortedList>> + { + { "extra_client_assertion_claims", (CancellationToken ct) => Task.FromResult(clientAssertionClaims) } + }; + + WithAdditionalCacheKeyComponents(builder, cacheKey); + + return builder; + } } } diff --git a/src/client/Microsoft.Identity.Client/Internal/ClientCredential/CertificateAndClaimsClientCredential.cs b/src/client/Microsoft.Identity.Client/Internal/ClientCredential/CertificateAndClaimsClientCredential.cs index 8d5c4bfb87..cc7480c734 100644 --- a/src/client/Microsoft.Identity.Client/Internal/ClientCredential/CertificateAndClaimsClientCredential.cs +++ b/src/client/Microsoft.Identity.Client/Internal/ClientCredential/CertificateAndClaimsClientCredential.cs @@ -17,7 +17,7 @@ namespace Microsoft.Identity.Client.Internal.ClientCredential internal class CertificateAndClaimsClientCredential : IClientCredential { private readonly IDictionary _claimsToSign; - private readonly bool _appendDefaultClaims; + private readonly bool _appendDefaultClaims = true; private readonly Func> _certificateProvider; public AssertionType AssertionType => AssertionType.CertificateWithoutSni; @@ -72,12 +72,25 @@ public async Task AddConfidentialClientParametersAsync( bool useSha2 = requestParameters.AuthorityManager.Authority.AuthorityInfo.IsSha2CredentialSupported; - var jwtToken = new JsonWebToken( - cryptographyManager, - clientId, - tokenEndpoint, - _claimsToSign, - _appendDefaultClaims); + JsonWebToken jwtToken; + if (string.IsNullOrEmpty(requestParameters.ExtraClientAssertionClaims)) + { + jwtToken = new JsonWebToken( + cryptographyManager, + clientId, + tokenEndpoint, + _claimsToSign, + _appendDefaultClaims); + } + else + { + jwtToken = new JsonWebToken( + cryptographyManager, + clientId, + tokenEndpoint, + requestParameters.ExtraClientAssertionClaims, + _appendDefaultClaims); + } string assertion = jwtToken.Sign(certificate, requestParameters.SendX5C, useSha2); diff --git a/src/client/Microsoft.Identity.Client/Internal/JsonWebToken.cs b/src/client/Microsoft.Identity.Client/Internal/JsonWebToken.cs index 0ed39cf7ba..3adba0edc1 100644 --- a/src/client/Microsoft.Identity.Client/Internal/JsonWebToken.cs +++ b/src/client/Microsoft.Identity.Client/Internal/JsonWebToken.cs @@ -3,13 +3,14 @@ using System; using System.Collections.Generic; +using System.IO; using System.Security.Cryptography.X509Certificates; using System.Text; using Microsoft.Identity.Client.PlatformsCommon.Interfaces; using Microsoft.Identity.Client.Utils; using System.Security.Cryptography; #if SUPPORTS_SYSTEM_TEXT_JSON -using JObject = System.Text.Json.Nodes.JsonObject; +using System.Text.Json; #else using Microsoft.Identity.Json.Linq; #endif @@ -23,6 +24,7 @@ internal class JsonWebToken public const long JwtToAadLifetimeInSeconds = 60 * 10; // Ten minutes private readonly IDictionary _claimsToSign; + private readonly string _claimsToSignJson; private readonly ICryptographyManager _cryptographyManager; private readonly string _clientId; private readonly string _audience; @@ -47,12 +49,27 @@ public JsonWebToken( _appendDefaultClaims = appendDefaultClaims; } + public JsonWebToken( + ICryptographyManager cryptographyManager, + string clientId, + string audience, + string claimsToSignJson, + bool appendDefaultClaims = false) + : this(cryptographyManager, clientId, audience) + { + _claimsToSignJson = claimsToSignJson; + _appendDefaultClaims = appendDefaultClaims; + } + private string CreateJsonPayload() { long validFrom = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); long validTo = validFrom + JwtToAadLifetimeInSeconds; // 10 min - if (_claimsToSign == null || _claimsToSign.Count == 0) + bool hasClaimsFromDictionary = _claimsToSign != null && _claimsToSign.Count > 0; + bool hasClaimsFromJson = !string.IsNullOrWhiteSpace(_claimsToSignJson); + + if (!hasClaimsFromDictionary && !hasClaimsFromJson) { return $$"""{"aud":"{{_audience}}","iss":"{{_clientId}}","sub":"{{_clientId}}","nbf":"{{validFrom}}","exp":"{{validTo}}","jti":"{{Guid.NewGuid()}}"}"""; } @@ -70,17 +87,56 @@ private string CreateJsonPayload() payload.Append('{'); } - var json = new JObject(); - - foreach (var claim in _claimsToSign) + // Handle claims from JSON string + if (hasClaimsFromJson) { - json[claim.Key] = claim.Value; + // Remove outer braces from JSON string and append + string jsonClaims = _claimsToSignJson.Trim(); + if (jsonClaims.StartsWith("{") && jsonClaims.EndsWith("}")) + { + jsonClaims = jsonClaims.Substring(1, jsonClaims.Length - 2); + } + + payload.Append(jsonClaims); } - var jsonClaims = JsonHelper.JsonObjectToString(json); + // Handle claims from dictionary + else if (hasClaimsFromDictionary) + { +#if SUPPORTS_SYSTEM_TEXT_JSON + using (var stream = new MemoryStream()) + { + using (var writer = new Utf8JsonWriter(stream)) + { + writer.WriteStartObject(); + + foreach (var claim in _claimsToSign) + { + writer.WriteString(claim.Key, claim.Value); + } + + writer.WriteEndObject(); + } + + var jsonClaims = Encoding.UTF8.GetString(stream.ToArray()); + + //Remove extra brackets from JSON result + payload.Append(jsonClaims.Substring(1, jsonClaims.Length - 2)); + } +#else + var json = new JObject(); + + foreach (var claim in _claimsToSign) + { + json[claim.Key] = claim.Value; + } + + var jsonClaims = JsonHelper.JsonObjectToString(json); - //Remove extra brackets from JSON result - payload.Append(jsonClaims.Substring(1, jsonClaims.Length - 2)); + //Remove extra brackets from JSON result + payload.Append(jsonClaims.Substring(1, jsonClaims.Length - 2)); +#endif + } payload.Append('}'); @@ -146,11 +202,11 @@ private static string ComputeCertThumbprint(X509Certificate2 certificate, bool u thumbprint = Base64UrlHelpers.Encode(certificate.GetCertHash(HashAlgorithmName.SHA256)); #else - using (var hasher = SHA256.Create()) - { - byte[] hash = hasher.ComputeHash(certificate.RawData); - thumbprint = Base64UrlHelpers.Encode(hash); - } + using (var hasher = SHA256.Create()) + { + byte[] hash = hasher.ComputeHash(certificate.RawData); + thumbprint = Base64UrlHelpers.Encode(hash); + } #endif } else diff --git a/src/client/Microsoft.Identity.Client/Internal/Requests/AuthenticationRequestParameters.cs b/src/client/Microsoft.Identity.Client/Internal/Requests/AuthenticationRequestParameters.cs index c13aaf7e0b..bb23445538 100644 --- a/src/client/Microsoft.Identity.Client/Internal/Requests/AuthenticationRequestParameters.cs +++ b/src/client/Microsoft.Identity.Client/Internal/Requests/AuthenticationRequestParameters.cs @@ -201,6 +201,8 @@ public string LoginHint public string ClientAssertionFmiPath => _commonParameters.ClientAssertionFmiPath; #endregion + public string ExtraClientAssertionClaims => _commonParameters.ExtraClientAssertionClaims; + public void LogParameters() { var logger = RequestContext.Logger; diff --git a/src/client/Microsoft.Identity.Client/PublicApi/net462/PublicAPI.Unshipped.txt b/src/client/Microsoft.Identity.Client/PublicApi/net462/PublicAPI.Unshipped.txt index ca4eb07970..38101497be 100644 --- a/src/client/Microsoft.Identity.Client/PublicApi/net462/PublicAPI.Unshipped.txt +++ b/src/client/Microsoft.Identity.Client/PublicApi/net462/PublicAPI.Unshipped.txt @@ -1,3 +1,4 @@ +static Microsoft.Identity.Client.Extensibility.AbstractConfidentialClientAcquireTokenParameterBuilderExtension.WithExtraClientAssertionClaims(this Microsoft.Identity.Client.AbstractAcquireTokenParameterBuilder builder, string clientAssertionClaims) -> Microsoft.Identity.Client.AbstractAcquireTokenParameterBuilder Microsoft.Identity.Client.ManagedIdentityPopExtensions static Microsoft.Identity.Client.ManagedIdentityPopExtensions.WithMtlsProofOfPossession(this Microsoft.Identity.Client.AcquireTokenForManagedIdentityParameterBuilder builder) -> Microsoft.Identity.Client.AcquireTokenForManagedIdentityParameterBuilder Microsoft.Identity.Client.AcquireTokenForClientParameterBuilder.WithAttributes(string attributeJson) -> Microsoft.Identity.Client.AcquireTokenForClientParameterBuilder diff --git a/src/client/Microsoft.Identity.Client/PublicApi/net472/PublicAPI.Unshipped.txt b/src/client/Microsoft.Identity.Client/PublicApi/net472/PublicAPI.Unshipped.txt index ca4eb07970..38101497be 100644 --- a/src/client/Microsoft.Identity.Client/PublicApi/net472/PublicAPI.Unshipped.txt +++ b/src/client/Microsoft.Identity.Client/PublicApi/net472/PublicAPI.Unshipped.txt @@ -1,3 +1,4 @@ +static Microsoft.Identity.Client.Extensibility.AbstractConfidentialClientAcquireTokenParameterBuilderExtension.WithExtraClientAssertionClaims(this Microsoft.Identity.Client.AbstractAcquireTokenParameterBuilder builder, string clientAssertionClaims) -> Microsoft.Identity.Client.AbstractAcquireTokenParameterBuilder Microsoft.Identity.Client.ManagedIdentityPopExtensions static Microsoft.Identity.Client.ManagedIdentityPopExtensions.WithMtlsProofOfPossession(this Microsoft.Identity.Client.AcquireTokenForManagedIdentityParameterBuilder builder) -> Microsoft.Identity.Client.AcquireTokenForManagedIdentityParameterBuilder Microsoft.Identity.Client.AcquireTokenForClientParameterBuilder.WithAttributes(string attributeJson) -> Microsoft.Identity.Client.AcquireTokenForClientParameterBuilder diff --git a/src/client/Microsoft.Identity.Client/PublicApi/net8.0-android/PublicAPI.Unshipped.txt b/src/client/Microsoft.Identity.Client/PublicApi/net8.0-android/PublicAPI.Unshipped.txt index ca4eb07970..38101497be 100644 --- a/src/client/Microsoft.Identity.Client/PublicApi/net8.0-android/PublicAPI.Unshipped.txt +++ b/src/client/Microsoft.Identity.Client/PublicApi/net8.0-android/PublicAPI.Unshipped.txt @@ -1,3 +1,4 @@ +static Microsoft.Identity.Client.Extensibility.AbstractConfidentialClientAcquireTokenParameterBuilderExtension.WithExtraClientAssertionClaims(this Microsoft.Identity.Client.AbstractAcquireTokenParameterBuilder builder, string clientAssertionClaims) -> Microsoft.Identity.Client.AbstractAcquireTokenParameterBuilder Microsoft.Identity.Client.ManagedIdentityPopExtensions static Microsoft.Identity.Client.ManagedIdentityPopExtensions.WithMtlsProofOfPossession(this Microsoft.Identity.Client.AcquireTokenForManagedIdentityParameterBuilder builder) -> Microsoft.Identity.Client.AcquireTokenForManagedIdentityParameterBuilder Microsoft.Identity.Client.AcquireTokenForClientParameterBuilder.WithAttributes(string attributeJson) -> Microsoft.Identity.Client.AcquireTokenForClientParameterBuilder diff --git a/src/client/Microsoft.Identity.Client/PublicApi/net8.0-ios/PublicAPI.Unshipped.txt b/src/client/Microsoft.Identity.Client/PublicApi/net8.0-ios/PublicAPI.Unshipped.txt index ca4eb07970..38101497be 100644 --- a/src/client/Microsoft.Identity.Client/PublicApi/net8.0-ios/PublicAPI.Unshipped.txt +++ b/src/client/Microsoft.Identity.Client/PublicApi/net8.0-ios/PublicAPI.Unshipped.txt @@ -1,3 +1,4 @@ +static Microsoft.Identity.Client.Extensibility.AbstractConfidentialClientAcquireTokenParameterBuilderExtension.WithExtraClientAssertionClaims(this Microsoft.Identity.Client.AbstractAcquireTokenParameterBuilder builder, string clientAssertionClaims) -> Microsoft.Identity.Client.AbstractAcquireTokenParameterBuilder Microsoft.Identity.Client.ManagedIdentityPopExtensions static Microsoft.Identity.Client.ManagedIdentityPopExtensions.WithMtlsProofOfPossession(this Microsoft.Identity.Client.AcquireTokenForManagedIdentityParameterBuilder builder) -> Microsoft.Identity.Client.AcquireTokenForManagedIdentityParameterBuilder Microsoft.Identity.Client.AcquireTokenForClientParameterBuilder.WithAttributes(string attributeJson) -> Microsoft.Identity.Client.AcquireTokenForClientParameterBuilder diff --git a/src/client/Microsoft.Identity.Client/PublicApi/net8.0/PublicAPI.Unshipped.txt b/src/client/Microsoft.Identity.Client/PublicApi/net8.0/PublicAPI.Unshipped.txt index ca4eb07970..38101497be 100644 --- a/src/client/Microsoft.Identity.Client/PublicApi/net8.0/PublicAPI.Unshipped.txt +++ b/src/client/Microsoft.Identity.Client/PublicApi/net8.0/PublicAPI.Unshipped.txt @@ -1,3 +1,4 @@ +static Microsoft.Identity.Client.Extensibility.AbstractConfidentialClientAcquireTokenParameterBuilderExtension.WithExtraClientAssertionClaims(this Microsoft.Identity.Client.AbstractAcquireTokenParameterBuilder builder, string clientAssertionClaims) -> Microsoft.Identity.Client.AbstractAcquireTokenParameterBuilder Microsoft.Identity.Client.ManagedIdentityPopExtensions static Microsoft.Identity.Client.ManagedIdentityPopExtensions.WithMtlsProofOfPossession(this Microsoft.Identity.Client.AcquireTokenForManagedIdentityParameterBuilder builder) -> Microsoft.Identity.Client.AcquireTokenForManagedIdentityParameterBuilder Microsoft.Identity.Client.AcquireTokenForClientParameterBuilder.WithAttributes(string attributeJson) -> Microsoft.Identity.Client.AcquireTokenForClientParameterBuilder diff --git a/src/client/Microsoft.Identity.Client/PublicApi/netstandard2.0/PublicAPI.Unshipped.txt b/src/client/Microsoft.Identity.Client/PublicApi/netstandard2.0/PublicAPI.Unshipped.txt index ca4eb07970..38101497be 100644 --- a/src/client/Microsoft.Identity.Client/PublicApi/netstandard2.0/PublicAPI.Unshipped.txt +++ b/src/client/Microsoft.Identity.Client/PublicApi/netstandard2.0/PublicAPI.Unshipped.txt @@ -1,3 +1,4 @@ +static Microsoft.Identity.Client.Extensibility.AbstractConfidentialClientAcquireTokenParameterBuilderExtension.WithExtraClientAssertionClaims(this Microsoft.Identity.Client.AbstractAcquireTokenParameterBuilder builder, string clientAssertionClaims) -> Microsoft.Identity.Client.AbstractAcquireTokenParameterBuilder Microsoft.Identity.Client.ManagedIdentityPopExtensions static Microsoft.Identity.Client.ManagedIdentityPopExtensions.WithMtlsProofOfPossession(this Microsoft.Identity.Client.AcquireTokenForManagedIdentityParameterBuilder builder) -> Microsoft.Identity.Client.AcquireTokenForManagedIdentityParameterBuilder Microsoft.Identity.Client.AcquireTokenForClientParameterBuilder.WithAttributes(string attributeJson) -> Microsoft.Identity.Client.AcquireTokenForClientParameterBuilder diff --git a/tests/Microsoft.Identity.Test.Integration.netcore/HeadlessTests/ClientCredentialsTests.NetFwk.cs b/tests/Microsoft.Identity.Test.Integration.netcore/HeadlessTests/ClientCredentialsTests.NetFwk.cs index 5ba42fb9b0..a7ad4a511f 100644 --- a/tests/Microsoft.Identity.Test.Integration.netcore/HeadlessTests/ClientCredentialsTests.NetFwk.cs +++ b/tests/Microsoft.Identity.Test.Integration.netcore/HeadlessTests/ClientCredentialsTests.NetFwk.cs @@ -443,13 +443,14 @@ private static IConfidentialClientApplication CreateApp( aud2, cert)); break; - +#pragma warning disable CS0618 // Type or member is obsolete case CredentialType.ClientClaims_ExtraClaims: builder.WithClientClaims(cert, GetClaims(true), mergeWithDefaultClaims: false, sendX5C: sendX5C); break; case CredentialType.ClientClaims_MergeClaims: builder.WithClientClaims(cert, GetClaims(false), mergeWithDefaultClaims: true, sendX5C: sendX5C); break; +#pragma warning restore CS0618 // Type or member is obsolete default: throw new NotImplementedException(); } diff --git a/tests/Microsoft.Identity.Test.Unit/PublicApiTests/ClientCredentialWithCertTest.cs b/tests/Microsoft.Identity.Test.Unit/PublicApiTests/ClientCredentialWithCertTest.cs index 5e03718004..02a068d152 100644 --- a/tests/Microsoft.Identity.Test.Unit/PublicApiTests/ClientCredentialWithCertTest.cs +++ b/tests/Microsoft.Identity.Test.Unit/PublicApiTests/ClientCredentialWithCertTest.cs @@ -22,6 +22,7 @@ using Microsoft.Identity.Test.Common.Core.Helpers; using Microsoft.Identity.Test.Common.Core.Mocks; using Microsoft.VisualStudio.TestTools.UnitTesting; +using Microsoft.Identity.Client.Extensibility; namespace Microsoft.Identity.Test.Unit { @@ -39,6 +40,8 @@ public override void TestInitialize() _serializedCache = null; } + private const string _clientAssertionClaims = "{\"custom_claims\":{\"xms_az_tm\":\"azureinfra\",\"xms_az_nwperimid\":[\"00000000-1403-0100-0000-000000000000\",\"00000000-dc4b-4eb1-9fa3-902c8d13b5bd\"]}}"; + private static MockHttpMessageHandler CreateTokenResponseHttpHandler(bool clientCredentialFlow) { return new MockHttpMessageHandler() @@ -188,6 +191,7 @@ public async Task TestX5C( if (appFlag.HasValue) { +#pragma warning disable CS0618 // Type or member is obsolete appBuilder = appBuilder.WithClientClaims( certificate, claimsToSign, @@ -199,6 +203,7 @@ public async Task TestX5C( certificate, claimsToSign); // no app flag } +#pragma warning restore CS0618 // Type or member is obsolete var app = appBuilder.BuildConcrete(); @@ -236,12 +241,14 @@ public async Task ClientAssertionHasExpiration() }; +#pragma warning disable CS0618 // Type or member is obsolete var cca = ConfidentialClientApplicationBuilder .Create(TestConstants.ClientId) .WithAuthority("https://login.microsoftonline.com/tid") .WithHttpManager(harness.HttpManager) .WithClientClaims(certificate, extraAssertionContent, mergeWithDefaultClaims: true, sendX5C: true) // x5c can also be enabled on the request .Build(); +#pragma warning restore CS0618 // Type or member is obsolete // Checks the client assertion for x5c and for expiration var handler = harness.HttpManager.AddTokenResponse(TokenResponseType.Valid_ClientCredentials); @@ -271,12 +278,14 @@ public async Task ClientAssertionWithClaimOverride() { "iss", "issuer_override" } }; +#pragma warning disable CS0618 // Type or member is obsolete var cca = ConfidentialClientApplicationBuilder .Create(TestConstants.ClientId) .WithAuthority("https://login.microsoftonline.com/tid") .WithHttpManager(harness.HttpManager) .WithClientClaims(certificate, extraAssertionContent, mergeWithDefaultClaims: true, sendX5C: false) .Build(); +#pragma warning restore CS0618 // Type or member is obsolete // Checks the client assertion for x5c and for expiration var handler = harness.HttpManager.AddTokenResponse(TokenResponseType.Valid_ClientCredentials); @@ -1107,12 +1116,14 @@ public async Task ClientAssertionWithComplexClaims() { "custom_claims", "{\"xms_foo\":[\"abc\",\"def\"],\"xms_az_foo\":\"bar\"}" } }; +#pragma warning disable CS0618 // Type or member is obsolete var cca = ConfidentialClientApplicationBuilder .Create(TestConstants.ClientId) .WithAuthority("https://login.microsoftonline.com/tid") .WithHttpManager(harness.HttpManager) .WithClientClaims(certificate, extraAssertionContent, mergeWithDefaultClaims: true, sendX5C: true) .Build(); +#pragma warning restore CS0618 // Type or member is obsolete var handler = harness.HttpManager.AddTokenResponse(TokenResponseType.Valid_ClientCredentials); JwtSecurityToken assertion = null; @@ -1149,6 +1160,343 @@ public async Task ClientAssertionWithComplexClaims() } } + [TestMethod] + public async Task WithExtraClientAssertionClaims_AddsClaimsToCacheKey_Async() + { + using (var harness = CreateTestHarness()) + { + harness.HttpManager.AddInstanceDiscoveryMockHandler(); + var certificate = CertHelper.GetOrCreateTestCert(); + + var app = ConfidentialClientApplicationBuilder + .Create(TestConstants.ClientId) + .WithAuthority("https://login.microsoftonline.com/tid") + .WithHttpManager(harness.HttpManager) + .WithCertificate(certificate) + .Build(); + + var handler = harness.HttpManager.AddTokenResponse(TokenResponseType.Valid_ClientCredentials); + JwtSecurityToken assertion = null; + handler.AdditionalRequestValidation = (r) => + { + var requestContent = r.Content.ReadAsStringAsync().GetAwaiter().GetResult(); + var formsData = CoreHelpers.ParseKeyValueList(requestContent, '&', true, null); + + Assert.IsTrue(formsData.TryGetValue("client_assertion", out string encodedJwt), "Missing client_assertion from request"); + + var jwtHandler = new JwtSecurityTokenHandler(); + assertion = jwtHandler.ReadJwtToken(encodedJwt); + + // Validate extra claims are present + var customClaimsClaim = assertion.Claims.FirstOrDefault(c => c.Type == "custom_claims"); + Assert.IsNotNull(customClaimsClaim, "custom_claims should be present"); + + // Parse the nested claim value as JSON + var jsonElement = JsonSerializer.Deserialize(customClaimsClaim.Value); + Assert.AreEqual(JsonValueKind.Object, jsonElement.ValueKind, "custom_claims should be a JSON object"); + + // Validate nested properties + Assert.IsTrue(jsonElement.TryGetProperty("xms_az_tm", out var tmProperty)); + Assert.AreEqual("azureinfra", tmProperty.GetString()); + + Assert.IsTrue(jsonElement.TryGetProperty("xms_az_nwperimid", out var nwperimidProperty)); + Assert.AreEqual(JsonValueKind.Array, nwperimidProperty.ValueKind); + Assert.AreEqual(2, nwperimidProperty.GetArrayLength()); + }; + + AuthenticationResult result = await app.AcquireTokenForClient(TestConstants.s_scope) + .WithExtraClientAssertionClaims(_clientAssertionClaims) + .ExecuteAsync() + .ConfigureAwait(false); + + Assert.IsNotNull(result.AccessToken); + Assert.IsNotNull(assertion); + } + } + + [TestMethod] + public async Task WithExtraClientAssertionClaims_DifferentClaims_ResultsInDifferentCacheEntries_Async() + { + using (var harness = CreateTestHarness()) + { + harness.HttpManager.AddInstanceDiscoveryMockHandler(); + var certificate = CertHelper.GetOrCreateTestCert(); + + var app = ConfidentialClientApplicationBuilder + .Create(TestConstants.ClientId) + .WithAuthority("https://login.microsoftonline.com/tid") + .WithHttpManager(harness.HttpManager) + .WithCertificate(certificate) + .BuildConcrete(); + + // First request with _clientAssertionClaims + harness.HttpManager.AddTokenResponse(TokenResponseType.Valid_ClientCredentials); + + AuthenticationResult result1 = await app.AcquireTokenForClient(TestConstants.s_scope) + .WithExtraClientAssertionClaims(_clientAssertionClaims) + .ExecuteAsync() + .ConfigureAwait(false); + + Assert.IsNotNull(result1.AccessToken); + Assert.AreEqual(TokenSource.IdentityProvider, result1.AuthenticationResultMetadata.TokenSource); + + // Second request with different claims should go to IdP + harness.HttpManager.AddTokenResponse(TokenResponseType.Valid_ClientCredentials); + string extraClaims2 = "{\"claim2\":\"value2\"}"; + + AuthenticationResult result2 = await app.AcquireTokenForClient(TestConstants.s_scope) + .WithExtraClientAssertionClaims(extraClaims2) + .ExecuteAsync() + .ConfigureAwait(false); + + Assert.IsNotNull(result2.AccessToken); + Assert.AreEqual(TokenSource.IdentityProvider, result2.AuthenticationResultMetadata.TokenSource); + + // Verify we have 2 tokens in cache + Assert.AreEqual(2, app.AppTokenCacheInternal.Accessor.GetAllAccessTokens().Count()); + } + } + + [TestMethod] + public async Task WithExtraClientAssertionClaims_SameClaims_UsesCache_Async() + { + using (var harness = CreateTestHarness()) + { + harness.HttpManager.AddInstanceDiscoveryMockHandler(); + var certificate = CertHelper.GetOrCreateTestCert(); + + var app = ConfidentialClientApplicationBuilder + .Create(TestConstants.ClientId) + .WithAuthority("https://login.microsoftonline.com/tid") + .WithHttpManager(harness.HttpManager) + .WithCertificate(certificate) + .BuildConcrete(); + + harness.HttpManager.AddTokenResponse(TokenResponseType.Valid_ClientCredentials); + + // First request + AuthenticationResult result1 = await app.AcquireTokenForClient(TestConstants.s_scope) + .WithExtraClientAssertionClaims(_clientAssertionClaims) + .ExecuteAsync() + .ConfigureAwait(false); + + Assert.IsNotNull(result1.AccessToken); + Assert.AreEqual(TokenSource.IdentityProvider, result1.AuthenticationResultMetadata.TokenSource); + + // Second request with same claims should use cache + AuthenticationResult result2 = await app.AcquireTokenForClient(TestConstants.s_scope) + .WithExtraClientAssertionClaims(_clientAssertionClaims) + .ExecuteAsync() + .ConfigureAwait(false); + + Assert.IsNotNull(result2.AccessToken); + Assert.AreEqual(TokenSource.Cache, result2.AuthenticationResultMetadata.TokenSource); + Assert.AreEqual(result1.AccessToken, result2.AccessToken); + } + } + + [TestMethod] + public async Task WithExtraClientAssertionClaims_ComplexNestedClaims_Async() + { + using (var harness = CreateTestHarness()) + { + harness.HttpManager.AddInstanceDiscoveryMockHandler(); + var certificate = CertHelper.GetOrCreateTestCert(); + + var app = ConfidentialClientApplicationBuilder + .Create(TestConstants.ClientId) + .WithAuthority("https://login.microsoftonline.com/tid") + .WithHttpManager(harness.HttpManager) + .WithCertificate(certificate) + .Build(); + + var handler = harness.HttpManager.AddTokenResponse(TokenResponseType.Valid_ClientCredentials); + JwtSecurityToken assertion = null; + handler.AdditionalRequestValidation = (r) => + { + var requestContent = r.Content.ReadAsStringAsync().GetAwaiter().GetResult(); + var formsData = CoreHelpers.ParseKeyValueList(requestContent, '&', true, null); + + Assert.IsTrue(formsData.TryGetValue("client_assertion", out string encodedJwt), "Missing client_assertion from request"); + + var jwtHandler = new JwtSecurityTokenHandler(); + assertion = jwtHandler.ReadJwtToken(encodedJwt); + + // Validate nested claims + var customClaimsClaim = assertion.Claims.FirstOrDefault(c => c.Type == "custom_claims"); + Assert.IsNotNull(customClaimsClaim, "custom_claims should be present"); + + // Parse the nested claim value as JSON + var jsonElement = JsonSerializer.Deserialize(customClaimsClaim.Value); + Assert.AreEqual(JsonValueKind.Object, jsonElement.ValueKind, "custom_claims should be a JSON object"); + + // Validate xms_az_tm property + Assert.IsTrue(jsonElement.TryGetProperty("xms_az_tm", out var tmProperty)); + Assert.AreEqual("azureinfra", tmProperty.GetString()); + + // Validate xms_az_nwperimid array property + Assert.IsTrue(jsonElement.TryGetProperty("xms_az_nwperimid", out var nwperimidProperty)); + Assert.AreEqual(JsonValueKind.Array, nwperimidProperty.ValueKind); + + var arrayElements = nwperimidProperty.EnumerateArray().ToList(); + Assert.AreEqual(2, arrayElements.Count); + Assert.AreEqual("00000000-1403-0100-0000-000000000000", arrayElements[0].GetString()); + Assert.AreEqual("00000000-dc4b-4eb1-9fa3-902c8d13b5bd", arrayElements[1].GetString()); + }; + + AuthenticationResult result = await app.AcquireTokenForClient(TestConstants.s_scope) + .WithExtraClientAssertionClaims(_clientAssertionClaims) + .ExecuteAsync() + .ConfigureAwait(false); + + Assert.IsNotNull(result.AccessToken); + Assert.IsNotNull(assertion); + } + } + + [TestMethod] + public async Task WithExtraClientAssertionClaims_WorksWithOboFlow_Async() + { + using (var harness = CreateTestHarness()) + { + harness.HttpManager.AddInstanceDiscoveryMockHandler(); + var certificate = CertHelper.GetOrCreateTestCert(); + + var app = ConfidentialClientApplicationBuilder + .Create(TestConstants.ClientId) + .WithAuthority("https://login.microsoftonline.com/tid") + .WithHttpManager(harness.HttpManager) + .WithCertificate(certificate) + .Build(); + + var handler = harness.HttpManager.AddTokenResponse(TokenResponseType.Valid_UserFlows); + JwtSecurityToken assertion = null; + handler.AdditionalRequestValidation = (r) => + { + var requestContent = r.Content.ReadAsStringAsync().GetAwaiter().GetResult(); + var formsData = CoreHelpers.ParseKeyValueList(requestContent, '&', true, null); + + Assert.IsTrue(formsData.TryGetValue("client_assertion", out string encodedJwt), "Missing client_assertion from request"); + + var jwtHandler = new JwtSecurityTokenHandler(); + assertion = jwtHandler.ReadJwtToken(encodedJwt); + + // Validate extra claims are present in the client assertion + var customClaimsClaim = assertion.Claims.FirstOrDefault(c => c.Type == "custom_claims"); + Assert.IsNotNull(customClaimsClaim, "custom_claims should be present"); + + // Parse the nested claim value as JSON + var jsonElement = JsonSerializer.Deserialize(customClaimsClaim.Value); + Assert.AreEqual(JsonValueKind.Object, jsonElement.ValueKind, "custom_claims should be a JSON object"); + + // Validate nested properties + Assert.IsTrue(jsonElement.TryGetProperty("xms_az_tm", out var tmProperty)); + Assert.AreEqual("azureinfra", tmProperty.GetString()); + + Assert.IsTrue(jsonElement.TryGetProperty("xms_az_nwperimid", out var nwperimidProperty)); + Assert.AreEqual(JsonValueKind.Array, nwperimidProperty.ValueKind); + Assert.AreEqual(2, nwperimidProperty.GetArrayLength()); + + // Verify it's an OBO flow by checking for the user assertion + Assert.IsTrue(formsData.TryGetValue("grant_type", out string grantType)); + Assert.AreEqual("urn:ietf:params:oauth:grant-type:jwt-bearer", grantType); + Assert.IsTrue(formsData.TryGetValue("assertion", out string userAssertion)); + Assert.AreEqual(TestConstants.DefaultAccessToken, userAssertion); + }; + + var userAssertion = new UserAssertion(TestConstants.DefaultAccessToken); + + AuthenticationResult result = await app.AcquireTokenOnBehalfOf(TestConstants.s_scope, userAssertion) + .WithExtraClientAssertionClaims(_clientAssertionClaims) + .ExecuteAsync() + .ConfigureAwait(false); + + Assert.IsNotNull(result.AccessToken); + Assert.IsNotNull(assertion); + } + } + + [TestMethod] + public async Task WithExtraClientAssertionClaims_OboFlow_DifferentClaims_ResultsInDifferentCacheEntries_Async() + { + using (var harness = CreateTestHarness()) + { + harness.HttpManager.AddInstanceDiscoveryMockHandler(); + var certificate = CertHelper.GetOrCreateTestCert(); + + var app = ConfidentialClientApplicationBuilder + .Create(TestConstants.ClientId) + .WithAuthority("https://login.microsoftonline.com/tid") + .WithHttpManager(harness.HttpManager) + .WithCertificate(certificate) + .BuildConcrete(); + + var userAssertion = new UserAssertion(TestConstants.DefaultAccessToken); + + // First request with _clientAssertionClaims + harness.HttpManager.AddTokenResponse(TokenResponseType.Valid_UserFlows); + + AuthenticationResult result1 = await app.AcquireTokenOnBehalfOf(TestConstants.s_scope, userAssertion) + .WithExtraClientAssertionClaims(_clientAssertionClaims) + .ExecuteAsync() + .ConfigureAwait(false); + + Assert.IsNotNull(result1.AccessToken); + Assert.AreEqual(TokenSource.IdentityProvider, result1.AuthenticationResultMetadata.TokenSource); + + // Second request with different claims should go to IdP + harness.HttpManager.AddTokenResponse(TokenResponseType.Valid_UserFlows); + string extraClaims2 = "{\"claim2\":\"value2\"}"; + + AuthenticationResult result2 = await app.AcquireTokenOnBehalfOf(TestConstants.s_scope, userAssertion) + .WithExtraClientAssertionClaims(extraClaims2) + .ExecuteAsync() + .ConfigureAwait(false); + + Assert.IsNotNull(result2.AccessToken); + Assert.AreEqual(TokenSource.IdentityProvider, result2.AuthenticationResultMetadata.TokenSource); + } + } + + [TestMethod] + public async Task WithExtraClientAssertionClaims_OboFlow_SameClaims_UsesCache_Async() + { + using (var harness = CreateTestHarness()) + { + harness.HttpManager.AddInstanceDiscoveryMockHandler(); + var certificate = CertHelper.GetOrCreateTestCert(); + + var app = ConfidentialClientApplicationBuilder + .Create(TestConstants.ClientId) + .WithAuthority("https://login.microsoftonline.com/tid") + .WithHttpManager(harness.HttpManager) + .WithCertificate(certificate) + .BuildConcrete(); + + var userAssertion = new UserAssertion(TestConstants.DefaultAccessToken); + + harness.HttpManager.AddTokenResponse(TokenResponseType.Valid_UserFlows); + + // First request + AuthenticationResult result1 = await app.AcquireTokenOnBehalfOf(TestConstants.s_scope, userAssertion) + .WithExtraClientAssertionClaims(_clientAssertionClaims) + .ExecuteAsync() + .ConfigureAwait(false); + + Assert.IsNotNull(result1.AccessToken); + Assert.AreEqual(TokenSource.IdentityProvider, result1.AuthenticationResultMetadata.TokenSource); + + // Second request with same claims should use cache + AuthenticationResult result2 = await app.AcquireTokenOnBehalfOf(TestConstants.s_scope, userAssertion) + .WithExtraClientAssertionClaims(_clientAssertionClaims) + .ExecuteAsync() + .ConfigureAwait(false); + + Assert.IsNotNull(result2.AccessToken); + Assert.AreEqual(TokenSource.Cache, result2.AuthenticationResultMetadata.TokenSource); + Assert.AreEqual(result1.AccessToken, result2.AccessToken); + } + } private void BeforeCacheAccess(TokenCacheNotificationArgs args) { args.TokenCache.DeserializeMsalV3(_serializedCache); diff --git a/tests/Microsoft.Identity.Test.Unit/PublicApiTests/ConfidentialClientApplicationTests.cs b/tests/Microsoft.Identity.Test.Unit/PublicApiTests/ConfidentialClientApplicationTests.cs index a20528bc27..553a7a9b49 100644 --- a/tests/Microsoft.Identity.Test.Unit/PublicApiTests/ConfidentialClientApplicationTests.cs +++ b/tests/Microsoft.Identity.Test.Unit/PublicApiTests/ConfidentialClientApplicationTests.cs @@ -481,7 +481,9 @@ private enum CredentialType switch (credentialType) { case CredentialType.CertificateAndClaims: +#pragma warning disable CS0618 // Type or member is obsolete builder = builder.WithClientClaims(cert, TestConstants.s_clientAssertionClaims); +#pragma warning restore CS0618 // Type or member is obsolete app = builder.BuildConcrete(); Assert.AreEqual(cert, app.Certificate); break; diff --git a/tests/Microsoft.Identity.Test.Unit/PublicApiTests/MtlsPopTests.cs b/tests/Microsoft.Identity.Test.Unit/PublicApiTests/MtlsPopTests.cs index 230d3f4954..25aeb14e7d 100644 --- a/tests/Microsoft.Identity.Test.Unit/PublicApiTests/MtlsPopTests.cs +++ b/tests/Microsoft.Identity.Test.Unit/PublicApiTests/MtlsPopTests.cs @@ -93,10 +93,12 @@ public async Task MtlsPopWithoutCertificateWithClientClaimsAsync() { "client_ip", "192.168.1.2" } }; +#pragma warning disable CS0618 // Type or member is obsolete IConfidentialClientApplication app = ConfidentialClientApplicationBuilder .Create(TestConstants.ClientId) .WithClientClaims(s_testCertificate, ipAddress) .Build(); +#pragma warning restore CS0618 // Type or member is obsolete // Expecting an exception because MTLS PoP requires a certificate to sign the claims MsalClientException ex = await Assert.ThrowsExceptionAsync(() =>