Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for auth in URL #670

Merged
merged 14 commits into from
Nov 5, 2024
18 changes: 17 additions & 1 deletion src/NATS.Client.Core/Internal/NatsUri.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ internal sealed class NatsUri : IEquatable<NatsUri>
{
public const string DefaultScheme = "nats";

private readonly Uri _redactedUri;

public NatsUri(string urlString, bool isSeed, string defaultScheme = DefaultScheme)
{
IsSeed = isSeed;
Expand Down Expand Up @@ -38,6 +40,20 @@ public NatsUri(string urlString, bool isSeed, string defaultScheme = DefaultSche
}

Uri = uriBuilder.Uri;

if (uriBuilder.UserName is { Length: > 0 })
{
if (uriBuilder.Password is { Length: > 0 })
{
uriBuilder.Password = "***";
}
else
{
uriBuilder.UserName = "***";
}
}

_redactedUri = uriBuilder.Uri;
}

public Uri Uri { get; }
Expand Down Expand Up @@ -65,7 +81,7 @@ public NatsUri CloneWith(string host, int? port = default)

public override string ToString()
{
return IsWebSocket && Uri.AbsolutePath != "/" ? Uri.ToString() : Uri.ToString().Trim('/');
return IsWebSocket && Uri.AbsolutePath != "/" ? _redactedUri.ToString() : _redactedUri.ToString().Trim('/');
mtmk marked this conversation as resolved.
Show resolved Hide resolved
}

public override int GetHashCode() => Uri.GetHashCode();
Expand Down
44 changes: 42 additions & 2 deletions src/NATS.Client.Core/NatsConnection.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
using System.Buffers;
using System.Diagnostics;
using System.Runtime.CompilerServices;
using System.Threading.Channels;
using Microsoft.Extensions.Logging;
using NATS.Client.Core.Commands;
Expand Down Expand Up @@ -76,7 +75,7 @@ public NatsConnection()
public NatsConnection(NatsOpts opts)
{
_logger = opts.LoggerFactory.CreateLogger<NatsConnection>();
Opts = opts;
Opts = ReadUserInfoFromConnectionString(opts);
mtmk marked this conversation as resolved.
Show resolved Hide resolved
ConnectionState = NatsConnectionState.Closed;
_waitForOpenConnection = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
_disposedCancellationTokenSource = new CancellationTokenSource();
Expand Down Expand Up @@ -289,6 +288,47 @@ internal ValueTask UnsubscribeAsync(int sid)
return default;
}

private static NatsOpts ReadUserInfoFromConnectionString(NatsOpts opts)
{
// Setting credentials in options takes precedence over URL credentials
if (opts.AuthOpts.Username is { Length: > 0 } || opts.AuthOpts.Password is { Length: > 0 } || opts.AuthOpts.Token is { Length: > 0 })
{
return opts;
}

var natsUri = opts.GetSeedUris(suppressRandomization: true).First();
var uriBuilder = new UriBuilder(natsUri.Uri);

if (uriBuilder.UserName is not { Length: > 0 })
{
return opts;
}

if (uriBuilder.Password is { Length: > 0 })
{
opts = opts with
{
AuthOpts = opts.AuthOpts with
{
Username = Uri.UnescapeDataString(uriBuilder.UserName),
Password = Uri.UnescapeDataString(uriBuilder.Password),
},
};
}
else
{
opts = opts with
{
AuthOpts = opts.AuthOpts with
{
Token = Uri.UnescapeDataString(uriBuilder.UserName),
},
};
}

return opts;
}

private async ValueTask InitialConnectAsync()
{
Debug.Assert(ConnectionState == NatsConnectionState.Connecting, "Connection state");
Expand Down
26 changes: 24 additions & 2 deletions src/NATS.Client.Core/NatsOpts.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,28 @@ public sealed record NatsOpts
{
public static readonly NatsOpts Default = new();

/// <summary>
/// NATS server URL to connect to. (default: nats://localhost:4222)
/// </summary>
/// <remarks>
/// <para>
/// You can set more than one server as seed servers in a comma-separated list.
/// The client will randomly select a server from the list to connect to unless
/// <see cref="NoRandomize"/> (which is <c>false</c> by default) is set to <c>true</c>.
/// </para>
/// <para>
/// User-password or token authentication can be set in the URL.
/// For example, <c>nats://derek:s3cr3t@localhost:4222</c> or <c>nats://token@localhost:4222</c>.
/// You can also set the username and password or token separately using <see cref="AuthOpts"/>;
/// however, if both are set, the <see cref="AuthOpts"/> will take precedence.
/// You should URL-encode the username and password or token if they contain special characters.
/// </para>
/// <para>
/// If multiple servers are specified and user-password or token authentication is used in the URL,
/// only the credentials in the first server URL will be used; credentials in the remaining server
/// URLs will be ignored.
/// </para>
/// </remarks>
public string Url { get; init; } = "nats://localhost:4222";

public string Name { get; init; } = "NATS .NET Client";
Expand Down Expand Up @@ -117,10 +139,10 @@ public sealed record NatsOpts
/// </remarks>
public BoundedChannelFullMode SubPendingChannelFullMode { get; init; } = BoundedChannelFullMode.DropNewest;

internal NatsUri[] GetSeedUris()
internal NatsUri[] GetSeedUris(bool suppressRandomization = false)
{
var urls = Url.Split(',');
return NoRandomize
return NoRandomize || suppressRandomization
? urls.Select(x => new NatsUri(x, true)).Distinct().ToArray()
: urls.Select(x => new NatsUri(x, true)).OrderBy(_ => Guid.NewGuid()).Distinct().ToArray();
}
Expand Down
22 changes: 19 additions & 3 deletions src/NATS.Client.Simplified/NatsClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,25 @@ public class NatsClient : INatsClient
/// <summary>
/// Initializes a new instance of the <see cref="NatsClient"/> class.
/// </summary>
/// <param name="url">NATS server URL</param>
/// <param name="name">Client name</param>
/// <param name="credsFile">Credentials filepath</param>
/// <param name="url">NATS server URL to connect to. (default: nats://localhost:4222)</param>
/// <param name="name">Client name. (default: NATS .NET Client)</param>
/// <param name="credsFile">Credentials filepath.</param>
/// <remarks>
/// <para>
/// You can set more than one server as seed servers in a comma-separated list in the <paramref name="url"/>.
/// The client will randomly select a server from the list to connect.
/// </para>
/// <para>
/// User-password or token authentication can be set in the <paramref name="url"/>.
/// For example, <c>nats://derek:s3cr3t@localhost:4222</c> or <c>nats://token@localhost:4222</c>.
/// You should URL-encode the username and password or token if they contain special characters.
/// </para>
/// <para>
/// If multiple servers are specified and user-password or token authentication is used in the <paramref name="url"/>,
/// only the credentials in the first server URL will be used; credentials in the remaining server
/// URLs will be ignored.
/// </para>
/// </remarks>
public NatsClient(
string url = "nats://localhost:4222",
string name = "NATS .NET Client",
Expand Down
32 changes: 26 additions & 6 deletions tests/NATS.Client.Core.Tests/ClusterTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,23 +2,43 @@ namespace NATS.Client.Core.Tests;

public class ClusterTests(ITestOutputHelper output)
{
[Fact]
public async Task Seed_urls_on_retry()
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task Seed_urls_on_retry(bool userAuthInUrl)
{
await using var cluster1 = new NatsCluster(
new NullOutputHelper(),
TransportType.Tcp,
(i, b) => b.WithServerName($"c1n{i}"));
(i, b) =>
{
b.WithServerName($"c1n{i}");
if (userAuthInUrl)
{
b.AddServerConfig("resources/configs/auth/password.conf");
b.WithClientUrlAuthentication("a", "b");
}
},
userAuthInUrl);

await using var cluster2 = new NatsCluster(
new NullOutputHelper(),
TransportType.Tcp,
(i, b) => b.WithServerName($"c2n{i}"));
(i, b) =>
{
b.WithServerName($"c2n{i}");
if (userAuthInUrl)
{
b.AddServerConfig("resources/configs/auth/password.conf");
b.WithClientUrlAuthentication("a", "b");
}
},
userAuthInUrl);

// Use the first node from each cluster as the seed
// so that we can confirm seeds are used on retry
var url1 = cluster1.Server1.ClientUrl;
var url2 = cluster2.Server1.ClientUrl;
var url1 = userAuthInUrl ? cluster1.Server1.ClientUrlWithAuth : cluster1.Server1.ClientUrl;
var url2 = userAuthInUrl ? cluster2.Server1.ClientUrlWithAuth : cluster2.Server1.ClientUrl;

await using var nats = new NatsConnection(new NatsOpts
{
Expand Down
44 changes: 36 additions & 8 deletions tests/NATS.Client.Core.Tests/NatsConnectionTest.Auth.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,15 @@ public static IEnumerable<object[]> GetAuthConfigs()
NatsOpts.Default with { AuthOpts = NatsAuthOpts.Default with { Token = "s3cr3t", }, }),
};

yield return new object[]
{
new Auth(
"TOKEN_IN_CONNECTIONSTRING",
"resources/configs/auth/token.conf",
NatsOpts.Default,
urlAuth: "s3cr3t"),
};

yield return new object[]
{
new Auth(
Expand All @@ -23,6 +32,15 @@ NatsOpts.Default with
}),
};

yield return new object[]
{
new Auth(
"USER-PASSWORD_IN_CONNECTIONSTRING",
"resources/configs/auth/password.conf",
NatsOpts.Default,
urlAuth: "a:b"),
};

yield return new object[]
{
new Auth(
Expand Down Expand Up @@ -84,15 +102,22 @@ public async Task UserCredentialAuthTest(Auth auth)
var name = auth.Name;
var serverConfig = auth.ServerConfig;
var clientOpts = auth.ClientOpts;
var useAuthInUrl = !string.IsNullOrEmpty(auth.UrlAuth);

_output.WriteLine($"AUTH TEST {name}");

var serverOpts = new NatsServerOptsBuilder()
var serverOptsBuilder = new NatsServerOptsBuilder()
.UseTransport(_transportType)
.AddServerConfig(serverConfig)
.Build();
.AddServerConfig(serverConfig);

if (useAuthInUrl)
{
serverOptsBuilder.WithClientUrlAuthentication(auth.UrlAuth!);
}

await using var server = NatsServer.Start(_output, serverOpts, clientOpts);
var serverOpts = serverOptsBuilder.Build();

await using var server = NatsServer.Start(_output, serverOpts, clientOpts, useAuthInUrl);

var subject = Guid.NewGuid().ToString("N");

Expand All @@ -104,8 +129,8 @@ public async Task UserCredentialAuthTest(Auth auth)
Assert.Contains("Authorization Violation", natsException.GetBaseException().Message);
}

await using var subConnection = server.CreateClientConnection(clientOpts);
await using var pubConnection = server.CreateClientConnection(clientOpts);
await using var subConnection = server.CreateClientConnection(clientOpts, useAuthInUrl: useAuthInUrl);
await using var pubConnection = server.CreateClientConnection(clientOpts, useAuthInUrl: useAuthInUrl);

var signalComplete1 = new WaitSignal();
var signalComplete2 = new WaitSignal();
Expand Down Expand Up @@ -141,7 +166,7 @@ await Retry.Until(
await disconnectSignal2;

_output.WriteLine("START NEW SERVER");
await using var newServer = NatsServer.Start(_output, serverOpts, clientOpts);
await using var newServer = NatsServer.Start(_output, serverOpts, clientOpts, useAuthInUrl);
await subConnection.ConnectAsync(); // wait open again
await pubConnection.ConnectAsync(); // wait open again

Expand All @@ -162,11 +187,12 @@ await Retry.Until(

public class Auth
{
public Auth(string name, string serverConfig, NatsOpts clientOpts)
public Auth(string name, string serverConfig, NatsOpts clientOpts, string? urlAuth = null)
{
Name = name;
ServerConfig = serverConfig;
ClientOpts = clientOpts;
UrlAuth = urlAuth;
}

public string Name { get; }
Expand All @@ -175,6 +201,8 @@ public Auth(string name, string serverConfig, NatsOpts clientOpts)

public NatsOpts ClientOpts { get; }

public string? UrlAuth { get; }

public override string ToString() => Name;
}
}
Loading