Covers NuGet packages, protocol types, auth models, authenticators (token, user/password, NKey), AuthService orchestrator, permissions, server/client integration, account isolation, and integration tests.
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.FromBase64Stringcall with replacements handles both standard and URL-safe base64. The NATS client may send either encoding. IfConvert.ToBase64URLis 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:
- Add field:
private readonly AuthService _authService; - Add field:
private readonly ConcurrentDictionary<string, Account> _accounts = new(); - Add field:
private Account _globalAccount; - In constructor, after
_serverInfocreation:_authService = AuthService.Build(options); _globalAccount = new Account(Account.GlobalAccountName); _accounts[_globalAccount.Name] = _globalAccount; - In accept loop, when creating client — create per-client
ServerInfowith nonce:var clientInfo = CreateClientInfo(clientId, socket); var client = new NatsClient(clientId, socket, _options, clientInfo, _authService, this, clientLogger); - 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; } - Change
SubListproperty to use the request's account SubList or the global account SubList. - Update
ProcessMessageto use the sender's account's SubList instead of_subList. The server-level_subListfield is replaced by per-account SubLists.
Step 4: Modify NatsClient to enforce authentication
In src/NATS.Server/NatsClient.cs, apply these changes:
- Add fields:
private readonly AuthService _authService; private Account? _account; private ClientPermissions? _permissions; private byte[]? _nonce; - Update constructor to accept
AuthServiceand store nonce. - 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 // ... } - In
ProcessConnect, after deserializingClientOpts, 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 } - In
ProcessSub, add permission check before insert:if (_permissions != null && !_permissions.IsSubscribeAllowed(cmd.Subject!, cmd.Queue)) { await SendErrAsync($"{NatsProtocol.ErrPermissionsSubscribe} to \"{cmd.Subject}\""); return; } - 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> _authCompletedthat 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):
-
In
NatsServer, add account resolution:public Account GetOrCreateAccount(string? name) { if (string.IsNullOrEmpty(name)) return _globalAccount; return _accounts.GetOrAdd(name, n => new Account(n)); } -
In
NatsClient, after auth success, resolve account:_account = _server.GetOrCreateAccount(result.AccountName); _account.AddClient(Id); -
In
NatsClient.ProcessSub, insert subscription into account's SubList:_account?.SubList.Insert(sub); -
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 } -
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 thatNATS.Client.Core2.7.2 supportsNatsAuthOpts.NKeyandNatsAuthOpts.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 |