diff --git a/src/NATS.Client.Core/Internal/ClientOpts.cs b/src/NATS.Client.Core/Internal/ClientOpts.cs
index 571c658c8..cd4401fb0 100644
--- a/src/NATS.Client.Core/Internal/ClientOpts.cs
+++ b/src/NATS.Client.Core/Internal/ClientOpts.cs
@@ -56,11 +56,11 @@ private ClientOpts(NatsOpts opts)
/// Connection username (if auth_required is set)
[JsonPropertyName("user")]
- public string? Username { get; init; } = null;
+ public string? Username { get; set; } = null;
/// Connection password (if auth_required is set)
[JsonPropertyName("pass")]
- public string? Password { get; init; } = null;
+ public string? Password { get; set; } = null;
/// Optional client name
[JsonPropertyName("name")]
diff --git a/src/NATS.Client.Core/Internal/UserCredentials.cs b/src/NATS.Client.Core/Internal/UserCredentials.cs
index 898060841..3f78206ff 100644
--- a/src/NATS.Client.Core/Internal/UserCredentials.cs
+++ b/src/NATS.Client.Core/Internal/UserCredentials.cs
@@ -1,4 +1,6 @@
+using System.Security.Cryptography;
using System.Text;
+using Microsoft.Extensions.Logging;
using NATS.Client.Core.NaCl;
namespace NATS.Client.Core.Internal;
@@ -11,6 +13,7 @@ public UserCredentials(NatsAuthOpts authOpts)
Seed = authOpts.Seed;
NKey = authOpts.NKey;
Token = authOpts.Token;
+ AuthCredCallback = authOpts.AuthCredCallback;
if (!string.IsNullOrEmpty(authOpts.CredsFile))
{
@@ -31,24 +34,85 @@ public UserCredentials(NatsAuthOpts authOpts)
public string? Token { get; }
- public string? Sign(string? nonce)
+ public Func>? AuthCredCallback { get; }
+
+ public string? Sign(string? nonce, string? seed = null)
{
- if (Seed == null || nonce == null)
+ seed ??= Seed;
+
+ if (seed == null || nonce == null)
return null;
- using var kp = NKeys.FromSeed(Seed);
+ using var kp = NKeys.FromSeed(seed);
var bytes = kp.Sign(Encoding.ASCII.GetBytes(nonce));
var sig = CryptoBytes.ToBase64String(bytes);
return sig;
}
- internal void Authenticate(ClientOpts opts, ServerInfo? info)
+ internal async Task AuthenticateAsync(ClientOpts opts, ServerInfo? info, NatsUri uri, TimeSpan timeout, CancellationToken cancellationToken)
{
- opts.JWT = Jwt;
- opts.NKey = NKey;
- opts.AuthToken = Token;
- opts.Sig = info is { AuthRequired: true, Nonce: { } } ? Sign(info.Nonce) : null;
+ string? seed = null;
+ if (AuthCredCallback != null)
+ {
+ using var cts = new CancellationTokenSource(timeout);
+#if NETSTANDARD
+ using var ctr = cancellationToken.Register(static state => ((CancellationTokenSource)state!).Cancel(), cts);
+#else
+ await using var ctr = cancellationToken.UnsafeRegister(static state => ((CancellationTokenSource)state!).Cancel(), cts);
+#endif
+ var authCred = await AuthCredCallback(uri.Uri, cts.Token).ConfigureAwait(false);
+
+ switch (authCred.Type)
+ {
+ case NatsAuthType.None:
+ // Behavior in this case is undefined.
+ // A follow-up PR should define the AuthCredCallback
+ // behavior when returning NatsAuthType.None.
+ break;
+ case NatsAuthType.UserInfo:
+ opts.Username = authCred.Value;
+ opts.Password = authCred.Secret;
+ break;
+ case NatsAuthType.Token:
+ opts.AuthToken = authCred.Value;
+ break;
+ case NatsAuthType.Jwt:
+ opts.JWT = authCred.Value;
+ seed = authCred.Secret;
+ break;
+ case NatsAuthType.Nkey:
+ if (!string.IsNullOrEmpty(authCred.Secret))
+ {
+ seed = authCred.Secret;
+ opts.NKey = NKeys.PublicKeyFromSeed(seed);
+ }
+
+ break;
+ case NatsAuthType.CredsFile:
+ if (!string.IsNullOrEmpty(authCred.Value))
+ {
+ (opts.JWT, seed) = LoadCredsFile(authCred.Value);
+ }
+
+ break;
+ case NatsAuthType.NkeyFile:
+ if (!string.IsNullOrEmpty(authCred.Value))
+ {
+ (seed, opts.NKey) = LoadNKeyFile(authCred.Value);
+ }
+
+ break;
+ }
+ }
+ else
+ {
+ opts.JWT = Jwt;
+ opts.NKey = NKey;
+ opts.AuthToken = Token;
+ }
+
+ opts.Sig = info is { AuthRequired: true, Nonce: { } } ? Sign(info.Nonce, seed) : null;
}
private (string, string) LoadCredsFile(string path)
diff --git a/src/NATS.Client.Core/NatsAuthOpts.cs b/src/NATS.Client.Core/NatsAuthOpts.cs
index ca6012ba5..ddeb0882f 100644
--- a/src/NATS.Client.Core/NatsAuthOpts.cs
+++ b/src/NATS.Client.Core/NatsAuthOpts.cs
@@ -1,5 +1,45 @@
namespace NATS.Client.Core;
+internal enum NatsAuthType
+{
+ None,
+ UserInfo,
+ Token,
+ Jwt,
+ Nkey,
+ CredsFile,
+ NkeyFile,
+}
+
+public readonly struct NatsAuthCred
+{
+ private NatsAuthCred(NatsAuthType type, string value, string secret)
+ {
+ Type = type;
+ Value = value;
+ Secret = secret;
+ }
+
+ internal NatsAuthType Type { get; }
+
+ internal string? Value { get; }
+
+ internal string? Secret { get; }
+
+ public static NatsAuthCred FromUserInfo(string username, string password)
+ => new(NatsAuthType.UserInfo, $"{username}", $"{password}");
+
+ public static NatsAuthCred FromToken(string token) => new(NatsAuthType.Token, token, string.Empty);
+
+ public static NatsAuthCred FromJwt(string jwt, string seed) => new(NatsAuthType.Jwt, jwt, seed);
+
+ public static NatsAuthCred FromNkey(string seed) => new(NatsAuthType.Nkey, string.Empty, seed);
+
+ public static NatsAuthCred FromCredsFile(string credFile) => new(NatsAuthType.CredsFile, credFile, string.Empty);
+
+ public static NatsAuthCred FromNkeyFile(string nkeyFile) => new(NatsAuthType.NkeyFile, nkeyFile, string.Empty);
+}
+
public record NatsAuthOpts
{
public static readonly NatsAuthOpts Default = new();
@@ -20,11 +60,21 @@ public record NatsAuthOpts
public string? NKeyFile { get; init; }
+ ///
+ /// Callback to provide NATS authentication credentials.
+ /// When specified, value of will take precedence
+ /// over other authentication options. Note that, default value of
+ /// should not be returned as the behavior is not defined.
+ ///
+ public Func>? AuthCredCallback { get; init; }
+
public bool IsAnonymous => string.IsNullOrEmpty(Username)
&& string.IsNullOrEmpty(Password)
&& string.IsNullOrEmpty(Token)
&& string.IsNullOrEmpty(Jwt)
+ && string.IsNullOrEmpty(NKey)
&& string.IsNullOrEmpty(Seed)
&& string.IsNullOrEmpty(CredsFile)
- && string.IsNullOrEmpty(NKeyFile);
+ && string.IsNullOrEmpty(NKeyFile)
+ && AuthCredCallback == null;
}
diff --git a/src/NATS.Client.Core/NatsConnection.cs b/src/NATS.Client.Core/NatsConnection.cs
index 73ef8c9d9..ad245fa72 100644
--- a/src/NATS.Client.Core/NatsConnection.cs
+++ b/src/NATS.Client.Core/NatsConnection.cs
@@ -461,7 +461,10 @@ private async ValueTask SetupReaderWriterAsync(bool reconnect)
infoParsedSignal.SetResult();
// Authentication
- _userCredentials?.Authenticate(_clientOpts, WritableServerInfo);
+ if (_userCredentials != null)
+ {
+ await _userCredentials.AuthenticateAsync(_clientOpts, WritableServerInfo, _currentConnectUri, Opts.ConnectTimeout, _disposedCancellationTokenSource.Token).ConfigureAwait(false);
+ }
await using (var priorityCommandWriter = new PriorityCommandWriter(this, _pool, _socket!, Opts, Counter, EnqueuePing))
{
diff --git a/tests/NATS.Client.Core.Tests/NatsConnectionTest.Auth.cs b/tests/NATS.Client.Core.Tests/NatsConnectionTest.Auth.cs
index 11c6ac559..90cb44ae2 100644
--- a/tests/NATS.Client.Core.Tests/NatsConnectionTest.Auth.cs
+++ b/tests/NATS.Client.Core.Tests/NatsConnectionTest.Auth.cs
@@ -32,6 +32,21 @@ NatsOpts.Default with
}),
};
+ yield return new object[]
+ {
+ new Auth(
+ "USER-PASSWORD (AuthCallback takes precedence over Username & Password)",
+ "resources/configs/auth/password.conf",
+ NatsOpts.Default with
+ {
+ AuthOpts = NatsAuthOpts.Default with {
+ Username = "invalid",
+ Password = "invalid",
+ AuthCredCallback = async (_, _) => await Task.FromResult(NatsAuthCred.FromUserInfo("a", "b")),
+ },
+ }),
+ };
+
yield return new object[]
{
new Auth(
@@ -56,6 +71,22 @@ NatsOpts.Default with
}),
};
+ yield return new object[]
+ {
+ new Auth(
+ "NKEY (AuthCallback takes precedence over NKey & Seed)",
+ "resources/configs/auth/nkey.conf",
+ NatsOpts.Default with
+ {
+ AuthOpts = NatsAuthOpts.Default with
+ {
+ AuthCredCallback = async (_, _) => await Task.FromResult(NatsAuthCred.FromNkey("SUAAVWRZG6M5FA5VRRGWSCIHKTOJC7EWNIT4JV3FTOIPO4OBFR5WA7X5TE")),
+ NKey = "invalid nkey",
+ Seed = "invalid seed",
+ },
+ }),
+ };
+
yield return new object[]
{
new Auth(
@@ -67,6 +98,21 @@ NatsOpts.Default with
}),
};
+ yield return new object[]
+ {
+ new Auth(
+ "NKEY (FROM FILE) (AuthCallback takes precedence over original file)",
+ "resources/configs/auth/nkey.conf",
+ NatsOpts.Default with
+ {
+ AuthOpts = NatsAuthOpts.Default with
+ {
+ NKeyFile = string.Empty,
+ AuthCredCallback = async (_, _) => await Task.FromResult(NatsAuthCred.FromNkeyFile("resources/configs/auth/user.nk")),
+ },
+ }),
+ };
+
yield return new object[]
{
new Auth(
@@ -83,6 +129,22 @@ NatsOpts.Default with
}),
};
+ yield return new object[]
+ {
+ new Auth(
+ "USER-CREDS (AuthCallback takes precedence over Jwt & Seed)",
+ "resources/configs/auth/operator.conf",
+ NatsOpts.Default with
+ {
+ AuthOpts = NatsAuthOpts.Default with
+ {
+ AuthCredCallback = async (_, _) => await Task.FromResult(NatsAuthCred.FromJwt("eyJ0eXAiOiJKV1QiLCJhbGciOiJlZDI1NTE5LW5rZXkifQ.eyJqdGkiOiJOVDJTRkVIN0pNSUpUTzZIQ09GNUpYRFNDUU1WRlFNV0MyWjI1TFk3QVNPTklYTjZFVlhBIiwiaWF0IjoxNjc5MTQ0MDkwLCJpc3MiOiJBREpOSlpZNUNXQlI0M0NOSzJBMjJBMkxPSkVBSzJSS1RaTk9aVE1HUEVCRk9QVE5FVFBZTUlLNSIsIm5hbWUiOiJteS11c2VyIiwic3ViIjoiVUJPWjVMUVJPTEpRRFBBQUNYSk1VRkJaS0Q0R0JaSERUTFo3TjVQS1dSWFc1S1dKM0VBMlc0UloiLCJuYXRzIjp7InB1YiI6e30sInN1YiI6e30sInN1YnMiOi0xLCJkYXRhIjotMSwicGF5bG9hZCI6LTEsInR5cGUiOiJ1c2VyIiwidmVyc2lvbiI6Mn19.ElYEknDixe9pZdl55S9PjduQhhqR1OQLglI1JO7YK7ECYb1mLUjGd8ntcR7ISS04-_yhygSDzX8OS8buBIxMDA", "SUAJR32IC6D45J3URHJ5AOQZWBBO6QTID27NZQKXE3GC5U3SPFEYDJK6RQ")),
+ Jwt = "not a valid jwt",
+ Seed = "invalid nkey seed",
+ },
+ }),
+ };
+
yield return new object[]
{
new Auth(
@@ -93,6 +155,35 @@ NatsOpts.Default with
AuthOpts = NatsAuthOpts.Default with { CredsFile = "resources/configs/auth/user.creds", },
}),
};
+
+ yield return new object[]
+ {
+ new Auth(
+ "USER-CREDS (FROM FILE) (AuthCallback takes precedence over original file)",
+ "resources/configs/auth/operator.conf",
+ NatsOpts.Default with
+ {
+ AuthOpts = NatsAuthOpts.Default with
+ {
+ CredsFile = string.Empty,
+ AuthCredCallback = async (_, _) => await Task.FromResult(NatsAuthCred.FromCredsFile("resources/configs/auth/user.creds")),
+ },
+ }),
+ };
+
+ yield return new object[]
+ {
+ new Auth(
+ "Token (AuthCallback takes precedence over Token)",
+ "resources/configs/auth/token.conf",
+ NatsOpts.Default with
+ {
+ AuthOpts = NatsAuthOpts.Default with {
+ Token = "won't be used",
+ AuthCredCallback = async (_, _) => await Task.FromResult(NatsAuthCred.FromToken("s3cr3t")),
+ },
+ }),
+ };
}
[Theory]