diff --git a/.github/workflows/functional-tests.yml b/.github/workflows/functional-tests.yml index 87fb797..94bee20 100644 --- a/.github/workflows/functional-tests.yml +++ b/.github/workflows/functional-tests.yml @@ -79,7 +79,7 @@ jobs: run: | TAG=${INPUT_TAG} - EA_VERSION=2.0.1-1231 + EA_VERSION=2.2.0-1166 cat > cluster.yml <<'YAML' columnar: true @@ -89,6 +89,7 @@ jobs: docker: load-balancer: true use-dino-certs: true + jwt: true YAML # Substitute shell vars into YAML @@ -102,6 +103,16 @@ jobs: echo "CBDINO_USER=Administrator" >> "$GITHUB_ENV" echo "CBDINO_PASS=password" >> "$GITHUB_ENV" + - name: Create JWT test user and generate token + run: | + echo "Creating jwt-test-user on cluster $CBDINO_CLUSTER_ID" + cbdinocluster -v users add "$CBDINO_CLUSTER_ID" jwt-test-user \ + --password testpass --can-read --can-write + + echo "Generating JWT for jwt-test-user" + CBDINO_JWT=$(cbdinocluster jwt generate jwt-test-user --can-read --can-write) + echo "CBDINO_JWT=$CBDINO_JWT" >> "$GITHUB_ENV" + - name: Prepare Functional Test settings.json run: | echo "Writing functional test settings.json with cluster connection string" @@ -110,7 +121,8 @@ jobs: "TestSettings": { "ConnectionString": "${CBDINO_CONNSTR}", "Username": "${CBDINO_USER}", - "Password": "${CBDINO_PASS}" + "Password": "${CBDINO_PASS}", + "JwtToken": "${CBDINO_JWT}" } } EOF diff --git a/README.md b/README.md index cd70bbe..729e74b 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,15 @@ var cluster = Cluster.Create( ); ``` +Or authenticate with a JWT: + +```csharp +var cluster = Cluster.Create( + connectionString: "https://analytics.my-couchbase.example.com:18095", + credential: JwtCredential.Create("xxxxx.yyyyy.zzzzz") +); +``` + > [!NOTE] > Use `http://host:8095` for non-TLS connections, `https://host:18095` for TLS (or your own custom ports for a load balancer or proxy) > diff --git a/docs/getting-started.md b/docs/getting-started.md index 22c3797..19ab3e8 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -34,6 +34,29 @@ var cluster = Cluster.Create( ); ``` +#### JWT Authentication + +To authenticate with a JSON Web Token (JWT) instead of username and password: + +```csharp +var credential = JwtCredential.Create("xxxxx.yyyyy.zzzzz"); + +var cluster = Cluster.Create( + connectionString: "https://analytics.my-couchbase.example.com:18095", + credential: credential +); +``` + +#### Updating Credentials + +After the cluster is created, you can supply a new credential (of the same type) for all subsequent requests: + +```csharp +cluster.UpdateCredential(Credential.Create("newuser", "newpassword")); +// or +cluster.UpdateCredential(JwtCredential.Create("new.jwt.token")); +``` + > [!NOTE] > Use `http://host:8095` for non-TLS connections, `https://host:18095` for TLS (or your own custom ports for a load balancer or proxy) > diff --git a/src/Couchbase.Analytics/Cluster.cs b/src/Couchbase.Analytics/Cluster.cs index 6291bbc..e4d7ed4 100644 --- a/src/Couchbase.Analytics/Cluster.cs +++ b/src/Couchbase.Analytics/Cluster.cs @@ -32,14 +32,14 @@ namespace Couchbase.AnalyticsClient; public class Cluster : IDisposable { - private readonly Credential _credential; + private volatile ICredential _credential; private readonly ClusterOptions _clusterOptions; private readonly ILogger _logger; private readonly ICouchbaseServiceProvider _serviceProvider; private readonly LazyService _analyticsService; private readonly ConcurrentDictionary _databases = new(); - private Cluster(Credential credential, ClusterOptions clusterOptions) + private Cluster(ICredential credential, ClusterOptions clusterOptions) { if (string.IsNullOrWhiteSpace(clusterOptions.ConnectionString)) { @@ -48,11 +48,10 @@ private Cluster(Credential credential, ClusterOptions clusterOptions) _credential = credential ?? throw new ArgumentNullException(nameof(credential)); _clusterOptions = clusterOptions ?? throw new ArgumentNullException(nameof(clusterOptions)); - _serviceProvider = clusterOptions.BuildServiceProvider(_credential); + _serviceProvider = clusterOptions.BuildServiceProvider(() => _credential); _logger = _serviceProvider.GetRequiredService>(); _analyticsService = new LazyService(_serviceProvider); - } /// @@ -63,7 +62,7 @@ private Cluster(Credential credential, ClusterOptions clusterOptions) /// Action to configure cluster options /// A Cluster instance /// Thrown when the connection string is null or empty, or the credential is null - public static Cluster Create(string connectionString, Credential credential, Func configureOptions) + public static Cluster Create(string connectionString, ICredential credential, Func configureOptions) { if (string.IsNullOrWhiteSpace(connectionString)) throw new ArgumentException("Connection string cannot be null or empty.", nameof(connectionString)); @@ -87,7 +86,7 @@ public static Cluster Create(string connectionString, Credential credential, Fun /// The cluster options to use for the cluster /// A Cluster instance /// Thrown when the connection string is null or empty, or the credential is null - public static Cluster Create(string connectionString, Credential credential, ClusterOptions clusterOptions) + public static Cluster Create(string connectionString, ICredential credential, ClusterOptions clusterOptions) { if (string.IsNullOrWhiteSpace(connectionString)) throw new ArgumentException("Connection string cannot be null or empty.", nameof(connectionString)); @@ -106,7 +105,7 @@ public static Cluster Create(string connectionString, Credential credential, Clu /// The credentials to use for authentication /// A Cluster instance /// Thrown when the connection string is null or empty, or the credential is null - public static Cluster Create(string connectionString, Credential credential) + public static Cluster Create(string connectionString, ICredential credential) { return Create(connectionString, credential, new ClusterOptions()); } @@ -118,7 +117,7 @@ public static Cluster Create(string connectionString, Credential credential) /// Pre-configured cluster options with connection string /// A Cluster instance /// Thrown when the credential or cluster options are null - public static Cluster Create(Credential credential, ClusterOptions clusterOptions) + public static Cluster Create(ICredential credential, ClusterOptions clusterOptions) { ArgumentNullException.ThrowIfNull(credential); ArgumentNullException.ThrowIfNull(clusterOptions); @@ -126,6 +125,51 @@ public static Cluster Create(Credential credential, ClusterOptions clusterOption return new Cluster(credential, clusterOptions); } + // ── Binary-compatible forwarding overloads ────────────────────────── + // These preserve the exact method signatures from before the ICredential + // widening, so that existing compiled code continues to resolve correctly + // at runtime without recompilation. + + /// + /// Creates a cluster with a connection string, username/password credential, and an options builder. + /// + public static Cluster Create(string connectionString, Credential credential, Func configureOptions) + => Create(connectionString, (ICredential)credential, configureOptions); + + /// + /// Creates a cluster with a connection string, username/password credential, and cluster options. + /// + public static Cluster Create(string connectionString, Credential credential, ClusterOptions clusterOptions) + => Create(connectionString, (ICredential)credential, clusterOptions); + + /// + /// Creates a cluster with a connection string and username/password credential. + /// + public static Cluster Create(string connectionString, Credential credential) + => Create(connectionString, (ICredential)credential); + + /// + /// Creates a cluster with a username/password credential and cluster options. + /// + public static Cluster Create(Credential credential, ClusterOptions clusterOptions) + => Create((ICredential)credential, clusterOptions); + + /// + /// Replaces the credential used for all subsequent HTTP requests. + /// Thread-safe. The new credential must be the same type as the current credential. + /// + /// The new credential to use. + /// Thrown if the new credential is a different type than the current one. + public void UpdateCredential(ICredential newCredential) + { + ArgumentNullException.ThrowIfNull(newCredential); + var current = _credential; + if (current.GetType() != newCredential.GetType()) + throw new InvalidOperationException( + $"Cannot change credential type from {current.GetType().Name} to {newCredential.GetType().Name}."); + _credential = newCredential; + } + public Task ExecuteQueryAsync(string statement, Func options, CancellationToken cancellationToken = default) { var queryOptions = new QueryOptions(); diff --git a/src/Couchbase.Analytics/HTTP/Credential.cs b/src/Couchbase.Analytics/HTTP/Credential.cs index ef4fe20..c1390aa 100644 --- a/src/Couchbase.Analytics/HTTP/Credential.cs +++ b/src/Couchbase.Analytics/HTTP/Credential.cs @@ -19,12 +19,40 @@ * ************************************************************/ #endregion +using System.Net.Http.Headers; +using System.Text; + namespace Couchbase.AnalyticsClient.HTTP; +/// +/// A username and password credential that authenticates using the HTTP Basic scheme. +/// +/// The username to authenticate with. +/// The password to authenticate with. public record Credential(string Username, string Password) : ICredential { + /// + public AuthenticationHeaderValue AuthorizationHeader { get; } = + new("Basic", Convert.ToBase64String(Encoding.UTF8.GetBytes($"{Username}:{Password}"))); + + /// + /// Creates a username and password credential. + /// + /// The username to authenticate with. + /// The password to authenticate with. + /// A instance. public static Credential Create(string username, string password) { return new(username, password); } + + /// + /// Excludes from the record's ToString output + /// to prevent leaking encoded credentials into logs. + /// + protected virtual bool PrintMembers(System.Text.StringBuilder builder) + { + builder.Append($"Username = {Username}"); + return true; + } } diff --git a/src/Couchbase.Analytics/HTTP/ICredential.cs b/src/Couchbase.Analytics/HTTP/ICredential.cs index 140d60b..5ad8284 100644 --- a/src/Couchbase.Analytics/HTTP/ICredential.cs +++ b/src/Couchbase.Analytics/HTTP/ICredential.cs @@ -19,27 +19,22 @@ * ************************************************************/ #endregion +using System.Net.Http.Headers; + namespace Couchbase.AnalyticsClient.HTTP; +/// +/// Represents a credential used to authenticate HTTP requests to the analytics service. +/// +/// +/// Implementations should pre-compute the value at +/// construction time for efficiency, since credential instances are immutable. +/// public interface ICredential { /// - /// The username to authenticate against. + /// The Authorization header value for this credential, or null if + /// authentication is handled outside HTTP headers (e.g. mTLS). /// - string Username { get; init; } - - /// - /// The password of the principle - /// - string Password { get; init; } - - bool Equals(Credential? other); - - bool Equals(object? other); - - int GetHashCode(); - - void Deconstruct(out string Username, out string Password); - - string ToString(); + AuthenticationHeaderValue? AuthorizationHeader { get; } } diff --git a/src/Couchbase.Analytics/HTTP/JwtCredential.cs b/src/Couchbase.Analytics/HTTP/JwtCredential.cs new file mode 100644 index 0000000..613a796 --- /dev/null +++ b/src/Couchbase.Analytics/HTTP/JwtCredential.cs @@ -0,0 +1,55 @@ +#region License +/* ************************************************************ + * + * @author Couchbase + * @copyright 2025 Couchbase, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * ************************************************************/ +#endregion + +using System.Net.Http.Headers; + +namespace Couchbase.AnalyticsClient.HTTP; + +/// +/// A JSON Web Token (JWT) credential that authenticates using the HTTP Bearer scheme. +/// +/// The JWT token string. +public sealed record JwtCredential(string Token) : ICredential +{ + /// + public AuthenticationHeaderValue AuthorizationHeader { get; } = + new("Bearer", Token); + + /// + /// Creates a JWT credential. + /// + /// The JWT token string. + /// A instance. + public static JwtCredential Create(string token) + { + return new(token); + } + + /// + /// Excludes the full token and from the record's + /// ToString output to prevent leaking credentials into logs. + /// + private bool PrintMembers(System.Text.StringBuilder builder) + { + builder.Append($"Token = <{Token.Length} chars>"); + return true; + } +} diff --git a/src/Couchbase.Analytics/Internal/HTTP/AuthenticationHandler.cs b/src/Couchbase.Analytics/Internal/HTTP/AuthenticationHandler.cs index 9f86238..57bc819 100644 --- a/src/Couchbase.Analytics/Internal/HTTP/AuthenticationHandler.cs +++ b/src/Couchbase.Analytics/Internal/HTTP/AuthenticationHandler.cs @@ -19,44 +19,38 @@ * ************************************************************/ #endregion -using System.Net.Http.Headers; -using System.Text; using Couchbase.AnalyticsClient.HTTP; namespace Couchbase.AnalyticsClient.Internal.HTTP; +/// +/// A delegating handler that sets the Authorization header on outgoing HTTP requests +/// based on the current . +/// +/// +/// The credential is resolved via a on every request, +/// enabling credential hot-swap at runtime via . +/// internal class AuthenticationHandler : DelegatingHandler { - private const string BasicScheme = "Basic"; - private readonly string? _headerValue; - - public AuthenticationHandler(HttpMessageHandler innerHandler) - : this(innerHandler, "default", string.Empty) - { - } - - public AuthenticationHandler(HttpMessageHandler innerHandler, ICredential credential) - : this(innerHandler, credential.Username ?? "default", credential.Password ?? string.Empty) - { - } - - public AuthenticationHandler(HttpMessageHandler innerHandler, string username, string password) + private readonly Func _credentialProvider; + + /// + /// Creates an that resolves credentials from the given provider. + /// + /// The inner HTTP handler. + /// A function that returns the current credential. + public AuthenticationHandler(HttpMessageHandler innerHandler, Func credentialProvider) : base(innerHandler) { - if (!string.IsNullOrEmpty(username)) - { - // Just build once for speed - _headerValue = Convert.ToBase64String(Encoding.UTF8.GetBytes(string.Concat(username, ":", password))); - } + _credentialProvider = credentialProvider ?? throw new ArgumentNullException(nameof(credentialProvider)); } + /// protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { - if (_headerValue != null) - { - request.Headers.Authorization = new AuthenticationHeaderValue(BasicScheme, _headerValue); - } - + var credential = _credentialProvider(); + request.Headers.Authorization = credential.AuthorizationHeader; return base.SendAsync(request, cancellationToken); } } diff --git a/src/Couchbase.Analytics/Internal/HTTP/CouchbaseHttpClientFactory.cs b/src/Couchbase.Analytics/Internal/HTTP/CouchbaseHttpClientFactory.cs index 8f5db5c..5270a7c 100644 --- a/src/Couchbase.Analytics/Internal/HTTP/CouchbaseHttpClientFactory.cs +++ b/src/Couchbase.Analytics/Internal/HTTP/CouchbaseHttpClientFactory.cs @@ -33,17 +33,17 @@ namespace Couchbase.AnalyticsClient.Internal.HTTP; internal class CouchbaseHttpClientFactory : ICouchbaseHttpClientFactory { - private readonly ICredential _credential; + private readonly Func _credentialProvider; private readonly SecurityOptions _securityOptions; private readonly TimeoutOptions _timeoutOptions; private readonly ILogger _logger; private readonly AuthenticationHandler _sharedHandler; - public CouchbaseHttpClientFactory(ICredential credential, ClusterOptions options, + public CouchbaseHttpClientFactory(Func credentialProvider, ClusterOptions options, ILogger logger) { ArgumentNullException.ThrowIfNull(options); - _credential = credential; + _credentialProvider = credentialProvider ?? throw new ArgumentNullException(nameof(credentialProvider)); _securityOptions = options.SecurityOptions ?? throw new ArgumentNullException(nameof(options)); _timeoutOptions = options.TimeoutOptions ?? throw new ArgumentNullException(nameof(options)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); @@ -61,7 +61,7 @@ public CouchbaseHttpClientFactory(ICredential credential, ClusterOptions options /// - Server certificate validation callback based on the configured trust settings /// /// - /// The handler is not configured for Certificate Based Authentication. A username/password credential is still required. + /// The handler is not configured for Certificate Based Authentication. /// private AuthenticationHandler CreateClientHandler() { @@ -72,7 +72,7 @@ private AuthenticationHandler CreateClientHandler() handler.SslOptions.RemoteCertificateValidationCallback = CertificateValidation.CreateRemoteCertificateValidationCallback(_securityOptions, _logger); - return new AuthenticationHandler(handler, _credential); + return new AuthenticationHandler(handler, _credentialProvider); } private void ConfigureClientCertificates(SocketsHttpHandler handler) diff --git a/src/Couchbase.Analytics/Options/ClusterOptions.cs b/src/Couchbase.Analytics/Options/ClusterOptions.cs index 229c8c1..02ca98b 100644 --- a/src/Couchbase.Analytics/Options/ClusterOptions.cs +++ b/src/Couchbase.Analytics/Options/ClusterOptions.cs @@ -117,12 +117,12 @@ public ClusterOptions WithRedactionLevel(RedactionLevel redactionLevel) private readonly IDictionary _services = DefaultServices.GetDefaultServices(); - internal ICouchbaseServiceProvider BuildServiceProvider(ICredential? credential = null) + internal ICouchbaseServiceProvider BuildServiceProvider(Func? credentialProvider = null) { this.AddClusterService(this); this.AddClusterService(Logging ??= new NullLoggerFactory()); this.AddClusterService(new TypedRedactor(RedactionLevel)); - if (credential is not null) this.AddClusterService(credential); + if (credentialProvider is not null) this.AddClusterService(credentialProvider); return new CouchbaseServiceProvider(_services); } diff --git a/tests/Couchbase.Analytics.FunctionalTests/Fixtures/FixtureSettings.cs b/tests/Couchbase.Analytics.FunctionalTests/Fixtures/FixtureSettings.cs index dd70714..e1a9008 100644 --- a/tests/Couchbase.Analytics.FunctionalTests/Fixtures/FixtureSettings.cs +++ b/tests/Couchbase.Analytics.FunctionalTests/Fixtures/FixtureSettings.cs @@ -12,4 +12,7 @@ public class FixtureSettings [JsonPropertyName("Password")] public string? Password { get; set; } = "password"; + + [JsonPropertyName("JwtToken")] + public string? JwtToken { get; set; } } diff --git a/tests/Couchbase.Analytics.FunctionalTests/Fixtures/JwtCollection.cs b/tests/Couchbase.Analytics.FunctionalTests/Fixtures/JwtCollection.cs new file mode 100644 index 0000000..f7f64bd --- /dev/null +++ b/tests/Couchbase.Analytics.FunctionalTests/Fixtures/JwtCollection.cs @@ -0,0 +1,9 @@ +using Xunit; + +namespace Couchbase.AnalyticsClient.FunctionalTests.Fixtures; + +[CollectionDefinition(Name)] +public class JwtCollection : ICollectionFixture +{ + public const string Name = "JwtCollection"; +} diff --git a/tests/Couchbase.Analytics.FunctionalTests/Fixtures/JwtFixture.cs b/tests/Couchbase.Analytics.FunctionalTests/Fixtures/JwtFixture.cs new file mode 100644 index 0000000..59fd37d --- /dev/null +++ b/tests/Couchbase.Analytics.FunctionalTests/Fixtures/JwtFixture.cs @@ -0,0 +1,50 @@ +using Couchbase.AnalyticsClient.HTTP; +using Couchbase.AnalyticsClient.Options; +using Microsoft.Extensions.Configuration; + +namespace Couchbase.AnalyticsClient.FunctionalTests.Fixtures; + +/// +/// Fixture that connects to the analytics cluster using JWT (Bearer) authentication. +/// Requires JwtToken to be set in settings.json. +/// +public class JwtFixture : IDisposable +{ + public FixtureSettings FixtureSettings { get; } + public ClusterOptions ClusterOptions { get; } + public Cluster Cluster { get; } + public JwtCredential JwtCredential { get; } + + public JwtFixture() + { + FixtureSettings = GetFixtureSettings(); + + if (string.IsNullOrWhiteSpace(FixtureSettings.JwtToken)) + { + throw new InvalidOperationException( + "JwtToken is not set in settings.json. " + + "Generate a token with: cbdinocluster jwt generate --can-read --can-write"); + } + + JwtCredential = JwtCredential.Create(FixtureSettings.JwtToken); + ClusterOptions = new ClusterOptions(); + Cluster = Cluster.Create( + FixtureSettings.ConnectionString!, + JwtCredential, + ClusterOptions); + } + + private static FixtureSettings GetFixtureSettings() + { + return new ConfigurationBuilder() + .AddJsonFile("settings.json") + .Build() + .GetSection("TestSettings") + .Get()!; + } + + public void Dispose() + { + Cluster?.Dispose(); + } +} diff --git a/tests/Couchbase.Analytics.FunctionalTests/JwtAuthenticationTests.cs b/tests/Couchbase.Analytics.FunctionalTests/JwtAuthenticationTests.cs new file mode 100644 index 0000000..c51087c --- /dev/null +++ b/tests/Couchbase.Analytics.FunctionalTests/JwtAuthenticationTests.cs @@ -0,0 +1,77 @@ +using Couchbase.AnalyticsClient.Exceptions; +using Couchbase.AnalyticsClient.FunctionalTests.Fixtures; +using Couchbase.AnalyticsClient.HTTP; +using Couchbase.AnalyticsClient.Options; +using Xunit; +using Xunit.Abstractions; + +namespace Couchbase.AnalyticsClient.FunctionalTests; + +[Collection(JwtCollection.Name)] +public class JwtAuthenticationTests +{ + private readonly JwtFixture _fixture; + private readonly ITestOutputHelper _output; + + public JwtAuthenticationTests(JwtFixture fixture, ITestOutputHelper output) + { + _fixture = fixture; + _output = output; + } + + [Fact] + public async Task Test_Query_With_Jwt_Succeeds() + { + var result = await _fixture.Cluster.ExecuteQueryAsync( + "SELECT 1;", + new QueryOptions { Timeout = TimeSpan.FromSeconds(30) }); + + Assert.NotNull(result); + _output.WriteLine("JWT-authenticated query succeeded."); + } + + [Fact] + public async Task Test_UpdateCredential_With_Fresh_Jwt() + { + // The fixture's JWT is valid — use it to run a query first + var result1 = await _fixture.Cluster.ExecuteQueryAsync( + "SELECT 1;", + new QueryOptions { Timeout = TimeSpan.FromSeconds(30) }); + Assert.NotNull(result1); + + // "Refresh" the credential with the same token (simulates a token rotation) + var freshCredential = JwtCredential.Create(_fixture.FixtureSettings.JwtToken!); + _fixture.Cluster.UpdateCredential(freshCredential); + + // Verify the cluster still works after the credential swap + var result2 = await _fixture.Cluster.ExecuteQueryAsync( + "SELECT 2;", + new QueryOptions { Timeout = TimeSpan.FromSeconds(30) }); + Assert.NotNull(result2); + + // Restore the original credential so other tests aren't affected + _fixture.Cluster.UpdateCredential(_fixture.JwtCredential); + _output.WriteLine("UpdateCredential with fresh JWT succeeded."); + } + + [Fact] + public async Task Test_Query_With_Invalid_Jwt_Fails() + { + // Create a separate cluster with an obviously invalid JWT + var invalidToken = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJleHBpcmVkIiwiZXhwIjoxfQ.invalid"; + var invalidCredential = JwtCredential.Create(invalidToken); + using var invalidCluster = Cluster.Create( + _fixture.FixtureSettings.ConnectionString!, + invalidCredential, + new ClusterOptions()); + + await Assert.ThrowsAnyAsync(async () => + { + await invalidCluster.ExecuteQueryAsync( + "SELECT 1 AS value;", + new QueryOptions { Timeout = TimeSpan.FromSeconds(10) }); + }); + + _output.WriteLine("Invalid JWT was correctly rejected by the server."); + } +} diff --git a/tests/Couchbase.Analytics.FunctionalTests/settings.json b/tests/Couchbase.Analytics.FunctionalTests/settings.json index 3b7cda0..3f75f22 100644 --- a/tests/Couchbase.Analytics.FunctionalTests/settings.json +++ b/tests/Couchbase.Analytics.FunctionalTests/settings.json @@ -1,7 +1,8 @@ { "TestSettings": { - "ConnectionString": "http://192.168.106.129:8095", + "ConnectionString": "http://192.168.64.129:8095", "Username": "Administrator", - "Password": "password" + "Password": "password", + "JwtToken": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlcyI6WyJhZG1pbiJdLCJpc3MiOiJkaW5vIiwic3ViIjoiand0X3VzZXIiLCJhdWQiOlsiY2xpZW50Il0sImV4cCI6MTgwNjcxNzk5NX0.Ax4mVQpPIJ9vTh9JGQha-2ebcLd2psZoAGaGJG1d7HiRfprFY2sDskrIIw2doP3z6X_7-4q3TNoWXNtCLXvsEu070U-nnzO9lJPFn-QHRtB5DTt_H5lIj8DqWMjMNHDcUETyYU-kcWqk3uCmql5ST6mmnjLtir8wJR3nVyHyxVku77xuzkluxLIBLXgiJ6-iyfu10LthP69ZbRpJbDhw3MLXq8KI-POlEZMNlRylIR-gzifkwQSuIZhxANpsZb802YtcoWM_zqkj-H7u4rzVLpRZejPgrwqTH3hujSQ72XioJiBeU-iEVy9HU1Vky8727eKZT3RN2JC7cTO5XNS74rHm6zWRnzB1NcPy5iGs67J1LDSbTdLqDGZwIOUofEI7tlk0IHTooZYaAT4Hn8y5lt2Lz2rffzw8MTntPdF6Bp5DWW1VBDFeKh_f8kqXlqYdJwGW5nH3eOlL8MrC8yUI2IS6iFZE3SJLsN--ScwB5C7cEATglZkG9cps_AsWZ74PbI54QQoMF0YgoJjqFBRXqA7sKEqYwZrZJFE5Y9tW_MaL7nhEXlNudTtVed4o6TlRHgNoBGgeYUxIbEbca8MWXFZxoh4c_hwVPFMsoVVpL3KQNKHIraO2LNZCv3bvh_h4M4FzvOg8-KH9CelkJ2QNS9RdIp4SV2d8BXAoPRCo9d0" } -} \ No newline at end of file +} diff --git a/tests/Couchbase.Analytics.UnitTests/ClusterTests.cs b/tests/Couchbase.Analytics.UnitTests/ClusterTests.cs index 3055e6a..b34f974 100644 --- a/tests/Couchbase.Analytics.UnitTests/ClusterTests.cs +++ b/tests/Couchbase.Analytics.UnitTests/ClusterTests.cs @@ -115,5 +115,59 @@ public void Create_ClusterOptions_With_All_Parameters() Assert.NotNull(clusterOptions.TimeoutOptions); Assert.NotNull(clusterOptions.ConnectionStringValue); } + [Fact] + public void Create_WithJwtCredential_ReturnsClusterInstance() + { + // Arrange + var httpEndpoint = "http://localhost:8091"; + var credential = JwtCredential.Create("xxxxx.yyyyy.zzzzz"); + + // Act + var cluster = Cluster.Create(httpEndpoint, credential); + + // Assert + Assert.NotNull(cluster); + } + + [Fact] + public void UpdateCredential_SameType_Succeeds() + { + // Arrange + var cluster = Cluster.Create("http://localhost:8091", Credential.Create("admin", "pass1")); + + // Act — no exception + cluster.UpdateCredential(Credential.Create("admin", "pass2")); + } + + [Fact] + public void UpdateCredential_SameTypeJwt_Succeeds() + { + // Arrange + var cluster = Cluster.Create("http://localhost:8091", JwtCredential.Create("token1")); + + // Act — no exception + cluster.UpdateCredential(JwtCredential.Create("token2")); + } + + [Fact] + public void UpdateCredential_DifferentType_ThrowsInvalidOperationException() + { + // Arrange + var cluster = Cluster.Create("http://localhost:8091", Credential.Create("admin", "pass")); + + // Act & Assert + Assert.Throws(() => + cluster.UpdateCredential(JwtCredential.Create("token"))); + } + + [Fact] + public void UpdateCredential_Null_ThrowsArgumentNullException() + { + // Arrange + var cluster = Cluster.Create("http://localhost:8091", Credential.Create("admin", "pass")); + + // Act & Assert + Assert.Throws(() => cluster.UpdateCredential(null!)); + } } } diff --git a/tests/Couchbase.Analytics.UnitTests/HTTP/CredentialTests.cs b/tests/Couchbase.Analytics.UnitTests/HTTP/CredentialTests.cs new file mode 100644 index 0000000..d527f1a --- /dev/null +++ b/tests/Couchbase.Analytics.UnitTests/HTTP/CredentialTests.cs @@ -0,0 +1,162 @@ +using Couchbase.AnalyticsClient.HTTP; +using Xunit; + +namespace Couchbase.AnalyticsClient.UnitTests.HTTP; + +public class CredentialTests +{ + [Fact] + public void Create_ReturnsCredentialWithBasicHeader() + { + // Arrange & Act + var credential = Credential.Create("admin", "password"); + + // Assert + Assert.NotNull(credential.AuthorizationHeader); + Assert.Equal("Basic", credential.AuthorizationHeader.Scheme); + } + + [Fact] + public void Constructor_ReturnsCredentialWithBasicHeader() + { + // Arrange & Act + var credential = new Credential("admin", "password"); + + // Assert + Assert.NotNull(credential.AuthorizationHeader); + Assert.Equal("Basic", credential.AuthorizationHeader.Scheme); + } + + [Fact] + public void AuthorizationHeader_EncodesUsernameAndPassword() + { + // Arrange + var credential = Credential.Create("admin", "password"); + var expected = Convert.ToBase64String("admin:password"u8.ToArray()); + + // Act & Assert + Assert.Equal(expected, credential.AuthorizationHeader.Parameter); + } + + [Fact] + public void AuthorizationHeader_EncodesUtf8() + { + // Arrange — spec requires UTF-8 encoding + var credential = Credential.Create("user", "pässwörd"); + var expected = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes("user:pässwörd")); + + // Act & Assert + Assert.Equal(expected, credential.AuthorizationHeader.Parameter); + } + + [Fact] + public void Equals_SameValues_ReturnsTrue() + { + var a = Credential.Create("admin", "password"); + var b = Credential.Create("admin", "password"); + + Assert.Equal(a, b); + } + + [Fact] + public void Equals_DifferentValues_ReturnsFalse() + { + var a = Credential.Create("admin", "password1"); + var b = Credential.Create("admin", "password2"); + + Assert.NotEqual(a, b); + } + + [Fact] + public void Credential_ImplementsICredential() + { + ICredential credential = Credential.Create("admin", "password"); + + Assert.NotNull(credential.AuthorizationHeader); + Assert.Equal("Basic", credential.AuthorizationHeader!.Scheme); + } + [Fact] + public void ToString_DoesNotLeakAuthorizationHeader() + { + var credential = Credential.Create("admin", "password"); + var str = credential.ToString(); + + Assert.Contains("Username = admin", str); + Assert.DoesNotContain("Basic", str); + Assert.DoesNotContain("AuthorizationHeader", str); + Assert.DoesNotContain("password", str); + } +} + +public class JwtCredentialTests +{ + [Fact] + public void Create_ReturnsBearerHeader() + { + // Arrange & Act + var credential = JwtCredential.Create("xxxxx.yyyyy.zzzzz"); + + // Assert + Assert.NotNull(credential.AuthorizationHeader); + Assert.Equal("Bearer", credential.AuthorizationHeader.Scheme); + Assert.Equal("xxxxx.yyyyy.zzzzz", credential.AuthorizationHeader.Parameter); + } + + [Fact] + public void Constructor_ReturnsBearerHeader() + { + // Arrange & Act + var credential = new JwtCredential("my-jwt-token"); + + // Assert + Assert.Equal("Bearer", credential.AuthorizationHeader.Scheme); + Assert.Equal("my-jwt-token", credential.AuthorizationHeader.Parameter); + } + + [Fact] + public void Equals_SameToken_ReturnsTrue() + { + var a = JwtCredential.Create("token123"); + var b = JwtCredential.Create("token123"); + + Assert.Equal(a, b); + } + + [Fact] + public void Equals_DifferentTokens_ReturnsFalse() + { + var a = JwtCredential.Create("token1"); + var b = JwtCredential.Create("token2"); + + Assert.NotEqual(a, b); + } + + [Fact] + public void JwtCredential_ImplementsICredential() + { + ICredential credential = JwtCredential.Create("xxxxx.yyyyy.zzzzz"); + + Assert.NotNull(credential.AuthorizationHeader); + Assert.Equal("Bearer", credential.AuthorizationHeader!.Scheme); + } + + [Fact] + public void JwtCredential_NotEqualToBasicCredential() + { + ICredential basic = Credential.Create("user", "pass"); + ICredential jwt = JwtCredential.Create("token"); + + Assert.NotEqual(basic, jwt); + } + [Fact] + public void ToString_DoesNotLeakToken() + { + var credential = JwtCredential.Create("xxxxx.yyyyy.zzzzz"); + var str = credential.ToString(); + + Assert.DoesNotContain("xxxxx.yyyyy.zzzzz", str); + Assert.DoesNotContain("Bearer", str); + Assert.DoesNotContain("AuthorizationHeader", str); + Assert.Contains($"<{"xxxxx.yyyyy.zzzzz".Length} chars>", str); + } +} diff --git a/tests/Couchbase.Analytics.UnitTests/Internal/DnsUtil/ConnectCallbackRoundRobinTests.cs b/tests/Couchbase.Analytics.UnitTests/Internal/DnsUtil/ConnectCallbackRoundRobinTests.cs index 7ce0e24..a4ed555 100644 --- a/tests/Couchbase.Analytics.UnitTests/Internal/DnsUtil/ConnectCallbackRoundRobinTests.cs +++ b/tests/Couchbase.Analytics.UnitTests/Internal/DnsUtil/ConnectCallbackRoundRobinTests.cs @@ -30,7 +30,7 @@ public ConnectCallbackRoundRobinTests(ITestOutputHelper outputHelper) [Fact] public void ConnectCallback_Should_Use_RandomEndpointSelector() { - var credential = new Mock().Object; + ICredential credential = Credential.Create("test", "test"); var options = new ClusterOptions() .WithSecurityOptions(new SecurityOptions() .WithDisableCertificateVerification(true)) @@ -38,7 +38,7 @@ public void ConnectCallback_Should_Use_RandomEndpointSelector() .WithConnectTimeout(TimeSpan.FromMilliseconds(50))); var logger = new Mock>().Object; - var factory = new CouchbaseHttpClientFactory(credential, options, logger); + var factory = new CouchbaseHttpClientFactory(() => credential, options, logger); // Extract the underlying SocketsHttpHandler and its ConnectCallback via reflection var sharedHandlerField = typeof(CouchbaseHttpClientFactory).GetField("_sharedHandler", BindingFlags.NonPublic | BindingFlags.Instance)!; @@ -90,3 +90,4 @@ public void ConnectCallback_Should_Use_RandomEndpointSelector() Assert.Equal(1, selections.Count(x => x == 2)); } } + diff --git a/tests/Couchbase.Analytics.UnitTests/Internal/HTTP/AnalyticsHttpClientFactoryTests.cs b/tests/Couchbase.Analytics.UnitTests/Internal/HTTP/AnalyticsHttpClientFactoryTests.cs index 306a1c9..4624bbd 100644 --- a/tests/Couchbase.Analytics.UnitTests/Internal/HTTP/AnalyticsHttpClientFactoryTests.cs +++ b/tests/Couchbase.Analytics.UnitTests/Internal/HTTP/AnalyticsHttpClientFactoryTests.cs @@ -10,16 +10,17 @@ namespace Couchbase.AnalyticsClient.UnitTests.Internal.HTTP; public class CouchbaseHttpClientFactoryTest { + private static Func TestCredentialProvider() => () => Credential.Create("test", "test"); + [Fact] public void Constructor_ValidParameters_CreatesInstance() { // Arrange - var credential = new Mock().Object; var options = new ClusterOptions().WithSecurityOptions(new SecurityOptions().WithDisableCertificateVerification(true)); var logger = new Mock>().Object; // Act - var factory = new CouchbaseHttpClientFactory(credential, options, logger); + var factory = new CouchbaseHttpClientFactory(TestCredentialProvider(), options, logger); // Assert Assert.NotNull(factory); @@ -29,32 +30,29 @@ public void Constructor_ValidParameters_CreatesInstance() public void Constructor_NullSecurityOptions_ThrowsArgumentNullException() { // Arrange - var credential = new Mock().Object; var logger = new Mock>().Object; // Act & Assert - Assert.Throws(() => new CouchbaseHttpClientFactory(credential, null!, logger)); + Assert.Throws(() => new CouchbaseHttpClientFactory(TestCredentialProvider(), null!, logger)); } [Fact] public void Constructor_NullLogger_ThrowsArgumentNullException() { // Arrange - var credential = new Mock().Object; var options = new ClusterOptions(); // Act & Assert - Assert.Throws(() => new CouchbaseHttpClientFactory(credential, options, null!)); + Assert.Throws(() => new CouchbaseHttpClientFactory(TestCredentialProvider(), options, null!)); } [Fact] public void Create_ReturnsHttpClientInstance() { // Arrange - var credential = new Credential("Administrator", "password"); var options = new ClusterOptions().WithSecurityOptions(new SecurityOptions().WithSslProtocols(SslProtocols.Tls12)); var logger = new Mock>().Object; - var factory = new CouchbaseHttpClientFactory(credential, options, logger); + var factory = new CouchbaseHttpClientFactory(TestCredentialProvider(), options, logger); // Act var httpClient = factory.Create(); @@ -68,13 +66,12 @@ public void Create_ReturnsHttpClientInstance() public void CreateClientHandler_DisableServerCertificateValidation_SetsValidationCallback() { // Arrange - var credential = new Mock().Object; var options = new ClusterOptions().WithSecurityOptions(new SecurityOptions() .WithDisableCertificateVerification(true) .WithTrustOnlyCapella()); var logger = new Mock>().Object; - var factory = new CouchbaseHttpClientFactory(credential, options, logger); + var factory = new CouchbaseHttpClientFactory(TestCredentialProvider(), options, logger); // Act var handler = factory.Create().DefaultRequestHeaders; @@ -87,10 +84,9 @@ public void CreateClientHandler_DisableServerCertificateValidation_SetsValidatio public void DefaultCompletionOption_HasExpectedValue() { // Arrange - var credential = new Mock().Object; var options = new ClusterOptions(); var logger = new Mock>().Object; - var factory = new CouchbaseHttpClientFactory(credential, options, logger); + var factory = new CouchbaseHttpClientFactory(TestCredentialProvider(), options, logger); // Act var completionOption = factory.DefaultCompletionOption; diff --git a/tests/Couchbase.Analytics.UnitTests/Internal/HTTP/AuthenticationHandlerTests.cs b/tests/Couchbase.Analytics.UnitTests/Internal/HTTP/AuthenticationHandlerTests.cs new file mode 100644 index 0000000..2b7ff3b --- /dev/null +++ b/tests/Couchbase.Analytics.UnitTests/Internal/HTTP/AuthenticationHandlerTests.cs @@ -0,0 +1,89 @@ +using System.Net; +using Couchbase.AnalyticsClient.HTTP; +using Couchbase.AnalyticsClient.Internal.HTTP; +using Xunit; + +namespace Couchbase.AnalyticsClient.UnitTests.Internal.HTTP; + +public class AuthenticationHandlerTests +{ + /// + /// A test handler that captures the request for inspection instead of sending it. + /// + private sealed class CapturingHandler : HttpMessageHandler + { + public HttpRequestMessage? CapturedRequest { get; private set; } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + CapturedRequest = request; + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)); + } + } + + [Fact] + public async Task SendAsync_BasicCredential_SetsBasicAuthHeader() + { + // Arrange + var credential = Credential.Create("admin", "password"); + var inner = new CapturingHandler(); + var handler = new AuthenticationHandler(inner, () => credential); + var client = new HttpClient(handler); + + // Act + await client.GetAsync("http://localhost/test"); + + // Assert + Assert.NotNull(inner.CapturedRequest); + Assert.NotNull(inner.CapturedRequest!.Headers.Authorization); + Assert.Equal("Basic", inner.CapturedRequest.Headers.Authorization!.Scheme); + } + + [Fact] + public async Task SendAsync_JwtCredential_SetsBearerAuthHeader() + { + // Arrange + var credential = JwtCredential.Create("my.jwt.token"); + var inner = new CapturingHandler(); + var handler = new AuthenticationHandler(inner, () => credential); + var client = new HttpClient(handler); + + // Act + await client.GetAsync("http://localhost/test"); + + // Assert + Assert.NotNull(inner.CapturedRequest); + Assert.NotNull(inner.CapturedRequest!.Headers.Authorization); + Assert.Equal("Bearer", inner.CapturedRequest.Headers.Authorization!.Scheme); + Assert.Equal("my.jwt.token", inner.CapturedRequest.Headers.Authorization.Parameter); + } + + [Fact] + public async Task SendAsync_CredentialHotSwap_UsesUpdatedCredential() + { + // Arrange — start with Basic, swap to JWT mid-test + ICredential current = Credential.Create("admin", "password"); + var inner = new CapturingHandler(); + var handler = new AuthenticationHandler(inner, () => current); + var client = new HttpClient(handler); + + // Act — first request uses Basic + await client.GetAsync("http://localhost/test"); + Assert.Equal("Basic", inner.CapturedRequest!.Headers.Authorization!.Scheme); + + // Swap credential + current = JwtCredential.Create("swapped.jwt.token"); + + // Act — second request uses Bearer + await client.GetAsync("http://localhost/test"); + Assert.Equal("Bearer", inner.CapturedRequest!.Headers.Authorization!.Scheme); + Assert.Equal("swapped.jwt.token", inner.CapturedRequest.Headers.Authorization.Parameter); + } + + [Fact] + public void Constructor_NullProvider_ThrowsArgumentNullException() + { + var inner = new CapturingHandler(); + Assert.Throws(() => new AuthenticationHandler(inner, null!)); + } +}