Files
natsdotnet/tests/NATS.Server.Tests/JwtTests.cs
Joseph Doherty 4836f7851e feat: add JWT core decode/verify and claim structs for NATS auth
Implement NatsJwt static class with Ed25519 signature verification,
base64url decoding, and JWT parsing. Add UserClaims and AccountClaims
with all NATS-specific fields (permissions, bearer tokens, limits,
signing keys, revocations). Includes 44 tests covering decode, verify,
nonce verification, and full round-trip signing with real NKey keypairs.
2026-02-23 04:30:20 -05:00

933 lines
28 KiB
C#

using System.Text;
using System.Text.Json;
using NATS.NKeys;
using NATS.Server.Auth.Jwt;
namespace NATS.Server.Tests;
public class JwtTests
{
/// <summary>
/// Helper: base64url-encode a string for constructing test JWTs.
/// </summary>
private static string Base64UrlEncode(string value)
{
var bytes = Encoding.UTF8.GetBytes(value);
return Convert.ToBase64String(bytes)
.TrimEnd('=')
.Replace('+', '-')
.Replace('/', '_');
}
/// <summary>
/// Helper: build a minimal unsigned JWT from header and payload JSON strings.
/// The signature part is a base64url-encoded 64-byte zero array (invalid but structurally correct).
/// </summary>
private static string BuildUnsignedToken(string headerJson, string payloadJson)
{
var header = Base64UrlEncode(headerJson);
var payload = Base64UrlEncode(payloadJson);
var fakeSig = Convert.ToBase64String(new byte[64])
.TrimEnd('=')
.Replace('+', '-')
.Replace('/', '_');
return $"{header}.{payload}.{fakeSig}";
}
/// <summary>
/// Helper: build a real signed NATS JWT using an NKey keypair.
/// Signs header.payload with Ed25519.
/// </summary>
private static string BuildSignedToken(string payloadJson, KeyPair signingKey)
{
var headerJson = """{"typ":"JWT","alg":"ed25519-nkey"}""";
var header = Base64UrlEncode(headerJson);
var payload = Base64UrlEncode(payloadJson);
var signingInput = $"{header}.{payload}";
var signingInputBytes = Encoding.UTF8.GetBytes(signingInput);
var sig = new byte[64];
signingKey.Sign(signingInputBytes, sig);
var sigB64 = Convert.ToBase64String(sig)
.TrimEnd('=')
.Replace('+', '-')
.Replace('/', '_');
return $"{header}.{payload}.{sigB64}";
}
// =====================================================================
// IsJwt tests
// =====================================================================
[Fact]
public void IsJwt_returns_true_for_eyJ_prefix()
{
NatsJwt.IsJwt("eyJhbGciOiJlZDI1NTE5LW5rZXkiLCJ0eXAiOiJKV1QifQ.payload.sig").ShouldBeTrue();
}
[Fact]
public void IsJwt_returns_true_for_minimal_eyJ()
{
NatsJwt.IsJwt("eyJ").ShouldBeTrue();
}
[Fact]
public void IsJwt_returns_false_for_non_jwt()
{
NatsJwt.IsJwt("notajwt").ShouldBeFalse();
}
[Fact]
public void IsJwt_returns_false_for_empty_string()
{
NatsJwt.IsJwt("").ShouldBeFalse();
}
[Fact]
public void IsJwt_returns_false_for_null()
{
NatsJwt.IsJwt(null!).ShouldBeFalse();
}
// =====================================================================
// Decode tests
// =====================================================================
[Fact]
public void Decode_splits_header_payload_signature_correctly()
{
var headerJson = """{"typ":"JWT","alg":"ed25519-nkey"}""";
var payloadJson = """{"sub":"UAXXX","iss":"AAXXX","iat":1700000000}""";
var token = BuildUnsignedToken(headerJson, payloadJson);
var result = NatsJwt.Decode(token);
result.ShouldNotBeNull();
result.Header.ShouldNotBeNull();
result.Header.Type.ShouldBe("JWT");
result.Header.Algorithm.ShouldBe("ed25519-nkey");
}
[Fact]
public void Decode_returns_payload_json()
{
var headerJson = """{"typ":"JWT","alg":"ed25519-nkey"}""";
var payloadJson = """{"sub":"UAXXX","iss":"AAXXX","iat":1700000000}""";
var token = BuildUnsignedToken(headerJson, payloadJson);
var result = NatsJwt.Decode(token);
result.ShouldNotBeNull();
result.PayloadJson.ShouldNotBeNullOrEmpty();
// The payload JSON should parse back to matching fields
using var doc = JsonDocument.Parse(result.PayloadJson);
doc.RootElement.GetProperty("sub").GetString().ShouldBe("UAXXX");
doc.RootElement.GetProperty("iss").GetString().ShouldBe("AAXXX");
}
[Fact]
public void Decode_preserves_signature_bytes()
{
var headerJson = """{"typ":"JWT","alg":"ed25519-nkey"}""";
var payloadJson = """{"sub":"test"}""";
var token = BuildUnsignedToken(headerJson, payloadJson);
var result = NatsJwt.Decode(token);
result.ShouldNotBeNull();
result.Signature.ShouldNotBeNull();
result.Signature.Length.ShouldBe(64);
}
[Fact]
public void Decode_preserves_signing_input()
{
var headerJson = """{"typ":"JWT","alg":"ed25519-nkey"}""";
var payloadJson = """{"sub":"test"}""";
var token = BuildUnsignedToken(headerJson, payloadJson);
var result = NatsJwt.Decode(token);
result.ShouldNotBeNull();
// SigningInput should be "header.payload" (the first two parts)
var parts = token.Split('.');
var expectedSigningInput = $"{parts[0]}.{parts[1]}";
result.SigningInput.ShouldBe(expectedSigningInput);
}
[Fact]
public void Decode_returns_null_for_invalid_token_missing_parts()
{
NatsJwt.Decode("onlyonepart").ShouldBeNull();
}
[Fact]
public void Decode_returns_null_for_two_parts()
{
NatsJwt.Decode("part1.part2").ShouldBeNull();
}
[Fact]
public void Decode_returns_null_for_empty_string()
{
NatsJwt.Decode("").ShouldBeNull();
}
[Fact]
public void Decode_returns_null_for_invalid_base64_in_header()
{
NatsJwt.Decode("!!!invalid.payload.sig").ShouldBeNull();
}
// =====================================================================
// Verify tests
// =====================================================================
[Fact]
public void Verify_returns_true_for_valid_signed_token()
{
var accountKp = KeyPair.CreatePair(PrefixByte.Account);
var accountPublicKey = accountKp.GetPublicKey();
var payloadJson = $$"""{"sub":"UAXXX","iss":"{{accountPublicKey}}","iat":1700000000}""";
var token = BuildSignedToken(payloadJson, accountKp);
NatsJwt.Verify(token, accountPublicKey).ShouldBeTrue();
}
[Fact]
public void Verify_returns_false_for_wrong_key()
{
var signingKp = KeyPair.CreatePair(PrefixByte.Account);
var wrongKp = KeyPair.CreatePair(PrefixByte.Account);
var payloadJson = """{"sub":"UAXXX","iss":"AAXXX","iat":1700000000}""";
var token = BuildSignedToken(payloadJson, signingKp);
NatsJwt.Verify(token, wrongKp.GetPublicKey()).ShouldBeFalse();
}
[Fact]
public void Verify_returns_false_for_tampered_payload()
{
var accountKp = KeyPair.CreatePair(PrefixByte.Account);
var accountPublicKey = accountKp.GetPublicKey();
var payloadJson = """{"sub":"UAXXX","iss":"AAXXX","iat":1700000000}""";
var token = BuildSignedToken(payloadJson, accountKp);
// Tamper with the payload
var parts = token.Split('.');
var tamperedPayload = Base64UrlEncode("""{"sub":"HACKED","iss":"AAXXX","iat":1700000000}""");
var tampered = $"{parts[0]}.{tamperedPayload}.{parts[2]}";
NatsJwt.Verify(tampered, accountPublicKey).ShouldBeFalse();
}
[Fact]
public void Verify_returns_false_for_invalid_token()
{
var kp = KeyPair.CreatePair(PrefixByte.Account);
NatsJwt.Verify("not.a.jwt", kp.GetPublicKey()).ShouldBeFalse();
}
// =====================================================================
// VerifyNonce tests
// =====================================================================
[Fact]
public void VerifyNonce_accepts_base64url_signature()
{
var kp = KeyPair.CreatePair(PrefixByte.User);
var publicKey = kp.GetPublicKey();
var nonce = "test-nonce-data"u8.ToArray();
var sig = new byte[64];
kp.Sign(nonce, sig);
// Encode as base64url
var sigB64Url = Convert.ToBase64String(sig)
.TrimEnd('=')
.Replace('+', '-')
.Replace('/', '_');
NatsJwt.VerifyNonce(nonce, sigB64Url, publicKey).ShouldBeTrue();
}
[Fact]
public void VerifyNonce_accepts_standard_base64_signature()
{
var kp = KeyPair.CreatePair(PrefixByte.User);
var publicKey = kp.GetPublicKey();
var nonce = "test-nonce-data"u8.ToArray();
var sig = new byte[64];
kp.Sign(nonce, sig);
// Encode as standard base64
var sigB64 = Convert.ToBase64String(sig);
NatsJwt.VerifyNonce(nonce, sigB64, publicKey).ShouldBeTrue();
}
[Fact]
public void VerifyNonce_returns_false_for_wrong_nonce()
{
var kp = KeyPair.CreatePair(PrefixByte.User);
var publicKey = kp.GetPublicKey();
var nonce = "original-nonce"u8.ToArray();
var wrongNonce = "different-nonce"u8.ToArray();
var sig = new byte[64];
kp.Sign(nonce, sig);
var sigB64 = Convert.ToBase64String(sig);
NatsJwt.VerifyNonce(wrongNonce, sigB64, publicKey).ShouldBeFalse();
}
[Fact]
public void VerifyNonce_returns_false_for_invalid_signature()
{
var kp = KeyPair.CreatePair(PrefixByte.User);
var publicKey = kp.GetPublicKey();
var nonce = "test-nonce"u8.ToArray();
NatsJwt.VerifyNonce(nonce, "invalid-sig!", publicKey).ShouldBeFalse();
}
// =====================================================================
// DecodeUserClaims tests
// =====================================================================
[Fact]
public void DecodeUserClaims_parses_subject_and_issuer()
{
var headerJson = """{"typ":"JWT","alg":"ed25519-nkey"}""";
var payloadJson = """
{
"sub":"UAXXX_USER_KEY",
"iss":"AAXXX_ISSUER",
"iat":1700000000,
"name":"test-user",
"nats":{
"type":"user",
"version":2
}
}
""";
var token = BuildUnsignedToken(headerJson, payloadJson);
var claims = NatsJwt.DecodeUserClaims(token);
claims.ShouldNotBeNull();
claims.Subject.ShouldBe("UAXXX_USER_KEY");
claims.Issuer.ShouldBe("AAXXX_ISSUER");
claims.Name.ShouldBe("test-user");
claims.IssuedAt.ShouldBe(1700000000);
}
[Fact]
public void DecodeUserClaims_parses_pub_sub_permissions()
{
var headerJson = """{"typ":"JWT","alg":"ed25519-nkey"}""";
var payloadJson = """
{
"sub":"UAXXX",
"iss":"AAXXX",
"iat":1700000000,
"nats":{
"pub":{"allow":["foo.>","bar.*"],"deny":["bar.secret"]},
"sub":{"allow":[">"],"deny":["_INBOX.private.>"]},
"type":"user",
"version":2
}
}
""";
var token = BuildUnsignedToken(headerJson, payloadJson);
var claims = NatsJwt.DecodeUserClaims(token);
claims.ShouldNotBeNull();
claims.Nats.ShouldNotBeNull();
claims.Nats.Pub.ShouldNotBeNull();
claims.Nats.Pub.Allow.ShouldBe(["foo.>", "bar.*"]);
claims.Nats.Pub.Deny.ShouldBe(["bar.secret"]);
claims.Nats.Sub.ShouldNotBeNull();
claims.Nats.Sub.Allow.ShouldBe([">"]);
claims.Nats.Sub.Deny.ShouldBe(["_INBOX.private.>"]);
}
[Fact]
public void DecodeUserClaims_parses_response_permission()
{
var headerJson = """{"typ":"JWT","alg":"ed25519-nkey"}""";
var payloadJson = """
{
"sub":"UAXXX",
"iss":"AAXXX",
"iat":1700000000,
"nats":{
"resp":{"max":5,"ttl":3000000000},
"type":"user",
"version":2
}
}
""";
var token = BuildUnsignedToken(headerJson, payloadJson);
var claims = NatsJwt.DecodeUserClaims(token);
claims.ShouldNotBeNull();
claims.Nats.ShouldNotBeNull();
claims.Nats.Resp.ShouldNotBeNull();
claims.Nats.Resp.MaxMsgs.ShouldBe(5);
claims.Nats.Resp.TtlNanos.ShouldBe(3000000000L);
claims.Nats.Resp.Ttl.ShouldBe(TimeSpan.FromSeconds(3));
}
[Fact]
public void DecodeUserClaims_parses_bearer_token_flag()
{
var headerJson = """{"typ":"JWT","alg":"ed25519-nkey"}""";
var payloadJson = """
{
"sub":"UAXXX",
"iss":"AAXXX",
"iat":1700000000,
"nats":{
"bearer_token":true,
"issuer_account":"AAXXX_ISSUER_ACCOUNT",
"type":"user",
"version":2
}
}
""";
var token = BuildUnsignedToken(headerJson, payloadJson);
var claims = NatsJwt.DecodeUserClaims(token);
claims.ShouldNotBeNull();
claims.Nats.ShouldNotBeNull();
claims.Nats.BearerToken.ShouldBeTrue();
claims.Nats.IssuerAccount.ShouldBe("AAXXX_ISSUER_ACCOUNT");
}
[Fact]
public void DecodeUserClaims_parses_tags_src_connection_types()
{
var headerJson = """{"typ":"JWT","alg":"ed25519-nkey"}""";
var payloadJson = """
{
"sub":"UAXXX",
"iss":"AAXXX",
"iat":1700000000,
"nats":{
"tags":["web","mobile"],
"src":["192.168.1.0/24","10.0.0.0/8"],
"allowed_connection_types":["STANDARD","WEBSOCKET"],
"type":"user",
"version":2
}
}
""";
var token = BuildUnsignedToken(headerJson, payloadJson);
var claims = NatsJwt.DecodeUserClaims(token);
claims.ShouldNotBeNull();
claims.Nats.ShouldNotBeNull();
claims.Nats.Tags.ShouldBe(["web", "mobile"]);
claims.Nats.Src.ShouldBe(["192.168.1.0/24", "10.0.0.0/8"]);
claims.Nats.AllowedConnectionTypes.ShouldBe(["STANDARD", "WEBSOCKET"]);
}
[Fact]
public void DecodeUserClaims_parses_time_ranges()
{
var headerJson = """{"typ":"JWT","alg":"ed25519-nkey"}""";
var payloadJson = """
{
"sub":"UAXXX",
"iss":"AAXXX",
"iat":1700000000,
"nats":{
"times":[
{"start":"08:00:00","end":"17:00:00"},
{"start":"20:00:00","end":"22:00:00"}
],
"type":"user",
"version":2
}
}
""";
var token = BuildUnsignedToken(headerJson, payloadJson);
var claims = NatsJwt.DecodeUserClaims(token);
claims.ShouldNotBeNull();
claims.Nats.ShouldNotBeNull();
claims.Nats.Times.ShouldNotBeNull();
claims.Nats.Times.Length.ShouldBe(2);
claims.Nats.Times[0].Start.ShouldBe("08:00:00");
claims.Nats.Times[0].End.ShouldBe("17:00:00");
claims.Nats.Times[1].Start.ShouldBe("20:00:00");
claims.Nats.Times[1].End.ShouldBe("22:00:00");
}
[Fact]
public void DecodeUserClaims_convenience_properties_delegate_to_nats()
{
var headerJson = """{"typ":"JWT","alg":"ed25519-nkey"}""";
var payloadJson = """
{
"sub":"UAXXX",
"iss":"AAXXX",
"iat":1700000000,
"nats":{
"bearer_token":true,
"issuer_account":"AAXXX_ACCOUNT",
"type":"user",
"version":2
}
}
""";
var token = BuildUnsignedToken(headerJson, payloadJson);
var claims = NatsJwt.DecodeUserClaims(token);
claims.ShouldNotBeNull();
// Convenience properties should delegate to Nats sub-object
claims.BearerToken.ShouldBeTrue();
claims.IssuerAccount.ShouldBe("AAXXX_ACCOUNT");
}
[Fact]
public void DecodeUserClaims_IsExpired_returns_false_when_no_expiry()
{
var headerJson = """{"typ":"JWT","alg":"ed25519-nkey"}""";
var payloadJson = """
{
"sub":"UAXXX",
"iss":"AAXXX",
"iat":1700000000,
"nats":{"type":"user","version":2}
}
""";
var token = BuildUnsignedToken(headerJson, payloadJson);
var claims = NatsJwt.DecodeUserClaims(token);
claims.ShouldNotBeNull();
claims.Expires.ShouldBe(0);
claims.IsExpired().ShouldBeFalse();
claims.GetExpiry().ShouldBeNull();
}
[Fact]
public void DecodeUserClaims_IsExpired_returns_true_for_past_expiry()
{
var headerJson = """{"typ":"JWT","alg":"ed25519-nkey"}""";
// Expired in 2020
var payloadJson = """
{
"sub":"UAXXX",
"iss":"AAXXX",
"iat":1500000000,
"exp":1600000000,
"nats":{"type":"user","version":2}
}
""";
var token = BuildUnsignedToken(headerJson, payloadJson);
var claims = NatsJwt.DecodeUserClaims(token);
claims.ShouldNotBeNull();
claims.Expires.ShouldBe(1600000000);
claims.IsExpired().ShouldBeTrue();
claims.GetExpiry().ShouldNotBeNull();
claims.GetExpiry()!.Value.ToUnixTimeSeconds().ShouldBe(1600000000);
}
[Fact]
public void DecodeUserClaims_IsExpired_returns_false_for_future_expiry()
{
var headerJson = """{"typ":"JWT","alg":"ed25519-nkey"}""";
// Expires far in the future
var payloadJson = """
{
"sub":"UAXXX",
"iss":"AAXXX",
"iat":1700000000,
"exp":4102444800,
"nats":{"type":"user","version":2}
}
""";
var token = BuildUnsignedToken(headerJson, payloadJson);
var claims = NatsJwt.DecodeUserClaims(token);
claims.ShouldNotBeNull();
claims.IsExpired().ShouldBeFalse();
claims.GetExpiry().ShouldNotBeNull();
}
[Fact]
public void DecodeUserClaims_returns_null_for_invalid_token()
{
NatsJwt.DecodeUserClaims("not-a-jwt").ShouldBeNull();
}
// =====================================================================
// DecodeAccountClaims tests
// =====================================================================
[Fact]
public void DecodeAccountClaims_parses_subject_and_issuer()
{
var headerJson = """{"typ":"JWT","alg":"ed25519-nkey"}""";
var payloadJson = """
{
"sub":"AAXXX_ACCOUNT_KEY",
"iss":"OAXXX_OPERATOR",
"iat":1700000000,
"name":"test-account",
"nats":{
"type":"account",
"version":2
}
}
""";
var token = BuildUnsignedToken(headerJson, payloadJson);
var claims = NatsJwt.DecodeAccountClaims(token);
claims.ShouldNotBeNull();
claims.Subject.ShouldBe("AAXXX_ACCOUNT_KEY");
claims.Issuer.ShouldBe("OAXXX_OPERATOR");
claims.Name.ShouldBe("test-account");
claims.IssuedAt.ShouldBe(1700000000);
}
[Fact]
public void DecodeAccountClaims_parses_limits()
{
var headerJson = """{"typ":"JWT","alg":"ed25519-nkey"}""";
var payloadJson = """
{
"sub":"AAXXX",
"iss":"OAXXX",
"iat":1700000000,
"nats":{
"limits":{
"conn":100,
"subs":1000,
"payload":1048576,
"data":10737418240
},
"type":"account",
"version":2
}
}
""";
var token = BuildUnsignedToken(headerJson, payloadJson);
var claims = NatsJwt.DecodeAccountClaims(token);
claims.ShouldNotBeNull();
claims.Nats.ShouldNotBeNull();
claims.Nats.Limits.ShouldNotBeNull();
claims.Nats.Limits.MaxConnections.ShouldBe(100);
claims.Nats.Limits.MaxSubscriptions.ShouldBe(1000);
claims.Nats.Limits.MaxPayload.ShouldBe(1048576);
claims.Nats.Limits.MaxData.ShouldBe(10737418240L);
}
[Fact]
public void DecodeAccountClaims_parses_signing_keys()
{
var headerJson = """{"typ":"JWT","alg":"ed25519-nkey"}""";
var payloadJson = """
{
"sub":"AAXXX",
"iss":"OAXXX",
"iat":1700000000,
"nats":{
"signing_keys":["AAXXX_SIGN_1","AAXXX_SIGN_2","AAXXX_SIGN_3"],
"type":"account",
"version":2
}
}
""";
var token = BuildUnsignedToken(headerJson, payloadJson);
var claims = NatsJwt.DecodeAccountClaims(token);
claims.ShouldNotBeNull();
claims.Nats.ShouldNotBeNull();
claims.Nats.SigningKeys.ShouldNotBeNull();
claims.Nats.SigningKeys.ShouldBe(["AAXXX_SIGN_1", "AAXXX_SIGN_2", "AAXXX_SIGN_3"]);
}
[Fact]
public void DecodeAccountClaims_parses_revocations()
{
var headerJson = """{"typ":"JWT","alg":"ed25519-nkey"}""";
var payloadJson = """
{
"sub":"AAXXX",
"iss":"OAXXX",
"iat":1700000000,
"nats":{
"revocations":{
"UAXXX_REVOKED_1":1700000000,
"UAXXX_REVOKED_2":1700001000
},
"type":"account",
"version":2
}
}
""";
var token = BuildUnsignedToken(headerJson, payloadJson);
var claims = NatsJwt.DecodeAccountClaims(token);
claims.ShouldNotBeNull();
claims.Nats.ShouldNotBeNull();
claims.Nats.Revocations.ShouldNotBeNull();
claims.Nats.Revocations.Count.ShouldBe(2);
claims.Nats.Revocations["UAXXX_REVOKED_1"].ShouldBe(1700000000);
claims.Nats.Revocations["UAXXX_REVOKED_2"].ShouldBe(1700001000);
}
[Fact]
public void DecodeAccountClaims_handles_negative_one_unlimited_limits()
{
var headerJson = """{"typ":"JWT","alg":"ed25519-nkey"}""";
var payloadJson = """
{
"sub":"AAXXX",
"iss":"OAXXX",
"iat":1700000000,
"nats":{
"limits":{
"conn":-1,
"subs":-1,
"payload":-1,
"data":-1
},
"type":"account",
"version":2
}
}
""";
var token = BuildUnsignedToken(headerJson, payloadJson);
var claims = NatsJwt.DecodeAccountClaims(token);
claims.ShouldNotBeNull();
claims.Nats.ShouldNotBeNull();
claims.Nats.Limits.ShouldNotBeNull();
claims.Nats.Limits.MaxConnections.ShouldBe(-1);
claims.Nats.Limits.MaxSubscriptions.ShouldBe(-1);
claims.Nats.Limits.MaxPayload.ShouldBe(-1);
claims.Nats.Limits.MaxData.ShouldBe(-1);
}
[Fact]
public void DecodeAccountClaims_returns_null_for_invalid_token()
{
NatsJwt.DecodeAccountClaims("invalid").ShouldBeNull();
}
[Fact]
public void DecodeAccountClaims_parses_expiry()
{
var headerJson = """{"typ":"JWT","alg":"ed25519-nkey"}""";
var payloadJson = """
{
"sub":"AAXXX",
"iss":"OAXXX",
"iat":1700000000,
"exp":1800000000,
"name":"expiring-account",
"nats":{
"type":"account",
"version":2
}
}
""";
var token = BuildUnsignedToken(headerJson, payloadJson);
var claims = NatsJwt.DecodeAccountClaims(token);
claims.ShouldNotBeNull();
claims.Expires.ShouldBe(1800000000);
}
// =====================================================================
// Round-trip with real Ed25519 signing tests
// =====================================================================
[Fact]
public void Roundtrip_sign_and_verify_user_claims()
{
var accountKp = KeyPair.CreatePair(PrefixByte.Account);
var accountPublicKey = accountKp.GetPublicKey();
var payloadJson = $$"""
{
"sub":"UAXXX_USER",
"iss":"{{accountPublicKey}}",
"iat":1700000000,
"name":"roundtrip-user",
"nats":{
"pub":{"allow":["test.>"]},
"bearer_token":true,
"issuer_account":"{{accountPublicKey}}",
"type":"user",
"version":2
}
}
""";
var token = BuildSignedToken(payloadJson, accountKp);
// Verify signature
NatsJwt.Verify(token, accountPublicKey).ShouldBeTrue();
// Decode claims
var claims = NatsJwt.DecodeUserClaims(token);
claims.ShouldNotBeNull();
claims.Subject.ShouldBe("UAXXX_USER");
claims.Name.ShouldBe("roundtrip-user");
claims.Nats.ShouldNotBeNull();
claims.Nats.Pub.ShouldNotBeNull();
claims.Nats.Pub.Allow.ShouldBe(["test.>"]);
claims.BearerToken.ShouldBeTrue();
claims.IssuerAccount.ShouldBe(accountPublicKey);
}
[Fact]
public void Roundtrip_sign_and_verify_account_claims()
{
var operatorKp = KeyPair.CreatePair(PrefixByte.Operator);
var operatorPublicKey = operatorKp.GetPublicKey();
var payloadJson = $$"""
{
"sub":"AAXXX_ACCOUNT",
"iss":"{{operatorPublicKey}}",
"iat":1700000000,
"name":"roundtrip-account",
"nats":{
"limits":{"conn":50,"subs":500,"payload":65536,"data":-1},
"signing_keys":["AAXXX_SK1"],
"revocations":{"UAXXX_OLD":1699000000},
"type":"account",
"version":2
}
}
""";
var token = BuildSignedToken(payloadJson, operatorKp);
// Verify signature
NatsJwt.Verify(token, operatorPublicKey).ShouldBeTrue();
// Decode claims
var claims = NatsJwt.DecodeAccountClaims(token);
claims.ShouldNotBeNull();
claims.Subject.ShouldBe("AAXXX_ACCOUNT");
claims.Name.ShouldBe("roundtrip-account");
claims.Nats.ShouldNotBeNull();
claims.Nats.Limits.ShouldNotBeNull();
claims.Nats.Limits.MaxConnections.ShouldBe(50);
claims.Nats.SigningKeys.ShouldBe(["AAXXX_SK1"]);
claims.Nats.Revocations.ShouldNotBeNull();
claims.Nats.Revocations["UAXXX_OLD"].ShouldBe(1699000000);
}
// =====================================================================
// Edge case tests
// =====================================================================
[Fact]
public void DecodeUserClaims_handles_missing_nats_object()
{
var headerJson = """{"typ":"JWT","alg":"ed25519-nkey"}""";
var payloadJson = """
{
"sub":"UAXXX",
"iss":"AAXXX",
"iat":1700000000
}
""";
var token = BuildUnsignedToken(headerJson, payloadJson);
var claims = NatsJwt.DecodeUserClaims(token);
// Should still decode the outer fields even if nats is missing
claims.ShouldNotBeNull();
claims.Subject.ShouldBe("UAXXX");
claims.Issuer.ShouldBe("AAXXX");
}
[Fact]
public void DecodeAccountClaims_handles_empty_nats_object()
{
var headerJson = """{"typ":"JWT","alg":"ed25519-nkey"}""";
var payloadJson = """
{
"sub":"AAXXX",
"iss":"OAXXX",
"iat":1700000000,
"nats":{}
}
""";
var token = BuildUnsignedToken(headerJson, payloadJson);
var claims = NatsJwt.DecodeAccountClaims(token);
claims.ShouldNotBeNull();
claims.Subject.ShouldBe("AAXXX");
claims.Nats.ShouldNotBeNull();
}
[Fact]
public void DecodeUserClaims_handles_empty_pub_sub_permissions()
{
var headerJson = """{"typ":"JWT","alg":"ed25519-nkey"}""";
var payloadJson = """
{
"sub":"UAXXX",
"iss":"AAXXX",
"iat":1700000000,
"nats":{
"pub":{},
"sub":{},
"type":"user",
"version":2
}
}
""";
var token = BuildUnsignedToken(headerJson, payloadJson);
var claims = NatsJwt.DecodeUserClaims(token);
claims.ShouldNotBeNull();
claims.Nats.ShouldNotBeNull();
claims.Nats.Pub.ShouldNotBeNull();
claims.Nats.Sub.ShouldNotBeNull();
// Allow/Deny should be null when not specified
claims.Nats.Pub.Allow.ShouldBeNull();
claims.Nats.Pub.Deny.ShouldBeNull();
}
}