Automatic OAuth2 client credentials token acquisition, in-memory caching and HttpClient bearer injection
$ dotnet add package Another.OAuth2.ClientUtility.ClientCredentialsFlowA modular .NET 8 library that provide an OAuth2 client credentials token acquisition, in-memory caching and HttpClient bearer injection
IAccessTokenCache interface.You can reuse a named HttpClient across your application, the first outbound request triggers token acquisition, subsequent requests reuse the cached token until refresh is required.
This project was born from a practical limitation encountered when integrating with an OAuth 2.0 authorization server that did not expose a valid OpenID Connect Discovery document (the .well-known/openid-configuration endpoint).
Because of this missing endpoint, it wasn’t possible to use MSAL.NET - Microsoft’s official library - even though the latest versions of MSAL now support the Client Credentials flow for non-Azure OAuth 2.0 providers. This library therefore provides a lightweight and fully configurable alternative, allowing you to:
Use it when your identity provider doesn’t fully implement OpenID Connect discovery or when you simply prefer a minimal, dependency-free solution focused on server-to-server authentication.
Although this library currently focuses on the Client Credentials flow, its architecture was designed with extensibility in mind.
You can easily extend it to support additional OAuth 2.0 grant types or custom authentication mechanisms by:
IAccessTokenProvider to handle alternative flows (e.g. Resource Owner Password, JWT Bearer, or Device Code)This makes the library a flexible foundation for building modular authentication clients that can adapt to different OAuth 2.0 scenarios.
Install the package via NuGet Package Manager Console:
dotnet add package Another.OAuth2.ClientUtility.ClientCredentialsFlow.NuGet
The package includes:
| Project | Purpose |
|---|---|
Common | Shared abstractions, option models, token models. |
BusinessLogic | Token provider + delegating handler + DI extensions. |
DataAccess | Token manager (internal) + cache abstraction + in-memory cache. |
ConsoleAppSample | Example usage showing a protected API call. |
ClientCredentialsFlow.NuGet | The NuGet package project that bundles the above. |
appsettings.json):{
"OAuthClients":{
"SampleApi":{
"TokenEndpoint":"https://demo.duendesoftware.com/connect/token",
"ClientId":"m2m",
"ClientSecret":"secret",
"Scope":"api",
"RefreshBeforeExpiration":"00:00:05"
}
},
"ProtectedApis":{
"SampleApi":{
"BaseUrl":"https://demo.duendesoftware.com/"
}
}
}
Program.cs:services.AddClientCredentialsHttpClient( "SampleApi", configuration.GetSection("OAuthClients:SampleApi")) .ConfigureHttpClient((sp, client) => { var baseUrl = configuration.GetValue<string>("ProtectedApis:SampleApi:BaseUrl"); client.BaseAddress = new Uri(baseUrl); });
HttpClient:public class MyService {
private readonly HttpClient _httpClient;
public MyService(HttpClient httpClient) { _httpClient = httpClient; }
public async Task CallApiAsync() {
var response = await _httpClient.GetAsync("api/data");
response.EnsureSuccessStatusCode();
var content = await response.Content.ReadAsStringAsync();
Console.WriteLine(content);
}
}
ClientCredentialsOptions (named options per client):
TokenEndpoint (required) – full token endpoint URL (e.g. https://demo.duendesoftware.com/connect/token)ClientId, ClientSecret (required)Scope (optional) – space-delimited or single scopeAudience (optional)AdditionalBodyParameters / AdditionalHeaders (optional dictionaries)RefreshBeforeExpiration – subtract time from token expiry to refresh earlyTimeout – per token requestProtected API settings (SampleClientSettings in sample) supply base URL only.
Entry point: SampleConsoleRunner:
var client = _httpClientFactory.CreateClient(SampleConsoleRunner.SampleClientName);
var json = await client.GetStringAsync("api/test");
_logger.LogInformation("Protected API response: {Response}", json);
HttpClient via AddClientCredentialsHttpClient.ClientCredentialsDelegatingHandler.Authorization header.Authorization: Bearer <token>.Register multiple:
services.AddClientCredentialsHttpClient("CatalogApi", config.GetSection("OAuthClients:CatalogApi")) .ConfigureHttpClient((_, c) => c.BaseAddress = new Uri(config["ProtectedApis:CatalogApi:BaseUrl"]));
services.AddClientCredentialsHttpClient("ReportsApi", config.GetSection("OAuthClients:ReportsApi")) .ConfigureHttpClient((_, c) => c.BaseAddress = new Uri(config["ProtectedApis:ReportsApi:BaseUrl"]));
Use via named HttpClient:
var catalogClient = _httpClientFactory.CreateClient("CatalogApi");
var reportsClient = _httpClientFactory.CreateClient("ReportsApi");
Each client has isolated options, cache, and refresh lifecycle.
Introduce an abstraction already defined:
public interface IAccessTokenCache { Task<AccessToken?> GetAsync(string clientName, CancellationToken ct = default); Task SetAsync(string clientName, AccessToken token, CancellationToken ct = default); }
Create a Redis implementation (example):
public sealed class RedisAccessTokenCache : IAccessTokenCache { private readonly IConnectionMultiplexer _redis; public RedisAccessTokenCache(IConnectionMultiplexer redis) => _redis = redis;
public async Task<AccessToken?> GetAsync(string clientName, CancellationToken ct)
{
var db = _redis.GetDatabase();
var raw = await db.StringGetAsync($"oauth:token:{clientName}");
if (raw.IsNullOrEmpty) return null;
var parts = raw.ToString().Split('|', 2);
return parts.Length == 2 && DateTimeOffset.TryParse(parts[1], out var exp)
? new AccessToken(parts[0], exp)
: null;
}
public async Task SetAsync(string clientName, AccessToken token, CancellationToken ct)
{
var db = _redis.GetDatabase();
var value = $"{token.Value}|{token.ExpiresAt:O}";
var ttl = token.ExpiresAt - DateTimeOffset.UtcNow;
await db.StringSetAsync($"oauth:token:{clientName}", value, ttl > TimeSpan.Zero ? ttl : TimeSpan.FromMinutes(5));
}
}
Wire it in place of the memory cache:
services.AddSingleton<IAccessTokenCache, RedisAccessTokenCache>();
Done, whitout any change to handlers or consumers.
| Component | Interface | Replace When |
|---|---|---|
| Token retrieval | IClientCredentialsTokenProvider | Custom auth server logic / mTLS |
| Caching | IAccessTokenCache | Distributed (Redis) / encryption requirements |
| Delegating handler | ClientCredentialsDelegatingHandler | Advanced header logic / tracing |
| Options | ClientCredentialsOptions | Additional OAuth parameters |
You can also add a proactive refresh background service if you need zero latency on first post-expiration call (optional).
Log levels:
ClientSecret securely (user secrets, environment variables, Azure Key Vault).Uri usage).| Issue | Cause | Resolution |
|---|---|---|
404 calling /api/test | Wrong BaseAddress or path | Verify BaseUrl ends with / and call relative "api/test" |
| 401 Unauthorized | Invalid client credentials / scope mismatch | Check ClientId, ClientSecret, Scope in config |
| Token not refreshed | RefreshBeforeExpiration zero or handler skipped | Do not set your own Authorization header; rely on handler |
| Extension method missing | Missing project reference or using | Add reference & using ...BusinessLogic.Extensions |
| Multiple token requests close together | Manager not shared | Ensure keyed registration was used, or factory exists |
See LICENSE file.
The sample uses the public Duende demo (https://demo.duendesoftware.com), intended for development only. Do not rely on demo credentials or endpoints in production.
Feedback or contributions are super-welcome, feel free to open issues or pull requests. Happy coding 😊