diff --git a/src/Microsoft.Identity.Web.DownstreamApi/DownstreamApi.cs b/src/Microsoft.Identity.Web.DownstreamApi/DownstreamApi.cs index cd53cfec0..a1c606893 100644 --- a/src/Microsoft.Identity.Web.DownstreamApi/DownstreamApi.cs +++ b/src/Microsoft.Identity.Web.DownstreamApi/DownstreamApi.cs @@ -679,6 +679,64 @@ public Task CallApiForAppAsync( AuthorizationHeaderInformation? authorizationHeaderInformation = null; CredentialDescription? credential = null; + if (!string.IsNullOrEmpty(effectiveOptions.AcceptHeader)) + { + httpRequestMessage.Headers.Accept.ParseAdd(effectiveOptions.AcceptHeader); + } + + // Add extra headers if specified directly on DownstreamApiOptions. + // Skip names that are reserved for the library or already present on + // the outgoing request to keep the library-set values authoritative. + if (effectiveOptions.ExtraHeaderParameters != null) + { + foreach (var header in effectiveOptions.ExtraHeaderParameters) + { + if (ReservedHeaderNames.IsReserved(header.Key)) + { + Logger.ReservedHeaderIgnored(_logger, header.Key); + continue; + } + + if (httpRequestMessage.Headers.Contains(header.Key)) + { + Logger.DuplicateHeaderIgnored(_logger, header.Key); + continue; + } + + 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 before request-binding authorization headers are created. + effectiveOptions.CustomizeHttpRequestMessage?.Invoke(httpRequestMessage); + // Obtention of the authorization header (except when calling an anonymous endpoint) // which is done by not specifying any scopes or mTLS scheme. if (string.Equals(effectiveOptions.ProtocolScheme, Constants.MtlsProtocolScheme, StringComparison.OrdinalIgnoreCase)) @@ -783,64 +841,6 @@ public Task CallApiForAppAsync( Logger.UnauthenticatedApiCall(_logger, null); } - if (!string.IsNullOrEmpty(effectiveOptions.AcceptHeader)) - { - httpRequestMessage.Headers.Accept.ParseAdd(effectiveOptions.AcceptHeader); - } - - // Add extra headers if specified directly on DownstreamApiOptions. - // Skip names that are reserved for the library or already present on - // the outgoing request to keep the library-set values authoritative. - if (effectiveOptions.ExtraHeaderParameters != null) - { - foreach (var header in effectiveOptions.ExtraHeaderParameters) - { - if (ReservedHeaderNames.IsReserved(header.Key)) - { - Logger.ReservedHeaderIgnored(_logger, header.Key); - continue; - } - - if (httpRequestMessage.Headers.Contains(header.Key)) - { - Logger.DuplicateHeaderIgnored(_logger, header.Key); - continue; - } - - 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, credential); } diff --git a/tests/Microsoft.Identity.Web.Test/DownstreamWebApiSupport/DownstreamApiTests.cs b/tests/Microsoft.Identity.Web.Test/DownstreamWebApiSupport/DownstreamApiTests.cs index ee95f5694..eaf27d3d4 100644 --- a/tests/Microsoft.Identity.Web.Test/DownstreamWebApiSupport/DownstreamApiTests.cs +++ b/tests/Microsoft.Identity.Web.Test/DownstreamWebApiSupport/DownstreamApiTests.cs @@ -145,6 +145,113 @@ public async Task UpdateRequestAsync_WithScopes_AddsAuthorizationHeaderToRequest Assert.Equal(options.AcquireTokenOptions.ExtraQueryParameters, DownstreamApi.CallerSDKDetails); } + [Fact] + public async Task UpdateRequestAsync_WithScopes_FlowsFinalRequestToAuthorizationHeaderProviderAsync() + { + // Arrange + var authorizationHeaderProvider = new CapturingAuthorizationHeaderProvider(); + var downstreamApi = new DownstreamApi( + authorizationHeaderProvider, + _namedDownstreamApiOptions, + _httpClientFactory, + _logger, + msalHttpClientFactory: null, + credentialsProvider: _provider); + + var httpRequestMessage = new HttpRequestMessage(HttpMethod.Post, "https://example.com/path"); + var content = new StringContent("test content"); + var options = new DownstreamApiOptions + { + Scopes = ["scope1"], + ExtraHeaderParameters = new Dictionary + { + { "X-From-Options", "header-value" } + }, + ExtraQueryParameters = new Dictionary + { + { "fromOptions", "query-value" } + }, + CustomizeHttpRequestMessage = request => + { + request.Headers.TryAddWithoutValidation("X-From-Customizer", "customized"); + } + }; + + // Act + await downstreamApi.UpdateRequestWithCertificateAsync(httpRequestMessage, content, options, false, new ClaimsPrincipal(), CancellationToken.None); + + // Assert + Assert.Same(httpRequestMessage, authorizationHeaderProvider.CapturedRequest); + Assert.Same(content, authorizationHeaderProvider.CapturedRequest!.Content); + Assert.Contains("fromOptions=query-value", authorizationHeaderProvider.CapturedRequest.RequestUri!.Query, StringComparison.Ordinal); + Assert.True(authorizationHeaderProvider.CapturedRequest.Headers.Contains("X-From-Options")); + Assert.True(authorizationHeaderProvider.CapturedRequest.Headers.Contains("X-From-Customizer")); + Assert.False(authorizationHeaderProvider.AuthorizationHeaderPresentWhenCaptured); + Assert.True(httpRequestMessage.Headers.Contains("Authorization")); + } + + [Theory] + [InlineData("Authorization")] + [InlineData("Cookie")] + [InlineData("Host")] + [InlineData("X-Original-URL")] + [InlineData("X-MS-CLIENT-PRINCIPAL")] + public async Task UpdateRequestAsync_ReservedExtraHeaderParameters_AreSkippedInFlowedRequestAsync(string reservedHeaderName) + { + // Arrange + var authorizationHeaderProvider = new CapturingAuthorizationHeaderProvider(); + var downstreamApi = new DownstreamApi( + authorizationHeaderProvider, + _namedDownstreamApiOptions, + _httpClientFactory, + _logger, + msalHttpClientFactory: null, + credentialsProvider: _provider); + + var httpRequestMessage = new HttpRequestMessage(HttpMethod.Post, "https://example.com/path"); + var content = new StringContent("test content"); + var options = new DownstreamApiOptions + { + Scopes = ["scope1"], + ExtraHeaderParameters = new Dictionary + { + { reservedHeaderName, "caller-supplied-value" }, + { "X-Allowed", "allowed-value" } + } + }; + + // Act + await downstreamApi.UpdateRequestWithCertificateAsync(httpRequestMessage, content, options, false, new ClaimsPrincipal(), CancellationToken.None); + + // Assert + Assert.Same(httpRequestMessage, authorizationHeaderProvider.CapturedRequest); + + // Reserved names must not be flowed on the captured request, even in the new pre-signing position. + Assert.False( + authorizationHeaderProvider.CapturedRequest!.Headers.TryGetValues(reservedHeaderName, out var capturedValues) + && capturedValues.Any(v => v == "caller-supplied-value"), + $"Reserved header '{reservedHeaderName}' from ExtraHeaderParameters should not be flowed to the authorization header provider."); + + // Non-reserved ExtraHeaderParameters entries should still flow through in the same call. + Assert.True(authorizationHeaderProvider.CapturedRequest.Headers.Contains("X-Allowed")); + + // The final request must not contain a caller-supplied value for the reserved header either; + // for Authorization specifically, the SDK-produced header is the only one that should be present. + if (string.Equals(reservedHeaderName, "Authorization", StringComparison.OrdinalIgnoreCase)) + { + var authorizationValues = httpRequestMessage.Headers.GetValues("Authorization").ToList(); + Assert.Single(authorizationValues); + Assert.DoesNotContain("caller-supplied-value", authorizationValues[0], StringComparison.Ordinal); + } + else + { + Assert.False( + httpRequestMessage.Headers.TryGetValues(reservedHeaderName, out var finalValues) + && finalValues.Any(v => v == "caller-supplied-value"), + $"Reserved header '{reservedHeaderName}' from ExtraHeaderParameters should not be present on the final request."); + } + } + [Theory] [InlineData(true)] [InlineData(false)] @@ -1227,6 +1334,37 @@ public Task CreateAuthorizationHeaderAsync(IEnumerable scopes, A } } + public class CapturingAuthorizationHeaderProvider : IAuthorizationHeaderProvider + { + public HttpRequestMessage? CapturedRequest { get; private set; } + + public bool AuthorizationHeaderPresentWhenCaptured { get; private set; } + + public Task CreateAuthorizationHeaderForAppAsync(string scopes, AuthorizationHeaderProviderOptions? downstreamApiOptions = null, CancellationToken cancellationToken = default) + { + CaptureRequest(downstreamApiOptions); + return Task.FromResult("******"); + } + + public Task CreateAuthorizationHeaderForUserAsync(IEnumerable scopes, AuthorizationHeaderProviderOptions? authorizationHeaderProviderOptions = null, ClaimsPrincipal? claimsPrincipal = null, CancellationToken cancellationToken = default) + { + CaptureRequest(authorizationHeaderProviderOptions); + return Task.FromResult("******"); + } + + public Task CreateAuthorizationHeaderAsync(IEnumerable scopes, AuthorizationHeaderProviderOptions? authorizationHeaderProviderOptions = null, ClaimsPrincipal? claimsPrincipal = null, CancellationToken cancellationToken = default) + { + CaptureRequest(authorizationHeaderProviderOptions); + return Task.FromResult("******"); + } + + private void CaptureRequest(AuthorizationHeaderProviderOptions? authorizationHeaderProviderOptions) + { + CapturedRequest = authorizationHeaderProviderOptions?.AcquireTokenOptions.GetHttpRequestMessage(); + AuthorizationHeaderPresentWhenCaptured = CapturedRequest?.Headers.Contains("Authorization") == true; + } + } + public class MySamlAuthorizationHeaderProvider : IAuthorizationHeaderProvider { public Task CreateAuthorizationHeaderForAppAsync(string scopes, AuthorizationHeaderProviderOptions? downstreamApiOptions = null, CancellationToken cancellationToken = default) @@ -1252,4 +1390,3 @@ public class ThrowingAuthorizationHeaderProvider : IAuthorizationHeaderProvider public Task CreateAuthorizationHeaderForUserAsync(IEnumerable scopes, AuthorizationHeaderProviderOptions? authorizationHeaderProviderOptions = null, ClaimsPrincipal? claimsPrincipal = null, CancellationToken cancellationToken = default) => throw new NotImplementedException(); } } -