diff --git a/src/NATS.Server/Auth/Jwt/AccountResolver.cs b/src/NATS.Server/Auth/Jwt/AccountResolver.cs new file mode 100644 index 0000000..b3739b2 --- /dev/null +++ b/src/NATS.Server/Auth/Jwt/AccountResolver.cs @@ -0,0 +1,65 @@ +using System.Collections.Concurrent; + +namespace NATS.Server.Auth.Jwt; + +/// +/// Resolves account JWTs by account NKey public key. The server calls +/// during client authentication to obtain the +/// account JWT that was previously published by an account operator. +/// +/// +/// Reference: golang/nats-server/server/accounts.go:4035+ — AccountResolver interface +/// and MemAccResolver implementation. +/// +public interface IAccountResolver +{ + /// + /// Fetches the JWT for the given account NKey. Returns null when + /// the NKey is not known to this resolver. + /// + Task FetchAsync(string accountNkey); + + /// + /// Stores (or replaces) the JWT for the given account NKey. Callers that + /// target a read-only resolver should check first. + /// + Task StoreAsync(string accountNkey, string jwt); + + /// + /// When true, is not supported and will + /// throw . Directory and URL resolvers + /// may be read-only; in-memory resolvers are not. + /// + bool IsReadOnly { get; } +} + +/// +/// In-memory account resolver backed by a . +/// Suitable for tests and simple single-operator deployments where account JWTs +/// are provided at startup via . +/// +/// +/// Reference: golang/nats-server/server/accounts.go — MemAccResolver +/// +public sealed class MemAccountResolver : IAccountResolver +{ + private readonly ConcurrentDictionary _accounts = + new(StringComparer.Ordinal); + + /// + public bool IsReadOnly => false; + + /// + public Task FetchAsync(string accountNkey) + { + _accounts.TryGetValue(accountNkey, out var jwt); + return Task.FromResult(jwt); + } + + /// + public Task StoreAsync(string accountNkey, string jwt) + { + _accounts[accountNkey] = jwt; + return Task.CompletedTask; + } +} diff --git a/tests/NATS.Server.Tests/AccountResolverTests.cs b/tests/NATS.Server.Tests/AccountResolverTests.cs new file mode 100644 index 0000000..691148e --- /dev/null +++ b/tests/NATS.Server.Tests/AccountResolverTests.cs @@ -0,0 +1,68 @@ +using NATS.Server.Auth.Jwt; + +namespace NATS.Server.Tests; + +public class AccountResolverTests +{ + [Fact] + public async Task Store_and_fetch_roundtrip() + { + var resolver = new MemAccountResolver(); + const string nkey = "AABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789ABCDEFGHIJKLMNOPQ"; + const string jwt = "eyJhbGciOiJlZDI1NTE5LW5rZXkiLCJ0eXAiOiJKV1QifQ.payload.sig"; + + await resolver.StoreAsync(nkey, jwt); + var fetched = await resolver.FetchAsync(nkey); + + fetched.ShouldBe(jwt); + } + + [Fact] + public async Task Fetch_unknown_key_returns_null() + { + var resolver = new MemAccountResolver(); + + var result = await resolver.FetchAsync("UNKNOWN_NKEY"); + + result.ShouldBeNull(); + } + + [Fact] + public async Task Store_overwrites_existing_entry() + { + var resolver = new MemAccountResolver(); + const string nkey = "AABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789ABCDEFGHIJKLMNOPQ"; + const string originalJwt = "original.jwt.token"; + const string updatedJwt = "updated.jwt.token"; + + await resolver.StoreAsync(nkey, originalJwt); + await resolver.StoreAsync(nkey, updatedJwt); + var fetched = await resolver.FetchAsync(nkey); + + fetched.ShouldBe(updatedJwt); + } + + [Fact] + public void IsReadOnly_returns_false() + { + IAccountResolver resolver = new MemAccountResolver(); + + resolver.IsReadOnly.ShouldBeFalse(); + } + + [Fact] + public async Task Multiple_accounts_are_stored_independently() + { + var resolver = new MemAccountResolver(); + const string nkey1 = "AABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789ABCDEFGHIJKLMNOPQ1"; + const string nkey2 = "AABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789ABCDEFGHIJKLMNOPQ2"; + const string jwt1 = "jwt.for.account.one"; + const string jwt2 = "jwt.for.account.two"; + + await resolver.StoreAsync(nkey1, jwt1); + await resolver.StoreAsync(nkey2, jwt2); + + (await resolver.FetchAsync(nkey1)).ShouldBe(jwt1); + (await resolver.FetchAsync(nkey2)).ShouldBe(jwt2); + } +}