From e53b4aa17a99a0b5fffba2fe671ce3a3b0308bf6 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sat, 28 Feb 2026 20:21:52 -0500 Subject: [PATCH] feat(batch19): implement activation, issuer, and external auth account methods --- .../ZB.MOM.NatsNet.Server/Accounts/Account.cs | 545 +++++++++++++++++- porting.db | Bin 6688768 -> 6692864 bytes 2 files changed, 541 insertions(+), 4 deletions(-) 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 b519e6617c5988917a52b8ff0332b00143fbe7a6..4c7856ec802457a4aea3ed883c9236fcb41addb3 100644 GIT binary patch delta 3350 zcmai#ZA?>V6vumeZ*Om3p7J7~(w4TUylLs}8x$&v4%`%{f{GxBEpY1_EnorXoI_X^ z7PCzGKp)u$Ct-=3afxOLcOSM)H%D|^Hub}@WQOozn#?R2xMeY9VLM*nLb+}C;g|eR z&i|a}JTJXHcjXd!?$#ypqFxb^$>`=m^JXG)(^qaI{(Wo2#*f&D3pdns5uHb8(ptqV zHuuTXFgrnr9Dg?DrP*zY5LFIzV6I2eUEJ2w-r3oqvlv+>*-h55|C28^|{XOq01vTd? z0X66H@Ux%D>1^hTwo>-uvUu%%`6fcHt;E&tRWe*Eho?>Enag>37WQ@70s2@_~RgYyim;GPeUkwNiiSxp&Dl3X$S#2 zAPGnYQh-!o1&{`$0~x?dU=^?$$OQC&0Wk7ULs?nVX88>9jqIULubt9NYSijZ)hP3| z!cTobPBN4-g*Ic0nYgCzYw!1_$_Q^?685#h)u#kaXBK8u;u}Fz)H`|{H2s28yU|+w zYsj>8dYsvAI=@(Vx81Z}*xUS*@%C5ZnO0N5nBVkF6Y=Sp0j9$Qm;nor4Xgpy0_%Vr zAQ#93@__DLU;#Ve0Gxmea05j^G2j78fKtE1P%dBz+u1# z_{RkDY&`oS&wjGp1+Dw$Dk+?~GH z8Q}a~-6y#)!Q$zA-0lLmgLM>HS*Mk?n^}9Qy{OdT=ovmH_1gr0er*3ysUH*1TMF>h z_gqe#HI7JYtchj{Nd38q{G8OElgQsH^_vp;TcrN1ME+)}-yrzYVx1}fJ{1UPA{~gb zrjxz0q=6)zZb)Z9qo_dpHvB-n>WFGuwUfEZ$d!la&*&A35Ot4QOAcAoJ9zkJQRK=# z%W$u-X4rUk@!q}oCKd3IuHs_6MH5)9a-01vE$!W*E_{26%f$WDT&iL2yKNbMQ(9g+ zU+yl7TfTas{Os~_Jm_t(Vc&84B%LlhF4x3vATIy<@&;kTKEFJRJI9;TBZ`0`)(B#U)6x#?+zC28@h?EX zaDuN%%W>zQ2lc`pD5tYH{w;_-PQ~ak?SdY+MmQtk#MMLG%6R(_oAktJGWe`f9K;cS zGvZlvOMB$bpM~8W_brGcbV( zpDgTW8k{)!I!94X2OIxl#PjHsHZch20gue`ZF5HAOX(Tz5bC2o>fS^jpmwRhG?70f q^_L{_2c>?G;E(gC(I)ldn+mQFCx6Ff$Ei6HqlOr0nX8HUA^i_6_71`T delta 1381 zcmY+>TWnKx90&08Kj-xH^zv`F!Uk?@PgmGYw^iPuHu)mBxtR#jS6 z<*TZi^t(6N)rFkfxg=x@$6ih|7gEAV9BJ4{PNV@NIgomdWJlU;BpXt@kyIp~k*r8- zjieyijl^{SCGitUTjRcSzsrMUcGV)8T{ZgO^P&<^&pJaw<+63>SK=Nmo=Xg9Q|@f(kaM^f>g;b?3*0fMr>}D~nQ4?036d9nNcx-yHS!S8Y=)A;i^A z>l?}xHTzko-gk)q%5?oOACxIR%rDT5Db+h0lC0zn^L8o~6&FRr`~U?OEXMfkew$36 zhq*@6i;_$KKF(82?@sU&OdTz5sukF_S7>~d*O4c}UnI|fY_4`T!duMMRwDdODu2Ds z9G{5dFO`)@kMh6%@4q&}17^P(Lw|8z_R{4T51M1Y#Q58kzH07>-jDMQl{NV6w4yW> z1lP~}qPo=?cQh!XJSlx6CfG%xVEbKNteiQl&*gbUrDI9{87&-FyfmK1{4Z6nc_ex_ z#XmAOQu)wpZtjaT|BlLU@r|^c;a$dYyIh!FqTA8lVxHz^mUH^Z9Q3-3|OF zXW7ARH`EpDJ!?jpv3w{-*(qT~z9UPLr*iB|dMTs%o%V^BianKWPq-;LjAy!?)Q(fR zQ17Mb7r2d1r?gh{$V(~hTM|FG|9*>Ut;4+gKhxR?D%auOFu_6ZWwf@+1U{Q=R43IV z8u+0Z0_ctPeBOw!vQ!5Lof_sh(HuZAO>-G8V4YR#%VY?wyo8@|hWHzD37#jLG^LIiGj@1FSgAcK`qY