From e2bfca48e428153fb0bab4f53671f4379dbcd4b3 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 25 Feb 2026 12:56:48 -0500 Subject: [PATCH] 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. --- src/NATS.Server/Auth/Account.cs | 277 +++++++++++++++++- .../Auth/AccountExpirationTests.cs | 135 +++++++++ .../Auth/NKeyRevocationTests.cs | 165 +++++++++++ 3 files changed, 576 insertions(+), 1 deletion(-) create mode 100644 tests/NATS.Server.Tests/Auth/AccountExpirationTests.cs create mode 100644 tests/NATS.Server.Tests/Auth/NKeyRevocationTests.cs diff --git a/src/NATS.Server/Auth/Account.cs b/src/NATS.Server/Auth/Account.cs index 00bd613..c67a1e6 100644 --- a/src/NATS.Server/Auth/Account.cs +++ b/src/NATS.Server/Auth/Account.cs @@ -35,18 +35,53 @@ public sealed class Account : IDisposable public Dictionary? SigningKeys { get; set; } private readonly ConcurrentDictionary _revokedUsers = new(StringComparer.Ordinal); + /// Special key used to revoke all users regardless of their NKey. + /// Go reference: jwt.All constant used in accounts.go isRevoked (~line 2934). + private const string GlobalRevocationKey = "*"; + public void RevokeUser(string userNkey, long issuedAt) => _revokedUsers[userNkey] = issuedAt; + /// + /// Revokes all users with a JWT issued at or before . + /// 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). + /// + 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; } + /// Returns the number of revoked user NKey entries (including any global revocation entry). + public int RevokedUserCount => _revokedUsers.Count; + + /// Returns true when a global ("*") revocation entry is present. + /// Go reference: accounts.go — Revocations[jwt.All] check (~line 3887). + public bool IsGlobalRevocation() => _revokedUsers.ContainsKey(GlobalRevocationKey); + + /// Returns a snapshot list of all revoked NKeys currently in the revocation dictionary. + public IReadOnlyList GetRevokedUsers() => [.. _revokedUsers.Keys]; + + /// + /// Removes the revocation entry for . + /// Returns if the entry was found and removed. + /// + public bool UnrevokeUser(string userNkey) => _revokedUsers.TryRemove(userNkey, out _); + + /// Removes all revocation entries, including any global ("*") revocation. + public void ClearAllRevocations() => _revokedUsers.Clear(); + + /// Returns a summary snapshot of the current revocation state for this account. + public RevocationInfo GetRevocationInfo() => + new(RevokedUserCount, IsGlobalRevocation(), GetRevokedUsers()); + private readonly ConcurrentDictionary _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); + } + + /// + /// The UTC time at which this account expires, or when no expiration is set. + /// Go reference: accounts.go — account.expiry field. + /// + public DateTime? ExpiresAt => ReadExpiry(); + + /// + /// Returns when an expiration has been set and + /// is at or past . + /// Go reference: accounts.go — isExpired() check. + /// + public bool IsExpired + { + get + { + var ticks = Interlocked.Read(ref _expiresAtTicks); + return ticks != NoExpiration && DateTime.UtcNow.Ticks >= ticks; + } + } + + /// + /// Returns the time remaining until expiry, if already expired, + /// or when no expiration is set. + /// Go reference: accounts.go — expiry-based TTL computation. + /// + 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; + } + } + + /// + /// Sets the UTC expiration time for this account. + /// Go reference: accounts.go — SetExpirationTimer / account.expiry assignment. + /// + public void SetExpiration(DateTime expiresAtUtc) => + Interlocked.Exchange(ref _expiresAtTicks, DateTime.SpecifyKind(expiresAtUtc, DateTimeKind.Utc).Ticks); + + /// Removes any previously set expiration, making the account permanent. + public void ClearExpiration() => Interlocked.Exchange(ref _expiresAtTicks, NoExpiration); + + /// + /// Convenience method: sets the expiration to DateTime.UtcNow + . + /// Go reference: accounts.go — SetExpirationTimer with duration argument. + /// + public void SetExpirationFromTtl(TimeSpan ttl) => SetExpiration(DateTime.UtcNow + ttl); + + /// + /// Returns a snapshot of the current expiration state for this account. + /// Go reference: accounts.go — account expiry inspection. + /// + 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 _activations = + new(StringComparer.Ordinal); + + /// + /// Registers a JWT activation claim for the given subject. + /// Go reference: accounts.go — checkActivation registers expiry timers for activation tokens. + /// + public void RegisterActivation(string subject, ActivationClaim claim) => + _activations[subject] = claim; + + /// + /// Checks whether the activation for 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 <= tn ⇒ expired. + /// + 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); + } + + /// + /// Returns when the activation for exists + /// and has passed its expiry time. + /// Go reference: accounts.go — act.Expires <= tn check inside checkActivation. + /// + public bool IsActivationExpired(string subject) => + _activations.TryGetValue(subject, out var claim) && claim.IsExpired; + + /// + /// Returns all subjects whose activation claim has expired. + /// Go reference: accounts.go — activationExpired fires per-subject when expiry timer triggers. + /// + public IReadOnlyList GetExpiredActivations() + { + var expired = new List(); + foreach (var (subject, claim) in _activations) + { + if (claim.IsExpired) + expired.Add(subject); + } + return expired; + } + + /// + /// Removes all expired activation claims and returns the count removed. + /// Go reference: accounts.go — activationExpired cleans up service/stream imports. + /// + public int RemoveExpiredActivations() + { + int count = 0; + foreach (var (subject, claim) in _activations) + { + if (claim.IsExpired && _activations.TryRemove(subject, out _)) + count++; + } + return count; + } + + /// + /// Returns the number of activation claims that have not yet expired. + /// Go reference: accounts.go — active activation tracking. + /// + 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); + +/// +/// Snapshot of an account's expiration state returned by . +/// Go reference: accounts.go — account expiry fields. +/// +/// The name of the account. +/// Whether an expiration time has been set. +/// The UTC expiry time, or if none is set. +/// Whether the account is already past its expiry time. +/// +/// Time remaining until expiry; when already expired; +/// when no expiry is configured. +/// +public sealed record AccountExpirationInfo( + string AccountName, + bool HasExpiration, + DateTime? ExpiresAt, + bool IsExpired, + TimeSpan? TimeToExpiry); + +/// +/// Summary snapshot of an account's revocation state returned by . +/// Go reference: accounts.go — usersRevoked map and Revocations[jwt.All] global key (~line 2934). +/// +/// Total number of revocation entries, including any global entry. +/// Whether the special "*" global revocation key is present. +/// Snapshot list of all revoked NKey strings. +public sealed record RevocationInfo( + int RevokedCount, + bool HasGlobalRevocation, + IReadOnlyList RevokedNKeys); + +/// +/// Represents a JWT activation claim for a service or stream import. +/// Go reference: server/accounts.go — jwt.ActivationClaims decoded inside checkActivation (~line 2955). +/// +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; } + + /// + /// Returns when is at or past . + /// Go reference: accounts.go — act.Expires <= tn check. + /// + public bool IsExpired => DateTime.UtcNow >= ExpiresAt; + + /// + /// Time remaining until expiry, or if already expired. + /// Go reference: accounts.go — expiresAt duration computed from act.Expires - time.Now().Unix(). + /// + public TimeSpan TimeToExpiry => IsExpired ? TimeSpan.Zero : ExpiresAt - DateTime.UtcNow; +} + +/// +/// Result of for a given subject. +/// Go reference: server/accounts.go — checkActivation return value (~line 2943). +/// +/// Whether an activation claim was registered for the subject. +/// Whether the claim has passed its expiry time. +/// The UTC expiry time of the claim, or when not found. +/// +/// Time remaining until expiry; when already expired; +/// when the subject has no registered claim. +/// +public sealed record ActivationCheckResult( + bool Found, + bool IsExpired, + DateTime? ExpiresAt, + TimeSpan? TimeToExpiry); diff --git a/tests/NATS.Server.Tests/Auth/AccountExpirationTests.cs b/tests/NATS.Server.Tests/Auth/AccountExpirationTests.cs new file mode 100644 index 0000000..640bb0c --- /dev/null +++ b/tests/NATS.Server.Tests/Auth/AccountExpirationTests.cs @@ -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); + } +} diff --git a/tests/NATS.Server.Tests/Auth/NKeyRevocationTests.cs b/tests/NATS.Server.Tests/Auth/NKeyRevocationTests.cs new file mode 100644 index 0000000..0d8339f --- /dev/null +++ b/tests/NATS.Server.Tests/Auth/NKeyRevocationTests.cs @@ -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("*"); + } +}