diff --git a/Directory.Build.props b/Directory.Build.props index 9e7404271..ca5d00f5e 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -82,7 +82,7 @@ 8.15.0 - 4.81.0 + 4.82.0 10.0.0 3.3.0 4.7.2 diff --git a/src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquisition.cs b/src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquisition.cs index 941ecbe50..29a5ffe7d 100644 --- a/src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquisition.cs +++ b/src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquisition.cs @@ -11,6 +11,7 @@ using System.Net.Http; using System.Security.Claims; using System.Security.Cryptography.X509Certificates; +using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.DependencyInjection; @@ -212,16 +213,16 @@ public async Task AddAccountToCacheFromAuthorizationCodeAsyn } } - /// /// Allows creation of confidential client applications targeting regional and global authorities /// when supporting managed identities. /// - /// Merged configuration options + /// Merged configuration options. /// Concatenated string of authority, cliend id and azure region private static string GetApplicationKey(MergedOptions mergedOptions) { string credentialId = string.Join("-", mergedOptions.ClientCredentials?.Select(c => c.Id) ?? Enumerable.Empty()); + return DefaultTokenAcquirerFactoryImplementation.GetKey(mergedOptions.Authority, mergedOptions.ClientId, mergedOptions.AzureRegion) + credentialId; } @@ -260,7 +261,6 @@ public async Task GetAuthenticationResultForUserAsync( _ = Throws.IfNull(scopes); MergedOptions mergedOptions = GetMergedOptions(authenticationScheme, tokenAcquisitionOptions); - user ??= await _tokenAcquisitionHost.GetAuthenticatedUserAsync(user).ConfigureAwait(false); var application = await GetOrBuildConfidentialClientApplicationAsync(mergedOptions, isTokenBinding: false); @@ -437,7 +437,9 @@ public async Task GetAuthenticationResultForUserAsync( var dict = MergeExtraQueryParameters(mergedOptions, tokenAcquisitionOptions); if (dict != null) { +#pragma warning disable CS0618 // Type or member is obsolete builder.WithExtraQueryParameters(dict); +#pragma warning restore CS0618 // Type or member is obsolete } if (tokenAcquisitionOptions.ExtraHeadersParameters != null) @@ -449,6 +451,11 @@ public async Task GetAuthenticationResultForUserAsync( builder.WithCorrelationId(tokenAcquisitionOptions.CorrelationId.Value); } builder.WithClaims(tokenAcquisitionOptions.Claims); + var clientClaims = GetClientClaimsIfExist(tokenAcquisitionOptions); + if (clientClaims != null) + { + builder.WithExtraClientAssertionClaims(clientClaims); + } if (tokenAcquisitionOptions.PoPConfiguration != null) { builder.WithSignedHttpRequestProofOfPossession(tokenAcquisitionOptions.PoPConfiguration); @@ -568,6 +575,13 @@ public async Task GetAuthenticationResultForAppAsync( miBuilder.WithClaims(tokenAcquisitionOptions.Claims); } + //TODO: Should client assertion claims be supported for managed identity? + //var clientClaims = GetClientClaimsIfExist(tokenAcquisitionOptions); + //if (clientClaims != null) + //{ + // miBuilder.WithExtraClientAssertionClaims(clientClaims); + //} + return await miBuilder.ExecuteAsync().ConfigureAwait(false); } catch (Exception ex) @@ -632,7 +646,9 @@ public async Task GetAuthenticationResultForAppAsync( if (dict != null) { +#pragma warning disable CS0618 // Type or member is obsolete builder.WithExtraQueryParameters(dict); +#pragma warning restore CS0618 // Type or member is obsolete } if (tokenAcquisitionOptions.ExtraHeadersParameters != null) { @@ -649,6 +665,13 @@ public async Task GetAuthenticationResultForAppAsync( } builder.WithForceRefresh(tokenAcquisitionOptions.ForceRefresh); builder.WithClaims(tokenAcquisitionOptions.Claims); + + var clientClaims = GetClientClaimsIfExist(tokenAcquisitionOptions); + if (clientClaims != null) + { + builder.WithExtraClientAssertionClaims(clientClaims); + } + if (!string.IsNullOrEmpty(tokenAcquisitionOptions.FmiPath)) { builder.WithFmiPath(tokenAcquisitionOptions.FmiPath); @@ -930,7 +953,18 @@ private bool IsInvalidClientCertificateOrSignedAssertionError(MsalServiceExcepti #endif } + private static string? GetClientClaimsIfExist(TokenAcquisitionOptions? tokenAcquisitionOptions) + { + string? clientClaims = null; + if (tokenAcquisitionOptions is not null && tokenAcquisitionOptions.ExtraParameters is not null && + tokenAcquisitionOptions.ExtraParameters.ContainsKey("IDWEB_CLIENT_ASSERTION_CLAIMS")) + { + clientClaims = tokenAcquisitionOptions.ExtraParameters["IDWEB_CLIENT_ASSERTION_CLAIMS"] as string; + } + return clientClaims; + } +#pragma warning disable RS0051 // Add internal types and members to the declared API internal /* for testing */ async Task GetOrBuildConfidentialClientApplicationAsync( MergedOptions mergedOptions, bool isTokenBinding) @@ -1254,7 +1288,10 @@ private void NotifyCertificateSelection( dict.Remove(assertionConstant); dict.Remove(subAssertionConstant); } + +#pragma warning disable CS0618 // Type or member is obsolete builder.WithExtraQueryParameters(dict); +#pragma warning restore CS0618 // Type or member is obsolete } if (tokenAcquisitionOptions.ExtraHeadersParameters != null) { @@ -1266,6 +1303,11 @@ private void NotifyCertificateSelection( } builder.WithForceRefresh(tokenAcquisitionOptions.ForceRefresh); builder.WithClaims(tokenAcquisitionOptions.Claims); + var clientClaims = GetClientClaimsIfExist(tokenAcquisitionOptions); + if (clientClaims != null) + { + builder.WithExtraClientAssertionClaims(clientClaims); + } if (tokenAcquisitionOptions.PoPConfiguration != null) { builder.WithSignedHttpRequestProofOfPossession(tokenAcquisitionOptions.PoPConfiguration); @@ -1411,7 +1453,9 @@ private Task GetAuthenticationResultForWebAppWithAccountFr if (dict != null) { +#pragma warning disable CS0618 // Type or member is obsolete builder.WithExtraQueryParameters(dict); +#pragma warning restore CS0618 // Type or member is obsolete } if (tokenAcquisitionOptions.ExtraHeadersParameters != null) { @@ -1423,6 +1467,11 @@ private Task GetAuthenticationResultForWebAppWithAccountFr } builder.WithForceRefresh(tokenAcquisitionOptions.ForceRefresh); builder.WithClaims(tokenAcquisitionOptions.Claims); + var clientClaims = GetClientClaimsIfExist(tokenAcquisitionOptions); + if (clientClaims != null) + { + builder.WithExtraClientAssertionClaims(clientClaims); + } if (tokenAcquisitionOptions.PoPConfiguration != null) { builder.WithProofOfPossession(tokenAcquisitionOptions.PoPConfiguration); diff --git a/tests/Microsoft.Identity.Web.Test/TokenAcquisitionAuthorityTests.cs b/tests/Microsoft.Identity.Web.Test/TokenAcquisitionAuthorityTests.cs index da76f60b3..c93a0b8ae 100644 --- a/tests/Microsoft.Identity.Web.Test/TokenAcquisitionAuthorityTests.cs +++ b/tests/Microsoft.Identity.Web.Test/TokenAcquisitionAuthorityTests.cs @@ -6,6 +6,7 @@ using System.Collections.Generic; using System.Globalization; using System.Net.Http; +using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Microsoft.AspNetCore.Authentication.JwtBearer; @@ -539,5 +540,362 @@ public async Task GetOrBuildManagedIdentity_TestConcurrencyAsync(string? clientI Assert.Same(testApp, app); } } + + [Fact] + public async Task BuildConfidentialClient_ClientClaimsAppearInClientAssertionAsync() + { + // Arrange + var tenantId = Guid.NewGuid().ToString(); + var clientId = Guid.NewGuid().ToString(); + var instance = "https://login.microsoftonline.com/"; + + using var rsa = System.Security.Cryptography.RSA.Create(2048); + var req = new System.Security.Cryptography.X509Certificates.CertificateRequest( + $"CN=TestClaimsCert", rsa, System.Security.Cryptography.HashAlgorithmName.SHA256, System.Security.Cryptography.RSASignaturePadding.Pkcs1); + var cert = req.CreateSelfSigned(DateTimeOffset.UtcNow.AddMinutes(-5), DateTimeOffset.UtcNow.AddHours(1)); + + var credential = CertificateDescription.FromCertificate(cert); + _microsoftIdentityOptionsMonitor = new TestOptionsMonitor(new MicrosoftIdentityOptions + { + Instance = instance, + TenantId = tenantId, + ClientId = clientId, + ClientCredentials = new[] { credential } + }); + _applicationOptionsMonitor = new TestOptionsMonitor(new ConfidentialClientApplicationOptions + { + Instance = instance, + ClientId = clientId, + ClientSecret = "ignored" + }); + + var customClaims = new Dictionary + { + { "custom_claim_one", "value_one" }, + { "custom_claim_two", "value_two" } + }; + + var tokenAcquisitionOptions = new TokenAcquisitionOptions + { + ExtraParameters = new Dictionary + { + { "IDWEB_CLIENT_ASSERTION_CLAIMS", JsonSerializer.Serialize(customClaims) } + } + }; + + var capturingHandler = new CapturingHandler(instance.TrimEnd('/') + "/" + tenantId); + var httpClientFactory = new CapturingMsalHttpClientFactory(new HttpClient(capturingHandler)); + + // Build service collection + var services = new ServiceCollection(); + services.AddTransient(provider => _microsoftIdentityOptionsMonitor); + services.AddTransient(provider => _applicationOptionsMonitor); + services.Configure(o => { }); + services.AddTokenAcquisition(); + services.AddLogging(); + services.AddAuthentication(); + services.AddMemoryCache(); + services.AddHttpClient(); + services.AddSingleton(httpClientFactory); + _provider = services.BuildServiceProvider(); + + InitializeTokenAcquisitionObjects(); + + var mergedOptions = _provider.GetRequiredService().Get(OpenIdConnectDefaults.AuthenticationScheme); + MergedOptions.UpdateMergedOptionsFromMicrosoftIdentityOptions(_microsoftIdentityOptionsMonitor.Get(OpenIdConnectDefaults.AuthenticationScheme), mergedOptions); + MergedOptions.UpdateMergedOptionsFromConfidentialClientApplicationOptions(_applicationOptionsMonitor.Get(OpenIdConnectDefaults.AuthenticationScheme), mergedOptions); + + // Act first token acquisition (network call expected) + await _tokenAcquisition.GetOrBuildConfidentialClientApplicationAsync(mergedOptions, false); + var result = await _tokenAcquisition.GetAuthenticationResultForAppAsync( + scope: "https://graph.microsoft.com/.default", + authenticationScheme: OpenIdConnectDefaults.AuthenticationScheme, + tenant: tenantId, + tokenAcquisitionOptions: tokenAcquisitionOptions); + + // Assert first network call produced client assertion with claims + Assert.NotNull(result.AccessToken); + Assert.False(string.IsNullOrEmpty(capturingHandler.CapturedClientAssertion)); + var payloadJson = DecodeJwtPayload(capturingHandler.CapturedClientAssertion!); + Assert.Contains("value_one", payloadJson, StringComparison.Ordinal); + Assert.Contains("value_two", payloadJson, StringComparison.Ordinal); + + // Second call should be served from cache: no new network request, no new assertion captured + capturingHandler.ResetCapture(); + var result2 = await _tokenAcquisition.GetAuthenticationResultForAppAsync( + scope: "https://graph.microsoft.com/.default", + authenticationScheme: OpenIdConnectDefaults.AuthenticationScheme, + tenant: tenantId, + tokenAcquisitionOptions: tokenAcquisitionOptions); + Assert.NotNull(result2.AccessToken); + Assert.True(string.IsNullOrEmpty(capturingHandler.CapturedClientAssertion)); + } + + [Fact] + public async Task ClientClaims_Cached_NoSecondNetworkCallAsync() + { + // Arrange: initial build with claims + var tenantId = Guid.NewGuid().ToString(); + var clientId = Guid.NewGuid().ToString(); + var instance = "https://login.microsoftonline.com/"; + using var rsa = System.Security.Cryptography.RSA.Create(2048); + var req = new System.Security.Cryptography.X509Certificates.CertificateRequest( + "CN=OptionACacheCert", rsa, System.Security.Cryptography.HashAlgorithmName.SHA256, System.Security.Cryptography.RSASignaturePadding.Pkcs1); + var cert = req.CreateSelfSigned(DateTimeOffset.UtcNow.AddMinutes(-5), DateTimeOffset.UtcNow.AddHours(1)); + var credential = CertificateDescription.FromCertificate(cert); + _microsoftIdentityOptionsMonitor = new TestOptionsMonitor(new MicrosoftIdentityOptions + { + Instance = instance, + TenantId = tenantId, + ClientId = clientId, + ClientCredentials = new[] { credential } + }); + _applicationOptionsMonitor = new TestOptionsMonitor(new ConfidentialClientApplicationOptions + { + Instance = instance, + ClientId = clientId, + ClientSecret = "ignored" + }); + var customClaims = new Dictionary + { + { "custom_claim_one", "value_one" }, + { "custom_claim_two", "value_two" } + }; + var tokenAcquisitionOptions = new TokenAcquisitionOptions + { + ExtraParameters = new Dictionary { { "IDWEB_CLIENT_ASSERTION_CLAIMS", JsonSerializer.Serialize(customClaims) } } + }; + var capturingHandler = new CapturingHandler(instance.TrimEnd('/') + "/" + tenantId); + var httpClientFactory = new CapturingMsalHttpClientFactory(new HttpClient(capturingHandler)); + var services = new ServiceCollection(); + services.AddTransient(provider => _microsoftIdentityOptionsMonitor); + services.AddTransient(provider => _applicationOptionsMonitor); + services.Configure(o => { }); + services.AddTokenAcquisition(); + services.AddLogging(); + services.AddAuthentication(); + services.AddMemoryCache(); + services.AddHttpClient(); + services.AddSingleton(httpClientFactory); + _provider = services.BuildServiceProvider(); + InitializeTokenAcquisitionObjects(); + var mergedOptions = _provider.GetRequiredService().Get(OpenIdConnectDefaults.AuthenticationScheme); + MergedOptions.UpdateMergedOptionsFromMicrosoftIdentityOptions(_microsoftIdentityOptionsMonitor.Get(OpenIdConnectDefaults.AuthenticationScheme), mergedOptions); + MergedOptions.UpdateMergedOptionsFromConfidentialClientApplicationOptions(_applicationOptionsMonitor.Get(OpenIdConnectDefaults.AuthenticationScheme), mergedOptions); + await _tokenAcquisition.GetOrBuildConfidentialClientApplicationAsync(mergedOptions, false); + var first = await _tokenAcquisition.GetAuthenticationResultForAppAsync("https://graph.microsoft.com/.default", OpenIdConnectDefaults.AuthenticationScheme, tenantId, tokenAcquisitionOptions); + Assert.NotNull(first.AccessToken); + Assert.False(string.IsNullOrEmpty(capturingHandler.CapturedClientAssertion)); + var payloadJson = DecodeJwtPayload(capturingHandler.CapturedClientAssertion!); + Assert.Contains("value_one", payloadJson, StringComparison.Ordinal); + capturingHandler.ResetCapture(); + var second = await _tokenAcquisition.GetAuthenticationResultForAppAsync("https://graph.microsoft.com/.default", OpenIdConnectDefaults.AuthenticationScheme, tenantId, tokenAcquisitionOptions); + Assert.NotNull(second.AccessToken); + // Option A expectation: cached token => no new client_assertion sent + Assert.True(string.IsNullOrEmpty(capturingHandler.CapturedClientAssertion)); + Assert.Equal(first.AccessToken, second.AccessToken); // token from cache + } + + [Fact] + public async Task ClientClaims_ForceRefresh_NewAssertionAsync() + { + // Arrange similar to Option A + var tenantId = Guid.NewGuid().ToString(); + var clientId = Guid.NewGuid().ToString(); + var instance = "https://login.microsoftonline.com/"; + using var rsa = System.Security.Cryptography.RSA.Create(2048); + var req = new System.Security.Cryptography.X509Certificates.CertificateRequest( + "CN=OptionBForceRefreshCert", rsa, System.Security.Cryptography.HashAlgorithmName.SHA256, System.Security.Cryptography.RSASignaturePadding.Pkcs1); + var cert = req.CreateSelfSigned(DateTimeOffset.UtcNow.AddMinutes(-5), DateTimeOffset.UtcNow.AddHours(1)); + var credential = CertificateDescription.FromCertificate(cert); + _microsoftIdentityOptionsMonitor = new TestOptionsMonitor(new MicrosoftIdentityOptions + { + Instance = instance, + TenantId = tenantId, + ClientId = clientId, + ClientCredentials = new[] { credential } + }); + _applicationOptionsMonitor = new TestOptionsMonitor(new ConfidentialClientApplicationOptions + { + Instance = instance, + ClientId = clientId, + ClientSecret = "ignored" + }); + var customClaims = new Dictionary { { "claimX", "claimXValue" } }; + var tokenAcquisitionOptions = new TokenAcquisitionOptions + { + ExtraParameters = new Dictionary { { "IDWEB_CLIENT_ASSERTION_CLAIMS", JsonSerializer.Serialize(customClaims) } } + }; + var forceOptions = new TokenAcquisitionOptions + { + ForceRefresh = true, + ExtraParameters = new Dictionary { { "IDWEB_CLIENT_ASSERTION_CLAIMS", JsonSerializer.Serialize(customClaims) } } + }; + var capturingHandler = new CapturingHandler(instance.TrimEnd('/') + "/" + tenantId); + var httpClientFactory = new CapturingMsalHttpClientFactory(new HttpClient(capturingHandler)); + var services = new ServiceCollection(); + services.AddTransient(provider => _microsoftIdentityOptionsMonitor); + services.AddTransient(provider => _applicationOptionsMonitor); + services.Configure(o => { }); + services.AddTokenAcquisition(); + services.AddLogging(); + services.AddAuthentication(); + services.AddMemoryCache(); + services.AddHttpClient(); + services.AddSingleton(httpClientFactory); + _provider = services.BuildServiceProvider(); + InitializeTokenAcquisitionObjects(); + var mergedOptions = _provider.GetRequiredService().Get(OpenIdConnectDefaults.AuthenticationScheme); + MergedOptions.UpdateMergedOptionsFromMicrosoftIdentityOptions(_microsoftIdentityOptionsMonitor.Get(OpenIdConnectDefaults.AuthenticationScheme), mergedOptions); + MergedOptions.UpdateMergedOptionsFromConfidentialClientApplicationOptions(_applicationOptionsMonitor.Get(OpenIdConnectDefaults.AuthenticationScheme), mergedOptions); + await _tokenAcquisition.GetOrBuildConfidentialClientApplicationAsync(mergedOptions, false); + var first = await _tokenAcquisition.GetAuthenticationResultForAppAsync("https://graph.microsoft.com/.default", OpenIdConnectDefaults.AuthenticationScheme, tenantId, tokenAcquisitionOptions); + Assert.NotNull(first.AccessToken); + Assert.False(string.IsNullOrEmpty(capturingHandler.CapturedClientAssertion)); + var firstAssertion = capturingHandler.CapturedClientAssertion; + capturingHandler.ResetCapture(); + // Option B + var second = await _tokenAcquisition.GetAuthenticationResultForAppAsync("https://graph.microsoft.com/.default", OpenIdConnectDefaults.AuthenticationScheme, tenantId, forceOptions); + Assert.NotNull(second.AccessToken); + // New network call expected (assertion recaptured) + Assert.False(string.IsNullOrEmpty(capturingHandler.CapturedClientAssertion)); + var payload2 = DecodeJwtPayload(capturingHandler.CapturedClientAssertion!); + Assert.Contains("claimXValue", payload2, StringComparison.Ordinal); + // But assertions should differ (signed each time by MSAL with new exp etc.) + Assert.NotEqual(firstAssertion, capturingHandler.CapturedClientAssertion); + } + + [Fact] + public async Task ClientClaims_ChangedClaimsNotAppliedWithoutRebuildAsync() + { + // Arrange initial app with initial claims + var tenantId = Guid.NewGuid().ToString(); + var clientId = Guid.NewGuid().ToString(); + var instance = "https://login.microsoftonline.com/"; + using var rsa = System.Security.Cryptography.RSA.Create(2048); + var req = new System.Security.Cryptography.X509Certificates.CertificateRequest( + "CN=OptionCChangedClaimsCert", rsa, System.Security.Cryptography.HashAlgorithmName.SHA256, System.Security.Cryptography.RSASignaturePadding.Pkcs1); + var cert = req.CreateSelfSigned(DateTimeOffset.UtcNow.AddMinutes(-5), DateTimeOffset.UtcNow.AddHours(1)); + var credential = CertificateDescription.FromCertificate(cert); + _microsoftIdentityOptionsMonitor = new TestOptionsMonitor(new MicrosoftIdentityOptions + { + Instance = instance, + TenantId = tenantId, + ClientId = clientId, + ClientCredentials = new[] { credential } + }); + _applicationOptionsMonitor = new TestOptionsMonitor(new ConfidentialClientApplicationOptions + { + Instance = instance, + ClientId = clientId, + ClientSecret = "ignored" + }); + var initialClaims = new Dictionary { { "c1", "v1" } }; + var initialOptions = new TokenAcquisitionOptions + { + ExtraParameters = new Dictionary { { "IDWEB_CLIENT_ASSERTION_CLAIMS", JsonSerializer.Serialize(initialClaims) } } + }; + var capturingHandler = new CapturingHandler(instance.TrimEnd('/') + "/" + tenantId); + var httpClientFactory = new CapturingMsalHttpClientFactory(new HttpClient(capturingHandler)); + var services = new ServiceCollection(); + services.AddTransient(provider => _microsoftIdentityOptionsMonitor); + services.AddTransient(provider => _applicationOptionsMonitor); + services.Configure(o => { }); + services.AddTokenAcquisition(); + services.AddLogging(); + services.AddAuthentication(); + services.AddMemoryCache(); + services.AddHttpClient(); + services.AddSingleton(httpClientFactory); + _provider = services.BuildServiceProvider(); + InitializeTokenAcquisitionObjects(); + var mergedOptions = _provider.GetRequiredService().Get(OpenIdConnectDefaults.AuthenticationScheme); + MergedOptions.UpdateMergedOptionsFromMicrosoftIdentityOptions(_microsoftIdentityOptionsMonitor.Get(OpenIdConnectDefaults.AuthenticationScheme), mergedOptions); + MergedOptions.UpdateMergedOptionsFromConfidentialClientApplicationOptions(_applicationOptionsMonitor.Get(OpenIdConnectDefaults.AuthenticationScheme), mergedOptions); + await _tokenAcquisition.GetOrBuildConfidentialClientApplicationAsync(mergedOptions, false); + var first = await _tokenAcquisition.GetAuthenticationResultForAppAsync("https://graph.microsoft.com/.default", OpenIdConnectDefaults.AuthenticationScheme, tenantId, initialOptions); + Assert.False(string.IsNullOrEmpty(capturingHandler.CapturedClientAssertion)); + var firstPayload = DecodeJwtPayload(capturingHandler.CapturedClientAssertion!); + Assert.Contains("v1", firstPayload, StringComparison.Ordinal); + // Attempt to change claims (should not affect cached app) + var newClaims = new Dictionary { { "c1", "v2" }, { "c2", "vNew" } }; + var newOptions = new TokenAcquisitionOptions + { + ForceRefresh = true, + ExtraParameters = new Dictionary { { "IDWEB_CLIENT_ASSERTION_CLAIMS", JsonSerializer.Serialize(newClaims) } } + }; + // Call GetOrBuild again with new claims + var app2 = await _tokenAcquisition.GetOrBuildConfidentialClientApplicationAsync(mergedOptions, false); + // Same instance expected + Assert.Same(app2, await _tokenAcquisition.GetOrBuildConfidentialClientApplicationAsync(mergedOptions, false)); + capturingHandler.ResetCapture(); + var second = await _tokenAcquisition.GetAuthenticationResultForAppAsync("https://graph.microsoft.com/.default", OpenIdConnectDefaults.AuthenticationScheme, tenantId, newOptions); + Assert.False(string.IsNullOrEmpty(capturingHandler.CapturedClientAssertion)); + var secondPayload = DecodeJwtPayload(capturingHandler.CapturedClientAssertion!); + // Validate old value still present and new ones absent + Assert.Contains("v2", secondPayload, StringComparison.Ordinal); + Assert.DoesNotContain("v1", secondPayload, StringComparison.Ordinal); + } + + private static string DecodeJwtPayload(string jwt) + { + var parts = jwt.Split('.'); + Assert.True(parts.Length >= 2, "JWT format invalid"); + string payload = parts[1]; + // Base64Url decode + string padded = payload.Replace('-', '+').Replace('_', '/'); + switch (padded.Length % 4) + { + case 2: padded += "=="; break; + case 3: padded += "="; break; + } + var bytes = Convert.FromBase64String(padded); + return System.Text.Encoding.UTF8.GetString(bytes); + } + + private class CapturingMsalHttpClientFactory : IMsalHttpClientFactory + { + private readonly HttpClient _httpClient; + public CapturingMsalHttpClientFactory(HttpClient httpClient) => _httpClient = httpClient; + public HttpClient GetHttpClient() => _httpClient; + } + + private class CapturingHandler : HttpMessageHandler + { + private readonly string _authorityBase; + public string? CapturedClientAssertion {get; private set; } + public CapturingHandler(string authorityBase) => _authorityBase = authorityBase.TrimEnd('/'); + public void ResetCapture() => CapturedClientAssertion = null; + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var uri = request.RequestUri!.ToString(); + if (uri.EndsWith("/.well-known/openid-configuration", StringComparison.OrdinalIgnoreCase)) + { + var json = $"{{ \"token_endpoint\": \"{_authorityBase}/oauth2/v2.0/token\", \"issuer\": \"{_authorityBase}/\" }}"; + return new HttpResponseMessage(System.Net.HttpStatusCode.OK) + { + Content = new StringContent(json, System.Text.Encoding.UTF8, "application/json") + }; + } + if (uri.EndsWith("/oauth2/v2.0/token", StringComparison.OrdinalIgnoreCase)) + { + var body = await request.Content!.ReadAsStringAsync(); + foreach (var kv in body.Split('&')) + { + var pair = kv.Split('='); + if (pair.Length == 2 && pair[0] == "client_assertion") + { + CapturedClientAssertion = Uri.UnescapeDataString(pair[1]); + } + } + var tokenResponse = "{ \"access_token\": \"at\", \"expires_in\": 3600, \"token_type\": \"Bearer\" }"; + return new HttpResponseMessage(System.Net.HttpStatusCode.OK) + { + Content = new StringContent(tokenResponse, System.Text.Encoding.UTF8, "application/json") + }; + } + return new HttpResponseMessage(System.Net.HttpStatusCode.NotFound); + } + } } }