Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
167 changes: 166 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,4 +157,169 @@ public async Task<ActionResult<SomeObject>> GetSomeObject(CancellationToken canc
"id": 1,
"name": "Leanne Graham"
}
```
```

## Token Caching (OAuth client credentials)

The Core library can transparently acquire and cache OAuth access tokens using the
`client_credentials` grant. Once enabled, tokens are requested on demand, cached per
scope in an `IDistributedCache`, attached to outgoing requests as an
`Authorization: Bearer` header, and automatically refreshed when the server responds
with `401 Unauthorized`.

The whole flow is handled by an `HttpClient` message handler
(`OAuthDelegatingHandler<TClient>`), so your client code does not have to fetch, store,
or renew tokens itself.

### Enable token caching

Call `AddOAuthTokenCaching<TClient>` **after** registering the client with
`AddOpenApiClient` / `AddExampleClient`, then **choose where tokens are cached**. The
choice is not optional: `AddOAuthTokenCaching<TClient>` returns a builder and you must
finish the chain with exactly one of the three terminal methods below.

The SDK never registers a cache backend on your behalf and never touches your
`IDistributedCache` registration. If you forget the terminal call, resolving the client
throws an `InvalidOperationException` that names the available options — it will not
silently fall back to a per-process cache.

| Terminal call | Where tokens live | Use when |
| --- | --- | --- |
| `.UseInMemoryCache()` | A private in-memory cache owned by this SDK, per process | Single-instance apps, local development, tests |
| `.UseRegisteredDistributedCache()` | The `IDistributedCache` registered in your container | You already configure Redis / SQL Server / etc. centrally |
| `.UseDistributedCache(cache)`<br>`.UseDistributedCache(sp => ...)` | A cache instance you supply directly | You want a dedicated cache for tokens, separate from the app's |

Program.cs
```c#
builder.Services
.AddExampleClient(builder.Configuration.GetSection("ExampleClient").Get<ExampleClientOptions>())
.AddOAuthTokenCaching<IExampleClient>()
.UseInMemoryCache();
```

> [!WARNING]
> `.UseInMemoryCache()` is **per process and not shared**. In a multi-instance
> deployment every instance keeps its own token cache and requests its own tokens. Pick
> a distributed option if that is not acceptable.

To reuse the `IDistributedCache` already registered in the container:

```c#
builder.Services.AddStackExchangeRedisCache(options =>
{
options.Configuration = "localhost:6379";
});

builder.Services
.AddExampleClient(builder.Configuration.GetSection("ExampleClient").Get<ExampleClientOptions>())
.AddOAuthTokenCaching<IExampleClient>()
.UseRegisteredDistributedCache();
```

Registration order no longer matters — the cache is resolved lazily, the first time a
token is needed. If nothing is registered at that point, or if the registered
implementation is `MemoryDistributedCache` (which `AddDistributedMemoryCache()`
registers and which is *not* shared across instances), the call throws. That is
deliberate: if you want a per-process cache, say so explicitly with
`.UseInMemoryCache()`.

To hand the SDK a cache instance directly, without registering it in the container:

```c#
builder.Services
.AddExampleClient(options)
.AddOAuthTokenCaching<IExampleClient>()
.UseDistributedCache(myTokenCache);

// or resolved lazily from the container
builder.Services
.AddExampleClient(options)
.AddOAuthTokenCaching<IExampleClient>()
.UseDistributedCache(sp => sp.GetRequiredKeyedService<IDistributedCache>("tokens"));
```

The cache choice is made **per client**, so different clients can use different
backends.

When you build clients through `OpenApiClientFactoryBuilder`, the same terminal methods
are available and return the factory builder so the chain continues:

```c#
var factory = new OpenApiClientFactoryBuilder()
.AddExampleClient(options)
.AddOAuthTokenCaching<IExampleClient>()
.UseInMemoryCache()
.Build();
```

### Migrating from 3.x

`AddOAuthTokenCaching<TClient>()` used to register an in-memory distributed cache as a
fallback when no `IDistributedCache` was present. That made the caching topology depend
on registration order and could silently give multi-instance deployments non-shared
caches. It no longer does anything of the sort.

Existing code stops compiling until a terminal call is added — the fix is mechanical:

```diff
builder.Services
.AddExampleClient(options)
- .AddOAuthTokenCaching<IExampleClient>();
+ .AddOAuthTokenCaching<IExampleClient>()
+ .UseInMemoryCache(); // previous behaviour without a registered IDistributedCache
```

```diff
builder.Services.AddStackExchangeRedisCache(o => o.Configuration = "localhost:6379");
builder.Services
.AddExampleClient(options)
- .AddOAuthTokenCaching<IExampleClient>();
+ .AddOAuthTokenCaching<IExampleClient>()
+ .UseRegisteredDistributedCache(); // previous behaviour with a registered IDistributedCache
```

### Sending a scoped request

The scope is supplied per request through the `X-TBC-OAuth-Scope` marker header
(`OAuthConstants.ScopeHeaderName`). The handler reads and removes that header, resolves a
token for the scope, and injects the `Authorization: Bearer <token>` header. Requests
that do not carry the marker header are passed through untouched (for example the token
endpoint call itself), which prevents recursion.

```c#
public async Task<SomeObject> GetSomeObjectAsync(CancellationToken cancellationToken = default)
{
var headers = new HeaderParamCollection
{
[OAuthConstants.ScopeHeaderName] = "read:some-object"
};

var result = await _http.GetJsonAsync<SomeObject>("/", query: null, headers, cancellationToken).ConfigureAwait(false);

if (!result.IsSuccess)
throw new OpenApiException(result.Problem?.Title ?? "Unexpected error occurred", result.Exception);

return result.Data!;
}
```

### How it works

1. The outgoing request carries the `X-TBC-OAuth-Scope` header with the required scope.
2. `OAuthDelegatingHandler<TClient>` extracts and removes that header.
3. A cached token for the scope is looked up in the `IDistributedCache`. If none exists,
a new token is requested via `POST oauth/token` using `grant_type=client_credentials`
and the given scope, then cached.
4. The `Authorization: Bearer <access_token>` header is added and the request is sent.
5. If the response is `401 Unauthorized`, the cached token is invalidated so the next
request obtains a fresh one.

### Cache behavior

* **Cache key** — composed as `TbcOpenApiOAuthToken:{ClientTypeName}:{scope}`, so tokens
are isolated per client type and per scope.
* **Lifetime** — a token is cached for its `expires_in` value minus a 30-second grace
period (`OAuthConstants.TokenTimeoutGracePeriodSec`), with a minimum of 30 seconds
(`OAuthConstants.MinCacheTtlSec`). Tokens without an `expires_in` value use the minimum.
* **Concurrency** — concurrent requests for the same scope are de-duplicated so only a
single token request is made while the others await the same result.
19 changes: 19 additions & 0 deletions src/TBC.OpenAPI.SDK.Core/Authentication/IOAuthTokenCacheHelper.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
namespace TBC.OpenAPI.SDK.Core.Authentication
{
public interface IOAuthTokenCacheHelper<TClient>
where TClient : class, IOpenApiClient
{
/// <summary>
/// Gets a cached access token for the specified <paramref name="scope"/>, requesting and
/// caching a fresh one if none is cached (or if the cached one has expired).
/// </summary>
Task<OAuthTokenResponse> GetTokenAsync(string scope, CancellationToken cancellationToken);

/// <summary>
/// Removes any cached access token for the specified <paramref name="scope"/> so that the
/// next call to <see cref="GetTokenAsync"/> requests a fresh one. Used to recover from a
/// <c>401 Unauthorized</c> response caused by a stale/revoked token.
/// </summary>
Task RemoveTokenAsync(string scope, CancellationToken cancellationToken);
}
}
8 changes: 8 additions & 0 deletions src/TBC.OpenAPI.SDK.Core/Authentication/IOAuthTokenHelper.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
namespace TBC.OpenAPI.SDK.Core.Authentication
{
public interface IOAuthTokenHelper<TClient>
where TClient : class, IOpenApiClient
{
Task<OAuthTokenResponse> RequestToken(string scope, CancellationToken cancellationToken);
}
}
31 changes: 31 additions & 0 deletions src/TBC.OpenAPI.SDK.Core/Authentication/OAuthConstants.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
namespace TBC.OpenAPI.SDK.Core.Authentication
{
public static class OAuthConstants
{
/// <summary>
/// Minimum number of seconds to cache an access token.
/// Tokens with no or shorter lifetime will be cached for this duration.
/// </summary>
public const int MinCacheTtlSec = 30;

/// <summary>
/// Number of seconds to subtract from the token's <c>expires_in</c> value when caching it,
/// to avoid using a token that is about to expire.
/// </summary>
public const int TokenTimeoutGracePeriodSec = 30;

/// <summary>
/// Name of the per-request marker header used to convey the OAuth scope to
/// <see cref="OAuthDelegatingHandler{TClient}"/>. The handler reads and removes this
/// header, then injects an <c>Authorization: Bearer</c> header for the resolved scope.
/// Requests without this header bypass token handling (e.g. the token endpoint itself).
/// </summary>
public const string ScopeHeaderName = "X-TBC-OAuth-Scope";

/// <summary>
/// Prefix used when composing the distributed cache key for a cached access token.
/// The full key also includes the client type name and the scope.
/// </summary>
public const string CacheKeyPrefix = "TbcOpenApiOAuthToken";
}
}
82 changes: 82 additions & 0 deletions src/TBC.OpenAPI.SDK.Core/Authentication/OAuthDelegatingHandler.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
using System.Net;
using System.Net.Http.Headers;

namespace TBC.OpenAPI.SDK.Core.Authentication
{
/// <summary>
/// A <see cref="DelegatingHandler"/> that transparently attaches an OAuth bearer token to
/// outgoing requests for <typeparamref name="TClient"/>.
/// <para>
/// The scope is conveyed per request through the <see cref="OAuthConstants.ScopeHeaderName"/>
/// marker header. The handler removes that header, resolves and caches a token for the scope
/// via <see cref="IOAuthTokenCacheHelper{TClient}"/>, and adds an
/// <c>Authorization: Bearer</c> header. If the server responds with
/// <see cref="HttpStatusCode.Unauthorized"/>, the cached token is invalidated so that the next
/// request regenerates it.
/// </para>
/// <para>
/// Requests without the marker header (for example the token endpoint call itself) are passed
/// through untouched, which prevents recursion when the token endpoint shares the same
/// <see cref="HttpClient"/> pipeline.
/// </para>
/// </summary>
internal sealed class OAuthDelegatingHandler<TClient> : DelegatingHandler
where TClient : class, IOpenApiClient
{
private readonly IOAuthTokenCacheHelper<TClient> _tokenCacheHelper;

public OAuthDelegatingHandler(IOAuthTokenCacheHelper<TClient> tokenCacheHelper)
{
_tokenCacheHelper = tokenCacheHelper;
}

protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
#if NET
ArgumentNullException.ThrowIfNull(request);
#else
if (request is null)
{
throw new ArgumentNullException(nameof(request));
}
#endif

if (!TryExtractScope(request, out var scope))
{
return await base.SendAsync(request, cancellationToken).ConfigureAwait(false);
}

var token = await _tokenCacheHelper.GetTokenAsync(scope, cancellationToken).ConfigureAwait(false);
SetBearer(request, token);

var response = await base.SendAsync(request, cancellationToken).ConfigureAwait(false);

if (response.StatusCode == HttpStatusCode.Unauthorized)
{
await _tokenCacheHelper.RemoveTokenAsync(scope, cancellationToken).ConfigureAwait(false);
}

return response;
}

private static bool TryExtractScope(HttpRequestMessage request, out string scope)
{
scope = string.Empty;

if (!request.Headers.TryGetValues(OAuthConstants.ScopeHeaderName, out var values))
{
return false;
}

request.Headers.Remove(OAuthConstants.ScopeHeaderName);

scope = values.FirstOrDefault() ?? string.Empty;
return !string.IsNullOrEmpty(scope);
}

private static void SetBearer(HttpRequestMessage request, OAuthTokenResponse token)
{
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.AccessToken);
}
}
}
Loading
Loading