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 objectEnumerable) + { + var results = new List(); + foreach (var item in objectEnumerable) + results.Add(item?.ToString() ?? string.Empty); + return results; + } + } + + return []; + } + + private static string NormalizeExportKind(object? kind) + { + if (kind is JsonElement element) + return element.ToString().Trim().ToLowerInvariant(); + + return kind?.ToString()?.Trim().ToLowerInvariant() ?? string.Empty; + } + + private static string ReadActivationImportSubject(object claimOrPayload) + { + if (TryReadStringMember(claimOrPayload, "ImportSubject", out var importSubject) && !string.IsNullOrEmpty(importSubject)) + return importSubject; + if (TryReadStringMember(claimOrPayload, "import_subject", out var importSubjectSnake) && !string.IsNullOrEmpty(importSubjectSnake)) + return importSubjectSnake; + + if (claimOrPayload is JsonElement element && + element.ValueKind == JsonValueKind.Object && + element.TryGetProperty("nats", out var natsObj) && + natsObj.ValueKind == JsonValueKind.Object && + natsObj.TryGetProperty("import_subject", out var natsImportSubject) && + natsImportSubject.ValueKind == JsonValueKind.String) + { + return natsImportSubject.GetString() ?? string.Empty; + } + + return string.Empty; + } + /// /// Tokenises a subject string into an array, using the same split logic /// as btsep-based tokenisation in the Go source. @@ -3923,9 +4462,8 @@ public sealed class Account : INatsAccount /// Checks whether is authorised to use /// (either via explicit approval or token requirement). /// Mirrors Go (a *Account) checkAuth(...) bool. - /// TODO: session 11 — full JWT activation check. /// - private static bool CheckAuth( + private bool CheckAuth( ExportAuth ea, Account account, object? imClaim, @@ -3936,8 +4474,7 @@ public sealed class Account : INatsAccount if (ea.TokenRequired) { - // TODO: session 11 — validate activation token in imClaim. - return imClaim != null; + return CheckActivation(account, imClaim, ea, expirationTimer: true); } // No approved list and no token required → public export. diff --git a/porting.db b/porting.db index b519e66..4c7856e 100644 Binary files a/porting.db and b/porting.db differ