feat: add JWT activation claim expiration checking (Gap 9.7)

Add ActivationClaim and ActivationCheckResult types plus five methods on
Account (RegisterActivation, CheckActivationExpiry, IsActivationExpired,
GetExpiredActivations, RemoveExpiredActivations) and an ActiveActivationCount
property, mirroring Go accounts.go checkActivation / activationExpired logic.
Adds 10 targeted tests in Auth/ActivationExpirationTests.cs (all pass).
This commit is contained in:
Joseph Doherty
2026-02-25 12:57:41 -05:00
parent e2bfca48e4
commit e4b5ed9a83
3 changed files with 486 additions and 0 deletions

View File

@@ -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;
/// <summary>The most recently applied claim data, or null if no claims have been applied.</summary>
public AccountClaimData? CurrentClaims => Volatile.Read(ref _currentClaims);
/// <summary>Returns true if at least one claim update has been applied to this account.</summary>
public bool HasClaims => Volatile.Read(ref _currentClaims) != null;
/// <summary>Total number of successful (changed) claim updates applied to this account.</summary>
public int ClaimUpdateCount => Volatile.Read(ref _claimUpdateCount);
/// <summary>
/// Applies <paramref name="newClaims"/> 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).
/// </summary>
public AccountClaimUpdateResult UpdateAccountClaims(AccountClaimData newClaims)
{
ArgumentNullException.ThrowIfNull(newClaims);
var old = Volatile.Read(ref _currentClaims);
var changedFields = new List<string>();
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);
/// <summary>
/// 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).
/// </summary>
public sealed class AccountClaimData
{
/// <summary>Maximum number of connections (null = not specified by claim).</summary>
public int? MaxConnections { get; init; }
/// <summary>Maximum number of subscriptions (null = not specified by claim).</summary>
public int? MaxSubscriptions { get; init; }
/// <summary>The account's public NKey, if present in the claim.</summary>
public string? Nkey { get; init; }
/// <summary>The operator or signing key that issued the account JWT.</summary>
public string? Issuer { get; init; }
/// <summary>The UTC expiry time encoded in the claim, if present.</summary>
public DateTime? ExpiresAt { get; init; }
/// <summary>Arbitrary tags associated with the account in the JWT.</summary>
public Dictionary<string, string> Tags { get; init; } = new();
}
/// <summary>
/// Result of <see cref="Account.UpdateAccountClaims"/> describing which fields changed.
/// Go reference: server/accounts.go — updateAccountClaimsWithRefresh output (~line 3374).
/// </summary>
/// <param name="Changed">Whether any field differed from the previous claim state.</param>
/// <param name="ChangedFields">Names of the properties that changed.</param>
/// <param name="UpdateCount">The new total claim update count after this call.</param>
public sealed record AccountClaimUpdateResult(
bool Changed,
IReadOnlyList<string> ChangedFields,
int UpdateCount);

View File

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

View File

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