docs: add authentication design for username/password, token, and NKeys

Covers auth models, strategy pattern with pluggable authenticators,
permission enforcement, core account isolation, and server integration.
This commit is contained in:
Joseph Doherty
2026-02-22 22:07:16 -05:00
parent 1813250a9e
commit bca703b310

View File

@@ -0,0 +1,286 @@
# Authentication Design
**Date:** 2026-02-22
**Scope:** Username/password, token, NKeys, permissions, core account isolation
**Approach:** Strategy pattern with pluggable authenticators
## Overview
Port the NATS server authentication subsystem from Go (`server/auth.go`, `server/accounts.go`) to .NET. The system authenticates clients during the CONNECT phase using nonce-based challenge-response (NKeys), password comparison (plain/bcrypt), or bearer tokens. Each authenticated user can have per-subject publish/subscribe permissions. Clients are assigned to accounts that provide subscription isolation.
## Core Auth Models
### User (username/password auth)
```csharp
public sealed class User
{
public required string Username { get; init; }
public required string Password { get; init; } // plain or bcrypt ($2...)
public Permissions? Permissions { get; init; }
public Account? Account { get; init; }
public DateTimeOffset? ConnectionDeadline { get; init; }
}
```
### NKeyUser (Ed25519 auth)
```csharp
public sealed class NKeyUser
{
public required string Nkey { get; init; } // public nkey
public Permissions? Permissions { get; init; }
public Account? Account { get; init; }
public string? SigningKey { get; init; }
}
```
### Permissions
```csharp
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; }
}
```
### Account
Per-account isolation with its own SubList:
```csharp
public sealed class Account
{
public string Name { get; }
public SubList SubList { get; }
public ConcurrentDictionary<ulong, NatsClient> Clients { get; }
public Permissions? DefaultPermissions { get; set; }
}
```
Server holds `ConcurrentDictionary<string, Account>` with lazy global account (`DEFAULT`).
## Authentication Flow
```
Client connects
-> Server sends INFO { auth_required: true, nonce: "..." }
-> Client sends CONNECT { user, pass, token, nkey, sig }
-> AuthService.Authenticate(context)
|- NKeyAuthenticator (if nkeys configured)
|- UserPasswordAuthenticator (if users configured)
|- TokenAuthenticator (if token configured)
|- SimpleUserPasswordAuthenticator (if single user configured)
'- NoAuthUser fallback (if configured)
-> Success: assign permissions, assign account
-> Failure: -ERR 'Authorization Violation', disconnect
```
### Interfaces
```csharp
public interface IAuthenticator
{
AuthResult? Authenticate(ClientAuthContext context);
}
public sealed class ClientAuthContext
{
public required ClientOptions Opts { get; init; }
public required byte[] Nonce { get; init; }
public required NatsClient Client { get; init; }
}
public sealed class AuthResult
{
public required string Identity { get; init; }
public Account Account { get; init; }
public Permissions? Permissions { get; init; }
public DateTimeOffset? Expiry { get; init; }
}
```
### AuthService
Orchestrates authenticators in priority order (matching Go's `processClientOrLeafAuthentication`):
```csharp
public sealed class AuthService
{
private readonly List<IAuthenticator> _authenticators;
public AuthResult? Authenticate(ClientAuthContext context);
public bool IsAuthRequired { get; }
}
```
### Concrete Authenticators
- **NKeyAuthenticator** - looks up nkey in server map, verifies Ed25519 signature against nonce using NATS.NKeys
- **UserPasswordAuthenticator** - looks up username in server map, compares password (bcrypt via BCrypt.Net-Next, or constant-time plain comparison)
- **TokenAuthenticator** - constant-time comparison against configured token
- **SimpleUserPasswordAuthenticator** - for single `username`/`password` option (no map lookup)
### Auth Timeout
`CancellationTokenSource` with configurable timeout (default 1 second, matching Go `DEFAULT_AUTH_TIMEOUT`). If CONNECT not received and authenticated within window, disconnect client.
### Nonce Generation
Per-client nonce: 11 random bytes, base64url-encoded to 15 chars (matching Go). Generated using `RandomNumberGenerator.Fill()`. Included in INFO only when auth is required.
### Password Comparison
- Bcrypt passwords (prefixed `$2`): use `BCrypt.Net.BCrypt.Verify()` (constant-time)
- Plain passwords: use `CryptographicOperations.FixedTimeEquals()` (constant-time)
## Permission Enforcement
### ClientPermissions
```csharp
public sealed class ClientPermissions
{
public PermissionSet? Publish { get; init; }
public PermissionSet? Subscribe { get; init; }
public ResponsePermission? Response { get; init; }
private readonly ConcurrentDictionary<string, bool> _pubCache = new();
public bool IsPublishAllowed(string subject);
public bool IsSubscribeAllowed(string subject, string? queue = null);
}
public sealed class PermissionSet
{
public SubList? Allow { get; init; }
public SubList? Deny { get; init; }
}
```
### Logic (matches Go)
1. No permissions set -> allow everything
2. Allow list exists -> subject must match allow list
3. Deny list exists -> subject must NOT match deny list
4. Subscribe checks queue groups against queue-specific entries
### Integration Points
- `ProcessPub()` - check `IsPublishAllowed(subject)` before routing. `-ERR 'Permissions Violation for Publish to "<subject>"'` on deny.
- `ProcessSub()` - check `IsSubscribeAllowed(subject, queue)` before adding subscription. `-ERR 'Permissions Violation for Subscription to "<subject>"'` on deny.
## Configuration Changes
### NatsOptions
```csharp
public sealed class NatsOptions
{
// ... existing fields ...
// Simple auth (single user)
public string? Username { get; set; }
public string? Password { get; set; }
public string? Authorization { get; set; } // bearer token
// 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);
}
```
### ServerInfo Additions
```csharp
public bool AuthRequired { get; set; }
public string? Nonce { get; set; }
```
### ClientOptions Additions
```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; }
```
## Server/Client Integration
### NatsServer
- Build `AuthService` during startup based on options
- Maintain `ConcurrentDictionary<string, Account>` with lazy global account
- Build `users` and `nkeys` lookup maps from options
- Generate per-client nonce when `AuthService.IsAuthRequired`
- Include `AuthRequired` and `Nonce` in per-client INFO
### NatsClient
- Store `ClientPermissions` after successful auth
- Store assigned `Account` reference
- Auth timeout via `CancellationTokenSource` on CONNECT wait
- Register/unregister with account's client set on connect/disconnect
- PUB messages route through account's SubList for isolation
## NuGet Packages
- `NATS.NKeys` - Ed25519 NKey operations
- `BCrypt.Net-Next` - bcrypt password hashing
## Testing Strategy
### Unit Tests
- `TokenAuthenticatorTests` - correct/wrong token, constant-time comparison
- `UserPasswordAuthenticatorTests` - plain password, bcrypt password, wrong password, unknown user
- `NKeyAuthenticatorTests` - valid/invalid signature, unknown nkey
- `AuthServiceTests` - priority ordering, NoAuthUser fallback, auth-not-required case
- `ClientPermissionsTests` - allow/deny lists, wildcards, queue groups, publish cache
### Account Tests
- Two accounts configured, cross-account isolation verified
### Integration Tests
- Connect with valid token/user-pass/nkey -> success
- Connect with wrong credentials -> `-ERR`, disconnect
- Connect with no credentials when auth required -> `-ERR`, disconnect
- NoAuthUser configured -> success with default permissions
- Auth timeout -> disconnect
- Publish/subscribe to denied subjects -> `-ERR 'Permissions Violation ...'`
## Go Reference Files
- `server/auth.go` - Core authentication logic
- `server/accounts.go` - Account struct and management
- `server/client.go` - CONNECT processing, RegisterUser, RegisterNkeyUser, permission checks
- `server/nkey.go` - Nonce generation
- `server/opts.go` - Auth configuration parsing