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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 14 additions & 2 deletions .github/workflows/functional-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -89,6 +89,7 @@ jobs:
docker:
load-balancer: true
use-dino-certs: true
jwt: true
YAML

# Substitute shell vars into YAML
Expand All @@ -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"
Expand All @@ -110,7 +121,8 @@ jobs:
"TestSettings": {
"ConnectionString": "${CBDINO_CONNSTR}",
"Username": "${CBDINO_USER}",
"Password": "${CBDINO_PASS}"
"Password": "${CBDINO_PASS}",
"JwtToken": "${CBDINO_JWT}"
}
}
EOF
Expand Down
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
>
Expand Down
23 changes: 23 additions & 0 deletions docs/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
>
Expand Down
60 changes: 52 additions & 8 deletions src/Couchbase.Analytics/Cluster.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Cluster> _logger;
private readonly ICouchbaseServiceProvider _serviceProvider;
private readonly LazyService<IAnalyticsService> _analyticsService;
private readonly ConcurrentDictionary<string, Database> _databases = new();

private Cluster(Credential credential, ClusterOptions clusterOptions)
private Cluster(ICredential credential, ClusterOptions clusterOptions)
{
if (string.IsNullOrWhiteSpace(clusterOptions.ConnectionString))
{
Expand All @@ -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<ILogger<Cluster>>();
_analyticsService = new LazyService<IAnalyticsService>(_serviceProvider);

}

/// <summary>
Expand All @@ -63,7 +62,7 @@ private Cluster(Credential credential, ClusterOptions clusterOptions)
/// <param name="configureOptions">Action to configure cluster options</param>
/// <returns>A Cluster instance</returns>
/// <exception cref="ArgumentException">Thrown when the connection string is null or empty, or the credential is null</exception>
public static Cluster Create(string connectionString, Credential credential, Func<ClusterOptions, ClusterOptions> configureOptions)
public static Cluster Create(string connectionString, ICredential credential, Func<ClusterOptions, ClusterOptions> configureOptions)
{
if (string.IsNullOrWhiteSpace(connectionString))
throw new ArgumentException("Connection string cannot be null or empty.", nameof(connectionString));
Expand All @@ -87,7 +86,7 @@ public static Cluster Create(string connectionString, Credential credential, Fun
/// <param name="clusterOptions">The cluster options to use for the cluster</param>
/// <returns>A Cluster instance</returns>
/// <exception cref="ArgumentException">Thrown when the connection string is null or empty, or the credential is null</exception>
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));
Expand All @@ -106,7 +105,7 @@ public static Cluster Create(string connectionString, Credential credential, Clu
/// <param name="credential">The credentials to use for authentication</param>
/// <returns>A Cluster instance</returns>
/// <exception cref="ArgumentException">Thrown when the connection string is null or empty, or the credential is null</exception>
public static Cluster Create(string connectionString, Credential credential)
public static Cluster Create(string connectionString, ICredential credential)
{
return Create(connectionString, credential, new ClusterOptions());
}
Expand All @@ -118,14 +117,59 @@ public static Cluster Create(string connectionString, Credential credential)
/// <param name="clusterOptions">Pre-configured cluster options with connection string</param>
/// <returns>A Cluster instance</returns>
/// <exception cref="ArgumentNullException">Thrown when the credential or cluster options are null</exception>
public static Cluster Create(Credential credential, ClusterOptions clusterOptions)
public static Cluster Create(ICredential credential, ClusterOptions clusterOptions)
{
ArgumentNullException.ThrowIfNull(credential);
ArgumentNullException.ThrowIfNull(clusterOptions);

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.

/// <summary>
/// Creates a cluster with a connection string, username/password credential, and an options builder.
/// </summary>
public static Cluster Create(string connectionString, Credential credential, Func<ClusterOptions, ClusterOptions> configureOptions)
=> Create(connectionString, (ICredential)credential, configureOptions);

/// <summary>
/// Creates a cluster with a connection string, username/password credential, and cluster options.
/// </summary>
public static Cluster Create(string connectionString, Credential credential, ClusterOptions clusterOptions)
=> Create(connectionString, (ICredential)credential, clusterOptions);

/// <summary>
/// Creates a cluster with a connection string and username/password credential.
/// </summary>
public static Cluster Create(string connectionString, Credential credential)
=> Create(connectionString, (ICredential)credential);

/// <summary>
/// Creates a cluster with a username/password credential and cluster options.
/// </summary>
public static Cluster Create(Credential credential, ClusterOptions clusterOptions)
=> Create((ICredential)credential, clusterOptions);

/// <summary>
/// Replaces the credential used for all subsequent HTTP requests.
/// Thread-safe. The new credential must be the same type as the current credential.
/// </summary>
/// <param name="newCredential">The new credential to use.</param>
/// <exception cref="InvalidOperationException">Thrown if the new credential is a different type than the current one.</exception>
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<IQueryResult> ExecuteQueryAsync(string statement, Func<QueryOptions, QueryOptions> options, CancellationToken cancellationToken = default)
{
var queryOptions = new QueryOptions();
Expand Down
28 changes: 28 additions & 0 deletions src/Couchbase.Analytics/HTTP/Credential.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,40 @@
* ************************************************************/
#endregion

using System.Net.Http.Headers;
using System.Text;

namespace Couchbase.AnalyticsClient.HTTP;

/// <summary>
/// A username and password credential that authenticates using the HTTP Basic scheme.
/// </summary>
/// <param name="Username">The username to authenticate with.</param>
/// <param name="Password">The password to authenticate with.</param>
public record Credential(string Username, string Password) : ICredential
{
/// <inheritdoc />
public AuthenticationHeaderValue AuthorizationHeader { get; } =
new("Basic", Convert.ToBase64String(Encoding.UTF8.GetBytes($"{Username}:{Password}")));

/// <summary>
/// Creates a username and password credential.
/// </summary>
/// <param name="username">The username to authenticate with.</param>
/// <param name="password">The password to authenticate with.</param>
/// <returns>A <see cref="Credential"/> instance.</returns>
public static Credential Create(string username, string password)
{
return new(username, password);
}

/// <summary>
/// Excludes <see cref="AuthorizationHeader"/> from the record's ToString output
/// to prevent leaking encoded credentials into logs.
/// </summary>
protected virtual bool PrintMembers(System.Text.StringBuilder builder)
{
builder.Append($"Username = {Username}");
return true;
}
}
29 changes: 12 additions & 17 deletions src/Couchbase.Analytics/HTTP/ICredential.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,27 +19,22 @@
* ************************************************************/
#endregion

using System.Net.Http.Headers;

namespace Couchbase.AnalyticsClient.HTTP;

/// <summary>
/// Represents a credential used to authenticate HTTP requests to the analytics service.
/// </summary>
/// <remarks>
/// Implementations should pre-compute the <see cref="AuthorizationHeader"/> value at
/// construction time for efficiency, since credential instances are immutable.
/// </remarks>
public interface ICredential
{
/// <summary>
/// The username to authenticate against.
/// The Authorization header value for this credential, or <c>null</c> if
/// authentication is handled outside HTTP headers (e.g. mTLS).
/// </summary>
string Username { get; init; }

/// <summary>
/// The password of the principle
/// </summary>
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; }
}
55 changes: 55 additions & 0 deletions src/Couchbase.Analytics/HTTP/JwtCredential.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
#region License
/* ************************************************************
*
* @author Couchbase <info@couchbase.com>
* @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;

/// <summary>
/// A JSON Web Token (JWT) credential that authenticates using the HTTP Bearer scheme.
/// </summary>
/// <param name="Token">The JWT token string.</param>
public sealed record JwtCredential(string Token) : ICredential
{
/// <inheritdoc />
public AuthenticationHeaderValue AuthorizationHeader { get; } =
new("Bearer", Token);

/// <summary>
/// Creates a JWT credential.
/// </summary>
/// <param name="token">The JWT token string.</param>
/// <returns>A <see cref="JwtCredential"/> instance.</returns>
public static JwtCredential Create(string token)
{
return new(token);
}

/// <summary>
/// Excludes the full token and <see cref="AuthorizationHeader"/> from the record's
/// ToString output to prevent leaking credentials into logs.
/// </summary>
private bool PrintMembers(System.Text.StringBuilder builder)
{
builder.Append($"Token = <{Token.Length} chars>");
return true;
}
}
Loading
Loading