diff --git a/tests/NATS.Server.Tests/Auth/JwtGoParityTests.cs b/tests/NATS.Server.Tests/Auth/JwtGoParityTests.cs
new file mode 100644
index 0000000..f44d904
--- /dev/null
+++ b/tests/NATS.Server.Tests/Auth/JwtGoParityTests.cs
@@ -0,0 +1,1770 @@
+// Port of Go server/jwt_test.go — JWT claims, trust chain, signing keys, revocation,
+// bearer tokens, permission templates, connection type filtering, and account limits.
+// Reference: golang/nats-server/server/jwt_test.go
+
+using System.Security.Cryptography;
+using System.Text;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using NATS.NKeys;
+using NATS.Server.Auth;
+using NATS.Server.Auth.Jwt;
+using NATS.Server.Protocol;
+
+namespace NATS.Server.Tests.Auth
+{
+
+///
+/// Parity tests ported from Go server/jwt_test.go covering JWT authentication:
+/// trust chain validation, signing key hierarchy, expiration, revocation,
+/// bearer tokens, permission claims, account limits, connection type filtering,
+/// and permission template expansion.
+///
+public class JwtGoParityTests
+{
+ // =========================================================================
+ // Test helpers — mirror Go's opTrustBasicSetup, buildMemAccResolver, etc.
+ // Reference: jwt_test.go:65-170
+ // =========================================================================
+
+ /// Creates an operator key pair (mirrors Go oKp / nkeys.CreateOperator).
+ private static KeyPair CreateOperatorKp()
+ => KeyPair.CreatePair(PrefixByte.Operator);
+
+ /// Creates a fresh account key pair (mirrors nkeys.CreateAccount).
+ private static KeyPair CreateAccountKp()
+ => KeyPair.CreatePair(PrefixByte.Account);
+
+ /// Creates a fresh user key pair (mirrors nkeys.CreateUser).
+ private static KeyPair CreateUserKp()
+ => KeyPair.CreatePair(PrefixByte.User);
+
+ ///
+ /// Encodes a JWT payload using Ed25519 signing with the given key pair.
+ /// Matches the NATS JWT wire format: base64url(header).base64url(payload).base64url(signature).
+ /// Reference: github.com/nats-io/jwt/v2 — Claims.Encode
+ ///
+ private static string EncodeJwt(object payload, KeyPair signingKp)
+ {
+ var header = new JwtEncodingHeader { Algorithm = "ed25519-nkey", Type = "jwt" };
+ var headerJson = JsonSerializer.Serialize(header);
+ var payloadJson = JsonSerializer.Serialize(payload);
+
+ var headerB64 = Base64UrlEncode(Encoding.UTF8.GetBytes(headerJson));
+ var payloadB64 = Base64UrlEncode(Encoding.UTF8.GetBytes(payloadJson));
+
+ var signingInput = $"{headerB64}.{payloadB64}";
+ var signingInputBytes = Encoding.UTF8.GetBytes(signingInput);
+
+ var sig = new byte[64];
+ signingKp.Sign(signingInputBytes, sig);
+
+ var sigB64 = Base64UrlEncode(sig);
+ return $"{headerB64}.{payloadB64}.{sigB64}";
+ }
+
+ private static string Base64UrlEncode(byte[] data)
+ => Convert.ToBase64String(data).TrimEnd('=').Replace('+', '-').Replace('/', '_');
+
+ private static long Now() => DateTimeOffset.UtcNow.ToUnixTimeSeconds();
+ private static long Ago(int seconds) => Now() - seconds;
+ private static long InFuture(int seconds) => Now() + seconds;
+
+ ///
+ /// Builds an account JWT signed by the operator key pair.
+ /// Reference: jwt_test.go:111-140 setupJWTTestWithClaims
+ ///
+ private static string BuildAccountJwt(
+ KeyPair operatorKp,
+ string accountPub,
+ string[]? signingKeys = null,
+ long? expiresAt = null,
+ long? issuedAt = null,
+ Dictionary? revocations = null,
+ JwtTestAccountLimits? limits = null,
+ string? name = null,
+ string[]? tags = null)
+ {
+ var operatorPub = operatorKp.GetPublicKey();
+ var payload = new JwtTestAccountClaims
+ {
+ Subject = accountPub,
+ Issuer = operatorPub,
+ IssuedAt = issuedAt ?? Now(),
+ Expires = expiresAt ?? 0,
+ Name = name,
+ Nats = new JwtTestAccountNats
+ {
+ Type = "account",
+ Version = 2,
+ SigningKeys = signingKeys,
+ Revocations = revocations,
+ Limits = limits,
+ Tags = tags,
+ },
+ };
+ return EncodeJwt(payload, operatorKp);
+ }
+
+ ///
+ /// Builds a user JWT signed by the account key pair (or a signing key).
+ /// Reference: jwt_test.go:62-109 createClientWithIssuer / createClient
+ ///
+ private static string BuildUserJwt(
+ KeyPair signingKp,
+ string userPub,
+ string? issuerAccount = null,
+ long? expiresAt = null,
+ long? issuedAt = null,
+ bool bearerToken = false,
+ string[]? pubAllow = null,
+ string[]? pubDeny = null,
+ string[]? subAllow = null,
+ string[]? subDeny = null,
+ string[]? allowedConnectionTypes = null,
+ string? name = null,
+ string[]? tags = null)
+ {
+ var signingPub = signingKp.GetPublicKey();
+ var payload = new JwtTestUserClaims
+ {
+ Subject = userPub,
+ Issuer = signingPub,
+ IssuedAt = issuedAt ?? Now(),
+ Expires = expiresAt ?? 0,
+ Name = name,
+ Nats = new JwtTestUserNats
+ {
+ Type = "user",
+ Version = 2,
+ IssuerAccount = issuerAccount,
+ BearerToken = bearerToken ? true : null,
+ AllowedConnectionTypes = allowedConnectionTypes,
+ Tags = tags,
+ Pub = (pubAllow != null || pubDeny != null)
+ ? new JwtTestSubjectPerm { Allow = pubAllow, Deny = pubDeny }
+ : null,
+ Sub = (subAllow != null || subDeny != null)
+ ? new JwtTestSubjectPerm { Allow = subAllow, Deny = subDeny }
+ : null,
+ },
+ };
+ return EncodeJwt(payload, signingKp);
+ }
+
+ ///
+ /// Builds a JwtAuthenticator with the given operator key pair and account JWT pre-loaded.
+ /// Reference: jwt_test.go:137-141 opTrustBasicSetup / buildMemAccResolver / addAccountToMemResolver
+ ///
+ private static (JwtAuthenticator auth, MemAccountResolver resolver) BuildAuthenticator(
+ KeyPair operatorKp,
+ string? accountPub = null,
+ string? accountJwt = null)
+ {
+ var resolver = new MemAccountResolver();
+ if (accountPub != null && accountJwt != null)
+ resolver.StoreAsync(accountPub, accountJwt).GetAwaiter().GetResult();
+
+ var auth = new JwtAuthenticator([operatorKp.GetPublicKey()], resolver);
+ return (auth, resolver);
+ }
+
+ /// Creates a valid nonce signature for a user key pair.
+ private static (byte[] nonce, string sig) CreateNonceSig(KeyPair userKp)
+ {
+ var nonce = new byte[16];
+ RandomNumberGenerator.Fill(nonce);
+ var sig = new byte[64];
+ userKp.Sign(nonce, sig);
+ var sigStr = Base64UrlEncode(sig);
+ return (nonce, sigStr);
+ }
+
+ /// Calls Authenticate with a freshly-signed nonce.
+ private static AuthResult? Authenticate(
+ JwtAuthenticator auth,
+ string userJwt,
+ KeyPair userKp)
+ {
+ var (nonce, sig) = CreateNonceSig(userKp);
+ return auth.Authenticate(new ClientAuthContext
+ {
+ Opts = new ClientOptions { JWT = userJwt, Sig = sig },
+ Nonce = nonce,
+ });
+ }
+
+ // =========================================================================
+ // TestJWTUser — basic trust chain: operator -> account -> user
+ // Go reference: jwt_test.go:199 TestJWTUser
+ // =========================================================================
+
+ [Fact]
+ public void JwtUser_NoJwt_ReturnsNull()
+ {
+ // Go: TestJWTUser — connecting without jwt field should fail (return null).
+ using var operatorKp = CreateOperatorKp();
+ using var accountKp = CreateAccountKp();
+ var accountPub = accountKp.GetPublicKey();
+ var accountJwt = BuildAccountJwt(operatorKp, accountPub);
+
+ var (auth, _) = BuildAuthenticator(operatorKp, accountPub, accountJwt);
+
+ var result = auth.Authenticate(new ClientAuthContext
+ {
+ Opts = new ClientOptions(),
+ Nonce = [],
+ });
+
+ result.ShouldBeNull();
+ }
+
+ [Fact]
+ public void JwtUser_NoAccountResolver_ReturnsNull()
+ {
+ // Go: TestJWTUser — connecting with JWT but no account in resolver should fail.
+ using var operatorKp = CreateOperatorKp();
+ using var accountKp = CreateAccountKp();
+ using var userKp = CreateUserKp();
+ var accountPub = accountKp.GetPublicKey();
+ var userPub = userKp.GetPublicKey();
+
+ // Authenticator with empty resolver
+ var resolver = new MemAccountResolver();
+ var auth = new JwtAuthenticator([operatorKp.GetPublicKey()], resolver);
+
+ var userJwt = BuildUserJwt(accountKp, userPub);
+ var result = Authenticate(auth, userJwt, userKp);
+
+ result.ShouldBeNull();
+ }
+
+ [Fact]
+ public void JwtUser_ValidTrustChain_ReturnsResult()
+ {
+ // Go: TestJWTUser — valid operator -> account -> user chain allows connection.
+ using var operatorKp = CreateOperatorKp();
+ using var accountKp = CreateAccountKp();
+ using var userKp = CreateUserKp();
+ var accountPub = accountKp.GetPublicKey();
+ var userPub = userKp.GetPublicKey();
+
+ var accountJwt = BuildAccountJwt(operatorKp, accountPub);
+ var userJwt = BuildUserJwt(accountKp, userPub);
+ var (auth, _) = BuildAuthenticator(operatorKp, accountPub, accountJwt);
+
+ var result = Authenticate(auth, userJwt, userKp);
+
+ result.ShouldNotBeNull();
+ result.Identity.ShouldBe(userPub);
+ result.AccountName.ShouldBe(accountPub);
+ }
+
+ // =========================================================================
+ // TestJWTUserBadTrusted — bad trusted key rejects valid JWT
+ // Go reference: jwt_test.go:255 TestJWTUserBadTrusted
+ // =========================================================================
+
+ [Fact]
+ public async Task JwtUser_BadTrustedKey_ReturnsNull()
+ {
+ // Go: TestJWTUserBadTrusted — replacing trusted keys with invalid key rejects connections.
+ using var operatorKp = CreateOperatorKp();
+ using var accountKp = CreateAccountKp();
+ using var userKp = CreateUserKp();
+ var accountPub = accountKp.GetPublicKey();
+ var userPub = userKp.GetPublicKey();
+
+ var accountJwt = BuildAccountJwt(operatorKp, accountPub);
+ var userJwt = BuildUserJwt(accountKp, userPub);
+
+ using var badOperatorKp = CreateOperatorKp();
+ var resolver = new MemAccountResolver();
+ await resolver.StoreAsync(accountPub, accountJwt);
+ var auth = new JwtAuthenticator([badOperatorKp.GetPublicKey()], resolver);
+
+ var result = Authenticate(auth, userJwt, userKp);
+
+ result.ShouldBeNull();
+ }
+
+ // =========================================================================
+ // TestJWTUserExpired — expired user JWT
+ // Go reference: jwt_test.go:292 TestJWTUserExpired
+ // =========================================================================
+
+ [Fact]
+ public void JwtUser_ExpiredUser_ReturnsNull()
+ {
+ // Go: TestJWTUserExpired — expired user JWT is rejected.
+ using var operatorKp = CreateOperatorKp();
+ using var accountKp = CreateAccountKp();
+ using var userKp = CreateUserKp();
+ var accountPub = accountKp.GetPublicKey();
+ var userPub = userKp.GetPublicKey();
+
+ var accountJwt = BuildAccountJwt(operatorKp, accountPub);
+ var userJwt = BuildUserJwt(accountKp, userPub,
+ issuedAt: Ago(10),
+ expiresAt: Ago(2));
+ var (auth, _) = BuildAuthenticator(operatorKp, accountPub, accountJwt);
+
+ var result = Authenticate(auth, userJwt, userKp);
+
+ result.ShouldBeNull();
+ }
+
+ // =========================================================================
+ // TestJWTUserExpiresAfterConnect — JWT not yet expired returns result
+ // Go reference: jwt_test.go:301 TestJWTUserExpiresAfterConnect
+ // =========================================================================
+
+ [Fact]
+ public void JwtUser_NotYetExpired_ReturnsResult()
+ {
+ // Go: TestJWTUserExpiresAfterConnect — a JWT that expires in the future should succeed at connect time.
+ using var operatorKp = CreateOperatorKp();
+ using var accountKp = CreateAccountKp();
+ using var userKp = CreateUserKp();
+ var accountPub = accountKp.GetPublicKey();
+ var userPub = userKp.GetPublicKey();
+
+ var accountJwt = BuildAccountJwt(operatorKp, accountPub);
+ var userJwt = BuildUserJwt(accountKp, userPub,
+ issuedAt: Now(),
+ expiresAt: InFuture(60));
+ var (auth, _) = BuildAuthenticator(operatorKp, accountPub, accountJwt);
+
+ var result = Authenticate(auth, userJwt, userKp);
+
+ result.ShouldNotBeNull();
+ result.Expiry.ShouldNotBeNull();
+ var expiryUnix = result.Expiry!.Value.ToUnixTimeSeconds();
+ (expiryUnix >= InFuture(58) && expiryUnix <= InFuture(62)).ShouldBeTrue("expiry should be approximately 60 seconds in the future");
+ }
+
+ // =========================================================================
+ // TestJWTAccountExpired — expired account JWT decode test
+ // Go reference: jwt_test.go:473 TestJWTAccountExpired
+ // =========================================================================
+
+ [Fact]
+ public void JwtAccount_ExpiredClaims_DecodedCorrectly()
+ {
+ // Go: TestJWTAccountExpired — expired account JWT can still be decoded.
+ // The .NET JwtAuthenticator checks user expiry; account expiry is a server-level concern.
+ using var operatorKp = CreateOperatorKp();
+ using var accountKp = CreateAccountKp();
+ var accountPub = accountKp.GetPublicKey();
+
+ var accountJwt = BuildAccountJwt(operatorKp, accountPub,
+ issuedAt: Ago(10),
+ expiresAt: Ago(2));
+
+ var claims = NatsJwt.DecodeAccountClaims(accountJwt);
+ claims.ShouldNotBeNull();
+ claims.Expires.ShouldBeGreaterThan(0);
+ (DateTimeOffset.UtcNow.ToUnixTimeSeconds() > claims.Expires).ShouldBeTrue();
+ }
+
+ // =========================================================================
+ // TestJWTUserPermissionClaims — user permissions from JWT claims
+ // Go reference: jwt_test.go:331 TestJWTUserPermissionClaims
+ // =========================================================================
+
+ [Fact]
+ public void JwtUser_PermissionClaims_Transferred()
+ {
+ // Go: TestJWTUserPermissionClaims — pub/sub allow/deny transferred from JWT to AuthResult.
+ using var operatorKp = CreateOperatorKp();
+ using var accountKp = CreateAccountKp();
+ using var userKp = CreateUserKp();
+ var accountPub = accountKp.GetPublicKey();
+ var userPub = userKp.GetPublicKey();
+
+ var accountJwt = BuildAccountJwt(operatorKp, accountPub);
+ var userJwt = BuildUserJwt(accountKp, userPub,
+ pubAllow: ["foo", "bar"],
+ pubDeny: ["baz"],
+ subAllow: ["foo", "bar"],
+ subDeny: ["baz"]);
+ var (auth, _) = BuildAuthenticator(operatorKp, accountPub, accountJwt);
+
+ var result = Authenticate(auth, userJwt, userKp);
+
+ result.ShouldNotBeNull();
+ result.Permissions.ShouldNotBeNull();
+ result.Permissions!.Publish.ShouldNotBeNull();
+ result.Permissions!.Subscribe.ShouldNotBeNull();
+
+ result.Permissions.Publish!.Allow.ShouldNotBeNull();
+ result.Permissions.Publish.Allow!.Count.ShouldBe(2);
+ result.Permissions.Publish.Deny.ShouldNotBeNull();
+ result.Permissions.Publish.Deny!.Count.ShouldBe(1);
+
+ result.Permissions.Subscribe!.Allow.ShouldNotBeNull();
+ result.Permissions.Subscribe.Allow!.Count.ShouldBe(2);
+ result.Permissions.Subscribe.Deny.ShouldNotBeNull();
+ result.Permissions.Subscribe.Deny!.Count.ShouldBe(1);
+
+ result.Permissions.Publish.Allow.ShouldContain("foo");
+ result.Permissions.Publish.Allow.ShouldContain("bar");
+ result.Permissions.Publish.Deny.ShouldContain("baz");
+ result.Permissions.Subscribe.Allow.ShouldContain("foo");
+ result.Permissions.Subscribe.Allow.ShouldContain("bar");
+ result.Permissions.Subscribe.Deny.ShouldContain("baz");
+ }
+
+ [Fact]
+ public void JwtUser_NoPermissions_NullPermissions()
+ {
+ // Go: user with no permissions results in null Permissions on AuthResult.
+ using var operatorKp = CreateOperatorKp();
+ using var accountKp = CreateAccountKp();
+ using var userKp = CreateUserKp();
+ var accountPub = accountKp.GetPublicKey();
+ var userPub = userKp.GetPublicKey();
+
+ var accountJwt = BuildAccountJwt(operatorKp, accountPub);
+ var userJwt = BuildUserJwt(accountKp, userPub);
+ var (auth, _) = BuildAuthenticator(operatorKp, accountPub, accountJwt);
+
+ var result = Authenticate(auth, userJwt, userKp);
+
+ result.ShouldNotBeNull();
+ result.Permissions.ShouldBeNull();
+ }
+
+ // =========================================================================
+ // TestJWTUserSigningKey — account signing key hierarchy
+ // Go reference: jwt_test.go:2241 TestJWTUserSigningKey
+ // =========================================================================
+
+ [Fact]
+ public void JwtUser_SigningKey_NotInAccount_ReturnsNull()
+ {
+ // Go: TestJWTUserSigningKey — signing key not registered in account JWT is rejected.
+ using var operatorKp = CreateOperatorKp();
+ using var accountKp = CreateAccountKp();
+ using var signingKp = CreateAccountKp();
+ using var userKp = CreateUserKp();
+ var accountPub = accountKp.GetPublicKey();
+ var userPub = userKp.GetPublicKey();
+
+ var accountJwt = BuildAccountJwt(operatorKp, accountPub);
+ var userJwt = BuildUserJwt(signingKp, userPub, issuerAccount: accountPub);
+ var (auth, _) = BuildAuthenticator(operatorKp, accountPub, accountJwt);
+
+ var result = Authenticate(auth, userJwt, userKp);
+
+ result.ShouldBeNull();
+ }
+
+ [Fact]
+ public void JwtUser_SigningKey_RegisteredInAccount_ReturnsResult()
+ {
+ // Go: TestJWTUserSigningKey — signing key listed in account JWT allows user connection.
+ using var operatorKp = CreateOperatorKp();
+ using var accountKp = CreateAccountKp();
+ using var signingKp = CreateAccountKp();
+ using var userKp = CreateUserKp();
+ var accountPub = accountKp.GetPublicKey();
+ var signingPub = signingKp.GetPublicKey();
+ var userPub = userKp.GetPublicKey();
+
+ var accountJwt = BuildAccountJwt(operatorKp, accountPub, signingKeys: [signingPub]);
+ var userJwt = BuildUserJwt(signingKp, userPub, issuerAccount: accountPub);
+ var (auth, _) = BuildAuthenticator(operatorKp, accountPub, accountJwt);
+
+ var result = Authenticate(auth, userJwt, userKp);
+
+ result.ShouldNotBeNull();
+ result.Identity.ShouldBe(userPub);
+ result.AccountName.ShouldBe(accountPub);
+ }
+
+ [Fact]
+ public void JwtUser_MultipleSigningKeys_AnyWorks()
+ {
+ // Go: account can have multiple signing keys; any one can sign user JWTs.
+ using var operatorKp = CreateOperatorKp();
+ using var accountKp = CreateAccountKp();
+ using var signingKp1 = CreateAccountKp();
+ using var signingKp2 = CreateAccountKp();
+ using var userKp = CreateUserKp();
+ var accountPub = accountKp.GetPublicKey();
+ var userPub = userKp.GetPublicKey();
+
+ var accountJwt = BuildAccountJwt(operatorKp, accountPub,
+ signingKeys: [signingKp1.GetPublicKey(), signingKp2.GetPublicKey()]);
+ var userJwt = BuildUserJwt(signingKp2, userPub, issuerAccount: accountPub);
+ var (auth, _) = BuildAuthenticator(operatorKp, accountPub, accountJwt);
+
+ var result = Authenticate(auth, userJwt, userKp);
+
+ result.ShouldNotBeNull();
+ result.Identity.ShouldBe(userPub);
+ }
+
+ // =========================================================================
+ // TestJWTUserRevoked — revoked user JWT
+ // Go reference: jwt_test.go:2673 TestJWTUserRevoked
+ // =========================================================================
+
+ [Fact]
+ public void JwtUser_Revoked_ReturnsNull()
+ {
+ // Go: TestJWTUserRevoked — user revoked in account JWT is rejected at connect.
+ using var operatorKp = CreateOperatorKp();
+ using var accountKp = CreateAccountKp();
+ using var userKp = CreateUserKp();
+ var accountPub = accountKp.GetPublicKey();
+ var userPub = userKp.GetPublicKey();
+
+ // Account with user revoked at "now"; user JWT issued 5 seconds ago
+ var accountJwt = BuildAccountJwt(operatorKp, accountPub,
+ revocations: new Dictionary { [userPub] = Now() });
+ var userJwt = BuildUserJwt(accountKp, userPub, issuedAt: Ago(5));
+ var (auth, _) = BuildAuthenticator(operatorKp, accountPub, accountJwt);
+
+ var result = Authenticate(auth, userJwt, userKp);
+
+ result.ShouldBeNull();
+ }
+
+ [Fact]
+ public void JwtUser_NotRevoked_ReturnsResult()
+ {
+ // Go: user issued AFTER revocation time is not revoked.
+ using var operatorKp = CreateOperatorKp();
+ using var accountKp = CreateAccountKp();
+ using var userKp = CreateUserKp();
+ var accountPub = accountKp.GetPublicKey();
+ var userPub = userKp.GetPublicKey();
+
+ // Revocation set to 10 seconds ago
+ var accountJwt = BuildAccountJwt(operatorKp, accountPub,
+ revocations: new Dictionary { [userPub] = Ago(10) });
+ var userJwt = BuildUserJwt(accountKp, userPub, issuedAt: Now());
+ var (auth, _) = BuildAuthenticator(operatorKp, accountPub, accountJwt);
+
+ var result = Authenticate(auth, userJwt, userKp);
+
+ result.ShouldNotBeNull();
+ }
+
+ [Fact]
+ public void JwtUser_WildcardRevocation_RevokesAll()
+ {
+ // Go: TestJWTUserRevocation — wildcard "*" revocation revokes all users issued before the time.
+ using var operatorKp = CreateOperatorKp();
+ using var accountKp = CreateAccountKp();
+ using var userKp = CreateUserKp();
+ var accountPub = accountKp.GetPublicKey();
+ var userPub = userKp.GetPublicKey();
+
+ var accountJwt = BuildAccountJwt(operatorKp, accountPub,
+ revocations: new Dictionary { ["*"] = Now() });
+ var userJwt = BuildUserJwt(accountKp, userPub, issuedAt: Ago(5));
+ var (auth, _) = BuildAuthenticator(operatorKp, accountPub, accountJwt);
+
+ var result = Authenticate(auth, userJwt, userKp);
+
+ result.ShouldBeNull();
+ }
+
+ [Fact]
+ public void JwtUser_WildcardRevocation_NewJwt_NotRevoked()
+ {
+ // Go: TestJWTUserRevocation — user JWT issued after wildcard revocation time is allowed.
+ using var operatorKp = CreateOperatorKp();
+ using var accountKp = CreateAccountKp();
+ using var userKp = CreateUserKp();
+ var accountPub = accountKp.GetPublicKey();
+ var userPub = userKp.GetPublicKey();
+
+ var accountJwt = BuildAccountJwt(operatorKp, accountPub,
+ revocations: new Dictionary { ["*"] = Ago(10) });
+ var userJwt = BuildUserJwt(accountKp, userPub, issuedAt: Now());
+ var (auth, _) = BuildAuthenticator(operatorKp, accountPub, accountJwt);
+
+ var result = Authenticate(auth, userJwt, userKp);
+
+ result.ShouldNotBeNull();
+ }
+
+ // =========================================================================
+ // TestJWTBearerToken — bearer token skips nonce signature
+ // Go reference: jwt_test.go:2997 TestJWTBearerToken
+ // =========================================================================
+
+ [Fact]
+ public void JwtBearerToken_NoSignature_ReturnsResult()
+ {
+ // Go: TestJWTBearerToken — bearer token does not require nonce signature.
+ using var operatorKp = CreateOperatorKp();
+ using var accountKp = CreateAccountKp();
+ using var userKp = CreateUserKp();
+ var accountPub = accountKp.GetPublicKey();
+ var userPub = userKp.GetPublicKey();
+
+ var accountJwt = BuildAccountJwt(operatorKp, accountPub);
+ var userJwt = BuildUserJwt(accountKp, userPub, bearerToken: true);
+ var (auth, _) = BuildAuthenticator(operatorKp, accountPub, accountJwt);
+
+ var result = auth.Authenticate(new ClientAuthContext
+ {
+ Opts = new ClientOptions { JWT = userJwt },
+ Nonce = [],
+ });
+
+ result.ShouldNotBeNull();
+ result.Identity.ShouldBe(userPub);
+ }
+
+ [Fact]
+ public void JwtBearerToken_WithIssuerAccount_SameAsAccount_Succeeds()
+ {
+ // Go: TestJWTBearerWithIssuerSameAsAccountToken — bearer with IssuerAccount = account pub is OK.
+ using var operatorKp = CreateOperatorKp();
+ using var accountKp = CreateAccountKp();
+ using var userKp = CreateUserKp();
+ var accountPub = accountKp.GetPublicKey();
+ var userPub = userKp.GetPublicKey();
+
+ var accountJwt = BuildAccountJwt(operatorKp, accountPub);
+ var userJwt = BuildUserJwt(accountKp, userPub, issuerAccount: accountPub, bearerToken: true);
+ var (auth, _) = BuildAuthenticator(operatorKp, accountPub, accountJwt);
+
+ var result = auth.Authenticate(new ClientAuthContext
+ {
+ Opts = new ClientOptions { JWT = userJwt },
+ Nonce = [],
+ });
+
+ result.ShouldNotBeNull();
+ }
+
+ [Fact]
+ public void JwtBearerToken_WithBadIssuerAccount_ReturnsNull()
+ {
+ // Go: TestJWTBearerWithBadIssuerToken — bearer with IssuerAccount pointing to different account is rejected.
+ using var operatorKp = CreateOperatorKp();
+ using var accountKp = CreateAccountKp();
+ using var differentAccountKp = CreateAccountKp();
+ using var userKp = CreateUserKp();
+ var accountPub = accountKp.GetPublicKey();
+ var differentAccountPub = differentAccountKp.GetPublicKey();
+ var userPub = userKp.GetPublicKey();
+
+ var accountJwt = BuildAccountJwt(operatorKp, accountPub);
+ var userJwt = BuildUserJwt(accountKp, userPub, issuerAccount: differentAccountPub, bearerToken: true);
+ var (auth, _) = BuildAuthenticator(operatorKp, accountPub, accountJwt);
+
+ var result = auth.Authenticate(new ClientAuthContext
+ {
+ Opts = new ClientOptions { JWT = userJwt },
+ Nonce = [],
+ });
+
+ result.ShouldBeNull();
+ }
+
+ [Fact]
+ public void JwtNonBearer_NoSignature_ReturnsNull()
+ {
+ // Go: non-bearer tokens without nonce signature are rejected.
+ using var operatorKp = CreateOperatorKp();
+ using var accountKp = CreateAccountKp();
+ using var userKp = CreateUserKp();
+ var accountPub = accountKp.GetPublicKey();
+ var userPub = userKp.GetPublicKey();
+
+ var accountJwt = BuildAccountJwt(operatorKp, accountPub);
+ var userJwt = BuildUserJwt(accountKp, userPub);
+ var (auth, _) = BuildAuthenticator(operatorKp, accountPub, accountJwt);
+
+ var result = auth.Authenticate(new ClientAuthContext
+ {
+ Opts = new ClientOptions { JWT = userJwt },
+ Nonce = [],
+ });
+
+ result.ShouldBeNull();
+ }
+
+ [Fact]
+ public void JwtNonBearer_BadSignature_ReturnsNull()
+ {
+ // Go: non-bearer tokens with incorrect nonce signature are rejected.
+ using var operatorKp = CreateOperatorKp();
+ using var accountKp = CreateAccountKp();
+ using var userKp = CreateUserKp();
+ using var wrongKp = CreateUserKp();
+ var accountPub = accountKp.GetPublicKey();
+ var userPub = userKp.GetPublicKey();
+
+ var accountJwt = BuildAccountJwt(operatorKp, accountPub);
+ var userJwt = BuildUserJwt(accountKp, userPub);
+ var (auth, _) = BuildAuthenticator(operatorKp, accountPub, accountJwt);
+
+ var nonce = new byte[16];
+ RandomNumberGenerator.Fill(nonce);
+ var (_, wrongSig) = CreateNonceSig(wrongKp);
+
+ var result = auth.Authenticate(new ClientAuthContext
+ {
+ Opts = new ClientOptions { JWT = userJwt, Sig = wrongSig },
+ Nonce = nonce,
+ });
+
+ result.ShouldBeNull();
+ }
+
+ // =========================================================================
+ // TestJWTNoOperatorMode — non-operator mode
+ // Go reference: jwt_test.go:4483 TestJWTNoOperatorMode
+ // =========================================================================
+
+ [Fact]
+ public void JwtAuth_NoTrustedKeys_AuthNotRequired()
+ {
+ // Go: TestJWTNoOperatorMode — without operator mode, auth is not required (no TrustedKeys).
+ var service = AuthService.Build(new NatsOptions());
+
+ service.IsAuthRequired.ShouldBeFalse();
+ }
+
+ [Fact]
+ public void JwtAuth_TrustedKeysConfigured_AuthRequired()
+ {
+ // Go: TestJWTNoOperatorMode — operator mode with TrustedKeys sets auth required.
+ using var operatorKp = CreateOperatorKp();
+ var service = AuthService.Build(new NatsOptions
+ {
+ TrustedKeys = [operatorKp.GetPublicKey()],
+ AccountResolver = new MemAccountResolver(),
+ });
+
+ service.IsAuthRequired.ShouldBeTrue();
+ service.NonceRequired.ShouldBeTrue();
+ }
+
+ // =========================================================================
+ // Connection types filtering
+ // Go reference: jwt_test.go — allowed_connection_types claim enforcement
+ // =========================================================================
+
+ [Fact]
+ public void JwtConnectionTypes_StandardAllowed_Succeeds()
+ {
+ // Go: when allowed_connection_types includes STANDARD, standard client connects.
+ using var operatorKp = CreateOperatorKp();
+ using var accountKp = CreateAccountKp();
+ using var userKp = CreateUserKp();
+ var accountPub = accountKp.GetPublicKey();
+ var userPub = userKp.GetPublicKey();
+
+ var accountJwt = BuildAccountJwt(operatorKp, accountPub);
+ var userJwt = BuildUserJwt(accountKp, userPub, allowedConnectionTypes: ["STANDARD"]);
+ var (auth, _) = BuildAuthenticator(operatorKp, accountPub, accountJwt);
+
+ var (nonce, sig) = CreateNonceSig(userKp);
+ var result = auth.Authenticate(new ClientAuthContext
+ {
+ Opts = new ClientOptions { JWT = userJwt, Sig = sig },
+ Nonce = nonce,
+ ConnectionType = "STANDARD",
+ });
+
+ result.ShouldNotBeNull();
+ }
+
+ [Fact]
+ public void JwtConnectionTypes_WebsocketOnly_StandardRejected()
+ {
+ // Go: when allowed_connection_types only includes WEBSOCKET, standard client is rejected.
+ using var operatorKp = CreateOperatorKp();
+ using var accountKp = CreateAccountKp();
+ using var userKp = CreateUserKp();
+ var accountPub = accountKp.GetPublicKey();
+ var userPub = userKp.GetPublicKey();
+
+ var accountJwt = BuildAccountJwt(operatorKp, accountPub);
+ var userJwt = BuildUserJwt(accountKp, userPub, allowedConnectionTypes: ["WEBSOCKET"]);
+ var (auth, _) = BuildAuthenticator(operatorKp, accountPub, accountJwt);
+
+ var (nonce, sig) = CreateNonceSig(userKp);
+ var result = auth.Authenticate(new ClientAuthContext
+ {
+ Opts = new ClientOptions { JWT = userJwt, Sig = sig },
+ Nonce = nonce,
+ ConnectionType = "STANDARD",
+ });
+
+ result.ShouldBeNull();
+ }
+
+ [Fact]
+ public void JwtConnectionTypes_EmptyList_AllConnectionsAllowed()
+ {
+ // Go: empty allowed_connection_types means all types are allowed.
+ using var operatorKp = CreateOperatorKp();
+ using var accountKp = CreateAccountKp();
+ using var userKp = CreateUserKp();
+ var accountPub = accountKp.GetPublicKey();
+ var userPub = userKp.GetPublicKey();
+
+ var accountJwt = BuildAccountJwt(operatorKp, accountPub);
+ var userJwt = BuildUserJwt(accountKp, userPub);
+ var (auth, _) = BuildAuthenticator(operatorKp, accountPub, accountJwt);
+
+ var (nonce, sig) = CreateNonceSig(userKp);
+ var result = auth.Authenticate(new ClientAuthContext
+ {
+ Opts = new ClientOptions { JWT = userJwt, Sig = sig },
+ Nonce = nonce,
+ ConnectionType = "STANDARD",
+ });
+
+ result.ShouldNotBeNull();
+ }
+
+ [Fact]
+ public void JwtConnectionTypes_MultipleTypes_AnyMatchingAllowed()
+ {
+ // Go: multiple allowed connection types — any matching type is allowed.
+ using var operatorKp = CreateOperatorKp();
+ using var accountKp = CreateAccountKp();
+ using var userKp = CreateUserKp();
+ var accountPub = accountKp.GetPublicKey();
+ var userPub = userKp.GetPublicKey();
+
+ var accountJwt = BuildAccountJwt(operatorKp, accountPub);
+ var userJwt = BuildUserJwt(accountKp, userPub,
+ allowedConnectionTypes: ["STANDARD", "WEBSOCKET", "LEAFNODE"]);
+ var (auth, _) = BuildAuthenticator(operatorKp, accountPub, accountJwt);
+
+ foreach (var connType in new[] { "STANDARD", "WEBSOCKET", "LEAFNODE" })
+ {
+ var (nonce, sig) = CreateNonceSig(userKp);
+ var result = auth.Authenticate(new ClientAuthContext
+ {
+ Opts = new ClientOptions { JWT = userJwt, Sig = sig },
+ Nonce = nonce,
+ ConnectionType = connType,
+ });
+ result.ShouldNotBeNull($"expected connection type {connType} to be allowed");
+ }
+ }
+
+ [Fact]
+ public void JwtConnectionTypes_UnknownTypeOnly_ReturnsNull()
+ {
+ // Go: unknown connection type values cause rejection.
+ using var operatorKp = CreateOperatorKp();
+ using var accountKp = CreateAccountKp();
+ using var userKp = CreateUserKp();
+ var accountPub = accountKp.GetPublicKey();
+ var userPub = userKp.GetPublicKey();
+
+ var accountJwt = BuildAccountJwt(operatorKp, accountPub);
+ var userJwt = BuildUserJwt(accountKp, userPub, allowedConnectionTypes: ["UNKNOWN_TYPE_XYZ"]);
+ var (auth, _) = BuildAuthenticator(operatorKp, accountPub, accountJwt);
+
+ var (nonce, sig) = CreateNonceSig(userKp);
+ var result = auth.Authenticate(new ClientAuthContext
+ {
+ Opts = new ClientOptions { JWT = userJwt, Sig = sig },
+ Nonce = nonce,
+ ConnectionType = "STANDARD",
+ });
+
+ result.ShouldBeNull();
+ }
+
+ // =========================================================================
+ // NatsJwt.IsJwt — JWT string detection
+ // Go reference: jwt_test.go:4951 TestJWTHeader
+ // =========================================================================
+
+ [Fact]
+ public void NatsJwt_IsJwt_ValidJwt_ReturnsTrue()
+ {
+ // Go: TestJWTHeader — valid JWT starts with "eyJ".
+ using var accountKp = CreateAccountKp();
+ using var userKp = CreateUserKp();
+ var userJwt = BuildUserJwt(accountKp, userKp.GetPublicKey());
+
+ NatsJwt.IsJwt(userJwt).ShouldBeTrue();
+ userJwt.ShouldStartWith("eyJ");
+ }
+
+ [Fact]
+ public void NatsJwt_IsJwt_EmptyString_ReturnsFalse()
+ {
+ NatsJwt.IsJwt("").ShouldBeFalse();
+ NatsJwt.IsJwt(null!).ShouldBeFalse();
+ }
+
+ [Fact]
+ public void NatsJwt_IsJwt_PlainString_ReturnsFalse()
+ {
+ NatsJwt.IsJwt("not-a-jwt").ShouldBeFalse();
+ NatsJwt.IsJwt("Bearer token").ShouldBeFalse();
+ }
+
+ // =========================================================================
+ // NatsJwt.Decode — structural JWT parsing
+ // Go reference: jwt_test.go:4951 TestJWTHeader
+ // =========================================================================
+
+ [Fact]
+ public void NatsJwt_Decode_ValidToken_ReturnsToken()
+ {
+ // Go: TestJWTHeader — JWT decoded into header+payload+signature.
+ using var accountKp = CreateAccountKp();
+ using var userKp = CreateUserKp();
+ var userJwt = BuildUserJwt(accountKp, userKp.GetPublicKey());
+
+ var decoded = NatsJwt.Decode(userJwt);
+
+ decoded.ShouldNotBeNull();
+ decoded.Header.ShouldNotBeNull();
+ decoded.Header.Algorithm.ShouldBe("ed25519-nkey");
+ decoded.Header.Type.ShouldBe("jwt");
+ decoded.PayloadJson.ShouldNotBeNullOrEmpty();
+ decoded.Signature.ShouldNotBeNull();
+ decoded.Signature.Length.ShouldBe(64);
+ }
+
+ [Fact]
+ public void NatsJwt_Decode_MalformedToken_ReturnsNull()
+ {
+ NatsJwt.Decode("not.valid").ShouldBeNull();
+ NatsJwt.Decode("").ShouldBeNull();
+ NatsJwt.Decode("a.b.c.d").ShouldBeNull();
+ }
+
+ // =========================================================================
+ // NatsJwt.DecodeUserClaims — user claims deserialization
+ // Go reference: jwt_test.go — user claims structures
+ // =========================================================================
+
+ [Fact]
+ public void NatsJwt_DecodeUserClaims_AllFields_Populated()
+ {
+ // Go: user claims decoded from JWT with all standard fields.
+ using var accountKp = CreateAccountKp();
+ using var userKp = CreateUserKp();
+ var accountPub = accountKp.GetPublicKey();
+ var userPub = userKp.GetPublicKey();
+ var iat = Now();
+ var exp = InFuture(3600);
+
+ var userJwt = BuildUserJwt(accountKp, userPub,
+ issuedAt: iat,
+ expiresAt: exp,
+ name: "testuser",
+ pubAllow: ["foo", "bar"]);
+
+ var claims = NatsJwt.DecodeUserClaims(userJwt);
+
+ claims.ShouldNotBeNull();
+ claims.Subject.ShouldBe(userPub);
+ claims.Issuer.ShouldBe(accountPub);
+ claims.IssuedAt.ShouldBe(iat);
+ claims.Expires.ShouldBe(exp);
+ claims.Name.ShouldBe("testuser");
+ claims.Nats.ShouldNotBeNull();
+ claims.Nats!.Pub.ShouldNotBeNull();
+ claims.Nats.Pub!.Allow.ShouldNotBeNull();
+ claims.Nats.Pub.Allow!.Length.ShouldBe(2);
+ }
+
+ [Fact]
+ public void NatsJwt_DecodeUserClaims_BearerToken_Parsed()
+ {
+ // Go: TestJWTBearerToken — bearer_token field parsed correctly.
+ using var accountKp = CreateAccountKp();
+ using var userKp = CreateUserKp();
+
+ var userJwt = BuildUserJwt(accountKp, userKp.GetPublicKey(), bearerToken: true);
+ var claims = NatsJwt.DecodeUserClaims(userJwt);
+
+ claims.ShouldNotBeNull();
+ claims.BearerToken.ShouldBeTrue();
+ }
+
+ [Fact]
+ public void NatsJwt_DecodeUserClaims_IssuerAccount_Parsed()
+ {
+ // Go: TestJWTUserSigningKey — issuer_account field preserved in claims.
+ using var accountKp = CreateAccountKp();
+ using var signingKp = CreateAccountKp();
+ using var userKp = CreateUserKp();
+ var accountPub = accountKp.GetPublicKey();
+
+ var userJwt = BuildUserJwt(signingKp, userKp.GetPublicKey(), issuerAccount: accountPub);
+ var claims = NatsJwt.DecodeUserClaims(userJwt);
+
+ claims.ShouldNotBeNull();
+ claims.IssuerAccount.ShouldBe(accountPub);
+ }
+
+ // =========================================================================
+ // NatsJwt.DecodeAccountClaims — account claims deserialization
+ // Go reference: jwt_test.go:629 TestJWTAccountBasicImportExport
+ // =========================================================================
+
+ [Fact]
+ public void NatsJwt_DecodeAccountClaims_AllFields_Populated()
+ {
+ // Go: TestJWTAccountBasicImportExport — account claims decoded with signing keys and revocations.
+ using var operatorKp = CreateOperatorKp();
+ using var accountKp = CreateAccountKp();
+ using var signingKp = CreateAccountKp();
+ var accountPub = accountKp.GetPublicKey();
+ var signingPub = signingKp.GetPublicKey();
+ var revokedUser = "UREVOKED123";
+ var revocationTime = Ago(100);
+
+ var accountJwt = BuildAccountJwt(operatorKp, accountPub,
+ signingKeys: [signingPub],
+ revocations: new Dictionary { [revokedUser] = revocationTime },
+ name: "TestAccount");
+
+ var claims = NatsJwt.DecodeAccountClaims(accountJwt);
+
+ claims.ShouldNotBeNull();
+ claims.Subject.ShouldBe(accountPub);
+ claims.Issuer.ShouldBe(operatorKp.GetPublicKey());
+ claims.Name.ShouldBe("TestAccount");
+ claims.Nats.ShouldNotBeNull();
+ claims.Nats!.SigningKeys.ShouldNotBeNull();
+ claims.Nats.SigningKeys!.ShouldContain(signingPub);
+ claims.Nats.Revocations.ShouldNotBeNull();
+ claims.Nats.Revocations!.ShouldContainKey(revokedUser);
+ claims.Nats.Revocations[revokedUser].ShouldBe(revocationTime);
+ }
+
+ // =========================================================================
+ // NatsJwt.Verify — JWT signature verification
+ // Go reference: jwt_test.go — trust chain verification
+ // =========================================================================
+
+ [Fact]
+ public void NatsJwt_Verify_ValidSignature_ReturnsTrue()
+ {
+ using var accountKp = CreateAccountKp();
+ using var userKp = CreateUserKp();
+ var accountPub = accountKp.GetPublicKey();
+ var userJwt = BuildUserJwt(accountKp, userKp.GetPublicKey());
+
+ NatsJwt.Verify(userJwt, accountPub).ShouldBeTrue();
+ }
+
+ [Fact]
+ public void NatsJwt_Verify_WrongKey_ReturnsFalse()
+ {
+ using var accountKp = CreateAccountKp();
+ using var wrongKp = CreateAccountKp();
+ using var userKp = CreateUserKp();
+ var userJwt = BuildUserJwt(accountKp, userKp.GetPublicKey());
+
+ NatsJwt.Verify(userJwt, wrongKp.GetPublicKey()).ShouldBeFalse();
+ }
+
+ [Fact]
+ public void NatsJwt_Verify_TamperedPayload_ReturnsFalse()
+ {
+ using var accountKp = CreateAccountKp();
+ using var userKp = CreateUserKp();
+ var accountPub = accountKp.GetPublicKey();
+ var userJwt = BuildUserJwt(accountKp, userKp.GetPublicKey());
+
+ var parts = userJwt.Split('.');
+ var tamperedJwt = $"{parts[0]}.{parts[1]}X.{parts[2]}";
+
+ NatsJwt.Verify(tamperedJwt, accountPub).ShouldBeFalse();
+ }
+
+ // =========================================================================
+ // MemAccountResolver — in-memory resolver store/fetch
+ // Go reference: jwt_test.go:65-90 buildMemAccResolver / addAccountToMemResolver
+ // =========================================================================
+
+ [Fact]
+ public async Task MemAccountResolver_StoreAndFetch_ReturnsJwt()
+ {
+ var resolver = new MemAccountResolver();
+ resolver.IsReadOnly.ShouldBeFalse();
+
+ await resolver.StoreAsync("ABCDEF", "jwt-value");
+ var jwt = await resolver.FetchAsync("ABCDEF");
+
+ jwt.ShouldBe("jwt-value");
+ }
+
+ [Fact]
+ public async Task MemAccountResolver_FetchUnknown_ReturnsNull()
+ {
+ var resolver = new MemAccountResolver();
+ var jwt = await resolver.FetchAsync("NONEXISTENT");
+
+ jwt.ShouldBeNull();
+ }
+
+ [Fact]
+ public async Task MemAccountResolver_UpdateExisting_ReplacesJwt()
+ {
+ var resolver = new MemAccountResolver();
+ await resolver.StoreAsync("ACCT1", "old-jwt");
+ await resolver.StoreAsync("ACCT1", "new-jwt");
+
+ var jwt = await resolver.FetchAsync("ACCT1");
+ jwt.ShouldBe("new-jwt");
+ }
+
+ [Fact]
+ public async Task MemAccountResolver_MultipleAccounts_IndependentStorage()
+ {
+ var resolver = new MemAccountResolver();
+ await resolver.StoreAsync("ACCT1", "jwt-for-acct1");
+ await resolver.StoreAsync("ACCT2", "jwt-for-acct2");
+
+ (await resolver.FetchAsync("ACCT1")).ShouldBe("jwt-for-acct1");
+ (await resolver.FetchAsync("ACCT2")).ShouldBe("jwt-for-acct2");
+ }
+
+ // =========================================================================
+ // Permission templates
+ // Go reference: jwt_test.go:4315 TestJWTTemplates
+ // =========================================================================
+
+ [Fact]
+ public void PermissionTemplates_NameSubjectAccountExpansion()
+ {
+ // Go: TestJWTTemplates — name/subject/account-name/account-subject expand correctly.
+ var result = PermissionTemplates.Expand(
+ "foo.{{name()}}.{{subject()}}.{{account-name()}}.{{account-subject()}}.bar",
+ name: "myname", subject: "UABC123",
+ accountName: "accname", accountSubject: "AABC456",
+ userTags: [], accountTags: []);
+
+ result.Count.ShouldBe(1);
+ result[0].ShouldBe("foo.myname.UABC123.accname.AABC456.bar");
+ }
+
+ [Fact]
+ public void PermissionTemplates_TagExpansion_SingleTag()
+ {
+ // Go: TestJWTInLineTemplates — {{tag(bucket)}} expands to tag value.
+ var result = PermissionTemplates.Expand(
+ "$JS.API.STREAM.INFO.KV_{{tag(bucket)}}",
+ name: "myname", subject: "UABC123",
+ accountName: "accname", accountSubject: "AABC456",
+ userTags: ["bucket:a"], accountTags: []);
+
+ result.Count.ShouldBe(1);
+ result[0].ShouldBe("$JS.API.STREAM.INFO.KV_a");
+ }
+
+ [Fact]
+ public void PermissionTemplates_TagExpansion_MultipleValues_CartesianProduct()
+ {
+ // Go: TestJWTTemplates — multiple tag values produce cartesian product.
+ var result = PermissionTemplates.Expand(
+ "{{tag(foo)}}.none.{{tag(bar)}}",
+ name: "myname", subject: "UABC123",
+ accountName: "accname", accountSubject: "AABC456",
+ userTags: ["foo:foo1", "foo:foo2", "bar:bar1", "bar:bar2", "bar:bar3"],
+ accountTags: []);
+
+ result.Count.ShouldBe(6);
+ result.ShouldContain("foo1.none.bar1");
+ result.ShouldContain("foo2.none.bar3");
+ }
+
+ [Fact]
+ public void PermissionTemplates_AccountTagExpansion()
+ {
+ // Go: TestJWTTemplates — {{account-tag(acc)}} uses account tags.
+ var result = PermissionTemplates.Expand(
+ "{{tag(foo)}}.{{account-tag(acc)}}",
+ name: "myname", subject: "UABC123",
+ accountName: "accname", accountSubject: "AABC456",
+ userTags: ["foo:foo1", "foo:foo2"],
+ accountTags: ["acc:acc1", "acc:acc2"]);
+
+ result.Count.ShouldBe(4);
+ result.ShouldContain("foo1.acc1");
+ result.ShouldContain("foo2.acc2");
+ }
+
+ [Fact]
+ public void PermissionTemplates_MissingTag_EmptyResult()
+ {
+ // Go: TestJWTTemplates — missing tag produces empty result (subject dropped).
+ var result = PermissionTemplates.Expand(
+ "{{tag(NOT_THERE)}}",
+ name: "myname", subject: "UABC123",
+ accountName: "accname", accountSubject: "AABC456",
+ userTags: ["foo:foo1"], accountTags: []);
+
+ result.Count.ShouldBe(0);
+ }
+
+ [Fact]
+ public void PermissionTemplates_NoTemplates_ReturnsOriginal()
+ {
+ var result = PermissionTemplates.Expand(
+ "plain.subject.>",
+ name: "myname", subject: "UABC123",
+ accountName: "accname", accountSubject: "AABC456",
+ userTags: [], accountTags: []);
+
+ result.Count.ShouldBe(1);
+ result[0].ShouldBe("plain.subject.>");
+ }
+
+ [Fact]
+ public void PermissionTemplates_ExpandAll_MultiplePatterns_Flattened()
+ {
+ // Go: TestJWTTemplates — ExpandAll processes multiple patterns, dropping empties.
+ var patterns = new[] { "{{name()}}", "plain.sub", "{{tag(NOT_THERE)}}" };
+ var result = PermissionTemplates.ExpandAll(patterns,
+ name: "myname", subject: "UABC123",
+ accountName: "accname", accountSubject: "AABC456",
+ userTags: [], accountTags: []);
+
+ result.Count.ShouldBe(2);
+ result.ShouldContain("myname");
+ result.ShouldContain("plain.sub");
+ }
+
+ // =========================================================================
+ // JwtConnectionTypes static conversion
+ // Go reference: jwt_test.go — allowed_connection_types claim
+ // =========================================================================
+
+ [Fact]
+ public void JwtConnectionTypesConvert_KnownTypes_AllRecognized()
+ {
+ var knownTypes = new[]
+ {
+ "STANDARD", "WEBSOCKET", "LEAFNODE", "LEAFNODE_WS", "MQTT", "MQTT_WS", "INPROCESS"
+ };
+
+ foreach (var type in knownTypes)
+ {
+ var (valid, hasUnknown) = JwtConnectionTypesTestHelper.Convert([type]);
+ valid.ShouldContain(type);
+ hasUnknown.ShouldBeFalse();
+ }
+ }
+
+ [Fact]
+ public void JwtConnectionTypesConvert_CaseInsensitive_Normalized()
+ {
+ var (valid, _) = JwtConnectionTypesTestHelper.Convert(["standard", "WebSocket"]);
+ valid.ShouldContain("STANDARD");
+ valid.ShouldContain("WEBSOCKET");
+ }
+
+ [Fact]
+ public void JwtConnectionTypesConvert_UnknownType_HasUnknownFlag()
+ {
+ var (valid, hasUnknown) = JwtConnectionTypesTestHelper.Convert(["UNKNOWN_XYZ"]);
+ valid.Count.ShouldBe(0);
+ hasUnknown.ShouldBeTrue();
+ }
+
+ [Fact]
+ public void JwtConnectionTypesConvert_MixedKnownUnknown_PartialValid()
+ {
+ var (valid, hasUnknown) = JwtConnectionTypesTestHelper.Convert(["STANDARD", "UNKNOWN_TYPE"]);
+ valid.ShouldContain("STANDARD");
+ hasUnknown.ShouldBeTrue();
+ }
+
+ [Fact]
+ public void JwtConnectionTypesConvert_EmptyList_BothEmpty()
+ {
+ var (valid, hasUnknown) = JwtConnectionTypesTestHelper.Convert([]);
+ valid.Count.ShouldBe(0);
+ hasUnknown.ShouldBeFalse();
+ }
+
+ // =========================================================================
+ // UserClaims helpers
+ // Go reference: jwt_test.go:292 TestJWTUserExpired — IsExpired()
+ // =========================================================================
+
+ [Fact]
+ public void UserClaims_IsExpired_ZeroExpiry_ReturnsFalse()
+ {
+ new UserClaims { Expires = 0 }.IsExpired().ShouldBeFalse();
+ }
+
+ [Fact]
+ public void UserClaims_IsExpired_FutureExpiry_ReturnsFalse()
+ {
+ new UserClaims { Expires = InFuture(3600) }.IsExpired().ShouldBeFalse();
+ }
+
+ [Fact]
+ public void UserClaims_IsExpired_PastExpiry_ReturnsTrue()
+ {
+ new UserClaims { Expires = Ago(10) }.IsExpired().ShouldBeTrue();
+ }
+
+ [Fact]
+ public void UserClaims_GetExpiry_ZeroExpires_ReturnsNull()
+ {
+ new UserClaims { Expires = 0 }.GetExpiry().ShouldBeNull();
+ }
+
+ [Fact]
+ public void UserClaims_GetExpiry_NonZeroExpires_ReturnsDateTimeOffset()
+ {
+ var exp = InFuture(3600);
+ var expiry = new UserClaims { Expires = exp }.GetExpiry();
+ expiry.ShouldNotBeNull();
+ expiry!.Value.ToUnixTimeSeconds().ShouldBe(exp);
+ }
+
+ // =========================================================================
+ // Trust chain: user issuer validation
+ // Go reference: jwt_test.go:2241 TestJWTUserSigningKey
+ // =========================================================================
+
+ [Fact]
+ public void JwtAuth_UserIssuedByWrongAccount_ReturnsNull()
+ {
+ using var operatorKp = CreateOperatorKp();
+ using var accountKp = CreateAccountKp();
+ using var wrongAccountKp = CreateAccountKp();
+ using var userKp = CreateUserKp();
+ var accountPub = accountKp.GetPublicKey();
+ var userPub = userKp.GetPublicKey();
+
+ var accountJwt = BuildAccountJwt(operatorKp, accountPub);
+ var userJwt = BuildUserJwt(wrongAccountKp, userPub);
+ var (auth, _) = BuildAuthenticator(operatorKp, accountPub, accountJwt);
+
+ Authenticate(auth, userJwt, userKp).ShouldBeNull();
+ }
+
+ [Fact]
+ public async Task JwtAuth_AccountIssuedByWrongOperator_ReturnsNull()
+ {
+ using var trustedOpKp = CreateOperatorKp();
+ using var untrustedOpKp = CreateOperatorKp();
+ using var accountKp = CreateAccountKp();
+ using var userKp = CreateUserKp();
+ var accountPub = accountKp.GetPublicKey();
+ var userPub = userKp.GetPublicKey();
+
+ var accountJwt = BuildAccountJwt(untrustedOpKp, accountPub);
+ var userJwt = BuildUserJwt(accountKp, userPub);
+
+ var resolver = new MemAccountResolver();
+ await resolver.StoreAsync(accountPub, accountJwt);
+ var auth = new JwtAuthenticator([trustedOpKp.GetPublicKey()], resolver);
+
+ Authenticate(auth, userJwt, userKp).ShouldBeNull();
+ }
+
+ // =========================================================================
+ // Account limits from JWT claims
+ // Go reference: jwt_test.go:1088 TestJWTAccountLimitsSubs
+ // =========================================================================
+
+ [Fact]
+ public void JwtAuth_AccountLimits_Parsed_FromClaims()
+ {
+ // Go: TestJWTAccountLimitsSubs — account limits decoded correctly from JWT.
+ using var operatorKp = CreateOperatorKp();
+ using var accountKp = CreateAccountKp();
+ var accountPub = accountKp.GetPublicKey();
+
+ var accountJwt = BuildAccountJwt(operatorKp, accountPub,
+ limits: new JwtTestAccountLimits
+ {
+ MaxSubscriptions = 10,
+ MaxPayload = 8,
+ MaxConnections = 5,
+ MaxData = 1024 * 1024,
+ });
+
+ var claims = NatsJwt.DecodeAccountClaims(accountJwt);
+ claims.ShouldNotBeNull();
+ claims.Nats!.Limits.ShouldNotBeNull();
+ claims.Nats.Limits!.MaxSubscriptions.ShouldBe(10);
+ claims.Nats.Limits.MaxPayload.ShouldBe(8);
+ claims.Nats.Limits.MaxConnections.ShouldBe(5);
+ claims.Nats.Limits.MaxData.ShouldBe(1024 * 1024);
+ }
+
+ // =========================================================================
+ // JetStream limits from JWT
+ // Go reference: jwt_test.go:5400 TestJWTJetStreamTiers
+ // =========================================================================
+
+ [Fact]
+ public void JwtAuth_JetStreamLimits_InAuthResult()
+ {
+ // Go: TestJWTJetStreamTiers — JetStream max_streams from account JWT in AuthResult.
+ using var operatorKp = CreateOperatorKp();
+ using var accountKp = CreateAccountKp();
+ using var userKp = CreateUserKp();
+ var accountPub = accountKp.GetPublicKey();
+ var userPub = userKp.GetPublicKey();
+
+ var accountJwt = BuildAccountJwtWithJetStream(operatorKp, accountPub, maxStreams: 10);
+ var userJwt = BuildUserJwt(accountKp, userPub);
+ var (auth, _) = BuildAuthenticator(operatorKp, accountPub, accountJwt);
+
+ var result = Authenticate(auth, userJwt, userKp);
+
+ result.ShouldNotBeNull();
+ result.MaxJetStreamStreams.ShouldBe(10);
+ }
+
+ // =========================================================================
+ // Invalid JWT token edge cases
+ // =========================================================================
+
+ [Fact]
+ public void JwtAuth_MalformedToken_ReturnsNull()
+ {
+ using var operatorKp = CreateOperatorKp();
+ var auth = new JwtAuthenticator([operatorKp.GetPublicKey()], new MemAccountResolver());
+
+ auth.Authenticate(new ClientAuthContext
+ {
+ Opts = new ClientOptions { JWT = "not-a-jwt-at-all" },
+ Nonce = [],
+ }).ShouldBeNull();
+ }
+
+ [Fact]
+ public void JwtAuth_EmptyJwtField_ReturnsNull()
+ {
+ using var operatorKp = CreateOperatorKp();
+ var auth = new JwtAuthenticator([operatorKp.GetPublicKey()], new MemAccountResolver());
+
+ auth.Authenticate(new ClientAuthContext
+ {
+ Opts = new ClientOptions { JWT = "" },
+ Nonce = [],
+ }).ShouldBeNull();
+ }
+
+ // =========================================================================
+ // Signing key removed blocks new connections
+ // Go reference: jwt_test.go:2338 TestJWTAccountImportSignerRemoved
+ // =========================================================================
+
+ [Fact]
+ public async Task JwtAuth_SigningKeyRemoved_NewConnectionFails()
+ {
+ // Go: TestJWTAccountImportSignerRemoved — removing signing key blocks new connections.
+ using var operatorKp = CreateOperatorKp();
+ using var accountKp = CreateAccountKp();
+ using var signingKp = CreateAccountKp();
+ using var userKp = CreateUserKp();
+ var accountPub = accountKp.GetPublicKey();
+ var signingPub = signingKp.GetPublicKey();
+ var userPub = userKp.GetPublicKey();
+
+ var resolver = new MemAccountResolver();
+
+ // Step 1: signing key registered — succeeds
+ var accountJwt = BuildAccountJwt(operatorKp, accountPub, signingKeys: [signingPub]);
+ await resolver.StoreAsync(accountPub, accountJwt);
+ var auth = new JwtAuthenticator([operatorKp.GetPublicKey()], resolver);
+ var userJwt = BuildUserJwt(signingKp, userPub, issuerAccount: accountPub);
+
+ Authenticate(auth, userJwt, userKp).ShouldNotBeNull();
+
+ // Step 2: signing key removed — new connection fails
+ var accountJwtNoKey = BuildAccountJwt(operatorKp, accountPub, signingKeys: null);
+ await resolver.StoreAsync(accountPub, accountJwtNoKey);
+ var auth2 = new JwtAuthenticator([operatorKp.GetPublicKey()], resolver);
+
+ Authenticate(auth2, userJwt, userKp).ShouldBeNull();
+ }
+
+ // =========================================================================
+ // AuthResult identity and account name
+ // =========================================================================
+
+ [Fact]
+ public void JwtAuth_AuthResult_IdentityIsUserPublicKey()
+ {
+ using var operatorKp = CreateOperatorKp();
+ using var accountKp = CreateAccountKp();
+ using var userKp = CreateUserKp();
+ var accountPub = accountKp.GetPublicKey();
+ var userPub = userKp.GetPublicKey();
+
+ var (auth, _) = BuildAuthenticator(operatorKp, accountPub,
+ BuildAccountJwt(operatorKp, accountPub));
+ var result = Authenticate(auth, BuildUserJwt(accountKp, userPub), userKp);
+
+ result.ShouldNotBeNull();
+ result.Identity.ShouldBe(userPub);
+ result.Identity.ShouldStartWith("U");
+ }
+
+ [Fact]
+ public void JwtAuth_AuthResult_AccountNameIsAccountPublicKey()
+ {
+ using var operatorKp = CreateOperatorKp();
+ using var accountKp = CreateAccountKp();
+ using var userKp = CreateUserKp();
+ var accountPub = accountKp.GetPublicKey();
+ var userPub = userKp.GetPublicKey();
+
+ var (auth, _) = BuildAuthenticator(operatorKp, accountPub,
+ BuildAccountJwt(operatorKp, accountPub));
+ var result = Authenticate(auth, BuildUserJwt(accountKp, userPub), userKp);
+
+ result.ShouldNotBeNull();
+ result.AccountName.ShouldBe(accountPub);
+ result.AccountName!.ShouldStartWith("A");
+ }
+
+ [Fact]
+ public void JwtAuth_AuthResult_WithSigningKey_AccountNameIsAccountPub()
+ {
+ // Go: TestJWTUserSigningKey — AccountName is account pub even when signing key used.
+ using var operatorKp = CreateOperatorKp();
+ using var accountKp = CreateAccountKp();
+ using var signingKp = CreateAccountKp();
+ using var userKp = CreateUserKp();
+ var accountPub = accountKp.GetPublicKey();
+ var signingPub = signingKp.GetPublicKey();
+ var userPub = userKp.GetPublicKey();
+
+ var accountJwt = BuildAccountJwt(operatorKp, accountPub, signingKeys: [signingPub]);
+ var userJwt = BuildUserJwt(signingKp, userPub, issuerAccount: accountPub);
+ var (auth, _) = BuildAuthenticator(operatorKp, accountPub, accountJwt);
+
+ var result = Authenticate(auth, userJwt, userKp);
+
+ result.ShouldNotBeNull();
+ result.AccountName.ShouldBe(accountPub);
+ }
+
+ // =========================================================================
+ // Permission template integration in JwtAuthenticator
+ // Go reference: jwt_test.go:4315 TestJWTTemplates
+ // =========================================================================
+
+ [Fact]
+ public void JwtAuth_PermissionTemplates_Expanded_InAuthResult()
+ {
+ // Go: TestJWTTemplates — permission templates in JWT are expanded during authentication.
+ using var operatorKp = CreateOperatorKp();
+ using var accountKp = CreateAccountKp();
+ using var userKp = CreateUserKp();
+ var accountPub = accountKp.GetPublicKey();
+ var userPub = userKp.GetPublicKey();
+
+ var accountJwt = BuildAccountJwt(operatorKp, accountPub, name: "accname");
+ var userJwt = BuildUserJwt(accountKp, userPub,
+ name: "myname",
+ pubAllow: ["foo.{{name()}}"],
+ subAllow: ["bar.{{subject()}}"]);
+ var (auth, _) = BuildAuthenticator(operatorKp, accountPub, accountJwt);
+
+ var result = Authenticate(auth, userJwt, userKp);
+
+ result.ShouldNotBeNull();
+ result.Permissions.ShouldNotBeNull();
+ result.Permissions!.Publish!.Allow.ShouldNotBeNull();
+ result.Permissions.Publish.Allow!.ShouldContain("foo.myname");
+ result.Permissions.Subscribe!.Allow.ShouldNotBeNull();
+ result.Permissions.Subscribe.Allow!.ShouldContain($"bar.{userPub}");
+ }
+
+ // =========================================================================
+ // NatsJwt.VerifyNonce — nonce signature verification
+ // Go reference: jwt_test.go — nonce signing in createClientWithIssuer
+ // =========================================================================
+
+ [Fact]
+ public void NatsJwt_VerifyNonce_ValidSignature_ReturnsTrue()
+ {
+ using var userKp = CreateUserKp();
+ var userPub = userKp.GetPublicKey();
+
+ var nonce = new byte[16];
+ RandomNumberGenerator.Fill(nonce);
+ var sig = new byte[64];
+ userKp.Sign(nonce, sig);
+
+ NatsJwt.VerifyNonce(nonce, Base64UrlEncode(sig), userPub).ShouldBeTrue();
+ }
+
+ [Fact]
+ public void NatsJwt_VerifyNonce_WrongKey_ReturnsFalse()
+ {
+ using var userKp = CreateUserKp();
+ using var wrongKp = CreateUserKp();
+
+ var nonce = new byte[16];
+ RandomNumberGenerator.Fill(nonce);
+ var sig = new byte[64];
+ userKp.Sign(nonce, sig);
+
+ NatsJwt.VerifyNonce(nonce, Base64UrlEncode(sig), wrongKp.GetPublicKey()).ShouldBeFalse();
+ }
+
+ [Fact]
+ public void NatsJwt_VerifyNonce_ModifiedNonce_ReturnsFalse()
+ {
+ using var userKp = CreateUserKp();
+ var userPub = userKp.GetPublicKey();
+
+ var nonce = new byte[16];
+ RandomNumberGenerator.Fill(nonce);
+ var sig = new byte[64];
+ userKp.Sign(nonce, sig);
+ var sigStr = Base64UrlEncode(sig);
+
+ nonce[0] ^= 0xFF; // tamper after signing
+
+ NatsJwt.VerifyNonce(nonce, sigStr, userPub).ShouldBeFalse();
+ }
+
+ // =========================================================================
+ // Helper: build account JWT with JetStream limits
+ // =========================================================================
+
+ private static string BuildAccountJwtWithJetStream(
+ KeyPair operatorKp,
+ string accountPub,
+ int maxStreams)
+ {
+ var payload = new JwtTestAccountClaimsJs
+ {
+ Subject = accountPub,
+ Issuer = operatorKp.GetPublicKey(),
+ IssuedAt = Now(),
+ Expires = 0,
+ Nats = new JwtTestAccountNatsJs
+ {
+ Type = "account",
+ Version = 2,
+ JetStream = new JwtTestJetStreamLimits { MaxStreams = maxStreams },
+ },
+ };
+ return EncodeJwt(payload, operatorKp);
+ }
+}
+
+// =========================================================================
+// Internal helper: expose JwtConnectionTypes.Convert (internal) for testing
+// =========================================================================
+
+internal static class JwtConnectionTypesTestHelper
+{
+ public static (HashSet Valid, bool HasUnknown) Convert(IEnumerable values)
+ => JwtConnectionTypes.Convert(values);
+}
+
+// =========================================================================
+// Internal payload types for JWT encoding in tests.
+// Prefixed "JwtTest" to avoid conflicts with production types.
+// =========================================================================
+
+internal sealed class JwtEncodingHeader
+{
+ [JsonPropertyName("alg")] public string? Algorithm { get; set; }
+ [JsonPropertyName("typ")] public string? Type { get; set; }
+}
+
+internal sealed class JwtTestAccountClaims
+{
+ [JsonPropertyName("sub")] public string? Subject { get; set; }
+ [JsonPropertyName("iss")] public string? Issuer { get; set; }
+ [JsonPropertyName("iat")] public long IssuedAt { get; set; }
+ [JsonPropertyName("exp")] public long Expires { get; set; }
+ [JsonPropertyName("name")] public string? Name { get; set; }
+ [JsonPropertyName("nats")] public JwtTestAccountNats? Nats { get; set; }
+}
+
+internal sealed class JwtTestAccountNats
+{
+ [JsonPropertyName("type")] public string? Type { get; set; }
+ [JsonPropertyName("version")] public int Version { get; set; }
+ [JsonPropertyName("signing_keys")] public string[]? SigningKeys { get; set; }
+ [JsonPropertyName("revocations")] public Dictionary? Revocations { get; set; }
+ [JsonPropertyName("limits")] public JwtTestAccountLimits? Limits { get; set; }
+ [JsonPropertyName("tags")] public string[]? Tags { get; set; }
+}
+
+internal sealed class JwtTestAccountLimits
+{
+ [JsonPropertyName("conn")] public long MaxConnections { get; set; }
+ [JsonPropertyName("subs")] public long MaxSubscriptions { get; set; }
+ [JsonPropertyName("payload")] public long MaxPayload { get; set; }
+ [JsonPropertyName("data")] public long MaxData { get; set; }
+}
+
+internal sealed class JwtTestAccountClaimsJs
+{
+ [JsonPropertyName("sub")] public string? Subject { get; set; }
+ [JsonPropertyName("iss")] public string? Issuer { get; set; }
+ [JsonPropertyName("iat")] public long IssuedAt { get; set; }
+ [JsonPropertyName("exp")] public long Expires { get; set; }
+ [JsonPropertyName("nats")] public JwtTestAccountNatsJs? Nats { get; set; }
+}
+
+internal sealed class JwtTestAccountNatsJs
+{
+ [JsonPropertyName("type")] public string? Type { get; set; }
+ [JsonPropertyName("version")] public int Version { get; set; }
+ [JsonPropertyName("jetstream")] public JwtTestJetStreamLimits? JetStream { get; set; }
+}
+
+internal sealed class JwtTestJetStreamLimits
+{
+ [JsonPropertyName("max_streams")] public int MaxStreams { get; set; }
+}
+
+internal sealed class JwtTestUserClaims
+{
+ [JsonPropertyName("sub")] public string? Subject { get; set; }
+ [JsonPropertyName("iss")] public string? Issuer { get; set; }
+ [JsonPropertyName("iat")] public long IssuedAt { get; set; }
+ [JsonPropertyName("exp")] public long Expires { get; set; }
+ [JsonPropertyName("name")] public string? Name { get; set; }
+ [JsonPropertyName("nats")] public JwtTestUserNats? Nats { get; set; }
+}
+
+internal sealed class JwtTestUserNats
+{
+ [JsonPropertyName("type")] public string? Type { get; set; }
+ [JsonPropertyName("version")] public int Version { get; set; }
+ [JsonPropertyName("issuer_account")] public string? IssuerAccount { get; set; }
+ [JsonPropertyName("bearer_token")]
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public bool? BearerToken { get; set; }
+ [JsonPropertyName("allowed_connection_types")] public string[]? AllowedConnectionTypes { get; set; }
+ [JsonPropertyName("tags")] public string[]? Tags { get; set; }
+ [JsonPropertyName("pub")] public JwtTestSubjectPerm? Pub { get; set; }
+ [JsonPropertyName("sub")] public JwtTestSubjectPerm? Sub { get; set; }
+}
+
+internal sealed class JwtTestSubjectPerm
+{
+ [JsonPropertyName("allow")] public string[]? Allow { get; set; }
+ [JsonPropertyName("deny")] public string[]? Deny { get; set; }
+}
+
+} // namespace NATS.Server.Tests.Auth