Files
natsdotnet/docs/plans/2026-02-22-authentication-plan.md
Joseph Doherty 1c8cc43fb4 docs: add authentication implementation plan with 15 TDD tasks
Covers NuGet packages, protocol types, auth models, authenticators
(token, user/password, NKey), AuthService orchestrator, permissions,
server/client integration, account isolation, and integration tests.
2026-02-22 22:15:48 -05:00

2685 lines
77 KiB
Markdown

# Authentication Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task.
**Goal:** Add username/password, token, and NKey authentication with per-user permissions and core account isolation to the .NET NATS server.
**Architecture:** Strategy pattern — `IAuthenticator` interface with concrete implementations per mechanism, orchestrated by `AuthService` in priority order. Per-account `SubList` isolation. Permission checks on PUB/SUB hot paths.
**Tech Stack:** .NET 10, NATS.NKeys (Ed25519), BCrypt.Net-Next (password hashing), xUnit + Shouldly (tests)
**Design doc:** `docs/plans/2026-02-22-authentication-design.md`
---
### Task 0: Add NuGet packages and update project references
**Files:**
- Modify: `Directory.Packages.props`
- Modify: `src/NATS.Server/NATS.Server.csproj`
- Modify: `tests/NATS.Server.Tests/NATS.Server.Tests.csproj`
**Step 1: Add package versions to Directory.Packages.props**
Add these entries to the `<ItemGroup>` in `Directory.Packages.props`, after the Logging section:
```xml
<!-- Authentication -->
<PackageVersion Include="NATS.NKeys" Version="1.0.0-preview.3" />
<PackageVersion Include="BCrypt.Net-Next" Version="4.0.3" />
```
**Step 2: Add package references to NATS.Server.csproj**
Add to the `<ItemGroup>` in `src/NATS.Server/NATS.Server.csproj`:
```xml
<PackageReference Include="NATS.NKeys" />
<PackageReference Include="BCrypt.Net-Next" />
```
**Step 3: Add NATS.NKeys to test project**
Add to the `<ItemGroup>` (package references) in `tests/NATS.Server.Tests/NATS.Server.Tests.csproj`:
```xml
<PackageReference Include="NATS.NKeys" />
```
**Step 4: Verify build**
Run: `dotnet build`
Expected: Build succeeds with no errors.
**Step 5: Commit**
```bash
git add Directory.Packages.props src/NATS.Server/NATS.Server.csproj tests/NATS.Server.Tests/NATS.Server.Tests.csproj
git commit -m "chore: add NATS.NKeys and BCrypt.Net-Next packages for authentication"
```
---
### Task 1: Add auth fields to protocol types (ServerInfo, ClientOptions, error constants)
**Files:**
- Modify: `src/NATS.Server/Protocol/NatsProtocol.cs:23-28` (add error constants)
- Modify: `src/NATS.Server/Protocol/NatsProtocol.cs:31-64` (ServerInfo — add AuthRequired, Nonce)
- Modify: `src/NATS.Server/Protocol/NatsProtocol.cs:66-94` (ClientOptions — add auth credential fields)
**Step 1: Write a failing test for auth fields in ClientOptions**
Create `tests/NATS.Server.Tests/AuthProtocolTests.cs`:
```csharp
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");
}
}
```
**Step 2: Run test to verify it fails**
Run: `dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~AuthProtocolTests" -v normal`
Expected: FAIL — `Username`, `Password`, `Token`, `Nkey`, `Sig` properties don't exist on `ClientOptions`. `AuthRequired`, `Nonce` don't exist on `ServerInfo`.
**Step 3: Add error constants to NatsProtocol**
In `src/NATS.Server/Protocol/NatsProtocol.cs`, add after line 28 (`ErrInvalidSubject`):
```csharp
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";
```
**Step 4: Add auth fields to ServerInfo**
In `src/NATS.Server/Protocol/NatsProtocol.cs`, add after the `ClientIp` property (line 63):
```csharp
[JsonPropertyName("auth_required")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public bool AuthRequired { get; set; }
[JsonPropertyName("nonce")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Nonce { get; set; }
```
**Step 5: Add auth fields to ClientOptions**
In `src/NATS.Server/Protocol/NatsProtocol.cs`, add after the `NoResponders` property (line 93):
```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; }
```
**Step 6: Run tests to verify they pass**
Run: `dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~AuthProtocolTests" -v normal`
Expected: All 3 tests PASS.
**Step 7: Run full test suite for regressions**
Run: `dotnet test`
Expected: All existing tests still pass.
**Step 8: Commit**
```bash
git add src/NATS.Server/Protocol/NatsProtocol.cs tests/NATS.Server.Tests/AuthProtocolTests.cs
git commit -m "feat: add auth fields to ServerInfo and ClientOptions protocol types"
```
---
### Task 2: Add auth configuration to NatsOptions
**Files:**
- Modify: `src/NATS.Server/NatsOptions.cs`
**Step 1: Write failing test**
Add to `tests/NATS.Server.Tests/AuthProtocolTests.cs`:
```csharp
using NATS.Server;
// ... add this test class to the same file or a new AuthConfigTests.cs ...
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(1));
}
}
```
**Step 2: Run test to verify it fails**
Run: `dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~AuthConfigTests" -v normal`
Expected: FAIL — properties don't exist.
**Step 3: Create auth model types and update NatsOptions**
Create `src/NATS.Server/Auth/User.cs`:
```csharp
using NATS.Server.Auth;
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; }
}
```
Create `src/NATS.Server/Auth/NKeyUser.cs`:
```csharp
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; }
}
```
Create `src/NATS.Server/Auth/Permissions.cs`:
```csharp
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; }
}
```
Update `src/NATS.Server/NatsOptions.cs` to add auth fields:
```csharp
using NATS.Server.Auth;
namespace NATS.Server;
public sealed class NatsOptions
{
public string Host { get; set; } = "0.0.0.0";
public int Port { get; set; } = 4222;
public string? ServerName { get; set; }
public int MaxPayload { get; set; } = 1024 * 1024; // 1MB
public int MaxControlLine { get; set; } = 4096;
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(1);
}
```
**Step 4: Run tests to verify they pass**
Run: `dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~AuthConfigTests" -v normal`
Expected: PASS.
**Step 5: Run full test suite**
Run: `dotnet test`
Expected: All tests pass.
**Step 6: Commit**
```bash
git add src/NATS.Server/Auth/ src/NATS.Server/NatsOptions.cs tests/NATS.Server.Tests/AuthProtocolTests.cs
git commit -m "feat: add auth model types (User, NKeyUser, Permissions) and auth config to NatsOptions"
```
---
### Task 3: Implement Account type with per-account SubList
**Files:**
- Create: `src/NATS.Server/Auth/Account.cs`
- Test: `tests/NATS.Server.Tests/AccountTests.cs`
**Step 1: Write failing test**
Create `tests/NATS.Server.Tests/AccountTests.cs`:
```csharp
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");
}
}
```
**Step 2: Run test to verify it fails**
Run: `dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~AccountTests" -v normal`
Expected: FAIL — `Account` class doesn't exist.
**Step 3: Implement Account**
Create `src/NATS.Server/Auth/Account.cs`:
```csharp
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();
}
```
**Step 4: Run tests to verify they pass**
Run: `dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~AccountTests" -v normal`
Expected: All 3 tests PASS.
**Step 5: Commit**
```bash
git add src/NATS.Server/Auth/Account.cs tests/NATS.Server.Tests/AccountTests.cs
git commit -m "feat: add Account type with per-account SubList and client tracking"
```
---
### Task 4: Implement TokenAuthenticator
**Files:**
- Create: `src/NATS.Server/Auth/IAuthenticator.cs`
- Create: `src/NATS.Server/Auth/AuthResult.cs`
- Create: `src/NATS.Server/Auth/TokenAuthenticator.cs`
- Test: `tests/NATS.Server.Tests/TokenAuthenticatorTests.cs`
**Step 1: Write failing test**
Create `tests/NATS.Server.Tests/TokenAuthenticatorTests.cs`:
```csharp
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();
}
}
```
**Step 2: Run test to verify it fails**
Run: `dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~TokenAuthenticatorTests" -v normal`
Expected: FAIL — types don't exist.
**Step 3: Implement interfaces and TokenAuthenticator**
Create `src/NATS.Server/Auth/IAuthenticator.cs`:
```csharp
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; }
}
```
Create `src/NATS.Server/Auth/AuthResult.cs`:
```csharp
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; }
}
```
Create `src/NATS.Server/Auth/TokenAuthenticator.cs`:
```csharp
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" };
}
}
```
**Step 4: Run tests to verify they pass**
Run: `dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~TokenAuthenticatorTests" -v normal`
Expected: All 4 tests PASS.
**Step 5: Commit**
```bash
git add src/NATS.Server/Auth/IAuthenticator.cs src/NATS.Server/Auth/AuthResult.cs src/NATS.Server/Auth/TokenAuthenticator.cs tests/NATS.Server.Tests/TokenAuthenticatorTests.cs
git commit -m "feat: add IAuthenticator interface and TokenAuthenticator with constant-time comparison"
```
---
### Task 5: Implement UserPasswordAuthenticator (plain + bcrypt)
**Files:**
- Create: `src/NATS.Server/Auth/UserPasswordAuthenticator.cs`
- Test: `tests/NATS.Server.Tests/UserPasswordAuthenticatorTests.cs`
**Step 1: Write failing test**
Create `tests/NATS.Server.Tests/UserPasswordAuthenticatorTests.cs`:
```csharp
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()
{
// Pre-computed bcrypt hash of "secret"
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");
}
}
```
**Step 2: Run test to verify it fails**
Run: `dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~UserPasswordAuthenticatorTests" -v normal`
Expected: FAIL — `UserPasswordAuthenticator` doesn't exist.
**Step 3: Implement UserPasswordAuthenticator**
Create `src/NATS.Server/Auth/UserPasswordAuthenticator.cs`:
```csharp
using System.Security.Cryptography;
using System.Text;
namespace NATS.Server.Auth;
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");
}
```
**Step 4: Run tests to verify they pass**
Run: `dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~UserPasswordAuthenticatorTests" -v normal`
Expected: All 7 tests PASS.
**Step 5: Commit**
```bash
git add src/NATS.Server/Auth/UserPasswordAuthenticator.cs tests/NATS.Server.Tests/UserPasswordAuthenticatorTests.cs
git commit -m "feat: add UserPasswordAuthenticator with plain and bcrypt password support"
```
---
### Task 6: Implement SimpleUserPasswordAuthenticator
**Files:**
- Create: `src/NATS.Server/Auth/SimpleUserPasswordAuthenticator.cs`
- Test: `tests/NATS.Server.Tests/SimpleUserPasswordAuthenticatorTests.cs`
This handles the single `username`/`password` config option (no user map lookup).
**Step 1: Write failing test**
Create `tests/NATS.Server.Tests/SimpleUserPasswordAuthenticatorTests.cs`:
```csharp
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 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();
}
}
```
**Step 2: Run test to verify it fails**
Run: `dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~SimpleUserPasswordAuthenticatorTests" -v normal`
Expected: FAIL — type doesn't exist.
**Step 3: Implement SimpleUserPasswordAuthenticator**
Create `src/NATS.Server/Auth/SimpleUserPasswordAuthenticator.cs`:
```csharp
using System.Security.Cryptography;
using System.Text;
namespace NATS.Server.Auth;
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)
{
if (serverPassword.StartsWith("$2"))
{
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);
}
}
```
**Step 4: Run tests to verify they pass**
Run: `dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~SimpleUserPasswordAuthenticatorTests" -v normal`
Expected: All 4 tests PASS.
**Step 5: Commit**
```bash
git add src/NATS.Server/Auth/SimpleUserPasswordAuthenticator.cs tests/NATS.Server.Tests/SimpleUserPasswordAuthenticatorTests.cs
git commit -m "feat: add SimpleUserPasswordAuthenticator for single username/password config"
```
---
### Task 7: Implement NKeyAuthenticator
**Files:**
- Create: `src/NATS.Server/Auth/NKeyAuthenticator.cs`
- Test: `tests/NATS.Server.Tests/NKeyAuthenticatorTests.cs`
**Step 1: Write failing test**
Create `tests/NATS.Server.Tests/NKeyAuthenticatorTests.cs`:
```csharp
using NATS.NKeys;
using NATS.Server.Auth;
using NATS.Server.Protocol;
namespace NATS.Server.Tests;
public class NKeyAuthenticatorTests
{
[Fact]
public void Returns_result_for_valid_signature()
{
// Generate a test keypair
var kp = KeyPair.CreateUser();
var publicKey = kp.GetPublicKey();
var nonce = "test-nonce-123"u8.ToArray();
var sig = kp.Sign(nonce);
var sigBase64 = Convert.ToBase64URL(sig);
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.CreateUser();
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.CreateUser();
var publicKey = kp.GetPublicKey();
var nonce = "test-nonce-123"u8.ToArray();
var sig = kp.Sign(nonce);
var sigBase64 = Convert.ToBase64URL(sig);
// Server doesn't know about this key
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.CreateUser();
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.CreateUser();
var publicKey = kp.GetPublicKey();
var nonce = "test-nonce"u8.ToArray();
var sig = kp.Sign(nonce);
var sigBase64 = Convert.ToBase64URL(sig);
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);
}
}
```
**Step 2: Run test to verify it fails**
Run: `dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~NKeyAuthenticatorTests" -v normal`
Expected: FAIL — `NKeyAuthenticator` doesn't exist.
**Step 3: Implement NKeyAuthenticator**
Create `src/NATS.Server/Auth/NKeyAuthenticator.cs`:
```csharp
using NATS.NKeys;
namespace NATS.Server.Auth;
public sealed class NKeyAuthenticator : IAuthenticator
{
private readonly Dictionary<string, NKeyUser> _nkeys;
public NKeyAuthenticator(IEnumerable<NKeyUser> nkeyUsers)
{
_nkeys = new Dictionary<string, NKeyUser>(StringComparer.Ordinal);
foreach (var nkeyUser in nkeyUsers)
_nkeys[nkeyUser.Nkey] = nkeyUser;
}
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
{
var sigBytes = Convert.FromBase64String(
clientSig.Replace('-', '+').Replace('_', '/').PadRight(
clientSig.Length + (4 - clientSig.Length % 4) % 4, '='));
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,
};
}
}
```
> **Note for implementer:** The `Convert.FromBase64String` call with replacements handles both standard and URL-safe base64. The NATS client may send either encoding. If `Convert.ToBase64URL` is available in the test (it is in .NET 10), prefer that for the test side. The server must accept both.
**Step 4: Run tests to verify they pass**
Run: `dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~NKeyAuthenticatorTests" -v normal`
Expected: All 5 tests PASS.
**Step 5: Commit**
```bash
git add src/NATS.Server/Auth/NKeyAuthenticator.cs tests/NATS.Server.Tests/NKeyAuthenticatorTests.cs
git commit -m "feat: add NKeyAuthenticator with Ed25519 nonce signature verification"
```
---
### Task 8: Implement AuthService (orchestrator)
**Files:**
- Create: `src/NATS.Server/Auth/AuthService.cs`
- Test: `tests/NATS.Server.Tests/AuthServiceTests.cs`
**Step 1: Write failing test**
Create `tests/NATS.Server.Tests/AuthServiceTests.cs`:
```csharp
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_returns_null_when_no_auth_and_creds_provided()
{
// No auth configured but client sends credentials — should still succeed (no auth required)
var service = AuthService.Build(new NatsOptions());
var ctx = new ClientAuthContext
{
Opts = new ClientOptions { Token = "anything" },
Nonce = [],
};
// When auth is not required, Authenticate returns a default result
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(), // No credentials
Nonce = [],
};
var result = service.Authenticate(ctx);
result.ShouldNotBeNull();
result.Identity.ShouldBe("default");
}
[Fact]
public void NKeys_tried_before_users()
{
// If both NKeys and Users are configured, NKeys should take priority
var opts = new NatsOptions
{
NKeys = [new NKeyUser { Nkey = "UABC" }],
Users = [new User { Username = "alice", Password = "secret" }],
};
var service = AuthService.Build(opts);
// Provide user credentials — should still try NKey first (and fail for NKey),
// then fall through to user auth
var ctx = new ClientAuthContext
{
Opts = new ClientOptions { Username = "alice", Password = "secret" },
Nonce = [],
};
var result = service.Authenticate(ctx);
result.ShouldNotBeNull();
result.Identity.ShouldBe("alice");
}
}
```
**Step 2: Run test to verify it fails**
Run: `dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~AuthServiceTests" -v normal`
Expected: FAIL — `AuthService` doesn't exist.
**Step 3: Implement AuthService**
Create `src/NATS.Server/Auth/AuthService.cs`:
```csharp
using System.Security.Cryptography;
namespace NATS.Server.Auth;
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>();
bool authRequired = false;
bool 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 };
// Try each authenticator in priority order
foreach (var authenticator in _authenticators)
{
var result = authenticator.Authenticate(context);
if (result != null)
return result;
}
// NoAuthUser fallback: if client provided no credentials and NoAuthUser is set
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('/', '_');
}
}
```
**Step 4: Run tests to verify they pass**
Run: `dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~AuthServiceTests" -v normal`
Expected: All 12 tests PASS.
**Step 5: Run full test suite**
Run: `dotnet test`
Expected: All tests pass.
**Step 6: Commit**
```bash
git add src/NATS.Server/Auth/AuthService.cs tests/NATS.Server.Tests/AuthServiceTests.cs
git commit -m "feat: add AuthService orchestrator with priority-ordered authentication"
```
---
### Task 9: Implement ClientPermissions (publish/subscribe authorization)
**Files:**
- Create: `src/NATS.Server/Auth/ClientPermissions.cs`
- Test: `tests/NATS.Server.Tests/ClientPermissionsTests.cs`
**Step 1: Write failing test**
Create `tests/NATS.Server.Tests/ClientPermissionsTests.cs`:
```csharp
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(); // null means no restrictions
}
[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();
// First call populates cache
perms.IsPublishAllowed("foo.bar").ShouldBeTrue();
// Second call should use cache — same result
perms.IsPublishAllowed("foo.bar").ShouldBeTrue();
perms.IsPublishAllowed("baz.bar").ShouldBeFalse();
perms.IsPublishAllowed("baz.bar").ShouldBeFalse();
}
[Fact]
public void Empty_permissions_object_allows_everything()
{
// Permissions object exists but has no publish or subscribe restrictions
var perms = ClientPermissions.Build(new Permissions());
perms.ShouldBeNull();
}
}
```
**Step 2: Run test to verify it fails**
Run: `dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~ClientPermissionsTests" -v normal`
Expected: FAIL — `ClientPermissions` doesn't exist.
**Step 3: Implement ClientPermissions**
Create `src/NATS.Server/Auth/ClientPermissions.cs`:
```csharp
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, s => _publish.IsAllowed(s));
}
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 list exists, subject must match it
if (_allow != null)
{
var result = _allow.Match(subject);
allowed = result.PlainSubs.Length > 0 || result.QueueSubs.Length > 0;
}
// If deny list exists, subject must NOT match it
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();
}
}
```
**Step 4: Run tests to verify they pass**
Run: `dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~ClientPermissionsTests" -v normal`
Expected: All 8 tests PASS.
**Step 5: Commit**
```bash
git add src/NATS.Server/Auth/ClientPermissions.cs tests/NATS.Server.Tests/ClientPermissionsTests.cs
git commit -m "feat: add ClientPermissions with SubList-based publish/subscribe authorization"
```
---
### Task 10: Integrate auth into NatsServer and NatsClient
This is the largest task — wire everything together. It modifies the server accept loop, client lifecycle, and message routing.
**Files:**
- Modify: `src/NATS.Server/NatsServer.cs` (add AuthService, accounts, per-client INFO)
- Modify: `src/NATS.Server/NatsClient.cs` (auth validation in ProcessConnect, permission checks in ProcessSub/ProcessPub, auth timeout, account assignment)
**Step 1: Write failing integration test**
Create `tests/NATS.Server.Tests/AuthIntegrationTests.cs`:
```csharp
using System.Net;
using System.Net.Sockets;
using System.Text;
using Microsoft.Extensions.Logging.Abstractions;
using NATS.Client.Core;
using NATS.Server;
using NATS.Server.Auth;
namespace NATS.Server.Tests;
public class AuthIntegrationTests : IAsyncLifetime
{
private NatsServer? _server;
private int _port;
private 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;
}
private void StartServer(NatsOptions opts)
{
_port = GetFreePort();
opts.Port = _port;
_server = new NatsServer(opts, NullLoggerFactory.Instance);
}
public async Task InitializeAsync()
{
// Default server — tests will create their own via StartServer
}
public async Task DisposeAsync()
{
await _cts.CancelAsync();
_server?.Dispose();
}
private async Task StartAndWaitAsync()
{
_serverTask = _server!.StartAsync(_cts.Token);
await _server.WaitForReadyAsync();
}
private NatsConnection CreateClient(string? user = null, string? pass = null, string? token = null)
{
var url = $"nats://";
if (user != null && pass != null)
url = $"nats://{user}:{pass}@";
else if (token != null)
url = $"nats://token@"; // NATS client uses this format for token
var opts = new NatsOpts
{
Url = $"{url}127.0.0.1:{_port}",
AuthOpts = token != null ? new NatsAuthOpts { Token = token } : default,
};
return new NatsConnection(opts);
}
[Fact]
public async Task Token_auth_success()
{
StartServer(new NatsOptions { Authorization = "mytoken" });
await StartAndWaitAsync();
await using var client = new NatsConnection(new NatsOpts
{
Url = $"nats://127.0.0.1:{_port}",
AuthOpts = new NatsAuthOpts { Token = "mytoken" },
});
await client.ConnectAsync();
await client.PingAsync();
}
[Fact]
public async Task Token_auth_failure_disconnects()
{
StartServer(new NatsOptions { Authorization = "mytoken" });
await StartAndWaitAsync();
await using var client = new NatsConnection(new NatsOpts
{
Url = $"nats://127.0.0.1:{_port}",
AuthOpts = new NatsAuthOpts { Token = "wrong" },
});
var ex = await Should.ThrowAsync<Exception>(async () =>
{
await client.ConnectAsync();
await client.PingAsync();
});
}
[Fact]
public async Task UserPassword_auth_success()
{
StartServer(new NatsOptions { Username = "admin", Password = "secret" });
await StartAndWaitAsync();
await using var client = new NatsConnection(new NatsOpts
{
Url = $"nats://admin:secret@127.0.0.1:{_port}",
});
await client.ConnectAsync();
await client.PingAsync();
}
[Fact]
public async Task UserPassword_auth_failure_disconnects()
{
StartServer(new NatsOptions { Username = "admin", Password = "secret" });
await StartAndWaitAsync();
await using var client = new NatsConnection(new NatsOpts
{
Url = $"nats://admin:wrong@127.0.0.1:{_port}",
});
var ex = await Should.ThrowAsync<Exception>(async () =>
{
await client.ConnectAsync();
await client.PingAsync();
});
}
[Fact]
public async Task MultiUser_auth_success()
{
StartServer(new NatsOptions
{
Users = [
new User { Username = "alice", Password = "pass1" },
new User { Username = "bob", Password = "pass2" },
],
});
await StartAndWaitAsync();
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();
}
[Fact]
public async Task No_credentials_when_auth_required_disconnects()
{
StartServer(new NatsOptions { Authorization = "mytoken" });
await StartAndWaitAsync();
await using var client = new NatsConnection(new NatsOpts
{
Url = $"nats://127.0.0.1:{_port}",
});
var ex = await Should.ThrowAsync<Exception>(async () =>
{
await client.ConnectAsync();
await client.PingAsync();
});
}
[Fact]
public async Task No_auth_configured_allows_all()
{
StartServer(new NatsOptions());
await StartAndWaitAsync();
await using var client = new NatsConnection(new NatsOpts
{
Url = $"nats://127.0.0.1:{_port}",
});
await client.ConnectAsync();
await client.PingAsync();
}
}
```
**Step 2: Run test to verify it fails**
Run: `dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~AuthIntegrationTests" -v normal`
Expected: FAIL — auth tests fail because server doesn't enforce authentication.
**Step 3: Modify NatsServer to integrate AuthService**
In `src/NATS.Server/NatsServer.cs`, apply these changes:
1. Add field: `private readonly AuthService _authService;`
2. Add field: `private readonly ConcurrentDictionary<string, Account> _accounts = new();`
3. Add field: `private Account _globalAccount;`
4. In constructor, after `_serverInfo` creation:
```csharp
_authService = AuthService.Build(options);
_globalAccount = new Account(Account.GlobalAccountName);
_accounts[_globalAccount.Name] = _globalAccount;
```
5. In accept loop, when creating client — create per-client `ServerInfo` with nonce:
```csharp
var clientInfo = CreateClientInfo(clientId, socket);
var client = new NatsClient(clientId, socket, _options, clientInfo, _authService, this, clientLogger);
```
6. Add method `CreateClientInfo`:
```csharp
private ServerInfo CreateClientInfo(ulong clientId, Socket socket)
{
var info = new ServerInfo
{
ServerId = _serverInfo.ServerId,
ServerName = _serverInfo.ServerName,
Version = _serverInfo.Version,
Host = _serverInfo.Host,
Port = _serverInfo.Port,
MaxPayload = _serverInfo.MaxPayload,
ClientId = clientId,
ClientIp = (socket.RemoteEndPoint as IPEndPoint)?.Address.ToString(),
AuthRequired = _authService.IsAuthRequired,
};
if (_authService.NonceRequired)
{
var nonce = _authService.GenerateNonce();
info.Nonce = _authService.EncodeNonce(nonce);
// Store raw nonce bytes for signature verification — passed to NatsClient
}
return info;
}
```
7. Change `SubList` property to use the request's account SubList or the global account SubList.
8. Update `ProcessMessage` to use the sender's account's SubList instead of `_subList`. The server-level `_subList` field is replaced by per-account SubLists.
**Step 4: Modify NatsClient to enforce authentication**
In `src/NATS.Server/NatsClient.cs`, apply these changes:
1. Add fields:
```csharp
private readonly AuthService _authService;
private Account? _account;
private ClientPermissions? _permissions;
private byte[]? _nonce;
```
2. Update constructor to accept `AuthService` and store nonce.
3. In `RunAsync`, after sending INFO, add auth timeout:
```csharp
if (_authService.IsAuthRequired)
{
using var authTimeout = new CancellationTokenSource(_options.AuthTimeout);
using var combined = CancellationTokenSource.CreateLinkedTokenSource(
_clientCts.Token, authTimeout.Token);
// Wait for CONNECT to be processed with auth
// ...
}
```
4. In `ProcessConnect`, after deserializing `ClientOpts`, call auth:
```csharp
if (_authService.IsAuthRequired)
{
var ctx = new ClientAuthContext
{
Opts = ClientOpts,
Nonce = _nonce ?? [],
};
var result = _authService.Authenticate(ctx);
if (result == null)
{
await SendErrAndCloseAsync(NatsProtocol.ErrAuthorizationViolation);
return;
}
_permissions = ClientPermissions.Build(result.Permissions);
// Assign account
}
```
5. In `ProcessSub`, add permission check before insert:
```csharp
if (_permissions != null && !_permissions.IsSubscribeAllowed(cmd.Subject!, cmd.Queue))
{
await SendErrAsync($"{NatsProtocol.ErrPermissionsSubscribe} to \"{cmd.Subject}\"");
return;
}
```
6. In `ProcessPubAsync`, add permission check before routing:
```csharp
if (_permissions != null && !_permissions.IsPublishAllowed(cmd.Subject!))
{
await SendErrAsync($"{NatsProtocol.ErrPermissionsPublish} to \"{cmd.Subject}\"");
return;
}
```
> **Note for implementer:** This is the most complex integration task. The exact modifications depend on how the auth timeout interleaving works. The key principle is: ProcessConnect becomes async (to send -ERR), returns a success/failure signal, and RunAsync gates all further command processing on auth success. One clean approach is to add a `TaskCompletionSource<bool> _authCompleted` that ProcessConnect sets. RunAsync waits on it (with timeout) before starting the main command loop.
**Step 5: Run tests to verify they pass**
Run: `dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~AuthIntegrationTests" -v normal`
Expected: All 7 tests PASS.
**Step 6: Run full test suite to check for regressions**
Run: `dotnet test`
Expected: All tests pass. Existing tests that don't use auth should still work because when no auth is configured, `AuthService.IsAuthRequired` is `false` and auth is bypassed.
**Step 7: Commit**
```bash
git add src/NATS.Server/NatsServer.cs src/NATS.Server/NatsClient.cs tests/NATS.Server.Tests/AuthIntegrationTests.cs
git commit -m "feat: integrate authentication into server accept loop and client CONNECT processing"
```
---
### Task 11: Implement account isolation in message routing
**Files:**
- Modify: `src/NATS.Server/NatsServer.cs` (ProcessMessage uses account SubList)
- Modify: `src/NATS.Server/NatsClient.cs` (subscriptions go to account SubList)
- Test: `tests/NATS.Server.Tests/AccountIsolationTests.cs`
**Step 1: Write failing test**
Create `tests/NATS.Server.Tests/AccountIsolationTests.cs`:
```csharp
using System.Net;
using System.Net.Sockets;
using System.Text;
using Microsoft.Extensions.Logging.Abstractions;
using NATS.Client.Core;
using NATS.Server;
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)
}
}
}
```
**Step 2: Run test to verify it fails**
Run: `dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~AccountIsolationTests" -v normal`
Expected: FAIL — account isolation not implemented.
**Step 3: Implement account-aware routing**
The key changes (building on Task 10):
1. In `NatsServer`, add account resolution:
```csharp
public Account GetOrCreateAccount(string? name)
{
if (string.IsNullOrEmpty(name))
return _globalAccount;
return _accounts.GetOrAdd(name, n => new Account(n));
}
```
2. In `NatsClient`, after auth success, resolve account:
```csharp
_account = _server.GetOrCreateAccount(result.AccountName);
_account.AddClient(Id);
```
3. In `NatsClient.ProcessSub`, insert subscription into account's SubList:
```csharp
_account?.SubList.Insert(sub);
```
4. In `NatsServer.ProcessMessage`, use the sender's account SubList:
```csharp
public void ProcessMessage(string subject, string? replyTo, ReadOnlyMemory<byte> headers,
ReadOnlyMemory<byte> payload, NatsClient sender)
{
var subList = sender.Account?.SubList ?? _globalAccount.SubList;
var result = subList.Match(subject);
// ... rest of delivery logic unchanged
}
```
5. In cleanup (`RemoveClient`), remove from account:
```csharp
client.Account?.RemoveClient(client.Id);
client.RemoveAllSubscriptions(client.Account?.SubList ?? _globalAccount.SubList);
```
**Step 4: Run tests to verify they pass**
Run: `dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~AccountIsolationTests" -v normal`
Expected: Both tests PASS.
**Step 5: Run full test suite**
Run: `dotnet test`
Expected: All tests pass.
**Step 6: Commit**
```bash
git add src/NATS.Server/NatsServer.cs src/NATS.Server/NatsClient.cs tests/NATS.Server.Tests/AccountIsolationTests.cs
git commit -m "feat: add per-account SubList isolation for message routing"
```
---
### Task 12: Add permission enforcement integration tests
**Files:**
- Test: `tests/NATS.Server.Tests/PermissionIntegrationTests.cs`
**Step 1: Write permission integration tests**
Create `tests/NATS.Server.Tests/PermissionIntegrationTests.cs`:
```csharp
using System.Net;
using System.Net.Sockets;
using System.Text;
using Microsoft.Extensions.Logging.Abstractions;
using NATS.Client.Core;
using NATS.Server;
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");
}
}
```
**Step 2: Run tests**
Run: `dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~PermissionIntegrationTests" -v normal`
Expected: All tests PASS (permissions were integrated in Task 10).
**Step 3: Commit**
```bash
git add tests/NATS.Server.Tests/PermissionIntegrationTests.cs
git commit -m "test: add permission enforcement integration tests"
```
---
### Task 13: Add NKey integration test
**Files:**
- Test: `tests/NATS.Server.Tests/NKeyIntegrationTests.cs`
**Step 1: Write NKey integration test**
Create `tests/NATS.Server.Tests/NKeyIntegrationTests.cs`:
```csharp
using System.Net;
using System.Net.Sockets;
using System.Text;
using Microsoft.Extensions.Logging.Abstractions;
using NATS.Client.Core;
using NATS.NKeys;
using NATS.Server;
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.CreateUser();
_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.CreateUser();
await using var client = new NatsConnection(new NatsOpts
{
Url = $"nats://127.0.0.1:{_port}",
AuthOpts = new NatsAuthOpts { NKey = otherKp.GetPublicKey(), Seed = otherKp.GetSeed() },
});
var ex = await Should.ThrowAsync<Exception>(async () =>
{
await client.ConnectAsync();
await client.PingAsync();
});
}
}
```
> **Note for implementer:** The NATS.Client.Core library handles NKey signing automatically when you provide the NKey and Seed in `NatsAuthOpts`. It reads the server's nonce from INFO, signs it with the seed, and sends the public key + signature in CONNECT. Verify that `NATS.Client.Core` 2.7.2 supports `NatsAuthOpts.NKey` and `NatsAuthOpts.Seed` — check the NuGet docs if needed.
**Step 2: Run tests**
Run: `dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~NKeyIntegrationTests" -v normal`
Expected: Both tests PASS.
**Step 3: Commit**
```bash
git add tests/NATS.Server.Tests/NKeyIntegrationTests.cs
git commit -m "test: add NKey authentication integration tests"
```
---
### Task 14: Final regression test and cleanup
**Files:**
- No new files — run all tests and verify
**Step 1: Run full test suite**
Run: `dotnet test -v normal`
Expected: ALL tests pass — both old (parser, sublist, subject match, client, server, integration) and new (auth protocol, auth config, account, token auth, user/pass auth, simple user/pass auth, nkey auth, auth service, client permissions, auth integration, account isolation, permission integration, nkey integration).
**Step 2: Build with no warnings**
Run: `dotnet build -warnaserror`
Expected: Build succeeds with zero warnings.
**Step 3: Commit (if any cleanup needed)**
```bash
git add -A
git commit -m "chore: final cleanup for authentication implementation"
```
---
## File Summary
### New Files (13)
| File | Purpose |
|------|---------|
| `src/NATS.Server/Auth/User.cs` | Username/password user model |
| `src/NATS.Server/Auth/NKeyUser.cs` | NKey user model |
| `src/NATS.Server/Auth/Permissions.cs` | Permission types (SubjectPermission, ResponsePermission) |
| `src/NATS.Server/Auth/Account.cs` | Per-account SubList and client tracking |
| `src/NATS.Server/Auth/IAuthenticator.cs` | Auth interface and ClientAuthContext |
| `src/NATS.Server/Auth/AuthResult.cs` | Authentication result |
| `src/NATS.Server/Auth/TokenAuthenticator.cs` | Token-based auth |
| `src/NATS.Server/Auth/UserPasswordAuthenticator.cs` | Multi-user password auth |
| `src/NATS.Server/Auth/SimpleUserPasswordAuthenticator.cs` | Single user/pass auth |
| `src/NATS.Server/Auth/NKeyAuthenticator.cs` | NKey Ed25519 auth |
| `src/NATS.Server/Auth/AuthService.cs` | Auth orchestrator |
| `src/NATS.Server/Auth/ClientPermissions.cs` | Publish/subscribe permission enforcement |
### Modified Files (5)
| File | Changes |
|------|---------|
| `Directory.Packages.props` | Add NATS.NKeys, BCrypt.Net-Next |
| `src/NATS.Server/NATS.Server.csproj` | Add package references |
| `src/NATS.Server/Protocol/NatsProtocol.cs` | Auth errors, ServerInfo.AuthRequired/Nonce, ClientOptions auth fields |
| `src/NATS.Server/NatsOptions.cs` | Auth configuration fields |
| `src/NATS.Server/NatsServer.cs` | AuthService integration, account management, per-client INFO |
| `src/NATS.Server/NatsClient.cs` | Auth validation, permission checks, account assignment |
### New Test Files (9)
| File | Tests |
|------|-------|
| `tests/NATS.Server.Tests/AuthProtocolTests.cs` | Protocol type serialization |
| `tests/NATS.Server.Tests/AuthConfigTests.cs` | Options defaults |
| `tests/NATS.Server.Tests/AccountTests.cs` | Account model |
| `tests/NATS.Server.Tests/TokenAuthenticatorTests.cs` | Token auth |
| `tests/NATS.Server.Tests/UserPasswordAuthenticatorTests.cs` | User/pass auth |
| `tests/NATS.Server.Tests/SimpleUserPasswordAuthenticatorTests.cs` | Simple user/pass auth |
| `tests/NATS.Server.Tests/NKeyAuthenticatorTests.cs` | NKey auth |
| `tests/NATS.Server.Tests/AuthServiceTests.cs` | Auth orchestration |
| `tests/NATS.Server.Tests/ClientPermissionsTests.cs` | Permission checks |
| `tests/NATS.Server.Tests/AuthIntegrationTests.cs` | Full auth integration |
| `tests/NATS.Server.Tests/AccountIsolationTests.cs` | Cross-account isolation |
| `tests/NATS.Server.Tests/PermissionIntegrationTests.cs` | Permission enforcement |
| `tests/NATS.Server.Tests/NKeyIntegrationTests.cs` | NKey end-to-end |