From 3f388de23c3ccdd1c8e95f6e02c5cca9f12157f0 Mon Sep 17 00:00:00 2001 From: trwalke Date: Mon, 12 Jan 2026 22:15:37 -0800 Subject: [PATCH 1/7] Adding WithExtraClientAssertionClaims api --- .../PublicAPI.Unshipped.txt | 1 + .../AcquireTokenForClientParameterBuilder.cs | 32 +++ .../AcquireTokenCommonParameters.cs | 1 + .../ConfidentialClientApplicationBuilder.cs | 2 + .../CertificateAndClaimsClientCredential.cs | 25 +- .../Internal/JsonWebToken.cs | 49 +++- .../AuthenticationRequestParameters.cs | 2 + .../PublicApi/net462/PublicAPI.Unshipped.txt | 2 +- .../PublicApi/net472/PublicAPI.Unshipped.txt | 1 + .../net8.0-android/PublicAPI.Unshipped.txt | 1 + .../net8.0-ios/PublicAPI.Unshipped.txt | 1 + .../PublicApi/net8.0/PublicAPI.Unshipped.txt | 1 + .../netstandard2.0/PublicAPI.Unshipped.txt | 1 + .../ClientCredentialsTests.NetFwk.cs | 2 + .../ClientCredentialWithCertTest.cs | 267 ++++++++++++++++++ .../ConfidentialClientApplicationTests.cs | 2 + .../PublicApiTests/MtlsPopTests.cs | 2 + 17 files changed, 377 insertions(+), 15 deletions(-) create mode 100644 src/client/Microsoft.Identity.Client.ApiConfig/PublicAPI.Unshipped.txt 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/AcquireTokenForClientParameterBuilder.cs b/src/client/Microsoft.Identity.Client/ApiConfig/AcquireTokenForClientParameterBuilder.cs index 281011e8d8..3f0e0cb7da 100644 --- a/src/client/Microsoft.Identity.Client/ApiConfig/AcquireTokenForClientParameterBuilder.cs +++ b/src/client/Microsoft.Identity.Client/ApiConfig/AcquireTokenForClientParameterBuilder.cs @@ -155,6 +155,38 @@ public AcquireTokenForClientParameterBuilder WithFmiPath(string pathSuffix) return this; } + /// + /// 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. + /// + /// Additional claims in JSON format to be signed in the client assertion. + /// The builder to chain the .With methods + /// Thrown when claimsToSign is null or whitespace. + public AcquireTokenForClientParameterBuilder WithExtraClientAssertionClaims(string claimsToSign) + { + ValidateUseOfExperimentalFeature(); + + if (string.IsNullOrWhiteSpace(claimsToSign)) + { + throw new ArgumentNullException(nameof(claimsToSign)); + } + + CommonParameters.ExtraClientAssertionClaims = claimsToSign; + + // 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(claimsToSign) } + }; + + this.WithAdditionalCacheKeyComponents(cacheKey); + + return this; + } + /// internal override Task ExecuteInternalAsync(CancellationToken cancellationToken) { diff --git a/src/client/Microsoft.Identity.Client/ApiConfig/Parameters/AcquireTokenCommonParameters.cs b/src/client/Microsoft.Identity.Client/ApiConfig/Parameters/AcquireTokenCommonParameters.cs index cbb52f4a88..fc59abfa45 100644 --- a/src/client/Microsoft.Identity.Client/ApiConfig/Parameters/AcquireTokenCommonParameters.cs +++ b/src/client/Microsoft.Identity.Client/ApiConfig/Parameters/AcquireTokenCommonParameters.cs @@ -40,6 +40,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; } internal Func> AttestationTokenProvider { get; set; } internal async Task InitMtlsPopParametersAsync(IServiceBundle serviceBundle, CancellationToken ct) diff --git a/src/client/Microsoft.Identity.Client/AppConfig/ConfidentialClientApplicationBuilder.cs b/src/client/Microsoft.Identity.Client/AppConfig/ConfidentialClientApplicationBuilder.cs index cf9e22b9c2..6b4746eeab 100644 --- a/src/client/Microsoft.Identity.Client/AppConfig/ConfidentialClientApplicationBuilder.cs +++ b/src/client/Microsoft.Identity.Client/AppConfig/ConfidentialClientApplicationBuilder.cs @@ -142,6 +142,7 @@ 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)] public ConfidentialClientApplicationBuilder WithClientClaims(X509Certificate2 certificate, IDictionary claimsToSign, bool mergeWithDefaultClaims) { return WithClientClaims(certificate, claimsToSign, mergeWithDefaultClaims, false); @@ -157,6 +158,7 @@ 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)] public ConfidentialClientApplicationBuilder WithClientClaims(X509Certificate2 certificate, IDictionary claimsToSign, bool mergeWithDefaultClaims = true, bool sendX5C = false) { if (certificate == null) diff --git a/src/client/Microsoft.Identity.Client/Internal/ClientCredential/CertificateAndClaimsClientCredential.cs b/src/client/Microsoft.Identity.Client/Internal/ClientCredential/CertificateAndClaimsClientCredential.cs index 8d5c4bfb87..cf31483eb0 100644 --- a/src/client/Microsoft.Identity.Client/Internal/ClientCredential/CertificateAndClaimsClientCredential.cs +++ b/src/client/Microsoft.Identity.Client/Internal/ClientCredential/CertificateAndClaimsClientCredential.cs @@ -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..0808e035de 100644 --- a/src/client/Microsoft.Identity.Client/Internal/JsonWebToken.cs +++ b/src/client/Microsoft.Identity.Client/Internal/JsonWebToken.cs @@ -23,6 +23,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 +48,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 +86,34 @@ 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) + { + var json = new JObject(); + + foreach (var claim in _claimsToSign) + { + json[claim.Key] = claim.Value; + } - //Remove extra brackets from JSON result - payload.Append(jsonClaims.Substring(1, jsonClaims.Length - 2)); + var jsonClaims = JsonHelper.JsonObjectToString(json); + + //Remove extra brackets from JSON result + payload.Append(jsonClaims.Substring(1, jsonClaims.Length - 2)); + } payload.Append('}'); 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 8b13789179..8f2d422155 100644 --- a/src/client/Microsoft.Identity.Client/PublicApi/net462/PublicAPI.Unshipped.txt +++ b/src/client/Microsoft.Identity.Client/PublicApi/net462/PublicAPI.Unshipped.txt @@ -1 +1 @@ - +Microsoft.Identity.Client.AcquireTokenForClientParameterBuilder.WithExtraClientAssertionClaims(string claimsToSign) -> 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 e69de29bb2..8f2d422155 100644 --- a/src/client/Microsoft.Identity.Client/PublicApi/net472/PublicAPI.Unshipped.txt +++ b/src/client/Microsoft.Identity.Client/PublicApi/net472/PublicAPI.Unshipped.txt @@ -0,0 +1 @@ +Microsoft.Identity.Client.AcquireTokenForClientParameterBuilder.WithExtraClientAssertionClaims(string claimsToSign) -> 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 e69de29bb2..8f2d422155 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 @@ -0,0 +1 @@ +Microsoft.Identity.Client.AcquireTokenForClientParameterBuilder.WithExtraClientAssertionClaims(string claimsToSign) -> 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 e69de29bb2..8f2d422155 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 @@ -0,0 +1 @@ +Microsoft.Identity.Client.AcquireTokenForClientParameterBuilder.WithExtraClientAssertionClaims(string claimsToSign) -> 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 e69de29bb2..8f2d422155 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 @@ -0,0 +1 @@ +Microsoft.Identity.Client.AcquireTokenForClientParameterBuilder.WithExtraClientAssertionClaims(string claimsToSign) -> 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 e69de29bb2..8f2d422155 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 @@ -0,0 +1 @@ +Microsoft.Identity.Client.AcquireTokenForClientParameterBuilder.WithExtraClientAssertionClaims(string claimsToSign) -> 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 9ef9714065..c85ef1a61b 100644 --- a/tests/Microsoft.Identity.Test.Integration.netcore/HeadlessTests/ClientCredentialsTests.NetFwk.cs +++ b/tests/Microsoft.Identity.Test.Integration.netcore/HeadlessTests/ClientCredentialsTests.NetFwk.cs @@ -392,11 +392,13 @@ private static IConfidentialClientApplication CreateApp( break; case CredentialType.ClientClaims_ExtraClaims: +#pragma warning disable CS0618 // Type or member is obsolete builder.WithClientClaims(settings.Certificate, GetClaims(true), mergeWithDefaultClaims: false, sendX5C: sendX5C); break; case CredentialType.ClientClaims_MergeClaims: builder.WithClientClaims(settings.Certificate, 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..99d16581c7 100644 --- a/tests/Microsoft.Identity.Test.Unit/PublicApiTests/ClientCredentialWithCertTest.cs +++ b/tests/Microsoft.Identity.Test.Unit/PublicApiTests/ClientCredentialWithCertTest.cs @@ -188,6 +188,7 @@ public async Task TestX5C( if (appFlag.HasValue) { +#pragma warning disable CS0618 // Type or member is obsolete appBuilder = appBuilder.WithClientClaims( certificate, claimsToSign, @@ -199,6 +200,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 +238,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 +275,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 +1113,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 +1157,265 @@ 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) + .WithExperimentalFeatures() + .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 + Assert.AreEqual("value1", assertion.Claims.FirstOrDefault(c => c.Type == "claim1")?.Value); + Assert.AreEqual("value2", assertion.Claims.FirstOrDefault(c => c.Type == "claim2")?.Value); + }; + + string extraClaims = "{\"claim1\":\"value1\",\"claim2\":\"value2\"}"; + + AuthenticationResult result = await app.AcquireTokenForClient(TestConstants.s_scope) + .WithExtraClientAssertionClaims(extraClaims) + .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) + .WithExperimentalFeatures() + .BuildConcrete(); + + // First request with claim set 1 + harness.HttpManager.AddTokenResponse(TokenResponseType.Valid_ClientCredentials); + string extraClaims1 = "{\"claim1\":\"value1\"}"; + + AuthenticationResult result1 = await app.AcquireTokenForClient(TestConstants.s_scope) + .WithExtraClientAssertionClaims(extraClaims1) + .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) + .WithExperimentalFeatures() + .BuildConcrete(); + + harness.HttpManager.AddTokenResponse(TokenResponseType.Valid_ClientCredentials); + string extraClaims = "{\"claim1\":\"value1\"}"; + + // First request + AuthenticationResult result1 = await app.AcquireTokenForClient(TestConstants.s_scope) + .WithExtraClientAssertionClaims(extraClaims) + .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(extraClaims) + .ExecuteAsync() + .ConfigureAwait(false); + + Assert.IsNotNull(result2.AccessToken); + Assert.AreEqual(TokenSource.Cache, result2.AuthenticationResultMetadata.TokenSource); + Assert.AreEqual(result1.AccessToken, result2.AccessToken); + } + } + + [TestMethod] + public void WithExtraClientAssertionClaims_NullClaims_ThrowsException() + { + using (var harness = CreateTestHarness()) + { + var certificate = CertHelper.GetOrCreateTestCert(); + + var app = ConfidentialClientApplicationBuilder + .Create(TestConstants.ClientId) + .WithAuthority("https://login.microsoftonline.com/tid") + .WithHttpManager(harness.HttpManager) + .WithCertificate(certificate) + .WithExperimentalFeatures() + .Build(); + + var exception = Assert.ThrowsException(() => + { + app.AcquireTokenForClient(TestConstants.s_scope) + .WithExtraClientAssertionClaims(null); + }); + + Assert.IsTrue(exception.Message.Contains("claimsToSign")); + } + } + + [TestMethod] + public void WithExtraClientAssertionClaims_EmptyClaims_ThrowsException() + { + using (var harness = CreateTestHarness()) + { + var certificate = CertHelper.GetOrCreateTestCert(); + + var app = ConfidentialClientApplicationBuilder + .Create(TestConstants.ClientId) + .WithAuthority("https://login.microsoftonline.com/tid") + .WithHttpManager(harness.HttpManager) + .WithCertificate(certificate) + .WithExperimentalFeatures() + .Build(); + + var exception = Assert.ThrowsException(() => + { + app.AcquireTokenForClient(TestConstants.s_scope) + .WithExtraClientAssertionClaims(""); + }); + + Assert.IsTrue(exception.Message.Contains("claimsToSign")); + } + } + + [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) + .WithExperimentalFeatures() + .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 nestedClaim = assertion.Claims.FirstOrDefault(c => c.Type == "nested_claim"); + Assert.IsNotNull(nestedClaim, "nested_claim should be present"); + + // Parse the nested claim value as JSON + var jsonElement = JsonSerializer.Deserialize(nestedClaim.Value); + Assert.AreEqual(JsonValueKind.Object, jsonElement.ValueKind, "nested_claim should be a JSON object"); + }; + + string extraClaims = "{\"nested_claim\":{\"sub_claim1\":\"value1\",\"sub_claim2\":[\"a\",\"b\",\"c\"]}}"; + + AuthenticationResult result = await app.AcquireTokenForClient(TestConstants.s_scope) + .WithExtraClientAssertionClaims(extraClaims) + .ExecuteAsync() + .ConfigureAwait(false); + + Assert.IsNotNull(result.AccessToken); + Assert.IsNotNull(assertion); + } + } + + [TestMethod] + public async Task WithExtraClientAssertionClaims_WithClientSecret_Async() + { + using (var harness = CreateTestHarness()) + { + harness.HttpManager.AddInstanceDiscoveryMockHandler(); + + var app = ConfidentialClientApplicationBuilder + .Create(TestConstants.ClientId) + .WithAuthority("https://login.microsoftonline.com/tid") + .WithHttpManager(harness.HttpManager) + .WithClientSecret(TestConstants.ClientSecret) + .WithExperimentalFeatures() + .Build(); + + harness.HttpManager.AddTokenResponse(TokenResponseType.Valid_ClientCredentials); + + string extraClaims = "{\"claim1\":\"value1\"}"; + + // WithExtraClientAssertionClaims should work with client secret + // (though it won't have any effect since client secret doesn't use assertions) + AuthenticationResult result = await app.AcquireTokenForClient(TestConstants.s_scope) + .WithExtraClientAssertionClaims(extraClaims) + .ExecuteAsync() + .ConfigureAwait(false); + + Assert.IsNotNull(result.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(() => From e7997b30898acafef0deeeb8300824dfa866eb69 Mon Sep 17 00:00:00 2001 From: trwalke Date: Thu, 15 Jan 2026 03:10:56 -0800 Subject: [PATCH 2/7] Moving api to extension file. --- .../AcquireTokenForClientParameterBuilder.cs | 32 ---- .../AcquireTokenForClientBuilderExtensions.cs | 34 +++++ .../Internal/JsonWebToken.cs | 35 ++++- .../PublicApi/net462/PublicAPI.Unshipped.txt | 2 +- .../PublicApi/net472/PublicAPI.Unshipped.txt | 2 +- .../net8.0-android/PublicAPI.Unshipped.txt | 2 +- .../net8.0-ios/PublicAPI.Unshipped.txt | 2 +- .../PublicApi/net8.0/PublicAPI.Unshipped.txt | 2 +- .../netstandard2.0/PublicAPI.Unshipped.txt | 2 +- .../ClientCredentialWithCertTest.cs | 143 +++++------------- 10 files changed, 109 insertions(+), 147 deletions(-) diff --git a/src/client/Microsoft.Identity.Client/ApiConfig/AcquireTokenForClientParameterBuilder.cs b/src/client/Microsoft.Identity.Client/ApiConfig/AcquireTokenForClientParameterBuilder.cs index 3f0e0cb7da..281011e8d8 100644 --- a/src/client/Microsoft.Identity.Client/ApiConfig/AcquireTokenForClientParameterBuilder.cs +++ b/src/client/Microsoft.Identity.Client/ApiConfig/AcquireTokenForClientParameterBuilder.cs @@ -155,38 +155,6 @@ public AcquireTokenForClientParameterBuilder WithFmiPath(string pathSuffix) return this; } - /// - /// 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. - /// - /// Additional claims in JSON format to be signed in the client assertion. - /// The builder to chain the .With methods - /// Thrown when claimsToSign is null or whitespace. - public AcquireTokenForClientParameterBuilder WithExtraClientAssertionClaims(string claimsToSign) - { - ValidateUseOfExperimentalFeature(); - - if (string.IsNullOrWhiteSpace(claimsToSign)) - { - throw new ArgumentNullException(nameof(claimsToSign)); - } - - CommonParameters.ExtraClientAssertionClaims = claimsToSign; - - // 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(claimsToSign) } - }; - - this.WithAdditionalCacheKeyComponents(cacheKey); - - return this; - } - /// internal override Task ExecuteInternalAsync(CancellationToken cancellationToken) { diff --git a/src/client/Microsoft.Identity.Client/Extensibility/AcquireTokenForClientBuilderExtensions.cs b/src/client/Microsoft.Identity.Client/Extensibility/AcquireTokenForClientBuilderExtensions.cs index 032d77d60e..20942bb179 100644 --- a/src/client/Microsoft.Identity.Client/Extensibility/AcquireTokenForClientBuilderExtensions.cs +++ b/src/client/Microsoft.Identity.Client/Extensibility/AcquireTokenForClientBuilderExtensions.cs @@ -72,5 +72,39 @@ public static AcquireTokenForClientParameterBuilder WithExtraBodyParameters( builder.WithAdditionalCacheKeyComponents(extrabodyparams); 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. + /// + /// + /// Additional claims in JSON format to be signed in the client assertion. + /// The builder to chain the .With methods + /// Thrown when claimsToSign is null or whitespace. + public static AcquireTokenForClientParameterBuilder WithExtraClientAssertionClaims( + this AcquireTokenForClientParameterBuilder builder, + string claimsToSign) + { + + if (string.IsNullOrWhiteSpace(claimsToSign)) + { + throw new ArgumentNullException(nameof(claimsToSign)); + } + + builder.CommonParameters.ExtraClientAssertionClaims = claimsToSign; + + // 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(claimsToSign) } + }; + + builder.WithAdditionalCacheKeyComponents(cacheKey); + + return builder; + } } } diff --git a/src/client/Microsoft.Identity.Client/Internal/JsonWebToken.cs b/src/client/Microsoft.Identity.Client/Internal/JsonWebToken.cs index 0808e035de..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 @@ -102,6 +103,27 @@ private string CreateJsonPayload() // 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) @@ -113,6 +135,7 @@ private string CreateJsonPayload() //Remove extra brackets from JSON result payload.Append(jsonClaims.Substring(1, jsonClaims.Length - 2)); +#endif } payload.Append('}'); @@ -179,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/PublicApi/net462/PublicAPI.Unshipped.txt b/src/client/Microsoft.Identity.Client/PublicApi/net462/PublicAPI.Unshipped.txt index 8f2d422155..ccbf75651c 100644 --- a/src/client/Microsoft.Identity.Client/PublicApi/net462/PublicAPI.Unshipped.txt +++ b/src/client/Microsoft.Identity.Client/PublicApi/net462/PublicAPI.Unshipped.txt @@ -1 +1 @@ -Microsoft.Identity.Client.AcquireTokenForClientParameterBuilder.WithExtraClientAssertionClaims(string claimsToSign) -> Microsoft.Identity.Client.AcquireTokenForClientParameterBuilder +static Microsoft.Identity.Client.Extensibility.AcquireTokenForClientBuilderExtensions.WithExtraClientAssertionClaims(this Microsoft.Identity.Client.AcquireTokenForClientParameterBuilder builder, string claimsToSign) -> 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 8f2d422155..ccbf75651c 100644 --- a/src/client/Microsoft.Identity.Client/PublicApi/net472/PublicAPI.Unshipped.txt +++ b/src/client/Microsoft.Identity.Client/PublicApi/net472/PublicAPI.Unshipped.txt @@ -1 +1 @@ -Microsoft.Identity.Client.AcquireTokenForClientParameterBuilder.WithExtraClientAssertionClaims(string claimsToSign) -> Microsoft.Identity.Client.AcquireTokenForClientParameterBuilder +static Microsoft.Identity.Client.Extensibility.AcquireTokenForClientBuilderExtensions.WithExtraClientAssertionClaims(this Microsoft.Identity.Client.AcquireTokenForClientParameterBuilder builder, string claimsToSign) -> 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 8f2d422155..ccbf75651c 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 +1 @@ -Microsoft.Identity.Client.AcquireTokenForClientParameterBuilder.WithExtraClientAssertionClaims(string claimsToSign) -> Microsoft.Identity.Client.AcquireTokenForClientParameterBuilder +static Microsoft.Identity.Client.Extensibility.AcquireTokenForClientBuilderExtensions.WithExtraClientAssertionClaims(this Microsoft.Identity.Client.AcquireTokenForClientParameterBuilder builder, string claimsToSign) -> 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 8f2d422155..ccbf75651c 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 +1 @@ -Microsoft.Identity.Client.AcquireTokenForClientParameterBuilder.WithExtraClientAssertionClaims(string claimsToSign) -> Microsoft.Identity.Client.AcquireTokenForClientParameterBuilder +static Microsoft.Identity.Client.Extensibility.AcquireTokenForClientBuilderExtensions.WithExtraClientAssertionClaims(this Microsoft.Identity.Client.AcquireTokenForClientParameterBuilder builder, string claimsToSign) -> 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 8f2d422155..ccbf75651c 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 +1 @@ -Microsoft.Identity.Client.AcquireTokenForClientParameterBuilder.WithExtraClientAssertionClaims(string claimsToSign) -> Microsoft.Identity.Client.AcquireTokenForClientParameterBuilder +static Microsoft.Identity.Client.Extensibility.AcquireTokenForClientBuilderExtensions.WithExtraClientAssertionClaims(this Microsoft.Identity.Client.AcquireTokenForClientParameterBuilder builder, string claimsToSign) -> 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 8f2d422155..ccbf75651c 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 +1 @@ -Microsoft.Identity.Client.AcquireTokenForClientParameterBuilder.WithExtraClientAssertionClaims(string claimsToSign) -> Microsoft.Identity.Client.AcquireTokenForClientParameterBuilder +static Microsoft.Identity.Client.Extensibility.AcquireTokenForClientBuilderExtensions.WithExtraClientAssertionClaims(this Microsoft.Identity.Client.AcquireTokenForClientParameterBuilder builder, string claimsToSign) -> Microsoft.Identity.Client.AcquireTokenForClientParameterBuilder diff --git a/tests/Microsoft.Identity.Test.Unit/PublicApiTests/ClientCredentialWithCertTest.cs b/tests/Microsoft.Identity.Test.Unit/PublicApiTests/ClientCredentialWithCertTest.cs index 99d16581c7..a6d9ea3555 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() @@ -1170,7 +1173,6 @@ public async Task WithExtraClientAssertionClaims_AddsClaimsToCacheKey_Async() .WithAuthority("https://login.microsoftonline.com/tid") .WithHttpManager(harness.HttpManager) .WithCertificate(certificate) - .WithExperimentalFeatures() .Build(); var handler = harness.HttpManager.AddTokenResponse(TokenResponseType.Valid_ClientCredentials); @@ -1186,14 +1188,24 @@ public async Task WithExtraClientAssertionClaims_AddsClaimsToCacheKey_Async() assertion = jwtHandler.ReadJwtToken(encodedJwt); // Validate extra claims are present - Assert.AreEqual("value1", assertion.Claims.FirstOrDefault(c => c.Type == "claim1")?.Value); - Assert.AreEqual("value2", assertion.Claims.FirstOrDefault(c => c.Type == "claim2")?.Value); - }; + var customClaimsClaim = assertion.Claims.FirstOrDefault(c => c.Type == "custom_claims"); + Assert.IsNotNull(customClaimsClaim, "custom_claims should be present"); - string extraClaims = "{\"claim1\":\"value1\",\"claim2\":\"value2\"}"; + // 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(extraClaims) + .WithExtraClientAssertionClaims(_clientAssertionClaims) .ExecuteAsync() .ConfigureAwait(false); @@ -1215,15 +1227,13 @@ public async Task WithExtraClientAssertionClaims_DifferentClaims_ResultsInDiffer .WithAuthority("https://login.microsoftonline.com/tid") .WithHttpManager(harness.HttpManager) .WithCertificate(certificate) - .WithExperimentalFeatures() .BuildConcrete(); - // First request with claim set 1 + // First request with _clientAssertionClaims harness.HttpManager.AddTokenResponse(TokenResponseType.Valid_ClientCredentials); - string extraClaims1 = "{\"claim1\":\"value1\"}"; AuthenticationResult result1 = await app.AcquireTokenForClient(TestConstants.s_scope) - .WithExtraClientAssertionClaims(extraClaims1) + .WithExtraClientAssertionClaims(_clientAssertionClaims) .ExecuteAsync() .ConfigureAwait(false); @@ -1260,15 +1270,13 @@ public async Task WithExtraClientAssertionClaims_SameClaims_UsesCache_Async() .WithAuthority("https://login.microsoftonline.com/tid") .WithHttpManager(harness.HttpManager) .WithCertificate(certificate) - .WithExperimentalFeatures() .BuildConcrete(); harness.HttpManager.AddTokenResponse(TokenResponseType.Valid_ClientCredentials); - string extraClaims = "{\"claim1\":\"value1\"}"; // First request AuthenticationResult result1 = await app.AcquireTokenForClient(TestConstants.s_scope) - .WithExtraClientAssertionClaims(extraClaims) + .WithExtraClientAssertionClaims(_clientAssertionClaims) .ExecuteAsync() .ConfigureAwait(false); @@ -1277,7 +1285,7 @@ public async Task WithExtraClientAssertionClaims_SameClaims_UsesCache_Async() // Second request with same claims should use cache AuthenticationResult result2 = await app.AcquireTokenForClient(TestConstants.s_scope) - .WithExtraClientAssertionClaims(extraClaims) + .WithExtraClientAssertionClaims(_clientAssertionClaims) .ExecuteAsync() .ConfigureAwait(false); @@ -1287,56 +1295,6 @@ public async Task WithExtraClientAssertionClaims_SameClaims_UsesCache_Async() } } - [TestMethod] - public void WithExtraClientAssertionClaims_NullClaims_ThrowsException() - { - using (var harness = CreateTestHarness()) - { - var certificate = CertHelper.GetOrCreateTestCert(); - - var app = ConfidentialClientApplicationBuilder - .Create(TestConstants.ClientId) - .WithAuthority("https://login.microsoftonline.com/tid") - .WithHttpManager(harness.HttpManager) - .WithCertificate(certificate) - .WithExperimentalFeatures() - .Build(); - - var exception = Assert.ThrowsException(() => - { - app.AcquireTokenForClient(TestConstants.s_scope) - .WithExtraClientAssertionClaims(null); - }); - - Assert.IsTrue(exception.Message.Contains("claimsToSign")); - } - } - - [TestMethod] - public void WithExtraClientAssertionClaims_EmptyClaims_ThrowsException() - { - using (var harness = CreateTestHarness()) - { - var certificate = CertHelper.GetOrCreateTestCert(); - - var app = ConfidentialClientApplicationBuilder - .Create(TestConstants.ClientId) - .WithAuthority("https://login.microsoftonline.com/tid") - .WithHttpManager(harness.HttpManager) - .WithCertificate(certificate) - .WithExperimentalFeatures() - .Build(); - - var exception = Assert.ThrowsException(() => - { - app.AcquireTokenForClient(TestConstants.s_scope) - .WithExtraClientAssertionClaims(""); - }); - - Assert.IsTrue(exception.Message.Contains("claimsToSign")); - } - } - [TestMethod] public async Task WithExtraClientAssertionClaims_ComplexNestedClaims_Async() { @@ -1350,7 +1308,6 @@ public async Task WithExtraClientAssertionClaims_ComplexNestedClaims_Async() .WithAuthority("https://login.microsoftonline.com/tid") .WithHttpManager(harness.HttpManager) .WithCertificate(certificate) - .WithExperimentalFeatures() .Build(); var handler = harness.HttpManager.AddTokenResponse(TokenResponseType.Valid_ClientCredentials); @@ -1366,18 +1323,29 @@ public async Task WithExtraClientAssertionClaims_ComplexNestedClaims_Async() assertion = jwtHandler.ReadJwtToken(encodedJwt); // Validate nested claims - var nestedClaim = assertion.Claims.FirstOrDefault(c => c.Type == "nested_claim"); - Assert.IsNotNull(nestedClaim, "nested_claim should be 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(nestedClaim.Value); - Assert.AreEqual(JsonValueKind.Object, jsonElement.ValueKind, "nested_claim should be a JSON object"); + 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()); }; - string extraClaims = "{\"nested_claim\":{\"sub_claim1\":\"value1\",\"sub_claim2\":[\"a\",\"b\",\"c\"]}}"; - AuthenticationResult result = await app.AcquireTokenForClient(TestConstants.s_scope) - .WithExtraClientAssertionClaims(extraClaims) + .WithExtraClientAssertionClaims(_clientAssertionClaims) .ExecuteAsync() .ConfigureAwait(false); @@ -1385,37 +1353,6 @@ public async Task WithExtraClientAssertionClaims_ComplexNestedClaims_Async() Assert.IsNotNull(assertion); } } - - [TestMethod] - public async Task WithExtraClientAssertionClaims_WithClientSecret_Async() - { - using (var harness = CreateTestHarness()) - { - harness.HttpManager.AddInstanceDiscoveryMockHandler(); - - var app = ConfidentialClientApplicationBuilder - .Create(TestConstants.ClientId) - .WithAuthority("https://login.microsoftonline.com/tid") - .WithHttpManager(harness.HttpManager) - .WithClientSecret(TestConstants.ClientSecret) - .WithExperimentalFeatures() - .Build(); - - harness.HttpManager.AddTokenResponse(TokenResponseType.Valid_ClientCredentials); - - string extraClaims = "{\"claim1\":\"value1\"}"; - - // WithExtraClientAssertionClaims should work with client secret - // (though it won't have any effect since client secret doesn't use assertions) - AuthenticationResult result = await app.AcquireTokenForClient(TestConstants.s_scope) - .WithExtraClientAssertionClaims(extraClaims) - .ExecuteAsync() - .ConfigureAwait(false); - - Assert.IsNotNull(result.AccessToken); - } - } - private void BeforeCacheAccess(TokenCacheNotificationArgs args) { args.TokenCache.DeserializeMsalV3(_serializedCache); From 6d72979d59a5f6893c0cb23c4bd77cd0a6f27454 Mon Sep 17 00:00:00 2001 From: trwalke Date: Fri, 16 Jan 2026 08:35:24 -0800 Subject: [PATCH 3/7] Moving api to abstract CC builder --- ...ntAcquireTokenParameterBuilderExtension.cs | 34 ++++ .../AcquireTokenForClientBuilderExtensions.cs | 34 ---- .../PublicApi/net462/PublicAPI.Unshipped.txt | 2 +- .../PublicApi/net472/PublicAPI.Unshipped.txt | 2 +- .../net8.0-android/PublicAPI.Unshipped.txt | 2 +- .../net8.0-ios/PublicAPI.Unshipped.txt | 2 +- .../PublicApi/net8.0/PublicAPI.Unshipped.txt | 2 +- .../netstandard2.0/PublicAPI.Unshipped.txt | 2 +- .../ClientCredentialWithCertTest.cs | 147 ++++++++++++++++++ 9 files changed, 187 insertions(+), 40 deletions(-) diff --git a/src/client/Microsoft.Identity.Client/Extensibility/AbstractConfidentialClientAcquireTokenParameterBuilderExtension.cs b/src/client/Microsoft.Identity.Client/Extensibility/AbstractConfidentialClientAcquireTokenParameterBuilderExtension.cs index be3c53c80c..6fc70836fd 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 claimsToSign is null or whitespace. + public static AbstractAcquireTokenParameterBuilder WithExtraClientAssertionClaims( + this AbstractAcquireTokenParameterBuilder builder, + string claimsToSign) + where T : AbstractAcquireTokenParameterBuilder + { + if (string.IsNullOrWhiteSpace(claimsToSign)) + { + throw new ArgumentNullException(nameof(claimsToSign)); + } + + builder.CommonParameters.ExtraClientAssertionClaims = claimsToSign; + + // 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(claimsToSign) } + }; + + WithAdditionalCacheKeyComponents(builder, cacheKey); + + return builder; + } } } diff --git a/src/client/Microsoft.Identity.Client/Extensibility/AcquireTokenForClientBuilderExtensions.cs b/src/client/Microsoft.Identity.Client/Extensibility/AcquireTokenForClientBuilderExtensions.cs index 20942bb179..032d77d60e 100644 --- a/src/client/Microsoft.Identity.Client/Extensibility/AcquireTokenForClientBuilderExtensions.cs +++ b/src/client/Microsoft.Identity.Client/Extensibility/AcquireTokenForClientBuilderExtensions.cs @@ -72,39 +72,5 @@ public static AcquireTokenForClientParameterBuilder WithExtraBodyParameters( builder.WithAdditionalCacheKeyComponents(extrabodyparams); 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. - /// - /// - /// Additional claims in JSON format to be signed in the client assertion. - /// The builder to chain the .With methods - /// Thrown when claimsToSign is null or whitespace. - public static AcquireTokenForClientParameterBuilder WithExtraClientAssertionClaims( - this AcquireTokenForClientParameterBuilder builder, - string claimsToSign) - { - - if (string.IsNullOrWhiteSpace(claimsToSign)) - { - throw new ArgumentNullException(nameof(claimsToSign)); - } - - builder.CommonParameters.ExtraClientAssertionClaims = claimsToSign; - - // 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(claimsToSign) } - }; - - builder.WithAdditionalCacheKeyComponents(cacheKey); - - return builder; - } } } 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 ccbf75651c..2d4899e325 100644 --- a/src/client/Microsoft.Identity.Client/PublicApi/net462/PublicAPI.Unshipped.txt +++ b/src/client/Microsoft.Identity.Client/PublicApi/net462/PublicAPI.Unshipped.txt @@ -1 +1 @@ -static Microsoft.Identity.Client.Extensibility.AcquireTokenForClientBuilderExtensions.WithExtraClientAssertionClaims(this Microsoft.Identity.Client.AcquireTokenForClientParameterBuilder builder, string claimsToSign) -> Microsoft.Identity.Client.AcquireTokenForClientParameterBuilder +static Microsoft.Identity.Client.Extensibility.AbstractConfidentialClientAcquireTokenParameterBuilderExtension.WithExtraClientAssertionClaims(this Microsoft.Identity.Client.AbstractAcquireTokenParameterBuilder builder, string claimsToSign) -> Microsoft.Identity.Client.AbstractAcquireTokenParameterBuilder 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 ccbf75651c..2d4899e325 100644 --- a/src/client/Microsoft.Identity.Client/PublicApi/net472/PublicAPI.Unshipped.txt +++ b/src/client/Microsoft.Identity.Client/PublicApi/net472/PublicAPI.Unshipped.txt @@ -1 +1 @@ -static Microsoft.Identity.Client.Extensibility.AcquireTokenForClientBuilderExtensions.WithExtraClientAssertionClaims(this Microsoft.Identity.Client.AcquireTokenForClientParameterBuilder builder, string claimsToSign) -> Microsoft.Identity.Client.AcquireTokenForClientParameterBuilder +static Microsoft.Identity.Client.Extensibility.AbstractConfidentialClientAcquireTokenParameterBuilderExtension.WithExtraClientAssertionClaims(this Microsoft.Identity.Client.AbstractAcquireTokenParameterBuilder builder, string claimsToSign) -> Microsoft.Identity.Client.AbstractAcquireTokenParameterBuilder 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 ccbf75651c..2d4899e325 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 +1 @@ -static Microsoft.Identity.Client.Extensibility.AcquireTokenForClientBuilderExtensions.WithExtraClientAssertionClaims(this Microsoft.Identity.Client.AcquireTokenForClientParameterBuilder builder, string claimsToSign) -> Microsoft.Identity.Client.AcquireTokenForClientParameterBuilder +static Microsoft.Identity.Client.Extensibility.AbstractConfidentialClientAcquireTokenParameterBuilderExtension.WithExtraClientAssertionClaims(this Microsoft.Identity.Client.AbstractAcquireTokenParameterBuilder builder, string claimsToSign) -> Microsoft.Identity.Client.AbstractAcquireTokenParameterBuilder 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 ccbf75651c..2d4899e325 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 +1 @@ -static Microsoft.Identity.Client.Extensibility.AcquireTokenForClientBuilderExtensions.WithExtraClientAssertionClaims(this Microsoft.Identity.Client.AcquireTokenForClientParameterBuilder builder, string claimsToSign) -> Microsoft.Identity.Client.AcquireTokenForClientParameterBuilder +static Microsoft.Identity.Client.Extensibility.AbstractConfidentialClientAcquireTokenParameterBuilderExtension.WithExtraClientAssertionClaims(this Microsoft.Identity.Client.AbstractAcquireTokenParameterBuilder builder, string claimsToSign) -> Microsoft.Identity.Client.AbstractAcquireTokenParameterBuilder 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 ccbf75651c..2d4899e325 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 +1 @@ -static Microsoft.Identity.Client.Extensibility.AcquireTokenForClientBuilderExtensions.WithExtraClientAssertionClaims(this Microsoft.Identity.Client.AcquireTokenForClientParameterBuilder builder, string claimsToSign) -> Microsoft.Identity.Client.AcquireTokenForClientParameterBuilder +static Microsoft.Identity.Client.Extensibility.AbstractConfidentialClientAcquireTokenParameterBuilderExtension.WithExtraClientAssertionClaims(this Microsoft.Identity.Client.AbstractAcquireTokenParameterBuilder builder, string claimsToSign) -> Microsoft.Identity.Client.AbstractAcquireTokenParameterBuilder 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 ccbf75651c..2d4899e325 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 +1 @@ -static Microsoft.Identity.Client.Extensibility.AcquireTokenForClientBuilderExtensions.WithExtraClientAssertionClaims(this Microsoft.Identity.Client.AcquireTokenForClientParameterBuilder builder, string claimsToSign) -> Microsoft.Identity.Client.AcquireTokenForClientParameterBuilder +static Microsoft.Identity.Client.Extensibility.AbstractConfidentialClientAcquireTokenParameterBuilderExtension.WithExtraClientAssertionClaims(this Microsoft.Identity.Client.AbstractAcquireTokenParameterBuilder builder, string claimsToSign) -> Microsoft.Identity.Client.AbstractAcquireTokenParameterBuilder diff --git a/tests/Microsoft.Identity.Test.Unit/PublicApiTests/ClientCredentialWithCertTest.cs b/tests/Microsoft.Identity.Test.Unit/PublicApiTests/ClientCredentialWithCertTest.cs index a6d9ea3555..5ee16c8c0e 100644 --- a/tests/Microsoft.Identity.Test.Unit/PublicApiTests/ClientCredentialWithCertTest.cs +++ b/tests/Microsoft.Identity.Test.Unit/PublicApiTests/ClientCredentialWithCertTest.cs @@ -1353,6 +1353,153 @@ public async Task WithExtraClientAssertionClaims_ComplexNestedClaims_Async() 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); + + // Verify we have 2 tokens in cache (OBO uses user token cache) + Assert.AreEqual(2, app.UserTokenCacheInternal.Accessor.GetAllAccessTokens().Count()); + } + } + + [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); From 9134286819b1f962d07480095511afaf4ca695f4 Mon Sep 17 00:00:00 2001 From: trwalke Date: Tue, 20 Jan 2026 19:54:02 -0800 Subject: [PATCH 4/7] Making not browsable --- .../AppConfig/ConfidentialClientApplicationBuilder.cs | 2 ++ .../ClientCredential/CertificateAndClaimsClientCredential.cs | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/client/Microsoft.Identity.Client/AppConfig/ConfidentialClientApplicationBuilder.cs b/src/client/Microsoft.Identity.Client/AppConfig/ConfidentialClientApplicationBuilder.cs index 6b4746eeab..f724eacf93 100644 --- a/src/client/Microsoft.Identity.Client/AppConfig/ConfidentialClientApplicationBuilder.cs +++ b/src/client/Microsoft.Identity.Client/AppConfig/ConfidentialClientApplicationBuilder.cs @@ -143,6 +143,7 @@ public ConfidentialClientApplicationBuilder WithCertificate(X509Certificate2 cer /// 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); @@ -159,6 +160,7 @@ public ConfidentialClientApplicationBuilder WithClientClaims(X509Certificate2 ce /// 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/Internal/ClientCredential/CertificateAndClaimsClientCredential.cs b/src/client/Microsoft.Identity.Client/Internal/ClientCredential/CertificateAndClaimsClientCredential.cs index cf31483eb0..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; From 38c5bb195bd0b084c84e75cfbcb6cd23dce082f8 Mon Sep 17 00:00:00 2001 From: trwalke Date: Wed, 21 Jan 2026 08:52:11 -0800 Subject: [PATCH 5/7] Changing name of parameter --- ...lClientAcquireTokenParameterBuilderExtension.cs | 14 +++++++------- .../PublicApi/net462/PublicAPI.Unshipped.txt | 2 +- .../PublicApi/net472/PublicAPI.Unshipped.txt | 2 +- .../net8.0-android/PublicAPI.Unshipped.txt | 2 +- .../PublicApi/net8.0-ios/PublicAPI.Unshipped.txt | 2 +- .../PublicApi/net8.0/PublicAPI.Unshipped.txt | 2 +- .../netstandard2.0/PublicAPI.Unshipped.txt | 2 +- 7 files changed, 13 insertions(+), 13 deletions(-) diff --git a/src/client/Microsoft.Identity.Client/Extensibility/AbstractConfidentialClientAcquireTokenParameterBuilderExtension.cs b/src/client/Microsoft.Identity.Client/Extensibility/AbstractConfidentialClientAcquireTokenParameterBuilderExtension.cs index 6fc70836fd..1657e2f257 100644 --- a/src/client/Microsoft.Identity.Client/Extensibility/AbstractConfidentialClientAcquireTokenParameterBuilderExtension.cs +++ b/src/client/Microsoft.Identity.Client/Extensibility/AbstractConfidentialClientAcquireTokenParameterBuilderExtension.cs @@ -213,25 +213,25 @@ public static AbstractAcquireTokenParameterBuilder WithFmiPathForClientAssert /// 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. + /// Additional claims in JSON format to be signed in the client assertion. /// The builder to chain the .With methods - /// Thrown when claimsToSign is null or whitespace. + /// Thrown when clientAssertionClaims is null or whitespace. public static AbstractAcquireTokenParameterBuilder WithExtraClientAssertionClaims( this AbstractAcquireTokenParameterBuilder builder, - string claimsToSign) + string clientAssertionClaims) where T : AbstractAcquireTokenParameterBuilder { - if (string.IsNullOrWhiteSpace(claimsToSign)) + if (string.IsNullOrWhiteSpace(clientAssertionClaims)) { - throw new ArgumentNullException(nameof(claimsToSign)); + throw new ArgumentNullException(nameof(clientAssertionClaims)); } - builder.CommonParameters.ExtraClientAssertionClaims = claimsToSign; + 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(claimsToSign) } + { "extra_client_assertion_claims", (CancellationToken ct) => Task.FromResult(clientAssertionClaims) } }; WithAdditionalCacheKeyComponents(builder, cacheKey); 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 2d4899e325..5b9306326d 100644 --- a/src/client/Microsoft.Identity.Client/PublicApi/net462/PublicAPI.Unshipped.txt +++ b/src/client/Microsoft.Identity.Client/PublicApi/net462/PublicAPI.Unshipped.txt @@ -1 +1 @@ -static Microsoft.Identity.Client.Extensibility.AbstractConfidentialClientAcquireTokenParameterBuilderExtension.WithExtraClientAssertionClaims(this Microsoft.Identity.Client.AbstractAcquireTokenParameterBuilder builder, string claimsToSign) -> Microsoft.Identity.Client.AbstractAcquireTokenParameterBuilder +static Microsoft.Identity.Client.Extensibility.AbstractConfidentialClientAcquireTokenParameterBuilderExtension.WithExtraClientAssertionClaims(this Microsoft.Identity.Client.AbstractAcquireTokenParameterBuilder builder, string clientAssertionClaims) -> Microsoft.Identity.Client.AbstractAcquireTokenParameterBuilder 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 2d4899e325..5b9306326d 100644 --- a/src/client/Microsoft.Identity.Client/PublicApi/net472/PublicAPI.Unshipped.txt +++ b/src/client/Microsoft.Identity.Client/PublicApi/net472/PublicAPI.Unshipped.txt @@ -1 +1 @@ -static Microsoft.Identity.Client.Extensibility.AbstractConfidentialClientAcquireTokenParameterBuilderExtension.WithExtraClientAssertionClaims(this Microsoft.Identity.Client.AbstractAcquireTokenParameterBuilder builder, string claimsToSign) -> Microsoft.Identity.Client.AbstractAcquireTokenParameterBuilder +static Microsoft.Identity.Client.Extensibility.AbstractConfidentialClientAcquireTokenParameterBuilderExtension.WithExtraClientAssertionClaims(this Microsoft.Identity.Client.AbstractAcquireTokenParameterBuilder builder, string clientAssertionClaims) -> Microsoft.Identity.Client.AbstractAcquireTokenParameterBuilder 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 2d4899e325..5b9306326d 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 +1 @@ -static Microsoft.Identity.Client.Extensibility.AbstractConfidentialClientAcquireTokenParameterBuilderExtension.WithExtraClientAssertionClaims(this Microsoft.Identity.Client.AbstractAcquireTokenParameterBuilder builder, string claimsToSign) -> Microsoft.Identity.Client.AbstractAcquireTokenParameterBuilder +static Microsoft.Identity.Client.Extensibility.AbstractConfidentialClientAcquireTokenParameterBuilderExtension.WithExtraClientAssertionClaims(this Microsoft.Identity.Client.AbstractAcquireTokenParameterBuilder builder, string clientAssertionClaims) -> Microsoft.Identity.Client.AbstractAcquireTokenParameterBuilder 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 2d4899e325..5b9306326d 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 +1 @@ -static Microsoft.Identity.Client.Extensibility.AbstractConfidentialClientAcquireTokenParameterBuilderExtension.WithExtraClientAssertionClaims(this Microsoft.Identity.Client.AbstractAcquireTokenParameterBuilder builder, string claimsToSign) -> Microsoft.Identity.Client.AbstractAcquireTokenParameterBuilder +static Microsoft.Identity.Client.Extensibility.AbstractConfidentialClientAcquireTokenParameterBuilderExtension.WithExtraClientAssertionClaims(this Microsoft.Identity.Client.AbstractAcquireTokenParameterBuilder builder, string clientAssertionClaims) -> Microsoft.Identity.Client.AbstractAcquireTokenParameterBuilder 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 2d4899e325..5b9306326d 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 +1 @@ -static Microsoft.Identity.Client.Extensibility.AbstractConfidentialClientAcquireTokenParameterBuilderExtension.WithExtraClientAssertionClaims(this Microsoft.Identity.Client.AbstractAcquireTokenParameterBuilder builder, string claimsToSign) -> Microsoft.Identity.Client.AbstractAcquireTokenParameterBuilder +static Microsoft.Identity.Client.Extensibility.AbstractConfidentialClientAcquireTokenParameterBuilderExtension.WithExtraClientAssertionClaims(this Microsoft.Identity.Client.AbstractAcquireTokenParameterBuilder builder, string clientAssertionClaims) -> Microsoft.Identity.Client.AbstractAcquireTokenParameterBuilder 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 2d4899e325..5b9306326d 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 +1 @@ -static Microsoft.Identity.Client.Extensibility.AbstractConfidentialClientAcquireTokenParameterBuilderExtension.WithExtraClientAssertionClaims(this Microsoft.Identity.Client.AbstractAcquireTokenParameterBuilder builder, string claimsToSign) -> Microsoft.Identity.Client.AbstractAcquireTokenParameterBuilder +static Microsoft.Identity.Client.Extensibility.AbstractConfidentialClientAcquireTokenParameterBuilderExtension.WithExtraClientAssertionClaims(this Microsoft.Identity.Client.AbstractAcquireTokenParameterBuilder builder, string clientAssertionClaims) -> Microsoft.Identity.Client.AbstractAcquireTokenParameterBuilder From e086b802b0806f053d22b27b4018acffd17a3492 Mon Sep 17 00:00:00 2001 From: trwalke Date: Thu, 22 Jan 2026 22:23:40 -0800 Subject: [PATCH 6/7] Fixing build errors --- .../ApiConfig/Parameters/AcquireTokenCommonParameters.cs | 1 - .../HeadlessTests/ClientCredentialsTests.NetFwk.cs | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/src/client/Microsoft.Identity.Client/ApiConfig/Parameters/AcquireTokenCommonParameters.cs b/src/client/Microsoft.Identity.Client/ApiConfig/Parameters/AcquireTokenCommonParameters.cs index eba9d94c11..28e732efa6 100644 --- a/src/client/Microsoft.Identity.Client/ApiConfig/Parameters/AcquireTokenCommonParameters.cs +++ b/src/client/Microsoft.Identity.Client/ApiConfig/Parameters/AcquireTokenCommonParameters.cs @@ -42,7 +42,6 @@ internal class AcquireTokenCommonParameters public string ClientAssertionFmiPath { get; internal set; } public bool IsMtlsPopRequested { get; set; } public string ExtraClientAssertionClaims { get; internal set; } - internal Func> AttestationTokenProvider { get; set; } /// /// Optional delegate for obtaining attestation JWT for Credential Guard keys. 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 3b2da1979f..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,7 +443,7 @@ 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; From 89dd6d8dd0aa9947f399c46db0b6e300f165352a Mon Sep 17 00:00:00 2001 From: trwalke Date: Thu, 22 Jan 2026 22:30:54 -0800 Subject: [PATCH 7/7] Fixing test --- .../PublicApiTests/ClientCredentialWithCertTest.cs | 3 --- 1 file changed, 3 deletions(-) diff --git a/tests/Microsoft.Identity.Test.Unit/PublicApiTests/ClientCredentialWithCertTest.cs b/tests/Microsoft.Identity.Test.Unit/PublicApiTests/ClientCredentialWithCertTest.cs index 5ee16c8c0e..02a068d152 100644 --- a/tests/Microsoft.Identity.Test.Unit/PublicApiTests/ClientCredentialWithCertTest.cs +++ b/tests/Microsoft.Identity.Test.Unit/PublicApiTests/ClientCredentialWithCertTest.cs @@ -1455,9 +1455,6 @@ public async Task WithExtraClientAssertionClaims_OboFlow_DifferentClaims_Results Assert.IsNotNull(result2.AccessToken); Assert.AreEqual(TokenSource.IdentityProvider, result2.AuthenticationResultMetadata.TokenSource); - - // Verify we have 2 tokens in cache (OBO uses user token cache) - Assert.AreEqual(2, app.UserTokenCacheInternal.Accessor.GetAllAccessTokens().Count()); } }