diff --git a/src/NATS.Client.Core/Internal/NatsUri.cs b/src/NATS.Client.Core/Internal/NatsUri.cs index 64d3ea4c2..02144d50c 100644 --- a/src/NATS.Client.Core/Internal/NatsUri.cs +++ b/src/NATS.Client.Core/Internal/NatsUri.cs @@ -4,6 +4,8 @@ internal sealed class NatsUri : IEquatable { public const string DefaultScheme = "nats"; + private readonly string _redacted; + public NatsUri(string urlString, bool isSeed, string defaultScheme = DefaultScheme) { IsSeed = isSeed; @@ -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; } @@ -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(); diff --git a/src/NATS.Client.Core/NatsConnection.cs b/src/NATS.Client.Core/NatsConnection.cs index 85862e7b7..0601ea6d7 100644 --- a/src/NATS.Client.Core/NatsConnection.cs +++ b/src/NATS.Client.Core/NatsConnection.cs @@ -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; @@ -76,7 +75,7 @@ public NatsConnection() public NatsConnection(NatsOpts opts) { _logger = opts.LoggerFactory.CreateLogger(); - Opts = opts; + Opts = opts.ReadUserInfoFromConnectionString(); ConnectionState = NatsConnectionState.Closed; _waitForOpenConnection = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); _disposedCancellationTokenSource = new CancellationTokenSource(); diff --git a/src/NATS.Client.Core/NatsOpts.cs b/src/NATS.Client.Core/NatsOpts.cs index 2c2245b2a..5dc7b2083 100644 --- a/src/NATS.Client.Core/NatsOpts.cs +++ b/src/NATS.Client.Core/NatsOpts.cs @@ -14,6 +14,28 @@ public sealed record NatsOpts { public static readonly NatsOpts Default = new(); + /// + /// NATS server URL to connect to. (default: nats://localhost:4222) + /// + /// + /// + /// 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 + /// (which is false by default) is set to true. + /// + /// + /// User-password or token authentication can be set in the URL. + /// For example, nats://derek:s3cr3t@localhost:4222 or nats://token@localhost:4222. + /// You can also set the username and password or token separately using ; + /// however, if both are set, the will take precedence. + /// You should URL-encode the username and password or token if they contain special characters. + /// + /// + /// 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. + /// + /// public string Url { get; init; } = "nats://localhost:4222"; public string Name { get; init; } = "NATS .NET Client"; @@ -117,11 +139,50 @@ public sealed record NatsOpts /// 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), + }, + }; + } + } } diff --git a/src/NATS.Client.Simplified/NatsClient.cs b/src/NATS.Client.Simplified/NatsClient.cs index 87de0d6c0..5cc9dc383 100644 --- a/src/NATS.Client.Simplified/NatsClient.cs +++ b/src/NATS.Client.Simplified/NatsClient.cs @@ -11,9 +11,25 @@ public class NatsClient : INatsClient /// /// Initializes a new instance of the class. /// - /// NATS server URL - /// Client name - /// Credentials filepath + /// NATS server URL to connect to. (default: nats://localhost:4222) + /// Client name. (default: NATS .NET Client) + /// Credentials filepath. + /// + /// + /// You can set more than one server as seed servers in a comma-separated list in the . + /// The client will randomly select a server from the list to connect. + /// + /// + /// User-password or token authentication can be set in the . + /// For example, nats://derek:s3cr3t@localhost:4222 or nats://token@localhost:4222. + /// You should URL-encode the username and password or token if they contain special characters. + /// + /// + /// If multiple servers are specified and user-password or token authentication is used in the , + /// only the credentials in the first server URL will be used; credentials in the remaining server + /// URLs will be ignored. + /// + /// public NatsClient( string url = "nats://localhost:4222", string name = "NATS .NET Client", diff --git a/tests/NATS.Client.Core.Tests/ClusterTests.cs b/tests/NATS.Client.Core.Tests/ClusterTests.cs index 0ef8be7fc..05b3f2cec 100644 --- a/tests/NATS.Client.Core.Tests/ClusterTests.cs +++ b/tests/NATS.Client.Core.Tests/ClusterTests.cs @@ -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 { diff --git a/tests/NATS.Client.Core.Tests/NatsConnectionTest.Auth.cs b/tests/NATS.Client.Core.Tests/NatsConnectionTest.Auth.cs index 16aad262b..11c6ac559 100644 --- a/tests/NATS.Client.Core.Tests/NatsConnectionTest.Auth.cs +++ b/tests/NATS.Client.Core.Tests/NatsConnectionTest.Auth.cs @@ -12,6 +12,15 @@ public static IEnumerable 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( @@ -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( @@ -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"); @@ -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(); @@ -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 @@ -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; } @@ -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; } } diff --git a/tests/NATS.Client.CoreUnit.Tests/OptsUrlTests.cs b/tests/NATS.Client.CoreUnit.Tests/OptsUrlTests.cs new file mode 100644 index 000000000..e4144d179 --- /dev/null +++ b/tests/NATS.Client.CoreUnit.Tests/OptsUrlTests.cs @@ -0,0 +1,121 @@ +namespace NATS.Client.Core.Tests; + +public class OptsUrlTests +{ + [Fact] + public void Default_URL() + { + var opts = new NatsConnection().Opts; + Assert.Equal("nats://localhost:4222", opts.Url); + } + + [Fact] + public void Redact_URL_user_password() + { + var natsUri = new NatsUri("u:p@host", true); + Assert.Equal("nats://u:***@host:4222", natsUri.ToString()); + Assert.Equal("u:p", natsUri.Uri.UserInfo); + } + + [Fact] + public void Redact_URL_token() + { + var natsUri = new NatsUri("t@host", true); + Assert.Equal("nats://***@host:4222", natsUri.ToString()); + Assert.Equal("t", natsUri.Uri.UserInfo); + } + + [Theory] + [InlineData("host1", "nats://host1:4222", null, null, null)] + [InlineData("host1:1234", "nats://host1:1234", null, null, null)] + [InlineData("tls://host1", "tls://host1:4222", null, null, null)] + [InlineData("u:p@host1:1234", "nats://u:***@host1:1234", "u", "p", null)] + [InlineData("t@host1:1234", "nats://***@host1:1234", null, null, "t")] + [InlineData("host1,host2", "nats://host1:4222,nats://host2:4222", null, null, null)] + [InlineData("u:p@host1,host2", "nats://u:***@host1:4222,nats://host2:4222", "u", "p", null)] + [InlineData("u:p@host1,x@host2", "nats://u:***@host1:4222,nats://***@host2:4222", "u", "p", null)] + [InlineData("t@host1,x:x@host2", "nats://***@host1:4222,nats://x:***@host2:4222", null, null, "t")] + [InlineData("u:p@host1,host2,host3", "nats://u:***@host1:4222,nats://host2:4222,nats://host3:4222", "u", "p", null)] + [InlineData("t@host1,@host2,host3", "nats://***@host1:4222,nats://host2:4222,nats://host3:4222", null, null, "t")] + public void URL_parts(string url, string expected, string? user, string? pass, string? token) + { + var opts = new NatsConnection(new NatsOpts { Url = url }).Opts; + Assert.Equal(expected, GetUrisAsRedactedString(opts)); + Assert.Equal(user, opts.AuthOpts.Username); + Assert.Equal(pass, opts.AuthOpts.Password); + Assert.Equal(token, opts.AuthOpts.Token); + } + + [Theory] + [InlineData("u:p@host1:1234", "nats://u:***@host1:1234")] + [InlineData("t@host1:1234", "nats://***@host1:1234")] + public void URL_should_not_override_auth_options(string url, string expected) + { + var opts = new NatsConnection(new NatsOpts + { + Url = url, + AuthOpts = new NatsAuthOpts + { + Username = "shouldn't override username", + Password = "shouldn't override password", + Token = "shouldn't override token", + }, + }).Opts; + Assert.Equal(expected, GetUrisAsRedactedString(opts)); + Assert.Equal("shouldn't override username", opts.AuthOpts.Username); + Assert.Equal("shouldn't override password", opts.AuthOpts.Password); + Assert.Equal("shouldn't override token", opts.AuthOpts.Token); + } + + [Fact] + public void URL_escape_user_password() + { + var opts = new NatsConnection(new NatsOpts { Url = "nats://u%2C:p%2C@host1,host2" }).Opts; + Assert.Equal("nats://u%2C:***@host1:4222,nats://host2:4222", GetUrisAsRedactedString(opts)); + Assert.Equal("u,", opts.AuthOpts.Username); + Assert.Equal("p,", opts.AuthOpts.Password); + Assert.Null(opts.AuthOpts.Token); + + var uris = opts.GetSeedUris(true); + uris[0].Uri.Scheme.Should().Be("nats"); + uris[0].Uri.Host.Should().Be("host1"); + uris[0].Uri.Port.Should().Be(4222); + uris[0].Uri.UserInfo.Should().Be("u%2C:p%2C"); + uris[1].Uri.Scheme.Should().Be("nats"); + uris[1].Uri.Host.Should().Be("host2"); + uris[1].Uri.Port.Should().Be(4222); + uris[1].Uri.UserInfo.Should().Be(string.Empty); + } + + [Fact] + public void URL_escape_token() + { + var opts = new NatsConnection(new NatsOpts { Url = "nats://t%2C@host1,nats://t%2C@host2" }).Opts; + Assert.Equal("nats://***@host1:4222,nats://***@host2:4222", GetUrisAsRedactedString(opts)); + Assert.Null(opts.AuthOpts.Username); + Assert.Null(opts.AuthOpts.Password); + Assert.Equal("t,", opts.AuthOpts.Token); + + var uris = opts.GetSeedUris(true); + uris[0].Uri.Scheme.Should().Be("nats"); + uris[0].Uri.Host.Should().Be("host1"); + uris[0].Uri.Port.Should().Be(4222); + uris[0].Uri.UserInfo.Should().Be("t%2C"); + uris[1].Uri.Scheme.Should().Be("nats"); + uris[1].Uri.Host.Should().Be("host2"); + uris[1].Uri.Port.Should().Be(4222); + uris[1].Uri.UserInfo.Should().Be("t%2C"); + } + + [Fact] + public void Keep_URL_wss_path_and_query_string() + { + var opts = new NatsConnection(new NatsOpts { Url = "wss://t%2C@host1/path1/path2?q1=1" }).Opts; + Assert.Equal("wss://***@host1/path1/path2?q1=1", GetUrisAsRedactedString(opts)); + Assert.Null(opts.AuthOpts.Username); + Assert.Null(opts.AuthOpts.Password); + Assert.Equal("t,", opts.AuthOpts.Token); + } + + private static string GetUrisAsRedactedString(NatsOpts opts) => string.Join(",", opts.GetSeedUris(true).Select(u => u.ToString())); +} diff --git a/tests/NATS.Client.TestUtilities/NatsServer.cs b/tests/NATS.Client.TestUtilities/NatsServer.cs index 83cb90001..3de63b5d0 100644 --- a/tests/NATS.Client.TestUtilities/NatsServer.cs +++ b/tests/NATS.Client.TestUtilities/NatsServer.cs @@ -85,6 +85,22 @@ private NatsServer(ITestOutputHelper outputHelper, NatsServerOpts opts) _ => throw new ArgumentOutOfRangeException(), }; + public string ClientUrlWithAuth + { + get + { + if (!string.IsNullOrEmpty(Opts.ClientUrlUserName)) + { + var uriBuilder = new UriBuilder(ClientUrl); + uriBuilder.UserName = Opts.ClientUrlUserName; + uriBuilder.Password = Opts.ClientUrlPassword; + return uriBuilder.ToString().TrimEnd('/'); + } + + return ClientUrl; + } + } + public int ConnectionPort { get @@ -134,7 +150,7 @@ public static NatsServer StartWithTrace(ITestOutputHelper outputHelper) public static NatsServer Start(ITestOutputHelper outputHelper, TransportType transportType) => Start(outputHelper, new NatsServerOptsBuilder().UseTransport(transportType).Build()); - public static NatsServer Start(ITestOutputHelper outputHelper, NatsServerOpts opts, NatsOpts? clientOpts = default) + public static NatsServer Start(ITestOutputHelper outputHelper, NatsServerOpts opts, NatsOpts? clientOpts = default, bool useAuthInUrl = false) { NatsServer? server = null; NatsConnection? nats = null; @@ -144,7 +160,7 @@ public static NatsServer Start(ITestOutputHelper outputHelper, NatsServerOpts op { server = new NatsServer(outputHelper, opts); server.StartServerProcess(); - nats = server.CreateClientConnection(clientOpts ?? NatsOpts.Default, reTryCount: 3); + nats = server.CreateClientConnection(clientOpts ?? NatsOpts.Default, reTryCount: 3, useAuthInUrl: useAuthInUrl); #pragma warning disable CA2012 return server; } @@ -335,13 +351,13 @@ public async ValueTask DisposeAsync() return (client, proxy); } - public NatsConnection CreateClientConnection(NatsOpts? options = default, int reTryCount = 10, bool ignoreAuthorizationException = false, bool testLogger = true) + public NatsConnection CreateClientConnection(NatsOpts? options = default, int reTryCount = 10, bool ignoreAuthorizationException = false, bool testLogger = true, bool useAuthInUrl = false) { for (var i = 0; i < reTryCount; i++) { try { - var nats = new NatsConnection(ClientOpts(options ?? NatsOpts.Default, testLogger: testLogger)); + var nats = new NatsConnection(ClientOpts(options ?? NatsOpts.Default, testLogger: testLogger, useAuthInUrl: useAuthInUrl)); try { @@ -376,7 +392,7 @@ public NatsConnectionPool CreatePooledClientConnection(NatsOpts opts) return new NatsConnectionPool(4, ClientOpts(opts)); } - public NatsOpts ClientOpts(NatsOpts opts, bool testLogger = true) + public NatsOpts ClientOpts(NatsOpts opts, bool testLogger = true, bool useAuthInUrl = false) { var natsTlsOpts = Opts.EnableTls ? opts.TlsOpts with @@ -392,7 +408,7 @@ public NatsOpts ClientOpts(NatsOpts opts, bool testLogger = true) { LoggerFactory = testLogger ? _loggerFactory : opts.LoggerFactory, TlsOpts = natsTlsOpts, - Url = ClientUrl, + Url = useAuthInUrl ? ClientUrlWithAuth : ClientUrl, }; } @@ -464,7 +480,7 @@ public class NatsCluster : IAsyncDisposable { private readonly ITestOutputHelper _outputHelper; - public NatsCluster(ITestOutputHelper outputHelper, TransportType transportType, Action? configure = default) + public NatsCluster(ITestOutputHelper outputHelper, TransportType transportType, Action? configure = default, bool useAuthInUrl = false) { _outputHelper = outputHelper; @@ -516,13 +532,13 @@ public NatsCluster(ITestOutputHelper outputHelper, TransportType transportType, } _outputHelper.WriteLine($"Starting server 1..."); - Server1 = NatsServer.Start(outputHelper, opts1); + Server1 = NatsServer.Start(outputHelper, opts1, useAuthInUrl: useAuthInUrl); _outputHelper.WriteLine($"Starting server 2..."); - Server2 = NatsServer.Start(outputHelper, opts2); + Server2 = NatsServer.Start(outputHelper, opts2, useAuthInUrl: useAuthInUrl); _outputHelper.WriteLine($"Starting server 3..."); - Server3 = NatsServer.Start(outputHelper, opts3); + Server3 = NatsServer.Start(outputHelper, opts3, useAuthInUrl: useAuthInUrl); } public NatsServer Server1 { get; } diff --git a/tests/NATS.Client.TestUtilities/NatsServerOpts.cs b/tests/NATS.Client.TestUtilities/NatsServerOpts.cs index 43c69ba26..2f2b6fc3a 100644 --- a/tests/NATS.Client.TestUtilities/NatsServerOpts.cs +++ b/tests/NATS.Client.TestUtilities/NatsServerOpts.cs @@ -31,6 +31,8 @@ public sealed class NatsServerOptsBuilder private bool _serverDisposeReturnsPorts; private bool _enableClustering; private bool _trace; + private string? _clientUrlUserName; + private string? _clientUrlPassword; public NatsServerOpts Build() => new() { @@ -40,6 +42,8 @@ public sealed class NatsServerOptsBuilder TlsVerify = _tlsVerify, EnableJetStream = _enableJetStream, ServerName = _serverName, + ClientUrlUserName = _clientUrlUserName, + ClientUrlPassword = _clientUrlPassword, TlsServerCertFile = _tlsServerCertFile, TlsServerKeyFile = _tlsServerKeyFile, TlsClientCertFile = _tlsClientCertFile, @@ -110,6 +114,21 @@ public NatsServerOptsBuilder WithServerName(string serverName) return this; } + public NatsServerOptsBuilder WithClientUrlAuthentication(string userName, string password) + { + _clientUrlUserName = userName; + _clientUrlPassword = password; + return this; + } + + public NatsServerOptsBuilder WithClientUrlAuthentication(string authInfo) + { + var infoParts = authInfo.Split(':'); + _clientUrlUserName = infoParts.FirstOrDefault(); + _clientUrlPassword = infoParts.ElementAtOrDefault(1); + return this; + } + public NatsServerOptsBuilder UseJetStream() { _enableJetStream = true; @@ -176,6 +195,10 @@ public NatsServerOpts() public bool ServerDisposeReturnsPorts { get; init; } = true; + public string? ClientUrlUserName { get; set; } + + public string? ClientUrlPassword { get; set; } + public string? TlsClientCertFile { get; init; } public string? TlsClientKeyFile { get; init; }