feat(batch19): implement activation, issuer, and external auth account methods
This commit is contained in:
@@ -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.
|
||||
|
||||
BIN
porting.db
BIN
porting.db
Binary file not shown.
Reference in New Issue
Block a user