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