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
116 changes: 58 additions & 58 deletions src/Microsoft.Identity.Web.DownstreamApi/DownstreamApi.cs
Original file line number Diff line number Diff line change
Expand Up @@ -679,6 +679,64 @@ public Task<HttpResponseMessage> 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))
Expand Down Expand Up @@ -783,64 +841,6 @@ public Task<HttpResponseMessage> 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);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,113 @@ 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<string, string>
{
{ "X-From-Options", "header-value" }
},
ExtraQueryParameters = new Dictionary<string, string>
{
{ "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);
Comment thread
gladjohn marked this conversation as resolved.
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<string, string>
{
{ 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)]
Expand Down Expand Up @@ -1227,6 +1334,37 @@ public Task<string> CreateAuthorizationHeaderAsync(IEnumerable<string> scopes, A
}
}

public class CapturingAuthorizationHeaderProvider : IAuthorizationHeaderProvider
{
public HttpRequestMessage? CapturedRequest { get; private set; }

public bool AuthorizationHeaderPresentWhenCaptured { get; private set; }

public Task<string> CreateAuthorizationHeaderForAppAsync(string scopes, AuthorizationHeaderProviderOptions? downstreamApiOptions = null, CancellationToken cancellationToken = default)
{
CaptureRequest(downstreamApiOptions);
return Task.FromResult("******");
}

public Task<string> CreateAuthorizationHeaderForUserAsync(IEnumerable<string> scopes, AuthorizationHeaderProviderOptions? authorizationHeaderProviderOptions = null, ClaimsPrincipal? claimsPrincipal = null, CancellationToken cancellationToken = default)
{
CaptureRequest(authorizationHeaderProviderOptions);
return Task.FromResult("******");
}

public Task<string> CreateAuthorizationHeaderAsync(IEnumerable<string> 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<string> CreateAuthorizationHeaderForAppAsync(string scopes, AuthorizationHeaderProviderOptions? downstreamApiOptions = null, CancellationToken cancellationToken = default)
Expand All @@ -1252,4 +1390,3 @@ public class ThrowingAuthorizationHeaderProvider : IAuthorizationHeaderProvider
public Task<string> CreateAuthorizationHeaderForUserAsync(IEnumerable<string> scopes, AuthorizationHeaderProviderOptions? authorizationHeaderProviderOptions = null, ClaimsPrincipal? claimsPrincipal = null, CancellationToken cancellationToken = default) => throw new NotImplementedException();
}
}

Loading