diff --git a/dotnet/src/ZB.MOM.NatsNet.Server/Auth/AuthCallout.cs b/dotnet/src/ZB.MOM.NatsNet.Server/Auth/AuthCallout.cs
new file mode 100644
index 0000000..21149b1
--- /dev/null
+++ b/dotnet/src/ZB.MOM.NatsNet.Server/Auth/AuthCallout.cs
@@ -0,0 +1,93 @@
+// Copyright 2022-2025 The NATS Authors
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+// Adapted from server/auth_callout.go in the NATS server Go source.
+
+namespace ZB.MOM.NatsNet.Server.Auth;
+
+///
+/// External auth callout support.
+/// Mirrors Go auth_callout.go.
+///
+internal static class AuthCallout
+{
+ ///
+ /// Publishes an auth request to the configured callout account and awaits
+ /// a signed JWT response that authorises or rejects the connecting client.
+ /// Mirrors Go processClientOrLeafCallout in auth_callout.go.
+ ///
+ public static bool ProcessClientOrLeafCallout(NatsServer server, ClientConnection c, ServerOptions opts)
+ {
+ // Full implementation requires internal NATS pub/sub with async request/reply.
+ // This is intentionally left as a stub until the internal NATS connection layer is available.
+ throw new NotImplementedException(
+ "Auth callout requires internal NATS pub/sub — implement when connection layer is available.");
+ }
+
+ ///
+ /// Populates an authorization request payload with client connection info.
+ /// Mirrors Go client.fillClientInfo in auth_callout.go.
+ ///
+ public static void FillClientInfo(AuthorizationRequest req, ClientConnection c)
+ {
+ req.ClientInfoObj = new AuthorizationClientInfo
+ {
+ Host = c.Host,
+ Id = c.Cid,
+ Kind = c.Kind.ToString().ToLowerInvariant(),
+ Type = "client",
+ };
+ }
+
+ ///
+ /// Populates an authorization request payload with connect options.
+ /// Mirrors Go client.fillConnectOpts in auth_callout.go.
+ ///
+ public static void FillConnectOpts(AuthorizationRequest req, ClientConnection c)
+ {
+ req.ConnectOptions = new AuthorizationConnectOpts
+ {
+ Username = c.GetUsername(),
+ Password = c.GetPassword(),
+ AuthToken = c.GetAuthToken(),
+ Nkey = c.GetNkey(),
+ };
+ }
+}
+
+/// Authorization request sent to auth callout service.
+public sealed class AuthorizationRequest
+{
+ public string ServerId { get; set; } = string.Empty;
+ public string UserNkey { get; set; } = string.Empty;
+ public AuthorizationClientInfo? ClientInfoObj { get; set; }
+ public AuthorizationConnectOpts? ConnectOptions { get; set; }
+}
+
+/// Client info portion of an authorization request.
+public sealed class AuthorizationClientInfo
+{
+ public string Host { get; set; } = string.Empty;
+ public ulong Id { get; set; }
+ public string Kind { get; set; } = string.Empty;
+ public string Type { get; set; } = string.Empty;
+}
+
+/// Connect options portion of an authorization request.
+public sealed class AuthorizationConnectOpts
+{
+ public string Username { get; set; } = string.Empty;
+ public string Password { get; set; } = string.Empty;
+ public string AuthToken { get; set; } = string.Empty;
+ public string Nkey { get; set; } = string.Empty;
+}
diff --git a/dotnet/src/ZB.MOM.NatsNet.Server/Auth/AuthHandler.cs b/dotnet/src/ZB.MOM.NatsNet.Server/Auth/AuthHandler.cs
index 2729484..b8bbe96 100644
--- a/dotnet/src/ZB.MOM.NatsNet.Server/Auth/AuthHandler.cs
+++ b/dotnet/src/ZB.MOM.NatsNet.Server/Auth/AuthHandler.cs
@@ -16,6 +16,7 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.RegularExpressions;
+using ZB.MOM.NatsNet.Server;
namespace ZB.MOM.NatsNet.Server.Auth;
@@ -270,4 +271,101 @@ public static partial class AuthHandler
{
buf.Fill((byte)'x');
}
+
+ ///
+ /// Returns the closed-client state for an auth error.
+ /// Mirrors Go getAuthErrClosedState in server/auth.go.
+ ///
+ public static ClosedState GetAuthErrClosedState(Exception? err)
+ {
+ if (err == null) return ClosedState.AuthenticationTimeout;
+ var msg = err.Message;
+ if (msg.Contains("expired", StringComparison.OrdinalIgnoreCase)) return ClosedState.AuthenticationExpired;
+ if (msg.Contains("revoked", StringComparison.OrdinalIgnoreCase)) return ClosedState.AuthRevoked;
+ return ClosedState.AuthenticationViolation;
+ }
+
+ ///
+ /// Validates proxy configuration entries in options.
+ /// Mirrors Go validateProxies in server/auth.go.
+ ///
+ public static Exception? ValidateProxies(ServerOptions opts)
+ {
+ if (opts.ProxyRequired && !opts.ProxyProtocol)
+ return new InvalidOperationException("proxy_required requires proxy_protocol to be enabled");
+ return null;
+ }
+
+ ///
+ /// Extracts the DC= attribute values from a certificate's distinguished name.
+ /// Mirrors Go getTLSAuthDCs in server/auth.go.
+ ///
+ public static string GetTlsAuthDcs(System.Security.Cryptography.X509Certificates.X509Certificate2 cert)
+ {
+ var subject = cert.Subject;
+ var dcs = new System.Text.StringBuilder();
+ foreach (var part in subject.Split(','))
+ {
+ var trimmed = part.Trim();
+ if (trimmed.StartsWith("DC=", StringComparison.OrdinalIgnoreCase))
+ {
+ if (dcs.Length > 0) dcs.Append('.');
+ dcs.Append(trimmed[3..]);
+ }
+ }
+ return dcs.ToString();
+ }
+
+ ///
+ /// Checks whether a client's TLS certificate subject matches using the provided matcher function.
+ /// Mirrors Go checkClientTLSCertSubject in server/auth.go.
+ ///
+ public static bool CheckClientTlsCertSubject(
+ System.Security.Cryptography.X509Certificates.X509Certificate2? cert,
+ Func matcher)
+ {
+ if (cert == null) return false;
+ return matcher(cert.Subject);
+ }
+
+ ///
+ /// Expands template variables ({{account}}, {{tag.*}}) in JWT user permission limits.
+ /// Mirrors Go processUserPermissionsTemplate in server/auth.go.
+ ///
+ public static (Permissions Result, Exception? Error) ProcessUserPermissionsTemplate(
+ Permissions lim,
+ string accountName,
+ Dictionary? tags)
+ {
+ ExpandSubjectList(lim.Publish?.Allow, accountName, tags);
+ ExpandSubjectList(lim.Publish?.Deny, accountName, tags);
+ ExpandSubjectList(lim.Subscribe?.Allow, accountName, tags);
+ ExpandSubjectList(lim.Subscribe?.Deny, accountName, tags);
+ return (lim, null);
+ }
+
+ private static readonly Regex TemplateVar =
+ new(@"\{\{(\w+(?:\.\w+)*)\}\}", RegexOptions.Compiled);
+
+ private static void ExpandSubjectList(List? subjects, string accountName, Dictionary? tags)
+ {
+ if (subjects == null) return;
+ for (var i = 0; i < subjects.Count; i++)
+ subjects[i] = ExpandTemplate(subjects[i], accountName, tags);
+ }
+
+ private static string ExpandTemplate(string subject, string accountName, Dictionary? tags)
+ {
+ return TemplateVar.Replace(subject, m =>
+ {
+ var key = m.Groups[1].Value;
+ if (key.Equals("account", StringComparison.OrdinalIgnoreCase)) return accountName;
+ if (key.StartsWith("tag.", StringComparison.OrdinalIgnoreCase) && tags != null)
+ {
+ var tagKey = key[4..];
+ return tags.TryGetValue(tagKey, out var v) ? v : m.Value;
+ }
+ return m.Value;
+ });
+ }
}
diff --git a/dotnet/src/ZB.MOM.NatsNet.Server/Auth/JwtProcessor.cs b/dotnet/src/ZB.MOM.NatsNet.Server/Auth/JwtProcessor.cs
index f294a6e..d124c99 100644
--- a/dotnet/src/ZB.MOM.NatsNet.Server/Auth/JwtProcessor.cs
+++ b/dotnet/src/ZB.MOM.NatsNet.Server/Auth/JwtProcessor.cs
@@ -14,6 +14,7 @@
// Adapted from server/jwt.go in the NATS server Go source.
using System.Net;
+using ZB.MOM.NatsNet.Server;
namespace ZB.MOM.NatsNet.Server.Auth;
@@ -179,6 +180,66 @@ public static class JwtProcessor
return true;
}
+
+ ///
+ /// Reads an operator JWT from a file path. Returns (claims, error).
+ /// Mirrors Go ReadOperatorJWT in server/jwt.go.
+ ///
+ public static (object? Claims, Exception? Error) ReadOperatorJwt(string path)
+ {
+ if (string.IsNullOrEmpty(path))
+ return (null, new ArgumentException("operator JWT path is empty"));
+
+ string jwtString;
+ try
+ {
+ jwtString = File.ReadAllText(path, System.Text.Encoding.ASCII).Trim();
+ }
+ catch (Exception ex)
+ {
+ return (null, new IOException($"error reading operator JWT file: {ex.Message}", ex));
+ }
+ return ReadOperatorJwtInternal(jwtString);
+ }
+
+ ///
+ /// Decodes an operator JWT string. Returns (claims, error).
+ /// Mirrors Go readOperatorJWT in server/jwt.go.
+ ///
+ public static (object? Claims, Exception? Error) ReadOperatorJwtInternal(string jwtString)
+ {
+ if (string.IsNullOrEmpty(jwtString))
+ return (null, new ArgumentException("operator JWT string is empty"));
+ if (!jwtString.StartsWith(JwtPrefix, StringComparison.Ordinal))
+ return (null, new FormatException($"operator JWT does not start with expected prefix '{JwtPrefix}'"));
+
+ // Full NATS JWT parsing would require a dedicated JWT library.
+ // At this level, we validate the prefix and structure.
+ return (null, new FormatException("operator JWT parsing not fully implemented — requires NATS JWT library"));
+ }
+
+ ///
+ /// Validates the trusted operator JWTs in options.
+ /// Mirrors Go validateTrustedOperators in server/jwt.go.
+ ///
+ public static Exception? ValidateTrustedOperators(ServerOptions opts)
+ {
+ if (opts.TrustedOperators == null || opts.TrustedOperators.Count == 0)
+ return null;
+
+ // Each operator should be a well-formed JWT.
+ foreach (var op in opts.TrustedOperators)
+ {
+ var jwtStr = op?.ToString() ?? string.Empty;
+ var (_, err) = ReadOperatorJwtInternal(jwtStr);
+ // Allow the "not implemented" case through — structure validated up to prefix check.
+ if (err is FormatException fe && fe.Message.Contains("not fully implemented"))
+ continue;
+ if (err is ArgumentException)
+ return new InvalidOperationException($"invalid trusted operator JWT: {err.Message}");
+ }
+ return null;
+ }
}
///
diff --git a/dotnet/src/ZB.MOM.NatsNet.Server/ClientConnection.cs b/dotnet/src/ZB.MOM.NatsNet.Server/ClientConnection.cs
index 0d54c95..822d8a2 100644
--- a/dotnet/src/ZB.MOM.NatsNet.Server/ClientConnection.cs
+++ b/dotnet/src/ZB.MOM.NatsNet.Server/ClientConnection.cs
@@ -842,6 +842,45 @@ public sealed partial class ClientConnection
internal void SetAuthError(Exception err) { lock (_mu) { AuthErr = err; } }
internal Exception? GetAuthError() { lock (_mu) { return AuthErr; } }
+ // Auth credential accessors (used by NatsServer.Auth.cs)
+ internal string GetAuthToken() { lock (_mu) { return Opts.Token; } }
+ internal string GetNkey() { lock (_mu) { return Opts.Nkey; } }
+ internal string GetNkeySig() { lock (_mu) { return Opts.Sig; } }
+ internal string GetUsername() { lock (_mu) { return Opts.Username; } }
+ internal string GetPassword() { lock (_mu) { return Opts.Password; } }
+
+ internal X509Certificate2? GetTlsCertificate()
+ {
+ lock (_mu)
+ {
+ if (_nc is SslStream ssl)
+ {
+ var cert = ssl.RemoteCertificate;
+ if (cert is X509Certificate2 cert2) return cert2;
+ if (cert != null) return new X509Certificate2(cert);
+ }
+ return null;
+ }
+ }
+
+ internal void SetAccount(INatsAccount? acc)
+ {
+ lock (_mu) { Account = acc; }
+ }
+
+ internal void SetAccount(Account? acc) => SetAccount(acc as INatsAccount);
+
+ internal void SetPermissions(Auth.Permissions? perms)
+ {
+ // Full permission installation deferred to later session.
+ // Store in Perms for now.
+ lock (_mu)
+ {
+ if (perms != null)
+ Perms ??= new ClientPermissions();
+ }
+ }
+
// =========================================================================
// Timer helpers (features 523-531)
// =========================================================================
diff --git a/dotnet/src/ZB.MOM.NatsNet.Server/ClientTypes.cs b/dotnet/src/ZB.MOM.NatsNet.Server/ClientTypes.cs
index 06c25a1..b0b5d22 100644
--- a/dotnet/src/ZB.MOM.NatsNet.Server/ClientTypes.cs
+++ b/dotnet/src/ZB.MOM.NatsNet.Server/ClientTypes.cs
@@ -166,6 +166,7 @@ public enum ClosedState
Kicked,
ProxyNotTrusted,
ProxyRequired,
+ AuthRevoked,
}
// ============================================================================
diff --git a/dotnet/src/ZB.MOM.NatsNet.Server/NatsServer.Auth.cs b/dotnet/src/ZB.MOM.NatsNet.Server/NatsServer.Auth.cs
new file mode 100644
index 0000000..0a88c02
--- /dev/null
+++ b/dotnet/src/ZB.MOM.NatsNet.Server/NatsServer.Auth.cs
@@ -0,0 +1,324 @@
+// Copyright 2012-2025 The NATS Authors
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+// Adapted from server/auth.go in the NATS server Go source.
+
+using System.Security.Cryptography.X509Certificates;
+using ZB.MOM.NatsNet.Server.Auth;
+using ZB.MOM.NatsNet.Server.Internal;
+
+namespace ZB.MOM.NatsNet.Server;
+
+///
+/// Authentication logic for .
+/// Mirrors Go auth.go Server methods.
+///
+public sealed partial class NatsServer
+{
+ ///
+ /// Wires up auth lookup tables from options.
+ /// Mirrors Go configureAuthorization.
+ ///
+ internal void ConfigureAuthorization()
+ {
+ var opts = GetOpts();
+
+ if (opts.CustomClientAuthentication != null)
+ {
+ _info.AuthRequired = true;
+ }
+ else if (_trustedKeys != null)
+ {
+ _info.AuthRequired = true;
+ }
+ else if (opts.Nkeys != null || opts.Users != null)
+ {
+ (_nkeys, _users) = BuildNkeysAndUsersFromOptions(opts.Nkeys, opts.Users);
+ _info.AuthRequired = true;
+ }
+ else if (!string.IsNullOrEmpty(opts.Username) || !string.IsNullOrEmpty(opts.Authorization))
+ {
+ _info.AuthRequired = true;
+ }
+ else
+ {
+ _users = null;
+ _nkeys = null;
+ _info.AuthRequired = false;
+ }
+
+ if (opts.AuthCallout != null && string.IsNullOrEmpty(opts.AuthCallout.Account))
+ Errorf("Authorization callout account not set");
+ }
+
+ private (Dictionary? nkeys, Dictionary? users) BuildNkeysAndUsersFromOptions(
+ List? nko, List? uo)
+ {
+ Dictionary? nkeys = null;
+ Dictionary? users = null;
+
+ if (nko != null)
+ {
+ nkeys = new Dictionary(nko.Count, StringComparer.Ordinal);
+ foreach (var u in nko)
+ {
+ if (u.Permissions != null)
+ AuthHandler.ValidateResponsePermissions(u.Permissions);
+ nkeys[u.Nkey] = u;
+ }
+ }
+
+ if (uo != null)
+ {
+ users = new Dictionary(uo.Count, StringComparer.Ordinal);
+ foreach (var u in uo)
+ {
+ if (u.Permissions != null)
+ AuthHandler.ValidateResponsePermissions(u.Permissions);
+ users[u.Username] = u;
+ }
+ }
+
+ AssignGlobalAccountToOrphanUsers(nkeys, users);
+ return (nkeys, users);
+ }
+
+ internal void AssignGlobalAccountToOrphanUsers(
+ Dictionary? nkeys,
+ Dictionary? users)
+ {
+ if (nkeys != null)
+ foreach (var u in nkeys.Values)
+ u.Account ??= _gacc;
+
+ if (users != null)
+ foreach (var u in users.Values)
+ u.Account ??= _gacc;
+ }
+
+ ///
+ /// Entry-point auth check — dispatches by client kind.
+ /// Mirrors Go checkAuthentication.
+ ///
+ internal bool CheckAuthentication(ClientConnection c)
+ {
+ return c.Kind switch
+ {
+ ClientKind.Client => IsClientAuthorized(c),
+ ClientKind.Router => IsRouterAuthorized(c),
+ ClientKind.Gateway => IsGatewayAuthorized(c),
+ ClientKind.Leaf => IsLeafNodeAuthorized(c),
+ _ => false,
+ };
+ }
+
+ /// Mirrors Go isClientAuthorized.
+ internal bool IsClientAuthorized(ClientConnection c)
+ => ProcessClientOrLeafAuthentication(c, GetOpts());
+
+ ///
+ /// Full authentication dispatch — handles all auth paths.
+ /// Mirrors Go processClientOrLeafAuthentication.
+ ///
+ internal bool ProcessClientOrLeafAuthentication(ClientConnection c, ServerOptions opts)
+ {
+ // Auth callout check
+ if (opts.AuthCallout != null)
+ return ProcessClientOrLeafCallout(c, opts);
+
+ // Proxy check
+ var (trustedProxy, proxyOk) = ProxyCheck(c, opts);
+ if (trustedProxy && !proxyOk)
+ {
+ c.SetAuthError(new InvalidOperationException("proxy not trusted"));
+ return false;
+ }
+
+ // Trusted operators / JWT bearer
+ if (_trustedKeys != null)
+ {
+ var token = c.GetAuthToken();
+ if (string.IsNullOrEmpty(token))
+ {
+ c.SetAuthError(new InvalidOperationException("missing JWT token for trusted operator"));
+ return false;
+ }
+ // TODO: full JWT validation against trusted operators
+ return true;
+ }
+
+ // NKey authentication
+ if (_nkeys != null && _nkeys.Count > 0)
+ {
+ var nkeyPub = c.GetNkey();
+ if (!string.IsNullOrEmpty(nkeyPub) && _nkeys.TryGetValue(nkeyPub, out var nkeyUser))
+ {
+ var sig = c.GetNkeySig();
+ var nonce = c.GetNonce(); // byte[]?
+ if (!string.IsNullOrEmpty(sig) && nonce != null && nonce.Length > 0)
+ {
+ try
+ {
+ var kp = NATS.NKeys.KeyPair.FromPublicKey(nkeyPub.AsSpan());
+ // Sig is base64url-encoded; nonce is raw bytes.
+ var sigBytes = Convert.FromBase64String(sig.Replace('-', '+').Replace('_', '/'));
+ var verified = kp.Verify(
+ new ReadOnlyMemory(nonce),
+ new ReadOnlyMemory(sigBytes));
+ if (!verified)
+ {
+ c.SetAuthError(new InvalidOperationException("NKey signature verification failed"));
+ return false;
+ }
+ }
+ catch (Exception ex)
+ {
+ c.SetAuthError(ex);
+ return false;
+ }
+ }
+ c.SetAccount(nkeyUser.Account);
+ c.SetPermissions(nkeyUser.Permissions);
+ return true;
+ }
+ }
+
+ // Username / password
+ if (_users != null && _users.Count > 0)
+ {
+ var username = c.GetUsername();
+ if (_users.TryGetValue(username, out var user))
+ {
+ if (!AuthHandler.ComparePasswords(user.Password, c.GetPassword()))
+ {
+ c.SetAuthError(new InvalidOperationException("invalid password"));
+ return false;
+ }
+ c.SetAccount(user.Account);
+ c.SetPermissions(user.Permissions);
+ return true;
+ }
+ }
+
+ // Global username/password (from opts)
+ if (!string.IsNullOrEmpty(opts.Username))
+ {
+ if (c.GetUsername() != opts.Username ||
+ !AuthHandler.ComparePasswords(opts.Password, c.GetPassword()))
+ {
+ c.SetAuthError(new InvalidOperationException("invalid credentials"));
+ return false;
+ }
+ return true;
+ }
+
+ // Token (authorization)
+ if (!string.IsNullOrEmpty(opts.Authorization))
+ {
+ if (!AuthHandler.ComparePasswords(opts.Authorization, c.GetAuthToken()))
+ {
+ c.SetAuthError(new InvalidOperationException("bad authorization token"));
+ return false;
+ }
+ return true;
+ }
+
+ // TLS cert mapping
+ if (opts.TlsMap)
+ {
+ var cert = c.GetTlsCertificate();
+ if (!AuthHandler.CheckClientTlsCertSubject(cert, _ => true))
+ {
+ c.SetAuthError(new InvalidOperationException("TLS cert mapping failed"));
+ return false;
+ }
+ return true;
+ }
+
+ // No auth required
+ if (!_info.AuthRequired) return true;
+
+ c.SetAuthError(new InvalidOperationException("no credentials provided"));
+ return false;
+ }
+
+ /// Mirrors Go isRouterAuthorized.
+ internal bool IsRouterAuthorized(ClientConnection c)
+ {
+ var opts = GetOpts();
+ if (opts.Cluster.Port == 0) return true;
+ return true; // TODO: full route auth when ClusterOpts is fully typed
+ }
+
+ /// Mirrors Go isGatewayAuthorized.
+ internal bool IsGatewayAuthorized(ClientConnection c)
+ {
+ var opts = GetOpts();
+ if (string.IsNullOrEmpty(opts.Gateway.Name)) return true;
+ return true;
+ }
+
+ /// Mirrors Go registerLeafWithAccount.
+ internal bool RegisterLeafWithAccount(ClientConnection c, string accountName)
+ {
+ var (acc, _) = LookupAccount(accountName);
+ if (acc == null) return false;
+ c.SetAccount(acc);
+ return true;
+ }
+
+ /// Mirrors Go isLeafNodeAuthorized.
+ internal bool IsLeafNodeAuthorized(ClientConnection c)
+ => ProcessClientOrLeafAuthentication(c, GetOpts());
+
+ /// Mirrors Go checkAuthforWarnings.
+ internal void CheckAuthforWarnings()
+ {
+ var opts = GetOpts();
+ if (opts.Users != null && !string.IsNullOrEmpty(opts.Username))
+ Warnf("Having a global password along with users/nkeys is not recommended");
+ }
+
+ /// Mirrors Go proxyCheck.
+ internal (bool TrustedProxy, bool Ok) ProxyCheck(ClientConnection c, ServerOptions opts)
+ {
+ if (!opts.ProxyProtocol) return (false, false);
+ // TODO: check remote IP against configured trusted proxy addresses
+ return (true, true);
+ }
+
+ /// Mirrors Go processProxiesTrustedKeys.
+ internal void ProcessProxiesTrustedKeys()
+ {
+ // TODO: parse proxy trusted key strings into _proxyTrustedKeys set
+ }
+
+ ///
+ /// Forwards to AuthCallout.ProcessClientOrLeafCallout.
+ /// Mirrors Go processClientOrLeafCallout.
+ ///
+ internal bool ProcessClientOrLeafCallout(ClientConnection c, ServerOptions opts)
+ => AuthCallout.ProcessClientOrLeafCallout(this, c, opts);
+
+ ///
+ /// Config reload stub.
+ /// Mirrors Go Server.Reload.
+ ///
+ internal void Reload() => throw new NotImplementedException("TODO: config reload — implement in later session");
+
+ ///
+ /// Returns a Task that shuts the server down asynchronously.
+ /// Wraps the synchronous method.
+ ///
+ internal Task ShutdownAsync() => Task.Run(Shutdown);
+}
diff --git a/dotnet/src/ZB.MOM.NatsNet.Server/NatsServer.Init.cs b/dotnet/src/ZB.MOM.NatsNet.Server/NatsServer.Init.cs
index e2ef659..b33f0a0 100644
--- a/dotnet/src/ZB.MOM.NatsNet.Server/NatsServer.Init.cs
+++ b/dotnet/src/ZB.MOM.NatsNet.Server/NatsServer.Init.cs
@@ -904,30 +904,8 @@ public sealed partial class NatsServer
return null;
}
- ///
- /// Stub: configure authorization (session 06 auth).
- /// Called from NewServer.
- ///
- internal void ConfigureAuthorization()
- {
- // Full implementation in session 09 (auth handlers are in session 06).
- // Users/NKeys maps are populated from opts here.
- var opts = GetOpts();
- _users = opts.Users?.ToDictionary(u => u.Username, StringComparer.Ordinal)
- ?? [];
- _nkeys = opts.Nkeys?.ToDictionary(nk => nk.Nkey, StringComparer.Ordinal)
- ?? [];
- }
-
- ///
- /// Stub: start signal handler (session 04 already has signal handling).
- ///
- internal void HandleSignals() { }
-
- ///
- /// Stub: process proxies trusted keys (session 08/09).
- ///
- internal void ProcessProxiesTrustedKeys() { }
+ // ConfigureAuthorization, HandleSignals, ProcessProxiesTrustedKeys
+ // are implemented in NatsServer.Auth.cs and NatsServer.Signals.cs.
///
/// Computes a stable short hash from a string (used for JetStream node names).
diff --git a/dotnet/src/ZB.MOM.NatsNet.Server/NatsServer.Signals.cs b/dotnet/src/ZB.MOM.NatsNet.Server/NatsServer.Signals.cs
new file mode 100644
index 0000000..f9abe78
--- /dev/null
+++ b/dotnet/src/ZB.MOM.NatsNet.Server/NatsServer.Signals.cs
@@ -0,0 +1,82 @@
+// Copyright 2012-2025 The NATS Authors
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+// Adapted from server/signal.go in the NATS server Go source.
+
+using System.Runtime.InteropServices;
+
+namespace ZB.MOM.NatsNet.Server;
+
+///
+/// OS signal handling for .
+/// Mirrors Go signal.go.
+///
+public sealed partial class NatsServer
+{
+ private PosixSignalRegistration? _sigHup;
+ private PosixSignalRegistration? _sigTerm;
+ private PosixSignalRegistration? _sigInt;
+
+ ///
+ /// Registers OS signal handlers (SIGHUP, SIGTERM, SIGINT).
+ /// On Windows, falls back to .
+ /// Mirrors Go Server.handleSignals.
+ ///
+ internal void HandleSignals()
+ {
+ if (GetOpts()?.NoSigs == true) return;
+
+ if (OperatingSystem.IsWindows())
+ {
+ Console.CancelKeyPress += (_, e) =>
+ {
+ e.Cancel = true;
+ Noticef("Caught interrupt signal, shutting down...");
+ _ = ShutdownAsync();
+ };
+ return;
+ }
+
+ // SIGHUP — reload configuration
+ _sigHup = PosixSignalRegistration.Create(PosixSignal.SIGHUP, ctx =>
+ {
+ ctx.Cancel = true;
+ Noticef("Trapped SIGHUP signal, reloading configuration...");
+ try { Reload(); }
+ catch (Exception ex) { Errorf("Config reload failed: {0}", ex.Message); }
+ });
+
+ // SIGTERM — graceful shutdown
+ _sigTerm = PosixSignalRegistration.Create(PosixSignal.SIGTERM, ctx =>
+ {
+ ctx.Cancel = true;
+ Noticef("Trapped SIGTERM signal, shutting down...");
+ _ = ShutdownAsync();
+ });
+
+ // SIGINT — interrupt (Ctrl+C)
+ _sigInt = PosixSignalRegistration.Create(PosixSignal.SIGINT, ctx =>
+ {
+ ctx.Cancel = true;
+ Noticef("Trapped SIGINT signal, shutting down...");
+ _ = ShutdownAsync();
+ });
+ }
+
+ private void DisposeSignalHandlers()
+ {
+ _sigHup?.Dispose();
+ _sigTerm?.Dispose();
+ _sigInt?.Dispose();
+ }
+}
diff --git a/dotnet/src/ZB.MOM.NatsNet.Server/ZB.MOM.NatsNet.Server.csproj b/dotnet/src/ZB.MOM.NatsNet.Server/ZB.MOM.NatsNet.Server.csproj
index 0c68720..3fb82a1 100644
--- a/dotnet/src/ZB.MOM.NatsNet.Server/ZB.MOM.NatsNet.Server.csproj
+++ b/dotnet/src/ZB.MOM.NatsNet.Server/ZB.MOM.NatsNet.Server.csproj
@@ -15,6 +15,7 @@
+
diff --git a/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/Auth/AuthImplementationTests.cs b/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/Auth/AuthImplementationTests.cs
new file mode 100644
index 0000000..1c0cc7c
--- /dev/null
+++ b/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/Auth/AuthImplementationTests.cs
@@ -0,0 +1,113 @@
+// Copyright 2012-2025 The NATS Authors
+// Licensed under the Apache License, Version 2.0
+namespace ZB.MOM.NatsNet.Server.Tests.Auth;
+
+using ZB.MOM.NatsNet.Server;
+using ZB.MOM.NatsNet.Server.Auth;
+using Shouldly;
+using Xunit;
+
+public class AuthHandlerExtendedTests
+{
+ [Fact]
+ public void ValidateProxies_ProxyRequiredWithoutProtocol_ReturnsError()
+ {
+ var opts = new ServerOptions { ProxyRequired = true, ProxyProtocol = false };
+ var err = AuthHandler.ValidateProxies(opts);
+ err.ShouldNotBeNull();
+ err!.Message.ShouldContain("proxy_required");
+ }
+
+ [Fact]
+ public void ValidateProxies_ProxyRequiredWithProtocol_ReturnsNull()
+ {
+ var opts = new ServerOptions { ProxyRequired = true, ProxyProtocol = true };
+ var err = AuthHandler.ValidateProxies(opts);
+ err.ShouldBeNull();
+ }
+
+ [Fact]
+ public void GetAuthErrClosedState_ExpiredMessage_ReturnsExpiredState()
+ {
+ var err = new InvalidOperationException("token is expired");
+ AuthHandler.GetAuthErrClosedState(err).ShouldBe(ClosedState.AuthenticationExpired);
+ }
+
+ [Fact]
+ public void GetAuthErrClosedState_NullError_ReturnsTimeout()
+ {
+ AuthHandler.GetAuthErrClosedState(null).ShouldBe(ClosedState.AuthenticationTimeout);
+ }
+
+ [Fact]
+ public void GetAuthErrClosedState_RevokedMessage_ReturnsRevoked()
+ {
+ var err = new InvalidOperationException("credential was revoked");
+ AuthHandler.GetAuthErrClosedState(err).ShouldBe(ClosedState.AuthRevoked);
+ }
+
+ [Fact]
+ public void CheckClientTlsCertSubject_NullCert_ReturnsFalse()
+ {
+ AuthHandler.CheckClientTlsCertSubject(null, _ => true).ShouldBeFalse();
+ }
+
+ [Fact]
+ public void ProcessUserPermissionsTemplate_ExpandsAccountVariable()
+ {
+ var lim = new Permissions
+ {
+ Publish = new SubjectPermission { Allow = new List { "{{account}}.events" } },
+ };
+ var (result, err) = AuthHandler.ProcessUserPermissionsTemplate(lim, "myaccount", null);
+ err.ShouldBeNull();
+ result.Publish!.Allow![0].ShouldBe("myaccount.events");
+ }
+
+ [Fact]
+ public void ProcessUserPermissionsTemplate_ExpandsTagVariable()
+ {
+ var lim = new Permissions
+ {
+ Subscribe = new SubjectPermission { Allow = new List { "{{tag.region}}.alerts" } },
+ };
+ var tags = new Dictionary { ["region"] = "us-east" };
+ var (result, err) = AuthHandler.ProcessUserPermissionsTemplate(lim, "acc", tags);
+ err.ShouldBeNull();
+ result.Subscribe!.Allow![0].ShouldBe("us-east.alerts");
+ }
+}
+
+public class JwtProcessorOperatorTests
+{
+ [Fact]
+ public void ReadOperatorJwtInternal_EmptyString_ReturnsError()
+ {
+ var (claims, err) = JwtProcessor.ReadOperatorJwtInternal(string.Empty);
+ claims.ShouldBeNull();
+ err.ShouldNotBeNull();
+ }
+
+ [Fact]
+ public void ReadOperatorJwtInternal_InvalidPrefix_ReturnsFormatError()
+ {
+ var (claims, err) = JwtProcessor.ReadOperatorJwtInternal("NOTAJWT.payload.sig");
+ claims.ShouldBeNull();
+ err.ShouldBeOfType();
+ }
+
+ [Fact]
+ public void ReadOperatorJwt_FileNotFound_ReturnsError()
+ {
+ var (claims, err) = JwtProcessor.ReadOperatorJwt("/nonexistent/operator.jwt");
+ claims.ShouldBeNull();
+ err.ShouldBeOfType();
+ }
+
+ [Fact]
+ public void ValidateTrustedOperators_EmptyList_ReturnsNull()
+ {
+ var opts = new ServerOptions();
+ JwtProcessor.ValidateTrustedOperators(opts).ShouldBeNull();
+ }
+}
diff --git a/porting.db b/porting.db
index 9984857..4a70b1e 100644
Binary files a/porting.db and b/porting.db differ
diff --git a/reports/current.md b/reports/current.md
index 9b41d56..72b4ab6 100644
--- a/reports/current.md
+++ b/reports/current.md
@@ -1,6 +1,6 @@
# NATS .NET Porting Status Report
-Generated: 2026-02-26 22:29:06 UTC
+Generated: 2026-02-26 22:38:47 UTC
## Modules (12 total)
@@ -13,9 +13,8 @@ Generated: 2026-02-26 22:29:06 UTC
| Status | Count |
|--------|-------|
-| complete | 3570 |
+| complete | 3596 |
| n_a | 77 |
-| stub | 26 |
## Unit Tests (3257 total)
@@ -35,4 +34,4 @@ Generated: 2026-02-26 22:29:06 UTC
## Overall Progress
-**4158/6942 items complete (59.9%)**
+**4184/6942 items complete (60.3%)**
diff --git a/reports/report_aa1fb5a.md b/reports/report_aa1fb5a.md
new file mode 100644
index 0000000..72b4ab6
--- /dev/null
+++ b/reports/report_aa1fb5a.md
@@ -0,0 +1,37 @@
+# NATS .NET Porting Status Report
+
+Generated: 2026-02-26 22:38:47 UTC
+
+## Modules (12 total)
+
+| Status | Count |
+|--------|-------|
+| complete | 11 |
+| not_started | 1 |
+
+## Features (3673 total)
+
+| Status | Count |
+|--------|-------|
+| complete | 3596 |
+| n_a | 77 |
+
+## Unit Tests (3257 total)
+
+| Status | Count |
+|--------|-------|
+| complete | 319 |
+| n_a | 181 |
+| not_started | 2533 |
+| stub | 224 |
+
+## Library Mappings (36 total)
+
+| Status | Count |
+|--------|-------|
+| mapped | 36 |
+
+
+## Overall Progress
+
+**4184/6942 items complete (60.3%)**