diff --git a/src/Trakx.Canton.ApiClient/Configuration/AuthenticationRequestHandler.cs b/src/Trakx.Canton.ApiClient/Auth/AuthenticationRequestHandler.cs similarity index 100% rename from src/Trakx.Canton.ApiClient/Configuration/AuthenticationRequestHandler.cs rename to src/Trakx.Canton.ApiClient/Auth/AuthenticationRequestHandler.cs diff --git a/src/Trakx.Canton.ApiClient/Configuration/CantonApiClientCredentials.cs b/src/Trakx.Canton.ApiClient/Auth/CantonApiClientCredentials.cs similarity index 100% rename from src/Trakx.Canton.ApiClient/Configuration/CantonApiClientCredentials.cs rename to src/Trakx.Canton.ApiClient/Auth/CantonApiClientCredentials.cs diff --git a/src/Trakx.Canton.ApiClient/Auth/StaticJwtCredentialsProvider.cs b/src/Trakx.Canton.ApiClient/Auth/StaticJwtCredentialsProvider.cs new file mode 100644 index 0000000..5e50e4a --- /dev/null +++ b/src/Trakx.Canton.ApiClient/Auth/StaticJwtCredentialsProvider.cs @@ -0,0 +1,50 @@ +using System.Net.Http.Headers; +using Trakx.Authentication; +using Trakx.Authentication.Client; + +namespace Trakx.Canton.ApiClient; + +/// +/// Provides static JWT credentials. +/// +public class StaticJwtCredentialsProvider : IAuthCredentialsProvider +{ + private readonly string _authToken; + + /// Constructor. + public StaticJwtCredentialsProvider(string authToken) + { + ArgumentException.ThrowIfNullOrWhiteSpace(authToken); + _authToken = authToken; + } + + /// + public void AddCredentials(HttpRequestMessage msg) + { + var credentials = GetCredentials(); + msg.Headers.Authorization = new AuthenticationHeaderValue(credentials.Scheme, credentials.Token); + } + + /// + public Task AddCredentialsAsync(HttpRequestMessage msg, CancellationToken cancellationToken = default) + { + AddCredentials(msg); + return Task.CompletedTask; + } + + /// + public AuthCredentials GetCredentials() + { + return new AuthCredentials + { + Scheme = AuthenticationConstants.CredentialsScheme.Bearer, + Token = _authToken + }; + } + + /// + public Task GetCredentialsAsync(CancellationToken cancellationToken = default) + { + return Task.FromResult(GetCredentials()); + } +} \ No newline at end of file diff --git a/src/Trakx.Canton.ApiClient/Factories/CantonApiClientFactory.cs b/src/Trakx.Canton.ApiClient/Factories/CantonApiClientFactory.cs index a392b83..670767c 100644 --- a/src/Trakx.Canton.ApiClient/Factories/CantonApiClientFactory.cs +++ b/src/Trakx.Canton.ApiClient/Factories/CantonApiClientFactory.cs @@ -15,6 +15,7 @@ public class CantonApiClientFactory : ICantonApiClientFactory private readonly IHttpContextAccessor _httpContextAccessor; private readonly CantonApiClientConfiguration _configuration; private readonly SemaphoreSlim _apiKeyAuthGate = new(1, 1); + private readonly SemaphoreSlim _jwtFromRequestAuthGate = new(1, 1); private readonly SemaphoreSlim _jwtAuthGate = new(1, 1); private readonly TimeSpan _cacheDuration = TimeSpan.FromMinutes(30); private readonly RefitSettings _refitSettings; @@ -59,9 +60,9 @@ public TClient CreateClient(CantonApiClientCredentials credentials) whe } /// - public TClient CreateClientWithJwtAuth() where TClient : ICantonApiClientBase + public TClient CreateClientWithJwtAuth(string authToken) where TClient : ICantonApiClientBase { - var cacheKey = GetJwtAuthCacheKey(); + var cacheKey = GetJwtAuthCacheKey(authToken); if (_cache.TryGetValue(cacheKey, out var cachedClient)) return cachedClient!; @@ -72,7 +73,8 @@ public TClient CreateClientWithJwtAuth() where TClient : ICantonApiClie if (_cache.TryGetValue(cacheKey, out cachedClient)) return cachedClient!; - var client = CreateClientWithJwtAuthInternal(); + var credentialsProvider = new StaticJwtCredentialsProvider(authToken); + var client = CreateClientInternal(credentialsProvider); _cache.Set(cacheKey, client, new MemoryCacheEntryOptions { SlidingExpiration = _cacheDuration }); return client; } @@ -82,6 +84,31 @@ public TClient CreateClientWithJwtAuth() where TClient : ICantonApiClie } } + /// + public TClient CreateClientWithJwtAuthFromCurrentRequest() where TClient : ICantonApiClientBase + { + var cacheKey = GetJwtAuthFromCurrentCacheKey(); + + if (_cache.TryGetValue(cacheKey, out var cachedClient)) + return cachedClient!; + + _jwtFromRequestAuthGate.Wait(); + try + { + if (_cache.TryGetValue(cacheKey, out cachedClient)) + return cachedClient!; + + var credentialsProvider = new JwtCredentialsProvider(_httpContextAccessor); + var client = CreateClientInternal(credentialsProvider); + _cache.Set(cacheKey, client, new MemoryCacheEntryOptions { SlidingExpiration = _cacheDuration }); + return client; + } + finally + { + _jwtFromRequestAuthGate.Release(); + } + } + private TClient CreateClientWithApiKeyAuthInternal(CantonApiClientCredentials credentials) where TClient : ICantonApiClientBase { @@ -95,13 +122,6 @@ private TClient CreateClientWithApiKeyAuthInternal(CantonApiClientCrede return CreateClientInternal(credentialsProvider); } - private TClient CreateClientWithJwtAuthInternal() - where TClient : ICantonApiClientBase - { - var credentialsProvider = new JwtCredentialsProvider(_httpContextAccessor); - return CreateClientInternal(credentialsProvider); - } - private TClient CreateClientInternal(IAuthCredentialsProvider credentialsProvider) where TClient : ICantonApiClientBase { var clientType = typeof(TClient); @@ -120,6 +140,13 @@ private TClient CreateClientInternal(IAuthCredentialsProvider(CantonApiClientCredentials credentials) => $"Canton_ApiClient_ApiKeyAuth_{typeof(TClient).FullName}_{credentials.ClientId}"; - internal static string GetJwtAuthCacheKey() - => $"Canton_ApiClient_JwtAuth_{typeof(TClient).FullName}"; + internal static string GetJwtAuthFromCurrentCacheKey() + => $"Canton_ApiClient_JwtAuthFromRequest_{typeof(TClient).FullName}"; + + internal static string GetJwtAuthCacheKey(string authToken) + { + var tokenHash = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(authToken)); + var tokenHashString = Convert.ToHexString(tokenHash); + return $"Canton_ApiClient_JwtAuth_{tokenHashString}_{typeof(TClient).FullName}"; + } } \ No newline at end of file diff --git a/src/Trakx.Canton.ApiClient/Factories/ICantonApiClientFactory.cs b/src/Trakx.Canton.ApiClient/Factories/ICantonApiClientFactory.cs index b5f73b0..1e89f22 100644 --- a/src/Trakx.Canton.ApiClient/Factories/ICantonApiClientFactory.cs +++ b/src/Trakx.Canton.ApiClient/Factories/ICantonApiClientFactory.cs @@ -15,10 +15,19 @@ public interface ICantonApiClientFactory TClient CreateClient(CantonApiClientCredentials credentials) where TClient : ICantonApiClientBase; /// - /// Creates an API client with JWT authentication. + /// Creates an API client that will use the given JWT Token as authentication token. /// Each call creates a new HttpClient instance with its own handler chain. /// /// The API client interface type. + /// The JWT token to use for authentication. /// A new instance of the API client configured with JWT authentication handler. - TClient CreateClientWithJwtAuth() where TClient : ICantonApiClientBase; + TClient CreateClientWithJwtAuth(string authToken) where TClient : ICantonApiClientBase; + + /// + /// Creates an API client with JWT authentication using the JWT token from the current request. + /// Each call creates a new HttpClient instance with its own handler chain. + /// + /// The API client interface type. + /// A new instance of the API client configured with JWT authentication handler. + TClient CreateClientWithJwtAuthFromCurrentRequest() where TClient : ICantonApiClientBase; } \ No newline at end of file diff --git a/tests/Trakx.Canton.ApiClient.Tests/Auth/StaticJwtCredentialsProviderTests.cs b/tests/Trakx.Canton.ApiClient.Tests/Auth/StaticJwtCredentialsProviderTests.cs new file mode 100644 index 0000000..507a139 --- /dev/null +++ b/tests/Trakx.Canton.ApiClient.Tests/Auth/StaticJwtCredentialsProviderTests.cs @@ -0,0 +1,186 @@ +using System.Net.Http.Headers; +using Trakx.Authentication; + +namespace Trakx.Canton.ApiClient.Tests.Auth; + +public class StaticJwtCredentialsProviderTests +{ + [Fact] + public void Constructor_with_valid_token_should_create_instance() + { + // Arrange + const string token = "valid-jwt-token"; + + // Act + var provider = new StaticJwtCredentialsProvider(token); + + // Assert + provider.Should().NotBeNull(); + } + + [Fact] + public void Constructor_with_null_token_should_throw_ArgumentException() + { + // Act + var act = () => new StaticJwtCredentialsProvider(null!); + + // Assert + act.Should().Throw(); + } + + [Fact] + public void Constructor_with_empty_token_should_throw_ArgumentException() + { + // Act + var act = () => new StaticJwtCredentialsProvider(""); + + // Assert + act.Should().Throw(); + } + + [Fact] + public void Constructor_with_whitespace_token_should_throw_ArgumentException() + { + // Act + var act = () => new StaticJwtCredentialsProvider(" "); + + // Assert + act.Should().Throw(); + } + + [Fact] + public void GetCredentials_should_return_credentials_with_bearer_scheme() + { + // Arrange + const string token = "test-jwt-token"; + var provider = new StaticJwtCredentialsProvider(token); + + // Act + var credentials = provider.GetCredentials(); + + // Assert + credentials.Should().NotBeNull(); + credentials.Scheme.Should().Be(AuthenticationConstants.CredentialsScheme.Bearer); + credentials.Token.Should().Be(token); + } + + [Fact] + public async Task GetCredentialsAsync_should_return_credentials_with_bearer_scheme() + { + // Arrange + const string token = "test-jwt-token"; + var provider = new StaticJwtCredentialsProvider(token); + + // Act + var credentials = await provider.GetCredentialsAsync(); + + // Assert + credentials.Should().NotBeNull(); + credentials.Scheme.Should().Be(AuthenticationConstants.CredentialsScheme.Bearer); + credentials.Token.Should().Be(token); + } + + [Fact] + public void GetCredentials_called_multiple_times_should_return_same_token() + { + // Arrange + const string token = "consistent-jwt-token"; + var provider = new StaticJwtCredentialsProvider(token); + + // Act + var credentials1 = provider.GetCredentials(); + var credentials2 = provider.GetCredentials(); + + // Assert + credentials1.Token.Should().Be(token); + credentials2.Token.Should().Be(token); + credentials1.Token.Should().Be(credentials2.Token); + } + + [Fact] + public void AddCredentials_should_set_authorization_header() + { + // Arrange + const string token = "test-jwt-token"; + var provider = new StaticJwtCredentialsProvider(token); + var request = new HttpRequestMessage(); + + // Act + provider.AddCredentials(request); + + // Assert + request.Headers.Authorization.Should().NotBeNull(); + request.Headers.Authorization!.Scheme.Should().Be(AuthenticationConstants.CredentialsScheme.Bearer); + request.Headers.Authorization.Parameter.Should().Be(token); + } + + [Fact] + public async Task AddCredentialsAsync_should_set_authorization_header() + { + // Arrange + const string token = "test-jwt-token"; + var provider = new StaticJwtCredentialsProvider(token); + var request = new HttpRequestMessage(); + + // Act + await provider.AddCredentialsAsync(request); + + // Assert + request.Headers.Authorization.Should().NotBeNull(); + request.Headers.Authorization!.Scheme.Should().Be(AuthenticationConstants.CredentialsScheme.Bearer); + request.Headers.Authorization.Parameter.Should().Be(token); + } + + [Fact] + public void AddCredentials_called_multiple_times_should_use_same_token() + { + // Arrange + const string token = "consistent-jwt-token"; + var provider = new StaticJwtCredentialsProvider(token); + var request1 = new HttpRequestMessage(); + var request2 = new HttpRequestMessage(); + + // Act + provider.AddCredentials(request1); + provider.AddCredentials(request2); + + // Assert + request1.Headers.Authorization!.Parameter.Should().Be(token); + request2.Headers.Authorization!.Parameter.Should().Be(token); + } + + [Fact] + public void AddCredentials_should_replace_existing_authorization_header() + { + // Arrange + const string token = "new-jwt-token"; + var provider = new StaticJwtCredentialsProvider(token); + var request = new HttpRequestMessage(); + request.Headers.Authorization = new AuthenticationHeaderValue("Basic", "old-credentials"); + + // Act + provider.AddCredentials(request); + + // Assert + request.Headers.Authorization.Should().NotBeNull(); + request.Headers.Authorization!.Scheme.Should().Be(AuthenticationConstants.CredentialsScheme.Bearer); + request.Headers.Authorization.Parameter.Should().Be(token); + } + + [Fact] + public async Task AddCredentialsAsync_with_cancellation_token_should_complete_successfully() + { + // Arrange + const string token = "test-jwt-token"; + var provider = new StaticJwtCredentialsProvider(token); + var request = new HttpRequestMessage(); + var cancellationToken = CancellationToken.None; + + // Act + await provider.AddCredentialsAsync(request, cancellationToken); + + // Assert + request.Headers.Authorization.Should().NotBeNull(); + request.Headers.Authorization!.Parameter.Should().Be(token); + } +} \ No newline at end of file diff --git a/tests/Trakx.Canton.ApiClient.Tests/Factories/CantonApiClientFactoryTests.cs b/tests/Trakx.Canton.ApiClient.Tests/Factories/CantonApiClientFactoryTests.cs index 5935bf1..5b53ee8 100644 --- a/tests/Trakx.Canton.ApiClient.Tests/Factories/CantonApiClientFactoryTests.cs +++ b/tests/Trakx.Canton.ApiClient.Tests/Factories/CantonApiClientFactoryTests.cs @@ -149,10 +149,10 @@ public void CreateClient_after_cache_removal_should_create_new_instance() } [Fact] - public void CreateClientWithJwtAuth_should_return_client() + public void CreateClientWithJwtAuthFromCurrentRequest_should_return_client() { // Act - var client = _factory.CreateClientWithJwtAuth(); + var client = _factory.CreateClientWithJwtAuthFromCurrentRequest(); // Assert client.Should().NotBeNull(); @@ -160,7 +160,7 @@ public void CreateClientWithJwtAuth_should_return_client() } [Fact] - public void CreateClientWithJwtAuth_concurrent_calls_should_return_same_cached_instance() + public void CreateClientWithJwtAuthFromCurrentRequest_concurrent_calls_should_return_same_cached_instance() { // Arrange var clients = new System.Collections.Concurrent.ConcurrentBag(); @@ -168,7 +168,7 @@ public void CreateClientWithJwtAuth_concurrent_calls_should_return_same_cached_i // Act - Create 10 concurrent requests Parallel.For(0, 10, _ => { - var client = _factory.CreateClientWithJwtAuth(); + var client = _factory.CreateClientWithJwtAuthFromCurrentRequest(); clients.Add(client); }); @@ -188,12 +188,148 @@ public void GetApiKeyAuthCacheKey_should_return_the_correct_key() cacheKey.Should().NotContain(credentials.ClientSecret, "secret should not be in cache key for security"); } + [Fact] + public void GetJwtAuthFromCurrentCacheKey_should_return_the_correct_key() + { + var cacheKey = CantonApiClientFactory.GetJwtAuthFromCurrentCacheKey(); + cacheKey.Should().StartWith("Canton_ApiClient_JwtAuthFromRequest_"); + cacheKey.Should().Contain(typeof(IValidatorInternalClient).FullName); + } + [Fact] public void GetJwtAuthCacheKey_should_return_the_correct_key() { - var cacheKey = CantonApiClientFactory.GetJwtAuthCacheKey(); + const string authToken = "some-jwt-token"; + var cacheKey = CantonApiClientFactory.GetJwtAuthCacheKey(authToken); cacheKey.Should().StartWith("Canton_ApiClient_JwtAuth_"); + cacheKey.Should().NotContain(authToken, "token is stored in hashed form"); cacheKey.Should().Contain(typeof(IValidatorInternalClient).FullName); + + var tokenHash = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(authToken)); + var tokenHashString = Convert.ToHexString(tokenHash); + cacheKey.Should().Contain(tokenHashString); + } + + [Fact] + public void CreateClientWithJwtAuth_with_valid_token_should_return_client() + { + // Arrange + const string authToken = "valid-jwt-token"; + + // Act + var client = _factory.CreateClientWithJwtAuth(authToken); + + // Assert + client.Should().NotBeNull(); + client.Should().BeAssignableTo(); + } + + [Fact] + public void CreateClientWithJwtAuth_with_null_token_should_throw_ArgumentException() + { + // Act + var act = () => _factory.CreateClientWithJwtAuth(null!); + + // Assert + act.Should().Throw(); + } + + [Fact] + public void CreateClientWithJwtAuth_with_empty_token_should_throw_ArgumentException() + { + // Act + var act = () => _factory.CreateClientWithJwtAuth(""); + + // Assert + act.Should().Throw(); + } + + [Fact] + public void CreateClientWithJwtAuth_with_whitespace_token_should_throw_ArgumentException() + { + // Act + var act = () => _factory.CreateClientWithJwtAuth(" "); + + // Assert + act.Should().Throw(); + } + + [Fact] + public void CreateClientWithJwtAuth_called_twice_with_same_token_should_return_cached_instance() + { + // Arrange + const string authToken = "same-jwt-token"; + + // Act + var client1 = _factory.CreateClientWithJwtAuth(authToken); + var client2 = _factory.CreateClientWithJwtAuth(authToken); + + // Assert + client1.Should().BeSameAs(client2, "same token should return cached instance"); + } + + [Fact] + public void CreateClientWithJwtAuth_with_different_tokens_should_return_different_instances() + { + // Arrange + const string token1 = "jwt-token-1"; + const string token2 = "jwt-token-2"; + + // Act + var client1 = _factory.CreateClientWithJwtAuth(token1); + var client2 = _factory.CreateClientWithJwtAuth(token2); + + // Assert + client1.Should().NotBeSameAs(client2, "different tokens should create different clients"); + } + + [Fact] + public void CreateClientWithJwtAuth_with_different_client_types_should_return_different_instances() + { + // Arrange + const string authToken = "jwt-token"; + + // Act + var validatorClient = _factory.CreateClientWithJwtAuth(authToken); + var walletClient = _factory.CreateClientWithJwtAuth(authToken); + + // Assert + validatorClient.Should().NotBeSameAs(walletClient, "different client types should create different instances"); + } + + [Fact] + public void CreateClientWithJwtAuth_concurrent_calls_with_same_token_should_return_same_cached_instance() + { + // Arrange + const string authToken = "concurrent-jwt-token"; + var clients = new System.Collections.Concurrent.ConcurrentBag(); + + // Act - Create 10 concurrent requests + Parallel.For(0, 10, _ => + { + var client = _factory.CreateClientWithJwtAuth(authToken); + clients.Add(client); + }); + + // Assert - All should be the same cached instance + clients.Should().HaveCount(10); + clients.Distinct().Should().ContainSingle("all concurrent calls with same token should return the same cached instance"); + } + + [Fact] + public void CreateClientWithJwtAuth_after_cache_removal_should_create_new_instance() + { + // Arrange + const string authToken = "expiration-jwt-token"; + var cacheKey = CantonApiClientFactory.GetJwtAuthCacheKey(authToken); + + // Act + var client1 = _factory.CreateClientWithJwtAuth(authToken); + _memoryCache.Remove(cacheKey); // Simulate cache expiration + var client2 = _factory.CreateClientWithJwtAuth(authToken); + + // Assert + client1.Should().NotBeSameAs(client2, "after cache removal, new instance should be created"); } public void Dispose()