diff --git a/tests/NATS.Server.Tests/Accounts/AuthCalloutTests.cs b/tests/NATS.Server.Tests/Accounts/AuthCalloutTests.cs
new file mode 100644
index 0000000..8cbc4aa
--- /dev/null
+++ b/tests/NATS.Server.Tests/Accounts/AuthCalloutTests.cs
@@ -0,0 +1,822 @@
+using System.Net;
+using System.Net.Sockets;
+using Microsoft.Extensions.Logging.Abstractions;
+using NATS.Client.Core;
+using NATS.Server;
+using NATS.Server.Auth;
+using NATS.Server.Imports;
+using NATS.Server.Protocol;
+using NATS.Server.Subscriptions;
+
+namespace NATS.Server.Tests.Accounts;
+
+///
+/// Tests for auth callout behavior, account limits (max connections / max subscriptions),
+/// user revocation, and cross-account communication scenarios.
+/// Reference: Go auth_callout_test.go — TestAuthCallout*, TestAuthCalloutTimeout, etc.
+/// Reference: Go accounts_test.go — TestAccountMaxConns, TestAccountMaxSubs,
+/// TestUserRevoked*, TestCrossAccountRequestReply.
+///
+public class AuthCalloutTests
+{
+ private static int GetFreePort()
+ {
+ using var sock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
+ sock.Bind(new IPEndPoint(IPAddress.Loopback, 0));
+ return ((IPEndPoint)sock.LocalEndPoint!).Port;
+ }
+
+ private static NatsServer CreateTestServer(NatsOptions? options = null)
+ {
+ var port = GetFreePort();
+ options ??= new NatsOptions();
+ options.Port = port;
+ return new NatsServer(options, NullLoggerFactory.Instance);
+ }
+
+ private static async Task<(NatsServer server, int port, CancellationTokenSource cts)> StartServerAsync(NatsOptions options)
+ {
+ var port = GetFreePort();
+ options.Port = port;
+ var server = new NatsServer(options, NullLoggerFactory.Instance);
+ var cts = new CancellationTokenSource();
+ _ = server.StartAsync(cts.Token);
+ await server.WaitForReadyAsync();
+ return (server, port, cts);
+ }
+
+ private static bool ExceptionChainContains(Exception ex, string substring)
+ {
+ Exception? current = ex;
+ while (current != null)
+ {
+ if (current.Message.Contains(substring, StringComparison.OrdinalIgnoreCase))
+ return true;
+ current = current.InnerException;
+ }
+ return false;
+ }
+
+ // ── Auth callout handler registration ────────────────────────────────────
+
+ // Go: TestAuthCallout auth_callout_test.go — callout registered in options
+ [Fact]
+ public void AuthCallout_handler_registered_in_options()
+ {
+ var client = new StubExternalAuthClient(allow: true, identity: "callout-user");
+ var options = new NatsOptions
+ {
+ ExternalAuth = new ExternalAuthOptions
+ {
+ Enabled = true,
+ Client = client,
+ Timeout = TimeSpan.FromSeconds(2),
+ },
+ };
+
+ var authService = AuthService.Build(options);
+ authService.IsAuthRequired.ShouldBeTrue();
+ }
+
+ // Go: TestAuthCallout auth_callout_test.go — callout invoked with valid credentials
+ [Fact]
+ public void AuthCallout_valid_credentials_returns_auth_result()
+ {
+ var client = new StubExternalAuthClient(allow: true, identity: "callout-user", account: "acct-a");
+ var authService = AuthService.Build(new NatsOptions
+ {
+ ExternalAuth = new ExternalAuthOptions { Enabled = true, Client = client, Timeout = TimeSpan.FromSeconds(2) },
+ });
+
+ var result = authService.Authenticate(new ClientAuthContext
+ {
+ Opts = new ClientOptions { Username = "user", Password = "pass" },
+ Nonce = [],
+ });
+
+ result.ShouldNotBeNull();
+ result!.Identity.ShouldBe("callout-user");
+ result.AccountName.ShouldBe("acct-a");
+ }
+
+ // Go: TestAuthCallout auth_callout_test.go — callout with invalid credentials fails
+ [Fact]
+ public void AuthCallout_invalid_credentials_returns_null()
+ {
+ var client = new StubExternalAuthClient(allow: false);
+ var authService = AuthService.Build(new NatsOptions
+ {
+ ExternalAuth = new ExternalAuthOptions { Enabled = true, Client = client, Timeout = TimeSpan.FromSeconds(2) },
+ });
+
+ var result = authService.Authenticate(new ClientAuthContext
+ {
+ Opts = new ClientOptions { Username = "bad-user", Password = "bad-pass" },
+ Nonce = [],
+ });
+
+ result.ShouldBeNull();
+ }
+
+ // Go: TestAuthCalloutTimeout auth_callout_test.go — callout timeout returns null
+ [Fact]
+ public void AuthCallout_timeout_returns_null()
+ {
+ var client = new DelayedExternalAuthClient(delay: TimeSpan.FromSeconds(5));
+ var authService = AuthService.Build(new NatsOptions
+ {
+ ExternalAuth = new ExternalAuthOptions
+ {
+ Enabled = true,
+ Client = client,
+ Timeout = TimeSpan.FromMilliseconds(50),
+ },
+ });
+
+ var result = authService.Authenticate(new ClientAuthContext
+ {
+ Opts = new ClientOptions { Username = "user", Password = "pass" },
+ Nonce = [],
+ });
+
+ result.ShouldBeNull();
+ }
+
+ // Go: TestAuthCallout auth_callout_test.go — callout response assigns account
+ [Fact]
+ public void AuthCallout_response_assigns_account_name()
+ {
+ var client = new StubExternalAuthClient(allow: true, identity: "alice", account: "tenant-1");
+ var authService = AuthService.Build(new NatsOptions
+ {
+ ExternalAuth = new ExternalAuthOptions { Enabled = true, Client = client, Timeout = TimeSpan.FromSeconds(2) },
+ });
+
+ var result = authService.Authenticate(new ClientAuthContext
+ {
+ Opts = new ClientOptions { Username = "alice", Password = "x" },
+ Nonce = [],
+ });
+
+ result.ShouldNotBeNull();
+ result!.AccountName.ShouldBe("tenant-1");
+ }
+
+ // Go: TestAuthCallout auth_callout_test.go — callout with no account in response
+ [Fact]
+ public void AuthCallout_no_account_in_response_returns_null_account_name()
+ {
+ var client = new StubExternalAuthClient(allow: true, identity: "anonymous-user", account: null);
+ var authService = AuthService.Build(new NatsOptions
+ {
+ ExternalAuth = new ExternalAuthOptions { Enabled = true, Client = client, Timeout = TimeSpan.FromSeconds(2) },
+ });
+
+ var result = authService.Authenticate(new ClientAuthContext
+ {
+ Opts = new ClientOptions { Username = "anon", Password = "x" },
+ Nonce = [],
+ });
+
+ result.ShouldNotBeNull();
+ result!.AccountName.ShouldBeNull();
+ }
+
+ // Go: TestAuthCallout auth_callout_test.go — callout invoked (receives request data)
+ [Fact]
+ public void AuthCallout_receives_username_and_password()
+ {
+ var captureClient = new CapturingExternalAuthClient(allow: true, identity: "u");
+ var authService = AuthService.Build(new NatsOptions
+ {
+ ExternalAuth = new ExternalAuthOptions { Enabled = true, Client = captureClient, Timeout = TimeSpan.FromSeconds(2) },
+ });
+
+ authService.Authenticate(new ClientAuthContext
+ {
+ Opts = new ClientOptions { Username = "myuser", Password = "mypass" },
+ Nonce = [],
+ });
+
+ captureClient.LastRequest.ShouldNotBeNull();
+ captureClient.LastRequest!.Username.ShouldBe("myuser");
+ captureClient.LastRequest.Password.ShouldBe("mypass");
+ }
+
+ // Go: TestAuthCallout auth_callout_test.go — callout invoked with token
+ [Fact]
+ public void AuthCallout_receives_token()
+ {
+ var captureClient = new CapturingExternalAuthClient(allow: true, identity: "u");
+ var authService = AuthService.Build(new NatsOptions
+ {
+ ExternalAuth = new ExternalAuthOptions { Enabled = true, Client = captureClient, Timeout = TimeSpan.FromSeconds(2) },
+ });
+
+ authService.Authenticate(new ClientAuthContext
+ {
+ Opts = new ClientOptions { Token = "my-bearer-token" },
+ Nonce = [],
+ });
+
+ captureClient.LastRequest.ShouldNotBeNull();
+ captureClient.LastRequest!.Token.ShouldBe("my-bearer-token");
+ }
+
+ // Go: TestAuthCallout auth_callout_test.go — callout invoked for each connection
+ [Fact]
+ public void AuthCallout_invoked_for_each_authentication_attempt()
+ {
+ var client = new CountingExternalAuthClient(allow: true, identity: "u");
+ var authService = AuthService.Build(new NatsOptions
+ {
+ ExternalAuth = new ExternalAuthOptions { Enabled = true, Client = client, Timeout = TimeSpan.FromSeconds(2) },
+ });
+
+ for (int i = 0; i < 5; i++)
+ {
+ authService.Authenticate(new ClientAuthContext
+ {
+ Opts = new ClientOptions { Username = $"user{i}", Password = "p" },
+ Nonce = [],
+ });
+ }
+
+ client.CallCount.ShouldBe(5);
+ }
+
+ // ── Account limits: max connections ──────────────────────────────────────
+
+ // Go: TestAccountMaxConns accounts_test.go — max connections limit enforced
+ [Fact]
+ public void Account_max_connections_enforced()
+ {
+ using var server = CreateTestServer();
+ var acc = server.GetOrCreateAccount("limited");
+ acc.MaxConnections = 2;
+
+ acc.AddClient(1).ShouldBeTrue();
+ acc.AddClient(2).ShouldBeTrue();
+ acc.AddClient(3).ShouldBeFalse(); // limit reached
+ }
+
+ // Go: TestAccountMaxConns accounts_test.go — zero max connections means unlimited
+ [Fact]
+ public void Account_zero_max_connections_means_unlimited()
+ {
+ using var server = CreateTestServer();
+ var acc = server.GetOrCreateAccount("unlimited");
+ acc.MaxConnections = 0; // unlimited
+
+ for (ulong i = 1; i <= 100; i++)
+ acc.AddClient(i).ShouldBeTrue();
+
+ acc.ClientCount.ShouldBe(100);
+ }
+
+ // Go: TestAccountMaxConns accounts_test.go — connection count tracked
+ [Fact]
+ public void Account_connection_count_tracking()
+ {
+ using var server = CreateTestServer();
+ var acc = server.GetOrCreateAccount("tracked");
+
+ acc.AddClient(1);
+ acc.AddClient(2);
+ acc.AddClient(3);
+
+ acc.ClientCount.ShouldBe(3);
+ }
+
+ // Go: TestAccountMaxConns accounts_test.go — limits reset after disconnect
+ [Fact]
+ public void Account_connection_limit_resets_after_disconnect()
+ {
+ using var server = CreateTestServer();
+ var acc = server.GetOrCreateAccount("resetable");
+ acc.MaxConnections = 2;
+
+ acc.AddClient(1).ShouldBeTrue();
+ acc.AddClient(2).ShouldBeTrue();
+ acc.AddClient(3).ShouldBeFalse(); // full
+
+ acc.RemoveClient(1); // disconnect one
+
+ acc.AddClient(3).ShouldBeTrue(); // now room for another
+ }
+
+ // Go: TestAccountMaxConns accounts_test.go — different accounts have independent limits
+ [Fact]
+ public void Account_limits_are_per_account_independent()
+ {
+ using var server = CreateTestServer();
+ var accA = server.GetOrCreateAccount("acct-a");
+ var accB = server.GetOrCreateAccount("acct-b");
+
+ accA.MaxConnections = 2;
+ accB.MaxConnections = 5;
+
+ accA.AddClient(1).ShouldBeTrue();
+ accA.AddClient(2).ShouldBeTrue();
+ accA.AddClient(3).ShouldBeFalse(); // A is full
+
+ // B is independent — should still allow
+ accB.AddClient(10).ShouldBeTrue();
+ accB.AddClient(11).ShouldBeTrue();
+ accB.AddClient(12).ShouldBeTrue();
+ }
+
+ // Go: TestAccountMaxConns accounts_test.go — config-driven max connections
+ [Fact]
+ public void Account_from_config_applies_max_connections()
+ {
+ using var server = CreateTestServer(new NatsOptions
+ {
+ Accounts = new Dictionary
+ {
+ ["limited"] = new AccountConfig { MaxConnections = 3 },
+ },
+ });
+
+ var acc = server.GetOrCreateAccount("limited");
+ acc.MaxConnections.ShouldBe(3);
+
+ acc.AddClient(1).ShouldBeTrue();
+ acc.AddClient(2).ShouldBeTrue();
+ acc.AddClient(3).ShouldBeTrue();
+ acc.AddClient(4).ShouldBeFalse();
+ }
+
+ // ── Account limits: max subscriptions ────────────────────────────────────
+
+ // Go: TestAccountMaxSubs accounts_test.go — max subscriptions enforced
+ [Fact]
+ public void Account_max_subscriptions_enforced()
+ {
+ using var server = CreateTestServer();
+ var acc = server.GetOrCreateAccount("sub-limited");
+ acc.MaxSubscriptions = 2;
+
+ acc.IncrementSubscriptions().ShouldBeTrue();
+ acc.IncrementSubscriptions().ShouldBeTrue();
+ acc.IncrementSubscriptions().ShouldBeFalse(); // limit reached
+ }
+
+ // Go: TestAccountMaxSubs accounts_test.go — zero max subscriptions means unlimited
+ [Fact]
+ public void Account_zero_max_subscriptions_means_unlimited()
+ {
+ using var server = CreateTestServer();
+ var acc = server.GetOrCreateAccount("unlimited-subs");
+ acc.MaxSubscriptions = 0;
+
+ for (int i = 0; i < 100; i++)
+ acc.IncrementSubscriptions().ShouldBeTrue();
+
+ acc.SubscriptionCount.ShouldBe(100);
+ }
+
+ // Go: TestAccountMaxSubs accounts_test.go — subscription count tracked
+ [Fact]
+ public void Account_subscription_count_tracking()
+ {
+ using var server = CreateTestServer();
+ var acc = server.GetOrCreateAccount("sub-tracked");
+
+ acc.IncrementSubscriptions();
+ acc.IncrementSubscriptions();
+ acc.IncrementSubscriptions();
+
+ acc.SubscriptionCount.ShouldBe(3);
+ }
+
+ // Go: TestAccountMaxSubs accounts_test.go — decrement frees capacity
+ [Fact]
+ public void Account_subscription_decrement_frees_capacity()
+ {
+ using var server = CreateTestServer();
+ var acc = server.GetOrCreateAccount("sub-freeable");
+ acc.MaxSubscriptions = 2;
+
+ acc.IncrementSubscriptions().ShouldBeTrue();
+ acc.IncrementSubscriptions().ShouldBeTrue();
+ acc.IncrementSubscriptions().ShouldBeFalse(); // full
+
+ acc.DecrementSubscriptions(); // free one
+
+ acc.IncrementSubscriptions().ShouldBeTrue(); // now fits
+ }
+
+ // Go: TestAccountMaxSubs accounts_test.go — config-driven max subscriptions
+ [Fact]
+ public void Account_from_config_applies_max_subscriptions()
+ {
+ using var server = CreateTestServer(new NatsOptions
+ {
+ Accounts = new Dictionary
+ {
+ ["sub-limited"] = new AccountConfig { MaxSubscriptions = 5 },
+ },
+ });
+
+ var acc = server.GetOrCreateAccount("sub-limited");
+ acc.MaxSubscriptions.ShouldBe(5);
+ }
+
+ // Go: TestAccountMaxSubs accounts_test.go — different accounts have independent subscription limits
+ [Fact]
+ public void Account_subscription_limits_are_independent()
+ {
+ using var server = CreateTestServer();
+ var accA = server.GetOrCreateAccount("sub-a");
+ var accB = server.GetOrCreateAccount("sub-b");
+
+ accA.MaxSubscriptions = 1;
+ accB.MaxSubscriptions = 3;
+
+ accA.IncrementSubscriptions().ShouldBeTrue();
+ accA.IncrementSubscriptions().ShouldBeFalse(); // A full
+
+ accB.IncrementSubscriptions().ShouldBeTrue();
+ accB.IncrementSubscriptions().ShouldBeTrue();
+ accB.IncrementSubscriptions().ShouldBeTrue(); // B has capacity
+ }
+
+ // ── User revocation ───────────────────────────────────────────────────────
+
+ // Go: TestUserRevoked accounts_test.go — revoked user rejected
+ [Fact]
+ public void Revoked_user_is_rejected()
+ {
+ using var server = CreateTestServer();
+ var acc = server.GetOrCreateAccount("revocation-test");
+
+ acc.RevokeUser("UNKEY123", issuedAt: 1000);
+
+ acc.IsUserRevoked("UNKEY123", issuedAt: 999).ShouldBeTrue();
+ acc.IsUserRevoked("UNKEY123", issuedAt: 1000).ShouldBeTrue();
+ }
+
+ // Go: TestUserRevoked accounts_test.go — not-yet-revoked user is allowed
+ [Fact]
+ public void User_issued_after_revocation_time_is_allowed()
+ {
+ using var server = CreateTestServer();
+ var acc = server.GetOrCreateAccount("revocation-test");
+
+ acc.RevokeUser("UNKEY456", issuedAt: 1000);
+
+ // Issued after the revocation timestamp — should be allowed
+ acc.IsUserRevoked("UNKEY456", issuedAt: 1001).ShouldBeFalse();
+ }
+
+ // Go: TestUserRevoked accounts_test.go — non-existent user is not revoked
+ [Fact]
+ public void Non_revoked_user_is_allowed()
+ {
+ using var server = CreateTestServer();
+ var acc = server.GetOrCreateAccount("revocation-test");
+
+ acc.IsUserRevoked("UNKEY999", issuedAt: 500).ShouldBeFalse();
+ }
+
+ // Go: TestUserRevoked accounts_test.go — wildcard revocation affects all users
+ [Fact]
+ public void Wildcard_revocation_rejects_any_user()
+ {
+ using var server = CreateTestServer();
+ var acc = server.GetOrCreateAccount("revocation-test");
+
+ // Revoke ALL users issued at or before timestamp 2000
+ acc.RevokeUser("*", issuedAt: 2000);
+
+ acc.IsUserRevoked("UNKEY_A", issuedAt: 1000).ShouldBeTrue();
+ acc.IsUserRevoked("UNKEY_B", issuedAt: 2000).ShouldBeTrue();
+ acc.IsUserRevoked("UNKEY_C", issuedAt: 2001).ShouldBeFalse();
+ }
+
+ // Go: TestUserRevoked accounts_test.go — revocation of non-existent user is no-op
+ [Fact]
+ public void Revoking_non_existent_user_is_no_op()
+ {
+ using var server = CreateTestServer();
+ var acc = server.GetOrCreateAccount("revocation-test");
+
+ // Should not throw
+ var ex = Record.Exception(() => acc.RevokeUser("NONEXISTENT_KEY", issuedAt: 500));
+ ex.ShouldBeNull();
+ }
+
+ // Go: TestUserRevoked accounts_test.go — re-revoke at later time updates revocation
+ [Fact]
+ public void Re_revoking_user_with_later_timestamp_updates_revocation()
+ {
+ using var server = CreateTestServer();
+ var acc = server.GetOrCreateAccount("revocation-test");
+
+ acc.RevokeUser("UNKEY_RE", issuedAt: 1000);
+ // User issued at 1001 is currently allowed
+ acc.IsUserRevoked("UNKEY_RE", issuedAt: 1001).ShouldBeFalse();
+
+ // Re-revoke at a later timestamp
+ acc.RevokeUser("UNKEY_RE", issuedAt: 2000);
+ // Now user issued at 1001 should be rejected
+ acc.IsUserRevoked("UNKEY_RE", issuedAt: 1001).ShouldBeTrue();
+ // User issued at 2001 still allowed
+ acc.IsUserRevoked("UNKEY_RE", issuedAt: 2001).ShouldBeFalse();
+ }
+
+ // ── Cross-account communication ───────────────────────────────────────────
+
+ // Go: TestCrossAccountRequestReply accounts_test.go — service export visibility
+ [Fact]
+ public void Service_export_is_visible_in_exporter_account()
+ {
+ using var server = CreateTestServer();
+ var exporter = server.GetOrCreateAccount("exporter");
+
+ exporter.AddServiceExport("api.>", ServiceResponseType.Singleton, null);
+
+ exporter.Exports.Services.ShouldContainKey("api.>");
+ exporter.Exports.Services["api.>"].Account.ShouldBeSameAs(exporter);
+ }
+
+ // Go: TestCrossAccountRequestReply accounts_test.go — service import routing
+ [Fact]
+ public void Service_import_routes_to_exporter_sublist()
+ {
+ using var server = CreateTestServer();
+ var exporter = server.GetOrCreateAccount("exporter");
+ var importer = server.GetOrCreateAccount("importer");
+
+ exporter.AddServiceExport("svc.calc", ServiceResponseType.Singleton, null);
+ importer.AddServiceImport(exporter, "requests.calc", "svc.calc");
+
+ var received = new List();
+ var mockClient = new TestNatsClient(1, exporter);
+ mockClient.OnMessage = (subject, _, _, _, _) => received.Add(subject);
+
+ exporter.SubList.Insert(new Subscription { Subject = "svc.calc", Sid = "s1", Client = mockClient });
+
+ var si = importer.Imports.Services["requests.calc"][0];
+ server.ProcessServiceImport(si, "requests.calc", null, default, default);
+
+ received.Count.ShouldBe(1);
+ received[0].ShouldBe("svc.calc");
+ }
+
+ // Go: TestCrossAccountRequestReply accounts_test.go — response routed back to importer
+ [Fact]
+ public void Service_import_response_preserves_reply_to_inbox()
+ {
+ using var server = CreateTestServer();
+ var exporter = server.GetOrCreateAccount("exporter");
+ var importer = server.GetOrCreateAccount("importer");
+
+ exporter.AddServiceExport("api.query", ServiceResponseType.Singleton, null);
+ importer.AddServiceImport(exporter, "q.query", "api.query");
+
+ string? capturedReply = null;
+ var mockClient = new TestNatsClient(1, exporter);
+ mockClient.OnMessage = (_, _, replyTo, _, _) => capturedReply = replyTo;
+
+ exporter.SubList.Insert(new Subscription { Subject = "api.query", Sid = "s1", Client = mockClient });
+
+ var si = importer.Imports.Services["q.query"][0];
+ server.ProcessServiceImport(si, "q.query", "_INBOX.reply.001", default, default);
+
+ capturedReply.ShouldBe("_INBOX.reply.001");
+ }
+
+ // Go: TestCrossAccountRequestReply accounts_test.go — wildcard import/export matching
+ [Fact]
+ public void Wildcard_service_import_maps_token_suffix()
+ {
+ using var server = CreateTestServer();
+ var exporter = server.GetOrCreateAccount("exporter");
+ var importer = server.GetOrCreateAccount("importer");
+
+ exporter.AddServiceExport("backend.>", ServiceResponseType.Singleton, null);
+ importer.AddServiceImport(exporter, "public.>", "backend.>");
+
+ var received = new List();
+ var mockClient = new TestNatsClient(1, exporter);
+ mockClient.OnMessage = (subject, _, _, _, _) => received.Add(subject);
+
+ exporter.SubList.Insert(new Subscription { Subject = "backend.echo", Sid = "s1", Client = mockClient });
+
+ var si = importer.Imports.Services["public.>"][0];
+ server.ProcessServiceImport(si, "public.echo", null, default, default);
+
+ received.Count.ShouldBe(1);
+ received[0].ShouldBe("backend.echo");
+ }
+
+ // Go: TestCrossAccountRequestReply accounts_test.go — account subject namespaces independent
+ [Fact]
+ public void Account_specific_subject_namespaces_are_independent()
+ {
+ using var server = CreateTestServer();
+ var accA = server.GetOrCreateAccount("ns-a");
+ var accB = server.GetOrCreateAccount("ns-b");
+
+ var receivedA = new List();
+ var receivedB = new List();
+
+ var clientA = new TestNatsClient(1, accA);
+ clientA.OnMessage = (subject, _, _, _, _) => receivedA.Add(subject);
+ var clientB = new TestNatsClient(2, accB);
+ clientB.OnMessage = (subject, _, _, _, _) => receivedB.Add(subject);
+
+ accA.SubList.Insert(new Subscription { Subject = "shared.topic", Sid = "a1", Client = clientA });
+ accB.SubList.Insert(new Subscription { Subject = "shared.topic", Sid = "b1", Client = clientB });
+
+ // Publish only to A's namespace
+ var resultA = accA.SubList.Match("shared.topic");
+ foreach (var sub in resultA.PlainSubs)
+ sub.Client?.SendMessage("shared.topic", sub.Sid, null, default, default);
+
+ receivedA.Count.ShouldBe(1);
+ receivedB.Count.ShouldBe(0); // B's subscription not in A's sublist
+ }
+
+ // Go: accounts_test.go — proxy authenticator routes to correct account
+ [Fact]
+ public void ProxyAuthenticator_routes_to_configured_account()
+ {
+ var authService = AuthService.Build(new NatsOptions
+ {
+ ProxyAuth = new ProxyAuthOptions
+ {
+ Enabled = true,
+ UsernamePrefix = "proxy:",
+ Account = "proxy-account",
+ },
+ });
+
+ var result = authService.Authenticate(new ClientAuthContext
+ {
+ Opts = new ClientOptions { Username = "proxy:my-identity" },
+ Nonce = [],
+ });
+
+ result.ShouldNotBeNull();
+ result!.Identity.ShouldBe("my-identity");
+ result.AccountName.ShouldBe("proxy-account");
+ }
+
+ // Go: accounts_test.go — proxy authenticator rejects non-matching prefix
+ [Fact]
+ public void ProxyAuthenticator_rejects_non_matching_prefix()
+ {
+ var authService = AuthService.Build(new NatsOptions
+ {
+ ProxyAuth = new ProxyAuthOptions
+ {
+ Enabled = true,
+ UsernamePrefix = "proxy:",
+ Account = "proxy-account",
+ },
+ });
+
+ var result = authService.Authenticate(new ClientAuthContext
+ {
+ Opts = new ClientOptions { Username = "direct-user", Password = "x" },
+ Nonce = [],
+ });
+
+ result.ShouldBeNull();
+ }
+
+ // Go: auth_callout_test.go — integration: callout allowed connection succeeds
+ [Fact]
+ public async Task AuthCallout_allowed_connection_connects_successfully()
+ {
+ var calloutClient = new StubExternalAuthClient(allow: true, identity: "user1");
+ var (server, port, cts) = await StartServerAsync(new NatsOptions
+ {
+ ExternalAuth = new ExternalAuthOptions
+ {
+ Enabled = true,
+ Client = calloutClient,
+ Timeout = TimeSpan.FromSeconds(2),
+ },
+ });
+
+ try
+ {
+ await using var nats = new NatsConnection(new NatsOpts
+ {
+ Url = $"nats://user1:anypass@127.0.0.1:{port}",
+ });
+
+ await nats.ConnectAsync();
+ await nats.PingAsync();
+ }
+ finally
+ {
+ await cts.CancelAsync();
+ server.Dispose();
+ }
+ }
+
+ // Go: auth_callout_test.go — integration: callout denied connection fails
+ [Fact]
+ public async Task AuthCallout_denied_connection_is_rejected()
+ {
+ var calloutClient = new StubExternalAuthClient(allow: false);
+ var (server, port, cts) = await StartServerAsync(new NatsOptions
+ {
+ ExternalAuth = new ExternalAuthOptions
+ {
+ Enabled = true,
+ Client = calloutClient,
+ Timeout = TimeSpan.FromSeconds(2),
+ },
+ });
+
+ try
+ {
+ await using var nats = new NatsConnection(new NatsOpts
+ {
+ Url = $"nats://bad-user:badpass@127.0.0.1:{port}",
+ MaxReconnectRetry = 0,
+ });
+
+ var ex = await Should.ThrowAsync(async () =>
+ {
+ await nats.ConnectAsync();
+ await nats.PingAsync();
+ });
+
+ ExceptionChainContains(ex, "Authorization Violation").ShouldBeTrue(
+ $"Expected 'Authorization Violation' in exception chain, but got: {ex}");
+ }
+ finally
+ {
+ await cts.CancelAsync();
+ server.Dispose();
+ }
+ }
+
+ // ── Test doubles ─────────────────────────────────────────────────────────
+
+ private sealed class StubExternalAuthClient(bool allow, string? identity = null, string? account = null)
+ : IExternalAuthClient
+ {
+ public Task AuthorizeAsync(ExternalAuthRequest request, CancellationToken ct) =>
+ Task.FromResult(new ExternalAuthDecision(allow, identity, account));
+ }
+
+ private sealed class DelayedExternalAuthClient(TimeSpan delay) : IExternalAuthClient
+ {
+ public async Task AuthorizeAsync(ExternalAuthRequest request, CancellationToken ct)
+ {
+ await Task.Delay(delay, ct);
+ return new ExternalAuthDecision(true, "delayed");
+ }
+ }
+
+ private sealed class CapturingExternalAuthClient(bool allow, string identity) : IExternalAuthClient
+ {
+ public ExternalAuthRequest? LastRequest { get; private set; }
+
+ public Task AuthorizeAsync(ExternalAuthRequest request, CancellationToken ct)
+ {
+ LastRequest = request;
+ return Task.FromResult(new ExternalAuthDecision(allow, identity));
+ }
+ }
+
+ private sealed class CountingExternalAuthClient(bool allow, string identity) : IExternalAuthClient
+ {
+ private int _callCount;
+ public int CallCount => _callCount;
+
+ public Task AuthorizeAsync(ExternalAuthRequest request, CancellationToken ct)
+ {
+ Interlocked.Increment(ref _callCount);
+ return Task.FromResult(new ExternalAuthDecision(allow, identity));
+ }
+ }
+
+ private sealed class TestNatsClient(ulong id, Account account) : INatsClient
+ {
+ public ulong Id => id;
+ public ClientKind Kind => ClientKind.Client;
+ public Account? Account => account;
+ public ClientOptions? ClientOpts => null;
+ public ClientPermissions? Permissions => null;
+
+ public Action, ReadOnlyMemory>? OnMessage { get; set; }
+
+ public void SendMessage(string subject, string sid, string? replyTo,
+ ReadOnlyMemory headers, ReadOnlyMemory payload)
+ {
+ OnMessage?.Invoke(subject, sid, replyTo, headers, payload);
+ }
+
+ public bool QueueOutbound(ReadOnlyMemory data) => true;
+ public void RemoveSubscription(string sid) { }
+ }
+}