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

77 KiB

Authentication Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task.

Goal: Add username/password, token, and NKey authentication with per-user permissions and core account isolation to the .NET NATS server.

Architecture: Strategy pattern — IAuthenticator interface with concrete implementations per mechanism, orchestrated by AuthService in priority order. Per-account SubList isolation. Permission checks on PUB/SUB hot paths.

Tech Stack: .NET 10, NATS.NKeys (Ed25519), BCrypt.Net-Next (password hashing), xUnit + Shouldly (tests)

Design doc: docs/plans/2026-02-22-authentication-design.md


Task 0: Add NuGet packages and update project references

Files:

  • Modify: Directory.Packages.props
  • Modify: src/NATS.Server/NATS.Server.csproj
  • Modify: tests/NATS.Server.Tests/NATS.Server.Tests.csproj

Step 1: Add package versions to Directory.Packages.props

Add these entries to the <ItemGroup> in Directory.Packages.props, after the Logging section:

    <!-- Authentication -->
    <PackageVersion Include="NATS.NKeys" Version="1.0.0-preview.3" />
    <PackageVersion Include="BCrypt.Net-Next" Version="4.0.3" />

Step 2: Add package references to NATS.Server.csproj

Add to the <ItemGroup> in src/NATS.Server/NATS.Server.csproj:

    <PackageReference Include="NATS.NKeys" />
    <PackageReference Include="BCrypt.Net-Next" />

Step 3: Add NATS.NKeys to test project

Add to the <ItemGroup> (package references) in tests/NATS.Server.Tests/NATS.Server.Tests.csproj:

    <PackageReference Include="NATS.NKeys" />

Step 4: Verify build

Run: dotnet build Expected: Build succeeds with no errors.

Step 5: Commit

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:

using System.Text.Json;
using NATS.Server.Protocol;

namespace NATS.Server.Tests;

public class AuthProtocolTests
{
    [Fact]
    public void ClientOptions_deserializes_auth_fields()
    {
        var json = """{"user":"alice","pass":"secret","auth_token":"mytoken","nkey":"UABC","sig":"base64sig"}""";
        var opts = JsonSerializer.Deserialize<ClientOptions>(json);

        opts.ShouldNotBeNull();
        opts.Username.ShouldBe("alice");
        opts.Password.ShouldBe("secret");
        opts.Token.ShouldBe("mytoken");
        opts.Nkey.ShouldBe("UABC");
        opts.Sig.ShouldBe("base64sig");
    }

    [Fact]
    public void ServerInfo_serializes_auth_required_and_nonce()
    {
        var info = new ServerInfo
        {
            ServerId = "test",
            ServerName = "test",
            Version = "0.1.0",
            Host = "127.0.0.1",
            Port = 4222,
            AuthRequired = true,
            Nonce = "abc123",
        };

        var json = JsonSerializer.Serialize(info);
        json.ShouldContain("\"auth_required\":true");
        json.ShouldContain("\"nonce\":\"abc123\"");
    }

    [Fact]
    public void ServerInfo_omits_nonce_when_null()
    {
        var info = new ServerInfo
        {
            ServerId = "test",
            ServerName = "test",
            Version = "0.1.0",
            Host = "127.0.0.1",
            Port = 4222,
        };

        var json = JsonSerializer.Serialize(info);
        json.ShouldNotContain("nonce");
    }
}

Step 2: Run test to verify it fails

Run: dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~AuthProtocolTests" -v normal Expected: FAIL — Username, Password, Token, Nkey, Sig properties don't exist on ClientOptions. AuthRequired, Nonce don't exist on ServerInfo.

Step 3: Add error constants to NatsProtocol

In src/NATS.Server/Protocol/NatsProtocol.cs, add after line 28 (ErrInvalidSubject):

    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):

    [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):

    [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

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:

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:

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:

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:

namespace NATS.Server.Auth;

public sealed class Permissions
{
    public SubjectPermission? Publish { get; init; }
    public SubjectPermission? Subscribe { get; init; }
    public ResponsePermission? Response { get; init; }
}

public sealed class SubjectPermission
{
    public IReadOnlyList<string>? Allow { get; init; }
    public IReadOnlyList<string>? Deny { get; init; }
}

public sealed class ResponsePermission
{
    public int MaxMsgs { get; init; }
    public TimeSpan Expires { get; init; }
}

Update src/NATS.Server/NatsOptions.cs to add auth fields:

using NATS.Server.Auth;

namespace NATS.Server;

public sealed class NatsOptions
{
    public string Host { get; set; } = "0.0.0.0";
    public int Port { get; set; } = 4222;
    public string? ServerName { get; set; }
    public int MaxPayload { get; set; } = 1024 * 1024; // 1MB
    public int MaxControlLine { get; set; } = 4096;
    public int MaxConnections { get; set; } = 65536;
    public TimeSpan PingInterval { get; set; } = TimeSpan.FromMinutes(2);
    public int MaxPingsOut { get; set; } = 2;

    // Simple auth (single user)
    public string? Username { get; set; }
    public string? Password { get; set; }
    public string? Authorization { get; set; }

    // Multiple users/nkeys
    public IReadOnlyList<User>? Users { get; set; }
    public IReadOnlyList<NKeyUser>? NKeys { get; set; }

    // Default/fallback
    public string? NoAuthUser { get; set; }

    // Auth timing
    public TimeSpan AuthTimeout { get; set; } = TimeSpan.FromSeconds(1);
}

Step 4: Run tests to verify they pass

Run: dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~AuthConfigTests" -v normal Expected: PASS.

Step 5: Run full test suite

Run: dotnet test Expected: All tests pass.

Step 6: Commit

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:

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:

using System.Collections.Concurrent;
using NATS.Server.Subscriptions;

namespace NATS.Server.Auth;

public sealed class Account : IDisposable
{
    public const string GlobalAccountName = "$G";

    public string Name { get; }
    public SubList SubList { get; } = new();
    public Permissions? DefaultPermissions { get; set; }

    private readonly ConcurrentDictionary<ulong, byte> _clients = new();

    public Account(string name)
    {
        Name = name;
    }

    public int ClientCount => _clients.Count;

    public void AddClient(ulong clientId) => _clients[clientId] = 0;

    public void RemoveClient(ulong clientId) => _clients.TryRemove(clientId, out _);

    public void Dispose() => SubList.Dispose();
}

Step 4: Run tests to verify they pass

Run: dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~AccountTests" -v normal Expected: All 3 tests PASS.

Step 5: Commit

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:

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:

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:

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:

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

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:

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:

using System.Security.Cryptography;
using System.Text;

namespace NATS.Server.Auth;

public sealed class UserPasswordAuthenticator : IAuthenticator
{
    private readonly Dictionary<string, User> _users;

    public UserPasswordAuthenticator(IEnumerable<User> users)
    {
        _users = new Dictionary<string, User>(StringComparer.Ordinal);
        foreach (var user in users)
            _users[user.Username] = user;
    }

    public AuthResult? Authenticate(ClientAuthContext context)
    {
        var username = context.Opts.Username;
        if (string.IsNullOrEmpty(username))
            return null;

        if (!_users.TryGetValue(username, out var user))
            return null;

        var clientPassword = context.Opts.Password ?? string.Empty;

        if (!ComparePasswords(user.Password, clientPassword))
            return null;

        return new AuthResult
        {
            Identity = user.Username,
            AccountName = user.Account,
            Permissions = user.Permissions,
            Expiry = user.ConnectionDeadline,
        };
    }

    private static bool ComparePasswords(string serverPassword, string clientPassword)
    {
        if (IsBcrypt(serverPassword))
        {
            try
            {
                return BCrypt.Net.BCrypt.Verify(clientPassword, serverPassword);
            }
            catch
            {
                return false;
            }
        }

        var serverBytes = Encoding.UTF8.GetBytes(serverPassword);
        var clientBytes = Encoding.UTF8.GetBytes(clientPassword);
        return CryptographicOperations.FixedTimeEquals(serverBytes, clientBytes);
    }

    private static bool IsBcrypt(string password) => password.StartsWith("$2");
}

Step 4: Run tests to verify they pass

Run: dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~UserPasswordAuthenticatorTests" -v normal Expected: All 7 tests PASS.

Step 5: Commit

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:

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:

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

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:

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:

using NATS.NKeys;

namespace NATS.Server.Auth;

public sealed class NKeyAuthenticator : IAuthenticator
{
    private readonly Dictionary<string, NKeyUser> _nkeys;

    public NKeyAuthenticator(IEnumerable<NKeyUser> nkeyUsers)
    {
        _nkeys = new Dictionary<string, NKeyUser>(StringComparer.Ordinal);
        foreach (var nkeyUser in nkeyUsers)
            _nkeys[nkeyUser.Nkey] = nkeyUser;
    }

    public AuthResult? Authenticate(ClientAuthContext context)
    {
        var clientNkey = context.Opts.Nkey;
        if (string.IsNullOrEmpty(clientNkey))
            return null;

        if (!_nkeys.TryGetValue(clientNkey, out var nkeyUser))
            return null;

        var clientSig = context.Opts.Sig;
        if (string.IsNullOrEmpty(clientSig))
            return null;

        try
        {
            var sigBytes = Convert.FromBase64String(
                clientSig.Replace('-', '+').Replace('_', '/').PadRight(
                    clientSig.Length + (4 - clientSig.Length % 4) % 4, '='));

            var kp = KeyPair.FromPublicKey(clientNkey);
            if (!kp.Verify(context.Nonce, sigBytes))
                return null;
        }
        catch
        {
            return null;
        }

        return new AuthResult
        {
            Identity = clientNkey,
            AccountName = nkeyUser.Account,
            Permissions = nkeyUser.Permissions,
        };
    }
}

Note for implementer: The Convert.FromBase64String call with replacements handles both standard and URL-safe base64. The NATS client may send either encoding. If Convert.ToBase64URL is available in the test (it is in .NET 10), prefer that for the test side. The server must accept both.

Step 4: Run tests to verify they pass

Run: dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~NKeyAuthenticatorTests" -v normal Expected: All 5 tests PASS.

Step 5: Commit

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:

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:

using System.Security.Cryptography;

namespace NATS.Server.Auth;

public sealed class AuthService
{
    private readonly List<IAuthenticator> _authenticators;
    private readonly string? _noAuthUser;
    private readonly Dictionary<string, User>? _usersMap;

    public bool IsAuthRequired { get; }
    public bool NonceRequired { get; }

    private AuthService(List<IAuthenticator> authenticators, bool authRequired, bool nonceRequired,
        string? noAuthUser, Dictionary<string, User>? usersMap)
    {
        _authenticators = authenticators;
        IsAuthRequired = authRequired;
        NonceRequired = nonceRequired;
        _noAuthUser = noAuthUser;
        _usersMap = usersMap;
    }

    public static AuthService Build(NatsOptions options)
    {
        var authenticators = new List<IAuthenticator>();
        bool authRequired = false;
        bool nonceRequired = false;
        Dictionary<string, User>? usersMap = null;

        // Priority order (matching Go): NKeys > Users > Token > SimpleUserPassword

        if (options.NKeys is { Count: > 0 })
        {
            authenticators.Add(new NKeyAuthenticator(options.NKeys));
            authRequired = true;
            nonceRequired = true;
        }

        if (options.Users is { Count: > 0 })
        {
            authenticators.Add(new UserPasswordAuthenticator(options.Users));
            authRequired = true;
            usersMap = new Dictionary<string, User>(StringComparer.Ordinal);
            foreach (var u in options.Users)
                usersMap[u.Username] = u;
        }

        if (!string.IsNullOrEmpty(options.Authorization))
        {
            authenticators.Add(new TokenAuthenticator(options.Authorization));
            authRequired = true;
        }

        if (!string.IsNullOrEmpty(options.Username) && !string.IsNullOrEmpty(options.Password))
        {
            authenticators.Add(new SimpleUserPasswordAuthenticator(options.Username, options.Password));
            authRequired = true;
        }

        return new AuthService(authenticators, authRequired, nonceRequired, options.NoAuthUser, usersMap);
    }

    public AuthResult? Authenticate(ClientAuthContext context)
    {
        if (!IsAuthRequired)
            return new AuthResult { Identity = string.Empty };

        // Try each authenticator in priority order
        foreach (var authenticator in _authenticators)
        {
            var result = authenticator.Authenticate(context);
            if (result != null)
                return result;
        }

        // NoAuthUser fallback: if client provided no credentials and NoAuthUser is set
        if (_noAuthUser != null && IsNoCredentials(context))
        {
            return ResolveNoAuthUser();
        }

        return null;
    }

    private static bool IsNoCredentials(ClientAuthContext context)
    {
        var opts = context.Opts;
        return string.IsNullOrEmpty(opts.Username)
            && string.IsNullOrEmpty(opts.Password)
            && string.IsNullOrEmpty(opts.Token)
            && string.IsNullOrEmpty(opts.Nkey)
            && string.IsNullOrEmpty(opts.Sig);
    }

    private AuthResult? ResolveNoAuthUser()
    {
        if (_noAuthUser == null)
            return null;

        if (_usersMap != null && _usersMap.TryGetValue(_noAuthUser, out var user))
        {
            return new AuthResult
            {
                Identity = user.Username,
                AccountName = user.Account,
                Permissions = user.Permissions,
                Expiry = user.ConnectionDeadline,
            };
        }

        return new AuthResult { Identity = _noAuthUser };
    }

    public byte[] GenerateNonce()
    {
        Span<byte> raw = stackalloc byte[11];
        RandomNumberGenerator.Fill(raw);
        return raw.ToArray();
    }

    public string EncodeNonce(byte[] nonce)
    {
        return Convert.ToBase64String(nonce)
            .TrimEnd('=')
            .Replace('+', '-')
            .Replace('/', '_');
    }
}

Step 4: Run tests to verify they pass

Run: dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~AuthServiceTests" -v normal Expected: All 12 tests PASS.

Step 5: Run full test suite

Run: dotnet test Expected: All tests pass.

Step 6: Commit

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:

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:

using System.Collections.Concurrent;
using NATS.Server.Subscriptions;

namespace NATS.Server.Auth;

public sealed class ClientPermissions : IDisposable
{
    private readonly PermissionSet? _publish;
    private readonly PermissionSet? _subscribe;
    private readonly ConcurrentDictionary<string, bool> _pubCache = new(StringComparer.Ordinal);

    private ClientPermissions(PermissionSet? publish, PermissionSet? subscribe)
    {
        _publish = publish;
        _subscribe = subscribe;
    }

    public static ClientPermissions? Build(Permissions? permissions)
    {
        if (permissions == null)
            return null;

        var pub = PermissionSet.Build(permissions.Publish);
        var sub = PermissionSet.Build(permissions.Subscribe);

        if (pub == null && sub == null)
            return null;

        return new ClientPermissions(pub, sub);
    }

    public bool IsPublishAllowed(string subject)
    {
        if (_publish == null)
            return true;

        return _pubCache.GetOrAdd(subject, s => _publish.IsAllowed(s));
    }

    public bool IsSubscribeAllowed(string subject, string? queue = null)
    {
        if (_subscribe == null)
            return true;

        return _subscribe.IsAllowed(subject);
    }

    public void Dispose()
    {
        _publish?.Dispose();
        _subscribe?.Dispose();
    }
}

public sealed class PermissionSet : IDisposable
{
    private readonly SubList? _allow;
    private readonly SubList? _deny;

    private PermissionSet(SubList? allow, SubList? deny)
    {
        _allow = allow;
        _deny = deny;
    }

    public static PermissionSet? Build(SubjectPermission? permission)
    {
        if (permission == null)
            return null;

        bool hasAllow = permission.Allow is { Count: > 0 };
        bool hasDeny = permission.Deny is { Count: > 0 };

        if (!hasAllow && !hasDeny)
            return null;

        SubList? allow = null;
        SubList? deny = null;

        if (hasAllow)
        {
            allow = new SubList();
            foreach (var subject in permission.Allow!)
                allow.Insert(new Subscription { Subject = subject, Sid = "_perm_" });
        }

        if (hasDeny)
        {
            deny = new SubList();
            foreach (var subject in permission.Deny!)
                deny.Insert(new Subscription { Subject = subject, Sid = "_perm_" });
        }

        return new PermissionSet(allow, deny);
    }

    public bool IsAllowed(string subject)
    {
        bool allowed = true;

        // If allow list exists, subject must match it
        if (_allow != null)
        {
            var result = _allow.Match(subject);
            allowed = result.PlainSubs.Length > 0 || result.QueueSubs.Length > 0;
        }

        // If deny list exists, subject must NOT match it
        if (allowed && _deny != null)
        {
            var result = _deny.Match(subject);
            allowed = result.PlainSubs.Length == 0 && result.QueueSubs.Length == 0;
        }

        return allowed;
    }

    public void Dispose()
    {
        _allow?.Dispose();
        _deny?.Dispose();
    }
}

Step 4: Run tests to verify they pass

Run: dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~ClientPermissionsTests" -v normal Expected: All 8 tests PASS.

Step 5: Commit

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:

using System.Net;
using System.Net.Sockets;
using System.Text;
using Microsoft.Extensions.Logging.Abstractions;
using NATS.Client.Core;
using NATS.Server;
using NATS.Server.Auth;

namespace NATS.Server.Tests;

public class AuthIntegrationTests : IAsyncLifetime
{
    private NatsServer? _server;
    private int _port;
    private CancellationTokenSource _cts = new();
    private Task _serverTask = null!;

    private static int GetFreePort()
    {
        using var sock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
        sock.Bind(new IPEndPoint(IPAddress.Loopback, 0));
        return ((IPEndPoint)sock.LocalEndPoint!).Port;
    }

    private void StartServer(NatsOptions opts)
    {
        _port = GetFreePort();
        opts.Port = _port;
        _server = new NatsServer(opts, NullLoggerFactory.Instance);
    }

    public async Task InitializeAsync()
    {
        // Default server — tests will create their own via StartServer
    }

    public async Task DisposeAsync()
    {
        await _cts.CancelAsync();
        _server?.Dispose();
    }

    private async Task StartAndWaitAsync()
    {
        _serverTask = _server!.StartAsync(_cts.Token);
        await _server.WaitForReadyAsync();
    }

    private NatsConnection CreateClient(string? user = null, string? pass = null, string? token = null)
    {
        var url = $"nats://";
        if (user != null && pass != null)
            url = $"nats://{user}:{pass}@";
        else if (token != null)
            url = $"nats://token@"; // NATS client uses this format for token

        var opts = new NatsOpts
        {
            Url = $"{url}127.0.0.1:{_port}",
            AuthOpts = token != null ? new NatsAuthOpts { Token = token } : default,
        };
        return new NatsConnection(opts);
    }

    [Fact]
    public async Task Token_auth_success()
    {
        StartServer(new NatsOptions { Authorization = "mytoken" });
        await StartAndWaitAsync();

        await using var client = new NatsConnection(new NatsOpts
        {
            Url = $"nats://127.0.0.1:{_port}",
            AuthOpts = new NatsAuthOpts { Token = "mytoken" },
        });

        await client.ConnectAsync();
        await client.PingAsync();
    }

    [Fact]
    public async Task Token_auth_failure_disconnects()
    {
        StartServer(new NatsOptions { Authorization = "mytoken" });
        await StartAndWaitAsync();

        await using var client = new NatsConnection(new NatsOpts
        {
            Url = $"nats://127.0.0.1:{_port}",
            AuthOpts = new NatsAuthOpts { Token = "wrong" },
        });

        var ex = await Should.ThrowAsync<Exception>(async () =>
        {
            await client.ConnectAsync();
            await client.PingAsync();
        });
    }

    [Fact]
    public async Task UserPassword_auth_success()
    {
        StartServer(new NatsOptions { Username = "admin", Password = "secret" });
        await StartAndWaitAsync();

        await using var client = new NatsConnection(new NatsOpts
        {
            Url = $"nats://admin:secret@127.0.0.1:{_port}",
        });

        await client.ConnectAsync();
        await client.PingAsync();
    }

    [Fact]
    public async Task UserPassword_auth_failure_disconnects()
    {
        StartServer(new NatsOptions { Username = "admin", Password = "secret" });
        await StartAndWaitAsync();

        await using var client = new NatsConnection(new NatsOpts
        {
            Url = $"nats://admin:wrong@127.0.0.1:{_port}",
        });

        var ex = await Should.ThrowAsync<Exception>(async () =>
        {
            await client.ConnectAsync();
            await client.PingAsync();
        });
    }

    [Fact]
    public async Task MultiUser_auth_success()
    {
        StartServer(new NatsOptions
        {
            Users = [
                new User { Username = "alice", Password = "pass1" },
                new User { Username = "bob", Password = "pass2" },
            ],
        });
        await StartAndWaitAsync();

        await using var alice = new NatsConnection(new NatsOpts
        {
            Url = $"nats://alice:pass1@127.0.0.1:{_port}",
        });
        await using var bob = new NatsConnection(new NatsOpts
        {
            Url = $"nats://bob:pass2@127.0.0.1:{_port}",
        });

        await alice.ConnectAsync();
        await alice.PingAsync();
        await bob.ConnectAsync();
        await bob.PingAsync();
    }

    [Fact]
    public async Task No_credentials_when_auth_required_disconnects()
    {
        StartServer(new NatsOptions { Authorization = "mytoken" });
        await StartAndWaitAsync();

        await using var client = new NatsConnection(new NatsOpts
        {
            Url = $"nats://127.0.0.1:{_port}",
        });

        var ex = await Should.ThrowAsync<Exception>(async () =>
        {
            await client.ConnectAsync();
            await client.PingAsync();
        });
    }

    [Fact]
    public async Task No_auth_configured_allows_all()
    {
        StartServer(new NatsOptions());
        await StartAndWaitAsync();

        await using var client = new NatsConnection(new NatsOpts
        {
            Url = $"nats://127.0.0.1:{_port}",
        });

        await client.ConnectAsync();
        await client.PingAsync();
    }
}

Step 2: Run test to verify it fails

Run: dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~AuthIntegrationTests" -v normal Expected: FAIL — auth tests fail because server doesn't enforce authentication.

Step 3: Modify NatsServer to integrate AuthService

In src/NATS.Server/NatsServer.cs, apply these changes:

  1. Add field: private readonly AuthService _authService;
  2. Add field: private readonly ConcurrentDictionary<string, Account> _accounts = new();
  3. Add field: private Account _globalAccount;
  4. In constructor, after _serverInfo creation:
    _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:
    var clientInfo = CreateClientInfo(clientId, socket);
    var client = new NatsClient(clientId, socket, _options, clientInfo, _authService, this, clientLogger);
    
  6. Add method CreateClientInfo:
    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:
    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:
    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:
    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:
    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:
    if (_permissions != null && !_permissions.IsPublishAllowed(cmd.Subject!))
    {
        await SendErrAsync($"{NatsProtocol.ErrPermissionsPublish} to \"{cmd.Subject}\"");
        return;
    }
    

Note for implementer: This is the most complex integration task. The exact modifications depend on how the auth timeout interleaving works. The key principle is: ProcessConnect becomes async (to send -ERR), returns a success/failure signal, and RunAsync gates all further command processing on auth success. One clean approach is to add a TaskCompletionSource<bool> _authCompleted that ProcessConnect sets. RunAsync waits on it (with timeout) before starting the main command loop.

Step 5: Run tests to verify they pass

Run: dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~AuthIntegrationTests" -v normal Expected: All 7 tests PASS.

Step 6: Run full test suite to check for regressions

Run: dotnet test Expected: All tests pass. Existing tests that don't use auth should still work because when no auth is configured, AuthService.IsAuthRequired is false and auth is bypassed.

Step 7: Commit

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:

using System.Net;
using System.Net.Sockets;
using System.Text;
using Microsoft.Extensions.Logging.Abstractions;
using NATS.Client.Core;
using NATS.Server;
using NATS.Server.Auth;

namespace NATS.Server.Tests;

public class AccountIsolationTests : IAsyncLifetime
{
    private NatsServer _server = null!;
    private int _port;
    private readonly CancellationTokenSource _cts = new();
    private Task _serverTask = null!;

    private static int GetFreePort()
    {
        using var sock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
        sock.Bind(new IPEndPoint(IPAddress.Loopback, 0));
        return ((IPEndPoint)sock.LocalEndPoint!).Port;
    }

    public async Task InitializeAsync()
    {
        _port = GetFreePort();
        _server = new NatsServer(new NatsOptions
        {
            Port = _port,
            Users =
            [
                new User { Username = "alice", Password = "pass", Account = "acct-a" },
                new User { Username = "bob", Password = "pass", Account = "acct-b" },
                new User { Username = "charlie", Password = "pass", Account = "acct-a" },
            ],
        }, NullLoggerFactory.Instance);

        _serverTask = _server.StartAsync(_cts.Token);
        await _server.WaitForReadyAsync();
    }

    public async Task DisposeAsync()
    {
        await _cts.CancelAsync();
        _server.Dispose();
    }

    [Fact]
    public async Task Same_account_receives_messages()
    {
        // Alice and Charlie are in acct-a
        await using var alice = new NatsConnection(new NatsOpts
        {
            Url = $"nats://alice:pass@127.0.0.1:{_port}",
        });
        await using var charlie = new NatsConnection(new NatsOpts
        {
            Url = $"nats://charlie:pass@127.0.0.1:{_port}",
        });

        await alice.ConnectAsync();
        await charlie.ConnectAsync();

        await using var sub = await charlie.SubscribeCoreAsync<string>("test.subject");
        await charlie.PingAsync();

        await alice.PublishAsync("test.subject", "from-alice");

        using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
        var msg = await sub.Msgs.ReadAsync(timeout.Token);
        msg.Data.ShouldBe("from-alice");
    }

    [Fact]
    public async Task Different_account_does_not_receive_messages()
    {
        // Alice is in acct-a, Bob is in acct-b
        await using var alice = new NatsConnection(new NatsOpts
        {
            Url = $"nats://alice:pass@127.0.0.1:{_port}",
        });
        await using var bob = new NatsConnection(new NatsOpts
        {
            Url = $"nats://bob:pass@127.0.0.1:{_port}",
        });

        await alice.ConnectAsync();
        await bob.ConnectAsync();

        await using var sub = await bob.SubscribeCoreAsync<string>("test.subject");
        await bob.PingAsync();

        await alice.PublishAsync("test.subject", "from-alice");

        // Bob should NOT receive this — wait briefly then verify nothing arrived
        using var timeout = new CancellationTokenSource(TimeSpan.FromMilliseconds(500));
        try
        {
            await sub.Msgs.ReadAsync(timeout.Token);
            throw new Exception("Bob should not have received a message from a different account");
        }
        catch (OperationCanceledException)
        {
            // Expected — no message received (timeout)
        }
    }
}

Step 2: Run test to verify it fails

Run: dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~AccountIsolationTests" -v normal Expected: FAIL — account isolation not implemented.

Step 3: Implement account-aware routing

The key changes (building on Task 10):

  1. In NatsServer, add account resolution:

    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:

    _account = _server.GetOrCreateAccount(result.AccountName);
    _account.AddClient(Id);
    
  3. In NatsClient.ProcessSub, insert subscription into account's SubList:

    _account?.SubList.Insert(sub);
    
  4. In NatsServer.ProcessMessage, use the sender's account SubList:

    public void ProcessMessage(string subject, string? replyTo, ReadOnlyMemory<byte> headers,
        ReadOnlyMemory<byte> payload, NatsClient sender)
    {
        var subList = sender.Account?.SubList ?? _globalAccount.SubList;
        var result = subList.Match(subject);
        // ... rest of delivery logic unchanged
    }
    
  5. In cleanup (RemoveClient), remove from account:

    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

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:

using System.Net;
using System.Net.Sockets;
using System.Text;
using Microsoft.Extensions.Logging.Abstractions;
using NATS.Client.Core;
using NATS.Server;
using NATS.Server.Auth;

namespace NATS.Server.Tests;

public class PermissionIntegrationTests : IAsyncLifetime
{
    private NatsServer _server = null!;
    private int _port;
    private readonly CancellationTokenSource _cts = new();
    private Task _serverTask = null!;

    private static int GetFreePort()
    {
        using var sock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
        sock.Bind(new IPEndPoint(IPAddress.Loopback, 0));
        return ((IPEndPoint)sock.LocalEndPoint!).Port;
    }

    public async Task InitializeAsync()
    {
        _port = GetFreePort();
        _server = new NatsServer(new NatsOptions
        {
            Port = _port,
            Users =
            [
                new User
                {
                    Username = "publisher",
                    Password = "pass",
                    Permissions = new Permissions
                    {
                        Publish = new SubjectPermission { Allow = ["events.>"] },
                        Subscribe = new SubjectPermission { Deny = [">"] },
                    },
                },
                new User
                {
                    Username = "subscriber",
                    Password = "pass",
                    Permissions = new Permissions
                    {
                        Publish = new SubjectPermission { Deny = [">"] },
                        Subscribe = new SubjectPermission { Allow = ["events.>"] },
                    },
                },
                new User
                {
                    Username = "admin",
                    Password = "pass",
                    // No permissions — full access
                },
            ],
        }, NullLoggerFactory.Instance);

        _serverTask = _server.StartAsync(_cts.Token);
        await _server.WaitForReadyAsync();
    }

    public async Task DisposeAsync()
    {
        await _cts.CancelAsync();
        _server.Dispose();
    }

    [Fact]
    public async Task Publisher_can_publish_to_allowed_subject()
    {
        await using var pub = new NatsConnection(new NatsOpts
        {
            Url = $"nats://publisher:pass@127.0.0.1:{_port}",
        });
        await using var admin = new NatsConnection(new NatsOpts
        {
            Url = $"nats://admin:pass@127.0.0.1:{_port}",
        });

        await pub.ConnectAsync();
        await admin.ConnectAsync();

        await using var sub = await admin.SubscribeCoreAsync<string>("events.test");
        await admin.PingAsync();

        await pub.PublishAsync("events.test", "hello");

        using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
        var msg = await sub.Msgs.ReadAsync(timeout.Token);
        msg.Data.ShouldBe("hello");
    }

    [Fact]
    public async Task Admin_has_full_access()
    {
        await using var admin1 = new NatsConnection(new NatsOpts
        {
            Url = $"nats://admin:pass@127.0.0.1:{_port}",
        });
        await using var admin2 = new NatsConnection(new NatsOpts
        {
            Url = $"nats://admin:pass@127.0.0.1:{_port}",
        });

        await admin1.ConnectAsync();
        await admin2.ConnectAsync();

        await using var sub = await admin2.SubscribeCoreAsync<string>("anything.at.all");
        await admin2.PingAsync();

        await admin1.PublishAsync("anything.at.all", "data");

        using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
        var msg = await sub.Msgs.ReadAsync(timeout.Token);
        msg.Data.ShouldBe("data");
    }
}

Step 2: Run tests

Run: dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~PermissionIntegrationTests" -v normal Expected: All tests PASS (permissions were integrated in Task 10).

Step 3: Commit

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:

using System.Net;
using System.Net.Sockets;
using System.Text;
using Microsoft.Extensions.Logging.Abstractions;
using NATS.Client.Core;
using NATS.NKeys;
using NATS.Server;
using NATS.Server.Auth;

namespace NATS.Server.Tests;

public class NKeyIntegrationTests : IAsyncLifetime
{
    private NatsServer _server = null!;
    private int _port;
    private readonly CancellationTokenSource _cts = new();
    private Task _serverTask = null!;
    private KeyPair _userKeyPair = null!;
    private string _userSeed = null!;
    private string _userPublicKey = null!;

    private static int GetFreePort()
    {
        using var sock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
        sock.Bind(new IPEndPoint(IPAddress.Loopback, 0));
        return ((IPEndPoint)sock.LocalEndPoint!).Port;
    }

    public async Task InitializeAsync()
    {
        _port = GetFreePort();
        _userKeyPair = KeyPair.CreateUser();
        _userPublicKey = _userKeyPair.GetPublicKey();
        _userSeed = _userKeyPair.GetSeed();

        _server = new NatsServer(new NatsOptions
        {
            Port = _port,
            NKeys = [new NKeyUser { Nkey = _userPublicKey }],
        }, NullLoggerFactory.Instance);

        _serverTask = _server.StartAsync(_cts.Token);
        await _server.WaitForReadyAsync();
    }

    public async Task DisposeAsync()
    {
        await _cts.CancelAsync();
        _server.Dispose();
    }

    [Fact]
    public async Task NKey_auth_success()
    {
        await using var client = new NatsConnection(new NatsOpts
        {
            Url = $"nats://127.0.0.1:{_port}",
            AuthOpts = new NatsAuthOpts { NKey = _userPublicKey, Seed = _userSeed },
        });

        await client.ConnectAsync();
        await client.PingAsync();
    }

    [Fact]
    public async Task NKey_auth_wrong_key_fails()
    {
        // Generate a different key pair not known to the server
        var otherKp = KeyPair.CreateUser();

        await using var client = new NatsConnection(new NatsOpts
        {
            Url = $"nats://127.0.0.1:{_port}",
            AuthOpts = new NatsAuthOpts { NKey = otherKp.GetPublicKey(), Seed = otherKp.GetSeed() },
        });

        var ex = await Should.ThrowAsync<Exception>(async () =>
        {
            await client.ConnectAsync();
            await client.PingAsync();
        });
    }
}

Note for implementer: The NATS.Client.Core library handles NKey signing automatically when you provide the NKey and Seed in NatsAuthOpts. It reads the server's nonce from INFO, signs it with the seed, and sends the public key + signature in CONNECT. Verify that NATS.Client.Core 2.7.2 supports NatsAuthOpts.NKey and NatsAuthOpts.Seed — check the NuGet docs if needed.

Step 2: Run tests

Run: dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~NKeyIntegrationTests" -v normal Expected: Both tests PASS.

Step 3: Commit

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)

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