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 callback to NatsAuthOpts that allows refreshing a Token #712

Merged
merged 3 commits into from
Jan 24, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/NATS.Client.Core/Internal/ClientOpts.cs
Original file line number Diff line number Diff line change
Expand Up @@ -56,11 +56,11 @@ private ClientOpts(NatsOpts opts)

/// <summary>Connection username (if auth_required is set)</summary>
[JsonPropertyName("user")]
public string? Username { get; init; } = null;
public string? Username { get; set; } = null;

/// <summary>Connection password (if auth_required is set)</summary>
[JsonPropertyName("pass")]
public string? Password { get; init; } = null;
public string? Password { get; set; } = null;
caleblloyd marked this conversation as resolved.
Show resolved Hide resolved

/// <summary>Optional client name</summary>
[JsonPropertyName("name")]
Expand Down
80 changes: 72 additions & 8 deletions src/NATS.Client.Core/Internal/UserCredentials.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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))
{
Expand All @@ -31,24 +34,85 @@ public UserCredentials(NatsAuthOpts authOpts)

public string? Token { get; }

public string? Sign(string? nonce)
public Func<Uri, CancellationToken, ValueTask<NatsAuthCred>>? 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);
mtmk marked this conversation as resolved.
Show resolved Hide resolved
#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)
Expand Down
52 changes: 51 additions & 1 deletion src/NATS.Client.Core/NatsAuthOpts.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,45 @@
namespace NATS.Client.Core;

internal enum NatsAuthType
{
None,
mtmk marked this conversation as resolved.
Show resolved Hide resolved
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();
Expand All @@ -20,11 +60,21 @@ public record NatsAuthOpts

public string? NKeyFile { get; init; }

/// <summary>
/// Callback to provide NATS authentication credentials.
/// When specified, value of <see cref="NatsAuthCred"/> will take precedence
/// over other authentication options. Note that, <c>default</c> value of
/// <see cref="NatsAuthCred"/> should not be returned as the behavior is not defined.
/// </summary>
public Func<Uri, CancellationToken, ValueTask<NatsAuthCred>>? 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;
}
5 changes: 4 additions & 1 deletion src/NATS.Client.Core/NatsConnection.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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))
{
Expand Down
91 changes: 91 additions & 0 deletions tests/NATS.Client.Core.Tests/NatsConnectionTest.Auth.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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(
Expand All @@ -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(
Expand All @@ -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(
Expand All @@ -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]
Expand Down
Loading