Compare commits

...

14 Commits

Author SHA1 Message Date
Joseph Doherty
a6e9bd1467 feat: add monitoring port CLI args to server host
Support -m/--http_port, --http_base_path, and --https_port flags for
configuring the monitoring HTTP endpoint from the command line.
2026-02-22 23:08:04 -05:00
Joseph Doherty
543b185f7e fix: address code quality issues from review
- Make ConnectReceived thread-safe with Volatile.Read/Write (accessed from auth timeout task and command pipeline)
- Include authTimeoutTask in Task.WhenAny to propagate exceptions
- Clear nonce after authentication with CryptographicOperations.ZeroMemory
- Avoid closure allocation on publish permission cache hot path (method group)
- Update AuthTimeout default to 2s to match Go server
2026-02-22 23:07:31 -05:00
Joseph Doherty
c40c2cd994 test: add permission enforcement and NKey integration tests
Fix NKey nonce verification: the NATS client signs the nonce string
(ASCII bytes of the base64url-encoded nonce), not the raw nonce bytes.
Pass the encoded nonce string bytes to the authenticator for verification.
2026-02-22 23:03:41 -05:00
Joseph Doherty
9cb3e2fe0f feat: add per-account SubList isolation for message routing
Subscriptions and message routing now go through account-specific SubLists
instead of a single global SubList. Clients in different accounts cannot
see each other's messages. When no account is specified (or auth is not
configured), all clients share the global $G account.
2026-02-22 23:00:59 -05:00
Joseph Doherty
2980a343c1 feat: integrate authentication into server accept loop and client CONNECT processing
Wire AuthService into NatsServer and NatsClient to enforce authentication
on incoming connections. The server builds an AuthService from NatsOptions,
sets auth_required in ServerInfo, and generates per-client nonces when
NKey auth is configured. NatsClient validates credentials in ProcessConnect,
enforces publish/subscribe permissions, and implements an auth timeout that
closes connections that don't send CONNECT in time. Existing tests without
auth continue to work since AuthService.IsAuthRequired is false by default.
2026-02-22 22:55:50 -05:00
Joseph Doherty
2a2cc6f0a2 feat: add AuthService orchestrator with priority-ordered authentication 2026-02-22 22:44:58 -05:00
Joseph Doherty
6ebe791c6d feat: add authenticators, Account, and ClientPermissions (Tasks 3-7, 9)
- Account: per-account SubList and client tracking
- IAuthenticator interface, AuthResult, ClientAuthContext
- TokenAuthenticator: constant-time token comparison
- UserPasswordAuthenticator: multi-user with bcrypt/plain support
- SimpleUserPasswordAuthenticator: single user/pass config
- NKeyAuthenticator: Ed25519 nonce signature verification
- ClientPermissions: SubList-based publish/subscribe authorization
2026-02-22 22:41:45 -05:00
Joseph Doherty
562f89744d feat: add IAuthenticator interface and TokenAuthenticator with constant-time comparison 2026-02-22 22:24:53 -05:00
Joseph Doherty
0cce771907 feat: add Account type with per-account SubList and client tracking 2026-02-22 22:22:51 -05:00
Joseph Doherty
5305069dd8 feat: add auth model types (User, NKeyUser, Permissions) and auth config to NatsOptions 2026-02-22 22:21:00 -05:00
Joseph Doherty
11dc5e62f3 feat: add auth fields to ServerInfo and ClientOptions protocol types 2026-02-22 22:19:18 -05:00
Joseph Doherty
91aff1a867 chore: add NATS.NKeys and BCrypt.Net-Next packages for authentication 2026-02-22 22:17:42 -05:00
Joseph Doherty
1c8cc43fb4 docs: add authentication implementation plan with 15 TDD tasks
Covers NuGet packages, protocol types, auth models, authenticators
(token, user/password, NKey), AuthService orchestrator, permissions,
server/client integration, account isolation, and integration tests.
2026-02-22 22:15:48 -05:00
Joseph Doherty
bca703b310 docs: add authentication design for username/password, token, and NKeys
Covers auth models, strategy pattern with pluggable authenticators,
permission enforcement, core account isolation, and server integration.
2026-02-22 22:07:16 -05:00
37 changed files with 5168 additions and 19 deletions

View File

@@ -9,6 +9,10 @@
<PackageVersion Include="Serilog.Extensions.Hosting" Version="10.0.0" />
<PackageVersion Include="Serilog.Sinks.Console" Version="6.1.1" />
<!-- Authentication -->
<PackageVersion Include="NATS.NKeys" Version="1.0.0-preview.3" />
<PackageVersion Include="BCrypt.Net-Next" Version="4.0.3" />
<!-- Testing -->
<PackageVersion Include="coverlet.collector" Version="6.0.4" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />

View File

@@ -0,0 +1,286 @@
# Authentication Design
**Date:** 2026-02-22
**Scope:** Username/password, token, NKeys, permissions, core account isolation
**Approach:** Strategy pattern with pluggable authenticators
## Overview
Port the NATS server authentication subsystem from Go (`server/auth.go`, `server/accounts.go`) to .NET. The system authenticates clients during the CONNECT phase using nonce-based challenge-response (NKeys), password comparison (plain/bcrypt), or bearer tokens. Each authenticated user can have per-subject publish/subscribe permissions. Clients are assigned to accounts that provide subscription isolation.
## Core Auth Models
### User (username/password auth)
```csharp
public sealed class User
{
public required string Username { get; init; }
public required string Password { get; init; } // plain or bcrypt ($2...)
public Permissions? Permissions { get; init; }
public Account? Account { get; init; }
public DateTimeOffset? ConnectionDeadline { get; init; }
}
```
### NKeyUser (Ed25519 auth)
```csharp
public sealed class NKeyUser
{
public required string Nkey { get; init; } // public nkey
public Permissions? Permissions { get; init; }
public Account? Account { get; init; }
public string? SigningKey { get; init; }
}
```
### Permissions
```csharp
public sealed class Permissions
{
public SubjectPermission? Publish { get; init; }
public SubjectPermission? Subscribe { get; init; }
public ResponsePermission? Response { get; init; }
}
public sealed class SubjectPermission
{
public IReadOnlyList<string>? Allow { get; init; }
public IReadOnlyList<string>? Deny { get; init; }
}
public sealed class ResponsePermission
{
public int MaxMsgs { get; init; }
public TimeSpan Expires { get; init; }
}
```
### Account
Per-account isolation with its own SubList:
```csharp
public sealed class Account
{
public string Name { get; }
public SubList SubList { get; }
public ConcurrentDictionary<ulong, NatsClient> Clients { get; }
public Permissions? DefaultPermissions { get; set; }
}
```
Server holds `ConcurrentDictionary<string, Account>` with lazy global account (`DEFAULT`).
## Authentication Flow
```
Client connects
-> Server sends INFO { auth_required: true, nonce: "..." }
-> Client sends CONNECT { user, pass, token, nkey, sig }
-> AuthService.Authenticate(context)
|- NKeyAuthenticator (if nkeys configured)
|- UserPasswordAuthenticator (if users configured)
|- TokenAuthenticator (if token configured)
|- SimpleUserPasswordAuthenticator (if single user configured)
'- NoAuthUser fallback (if configured)
-> Success: assign permissions, assign account
-> Failure: -ERR 'Authorization Violation', disconnect
```
### Interfaces
```csharp
public interface IAuthenticator
{
AuthResult? Authenticate(ClientAuthContext context);
}
public sealed class ClientAuthContext
{
public required ClientOptions Opts { get; init; }
public required byte[] Nonce { get; init; }
public required NatsClient Client { get; init; }
}
public sealed class AuthResult
{
public required string Identity { get; init; }
public Account Account { get; init; }
public Permissions? Permissions { get; init; }
public DateTimeOffset? Expiry { get; init; }
}
```
### AuthService
Orchestrates authenticators in priority order (matching Go's `processClientOrLeafAuthentication`):
```csharp
public sealed class AuthService
{
private readonly List<IAuthenticator> _authenticators;
public AuthResult? Authenticate(ClientAuthContext context);
public bool IsAuthRequired { get; }
}
```
### Concrete Authenticators
- **NKeyAuthenticator** - looks up nkey in server map, verifies Ed25519 signature against nonce using NATS.NKeys
- **UserPasswordAuthenticator** - looks up username in server map, compares password (bcrypt via BCrypt.Net-Next, or constant-time plain comparison)
- **TokenAuthenticator** - constant-time comparison against configured token
- **SimpleUserPasswordAuthenticator** - for single `username`/`password` option (no map lookup)
### Auth Timeout
`CancellationTokenSource` with configurable timeout (default 1 second, matching Go `DEFAULT_AUTH_TIMEOUT`). If CONNECT not received and authenticated within window, disconnect client.
### Nonce Generation
Per-client nonce: 11 random bytes, base64url-encoded to 15 chars (matching Go). Generated using `RandomNumberGenerator.Fill()`. Included in INFO only when auth is required.
### Password Comparison
- Bcrypt passwords (prefixed `$2`): use `BCrypt.Net.BCrypt.Verify()` (constant-time)
- Plain passwords: use `CryptographicOperations.FixedTimeEquals()` (constant-time)
## Permission Enforcement
### ClientPermissions
```csharp
public sealed class ClientPermissions
{
public PermissionSet? Publish { get; init; }
public PermissionSet? Subscribe { get; init; }
public ResponsePermission? Response { get; init; }
private readonly ConcurrentDictionary<string, bool> _pubCache = new();
public bool IsPublishAllowed(string subject);
public bool IsSubscribeAllowed(string subject, string? queue = null);
}
public sealed class PermissionSet
{
public SubList? Allow { get; init; }
public SubList? Deny { get; init; }
}
```
### Logic (matches Go)
1. No permissions set -> allow everything
2. Allow list exists -> subject must match allow list
3. Deny list exists -> subject must NOT match deny list
4. Subscribe checks queue groups against queue-specific entries
### Integration Points
- `ProcessPub()` - check `IsPublishAllowed(subject)` before routing. `-ERR 'Permissions Violation for Publish to "<subject>"'` on deny.
- `ProcessSub()` - check `IsSubscribeAllowed(subject, queue)` before adding subscription. `-ERR 'Permissions Violation for Subscription to "<subject>"'` on deny.
## Configuration Changes
### NatsOptions
```csharp
public sealed class NatsOptions
{
// ... existing fields ...
// Simple auth (single user)
public string? Username { get; set; }
public string? Password { get; set; }
public string? Authorization { get; set; } // bearer token
// Multiple users/nkeys
public IReadOnlyList<User>? Users { get; set; }
public IReadOnlyList<NKeyUser>? NKeys { get; set; }
// Default/fallback
public string? NoAuthUser { get; set; }
// Auth timing
public TimeSpan AuthTimeout { get; set; } = TimeSpan.FromSeconds(1);
}
```
### ServerInfo Additions
```csharp
public bool AuthRequired { get; set; }
public string? Nonce { get; set; }
```
### ClientOptions Additions
```csharp
[JsonPropertyName("user")]
public string? Username { get; set; }
[JsonPropertyName("pass")]
public string? Password { get; set; }
[JsonPropertyName("auth_token")]
public string? Token { get; set; }
[JsonPropertyName("nkey")]
public string? Nkey { get; set; }
[JsonPropertyName("sig")]
public string? Sig { get; set; }
```
## Server/Client Integration
### NatsServer
- Build `AuthService` during startup based on options
- Maintain `ConcurrentDictionary<string, Account>` with lazy global account
- Build `users` and `nkeys` lookup maps from options
- Generate per-client nonce when `AuthService.IsAuthRequired`
- Include `AuthRequired` and `Nonce` in per-client INFO
### NatsClient
- Store `ClientPermissions` after successful auth
- Store assigned `Account` reference
- Auth timeout via `CancellationTokenSource` on CONNECT wait
- Register/unregister with account's client set on connect/disconnect
- PUB messages route through account's SubList for isolation
## NuGet Packages
- `NATS.NKeys` - Ed25519 NKey operations
- `BCrypt.Net-Next` - bcrypt password hashing
## Testing Strategy
### Unit Tests
- `TokenAuthenticatorTests` - correct/wrong token, constant-time comparison
- `UserPasswordAuthenticatorTests` - plain password, bcrypt password, wrong password, unknown user
- `NKeyAuthenticatorTests` - valid/invalid signature, unknown nkey
- `AuthServiceTests` - priority ordering, NoAuthUser fallback, auth-not-required case
- `ClientPermissionsTests` - allow/deny lists, wildcards, queue groups, publish cache
### Account Tests
- Two accounts configured, cross-account isolation verified
### Integration Tests
- Connect with valid token/user-pass/nkey -> success
- Connect with wrong credentials -> `-ERR`, disconnect
- Connect with no credentials when auth required -> `-ERR`, disconnect
- NoAuthUser configured -> success with default permissions
- Auth timeout -> disconnect
- Publish/subscribe to denied subjects -> `-ERR 'Permissions Violation ...'`
## Go Reference Files
- `server/auth.go` - Core authentication logic
- `server/accounts.go` - Account struct and management
- `server/client.go` - CONNECT processing, RegisterUser, RegisterNkeyUser, permission checks
- `server/nkey.go` - Nonce generation
- `server/opts.go` - Auth configuration parsing

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,21 @@
{
"planPath": "docs/plans/2026-02-22-authentication-plan.md",
"tasks": [
{"id": 0, "subject": "Task 0: Add NuGet packages (NATS.NKeys, BCrypt.Net-Next)", "status": "pending"},
{"id": 1, "subject": "Task 1: Add auth fields to protocol types", "status": "pending", "blockedBy": [0]},
{"id": 2, "subject": "Task 2: Add auth config to NatsOptions + model types", "status": "pending", "blockedBy": [1]},
{"id": 3, "subject": "Task 3: Implement Account type with per-account SubList", "status": "pending", "blockedBy": [2]},
{"id": 4, "subject": "Task 4: Implement TokenAuthenticator", "status": "pending", "blockedBy": [2]},
{"id": 5, "subject": "Task 5: Implement UserPasswordAuthenticator", "status": "pending", "blockedBy": [2]},
{"id": 6, "subject": "Task 6: Implement SimpleUserPasswordAuthenticator", "status": "pending", "blockedBy": [2]},
{"id": 7, "subject": "Task 7: Implement NKeyAuthenticator", "status": "pending", "blockedBy": [2]},
{"id": 8, "subject": "Task 8: Implement AuthService orchestrator", "status": "pending", "blockedBy": [3, 4, 5, 6, 7]},
{"id": 9, "subject": "Task 9: Implement ClientPermissions", "status": "pending", "blockedBy": [2]},
{"id": 10, "subject": "Task 10: Integrate auth into NatsServer and NatsClient", "status": "pending", "blockedBy": [8, 9]},
{"id": 11, "subject": "Task 11: Implement account isolation in message routing", "status": "pending", "blockedBy": [10]},
{"id": 12, "subject": "Task 12: Add permission enforcement integration tests", "status": "pending", "blockedBy": [10]},
{"id": 13, "subject": "Task 13: Add NKey integration test", "status": "pending", "blockedBy": [10]},
{"id": 14, "subject": "Task 14: Final regression test and cleanup", "status": "pending", "blockedBy": [11, 12, 13]}
],
"lastUpdated": "2026-02-22T00:00:00Z"
}

View File

@@ -23,6 +23,15 @@ for (int i = 0; i < args.Length; i++)
case "-n" or "--name" when i + 1 < args.Length:
options.ServerName = args[++i];
break;
case "-m" or "--http_port" when i + 1 < args.Length:
options.MonitorPort = int.Parse(args[++i]);
break;
case "--http_base_path" when i + 1 < args.Length:
options.MonitorBasePath = args[++i];
break;
case "--https_port" when i + 1 < args.Length:
options.MonitorHttpsPort = int.Parse(args[++i]);
break;
}
}

View File

@@ -0,0 +1,28 @@
using System.Collections.Concurrent;
using NATS.Server.Subscriptions;
namespace NATS.Server.Auth;
public sealed class Account : IDisposable
{
public const string GlobalAccountName = "$G";
public string Name { get; }
public SubList SubList { get; } = new();
public Permissions? DefaultPermissions { get; set; }
private readonly ConcurrentDictionary<ulong, byte> _clients = new();
public Account(string name)
{
Name = name;
}
public int ClientCount => _clients.Count;
public void AddClient(ulong clientId) => _clients[clientId] = 0;
public void RemoveClient(ulong clientId) => _clients.TryRemove(clientId, out _);
public void Dispose() => SubList.Dispose();
}

View File

@@ -0,0 +1,9 @@
namespace NATS.Server.Auth;
public sealed class AuthResult
{
public required string Identity { get; init; }
public string? AccountName { get; init; }
public Permissions? Permissions { get; init; }
public DateTimeOffset? Expiry { get; init; }
}

View File

@@ -0,0 +1,131 @@
using System.Security.Cryptography;
namespace NATS.Server.Auth;
/// <summary>
/// Central authentication orchestrator that builds the appropriate authenticators
/// from NatsOptions and tries them in priority order matching the Go server:
/// NKeys > Users > Token > SimpleUserPassword.
/// Reference: golang/nats-server/server/auth.go — checkClientAuth, configureAuthentication.
/// </summary>
public sealed class AuthService
{
private readonly List<IAuthenticator> _authenticators;
private readonly string? _noAuthUser;
private readonly Dictionary<string, User>? _usersMap;
public bool IsAuthRequired { get; }
public bool NonceRequired { get; }
private AuthService(List<IAuthenticator> authenticators, bool authRequired, bool nonceRequired,
string? noAuthUser, Dictionary<string, User>? usersMap)
{
_authenticators = authenticators;
IsAuthRequired = authRequired;
NonceRequired = nonceRequired;
_noAuthUser = noAuthUser;
_usersMap = usersMap;
}
public static AuthService Build(NatsOptions options)
{
var authenticators = new List<IAuthenticator>();
var authRequired = false;
var nonceRequired = false;
Dictionary<string, User>? usersMap = null;
// Priority order (matching Go): NKeys > Users > Token > SimpleUserPassword
if (options.NKeys is { Count: > 0 })
{
authenticators.Add(new NKeyAuthenticator(options.NKeys));
authRequired = true;
nonceRequired = true;
}
if (options.Users is { Count: > 0 })
{
authenticators.Add(new UserPasswordAuthenticator(options.Users));
authRequired = true;
usersMap = new Dictionary<string, User>(StringComparer.Ordinal);
foreach (var u in options.Users)
usersMap[u.Username] = u;
}
if (!string.IsNullOrEmpty(options.Authorization))
{
authenticators.Add(new TokenAuthenticator(options.Authorization));
authRequired = true;
}
if (!string.IsNullOrEmpty(options.Username) && !string.IsNullOrEmpty(options.Password))
{
authenticators.Add(new SimpleUserPasswordAuthenticator(options.Username, options.Password));
authRequired = true;
}
return new AuthService(authenticators, authRequired, nonceRequired, options.NoAuthUser, usersMap);
}
public AuthResult? Authenticate(ClientAuthContext context)
{
if (!IsAuthRequired)
return new AuthResult { Identity = string.Empty };
foreach (var authenticator in _authenticators)
{
var result = authenticator.Authenticate(context);
if (result != null)
return result;
}
if (_noAuthUser != null && IsNoCredentials(context))
return ResolveNoAuthUser();
return null;
}
private static bool IsNoCredentials(ClientAuthContext context)
{
var opts = context.Opts;
return string.IsNullOrEmpty(opts.Username)
&& string.IsNullOrEmpty(opts.Password)
&& string.IsNullOrEmpty(opts.Token)
&& string.IsNullOrEmpty(opts.Nkey)
&& string.IsNullOrEmpty(opts.Sig);
}
private AuthResult? ResolveNoAuthUser()
{
if (_noAuthUser == null)
return null;
if (_usersMap != null && _usersMap.TryGetValue(_noAuthUser, out var user))
{
return new AuthResult
{
Identity = user.Username,
AccountName = user.Account,
Permissions = user.Permissions,
Expiry = user.ConnectionDeadline,
};
}
return new AuthResult { Identity = _noAuthUser };
}
public byte[] GenerateNonce()
{
Span<byte> raw = stackalloc byte[11];
RandomNumberGenerator.Fill(raw);
return raw.ToArray();
}
public string EncodeNonce(byte[] nonce)
{
return Convert.ToBase64String(nonce)
.TrimEnd('=')
.Replace('+', '-')
.Replace('/', '_');
}
}

View File

@@ -0,0 +1,121 @@
using System.Collections.Concurrent;
using NATS.Server.Subscriptions;
namespace NATS.Server.Auth;
public sealed class ClientPermissions : IDisposable
{
private readonly PermissionSet? _publish;
private readonly PermissionSet? _subscribe;
private readonly ConcurrentDictionary<string, bool> _pubCache = new(StringComparer.Ordinal);
private ClientPermissions(PermissionSet? publish, PermissionSet? subscribe)
{
_publish = publish;
_subscribe = subscribe;
}
public static ClientPermissions? Build(Permissions? permissions)
{
if (permissions == null)
return null;
var pub = PermissionSet.Build(permissions.Publish);
var sub = PermissionSet.Build(permissions.Subscribe);
if (pub == null && sub == null)
return null;
return new ClientPermissions(pub, sub);
}
public bool IsPublishAllowed(string subject)
{
if (_publish == null)
return true;
return _pubCache.GetOrAdd(subject, _publish.IsAllowed);
}
public bool IsSubscribeAllowed(string subject, string? queue = null)
{
if (_subscribe == null)
return true;
return _subscribe.IsAllowed(subject);
}
public void Dispose()
{
_publish?.Dispose();
_subscribe?.Dispose();
}
}
public sealed class PermissionSet : IDisposable
{
private readonly SubList? _allow;
private readonly SubList? _deny;
private PermissionSet(SubList? allow, SubList? deny)
{
_allow = allow;
_deny = deny;
}
public static PermissionSet? Build(SubjectPermission? permission)
{
if (permission == null)
return null;
bool hasAllow = permission.Allow is { Count: > 0 };
bool hasDeny = permission.Deny is { Count: > 0 };
if (!hasAllow && !hasDeny)
return null;
SubList? allow = null;
SubList? deny = null;
if (hasAllow)
{
allow = new SubList();
foreach (var subject in permission.Allow!)
allow.Insert(new Subscription { Subject = subject, Sid = "_perm_" });
}
if (hasDeny)
{
deny = new SubList();
foreach (var subject in permission.Deny!)
deny.Insert(new Subscription { Subject = subject, Sid = "_perm_" });
}
return new PermissionSet(allow, deny);
}
public bool IsAllowed(string subject)
{
bool allowed = true;
if (_allow != null)
{
var result = _allow.Match(subject);
allowed = result.PlainSubs.Length > 0 || result.QueueSubs.Length > 0;
}
if (allowed && _deny != null)
{
var result = _deny.Match(subject);
allowed = result.PlainSubs.Length == 0 && result.QueueSubs.Length == 0;
}
return allowed;
}
public void Dispose()
{
_allow?.Dispose();
_deny?.Dispose();
}
}

View File

@@ -0,0 +1,14 @@
using NATS.Server.Protocol;
namespace NATS.Server.Auth;
public interface IAuthenticator
{
AuthResult? Authenticate(ClientAuthContext context);
}
public sealed class ClientAuthContext
{
public required ClientOptions Opts { get; init; }
public required byte[] Nonce { get; init; }
}

View File

@@ -0,0 +1,66 @@
using NATS.NKeys;
namespace NATS.Server.Auth;
/// <summary>
/// Authenticates clients using NKey (Ed25519) public-key signature verification.
/// The server sends a random nonce in the INFO message. The client signs the nonce
/// with their private key and sends the public key + base64-encoded signature in CONNECT.
/// The server verifies the signature against the registered NKey users.
/// </summary>
/// <remarks>
/// Reference: golang/nats-server/server/auth.go — checkNKeyAuth
/// </remarks>
public sealed class NKeyAuthenticator(IEnumerable<NKeyUser> nkeyUsers) : IAuthenticator
{
private readonly Dictionary<string, NKeyUser> _nkeys = nkeyUsers.ToDictionary(
u => u.Nkey,
u => u,
StringComparer.Ordinal);
public AuthResult? Authenticate(ClientAuthContext context)
{
var clientNkey = context.Opts.Nkey;
if (string.IsNullOrEmpty(clientNkey))
return null;
if (!_nkeys.TryGetValue(clientNkey, out var nkeyUser))
return null;
var clientSig = context.Opts.Sig;
if (string.IsNullOrEmpty(clientSig))
return null;
try
{
// Decode base64 signature (handle both standard and URL-safe base64)
byte[] sigBytes;
try
{
sigBytes = Convert.FromBase64String(clientSig);
}
catch (FormatException)
{
// Try URL-safe base64 by converting to standard base64
var padded = clientSig.Replace('-', '+').Replace('_', '/');
padded = padded.PadRight(padded.Length + (4 - padded.Length % 4) % 4, '=');
sigBytes = Convert.FromBase64String(padded);
}
var kp = KeyPair.FromPublicKey(clientNkey);
if (!kp.Verify(context.Nonce, sigBytes))
return null;
}
catch
{
return null;
}
return new AuthResult
{
Identity = clientNkey,
AccountName = nkeyUser.Account,
Permissions = nkeyUser.Permissions,
};
}
}

View File

@@ -0,0 +1,9 @@
namespace NATS.Server.Auth;
public sealed class NKeyUser
{
public required string Nkey { get; init; }
public Permissions? Permissions { get; init; }
public string? Account { get; init; }
public string? SigningKey { get; init; }
}

View File

@@ -0,0 +1,20 @@
namespace NATS.Server.Auth;
public sealed class Permissions
{
public SubjectPermission? Publish { get; init; }
public SubjectPermission? Subscribe { get; init; }
public ResponsePermission? Response { get; init; }
}
public sealed class SubjectPermission
{
public IReadOnlyList<string>? Allow { get; init; }
public IReadOnlyList<string>? Deny { get; init; }
}
public sealed class ResponsePermission
{
public int MaxMsgs { get; init; }
public TimeSpan Expires { get; init; }
}

View File

@@ -0,0 +1,61 @@
using System.Security.Cryptography;
using System.Text;
namespace NATS.Server.Auth;
/// <summary>
/// Authenticates a single username/password pair configured on the server.
/// Supports plain-text and bcrypt-hashed passwords.
/// Uses constant-time comparison for both username and password to prevent timing attacks.
/// Reference: golang/nats-server/server/auth.go checkClientAuth for single user.
/// </summary>
public sealed class SimpleUserPasswordAuthenticator : IAuthenticator
{
private readonly byte[] _expectedUsername;
private readonly string _serverPassword;
public SimpleUserPasswordAuthenticator(string username, string password)
{
_expectedUsername = Encoding.UTF8.GetBytes(username);
_serverPassword = password;
}
public AuthResult? Authenticate(ClientAuthContext context)
{
var clientUsername = context.Opts.Username;
if (string.IsNullOrEmpty(clientUsername))
return null;
var clientUsernameBytes = Encoding.UTF8.GetBytes(clientUsername);
if (!CryptographicOperations.FixedTimeEquals(clientUsernameBytes, _expectedUsername))
return null;
var clientPassword = context.Opts.Password ?? string.Empty;
if (!ComparePasswords(_serverPassword, clientPassword))
return null;
return new AuthResult { Identity = clientUsername };
}
private static bool ComparePasswords(string serverPassword, string clientPassword)
{
// Bcrypt hashes start with "$2" (e.g., $2a$, $2b$, $2y$)
if (serverPassword.StartsWith("$2"))
{
try
{
return BCrypt.Net.BCrypt.Verify(clientPassword, serverPassword);
}
catch
{
return false;
}
}
// Plain-text: constant-time comparison to prevent timing attacks
var serverBytes = Encoding.UTF8.GetBytes(serverPassword);
var clientBytes = Encoding.UTF8.GetBytes(clientPassword);
return CryptographicOperations.FixedTimeEquals(serverBytes, clientBytes);
}
}

View File

@@ -0,0 +1,28 @@
using System.Security.Cryptography;
using System.Text;
namespace NATS.Server.Auth;
public sealed class TokenAuthenticator : IAuthenticator
{
private readonly byte[] _expectedToken;
public TokenAuthenticator(string token)
{
_expectedToken = Encoding.UTF8.GetBytes(token);
}
public AuthResult? Authenticate(ClientAuthContext context)
{
var clientToken = context.Opts.Token;
if (string.IsNullOrEmpty(clientToken))
return null;
var clientBytes = Encoding.UTF8.GetBytes(clientToken);
if (!CryptographicOperations.FixedTimeEquals(clientBytes, _expectedToken))
return null;
return new AuthResult { Identity = "token" };
}
}

View File

@@ -0,0 +1,10 @@
namespace NATS.Server.Auth;
public sealed class User
{
public required string Username { get; init; }
public required string Password { get; init; }
public Permissions? Permissions { get; init; }
public string? Account { get; init; }
public DateTimeOffset? ConnectionDeadline { get; init; }
}

View File

@@ -0,0 +1,66 @@
using System.Security.Cryptography;
using System.Text;
namespace NATS.Server.Auth;
/// <summary>
/// Authenticates clients by looking up username in a dictionary and comparing
/// the password using bcrypt (for $2-prefixed hashes) or constant-time comparison
/// (for plain text passwords).
/// Reference: golang/nats-server/server/auth.go checkClientPassword.
/// </summary>
public sealed class UserPasswordAuthenticator : IAuthenticator
{
private readonly Dictionary<string, User> _users;
public UserPasswordAuthenticator(IEnumerable<User> users)
{
_users = new Dictionary<string, User>(StringComparer.Ordinal);
foreach (var user in users)
_users[user.Username] = user;
}
public AuthResult? Authenticate(ClientAuthContext context)
{
var username = context.Opts.Username;
if (string.IsNullOrEmpty(username))
return null;
if (!_users.TryGetValue(username, out var user))
return null;
var clientPassword = context.Opts.Password ?? string.Empty;
if (!ComparePasswords(user.Password, clientPassword))
return null;
return new AuthResult
{
Identity = user.Username,
AccountName = user.Account,
Permissions = user.Permissions,
Expiry = user.ConnectionDeadline,
};
}
private static bool ComparePasswords(string serverPassword, string clientPassword)
{
if (IsBcrypt(serverPassword))
{
try
{
return BCrypt.Net.BCrypt.Verify(clientPassword, serverPassword);
}
catch
{
return false;
}
}
var serverBytes = Encoding.UTF8.GetBytes(serverPassword);
var clientBytes = Encoding.UTF8.GetBytes(clientPassword);
return CryptographicOperations.FixedTimeEquals(serverBytes, clientBytes);
}
private static bool IsBcrypt(string password) => password.StartsWith("$2");
}

View File

@@ -1,5 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="NATS.NKeys" />
<PackageReference Include="BCrypt.Net-Next" />
</ItemGroup>
</Project>

View File

@@ -1,9 +1,11 @@
using System.Buffers;
using System.IO.Pipelines;
using System.Net.Sockets;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using NATS.Server.Auth;
using NATS.Server.Protocol;
using NATS.Server.Subscriptions;
@@ -27,16 +29,23 @@ public sealed class NatsClient : IDisposable
private readonly NetworkStream _stream;
private readonly NatsOptions _options;
private readonly ServerInfo _serverInfo;
private readonly AuthService _authService;
private readonly byte[]? _nonce;
private readonly NatsParser _parser;
private readonly SemaphoreSlim _writeLock = new(1, 1);
private CancellationTokenSource? _clientCts;
private readonly Dictionary<string, Subscription> _subs = new();
private readonly ILogger _logger;
private ClientPermissions? _permissions;
public ulong Id { get; }
public ClientOptions? ClientOpts { get; private set; }
public IMessageRouter? Router { get; set; }
public bool ConnectReceived { get; private set; }
public Account? Account { get; private set; }
// Thread-safe: read from auth timeout task on threadpool, written from command pipeline
private int _connectReceived;
public bool ConnectReceived => Volatile.Read(ref _connectReceived) != 0;
// Stats
public long InMsgs;
@@ -50,13 +59,16 @@ public sealed class NatsClient : IDisposable
public IReadOnlyDictionary<string, Subscription> Subscriptions => _subs;
public NatsClient(ulong id, Socket socket, NatsOptions options, ServerInfo serverInfo, ILogger logger)
public NatsClient(ulong id, Socket socket, NatsOptions options, ServerInfo serverInfo,
AuthService authService, byte[]? nonce, ILogger logger)
{
Id = id;
_socket = socket;
_stream = new NetworkStream(socket, ownsSocket: false);
_options = options;
_serverInfo = serverInfo;
_authService = authService;
_nonce = nonce;
_logger = logger;
_parser = new NatsParser(options.MaxPayload);
}
@@ -71,12 +83,37 @@ public sealed class NatsClient : IDisposable
// Send INFO
await SendInfoAsync(_clientCts.Token);
// Start auth timeout if auth is required
Task? authTimeoutTask = null;
if (_authService.IsAuthRequired)
{
authTimeoutTask = Task.Run(async () =>
{
try
{
await Task.Delay(_options.AuthTimeout, _clientCts!.Token);
if (!ConnectReceived)
{
_logger.LogDebug("Client {ClientId} auth timeout", Id);
await SendErrAndCloseAsync(NatsProtocol.ErrAuthTimeout);
}
}
catch (OperationCanceledException)
{
// Normal — client connected or was cancelled
}
}, _clientCts.Token);
}
// Start read pump, command processing, and ping timer in parallel
var fillTask = FillPipeAsync(pipe.Writer, _clientCts.Token);
var processTask = ProcessCommandsAsync(pipe.Reader, _clientCts.Token);
var pingTask = RunPingTimerAsync(_clientCts.Token);
await Task.WhenAny(fillTask, processTask, pingTask);
if (authTimeoutTask != null)
await Task.WhenAny(fillTask, processTask, pingTask, authTimeoutTask);
else
await Task.WhenAny(fillTask, processTask, pingTask);
}
catch (OperationCanceledException)
{
@@ -147,10 +184,28 @@ public sealed class NatsClient : IDisposable
private async ValueTask DispatchCommandAsync(ParsedCommand cmd, CancellationToken ct)
{
// If auth is required and CONNECT hasn't been received yet,
// only allow CONNECT and PING commands
if (_authService.IsAuthRequired && !ConnectReceived)
{
switch (cmd.Type)
{
case CommandType.Connect:
await ProcessConnectAsync(cmd);
return;
case CommandType.Ping:
await WriteAsync(NatsProtocol.PongBytes, ct);
return;
default:
// Ignore all other commands until authenticated
return;
}
}
switch (cmd.Type)
{
case CommandType.Connect:
ProcessConnect(cmd);
await ProcessConnectAsync(cmd);
break;
case CommandType.Ping:
@@ -162,7 +217,7 @@ public sealed class NatsClient : IDisposable
break;
case CommandType.Sub:
ProcessSub(cmd);
await ProcessSubAsync(cmd);
break;
case CommandType.Unsub:
@@ -176,16 +231,67 @@ public sealed class NatsClient : IDisposable
}
}
private void ProcessConnect(ParsedCommand cmd)
private async ValueTask ProcessConnectAsync(ParsedCommand cmd)
{
ClientOpts = JsonSerializer.Deserialize<ClientOptions>(cmd.Payload.Span)
?? new ClientOptions();
ConnectReceived = true;
// Authenticate if auth is required
if (_authService.IsAuthRequired)
{
var context = new ClientAuthContext
{
Opts = ClientOpts,
Nonce = _nonce ?? [],
};
var result = _authService.Authenticate(context);
if (result == null)
{
_logger.LogWarning("Client {ClientId} authentication failed", Id);
await SendErrAndCloseAsync(NatsProtocol.ErrAuthorizationViolation);
return;
}
// Build permissions from auth result
_permissions = ClientPermissions.Build(result.Permissions);
// Resolve account
if (Router is NatsServer server)
{
var accountName = result.AccountName ?? Account.GlobalAccountName;
Account = server.GetOrCreateAccount(accountName);
Account.AddClient(Id);
}
_logger.LogDebug("Client {ClientId} authenticated as {Identity}", Id, result.Identity);
// Clear nonce after use — defense-in-depth against memory dumps
if (_nonce != null)
CryptographicOperations.ZeroMemory(_nonce);
}
// If no account was assigned by auth, assign global account
if (Account == null && Router is NatsServer server2)
{
Account = server2.GetOrCreateAccount(Account.GlobalAccountName);
Account.AddClient(Id);
}
Volatile.Write(ref _connectReceived, 1);
_logger.LogDebug("CONNECT received from client {ClientId}, name={ClientName}", Id, ClientOpts?.Name);
}
private void ProcessSub(ParsedCommand cmd)
private async ValueTask ProcessSubAsync(ParsedCommand cmd)
{
// Permission check for subscribe
if (_permissions != null && !_permissions.IsSubscribeAllowed(cmd.Subject!, cmd.Queue))
{
_logger.LogDebug("Client {ClientId} subscribe permission denied for {Subject}", Id, cmd.Subject);
await SendErrAsync(NatsProtocol.ErrPermissionsSubscribe);
return;
}
var sub = new Subscription
{
Subject = cmd.Subject!,
@@ -198,8 +304,7 @@ public sealed class NatsClient : IDisposable
_logger.LogDebug("SUB {Subject} {Sid} from client {ClientId}", cmd.Subject, cmd.Sid, Id);
if (Router is ISubListAccess sl)
sl.SubList.Insert(sub);
Account?.SubList.Insert(sub);
}
private void ProcessUnsub(ParsedCommand cmd)
@@ -218,8 +323,7 @@ public sealed class NatsClient : IDisposable
_subs.Remove(cmd.Sid!);
if (Router is ISubListAccess sl)
sl.SubList.Remove(sub);
Account?.SubList.Remove(sub);
}
private async ValueTask ProcessPubAsync(ParsedCommand cmd)
@@ -244,6 +348,14 @@ public sealed class NatsClient : IDisposable
return;
}
// Permission check for publish
if (_permissions != null && !_permissions.IsPublishAllowed(cmd.Subject!))
{
_logger.LogDebug("Client {ClientId} publish permission denied for {Subject}", Id, cmd.Subject);
await SendErrAsync(NatsProtocol.ErrPermissionsPublish);
return;
}
ReadOnlyMemory<byte> headers = default;
ReadOnlyMemory<byte> payload = cmd.Payload;
@@ -392,6 +504,7 @@ public sealed class NatsClient : IDisposable
public void Dispose()
{
_permissions?.Dispose();
_clientCts?.Dispose();
_stream.Dispose();
_socket.Dispose();

View File

@@ -1,3 +1,5 @@
using NATS.Server.Auth;
namespace NATS.Server;
public sealed class NatsOptions
@@ -10,4 +12,19 @@ public sealed class NatsOptions
public int MaxConnections { get; set; } = 65536;
public TimeSpan PingInterval { get; set; } = TimeSpan.FromMinutes(2);
public int MaxPingsOut { get; set; } = 2;
// Simple auth (single user)
public string? Username { get; set; }
public string? Password { get; set; }
public string? Authorization { get; set; }
// Multiple users/nkeys
public IReadOnlyList<User>? Users { get; set; }
public IReadOnlyList<NKeyUser>? NKeys { get; set; }
// Default/fallback
public string? NoAuthUser { get; set; }
// Auth timing
public TimeSpan AuthTimeout { get; set; } = TimeSpan.FromSeconds(2);
}

View File

@@ -3,6 +3,7 @@ using System.Net;
using System.Net.Sockets;
using System.Text;
using Microsoft.Extensions.Logging;
using NATS.Server.Auth;
using NATS.Server.Protocol;
using NATS.Server.Subscriptions;
@@ -12,15 +13,17 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
{
private readonly NatsOptions _options;
private readonly ConcurrentDictionary<ulong, NatsClient> _clients = new();
private readonly SubList _subList = new();
private readonly ServerInfo _serverInfo;
private readonly ILogger<NatsServer> _logger;
private readonly ILoggerFactory _loggerFactory;
private readonly TaskCompletionSource _listeningStarted = new(TaskCreationOptions.RunContinuationsAsynchronously);
private readonly AuthService _authService;
private readonly ConcurrentDictionary<string, Account> _accounts = new(StringComparer.Ordinal);
private readonly Account _globalAccount;
private Socket? _listener;
private ulong _nextClientId;
public SubList SubList => _subList;
public SubList SubList => _globalAccount.SubList;
public Task WaitForReadyAsync() => _listeningStarted.Task;
@@ -29,6 +32,9 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
_options = options;
_loggerFactory = loggerFactory;
_logger = loggerFactory.CreateLogger<NatsServer>();
_authService = AuthService.Build(options);
_globalAccount = new Account(Account.GlobalAccountName);
_accounts[Account.GlobalAccountName] = _globalAccount;
_serverInfo = new ServerInfo
{
ServerId = Guid.NewGuid().ToString("N")[..20].ToUpperInvariant(),
@@ -37,6 +43,7 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
Host = options.Host,
Port = options.Port,
MaxPayload = options.MaxPayload,
AuthRequired = _authService.IsAuthRequired,
};
}
@@ -87,8 +94,30 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
_logger.LogDebug("Client {ClientId} connected from {RemoteEndpoint}", clientId, socket.RemoteEndPoint);
// Build per-client ServerInfo with nonce if NKey auth is configured
byte[]? nonce = null;
var clientInfo = _serverInfo;
if (_authService.NonceRequired)
{
var rawNonce = _authService.GenerateNonce();
var nonceStr = _authService.EncodeNonce(rawNonce);
// The client signs the nonce string (ASCII), not the raw bytes
nonce = Encoding.ASCII.GetBytes(nonceStr);
clientInfo = new ServerInfo
{
ServerId = _serverInfo.ServerId,
ServerName = _serverInfo.ServerName,
Version = _serverInfo.Version,
Host = _serverInfo.Host,
Port = _serverInfo.Port,
MaxPayload = _serverInfo.MaxPayload,
AuthRequired = _serverInfo.AuthRequired,
Nonce = nonceStr,
};
}
var clientLogger = _loggerFactory.CreateLogger($"NATS.Server.NatsClient[{clientId}]");
var client = new NatsClient(clientId, socket, _options, _serverInfo, clientLogger);
var client = new NatsClient(clientId, socket, _options, clientInfo, _authService, nonce, clientLogger);
client.Router = this;
_clients[clientId] = client;
@@ -121,7 +150,8 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
public void ProcessMessage(string subject, string? replyTo, ReadOnlyMemory<byte> headers,
ReadOnlyMemory<byte> payload, NatsClient sender)
{
var result = _subList.Match(subject);
var subList = sender.Account?.SubList ?? _globalAccount.SubList;
var result = subList.Match(subject);
// Deliver to plain subscribers
foreach (var sub in result.PlainSubs)
@@ -169,11 +199,18 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
_ = client.SendMessageAsync(subject, sub.Sid, replyTo, headers, payload, CancellationToken.None);
}
public Account GetOrCreateAccount(string name)
{
return _accounts.GetOrAdd(name, n => new Account(n));
}
public void RemoveClient(NatsClient client)
{
_clients.TryRemove(client.Id, out _);
_logger.LogDebug("Removed client {ClientId}", client.Id);
client.RemoveAllSubscriptions(_subList);
var subList = client.Account?.SubList ?? _globalAccount.SubList;
client.RemoveAllSubscriptions(subList);
client.Account?.RemoveClient(client.Id);
}
public void Dispose()
@@ -181,6 +218,7 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
_listener?.Dispose();
foreach (var client in _clients.Values)
client.Dispose();
_subList.Dispose();
foreach (var account in _accounts.Values)
account.Dispose();
}
}

View File

@@ -26,6 +26,10 @@ public static class NatsProtocol
public const string ErrMaxPayloadViolation = "Maximum Payload Violation";
public const string ErrInvalidPublishSubject = "Invalid Publish Subject";
public const string ErrInvalidSubject = "Invalid Subject";
public const string ErrAuthorizationViolation = "Authorization Violation";
public const string ErrAuthTimeout = "Authentication Timeout";
public const string ErrPermissionsPublish = "Permissions Violation for Publish";
public const string ErrPermissionsSubscribe = "Permissions Violation for Subscription";
}
public sealed class ServerInfo
@@ -61,6 +65,14 @@ public sealed class ServerInfo
[JsonPropertyName("client_ip")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? ClientIp { get; set; }
[JsonPropertyName("auth_required")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public bool AuthRequired { get; set; }
[JsonPropertyName("nonce")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Nonce { get; set; }
}
public sealed class ClientOptions
@@ -91,4 +103,19 @@ public sealed class ClientOptions
[JsonPropertyName("no_responders")]
public bool NoResponders { get; set; }
[JsonPropertyName("user")]
public string? Username { get; set; }
[JsonPropertyName("pass")]
public string? Password { get; set; }
[JsonPropertyName("auth_token")]
public string? Token { get; set; }
[JsonPropertyName("nkey")]
public string? Nkey { get; set; }
[JsonPropertyName("sig")]
public string? Sig { get; set; }
}

View File

@@ -0,0 +1,106 @@
using System.Net;
using System.Net.Sockets;
using Microsoft.Extensions.Logging.Abstractions;
using NATS.Client.Core;
using NATS.Server.Auth;
namespace NATS.Server.Tests;
public class AccountIsolationTests : IAsyncLifetime
{
private NatsServer _server = null!;
private int _port;
private readonly CancellationTokenSource _cts = new();
private Task _serverTask = null!;
private static int GetFreePort()
{
using var sock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
sock.Bind(new IPEndPoint(IPAddress.Loopback, 0));
return ((IPEndPoint)sock.LocalEndPoint!).Port;
}
public async Task InitializeAsync()
{
_port = GetFreePort();
_server = new NatsServer(new NatsOptions
{
Port = _port,
Users =
[
new User { Username = "alice", Password = "pass", Account = "acct-a" },
new User { Username = "bob", Password = "pass", Account = "acct-b" },
new User { Username = "charlie", Password = "pass", Account = "acct-a" },
],
}, NullLoggerFactory.Instance);
_serverTask = _server.StartAsync(_cts.Token);
await _server.WaitForReadyAsync();
}
public async Task DisposeAsync()
{
await _cts.CancelAsync();
_server.Dispose();
}
[Fact]
public async Task Same_account_receives_messages()
{
// Alice and Charlie are in acct-a
await using var alice = new NatsConnection(new NatsOpts
{
Url = $"nats://alice:pass@127.0.0.1:{_port}",
});
await using var charlie = new NatsConnection(new NatsOpts
{
Url = $"nats://charlie:pass@127.0.0.1:{_port}",
});
await alice.ConnectAsync();
await charlie.ConnectAsync();
await using var sub = await charlie.SubscribeCoreAsync<string>("test.subject");
await charlie.PingAsync();
await alice.PublishAsync("test.subject", "from-alice");
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var msg = await sub.Msgs.ReadAsync(timeout.Token);
msg.Data.ShouldBe("from-alice");
}
[Fact]
public async Task Different_account_does_not_receive_messages()
{
// Alice is in acct-a, Bob is in acct-b
await using var alice = new NatsConnection(new NatsOpts
{
Url = $"nats://alice:pass@127.0.0.1:{_port}",
});
await using var bob = new NatsConnection(new NatsOpts
{
Url = $"nats://bob:pass@127.0.0.1:{_port}",
});
await alice.ConnectAsync();
await bob.ConnectAsync();
await using var sub = await bob.SubscribeCoreAsync<string>("test.subject");
await bob.PingAsync();
await alice.PublishAsync("test.subject", "from-alice");
// Bob should NOT receive this — wait briefly then verify nothing arrived
using var timeout = new CancellationTokenSource(TimeSpan.FromMilliseconds(500));
try
{
await sub.Msgs.ReadAsync(timeout.Token);
throw new Exception("Bob should not have received a message from a different account");
}
catch (OperationCanceledException)
{
// Expected — no message received (timeout)
}
}
}

View File

@@ -0,0 +1,35 @@
using NATS.Server.Auth;
using NATS.Server.Subscriptions;
namespace NATS.Server.Tests;
public class AccountTests
{
[Fact]
public void Account_has_name_and_own_sublist()
{
var account = new Account("test-account");
account.Name.ShouldBe("test-account");
account.SubList.ShouldNotBeNull();
account.SubList.Count.ShouldBe(0u);
}
[Fact]
public void Account_tracks_clients()
{
var account = new Account("test");
account.ClientCount.ShouldBe(0);
account.AddClient(1);
account.ClientCount.ShouldBe(1);
account.RemoveClient(1);
account.ClientCount.ShouldBe(0);
}
[Fact]
public void GlobalAccount_has_default_name()
{
Account.GlobalAccountName.ShouldBe("$G");
}
}

View File

@@ -0,0 +1,21 @@
using NATS.Server;
using NATS.Server.Auth;
namespace NATS.Server.Tests;
public class AuthConfigTests
{
[Fact]
public void NatsOptions_has_auth_fields_with_defaults()
{
var opts = new NatsOptions();
opts.Username.ShouldBeNull();
opts.Password.ShouldBeNull();
opts.Authorization.ShouldBeNull();
opts.Users.ShouldBeNull();
opts.NKeys.ShouldBeNull();
opts.NoAuthUser.ShouldBeNull();
opts.AuthTimeout.ShouldBe(TimeSpan.FromSeconds(2));
}
}

View File

@@ -0,0 +1,256 @@
using System.Net;
using System.Net.Sockets;
using Microsoft.Extensions.Logging.Abstractions;
using NATS.Client.Core;
using NATS.Server;
using NATS.Server.Auth;
namespace NATS.Server.Tests;
public class AuthIntegrationTests
{
private static int GetFreePort()
{
using var sock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
sock.Bind(new IPEndPoint(IPAddress.Loopback, 0));
return ((IPEndPoint)sock.LocalEndPoint!).Port;
}
/// <summary>
/// Checks whether any exception in the chain contains the given substring.
/// The NATS client wraps server errors in outer NatsException messages,
/// so the actual "Authorization Violation" may be in an inner exception.
/// </summary>
private static bool ExceptionChainContains(Exception ex, string substring)
{
Exception? current = ex;
while (current != null)
{
if (current.Message.Contains(substring, StringComparison.OrdinalIgnoreCase))
return true;
current = current.InnerException;
}
return false;
}
private static (NatsServer server, int port, CancellationTokenSource cts) StartServer(NatsOptions options)
{
var port = GetFreePort();
options.Port = port;
var server = new NatsServer(options, NullLoggerFactory.Instance);
var cts = new CancellationTokenSource();
_ = server.StartAsync(cts.Token);
return (server, port, cts);
}
private static async Task<(NatsServer server, int port, CancellationTokenSource cts)> StartServerAsync(NatsOptions options)
{
var (server, port, cts) = StartServer(options);
await server.WaitForReadyAsync();
return (server, port, cts);
}
[Fact]
public async Task Token_auth_success()
{
var (server, port, cts) = await StartServerAsync(new NatsOptions
{
Authorization = "s3cr3t",
});
try
{
await using var client = new NatsConnection(new NatsOpts
{
Url = $"nats://s3cr3t@127.0.0.1:{port}",
});
await client.ConnectAsync();
await client.PingAsync();
}
finally
{
await cts.CancelAsync();
server.Dispose();
}
}
[Fact]
public async Task Token_auth_failure_disconnects()
{
var (server, port, cts) = await StartServerAsync(new NatsOptions
{
Authorization = "s3cr3t",
});
try
{
await using var client = new NatsConnection(new NatsOpts
{
Url = $"nats://wrongtoken@127.0.0.1:{port}",
MaxReconnectRetry = 0,
});
var ex = await Should.ThrowAsync<NatsException>(async () =>
{
await client.ConnectAsync();
await client.PingAsync();
});
ExceptionChainContains(ex, "Authorization Violation").ShouldBeTrue(
$"Expected 'Authorization Violation' in exception chain, but got: {ex}");
}
finally
{
await cts.CancelAsync();
server.Dispose();
}
}
[Fact]
public async Task UserPassword_auth_success()
{
var (server, port, cts) = await StartServerAsync(new NatsOptions
{
Username = "admin",
Password = "secret",
});
try
{
await using var client = new NatsConnection(new NatsOpts
{
Url = $"nats://admin:secret@127.0.0.1:{port}",
});
await client.ConnectAsync();
await client.PingAsync();
}
finally
{
await cts.CancelAsync();
server.Dispose();
}
}
[Fact]
public async Task UserPassword_auth_failure_disconnects()
{
var (server, port, cts) = await StartServerAsync(new NatsOptions
{
Username = "admin",
Password = "secret",
});
try
{
await using var client = new NatsConnection(new NatsOpts
{
Url = $"nats://admin:wrong@127.0.0.1:{port}",
MaxReconnectRetry = 0,
});
var ex = await Should.ThrowAsync<NatsException>(async () =>
{
await client.ConnectAsync();
await client.PingAsync();
});
ExceptionChainContains(ex, "Authorization Violation").ShouldBeTrue(
$"Expected 'Authorization Violation' in exception chain, but got: {ex}");
}
finally
{
await cts.CancelAsync();
server.Dispose();
}
}
[Fact]
public async Task MultiUser_auth_success()
{
var (server, port, cts) = await StartServerAsync(new NatsOptions
{
Users =
[
new User { Username = "alice", Password = "pass1" },
new User { Username = "bob", Password = "pass2" },
],
});
try
{
await using var alice = new NatsConnection(new NatsOpts
{
Url = $"nats://alice:pass1@127.0.0.1:{port}",
});
await using var bob = new NatsConnection(new NatsOpts
{
Url = $"nats://bob:pass2@127.0.0.1:{port}",
});
await alice.ConnectAsync();
await alice.PingAsync();
await bob.ConnectAsync();
await bob.PingAsync();
}
finally
{
await cts.CancelAsync();
server.Dispose();
}
}
[Fact]
public async Task No_credentials_when_auth_required_disconnects()
{
var (server, port, cts) = await StartServerAsync(new NatsOptions
{
Authorization = "s3cr3t",
});
try
{
await using var client = new NatsConnection(new NatsOpts
{
Url = $"nats://127.0.0.1:{port}",
MaxReconnectRetry = 0,
});
var ex = await Should.ThrowAsync<NatsException>(async () =>
{
await client.ConnectAsync();
await client.PingAsync();
});
ExceptionChainContains(ex, "Authorization Violation").ShouldBeTrue(
$"Expected 'Authorization Violation' in exception chain, but got: {ex}");
}
finally
{
await cts.CancelAsync();
server.Dispose();
}
}
[Fact]
public async Task No_auth_configured_allows_all()
{
var (server, port, cts) = await StartServerAsync(new NatsOptions());
try
{
await using var client = new NatsConnection(new NatsOpts
{
Url = $"nats://127.0.0.1:{port}",
});
await client.ConnectAsync();
await client.PingAsync();
}
finally
{
await cts.CancelAsync();
server.Dispose();
}
}
}

View File

@@ -0,0 +1,56 @@
using System.Text.Json;
using NATS.Server.Protocol;
namespace NATS.Server.Tests;
public class AuthProtocolTests
{
[Fact]
public void ClientOptions_deserializes_auth_fields()
{
var json = """{"user":"alice","pass":"secret","auth_token":"mytoken","nkey":"UABC","sig":"base64sig"}""";
var opts = JsonSerializer.Deserialize<ClientOptions>(json);
opts.ShouldNotBeNull();
opts.Username.ShouldBe("alice");
opts.Password.ShouldBe("secret");
opts.Token.ShouldBe("mytoken");
opts.Nkey.ShouldBe("UABC");
opts.Sig.ShouldBe("base64sig");
}
[Fact]
public void ServerInfo_serializes_auth_required_and_nonce()
{
var info = new ServerInfo
{
ServerId = "test",
ServerName = "test",
Version = "0.1.0",
Host = "127.0.0.1",
Port = 4222,
AuthRequired = true,
Nonce = "abc123",
};
var json = JsonSerializer.Serialize(info);
json.ShouldContain("\"auth_required\":true");
json.ShouldContain("\"nonce\":\"abc123\"");
}
[Fact]
public void ServerInfo_omits_nonce_when_null()
{
var info = new ServerInfo
{
ServerId = "test",
ServerName = "test",
Version = "0.1.0",
Host = "127.0.0.1",
Port = 4222,
};
var json = JsonSerializer.Serialize(info);
json.ShouldNotContain("nonce");
}
}

View File

@@ -0,0 +1,172 @@
using NATS.Server.Auth;
using NATS.Server.Protocol;
namespace NATS.Server.Tests;
public class AuthServiceTests
{
[Fact]
public void IsAuthRequired_false_when_no_auth_configured()
{
var service = AuthService.Build(new NatsOptions());
service.IsAuthRequired.ShouldBeFalse();
}
[Fact]
public void IsAuthRequired_true_when_token_configured()
{
var service = AuthService.Build(new NatsOptions { Authorization = "mytoken" });
service.IsAuthRequired.ShouldBeTrue();
}
[Fact]
public void IsAuthRequired_true_when_username_configured()
{
var service = AuthService.Build(new NatsOptions { Username = "admin", Password = "pass" });
service.IsAuthRequired.ShouldBeTrue();
}
[Fact]
public void IsAuthRequired_true_when_users_configured()
{
var opts = new NatsOptions
{
Users = [new User { Username = "alice", Password = "secret" }],
};
var service = AuthService.Build(opts);
service.IsAuthRequired.ShouldBeTrue();
}
[Fact]
public void IsAuthRequired_true_when_nkeys_configured()
{
var opts = new NatsOptions
{
NKeys = [new NKeyUser { Nkey = "UABC" }],
};
var service = AuthService.Build(opts);
service.IsAuthRequired.ShouldBeTrue();
}
[Fact]
public void Authenticate_succeeds_when_no_auth_required()
{
var service = AuthService.Build(new NatsOptions());
var ctx = new ClientAuthContext
{
Opts = new ClientOptions { Token = "anything" },
Nonce = [],
};
var result = service.Authenticate(ctx);
result.ShouldNotBeNull();
}
[Fact]
public void Authenticate_token_success()
{
var service = AuthService.Build(new NatsOptions { Authorization = "mytoken" });
var ctx = new ClientAuthContext
{
Opts = new ClientOptions { Token = "mytoken" },
Nonce = [],
};
var result = service.Authenticate(ctx);
result.ShouldNotBeNull();
result.Identity.ShouldBe("token");
}
[Fact]
public void Authenticate_token_failure()
{
var service = AuthService.Build(new NatsOptions { Authorization = "mytoken" });
var ctx = new ClientAuthContext
{
Opts = new ClientOptions { Token = "wrong" },
Nonce = [],
};
service.Authenticate(ctx).ShouldBeNull();
}
[Fact]
public void Authenticate_simple_user_password_success()
{
var service = AuthService.Build(new NatsOptions { Username = "admin", Password = "pass" });
var ctx = new ClientAuthContext
{
Opts = new ClientOptions { Username = "admin", Password = "pass" },
Nonce = [],
};
var result = service.Authenticate(ctx);
result.ShouldNotBeNull();
result.Identity.ShouldBe("admin");
}
[Fact]
public void Authenticate_multi_user_success()
{
var opts = new NatsOptions
{
Users = [
new User { Username = "alice", Password = "secret1" },
new User { Username = "bob", Password = "secret2" },
],
};
var service = AuthService.Build(opts);
var ctx = new ClientAuthContext
{
Opts = new ClientOptions { Username = "bob", Password = "secret2" },
Nonce = [],
};
var result = service.Authenticate(ctx);
result.ShouldNotBeNull();
result.Identity.ShouldBe("bob");
}
[Fact]
public void NoAuthUser_fallback_when_no_creds()
{
var opts = new NatsOptions
{
Users = [
new User { Username = "default", Password = "unused" },
],
NoAuthUser = "default",
};
var service = AuthService.Build(opts);
var ctx = new ClientAuthContext
{
Opts = new ClientOptions(),
Nonce = [],
};
var result = service.Authenticate(ctx);
result.ShouldNotBeNull();
result.Identity.ShouldBe("default");
}
[Fact]
public void NKeys_tried_before_users()
{
var opts = new NatsOptions
{
NKeys = [new NKeyUser { Nkey = "UABC" }],
Users = [new User { Username = "alice", Password = "secret" }],
};
var service = AuthService.Build(opts);
var ctx = new ClientAuthContext
{
Opts = new ClientOptions { Username = "alice", Password = "secret" },
Nonce = [],
};
var result = service.Authenticate(ctx);
result.ShouldNotBeNull();
result.Identity.ShouldBe("alice");
}
}

View File

@@ -0,0 +1,107 @@
using NATS.Server.Auth;
namespace NATS.Server.Tests;
public class ClientPermissionsTests
{
[Fact]
public void No_permissions_allows_everything()
{
var perms = ClientPermissions.Build(null);
perms.ShouldBeNull();
}
[Fact]
public void Publish_allow_list_only()
{
var perms = ClientPermissions.Build(new Permissions
{
Publish = new SubjectPermission { Allow = ["foo.>", "bar.*"] },
});
perms.ShouldNotBeNull();
perms.IsPublishAllowed("foo.bar").ShouldBeTrue();
perms.IsPublishAllowed("foo.bar.baz").ShouldBeTrue();
perms.IsPublishAllowed("bar.one").ShouldBeTrue();
perms.IsPublishAllowed("baz.one").ShouldBeFalse();
}
[Fact]
public void Publish_deny_list_only()
{
var perms = ClientPermissions.Build(new Permissions
{
Publish = new SubjectPermission { Deny = ["secret.>"] },
});
perms.ShouldNotBeNull();
perms.IsPublishAllowed("foo.bar").ShouldBeTrue();
perms.IsPublishAllowed("secret.data").ShouldBeFalse();
perms.IsPublishAllowed("secret.nested.deep").ShouldBeFalse();
}
[Fact]
public void Publish_allow_and_deny()
{
var perms = ClientPermissions.Build(new Permissions
{
Publish = new SubjectPermission
{
Allow = ["events.>"],
Deny = ["events.internal.>"],
},
});
perms.ShouldNotBeNull();
perms.IsPublishAllowed("events.public.data").ShouldBeTrue();
perms.IsPublishAllowed("events.internal.secret").ShouldBeFalse();
}
[Fact]
public void Subscribe_allow_list()
{
var perms = ClientPermissions.Build(new Permissions
{
Subscribe = new SubjectPermission { Allow = ["data.>"] },
});
perms.ShouldNotBeNull();
perms.IsSubscribeAllowed("data.updates").ShouldBeTrue();
perms.IsSubscribeAllowed("admin.logs").ShouldBeFalse();
}
[Fact]
public void Subscribe_deny_list()
{
var perms = ClientPermissions.Build(new Permissions
{
Subscribe = new SubjectPermission { Deny = ["admin.>"] },
});
perms.ShouldNotBeNull();
perms.IsSubscribeAllowed("data.updates").ShouldBeTrue();
perms.IsSubscribeAllowed("admin.logs").ShouldBeFalse();
}
[Fact]
public void Publish_cache_returns_same_result()
{
var perms = ClientPermissions.Build(new Permissions
{
Publish = new SubjectPermission { Allow = ["foo.>"] },
});
perms.ShouldNotBeNull();
perms.IsPublishAllowed("foo.bar").ShouldBeTrue();
perms.IsPublishAllowed("foo.bar").ShouldBeTrue();
perms.IsPublishAllowed("baz.bar").ShouldBeFalse();
perms.IsPublishAllowed("baz.bar").ShouldBeFalse();
}
[Fact]
public void Empty_permissions_object_allows_everything()
{
var perms = ClientPermissions.Build(new Permissions());
perms.ShouldBeNull();
}
}

View File

@@ -6,6 +6,7 @@ using System.Text.Json;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using NATS.Server;
using NATS.Server.Auth;
using NATS.Server.Protocol;
namespace NATS.Server.Tests;
@@ -39,7 +40,8 @@ public class ClientTests : IAsyncDisposable
Port = 4222,
};
_natsClient = new NatsClient(1, _serverSocket, new NatsOptions(), serverInfo, NullLogger.Instance);
var authService = AuthService.Build(new NatsOptions());
_natsClient = new NatsClient(1, _serverSocket, new NatsOptions(), serverInfo, authService, null, NullLogger.Instance);
}
public async ValueTask DisposeAsync()

View File

@@ -12,6 +12,7 @@
<PackageReference Include="Shouldly" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio" />
<PackageReference Include="NATS.NKeys" />
</ItemGroup>
<ItemGroup>

View File

@@ -0,0 +1,130 @@
using NATS.NKeys;
using NATS.Server.Auth;
using NATS.Server.Protocol;
namespace NATS.Server.Tests;
public class NKeyAuthenticatorTests
{
private static (string PublicKey, string SignatureBase64) CreateSignedNonce(byte[] nonce)
{
var kp = KeyPair.CreatePair(PrefixByte.User);
var publicKey = kp.GetPublicKey();
var sig = new byte[64];
kp.Sign(nonce, sig);
var sigBase64 = Convert.ToBase64String(sig);
return (publicKey, sigBase64);
}
private static string SignNonce(KeyPair kp, byte[] nonce)
{
var sig = new byte[64];
kp.Sign(nonce, sig);
return Convert.ToBase64String(sig);
}
[Fact]
public void Returns_result_for_valid_signature()
{
var kp = KeyPair.CreatePair(PrefixByte.User);
var publicKey = kp.GetPublicKey();
var nonce = "test-nonce-123"u8.ToArray();
var sigBase64 = SignNonce(kp, nonce);
var nkeyUser = new NKeyUser { Nkey = publicKey };
var auth = new NKeyAuthenticator([nkeyUser]);
var ctx = new ClientAuthContext
{
Opts = new ClientOptions { Nkey = publicKey, Sig = sigBase64 },
Nonce = nonce,
};
var result = auth.Authenticate(ctx);
result.ShouldNotBeNull();
result.Identity.ShouldBe(publicKey);
}
[Fact]
public void Returns_null_for_invalid_signature()
{
var kp = KeyPair.CreatePair(PrefixByte.User);
var publicKey = kp.GetPublicKey();
var nonce = "test-nonce-123"u8.ToArray();
var nkeyUser = new NKeyUser { Nkey = publicKey };
var auth = new NKeyAuthenticator([nkeyUser]);
var ctx = new ClientAuthContext
{
Opts = new ClientOptions { Nkey = publicKey, Sig = Convert.ToBase64String(new byte[64]) },
Nonce = nonce,
};
auth.Authenticate(ctx).ShouldBeNull();
}
[Fact]
public void Returns_null_for_unknown_nkey()
{
var kp = KeyPair.CreatePair(PrefixByte.User);
var publicKey = kp.GetPublicKey();
var nonce = "test-nonce-123"u8.ToArray();
var sigBase64 = SignNonce(kp, nonce);
var auth = new NKeyAuthenticator([]);
var ctx = new ClientAuthContext
{
Opts = new ClientOptions { Nkey = publicKey, Sig = sigBase64 },
Nonce = nonce,
};
auth.Authenticate(ctx).ShouldBeNull();
}
[Fact]
public void Returns_null_when_no_nkey_provided()
{
var kp = KeyPair.CreatePair(PrefixByte.User);
var publicKey = kp.GetPublicKey();
var nkeyUser = new NKeyUser { Nkey = publicKey };
var auth = new NKeyAuthenticator([nkeyUser]);
var ctx = new ClientAuthContext
{
Opts = new ClientOptions(),
Nonce = "nonce"u8.ToArray(),
};
auth.Authenticate(ctx).ShouldBeNull();
}
[Fact]
public void Returns_permissions_from_nkey_user()
{
var kp = KeyPair.CreatePair(PrefixByte.User);
var publicKey = kp.GetPublicKey();
var nonce = "test-nonce"u8.ToArray();
var sigBase64 = SignNonce(kp, nonce);
var perms = new Permissions
{
Publish = new SubjectPermission { Allow = ["foo.>"] },
};
var nkeyUser = new NKeyUser { Nkey = publicKey, Permissions = perms };
var auth = new NKeyAuthenticator([nkeyUser]);
var ctx = new ClientAuthContext
{
Opts = new ClientOptions { Nkey = publicKey, Sig = sigBase64 },
Nonce = nonce,
};
var result = auth.Authenticate(ctx);
result.ShouldNotBeNull();
result.Permissions.ShouldBe(perms);
}
}

View File

@@ -0,0 +1,82 @@
using System.Net;
using System.Net.Sockets;
using Microsoft.Extensions.Logging.Abstractions;
using NATS.Client.Core;
using NATS.NKeys;
using NATS.Server.Auth;
namespace NATS.Server.Tests;
public class NKeyIntegrationTests : IAsyncLifetime
{
private NatsServer _server = null!;
private int _port;
private readonly CancellationTokenSource _cts = new();
private Task _serverTask = null!;
private KeyPair _userKeyPair = null!;
private string _userSeed = null!;
private string _userPublicKey = null!;
private static int GetFreePort()
{
using var sock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
sock.Bind(new IPEndPoint(IPAddress.Loopback, 0));
return ((IPEndPoint)sock.LocalEndPoint!).Port;
}
public async Task InitializeAsync()
{
_port = GetFreePort();
_userKeyPair = KeyPair.CreatePair(PrefixByte.User);
_userPublicKey = _userKeyPair.GetPublicKey();
_userSeed = _userKeyPair.GetSeed();
_server = new NatsServer(new NatsOptions
{
Port = _port,
NKeys = [new NKeyUser { Nkey = _userPublicKey }],
}, NullLoggerFactory.Instance);
_serverTask = _server.StartAsync(_cts.Token);
await _server.WaitForReadyAsync();
}
public async Task DisposeAsync()
{
await _cts.CancelAsync();
_server.Dispose();
}
[Fact]
public async Task NKey_auth_success()
{
await using var client = new NatsConnection(new NatsOpts
{
Url = $"nats://127.0.0.1:{_port}",
AuthOpts = new NatsAuthOpts { NKey = _userPublicKey, Seed = _userSeed },
});
await client.ConnectAsync();
await client.PingAsync();
}
[Fact]
public async Task NKey_auth_wrong_key_fails()
{
// Generate a different key pair not known to the server
var otherKp = KeyPair.CreatePair(PrefixByte.User);
await using var client = new NatsConnection(new NatsOpts
{
Url = $"nats://127.0.0.1:{_port}",
AuthOpts = new NatsAuthOpts { NKey = otherKp.GetPublicKey(), Seed = otherKp.GetSeed() },
MaxReconnectRetry = 0,
});
await Should.ThrowAsync<NatsException>(async () =>
{
await client.ConnectAsync();
await client.PingAsync();
});
}
}

View File

@@ -0,0 +1,119 @@
using System.Net;
using System.Net.Sockets;
using Microsoft.Extensions.Logging.Abstractions;
using NATS.Client.Core;
using NATS.Server.Auth;
namespace NATS.Server.Tests;
public class PermissionIntegrationTests : IAsyncLifetime
{
private NatsServer _server = null!;
private int _port;
private readonly CancellationTokenSource _cts = new();
private Task _serverTask = null!;
private static int GetFreePort()
{
using var sock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
sock.Bind(new IPEndPoint(IPAddress.Loopback, 0));
return ((IPEndPoint)sock.LocalEndPoint!).Port;
}
public async Task InitializeAsync()
{
_port = GetFreePort();
_server = new NatsServer(new NatsOptions
{
Port = _port,
Users =
[
new User
{
Username = "publisher",
Password = "pass",
Permissions = new Permissions
{
Publish = new SubjectPermission { Allow = ["events.>"] },
Subscribe = new SubjectPermission { Deny = [">"] },
},
},
new User
{
Username = "subscriber",
Password = "pass",
Permissions = new Permissions
{
Publish = new SubjectPermission { Deny = [">"] },
Subscribe = new SubjectPermission { Allow = ["events.>"] },
},
},
new User
{
Username = "admin",
Password = "pass",
// No permissions — full access
},
],
}, NullLoggerFactory.Instance);
_serverTask = _server.StartAsync(_cts.Token);
await _server.WaitForReadyAsync();
}
public async Task DisposeAsync()
{
await _cts.CancelAsync();
_server.Dispose();
}
[Fact]
public async Task Publisher_can_publish_to_allowed_subject()
{
await using var pub = new NatsConnection(new NatsOpts
{
Url = $"nats://publisher:pass@127.0.0.1:{_port}",
});
await using var admin = new NatsConnection(new NatsOpts
{
Url = $"nats://admin:pass@127.0.0.1:{_port}",
});
await pub.ConnectAsync();
await admin.ConnectAsync();
await using var sub = await admin.SubscribeCoreAsync<string>("events.test");
await admin.PingAsync();
await pub.PublishAsync("events.test", "hello");
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var msg = await sub.Msgs.ReadAsync(timeout.Token);
msg.Data.ShouldBe("hello");
}
[Fact]
public async Task Admin_has_full_access()
{
await using var admin1 = new NatsConnection(new NatsOpts
{
Url = $"nats://admin:pass@127.0.0.1:{_port}",
});
await using var admin2 = new NatsConnection(new NatsOpts
{
Url = $"nats://admin:pass@127.0.0.1:{_port}",
});
await admin1.ConnectAsync();
await admin2.ConnectAsync();
await using var sub = await admin2.SubscribeCoreAsync<string>("anything.at.all");
await admin2.PingAsync();
await admin1.PublishAsync("anything.at.all", "data");
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var msg = await sub.Msgs.ReadAsync(timeout.Token);
msg.Data.ShouldBe("data");
}
}

View File

@@ -0,0 +1,116 @@
using NATS.Server.Auth;
using NATS.Server.Protocol;
namespace NATS.Server.Tests;
public class SimpleUserPasswordAuthenticatorTests
{
[Fact]
public void Returns_result_for_correct_credentials()
{
var auth = new SimpleUserPasswordAuthenticator("admin", "password123");
var ctx = new ClientAuthContext
{
Opts = new ClientOptions { Username = "admin", Password = "password123" },
Nonce = [],
};
var result = auth.Authenticate(ctx);
result.ShouldNotBeNull();
result.Identity.ShouldBe("admin");
}
[Fact]
public void Returns_null_for_wrong_username()
{
var auth = new SimpleUserPasswordAuthenticator("admin", "password123");
var ctx = new ClientAuthContext
{
Opts = new ClientOptions { Username = "wrong", Password = "password123" },
Nonce = [],
};
auth.Authenticate(ctx).ShouldBeNull();
}
[Fact]
public void Returns_null_for_wrong_password()
{
var auth = new SimpleUserPasswordAuthenticator("admin", "password123");
var ctx = new ClientAuthContext
{
Opts = new ClientOptions { Username = "admin", Password = "wrong" },
Nonce = [],
};
auth.Authenticate(ctx).ShouldBeNull();
}
[Fact]
public void Returns_null_for_null_username()
{
var auth = new SimpleUserPasswordAuthenticator("admin", "password123");
var ctx = new ClientAuthContext
{
Opts = new ClientOptions { Username = null, Password = "password123" },
Nonce = [],
};
auth.Authenticate(ctx).ShouldBeNull();
}
[Fact]
public void Returns_null_for_empty_username()
{
var auth = new SimpleUserPasswordAuthenticator("admin", "password123");
var ctx = new ClientAuthContext
{
Opts = new ClientOptions { Username = "", Password = "password123" },
Nonce = [],
};
auth.Authenticate(ctx).ShouldBeNull();
}
[Fact]
public void Returns_null_for_null_password()
{
var auth = new SimpleUserPasswordAuthenticator("admin", "password123");
var ctx = new ClientAuthContext
{
Opts = new ClientOptions { Username = "admin", Password = null },
Nonce = [],
};
auth.Authenticate(ctx).ShouldBeNull();
}
[Fact]
public void Supports_bcrypt_password()
{
var hash = BCrypt.Net.BCrypt.HashPassword("secret");
var auth = new SimpleUserPasswordAuthenticator("admin", hash);
var ctx = new ClientAuthContext
{
Opts = new ClientOptions { Username = "admin", Password = "secret" },
Nonce = [],
};
auth.Authenticate(ctx).ShouldNotBeNull();
}
[Fact]
public void Rejects_wrong_password_with_bcrypt()
{
var hash = BCrypt.Net.BCrypt.HashPassword("secret");
var auth = new SimpleUserPasswordAuthenticator("admin", hash);
var ctx = new ClientAuthContext
{
Opts = new ClientOptions { Username = "admin", Password = "wrongpassword" },
Nonce = [],
};
auth.Authenticate(ctx).ShouldBeNull();
}
}

View File

@@ -0,0 +1,62 @@
using NATS.Server.Auth;
using NATS.Server.Protocol;
namespace NATS.Server.Tests;
public class TokenAuthenticatorTests
{
[Fact]
public void Returns_result_for_correct_token()
{
var auth = new TokenAuthenticator("secret-token");
var ctx = new ClientAuthContext
{
Opts = new ClientOptions { Token = "secret-token" },
Nonce = [],
};
var result = auth.Authenticate(ctx);
result.ShouldNotBeNull();
result.Identity.ShouldBe("token");
}
[Fact]
public void Returns_null_for_wrong_token()
{
var auth = new TokenAuthenticator("secret-token");
var ctx = new ClientAuthContext
{
Opts = new ClientOptions { Token = "wrong-token" },
Nonce = [],
};
auth.Authenticate(ctx).ShouldBeNull();
}
[Fact]
public void Returns_null_when_no_token_provided()
{
var auth = new TokenAuthenticator("secret-token");
var ctx = new ClientAuthContext
{
Opts = new ClientOptions(),
Nonce = [],
};
auth.Authenticate(ctx).ShouldBeNull();
}
[Fact]
public void Returns_null_for_different_length_token()
{
var auth = new TokenAuthenticator("secret-token");
var ctx = new ClientAuthContext
{
Opts = new ClientOptions { Token = "short" },
Nonce = [],
};
auth.Authenticate(ctx).ShouldBeNull();
}
}

View File

@@ -0,0 +1,120 @@
using NATS.Server.Auth;
using NATS.Server.Protocol;
namespace NATS.Server.Tests;
public class UserPasswordAuthenticatorTests
{
private static UserPasswordAuthenticator CreateAuth(params User[] users)
{
return new UserPasswordAuthenticator(users);
}
[Fact]
public void Returns_result_for_correct_plain_password()
{
var auth = CreateAuth(new User { Username = "alice", Password = "secret" });
var ctx = new ClientAuthContext
{
Opts = new ClientOptions { Username = "alice", Password = "secret" },
Nonce = [],
};
var result = auth.Authenticate(ctx);
result.ShouldNotBeNull();
result.Identity.ShouldBe("alice");
}
[Fact]
public void Returns_result_for_correct_bcrypt_password()
{
var hash = BCrypt.Net.BCrypt.HashPassword("secret");
var auth = CreateAuth(new User { Username = "bob", Password = hash });
var ctx = new ClientAuthContext
{
Opts = new ClientOptions { Username = "bob", Password = "secret" },
Nonce = [],
};
var result = auth.Authenticate(ctx);
result.ShouldNotBeNull();
result.Identity.ShouldBe("bob");
}
[Fact]
public void Returns_null_for_wrong_password()
{
var auth = CreateAuth(new User { Username = "alice", Password = "secret" });
var ctx = new ClientAuthContext
{
Opts = new ClientOptions { Username = "alice", Password = "wrong" },
Nonce = [],
};
auth.Authenticate(ctx).ShouldBeNull();
}
[Fact]
public void Returns_null_for_unknown_user()
{
var auth = CreateAuth(new User { Username = "alice", Password = "secret" });
var ctx = new ClientAuthContext
{
Opts = new ClientOptions { Username = "unknown", Password = "secret" },
Nonce = [],
};
auth.Authenticate(ctx).ShouldBeNull();
}
[Fact]
public void Returns_null_when_no_username_provided()
{
var auth = CreateAuth(new User { Username = "alice", Password = "secret" });
var ctx = new ClientAuthContext
{
Opts = new ClientOptions(),
Nonce = [],
};
auth.Authenticate(ctx).ShouldBeNull();
}
[Fact]
public void Returns_permissions_from_user()
{
var perms = new Permissions
{
Publish = new SubjectPermission { Allow = ["foo.>"] },
};
var auth = CreateAuth(new User { Username = "alice", Password = "secret", Permissions = perms });
var ctx = new ClientAuthContext
{
Opts = new ClientOptions { Username = "alice", Password = "secret" },
Nonce = [],
};
var result = auth.Authenticate(ctx);
result.ShouldNotBeNull();
result.Permissions.ShouldBe(perms);
}
[Fact]
public void Returns_account_name_from_user()
{
var auth = CreateAuth(new User { Username = "alice", Password = "secret", Account = "myaccount" });
var ctx = new ClientAuthContext
{
Opts = new ClientOptions { Username = "alice", Password = "secret" },
Nonce = [],
};
var result = auth.Authenticate(ctx);
result.ShouldNotBeNull();
result.AccountName.ShouldBe("myaccount");
}
}