feat(batch19): implement activation, issuer, and external auth account methods

This commit is contained in:
Joseph Doherty
2026-02-28 20:21:52 -05:00
parent 5493703280
commit e53b4aa17a
2 changed files with 541 additions and 4 deletions

View File

@@ -3182,6 +3182,321 @@ public sealed class Account : INatsAccount
}
}
/// <summary>
/// Re-evaluates import validity when an activation token expiration timer fires.
/// Mirrors Go <c>(a *Account) activationExpired(...)</c>.
/// </summary>
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);
}
}
/// <summary>
/// Validates an import activation claim/token.
/// Mirrors Go <c>(a *Account) checkActivation(...)</c>.
/// </summary>
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);
}
/// <summary>
/// Returns true when activation issuer details are trusted for this account.
/// Mirrors Go <c>(a *Account) isIssuerClaimTrusted(...)</c>.
/// </summary>
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();
}
}
/// <summary>
/// Checks whether another account is approved to import this service export.
/// Mirrors Go <c>(a *Account) checkServiceImportAuthorized(...)</c>.
/// </summary>
internal bool CheckServiceImportAuthorized(Account account, string subject, object? importClaim)
{
_mu.EnterReadLock();
try { return CheckServiceImportAuthorizedNoLock(account, subject, importClaim); }
finally { _mu.ExitReadLock(); }
}
/// <summary>
/// Lock-free helper for service import authorization checks.
/// Mirrors Go <c>(a *Account) checkServiceImportAuthorizedNoLock(...)</c>.
/// </summary>
internal bool CheckServiceImportAuthorizedNoLock(Account account, string subject, object? importClaim)
{
if (Exports.Services == null)
return false;
return CheckServiceExportApproved(account, subject, importClaim);
}
/// <summary>
/// Returns whether bearer tokens should be rejected for this account.
/// Mirrors Go <c>(a *Account) failBearer() bool</c>.
/// </summary>
internal bool FailBearer()
{
_mu.EnterReadLock();
try { return DisallowBearer; }
finally { _mu.ExitReadLock(); }
}
/// <summary>
/// Updates expiration state/timer from claim data.
/// Mirrors Go <c>(a *Account) checkExpiration(...)</c>.
/// </summary>
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();
}
}
/// <summary>
/// Returns signer scope for issuer, if present.
/// Mirrors Go <c>(a *Account) hasIssuer(...)</c>.
/// </summary>
internal (object? Scope, bool Ok) HasIssuer(string issuer)
{
_mu.EnterReadLock();
try { return HasIssuerNoLock(issuer); }
finally { _mu.ExitReadLock(); }
}
/// <summary>
/// Lock-free signer lookup.
/// Mirrors Go <c>(a *Account) hasIssuerNoLock(...)</c>.
/// </summary>
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);
}
/// <summary>
/// Returns the leaf-node loop-detection subject.
/// Mirrors Go <c>(a *Account) getLDSubject() string</c>.
/// </summary>
internal string GetLDSubject()
{
_mu.EnterReadLock();
try { return LoopDetectionSubject; }
finally { _mu.ExitReadLock(); }
}
/// <summary>
/// Returns account label used in trace output.
/// Mirrors Go <c>(a *Account) traceLabel() string</c>.
/// </summary>
internal string TraceLabel()
{
if (string.IsNullOrEmpty(NameTag))
return Name;
return $"{Name}/{NameTag}";
}
/// <summary>
/// Returns true when external auth is configured.
/// Mirrors Go <c>(a *Account) hasExternalAuth() bool</c>.
/// </summary>
internal bool HasExternalAuth()
{
_mu.EnterReadLock();
try { return ExternalAuth != null; }
finally { _mu.ExitReadLock(); }
}
/// <summary>
/// Returns true when <paramref name="userId"/> is configured as an external-auth user.
/// Mirrors Go <c>(a *Account) isExternalAuthUser(userID string) bool</c>.
/// </summary>
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();
}
}
/// <summary>
/// Returns configured external-auth xkey, or empty when unset.
/// Mirrors Go <c>(a *Account) externalAuthXKey() string</c>.
/// </summary>
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();
}
}
/// <summary>
/// Returns whether external auth allows account switching to <paramref name="account"/>.
/// Mirrors Go <c>(a *Account) isAllowedAcount(acc string) bool</c>.
/// </summary>
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<string, object?> dictionary &&
dictionary.TryGetValue(name, out var dictionaryValue))
{
value = dictionaryValue;
return true;
}
if (source is IDictionary<string, string> 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<string> 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<string> enumerableStrings)
return [.. enumerableStrings];
if (member is JsonElement element && element.ValueKind == JsonValueKind.Array)
{
var results = new List<string>();
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<object?> objectEnumerable)
{
var results = new List<string>();
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;
}
/// <summary>
/// Tokenises a subject string into an array, using the same split logic
/// as <c>btsep</c>-based tokenisation in the Go source.
@@ -3923,9 +4462,8 @@ public sealed class Account : INatsAccount
/// Checks whether <paramref name="account"/> is authorised to use
/// <paramref name="ea"/> (either via explicit approval or token requirement).
/// Mirrors Go <c>(a *Account) checkAuth(...) bool</c>.
/// TODO: session 11 — full JWT activation check.
/// </summary>
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.