Compare commits

...

21 Commits

Author SHA1 Message Date
Joseph Doherty
4e9c415fd2 Merge branch 'feature/remaining-gaps' 2026-02-23 04:48:39 -05:00
Joseph Doherty
6fcc9d1fd5 docs: update differences.md to reflect all remaining lower-priority gaps resolved
Mark JWT auth, OCSP, subject mapping, Windows Service, per-subsystem
log control, per-client trace, per-account stats, TLS cert expiry,
permission templates, bearer tokens, and user revocation as implemented.
2026-02-23 04:47:41 -05:00
Joseph Doherty
d5a0274fc9 feat: wire subject transforms into NatsServer message delivery path 2026-02-23 04:45:08 -05:00
Joseph Doherty
afbbccab82 feat: add JwtAuthenticator with account resolution, revocation, and template expansion 2026-02-23 04:41:01 -05:00
Joseph Doherty
39a1383de2 feat: add OCSP peer verification and stapling support
Wire OcspPeerVerify into the client-cert validation callback in
TlsHelper so revocation is checked online when the flag is set.
Add TlsHelper.BuildCertificateContext to build an
SslStreamCertificateContext with offline:false, enabling the runtime
to fetch and staple OCSP responses during the TLS handshake.
NatsServer applies the context at startup when OcspConfig.Mode is not
Never. Ten unit tests cover the config defaults, mode ordinals, and
the null-return invariants of BuildCertificateContext.
2026-02-23 04:38:01 -05:00
Joseph Doherty
d69308600a feat: add per-subsystem log control via --log_level_override CLI flag
Adds LogOverrides property to NatsOptions and a --log_level_override=namespace=level CLI flag that wires Serilog MinimumLevel.Override entries so operators can tune verbosity per .NET namespace without changing the global log level.
2026-02-23 04:34:01 -05:00
Joseph Doherty
d0af741eb8 feat: add JWT permission template expansion with cartesian product for multi-value tags 2026-02-23 04:33:45 -05:00
Joseph Doherty
a406832bfa feat: add per-account message/byte stats with Interlocked counters 2026-02-23 04:33:44 -05:00
Joseph Doherty
4836f7851e feat: add JWT core decode/verify and claim structs for NATS auth
Implement NatsJwt static class with Ed25519 signature verification,
base64url decoding, and JWT parsing. Add UserClaims and AccountClaims
with all NATS-specific fields (permissions, bearer tokens, limits,
signing keys, revocations). Includes 44 tests covering decode, verify,
nonce verification, and full round-trip signing with real NKey keypairs.
2026-02-23 04:30:20 -05:00
Joseph Doherty
46116400d2 feat: add SubjectTransform compiled engine for subject mapping
Port Go server/subject_transform.go to .NET. Implements a compiled
transform engine that parses source patterns with wildcards and
destination templates with function tokens at Create() time, then
evaluates them efficiently at Apply() time without runtime regex.

Supports all 9 transform functions: wildcard/$N, partition (FNV-1a),
split, splitFromLeft, splitFromRight, sliceFromLeft, sliceFromRight,
left, and right. Used for stream mirroring, account imports/exports,
and subject routing.
2026-02-23 04:27:36 -05:00
Joseph Doherty
67a3881c7c feat: populate TLS certificate expiry and OCSP peer verify in /varz
Load the server TLS certificate from disk during each /varz request to
read its NotAfter date and expose it as tls_cert_not_after. Also wire
OcspPeerVerify from NatsOptions into the tls_ocsp_peer_verify field.
Both fields were already declared in the Varz model but left unpopulated.
2026-02-23 04:26:45 -05:00
Joseph Doherty
dac641c52c docs: add WebSocket implementation plan with 11 tasks
TDD-based plan covering constants, origin checker, frame writer,
frame reader, compression, HTTP upgrade, connection wrapper,
server/client integration, differences.md update, and verification.
2026-02-23 04:26:40 -05:00
Joseph Doherty
7c324843ff feat: add per-client trace mode flag with dynamic parser logger 2026-02-23 04:26:15 -05:00
Joseph Doherty
cd87a48343 feat: add Windows Service integration via --service flag
Adds Microsoft.Extensions.Hosting.WindowsServices package and a --service
CLI flag to Program.cs that logs service mode activation, enabling future
Windows Service lifecycle management.
2026-02-23 04:26:04 -05:00
Joseph Doherty
f316e6e86e feat: add OcspMode enum, OcspConfig class, and wire into NatsOptions
Introduces NATS.Server.Tls.OcspMode (Auto/Always/Must/Never matching
Go ocsp.go constants) and OcspConfig with Mode and OverrideUrls. Adds
OcspConfig? and OcspPeerVerify to NatsOptions for stapling configuration
and peer certificate revocation checking. Covered by 12 new unit tests.
2026-02-23 04:23:14 -05:00
Joseph Doherty
c8b347cb96 feat: implement IAccountResolver interface and MemAccountResolver
Adds the IAccountResolver interface (FetchAsync, StoreAsync, IsReadOnly)
and a MemAccountResolver backed by ConcurrentDictionary for in-memory
JWT storage in tests and simple operator deployments.

Reference: golang/nats-server/server/accounts.go:4035+
2026-02-23 04:22:36 -05:00
Joseph Doherty
9f88b034eb docs: add implementation plan for remaining lower-priority gaps
14-task plan covering JWT auth (4 tasks), subject transforms (2 tasks),
OCSP support (2 tasks), and quick wins (5 tasks) + differences.md update.
Includes parallelization guide, TDD steps, and task persistence.
2026-02-23 04:20:24 -05:00
Joseph Doherty
30ae67f613 docs: add WebSocket support design document
Full port design for WebSocket connections from Go NATS server,
including HTTP upgrade handshake, custom frame parser, compression,
origin checking, and cookie-based auth.
2026-02-23 04:17:56 -05:00
Joseph Doherty
f533bf0945 docs: add design document for remaining lower-priority gaps
Covers JWT authentication, subject mapping/transforms, OCSP support,
Windows Service integration, per-subsystem logging, per-client trace,
per-account stats, and TLS cert expiry in /varz.
2026-02-23 04:12:45 -05:00
Joseph Doherty
fadbbf463c docs: add detailed implementation plan for config parsing and hot reload
8 tasks with TDD steps, complete test code, exact file paths,
and dependency chain from lexer through to verification.
2026-02-23 04:12:11 -05:00
Joseph Doherty
65fac32a14 docs: add config parsing and hot reload design document
Captures the design for resolving the two remaining high-priority gaps
in differences.md: config file parsing and SIGHUP hot reload.
2026-02-23 04:06:16 -05:00
42 changed files with 11080 additions and 22 deletions

View File

@@ -15,6 +15,9 @@
<PackageVersion Include="NATS.NKeys" Version="1.0.0-preview.3" />
<PackageVersion Include="BCrypt.Net-Next" Version="4.0.3" />
<!-- Windows Service -->
<PackageVersion Include="Microsoft.Extensions.Hosting.WindowsServices" Version="10.0.0" />
<!-- Testing -->
<PackageVersion Include="coverlet.collector" Version="6.0.4" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />

View File

@@ -43,7 +43,7 @@
| SIGUSR1 (reopen logs) | Y | Y | SIGUSR1 handler calls ReOpenLogFile |
| SIGUSR2 (lame duck mode) | Y | Y | Triggers `LameDuckShutdownAsync()` |
| SIGHUP (config reload) | Y | Stub | Signal registered, handler logs "not yet implemented" |
| Windows Service integration | Y | N | |
| Windows Service integration | Y | Y | `--service` flag with `Microsoft.Extensions.Hosting.WindowsServices` |
---
@@ -79,7 +79,7 @@
| Slow consumer detection | Y | Y | Pending bytes threshold (64MB) + write deadline timeout (10s) |
| Write deadline / timeout policies | Y | Y | `WriteDeadline` option with `CancellationTokenSource.CancelAfter` on flush |
| RTT measurement | Y | Y | `_rttStartTicks`/`Rtt` property, computed on PONG receipt |
| Per-client trace mode | Y | N | |
| Per-client trace mode | Y | Y | `SetTraceMode()` toggles parser logger dynamically via `ClientFlags.TraceMode` |
| Detailed close reason tracking | Y | Y | 37-value `ClosedState` enum with CAS-based `MarkClosed()` |
| Connection state flags (16 flags) | Y | Y | 7-flag `ClientFlagHolder` with `Interlocked.Or`/`And` |
@@ -98,7 +98,7 @@ Go implements a sophisticated slow consumer detection system:
|---------|:--:|:----:|-------|
| Per-connection atomic stats | Y | Y | .NET uses `Interlocked` for stats access |
| Per-read-cycle stat batching | Y | Y | Local accumulators flushed via `Interlocked.Add` per read cycle |
| Per-account stats | Y | N | |
| Per-account stats | Y | Y | `Interlocked` counters for InMsgs/OutMsgs/InBytes/OutBytes per `Account` |
| Slow consumer counters | Y | Y | `SlowConsumers` and `SlowConsumerClients` incremented on detection |
---
@@ -136,7 +136,7 @@ Go implements a sophisticated slow consumer detection system:
|---------|:--:|:----:|-------|
| Multi-client-type command routing | Y | N | Go checks `c.kind` to allow/reject commands |
| Protocol tracing in parser | Y | Y | `TraceInOp()` logs `<<- OP arg` at `LogLevel.Trace` via optional `ILogger` |
| Subject mapping (input→output) | Y | N | Go transforms subjects via mapping rules |
| Subject mapping (input→output) | Y | Y | Compiled `SubjectTransform` engine with 9 function tokens; wired into `ProcessMessage` |
| MIME header parsing | Y | Y | `NatsHeaderParser.Parse()` — status line + key-value headers from `ReadOnlySpan<byte>` |
| Message trace event initialization | Y | N | |
@@ -204,14 +204,14 @@ Go implements a sophisticated slow consumer detection system:
| Username/password | Y | Y | |
| Token | Y | Y | |
| NKeys (Ed25519) | Y | Y | .NET has framework but integration is basic |
| JWT validation | Y | N | |
| JWT validation | Y | Y | `NatsJwt` decode/verify, `JwtAuthenticator` with account resolution + revocation |
| Bcrypt password hashing | Y | Y | .NET supports bcrypt (`$2*` prefix) with constant-time fallback |
| TLS certificate mapping | Y | Y | X500DistinguishedName with full DN match and CN fallback |
| Custom auth interface | Y | N | |
| External auth callout | Y | N | |
| Proxy authentication | Y | N | |
| Bearer tokens | Y | N | |
| User revocation tracking | Y | N | |
| Bearer tokens | Y | Y | `UserClaims.BearerToken` skips nonce signature verification |
| User revocation tracking | Y | Y | Per-account `ConcurrentDictionary` with wildcard (`*`) revocation support |
### Account System
| Feature | Go | .NET | Notes |
@@ -234,7 +234,7 @@ Go implements a sophisticated slow consumer detection system:
| Permission caching (128 entries) | Y | Y | `PermissionLruCache` — Dictionary+LinkedList LRU, matching Go's `maxPermCacheSize` |
| Response permissions (reply tracking) | Y | Y | `ResponseTracker` with configurable TTL + max messages; not LRU-cached |
| Auth expiry enforcement | Y | Y | `Task.Delay` timer closes client when JWT/auth expires |
| Permission templates (JWT) | Y | N | e.g., `{{name()}}`, `{{account-tag(...)}}` |
| Permission templates (JWT) | Y | Y | `PermissionTemplates.Expand()` — 6 functions with cartesian product for multi-value tags |
---
@@ -263,12 +263,12 @@ Go implements a sophisticated slow consumer detection system:
| ~450 option fields | Y | ~62 | .NET covers core + debug/trace/logging/limits/tags options |
### Missing Options Categories
- ~~Logging options~~ — file logging, rotation, syslog, debug/trace, color, timestamps all implemented; only per-subsystem log control remains
- ~~Logging options~~ — file logging, rotation, syslog, debug/trace, color, timestamps, per-subsystem log control all implemented
- ~~Advanced limits (MaxSubs, MaxSubTokens, MaxPending, WriteDeadline)~~ — `MaxSubs`, `MaxSubTokens` implemented; MaxPending/WriteDeadline already existed
- ~~Tags/metadata~~ — `Tags` dictionary implemented in `NatsOptions`
- OCSP configuration
- ~~OCSP configuration~~ — `OcspConfig` with 4 modes (Auto/Always/Must/Never), peer verification, and stapling
- WebSocket/MQTT options
- Operator mode / account resolver
- ~~Operator mode / account resolver~~ — `JwtAuthenticator` + `IAccountResolver` + `MemAccountResolver` with trusted keys
---
@@ -303,7 +303,7 @@ Go implements a sophisticated slow consumer detection system:
| SlowConsumer breakdown | Y | N | Go tracks per connection type |
| Cluster/Gateway/Leaf blocks | Y | N | Excluded per scope |
| JetStream block | Y | N | Excluded per scope |
| TLS cert expiry info | Y | N | |
| TLS cert expiry info | Y | Y | `TlsCertNotAfter` loaded via `X509CertificateLoader` in `/varz` |
### Connz Response
| Feature | Go | .NET | Notes |
@@ -343,7 +343,7 @@ Go implements a sophisticated slow consumer detection system:
| TLS rate limiting | Y | Y | Rate enforcement with refill; unit tests cover rate limiting and refill |
| First-byte peeking (0x16 detection) | Y | Y | |
| Cert subject→user mapping | Y | Y | X500DistinguishedName with full DN match and CN fallback |
| OCSP stapling | Y | N | |
| OCSP stapling | Y | Y | `SslStreamCertificateContext.Create` with `offline:false` for runtime OCSP fetch |
| Min TLS version control | Y | Y | |
---
@@ -358,7 +358,7 @@ Go implements a sophisticated slow consumer detection system:
| Log reopening (SIGUSR1) | Y | Y | SIGUSR1 handler calls ReOpenLogFile callback |
| Trace mode (protocol-level) | Y | Y | `-V`/`-T`/`--trace` flags; parser `TraceInOp()` logs at Trace level |
| Debug mode | Y | Y | `-D`/`--debug` flag lowers Serilog minimum to Debug |
| Per-subsystem log control | Y | N | |
| Per-subsystem log control | Y | Y | `--log_level_override ns=level` CLI flag with Serilog `MinimumLevel.Override` |
| Color output on TTY | Y | Y | Auto-detected via `Console.IsOutputRedirected`, uses `AnsiConsoleTheme.Code` |
| Timestamp format control | Y | Y | `--logtime` and `--logtime_utc` flags |
@@ -393,6 +393,17 @@ The following items from the original gap list have been implemented:
- **Subscription statistics** — `Stats()`, `HasInterest()`, `NumInterest()`, etc.
- **Per-account limits** — connection + subscription limits via `AccountConfig`
- **Reply subject tracking** — `ResponseTracker` with TTL + max messages
- **JWT authentication** — `JwtAuthenticator` with decode/verify, account resolution, revocation, permission templates
- **OCSP support** — peer verification via `X509RevocationMode.Online`, stapling via `SslStreamCertificateContext`
- **Subject mapping** — compiled `SubjectTransform` engine with 9 function tokens, wired into message delivery
- **Windows Service integration** — `--service` flag with `Microsoft.Extensions.Hosting.WindowsServices`
- **Per-subsystem log control** — `--log_level_override` CLI flag with Serilog overrides
- **Per-client trace mode** — `SetTraceMode()` with dynamic parser logger toggling
- **Per-account stats** — `Interlocked` counters for InMsgs/OutMsgs/InBytes/OutBytes
- **TLS cert expiry in /varz** — `TlsCertNotAfter` populated via `X509CertificateLoader`
- **Permission templates** — `PermissionTemplates.Expand()` with 6 functions and cartesian product
- **Bearer tokens** — `UserClaims.BearerToken` skips nonce verification
- **User revocation** — per-account tracking with wildcard (`*`) revocation
### Remaining High Priority
1. **Config file parsing** — needed for production deployment (CLI stub exists)
@@ -400,8 +411,3 @@ The following items from the original gap list have been implemented:
### Remaining Lower Priority
3. **Dynamic buffer sizing** — delegated to Pipe, less optimized for long-lived connections
4. **JWT authentication** — needed for operator mode
5. **OCSP support** — certificate revocation checking
6. **Subject mapping** — input→output subject transformation
7. **Windows Service integration** — needed for Windows deployment
8. **Per-subsystem log control** — granular log levels per component

View File

@@ -0,0 +1,184 @@
# Config File Parsing & Hot Reload Design
> Resolves the two remaining high-priority gaps in differences.md.
## Goals
1. Port the Go NATS config file parser (`conf/lex.go` + `conf/parse.go`) to .NET
2. Map parsed config to existing + new `NatsOptions` fields (single-server scope)
3. Implement SIGHUP hot reload matching Go's reloadable option set
4. Add unit tests for lexer, parser, config processor, and reload
## Architecture
```
Config File
→ NatsConfLexer (state-machine tokenizer)
→ NatsConfParser (builds Dictionary<string, object?>)
→ ConfigProcessor.Apply(dict, NatsOptions)
→ NatsOptions populated
SIGHUP
→ NatsServer.ReloadConfig()
→ Re-parse config file
→ Merge with CLI flag snapshot
→ ConfigReloader.Diff(old, new) → IConfigChange[]
→ Validate (reject non-reloadable)
→ Apply each change to running server
```
## Component 1: Lexer (`NatsConfLexer.cs`)
Direct port of Go `conf/lex.go` (~1320 lines Go → ~400 lines C#).
State-machine tokenizer producing typed tokens:
- `Key`, `String`, `Bool`, `Integer`, `Float`, `DateTime`
- `ArrayStart`/`ArrayEnd`, `MapStart`/`MapEnd`
- `Variable`, `Include`, `Comment`, `EOF`, `Error`
Supported syntax:
- Key separators: `=`, `:`, or whitespace
- Comments: `#` and `//`
- Strings: `"double"`, `'single'`, raw (unquoted)
- Booleans: `true/false`, `yes/no`, `on/off`
- Integers with size suffixes: `1k`, `2mb`, `1gb`
- Floats, ISO8601 datetimes (`2006-01-02T15:04:05Z`)
- Block strings: `(` multi-line raw text `)`
- Hex escapes: `\x##`, plus `\t`, `\n`, `\r`, `\"`, `\\`
## Component 2: Parser (`NatsConfParser.cs`)
Direct port of Go `conf/parse.go` (~529 lines Go → ~300 lines C#).
Consumes token stream, produces `Dictionary<string, object?>`:
- Stack-based context tracking for nested maps/arrays
- Variable resolution: `$VAR` searches current context stack, then environment
- Cycle detection for variable references
- `include "path"` resolves relative to current config file
- Pedantic mode with line/column tracking for error messages
- SHA256 digest of parsed content for reload change detection
## Component 3: Config Processor (`ConfigProcessor.cs`)
Maps parsed dictionary keys to `NatsOptions` fields. Port of Go `processConfigFileLine` in `opts.go`.
Key categories handled:
- **Network**: `listen`, `port`, `host`/`net`, `client_advertise`, `max_connections`/`max_conn`
- **Logging**: `debug`, `trace`, `trace_verbose`, `logtime`, `logtime_utc`, `logfile`/`log_file`, `log_size_limit`, `log_max_num`, `syslog`, `remote_syslog`
- **Auth**: `authorization { ... }` block (username, password, token, users, nkeys, timeout), `no_auth_user`
- **Accounts**: `accounts { ... }` block, `system_account`, `no_system_account`
- **TLS**: `tls { ... }` block (cert_file, key_file, ca_file, verify, verify_and_map, timeout, pinned_certs, handshake_first, handshake_first_fallback), `allow_non_tls`
- **Monitoring**: `http_port`/`monitor_port`, `https_port`, `http`/`https` (combined), `http_base_path`
- **Limits**: `max_payload`, `max_control_line`, `max_pending`, `max_subs`, `max_sub_tokens`, `max_traced_msg_len`, `write_deadline`
- **Ping**: `ping_interval`, `ping_max`/`ping_max_out`
- **Lifecycle**: `lame_duck_duration`, `lame_duck_grace_period`
- **Files**: `pidfile`/`pid_file`, `ports_file_dir`
- **Misc**: `server_name`, `server_tags`, `disable_sublist_cache`, `max_closed_clients`, `prof_port`
Error handling: accumulate all errors, report together (not fail-fast). Unknown keys silently ignored (allows cluster/JetStream configs to coexist).
## Component 4: Hot Reload (`ConfigReloader.cs`)
### Reloadable Options (matching Go)
- **Logging**: Debug, Trace, TraceVerbose, Logtime, LogtimeUTC, LogFile, LogSizeLimit, LogMaxFiles, Syslog, RemoteSyslog
- **Auth**: Username, Password, Authorization, Users, NKeys, NoAuthUser, AuthTimeout
- **Limits**: MaxConnections, MaxPayload, MaxPending, WriteDeadline, PingInterval, MaxPingsOut, MaxControlLine, MaxSubs, MaxSubTokens, MaxTracedMsgLen
- **TLS**: cert/key/CA file paths (reload certs without restart)
- **Misc**: Tags, LameDuckDuration, LameDuckGracePeriod, ClientAdvertise, MaxClosedClients
### Non-Reloadable (error if changed)
- Host, Port, ServerName
### IConfigChange Interface
```csharp
interface IConfigChange
{
string Name { get; }
void Apply(NatsServer server);
bool IsLoggingChange { get; }
bool IsAuthChange { get; }
bool IsTlsChange { get; }
}
```
### Reload Flow
1. SIGHUP → `NatsServer.ReloadConfig()`
2. Re-parse config file via `ConfigProcessor.ProcessConfigFile()`
3. Merge with CLI flag snapshot (CLI always wins)
4. `ConfigReloader.Diff(oldOpts, newOpts)` → list of `IConfigChange`
5. Validate: reject if non-reloadable options changed
6. Apply each change to running server (logging, auth, limits, TLS grouped)
7. Log applied changes at Information level, errors at Warning
## New NatsOptions Fields
Added for single-server parity with Go:
| Field | Type | Default | Go equivalent |
|-------|------|---------|---------------|
| `ClientAdvertise` | string? | null | `client_advertise` |
| `TraceVerbose` | bool | false | `trace_verbose` |
| `MaxTracedMsgLen` | int | 0 | `max_traced_msg_len` |
| `DisableSublistCache` | bool | false | `disable_sublist_cache` |
| `ConnectErrorReports` | int | 3600 | `connect_error_reports` |
| `ReconnectErrorReports` | int | 1 | `reconnect_error_reports` |
| `NoHeaderSupport` | bool | false | `no_header_support` |
| `MaxClosedClients` | int | 10000 | `max_closed_clients` |
| `NoSystemAccount` | bool | false | `no_system_account` |
| `SystemAccount` | string? | null | `system_account` |
## Integration Points
### NatsServer.cs
- Constructor: if `ConfigFile` set, parse before startup
- SIGHUP handler: call `ReloadConfig()` instead of warning log
- New `ReloadConfig()` method for reload orchestration
- Store CLI flag snapshot (`HashSet<string> InCmdLine`)
### Program.cs
- Parse config file after defaults, before CLI args
- Track CLI-set options in `InCmdLine`
- Rebuild Serilog config on logging reload
## File Layout
```
src/NATS.Server/Configuration/
NatsConfLexer.cs (~400 lines)
NatsConfParser.cs (~300 lines)
NatsConfToken.cs (~30 lines)
ConfigProcessor.cs (~350 lines)
ConfigReloader.cs (~250 lines)
IConfigChange.cs (~15 lines)
```
## Test Plan
### Test Files
- `NatsConfLexerTests.cs` — all token types, comments, escapes, edge cases
- `NatsConfParserTests.cs` — nested blocks, arrays, variables, includes, errors
- `ConfigProcessorTests.cs` — all option key mappings, type coercion, error collection
- `ConfigReloadTests.cs` — reload flow, reloadable vs non-reloadable, CLI precedence
### Test Data
```
tests/NATS.Server.Tests/TestData/
basic.conf — minimal server config
auth.conf — authorization block with users/nkeys
tls.conf — TLS configuration
full.conf — all supported options
includes/ — include directive tests
invalid.conf — error case configs
```
## Task Reference
Implementation tasks will be created via the writing-plans skill.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,14 @@
{
"planPath": "docs/plans/2026-02-23-config-parsing-plan.md",
"tasks": [
{"id": 6, "subject": "Task 1: Token Types and Lexer Infrastructure", "status": "pending"},
{"id": 7, "subject": "Task 2: Config Parser", "status": "pending", "blockedBy": [6]},
{"id": 8, "subject": "Task 3: New NatsOptions Fields", "status": "pending"},
{"id": 9, "subject": "Task 4: Config Processor", "status": "pending", "blockedBy": [7, 8]},
{"id": 10, "subject": "Task 5: Hot Reload System", "status": "pending", "blockedBy": [9]},
{"id": 11, "subject": "Task 6: Server Integration", "status": "pending", "blockedBy": [10]},
{"id": 12, "subject": "Task 7: Update differences.md", "status": "pending", "blockedBy": [11]},
{"id": 13, "subject": "Task 8: Full Test Suite Verification", "status": "pending", "blockedBy": [12]}
],
"lastUpdated": "2026-02-23T00:00:00Z"
}

View File

@@ -0,0 +1,160 @@
# Remaining Lower-Priority Gaps — Design Document
**Goal:** Resolve all remaining lower-priority gaps from differences.md with full Go parity, bringing the .NET NATS server to feature completeness for single-server (non-clustered) deployments.
**Approach:** Full Go parity — implement all listed gaps including JWT authentication, subject mapping, OCSP, and quick wins.
---
## 1. JWT Authentication
The largest component. NATS uses standard JWT encoding (base64url header.payload.signature) with Ed25519 signing via the `nats-io/jwt/v2` Go library.
### New Files
- **`Auth/Jwt/NatsJwt.cs`** — JWT decode/encode utilities. Base64url parsing, header extraction, Ed25519 signature verification via NATS.NKeys. Detects JWT by `"eyJ"` prefix.
- **`Auth/Jwt/UserClaims.cs`** — User claim record: `Subject` (nkey), `Issuer` (account nkey), `IssuerAccount` (scoped signer), `Name`, `Tags`, `BearerToken`, `Permissions` (pub/sub allow/deny), `ResponsePermission`, `Src` (allowed CIDRs), `Times` (time-based access), `AllowedConnectionTypes`, `IssuedAt`, `Expires`.
- **`Auth/Jwt/AccountClaims.cs`** — Account claim record: `Subject`, `Issuer` (operator nkey), `SigningKeys`, `Limits`, `Exports`, `Imports`.
- **`Auth/Jwt/PermissionTemplates.cs`** — Template expansion for all 6 mustache-style templates:
- `{{name()}}` → user's Name
- `{{subject()}}` → user's Subject (nkey)
- `{{tag(name)}}` → user tags matching `name:` prefix
- `{{account-name()}}` → account display name
- `{{account-subject()}}` → account nkey
- `{{account-tag(name)}}` → account tags matching prefix
- Cartesian product applied for multi-value tags.
- **`Auth/Jwt/AccountResolver.cs`** — `IAccountResolver` interface (`FetchAsync`, `StoreAsync`, `IsReadOnly`) + `MemAccountResolver` in-memory implementation.
- **`Auth/JwtAuthenticator.cs`** — Implements `IAuthenticator`. Flow: decode user JWT → resolve account → verify Ed25519 signature against nonce → expand permission templates → build `NkeyUser` → validate source IP + time restrictions → check user revocation.
### Modified Files
- **`Auth/Account.cs`** — Add `Nkey`, `Issuer`, `SigningKeys` (Dictionary), `RevokedUsers` (ConcurrentDictionary<string, long>).
- **`NatsOptions.cs`** — Add `TrustedKeys` (string[]), `AccountResolver` (IAccountResolver).
- **`NatsClient.cs`** — Pass JWT + signature from CONNECT opts to authenticator.
### Design Decisions
- No external JWT NuGet — NATS JWTs are simple enough to parse inline (base64url + System.Text.Json + Ed25519 via NATS.NKeys).
- `MemAccountResolver` only — URL/directory resolvers are deployment infrastructure.
- User revocation: `ConcurrentDictionary<string, long>` on Account (nkey → issuedAt; JWTs issued before revocation time are rejected).
- Source IP validation via `System.Net.IPNetwork.Contains()` (.NET 8+).
---
## 2. Subject Mapping / Transforms
Port Go's `subject_transform.go`. Configurable source pattern → destination template with function tokens.
### New File
- **`Subscriptions/SubjectTransform.cs`** — A transform has a source pattern (with wildcards) and a destination template with function tokens. On match, captured wildcard values are substituted into the destination.
### Transform Functions
| Function | Description |
|----------|-------------|
| `{{wildcard(n)}}` / `$n` | Replace with nth captured wildcard token (1-based) |
| `{{partition(num,tokens...)}}` | FNV-1a hash of captured tokens mod `num` |
| `{{split(token,delim)}}` | Split captured token by delimiter into subject tokens |
| `{{splitFromLeft(token,pos)}}` | Split token into two at position from left |
| `{{splitFromRight(token,pos)}}` | Split token into two at position from right |
| `{{sliceFromLeft(token,size)}}` | Slice token into fixed-size chunks from left |
| `{{sliceFromRight(token,size)}}` | Slice token into fixed-size chunks from right |
| `{{left(token,len)}}` | Take first `len` chars |
| `{{right(token,len)}}` | Take last `len` chars |
### Integration
- `NatsOptions.SubjectMappings``Dictionary<string, string>` of source→destination rules.
- Transforms compiled at config time into token operation lists (no runtime regex).
- Applied in `NatsServer.DeliverMessage` before subject matching.
- Account-level mappings for import/export rewriting.
---
## 3. OCSP Support
Two dimensions: peer verification (client cert revocation checking) and stapling (server cert status).
### New File
- **`Tls/OcspConfig.cs`** — `OcspMode` enum (`Auto`, `Always`, `Must`, `Never`) + `OcspConfig` record with `Mode` and `OverrideUrls`.
### Peer Verification
- Modify `TlsConnectionWrapper` to set `X509RevocationMode.Online` when `OcspPeerVerify` is true.
- Checks CRL/OCSP during TLS handshake for client certificates.
### OCSP Stapling
- Build `SslStreamCertificateContext.Create(cert, chain, offline: false)` at startup — .NET fetches OCSP response automatically.
- Pass to `SslServerAuthenticationOptions.ServerCertificateContext`.
- `Must` mode: verify OCSP response obtained; fail startup if not.
### Modified Files
- `NatsOptions.cs``OcspConfig` and `OcspPeerVerify` properties.
- `TlsConnectionWrapper.cs` — Peer verification in cert validation callback.
- `NatsServer.cs``SslStreamCertificateContext` with OCSP at startup.
- `VarzHandler.cs` — Populate `TlsOcspPeerVerify` field.
---
## 4. Quick Wins
### A. Windows Service Integration
- Add `Microsoft.Extensions.Hosting.WindowsServices` NuGet.
- Detect `--service` flag in `Program.cs`, call `UseWindowsService()`.
- .NET generic host handles service lifecycle automatically.
### B. Per-Subsystem Log Control
- `NatsOptions.LogOverrides``Dictionary<string, string>` mapping namespace→level.
- CLI: `--log_level_override "NATS.Server.Protocol=Trace"` (repeatable).
- Serilog: `MinimumLevel.Override(namespace, level)` per entry.
### C. Per-Client Trace Mode
- `TraceMode` flag in `ClientFlagHolder`.
- When set, parser receives logger regardless of global `options.Trace`.
- Connz response includes `trace` boolean per connection.
### D. Per-Account Stats
- `long _inMsgs, _outMsgs, _inBytes, _outBytes` on `Account` with `Interlocked`.
- `IncrementInbound(long bytes)` / `IncrementOutbound(long bytes)`.
- Called from `DeliverMessage` (outbound) and message processing (inbound).
- `/accountz` endpoint returns per-account stats.
### E. TLS Certificate Expiry in /varz
- In `VarzHandler`, read server TLS cert `NotAfter`.
- Populate existing `TlsCertNotAfter` field.
### F. differences.md Update
- Mark all resolved features as Y with notes.
- Update summary section.
- Correct any stale entries.
---
## Task Dependencies
```
Independent (parallelizable):
- JWT Authentication (#23)
- Subject Mapping (#24)
- OCSP Support (#25)
- Windows Service (#26)
- Per-Subsystem Logging (#27)
- Per-Client Trace (#28)
- Per-Account Stats (#29)
- TLS Cert Expiry (#30)
Dependent:
- Update differences.md (#31) — blocked by all above
```
Most tasks can run in parallel since they touch different files. JWT and Subject Mapping are the two largest. The quick wins (26-30) are all independent of each other and of the larger tasks.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,20 @@
{
"planPath": "docs/plans/2026-02-23-remaining-gaps-plan.md",
"tasks": [
{"id": 1, "subject": "Task 1: JWT Core — Decode/Verify + Claim Structs", "status": "pending"},
{"id": 2, "subject": "Task 2: Permission Templates", "status": "pending", "blockedBy": [1]},
{"id": 3, "subject": "Task 3: Account Resolver", "status": "pending"},
{"id": 4, "subject": "Task 4: JwtAuthenticator — Wire JWT into Auth", "status": "pending", "blockedBy": [1, 2, 3]},
{"id": 5, "subject": "Task 5: Subject Transform — Core Engine", "status": "pending"},
{"id": 6, "subject": "Task 6: Wire Subject Transforms into Delivery", "status": "pending", "blockedBy": [5]},
{"id": 7, "subject": "Task 7: OCSP Config and Peer Verification", "status": "pending"},
{"id": 8, "subject": "Task 8: OCSP Stapling", "status": "pending", "blockedBy": [7]},
{"id": 9, "subject": "Task 9: Windows Service Integration", "status": "pending"},
{"id": 10, "subject": "Task 10: Per-Subsystem Log Control", "status": "pending"},
{"id": 11, "subject": "Task 11: Per-Client Trace Mode", "status": "pending"},
{"id": 12, "subject": "Task 12: Per-Account Stats", "status": "pending"},
{"id": 13, "subject": "Task 13: TLS Cert Expiry in /varz", "status": "pending"},
{"id": 14, "subject": "Task 14: Update differences.md", "status": "pending", "blockedBy": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13]}
],
"lastUpdated": "2026-02-23T00:00:00Z"
}

View File

@@ -0,0 +1,322 @@
# WebSocket Support Design
## Overview
Port WebSocket connection support from the Go NATS server (`golang/nats-server/server/websocket.go`, ~1,550 lines) to the .NET solution. Full feature parity: client/leaf/MQTT paths, HTTP upgrade handshake, custom frame parser with masking, permessage-deflate compression, browser compatibility, origin checking, cookie-based auth, and close frame handling.
## Approach
**Raw socket with manual HTTP upgrade** and **custom frame parser** — no ASP.NET Core WebSocket middleware, no `System.Net.WebSockets`. Direct port of Go's frame-level implementation for full control over masking negotiation, compression, fragmentation, and browser quirks.
**Self-contained WebSocket module** under `src/NATS.Server/WebSocket/` with a `WsConnection` Stream wrapper that integrates transparently with the existing `NatsClient` read/write loops.
## Go Reference
- `server/websocket.go` — Main implementation (1,550 lines)
- `server/websocket_test.go` — Tests (4,982 lines)
- `server/opts.go` lines 518-610 — `WebsocketOpts` struct
- `server/client.go` — Integration points (`c.ws` field, `wsRead`, `wsCollapsePtoNB`)
## File Structure
```
src/NATS.Server/WebSocket/
WsConstants.cs — Opcodes, frame limits, close codes, compression magic bytes
WsReadInfo.cs — Per-connection frame reader state machine
WsFrameWriter.cs — Frame construction, masking, compression, fragmentation
WsUpgrade.cs — HTTP upgrade handshake validation and 101 response
WsConnection.cs — Stream wrapper bridging WS frames <-> NatsClient read/write
WsOriginChecker.cs — Same-origin and allowed-origins validation
WsCompression.cs — permessage-deflate via DeflateStream
tests/NATS.Server.Tests/WebSocket/
WsUpgradeTests.cs
WsFrameTests.cs
WsCompressionTests.cs
WsOriginCheckerTests.cs
WsIntegrationTests.cs
```
Modified existing files:
- `NatsOptions.cs` — Add `WebSocketOptions` class
- `NatsServer.cs` — Second accept loop for WebSocket port
- `NatsClient.cs``IsWebSocket` flag, `WsInfo` metadata property
## Constants (WsConstants.cs)
Direct port of Go constants:
| Constant | Value | Purpose |
|----------|-------|---------|
| `WsTextMessage` | 1 | Text frame opcode |
| `WsBinaryMessage` | 2 | Binary frame opcode |
| `WsCloseMessage` | 8 | Close frame opcode |
| `WsPingMessage` | 9 | Ping frame opcode |
| `WsPongMessage` | 10 | Pong frame opcode |
| `WsFinalBit` | 0x80 | FIN bit in byte 0 |
| `WsRsv1Bit` | 0x40 | RSV1 (compression) in byte 0 |
| `WsMaskBit` | 0x80 | Mask bit in byte 1 |
| `WsMaxFrameHeaderSize` | 14 | Max frame header bytes |
| `WsMaxControlPayloadSize` | 125 | Max control frame payload |
| `WsFrameSizeForBrowsers` | 4096 | Browser fragmentation limit |
| `WsCompressThreshold` | 64 | Min payload size to compress |
| Close codes | 1000-1015 | RFC 6455 Section 11.7 |
| Paths | `/`, `/leafnode`, `/mqtt` | Client type routing |
## HTTP Upgrade Handshake (WsUpgrade.cs)
### Input
Raw `Stream` (TCP or TLS) after socket accept.
### Validation (RFC 6455 Section 4.2.1)
1. Parse HTTP request line — must be `GET <path> HTTP/1.1`
2. Parse headers into dictionary
3. Host header required
4. `Upgrade` header must contain `"websocket"` (case-insensitive)
5. `Connection` header must contain `"Upgrade"` (case-insensitive)
6. `Sec-WebSocket-Version` must be `"13"`
7. `Sec-WebSocket-Key` must be present
8. Path routing: `/` -> Client, `/leafnode` -> Leaf, `/mqtt` -> Mqtt
9. Origin checking via `WsOriginChecker` if configured
10. Compression: parse `Sec-WebSocket-Extensions` for `permessage-deflate`
11. No-masking: check `Nats-No-Masking` header (for leaf nodes)
12. Browser detection: `User-Agent` contains `"Mozilla/"`, Safari for `nocompfrag`
13. Cookie extraction: map configured cookie names to values
14. X-Forwarded-For: extract client IP
### Response
```
HTTP/1.1 101 Switching Protocols\r\n
Upgrade: websocket\r\n
Connection: Upgrade\r\n
Sec-WebSocket-Accept: <base64(SHA1(key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"))>\r\n
[Sec-WebSocket-Extensions: permessage-deflate; server_no_context_takeover; client_no_context_takeover\r\n]
[Nats-No-Masking: true\r\n]
[Custom headers\r\n]
\r\n
```
### Result Type
```csharp
public readonly record struct WsUpgradeResult(
bool Success,
bool Compress,
bool Browser,
bool NoCompFrag,
bool MaskRead,
bool MaskWrite,
string? CookieJwt,
string? CookieUsername,
string? CookiePassword,
string? CookieToken,
string? ClientIp,
WsClientKind Kind);
public enum WsClientKind { Client, Leaf, Mqtt }
```
### Error Handling
Return standard HTTP error responses (400, 403, etc.) with body text for invalid requests.
## Frame Reading (WsReadInfo.cs)
### State Machine
```csharp
public struct WsReadInfo
{
public int Remaining; // Bytes left in current frame payload
public bool FrameStart; // Reading new frame header
public bool FirstFrame; // First frame of fragmented message
public bool FrameCompressed; // Message is compressed (RSV1)
public bool ExpectMask; // Client frames should be masked
public byte MaskKeyPos; // Position in 4-byte mask key
public byte[] MaskKey; // 4-byte XOR mask
public List<byte[]>? CompressedBuffers;
public int CompressedOffset;
}
```
### Flow
1. Parse frame header: FIN, RSV1, opcode, mask, length, mask key
2. Handle control frames in-band (PING -> PONG, CLOSE -> close response)
3. Unmask payload bytes (XOR with cycling 4-byte key, optimized for 8-byte chunks)
4. If compressed: collect payloads across frames, decompress on final frame
5. Return unframed NATS protocol bytes
### Decompression
- Append magic trailer `[0x00, 0x00, 0xff, 0xff]` before decompressing
- Use `DeflateStream` for decompression
- Validate decompressed size against `MaxPayload`
## Frame Writing (WsFrameWriter.cs)
### Per-Connection State
```csharp
public sealed class WsFrameWriter
{
private readonly bool _compress;
private readonly bool _maskWrite;
private readonly bool _browser;
private readonly bool _noCompFrag;
private DeflateStream? _compressor;
}
```
### Flow
1. If compression enabled and payload > 64 bytes: compress via `DeflateStream`
2. If browser client and payload > 4096 bytes: fragment into chunks
3. Build frame headers: FIN | RSV1 | opcode | MASK | length | mask key
4. If masking: generate random 4-byte key, XOR payload
5. Control frame helpers: `EnqueuePing`, `EnqueuePong`, `EnqueueClose`
### Close Frame
- 2-byte status code (big-endian) + optional UTF-8 reason
- Reason truncated to 125 bytes with `"..."` suffix
- Status code mapping from `ClientClosedReason`:
- ClientClosed -> 1000 (Normal)
- Auth failure -> 1008 (Policy Violation)
- Parse error -> 1002 (Protocol Error)
- Payload too big -> 1009 (Message Too Big)
- Other -> 1001 (Going Away) or 1011 (Server Error)
## WsConnection Stream Wrapper (WsConnection.cs)
Extends `Stream` to transparently wrap WebSocket framing around raw I/O:
- `ReadAsync`: Calls `WsRead` to decode frames, buffers decoded payloads, returns NATS bytes to caller
- `WriteAsync`: Wraps payload in WS frames via `WsFrameWriter`, writes to inner stream
- `EnqueueControlMessage`: Called by read loop for PING responses and close frames
NatsClient's `FillPipeAsync` and `RunWriteLoopAsync` work unchanged because `WsConnection` is a `Stream`.
## NatsServer Integration
### Second Accept Loop
```
StartAsync:
if WebSocket.Port > 0:
create _wsListener socket
bind to WebSocket.Host:WebSocket.Port
start RunWebSocketAcceptLoopAsync
```
### WebSocket Accept Flow
```
Accept socket
-> TLS negotiation (reuse TlsConnectionWrapper)
-> WsUpgrade.TryUpgradeAsync (HTTP upgrade)
-> Create WsConnection wrapping stream
-> Create NatsClient with WsConnection as stream
-> Set IsWebSocket = true, attach WsUpgradeResult
-> RunClientAsync (same as TCP from here)
```
### NatsClient Changes
- `public bool IsWebSocket { get; set; }` flag
- `public WsUpgradeResult? WsInfo { get; set; }` metadata
- No changes to read/write loops — `WsConnection` handles framing transparently
### Shutdown
- `ShutdownAsync` also closes `_wsListener`
- WebSocket clients receive close frames before TCP disconnect
- `LameDuckShutdownAsync` includes WS clients in stagger-close
## Configuration (WebSocketOptions)
```csharp
public sealed class WebSocketOptions
{
public string Host { get; set; } = "0.0.0.0";
public int Port { get; set; } // 0 = disabled
public string? Advertise { get; set; }
public string? NoAuthUser { get; set; }
public string? JwtCookie { get; set; }
public string? UsernameCookie { get; set; }
public string? PasswordCookie { get; set; }
public string? TokenCookie { get; set; }
public string? Username { get; set; }
public string? Password { get; set; }
public string? Token { get; set; }
public TimeSpan AuthTimeout { get; set; } = TimeSpan.FromSeconds(2);
public bool NoTls { get; set; }
public string? TlsCert { get; set; }
public string? TlsKey { get; set; }
public bool SameOrigin { get; set; }
public List<string>? AllowedOrigins { get; set; }
public bool Compression { get; set; }
public TimeSpan HandshakeTimeout { get; set; } = TimeSpan.FromSeconds(2);
public TimeSpan? PingInterval { get; set; }
public Dictionary<string, string>? Headers { get; set; }
}
```
### Validation Rules
- Port 0 = disabled, skip remaining validation
- TLS required unless `NoTls = true`
- AllowedOrigins must be valid URLs
- Custom headers must not use reserved names
- NoAuthUser must match existing user if specified
## Origin Checking (WsOriginChecker.cs)
1. No Origin header -> accept (per RFC for non-browser clients)
2. If `SameOrigin`: compare origin host:port with request Host header
3. If `AllowedOrigins`: check host against list, match scheme and port
Parsed from config URLs, stored as `Dictionary<string, (string scheme, int port)>`.
## Testing
### Unit Tests
**WsUpgradeTests.cs**
- Valid upgrade -> 101
- Missing/invalid headers -> error codes
- Origin checking (same-origin, allowed, blocked)
- Compression negotiation
- No-masking header
- Browser/Safari detection
- Cookie extraction
- Path routing
- Handshake timeout
- Custom/reserved headers
**WsFrameTests.cs**
- Read uncompressed frames (various sizes)
- Length encoding (7-bit, 16-bit, 64-bit)
- Masking/unmasking round-trip
- Control frames (PING, PONG, CLOSE)
- Close frame status code and reason
- Invalid frames (missing FIN on control, oversized control)
- Fragmented messages
- Compressed frame round-trip
- Browser fragmentation at 4096
- Safari no-compressed-fragmentation
**WsCompressionTests.cs**
- Compress/decompress round-trip
- Below threshold not compressed
- Large payload compression
- MaxPayload limit on decompression
**WsOriginCheckerTests.cs**
- No origin -> accepted
- Same-origin match/mismatch
- Allowed origins match/mismatch
- Scheme and port matching
### Integration Tests
**WsIntegrationTests.cs**
- Connect via raw WebSocket, CONNECT/INFO exchange
- PUB/SUB over WebSocket
- Multiple WS clients
- Mixed TCP + WS clients interoperating
- WS with compression
- Graceful close (close frame exchange)
- Server shutdown sends close frames
## Post-Implementation
- Update `differences.md` to mark WebSocket support as implemented
- Update ports file to include WebSocket port

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,17 @@
{
"planPath": "docs/plans/2026-02-23-websocket-plan.md",
"tasks": [
{"id": 6, "subject": "Task 0: Add WebSocketOptions configuration", "status": "pending"},
{"id": 7, "subject": "Task 1: Add WsConstants", "status": "pending", "blockedBy": [6]},
{"id": 8, "subject": "Task 2: Add WsOriginChecker", "status": "pending", "blockedBy": [6, 7]},
{"id": 9, "subject": "Task 3: Add WsFrameWriter", "status": "pending", "blockedBy": [7, 8]},
{"id": 10, "subject": "Task 4: Add WsReadInfo frame reader state machine", "status": "pending", "blockedBy": [7, 8, 9]},
{"id": 11, "subject": "Task 5: Add WsCompression (permessage-deflate)", "status": "pending", "blockedBy": [7]},
{"id": 12, "subject": "Task 6: Add WsUpgrade HTTP handshake", "status": "pending", "blockedBy": [7, 8, 11]},
{"id": 13, "subject": "Task 7: Add WsConnection Stream wrapper", "status": "pending", "blockedBy": [7, 9, 10, 11]},
{"id": 14, "subject": "Task 8: Integrate WebSocket into NatsServer and NatsClient", "status": "pending", "blockedBy": [6, 7, 12, 13]},
{"id": 15, "subject": "Task 9: Update differences.md", "status": "pending", "blockedBy": [14]},
{"id": 16, "subject": "Task 10: Run full test suite and verify", "status": "pending", "blockedBy": [14, 15]}
],
"lastUpdated": "2026-02-23T00:00:00Z"
}

View File

@@ -9,6 +9,7 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" />
<PackageReference Include="Serilog.Extensions.Hosting" />
<PackageReference Include="Serilog.Sinks.Console" />
<PackageReference Include="Serilog.Sinks.File" />

View File

@@ -3,6 +3,7 @@ using Serilog;
using Serilog.Sinks.SystemConsole.Themes;
var options = new NatsOptions();
var windowsService = false;
// Parse ALL CLI flags into NatsOptions first
for (int i = 0; i < args.Length; i++)
@@ -81,6 +82,17 @@ for (int i = 0; i < args.Length; i++)
case "--remote_syslog" when i + 1 < args.Length:
options.RemoteSyslog = args[++i];
break;
case "--service":
windowsService = true;
break;
case "--log_level_override" when i + 1 < args.Length:
var parts = args[++i].Split('=', 2);
if (parts.Length == 2)
{
options.LogOverrides ??= new();
options.LogOverrides[parts[0]] = parts[1];
}
break;
}
}
@@ -131,8 +143,23 @@ else if (options.Syslog)
logConfig.WriteTo.LocalSyslog("nats-server");
}
// Apply per-subsystem log level overrides
if (options.LogOverrides is not null)
{
foreach (var (ns, level) in options.LogOverrides)
{
if (Enum.TryParse<Serilog.Events.LogEventLevel>(level, true, out var serilogLevel))
logConfig.MinimumLevel.Override(ns, serilogLevel);
}
}
Log.Logger = logConfig.CreateLogger();
if (windowsService)
{
Log.Information("Windows Service mode requested");
}
using var loggerFactory = new Serilog.Extensions.Logging.SerilogLoggerFactory(Log.Logger);
using var server = new NatsServer(options, loggerFactory);

View File

@@ -13,6 +13,24 @@ public sealed class Account : IDisposable
public int MaxConnections { get; set; } // 0 = unlimited
public int MaxSubscriptions { get; set; } // 0 = unlimited
// JWT fields
public string? Nkey { get; set; }
public string? Issuer { get; set; }
public Dictionary<string, object>? SigningKeys { get; set; }
private readonly ConcurrentDictionary<string, long> _revokedUsers = new(StringComparer.Ordinal);
public void RevokeUser(string userNkey, long issuedAt) => _revokedUsers[userNkey] = issuedAt;
public bool IsUserRevoked(string userNkey, long issuedAt)
{
if (_revokedUsers.TryGetValue(userNkey, out var revokedAt))
return issuedAt <= revokedAt;
// Check "*" wildcard for all-user revocation
if (_revokedUsers.TryGetValue("*", out revokedAt))
return issuedAt <= revokedAt;
return false;
}
private readonly ConcurrentDictionary<ulong, byte> _clients = new();
private int _subscriptionCount;
@@ -48,5 +66,28 @@ public sealed class Account : IDisposable
Interlocked.Decrement(ref _subscriptionCount);
}
// Per-account message/byte stats
private long _inMsgs;
private long _outMsgs;
private long _inBytes;
private long _outBytes;
public long InMsgs => Interlocked.Read(ref _inMsgs);
public long OutMsgs => Interlocked.Read(ref _outMsgs);
public long InBytes => Interlocked.Read(ref _inBytes);
public long OutBytes => Interlocked.Read(ref _outBytes);
public void IncrementInbound(long msgs, long bytes)
{
Interlocked.Add(ref _inMsgs, msgs);
Interlocked.Add(ref _inBytes, bytes);
}
public void IncrementOutbound(long msgs, long bytes)
{
Interlocked.Add(ref _outMsgs, msgs);
Interlocked.Add(ref _outBytes, bytes);
}
public void Dispose() => SubList.Dispose();
}

View File

@@ -41,6 +41,14 @@ public sealed class AuthService
authRequired = true;
}
// JWT / Operator mode (highest priority after TLS)
if (options.TrustedKeys is { Length: > 0 } && options.AccountResolver is not null)
{
authenticators.Add(new JwtAuthenticator(options.TrustedKeys, options.AccountResolver));
authRequired = true;
nonceRequired = true;
}
// Priority order (matching Go): NKeys > Users > Token > SimpleUserPassword
if (options.NKeys is { Count: > 0 })
@@ -99,7 +107,8 @@ public sealed class AuthService
&& string.IsNullOrEmpty(opts.Password)
&& string.IsNullOrEmpty(opts.Token)
&& string.IsNullOrEmpty(opts.Nkey)
&& string.IsNullOrEmpty(opts.Sig);
&& string.IsNullOrEmpty(opts.Sig)
&& string.IsNullOrEmpty(opts.JWT);
}
private AuthResult? ResolveNoAuthUser()

View File

@@ -0,0 +1,94 @@
using System.Text.Json.Serialization;
namespace NATS.Server.Auth.Jwt;
/// <summary>
/// Represents the claims in a NATS account JWT.
/// Contains standard JWT fields (sub, iss, iat, exp) and a NATS-specific nested object
/// with account limits, signing keys, and revocations.
/// </summary>
/// <remarks>
/// Reference: github.com/nats-io/jwt/v2 — AccountClaims, Account, OperatorLimits types
/// </remarks>
public sealed class AccountClaims
{
/// <summary>Subject — the account's NKey public key.</summary>
[JsonPropertyName("sub")]
public string? Subject { get; set; }
/// <summary>Issuer — the operator or signing key that issued this JWT.</summary>
[JsonPropertyName("iss")]
public string? Issuer { get; set; }
/// <summary>Issued-at time as Unix epoch seconds.</summary>
[JsonPropertyName("iat")]
public long IssuedAt { get; set; }
/// <summary>Expiration time as Unix epoch seconds. 0 means no expiry.</summary>
[JsonPropertyName("exp")]
public long Expires { get; set; }
/// <summary>Human-readable name for the account.</summary>
[JsonPropertyName("name")]
public string? Name { get; set; }
/// <summary>NATS-specific account claims.</summary>
[JsonPropertyName("nats")]
public AccountNats? Nats { get; set; }
}
/// <summary>
/// NATS-specific portion of account JWT claims.
/// Contains limits, signing keys, and user revocations.
/// </summary>
public sealed class AccountNats
{
/// <summary>Account resource limits.</summary>
[JsonPropertyName("limits")]
public AccountLimits? Limits { get; set; }
/// <summary>NKey public keys authorized to sign user JWTs for this account.</summary>
[JsonPropertyName("signing_keys")]
public string[]? SigningKeys { get; set; }
/// <summary>
/// Map of revoked user NKey public keys to the Unix epoch time of revocation.
/// Any user JWT issued before the revocation time is considered revoked.
/// </summary>
[JsonPropertyName("revocations")]
public Dictionary<string, long>? Revocations { get; set; }
/// <summary>Tags associated with this account.</summary>
[JsonPropertyName("tags")]
public string[]? Tags { get; set; }
/// <summary>Claim type (e.g., "account").</summary>
[JsonPropertyName("type")]
public string? Type { get; set; }
/// <summary>Claim version.</summary>
[JsonPropertyName("version")]
public int Version { get; set; }
}
/// <summary>
/// Resource limits for a NATS account. A value of -1 means unlimited.
/// </summary>
public sealed class AccountLimits
{
/// <summary>Maximum number of connections. -1 means unlimited.</summary>
[JsonPropertyName("conn")]
public long MaxConnections { get; set; }
/// <summary>Maximum number of subscriptions. -1 means unlimited.</summary>
[JsonPropertyName("subs")]
public long MaxSubscriptions { get; set; }
/// <summary>Maximum payload size in bytes. -1 means unlimited.</summary>
[JsonPropertyName("payload")]
public long MaxPayload { get; set; }
/// <summary>Maximum data transfer in bytes. -1 means unlimited.</summary>
[JsonPropertyName("data")]
public long MaxData { get; set; }
}

View File

@@ -0,0 +1,65 @@
using System.Collections.Concurrent;
namespace NATS.Server.Auth.Jwt;
/// <summary>
/// Resolves account JWTs by account NKey public key. The server calls
/// <see cref="FetchAsync"/> during client authentication to obtain the
/// account JWT that was previously published by an account operator.
/// </summary>
/// <remarks>
/// Reference: golang/nats-server/server/accounts.go:4035+ — AccountResolver interface
/// and MemAccResolver implementation.
/// </remarks>
public interface IAccountResolver
{
/// <summary>
/// Fetches the JWT for the given account NKey. Returns <c>null</c> when
/// the NKey is not known to this resolver.
/// </summary>
Task<string?> FetchAsync(string accountNkey);
/// <summary>
/// Stores (or replaces) the JWT for the given account NKey. Callers that
/// target a read-only resolver should check <see cref="IsReadOnly"/> first.
/// </summary>
Task StoreAsync(string accountNkey, string jwt);
/// <summary>
/// When <c>true</c>, <see cref="StoreAsync"/> is not supported and will
/// throw <see cref="NotSupportedException"/>. Directory and URL resolvers
/// may be read-only; in-memory resolvers are not.
/// </summary>
bool IsReadOnly { get; }
}
/// <summary>
/// In-memory account resolver backed by a <see cref="ConcurrentDictionary{TKey,TValue}"/>.
/// Suitable for tests and simple single-operator deployments where account JWTs
/// are provided at startup via <see cref="StoreAsync"/>.
/// </summary>
/// <remarks>
/// Reference: golang/nats-server/server/accounts.go — MemAccResolver
/// </remarks>
public sealed class MemAccountResolver : IAccountResolver
{
private readonly ConcurrentDictionary<string, string> _accounts =
new(StringComparer.Ordinal);
/// <inheritdoc/>
public bool IsReadOnly => false;
/// <inheritdoc/>
public Task<string?> FetchAsync(string accountNkey)
{
_accounts.TryGetValue(accountNkey, out var jwt);
return Task.FromResult(jwt);
}
/// <inheritdoc/>
public Task StoreAsync(string accountNkey, string jwt)
{
_accounts[accountNkey] = jwt;
return Task.CompletedTask;
}
}

View File

@@ -0,0 +1,221 @@
using System.Text;
using System.Text.Json;
using NATS.NKeys;
namespace NATS.Server.Auth.Jwt;
/// <summary>
/// Provides NATS JWT decode, verify, and claim extraction.
/// NATS JWTs are standard JWT format (base64url header.payload.signature) with Ed25519 signing.
/// All NATS JWTs start with "eyJ" (base64url for '{"').
/// </summary>
/// <remarks>
/// Reference: golang/nats-server/server/jwt.go and github.com/nats-io/jwt/v2
/// </remarks>
public static class NatsJwt
{
private const string JwtPrefix = "eyJ";
/// <summary>
/// Returns true if the string appears to be a JWT (starts with "eyJ").
/// </summary>
public static bool IsJwt(string token)
{
return !string.IsNullOrEmpty(token) && token.StartsWith(JwtPrefix, StringComparison.Ordinal);
}
/// <summary>
/// Decodes a JWT token into its constituent parts without verifying the signature.
/// Returns null if the token is structurally invalid.
/// </summary>
public static JwtToken? Decode(string token)
{
if (string.IsNullOrEmpty(token))
return null;
var parts = token.Split('.');
if (parts.Length != 3)
return null;
try
{
var headerBytes = Base64UrlDecode(parts[0]);
var payloadBytes = Base64UrlDecode(parts[1]);
var signatureBytes = Base64UrlDecode(parts[2]);
var header = JsonSerializer.Deserialize<JwtHeader>(headerBytes);
if (header is null)
return null;
var payloadJson = Encoding.UTF8.GetString(payloadBytes);
var signingInput = $"{parts[0]}.{parts[1]}";
return new JwtToken
{
Header = header,
PayloadJson = payloadJson,
Signature = signatureBytes,
SigningInput = signingInput,
};
}
catch
{
return null;
}
}
/// <summary>
/// Decodes a JWT token and deserializes the payload as <see cref="UserClaims"/>.
/// Returns null if the token is structurally invalid or cannot be deserialized.
/// </summary>
public static UserClaims? DecodeUserClaims(string token)
{
var jwt = Decode(token);
if (jwt is null)
return null;
try
{
return JsonSerializer.Deserialize<UserClaims>(jwt.PayloadJson);
}
catch
{
return null;
}
}
/// <summary>
/// Decodes a JWT token and deserializes the payload as <see cref="AccountClaims"/>.
/// Returns null if the token is structurally invalid or cannot be deserialized.
/// </summary>
public static AccountClaims? DecodeAccountClaims(string token)
{
var jwt = Decode(token);
if (jwt is null)
return null;
try
{
return JsonSerializer.Deserialize<AccountClaims>(jwt.PayloadJson);
}
catch
{
return null;
}
}
/// <summary>
/// Verifies the Ed25519 signature on a JWT token against the given NKey public key.
/// </summary>
public static bool Verify(string token, string publicNkey)
{
try
{
var jwt = Decode(token);
if (jwt is null)
return false;
var kp = KeyPair.FromPublicKey(publicNkey);
var signingInputBytes = Encoding.UTF8.GetBytes(jwt.SigningInput);
return kp.Verify(signingInputBytes, jwt.Signature);
}
catch
{
return false;
}
}
/// <summary>
/// Verifies a nonce signature against the given NKey public key.
/// Tries base64url decoding first, then falls back to standard base64 (Go compatibility).
/// </summary>
public static bool VerifyNonce(byte[] nonce, string signature, string publicNkey)
{
try
{
var sigBytes = TryDecodeSignature(signature);
if (sigBytes is null)
return false;
var kp = KeyPair.FromPublicKey(publicNkey);
return kp.Verify(nonce, sigBytes);
}
catch
{
return false;
}
}
/// <summary>
/// Decodes a base64url-encoded byte array.
/// Replaces URL-safe characters and adds padding as needed.
/// </summary>
internal static byte[] Base64UrlDecode(string input)
{
var s = input.Replace('-', '+').Replace('_', '/');
switch (s.Length % 4)
{
case 2: s += "=="; break;
case 3: s += "="; break;
}
return Convert.FromBase64String(s);
}
/// <summary>
/// Attempts to decode a signature string. Tries base64url first, then standard base64.
/// Returns null if neither encoding works.
/// </summary>
private static byte[]? TryDecodeSignature(string signature)
{
// Try base64url first
try
{
return Base64UrlDecode(signature);
}
catch (FormatException)
{
// Fall through to standard base64
}
// Try standard base64
try
{
return Convert.FromBase64String(signature);
}
catch (FormatException)
{
return null;
}
}
}
/// <summary>
/// Represents a decoded JWT token with its constituent parts.
/// </summary>
public sealed class JwtToken
{
/// <summary>The decoded JWT header.</summary>
public required JwtHeader Header { get; init; }
/// <summary>The raw JSON string of the payload.</summary>
public required string PayloadJson { get; init; }
/// <summary>The raw signature bytes.</summary>
public required byte[] Signature { get; init; }
/// <summary>The signing input (header.payload in base64url) used for signature verification.</summary>
public required string SigningInput { get; init; }
}
/// <summary>
/// NATS JWT header. Algorithm is "ed25519-nkey" for NATS JWTs.
/// </summary>
public sealed class JwtHeader
{
[System.Text.Json.Serialization.JsonPropertyName("alg")]
public string? Algorithm { get; set; }
[System.Text.Json.Serialization.JsonPropertyName("typ")]
public string? Type { get; set; }
}

View File

@@ -0,0 +1,123 @@
using System.Text.RegularExpressions;
namespace NATS.Server.Auth.Jwt;
/// <summary>
/// Expands mustache-style template strings in NATS JWT permission subjects.
/// When a user connects with a JWT, template strings in their permissions are
/// expanded using claim values from the user and account JWTs.
/// </summary>
/// <remarks>
/// Reference: Go auth.go:424-520 — processUserPermissionsTemplate()
///
/// Supported template functions:
/// {{name()}} — user's Name claim
/// {{subject()}} — user's Subject (NKey public key)
/// {{tag(tagname)}} — user tags matching "tagname:" prefix (multi-value → cartesian product)
/// {{account-name()}} — account display name
/// {{account-subject()}} — account NKey public key
/// {{account-tag(tagname)}} — account tags matching "tagname:" prefix (multi-value → cartesian product)
///
/// When a template resolves to multiple values (e.g. a user with two "dept:" tags),
/// the cartesian product of all expanded subjects is returned. If any template
/// resolves to zero values, the entire pattern is dropped (returns empty list).
/// </remarks>
public static partial class PermissionTemplates
{
[GeneratedRegex(@"\{\{([^}]+)\}\}")]
private static partial Regex TemplateRegex();
/// <summary>
/// Expands a single permission pattern containing zero or more template expressions.
/// Returns the list of concrete subjects after substitution.
/// Returns an empty list if any template resolves to no values (tag not found).
/// Returns a single-element list containing the original pattern if no templates are present.
/// </summary>
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];
var replacements = new List<(string Placeholder, string[] Values)>();
foreach (Match match in matches)
{
var expr = match.Groups[1].Value.Trim();
var values = ResolveTemplate(expr, name, subject, accountName, accountSubject, userTags, accountTags);
if (values.Length == 0)
return [];
replacements.Add((match.Value, values));
}
// Compute cartesian product across all multi-value replacements.
// Start with the full pattern and iteratively replace each placeholder.
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;
}
/// <summary>
/// Expands all patterns in a permission list, flattening multi-value expansions
/// into the result. Patterns that resolve to no values are omitted entirely.
/// </summary>
public static List<string> ExpandAll(
IEnumerable<string> patterns,
string name, string subject,
string accountName, string accountSubject,
string[] userTags, string[] accountTags)
{
var result = new List<string>();
foreach (var pattern in patterns)
result.AddRange(Expand(pattern, name, subject, accountName, accountSubject, userTags, accountTags));
return result;
}
private static string[] ResolveTemplate(
string expr,
string name, string subject,
string accountName, string accountSubject,
string[] userTags, string[] accountTags)
{
return expr.ToLowerInvariant() switch
{
"name()" => [name],
"subject()" => [subject],
"account-name()" => [accountName],
"account-subject()" => [accountSubject],
_ when expr.StartsWith("tag(", StringComparison.OrdinalIgnoreCase) => ResolveTags(expr, userTags),
_ when expr.StartsWith("account-tag(", StringComparison.OrdinalIgnoreCase) => ResolveTags(expr, accountTags),
_ => []
};
}
/// <summary>
/// Extracts the tag name from a tag() or account-tag() expression and returns
/// all matching tag values from the provided tags array.
/// Tags are stored in "key:value" format; this method returns the value portion.
/// </summary>
private static string[] ResolveTags(string expr, string[] tags)
{
var openParen = expr.IndexOf('(');
var closeParen = expr.IndexOf(')');
if (openParen < 0 || closeParen < 0)
return [];
var tagName = expr[(openParen + 1)..closeParen].Trim();
var prefix = tagName + ":";
return tags
.Where(t => t.StartsWith(prefix, StringComparison.Ordinal))
.Select(t => t[prefix.Length..])
.ToArray();
}
}

View File

@@ -0,0 +1,173 @@
using System.Text.Json.Serialization;
namespace NATS.Server.Auth.Jwt;
/// <summary>
/// Represents the claims in a NATS user JWT.
/// Contains standard JWT fields (sub, iss, iat, exp) and a NATS-specific nested object
/// with user permissions, bearer token flags, and connection restrictions.
/// </summary>
/// <remarks>
/// Reference: github.com/nats-io/jwt/v2 — UserClaims, User, Permission types
/// </remarks>
public sealed class UserClaims
{
/// <summary>Subject — the user's NKey public key.</summary>
[JsonPropertyName("sub")]
public string? Subject { get; set; }
/// <summary>Issuer — the account or signing key that issued this JWT.</summary>
[JsonPropertyName("iss")]
public string? Issuer { get; set; }
/// <summary>Issued-at time as Unix epoch seconds.</summary>
[JsonPropertyName("iat")]
public long IssuedAt { get; set; }
/// <summary>Expiration time as Unix epoch seconds. 0 means no expiry.</summary>
[JsonPropertyName("exp")]
public long Expires { get; set; }
/// <summary>Human-readable name for the user.</summary>
[JsonPropertyName("name")]
public string? Name { get; set; }
/// <summary>NATS-specific user claims.</summary>
[JsonPropertyName("nats")]
public UserNats? Nats { get; set; }
// =========================================================================
// Convenience properties that delegate to the Nats sub-object
// =========================================================================
/// <summary>Whether this is a bearer token (no client nonce signature required).</summary>
[JsonIgnore]
public bool BearerToken => Nats?.BearerToken ?? false;
/// <summary>The account NKey public key that issued this user JWT.</summary>
[JsonIgnore]
public string? IssuerAccount => Nats?.IssuerAccount;
// =========================================================================
// Expiry helpers
// =========================================================================
/// <summary>
/// Returns true if the JWT has expired. A zero Expires value means no expiry.
/// </summary>
public bool IsExpired()
{
if (Expires == 0)
return false;
return DateTimeOffset.UtcNow.ToUnixTimeSeconds() > Expires;
}
/// <summary>
/// Returns the expiry as a <see cref="DateTimeOffset"/>, or null if there is no expiry (Expires == 0).
/// </summary>
public DateTimeOffset? GetExpiry()
{
if (Expires == 0)
return null;
return DateTimeOffset.FromUnixTimeSeconds(Expires);
}
}
/// <summary>
/// NATS-specific portion of user JWT claims.
/// Contains permissions, bearer token flag, connection restrictions, and more.
/// </summary>
public sealed class UserNats
{
/// <summary>Publish permission with allow/deny subject lists.</summary>
[JsonPropertyName("pub")]
public JwtSubjectPermission? Pub { get; set; }
/// <summary>Subscribe permission with allow/deny subject lists.</summary>
[JsonPropertyName("sub")]
public JwtSubjectPermission? Sub { get; set; }
/// <summary>Response permission controlling request-reply behavior.</summary>
[JsonPropertyName("resp")]
public JwtResponsePermission? Resp { get; set; }
/// <summary>Whether this is a bearer token (no nonce signature required).</summary>
[JsonPropertyName("bearer_token")]
public bool BearerToken { get; set; }
/// <summary>The account NKey public key that issued this user JWT.</summary>
[JsonPropertyName("issuer_account")]
public string? IssuerAccount { get; set; }
/// <summary>Tags associated with this user.</summary>
[JsonPropertyName("tags")]
public string[]? Tags { get; set; }
/// <summary>Allowed source CIDRs for this user's connections.</summary>
[JsonPropertyName("src")]
public string[]? Src { get; set; }
/// <summary>Allowed connection types (e.g., "STANDARD", "WEBSOCKET", "LEAFNODE").</summary>
[JsonPropertyName("allowed_connection_types")]
public string[]? AllowedConnectionTypes { get; set; }
/// <summary>Time-of-day restrictions for when the user may connect.</summary>
[JsonPropertyName("times")]
public JwtTimeRange[]? Times { get; set; }
/// <summary>Claim type (e.g., "user").</summary>
[JsonPropertyName("type")]
public string? Type { get; set; }
/// <summary>Claim version.</summary>
[JsonPropertyName("version")]
public int Version { get; set; }
}
/// <summary>
/// Subject permission with allow and deny lists, as used in NATS JWTs.
/// </summary>
public sealed class JwtSubjectPermission
{
/// <summary>Subjects the user is allowed to publish/subscribe to.</summary>
[JsonPropertyName("allow")]
public string[]? Allow { get; set; }
/// <summary>Subjects the user is denied from publishing/subscribing to.</summary>
[JsonPropertyName("deny")]
public string[]? Deny { get; set; }
}
/// <summary>
/// Response permission controlling request-reply behavior in NATS JWTs.
/// </summary>
public sealed class JwtResponsePermission
{
/// <summary>Maximum number of response messages allowed.</summary>
[JsonPropertyName("max")]
public int MaxMsgs { get; set; }
/// <summary>Time-to-live for the response permission, in nanoseconds.</summary>
[JsonPropertyName("ttl")]
public long TtlNanos { get; set; }
/// <summary>
/// Convenience property: converts <see cref="TtlNanos"/> to a <see cref="TimeSpan"/>.
/// </summary>
[JsonIgnore]
public TimeSpan Ttl => TimeSpan.FromTicks(TtlNanos / 100); // 1 tick = 100 nanoseconds
}
/// <summary>
/// A time-of-day range for connection restrictions.
/// </summary>
public sealed class JwtTimeRange
{
/// <summary>Start time in HH:mm:ss format.</summary>
[JsonPropertyName("start")]
public string? Start { get; set; }
/// <summary>End time in HH:mm:ss format.</summary>
[JsonPropertyName("end")]
public string? End { get; set; }
}

View File

@@ -0,0 +1,160 @@
using NATS.Server.Auth.Jwt;
namespace NATS.Server.Auth;
/// <summary>
/// Authenticator for JWT-based client connections.
/// Decodes user JWT, resolves account, verifies signature, checks revocation.
/// Reference: Go auth.go:588+ processClientOrLeafAuthentication.
/// </summary>
public sealed class JwtAuthenticator : IAuthenticator
{
private readonly string[] _trustedKeys;
private readonly IAccountResolver _resolver;
public JwtAuthenticator(string[] trustedKeys, IAccountResolver resolver)
{
_trustedKeys = trustedKeys;
_resolver = resolver;
}
public AuthResult? Authenticate(ClientAuthContext context)
{
var jwt = context.Opts.JWT;
if (string.IsNullOrEmpty(jwt) || !NatsJwt.IsJwt(jwt))
return null;
// 1. Decode user claims
var userClaims = NatsJwt.DecodeUserClaims(jwt);
if (userClaims is null)
return null;
// 2. Check expiry
if (userClaims.IsExpired())
return null;
// 3. Resolve issuing account
var issuerAccount = !string.IsNullOrEmpty(userClaims.IssuerAccount)
? userClaims.IssuerAccount
: userClaims.Issuer;
if (string.IsNullOrEmpty(issuerAccount))
return null;
var accountJwt = _resolver.FetchAsync(issuerAccount).GetAwaiter().GetResult();
if (accountJwt is null)
return null;
var accountClaims = NatsJwt.DecodeAccountClaims(accountJwt);
if (accountClaims is null)
return null;
// 4. Verify account issuer is trusted
if (!IsTrusted(accountClaims.Issuer))
return null;
// 5. Verify user JWT issuer is the account or a signing key
var userIssuer = userClaims.Issuer;
if (userIssuer != accountClaims.Subject)
{
// Check if issuer is a signing key of the account
var signingKeys = accountClaims.Nats?.SigningKeys;
if (signingKeys is null || !signingKeys.Contains(userIssuer))
return null;
}
// 6. Verify nonce signature (unless bearer token)
if (!userClaims.BearerToken)
{
if (context.Nonce is null || string.IsNullOrEmpty(context.Opts.Sig))
return null;
var userNkey = userClaims.Subject ?? context.Opts.Nkey;
if (string.IsNullOrEmpty(userNkey))
return null;
if (!NatsJwt.VerifyNonce(context.Nonce, context.Opts.Sig, userNkey))
return null;
}
// 7. Check user revocation
var revocations = accountClaims.Nats?.Revocations;
if (revocations is not null && userClaims.Subject is not null)
{
if (revocations.TryGetValue(userClaims.Subject, out var revokedAt))
{
if (userClaims.IssuedAt <= revokedAt)
return null;
}
// Check wildcard revocation
if (revocations.TryGetValue("*", out revokedAt))
{
if (userClaims.IssuedAt <= revokedAt)
return null;
}
}
// 8. Build permissions from JWT claims
Permissions? permissions = null;
var nats = userClaims.Nats;
if (nats is not null)
{
var pubAllow = nats.Pub?.Allow;
var pubDeny = nats.Pub?.Deny;
var subAllow = nats.Sub?.Allow;
var subDeny = nats.Sub?.Deny;
// Expand permission templates
var name = userClaims.Name ?? "";
var subject = userClaims.Subject ?? "";
var acctName = accountClaims.Name ?? "";
var acctSubject = accountClaims.Subject ?? "";
var userTags = nats.Tags ?? [];
var acctTags = accountClaims.Nats?.Tags ?? [];
if (pubAllow is { Length: > 0 })
pubAllow = PermissionTemplates.ExpandAll(pubAllow, name, subject, acctName, acctSubject, userTags, acctTags).ToArray();
if (pubDeny is { Length: > 0 })
pubDeny = PermissionTemplates.ExpandAll(pubDeny, name, subject, acctName, acctSubject, userTags, acctTags).ToArray();
if (subAllow is { Length: > 0 })
subAllow = PermissionTemplates.ExpandAll(subAllow, name, subject, acctName, acctSubject, userTags, acctTags).ToArray();
if (subDeny is { Length: > 0 })
subDeny = PermissionTemplates.ExpandAll(subDeny, name, subject, acctName, acctSubject, userTags, acctTags).ToArray();
if (pubAllow is not null || pubDeny is not null || subAllow is not null || subDeny is not null)
{
permissions = new Permissions
{
Publish = (pubAllow is not null || pubDeny is not null)
? new SubjectPermission { Allow = pubAllow, Deny = pubDeny }
: null,
Subscribe = (subAllow is not null || subDeny is not null)
? new SubjectPermission { Allow = subAllow, Deny = subDeny }
: null,
};
}
}
// 9. Build result
return new AuthResult
{
Identity = userClaims.Subject ?? "",
AccountName = issuerAccount,
Permissions = permissions,
Expiry = userClaims.GetExpiry(),
};
}
private bool IsTrusted(string? issuer)
{
if (string.IsNullOrEmpty(issuer)) return false;
foreach (var key in _trustedKeys)
{
if (key == issuer)
return true;
}
return false;
}
}

View File

@@ -15,6 +15,7 @@ public enum ClientFlags
WriteLoopStarted = 1 << 4,
IsSlowConsumer = 1 << 5,
ConnectProcessFinished = 1 << 6,
TraceMode = 1 << 7,
}
/// <summary>

View File

@@ -1,5 +1,6 @@
using System.Diagnostics;
using System.Runtime.InteropServices;
using System.Security.Cryptography.X509Certificates;
using NATS.Server.Protocol;
namespace NATS.Server.Monitoring;
@@ -47,6 +48,22 @@ public sealed class VarzHandler : IDisposable
_lastCpuUsage = currentCpu;
}
// Load the TLS certificate to report its expiry date in /varz.
// Corresponds to Go server/monitor.go handleVarz populating TLSCertExpiry.
DateTime? tlsCertExpiry = null;
if (_options.HasTls && !string.IsNullOrEmpty(_options.TlsCert))
{
try
{
using var cert = X509CertificateLoader.LoadCertificateFromFile(_options.TlsCert);
tlsCertExpiry = cert.NotAfter;
}
catch
{
// cert load failure — leave field as default
}
}
return new Varz
{
Id = _server.ServerId,
@@ -63,6 +80,8 @@ public sealed class VarzHandler : IDisposable
TlsRequired = _options.HasTls && !_options.AllowNonTls,
TlsVerify = _options.HasTls && _options.TlsVerify,
TlsTimeout = _options.HasTls ? _options.TlsTimeout.TotalSeconds : 0,
TlsCertNotAfter = tlsCertExpiry ?? default,
TlsOcspPeerVerify = _options.OcspPeerVerify,
MaxConnections = _options.MaxConnections,
MaxPayload = _options.MaxPayload,
MaxControlLine = _options.MaxControlLine,

View File

@@ -54,6 +54,20 @@ public sealed class NatsClient : IDisposable
public bool ConnectReceived => _flags.HasFlag(ClientFlags.ConnectReceived);
public ClientClosedReason CloseReason { get; private set; }
public void SetTraceMode(bool enabled)
{
if (enabled)
{
_flags.SetFlag(ClientFlags.TraceMode);
_parser.Logger = _logger;
}
else
{
_flags.ClearFlag(ClientFlags.TraceMode);
_parser.Logger = _options.Trace ? _logger : null;
}
}
public DateTime StartTime { get; }
private long _lastActivityTicks;
public DateTime LastActivity => new(Interlocked.Read(ref _lastActivityTicks), DateTimeKind.Utc);

View File

@@ -1,5 +1,6 @@
using System.Security.Authentication;
using NATS.Server.Auth;
using NATS.Server.Tls;
namespace NATS.Server;
@@ -85,5 +86,19 @@ public sealed class NatsOptions
public HashSet<string>? TlsPinnedCerts { get; set; }
public SslProtocols TlsMinVersion { get; set; } = SslProtocols.Tls12;
// OCSP stapling and peer verification
public OcspConfig? OcspConfig { get; set; }
public bool OcspPeerVerify { get; set; }
// JWT / Operator mode
public string[]? TrustedKeys { get; set; }
public Auth.Jwt.IAccountResolver? AccountResolver { get; set; }
// Per-subsystem log level overrides (namespace -> level)
public Dictionary<string, string>? LogOverrides { get; set; }
// Subject mapping / transforms (source pattern -> destination template)
public Dictionary<string, string>? SubjectMappings { get; set; }
public bool HasTls => TlsCert != null && TlsKey != null;
}

View File

@@ -32,6 +32,7 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
private readonly Account _systemAccount;
private readonly SslServerAuthenticationOptions? _sslOptions;
private readonly TlsRateLimiter? _tlsRateLimiter;
private readonly SubjectTransform[] _subjectTransforms;
private Socket? _listener;
private MonitorServer? _monitorServer;
private ulong _nextClientId;
@@ -276,6 +277,19 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
if (options.HasTls)
{
_sslOptions = TlsHelper.BuildServerAuthOptions(options);
// OCSP stapling: build a certificate context so the runtime can
// fetch and cache a fresh OCSP response and staple it during the
// TLS handshake. offline:false tells the runtime to contact the
// OCSP responder; if the responder is unreachable we fall back to
// no stapling rather than refusing all connections.
var certContext = TlsHelper.BuildCertificateContext(options, offline: false);
if (certContext != null)
{
_sslOptions.ServerCertificateContext = certContext;
_logger.LogInformation("OCSP stapling enabled (mode: {OcspMode})", options.OcspConfig!.Mode);
}
_serverInfo.TlsRequired = !options.AllowNonTls;
_serverInfo.TlsAvailable = options.AllowNonTls;
_serverInfo.TlsVerify = options.TlsVerify;
@@ -284,6 +298,27 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
_tlsRateLimiter = new TlsRateLimiter(options.TlsRateLimit);
}
// Compile subject transforms
if (options.SubjectMappings is { Count: > 0 })
{
var transforms = new List<SubjectTransform>();
foreach (var (source, dest) in options.SubjectMappings)
{
var t = SubjectTransform.Create(source, dest);
if (t != null)
transforms.Add(t);
else
_logger.LogWarning("Invalid subject mapping: {Source} -> {Dest}", source, dest);
}
_subjectTransforms = transforms.ToArray();
if (_subjectTransforms.Length > 0)
_logger.LogInformation("Compiled {Count} subject transform(s)", _subjectTransforms.Length);
}
else
{
_subjectTransforms = [];
}
BuildCachedInfo();
}
@@ -499,6 +534,20 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
public void ProcessMessage(string subject, string? replyTo, ReadOnlyMemory<byte> headers,
ReadOnlyMemory<byte> payload, NatsClient sender)
{
// Apply subject transforms
if (_subjectTransforms.Length > 0)
{
foreach (var transform in _subjectTransforms)
{
var mapped = transform.Apply(subject);
if (mapped != null)
{
subject = mapped;
break; // First matching transform wins
}
}
}
var subList = sender.Account?.SubList ?? _globalAccount.SubList;
var result = subList.Match(subject);
var delivered = false;

View File

@@ -36,7 +36,8 @@ public sealed class NatsParser
{
private static readonly byte[] CrLfBytes = "\r\n"u8.ToArray();
private readonly int _maxPayload;
private readonly ILogger? _logger;
private ILogger? _logger;
public ILogger? Logger { set => _logger = value; }
// State for split-packet payload reading
private bool _awaitingPayload;

View File

@@ -134,4 +134,7 @@ public sealed class ClientOptions
[JsonPropertyName("sig")]
public string? Sig { get; set; }
[JsonPropertyName("jwt")]
public string? JWT { get; set; }
}

View File

@@ -0,0 +1,708 @@
using System.Text;
using System.Text.RegularExpressions;
namespace NATS.Server.Subscriptions;
/// <summary>
/// Compiled subject transform engine that maps subjects from a source pattern to a destination template.
/// Reference: Go server/subject_transform.go
/// </summary>
public sealed partial class SubjectTransform
{
private readonly string _source;
private readonly string _dest;
private readonly string[] _sourceTokens;
private readonly string[] _destTokens;
private readonly TransformOp[] _ops;
private SubjectTransform(string source, string dest, string[] sourceTokens, string[] destTokens, TransformOp[] ops)
{
_source = source;
_dest = dest;
_sourceTokens = sourceTokens;
_destTokens = destTokens;
_ops = ops;
}
/// <summary>
/// Compiles a subject transform from source pattern to destination template.
/// Returns null if source is invalid or destination references out-of-range wildcards.
/// </summary>
public static SubjectTransform? Create(string source, string destination)
{
if (string.IsNullOrEmpty(destination))
return null;
if (string.IsNullOrEmpty(source))
source = ">";
// Validate source and destination as subjects
var (srcValid, srcTokens, srcPwcCount, srcHasFwc) = SubjectInfo(source);
var (destValid, destTokens, destPwcCount, destHasFwc) = SubjectInfo(destination);
// Both must be valid, dest must have no pwcs, fwc must match
if (!srcValid || !destValid || destPwcCount > 0 || srcHasFwc != destHasFwc)
return null;
var ops = new TransformOp[destTokens.Length];
if (srcPwcCount > 0 || srcHasFwc)
{
// Build map from 1-based wildcard index to source token position
var wildcardPositions = new Dictionary<int, int>();
int wildcardNum = 0;
for (int i = 0; i < srcTokens.Length; i++)
{
if (srcTokens[i] == "*")
{
wildcardNum++;
wildcardPositions[wildcardNum] = i;
}
}
for (int i = 0; i < destTokens.Length; i++)
{
var parsed = ParseDestToken(destTokens[i]);
if (parsed == null)
return null; // Parse error (bad function, etc.)
if (parsed.Type == TransformType.None)
{
ops[i] = new TransformOp(TransformType.None);
continue;
}
// Resolve wildcard indexes to source token positions
var srcPositions = new int[parsed.WildcardIndexes.Length];
for (int j = 0; j < parsed.WildcardIndexes.Length; j++)
{
int wcIdx = parsed.WildcardIndexes[j];
if (wcIdx > srcPwcCount)
return null; // Out of range
// Match Go behavior: missing map key returns zero-value (0)
// This happens for partition with index 0, which Go silently allows.
if (!wildcardPositions.TryGetValue(wcIdx, out int pos))
pos = 0;
srcPositions[j] = pos;
}
ops[i] = new TransformOp(parsed.Type, srcPositions, parsed.IntArg, parsed.StringArg);
}
}
else
{
// No wildcards in source: only NoTransform, Partition, and Random allowed
for (int i = 0; i < destTokens.Length; i++)
{
var parsed = ParseDestToken(destTokens[i]);
if (parsed == null)
return null;
if (parsed.Type == TransformType.None)
{
ops[i] = new TransformOp(TransformType.None);
}
else if (parsed.Type == TransformType.Partition)
{
ops[i] = new TransformOp(TransformType.Partition, [], parsed.IntArg, parsed.StringArg);
}
else
{
// Other functions not allowed without wildcards in source
return null;
}
}
}
return new SubjectTransform(source, destination, srcTokens, destTokens, ops);
}
/// <summary>
/// Matches subject against source pattern, captures wildcard values, evaluates destination template.
/// Returns null if subject doesn't match source.
/// </summary>
public string? Apply(string subject)
{
if (string.IsNullOrEmpty(subject))
return null;
// Special case: source is > (match everything) and dest is > (passthrough)
if ((_source == ">" || _source == string.Empty) && (_dest == ">" || _dest == string.Empty))
return subject;
var subjectTokens = subject.Split('.');
// Check if subject matches source pattern
if (_source != ">" && !MatchTokens(subjectTokens, _sourceTokens))
return null;
return TransformTokenized(subjectTokens);
}
private string TransformTokenized(string[] tokens)
{
if (_ops.Length == 0)
return _dest;
var sb = new StringBuilder();
int lastIndex = _ops.Length - 1;
for (int i = 0; i < _ops.Length; i++)
{
var op = _ops[i];
if (op.Type == TransformType.None)
{
// If this dest token is fwc, break out to handle trailing tokens
if (_destTokens[i] == ">")
break;
sb.Append(_destTokens[i]);
}
else
{
switch (op.Type)
{
case TransformType.Wildcard:
if (op.SourcePositions.Length > 0 && op.SourcePositions[0] < tokens.Length)
sb.Append(tokens[op.SourcePositions[0]]);
break;
case TransformType.Partition:
sb.Append(ComputePartition(tokens, op));
break;
case TransformType.Split:
ApplySplit(sb, tokens, op);
break;
case TransformType.SplitFromLeft:
ApplySplitFromLeft(sb, tokens, op);
break;
case TransformType.SplitFromRight:
ApplySplitFromRight(sb, tokens, op);
break;
case TransformType.SliceFromLeft:
ApplySliceFromLeft(sb, tokens, op);
break;
case TransformType.SliceFromRight:
ApplySliceFromRight(sb, tokens, op);
break;
case TransformType.Left:
ApplyLeft(sb, tokens, op);
break;
case TransformType.Right:
ApplyRight(sb, tokens, op);
break;
}
}
if (i < lastIndex)
sb.Append('.');
}
// Handle trailing fwc: append remaining tokens from subject
if (_destTokens[^1] == ">")
{
int srcFwcPos = _sourceTokens.Length - 1; // position of > in source
for (int i = srcFwcPos; i < tokens.Length; i++)
{
sb.Append(tokens[i]);
if (i < tokens.Length - 1)
sb.Append('.');
}
}
return sb.ToString();
}
private static string ComputePartition(string[] tokens, TransformOp op)
{
int numBuckets = op.IntArg;
if (numBuckets == 0)
return "0";
byte[] keyBytes;
if (op.SourcePositions.Length > 0)
{
// Hash concatenation of specified source tokens
var keyBuilder = new StringBuilder();
foreach (int pos in op.SourcePositions)
{
if (pos < tokens.Length)
keyBuilder.Append(tokens[pos]);
}
keyBytes = Encoding.ASCII.GetBytes(keyBuilder.ToString());
}
else
{
// Hash full subject (all tokens joined with .)
keyBytes = Encoding.ASCII.GetBytes(string.Join(".", tokens));
}
uint hash = Fnv1A32(keyBytes);
return (hash % (uint)numBuckets).ToString();
}
/// <summary>
/// FNV-1a 32-bit hash. Offset basis: 2166136261, prime: 16777619.
/// </summary>
private static uint Fnv1A32(byte[] data)
{
const uint offsetBasis = 2166136261;
const uint prime = 16777619;
uint hash = offsetBasis;
foreach (byte b in data)
{
hash ^= b;
hash *= prime;
}
return hash;
}
private static void ApplySplit(StringBuilder sb, string[] tokens, TransformOp op)
{
if (op.SourcePositions.Length == 0)
return;
string sourceToken = tokens[op.SourcePositions[0]];
string delimiter = op.StringArg ?? string.Empty;
var splits = sourceToken.Split(delimiter);
bool first = true;
for (int j = 0; j < splits.Length; j++)
{
string split = splits[j];
if (split != string.Empty)
{
if (!first)
sb.Append('.');
sb.Append(split);
first = false;
}
}
}
private static void ApplySplitFromLeft(StringBuilder sb, string[] tokens, TransformOp op)
{
string sourceToken = tokens[op.SourcePositions[0]];
int position = op.IntArg;
if (position > 0 && position < sourceToken.Length)
{
sb.Append(sourceToken.AsSpan(0, position));
sb.Append('.');
sb.Append(sourceToken.AsSpan(position));
}
else
{
sb.Append(sourceToken);
}
}
private static void ApplySplitFromRight(StringBuilder sb, string[] tokens, TransformOp op)
{
string sourceToken = tokens[op.SourcePositions[0]];
int position = op.IntArg;
if (position > 0 && position < sourceToken.Length)
{
sb.Append(sourceToken.AsSpan(0, sourceToken.Length - position));
sb.Append('.');
sb.Append(sourceToken.AsSpan(sourceToken.Length - position));
}
else
{
sb.Append(sourceToken);
}
}
private static void ApplySliceFromLeft(StringBuilder sb, string[] tokens, TransformOp op)
{
string sourceToken = tokens[op.SourcePositions[0]];
int sliceSize = op.IntArg;
if (sliceSize > 0 && sliceSize < sourceToken.Length)
{
for (int i = 0; i + sliceSize <= sourceToken.Length; i += sliceSize)
{
if (i != 0)
sb.Append('.');
sb.Append(sourceToken.AsSpan(i, sliceSize));
// If there's a remainder that doesn't fill a full slice
if (i + sliceSize != sourceToken.Length && i + sliceSize + sliceSize > sourceToken.Length)
{
sb.Append('.');
sb.Append(sourceToken.AsSpan(i + sliceSize));
break;
}
}
}
else
{
sb.Append(sourceToken);
}
}
private static void ApplySliceFromRight(StringBuilder sb, string[] tokens, TransformOp op)
{
string sourceToken = tokens[op.SourcePositions[0]];
int sliceSize = op.IntArg;
if (sliceSize > 0 && sliceSize < sourceToken.Length)
{
int remainder = sourceToken.Length % sliceSize;
if (remainder > 0)
{
sb.Append(sourceToken.AsSpan(0, remainder));
sb.Append('.');
}
for (int i = remainder; i + sliceSize <= sourceToken.Length; i += sliceSize)
{
sb.Append(sourceToken.AsSpan(i, sliceSize));
if (i + sliceSize < sourceToken.Length)
sb.Append('.');
}
}
else
{
sb.Append(sourceToken);
}
}
private static void ApplyLeft(StringBuilder sb, string[] tokens, TransformOp op)
{
string sourceToken = tokens[op.SourcePositions[0]];
int length = op.IntArg;
if (length > 0 && length < sourceToken.Length)
{
sb.Append(sourceToken.AsSpan(0, length));
}
else
{
sb.Append(sourceToken);
}
}
private static void ApplyRight(StringBuilder sb, string[] tokens, TransformOp op)
{
string sourceToken = tokens[op.SourcePositions[0]];
int length = op.IntArg;
if (length > 0 && length < sourceToken.Length)
{
sb.Append(sourceToken.AsSpan(sourceToken.Length - length));
}
else
{
sb.Append(sourceToken);
}
}
/// <summary>
/// Matches literal subject tokens against a pattern with wildcards.
/// Subject tokens must be literal (no wildcards).
/// </summary>
private static bool MatchTokens(string[] subjectTokens, string[] patternTokens)
{
for (int i = 0; i < patternTokens.Length; i++)
{
if (i >= subjectTokens.Length)
return false;
string pt = patternTokens[i];
// Full wildcard matches all remaining
if (pt == ">")
return true;
// Partial wildcard matches any single token
if (pt == "*")
continue;
// Literal comparison
if (subjectTokens[i] != pt)
return false;
}
// Both must be exhausted (unless pattern ended with >)
return subjectTokens.Length == patternTokens.Length;
}
/// <summary>
/// Validates a subject and returns (valid, tokens, pwcCount, hasFwc).
/// Reference: Go subject_transform.go subjectInfo()
/// </summary>
private static (bool Valid, string[] Tokens, int PwcCount, bool HasFwc) SubjectInfo(string subject)
{
if (string.IsNullOrEmpty(subject))
return (false, [], 0, false);
string[] tokens = subject.Split('.');
int pwcCount = 0;
bool hasFwc = false;
foreach (string t in tokens)
{
if (t.Length == 0 || hasFwc)
return (false, [], 0, false);
if (t.Length == 1)
{
switch (t[0])
{
case '>':
hasFwc = true;
break;
case '*':
pwcCount++;
break;
}
}
}
return (true, tokens, pwcCount, hasFwc);
}
/// <summary>
/// Parses a single destination token into a transform operation descriptor.
/// Returns null on parse error.
/// </summary>
private static ParsedToken? ParseDestToken(string token)
{
if (token.Length <= 1)
return new ParsedToken(TransformType.None, [], -1, string.Empty);
// $N shorthand for wildcard(N)
if (token[0] == '$')
{
if (int.TryParse(token.AsSpan(1), out int idx))
return new ParsedToken(TransformType.Wildcard, [idx], -1, string.Empty);
// Other things rely on tokens starting with $ so not an error
return new ParsedToken(TransformType.None, [], -1, string.Empty);
}
// Mustache-style {{function(args)}}
if (token.Length > 4 && token[0] == '{' && token[1] == '{' && token[^2] == '}' && token[^1] == '}')
{
return ParseMustacheToken(token);
}
return new ParsedToken(TransformType.None, [], -1, string.Empty);
}
private static ParsedToken? ParseMustacheToken(string token)
{
// wildcard(n)
var args = GetFunctionArgs(WildcardRegex(), token);
if (args != null)
{
if (args.Length == 1 && args[0] == string.Empty)
return null; // Not enough args
if (args.Length == 1)
{
if (!int.TryParse(args[0].Trim(), out int idx))
return null;
return new ParsedToken(TransformType.Wildcard, [idx], -1, string.Empty);
}
return null; // Too many args
}
// partition(num, tokens...)
args = GetFunctionArgs(PartitionRegex(), token);
if (args != null)
{
if (args.Length < 1)
return null;
if (args.Length == 1)
{
if (!TryParseInt32(args[0].Trim(), out int numBuckets))
return null;
return new ParsedToken(TransformType.Partition, [], numBuckets, string.Empty);
}
// partition(num, tok1, tok2, ...)
if (!TryParseInt32(args[0].Trim(), out int buckets))
return null;
var indexes = new int[args.Length - 1];
for (int i = 1; i < args.Length; i++)
{
if (!int.TryParse(args[i].Trim(), out indexes[i - 1]))
return null;
}
return new ParsedToken(TransformType.Partition, indexes, buckets, string.Empty);
}
// splitFromLeft(token, position)
args = GetFunctionArgs(SplitFromLeftRegex(), token);
if (args != null)
return ParseIndexIntArgs(args, TransformType.SplitFromLeft);
// splitFromRight(token, position)
args = GetFunctionArgs(SplitFromRightRegex(), token);
if (args != null)
return ParseIndexIntArgs(args, TransformType.SplitFromRight);
// sliceFromLeft(token, size)
args = GetFunctionArgs(SliceFromLeftRegex(), token);
if (args != null)
return ParseIndexIntArgs(args, TransformType.SliceFromLeft);
// sliceFromRight(token, size)
args = GetFunctionArgs(SliceFromRightRegex(), token);
if (args != null)
return ParseIndexIntArgs(args, TransformType.SliceFromRight);
// right(token, length)
args = GetFunctionArgs(RightRegex(), token);
if (args != null)
return ParseIndexIntArgs(args, TransformType.Right);
// left(token, length)
args = GetFunctionArgs(LeftRegex(), token);
if (args != null)
return ParseIndexIntArgs(args, TransformType.Left);
// split(token, delimiter)
args = GetFunctionArgs(SplitRegex(), token);
if (args != null)
{
if (args.Length < 2)
return null;
if (args.Length > 2)
return null;
if (!int.TryParse(args[0].Trim(), out int idx))
return null;
string delimiter = args[1];
if (delimiter.Contains(' ') || delimiter.Contains('.'))
return null;
return new ParsedToken(TransformType.Split, [idx], -1, delimiter);
}
// Unknown function
return null;
}
private static ParsedToken? ParseIndexIntArgs(string[] args, TransformType type)
{
if (args.Length < 2)
return null;
if (args.Length > 2)
return null;
if (!int.TryParse(args[0].Trim(), out int idx))
return null;
if (!TryParseInt32(args[1].Trim(), out int intArg))
return null;
return new ParsedToken(type, [idx], intArg, string.Empty);
}
private static bool TryParseInt32(string s, out int result)
{
// Parse as long first to detect overflow
if (long.TryParse(s, out long longVal) && longVal >= 0 && longVal <= int.MaxValue)
{
result = (int)longVal;
return true;
}
result = -1;
return false;
}
private static string[]? GetFunctionArgs(Regex regex, string token)
{
var match = regex.Match(token);
if (match.Success && match.Groups.Count > 1)
{
string argsStr = match.Groups[1].Value;
return CommaSeparatorRegex().Split(argsStr);
}
return null;
}
// Regex patterns matching the Go reference implementation (case-insensitive function names)
[GeneratedRegex(@"\{\{\s*[wW]ildcard\s*\((.*)\)\s*\}\}")]
private static partial Regex WildcardRegex();
[GeneratedRegex(@"\{\{\s*[pP]artition\s*\((.*)\)\s*\}\}")]
private static partial Regex PartitionRegex();
[GeneratedRegex(@"\{\{\s*[sS]plit[fF]rom[lL]eft\s*\((.*)\)\s*\}\}")]
private static partial Regex SplitFromLeftRegex();
[GeneratedRegex(@"\{\{\s*[sS]plit[fF]rom[rR]ight\s*\((.*)\)\s*\}\}")]
private static partial Regex SplitFromRightRegex();
[GeneratedRegex(@"\{\{\s*[sS]lice[fF]rom[lL]eft\s*\((.*)\)\s*\}\}")]
private static partial Regex SliceFromLeftRegex();
[GeneratedRegex(@"\{\{\s*[sS]lice[fF]rom[rR]ight\s*\((.*)\)\s*\}\}")]
private static partial Regex SliceFromRightRegex();
[GeneratedRegex(@"\{\{\s*[sS]plit\s*\((.*)\)\s*\}\}")]
private static partial Regex SplitRegex();
[GeneratedRegex(@"\{\{\s*[lL]eft\s*\((.*)\)\s*\}\}")]
private static partial Regex LeftRegex();
[GeneratedRegex(@"\{\{\s*[rR]ight\s*\((.*)\)\s*\}\}")]
private static partial Regex RightRegex();
[GeneratedRegex(@",\s*")]
private static partial Regex CommaSeparatorRegex();
private enum TransformType
{
None,
Wildcard,
Partition,
Split,
SplitFromLeft,
SplitFromRight,
SliceFromLeft,
SliceFromRight,
Left,
Right,
}
private sealed record ParsedToken(TransformType Type, int[] WildcardIndexes, int IntArg, string StringArg);
private readonly record struct TransformOp(
TransformType Type,
int[] SourcePositions,
int IntArg,
string? StringArg)
{
public TransformOp(TransformType type) : this(type, [], -1, null)
{
}
}
}

View File

@@ -0,0 +1,20 @@
namespace NATS.Server.Tls;
// OcspMode mirrors the OCSPMode constants from the Go reference implementation (ocsp.go).
// Auto — staple only if the certificate contains the status_request TLS extension.
// Always — always attempt stapling; warn but continue if the OCSP response cannot be obtained.
// Must — stapling is mandatory; fail server startup if the OCSP response cannot be obtained.
// Never — never attempt stapling regardless of certificate extensions.
public enum OcspMode
{
Auto = 0,
Always = 1,
Must = 2,
Never = 3,
}
public sealed class OcspConfig
{
public OcspMode Mode { get; init; } = OcspMode.Auto;
public string[] OverrideUrls { get; init; } = [];
}

View File

@@ -33,6 +33,10 @@ public static class TlsHelper
if (opts.TlsVerify && opts.TlsCaCert != null)
{
var revocationMode = opts.OcspPeerVerify
? X509RevocationMode.Online
: X509RevocationMode.NoCheck;
var caCerts = LoadCaCertificates(opts.TlsCaCert);
authOpts.RemoteCertificateValidationCallback = (_, cert, chain, errors) =>
{
@@ -41,7 +45,19 @@ public static class TlsHelper
chain2.ChainPolicy.TrustMode = X509ChainTrustMode.CustomRootTrust;
foreach (var ca in caCerts)
chain2.ChainPolicy.CustomTrustStore.Add(ca);
chain2.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck;
chain2.ChainPolicy.RevocationMode = revocationMode;
var cert2 = cert as X509Certificate2 ?? X509CertificateLoader.LoadCertificate(cert.GetRawCertData());
return chain2.Build(cert2);
};
}
else if (opts.OcspPeerVerify)
{
// No custom CA — still enable online revocation checking against the system store
authOpts.RemoteCertificateValidationCallback = (_, cert, chain, errors) =>
{
if (cert == null) return false;
using var chain2 = new X509Chain();
chain2.ChainPolicy.RevocationMode = X509RevocationMode.Online;
var cert2 = cert as X509Certificate2 ?? X509CertificateLoader.LoadCertificate(cert.GetRawCertData());
return chain2.Build(cert2);
};
@@ -50,6 +66,25 @@ public static class TlsHelper
return authOpts;
}
/// <summary>
/// Builds an <see cref="SslStreamCertificateContext"/> for OCSP stapling.
/// Returns null when TLS is not configured or OCSP mode is Never.
/// When <paramref name="offline"/> is false the runtime will contact the
/// certificate's OCSP responder to obtain a fresh stapled response.
/// </summary>
public static SslStreamCertificateContext? BuildCertificateContext(NatsOptions opts, bool offline = false)
{
if (!opts.HasTls) return null;
if (opts.OcspConfig is null || opts.OcspConfig.Mode == OcspMode.Never) return null;
var cert = LoadCertificate(opts.TlsCert!, opts.TlsKey);
var chain = new X509Certificate2Collection();
if (!string.IsNullOrEmpty(opts.TlsCaCert))
chain.ImportFromPemFile(opts.TlsCaCert);
return SslStreamCertificateContext.Create(cert, chain, offline: offline);
}
public static string GetCertificateHash(X509Certificate2 cert)
{
var spki = cert.PublicKey.ExportSubjectPublicKeyInfo();

View File

@@ -0,0 +1,68 @@
using NATS.Server.Auth.Jwt;
namespace NATS.Server.Tests;
public class AccountResolverTests
{
[Fact]
public async Task Store_and_fetch_roundtrip()
{
var resolver = new MemAccountResolver();
const string nkey = "AABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789ABCDEFGHIJKLMNOPQ";
const string jwt = "eyJhbGciOiJlZDI1NTE5LW5rZXkiLCJ0eXAiOiJKV1QifQ.payload.sig";
await resolver.StoreAsync(nkey, jwt);
var fetched = await resolver.FetchAsync(nkey);
fetched.ShouldBe(jwt);
}
[Fact]
public async Task Fetch_unknown_key_returns_null()
{
var resolver = new MemAccountResolver();
var result = await resolver.FetchAsync("UNKNOWN_NKEY");
result.ShouldBeNull();
}
[Fact]
public async Task Store_overwrites_existing_entry()
{
var resolver = new MemAccountResolver();
const string nkey = "AABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789ABCDEFGHIJKLMNOPQ";
const string originalJwt = "original.jwt.token";
const string updatedJwt = "updated.jwt.token";
await resolver.StoreAsync(nkey, originalJwt);
await resolver.StoreAsync(nkey, updatedJwt);
var fetched = await resolver.FetchAsync(nkey);
fetched.ShouldBe(updatedJwt);
}
[Fact]
public void IsReadOnly_returns_false()
{
IAccountResolver resolver = new MemAccountResolver();
resolver.IsReadOnly.ShouldBeFalse();
}
[Fact]
public async Task Multiple_accounts_are_stored_independently()
{
var resolver = new MemAccountResolver();
const string nkey1 = "AABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789ABCDEFGHIJKLMNOPQ1";
const string nkey2 = "AABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789ABCDEFGHIJKLMNOPQ2";
const string jwt1 = "jwt.for.account.one";
const string jwt2 = "jwt.for.account.two";
await resolver.StoreAsync(nkey1, jwt1);
await resolver.StoreAsync(nkey2, jwt2);
(await resolver.FetchAsync(nkey1)).ShouldBe(jwt1);
(await resolver.FetchAsync(nkey2)).ShouldBe(jwt2);
}
}

View File

@@ -0,0 +1,48 @@
using NATS.Server.Auth;
namespace NATS.Server.Tests;
public class AccountStatsTests
{
[Fact]
public void Account_tracks_inbound_stats()
{
var account = new Account("test");
account.IncrementInbound(1, 100);
account.IncrementInbound(1, 200);
account.InMsgs.ShouldBe(2);
account.InBytes.ShouldBe(300);
}
[Fact]
public void Account_tracks_outbound_stats()
{
var account = new Account("test");
account.IncrementOutbound(1, 50);
account.IncrementOutbound(1, 75);
account.OutMsgs.ShouldBe(2);
account.OutBytes.ShouldBe(125);
}
[Fact]
public void Account_stats_start_at_zero()
{
var account = new Account("test");
account.InMsgs.ShouldBe(0);
account.OutMsgs.ShouldBe(0);
account.InBytes.ShouldBe(0);
account.OutBytes.ShouldBe(0);
}
[Fact]
public void Account_stats_are_independent()
{
var account = new Account("test");
account.IncrementInbound(5, 500);
account.IncrementOutbound(3, 300);
account.InMsgs.ShouldBe(5);
account.OutMsgs.ShouldBe(3);
account.InBytes.ShouldBe(500);
account.OutBytes.ShouldBe(300);
}
}

View File

@@ -0,0 +1,15 @@
namespace NATS.Server.Tests;
public class ClientTraceModeTests
{
[Fact]
public void TraceMode_flag_can_be_set_and_cleared()
{
var holder = new ClientFlagHolder();
holder.HasFlag(ClientFlags.TraceMode).ShouldBeFalse();
holder.SetFlag(ClientFlags.TraceMode);
holder.HasFlag(ClientFlags.TraceMode).ShouldBeTrue();
holder.ClearFlag(ClientFlags.TraceMode);
holder.HasFlag(ClientFlags.TraceMode).ShouldBeFalse();
}
}

View File

@@ -0,0 +1,591 @@
using System.Text;
using NATS.NKeys;
using NATS.Server.Auth;
using NATS.Server.Auth.Jwt;
using NATS.Server.Protocol;
namespace NATS.Server.Tests;
public class JwtAuthenticatorTests
{
private static string Base64UrlEncode(string input) =>
Base64UrlEncode(Encoding.UTF8.GetBytes(input));
private static string Base64UrlEncode(byte[] input) =>
Convert.ToBase64String(input).TrimEnd('=').Replace('+', '-').Replace('/', '_');
private static string BuildSignedToken(string payloadJson, KeyPair signingKey)
{
var header = Base64UrlEncode("""{"typ":"JWT","alg":"ed25519-nkey"}""");
var payload = Base64UrlEncode(payloadJson);
var signingInput = Encoding.UTF8.GetBytes($"{header}.{payload}");
var sig = new byte[64];
signingKey.Sign(signingInput, sig);
return $"{header}.{payload}.{Base64UrlEncode(sig)}";
}
private static string SignNonce(KeyPair kp, byte[] nonce)
{
var sig = new byte[64];
kp.Sign(nonce, sig);
return Convert.ToBase64String(sig).TrimEnd('=').Replace('+', '-').Replace('/', '_');
}
[Fact]
public async Task Valid_bearer_jwt_returns_auth_result()
{
var operatorKp = KeyPair.CreatePair(PrefixByte.Operator);
var accountKp = KeyPair.CreatePair(PrefixByte.Account);
var userKp = KeyPair.CreatePair(PrefixByte.User);
var operatorPub = operatorKp.GetPublicKey();
var accountPub = accountKp.GetPublicKey();
var userPub = userKp.GetPublicKey();
var accountPayload = $$"""
{
"sub":"{{accountPub}}",
"iss":"{{operatorPub}}",
"iat":1700000000,
"nats":{"type":"account","version":2}
}
""";
var accountJwt = BuildSignedToken(accountPayload, operatorKp);
var userPayload = $$"""
{
"sub":"{{userPub}}",
"iss":"{{accountPub}}",
"iat":1700000000,
"nats":{
"type":"user","version":2,
"bearer_token":true,
"issuer_account":"{{accountPub}}"
}
}
""";
var userJwt = BuildSignedToken(userPayload, accountKp);
var resolver = new MemAccountResolver();
await resolver.StoreAsync(accountPub, accountJwt);
var auth = new JwtAuthenticator([operatorPub], resolver);
var ctx = new ClientAuthContext
{
Opts = new ClientOptions { JWT = userJwt },
Nonce = "test-nonce"u8.ToArray(),
};
var result = auth.Authenticate(ctx);
result.ShouldNotBeNull();
result.Identity.ShouldBe(userPub);
result.AccountName.ShouldBe(accountPub);
}
[Fact]
public async Task Valid_jwt_with_nonce_signature_returns_auth_result()
{
var operatorKp = KeyPair.CreatePair(PrefixByte.Operator);
var accountKp = KeyPair.CreatePair(PrefixByte.Account);
var userKp = KeyPair.CreatePair(PrefixByte.User);
var operatorPub = operatorKp.GetPublicKey();
var accountPub = accountKp.GetPublicKey();
var userPub = userKp.GetPublicKey();
var accountPayload = $$"""
{
"sub":"{{accountPub}}",
"iss":"{{operatorPub}}",
"iat":1700000000,
"nats":{"type":"account","version":2}
}
""";
var accountJwt = BuildSignedToken(accountPayload, operatorKp);
var userPayload = $$"""
{
"sub":"{{userPub}}",
"iss":"{{accountPub}}",
"iat":1700000000,
"nats":{
"type":"user","version":2,
"issuer_account":"{{accountPub}}"
}
}
""";
var userJwt = BuildSignedToken(userPayload, accountKp);
var resolver = new MemAccountResolver();
await resolver.StoreAsync(accountPub, accountJwt);
var auth = new JwtAuthenticator([operatorPub], resolver);
var nonce = "test-nonce-data"u8.ToArray();
var sig = SignNonce(userKp, nonce);
var ctx = new ClientAuthContext
{
Opts = new ClientOptions { JWT = userJwt, Nkey = userPub, Sig = sig },
Nonce = nonce,
};
var result = auth.Authenticate(ctx);
result.ShouldNotBeNull();
result.Identity.ShouldBe(userPub);
result.AccountName.ShouldBe(accountPub);
}
[Fact]
public void No_jwt_returns_null()
{
var operatorKp = KeyPair.CreatePair(PrefixByte.Operator);
var resolver = new MemAccountResolver();
var auth = new JwtAuthenticator([operatorKp.GetPublicKey()], resolver);
var ctx = new ClientAuthContext
{
Opts = new ClientOptions(),
Nonce = "nonce"u8.ToArray(),
};
auth.Authenticate(ctx).ShouldBeNull();
}
[Fact]
public void Non_jwt_string_returns_null()
{
var operatorKp = KeyPair.CreatePair(PrefixByte.Operator);
var resolver = new MemAccountResolver();
var auth = new JwtAuthenticator([operatorKp.GetPublicKey()], resolver);
var ctx = new ClientAuthContext
{
Opts = new ClientOptions { JWT = "not-a-jwt" },
Nonce = "nonce"u8.ToArray(),
};
auth.Authenticate(ctx).ShouldBeNull();
}
[Fact]
public async Task Expired_jwt_returns_null()
{
var operatorKp = KeyPair.CreatePair(PrefixByte.Operator);
var accountKp = KeyPair.CreatePair(PrefixByte.Account);
var userKp = KeyPair.CreatePair(PrefixByte.User);
var operatorPub = operatorKp.GetPublicKey();
var accountPub = accountKp.GetPublicKey();
var userPub = userKp.GetPublicKey();
var accountPayload = $$"""
{
"sub":"{{accountPub}}",
"iss":"{{operatorPub}}",
"iat":1700000000,
"nats":{"type":"account","version":2}
}
""";
var accountJwt = BuildSignedToken(accountPayload, operatorKp);
// Expired in 2020
var userPayload = $$"""
{
"sub":"{{userPub}}",
"iss":"{{accountPub}}",
"iat":1500000000,
"exp":1600000000,
"nats":{
"type":"user","version":2,
"bearer_token":true,
"issuer_account":"{{accountPub}}"
}
}
""";
var userJwt = BuildSignedToken(userPayload, accountKp);
var resolver = new MemAccountResolver();
await resolver.StoreAsync(accountPub, accountJwt);
var auth = new JwtAuthenticator([operatorPub], resolver);
var ctx = new ClientAuthContext
{
Opts = new ClientOptions { JWT = userJwt },
Nonce = "nonce"u8.ToArray(),
};
auth.Authenticate(ctx).ShouldBeNull();
}
[Fact]
public async Task Revoked_user_returns_null()
{
var operatorKp = KeyPair.CreatePair(PrefixByte.Operator);
var accountKp = KeyPair.CreatePair(PrefixByte.Account);
var userKp = KeyPair.CreatePair(PrefixByte.User);
var operatorPub = operatorKp.GetPublicKey();
var accountPub = accountKp.GetPublicKey();
var userPub = userKp.GetPublicKey();
// Account JWT with revocation for user
var accountPayload = $$"""
{
"sub":"{{accountPub}}",
"iss":"{{operatorPub}}",
"iat":1700000000,
"nats":{
"type":"account","version":2,
"revocations":{
"{{userPub}}":1700000001
}
}
}
""";
var accountJwt = BuildSignedToken(accountPayload, operatorKp);
// User JWT issued at 1700000000 (before revocation time 1700000001)
var userPayload = $$"""
{
"sub":"{{userPub}}",
"iss":"{{accountPub}}",
"iat":1700000000,
"nats":{
"type":"user","version":2,
"bearer_token":true,
"issuer_account":"{{accountPub}}"
}
}
""";
var userJwt = BuildSignedToken(userPayload, accountKp);
var resolver = new MemAccountResolver();
await resolver.StoreAsync(accountPub, accountJwt);
var auth = new JwtAuthenticator([operatorPub], resolver);
var ctx = new ClientAuthContext
{
Opts = new ClientOptions { JWT = userJwt },
Nonce = "nonce"u8.ToArray(),
};
auth.Authenticate(ctx).ShouldBeNull();
}
[Fact]
public async Task Untrusted_operator_returns_null()
{
var operatorKp = KeyPair.CreatePair(PrefixByte.Operator);
var accountKp = KeyPair.CreatePair(PrefixByte.Account);
var userKp = KeyPair.CreatePair(PrefixByte.User);
var operatorPub = operatorKp.GetPublicKey();
var accountPub = accountKp.GetPublicKey();
var userPub = userKp.GetPublicKey();
var accountPayload = $$"""
{
"sub":"{{accountPub}}",
"iss":"{{operatorPub}}",
"iat":1700000000,
"nats":{"type":"account","version":2}
}
""";
var accountJwt = BuildSignedToken(accountPayload, operatorKp);
var userPayload = $$"""
{
"sub":"{{userPub}}",
"iss":"{{accountPub}}",
"iat":1700000000,
"nats":{
"type":"user","version":2,
"bearer_token":true,
"issuer_account":"{{accountPub}}"
}
}
""";
var userJwt = BuildSignedToken(userPayload, accountKp);
var resolver = new MemAccountResolver();
await resolver.StoreAsync(accountPub, accountJwt);
// Use a different trusted key that doesn't match the operator
var otherOperator = KeyPair.CreatePair(PrefixByte.Operator).GetPublicKey();
var auth = new JwtAuthenticator([otherOperator], resolver);
var ctx = new ClientAuthContext
{
Opts = new ClientOptions { JWT = userJwt },
Nonce = "nonce"u8.ToArray(),
};
auth.Authenticate(ctx).ShouldBeNull();
}
[Fact]
public void Unknown_account_returns_null()
{
var operatorKp = KeyPair.CreatePair(PrefixByte.Operator);
var accountKp = KeyPair.CreatePair(PrefixByte.Account);
var userKp = KeyPair.CreatePair(PrefixByte.User);
var operatorPub = operatorKp.GetPublicKey();
var accountPub = accountKp.GetPublicKey();
var userPub = userKp.GetPublicKey();
var userPayload = $$"""
{
"sub":"{{userPub}}",
"iss":"{{accountPub}}",
"iat":1700000000,
"nats":{
"type":"user","version":2,
"bearer_token":true,
"issuer_account":"{{accountPub}}"
}
}
""";
var userJwt = BuildSignedToken(userPayload, accountKp);
// Don't store the account JWT in the resolver
var resolver = new MemAccountResolver();
var auth = new JwtAuthenticator([operatorPub], resolver);
var ctx = new ClientAuthContext
{
Opts = new ClientOptions { JWT = userJwt },
Nonce = "nonce"u8.ToArray(),
};
auth.Authenticate(ctx).ShouldBeNull();
}
[Fact]
public async Task Non_bearer_without_sig_returns_null()
{
var operatorKp = KeyPair.CreatePair(PrefixByte.Operator);
var accountKp = KeyPair.CreatePair(PrefixByte.Account);
var userKp = KeyPair.CreatePair(PrefixByte.User);
var operatorPub = operatorKp.GetPublicKey();
var accountPub = accountKp.GetPublicKey();
var userPub = userKp.GetPublicKey();
var accountPayload = $$"""
{
"sub":"{{accountPub}}",
"iss":"{{operatorPub}}",
"iat":1700000000,
"nats":{"type":"account","version":2}
}
""";
var accountJwt = BuildSignedToken(accountPayload, operatorKp);
// Non-bearer user JWT
var userPayload = $$"""
{
"sub":"{{userPub}}",
"iss":"{{accountPub}}",
"iat":1700000000,
"nats":{
"type":"user","version":2,
"issuer_account":"{{accountPub}}"
}
}
""";
var userJwt = BuildSignedToken(userPayload, accountKp);
var resolver = new MemAccountResolver();
await resolver.StoreAsync(accountPub, accountJwt);
var auth = new JwtAuthenticator([operatorPub], resolver);
var ctx = new ClientAuthContext
{
Opts = new ClientOptions { JWT = userJwt }, // No Sig provided
Nonce = "nonce"u8.ToArray(),
};
auth.Authenticate(ctx).ShouldBeNull();
}
[Fact]
public async Task Jwt_with_permissions_returns_permissions()
{
var operatorKp = KeyPair.CreatePair(PrefixByte.Operator);
var accountKp = KeyPair.CreatePair(PrefixByte.Account);
var userKp = KeyPair.CreatePair(PrefixByte.User);
var operatorPub = operatorKp.GetPublicKey();
var accountPub = accountKp.GetPublicKey();
var userPub = userKp.GetPublicKey();
var accountPayload = $$"""
{
"sub":"{{accountPub}}",
"iss":"{{operatorPub}}",
"iat":1700000000,
"nats":{"type":"account","version":2}
}
""";
var accountJwt = BuildSignedToken(accountPayload, operatorKp);
var userPayload = $$"""
{
"sub":"{{userPub}}",
"iss":"{{accountPub}}",
"iat":1700000000,
"nats":{
"type":"user","version":2,
"bearer_token":true,
"issuer_account":"{{accountPub}}",
"pub":{"allow":["foo.>","bar.*"]}
}
}
""";
var userJwt = BuildSignedToken(userPayload, accountKp);
var resolver = new MemAccountResolver();
await resolver.StoreAsync(accountPub, accountJwt);
var auth = new JwtAuthenticator([operatorPub], resolver);
var ctx = new ClientAuthContext
{
Opts = new ClientOptions { JWT = userJwt },
Nonce = "nonce"u8.ToArray(),
};
var result = auth.Authenticate(ctx);
result.ShouldNotBeNull();
result.Permissions.ShouldNotBeNull();
result.Permissions.Publish.ShouldNotBeNull();
result.Permissions.Publish.Allow.ShouldNotBeNull();
result.Permissions.Publish.Allow.ShouldContain("foo.>");
result.Permissions.Publish.Allow.ShouldContain("bar.*");
}
[Fact]
public async Task Signing_key_based_user_jwt_succeeds()
{
var operatorKp = KeyPair.CreatePair(PrefixByte.Operator);
var accountKp = KeyPair.CreatePair(PrefixByte.Account);
var signingKp = KeyPair.CreatePair(PrefixByte.Account);
var userKp = KeyPair.CreatePair(PrefixByte.User);
var operatorPub = operatorKp.GetPublicKey();
var accountPub = accountKp.GetPublicKey();
var signingPub = signingKp.GetPublicKey();
var userPub = userKp.GetPublicKey();
// Account JWT with signing key
var accountPayload = $$"""
{
"sub":"{{accountPub}}",
"iss":"{{operatorPub}}",
"iat":1700000000,
"nats":{
"type":"account","version":2,
"signing_keys":["{{signingPub}}"]
}
}
""";
var accountJwt = BuildSignedToken(accountPayload, operatorKp);
// User JWT issued by the signing key
var userPayload = $$"""
{
"sub":"{{userPub}}",
"iss":"{{signingPub}}",
"iat":1700000000,
"nats":{
"type":"user","version":2,
"bearer_token":true,
"issuer_account":"{{accountPub}}"
}
}
""";
var userJwt = BuildSignedToken(userPayload, signingKp);
var resolver = new MemAccountResolver();
await resolver.StoreAsync(accountPub, accountJwt);
var auth = new JwtAuthenticator([operatorPub], resolver);
var ctx = new ClientAuthContext
{
Opts = new ClientOptions { JWT = userJwt },
Nonce = "nonce"u8.ToArray(),
};
var result = auth.Authenticate(ctx);
result.ShouldNotBeNull();
result.Identity.ShouldBe(userPub);
result.AccountName.ShouldBe(accountPub);
}
[Fact]
public async Task Wildcard_revocation_returns_null()
{
var operatorKp = KeyPair.CreatePair(PrefixByte.Operator);
var accountKp = KeyPair.CreatePair(PrefixByte.Account);
var userKp = KeyPair.CreatePair(PrefixByte.User);
var operatorPub = operatorKp.GetPublicKey();
var accountPub = accountKp.GetPublicKey();
var userPub = userKp.GetPublicKey();
// Account JWT with wildcard revocation
var accountPayload = $$"""
{
"sub":"{{accountPub}}",
"iss":"{{operatorPub}}",
"iat":1700000000,
"nats":{
"type":"account","version":2,
"revocations":{
"*":1700000001
}
}
}
""";
var accountJwt = BuildSignedToken(accountPayload, operatorKp);
// User JWT issued at 1700000000 (before wildcard revocation)
var userPayload = $$"""
{
"sub":"{{userPub}}",
"iss":"{{accountPub}}",
"iat":1700000000,
"nats":{
"type":"user","version":2,
"bearer_token":true,
"issuer_account":"{{accountPub}}"
}
}
""";
var userJwt = BuildSignedToken(userPayload, accountKp);
var resolver = new MemAccountResolver();
await resolver.StoreAsync(accountPub, accountJwt);
var auth = new JwtAuthenticator([operatorPub], resolver);
var ctx = new ClientAuthContext
{
Opts = new ClientOptions { JWT = userJwt },
Nonce = "nonce"u8.ToArray(),
};
auth.Authenticate(ctx).ShouldBeNull();
}
}

View File

@@ -0,0 +1,932 @@
using System.Text;
using System.Text.Json;
using NATS.NKeys;
using NATS.Server.Auth.Jwt;
namespace NATS.Server.Tests;
public class JwtTests
{
/// <summary>
/// Helper: base64url-encode a string for constructing test JWTs.
/// </summary>
private static string Base64UrlEncode(string value)
{
var bytes = Encoding.UTF8.GetBytes(value);
return Convert.ToBase64String(bytes)
.TrimEnd('=')
.Replace('+', '-')
.Replace('/', '_');
}
/// <summary>
/// Helper: build a minimal unsigned JWT from header and payload JSON strings.
/// The signature part is a base64url-encoded 64-byte zero array (invalid but structurally correct).
/// </summary>
private static string BuildUnsignedToken(string headerJson, string payloadJson)
{
var header = Base64UrlEncode(headerJson);
var payload = Base64UrlEncode(payloadJson);
var fakeSig = Convert.ToBase64String(new byte[64])
.TrimEnd('=')
.Replace('+', '-')
.Replace('/', '_');
return $"{header}.{payload}.{fakeSig}";
}
/// <summary>
/// Helper: build a real signed NATS JWT using an NKey keypair.
/// Signs header.payload with Ed25519.
/// </summary>
private static string BuildSignedToken(string payloadJson, KeyPair signingKey)
{
var headerJson = """{"typ":"JWT","alg":"ed25519-nkey"}""";
var header = Base64UrlEncode(headerJson);
var payload = Base64UrlEncode(payloadJson);
var signingInput = $"{header}.{payload}";
var signingInputBytes = Encoding.UTF8.GetBytes(signingInput);
var sig = new byte[64];
signingKey.Sign(signingInputBytes, sig);
var sigB64 = Convert.ToBase64String(sig)
.TrimEnd('=')
.Replace('+', '-')
.Replace('/', '_');
return $"{header}.{payload}.{sigB64}";
}
// =====================================================================
// IsJwt tests
// =====================================================================
[Fact]
public void IsJwt_returns_true_for_eyJ_prefix()
{
NatsJwt.IsJwt("eyJhbGciOiJlZDI1NTE5LW5rZXkiLCJ0eXAiOiJKV1QifQ.payload.sig").ShouldBeTrue();
}
[Fact]
public void IsJwt_returns_true_for_minimal_eyJ()
{
NatsJwt.IsJwt("eyJ").ShouldBeTrue();
}
[Fact]
public void IsJwt_returns_false_for_non_jwt()
{
NatsJwt.IsJwt("notajwt").ShouldBeFalse();
}
[Fact]
public void IsJwt_returns_false_for_empty_string()
{
NatsJwt.IsJwt("").ShouldBeFalse();
}
[Fact]
public void IsJwt_returns_false_for_null()
{
NatsJwt.IsJwt(null!).ShouldBeFalse();
}
// =====================================================================
// Decode tests
// =====================================================================
[Fact]
public void Decode_splits_header_payload_signature_correctly()
{
var headerJson = """{"typ":"JWT","alg":"ed25519-nkey"}""";
var payloadJson = """{"sub":"UAXXX","iss":"AAXXX","iat":1700000000}""";
var token = BuildUnsignedToken(headerJson, payloadJson);
var result = NatsJwt.Decode(token);
result.ShouldNotBeNull();
result.Header.ShouldNotBeNull();
result.Header.Type.ShouldBe("JWT");
result.Header.Algorithm.ShouldBe("ed25519-nkey");
}
[Fact]
public void Decode_returns_payload_json()
{
var headerJson = """{"typ":"JWT","alg":"ed25519-nkey"}""";
var payloadJson = """{"sub":"UAXXX","iss":"AAXXX","iat":1700000000}""";
var token = BuildUnsignedToken(headerJson, payloadJson);
var result = NatsJwt.Decode(token);
result.ShouldNotBeNull();
result.PayloadJson.ShouldNotBeNullOrEmpty();
// The payload JSON should parse back to matching fields
using var doc = JsonDocument.Parse(result.PayloadJson);
doc.RootElement.GetProperty("sub").GetString().ShouldBe("UAXXX");
doc.RootElement.GetProperty("iss").GetString().ShouldBe("AAXXX");
}
[Fact]
public void Decode_preserves_signature_bytes()
{
var headerJson = """{"typ":"JWT","alg":"ed25519-nkey"}""";
var payloadJson = """{"sub":"test"}""";
var token = BuildUnsignedToken(headerJson, payloadJson);
var result = NatsJwt.Decode(token);
result.ShouldNotBeNull();
result.Signature.ShouldNotBeNull();
result.Signature.Length.ShouldBe(64);
}
[Fact]
public void Decode_preserves_signing_input()
{
var headerJson = """{"typ":"JWT","alg":"ed25519-nkey"}""";
var payloadJson = """{"sub":"test"}""";
var token = BuildUnsignedToken(headerJson, payloadJson);
var result = NatsJwt.Decode(token);
result.ShouldNotBeNull();
// SigningInput should be "header.payload" (the first two parts)
var parts = token.Split('.');
var expectedSigningInput = $"{parts[0]}.{parts[1]}";
result.SigningInput.ShouldBe(expectedSigningInput);
}
[Fact]
public void Decode_returns_null_for_invalid_token_missing_parts()
{
NatsJwt.Decode("onlyonepart").ShouldBeNull();
}
[Fact]
public void Decode_returns_null_for_two_parts()
{
NatsJwt.Decode("part1.part2").ShouldBeNull();
}
[Fact]
public void Decode_returns_null_for_empty_string()
{
NatsJwt.Decode("").ShouldBeNull();
}
[Fact]
public void Decode_returns_null_for_invalid_base64_in_header()
{
NatsJwt.Decode("!!!invalid.payload.sig").ShouldBeNull();
}
// =====================================================================
// Verify tests
// =====================================================================
[Fact]
public void Verify_returns_true_for_valid_signed_token()
{
var accountKp = KeyPair.CreatePair(PrefixByte.Account);
var accountPublicKey = accountKp.GetPublicKey();
var payloadJson = $$"""{"sub":"UAXXX","iss":"{{accountPublicKey}}","iat":1700000000}""";
var token = BuildSignedToken(payloadJson, accountKp);
NatsJwt.Verify(token, accountPublicKey).ShouldBeTrue();
}
[Fact]
public void Verify_returns_false_for_wrong_key()
{
var signingKp = KeyPair.CreatePair(PrefixByte.Account);
var wrongKp = KeyPair.CreatePair(PrefixByte.Account);
var payloadJson = """{"sub":"UAXXX","iss":"AAXXX","iat":1700000000}""";
var token = BuildSignedToken(payloadJson, signingKp);
NatsJwt.Verify(token, wrongKp.GetPublicKey()).ShouldBeFalse();
}
[Fact]
public void Verify_returns_false_for_tampered_payload()
{
var accountKp = KeyPair.CreatePair(PrefixByte.Account);
var accountPublicKey = accountKp.GetPublicKey();
var payloadJson = """{"sub":"UAXXX","iss":"AAXXX","iat":1700000000}""";
var token = BuildSignedToken(payloadJson, accountKp);
// Tamper with the payload
var parts = token.Split('.');
var tamperedPayload = Base64UrlEncode("""{"sub":"HACKED","iss":"AAXXX","iat":1700000000}""");
var tampered = $"{parts[0]}.{tamperedPayload}.{parts[2]}";
NatsJwt.Verify(tampered, accountPublicKey).ShouldBeFalse();
}
[Fact]
public void Verify_returns_false_for_invalid_token()
{
var kp = KeyPair.CreatePair(PrefixByte.Account);
NatsJwt.Verify("not.a.jwt", kp.GetPublicKey()).ShouldBeFalse();
}
// =====================================================================
// VerifyNonce tests
// =====================================================================
[Fact]
public void VerifyNonce_accepts_base64url_signature()
{
var kp = KeyPair.CreatePair(PrefixByte.User);
var publicKey = kp.GetPublicKey();
var nonce = "test-nonce-data"u8.ToArray();
var sig = new byte[64];
kp.Sign(nonce, sig);
// Encode as base64url
var sigB64Url = Convert.ToBase64String(sig)
.TrimEnd('=')
.Replace('+', '-')
.Replace('/', '_');
NatsJwt.VerifyNonce(nonce, sigB64Url, publicKey).ShouldBeTrue();
}
[Fact]
public void VerifyNonce_accepts_standard_base64_signature()
{
var kp = KeyPair.CreatePair(PrefixByte.User);
var publicKey = kp.GetPublicKey();
var nonce = "test-nonce-data"u8.ToArray();
var sig = new byte[64];
kp.Sign(nonce, sig);
// Encode as standard base64
var sigB64 = Convert.ToBase64String(sig);
NatsJwt.VerifyNonce(nonce, sigB64, publicKey).ShouldBeTrue();
}
[Fact]
public void VerifyNonce_returns_false_for_wrong_nonce()
{
var kp = KeyPair.CreatePair(PrefixByte.User);
var publicKey = kp.GetPublicKey();
var nonce = "original-nonce"u8.ToArray();
var wrongNonce = "different-nonce"u8.ToArray();
var sig = new byte[64];
kp.Sign(nonce, sig);
var sigB64 = Convert.ToBase64String(sig);
NatsJwt.VerifyNonce(wrongNonce, sigB64, publicKey).ShouldBeFalse();
}
[Fact]
public void VerifyNonce_returns_false_for_invalid_signature()
{
var kp = KeyPair.CreatePair(PrefixByte.User);
var publicKey = kp.GetPublicKey();
var nonce = "test-nonce"u8.ToArray();
NatsJwt.VerifyNonce(nonce, "invalid-sig!", publicKey).ShouldBeFalse();
}
// =====================================================================
// DecodeUserClaims tests
// =====================================================================
[Fact]
public void DecodeUserClaims_parses_subject_and_issuer()
{
var headerJson = """{"typ":"JWT","alg":"ed25519-nkey"}""";
var payloadJson = """
{
"sub":"UAXXX_USER_KEY",
"iss":"AAXXX_ISSUER",
"iat":1700000000,
"name":"test-user",
"nats":{
"type":"user",
"version":2
}
}
""";
var token = BuildUnsignedToken(headerJson, payloadJson);
var claims = NatsJwt.DecodeUserClaims(token);
claims.ShouldNotBeNull();
claims.Subject.ShouldBe("UAXXX_USER_KEY");
claims.Issuer.ShouldBe("AAXXX_ISSUER");
claims.Name.ShouldBe("test-user");
claims.IssuedAt.ShouldBe(1700000000);
}
[Fact]
public void DecodeUserClaims_parses_pub_sub_permissions()
{
var headerJson = """{"typ":"JWT","alg":"ed25519-nkey"}""";
var payloadJson = """
{
"sub":"UAXXX",
"iss":"AAXXX",
"iat":1700000000,
"nats":{
"pub":{"allow":["foo.>","bar.*"],"deny":["bar.secret"]},
"sub":{"allow":[">"],"deny":["_INBOX.private.>"]},
"type":"user",
"version":2
}
}
""";
var token = BuildUnsignedToken(headerJson, payloadJson);
var claims = NatsJwt.DecodeUserClaims(token);
claims.ShouldNotBeNull();
claims.Nats.ShouldNotBeNull();
claims.Nats.Pub.ShouldNotBeNull();
claims.Nats.Pub.Allow.ShouldBe(["foo.>", "bar.*"]);
claims.Nats.Pub.Deny.ShouldBe(["bar.secret"]);
claims.Nats.Sub.ShouldNotBeNull();
claims.Nats.Sub.Allow.ShouldBe([">"]);
claims.Nats.Sub.Deny.ShouldBe(["_INBOX.private.>"]);
}
[Fact]
public void DecodeUserClaims_parses_response_permission()
{
var headerJson = """{"typ":"JWT","alg":"ed25519-nkey"}""";
var payloadJson = """
{
"sub":"UAXXX",
"iss":"AAXXX",
"iat":1700000000,
"nats":{
"resp":{"max":5,"ttl":3000000000},
"type":"user",
"version":2
}
}
""";
var token = BuildUnsignedToken(headerJson, payloadJson);
var claims = NatsJwt.DecodeUserClaims(token);
claims.ShouldNotBeNull();
claims.Nats.ShouldNotBeNull();
claims.Nats.Resp.ShouldNotBeNull();
claims.Nats.Resp.MaxMsgs.ShouldBe(5);
claims.Nats.Resp.TtlNanos.ShouldBe(3000000000L);
claims.Nats.Resp.Ttl.ShouldBe(TimeSpan.FromSeconds(3));
}
[Fact]
public void DecodeUserClaims_parses_bearer_token_flag()
{
var headerJson = """{"typ":"JWT","alg":"ed25519-nkey"}""";
var payloadJson = """
{
"sub":"UAXXX",
"iss":"AAXXX",
"iat":1700000000,
"nats":{
"bearer_token":true,
"issuer_account":"AAXXX_ISSUER_ACCOUNT",
"type":"user",
"version":2
}
}
""";
var token = BuildUnsignedToken(headerJson, payloadJson);
var claims = NatsJwt.DecodeUserClaims(token);
claims.ShouldNotBeNull();
claims.Nats.ShouldNotBeNull();
claims.Nats.BearerToken.ShouldBeTrue();
claims.Nats.IssuerAccount.ShouldBe("AAXXX_ISSUER_ACCOUNT");
}
[Fact]
public void DecodeUserClaims_parses_tags_src_connection_types()
{
var headerJson = """{"typ":"JWT","alg":"ed25519-nkey"}""";
var payloadJson = """
{
"sub":"UAXXX",
"iss":"AAXXX",
"iat":1700000000,
"nats":{
"tags":["web","mobile"],
"src":["192.168.1.0/24","10.0.0.0/8"],
"allowed_connection_types":["STANDARD","WEBSOCKET"],
"type":"user",
"version":2
}
}
""";
var token = BuildUnsignedToken(headerJson, payloadJson);
var claims = NatsJwt.DecodeUserClaims(token);
claims.ShouldNotBeNull();
claims.Nats.ShouldNotBeNull();
claims.Nats.Tags.ShouldBe(["web", "mobile"]);
claims.Nats.Src.ShouldBe(["192.168.1.0/24", "10.0.0.0/8"]);
claims.Nats.AllowedConnectionTypes.ShouldBe(["STANDARD", "WEBSOCKET"]);
}
[Fact]
public void DecodeUserClaims_parses_time_ranges()
{
var headerJson = """{"typ":"JWT","alg":"ed25519-nkey"}""";
var payloadJson = """
{
"sub":"UAXXX",
"iss":"AAXXX",
"iat":1700000000,
"nats":{
"times":[
{"start":"08:00:00","end":"17:00:00"},
{"start":"20:00:00","end":"22:00:00"}
],
"type":"user",
"version":2
}
}
""";
var token = BuildUnsignedToken(headerJson, payloadJson);
var claims = NatsJwt.DecodeUserClaims(token);
claims.ShouldNotBeNull();
claims.Nats.ShouldNotBeNull();
claims.Nats.Times.ShouldNotBeNull();
claims.Nats.Times.Length.ShouldBe(2);
claims.Nats.Times[0].Start.ShouldBe("08:00:00");
claims.Nats.Times[0].End.ShouldBe("17:00:00");
claims.Nats.Times[1].Start.ShouldBe("20:00:00");
claims.Nats.Times[1].End.ShouldBe("22:00:00");
}
[Fact]
public void DecodeUserClaims_convenience_properties_delegate_to_nats()
{
var headerJson = """{"typ":"JWT","alg":"ed25519-nkey"}""";
var payloadJson = """
{
"sub":"UAXXX",
"iss":"AAXXX",
"iat":1700000000,
"nats":{
"bearer_token":true,
"issuer_account":"AAXXX_ACCOUNT",
"type":"user",
"version":2
}
}
""";
var token = BuildUnsignedToken(headerJson, payloadJson);
var claims = NatsJwt.DecodeUserClaims(token);
claims.ShouldNotBeNull();
// Convenience properties should delegate to Nats sub-object
claims.BearerToken.ShouldBeTrue();
claims.IssuerAccount.ShouldBe("AAXXX_ACCOUNT");
}
[Fact]
public void DecodeUserClaims_IsExpired_returns_false_when_no_expiry()
{
var headerJson = """{"typ":"JWT","alg":"ed25519-nkey"}""";
var payloadJson = """
{
"sub":"UAXXX",
"iss":"AAXXX",
"iat":1700000000,
"nats":{"type":"user","version":2}
}
""";
var token = BuildUnsignedToken(headerJson, payloadJson);
var claims = NatsJwt.DecodeUserClaims(token);
claims.ShouldNotBeNull();
claims.Expires.ShouldBe(0);
claims.IsExpired().ShouldBeFalse();
claims.GetExpiry().ShouldBeNull();
}
[Fact]
public void DecodeUserClaims_IsExpired_returns_true_for_past_expiry()
{
var headerJson = """{"typ":"JWT","alg":"ed25519-nkey"}""";
// Expired in 2020
var payloadJson = """
{
"sub":"UAXXX",
"iss":"AAXXX",
"iat":1500000000,
"exp":1600000000,
"nats":{"type":"user","version":2}
}
""";
var token = BuildUnsignedToken(headerJson, payloadJson);
var claims = NatsJwt.DecodeUserClaims(token);
claims.ShouldNotBeNull();
claims.Expires.ShouldBe(1600000000);
claims.IsExpired().ShouldBeTrue();
claims.GetExpiry().ShouldNotBeNull();
claims.GetExpiry()!.Value.ToUnixTimeSeconds().ShouldBe(1600000000);
}
[Fact]
public void DecodeUserClaims_IsExpired_returns_false_for_future_expiry()
{
var headerJson = """{"typ":"JWT","alg":"ed25519-nkey"}""";
// Expires far in the future
var payloadJson = """
{
"sub":"UAXXX",
"iss":"AAXXX",
"iat":1700000000,
"exp":4102444800,
"nats":{"type":"user","version":2}
}
""";
var token = BuildUnsignedToken(headerJson, payloadJson);
var claims = NatsJwt.DecodeUserClaims(token);
claims.ShouldNotBeNull();
claims.IsExpired().ShouldBeFalse();
claims.GetExpiry().ShouldNotBeNull();
}
[Fact]
public void DecodeUserClaims_returns_null_for_invalid_token()
{
NatsJwt.DecodeUserClaims("not-a-jwt").ShouldBeNull();
}
// =====================================================================
// DecodeAccountClaims tests
// =====================================================================
[Fact]
public void DecodeAccountClaims_parses_subject_and_issuer()
{
var headerJson = """{"typ":"JWT","alg":"ed25519-nkey"}""";
var payloadJson = """
{
"sub":"AAXXX_ACCOUNT_KEY",
"iss":"OAXXX_OPERATOR",
"iat":1700000000,
"name":"test-account",
"nats":{
"type":"account",
"version":2
}
}
""";
var token = BuildUnsignedToken(headerJson, payloadJson);
var claims = NatsJwt.DecodeAccountClaims(token);
claims.ShouldNotBeNull();
claims.Subject.ShouldBe("AAXXX_ACCOUNT_KEY");
claims.Issuer.ShouldBe("OAXXX_OPERATOR");
claims.Name.ShouldBe("test-account");
claims.IssuedAt.ShouldBe(1700000000);
}
[Fact]
public void DecodeAccountClaims_parses_limits()
{
var headerJson = """{"typ":"JWT","alg":"ed25519-nkey"}""";
var payloadJson = """
{
"sub":"AAXXX",
"iss":"OAXXX",
"iat":1700000000,
"nats":{
"limits":{
"conn":100,
"subs":1000,
"payload":1048576,
"data":10737418240
},
"type":"account",
"version":2
}
}
""";
var token = BuildUnsignedToken(headerJson, payloadJson);
var claims = NatsJwt.DecodeAccountClaims(token);
claims.ShouldNotBeNull();
claims.Nats.ShouldNotBeNull();
claims.Nats.Limits.ShouldNotBeNull();
claims.Nats.Limits.MaxConnections.ShouldBe(100);
claims.Nats.Limits.MaxSubscriptions.ShouldBe(1000);
claims.Nats.Limits.MaxPayload.ShouldBe(1048576);
claims.Nats.Limits.MaxData.ShouldBe(10737418240L);
}
[Fact]
public void DecodeAccountClaims_parses_signing_keys()
{
var headerJson = """{"typ":"JWT","alg":"ed25519-nkey"}""";
var payloadJson = """
{
"sub":"AAXXX",
"iss":"OAXXX",
"iat":1700000000,
"nats":{
"signing_keys":["AAXXX_SIGN_1","AAXXX_SIGN_2","AAXXX_SIGN_3"],
"type":"account",
"version":2
}
}
""";
var token = BuildUnsignedToken(headerJson, payloadJson);
var claims = NatsJwt.DecodeAccountClaims(token);
claims.ShouldNotBeNull();
claims.Nats.ShouldNotBeNull();
claims.Nats.SigningKeys.ShouldNotBeNull();
claims.Nats.SigningKeys.ShouldBe(["AAXXX_SIGN_1", "AAXXX_SIGN_2", "AAXXX_SIGN_3"]);
}
[Fact]
public void DecodeAccountClaims_parses_revocations()
{
var headerJson = """{"typ":"JWT","alg":"ed25519-nkey"}""";
var payloadJson = """
{
"sub":"AAXXX",
"iss":"OAXXX",
"iat":1700000000,
"nats":{
"revocations":{
"UAXXX_REVOKED_1":1700000000,
"UAXXX_REVOKED_2":1700001000
},
"type":"account",
"version":2
}
}
""";
var token = BuildUnsignedToken(headerJson, payloadJson);
var claims = NatsJwt.DecodeAccountClaims(token);
claims.ShouldNotBeNull();
claims.Nats.ShouldNotBeNull();
claims.Nats.Revocations.ShouldNotBeNull();
claims.Nats.Revocations.Count.ShouldBe(2);
claims.Nats.Revocations["UAXXX_REVOKED_1"].ShouldBe(1700000000);
claims.Nats.Revocations["UAXXX_REVOKED_2"].ShouldBe(1700001000);
}
[Fact]
public void DecodeAccountClaims_handles_negative_one_unlimited_limits()
{
var headerJson = """{"typ":"JWT","alg":"ed25519-nkey"}""";
var payloadJson = """
{
"sub":"AAXXX",
"iss":"OAXXX",
"iat":1700000000,
"nats":{
"limits":{
"conn":-1,
"subs":-1,
"payload":-1,
"data":-1
},
"type":"account",
"version":2
}
}
""";
var token = BuildUnsignedToken(headerJson, payloadJson);
var claims = NatsJwt.DecodeAccountClaims(token);
claims.ShouldNotBeNull();
claims.Nats.ShouldNotBeNull();
claims.Nats.Limits.ShouldNotBeNull();
claims.Nats.Limits.MaxConnections.ShouldBe(-1);
claims.Nats.Limits.MaxSubscriptions.ShouldBe(-1);
claims.Nats.Limits.MaxPayload.ShouldBe(-1);
claims.Nats.Limits.MaxData.ShouldBe(-1);
}
[Fact]
public void DecodeAccountClaims_returns_null_for_invalid_token()
{
NatsJwt.DecodeAccountClaims("invalid").ShouldBeNull();
}
[Fact]
public void DecodeAccountClaims_parses_expiry()
{
var headerJson = """{"typ":"JWT","alg":"ed25519-nkey"}""";
var payloadJson = """
{
"sub":"AAXXX",
"iss":"OAXXX",
"iat":1700000000,
"exp":1800000000,
"name":"expiring-account",
"nats":{
"type":"account",
"version":2
}
}
""";
var token = BuildUnsignedToken(headerJson, payloadJson);
var claims = NatsJwt.DecodeAccountClaims(token);
claims.ShouldNotBeNull();
claims.Expires.ShouldBe(1800000000);
}
// =====================================================================
// Round-trip with real Ed25519 signing tests
// =====================================================================
[Fact]
public void Roundtrip_sign_and_verify_user_claims()
{
var accountKp = KeyPair.CreatePair(PrefixByte.Account);
var accountPublicKey = accountKp.GetPublicKey();
var payloadJson = $$"""
{
"sub":"UAXXX_USER",
"iss":"{{accountPublicKey}}",
"iat":1700000000,
"name":"roundtrip-user",
"nats":{
"pub":{"allow":["test.>"]},
"bearer_token":true,
"issuer_account":"{{accountPublicKey}}",
"type":"user",
"version":2
}
}
""";
var token = BuildSignedToken(payloadJson, accountKp);
// Verify signature
NatsJwt.Verify(token, accountPublicKey).ShouldBeTrue();
// Decode claims
var claims = NatsJwt.DecodeUserClaims(token);
claims.ShouldNotBeNull();
claims.Subject.ShouldBe("UAXXX_USER");
claims.Name.ShouldBe("roundtrip-user");
claims.Nats.ShouldNotBeNull();
claims.Nats.Pub.ShouldNotBeNull();
claims.Nats.Pub.Allow.ShouldBe(["test.>"]);
claims.BearerToken.ShouldBeTrue();
claims.IssuerAccount.ShouldBe(accountPublicKey);
}
[Fact]
public void Roundtrip_sign_and_verify_account_claims()
{
var operatorKp = KeyPair.CreatePair(PrefixByte.Operator);
var operatorPublicKey = operatorKp.GetPublicKey();
var payloadJson = $$"""
{
"sub":"AAXXX_ACCOUNT",
"iss":"{{operatorPublicKey}}",
"iat":1700000000,
"name":"roundtrip-account",
"nats":{
"limits":{"conn":50,"subs":500,"payload":65536,"data":-1},
"signing_keys":["AAXXX_SK1"],
"revocations":{"UAXXX_OLD":1699000000},
"type":"account",
"version":2
}
}
""";
var token = BuildSignedToken(payloadJson, operatorKp);
// Verify signature
NatsJwt.Verify(token, operatorPublicKey).ShouldBeTrue();
// Decode claims
var claims = NatsJwt.DecodeAccountClaims(token);
claims.ShouldNotBeNull();
claims.Subject.ShouldBe("AAXXX_ACCOUNT");
claims.Name.ShouldBe("roundtrip-account");
claims.Nats.ShouldNotBeNull();
claims.Nats.Limits.ShouldNotBeNull();
claims.Nats.Limits.MaxConnections.ShouldBe(50);
claims.Nats.SigningKeys.ShouldBe(["AAXXX_SK1"]);
claims.Nats.Revocations.ShouldNotBeNull();
claims.Nats.Revocations["UAXXX_OLD"].ShouldBe(1699000000);
}
// =====================================================================
// Edge case tests
// =====================================================================
[Fact]
public void DecodeUserClaims_handles_missing_nats_object()
{
var headerJson = """{"typ":"JWT","alg":"ed25519-nkey"}""";
var payloadJson = """
{
"sub":"UAXXX",
"iss":"AAXXX",
"iat":1700000000
}
""";
var token = BuildUnsignedToken(headerJson, payloadJson);
var claims = NatsJwt.DecodeUserClaims(token);
// Should still decode the outer fields even if nats is missing
claims.ShouldNotBeNull();
claims.Subject.ShouldBe("UAXXX");
claims.Issuer.ShouldBe("AAXXX");
}
[Fact]
public void DecodeAccountClaims_handles_empty_nats_object()
{
var headerJson = """{"typ":"JWT","alg":"ed25519-nkey"}""";
var payloadJson = """
{
"sub":"AAXXX",
"iss":"OAXXX",
"iat":1700000000,
"nats":{}
}
""";
var token = BuildUnsignedToken(headerJson, payloadJson);
var claims = NatsJwt.DecodeAccountClaims(token);
claims.ShouldNotBeNull();
claims.Subject.ShouldBe("AAXXX");
claims.Nats.ShouldNotBeNull();
}
[Fact]
public void DecodeUserClaims_handles_empty_pub_sub_permissions()
{
var headerJson = """{"typ":"JWT","alg":"ed25519-nkey"}""";
var payloadJson = """
{
"sub":"UAXXX",
"iss":"AAXXX",
"iat":1700000000,
"nats":{
"pub":{},
"sub":{},
"type":"user",
"version":2
}
}
""";
var token = BuildUnsignedToken(headerJson, payloadJson);
var claims = NatsJwt.DecodeUserClaims(token);
claims.ShouldNotBeNull();
claims.Nats.ShouldNotBeNull();
claims.Nats.Pub.ShouldNotBeNull();
claims.Nats.Sub.ShouldNotBeNull();
// Allow/Deny should be null when not specified
claims.Nats.Pub.Allow.ShouldBeNull();
claims.Nats.Pub.Deny.ShouldBeNull();
}
}

View File

@@ -15,3 +15,24 @@ public class NatsOptionsTests
opts.Tags.ShouldBeNull();
}
}
public class LogOverrideTests
{
[Fact]
public void LogOverrides_defaults_to_null()
{
var options = new NatsOptions();
options.LogOverrides.ShouldBeNull();
}
[Fact]
public void LogOverrides_can_be_set()
{
var options = new NatsOptions
{
LogOverrides = new() { ["NATS.Server.Protocol"] = "Trace" }
};
options.LogOverrides.ShouldNotBeNull();
options.LogOverrides["NATS.Server.Protocol"].ShouldBe("Trace");
}
}

View File

@@ -0,0 +1,90 @@
using NATS.Server.Tls;
namespace NATS.Server.Tests;
public class OcspConfigTests
{
[Fact]
public void OcspMode_Auto_has_value_zero()
{
((int)OcspMode.Auto).ShouldBe(0);
}
[Fact]
public void OcspMode_Always_has_value_one()
{
((int)OcspMode.Always).ShouldBe(1);
}
[Fact]
public void OcspMode_Must_has_value_two()
{
((int)OcspMode.Must).ShouldBe(2);
}
[Fact]
public void OcspMode_Never_has_value_three()
{
((int)OcspMode.Never).ShouldBe(3);
}
[Fact]
public void OcspConfig_default_mode_is_Auto()
{
var config = new OcspConfig();
config.Mode.ShouldBe(OcspMode.Auto);
}
[Fact]
public void OcspConfig_OverrideUrls_defaults_to_empty_array()
{
var config = new OcspConfig();
config.OverrideUrls.ShouldNotBeNull();
config.OverrideUrls.ShouldBeEmpty();
}
[Fact]
public void OcspConfig_Mode_can_be_set_via_init()
{
var config = new OcspConfig { Mode = OcspMode.Must };
config.Mode.ShouldBe(OcspMode.Must);
}
[Fact]
public void OcspConfig_OverrideUrls_can_be_set_via_init()
{
var urls = new[] { "http://ocsp.example.com", "http://backup.example.com" };
var config = new OcspConfig { OverrideUrls = urls };
config.OverrideUrls.ShouldBe(urls);
}
[Fact]
public void NatsOptions_OcspConfig_defaults_to_null()
{
var opts = new NatsOptions();
opts.OcspConfig.ShouldBeNull();
}
[Fact]
public void NatsOptions_OcspPeerVerify_defaults_to_false()
{
var opts = new NatsOptions();
opts.OcspPeerVerify.ShouldBeFalse();
}
[Fact]
public void NatsOptions_OcspConfig_can_be_assigned()
{
var config = new OcspConfig { Mode = OcspMode.Always };
var opts = new NatsOptions { OcspConfig = config };
opts.OcspConfig.ShouldNotBeNull();
opts.OcspConfig!.Mode.ShouldBe(OcspMode.Always);
}
[Fact]
public void NatsOptions_OcspPeerVerify_can_be_set_to_true()
{
var opts = new NatsOptions { OcspPeerVerify = true };
opts.OcspPeerVerify.ShouldBeTrue();
}
}

View File

@@ -0,0 +1,97 @@
using NATS.Server.Tls;
namespace NATS.Server.Tests;
public class OcspStaplingTests
{
[Fact]
public void OcspMode_Must_is_strictest()
{
var config = new OcspConfig { Mode = OcspMode.Must };
config.Mode.ShouldBe(OcspMode.Must);
}
[Fact]
public void OcspMode_Never_disables_all()
{
var config = new OcspConfig { Mode = OcspMode.Never };
config.Mode.ShouldBe(OcspMode.Never);
}
[Fact]
public void OcspPeerVerify_default_is_false()
{
var options = new NatsOptions();
options.OcspPeerVerify.ShouldBeFalse();
}
[Fact]
public void OcspConfig_default_mode_is_Auto()
{
var config = new OcspConfig();
config.Mode.ShouldBe(OcspMode.Auto);
}
[Fact]
public void OcspConfig_default_OverrideUrls_is_empty()
{
var config = new OcspConfig();
config.OverrideUrls.ShouldBeEmpty();
}
[Fact]
public void BuildCertificateContext_returns_null_when_no_tls()
{
var options = new NatsOptions
{
OcspConfig = new OcspConfig { Mode = OcspMode.Always },
};
// HasTls is false because TlsCert and TlsKey are not set
options.HasTls.ShouldBeFalse();
var context = TlsHelper.BuildCertificateContext(options);
context.ShouldBeNull();
}
[Fact]
public void BuildCertificateContext_returns_null_when_mode_is_Never()
{
var options = new NatsOptions
{
TlsCert = "server.pem",
TlsKey = "server-key.pem",
OcspConfig = new OcspConfig { Mode = OcspMode.Never },
};
// OcspMode.Never must short-circuit even when TLS cert paths are set
var context = TlsHelper.BuildCertificateContext(options);
context.ShouldBeNull();
}
[Fact]
public void BuildCertificateContext_returns_null_when_OcspConfig_is_null()
{
var options = new NatsOptions
{
TlsCert = "server.pem",
TlsKey = "server-key.pem",
OcspConfig = null,
};
var context = TlsHelper.BuildCertificateContext(options);
context.ShouldBeNull();
}
[Fact]
public void OcspPeerVerify_can_be_enabled()
{
var options = new NatsOptions { OcspPeerVerify = true };
options.OcspPeerVerify.ShouldBeTrue();
}
[Fact]
public void OcspMode_values_have_correct_ordinals()
{
((int)OcspMode.Auto).ShouldBe(0);
((int)OcspMode.Always).ShouldBe(1);
((int)OcspMode.Must).ShouldBe(2);
((int)OcspMode.Never).ShouldBe(3);
}
}

View File

@@ -0,0 +1,99 @@
namespace NATS.Server.Tests;
using NATS.Server.Auth.Jwt;
public class PermissionTemplateTests
{
[Fact]
public void Expand_name_template()
{
var result = PermissionTemplates.Expand("user.{{name()}}.>",
name: "alice", subject: "UABC", accountName: "acct", accountSubject: "AABC",
userTags: [], accountTags: []);
result.ShouldBe(["user.alice.>"]);
}
[Fact]
public void Expand_subject_template()
{
var result = PermissionTemplates.Expand("inbox.{{subject()}}.>",
name: "alice", subject: "UABC123", accountName: "acct", accountSubject: "AABC",
userTags: [], accountTags: []);
result.ShouldBe(["inbox.UABC123.>"]);
}
[Fact]
public void Expand_account_name_template()
{
var result = PermissionTemplates.Expand("acct.{{account-name()}}.>",
name: "alice", subject: "UABC", accountName: "myaccount", accountSubject: "AABC",
userTags: [], accountTags: []);
result.ShouldBe(["acct.myaccount.>"]);
}
[Fact]
public void Expand_account_subject_template()
{
var result = PermissionTemplates.Expand("acct.{{account-subject()}}.>",
name: "alice", subject: "UABC", accountName: "acct", accountSubject: "AABC456",
userTags: [], accountTags: []);
result.ShouldBe(["acct.AABC456.>"]);
}
[Fact]
public void Expand_tag_template_single_value()
{
var result = PermissionTemplates.Expand("dept.{{tag(dept)}}.>",
name: "alice", subject: "UABC", accountName: "acct", accountSubject: "AABC",
userTags: ["dept:engineering"], accountTags: []);
result.ShouldBe(["dept.engineering.>"]);
}
[Fact]
public void Expand_tag_template_multi_value_cartesian()
{
var result = PermissionTemplates.Expand("dept.{{tag(dept)}}.>",
name: "alice", subject: "UABC", accountName: "acct", accountSubject: "AABC",
userTags: ["dept:eng", "dept:sales"], accountTags: []);
result.Count.ShouldBe(2);
result.ShouldContain("dept.eng.>");
result.ShouldContain("dept.sales.>");
}
[Fact]
public void Expand_account_tag_template()
{
var result = PermissionTemplates.Expand("region.{{account-tag(region)}}.>",
name: "alice", subject: "UABC", accountName: "acct", accountSubject: "AABC",
userTags: [], accountTags: ["region:us-east"]);
result.ShouldBe(["region.us-east.>"]);
}
[Fact]
public void Expand_no_templates_returns_original()
{
var result = PermissionTemplates.Expand("foo.bar.>",
name: "alice", subject: "UABC", accountName: "acct", accountSubject: "AABC",
userTags: [], accountTags: []);
result.ShouldBe(["foo.bar.>"]);
}
[Fact]
public void Expand_unknown_tag_returns_empty()
{
var result = PermissionTemplates.Expand("dept.{{tag(missing)}}.>",
name: "alice", subject: "UABC", accountName: "acct", accountSubject: "AABC",
userTags: ["dept:eng"], accountTags: []);
result.ShouldBeEmpty();
}
[Fact]
public void ExpandAll_expands_array_of_subjects()
{
var subjects = new[] { "user.{{name()}}.>", "inbox.{{subject()}}.>" };
var result = PermissionTemplates.ExpandAll(subjects,
name: "alice", subject: "UABC", accountName: "acct", accountSubject: "AABC",
userTags: [], accountTags: []);
result.ShouldBe(["user.alice.>", "inbox.UABC.>"]);
}
}

View File

@@ -0,0 +1,91 @@
using Microsoft.Extensions.Logging.Abstractions;
using NATS.Server.Subscriptions;
namespace NATS.Server.Tests;
public class SubjectTransformIntegrationTests
{
[Fact]
public void Server_compiles_subject_mappings()
{
var options = new NatsOptions
{
SubjectMappings = new Dictionary<string, string>
{
["src.*"] = "dest.{{wildcard(1)}}",
["orders.*.*"] = "processed.{{wildcard(2)}}.{{wildcard(1)}}",
},
};
using var server = new NatsServer(options, NullLoggerFactory.Instance);
// Server should have started without errors (transforms compiled)
server.Port.ShouldBe(4222);
}
[Fact]
public void Server_ignores_null_subject_mappings()
{
var options = new NatsOptions { SubjectMappings = null };
using var server = new NatsServer(options, NullLoggerFactory.Instance);
server.Port.ShouldBe(4222);
}
[Fact]
public void Server_ignores_empty_subject_mappings()
{
var options = new NatsOptions { SubjectMappings = new Dictionary<string, string>() };
using var server = new NatsServer(options, NullLoggerFactory.Instance);
server.Port.ShouldBe(4222);
}
[Fact]
public void Server_logs_warning_for_invalid_mapping()
{
var options = new NatsOptions
{
SubjectMappings = new Dictionary<string, string>
{
[""] = "dest", // invalid empty source becomes ">" which is valid
},
};
using var server = new NatsServer(options, NullLoggerFactory.Instance);
// Should not throw, just log a warning and skip
server.Port.ShouldBe(4222);
}
[Fact]
public void SubjectTransform_applies_first_matching_rule()
{
// Unit test the transform application logic directly
var t1 = SubjectTransform.Create("src.*", "dest.{{wildcard(1)}}");
var t2 = SubjectTransform.Create("src.*", "other.{{wildcard(1)}}");
t1.ShouldNotBeNull();
t2.ShouldNotBeNull();
var transforms = new[] { t1, t2 };
string subject = "src.hello";
// Apply transforms -- first match wins
foreach (var transform in transforms)
{
var mapped = transform.Apply(subject);
if (mapped != null)
{
subject = mapped;
break;
}
}
subject.ShouldBe("dest.hello");
}
[Fact]
public void SubjectTransform_non_matching_subject_unchanged()
{
var t = SubjectTransform.Create("src.*", "dest.{{wildcard(1)}}");
t.ShouldNotBeNull();
var result = t.Apply("other.hello");
result.ShouldBeNull(); // No match
}
}

View File

@@ -0,0 +1,396 @@
using NATS.Server.Subscriptions;
namespace NATS.Server.Tests;
public class SubjectTransformTests
{
[Fact]
public void WildcardReplacement_SingleToken()
{
// foo.* -> bar.{{wildcard(1)}}
var transform = SubjectTransform.Create("foo.*", "bar.{{wildcard(1)}}");
transform.ShouldNotBeNull();
transform.Apply("foo.baz").ShouldBe("bar.baz");
}
[Fact]
public void DollarSyntax_ReversesOrder()
{
// foo.*.* -> bar.$2.$1 reverses captured tokens
var transform = SubjectTransform.Create("foo.*.*", "bar.$2.$1");
transform.ShouldNotBeNull();
transform.Apply("foo.A.B").ShouldBe("bar.B.A");
}
[Fact]
public void DollarSyntax_MultipleWildcardPositions()
{
// foo.*.bar.*.baz -> req.$2.$1
var transform = SubjectTransform.Create("foo.*.bar.*.baz", "req.$2.$1");
transform.ShouldNotBeNull();
transform.Apply("foo.A.bar.B.baz").ShouldBe("req.B.A");
}
[Fact]
public void WildcardFunction_MultiplePositions()
{
// foo.*.bar.*.baz -> req.{{wildcard(2)}}.{{wildcard(1)}}
var transform = SubjectTransform.Create("foo.*.bar.*.baz", "req.{{wildcard(2)}}.{{wildcard(1)}}");
transform.ShouldNotBeNull();
transform.Apply("foo.A.bar.B.baz").ShouldBe("req.B.A");
}
[Fact]
public void FullWildcardCapture_MultiToken()
{
// baz.> -> my.pre.> captures multi-token remainder
var transform = SubjectTransform.Create("baz.>", "my.pre.>");
transform.ShouldNotBeNull();
transform.Apply("baz.1.2.3").ShouldBe("my.pre.1.2.3");
}
[Fact]
public void FullWildcardCapture_FooBar()
{
// baz.> -> foo.bar.>
var transform = SubjectTransform.Create("baz.>", "foo.bar.>");
transform.ShouldNotBeNull();
transform.Apply("baz.1.2.3").ShouldBe("foo.bar.1.2.3");
}
[Fact]
public void NoMatch_ReturnsNull()
{
var transform = SubjectTransform.Create("foo.*", "bar.$1");
transform.ShouldNotBeNull();
transform.Apply("baz.qux").ShouldBeNull();
}
[Fact]
public void NoMatch_WrongTokenCount()
{
var transform = SubjectTransform.Create("foo.*", "bar.$1");
transform.ShouldNotBeNull();
transform.Apply("foo.a.b").ShouldBeNull();
}
[Fact]
public void PartitionFunction_DeterministicResult()
{
// Partition should produce deterministic 0..N-1 results
var transform = SubjectTransform.Create("*", "bar.{{partition(10)}}");
transform.ShouldNotBeNull();
// FNV-1a of "foo" mod 10 = 3
transform.Apply("foo").ShouldBe("bar.3");
// FNV-1a of "baz" mod 10 = 0
transform.Apply("baz").ShouldBe("bar.0");
// FNV-1a of "qux" mod 10 = 9
transform.Apply("qux").ShouldBe("bar.9");
}
[Fact]
public void PartitionFunction_ZeroBuckets()
{
var transform = SubjectTransform.Create("*", "bar.{{partition(0)}}");
transform.ShouldNotBeNull();
transform.Apply("baz").ShouldBe("bar.0");
}
[Fact]
public void PartitionFunction_WithTokenIndexes()
{
// partition(10, 1, 2) hashes concatenation of wildcard 1 and wildcard 2
// For source *.*: wildcard 1 -> pos 0 ("foo"), wildcard 2 -> pos 1 ("bar")
// Key = "foobar" (no separator), FNV-1a("foobar") % 10 = 0
var transform = SubjectTransform.Create("*.*", "bar.{{partition(10,1,2)}}");
transform.ShouldNotBeNull();
transform.Apply("foo.bar").ShouldBe("bar.0");
}
[Fact]
public void PartitionFunction_WithSpecificToken()
{
// partition(10, 0) with wildcard source: in Go, wildcard index 0 silently
// maps to source position 0 (Go map zero-value behavior). We match this.
var transform = SubjectTransform.Create("*", "bar.{{partition(10, 0)}}");
transform.ShouldNotBeNull();
transform.Apply("foo").ShouldBe("bar.3");
}
[Fact]
public void PartitionFunction_ShorthandNoWildcardsInSource()
{
// When source has no wildcards, partition(n) hashes the full subject
var transform = SubjectTransform.Create("foo.bar", "baz.{{partition(10)}}");
transform.ShouldNotBeNull();
transform.Apply("foo.bar").ShouldBe("baz.6");
}
[Fact]
public void PartitionFunction_ShorthandWithWildcards()
{
// partition(10) with wildcards hashes all subject tokens joined
var transform = SubjectTransform.Create("*.*", "bar.{{partition(10)}}");
transform.ShouldNotBeNull();
transform.Apply("foo.bar").ShouldBe("bar.6");
}
[Fact]
public void SplitFunction_BasicDelimiter()
{
// events.a-b-c with split(1,-) -> split.a.b.c
var transform = SubjectTransform.Create("*", "{{split(1,-)}}");
transform.ShouldNotBeNull();
transform.Apply("abc-def--ghi-").ShouldBe("abc.def.ghi");
}
[Fact]
public void SplitFunction_LeadingDelimiter()
{
var transform = SubjectTransform.Create("*", "{{split(1,-)}}");
transform.ShouldNotBeNull();
transform.Apply("-abc-def--ghi-").ShouldBe("abc.def.ghi");
}
[Fact]
public void LeftFunction_BasicTrim()
{
// data.abcdef with left(1,3) -> prefix.abc
var transform = SubjectTransform.Create("*", "prefix.{{left(1,3)}}");
transform.ShouldNotBeNull();
transform.Apply("abcdef").ShouldBe("prefix.abc");
}
[Fact]
public void LeftFunction_LenExceedsToken()
{
var transform = SubjectTransform.Create("*", "{{left(1,6)}}");
transform.ShouldNotBeNull();
// When len exceeds token length, return full token
transform.Apply("1234").ShouldBe("1234");
}
[Fact]
public void LeftFunction_SingleChar()
{
var transform = SubjectTransform.Create("*", "{{left(1,1)}}");
transform.ShouldNotBeNull();
transform.Apply("1234").ShouldBe("1");
}
[Fact]
public void RightFunction_BasicTrim()
{
// data.abcdef with right(1,3) -> suffix.def
var transform = SubjectTransform.Create("*", "suffix.{{right(1,3)}}");
transform.ShouldNotBeNull();
transform.Apply("abcdef").ShouldBe("suffix.def");
}
[Fact]
public void RightFunction_LenExceedsToken()
{
var transform = SubjectTransform.Create("*", "{{right(1,6)}}");
transform.ShouldNotBeNull();
transform.Apply("1234").ShouldBe("1234");
}
[Fact]
public void RightFunction_SingleChar()
{
var transform = SubjectTransform.Create("*", "{{right(1,1)}}");
transform.ShouldNotBeNull();
transform.Apply("1234").ShouldBe("4");
}
[Fact]
public void RightFunction_ThreeChars()
{
var transform = SubjectTransform.Create("*", "{{right(1,3)}}");
transform.ShouldNotBeNull();
transform.Apply("1234").ShouldBe("234");
}
[Fact]
public void SplitFromLeft_BasicSplit()
{
// data.abcdef with splitFromLeft(1,3) -> parts.abc.def
var transform = SubjectTransform.Create("*", "{{splitFromLeft(1,3)}}");
transform.ShouldNotBeNull();
transform.Apply("12345").ShouldBe("123.45");
}
[Fact]
public void SplitFromRight_BasicSplit()
{
// data.abcdef with splitFromRight(1,3) -> parts.abc.def
var transform = SubjectTransform.Create("*", "{{SplitFromRight(1,3)}}");
transform.ShouldNotBeNull();
transform.Apply("12345").ShouldBe("12.345");
}
[Fact]
public void SliceFromLeft_BasicSlice()
{
// data.abcdef with sliceFromLeft(1,2) -> chunks.ab.cd.ef
var transform = SubjectTransform.Create("*", "{{SliceFromLeft(1,3)}}");
transform.ShouldNotBeNull();
transform.Apply("1234567890").ShouldBe("123.456.789.0");
}
[Fact]
public void SliceFromRight_BasicSlice()
{
// data.abcdef with sliceFromRight(1,2) -> chunks.ab.cd.ef
var transform = SubjectTransform.Create("*", "{{SliceFromRight(1,3)}}");
transform.ShouldNotBeNull();
transform.Apply("1234567890").ShouldBe("1.234.567.890");
}
[Fact]
public void LiteralPassthrough_NoWildcards()
{
// Literal source with no wildcards: exact match, returns dest
var transform = SubjectTransform.Create("foo", "bar");
transform.ShouldNotBeNull();
transform.Apply("foo").ShouldBe("bar");
}
[Fact]
public void LiteralPassthrough_NoMatchOnDifferentSubject()
{
var transform = SubjectTransform.Create("foo", "bar");
transform.ShouldNotBeNull();
transform.Apply("baz").ShouldBeNull();
}
[Fact]
public void InvalidSource_ReturnsNull()
{
// foo.. is not a valid subject
SubjectTransform.Create("foo..", "bar").ShouldBeNull();
}
[Fact]
public void InvalidSource_EmptyToken()
{
SubjectTransform.Create(".foo", "bar").ShouldBeNull();
}
[Fact]
public void WildcardIndexOutOfRange_ReturnsNull()
{
// Source has 1 wildcard but dest references $2
SubjectTransform.Create("foo.*", "bar.$2").ShouldBeNull();
}
[Fact]
public void DestinationWithWildcard_ReturnsNull()
{
// Wildcards not allowed in destination (pwc)
SubjectTransform.Create("foo.*", "bar.*").ShouldBeNull();
}
[Fact]
public void FwcMismatch_ReturnsNull()
{
// If source has >, dest must also have >
SubjectTransform.Create("foo.*", "bar.$1.>").ShouldBeNull();
SubjectTransform.Create("foo.>", "bar.baz").ShouldBeNull();
}
[Fact]
public void UnknownFunction_ReturnsNull()
{
SubjectTransform.Create("foo.*", "foo.{{unimplemented(1)}}").ShouldBeNull();
}
[Fact]
public void SingleWildcardCapture_ExpandedToBarPrefix()
{
var transform = SubjectTransform.Create("*", "foo.bar.$1");
transform.ShouldNotBeNull();
transform.Apply("foo").ShouldBe("foo.bar.foo");
}
[Fact]
public void ComboTransform_SplitAndSplitFromLeft()
{
// Combo: split + splitFromLeft
var transform = SubjectTransform.Create("*.*", "{{split(2,-)}}.{{splitfromleft(1,2)}}");
transform.ShouldNotBeNull();
transform.Apply("foo.-abc-def--ghij-").ShouldBe("abc.def.ghij.fo.o");
}
[Fact]
public void PartitionFunction_NoWildcardSource_FullSubjectHash()
{
// foo.baz -> qux.{{partition(10)}}
var transform = SubjectTransform.Create("foo.baz", "qux.{{partition(10)}}");
transform.ShouldNotBeNull();
transform.Apply("foo.baz").ShouldBe("qux.4");
}
[Fact]
public void PartitionFunction_NoWildcardSource_TestSubject()
{
var transform = SubjectTransform.Create("test.subject", "result.{{partition(5)}}");
transform.ShouldNotBeNull();
transform.Apply("test.subject").ShouldBe("result.0");
}
[Fact]
public void WildcardFunction_CaseInsensitive()
{
// Function names are case-insensitive (e.g. Wildcard, wildcard, WILDCARD)
var transform = SubjectTransform.Create("foo.*", "bar.{{Wildcard(1)}}");
transform.ShouldNotBeNull();
transform.Apply("foo.test").ShouldBe("bar.test");
}
[Fact]
public void SplitFromLeft_CaseInsensitive()
{
var transform = SubjectTransform.Create("*", "{{splitfromleft(1,1)}}");
transform.ShouldNotBeNull();
// Single char split from left pos 1: "ab" -> "a.b"
}
[Fact]
public void NotEnoughTokensInDest_PartitionWithMissingArgs()
{
SubjectTransform.Create("foo.*", "foo.{{partition()}}").ShouldBeNull();
}
[Fact]
public void WildcardFunctionBadArg_ReturnsNull()
{
SubjectTransform.Create("foo.*", "foo.{{wildcard(foo)}}").ShouldBeNull();
}
[Fact]
public void WildcardFunctionNoArgs_ReturnsNull()
{
SubjectTransform.Create("foo.*", "foo.{{wildcard()}}").ShouldBeNull();
}
[Fact]
public void WildcardFunctionTooManyArgs_ReturnsNull()
{
SubjectTransform.Create("foo.*", "foo.{{wildcard(1,2)}}").ShouldBeNull();
}
[Fact]
public void BadMustacheFormat_ReturnsNull()
{
SubjectTransform.Create("foo.*", "foo.{{ wildcard5) }}").ShouldBeNull();
}
[Fact]
public void NoWildcardSource_TransformFunctionNotAllowed()
{
// When source has no wildcards, only partition and random functions are allowed
SubjectTransform.Create("foo", "bla.{{wildcard(1)}}").ShouldBeNull();
}
}