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]