diff --git a/changelog.md b/changelog.md index 3b2a60f56..3d86d1ce1 100644 --- a/changelog.md +++ b/changelog.md @@ -1,8 +1,14 @@ ## 4.13.2 +### New features +- `DownstreamApi` now honors the new `AuthorizationHeaderProviderOptions.OnBeforeAuthHeaderCreation` and `OnAfterAuthHeaderCreation` hooks (`Microsoft.Identity.Abstractions` 12.5.0). `OnBeforeAuthHeaderCreation` runs before the header is created — use it to shape the request that request-binding protocols (SignedHttpRequest `q`/`h`/`b`) sign — and `OnAfterAuthHeaderCreation` runs after the header is set, to observe or adjust the finalized request. See [#3942](https://github.com/AzureAD/microsoft-identity-web/pull/3942). + ### Dependencies updates - Bump `Microsoft.Identity.Abstractions` from 12.4.0 to 12.5.0 (adds the `OnBeforeAuthHeaderCreation` / `OnAfterAuthHeaderCreation` request hooks). See [#3947](https://github.com/AzureAD/microsoft-identity-web/pull/3947). +### Fundamentals +- `BaseAuthorizationHeaderProvider` and `DefaultAuthorizationHeaderProvider` declare `IAuthorizationHeaderProvider2` only, since it already extends `IAuthorizationHeaderProvider`. See [#3942](https://github.com/AzureAD/microsoft-identity-web/pull/3942). + ## 4.13.1 ### Bug fixes diff --git a/src/Microsoft.Identity.Web.DownstreamApi/DownstreamApi.cs b/src/Microsoft.Identity.Web.DownstreamApi/DownstreamApi.cs index 0bd1678bb..c3f3264c1 100644 --- a/src/Microsoft.Identity.Web.DownstreamApi/DownstreamApi.cs +++ b/src/Microsoft.Identity.Web.DownstreamApi/DownstreamApi.cs @@ -734,6 +734,10 @@ public Task CallApiForAppAsync( httpRequestMessage.RequestUri = uriBuilder.Uri; } + // Opportunity to shape the request before the authorization header is created and (for request-binding + // protocols such as SignedHttpRequest q/h/b) signed, so the signature covers the finalized request. + effectiveOptions.OnBeforeAuthHeaderCreation?.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)) @@ -838,9 +842,10 @@ 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. + // Observe or adjust the finalized request once the authorization header (including the Authorization + // header) has been set, just before it is sent. Fire the new OnAfterAuthHeaderCreation hook and the + // pre-existing CustomizeHttpRequestMessage (retained for backwards compatibility); both share this timing. + effectiveOptions.OnAfterAuthHeaderCreation?.Invoke(httpRequestMessage); effectiveOptions.CustomizeHttpRequestMessage?.Invoke(httpRequestMessage); return (authorizationHeaderInformation, credential); diff --git a/src/Microsoft.Identity.Web.TokenAcquisition/BaseAuthorizationHeaderProvider.cs b/src/Microsoft.Identity.Web.TokenAcquisition/BaseAuthorizationHeaderProvider.cs index cb7228ceb..c98fbe800 100644 --- a/src/Microsoft.Identity.Web.TokenAcquisition/BaseAuthorizationHeaderProvider.cs +++ b/src/Microsoft.Identity.Web.TokenAcquisition/BaseAuthorizationHeaderProvider.cs @@ -21,7 +21,7 @@ namespace Microsoft.Identity.Web.Extensibility /// metadata-rich header-creation surface without needing to opt in. Override the /// CreateAuthorizationHeaderInformation* virtuals to customize that path. /// - public class BaseAuthorizationHeaderProvider : IAuthorizationHeaderProvider, IAuthorizationHeaderProvider2 + public class BaseAuthorizationHeaderProvider : IAuthorizationHeaderProvider2 { /// /// Constructor from a service provider @@ -36,7 +36,7 @@ public BaseAuthorizationHeaderProvider(IServiceProvider serviceProvider) _headerProvider = new DefaultAuthorizationHeaderProvider(_tokenAcquisition); } - private readonly DefaultAuthorizationHeaderProvider _headerProvider; + private readonly IAuthorizationHeaderProvider2 _headerProvider; /// public virtual Task CreateAuthorizationHeaderForUserAsync(IEnumerable scopes, AuthorizationHeaderProviderOptions? authorizationHeaderProviderOptions = null, ClaimsPrincipal? claimsPrincipal = null, CancellationToken cancellationToken = default) diff --git a/src/Microsoft.Identity.Web.TokenAcquisition/DefaultAuthorizationHeaderProvider.cs b/src/Microsoft.Identity.Web.TokenAcquisition/DefaultAuthorizationHeaderProvider.cs index 5a9574a84..41c5e8988 100644 --- a/src/Microsoft.Identity.Web.TokenAcquisition/DefaultAuthorizationHeaderProvider.cs +++ b/src/Microsoft.Identity.Web.TokenAcquisition/DefaultAuthorizationHeaderProvider.cs @@ -17,7 +17,6 @@ namespace Microsoft.Identity.Web * Treat as a public member. */ internal sealed class DefaultAuthorizationHeaderProvider : - IAuthorizationHeaderProvider, IAuthorizationHeaderProvider2, IBoundAuthorizationHeaderProvider { diff --git a/src/Microsoft.Identity.Web.TokenAcquisition/MicrosoftIdentityMessageHandler.cs b/src/Microsoft.Identity.Web.TokenAcquisition/MicrosoftIdentityMessageHandler.cs index 4d3d7955c..bec33a714 100644 --- a/src/Microsoft.Identity.Web.TokenAcquisition/MicrosoftIdentityMessageHandler.cs +++ b/src/Microsoft.Identity.Web.TokenAcquisition/MicrosoftIdentityMessageHandler.cs @@ -793,6 +793,8 @@ private static DownstreamApiOptions CreateDownstreamApiOptions( RelativePath = options.RelativePath, HttpMethod = options.HttpMethod, CustomizeHttpRequestMessage = options.CustomizeHttpRequestMessage, + OnBeforeAuthHeaderCreation = options.OnBeforeAuthHeaderCreation, + OnAfterAuthHeaderCreation = options.OnAfterAuthHeaderCreation, AcquireTokenOptions = options.AcquireTokenOptions, ProtocolScheme = options.ProtocolScheme, RequestAppToken = options.RequestAppToken, diff --git a/tests/Microsoft.Identity.Web.Test/DownstreamWebApiSupport/DownstreamApiTests.cs b/tests/Microsoft.Identity.Web.Test/DownstreamWebApiSupport/DownstreamApiTests.cs index 72b513aa8..04cdf8fe2 100644 --- a/tests/Microsoft.Identity.Web.Test/DownstreamWebApiSupport/DownstreamApiTests.cs +++ b/tests/Microsoft.Identity.Web.Test/DownstreamWebApiSupport/DownstreamApiTests.cs @@ -170,6 +170,10 @@ public async Task UpdateRequestAsync_WithScopes_FlowsFinalRequestToAuthorization ExtraQueryParameters = new Dictionary { { "fromOptions", "query-value" } + }, + OnBeforeAuthHeaderCreation = request => + { + request.Headers.TryAddWithoutValidation("X-From-OnBefore", "customized"); } }; @@ -181,6 +185,7 @@ 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-OnBefore")); Assert.False(authorizationHeaderProvider.AuthorizationHeaderPresentWhenCaptured); Assert.True(httpRequestMessage.Headers.Contains("Authorization")); } @@ -211,6 +216,100 @@ public async Task UpdateRequestAsync_CustomizeHttpRequestMessage_SeesAuthorizati Assert.Equal("ey", authorizationHeaderSeenByCustomizer); } + [Fact] + // Validates the authorization-header lifecycle hooks. OnBeforeAuthHeaderCreation runs before the header is + // created (so it can shape the request that request-binding protocols such as SHR q/h/b sign) and therefore + // sees no Authorization header. OnAfterAuthHeaderCreation and the pre-existing CustomizeHttpRequestMessage + // (kept for backwards compatibility) run after the header is attached and both observe it. + public async Task UpdateRequestAsync_AuthHeaderLifecycleHooks_RunAtDocumentedTimesAsync() + { + // Arrange + var httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, "https://localhost:44352"); + var content = new StringContent("test content"); + string? headerSeenByOnBefore = "sentinel"; + string? headerSeenByOnAfter = null; + string? headerSeenByCustomize = null; + var options = new DownstreamApiOptions + { + Scopes = ["api://a021aff4-57ad-453a-bae8-e4192e5860f3/.default"], + BaseUrl = "https://localhost:44352", + RelativePath = "/WeatherForecast", + RequestAppToken = true, + OnBeforeAuthHeaderCreation = request => headerSeenByOnBefore = request.Headers.Authorization?.Parameter, + OnAfterAuthHeaderCreation = request => headerSeenByOnAfter = request.Headers.Authorization?.Parameter, + CustomizeHttpRequestMessage = request => headerSeenByCustomize = request.Headers.Authorization?.Parameter, + }; + + // Act + await _input.UpdateRequestWithCertificateAsync(httpRequestMessage, content, options, appToken: true, new ClaimsPrincipal(), CancellationToken.None); + + // Assert - OnBefore ran (sentinel overwritten) and saw no header; OnAfter and CustomizeHttpRequestMessage saw it. + Assert.Null(headerSeenByOnBefore); + Assert.Equal("ey", headerSeenByOnAfter); + Assert.Equal("ey", headerSeenByCustomize); + } + + [Fact] + // Without request-binding (no SHR options configured), the bearer flow still creates and attaches the + // Authorization header. + public async Task UpdateRequestAsync_WithoutShr_CreatesAuthorizationHeaderAsync() + { + // Arrange + var httpRequestMessage = new HttpRequestMessage(HttpMethod.Post, "https://example.com/path"); + var content = new StringContent("test content"); + var options = new DownstreamApiOptions + { + Scopes = ["scope1"], + }; + + // Act + await _input.UpdateRequestWithCertificateAsync(httpRequestMessage, content, options, false, new ClaimsPrincipal(), CancellationToken.None); + + // Assert - the Authorization header is created and attached. + Assert.True(httpRequestMessage.Headers.Contains("Authorization")); + Assert.Equal("ey", httpRequestMessage.Headers.Authorization?.Parameter); + } + + [Fact] + // With request-binding (SHR) configured, the Authorization header is still created and attached. The request + // shaped by OnBeforeAuthHeaderCreation is flowed to the provider before signing (so SHR q/h/b can bind it), + // and the header is added afterwards. + public async Task UpdateRequestAsync_WithShr_CreatesAuthorizationHeaderAsync() + { + // 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("signed-body"); + var options = new DownstreamApiOptions + { + Scopes = ["scope1"], + OnBeforeAuthHeaderCreation = request => request.Headers.TryAddWithoutValidation("X-Bound-Header", "hvalue"), + }; + // Simulate a request-binding (SHR) caller: WithShrOptions stashes the SHR options in ExtraParameters. + options.AcquireTokenOptions.ExtraParameters = new Dictionary(StringComparer.Ordinal) + { + ["SignedHttpRequestCreationParametersOptions"] = new object(), + }; + + // Act + await downstreamApi.UpdateRequestWithCertificateAsync(httpRequestMessage, content, options, false, new ClaimsPrincipal(), CancellationToken.None); + + // Assert - the OnBefore-shaped request reached the provider before signing (Authorization not yet present), + // and the Authorization header is created and attached to the final request. + Assert.NotNull(authorizationHeaderProvider.CapturedRequest); + Assert.True(authorizationHeaderProvider.CapturedRequest!.Headers.Contains("X-Bound-Header")); + Assert.False(authorizationHeaderProvider.AuthorizationHeaderPresentWhenCaptured); + Assert.True(httpRequestMessage.Headers.Contains("Authorization")); + } + [Theory] [InlineData("Authorization")] [InlineData("Cookie")]