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(); + } +}