Merge branch 'feature/remaining-gaps'
This commit is contained in:
@@ -15,6 +15,9 @@
|
||||
<PackageVersion Include="NATS.NKeys" Version="1.0.0-preview.3" />
|
||||
<PackageVersion Include="BCrypt.Net-Next" Version="4.0.3" />
|
||||
|
||||
<!-- Windows Service -->
|
||||
<PackageVersion Include="Microsoft.Extensions.Hosting.WindowsServices" Version="10.0.0" />
|
||||
|
||||
<!-- Testing -->
|
||||
<PackageVersion Include="coverlet.collector" Version="6.0.4" />
|
||||
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
| SIGUSR1 (reopen logs) | Y | Y | SIGUSR1 handler calls ReOpenLogFile |
|
||||
| SIGUSR2 (lame duck mode) | Y | Y | Triggers `LameDuckShutdownAsync()` |
|
||||
| SIGHUP (config reload) | Y | Stub | Signal registered, handler logs "not yet implemented" |
|
||||
| Windows Service integration | Y | N | |
|
||||
| Windows Service integration | Y | Y | `--service` flag with `Microsoft.Extensions.Hosting.WindowsServices` |
|
||||
|
||||
---
|
||||
|
||||
@@ -79,7 +79,7 @@
|
||||
| Slow consumer detection | Y | Y | Pending bytes threshold (64MB) + write deadline timeout (10s) |
|
||||
| Write deadline / timeout policies | Y | Y | `WriteDeadline` option with `CancellationTokenSource.CancelAfter` on flush |
|
||||
| RTT measurement | Y | Y | `_rttStartTicks`/`Rtt` property, computed on PONG receipt |
|
||||
| Per-client trace mode | Y | N | |
|
||||
| Per-client trace mode | Y | Y | `SetTraceMode()` toggles parser logger dynamically via `ClientFlags.TraceMode` |
|
||||
| Detailed close reason tracking | Y | Y | 37-value `ClosedState` enum with CAS-based `MarkClosed()` |
|
||||
| Connection state flags (16 flags) | Y | Y | 7-flag `ClientFlagHolder` with `Interlocked.Or`/`And` |
|
||||
|
||||
@@ -98,7 +98,7 @@ Go implements a sophisticated slow consumer detection system:
|
||||
|---------|:--:|:----:|-------|
|
||||
| Per-connection atomic stats | Y | Y | .NET uses `Interlocked` for stats access |
|
||||
| Per-read-cycle stat batching | Y | Y | Local accumulators flushed via `Interlocked.Add` per read cycle |
|
||||
| Per-account stats | Y | N | |
|
||||
| Per-account stats | Y | Y | `Interlocked` counters for InMsgs/OutMsgs/InBytes/OutBytes per `Account` |
|
||||
| Slow consumer counters | Y | Y | `SlowConsumers` and `SlowConsumerClients` incremented on detection |
|
||||
|
||||
---
|
||||
@@ -136,7 +136,7 @@ Go implements a sophisticated slow consumer detection system:
|
||||
|---------|:--:|:----:|-------|
|
||||
| Multi-client-type command routing | Y | N | Go checks `c.kind` to allow/reject commands |
|
||||
| Protocol tracing in parser | Y | Y | `TraceInOp()` logs `<<- OP arg` at `LogLevel.Trace` via optional `ILogger` |
|
||||
| Subject mapping (input→output) | Y | N | Go transforms subjects via mapping rules |
|
||||
| Subject mapping (input→output) | Y | Y | Compiled `SubjectTransform` engine with 9 function tokens; wired into `ProcessMessage` |
|
||||
| MIME header parsing | Y | Y | `NatsHeaderParser.Parse()` — status line + key-value headers from `ReadOnlySpan<byte>` |
|
||||
| Message trace event initialization | Y | N | |
|
||||
|
||||
@@ -204,14 +204,14 @@ Go implements a sophisticated slow consumer detection system:
|
||||
| Username/password | Y | Y | |
|
||||
| Token | Y | Y | |
|
||||
| NKeys (Ed25519) | Y | Y | .NET has framework but integration is basic |
|
||||
| JWT validation | Y | N | |
|
||||
| JWT validation | Y | Y | `NatsJwt` decode/verify, `JwtAuthenticator` with account resolution + revocation |
|
||||
| Bcrypt password hashing | Y | Y | .NET supports bcrypt (`$2*` prefix) with constant-time fallback |
|
||||
| TLS certificate mapping | Y | Y | X500DistinguishedName with full DN match and CN fallback |
|
||||
| Custom auth interface | Y | N | |
|
||||
| External auth callout | Y | N | |
|
||||
| Proxy authentication | Y | N | |
|
||||
| Bearer tokens | Y | N | |
|
||||
| User revocation tracking | Y | N | |
|
||||
| Bearer tokens | Y | Y | `UserClaims.BearerToken` skips nonce signature verification |
|
||||
| User revocation tracking | Y | Y | Per-account `ConcurrentDictionary` with wildcard (`*`) revocation support |
|
||||
|
||||
### Account System
|
||||
| Feature | Go | .NET | Notes |
|
||||
@@ -234,7 +234,7 @@ Go implements a sophisticated slow consumer detection system:
|
||||
| Permission caching (128 entries) | Y | Y | `PermissionLruCache` — Dictionary+LinkedList LRU, matching Go's `maxPermCacheSize` |
|
||||
| Response permissions (reply tracking) | Y | Y | `ResponseTracker` with configurable TTL + max messages; not LRU-cached |
|
||||
| Auth expiry enforcement | Y | Y | `Task.Delay` timer closes client when JWT/auth expires |
|
||||
| Permission templates (JWT) | Y | N | e.g., `{{name()}}`, `{{account-tag(...)}}` |
|
||||
| Permission templates (JWT) | Y | Y | `PermissionTemplates.Expand()` — 6 functions with cartesian product for multi-value tags |
|
||||
|
||||
---
|
||||
|
||||
@@ -263,12 +263,12 @@ Go implements a sophisticated slow consumer detection system:
|
||||
| ~450 option fields | Y | ~62 | .NET covers core + debug/trace/logging/limits/tags options |
|
||||
|
||||
### Missing Options Categories
|
||||
- ~~Logging options~~ — file logging, rotation, syslog, debug/trace, color, timestamps all implemented; only per-subsystem log control remains
|
||||
- ~~Logging options~~ — file logging, rotation, syslog, debug/trace, color, timestamps, per-subsystem log control all implemented
|
||||
- ~~Advanced limits (MaxSubs, MaxSubTokens, MaxPending, WriteDeadline)~~ — `MaxSubs`, `MaxSubTokens` implemented; MaxPending/WriteDeadline already existed
|
||||
- ~~Tags/metadata~~ — `Tags` dictionary implemented in `NatsOptions`
|
||||
- OCSP configuration
|
||||
- ~~OCSP configuration~~ — `OcspConfig` with 4 modes (Auto/Always/Must/Never), peer verification, and stapling
|
||||
- WebSocket/MQTT options
|
||||
- Operator mode / account resolver
|
||||
- ~~Operator mode / account resolver~~ — `JwtAuthenticator` + `IAccountResolver` + `MemAccountResolver` with trusted keys
|
||||
|
||||
---
|
||||
|
||||
@@ -303,7 +303,7 @@ Go implements a sophisticated slow consumer detection system:
|
||||
| SlowConsumer breakdown | Y | N | Go tracks per connection type |
|
||||
| Cluster/Gateway/Leaf blocks | Y | N | Excluded per scope |
|
||||
| JetStream block | Y | N | Excluded per scope |
|
||||
| TLS cert expiry info | Y | N | |
|
||||
| TLS cert expiry info | Y | Y | `TlsCertNotAfter` loaded via `X509CertificateLoader` in `/varz` |
|
||||
|
||||
### Connz Response
|
||||
| Feature | Go | .NET | Notes |
|
||||
@@ -343,7 +343,7 @@ Go implements a sophisticated slow consumer detection system:
|
||||
| TLS rate limiting | Y | Y | Rate enforcement with refill; unit tests cover rate limiting and refill |
|
||||
| First-byte peeking (0x16 detection) | Y | Y | |
|
||||
| Cert subject→user mapping | Y | Y | X500DistinguishedName with full DN match and CN fallback |
|
||||
| OCSP stapling | Y | N | |
|
||||
| OCSP stapling | Y | Y | `SslStreamCertificateContext.Create` with `offline:false` for runtime OCSP fetch |
|
||||
| Min TLS version control | Y | Y | |
|
||||
|
||||
---
|
||||
@@ -358,7 +358,7 @@ Go implements a sophisticated slow consumer detection system:
|
||||
| Log reopening (SIGUSR1) | Y | Y | SIGUSR1 handler calls ReOpenLogFile callback |
|
||||
| Trace mode (protocol-level) | Y | Y | `-V`/`-T`/`--trace` flags; parser `TraceInOp()` logs at Trace level |
|
||||
| Debug mode | Y | Y | `-D`/`--debug` flag lowers Serilog minimum to Debug |
|
||||
| Per-subsystem log control | Y | N | |
|
||||
| Per-subsystem log control | Y | Y | `--log_level_override ns=level` CLI flag with Serilog `MinimumLevel.Override` |
|
||||
| Color output on TTY | Y | Y | Auto-detected via `Console.IsOutputRedirected`, uses `AnsiConsoleTheme.Code` |
|
||||
| Timestamp format control | Y | Y | `--logtime` and `--logtime_utc` flags |
|
||||
|
||||
@@ -393,6 +393,17 @@ The following items from the original gap list have been implemented:
|
||||
- **Subscription statistics** — `Stats()`, `HasInterest()`, `NumInterest()`, etc.
|
||||
- **Per-account limits** — connection + subscription limits via `AccountConfig`
|
||||
- **Reply subject tracking** — `ResponseTracker` with TTL + max messages
|
||||
- **JWT authentication** — `JwtAuthenticator` with decode/verify, account resolution, revocation, permission templates
|
||||
- **OCSP support** — peer verification via `X509RevocationMode.Online`, stapling via `SslStreamCertificateContext`
|
||||
- **Subject mapping** — compiled `SubjectTransform` engine with 9 function tokens, wired into message delivery
|
||||
- **Windows Service integration** — `--service` flag with `Microsoft.Extensions.Hosting.WindowsServices`
|
||||
- **Per-subsystem log control** — `--log_level_override` CLI flag with Serilog overrides
|
||||
- **Per-client trace mode** — `SetTraceMode()` with dynamic parser logger toggling
|
||||
- **Per-account stats** — `Interlocked` counters for InMsgs/OutMsgs/InBytes/OutBytes
|
||||
- **TLS cert expiry in /varz** — `TlsCertNotAfter` populated via `X509CertificateLoader`
|
||||
- **Permission templates** — `PermissionTemplates.Expand()` with 6 functions and cartesian product
|
||||
- **Bearer tokens** — `UserClaims.BearerToken` skips nonce verification
|
||||
- **User revocation** — per-account tracking with wildcard (`*`) revocation
|
||||
|
||||
### Remaining High Priority
|
||||
1. **Config file parsing** — needed for production deployment (CLI stub exists)
|
||||
@@ -400,8 +411,3 @@ The following items from the original gap list have been implemented:
|
||||
|
||||
### Remaining Lower Priority
|
||||
3. **Dynamic buffer sizing** — delegated to Pipe, less optimized for long-lived connections
|
||||
4. **JWT authentication** — needed for operator mode
|
||||
5. **OCSP support** — certificate revocation checking
|
||||
6. **Subject mapping** — input→output subject transformation
|
||||
7. **Windows Service integration** — needed for Windows deployment
|
||||
8. **Per-subsystem log control** — granular log levels per component
|
||||
|
||||
1806
docs/plans/2026-02-23-remaining-gaps-plan.md
Normal file
1806
docs/plans/2026-02-23-remaining-gaps-plan.md
Normal file
File diff suppressed because it is too large
Load Diff
20
docs/plans/2026-02-23-remaining-gaps-plan.md.tasks.json
Normal file
20
docs/plans/2026-02-23-remaining-gaps-plan.md.tasks.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"planPath": "docs/plans/2026-02-23-remaining-gaps-plan.md",
|
||||
"tasks": [
|
||||
{"id": 1, "subject": "Task 1: JWT Core — Decode/Verify + Claim Structs", "status": "pending"},
|
||||
{"id": 2, "subject": "Task 2: Permission Templates", "status": "pending", "blockedBy": [1]},
|
||||
{"id": 3, "subject": "Task 3: Account Resolver", "status": "pending"},
|
||||
{"id": 4, "subject": "Task 4: JwtAuthenticator — Wire JWT into Auth", "status": "pending", "blockedBy": [1, 2, 3]},
|
||||
{"id": 5, "subject": "Task 5: Subject Transform — Core Engine", "status": "pending"},
|
||||
{"id": 6, "subject": "Task 6: Wire Subject Transforms into Delivery", "status": "pending", "blockedBy": [5]},
|
||||
{"id": 7, "subject": "Task 7: OCSP Config and Peer Verification", "status": "pending"},
|
||||
{"id": 8, "subject": "Task 8: OCSP Stapling", "status": "pending", "blockedBy": [7]},
|
||||
{"id": 9, "subject": "Task 9: Windows Service Integration", "status": "pending"},
|
||||
{"id": 10, "subject": "Task 10: Per-Subsystem Log Control", "status": "pending"},
|
||||
{"id": 11, "subject": "Task 11: Per-Client Trace Mode", "status": "pending"},
|
||||
{"id": 12, "subject": "Task 12: Per-Account Stats", "status": "pending"},
|
||||
{"id": 13, "subject": "Task 13: TLS Cert Expiry in /varz", "status": "pending"},
|
||||
{"id": 14, "subject": "Task 14: Update differences.md", "status": "pending", "blockedBy": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13]}
|
||||
],
|
||||
"lastUpdated": "2026-02-23T00:00:00Z"
|
||||
}
|
||||
@@ -9,6 +9,7 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" />
|
||||
<PackageReference Include="Serilog.Extensions.Hosting" />
|
||||
<PackageReference Include="Serilog.Sinks.Console" />
|
||||
<PackageReference Include="Serilog.Sinks.File" />
|
||||
|
||||
@@ -3,6 +3,7 @@ using Serilog;
|
||||
using Serilog.Sinks.SystemConsole.Themes;
|
||||
|
||||
var options = new NatsOptions();
|
||||
var windowsService = false;
|
||||
|
||||
// Parse ALL CLI flags into NatsOptions first
|
||||
for (int i = 0; i < args.Length; i++)
|
||||
@@ -81,6 +82,17 @@ for (int i = 0; i < args.Length; i++)
|
||||
case "--remote_syslog" when i + 1 < args.Length:
|
||||
options.RemoteSyslog = args[++i];
|
||||
break;
|
||||
case "--service":
|
||||
windowsService = true;
|
||||
break;
|
||||
case "--log_level_override" when i + 1 < args.Length:
|
||||
var parts = args[++i].Split('=', 2);
|
||||
if (parts.Length == 2)
|
||||
{
|
||||
options.LogOverrides ??= new();
|
||||
options.LogOverrides[parts[0]] = parts[1];
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,8 +143,23 @@ else if (options.Syslog)
|
||||
logConfig.WriteTo.LocalSyslog("nats-server");
|
||||
}
|
||||
|
||||
// Apply per-subsystem log level overrides
|
||||
if (options.LogOverrides is not null)
|
||||
{
|
||||
foreach (var (ns, level) in options.LogOverrides)
|
||||
{
|
||||
if (Enum.TryParse<Serilog.Events.LogEventLevel>(level, true, out var serilogLevel))
|
||||
logConfig.MinimumLevel.Override(ns, serilogLevel);
|
||||
}
|
||||
}
|
||||
|
||||
Log.Logger = logConfig.CreateLogger();
|
||||
|
||||
if (windowsService)
|
||||
{
|
||||
Log.Information("Windows Service mode requested");
|
||||
}
|
||||
|
||||
using var loggerFactory = new Serilog.Extensions.Logging.SerilogLoggerFactory(Log.Logger);
|
||||
using var server = new NatsServer(options, loggerFactory);
|
||||
|
||||
|
||||
@@ -13,6 +13,24 @@ public sealed class Account : IDisposable
|
||||
public int MaxConnections { get; set; } // 0 = unlimited
|
||||
public int MaxSubscriptions { get; set; } // 0 = unlimited
|
||||
|
||||
// JWT fields
|
||||
public string? Nkey { get; set; }
|
||||
public string? Issuer { get; set; }
|
||||
public Dictionary<string, object>? SigningKeys { get; set; }
|
||||
private readonly ConcurrentDictionary<string, long> _revokedUsers = new(StringComparer.Ordinal);
|
||||
|
||||
public void RevokeUser(string userNkey, long issuedAt) => _revokedUsers[userNkey] = issuedAt;
|
||||
|
||||
public bool IsUserRevoked(string userNkey, long issuedAt)
|
||||
{
|
||||
if (_revokedUsers.TryGetValue(userNkey, out var revokedAt))
|
||||
return issuedAt <= revokedAt;
|
||||
// Check "*" wildcard for all-user revocation
|
||||
if (_revokedUsers.TryGetValue("*", out revokedAt))
|
||||
return issuedAt <= revokedAt;
|
||||
return false;
|
||||
}
|
||||
|
||||
private readonly ConcurrentDictionary<ulong, byte> _clients = new();
|
||||
private int _subscriptionCount;
|
||||
|
||||
@@ -48,5 +66,28 @@ public sealed class Account : IDisposable
|
||||
Interlocked.Decrement(ref _subscriptionCount);
|
||||
}
|
||||
|
||||
// Per-account message/byte stats
|
||||
private long _inMsgs;
|
||||
private long _outMsgs;
|
||||
private long _inBytes;
|
||||
private long _outBytes;
|
||||
|
||||
public long InMsgs => Interlocked.Read(ref _inMsgs);
|
||||
public long OutMsgs => Interlocked.Read(ref _outMsgs);
|
||||
public long InBytes => Interlocked.Read(ref _inBytes);
|
||||
public long OutBytes => Interlocked.Read(ref _outBytes);
|
||||
|
||||
public void IncrementInbound(long msgs, long bytes)
|
||||
{
|
||||
Interlocked.Add(ref _inMsgs, msgs);
|
||||
Interlocked.Add(ref _inBytes, bytes);
|
||||
}
|
||||
|
||||
public void IncrementOutbound(long msgs, long bytes)
|
||||
{
|
||||
Interlocked.Add(ref _outMsgs, msgs);
|
||||
Interlocked.Add(ref _outBytes, bytes);
|
||||
}
|
||||
|
||||
public void Dispose() => SubList.Dispose();
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
94
src/NATS.Server/Auth/Jwt/AccountClaims.cs
Normal file
94
src/NATS.Server/Auth/Jwt/AccountClaims.cs
Normal file
@@ -0,0 +1,94 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace NATS.Server.Auth.Jwt;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the claims in a NATS account JWT.
|
||||
/// Contains standard JWT fields (sub, iss, iat, exp) and a NATS-specific nested object
|
||||
/// with account limits, signing keys, and revocations.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Reference: github.com/nats-io/jwt/v2 — AccountClaims, Account, OperatorLimits types
|
||||
/// </remarks>
|
||||
public sealed class AccountClaims
|
||||
{
|
||||
/// <summary>Subject — the account's NKey public key.</summary>
|
||||
[JsonPropertyName("sub")]
|
||||
public string? Subject { get; set; }
|
||||
|
||||
/// <summary>Issuer — the operator or signing key that issued this JWT.</summary>
|
||||
[JsonPropertyName("iss")]
|
||||
public string? Issuer { get; set; }
|
||||
|
||||
/// <summary>Issued-at time as Unix epoch seconds.</summary>
|
||||
[JsonPropertyName("iat")]
|
||||
public long IssuedAt { get; set; }
|
||||
|
||||
/// <summary>Expiration time as Unix epoch seconds. 0 means no expiry.</summary>
|
||||
[JsonPropertyName("exp")]
|
||||
public long Expires { get; set; }
|
||||
|
||||
/// <summary>Human-readable name for the account.</summary>
|
||||
[JsonPropertyName("name")]
|
||||
public string? Name { get; set; }
|
||||
|
||||
/// <summary>NATS-specific account claims.</summary>
|
||||
[JsonPropertyName("nats")]
|
||||
public AccountNats? Nats { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// NATS-specific portion of account JWT claims.
|
||||
/// Contains limits, signing keys, and user revocations.
|
||||
/// </summary>
|
||||
public sealed class AccountNats
|
||||
{
|
||||
/// <summary>Account resource limits.</summary>
|
||||
[JsonPropertyName("limits")]
|
||||
public AccountLimits? Limits { get; set; }
|
||||
|
||||
/// <summary>NKey public keys authorized to sign user JWTs for this account.</summary>
|
||||
[JsonPropertyName("signing_keys")]
|
||||
public string[]? SigningKeys { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Map of revoked user NKey public keys to the Unix epoch time of revocation.
|
||||
/// Any user JWT issued before the revocation time is considered revoked.
|
||||
/// </summary>
|
||||
[JsonPropertyName("revocations")]
|
||||
public Dictionary<string, long>? Revocations { get; set; }
|
||||
|
||||
/// <summary>Tags associated with this account.</summary>
|
||||
[JsonPropertyName("tags")]
|
||||
public string[]? Tags { get; set; }
|
||||
|
||||
/// <summary>Claim type (e.g., "account").</summary>
|
||||
[JsonPropertyName("type")]
|
||||
public string? Type { get; set; }
|
||||
|
||||
/// <summary>Claim version.</summary>
|
||||
[JsonPropertyName("version")]
|
||||
public int Version { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resource limits for a NATS account. A value of -1 means unlimited.
|
||||
/// </summary>
|
||||
public sealed class AccountLimits
|
||||
{
|
||||
/// <summary>Maximum number of connections. -1 means unlimited.</summary>
|
||||
[JsonPropertyName("conn")]
|
||||
public long MaxConnections { get; set; }
|
||||
|
||||
/// <summary>Maximum number of subscriptions. -1 means unlimited.</summary>
|
||||
[JsonPropertyName("subs")]
|
||||
public long MaxSubscriptions { get; set; }
|
||||
|
||||
/// <summary>Maximum payload size in bytes. -1 means unlimited.</summary>
|
||||
[JsonPropertyName("payload")]
|
||||
public long MaxPayload { get; set; }
|
||||
|
||||
/// <summary>Maximum data transfer in bytes. -1 means unlimited.</summary>
|
||||
[JsonPropertyName("data")]
|
||||
public long MaxData { get; set; }
|
||||
}
|
||||
65
src/NATS.Server/Auth/Jwt/AccountResolver.cs
Normal file
65
src/NATS.Server/Auth/Jwt/AccountResolver.cs
Normal file
@@ -0,0 +1,65 @@
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace NATS.Server.Auth.Jwt;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves account JWTs by account NKey public key. The server calls
|
||||
/// <see cref="FetchAsync"/> during client authentication to obtain the
|
||||
/// account JWT that was previously published by an account operator.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Reference: golang/nats-server/server/accounts.go:4035+ — AccountResolver interface
|
||||
/// and MemAccResolver implementation.
|
||||
/// </remarks>
|
||||
public interface IAccountResolver
|
||||
{
|
||||
/// <summary>
|
||||
/// Fetches the JWT for the given account NKey. Returns <c>null</c> when
|
||||
/// the NKey is not known to this resolver.
|
||||
/// </summary>
|
||||
Task<string?> FetchAsync(string accountNkey);
|
||||
|
||||
/// <summary>
|
||||
/// Stores (or replaces) the JWT for the given account NKey. Callers that
|
||||
/// target a read-only resolver should check <see cref="IsReadOnly"/> first.
|
||||
/// </summary>
|
||||
Task StoreAsync(string accountNkey, string jwt);
|
||||
|
||||
/// <summary>
|
||||
/// When <c>true</c>, <see cref="StoreAsync"/> is not supported and will
|
||||
/// throw <see cref="NotSupportedException"/>. Directory and URL resolvers
|
||||
/// may be read-only; in-memory resolvers are not.
|
||||
/// </summary>
|
||||
bool IsReadOnly { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// In-memory account resolver backed by a <see cref="ConcurrentDictionary{TKey,TValue}"/>.
|
||||
/// Suitable for tests and simple single-operator deployments where account JWTs
|
||||
/// are provided at startup via <see cref="StoreAsync"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Reference: golang/nats-server/server/accounts.go — MemAccResolver
|
||||
/// </remarks>
|
||||
public sealed class MemAccountResolver : IAccountResolver
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, string> _accounts =
|
||||
new(StringComparer.Ordinal);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public bool IsReadOnly => false;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<string?> FetchAsync(string accountNkey)
|
||||
{
|
||||
_accounts.TryGetValue(accountNkey, out var jwt);
|
||||
return Task.FromResult(jwt);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task StoreAsync(string accountNkey, string jwt)
|
||||
{
|
||||
_accounts[accountNkey] = jwt;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
221
src/NATS.Server/Auth/Jwt/NatsJwt.cs
Normal file
221
src/NATS.Server/Auth/Jwt/NatsJwt.cs
Normal file
@@ -0,0 +1,221 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using NATS.NKeys;
|
||||
|
||||
namespace NATS.Server.Auth.Jwt;
|
||||
|
||||
/// <summary>
|
||||
/// Provides NATS JWT decode, verify, and claim extraction.
|
||||
/// NATS JWTs are standard JWT format (base64url header.payload.signature) with Ed25519 signing.
|
||||
/// All NATS JWTs start with "eyJ" (base64url for '{"').
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Reference: golang/nats-server/server/jwt.go and github.com/nats-io/jwt/v2
|
||||
/// </remarks>
|
||||
public static class NatsJwt
|
||||
{
|
||||
private const string JwtPrefix = "eyJ";
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if the string appears to be a JWT (starts with "eyJ").
|
||||
/// </summary>
|
||||
public static bool IsJwt(string token)
|
||||
{
|
||||
return !string.IsNullOrEmpty(token) && token.StartsWith(JwtPrefix, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decodes a JWT token into its constituent parts without verifying the signature.
|
||||
/// Returns null if the token is structurally invalid.
|
||||
/// </summary>
|
||||
public static JwtToken? Decode(string token)
|
||||
{
|
||||
if (string.IsNullOrEmpty(token))
|
||||
return null;
|
||||
|
||||
var parts = token.Split('.');
|
||||
if (parts.Length != 3)
|
||||
return null;
|
||||
|
||||
try
|
||||
{
|
||||
var headerBytes = Base64UrlDecode(parts[0]);
|
||||
var payloadBytes = Base64UrlDecode(parts[1]);
|
||||
var signatureBytes = Base64UrlDecode(parts[2]);
|
||||
|
||||
var header = JsonSerializer.Deserialize<JwtHeader>(headerBytes);
|
||||
if (header is null)
|
||||
return null;
|
||||
|
||||
var payloadJson = Encoding.UTF8.GetString(payloadBytes);
|
||||
var signingInput = $"{parts[0]}.{parts[1]}";
|
||||
|
||||
return new JwtToken
|
||||
{
|
||||
Header = header,
|
||||
PayloadJson = payloadJson,
|
||||
Signature = signatureBytes,
|
||||
SigningInput = signingInput,
|
||||
};
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decodes a JWT token and deserializes the payload as <see cref="UserClaims"/>.
|
||||
/// Returns null if the token is structurally invalid or cannot be deserialized.
|
||||
/// </summary>
|
||||
public static UserClaims? DecodeUserClaims(string token)
|
||||
{
|
||||
var jwt = Decode(token);
|
||||
if (jwt is null)
|
||||
return null;
|
||||
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<UserClaims>(jwt.PayloadJson);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decodes a JWT token and deserializes the payload as <see cref="AccountClaims"/>.
|
||||
/// Returns null if the token is structurally invalid or cannot be deserialized.
|
||||
/// </summary>
|
||||
public static AccountClaims? DecodeAccountClaims(string token)
|
||||
{
|
||||
var jwt = Decode(token);
|
||||
if (jwt is null)
|
||||
return null;
|
||||
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<AccountClaims>(jwt.PayloadJson);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the Ed25519 signature on a JWT token against the given NKey public key.
|
||||
/// </summary>
|
||||
public static bool Verify(string token, string publicNkey)
|
||||
{
|
||||
try
|
||||
{
|
||||
var jwt = Decode(token);
|
||||
if (jwt is null)
|
||||
return false;
|
||||
|
||||
var kp = KeyPair.FromPublicKey(publicNkey);
|
||||
var signingInputBytes = Encoding.UTF8.GetBytes(jwt.SigningInput);
|
||||
return kp.Verify(signingInputBytes, jwt.Signature);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies a nonce signature against the given NKey public key.
|
||||
/// Tries base64url decoding first, then falls back to standard base64 (Go compatibility).
|
||||
/// </summary>
|
||||
public static bool VerifyNonce(byte[] nonce, string signature, string publicNkey)
|
||||
{
|
||||
try
|
||||
{
|
||||
var sigBytes = TryDecodeSignature(signature);
|
||||
if (sigBytes is null)
|
||||
return false;
|
||||
|
||||
var kp = KeyPair.FromPublicKey(publicNkey);
|
||||
return kp.Verify(nonce, sigBytes);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decodes a base64url-encoded byte array.
|
||||
/// Replaces URL-safe characters and adds padding as needed.
|
||||
/// </summary>
|
||||
internal static byte[] Base64UrlDecode(string input)
|
||||
{
|
||||
var s = input.Replace('-', '+').Replace('_', '/');
|
||||
switch (s.Length % 4)
|
||||
{
|
||||
case 2: s += "=="; break;
|
||||
case 3: s += "="; break;
|
||||
}
|
||||
|
||||
return Convert.FromBase64String(s);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to decode a signature string. Tries base64url first, then standard base64.
|
||||
/// Returns null if neither encoding works.
|
||||
/// </summary>
|
||||
private static byte[]? TryDecodeSignature(string signature)
|
||||
{
|
||||
// Try base64url first
|
||||
try
|
||||
{
|
||||
return Base64UrlDecode(signature);
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
// Fall through to standard base64
|
||||
}
|
||||
|
||||
// Try standard base64
|
||||
try
|
||||
{
|
||||
return Convert.FromBase64String(signature);
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a decoded JWT token with its constituent parts.
|
||||
/// </summary>
|
||||
public sealed class JwtToken
|
||||
{
|
||||
/// <summary>The decoded JWT header.</summary>
|
||||
public required JwtHeader Header { get; init; }
|
||||
|
||||
/// <summary>The raw JSON string of the payload.</summary>
|
||||
public required string PayloadJson { get; init; }
|
||||
|
||||
/// <summary>The raw signature bytes.</summary>
|
||||
public required byte[] Signature { get; init; }
|
||||
|
||||
/// <summary>The signing input (header.payload in base64url) used for signature verification.</summary>
|
||||
public required string SigningInput { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// NATS JWT header. Algorithm is "ed25519-nkey" for NATS JWTs.
|
||||
/// </summary>
|
||||
public sealed class JwtHeader
|
||||
{
|
||||
[System.Text.Json.Serialization.JsonPropertyName("alg")]
|
||||
public string? Algorithm { get; set; }
|
||||
|
||||
[System.Text.Json.Serialization.JsonPropertyName("typ")]
|
||||
public string? Type { get; set; }
|
||||
}
|
||||
123
src/NATS.Server/Auth/Jwt/PermissionTemplates.cs
Normal file
123
src/NATS.Server/Auth/Jwt/PermissionTemplates.cs
Normal file
@@ -0,0 +1,123 @@
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace NATS.Server.Auth.Jwt;
|
||||
|
||||
/// <summary>
|
||||
/// Expands mustache-style template strings in NATS JWT permission subjects.
|
||||
/// When a user connects with a JWT, template strings in their permissions are
|
||||
/// expanded using claim values from the user and account JWTs.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Reference: Go auth.go:424-520 — processUserPermissionsTemplate()
|
||||
///
|
||||
/// Supported template functions:
|
||||
/// {{name()}} — user's Name claim
|
||||
/// {{subject()}} — user's Subject (NKey public key)
|
||||
/// {{tag(tagname)}} — user tags matching "tagname:" prefix (multi-value → cartesian product)
|
||||
/// {{account-name()}} — account display name
|
||||
/// {{account-subject()}} — account NKey public key
|
||||
/// {{account-tag(tagname)}} — account tags matching "tagname:" prefix (multi-value → cartesian product)
|
||||
///
|
||||
/// When a template resolves to multiple values (e.g. a user with two "dept:" tags),
|
||||
/// the cartesian product of all expanded subjects is returned. If any template
|
||||
/// resolves to zero values, the entire pattern is dropped (returns empty list).
|
||||
/// </remarks>
|
||||
public static partial class PermissionTemplates
|
||||
{
|
||||
[GeneratedRegex(@"\{\{([^}]+)\}\}")]
|
||||
private static partial Regex TemplateRegex();
|
||||
|
||||
/// <summary>
|
||||
/// Expands a single permission pattern containing zero or more template expressions.
|
||||
/// Returns the list of concrete subjects after substitution.
|
||||
/// Returns an empty list if any template resolves to no values (tag not found).
|
||||
/// Returns a single-element list containing the original pattern if no templates are present.
|
||||
/// </summary>
|
||||
public static List<string> Expand(
|
||||
string pattern,
|
||||
string name, string subject,
|
||||
string accountName, string accountSubject,
|
||||
string[] userTags, string[] accountTags)
|
||||
{
|
||||
var matches = TemplateRegex().Matches(pattern);
|
||||
if (matches.Count == 0)
|
||||
return [pattern];
|
||||
|
||||
var replacements = new List<(string Placeholder, string[] Values)>();
|
||||
foreach (Match match in matches)
|
||||
{
|
||||
var expr = match.Groups[1].Value.Trim();
|
||||
var values = ResolveTemplate(expr, name, subject, accountName, accountSubject, userTags, accountTags);
|
||||
if (values.Length == 0)
|
||||
return [];
|
||||
replacements.Add((match.Value, values));
|
||||
}
|
||||
|
||||
// Compute cartesian product across all multi-value replacements.
|
||||
// Start with the full pattern and iteratively replace each placeholder.
|
||||
var results = new List<string> { pattern };
|
||||
foreach (var (placeholder, values) in replacements)
|
||||
{
|
||||
var next = new List<string>();
|
||||
foreach (var current in results)
|
||||
foreach (var value in values)
|
||||
next.Add(current.Replace(placeholder, value));
|
||||
results = next;
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Expands all patterns in a permission list, flattening multi-value expansions
|
||||
/// into the result. Patterns that resolve to no values are omitted entirely.
|
||||
/// </summary>
|
||||
public static List<string> ExpandAll(
|
||||
IEnumerable<string> patterns,
|
||||
string name, string subject,
|
||||
string accountName, string accountSubject,
|
||||
string[] userTags, string[] accountTags)
|
||||
{
|
||||
var result = new List<string>();
|
||||
foreach (var pattern in patterns)
|
||||
result.AddRange(Expand(pattern, name, subject, accountName, accountSubject, userTags, accountTags));
|
||||
return result;
|
||||
}
|
||||
|
||||
private static string[] ResolveTemplate(
|
||||
string expr,
|
||||
string name, string subject,
|
||||
string accountName, string accountSubject,
|
||||
string[] userTags, string[] accountTags)
|
||||
{
|
||||
return expr.ToLowerInvariant() switch
|
||||
{
|
||||
"name()" => [name],
|
||||
"subject()" => [subject],
|
||||
"account-name()" => [accountName],
|
||||
"account-subject()" => [accountSubject],
|
||||
_ when expr.StartsWith("tag(", StringComparison.OrdinalIgnoreCase) => ResolveTags(expr, userTags),
|
||||
_ when expr.StartsWith("account-tag(", StringComparison.OrdinalIgnoreCase) => ResolveTags(expr, accountTags),
|
||||
_ => []
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts the tag name from a tag() or account-tag() expression and returns
|
||||
/// all matching tag values from the provided tags array.
|
||||
/// Tags are stored in "key:value" format; this method returns the value portion.
|
||||
/// </summary>
|
||||
private static string[] ResolveTags(string expr, string[] tags)
|
||||
{
|
||||
var openParen = expr.IndexOf('(');
|
||||
var closeParen = expr.IndexOf(')');
|
||||
if (openParen < 0 || closeParen < 0)
|
||||
return [];
|
||||
|
||||
var tagName = expr[(openParen + 1)..closeParen].Trim();
|
||||
var prefix = tagName + ":";
|
||||
return tags
|
||||
.Where(t => t.StartsWith(prefix, StringComparison.Ordinal))
|
||||
.Select(t => t[prefix.Length..])
|
||||
.ToArray();
|
||||
}
|
||||
}
|
||||
173
src/NATS.Server/Auth/Jwt/UserClaims.cs
Normal file
173
src/NATS.Server/Auth/Jwt/UserClaims.cs
Normal file
@@ -0,0 +1,173 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace NATS.Server.Auth.Jwt;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the claims in a NATS user JWT.
|
||||
/// Contains standard JWT fields (sub, iss, iat, exp) and a NATS-specific nested object
|
||||
/// with user permissions, bearer token flags, and connection restrictions.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Reference: github.com/nats-io/jwt/v2 — UserClaims, User, Permission types
|
||||
/// </remarks>
|
||||
public sealed class UserClaims
|
||||
{
|
||||
/// <summary>Subject — the user's NKey public key.</summary>
|
||||
[JsonPropertyName("sub")]
|
||||
public string? Subject { get; set; }
|
||||
|
||||
/// <summary>Issuer — the account or signing key that issued this JWT.</summary>
|
||||
[JsonPropertyName("iss")]
|
||||
public string? Issuer { get; set; }
|
||||
|
||||
/// <summary>Issued-at time as Unix epoch seconds.</summary>
|
||||
[JsonPropertyName("iat")]
|
||||
public long IssuedAt { get; set; }
|
||||
|
||||
/// <summary>Expiration time as Unix epoch seconds. 0 means no expiry.</summary>
|
||||
[JsonPropertyName("exp")]
|
||||
public long Expires { get; set; }
|
||||
|
||||
/// <summary>Human-readable name for the user.</summary>
|
||||
[JsonPropertyName("name")]
|
||||
public string? Name { get; set; }
|
||||
|
||||
/// <summary>NATS-specific user claims.</summary>
|
||||
[JsonPropertyName("nats")]
|
||||
public UserNats? Nats { get; set; }
|
||||
|
||||
// =========================================================================
|
||||
// Convenience properties that delegate to the Nats sub-object
|
||||
// =========================================================================
|
||||
|
||||
/// <summary>Whether this is a bearer token (no client nonce signature required).</summary>
|
||||
[JsonIgnore]
|
||||
public bool BearerToken => Nats?.BearerToken ?? false;
|
||||
|
||||
/// <summary>The account NKey public key that issued this user JWT.</summary>
|
||||
[JsonIgnore]
|
||||
public string? IssuerAccount => Nats?.IssuerAccount;
|
||||
|
||||
// =========================================================================
|
||||
// Expiry helpers
|
||||
// =========================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if the JWT has expired. A zero Expires value means no expiry.
|
||||
/// </summary>
|
||||
public bool IsExpired()
|
||||
{
|
||||
if (Expires == 0)
|
||||
return false;
|
||||
return DateTimeOffset.UtcNow.ToUnixTimeSeconds() > Expires;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the expiry as a <see cref="DateTimeOffset"/>, or null if there is no expiry (Expires == 0).
|
||||
/// </summary>
|
||||
public DateTimeOffset? GetExpiry()
|
||||
{
|
||||
if (Expires == 0)
|
||||
return null;
|
||||
return DateTimeOffset.FromUnixTimeSeconds(Expires);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// NATS-specific portion of user JWT claims.
|
||||
/// Contains permissions, bearer token flag, connection restrictions, and more.
|
||||
/// </summary>
|
||||
public sealed class UserNats
|
||||
{
|
||||
/// <summary>Publish permission with allow/deny subject lists.</summary>
|
||||
[JsonPropertyName("pub")]
|
||||
public JwtSubjectPermission? Pub { get; set; }
|
||||
|
||||
/// <summary>Subscribe permission with allow/deny subject lists.</summary>
|
||||
[JsonPropertyName("sub")]
|
||||
public JwtSubjectPermission? Sub { get; set; }
|
||||
|
||||
/// <summary>Response permission controlling request-reply behavior.</summary>
|
||||
[JsonPropertyName("resp")]
|
||||
public JwtResponsePermission? Resp { get; set; }
|
||||
|
||||
/// <summary>Whether this is a bearer token (no nonce signature required).</summary>
|
||||
[JsonPropertyName("bearer_token")]
|
||||
public bool BearerToken { get; set; }
|
||||
|
||||
/// <summary>The account NKey public key that issued this user JWT.</summary>
|
||||
[JsonPropertyName("issuer_account")]
|
||||
public string? IssuerAccount { get; set; }
|
||||
|
||||
/// <summary>Tags associated with this user.</summary>
|
||||
[JsonPropertyName("tags")]
|
||||
public string[]? Tags { get; set; }
|
||||
|
||||
/// <summary>Allowed source CIDRs for this user's connections.</summary>
|
||||
[JsonPropertyName("src")]
|
||||
public string[]? Src { get; set; }
|
||||
|
||||
/// <summary>Allowed connection types (e.g., "STANDARD", "WEBSOCKET", "LEAFNODE").</summary>
|
||||
[JsonPropertyName("allowed_connection_types")]
|
||||
public string[]? AllowedConnectionTypes { get; set; }
|
||||
|
||||
/// <summary>Time-of-day restrictions for when the user may connect.</summary>
|
||||
[JsonPropertyName("times")]
|
||||
public JwtTimeRange[]? Times { get; set; }
|
||||
|
||||
/// <summary>Claim type (e.g., "user").</summary>
|
||||
[JsonPropertyName("type")]
|
||||
public string? Type { get; set; }
|
||||
|
||||
/// <summary>Claim version.</summary>
|
||||
[JsonPropertyName("version")]
|
||||
public int Version { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Subject permission with allow and deny lists, as used in NATS JWTs.
|
||||
/// </summary>
|
||||
public sealed class JwtSubjectPermission
|
||||
{
|
||||
/// <summary>Subjects the user is allowed to publish/subscribe to.</summary>
|
||||
[JsonPropertyName("allow")]
|
||||
public string[]? Allow { get; set; }
|
||||
|
||||
/// <summary>Subjects the user is denied from publishing/subscribing to.</summary>
|
||||
[JsonPropertyName("deny")]
|
||||
public string[]? Deny { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response permission controlling request-reply behavior in NATS JWTs.
|
||||
/// </summary>
|
||||
public sealed class JwtResponsePermission
|
||||
{
|
||||
/// <summary>Maximum number of response messages allowed.</summary>
|
||||
[JsonPropertyName("max")]
|
||||
public int MaxMsgs { get; set; }
|
||||
|
||||
/// <summary>Time-to-live for the response permission, in nanoseconds.</summary>
|
||||
[JsonPropertyName("ttl")]
|
||||
public long TtlNanos { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Convenience property: converts <see cref="TtlNanos"/> to a <see cref="TimeSpan"/>.
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
public TimeSpan Ttl => TimeSpan.FromTicks(TtlNanos / 100); // 1 tick = 100 nanoseconds
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A time-of-day range for connection restrictions.
|
||||
/// </summary>
|
||||
public sealed class JwtTimeRange
|
||||
{
|
||||
/// <summary>Start time in HH:mm:ss format.</summary>
|
||||
[JsonPropertyName("start")]
|
||||
public string? Start { get; set; }
|
||||
|
||||
/// <summary>End time in HH:mm:ss format.</summary>
|
||||
[JsonPropertyName("end")]
|
||||
public string? End { get; set; }
|
||||
}
|
||||
160
src/NATS.Server/Auth/JwtAuthenticator.cs
Normal file
160
src/NATS.Server/Auth/JwtAuthenticator.cs
Normal file
@@ -0,0 +1,160 @@
|
||||
using NATS.Server.Auth.Jwt;
|
||||
|
||||
namespace NATS.Server.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Authenticator for JWT-based client connections.
|
||||
/// Decodes user JWT, resolves account, verifies signature, checks revocation.
|
||||
/// Reference: Go auth.go:588+ processClientOrLeafAuthentication.
|
||||
/// </summary>
|
||||
public sealed class JwtAuthenticator : IAuthenticator
|
||||
{
|
||||
private readonly string[] _trustedKeys;
|
||||
private readonly IAccountResolver _resolver;
|
||||
|
||||
public JwtAuthenticator(string[] trustedKeys, IAccountResolver resolver)
|
||||
{
|
||||
_trustedKeys = trustedKeys;
|
||||
_resolver = resolver;
|
||||
}
|
||||
|
||||
public AuthResult? Authenticate(ClientAuthContext context)
|
||||
{
|
||||
var jwt = context.Opts.JWT;
|
||||
if (string.IsNullOrEmpty(jwt) || !NatsJwt.IsJwt(jwt))
|
||||
return null;
|
||||
|
||||
// 1. Decode user claims
|
||||
var userClaims = NatsJwt.DecodeUserClaims(jwt);
|
||||
if (userClaims is null)
|
||||
return null;
|
||||
|
||||
// 2. Check expiry
|
||||
if (userClaims.IsExpired())
|
||||
return null;
|
||||
|
||||
// 3. Resolve issuing account
|
||||
var issuerAccount = !string.IsNullOrEmpty(userClaims.IssuerAccount)
|
||||
? userClaims.IssuerAccount
|
||||
: userClaims.Issuer;
|
||||
|
||||
if (string.IsNullOrEmpty(issuerAccount))
|
||||
return null;
|
||||
|
||||
var accountJwt = _resolver.FetchAsync(issuerAccount).GetAwaiter().GetResult();
|
||||
if (accountJwt is null)
|
||||
return null;
|
||||
|
||||
var accountClaims = NatsJwt.DecodeAccountClaims(accountJwt);
|
||||
if (accountClaims is null)
|
||||
return null;
|
||||
|
||||
// 4. Verify account issuer is trusted
|
||||
if (!IsTrusted(accountClaims.Issuer))
|
||||
return null;
|
||||
|
||||
// 5. Verify user JWT issuer is the account or a signing key
|
||||
var userIssuer = userClaims.Issuer;
|
||||
if (userIssuer != accountClaims.Subject)
|
||||
{
|
||||
// Check if issuer is a signing key of the account
|
||||
var signingKeys = accountClaims.Nats?.SigningKeys;
|
||||
if (signingKeys is null || !signingKeys.Contains(userIssuer))
|
||||
return null;
|
||||
}
|
||||
|
||||
// 6. Verify nonce signature (unless bearer token)
|
||||
if (!userClaims.BearerToken)
|
||||
{
|
||||
if (context.Nonce is null || string.IsNullOrEmpty(context.Opts.Sig))
|
||||
return null;
|
||||
|
||||
var userNkey = userClaims.Subject ?? context.Opts.Nkey;
|
||||
if (string.IsNullOrEmpty(userNkey))
|
||||
return null;
|
||||
|
||||
if (!NatsJwt.VerifyNonce(context.Nonce, context.Opts.Sig, userNkey))
|
||||
return null;
|
||||
}
|
||||
|
||||
// 7. Check user revocation
|
||||
var revocations = accountClaims.Nats?.Revocations;
|
||||
if (revocations is not null && userClaims.Subject is not null)
|
||||
{
|
||||
if (revocations.TryGetValue(userClaims.Subject, out var revokedAt))
|
||||
{
|
||||
if (userClaims.IssuedAt <= revokedAt)
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check wildcard revocation
|
||||
if (revocations.TryGetValue("*", out revokedAt))
|
||||
{
|
||||
if (userClaims.IssuedAt <= revokedAt)
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// 8. Build permissions from JWT claims
|
||||
Permissions? permissions = null;
|
||||
var nats = userClaims.Nats;
|
||||
if (nats is not null)
|
||||
{
|
||||
var pubAllow = nats.Pub?.Allow;
|
||||
var pubDeny = nats.Pub?.Deny;
|
||||
var subAllow = nats.Sub?.Allow;
|
||||
var subDeny = nats.Sub?.Deny;
|
||||
|
||||
// Expand permission templates
|
||||
var name = userClaims.Name ?? "";
|
||||
var subject = userClaims.Subject ?? "";
|
||||
var acctName = accountClaims.Name ?? "";
|
||||
var acctSubject = accountClaims.Subject ?? "";
|
||||
var userTags = nats.Tags ?? [];
|
||||
var acctTags = accountClaims.Nats?.Tags ?? [];
|
||||
|
||||
if (pubAllow is { Length: > 0 })
|
||||
pubAllow = PermissionTemplates.ExpandAll(pubAllow, name, subject, acctName, acctSubject, userTags, acctTags).ToArray();
|
||||
if (pubDeny is { Length: > 0 })
|
||||
pubDeny = PermissionTemplates.ExpandAll(pubDeny, name, subject, acctName, acctSubject, userTags, acctTags).ToArray();
|
||||
if (subAllow is { Length: > 0 })
|
||||
subAllow = PermissionTemplates.ExpandAll(subAllow, name, subject, acctName, acctSubject, userTags, acctTags).ToArray();
|
||||
if (subDeny is { Length: > 0 })
|
||||
subDeny = PermissionTemplates.ExpandAll(subDeny, name, subject, acctName, acctSubject, userTags, acctTags).ToArray();
|
||||
|
||||
if (pubAllow is not null || pubDeny is not null || subAllow is not null || subDeny is not null)
|
||||
{
|
||||
permissions = new Permissions
|
||||
{
|
||||
Publish = (pubAllow is not null || pubDeny is not null)
|
||||
? new SubjectPermission { Allow = pubAllow, Deny = pubDeny }
|
||||
: null,
|
||||
Subscribe = (subAllow is not null || subDeny is not null)
|
||||
? new SubjectPermission { Allow = subAllow, Deny = subDeny }
|
||||
: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 9. Build result
|
||||
return new AuthResult
|
||||
{
|
||||
Identity = userClaims.Subject ?? "",
|
||||
AccountName = issuerAccount,
|
||||
Permissions = permissions,
|
||||
Expiry = userClaims.GetExpiry(),
|
||||
};
|
||||
}
|
||||
|
||||
private bool IsTrusted(string? issuer)
|
||||
{
|
||||
if (string.IsNullOrEmpty(issuer)) return false;
|
||||
foreach (var key in _trustedKeys)
|
||||
{
|
||||
if (key == issuer)
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ public enum ClientFlags
|
||||
WriteLoopStarted = 1 << 4,
|
||||
IsSlowConsumer = 1 << 5,
|
||||
ConnectProcessFinished = 1 << 6,
|
||||
TraceMode = 1 << 7,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Security.Authentication;
|
||||
using NATS.Server.Auth;
|
||||
using NATS.Server.Tls;
|
||||
|
||||
namespace NATS.Server;
|
||||
|
||||
@@ -85,5 +86,19 @@ public sealed class NatsOptions
|
||||
public HashSet<string>? TlsPinnedCerts { get; set; }
|
||||
public SslProtocols TlsMinVersion { get; set; } = SslProtocols.Tls12;
|
||||
|
||||
// OCSP stapling and peer verification
|
||||
public OcspConfig? OcspConfig { get; set; }
|
||||
public bool OcspPeerVerify { get; set; }
|
||||
|
||||
// JWT / Operator mode
|
||||
public string[]? TrustedKeys { get; set; }
|
||||
public Auth.Jwt.IAccountResolver? AccountResolver { get; set; }
|
||||
|
||||
// Per-subsystem log level overrides (namespace -> level)
|
||||
public Dictionary<string, string>? LogOverrides { get; set; }
|
||||
|
||||
// Subject mapping / transforms (source pattern -> destination template)
|
||||
public Dictionary<string, string>? SubjectMappings { get; set; }
|
||||
|
||||
public bool HasTls => TlsCert != null && TlsKey != null;
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
|
||||
private readonly Account _systemAccount;
|
||||
private readonly SslServerAuthenticationOptions? _sslOptions;
|
||||
private readonly TlsRateLimiter? _tlsRateLimiter;
|
||||
private readonly SubjectTransform[] _subjectTransforms;
|
||||
private Socket? _listener;
|
||||
private MonitorServer? _monitorServer;
|
||||
private ulong _nextClientId;
|
||||
@@ -276,6 +277,19 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
|
||||
if (options.HasTls)
|
||||
{
|
||||
_sslOptions = TlsHelper.BuildServerAuthOptions(options);
|
||||
|
||||
// OCSP stapling: build a certificate context so the runtime can
|
||||
// fetch and cache a fresh OCSP response and staple it during the
|
||||
// TLS handshake. offline:false tells the runtime to contact the
|
||||
// OCSP responder; if the responder is unreachable we fall back to
|
||||
// no stapling rather than refusing all connections.
|
||||
var certContext = TlsHelper.BuildCertificateContext(options, offline: false);
|
||||
if (certContext != null)
|
||||
{
|
||||
_sslOptions.ServerCertificateContext = certContext;
|
||||
_logger.LogInformation("OCSP stapling enabled (mode: {OcspMode})", options.OcspConfig!.Mode);
|
||||
}
|
||||
|
||||
_serverInfo.TlsRequired = !options.AllowNonTls;
|
||||
_serverInfo.TlsAvailable = options.AllowNonTls;
|
||||
_serverInfo.TlsVerify = options.TlsVerify;
|
||||
@@ -284,6 +298,27 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
|
||||
_tlsRateLimiter = new TlsRateLimiter(options.TlsRateLimit);
|
||||
}
|
||||
|
||||
// Compile subject transforms
|
||||
if (options.SubjectMappings is { Count: > 0 })
|
||||
{
|
||||
var transforms = new List<SubjectTransform>();
|
||||
foreach (var (source, dest) in options.SubjectMappings)
|
||||
{
|
||||
var t = SubjectTransform.Create(source, dest);
|
||||
if (t != null)
|
||||
transforms.Add(t);
|
||||
else
|
||||
_logger.LogWarning("Invalid subject mapping: {Source} -> {Dest}", source, dest);
|
||||
}
|
||||
_subjectTransforms = transforms.ToArray();
|
||||
if (_subjectTransforms.Length > 0)
|
||||
_logger.LogInformation("Compiled {Count} subject transform(s)", _subjectTransforms.Length);
|
||||
}
|
||||
else
|
||||
{
|
||||
_subjectTransforms = [];
|
||||
}
|
||||
|
||||
BuildCachedInfo();
|
||||
}
|
||||
|
||||
@@ -499,6 +534,20 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
|
||||
public void ProcessMessage(string subject, string? replyTo, ReadOnlyMemory<byte> headers,
|
||||
ReadOnlyMemory<byte> payload, NatsClient sender)
|
||||
{
|
||||
// Apply subject transforms
|
||||
if (_subjectTransforms.Length > 0)
|
||||
{
|
||||
foreach (var transform in _subjectTransforms)
|
||||
{
|
||||
var mapped = transform.Apply(subject);
|
||||
if (mapped != null)
|
||||
{
|
||||
subject = mapped;
|
||||
break; // First matching transform wins
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var subList = sender.Account?.SubList ?? _globalAccount.SubList;
|
||||
var result = subList.Match(subject);
|
||||
var delivered = false;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -134,4 +134,7 @@ public sealed class ClientOptions
|
||||
|
||||
[JsonPropertyName("sig")]
|
||||
public string? Sig { get; set; }
|
||||
|
||||
[JsonPropertyName("jwt")]
|
||||
public string? JWT { get; set; }
|
||||
}
|
||||
|
||||
708
src/NATS.Server/Subscriptions/SubjectTransform.cs
Normal file
708
src/NATS.Server/Subscriptions/SubjectTransform.cs
Normal file
@@ -0,0 +1,708 @@
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace NATS.Server.Subscriptions;
|
||||
|
||||
/// <summary>
|
||||
/// Compiled subject transform engine that maps subjects from a source pattern to a destination template.
|
||||
/// Reference: Go server/subject_transform.go
|
||||
/// </summary>
|
||||
public sealed partial class SubjectTransform
|
||||
{
|
||||
private readonly string _source;
|
||||
private readonly string _dest;
|
||||
private readonly string[] _sourceTokens;
|
||||
private readonly string[] _destTokens;
|
||||
private readonly TransformOp[] _ops;
|
||||
|
||||
private SubjectTransform(string source, string dest, string[] sourceTokens, string[] destTokens, TransformOp[] ops)
|
||||
{
|
||||
_source = source;
|
||||
_dest = dest;
|
||||
_sourceTokens = sourceTokens;
|
||||
_destTokens = destTokens;
|
||||
_ops = ops;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compiles a subject transform from source pattern to destination template.
|
||||
/// Returns null if source is invalid or destination references out-of-range wildcards.
|
||||
/// </summary>
|
||||
public static SubjectTransform? Create(string source, string destination)
|
||||
{
|
||||
if (string.IsNullOrEmpty(destination))
|
||||
return null;
|
||||
|
||||
if (string.IsNullOrEmpty(source))
|
||||
source = ">";
|
||||
|
||||
// Validate source and destination as subjects
|
||||
var (srcValid, srcTokens, srcPwcCount, srcHasFwc) = SubjectInfo(source);
|
||||
var (destValid, destTokens, destPwcCount, destHasFwc) = SubjectInfo(destination);
|
||||
|
||||
// Both must be valid, dest must have no pwcs, fwc must match
|
||||
if (!srcValid || !destValid || destPwcCount > 0 || srcHasFwc != destHasFwc)
|
||||
return null;
|
||||
|
||||
var ops = new TransformOp[destTokens.Length];
|
||||
|
||||
if (srcPwcCount > 0 || srcHasFwc)
|
||||
{
|
||||
// Build map from 1-based wildcard index to source token position
|
||||
var wildcardPositions = new Dictionary<int, int>();
|
||||
int wildcardNum = 0;
|
||||
for (int i = 0; i < srcTokens.Length; i++)
|
||||
{
|
||||
if (srcTokens[i] == "*")
|
||||
{
|
||||
wildcardNum++;
|
||||
wildcardPositions[wildcardNum] = i;
|
||||
}
|
||||
}
|
||||
|
||||
for (int i = 0; i < destTokens.Length; i++)
|
||||
{
|
||||
var parsed = ParseDestToken(destTokens[i]);
|
||||
if (parsed == null)
|
||||
return null; // Parse error (bad function, etc.)
|
||||
|
||||
if (parsed.Type == TransformType.None)
|
||||
{
|
||||
ops[i] = new TransformOp(TransformType.None);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Resolve wildcard indexes to source token positions
|
||||
var srcPositions = new int[parsed.WildcardIndexes.Length];
|
||||
for (int j = 0; j < parsed.WildcardIndexes.Length; j++)
|
||||
{
|
||||
int wcIdx = parsed.WildcardIndexes[j];
|
||||
if (wcIdx > srcPwcCount)
|
||||
return null; // Out of range
|
||||
|
||||
// Match Go behavior: missing map key returns zero-value (0)
|
||||
// This happens for partition with index 0, which Go silently allows.
|
||||
if (!wildcardPositions.TryGetValue(wcIdx, out int pos))
|
||||
pos = 0;
|
||||
|
||||
srcPositions[j] = pos;
|
||||
}
|
||||
|
||||
ops[i] = new TransformOp(parsed.Type, srcPositions, parsed.IntArg, parsed.StringArg);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// No wildcards in source: only NoTransform, Partition, and Random allowed
|
||||
for (int i = 0; i < destTokens.Length; i++)
|
||||
{
|
||||
var parsed = ParseDestToken(destTokens[i]);
|
||||
if (parsed == null)
|
||||
return null;
|
||||
|
||||
if (parsed.Type == TransformType.None)
|
||||
{
|
||||
ops[i] = new TransformOp(TransformType.None);
|
||||
}
|
||||
else if (parsed.Type == TransformType.Partition)
|
||||
{
|
||||
ops[i] = new TransformOp(TransformType.Partition, [], parsed.IntArg, parsed.StringArg);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Other functions not allowed without wildcards in source
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new SubjectTransform(source, destination, srcTokens, destTokens, ops);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Matches subject against source pattern, captures wildcard values, evaluates destination template.
|
||||
/// Returns null if subject doesn't match source.
|
||||
/// </summary>
|
||||
public string? Apply(string subject)
|
||||
{
|
||||
if (string.IsNullOrEmpty(subject))
|
||||
return null;
|
||||
|
||||
// Special case: source is > (match everything) and dest is > (passthrough)
|
||||
if ((_source == ">" || _source == string.Empty) && (_dest == ">" || _dest == string.Empty))
|
||||
return subject;
|
||||
|
||||
var subjectTokens = subject.Split('.');
|
||||
|
||||
// Check if subject matches source pattern
|
||||
if (_source != ">" && !MatchTokens(subjectTokens, _sourceTokens))
|
||||
return null;
|
||||
|
||||
return TransformTokenized(subjectTokens);
|
||||
}
|
||||
|
||||
private string TransformTokenized(string[] tokens)
|
||||
{
|
||||
if (_ops.Length == 0)
|
||||
return _dest;
|
||||
|
||||
var sb = new StringBuilder();
|
||||
int lastIndex = _ops.Length - 1;
|
||||
|
||||
for (int i = 0; i < _ops.Length; i++)
|
||||
{
|
||||
var op = _ops[i];
|
||||
|
||||
if (op.Type == TransformType.None)
|
||||
{
|
||||
// If this dest token is fwc, break out to handle trailing tokens
|
||||
if (_destTokens[i] == ">")
|
||||
break;
|
||||
|
||||
sb.Append(_destTokens[i]);
|
||||
}
|
||||
else
|
||||
{
|
||||
switch (op.Type)
|
||||
{
|
||||
case TransformType.Wildcard:
|
||||
if (op.SourcePositions.Length > 0 && op.SourcePositions[0] < tokens.Length)
|
||||
sb.Append(tokens[op.SourcePositions[0]]);
|
||||
break;
|
||||
|
||||
case TransformType.Partition:
|
||||
sb.Append(ComputePartition(tokens, op));
|
||||
break;
|
||||
|
||||
case TransformType.Split:
|
||||
ApplySplit(sb, tokens, op);
|
||||
break;
|
||||
|
||||
case TransformType.SplitFromLeft:
|
||||
ApplySplitFromLeft(sb, tokens, op);
|
||||
break;
|
||||
|
||||
case TransformType.SplitFromRight:
|
||||
ApplySplitFromRight(sb, tokens, op);
|
||||
break;
|
||||
|
||||
case TransformType.SliceFromLeft:
|
||||
ApplySliceFromLeft(sb, tokens, op);
|
||||
break;
|
||||
|
||||
case TransformType.SliceFromRight:
|
||||
ApplySliceFromRight(sb, tokens, op);
|
||||
break;
|
||||
|
||||
case TransformType.Left:
|
||||
ApplyLeft(sb, tokens, op);
|
||||
break;
|
||||
|
||||
case TransformType.Right:
|
||||
ApplyRight(sb, tokens, op);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (i < lastIndex)
|
||||
sb.Append('.');
|
||||
}
|
||||
|
||||
// Handle trailing fwc: append remaining tokens from subject
|
||||
if (_destTokens[^1] == ">")
|
||||
{
|
||||
int srcFwcPos = _sourceTokens.Length - 1; // position of > in source
|
||||
for (int i = srcFwcPos; i < tokens.Length; i++)
|
||||
{
|
||||
sb.Append(tokens[i]);
|
||||
if (i < tokens.Length - 1)
|
||||
sb.Append('.');
|
||||
}
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string ComputePartition(string[] tokens, TransformOp op)
|
||||
{
|
||||
int numBuckets = op.IntArg;
|
||||
if (numBuckets == 0)
|
||||
return "0";
|
||||
|
||||
byte[] keyBytes;
|
||||
if (op.SourcePositions.Length > 0)
|
||||
{
|
||||
// Hash concatenation of specified source tokens
|
||||
var keyBuilder = new StringBuilder();
|
||||
foreach (int pos in op.SourcePositions)
|
||||
{
|
||||
if (pos < tokens.Length)
|
||||
keyBuilder.Append(tokens[pos]);
|
||||
}
|
||||
|
||||
keyBytes = Encoding.ASCII.GetBytes(keyBuilder.ToString());
|
||||
}
|
||||
else
|
||||
{
|
||||
// Hash full subject (all tokens joined with .)
|
||||
keyBytes = Encoding.ASCII.GetBytes(string.Join(".", tokens));
|
||||
}
|
||||
|
||||
uint hash = Fnv1A32(keyBytes);
|
||||
return (hash % (uint)numBuckets).ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// FNV-1a 32-bit hash. Offset basis: 2166136261, prime: 16777619.
|
||||
/// </summary>
|
||||
private static uint Fnv1A32(byte[] data)
|
||||
{
|
||||
const uint offsetBasis = 2166136261;
|
||||
const uint prime = 16777619;
|
||||
|
||||
uint hash = offsetBasis;
|
||||
foreach (byte b in data)
|
||||
{
|
||||
hash ^= b;
|
||||
hash *= prime;
|
||||
}
|
||||
|
||||
return hash;
|
||||
}
|
||||
|
||||
private static void ApplySplit(StringBuilder sb, string[] tokens, TransformOp op)
|
||||
{
|
||||
if (op.SourcePositions.Length == 0)
|
||||
return;
|
||||
|
||||
string sourceToken = tokens[op.SourcePositions[0]];
|
||||
string delimiter = op.StringArg ?? string.Empty;
|
||||
|
||||
var splits = sourceToken.Split(delimiter);
|
||||
bool first = true;
|
||||
|
||||
for (int j = 0; j < splits.Length; j++)
|
||||
{
|
||||
string split = splits[j];
|
||||
if (split != string.Empty)
|
||||
{
|
||||
if (!first)
|
||||
sb.Append('.');
|
||||
sb.Append(split);
|
||||
first = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void ApplySplitFromLeft(StringBuilder sb, string[] tokens, TransformOp op)
|
||||
{
|
||||
string sourceToken = tokens[op.SourcePositions[0]];
|
||||
int position = op.IntArg;
|
||||
|
||||
if (position > 0 && position < sourceToken.Length)
|
||||
{
|
||||
sb.Append(sourceToken.AsSpan(0, position));
|
||||
sb.Append('.');
|
||||
sb.Append(sourceToken.AsSpan(position));
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.Append(sourceToken);
|
||||
}
|
||||
}
|
||||
|
||||
private static void ApplySplitFromRight(StringBuilder sb, string[] tokens, TransformOp op)
|
||||
{
|
||||
string sourceToken = tokens[op.SourcePositions[0]];
|
||||
int position = op.IntArg;
|
||||
|
||||
if (position > 0 && position < sourceToken.Length)
|
||||
{
|
||||
sb.Append(sourceToken.AsSpan(0, sourceToken.Length - position));
|
||||
sb.Append('.');
|
||||
sb.Append(sourceToken.AsSpan(sourceToken.Length - position));
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.Append(sourceToken);
|
||||
}
|
||||
}
|
||||
|
||||
private static void ApplySliceFromLeft(StringBuilder sb, string[] tokens, TransformOp op)
|
||||
{
|
||||
string sourceToken = tokens[op.SourcePositions[0]];
|
||||
int sliceSize = op.IntArg;
|
||||
|
||||
if (sliceSize > 0 && sliceSize < sourceToken.Length)
|
||||
{
|
||||
for (int i = 0; i + sliceSize <= sourceToken.Length; i += sliceSize)
|
||||
{
|
||||
if (i != 0)
|
||||
sb.Append('.');
|
||||
|
||||
sb.Append(sourceToken.AsSpan(i, sliceSize));
|
||||
|
||||
// If there's a remainder that doesn't fill a full slice
|
||||
if (i + sliceSize != sourceToken.Length && i + sliceSize + sliceSize > sourceToken.Length)
|
||||
{
|
||||
sb.Append('.');
|
||||
sb.Append(sourceToken.AsSpan(i + sliceSize));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.Append(sourceToken);
|
||||
}
|
||||
}
|
||||
|
||||
private static void ApplySliceFromRight(StringBuilder sb, string[] tokens, TransformOp op)
|
||||
{
|
||||
string sourceToken = tokens[op.SourcePositions[0]];
|
||||
int sliceSize = op.IntArg;
|
||||
|
||||
if (sliceSize > 0 && sliceSize < sourceToken.Length)
|
||||
{
|
||||
int remainder = sourceToken.Length % sliceSize;
|
||||
if (remainder > 0)
|
||||
{
|
||||
sb.Append(sourceToken.AsSpan(0, remainder));
|
||||
sb.Append('.');
|
||||
}
|
||||
|
||||
for (int i = remainder; i + sliceSize <= sourceToken.Length; i += sliceSize)
|
||||
{
|
||||
sb.Append(sourceToken.AsSpan(i, sliceSize));
|
||||
if (i + sliceSize < sourceToken.Length)
|
||||
sb.Append('.');
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.Append(sourceToken);
|
||||
}
|
||||
}
|
||||
|
||||
private static void ApplyLeft(StringBuilder sb, string[] tokens, TransformOp op)
|
||||
{
|
||||
string sourceToken = tokens[op.SourcePositions[0]];
|
||||
int length = op.IntArg;
|
||||
|
||||
if (length > 0 && length < sourceToken.Length)
|
||||
{
|
||||
sb.Append(sourceToken.AsSpan(0, length));
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.Append(sourceToken);
|
||||
}
|
||||
}
|
||||
|
||||
private static void ApplyRight(StringBuilder sb, string[] tokens, TransformOp op)
|
||||
{
|
||||
string sourceToken = tokens[op.SourcePositions[0]];
|
||||
int length = op.IntArg;
|
||||
|
||||
if (length > 0 && length < sourceToken.Length)
|
||||
{
|
||||
sb.Append(sourceToken.AsSpan(sourceToken.Length - length));
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.Append(sourceToken);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Matches literal subject tokens against a pattern with wildcards.
|
||||
/// Subject tokens must be literal (no wildcards).
|
||||
/// </summary>
|
||||
private static bool MatchTokens(string[] subjectTokens, string[] patternTokens)
|
||||
{
|
||||
for (int i = 0; i < patternTokens.Length; i++)
|
||||
{
|
||||
if (i >= subjectTokens.Length)
|
||||
return false;
|
||||
|
||||
string pt = patternTokens[i];
|
||||
|
||||
// Full wildcard matches all remaining
|
||||
if (pt == ">")
|
||||
return true;
|
||||
|
||||
// Partial wildcard matches any single token
|
||||
if (pt == "*")
|
||||
continue;
|
||||
|
||||
// Literal comparison
|
||||
if (subjectTokens[i] != pt)
|
||||
return false;
|
||||
}
|
||||
|
||||
// Both must be exhausted (unless pattern ended with >)
|
||||
return subjectTokens.Length == patternTokens.Length;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates a subject and returns (valid, tokens, pwcCount, hasFwc).
|
||||
/// Reference: Go subject_transform.go subjectInfo()
|
||||
/// </summary>
|
||||
private static (bool Valid, string[] Tokens, int PwcCount, bool HasFwc) SubjectInfo(string subject)
|
||||
{
|
||||
if (string.IsNullOrEmpty(subject))
|
||||
return (false, [], 0, false);
|
||||
|
||||
string[] tokens = subject.Split('.');
|
||||
int pwcCount = 0;
|
||||
bool hasFwc = false;
|
||||
|
||||
foreach (string t in tokens)
|
||||
{
|
||||
if (t.Length == 0 || hasFwc)
|
||||
return (false, [], 0, false);
|
||||
|
||||
if (t.Length == 1)
|
||||
{
|
||||
switch (t[0])
|
||||
{
|
||||
case '>':
|
||||
hasFwc = true;
|
||||
break;
|
||||
case '*':
|
||||
pwcCount++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (true, tokens, pwcCount, hasFwc);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses a single destination token into a transform operation descriptor.
|
||||
/// Returns null on parse error.
|
||||
/// </summary>
|
||||
private static ParsedToken? ParseDestToken(string token)
|
||||
{
|
||||
if (token.Length <= 1)
|
||||
return new ParsedToken(TransformType.None, [], -1, string.Empty);
|
||||
|
||||
// $N shorthand for wildcard(N)
|
||||
if (token[0] == '$')
|
||||
{
|
||||
if (int.TryParse(token.AsSpan(1), out int idx))
|
||||
return new ParsedToken(TransformType.Wildcard, [idx], -1, string.Empty);
|
||||
|
||||
// Other things rely on tokens starting with $ so not an error
|
||||
return new ParsedToken(TransformType.None, [], -1, string.Empty);
|
||||
}
|
||||
|
||||
// Mustache-style {{function(args)}}
|
||||
if (token.Length > 4 && token[0] == '{' && token[1] == '{' && token[^2] == '}' && token[^1] == '}')
|
||||
{
|
||||
return ParseMustacheToken(token);
|
||||
}
|
||||
|
||||
return new ParsedToken(TransformType.None, [], -1, string.Empty);
|
||||
}
|
||||
|
||||
private static ParsedToken? ParseMustacheToken(string token)
|
||||
{
|
||||
// wildcard(n)
|
||||
var args = GetFunctionArgs(WildcardRegex(), token);
|
||||
if (args != null)
|
||||
{
|
||||
if (args.Length == 1 && args[0] == string.Empty)
|
||||
return null; // Not enough args
|
||||
|
||||
if (args.Length == 1)
|
||||
{
|
||||
if (!int.TryParse(args[0].Trim(), out int idx))
|
||||
return null;
|
||||
return new ParsedToken(TransformType.Wildcard, [idx], -1, string.Empty);
|
||||
}
|
||||
|
||||
return null; // Too many args
|
||||
}
|
||||
|
||||
// partition(num, tokens...)
|
||||
args = GetFunctionArgs(PartitionRegex(), token);
|
||||
if (args != null)
|
||||
{
|
||||
if (args.Length < 1)
|
||||
return null;
|
||||
|
||||
if (args.Length == 1)
|
||||
{
|
||||
if (!TryParseInt32(args[0].Trim(), out int numBuckets))
|
||||
return null;
|
||||
return new ParsedToken(TransformType.Partition, [], numBuckets, string.Empty);
|
||||
}
|
||||
|
||||
// partition(num, tok1, tok2, ...)
|
||||
if (!TryParseInt32(args[0].Trim(), out int buckets))
|
||||
return null;
|
||||
|
||||
var indexes = new int[args.Length - 1];
|
||||
for (int i = 1; i < args.Length; i++)
|
||||
{
|
||||
if (!int.TryParse(args[i].Trim(), out indexes[i - 1]))
|
||||
return null;
|
||||
}
|
||||
|
||||
return new ParsedToken(TransformType.Partition, indexes, buckets, string.Empty);
|
||||
}
|
||||
|
||||
// splitFromLeft(token, position)
|
||||
args = GetFunctionArgs(SplitFromLeftRegex(), token);
|
||||
if (args != null)
|
||||
return ParseIndexIntArgs(args, TransformType.SplitFromLeft);
|
||||
|
||||
// splitFromRight(token, position)
|
||||
args = GetFunctionArgs(SplitFromRightRegex(), token);
|
||||
if (args != null)
|
||||
return ParseIndexIntArgs(args, TransformType.SplitFromRight);
|
||||
|
||||
// sliceFromLeft(token, size)
|
||||
args = GetFunctionArgs(SliceFromLeftRegex(), token);
|
||||
if (args != null)
|
||||
return ParseIndexIntArgs(args, TransformType.SliceFromLeft);
|
||||
|
||||
// sliceFromRight(token, size)
|
||||
args = GetFunctionArgs(SliceFromRightRegex(), token);
|
||||
if (args != null)
|
||||
return ParseIndexIntArgs(args, TransformType.SliceFromRight);
|
||||
|
||||
// right(token, length)
|
||||
args = GetFunctionArgs(RightRegex(), token);
|
||||
if (args != null)
|
||||
return ParseIndexIntArgs(args, TransformType.Right);
|
||||
|
||||
// left(token, length)
|
||||
args = GetFunctionArgs(LeftRegex(), token);
|
||||
if (args != null)
|
||||
return ParseIndexIntArgs(args, TransformType.Left);
|
||||
|
||||
// split(token, delimiter)
|
||||
args = GetFunctionArgs(SplitRegex(), token);
|
||||
if (args != null)
|
||||
{
|
||||
if (args.Length < 2)
|
||||
return null;
|
||||
if (args.Length > 2)
|
||||
return null;
|
||||
|
||||
if (!int.TryParse(args[0].Trim(), out int idx))
|
||||
return null;
|
||||
|
||||
string delimiter = args[1];
|
||||
if (delimiter.Contains(' ') || delimiter.Contains('.'))
|
||||
return null;
|
||||
|
||||
return new ParsedToken(TransformType.Split, [idx], -1, delimiter);
|
||||
}
|
||||
|
||||
// Unknown function
|
||||
return null;
|
||||
}
|
||||
|
||||
private static ParsedToken? ParseIndexIntArgs(string[] args, TransformType type)
|
||||
{
|
||||
if (args.Length < 2)
|
||||
return null;
|
||||
if (args.Length > 2)
|
||||
return null;
|
||||
|
||||
if (!int.TryParse(args[0].Trim(), out int idx))
|
||||
return null;
|
||||
|
||||
if (!TryParseInt32(args[1].Trim(), out int intArg))
|
||||
return null;
|
||||
|
||||
return new ParsedToken(type, [idx], intArg, string.Empty);
|
||||
}
|
||||
|
||||
private static bool TryParseInt32(string s, out int result)
|
||||
{
|
||||
// Parse as long first to detect overflow
|
||||
if (long.TryParse(s, out long longVal) && longVal >= 0 && longVal <= int.MaxValue)
|
||||
{
|
||||
result = (int)longVal;
|
||||
return true;
|
||||
}
|
||||
|
||||
result = -1;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string[]? GetFunctionArgs(Regex regex, string token)
|
||||
{
|
||||
var match = regex.Match(token);
|
||||
if (match.Success && match.Groups.Count > 1)
|
||||
{
|
||||
string argsStr = match.Groups[1].Value;
|
||||
return CommaSeparatorRegex().Split(argsStr);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Regex patterns matching the Go reference implementation (case-insensitive function names)
|
||||
[GeneratedRegex(@"\{\{\s*[wW]ildcard\s*\((.*)\)\s*\}\}")]
|
||||
private static partial Regex WildcardRegex();
|
||||
|
||||
[GeneratedRegex(@"\{\{\s*[pP]artition\s*\((.*)\)\s*\}\}")]
|
||||
private static partial Regex PartitionRegex();
|
||||
|
||||
[GeneratedRegex(@"\{\{\s*[sS]plit[fF]rom[lL]eft\s*\((.*)\)\s*\}\}")]
|
||||
private static partial Regex SplitFromLeftRegex();
|
||||
|
||||
[GeneratedRegex(@"\{\{\s*[sS]plit[fF]rom[rR]ight\s*\((.*)\)\s*\}\}")]
|
||||
private static partial Regex SplitFromRightRegex();
|
||||
|
||||
[GeneratedRegex(@"\{\{\s*[sS]lice[fF]rom[lL]eft\s*\((.*)\)\s*\}\}")]
|
||||
private static partial Regex SliceFromLeftRegex();
|
||||
|
||||
[GeneratedRegex(@"\{\{\s*[sS]lice[fF]rom[rR]ight\s*\((.*)\)\s*\}\}")]
|
||||
private static partial Regex SliceFromRightRegex();
|
||||
|
||||
[GeneratedRegex(@"\{\{\s*[sS]plit\s*\((.*)\)\s*\}\}")]
|
||||
private static partial Regex SplitRegex();
|
||||
|
||||
[GeneratedRegex(@"\{\{\s*[lL]eft\s*\((.*)\)\s*\}\}")]
|
||||
private static partial Regex LeftRegex();
|
||||
|
||||
[GeneratedRegex(@"\{\{\s*[rR]ight\s*\((.*)\)\s*\}\}")]
|
||||
private static partial Regex RightRegex();
|
||||
|
||||
[GeneratedRegex(@",\s*")]
|
||||
private static partial Regex CommaSeparatorRegex();
|
||||
|
||||
private enum TransformType
|
||||
{
|
||||
None,
|
||||
Wildcard,
|
||||
Partition,
|
||||
Split,
|
||||
SplitFromLeft,
|
||||
SplitFromRight,
|
||||
SliceFromLeft,
|
||||
SliceFromRight,
|
||||
Left,
|
||||
Right,
|
||||
}
|
||||
|
||||
private sealed record ParsedToken(TransformType Type, int[] WildcardIndexes, int IntArg, string StringArg);
|
||||
|
||||
private readonly record struct TransformOp(
|
||||
TransformType Type,
|
||||
int[] SourcePositions,
|
||||
int IntArg,
|
||||
string? StringArg)
|
||||
{
|
||||
public TransformOp(TransformType type) : this(type, [], -1, null)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
20
src/NATS.Server/Tls/OcspConfig.cs
Normal file
20
src/NATS.Server/Tls/OcspConfig.cs
Normal file
@@ -0,0 +1,20 @@
|
||||
namespace NATS.Server.Tls;
|
||||
|
||||
// OcspMode mirrors the OCSPMode constants from the Go reference implementation (ocsp.go).
|
||||
// Auto — staple only if the certificate contains the status_request TLS extension.
|
||||
// Always — always attempt stapling; warn but continue if the OCSP response cannot be obtained.
|
||||
// Must — stapling is mandatory; fail server startup if the OCSP response cannot be obtained.
|
||||
// Never — never attempt stapling regardless of certificate extensions.
|
||||
public enum OcspMode
|
||||
{
|
||||
Auto = 0,
|
||||
Always = 1,
|
||||
Must = 2,
|
||||
Never = 3,
|
||||
}
|
||||
|
||||
public sealed class OcspConfig
|
||||
{
|
||||
public OcspMode Mode { get; init; } = OcspMode.Auto;
|
||||
public string[] OverrideUrls { get; init; } = [];
|
||||
}
|
||||
@@ -33,6 +33,10 @@ public static class TlsHelper
|
||||
|
||||
if (opts.TlsVerify && opts.TlsCaCert != null)
|
||||
{
|
||||
var revocationMode = opts.OcspPeerVerify
|
||||
? X509RevocationMode.Online
|
||||
: X509RevocationMode.NoCheck;
|
||||
|
||||
var caCerts = LoadCaCertificates(opts.TlsCaCert);
|
||||
authOpts.RemoteCertificateValidationCallback = (_, cert, chain, errors) =>
|
||||
{
|
||||
@@ -41,7 +45,19 @@ public static class TlsHelper
|
||||
chain2.ChainPolicy.TrustMode = X509ChainTrustMode.CustomRootTrust;
|
||||
foreach (var ca in caCerts)
|
||||
chain2.ChainPolicy.CustomTrustStore.Add(ca);
|
||||
chain2.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck;
|
||||
chain2.ChainPolicy.RevocationMode = revocationMode;
|
||||
var cert2 = cert as X509Certificate2 ?? X509CertificateLoader.LoadCertificate(cert.GetRawCertData());
|
||||
return chain2.Build(cert2);
|
||||
};
|
||||
}
|
||||
else if (opts.OcspPeerVerify)
|
||||
{
|
||||
// No custom CA — still enable online revocation checking against the system store
|
||||
authOpts.RemoteCertificateValidationCallback = (_, cert, chain, errors) =>
|
||||
{
|
||||
if (cert == null) return false;
|
||||
using var chain2 = new X509Chain();
|
||||
chain2.ChainPolicy.RevocationMode = X509RevocationMode.Online;
|
||||
var cert2 = cert as X509Certificate2 ?? X509CertificateLoader.LoadCertificate(cert.GetRawCertData());
|
||||
return chain2.Build(cert2);
|
||||
};
|
||||
@@ -50,6 +66,25 @@ public static class TlsHelper
|
||||
return authOpts;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds an <see cref="SslStreamCertificateContext"/> for OCSP stapling.
|
||||
/// Returns null when TLS is not configured or OCSP mode is Never.
|
||||
/// When <paramref name="offline"/> is false the runtime will contact the
|
||||
/// certificate's OCSP responder to obtain a fresh stapled response.
|
||||
/// </summary>
|
||||
public static SslStreamCertificateContext? BuildCertificateContext(NatsOptions opts, bool offline = false)
|
||||
{
|
||||
if (!opts.HasTls) return null;
|
||||
if (opts.OcspConfig is null || opts.OcspConfig.Mode == OcspMode.Never) return null;
|
||||
|
||||
var cert = LoadCertificate(opts.TlsCert!, opts.TlsKey);
|
||||
var chain = new X509Certificate2Collection();
|
||||
if (!string.IsNullOrEmpty(opts.TlsCaCert))
|
||||
chain.ImportFromPemFile(opts.TlsCaCert);
|
||||
|
||||
return SslStreamCertificateContext.Create(cert, chain, offline: offline);
|
||||
}
|
||||
|
||||
public static string GetCertificateHash(X509Certificate2 cert)
|
||||
{
|
||||
var spki = cert.PublicKey.ExportSubjectPublicKeyInfo();
|
||||
|
||||
68
tests/NATS.Server.Tests/AccountResolverTests.cs
Normal file
68
tests/NATS.Server.Tests/AccountResolverTests.cs
Normal file
@@ -0,0 +1,68 @@
|
||||
using NATS.Server.Auth.Jwt;
|
||||
|
||||
namespace NATS.Server.Tests;
|
||||
|
||||
public class AccountResolverTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Store_and_fetch_roundtrip()
|
||||
{
|
||||
var resolver = new MemAccountResolver();
|
||||
const string nkey = "AABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789ABCDEFGHIJKLMNOPQ";
|
||||
const string jwt = "eyJhbGciOiJlZDI1NTE5LW5rZXkiLCJ0eXAiOiJKV1QifQ.payload.sig";
|
||||
|
||||
await resolver.StoreAsync(nkey, jwt);
|
||||
var fetched = await resolver.FetchAsync(nkey);
|
||||
|
||||
fetched.ShouldBe(jwt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Fetch_unknown_key_returns_null()
|
||||
{
|
||||
var resolver = new MemAccountResolver();
|
||||
|
||||
var result = await resolver.FetchAsync("UNKNOWN_NKEY");
|
||||
|
||||
result.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Store_overwrites_existing_entry()
|
||||
{
|
||||
var resolver = new MemAccountResolver();
|
||||
const string nkey = "AABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789ABCDEFGHIJKLMNOPQ";
|
||||
const string originalJwt = "original.jwt.token";
|
||||
const string updatedJwt = "updated.jwt.token";
|
||||
|
||||
await resolver.StoreAsync(nkey, originalJwt);
|
||||
await resolver.StoreAsync(nkey, updatedJwt);
|
||||
var fetched = await resolver.FetchAsync(nkey);
|
||||
|
||||
fetched.ShouldBe(updatedJwt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsReadOnly_returns_false()
|
||||
{
|
||||
IAccountResolver resolver = new MemAccountResolver();
|
||||
|
||||
resolver.IsReadOnly.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Multiple_accounts_are_stored_independently()
|
||||
{
|
||||
var resolver = new MemAccountResolver();
|
||||
const string nkey1 = "AABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789ABCDEFGHIJKLMNOPQ1";
|
||||
const string nkey2 = "AABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789ABCDEFGHIJKLMNOPQ2";
|
||||
const string jwt1 = "jwt.for.account.one";
|
||||
const string jwt2 = "jwt.for.account.two";
|
||||
|
||||
await resolver.StoreAsync(nkey1, jwt1);
|
||||
await resolver.StoreAsync(nkey2, jwt2);
|
||||
|
||||
(await resolver.FetchAsync(nkey1)).ShouldBe(jwt1);
|
||||
(await resolver.FetchAsync(nkey2)).ShouldBe(jwt2);
|
||||
}
|
||||
}
|
||||
48
tests/NATS.Server.Tests/AccountStatsTests.cs
Normal file
48
tests/NATS.Server.Tests/AccountStatsTests.cs
Normal file
@@ -0,0 +1,48 @@
|
||||
using NATS.Server.Auth;
|
||||
|
||||
namespace NATS.Server.Tests;
|
||||
|
||||
public class AccountStatsTests
|
||||
{
|
||||
[Fact]
|
||||
public void Account_tracks_inbound_stats()
|
||||
{
|
||||
var account = new Account("test");
|
||||
account.IncrementInbound(1, 100);
|
||||
account.IncrementInbound(1, 200);
|
||||
account.InMsgs.ShouldBe(2);
|
||||
account.InBytes.ShouldBe(300);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Account_tracks_outbound_stats()
|
||||
{
|
||||
var account = new Account("test");
|
||||
account.IncrementOutbound(1, 50);
|
||||
account.IncrementOutbound(1, 75);
|
||||
account.OutMsgs.ShouldBe(2);
|
||||
account.OutBytes.ShouldBe(125);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Account_stats_start_at_zero()
|
||||
{
|
||||
var account = new Account("test");
|
||||
account.InMsgs.ShouldBe(0);
|
||||
account.OutMsgs.ShouldBe(0);
|
||||
account.InBytes.ShouldBe(0);
|
||||
account.OutBytes.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Account_stats_are_independent()
|
||||
{
|
||||
var account = new Account("test");
|
||||
account.IncrementInbound(5, 500);
|
||||
account.IncrementOutbound(3, 300);
|
||||
account.InMsgs.ShouldBe(5);
|
||||
account.OutMsgs.ShouldBe(3);
|
||||
account.InBytes.ShouldBe(500);
|
||||
account.OutBytes.ShouldBe(300);
|
||||
}
|
||||
}
|
||||
15
tests/NATS.Server.Tests/ClientTraceModeTests.cs
Normal file
15
tests/NATS.Server.Tests/ClientTraceModeTests.cs
Normal file
@@ -0,0 +1,15 @@
|
||||
namespace NATS.Server.Tests;
|
||||
|
||||
public class ClientTraceModeTests
|
||||
{
|
||||
[Fact]
|
||||
public void TraceMode_flag_can_be_set_and_cleared()
|
||||
{
|
||||
var holder = new ClientFlagHolder();
|
||||
holder.HasFlag(ClientFlags.TraceMode).ShouldBeFalse();
|
||||
holder.SetFlag(ClientFlags.TraceMode);
|
||||
holder.HasFlag(ClientFlags.TraceMode).ShouldBeTrue();
|
||||
holder.ClearFlag(ClientFlags.TraceMode);
|
||||
holder.HasFlag(ClientFlags.TraceMode).ShouldBeFalse();
|
||||
}
|
||||
}
|
||||
591
tests/NATS.Server.Tests/JwtAuthenticatorTests.cs
Normal file
591
tests/NATS.Server.Tests/JwtAuthenticatorTests.cs
Normal file
@@ -0,0 +1,591 @@
|
||||
using System.Text;
|
||||
using NATS.NKeys;
|
||||
using NATS.Server.Auth;
|
||||
using NATS.Server.Auth.Jwt;
|
||||
using NATS.Server.Protocol;
|
||||
|
||||
namespace NATS.Server.Tests;
|
||||
|
||||
public class JwtAuthenticatorTests
|
||||
{
|
||||
private static string Base64UrlEncode(string input) =>
|
||||
Base64UrlEncode(Encoding.UTF8.GetBytes(input));
|
||||
|
||||
private static string Base64UrlEncode(byte[] input) =>
|
||||
Convert.ToBase64String(input).TrimEnd('=').Replace('+', '-').Replace('/', '_');
|
||||
|
||||
private static string BuildSignedToken(string payloadJson, KeyPair signingKey)
|
||||
{
|
||||
var header = Base64UrlEncode("""{"typ":"JWT","alg":"ed25519-nkey"}""");
|
||||
var payload = Base64UrlEncode(payloadJson);
|
||||
var signingInput = Encoding.UTF8.GetBytes($"{header}.{payload}");
|
||||
var sig = new byte[64];
|
||||
signingKey.Sign(signingInput, sig);
|
||||
return $"{header}.{payload}.{Base64UrlEncode(sig)}";
|
||||
}
|
||||
|
||||
private static string SignNonce(KeyPair kp, byte[] nonce)
|
||||
{
|
||||
var sig = new byte[64];
|
||||
kp.Sign(nonce, sig);
|
||||
return Convert.ToBase64String(sig).TrimEnd('=').Replace('+', '-').Replace('/', '_');
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Valid_bearer_jwt_returns_auth_result()
|
||||
{
|
||||
var operatorKp = KeyPair.CreatePair(PrefixByte.Operator);
|
||||
var accountKp = KeyPair.CreatePair(PrefixByte.Account);
|
||||
var userKp = KeyPair.CreatePair(PrefixByte.User);
|
||||
|
||||
var operatorPub = operatorKp.GetPublicKey();
|
||||
var accountPub = accountKp.GetPublicKey();
|
||||
var userPub = userKp.GetPublicKey();
|
||||
|
||||
var accountPayload = $$"""
|
||||
{
|
||||
"sub":"{{accountPub}}",
|
||||
"iss":"{{operatorPub}}",
|
||||
"iat":1700000000,
|
||||
"nats":{"type":"account","version":2}
|
||||
}
|
||||
""";
|
||||
var accountJwt = BuildSignedToken(accountPayload, operatorKp);
|
||||
|
||||
var userPayload = $$"""
|
||||
{
|
||||
"sub":"{{userPub}}",
|
||||
"iss":"{{accountPub}}",
|
||||
"iat":1700000000,
|
||||
"nats":{
|
||||
"type":"user","version":2,
|
||||
"bearer_token":true,
|
||||
"issuer_account":"{{accountPub}}"
|
||||
}
|
||||
}
|
||||
""";
|
||||
var userJwt = BuildSignedToken(userPayload, accountKp);
|
||||
|
||||
var resolver = new MemAccountResolver();
|
||||
await resolver.StoreAsync(accountPub, accountJwt);
|
||||
|
||||
var auth = new JwtAuthenticator([operatorPub], resolver);
|
||||
|
||||
var ctx = new ClientAuthContext
|
||||
{
|
||||
Opts = new ClientOptions { JWT = userJwt },
|
||||
Nonce = "test-nonce"u8.ToArray(),
|
||||
};
|
||||
|
||||
var result = auth.Authenticate(ctx);
|
||||
|
||||
result.ShouldNotBeNull();
|
||||
result.Identity.ShouldBe(userPub);
|
||||
result.AccountName.ShouldBe(accountPub);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Valid_jwt_with_nonce_signature_returns_auth_result()
|
||||
{
|
||||
var operatorKp = KeyPair.CreatePair(PrefixByte.Operator);
|
||||
var accountKp = KeyPair.CreatePair(PrefixByte.Account);
|
||||
var userKp = KeyPair.CreatePair(PrefixByte.User);
|
||||
|
||||
var operatorPub = operatorKp.GetPublicKey();
|
||||
var accountPub = accountKp.GetPublicKey();
|
||||
var userPub = userKp.GetPublicKey();
|
||||
|
||||
var accountPayload = $$"""
|
||||
{
|
||||
"sub":"{{accountPub}}",
|
||||
"iss":"{{operatorPub}}",
|
||||
"iat":1700000000,
|
||||
"nats":{"type":"account","version":2}
|
||||
}
|
||||
""";
|
||||
var accountJwt = BuildSignedToken(accountPayload, operatorKp);
|
||||
|
||||
var userPayload = $$"""
|
||||
{
|
||||
"sub":"{{userPub}}",
|
||||
"iss":"{{accountPub}}",
|
||||
"iat":1700000000,
|
||||
"nats":{
|
||||
"type":"user","version":2,
|
||||
"issuer_account":"{{accountPub}}"
|
||||
}
|
||||
}
|
||||
""";
|
||||
var userJwt = BuildSignedToken(userPayload, accountKp);
|
||||
|
||||
var resolver = new MemAccountResolver();
|
||||
await resolver.StoreAsync(accountPub, accountJwt);
|
||||
|
||||
var auth = new JwtAuthenticator([operatorPub], resolver);
|
||||
|
||||
var nonce = "test-nonce-data"u8.ToArray();
|
||||
var sig = SignNonce(userKp, nonce);
|
||||
|
||||
var ctx = new ClientAuthContext
|
||||
{
|
||||
Opts = new ClientOptions { JWT = userJwt, Nkey = userPub, Sig = sig },
|
||||
Nonce = nonce,
|
||||
};
|
||||
|
||||
var result = auth.Authenticate(ctx);
|
||||
|
||||
result.ShouldNotBeNull();
|
||||
result.Identity.ShouldBe(userPub);
|
||||
result.AccountName.ShouldBe(accountPub);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void No_jwt_returns_null()
|
||||
{
|
||||
var operatorKp = KeyPair.CreatePair(PrefixByte.Operator);
|
||||
var resolver = new MemAccountResolver();
|
||||
var auth = new JwtAuthenticator([operatorKp.GetPublicKey()], resolver);
|
||||
|
||||
var ctx = new ClientAuthContext
|
||||
{
|
||||
Opts = new ClientOptions(),
|
||||
Nonce = "nonce"u8.ToArray(),
|
||||
};
|
||||
|
||||
auth.Authenticate(ctx).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Non_jwt_string_returns_null()
|
||||
{
|
||||
var operatorKp = KeyPair.CreatePair(PrefixByte.Operator);
|
||||
var resolver = new MemAccountResolver();
|
||||
var auth = new JwtAuthenticator([operatorKp.GetPublicKey()], resolver);
|
||||
|
||||
var ctx = new ClientAuthContext
|
||||
{
|
||||
Opts = new ClientOptions { JWT = "not-a-jwt" },
|
||||
Nonce = "nonce"u8.ToArray(),
|
||||
};
|
||||
|
||||
auth.Authenticate(ctx).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Expired_jwt_returns_null()
|
||||
{
|
||||
var operatorKp = KeyPair.CreatePair(PrefixByte.Operator);
|
||||
var accountKp = KeyPair.CreatePair(PrefixByte.Account);
|
||||
var userKp = KeyPair.CreatePair(PrefixByte.User);
|
||||
|
||||
var operatorPub = operatorKp.GetPublicKey();
|
||||
var accountPub = accountKp.GetPublicKey();
|
||||
var userPub = userKp.GetPublicKey();
|
||||
|
||||
var accountPayload = $$"""
|
||||
{
|
||||
"sub":"{{accountPub}}",
|
||||
"iss":"{{operatorPub}}",
|
||||
"iat":1700000000,
|
||||
"nats":{"type":"account","version":2}
|
||||
}
|
||||
""";
|
||||
var accountJwt = BuildSignedToken(accountPayload, operatorKp);
|
||||
|
||||
// Expired in 2020
|
||||
var userPayload = $$"""
|
||||
{
|
||||
"sub":"{{userPub}}",
|
||||
"iss":"{{accountPub}}",
|
||||
"iat":1500000000,
|
||||
"exp":1600000000,
|
||||
"nats":{
|
||||
"type":"user","version":2,
|
||||
"bearer_token":true,
|
||||
"issuer_account":"{{accountPub}}"
|
||||
}
|
||||
}
|
||||
""";
|
||||
var userJwt = BuildSignedToken(userPayload, accountKp);
|
||||
|
||||
var resolver = new MemAccountResolver();
|
||||
await resolver.StoreAsync(accountPub, accountJwt);
|
||||
|
||||
var auth = new JwtAuthenticator([operatorPub], resolver);
|
||||
|
||||
var ctx = new ClientAuthContext
|
||||
{
|
||||
Opts = new ClientOptions { JWT = userJwt },
|
||||
Nonce = "nonce"u8.ToArray(),
|
||||
};
|
||||
|
||||
auth.Authenticate(ctx).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Revoked_user_returns_null()
|
||||
{
|
||||
var operatorKp = KeyPair.CreatePair(PrefixByte.Operator);
|
||||
var accountKp = KeyPair.CreatePair(PrefixByte.Account);
|
||||
var userKp = KeyPair.CreatePair(PrefixByte.User);
|
||||
|
||||
var operatorPub = operatorKp.GetPublicKey();
|
||||
var accountPub = accountKp.GetPublicKey();
|
||||
var userPub = userKp.GetPublicKey();
|
||||
|
||||
// Account JWT with revocation for user
|
||||
var accountPayload = $$"""
|
||||
{
|
||||
"sub":"{{accountPub}}",
|
||||
"iss":"{{operatorPub}}",
|
||||
"iat":1700000000,
|
||||
"nats":{
|
||||
"type":"account","version":2,
|
||||
"revocations":{
|
||||
"{{userPub}}":1700000001
|
||||
}
|
||||
}
|
||||
}
|
||||
""";
|
||||
var accountJwt = BuildSignedToken(accountPayload, operatorKp);
|
||||
|
||||
// User JWT issued at 1700000000 (before revocation time 1700000001)
|
||||
var userPayload = $$"""
|
||||
{
|
||||
"sub":"{{userPub}}",
|
||||
"iss":"{{accountPub}}",
|
||||
"iat":1700000000,
|
||||
"nats":{
|
||||
"type":"user","version":2,
|
||||
"bearer_token":true,
|
||||
"issuer_account":"{{accountPub}}"
|
||||
}
|
||||
}
|
||||
""";
|
||||
var userJwt = BuildSignedToken(userPayload, accountKp);
|
||||
|
||||
var resolver = new MemAccountResolver();
|
||||
await resolver.StoreAsync(accountPub, accountJwt);
|
||||
|
||||
var auth = new JwtAuthenticator([operatorPub], resolver);
|
||||
|
||||
var ctx = new ClientAuthContext
|
||||
{
|
||||
Opts = new ClientOptions { JWT = userJwt },
|
||||
Nonce = "nonce"u8.ToArray(),
|
||||
};
|
||||
|
||||
auth.Authenticate(ctx).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Untrusted_operator_returns_null()
|
||||
{
|
||||
var operatorKp = KeyPair.CreatePair(PrefixByte.Operator);
|
||||
var accountKp = KeyPair.CreatePair(PrefixByte.Account);
|
||||
var userKp = KeyPair.CreatePair(PrefixByte.User);
|
||||
|
||||
var operatorPub = operatorKp.GetPublicKey();
|
||||
var accountPub = accountKp.GetPublicKey();
|
||||
var userPub = userKp.GetPublicKey();
|
||||
|
||||
var accountPayload = $$"""
|
||||
{
|
||||
"sub":"{{accountPub}}",
|
||||
"iss":"{{operatorPub}}",
|
||||
"iat":1700000000,
|
||||
"nats":{"type":"account","version":2}
|
||||
}
|
||||
""";
|
||||
var accountJwt = BuildSignedToken(accountPayload, operatorKp);
|
||||
|
||||
var userPayload = $$"""
|
||||
{
|
||||
"sub":"{{userPub}}",
|
||||
"iss":"{{accountPub}}",
|
||||
"iat":1700000000,
|
||||
"nats":{
|
||||
"type":"user","version":2,
|
||||
"bearer_token":true,
|
||||
"issuer_account":"{{accountPub}}"
|
||||
}
|
||||
}
|
||||
""";
|
||||
var userJwt = BuildSignedToken(userPayload, accountKp);
|
||||
|
||||
var resolver = new MemAccountResolver();
|
||||
await resolver.StoreAsync(accountPub, accountJwt);
|
||||
|
||||
// Use a different trusted key that doesn't match the operator
|
||||
var otherOperator = KeyPair.CreatePair(PrefixByte.Operator).GetPublicKey();
|
||||
var auth = new JwtAuthenticator([otherOperator], resolver);
|
||||
|
||||
var ctx = new ClientAuthContext
|
||||
{
|
||||
Opts = new ClientOptions { JWT = userJwt },
|
||||
Nonce = "nonce"u8.ToArray(),
|
||||
};
|
||||
|
||||
auth.Authenticate(ctx).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Unknown_account_returns_null()
|
||||
{
|
||||
var operatorKp = KeyPair.CreatePair(PrefixByte.Operator);
|
||||
var accountKp = KeyPair.CreatePair(PrefixByte.Account);
|
||||
var userKp = KeyPair.CreatePair(PrefixByte.User);
|
||||
|
||||
var operatorPub = operatorKp.GetPublicKey();
|
||||
var accountPub = accountKp.GetPublicKey();
|
||||
var userPub = userKp.GetPublicKey();
|
||||
|
||||
var userPayload = $$"""
|
||||
{
|
||||
"sub":"{{userPub}}",
|
||||
"iss":"{{accountPub}}",
|
||||
"iat":1700000000,
|
||||
"nats":{
|
||||
"type":"user","version":2,
|
||||
"bearer_token":true,
|
||||
"issuer_account":"{{accountPub}}"
|
||||
}
|
||||
}
|
||||
""";
|
||||
var userJwt = BuildSignedToken(userPayload, accountKp);
|
||||
|
||||
// Don't store the account JWT in the resolver
|
||||
var resolver = new MemAccountResolver();
|
||||
var auth = new JwtAuthenticator([operatorPub], resolver);
|
||||
|
||||
var ctx = new ClientAuthContext
|
||||
{
|
||||
Opts = new ClientOptions { JWT = userJwt },
|
||||
Nonce = "nonce"u8.ToArray(),
|
||||
};
|
||||
|
||||
auth.Authenticate(ctx).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Non_bearer_without_sig_returns_null()
|
||||
{
|
||||
var operatorKp = KeyPair.CreatePair(PrefixByte.Operator);
|
||||
var accountKp = KeyPair.CreatePair(PrefixByte.Account);
|
||||
var userKp = KeyPair.CreatePair(PrefixByte.User);
|
||||
|
||||
var operatorPub = operatorKp.GetPublicKey();
|
||||
var accountPub = accountKp.GetPublicKey();
|
||||
var userPub = userKp.GetPublicKey();
|
||||
|
||||
var accountPayload = $$"""
|
||||
{
|
||||
"sub":"{{accountPub}}",
|
||||
"iss":"{{operatorPub}}",
|
||||
"iat":1700000000,
|
||||
"nats":{"type":"account","version":2}
|
||||
}
|
||||
""";
|
||||
var accountJwt = BuildSignedToken(accountPayload, operatorKp);
|
||||
|
||||
// Non-bearer user JWT
|
||||
var userPayload = $$"""
|
||||
{
|
||||
"sub":"{{userPub}}",
|
||||
"iss":"{{accountPub}}",
|
||||
"iat":1700000000,
|
||||
"nats":{
|
||||
"type":"user","version":2,
|
||||
"issuer_account":"{{accountPub}}"
|
||||
}
|
||||
}
|
||||
""";
|
||||
var userJwt = BuildSignedToken(userPayload, accountKp);
|
||||
|
||||
var resolver = new MemAccountResolver();
|
||||
await resolver.StoreAsync(accountPub, accountJwt);
|
||||
|
||||
var auth = new JwtAuthenticator([operatorPub], resolver);
|
||||
|
||||
var ctx = new ClientAuthContext
|
||||
{
|
||||
Opts = new ClientOptions { JWT = userJwt }, // No Sig provided
|
||||
Nonce = "nonce"u8.ToArray(),
|
||||
};
|
||||
|
||||
auth.Authenticate(ctx).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Jwt_with_permissions_returns_permissions()
|
||||
{
|
||||
var operatorKp = KeyPair.CreatePair(PrefixByte.Operator);
|
||||
var accountKp = KeyPair.CreatePair(PrefixByte.Account);
|
||||
var userKp = KeyPair.CreatePair(PrefixByte.User);
|
||||
|
||||
var operatorPub = operatorKp.GetPublicKey();
|
||||
var accountPub = accountKp.GetPublicKey();
|
||||
var userPub = userKp.GetPublicKey();
|
||||
|
||||
var accountPayload = $$"""
|
||||
{
|
||||
"sub":"{{accountPub}}",
|
||||
"iss":"{{operatorPub}}",
|
||||
"iat":1700000000,
|
||||
"nats":{"type":"account","version":2}
|
||||
}
|
||||
""";
|
||||
var accountJwt = BuildSignedToken(accountPayload, operatorKp);
|
||||
|
||||
var userPayload = $$"""
|
||||
{
|
||||
"sub":"{{userPub}}",
|
||||
"iss":"{{accountPub}}",
|
||||
"iat":1700000000,
|
||||
"nats":{
|
||||
"type":"user","version":2,
|
||||
"bearer_token":true,
|
||||
"issuer_account":"{{accountPub}}",
|
||||
"pub":{"allow":["foo.>","bar.*"]}
|
||||
}
|
||||
}
|
||||
""";
|
||||
var userJwt = BuildSignedToken(userPayload, accountKp);
|
||||
|
||||
var resolver = new MemAccountResolver();
|
||||
await resolver.StoreAsync(accountPub, accountJwt);
|
||||
|
||||
var auth = new JwtAuthenticator([operatorPub], resolver);
|
||||
|
||||
var ctx = new ClientAuthContext
|
||||
{
|
||||
Opts = new ClientOptions { JWT = userJwt },
|
||||
Nonce = "nonce"u8.ToArray(),
|
||||
};
|
||||
|
||||
var result = auth.Authenticate(ctx);
|
||||
|
||||
result.ShouldNotBeNull();
|
||||
result.Permissions.ShouldNotBeNull();
|
||||
result.Permissions.Publish.ShouldNotBeNull();
|
||||
result.Permissions.Publish.Allow.ShouldNotBeNull();
|
||||
result.Permissions.Publish.Allow.ShouldContain("foo.>");
|
||||
result.Permissions.Publish.Allow.ShouldContain("bar.*");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Signing_key_based_user_jwt_succeeds()
|
||||
{
|
||||
var operatorKp = KeyPair.CreatePair(PrefixByte.Operator);
|
||||
var accountKp = KeyPair.CreatePair(PrefixByte.Account);
|
||||
var signingKp = KeyPair.CreatePair(PrefixByte.Account);
|
||||
var userKp = KeyPair.CreatePair(PrefixByte.User);
|
||||
|
||||
var operatorPub = operatorKp.GetPublicKey();
|
||||
var accountPub = accountKp.GetPublicKey();
|
||||
var signingPub = signingKp.GetPublicKey();
|
||||
var userPub = userKp.GetPublicKey();
|
||||
|
||||
// Account JWT with signing key
|
||||
var accountPayload = $$"""
|
||||
{
|
||||
"sub":"{{accountPub}}",
|
||||
"iss":"{{operatorPub}}",
|
||||
"iat":1700000000,
|
||||
"nats":{
|
||||
"type":"account","version":2,
|
||||
"signing_keys":["{{signingPub}}"]
|
||||
}
|
||||
}
|
||||
""";
|
||||
var accountJwt = BuildSignedToken(accountPayload, operatorKp);
|
||||
|
||||
// User JWT issued by the signing key
|
||||
var userPayload = $$"""
|
||||
{
|
||||
"sub":"{{userPub}}",
|
||||
"iss":"{{signingPub}}",
|
||||
"iat":1700000000,
|
||||
"nats":{
|
||||
"type":"user","version":2,
|
||||
"bearer_token":true,
|
||||
"issuer_account":"{{accountPub}}"
|
||||
}
|
||||
}
|
||||
""";
|
||||
var userJwt = BuildSignedToken(userPayload, signingKp);
|
||||
|
||||
var resolver = new MemAccountResolver();
|
||||
await resolver.StoreAsync(accountPub, accountJwt);
|
||||
|
||||
var auth = new JwtAuthenticator([operatorPub], resolver);
|
||||
|
||||
var ctx = new ClientAuthContext
|
||||
{
|
||||
Opts = new ClientOptions { JWT = userJwt },
|
||||
Nonce = "nonce"u8.ToArray(),
|
||||
};
|
||||
|
||||
var result = auth.Authenticate(ctx);
|
||||
|
||||
result.ShouldNotBeNull();
|
||||
result.Identity.ShouldBe(userPub);
|
||||
result.AccountName.ShouldBe(accountPub);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Wildcard_revocation_returns_null()
|
||||
{
|
||||
var operatorKp = KeyPair.CreatePair(PrefixByte.Operator);
|
||||
var accountKp = KeyPair.CreatePair(PrefixByte.Account);
|
||||
var userKp = KeyPair.CreatePair(PrefixByte.User);
|
||||
|
||||
var operatorPub = operatorKp.GetPublicKey();
|
||||
var accountPub = accountKp.GetPublicKey();
|
||||
var userPub = userKp.GetPublicKey();
|
||||
|
||||
// Account JWT with wildcard revocation
|
||||
var accountPayload = $$"""
|
||||
{
|
||||
"sub":"{{accountPub}}",
|
||||
"iss":"{{operatorPub}}",
|
||||
"iat":1700000000,
|
||||
"nats":{
|
||||
"type":"account","version":2,
|
||||
"revocations":{
|
||||
"*":1700000001
|
||||
}
|
||||
}
|
||||
}
|
||||
""";
|
||||
var accountJwt = BuildSignedToken(accountPayload, operatorKp);
|
||||
|
||||
// User JWT issued at 1700000000 (before wildcard revocation)
|
||||
var userPayload = $$"""
|
||||
{
|
||||
"sub":"{{userPub}}",
|
||||
"iss":"{{accountPub}}",
|
||||
"iat":1700000000,
|
||||
"nats":{
|
||||
"type":"user","version":2,
|
||||
"bearer_token":true,
|
||||
"issuer_account":"{{accountPub}}"
|
||||
}
|
||||
}
|
||||
""";
|
||||
var userJwt = BuildSignedToken(userPayload, accountKp);
|
||||
|
||||
var resolver = new MemAccountResolver();
|
||||
await resolver.StoreAsync(accountPub, accountJwt);
|
||||
|
||||
var auth = new JwtAuthenticator([operatorPub], resolver);
|
||||
|
||||
var ctx = new ClientAuthContext
|
||||
{
|
||||
Opts = new ClientOptions { JWT = userJwt },
|
||||
Nonce = "nonce"u8.ToArray(),
|
||||
};
|
||||
|
||||
auth.Authenticate(ctx).ShouldBeNull();
|
||||
}
|
||||
}
|
||||
932
tests/NATS.Server.Tests/JwtTests.cs
Normal file
932
tests/NATS.Server.Tests/JwtTests.cs
Normal file
@@ -0,0 +1,932 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using NATS.NKeys;
|
||||
using NATS.Server.Auth.Jwt;
|
||||
|
||||
namespace NATS.Server.Tests;
|
||||
|
||||
public class JwtTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Helper: base64url-encode a string for constructing test JWTs.
|
||||
/// </summary>
|
||||
private static string Base64UrlEncode(string value)
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes(value);
|
||||
return Convert.ToBase64String(bytes)
|
||||
.TrimEnd('=')
|
||||
.Replace('+', '-')
|
||||
.Replace('/', '_');
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Helper: build a minimal unsigned JWT from header and payload JSON strings.
|
||||
/// The signature part is a base64url-encoded 64-byte zero array (invalid but structurally correct).
|
||||
/// </summary>
|
||||
private static string BuildUnsignedToken(string headerJson, string payloadJson)
|
||||
{
|
||||
var header = Base64UrlEncode(headerJson);
|
||||
var payload = Base64UrlEncode(payloadJson);
|
||||
var fakeSig = Convert.ToBase64String(new byte[64])
|
||||
.TrimEnd('=')
|
||||
.Replace('+', '-')
|
||||
.Replace('/', '_');
|
||||
return $"{header}.{payload}.{fakeSig}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Helper: build a real signed NATS JWT using an NKey keypair.
|
||||
/// Signs header.payload with Ed25519.
|
||||
/// </summary>
|
||||
private static string BuildSignedToken(string payloadJson, KeyPair signingKey)
|
||||
{
|
||||
var headerJson = """{"typ":"JWT","alg":"ed25519-nkey"}""";
|
||||
var header = Base64UrlEncode(headerJson);
|
||||
var payload = Base64UrlEncode(payloadJson);
|
||||
var signingInput = $"{header}.{payload}";
|
||||
var signingInputBytes = Encoding.UTF8.GetBytes(signingInput);
|
||||
|
||||
var sig = new byte[64];
|
||||
signingKey.Sign(signingInputBytes, sig);
|
||||
|
||||
var sigB64 = Convert.ToBase64String(sig)
|
||||
.TrimEnd('=')
|
||||
.Replace('+', '-')
|
||||
.Replace('/', '_');
|
||||
|
||||
return $"{header}.{payload}.{sigB64}";
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// IsJwt tests
|
||||
// =====================================================================
|
||||
|
||||
[Fact]
|
||||
public void IsJwt_returns_true_for_eyJ_prefix()
|
||||
{
|
||||
NatsJwt.IsJwt("eyJhbGciOiJlZDI1NTE5LW5rZXkiLCJ0eXAiOiJKV1QifQ.payload.sig").ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsJwt_returns_true_for_minimal_eyJ()
|
||||
{
|
||||
NatsJwt.IsJwt("eyJ").ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsJwt_returns_false_for_non_jwt()
|
||||
{
|
||||
NatsJwt.IsJwt("notajwt").ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsJwt_returns_false_for_empty_string()
|
||||
{
|
||||
NatsJwt.IsJwt("").ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsJwt_returns_false_for_null()
|
||||
{
|
||||
NatsJwt.IsJwt(null!).ShouldBeFalse();
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Decode tests
|
||||
// =====================================================================
|
||||
|
||||
[Fact]
|
||||
public void Decode_splits_header_payload_signature_correctly()
|
||||
{
|
||||
var headerJson = """{"typ":"JWT","alg":"ed25519-nkey"}""";
|
||||
var payloadJson = """{"sub":"UAXXX","iss":"AAXXX","iat":1700000000}""";
|
||||
var token = BuildUnsignedToken(headerJson, payloadJson);
|
||||
|
||||
var result = NatsJwt.Decode(token);
|
||||
|
||||
result.ShouldNotBeNull();
|
||||
result.Header.ShouldNotBeNull();
|
||||
result.Header.Type.ShouldBe("JWT");
|
||||
result.Header.Algorithm.ShouldBe("ed25519-nkey");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Decode_returns_payload_json()
|
||||
{
|
||||
var headerJson = """{"typ":"JWT","alg":"ed25519-nkey"}""";
|
||||
var payloadJson = """{"sub":"UAXXX","iss":"AAXXX","iat":1700000000}""";
|
||||
var token = BuildUnsignedToken(headerJson, payloadJson);
|
||||
|
||||
var result = NatsJwt.Decode(token);
|
||||
|
||||
result.ShouldNotBeNull();
|
||||
result.PayloadJson.ShouldNotBeNullOrEmpty();
|
||||
|
||||
// The payload JSON should parse back to matching fields
|
||||
using var doc = JsonDocument.Parse(result.PayloadJson);
|
||||
doc.RootElement.GetProperty("sub").GetString().ShouldBe("UAXXX");
|
||||
doc.RootElement.GetProperty("iss").GetString().ShouldBe("AAXXX");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Decode_preserves_signature_bytes()
|
||||
{
|
||||
var headerJson = """{"typ":"JWT","alg":"ed25519-nkey"}""";
|
||||
var payloadJson = """{"sub":"test"}""";
|
||||
var token = BuildUnsignedToken(headerJson, payloadJson);
|
||||
|
||||
var result = NatsJwt.Decode(token);
|
||||
|
||||
result.ShouldNotBeNull();
|
||||
result.Signature.ShouldNotBeNull();
|
||||
result.Signature.Length.ShouldBe(64);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Decode_preserves_signing_input()
|
||||
{
|
||||
var headerJson = """{"typ":"JWT","alg":"ed25519-nkey"}""";
|
||||
var payloadJson = """{"sub":"test"}""";
|
||||
var token = BuildUnsignedToken(headerJson, payloadJson);
|
||||
|
||||
var result = NatsJwt.Decode(token);
|
||||
|
||||
result.ShouldNotBeNull();
|
||||
|
||||
// SigningInput should be "header.payload" (the first two parts)
|
||||
var parts = token.Split('.');
|
||||
var expectedSigningInput = $"{parts[0]}.{parts[1]}";
|
||||
result.SigningInput.ShouldBe(expectedSigningInput);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Decode_returns_null_for_invalid_token_missing_parts()
|
||||
{
|
||||
NatsJwt.Decode("onlyonepart").ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Decode_returns_null_for_two_parts()
|
||||
{
|
||||
NatsJwt.Decode("part1.part2").ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Decode_returns_null_for_empty_string()
|
||||
{
|
||||
NatsJwt.Decode("").ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Decode_returns_null_for_invalid_base64_in_header()
|
||||
{
|
||||
NatsJwt.Decode("!!!invalid.payload.sig").ShouldBeNull();
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Verify tests
|
||||
// =====================================================================
|
||||
|
||||
[Fact]
|
||||
public void Verify_returns_true_for_valid_signed_token()
|
||||
{
|
||||
var accountKp = KeyPair.CreatePair(PrefixByte.Account);
|
||||
var accountPublicKey = accountKp.GetPublicKey();
|
||||
|
||||
var payloadJson = $$"""{"sub":"UAXXX","iss":"{{accountPublicKey}}","iat":1700000000}""";
|
||||
var token = BuildSignedToken(payloadJson, accountKp);
|
||||
|
||||
NatsJwt.Verify(token, accountPublicKey).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Verify_returns_false_for_wrong_key()
|
||||
{
|
||||
var signingKp = KeyPair.CreatePair(PrefixByte.Account);
|
||||
var wrongKp = KeyPair.CreatePair(PrefixByte.Account);
|
||||
|
||||
var payloadJson = """{"sub":"UAXXX","iss":"AAXXX","iat":1700000000}""";
|
||||
var token = BuildSignedToken(payloadJson, signingKp);
|
||||
|
||||
NatsJwt.Verify(token, wrongKp.GetPublicKey()).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Verify_returns_false_for_tampered_payload()
|
||||
{
|
||||
var accountKp = KeyPair.CreatePair(PrefixByte.Account);
|
||||
var accountPublicKey = accountKp.GetPublicKey();
|
||||
|
||||
var payloadJson = """{"sub":"UAXXX","iss":"AAXXX","iat":1700000000}""";
|
||||
var token = BuildSignedToken(payloadJson, accountKp);
|
||||
|
||||
// Tamper with the payload
|
||||
var parts = token.Split('.');
|
||||
var tamperedPayload = Base64UrlEncode("""{"sub":"HACKED","iss":"AAXXX","iat":1700000000}""");
|
||||
var tampered = $"{parts[0]}.{tamperedPayload}.{parts[2]}";
|
||||
|
||||
NatsJwt.Verify(tampered, accountPublicKey).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Verify_returns_false_for_invalid_token()
|
||||
{
|
||||
var kp = KeyPair.CreatePair(PrefixByte.Account);
|
||||
NatsJwt.Verify("not.a.jwt", kp.GetPublicKey()).ShouldBeFalse();
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// VerifyNonce tests
|
||||
// =====================================================================
|
||||
|
||||
[Fact]
|
||||
public void VerifyNonce_accepts_base64url_signature()
|
||||
{
|
||||
var kp = KeyPair.CreatePair(PrefixByte.User);
|
||||
var publicKey = kp.GetPublicKey();
|
||||
var nonce = "test-nonce-data"u8.ToArray();
|
||||
|
||||
var sig = new byte[64];
|
||||
kp.Sign(nonce, sig);
|
||||
|
||||
// Encode as base64url
|
||||
var sigB64Url = Convert.ToBase64String(sig)
|
||||
.TrimEnd('=')
|
||||
.Replace('+', '-')
|
||||
.Replace('/', '_');
|
||||
|
||||
NatsJwt.VerifyNonce(nonce, sigB64Url, publicKey).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VerifyNonce_accepts_standard_base64_signature()
|
||||
{
|
||||
var kp = KeyPair.CreatePair(PrefixByte.User);
|
||||
var publicKey = kp.GetPublicKey();
|
||||
var nonce = "test-nonce-data"u8.ToArray();
|
||||
|
||||
var sig = new byte[64];
|
||||
kp.Sign(nonce, sig);
|
||||
|
||||
// Encode as standard base64
|
||||
var sigB64 = Convert.ToBase64String(sig);
|
||||
|
||||
NatsJwt.VerifyNonce(nonce, sigB64, publicKey).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VerifyNonce_returns_false_for_wrong_nonce()
|
||||
{
|
||||
var kp = KeyPair.CreatePair(PrefixByte.User);
|
||||
var publicKey = kp.GetPublicKey();
|
||||
var nonce = "original-nonce"u8.ToArray();
|
||||
var wrongNonce = "different-nonce"u8.ToArray();
|
||||
|
||||
var sig = new byte[64];
|
||||
kp.Sign(nonce, sig);
|
||||
var sigB64 = Convert.ToBase64String(sig);
|
||||
|
||||
NatsJwt.VerifyNonce(wrongNonce, sigB64, publicKey).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VerifyNonce_returns_false_for_invalid_signature()
|
||||
{
|
||||
var kp = KeyPair.CreatePair(PrefixByte.User);
|
||||
var publicKey = kp.GetPublicKey();
|
||||
var nonce = "test-nonce"u8.ToArray();
|
||||
|
||||
NatsJwt.VerifyNonce(nonce, "invalid-sig!", publicKey).ShouldBeFalse();
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// DecodeUserClaims tests
|
||||
// =====================================================================
|
||||
|
||||
[Fact]
|
||||
public void DecodeUserClaims_parses_subject_and_issuer()
|
||||
{
|
||||
var headerJson = """{"typ":"JWT","alg":"ed25519-nkey"}""";
|
||||
var payloadJson = """
|
||||
{
|
||||
"sub":"UAXXX_USER_KEY",
|
||||
"iss":"AAXXX_ISSUER",
|
||||
"iat":1700000000,
|
||||
"name":"test-user",
|
||||
"nats":{
|
||||
"type":"user",
|
||||
"version":2
|
||||
}
|
||||
}
|
||||
""";
|
||||
var token = BuildUnsignedToken(headerJson, payloadJson);
|
||||
|
||||
var claims = NatsJwt.DecodeUserClaims(token);
|
||||
|
||||
claims.ShouldNotBeNull();
|
||||
claims.Subject.ShouldBe("UAXXX_USER_KEY");
|
||||
claims.Issuer.ShouldBe("AAXXX_ISSUER");
|
||||
claims.Name.ShouldBe("test-user");
|
||||
claims.IssuedAt.ShouldBe(1700000000);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DecodeUserClaims_parses_pub_sub_permissions()
|
||||
{
|
||||
var headerJson = """{"typ":"JWT","alg":"ed25519-nkey"}""";
|
||||
var payloadJson = """
|
||||
{
|
||||
"sub":"UAXXX",
|
||||
"iss":"AAXXX",
|
||||
"iat":1700000000,
|
||||
"nats":{
|
||||
"pub":{"allow":["foo.>","bar.*"],"deny":["bar.secret"]},
|
||||
"sub":{"allow":[">"],"deny":["_INBOX.private.>"]},
|
||||
"type":"user",
|
||||
"version":2
|
||||
}
|
||||
}
|
||||
""";
|
||||
var token = BuildUnsignedToken(headerJson, payloadJson);
|
||||
|
||||
var claims = NatsJwt.DecodeUserClaims(token);
|
||||
|
||||
claims.ShouldNotBeNull();
|
||||
claims.Nats.ShouldNotBeNull();
|
||||
|
||||
claims.Nats.Pub.ShouldNotBeNull();
|
||||
claims.Nats.Pub.Allow.ShouldBe(["foo.>", "bar.*"]);
|
||||
claims.Nats.Pub.Deny.ShouldBe(["bar.secret"]);
|
||||
|
||||
claims.Nats.Sub.ShouldNotBeNull();
|
||||
claims.Nats.Sub.Allow.ShouldBe([">"]);
|
||||
claims.Nats.Sub.Deny.ShouldBe(["_INBOX.private.>"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DecodeUserClaims_parses_response_permission()
|
||||
{
|
||||
var headerJson = """{"typ":"JWT","alg":"ed25519-nkey"}""";
|
||||
var payloadJson = """
|
||||
{
|
||||
"sub":"UAXXX",
|
||||
"iss":"AAXXX",
|
||||
"iat":1700000000,
|
||||
"nats":{
|
||||
"resp":{"max":5,"ttl":3000000000},
|
||||
"type":"user",
|
||||
"version":2
|
||||
}
|
||||
}
|
||||
""";
|
||||
var token = BuildUnsignedToken(headerJson, payloadJson);
|
||||
|
||||
var claims = NatsJwt.DecodeUserClaims(token);
|
||||
|
||||
claims.ShouldNotBeNull();
|
||||
claims.Nats.ShouldNotBeNull();
|
||||
claims.Nats.Resp.ShouldNotBeNull();
|
||||
claims.Nats.Resp.MaxMsgs.ShouldBe(5);
|
||||
claims.Nats.Resp.TtlNanos.ShouldBe(3000000000L);
|
||||
claims.Nats.Resp.Ttl.ShouldBe(TimeSpan.FromSeconds(3));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DecodeUserClaims_parses_bearer_token_flag()
|
||||
{
|
||||
var headerJson = """{"typ":"JWT","alg":"ed25519-nkey"}""";
|
||||
var payloadJson = """
|
||||
{
|
||||
"sub":"UAXXX",
|
||||
"iss":"AAXXX",
|
||||
"iat":1700000000,
|
||||
"nats":{
|
||||
"bearer_token":true,
|
||||
"issuer_account":"AAXXX_ISSUER_ACCOUNT",
|
||||
"type":"user",
|
||||
"version":2
|
||||
}
|
||||
}
|
||||
""";
|
||||
var token = BuildUnsignedToken(headerJson, payloadJson);
|
||||
|
||||
var claims = NatsJwt.DecodeUserClaims(token);
|
||||
|
||||
claims.ShouldNotBeNull();
|
||||
claims.Nats.ShouldNotBeNull();
|
||||
claims.Nats.BearerToken.ShouldBeTrue();
|
||||
claims.Nats.IssuerAccount.ShouldBe("AAXXX_ISSUER_ACCOUNT");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DecodeUserClaims_parses_tags_src_connection_types()
|
||||
{
|
||||
var headerJson = """{"typ":"JWT","alg":"ed25519-nkey"}""";
|
||||
var payloadJson = """
|
||||
{
|
||||
"sub":"UAXXX",
|
||||
"iss":"AAXXX",
|
||||
"iat":1700000000,
|
||||
"nats":{
|
||||
"tags":["web","mobile"],
|
||||
"src":["192.168.1.0/24","10.0.0.0/8"],
|
||||
"allowed_connection_types":["STANDARD","WEBSOCKET"],
|
||||
"type":"user",
|
||||
"version":2
|
||||
}
|
||||
}
|
||||
""";
|
||||
var token = BuildUnsignedToken(headerJson, payloadJson);
|
||||
|
||||
var claims = NatsJwt.DecodeUserClaims(token);
|
||||
|
||||
claims.ShouldNotBeNull();
|
||||
claims.Nats.ShouldNotBeNull();
|
||||
claims.Nats.Tags.ShouldBe(["web", "mobile"]);
|
||||
claims.Nats.Src.ShouldBe(["192.168.1.0/24", "10.0.0.0/8"]);
|
||||
claims.Nats.AllowedConnectionTypes.ShouldBe(["STANDARD", "WEBSOCKET"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DecodeUserClaims_parses_time_ranges()
|
||||
{
|
||||
var headerJson = """{"typ":"JWT","alg":"ed25519-nkey"}""";
|
||||
var payloadJson = """
|
||||
{
|
||||
"sub":"UAXXX",
|
||||
"iss":"AAXXX",
|
||||
"iat":1700000000,
|
||||
"nats":{
|
||||
"times":[
|
||||
{"start":"08:00:00","end":"17:00:00"},
|
||||
{"start":"20:00:00","end":"22:00:00"}
|
||||
],
|
||||
"type":"user",
|
||||
"version":2
|
||||
}
|
||||
}
|
||||
""";
|
||||
var token = BuildUnsignedToken(headerJson, payloadJson);
|
||||
|
||||
var claims = NatsJwt.DecodeUserClaims(token);
|
||||
|
||||
claims.ShouldNotBeNull();
|
||||
claims.Nats.ShouldNotBeNull();
|
||||
claims.Nats.Times.ShouldNotBeNull();
|
||||
claims.Nats.Times.Length.ShouldBe(2);
|
||||
claims.Nats.Times[0].Start.ShouldBe("08:00:00");
|
||||
claims.Nats.Times[0].End.ShouldBe("17:00:00");
|
||||
claims.Nats.Times[1].Start.ShouldBe("20:00:00");
|
||||
claims.Nats.Times[1].End.ShouldBe("22:00:00");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DecodeUserClaims_convenience_properties_delegate_to_nats()
|
||||
{
|
||||
var headerJson = """{"typ":"JWT","alg":"ed25519-nkey"}""";
|
||||
var payloadJson = """
|
||||
{
|
||||
"sub":"UAXXX",
|
||||
"iss":"AAXXX",
|
||||
"iat":1700000000,
|
||||
"nats":{
|
||||
"bearer_token":true,
|
||||
"issuer_account":"AAXXX_ACCOUNT",
|
||||
"type":"user",
|
||||
"version":2
|
||||
}
|
||||
}
|
||||
""";
|
||||
var token = BuildUnsignedToken(headerJson, payloadJson);
|
||||
|
||||
var claims = NatsJwt.DecodeUserClaims(token);
|
||||
|
||||
claims.ShouldNotBeNull();
|
||||
|
||||
// Convenience properties should delegate to Nats sub-object
|
||||
claims.BearerToken.ShouldBeTrue();
|
||||
claims.IssuerAccount.ShouldBe("AAXXX_ACCOUNT");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DecodeUserClaims_IsExpired_returns_false_when_no_expiry()
|
||||
{
|
||||
var headerJson = """{"typ":"JWT","alg":"ed25519-nkey"}""";
|
||||
var payloadJson = """
|
||||
{
|
||||
"sub":"UAXXX",
|
||||
"iss":"AAXXX",
|
||||
"iat":1700000000,
|
||||
"nats":{"type":"user","version":2}
|
||||
}
|
||||
""";
|
||||
var token = BuildUnsignedToken(headerJson, payloadJson);
|
||||
|
||||
var claims = NatsJwt.DecodeUserClaims(token);
|
||||
|
||||
claims.ShouldNotBeNull();
|
||||
claims.Expires.ShouldBe(0);
|
||||
claims.IsExpired().ShouldBeFalse();
|
||||
claims.GetExpiry().ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DecodeUserClaims_IsExpired_returns_true_for_past_expiry()
|
||||
{
|
||||
var headerJson = """{"typ":"JWT","alg":"ed25519-nkey"}""";
|
||||
// Expired in 2020
|
||||
var payloadJson = """
|
||||
{
|
||||
"sub":"UAXXX",
|
||||
"iss":"AAXXX",
|
||||
"iat":1500000000,
|
||||
"exp":1600000000,
|
||||
"nats":{"type":"user","version":2}
|
||||
}
|
||||
""";
|
||||
var token = BuildUnsignedToken(headerJson, payloadJson);
|
||||
|
||||
var claims = NatsJwt.DecodeUserClaims(token);
|
||||
|
||||
claims.ShouldNotBeNull();
|
||||
claims.Expires.ShouldBe(1600000000);
|
||||
claims.IsExpired().ShouldBeTrue();
|
||||
claims.GetExpiry().ShouldNotBeNull();
|
||||
claims.GetExpiry()!.Value.ToUnixTimeSeconds().ShouldBe(1600000000);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DecodeUserClaims_IsExpired_returns_false_for_future_expiry()
|
||||
{
|
||||
var headerJson = """{"typ":"JWT","alg":"ed25519-nkey"}""";
|
||||
// Expires far in the future
|
||||
var payloadJson = """
|
||||
{
|
||||
"sub":"UAXXX",
|
||||
"iss":"AAXXX",
|
||||
"iat":1700000000,
|
||||
"exp":4102444800,
|
||||
"nats":{"type":"user","version":2}
|
||||
}
|
||||
""";
|
||||
var token = BuildUnsignedToken(headerJson, payloadJson);
|
||||
|
||||
var claims = NatsJwt.DecodeUserClaims(token);
|
||||
|
||||
claims.ShouldNotBeNull();
|
||||
claims.IsExpired().ShouldBeFalse();
|
||||
claims.GetExpiry().ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DecodeUserClaims_returns_null_for_invalid_token()
|
||||
{
|
||||
NatsJwt.DecodeUserClaims("not-a-jwt").ShouldBeNull();
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// DecodeAccountClaims tests
|
||||
// =====================================================================
|
||||
|
||||
[Fact]
|
||||
public void DecodeAccountClaims_parses_subject_and_issuer()
|
||||
{
|
||||
var headerJson = """{"typ":"JWT","alg":"ed25519-nkey"}""";
|
||||
var payloadJson = """
|
||||
{
|
||||
"sub":"AAXXX_ACCOUNT_KEY",
|
||||
"iss":"OAXXX_OPERATOR",
|
||||
"iat":1700000000,
|
||||
"name":"test-account",
|
||||
"nats":{
|
||||
"type":"account",
|
||||
"version":2
|
||||
}
|
||||
}
|
||||
""";
|
||||
var token = BuildUnsignedToken(headerJson, payloadJson);
|
||||
|
||||
var claims = NatsJwt.DecodeAccountClaims(token);
|
||||
|
||||
claims.ShouldNotBeNull();
|
||||
claims.Subject.ShouldBe("AAXXX_ACCOUNT_KEY");
|
||||
claims.Issuer.ShouldBe("OAXXX_OPERATOR");
|
||||
claims.Name.ShouldBe("test-account");
|
||||
claims.IssuedAt.ShouldBe(1700000000);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DecodeAccountClaims_parses_limits()
|
||||
{
|
||||
var headerJson = """{"typ":"JWT","alg":"ed25519-nkey"}""";
|
||||
var payloadJson = """
|
||||
{
|
||||
"sub":"AAXXX",
|
||||
"iss":"OAXXX",
|
||||
"iat":1700000000,
|
||||
"nats":{
|
||||
"limits":{
|
||||
"conn":100,
|
||||
"subs":1000,
|
||||
"payload":1048576,
|
||||
"data":10737418240
|
||||
},
|
||||
"type":"account",
|
||||
"version":2
|
||||
}
|
||||
}
|
||||
""";
|
||||
var token = BuildUnsignedToken(headerJson, payloadJson);
|
||||
|
||||
var claims = NatsJwt.DecodeAccountClaims(token);
|
||||
|
||||
claims.ShouldNotBeNull();
|
||||
claims.Nats.ShouldNotBeNull();
|
||||
claims.Nats.Limits.ShouldNotBeNull();
|
||||
claims.Nats.Limits.MaxConnections.ShouldBe(100);
|
||||
claims.Nats.Limits.MaxSubscriptions.ShouldBe(1000);
|
||||
claims.Nats.Limits.MaxPayload.ShouldBe(1048576);
|
||||
claims.Nats.Limits.MaxData.ShouldBe(10737418240L);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DecodeAccountClaims_parses_signing_keys()
|
||||
{
|
||||
var headerJson = """{"typ":"JWT","alg":"ed25519-nkey"}""";
|
||||
var payloadJson = """
|
||||
{
|
||||
"sub":"AAXXX",
|
||||
"iss":"OAXXX",
|
||||
"iat":1700000000,
|
||||
"nats":{
|
||||
"signing_keys":["AAXXX_SIGN_1","AAXXX_SIGN_2","AAXXX_SIGN_3"],
|
||||
"type":"account",
|
||||
"version":2
|
||||
}
|
||||
}
|
||||
""";
|
||||
var token = BuildUnsignedToken(headerJson, payloadJson);
|
||||
|
||||
var claims = NatsJwt.DecodeAccountClaims(token);
|
||||
|
||||
claims.ShouldNotBeNull();
|
||||
claims.Nats.ShouldNotBeNull();
|
||||
claims.Nats.SigningKeys.ShouldNotBeNull();
|
||||
claims.Nats.SigningKeys.ShouldBe(["AAXXX_SIGN_1", "AAXXX_SIGN_2", "AAXXX_SIGN_3"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DecodeAccountClaims_parses_revocations()
|
||||
{
|
||||
var headerJson = """{"typ":"JWT","alg":"ed25519-nkey"}""";
|
||||
var payloadJson = """
|
||||
{
|
||||
"sub":"AAXXX",
|
||||
"iss":"OAXXX",
|
||||
"iat":1700000000,
|
||||
"nats":{
|
||||
"revocations":{
|
||||
"UAXXX_REVOKED_1":1700000000,
|
||||
"UAXXX_REVOKED_2":1700001000
|
||||
},
|
||||
"type":"account",
|
||||
"version":2
|
||||
}
|
||||
}
|
||||
""";
|
||||
var token = BuildUnsignedToken(headerJson, payloadJson);
|
||||
|
||||
var claims = NatsJwt.DecodeAccountClaims(token);
|
||||
|
||||
claims.ShouldNotBeNull();
|
||||
claims.Nats.ShouldNotBeNull();
|
||||
claims.Nats.Revocations.ShouldNotBeNull();
|
||||
claims.Nats.Revocations.Count.ShouldBe(2);
|
||||
claims.Nats.Revocations["UAXXX_REVOKED_1"].ShouldBe(1700000000);
|
||||
claims.Nats.Revocations["UAXXX_REVOKED_2"].ShouldBe(1700001000);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DecodeAccountClaims_handles_negative_one_unlimited_limits()
|
||||
{
|
||||
var headerJson = """{"typ":"JWT","alg":"ed25519-nkey"}""";
|
||||
var payloadJson = """
|
||||
{
|
||||
"sub":"AAXXX",
|
||||
"iss":"OAXXX",
|
||||
"iat":1700000000,
|
||||
"nats":{
|
||||
"limits":{
|
||||
"conn":-1,
|
||||
"subs":-1,
|
||||
"payload":-1,
|
||||
"data":-1
|
||||
},
|
||||
"type":"account",
|
||||
"version":2
|
||||
}
|
||||
}
|
||||
""";
|
||||
var token = BuildUnsignedToken(headerJson, payloadJson);
|
||||
|
||||
var claims = NatsJwt.DecodeAccountClaims(token);
|
||||
|
||||
claims.ShouldNotBeNull();
|
||||
claims.Nats.ShouldNotBeNull();
|
||||
claims.Nats.Limits.ShouldNotBeNull();
|
||||
claims.Nats.Limits.MaxConnections.ShouldBe(-1);
|
||||
claims.Nats.Limits.MaxSubscriptions.ShouldBe(-1);
|
||||
claims.Nats.Limits.MaxPayload.ShouldBe(-1);
|
||||
claims.Nats.Limits.MaxData.ShouldBe(-1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DecodeAccountClaims_returns_null_for_invalid_token()
|
||||
{
|
||||
NatsJwt.DecodeAccountClaims("invalid").ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DecodeAccountClaims_parses_expiry()
|
||||
{
|
||||
var headerJson = """{"typ":"JWT","alg":"ed25519-nkey"}""";
|
||||
var payloadJson = """
|
||||
{
|
||||
"sub":"AAXXX",
|
||||
"iss":"OAXXX",
|
||||
"iat":1700000000,
|
||||
"exp":1800000000,
|
||||
"name":"expiring-account",
|
||||
"nats":{
|
||||
"type":"account",
|
||||
"version":2
|
||||
}
|
||||
}
|
||||
""";
|
||||
var token = BuildUnsignedToken(headerJson, payloadJson);
|
||||
|
||||
var claims = NatsJwt.DecodeAccountClaims(token);
|
||||
|
||||
claims.ShouldNotBeNull();
|
||||
claims.Expires.ShouldBe(1800000000);
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Round-trip with real Ed25519 signing tests
|
||||
// =====================================================================
|
||||
|
||||
[Fact]
|
||||
public void Roundtrip_sign_and_verify_user_claims()
|
||||
{
|
||||
var accountKp = KeyPair.CreatePair(PrefixByte.Account);
|
||||
var accountPublicKey = accountKp.GetPublicKey();
|
||||
|
||||
var payloadJson = $$"""
|
||||
{
|
||||
"sub":"UAXXX_USER",
|
||||
"iss":"{{accountPublicKey}}",
|
||||
"iat":1700000000,
|
||||
"name":"roundtrip-user",
|
||||
"nats":{
|
||||
"pub":{"allow":["test.>"]},
|
||||
"bearer_token":true,
|
||||
"issuer_account":"{{accountPublicKey}}",
|
||||
"type":"user",
|
||||
"version":2
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
var token = BuildSignedToken(payloadJson, accountKp);
|
||||
|
||||
// Verify signature
|
||||
NatsJwt.Verify(token, accountPublicKey).ShouldBeTrue();
|
||||
|
||||
// Decode claims
|
||||
var claims = NatsJwt.DecodeUserClaims(token);
|
||||
claims.ShouldNotBeNull();
|
||||
claims.Subject.ShouldBe("UAXXX_USER");
|
||||
claims.Name.ShouldBe("roundtrip-user");
|
||||
claims.Nats.ShouldNotBeNull();
|
||||
claims.Nats.Pub.ShouldNotBeNull();
|
||||
claims.Nats.Pub.Allow.ShouldBe(["test.>"]);
|
||||
claims.BearerToken.ShouldBeTrue();
|
||||
claims.IssuerAccount.ShouldBe(accountPublicKey);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Roundtrip_sign_and_verify_account_claims()
|
||||
{
|
||||
var operatorKp = KeyPair.CreatePair(PrefixByte.Operator);
|
||||
var operatorPublicKey = operatorKp.GetPublicKey();
|
||||
|
||||
var payloadJson = $$"""
|
||||
{
|
||||
"sub":"AAXXX_ACCOUNT",
|
||||
"iss":"{{operatorPublicKey}}",
|
||||
"iat":1700000000,
|
||||
"name":"roundtrip-account",
|
||||
"nats":{
|
||||
"limits":{"conn":50,"subs":500,"payload":65536,"data":-1},
|
||||
"signing_keys":["AAXXX_SK1"],
|
||||
"revocations":{"UAXXX_OLD":1699000000},
|
||||
"type":"account",
|
||||
"version":2
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
var token = BuildSignedToken(payloadJson, operatorKp);
|
||||
|
||||
// Verify signature
|
||||
NatsJwt.Verify(token, operatorPublicKey).ShouldBeTrue();
|
||||
|
||||
// Decode claims
|
||||
var claims = NatsJwt.DecodeAccountClaims(token);
|
||||
claims.ShouldNotBeNull();
|
||||
claims.Subject.ShouldBe("AAXXX_ACCOUNT");
|
||||
claims.Name.ShouldBe("roundtrip-account");
|
||||
claims.Nats.ShouldNotBeNull();
|
||||
claims.Nats.Limits.ShouldNotBeNull();
|
||||
claims.Nats.Limits.MaxConnections.ShouldBe(50);
|
||||
claims.Nats.SigningKeys.ShouldBe(["AAXXX_SK1"]);
|
||||
claims.Nats.Revocations.ShouldNotBeNull();
|
||||
claims.Nats.Revocations["UAXXX_OLD"].ShouldBe(1699000000);
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Edge case tests
|
||||
// =====================================================================
|
||||
|
||||
[Fact]
|
||||
public void DecodeUserClaims_handles_missing_nats_object()
|
||||
{
|
||||
var headerJson = """{"typ":"JWT","alg":"ed25519-nkey"}""";
|
||||
var payloadJson = """
|
||||
{
|
||||
"sub":"UAXXX",
|
||||
"iss":"AAXXX",
|
||||
"iat":1700000000
|
||||
}
|
||||
""";
|
||||
var token = BuildUnsignedToken(headerJson, payloadJson);
|
||||
|
||||
var claims = NatsJwt.DecodeUserClaims(token);
|
||||
|
||||
// Should still decode the outer fields even if nats is missing
|
||||
claims.ShouldNotBeNull();
|
||||
claims.Subject.ShouldBe("UAXXX");
|
||||
claims.Issuer.ShouldBe("AAXXX");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DecodeAccountClaims_handles_empty_nats_object()
|
||||
{
|
||||
var headerJson = """{"typ":"JWT","alg":"ed25519-nkey"}""";
|
||||
var payloadJson = """
|
||||
{
|
||||
"sub":"AAXXX",
|
||||
"iss":"OAXXX",
|
||||
"iat":1700000000,
|
||||
"nats":{}
|
||||
}
|
||||
""";
|
||||
var token = BuildUnsignedToken(headerJson, payloadJson);
|
||||
|
||||
var claims = NatsJwt.DecodeAccountClaims(token);
|
||||
|
||||
claims.ShouldNotBeNull();
|
||||
claims.Subject.ShouldBe("AAXXX");
|
||||
claims.Nats.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DecodeUserClaims_handles_empty_pub_sub_permissions()
|
||||
{
|
||||
var headerJson = """{"typ":"JWT","alg":"ed25519-nkey"}""";
|
||||
var payloadJson = """
|
||||
{
|
||||
"sub":"UAXXX",
|
||||
"iss":"AAXXX",
|
||||
"iat":1700000000,
|
||||
"nats":{
|
||||
"pub":{},
|
||||
"sub":{},
|
||||
"type":"user",
|
||||
"version":2
|
||||
}
|
||||
}
|
||||
""";
|
||||
var token = BuildUnsignedToken(headerJson, payloadJson);
|
||||
|
||||
var claims = NatsJwt.DecodeUserClaims(token);
|
||||
|
||||
claims.ShouldNotBeNull();
|
||||
claims.Nats.ShouldNotBeNull();
|
||||
claims.Nats.Pub.ShouldNotBeNull();
|
||||
claims.Nats.Sub.ShouldNotBeNull();
|
||||
// Allow/Deny should be null when not specified
|
||||
claims.Nats.Pub.Allow.ShouldBeNull();
|
||||
claims.Nats.Pub.Deny.ShouldBeNull();
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
90
tests/NATS.Server.Tests/OcspConfigTests.cs
Normal file
90
tests/NATS.Server.Tests/OcspConfigTests.cs
Normal file
@@ -0,0 +1,90 @@
|
||||
using NATS.Server.Tls;
|
||||
|
||||
namespace NATS.Server.Tests;
|
||||
|
||||
public class OcspConfigTests
|
||||
{
|
||||
[Fact]
|
||||
public void OcspMode_Auto_has_value_zero()
|
||||
{
|
||||
((int)OcspMode.Auto).ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OcspMode_Always_has_value_one()
|
||||
{
|
||||
((int)OcspMode.Always).ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OcspMode_Must_has_value_two()
|
||||
{
|
||||
((int)OcspMode.Must).ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OcspMode_Never_has_value_three()
|
||||
{
|
||||
((int)OcspMode.Never).ShouldBe(3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OcspConfig_default_mode_is_Auto()
|
||||
{
|
||||
var config = new OcspConfig();
|
||||
config.Mode.ShouldBe(OcspMode.Auto);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OcspConfig_OverrideUrls_defaults_to_empty_array()
|
||||
{
|
||||
var config = new OcspConfig();
|
||||
config.OverrideUrls.ShouldNotBeNull();
|
||||
config.OverrideUrls.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OcspConfig_Mode_can_be_set_via_init()
|
||||
{
|
||||
var config = new OcspConfig { Mode = OcspMode.Must };
|
||||
config.Mode.ShouldBe(OcspMode.Must);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OcspConfig_OverrideUrls_can_be_set_via_init()
|
||||
{
|
||||
var urls = new[] { "http://ocsp.example.com", "http://backup.example.com" };
|
||||
var config = new OcspConfig { OverrideUrls = urls };
|
||||
config.OverrideUrls.ShouldBe(urls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NatsOptions_OcspConfig_defaults_to_null()
|
||||
{
|
||||
var opts = new NatsOptions();
|
||||
opts.OcspConfig.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NatsOptions_OcspPeerVerify_defaults_to_false()
|
||||
{
|
||||
var opts = new NatsOptions();
|
||||
opts.OcspPeerVerify.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NatsOptions_OcspConfig_can_be_assigned()
|
||||
{
|
||||
var config = new OcspConfig { Mode = OcspMode.Always };
|
||||
var opts = new NatsOptions { OcspConfig = config };
|
||||
opts.OcspConfig.ShouldNotBeNull();
|
||||
opts.OcspConfig!.Mode.ShouldBe(OcspMode.Always);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NatsOptions_OcspPeerVerify_can_be_set_to_true()
|
||||
{
|
||||
var opts = new NatsOptions { OcspPeerVerify = true };
|
||||
opts.OcspPeerVerify.ShouldBeTrue();
|
||||
}
|
||||
}
|
||||
97
tests/NATS.Server.Tests/OcspStaplingTests.cs
Normal file
97
tests/NATS.Server.Tests/OcspStaplingTests.cs
Normal file
@@ -0,0 +1,97 @@
|
||||
using NATS.Server.Tls;
|
||||
|
||||
namespace NATS.Server.Tests;
|
||||
|
||||
public class OcspStaplingTests
|
||||
{
|
||||
[Fact]
|
||||
public void OcspMode_Must_is_strictest()
|
||||
{
|
||||
var config = new OcspConfig { Mode = OcspMode.Must };
|
||||
config.Mode.ShouldBe(OcspMode.Must);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OcspMode_Never_disables_all()
|
||||
{
|
||||
var config = new OcspConfig { Mode = OcspMode.Never };
|
||||
config.Mode.ShouldBe(OcspMode.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OcspPeerVerify_default_is_false()
|
||||
{
|
||||
var options = new NatsOptions();
|
||||
options.OcspPeerVerify.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OcspConfig_default_mode_is_Auto()
|
||||
{
|
||||
var config = new OcspConfig();
|
||||
config.Mode.ShouldBe(OcspMode.Auto);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OcspConfig_default_OverrideUrls_is_empty()
|
||||
{
|
||||
var config = new OcspConfig();
|
||||
config.OverrideUrls.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildCertificateContext_returns_null_when_no_tls()
|
||||
{
|
||||
var options = new NatsOptions
|
||||
{
|
||||
OcspConfig = new OcspConfig { Mode = OcspMode.Always },
|
||||
};
|
||||
// HasTls is false because TlsCert and TlsKey are not set
|
||||
options.HasTls.ShouldBeFalse();
|
||||
var context = TlsHelper.BuildCertificateContext(options);
|
||||
context.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildCertificateContext_returns_null_when_mode_is_Never()
|
||||
{
|
||||
var options = new NatsOptions
|
||||
{
|
||||
TlsCert = "server.pem",
|
||||
TlsKey = "server-key.pem",
|
||||
OcspConfig = new OcspConfig { Mode = OcspMode.Never },
|
||||
};
|
||||
// OcspMode.Never must short-circuit even when TLS cert paths are set
|
||||
var context = TlsHelper.BuildCertificateContext(options);
|
||||
context.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildCertificateContext_returns_null_when_OcspConfig_is_null()
|
||||
{
|
||||
var options = new NatsOptions
|
||||
{
|
||||
TlsCert = "server.pem",
|
||||
TlsKey = "server-key.pem",
|
||||
OcspConfig = null,
|
||||
};
|
||||
var context = TlsHelper.BuildCertificateContext(options);
|
||||
context.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OcspPeerVerify_can_be_enabled()
|
||||
{
|
||||
var options = new NatsOptions { OcspPeerVerify = true };
|
||||
options.OcspPeerVerify.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OcspMode_values_have_correct_ordinals()
|
||||
{
|
||||
((int)OcspMode.Auto).ShouldBe(0);
|
||||
((int)OcspMode.Always).ShouldBe(1);
|
||||
((int)OcspMode.Must).ShouldBe(2);
|
||||
((int)OcspMode.Never).ShouldBe(3);
|
||||
}
|
||||
}
|
||||
99
tests/NATS.Server.Tests/PermissionTemplateTests.cs
Normal file
99
tests/NATS.Server.Tests/PermissionTemplateTests.cs
Normal file
@@ -0,0 +1,99 @@
|
||||
namespace NATS.Server.Tests;
|
||||
|
||||
using NATS.Server.Auth.Jwt;
|
||||
|
||||
public class PermissionTemplateTests
|
||||
{
|
||||
[Fact]
|
||||
public void Expand_name_template()
|
||||
{
|
||||
var result = PermissionTemplates.Expand("user.{{name()}}.>",
|
||||
name: "alice", subject: "UABC", accountName: "acct", accountSubject: "AABC",
|
||||
userTags: [], accountTags: []);
|
||||
result.ShouldBe(["user.alice.>"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Expand_subject_template()
|
||||
{
|
||||
var result = PermissionTemplates.Expand("inbox.{{subject()}}.>",
|
||||
name: "alice", subject: "UABC123", accountName: "acct", accountSubject: "AABC",
|
||||
userTags: [], accountTags: []);
|
||||
result.ShouldBe(["inbox.UABC123.>"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Expand_account_name_template()
|
||||
{
|
||||
var result = PermissionTemplates.Expand("acct.{{account-name()}}.>",
|
||||
name: "alice", subject: "UABC", accountName: "myaccount", accountSubject: "AABC",
|
||||
userTags: [], accountTags: []);
|
||||
result.ShouldBe(["acct.myaccount.>"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Expand_account_subject_template()
|
||||
{
|
||||
var result = PermissionTemplates.Expand("acct.{{account-subject()}}.>",
|
||||
name: "alice", subject: "UABC", accountName: "acct", accountSubject: "AABC456",
|
||||
userTags: [], accountTags: []);
|
||||
result.ShouldBe(["acct.AABC456.>"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Expand_tag_template_single_value()
|
||||
{
|
||||
var result = PermissionTemplates.Expand("dept.{{tag(dept)}}.>",
|
||||
name: "alice", subject: "UABC", accountName: "acct", accountSubject: "AABC",
|
||||
userTags: ["dept:engineering"], accountTags: []);
|
||||
result.ShouldBe(["dept.engineering.>"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Expand_tag_template_multi_value_cartesian()
|
||||
{
|
||||
var result = PermissionTemplates.Expand("dept.{{tag(dept)}}.>",
|
||||
name: "alice", subject: "UABC", accountName: "acct", accountSubject: "AABC",
|
||||
userTags: ["dept:eng", "dept:sales"], accountTags: []);
|
||||
result.Count.ShouldBe(2);
|
||||
result.ShouldContain("dept.eng.>");
|
||||
result.ShouldContain("dept.sales.>");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Expand_account_tag_template()
|
||||
{
|
||||
var result = PermissionTemplates.Expand("region.{{account-tag(region)}}.>",
|
||||
name: "alice", subject: "UABC", accountName: "acct", accountSubject: "AABC",
|
||||
userTags: [], accountTags: ["region:us-east"]);
|
||||
result.ShouldBe(["region.us-east.>"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Expand_no_templates_returns_original()
|
||||
{
|
||||
var result = PermissionTemplates.Expand("foo.bar.>",
|
||||
name: "alice", subject: "UABC", accountName: "acct", accountSubject: "AABC",
|
||||
userTags: [], accountTags: []);
|
||||
result.ShouldBe(["foo.bar.>"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Expand_unknown_tag_returns_empty()
|
||||
{
|
||||
var result = PermissionTemplates.Expand("dept.{{tag(missing)}}.>",
|
||||
name: "alice", subject: "UABC", accountName: "acct", accountSubject: "AABC",
|
||||
userTags: ["dept:eng"], accountTags: []);
|
||||
result.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExpandAll_expands_array_of_subjects()
|
||||
{
|
||||
var subjects = new[] { "user.{{name()}}.>", "inbox.{{subject()}}.>" };
|
||||
var result = PermissionTemplates.ExpandAll(subjects,
|
||||
name: "alice", subject: "UABC", accountName: "acct", accountSubject: "AABC",
|
||||
userTags: [], accountTags: []);
|
||||
result.ShouldBe(["user.alice.>", "inbox.UABC.>"]);
|
||||
}
|
||||
}
|
||||
91
tests/NATS.Server.Tests/SubjectTransformIntegrationTests.cs
Normal file
91
tests/NATS.Server.Tests/SubjectTransformIntegrationTests.cs
Normal file
@@ -0,0 +1,91 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NATS.Server.Subscriptions;
|
||||
|
||||
namespace NATS.Server.Tests;
|
||||
|
||||
public class SubjectTransformIntegrationTests
|
||||
{
|
||||
[Fact]
|
||||
public void Server_compiles_subject_mappings()
|
||||
{
|
||||
var options = new NatsOptions
|
||||
{
|
||||
SubjectMappings = new Dictionary<string, string>
|
||||
{
|
||||
["src.*"] = "dest.{{wildcard(1)}}",
|
||||
["orders.*.*"] = "processed.{{wildcard(2)}}.{{wildcard(1)}}",
|
||||
},
|
||||
};
|
||||
using var server = new NatsServer(options, NullLoggerFactory.Instance);
|
||||
|
||||
// Server should have started without errors (transforms compiled)
|
||||
server.Port.ShouldBe(4222);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Server_ignores_null_subject_mappings()
|
||||
{
|
||||
var options = new NatsOptions { SubjectMappings = null };
|
||||
using var server = new NatsServer(options, NullLoggerFactory.Instance);
|
||||
server.Port.ShouldBe(4222);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Server_ignores_empty_subject_mappings()
|
||||
{
|
||||
var options = new NatsOptions { SubjectMappings = new Dictionary<string, string>() };
|
||||
using var server = new NatsServer(options, NullLoggerFactory.Instance);
|
||||
server.Port.ShouldBe(4222);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Server_logs_warning_for_invalid_mapping()
|
||||
{
|
||||
var options = new NatsOptions
|
||||
{
|
||||
SubjectMappings = new Dictionary<string, string>
|
||||
{
|
||||
[""] = "dest", // invalid empty source becomes ">" which is valid
|
||||
},
|
||||
};
|
||||
using var server = new NatsServer(options, NullLoggerFactory.Instance);
|
||||
// Should not throw, just log a warning and skip
|
||||
server.Port.ShouldBe(4222);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SubjectTransform_applies_first_matching_rule()
|
||||
{
|
||||
// Unit test the transform application logic directly
|
||||
var t1 = SubjectTransform.Create("src.*", "dest.{{wildcard(1)}}");
|
||||
var t2 = SubjectTransform.Create("src.*", "other.{{wildcard(1)}}");
|
||||
t1.ShouldNotBeNull();
|
||||
t2.ShouldNotBeNull();
|
||||
|
||||
var transforms = new[] { t1, t2 };
|
||||
string subject = "src.hello";
|
||||
|
||||
// Apply transforms -- first match wins
|
||||
foreach (var transform in transforms)
|
||||
{
|
||||
var mapped = transform.Apply(subject);
|
||||
if (mapped != null)
|
||||
{
|
||||
subject = mapped;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
subject.ShouldBe("dest.hello");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SubjectTransform_non_matching_subject_unchanged()
|
||||
{
|
||||
var t = SubjectTransform.Create("src.*", "dest.{{wildcard(1)}}");
|
||||
t.ShouldNotBeNull();
|
||||
|
||||
var result = t.Apply("other.hello");
|
||||
result.ShouldBeNull(); // No match
|
||||
}
|
||||
}
|
||||
396
tests/NATS.Server.Tests/SubjectTransformTests.cs
Normal file
396
tests/NATS.Server.Tests/SubjectTransformTests.cs
Normal file
@@ -0,0 +1,396 @@
|
||||
using NATS.Server.Subscriptions;
|
||||
|
||||
namespace NATS.Server.Tests;
|
||||
|
||||
public class SubjectTransformTests
|
||||
{
|
||||
[Fact]
|
||||
public void WildcardReplacement_SingleToken()
|
||||
{
|
||||
// foo.* -> bar.{{wildcard(1)}}
|
||||
var transform = SubjectTransform.Create("foo.*", "bar.{{wildcard(1)}}");
|
||||
transform.ShouldNotBeNull();
|
||||
transform.Apply("foo.baz").ShouldBe("bar.baz");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DollarSyntax_ReversesOrder()
|
||||
{
|
||||
// foo.*.* -> bar.$2.$1 reverses captured tokens
|
||||
var transform = SubjectTransform.Create("foo.*.*", "bar.$2.$1");
|
||||
transform.ShouldNotBeNull();
|
||||
transform.Apply("foo.A.B").ShouldBe("bar.B.A");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DollarSyntax_MultipleWildcardPositions()
|
||||
{
|
||||
// foo.*.bar.*.baz -> req.$2.$1
|
||||
var transform = SubjectTransform.Create("foo.*.bar.*.baz", "req.$2.$1");
|
||||
transform.ShouldNotBeNull();
|
||||
transform.Apply("foo.A.bar.B.baz").ShouldBe("req.B.A");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WildcardFunction_MultiplePositions()
|
||||
{
|
||||
// foo.*.bar.*.baz -> req.{{wildcard(2)}}.{{wildcard(1)}}
|
||||
var transform = SubjectTransform.Create("foo.*.bar.*.baz", "req.{{wildcard(2)}}.{{wildcard(1)}}");
|
||||
transform.ShouldNotBeNull();
|
||||
transform.Apply("foo.A.bar.B.baz").ShouldBe("req.B.A");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FullWildcardCapture_MultiToken()
|
||||
{
|
||||
// baz.> -> my.pre.> captures multi-token remainder
|
||||
var transform = SubjectTransform.Create("baz.>", "my.pre.>");
|
||||
transform.ShouldNotBeNull();
|
||||
transform.Apply("baz.1.2.3").ShouldBe("my.pre.1.2.3");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FullWildcardCapture_FooBar()
|
||||
{
|
||||
// baz.> -> foo.bar.>
|
||||
var transform = SubjectTransform.Create("baz.>", "foo.bar.>");
|
||||
transform.ShouldNotBeNull();
|
||||
transform.Apply("baz.1.2.3").ShouldBe("foo.bar.1.2.3");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NoMatch_ReturnsNull()
|
||||
{
|
||||
var transform = SubjectTransform.Create("foo.*", "bar.$1");
|
||||
transform.ShouldNotBeNull();
|
||||
transform.Apply("baz.qux").ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NoMatch_WrongTokenCount()
|
||||
{
|
||||
var transform = SubjectTransform.Create("foo.*", "bar.$1");
|
||||
transform.ShouldNotBeNull();
|
||||
transform.Apply("foo.a.b").ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PartitionFunction_DeterministicResult()
|
||||
{
|
||||
// Partition should produce deterministic 0..N-1 results
|
||||
var transform = SubjectTransform.Create("*", "bar.{{partition(10)}}");
|
||||
transform.ShouldNotBeNull();
|
||||
|
||||
// FNV-1a of "foo" mod 10 = 3
|
||||
transform.Apply("foo").ShouldBe("bar.3");
|
||||
// FNV-1a of "baz" mod 10 = 0
|
||||
transform.Apply("baz").ShouldBe("bar.0");
|
||||
// FNV-1a of "qux" mod 10 = 9
|
||||
transform.Apply("qux").ShouldBe("bar.9");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PartitionFunction_ZeroBuckets()
|
||||
{
|
||||
var transform = SubjectTransform.Create("*", "bar.{{partition(0)}}");
|
||||
transform.ShouldNotBeNull();
|
||||
transform.Apply("baz").ShouldBe("bar.0");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PartitionFunction_WithTokenIndexes()
|
||||
{
|
||||
// partition(10, 1, 2) hashes concatenation of wildcard 1 and wildcard 2
|
||||
// For source *.*: wildcard 1 -> pos 0 ("foo"), wildcard 2 -> pos 1 ("bar")
|
||||
// Key = "foobar" (no separator), FNV-1a("foobar") % 10 = 0
|
||||
var transform = SubjectTransform.Create("*.*", "bar.{{partition(10,1,2)}}");
|
||||
transform.ShouldNotBeNull();
|
||||
transform.Apply("foo.bar").ShouldBe("bar.0");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PartitionFunction_WithSpecificToken()
|
||||
{
|
||||
// partition(10, 0) with wildcard source: in Go, wildcard index 0 silently
|
||||
// maps to source position 0 (Go map zero-value behavior). We match this.
|
||||
var transform = SubjectTransform.Create("*", "bar.{{partition(10, 0)}}");
|
||||
transform.ShouldNotBeNull();
|
||||
transform.Apply("foo").ShouldBe("bar.3");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PartitionFunction_ShorthandNoWildcardsInSource()
|
||||
{
|
||||
// When source has no wildcards, partition(n) hashes the full subject
|
||||
var transform = SubjectTransform.Create("foo.bar", "baz.{{partition(10)}}");
|
||||
transform.ShouldNotBeNull();
|
||||
transform.Apply("foo.bar").ShouldBe("baz.6");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PartitionFunction_ShorthandWithWildcards()
|
||||
{
|
||||
// partition(10) with wildcards hashes all subject tokens joined
|
||||
var transform = SubjectTransform.Create("*.*", "bar.{{partition(10)}}");
|
||||
transform.ShouldNotBeNull();
|
||||
transform.Apply("foo.bar").ShouldBe("bar.6");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SplitFunction_BasicDelimiter()
|
||||
{
|
||||
// events.a-b-c with split(1,-) -> split.a.b.c
|
||||
var transform = SubjectTransform.Create("*", "{{split(1,-)}}");
|
||||
transform.ShouldNotBeNull();
|
||||
transform.Apply("abc-def--ghi-").ShouldBe("abc.def.ghi");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SplitFunction_LeadingDelimiter()
|
||||
{
|
||||
var transform = SubjectTransform.Create("*", "{{split(1,-)}}");
|
||||
transform.ShouldNotBeNull();
|
||||
transform.Apply("-abc-def--ghi-").ShouldBe("abc.def.ghi");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LeftFunction_BasicTrim()
|
||||
{
|
||||
// data.abcdef with left(1,3) -> prefix.abc
|
||||
var transform = SubjectTransform.Create("*", "prefix.{{left(1,3)}}");
|
||||
transform.ShouldNotBeNull();
|
||||
transform.Apply("abcdef").ShouldBe("prefix.abc");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LeftFunction_LenExceedsToken()
|
||||
{
|
||||
var transform = SubjectTransform.Create("*", "{{left(1,6)}}");
|
||||
transform.ShouldNotBeNull();
|
||||
// When len exceeds token length, return full token
|
||||
transform.Apply("1234").ShouldBe("1234");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LeftFunction_SingleChar()
|
||||
{
|
||||
var transform = SubjectTransform.Create("*", "{{left(1,1)}}");
|
||||
transform.ShouldNotBeNull();
|
||||
transform.Apply("1234").ShouldBe("1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RightFunction_BasicTrim()
|
||||
{
|
||||
// data.abcdef with right(1,3) -> suffix.def
|
||||
var transform = SubjectTransform.Create("*", "suffix.{{right(1,3)}}");
|
||||
transform.ShouldNotBeNull();
|
||||
transform.Apply("abcdef").ShouldBe("suffix.def");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RightFunction_LenExceedsToken()
|
||||
{
|
||||
var transform = SubjectTransform.Create("*", "{{right(1,6)}}");
|
||||
transform.ShouldNotBeNull();
|
||||
transform.Apply("1234").ShouldBe("1234");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RightFunction_SingleChar()
|
||||
{
|
||||
var transform = SubjectTransform.Create("*", "{{right(1,1)}}");
|
||||
transform.ShouldNotBeNull();
|
||||
transform.Apply("1234").ShouldBe("4");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RightFunction_ThreeChars()
|
||||
{
|
||||
var transform = SubjectTransform.Create("*", "{{right(1,3)}}");
|
||||
transform.ShouldNotBeNull();
|
||||
transform.Apply("1234").ShouldBe("234");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SplitFromLeft_BasicSplit()
|
||||
{
|
||||
// data.abcdef with splitFromLeft(1,3) -> parts.abc.def
|
||||
var transform = SubjectTransform.Create("*", "{{splitFromLeft(1,3)}}");
|
||||
transform.ShouldNotBeNull();
|
||||
transform.Apply("12345").ShouldBe("123.45");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SplitFromRight_BasicSplit()
|
||||
{
|
||||
// data.abcdef with splitFromRight(1,3) -> parts.abc.def
|
||||
var transform = SubjectTransform.Create("*", "{{SplitFromRight(1,3)}}");
|
||||
transform.ShouldNotBeNull();
|
||||
transform.Apply("12345").ShouldBe("12.345");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SliceFromLeft_BasicSlice()
|
||||
{
|
||||
// data.abcdef with sliceFromLeft(1,2) -> chunks.ab.cd.ef
|
||||
var transform = SubjectTransform.Create("*", "{{SliceFromLeft(1,3)}}");
|
||||
transform.ShouldNotBeNull();
|
||||
transform.Apply("1234567890").ShouldBe("123.456.789.0");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SliceFromRight_BasicSlice()
|
||||
{
|
||||
// data.abcdef with sliceFromRight(1,2) -> chunks.ab.cd.ef
|
||||
var transform = SubjectTransform.Create("*", "{{SliceFromRight(1,3)}}");
|
||||
transform.ShouldNotBeNull();
|
||||
transform.Apply("1234567890").ShouldBe("1.234.567.890");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LiteralPassthrough_NoWildcards()
|
||||
{
|
||||
// Literal source with no wildcards: exact match, returns dest
|
||||
var transform = SubjectTransform.Create("foo", "bar");
|
||||
transform.ShouldNotBeNull();
|
||||
transform.Apply("foo").ShouldBe("bar");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LiteralPassthrough_NoMatchOnDifferentSubject()
|
||||
{
|
||||
var transform = SubjectTransform.Create("foo", "bar");
|
||||
transform.ShouldNotBeNull();
|
||||
transform.Apply("baz").ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InvalidSource_ReturnsNull()
|
||||
{
|
||||
// foo.. is not a valid subject
|
||||
SubjectTransform.Create("foo..", "bar").ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InvalidSource_EmptyToken()
|
||||
{
|
||||
SubjectTransform.Create(".foo", "bar").ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WildcardIndexOutOfRange_ReturnsNull()
|
||||
{
|
||||
// Source has 1 wildcard but dest references $2
|
||||
SubjectTransform.Create("foo.*", "bar.$2").ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DestinationWithWildcard_ReturnsNull()
|
||||
{
|
||||
// Wildcards not allowed in destination (pwc)
|
||||
SubjectTransform.Create("foo.*", "bar.*").ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FwcMismatch_ReturnsNull()
|
||||
{
|
||||
// If source has >, dest must also have >
|
||||
SubjectTransform.Create("foo.*", "bar.$1.>").ShouldBeNull();
|
||||
SubjectTransform.Create("foo.>", "bar.baz").ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnknownFunction_ReturnsNull()
|
||||
{
|
||||
SubjectTransform.Create("foo.*", "foo.{{unimplemented(1)}}").ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SingleWildcardCapture_ExpandedToBarPrefix()
|
||||
{
|
||||
var transform = SubjectTransform.Create("*", "foo.bar.$1");
|
||||
transform.ShouldNotBeNull();
|
||||
transform.Apply("foo").ShouldBe("foo.bar.foo");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComboTransform_SplitAndSplitFromLeft()
|
||||
{
|
||||
// Combo: split + splitFromLeft
|
||||
var transform = SubjectTransform.Create("*.*", "{{split(2,-)}}.{{splitfromleft(1,2)}}");
|
||||
transform.ShouldNotBeNull();
|
||||
transform.Apply("foo.-abc-def--ghij-").ShouldBe("abc.def.ghij.fo.o");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PartitionFunction_NoWildcardSource_FullSubjectHash()
|
||||
{
|
||||
// foo.baz -> qux.{{partition(10)}}
|
||||
var transform = SubjectTransform.Create("foo.baz", "qux.{{partition(10)}}");
|
||||
transform.ShouldNotBeNull();
|
||||
transform.Apply("foo.baz").ShouldBe("qux.4");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PartitionFunction_NoWildcardSource_TestSubject()
|
||||
{
|
||||
var transform = SubjectTransform.Create("test.subject", "result.{{partition(5)}}");
|
||||
transform.ShouldNotBeNull();
|
||||
transform.Apply("test.subject").ShouldBe("result.0");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WildcardFunction_CaseInsensitive()
|
||||
{
|
||||
// Function names are case-insensitive (e.g. Wildcard, wildcard, WILDCARD)
|
||||
var transform = SubjectTransform.Create("foo.*", "bar.{{Wildcard(1)}}");
|
||||
transform.ShouldNotBeNull();
|
||||
transform.Apply("foo.test").ShouldBe("bar.test");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SplitFromLeft_CaseInsensitive()
|
||||
{
|
||||
var transform = SubjectTransform.Create("*", "{{splitfromleft(1,1)}}");
|
||||
transform.ShouldNotBeNull();
|
||||
// Single char split from left pos 1: "ab" -> "a.b"
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NotEnoughTokensInDest_PartitionWithMissingArgs()
|
||||
{
|
||||
SubjectTransform.Create("foo.*", "foo.{{partition()}}").ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WildcardFunctionBadArg_ReturnsNull()
|
||||
{
|
||||
SubjectTransform.Create("foo.*", "foo.{{wildcard(foo)}}").ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WildcardFunctionNoArgs_ReturnsNull()
|
||||
{
|
||||
SubjectTransform.Create("foo.*", "foo.{{wildcard()}}").ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WildcardFunctionTooManyArgs_ReturnsNull()
|
||||
{
|
||||
SubjectTransform.Create("foo.*", "foo.{{wildcard(1,2)}}").ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BadMustacheFormat_ReturnsNull()
|
||||
{
|
||||
SubjectTransform.Create("foo.*", "foo.{{ wildcard5) }}").ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NoWildcardSource_TransformFunctionNotAllowed()
|
||||
{
|
||||
// When source has no wildcards, only partition and random functions are allowed
|
||||
SubjectTransform.Create("foo", "bla.{{wildcard(1)}}").ShouldBeNull();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user