Skip to content

Commit bad1289

Browse files
cpp11nullptrZhenya PolyvanyijmprieurCopilot
authored
Add token binding (#3622)
* Implement mTLS HTTP client factory * Implement authorization header provider for token with binding certificate * Add unit tests for downstream API changes * Add token binding to token acquisition flow * Update src/Microsoft.Identity.Web.DownstreamApi/DownstreamApi.cs Co-authored-by: Jean-Marc Prieur <jmprieur@microsoft.com> * Update src/Microsoft.Identity.Web.DownstreamApi/DownstreamApi.cs Co-authored-by: Jean-Marc Prieur <jmprieur@microsoft.com> * Commit changes after code review comments * Exclude net462 from condition for SUPPORTS_MTLS * Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Set token binding flow through token acquisition extra parameters * Fix typo in comment * Update unit tests * Add E2E test for token acquirer * Add mTLS PoP client and web API samples * Use AuthenticationOptionsName for enabling token binding flow during token acquisition * Update outdated test * Don't dispose HTTP client managed by HTTP client factory * Make mTLS HTTL client factory thread safe and prevent resource leak * Use read-write lock for mTLS HTTP client factory * Pass token binding sentinel to token acquisition through extra parameters * Use upgradble read lock for mTLS PoP HTTP client factory * Add protocol schema check for token acquirer * Use pre-boxed boolean and update stale tests * Use updated IBoundAuthorizationHeaderProvider interface * Remove isTokenBinding property from MergedOptions * Use JsonWebToken instead of manual parsing in tests * Fix naming for private static member --------- Co-authored-by: Zhenya Polyvanyi <iepoly@microsoft.com> Co-authored-by: Jean-Marc Prieur <jmprieur@microsoft.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1 parent 5f2a9c9 commit bad1289

File tree

57 files changed

+2683
-131
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

57 files changed

+2683
-131
lines changed

Microsoft.Identity.Web.sln

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,12 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.Identity.Web.Side
175175
EndProject
176176
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sidecar.Tests", "tests\E2E Tests\Sidecar.Tests\Sidecar.Tests.csproj", "{946E6BED-2A06-4FF4-3E39-22ACEB44A984}"
177177
EndProject
178+
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "MtlsPop", "MtlsPop", "{06818CF6-16AD-4184-9264-B593B8F2AA25}"
179+
EndProject
180+
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MtlsPopClient", "tests\DevApps\MtlsPop\MtlsPopClient\MtlsPopClient.csproj", "{3ECC1B78-A458-726F-D7B8-AB74733CCCDC}"
181+
EndProject
182+
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MtlsPopWebApi", "tests\DevApps\MtlsPop\MtlsPopWebApi\MtlsPopWebApi.csproj", "{A61CEEDE-6F2C-0710-E008-B5F6F25D87D7}"
183+
EndProject
178184
Global
179185
GlobalSection(SolutionConfigurationPlatforms) = preSolution
180186
Debug|Any CPU = Debug|Any CPU
@@ -418,6 +424,14 @@ Global
418424
{946E6BED-2A06-4FF4-3E39-22ACEB44A984}.Debug|Any CPU.Build.0 = Debug|Any CPU
419425
{946E6BED-2A06-4FF4-3E39-22ACEB44A984}.Release|Any CPU.ActiveCfg = Release|Any CPU
420426
{946E6BED-2A06-4FF4-3E39-22ACEB44A984}.Release|Any CPU.Build.0 = Release|Any CPU
427+
{3ECC1B78-A458-726F-D7B8-AB74733CCCDC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
428+
{3ECC1B78-A458-726F-D7B8-AB74733CCCDC}.Debug|Any CPU.Build.0 = Debug|Any CPU
429+
{3ECC1B78-A458-726F-D7B8-AB74733CCCDC}.Release|Any CPU.ActiveCfg = Release|Any CPU
430+
{3ECC1B78-A458-726F-D7B8-AB74733CCCDC}.Release|Any CPU.Build.0 = Release|Any CPU
431+
{A61CEEDE-6F2C-0710-E008-B5F6F25D87D7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
432+
{A61CEEDE-6F2C-0710-E008-B5F6F25D87D7}.Debug|Any CPU.Build.0 = Debug|Any CPU
433+
{A61CEEDE-6F2C-0710-E008-B5F6F25D87D7}.Release|Any CPU.ActiveCfg = Release|Any CPU
434+
{A61CEEDE-6F2C-0710-E008-B5F6F25D87D7}.Release|Any CPU.Build.0 = Release|Any CPU
421435
EndGlobalSection
422436
GlobalSection(SolutionProperties) = preSolution
423437
HideSolutionNode = FALSE
@@ -497,6 +511,9 @@ Global
497511
{A8181404-23E0-D38B-454C-D16ECDB18B9F} = {E37CDBC1-18F6-4C06-A3EE-532C9106721F}
498512
{55C81F88-0FFA-491C-A1D7-0ACA7212B59C} = {1DDE1AAC-5AE6-4725-94B6-A26C58D3423F}
499513
{946E6BED-2A06-4FF4-3E39-22ACEB44A984} = {45B20A78-91F8-4DD2-B9AD-F12D3A93536C}
514+
{06818CF6-16AD-4184-9264-B593B8F2AA25} = {7786D2DD-9EE4-42E1-B587-740A2E15C41D}
515+
{3ECC1B78-A458-726F-D7B8-AB74733CCCDC} = {06818CF6-16AD-4184-9264-B593B8F2AA25}
516+
{A61CEEDE-6F2C-0710-E008-B5F6F25D87D7} = {06818CF6-16AD-4184-9264-B593B8F2AA25}
500517
EndGlobalSection
501518
GlobalSection(ExtensibilityGlobals) = postSolution
502519
SolutionGuid = {104367F1-CE75-4F40-B32F-F14853973187}

src/Microsoft.Identity.Web.DownstreamApi/DownstreamApi.cs

Lines changed: 144 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,22 @@
11
// Copyright (c) Microsoft Corporation. All rights reserved.
22
// Licensed under the MIT License.
33

4-
using System;
5-
using System.Collections.Generic;
6-
using System.Diagnostics.CodeAnalysis;
7-
using System.IO;
8-
using System.Linq;
9-
using System.Net.Http;
10-
using System.Runtime.CompilerServices;
11-
using System.Security.Claims;
12-
using System.Text;
13-
using System.Text.Json;
14-
using System.Text.Json.Serialization.Metadata;
15-
using System.Threading;
16-
using System.Threading.Tasks;
17-
using Microsoft.Extensions.Logging;
18-
using Microsoft.Extensions.Options;
19-
using Microsoft.Identity.Abstractions;
4+
using System;
5+
using System.Collections.Generic;
6+
using System.Diagnostics.CodeAnalysis;
7+
using System.IO;
8+
using System.Linq;
9+
using System.Net.Http;
10+
using System.Runtime.CompilerServices;
11+
using System.Security.Claims;
12+
using System.Text;
13+
using System.Text.Json;
14+
using System.Text.Json.Serialization.Metadata;
15+
using System.Threading;
16+
using System.Threading.Tasks;
17+
using Microsoft.Extensions.Logging;
18+
using Microsoft.Extensions.Options;
19+
using Microsoft.Identity.Abstractions;
2020
using Microsoft.Identity.Client;
2121

2222
namespace Microsoft.Identity.Web
@@ -26,11 +26,20 @@ internal partial class DownstreamApi : IDownstreamApi
2626
{
2727
private readonly IAuthorizationHeaderProvider _authorizationHeaderProvider;
2828
private readonly IHttpClientFactory _httpClientFactory;
29+
30+
// This MSAL HTTP client factory is used to create HTTP clients with mTLS binding certificate.
31+
// Note, that it doesn't replace _httpClientFactory to keep backward compatibility and ability
32+
// to create named HTTP clients for non-mTLS scenarios.
33+
private readonly IMsalHttpClientFactory? _msalHttpClientFactory;
34+
2935
private readonly IOptionsMonitor<DownstreamApiOptions> _namedDownstreamApiOptions;
36+
3037
private const string Authorization = "Authorization";
31-
protected readonly ILogger<DownstreamApi> _logger;
38+
private const string TokenBindingProtocolScheme = "MTLS_POP";
3239
private const string AuthSchemeDstsSamlBearer = "http://schemas.microsoft.com/dsts/saml2-bearer";
3340

41+
protected readonly ILogger<DownstreamApi> _logger;
42+
3443
/// <summary>
3544
/// Constructor.
3645
/// </summary>
@@ -43,10 +52,33 @@ public DownstreamApi(
4352
IOptionsMonitor<DownstreamApiOptions> namedDownstreamApiOptions,
4453
IHttpClientFactory httpClientFactory,
4554
ILogger<DownstreamApi> logger)
55+
: this(authorizationHeaderProvider,
56+
namedDownstreamApiOptions,
57+
httpClientFactory,
58+
logger,
59+
msalHttpClientFactory: null)
60+
{
61+
}
62+
63+
/// <summary>
64+
/// Constructor which accepts optional MSAL HTTP client factory.
65+
/// </summary>
66+
/// <param name="authorizationHeaderProvider">Authorization header provider.</param>
67+
/// <param name="namedDownstreamApiOptions">Named options provider.</param>
68+
/// <param name="httpClientFactory">HTTP client factory.</param>
69+
/// <param name="logger">Logger.</param>
70+
/// <param name="msalHttpClientFactory">The MSAL HTTP client factory for mTLS PoP scenarios.</param>
71+
public DownstreamApi(
72+
IAuthorizationHeaderProvider authorizationHeaderProvider,
73+
IOptionsMonitor<DownstreamApiOptions> namedDownstreamApiOptions,
74+
IHttpClientFactory httpClientFactory,
75+
ILogger<DownstreamApi> logger,
76+
IMsalHttpClientFactory? msalHttpClientFactory)
4677
{
4778
_authorizationHeaderProvider = authorizationHeaderProvider;
4879
_namedDownstreamApiOptions = namedDownstreamApiOptions;
4980
_httpClientFactory = httpClientFactory;
81+
_msalHttpClientFactory = msalHttpClientFactory ?? new MsalMtlsHttpClientFactory(httpClientFactory);
5082
_logger = logger;
5183
}
5284

@@ -436,7 +468,7 @@ public Task<HttpResponseMessage> CallApiForAppAsync(
436468
string stringContent = await content.ReadAsStringAsync();
437469
if (mediaType == "application/json")
438470
{
439-
return JsonSerializer.Deserialize<TOutput>(stringContent, new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
471+
return JsonSerializer.Deserialize<TOutput>(stringContent, new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
440472
}
441473
if (mediaType != null && !mediaType.StartsWith("text/", StringComparison.OrdinalIgnoreCase))
442474
{
@@ -514,11 +546,17 @@ public Task<HttpResponseMessage> CallApiForAppAsync(
514546
new HttpMethod(effectiveOptions.HttpMethod),
515547
apiUrl);
516548

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

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

521-
// Send the HTTP message
559+
// Send the HTTP message
522560
var downstreamApiResult = await client.SendAsync(httpRequestMessage, cancellationToken).ConfigureAwait(false);
523561

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

544-
internal /* internal for test */ async Task UpdateRequestAsync(
582+
internal /* internal for test */ async Task<AuthorizationHeaderInformation?> UpdateRequestAsync(
545583
HttpRequestMessage httpRequestMessage,
546584
HttpContent? content,
547585
DownstreamApiOptions effectiveOptions,
@@ -558,15 +596,42 @@ public Task<HttpResponseMessage> CallApiForAppAsync(
558596

559597
effectiveOptions.RequestAppToken = appToken;
560598

599+
AuthorizationHeaderInformation? authorizationHeaderInformation = null;
600+
561601
// Obtention of the authorization header (except when calling an anonymous endpoint
562602
// which is done by not specifying any scopes
563603
if (effectiveOptions.Scopes != null && effectiveOptions.Scopes.Any())
564604
{
565-
string authorizationHeader = await _authorizationHeaderProvider.CreateAuthorizationHeaderAsync(
566-
effectiveOptions.Scopes,
567-
effectiveOptions,
568-
user,
569-
cancellationToken).ConfigureAwait(false);
605+
string authorizationHeader = string.Empty;
606+
607+
// Firstly check if it's token binding scenario so authorization header provider returns
608+
// a binding certificate along with acquired authorization header.
609+
if (_authorizationHeaderProvider is IBoundAuthorizationHeaderProvider boundAuthorizationHeaderBoundProvider
610+
&& string.Equals(effectiveOptions.ProtocolScheme, TokenBindingProtocolScheme, StringComparison.OrdinalIgnoreCase))
611+
{
612+
var authorizationHeaderResult = await boundAuthorizationHeaderBoundProvider.CreateBoundAuthorizationHeaderAsync(
613+
effectiveOptions,
614+
user,
615+
cancellationToken).ConfigureAwait(false);
616+
617+
if (!authorizationHeaderResult.Succeeded)
618+
{
619+
// in theory it shouldn't happen because in case of error during token acquisition
620+
// there will be thrown corresponding exception, so it's more a safeguard
621+
throw new InvalidOperationException("Cannot acquire bound authorization header.");
622+
}
623+
624+
authorizationHeaderInformation = authorizationHeaderResult.Result;
625+
authorizationHeader = authorizationHeaderInformation?.AuthorizationHeaderValue!;
626+
}
627+
else
628+
{
629+
authorizationHeader = await _authorizationHeaderProvider.CreateAuthorizationHeaderAsync(
630+
effectiveOptions.Scopes,
631+
effectiveOptions,
632+
user,
633+
cancellationToken).ConfigureAwait(false);
634+
}
570635

571636
if (authorizationHeader.StartsWith(AuthSchemeDstsSamlBearer, StringComparison.OrdinalIgnoreCase))
572637
{
@@ -582,54 +647,56 @@ public Task<HttpResponseMessage> CallApiForAppAsync(
582647
{
583648
Logger.UnauthenticatedApiCall(_logger, null);
584649
}
585-
if (!string.IsNullOrEmpty(effectiveOptions.AcceptHeader))
586-
{
587-
httpRequestMessage.Headers.Accept.ParseAdd(effectiveOptions.AcceptHeader);
588-
}
589-
590-
// Add extra headers if specified directly on DownstreamApiOptions
591-
if (effectiveOptions.ExtraHeaderParameters != null)
592-
{
593-
foreach (var header in effectiveOptions.ExtraHeaderParameters)
594-
{
595-
httpRequestMessage.Headers.TryAddWithoutValidation(header.Key, header.Value);
596-
}
597-
}
598-
599-
// Add extra query parameters if specified directly on DownstreamApiOptions
600-
if (effectiveOptions.ExtraQueryParameters != null && effectiveOptions.ExtraQueryParameters.Count > 0)
601-
{
602-
var uriBuilder = new UriBuilder(httpRequestMessage.RequestUri!);
603-
var existingQuery = uriBuilder.Query;
604-
var queryString = new StringBuilder(existingQuery);
605-
606-
foreach (var queryParam in effectiveOptions.ExtraQueryParameters)
607-
{
608-
if (queryString.Length > 1) // if there are existing query parameters
609-
{
610-
queryString.Append('&');
611-
}
612-
else if (queryString.Length == 0)
613-
{
614-
queryString.Append('?');
615-
}
616-
617-
queryString.Append(Uri.EscapeDataString(queryParam.Key));
618-
queryString.Append('=');
619-
queryString.Append(Uri.EscapeDataString(queryParam.Value));
620-
}
621-
622-
uriBuilder.Query = queryString.ToString().TrimStart('?');
623-
httpRequestMessage.RequestUri = uriBuilder.Uri;
624-
}
625-
626-
// Opportunity to change the request message
650+
if (!string.IsNullOrEmpty(effectiveOptions.AcceptHeader))
651+
{
652+
httpRequestMessage.Headers.Accept.ParseAdd(effectiveOptions.AcceptHeader);
653+
}
654+
655+
// Add extra headers if specified directly on DownstreamApiOptions
656+
if (effectiveOptions.ExtraHeaderParameters != null)
657+
{
658+
foreach (var header in effectiveOptions.ExtraHeaderParameters)
659+
{
660+
httpRequestMessage.Headers.TryAddWithoutValidation(header.Key, header.Value);
661+
}
662+
}
663+
664+
// Add extra query parameters if specified directly on DownstreamApiOptions
665+
if (effectiveOptions.ExtraQueryParameters != null && effectiveOptions.ExtraQueryParameters.Count > 0)
666+
{
667+
var uriBuilder = new UriBuilder(httpRequestMessage.RequestUri!);
668+
var existingQuery = uriBuilder.Query;
669+
var queryString = new StringBuilder(existingQuery);
670+
671+
foreach (var queryParam in effectiveOptions.ExtraQueryParameters)
672+
{
673+
if (queryString.Length > 1) // if there are existing query parameters
674+
{
675+
queryString.Append('&');
676+
}
677+
else if (queryString.Length == 0)
678+
{
679+
queryString.Append('?');
680+
}
681+
682+
queryString.Append(Uri.EscapeDataString(queryParam.Key));
683+
queryString.Append('=');
684+
queryString.Append(Uri.EscapeDataString(queryParam.Value));
685+
}
686+
687+
uriBuilder.Query = queryString.ToString().TrimStart('?');
688+
httpRequestMessage.RequestUri = uriBuilder.Uri;
689+
}
690+
691+
// Opportunity to change the request message
627692
effectiveOptions.CustomizeHttpRequestMessage?.Invoke(httpRequestMessage);
693+
694+
return authorizationHeaderInformation;
628695
}
629696

630697
internal /* for test */ static Dictionary<string, string> CallerSDKDetails { get; } = new()
631698
{
632-
{ "caller-sdk-id", "IdWeb_1" },
699+
{ "caller-sdk-id", "IdWeb_1" },
633700
{ "caller-sdk-ver", IdHelper.GetIdWebVersion() }
634701
};
635702

@@ -657,33 +724,33 @@ private static void AddCallerSDKTelemetry(DownstreamApiOptions effectiveOptions)
657724
internal static async Task<string> ReadErrorResponseContentAsync(HttpResponseMessage response, CancellationToken cancellationToken = default)
658725
{
659726
const int maxErrorContentLength = 4096;
660-
727+
661728
long? contentLength = response.Content.Headers.ContentLength;
662-
729+
663730
if (contentLength.HasValue && contentLength.Value > maxErrorContentLength)
664731
{
665732
return $"[Error response too large: {contentLength.Value} bytes, not captured]";
666733
}
667-
734+
668735
// Use streaming to read only up to maxErrorContentLength to avoid loading entire response into memory
669736
#if NET5_0_OR_GREATER
670737
using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
671738
#else
672739
using var stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false);
673740
#endif
674741
using var reader = new StreamReader(stream);
675-
742+
676743
char[] buffer = new char[maxErrorContentLength];
677744
int readCount = await reader.ReadBlockAsync(buffer, 0, maxErrorContentLength).ConfigureAwait(false);
678-
745+
679746
string errorResponseContent = new string(buffer, 0, readCount);
680-
747+
681748
// Check if there's more content that was truncated
682749
if (readCount == maxErrorContentLength && reader.Peek() != -1)
683750
{
684751
errorResponseContent += "... (truncated)";
685752
}
686-
753+
687754
return errorResponseContent;
688755
}
689756
}

0 commit comments

Comments
 (0)