Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add workaround for Scope Mismatch issue for HttpClientFactory #34864

Merged
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
19 changes: 17 additions & 2 deletions docs/core/extensions/httpclient-factory.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ The typed client is registered as transient with DI. In the preceding code, `Add
1. Create an instance of `TodoService`, passing in the instance of `HttpClient` to its constructor.

> [!IMPORTANT]
> Using typed clients in singleton services can be dangerous. For more information, see the [Using Typed clients in singleton services](#use-typed-clients-in-singleton-services) section.
> Using typed clients in singleton services can be dangerous. For more information, see the [Avoid Typed clients in singleton services](#avoid-typed-clients-in-singleton-services) section.

### Generated clients

Expand Down Expand Up @@ -256,7 +256,7 @@ services.AddHttpClient(name)
.SetHandlerLifetime(Timeout.InfiniteTimeSpan); // Disable rotation, as it is handled by PooledConnectionLifetime
```

## Use typed clients in singleton services
## Avoid typed clients in singleton services

When using the _named client_ approach, `IHttpClientFactory` is injected into services, and `HttpClient` instances are created by calling <xref:System.Net.Http.IHttpClientFactory.CreateClient%2A> every time an `HttpClient` is needed.

Expand All @@ -278,12 +278,27 @@ If you need to use `HttpClient` instances in a singleton service, consider the f

Users are strongly advised **not to cache scope-related information** (such as data from `HttpContext`) inside `HttpMessageHandler` instances and use scoped dependencies with caution to avoid leaking sensitive information.

If you require access to an app DI scope from your message handler, for authentication as an example, you'd encapsulate scope-aware logic in a separate transient `DelegatingHandler`, and wrap it around an `HttpMessageHandler` instance from the `IHttpClientFactory` cache. To access the handler call <xref:System.Net.Http.IHttpMessageHandlerFactory.CreateHandler%2A?displayProperty=nameWithType> for any registered _named client_. In that case, you'd create an `HttpClient` instance yourself using the constructed handler.

:::image type="content" source="media/httpclientfactory-scopes-workaround.png" alt-text="Diagram showing gaining access to app DI scopes via a separate transient message handler and IHttpMessageHandlerFactory":::

The following example shows creating an `HttpClient` with a scope-aware `DelegatingHandler`:

:::code source="snippets/http/scopeworkaround/ScopeAwareHttpClientFactory.cs" id="CreateClient":::

A further workaround can follow with an extension method for registering a scope-aware `DelegatingHandler` and overriding default `IHttpClientFactory` registration by a transient service with access to the current app scope:

:::code source="snippets/http/scopeworkaround/ScopeAwareHttpClientFactory.cs" id="AddScopeAwareHttpHandler":::

For more information, see the [full example](https://github.com/dotnet/docs/tree/main/docs/core/extensions/snippets/http/scopeworkaround).

## See also

- [Dependency injection in .NET][di]
- [Logging in .NET][logging]
- [Configuration in .NET][config]
- <xref:System.Net.Http.IHttpClientFactory>
- <xref:System.Net.Http.IHttpMessageHandlerFactory>
- <xref:System.Net.Http.HttpClient>
- [Make HTTP requests with the HttpClient][httpclient]
- [Implement HTTP retry with exponential backoff][http-retry]
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
using System.Security.Claims;

internal class AuthenticatingHandler : DelegatingHandler
{
private readonly UserContext _userContext;

public AuthenticatingHandler(IHttpContextAccessor httpContext, UserContext userContext)
{
_userContext = userContext;
_userContext.User = httpContext.HttpContext!.User;
}

protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
request.Headers.TryAddWithoutValidation("X-Auth", _userContext.GetCurrentUserName());

// Emulate server checking auth: this will catch scope mismatch error
// when ScopeAwareHttpClientFactory isn't used.
if (!request.RequestUri!.Query.Contains($"userId={_userContext.GetCurrentUserId()}"))
{
return Task.FromResult(new HttpResponseMessage(System.Net.HttpStatusCode.Unauthorized));
}

return base.SendAsync(request, cancellationToken);
}
}

class UserContext
{
public static readonly HttpRequestOptionsKey<UserContext> Key = new("userContext");

public ClaimsPrincipal? User { get; set; }

public string? GetCurrentUserName() =>
User?.Identity?.Name;

public string? GetCurrentUserId() =>
User?.FindFirstValue(ClaimTypes.NameIdentifier);
}
49 changes: 49 additions & 0 deletions docs/core/extensions/snippets/http/scopeworkaround/Program.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
using System.Security.Claims;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<UserContext>();

builder.Services.AddHttpClient("backend")
.AddScopeAwareHttpHandler<AuthenticatingHandler>();

var app = builder.Build();

app.Use((context, next) =>
{
// Pretend the incoming request has a random user
context.User = MakeRandomUser()!;

var userContext = context.RequestServices.GetRequiredService<UserContext>();
userContext.User = context.User;
CarnaViire marked this conversation as resolved.
Show resolved Hide resolved

return next(context);
});

static ClaimsPrincipal MakeRandomUser()
{
var id = Random.Shared.Next(1, 11).ToString();
var name = Guid.NewGuid().ToString();
var user = new ClaimsPrincipal(
new ClaimsIdentity(new[]
{
new Claim(ClaimTypes.Name, name),
new Claim(ClaimTypes.NameIdentifier, id)
},
"CustomAuth"));

return user;
}

app.MapGet("/", async (IHttpClientFactory factory, UserContext context) =>
{
var client = factory.CreateClient("backend");
var response = await client.GetAsync($"https://jsonplaceholder.typicode.com/todos?userId={context.GetCurrentUserId()}");
response.EnsureSuccessStatusCode();

return Results.Stream(
await response.Content.ReadAsStreamAsync(),
response.Content.Headers.ContentType!.ToString());
});

app.Run();
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Http;
using Microsoft.Extensions.Options;

// Overrides default IHttpClientFactory registration as a transient
// service with access to the current scope.
public class ScopeAwareHttpClientFactory : IHttpClientFactory
{
private readonly IServiceProvider _scopeServiceProvider;
private readonly IHttpMessageHandlerFactory _httpMessageHandlerFactory; // using IHttpMessageHandlerFactory to get access to HttpClientFactory's cached handlers
private readonly IOptionsMonitor<HttpClientFactoryOptions> _hcfOptionsMonitor;
private readonly IOptionsMonitor<ScopeAwareHttpClientFactoryOptions> _scopeAwareOptionsMonitor;

public ScopeAwareHttpClientFactory(
IServiceProvider scopeServiceProvider,
IHttpMessageHandlerFactory httpMessageHandlerFactory,
IOptionsMonitor<HttpClientFactoryOptions> hcfOptionsMonitor,
IOptionsMonitor<ScopeAwareHttpClientFactoryOptions> scopeAwareOptionsMonitor)
{
_scopeServiceProvider = scopeServiceProvider;
_httpMessageHandlerFactory = httpMessageHandlerFactory;
_hcfOptionsMonitor = hcfOptionsMonitor;
_scopeAwareOptionsMonitor = scopeAwareOptionsMonitor;
}

public HttpClient CreateClient(string name)
{
DelegatingHandler? scopeAwareHandler = null;

// Get custom options to get scope aware handler information
ScopeAwareHttpClientFactoryOptions scopeAwareOptions = _scopeAwareOptionsMonitor.Get(name);
Type? scopeAwareHandlerType = scopeAwareOptions.HttpHandlerType;

// <CreateClient>
if (scopeAwareHandlerType != null)
{
if (!typeof(DelegatingHandler).IsAssignableFrom(scopeAwareHandlerType))
{
throw new ArgumentException($"""
Scope aware HttpHandler {scopeAwareHandlerType.Name} should
be assignable to DelegatingHandler
""");
}

// Create top-most delegating handler with scoped dependencies
scopeAwareHandler = (DelegatingHandler)_scopeServiceProvider.GetRequiredService(scopeAwareHandlerType); // should be transient
if (scopeAwareHandler.InnerHandler != null)
{
throw new ArgumentException($"""
Inner handler of a delegating handler {scopeAwareHandlerType.Name} should be null.
Scope aware HttpHandler should be registered as Transient.
""");
}
}

// Get or create HttpMessageHandler from HttpClientFactory
HttpMessageHandler handler = _httpMessageHandlerFactory.CreateHandler(name);

if (scopeAwareHandler != null)
{
scopeAwareHandler.InnerHandler = handler;
handler = scopeAwareHandler;
}

HttpClient client = new(handler);
// </CreateClient>

// configure HttpClient in the same way HttpClientFactory would do
HttpClientFactoryOptions hcfOptions = _hcfOptionsMonitor.Get(name);
for (int i = 0; i < hcfOptions.HttpClientActions.Count; i++)
{
hcfOptions.HttpClientActions[i](client);
}
return client;
}
}

public class ScopeAwareHttpClientFactoryOptions
{
public Type? HttpHandlerType { get; set; }
}

public static class ScopeAwareHttpClientBuilderExtensions
{
// <AddScopeAwareHttpHandler>
public static IHttpClientBuilder AddScopeAwareHttpHandler<THandler>(
this IHttpClientBuilder builder) where THandler : DelegatingHandler
{
builder.Services.TryAddTransient<THandler>();
if (!builder.Services.Any(sd => sd.ImplementationType == typeof(ScopeAwareHttpClientFactory)))
{
// Override default IHttpClientFactory registration
builder.Services.AddTransient<IHttpClientFactory, ScopeAwareHttpClientFactory>();
}

builder.Services.Configure<ScopeAwareHttpClientFactoryOptions>(
builder.Name, options => options.HttpHandlerType = typeof(THandler));

return builder;
}
// </AddScopeAwareHttpHandler>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<Project Sdk="Microsoft.NET.Sdk.Web">

<PropertyGroup>
<TargetFramework>net7.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>ScopeWorkaround</RootNamespace>
</PropertyGroup>

</Project>