Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions changelog.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
11 changes: 8 additions & 3 deletions src/Microsoft.Identity.Web.DownstreamApi/DownstreamApi.cs
Original file line number Diff line number Diff line change
Expand Up @@ -734,6 +734,10 @@ public Task<HttpResponseMessage> 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))
Expand Down Expand Up @@ -838,9 +842,10 @@ public Task<HttpResponseMessage> 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);
Comment thread
neha-bhargava marked this conversation as resolved.
effectiveOptions.CustomizeHttpRequestMessage?.Invoke(httpRequestMessage);

return (authorizationHeaderInformation, credential);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ namespace Microsoft.Identity.Web.Extensibility
/// metadata-rich header-creation surface without needing to opt in. Override the
/// <c>CreateAuthorizationHeaderInformation*</c> virtuals to customize that path.
/// </remarks>
public class BaseAuthorizationHeaderProvider : IAuthorizationHeaderProvider, IAuthorizationHeaderProvider2
public class BaseAuthorizationHeaderProvider : IAuthorizationHeaderProvider2
{
/// <summary>
/// Constructor from a service provider
Expand All @@ -36,7 +36,7 @@ public BaseAuthorizationHeaderProvider(IServiceProvider serviceProvider)
_headerProvider = new DefaultAuthorizationHeaderProvider(_tokenAcquisition);
}

private readonly DefaultAuthorizationHeaderProvider _headerProvider;
private readonly IAuthorizationHeaderProvider2 _headerProvider;

/// <inheritdoc/>
public virtual Task<string> CreateAuthorizationHeaderForUserAsync(IEnumerable<string> scopes, AuthorizationHeaderProviderOptions? authorizationHeaderProviderOptions = null, ClaimsPrincipal? claimsPrincipal = null, CancellationToken cancellationToken = default)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ namespace Microsoft.Identity.Web
* Treat as a public member.
*/
internal sealed class DefaultAuthorizationHeaderProvider :
IAuthorizationHeaderProvider,
IAuthorizationHeaderProvider2,
IBoundAuthorizationHeaderProvider
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,10 @@ public async Task UpdateRequestAsync_WithScopes_FlowsFinalRequestToAuthorization
ExtraQueryParameters = new Dictionary<string, string>
{
{ "fromOptions", "query-value" }
},
OnBeforeAuthHeaderCreation = request =>
{
request.Headers.TryAddWithoutValidation("X-From-OnBefore", "customized");
}
};

Expand All @@ -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"));
}
Expand Down Expand Up @@ -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<string, object>(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")]
Expand Down
Loading