Table of Contents

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>:

  1. Gets the services of the current request through IScopedServiceProviderAccessor: the scope set for the current async flow, otherwise the HttpContext.RequestServices of the current request, otherwise the service provider given to the constructor.
  2. Sends the request unchanged when no IApplicationContext is registered, or when the settings have no AuthenticationType (for example empty settings, given to the constructor that takes the IKeyValueSettings).
  3. When AuthenticationType is not Inject and the current user has an identity, sends the request unchanged unless the settings value contains the identity's authentication type (case-insensitive).
  4. Sends the request unchanged when it already has an Authorization header.
  5. Resolves the keyed ITokenProvider named by ProviderId, and sends the request unchanged when the settings have no ProviderId, when no provider is registered under it, when the provider returns a failed result or no token (NullTokenProvider), or when the token is expired.
  6. Sets the header: with Inject, the scheme is the token type (Bearer and Basic go in Authorization, any other type is the name of a custom header); otherwise the scheme is Bearer.
  7. Adds a culture header 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 IApplicationContext has no user, even with Inject settings. A call made with an application identity (client credentials) must still run with a principal set.
  • When AuthenticationType is not Inject, the settings value must be equal to the identity's authentication type (case-insensitive), not only contain it.
  • It passes the current ClaimsIdentity to the token provider as platformParameters.
  • 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.Get returns empty settings for a name that does not exist, and the handler then does nothing. Check the entry name in appsettings.json. The constructors that take the settings name throw ConfigurationException instead.
  • The AuthenticationType of the settings does not match the caller (step 3 above). Use Inject for an application identity.
  • The token provider is not registered under the ProviderId key, or it returned a failed result: the handler logs it in the Technical category.
  • For gRPC, the IApplicationContext has no principal.
  • The ProviderId is 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