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 2 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 [Using Typed clients in singleton services](#using-typed-clients-in-singleton-services) section.
CarnaViire marked this conversation as resolved.
Show resolved Hide resolved

### 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
## Using typed clients in singleton services
CarnaViire marked this conversation as resolved.
Show resolved Hide resolved

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 application DI scope from your message handler, e.g. for authentication, you can work around that by incapsulating scope-aware logic in a separate transient `DelegatingHandler`, and wrap it around `HttpMessageHandler` instance from the `IHttpClientFactory` cache, which you can get using `IHttpMessageHandlerFactory` by calling <xref:System.Net.Http.IHttpMessageHandlerFactory.CreateHandler%2A> for any registered _named client_. In that case, you would need to create `HttpClient` instance yourself using the constructed handler.
CarnaViire marked this conversation as resolved.
Show resolved Hide resolved

:::image type="content" source="media/httpclientfactory-scopes-workaround.png" alt-text="Diagram showing gaining access to application DI scopes via a separate transient message handler and IHttpMessageHandlerFactory":::
CarnaViire marked this conversation as resolved.
Show resolved Hide resolved

The following example shows creating an `HttpClient` with a scope-aware `HttpHandler`:
CarnaViire marked this conversation as resolved.
Show resolved Hide resolved

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

A further workaround can follow with an extension method for registering a scope-aware `HttpHandler` and overriding default `IHttpClientFactory` registration by a transient service with access to the current application scope:
CarnaViire marked this conversation as resolved.
Show resolved Hide resolved

:::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,42 @@
using System.Security.Claims;

internal class AuthenticatingHandler : DelegatingHandler
{
private readonly UserContext _userContext;

public AuthenticatingHandler(IHttpContextAccessor httpContext, UserContext userContext)
{
userContext.User = httpContext.HttpContext!.User;
_userContext = userContext;
CarnaViire marked this conversation as resolved.
Show resolved Hide resolved
}

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 if ScopeAwareHttpClientFactory isn't used
CarnaViire marked this conversation as resolved.
Show resolved Hide resolved
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()
{
return User?.Identity?.Name;
}
CarnaViire marked this conversation as resolved.
Show resolved Hide resolved

public string? GetCurrentUserId()
{
return User?.FindFirstValue(ClaimTypes.NameIdentifier);
}
CarnaViire marked this conversation as resolved.
Show resolved Hide resolved
}
41 changes: 41 additions & 0 deletions docs/core/extensions/snippets/http/scopeworkaround/Program.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
using System.Security.Claims;

var random = new Random();
CarnaViire marked this conversation as resolved.
Show resolved Hide resolved

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);
});

ClaimsPrincipal? MakeRandomUser()
CarnaViire marked this conversation as resolved.
Show resolved Hide resolved
{
var id = random.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;
CarnaViire marked this conversation as resolved.
Show resolved Hide resolved
}

app.MapGet("/", async (IHttpClientFactory c, UserContext context) =>
{
var client = c.CreateClient("backend");
CarnaViire marked this conversation as resolved.
Show resolved Hide resolved
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());
CarnaViire marked this conversation as resolved.
Show resolved Hide resolved
});

app.Run();
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
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
CarnaViire marked this conversation as resolved.
Show resolved Hide resolved
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");
CarnaViire marked this conversation as resolved.
Show resolved Hide resolved
}

// 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.");
CarnaViire marked this conversation as resolved.
Show resolved Hide resolved
}
}

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

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

HttpClient client = new HttpClient(handler);
CarnaViire marked this conversation as resolved.
Show resolved Hide resolved
// </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
CarnaViire marked this conversation as resolved.
Show resolved Hide resolved
{
builder.Services.TryAddTransient<THandler>();
if (!builder.Services.Any(sd => sd.ImplementationType == typeof(ScopeAwareHttpClientFactory)))
{
builder.Services.AddTransient<IHttpClientFactory, ScopeAwareHttpClientFactory>(); // override default IHttpClientFactory registration
CarnaViire marked this conversation as resolved.
Show resolved Hide resolved
}

builder.Services.Configure<ScopeAwareHttpClientFactoryOptions>(builder.Name, options => options.HttpHandlerType = typeof(THandler));
CarnaViire marked this conversation as resolved.
Show resolved Hide resolved

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>