diff --git a/src/NATS.Server/Auth/AuthService.cs b/src/NATS.Server/Auth/AuthService.cs index ba348d5..c17b8aa 100644 --- a/src/NATS.Server/Auth/AuthService.cs +++ b/src/NATS.Server/Auth/AuthService.cs @@ -34,6 +34,13 @@ public sealed class AuthService var nonceRequired = false; Dictionary? usersMap = null; + // TLS certificate mapping (highest priority when enabled) + if (options.TlsMap && options.TlsVerify && options.Users is { Count: > 0 }) + { + authenticators.Add(new TlsMapAuthenticator(options.Users)); + authRequired = true; + } + // Priority order (matching Go): NKeys > Users > Token > SimpleUserPassword if (options.NKeys is { Count: > 0 }) diff --git a/src/NATS.Server/Auth/IAuthenticator.cs b/src/NATS.Server/Auth/IAuthenticator.cs index 32a5788..3783c88 100644 --- a/src/NATS.Server/Auth/IAuthenticator.cs +++ b/src/NATS.Server/Auth/IAuthenticator.cs @@ -1,3 +1,4 @@ +using System.Security.Cryptography.X509Certificates; using NATS.Server.Protocol; namespace NATS.Server.Auth; @@ -11,4 +12,5 @@ public sealed class ClientAuthContext { public required ClientOptions Opts { get; init; } public required byte[] Nonce { get; init; } + public X509Certificate2? ClientCertificate { get; init; } } diff --git a/src/NATS.Server/Auth/TlsMapAuthenticator.cs b/src/NATS.Server/Auth/TlsMapAuthenticator.cs new file mode 100644 index 0000000..b213e94 --- /dev/null +++ b/src/NATS.Server/Auth/TlsMapAuthenticator.cs @@ -0,0 +1,67 @@ +using System.Security.Cryptography.X509Certificates; + +namespace NATS.Server.Auth; + +/// +/// Authenticates clients by mapping TLS certificate subject DN to configured users. +/// Corresponds to Go server/auth.go checkClientTLSCertSubject. +/// +public sealed class TlsMapAuthenticator : IAuthenticator +{ + private readonly Dictionary _usersByDn; + private readonly Dictionary _usersByCn; + + public TlsMapAuthenticator(IReadOnlyList users) + { + _usersByDn = new Dictionary(StringComparer.OrdinalIgnoreCase); + _usersByCn = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var user in users) + { + _usersByDn[user.Username] = user; + _usersByCn[user.Username] = user; + } + } + + public AuthResult? Authenticate(ClientAuthContext context) + { + var cert = context.ClientCertificate; + if (cert == null) + return null; + + var dn = cert.SubjectName; + var dnString = dn.Name; // RFC 2253 format + + // Try exact DN match first + if (_usersByDn.TryGetValue(dnString, out var user)) + return BuildResult(user); + + // Try CN extraction + var cn = ExtractCn(dn); + if (cn != null && _usersByCn.TryGetValue(cn, out user)) + return BuildResult(user); + + return null; + } + + private static string? ExtractCn(X500DistinguishedName dn) + { + var dnString = dn.Name; + foreach (var rdn in dnString.Split(',', StringSplitOptions.TrimEntries)) + { + if (rdn.StartsWith("CN=", StringComparison.OrdinalIgnoreCase)) + return rdn[3..]; + } + return null; + } + + private static AuthResult BuildResult(User user) + { + return new AuthResult + { + Identity = user.Username, + AccountName = user.Account, + Permissions = user.Permissions, + Expiry = user.ConnectionDeadline, + }; + } +} diff --git a/src/NATS.Server/NatsClient.cs b/src/NATS.Server/NatsClient.cs index daace96..6c2f325 100644 --- a/src/NATS.Server/NatsClient.cs +++ b/src/NATS.Server/NatsClient.cs @@ -367,6 +367,7 @@ public sealed class NatsClient : IDisposable { Opts = ClientOpts, Nonce = _nonce ?? [], + ClientCertificate = TlsState?.PeerCert, }; var result = _authService.Authenticate(context); diff --git a/tests/NATS.Server.Tests/TlsMapAuthenticatorTests.cs b/tests/NATS.Server.Tests/TlsMapAuthenticatorTests.cs new file mode 100644 index 0000000..e3e27ed --- /dev/null +++ b/tests/NATS.Server.Tests/TlsMapAuthenticatorTests.cs @@ -0,0 +1,134 @@ +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using NATS.Server.Auth; + +namespace NATS.Server.Tests; + +public class TlsMapAuthenticatorTests +{ + private static X509Certificate2 CreateSelfSignedCert(string cn) + { + using var rsa = RSA.Create(2048); + var req = new CertificateRequest($"CN={cn}", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + return req.CreateSelfSigned(DateTimeOffset.UtcNow, DateTimeOffset.UtcNow.AddYears(1)); + } + + private static X509Certificate2 CreateCertWithDn(string dn) + { + using var rsa = RSA.Create(2048); + var req = new CertificateRequest(dn, rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + return req.CreateSelfSigned(DateTimeOffset.UtcNow, DateTimeOffset.UtcNow.AddYears(1)); + } + + [Fact] + public void Matches_user_by_cn() + { + var users = new List + { + new() { Username = "alice", Password = "" }, + }; + var auth = new TlsMapAuthenticator(users); + var cert = CreateSelfSignedCert("alice"); + + var ctx = new ClientAuthContext + { + Opts = new Protocol.ClientOptions(), + Nonce = [], + ClientCertificate = cert, + }; + + var result = auth.Authenticate(ctx); + result.ShouldNotBeNull(); + result.Identity.ShouldBe("alice"); + } + + [Fact] + public void Returns_null_when_no_cert() + { + var users = new List + { + new() { Username = "alice", Password = "" }, + }; + var auth = new TlsMapAuthenticator(users); + + var ctx = new ClientAuthContext + { + Opts = new Protocol.ClientOptions(), + Nonce = [], + ClientCertificate = null, + }; + + var result = auth.Authenticate(ctx); + result.ShouldBeNull(); + } + + [Fact] + public void Returns_null_when_cn_doesnt_match() + { + var users = new List + { + new() { Username = "alice", Password = "" }, + }; + var auth = new TlsMapAuthenticator(users); + var cert = CreateSelfSignedCert("bob"); + + var ctx = new ClientAuthContext + { + Opts = new Protocol.ClientOptions(), + Nonce = [], + ClientCertificate = cert, + }; + + var result = auth.Authenticate(ctx); + result.ShouldBeNull(); + } + + [Fact] + public void Matches_by_full_dn_string() + { + var users = new List + { + new() { Username = "CN=alice, O=TestOrg", Password = "" }, + }; + var auth = new TlsMapAuthenticator(users); + var cert = CreateCertWithDn("CN=alice, O=TestOrg"); + + var ctx = new ClientAuthContext + { + Opts = new Protocol.ClientOptions(), + Nonce = [], + ClientCertificate = cert, + }; + + var result = auth.Authenticate(ctx); + result.ShouldNotBeNull(); + result.Identity.ShouldBe("CN=alice, O=TestOrg"); + } + + [Fact] + public void Returns_permissions_from_matched_user() + { + var perms = new Permissions + { + Publish = new SubjectPermission { Allow = ["foo.>"] }, + }; + var users = new List + { + new() { Username = "alice", Password = "", Permissions = perms }, + }; + var auth = new TlsMapAuthenticator(users); + var cert = CreateSelfSignedCert("alice"); + + var ctx = new ClientAuthContext + { + Opts = new Protocol.ClientOptions(), + Nonce = [], + ClientCertificate = cert, + }; + + var result = auth.Authenticate(ctx); + result.ShouldNotBeNull(); + result.Permissions.ShouldNotBeNull(); + result.Permissions.Publish!.Allow!.ShouldContain("foo.>"); + } +}