Skip to content
Merged
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
184 changes: 174 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,14 @@ Library is written in the C # programming language and is compatible with .netst

## Example of using "ExampleClient" for creating SDK Client

> [!NOTE]
> The `Example*` types are **not part of this package**; they ship in the separate
> [`TBC.OpenAPI.SDK.ExampleClient`](https://www.nuget.org/packages/TBC.OpenAPI.SDK.ExampleClient/)
> reference package as a template for your own client. Core provides the primitives:
> `AddOpenApiClient<TInterface, TImplementation, TOptions>` for DI, and
> `OpenApiClientFactoryBuilder.AddClient<...>` / `OpenApiClientFactory.GetOpenApiClient<TInterface>()`
> for the factory. `AddExampleClient` is a thin wrapper you write yourself.

* Create interface "IExampleClient" and inherit from "TBC.OpenAPI.SDK.Core.IOpenApiClient"
```c#
public interface IExampleClient : IOpenApiClient
Expand All @@ -24,7 +32,7 @@ public class ExampleClient : IExampleClient
{
private readonly IHttpHelper<ExampleClient> _http;

public ExampleClient(HttpHelper<ExampleClient> http)
public ExampleClient(IHttpHelper<ExampleClient> http)
{
_http = http;
}
Expand All @@ -40,18 +48,20 @@ public class ExampleClient : IExampleClient
}
}
```
* Create property ```private readonly IHttpHelper<ExampleClient> _http``` and assign it from the constructor by dependency injection
```c#
public ExampleClient(HttpHelper<ExampleClient> http)
{
_http = http;
}
```
> [!IMPORTANT]
> Inject the `IHttpHelper<T>` abstraction (only the interface is registered) and
> parameterize it with the **implementation** type — `IHttpHelper<ExampleClient>`, not
> `IHttpHelper<IExampleClient>`. That is the named `HttpClient` `AddOpenApiClient`
> configures, and the same type argument `AddOAuthTokenCaching<T>` expects.

* Create class "ExampleClientOptions" and inherit from "TBC.OpenAPI.SDK.Core.OptionsBase"
* If you need client secret in options, inherit from "TBC.OpenAPI.SDK.Core.BasicAuthOptions"

```c#
public class ExampleClientOptions : OptionsBase{}

// Both base classes are abstract, so a client secret needs its own concrete class:
public class ExampleClientBasicAuthOptions : BasicAuthOptions{}
```
* Create class "ServiceCollectionExtensions" with extension method "AddExampleClient" for "Microsoft.Extensions.DependencyInjection.IServiceCollection", used for adding client to middleware
```c#
Expand Down Expand Up @@ -118,7 +128,7 @@ appsettings.json

Program.cs
```c#
builder.Services.AddExampleClient(builder.Configuration.GetSection("ExampleClient").Get<BasicAuthOptions>());
builder.Services.AddExampleClient(builder.Configuration.GetSection("ExampleClient").Get<ExampleClientBasicAuthOptions>());
```
appsettings.json
```json
Expand Down Expand Up @@ -157,4 +167,158 @@ public async Task<ActionResult<SomeObject>> GetSomeObject(CancellationToken canc
"id": 1,
"name": "Leanne Graham"
}
```
```

## Token Caching (OAuth client credentials)

Core can transparently acquire and cache OAuth access tokens using the
`client_credentials` grant. Tokens are requested on demand, cached per scope in an
`IDistributedCache` and attached as an `Authorization: Bearer` header by an `HttpClient`
message handler (`OAuthDelegatingHandler<TClient>`), so client code never fetches or
stores tokens itself.

> [!IMPORTANT]
> There is **no token refresh**. Tokens are acquired lazily and renewed only when they
> expire out of the cache or are evicted. See
> [What happens on 401 Unauthorized](#what-happens-on-401-unauthorized).

### Enable token caching

Call `AddOAuthTokenCaching<TClient>` **after** registering the client with
`AddOpenApiClient`, then finish the chain with exactly one of the terminal methods below
— the cache choice is not optional and is made per client. The SDK never registers a
cache backend on your behalf; if the terminal call is missing, resolving the client
throws an `InvalidOperationException` naming the available options instead of silently
falling back to a per-process cache.

> [!IMPORTANT]
> `TClient` must be the client's **implementation** type (`ExampleClient`), the same type
> argument the client passes to `IHttpHelper<T>`. Passing an interface throws an
> `InvalidOperationException` at registration time.

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

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

// reuse the IDistributedCache registered in the container (Redis, SQL Server, ...)
builder.Services
.AddOpenApiClient<IExampleClient, ExampleClient, ExampleClientOptions>(options)
.AddOAuthTokenCaching<ExampleClient>()
.UseRegisteredDistributedCache();

// or hand over an instance directly, without registering it
builder.Services
.AddOpenApiClient<IExampleClient, ExampleClient, ExampleClientOptions>(options)
.AddOAuthTokenCaching<ExampleClient>()
.UseDistributedCache(sp => sp.GetRequiredKeyedService<IDistributedCache>("tokens"));
```

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

Registration order does not matter — `.UseRegisteredDistributedCache()` resolves the
cache lazily, the first time a token is needed. If nothing is registered by then, or if
the registered implementation is the non-shared `MemoryDistributedCache` (what
`AddDistributedMemoryCache()` registers), it throws; use `.UseInMemoryCache()` if a
per-process cache is what you want.

The same terminal methods are available on `OpenApiClientFactoryBuilder` and return the
builder so the chain continues:

```c#
var factory = new OpenApiClientFactoryBuilder()
.AddClient<IExampleClient, ExampleClient, ExampleClientOptions>(options)
.AddOAuthTokenCaching<ExampleClient>()
.UseInMemoryCache()
.Build();

var client = factory.GetOpenApiClient<IExampleClient>();
```

### Migrating from 3.x

`AddOAuthTokenCaching<TClient>()` used to fall back to an in-memory distributed cache
when no `IDistributedCache` was registered, which made the caching topology depend on
registration order. A terminal call is now required, and `TClient` must be the
implementation type — `AddOAuthTokenCaching<IExampleClient>()` never worked (the handler
was attached to a named `HttpClient` nothing resolved, so requests went out without an
`Authorization` header) and now throws instead of failing silently.

```diff
builder.Services
.AddOpenApiClient<IExampleClient, ExampleClient, ExampleClientOptions>(options)
- .AddOAuthTokenCaching<IExampleClient>();
+ .AddOAuthTokenCaching<ExampleClient>()
+ .UseInMemoryCache(); // previous behaviour without a registered IDistributedCache
+ // .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. The `401` itself is returned to the caller.

### What happens on 401 Unauthorized

The handler **evicts, it does not refresh**: the failed request is not retried, only the
*next* request for that scope gets a fresh token. The SDK never retries with a new token,
never uses the `refresh_token` grant (no refresh token is stored) and never renews
proactively. The only protection against sending an about-to-expire token is the
30-second grace period subtracted from `expires_in`, so treat a `401` as a normal failed
response — retry and backoff are the caller's responsibility.

### 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.
* **Stored value** — only the access-token string is persisted. A cache hit therefore
yields a token response whose `TokenType`, `ExpiresIn` and `Scope` are null.
* **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. This de-duplication
is **per process**: in a multi-instance deployment each instance still issues its own
initial token request per scope, after which the shared cache takes over.
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";
}
}
87 changes: 87 additions & 0 deletions src/TBC.OpenAPI.SDK.Core/Authentication/OAuthDelegatingHandler.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
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>
/// The handler does not retry: the <see cref="HttpStatusCode.Unauthorized"/> response is
/// returned to the caller unchanged, and only the following request for that scope benefits
/// from the eviction. Retry and backoff are the caller's responsibility.
/// </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