diff --git a/README.md b/README.md index 63f8517..7d2d5f6 100644 --- a/README.md +++ b/README.md @@ -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` for DI, and +> `OpenApiClientFactoryBuilder.AddClient<...>` / `OpenApiClientFactory.GetOpenApiClient()` +> 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 @@ -24,7 +32,7 @@ public class ExampleClient : IExampleClient { private readonly IHttpHelper _http; - public ExampleClient(HttpHelper http) + public ExampleClient(IHttpHelper http) { _http = http; } @@ -40,18 +48,20 @@ public class ExampleClient : IExampleClient } } ``` -* Create property ```private readonly IHttpHelper _http``` and assign it from the constructor by dependency injection -```c# -public ExampleClient(HttpHelper http) -{ - _http = http; -} -``` +> [!IMPORTANT] +> Inject the `IHttpHelper` abstraction (only the interface is registered) and +> parameterize it with the **implementation** type — `IHttpHelper`, not +> `IHttpHelper`. That is the named `HttpClient` `AddOpenApiClient` +> configures, and the same type argument `AddOAuthTokenCaching` 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# @@ -118,7 +128,7 @@ appsettings.json Program.cs ```c# -builder.Services.AddExampleClient(builder.Configuration.GetSection("ExampleClient").Get()); +builder.Services.AddExampleClient(builder.Configuration.GetSection("ExampleClient").Get()); ``` appsettings.json ```json @@ -157,4 +167,158 @@ public async Task> GetSomeObject(CancellationToken canc "id": 1, "name": "Leanne Graham" } -``` \ No newline at end of file +``` + +## 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`), 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` **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`. 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)`
`.UseDistributedCache(sp => ...)` | A cache instance you supply directly | You want a dedicated cache for tokens, separate from the app's | + +```c# +builder.Services + .AddOpenApiClient( + builder.Configuration.GetSection("ExampleClient").Get()) + .AddOAuthTokenCaching() + .UseInMemoryCache(); + +// reuse the IDistributedCache registered in the container (Redis, SQL Server, ...) +builder.Services + .AddOpenApiClient(options) + .AddOAuthTokenCaching() + .UseRegisteredDistributedCache(); + +// or hand over an instance directly, without registering it +builder.Services + .AddOpenApiClient(options) + .AddOAuthTokenCaching() + .UseDistributedCache(sp => sp.GetRequiredKeyedService("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(options) + .AddOAuthTokenCaching() + .UseInMemoryCache() + .Build(); + +var client = factory.GetOpenApiClient(); +``` + +### Migrating from 3.x + +`AddOAuthTokenCaching()` 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()` 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(options) +- .AddOAuthTokenCaching(); ++ .AddOAuthTokenCaching() ++ .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 ` 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 GetSomeObjectAsync(CancellationToken cancellationToken = default) +{ + var headers = new HeaderParamCollection + { + [OAuthConstants.ScopeHeaderName] = "read:some-object" + }; + + var result = await _http.GetJsonAsync("/", 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` 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 ` 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. \ No newline at end of file diff --git a/src/TBC.OpenAPI.SDK.Core/Authentication/IOAuthTokenCacheHelper.cs b/src/TBC.OpenAPI.SDK.Core/Authentication/IOAuthTokenCacheHelper.cs new file mode 100644 index 0000000..dbe38a7 --- /dev/null +++ b/src/TBC.OpenAPI.SDK.Core/Authentication/IOAuthTokenCacheHelper.cs @@ -0,0 +1,19 @@ +namespace TBC.OpenAPI.SDK.Core.Authentication +{ + public interface IOAuthTokenCacheHelper + where TClient : class, IOpenApiClient + { + /// + /// Gets a cached access token for the specified , requesting and + /// caching a fresh one if none is cached (or if the cached one has expired). + /// + Task GetTokenAsync(string scope, CancellationToken cancellationToken); + + /// + /// Removes any cached access token for the specified so that the + /// next call to requests a fresh one. Used to recover from a + /// 401 Unauthorized response caused by a stale/revoked token. + /// + Task RemoveTokenAsync(string scope, CancellationToken cancellationToken); + } +} diff --git a/src/TBC.OpenAPI.SDK.Core/Authentication/IOAuthTokenHelper.cs b/src/TBC.OpenAPI.SDK.Core/Authentication/IOAuthTokenHelper.cs new file mode 100644 index 0000000..ad3fc4f --- /dev/null +++ b/src/TBC.OpenAPI.SDK.Core/Authentication/IOAuthTokenHelper.cs @@ -0,0 +1,8 @@ +namespace TBC.OpenAPI.SDK.Core.Authentication +{ + public interface IOAuthTokenHelper + where TClient : class, IOpenApiClient + { + Task RequestToken(string scope, CancellationToken cancellationToken); + } +} diff --git a/src/TBC.OpenAPI.SDK.Core/Authentication/OAuthConstants.cs b/src/TBC.OpenAPI.SDK.Core/Authentication/OAuthConstants.cs new file mode 100644 index 0000000..0b21cee --- /dev/null +++ b/src/TBC.OpenAPI.SDK.Core/Authentication/OAuthConstants.cs @@ -0,0 +1,31 @@ +namespace TBC.OpenAPI.SDK.Core.Authentication +{ + public static class OAuthConstants + { + /// + /// Minimum number of seconds to cache an access token. + /// Tokens with no or shorter lifetime will be cached for this duration. + /// + public const int MinCacheTtlSec = 30; + + /// + /// Number of seconds to subtract from the token's expires_in value when caching it, + /// to avoid using a token that is about to expire. + /// + public const int TokenTimeoutGracePeriodSec = 30; + + /// + /// Name of the per-request marker header used to convey the OAuth scope to + /// . The handler reads and removes this + /// header, then injects an Authorization: Bearer header for the resolved scope. + /// Requests without this header bypass token handling (e.g. the token endpoint itself). + /// + public const string ScopeHeaderName = "X-TBC-OAuth-Scope"; + + /// + /// 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. + /// + public const string CacheKeyPrefix = "TbcOpenApiOAuthToken"; + } +} diff --git a/src/TBC.OpenAPI.SDK.Core/Authentication/OAuthDelegatingHandler.cs b/src/TBC.OpenAPI.SDK.Core/Authentication/OAuthDelegatingHandler.cs new file mode 100644 index 0000000..42fda47 --- /dev/null +++ b/src/TBC.OpenAPI.SDK.Core/Authentication/OAuthDelegatingHandler.cs @@ -0,0 +1,87 @@ +using System.Net; +using System.Net.Http.Headers; + +namespace TBC.OpenAPI.SDK.Core.Authentication +{ + /// + /// A that transparently attaches an OAuth bearer token to + /// outgoing requests for . + /// + /// The scope is conveyed per request through the + /// marker header. The handler removes that header, resolves and caches a token for the scope + /// via , and adds an + /// Authorization: Bearer header. If the server responds with + /// , the cached token is invalidated so that the next + /// request regenerates it. + /// + /// + /// The handler does not retry: the 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. + /// + /// + /// 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 + /// pipeline. + /// + /// + internal sealed class OAuthDelegatingHandler : DelegatingHandler + where TClient : class, IOpenApiClient + { + private readonly IOAuthTokenCacheHelper _tokenCacheHelper; + + public OAuthDelegatingHandler(IOAuthTokenCacheHelper tokenCacheHelper) + { + _tokenCacheHelper = tokenCacheHelper; + } + + protected override async Task 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); + } + } +} diff --git a/src/TBC.OpenAPI.SDK.Core/Authentication/OAuthTokenCacheHelper.cs b/src/TBC.OpenAPI.SDK.Core/Authentication/OAuthTokenCacheHelper.cs new file mode 100644 index 0000000..0c16d6b --- /dev/null +++ b/src/TBC.OpenAPI.SDK.Core/Authentication/OAuthTokenCacheHelper.cs @@ -0,0 +1,85 @@ +using Microsoft.Extensions.Caching.Distributed; +using TBC.OpenAPI.SDK.Core.Exceptions; + +namespace TBC.OpenAPI.SDK.Core.Authentication +{ + internal sealed class OAuthTokenCacheHelper : IOAuthTokenCacheHelper + where TClient : class, IOpenApiClient + { + private readonly SingleFlightExecutor _singleFlight + = new SingleFlightExecutor(); + + private readonly IOAuthTokenHelper _tokenHelper; + private readonly IDistributedCache _distributedCache; + + public OAuthTokenCacheHelper( + IOAuthTokenHelper tokenHelper, + IDistributedCache distributedCache) + { + _tokenHelper = tokenHelper; + _distributedCache = distributedCache; + } + + public async Task GetTokenAsync(string scope, CancellationToken cancellationToken) + { + string cacheKey = GetCacheKey(scope); + + var cached = await _distributedCache.GetStringAsync(cacheKey, cancellationToken).ConfigureAwait(false); + if (!string.IsNullOrEmpty(cached)) + { + return new OAuthTokenResponse { AccessToken = cached }; + } + + return await _singleFlight + .ExecuteAsync(cacheKey, () => FetchAndCacheTokenAsync(scope, cacheKey), cancellationToken) + .ConfigureAwait(false); + } + + private async Task FetchAndCacheTokenAsync(string scope, string cacheKey) + { + var token = await _distributedCache.GetStringAsync(cacheKey, CancellationToken.None).ConfigureAwait(false); + if (!string.IsNullOrEmpty(token)) + { + return new OAuthTokenResponse { AccessToken = token }; + } + + var newTokenResponse = await _tokenHelper.RequestToken(scope, CancellationToken.None).ConfigureAwait(false); + if (newTokenResponse is null) + { + throw new OpenApiException("Failed to obtain a new OAuth token."); + } + + var accessToken = newTokenResponse.AccessToken; + if (string.IsNullOrEmpty(accessToken)) + { + throw new OpenApiException("Received empty access token."); + } + + int ttlSec = newTokenResponse.ExpiresIn.HasValue + ? newTokenResponse.ExpiresIn.Value - OAuthConstants.TokenTimeoutGracePeriodSec + : 0; + if (ttlSec <= 0) + { + ttlSec = OAuthConstants.MinCacheTtlSec; + } + + var options = new DistributedCacheEntryOptions + { + AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(ttlSec) + }; + await _distributedCache.SetStringAsync(cacheKey, accessToken!, options, CancellationToken.None).ConfigureAwait(false); + + return newTokenResponse; + } + + public Task RemoveTokenAsync(string scope, CancellationToken cancellationToken) + { + return _distributedCache.RemoveAsync(GetCacheKey(scope), cancellationToken); + } + + private static string GetCacheKey(string scope) + { + return $"{OAuthConstants.CacheKeyPrefix}:{typeof(TClient).FullName}:{scope}"; + } + } +} diff --git a/src/TBC.OpenAPI.SDK.Core/Authentication/OAuthTokenCachingBuilder.cs b/src/TBC.OpenAPI.SDK.Core/Authentication/OAuthTokenCachingBuilder.cs new file mode 100644 index 0000000..c7769a6 --- /dev/null +++ b/src/TBC.OpenAPI.SDK.Core/Authentication/OAuthTokenCachingBuilder.cs @@ -0,0 +1,106 @@ +using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.DependencyInjection; + +namespace TBC.OpenAPI.SDK.Core.Authentication +{ + /// + /// Selects which cache stores OAuth access tokens for . + /// + /// The choice is deliberately explicit: this SDK never registers a cache backend on your behalf + /// and never falls back to one, so it cannot quietly take over your application's caching + /// topology. Exactly one of the Use… methods must be called; until one is, resolving the + /// client throws an error that names the available options. + /// + /// + /// The Open API client the token cache applies to. + public sealed class OAuthTokenCachingBuilder + where TClient : class, IOpenApiClient + { + internal OAuthTokenCachingBuilder(IServiceCollection services) + { + Services = services; + } + + /// + /// The service collection being configured. + /// + public IServiceCollection Services { get; } + + /// + /// Caches tokens in a private in-memory store owned by this SDK and dedicated to + /// . Nothing is registered in the container under + /// , so your application's caching topology is untouched. + /// + /// The cache lives inside a single process and is not shared. In a multi-instance + /// deployment every instance requests and caches its own tokens. Prefer + /// or + /// when running more than one instance. + /// + /// + /// The service collection, so registration can continue to be chained. + public IServiceCollection UseInMemoryCache() + { + return OAuthTokenCachingRegistration.UseInMemoryCache(Services); + } + + /// + /// Caches tokens in the registered in the container. The + /// cache is resolved when the client is first used, so it may be registered before or after + /// this call. + /// + /// Throws when no is registered, and also when the registered + /// one is a — that implementation is not shared across + /// processes, so accepting it here would silently give a multi-instance deployment a + /// per-instance token cache. Call if that is what you want. + /// + /// + /// The service collection, so registration can continue to be chained. + public IServiceCollection UseRegisteredDistributedCache() + { + return OAuthTokenCachingRegistration.UseRegisteredDistributedCache(Services); + } + + /// + /// Caches tokens in the supplied instance, bypassing the + /// container entirely. + /// + /// The cache to store access tokens in. + /// The service collection, so registration can continue to be chained. + /// is . + public IServiceCollection UseDistributedCache(IDistributedCache cache) + { +#if NET + ArgumentNullException.ThrowIfNull(cache); +#else + if (cache is null) + { + throw new ArgumentNullException(nameof(cache)); + } +#endif + + return OAuthTokenCachingRegistration.UseDistributedCache(Services, cache); + } + + /// + /// Caches tokens in an produced by . + /// The factory runs once, when the client is first used, which allows the cache to be built + /// from other services in the container. + /// + /// Factory that produces the cache to store access tokens in. + /// The service collection, so registration can continue to be chained. + /// is . + public IServiceCollection UseDistributedCache(Func cacheFactory) + { +#if NET + ArgumentNullException.ThrowIfNull(cacheFactory); +#else + if (cacheFactory is null) + { + throw new ArgumentNullException(nameof(cacheFactory)); + } +#endif + + return OAuthTokenCachingRegistration.UseDistributedCache(Services, cacheFactory); + } + } +} diff --git a/src/TBC.OpenAPI.SDK.Core/Authentication/OAuthTokenCachingRegistration.cs b/src/TBC.OpenAPI.SDK.Core/Authentication/OAuthTokenCachingRegistration.cs new file mode 100644 index 0000000..c565877 --- /dev/null +++ b/src/TBC.OpenAPI.SDK.Core/Authentication/OAuthTokenCachingRegistration.cs @@ -0,0 +1,171 @@ +using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Options; + +namespace TBC.OpenAPI.SDK.Core.Authentication +{ + /// + /// Shared registration logic behind the token caching builders. Every cache selection ends up + /// here, so the service-collection and client-factory flavours of the fluent API stay in sync. + /// + internal static class OAuthTokenCachingRegistration + { + /// + /// Registers the placeholder used when no cache + /// has been selected yet. Resolving it throws an actionable error, so a forgotten terminal + /// call fails loudly instead of silently sending unauthenticated requests. + /// + public static void AddUnconfiguredCache(IServiceCollection services) + where TClient : class, IOpenApiClient + { + services.TryAdd(ServiceDescriptor.Singleton>( + _ => throw new InvalidOperationException(NoCacheSelectedMessage()))); + } + + /// + /// Rejects an interface . Token caching attaches its handler to + /// the named called typeof(TClient).FullName, and + /// AddOpenApiClient always configures that client under the implementation type - its + /// TClientImplementation parameter is constrained to a class. An interface therefore + /// attaches the handler to a client nothing resolves, and requests silently go out without an + /// Authorization header. + /// + public static void ValidateClientTypeArgument() + where TClient : class, IOpenApiClient + { + if (typeof(TClient).IsInterface) + { + throw new InvalidOperationException(InterfaceTypeArgumentMessage()); + } + } + + /// + /// Points at a private in-memory cache owned by this SDK. + /// Nothing is registered in the container under . + /// + public static IServiceCollection UseInMemoryCache(IServiceCollection services) + where TClient : class, IOpenApiClient + { + return UseCache( + services, + _ => new MemoryDistributedCache(Options.Create(new MemoryDistributedCacheOptions()))); + } + + /// + /// Points at the registered in + /// the container, failing loudly when there is none or when it is not actually distributed. + /// + public static IServiceCollection UseRegisteredDistributedCache(IServiceCollection services) + where TClient : class, IOpenApiClient + { + return UseCache(services, serviceProvider => + { + var cache = serviceProvider.GetService(); + + if (cache is null) + { + throw new InvalidOperationException(NoRegisteredCacheMessage()); + } + + if (cache is MemoryDistributedCache) + { + throw new InvalidOperationException(NotActuallyDistributedMessage()); + } + + return cache; + }); + } + + /// + /// Points at the supplied instance. + /// + public static IServiceCollection UseDistributedCache(IServiceCollection services, IDistributedCache cache) + where TClient : class, IOpenApiClient + { + return UseCache(services, _ => cache); + } + + /// + /// Points at an built by the + /// supplied factory when the container is first asked for a token. + /// + public static IServiceCollection UseDistributedCache( + IServiceCollection services, + Func cacheFactory) + where TClient : class, IOpenApiClient + { + return UseCache(services, serviceProvider => + cacheFactory(serviceProvider) + ?? throw new InvalidOperationException(NullFactoryResultMessage())); + } + + private static IServiceCollection UseCache( + IServiceCollection services, + Func cacheFactory) + where TClient : class, IOpenApiClient + { + services.Replace(ServiceDescriptor.Singleton>(serviceProvider => + new OAuthTokenCacheHelper( + serviceProvider.GetRequiredService>(), + cacheFactory(serviceProvider)))); + + return services; + } + + private static string NoCacheSelectedMessage() + where TClient : class, IOpenApiClient + { + return $"No OAuth token cache has been selected for client '{ClientName()}'. " + + $"Follow AddOAuthTokenCaching<{typeof(TClient).Name}>() with exactly one of: " + + "UseInMemoryCache() for a per-process cache, " + + "UseRegisteredDistributedCache() to use the IDistributedCache registered in the container, " + + "or UseDistributedCache(...) to supply one directly."; + } + + private static string InterfaceTypeArgumentMessage() + where TClient : class, IOpenApiClient + { + return $"AddOAuthTokenCaching<{typeof(TClient).Name}>() was called with the interface " + + $"'{ClientName()}'. Token caching attaches its handler to the named HttpClient of the " + + "type argument, and AddOpenApiClient always configures that client under the implementation " + + "type, so an interface attaches the handler to an HttpClient nothing resolves and requests go " + + "out without an Authorization header. Pass the client implementation type instead - the same " + + "type argument the client passes to IHttpHelper."; + } + + private static string NoRegisteredCacheMessage() + where TClient : class, IOpenApiClient + { + return $"UseRegisteredDistributedCache() was selected for client '{ClientName()}', " + + "but no IDistributedCache is registered in the container. Register one " + + "(for example services.AddStackExchangeRedisCache(...) or services.AddDistributedSqlServerCache(...)), " + + "or select UseInMemoryCache() / UseDistributedCache(...) instead."; + } + + private static string NotActuallyDistributedMessage() + where TClient : class, IOpenApiClient + { + return $"UseRegisteredDistributedCache() was selected for client '{ClientName()}', " + + "but the registered IDistributedCache is a MemoryDistributedCache, which is not shared " + + "across processes or instances. Every instance of a multi-instance deployment would keep " + + "its own token cache and request its own tokens. Register a genuinely distributed cache " + + "(for example Redis or SQL Server), or select UseInMemoryCache() to explicitly accept a " + + "per-process cache."; + } + + private static string NullFactoryResultMessage() + where TClient : class, IOpenApiClient + { + return $"The IDistributedCache factory supplied to UseDistributedCache(...) for client " + + $"'{ClientName()}' returned null."; + } + + private static string ClientName() + where TClient : class, IOpenApiClient + { + return typeof(TClient).FullName ?? typeof(TClient).Name; + } + } +} diff --git a/src/TBC.OpenAPI.SDK.Core/Authentication/OAuthTokenHelper.cs b/src/TBC.OpenAPI.SDK.Core/Authentication/OAuthTokenHelper.cs new file mode 100644 index 0000000..38729a6 --- /dev/null +++ b/src/TBC.OpenAPI.SDK.Core/Authentication/OAuthTokenHelper.cs @@ -0,0 +1,40 @@ +using TBC.OpenAPI.SDK.Core.Exceptions; +using TBC.OpenAPI.SDK.Core.Models; + +namespace TBC.OpenAPI.SDK.Core.Authentication +{ + internal sealed class OAuthTokenHelper : IOAuthTokenHelper + where TClient : class, IOpenApiClient + { + private readonly IHttpHelper _http; + + public OAuthTokenHelper(IHttpHelper http) + { + _http = http; + } + + public async Task RequestToken(string scope, CancellationToken cancellationToken) + { + var form = new UrlFormCollection + { + ["grant_type"] = "client_credentials", + ["scope"] = scope + }; + + var response = await _http + .PostUrlFormAsync("oauth/token", form, cancellationToken) + .ConfigureAwait(false); + + if (!response.IsSuccess || + response.Data is null || + string.IsNullOrEmpty(response.Data.AccessToken)) + { + throw new OpenApiException( + response.Problem?.Title ?? "Token request was unsuccessful", + response.Exception); + } + + return response.Data; + } + } +} diff --git a/src/TBC.OpenAPI.SDK.Core/Authentication/OAuthTokenResponse.cs b/src/TBC.OpenAPI.SDK.Core/Authentication/OAuthTokenResponse.cs new file mode 100644 index 0000000..a0acbdf --- /dev/null +++ b/src/TBC.OpenAPI.SDK.Core/Authentication/OAuthTokenResponse.cs @@ -0,0 +1,19 @@ +using System.Text.Json.Serialization; + +namespace TBC.OpenAPI.SDK.Core.Authentication +{ + public class OAuthTokenResponse + { + [JsonPropertyName("access_token")] + public string? AccessToken { get; set; } + + [JsonPropertyName("token_type")] + public string? TokenType { get; set; } + + [JsonPropertyName("expires_in")] + public int? ExpiresIn { get; set; } + + [JsonPropertyName("scope")] + public string? Scope { get; set; } + } +} diff --git a/src/TBC.OpenAPI.SDK.Core/Authentication/OpenApiClientOAuthTokenCachingBuilder.cs b/src/TBC.OpenAPI.SDK.Core/Authentication/OpenApiClientOAuthTokenCachingBuilder.cs new file mode 100644 index 0000000..8329ed1 --- /dev/null +++ b/src/TBC.OpenAPI.SDK.Core/Authentication/OpenApiClientOAuthTokenCachingBuilder.cs @@ -0,0 +1,102 @@ +using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.DependencyInjection; + +namespace TBC.OpenAPI.SDK.Core.Authentication +{ + /// + /// Selects which cache stores OAuth access tokens for when clients + /// are composed through . + /// + /// Mirrors , but each terminal method returns the + /// so that further clients can be added and + /// can still be chained. + /// + /// + /// The Open API client the token cache applies to. + public sealed class OpenApiClientOAuthTokenCachingBuilder + where TClient : class, IOpenApiClient + { + private readonly IServiceCollection _services; + private readonly OpenApiClientFactoryBuilder _factoryBuilder; + + internal OpenApiClientOAuthTokenCachingBuilder(IServiceCollection services, OpenApiClientFactoryBuilder factoryBuilder) + { + _services = services; + _factoryBuilder = factoryBuilder; + } + + /// + /// Caches tokens in a private in-memory store owned by this SDK and dedicated to + /// . Nothing is registered under . + /// + /// The cache lives inside a single process and is not shared. In a multi-instance + /// deployment every instance requests and caches its own tokens. + /// + /// + /// The client factory builder, so configuration can continue to be chained. + public OpenApiClientFactoryBuilder UseInMemoryCache() + { + OAuthTokenCachingRegistration.UseInMemoryCache(_services); + return _factoryBuilder; + } + + /// + /// Caches tokens in the registered in the underlying service + /// collection. + /// + /// Throws when no is registered, and also when the registered + /// one is a , which is not shared across processes. Call + /// to accept a per-process cache explicitly. + /// + /// + /// The client factory builder, so configuration can continue to be chained. + public OpenApiClientFactoryBuilder UseRegisteredDistributedCache() + { + OAuthTokenCachingRegistration.UseRegisteredDistributedCache(_services); + return _factoryBuilder; + } + + /// + /// Caches tokens in the supplied instance. + /// + /// The cache to store access tokens in. + /// The client factory builder, so configuration can continue to be chained. + /// is . + public OpenApiClientFactoryBuilder UseDistributedCache(IDistributedCache cache) + { +#if NET + ArgumentNullException.ThrowIfNull(cache); +#else + if (cache is null) + { + throw new ArgumentNullException(nameof(cache)); + } +#endif + + OAuthTokenCachingRegistration.UseDistributedCache(_services, cache); + return _factoryBuilder; + } + + /// + /// Caches tokens in an produced by + /// when the client is first used. + /// + /// Factory that produces the cache to store access tokens in. + /// The client factory builder, so configuration can continue to be chained. + /// is . + public OpenApiClientFactoryBuilder UseDistributedCache(Func cacheFactory) + { +#if NET + ArgumentNullException.ThrowIfNull(cacheFactory); +#else + if (cacheFactory is null) + { + throw new ArgumentNullException(nameof(cacheFactory)); + } +#endif + + OAuthTokenCachingRegistration.UseDistributedCache(_services, cacheFactory); + return _factoryBuilder; + } + } +} diff --git a/src/TBC.OpenAPI.SDK.Core/Extensions/ServiceCollectionExtensions.cs b/src/TBC.OpenAPI.SDK.Core/Extensions/ServiceCollectionExtensions.cs index c34ee03..9db7abb 100644 --- a/src/TBC.OpenAPI.SDK.Core/Extensions/ServiceCollectionExtensions.cs +++ b/src/TBC.OpenAPI.SDK.Core/Extensions/ServiceCollectionExtensions.cs @@ -1,6 +1,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using System.Text; +using TBC.OpenAPI.SDK.Core.Authentication; namespace TBC.OpenAPI.SDK.Core.Extensions { @@ -14,14 +15,11 @@ public static IServiceCollection AddOpenApiClient + var httpClientBuilder = services.AddHttpClient(typeof(TClientImplementation).FullName!, client => { - if (typeof(BasicAuthOptions).IsAssignableFrom(typeof(TOptions))) + if (options is BasicAuthOptions opt) { - - var opt = options as BasicAuthOptions; - string encoded = System.Convert.ToBase64String(Encoding.GetEncoding("ISO-8859-1") .GetBytes(opt.ApiKey + ":" + opt.ClientSecret)); client.DefaultRequestHeaders.Add("Authorization", "Basic " + encoded); @@ -45,5 +43,63 @@ public static IServiceCollection AddOpenApiClient + /// Enables transparent OAuth client-credentials token acquisition and per-scope caching for + /// . Callers convey the scope per request through the + /// marker header; the registered + /// exchanges it for an + /// Authorization: Bearer header. + /// + /// must be the implementation type of the client - + /// the same type argument the client passes to - because + /// that is the named + /// configures. + /// Passing the client interface throws. + /// + /// + /// There is no token refresh: a 401 Unauthorized response evicts the cached token and + /// is returned to the caller unchanged. The request that hit the 401 is not retried; + /// the next request for that scope acquires a fresh token. Tokens are otherwise renewed only + /// when they expire out of the cache. + /// + /// + /// Call this after + /// for the same client, then select a cache on the returned builder with exactly one of + /// , + /// or + /// . + /// No cache backend is registered on your behalf, and there is no implicit fallback: until a + /// cache is selected, resolving the client throws an error naming the available options. + /// + /// + /// A builder used to select where access tokens are cached. + /// + /// is an interface rather than a client implementation type. + /// + public static OAuthTokenCachingBuilder AddOAuthTokenCaching(this IServiceCollection services) + where TClient : class, IOpenApiClient + { +#if NET + ArgumentNullException.ThrowIfNull(services); +#else + if (services is null) + { + throw new ArgumentNullException(nameof(services)); + } +#endif + + OAuthTokenCachingRegistration.ValidateClientTypeArgument(); + + services.TryAddSingleton(typeof(IOAuthTokenHelper<>), typeof(OAuthTokenHelper<>)); + OAuthTokenCachingRegistration.AddUnconfiguredCache(services); + + services.TryAddTransient>(); + + services.AddHttpClient(typeof(TClient).FullName!) + .AddHttpMessageHandler>(); + + return new OAuthTokenCachingBuilder(services); + } } -} \ No newline at end of file +} diff --git a/src/TBC.OpenAPI.SDK.Core/OpenApiClientFactory.cs b/src/TBC.OpenAPI.SDK.Core/OpenApiClientFactory.cs index ca63192..7f0a2bf 100644 --- a/src/TBC.OpenAPI.SDK.Core/OpenApiClientFactory.cs +++ b/src/TBC.OpenAPI.SDK.Core/OpenApiClientFactory.cs @@ -1,4 +1,5 @@ using Microsoft.Extensions.DependencyInjection; +using TBC.OpenAPI.SDK.Core.Authentication; using TBC.OpenAPI.SDK.Core.Extensions; namespace TBC.OpenAPI.SDK.Core @@ -48,6 +49,38 @@ public OpenApiClientFactoryBuilder AddClient + /// Enables transparent OAuth client-credentials token acquisition and per-scope caching for + /// . Call after the matching + /// , then select a + /// cache on the returned builder with exactly one of + /// , + /// or + /// . + /// There is no implicit fallback: until a cache is selected, resolving the client throws an + /// error naming the available options. + /// + /// must be the implementation type of the client - + /// the same type argument the client passes to . Passing + /// the client interface throws. + /// + /// + /// There is no token refresh: a 401 Unauthorized response evicts the cached token and + /// is returned to the caller unchanged. The request that hit the 401 is not retried. + /// + /// + /// A builder used to select where access tokens are cached. + /// + /// is the interface of a registered client instead of its + /// implementation type. + /// + public OpenApiClientOAuthTokenCachingBuilder AddOAuthTokenCaching() + where TClient : class, IOpenApiClient + { + _serviceCollection.AddOAuthTokenCaching(); + return new OpenApiClientOAuthTokenCachingBuilder(_serviceCollection, this); + } + public OpenApiClientFactory Build() { _serviceProvider ??= _serviceCollection.BuildServiceProvider(); diff --git a/src/TBC.OpenAPI.SDK.Core/SingleFlightExecutor.cs b/src/TBC.OpenAPI.SDK.Core/SingleFlightExecutor.cs new file mode 100644 index 0000000..323f4af --- /dev/null +++ b/src/TBC.OpenAPI.SDK.Core/SingleFlightExecutor.cs @@ -0,0 +1,79 @@ +using System.Collections.Concurrent; + +namespace TBC.OpenAPI.SDK.Core +{ + /// + /// Coalesces concurrent asynchronous operations that share the same key so that the underlying + /// work runs only once at a time (single-flight / stampede protection). Callers that arrive + /// while an operation for a given key is in flight await the same task rather than starting a + /// duplicate one. + /// + /// The type produced by the coalesced operation. + internal sealed class SingleFlightExecutor + { + private readonly ConcurrentDictionary>> _pending + = new ConcurrentDictionary>>(StringComparer.Ordinal); + + /// + /// Runs for the specified , ensuring that + /// concurrent callers with the same key share a single in-flight execution. The pending + /// entry is removed once the operation completes (whether it succeeds or fails). + /// + /// The key that identifies the operation to coalesce. + /// The work to execute when no operation for the key is in flight. + /// A token that lets the caller stop awaiting the shared task. + public Task ExecuteAsync(string key, Func> factory, CancellationToken cancellationToken) + { + var pending = _pending.GetOrAdd( + key, + k => new Lazy>( + () => RunAsync(k, factory), + LazyThreadSafetyMode.ExecutionAndPublication)); + + return WaitAsyncSafe(pending.Value, cancellationToken); + } + + private async Task RunAsync(string key, Func> factory) + { + try + { + return await factory().ConfigureAwait(false); + } + finally + { + _pending.TryRemove(key, out _); + } + } + + private static Task WaitAsyncSafe(Task task, CancellationToken cancellationToken) + { +#if NET6_0_OR_GREATER + return task.WaitAsync(cancellationToken); +#else + return WaitAsyncPolyfill(task, cancellationToken); +#endif + } + +#if !NET6_0_OR_GREATER + private static async Task WaitAsyncPolyfill(Task task, CancellationToken cancellationToken) + { + if (!cancellationToken.CanBeCanceled || task.IsCompleted) + { + return await task.ConfigureAwait(false); + } + + var cancellationSignal = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using (cancellationToken.Register(static state => ((TaskCompletionSource)state!).TrySetResult(true), cancellationSignal)) + { + var completed = await Task.WhenAny(task, cancellationSignal.Task).ConfigureAwait(false); + if (completed != task) + { + throw new OperationCanceledException(cancellationToken); + } + } + + return await task.ConfigureAwait(false); + } +#endif + } +} diff --git a/src/TBC.OpenAPI.SDK.Core/TBC.OpenAPI.SDK.Core.csproj b/src/TBC.OpenAPI.SDK.Core/TBC.OpenAPI.SDK.Core.csproj index 7225fa9..666e34a 100644 --- a/src/TBC.OpenAPI.SDK.Core/TBC.OpenAPI.SDK.Core.csproj +++ b/src/TBC.OpenAPI.SDK.Core/TBC.OpenAPI.SDK.Core.csproj @@ -26,6 +26,10 @@ latest-all + + + + true true @@ -35,6 +39,7 @@ + @@ -47,6 +52,7 @@ + @@ -59,6 +65,7 @@ + @@ -83,6 +90,7 @@ + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/tests/TBC.OpenAPI.SDK.Core.Tests/OAuthDelegatingHandlerTests.cs b/tests/TBC.OpenAPI.SDK.Core.Tests/OAuthDelegatingHandlerTests.cs new file mode 100644 index 0000000..b68f8c2 --- /dev/null +++ b/tests/TBC.OpenAPI.SDK.Core.Tests/OAuthDelegatingHandlerTests.cs @@ -0,0 +1,210 @@ +using System; +using System.Net; +using System.Net.Http; +using System.Reflection; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using TBC.OpenAPI.SDK.Core.Authentication; +using Xunit; + +namespace TBC.OpenAPI.SDK.Core.Tests +{ + public class OAuthDelegatingHandlerTests + { + private const string Scope = "some-scope"; + + private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(5); + + private readonly Mock> _tokenCacheHelper = new(MockBehavior.Strict); + private readonly RecordingInnerHandler _inner = new(); + + private HttpClient CreateClient() + { + var sut = new OAuthDelegatingHandler(_tokenCacheHelper.Object) + { + InnerHandler = _inner + }; + return new HttpClient(sut); + } + + [Fact] + public async Task SendAsync_WhenScopeHeaderPresent_AttachesBearerAndRemovesMarkerHeader() + { + _tokenCacheHelper + .Setup(x => x.GetTokenAsync(Scope, It.IsAny())) + .ReturnsAsync(new OAuthTokenResponse { AccessToken = "the-token" }); + + using var client = CreateClient(); + using var request = new HttpRequestMessage(HttpMethod.Get, "https://example.test/resource"); + request.Headers.Add(OAuthConstants.ScopeHeaderName, Scope); + + using var response = await client.SendAsync(request).WaitAsync(Timeout); + + _inner.LastRequest.Should().NotBeNull(); + _inner.LastRequest!.Headers.Authorization.Should().NotBeNull(); + _inner.LastRequest.Headers.Authorization!.Scheme.Should().Be("Bearer"); + _inner.LastRequest.Headers.Authorization.Parameter.Should().Be("the-token"); + _inner.LastRequest.Headers.Contains(OAuthConstants.ScopeHeaderName).Should().BeFalse(); + + _tokenCacheHelper.Verify( + x => x.GetTokenAsync(Scope, It.IsAny()), + Times.Once); + } + + [Fact] + public async Task SendAsync_WhenNoScopeHeader_PassesThroughWithoutRequestingToken() + { + using var client = CreateClient(); + using var request = new HttpRequestMessage(HttpMethod.Get, "https://example.test/resource"); + + using var response = await client.SendAsync(request).WaitAsync(Timeout); + + _inner.LastRequest.Should().NotBeNull(); + _inner.LastRequest!.Headers.Authorization.Should().BeNull(); + + _tokenCacheHelper.Verify( + x => x.GetTokenAsync(It.IsAny(), It.IsAny()), + Times.Never); + } + + [Fact] + public async Task SendAsync_WhenScopeHeaderEmpty_PassesThroughWithoutRequestingToken() + { + using var client = CreateClient(); + using var request = new HttpRequestMessage(HttpMethod.Get, "https://example.test/resource"); + request.Headers.Add(OAuthConstants.ScopeHeaderName, string.Empty); + + using var response = await client.SendAsync(request).WaitAsync(Timeout); + + _inner.LastRequest.Should().NotBeNull(); + _inner.LastRequest!.Headers.Authorization.Should().BeNull(); + _inner.LastRequest.Headers.Contains(OAuthConstants.ScopeHeaderName).Should().BeFalse(); + + _tokenCacheHelper.Verify( + x => x.GetTokenAsync(It.IsAny(), It.IsAny()), + Times.Never); + } + + [Fact] + public async Task SendAsync_WhenResponseUnauthorized_InvalidatesCachedToken() + { + _tokenCacheHelper + .Setup(x => x.GetTokenAsync(Scope, It.IsAny())) + .ReturnsAsync(new OAuthTokenResponse { AccessToken = "the-token" }); + _tokenCacheHelper + .Setup(x => x.RemoveTokenAsync(Scope, It.IsAny())) + .Returns(Task.CompletedTask); + + _inner.ResponseStatusCode = HttpStatusCode.Unauthorized; + + using var client = CreateClient(); + using var request = new HttpRequestMessage(HttpMethod.Get, "https://example.test/resource"); + request.Headers.Add(OAuthConstants.ScopeHeaderName, Scope); + + using var response = await client.SendAsync(request).WaitAsync(Timeout); + + response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + _tokenCacheHelper.Verify( + x => x.RemoveTokenAsync(Scope, It.IsAny()), + Times.Once); + } + + [Fact] + public async Task SendAsync_WhenResponseUnauthorized_DoesNotRetryAndSurfacesTheUnauthorizedResponse() + { + _tokenCacheHelper + .Setup(x => x.GetTokenAsync(Scope, It.IsAny())) + .ReturnsAsync(new OAuthTokenResponse { AccessToken = "the-token" }); + _tokenCacheHelper + .Setup(x => x.RemoveTokenAsync(Scope, It.IsAny())) + .Returns(Task.CompletedTask); + + _inner.ResponseStatusCode = HttpStatusCode.Unauthorized; + + using var client = CreateClient(); + using var request = new HttpRequestMessage(HttpMethod.Get, "https://example.test/resource"); + request.Headers.Add(OAuthConstants.ScopeHeaderName, Scope); + + using var response = await client.SendAsync(request).WaitAsync(Timeout); + + // Eviction only: the request is sent once and the 401 reaches the caller unchanged. + // Recovery is one request late by design. + _inner.CallCount.Should().Be(1); + response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + _tokenCacheHelper.Verify( + x => x.GetTokenAsync(Scope, It.IsAny()), + Times.Once); + } + + [Fact] + public async Task SendAsync_WhenResponseSuccessful_DoesNotInvalidateCachedToken() + { + _tokenCacheHelper + .Setup(x => x.GetTokenAsync(Scope, It.IsAny())) + .ReturnsAsync(new OAuthTokenResponse { AccessToken = "the-token" }); + + _inner.ResponseStatusCode = HttpStatusCode.OK; + + using var client = CreateClient(); + using var request = new HttpRequestMessage(HttpMethod.Get, "https://example.test/resource"); + request.Headers.Add(OAuthConstants.ScopeHeaderName, Scope); + + using var response = await client.SendAsync(request).WaitAsync(Timeout); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + _tokenCacheHelper.Verify( + x => x.RemoveTokenAsync(It.IsAny(), It.IsAny()), + Times.Never); + } + + [Fact] + public async Task SendAsync_WhenRequestNull_ThrowsArgumentNullException() + { + var sut = new OAuthDelegatingHandler(_tokenCacheHelper.Object) + { + InnerHandler = _inner + }; + + var sendAsync = typeof(OAuthDelegatingHandler).GetMethod( + "SendAsync", + BindingFlags.Instance | BindingFlags.NonPublic, + binder: null, + types: new[] { typeof(HttpRequestMessage), typeof(CancellationToken) }, + modifiers: null); + sendAsync.Should().NotBeNull(); + + var act = () => + { + var task = (Task)sendAsync!.Invoke( + sut, + new object?[] { null, CancellationToken.None })!; + return task; + }; + + await FluentActions + .Awaiting(() => act()) + .Should().ThrowAsync(); + } + + private sealed class RecordingInnerHandler : HttpMessageHandler + { + public HttpRequestMessage? LastRequest { get; private set; } + + public int CallCount { get; private set; } + + public HttpStatusCode ResponseStatusCode { get; set; } = HttpStatusCode.OK; + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + LastRequest = request; + CallCount++; + return Task.FromResult(new HttpResponseMessage(ResponseStatusCode) + { + RequestMessage = request + }); + } + } + } +} diff --git a/tests/TBC.OpenAPI.SDK.Core.Tests/OAuthTokenCacheHelperTests.cs b/tests/TBC.OpenAPI.SDK.Core.Tests/OAuthTokenCacheHelperTests.cs new file mode 100644 index 0000000..b85d2e3 --- /dev/null +++ b/tests/TBC.OpenAPI.SDK.Core.Tests/OAuthTokenCacheHelperTests.cs @@ -0,0 +1,288 @@ +using System; +using System.Collections.Concurrent; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Microsoft.Extensions.Caching.Distributed; +using Moq; +using TBC.OpenAPI.SDK.Core.Authentication; +using TBC.OpenAPI.SDK.Core.Exceptions; +using Xunit; + +namespace TBC.OpenAPI.SDK.Core.Tests +{ + public class OAuthTokenCacheHelperTests + { + private const string Scope = "some-scope"; + + private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(5); + + private readonly Mock> _tokenHelper = new(MockBehavior.Strict); + private readonly FakeDistributedCache _cache = new(); + + private OAuthTokenCacheHelper CreateSut() + => new(_tokenHelper.Object, _cache); + + [Fact] + public async Task GetTokenAsync_WhenTokenCached_ReturnsCachedTokenWithoutRequesting() + { + var sut = CreateSut(); + _cache.Seed(GetCacheKey(Scope), "cached-token"); + + var result = await sut.GetTokenAsync(Scope, CancellationToken.None).WaitAsync(Timeout); + + result.AccessToken.Should().Be("cached-token"); + _tokenHelper.Verify( + x => x.RequestToken(It.IsAny(), It.IsAny()), + Times.Never); + } + + [Fact] + public async Task GetTokenAsync_WhenNotCached_RequestsCachesAndReturnsToken() + { + var sut = CreateSut(); + SetupToken(accessToken: "fresh-token", expiresIn: 3600); + + var result = await sut.GetTokenAsync(Scope, CancellationToken.None).WaitAsync(Timeout); + + result.AccessToken.Should().Be("fresh-token"); + _cache.GetString(GetCacheKey(Scope)).Should().Be("fresh-token"); + _tokenHelper.Verify( + x => x.RequestToken(Scope, It.IsAny()), + Times.Once); + } + + [Fact] + public async Task GetTokenAsync_WhenConcurrentSameScope_RequestsTokenOnce() + { + var sut = CreateSut(); + + var gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var callCount = 0; + _tokenHelper + .Setup(x => x.RequestToken(Scope, It.IsAny())) + .Returns(async (_, _) => + { + Interlocked.Increment(ref callCount); + await gate.Task; + return new OAuthTokenResponse { AccessToken = "coalesced-token", ExpiresIn = 3600 }; + }); + + var callers = Enumerable + .Range(0, 50) + .Select(_ => Task.Run(() => sut.GetTokenAsync(Scope, CancellationToken.None))) + .ToArray(); + + // Give every caller time to subscribe to the single in-flight fetch, then release it. + await Task.Delay(100); + gate.SetResult(true); + + var results = await Task.WhenAll(callers).WaitAsync(Timeout); + + results.Should().OnlyContain(r => r.AccessToken == "coalesced-token"); + callCount.Should().Be(1); + _tokenHelper.Verify( + x => x.RequestToken(Scope, It.IsAny()), + Times.Once); + } + + [Fact] + public async Task GetTokenAsync_WhenDifferentScopes_FetchesConcurrentlyPerScope() + { + var sut = CreateSut(); + + const string scopeA = "scope-a"; + const string scopeB = "scope-b"; + + var started = 0; + var bothStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + _tokenHelper + .Setup(x => x.RequestToken(It.IsAny(), It.IsAny())) + .Returns(async (scope, _) => + { + // A single global lock would prevent the second scope from ever starting, + // so bothStarted would never complete and the test would time out. + if (Interlocked.Increment(ref started) == 2) + { + bothStarted.SetResult(true); + } + + await bothStarted.Task; + return new OAuthTokenResponse { AccessToken = $"token:{scope}", ExpiresIn = 3600 }; + }); + + var taskA = Task.Run(() => sut.GetTokenAsync(scopeA, CancellationToken.None)); + var taskB = Task.Run(() => sut.GetTokenAsync(scopeB, CancellationToken.None)); + + var results = await Task.WhenAll(taskA, taskB).WaitAsync(Timeout); + + results[0].AccessToken.Should().Be($"token:{scopeA}"); + results[1].AccessToken.Should().Be($"token:{scopeB}"); + _tokenHelper.Verify( + x => x.RequestToken(It.IsAny(), It.IsAny()), + Times.Exactly(2)); + } + + [Fact] + public async Task GetTokenAsync_WhenOneCallerCancels_DoesNotAbortSharedFetch() + { + var sut = CreateSut(); + + var gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + _tokenHelper + .Setup(x => x.RequestToken(Scope, It.IsAny())) + .Returns(async (_, _) => + { + await gate.Task; + return new OAuthTokenResponse { AccessToken = "shared-token", ExpiresIn = 3600 }; + }); + + using var ctsForCanceller = new CancellationTokenSource(); + var cancellingCaller = sut.GetTokenAsync(Scope, ctsForCanceller.Token); + var survivingCaller = sut.GetTokenAsync(Scope, CancellationToken.None); + + // Let both callers subscribe to the same in-flight fetch before cancelling one of them. + await Task.Delay(100); + ctsForCanceller.Cancel(); + + await FluentActions + .Awaiting(() => cancellingCaller.WaitAsync(Timeout)) + .Should().ThrowAsync(); + + gate.SetResult(true); + + var result = await survivingCaller.WaitAsync(Timeout); + result.AccessToken.Should().Be("shared-token"); + _tokenHelper.Verify( + x => x.RequestToken(Scope, It.IsAny()), + Times.Once); + } + + [Theory] + [InlineData(3600, 3600 - OAuthConstants.TokenTimeoutGracePeriodSec)] + [InlineData(10, OAuthConstants.MinCacheTtlSec)] + [InlineData(null, OAuthConstants.MinCacheTtlSec)] + public async Task GetTokenAsync_CachesTokenWithExpectedTtl(int? expiresIn, int expectedTtlSec) + { + var sut = CreateSut(); + SetupToken(accessToken: "ttl-token", expiresIn: expiresIn); + + await sut.GetTokenAsync(Scope, CancellationToken.None).WaitAsync(Timeout); + + _cache.LastSetOptions.Should().NotBeNull(); + _cache.LastSetOptions!.AbsoluteExpirationRelativeToNow + .Should().Be(TimeSpan.FromSeconds(expectedTtlSec)); + } + + [Fact] + public async Task RemoveTokenAsync_RemovesCachedTokenSoNextCallRefetches() + { + var sut = CreateSut(); + SetupToken(accessToken: "first-token", expiresIn: 3600); + + await sut.GetTokenAsync(Scope, CancellationToken.None).WaitAsync(Timeout); + await sut.RemoveTokenAsync(Scope, CancellationToken.None).WaitAsync(Timeout); + + _cache.GetString(GetCacheKey(Scope)).Should().BeNull(); + + await sut.GetTokenAsync(Scope, CancellationToken.None).WaitAsync(Timeout); + + _tokenHelper.Verify( + x => x.RequestToken(Scope, It.IsAny()), + Times.Exactly(2)); + } + + [Fact] + public async Task GetTokenAsync_WhenTokenHelperReturnsNull_ThrowsAndDoesNotCache() + { + var sut = CreateSut(); + _tokenHelper + .Setup(x => x.RequestToken(Scope, It.IsAny())) + .ReturnsAsync((OAuthTokenResponse)null!); + + await FluentActions + .Awaiting(() => sut.GetTokenAsync(Scope, CancellationToken.None).WaitAsync(Timeout)) + .Should().ThrowAsync() + .WithMessage("Failed to obtain a new OAuth token."); + + _cache.GetString(GetCacheKey(Scope)).Should().BeNull(); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + public async Task GetTokenAsync_WhenAccessTokenEmpty_ThrowsAndDoesNotCache(string? accessToken) + { + var sut = CreateSut(); + _tokenHelper + .Setup(x => x.RequestToken(Scope, It.IsAny())) + .ReturnsAsync(new OAuthTokenResponse { AccessToken = accessToken, ExpiresIn = 3600 }); + + await FluentActions + .Awaiting(() => sut.GetTokenAsync(Scope, CancellationToken.None).WaitAsync(Timeout)) + .Should().ThrowAsync() + .WithMessage("Received empty access token."); + + _cache.GetString(GetCacheKey(Scope)).Should().BeNull(); + } + + private void SetupToken(string accessToken, int? expiresIn) + { + _tokenHelper + .Setup(x => x.RequestToken(Scope, It.IsAny())) + .ReturnsAsync(new OAuthTokenResponse { AccessToken = accessToken, ExpiresIn = expiresIn }); + } + + private static string GetCacheKey(string scope) + => $"{OAuthConstants.CacheKeyPrefix}:{typeof(ITestClient).FullName}:{scope}"; + + private sealed class FakeDistributedCache : IDistributedCache + { + private readonly ConcurrentDictionary _store = new(StringComparer.Ordinal); + + public DistributedCacheEntryOptions? LastSetOptions { get; private set; } + + public void Seed(string key, string value) + => _store[key] = Encoding.UTF8.GetBytes(value); + + public string? GetString(string key) + => _store.TryGetValue(key, out var value) ? Encoding.UTF8.GetString(value) : null; + + public byte[]? Get(string key) + => _store.TryGetValue(key, out var value) ? value : null; + + public Task GetAsync(string key, CancellationToken token = default) + => Task.FromResult(Get(key)); + + public void Set(string key, byte[] value, DistributedCacheEntryOptions options) + { + LastSetOptions = options; + _store[key] = value; + } + + public Task SetAsync(string key, byte[] value, DistributedCacheEntryOptions options, CancellationToken token = default) + { + Set(key, value, options); + return Task.CompletedTask; + } + + public void Refresh(string key) + { + } + + public Task RefreshAsync(string key, CancellationToken token = default) + => Task.CompletedTask; + + public void Remove(string key) + => _store.TryRemove(key, out _); + + public Task RemoveAsync(string key, CancellationToken token = default) + { + Remove(key); + return Task.CompletedTask; + } + } + } +} diff --git a/tests/TBC.OpenAPI.SDK.Core.Tests/OAuthTokenCachingBuilderTests.cs b/tests/TBC.OpenAPI.SDK.Core.Tests/OAuthTokenCachingBuilderTests.cs new file mode 100644 index 0000000..6732462 --- /dev/null +++ b/tests/TBC.OpenAPI.SDK.Core.Tests/OAuthTokenCachingBuilderTests.cs @@ -0,0 +1,344 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using FluentAssertions.Execution; +using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.DependencyInjection; +using Moq; +using TBC.OpenAPI.SDK.Core.Authentication; +using TBC.OpenAPI.SDK.Core.Extensions; +using Xunit; + +namespace TBC.OpenAPI.SDK.Core.Tests +{ + public class OAuthTokenCachingBuilderTests + { + private const string Scope = "some-scope"; + + private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(5); + + [Fact] + public void AddOAuthTokenCaching_WhenNoCacheSelected_ShouldThrowWithActionableMessage() + { + // Arrange + var services = CreateServices(); + services.AddOAuthTokenCaching(); + using var provider = services.BuildServiceProvider(); + + // Act + Action act = () => provider.GetRequiredService>(); + + // Assert + using (new AssertionScope()) + { + var assertion = act.Should().Throw(); + assertion.Which.Message.Should().Contain("No OAuth token cache has been selected"); + assertion.Which.Message.Should().Contain("UseInMemoryCache()"); + assertion.Which.Message.Should().Contain("UseRegisteredDistributedCache()"); + assertion.Which.Message.Should().Contain("UseDistributedCache("); + assertion.Which.Message.Should().Contain(typeof(TestClient).FullName); + } + } + + [Fact] + public void UseInMemoryCache_ShouldNotRegisterDistributedCacheInContainer() + { + // Arrange + var services = CreateServices(); + services.AddOAuthTokenCaching().UseInMemoryCache(); + using var provider = services.BuildServiceProvider(); + + // Act + var registeredCache = provider.GetService(); + + // Assert + registeredCache.Should().BeNull(); + } + + [Fact] + public async Task UseInMemoryCache_ShouldResolveWorkingTokenCacheHelper() + { + // Arrange + var services = CreateServices(out var tokenHelper); + tokenHelper + .Setup(x => x.RequestToken(Scope, It.IsAny())) + .ReturnsAsync(new OAuthTokenResponse { AccessToken = "token", ExpiresIn = 3600 }); + services.AddOAuthTokenCaching().UseInMemoryCache(); + using var provider = services.BuildServiceProvider(); + var sut = provider.GetRequiredService>(); + + // Act + var first = await sut.GetTokenAsync(Scope, CancellationToken.None).WaitAsync(Timeout); + var second = await sut.GetTokenAsync(Scope, CancellationToken.None).WaitAsync(Timeout); + + // Assert + using (new AssertionScope()) + { + first.AccessToken.Should().Be("token"); + second.AccessToken.Should().Be("token"); + tokenHelper.Verify( + x => x.RequestToken(Scope, It.IsAny()), + Times.Once); + } + } + + [Fact] + public void UseRegisteredDistributedCache_WhenNoCacheRegistered_ShouldThrow() + { + // Arrange + var services = CreateServices(); + services.AddOAuthTokenCaching().UseRegisteredDistributedCache(); + using var provider = services.BuildServiceProvider(); + + // Act + Action act = () => provider.GetRequiredService>(); + + // Assert + act.Should().Throw() + .Which.Message.Should().Contain("no IDistributedCache is registered in the container"); + } + + [Fact] + public void UseRegisteredDistributedCache_WhenMemoryDistributedCacheRegistered_ShouldThrow() + { + // Arrange + var services = CreateServices(); + services.AddDistributedMemoryCache(); + services.AddOAuthTokenCaching().UseRegisteredDistributedCache(); + using var provider = services.BuildServiceProvider(); + + // Act + Action act = () => provider.GetRequiredService>(); + + // Assert + act.Should().Throw() + .Which.Message.Should().Contain("not shared across processes or instances"); + } + + [Fact] + public async Task UseRegisteredDistributedCache_WhenDistributedCacheRegistered_ShouldUseIt() + { + // Arrange + var cache = new RecordingDistributedCache(); + var services = CreateServices(); + services.AddSingleton(cache); + services.AddOAuthTokenCaching().UseRegisteredDistributedCache(); + using var provider = services.BuildServiceProvider(); + var sut = provider.GetRequiredService>(); + + // Act + await sut.RemoveTokenAsync(Scope, CancellationToken.None).WaitAsync(Timeout); + + // Assert + cache.RemovedKeys.Should().ContainSingle().Which.Should().Be(GetCacheKey(Scope)); + } + + [Fact] + public async Task UseDistributedCache_WithInstance_ShouldUseProvidedInstance() + { + // Arrange + var cache = new RecordingDistributedCache(); + var services = CreateServices(); + services.AddOAuthTokenCaching().UseDistributedCache(cache); + using var provider = services.BuildServiceProvider(); + var sut = provider.GetRequiredService>(); + + // Act + await sut.RemoveTokenAsync(Scope, CancellationToken.None).WaitAsync(Timeout); + + // Assert + using (new AssertionScope()) + { + cache.RemovedKeys.Should().ContainSingle().Which.Should().Be(GetCacheKey(Scope)); + provider.GetService().Should().BeNull(); + } + } + + [Fact] + public async Task UseDistributedCache_WithFactory_ShouldUseFactoryResult() + { + // Arrange + var cache = new RecordingDistributedCache(); + var services = CreateServices(); + services.AddSingleton(cache); + services.AddOAuthTokenCaching() + .UseDistributedCache(sp => sp.GetRequiredService()); + using var provider = services.BuildServiceProvider(); + var sut = provider.GetRequiredService>(); + + // Act + await sut.RemoveTokenAsync(Scope, CancellationToken.None).WaitAsync(Timeout); + + // Assert + cache.RemovedKeys.Should().ContainSingle().Which.Should().Be(GetCacheKey(Scope)); + } + + [Fact] + public void UseDistributedCache_WhenCacheIsNull_ShouldThrowArgumentNullException() + { + // Arrange + var builder = CreateServices().AddOAuthTokenCaching(); + + // Act + Action act = () => builder.UseDistributedCache((IDistributedCache)null!); + + // Assert + act.Should().Throw(); + } + + [Fact] + public void UseDistributedCache_WhenFactoryIsNull_ShouldThrowArgumentNullException() + { + // Arrange + var builder = CreateServices().AddOAuthTokenCaching(); + + // Act + Action act = () => builder.UseDistributedCache((Func)null!); + + // Assert + act.Should().Throw(); + } + + [Fact] + public void UseDistributedCache_WhenFactoryReturnsNull_ShouldThrow() + { + // Arrange + var services = CreateServices(); + services.AddOAuthTokenCaching().UseDistributedCache(_ => null!); + using var provider = services.BuildServiceProvider(); + + // Act + Action act = () => provider.GetRequiredService>(); + + // Assert + act.Should().Throw() + .Which.Message.Should().Contain("returned null"); + } + + [Fact] + public async Task AddOAuthTokenCaching_WhenTwoClientsSelectDifferentCaches_ShouldKeepThemIsolated() + { + // Arrange + var sharedCache = new RecordingDistributedCache(); + var services = CreateServices(); + services.AddSingleton(Mock.Of>()); + services.AddOAuthTokenCaching().UseDistributedCache(sharedCache); + services.AddOAuthTokenCaching().UseInMemoryCache(); + using var provider = services.BuildServiceProvider(); + + // Act + await provider.GetRequiredService>() + .RemoveTokenAsync(Scope, CancellationToken.None).WaitAsync(Timeout); + await provider.GetRequiredService>() + .RemoveTokenAsync(Scope, CancellationToken.None).WaitAsync(Timeout); + + // Assert + sharedCache.RemovedKeys.Should().ContainSingle().Which.Should().Be(GetCacheKey(Scope)); + } + + [Fact] + public async Task AddOAuthTokenCaching_WhenSelectedTwice_ShouldUseLastSelection() + { + // Arrange + var first = new RecordingDistributedCache(); + var second = new RecordingDistributedCache(); + var services = CreateServices(); + services.AddOAuthTokenCaching().UseDistributedCache(first); + services.AddOAuthTokenCaching().UseDistributedCache(second); + using var provider = services.BuildServiceProvider(); + var sut = provider.GetRequiredService>(); + + // Act + await sut.RemoveTokenAsync(Scope, CancellationToken.None).WaitAsync(Timeout); + + // Assert + using (new AssertionScope()) + { + first.RemovedKeys.Should().BeEmpty(); + second.RemovedKeys.Should().ContainSingle(); + } + } + + [Fact] + public void OpenApiClientFactoryBuilder_AddOAuthTokenCaching_ShouldReturnFactoryBuilderFromTerminal() + { + // Arrange + var factoryBuilder = new OpenApiClientFactoryBuilder(); + + // Act + var result = factoryBuilder.AddOAuthTokenCaching().UseInMemoryCache(); + + // Assert + result.Should().BeSameAs(factoryBuilder); + } + + private static IServiceCollection CreateServices() + => CreateServices(out _); + + private static IServiceCollection CreateServices(out Mock> tokenHelper) + { + tokenHelper = new Mock>(); + + var services = new ServiceCollection(); + services.AddSingleton(tokenHelper.Object); + + return services; + } + + private static string GetCacheKey(string scope) + => $"{OAuthConstants.CacheKeyPrefix}:{typeof(TClient).FullName}:{scope}"; + + private sealed class RecordingDistributedCache : IDistributedCache + { + private readonly ConcurrentDictionary _store = new(StringComparer.Ordinal); + private readonly ConcurrentQueue _removedKeys = new(); + + public IEnumerable RemovedKeys => _removedKeys; + + public byte[]? Get(string key) + => _store.TryGetValue(key, out var value) ? value : null; + + public Task GetAsync(string key, CancellationToken token = default) + => Task.FromResult(Get(key)); + + public void Set(string key, byte[] value, DistributedCacheEntryOptions options) + => _store[key] = value; + + public Task SetAsync(string key, byte[] value, DistributedCacheEntryOptions options, CancellationToken token = default) + { + Set(key, value, options); + return Task.CompletedTask; + } + + public void Refresh(string key) + { + } + + public Task RefreshAsync(string key, CancellationToken token = default) + => Task.CompletedTask; + + public void Remove(string key) + { + _store.TryRemove(key, out _); + _removedKeys.Enqueue(key); + } + + public Task RemoveAsync(string key, CancellationToken token = default) + { + Remove(key); + return Task.CompletedTask; + } + } + } + + public interface IOtherTestClient : IOpenApiClient + { + } + + public class OtherTestClient : IOtherTestClient + { + } +} diff --git a/tests/TBC.OpenAPI.SDK.Core.Tests/OAuthTokenCachingClientBindingTests.cs b/tests/TBC.OpenAPI.SDK.Core.Tests/OAuthTokenCachingClientBindingTests.cs new file mode 100644 index 0000000..343bd86 --- /dev/null +++ b/tests/TBC.OpenAPI.SDK.Core.Tests/OAuthTokenCachingClientBindingTests.cs @@ -0,0 +1,270 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using FluentAssertions.Execution; +using Microsoft.Extensions.DependencyInjection; +using TBC.OpenAPI.SDK.Core.Authentication; +using TBC.OpenAPI.SDK.Core.Extensions; +using TBC.OpenAPI.SDK.Core.Models; +using WireMock; +using WireMock.RequestBuilders; +using WireMock.ResponseBuilders; +using WireMock.Server; +using Xunit; + +namespace TBC.OpenAPI.SDK.Core.Tests +{ + /// + /// Covers which named token caching binds to. The + /// handler is attached to the client named after the type argument, while AddOpenApiClient + /// configures that client under the implementation type, so only the implementation type wires + /// the two together. + /// + public class OAuthTokenCachingClientBindingTests : IDisposable + { + private const string Scope = "read:some-resource"; + private const string AccessToken = "the-access-token"; + + private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(30); + + private readonly WireMockServer _server = WireMockServer.Start(); + + [Fact] + public async Task AddOAuthTokenCaching_WhenClientSendsScopedRequest_ShouldAttachBearerTokenAndStripScopeHeader() + { + // Arrange + StubTokenEndpoint(); + StubResourceEndpoint(); + + var services = new ServiceCollection(); + services.AddOpenApiClient( + new BindingTestClientOptions + { + BaseUrl = $"{_server.Urls[0]}/", + ApiKey = "the-api-key" + }); + services.AddOAuthTokenCaching().UseInMemoryCache(); + + using var provider = services.BuildServiceProvider(); + var client = provider.GetRequiredService(); + + // Act + var response = await client.GetResourceAsync(Scope, CancellationToken.None).WaitAsync(Timeout); + + // Assert + using (new AssertionScope()) + { + response.IsSuccess.Should().BeTrue(); + + var resourceRequest = SingleRequestTo("/some-resource"); + FindHeader(resourceRequest, "Authorization").Should().Be($"Bearer {AccessToken}"); + FindHeader(resourceRequest, OAuthConstants.ScopeHeaderName).Should().BeNull(); + + // The token endpoint call proves the token helper resolved the configured client: + // an unbound named client would have no BaseAddress. + var tokenRequest = SingleRequestTo("/oauth/token"); + FindHeader(tokenRequest, "apikey").Should().Be("the-api-key"); + FindHeader(tokenRequest, "Authorization").Should().BeNull(); + } + } + + [Fact] + public async Task AddOAuthTokenCaching_WhenSameScopeRequestedTwice_ShouldRequestTokenOnce() + { + // Arrange + StubTokenEndpoint(); + StubResourceEndpoint(); + + var services = new ServiceCollection(); + services.AddOpenApiClient( + new BindingTestClientOptions + { + BaseUrl = $"{_server.Urls[0]}/", + ApiKey = "the-api-key" + }); + services.AddOAuthTokenCaching().UseInMemoryCache(); + + using var provider = services.BuildServiceProvider(); + var client = provider.GetRequiredService(); + + // Act + await client.GetResourceAsync(Scope, CancellationToken.None).WaitAsync(Timeout); + await client.GetResourceAsync(Scope, CancellationToken.None).WaitAsync(Timeout); + + // Assert + using (new AssertionScope()) + { + RequestsTo("/some-resource").Should().HaveCount(2); + RequestsTo("/oauth/token").Should().ContainSingle(); + } + } + + [Fact] + public void AddOAuthTokenCaching_WhenGivenTheClientInterface_ShouldThrow() + { + // Arrange + var services = new ServiceCollection(); + services.AddOpenApiClient( + new BindingTestClientOptions + { + BaseUrl = "https://example.test/", + ApiKey = "the-api-key" + }); + + // Act + Action act = () => services.AddOAuthTokenCaching(); + + // Assert + using (new AssertionScope()) + { + var assertion = act.Should().Throw(); + assertion.Which.Message.Should().Contain(typeof(IBindingTestClient).FullName); + assertion.Which.Message.Should().Contain("implementation type"); + assertion.Which.Message.Should().Contain("IHttpHelper"); + } + } + + [Fact] + public void AddOAuthTokenCaching_WhenGivenTheImplementationType_ShouldNotThrow() + { + // Arrange + var services = new ServiceCollection(); + services.AddOpenApiClient( + new BindingTestClientOptions + { + BaseUrl = "https://example.test/", + ApiKey = "the-api-key" + }); + + // Act + Action act = () => services.AddOAuthTokenCaching().UseInMemoryCache(); + + // Assert + act.Should().NotThrow(); + } + + [Fact] + public void AddOAuthTokenCaching_WhenGivenAnInterfaceAndNoClientRegistered_ShouldStillThrow() + { + // Arrange - an interface can never name the configured HttpClient, so the guard does not + // depend on AddOpenApiClient having run first. + var services = new ServiceCollection(); + + // Act + Action act = () => services.AddOAuthTokenCaching(); + + // Assert + act.Should().Throw() + .Which.Message.Should().Contain(typeof(IBindingTestClient).FullName); + } + + [Fact] + public void OpenApiClientFactoryBuilder_AddOAuthTokenCaching_WhenGivenTheClientInterface_ShouldThrow() + { + // Arrange + var factoryBuilder = new OpenApiClientFactoryBuilder() + .AddClient( + new BindingTestClientOptions + { + BaseUrl = "https://example.test/", + ApiKey = "the-api-key" + }); + + // Act + Action act = () => factoryBuilder.AddOAuthTokenCaching(); + + // Assert + act.Should().Throw() + .Which.Message.Should().Contain(typeof(IBindingTestClient).FullName); + } + + public void Dispose() + { + _server.Stop(); + _server.Dispose(); + GC.SuppressFinalize(this); + } + + private void StubTokenEndpoint() + { + _server + .Given(Request.Create().WithPath("/oauth/token").UsingPost()) + .RespondWith(Response.Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody($"{{\"access_token\":\"{AccessToken}\",\"token_type\":\"Bearer\",\"expires_in\":3600}}")); + } + + private void StubResourceEndpoint() + { + _server + .Given(Request.Create().WithPath("/some-resource").UsingGet()) + .RespondWith(Response.Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody("{\"id\":1}")); + } + + private IEnumerable RequestsTo(string path) + => _server.LogEntries + .Select(x => x.RequestMessage) + .Where(x => string.Equals(x.Path, path, StringComparison.Ordinal)); + + private IRequestMessage SingleRequestTo(string path) => RequestsTo(path).Single(); + + private static string? FindHeader(IRequestMessage request, string name) + { + if (request.Headers is null) + { + return null; + } + + foreach (var header in request.Headers) + { + if (string.Equals(header.Key, name, StringComparison.OrdinalIgnoreCase)) + { + return header.Value?.FirstOrDefault(); + } + } + + return null; + } + } + + public interface IBindingTestClient : IOpenApiClient + { + Task> GetResourceAsync(string scope, CancellationToken cancellationToken); + } + + public class BindingTestClient : IBindingTestClient + { + private readonly IHttpHelper _http; + + public BindingTestClient(IHttpHelper http) + { + _http = http; + } + + public Task> GetResourceAsync(string scope, CancellationToken cancellationToken) + { + var headers = new HeaderParamCollection + { + [OAuthConstants.ScopeHeaderName] = scope + }; + + return _http.GetJsonAsync("/some-resource", query: null, headers, cancellationToken); + } + } + + public class BindingTestClientOptions : OptionsBase + { + } + + public class BindingTestResource + { + public int Id { get; set; } + } +} diff --git a/tests/TBC.OpenAPI.SDK.Core.Tests/OAuthTokenHelperTests.cs b/tests/TBC.OpenAPI.SDK.Core.Tests/OAuthTokenHelperTests.cs new file mode 100644 index 0000000..d9f7ecf --- /dev/null +++ b/tests/TBC.OpenAPI.SDK.Core.Tests/OAuthTokenHelperTests.cs @@ -0,0 +1,154 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using TBC.OpenAPI.SDK.Core.Authentication; +using TBC.OpenAPI.SDK.Core.Exceptions; +using TBC.OpenAPI.SDK.Core.Models; +using Xunit; + +namespace TBC.OpenAPI.SDK.Core.Tests +{ + public class OAuthTokenHelperTests + { + private const string Scope = "some-scope"; + + private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(5); + + private readonly Mock> _http = new(MockBehavior.Strict); + + private OAuthTokenHelper CreateSut() + => new(_http.Object); + + [Fact] + public async Task RequestToken_WhenSuccessful_PostsClientCredentialsFormAndReturnsData() + { + var expected = new OAuthTokenResponse { AccessToken = "fresh-token", ExpiresIn = 3600 }; + UrlFormCollection? capturedForm = null; + string? capturedPath = null; + + _http + .Setup(x => x.PostUrlFormAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Callback((path, form, _) => + { + capturedPath = path; + capturedForm = form; + }) + .ReturnsAsync(new ApiResponse { IsSuccess = true, Data = expected }); + + var sut = CreateSut(); + + var result = await sut.RequestToken(Scope, CancellationToken.None).WaitAsync(Timeout); + + result.Should().BeSameAs(expected); + capturedPath.Should().Be("oauth/token"); + capturedForm.Should().NotBeNull(); + capturedForm!["grant_type"].Should().Be("client_credentials"); + capturedForm["scope"].Should().Be(Scope); + } + + [Fact] + public async Task RequestToken_WhenNotSuccessful_ThrowsWithProblemTitle() + { + _http + .Setup(x => x.PostUrlFormAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(new ApiResponse + { + IsSuccess = false, + Problem = new ProblemDetails { Title = "invalid_client" } + }); + + var sut = CreateSut(); + + await FluentActions + .Awaiting(() => sut.RequestToken(Scope, CancellationToken.None).WaitAsync(Timeout)) + .Should().ThrowAsync() + .WithMessage("invalid_client"); + } + + [Fact] + public async Task RequestToken_WhenNotSuccessfulAndNoProblem_ThrowsWithFallbackMessage() + { + _http + .Setup(x => x.PostUrlFormAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(new ApiResponse { IsSuccess = false, Problem = null }); + + var sut = CreateSut(); + + await FluentActions + .Awaiting(() => sut.RequestToken(Scope, CancellationToken.None).WaitAsync(Timeout)) + .Should().ThrowAsync() + .WithMessage("Token request was unsuccessful"); + } + + [Fact] + public async Task RequestToken_WhenNotSuccessful_PropagatesInnerException() + { + var inner = new InvalidOperationException("transport-failure"); + _http + .Setup(x => x.PostUrlFormAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(new ApiResponse { IsSuccess = false, Exception = inner }); + + var sut = CreateSut(); + + var thrown = await FluentActions + .Awaiting(() => sut.RequestToken(Scope, CancellationToken.None).WaitAsync(Timeout)) + .Should().ThrowAsync(); + + thrown.Which.InnerException.Should().BeSameAs(inner); + } + + [Fact] + public async Task RequestToken_WhenDataNull_Throws() + { + _http + .Setup(x => x.PostUrlFormAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(new ApiResponse { IsSuccess = true, Data = null }); + + var sut = CreateSut(); + + await FluentActions + .Awaiting(() => sut.RequestToken(Scope, CancellationToken.None).WaitAsync(Timeout)) + .Should().ThrowAsync(); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + public async Task RequestToken_WhenAccessTokenMissing_Throws(string? accessToken) + { + _http + .Setup(x => x.PostUrlFormAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(new ApiResponse + { + IsSuccess = true, + Data = new OAuthTokenResponse { AccessToken = accessToken } + }); + + var sut = CreateSut(); + + await FluentActions + .Awaiting(() => sut.RequestToken(Scope, CancellationToken.None).WaitAsync(Timeout)) + .Should().ThrowAsync(); + } + } +} diff --git a/tests/TBC.OpenAPI.SDK.Core.Tests/SingleFlightExecutorTests.cs b/tests/TBC.OpenAPI.SDK.Core.Tests/SingleFlightExecutorTests.cs new file mode 100644 index 0000000..e71e4c9 --- /dev/null +++ b/tests/TBC.OpenAPI.SDK.Core.Tests/SingleFlightExecutorTests.cs @@ -0,0 +1,234 @@ +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Xunit; + +namespace TBC.OpenAPI.SDK.Core.Tests +{ + public class SingleFlightExecutorTests + { + private const string Key = "some-key"; + + private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(5); + + [Fact] + public async Task ExecuteAsync_WhenCalledOnce_RunsFactoryAndReturnsResult() + { + var sut = new SingleFlightExecutor(); + var callCount = 0; + + var result = await sut + .ExecuteAsync(Key, () => + { + Interlocked.Increment(ref callCount); + return Task.FromResult(42); + }, CancellationToken.None) + .WaitAsync(Timeout); + + result.Should().Be(42); + callCount.Should().Be(1); + } + + [Fact] + public async Task ExecuteAsync_WhenConcurrentSameKey_RunsFactoryOnce() + { + var sut = new SingleFlightExecutor(); + + var gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var callCount = 0; + + Func> factory = async () => + { + Interlocked.Increment(ref callCount); + await gate.Task; + return 7; + }; + + var callers = Enumerable + .Range(0, 50) + .Select(_ => Task.Run(() => sut.ExecuteAsync(Key, factory, CancellationToken.None))) + .ToArray(); + + // Give every caller time to subscribe to the single in-flight execution, then release it. + await Task.Delay(100); + gate.SetResult(true); + + var results = await Task.WhenAll(callers).WaitAsync(Timeout); + + results.Should().OnlyContain(r => r == 7); + callCount.Should().Be(1); + } + + [Fact] + public async Task ExecuteAsync_WhenDifferentKeys_RunsFactoryPerKey() + { + var sut = new SingleFlightExecutor(); + + const int keyCount = 10; + var keys = Enumerable.Range(0, keyCount).Select(i => $"key-{i}").ToArray(); + + var started = 0; + var allStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + Func> factory = async key => + { + // A single global lock would prevent later keys from ever starting, + // so allStarted would never complete and the test would time out. + if (Interlocked.Increment(ref started) == keyCount) + { + allStarted.SetResult(true); + } + + await allStarted.Task; + return $"value:{key}"; + }; + + var tasks = keys + .Select(key => Task.Run(() => sut.ExecuteAsync(key, () => factory(key), CancellationToken.None))) + .ToArray(); + + var results = await Task.WhenAll(tasks).WaitAsync(Timeout); + + results.Should().BeEquivalentTo(keys.Select(key => $"value:{key}")); + started.Should().Be(keyCount); + } + + [Fact] + public async Task ExecuteAsync_WhenOperationCompletes_RemovesPendingEntrySoNextCallReruns() + { + var sut = new SingleFlightExecutor(); + var callCount = 0; + + Func> factory = () => + { + Interlocked.Increment(ref callCount); + return Task.FromResult(1); + }; + + await sut.ExecuteAsync(Key, factory, CancellationToken.None).WaitAsync(Timeout); + await sut.ExecuteAsync(Key, factory, CancellationToken.None).WaitAsync(Timeout); + + callCount.Should().Be(2); + } + + [Fact] + public async Task ExecuteAsync_WhenFactoryThrows_PropagatesExceptionToAllCallers() + { + var sut = new SingleFlightExecutor(); + + var gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + var callCount = 0; + Func> factory = async () => + { + Interlocked.Increment(ref callCount); + await gate.Task; + throw new InvalidOperationException("boom"); + }; + + var callers = Enumerable + .Range(0, 50) + .Select(_ => Task.Run(() => sut.ExecuteAsync(Key, factory, CancellationToken.None))) + .ToArray(); + + // Give every caller time to subscribe to the single in-flight execution, then release it. + await Task.Delay(100); + gate.SetResult(true); + + foreach (var caller in callers) + { + await FluentActions + .Awaiting(() => caller.WaitAsync(Timeout)) + .Should().ThrowAsync() + .WithMessage("boom"); + } + + callCount.Should().Be(1); + } + + [Fact] + public async Task ExecuteAsync_WhenFactoryThrows_RemovesPendingEntrySoNextCallReruns() + { + var sut = new SingleFlightExecutor(); + var callCount = 0; + + Func> throwingFactory = () => + { + Interlocked.Increment(ref callCount); + throw new InvalidOperationException("boom"); + }; + + await FluentActions + .Awaiting(() => sut.ExecuteAsync(Key, throwingFactory, CancellationToken.None).WaitAsync(Timeout)) + .Should().ThrowAsync(); + + var result = await sut + .ExecuteAsync(Key, () => Task.FromResult(99), CancellationToken.None) + .WaitAsync(Timeout); + + result.Should().Be(99); + callCount.Should().Be(1); + } + + [Fact] + public async Task ExecuteAsync_WhenOneCallerCancels_DoesNotAbortSharedExecution() + { + var sut = new SingleFlightExecutor(); + + var gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var callCount = 0; + + Func> factory = async () => + { + Interlocked.Increment(ref callCount); + await gate.Task; + return 5; + }; + + using var ctsForCanceller = new CancellationTokenSource(); + var cancellingCaller = sut.ExecuteAsync(Key, factory, ctsForCanceller.Token); + var survivingCallers = Enumerable + .Range(0, 50) + .Select(_ => sut.ExecuteAsync(Key, factory, CancellationToken.None)) + .ToArray(); + + // Let all callers subscribe to the same in-flight execution before cancelling one of them. + await Task.Delay(100); + ctsForCanceller.Cancel(); + + await FluentActions + .Awaiting(() => cancellingCaller.WaitAsync(Timeout)) + .Should().ThrowAsync(); + + gate.SetResult(true); + + var results = await Task.WhenAll(survivingCallers).WaitAsync(Timeout); + results.Should().OnlyContain(r => r == 5); + callCount.Should().Be(1); + } + + [Fact] + public async Task ExecuteAsync_WhenTokenAlreadyCancelled_ThrowsOperationCanceled() + { + var sut = new SingleFlightExecutor(); + + var gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + Func> factory = async () => + { + await gate.Task; + return 1; + }; + + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + await FluentActions + .Awaiting(() => sut.ExecuteAsync(Key, factory, cts.Token).WaitAsync(Timeout)) + .Should().ThrowAsync(); + + gate.SetResult(true); + } + } +}