diff --git a/dotnet/src/ZB.MOM.NatsNet.Server/Accounts/Account.cs b/dotnet/src/ZB.MOM.NatsNet.Server/Accounts/Account.cs
index c12203e..9718c11 100644
--- a/dotnet/src/ZB.MOM.NatsNet.Server/Accounts/Account.cs
+++ b/dotnet/src/ZB.MOM.NatsNet.Server/Accounts/Account.cs
@@ -3182,6 +3182,321 @@ public sealed class Account : INatsAccount
}
}
+ ///
+ /// Re-evaluates import validity when an activation token expiration timer fires.
+ /// Mirrors Go (a *Account) activationExpired(...).
+ ///
+ internal void ActivationExpired(Account exportAccount, string subject, object? kind)
+ {
+ var normalizedKind = NormalizeExportKind(kind);
+ if (string.Equals(normalizedKind, "stream", StringComparison.Ordinal))
+ {
+ StreamActivationExpired(exportAccount, subject);
+ }
+ else if (string.Equals(normalizedKind, "service", StringComparison.Ordinal))
+ {
+ ServiceActivationExpired(exportAccount, subject);
+ }
+ }
+
+ ///
+ /// Validates an import activation claim/token.
+ /// Mirrors Go (a *Account) checkActivation(...).
+ ///
+ internal bool CheckActivation(Account importAccount, object? claim, ExportAuth? exportAuth, bool expirationTimer)
+ {
+ if (claim == null)
+ return false;
+
+ if (!TryReadStringMember(claim, "Token", out var token) || string.IsNullOrWhiteSpace(token))
+ return false;
+
+ if (!TryDecodeJwtPayload(token, out var activationPayload))
+ return false;
+
+ if (!IsIssuerClaimTrusted(activationPayload))
+ return false;
+
+ if (TryReadLongMember(activationPayload, "exp", out var expires) && expires > 0)
+ {
+ var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
+ if (expires <= now)
+ return false;
+
+ if (expirationTimer)
+ {
+ var delay = TimeSpan.FromSeconds(expires - now);
+ string importSubject = ReadActivationImportSubject(activationPayload);
+ object? claimType = TryReadMember(claim, "Type", out var typeValue) ? typeValue : null;
+
+ _ = new Timer(
+ _ => importAccount.ActivationExpired(this, importSubject, claimType),
+ null,
+ delay,
+ Timeout.InfiniteTimeSpan);
+ }
+ }
+
+ if (exportAuth == null)
+ return true;
+
+ string subject = TryReadStringMember(activationPayload, "sub", out var sub) ? sub : string.Empty;
+ long issuedAt = TryReadLongMember(activationPayload, "iat", out var iat) ? iat : 0;
+ return !IsRevoked(exportAuth.ActivationsRevoked, subject, issuedAt);
+ }
+
+ ///
+ /// Returns true when activation issuer details are trusted for this account.
+ /// Mirrors Go (a *Account) isIssuerClaimTrusted(...).
+ ///
+ internal bool IsIssuerClaimTrusted(object? claims)
+ {
+ if (claims == null)
+ return false;
+
+ string issuerAccount =
+ TryReadStringMember(claims, "IssuerAccount", out var ia) ? ia :
+ TryReadStringMember(claims, "issuer_account", out var iaAlt) ? iaAlt :
+ string.Empty;
+
+ // If issuer-account is omitted, issuer defaults to the account itself.
+ if (string.IsNullOrEmpty(issuerAccount))
+ return true;
+
+ if (!string.Equals(Name, issuerAccount, StringComparison.Ordinal))
+ {
+ if (Server is NatsServer server)
+ {
+ string importSubject = ReadActivationImportSubject(claims);
+ string importType = TryReadStringMember(claims, "import_type", out var it) ? it : string.Empty;
+ server.Errorf(
+ "Invalid issuer account {0} in activation claim (subject: {1} - type: {2}) for account {3}",
+ issuerAccount,
+ importSubject,
+ importType,
+ Name);
+ }
+
+ return false;
+ }
+
+ string issuer =
+ TryReadStringMember(claims, "Issuer", out var issuerValue) ? issuerValue :
+ TryReadStringMember(claims, "iss", out var issValue) ? issValue :
+ string.Empty;
+
+ _mu.EnterReadLock();
+ try
+ {
+ (_, var ok) = HasIssuerNoLock(issuer);
+ return ok;
+ }
+ finally
+ {
+ _mu.ExitReadLock();
+ }
+ }
+
+ ///
+ /// Checks whether another account is approved to import this service export.
+ /// Mirrors Go (a *Account) checkServiceImportAuthorized(...).
+ ///
+ internal bool CheckServiceImportAuthorized(Account account, string subject, object? importClaim)
+ {
+ _mu.EnterReadLock();
+ try { return CheckServiceImportAuthorizedNoLock(account, subject, importClaim); }
+ finally { _mu.ExitReadLock(); }
+ }
+
+ ///
+ /// Lock-free helper for service import authorization checks.
+ /// Mirrors Go (a *Account) checkServiceImportAuthorizedNoLock(...).
+ ///
+ internal bool CheckServiceImportAuthorizedNoLock(Account account, string subject, object? importClaim)
+ {
+ if (Exports.Services == null)
+ return false;
+
+ return CheckServiceExportApproved(account, subject, importClaim);
+ }
+
+ ///
+ /// Returns whether bearer tokens should be rejected for this account.
+ /// Mirrors Go (a *Account) failBearer() bool.
+ ///
+ internal bool FailBearer()
+ {
+ _mu.EnterReadLock();
+ try { return DisallowBearer; }
+ finally { _mu.ExitReadLock(); }
+ }
+
+ ///
+ /// Updates expiration state/timer from claim data.
+ /// Mirrors Go (a *Account) checkExpiration(...).
+ ///
+ internal void CheckExpiration(object? claimsData)
+ {
+ long expires =
+ claimsData != null && TryReadLongMember(claimsData, "Expires", out var exp) ? exp :
+ claimsData != null && TryReadLongMember(claimsData, "exp", out var expUnix) ? expUnix :
+ 0;
+
+ _mu.EnterWriteLock();
+ try
+ {
+ ClearExpirationTimer();
+
+ if (expires == 0)
+ {
+ Interlocked.Exchange(ref _expired, 0);
+ return;
+ }
+
+ long now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
+ if (expires <= now)
+ {
+ Interlocked.Exchange(ref _expired, 1);
+ return;
+ }
+
+ SetExpirationTimer(TimeSpan.FromSeconds(expires - now));
+ Interlocked.Exchange(ref _expired, 0);
+ }
+ finally
+ {
+ _mu.ExitWriteLock();
+ }
+ }
+
+ ///
+ /// Returns signer scope for issuer, if present.
+ /// Mirrors Go (a *Account) hasIssuer(...).
+ ///
+ internal (object? Scope, bool Ok) HasIssuer(string issuer)
+ {
+ _mu.EnterReadLock();
+ try { return HasIssuerNoLock(issuer); }
+ finally { _mu.ExitReadLock(); }
+ }
+
+ ///
+ /// Lock-free signer lookup.
+ /// Mirrors Go (a *Account) hasIssuerNoLock(...).
+ ///
+ internal (object? Scope, bool Ok) HasIssuerNoLock(string issuer)
+ {
+ if (SigningKeys == null || string.IsNullOrEmpty(issuer))
+ return (null, false);
+
+ return SigningKeys.TryGetValue(issuer, out var scope)
+ ? (scope, true)
+ : (null, false);
+ }
+
+ ///
+ /// Returns the leaf-node loop-detection subject.
+ /// Mirrors Go (a *Account) getLDSubject() string.
+ ///
+ internal string GetLDSubject()
+ {
+ _mu.EnterReadLock();
+ try { return LoopDetectionSubject; }
+ finally { _mu.ExitReadLock(); }
+ }
+
+ ///
+ /// Returns account label used in trace output.
+ /// Mirrors Go (a *Account) traceLabel() string.
+ ///
+ internal string TraceLabel()
+ {
+ if (string.IsNullOrEmpty(NameTag))
+ return Name;
+ return $"{Name}/{NameTag}";
+ }
+
+ ///
+ /// Returns true when external auth is configured.
+ /// Mirrors Go (a *Account) hasExternalAuth() bool.
+ ///
+ internal bool HasExternalAuth()
+ {
+ _mu.EnterReadLock();
+ try { return ExternalAuth != null; }
+ finally { _mu.ExitReadLock(); }
+ }
+
+ ///
+ /// Returns true when is configured as an external-auth user.
+ /// Mirrors Go (a *Account) isExternalAuthUser(userID string) bool.
+ ///
+ internal bool IsExternalAuthUser(string userId)
+ {
+ _mu.EnterReadLock();
+ try
+ {
+ foreach (var authUser in ReadStringListMember(ExternalAuth, "AuthUsers", "auth_users"))
+ {
+ if (string.Equals(userId, authUser, StringComparison.Ordinal))
+ return true;
+ }
+ return false;
+ }
+ finally
+ {
+ _mu.ExitReadLock();
+ }
+ }
+
+ ///
+ /// Returns configured external-auth xkey, or empty when unset.
+ /// Mirrors Go (a *Account) externalAuthXKey() string.
+ ///
+ internal string ExternalAuthXKey()
+ {
+ _mu.EnterReadLock();
+ try
+ {
+ if (TryReadStringMember(ExternalAuth, "XKey", out var xkey) && !string.IsNullOrEmpty(xkey))
+ return xkey;
+ if (TryReadStringMember(ExternalAuth, "xkey", out var xkeyAlt) && !string.IsNullOrEmpty(xkeyAlt))
+ return xkeyAlt;
+ return string.Empty;
+ }
+ finally
+ {
+ _mu.ExitReadLock();
+ }
+ }
+
+ ///
+ /// Returns whether external auth allows account switching to .
+ /// Mirrors Go (a *Account) isAllowedAcount(acc string) bool.
+ ///
+ internal bool IsAllowedAcount(string account)
+ {
+ _mu.EnterReadLock();
+ try
+ {
+ var allowed = ReadStringListMember(ExternalAuth, "AllowedAccounts", "allowed_accounts");
+ if (allowed.Count == 1 && string.Equals(allowed[0], "*", StringComparison.Ordinal))
+ return true;
+
+ foreach (var candidate in allowed)
+ {
+ if (string.Equals(candidate, account, StringComparison.Ordinal))
+ return true;
+ }
+
+ return false;
+ }
+ finally
+ {
+ _mu.ExitReadLock();
+ }
+ }
+
// -------------------------------------------------------------------------
// Export checks
// -------------------------------------------------------------------------
@@ -3877,6 +4192,230 @@ public sealed class Account : INatsAccount
return DateTime.UnixEpoch.AddTicks(unixNanos / 100L);
}
+ private static bool TryDecodeJwtPayload(string token, out JsonElement payload)
+ {
+ payload = default;
+ if (string.IsNullOrWhiteSpace(token))
+ return false;
+
+ var parts = token.Split('.');
+ if (parts.Length < 2)
+ return false;
+
+ string base64 = parts[1]
+ .Replace("-", "+", StringComparison.Ordinal)
+ .Replace("_", "/", StringComparison.Ordinal);
+
+ int mod = base64.Length % 4;
+ if (mod > 0)
+ base64 = base64.PadRight(base64.Length + (4 - mod), '=');
+
+ byte[] bytes;
+ try { bytes = Convert.FromBase64String(base64); }
+ catch { return false; }
+
+ try
+ {
+ using var doc = JsonDocument.Parse(bytes);
+ payload = doc.RootElement.Clone();
+ return payload.ValueKind == JsonValueKind.Object;
+ }
+ catch
+ {
+ return false;
+ }
+ }
+
+ private static bool TryReadMember(object source, string name, out object? value)
+ {
+ value = null;
+ if (source == null)
+ return false;
+
+ if (source is JsonElement element)
+ {
+ if (element.ValueKind != JsonValueKind.Object)
+ return false;
+
+ foreach (var property in element.EnumerateObject())
+ {
+ if (string.Equals(property.Name, name, StringComparison.OrdinalIgnoreCase))
+ {
+ value = property.Value;
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ if (source is IDictionary dictionary &&
+ dictionary.TryGetValue(name, out var dictionaryValue))
+ {
+ value = dictionaryValue;
+ return true;
+ }
+
+ if (source is IDictionary stringDictionary &&
+ stringDictionary.TryGetValue(name, out var stringDictionaryValue))
+ {
+ value = stringDictionaryValue;
+ return true;
+ }
+
+ var propertyInfo = source
+ .GetType()
+ .GetProperty(name, System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.IgnoreCase);
+ if (propertyInfo == null)
+ return false;
+
+ value = propertyInfo.GetValue(source);
+ return true;
+ }
+
+ private static bool TryReadStringMember(object? source, string name, out string value)
+ {
+ value = string.Empty;
+ if (source == null || !TryReadMember(source, name, out var member))
+ return false;
+
+ if (member is JsonElement element)
+ {
+ if (element.ValueKind == JsonValueKind.String)
+ {
+ value = element.GetString() ?? string.Empty;
+ return true;
+ }
+
+ if (element.ValueKind == JsonValueKind.Number)
+ {
+ value = element.ToString();
+ return true;
+ }
+
+ return false;
+ }
+
+ value = member?.ToString() ?? string.Empty;
+ return true;
+ }
+
+ private static bool TryReadLongMember(object source, string name, out long value)
+ {
+ value = 0;
+ if (!TryReadMember(source, name, out var member))
+ return false;
+
+ if (member is JsonElement element)
+ {
+ if (element.ValueKind == JsonValueKind.Number)
+ return element.TryGetInt64(out value);
+
+ if (element.ValueKind == JsonValueKind.String)
+ return long.TryParse(element.GetString(), out value);
+
+ return false;
+ }
+
+ switch (member)
+ {
+ case byte b:
+ value = b;
+ return true;
+ case sbyte sb:
+ value = sb;
+ return true;
+ case short s:
+ value = s;
+ return true;
+ case ushort us:
+ value = us;
+ return true;
+ case int i:
+ value = i;
+ return true;
+ case uint ui:
+ value = ui;
+ return true;
+ case long l:
+ value = l;
+ return true;
+ case ulong ul when ul <= long.MaxValue:
+ value = (long)ul;
+ return true;
+ case string str:
+ return long.TryParse(str, out value);
+ default:
+ return false;
+ }
+ }
+
+ private static IReadOnlyList ReadStringListMember(object? source, params string[] names)
+ {
+ if (source == null)
+ return [];
+
+ foreach (var name in names)
+ {
+ if (!TryReadMember(source, name, out var member) || member == null)
+ continue;
+
+ if (member is IEnumerable enumerableStrings)
+ return [.. enumerableStrings];
+
+ if (member is JsonElement element && element.ValueKind == JsonValueKind.Array)
+ {
+ var results = new List();
+ foreach (var item in element.EnumerateArray())
+ {
+ if (item.ValueKind == JsonValueKind.String)
+ results.Add(item.GetString() ?? string.Empty);
+ else
+ results.Add(item.ToString());
+ }
+ return results;
+ }
+
+ if (member is IEnumerable