Files
natsdotnet/tests/NATS.Server.Tests/Auth/JwtGoParityTests.cs
Joseph Doherty cd009b9342 feat: add JWT claims & account resolver Go-parity tests (Task 16)
75 tests covering JWT token parsing, account claims, NKey authentication,
authorization callout, user JWT validation, and account resolver patterns.
Go refs: jwt_test.go, nkey_test.go, accounts_test.go
2026-02-24 21:10:59 -05:00

1771 lines
67 KiB
C#

// 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
{
/// <summary>
/// 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.
/// </summary>
public class JwtGoParityTests
{
// =========================================================================
// Test helpers — mirror Go's opTrustBasicSetup, buildMemAccResolver, etc.
// Reference: jwt_test.go:65-170
// =========================================================================
/// <summary>Creates an operator key pair (mirrors Go oKp / nkeys.CreateOperator).</summary>
private static KeyPair CreateOperatorKp()
=> KeyPair.CreatePair(PrefixByte.Operator);
/// <summary>Creates a fresh account key pair (mirrors nkeys.CreateAccount).</summary>
private static KeyPair CreateAccountKp()
=> KeyPair.CreatePair(PrefixByte.Account);
/// <summary>Creates a fresh user key pair (mirrors nkeys.CreateUser).</summary>
private static KeyPair CreateUserKp()
=> KeyPair.CreatePair(PrefixByte.User);
/// <summary>
/// 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
/// </summary>
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;
/// <summary>
/// Builds an account JWT signed by the operator key pair.
/// Reference: jwt_test.go:111-140 setupJWTTestWithClaims
/// </summary>
private static string BuildAccountJwt(
KeyPair operatorKp,
string accountPub,
string[]? signingKeys = null,
long? expiresAt = null,
long? issuedAt = null,
Dictionary<string, long>? 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);
}
/// <summary>
/// Builds a user JWT signed by the account key pair (or a signing key).
/// Reference: jwt_test.go:62-109 createClientWithIssuer / createClient
/// </summary>
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);
}
/// <summary>
/// Builds a JwtAuthenticator with the given operator key pair and account JWT pre-loaded.
/// Reference: jwt_test.go:137-141 opTrustBasicSetup / buildMemAccResolver / addAccountToMemResolver
/// </summary>
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);
}
/// <summary>Creates a valid nonce signature for a user key pair.</summary>
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);
}
/// <summary>Calls Authenticate with a freshly-signed nonce.</summary>
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<string, long> { [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<string, long> { [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<string, long> { ["*"] = 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<string, long> { ["*"] = 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<string, long> { [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<string> Valid, bool HasUnknown) Convert(IEnumerable<string> 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<string, long>? 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