using System.Text; using NATS.NKeys; using NATS.Server.Auth; using NATS.Server.Auth.Jwt; using NATS.Server.Protocol; namespace NATS.Server.Auth.Tests; public class JwtAuthenticatorTests { private static string Base64UrlEncode(string input) => Base64UrlEncode(Encoding.UTF8.GetBytes(input)); private static string Base64UrlEncode(byte[] input) => Convert.ToBase64String(input).TrimEnd('=').Replace('+', '-').Replace('/', '_'); private static string BuildSignedToken(string payloadJson, KeyPair signingKey) { var header = Base64UrlEncode("""{"typ":"JWT","alg":"ed25519-nkey"}"""); var payload = Base64UrlEncode(payloadJson); var signingInput = Encoding.UTF8.GetBytes($"{header}.{payload}"); var sig = new byte[64]; signingKey.Sign(signingInput, sig); return $"{header}.{payload}.{Base64UrlEncode(sig)}"; } private static string SignNonce(KeyPair kp, byte[] nonce) { var sig = new byte[64]; kp.Sign(nonce, sig); return Convert.ToBase64String(sig).TrimEnd('=').Replace('+', '-').Replace('/', '_'); } [Fact] public async Task Valid_bearer_jwt_returns_auth_result() { 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}}" } } """; 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 = "test-nonce"u8.ToArray(), }; var result = auth.Authenticate(ctx); result.ShouldNotBeNull(); result.Identity.ShouldBe(userPub); result.AccountName.ShouldBe(accountPub); } [Fact] public async Task Valid_jwt_with_nonce_signature_returns_auth_result() { 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, "issuer_account":"{{accountPub}}" } } """; var userJwt = BuildSignedToken(userPayload, accountKp); var resolver = new MemAccountResolver(); await resolver.StoreAsync(accountPub, accountJwt); var auth = new JwtAuthenticator([operatorPub], resolver); var nonce = "test-nonce-data"u8.ToArray(); var sig = SignNonce(userKp, nonce); var ctx = new ClientAuthContext { Opts = new ClientOptions { JWT = userJwt, Nkey = userPub, Sig = sig }, Nonce = nonce, }; var result = auth.Authenticate(ctx); result.ShouldNotBeNull(); result.Identity.ShouldBe(userPub); result.AccountName.ShouldBe(accountPub); } [Fact] public void No_jwt_returns_null() { var operatorKp = KeyPair.CreatePair(PrefixByte.Operator); var resolver = new MemAccountResolver(); var auth = new JwtAuthenticator([operatorKp.GetPublicKey()], resolver); var ctx = new ClientAuthContext { Opts = new ClientOptions(), Nonce = "nonce"u8.ToArray(), }; auth.Authenticate(ctx).ShouldBeNull(); } [Fact] public void Non_jwt_string_returns_null() { var operatorKp = KeyPair.CreatePair(PrefixByte.Operator); var resolver = new MemAccountResolver(); var auth = new JwtAuthenticator([operatorKp.GetPublicKey()], resolver); var ctx = new ClientAuthContext { Opts = new ClientOptions { JWT = "not-a-jwt" }, Nonce = "nonce"u8.ToArray(), }; auth.Authenticate(ctx).ShouldBeNull(); } [Fact] public async Task Expired_jwt_returns_null() { 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); // Expired in 2020 var userPayload = $$""" { "sub":"{{userPub}}", "iss":"{{accountPub}}", "iat":1500000000, "exp":1600000000, "nats":{ "type":"user","version":2, "bearer_token":true, "issuer_account":"{{accountPub}}" } } """; 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(), }; auth.Authenticate(ctx).ShouldBeNull(); } [Fact] public async Task Revoked_user_returns_null() { 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(); // Account JWT with revocation for user var accountPayload = $$""" { "sub":"{{accountPub}}", "iss":"{{operatorPub}}", "iat":1700000000, "nats":{ "type":"account","version":2, "revocations":{ "{{userPub}}":1700000001 } } } """; var accountJwt = BuildSignedToken(accountPayload, operatorKp); // User JWT issued at 1700000000 (before revocation time 1700000001) var userPayload = $$""" { "sub":"{{userPub}}", "iss":"{{accountPub}}", "iat":1700000000, "nats":{ "type":"user","version":2, "bearer_token":true, "issuer_account":"{{accountPub}}" } } """; 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(), }; auth.Authenticate(ctx).ShouldBeNull(); } [Fact] public async Task Untrusted_operator_returns_null() { 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}}" } } """; var userJwt = BuildSignedToken(userPayload, accountKp); var resolver = new MemAccountResolver(); await resolver.StoreAsync(accountPub, accountJwt); // Use a different trusted key that doesn't match the operator var otherOperator = KeyPair.CreatePair(PrefixByte.Operator).GetPublicKey(); var auth = new JwtAuthenticator([otherOperator], resolver); var ctx = new ClientAuthContext { Opts = new ClientOptions { JWT = userJwt }, Nonce = "nonce"u8.ToArray(), }; auth.Authenticate(ctx).ShouldBeNull(); } [Fact] public void Unknown_account_returns_null() { 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 userPayload = $$""" { "sub":"{{userPub}}", "iss":"{{accountPub}}", "iat":1700000000, "nats":{ "type":"user","version":2, "bearer_token":true, "issuer_account":"{{accountPub}}" } } """; var userJwt = BuildSignedToken(userPayload, accountKp); // Don't store the account JWT in the resolver var resolver = new MemAccountResolver(); var auth = new JwtAuthenticator([operatorPub], resolver); var ctx = new ClientAuthContext { Opts = new ClientOptions { JWT = userJwt }, Nonce = "nonce"u8.ToArray(), }; auth.Authenticate(ctx).ShouldBeNull(); } [Fact] public async Task Non_bearer_without_sig_returns_null() { 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); // Non-bearer user JWT var userPayload = $$""" { "sub":"{{userPub}}", "iss":"{{accountPub}}", "iat":1700000000, "nats":{ "type":"user","version":2, "issuer_account":"{{accountPub}}" } } """; 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 }, // No Sig provided Nonce = "nonce"u8.ToArray(), }; auth.Authenticate(ctx).ShouldBeNull(); } [Fact] public async Task Jwt_with_permissions_returns_permissions() { 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}}", "pub":{"allow":["foo.>","bar.*"]} } } """; 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(), }; var result = auth.Authenticate(ctx); result.ShouldNotBeNull(); result.Permissions.ShouldNotBeNull(); result.Permissions.Publish.ShouldNotBeNull(); result.Permissions.Publish.Allow.ShouldNotBeNull(); result.Permissions.Publish.Allow.ShouldContain("foo.>"); result.Permissions.Publish.Allow.ShouldContain("bar.*"); } [Fact] public async Task Signing_key_based_user_jwt_succeeds() { var operatorKp = KeyPair.CreatePair(PrefixByte.Operator); var accountKp = KeyPair.CreatePair(PrefixByte.Account); var signingKp = KeyPair.CreatePair(PrefixByte.Account); var userKp = KeyPair.CreatePair(PrefixByte.User); var operatorPub = operatorKp.GetPublicKey(); var accountPub = accountKp.GetPublicKey(); var signingPub = signingKp.GetPublicKey(); var userPub = userKp.GetPublicKey(); // Account JWT with signing key var accountPayload = $$""" { "sub":"{{accountPub}}", "iss":"{{operatorPub}}", "iat":1700000000, "nats":{ "type":"account","version":2, "signing_keys":["{{signingPub}}"] } } """; var accountJwt = BuildSignedToken(accountPayload, operatorKp); // User JWT issued by the signing key var userPayload = $$""" { "sub":"{{userPub}}", "iss":"{{signingPub}}", "iat":1700000000, "nats":{ "type":"user","version":2, "bearer_token":true, "issuer_account":"{{accountPub}}" } } """; var userJwt = BuildSignedToken(userPayload, signingKp); 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(), }; var result = auth.Authenticate(ctx); result.ShouldNotBeNull(); result.Identity.ShouldBe(userPub); result.AccountName.ShouldBe(accountPub); } [Fact] public async Task Wildcard_revocation_returns_null() { 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(); // Account JWT with wildcard revocation var accountPayload = $$""" { "sub":"{{accountPub}}", "iss":"{{operatorPub}}", "iat":1700000000, "nats":{ "type":"account","version":2, "revocations":{ "*":1700000001 } } } """; var accountJwt = BuildSignedToken(accountPayload, operatorKp); // User JWT issued at 1700000000 (before wildcard revocation) var userPayload = $$""" { "sub":"{{userPub}}", "iss":"{{accountPub}}", "iat":1700000000, "nats":{ "type":"user","version":2, "bearer_token":true, "issuer_account":"{{accountPub}}" } } """; 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(), }; 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); } }