Covers NuGet packages, protocol types, auth models, authenticators (token, user/password, NKey), AuthService orchestrator, permissions, server/client integration, account isolation, and integration tests.
2685 lines
77 KiB
Markdown
2685 lines
77 KiB
Markdown
# Authentication Implementation Plan
|
|
|
|
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task.
|
|
|
|
**Goal:** Add username/password, token, and NKey authentication with per-user permissions and core account isolation to the .NET NATS server.
|
|
|
|
**Architecture:** Strategy pattern — `IAuthenticator` interface with concrete implementations per mechanism, orchestrated by `AuthService` in priority order. Per-account `SubList` isolation. Permission checks on PUB/SUB hot paths.
|
|
|
|
**Tech Stack:** .NET 10, NATS.NKeys (Ed25519), BCrypt.Net-Next (password hashing), xUnit + Shouldly (tests)
|
|
|
|
**Design doc:** `docs/plans/2026-02-22-authentication-design.md`
|
|
|
|
---
|
|
|
|
### Task 0: Add NuGet packages and update project references
|
|
|
|
**Files:**
|
|
- Modify: `Directory.Packages.props`
|
|
- Modify: `src/NATS.Server/NATS.Server.csproj`
|
|
- Modify: `tests/NATS.Server.Tests/NATS.Server.Tests.csproj`
|
|
|
|
**Step 1: Add package versions to Directory.Packages.props**
|
|
|
|
Add these entries to the `<ItemGroup>` in `Directory.Packages.props`, after the Logging section:
|
|
|
|
```xml
|
|
<!-- Authentication -->
|
|
<PackageVersion Include="NATS.NKeys" Version="1.0.0-preview.3" />
|
|
<PackageVersion Include="BCrypt.Net-Next" Version="4.0.3" />
|
|
```
|
|
|
|
**Step 2: Add package references to NATS.Server.csproj**
|
|
|
|
Add to the `<ItemGroup>` in `src/NATS.Server/NATS.Server.csproj`:
|
|
|
|
```xml
|
|
<PackageReference Include="NATS.NKeys" />
|
|
<PackageReference Include="BCrypt.Net-Next" />
|
|
```
|
|
|
|
**Step 3: Add NATS.NKeys to test project**
|
|
|
|
Add to the `<ItemGroup>` (package references) in `tests/NATS.Server.Tests/NATS.Server.Tests.csproj`:
|
|
|
|
```xml
|
|
<PackageReference Include="NATS.NKeys" />
|
|
```
|
|
|
|
**Step 4: Verify build**
|
|
|
|
Run: `dotnet build`
|
|
Expected: Build succeeds with no errors.
|
|
|
|
**Step 5: Commit**
|
|
|
|
```bash
|
|
git add Directory.Packages.props src/NATS.Server/NATS.Server.csproj tests/NATS.Server.Tests/NATS.Server.Tests.csproj
|
|
git commit -m "chore: add NATS.NKeys and BCrypt.Net-Next packages for authentication"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 1: Add auth fields to protocol types (ServerInfo, ClientOptions, error constants)
|
|
|
|
**Files:**
|
|
- Modify: `src/NATS.Server/Protocol/NatsProtocol.cs:23-28` (add error constants)
|
|
- Modify: `src/NATS.Server/Protocol/NatsProtocol.cs:31-64` (ServerInfo — add AuthRequired, Nonce)
|
|
- Modify: `src/NATS.Server/Protocol/NatsProtocol.cs:66-94` (ClientOptions — add auth credential fields)
|
|
|
|
**Step 1: Write a failing test for auth fields in ClientOptions**
|
|
|
|
Create `tests/NATS.Server.Tests/AuthProtocolTests.cs`:
|
|
|
|
```csharp
|
|
using System.Text.Json;
|
|
using NATS.Server.Protocol;
|
|
|
|
namespace NATS.Server.Tests;
|
|
|
|
public class AuthProtocolTests
|
|
{
|
|
[Fact]
|
|
public void ClientOptions_deserializes_auth_fields()
|
|
{
|
|
var json = """{"user":"alice","pass":"secret","auth_token":"mytoken","nkey":"UABC","sig":"base64sig"}""";
|
|
var opts = JsonSerializer.Deserialize<ClientOptions>(json);
|
|
|
|
opts.ShouldNotBeNull();
|
|
opts.Username.ShouldBe("alice");
|
|
opts.Password.ShouldBe("secret");
|
|
opts.Token.ShouldBe("mytoken");
|
|
opts.Nkey.ShouldBe("UABC");
|
|
opts.Sig.ShouldBe("base64sig");
|
|
}
|
|
|
|
[Fact]
|
|
public void ServerInfo_serializes_auth_required_and_nonce()
|
|
{
|
|
var info = new ServerInfo
|
|
{
|
|
ServerId = "test",
|
|
ServerName = "test",
|
|
Version = "0.1.0",
|
|
Host = "127.0.0.1",
|
|
Port = 4222,
|
|
AuthRequired = true,
|
|
Nonce = "abc123",
|
|
};
|
|
|
|
var json = JsonSerializer.Serialize(info);
|
|
json.ShouldContain("\"auth_required\":true");
|
|
json.ShouldContain("\"nonce\":\"abc123\"");
|
|
}
|
|
|
|
[Fact]
|
|
public void ServerInfo_omits_nonce_when_null()
|
|
{
|
|
var info = new ServerInfo
|
|
{
|
|
ServerId = "test",
|
|
ServerName = "test",
|
|
Version = "0.1.0",
|
|
Host = "127.0.0.1",
|
|
Port = 4222,
|
|
};
|
|
|
|
var json = JsonSerializer.Serialize(info);
|
|
json.ShouldNotContain("nonce");
|
|
}
|
|
}
|
|
```
|
|
|
|
**Step 2: Run test to verify it fails**
|
|
|
|
Run: `dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~AuthProtocolTests" -v normal`
|
|
Expected: FAIL — `Username`, `Password`, `Token`, `Nkey`, `Sig` properties don't exist on `ClientOptions`. `AuthRequired`, `Nonce` don't exist on `ServerInfo`.
|
|
|
|
**Step 3: Add error constants to NatsProtocol**
|
|
|
|
In `src/NATS.Server/Protocol/NatsProtocol.cs`, add after line 28 (`ErrInvalidSubject`):
|
|
|
|
```csharp
|
|
public const string ErrAuthorizationViolation = "Authorization Violation";
|
|
public const string ErrAuthTimeout = "Authentication Timeout";
|
|
public const string ErrPermissionsPublish = "Permissions Violation for Publish";
|
|
public const string ErrPermissionsSubscribe = "Permissions Violation for Subscription";
|
|
```
|
|
|
|
**Step 4: Add auth fields to ServerInfo**
|
|
|
|
In `src/NATS.Server/Protocol/NatsProtocol.cs`, add after the `ClientIp` property (line 63):
|
|
|
|
```csharp
|
|
[JsonPropertyName("auth_required")]
|
|
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
|
|
public bool AuthRequired { get; set; }
|
|
|
|
[JsonPropertyName("nonce")]
|
|
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
|
public string? Nonce { get; set; }
|
|
```
|
|
|
|
**Step 5: Add auth fields to ClientOptions**
|
|
|
|
In `src/NATS.Server/Protocol/NatsProtocol.cs`, add after the `NoResponders` property (line 93):
|
|
|
|
```csharp
|
|
[JsonPropertyName("user")]
|
|
public string? Username { get; set; }
|
|
|
|
[JsonPropertyName("pass")]
|
|
public string? Password { get; set; }
|
|
|
|
[JsonPropertyName("auth_token")]
|
|
public string? Token { get; set; }
|
|
|
|
[JsonPropertyName("nkey")]
|
|
public string? Nkey { get; set; }
|
|
|
|
[JsonPropertyName("sig")]
|
|
public string? Sig { get; set; }
|
|
```
|
|
|
|
**Step 6: Run tests to verify they pass**
|
|
|
|
Run: `dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~AuthProtocolTests" -v normal`
|
|
Expected: All 3 tests PASS.
|
|
|
|
**Step 7: Run full test suite for regressions**
|
|
|
|
Run: `dotnet test`
|
|
Expected: All existing tests still pass.
|
|
|
|
**Step 8: Commit**
|
|
|
|
```bash
|
|
git add src/NATS.Server/Protocol/NatsProtocol.cs tests/NATS.Server.Tests/AuthProtocolTests.cs
|
|
git commit -m "feat: add auth fields to ServerInfo and ClientOptions protocol types"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 2: Add auth configuration to NatsOptions
|
|
|
|
**Files:**
|
|
- Modify: `src/NATS.Server/NatsOptions.cs`
|
|
|
|
**Step 1: Write failing test**
|
|
|
|
Add to `tests/NATS.Server.Tests/AuthProtocolTests.cs`:
|
|
|
|
```csharp
|
|
using NATS.Server;
|
|
|
|
// ... add this test class to the same file or a new AuthConfigTests.cs ...
|
|
|
|
public class AuthConfigTests
|
|
{
|
|
[Fact]
|
|
public void NatsOptions_has_auth_fields_with_defaults()
|
|
{
|
|
var opts = new NatsOptions();
|
|
|
|
opts.Username.ShouldBeNull();
|
|
opts.Password.ShouldBeNull();
|
|
opts.Authorization.ShouldBeNull();
|
|
opts.Users.ShouldBeNull();
|
|
opts.NKeys.ShouldBeNull();
|
|
opts.NoAuthUser.ShouldBeNull();
|
|
opts.AuthTimeout.ShouldBe(TimeSpan.FromSeconds(1));
|
|
}
|
|
}
|
|
```
|
|
|
|
**Step 2: Run test to verify it fails**
|
|
|
|
Run: `dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~AuthConfigTests" -v normal`
|
|
Expected: FAIL — properties don't exist.
|
|
|
|
**Step 3: Create auth model types and update NatsOptions**
|
|
|
|
Create `src/NATS.Server/Auth/User.cs`:
|
|
|
|
```csharp
|
|
using NATS.Server.Auth;
|
|
|
|
namespace NATS.Server.Auth;
|
|
|
|
public sealed class User
|
|
{
|
|
public required string Username { get; init; }
|
|
public required string Password { get; init; }
|
|
public Permissions? Permissions { get; init; }
|
|
public string? Account { get; init; }
|
|
public DateTimeOffset? ConnectionDeadline { get; init; }
|
|
}
|
|
```
|
|
|
|
Create `src/NATS.Server/Auth/NKeyUser.cs`:
|
|
|
|
```csharp
|
|
namespace NATS.Server.Auth;
|
|
|
|
public sealed class NKeyUser
|
|
{
|
|
public required string Nkey { get; init; }
|
|
public Permissions? Permissions { get; init; }
|
|
public string? Account { get; init; }
|
|
public string? SigningKey { get; init; }
|
|
}
|
|
```
|
|
|
|
Create `src/NATS.Server/Auth/Permissions.cs`:
|
|
|
|
```csharp
|
|
namespace NATS.Server.Auth;
|
|
|
|
public sealed class Permissions
|
|
{
|
|
public SubjectPermission? Publish { get; init; }
|
|
public SubjectPermission? Subscribe { get; init; }
|
|
public ResponsePermission? Response { get; init; }
|
|
}
|
|
|
|
public sealed class SubjectPermission
|
|
{
|
|
public IReadOnlyList<string>? Allow { get; init; }
|
|
public IReadOnlyList<string>? Deny { get; init; }
|
|
}
|
|
|
|
public sealed class ResponsePermission
|
|
{
|
|
public int MaxMsgs { get; init; }
|
|
public TimeSpan Expires { get; init; }
|
|
}
|
|
```
|
|
|
|
Update `src/NATS.Server/NatsOptions.cs` to add auth fields:
|
|
|
|
```csharp
|
|
using NATS.Server.Auth;
|
|
|
|
namespace NATS.Server;
|
|
|
|
public sealed class NatsOptions
|
|
{
|
|
public string Host { get; set; } = "0.0.0.0";
|
|
public int Port { get; set; } = 4222;
|
|
public string? ServerName { get; set; }
|
|
public int MaxPayload { get; set; } = 1024 * 1024; // 1MB
|
|
public int MaxControlLine { get; set; } = 4096;
|
|
public int MaxConnections { get; set; } = 65536;
|
|
public TimeSpan PingInterval { get; set; } = TimeSpan.FromMinutes(2);
|
|
public int MaxPingsOut { get; set; } = 2;
|
|
|
|
// Simple auth (single user)
|
|
public string? Username { get; set; }
|
|
public string? Password { get; set; }
|
|
public string? Authorization { get; set; }
|
|
|
|
// Multiple users/nkeys
|
|
public IReadOnlyList<User>? Users { get; set; }
|
|
public IReadOnlyList<NKeyUser>? NKeys { get; set; }
|
|
|
|
// Default/fallback
|
|
public string? NoAuthUser { get; set; }
|
|
|
|
// Auth timing
|
|
public TimeSpan AuthTimeout { get; set; } = TimeSpan.FromSeconds(1);
|
|
}
|
|
```
|
|
|
|
**Step 4: Run tests to verify they pass**
|
|
|
|
Run: `dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~AuthConfigTests" -v normal`
|
|
Expected: PASS.
|
|
|
|
**Step 5: Run full test suite**
|
|
|
|
Run: `dotnet test`
|
|
Expected: All tests pass.
|
|
|
|
**Step 6: Commit**
|
|
|
|
```bash
|
|
git add src/NATS.Server/Auth/ src/NATS.Server/NatsOptions.cs tests/NATS.Server.Tests/AuthProtocolTests.cs
|
|
git commit -m "feat: add auth model types (User, NKeyUser, Permissions) and auth config to NatsOptions"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 3: Implement Account type with per-account SubList
|
|
|
|
**Files:**
|
|
- Create: `src/NATS.Server/Auth/Account.cs`
|
|
- Test: `tests/NATS.Server.Tests/AccountTests.cs`
|
|
|
|
**Step 1: Write failing test**
|
|
|
|
Create `tests/NATS.Server.Tests/AccountTests.cs`:
|
|
|
|
```csharp
|
|
using NATS.Server.Auth;
|
|
using NATS.Server.Subscriptions;
|
|
|
|
namespace NATS.Server.Tests;
|
|
|
|
public class AccountTests
|
|
{
|
|
[Fact]
|
|
public void Account_has_name_and_own_sublist()
|
|
{
|
|
var account = new Account("test-account");
|
|
|
|
account.Name.ShouldBe("test-account");
|
|
account.SubList.ShouldNotBeNull();
|
|
account.SubList.Count.ShouldBe(0u);
|
|
}
|
|
|
|
[Fact]
|
|
public void Account_tracks_clients()
|
|
{
|
|
var account = new Account("test");
|
|
|
|
account.ClientCount.ShouldBe(0);
|
|
account.AddClient(1);
|
|
account.ClientCount.ShouldBe(1);
|
|
account.RemoveClient(1);
|
|
account.ClientCount.ShouldBe(0);
|
|
}
|
|
|
|
[Fact]
|
|
public void GlobalAccount_has_default_name()
|
|
{
|
|
Account.GlobalAccountName.ShouldBe("$G");
|
|
}
|
|
}
|
|
```
|
|
|
|
**Step 2: Run test to verify it fails**
|
|
|
|
Run: `dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~AccountTests" -v normal`
|
|
Expected: FAIL — `Account` class doesn't exist.
|
|
|
|
**Step 3: Implement Account**
|
|
|
|
Create `src/NATS.Server/Auth/Account.cs`:
|
|
|
|
```csharp
|
|
using System.Collections.Concurrent;
|
|
using NATS.Server.Subscriptions;
|
|
|
|
namespace NATS.Server.Auth;
|
|
|
|
public sealed class Account : IDisposable
|
|
{
|
|
public const string GlobalAccountName = "$G";
|
|
|
|
public string Name { get; }
|
|
public SubList SubList { get; } = new();
|
|
public Permissions? DefaultPermissions { get; set; }
|
|
|
|
private readonly ConcurrentDictionary<ulong, byte> _clients = new();
|
|
|
|
public Account(string name)
|
|
{
|
|
Name = name;
|
|
}
|
|
|
|
public int ClientCount => _clients.Count;
|
|
|
|
public void AddClient(ulong clientId) => _clients[clientId] = 0;
|
|
|
|
public void RemoveClient(ulong clientId) => _clients.TryRemove(clientId, out _);
|
|
|
|
public void Dispose() => SubList.Dispose();
|
|
}
|
|
```
|
|
|
|
**Step 4: Run tests to verify they pass**
|
|
|
|
Run: `dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~AccountTests" -v normal`
|
|
Expected: All 3 tests PASS.
|
|
|
|
**Step 5: Commit**
|
|
|
|
```bash
|
|
git add src/NATS.Server/Auth/Account.cs tests/NATS.Server.Tests/AccountTests.cs
|
|
git commit -m "feat: add Account type with per-account SubList and client tracking"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 4: Implement TokenAuthenticator
|
|
|
|
**Files:**
|
|
- Create: `src/NATS.Server/Auth/IAuthenticator.cs`
|
|
- Create: `src/NATS.Server/Auth/AuthResult.cs`
|
|
- Create: `src/NATS.Server/Auth/TokenAuthenticator.cs`
|
|
- Test: `tests/NATS.Server.Tests/TokenAuthenticatorTests.cs`
|
|
|
|
**Step 1: Write failing test**
|
|
|
|
Create `tests/NATS.Server.Tests/TokenAuthenticatorTests.cs`:
|
|
|
|
```csharp
|
|
using NATS.Server.Auth;
|
|
using NATS.Server.Protocol;
|
|
|
|
namespace NATS.Server.Tests;
|
|
|
|
public class TokenAuthenticatorTests
|
|
{
|
|
[Fact]
|
|
public void Returns_result_for_correct_token()
|
|
{
|
|
var auth = new TokenAuthenticator("secret-token");
|
|
var ctx = new ClientAuthContext
|
|
{
|
|
Opts = new ClientOptions { Token = "secret-token" },
|
|
Nonce = [],
|
|
};
|
|
|
|
var result = auth.Authenticate(ctx);
|
|
|
|
result.ShouldNotBeNull();
|
|
result.Identity.ShouldBe("token");
|
|
}
|
|
|
|
[Fact]
|
|
public void Returns_null_for_wrong_token()
|
|
{
|
|
var auth = new TokenAuthenticator("secret-token");
|
|
var ctx = new ClientAuthContext
|
|
{
|
|
Opts = new ClientOptions { Token = "wrong-token" },
|
|
Nonce = [],
|
|
};
|
|
|
|
auth.Authenticate(ctx).ShouldBeNull();
|
|
}
|
|
|
|
[Fact]
|
|
public void Returns_null_when_no_token_provided()
|
|
{
|
|
var auth = new TokenAuthenticator("secret-token");
|
|
var ctx = new ClientAuthContext
|
|
{
|
|
Opts = new ClientOptions(),
|
|
Nonce = [],
|
|
};
|
|
|
|
auth.Authenticate(ctx).ShouldBeNull();
|
|
}
|
|
|
|
[Fact]
|
|
public void Returns_null_for_different_length_token()
|
|
{
|
|
var auth = new TokenAuthenticator("secret-token");
|
|
var ctx = new ClientAuthContext
|
|
{
|
|
Opts = new ClientOptions { Token = "short" },
|
|
Nonce = [],
|
|
};
|
|
|
|
auth.Authenticate(ctx).ShouldBeNull();
|
|
}
|
|
}
|
|
```
|
|
|
|
**Step 2: Run test to verify it fails**
|
|
|
|
Run: `dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~TokenAuthenticatorTests" -v normal`
|
|
Expected: FAIL — types don't exist.
|
|
|
|
**Step 3: Implement interfaces and TokenAuthenticator**
|
|
|
|
Create `src/NATS.Server/Auth/IAuthenticator.cs`:
|
|
|
|
```csharp
|
|
using NATS.Server.Protocol;
|
|
|
|
namespace NATS.Server.Auth;
|
|
|
|
public interface IAuthenticator
|
|
{
|
|
AuthResult? Authenticate(ClientAuthContext context);
|
|
}
|
|
|
|
public sealed class ClientAuthContext
|
|
{
|
|
public required ClientOptions Opts { get; init; }
|
|
public required byte[] Nonce { get; init; }
|
|
}
|
|
```
|
|
|
|
Create `src/NATS.Server/Auth/AuthResult.cs`:
|
|
|
|
```csharp
|
|
namespace NATS.Server.Auth;
|
|
|
|
public sealed class AuthResult
|
|
{
|
|
public required string Identity { get; init; }
|
|
public string? AccountName { get; init; }
|
|
public Permissions? Permissions { get; init; }
|
|
public DateTimeOffset? Expiry { get; init; }
|
|
}
|
|
```
|
|
|
|
Create `src/NATS.Server/Auth/TokenAuthenticator.cs`:
|
|
|
|
```csharp
|
|
using System.Security.Cryptography;
|
|
using System.Text;
|
|
|
|
namespace NATS.Server.Auth;
|
|
|
|
public sealed class TokenAuthenticator : IAuthenticator
|
|
{
|
|
private readonly byte[] _expectedToken;
|
|
|
|
public TokenAuthenticator(string token)
|
|
{
|
|
_expectedToken = Encoding.UTF8.GetBytes(token);
|
|
}
|
|
|
|
public AuthResult? Authenticate(ClientAuthContext context)
|
|
{
|
|
var clientToken = context.Opts.Token;
|
|
if (string.IsNullOrEmpty(clientToken))
|
|
return null;
|
|
|
|
var clientBytes = Encoding.UTF8.GetBytes(clientToken);
|
|
|
|
if (!CryptographicOperations.FixedTimeEquals(clientBytes, _expectedToken))
|
|
return null;
|
|
|
|
return new AuthResult { Identity = "token" };
|
|
}
|
|
}
|
|
```
|
|
|
|
**Step 4: Run tests to verify they pass**
|
|
|
|
Run: `dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~TokenAuthenticatorTests" -v normal`
|
|
Expected: All 4 tests PASS.
|
|
|
|
**Step 5: Commit**
|
|
|
|
```bash
|
|
git add src/NATS.Server/Auth/IAuthenticator.cs src/NATS.Server/Auth/AuthResult.cs src/NATS.Server/Auth/TokenAuthenticator.cs tests/NATS.Server.Tests/TokenAuthenticatorTests.cs
|
|
git commit -m "feat: add IAuthenticator interface and TokenAuthenticator with constant-time comparison"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 5: Implement UserPasswordAuthenticator (plain + bcrypt)
|
|
|
|
**Files:**
|
|
- Create: `src/NATS.Server/Auth/UserPasswordAuthenticator.cs`
|
|
- Test: `tests/NATS.Server.Tests/UserPasswordAuthenticatorTests.cs`
|
|
|
|
**Step 1: Write failing test**
|
|
|
|
Create `tests/NATS.Server.Tests/UserPasswordAuthenticatorTests.cs`:
|
|
|
|
```csharp
|
|
using NATS.Server.Auth;
|
|
using NATS.Server.Protocol;
|
|
|
|
namespace NATS.Server.Tests;
|
|
|
|
public class UserPasswordAuthenticatorTests
|
|
{
|
|
private static UserPasswordAuthenticator CreateAuth(params User[] users)
|
|
{
|
|
return new UserPasswordAuthenticator(users);
|
|
}
|
|
|
|
[Fact]
|
|
public void Returns_result_for_correct_plain_password()
|
|
{
|
|
var auth = CreateAuth(new User { Username = "alice", Password = "secret" });
|
|
var ctx = new ClientAuthContext
|
|
{
|
|
Opts = new ClientOptions { Username = "alice", Password = "secret" },
|
|
Nonce = [],
|
|
};
|
|
|
|
var result = auth.Authenticate(ctx);
|
|
|
|
result.ShouldNotBeNull();
|
|
result.Identity.ShouldBe("alice");
|
|
}
|
|
|
|
[Fact]
|
|
public void Returns_result_for_correct_bcrypt_password()
|
|
{
|
|
// Pre-computed bcrypt hash of "secret"
|
|
var hash = BCrypt.Net.BCrypt.HashPassword("secret");
|
|
var auth = CreateAuth(new User { Username = "bob", Password = hash });
|
|
var ctx = new ClientAuthContext
|
|
{
|
|
Opts = new ClientOptions { Username = "bob", Password = "secret" },
|
|
Nonce = [],
|
|
};
|
|
|
|
var result = auth.Authenticate(ctx);
|
|
|
|
result.ShouldNotBeNull();
|
|
result.Identity.ShouldBe("bob");
|
|
}
|
|
|
|
[Fact]
|
|
public void Returns_null_for_wrong_password()
|
|
{
|
|
var auth = CreateAuth(new User { Username = "alice", Password = "secret" });
|
|
var ctx = new ClientAuthContext
|
|
{
|
|
Opts = new ClientOptions { Username = "alice", Password = "wrong" },
|
|
Nonce = [],
|
|
};
|
|
|
|
auth.Authenticate(ctx).ShouldBeNull();
|
|
}
|
|
|
|
[Fact]
|
|
public void Returns_null_for_unknown_user()
|
|
{
|
|
var auth = CreateAuth(new User { Username = "alice", Password = "secret" });
|
|
var ctx = new ClientAuthContext
|
|
{
|
|
Opts = new ClientOptions { Username = "unknown", Password = "secret" },
|
|
Nonce = [],
|
|
};
|
|
|
|
auth.Authenticate(ctx).ShouldBeNull();
|
|
}
|
|
|
|
[Fact]
|
|
public void Returns_null_when_no_username_provided()
|
|
{
|
|
var auth = CreateAuth(new User { Username = "alice", Password = "secret" });
|
|
var ctx = new ClientAuthContext
|
|
{
|
|
Opts = new ClientOptions(),
|
|
Nonce = [],
|
|
};
|
|
|
|
auth.Authenticate(ctx).ShouldBeNull();
|
|
}
|
|
|
|
[Fact]
|
|
public void Returns_permissions_from_user()
|
|
{
|
|
var perms = new Permissions
|
|
{
|
|
Publish = new SubjectPermission { Allow = ["foo.>"] },
|
|
};
|
|
var auth = CreateAuth(new User { Username = "alice", Password = "secret", Permissions = perms });
|
|
var ctx = new ClientAuthContext
|
|
{
|
|
Opts = new ClientOptions { Username = "alice", Password = "secret" },
|
|
Nonce = [],
|
|
};
|
|
|
|
var result = auth.Authenticate(ctx);
|
|
|
|
result.ShouldNotBeNull();
|
|
result.Permissions.ShouldBe(perms);
|
|
}
|
|
|
|
[Fact]
|
|
public void Returns_account_name_from_user()
|
|
{
|
|
var auth = CreateAuth(new User { Username = "alice", Password = "secret", Account = "myaccount" });
|
|
var ctx = new ClientAuthContext
|
|
{
|
|
Opts = new ClientOptions { Username = "alice", Password = "secret" },
|
|
Nonce = [],
|
|
};
|
|
|
|
var result = auth.Authenticate(ctx);
|
|
|
|
result.ShouldNotBeNull();
|
|
result.AccountName.ShouldBe("myaccount");
|
|
}
|
|
}
|
|
```
|
|
|
|
**Step 2: Run test to verify it fails**
|
|
|
|
Run: `dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~UserPasswordAuthenticatorTests" -v normal`
|
|
Expected: FAIL — `UserPasswordAuthenticator` doesn't exist.
|
|
|
|
**Step 3: Implement UserPasswordAuthenticator**
|
|
|
|
Create `src/NATS.Server/Auth/UserPasswordAuthenticator.cs`:
|
|
|
|
```csharp
|
|
using System.Security.Cryptography;
|
|
using System.Text;
|
|
|
|
namespace NATS.Server.Auth;
|
|
|
|
public sealed class UserPasswordAuthenticator : IAuthenticator
|
|
{
|
|
private readonly Dictionary<string, User> _users;
|
|
|
|
public UserPasswordAuthenticator(IEnumerable<User> users)
|
|
{
|
|
_users = new Dictionary<string, User>(StringComparer.Ordinal);
|
|
foreach (var user in users)
|
|
_users[user.Username] = user;
|
|
}
|
|
|
|
public AuthResult? Authenticate(ClientAuthContext context)
|
|
{
|
|
var username = context.Opts.Username;
|
|
if (string.IsNullOrEmpty(username))
|
|
return null;
|
|
|
|
if (!_users.TryGetValue(username, out var user))
|
|
return null;
|
|
|
|
var clientPassword = context.Opts.Password ?? string.Empty;
|
|
|
|
if (!ComparePasswords(user.Password, clientPassword))
|
|
return null;
|
|
|
|
return new AuthResult
|
|
{
|
|
Identity = user.Username,
|
|
AccountName = user.Account,
|
|
Permissions = user.Permissions,
|
|
Expiry = user.ConnectionDeadline,
|
|
};
|
|
}
|
|
|
|
private static bool ComparePasswords(string serverPassword, string clientPassword)
|
|
{
|
|
if (IsBcrypt(serverPassword))
|
|
{
|
|
try
|
|
{
|
|
return BCrypt.Net.BCrypt.Verify(clientPassword, serverPassword);
|
|
}
|
|
catch
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
|
|
var serverBytes = Encoding.UTF8.GetBytes(serverPassword);
|
|
var clientBytes = Encoding.UTF8.GetBytes(clientPassword);
|
|
return CryptographicOperations.FixedTimeEquals(serverBytes, clientBytes);
|
|
}
|
|
|
|
private static bool IsBcrypt(string password) => password.StartsWith("$2");
|
|
}
|
|
```
|
|
|
|
**Step 4: Run tests to verify they pass**
|
|
|
|
Run: `dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~UserPasswordAuthenticatorTests" -v normal`
|
|
Expected: All 7 tests PASS.
|
|
|
|
**Step 5: Commit**
|
|
|
|
```bash
|
|
git add src/NATS.Server/Auth/UserPasswordAuthenticator.cs tests/NATS.Server.Tests/UserPasswordAuthenticatorTests.cs
|
|
git commit -m "feat: add UserPasswordAuthenticator with plain and bcrypt password support"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 6: Implement SimpleUserPasswordAuthenticator
|
|
|
|
**Files:**
|
|
- Create: `src/NATS.Server/Auth/SimpleUserPasswordAuthenticator.cs`
|
|
- Test: `tests/NATS.Server.Tests/SimpleUserPasswordAuthenticatorTests.cs`
|
|
|
|
This handles the single `username`/`password` config option (no user map lookup).
|
|
|
|
**Step 1: Write failing test**
|
|
|
|
Create `tests/NATS.Server.Tests/SimpleUserPasswordAuthenticatorTests.cs`:
|
|
|
|
```csharp
|
|
using NATS.Server.Auth;
|
|
using NATS.Server.Protocol;
|
|
|
|
namespace NATS.Server.Tests;
|
|
|
|
public class SimpleUserPasswordAuthenticatorTests
|
|
{
|
|
[Fact]
|
|
public void Returns_result_for_correct_credentials()
|
|
{
|
|
var auth = new SimpleUserPasswordAuthenticator("admin", "password123");
|
|
var ctx = new ClientAuthContext
|
|
{
|
|
Opts = new ClientOptions { Username = "admin", Password = "password123" },
|
|
Nonce = [],
|
|
};
|
|
|
|
var result = auth.Authenticate(ctx);
|
|
|
|
result.ShouldNotBeNull();
|
|
result.Identity.ShouldBe("admin");
|
|
}
|
|
|
|
[Fact]
|
|
public void Returns_null_for_wrong_username()
|
|
{
|
|
var auth = new SimpleUserPasswordAuthenticator("admin", "password123");
|
|
var ctx = new ClientAuthContext
|
|
{
|
|
Opts = new ClientOptions { Username = "wrong", Password = "password123" },
|
|
Nonce = [],
|
|
};
|
|
|
|
auth.Authenticate(ctx).ShouldBeNull();
|
|
}
|
|
|
|
[Fact]
|
|
public void Returns_null_for_wrong_password()
|
|
{
|
|
var auth = new SimpleUserPasswordAuthenticator("admin", "password123");
|
|
var ctx = new ClientAuthContext
|
|
{
|
|
Opts = new ClientOptions { Username = "admin", Password = "wrong" },
|
|
Nonce = [],
|
|
};
|
|
|
|
auth.Authenticate(ctx).ShouldBeNull();
|
|
}
|
|
|
|
[Fact]
|
|
public void Supports_bcrypt_password()
|
|
{
|
|
var hash = BCrypt.Net.BCrypt.HashPassword("secret");
|
|
var auth = new SimpleUserPasswordAuthenticator("admin", hash);
|
|
var ctx = new ClientAuthContext
|
|
{
|
|
Opts = new ClientOptions { Username = "admin", Password = "secret" },
|
|
Nonce = [],
|
|
};
|
|
|
|
auth.Authenticate(ctx).ShouldNotBeNull();
|
|
}
|
|
}
|
|
```
|
|
|
|
**Step 2: Run test to verify it fails**
|
|
|
|
Run: `dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~SimpleUserPasswordAuthenticatorTests" -v normal`
|
|
Expected: FAIL — type doesn't exist.
|
|
|
|
**Step 3: Implement SimpleUserPasswordAuthenticator**
|
|
|
|
Create `src/NATS.Server/Auth/SimpleUserPasswordAuthenticator.cs`:
|
|
|
|
```csharp
|
|
using System.Security.Cryptography;
|
|
using System.Text;
|
|
|
|
namespace NATS.Server.Auth;
|
|
|
|
public sealed class SimpleUserPasswordAuthenticator : IAuthenticator
|
|
{
|
|
private readonly byte[] _expectedUsername;
|
|
private readonly string _serverPassword;
|
|
|
|
public SimpleUserPasswordAuthenticator(string username, string password)
|
|
{
|
|
_expectedUsername = Encoding.UTF8.GetBytes(username);
|
|
_serverPassword = password;
|
|
}
|
|
|
|
public AuthResult? Authenticate(ClientAuthContext context)
|
|
{
|
|
var clientUsername = context.Opts.Username;
|
|
if (string.IsNullOrEmpty(clientUsername))
|
|
return null;
|
|
|
|
var clientUsernameBytes = Encoding.UTF8.GetBytes(clientUsername);
|
|
if (!CryptographicOperations.FixedTimeEquals(clientUsernameBytes, _expectedUsername))
|
|
return null;
|
|
|
|
var clientPassword = context.Opts.Password ?? string.Empty;
|
|
|
|
if (!ComparePasswords(_serverPassword, clientPassword))
|
|
return null;
|
|
|
|
return new AuthResult { Identity = clientUsername };
|
|
}
|
|
|
|
private static bool ComparePasswords(string serverPassword, string clientPassword)
|
|
{
|
|
if (serverPassword.StartsWith("$2"))
|
|
{
|
|
try
|
|
{
|
|
return BCrypt.Net.BCrypt.Verify(clientPassword, serverPassword);
|
|
}
|
|
catch
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
|
|
var serverBytes = Encoding.UTF8.GetBytes(serverPassword);
|
|
var clientBytes = Encoding.UTF8.GetBytes(clientPassword);
|
|
return CryptographicOperations.FixedTimeEquals(serverBytes, clientBytes);
|
|
}
|
|
}
|
|
```
|
|
|
|
**Step 4: Run tests to verify they pass**
|
|
|
|
Run: `dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~SimpleUserPasswordAuthenticatorTests" -v normal`
|
|
Expected: All 4 tests PASS.
|
|
|
|
**Step 5: Commit**
|
|
|
|
```bash
|
|
git add src/NATS.Server/Auth/SimpleUserPasswordAuthenticator.cs tests/NATS.Server.Tests/SimpleUserPasswordAuthenticatorTests.cs
|
|
git commit -m "feat: add SimpleUserPasswordAuthenticator for single username/password config"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 7: Implement NKeyAuthenticator
|
|
|
|
**Files:**
|
|
- Create: `src/NATS.Server/Auth/NKeyAuthenticator.cs`
|
|
- Test: `tests/NATS.Server.Tests/NKeyAuthenticatorTests.cs`
|
|
|
|
**Step 1: Write failing test**
|
|
|
|
Create `tests/NATS.Server.Tests/NKeyAuthenticatorTests.cs`:
|
|
|
|
```csharp
|
|
using NATS.NKeys;
|
|
using NATS.Server.Auth;
|
|
using NATS.Server.Protocol;
|
|
|
|
namespace NATS.Server.Tests;
|
|
|
|
public class NKeyAuthenticatorTests
|
|
{
|
|
[Fact]
|
|
public void Returns_result_for_valid_signature()
|
|
{
|
|
// Generate a test keypair
|
|
var kp = KeyPair.CreateUser();
|
|
var publicKey = kp.GetPublicKey();
|
|
var nonce = "test-nonce-123"u8.ToArray();
|
|
var sig = kp.Sign(nonce);
|
|
var sigBase64 = Convert.ToBase64URL(sig);
|
|
|
|
var nkeyUser = new NKeyUser { Nkey = publicKey };
|
|
var auth = new NKeyAuthenticator([nkeyUser]);
|
|
|
|
var ctx = new ClientAuthContext
|
|
{
|
|
Opts = new ClientOptions { Nkey = publicKey, Sig = sigBase64 },
|
|
Nonce = nonce,
|
|
};
|
|
|
|
var result = auth.Authenticate(ctx);
|
|
|
|
result.ShouldNotBeNull();
|
|
result.Identity.ShouldBe(publicKey);
|
|
}
|
|
|
|
[Fact]
|
|
public void Returns_null_for_invalid_signature()
|
|
{
|
|
var kp = KeyPair.CreateUser();
|
|
var publicKey = kp.GetPublicKey();
|
|
var nonce = "test-nonce-123"u8.ToArray();
|
|
|
|
var nkeyUser = new NKeyUser { Nkey = publicKey };
|
|
var auth = new NKeyAuthenticator([nkeyUser]);
|
|
|
|
var ctx = new ClientAuthContext
|
|
{
|
|
Opts = new ClientOptions { Nkey = publicKey, Sig = Convert.ToBase64String(new byte[64]) },
|
|
Nonce = nonce,
|
|
};
|
|
|
|
auth.Authenticate(ctx).ShouldBeNull();
|
|
}
|
|
|
|
[Fact]
|
|
public void Returns_null_for_unknown_nkey()
|
|
{
|
|
var kp = KeyPair.CreateUser();
|
|
var publicKey = kp.GetPublicKey();
|
|
var nonce = "test-nonce-123"u8.ToArray();
|
|
var sig = kp.Sign(nonce);
|
|
var sigBase64 = Convert.ToBase64URL(sig);
|
|
|
|
// Server doesn't know about this key
|
|
var auth = new NKeyAuthenticator([]);
|
|
|
|
var ctx = new ClientAuthContext
|
|
{
|
|
Opts = new ClientOptions { Nkey = publicKey, Sig = sigBase64 },
|
|
Nonce = nonce,
|
|
};
|
|
|
|
auth.Authenticate(ctx).ShouldBeNull();
|
|
}
|
|
|
|
[Fact]
|
|
public void Returns_null_when_no_nkey_provided()
|
|
{
|
|
var kp = KeyPair.CreateUser();
|
|
var publicKey = kp.GetPublicKey();
|
|
var nkeyUser = new NKeyUser { Nkey = publicKey };
|
|
var auth = new NKeyAuthenticator([nkeyUser]);
|
|
|
|
var ctx = new ClientAuthContext
|
|
{
|
|
Opts = new ClientOptions(),
|
|
Nonce = "nonce"u8.ToArray(),
|
|
};
|
|
|
|
auth.Authenticate(ctx).ShouldBeNull();
|
|
}
|
|
|
|
[Fact]
|
|
public void Returns_permissions_from_nkey_user()
|
|
{
|
|
var kp = KeyPair.CreateUser();
|
|
var publicKey = kp.GetPublicKey();
|
|
var nonce = "test-nonce"u8.ToArray();
|
|
var sig = kp.Sign(nonce);
|
|
var sigBase64 = Convert.ToBase64URL(sig);
|
|
|
|
var perms = new Permissions
|
|
{
|
|
Publish = new SubjectPermission { Allow = ["foo.>"] },
|
|
};
|
|
var nkeyUser = new NKeyUser { Nkey = publicKey, Permissions = perms };
|
|
var auth = new NKeyAuthenticator([nkeyUser]);
|
|
|
|
var ctx = new ClientAuthContext
|
|
{
|
|
Opts = new ClientOptions { Nkey = publicKey, Sig = sigBase64 },
|
|
Nonce = nonce,
|
|
};
|
|
|
|
var result = auth.Authenticate(ctx);
|
|
|
|
result.ShouldNotBeNull();
|
|
result.Permissions.ShouldBe(perms);
|
|
}
|
|
}
|
|
```
|
|
|
|
**Step 2: Run test to verify it fails**
|
|
|
|
Run: `dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~NKeyAuthenticatorTests" -v normal`
|
|
Expected: FAIL — `NKeyAuthenticator` doesn't exist.
|
|
|
|
**Step 3: Implement NKeyAuthenticator**
|
|
|
|
Create `src/NATS.Server/Auth/NKeyAuthenticator.cs`:
|
|
|
|
```csharp
|
|
using NATS.NKeys;
|
|
|
|
namespace NATS.Server.Auth;
|
|
|
|
public sealed class NKeyAuthenticator : IAuthenticator
|
|
{
|
|
private readonly Dictionary<string, NKeyUser> _nkeys;
|
|
|
|
public NKeyAuthenticator(IEnumerable<NKeyUser> nkeyUsers)
|
|
{
|
|
_nkeys = new Dictionary<string, NKeyUser>(StringComparer.Ordinal);
|
|
foreach (var nkeyUser in nkeyUsers)
|
|
_nkeys[nkeyUser.Nkey] = nkeyUser;
|
|
}
|
|
|
|
public AuthResult? Authenticate(ClientAuthContext context)
|
|
{
|
|
var clientNkey = context.Opts.Nkey;
|
|
if (string.IsNullOrEmpty(clientNkey))
|
|
return null;
|
|
|
|
if (!_nkeys.TryGetValue(clientNkey, out var nkeyUser))
|
|
return null;
|
|
|
|
var clientSig = context.Opts.Sig;
|
|
if (string.IsNullOrEmpty(clientSig))
|
|
return null;
|
|
|
|
try
|
|
{
|
|
var sigBytes = Convert.FromBase64String(
|
|
clientSig.Replace('-', '+').Replace('_', '/').PadRight(
|
|
clientSig.Length + (4 - clientSig.Length % 4) % 4, '='));
|
|
|
|
var kp = KeyPair.FromPublicKey(clientNkey);
|
|
if (!kp.Verify(context.Nonce, sigBytes))
|
|
return null;
|
|
}
|
|
catch
|
|
{
|
|
return null;
|
|
}
|
|
|
|
return new AuthResult
|
|
{
|
|
Identity = clientNkey,
|
|
AccountName = nkeyUser.Account,
|
|
Permissions = nkeyUser.Permissions,
|
|
};
|
|
}
|
|
}
|
|
```
|
|
|
|
> **Note for implementer:** The `Convert.FromBase64String` call with replacements handles both standard and URL-safe base64. The NATS client may send either encoding. If `Convert.ToBase64URL` is available in the test (it is in .NET 10), prefer that for the test side. The server must accept both.
|
|
|
|
**Step 4: Run tests to verify they pass**
|
|
|
|
Run: `dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~NKeyAuthenticatorTests" -v normal`
|
|
Expected: All 5 tests PASS.
|
|
|
|
**Step 5: Commit**
|
|
|
|
```bash
|
|
git add src/NATS.Server/Auth/NKeyAuthenticator.cs tests/NATS.Server.Tests/NKeyAuthenticatorTests.cs
|
|
git commit -m "feat: add NKeyAuthenticator with Ed25519 nonce signature verification"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 8: Implement AuthService (orchestrator)
|
|
|
|
**Files:**
|
|
- Create: `src/NATS.Server/Auth/AuthService.cs`
|
|
- Test: `tests/NATS.Server.Tests/AuthServiceTests.cs`
|
|
|
|
**Step 1: Write failing test**
|
|
|
|
Create `tests/NATS.Server.Tests/AuthServiceTests.cs`:
|
|
|
|
```csharp
|
|
using NATS.Server.Auth;
|
|
using NATS.Server.Protocol;
|
|
|
|
namespace NATS.Server.Tests;
|
|
|
|
public class AuthServiceTests
|
|
{
|
|
[Fact]
|
|
public void IsAuthRequired_false_when_no_auth_configured()
|
|
{
|
|
var service = AuthService.Build(new NatsOptions());
|
|
service.IsAuthRequired.ShouldBeFalse();
|
|
}
|
|
|
|
[Fact]
|
|
public void IsAuthRequired_true_when_token_configured()
|
|
{
|
|
var service = AuthService.Build(new NatsOptions { Authorization = "mytoken" });
|
|
service.IsAuthRequired.ShouldBeTrue();
|
|
}
|
|
|
|
[Fact]
|
|
public void IsAuthRequired_true_when_username_configured()
|
|
{
|
|
var service = AuthService.Build(new NatsOptions { Username = "admin", Password = "pass" });
|
|
service.IsAuthRequired.ShouldBeTrue();
|
|
}
|
|
|
|
[Fact]
|
|
public void IsAuthRequired_true_when_users_configured()
|
|
{
|
|
var opts = new NatsOptions
|
|
{
|
|
Users = [new User { Username = "alice", Password = "secret" }],
|
|
};
|
|
var service = AuthService.Build(opts);
|
|
service.IsAuthRequired.ShouldBeTrue();
|
|
}
|
|
|
|
[Fact]
|
|
public void IsAuthRequired_true_when_nkeys_configured()
|
|
{
|
|
var opts = new NatsOptions
|
|
{
|
|
NKeys = [new NKeyUser { Nkey = "UABC" }],
|
|
};
|
|
var service = AuthService.Build(opts);
|
|
service.IsAuthRequired.ShouldBeTrue();
|
|
}
|
|
|
|
[Fact]
|
|
public void Authenticate_returns_null_when_no_auth_and_creds_provided()
|
|
{
|
|
// No auth configured but client sends credentials — should still succeed (no auth required)
|
|
var service = AuthService.Build(new NatsOptions());
|
|
var ctx = new ClientAuthContext
|
|
{
|
|
Opts = new ClientOptions { Token = "anything" },
|
|
Nonce = [],
|
|
};
|
|
|
|
// When auth is not required, Authenticate returns a default result
|
|
var result = service.Authenticate(ctx);
|
|
result.ShouldNotBeNull();
|
|
}
|
|
|
|
[Fact]
|
|
public void Authenticate_token_success()
|
|
{
|
|
var service = AuthService.Build(new NatsOptions { Authorization = "mytoken" });
|
|
var ctx = new ClientAuthContext
|
|
{
|
|
Opts = new ClientOptions { Token = "mytoken" },
|
|
Nonce = [],
|
|
};
|
|
|
|
var result = service.Authenticate(ctx);
|
|
result.ShouldNotBeNull();
|
|
result.Identity.ShouldBe("token");
|
|
}
|
|
|
|
[Fact]
|
|
public void Authenticate_token_failure()
|
|
{
|
|
var service = AuthService.Build(new NatsOptions { Authorization = "mytoken" });
|
|
var ctx = new ClientAuthContext
|
|
{
|
|
Opts = new ClientOptions { Token = "wrong" },
|
|
Nonce = [],
|
|
};
|
|
|
|
service.Authenticate(ctx).ShouldBeNull();
|
|
}
|
|
|
|
[Fact]
|
|
public void Authenticate_simple_user_password_success()
|
|
{
|
|
var service = AuthService.Build(new NatsOptions { Username = "admin", Password = "pass" });
|
|
var ctx = new ClientAuthContext
|
|
{
|
|
Opts = new ClientOptions { Username = "admin", Password = "pass" },
|
|
Nonce = [],
|
|
};
|
|
|
|
var result = service.Authenticate(ctx);
|
|
result.ShouldNotBeNull();
|
|
result.Identity.ShouldBe("admin");
|
|
}
|
|
|
|
[Fact]
|
|
public void Authenticate_multi_user_success()
|
|
{
|
|
var opts = new NatsOptions
|
|
{
|
|
Users = [
|
|
new User { Username = "alice", Password = "secret1" },
|
|
new User { Username = "bob", Password = "secret2" },
|
|
],
|
|
};
|
|
var service = AuthService.Build(opts);
|
|
var ctx = new ClientAuthContext
|
|
{
|
|
Opts = new ClientOptions { Username = "bob", Password = "secret2" },
|
|
Nonce = [],
|
|
};
|
|
|
|
var result = service.Authenticate(ctx);
|
|
result.ShouldNotBeNull();
|
|
result.Identity.ShouldBe("bob");
|
|
}
|
|
|
|
[Fact]
|
|
public void NoAuthUser_fallback_when_no_creds()
|
|
{
|
|
var opts = new NatsOptions
|
|
{
|
|
Users = [
|
|
new User { Username = "default", Password = "unused" },
|
|
],
|
|
NoAuthUser = "default",
|
|
};
|
|
var service = AuthService.Build(opts);
|
|
var ctx = new ClientAuthContext
|
|
{
|
|
Opts = new ClientOptions(), // No credentials
|
|
Nonce = [],
|
|
};
|
|
|
|
var result = service.Authenticate(ctx);
|
|
result.ShouldNotBeNull();
|
|
result.Identity.ShouldBe("default");
|
|
}
|
|
|
|
[Fact]
|
|
public void NKeys_tried_before_users()
|
|
{
|
|
// If both NKeys and Users are configured, NKeys should take priority
|
|
var opts = new NatsOptions
|
|
{
|
|
NKeys = [new NKeyUser { Nkey = "UABC" }],
|
|
Users = [new User { Username = "alice", Password = "secret" }],
|
|
};
|
|
var service = AuthService.Build(opts);
|
|
|
|
// Provide user credentials — should still try NKey first (and fail for NKey),
|
|
// then fall through to user auth
|
|
var ctx = new ClientAuthContext
|
|
{
|
|
Opts = new ClientOptions { Username = "alice", Password = "secret" },
|
|
Nonce = [],
|
|
};
|
|
|
|
var result = service.Authenticate(ctx);
|
|
result.ShouldNotBeNull();
|
|
result.Identity.ShouldBe("alice");
|
|
}
|
|
}
|
|
```
|
|
|
|
**Step 2: Run test to verify it fails**
|
|
|
|
Run: `dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~AuthServiceTests" -v normal`
|
|
Expected: FAIL — `AuthService` doesn't exist.
|
|
|
|
**Step 3: Implement AuthService**
|
|
|
|
Create `src/NATS.Server/Auth/AuthService.cs`:
|
|
|
|
```csharp
|
|
using System.Security.Cryptography;
|
|
|
|
namespace NATS.Server.Auth;
|
|
|
|
public sealed class AuthService
|
|
{
|
|
private readonly List<IAuthenticator> _authenticators;
|
|
private readonly string? _noAuthUser;
|
|
private readonly Dictionary<string, User>? _usersMap;
|
|
|
|
public bool IsAuthRequired { get; }
|
|
public bool NonceRequired { get; }
|
|
|
|
private AuthService(List<IAuthenticator> authenticators, bool authRequired, bool nonceRequired,
|
|
string? noAuthUser, Dictionary<string, User>? usersMap)
|
|
{
|
|
_authenticators = authenticators;
|
|
IsAuthRequired = authRequired;
|
|
NonceRequired = nonceRequired;
|
|
_noAuthUser = noAuthUser;
|
|
_usersMap = usersMap;
|
|
}
|
|
|
|
public static AuthService Build(NatsOptions options)
|
|
{
|
|
var authenticators = new List<IAuthenticator>();
|
|
bool authRequired = false;
|
|
bool nonceRequired = false;
|
|
Dictionary<string, User>? usersMap = null;
|
|
|
|
// Priority order (matching Go): NKeys > Users > Token > SimpleUserPassword
|
|
|
|
if (options.NKeys is { Count: > 0 })
|
|
{
|
|
authenticators.Add(new NKeyAuthenticator(options.NKeys));
|
|
authRequired = true;
|
|
nonceRequired = true;
|
|
}
|
|
|
|
if (options.Users is { Count: > 0 })
|
|
{
|
|
authenticators.Add(new UserPasswordAuthenticator(options.Users));
|
|
authRequired = true;
|
|
usersMap = new Dictionary<string, User>(StringComparer.Ordinal);
|
|
foreach (var u in options.Users)
|
|
usersMap[u.Username] = u;
|
|
}
|
|
|
|
if (!string.IsNullOrEmpty(options.Authorization))
|
|
{
|
|
authenticators.Add(new TokenAuthenticator(options.Authorization));
|
|
authRequired = true;
|
|
}
|
|
|
|
if (!string.IsNullOrEmpty(options.Username) && !string.IsNullOrEmpty(options.Password))
|
|
{
|
|
authenticators.Add(new SimpleUserPasswordAuthenticator(options.Username, options.Password));
|
|
authRequired = true;
|
|
}
|
|
|
|
return new AuthService(authenticators, authRequired, nonceRequired, options.NoAuthUser, usersMap);
|
|
}
|
|
|
|
public AuthResult? Authenticate(ClientAuthContext context)
|
|
{
|
|
if (!IsAuthRequired)
|
|
return new AuthResult { Identity = string.Empty };
|
|
|
|
// Try each authenticator in priority order
|
|
foreach (var authenticator in _authenticators)
|
|
{
|
|
var result = authenticator.Authenticate(context);
|
|
if (result != null)
|
|
return result;
|
|
}
|
|
|
|
// NoAuthUser fallback: if client provided no credentials and NoAuthUser is set
|
|
if (_noAuthUser != null && IsNoCredentials(context))
|
|
{
|
|
return ResolveNoAuthUser();
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private static bool IsNoCredentials(ClientAuthContext context)
|
|
{
|
|
var opts = context.Opts;
|
|
return string.IsNullOrEmpty(opts.Username)
|
|
&& string.IsNullOrEmpty(opts.Password)
|
|
&& string.IsNullOrEmpty(opts.Token)
|
|
&& string.IsNullOrEmpty(opts.Nkey)
|
|
&& string.IsNullOrEmpty(opts.Sig);
|
|
}
|
|
|
|
private AuthResult? ResolveNoAuthUser()
|
|
{
|
|
if (_noAuthUser == null)
|
|
return null;
|
|
|
|
if (_usersMap != null && _usersMap.TryGetValue(_noAuthUser, out var user))
|
|
{
|
|
return new AuthResult
|
|
{
|
|
Identity = user.Username,
|
|
AccountName = user.Account,
|
|
Permissions = user.Permissions,
|
|
Expiry = user.ConnectionDeadline,
|
|
};
|
|
}
|
|
|
|
return new AuthResult { Identity = _noAuthUser };
|
|
}
|
|
|
|
public byte[] GenerateNonce()
|
|
{
|
|
Span<byte> raw = stackalloc byte[11];
|
|
RandomNumberGenerator.Fill(raw);
|
|
return raw.ToArray();
|
|
}
|
|
|
|
public string EncodeNonce(byte[] nonce)
|
|
{
|
|
return Convert.ToBase64String(nonce)
|
|
.TrimEnd('=')
|
|
.Replace('+', '-')
|
|
.Replace('/', '_');
|
|
}
|
|
}
|
|
```
|
|
|
|
**Step 4: Run tests to verify they pass**
|
|
|
|
Run: `dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~AuthServiceTests" -v normal`
|
|
Expected: All 12 tests PASS.
|
|
|
|
**Step 5: Run full test suite**
|
|
|
|
Run: `dotnet test`
|
|
Expected: All tests pass.
|
|
|
|
**Step 6: Commit**
|
|
|
|
```bash
|
|
git add src/NATS.Server/Auth/AuthService.cs tests/NATS.Server.Tests/AuthServiceTests.cs
|
|
git commit -m "feat: add AuthService orchestrator with priority-ordered authentication"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 9: Implement ClientPermissions (publish/subscribe authorization)
|
|
|
|
**Files:**
|
|
- Create: `src/NATS.Server/Auth/ClientPermissions.cs`
|
|
- Test: `tests/NATS.Server.Tests/ClientPermissionsTests.cs`
|
|
|
|
**Step 1: Write failing test**
|
|
|
|
Create `tests/NATS.Server.Tests/ClientPermissionsTests.cs`:
|
|
|
|
```csharp
|
|
using NATS.Server.Auth;
|
|
|
|
namespace NATS.Server.Tests;
|
|
|
|
public class ClientPermissionsTests
|
|
{
|
|
[Fact]
|
|
public void No_permissions_allows_everything()
|
|
{
|
|
var perms = ClientPermissions.Build(null);
|
|
|
|
perms.ShouldBeNull(); // null means no restrictions
|
|
}
|
|
|
|
[Fact]
|
|
public void Publish_allow_list_only()
|
|
{
|
|
var perms = ClientPermissions.Build(new Permissions
|
|
{
|
|
Publish = new SubjectPermission { Allow = ["foo.>", "bar.*"] },
|
|
});
|
|
|
|
perms.ShouldNotBeNull();
|
|
perms.IsPublishAllowed("foo.bar").ShouldBeTrue();
|
|
perms.IsPublishAllowed("foo.bar.baz").ShouldBeTrue();
|
|
perms.IsPublishAllowed("bar.one").ShouldBeTrue();
|
|
perms.IsPublishAllowed("baz.one").ShouldBeFalse();
|
|
}
|
|
|
|
[Fact]
|
|
public void Publish_deny_list_only()
|
|
{
|
|
var perms = ClientPermissions.Build(new Permissions
|
|
{
|
|
Publish = new SubjectPermission { Deny = ["secret.>"] },
|
|
});
|
|
|
|
perms.ShouldNotBeNull();
|
|
perms.IsPublishAllowed("foo.bar").ShouldBeTrue();
|
|
perms.IsPublishAllowed("secret.data").ShouldBeFalse();
|
|
perms.IsPublishAllowed("secret.nested.deep").ShouldBeFalse();
|
|
}
|
|
|
|
[Fact]
|
|
public void Publish_allow_and_deny()
|
|
{
|
|
var perms = ClientPermissions.Build(new Permissions
|
|
{
|
|
Publish = new SubjectPermission
|
|
{
|
|
Allow = ["events.>"],
|
|
Deny = ["events.internal.>"],
|
|
},
|
|
});
|
|
|
|
perms.ShouldNotBeNull();
|
|
perms.IsPublishAllowed("events.public.data").ShouldBeTrue();
|
|
perms.IsPublishAllowed("events.internal.secret").ShouldBeFalse();
|
|
}
|
|
|
|
[Fact]
|
|
public void Subscribe_allow_list()
|
|
{
|
|
var perms = ClientPermissions.Build(new Permissions
|
|
{
|
|
Subscribe = new SubjectPermission { Allow = ["data.>"] },
|
|
});
|
|
|
|
perms.ShouldNotBeNull();
|
|
perms.IsSubscribeAllowed("data.updates").ShouldBeTrue();
|
|
perms.IsSubscribeAllowed("admin.logs").ShouldBeFalse();
|
|
}
|
|
|
|
[Fact]
|
|
public void Subscribe_deny_list()
|
|
{
|
|
var perms = ClientPermissions.Build(new Permissions
|
|
{
|
|
Subscribe = new SubjectPermission { Deny = ["admin.>"] },
|
|
});
|
|
|
|
perms.ShouldNotBeNull();
|
|
perms.IsSubscribeAllowed("data.updates").ShouldBeTrue();
|
|
perms.IsSubscribeAllowed("admin.logs").ShouldBeFalse();
|
|
}
|
|
|
|
[Fact]
|
|
public void Publish_cache_returns_same_result()
|
|
{
|
|
var perms = ClientPermissions.Build(new Permissions
|
|
{
|
|
Publish = new SubjectPermission { Allow = ["foo.>"] },
|
|
});
|
|
|
|
perms.ShouldNotBeNull();
|
|
|
|
// First call populates cache
|
|
perms.IsPublishAllowed("foo.bar").ShouldBeTrue();
|
|
// Second call should use cache — same result
|
|
perms.IsPublishAllowed("foo.bar").ShouldBeTrue();
|
|
|
|
perms.IsPublishAllowed("baz.bar").ShouldBeFalse();
|
|
perms.IsPublishAllowed("baz.bar").ShouldBeFalse();
|
|
}
|
|
|
|
[Fact]
|
|
public void Empty_permissions_object_allows_everything()
|
|
{
|
|
// Permissions object exists but has no publish or subscribe restrictions
|
|
var perms = ClientPermissions.Build(new Permissions());
|
|
|
|
perms.ShouldBeNull();
|
|
}
|
|
}
|
|
```
|
|
|
|
**Step 2: Run test to verify it fails**
|
|
|
|
Run: `dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~ClientPermissionsTests" -v normal`
|
|
Expected: FAIL — `ClientPermissions` doesn't exist.
|
|
|
|
**Step 3: Implement ClientPermissions**
|
|
|
|
Create `src/NATS.Server/Auth/ClientPermissions.cs`:
|
|
|
|
```csharp
|
|
using System.Collections.Concurrent;
|
|
using NATS.Server.Subscriptions;
|
|
|
|
namespace NATS.Server.Auth;
|
|
|
|
public sealed class ClientPermissions : IDisposable
|
|
{
|
|
private readonly PermissionSet? _publish;
|
|
private readonly PermissionSet? _subscribe;
|
|
private readonly ConcurrentDictionary<string, bool> _pubCache = new(StringComparer.Ordinal);
|
|
|
|
private ClientPermissions(PermissionSet? publish, PermissionSet? subscribe)
|
|
{
|
|
_publish = publish;
|
|
_subscribe = subscribe;
|
|
}
|
|
|
|
public static ClientPermissions? Build(Permissions? permissions)
|
|
{
|
|
if (permissions == null)
|
|
return null;
|
|
|
|
var pub = PermissionSet.Build(permissions.Publish);
|
|
var sub = PermissionSet.Build(permissions.Subscribe);
|
|
|
|
if (pub == null && sub == null)
|
|
return null;
|
|
|
|
return new ClientPermissions(pub, sub);
|
|
}
|
|
|
|
public bool IsPublishAllowed(string subject)
|
|
{
|
|
if (_publish == null)
|
|
return true;
|
|
|
|
return _pubCache.GetOrAdd(subject, s => _publish.IsAllowed(s));
|
|
}
|
|
|
|
public bool IsSubscribeAllowed(string subject, string? queue = null)
|
|
{
|
|
if (_subscribe == null)
|
|
return true;
|
|
|
|
return _subscribe.IsAllowed(subject);
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
_publish?.Dispose();
|
|
_subscribe?.Dispose();
|
|
}
|
|
}
|
|
|
|
public sealed class PermissionSet : IDisposable
|
|
{
|
|
private readonly SubList? _allow;
|
|
private readonly SubList? _deny;
|
|
|
|
private PermissionSet(SubList? allow, SubList? deny)
|
|
{
|
|
_allow = allow;
|
|
_deny = deny;
|
|
}
|
|
|
|
public static PermissionSet? Build(SubjectPermission? permission)
|
|
{
|
|
if (permission == null)
|
|
return null;
|
|
|
|
bool hasAllow = permission.Allow is { Count: > 0 };
|
|
bool hasDeny = permission.Deny is { Count: > 0 };
|
|
|
|
if (!hasAllow && !hasDeny)
|
|
return null;
|
|
|
|
SubList? allow = null;
|
|
SubList? deny = null;
|
|
|
|
if (hasAllow)
|
|
{
|
|
allow = new SubList();
|
|
foreach (var subject in permission.Allow!)
|
|
allow.Insert(new Subscription { Subject = subject, Sid = "_perm_" });
|
|
}
|
|
|
|
if (hasDeny)
|
|
{
|
|
deny = new SubList();
|
|
foreach (var subject in permission.Deny!)
|
|
deny.Insert(new Subscription { Subject = subject, Sid = "_perm_" });
|
|
}
|
|
|
|
return new PermissionSet(allow, deny);
|
|
}
|
|
|
|
public bool IsAllowed(string subject)
|
|
{
|
|
bool allowed = true;
|
|
|
|
// If allow list exists, subject must match it
|
|
if (_allow != null)
|
|
{
|
|
var result = _allow.Match(subject);
|
|
allowed = result.PlainSubs.Length > 0 || result.QueueSubs.Length > 0;
|
|
}
|
|
|
|
// If deny list exists, subject must NOT match it
|
|
if (allowed && _deny != null)
|
|
{
|
|
var result = _deny.Match(subject);
|
|
allowed = result.PlainSubs.Length == 0 && result.QueueSubs.Length == 0;
|
|
}
|
|
|
|
return allowed;
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
_allow?.Dispose();
|
|
_deny?.Dispose();
|
|
}
|
|
}
|
|
```
|
|
|
|
**Step 4: Run tests to verify they pass**
|
|
|
|
Run: `dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~ClientPermissionsTests" -v normal`
|
|
Expected: All 8 tests PASS.
|
|
|
|
**Step 5: Commit**
|
|
|
|
```bash
|
|
git add src/NATS.Server/Auth/ClientPermissions.cs tests/NATS.Server.Tests/ClientPermissionsTests.cs
|
|
git commit -m "feat: add ClientPermissions with SubList-based publish/subscribe authorization"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 10: Integrate auth into NatsServer and NatsClient
|
|
|
|
This is the largest task — wire everything together. It modifies the server accept loop, client lifecycle, and message routing.
|
|
|
|
**Files:**
|
|
- Modify: `src/NATS.Server/NatsServer.cs` (add AuthService, accounts, per-client INFO)
|
|
- Modify: `src/NATS.Server/NatsClient.cs` (auth validation in ProcessConnect, permission checks in ProcessSub/ProcessPub, auth timeout, account assignment)
|
|
|
|
**Step 1: Write failing integration test**
|
|
|
|
Create `tests/NATS.Server.Tests/AuthIntegrationTests.cs`:
|
|
|
|
```csharp
|
|
using System.Net;
|
|
using System.Net.Sockets;
|
|
using System.Text;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using NATS.Client.Core;
|
|
using NATS.Server;
|
|
using NATS.Server.Auth;
|
|
|
|
namespace NATS.Server.Tests;
|
|
|
|
public class AuthIntegrationTests : IAsyncLifetime
|
|
{
|
|
private NatsServer? _server;
|
|
private int _port;
|
|
private CancellationTokenSource _cts = new();
|
|
private Task _serverTask = null!;
|
|
|
|
private static int GetFreePort()
|
|
{
|
|
using var sock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
|
sock.Bind(new IPEndPoint(IPAddress.Loopback, 0));
|
|
return ((IPEndPoint)sock.LocalEndPoint!).Port;
|
|
}
|
|
|
|
private void StartServer(NatsOptions opts)
|
|
{
|
|
_port = GetFreePort();
|
|
opts.Port = _port;
|
|
_server = new NatsServer(opts, NullLoggerFactory.Instance);
|
|
}
|
|
|
|
public async Task InitializeAsync()
|
|
{
|
|
// Default server — tests will create their own via StartServer
|
|
}
|
|
|
|
public async Task DisposeAsync()
|
|
{
|
|
await _cts.CancelAsync();
|
|
_server?.Dispose();
|
|
}
|
|
|
|
private async Task StartAndWaitAsync()
|
|
{
|
|
_serverTask = _server!.StartAsync(_cts.Token);
|
|
await _server.WaitForReadyAsync();
|
|
}
|
|
|
|
private NatsConnection CreateClient(string? user = null, string? pass = null, string? token = null)
|
|
{
|
|
var url = $"nats://";
|
|
if (user != null && pass != null)
|
|
url = $"nats://{user}:{pass}@";
|
|
else if (token != null)
|
|
url = $"nats://token@"; // NATS client uses this format for token
|
|
|
|
var opts = new NatsOpts
|
|
{
|
|
Url = $"{url}127.0.0.1:{_port}",
|
|
AuthOpts = token != null ? new NatsAuthOpts { Token = token } : default,
|
|
};
|
|
return new NatsConnection(opts);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Token_auth_success()
|
|
{
|
|
StartServer(new NatsOptions { Authorization = "mytoken" });
|
|
await StartAndWaitAsync();
|
|
|
|
await using var client = new NatsConnection(new NatsOpts
|
|
{
|
|
Url = $"nats://127.0.0.1:{_port}",
|
|
AuthOpts = new NatsAuthOpts { Token = "mytoken" },
|
|
});
|
|
|
|
await client.ConnectAsync();
|
|
await client.PingAsync();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Token_auth_failure_disconnects()
|
|
{
|
|
StartServer(new NatsOptions { Authorization = "mytoken" });
|
|
await StartAndWaitAsync();
|
|
|
|
await using var client = new NatsConnection(new NatsOpts
|
|
{
|
|
Url = $"nats://127.0.0.1:{_port}",
|
|
AuthOpts = new NatsAuthOpts { Token = "wrong" },
|
|
});
|
|
|
|
var ex = await Should.ThrowAsync<Exception>(async () =>
|
|
{
|
|
await client.ConnectAsync();
|
|
await client.PingAsync();
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public async Task UserPassword_auth_success()
|
|
{
|
|
StartServer(new NatsOptions { Username = "admin", Password = "secret" });
|
|
await StartAndWaitAsync();
|
|
|
|
await using var client = new NatsConnection(new NatsOpts
|
|
{
|
|
Url = $"nats://admin:secret@127.0.0.1:{_port}",
|
|
});
|
|
|
|
await client.ConnectAsync();
|
|
await client.PingAsync();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task UserPassword_auth_failure_disconnects()
|
|
{
|
|
StartServer(new NatsOptions { Username = "admin", Password = "secret" });
|
|
await StartAndWaitAsync();
|
|
|
|
await using var client = new NatsConnection(new NatsOpts
|
|
{
|
|
Url = $"nats://admin:wrong@127.0.0.1:{_port}",
|
|
});
|
|
|
|
var ex = await Should.ThrowAsync<Exception>(async () =>
|
|
{
|
|
await client.ConnectAsync();
|
|
await client.PingAsync();
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public async Task MultiUser_auth_success()
|
|
{
|
|
StartServer(new NatsOptions
|
|
{
|
|
Users = [
|
|
new User { Username = "alice", Password = "pass1" },
|
|
new User { Username = "bob", Password = "pass2" },
|
|
],
|
|
});
|
|
await StartAndWaitAsync();
|
|
|
|
await using var alice = new NatsConnection(new NatsOpts
|
|
{
|
|
Url = $"nats://alice:pass1@127.0.0.1:{_port}",
|
|
});
|
|
await using var bob = new NatsConnection(new NatsOpts
|
|
{
|
|
Url = $"nats://bob:pass2@127.0.0.1:{_port}",
|
|
});
|
|
|
|
await alice.ConnectAsync();
|
|
await alice.PingAsync();
|
|
await bob.ConnectAsync();
|
|
await bob.PingAsync();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task No_credentials_when_auth_required_disconnects()
|
|
{
|
|
StartServer(new NatsOptions { Authorization = "mytoken" });
|
|
await StartAndWaitAsync();
|
|
|
|
await using var client = new NatsConnection(new NatsOpts
|
|
{
|
|
Url = $"nats://127.0.0.1:{_port}",
|
|
});
|
|
|
|
var ex = await Should.ThrowAsync<Exception>(async () =>
|
|
{
|
|
await client.ConnectAsync();
|
|
await client.PingAsync();
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public async Task No_auth_configured_allows_all()
|
|
{
|
|
StartServer(new NatsOptions());
|
|
await StartAndWaitAsync();
|
|
|
|
await using var client = new NatsConnection(new NatsOpts
|
|
{
|
|
Url = $"nats://127.0.0.1:{_port}",
|
|
});
|
|
|
|
await client.ConnectAsync();
|
|
await client.PingAsync();
|
|
}
|
|
}
|
|
```
|
|
|
|
**Step 2: Run test to verify it fails**
|
|
|
|
Run: `dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~AuthIntegrationTests" -v normal`
|
|
Expected: FAIL — auth tests fail because server doesn't enforce authentication.
|
|
|
|
**Step 3: Modify NatsServer to integrate AuthService**
|
|
|
|
In `src/NATS.Server/NatsServer.cs`, apply these changes:
|
|
|
|
1. Add field: `private readonly AuthService _authService;`
|
|
2. Add field: `private readonly ConcurrentDictionary<string, Account> _accounts = new();`
|
|
3. Add field: `private Account _globalAccount;`
|
|
4. In constructor, after `_serverInfo` creation:
|
|
```csharp
|
|
_authService = AuthService.Build(options);
|
|
_globalAccount = new Account(Account.GlobalAccountName);
|
|
_accounts[_globalAccount.Name] = _globalAccount;
|
|
```
|
|
5. In accept loop, when creating client — create per-client `ServerInfo` with nonce:
|
|
```csharp
|
|
var clientInfo = CreateClientInfo(clientId, socket);
|
|
var client = new NatsClient(clientId, socket, _options, clientInfo, _authService, this, clientLogger);
|
|
```
|
|
6. Add method `CreateClientInfo`:
|
|
```csharp
|
|
private ServerInfo CreateClientInfo(ulong clientId, Socket socket)
|
|
{
|
|
var info = new ServerInfo
|
|
{
|
|
ServerId = _serverInfo.ServerId,
|
|
ServerName = _serverInfo.ServerName,
|
|
Version = _serverInfo.Version,
|
|
Host = _serverInfo.Host,
|
|
Port = _serverInfo.Port,
|
|
MaxPayload = _serverInfo.MaxPayload,
|
|
ClientId = clientId,
|
|
ClientIp = (socket.RemoteEndPoint as IPEndPoint)?.Address.ToString(),
|
|
AuthRequired = _authService.IsAuthRequired,
|
|
};
|
|
|
|
if (_authService.NonceRequired)
|
|
{
|
|
var nonce = _authService.GenerateNonce();
|
|
info.Nonce = _authService.EncodeNonce(nonce);
|
|
// Store raw nonce bytes for signature verification — passed to NatsClient
|
|
}
|
|
|
|
return info;
|
|
}
|
|
```
|
|
7. Change `SubList` property to use the request's account SubList or the global account SubList.
|
|
8. Update `ProcessMessage` to use the sender's account's SubList instead of `_subList`. The server-level `_subList` field is replaced by per-account SubLists.
|
|
|
|
**Step 4: Modify NatsClient to enforce authentication**
|
|
|
|
In `src/NATS.Server/NatsClient.cs`, apply these changes:
|
|
|
|
1. Add fields:
|
|
```csharp
|
|
private readonly AuthService _authService;
|
|
private Account? _account;
|
|
private ClientPermissions? _permissions;
|
|
private byte[]? _nonce;
|
|
```
|
|
2. Update constructor to accept `AuthService` and store nonce.
|
|
3. In `RunAsync`, after sending INFO, add auth timeout:
|
|
```csharp
|
|
if (_authService.IsAuthRequired)
|
|
{
|
|
using var authTimeout = new CancellationTokenSource(_options.AuthTimeout);
|
|
using var combined = CancellationTokenSource.CreateLinkedTokenSource(
|
|
_clientCts.Token, authTimeout.Token);
|
|
// Wait for CONNECT to be processed with auth
|
|
// ...
|
|
}
|
|
```
|
|
4. In `ProcessConnect`, after deserializing `ClientOpts`, call auth:
|
|
```csharp
|
|
if (_authService.IsAuthRequired)
|
|
{
|
|
var ctx = new ClientAuthContext
|
|
{
|
|
Opts = ClientOpts,
|
|
Nonce = _nonce ?? [],
|
|
};
|
|
var result = _authService.Authenticate(ctx);
|
|
if (result == null)
|
|
{
|
|
await SendErrAndCloseAsync(NatsProtocol.ErrAuthorizationViolation);
|
|
return;
|
|
}
|
|
_permissions = ClientPermissions.Build(result.Permissions);
|
|
// Assign account
|
|
}
|
|
```
|
|
5. In `ProcessSub`, add permission check before insert:
|
|
```csharp
|
|
if (_permissions != null && !_permissions.IsSubscribeAllowed(cmd.Subject!, cmd.Queue))
|
|
{
|
|
await SendErrAsync($"{NatsProtocol.ErrPermissionsSubscribe} to \"{cmd.Subject}\"");
|
|
return;
|
|
}
|
|
```
|
|
6. In `ProcessPubAsync`, add permission check before routing:
|
|
```csharp
|
|
if (_permissions != null && !_permissions.IsPublishAllowed(cmd.Subject!))
|
|
{
|
|
await SendErrAsync($"{NatsProtocol.ErrPermissionsPublish} to \"{cmd.Subject}\"");
|
|
return;
|
|
}
|
|
```
|
|
|
|
> **Note for implementer:** This is the most complex integration task. The exact modifications depend on how the auth timeout interleaving works. The key principle is: ProcessConnect becomes async (to send -ERR), returns a success/failure signal, and RunAsync gates all further command processing on auth success. One clean approach is to add a `TaskCompletionSource<bool> _authCompleted` that ProcessConnect sets. RunAsync waits on it (with timeout) before starting the main command loop.
|
|
|
|
**Step 5: Run tests to verify they pass**
|
|
|
|
Run: `dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~AuthIntegrationTests" -v normal`
|
|
Expected: All 7 tests PASS.
|
|
|
|
**Step 6: Run full test suite to check for regressions**
|
|
|
|
Run: `dotnet test`
|
|
Expected: All tests pass. Existing tests that don't use auth should still work because when no auth is configured, `AuthService.IsAuthRequired` is `false` and auth is bypassed.
|
|
|
|
**Step 7: Commit**
|
|
|
|
```bash
|
|
git add src/NATS.Server/NatsServer.cs src/NATS.Server/NatsClient.cs tests/NATS.Server.Tests/AuthIntegrationTests.cs
|
|
git commit -m "feat: integrate authentication into server accept loop and client CONNECT processing"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 11: Implement account isolation in message routing
|
|
|
|
**Files:**
|
|
- Modify: `src/NATS.Server/NatsServer.cs` (ProcessMessage uses account SubList)
|
|
- Modify: `src/NATS.Server/NatsClient.cs` (subscriptions go to account SubList)
|
|
- Test: `tests/NATS.Server.Tests/AccountIsolationTests.cs`
|
|
|
|
**Step 1: Write failing test**
|
|
|
|
Create `tests/NATS.Server.Tests/AccountIsolationTests.cs`:
|
|
|
|
```csharp
|
|
using System.Net;
|
|
using System.Net.Sockets;
|
|
using System.Text;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using NATS.Client.Core;
|
|
using NATS.Server;
|
|
using NATS.Server.Auth;
|
|
|
|
namespace NATS.Server.Tests;
|
|
|
|
public class AccountIsolationTests : IAsyncLifetime
|
|
{
|
|
private NatsServer _server = null!;
|
|
private int _port;
|
|
private readonly CancellationTokenSource _cts = new();
|
|
private Task _serverTask = null!;
|
|
|
|
private static int GetFreePort()
|
|
{
|
|
using var sock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
|
sock.Bind(new IPEndPoint(IPAddress.Loopback, 0));
|
|
return ((IPEndPoint)sock.LocalEndPoint!).Port;
|
|
}
|
|
|
|
public async Task InitializeAsync()
|
|
{
|
|
_port = GetFreePort();
|
|
_server = new NatsServer(new NatsOptions
|
|
{
|
|
Port = _port,
|
|
Users =
|
|
[
|
|
new User { Username = "alice", Password = "pass", Account = "acct-a" },
|
|
new User { Username = "bob", Password = "pass", Account = "acct-b" },
|
|
new User { Username = "charlie", Password = "pass", Account = "acct-a" },
|
|
],
|
|
}, NullLoggerFactory.Instance);
|
|
|
|
_serverTask = _server.StartAsync(_cts.Token);
|
|
await _server.WaitForReadyAsync();
|
|
}
|
|
|
|
public async Task DisposeAsync()
|
|
{
|
|
await _cts.CancelAsync();
|
|
_server.Dispose();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Same_account_receives_messages()
|
|
{
|
|
// Alice and Charlie are in acct-a
|
|
await using var alice = new NatsConnection(new NatsOpts
|
|
{
|
|
Url = $"nats://alice:pass@127.0.0.1:{_port}",
|
|
});
|
|
await using var charlie = new NatsConnection(new NatsOpts
|
|
{
|
|
Url = $"nats://charlie:pass@127.0.0.1:{_port}",
|
|
});
|
|
|
|
await alice.ConnectAsync();
|
|
await charlie.ConnectAsync();
|
|
|
|
await using var sub = await charlie.SubscribeCoreAsync<string>("test.subject");
|
|
await charlie.PingAsync();
|
|
|
|
await alice.PublishAsync("test.subject", "from-alice");
|
|
|
|
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
|
var msg = await sub.Msgs.ReadAsync(timeout.Token);
|
|
msg.Data.ShouldBe("from-alice");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Different_account_does_not_receive_messages()
|
|
{
|
|
// Alice is in acct-a, Bob is in acct-b
|
|
await using var alice = new NatsConnection(new NatsOpts
|
|
{
|
|
Url = $"nats://alice:pass@127.0.0.1:{_port}",
|
|
});
|
|
await using var bob = new NatsConnection(new NatsOpts
|
|
{
|
|
Url = $"nats://bob:pass@127.0.0.1:{_port}",
|
|
});
|
|
|
|
await alice.ConnectAsync();
|
|
await bob.ConnectAsync();
|
|
|
|
await using var sub = await bob.SubscribeCoreAsync<string>("test.subject");
|
|
await bob.PingAsync();
|
|
|
|
await alice.PublishAsync("test.subject", "from-alice");
|
|
|
|
// Bob should NOT receive this — wait briefly then verify nothing arrived
|
|
using var timeout = new CancellationTokenSource(TimeSpan.FromMilliseconds(500));
|
|
try
|
|
{
|
|
await sub.Msgs.ReadAsync(timeout.Token);
|
|
throw new Exception("Bob should not have received a message from a different account");
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
// Expected — no message received (timeout)
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
**Step 2: Run test to verify it fails**
|
|
|
|
Run: `dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~AccountIsolationTests" -v normal`
|
|
Expected: FAIL — account isolation not implemented.
|
|
|
|
**Step 3: Implement account-aware routing**
|
|
|
|
The key changes (building on Task 10):
|
|
|
|
1. In `NatsServer`, add account resolution:
|
|
```csharp
|
|
public Account GetOrCreateAccount(string? name)
|
|
{
|
|
if (string.IsNullOrEmpty(name))
|
|
return _globalAccount;
|
|
return _accounts.GetOrAdd(name, n => new Account(n));
|
|
}
|
|
```
|
|
|
|
2. In `NatsClient`, after auth success, resolve account:
|
|
```csharp
|
|
_account = _server.GetOrCreateAccount(result.AccountName);
|
|
_account.AddClient(Id);
|
|
```
|
|
|
|
3. In `NatsClient.ProcessSub`, insert subscription into account's SubList:
|
|
```csharp
|
|
_account?.SubList.Insert(sub);
|
|
```
|
|
|
|
4. In `NatsServer.ProcessMessage`, use the sender's account SubList:
|
|
```csharp
|
|
public void ProcessMessage(string subject, string? replyTo, ReadOnlyMemory<byte> headers,
|
|
ReadOnlyMemory<byte> payload, NatsClient sender)
|
|
{
|
|
var subList = sender.Account?.SubList ?? _globalAccount.SubList;
|
|
var result = subList.Match(subject);
|
|
// ... rest of delivery logic unchanged
|
|
}
|
|
```
|
|
|
|
5. In cleanup (`RemoveClient`), remove from account:
|
|
```csharp
|
|
client.Account?.RemoveClient(client.Id);
|
|
client.RemoveAllSubscriptions(client.Account?.SubList ?? _globalAccount.SubList);
|
|
```
|
|
|
|
**Step 4: Run tests to verify they pass**
|
|
|
|
Run: `dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~AccountIsolationTests" -v normal`
|
|
Expected: Both tests PASS.
|
|
|
|
**Step 5: Run full test suite**
|
|
|
|
Run: `dotnet test`
|
|
Expected: All tests pass.
|
|
|
|
**Step 6: Commit**
|
|
|
|
```bash
|
|
git add src/NATS.Server/NatsServer.cs src/NATS.Server/NatsClient.cs tests/NATS.Server.Tests/AccountIsolationTests.cs
|
|
git commit -m "feat: add per-account SubList isolation for message routing"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 12: Add permission enforcement integration tests
|
|
|
|
**Files:**
|
|
- Test: `tests/NATS.Server.Tests/PermissionIntegrationTests.cs`
|
|
|
|
**Step 1: Write permission integration tests**
|
|
|
|
Create `tests/NATS.Server.Tests/PermissionIntegrationTests.cs`:
|
|
|
|
```csharp
|
|
using System.Net;
|
|
using System.Net.Sockets;
|
|
using System.Text;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using NATS.Client.Core;
|
|
using NATS.Server;
|
|
using NATS.Server.Auth;
|
|
|
|
namespace NATS.Server.Tests;
|
|
|
|
public class PermissionIntegrationTests : IAsyncLifetime
|
|
{
|
|
private NatsServer _server = null!;
|
|
private int _port;
|
|
private readonly CancellationTokenSource _cts = new();
|
|
private Task _serverTask = null!;
|
|
|
|
private static int GetFreePort()
|
|
{
|
|
using var sock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
|
sock.Bind(new IPEndPoint(IPAddress.Loopback, 0));
|
|
return ((IPEndPoint)sock.LocalEndPoint!).Port;
|
|
}
|
|
|
|
public async Task InitializeAsync()
|
|
{
|
|
_port = GetFreePort();
|
|
_server = new NatsServer(new NatsOptions
|
|
{
|
|
Port = _port,
|
|
Users =
|
|
[
|
|
new User
|
|
{
|
|
Username = "publisher",
|
|
Password = "pass",
|
|
Permissions = new Permissions
|
|
{
|
|
Publish = new SubjectPermission { Allow = ["events.>"] },
|
|
Subscribe = new SubjectPermission { Deny = [">"] },
|
|
},
|
|
},
|
|
new User
|
|
{
|
|
Username = "subscriber",
|
|
Password = "pass",
|
|
Permissions = new Permissions
|
|
{
|
|
Publish = new SubjectPermission { Deny = [">"] },
|
|
Subscribe = new SubjectPermission { Allow = ["events.>"] },
|
|
},
|
|
},
|
|
new User
|
|
{
|
|
Username = "admin",
|
|
Password = "pass",
|
|
// No permissions — full access
|
|
},
|
|
],
|
|
}, NullLoggerFactory.Instance);
|
|
|
|
_serverTask = _server.StartAsync(_cts.Token);
|
|
await _server.WaitForReadyAsync();
|
|
}
|
|
|
|
public async Task DisposeAsync()
|
|
{
|
|
await _cts.CancelAsync();
|
|
_server.Dispose();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Publisher_can_publish_to_allowed_subject()
|
|
{
|
|
await using var pub = new NatsConnection(new NatsOpts
|
|
{
|
|
Url = $"nats://publisher:pass@127.0.0.1:{_port}",
|
|
});
|
|
await using var admin = new NatsConnection(new NatsOpts
|
|
{
|
|
Url = $"nats://admin:pass@127.0.0.1:{_port}",
|
|
});
|
|
|
|
await pub.ConnectAsync();
|
|
await admin.ConnectAsync();
|
|
|
|
await using var sub = await admin.SubscribeCoreAsync<string>("events.test");
|
|
await admin.PingAsync();
|
|
|
|
await pub.PublishAsync("events.test", "hello");
|
|
|
|
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
|
var msg = await sub.Msgs.ReadAsync(timeout.Token);
|
|
msg.Data.ShouldBe("hello");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Admin_has_full_access()
|
|
{
|
|
await using var admin1 = new NatsConnection(new NatsOpts
|
|
{
|
|
Url = $"nats://admin:pass@127.0.0.1:{_port}",
|
|
});
|
|
await using var admin2 = new NatsConnection(new NatsOpts
|
|
{
|
|
Url = $"nats://admin:pass@127.0.0.1:{_port}",
|
|
});
|
|
|
|
await admin1.ConnectAsync();
|
|
await admin2.ConnectAsync();
|
|
|
|
await using var sub = await admin2.SubscribeCoreAsync<string>("anything.at.all");
|
|
await admin2.PingAsync();
|
|
|
|
await admin1.PublishAsync("anything.at.all", "data");
|
|
|
|
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
|
var msg = await sub.Msgs.ReadAsync(timeout.Token);
|
|
msg.Data.ShouldBe("data");
|
|
}
|
|
}
|
|
```
|
|
|
|
**Step 2: Run tests**
|
|
|
|
Run: `dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~PermissionIntegrationTests" -v normal`
|
|
Expected: All tests PASS (permissions were integrated in Task 10).
|
|
|
|
**Step 3: Commit**
|
|
|
|
```bash
|
|
git add tests/NATS.Server.Tests/PermissionIntegrationTests.cs
|
|
git commit -m "test: add permission enforcement integration tests"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 13: Add NKey integration test
|
|
|
|
**Files:**
|
|
- Test: `tests/NATS.Server.Tests/NKeyIntegrationTests.cs`
|
|
|
|
**Step 1: Write NKey integration test**
|
|
|
|
Create `tests/NATS.Server.Tests/NKeyIntegrationTests.cs`:
|
|
|
|
```csharp
|
|
using System.Net;
|
|
using System.Net.Sockets;
|
|
using System.Text;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using NATS.Client.Core;
|
|
using NATS.NKeys;
|
|
using NATS.Server;
|
|
using NATS.Server.Auth;
|
|
|
|
namespace NATS.Server.Tests;
|
|
|
|
public class NKeyIntegrationTests : IAsyncLifetime
|
|
{
|
|
private NatsServer _server = null!;
|
|
private int _port;
|
|
private readonly CancellationTokenSource _cts = new();
|
|
private Task _serverTask = null!;
|
|
private KeyPair _userKeyPair = null!;
|
|
private string _userSeed = null!;
|
|
private string _userPublicKey = null!;
|
|
|
|
private static int GetFreePort()
|
|
{
|
|
using var sock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
|
sock.Bind(new IPEndPoint(IPAddress.Loopback, 0));
|
|
return ((IPEndPoint)sock.LocalEndPoint!).Port;
|
|
}
|
|
|
|
public async Task InitializeAsync()
|
|
{
|
|
_port = GetFreePort();
|
|
_userKeyPair = KeyPair.CreateUser();
|
|
_userPublicKey = _userKeyPair.GetPublicKey();
|
|
_userSeed = _userKeyPair.GetSeed();
|
|
|
|
_server = new NatsServer(new NatsOptions
|
|
{
|
|
Port = _port,
|
|
NKeys = [new NKeyUser { Nkey = _userPublicKey }],
|
|
}, NullLoggerFactory.Instance);
|
|
|
|
_serverTask = _server.StartAsync(_cts.Token);
|
|
await _server.WaitForReadyAsync();
|
|
}
|
|
|
|
public async Task DisposeAsync()
|
|
{
|
|
await _cts.CancelAsync();
|
|
_server.Dispose();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task NKey_auth_success()
|
|
{
|
|
await using var client = new NatsConnection(new NatsOpts
|
|
{
|
|
Url = $"nats://127.0.0.1:{_port}",
|
|
AuthOpts = new NatsAuthOpts { NKey = _userPublicKey, Seed = _userSeed },
|
|
});
|
|
|
|
await client.ConnectAsync();
|
|
await client.PingAsync();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task NKey_auth_wrong_key_fails()
|
|
{
|
|
// Generate a different key pair not known to the server
|
|
var otherKp = KeyPair.CreateUser();
|
|
|
|
await using var client = new NatsConnection(new NatsOpts
|
|
{
|
|
Url = $"nats://127.0.0.1:{_port}",
|
|
AuthOpts = new NatsAuthOpts { NKey = otherKp.GetPublicKey(), Seed = otherKp.GetSeed() },
|
|
});
|
|
|
|
var ex = await Should.ThrowAsync<Exception>(async () =>
|
|
{
|
|
await client.ConnectAsync();
|
|
await client.PingAsync();
|
|
});
|
|
}
|
|
}
|
|
```
|
|
|
|
> **Note for implementer:** The NATS.Client.Core library handles NKey signing automatically when you provide the NKey and Seed in `NatsAuthOpts`. It reads the server's nonce from INFO, signs it with the seed, and sends the public key + signature in CONNECT. Verify that `NATS.Client.Core` 2.7.2 supports `NatsAuthOpts.NKey` and `NatsAuthOpts.Seed` — check the NuGet docs if needed.
|
|
|
|
**Step 2: Run tests**
|
|
|
|
Run: `dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~NKeyIntegrationTests" -v normal`
|
|
Expected: Both tests PASS.
|
|
|
|
**Step 3: Commit**
|
|
|
|
```bash
|
|
git add tests/NATS.Server.Tests/NKeyIntegrationTests.cs
|
|
git commit -m "test: add NKey authentication integration tests"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 14: Final regression test and cleanup
|
|
|
|
**Files:**
|
|
- No new files — run all tests and verify
|
|
|
|
**Step 1: Run full test suite**
|
|
|
|
Run: `dotnet test -v normal`
|
|
Expected: ALL tests pass — both old (parser, sublist, subject match, client, server, integration) and new (auth protocol, auth config, account, token auth, user/pass auth, simple user/pass auth, nkey auth, auth service, client permissions, auth integration, account isolation, permission integration, nkey integration).
|
|
|
|
**Step 2: Build with no warnings**
|
|
|
|
Run: `dotnet build -warnaserror`
|
|
Expected: Build succeeds with zero warnings.
|
|
|
|
**Step 3: Commit (if any cleanup needed)**
|
|
|
|
```bash
|
|
git add -A
|
|
git commit -m "chore: final cleanup for authentication implementation"
|
|
```
|
|
|
|
---
|
|
|
|
## File Summary
|
|
|
|
### New Files (13)
|
|
| File | Purpose |
|
|
|------|---------|
|
|
| `src/NATS.Server/Auth/User.cs` | Username/password user model |
|
|
| `src/NATS.Server/Auth/NKeyUser.cs` | NKey user model |
|
|
| `src/NATS.Server/Auth/Permissions.cs` | Permission types (SubjectPermission, ResponsePermission) |
|
|
| `src/NATS.Server/Auth/Account.cs` | Per-account SubList and client tracking |
|
|
| `src/NATS.Server/Auth/IAuthenticator.cs` | Auth interface and ClientAuthContext |
|
|
| `src/NATS.Server/Auth/AuthResult.cs` | Authentication result |
|
|
| `src/NATS.Server/Auth/TokenAuthenticator.cs` | Token-based auth |
|
|
| `src/NATS.Server/Auth/UserPasswordAuthenticator.cs` | Multi-user password auth |
|
|
| `src/NATS.Server/Auth/SimpleUserPasswordAuthenticator.cs` | Single user/pass auth |
|
|
| `src/NATS.Server/Auth/NKeyAuthenticator.cs` | NKey Ed25519 auth |
|
|
| `src/NATS.Server/Auth/AuthService.cs` | Auth orchestrator |
|
|
| `src/NATS.Server/Auth/ClientPermissions.cs` | Publish/subscribe permission enforcement |
|
|
|
|
### Modified Files (5)
|
|
| File | Changes |
|
|
|------|---------|
|
|
| `Directory.Packages.props` | Add NATS.NKeys, BCrypt.Net-Next |
|
|
| `src/NATS.Server/NATS.Server.csproj` | Add package references |
|
|
| `src/NATS.Server/Protocol/NatsProtocol.cs` | Auth errors, ServerInfo.AuthRequired/Nonce, ClientOptions auth fields |
|
|
| `src/NATS.Server/NatsOptions.cs` | Auth configuration fields |
|
|
| `src/NATS.Server/NatsServer.cs` | AuthService integration, account management, per-client INFO |
|
|
| `src/NATS.Server/NatsClient.cs` | Auth validation, permission checks, account assignment |
|
|
|
|
### New Test Files (9)
|
|
| File | Tests |
|
|
|------|-------|
|
|
| `tests/NATS.Server.Tests/AuthProtocolTests.cs` | Protocol type serialization |
|
|
| `tests/NATS.Server.Tests/AuthConfigTests.cs` | Options defaults |
|
|
| `tests/NATS.Server.Tests/AccountTests.cs` | Account model |
|
|
| `tests/NATS.Server.Tests/TokenAuthenticatorTests.cs` | Token auth |
|
|
| `tests/NATS.Server.Tests/UserPasswordAuthenticatorTests.cs` | User/pass auth |
|
|
| `tests/NATS.Server.Tests/SimpleUserPasswordAuthenticatorTests.cs` | Simple user/pass auth |
|
|
| `tests/NATS.Server.Tests/NKeyAuthenticatorTests.cs` | NKey auth |
|
|
| `tests/NATS.Server.Tests/AuthServiceTests.cs` | Auth orchestration |
|
|
| `tests/NATS.Server.Tests/ClientPermissionsTests.cs` | Permission checks |
|
|
| `tests/NATS.Server.Tests/AuthIntegrationTests.cs` | Full auth integration |
|
|
| `tests/NATS.Server.Tests/AccountIsolationTests.cs` | Cross-account isolation |
|
|
| `tests/NATS.Server.Tests/PermissionIntegrationTests.cs` | Permission enforcement |
|
|
| `tests/NATS.Server.Tests/NKeyIntegrationTests.cs` | NKey end-to-end |
|