diff --git a/docs/core/extensions/httpclient-factory.md b/docs/core/extensions/httpclient-factory.md index b23d1a1c596fa..de40a57707f39 100644 --- a/docs/core/extensions/httpclient-factory.md +++ b/docs/core/extensions/httpclient-factory.md @@ -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 @@ -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 every time an `HttpClient` is needed. @@ -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 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] - +- - - [Make HTTP requests with the HttpClient][httpclient] - [Implement HTTP retry with exponential backoff][http-retry] diff --git a/docs/core/extensions/media/httpclientfactory-scopes-workaround.png b/docs/core/extensions/media/httpclientfactory-scopes-workaround.png new file mode 100644 index 0000000000000..f4b35bf212e06 Binary files /dev/null and b/docs/core/extensions/media/httpclientfactory-scopes-workaround.png differ diff --git a/docs/core/extensions/snippets/http/scopeworkaround/AuthenticatingHandler.cs b/docs/core/extensions/snippets/http/scopeworkaround/AuthenticatingHandler.cs new file mode 100644 index 0000000000000..5070d91005cc8 --- /dev/null +++ b/docs/core/extensions/snippets/http/scopeworkaround/AuthenticatingHandler.cs @@ -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 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 Key = new("userContext"); + + public ClaimsPrincipal? User { get; set; } + + public string? GetCurrentUserName() => + User?.Identity?.Name; + + public string? GetCurrentUserId() => + User?.FindFirstValue(ClaimTypes.NameIdentifier); +} diff --git a/docs/core/extensions/snippets/http/scopeworkaround/Program.cs b/docs/core/extensions/snippets/http/scopeworkaround/Program.cs new file mode 100644 index 0000000000000..6f0c6160f53f6 --- /dev/null +++ b/docs/core/extensions/snippets/http/scopeworkaround/Program.cs @@ -0,0 +1,49 @@ +using System.Security.Claims; + +var builder = WebApplication.CreateBuilder(args); +builder.Services.AddHttpContextAccessor(); +builder.Services.AddScoped(); + +builder.Services.AddHttpClient("backend") + .AddScopeAwareHttpHandler(); + +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.User = context.User; + + 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(); diff --git a/docs/core/extensions/snippets/http/scopeworkaround/ScopeAwareHttpClientFactory.cs b/docs/core/extensions/snippets/http/scopeworkaround/ScopeAwareHttpClientFactory.cs new file mode 100644 index 0000000000000..e06b2be8685f5 --- /dev/null +++ b/docs/core/extensions/snippets/http/scopeworkaround/ScopeAwareHttpClientFactory.cs @@ -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 _hcfOptionsMonitor; + private readonly IOptionsMonitor _scopeAwareOptionsMonitor; + + public ScopeAwareHttpClientFactory( + IServiceProvider scopeServiceProvider, + IHttpMessageHandlerFactory httpMessageHandlerFactory, + IOptionsMonitor hcfOptionsMonitor, + IOptionsMonitor 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; + + // + 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); + // + + // 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 +{ + // + public static IHttpClientBuilder AddScopeAwareHttpHandler( + this IHttpClientBuilder builder) where THandler : DelegatingHandler + { + builder.Services.TryAddTransient(); + if (!builder.Services.Any(sd => sd.ImplementationType == typeof(ScopeAwareHttpClientFactory))) + { + // Override default IHttpClientFactory registration + builder.Services.AddTransient(); + } + + builder.Services.Configure( + builder.Name, options => options.HttpHandlerType = typeof(THandler)); + + return builder; + } + // +} diff --git a/docs/core/extensions/snippets/http/scopeworkaround/appsettings.json b/docs/core/extensions/snippets/http/scopeworkaround/appsettings.json new file mode 100644 index 0000000000000..10f68b8c8b4f7 --- /dev/null +++ b/docs/core/extensions/snippets/http/scopeworkaround/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/docs/core/extensions/snippets/http/scopeworkaround/scopeworkaround.csproj b/docs/core/extensions/snippets/http/scopeworkaround/scopeworkaround.csproj new file mode 100644 index 0000000000000..1f324f0a0556d --- /dev/null +++ b/docs/core/extensions/snippets/http/scopeworkaround/scopeworkaround.csproj @@ -0,0 +1,10 @@ + + + + net7.0 + enable + enable + ScopeWorkaround + + +