Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
f97d2b2
Implement mTLS HTTP client factory
Nov 5, 2025
77bbd55
Implement authorization header provider for token with binding certif…
Nov 7, 2025
869484f
Add unit tests for downstream API changes
Nov 8, 2025
d2cf9af
Add token binding to token acquisition flow
Nov 10, 2025
bd33d88
Merge branch 'master' into iepoly/add-token-binding-scenario
Nov 10, 2025
16fe5a9
Update src/Microsoft.Identity.Web.DownstreamApi/DownstreamApi.cs
cpp11nullptr Nov 14, 2025
d8bcba1
Update src/Microsoft.Identity.Web.DownstreamApi/DownstreamApi.cs
cpp11nullptr Nov 14, 2025
5591d8d
Commit changes after code review comments
Nov 14, 2025
50e38f3
Merge branch 'iepoly/add-token-binding-scenario' of https://github.co…
Nov 14, 2025
d33b389
Exclude net462 from condition for SUPPORTS_MTLS
Nov 14, 2025
323f1db
Merge branch 'master' into iepoly/add-token-binding-scenario
Nov 14, 2025
6377bcf
Apply suggestions from code review
cpp11nullptr Nov 18, 2025
b316bd2
Set token binding flow through token acquisition extra parameters
Nov 20, 2025
b814843
Merge branch 'iepoly/add-token-binding-scenario' of https://github.co…
Nov 20, 2025
3351aaa
Fix typo in comment
Nov 21, 2025
817d593
Merge branch 'master' into iepoly/add-token-binding-scenario
Nov 21, 2025
7917c69
Update unit tests
Nov 21, 2025
672346b
Add E2E test for token acquirer
Nov 22, 2025
becef8a
Add mTLS PoP client and web API samples
Nov 24, 2025
1a475f4
Use AuthenticationOptionsName for enabling token binding flow during …
Nov 24, 2025
de0b580
Merge remote-tracking branch 'origin/master' into iepoly/add-token-bi…
Nov 24, 2025
97e0126
Update outdated test
Nov 24, 2025
661e88c
Don't dispose HTTP client managed by HTTP client factory
Nov 25, 2025
49fe240
Make mTLS HTTL client factory thread safe and prevent resource leak
Nov 25, 2025
eb5d369
Use read-write lock for mTLS HTTP client factory
Nov 28, 2025
7aa4242
Merge branch 'master' into iepoly/add-token-binding
cpp11nullptr Nov 28, 2025
f36b969
Pass token binding sentinel to token acquisition through extra parame…
Dec 1, 2025
bfb7820
Merge branch 'iepoly/add-token-binding' of https://github.com/AzureAD…
Dec 1, 2025
3c31632
Use upgradble read lock for mTLS PoP HTTP client factory
Dec 2, 2025
3499436
Add protocol schema check for token acquirer
Dec 3, 2025
3a43b1b
Use pre-boxed boolean and update stale tests
Dec 4, 2025
ba2cdd3
Use updated IBoundAuthorizationHeaderProvider interface
Dec 10, 2025
9187a30
Remove isTokenBinding property from MergedOptions
Dec 10, 2025
0bf9cb4
Use JsonWebToken instead of manual parsing in tests
Dec 10, 2025
bf958da
Merge branch 'master' into iepoly/add-token-binding
cpp11nullptr Jan 2, 2026
9a7e25c
Fix naming for private static member
Jan 5, 2026
e257de9
Merge branch 'iepoly/add-token-binding' of https://github.com/AzureAD…
Jan 5, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions Microsoft.Identity.Web.sln
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,12 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.Identity.Web.Side
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sidecar.Tests", "tests\E2E Tests\Sidecar.Tests\Sidecar.Tests.csproj", "{946E6BED-2A06-4FF4-3E39-22ACEB44A984}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "MtlsPop", "MtlsPop", "{06818CF6-16AD-4184-9264-B593B8F2AA25}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MtlsPopClient", "tests\DevApps\MtlsPop\MtlsPopClient\MtlsPopClient.csproj", "{3ECC1B78-A458-726F-D7B8-AB74733CCCDC}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MtlsPopWebApi", "tests\DevApps\MtlsPop\MtlsPopWebApi\MtlsPopWebApi.csproj", "{A61CEEDE-6F2C-0710-E008-B5F6F25D87D7}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Expand Down Expand Up @@ -418,6 +424,14 @@ Global
{946E6BED-2A06-4FF4-3E39-22ACEB44A984}.Debug|Any CPU.Build.0 = Debug|Any CPU
{946E6BED-2A06-4FF4-3E39-22ACEB44A984}.Release|Any CPU.ActiveCfg = Release|Any CPU
{946E6BED-2A06-4FF4-3E39-22ACEB44A984}.Release|Any CPU.Build.0 = Release|Any CPU
{3ECC1B78-A458-726F-D7B8-AB74733CCCDC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{3ECC1B78-A458-726F-D7B8-AB74733CCCDC}.Debug|Any CPU.Build.0 = Debug|Any CPU
{3ECC1B78-A458-726F-D7B8-AB74733CCCDC}.Release|Any CPU.ActiveCfg = Release|Any CPU
{3ECC1B78-A458-726F-D7B8-AB74733CCCDC}.Release|Any CPU.Build.0 = Release|Any CPU
{A61CEEDE-6F2C-0710-E008-B5F6F25D87D7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A61CEEDE-6F2C-0710-E008-B5F6F25D87D7}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A61CEEDE-6F2C-0710-E008-B5F6F25D87D7}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A61CEEDE-6F2C-0710-E008-B5F6F25D87D7}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
Expand Down Expand Up @@ -497,6 +511,9 @@ Global
{A8181404-23E0-D38B-454C-D16ECDB18B9F} = {E37CDBC1-18F6-4C06-A3EE-532C9106721F}
{55C81F88-0FFA-491C-A1D7-0ACA7212B59C} = {1DDE1AAC-5AE6-4725-94B6-A26C58D3423F}
{946E6BED-2A06-4FF4-3E39-22ACEB44A984} = {45B20A78-91F8-4DD2-B9AD-F12D3A93536C}
{06818CF6-16AD-4184-9264-B593B8F2AA25} = {7786D2DD-9EE4-42E1-B587-740A2E15C41D}
{3ECC1B78-A458-726F-D7B8-AB74733CCCDC} = {06818CF6-16AD-4184-9264-B593B8F2AA25}
{A61CEEDE-6F2C-0710-E008-B5F6F25D87D7} = {06818CF6-16AD-4184-9264-B593B8F2AA25}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {104367F1-CE75-4F40-B32F-F14853973187}
Expand Down
221 changes: 144 additions & 77 deletions src/Microsoft.Identity.Web.DownstreamApi/DownstreamApi.cs
Original file line number Diff line number Diff line change
@@ -1,22 +1,22 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Runtime.CompilerServices;
using System.Security.Claims;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization.Metadata;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Microsoft.Identity.Abstractions;
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Runtime.CompilerServices;
using System.Security.Claims;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization.Metadata;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Microsoft.Identity.Abstractions;
using Microsoft.Identity.Client;

namespace Microsoft.Identity.Web
Expand All @@ -26,11 +26,20 @@ internal partial class DownstreamApi : IDownstreamApi
{
private readonly IAuthorizationHeaderProvider _authorizationHeaderProvider;
private readonly IHttpClientFactory _httpClientFactory;

// This MSAL HTTP client factory is used to create HTTP clients with mTLS binding certificate.
// Note, that it doesn't replace _httpClientFactory to keep backward compatibility and ability
// to create named HTTP clients for non-mTLS scenarios.
private readonly IMsalHttpClientFactory? _msalHttpClientFactory;

private readonly IOptionsMonitor<DownstreamApiOptions> _namedDownstreamApiOptions;

private const string Authorization = "Authorization";
protected readonly ILogger<DownstreamApi> _logger;
private const string TokenBindingProtocolScheme = "MTLS_POP";
private const string AuthSchemeDstsSamlBearer = "http://schemas.microsoft.com/dsts/saml2-bearer";

protected readonly ILogger<DownstreamApi> _logger;

/// <summary>
/// Constructor.
/// </summary>
Expand All @@ -43,10 +52,33 @@ public DownstreamApi(
IOptionsMonitor<DownstreamApiOptions> namedDownstreamApiOptions,
IHttpClientFactory httpClientFactory,
ILogger<DownstreamApi> logger)
: this(authorizationHeaderProvider,
namedDownstreamApiOptions,
httpClientFactory,
logger,
msalHttpClientFactory: null)
{
}

/// <summary>
/// Constructor which accepts optional MSAL HTTP client factory.
/// </summary>
/// <param name="authorizationHeaderProvider">Authorization header provider.</param>
/// <param name="namedDownstreamApiOptions">Named options provider.</param>
/// <param name="httpClientFactory">HTTP client factory.</param>
/// <param name="logger">Logger.</param>
/// <param name="msalHttpClientFactory">The MSAL HTTP client factory for mTLS PoP scenarios.</param>
public DownstreamApi(
IAuthorizationHeaderProvider authorizationHeaderProvider,
IOptionsMonitor<DownstreamApiOptions> namedDownstreamApiOptions,
IHttpClientFactory httpClientFactory,
ILogger<DownstreamApi> logger,
IMsalHttpClientFactory? msalHttpClientFactory)
{
_authorizationHeaderProvider = authorizationHeaderProvider;
_namedDownstreamApiOptions = namedDownstreamApiOptions;
_httpClientFactory = httpClientFactory;
_msalHttpClientFactory = msalHttpClientFactory ?? new MsalMtlsHttpClientFactory(httpClientFactory);
_logger = logger;
}

Expand Down Expand Up @@ -436,7 +468,7 @@ public Task<HttpResponseMessage> CallApiForAppAsync(
string stringContent = await content.ReadAsStringAsync();
if (mediaType == "application/json")
{
return JsonSerializer.Deserialize<TOutput>(stringContent, new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
return JsonSerializer.Deserialize<TOutput>(stringContent, new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
}
if (mediaType != null && !mediaType.StartsWith("text/", StringComparison.OrdinalIgnoreCase))
{
Expand Down Expand Up @@ -514,11 +546,17 @@ public Task<HttpResponseMessage> CallApiForAppAsync(
new HttpMethod(effectiveOptions.HttpMethod),
apiUrl);

await UpdateRequestAsync(httpRequestMessage, content, effectiveOptions, appToken, user, cancellationToken);
// Request result will contain authorization header and potentially binding certificate for mTLS
var requestResult = await UpdateRequestAsync(httpRequestMessage, content, effectiveOptions, appToken, user, cancellationToken);

using HttpClient client = string.IsNullOrEmpty(serviceName) ? _httpClientFactory.CreateClient() : _httpClientFactory.CreateClient(serviceName);
// If a binding certificate is specified (which means mTLS is required) and MSAL mTLS HTTP factory is present
// then create an HttpClient with the certificate by using IMsalMtlsHttpClientFactory.
// Otherwise use the default HttpClientFactory with optional named client.
HttpClient client = requestResult?.BindingCertificate != null && _msalHttpClientFactory != null && _msalHttpClientFactory is IMsalMtlsHttpClientFactory msalMtlsHttpClientFactory
? msalMtlsHttpClientFactory.GetHttpClient(requestResult.BindingCertificate)
: (string.IsNullOrEmpty(serviceName) ? _httpClientFactory.CreateClient() : _httpClientFactory.CreateClient(serviceName));

// Send the HTTP message
// Send the HTTP message
var downstreamApiResult = await client.SendAsync(httpRequestMessage, cancellationToken).ConfigureAwait(false);

// Retry only if the resource sent 401 Unauthorized with WWW-Authenticate header and claims
Expand All @@ -541,7 +579,7 @@ public Task<HttpResponseMessage> CallApiForAppAsync(
return downstreamApiResult;
}

internal /* internal for test */ async Task UpdateRequestAsync(
internal /* internal for test */ async Task<AuthorizationHeaderInformation?> UpdateRequestAsync(
HttpRequestMessage httpRequestMessage,
HttpContent? content,
DownstreamApiOptions effectiveOptions,
Expand All @@ -558,15 +596,42 @@ public Task<HttpResponseMessage> CallApiForAppAsync(

effectiveOptions.RequestAppToken = appToken;

AuthorizationHeaderInformation? authorizationHeaderInformation = null;

// Obtention of the authorization header (except when calling an anonymous endpoint
// which is done by not specifying any scopes
if (effectiveOptions.Scopes != null && effectiveOptions.Scopes.Any())
{
string authorizationHeader = await _authorizationHeaderProvider.CreateAuthorizationHeaderAsync(
effectiveOptions.Scopes,
effectiveOptions,
user,
cancellationToken).ConfigureAwait(false);
string authorizationHeader = string.Empty;

// Firstly check if it's token binding scenario so authorization header provider returns
// a binding certificate along with acquired authorization header.
if (_authorizationHeaderProvider is IBoundAuthorizationHeaderProvider boundAuthorizationHeaderBoundProvider
&& string.Equals(effectiveOptions.ProtocolScheme, TokenBindingProtocolScheme, StringComparison.OrdinalIgnoreCase))
{
var authorizationHeaderResult = await boundAuthorizationHeaderBoundProvider.CreateBoundAuthorizationHeaderAsync(
effectiveOptions,
user,
cancellationToken).ConfigureAwait(false);

if (!authorizationHeaderResult.Succeeded)
{
// in theory it shouldn't happen because in case of error during token acquisition
// there will be thrown corresponding exception, so it's more a safeguard
throw new InvalidOperationException("Cannot acquire bound authorization header.");
}

authorizationHeaderInformation = authorizationHeaderResult.Result;
authorizationHeader = authorizationHeaderInformation?.AuthorizationHeaderValue!;
}
else
{
authorizationHeader = await _authorizationHeaderProvider.CreateAuthorizationHeaderAsync(
effectiveOptions.Scopes,
effectiveOptions,
user,
cancellationToken).ConfigureAwait(false);
}

if (authorizationHeader.StartsWith(AuthSchemeDstsSamlBearer, StringComparison.OrdinalIgnoreCase))
{
Expand All @@ -582,54 +647,56 @@ public Task<HttpResponseMessage> CallApiForAppAsync(
{
Logger.UnauthenticatedApiCall(_logger, null);
}
if (!string.IsNullOrEmpty(effectiveOptions.AcceptHeader))
{
httpRequestMessage.Headers.Accept.ParseAdd(effectiveOptions.AcceptHeader);
}

// Add extra headers if specified directly on DownstreamApiOptions
if (effectiveOptions.ExtraHeaderParameters != null)
{
foreach (var header in effectiveOptions.ExtraHeaderParameters)
{
httpRequestMessage.Headers.TryAddWithoutValidation(header.Key, header.Value);
}
}

// Add extra query parameters if specified directly on DownstreamApiOptions
if (effectiveOptions.ExtraQueryParameters != null && effectiveOptions.ExtraQueryParameters.Count > 0)
{
var uriBuilder = new UriBuilder(httpRequestMessage.RequestUri!);
var existingQuery = uriBuilder.Query;
var queryString = new StringBuilder(existingQuery);
foreach (var queryParam in effectiveOptions.ExtraQueryParameters)
{
if (queryString.Length > 1) // if there are existing query parameters
{
queryString.Append('&');
}
else if (queryString.Length == 0)
{
queryString.Append('?');
}
queryString.Append(Uri.EscapeDataString(queryParam.Key));
queryString.Append('=');
queryString.Append(Uri.EscapeDataString(queryParam.Value));
}
uriBuilder.Query = queryString.ToString().TrimStart('?');
httpRequestMessage.RequestUri = uriBuilder.Uri;
}

// Opportunity to change the request message
if (!string.IsNullOrEmpty(effectiveOptions.AcceptHeader))
{
httpRequestMessage.Headers.Accept.ParseAdd(effectiveOptions.AcceptHeader);
}
// Add extra headers if specified directly on DownstreamApiOptions
if (effectiveOptions.ExtraHeaderParameters != null)
{
foreach (var header in effectiveOptions.ExtraHeaderParameters)
{
httpRequestMessage.Headers.TryAddWithoutValidation(header.Key, header.Value);
}
}
// Add extra query parameters if specified directly on DownstreamApiOptions
if (effectiveOptions.ExtraQueryParameters != null && effectiveOptions.ExtraQueryParameters.Count > 0)
{
var uriBuilder = new UriBuilder(httpRequestMessage.RequestUri!);
var existingQuery = uriBuilder.Query;
var queryString = new StringBuilder(existingQuery);
foreach (var queryParam in effectiveOptions.ExtraQueryParameters)
{
if (queryString.Length > 1) // if there are existing query parameters
{
queryString.Append('&');
}
else if (queryString.Length == 0)
{
queryString.Append('?');
}
queryString.Append(Uri.EscapeDataString(queryParam.Key));
queryString.Append('=');
queryString.Append(Uri.EscapeDataString(queryParam.Value));
}
uriBuilder.Query = queryString.ToString().TrimStart('?');
httpRequestMessage.RequestUri = uriBuilder.Uri;
}
// Opportunity to change the request message
effectiveOptions.CustomizeHttpRequestMessage?.Invoke(httpRequestMessage);

return authorizationHeaderInformation;
}

internal /* for test */ static Dictionary<string, string> CallerSDKDetails { get; } = new()
{
{ "caller-sdk-id", "IdWeb_1" },
{ "caller-sdk-id", "IdWeb_1" },
{ "caller-sdk-ver", IdHelper.GetIdWebVersion() }
};

Expand Down Expand Up @@ -657,33 +724,33 @@ private static void AddCallerSDKTelemetry(DownstreamApiOptions effectiveOptions)
internal static async Task<string> ReadErrorResponseContentAsync(HttpResponseMessage response, CancellationToken cancellationToken = default)
{
const int maxErrorContentLength = 4096;

long? contentLength = response.Content.Headers.ContentLength;

if (contentLength.HasValue && contentLength.Value > maxErrorContentLength)
{
return $"[Error response too large: {contentLength.Value} bytes, not captured]";
}

// Use streaming to read only up to maxErrorContentLength to avoid loading entire response into memory
#if NET5_0_OR_GREATER
using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
#else
using var stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false);
#endif
using var reader = new StreamReader(stream);

char[] buffer = new char[maxErrorContentLength];
int readCount = await reader.ReadBlockAsync(buffer, 0, maxErrorContentLength).ConfigureAwait(false);

string errorResponseContent = new string(buffer, 0, readCount);

// Check if there's more content that was truncated
if (readCount == maxErrorContentLength && reader.Peek() != -1)
{
errorResponseContent += "... (truncated)";
}

return errorResponseContent;
}
}
Expand Down
Loading
Loading