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