Compare commits
14 Commits
1813250a9e
...
a6e9bd1467
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a6e9bd1467 | ||
|
|
543b185f7e | ||
|
|
c40c2cd994 | ||
|
|
9cb3e2fe0f | ||
|
|
2980a343c1 | ||
|
|
2a2cc6f0a2 | ||
|
|
6ebe791c6d | ||
|
|
562f89744d | ||
|
|
0cce771907 | ||
|
|
5305069dd8 | ||
|
|
11dc5e62f3 | ||
|
|
91aff1a867 | ||
|
|
1c8cc43fb4 | ||
|
|
bca703b310 |
@@ -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" />
|
||||
|
||||
286
docs/plans/2026-02-22-authentication-design.md
Normal file
286
docs/plans/2026-02-22-authentication-design.md
Normal 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
|
||||
2684
docs/plans/2026-02-22-authentication-plan.md
Normal file
2684
docs/plans/2026-02-22-authentication-plan.md
Normal file
File diff suppressed because it is too large
Load Diff
21
docs/plans/2026-02-22-authentication-plan.md.tasks.json
Normal file
21
docs/plans/2026-02-22-authentication-plan.md.tasks.json
Normal 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"
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
28
src/NATS.Server/Auth/Account.cs
Normal file
28
src/NATS.Server/Auth/Account.cs
Normal 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();
|
||||
}
|
||||
9
src/NATS.Server/Auth/AuthResult.cs
Normal file
9
src/NATS.Server/Auth/AuthResult.cs
Normal 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; }
|
||||
}
|
||||
131
src/NATS.Server/Auth/AuthService.cs
Normal file
131
src/NATS.Server/Auth/AuthService.cs
Normal 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('/', '_');
|
||||
}
|
||||
}
|
||||
121
src/NATS.Server/Auth/ClientPermissions.cs
Normal file
121
src/NATS.Server/Auth/ClientPermissions.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
14
src/NATS.Server/Auth/IAuthenticator.cs
Normal file
14
src/NATS.Server/Auth/IAuthenticator.cs
Normal 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; }
|
||||
}
|
||||
66
src/NATS.Server/Auth/NKeyAuthenticator.cs
Normal file
66
src/NATS.Server/Auth/NKeyAuthenticator.cs
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
9
src/NATS.Server/Auth/NKeyUser.cs
Normal file
9
src/NATS.Server/Auth/NKeyUser.cs
Normal 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; }
|
||||
}
|
||||
20
src/NATS.Server/Auth/Permissions.cs
Normal file
20
src/NATS.Server/Auth/Permissions.cs
Normal 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; }
|
||||
}
|
||||
61
src/NATS.Server/Auth/SimpleUserPasswordAuthenticator.cs
Normal file
61
src/NATS.Server/Auth/SimpleUserPasswordAuthenticator.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
28
src/NATS.Server/Auth/TokenAuthenticator.cs
Normal file
28
src/NATS.Server/Auth/TokenAuthenticator.cs
Normal 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" };
|
||||
}
|
||||
}
|
||||
10
src/NATS.Server/Auth/User.cs
Normal file
10
src/NATS.Server/Auth/User.cs
Normal 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; }
|
||||
}
|
||||
66
src/NATS.Server/Auth/UserPasswordAuthenticator.cs
Normal file
66
src/NATS.Server/Auth/UserPasswordAuthenticator.cs
Normal 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");
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
106
tests/NATS.Server.Tests/AccountIsolationTests.cs
Normal file
106
tests/NATS.Server.Tests/AccountIsolationTests.cs
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
35
tests/NATS.Server.Tests/AccountTests.cs
Normal file
35
tests/NATS.Server.Tests/AccountTests.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
21
tests/NATS.Server.Tests/AuthConfigTests.cs
Normal file
21
tests/NATS.Server.Tests/AuthConfigTests.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
256
tests/NATS.Server.Tests/AuthIntegrationTests.cs
Normal file
256
tests/NATS.Server.Tests/AuthIntegrationTests.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
56
tests/NATS.Server.Tests/AuthProtocolTests.cs
Normal file
56
tests/NATS.Server.Tests/AuthProtocolTests.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
172
tests/NATS.Server.Tests/AuthServiceTests.cs
Normal file
172
tests/NATS.Server.Tests/AuthServiceTests.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
107
tests/NATS.Server.Tests/ClientPermissionsTests.cs
Normal file
107
tests/NATS.Server.Tests/ClientPermissionsTests.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
<PackageReference Include="Shouldly" />
|
||||
<PackageReference Include="xunit" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" />
|
||||
<PackageReference Include="NATS.NKeys" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
130
tests/NATS.Server.Tests/NKeyAuthenticatorTests.cs
Normal file
130
tests/NATS.Server.Tests/NKeyAuthenticatorTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
82
tests/NATS.Server.Tests/NKeyIntegrationTests.cs
Normal file
82
tests/NATS.Server.Tests/NKeyIntegrationTests.cs
Normal 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();
|
||||
});
|
||||
}
|
||||
}
|
||||
119
tests/NATS.Server.Tests/PermissionIntegrationTests.cs
Normal file
119
tests/NATS.Server.Tests/PermissionIntegrationTests.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
116
tests/NATS.Server.Tests/SimpleUserPasswordAuthenticatorTests.cs
Normal file
116
tests/NATS.Server.Tests/SimpleUserPasswordAuthenticatorTests.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
62
tests/NATS.Server.Tests/TokenAuthenticatorTests.cs
Normal file
62
tests/NATS.Server.Tests/TokenAuthenticatorTests.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
120
tests/NATS.Server.Tests/UserPasswordAuthenticatorTests.cs
Normal file
120
tests/NATS.Server.Tests/UserPasswordAuthenticatorTests.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user