Table of Contents

Authentication Client

An authentication client is a typed HttpClient. It requests an access token from the provider — nothing else. The Authentication Service handles caching and refresh.

Note

General-purpose OAuth 2.0 libraries exist, such as IdentityModel. Use native .NET components instead. This maximizes performance, avoids third-party dependencies, and supports trimming and native AOT.

Using AbstractClientCredentialsAuthenticationClient

For providers that use the client_credentials grant, extend AbstractClientCredentialsAuthenticationClient. The base class handles the token request and JSON deserialization; you implement three abstract properties:

[SuppressMessage("Performance", "CA1812:Avoid uninstantiated internal classes", Justification = "Instantiated by DI container")]
internal sealed class MyAuthenticationClient : AbstractClientCredentialsAuthenticationClient
{
    private readonly IOptions<MyServiceOptions> _options;

    public MyAuthenticationClient(HttpClient httpClient, IOptions<MyServiceOptions> options)
        : base(httpClient)
    {
        _options = options ?? throw new ArgumentNullException(nameof(options));
    }

    protected override string ClientId => _options.Value.ClientId;

    protected override string ClientSecret => _options.Value.ClientSecret;

    protected override Uri TokenUri => new(_options.Value.EndpointUri, "/oauth/token");
}

By default, the base class sends ClientId and ClientSecret as client_id and client_secret form fields in the request body. If the provider instead requires HTTP Basic Authentication, override the virtual UseHttpBasicAuthentication property:

protected override bool UseHttpBasicAuthentication => true;

If the provider requires additional headers on the token request, override the virtual ApplyAdditionalHeaders method:

protected override void ApplyAdditionalHeaders(HttpRequestMessage request)
{
    request.Headers.Add("x-merchant-id", _options.Value.AccountNumber);
}

Custom flows

For providers that use a non-standard grant type, bearer token in the request body, or another scheme that does not fit the client-credentials pattern, implement IAuthenticationClient directly:

[SuppressMessage("Performance", "CA1812:Avoid uninstantiated internal classes", Justification = "Instantiated by DI container")]
internal sealed class MyAuthenticationClient : IAuthenticationClient
{
    private readonly HttpClient _httpClient;

    private readonly IOptions<MyServiceOptions> _options;

    internal MyAuthenticationClient(HttpClient httpClient, IOptions<MyServiceOptions> options)
    {
        _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
        _options = options ?? throw new ArgumentNullException(nameof(options));
    }

    public async Task<TokenResponse?> RequestClientCredentialsTokenAsync(CancellationToken cancellationToken = default)
    {
        Uri requestUri = new(_options.Value.EndpointUri, "/oauth/token");

        List<KeyValuePair<string, string>> payload =
        [
            new("grant_type", "client_credentials"),
            new("client_id", _options.Value.ClientId),
            new("client_secret", _options.Value.ClientSecret),
        ];

        using HttpRequestMessage request = new(HttpMethod.Post, requestUri);

        request.Content = new FormUrlEncodedContent(payload);

        using HttpResponseMessage response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
        if ( !response.IsSuccessStatusCode )
        {
            return null;
        }

        return await response.Content.ReadFromJsonAsync(DefaultJsonSerializerContext.Default.TokenResponse, cancellationToken)
                                     .ConfigureAwait(false);
    }
}
Note

The DefaultJsonSerializerContext instance passed to ReadFromJsonAsync(...) is a System.Text.Json source generator, required for correct behavior in trimmed and native AOT deployments.

Note

Mark the authentication client internal. This reduces the public API surface if you redistribute the integration as a library.

Authentication Service

The authentication service wraps the authentication client and does the following:

  • It requests an access token from the underlying service.
  • It caches the access token with HybridCache to avoid unnecessary calls.
    • If the cached token expires, it requests a new token.

The example below assumes a MyServiceOptions class that extends AbstractServiceOptions and exposes the provider credentials:

public sealed class MyServiceOptions : AbstractServiceOptions
{
    public const string SectionName = "AddressValidationSettings:MyProvider";

    public override Uri EndpointUri => /* ... */;

    [Required(AllowEmptyStrings = false)]
    public required string ClientId { get; set; }

    [Required(AllowEmptyStrings = false)]
    public required string ClientSecret { get; set; }
}
Note

See Registering Services for the complete options class definition, including the EndpointUri implementation and source-generated validator.

Below is an example of a simple authentication service that wraps the authentication client.

[SuppressMessage("Performance", "CA1812:Avoid uninstantiated internal classes", Justification = "Instantiated by DI container")]
internal sealed class MyAuthenticationService : AbstractAuthenticationService<MyAuthenticationClient>
{
    private readonly IOptions<MyServiceOptions> _options;

    internal MyAuthenticationService(
        HybridCache cache,
        IOptions<MyServiceOptions> options,
        MyAuthenticationClient authenticationClient)
        : base(authenticationClient, cache)
    {
        _options = options ?? throw new ArgumentNullException(nameof(options));
    }

    protected override string GenerateCacheKey()
    {
        return string.IsNullOrWhiteSpace(_options.Value.ClientId)
                   ? throw new InvalidOperationException($"{nameof(MyServiceOptions.ClientId)} is required to generate a cache key.")
                   : $"{CacheKeyTag}my-provider:{_options.Value.ClientId}:{_options.Value.ClientEnvironment}";
    }
}
Important

GenerateCacheKey() must return a non-null, non-empty string. The key may only contain letters, digits, underscores, hyphens, and colons. If the required credentials are absent, throw an InvalidOperationException instead of returning an empty or invalid value. The base class also throws if the key fails validation.

Important

The service must inherit the AbstractAuthenticationService<TClient> class.

Note

BearerTokenDelegatingHandler<TClient> uses this service to attach a bearer token to the request automatically. See the Registering Services page for details.

Note

Mark the authentication service internal. This reduces the public API surface if you redistribute the integration as a library.