diff --git a/src/NATS.Server/Auth/Jwt/AccountClaims.cs b/src/NATS.Server/Auth/Jwt/AccountClaims.cs
new file mode 100644
index 0000000..07513da
--- /dev/null
+++ b/src/NATS.Server/Auth/Jwt/AccountClaims.cs
@@ -0,0 +1,90 @@
+using System.Text.Json.Serialization;
+
+namespace NATS.Server.Auth.Jwt;
+
+///
+/// Represents the claims in a NATS account JWT.
+/// Contains standard JWT fields (sub, iss, iat, exp) and a NATS-specific nested object
+/// with account limits, signing keys, and revocations.
+///
+///
+/// Reference: github.com/nats-io/jwt/v2 — AccountClaims, Account, OperatorLimits types
+///
+public sealed class AccountClaims
+{
+ /// Subject — the account's NKey public key.
+ [JsonPropertyName("sub")]
+ public string? Subject { get; set; }
+
+ /// Issuer — the operator or signing key that issued this JWT.
+ [JsonPropertyName("iss")]
+ public string? Issuer { get; set; }
+
+ /// Issued-at time as Unix epoch seconds.
+ [JsonPropertyName("iat")]
+ public long IssuedAt { get; set; }
+
+ /// Expiration time as Unix epoch seconds. 0 means no expiry.
+ [JsonPropertyName("exp")]
+ public long Expires { get; set; }
+
+ /// Human-readable name for the account.
+ [JsonPropertyName("name")]
+ public string? Name { get; set; }
+
+ /// NATS-specific account claims.
+ [JsonPropertyName("nats")]
+ public AccountNats? Nats { get; set; }
+}
+
+///
+/// NATS-specific portion of account JWT claims.
+/// Contains limits, signing keys, and user revocations.
+///
+public sealed class AccountNats
+{
+ /// Account resource limits.
+ [JsonPropertyName("limits")]
+ public AccountLimits? Limits { get; set; }
+
+ /// NKey public keys authorized to sign user JWTs for this account.
+ [JsonPropertyName("signing_keys")]
+ public string[]? SigningKeys { get; set; }
+
+ ///
+ /// Map of revoked user NKey public keys to the Unix epoch time of revocation.
+ /// Any user JWT issued before the revocation time is considered revoked.
+ ///
+ [JsonPropertyName("revocations")]
+ public Dictionary? Revocations { get; set; }
+
+ /// Claim type (e.g., "account").
+ [JsonPropertyName("type")]
+ public string? Type { get; set; }
+
+ /// Claim version.
+ [JsonPropertyName("version")]
+ public int Version { get; set; }
+}
+
+///
+/// Resource limits for a NATS account. A value of -1 means unlimited.
+///
+public sealed class AccountLimits
+{
+ /// Maximum number of connections. -1 means unlimited.
+ [JsonPropertyName("conn")]
+ public long MaxConnections { get; set; }
+
+ /// Maximum number of subscriptions. -1 means unlimited.
+ [JsonPropertyName("subs")]
+ public long MaxSubscriptions { get; set; }
+
+ /// Maximum payload size in bytes. -1 means unlimited.
+ [JsonPropertyName("payload")]
+ public long MaxPayload { get; set; }
+
+ /// Maximum data transfer in bytes. -1 means unlimited.
+ [JsonPropertyName("data")]
+ public long MaxData { get; set; }
+}
diff --git a/src/NATS.Server/Auth/Jwt/NatsJwt.cs b/src/NATS.Server/Auth/Jwt/NatsJwt.cs
new file mode 100644
index 0000000..2b23b5d
--- /dev/null
+++ b/src/NATS.Server/Auth/Jwt/NatsJwt.cs
@@ -0,0 +1,221 @@
+using System.Text;
+using System.Text.Json;
+using NATS.NKeys;
+
+namespace NATS.Server.Auth.Jwt;
+
+///
+/// Provides NATS JWT decode, verify, and claim extraction.
+/// NATS JWTs are standard JWT format (base64url header.payload.signature) with Ed25519 signing.
+/// All NATS JWTs start with "eyJ" (base64url for '{"').
+///
+///
+/// Reference: golang/nats-server/server/jwt.go and github.com/nats-io/jwt/v2
+///
+public static class NatsJwt
+{
+ private const string JwtPrefix = "eyJ";
+
+ ///
+ /// Returns true if the string appears to be a JWT (starts with "eyJ").
+ ///
+ public static bool IsJwt(string token)
+ {
+ return !string.IsNullOrEmpty(token) && token.StartsWith(JwtPrefix, StringComparison.Ordinal);
+ }
+
+ ///
+ /// Decodes a JWT token into its constituent parts without verifying the signature.
+ /// Returns null if the token is structurally invalid.
+ ///
+ public static JwtToken? Decode(string token)
+ {
+ if (string.IsNullOrEmpty(token))
+ return null;
+
+ var parts = token.Split('.');
+ if (parts.Length != 3)
+ return null;
+
+ try
+ {
+ var headerBytes = Base64UrlDecode(parts[0]);
+ var payloadBytes = Base64UrlDecode(parts[1]);
+ var signatureBytes = Base64UrlDecode(parts[2]);
+
+ var header = JsonSerializer.Deserialize(headerBytes);
+ if (header is null)
+ return null;
+
+ var payloadJson = Encoding.UTF8.GetString(payloadBytes);
+ var signingInput = $"{parts[0]}.{parts[1]}";
+
+ return new JwtToken
+ {
+ Header = header,
+ PayloadJson = payloadJson,
+ Signature = signatureBytes,
+ SigningInput = signingInput,
+ };
+ }
+ catch
+ {
+ return null;
+ }
+ }
+
+ ///
+ /// Decodes a JWT token and deserializes the payload as .
+ /// Returns null if the token is structurally invalid or cannot be deserialized.
+ ///
+ public static UserClaims? DecodeUserClaims(string token)
+ {
+ var jwt = Decode(token);
+ if (jwt is null)
+ return null;
+
+ try
+ {
+ return JsonSerializer.Deserialize(jwt.PayloadJson);
+ }
+ catch
+ {
+ return null;
+ }
+ }
+
+ ///
+ /// Decodes a JWT token and deserializes the payload as .
+ /// Returns null if the token is structurally invalid or cannot be deserialized.
+ ///
+ public static AccountClaims? DecodeAccountClaims(string token)
+ {
+ var jwt = Decode(token);
+ if (jwt is null)
+ return null;
+
+ try
+ {
+ return JsonSerializer.Deserialize(jwt.PayloadJson);
+ }
+ catch
+ {
+ return null;
+ }
+ }
+
+ ///
+ /// Verifies the Ed25519 signature on a JWT token against the given NKey public key.
+ ///
+ public static bool Verify(string token, string publicNkey)
+ {
+ try
+ {
+ var jwt = Decode(token);
+ if (jwt is null)
+ return false;
+
+ var kp = KeyPair.FromPublicKey(publicNkey);
+ var signingInputBytes = Encoding.UTF8.GetBytes(jwt.SigningInput);
+ return kp.Verify(signingInputBytes, jwt.Signature);
+ }
+ catch
+ {
+ return false;
+ }
+ }
+
+ ///
+ /// Verifies a nonce signature against the given NKey public key.
+ /// Tries base64url decoding first, then falls back to standard base64 (Go compatibility).
+ ///
+ public static bool VerifyNonce(byte[] nonce, string signature, string publicNkey)
+ {
+ try
+ {
+ var sigBytes = TryDecodeSignature(signature);
+ if (sigBytes is null)
+ return false;
+
+ var kp = KeyPair.FromPublicKey(publicNkey);
+ return kp.Verify(nonce, sigBytes);
+ }
+ catch
+ {
+ return false;
+ }
+ }
+
+ ///
+ /// Decodes a base64url-encoded byte array.
+ /// Replaces URL-safe characters and adds padding as needed.
+ ///
+ internal static byte[] Base64UrlDecode(string input)
+ {
+ var s = input.Replace('-', '+').Replace('_', '/');
+ switch (s.Length % 4)
+ {
+ case 2: s += "=="; break;
+ case 3: s += "="; break;
+ }
+
+ return Convert.FromBase64String(s);
+ }
+
+ ///
+ /// Attempts to decode a signature string. Tries base64url first, then standard base64.
+ /// Returns null if neither encoding works.
+ ///
+ private static byte[]? TryDecodeSignature(string signature)
+ {
+ // Try base64url first
+ try
+ {
+ return Base64UrlDecode(signature);
+ }
+ catch (FormatException)
+ {
+ // Fall through to standard base64
+ }
+
+ // Try standard base64
+ try
+ {
+ return Convert.FromBase64String(signature);
+ }
+ catch (FormatException)
+ {
+ return null;
+ }
+ }
+}
+
+///
+/// Represents a decoded JWT token with its constituent parts.
+///
+public sealed class JwtToken
+{
+ /// The decoded JWT header.
+ public required JwtHeader Header { get; init; }
+
+ /// The raw JSON string of the payload.
+ public required string PayloadJson { get; init; }
+
+ /// The raw signature bytes.
+ public required byte[] Signature { get; init; }
+
+ /// The signing input (header.payload in base64url) used for signature verification.
+ public required string SigningInput { get; init; }
+}
+
+///
+/// NATS JWT header. Algorithm is "ed25519-nkey" for NATS JWTs.
+///
+public sealed class JwtHeader
+{
+ [System.Text.Json.Serialization.JsonPropertyName("alg")]
+ public string? Algorithm { get; set; }
+
+ [System.Text.Json.Serialization.JsonPropertyName("typ")]
+ public string? Type { get; set; }
+}
diff --git a/src/NATS.Server/Auth/Jwt/UserClaims.cs b/src/NATS.Server/Auth/Jwt/UserClaims.cs
new file mode 100644
index 0000000..22a1b05
--- /dev/null
+++ b/src/NATS.Server/Auth/Jwt/UserClaims.cs
@@ -0,0 +1,173 @@
+using System.Text.Json.Serialization;
+
+namespace NATS.Server.Auth.Jwt;
+
+///
+/// Represents the claims in a NATS user JWT.
+/// Contains standard JWT fields (sub, iss, iat, exp) and a NATS-specific nested object
+/// with user permissions, bearer token flags, and connection restrictions.
+///
+///
+/// Reference: github.com/nats-io/jwt/v2 — UserClaims, User, Permission types
+///
+public sealed class UserClaims
+{
+ /// Subject — the user's NKey public key.
+ [JsonPropertyName("sub")]
+ public string? Subject { get; set; }
+
+ /// Issuer — the account or signing key that issued this JWT.
+ [JsonPropertyName("iss")]
+ public string? Issuer { get; set; }
+
+ /// Issued-at time as Unix epoch seconds.
+ [JsonPropertyName("iat")]
+ public long IssuedAt { get; set; }
+
+ /// Expiration time as Unix epoch seconds. 0 means no expiry.
+ [JsonPropertyName("exp")]
+ public long Expires { get; set; }
+
+ /// Human-readable name for the user.
+ [JsonPropertyName("name")]
+ public string? Name { get; set; }
+
+ /// NATS-specific user claims.
+ [JsonPropertyName("nats")]
+ public UserNats? Nats { get; set; }
+
+ // =========================================================================
+ // Convenience properties that delegate to the Nats sub-object
+ // =========================================================================
+
+ /// Whether this is a bearer token (no client nonce signature required).
+ [JsonIgnore]
+ public bool BearerToken => Nats?.BearerToken ?? false;
+
+ /// The account NKey public key that issued this user JWT.
+ [JsonIgnore]
+ public string? IssuerAccount => Nats?.IssuerAccount;
+
+ // =========================================================================
+ // Expiry helpers
+ // =========================================================================
+
+ ///
+ /// Returns true if the JWT has expired. A zero Expires value means no expiry.
+ ///
+ public bool IsExpired()
+ {
+ if (Expires == 0)
+ return false;
+ return DateTimeOffset.UtcNow.ToUnixTimeSeconds() > Expires;
+ }
+
+ ///
+ /// Returns the expiry as a , or null if there is no expiry (Expires == 0).
+ ///
+ public DateTimeOffset? GetExpiry()
+ {
+ if (Expires == 0)
+ return null;
+ return DateTimeOffset.FromUnixTimeSeconds(Expires);
+ }
+}
+
+///
+/// NATS-specific portion of user JWT claims.
+/// Contains permissions, bearer token flag, connection restrictions, and more.
+///
+public sealed class UserNats
+{
+ /// Publish permission with allow/deny subject lists.
+ [JsonPropertyName("pub")]
+ public JwtSubjectPermission? Pub { get; set; }
+
+ /// Subscribe permission with allow/deny subject lists.
+ [JsonPropertyName("sub")]
+ public JwtSubjectPermission? Sub { get; set; }
+
+ /// Response permission controlling request-reply behavior.
+ [JsonPropertyName("resp")]
+ public JwtResponsePermission? Resp { get; set; }
+
+ /// Whether this is a bearer token (no nonce signature required).
+ [JsonPropertyName("bearer_token")]
+ public bool BearerToken { get; set; }
+
+ /// The account NKey public key that issued this user JWT.
+ [JsonPropertyName("issuer_account")]
+ public string? IssuerAccount { get; set; }
+
+ /// Tags associated with this user.
+ [JsonPropertyName("tags")]
+ public string[]? Tags { get; set; }
+
+ /// Allowed source CIDRs for this user's connections.
+ [JsonPropertyName("src")]
+ public string[]? Src { get; set; }
+
+ /// Allowed connection types (e.g., "STANDARD", "WEBSOCKET", "LEAFNODE").
+ [JsonPropertyName("allowed_connection_types")]
+ public string[]? AllowedConnectionTypes { get; set; }
+
+ /// Time-of-day restrictions for when the user may connect.
+ [JsonPropertyName("times")]
+ public JwtTimeRange[]? Times { get; set; }
+
+ /// Claim type (e.g., "user").
+ [JsonPropertyName("type")]
+ public string? Type { get; set; }
+
+ /// Claim version.
+ [JsonPropertyName("version")]
+ public int Version { get; set; }
+}
+
+///
+/// Subject permission with allow and deny lists, as used in NATS JWTs.
+///
+public sealed class JwtSubjectPermission
+{
+ /// Subjects the user is allowed to publish/subscribe to.
+ [JsonPropertyName("allow")]
+ public string[]? Allow { get; set; }
+
+ /// Subjects the user is denied from publishing/subscribing to.
+ [JsonPropertyName("deny")]
+ public string[]? Deny { get; set; }
+}
+
+///
+/// Response permission controlling request-reply behavior in NATS JWTs.
+///
+public sealed class JwtResponsePermission
+{
+ /// Maximum number of response messages allowed.
+ [JsonPropertyName("max")]
+ public int MaxMsgs { get; set; }
+
+ /// Time-to-live for the response permission, in nanoseconds.
+ [JsonPropertyName("ttl")]
+ public long TtlNanos { get; set; }
+
+ ///
+ /// Convenience property: converts to a .
+ ///
+ [JsonIgnore]
+ public TimeSpan Ttl => TimeSpan.FromTicks(TtlNanos / 100); // 1 tick = 100 nanoseconds
+}
+
+///
+/// A time-of-day range for connection restrictions.
+///
+public sealed class JwtTimeRange
+{
+ /// Start time in HH:mm:ss format.
+ [JsonPropertyName("start")]
+ public string? Start { get; set; }
+
+ /// End time in HH:mm:ss format.
+ [JsonPropertyName("end")]
+ public string? End { get; set; }
+}
diff --git a/tests/NATS.Server.Tests/JwtTests.cs b/tests/NATS.Server.Tests/JwtTests.cs
new file mode 100644
index 0000000..ce49c54
--- /dev/null
+++ b/tests/NATS.Server.Tests/JwtTests.cs
@@ -0,0 +1,932 @@
+using System.Text;
+using System.Text.Json;
+using NATS.NKeys;
+using NATS.Server.Auth.Jwt;
+
+namespace NATS.Server.Tests;
+
+public class JwtTests
+{
+ ///
+ /// Helper: base64url-encode a string for constructing test JWTs.
+ ///
+ private static string Base64UrlEncode(string value)
+ {
+ var bytes = Encoding.UTF8.GetBytes(value);
+ return Convert.ToBase64String(bytes)
+ .TrimEnd('=')
+ .Replace('+', '-')
+ .Replace('/', '_');
+ }
+
+ ///
+ /// Helper: build a minimal unsigned JWT from header and payload JSON strings.
+ /// The signature part is a base64url-encoded 64-byte zero array (invalid but structurally correct).
+ ///
+ private static string BuildUnsignedToken(string headerJson, string payloadJson)
+ {
+ var header = Base64UrlEncode(headerJson);
+ var payload = Base64UrlEncode(payloadJson);
+ var fakeSig = Convert.ToBase64String(new byte[64])
+ .TrimEnd('=')
+ .Replace('+', '-')
+ .Replace('/', '_');
+ return $"{header}.{payload}.{fakeSig}";
+ }
+
+ ///
+ /// Helper: build a real signed NATS JWT using an NKey keypair.
+ /// Signs header.payload with Ed25519.
+ ///
+ private static string BuildSignedToken(string payloadJson, KeyPair signingKey)
+ {
+ var headerJson = """{"typ":"JWT","alg":"ed25519-nkey"}""";
+ var header = Base64UrlEncode(headerJson);
+ var payload = Base64UrlEncode(payloadJson);
+ var signingInput = $"{header}.{payload}";
+ var signingInputBytes = Encoding.UTF8.GetBytes(signingInput);
+
+ var sig = new byte[64];
+ signingKey.Sign(signingInputBytes, sig);
+
+ var sigB64 = Convert.ToBase64String(sig)
+ .TrimEnd('=')
+ .Replace('+', '-')
+ .Replace('/', '_');
+
+ return $"{header}.{payload}.{sigB64}";
+ }
+
+ // =====================================================================
+ // IsJwt tests
+ // =====================================================================
+
+ [Fact]
+ public void IsJwt_returns_true_for_eyJ_prefix()
+ {
+ NatsJwt.IsJwt("eyJhbGciOiJlZDI1NTE5LW5rZXkiLCJ0eXAiOiJKV1QifQ.payload.sig").ShouldBeTrue();
+ }
+
+ [Fact]
+ public void IsJwt_returns_true_for_minimal_eyJ()
+ {
+ NatsJwt.IsJwt("eyJ").ShouldBeTrue();
+ }
+
+ [Fact]
+ public void IsJwt_returns_false_for_non_jwt()
+ {
+ NatsJwt.IsJwt("notajwt").ShouldBeFalse();
+ }
+
+ [Fact]
+ public void IsJwt_returns_false_for_empty_string()
+ {
+ NatsJwt.IsJwt("").ShouldBeFalse();
+ }
+
+ [Fact]
+ public void IsJwt_returns_false_for_null()
+ {
+ NatsJwt.IsJwt(null!).ShouldBeFalse();
+ }
+
+ // =====================================================================
+ // Decode tests
+ // =====================================================================
+
+ [Fact]
+ public void Decode_splits_header_payload_signature_correctly()
+ {
+ var headerJson = """{"typ":"JWT","alg":"ed25519-nkey"}""";
+ var payloadJson = """{"sub":"UAXXX","iss":"AAXXX","iat":1700000000}""";
+ var token = BuildUnsignedToken(headerJson, payloadJson);
+
+ var result = NatsJwt.Decode(token);
+
+ result.ShouldNotBeNull();
+ result.Header.ShouldNotBeNull();
+ result.Header.Type.ShouldBe("JWT");
+ result.Header.Algorithm.ShouldBe("ed25519-nkey");
+ }
+
+ [Fact]
+ public void Decode_returns_payload_json()
+ {
+ var headerJson = """{"typ":"JWT","alg":"ed25519-nkey"}""";
+ var payloadJson = """{"sub":"UAXXX","iss":"AAXXX","iat":1700000000}""";
+ var token = BuildUnsignedToken(headerJson, payloadJson);
+
+ var result = NatsJwt.Decode(token);
+
+ result.ShouldNotBeNull();
+ result.PayloadJson.ShouldNotBeNullOrEmpty();
+
+ // The payload JSON should parse back to matching fields
+ using var doc = JsonDocument.Parse(result.PayloadJson);
+ doc.RootElement.GetProperty("sub").GetString().ShouldBe("UAXXX");
+ doc.RootElement.GetProperty("iss").GetString().ShouldBe("AAXXX");
+ }
+
+ [Fact]
+ public void Decode_preserves_signature_bytes()
+ {
+ var headerJson = """{"typ":"JWT","alg":"ed25519-nkey"}""";
+ var payloadJson = """{"sub":"test"}""";
+ var token = BuildUnsignedToken(headerJson, payloadJson);
+
+ var result = NatsJwt.Decode(token);
+
+ result.ShouldNotBeNull();
+ result.Signature.ShouldNotBeNull();
+ result.Signature.Length.ShouldBe(64);
+ }
+
+ [Fact]
+ public void Decode_preserves_signing_input()
+ {
+ var headerJson = """{"typ":"JWT","alg":"ed25519-nkey"}""";
+ var payloadJson = """{"sub":"test"}""";
+ var token = BuildUnsignedToken(headerJson, payloadJson);
+
+ var result = NatsJwt.Decode(token);
+
+ result.ShouldNotBeNull();
+
+ // SigningInput should be "header.payload" (the first two parts)
+ var parts = token.Split('.');
+ var expectedSigningInput = $"{parts[0]}.{parts[1]}";
+ result.SigningInput.ShouldBe(expectedSigningInput);
+ }
+
+ [Fact]
+ public void Decode_returns_null_for_invalid_token_missing_parts()
+ {
+ NatsJwt.Decode("onlyonepart").ShouldBeNull();
+ }
+
+ [Fact]
+ public void Decode_returns_null_for_two_parts()
+ {
+ NatsJwt.Decode("part1.part2").ShouldBeNull();
+ }
+
+ [Fact]
+ public void Decode_returns_null_for_empty_string()
+ {
+ NatsJwt.Decode("").ShouldBeNull();
+ }
+
+ [Fact]
+ public void Decode_returns_null_for_invalid_base64_in_header()
+ {
+ NatsJwt.Decode("!!!invalid.payload.sig").ShouldBeNull();
+ }
+
+ // =====================================================================
+ // Verify tests
+ // =====================================================================
+
+ [Fact]
+ public void Verify_returns_true_for_valid_signed_token()
+ {
+ var accountKp = KeyPair.CreatePair(PrefixByte.Account);
+ var accountPublicKey = accountKp.GetPublicKey();
+
+ var payloadJson = $$"""{"sub":"UAXXX","iss":"{{accountPublicKey}}","iat":1700000000}""";
+ var token = BuildSignedToken(payloadJson, accountKp);
+
+ NatsJwt.Verify(token, accountPublicKey).ShouldBeTrue();
+ }
+
+ [Fact]
+ public void Verify_returns_false_for_wrong_key()
+ {
+ var signingKp = KeyPair.CreatePair(PrefixByte.Account);
+ var wrongKp = KeyPair.CreatePair(PrefixByte.Account);
+
+ var payloadJson = """{"sub":"UAXXX","iss":"AAXXX","iat":1700000000}""";
+ var token = BuildSignedToken(payloadJson, signingKp);
+
+ NatsJwt.Verify(token, wrongKp.GetPublicKey()).ShouldBeFalse();
+ }
+
+ [Fact]
+ public void Verify_returns_false_for_tampered_payload()
+ {
+ var accountKp = KeyPair.CreatePair(PrefixByte.Account);
+ var accountPublicKey = accountKp.GetPublicKey();
+
+ var payloadJson = """{"sub":"UAXXX","iss":"AAXXX","iat":1700000000}""";
+ var token = BuildSignedToken(payloadJson, accountKp);
+
+ // Tamper with the payload
+ var parts = token.Split('.');
+ var tamperedPayload = Base64UrlEncode("""{"sub":"HACKED","iss":"AAXXX","iat":1700000000}""");
+ var tampered = $"{parts[0]}.{tamperedPayload}.{parts[2]}";
+
+ NatsJwt.Verify(tampered, accountPublicKey).ShouldBeFalse();
+ }
+
+ [Fact]
+ public void Verify_returns_false_for_invalid_token()
+ {
+ var kp = KeyPair.CreatePair(PrefixByte.Account);
+ NatsJwt.Verify("not.a.jwt", kp.GetPublicKey()).ShouldBeFalse();
+ }
+
+ // =====================================================================
+ // VerifyNonce tests
+ // =====================================================================
+
+ [Fact]
+ public void VerifyNonce_accepts_base64url_signature()
+ {
+ var kp = KeyPair.CreatePair(PrefixByte.User);
+ var publicKey = kp.GetPublicKey();
+ var nonce = "test-nonce-data"u8.ToArray();
+
+ var sig = new byte[64];
+ kp.Sign(nonce, sig);
+
+ // Encode as base64url
+ var sigB64Url = Convert.ToBase64String(sig)
+ .TrimEnd('=')
+ .Replace('+', '-')
+ .Replace('/', '_');
+
+ NatsJwt.VerifyNonce(nonce, sigB64Url, publicKey).ShouldBeTrue();
+ }
+
+ [Fact]
+ public void VerifyNonce_accepts_standard_base64_signature()
+ {
+ var kp = KeyPair.CreatePair(PrefixByte.User);
+ var publicKey = kp.GetPublicKey();
+ var nonce = "test-nonce-data"u8.ToArray();
+
+ var sig = new byte[64];
+ kp.Sign(nonce, sig);
+
+ // Encode as standard base64
+ var sigB64 = Convert.ToBase64String(sig);
+
+ NatsJwt.VerifyNonce(nonce, sigB64, publicKey).ShouldBeTrue();
+ }
+
+ [Fact]
+ public void VerifyNonce_returns_false_for_wrong_nonce()
+ {
+ var kp = KeyPair.CreatePair(PrefixByte.User);
+ var publicKey = kp.GetPublicKey();
+ var nonce = "original-nonce"u8.ToArray();
+ var wrongNonce = "different-nonce"u8.ToArray();
+
+ var sig = new byte[64];
+ kp.Sign(nonce, sig);
+ var sigB64 = Convert.ToBase64String(sig);
+
+ NatsJwt.VerifyNonce(wrongNonce, sigB64, publicKey).ShouldBeFalse();
+ }
+
+ [Fact]
+ public void VerifyNonce_returns_false_for_invalid_signature()
+ {
+ var kp = KeyPair.CreatePair(PrefixByte.User);
+ var publicKey = kp.GetPublicKey();
+ var nonce = "test-nonce"u8.ToArray();
+
+ NatsJwt.VerifyNonce(nonce, "invalid-sig!", publicKey).ShouldBeFalse();
+ }
+
+ // =====================================================================
+ // DecodeUserClaims tests
+ // =====================================================================
+
+ [Fact]
+ public void DecodeUserClaims_parses_subject_and_issuer()
+ {
+ var headerJson = """{"typ":"JWT","alg":"ed25519-nkey"}""";
+ var payloadJson = """
+ {
+ "sub":"UAXXX_USER_KEY",
+ "iss":"AAXXX_ISSUER",
+ "iat":1700000000,
+ "name":"test-user",
+ "nats":{
+ "type":"user",
+ "version":2
+ }
+ }
+ """;
+ var token = BuildUnsignedToken(headerJson, payloadJson);
+
+ var claims = NatsJwt.DecodeUserClaims(token);
+
+ claims.ShouldNotBeNull();
+ claims.Subject.ShouldBe("UAXXX_USER_KEY");
+ claims.Issuer.ShouldBe("AAXXX_ISSUER");
+ claims.Name.ShouldBe("test-user");
+ claims.IssuedAt.ShouldBe(1700000000);
+ }
+
+ [Fact]
+ public void DecodeUserClaims_parses_pub_sub_permissions()
+ {
+ var headerJson = """{"typ":"JWT","alg":"ed25519-nkey"}""";
+ var payloadJson = """
+ {
+ "sub":"UAXXX",
+ "iss":"AAXXX",
+ "iat":1700000000,
+ "nats":{
+ "pub":{"allow":["foo.>","bar.*"],"deny":["bar.secret"]},
+ "sub":{"allow":[">"],"deny":["_INBOX.private.>"]},
+ "type":"user",
+ "version":2
+ }
+ }
+ """;
+ var token = BuildUnsignedToken(headerJson, payloadJson);
+
+ var claims = NatsJwt.DecodeUserClaims(token);
+
+ claims.ShouldNotBeNull();
+ claims.Nats.ShouldNotBeNull();
+
+ claims.Nats.Pub.ShouldNotBeNull();
+ claims.Nats.Pub.Allow.ShouldBe(["foo.>", "bar.*"]);
+ claims.Nats.Pub.Deny.ShouldBe(["bar.secret"]);
+
+ claims.Nats.Sub.ShouldNotBeNull();
+ claims.Nats.Sub.Allow.ShouldBe([">"]);
+ claims.Nats.Sub.Deny.ShouldBe(["_INBOX.private.>"]);
+ }
+
+ [Fact]
+ public void DecodeUserClaims_parses_response_permission()
+ {
+ var headerJson = """{"typ":"JWT","alg":"ed25519-nkey"}""";
+ var payloadJson = """
+ {
+ "sub":"UAXXX",
+ "iss":"AAXXX",
+ "iat":1700000000,
+ "nats":{
+ "resp":{"max":5,"ttl":3000000000},
+ "type":"user",
+ "version":2
+ }
+ }
+ """;
+ var token = BuildUnsignedToken(headerJson, payloadJson);
+
+ var claims = NatsJwt.DecodeUserClaims(token);
+
+ claims.ShouldNotBeNull();
+ claims.Nats.ShouldNotBeNull();
+ claims.Nats.Resp.ShouldNotBeNull();
+ claims.Nats.Resp.MaxMsgs.ShouldBe(5);
+ claims.Nats.Resp.TtlNanos.ShouldBe(3000000000L);
+ claims.Nats.Resp.Ttl.ShouldBe(TimeSpan.FromSeconds(3));
+ }
+
+ [Fact]
+ public void DecodeUserClaims_parses_bearer_token_flag()
+ {
+ var headerJson = """{"typ":"JWT","alg":"ed25519-nkey"}""";
+ var payloadJson = """
+ {
+ "sub":"UAXXX",
+ "iss":"AAXXX",
+ "iat":1700000000,
+ "nats":{
+ "bearer_token":true,
+ "issuer_account":"AAXXX_ISSUER_ACCOUNT",
+ "type":"user",
+ "version":2
+ }
+ }
+ """;
+ var token = BuildUnsignedToken(headerJson, payloadJson);
+
+ var claims = NatsJwt.DecodeUserClaims(token);
+
+ claims.ShouldNotBeNull();
+ claims.Nats.ShouldNotBeNull();
+ claims.Nats.BearerToken.ShouldBeTrue();
+ claims.Nats.IssuerAccount.ShouldBe("AAXXX_ISSUER_ACCOUNT");
+ }
+
+ [Fact]
+ public void DecodeUserClaims_parses_tags_src_connection_types()
+ {
+ var headerJson = """{"typ":"JWT","alg":"ed25519-nkey"}""";
+ var payloadJson = """
+ {
+ "sub":"UAXXX",
+ "iss":"AAXXX",
+ "iat":1700000000,
+ "nats":{
+ "tags":["web","mobile"],
+ "src":["192.168.1.0/24","10.0.0.0/8"],
+ "allowed_connection_types":["STANDARD","WEBSOCKET"],
+ "type":"user",
+ "version":2
+ }
+ }
+ """;
+ var token = BuildUnsignedToken(headerJson, payloadJson);
+
+ var claims = NatsJwt.DecodeUserClaims(token);
+
+ claims.ShouldNotBeNull();
+ claims.Nats.ShouldNotBeNull();
+ claims.Nats.Tags.ShouldBe(["web", "mobile"]);
+ claims.Nats.Src.ShouldBe(["192.168.1.0/24", "10.0.0.0/8"]);
+ claims.Nats.AllowedConnectionTypes.ShouldBe(["STANDARD", "WEBSOCKET"]);
+ }
+
+ [Fact]
+ public void DecodeUserClaims_parses_time_ranges()
+ {
+ var headerJson = """{"typ":"JWT","alg":"ed25519-nkey"}""";
+ var payloadJson = """
+ {
+ "sub":"UAXXX",
+ "iss":"AAXXX",
+ "iat":1700000000,
+ "nats":{
+ "times":[
+ {"start":"08:00:00","end":"17:00:00"},
+ {"start":"20:00:00","end":"22:00:00"}
+ ],
+ "type":"user",
+ "version":2
+ }
+ }
+ """;
+ var token = BuildUnsignedToken(headerJson, payloadJson);
+
+ var claims = NatsJwt.DecodeUserClaims(token);
+
+ claims.ShouldNotBeNull();
+ claims.Nats.ShouldNotBeNull();
+ claims.Nats.Times.ShouldNotBeNull();
+ claims.Nats.Times.Length.ShouldBe(2);
+ claims.Nats.Times[0].Start.ShouldBe("08:00:00");
+ claims.Nats.Times[0].End.ShouldBe("17:00:00");
+ claims.Nats.Times[1].Start.ShouldBe("20:00:00");
+ claims.Nats.Times[1].End.ShouldBe("22:00:00");
+ }
+
+ [Fact]
+ public void DecodeUserClaims_convenience_properties_delegate_to_nats()
+ {
+ var headerJson = """{"typ":"JWT","alg":"ed25519-nkey"}""";
+ var payloadJson = """
+ {
+ "sub":"UAXXX",
+ "iss":"AAXXX",
+ "iat":1700000000,
+ "nats":{
+ "bearer_token":true,
+ "issuer_account":"AAXXX_ACCOUNT",
+ "type":"user",
+ "version":2
+ }
+ }
+ """;
+ var token = BuildUnsignedToken(headerJson, payloadJson);
+
+ var claims = NatsJwt.DecodeUserClaims(token);
+
+ claims.ShouldNotBeNull();
+
+ // Convenience properties should delegate to Nats sub-object
+ claims.BearerToken.ShouldBeTrue();
+ claims.IssuerAccount.ShouldBe("AAXXX_ACCOUNT");
+ }
+
+ [Fact]
+ public void DecodeUserClaims_IsExpired_returns_false_when_no_expiry()
+ {
+ var headerJson = """{"typ":"JWT","alg":"ed25519-nkey"}""";
+ var payloadJson = """
+ {
+ "sub":"UAXXX",
+ "iss":"AAXXX",
+ "iat":1700000000,
+ "nats":{"type":"user","version":2}
+ }
+ """;
+ var token = BuildUnsignedToken(headerJson, payloadJson);
+
+ var claims = NatsJwt.DecodeUserClaims(token);
+
+ claims.ShouldNotBeNull();
+ claims.Expires.ShouldBe(0);
+ claims.IsExpired().ShouldBeFalse();
+ claims.GetExpiry().ShouldBeNull();
+ }
+
+ [Fact]
+ public void DecodeUserClaims_IsExpired_returns_true_for_past_expiry()
+ {
+ var headerJson = """{"typ":"JWT","alg":"ed25519-nkey"}""";
+ // Expired in 2020
+ var payloadJson = """
+ {
+ "sub":"UAXXX",
+ "iss":"AAXXX",
+ "iat":1500000000,
+ "exp":1600000000,
+ "nats":{"type":"user","version":2}
+ }
+ """;
+ var token = BuildUnsignedToken(headerJson, payloadJson);
+
+ var claims = NatsJwt.DecodeUserClaims(token);
+
+ claims.ShouldNotBeNull();
+ claims.Expires.ShouldBe(1600000000);
+ claims.IsExpired().ShouldBeTrue();
+ claims.GetExpiry().ShouldNotBeNull();
+ claims.GetExpiry()!.Value.ToUnixTimeSeconds().ShouldBe(1600000000);
+ }
+
+ [Fact]
+ public void DecodeUserClaims_IsExpired_returns_false_for_future_expiry()
+ {
+ var headerJson = """{"typ":"JWT","alg":"ed25519-nkey"}""";
+ // Expires far in the future
+ var payloadJson = """
+ {
+ "sub":"UAXXX",
+ "iss":"AAXXX",
+ "iat":1700000000,
+ "exp":4102444800,
+ "nats":{"type":"user","version":2}
+ }
+ """;
+ var token = BuildUnsignedToken(headerJson, payloadJson);
+
+ var claims = NatsJwt.DecodeUserClaims(token);
+
+ claims.ShouldNotBeNull();
+ claims.IsExpired().ShouldBeFalse();
+ claims.GetExpiry().ShouldNotBeNull();
+ }
+
+ [Fact]
+ public void DecodeUserClaims_returns_null_for_invalid_token()
+ {
+ NatsJwt.DecodeUserClaims("not-a-jwt").ShouldBeNull();
+ }
+
+ // =====================================================================
+ // DecodeAccountClaims tests
+ // =====================================================================
+
+ [Fact]
+ public void DecodeAccountClaims_parses_subject_and_issuer()
+ {
+ var headerJson = """{"typ":"JWT","alg":"ed25519-nkey"}""";
+ var payloadJson = """
+ {
+ "sub":"AAXXX_ACCOUNT_KEY",
+ "iss":"OAXXX_OPERATOR",
+ "iat":1700000000,
+ "name":"test-account",
+ "nats":{
+ "type":"account",
+ "version":2
+ }
+ }
+ """;
+ var token = BuildUnsignedToken(headerJson, payloadJson);
+
+ var claims = NatsJwt.DecodeAccountClaims(token);
+
+ claims.ShouldNotBeNull();
+ claims.Subject.ShouldBe("AAXXX_ACCOUNT_KEY");
+ claims.Issuer.ShouldBe("OAXXX_OPERATOR");
+ claims.Name.ShouldBe("test-account");
+ claims.IssuedAt.ShouldBe(1700000000);
+ }
+
+ [Fact]
+ public void DecodeAccountClaims_parses_limits()
+ {
+ var headerJson = """{"typ":"JWT","alg":"ed25519-nkey"}""";
+ var payloadJson = """
+ {
+ "sub":"AAXXX",
+ "iss":"OAXXX",
+ "iat":1700000000,
+ "nats":{
+ "limits":{
+ "conn":100,
+ "subs":1000,
+ "payload":1048576,
+ "data":10737418240
+ },
+ "type":"account",
+ "version":2
+ }
+ }
+ """;
+ var token = BuildUnsignedToken(headerJson, payloadJson);
+
+ var claims = NatsJwt.DecodeAccountClaims(token);
+
+ claims.ShouldNotBeNull();
+ claims.Nats.ShouldNotBeNull();
+ claims.Nats.Limits.ShouldNotBeNull();
+ claims.Nats.Limits.MaxConnections.ShouldBe(100);
+ claims.Nats.Limits.MaxSubscriptions.ShouldBe(1000);
+ claims.Nats.Limits.MaxPayload.ShouldBe(1048576);
+ claims.Nats.Limits.MaxData.ShouldBe(10737418240L);
+ }
+
+ [Fact]
+ public void DecodeAccountClaims_parses_signing_keys()
+ {
+ var headerJson = """{"typ":"JWT","alg":"ed25519-nkey"}""";
+ var payloadJson = """
+ {
+ "sub":"AAXXX",
+ "iss":"OAXXX",
+ "iat":1700000000,
+ "nats":{
+ "signing_keys":["AAXXX_SIGN_1","AAXXX_SIGN_2","AAXXX_SIGN_3"],
+ "type":"account",
+ "version":2
+ }
+ }
+ """;
+ var token = BuildUnsignedToken(headerJson, payloadJson);
+
+ var claims = NatsJwt.DecodeAccountClaims(token);
+
+ claims.ShouldNotBeNull();
+ claims.Nats.ShouldNotBeNull();
+ claims.Nats.SigningKeys.ShouldNotBeNull();
+ claims.Nats.SigningKeys.ShouldBe(["AAXXX_SIGN_1", "AAXXX_SIGN_2", "AAXXX_SIGN_3"]);
+ }
+
+ [Fact]
+ public void DecodeAccountClaims_parses_revocations()
+ {
+ var headerJson = """{"typ":"JWT","alg":"ed25519-nkey"}""";
+ var payloadJson = """
+ {
+ "sub":"AAXXX",
+ "iss":"OAXXX",
+ "iat":1700000000,
+ "nats":{
+ "revocations":{
+ "UAXXX_REVOKED_1":1700000000,
+ "UAXXX_REVOKED_2":1700001000
+ },
+ "type":"account",
+ "version":2
+ }
+ }
+ """;
+ var token = BuildUnsignedToken(headerJson, payloadJson);
+
+ var claims = NatsJwt.DecodeAccountClaims(token);
+
+ claims.ShouldNotBeNull();
+ claims.Nats.ShouldNotBeNull();
+ claims.Nats.Revocations.ShouldNotBeNull();
+ claims.Nats.Revocations.Count.ShouldBe(2);
+ claims.Nats.Revocations["UAXXX_REVOKED_1"].ShouldBe(1700000000);
+ claims.Nats.Revocations["UAXXX_REVOKED_2"].ShouldBe(1700001000);
+ }
+
+ [Fact]
+ public void DecodeAccountClaims_handles_negative_one_unlimited_limits()
+ {
+ var headerJson = """{"typ":"JWT","alg":"ed25519-nkey"}""";
+ var payloadJson = """
+ {
+ "sub":"AAXXX",
+ "iss":"OAXXX",
+ "iat":1700000000,
+ "nats":{
+ "limits":{
+ "conn":-1,
+ "subs":-1,
+ "payload":-1,
+ "data":-1
+ },
+ "type":"account",
+ "version":2
+ }
+ }
+ """;
+ var token = BuildUnsignedToken(headerJson, payloadJson);
+
+ var claims = NatsJwt.DecodeAccountClaims(token);
+
+ claims.ShouldNotBeNull();
+ claims.Nats.ShouldNotBeNull();
+ claims.Nats.Limits.ShouldNotBeNull();
+ claims.Nats.Limits.MaxConnections.ShouldBe(-1);
+ claims.Nats.Limits.MaxSubscriptions.ShouldBe(-1);
+ claims.Nats.Limits.MaxPayload.ShouldBe(-1);
+ claims.Nats.Limits.MaxData.ShouldBe(-1);
+ }
+
+ [Fact]
+ public void DecodeAccountClaims_returns_null_for_invalid_token()
+ {
+ NatsJwt.DecodeAccountClaims("invalid").ShouldBeNull();
+ }
+
+ [Fact]
+ public void DecodeAccountClaims_parses_expiry()
+ {
+ var headerJson = """{"typ":"JWT","alg":"ed25519-nkey"}""";
+ var payloadJson = """
+ {
+ "sub":"AAXXX",
+ "iss":"OAXXX",
+ "iat":1700000000,
+ "exp":1800000000,
+ "name":"expiring-account",
+ "nats":{
+ "type":"account",
+ "version":2
+ }
+ }
+ """;
+ var token = BuildUnsignedToken(headerJson, payloadJson);
+
+ var claims = NatsJwt.DecodeAccountClaims(token);
+
+ claims.ShouldNotBeNull();
+ claims.Expires.ShouldBe(1800000000);
+ }
+
+ // =====================================================================
+ // Round-trip with real Ed25519 signing tests
+ // =====================================================================
+
+ [Fact]
+ public void Roundtrip_sign_and_verify_user_claims()
+ {
+ var accountKp = KeyPair.CreatePair(PrefixByte.Account);
+ var accountPublicKey = accountKp.GetPublicKey();
+
+ var payloadJson = $$"""
+ {
+ "sub":"UAXXX_USER",
+ "iss":"{{accountPublicKey}}",
+ "iat":1700000000,
+ "name":"roundtrip-user",
+ "nats":{
+ "pub":{"allow":["test.>"]},
+ "bearer_token":true,
+ "issuer_account":"{{accountPublicKey}}",
+ "type":"user",
+ "version":2
+ }
+ }
+ """;
+
+ var token = BuildSignedToken(payloadJson, accountKp);
+
+ // Verify signature
+ NatsJwt.Verify(token, accountPublicKey).ShouldBeTrue();
+
+ // Decode claims
+ var claims = NatsJwt.DecodeUserClaims(token);
+ claims.ShouldNotBeNull();
+ claims.Subject.ShouldBe("UAXXX_USER");
+ claims.Name.ShouldBe("roundtrip-user");
+ claims.Nats.ShouldNotBeNull();
+ claims.Nats.Pub.ShouldNotBeNull();
+ claims.Nats.Pub.Allow.ShouldBe(["test.>"]);
+ claims.BearerToken.ShouldBeTrue();
+ claims.IssuerAccount.ShouldBe(accountPublicKey);
+ }
+
+ [Fact]
+ public void Roundtrip_sign_and_verify_account_claims()
+ {
+ var operatorKp = KeyPair.CreatePair(PrefixByte.Operator);
+ var operatorPublicKey = operatorKp.GetPublicKey();
+
+ var payloadJson = $$"""
+ {
+ "sub":"AAXXX_ACCOUNT",
+ "iss":"{{operatorPublicKey}}",
+ "iat":1700000000,
+ "name":"roundtrip-account",
+ "nats":{
+ "limits":{"conn":50,"subs":500,"payload":65536,"data":-1},
+ "signing_keys":["AAXXX_SK1"],
+ "revocations":{"UAXXX_OLD":1699000000},
+ "type":"account",
+ "version":2
+ }
+ }
+ """;
+
+ var token = BuildSignedToken(payloadJson, operatorKp);
+
+ // Verify signature
+ NatsJwt.Verify(token, operatorPublicKey).ShouldBeTrue();
+
+ // Decode claims
+ var claims = NatsJwt.DecodeAccountClaims(token);
+ claims.ShouldNotBeNull();
+ claims.Subject.ShouldBe("AAXXX_ACCOUNT");
+ claims.Name.ShouldBe("roundtrip-account");
+ claims.Nats.ShouldNotBeNull();
+ claims.Nats.Limits.ShouldNotBeNull();
+ claims.Nats.Limits.MaxConnections.ShouldBe(50);
+ claims.Nats.SigningKeys.ShouldBe(["AAXXX_SK1"]);
+ claims.Nats.Revocations.ShouldNotBeNull();
+ claims.Nats.Revocations["UAXXX_OLD"].ShouldBe(1699000000);
+ }
+
+ // =====================================================================
+ // Edge case tests
+ // =====================================================================
+
+ [Fact]
+ public void DecodeUserClaims_handles_missing_nats_object()
+ {
+ var headerJson = """{"typ":"JWT","alg":"ed25519-nkey"}""";
+ var payloadJson = """
+ {
+ "sub":"UAXXX",
+ "iss":"AAXXX",
+ "iat":1700000000
+ }
+ """;
+ var token = BuildUnsignedToken(headerJson, payloadJson);
+
+ var claims = NatsJwt.DecodeUserClaims(token);
+
+ // Should still decode the outer fields even if nats is missing
+ claims.ShouldNotBeNull();
+ claims.Subject.ShouldBe("UAXXX");
+ claims.Issuer.ShouldBe("AAXXX");
+ }
+
+ [Fact]
+ public void DecodeAccountClaims_handles_empty_nats_object()
+ {
+ var headerJson = """{"typ":"JWT","alg":"ed25519-nkey"}""";
+ var payloadJson = """
+ {
+ "sub":"AAXXX",
+ "iss":"OAXXX",
+ "iat":1700000000,
+ "nats":{}
+ }
+ """;
+ var token = BuildUnsignedToken(headerJson, payloadJson);
+
+ var claims = NatsJwt.DecodeAccountClaims(token);
+
+ claims.ShouldNotBeNull();
+ claims.Subject.ShouldBe("AAXXX");
+ claims.Nats.ShouldNotBeNull();
+ }
+
+ [Fact]
+ public void DecodeUserClaims_handles_empty_pub_sub_permissions()
+ {
+ var headerJson = """{"typ":"JWT","alg":"ed25519-nkey"}""";
+ var payloadJson = """
+ {
+ "sub":"UAXXX",
+ "iss":"AAXXX",
+ "iat":1700000000,
+ "nats":{
+ "pub":{},
+ "sub":{},
+ "type":"user",
+ "version":2
+ }
+ }
+ """;
+ var token = BuildUnsignedToken(headerJson, payloadJson);
+
+ var claims = NatsJwt.DecodeUserClaims(token);
+
+ claims.ShouldNotBeNull();
+ claims.Nats.ShouldNotBeNull();
+ claims.Nats.Pub.ShouldNotBeNull();
+ claims.Nats.Sub.ShouldNotBeNull();
+ // Allow/Deny should be null when not specified
+ claims.Nats.Pub.Allow.ShouldBeNull();
+ claims.Nats.Pub.Deny.ShouldBeNull();
+ }
+}