HttpClient and gRPC clients
Arc4u attaches tokens with a DelegatingHandler for HttpClient and an Interceptor for gRPC
clients. You add them to the clients you register with IHttpClientFactory or the gRPC client
factory, and give each one the named settings that select the
token provider.
| Handler | Package | Use it in |
|---|---|---|
| JwtHttpHandler<T> | Arc4u.OAuth2.AspNetCore.Authentication |
ASP.NET Core services and web apps. |
| OAuth2Interceptor<T> | Arc4u.gRPC |
gRPC clients, in services and client apps. |
| JwtHttpHandler<T> | Arc4u.OAuth2.Client.Authentication |
Desktop and mobile apps. See Desktop and mobile clients. |
| JwtHttpHandler | Arc4u.OAuth2.Blazor |
Blazor WebAssembly apps. See Blazor. |
Services the handlers need
The server-side handler and the interceptor resolve these services. The server authentication registration adds some of them, and the Arc4u source generator registers the exported ones (see Dependency injection).
| Service | Implementation | Why |
|---|---|---|
ILogger<T> (Arc4u) |
AddILogger() from Arc4u.Diagnostics |
The handlers log through the Arc4u logger. With the plain .NET logger, the first call throws InvalidOperationException: Bad Arc4u usage. See Diagnostics. |
| IScopedServiceProviderAccessor | ScopedServiceProviderAccessor (singleton), with AddHttpContextAccessor() |
Gives the handler the services of the current request, so it reads the user of that request. |
IApplicationContext |
ApplicationInstanceContext (scoped in a service) |
Holds the current user. |
| The token providers | See Token providers | Keyed by their ProviderId. |
| ITokenCache | ApplicationCache with CacheHelper | The client credentials and user name and password providers keep their tokens in it; they fail to resolve without it. ApplicationCache stores them in the Arc4u cache named by AddTokenCache (section Authentication:TokenCache). The shortest setup shows the complete registration with a memory cache. |
Add the handler to an HttpClient
Build a JwtHttpHandler<T> with the service provider, a logger and the
name of the settings, in AddHttpMessageHandler: the handler resolves the named
SimpleKeyValueSettings options and throws ConfigurationException when none exist with that
name. Another constructor takes the IKeyValueSettings themselves. The type parameter only sets
the logger category.
// Program.cs
using Arc4u.Configuration;
using Arc4u.OAuth2.Token;
var builder = WebApplication.CreateBuilder(args);
// ... token providers and their settings: see Token providers.
builder.Services.AddHttpClient<InventoryClient>(client => client.BaseAddress = new Uri("https://inventory.example.com/"))
.AddHttpMessageHandler(sp => new JwtHttpHandler<InventoryClient>(
sp,
sp.GetRequiredService<ILogger<InventoryClient>>(),
"Inventory"));
var app = builder.Build();
app.Run();
public sealed class InventoryClient(HttpClient httpClient)
{
public Task<string> GetStockAsync(string sku, CancellationToken cancellationToken)
=> httpClient.GetStringAsync($"stock/{sku}", cancellationToken);
}
"Inventory" is the settings name: the entry name under Authentication:ClientTokens,
Authentication:OnBehalfOf or Authentication:RemoteSecrets, or OAuth2 and Cookies for the
user's own token.
Chain several handlers
One client can serve several kinds of callers, for example an API called both by users (bearer
token) and by a web app (cookie), with a technical account as a fallback. Add one handler per
settings: the first handler that attaches a token wins, because the next ones skip a request
that already has an Authorization header.
builder.Services.AddHttpClient<InventoryClient>(client => client.BaseAddress = new Uri("https://inventory.example.com/"))
.AddHttpMessageHandler(sp => CreateHandler(sp, "OAuth2"))
.AddHttpMessageHandler(sp => CreateHandler(sp, "Cookies"))
.AddHttpMessageHandler(sp => CreateHandler(sp, "Inventory"));
static JwtHttpHandler<InventoryClient> CreateHandler(IServiceProvider sp, string settingsName)
=> new(sp,
sp.GetRequiredService<ILogger<InventoryClient>>(),
settingsName);
A handler whose token provider sends a custom header (a remote secret with a HeaderKey other
than Basic) does not set Authorization, so the next handlers still run.
How the handlers decide
For each request, JwtHttpHandler<T>:
- Gets the services of the current request through
IScopedServiceProviderAccessor: the scope set for the current async flow, otherwise theHttpContext.RequestServicesof the current request, otherwise the service provider given to the constructor. - Sends the request unchanged when no
IApplicationContextis registered, or when the settings have noAuthenticationType(for example empty settings, given to the constructor that takes theIKeyValueSettings). - When
AuthenticationTypeis notInjectand the current user has an identity, sends the request unchanged unless the settings value contains the identity's authentication type (case-insensitive). - Sends the request unchanged when it already has an
Authorizationheader. - Resolves the keyed
ITokenProvidernamed byProviderId, and sends the request unchanged when the settings have noProviderId, when no provider is registered under it, when the provider returns a failed result or no token (NullTokenProvider), or when the token is expired. - Sets the header: with
Inject, the scheme is the token type (BearerandBasicgo inAuthorization, any other type is the name of a custom header); otherwise the scheme isBearer. - Adds a
cultureheader with the two-letter language of the user, when there is a user.
An exception thrown by the token provider is not caught: the request fails with it.
Add the interceptor to a gRPC client
OAuth2Interceptor<T> does the same for gRPC calls: it adds an
authorization metadata entry (or a custom one) and a culture entry. Register it with the gRPC
client factory (Grpc.Net.ClientFactory package):
// Program.cs
using Arc4u.Configuration;
using Arc4u.gRPC.Interceptors;
var builder = WebApplication.CreateBuilder(args);
// ... token providers and their settings: see Token providers.
builder.Services.AddGrpcClient<Inventory.InventoryClient>(options => options.Address = new Uri("https://inventory.example.com"))
.AddInterceptor(sp => new OAuth2Interceptor<Inventory.InventoryClient>(
sp,
sp.GetRequiredService<ILogger<Inventory.InventoryClient>>(),
"Inventory"));
var app = builder.Build();
app.Run();
Inventory.InventoryClient is the client generated from your .proto file. The interceptor
differs from the HttpClient handler on these points:
- It adds nothing when the
IApplicationContexthas no user, even withInjectsettings. A call made with an application identity (client credentials) must still run with a principal set. - When
AuthenticationTypeis notInject, the settings value must be equal to the identity's authentication type (case-insensitive), not only contain it. - It passes the current
ClaimsIdentityto the token provider asplatformParameters. - For an asynchronous unary call, it awaits the token provider before the call starts; for a blocking or streaming call, it waits synchronously for it. It logs and ignores the exceptions the provider throws: the call is then sent without a token.
Status codes and ProblemDetails returned by gRPC services are covered in gRPC and versioning.
Call an API outside a request
A background job (an IHostedService, a message handler) has no HttpContext. Create a scope,
give it to the accessor so that the handlers read the IApplicationContext of that scope, and set
a principal in it when the call needs one:
using Arc4u.OAuth2;
public sealed class StockSynchronizer(IServiceScopeFactory scopeFactory, IScopedServiceProviderAccessor accessor) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
using var scope = scopeFactory.CreateScope();
accessor.ServiceProvider = scope.ServiceProvider;
var client = scope.ServiceProvider.GetRequiredService<InventoryClient>();
await client.GetStockAsync("sku-42", stoppingToken);
}
}
The accessor keeps the scope for the rest of the async flow, even after the scope is disposed: set
it again for each new scope, otherwise the next call fails with ObjectDisposedException. Without
a scope, the HttpClient handler and the gRPC interceptor fall back to the service provider they
were created with.
Troubleshooting
The downstream API returns 401 and the request has no Authorization header
- The settings given to the handler are empty:
IOptionsMonitor.Getreturns empty settings for a name that does not exist, and the handler then does nothing. Check the entry name inappsettings.json. The constructors that take the settings name throwConfigurationExceptioninstead. - The
AuthenticationTypeof the settings does not match the caller (step 3 above). UseInjectfor an application identity. - The token provider is not registered under the
ProviderIdkey, or it returned a failed result: the handler logs it in theTechnicalcategory. - For gRPC, the
IApplicationContexthas no principal. - The
ProviderIdis the string"null"(NullTokenProvider): the provider returns a success without a token, and the handlers and the interceptor send the request without one. This is by design.
See also
- Token providers
- Blazor
- gRPC and versioning
- Arc4u.gRPC.Interceptors in the API reference