From e562077e4ced074eb6ef7e13164488890d3006f8 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 23 Feb 2026 05:37:04 -0500 Subject: [PATCH] test: add failing jwt allowed connection type coverage Add 5 tests for JWT allowed_connection_types enforcement which the authenticator does not yet implement. Two tests (reject MQTT-only for STANDARD context, reject unknown-only types) fail on assertions because JwtAuthenticator currently ignores the claim. Three tests (allow STANDARD, allow with unknown mixed in, case-insensitive match) pass trivially since the field is not checked. Also adds ConnectionType property to ClientAuthContext (defaults to "STANDARD") so the tests compile. --- src/NATS.Server/Auth/IAuthenticator.cs | 7 + .../JwtAuthenticatorTests.cs | 275 ++++++++++++++++++ 2 files changed, 282 insertions(+) diff --git a/src/NATS.Server/Auth/IAuthenticator.cs b/src/NATS.Server/Auth/IAuthenticator.cs index 3783c88..fb28f0c 100644 --- a/src/NATS.Server/Auth/IAuthenticator.cs +++ b/src/NATS.Server/Auth/IAuthenticator.cs @@ -13,4 +13,11 @@ public sealed class ClientAuthContext public required ClientOptions Opts { get; init; } public required byte[] Nonce { get; init; } public X509Certificate2? ClientCertificate { get; init; } + + /// + /// The type of connection (e.g., "STANDARD", "WEBSOCKET", "MQTT", "LEAFNODE"). + /// Used by JWT authenticator to enforce allowed_connection_types claims. + /// Defaults to "STANDARD" for regular NATS client connections. + /// + public string ConnectionType { get; init; } = "STANDARD"; } diff --git a/tests/NATS.Server.Tests/JwtAuthenticatorTests.cs b/tests/NATS.Server.Tests/JwtAuthenticatorTests.cs index 7cb0eaf..7e60f76 100644 --- a/tests/NATS.Server.Tests/JwtAuthenticatorTests.cs +++ b/tests/NATS.Server.Tests/JwtAuthenticatorTests.cs @@ -588,4 +588,279 @@ public class JwtAuthenticatorTests auth.Authenticate(ctx).ShouldBeNull(); } + + // ========================================================================= + // allowed_connection_types tests + // ========================================================================= + + [Fact] + public async Task Allowed_connection_types_allows_standard_context() + { + var operatorKp = KeyPair.CreatePair(PrefixByte.Operator); + var accountKp = KeyPair.CreatePair(PrefixByte.Account); + var userKp = KeyPair.CreatePair(PrefixByte.User); + + var operatorPub = operatorKp.GetPublicKey(); + var accountPub = accountKp.GetPublicKey(); + var userPub = userKp.GetPublicKey(); + + var accountPayload = $$""" + { + "sub":"{{accountPub}}", + "iss":"{{operatorPub}}", + "iat":1700000000, + "nats":{"type":"account","version":2} + } + """; + var accountJwt = BuildSignedToken(accountPayload, operatorKp); + + var userPayload = $$""" + { + "sub":"{{userPub}}", + "iss":"{{accountPub}}", + "iat":1700000000, + "nats":{ + "type":"user","version":2, + "bearer_token":true, + "issuer_account":"{{accountPub}}", + "allowed_connection_types":["STANDARD"] + } + } + """; + var userJwt = BuildSignedToken(userPayload, accountKp); + + var resolver = new MemAccountResolver(); + await resolver.StoreAsync(accountPub, accountJwt); + + var auth = new JwtAuthenticator([operatorPub], resolver); + + var ctx = new ClientAuthContext + { + Opts = new ClientOptions { JWT = userJwt }, + Nonce = "nonce"u8.ToArray(), + ConnectionType = "STANDARD", + }; + + var result = auth.Authenticate(ctx); + + result.ShouldNotBeNull(); + result.Identity.ShouldBe(userPub); + } + + [Fact] + public async Task Allowed_connection_types_rejects_mqtt_only_for_standard_context() + { + var operatorKp = KeyPair.CreatePair(PrefixByte.Operator); + var accountKp = KeyPair.CreatePair(PrefixByte.Account); + var userKp = KeyPair.CreatePair(PrefixByte.User); + + var operatorPub = operatorKp.GetPublicKey(); + var accountPub = accountKp.GetPublicKey(); + var userPub = userKp.GetPublicKey(); + + var accountPayload = $$""" + { + "sub":"{{accountPub}}", + "iss":"{{operatorPub}}", + "iat":1700000000, + "nats":{"type":"account","version":2} + } + """; + var accountJwt = BuildSignedToken(accountPayload, operatorKp); + + // User JWT only allows MQTT connections + var userPayload = $$""" + { + "sub":"{{userPub}}", + "iss":"{{accountPub}}", + "iat":1700000000, + "nats":{ + "type":"user","version":2, + "bearer_token":true, + "issuer_account":"{{accountPub}}", + "allowed_connection_types":["MQTT"] + } + } + """; + var userJwt = BuildSignedToken(userPayload, accountKp); + + var resolver = new MemAccountResolver(); + await resolver.StoreAsync(accountPub, accountJwt); + + var auth = new JwtAuthenticator([operatorPub], resolver); + + var ctx = new ClientAuthContext + { + Opts = new ClientOptions { JWT = userJwt }, + Nonce = "nonce"u8.ToArray(), + ConnectionType = "STANDARD", + }; + + // Should reject: STANDARD is not in allowed_connection_types + auth.Authenticate(ctx).ShouldBeNull(); + } + + [Fact] + public async Task Allowed_connection_types_allows_known_even_with_unknown_values() + { + var operatorKp = KeyPair.CreatePair(PrefixByte.Operator); + var accountKp = KeyPair.CreatePair(PrefixByte.Account); + var userKp = KeyPair.CreatePair(PrefixByte.User); + + var operatorPub = operatorKp.GetPublicKey(); + var accountPub = accountKp.GetPublicKey(); + var userPub = userKp.GetPublicKey(); + + var accountPayload = $$""" + { + "sub":"{{accountPub}}", + "iss":"{{operatorPub}}", + "iat":1700000000, + "nats":{"type":"account","version":2} + } + """; + var accountJwt = BuildSignedToken(accountPayload, operatorKp); + + // User JWT allows STANDARD and an unknown type + var userPayload = $$""" + { + "sub":"{{userPub}}", + "iss":"{{accountPub}}", + "iat":1700000000, + "nats":{ + "type":"user","version":2, + "bearer_token":true, + "issuer_account":"{{accountPub}}", + "allowed_connection_types":["STANDARD","SOME_NEW_TYPE"] + } + } + """; + var userJwt = BuildSignedToken(userPayload, accountKp); + + var resolver = new MemAccountResolver(); + await resolver.StoreAsync(accountPub, accountJwt); + + var auth = new JwtAuthenticator([operatorPub], resolver); + + var ctx = new ClientAuthContext + { + Opts = new ClientOptions { JWT = userJwt }, + Nonce = "nonce"u8.ToArray(), + ConnectionType = "STANDARD", + }; + + var result = auth.Authenticate(ctx); + + result.ShouldNotBeNull(); + result.Identity.ShouldBe(userPub); + } + + [Fact] + public async Task Allowed_connection_types_rejects_when_only_unknown_values_present() + { + var operatorKp = KeyPair.CreatePair(PrefixByte.Operator); + var accountKp = KeyPair.CreatePair(PrefixByte.Account); + var userKp = KeyPair.CreatePair(PrefixByte.User); + + var operatorPub = operatorKp.GetPublicKey(); + var accountPub = accountKp.GetPublicKey(); + var userPub = userKp.GetPublicKey(); + + var accountPayload = $$""" + { + "sub":"{{accountPub}}", + "iss":"{{operatorPub}}", + "iat":1700000000, + "nats":{"type":"account","version":2} + } + """; + var accountJwt = BuildSignedToken(accountPayload, operatorKp); + + // User JWT only allows an unknown connection type + var userPayload = $$""" + { + "sub":"{{userPub}}", + "iss":"{{accountPub}}", + "iat":1700000000, + "nats":{ + "type":"user","version":2, + "bearer_token":true, + "issuer_account":"{{accountPub}}", + "allowed_connection_types":["SOME_NEW_TYPE"] + } + } + """; + var userJwt = BuildSignedToken(userPayload, accountKp); + + var resolver = new MemAccountResolver(); + await resolver.StoreAsync(accountPub, accountJwt); + + var auth = new JwtAuthenticator([operatorPub], resolver); + + var ctx = new ClientAuthContext + { + Opts = new ClientOptions { JWT = userJwt }, + Nonce = "nonce"u8.ToArray(), + ConnectionType = "STANDARD", + }; + + // Should reject: STANDARD is not in allowed_connection_types + auth.Authenticate(ctx).ShouldBeNull(); + } + + [Fact] + public async Task Allowed_connection_types_is_case_insensitive_for_input_values() + { + var operatorKp = KeyPair.CreatePair(PrefixByte.Operator); + var accountKp = KeyPair.CreatePair(PrefixByte.Account); + var userKp = KeyPair.CreatePair(PrefixByte.User); + + var operatorPub = operatorKp.GetPublicKey(); + var accountPub = accountKp.GetPublicKey(); + var userPub = userKp.GetPublicKey(); + + var accountPayload = $$""" + { + "sub":"{{accountPub}}", + "iss":"{{operatorPub}}", + "iat":1700000000, + "nats":{"type":"account","version":2} + } + """; + var accountJwt = BuildSignedToken(accountPayload, operatorKp); + + // User JWT allows "standard" (lowercase) + var userPayload = $$""" + { + "sub":"{{userPub}}", + "iss":"{{accountPub}}", + "iat":1700000000, + "nats":{ + "type":"user","version":2, + "bearer_token":true, + "issuer_account":"{{accountPub}}", + "allowed_connection_types":["standard"] + } + } + """; + var userJwt = BuildSignedToken(userPayload, accountKp); + + var resolver = new MemAccountResolver(); + await resolver.StoreAsync(accountPub, accountJwt); + + var auth = new JwtAuthenticator([operatorPub], resolver); + + var ctx = new ClientAuthContext + { + Opts = new ClientOptions { JWT = userJwt }, + Nonce = "nonce"u8.ToArray(), + ConnectionType = "STANDARD", + }; + + // Should allow: case-insensitive match of "standard" == "STANDARD" + var result = auth.Authenticate(ctx); + + result.ShouldNotBeNull(); + result.Identity.ShouldBe(userPub); + } }