Skip to content

Commit 19da5e1

Browse files
Add OnBeforeAuthHeaderCreation / OnAfterAuthHeaderCreation hooks to DownstreamApi
Honor the new AuthorizationHeaderProviderOptions hooks from Microsoft.Identity.Abstractions 12.5.0: - OnBeforeAuthHeaderCreation runs before the authorization header is created and signed, so callers can shape the request that request-binding protocols (SignedHttpRequest q/h/b) sign, ensuring the signature covers the finalized request. - OnAfterAuthHeaderCreation runs after the header is attached. The pre-existing CustomizeHttpRequestMessage is still invoked at the same point for backwards compatibility. Both hooks are propagated through MicrosoftIdentityMessageHandler. Also declare IAuthorizationHeaderProvider2 only on the Base/Default header providers (it already extends IAuthorizationHeaderProvider), and type the Base provider's delegate field as IAuthorizationHeaderProvider2 to avoid upcasting at the metadata delegation sites. Stacked on the regression fix (#3943). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 9f8e174 commit 19da5e1

6 files changed

Lines changed: 117 additions & 6 deletions

File tree

changelog.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,12 @@
33
### Bug fixes
44
- `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).
55

6+
### New features
7+
- `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).
8+
9+
### Fundamentals
10+
- `BaseAuthorizationHeaderProvider` and `DefaultAuthorizationHeaderProvider` declare `IAuthorizationHeaderProvider2` only, since it already extends `IAuthorizationHeaderProvider`. See [#3942](https://github.com/AzureAD/microsoft-identity-web/pull/3942).
11+
612
## 4.13.0
713

814
### New features

src/Microsoft.Identity.Web.DownstreamApi/DownstreamApi.cs

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -734,6 +734,10 @@ public Task<HttpResponseMessage> CallApiForAppAsync(
734734
httpRequestMessage.RequestUri = uriBuilder.Uri;
735735
}
736736

737+
// Opportunity to shape the request before the authorization header is created and (for request-binding
738+
// protocols such as SignedHttpRequest q/h/b) signed, so the signature covers the finalized request.
739+
effectiveOptions.OnBeforeAuthHeaderCreation?.Invoke(httpRequestMessage);
740+
737741
// Obtention of the authorization header (except when calling an anonymous endpoint)
738742
// which is done by not specifying any scopes or mTLS scheme.
739743
if (string.Equals(effectiveOptions.ProtocolScheme, Constants.MtlsProtocolScheme, StringComparison.OrdinalIgnoreCase))
@@ -838,9 +842,10 @@ public Task<HttpResponseMessage> CallApiForAppAsync(
838842
Logger.UnauthenticatedApiCall(_logger, null);
839843
}
840844

841-
// Opportunity to change the request message after the authorization header (including the Authorization
842-
// header) has been set, just before the request is sent, per CustomizeHttpRequestMessage's documented
843-
// contract. #3902 had moved this before header creation, which regressed callers that read the header.
845+
// Observe or adjust the finalized request once the authorization header (including the Authorization
846+
// header) has been set, just before it is sent. Fire the new OnAfterAuthHeaderCreation hook and the
847+
// pre-existing CustomizeHttpRequestMessage (retained for backwards compatibility); both share this timing.
848+
effectiveOptions.OnAfterAuthHeaderCreation?.Invoke(httpRequestMessage);
844849
effectiveOptions.CustomizeHttpRequestMessage?.Invoke(httpRequestMessage);
845850

846851
return (authorizationHeaderInformation, credential);

src/Microsoft.Identity.Web.TokenAcquisition/BaseAuthorizationHeaderProvider.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ namespace Microsoft.Identity.Web.Extensibility
2121
/// metadata-rich header-creation surface without needing to opt in. Override the
2222
/// <c>CreateAuthorizationHeaderInformation*</c> virtuals to customize that path.
2323
/// </remarks>
24-
public class BaseAuthorizationHeaderProvider : IAuthorizationHeaderProvider, IAuthorizationHeaderProvider2
24+
public class BaseAuthorizationHeaderProvider : IAuthorizationHeaderProvider2
2525
{
2626
/// <summary>
2727
/// Constructor from a service provider
@@ -36,7 +36,7 @@ public BaseAuthorizationHeaderProvider(IServiceProvider serviceProvider)
3636
_headerProvider = new DefaultAuthorizationHeaderProvider(_tokenAcquisition);
3737
}
3838

39-
private readonly DefaultAuthorizationHeaderProvider _headerProvider;
39+
private readonly IAuthorizationHeaderProvider2 _headerProvider;
4040

4141
/// <inheritdoc/>
4242
public virtual Task<string> CreateAuthorizationHeaderForUserAsync(IEnumerable<string> scopes, AuthorizationHeaderProviderOptions? authorizationHeaderProviderOptions = null, ClaimsPrincipal? claimsPrincipal = null, CancellationToken cancellationToken = default)

src/Microsoft.Identity.Web.TokenAcquisition/DefaultAuthorizationHeaderProvider.cs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@ namespace Microsoft.Identity.Web
1717
* Treat as a public member.
1818
*/
1919
internal sealed class DefaultAuthorizationHeaderProvider :
20-
IAuthorizationHeaderProvider,
2120
IAuthorizationHeaderProvider2,
2221
IBoundAuthorizationHeaderProvider
2322
{

src/Microsoft.Identity.Web.TokenAcquisition/MicrosoftIdentityMessageHandler.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -793,6 +793,8 @@ private static DownstreamApiOptions CreateDownstreamApiOptions(
793793
RelativePath = options.RelativePath,
794794
HttpMethod = options.HttpMethod,
795795
CustomizeHttpRequestMessage = options.CustomizeHttpRequestMessage,
796+
OnBeforeAuthHeaderCreation = options.OnBeforeAuthHeaderCreation,
797+
OnAfterAuthHeaderCreation = options.OnAfterAuthHeaderCreation,
796798
AcquireTokenOptions = options.AcquireTokenOptions,
797799
ProtocolScheme = options.ProtocolScheme,
798800
RequestAppToken = options.RequestAppToken,

tests/Microsoft.Identity.Web.Test/DownstreamWebApiSupport/DownstreamApiTests.cs

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,10 @@ public async Task UpdateRequestAsync_WithScopes_FlowsFinalRequestToAuthorization
170170
ExtraQueryParameters = new Dictionary<string, string>
171171
{
172172
{ "fromOptions", "query-value" }
173+
},
174+
OnBeforeAuthHeaderCreation = request =>
175+
{
176+
request.Headers.TryAddWithoutValidation("X-From-OnBefore", "customized");
173177
}
174178
};
175179

@@ -181,6 +185,7 @@ public async Task UpdateRequestAsync_WithScopes_FlowsFinalRequestToAuthorization
181185
Assert.Same(content, authorizationHeaderProvider.CapturedRequest!.Content);
182186
Assert.Contains("fromOptions=query-value", authorizationHeaderProvider.CapturedRequest.RequestUri!.Query, StringComparison.Ordinal);
183187
Assert.True(authorizationHeaderProvider.CapturedRequest.Headers.Contains("X-From-Options"));
188+
Assert.True(authorizationHeaderProvider.CapturedRequest.Headers.Contains("X-From-OnBefore"));
184189
Assert.False(authorizationHeaderProvider.AuthorizationHeaderPresentWhenCaptured);
185190
Assert.True(httpRequestMessage.Headers.Contains("Authorization"));
186191
}
@@ -211,6 +216,100 @@ public async Task UpdateRequestAsync_CustomizeHttpRequestMessage_SeesAuthorizati
211216
Assert.Equal("ey", authorizationHeaderSeenByCustomizer);
212217
}
213218

219+
[Fact]
220+
// Validates the authorization-header lifecycle hooks. OnBeforeAuthHeaderCreation runs before the header is
221+
// created (so it can shape the request that request-binding protocols such as SHR q/h/b sign) and therefore
222+
// sees no Authorization header. OnAfterAuthHeaderCreation and the pre-existing CustomizeHttpRequestMessage
223+
// (kept for backwards compatibility) run after the header is attached and both observe it.
224+
public async Task UpdateRequestAsync_AuthHeaderLifecycleHooks_RunAtDocumentedTimesAsync()
225+
{
226+
// Arrange
227+
var httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, "https://localhost:44352");
228+
var content = new StringContent("test content");
229+
string? headerSeenByOnBefore = "sentinel";
230+
string? headerSeenByOnAfter = null;
231+
string? headerSeenByCustomize = null;
232+
var options = new DownstreamApiOptions
233+
{
234+
Scopes = ["api://a021aff4-57ad-453a-bae8-e4192e5860f3/.default"],
235+
BaseUrl = "https://localhost:44352",
236+
RelativePath = "/WeatherForecast",
237+
RequestAppToken = true,
238+
OnBeforeAuthHeaderCreation = request => headerSeenByOnBefore = request.Headers.Authorization?.Parameter,
239+
OnAfterAuthHeaderCreation = request => headerSeenByOnAfter = request.Headers.Authorization?.Parameter,
240+
CustomizeHttpRequestMessage = request => headerSeenByCustomize = request.Headers.Authorization?.Parameter,
241+
};
242+
243+
// Act
244+
await _input.UpdateRequestWithCertificateAsync(httpRequestMessage, content, options, appToken: true, new ClaimsPrincipal(), CancellationToken.None);
245+
246+
// Assert - OnBefore ran (sentinel overwritten) and saw no header; OnAfter and CustomizeHttpRequestMessage saw it.
247+
Assert.Null(headerSeenByOnBefore);
248+
Assert.Equal("ey", headerSeenByOnAfter);
249+
Assert.Equal("ey", headerSeenByCustomize);
250+
}
251+
252+
[Fact]
253+
// Without request-binding (no SHR options configured), the bearer flow still creates and attaches the
254+
// Authorization header.
255+
public async Task UpdateRequestAsync_WithoutShr_CreatesAuthorizationHeaderAsync()
256+
{
257+
// Arrange
258+
var httpRequestMessage = new HttpRequestMessage(HttpMethod.Post, "https://example.com/path");
259+
var content = new StringContent("test content");
260+
var options = new DownstreamApiOptions
261+
{
262+
Scopes = ["scope1"],
263+
};
264+
265+
// Act
266+
await _input.UpdateRequestWithCertificateAsync(httpRequestMessage, content, options, false, new ClaimsPrincipal(), CancellationToken.None);
267+
268+
// Assert - the Authorization header is created and attached.
269+
Assert.True(httpRequestMessage.Headers.Contains("Authorization"));
270+
Assert.Equal("ey", httpRequestMessage.Headers.Authorization?.Parameter);
271+
}
272+
273+
[Fact]
274+
// With request-binding (SHR) configured, the Authorization header is still created and attached. The request
275+
// shaped by OnBeforeAuthHeaderCreation is flowed to the provider before signing (so SHR q/h/b can bind it),
276+
// and the header is added afterwards.
277+
public async Task UpdateRequestAsync_WithShr_CreatesAuthorizationHeaderAsync()
278+
{
279+
// Arrange
280+
var authorizationHeaderProvider = new CapturingAuthorizationHeaderProvider();
281+
var downstreamApi = new DownstreamApi(
282+
authorizationHeaderProvider,
283+
_namedDownstreamApiOptions,
284+
_httpClientFactory,
285+
_logger,
286+
msalHttpClientFactory: null,
287+
credentialsProvider: _provider);
288+
289+
var httpRequestMessage = new HttpRequestMessage(HttpMethod.Post, "https://example.com/path");
290+
var content = new StringContent("signed-body");
291+
var options = new DownstreamApiOptions
292+
{
293+
Scopes = ["scope1"],
294+
OnBeforeAuthHeaderCreation = request => request.Headers.TryAddWithoutValidation("X-Bound-Header", "hvalue"),
295+
};
296+
// Simulate a request-binding (SHR) caller: WithShrOptions stashes the SHR options in ExtraParameters.
297+
options.AcquireTokenOptions.ExtraParameters = new Dictionary<string, object>(StringComparer.Ordinal)
298+
{
299+
["SignedHttpRequestCreationParametersOptions"] = new object(),
300+
};
301+
302+
// Act
303+
await downstreamApi.UpdateRequestWithCertificateAsync(httpRequestMessage, content, options, false, new ClaimsPrincipal(), CancellationToken.None);
304+
305+
// Assert - the OnBefore-shaped request reached the provider before signing (Authorization not yet present),
306+
// and the Authorization header is created and attached to the final request.
307+
Assert.NotNull(authorizationHeaderProvider.CapturedRequest);
308+
Assert.True(authorizationHeaderProvider.CapturedRequest!.Headers.Contains("X-Bound-Header"));
309+
Assert.False(authorizationHeaderProvider.AuthorizationHeaderPresentWhenCaptured);
310+
Assert.True(httpRequestMessage.Headers.Contains("Authorization"));
311+
}
312+
214313
[Theory]
215314
[InlineData("Authorization")]
216315
[InlineData("Cookie")]

0 commit comments

Comments
 (0)