diff --git a/src/NATS.Server/Auth/AuthService.cs b/src/NATS.Server/Auth/AuthService.cs new file mode 100644 index 0000000..ba348d5 --- /dev/null +++ b/src/NATS.Server/Auth/AuthService.cs @@ -0,0 +1,131 @@ +using System.Security.Cryptography; + +namespace NATS.Server.Auth; + +/// +/// Central authentication orchestrator that builds the appropriate authenticators +/// from NatsOptions and tries them in priority order matching the Go server: +/// NKeys > Users > Token > SimpleUserPassword. +/// Reference: golang/nats-server/server/auth.go — checkClientAuth, configureAuthentication. +/// +public sealed class AuthService +{ + private readonly List _authenticators; + private readonly string? _noAuthUser; + private readonly Dictionary? _usersMap; + + public bool IsAuthRequired { get; } + public bool NonceRequired { get; } + + private AuthService(List authenticators, bool authRequired, bool nonceRequired, + string? noAuthUser, Dictionary? usersMap) + { + _authenticators = authenticators; + IsAuthRequired = authRequired; + NonceRequired = nonceRequired; + _noAuthUser = noAuthUser; + _usersMap = usersMap; + } + + public static AuthService Build(NatsOptions options) + { + var authenticators = new List(); + var authRequired = false; + var nonceRequired = false; + Dictionary? usersMap = null; + + // Priority order (matching Go): NKeys > Users > Token > SimpleUserPassword + + if (options.NKeys is { Count: > 0 }) + { + authenticators.Add(new NKeyAuthenticator(options.NKeys)); + authRequired = true; + nonceRequired = true; + } + + if (options.Users is { Count: > 0 }) + { + authenticators.Add(new UserPasswordAuthenticator(options.Users)); + authRequired = true; + usersMap = new Dictionary(StringComparer.Ordinal); + foreach (var u in options.Users) + usersMap[u.Username] = u; + } + + if (!string.IsNullOrEmpty(options.Authorization)) + { + authenticators.Add(new TokenAuthenticator(options.Authorization)); + authRequired = true; + } + + if (!string.IsNullOrEmpty(options.Username) && !string.IsNullOrEmpty(options.Password)) + { + authenticators.Add(new SimpleUserPasswordAuthenticator(options.Username, options.Password)); + authRequired = true; + } + + return new AuthService(authenticators, authRequired, nonceRequired, options.NoAuthUser, usersMap); + } + + public AuthResult? Authenticate(ClientAuthContext context) + { + if (!IsAuthRequired) + return new AuthResult { Identity = string.Empty }; + + foreach (var authenticator in _authenticators) + { + var result = authenticator.Authenticate(context); + if (result != null) + return result; + } + + if (_noAuthUser != null && IsNoCredentials(context)) + return ResolveNoAuthUser(); + + return null; + } + + private static bool IsNoCredentials(ClientAuthContext context) + { + var opts = context.Opts; + return string.IsNullOrEmpty(opts.Username) + && string.IsNullOrEmpty(opts.Password) + && string.IsNullOrEmpty(opts.Token) + && string.IsNullOrEmpty(opts.Nkey) + && string.IsNullOrEmpty(opts.Sig); + } + + private AuthResult? ResolveNoAuthUser() + { + if (_noAuthUser == null) + return null; + + if (_usersMap != null && _usersMap.TryGetValue(_noAuthUser, out var user)) + { + return new AuthResult + { + Identity = user.Username, + AccountName = user.Account, + Permissions = user.Permissions, + Expiry = user.ConnectionDeadline, + }; + } + + return new AuthResult { Identity = _noAuthUser }; + } + + public byte[] GenerateNonce() + { + Span raw = stackalloc byte[11]; + RandomNumberGenerator.Fill(raw); + return raw.ToArray(); + } + + public string EncodeNonce(byte[] nonce) + { + return Convert.ToBase64String(nonce) + .TrimEnd('=') + .Replace('+', '-') + .Replace('/', '_'); + } +} diff --git a/tests/NATS.Server.Tests/AuthServiceTests.cs b/tests/NATS.Server.Tests/AuthServiceTests.cs new file mode 100644 index 0000000..fd7a15e --- /dev/null +++ b/tests/NATS.Server.Tests/AuthServiceTests.cs @@ -0,0 +1,172 @@ +using NATS.Server.Auth; +using NATS.Server.Protocol; + +namespace NATS.Server.Tests; + +public class AuthServiceTests +{ + [Fact] + public void IsAuthRequired_false_when_no_auth_configured() + { + var service = AuthService.Build(new NatsOptions()); + service.IsAuthRequired.ShouldBeFalse(); + } + + [Fact] + public void IsAuthRequired_true_when_token_configured() + { + var service = AuthService.Build(new NatsOptions { Authorization = "mytoken" }); + service.IsAuthRequired.ShouldBeTrue(); + } + + [Fact] + public void IsAuthRequired_true_when_username_configured() + { + var service = AuthService.Build(new NatsOptions { Username = "admin", Password = "pass" }); + service.IsAuthRequired.ShouldBeTrue(); + } + + [Fact] + public void IsAuthRequired_true_when_users_configured() + { + var opts = new NatsOptions + { + Users = [new User { Username = "alice", Password = "secret" }], + }; + var service = AuthService.Build(opts); + service.IsAuthRequired.ShouldBeTrue(); + } + + [Fact] + public void IsAuthRequired_true_when_nkeys_configured() + { + var opts = new NatsOptions + { + NKeys = [new NKeyUser { Nkey = "UABC" }], + }; + var service = AuthService.Build(opts); + service.IsAuthRequired.ShouldBeTrue(); + } + + [Fact] + public void Authenticate_succeeds_when_no_auth_required() + { + var service = AuthService.Build(new NatsOptions()); + var ctx = new ClientAuthContext + { + Opts = new ClientOptions { Token = "anything" }, + Nonce = [], + }; + + var result = service.Authenticate(ctx); + result.ShouldNotBeNull(); + } + + [Fact] + public void Authenticate_token_success() + { + var service = AuthService.Build(new NatsOptions { Authorization = "mytoken" }); + var ctx = new ClientAuthContext + { + Opts = new ClientOptions { Token = "mytoken" }, + Nonce = [], + }; + + var result = service.Authenticate(ctx); + result.ShouldNotBeNull(); + result.Identity.ShouldBe("token"); + } + + [Fact] + public void Authenticate_token_failure() + { + var service = AuthService.Build(new NatsOptions { Authorization = "mytoken" }); + var ctx = new ClientAuthContext + { + Opts = new ClientOptions { Token = "wrong" }, + Nonce = [], + }; + + service.Authenticate(ctx).ShouldBeNull(); + } + + [Fact] + public void Authenticate_simple_user_password_success() + { + var service = AuthService.Build(new NatsOptions { Username = "admin", Password = "pass" }); + var ctx = new ClientAuthContext + { + Opts = new ClientOptions { Username = "admin", Password = "pass" }, + Nonce = [], + }; + + var result = service.Authenticate(ctx); + result.ShouldNotBeNull(); + result.Identity.ShouldBe("admin"); + } + + [Fact] + public void Authenticate_multi_user_success() + { + var opts = new NatsOptions + { + Users = [ + new User { Username = "alice", Password = "secret1" }, + new User { Username = "bob", Password = "secret2" }, + ], + }; + var service = AuthService.Build(opts); + var ctx = new ClientAuthContext + { + Opts = new ClientOptions { Username = "bob", Password = "secret2" }, + Nonce = [], + }; + + var result = service.Authenticate(ctx); + result.ShouldNotBeNull(); + result.Identity.ShouldBe("bob"); + } + + [Fact] + public void NoAuthUser_fallback_when_no_creds() + { + var opts = new NatsOptions + { + Users = [ + new User { Username = "default", Password = "unused" }, + ], + NoAuthUser = "default", + }; + var service = AuthService.Build(opts); + var ctx = new ClientAuthContext + { + Opts = new ClientOptions(), + Nonce = [], + }; + + var result = service.Authenticate(ctx); + result.ShouldNotBeNull(); + result.Identity.ShouldBe("default"); + } + + [Fact] + public void NKeys_tried_before_users() + { + var opts = new NatsOptions + { + NKeys = [new NKeyUser { Nkey = "UABC" }], + Users = [new User { Username = "alice", Password = "secret" }], + }; + var service = AuthService.Build(opts); + + var ctx = new ClientAuthContext + { + Opts = new ClientOptions { Username = "alice", Password = "secret" }, + Nonce = [], + }; + + var result = service.Authenticate(ctx); + result.ShouldNotBeNull(); + result.Identity.ShouldBe("alice"); + } +}