feat: implement IAccountResolver interface and MemAccountResolver

Adds the IAccountResolver interface (FetchAsync, StoreAsync, IsReadOnly)
and a MemAccountResolver backed by ConcurrentDictionary for in-memory
JWT storage in tests and simple operator deployments.

Reference: golang/nats-server/server/accounts.go:4035+
This commit is contained in:
Joseph Doherty
2026-02-23 04:22:36 -05:00
parent 9f88b034eb
commit c8b347cb96
2 changed files with 133 additions and 0 deletions

View File

@@ -0,0 +1,65 @@
using System.Collections.Concurrent;
namespace NATS.Server.Auth.Jwt;
/// <summary>
/// Resolves account JWTs by account NKey public key. The server calls
/// <see cref="FetchAsync"/> during client authentication to obtain the
/// account JWT that was previously published by an account operator.
/// </summary>
/// <remarks>
/// Reference: golang/nats-server/server/accounts.go:4035+ — AccountResolver interface
/// and MemAccResolver implementation.
/// </remarks>
public interface IAccountResolver
{
/// <summary>
/// Fetches the JWT for the given account NKey. Returns <c>null</c> when
/// the NKey is not known to this resolver.
/// </summary>
Task<string?> FetchAsync(string accountNkey);
/// <summary>
/// Stores (or replaces) the JWT for the given account NKey. Callers that
/// target a read-only resolver should check <see cref="IsReadOnly"/> first.
/// </summary>
Task StoreAsync(string accountNkey, string jwt);
/// <summary>
/// When <c>true</c>, <see cref="StoreAsync"/> is not supported and will
/// throw <see cref="NotSupportedException"/>. Directory and URL resolvers
/// may be read-only; in-memory resolvers are not.
/// </summary>
bool IsReadOnly { get; }
}
/// <summary>
/// In-memory account resolver backed by a <see cref="ConcurrentDictionary{TKey,TValue}"/>.
/// Suitable for tests and simple single-operator deployments where account JWTs
/// are provided at startup via <see cref="StoreAsync"/>.
/// </summary>
/// <remarks>
/// Reference: golang/nats-server/server/accounts.go — MemAccResolver
/// </remarks>
public sealed class MemAccountResolver : IAccountResolver
{
private readonly ConcurrentDictionary<string, string> _accounts =
new(StringComparer.Ordinal);
/// <inheritdoc/>
public bool IsReadOnly => false;
/// <inheritdoc/>
public Task<string?> FetchAsync(string accountNkey)
{
_accounts.TryGetValue(accountNkey, out var jwt);
return Task.FromResult(jwt);
}
/// <inheritdoc/>
public Task StoreAsync(string accountNkey, string jwt)
{
_accounts[accountNkey] = jwt;
return Task.CompletedTask;
}
}

View File

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