Skip to content

Commit

Permalink
Add support for auth in URL (#670)
Browse files Browse the repository at this point in the history
* Add support for auth in URL

* formatting

* undo not needed change

* address review

* use first uri to get auth information

* set username on multiple uris to the actual used one + better redaction

* Add tests and docs

* Add docs to the client

* Format

* Implement URL encoding for user credentials

* Refactor authentication precedence and URL redaction handling

Redact passwords and tokens at the `NatsUri` level to preserve the original
URIs, especially when used in WebSocket connections. This change aims to
maintain backward compatibility and prevent disruptions in WebSocket connections
that may rely on tokens or user-password pairs for proxy-level authentication.

* Remove unused test params

* Refactor redacted URI handling in NatsUri class

Modified the NatsUri class to store the redacted URI as a string instead of a
Uri object. This change simplifies the ToString method and ensures that sensitive
information like user credentials is consistently redacted for logging purposes.
Redacted string is created once in the ctor and avoiding Uri.ToString() calls
being made everytime it's needed.

* Move read URL credentials into NatsOpts

---------

Co-authored-by: Ziya Suzen <[email protected]>
  • Loading branch information
Mrxx99 and mtmk authored Nov 5, 2024
1 parent 3462ebf commit aedc9aa
Show file tree
Hide file tree
Showing 9 changed files with 333 additions and 35 deletions.
22 changes: 18 additions & 4 deletions 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 string _redacted;

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

Uri = uriBuilder.Uri;

// Redact user/password or token from the URI string for logging
if (uriBuilder.UserName is { Length: > 0 })
{
if (uriBuilder.Password is { Length: > 0 })
{
uriBuilder.Password = "***";
}
else
{
uriBuilder.UserName = "***";
}
}

_redacted = IsWebSocket && Uri.AbsolutePath != "/" ? uriBuilder.Uri.ToString() : uriBuilder.Uri.ToString().Trim('/');
}

public Uri Uri { get; }
Expand All @@ -63,10 +80,7 @@ public NatsUri CloneWith(string host, int? port = default)
return new NatsUri(newUri, IsSeed);
}

public override string ToString()
{
return IsWebSocket && Uri.AbsolutePath != "/" ? Uri.ToString() : Uri.ToString().Trim('/');
}
public override string ToString() => _redacted;

public override int GetHashCode() => Uri.GetHashCode();

Expand Down
3 changes: 1 addition & 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 = opts.ReadUserInfoFromConnectionString();
ConnectionState = NatsConnectionState.Closed;
_waitForOpenConnection = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
_disposedCancellationTokenSource = new CancellationTokenSource();
Expand Down
65 changes: 63 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,11 +139,50 @@ 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();
}

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

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

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

if (uriBuilder.Password is { Length: > 0 })
{
return this with
{
AuthOpts = AuthOpts with
{
Username = Uri.UnescapeDataString(uriBuilder.UserName),
Password = Uri.UnescapeDataString(uriBuilder.Password),
},
};
}
else
{
return this with
{
AuthOpts = AuthOpts with
{
Token = Uri.UnescapeDataString(uriBuilder.UserName),
},
};
}
}
}
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

0 comments on commit aedc9aa

Please sign in to comment.