From 45ff3bee0ae80ec2cf6bdbc568a44b2fd1aa6055 Mon Sep 17 00:00:00 2001 From: Bogdan Gavril Date: Tue, 30 Jun 2026 11:25:17 +0100 Subject: [PATCH 1/2] Flow finalized request to authorization provider Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../DownstreamApi.cs | 116 +++++++++--------- .../DownstreamApiTests.cs | 77 +++++++++++- 2 files changed, 134 insertions(+), 59 deletions(-) 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..6c34c2b41 100644 --- a/tests/Microsoft.Identity.Web.Test/DownstreamWebApiSupport/DownstreamApiTests.cs +++ b/tests/Microsoft.Identity.Web.Test/DownstreamWebApiSupport/DownstreamApiTests.cs @@ -145,6 +145,51 @@ 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(true)] [InlineData(false)] @@ -1227,6 +1272,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 +1328,3 @@ public class ThrowingAuthorizationHeaderProvider : IAuthorizationHeaderProvider public Task CreateAuthorizationHeaderForUserAsync(IEnumerable scopes, AuthorizationHeaderProviderOptions? authorizationHeaderProviderOptions = null, ClaimsPrincipal? claimsPrincipal = null, CancellationToken cancellationToken = default) => throw new NotImplementedException(); } } - From 73f72a969aed96fe0151f147d8d8e8b1eb30c3a2 Mon Sep 17 00:00:00 2001 From: Nilesh Choudhary Date: Wed, 1 Jul 2026 14:04:08 +0100 Subject: [PATCH 2/2] Add negative test for reserved ExtraHeaderParameters in flowed request Verifies that reserved header names supplied via ExtraHeaderParameters remain skipped both on the request flowed to the authorization header provider and on the final outgoing request, ensuring the request-ordering change did not alter the reserved/duplicate-skip semantics. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../DownstreamApiTests.cs | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/tests/Microsoft.Identity.Web.Test/DownstreamWebApiSupport/DownstreamApiTests.cs b/tests/Microsoft.Identity.Web.Test/DownstreamWebApiSupport/DownstreamApiTests.cs index 6c34c2b41..eaf27d3d4 100644 --- a/tests/Microsoft.Identity.Web.Test/DownstreamWebApiSupport/DownstreamApiTests.cs +++ b/tests/Microsoft.Identity.Web.Test/DownstreamWebApiSupport/DownstreamApiTests.cs @@ -190,6 +190,68 @@ public async Task UpdateRequestAsync_WithScopes_FlowsFinalRequestToAuthorization 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)]