feat: implement TLS cert-to-user mapping via X500 DN matching

This commit is contained in:
Joseph Doherty
2026-02-23 00:55:29 -05:00
parent 1269ae8275
commit 1f13269447
5 changed files with 211 additions and 0 deletions

View File

@@ -34,6 +34,13 @@ public sealed class AuthService
var nonceRequired = false;
Dictionary<string, User>? 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 })

View File

@@ -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; }
}

View File

@@ -0,0 +1,67 @@
using System.Security.Cryptography.X509Certificates;
namespace NATS.Server.Auth;
/// <summary>
/// Authenticates clients by mapping TLS certificate subject DN to configured users.
/// Corresponds to Go server/auth.go checkClientTLSCertSubject.
/// </summary>
public sealed class TlsMapAuthenticator : IAuthenticator
{
private readonly Dictionary<string, User> _usersByDn;
private readonly Dictionary<string, User> _usersByCn;
public TlsMapAuthenticator(IReadOnlyList<User> users)
{
_usersByDn = new Dictionary<string, User>(StringComparer.OrdinalIgnoreCase);
_usersByCn = new Dictionary<string, User>(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,
};
}
}

View File

@@ -367,6 +367,7 @@ public sealed class NatsClient : IDisposable
{
Opts = ClientOpts,
Nonce = _nonce ?? [],
ClientCertificate = TlsState?.PeerCert,
};
var result = _authService.Authenticate(context);

View File

@@ -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<User>
{
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<User>
{
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<User>
{
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<User>
{
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<User>
{
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.>");
}
}