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 ;
2020using Microsoft . Identity . Client ;
2121
2222namespace 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