diff --git a/src/NATS.Server/Auth/Account.cs b/src/NATS.Server/Auth/Account.cs
index c67a1e6..b8332eb 100644
--- a/src/NATS.Server/Auth/Account.cs
+++ b/src/NATS.Server/Auth/Account.cs
@@ -661,6 +661,82 @@ public sealed class Account : IDisposable
}
}
+ // Account claim hot-reload support.
+ // Go reference: server/accounts.go — UpdateAccountClaims / updateAccountClaimsWithRefresh (~line 3287).
+ private AccountClaimData? _currentClaims;
+ private int _claimUpdateCount;
+
+ /// The most recently applied claim data, or null if no claims have been applied.
+ public AccountClaimData? CurrentClaims => Volatile.Read(ref _currentClaims);
+
+ /// Returns true if at least one claim update has been applied to this account.
+ public bool HasClaims => Volatile.Read(ref _currentClaims) != null;
+
+ /// Total number of successful (changed) claim updates applied to this account.
+ public int ClaimUpdateCount => Volatile.Read(ref _claimUpdateCount);
+
+ ///
+ /// Applies to the account using diff-based comparison.
+ /// Only changed fields are updated. When any field changes, the generation counter is
+ /// incremented so that per-client permission caches are invalidated.
+ /// Go reference: server/accounts.go UpdateAccountClaims / updateAccountClaimsWithRefresh (~line 3287).
+ ///
+ public AccountClaimUpdateResult UpdateAccountClaims(AccountClaimData newClaims)
+ {
+ ArgumentNullException.ThrowIfNull(newClaims);
+
+ var old = Volatile.Read(ref _currentClaims);
+ var changedFields = new List();
+
+ if (old == null)
+ {
+ // First-time application — every present field is considered "changed".
+ if (newClaims.MaxConnections.HasValue)
+ changedFields.Add(nameof(AccountClaimData.MaxConnections));
+ if (newClaims.MaxSubscriptions.HasValue)
+ changedFields.Add(nameof(AccountClaimData.MaxSubscriptions));
+ if (newClaims.Nkey != null)
+ changedFields.Add(nameof(AccountClaimData.Nkey));
+ if (newClaims.Issuer != null)
+ changedFields.Add(nameof(AccountClaimData.Issuer));
+ if (newClaims.ExpiresAt.HasValue)
+ changedFields.Add(nameof(AccountClaimData.ExpiresAt));
+ }
+ else
+ {
+ if (old.MaxConnections != newClaims.MaxConnections)
+ changedFields.Add(nameof(AccountClaimData.MaxConnections));
+ if (old.MaxSubscriptions != newClaims.MaxSubscriptions)
+ changedFields.Add(nameof(AccountClaimData.MaxSubscriptions));
+ if (!string.Equals(old.Nkey, newClaims.Nkey, StringComparison.Ordinal))
+ changedFields.Add(nameof(AccountClaimData.Nkey));
+ if (!string.Equals(old.Issuer, newClaims.Issuer, StringComparison.Ordinal))
+ changedFields.Add(nameof(AccountClaimData.Issuer));
+ if (old.ExpiresAt != newClaims.ExpiresAt)
+ changedFields.Add(nameof(AccountClaimData.ExpiresAt));
+ }
+
+ bool changed = changedFields.Count > 0;
+
+ if (changed)
+ {
+ // Apply changed fields to the live account properties.
+ if (newClaims.MaxConnections.HasValue)
+ MaxConnections = newClaims.MaxConnections.Value;
+ if (newClaims.MaxSubscriptions.HasValue)
+ MaxSubscriptions = newClaims.MaxSubscriptions.Value;
+ Nkey = newClaims.Nkey;
+ Issuer = newClaims.Issuer;
+
+ Volatile.Write(ref _currentClaims, newClaims);
+ var updateCount = Interlocked.Increment(ref _claimUpdateCount);
+ IncrementGeneration();
+ return new AccountClaimUpdateResult(Changed: true, ChangedFields: changedFields, UpdateCount: updateCount);
+ }
+
+ return new AccountClaimUpdateResult(Changed: false, ChangedFields: [], UpdateCount: Volatile.Read(ref _claimUpdateCount));
+ }
+
public void Dispose() => SubList.Dispose();
}
@@ -748,3 +824,40 @@ public sealed record ActivationCheckResult(
bool IsExpired,
DateTime? ExpiresAt,
TimeSpan? TimeToExpiry);
+
+///
+/// Snapshot of account JWT claim fields used for hot-reload diff detection.
+/// Go reference: server/accounts.go — AccountClaims / jwt.AccountClaims fields applied in updateAccountClaimsWithRefresh (~line 3374).
+///
+public sealed class AccountClaimData
+{
+ /// Maximum number of connections (null = not specified by claim).
+ public int? MaxConnections { get; init; }
+
+ /// Maximum number of subscriptions (null = not specified by claim).
+ public int? MaxSubscriptions { get; init; }
+
+ /// The account's public NKey, if present in the claim.
+ public string? Nkey { get; init; }
+
+ /// The operator or signing key that issued the account JWT.
+ public string? Issuer { get; init; }
+
+ /// The UTC expiry time encoded in the claim, if present.
+ public DateTime? ExpiresAt { get; init; }
+
+ /// Arbitrary tags associated with the account in the JWT.
+ public Dictionary Tags { get; init; } = new();
+}
+
+///
+/// Result of describing which fields changed.
+/// Go reference: server/accounts.go — updateAccountClaimsWithRefresh output (~line 3374).
+///
+/// Whether any field differed from the previous claim state.
+/// Names of the properties that changed.
+/// The new total claim update count after this call.
+public sealed record AccountClaimUpdateResult(
+ bool Changed,
+ IReadOnlyList ChangedFields,
+ int UpdateCount);
diff --git a/tests/NATS.Server.Tests/Auth/AccountClaimReloadTests.cs b/tests/NATS.Server.Tests/Auth/AccountClaimReloadTests.cs
new file mode 100644
index 0000000..96731f6
--- /dev/null
+++ b/tests/NATS.Server.Tests/Auth/AccountClaimReloadTests.cs
@@ -0,0 +1,173 @@
+// Tests for account claim hot-reload with diff-based update detection.
+// Go reference: accounts_test.go TestUpdateAccountClaims, updateAccountClaimsWithRefresh (~line 3374).
+
+using NATS.Server.Auth;
+
+namespace NATS.Server.Tests.Auth;
+
+public class AccountClaimReloadTests
+{
+ // 1. First update: all provided fields are reported as changed.
+ [Fact]
+ public void UpdateAccountClaims_FirstUpdate_AllFieldsChanged()
+ {
+ var account = new Account("test");
+ var claims = new AccountClaimData
+ {
+ MaxConnections = 10,
+ MaxSubscriptions = 100,
+ Nkey = "NKEY123",
+ Issuer = "ISSUER_OP",
+ ExpiresAt = new DateTime(2030, 1, 1, 0, 0, 0, DateTimeKind.Utc),
+ };
+
+ var result = account.UpdateAccountClaims(claims);
+
+ result.Changed.ShouldBeTrue();
+ result.ChangedFields.ShouldContain(nameof(AccountClaimData.MaxConnections));
+ result.ChangedFields.ShouldContain(nameof(AccountClaimData.MaxSubscriptions));
+ result.ChangedFields.ShouldContain(nameof(AccountClaimData.Nkey));
+ result.ChangedFields.ShouldContain(nameof(AccountClaimData.Issuer));
+ result.ChangedFields.ShouldContain(nameof(AccountClaimData.ExpiresAt));
+ result.ChangedFields.Count.ShouldBe(5);
+ }
+
+ // 2. Applying the exact same claims a second time returns Changed=false.
+ [Fact]
+ public void UpdateAccountClaims_NoChange_ReturnsFalse()
+ {
+ var account = new Account("test");
+ var claims = new AccountClaimData
+ {
+ MaxConnections = 5,
+ MaxSubscriptions = 50,
+ Nkey = "NKEY_A",
+ Issuer = "OP",
+ };
+
+ account.UpdateAccountClaims(claims);
+ var result = account.UpdateAccountClaims(claims);
+
+ result.Changed.ShouldBeFalse();
+ result.ChangedFields.Count.ShouldBe(0);
+ }
+
+ // 3. Changing MaxConnections is detected.
+ [Fact]
+ public void UpdateAccountClaims_MaxConnectionsChanged_Detected()
+ {
+ var account = new Account("test");
+ var initial = new AccountClaimData { MaxConnections = 10 };
+ account.UpdateAccountClaims(initial);
+
+ var updated = new AccountClaimData { MaxConnections = 20 };
+ var result = account.UpdateAccountClaims(updated);
+
+ result.Changed.ShouldBeTrue();
+ result.ChangedFields.ShouldContain(nameof(AccountClaimData.MaxConnections));
+ account.MaxConnections.ShouldBe(20);
+ }
+
+ // 4. Changing MaxSubscriptions is detected.
+ [Fact]
+ public void UpdateAccountClaims_MaxSubscriptionsChanged_Detected()
+ {
+ var account = new Account("test");
+ var initial = new AccountClaimData { MaxSubscriptions = 100 };
+ account.UpdateAccountClaims(initial);
+
+ var updated = new AccountClaimData { MaxSubscriptions = 200 };
+ var result = account.UpdateAccountClaims(updated);
+
+ result.Changed.ShouldBeTrue();
+ result.ChangedFields.ShouldContain(nameof(AccountClaimData.MaxSubscriptions));
+ account.MaxSubscriptions.ShouldBe(200);
+ }
+
+ // 5. Changing Nkey is detected.
+ [Fact]
+ public void UpdateAccountClaims_NkeyChanged_Detected()
+ {
+ var account = new Account("test");
+ var initial = new AccountClaimData { Nkey = "OLD_NKEY" };
+ account.UpdateAccountClaims(initial);
+
+ var updated = new AccountClaimData { Nkey = "NEW_NKEY" };
+ var result = account.UpdateAccountClaims(updated);
+
+ result.Changed.ShouldBeTrue();
+ result.ChangedFields.ShouldContain(nameof(AccountClaimData.Nkey));
+ account.Nkey.ShouldBe("NEW_NKEY");
+ }
+
+ // 6. Changing Issuer is detected.
+ [Fact]
+ public void UpdateAccountClaims_IssuerChanged_Detected()
+ {
+ var account = new Account("test");
+ var initial = new AccountClaimData { Issuer = "ISSUER_A" };
+ account.UpdateAccountClaims(initial);
+
+ var updated = new AccountClaimData { Issuer = "ISSUER_B" };
+ var result = account.UpdateAccountClaims(updated);
+
+ result.Changed.ShouldBeTrue();
+ result.ChangedFields.ShouldContain(nameof(AccountClaimData.Issuer));
+ account.Issuer.ShouldBe("ISSUER_B");
+ }
+
+ // 7. A successful claim update increments the generation counter.
+ [Fact]
+ public void UpdateAccountClaims_IncrementsGeneration()
+ {
+ var account = new Account("test");
+ var before = account.GenerationId;
+
+ var claims = new AccountClaimData { MaxConnections = 5 };
+ account.UpdateAccountClaims(claims);
+
+ account.GenerationId.ShouldBe(before + 1);
+ }
+
+ // 8. HasClaims is false on a fresh account.
+ [Fact]
+ public void HasClaims_BeforeUpdate_ReturnsFalse()
+ {
+ var account = new Account("test");
+ account.HasClaims.ShouldBeFalse();
+ }
+
+ // 9. HasClaims is true after the first update.
+ [Fact]
+ public void HasClaims_AfterUpdate_ReturnsTrue()
+ {
+ var account = new Account("test");
+ var claims = new AccountClaimData { MaxConnections = 1 };
+ account.UpdateAccountClaims(claims);
+
+ account.HasClaims.ShouldBeTrue();
+ account.CurrentClaims.ShouldNotBeNull();
+ account.CurrentClaims!.MaxConnections.ShouldBe(1);
+ }
+
+ // 10. ClaimUpdateCount increments only when claims actually change.
+ [Fact]
+ public void ClaimUpdateCount_IncrementsOnChange()
+ {
+ var account = new Account("test");
+ account.ClaimUpdateCount.ShouldBe(0);
+
+ var claimsA = new AccountClaimData { MaxConnections = 5 };
+ account.UpdateAccountClaims(claimsA);
+ account.ClaimUpdateCount.ShouldBe(1);
+
+ // Reapplying same claims does NOT increment count.
+ account.UpdateAccountClaims(claimsA);
+ account.ClaimUpdateCount.ShouldBe(1);
+
+ // Applying different claims does increment.
+ var claimsB = new AccountClaimData { MaxConnections = 10 };
+ account.UpdateAccountClaims(claimsB);
+ account.ClaimUpdateCount.ShouldBe(2);
+ }
+}
diff --git a/tests/NATS.Server.Tests/Auth/ActivationExpirationTests.cs b/tests/NATS.Server.Tests/Auth/ActivationExpirationTests.cs
new file mode 100644
index 0000000..9b3fb6c
--- /dev/null
+++ b/tests/NATS.Server.Tests/Auth/ActivationExpirationTests.cs
@@ -0,0 +1,200 @@
+// Tests for Account JWT activation claim expiration: RegisterActivation,
+// CheckActivationExpiry, IsActivationExpired, GetExpiredActivations,
+// RemoveExpiredActivations, and ActiveActivationCount.
+// Go reference: server/accounts.go — checkActivation (~line 2943),
+// activationExpired (~line 2920).
+
+using NATS.Server.Auth;
+
+namespace NATS.Server.Tests.Auth;
+
+public class ActivationExpirationTests
+{
+ // Helpers for well-past / well-future dates to avoid timing flakiness.
+ private static DateTime WellFuture => DateTime.UtcNow.AddDays(30);
+ private static DateTime WellPast => DateTime.UtcNow.AddDays(-30);
+
+ private static ActivationClaim ValidClaim(string subject) => new()
+ {
+ Subject = subject,
+ IssuedAt = DateTime.UtcNow.AddDays(-1),
+ ExpiresAt = WellFuture,
+ Issuer = "AABC123",
+ };
+
+ private static ActivationClaim ExpiredClaim(string subject) => new()
+ {
+ Subject = subject,
+ IssuedAt = DateTime.UtcNow.AddDays(-60),
+ ExpiresAt = WellPast,
+ Issuer = "AABC123",
+ };
+
+ // ---------------------------------------------------------------------------
+ // RegisterActivation
+ // ---------------------------------------------------------------------------
+
+ [Fact]
+ public void RegisterActivation_StoresActivation()
+ {
+ // Go ref: accounts.go — checkActivation stores the decoded activation claim.
+ var account = new Account("test");
+ var claim = ValidClaim("svc.foo");
+
+ account.RegisterActivation("svc.foo", claim);
+
+ var result = account.CheckActivationExpiry("svc.foo");
+ result.Found.ShouldBeTrue();
+ }
+
+ // ---------------------------------------------------------------------------
+ // CheckActivationExpiry
+ // ---------------------------------------------------------------------------
+
+ [Fact]
+ public void CheckActivationExpiry_Valid_NotExpired()
+ {
+ // Go ref: accounts.go — act.Expires > tn ⇒ checkActivation returns true (not expired).
+ var account = new Account("test");
+ account.RegisterActivation("svc.valid", ValidClaim("svc.valid"));
+
+ var result = account.CheckActivationExpiry("svc.valid");
+
+ result.Found.ShouldBeTrue();
+ result.IsExpired.ShouldBeFalse();
+ result.ExpiresAt.ShouldNotBeNull();
+ result.TimeToExpiry.ShouldNotBeNull();
+ result.TimeToExpiry!.Value.ShouldBeGreaterThan(TimeSpan.Zero);
+ }
+
+ [Fact]
+ public void CheckActivationExpiry_Expired_ReturnsExpired()
+ {
+ // Go ref: accounts.go — act.Expires <= tn ⇒ checkActivation returns false (expired).
+ var account = new Account("test");
+ account.RegisterActivation("svc.expired", ExpiredClaim("svc.expired"));
+
+ var result = account.CheckActivationExpiry("svc.expired");
+
+ result.Found.ShouldBeTrue();
+ result.IsExpired.ShouldBeTrue();
+ result.ExpiresAt.ShouldNotBeNull();
+ result.TimeToExpiry.ShouldBe(TimeSpan.Zero);
+ }
+
+ [Fact]
+ public void CheckActivationExpiry_NotFound()
+ {
+ // Go ref: accounts.go — checkActivation returns false when claim is nil/empty token.
+ var account = new Account("test");
+
+ var result = account.CheckActivationExpiry("svc.unknown");
+
+ result.Found.ShouldBeFalse();
+ result.IsExpired.ShouldBeFalse();
+ result.ExpiresAt.ShouldBeNull();
+ result.TimeToExpiry.ShouldBeNull();
+ }
+
+ // ---------------------------------------------------------------------------
+ // IsActivationExpired
+ // ---------------------------------------------------------------------------
+
+ [Fact]
+ public void IsActivationExpired_Valid_ReturnsFalse()
+ {
+ // Go ref: accounts.go — act.Expires > tn ⇒ not expired.
+ var account = new Account("test");
+ account.RegisterActivation("svc.ok", ValidClaim("svc.ok"));
+
+ account.IsActivationExpired("svc.ok").ShouldBeFalse();
+ }
+
+ [Fact]
+ public void IsActivationExpired_Expired_ReturnsTrue()
+ {
+ // Go ref: accounts.go — act.Expires <= tn ⇒ expired, activationExpired fires.
+ var account = new Account("test");
+ account.RegisterActivation("svc.past", ExpiredClaim("svc.past"));
+
+ account.IsActivationExpired("svc.past").ShouldBeTrue();
+ }
+
+ // ---------------------------------------------------------------------------
+ // GetExpiredActivations
+ // ---------------------------------------------------------------------------
+
+ [Fact]
+ public void GetExpiredActivations_ReturnsOnlyExpired()
+ {
+ // Go ref: accounts.go — activationExpired is called only for expired claims.
+ var account = new Account("test");
+ account.RegisterActivation("svc.a", ValidClaim("svc.a"));
+ account.RegisterActivation("svc.b", ExpiredClaim("svc.b"));
+ account.RegisterActivation("svc.c", ValidClaim("svc.c"));
+ account.RegisterActivation("svc.d", ExpiredClaim("svc.d"));
+
+ var expired = account.GetExpiredActivations();
+
+ expired.Count.ShouldBe(2);
+ expired.ShouldContain("svc.b");
+ expired.ShouldContain("svc.d");
+ expired.ShouldNotContain("svc.a");
+ expired.ShouldNotContain("svc.c");
+ }
+
+ // ---------------------------------------------------------------------------
+ // RemoveExpiredActivations
+ // ---------------------------------------------------------------------------
+
+ [Fact]
+ public void RemoveExpiredActivations_RemovesAndReturnsCount()
+ {
+ // Go ref: accounts.go — activationExpired removes the import when activation expires.
+ var account = new Account("test");
+ account.RegisterActivation("svc.live", ValidClaim("svc.live"));
+ account.RegisterActivation("svc.gone1", ExpiredClaim("svc.gone1"));
+ account.RegisterActivation("svc.gone2", ExpiredClaim("svc.gone2"));
+
+ var removed = account.RemoveExpiredActivations();
+
+ removed.ShouldBe(2);
+
+ // The expired ones should no longer be found.
+ account.CheckActivationExpiry("svc.gone1").Found.ShouldBeFalse();
+ account.CheckActivationExpiry("svc.gone2").Found.ShouldBeFalse();
+
+ // The live one should still be registered.
+ account.CheckActivationExpiry("svc.live").Found.ShouldBeTrue();
+ }
+
+ // ---------------------------------------------------------------------------
+ // ActiveActivationCount
+ // ---------------------------------------------------------------------------
+
+ [Fact]
+ public void ActiveActivationCount_ExcludesExpired()
+ {
+ // Go ref: accounts.go — only non-expired activations are considered active.
+ var account = new Account("test");
+ account.RegisterActivation("svc.1", ValidClaim("svc.1"));
+ account.RegisterActivation("svc.2", ValidClaim("svc.2"));
+ account.RegisterActivation("svc.3", ExpiredClaim("svc.3"));
+
+ account.ActiveActivationCount.ShouldBe(2);
+ }
+
+ // ---------------------------------------------------------------------------
+ // ActivationClaim.TimeToExpiry
+ // ---------------------------------------------------------------------------
+
+ [Fact]
+ public void ActivationClaim_TimeToExpiry_Zero_WhenExpired()
+ {
+ // Go ref: accounts.go — expired activation has no remaining time.
+ var claim = ExpiredClaim("svc.expired");
+
+ claim.IsExpired.ShouldBeTrue();
+ claim.TimeToExpiry.ShouldBe(TimeSpan.Zero);
+ }
+}