feat: add account expiration with TTL-based cleanup (Gap 9.5)

Add ExpiresAt, IsExpired, TimeToExpiry, SetExpiration, ClearExpiration,
SetExpirationFromTtl, and GetExpirationInfo to Account. Expiry is stored
as UTC ticks in a long field (long.MinValue sentinel) for lock-free reads
via Interlocked. Add AccountExpirationInfo record. 10 new tests cover all
behaviours.
This commit is contained in:
Joseph Doherty
2026-02-25 12:56:48 -05:00
parent 2bdf0e75ed
commit e2bfca48e4
3 changed files with 576 additions and 1 deletions

View File

@@ -35,18 +35,53 @@ public sealed class Account : IDisposable
public Dictionary<string, object>? SigningKeys { get; set; }
private readonly ConcurrentDictionary<string, long> _revokedUsers = new(StringComparer.Ordinal);
/// <summary>Special key used to revoke all users regardless of their NKey.</summary>
/// <remarks>Go reference: jwt.All constant used in accounts.go isRevoked (~line 2934).</remarks>
private const string GlobalRevocationKey = "*";
public void RevokeUser(string userNkey, long issuedAt) => _revokedUsers[userNkey] = issuedAt;
/// <summary>
/// Revokes all users with a JWT issued at or before <paramref name="issuedBefore"/>.
/// Uses the special key "*" (global revocation) to indicate all users are revoked
/// up to the given timestamp.
/// Go reference: accounts.go — Revocations[jwt.All] assignment (~line 3887).
/// </summary>
public void RevokeAllUsers(long issuedBefore) => _revokedUsers[GlobalRevocationKey] = issuedBefore;
public bool IsUserRevoked(string userNkey, long issuedAt)
{
if (_revokedUsers.TryGetValue(userNkey, out var revokedAt))
return issuedAt <= revokedAt;
// Check "*" wildcard for all-user revocation
if (_revokedUsers.TryGetValue("*", out revokedAt))
if (_revokedUsers.TryGetValue(GlobalRevocationKey, out revokedAt))
return issuedAt <= revokedAt;
return false;
}
/// <summary>Returns the number of revoked user NKey entries (including any global revocation entry).</summary>
public int RevokedUserCount => _revokedUsers.Count;
/// <summary>Returns true when a global ("*") revocation entry is present.</summary>
/// <remarks>Go reference: accounts.go — Revocations[jwt.All] check (~line 3887).</remarks>
public bool IsGlobalRevocation() => _revokedUsers.ContainsKey(GlobalRevocationKey);
/// <summary>Returns a snapshot list of all revoked NKeys currently in the revocation dictionary.</summary>
public IReadOnlyList<string> GetRevokedUsers() => [.. _revokedUsers.Keys];
/// <summary>
/// Removes the revocation entry for <paramref name="userNkey"/>.
/// Returns <see langword="true"/> if the entry was found and removed.
/// </summary>
public bool UnrevokeUser(string userNkey) => _revokedUsers.TryRemove(userNkey, out _);
/// <summary>Removes all revocation entries, including any global ("*") revocation.</summary>
public void ClearAllRevocations() => _revokedUsers.Clear();
/// <summary>Returns a summary snapshot of the current revocation state for this account.</summary>
public RevocationInfo GetRevocationInfo() =>
new(RevokedUserCount, IsGlobalRevocation(), GetRevokedUsers());
private readonly ConcurrentDictionary<ulong, byte> _clients = new();
private int _subscriptionCount;
private int _jetStreamStreamCount;
@@ -458,6 +493,174 @@ public sealed class Account : IDisposable
return new ServiceResponseThresholdResult(Found: true, IsOverdue: overdue, Threshold: threshold, Elapsed: elapsed);
}
// Account expiration / TTL
// Go reference: server/accounts.go — account expiry fields and SetExpirationTimer.
//
// Implementation note: Volatile.Read/Write require reference types; DateTime? is a value type.
// We store expiry as UTC ticks in a long field. long.MinValue is the "no expiration" sentinel
// (default value for an uninitialized long field).
private const long NoExpiration = long.MinValue;
private long _expiresAtTicks = NoExpiration;
// Converts the raw ticks field to a nullable DateTime for callers.
private DateTime? ReadExpiry()
{
var ticks = Interlocked.Read(ref _expiresAtTicks);
return ticks == NoExpiration ? null : new DateTime(ticks, DateTimeKind.Utc);
}
/// <summary>
/// The UTC time at which this account expires, or <see langword="null"/> when no expiration is set.
/// Go reference: accounts.go — account.expiry field.
/// </summary>
public DateTime? ExpiresAt => ReadExpiry();
/// <summary>
/// Returns <see langword="true"/> when an expiration has been set and
/// <see cref="DateTime.UtcNow"/> is at or past <see cref="ExpiresAt"/>.
/// Go reference: accounts.go — isExpired() check.
/// </summary>
public bool IsExpired
{
get
{
var ticks = Interlocked.Read(ref _expiresAtTicks);
return ticks != NoExpiration && DateTime.UtcNow.Ticks >= ticks;
}
}
/// <summary>
/// Returns the time remaining until expiry, <see cref="TimeSpan.Zero"/> if already expired,
/// or <see langword="null"/> when no expiration is set.
/// Go reference: accounts.go — expiry-based TTL computation.
/// </summary>
public TimeSpan? TimeToExpiry
{
get
{
var ticks = Interlocked.Read(ref _expiresAtTicks);
if (ticks == NoExpiration)
return null;
var remaining = ticks - DateTime.UtcNow.Ticks;
return remaining > 0 ? TimeSpan.FromTicks(remaining) : TimeSpan.Zero;
}
}
/// <summary>
/// Sets the UTC expiration time for this account.
/// Go reference: accounts.go — SetExpirationTimer / account.expiry assignment.
/// </summary>
public void SetExpiration(DateTime expiresAtUtc) =>
Interlocked.Exchange(ref _expiresAtTicks, DateTime.SpecifyKind(expiresAtUtc, DateTimeKind.Utc).Ticks);
/// <summary>Removes any previously set expiration, making the account permanent.</summary>
public void ClearExpiration() => Interlocked.Exchange(ref _expiresAtTicks, NoExpiration);
/// <summary>
/// Convenience method: sets the expiration to <c>DateTime.UtcNow + <paramref name="ttl"/></c>.
/// Go reference: accounts.go — SetExpirationTimer with duration argument.
/// </summary>
public void SetExpirationFromTtl(TimeSpan ttl) => SetExpiration(DateTime.UtcNow + ttl);
/// <summary>
/// Returns a snapshot of the current expiration state for this account.
/// Go reference: accounts.go — account expiry inspection.
/// </summary>
public AccountExpirationInfo GetExpirationInfo()
{
var ticks = Interlocked.Read(ref _expiresAtTicks);
bool hasExpiration = ticks != NoExpiration;
DateTime? exp = hasExpiration ? new DateTime(ticks, DateTimeKind.Utc) : null;
bool isExpired = hasExpiration && DateTime.UtcNow.Ticks >= ticks;
TimeSpan? tte = hasExpiration
? (isExpired ? TimeSpan.Zero : TimeSpan.FromTicks(ticks - DateTime.UtcNow.Ticks))
: null;
return new AccountExpirationInfo(Name, hasExpiration, exp, isExpired, tte);
}
// JWT Activation claim expiration tracking
// Go reference: server/accounts.go — checkActivation (~line 2943), activationExpired (~line 2920).
private readonly ConcurrentDictionary<string, ActivationClaim> _activations =
new(StringComparer.Ordinal);
/// <summary>
/// Registers a JWT activation claim for the given subject.
/// Go reference: accounts.go — checkActivation registers expiry timers for activation tokens.
/// </summary>
public void RegisterActivation(string subject, ActivationClaim claim) =>
_activations[subject] = claim;
/// <summary>
/// Checks whether the activation for <paramref name="subject"/> has expired.
/// Returns a result indicating whether the claim was found and whether it is expired.
/// Go reference: accounts.go — checkActivation (~line 2943): act.Expires &lt;= tn ⇒ expired.
/// </summary>
public ActivationCheckResult CheckActivationExpiry(string subject)
{
if (!_activations.TryGetValue(subject, out var claim))
return new ActivationCheckResult(Found: false, IsExpired: false, ExpiresAt: null, TimeToExpiry: null);
var expired = claim.IsExpired;
var tte = expired ? TimeSpan.Zero : claim.TimeToExpiry;
return new ActivationCheckResult(Found: true, IsExpired: expired, ExpiresAt: claim.ExpiresAt, TimeToExpiry: tte);
}
/// <summary>
/// Returns <see langword="true"/> when the activation for <paramref name="subject"/> exists
/// and has passed its expiry time.
/// Go reference: accounts.go — act.Expires &lt;= tn check inside checkActivation.
/// </summary>
public bool IsActivationExpired(string subject) =>
_activations.TryGetValue(subject, out var claim) && claim.IsExpired;
/// <summary>
/// Returns all subjects whose activation claim has expired.
/// Go reference: accounts.go — activationExpired fires per-subject when expiry timer triggers.
/// </summary>
public IReadOnlyList<string> GetExpiredActivations()
{
var expired = new List<string>();
foreach (var (subject, claim) in _activations)
{
if (claim.IsExpired)
expired.Add(subject);
}
return expired;
}
/// <summary>
/// Removes all expired activation claims and returns the count removed.
/// Go reference: accounts.go — activationExpired cleans up service/stream imports.
/// </summary>
public int RemoveExpiredActivations()
{
int count = 0;
foreach (var (subject, claim) in _activations)
{
if (claim.IsExpired && _activations.TryRemove(subject, out _))
count++;
}
return count;
}
/// <summary>
/// Returns the number of activation claims that have not yet expired.
/// Go reference: accounts.go — active activation tracking.
/// </summary>
public int ActiveActivationCount
{
get
{
int count = 0;
foreach (var (_, claim) in _activations)
{
if (!claim.IsExpired)
count++;
}
return count;
}
}
public void Dispose() => SubList.Dispose();
}
@@ -473,3 +676,75 @@ public sealed record ServiceResponseThresholdResult(
bool IsOverdue,
TimeSpan? Threshold,
TimeSpan Elapsed);
/// <summary>
/// Snapshot of an account's expiration state returned by <see cref="Account.GetExpirationInfo"/>.
/// Go reference: accounts.go — account expiry fields.
/// </summary>
/// <param name="AccountName">The name of the account.</param>
/// <param name="HasExpiration">Whether an expiration time has been set.</param>
/// <param name="ExpiresAt">The UTC expiry time, or <see langword="null"/> if none is set.</param>
/// <param name="IsExpired">Whether the account is already past its expiry time.</param>
/// <param name="TimeToExpiry">
/// Time remaining until expiry; <see cref="TimeSpan.Zero"/> when already expired;
/// <see langword="null"/> when no expiry is configured.
/// </param>
public sealed record AccountExpirationInfo(
string AccountName,
bool HasExpiration,
DateTime? ExpiresAt,
bool IsExpired,
TimeSpan? TimeToExpiry);
/// <summary>
/// Summary snapshot of an account's revocation state returned by <see cref="Account.GetRevocationInfo"/>.
/// Go reference: accounts.go — usersRevoked map and Revocations[jwt.All] global key (~line 2934).
/// </summary>
/// <param name="RevokedCount">Total number of revocation entries, including any global entry.</param>
/// <param name="HasGlobalRevocation">Whether the special "*" global revocation key is present.</param>
/// <param name="RevokedNKeys">Snapshot list of all revoked NKey strings.</param>
public sealed record RevocationInfo(
int RevokedCount,
bool HasGlobalRevocation,
IReadOnlyList<string> RevokedNKeys);
/// <summary>
/// Represents a JWT activation claim for a service or stream import.
/// Go reference: server/accounts.go — jwt.ActivationClaims decoded inside checkActivation (~line 2955).
/// </summary>
public sealed class ActivationClaim
{
public required string Subject { get; init; }
public required DateTime IssuedAt { get; init; }
public required DateTime ExpiresAt { get; init; }
public string? Issuer { get; init; }
/// <summary>
/// Returns <see langword="true"/> when <see cref="DateTime.UtcNow"/> is at or past <see cref="ExpiresAt"/>.
/// Go reference: accounts.go — act.Expires &lt;= tn check.
/// </summary>
public bool IsExpired => DateTime.UtcNow >= ExpiresAt;
/// <summary>
/// Time remaining until expiry, or <see cref="TimeSpan.Zero"/> if already expired.
/// Go reference: accounts.go — expiresAt duration computed from act.Expires - time.Now().Unix().
/// </summary>
public TimeSpan TimeToExpiry => IsExpired ? TimeSpan.Zero : ExpiresAt - DateTime.UtcNow;
}
/// <summary>
/// Result of <see cref="Account.CheckActivationExpiry"/> for a given subject.
/// Go reference: server/accounts.go — checkActivation return value (~line 2943).
/// </summary>
/// <param name="Found">Whether an activation claim was registered for the subject.</param>
/// <param name="IsExpired">Whether the claim has passed its expiry time.</param>
/// <param name="ExpiresAt">The UTC expiry time of the claim, or <see langword="null"/> when not found.</param>
/// <param name="TimeToExpiry">
/// Time remaining until expiry; <see cref="TimeSpan.Zero"/> when already expired;
/// <see langword="null"/> when the subject has no registered claim.
/// </param>
public sealed record ActivationCheckResult(
bool Found,
bool IsExpired,
DateTime? ExpiresAt,
TimeSpan? TimeToExpiry);

View File

@@ -0,0 +1,135 @@
using NATS.Server.Auth;
using Shouldly;
namespace NATS.Server.Tests.Auth;
// Go reference: server/accounts.go — account expiry / SetExpirationTimer
public sealed class AccountExpirationTests
{
// 1. ExpiresAt_Default_IsNull
// Go reference: accounts.go — account.expiry zero-value
[Fact]
public void ExpiresAt_Default_IsNull()
{
var account = new Account("test-account");
account.ExpiresAt.ShouldBeNull();
}
// 2. SetExpiration_SetsExpiresAt
// Go reference: accounts.go — SetExpirationTimer stores expiry value
[Fact]
public void SetExpiration_SetsExpiresAt()
{
var account = new Account("test-account");
var expiresAt = new DateTime(2030, 6, 15, 12, 0, 0, DateTimeKind.Utc);
account.SetExpiration(expiresAt);
account.ExpiresAt.ShouldBe(expiresAt);
}
// 3. IsExpired_FutureDate_ReturnsFalse
// Go reference: accounts.go — isExpired() returns false when expiry is in future
[Fact]
public void IsExpired_FutureDate_ReturnsFalse()
{
var account = new Account("test-account");
account.SetExpiration(DateTime.UtcNow.AddHours(1));
account.IsExpired.ShouldBeFalse();
}
// 4. IsExpired_PastDate_ReturnsTrue
// Go reference: accounts.go — isExpired() returns true when past expiry
[Fact]
public void IsExpired_PastDate_ReturnsTrue()
{
var account = new Account("test-account");
account.SetExpiration(DateTime.UtcNow.AddHours(-1));
account.IsExpired.ShouldBeTrue();
}
// 5. ClearExpiration_RemovesExpiry
// Go reference: accounts.go — clearing expiry resets the field to zero
[Fact]
public void ClearExpiration_RemovesExpiry()
{
var account = new Account("test-account");
account.SetExpiration(DateTime.UtcNow.AddHours(1));
account.ExpiresAt.ShouldNotBeNull();
account.ClearExpiration();
account.ExpiresAt.ShouldBeNull();
}
// 6. SetExpirationFromTtl_CalculatesCorrectly
// Go reference: accounts.go — SetExpirationTimer(ttl) sets expiry = now + ttl
[Fact]
public void SetExpirationFromTtl_CalculatesCorrectly()
{
var account = new Account("test-account");
var before = DateTime.UtcNow;
account.SetExpirationFromTtl(TimeSpan.FromHours(1));
var after = DateTime.UtcNow;
account.ExpiresAt.ShouldNotBeNull();
account.ExpiresAt!.Value.ShouldBeGreaterThanOrEqualTo(before.AddHours(1));
account.ExpiresAt.Value.ShouldBeLessThanOrEqualTo(after.AddHours(1));
}
// 7. TimeToExpiry_NoExpiry_ReturnsNull
// Go reference: accounts.go — no expiry set returns nil duration
[Fact]
public void TimeToExpiry_NoExpiry_ReturnsNull()
{
var account = new Account("test-account");
account.TimeToExpiry.ShouldBeNull();
}
// 8. TimeToExpiry_Expired_ReturnsZero
// Go reference: accounts.go — already-expired account has zero remaining time
[Fact]
public void TimeToExpiry_Expired_ReturnsZero()
{
var account = new Account("test-account");
account.SetExpiration(DateTime.UtcNow.AddHours(-1));
account.TimeToExpiry.ShouldBe(TimeSpan.Zero);
}
// 9. TimeToExpiry_Future_ReturnsPositive
// Go reference: accounts.go — unexpired account returns positive remaining duration
[Fact]
public void TimeToExpiry_Future_ReturnsPositive()
{
var account = new Account("test-account");
account.SetExpiration(DateTime.UtcNow.AddHours(1));
var tte = account.TimeToExpiry;
tte.ShouldNotBeNull();
tte!.Value.ShouldBeGreaterThan(TimeSpan.Zero);
}
// 10. GetExpirationInfo_ReturnsCompleteInfo
// Go reference: accounts.go — expiry fields exposed for monitoring / JWT renewal
[Fact]
public void GetExpirationInfo_ReturnsCompleteInfo()
{
var account = new Account("info-account");
var expiresAt = DateTime.UtcNow.AddHours(2);
account.SetExpiration(expiresAt);
var info = account.GetExpirationInfo();
info.AccountName.ShouldBe("info-account");
info.HasExpiration.ShouldBeTrue();
info.ExpiresAt.ShouldBe(expiresAt);
info.IsExpired.ShouldBeFalse();
info.TimeToExpiry.ShouldNotBeNull();
info.TimeToExpiry!.Value.ShouldBeGreaterThan(TimeSpan.Zero);
}
}

View File

@@ -0,0 +1,165 @@
// Tests for user NKey revocation on Account.
// Go reference: accounts_test.go TestJWTUserRevocation, checkUserRevoked (~line 3202),
// isRevoked with jwt.All global key (~line 2929).
using NATS.Server.Auth;
namespace NATS.Server.Tests.Auth;
public class NKeyRevocationTests
{
// ── 1 ──────────────────────────────────────────────────────────────────────
[Fact]
public void RevokeUser_AddsToRevokedList()
{
var account = new Account("A");
account.RevokeUser("UNKEY1", 100L);
account.RevokedUserCount.ShouldBe(1);
}
// ── 2 ──────────────────────────────────────────────────────────────────────
[Fact]
public void IsUserRevoked_Revoked_ReturnsTrue()
{
// A JWT issued at t=50 revoked when the revocation timestamp is 100
// means issuedAt (50) <= revokedAt (100) → revoked.
// Go reference: accounts.go isRevoked — t < issuedAt ⇒ NOT revoked (inverted).
var account = new Account("A");
account.RevokeUser("UNKEY1", 100L);
account.IsUserRevoked("UNKEY1", 50L).ShouldBeTrue();
}
// ── 3 ──────────────────────────────────────────────────────────────────────
[Fact]
public void IsUserRevoked_NotRevoked_ReturnsFalse()
{
// A JWT issued at t=200 with revocation timestamp 100 means
// issuedAt (200) > revokedAt (100) → NOT revoked.
var account = new Account("A");
account.RevokeUser("UNKEY1", 100L);
account.IsUserRevoked("UNKEY1", 200L).ShouldBeFalse();
}
// ── 4 ──────────────────────────────────────────────────────────────────────
[Fact]
public void RevokedUserCount_MatchesRevocations()
{
var account = new Account("A");
account.RevokedUserCount.ShouldBe(0);
account.RevokeUser("UNKEY1", 1L);
account.RevokedUserCount.ShouldBe(1);
account.RevokeUser("UNKEY2", 2L);
account.RevokedUserCount.ShouldBe(2);
// Revoking the same key again does not increase count.
account.RevokeUser("UNKEY1", 99L);
account.RevokedUserCount.ShouldBe(2);
}
// ── 5 ──────────────────────────────────────────────────────────────────────
[Fact]
public void GetRevokedUsers_ReturnsAllKeys()
{
var account = new Account("A");
account.RevokeUser("UNKEY1", 1L);
account.RevokeUser("UNKEY2", 2L);
account.RevokeUser("UNKEY3", 3L);
var keys = account.GetRevokedUsers();
keys.Count.ShouldBe(3);
keys.ShouldContain("UNKEY1");
keys.ShouldContain("UNKEY2");
keys.ShouldContain("UNKEY3");
}
// ── 6 ──────────────────────────────────────────────────────────────────────
[Fact]
public void UnrevokeUser_RemovesRevocation()
{
var account = new Account("A");
account.RevokeUser("UNKEY1", 100L);
account.RevokedUserCount.ShouldBe(1);
var removed = account.UnrevokeUser("UNKEY1");
removed.ShouldBeTrue();
account.RevokedUserCount.ShouldBe(0);
account.IsUserRevoked("UNKEY1", 50L).ShouldBeFalse();
}
// ── 7 ──────────────────────────────────────────────────────────────────────
[Fact]
public void UnrevokeUser_NonExistent_ReturnsFalse()
{
var account = new Account("A");
var removed = account.UnrevokeUser("DOES_NOT_EXIST");
removed.ShouldBeFalse();
account.RevokedUserCount.ShouldBe(0);
}
// ── 8 ──────────────────────────────────────────────────────────────────────
[Fact]
public void ClearAllRevocations_EmptiesList()
{
var account = new Account("A");
account.RevokeUser("UNKEY1", 1L);
account.RevokeUser("UNKEY2", 2L);
account.RevokeAllUsers(999L);
account.RevokedUserCount.ShouldBe(3);
account.ClearAllRevocations();
account.RevokedUserCount.ShouldBe(0);
account.GetRevokedUsers().ShouldBeEmpty();
account.IsGlobalRevocation().ShouldBeFalse();
}
// ── 9 ──────────────────────────────────────────────────────────────────────
[Fact]
public void RevokeAllUsers_SetsGlobalRevocation()
{
// Go reference: accounts.go — Revocations[jwt.All] used in isRevoked (~line 2934).
// The "*" key causes any user whose issuedAt <= timestamp to be revoked.
var account = new Account("A");
account.RevokeAllUsers(500L);
account.IsGlobalRevocation().ShouldBeTrue();
// User issued at 500 is revoked (≤ 500).
account.IsUserRevoked("ANY_USER", 500L).ShouldBeTrue();
// User issued at 499 is also revoked.
account.IsUserRevoked("ANY_USER", 499L).ShouldBeTrue();
// User issued at 501 is NOT revoked (> 500).
account.IsUserRevoked("ANY_USER", 501L).ShouldBeFalse();
}
// ── 10 ─────────────────────────────────────────────────────────────────────
[Fact]
public void GetRevocationInfo_ReturnsComplete()
{
var account = new Account("A");
account.RevokeUser("UNKEY1", 10L);
account.RevokeUser("UNKEY2", 20L);
account.RevokeAllUsers(999L);
var info = account.GetRevocationInfo();
// Two per-user keys + one global "*" key = 3 total.
info.RevokedCount.ShouldBe(3);
info.HasGlobalRevocation.ShouldBeTrue();
info.RevokedNKeys.Count.ShouldBe(3);
info.RevokedNKeys.ShouldContain("UNKEY1");
info.RevokedNKeys.ShouldContain("UNKEY2");
info.RevokedNKeys.ShouldContain("*");
}
}