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:
286
docs/plans/2026-02-22-authentication-design.md
Normal file
286
docs/plans/2026-02-22-authentication-design.md
Normal 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
|
||||
Reference in New Issue
Block a user