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