From 1c8cc43fb4e707027789c37f526f727b4af363ea Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 22 Feb 2026 22:15:48 -0500 Subject: [PATCH] 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. --- docs/plans/2026-02-22-authentication-plan.md | 2684 +++++++++++++++++ ...26-02-22-authentication-plan.md.tasks.json | 21 + 2 files changed, 2705 insertions(+) create mode 100644 docs/plans/2026-02-22-authentication-plan.md create mode 100644 docs/plans/2026-02-22-authentication-plan.md.tasks.json diff --git a/docs/plans/2026-02-22-authentication-plan.md b/docs/plans/2026-02-22-authentication-plan.md new file mode 100644 index 0000000..9cc4264 --- /dev/null +++ b/docs/plans/2026-02-22-authentication-plan.md @@ -0,0 +1,2684 @@ +# 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 `` in `Directory.Packages.props`, after the Logging section: + +```xml + + + +``` + +**Step 2: Add package references to NATS.Server.csproj** + +Add to the `` in `src/NATS.Server/NATS.Server.csproj`: + +```xml + + +``` + +**Step 3: Add NATS.NKeys to test project** + +Add to the `` (package references) in `tests/NATS.Server.Tests/NATS.Server.Tests.csproj`: + +```xml + +``` + +**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(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? Allow { get; init; } + public IReadOnlyList? 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? Users { get; set; } + public IReadOnlyList? 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 _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 _users; + + public UserPasswordAuthenticator(IEnumerable users) + { + _users = new Dictionary(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 _nkeys; + + public NKeyAuthenticator(IEnumerable nkeyUsers) + { + _nkeys = new Dictionary(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 _authenticators; + private readonly string? _noAuthUser; + private readonly Dictionary? _usersMap; + + public bool IsAuthRequired { get; } + public bool NonceRequired { get; } + + private AuthService(List authenticators, bool authRequired, bool nonceRequired, + string? noAuthUser, Dictionary? usersMap) + { + _authenticators = authenticators; + IsAuthRequired = authRequired; + NonceRequired = nonceRequired; + _noAuthUser = noAuthUser; + _usersMap = usersMap; + } + + public static AuthService Build(NatsOptions options) + { + var authenticators = new List(); + bool authRequired = false; + bool nonceRequired = false; + Dictionary? 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(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 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 _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(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(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(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 _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 _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("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("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 headers, + ReadOnlyMemory 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("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("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(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 | diff --git a/docs/plans/2026-02-22-authentication-plan.md.tasks.json b/docs/plans/2026-02-22-authentication-plan.md.tasks.json new file mode 100644 index 0000000..01d8a20 --- /dev/null +++ b/docs/plans/2026-02-22-authentication-plan.md.tasks.json @@ -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" +}