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(); } // ===================================================================== // Response permission edge cases // Go reference: TestJWTUserResponsePermissionClaimsDefaultValues, // TestJWTUserResponsePermissionClaimsNegativeValues // ===================================================================== [Fact] public void DecodeUserClaims_resp_with_zero_max_and_zero_ttl_is_present_but_zeroed() { // Go TestJWTUserResponsePermissionClaimsDefaultValues: // an empty ResponsePermission{} in the JWT serializes as max=0, ttl=0. // The .NET parser must round-trip those zero values rather than // treating the object as absent. var headerJson = """{"typ":"JWT","alg":"ed25519-nkey"}"""; var payloadJson = """ { "sub":"UAXXX", "iss":"AAXXX", "iat":1700000000, "nats":{ "resp":{"max":0,"ttl":0}, "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(0); claims.Nats.Resp.TtlNanos.ShouldBe(0L); claims.Nats.Resp.Ttl.ShouldBe(TimeSpan.Zero); } [Fact] public void DecodeUserClaims_resp_with_negative_max_and_negative_ttl_round_trips() { // Go TestJWTUserResponsePermissionClaimsNegativeValues: // MaxMsgs=-1, Expires=-1s (== -1_000_000_000 ns). // The .NET parser must preserve negative values verbatim. var headerJson = """{"typ":"JWT","alg":"ed25519-nkey"}"""; var payloadJson = """ { "sub":"UAXXX", "iss":"AAXXX", "iat":1700000000, "nats":{ "resp":{"max":-1,"ttl":-1000000000}, "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(-1); claims.Nats.Resp.TtlNanos.ShouldBe(-1_000_000_000L); } // ===================================================================== // JWT expiration edge cases // Go reference: TestJWTUserExpired, TestJWTAccountExpired // ===================================================================== [Fact] public void DecodeUserClaims_IsExpired_returns_true_when_expired_by_one_second() { // Mirrors the Go TestJWTUserExpired / TestJWTAccountExpired pattern: // exp is set to "now - 2 seconds" which is definitely past. var headerJson = """{"typ":"JWT","alg":"ed25519-nkey"}"""; var expiredByOneSecond = DateTimeOffset.UtcNow.AddSeconds(-1).ToUnixTimeSeconds(); var payloadJson = $$""" { "sub":"UAXXX", "iss":"AAXXX", "iat":1700000000, "exp":{{expiredByOneSecond}}, "nats":{"type":"user","version":2} } """; var token = BuildUnsignedToken(headerJson, payloadJson); var claims = NatsJwt.DecodeUserClaims(token); claims.ShouldNotBeNull(); claims.IsExpired().ShouldBeTrue(); } [Fact] public void DecodeUserClaims_IsExpired_returns_false_when_not_yet_expired_by_one_second() { // Complementary case: exp is 1 second in the future — token is valid. var headerJson = """{"typ":"JWT","alg":"ed25519-nkey"}"""; var expiresSoon = DateTimeOffset.UtcNow.AddSeconds(1).ToUnixTimeSeconds(); var payloadJson = $$""" { "sub":"UAXXX", "iss":"AAXXX", "iat":1700000000, "exp":{{expiresSoon}}, "nats":{"type":"user","version":2} } """; var token = BuildUnsignedToken(headerJson, payloadJson); var claims = NatsJwt.DecodeUserClaims(token); claims.ShouldNotBeNull(); claims.IsExpired().ShouldBeFalse(); } [Fact] public void DecodeAccountClaims_IsExpired_returns_true_when_account_is_expired() { // Mirrors Go TestJWTAccountExpired: iat = now-10s, exp = now-2s. var headerJson = """{"typ":"JWT","alg":"ed25519-nkey"}"""; var issuedAt = DateTimeOffset.UtcNow.AddSeconds(-10).ToUnixTimeSeconds(); var expires = DateTimeOffset.UtcNow.AddSeconds(-2).ToUnixTimeSeconds(); var payloadJson = $$""" { "sub":"AAXXX", "iss":"OAXXX", "iat":{{issuedAt}}, "exp":{{expires}}, "nats":{"type":"account","version":2} } """; var token = BuildUnsignedToken(headerJson, payloadJson); var claims = NatsJwt.DecodeAccountClaims(token); claims.ShouldNotBeNull(); claims.Expires.ShouldBe(expires); // AccountClaims uses the standard exp field; verify it's in the past DateTimeOffset.UtcNow.ToUnixTimeSeconds().ShouldBeGreaterThan(claims.Expires); } // ===================================================================== // Signing key chain (multi-level) claim fields // Go reference: TestJWTUserSigningKey — user issued by account signing key // ===================================================================== [Fact] public void DecodeUserClaims_parses_issuer_account_when_user_signed_by_signing_key() { // In Go, when a user JWT is signed by an account *signing key* (not the // primary account key), the JWT issuer (iss) is the signing key's public key // and the issuer_account field carries the primary account public key. // This test verifies those two fields are decoded correctly. var accountKp = KeyPair.CreatePair(PrefixByte.Account); var accountPublicKey = accountKp.GetPublicKey(); // Simulate a signing key (another account-type keypair acting as delegated signer) var signingKp = KeyPair.CreatePair(PrefixByte.Account); var signingPublicKey = signingKp.GetPublicKey(); var payloadJson = $$""" { "sub":"UAXXX_USER", "iss":"{{signingPublicKey}}", "iat":1700000000, "name":"signing-key-user", "nats":{ "issuer_account":"{{accountPublicKey}}", "type":"user", "version":2 } } """; var token = BuildSignedToken(payloadJson, signingKp); var claims = NatsJwt.DecodeUserClaims(token); claims.ShouldNotBeNull(); // The issuer is the signing key, not the primary account claims.Issuer.ShouldBe(signingPublicKey); // The issuer_account carries the primary account key claims.IssuerAccount.ShouldBe(accountPublicKey); // Convenience property must also reflect the nats sub-object claims.Nats.ShouldNotBeNull(); claims.Nats.IssuerAccount.ShouldBe(accountPublicKey); } [Fact] public void Verify_returns_true_when_signed_by_account_signing_key() { // JWT is signed by a signing key (not the primary account key). // Verify must succeed when checked against the signing key's public key. var signingKp = KeyPair.CreatePair(PrefixByte.Account); var signingPublicKey = signingKp.GetPublicKey(); var accountPublicKey = KeyPair.CreatePair(PrefixByte.Account).GetPublicKey(); var payloadJson = $$""" { "sub":"UAXXX_USER", "iss":"{{signingPublicKey}}", "iat":1700000000, "nats":{ "issuer_account":"{{accountPublicKey}}", "type":"user", "version":2 } } """; var token = BuildSignedToken(payloadJson, signingKp); // Verify against the signing key (not the primary account key) NatsJwt.Verify(token, signingPublicKey).ShouldBeTrue(); // Verify against the primary account key must fail (different key) NatsJwt.Verify(token, accountPublicKey).ShouldBeFalse(); } // ===================================================================== // Account claims — JetStream limits // Go reference: TestJWTJetStreamTiers (claims parsing portion) // ===================================================================== [Fact] public void DecodeAccountClaims_parses_jetstream_limits() { var headerJson = """{"typ":"JWT","alg":"ed25519-nkey"}"""; var payloadJson = """ { "sub":"AAXXX", "iss":"OAXXX", "iat":1700000000, "nats":{ "jetstream":{ "max_streams":10, "tier":"T1" }, "type":"account", "version":2 } } """; var token = BuildUnsignedToken(headerJson, payloadJson); var claims = NatsJwt.DecodeAccountClaims(token); claims.ShouldNotBeNull(); claims.Nats.ShouldNotBeNull(); claims.Nats.JetStream.ShouldNotBeNull(); claims.Nats.JetStream.MaxStreams.ShouldBe(10); claims.Nats.JetStream.Tier.ShouldBe("T1"); } [Fact] public void DecodeAccountClaims_absent_jetstream_block_leaves_property_null() { var headerJson = """{"typ":"JWT","alg":"ed25519-nkey"}"""; var payloadJson = """ { "sub":"AAXXX", "iss":"OAXXX", "iat":1700000000, "nats":{ "type":"account", "version":2 } } """; var token = BuildUnsignedToken(headerJson, payloadJson); var claims = NatsJwt.DecodeAccountClaims(token); claims.ShouldNotBeNull(); claims.Nats.ShouldNotBeNull(); claims.Nats.JetStream.ShouldBeNull(); } // ===================================================================== // Account claims — tags // Go reference: Account claims can carry tags just like user claims // ===================================================================== [Fact] public void DecodeAccountClaims_parses_tags() { var headerJson = """{"typ":"JWT","alg":"ed25519-nkey"}"""; var payloadJson = """ { "sub":"AAXXX", "iss":"OAXXX", "iat":1700000000, "nats":{ "tags":["env:prod","region:us-east"], "type":"account", "version":2 } } """; var token = BuildUnsignedToken(headerJson, payloadJson); var claims = NatsJwt.DecodeAccountClaims(token); claims.ShouldNotBeNull(); claims.Nats.ShouldNotBeNull(); claims.Nats.Tags.ShouldNotBeNull(); claims.Nats.Tags.ShouldBe(["env:prod", "region:us-east"]); } // ===================================================================== // Malformed JWT structural edge cases // Go reference: NatsJwt.Decode robustness // ===================================================================== [Fact] public void Decode_returns_null_for_four_dot_separated_parts() { // JWT must have exactly three parts. Four segments is not a valid JWT. NatsJwt.Decode("part1.part2.part3.part4").ShouldBeNull(); } [Fact] public void Decode_handles_base64_with_standard_padding_in_payload() { // Some JWT implementations emit standard Base64 with '=' padding instead of // URL-safe base64url. Verify the decoder handles padding characters correctly. var headerJson = """{"typ":"JWT","alg":"ed25519-nkey"}"""; var payloadJson = """{"sub":"UAXXX","iss":"AAXXX","iat":1700000000}"""; // Manually build a token where the payload uses standard base64 WITH padding var headerB64 = Base64UrlEncode(headerJson); var payloadBytes = Encoding.UTF8.GetBytes(payloadJson); // Standard base64 with padding (not base64url) var payloadB64WithPadding = Convert.ToBase64String(payloadBytes); // may contain '=' padding var fakeSig = Convert.ToBase64String(new byte[64]).TrimEnd('=').Replace('+', '-').Replace('/', '_'); var token = $"{headerB64}.{payloadB64WithPadding}.{fakeSig}"; // The decoder should handle the padding transparently var result = NatsJwt.Decode(token); result.ShouldNotBeNull(); result.PayloadJson.ShouldContain("UAXXX"); } [Fact] public void Decode_returns_null_for_empty_header_segment() { // An empty header part cannot be valid base64 for a JSON object. NatsJwt.Decode(".payload.sig").ShouldBeNull(); } [Fact] public void Decode_returns_null_for_invalid_base64_in_payload() { var headerB64 = Base64UrlEncode("""{"typ":"JWT","alg":"ed25519-nkey"}"""); NatsJwt.Decode($"{headerB64}.!!!invalid.sig").ShouldBeNull(); } [Fact] public void Decode_returns_null_for_non_json_payload() { // A payload that is valid base64url but does not decode to JSON // should return null because the header cannot be deserialized. var nonJsonPayload = Base64UrlEncode("this-is-not-json"); var headerB64 = Base64UrlEncode("""{"typ":"JWT","alg":"ed25519-nkey"}"""); var fakeSig = Convert.ToBase64String(new byte[64]).TrimEnd('=').Replace('+', '-').Replace('/', '_'); // Decode does not deserialize the payload (only the header), so this // actually succeeds at the Decode level but the payloadJson is "this-is-not-json". // DecodeUserClaims should return null because the payload is not valid claims JSON. var token = $"{headerB64}.{nonJsonPayload}.{fakeSig}"; var decoded = NatsJwt.Decode(token); decoded.ShouldNotBeNull(); decoded.PayloadJson.ShouldBe("this-is-not-json"); // But decoding as UserClaims should fail NatsJwt.DecodeUserClaims(token).ShouldBeNull(); } // ===================================================================== // Verify edge cases // ===================================================================== [Fact] public void Verify_returns_false_for_empty_public_key() { var kp = KeyPair.CreatePair(PrefixByte.Account); var payloadJson = """{"sub":"UAXXX","iss":"AAXXX","iat":1700000000}"""; var token = BuildSignedToken(payloadJson, kp); NatsJwt.Verify(token, "").ShouldBeFalse(); } [Fact] public void Verify_returns_false_for_malformed_public_key() { var kp = KeyPair.CreatePair(PrefixByte.Account); var payloadJson = """{"sub":"UAXXX","iss":"AAXXX","iat":1700000000}"""; var token = BuildSignedToken(payloadJson, kp); NatsJwt.Verify(token, "NOT_A_VALID_NKEY").ShouldBeFalse(); } [Fact] public void Verify_returns_false_when_signature_is_truncated() { var kp = KeyPair.CreatePair(PrefixByte.Account); var accountPublicKey = kp.GetPublicKey(); var payloadJson = $$"""{"sub":"UAXXX","iss":"{{accountPublicKey}}","iat":1700000000}"""; var token = BuildSignedToken(payloadJson, kp); // Truncate the signature part to only 10 chars — invalid length var parts = token.Split('.'); var truncatedToken = $"{parts[0]}.{parts[1]}.{parts[2][..10]}"; NatsJwt.Verify(truncatedToken, accountPublicKey).ShouldBeFalse(); } // ===================================================================== // DecodeUserClaims — sub-permission variations // Go reference: TestJWTUserPermissionClaims // ===================================================================== [Fact] public void DecodeUserClaims_parses_pub_allow_only_with_no_deny() { // Permissions with only allow and no deny list. var headerJson = """{"typ":"JWT","alg":"ed25519-nkey"}"""; var payloadJson = """ { "sub":"UAXXX", "iss":"AAXXX", "iat":1700000000, "nats":{ "pub":{"allow":["foo.>","bar.*"]}, "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.ShouldBeNull(); claims.Nats.Sub.ShouldBeNull(); } [Fact] public void DecodeUserClaims_parses_sub_deny_only_with_no_allow() { // Permissions with only deny and no allow list. var headerJson = """{"typ":"JWT","alg":"ed25519-nkey"}"""; var payloadJson = """ { "sub":"UAXXX", "iss":"AAXXX", "iat":1700000000, "nats":{ "sub":{"deny":["private.>"]}, "type":"user", "version":2 } } """; var token = BuildUnsignedToken(headerJson, payloadJson); var claims = NatsJwt.DecodeUserClaims(token); claims.ShouldNotBeNull(); claims.Nats.ShouldNotBeNull(); claims.Nats.Pub.ShouldBeNull(); claims.Nats.Sub.ShouldNotBeNull(); claims.Nats.Sub.Allow.ShouldBeNull(); claims.Nats.Sub.Deny.ShouldBe(["private.>"]); } // ===================================================================== // DecodeAccountClaims — revocation-only and limits-only splits // Go reference: TestJWTUserRevoked, TestJWTAccountLimitsSubs // ===================================================================== [Fact] public void DecodeAccountClaims_parses_revocations_without_limits() { // Account JWT with only revocations defined (no limits block). var headerJson = """{"typ":"JWT","alg":"ed25519-nkey"}"""; var payloadJson = """ { "sub":"AAXXX", "iss":"OAXXX", "iat":1700000000, "nats":{ "revocations":{ "UAXXX_REVOKED":1699000000 }, "type":"account", "version":2 } } """; var token = BuildUnsignedToken(headerJson, payloadJson); var claims = NatsJwt.DecodeAccountClaims(token); claims.ShouldNotBeNull(); claims.Nats.ShouldNotBeNull(); claims.Nats.Limits.ShouldBeNull(); claims.Nats.Revocations.ShouldNotBeNull(); claims.Nats.Revocations.Count.ShouldBe(1); claims.Nats.Revocations["UAXXX_REVOKED"].ShouldBe(1699000000); } [Fact] public void DecodeAccountClaims_parses_limits_without_revocations() { // Account JWT with only limits defined (no revocations block). var headerJson = """{"typ":"JWT","alg":"ed25519-nkey"}"""; var payloadJson = """ { "sub":"AAXXX", "iss":"OAXXX", "iat":1700000000, "nats":{ "limits":{ "conn":50, "subs":500 }, "type":"account", "version":2 } } """; var token = BuildUnsignedToken(headerJson, payloadJson); var claims = NatsJwt.DecodeAccountClaims(token); claims.ShouldNotBeNull(); claims.Nats.ShouldNotBeNull(); claims.Nats.Revocations.ShouldBeNull(); claims.Nats.Limits.ShouldNotBeNull(); claims.Nats.Limits.MaxConnections.ShouldBe(50); claims.Nats.Limits.MaxSubscriptions.ShouldBe(500); } // ===================================================================== // Wildcard revocation sentinel value // Go reference: TestJWTUserRevocation — "*" key with timestamp=0 means // all users issued before that time are revoked // ===================================================================== [Fact] public void DecodeAccountClaims_parses_wildcard_revocation_sentinel() { // The Go JWT library uses "*" as a key in the revocations map // to mean "revoke all users issued before this timestamp". var headerJson = """{"typ":"JWT","alg":"ed25519-nkey"}"""; var payloadJson = """ { "sub":"AAXXX", "iss":"OAXXX", "iat":1700000000, "nats":{ "revocations":{ "*":1699000000, "UAXXX_SPECIFIC":1700000000 }, "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.ContainsKey("*").ShouldBeTrue(); claims.Nats.Revocations["*"].ShouldBe(1699000000); claims.Nats.Revocations["UAXXX_SPECIFIC"].ShouldBe(1700000000); } // ===================================================================== // VerifyNonce edge cases // Go reference: nonce verification with user keypair // ===================================================================== [Fact] public void VerifyNonce_returns_false_for_empty_nonce_with_wrong_sig() { var kp = KeyPair.CreatePair(PrefixByte.User); var publicKey = kp.GetPublicKey(); // Sign a non-empty nonce but verify against empty nonce var nonce = "real-nonce"u8.ToArray(); var sig = new byte[64]; kp.Sign(nonce, sig); var sigB64 = Convert.ToBase64String(sig); NatsJwt.VerifyNonce([], sigB64, publicKey).ShouldBeFalse(); } [Fact] public void VerifyNonce_returns_false_for_zero_length_base64_payload() { var kp = KeyPair.CreatePair(PrefixByte.User); var publicKey = kp.GetPublicKey(); var nonce = "some-nonce"u8.ToArray(); // An empty string is not valid base64 for a 64-byte signature NatsJwt.VerifyNonce(nonce, "", publicKey).ShouldBeFalse(); } // ===================================================================== // Roundtrip — operator-signed account, account-signed user (full chain) // Go reference: TestJWTUser — full three-tier trust chain // ===================================================================== [Fact] public void Roundtrip_three_tier_claims_operator_account_user() { // Mimics the Go three-tier trust hierarchy: // Operator -> signs Account JWT -> signs User JWT // This test validates that all three levels decode correctly and // the signing key chain fields are properly populated. var operatorKp = KeyPair.CreatePair(PrefixByte.Operator); var operatorPublicKey = operatorKp.GetPublicKey(); var accountKp = KeyPair.CreatePair(PrefixByte.Account); var accountPublicKey = accountKp.GetPublicKey(); // Account JWT: issued by operator var accountPayload = $$""" { "sub":"{{accountPublicKey}}", "iss":"{{operatorPublicKey}}", "iat":1700000000, "name":"test-account", "nats":{ "limits":{"conn":100,"subs":-1,"payload":-1,"data":-1}, "type":"account", "version":2 } } """; var accountToken = BuildSignedToken(accountPayload, operatorKp); // User JWT: issued by account key var userPublicKey = KeyPair.CreatePair(PrefixByte.User).GetPublicKey(); var userPayload = $$""" { "sub":"{{userPublicKey}}", "iss":"{{accountPublicKey}}", "iat":1700000000, "name":"test-user", "nats":{ "pub":{"allow":[">"]}, "sub":{"allow":[">"]}, "type":"user", "version":2 } } """; var userToken = BuildSignedToken(userPayload, accountKp); // Account JWT: verify and decode NatsJwt.Verify(accountToken, operatorPublicKey).ShouldBeTrue(); var accountClaims = NatsJwt.DecodeAccountClaims(accountToken); accountClaims.ShouldNotBeNull(); accountClaims.Subject.ShouldBe(accountPublicKey); accountClaims.Issuer.ShouldBe(operatorPublicKey); accountClaims.Name.ShouldBe("test-account"); accountClaims.Nats.ShouldNotBeNull(); accountClaims.Nats.Limits.ShouldNotBeNull(); accountClaims.Nats.Limits.MaxConnections.ShouldBe(100); // User JWT: verify and decode NatsJwt.Verify(userToken, accountPublicKey).ShouldBeTrue(); var userClaims = NatsJwt.DecodeUserClaims(userToken); userClaims.ShouldNotBeNull(); userClaims.Subject.ShouldBe(userPublicKey); userClaims.Issuer.ShouldBe(accountPublicKey); userClaims.Name.ShouldBe("test-user"); userClaims.Nats.ShouldNotBeNull(); userClaims.Nats.Pub.ShouldNotBeNull(); userClaims.Nats.Pub.Allow.ShouldBe([">"]); } }