From 9f8e174675a34866df9fa6476d40c3ef25b8d844 Mon Sep 17 00:00:00 2001 From: Neha Bhargava Date: Mon, 13 Jul 2026 21:35:00 -0700 Subject: [PATCH] Restore CustomizeHttpRequestMessage to run after the authorization header CustomizeHttpRequestMessage is documented to run after the message is formed, including the Authorization header, and just before it is sent. #3902 moved it before authorization-header creation (to flow the finalized request for request-binding), which regressed callers that read the header in the callback - they saw a null Authorization header. This moves only the CustomizeHttpRequestMessage invocation back to after the header is set, leaving the request-flow plumbing (options material + SetHttpRequestMessage) untouched. Adds a regression test asserting the callback observes the Authorization header. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- changelog.md | 5 +++ .../DownstreamApi.cs | 8 +++-- .../DownstreamApiTests.cs | 31 ++++++++++++++++--- 3 files changed, 36 insertions(+), 8 deletions(-) diff --git a/changelog.md b/changelog.md index 2126203af..151f27025 100644 --- a/changelog.md +++ b/changelog.md @@ -1,3 +1,8 @@ +## 4.13.1 + +### Bug fixes +- `DownstreamApi`: restored `CustomizeHttpRequestMessage` to its documented timing — it runs after the authorization header (including the `Authorization` header) is set, just before the request is sent. [#3902](https://github.com/AzureAD/microsoft-identity-web/pull/3902) had moved it before header creation (to flow the finalized request for request-binding), which regressed callers that read the header in the callback — they saw `null`. See [#3943](https://github.com/AzureAD/microsoft-identity-web/pull/3943). + ## 4.13.0 ### New features diff --git a/src/Microsoft.Identity.Web.DownstreamApi/DownstreamApi.cs b/src/Microsoft.Identity.Web.DownstreamApi/DownstreamApi.cs index a1c606893..0bd1678bb 100644 --- a/src/Microsoft.Identity.Web.DownstreamApi/DownstreamApi.cs +++ b/src/Microsoft.Identity.Web.DownstreamApi/DownstreamApi.cs @@ -734,9 +734,6 @@ public Task CallApiForAppAsync( 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)) @@ -841,6 +838,11 @@ public Task CallApiForAppAsync( Logger.UnauthenticatedApiCall(_logger, null); } + // Opportunity to change the request message after the authorization header (including the Authorization + // header) has been set, just before the request is sent, per CustomizeHttpRequestMessage's documented + // contract. #3902 had moved this before header creation, which regressed callers that read the header. + 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 1d76be6ee..72b513aa8 100644 --- a/tests/Microsoft.Identity.Web.Test/DownstreamWebApiSupport/DownstreamApiTests.cs +++ b/tests/Microsoft.Identity.Web.Test/DownstreamWebApiSupport/DownstreamApiTests.cs @@ -170,10 +170,6 @@ public async Task UpdateRequestAsync_WithScopes_FlowsFinalRequestToAuthorization ExtraQueryParameters = new Dictionary { { "fromOptions", "query-value" } - }, - CustomizeHttpRequestMessage = request => - { - request.Headers.TryAddWithoutValidation("X-From-Customizer", "customized"); } }; @@ -185,11 +181,36 @@ public async Task UpdateRequestAsync_WithScopes_FlowsFinalRequestToAuthorization 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")); } + [Fact] + // Regression test for CustomizeHttpRequestMessage timing: it must run after the authorization header is set + // (its documented contract), so a callback that reads the Authorization header observes it. #3902 had moved + // it before header creation, so such callbacks saw null. + public async Task UpdateRequestAsync_CustomizeHttpRequestMessage_SeesAuthorizationHeaderAsync() + { + // Arrange + var httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, "https://localhost:44352"); + var content = new StringContent("test content"); + string? authorizationHeaderSeenByCustomizer = null; + var options = new DownstreamApiOptions + { + Scopes = ["api://a021aff4-57ad-453a-bae8-e4192e5860f3/.default"], + BaseUrl = "https://localhost:44352", + RelativePath = "/WeatherForecast", + RequestAppToken = true, + CustomizeHttpRequestMessage = request => authorizationHeaderSeenByCustomizer = request.Headers.Authorization?.Parameter, + }; + + // Act + await _input.UpdateRequestWithCertificateAsync(httpRequestMessage, content, options, appToken: true, new ClaimsPrincipal(), CancellationToken.None); + + // Assert - the customizer runs after the header is set, so it observes the Authorization header. + Assert.Equal("ey", authorizationHeaderSeenByCustomizer); + } + [Theory] [InlineData("Authorization")] [InlineData("Cookie")]