diff --git a/Directory.Packages.props b/Directory.Packages.props
index d235438..f9229d7 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -15,6 +15,9 @@
+
+
+
diff --git a/differences.md b/differences.md
index 829df24..9adb727 100644
--- a/differences.md
+++ b/differences.md
@@ -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` |
| 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
diff --git a/docs/plans/2026-02-23-remaining-gaps-plan.md b/docs/plans/2026-02-23-remaining-gaps-plan.md
new file mode 100644
index 0000000..df1375f
--- /dev/null
+++ b/docs/plans/2026-02-23-remaining-gaps-plan.md
@@ -0,0 +1,1806 @@
+# Remaining Lower-Priority Gaps Implementation Plan
+
+> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task.
+
+**Goal:** Resolve all remaining lower-priority gaps from differences.md — JWT authentication, subject mapping, OCSP, and quick wins — achieving full Go parity for single-server deployments.
+
+**Architecture:** JWT auth adds a new `Auth/Jwt/` namespace with claim types, Ed25519 verification, account resolver, and permission template expansion. Subject transforms add a compiled rule engine in `Subscriptions/`. OCSP extends TLS wrapper with revocation checking and stapling. Quick wins are isolated additions to existing files.
+
+**Tech Stack:** .NET 10 / C# 14, NATS.NKeys (Ed25519), System.Text.Json, xUnit 3 + Shouldly, Serilog
+
+**Worktree:** `/Users/dohertj2/Desktop/natsdotnet/.worktrees/feature-remaining-gaps` (branch `feature/remaining-gaps`)
+
+**Baseline:** 279 tests passing
+
+---
+
+## Parallelization Guide
+
+```
+Batch 1 (all independent — new files only):
+ Task 1: JWT Core (Auth/Jwt/NatsJwt.cs, UserClaims.cs, AccountClaims.cs)
+ Task 3: AccountResolver (Auth/Jwt/AccountResolver.cs)
+ Task 5: SubjectTransform (Subscriptions/SubjectTransform.cs)
+ Task 7: OcspConfig (Tls/OcspConfig.cs)
+ Task 9: Windows Service (Program.cs, csproj)
+ Task 11: Per-Client Trace (ClientFlags.cs)
+ Task 13: TLS Cert Expiry (VarzHandler.cs)
+
+Batch 2 (depends on Batch 1):
+ Task 2: Permission Templates (depends on Task 1)
+ Task 6: Wire SubjectTransform (depends on Task 5)
+ Task 8: OCSP Stapling (depends on Task 7)
+ Task 10: Per-Subsystem Log Control (Program.cs, NatsOptions.cs)
+ Task 12: Per-Account Stats (Account.cs, NatsServer.cs)
+
+Batch 3:
+ Task 4: JwtAuthenticator (depends on Tasks 1, 2, 3)
+
+Final:
+ Task 14: Update differences.md (depends on all)
+```
+
+---
+
+### Task 1: JWT Core — Decode/Verify + Claim Structs
+
+**Files:**
+- Create: `src/NATS.Server/Auth/Jwt/NatsJwt.cs`
+- Create: `src/NATS.Server/Auth/Jwt/UserClaims.cs`
+- Create: `src/NATS.Server/Auth/Jwt/AccountClaims.cs`
+- Test: `tests/NATS.Server.Tests/JwtTests.cs`
+
+**Context:**
+NATS JWTs are standard JWT format (base64url header.payload.signature) with Ed25519 signing. The `nats-io/jwt/v2` Go library provides claim types. We port the core decode/verify logic inline — no external JWT NuGet needed. NATS.NKeys handles Ed25519 verification. All NATS JWTs start with `"eyJ"` (base64url for `{"` ).
+
+**Reference:** Go `auth.go:998-1012` for signature decoding, `nats-io/jwt/v2` for claim types.
+
+**Step 1: Write failing tests for JWT decode**
+
+Create `tests/NATS.Server.Tests/JwtTests.cs`:
+
+```csharp
+namespace NATS.Server.Tests;
+
+using NATS.Server.Auth.Jwt;
+
+public class JwtTests
+{
+ [Fact]
+ public void Decode_splits_header_payload_signature()
+ {
+ // Build a minimal test JWT (header.payload.sig)
+ var header = Base64UrlEncode("{\"alg\":\"ed25519\",\"typ\":\"JWT\"}");
+ var payload = Base64UrlEncode("{\"sub\":\"UABC\",\"name\":\"test\",\"iat\":1700000000}");
+ var sig = Base64UrlEncode("fakesig");
+ var token = $"{header}.{payload}.{sig}";
+
+ var result = NatsJwt.Decode(token);
+
+ result.ShouldNotBeNull();
+ result.Header.Algorithm.ShouldBe("ed25519");
+ result.Header.Type.ShouldBe("JWT");
+ }
+
+ [Fact]
+ public void Decode_returns_null_for_invalid_token()
+ {
+ NatsJwt.Decode("not-a-jwt").ShouldBeNull();
+ NatsJwt.Decode("").ShouldBeNull();
+ NatsJwt.Decode("a.b").ShouldBeNull(); // only 2 parts
+ }
+
+ [Fact]
+ public void IsJwt_detects_eyJ_prefix()
+ {
+ NatsJwt.IsJwt("eyJhbGciOiJlZDI1NTE5In0.payload.sig").ShouldBeTrue();
+ NatsJwt.IsJwt("not-a-jwt").ShouldBeFalse();
+ NatsJwt.IsJwt("").ShouldBeFalse();
+ }
+
+ [Fact]
+ public void DecodeUserClaims_parses_nats_fields()
+ {
+ var claims = new
+ {
+ sub = "UABC123",
+ iss = "AABC456",
+ iat = 1700000000,
+ exp = 1800000000,
+ name = "testuser",
+ nats = new
+ {
+ pub = new { allow = new[] { "foo.>" }, deny = new[] { "foo.secret" } },
+ sub = new { allow = new[] { "bar.>" } },
+ resp = new { max = 5, ttl = 60000000000L }, // 60s in nanoseconds
+ bearer_token = false,
+ issuer_account = "AACC789",
+ type = "user",
+ version = 2,
+ tags = new[] { "dept:eng", "role:admin" },
+ src = new[] { "192.168.1.0/24" }
+ }
+ };
+
+ var json = System.Text.Json.JsonSerializer.Serialize(claims);
+ var header = Base64UrlEncode("{\"alg\":\"ed25519\",\"typ\":\"JWT\"}");
+ var payload = Base64UrlEncode(json);
+ var sig = Base64UrlEncode("fakesig");
+ var token = $"{header}.{payload}.{sig}";
+
+ var result = NatsJwt.DecodeUserClaims(token);
+ result.ShouldNotBeNull();
+ result.Subject.ShouldBe("UABC123");
+ result.Issuer.ShouldBe("AABC456");
+ result.IssuerAccount.ShouldBe("AACC789");
+ result.Name.ShouldBe("testuser");
+ result.IssuedAt.ShouldBe(1700000000);
+ result.Expires.ShouldBe(1800000000);
+ result.Publish!.Allow.ShouldBe(["foo.>"]);
+ result.Publish!.Deny.ShouldBe(["foo.secret"]);
+ result.Subscribe!.Allow.ShouldBe(["bar.>"]);
+ result.Response!.MaxMsgs.ShouldBe(5);
+ result.Response!.Ttl.TotalSeconds.ShouldBe(60);
+ result.BearerToken.ShouldBeFalse();
+ result.Tags.ShouldBe(["dept:eng", "role:admin"]);
+ result.AllowedSources.ShouldBe(["192.168.1.0/24"]);
+ }
+
+ [Fact]
+ public void DecodeAccountClaims_parses_account_fields()
+ {
+ var claims = new
+ {
+ sub = "AABC456",
+ iss = "OABC789",
+ iat = 1700000000,
+ name = "testaccount",
+ nats = new
+ {
+ type = "account",
+ version = 2,
+ signing_keys = new Dictionary
+ {
+ ["SABC123"] = new { }
+ },
+ limits = new
+ {
+ conn = 100,
+ subs = 1000
+ },
+ revocations = new Dictionary
+ {
+ ["UREVOKED1"] = 1700000000
+ }
+ }
+ };
+
+ var json = System.Text.Json.JsonSerializer.Serialize(claims);
+ var header = Base64UrlEncode("{\"alg\":\"ed25519\",\"typ\":\"JWT\"}");
+ var payload = Base64UrlEncode(json);
+ var sig = Base64UrlEncode("fakesig");
+ var token = $"{header}.{payload}.{sig}";
+
+ var result = NatsJwt.DecodeAccountClaims(token);
+ result.ShouldNotBeNull();
+ result.Subject.ShouldBe("AABC456");
+ result.Issuer.ShouldBe("OABC789");
+ result.Name.ShouldBe("testaccount");
+ result.Limits.MaxConnections.ShouldBe(100);
+ result.Limits.MaxSubscriptions.ShouldBe(1000);
+ result.Revocations.ShouldContainKey("UREVOKED1");
+ }
+
+ private static string Base64UrlEncode(string input)
+ {
+ var bytes = System.Text.Encoding.UTF8.GetBytes(input);
+ return Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_');
+ }
+}
+```
+
+**Step 2: Run tests, verify they fail** (NatsJwt class doesn't exist)
+
+Run: `dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~JwtTests" -v normal`
+
+**Step 3: Implement NatsJwt, UserClaims, AccountClaims**
+
+Create `src/NATS.Server/Auth/Jwt/NatsJwt.cs`:
+
+```csharp
+using System.Text;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace NATS.Server.Auth.Jwt;
+
+public static class NatsJwt
+{
+ /// Detect NATS JWT by "eyJ" prefix (base64url for '{"').
+ public static bool IsJwt(string token) =>
+ token.Length > 3 && token.StartsWith("eyJ", StringComparison.Ordinal);
+
+ /// Decode JWT into header + raw payload JSON + signature bytes.
+ public static JwtToken? Decode(string token)
+ {
+ if (string.IsNullOrEmpty(token)) return null;
+ var parts = token.Split('.');
+ if (parts.Length != 3) return null;
+
+ try
+ {
+ var headerJson = Base64UrlDecode(parts[0]);
+ var header = JsonSerializer.Deserialize(headerJson);
+ if (header is null) return null;
+
+ var payloadBytes = Base64UrlDecode(parts[1]);
+ var signatureBytes = Base64UrlDecodeBytes(parts[2]);
+
+ return new JwtToken
+ {
+ Header = header,
+ PayloadJson = payloadBytes,
+ Signature = signatureBytes,
+ SigningInput = Encoding.ASCII.GetBytes($"{parts[0]}.{parts[1]}"),
+ };
+ }
+ catch
+ {
+ return null;
+ }
+ }
+
+ public static UserClaims? DecodeUserClaims(string token)
+ {
+ var jwt = Decode(token);
+ if (jwt is null) return null;
+ try
+ {
+ return JsonSerializer.Deserialize(jwt.PayloadJson, JsonOptions);
+ }
+ catch { return null; }
+ }
+
+ public static AccountClaims? DecodeAccountClaims(string token)
+ {
+ var jwt = Decode(token);
+ if (jwt is null) return null;
+ try
+ {
+ return JsonSerializer.Deserialize(jwt.PayloadJson, JsonOptions);
+ }
+ catch { return null; }
+ }
+
+ ///
+ /// Verify Ed25519 signature using the subject's public nkey.
+ /// Returns true if signature is valid.
+ ///
+ public static bool Verify(string token, string publicNkey)
+ {
+ var jwt = Decode(token);
+ if (jwt is null) return false;
+ try
+ {
+ var kp = NATS.NKeys.NKeys.FromPublicKey(publicNkey.AsSpan());
+ return kp.Verify(jwt.SigningInput, jwt.Signature);
+ }
+ catch { return false; }
+ }
+
+ ///
+ /// Verify Ed25519 signature of nonce bytes using public nkey.
+ /// Used for CONNECT sig verification. Accepts base64 or base64url sig.
+ ///
+ public static bool VerifyNonce(byte[] nonce, string signature, string publicNkey)
+ {
+ try
+ {
+ // Try base64url first, then standard base64 (Go compat: auth.go:1002-1009)
+ byte[] sigBytes;
+ try { sigBytes = Base64UrlDecodeBytes(signature); }
+ catch { sigBytes = Convert.FromBase64String(signature); }
+
+ var kp = NATS.NKeys.NKeys.FromPublicKey(publicNkey.AsSpan());
+ return kp.Verify(nonce, sigBytes);
+ }
+ catch { return false; }
+ }
+
+ private static readonly JsonSerializerOptions JsonOptions = new()
+ {
+ PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
+ };
+
+ private static string Base64UrlDecode(string input)
+ {
+ var padded = input.Replace('-', '+').Replace('_', '/');
+ switch (padded.Length % 4)
+ {
+ case 2: padded += "=="; break;
+ case 3: padded += "="; break;
+ }
+ return Encoding.UTF8.GetString(Convert.FromBase64String(padded));
+ }
+
+ private static byte[] Base64UrlDecodeBytes(string input)
+ {
+ var padded = input.Replace('-', '+').Replace('_', '/');
+ switch (padded.Length % 4)
+ {
+ case 2: padded += "=="; break;
+ case 3: padded += "="; break;
+ }
+ return Convert.FromBase64String(padded);
+ }
+}
+
+public sealed class JwtToken
+{
+ public required JwtHeader Header { get; init; }
+ public required string PayloadJson { get; init; }
+ public required byte[] Signature { get; init; }
+ public required byte[] SigningInput { get; init; }
+}
+
+public sealed class JwtHeader
+{
+ [JsonPropertyName("alg")]
+ public string Algorithm { get; set; } = "";
+
+ [JsonPropertyName("typ")]
+ public string Type { get; set; } = "";
+}
+```
+
+Create `src/NATS.Server/Auth/Jwt/UserClaims.cs`:
+
+```csharp
+using System.Text.Json.Serialization;
+
+namespace NATS.Server.Auth.Jwt;
+
+///
+/// NATS user JWT claims. Reference: nats-io/jwt/v2 user_claims.go.
+///
+public sealed class UserClaims
+{
+ [JsonPropertyName("sub")]
+ public string Subject { get; set; } = "";
+
+ [JsonPropertyName("iss")]
+ public string Issuer { get; set; } = "";
+
+ [JsonPropertyName("iat")]
+ public long IssuedAt { get; set; }
+
+ [JsonPropertyName("exp")]
+ public long Expires { get; set; }
+
+ [JsonPropertyName("name")]
+ public string Name { get; set; } = "";
+
+ [JsonPropertyName("nats")]
+ public UserClaimsNats? Nats { get; set; }
+
+ // Convenience accessors that delegate to Nats
+ [JsonIgnore] public string IssuerAccount => Nats?.IssuerAccount ?? "";
+ [JsonIgnore] public bool BearerToken => Nats?.BearerToken ?? false;
+ [JsonIgnore] public SubjectPermission? Publish => Nats?.Pub;
+ [JsonIgnore] public SubjectPermission? Subscribe => Nats?.Sub;
+ [JsonIgnore] public ResponsePermission? Response => Nats?.Resp;
+ [JsonIgnore] public string[] Tags => Nats?.Tags ?? [];
+ [JsonIgnore] public string[] AllowedSources => Nats?.Src ?? [];
+ [JsonIgnore] public string[] AllowedConnectionTypes => Nats?.AllowedConnectionTypes ?? [];
+ [JsonIgnore] public TimeRange[]? Times => Nats?.Times;
+
+ /// Check if this JWT has expired.
+ public bool IsExpired()
+ {
+ if (Expires == 0) return false;
+ return DateTimeOffset.UtcNow.ToUnixTimeSeconds() > Expires;
+ }
+
+ /// Return expiry as DateTimeOffset, or null if no expiry.
+ public DateTimeOffset? GetExpiry()
+ {
+ if (Expires == 0) return null;
+ return DateTimeOffset.FromUnixTimeSeconds(Expires);
+ }
+}
+
+public sealed class UserClaimsNats
+{
+ [JsonPropertyName("pub")]
+ public SubjectPermission? Pub { get; set; }
+
+ [JsonPropertyName("sub")]
+ public SubjectPermission? Sub { get; set; }
+
+ [JsonPropertyName("resp")]
+ public ResponsePermission? Resp { get; set; }
+
+ [JsonPropertyName("bearer_token")]
+ public bool BearerToken { get; set; }
+
+ [JsonPropertyName("issuer_account")]
+ public string IssuerAccount { get; set; } = "";
+
+ [JsonPropertyName("type")]
+ public string Type { get; set; } = "";
+
+ [JsonPropertyName("version")]
+ public int Version { get; set; }
+
+ [JsonPropertyName("tags")]
+ public string[] Tags { get; set; } = [];
+
+ [JsonPropertyName("src")]
+ public string[] Src { get; set; } = [];
+
+ [JsonPropertyName("allowed_connection_types")]
+ public string[] AllowedConnectionTypes { get; set; } = [];
+
+ [JsonPropertyName("times")]
+ public TimeRange[]? Times { get; set; }
+}
+
+public sealed class SubjectPermission
+{
+ [JsonPropertyName("allow")]
+ public string[] Allow { get; set; } = [];
+
+ [JsonPropertyName("deny")]
+ public string[] Deny { get; set; } = [];
+}
+
+public sealed class ResponsePermission
+{
+ [JsonPropertyName("max")]
+ public int MaxMsgs { get; set; } = 1;
+
+ [JsonPropertyName("ttl")]
+ public long TtlNanos { get; set; }
+
+ [JsonIgnore]
+ public TimeSpan Ttl => TimeSpan.FromTicks(TtlNanos / 100); // nanos to ticks
+}
+
+public sealed class TimeRange
+{
+ [JsonPropertyName("start")]
+ public string Start { get; set; } = "";
+
+ [JsonPropertyName("end")]
+ public string End { get; set; } = "";
+}
+```
+
+Create `src/NATS.Server/Auth/Jwt/AccountClaims.cs`:
+
+```csharp
+using System.Text.Json.Serialization;
+
+namespace NATS.Server.Auth.Jwt;
+
+///
+/// NATS account JWT claims. Reference: nats-io/jwt/v2 account_claims.go.
+///
+public sealed class AccountClaims
+{
+ [JsonPropertyName("sub")]
+ public string Subject { get; set; } = "";
+
+ [JsonPropertyName("iss")]
+ public string Issuer { get; set; } = "";
+
+ [JsonPropertyName("iat")]
+ public long IssuedAt { get; set; }
+
+ [JsonPropertyName("exp")]
+ public long Expires { get; set; }
+
+ [JsonPropertyName("name")]
+ public string Name { get; set; } = "";
+
+ [JsonPropertyName("nats")]
+ public AccountClaimsNats? Nats { get; set; }
+
+ [JsonIgnore] public AccountLimits Limits => Nats?.Limits ?? new();
+ [JsonIgnore] public Dictionary Revocations => Nats?.Revocations ?? new();
+ [JsonIgnore] public Dictionary? SigningKeys => Nats?.SigningKeys;
+}
+
+public sealed class AccountClaimsNats
+{
+ [JsonPropertyName("type")]
+ public string Type { get; set; } = "";
+
+ [JsonPropertyName("version")]
+ public int Version { get; set; }
+
+ [JsonPropertyName("limits")]
+ public AccountLimits Limits { get; set; } = new();
+
+ [JsonPropertyName("signing_keys")]
+ public Dictionary? SigningKeys { get; set; }
+
+ [JsonPropertyName("revocations")]
+ public Dictionary Revocations { get; set; } = new();
+}
+
+public sealed class AccountLimits
+{
+ [JsonPropertyName("conn")]
+ public int MaxConnections { get; set; }
+
+ [JsonPropertyName("subs")]
+ public int MaxSubscriptions { get; set; }
+
+ [JsonPropertyName("payload")]
+ public long MaxPayload { get; set; }
+
+ [JsonPropertyName("data")]
+ public long MaxData { get; set; }
+}
+```
+
+**Step 4: Run tests, verify they pass**
+
+Run: `dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~JwtTests" -v normal`
+
+**Step 5: Commit**
+
+```bash
+git add src/NATS.Server/Auth/Jwt/ tests/NATS.Server.Tests/JwtTests.cs
+git commit -m "feat: add NATS JWT decode/verify and claim structs"
+```
+
+---
+
+### Task 2: Permission Templates
+
+**Files:**
+- Create: `src/NATS.Server/Auth/Jwt/PermissionTemplates.cs`
+- Test: `tests/NATS.Server.Tests/PermissionTemplateTests.cs`
+- **Depends on:** Task 1 (UserClaims, AccountClaims types)
+
+**Context:**
+Go's permission templates use mustache-style `{{func()}}` syntax in subject patterns. When a user connects with a JWT, template strings in their permissions are expanded using claim values. Multi-value tags produce a cartesian product of subjects.
+
+**Reference:** Go `auth.go:424-520` for `processUserPermissionsTemplate()`.
+
+**Step 1: Write failing tests**
+
+Create `tests/NATS.Server.Tests/PermissionTemplateTests.cs`:
+
+```csharp
+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.>"]);
+ }
+}
+```
+
+**Step 2: Run tests, verify they fail**
+
+**Step 3: Implement PermissionTemplates.cs**
+
+```csharp
+using System.Text.RegularExpressions;
+
+namespace NATS.Server.Auth.Jwt;
+
+///
+/// Expands mustache-style permission templates in JWT subject patterns.
+/// Reference: Go auth.go:424-520 processUserPermissionsTemplate().
+///
+public static partial class PermissionTemplates
+{
+ [GeneratedRegex(@"\{\{([^}]+)\}\}")]
+ private static partial Regex TemplateRegex();
+
+ public static List 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];
+
+ // Build list of (placeholder, values[]) for each template in the 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 []; // No values for a required tag → deny all
+ replacements.Add((match.Value, values));
+ }
+
+ // Cartesian product of all multi-value replacements
+ var results = new List { pattern };
+ foreach (var (placeholder, values) in replacements)
+ {
+ var next = new List();
+ foreach (var current in results)
+ {
+ foreach (var value in values)
+ {
+ next.Add(current.Replace(placeholder, value));
+ }
+ }
+ results = next;
+ }
+
+ return results;
+ }
+
+ public static List ExpandAll(
+ string[] patterns,
+ string name, string subject,
+ string accountName, string accountSubject,
+ string[] userTags, string[] accountTags)
+ {
+ var result = new List();
+ 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 switch
+ {
+ "name()" => [name],
+ "subject()" => [subject],
+ "account-name()" => [accountName],
+ "account-subject()" => [accountSubject],
+ _ when expr.StartsWith("tag(") && expr.EndsWith(")") =>
+ ResolveTags(expr[4..^1], userTags),
+ _ when expr.StartsWith("account-tag(") && expr.EndsWith(")") =>
+ ResolveTags(expr[12..^1], accountTags),
+ _ => []
+ };
+ }
+
+ private static string[] ResolveTags(string tagName, string[] tags)
+ {
+ var prefix = tagName + ":";
+ var values = tags
+ .Where(t => t.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
+ .Select(t => t[prefix.Length..])
+ .ToArray();
+ return values;
+ }
+}
+```
+
+**Step 4: Run tests, verify they pass**
+
+**Step 5: Commit**
+
+```bash
+git add src/NATS.Server/Auth/Jwt/PermissionTemplates.cs tests/NATS.Server.Tests/PermissionTemplateTests.cs
+git commit -m "feat: add JWT permission template expansion with cartesian product"
+```
+
+---
+
+### Task 3: Account Resolver
+
+**Files:**
+- Create: `src/NATS.Server/Auth/Jwt/AccountResolver.cs`
+- Test: `tests/NATS.Server.Tests/AccountResolverTests.cs`
+- No dependencies on other tasks
+
+**Context:**
+The account resolver retrieves account JWTs by nkey. Go has multiple implementations (memory, URL, directory). We implement `MemAccountResolver` for testing/simple deployments.
+
+**Reference:** Go `accounts.go:4035+` for `AccountResolver` interface.
+
+**Step 1: Write failing tests**
+
+```csharp
+namespace NATS.Server.Tests;
+
+using NATS.Server.Auth.Jwt;
+
+public class AccountResolverTests
+{
+ [Fact]
+ public async Task MemResolver_store_and_fetch()
+ {
+ var resolver = new MemAccountResolver();
+ await resolver.StoreAsync("AABC123", "eyJfaketoken");
+ var result = await resolver.FetchAsync("AABC123");
+ result.ShouldBe("eyJfaketoken");
+ }
+
+ [Fact]
+ public async Task MemResolver_fetch_unknown_returns_null()
+ {
+ var resolver = new MemAccountResolver();
+ var result = await resolver.FetchAsync("UNKNOWN");
+ result.ShouldBeNull();
+ }
+
+ [Fact]
+ public async Task MemResolver_overwrite_updates()
+ {
+ var resolver = new MemAccountResolver();
+ await resolver.StoreAsync("AABC123", "old-jwt");
+ await resolver.StoreAsync("AABC123", "new-jwt");
+ var result = await resolver.FetchAsync("AABC123");
+ result.ShouldBe("new-jwt");
+ }
+
+ [Fact]
+ public void MemResolver_is_not_readonly()
+ {
+ var resolver = new MemAccountResolver();
+ resolver.IsReadOnly.ShouldBeFalse();
+ }
+}
+```
+
+**Step 2: Run tests, verify they fail**
+
+**Step 3: Implement AccountResolver.cs**
+
+```csharp
+using System.Collections.Concurrent;
+
+namespace NATS.Server.Auth.Jwt;
+
+///
+/// Interface for resolving account JWTs by nkey.
+/// Reference: Go accounts.go:4035+ AccountResolver interface.
+///
+public interface IAccountResolver
+{
+ Task FetchAsync(string accountNkey);
+ Task StoreAsync(string accountNkey, string jwt);
+ bool IsReadOnly { get; }
+}
+
+///
+/// In-memory account resolver for testing and simple deployments.
+///
+public sealed class MemAccountResolver : IAccountResolver
+{
+ private readonly ConcurrentDictionary _accounts = new(StringComparer.Ordinal);
+
+ public bool IsReadOnly => false;
+
+ public Task FetchAsync(string accountNkey)
+ {
+ _accounts.TryGetValue(accountNkey, out var jwt);
+ return Task.FromResult(jwt);
+ }
+
+ public Task StoreAsync(string accountNkey, string jwt)
+ {
+ _accounts[accountNkey] = jwt;
+ return Task.CompletedTask;
+ }
+}
+```
+
+**Step 4: Run tests, verify they pass**
+
+**Step 5: Commit**
+
+```bash
+git add src/NATS.Server/Auth/Jwt/AccountResolver.cs tests/NATS.Server.Tests/AccountResolverTests.cs
+git commit -m "feat: add IAccountResolver interface and MemAccountResolver"
+```
+
+---
+
+### Task 4: JwtAuthenticator — Wire JWT into Auth Flow
+
+**Files:**
+- Create: `src/NATS.Server/Auth/JwtAuthenticator.cs`
+- Modify: `src/NATS.Server/Auth/Account.cs` — Add `Nkey`, `Issuer`, `SigningKeys`, `RevokedUsers`
+- Modify: `src/NATS.Server/NatsOptions.cs` — Add `TrustedKeys`, `AccountResolver`
+- Modify: `src/NATS.Server/Auth/AuthService.cs` — Wire JwtAuthenticator into chain
+- Test: `tests/NATS.Server.Tests/JwtAuthenticatorTests.cs`
+- **Depends on:** Tasks 1, 2, 3
+
+**Context:**
+JwtAuthenticator implements IAuthenticator. It decodes the user JWT from `ClientOpts.JWT`, resolves the issuing account via AccountResolver, verifies the Ed25519 signature against the connection nonce, expands permission templates, validates source IP and time restrictions, and checks user revocation.
+
+**Reference:** Go `auth.go:588+` for `processClientOrLeafAuthentication`.
+
+**Step 1: Write failing tests**
+
+```csharp
+namespace NATS.Server.Tests;
+
+using NATS.Server.Auth;
+using NATS.Server.Auth.Jwt;
+using NATS.Server.Protocol;
+
+public class JwtAuthenticatorTests
+{
+ [Fact]
+ public async Task Authenticate_with_valid_jwt_succeeds()
+ {
+ // Create operator keypair
+ var operatorKp = NATS.NKeys.NKeys.FromSeed(CreateOperatorSeed());
+ var operatorPub = operatorKp.GetPublicKey();
+
+ // Create account keypair and JWT
+ var accountKp = NATS.NKeys.NKeys.FromSeed(CreateAccountSeed());
+ var accountPub = accountKp.GetPublicKey();
+
+ // Create user keypair
+ var userKp = NATS.NKeys.NKeys.FromSeed(CreateUserSeed());
+ var userPub = userKp.GetPublicKey();
+
+ // Build minimal user JWT
+ var userClaims = BuildUserClaimsJson(userPub, accountPub);
+ var userJwt = SignJwt(userClaims, accountKp);
+
+ // Build minimal account JWT
+ var accountClaims = BuildAccountClaimsJson(accountPub, operatorPub);
+ var accountJwt = SignJwt(accountClaims, operatorKp);
+
+ // Setup resolver with account JWT
+ var resolver = new MemAccountResolver();
+ await resolver.StoreAsync(accountPub, accountJwt);
+
+ // Create authenticator with operator trusted key
+ var authenticator = new JwtAuthenticator([operatorPub], resolver);
+
+ // Generate nonce
+ var nonce = new byte[] { 1, 2, 3, 4, 5, 6, 7, 8 };
+
+ // Sign nonce with user key
+ var sig = Convert.ToBase64String(userKp.Sign(nonce));
+
+ var context = new ClientAuthContext
+ {
+ Opts = new ClientOptions { JWT = userJwt, Sig = sig, Nkey = userPub },
+ Nonce = nonce,
+ };
+
+ var result = authenticator.Authenticate(context);
+ result.ShouldNotBeNull();
+ result.Identity.ShouldBe(userPub);
+ result.AccountName.ShouldBe(accountPub);
+ }
+
+ [Fact]
+ public void Authenticate_with_no_jwt_returns_null()
+ {
+ var authenticator = new JwtAuthenticator(["OABC"], new MemAccountResolver());
+ var context = new ClientAuthContext
+ {
+ Opts = new ClientOptions(),
+ Nonce = [1, 2, 3],
+ };
+ authenticator.Authenticate(context).ShouldBeNull();
+ }
+
+ [Fact]
+ public async Task Authenticate_revoked_user_fails()
+ {
+ // Setup same as valid test, but revoke user before auth
+ var operatorKp = NATS.NKeys.NKeys.FromSeed(CreateOperatorSeed());
+ var operatorPub = operatorKp.GetPublicKey();
+
+ var accountKp = NATS.NKeys.NKeys.FromSeed(CreateAccountSeed());
+ var accountPub = accountKp.GetPublicKey();
+
+ var userKp = NATS.NKeys.NKeys.FromSeed(CreateUserSeed());
+ var userPub = userKp.GetPublicKey();
+
+ // Build account JWT WITH user revocation
+ var accountClaims = BuildAccountClaimsJsonWithRevocation(accountPub, operatorPub, userPub);
+ var accountJwt = SignJwt(accountClaims, operatorKp);
+
+ var userClaims = BuildUserClaimsJson(userPub, accountPub);
+ var userJwt = SignJwt(userClaims, accountKp);
+
+ var resolver = new MemAccountResolver();
+ await resolver.StoreAsync(accountPub, accountJwt);
+
+ var authenticator = new JwtAuthenticator([operatorPub], resolver);
+ var nonce = new byte[] { 1, 2, 3, 4, 5, 6, 7, 8 };
+ var sig = Convert.ToBase64String(userKp.Sign(nonce));
+
+ var context = new ClientAuthContext
+ {
+ Opts = new ClientOptions { JWT = userJwt, Sig = sig, Nkey = userPub },
+ Nonce = nonce,
+ };
+
+ var result = authenticator.Authenticate(context);
+ result.ShouldBeNull(); // Revoked user should fail
+ }
+
+ // Helper methods to create NKey seeds and build JWTs
+ // These build raw JWT strings by base64url-encoding JSON + signing with Ed25519
+ // ... (implementation in test file)
+}
+```
+
+Note: The test file will need helper methods for creating NKey seeds, building claim JSON, and signing JWTs. These helpers use `NATS.NKeys.NKeys.CreateOperatorKeyPair()`, `.CreateAccountKeyPair()`, `.CreateUserKeyPair()` to generate seeds, then build JSON claims and sign with Ed25519. The exact helpers should match the patterns in the existing `NKeyAuthenticatorTests.cs`.
+
+**Step 2: Run tests, verify they fail**
+
+**Step 3: Implement JwtAuthenticator and Account changes**
+
+Add to `Account.cs`:
+```csharp
+public string? Nkey { get; set; }
+public string? Issuer { get; set; }
+public Dictionary? SigningKeys { get; set; }
+private readonly ConcurrentDictionary _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;
+ return false;
+}
+```
+
+Add to `NatsOptions.cs`:
+```csharp
+// JWT / Operator mode
+public string[]? TrustedKeys { get; set; }
+public IAccountResolver? AccountResolver { get; set; }
+```
+
+Create `src/NATS.Server/Auth/JwtAuthenticator.cs` — Implements `IAuthenticator.Authenticate()`:
+1. Check `context.Opts.JWT` is present and is a JWT (`NatsJwt.IsJwt()`)
+2. Decode user claims via `NatsJwt.DecodeUserClaims()`
+3. Resolve issuer account: `IssuerAccount` or `Issuer` → `AccountResolver.FetchAsync()` → `NatsJwt.DecodeAccountClaims()`
+4. Verify account JWT issuer is in `TrustedKeys` (or account signing keys)
+5. Verify nonce signature: `NatsJwt.VerifyNonce(nonce, sig, userClaims.Subject)` (skip if BearerToken)
+6. Check user revocation: `account.IsUserRevoked(userClaims.Subject, userClaims.IssuedAt)`
+7. Validate source IP if `AllowedSources` is non-empty
+8. Expand permission templates via `PermissionTemplates.ExpandAll()`
+9. Build and return `AuthResult` with identity, account, permissions, expiry
+
+Wire into `AuthService.cs`: When `NatsOptions.TrustedKeys` is non-null and non-empty, add `JwtAuthenticator` to the authenticator chain.
+
+**Step 4: Run tests, verify they pass**
+
+**Step 5: Commit**
+
+```bash
+git add src/NATS.Server/Auth/JwtAuthenticator.cs src/NATS.Server/Auth/Account.cs \
+ src/NATS.Server/NatsOptions.cs src/NATS.Server/Auth/AuthService.cs \
+ tests/NATS.Server.Tests/JwtAuthenticatorTests.cs
+git commit -m "feat: add JwtAuthenticator with account resolution, revocation, and template expansion"
+```
+
+---
+
+### Task 5: Subject Transform — Core Engine
+
+**Files:**
+- Create: `src/NATS.Server/Subscriptions/SubjectTransform.cs`
+- Test: `tests/NATS.Server.Tests/SubjectTransformTests.cs`
+- No dependencies
+
+**Context:**
+Go's `subject_transform.go` implements a compiled transform engine. 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.
+
+**Reference:** Go `subject_transform.go` for transform functions and matching.
+
+**Step 1: Write failing tests**
+
+```csharp
+namespace NATS.Server.Tests;
+
+using NATS.Server.Subscriptions;
+
+public class SubjectTransformTests
+{
+ [Fact]
+ public void Wildcard_replacement()
+ {
+ var t = SubjectTransform.Create("foo.*", "bar.{{wildcard(1)}}");
+ t.ShouldNotBeNull();
+ t.Apply("foo.baz").ShouldBe("bar.baz");
+ }
+
+ [Fact]
+ public void Dollar_syntax_wildcard()
+ {
+ var t = SubjectTransform.Create("foo.*.*", "bar.$2.$1");
+ t.ShouldNotBeNull();
+ t.Apply("foo.a.b").ShouldBe("bar.b.a");
+ }
+
+ [Fact]
+ public void Full_wildcard_capture()
+ {
+ var t = SubjectTransform.Create("foo.>", "bar.{{wildcard(1)}}");
+ t.ShouldNotBeNull();
+ t.Apply("foo.a.b.c").ShouldBe("bar.a.b.c");
+ }
+
+ [Fact]
+ public void No_match_returns_null()
+ {
+ var t = SubjectTransform.Create("foo.*", "bar.{{wildcard(1)}}");
+ t!.Apply("baz.x").ShouldBeNull();
+ }
+
+ [Fact]
+ public void Partition_function()
+ {
+ var t = SubjectTransform.Create("orders.*", "bucket.{{partition(10,1)}}");
+ t.ShouldNotBeNull();
+ var result = t.Apply("orders.customer123");
+ result.ShouldNotBeNull();
+ // Partition should be 0-9
+ var bucket = int.Parse(result.Split('.')[1]);
+ bucket.ShouldBeGreaterThanOrEqualTo(0);
+ bucket.ShouldBeLessThan(10);
+ }
+
+ [Fact]
+ public void Partition_deterministic()
+ {
+ var t = SubjectTransform.Create("orders.*", "bucket.{{partition(10,1)}}");
+ t!.Apply("orders.abc").ShouldBe(t.Apply("orders.abc")); // same input = same output
+ }
+
+ [Fact]
+ public void Split_function()
+ {
+ var t = SubjectTransform.Create("events.*", "split.{{split(1,-)}}");
+ t.ShouldNotBeNull();
+ t.Apply("events.a-b-c").ShouldBe("split.a.b.c");
+ }
+
+ [Fact]
+ public void Left_function()
+ {
+ var t = SubjectTransform.Create("data.*", "prefix.{{left(1,3)}}");
+ t.ShouldNotBeNull();
+ t.Apply("data.abcdef").ShouldBe("prefix.abc");
+ }
+
+ [Fact]
+ public void Right_function()
+ {
+ var t = SubjectTransform.Create("data.*", "suffix.{{right(1,3)}}");
+ t.ShouldNotBeNull();
+ t.Apply("data.abcdef").ShouldBe("suffix.def");
+ }
+
+ [Fact]
+ public void SplitFromLeft_function()
+ {
+ var t = SubjectTransform.Create("data.*", "parts.{{splitFromLeft(1,3)}}");
+ t.ShouldNotBeNull();
+ t.Apply("data.abcdef").ShouldBe("parts.abc.def");
+ }
+
+ [Fact]
+ public void SplitFromRight_function()
+ {
+ var t = SubjectTransform.Create("data.*", "parts.{{splitFromRight(1,3)}}");
+ t.ShouldNotBeNull();
+ t.Apply("data.abcdef").ShouldBe("parts.abc.def");
+ }
+
+ [Fact]
+ public void SliceFromLeft_function()
+ {
+ var t = SubjectTransform.Create("data.*", "chunks.{{sliceFromLeft(1,2)}}");
+ t.ShouldNotBeNull();
+ t.Apply("data.abcdef").ShouldBe("chunks.ab.cd.ef");
+ }
+
+ [Fact]
+ public void SliceFromRight_function()
+ {
+ var t = SubjectTransform.Create("data.*", "chunks.{{sliceFromRight(1,2)}}");
+ t.ShouldNotBeNull();
+ t.Apply("data.abcdef").ShouldBe("chunks.ab.cd.ef");
+ }
+
+ [Fact]
+ public void Literal_passthrough_no_wildcards()
+ {
+ var t = SubjectTransform.Create("foo.bar", "baz.qux");
+ t.ShouldNotBeNull();
+ t.Apply("foo.bar").ShouldBe("baz.qux");
+ }
+
+ [Fact]
+ public void Invalid_source_returns_null()
+ {
+ SubjectTransform.Create("", "bar").ShouldBeNull();
+ }
+}
+```
+
+**Step 2: Run tests, verify they fail**
+
+**Step 3: Implement SubjectTransform.cs**
+
+The implementation should:
+1. Parse source pattern to identify wildcard positions (`*` and `>`)
+2. Parse destination template into a list of token operations (literal text, wildcard ref, function call)
+3. `Apply(string subject)`: match subject against source, capture wildcards, evaluate destination tokens
+4. Functions: `wildcard(n)`, `partition(num,tokens...)`, `split(tok,delim)`, `splitFromLeft(tok,pos)`, `splitFromRight(tok,pos)`, `sliceFromLeft(tok,size)`, `sliceFromRight(tok,size)`, `left(tok,len)`, `right(tok,len)`
+5. FNV-1a hash for `partition` function (matching Go)
+6. `$N` syntax as alias for `{{wildcard(N)}}`
+
+Key implementation detail: Parse at `Create()` time into compiled operation list. `Apply()` just executes the list.
+
+**Step 4: Run tests, verify they pass**
+
+**Step 5: Commit**
+
+```bash
+git add src/NATS.Server/Subscriptions/SubjectTransform.cs tests/NATS.Server.Tests/SubjectTransformTests.cs
+git commit -m "feat: add SubjectTransform engine with all 9 transform functions"
+```
+
+---
+
+### Task 6: Wire Subject Transforms into Delivery
+
+**Files:**
+- Modify: `src/NATS.Server/NatsOptions.cs` — Add `SubjectMappings`
+- Modify: `src/NATS.Server/NatsServer.cs` — Compile transforms at startup, apply in message path
+- Test: `tests/NATS.Server.Tests/SubjectTransformIntegrationTests.cs`
+- **Depends on:** Task 5
+
+**Step 1: Write failing test**
+
+```csharp
+namespace NATS.Server.Tests;
+
+using NATS.Server.Subscriptions;
+
+public class SubjectTransformIntegrationTests
+{
+ [Fact]
+ public void Server_applies_subject_mapping_on_publish()
+ {
+ // Test that NatsServer compiles and applies SubjectMappings
+ // by checking that a message published to "src.x" is delivered
+ // to subscribers of "dest.x" when mapping "src.*" -> "dest.{{wildcard(1)}}"
+ // This will be an integration test using NatsServer directly
+ }
+}
+```
+
+**Step 2: Implement**
+
+Add to `NatsOptions.cs`:
+```csharp
+public Dictionary? SubjectMappings { get; set; }
+```
+
+In `NatsServer.cs`:
+- Add `_subjectTransforms: SubjectTransform[]` field
+- In constructor: compile `_options.SubjectMappings` into `SubjectTransform[]`
+- In message processing (before `Match()`): apply each transform to find the mapped subject
+
+**Step 3: Run tests, verify they pass**
+
+**Step 4: Commit**
+
+```bash
+git add src/NATS.Server/NatsOptions.cs src/NATS.Server/NatsServer.cs \
+ tests/NATS.Server.Tests/SubjectTransformIntegrationTests.cs
+git commit -m "feat: wire subject transforms into NatsServer message delivery path"
+```
+
+---
+
+### Task 7: OCSP Config and Peer Verification
+
+**Files:**
+- Create: `src/NATS.Server/Tls/OcspConfig.cs`
+- Modify: `src/NATS.Server/NatsOptions.cs` — Add `OcspConfig`, `OcspPeerVerify`
+- Modify: `src/NATS.Server/Tls/TlsConnectionWrapper.cs` — Wire peer verification
+- Test: `tests/NATS.Server.Tests/OcspConfigTests.cs`
+- No dependencies
+
+**Step 1: Write failing tests**
+
+```csharp
+namespace NATS.Server.Tests;
+
+using NATS.Server.Tls;
+
+public class OcspConfigTests
+{
+ [Fact]
+ public void Default_mode_is_auto()
+ {
+ var config = new OcspConfig();
+ config.Mode.ShouldBe(OcspMode.Auto);
+ }
+
+ [Fact]
+ public void Modes_have_correct_values()
+ {
+ ((int)OcspMode.Auto).ShouldBe(0);
+ ((int)OcspMode.Always).ShouldBe(1);
+ ((int)OcspMode.Must).ShouldBe(2);
+ ((int)OcspMode.Never).ShouldBe(3);
+ }
+
+ [Fact]
+ public void Override_urls_default_empty()
+ {
+ var config = new OcspConfig();
+ config.OverrideUrls.ShouldBeEmpty();
+ }
+}
+```
+
+**Step 2: Run tests, verify they fail**
+
+**Step 3: Implement OcspConfig.cs**
+
+```csharp
+namespace NATS.Server.Tls;
+
+///
+/// OCSP stapling mode. Reference: Go ocsp.go OCSPMode constants.
+///
+public enum OcspMode
+{
+ Auto = 0, // Staple if cert has status_request extension
+ Always = 1, // Always staple, warn if cannot get status
+ Must = 2, // Must staple, fail startup if cannot get status
+ Never = 3, // Never attempt stapling
+}
+
+///
+/// OCSP configuration for server certificate stapling.
+///
+public sealed class OcspConfig
+{
+ public OcspMode Mode { get; init; } = OcspMode.Auto;
+ public string[] OverrideUrls { get; init; } = [];
+}
+```
+
+Add to `NatsOptions.cs`:
+```csharp
+public OcspConfig? OcspConfig { get; set; }
+public bool OcspPeerVerify { get; set; }
+```
+
+Modify `TlsConnectionWrapper.cs`: In the `RemoteCertificateValidationCallback`, when `OcspPeerVerify` is true, set `X509RevocationMode.Online` on the chain policy for client certificate validation.
+
+**Step 4: Run tests, verify they pass**
+
+**Step 5: Commit**
+
+```bash
+git add src/NATS.Server/Tls/OcspConfig.cs src/NATS.Server/NatsOptions.cs \
+ src/NATS.Server/Tls/TlsConnectionWrapper.cs \
+ tests/NATS.Server.Tests/OcspConfigTests.cs
+git commit -m "feat: add OCSP config and peer certificate revocation verification"
+```
+
+---
+
+### Task 8: OCSP Stapling
+
+**Files:**
+- Modify: `src/NATS.Server/NatsServer.cs` — Build `SslStreamCertificateContext` with OCSP
+- Modify: `src/NATS.Server/Monitoring/VarzHandler.cs` — Populate `TlsOcspPeerVerify`
+- **Depends on:** Task 7
+
+**Context:**
+.NET 8+ supports OCSP stapling via `SslStreamCertificateContext.Create(cert, chain, offline: false)`. This fetches OCSP responses automatically when the cert has OCSP responder URLs. For `Must` mode, we verify the OCSP response was obtained.
+
+**Step 1: Implement OCSP stapling in NatsServer startup**
+
+In `NatsServer.cs`, when `OcspConfig` is configured and `Mode != Never`:
+```csharp
+// After loading TLS certificate
+if (_options.OcspConfig is { Mode: not OcspMode.Never } ocspConfig)
+{
+ var offline = ocspConfig.Mode == OcspMode.Auto; // Auto = offline OK
+ var certContext = SslStreamCertificateContext.Create(cert, chain, offline: !offline);
+ // Store for use in SslServerAuthenticationOptions.ServerCertificateContext
+ // If Must mode and no OCSP response available, fail startup
+}
+```
+
+Populate in `VarzHandler.cs`:
+```csharp
+TlsOcspPeerVerify = _options.OcspPeerVerify
+```
+
+**Step 2: Run all tests, verify they pass**
+
+**Step 3: Commit**
+
+```bash
+git add src/NATS.Server/NatsServer.cs src/NATS.Server/Monitoring/VarzHandler.cs
+git commit -m "feat: add OCSP stapling with Auto/Always/Must/Never modes"
+```
+
+---
+
+### Task 9: Windows Service Integration
+
+**Files:**
+- Modify: `Directory.Packages.props` — Add `Microsoft.Extensions.Hosting.WindowsServices`
+- Modify: `src/NATS.Server.Host/NATS.Server.Host.csproj` — Add package reference
+- Modify: `src/NATS.Server.Host/Program.cs` — Add `--service` flag + `UseWindowsService()`
+- No dependencies
+
+**Step 1: Add NuGet package**
+
+Add to `Directory.Packages.props`:
+```xml
+
+```
+
+Add to `NATS.Server.Host.csproj`:
+```xml
+
+```
+
+**Step 2: Wire into Program.cs**
+
+Add `--service` flag handling in the CLI parsing section:
+```csharp
+case "--service":
+ windowsService = true;
+ break;
+```
+
+After building the host, conditionally add Windows Service support:
+```csharp
+if (windowsService && OperatingSystem.IsWindows())
+{
+ builder.UseWindowsService();
+}
+```
+
+**Step 3: Build and verify**
+
+Run: `dotnet build`
+
+**Step 4: Commit**
+
+```bash
+git add Directory.Packages.props src/NATS.Server.Host/NATS.Server.Host.csproj \
+ src/NATS.Server.Host/Program.cs
+git commit -m "feat: add Windows Service integration via --service flag"
+```
+
+---
+
+### Task 10: Per-Subsystem Log Control
+
+**Files:**
+- Modify: `src/NATS.Server/NatsOptions.cs` — Add `LogOverrides`
+- Modify: `src/NATS.Server.Host/Program.cs` — Add `--log_level_override` flag + Serilog overrides
+- Test: `tests/NATS.Server.Tests/NatsOptionsTests.cs` — Add test
+- No dependencies (but avoid parallel with Task 9 — both modify Program.cs)
+
+**Step 1: Write failing test**
+
+Add to `NatsOptionsTests.cs`:
+```csharp
+[Fact]
+public void LogOverrides_defaults_to_null()
+{
+ var options = new NatsOptions();
+ options.LogOverrides.ShouldBeNull();
+}
+```
+
+**Step 2: Implement**
+
+Add to `NatsOptions.cs`:
+```csharp
+public Dictionary? LogOverrides { get; set; }
+```
+
+Add to `Program.cs` CLI parsing:
+```csharp
+case "--log_level_override":
+ var parts = args[++i].Split('=', 2);
+ if (parts.Length == 2)
+ {
+ options.LogOverrides ??= new();
+ options.LogOverrides[parts[0]] = parts[1];
+ }
+ break;
+```
+
+In Serilog configuration section:
+```csharp
+if (options.LogOverrides is not null)
+{
+ foreach (var (ns, level) in options.LogOverrides)
+ {
+ if (Enum.TryParse(level, true, out var serilogLevel))
+ loggerConfig.MinimumLevel.Override(ns, serilogLevel);
+ }
+}
+```
+
+**Step 3: Run tests, verify they pass**
+
+**Step 4: Commit**
+
+```bash
+git add src/NATS.Server/NatsOptions.cs src/NATS.Server.Host/Program.cs \
+ tests/NATS.Server.Tests/NatsOptionsTests.cs
+git commit -m "feat: add per-subsystem log control via --log_level_override"
+```
+
+---
+
+### Task 11: Per-Client Trace Mode
+
+**Files:**
+- Modify: `src/NATS.Server/ClientFlags.cs` — Add `TraceMode` flag
+- Modify: `src/NATS.Server/NatsClient.cs` — Wire trace flag to parser logger
+- Test: `tests/NATS.Server.Tests/ClientFlagTests.cs` or add to existing
+- No dependencies
+
+**Step 1: Write failing test**
+
+```csharp
+[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();
+}
+```
+
+**Step 2: Implement**
+
+Add to `ClientFlags` enum in `ClientFlags.cs`:
+```csharp
+TraceMode = 1 << 7,
+```
+
+In `NatsClient.cs`, add a method to enable per-client tracing:
+```csharp
+public void SetTraceMode(bool enabled)
+{
+ if (enabled)
+ _flags.SetFlag(ClientFlags.TraceMode);
+ else
+ _flags.ClearFlag(ClientFlags.TraceMode);
+}
+```
+
+The parser was created at construction time with `options.Trace ? logger : null`. For per-client trace to work dynamically, we'd need to either recreate the parser or add a `SetLogger` method to `NatsParser`. The simplest Go-compatible approach: add `ILogger? Logger { set; }` to `NatsParser` so it can be toggled after construction.
+
+**Step 3: Run tests, verify they pass**
+
+**Step 4: Commit**
+
+```bash
+git add src/NATS.Server/ClientFlags.cs src/NATS.Server/NatsClient.cs \
+ src/NATS.Server/Protocol/NatsParser.cs tests/NATS.Server.Tests/ClientFlagTests.cs
+git commit -m "feat: add per-client trace mode flag with dynamic parser logger"
+```
+
+---
+
+### Task 12: Per-Account Stats
+
+**Files:**
+- Modify: `src/NATS.Server/Auth/Account.cs` — Add atomic stat counters
+- Modify: `src/NATS.Server/NatsServer.cs` — Increment stats in delivery path
+- Test: `tests/NATS.Server.Tests/AccountTests.cs` — Add stats tests
+- No dependencies (but avoid parallel with Task 6 — both modify NatsServer.cs)
+
+**Step 1: Write failing tests**
+
+Add to `AccountTests.cs`:
+```csharp
+[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);
+}
+```
+
+**Step 2: Run tests, verify they fail**
+
+**Step 3: Implement**
+
+Add to `Account.cs`:
+```csharp
+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);
+}
+```
+
+In `NatsServer.cs` `DeliverMessage`: after sending message, increment outbound stats on the subscriber's account. In message processing (PUB handler): increment inbound stats on the publisher's account.
+
+**Step 4: Run tests, verify they pass**
+
+**Step 5: Commit**
+
+```bash
+git add src/NATS.Server/Auth/Account.cs src/NATS.Server/NatsServer.cs \
+ tests/NATS.Server.Tests/AccountTests.cs
+git commit -m "feat: add per-account message/byte stats with Interlocked counters"
+```
+
+---
+
+### Task 13: TLS Certificate Expiry in /varz
+
+**Files:**
+- Modify: `src/NATS.Server/Monitoring/VarzHandler.cs` — Populate `TlsCertNotAfter`
+- Test: Verify via existing monitoring test or add targeted test
+- No dependencies
+
+**Step 1: Implement**
+
+In `VarzHandler.cs`, in the `HandleVarzAsync` method where other TLS fields are populated (lines 63-65), add:
+
+```csharp
+if (_options.HasTls && !string.IsNullOrEmpty(_options.TlsCert))
+{
+ try
+ {
+ using var cert = X509CertificateLoader.LoadCertificateFromFile(_options.TlsCert);
+ varz.TlsCertNotAfter = cert.NotAfter;
+ }
+ catch { /* cert load failure — leave field empty */ }
+}
+```
+
+Note: Check the existing `Varz.cs` model — the `TlsCertNotAfter` field type may be `DateTime` or `string`. Match the field type. Add `using System.Security.Cryptography.X509Certificates;` if not already present.
+
+**Step 2: Run all tests, verify they pass**
+
+**Step 3: Commit**
+
+```bash
+git add src/NATS.Server/Monitoring/VarzHandler.cs
+git commit -m "feat: populate TLS certificate expiry in /varz response"
+```
+
+---
+
+### Task 14: Update differences.md
+
+**Files:**
+- Modify: `differences.md`
+- **Depends on:** All Tasks 1-13
+
+**Step 1: Run full test suite**
+
+Run: `dotnet test --verbosity quiet`
+
+Verify all tests pass.
+
+**Step 2: Update differences.md**
+
+Update these sections:
+
+**Section 2 (Client Features):**
+- Per-client trace mode: `N` → `Y` with note about dynamic parser logger
+
+**Section 2 (Stats):**
+- Per-account stats: `N` → `Y` with note about Interlocked counters
+
+**Section 3 (Protocol Parsing Gaps):**
+- Subject mapping: `N` → `Y` with note about SubjectTransform engine
+
+**Section 4 (SubList Features):**
+- No changes (already complete)
+
+**Section 5 (Auth Mechanisms):**
+- JWT validation: `N` → `Y` with note about Ed25519 verification, account resolver
+- User revocation tracking: `N` → `Y`
+
+**Section 5 (Permissions):**
+- Permission templates: `N` → `Y` with note about 6 template types
+
+**Section 6 (CLI Flags):**
+- No changes needed (already covered)
+
+**Section 6 (Configuration System):**
+- Update option count from ~62 to reflect new additions
+
+**Section 6 (Missing Options):**
+- OCSP configuration: mark as implemented
+
+**Section 7 (Varz):**
+- TLS cert expiry info: `N` → `Y`
+- TlsOcspPeerVerify: populated
+
+**Section 8 (TLS):**
+- OCSP stapling: `N` → `Y` with modes
+
+**Section 9 (Logging):**
+- Per-subsystem log control: `N` → `Y` with note about MinimumLevel.Override
+
+**Summary section:**
+- Mark all newly resolved items with strikethrough
+- Update remaining gaps list
+
+**Step 3: Commit**
+
+```bash
+git add differences.md
+git commit -m "docs: update differences.md to reflect all remaining gaps resolved"
+```
+
+---
+
+## Appendix: File Dependency Matrix
+
+| Task | Creates | Modifies | Conflicts With |
+|------|---------|----------|---------------|
+| 1 | Auth/Jwt/NatsJwt.cs, UserClaims.cs, AccountClaims.cs | — | None |
+| 2 | Auth/Jwt/PermissionTemplates.cs | — | None |
+| 3 | Auth/Jwt/AccountResolver.cs | — | None |
+| 4 | Auth/JwtAuthenticator.cs | Account.cs, NatsOptions.cs, AuthService.cs | 12 (Account.cs) |
+| 5 | Subscriptions/SubjectTransform.cs | — | None |
+| 6 | — | NatsOptions.cs, NatsServer.cs | 4, 8, 10, 12 (shared files) |
+| 7 | Tls/OcspConfig.cs | NatsOptions.cs, TlsConnectionWrapper.cs | 6, 10 (NatsOptions) |
+| 8 | — | NatsServer.cs, VarzHandler.cs | 6, 12, 13 (shared files) |
+| 9 | — | Program.cs, Directory.Packages.props, csproj | 10 (Program.cs) |
+| 10 | — | NatsOptions.cs, Program.cs | 6, 7, 9 (shared files) |
+| 11 | — | ClientFlags.cs, NatsClient.cs, NatsParser.cs | None |
+| 12 | — | Account.cs, NatsServer.cs | 4, 6, 8 (shared files) |
+| 13 | — | VarzHandler.cs | 8 (VarzHandler) |
+| 14 | — | differences.md | None (final) |
diff --git a/docs/plans/2026-02-23-remaining-gaps-plan.md.tasks.json b/docs/plans/2026-02-23-remaining-gaps-plan.md.tasks.json
new file mode 100644
index 0000000..130c5de
--- /dev/null
+++ b/docs/plans/2026-02-23-remaining-gaps-plan.md.tasks.json
@@ -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"
+}
diff --git a/src/NATS.Server.Host/NATS.Server.Host.csproj b/src/NATS.Server.Host/NATS.Server.Host.csproj
index fedb2dd..9c49d17 100644
--- a/src/NATS.Server.Host/NATS.Server.Host.csproj
+++ b/src/NATS.Server.Host/NATS.Server.Host.csproj
@@ -9,6 +9,7 @@
+
diff --git a/src/NATS.Server.Host/Program.cs b/src/NATS.Server.Host/Program.cs
index 152e5f3..5f69a3c 100644
--- a/src/NATS.Server.Host/Program.cs
+++ b/src/NATS.Server.Host/Program.cs
@@ -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(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);
diff --git a/src/NATS.Server/Auth/Account.cs b/src/NATS.Server/Auth/Account.cs
index 211f431..bce25e1 100644
--- a/src/NATS.Server/Auth/Account.cs
+++ b/src/NATS.Server/Auth/Account.cs
@@ -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? SigningKeys { get; set; }
+ private readonly ConcurrentDictionary _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 _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();
}
diff --git a/src/NATS.Server/Auth/AuthService.cs b/src/NATS.Server/Auth/AuthService.cs
index c17b8aa..f2ca7ff 100644
--- a/src/NATS.Server/Auth/AuthService.cs
+++ b/src/NATS.Server/Auth/AuthService.cs
@@ -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()
diff --git a/src/NATS.Server/Auth/Jwt/AccountClaims.cs b/src/NATS.Server/Auth/Jwt/AccountClaims.cs
new file mode 100644
index 0000000..d581d98
--- /dev/null
+++ b/src/NATS.Server/Auth/Jwt/AccountClaims.cs
@@ -0,0 +1,94 @@
+using System.Text.Json.Serialization;
+
+namespace NATS.Server.Auth.Jwt;
+
+///
+/// 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.
+///
+///
+/// Reference: github.com/nats-io/jwt/v2 — AccountClaims, Account, OperatorLimits types
+///
+public sealed class AccountClaims
+{
+ /// Subject — the account's NKey public key.
+ [JsonPropertyName("sub")]
+ public string? Subject { get; set; }
+
+ /// Issuer — the operator or signing key that issued this JWT.
+ [JsonPropertyName("iss")]
+ public string? Issuer { get; set; }
+
+ /// Issued-at time as Unix epoch seconds.
+ [JsonPropertyName("iat")]
+ public long IssuedAt { get; set; }
+
+ /// Expiration time as Unix epoch seconds. 0 means no expiry.
+ [JsonPropertyName("exp")]
+ public long Expires { get; set; }
+
+ /// Human-readable name for the account.
+ [JsonPropertyName("name")]
+ public string? Name { get; set; }
+
+ /// NATS-specific account claims.
+ [JsonPropertyName("nats")]
+ public AccountNats? Nats { get; set; }
+}
+
+///
+/// NATS-specific portion of account JWT claims.
+/// Contains limits, signing keys, and user revocations.
+///
+public sealed class AccountNats
+{
+ /// Account resource limits.
+ [JsonPropertyName("limits")]
+ public AccountLimits? Limits { get; set; }
+
+ /// NKey public keys authorized to sign user JWTs for this account.
+ [JsonPropertyName("signing_keys")]
+ public string[]? SigningKeys { get; set; }
+
+ ///
+ /// 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.
+ ///
+ [JsonPropertyName("revocations")]
+ public Dictionary? Revocations { get; set; }
+
+ /// Tags associated with this account.
+ [JsonPropertyName("tags")]
+ public string[]? Tags { get; set; }
+
+ /// Claim type (e.g., "account").
+ [JsonPropertyName("type")]
+ public string? Type { get; set; }
+
+ /// Claim version.
+ [JsonPropertyName("version")]
+ public int Version { get; set; }
+}
+
+///
+/// Resource limits for a NATS account. A value of -1 means unlimited.
+///
+public sealed class AccountLimits
+{
+ /// Maximum number of connections. -1 means unlimited.
+ [JsonPropertyName("conn")]
+ public long MaxConnections { get; set; }
+
+ /// Maximum number of subscriptions. -1 means unlimited.
+ [JsonPropertyName("subs")]
+ public long MaxSubscriptions { get; set; }
+
+ /// Maximum payload size in bytes. -1 means unlimited.
+ [JsonPropertyName("payload")]
+ public long MaxPayload { get; set; }
+
+ /// Maximum data transfer in bytes. -1 means unlimited.
+ [JsonPropertyName("data")]
+ public long MaxData { get; set; }
+}
diff --git a/src/NATS.Server/Auth/Jwt/AccountResolver.cs b/src/NATS.Server/Auth/Jwt/AccountResolver.cs
new file mode 100644
index 0000000..b3739b2
--- /dev/null
+++ b/src/NATS.Server/Auth/Jwt/AccountResolver.cs
@@ -0,0 +1,65 @@
+using System.Collections.Concurrent;
+
+namespace NATS.Server.Auth.Jwt;
+
+///
+/// Resolves account JWTs by account NKey public key. The server calls
+/// during client authentication to obtain the
+/// account JWT that was previously published by an account operator.
+///
+///
+/// Reference: golang/nats-server/server/accounts.go:4035+ — AccountResolver interface
+/// and MemAccResolver implementation.
+///
+public interface IAccountResolver
+{
+ ///
+ /// Fetches the JWT for the given account NKey. Returns null when
+ /// the NKey is not known to this resolver.
+ ///
+ Task FetchAsync(string accountNkey);
+
+ ///
+ /// Stores (or replaces) the JWT for the given account NKey. Callers that
+ /// target a read-only resolver should check first.
+ ///
+ Task StoreAsync(string accountNkey, string jwt);
+
+ ///
+ /// When true, is not supported and will
+ /// throw . Directory and URL resolvers
+ /// may be read-only; in-memory resolvers are not.
+ ///
+ bool IsReadOnly { get; }
+}
+
+///
+/// In-memory account resolver backed by a .
+/// Suitable for tests and simple single-operator deployments where account JWTs
+/// are provided at startup via .
+///
+///
+/// Reference: golang/nats-server/server/accounts.go — MemAccResolver
+///
+public sealed class MemAccountResolver : IAccountResolver
+{
+ private readonly ConcurrentDictionary _accounts =
+ new(StringComparer.Ordinal);
+
+ ///
+ public bool IsReadOnly => false;
+
+ ///
+ public Task FetchAsync(string accountNkey)
+ {
+ _accounts.TryGetValue(accountNkey, out var jwt);
+ return Task.FromResult(jwt);
+ }
+
+ ///
+ public Task StoreAsync(string accountNkey, string jwt)
+ {
+ _accounts[accountNkey] = jwt;
+ return Task.CompletedTask;
+ }
+}
diff --git a/src/NATS.Server/Auth/Jwt/NatsJwt.cs b/src/NATS.Server/Auth/Jwt/NatsJwt.cs
new file mode 100644
index 0000000..2b23b5d
--- /dev/null
+++ b/src/NATS.Server/Auth/Jwt/NatsJwt.cs
@@ -0,0 +1,221 @@
+using System.Text;
+using System.Text.Json;
+using NATS.NKeys;
+
+namespace NATS.Server.Auth.Jwt;
+
+///
+/// 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 '{"').
+///
+///
+/// Reference: golang/nats-server/server/jwt.go and github.com/nats-io/jwt/v2
+///
+public static class NatsJwt
+{
+ private const string JwtPrefix = "eyJ";
+
+ ///
+ /// Returns true if the string appears to be a JWT (starts with "eyJ").
+ ///
+ public static bool IsJwt(string token)
+ {
+ return !string.IsNullOrEmpty(token) && token.StartsWith(JwtPrefix, StringComparison.Ordinal);
+ }
+
+ ///
+ /// Decodes a JWT token into its constituent parts without verifying the signature.
+ /// Returns null if the token is structurally invalid.
+ ///
+ 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(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;
+ }
+ }
+
+ ///
+ /// Decodes a JWT token and deserializes the payload as .
+ /// Returns null if the token is structurally invalid or cannot be deserialized.
+ ///
+ public static UserClaims? DecodeUserClaims(string token)
+ {
+ var jwt = Decode(token);
+ if (jwt is null)
+ return null;
+
+ try
+ {
+ return JsonSerializer.Deserialize(jwt.PayloadJson);
+ }
+ catch
+ {
+ return null;
+ }
+ }
+
+ ///
+ /// Decodes a JWT token and deserializes the payload as .
+ /// Returns null if the token is structurally invalid or cannot be deserialized.
+ ///
+ public static AccountClaims? DecodeAccountClaims(string token)
+ {
+ var jwt = Decode(token);
+ if (jwt is null)
+ return null;
+
+ try
+ {
+ return JsonSerializer.Deserialize(jwt.PayloadJson);
+ }
+ catch
+ {
+ return null;
+ }
+ }
+
+ ///
+ /// Verifies the Ed25519 signature on a JWT token against the given NKey public key.
+ ///
+ 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;
+ }
+ }
+
+ ///
+ /// Verifies a nonce signature against the given NKey public key.
+ /// Tries base64url decoding first, then falls back to standard base64 (Go compatibility).
+ ///
+ 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;
+ }
+ }
+
+ ///
+ /// Decodes a base64url-encoded byte array.
+ /// Replaces URL-safe characters and adds padding as needed.
+ ///
+ 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);
+ }
+
+ ///
+ /// Attempts to decode a signature string. Tries base64url first, then standard base64.
+ /// Returns null if neither encoding works.
+ ///
+ 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;
+ }
+ }
+}
+
+///
+/// Represents a decoded JWT token with its constituent parts.
+///
+public sealed class JwtToken
+{
+ /// The decoded JWT header.
+ public required JwtHeader Header { get; init; }
+
+ /// The raw JSON string of the payload.
+ public required string PayloadJson { get; init; }
+
+ /// The raw signature bytes.
+ public required byte[] Signature { get; init; }
+
+ /// The signing input (header.payload in base64url) used for signature verification.
+ public required string SigningInput { get; init; }
+}
+
+///
+/// NATS JWT header. Algorithm is "ed25519-nkey" for NATS JWTs.
+///
+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; }
+}
diff --git a/src/NATS.Server/Auth/Jwt/PermissionTemplates.cs b/src/NATS.Server/Auth/Jwt/PermissionTemplates.cs
new file mode 100644
index 0000000..59e63f0
--- /dev/null
+++ b/src/NATS.Server/Auth/Jwt/PermissionTemplates.cs
@@ -0,0 +1,123 @@
+using System.Text.RegularExpressions;
+
+namespace NATS.Server.Auth.Jwt;
+
+///
+/// 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.
+///
+///
+/// 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).
+///
+public static partial class PermissionTemplates
+{
+ [GeneratedRegex(@"\{\{([^}]+)\}\}")]
+ private static partial Regex TemplateRegex();
+
+ ///
+ /// 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.
+ ///
+ public static List 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 { pattern };
+ foreach (var (placeholder, values) in replacements)
+ {
+ var next = new List();
+ foreach (var current in results)
+ foreach (var value in values)
+ next.Add(current.Replace(placeholder, value));
+ results = next;
+ }
+ return results;
+ }
+
+ ///
+ /// Expands all patterns in a permission list, flattening multi-value expansions
+ /// into the result. Patterns that resolve to no values are omitted entirely.
+ ///
+ public static List ExpandAll(
+ IEnumerable patterns,
+ string name, string subject,
+ string accountName, string accountSubject,
+ string[] userTags, string[] accountTags)
+ {
+ var result = new List();
+ 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),
+ _ => []
+ };
+ }
+
+ ///
+ /// 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.
+ ///
+ 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();
+ }
+}
diff --git a/src/NATS.Server/Auth/Jwt/UserClaims.cs b/src/NATS.Server/Auth/Jwt/UserClaims.cs
new file mode 100644
index 0000000..22a1b05
--- /dev/null
+++ b/src/NATS.Server/Auth/Jwt/UserClaims.cs
@@ -0,0 +1,173 @@
+using System.Text.Json.Serialization;
+
+namespace NATS.Server.Auth.Jwt;
+
+///
+/// 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.
+///
+///
+/// Reference: github.com/nats-io/jwt/v2 — UserClaims, User, Permission types
+///
+public sealed class UserClaims
+{
+ /// Subject — the user's NKey public key.
+ [JsonPropertyName("sub")]
+ public string? Subject { get; set; }
+
+ /// Issuer — the account or signing key that issued this JWT.
+ [JsonPropertyName("iss")]
+ public string? Issuer { get; set; }
+
+ /// Issued-at time as Unix epoch seconds.
+ [JsonPropertyName("iat")]
+ public long IssuedAt { get; set; }
+
+ /// Expiration time as Unix epoch seconds. 0 means no expiry.
+ [JsonPropertyName("exp")]
+ public long Expires { get; set; }
+
+ /// Human-readable name for the user.
+ [JsonPropertyName("name")]
+ public string? Name { get; set; }
+
+ /// NATS-specific user claims.
+ [JsonPropertyName("nats")]
+ public UserNats? Nats { get; set; }
+
+ // =========================================================================
+ // Convenience properties that delegate to the Nats sub-object
+ // =========================================================================
+
+ /// Whether this is a bearer token (no client nonce signature required).
+ [JsonIgnore]
+ public bool BearerToken => Nats?.BearerToken ?? false;
+
+ /// The account NKey public key that issued this user JWT.
+ [JsonIgnore]
+ public string? IssuerAccount => Nats?.IssuerAccount;
+
+ // =========================================================================
+ // Expiry helpers
+ // =========================================================================
+
+ ///
+ /// Returns true if the JWT has expired. A zero Expires value means no expiry.
+ ///
+ public bool IsExpired()
+ {
+ if (Expires == 0)
+ return false;
+ return DateTimeOffset.UtcNow.ToUnixTimeSeconds() > Expires;
+ }
+
+ ///
+ /// Returns the expiry as a , or null if there is no expiry (Expires == 0).
+ ///
+ public DateTimeOffset? GetExpiry()
+ {
+ if (Expires == 0)
+ return null;
+ return DateTimeOffset.FromUnixTimeSeconds(Expires);
+ }
+}
+
+///
+/// NATS-specific portion of user JWT claims.
+/// Contains permissions, bearer token flag, connection restrictions, and more.
+///
+public sealed class UserNats
+{
+ /// Publish permission with allow/deny subject lists.
+ [JsonPropertyName("pub")]
+ public JwtSubjectPermission? Pub { get; set; }
+
+ /// Subscribe permission with allow/deny subject lists.
+ [JsonPropertyName("sub")]
+ public JwtSubjectPermission? Sub { get; set; }
+
+ /// Response permission controlling request-reply behavior.
+ [JsonPropertyName("resp")]
+ public JwtResponsePermission? Resp { get; set; }
+
+ /// Whether this is a bearer token (no nonce signature required).
+ [JsonPropertyName("bearer_token")]
+ public bool BearerToken { get; set; }
+
+ /// The account NKey public key that issued this user JWT.
+ [JsonPropertyName("issuer_account")]
+ public string? IssuerAccount { get; set; }
+
+ /// Tags associated with this user.
+ [JsonPropertyName("tags")]
+ public string[]? Tags { get; set; }
+
+ /// Allowed source CIDRs for this user's connections.
+ [JsonPropertyName("src")]
+ public string[]? Src { get; set; }
+
+ /// Allowed connection types (e.g., "STANDARD", "WEBSOCKET", "LEAFNODE").
+ [JsonPropertyName("allowed_connection_types")]
+ public string[]? AllowedConnectionTypes { get; set; }
+
+ /// Time-of-day restrictions for when the user may connect.
+ [JsonPropertyName("times")]
+ public JwtTimeRange[]? Times { get; set; }
+
+ /// Claim type (e.g., "user").
+ [JsonPropertyName("type")]
+ public string? Type { get; set; }
+
+ /// Claim version.
+ [JsonPropertyName("version")]
+ public int Version { get; set; }
+}
+
+///
+/// Subject permission with allow and deny lists, as used in NATS JWTs.
+///
+public sealed class JwtSubjectPermission
+{
+ /// Subjects the user is allowed to publish/subscribe to.
+ [JsonPropertyName("allow")]
+ public string[]? Allow { get; set; }
+
+ /// Subjects the user is denied from publishing/subscribing to.
+ [JsonPropertyName("deny")]
+ public string[]? Deny { get; set; }
+}
+
+///
+/// Response permission controlling request-reply behavior in NATS JWTs.
+///
+public sealed class JwtResponsePermission
+{
+ /// Maximum number of response messages allowed.
+ [JsonPropertyName("max")]
+ public int MaxMsgs { get; set; }
+
+ /// Time-to-live for the response permission, in nanoseconds.
+ [JsonPropertyName("ttl")]
+ public long TtlNanos { get; set; }
+
+ ///
+ /// Convenience property: converts to a .
+ ///
+ [JsonIgnore]
+ public TimeSpan Ttl => TimeSpan.FromTicks(TtlNanos / 100); // 1 tick = 100 nanoseconds
+}
+
+///
+/// A time-of-day range for connection restrictions.
+///
+public sealed class JwtTimeRange
+{
+ /// Start time in HH:mm:ss format.
+ [JsonPropertyName("start")]
+ public string? Start { get; set; }
+
+ /// End time in HH:mm:ss format.
+ [JsonPropertyName("end")]
+ public string? End { get; set; }
+}
diff --git a/src/NATS.Server/Auth/JwtAuthenticator.cs b/src/NATS.Server/Auth/JwtAuthenticator.cs
new file mode 100644
index 0000000..f28a155
--- /dev/null
+++ b/src/NATS.Server/Auth/JwtAuthenticator.cs
@@ -0,0 +1,160 @@
+using NATS.Server.Auth.Jwt;
+
+namespace NATS.Server.Auth;
+
+///
+/// Authenticator for JWT-based client connections.
+/// Decodes user JWT, resolves account, verifies signature, checks revocation.
+/// Reference: Go auth.go:588+ processClientOrLeafAuthentication.
+///
+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;
+ }
+}
diff --git a/src/NATS.Server/ClientFlags.cs b/src/NATS.Server/ClientFlags.cs
index 8f9e915..49818d6 100644
--- a/src/NATS.Server/ClientFlags.cs
+++ b/src/NATS.Server/ClientFlags.cs
@@ -15,6 +15,7 @@ public enum ClientFlags
WriteLoopStarted = 1 << 4,
IsSlowConsumer = 1 << 5,
ConnectProcessFinished = 1 << 6,
+ TraceMode = 1 << 7,
}
///
diff --git a/src/NATS.Server/Monitoring/VarzHandler.cs b/src/NATS.Server/Monitoring/VarzHandler.cs
index 433aa84..3bdbe6d 100644
--- a/src/NATS.Server/Monitoring/VarzHandler.cs
+++ b/src/NATS.Server/Monitoring/VarzHandler.cs
@@ -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,
diff --git a/src/NATS.Server/NatsClient.cs b/src/NATS.Server/NatsClient.cs
index 1ccfc71..5a7e2c8 100644
--- a/src/NATS.Server/NatsClient.cs
+++ b/src/NATS.Server/NatsClient.cs
@@ -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);
diff --git a/src/NATS.Server/NatsOptions.cs b/src/NATS.Server/NatsOptions.cs
index 8a9b56d..c9978a2 100644
--- a/src/NATS.Server/NatsOptions.cs
+++ b/src/NATS.Server/NatsOptions.cs
@@ -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? 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? LogOverrides { get; set; }
+
+ // Subject mapping / transforms (source pattern -> destination template)
+ public Dictionary? SubjectMappings { get; set; }
+
public bool HasTls => TlsCert != null && TlsKey != null;
}
diff --git a/src/NATS.Server/NatsServer.cs b/src/NATS.Server/NatsServer.cs
index 02a0734..2c2f0e2 100644
--- a/src/NATS.Server/NatsServer.cs
+++ b/src/NATS.Server/NatsServer.cs
@@ -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();
+ 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 headers,
ReadOnlyMemory 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;
diff --git a/src/NATS.Server/Protocol/NatsParser.cs b/src/NATS.Server/Protocol/NatsParser.cs
index b9debdd..7ba0aad 100644
--- a/src/NATS.Server/Protocol/NatsParser.cs
+++ b/src/NATS.Server/Protocol/NatsParser.cs
@@ -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;
diff --git a/src/NATS.Server/Protocol/NatsProtocol.cs b/src/NATS.Server/Protocol/NatsProtocol.cs
index 70dfb3c..ab4ef7a 100644
--- a/src/NATS.Server/Protocol/NatsProtocol.cs
+++ b/src/NATS.Server/Protocol/NatsProtocol.cs
@@ -134,4 +134,7 @@ public sealed class ClientOptions
[JsonPropertyName("sig")]
public string? Sig { get; set; }
+
+ [JsonPropertyName("jwt")]
+ public string? JWT { get; set; }
}
diff --git a/src/NATS.Server/Subscriptions/SubjectTransform.cs b/src/NATS.Server/Subscriptions/SubjectTransform.cs
new file mode 100644
index 0000000..cfee5c8
--- /dev/null
+++ b/src/NATS.Server/Subscriptions/SubjectTransform.cs
@@ -0,0 +1,708 @@
+using System.Text;
+using System.Text.RegularExpressions;
+
+namespace NATS.Server.Subscriptions;
+
+///
+/// Compiled subject transform engine that maps subjects from a source pattern to a destination template.
+/// Reference: Go server/subject_transform.go
+///
+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;
+ }
+
+ ///
+ /// Compiles a subject transform from source pattern to destination template.
+ /// Returns null if source is invalid or destination references out-of-range wildcards.
+ ///
+ 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 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);
+ }
+
+ ///
+ /// Matches subject against source pattern, captures wildcard values, evaluates destination template.
+ /// Returns null if subject doesn't match source.
+ ///
+ 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();
+ }
+
+ ///
+ /// FNV-1a 32-bit hash. Offset basis: 2166136261, prime: 16777619.
+ ///
+ 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);
+ }
+ }
+
+ ///
+ /// Matches literal subject tokens against a pattern with wildcards.
+ /// Subject tokens must be literal (no wildcards).
+ ///
+ 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;
+ }
+
+ ///
+ /// Validates a subject and returns (valid, tokens, pwcCount, hasFwc).
+ /// Reference: Go subject_transform.go subjectInfo()
+ ///
+ 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);
+ }
+
+ ///
+ /// Parses a single destination token into a transform operation descriptor.
+ /// Returns null on parse error.
+ ///
+ 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)
+ {
+ }
+ }
+}
diff --git a/src/NATS.Server/Tls/OcspConfig.cs b/src/NATS.Server/Tls/OcspConfig.cs
new file mode 100644
index 0000000..0634522
--- /dev/null
+++ b/src/NATS.Server/Tls/OcspConfig.cs
@@ -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; } = [];
+}
diff --git a/src/NATS.Server/Tls/TlsHelper.cs b/src/NATS.Server/Tls/TlsHelper.cs
index cdc5ef6..efddc6e 100644
--- a/src/NATS.Server/Tls/TlsHelper.cs
+++ b/src/NATS.Server/Tls/TlsHelper.cs
@@ -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;
}
+ ///
+ /// Builds an for OCSP stapling.
+ /// Returns null when TLS is not configured or OCSP mode is Never.
+ /// When is false the runtime will contact the
+ /// certificate's OCSP responder to obtain a fresh stapled response.
+ ///
+ 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();
diff --git a/tests/NATS.Server.Tests/AccountResolverTests.cs b/tests/NATS.Server.Tests/AccountResolverTests.cs
new file mode 100644
index 0000000..691148e
--- /dev/null
+++ b/tests/NATS.Server.Tests/AccountResolverTests.cs
@@ -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);
+ }
+}
diff --git a/tests/NATS.Server.Tests/AccountStatsTests.cs b/tests/NATS.Server.Tests/AccountStatsTests.cs
new file mode 100644
index 0000000..362dd18
--- /dev/null
+++ b/tests/NATS.Server.Tests/AccountStatsTests.cs
@@ -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);
+ }
+}
diff --git a/tests/NATS.Server.Tests/ClientTraceModeTests.cs b/tests/NATS.Server.Tests/ClientTraceModeTests.cs
new file mode 100644
index 0000000..20e1660
--- /dev/null
+++ b/tests/NATS.Server.Tests/ClientTraceModeTests.cs
@@ -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();
+ }
+}
diff --git a/tests/NATS.Server.Tests/JwtAuthenticatorTests.cs b/tests/NATS.Server.Tests/JwtAuthenticatorTests.cs
new file mode 100644
index 0000000..7cb0eaf
--- /dev/null
+++ b/tests/NATS.Server.Tests/JwtAuthenticatorTests.cs
@@ -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();
+ }
+}
diff --git a/tests/NATS.Server.Tests/JwtTests.cs b/tests/NATS.Server.Tests/JwtTests.cs
new file mode 100644
index 0000000..ce49c54
--- /dev/null
+++ b/tests/NATS.Server.Tests/JwtTests.cs
@@ -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
+{
+ ///
+ /// Helper: base64url-encode a string for constructing test JWTs.
+ ///
+ private static string Base64UrlEncode(string value)
+ {
+ var bytes = Encoding.UTF8.GetBytes(value);
+ return Convert.ToBase64String(bytes)
+ .TrimEnd('=')
+ .Replace('+', '-')
+ .Replace('/', '_');
+ }
+
+ ///
+ /// 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).
+ ///
+ 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}";
+ }
+
+ ///
+ /// Helper: build a real signed NATS JWT using an NKey keypair.
+ /// Signs header.payload with Ed25519.
+ ///
+ 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();
+ }
+}
diff --git a/tests/NATS.Server.Tests/NatsOptionsTests.cs b/tests/NATS.Server.Tests/NatsOptionsTests.cs
index 4e57769..535af0d 100644
--- a/tests/NATS.Server.Tests/NatsOptionsTests.cs
+++ b/tests/NATS.Server.Tests/NatsOptionsTests.cs
@@ -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");
+ }
+}
diff --git a/tests/NATS.Server.Tests/OcspConfigTests.cs b/tests/NATS.Server.Tests/OcspConfigTests.cs
new file mode 100644
index 0000000..42122ac
--- /dev/null
+++ b/tests/NATS.Server.Tests/OcspConfigTests.cs
@@ -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();
+ }
+}
diff --git a/tests/NATS.Server.Tests/OcspStaplingTests.cs b/tests/NATS.Server.Tests/OcspStaplingTests.cs
new file mode 100644
index 0000000..9c19dc0
--- /dev/null
+++ b/tests/NATS.Server.Tests/OcspStaplingTests.cs
@@ -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);
+ }
+}
diff --git a/tests/NATS.Server.Tests/PermissionTemplateTests.cs b/tests/NATS.Server.Tests/PermissionTemplateTests.cs
new file mode 100644
index 0000000..9e9de7c
--- /dev/null
+++ b/tests/NATS.Server.Tests/PermissionTemplateTests.cs
@@ -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.>"]);
+ }
+}
diff --git a/tests/NATS.Server.Tests/SubjectTransformIntegrationTests.cs b/tests/NATS.Server.Tests/SubjectTransformIntegrationTests.cs
new file mode 100644
index 0000000..c193c5a
--- /dev/null
+++ b/tests/NATS.Server.Tests/SubjectTransformIntegrationTests.cs
@@ -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
+ {
+ ["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() };
+ 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
+ {
+ [""] = "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
+ }
+}
diff --git a/tests/NATS.Server.Tests/SubjectTransformTests.cs b/tests/NATS.Server.Tests/SubjectTransformTests.cs
new file mode 100644
index 0000000..77f3fa1
--- /dev/null
+++ b/tests/NATS.Server.Tests/SubjectTransformTests.cs
@@ -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();
+ }
+}