Files
natsdotnet/Documentation/Authentication/Overview.md
Joseph Doherty e553db6d40 docs: add Authentication, Clustering, JetStream, Monitoring overviews; update existing docs
New files:
- Documentation/Authentication/Overview.md — all 7 auth mechanisms with real source
  snippets (NKey/JWT/username-password/token/TLS mapping), nonce generation, account
  system, permissions, JWT permission templates
- Documentation/Clustering/Overview.md — route TCP handshake, in-process subscription
  propagation, gateway/leaf node stubs, honest gaps list
- Documentation/JetStream/Overview.md — API surface (4 handled subjects), streams,
  consumers, storage (MemStore/FileStore), in-process RAFT, mirror/source, gaps list
- Documentation/Monitoring/Overview.md — all 12 endpoints with real field tables,
  Go compatibility notes

Updated files:
- GettingStarted/Architecture.md — 14-subdirectory tree, real NatsClient/NatsServer
  field snippets, 9 new Go reference rows, Channel write queue design choice
- GettingStarted/Setup.md — xUnit 3, 100 test files grouped by area
- Operations/Overview.md — 99 test files, accurate Program.cs snippet, limitations
  section renamed to "Known Gaps vs Go Reference" with 7 real gaps
- Server/Overview.md — grouped fields, TLS/WS accept path, lame-duck mode, POSIX signals
- Configuration/Overview.md — 14 subsystem option tables, 24-row CLI table, LogOverrides
- Server/Client.md — Channel write queue, 4-task RunAsync, CommandMatrix, real fields

All docs verified against codebase 2026-02-23; 713 tests pass.
2026-02-23 10:14:18 -05:00

642 lines
24 KiB
Markdown

# Authentication Overview
`AuthService` is the single entry point for client authentication. It builds an ordered chain of authenticators from `NatsOptions` at startup and evaluates them in priority order when a client sends a `CONNECT` message. Each authenticator inspects the `ClientAuthContext` and returns an `AuthResult` on success or `null` to pass to the next authenticator in the chain.
---
## How Authentication Works
`AuthService.Build()` constructs the authenticator chain at server startup. The order matches the Go reference (see `golang/nats-server/server/auth.go`, `configureAuthentication`):
```csharp
// AuthService.cs — AuthService.Build()
public static AuthService Build(NatsOptions options)
{
var authenticators = new List<IAuthenticator>();
// TLS certificate mapping (highest priority when enabled)
if (options.TlsMap && options.TlsVerify && options.Users is { Count: > 0 })
authenticators.Add(new TlsMapAuthenticator(options.Users));
// JWT / Operator mode
if (options.TrustedKeys is { Length: > 0 } && options.AccountResolver is not null)
{
authenticators.Add(new JwtAuthenticator(options.TrustedKeys, options.AccountResolver));
nonceRequired = true;
}
// Priority order: NKeys > Users > Token > SimpleUserPassword
if (options.NKeys is { Count: > 0 })
authenticators.Add(new NKeyAuthenticator(options.NKeys));
if (options.Users is { Count: > 0 })
authenticators.Add(new UserPasswordAuthenticator(options.Users));
if (!string.IsNullOrEmpty(options.Authorization))
authenticators.Add(new TokenAuthenticator(options.Authorization));
if (!string.IsNullOrEmpty(options.Username) && !string.IsNullOrEmpty(options.Password))
authenticators.Add(new SimpleUserPasswordAuthenticator(options.Username, options.Password));
}
```
`NonceRequired` is set to `true` when JWT or NKey authenticators are active. The server includes a nonce in the `INFO` message before accepting `CONNECT`, so clients can sign it.
`Authenticate()` iterates the chain and returns the first non-null result. If all authenticators decline and a `NoAuthUser` is configured, it falls back to that user — but only when the client presented no credentials at all:
```csharp
// AuthService.cs — Authenticate() and IsNoCredentials()
public AuthResult? Authenticate(ClientAuthContext context)
{
if (!IsAuthRequired)
return new AuthResult { Identity = string.Empty };
foreach (var authenticator in _authenticators)
{
var result = authenticator.Authenticate(context);
if (result != null)
return result;
}
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)
&& string.IsNullOrEmpty(opts.JWT);
}
```
A `null` return from `Authenticate()` causes the server to reject the connection with an `-ERR 'Authorization Violation'` message.
---
## Auth Mechanisms
### TLS certificate mapping
`TlsMapAuthenticator` maps a client's TLS certificate to a configured `User` by matching the certificate subject Distinguished Name (DN) or Common Name (CN). This fires only when `tls_map: true` and `tls: { verify: true }` are both set alongside a `users` block.
```csharp
// TlsMapAuthenticator.cs — Authenticate()
public AuthResult? Authenticate(ClientAuthContext context)
{
var cert = context.ClientCertificate;
if (cert == null)
return null;
var dn = cert.SubjectName;
var dnString = dn.Name; // RFC 2253 format
// Try exact DN match first
if (_usersByDn.TryGetValue(dnString, out var user))
return BuildResult(user);
// Try CN extraction
var cn = ExtractCn(dn);
if (cn != null && _usersByCn.TryGetValue(cn, out user))
return BuildResult(user);
return null;
}
private static string? ExtractCn(X500DistinguishedName dn)
{
foreach (var rdn in dn.Name.Split(',', StringSplitOptions.TrimEntries))
{
if (rdn.StartsWith("CN=", StringComparison.OrdinalIgnoreCase))
return rdn[3..];
}
return null;
}
```
CN extraction splits the RFC 2253 DN string on commas and looks for the `CN=` attribute. The username in the `users` block must match either the full DN or the CN value.
### JWT / Operator mode
`JwtAuthenticator` validates client JWTs in operator mode. The server is configured with one or more trusted operator NKey public keys and an `IAccountResolver` that maps account NKey public keys to account JWTs. Validation runs nine steps before authentication succeeds:
```csharp
// JwtAuthenticator.cs — Authenticate() (steps 1-7)
var userClaims = NatsJwt.DecodeUserClaims(jwt); // 1. Decode user JWT
if (userClaims.IsExpired()) return null; // 2. Check expiry
var accountJwt = _resolver.FetchAsync(issuerAccount) // 3. Resolve account JWT
.GetAwaiter().GetResult();
var accountClaims = NatsJwt.DecodeAccountClaims(accountJwt);
if (!IsTrusted(accountClaims.Issuer)) return null; // 4. Account issuer must be trusted operator
// 5. User JWT must be issued by the account or one of its signing keys
if (userIssuer != accountClaims.Subject)
{
var signingKeys = accountClaims.Nats?.SigningKeys;
if (signingKeys is null || !signingKeys.Contains(userIssuer))
return null;
}
if (!userClaims.BearerToken) // 6. Verify nonce signature
{
if (!NatsJwt.VerifyNonce(context.Nonce, context.Opts.Sig, userNkey))
return null;
}
if (revocations.TryGetValue(userClaims.Subject, out var revokedAt)) // 7. Revocation check
if (userClaims.IssuedAt <= revokedAt) return null;
```
The `IAccountResolver` interface decouples JWT storage from the authenticator. `MemAccountResolver` covers tests and simple single-operator deployments; production deployments can supply a resolver backed by a URL or directory:
```csharp
// AccountResolver.cs
public sealed class MemAccountResolver : IAccountResolver
{
private readonly ConcurrentDictionary<string, string> _accounts = new(StringComparer.Ordinal);
public Task<string?> FetchAsync(string accountNkey)
{
_accounts.TryGetValue(accountNkey, out var jwt);
return Task.FromResult(jwt);
}
public Task StoreAsync(string accountNkey, string jwt)
{
_accounts[accountNkey] = jwt;
return Task.CompletedTask;
}
}
```
`NatsJwt.Decode()` splits the token into header, payload, and signature segments and uses `System.Text.Json` to deserialize them. All NATS JWTs use the `ed25519-nkey` algorithm and start with `eyJ` (base64url for `{"`).
### NKey
`NKeyAuthenticator` performs Ed25519 public-key authentication without a JWT. The client sends its public NKey and a base64-encoded signature of the server nonce. The server verifies the signature using the `NATS.NKeys` library:
```csharp
// NKeyAuthenticator.cs — Authenticate()
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;
// Decode base64 signature (handle both standard and URL-safe base64)
byte[] sigBytes;
try { sigBytes = Convert.FromBase64String(clientSig); }
catch (FormatException)
{
var padded = clientSig.Replace('-', '+').Replace('_', '/');
padded = padded.PadRight(padded.Length + (4 - padded.Length % 4) % 4, '=');
sigBytes = Convert.FromBase64String(padded);
}
var kp = KeyPair.FromPublicKey(clientNkey);
if (!kp.Verify(context.Nonce, sigBytes)) return null;
return new AuthResult { Identity = clientNkey, AccountName = nkeyUser.Account,
Permissions = nkeyUser.Permissions };
}
```
The signature fallback handles both URL-safe and standard base64 encoding because different NATS client libraries encode signatures differently.
### Username/password — multi-user
`UserPasswordAuthenticator` handles the `users` block where multiple username/password pairs are defined. It supports both plain-text and bcrypt-hashed passwords. The `$2` prefix detection matches the Go server's `isBcrypt()` function:
```csharp
// UserPasswordAuthenticator.cs — ComparePasswords()
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");
```
Plain-text passwords use `CryptographicOperations.FixedTimeEquals` to prevent timing attacks. Bcrypt hashes are prefixed with `$2a$`, `$2b$`, or `$2y$` depending on the variant.
### Username/password — single user
`SimpleUserPasswordAuthenticator` covers the common case of a single `user`/`password` pair in the server config. It applies constant-time comparison for both the username and password:
```csharp
// SimpleUserPasswordAuthenticator.cs — Authenticate()
public AuthResult? Authenticate(ClientAuthContext context)
{
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 };
}
```
Comparing the username in constant time prevents an attacker from using response timing to enumerate valid usernames even before the password check.
### Token
`TokenAuthenticator` matches a single opaque authorization token against the `authorization` config key. Comparison is constant-time to prevent length-based timing leaks:
```csharp
// TokenAuthenticator.cs
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" };
}
}
```
The token authenticator does not associate an account or permissions — those must be managed at the server level.
### No-auth user fallback
When `NoAuthUser` is set in `NatsOptions`, clients that present no credentials at all (no username, password, token, NKey, or JWT) are mapped to that named user. The fallback only applies after all authenticators have declined. The resolution pulls the user's permissions and account assignment from the `users` map built during `Build()`:
```csharp
// AuthService.cs — ResolveNoAuthUser()
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 };
}
```
This pattern lets an operator define one permissive "guest" user and one or more restricted named users without requiring every client to authenticate explicitly.
---
## Nonce Generation
When `NonceRequired` is `true`, the server generates a nonce before sending `INFO` and includes it in the `nonce` field. The client must sign this nonce with its private key and return the signature in the `sig` field of `CONNECT`.
The nonce is 11 random bytes encoded as URL-safe base64 (no padding). 11 bytes produce 15 base64 characters, which avoids padding characters entirely:
```csharp
// AuthService.cs — GenerateNonce() and EncodeNonce()
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('/', '_');
}
```
The raw nonce bytes (not the base64 string) are passed to `ClientAuthContext.Nonce` so that NKey and JWT signature verification receive the exact bytes that were sent on the wire as a base64 string but verified as the original bytes.
---
## The Account System
Every authenticated connection belongs to an `Account`. Accounts provide subject namespace isolation: each `Account` owns a dedicated `SubList`, so messages published within one account never reach subscribers in another unless an explicit export/import is configured.
```csharp
// Account.cs
public sealed class Account : IDisposable
{
public const string GlobalAccountName = "$G";
public string Name { get; }
public SubList SubList { get; } = new();
public int MaxConnections { get; set; } // 0 = unlimited
public int MaxSubscriptions { get; set; } // 0 = unlimited
public int MaxJetStreamStreams { get; set; } // 0 = unlimited
public ExportMap Exports { get; } = new();
public ImportMap Imports { get; } = new();
}
```
The `$G` (Global) account is the default when no multi-account configuration is present. All clients that authenticate without an explicit account name join `$G`.
Resource limits are enforced with atomic counters at connection and subscription time:
```csharp
// Account.cs — AddClient() and IncrementSubscriptions()
public bool AddClient(ulong clientId)
{
if (MaxConnections > 0 && _clients.Count >= MaxConnections)
return false;
_clients[clientId] = 0;
return true;
}
public bool IncrementSubscriptions()
{
if (MaxSubscriptions > 0 && Volatile.Read(ref _subscriptionCount) >= MaxSubscriptions)
return false;
Interlocked.Increment(ref _subscriptionCount);
return true;
}
public bool TryReserveStream()
{
if (MaxJetStreamStreams > 0 && Volatile.Read(ref _jetStreamStreamCount) >= MaxJetStreamStreams)
return false;
Interlocked.Increment(ref _jetStreamStreamCount);
return true;
}
```
`AddClient` checks the limit before inserting. The `_clients` dictionary uses `ulong` client IDs as keys so `ClientCount` reflects only the current live connections for that account.
User revocation is per-account and supports both individual user NKeys and a `"*"` wildcard that revokes all users issued before a given timestamp:
```csharp
// Account.cs — IsUserRevoked()
public bool IsUserRevoked(string userNkey, long issuedAt)
{
if (_revokedUsers.TryGetValue(userNkey, out var revokedAt))
return issuedAt <= revokedAt;
if (_revokedUsers.TryGetValue("*", out revokedAt))
return issuedAt <= revokedAt;
return false;
}
```
Exports and imports allow subjects to be shared between accounts. A service export makes a set of subjects callable by other accounts; a stream export allows subscriptions. Imports wire an external subject into the current account's namespace under a local alias. Both directions enforce authorization at configuration time via `ExportAuth.IsAuthorized()`.
---
## Permissions
`Permissions` defines per-client publish and subscribe rules via `SubjectPermission` allow/deny lists and an optional `ResponsePermission` for request-reply:
```csharp
// Permissions.cs
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; }
}
```
`ClientPermissions.Build()` compiles these into `PermissionSet` instances backed by `SubList` tries, so wildcard patterns in allow/deny lists are matched using the same trie that handles subscriptions. `PermissionSet.IsAllowed()` evaluates allow first, then deny:
```csharp
// ClientPermissions.cs — PermissionSet.IsAllowed()
public bool IsAllowed(string subject)
{
bool allowed = true;
if (_allow != null)
{
var result = _allow.Match(subject);
allowed = result.PlainSubs.Length > 0 || result.QueueSubs.Length > 0;
}
if (allowed && _deny != null)
{
var result = _deny.Match(subject);
allowed = result.PlainSubs.Length == 0 && result.QueueSubs.Length == 0;
}
return allowed;
}
```
A subject is allowed when it matches the allow list (or no allow list exists) and does not match any deny entry. Deny rules take precedence over allow rules when both match.
### PermissionLruCache
Permission checks happen on every `PUB` and `SUB` command. To avoid a `SubList.Match()` call on every message, `ClientPermissions` maintains a `PermissionLruCache` per client for publish results:
```csharp
// PermissionLruCache.cs
public sealed class PermissionLruCache
{
private readonly int _capacity;
private readonly Dictionary<string, LinkedListNode<(string Key, bool Value)>> _map;
private readonly LinkedList<(string Key, bool Value)> _list = new();
public void Set(string key, bool value)
{
if (_map.Count >= _capacity)
{
var last = _list.Last!;
_map.Remove(last.Value.Key);
_list.RemoveLast();
}
var node = new LinkedListNode<(string Key, bool Value)>((key, value));
_list.AddFirst(node);
_map[key] = node;
}
}
```
The default capacity is 128, matching the Go server (`maxPermCacheSize = 128` in `client.go`). Cache hits move the node to the front of the linked list; eviction removes the tail node. The cache is per-client and lock-protected, so contention is low.
Dynamic reply subjects bypass the cache. When a client sends a request with a reply subject, that subject is registered in `ResponseTracker` and bypasses the deny check for the configured window.
### ResponseTracker
`ResponseTracker` maintains the set of reply subjects a client is temporarily permitted to publish to. This enables request-reply patterns for clients whose `Publish` permission list does not include the auto-generated `_INBOX.*` subject:
```csharp
// ResponseTracker.cs — IsReplyAllowed()
public bool IsReplyAllowed(string subject)
{
lock (_lock)
{
if (!_replies.TryGetValue(subject, out var entry))
return false;
if (_expires > TimeSpan.Zero && DateTime.UtcNow - entry.RegisteredAt > _expires)
{
_replies.Remove(subject);
return false;
}
var newCount = entry.Count + 1;
if (_maxMsgs > 0 && newCount > _maxMsgs)
{
_replies.Remove(subject);
return false;
}
_replies[subject] = (entry.RegisteredAt, newCount);
return true;
}
}
```
Each entry tracks registration time and message count. Entries expire by TTL (`_expires`), by message count (`_maxMsgs`), or both. `Prune()` can be called periodically to evict stale entries without waiting for an access attempt.
---
## JWT Permission Templates
When a user connects via JWT, permission subjects can contain mustache-style template expressions that are expanded using claim values from the user and account JWTs. This allows a single JWT template to scope permissions to specific tenants or user identities without issuing unique JWTs for every user.
`PermissionTemplates.Expand()` handles the expansion for a single pattern. When a template expression resolves to multiple values (e.g., a user with two `dept:` tags), the cartesian product of all expansions is computed:
```csharp
// PermissionTemplates.cs — Expand()
public static List<string> Expand(
string pattern, string name, string subject,
string accountName, string accountSubject,
string[] userTags, string[] accountTags)
{
var matches = TemplateRegex().Matches(pattern);
if (matches.Count == 0)
return [pattern];
// Compute cartesian product across all multi-value replacements
var results = new List<string> { pattern };
foreach (var (placeholder, values) in replacements)
{
var next = new List<string>();
foreach (var current in results)
foreach (var value in values)
next.Add(current.Replace(placeholder, value));
results = next;
}
return results;
}
```
Supported template functions:
| Expression | Resolves to |
|---|---|
| `{{name()}}` | User's `name` claim |
| `{{subject()}}` | User's NKey public key (`sub` claim) |
| `{{tag(tagname)}}` | All user tag values for `tagname:` prefix (multi-value) |
| `{{account-name()}}` | Account's `name` claim |
| `{{account-subject()}}` | Account's NKey public key |
| `{{account-tag(tagname)}}` | All account tag values for `tagname:` prefix (multi-value) |
If a tag expression matches no tags, the entire pattern is dropped from the result list (returns empty), not expanded to an empty string. This prevents accidental wildcard grants when a user lacks the expected tag.
`JwtAuthenticator` calls `PermissionTemplates.ExpandAll()` after decoding the user JWT, before constructing the `Permissions` object that goes into `AuthResult`.
---
## AuthResult
`AuthResult` carries the outcome of a successful authentication. All fields are init-only; `AuthResult` is produced by authenticators and consumed by the server when the connection is accepted.
```csharp
// AuthResult.cs
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; }
public int MaxJetStreamStreams { get; init; }
public string? JetStreamTier { get; init; }
}
```
| Field | Purpose |
|---|---|
| `Identity` | Human-readable identifier for the client (username, NKey public key, or `"token"`) |
| `AccountName` | The account this client belongs to. `null` falls back to `$G`. |
| `Permissions` | Publish/subscribe/response restrictions. `null` means unrestricted. |
| `Expiry` | When the connection should be terminated. `null` means no expiry. Derived from JWT `exp` or `User.ConnectionDeadline`. |
| `MaxJetStreamStreams` | Maximum JetStream streams this client's account may create. `0` means unlimited. Set by JWT account claims. |
| `JetStreamTier` | JetStream resource tier from the account JWT. Informational; used for multi-tier deployments. |
---
## Configuration
These `NatsOptions` fields control authentication. All fields have zero-value defaults that disable the corresponding mechanism.
| `NatsOptions` field | NATS config key | Description |
|---|---|---|
| `Username` | `authorization.user` | Single username |
| `Password` | `authorization.password` | Single password (plain or bcrypt) |
| `Authorization` | `authorization.token` | Opaque auth token |
| `Users` | `authorization.users` | Multi-user list with per-user permissions |
| `NKeys` | `authorization.nkeys` | NKey user list |
| `NoAuthUser` | `authorization.no_auth_user` | Fallback user for unauthenticated clients |
| `AuthTimeout` | `authorization.timeout` | Seconds allowed for the client to send `CONNECT` (default 2s) |
| `TrustedKeys` | `operator` | Operator NKey public keys for JWT mode |
| `AccountResolver` | _(programmatic)_ | `IAccountResolver` implementation for JWT account lookups |
| `TlsVerify` | `tls.verify` | Require client TLS certificates |
| `TlsMap` | `tls.map` | Map TLS certificate subject to user |
| `Accounts` | `accounts` | Per-account limits (`MaxConnections`, `MaxSubscriptions`) |
Bcrypt-hashed passwords are stored in config as the full bcrypt string (e.g., `$2b$11$...`). The server detects the `$2` prefix and delegates to `BCrypt.Net.BCrypt.Verify()`.
---
## Related Documentation
- [Server Overview](../Server/Overview.md)
- [Configuration Overview](../Configuration/Overview.md)
- [Subscriptions Overview](../Subscriptions/Overview.md)
- [SubList](../Subscriptions/SubList.md)
<!-- Last verified against codebase: 2026-02-23 -->