A modular .NET 8 library that provide an OAuth2 client credentials token acquisition, in-memory caching and HttpClient bearer injection. Features:
- Automatic access token retrieval and refresh
- Caching of tokens (in-memory by default)
- Plug-in architecture for token storage (e.g. future Redis) by implementing the
IAccessTokenCacheinterface - Simple service registration via extension methods
- Seamless bearer injection using a delegating handler
What's the logic behind? 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:
- Work with any OAuth 2.0 server that supports the Client Credentials flow, even without OpenID Connect metadata
- Plug in your own endpoints for token acquisition
- Keep a clean separation between token management, caching, and HTTP request logic
- Is possible to extend the OAuth 2.0
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:
- Implementing your own
IAccessTokenProviderto handle alternative flows (e.g. Resource Owner Password, JWT Bearer, or Device Code) - Reusing the same caching and injection infrastructure already in place
- Registering your provider through the existing dependency injection extensions
This makes the library a flexible foundation for building modular authentication clients that can adapt to different OAuth 2.0 scenarios.
- NuGet Package
- Projects
- Quick Start
- Configuration
- Sample Console App
- How Automatic Token Injection Works
- Multiple Clients
- Redis / Distributed Cache Extension (For future implementation)
- Extensibility Points
- Diagnostics & Logging
- Security Considerations
- Troubleshooting
- Roadmap / Ideas / Contributions needed
- License
Install the package via NuGet Package Manager Console:
dotnet add package Another.OAuth2.ClientUtility.ClientCredentialsFlowThe package includes:
- In-memory token caching by default
- Automatic token refresh before expiration
- HttpClient integration via delegating handler
- Extensible architecture for custom token providers and cache implementations
| 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 |
The NuGet package project that bundles the above. |
- Prepare configuration (
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/"
}
}
}- Register services in
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);
});- Inject and use
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);
}
}- Run your application, the first API call will automatically handle token acquisition and injection.
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 request
Protected 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);- You register a named
HttpClientviaAddClientCredentialsHttpClient - The extension:
- Adds the token provider (for contacting the OAuth token endpoint)
- Registers a keyed internal token manager (one per client name)
- Attaches
ClientCredentialsDelegatingHandler
- On first outbound request:
- Handler sees no
Authorizationheader - Requests a token via the manager & provider
- Injects
Authorization: Bearer <token>
- Handler sees no
- Subsequent requests reuse cached token until near expiry
- On expiry or pre-refresh moment, a new token is fetched (single-threaded via semaphore)
Register multiple clients, here an example:
// First client called "CatalogApi"
services.AddClientCredentialsHttpClient("CatalogApi", config.GetSection("OAuthClients:CatalogApi"))
.ConfigureHttpClient((_, c) =>
c.BaseAddress = new Uri(config["ProtectedApis:CatalogApi:BaseUrl"])
);
// Second e client called "ReportsApi"
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.
Redis / Distributed cache extension (Not implemented, this is an example on how to use third parties caching systems)
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:
- Trace: token reuse / header injection decisions
- Debug: token request boundaries
- Error: token endpoint failures
- Store
ClientSecretsecurely (user secrets, environment variables, Azure Key Vault) - Avoid logging the full token; truncate if needed
- Prefer HTTPS for both token and resource endpoints (enforced by
Uriusage) - Rotate client secrets periodically
- If using distributed cache, consider encrypting access tokens at rest
| 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 |
Here a list of nedesired features for the next releases:
- Add Unit Test coverage
- Proactive token refresh service that refreshes before expiry in background
- Metrics (events: token requested / reused / expired)
- Polly integration for transient token endpoint retry logic, because now it fails immediately on error responses, which may be too harsh for some scenarios, maybe is better to retry a few times
- Redis / Memory hybrid fallback cache (try Redis, fallback to memory)
- Find a way that makes third party cache easier to integrate with minimal code (e.g. via NuGet)
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 😊