# 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 |