up
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Export Center CI / export-ci (push) Has been cancelled
Notify Smoke Test / Notify Unit Tests (push) Has been cancelled
Notify Smoke Test / Notifier Service Tests (push) Has been cancelled
Notify Smoke Test / Notification Smoke Test (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled
Signals Reachability Scoring & Events / reachability-smoke (push) Has been cancelled
Signals Reachability Scoring & Events / sign-and-upload (push) Has been cancelled
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Export Center CI / export-ci (push) Has been cancelled
Notify Smoke Test / Notify Unit Tests (push) Has been cancelled
Notify Smoke Test / Notifier Service Tests (push) Has been cancelled
Notify Smoke Test / Notification Smoke Test (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled
Signals Reachability Scoring & Events / reachability-smoke (push) Has been cancelled
Signals Reachability Scoring & Events / sign-and-upload (push) Has been cancelled
This commit is contained in:
@@ -1,50 +1,50 @@
|
||||
using System;
|
||||
|
||||
namespace StellaOps.Auth.Security.Dpop;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the outcome of attempting to consume a DPoP nonce.
|
||||
/// </summary>
|
||||
public sealed class DpopNonceConsumeResult
|
||||
{
|
||||
private DpopNonceConsumeResult(DpopNonceConsumeStatus status, DateTimeOffset? issuedAt, DateTimeOffset? expiresAt)
|
||||
{
|
||||
Status = status;
|
||||
IssuedAt = issuedAt;
|
||||
ExpiresAt = expiresAt;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Consumption status.
|
||||
/// </summary>
|
||||
public DpopNonceConsumeStatus Status { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Timestamp the nonce was originally issued (when available).
|
||||
/// </summary>
|
||||
public DateTimeOffset? IssuedAt { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Expiry timestamp for the nonce (when available).
|
||||
/// </summary>
|
||||
public DateTimeOffset? ExpiresAt { get; }
|
||||
|
||||
public static DpopNonceConsumeResult Success(DateTimeOffset issuedAt, DateTimeOffset expiresAt)
|
||||
=> new(DpopNonceConsumeStatus.Success, issuedAt, expiresAt);
|
||||
|
||||
public static DpopNonceConsumeResult Expired(DateTimeOffset? issuedAt, DateTimeOffset expiresAt)
|
||||
=> new(DpopNonceConsumeStatus.Expired, issuedAt, expiresAt);
|
||||
|
||||
public static DpopNonceConsumeResult NotFound()
|
||||
=> new(DpopNonceConsumeStatus.NotFound, null, null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Known statuses for nonce consumption attempts.
|
||||
/// </summary>
|
||||
public enum DpopNonceConsumeStatus
|
||||
{
|
||||
Success,
|
||||
Expired,
|
||||
NotFound
|
||||
}
|
||||
using System;
|
||||
|
||||
namespace StellaOps.Auth.Security.Dpop;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the outcome of attempting to consume a DPoP nonce.
|
||||
/// </summary>
|
||||
public sealed class DpopNonceConsumeResult
|
||||
{
|
||||
private DpopNonceConsumeResult(DpopNonceConsumeStatus status, DateTimeOffset? issuedAt, DateTimeOffset? expiresAt)
|
||||
{
|
||||
Status = status;
|
||||
IssuedAt = issuedAt;
|
||||
ExpiresAt = expiresAt;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Consumption status.
|
||||
/// </summary>
|
||||
public DpopNonceConsumeStatus Status { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Timestamp the nonce was originally issued (when available).
|
||||
/// </summary>
|
||||
public DateTimeOffset? IssuedAt { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Expiry timestamp for the nonce (when available).
|
||||
/// </summary>
|
||||
public DateTimeOffset? ExpiresAt { get; }
|
||||
|
||||
public static DpopNonceConsumeResult Success(DateTimeOffset issuedAt, DateTimeOffset expiresAt)
|
||||
=> new(DpopNonceConsumeStatus.Success, issuedAt, expiresAt);
|
||||
|
||||
public static DpopNonceConsumeResult Expired(DateTimeOffset? issuedAt, DateTimeOffset expiresAt)
|
||||
=> new(DpopNonceConsumeStatus.Expired, issuedAt, expiresAt);
|
||||
|
||||
public static DpopNonceConsumeResult NotFound()
|
||||
=> new(DpopNonceConsumeStatus.NotFound, null, null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Known statuses for nonce consumption attempts.
|
||||
/// </summary>
|
||||
public enum DpopNonceConsumeStatus
|
||||
{
|
||||
Success,
|
||||
Expired,
|
||||
NotFound
|
||||
}
|
||||
|
||||
@@ -1,56 +1,56 @@
|
||||
using System;
|
||||
|
||||
namespace StellaOps.Auth.Security.Dpop;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the result of issuing a DPoP nonce.
|
||||
/// </summary>
|
||||
public sealed class DpopNonceIssueResult
|
||||
{
|
||||
private DpopNonceIssueResult(DpopNonceIssueStatus status, string? nonce, DateTimeOffset? expiresAt, string? error)
|
||||
{
|
||||
Status = status;
|
||||
Nonce = nonce;
|
||||
ExpiresAt = expiresAt;
|
||||
Error = error;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Issue status.
|
||||
/// </summary>
|
||||
public DpopNonceIssueStatus Status { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Issued nonce when <see cref="Status"/> is <see cref="DpopNonceIssueStatus.Success"/>.
|
||||
/// </summary>
|
||||
public string? Nonce { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Expiry timestamp for the issued nonce (UTC).
|
||||
/// </summary>
|
||||
public DateTimeOffset? ExpiresAt { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Additional failure information, where applicable.
|
||||
/// </summary>
|
||||
public string? Error { get; }
|
||||
|
||||
public static DpopNonceIssueResult Success(string nonce, DateTimeOffset expiresAt)
|
||||
=> new(DpopNonceIssueStatus.Success, nonce, expiresAt, null);
|
||||
|
||||
public static DpopNonceIssueResult RateLimited(string? error = null)
|
||||
=> new(DpopNonceIssueStatus.RateLimited, null, null, error);
|
||||
|
||||
public static DpopNonceIssueResult Failure(string? error = null)
|
||||
=> new(DpopNonceIssueStatus.Failure, null, null, error);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Known statuses for nonce issuance.
|
||||
/// </summary>
|
||||
public enum DpopNonceIssueStatus
|
||||
{
|
||||
Success,
|
||||
RateLimited,
|
||||
Failure
|
||||
}
|
||||
using System;
|
||||
|
||||
namespace StellaOps.Auth.Security.Dpop;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the result of issuing a DPoP nonce.
|
||||
/// </summary>
|
||||
public sealed class DpopNonceIssueResult
|
||||
{
|
||||
private DpopNonceIssueResult(DpopNonceIssueStatus status, string? nonce, DateTimeOffset? expiresAt, string? error)
|
||||
{
|
||||
Status = status;
|
||||
Nonce = nonce;
|
||||
ExpiresAt = expiresAt;
|
||||
Error = error;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Issue status.
|
||||
/// </summary>
|
||||
public DpopNonceIssueStatus Status { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Issued nonce when <see cref="Status"/> is <see cref="DpopNonceIssueStatus.Success"/>.
|
||||
/// </summary>
|
||||
public string? Nonce { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Expiry timestamp for the issued nonce (UTC).
|
||||
/// </summary>
|
||||
public DateTimeOffset? ExpiresAt { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Additional failure information, where applicable.
|
||||
/// </summary>
|
||||
public string? Error { get; }
|
||||
|
||||
public static DpopNonceIssueResult Success(string nonce, DateTimeOffset expiresAt)
|
||||
=> new(DpopNonceIssueStatus.Success, nonce, expiresAt, null);
|
||||
|
||||
public static DpopNonceIssueResult RateLimited(string? error = null)
|
||||
=> new(DpopNonceIssueStatus.RateLimited, null, null, error);
|
||||
|
||||
public static DpopNonceIssueResult Failure(string? error = null)
|
||||
=> new(DpopNonceIssueStatus.Failure, null, null, error);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Known statuses for nonce issuance.
|
||||
/// </summary>
|
||||
public enum DpopNonceIssueStatus
|
||||
{
|
||||
Success,
|
||||
RateLimited,
|
||||
Failure
|
||||
}
|
||||
|
||||
@@ -1,66 +1,66 @@
|
||||
using System;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace StellaOps.Auth.Security.Dpop;
|
||||
|
||||
internal static class DpopNonceUtilities
|
||||
{
|
||||
private static readonly char[] Base64Padding = { '=' };
|
||||
|
||||
internal static string GenerateNonce()
|
||||
{
|
||||
Span<byte> buffer = stackalloc byte[32];
|
||||
RandomNumberGenerator.Fill(buffer);
|
||||
|
||||
return Convert.ToBase64String(buffer)
|
||||
.TrimEnd(Base64Padding)
|
||||
.Replace('+', '-')
|
||||
.Replace('/', '_');
|
||||
}
|
||||
|
||||
internal static byte[] ComputeNonceHash(string nonce)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(nonce);
|
||||
var bytes = Encoding.UTF8.GetBytes(nonce);
|
||||
return SHA256.HashData(bytes);
|
||||
}
|
||||
|
||||
internal static string EncodeHash(ReadOnlySpan<byte> hash)
|
||||
=> Convert.ToHexString(hash);
|
||||
|
||||
internal static string ComputeStorageKey(string audience, string clientId, string keyThumbprint)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(audience);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(clientId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(keyThumbprint);
|
||||
|
||||
return string.Create(
|
||||
"dpop-nonce:".Length + audience.Length + clientId.Length + keyThumbprint.Length + 2,
|
||||
(audience.Trim(), clientId.Trim(), keyThumbprint.Trim()),
|
||||
static (span, parts) =>
|
||||
{
|
||||
var index = 0;
|
||||
const string Prefix = "dpop-nonce:";
|
||||
Prefix.CopyTo(span);
|
||||
index += Prefix.Length;
|
||||
|
||||
index = Append(span, index, parts.Item1);
|
||||
span[index++] = ':';
|
||||
index = Append(span, index, parts.Item2);
|
||||
span[index++] = ':';
|
||||
_ = Append(span, index, parts.Item3);
|
||||
});
|
||||
|
||||
static int Append(Span<char> span, int index, string value)
|
||||
{
|
||||
if (value.Length == 0)
|
||||
{
|
||||
throw new ArgumentException("Value must not be empty after trimming.");
|
||||
}
|
||||
|
||||
value.AsSpan().CopyTo(span[index..]);
|
||||
return index + value.Length;
|
||||
}
|
||||
}
|
||||
}
|
||||
using System;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace StellaOps.Auth.Security.Dpop;
|
||||
|
||||
internal static class DpopNonceUtilities
|
||||
{
|
||||
private static readonly char[] Base64Padding = { '=' };
|
||||
|
||||
internal static string GenerateNonce()
|
||||
{
|
||||
Span<byte> buffer = stackalloc byte[32];
|
||||
RandomNumberGenerator.Fill(buffer);
|
||||
|
||||
return Convert.ToBase64String(buffer)
|
||||
.TrimEnd(Base64Padding)
|
||||
.Replace('+', '-')
|
||||
.Replace('/', '_');
|
||||
}
|
||||
|
||||
internal static byte[] ComputeNonceHash(string nonce)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(nonce);
|
||||
var bytes = Encoding.UTF8.GetBytes(nonce);
|
||||
return SHA256.HashData(bytes);
|
||||
}
|
||||
|
||||
internal static string EncodeHash(ReadOnlySpan<byte> hash)
|
||||
=> Convert.ToHexString(hash);
|
||||
|
||||
internal static string ComputeStorageKey(string audience, string clientId, string keyThumbprint)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(audience);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(clientId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(keyThumbprint);
|
||||
|
||||
return string.Create(
|
||||
"dpop-nonce:".Length + audience.Length + clientId.Length + keyThumbprint.Length + 2,
|
||||
(audience.Trim(), clientId.Trim(), keyThumbprint.Trim()),
|
||||
static (span, parts) =>
|
||||
{
|
||||
var index = 0;
|
||||
const string Prefix = "dpop-nonce:";
|
||||
Prefix.CopyTo(span);
|
||||
index += Prefix.Length;
|
||||
|
||||
index = Append(span, index, parts.Item1);
|
||||
span[index++] = ':';
|
||||
index = Append(span, index, parts.Item2);
|
||||
span[index++] = ':';
|
||||
_ = Append(span, index, parts.Item3);
|
||||
});
|
||||
|
||||
static int Append(Span<char> span, int index, string value)
|
||||
{
|
||||
if (value.Length == 0)
|
||||
{
|
||||
throw new ArgumentException("Value must not be empty after trimming.");
|
||||
}
|
||||
|
||||
value.AsSpan().CopyTo(span[index..]);
|
||||
return index + value.Length;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,258 +1,258 @@
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
|
||||
namespace StellaOps.Auth.Security.Dpop;
|
||||
|
||||
/// <summary>
|
||||
/// Validates DPoP proofs following RFC 9449.
|
||||
/// </summary>
|
||||
public sealed class DpopProofValidator : IDpopProofValidator
|
||||
{
|
||||
private static readonly string ProofType = "dpop+jwt";
|
||||
private readonly DpopValidationOptions options;
|
||||
private readonly IDpopReplayCache replayCache;
|
||||
private readonly TimeProvider timeProvider;
|
||||
private readonly ILogger<DpopProofValidator>? logger;
|
||||
private readonly JwtSecurityTokenHandler tokenHandler = new();
|
||||
|
||||
public DpopProofValidator(
|
||||
IOptions<DpopValidationOptions> options,
|
||||
IDpopReplayCache? replayCache = null,
|
||||
TimeProvider? timeProvider = null,
|
||||
ILogger<DpopProofValidator>? logger = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
var cloned = options.Value ?? throw new InvalidOperationException("DPoP options must be provided.");
|
||||
cloned.Validate();
|
||||
|
||||
this.options = cloned;
|
||||
this.replayCache = replayCache ?? NullReplayCache.Instance;
|
||||
this.timeProvider = timeProvider ?? TimeProvider.System;
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
public async ValueTask<DpopValidationResult> ValidateAsync(string proof, string httpMethod, Uri httpUri, string? nonce = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(proof);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(httpMethod);
|
||||
ArgumentNullException.ThrowIfNull(httpUri);
|
||||
|
||||
var now = timeProvider.GetUtcNow();
|
||||
|
||||
if (!TryDecodeSegment(proof, segmentIndex: 0, out var headerElement, out var headerError))
|
||||
{
|
||||
logger?.LogWarning("DPoP header decode failure: {Error}", headerError);
|
||||
return DpopValidationResult.Failure("invalid_header", headerError ?? "Unable to decode header.");
|
||||
}
|
||||
|
||||
if (!headerElement.TryGetProperty("typ", out var typElement) || !string.Equals(typElement.GetString(), ProofType, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return DpopValidationResult.Failure("invalid_header", "DPoP proof missing typ=dpop+jwt header.");
|
||||
}
|
||||
|
||||
if (!headerElement.TryGetProperty("alg", out var algElement))
|
||||
{
|
||||
return DpopValidationResult.Failure("invalid_header", "DPoP proof missing alg header.");
|
||||
}
|
||||
|
||||
var algorithm = algElement.GetString()?.Trim().ToUpperInvariant();
|
||||
if (string.IsNullOrEmpty(algorithm) || !options.NormalizedAlgorithms.Contains(algorithm))
|
||||
{
|
||||
return DpopValidationResult.Failure("invalid_header", "Unsupported DPoP algorithm.");
|
||||
}
|
||||
|
||||
if (!headerElement.TryGetProperty("jwk", out var jwkElement))
|
||||
{
|
||||
return DpopValidationResult.Failure("invalid_header", "DPoP proof missing jwk header.");
|
||||
}
|
||||
|
||||
JsonWebKey jwk;
|
||||
try
|
||||
{
|
||||
jwk = new JsonWebKey(jwkElement.GetRawText());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger?.LogWarning(ex, "Failed to parse DPoP jwk header.");
|
||||
return DpopValidationResult.Failure("invalid_header", "DPoP proof jwk header is invalid.");
|
||||
}
|
||||
|
||||
if (!TryDecodeSegment(proof, segmentIndex: 1, out var payloadElement, out var payloadError))
|
||||
{
|
||||
logger?.LogWarning("DPoP payload decode failure: {Error}", payloadError);
|
||||
return DpopValidationResult.Failure("invalid_payload", payloadError ?? "Unable to decode payload.");
|
||||
}
|
||||
|
||||
if (!payloadElement.TryGetProperty("htm", out var htmElement))
|
||||
{
|
||||
return DpopValidationResult.Failure("invalid_payload", "DPoP proof missing htm claim.");
|
||||
}
|
||||
|
||||
var method = httpMethod.Trim().ToUpperInvariant();
|
||||
if (!string.Equals(htmElement.GetString(), method, StringComparison.Ordinal))
|
||||
{
|
||||
return DpopValidationResult.Failure("invalid_payload", "DPoP htm does not match request method.");
|
||||
}
|
||||
|
||||
if (!payloadElement.TryGetProperty("htu", out var htuElement))
|
||||
{
|
||||
return DpopValidationResult.Failure("invalid_payload", "DPoP proof missing htu claim.");
|
||||
}
|
||||
|
||||
var normalizedHtu = NormalizeHtu(httpUri);
|
||||
if (!string.Equals(htuElement.GetString(), normalizedHtu, StringComparison.Ordinal))
|
||||
{
|
||||
return DpopValidationResult.Failure("invalid_payload", "DPoP htu does not match request URI.");
|
||||
}
|
||||
|
||||
if (!payloadElement.TryGetProperty("iat", out var iatElement) || iatElement.ValueKind is not JsonValueKind.Number)
|
||||
{
|
||||
return DpopValidationResult.Failure("invalid_payload", "DPoP proof missing iat claim.");
|
||||
}
|
||||
|
||||
if (!payloadElement.TryGetProperty("jti", out var jtiElement) || jtiElement.ValueKind != JsonValueKind.String)
|
||||
{
|
||||
return DpopValidationResult.Failure("invalid_payload", "DPoP proof missing jti claim.");
|
||||
}
|
||||
|
||||
long iatSeconds;
|
||||
try
|
||||
{
|
||||
iatSeconds = iatElement.GetInt64();
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return DpopValidationResult.Failure("invalid_payload", "DPoP proof iat claim is not a valid number.");
|
||||
}
|
||||
|
||||
var issuedAt = DateTimeOffset.FromUnixTimeSeconds(iatSeconds).ToUniversalTime();
|
||||
if (issuedAt - options.AllowedClockSkew > now)
|
||||
{
|
||||
return DpopValidationResult.Failure("invalid_token", "DPoP proof issued in the future.");
|
||||
}
|
||||
|
||||
if (now - issuedAt > options.GetMaximumAge())
|
||||
{
|
||||
return DpopValidationResult.Failure("invalid_token", "DPoP proof expired.");
|
||||
}
|
||||
|
||||
string? actualNonce = null;
|
||||
|
||||
if (nonce is not null)
|
||||
{
|
||||
if (!payloadElement.TryGetProperty("nonce", out var nonceElement) || nonceElement.ValueKind != JsonValueKind.String)
|
||||
{
|
||||
return DpopValidationResult.Failure("invalid_token", "DPoP proof missing nonce claim.");
|
||||
}
|
||||
|
||||
actualNonce = nonceElement.GetString();
|
||||
|
||||
if (!string.Equals(actualNonce, nonce, StringComparison.Ordinal))
|
||||
{
|
||||
return DpopValidationResult.Failure("invalid_token", "DPoP nonce mismatch.");
|
||||
}
|
||||
}
|
||||
else if (payloadElement.TryGetProperty("nonce", out var nonceElement) && nonceElement.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
actualNonce = nonceElement.GetString();
|
||||
}
|
||||
|
||||
var jwtId = jtiElement.GetString()!;
|
||||
|
||||
try
|
||||
{
|
||||
var parameters = new TokenValidationParameters
|
||||
{
|
||||
ValidateAudience = false,
|
||||
ValidateIssuer = false,
|
||||
ValidateLifetime = false,
|
||||
ValidateTokenReplay = false,
|
||||
RequireSignedTokens = true,
|
||||
ValidateIssuerSigningKey = true,
|
||||
IssuerSigningKey = jwk,
|
||||
ValidAlgorithms = options.NormalizedAlgorithms.ToArray()
|
||||
};
|
||||
|
||||
tokenHandler.ValidateToken(proof, parameters, out _);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger?.LogWarning(ex, "DPoP proof signature validation failed.");
|
||||
return DpopValidationResult.Failure("invalid_signature", "DPoP proof signature validation failed.");
|
||||
}
|
||||
|
||||
if (!await replayCache.TryStoreAsync(jwtId, issuedAt + options.ReplayWindow, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
return DpopValidationResult.Failure("replay", "DPoP proof already used.");
|
||||
}
|
||||
|
||||
return DpopValidationResult.Success(jwk, jwtId, issuedAt, actualNonce);
|
||||
}
|
||||
|
||||
private static string NormalizeHtu(Uri uri)
|
||||
{
|
||||
var builder = new UriBuilder(uri)
|
||||
{
|
||||
Fragment = null,
|
||||
Query = null
|
||||
};
|
||||
return builder.Uri.ToString();
|
||||
}
|
||||
|
||||
private static bool TryDecodeSegment(string token, int segmentIndex, out JsonElement element, out string? error)
|
||||
{
|
||||
element = default;
|
||||
error = null;
|
||||
|
||||
var segments = token.Split('.');
|
||||
if (segments.Length != 3)
|
||||
{
|
||||
error = "Token must contain three segments.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (segmentIndex < 0 || segmentIndex > 2)
|
||||
{
|
||||
error = "Segment index out of range.";
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var json = Base64UrlEncoder.Decode(segments[segmentIndex]);
|
||||
using var document = JsonDocument.Parse(json);
|
||||
element = document.RootElement.Clone();
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
error = ex.Message;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static class NullReplayCache
|
||||
{
|
||||
public static readonly IDpopReplayCache Instance = new Noop();
|
||||
|
||||
private sealed class Noop : IDpopReplayCache
|
||||
{
|
||||
public ValueTask<bool> TryStoreAsync(string jwtId, DateTimeOffset expiresAt, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(jwtId);
|
||||
return ValueTask.FromResult(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
file static class DpopValidationOptionsExtensions
|
||||
{
|
||||
public static TimeSpan GetMaximumAge(this DpopValidationOptions options)
|
||||
=> options.ProofLifetime + options.AllowedClockSkew;
|
||||
}
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
|
||||
namespace StellaOps.Auth.Security.Dpop;
|
||||
|
||||
/// <summary>
|
||||
/// Validates DPoP proofs following RFC 9449.
|
||||
/// </summary>
|
||||
public sealed class DpopProofValidator : IDpopProofValidator
|
||||
{
|
||||
private static readonly string ProofType = "dpop+jwt";
|
||||
private readonly DpopValidationOptions options;
|
||||
private readonly IDpopReplayCache replayCache;
|
||||
private readonly TimeProvider timeProvider;
|
||||
private readonly ILogger<DpopProofValidator>? logger;
|
||||
private readonly JwtSecurityTokenHandler tokenHandler = new();
|
||||
|
||||
public DpopProofValidator(
|
||||
IOptions<DpopValidationOptions> options,
|
||||
IDpopReplayCache? replayCache = null,
|
||||
TimeProvider? timeProvider = null,
|
||||
ILogger<DpopProofValidator>? logger = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
var cloned = options.Value ?? throw new InvalidOperationException("DPoP options must be provided.");
|
||||
cloned.Validate();
|
||||
|
||||
this.options = cloned;
|
||||
this.replayCache = replayCache ?? NullReplayCache.Instance;
|
||||
this.timeProvider = timeProvider ?? TimeProvider.System;
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
public async ValueTask<DpopValidationResult> ValidateAsync(string proof, string httpMethod, Uri httpUri, string? nonce = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(proof);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(httpMethod);
|
||||
ArgumentNullException.ThrowIfNull(httpUri);
|
||||
|
||||
var now = timeProvider.GetUtcNow();
|
||||
|
||||
if (!TryDecodeSegment(proof, segmentIndex: 0, out var headerElement, out var headerError))
|
||||
{
|
||||
logger?.LogWarning("DPoP header decode failure: {Error}", headerError);
|
||||
return DpopValidationResult.Failure("invalid_header", headerError ?? "Unable to decode header.");
|
||||
}
|
||||
|
||||
if (!headerElement.TryGetProperty("typ", out var typElement) || !string.Equals(typElement.GetString(), ProofType, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return DpopValidationResult.Failure("invalid_header", "DPoP proof missing typ=dpop+jwt header.");
|
||||
}
|
||||
|
||||
if (!headerElement.TryGetProperty("alg", out var algElement))
|
||||
{
|
||||
return DpopValidationResult.Failure("invalid_header", "DPoP proof missing alg header.");
|
||||
}
|
||||
|
||||
var algorithm = algElement.GetString()?.Trim().ToUpperInvariant();
|
||||
if (string.IsNullOrEmpty(algorithm) || !options.NormalizedAlgorithms.Contains(algorithm))
|
||||
{
|
||||
return DpopValidationResult.Failure("invalid_header", "Unsupported DPoP algorithm.");
|
||||
}
|
||||
|
||||
if (!headerElement.TryGetProperty("jwk", out var jwkElement))
|
||||
{
|
||||
return DpopValidationResult.Failure("invalid_header", "DPoP proof missing jwk header.");
|
||||
}
|
||||
|
||||
JsonWebKey jwk;
|
||||
try
|
||||
{
|
||||
jwk = new JsonWebKey(jwkElement.GetRawText());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger?.LogWarning(ex, "Failed to parse DPoP jwk header.");
|
||||
return DpopValidationResult.Failure("invalid_header", "DPoP proof jwk header is invalid.");
|
||||
}
|
||||
|
||||
if (!TryDecodeSegment(proof, segmentIndex: 1, out var payloadElement, out var payloadError))
|
||||
{
|
||||
logger?.LogWarning("DPoP payload decode failure: {Error}", payloadError);
|
||||
return DpopValidationResult.Failure("invalid_payload", payloadError ?? "Unable to decode payload.");
|
||||
}
|
||||
|
||||
if (!payloadElement.TryGetProperty("htm", out var htmElement))
|
||||
{
|
||||
return DpopValidationResult.Failure("invalid_payload", "DPoP proof missing htm claim.");
|
||||
}
|
||||
|
||||
var method = httpMethod.Trim().ToUpperInvariant();
|
||||
if (!string.Equals(htmElement.GetString(), method, StringComparison.Ordinal))
|
||||
{
|
||||
return DpopValidationResult.Failure("invalid_payload", "DPoP htm does not match request method.");
|
||||
}
|
||||
|
||||
if (!payloadElement.TryGetProperty("htu", out var htuElement))
|
||||
{
|
||||
return DpopValidationResult.Failure("invalid_payload", "DPoP proof missing htu claim.");
|
||||
}
|
||||
|
||||
var normalizedHtu = NormalizeHtu(httpUri);
|
||||
if (!string.Equals(htuElement.GetString(), normalizedHtu, StringComparison.Ordinal))
|
||||
{
|
||||
return DpopValidationResult.Failure("invalid_payload", "DPoP htu does not match request URI.");
|
||||
}
|
||||
|
||||
if (!payloadElement.TryGetProperty("iat", out var iatElement) || iatElement.ValueKind is not JsonValueKind.Number)
|
||||
{
|
||||
return DpopValidationResult.Failure("invalid_payload", "DPoP proof missing iat claim.");
|
||||
}
|
||||
|
||||
if (!payloadElement.TryGetProperty("jti", out var jtiElement) || jtiElement.ValueKind != JsonValueKind.String)
|
||||
{
|
||||
return DpopValidationResult.Failure("invalid_payload", "DPoP proof missing jti claim.");
|
||||
}
|
||||
|
||||
long iatSeconds;
|
||||
try
|
||||
{
|
||||
iatSeconds = iatElement.GetInt64();
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return DpopValidationResult.Failure("invalid_payload", "DPoP proof iat claim is not a valid number.");
|
||||
}
|
||||
|
||||
var issuedAt = DateTimeOffset.FromUnixTimeSeconds(iatSeconds).ToUniversalTime();
|
||||
if (issuedAt - options.AllowedClockSkew > now)
|
||||
{
|
||||
return DpopValidationResult.Failure("invalid_token", "DPoP proof issued in the future.");
|
||||
}
|
||||
|
||||
if (now - issuedAt > options.GetMaximumAge())
|
||||
{
|
||||
return DpopValidationResult.Failure("invalid_token", "DPoP proof expired.");
|
||||
}
|
||||
|
||||
string? actualNonce = null;
|
||||
|
||||
if (nonce is not null)
|
||||
{
|
||||
if (!payloadElement.TryGetProperty("nonce", out var nonceElement) || nonceElement.ValueKind != JsonValueKind.String)
|
||||
{
|
||||
return DpopValidationResult.Failure("invalid_token", "DPoP proof missing nonce claim.");
|
||||
}
|
||||
|
||||
actualNonce = nonceElement.GetString();
|
||||
|
||||
if (!string.Equals(actualNonce, nonce, StringComparison.Ordinal))
|
||||
{
|
||||
return DpopValidationResult.Failure("invalid_token", "DPoP nonce mismatch.");
|
||||
}
|
||||
}
|
||||
else if (payloadElement.TryGetProperty("nonce", out var nonceElement) && nonceElement.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
actualNonce = nonceElement.GetString();
|
||||
}
|
||||
|
||||
var jwtId = jtiElement.GetString()!;
|
||||
|
||||
try
|
||||
{
|
||||
var parameters = new TokenValidationParameters
|
||||
{
|
||||
ValidateAudience = false,
|
||||
ValidateIssuer = false,
|
||||
ValidateLifetime = false,
|
||||
ValidateTokenReplay = false,
|
||||
RequireSignedTokens = true,
|
||||
ValidateIssuerSigningKey = true,
|
||||
IssuerSigningKey = jwk,
|
||||
ValidAlgorithms = options.NormalizedAlgorithms.ToArray()
|
||||
};
|
||||
|
||||
tokenHandler.ValidateToken(proof, parameters, out _);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger?.LogWarning(ex, "DPoP proof signature validation failed.");
|
||||
return DpopValidationResult.Failure("invalid_signature", "DPoP proof signature validation failed.");
|
||||
}
|
||||
|
||||
if (!await replayCache.TryStoreAsync(jwtId, issuedAt + options.ReplayWindow, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
return DpopValidationResult.Failure("replay", "DPoP proof already used.");
|
||||
}
|
||||
|
||||
return DpopValidationResult.Success(jwk, jwtId, issuedAt, actualNonce);
|
||||
}
|
||||
|
||||
private static string NormalizeHtu(Uri uri)
|
||||
{
|
||||
var builder = new UriBuilder(uri)
|
||||
{
|
||||
Fragment = null,
|
||||
Query = null
|
||||
};
|
||||
return builder.Uri.ToString();
|
||||
}
|
||||
|
||||
private static bool TryDecodeSegment(string token, int segmentIndex, out JsonElement element, out string? error)
|
||||
{
|
||||
element = default;
|
||||
error = null;
|
||||
|
||||
var segments = token.Split('.');
|
||||
if (segments.Length != 3)
|
||||
{
|
||||
error = "Token must contain three segments.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (segmentIndex < 0 || segmentIndex > 2)
|
||||
{
|
||||
error = "Segment index out of range.";
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var json = Base64UrlEncoder.Decode(segments[segmentIndex]);
|
||||
using var document = JsonDocument.Parse(json);
|
||||
element = document.RootElement.Clone();
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
error = ex.Message;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static class NullReplayCache
|
||||
{
|
||||
public static readonly IDpopReplayCache Instance = new Noop();
|
||||
|
||||
private sealed class Noop : IDpopReplayCache
|
||||
{
|
||||
public ValueTask<bool> TryStoreAsync(string jwtId, DateTimeOffset expiresAt, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(jwtId);
|
||||
return ValueTask.FromResult(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
file static class DpopValidationOptionsExtensions
|
||||
{
|
||||
public static TimeSpan GetMaximumAge(this DpopValidationOptions options)
|
||||
=> options.ProofLifetime + options.AllowedClockSkew;
|
||||
}
|
||||
|
||||
@@ -1,77 +1,77 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace StellaOps.Auth.Security.Dpop;
|
||||
|
||||
/// <summary>
|
||||
/// Configures acceptable algorithms and replay windows for DPoP proof validation.
|
||||
/// </summary>
|
||||
public sealed class DpopValidationOptions
|
||||
{
|
||||
private readonly HashSet<string> allowedAlgorithms = new(StringComparer.Ordinal);
|
||||
|
||||
public DpopValidationOptions()
|
||||
{
|
||||
allowedAlgorithms.Add("ES256");
|
||||
allowedAlgorithms.Add("ES384");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maximum age a proof is considered valid relative to <see cref="IssuedAt"/>.
|
||||
/// </summary>
|
||||
public TimeSpan ProofLifetime { get; set; } = TimeSpan.FromMinutes(2);
|
||||
|
||||
/// <summary>
|
||||
/// Allowed clock skew when evaluating <c>iat</c>.
|
||||
/// </summary>
|
||||
public TimeSpan AllowedClockSkew { get; set; } = TimeSpan.FromSeconds(30);
|
||||
|
||||
/// <summary>
|
||||
/// Duration a successfully validated proof is tracked to prevent replay.
|
||||
/// </summary>
|
||||
public TimeSpan ReplayWindow { get; set; } = TimeSpan.FromMinutes(5);
|
||||
|
||||
/// <summary>
|
||||
/// Algorithms (JWA) permitted for DPoP proofs.
|
||||
/// </summary>
|
||||
public ISet<string> AllowedAlgorithms => allowedAlgorithms;
|
||||
|
||||
/// <summary>
|
||||
/// Normalised, upper-case representation of allowed algorithms.
|
||||
/// </summary>
|
||||
public IReadOnlySet<string> NormalizedAlgorithms { get; private set; } = ImmutableHashSet<string>.Empty;
|
||||
|
||||
public void Validate()
|
||||
{
|
||||
if (ProofLifetime <= TimeSpan.Zero)
|
||||
{
|
||||
throw new InvalidOperationException("DPoP proof lifetime must be greater than zero.");
|
||||
}
|
||||
|
||||
if (AllowedClockSkew < TimeSpan.Zero || AllowedClockSkew > TimeSpan.FromMinutes(5))
|
||||
{
|
||||
throw new InvalidOperationException("DPoP allowed clock skew must be between 0 seconds and 5 minutes.");
|
||||
}
|
||||
|
||||
if (ReplayWindow < TimeSpan.Zero)
|
||||
{
|
||||
throw new InvalidOperationException("DPoP replay window must be greater than or equal to zero.");
|
||||
}
|
||||
|
||||
if (allowedAlgorithms.Count == 0)
|
||||
{
|
||||
throw new InvalidOperationException("At least one allowed DPoP algorithm must be configured.");
|
||||
}
|
||||
|
||||
NormalizedAlgorithms = allowedAlgorithms
|
||||
.Select(static algorithm => algorithm.Trim().ToUpperInvariant())
|
||||
.Where(static algorithm => algorithm.Length > 0)
|
||||
.ToImmutableHashSet(StringComparer.Ordinal);
|
||||
|
||||
if (NormalizedAlgorithms.Count == 0)
|
||||
{
|
||||
throw new InvalidOperationException("Allowed DPoP algorithms cannot be empty after normalization.");
|
||||
}
|
||||
}
|
||||
}
|
||||
using System.Collections.Immutable;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace StellaOps.Auth.Security.Dpop;
|
||||
|
||||
/// <summary>
|
||||
/// Configures acceptable algorithms and replay windows for DPoP proof validation.
|
||||
/// </summary>
|
||||
public sealed class DpopValidationOptions
|
||||
{
|
||||
private readonly HashSet<string> allowedAlgorithms = new(StringComparer.Ordinal);
|
||||
|
||||
public DpopValidationOptions()
|
||||
{
|
||||
allowedAlgorithms.Add("ES256");
|
||||
allowedAlgorithms.Add("ES384");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maximum age a proof is considered valid relative to <see cref="IssuedAt"/>.
|
||||
/// </summary>
|
||||
public TimeSpan ProofLifetime { get; set; } = TimeSpan.FromMinutes(2);
|
||||
|
||||
/// <summary>
|
||||
/// Allowed clock skew when evaluating <c>iat</c>.
|
||||
/// </summary>
|
||||
public TimeSpan AllowedClockSkew { get; set; } = TimeSpan.FromSeconds(30);
|
||||
|
||||
/// <summary>
|
||||
/// Duration a successfully validated proof is tracked to prevent replay.
|
||||
/// </summary>
|
||||
public TimeSpan ReplayWindow { get; set; } = TimeSpan.FromMinutes(5);
|
||||
|
||||
/// <summary>
|
||||
/// Algorithms (JWA) permitted for DPoP proofs.
|
||||
/// </summary>
|
||||
public ISet<string> AllowedAlgorithms => allowedAlgorithms;
|
||||
|
||||
/// <summary>
|
||||
/// Normalised, upper-case representation of allowed algorithms.
|
||||
/// </summary>
|
||||
public IReadOnlySet<string> NormalizedAlgorithms { get; private set; } = ImmutableHashSet<string>.Empty;
|
||||
|
||||
public void Validate()
|
||||
{
|
||||
if (ProofLifetime <= TimeSpan.Zero)
|
||||
{
|
||||
throw new InvalidOperationException("DPoP proof lifetime must be greater than zero.");
|
||||
}
|
||||
|
||||
if (AllowedClockSkew < TimeSpan.Zero || AllowedClockSkew > TimeSpan.FromMinutes(5))
|
||||
{
|
||||
throw new InvalidOperationException("DPoP allowed clock skew must be between 0 seconds and 5 minutes.");
|
||||
}
|
||||
|
||||
if (ReplayWindow < TimeSpan.Zero)
|
||||
{
|
||||
throw new InvalidOperationException("DPoP replay window must be greater than or equal to zero.");
|
||||
}
|
||||
|
||||
if (allowedAlgorithms.Count == 0)
|
||||
{
|
||||
throw new InvalidOperationException("At least one allowed DPoP algorithm must be configured.");
|
||||
}
|
||||
|
||||
NormalizedAlgorithms = allowedAlgorithms
|
||||
.Select(static algorithm => algorithm.Trim().ToUpperInvariant())
|
||||
.Where(static algorithm => algorithm.Length > 0)
|
||||
.ToImmutableHashSet(StringComparer.Ordinal);
|
||||
|
||||
if (NormalizedAlgorithms.Count == 0)
|
||||
{
|
||||
throw new InvalidOperationException("Allowed DPoP algorithms cannot be empty after normalization.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,40 +1,40 @@
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
|
||||
namespace StellaOps.Auth.Security.Dpop;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the outcome of DPoP proof validation.
|
||||
/// </summary>
|
||||
public sealed class DpopValidationResult
|
||||
{
|
||||
private DpopValidationResult(bool success, string? errorCode, string? errorDescription, SecurityKey? key, string? jwtId, DateTimeOffset? issuedAt, string? nonce)
|
||||
{
|
||||
IsValid = success;
|
||||
ErrorCode = errorCode;
|
||||
ErrorDescription = errorDescription;
|
||||
PublicKey = key;
|
||||
JwtId = jwtId;
|
||||
IssuedAt = issuedAt;
|
||||
Nonce = nonce;
|
||||
}
|
||||
|
||||
public bool IsValid { get; }
|
||||
|
||||
public string? ErrorCode { get; }
|
||||
|
||||
public string? ErrorDescription { get; }
|
||||
|
||||
public SecurityKey? PublicKey { get; }
|
||||
|
||||
public string? JwtId { get; }
|
||||
|
||||
public DateTimeOffset? IssuedAt { get; }
|
||||
|
||||
public string? Nonce { get; }
|
||||
|
||||
public static DpopValidationResult Success(SecurityKey key, string jwtId, DateTimeOffset issuedAt, string? nonce)
|
||||
=> new(true, null, null, key, jwtId, issuedAt, nonce);
|
||||
|
||||
public static DpopValidationResult Failure(string code, string description)
|
||||
=> new(false, code, description, null, null, null, null);
|
||||
}
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
|
||||
namespace StellaOps.Auth.Security.Dpop;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the outcome of DPoP proof validation.
|
||||
/// </summary>
|
||||
public sealed class DpopValidationResult
|
||||
{
|
||||
private DpopValidationResult(bool success, string? errorCode, string? errorDescription, SecurityKey? key, string? jwtId, DateTimeOffset? issuedAt, string? nonce)
|
||||
{
|
||||
IsValid = success;
|
||||
ErrorCode = errorCode;
|
||||
ErrorDescription = errorDescription;
|
||||
PublicKey = key;
|
||||
JwtId = jwtId;
|
||||
IssuedAt = issuedAt;
|
||||
Nonce = nonce;
|
||||
}
|
||||
|
||||
public bool IsValid { get; }
|
||||
|
||||
public string? ErrorCode { get; }
|
||||
|
||||
public string? ErrorDescription { get; }
|
||||
|
||||
public SecurityKey? PublicKey { get; }
|
||||
|
||||
public string? JwtId { get; }
|
||||
|
||||
public DateTimeOffset? IssuedAt { get; }
|
||||
|
||||
public string? Nonce { get; }
|
||||
|
||||
public static DpopValidationResult Success(SecurityKey key, string jwtId, DateTimeOffset issuedAt, string? nonce)
|
||||
=> new(true, null, null, key, jwtId, issuedAt, nonce);
|
||||
|
||||
public static DpopValidationResult Failure(string code, string description)
|
||||
=> new(false, code, description, null, null, null, null);
|
||||
}
|
||||
|
||||
@@ -1,45 +1,45 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Auth.Security.Dpop;
|
||||
|
||||
/// <summary>
|
||||
/// Provides persistence and validation for DPoP nonces.
|
||||
/// </summary>
|
||||
public interface IDpopNonceStore
|
||||
{
|
||||
/// <summary>
|
||||
/// Issues a nonce tied to the specified audience, client, and DPoP key thumbprint.
|
||||
/// </summary>
|
||||
/// <param name="audience">Audience the nonce applies to.</param>
|
||||
/// <param name="clientId">Client identifier requesting the nonce.</param>
|
||||
/// <param name="keyThumbprint">Thumbprint of the DPoP public key.</param>
|
||||
/// <param name="ttl">Time-to-live for the nonce.</param>
|
||||
/// <param name="maxIssuancePerMinute">Maximum number of nonces that can be issued within a one-minute window for the tuple.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Outcome describing the issued nonce.</returns>
|
||||
ValueTask<DpopNonceIssueResult> IssueAsync(
|
||||
string audience,
|
||||
string clientId,
|
||||
string keyThumbprint,
|
||||
TimeSpan ttl,
|
||||
int maxIssuancePerMinute,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to consume a nonce previously issued for the tuple.
|
||||
/// </summary>
|
||||
/// <param name="nonce">Nonce supplied by the client.</param>
|
||||
/// <param name="audience">Audience the nonce should match.</param>
|
||||
/// <param name="clientId">Client identifier.</param>
|
||||
/// <param name="keyThumbprint">Thumbprint of the DPoP public key.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Outcome describing whether the nonce was accepted.</returns>
|
||||
ValueTask<DpopNonceConsumeResult> TryConsumeAsync(
|
||||
string nonce,
|
||||
string audience,
|
||||
string clientId,
|
||||
string keyThumbprint,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Auth.Security.Dpop;
|
||||
|
||||
/// <summary>
|
||||
/// Provides persistence and validation for DPoP nonces.
|
||||
/// </summary>
|
||||
public interface IDpopNonceStore
|
||||
{
|
||||
/// <summary>
|
||||
/// Issues a nonce tied to the specified audience, client, and DPoP key thumbprint.
|
||||
/// </summary>
|
||||
/// <param name="audience">Audience the nonce applies to.</param>
|
||||
/// <param name="clientId">Client identifier requesting the nonce.</param>
|
||||
/// <param name="keyThumbprint">Thumbprint of the DPoP public key.</param>
|
||||
/// <param name="ttl">Time-to-live for the nonce.</param>
|
||||
/// <param name="maxIssuancePerMinute">Maximum number of nonces that can be issued within a one-minute window for the tuple.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Outcome describing the issued nonce.</returns>
|
||||
ValueTask<DpopNonceIssueResult> IssueAsync(
|
||||
string audience,
|
||||
string clientId,
|
||||
string keyThumbprint,
|
||||
TimeSpan ttl,
|
||||
int maxIssuancePerMinute,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to consume a nonce previously issued for the tuple.
|
||||
/// </summary>
|
||||
/// <param name="nonce">Nonce supplied by the client.</param>
|
||||
/// <param name="audience">Audience the nonce should match.</param>
|
||||
/// <param name="clientId">Client identifier.</param>
|
||||
/// <param name="keyThumbprint">Thumbprint of the DPoP public key.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Outcome describing whether the nonce was accepted.</returns>
|
||||
ValueTask<DpopNonceConsumeResult> TryConsumeAsync(
|
||||
string nonce,
|
||||
string audience,
|
||||
string clientId,
|
||||
string keyThumbprint,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
namespace StellaOps.Auth.Security.Dpop;
|
||||
|
||||
public interface IDpopProofValidator
|
||||
{
|
||||
ValueTask<DpopValidationResult> ValidateAsync(string proof, string httpMethod, Uri httpUri, string? nonce = null, CancellationToken cancellationToken = default);
|
||||
}
|
||||
namespace StellaOps.Auth.Security.Dpop;
|
||||
|
||||
public interface IDpopProofValidator
|
||||
{
|
||||
ValueTask<DpopValidationResult> ValidateAsync(string proof, string httpMethod, Uri httpUri, string? nonce = null, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
namespace StellaOps.Auth.Security.Dpop;
|
||||
|
||||
public interface IDpopReplayCache
|
||||
{
|
||||
ValueTask<bool> TryStoreAsync(string jwtId, DateTimeOffset expiresAt, CancellationToken cancellationToken = default);
|
||||
}
|
||||
namespace StellaOps.Auth.Security.Dpop;
|
||||
|
||||
public interface IDpopReplayCache
|
||||
{
|
||||
ValueTask<bool> TryStoreAsync(string jwtId, DateTimeOffset expiresAt, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
@@ -1,176 +1,176 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Auth.Security.Dpop;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of <see cref="IDpopNonceStore"/> suitable for single-host or test environments.
|
||||
/// </summary>
|
||||
public sealed class InMemoryDpopNonceStore : IDpopNonceStore
|
||||
{
|
||||
private static readonly TimeSpan IssuanceWindow = TimeSpan.FromMinutes(1);
|
||||
private readonly ConcurrentDictionary<string, StoredNonce> nonces = new(StringComparer.Ordinal);
|
||||
private readonly ConcurrentDictionary<string, IssuanceBucket> issuanceBuckets = new(StringComparer.Ordinal);
|
||||
private readonly TimeProvider timeProvider;
|
||||
private readonly ILogger<InMemoryDpopNonceStore>? logger;
|
||||
|
||||
public InMemoryDpopNonceStore(TimeProvider? timeProvider = null, ILogger<InMemoryDpopNonceStore>? logger = null)
|
||||
{
|
||||
this.timeProvider = timeProvider ?? TimeProvider.System;
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
public ValueTask<DpopNonceIssueResult> IssueAsync(
|
||||
string audience,
|
||||
string clientId,
|
||||
string keyThumbprint,
|
||||
TimeSpan ttl,
|
||||
int maxIssuancePerMinute,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(audience);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(clientId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(keyThumbprint);
|
||||
|
||||
if (ttl <= TimeSpan.Zero)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(ttl), "Nonce TTL must be greater than zero.");
|
||||
}
|
||||
|
||||
if (maxIssuancePerMinute < 1)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(maxIssuancePerMinute), "Max issuance per minute must be at least 1.");
|
||||
}
|
||||
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var now = timeProvider.GetUtcNow();
|
||||
var bucketKey = BuildBucketKey(audience, clientId, keyThumbprint);
|
||||
var bucket = issuanceBuckets.GetOrAdd(bucketKey, static _ => new IssuanceBucket());
|
||||
|
||||
bool allowed;
|
||||
lock (bucket.SyncRoot)
|
||||
{
|
||||
bucket.Prune(now - IssuanceWindow);
|
||||
|
||||
if (bucket.IssuanceTimes.Count >= maxIssuancePerMinute)
|
||||
{
|
||||
allowed = false;
|
||||
}
|
||||
else
|
||||
{
|
||||
bucket.IssuanceTimes.Enqueue(now);
|
||||
allowed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!allowed)
|
||||
{
|
||||
logger?.LogDebug("DPoP nonce issuance throttled for {BucketKey}.", bucketKey);
|
||||
return ValueTask.FromResult(DpopNonceIssueResult.RateLimited("rate_limited"));
|
||||
}
|
||||
|
||||
var nonce = GenerateNonce();
|
||||
var nonceKey = BuildNonceKey(audience, clientId, keyThumbprint, nonce);
|
||||
var expiresAt = now + ttl;
|
||||
nonces[nonceKey] = new StoredNonce(now, expiresAt);
|
||||
return ValueTask.FromResult(DpopNonceIssueResult.Success(nonce, expiresAt));
|
||||
}
|
||||
|
||||
public ValueTask<DpopNonceConsumeResult> TryConsumeAsync(
|
||||
string nonce,
|
||||
string audience,
|
||||
string clientId,
|
||||
string keyThumbprint,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(nonce);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(audience);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(clientId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(keyThumbprint);
|
||||
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var now = timeProvider.GetUtcNow();
|
||||
var nonceKey = BuildNonceKey(audience, clientId, keyThumbprint, nonce);
|
||||
|
||||
if (!nonces.TryRemove(nonceKey, out var stored))
|
||||
{
|
||||
logger?.LogDebug("DPoP nonce {NonceKey} not found during consumption.", nonceKey);
|
||||
return ValueTask.FromResult(DpopNonceConsumeResult.NotFound());
|
||||
}
|
||||
|
||||
if (stored.ExpiresAt <= now)
|
||||
{
|
||||
logger?.LogDebug("DPoP nonce {NonceKey} expired at {ExpiresAt:o}.", nonceKey, stored.ExpiresAt);
|
||||
return ValueTask.FromResult(DpopNonceConsumeResult.Expired(stored.IssuedAt, stored.ExpiresAt));
|
||||
}
|
||||
|
||||
return ValueTask.FromResult(DpopNonceConsumeResult.Success(stored.IssuedAt, stored.ExpiresAt));
|
||||
}
|
||||
|
||||
private static string BuildBucketKey(string audience, string clientId, string keyThumbprint)
|
||||
=> $"{audience.Trim().ToLowerInvariant()}::{clientId.Trim().ToLowerInvariant()}::{keyThumbprint.Trim().ToLowerInvariant()}";
|
||||
|
||||
private static string BuildNonceKey(string audience, string clientId, string keyThumbprint, string nonce)
|
||||
{
|
||||
var bucketKey = BuildBucketKey(audience, clientId, keyThumbprint);
|
||||
var digest = ComputeSha256(nonce);
|
||||
return $"{bucketKey}::{digest}";
|
||||
}
|
||||
|
||||
private static string ComputeSha256(string value)
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes(value);
|
||||
var hash = SHA256.HashData(bytes);
|
||||
return Base64UrlEncode(hash);
|
||||
}
|
||||
|
||||
private static string Base64UrlEncode(ReadOnlySpan<byte> bytes)
|
||||
{
|
||||
return Convert.ToBase64String(bytes)
|
||||
.TrimEnd('=')
|
||||
.Replace('+', '-')
|
||||
.Replace('/', '_');
|
||||
}
|
||||
|
||||
private static string GenerateNonce()
|
||||
{
|
||||
Span<byte> buffer = stackalloc byte[32];
|
||||
RandomNumberGenerator.Fill(buffer);
|
||||
return Base64UrlEncode(buffer);
|
||||
}
|
||||
|
||||
private sealed class StoredNonce
|
||||
{
|
||||
internal StoredNonce(DateTimeOffset issuedAt, DateTimeOffset expiresAt)
|
||||
{
|
||||
IssuedAt = issuedAt;
|
||||
ExpiresAt = expiresAt;
|
||||
}
|
||||
|
||||
internal DateTimeOffset IssuedAt { get; }
|
||||
|
||||
internal DateTimeOffset ExpiresAt { get; }
|
||||
}
|
||||
|
||||
private sealed class IssuanceBucket
|
||||
{
|
||||
internal object SyncRoot { get; } = new();
|
||||
internal Queue<DateTimeOffset> IssuanceTimes { get; } = new();
|
||||
|
||||
internal void Prune(DateTimeOffset threshold)
|
||||
{
|
||||
while (IssuanceTimes.Count > 0 && IssuanceTimes.Peek() < threshold)
|
||||
{
|
||||
IssuanceTimes.Dequeue();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Auth.Security.Dpop;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of <see cref="IDpopNonceStore"/> suitable for single-host or test environments.
|
||||
/// </summary>
|
||||
public sealed class InMemoryDpopNonceStore : IDpopNonceStore
|
||||
{
|
||||
private static readonly TimeSpan IssuanceWindow = TimeSpan.FromMinutes(1);
|
||||
private readonly ConcurrentDictionary<string, StoredNonce> nonces = new(StringComparer.Ordinal);
|
||||
private readonly ConcurrentDictionary<string, IssuanceBucket> issuanceBuckets = new(StringComparer.Ordinal);
|
||||
private readonly TimeProvider timeProvider;
|
||||
private readonly ILogger<InMemoryDpopNonceStore>? logger;
|
||||
|
||||
public InMemoryDpopNonceStore(TimeProvider? timeProvider = null, ILogger<InMemoryDpopNonceStore>? logger = null)
|
||||
{
|
||||
this.timeProvider = timeProvider ?? TimeProvider.System;
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
public ValueTask<DpopNonceIssueResult> IssueAsync(
|
||||
string audience,
|
||||
string clientId,
|
||||
string keyThumbprint,
|
||||
TimeSpan ttl,
|
||||
int maxIssuancePerMinute,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(audience);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(clientId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(keyThumbprint);
|
||||
|
||||
if (ttl <= TimeSpan.Zero)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(ttl), "Nonce TTL must be greater than zero.");
|
||||
}
|
||||
|
||||
if (maxIssuancePerMinute < 1)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(maxIssuancePerMinute), "Max issuance per minute must be at least 1.");
|
||||
}
|
||||
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var now = timeProvider.GetUtcNow();
|
||||
var bucketKey = BuildBucketKey(audience, clientId, keyThumbprint);
|
||||
var bucket = issuanceBuckets.GetOrAdd(bucketKey, static _ => new IssuanceBucket());
|
||||
|
||||
bool allowed;
|
||||
lock (bucket.SyncRoot)
|
||||
{
|
||||
bucket.Prune(now - IssuanceWindow);
|
||||
|
||||
if (bucket.IssuanceTimes.Count >= maxIssuancePerMinute)
|
||||
{
|
||||
allowed = false;
|
||||
}
|
||||
else
|
||||
{
|
||||
bucket.IssuanceTimes.Enqueue(now);
|
||||
allowed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!allowed)
|
||||
{
|
||||
logger?.LogDebug("DPoP nonce issuance throttled for {BucketKey}.", bucketKey);
|
||||
return ValueTask.FromResult(DpopNonceIssueResult.RateLimited("rate_limited"));
|
||||
}
|
||||
|
||||
var nonce = GenerateNonce();
|
||||
var nonceKey = BuildNonceKey(audience, clientId, keyThumbprint, nonce);
|
||||
var expiresAt = now + ttl;
|
||||
nonces[nonceKey] = new StoredNonce(now, expiresAt);
|
||||
return ValueTask.FromResult(DpopNonceIssueResult.Success(nonce, expiresAt));
|
||||
}
|
||||
|
||||
public ValueTask<DpopNonceConsumeResult> TryConsumeAsync(
|
||||
string nonce,
|
||||
string audience,
|
||||
string clientId,
|
||||
string keyThumbprint,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(nonce);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(audience);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(clientId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(keyThumbprint);
|
||||
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var now = timeProvider.GetUtcNow();
|
||||
var nonceKey = BuildNonceKey(audience, clientId, keyThumbprint, nonce);
|
||||
|
||||
if (!nonces.TryRemove(nonceKey, out var stored))
|
||||
{
|
||||
logger?.LogDebug("DPoP nonce {NonceKey} not found during consumption.", nonceKey);
|
||||
return ValueTask.FromResult(DpopNonceConsumeResult.NotFound());
|
||||
}
|
||||
|
||||
if (stored.ExpiresAt <= now)
|
||||
{
|
||||
logger?.LogDebug("DPoP nonce {NonceKey} expired at {ExpiresAt:o}.", nonceKey, stored.ExpiresAt);
|
||||
return ValueTask.FromResult(DpopNonceConsumeResult.Expired(stored.IssuedAt, stored.ExpiresAt));
|
||||
}
|
||||
|
||||
return ValueTask.FromResult(DpopNonceConsumeResult.Success(stored.IssuedAt, stored.ExpiresAt));
|
||||
}
|
||||
|
||||
private static string BuildBucketKey(string audience, string clientId, string keyThumbprint)
|
||||
=> $"{audience.Trim().ToLowerInvariant()}::{clientId.Trim().ToLowerInvariant()}::{keyThumbprint.Trim().ToLowerInvariant()}";
|
||||
|
||||
private static string BuildNonceKey(string audience, string clientId, string keyThumbprint, string nonce)
|
||||
{
|
||||
var bucketKey = BuildBucketKey(audience, clientId, keyThumbprint);
|
||||
var digest = ComputeSha256(nonce);
|
||||
return $"{bucketKey}::{digest}";
|
||||
}
|
||||
|
||||
private static string ComputeSha256(string value)
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes(value);
|
||||
var hash = SHA256.HashData(bytes);
|
||||
return Base64UrlEncode(hash);
|
||||
}
|
||||
|
||||
private static string Base64UrlEncode(ReadOnlySpan<byte> bytes)
|
||||
{
|
||||
return Convert.ToBase64String(bytes)
|
||||
.TrimEnd('=')
|
||||
.Replace('+', '-')
|
||||
.Replace('/', '_');
|
||||
}
|
||||
|
||||
private static string GenerateNonce()
|
||||
{
|
||||
Span<byte> buffer = stackalloc byte[32];
|
||||
RandomNumberGenerator.Fill(buffer);
|
||||
return Base64UrlEncode(buffer);
|
||||
}
|
||||
|
||||
private sealed class StoredNonce
|
||||
{
|
||||
internal StoredNonce(DateTimeOffset issuedAt, DateTimeOffset expiresAt)
|
||||
{
|
||||
IssuedAt = issuedAt;
|
||||
ExpiresAt = expiresAt;
|
||||
}
|
||||
|
||||
internal DateTimeOffset IssuedAt { get; }
|
||||
|
||||
internal DateTimeOffset ExpiresAt { get; }
|
||||
}
|
||||
|
||||
private sealed class IssuanceBucket
|
||||
{
|
||||
internal object SyncRoot { get; } = new();
|
||||
internal Queue<DateTimeOffset> IssuanceTimes { get; } = new();
|
||||
|
||||
internal void Prune(DateTimeOffset threshold)
|
||||
{
|
||||
while (IssuanceTimes.Count > 0 && IssuanceTimes.Peek() < threshold)
|
||||
{
|
||||
IssuanceTimes.Dequeue();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,66 +1,66 @@
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace StellaOps.Auth.Security.Dpop;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory replay cache intended for single-process deployments or tests.
|
||||
/// </summary>
|
||||
public sealed class InMemoryDpopReplayCache : IDpopReplayCache
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, DateTimeOffset> entries = new(StringComparer.Ordinal);
|
||||
private readonly TimeProvider timeProvider;
|
||||
|
||||
public InMemoryDpopReplayCache(TimeProvider? timeProvider = null)
|
||||
{
|
||||
this.timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public ValueTask<bool> TryStoreAsync(string jwtId, DateTimeOffset expiresAt, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(jwtId);
|
||||
|
||||
var now = timeProvider.GetUtcNow();
|
||||
RemoveExpired(now);
|
||||
|
||||
if (entries.TryAdd(jwtId, expiresAt))
|
||||
{
|
||||
return ValueTask.FromResult(true);
|
||||
}
|
||||
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
if (!entries.TryGetValue(jwtId, out var existing))
|
||||
{
|
||||
if (entries.TryAdd(jwtId, expiresAt))
|
||||
{
|
||||
return ValueTask.FromResult(true);
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (existing > now)
|
||||
{
|
||||
return ValueTask.FromResult(false);
|
||||
}
|
||||
|
||||
if (entries.TryUpdate(jwtId, expiresAt, existing))
|
||||
{
|
||||
return ValueTask.FromResult(true);
|
||||
}
|
||||
}
|
||||
|
||||
return ValueTask.FromResult(false);
|
||||
}
|
||||
|
||||
private void RemoveExpired(DateTimeOffset now)
|
||||
{
|
||||
foreach (var entry in entries)
|
||||
{
|
||||
if (entry.Value <= now)
|
||||
{
|
||||
entries.TryRemove(entry.Key, out _);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace StellaOps.Auth.Security.Dpop;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory replay cache intended for single-process deployments or tests.
|
||||
/// </summary>
|
||||
public sealed class InMemoryDpopReplayCache : IDpopReplayCache
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, DateTimeOffset> entries = new(StringComparer.Ordinal);
|
||||
private readonly TimeProvider timeProvider;
|
||||
|
||||
public InMemoryDpopReplayCache(TimeProvider? timeProvider = null)
|
||||
{
|
||||
this.timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public ValueTask<bool> TryStoreAsync(string jwtId, DateTimeOffset expiresAt, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(jwtId);
|
||||
|
||||
var now = timeProvider.GetUtcNow();
|
||||
RemoveExpired(now);
|
||||
|
||||
if (entries.TryAdd(jwtId, expiresAt))
|
||||
{
|
||||
return ValueTask.FromResult(true);
|
||||
}
|
||||
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
if (!entries.TryGetValue(jwtId, out var existing))
|
||||
{
|
||||
if (entries.TryAdd(jwtId, expiresAt))
|
||||
{
|
||||
return ValueTask.FromResult(true);
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (existing > now)
|
||||
{
|
||||
return ValueTask.FromResult(false);
|
||||
}
|
||||
|
||||
if (entries.TryUpdate(jwtId, expiresAt, existing))
|
||||
{
|
||||
return ValueTask.FromResult(true);
|
||||
}
|
||||
}
|
||||
|
||||
return ValueTask.FromResult(false);
|
||||
}
|
||||
|
||||
private void RemoveExpired(DateTimeOffset now)
|
||||
{
|
||||
foreach (var entry in entries)
|
||||
{
|
||||
if (entry.Value <= now)
|
||||
{
|
||||
entries.TryRemove(entry.Key, out _);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,138 +1,138 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StackExchange.Redis;
|
||||
|
||||
namespace StellaOps.Auth.Security.Dpop;
|
||||
|
||||
/// <summary>
|
||||
/// Redis-backed implementation of <see cref="IDpopNonceStore"/> that supports multi-node deployments.
|
||||
/// </summary>
|
||||
public sealed class RedisDpopNonceStore : IDpopNonceStore
|
||||
{
|
||||
private const string ConsumeScript = @"
|
||||
local value = redis.call('GET', KEYS[1])
|
||||
if value ~= false and value == ARGV[1] then
|
||||
redis.call('DEL', KEYS[1])
|
||||
return 1
|
||||
end
|
||||
return 0";
|
||||
|
||||
private readonly IConnectionMultiplexer connection;
|
||||
private readonly TimeProvider timeProvider;
|
||||
|
||||
public RedisDpopNonceStore(IConnectionMultiplexer connection, TimeProvider? timeProvider = null)
|
||||
{
|
||||
this.connection = connection ?? throw new ArgumentNullException(nameof(connection));
|
||||
this.timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public async ValueTask<DpopNonceIssueResult> IssueAsync(
|
||||
string audience,
|
||||
string clientId,
|
||||
string keyThumbprint,
|
||||
TimeSpan ttl,
|
||||
int maxIssuancePerMinute,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(audience);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(clientId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(keyThumbprint);
|
||||
|
||||
if (ttl <= TimeSpan.Zero)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(ttl), "Nonce TTL must be greater than zero.");
|
||||
}
|
||||
|
||||
if (maxIssuancePerMinute < 1)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(maxIssuancePerMinute), "Max issuance per minute must be at least 1.");
|
||||
}
|
||||
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var database = connection.GetDatabase();
|
||||
var issuedAt = timeProvider.GetUtcNow();
|
||||
|
||||
var baseKey = DpopNonceUtilities.ComputeStorageKey(audience, clientId, keyThumbprint);
|
||||
var nonceKey = (RedisKey)baseKey;
|
||||
var metadataKey = (RedisKey)(baseKey + ":meta");
|
||||
var rateKey = (RedisKey)(baseKey + ":rate");
|
||||
|
||||
var rateCount = await database.StringIncrementAsync(rateKey, flags: CommandFlags.DemandMaster).ConfigureAwait(false);
|
||||
if (rateCount == 1)
|
||||
{
|
||||
await database.KeyExpireAsync(rateKey, TimeSpan.FromMinutes(1), CommandFlags.DemandMaster).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (rateCount > maxIssuancePerMinute)
|
||||
{
|
||||
return DpopNonceIssueResult.RateLimited("rate_limited");
|
||||
}
|
||||
|
||||
var nonce = DpopNonceUtilities.GenerateNonce();
|
||||
var hash = (RedisValue)DpopNonceUtilities.EncodeHash(DpopNonceUtilities.ComputeNonceHash(nonce));
|
||||
var expiresAt = issuedAt + ttl;
|
||||
|
||||
await database.StringSetAsync(nonceKey, hash, ttl, When.Always, CommandFlags.DemandMaster).ConfigureAwait(false);
|
||||
var metadataValue = FormattableString.Invariant($"{issuedAt.UtcTicks}|{ttl.Ticks}");
|
||||
await database.StringSetAsync(metadataKey, metadataValue, ttl, When.Always, CommandFlags.DemandMaster).ConfigureAwait(false);
|
||||
|
||||
return DpopNonceIssueResult.Success(nonce, expiresAt);
|
||||
}
|
||||
|
||||
public async ValueTask<DpopNonceConsumeResult> TryConsumeAsync(
|
||||
string nonce,
|
||||
string audience,
|
||||
string clientId,
|
||||
string keyThumbprint,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(nonce);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(audience);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(clientId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(keyThumbprint);
|
||||
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var database = connection.GetDatabase();
|
||||
|
||||
var baseKey = DpopNonceUtilities.ComputeStorageKey(audience, clientId, keyThumbprint);
|
||||
var nonceKey = (RedisKey)baseKey;
|
||||
var metadataKey = (RedisKey)(baseKey + ":meta");
|
||||
var hash = (RedisValue)DpopNonceUtilities.EncodeHash(DpopNonceUtilities.ComputeNonceHash(nonce));
|
||||
|
||||
var rawResult = await database.ScriptEvaluateAsync(
|
||||
ConsumeScript,
|
||||
new[] { nonceKey },
|
||||
new RedisValue[] { hash }).ConfigureAwait(false);
|
||||
|
||||
if (rawResult.IsNull || (long)rawResult != 1)
|
||||
{
|
||||
return DpopNonceConsumeResult.NotFound();
|
||||
}
|
||||
|
||||
var metadata = await database.StringGetAsync(metadataKey).ConfigureAwait(false);
|
||||
await database.KeyDeleteAsync(metadataKey, CommandFlags.DemandMaster).ConfigureAwait(false);
|
||||
|
||||
if (!metadata.IsNull)
|
||||
{
|
||||
var parts = metadata.ToString()
|
||||
.Split('|', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
|
||||
if (parts.Length == 2 &&
|
||||
long.TryParse(parts[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out var issuedTicks) &&
|
||||
long.TryParse(parts[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out var ttlTicks))
|
||||
{
|
||||
var issuedAt = new DateTimeOffset(issuedTicks, TimeSpan.Zero);
|
||||
var expiresAt = issuedAt + TimeSpan.FromTicks(ttlTicks);
|
||||
return expiresAt <= timeProvider.GetUtcNow()
|
||||
? DpopNonceConsumeResult.Expired(issuedAt, expiresAt)
|
||||
: DpopNonceConsumeResult.Success(issuedAt, expiresAt);
|
||||
}
|
||||
}
|
||||
|
||||
return DpopNonceConsumeResult.Success(timeProvider.GetUtcNow(), timeProvider.GetUtcNow());
|
||||
}
|
||||
}
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StackExchange.Redis;
|
||||
|
||||
namespace StellaOps.Auth.Security.Dpop;
|
||||
|
||||
/// <summary>
|
||||
/// Redis-backed implementation of <see cref="IDpopNonceStore"/> that supports multi-node deployments.
|
||||
/// </summary>
|
||||
public sealed class RedisDpopNonceStore : IDpopNonceStore
|
||||
{
|
||||
private const string ConsumeScript = @"
|
||||
local value = redis.call('GET', KEYS[1])
|
||||
if value ~= false and value == ARGV[1] then
|
||||
redis.call('DEL', KEYS[1])
|
||||
return 1
|
||||
end
|
||||
return 0";
|
||||
|
||||
private readonly IConnectionMultiplexer connection;
|
||||
private readonly TimeProvider timeProvider;
|
||||
|
||||
public RedisDpopNonceStore(IConnectionMultiplexer connection, TimeProvider? timeProvider = null)
|
||||
{
|
||||
this.connection = connection ?? throw new ArgumentNullException(nameof(connection));
|
||||
this.timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public async ValueTask<DpopNonceIssueResult> IssueAsync(
|
||||
string audience,
|
||||
string clientId,
|
||||
string keyThumbprint,
|
||||
TimeSpan ttl,
|
||||
int maxIssuancePerMinute,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(audience);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(clientId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(keyThumbprint);
|
||||
|
||||
if (ttl <= TimeSpan.Zero)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(ttl), "Nonce TTL must be greater than zero.");
|
||||
}
|
||||
|
||||
if (maxIssuancePerMinute < 1)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(maxIssuancePerMinute), "Max issuance per minute must be at least 1.");
|
||||
}
|
||||
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var database = connection.GetDatabase();
|
||||
var issuedAt = timeProvider.GetUtcNow();
|
||||
|
||||
var baseKey = DpopNonceUtilities.ComputeStorageKey(audience, clientId, keyThumbprint);
|
||||
var nonceKey = (RedisKey)baseKey;
|
||||
var metadataKey = (RedisKey)(baseKey + ":meta");
|
||||
var rateKey = (RedisKey)(baseKey + ":rate");
|
||||
|
||||
var rateCount = await database.StringIncrementAsync(rateKey, flags: CommandFlags.DemandMaster).ConfigureAwait(false);
|
||||
if (rateCount == 1)
|
||||
{
|
||||
await database.KeyExpireAsync(rateKey, TimeSpan.FromMinutes(1), CommandFlags.DemandMaster).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (rateCount > maxIssuancePerMinute)
|
||||
{
|
||||
return DpopNonceIssueResult.RateLimited("rate_limited");
|
||||
}
|
||||
|
||||
var nonce = DpopNonceUtilities.GenerateNonce();
|
||||
var hash = (RedisValue)DpopNonceUtilities.EncodeHash(DpopNonceUtilities.ComputeNonceHash(nonce));
|
||||
var expiresAt = issuedAt + ttl;
|
||||
|
||||
await database.StringSetAsync(nonceKey, hash, ttl, When.Always, CommandFlags.DemandMaster).ConfigureAwait(false);
|
||||
var metadataValue = FormattableString.Invariant($"{issuedAt.UtcTicks}|{ttl.Ticks}");
|
||||
await database.StringSetAsync(metadataKey, metadataValue, ttl, When.Always, CommandFlags.DemandMaster).ConfigureAwait(false);
|
||||
|
||||
return DpopNonceIssueResult.Success(nonce, expiresAt);
|
||||
}
|
||||
|
||||
public async ValueTask<DpopNonceConsumeResult> TryConsumeAsync(
|
||||
string nonce,
|
||||
string audience,
|
||||
string clientId,
|
||||
string keyThumbprint,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(nonce);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(audience);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(clientId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(keyThumbprint);
|
||||
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var database = connection.GetDatabase();
|
||||
|
||||
var baseKey = DpopNonceUtilities.ComputeStorageKey(audience, clientId, keyThumbprint);
|
||||
var nonceKey = (RedisKey)baseKey;
|
||||
var metadataKey = (RedisKey)(baseKey + ":meta");
|
||||
var hash = (RedisValue)DpopNonceUtilities.EncodeHash(DpopNonceUtilities.ComputeNonceHash(nonce));
|
||||
|
||||
var rawResult = await database.ScriptEvaluateAsync(
|
||||
ConsumeScript,
|
||||
new[] { nonceKey },
|
||||
new RedisValue[] { hash }).ConfigureAwait(false);
|
||||
|
||||
if (rawResult.IsNull || (long)rawResult != 1)
|
||||
{
|
||||
return DpopNonceConsumeResult.NotFound();
|
||||
}
|
||||
|
||||
var metadata = await database.StringGetAsync(metadataKey).ConfigureAwait(false);
|
||||
await database.KeyDeleteAsync(metadataKey, CommandFlags.DemandMaster).ConfigureAwait(false);
|
||||
|
||||
if (!metadata.IsNull)
|
||||
{
|
||||
var parts = metadata.ToString()
|
||||
.Split('|', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
|
||||
if (parts.Length == 2 &&
|
||||
long.TryParse(parts[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out var issuedTicks) &&
|
||||
long.TryParse(parts[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out var ttlTicks))
|
||||
{
|
||||
var issuedAt = new DateTimeOffset(issuedTicks, TimeSpan.Zero);
|
||||
var expiresAt = issuedAt + TimeSpan.FromTicks(ttlTicks);
|
||||
return expiresAt <= timeProvider.GetUtcNow()
|
||||
? DpopNonceConsumeResult.Expired(issuedAt, expiresAt)
|
||||
: DpopNonceConsumeResult.Success(issuedAt, expiresAt);
|
||||
}
|
||||
}
|
||||
|
||||
return DpopNonceConsumeResult.Success(timeProvider.GetUtcNow(), timeProvider.GetUtcNow());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,64 +1,64 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
namespace StellaOps.Configuration;
|
||||
|
||||
public sealed class StellaOpsBootstrapOptions<TOptions>
|
||||
where TOptions : class, new()
|
||||
{
|
||||
public StellaOpsBootstrapOptions()
|
||||
{
|
||||
ConfigurationOptions = new StellaOpsConfigurationOptions();
|
||||
}
|
||||
|
||||
internal StellaOpsConfigurationOptions ConfigurationOptions { get; }
|
||||
|
||||
public string? BasePath
|
||||
{
|
||||
get => ConfigurationOptions.BasePath;
|
||||
set => ConfigurationOptions.BasePath = value;
|
||||
}
|
||||
|
||||
public bool IncludeJsonFiles
|
||||
{
|
||||
get => ConfigurationOptions.IncludeJsonFiles;
|
||||
set => ConfigurationOptions.IncludeJsonFiles = value;
|
||||
}
|
||||
|
||||
public bool IncludeYamlFiles
|
||||
{
|
||||
get => ConfigurationOptions.IncludeYamlFiles;
|
||||
set => ConfigurationOptions.IncludeYamlFiles = value;
|
||||
}
|
||||
|
||||
public bool IncludeEnvironmentVariables
|
||||
{
|
||||
get => ConfigurationOptions.IncludeEnvironmentVariables;
|
||||
set => ConfigurationOptions.IncludeEnvironmentVariables = value;
|
||||
}
|
||||
|
||||
public string? EnvironmentPrefix
|
||||
{
|
||||
get => ConfigurationOptions.EnvironmentPrefix;
|
||||
set => ConfigurationOptions.EnvironmentPrefix = value;
|
||||
}
|
||||
|
||||
public IList<JsonConfigurationFile> JsonFiles => ConfigurationOptions.JsonFiles;
|
||||
|
||||
public IList<YamlConfigurationFile> YamlFiles => ConfigurationOptions.YamlFiles;
|
||||
|
||||
public string? BindingSection
|
||||
{
|
||||
get => ConfigurationOptions.BindingSection;
|
||||
set => ConfigurationOptions.BindingSection = value;
|
||||
}
|
||||
|
||||
public Action<IConfigurationBuilder>? ConfigureBuilder
|
||||
{
|
||||
get => ConfigurationOptions.ConfigureBuilder;
|
||||
set => ConfigurationOptions.ConfigureBuilder = value;
|
||||
}
|
||||
|
||||
public Action<TOptions, IConfiguration>? PostBind { get; set; }
|
||||
}
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
namespace StellaOps.Configuration;
|
||||
|
||||
public sealed class StellaOpsBootstrapOptions<TOptions>
|
||||
where TOptions : class, new()
|
||||
{
|
||||
public StellaOpsBootstrapOptions()
|
||||
{
|
||||
ConfigurationOptions = new StellaOpsConfigurationOptions();
|
||||
}
|
||||
|
||||
internal StellaOpsConfigurationOptions ConfigurationOptions { get; }
|
||||
|
||||
public string? BasePath
|
||||
{
|
||||
get => ConfigurationOptions.BasePath;
|
||||
set => ConfigurationOptions.BasePath = value;
|
||||
}
|
||||
|
||||
public bool IncludeJsonFiles
|
||||
{
|
||||
get => ConfigurationOptions.IncludeJsonFiles;
|
||||
set => ConfigurationOptions.IncludeJsonFiles = value;
|
||||
}
|
||||
|
||||
public bool IncludeYamlFiles
|
||||
{
|
||||
get => ConfigurationOptions.IncludeYamlFiles;
|
||||
set => ConfigurationOptions.IncludeYamlFiles = value;
|
||||
}
|
||||
|
||||
public bool IncludeEnvironmentVariables
|
||||
{
|
||||
get => ConfigurationOptions.IncludeEnvironmentVariables;
|
||||
set => ConfigurationOptions.IncludeEnvironmentVariables = value;
|
||||
}
|
||||
|
||||
public string? EnvironmentPrefix
|
||||
{
|
||||
get => ConfigurationOptions.EnvironmentPrefix;
|
||||
set => ConfigurationOptions.EnvironmentPrefix = value;
|
||||
}
|
||||
|
||||
public IList<JsonConfigurationFile> JsonFiles => ConfigurationOptions.JsonFiles;
|
||||
|
||||
public IList<YamlConfigurationFile> YamlFiles => ConfigurationOptions.YamlFiles;
|
||||
|
||||
public string? BindingSection
|
||||
{
|
||||
get => ConfigurationOptions.BindingSection;
|
||||
set => ConfigurationOptions.BindingSection = value;
|
||||
}
|
||||
|
||||
public Action<IConfigurationBuilder>? ConfigureBuilder
|
||||
{
|
||||
get => ConfigurationOptions.ConfigureBuilder;
|
||||
set => ConfigurationOptions.ConfigureBuilder = value;
|
||||
}
|
||||
|
||||
public Action<TOptions, IConfiguration>? PostBind { get; set; }
|
||||
}
|
||||
|
||||
@@ -1,106 +1,106 @@
|
||||
using System;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using NetEscapades.Configuration.Yaml;
|
||||
|
||||
namespace StellaOps.Configuration;
|
||||
|
||||
public static class StellaOpsConfigurationBootstrapper
|
||||
{
|
||||
public static StellaOpsConfigurationContext<TOptions> Build<TOptions>(
|
||||
Action<StellaOpsBootstrapOptions<TOptions>>? configure = null)
|
||||
where TOptions : class, new()
|
||||
{
|
||||
var bootstrapOptions = new StellaOpsBootstrapOptions<TOptions>();
|
||||
configure?.Invoke(bootstrapOptions);
|
||||
|
||||
var configurationOptions = bootstrapOptions.ConfigurationOptions;
|
||||
var builder = new ConfigurationBuilder();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(configurationOptions.BasePath))
|
||||
{
|
||||
builder.SetBasePath(configurationOptions.BasePath!);
|
||||
}
|
||||
|
||||
if (configurationOptions.IncludeJsonFiles)
|
||||
{
|
||||
foreach (var file in configurationOptions.JsonFiles)
|
||||
{
|
||||
builder.AddJsonFile(file.Path, optional: file.Optional, reloadOnChange: file.ReloadOnChange);
|
||||
}
|
||||
}
|
||||
|
||||
if (configurationOptions.IncludeYamlFiles)
|
||||
{
|
||||
foreach (var file in configurationOptions.YamlFiles)
|
||||
{
|
||||
builder.AddYamlFile(file.Path, optional: file.Optional);
|
||||
}
|
||||
}
|
||||
|
||||
configurationOptions.ConfigureBuilder?.Invoke(builder);
|
||||
|
||||
if (configurationOptions.IncludeEnvironmentVariables)
|
||||
{
|
||||
builder.AddEnvironmentVariables(configurationOptions.EnvironmentPrefix);
|
||||
}
|
||||
|
||||
var configuration = builder.Build();
|
||||
|
||||
IConfiguration bindingSource;
|
||||
if (string.IsNullOrWhiteSpace(configurationOptions.BindingSection))
|
||||
{
|
||||
bindingSource = configuration;
|
||||
}
|
||||
else
|
||||
{
|
||||
bindingSource = configuration.GetSection(configurationOptions.BindingSection!);
|
||||
}
|
||||
|
||||
var options = new TOptions();
|
||||
bindingSource.Bind(options);
|
||||
|
||||
bootstrapOptions.PostBind?.Invoke(options, configuration);
|
||||
|
||||
return new StellaOpsConfigurationContext<TOptions>(configuration, options);
|
||||
}
|
||||
|
||||
public static IConfigurationBuilder AddStellaOpsDefaults(
|
||||
this IConfigurationBuilder builder,
|
||||
Action<StellaOpsConfigurationOptions>? configure = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(builder);
|
||||
|
||||
var options = new StellaOpsConfigurationOptions();
|
||||
configure?.Invoke(options);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(options.BasePath))
|
||||
{
|
||||
builder.SetBasePath(options.BasePath!);
|
||||
}
|
||||
|
||||
if (options.IncludeJsonFiles)
|
||||
{
|
||||
foreach (var file in options.JsonFiles)
|
||||
{
|
||||
builder.AddJsonFile(file.Path, optional: file.Optional, reloadOnChange: file.ReloadOnChange);
|
||||
}
|
||||
}
|
||||
|
||||
if (options.IncludeYamlFiles)
|
||||
{
|
||||
foreach (var file in options.YamlFiles)
|
||||
{
|
||||
builder.AddYamlFile(file.Path, optional: file.Optional);
|
||||
}
|
||||
}
|
||||
|
||||
options.ConfigureBuilder?.Invoke(builder);
|
||||
|
||||
if (options.IncludeEnvironmentVariables)
|
||||
{
|
||||
builder.AddEnvironmentVariables(options.EnvironmentPrefix);
|
||||
}
|
||||
|
||||
return builder;
|
||||
}
|
||||
}
|
||||
using System;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using NetEscapades.Configuration.Yaml;
|
||||
|
||||
namespace StellaOps.Configuration;
|
||||
|
||||
public static class StellaOpsConfigurationBootstrapper
|
||||
{
|
||||
public static StellaOpsConfigurationContext<TOptions> Build<TOptions>(
|
||||
Action<StellaOpsBootstrapOptions<TOptions>>? configure = null)
|
||||
where TOptions : class, new()
|
||||
{
|
||||
var bootstrapOptions = new StellaOpsBootstrapOptions<TOptions>();
|
||||
configure?.Invoke(bootstrapOptions);
|
||||
|
||||
var configurationOptions = bootstrapOptions.ConfigurationOptions;
|
||||
var builder = new ConfigurationBuilder();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(configurationOptions.BasePath))
|
||||
{
|
||||
builder.SetBasePath(configurationOptions.BasePath!);
|
||||
}
|
||||
|
||||
if (configurationOptions.IncludeJsonFiles)
|
||||
{
|
||||
foreach (var file in configurationOptions.JsonFiles)
|
||||
{
|
||||
builder.AddJsonFile(file.Path, optional: file.Optional, reloadOnChange: file.ReloadOnChange);
|
||||
}
|
||||
}
|
||||
|
||||
if (configurationOptions.IncludeYamlFiles)
|
||||
{
|
||||
foreach (var file in configurationOptions.YamlFiles)
|
||||
{
|
||||
builder.AddYamlFile(file.Path, optional: file.Optional);
|
||||
}
|
||||
}
|
||||
|
||||
configurationOptions.ConfigureBuilder?.Invoke(builder);
|
||||
|
||||
if (configurationOptions.IncludeEnvironmentVariables)
|
||||
{
|
||||
builder.AddEnvironmentVariables(configurationOptions.EnvironmentPrefix);
|
||||
}
|
||||
|
||||
var configuration = builder.Build();
|
||||
|
||||
IConfiguration bindingSource;
|
||||
if (string.IsNullOrWhiteSpace(configurationOptions.BindingSection))
|
||||
{
|
||||
bindingSource = configuration;
|
||||
}
|
||||
else
|
||||
{
|
||||
bindingSource = configuration.GetSection(configurationOptions.BindingSection!);
|
||||
}
|
||||
|
||||
var options = new TOptions();
|
||||
bindingSource.Bind(options);
|
||||
|
||||
bootstrapOptions.PostBind?.Invoke(options, configuration);
|
||||
|
||||
return new StellaOpsConfigurationContext<TOptions>(configuration, options);
|
||||
}
|
||||
|
||||
public static IConfigurationBuilder AddStellaOpsDefaults(
|
||||
this IConfigurationBuilder builder,
|
||||
Action<StellaOpsConfigurationOptions>? configure = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(builder);
|
||||
|
||||
var options = new StellaOpsConfigurationOptions();
|
||||
configure?.Invoke(options);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(options.BasePath))
|
||||
{
|
||||
builder.SetBasePath(options.BasePath!);
|
||||
}
|
||||
|
||||
if (options.IncludeJsonFiles)
|
||||
{
|
||||
foreach (var file in options.JsonFiles)
|
||||
{
|
||||
builder.AddJsonFile(file.Path, optional: file.Optional, reloadOnChange: file.ReloadOnChange);
|
||||
}
|
||||
}
|
||||
|
||||
if (options.IncludeYamlFiles)
|
||||
{
|
||||
foreach (var file in options.YamlFiles)
|
||||
{
|
||||
builder.AddYamlFile(file.Path, optional: file.Optional);
|
||||
}
|
||||
}
|
||||
|
||||
options.ConfigureBuilder?.Invoke(builder);
|
||||
|
||||
if (options.IncludeEnvironmentVariables)
|
||||
{
|
||||
builder.AddEnvironmentVariables(options.EnvironmentPrefix);
|
||||
}
|
||||
|
||||
return builder;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
using System;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
namespace StellaOps.Configuration;
|
||||
|
||||
public sealed class StellaOpsConfigurationContext<TOptions>
|
||||
where TOptions : class, new()
|
||||
{
|
||||
public StellaOpsConfigurationContext(IConfigurationRoot configuration, TOptions options)
|
||||
{
|
||||
Configuration = configuration ?? throw new ArgumentNullException(nameof(configuration));
|
||||
Options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
}
|
||||
|
||||
public IConfigurationRoot Configuration { get; }
|
||||
|
||||
public TOptions Options { get; }
|
||||
}
|
||||
using System;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
namespace StellaOps.Configuration;
|
||||
|
||||
public sealed class StellaOpsConfigurationContext<TOptions>
|
||||
where TOptions : class, new()
|
||||
{
|
||||
public StellaOpsConfigurationContext(IConfigurationRoot configuration, TOptions options)
|
||||
{
|
||||
Configuration = configuration ?? throw new ArgumentNullException(nameof(configuration));
|
||||
Options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
}
|
||||
|
||||
public IConfigurationRoot Configuration { get; }
|
||||
|
||||
public TOptions Options { get; }
|
||||
}
|
||||
|
||||
@@ -1,49 +1,49 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
namespace StellaOps.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Defines how default StellaOps configuration sources are composed.
|
||||
/// </summary>
|
||||
public sealed class StellaOpsConfigurationOptions
|
||||
{
|
||||
public string? BasePath { get; set; } = Directory.GetCurrentDirectory();
|
||||
|
||||
public bool IncludeJsonFiles { get; set; } = true;
|
||||
|
||||
public bool IncludeYamlFiles { get; set; } = true;
|
||||
|
||||
public bool IncludeEnvironmentVariables { get; set; } = true;
|
||||
|
||||
public string? EnvironmentPrefix { get; set; }
|
||||
|
||||
public IList<JsonConfigurationFile> JsonFiles { get; } = new List<JsonConfigurationFile>
|
||||
{
|
||||
new("appsettings.json", true, false),
|
||||
new("appsettings.local.json", true, false)
|
||||
};
|
||||
|
||||
public IList<YamlConfigurationFile> YamlFiles { get; } = new List<YamlConfigurationFile>
|
||||
{
|
||||
new("appsettings.yaml", true),
|
||||
new("appsettings.local.yaml", true)
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Optional hook to register additional configuration sources (e.g. module-specific YAML files).
|
||||
/// </summary>
|
||||
public Action<IConfigurationBuilder>? ConfigureBuilder { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional configuration section name used when binding strongly typed options.
|
||||
/// Null or empty indicates the root.
|
||||
/// </summary>
|
||||
public string? BindingSection { get; set; }
|
||||
}
|
||||
|
||||
public sealed record JsonConfigurationFile(string Path, bool Optional = true, bool ReloadOnChange = false);
|
||||
|
||||
public sealed record YamlConfigurationFile(string Path, bool Optional = true);
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
namespace StellaOps.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Defines how default StellaOps configuration sources are composed.
|
||||
/// </summary>
|
||||
public sealed class StellaOpsConfigurationOptions
|
||||
{
|
||||
public string? BasePath { get; set; } = Directory.GetCurrentDirectory();
|
||||
|
||||
public bool IncludeJsonFiles { get; set; } = true;
|
||||
|
||||
public bool IncludeYamlFiles { get; set; } = true;
|
||||
|
||||
public bool IncludeEnvironmentVariables { get; set; } = true;
|
||||
|
||||
public string? EnvironmentPrefix { get; set; }
|
||||
|
||||
public IList<JsonConfigurationFile> JsonFiles { get; } = new List<JsonConfigurationFile>
|
||||
{
|
||||
new("appsettings.json", true, false),
|
||||
new("appsettings.local.json", true, false)
|
||||
};
|
||||
|
||||
public IList<YamlConfigurationFile> YamlFiles { get; } = new List<YamlConfigurationFile>
|
||||
{
|
||||
new("appsettings.yaml", true),
|
||||
new("appsettings.local.yaml", true)
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Optional hook to register additional configuration sources (e.g. module-specific YAML files).
|
||||
/// </summary>
|
||||
public Action<IConfigurationBuilder>? ConfigureBuilder { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional configuration section name used when binding strongly typed options.
|
||||
/// Null or empty indicates the root.
|
||||
/// </summary>
|
||||
public string? BindingSection { get; set; }
|
||||
}
|
||||
|
||||
public sealed record JsonConfigurationFile(string Path, bool Optional = true, bool ReloadOnChange = false);
|
||||
|
||||
public sealed record YamlConfigurationFile(string Path, bool Optional = true);
|
||||
|
||||
@@ -1,26 +1,26 @@
|
||||
using System;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
namespace StellaOps.Configuration;
|
||||
|
||||
public static class StellaOpsOptionsBinder
|
||||
{
|
||||
public static TOptions BindOptions<TOptions>(
|
||||
this IConfiguration configuration,
|
||||
string? section = null,
|
||||
Action<TOptions, IConfiguration>? postConfigure = null)
|
||||
where TOptions : class, new()
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(configuration);
|
||||
|
||||
var options = new TOptions();
|
||||
var bindingSource = string.IsNullOrWhiteSpace(section)
|
||||
? configuration
|
||||
: configuration.GetSection(section);
|
||||
|
||||
bindingSource.Bind(options);
|
||||
postConfigure?.Invoke(options, configuration);
|
||||
|
||||
return options;
|
||||
}
|
||||
}
|
||||
using System;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
namespace StellaOps.Configuration;
|
||||
|
||||
public static class StellaOpsOptionsBinder
|
||||
{
|
||||
public static TOptions BindOptions<TOptions>(
|
||||
this IConfiguration configuration,
|
||||
string? section = null,
|
||||
Action<TOptions, IConfiguration>? postConfigure = null)
|
||||
where TOptions : class, new()
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(configuration);
|
||||
|
||||
var options = new TOptions();
|
||||
var bindingSource = string.IsNullOrWhiteSpace(section)
|
||||
? configuration
|
||||
: configuration.GetSection(section);
|
||||
|
||||
bindingSource.Bind(options);
|
||||
postConfigure?.Invoke(options, configuration);
|
||||
|
||||
return options;
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,27 +1,27 @@
|
||||
namespace StellaOps.Cryptography.Kms;
|
||||
|
||||
/// <summary>
|
||||
/// Options for the <see cref="FileKmsClient"/>.
|
||||
/// </summary>
|
||||
public sealed class FileKmsOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Root directory for storing key material.
|
||||
/// </summary>
|
||||
public string RootPath { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Password used to encrypt private key material at rest.
|
||||
/// </summary>
|
||||
public required string Password { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Signing algorithm identifier (default ES256).
|
||||
/// </summary>
|
||||
public string Algorithm { get; set; } = KmsAlgorithms.Es256;
|
||||
|
||||
/// <summary>
|
||||
/// PBKDF2 iteration count for envelope encryption.
|
||||
/// </summary>
|
||||
public int KeyDerivationIterations { get; set; } = 600_000;
|
||||
}
|
||||
namespace StellaOps.Cryptography.Kms;
|
||||
|
||||
/// <summary>
|
||||
/// Options for the <see cref="FileKmsClient"/>.
|
||||
/// </summary>
|
||||
public sealed class FileKmsOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Root directory for storing key material.
|
||||
/// </summary>
|
||||
public string RootPath { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Password used to encrypt private key material at rest.
|
||||
/// </summary>
|
||||
public required string Password { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Signing algorithm identifier (default ES256).
|
||||
/// </summary>
|
||||
public string Algorithm { get; set; } = KmsAlgorithms.Es256;
|
||||
|
||||
/// <summary>
|
||||
/// PBKDF2 iteration count for envelope encryption.
|
||||
/// </summary>
|
||||
public int KeyDerivationIterations { get; set; } = 600_000;
|
||||
}
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using StellaOps.Cryptography;
|
||||
|
||||
namespace StellaOps.Cryptography.Plugin.BouncyCastle;
|
||||
|
||||
/// <summary>
|
||||
/// Dependency injection helpers for registering the BouncyCastle Ed25519 crypto provider.
|
||||
/// </summary>
|
||||
public static class BouncyCastleCryptoServiceCollectionExtensions
|
||||
{
|
||||
public static IServiceCollection AddBouncyCastleEd25519Provider(this IServiceCollection services)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
|
||||
services.TryAddSingleton<BouncyCastleEd25519CryptoProvider>();
|
||||
services.TryAddEnumerable(ServiceDescriptor.Singleton<ICryptoProvider, BouncyCastleEd25519CryptoProvider>());
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using StellaOps.Cryptography;
|
||||
|
||||
namespace StellaOps.Cryptography.Plugin.BouncyCastle;
|
||||
|
||||
/// <summary>
|
||||
/// Dependency injection helpers for registering the BouncyCastle Ed25519 crypto provider.
|
||||
/// </summary>
|
||||
public static class BouncyCastleCryptoServiceCollectionExtensions
|
||||
{
|
||||
public static IServiceCollection AddBouncyCastleEd25519Provider(this IServiceCollection services)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
|
||||
services.TryAddSingleton<BouncyCastleEd25519CryptoProvider>();
|
||||
services.TryAddEnumerable(ServiceDescriptor.Singleton<ICryptoProvider, BouncyCastleEd25519CryptoProvider>());
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,214 +1,214 @@
|
||||
using System.Collections.Concurrent;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using Org.BouncyCastle.Crypto.Parameters;
|
||||
using Org.BouncyCastle.Crypto.Signers;
|
||||
using StellaOps.Cryptography;
|
||||
|
||||
namespace StellaOps.Cryptography.Plugin.BouncyCastle;
|
||||
|
||||
/// <summary>
|
||||
/// Ed25519 signing provider backed by BouncyCastle primitives.
|
||||
/// </summary>
|
||||
public sealed class BouncyCastleEd25519CryptoProvider : ICryptoProvider
|
||||
{
|
||||
private static readonly HashSet<string> SupportedAlgorithms = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
SignatureAlgorithms.Ed25519,
|
||||
SignatureAlgorithms.EdDsa
|
||||
};
|
||||
|
||||
private static readonly string[] DefaultKeyOps = { "sign", "verify" };
|
||||
|
||||
private readonly ConcurrentDictionary<string, KeyEntry> signingKeys = new(StringComparer.Ordinal);
|
||||
|
||||
public string Name => "bouncycastle.ed25519";
|
||||
|
||||
public bool Supports(CryptoCapability capability, string algorithmId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(algorithmId))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return capability switch
|
||||
{
|
||||
CryptoCapability.Signing or CryptoCapability.Verification => SupportedAlgorithms.Contains(algorithmId),
|
||||
_ => false
|
||||
};
|
||||
}
|
||||
|
||||
public ICryptoHasher GetHasher(string algorithmId)
|
||||
=> throw new NotSupportedException("BouncyCastle Ed25519 provider does not expose hashing capabilities.");
|
||||
|
||||
public IPasswordHasher GetPasswordHasher(string algorithmId)
|
||||
=> throw new NotSupportedException("BouncyCastle provider does not expose password hashing capabilities.");
|
||||
|
||||
public ICryptoSigner GetSigner(string algorithmId, CryptoKeyReference keyReference)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(algorithmId);
|
||||
ArgumentNullException.ThrowIfNull(keyReference);
|
||||
|
||||
if (!signingKeys.TryGetValue(keyReference.KeyId, out var entry))
|
||||
{
|
||||
throw new KeyNotFoundException($"Signing key '{keyReference.KeyId}' is not registered with provider '{Name}'.");
|
||||
}
|
||||
|
||||
EnsureAlgorithmSupported(algorithmId);
|
||||
var normalized = NormalizeAlgorithm(algorithmId);
|
||||
if (!string.Equals(entry.Descriptor.AlgorithmId, normalized, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Signing key '{keyReference.KeyId}' is registered for algorithm '{entry.Descriptor.AlgorithmId}', not '{algorithmId}'.");
|
||||
}
|
||||
|
||||
return new Ed25519SignerWrapper(entry);
|
||||
}
|
||||
|
||||
public void UpsertSigningKey(CryptoSigningKey signingKey)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(signingKey);
|
||||
EnsureAlgorithmSupported(signingKey.AlgorithmId);
|
||||
|
||||
if (signingKey.Kind != CryptoSigningKeyKind.Raw)
|
||||
{
|
||||
throw new InvalidOperationException($"Provider '{Name}' requires raw Ed25519 private key material.");
|
||||
}
|
||||
|
||||
var privateKey = NormalizePrivateKey(signingKey.PrivateKey);
|
||||
var publicKey = NormalizePublicKey(signingKey.PublicKey, privateKey);
|
||||
|
||||
var privateKeyParameters = new Ed25519PrivateKeyParameters(privateKey, 0);
|
||||
var publicKeyParameters = new Ed25519PublicKeyParameters(publicKey, 0);
|
||||
|
||||
var descriptor = new CryptoSigningKey(
|
||||
signingKey.Reference,
|
||||
NormalizeAlgorithm(signingKey.AlgorithmId),
|
||||
privateKey,
|
||||
signingKey.CreatedAt,
|
||||
signingKey.ExpiresAt,
|
||||
publicKey,
|
||||
signingKey.Metadata);
|
||||
|
||||
signingKeys.AddOrUpdate(
|
||||
signingKey.Reference.KeyId,
|
||||
_ => new KeyEntry(descriptor, privateKeyParameters, publicKeyParameters),
|
||||
(_, _) => new KeyEntry(descriptor, privateKeyParameters, publicKeyParameters));
|
||||
}
|
||||
|
||||
public bool RemoveSigningKey(string keyId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(keyId))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return signingKeys.TryRemove(keyId, out _);
|
||||
}
|
||||
|
||||
public IReadOnlyCollection<CryptoSigningKey> GetSigningKeys()
|
||||
=> signingKeys.Values.Select(static entry => entry.Descriptor).ToArray();
|
||||
|
||||
private static void EnsureAlgorithmSupported(string algorithmId)
|
||||
{
|
||||
if (!SupportedAlgorithms.Contains(algorithmId))
|
||||
{
|
||||
throw new InvalidOperationException($"Signing algorithm '{algorithmId}' is not supported by provider 'bouncycastle.ed25519'.");
|
||||
}
|
||||
}
|
||||
|
||||
private static string NormalizeAlgorithm(string algorithmId)
|
||||
=> string.Equals(algorithmId, SignatureAlgorithms.EdDsa, StringComparison.OrdinalIgnoreCase)
|
||||
? SignatureAlgorithms.Ed25519
|
||||
: SignatureAlgorithms.Ed25519;
|
||||
|
||||
private static byte[] NormalizePrivateKey(ReadOnlyMemory<byte> privateKey)
|
||||
{
|
||||
var span = privateKey.Span;
|
||||
return span.Length switch
|
||||
{
|
||||
32 => span.ToArray(),
|
||||
64 => span[..32].ToArray(),
|
||||
_ => throw new InvalidOperationException("Ed25519 private key must be 32 or 64 bytes.")
|
||||
};
|
||||
}
|
||||
|
||||
private static byte[] NormalizePublicKey(ReadOnlyMemory<byte> publicKey, byte[] privateKey)
|
||||
{
|
||||
if (publicKey.IsEmpty)
|
||||
{
|
||||
var privateParams = new Ed25519PrivateKeyParameters(privateKey, 0);
|
||||
return privateParams.GeneratePublicKey().GetEncoded();
|
||||
}
|
||||
|
||||
if (publicKey.Span.Length != 32)
|
||||
{
|
||||
throw new InvalidOperationException("Ed25519 public key must be 32 bytes.");
|
||||
}
|
||||
|
||||
return publicKey.ToArray();
|
||||
}
|
||||
|
||||
private sealed record KeyEntry(
|
||||
CryptoSigningKey Descriptor,
|
||||
Ed25519PrivateKeyParameters PrivateKey,
|
||||
Ed25519PublicKeyParameters PublicKey);
|
||||
|
||||
private sealed class Ed25519SignerWrapper : ICryptoSigner
|
||||
{
|
||||
private readonly KeyEntry entry;
|
||||
|
||||
public Ed25519SignerWrapper(KeyEntry entry)
|
||||
{
|
||||
this.entry = entry ?? throw new ArgumentNullException(nameof(entry));
|
||||
}
|
||||
|
||||
public string KeyId => entry.Descriptor.Reference.KeyId;
|
||||
|
||||
public string AlgorithmId => entry.Descriptor.AlgorithmId;
|
||||
|
||||
public ValueTask<byte[]> SignAsync(ReadOnlyMemory<byte> data, CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var signer = new Ed25519Signer();
|
||||
var buffer = data.ToArray();
|
||||
signer.Init(true, entry.PrivateKey);
|
||||
signer.BlockUpdate(buffer, 0, buffer.Length);
|
||||
var signature = signer.GenerateSignature();
|
||||
return ValueTask.FromResult(signature);
|
||||
}
|
||||
|
||||
public ValueTask<bool> VerifyAsync(ReadOnlyMemory<byte> data, ReadOnlyMemory<byte> signature, CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var verifier = new Ed25519Signer();
|
||||
var buffer = data.ToArray();
|
||||
verifier.Init(false, entry.PublicKey);
|
||||
verifier.BlockUpdate(buffer, 0, buffer.Length);
|
||||
var verified = verifier.VerifySignature(signature.ToArray());
|
||||
return ValueTask.FromResult(verified);
|
||||
}
|
||||
|
||||
public JsonWebKey ExportPublicJsonWebKey()
|
||||
{
|
||||
var jwk = new JsonWebKey
|
||||
{
|
||||
Kid = entry.Descriptor.Reference.KeyId,
|
||||
Alg = SignatureAlgorithms.EdDsa,
|
||||
Kty = "OKP",
|
||||
Use = JsonWebKeyUseNames.Sig,
|
||||
Crv = "Ed25519"
|
||||
};
|
||||
|
||||
foreach (var op in DefaultKeyOps)
|
||||
{
|
||||
jwk.KeyOps.Add(op);
|
||||
}
|
||||
|
||||
jwk.X = Base64UrlEncoder.Encode(entry.PublicKey.GetEncoded());
|
||||
|
||||
return jwk;
|
||||
}
|
||||
}
|
||||
}
|
||||
using System.Collections.Concurrent;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using Org.BouncyCastle.Crypto.Parameters;
|
||||
using Org.BouncyCastle.Crypto.Signers;
|
||||
using StellaOps.Cryptography;
|
||||
|
||||
namespace StellaOps.Cryptography.Plugin.BouncyCastle;
|
||||
|
||||
/// <summary>
|
||||
/// Ed25519 signing provider backed by BouncyCastle primitives.
|
||||
/// </summary>
|
||||
public sealed class BouncyCastleEd25519CryptoProvider : ICryptoProvider
|
||||
{
|
||||
private static readonly HashSet<string> SupportedAlgorithms = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
SignatureAlgorithms.Ed25519,
|
||||
SignatureAlgorithms.EdDsa
|
||||
};
|
||||
|
||||
private static readonly string[] DefaultKeyOps = { "sign", "verify" };
|
||||
|
||||
private readonly ConcurrentDictionary<string, KeyEntry> signingKeys = new(StringComparer.Ordinal);
|
||||
|
||||
public string Name => "bouncycastle.ed25519";
|
||||
|
||||
public bool Supports(CryptoCapability capability, string algorithmId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(algorithmId))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return capability switch
|
||||
{
|
||||
CryptoCapability.Signing or CryptoCapability.Verification => SupportedAlgorithms.Contains(algorithmId),
|
||||
_ => false
|
||||
};
|
||||
}
|
||||
|
||||
public ICryptoHasher GetHasher(string algorithmId)
|
||||
=> throw new NotSupportedException("BouncyCastle Ed25519 provider does not expose hashing capabilities.");
|
||||
|
||||
public IPasswordHasher GetPasswordHasher(string algorithmId)
|
||||
=> throw new NotSupportedException("BouncyCastle provider does not expose password hashing capabilities.");
|
||||
|
||||
public ICryptoSigner GetSigner(string algorithmId, CryptoKeyReference keyReference)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(algorithmId);
|
||||
ArgumentNullException.ThrowIfNull(keyReference);
|
||||
|
||||
if (!signingKeys.TryGetValue(keyReference.KeyId, out var entry))
|
||||
{
|
||||
throw new KeyNotFoundException($"Signing key '{keyReference.KeyId}' is not registered with provider '{Name}'.");
|
||||
}
|
||||
|
||||
EnsureAlgorithmSupported(algorithmId);
|
||||
var normalized = NormalizeAlgorithm(algorithmId);
|
||||
if (!string.Equals(entry.Descriptor.AlgorithmId, normalized, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Signing key '{keyReference.KeyId}' is registered for algorithm '{entry.Descriptor.AlgorithmId}', not '{algorithmId}'.");
|
||||
}
|
||||
|
||||
return new Ed25519SignerWrapper(entry);
|
||||
}
|
||||
|
||||
public void UpsertSigningKey(CryptoSigningKey signingKey)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(signingKey);
|
||||
EnsureAlgorithmSupported(signingKey.AlgorithmId);
|
||||
|
||||
if (signingKey.Kind != CryptoSigningKeyKind.Raw)
|
||||
{
|
||||
throw new InvalidOperationException($"Provider '{Name}' requires raw Ed25519 private key material.");
|
||||
}
|
||||
|
||||
var privateKey = NormalizePrivateKey(signingKey.PrivateKey);
|
||||
var publicKey = NormalizePublicKey(signingKey.PublicKey, privateKey);
|
||||
|
||||
var privateKeyParameters = new Ed25519PrivateKeyParameters(privateKey, 0);
|
||||
var publicKeyParameters = new Ed25519PublicKeyParameters(publicKey, 0);
|
||||
|
||||
var descriptor = new CryptoSigningKey(
|
||||
signingKey.Reference,
|
||||
NormalizeAlgorithm(signingKey.AlgorithmId),
|
||||
privateKey,
|
||||
signingKey.CreatedAt,
|
||||
signingKey.ExpiresAt,
|
||||
publicKey,
|
||||
signingKey.Metadata);
|
||||
|
||||
signingKeys.AddOrUpdate(
|
||||
signingKey.Reference.KeyId,
|
||||
_ => new KeyEntry(descriptor, privateKeyParameters, publicKeyParameters),
|
||||
(_, _) => new KeyEntry(descriptor, privateKeyParameters, publicKeyParameters));
|
||||
}
|
||||
|
||||
public bool RemoveSigningKey(string keyId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(keyId))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return signingKeys.TryRemove(keyId, out _);
|
||||
}
|
||||
|
||||
public IReadOnlyCollection<CryptoSigningKey> GetSigningKeys()
|
||||
=> signingKeys.Values.Select(static entry => entry.Descriptor).ToArray();
|
||||
|
||||
private static void EnsureAlgorithmSupported(string algorithmId)
|
||||
{
|
||||
if (!SupportedAlgorithms.Contains(algorithmId))
|
||||
{
|
||||
throw new InvalidOperationException($"Signing algorithm '{algorithmId}' is not supported by provider 'bouncycastle.ed25519'.");
|
||||
}
|
||||
}
|
||||
|
||||
private static string NormalizeAlgorithm(string algorithmId)
|
||||
=> string.Equals(algorithmId, SignatureAlgorithms.EdDsa, StringComparison.OrdinalIgnoreCase)
|
||||
? SignatureAlgorithms.Ed25519
|
||||
: SignatureAlgorithms.Ed25519;
|
||||
|
||||
private static byte[] NormalizePrivateKey(ReadOnlyMemory<byte> privateKey)
|
||||
{
|
||||
var span = privateKey.Span;
|
||||
return span.Length switch
|
||||
{
|
||||
32 => span.ToArray(),
|
||||
64 => span[..32].ToArray(),
|
||||
_ => throw new InvalidOperationException("Ed25519 private key must be 32 or 64 bytes.")
|
||||
};
|
||||
}
|
||||
|
||||
private static byte[] NormalizePublicKey(ReadOnlyMemory<byte> publicKey, byte[] privateKey)
|
||||
{
|
||||
if (publicKey.IsEmpty)
|
||||
{
|
||||
var privateParams = new Ed25519PrivateKeyParameters(privateKey, 0);
|
||||
return privateParams.GeneratePublicKey().GetEncoded();
|
||||
}
|
||||
|
||||
if (publicKey.Span.Length != 32)
|
||||
{
|
||||
throw new InvalidOperationException("Ed25519 public key must be 32 bytes.");
|
||||
}
|
||||
|
||||
return publicKey.ToArray();
|
||||
}
|
||||
|
||||
private sealed record KeyEntry(
|
||||
CryptoSigningKey Descriptor,
|
||||
Ed25519PrivateKeyParameters PrivateKey,
|
||||
Ed25519PublicKeyParameters PublicKey);
|
||||
|
||||
private sealed class Ed25519SignerWrapper : ICryptoSigner
|
||||
{
|
||||
private readonly KeyEntry entry;
|
||||
|
||||
public Ed25519SignerWrapper(KeyEntry entry)
|
||||
{
|
||||
this.entry = entry ?? throw new ArgumentNullException(nameof(entry));
|
||||
}
|
||||
|
||||
public string KeyId => entry.Descriptor.Reference.KeyId;
|
||||
|
||||
public string AlgorithmId => entry.Descriptor.AlgorithmId;
|
||||
|
||||
public ValueTask<byte[]> SignAsync(ReadOnlyMemory<byte> data, CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var signer = new Ed25519Signer();
|
||||
var buffer = data.ToArray();
|
||||
signer.Init(true, entry.PrivateKey);
|
||||
signer.BlockUpdate(buffer, 0, buffer.Length);
|
||||
var signature = signer.GenerateSignature();
|
||||
return ValueTask.FromResult(signature);
|
||||
}
|
||||
|
||||
public ValueTask<bool> VerifyAsync(ReadOnlyMemory<byte> data, ReadOnlyMemory<byte> signature, CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var verifier = new Ed25519Signer();
|
||||
var buffer = data.ToArray();
|
||||
verifier.Init(false, entry.PublicKey);
|
||||
verifier.BlockUpdate(buffer, 0, buffer.Length);
|
||||
var verified = verifier.VerifySignature(signature.ToArray());
|
||||
return ValueTask.FromResult(verified);
|
||||
}
|
||||
|
||||
public JsonWebKey ExportPublicJsonWebKey()
|
||||
{
|
||||
var jwk = new JsonWebKey
|
||||
{
|
||||
Kid = entry.Descriptor.Reference.KeyId,
|
||||
Alg = SignatureAlgorithms.EdDsa,
|
||||
Kty = "OKP",
|
||||
Use = JsonWebKeyUseNames.Sig,
|
||||
Crv = "Ed25519"
|
||||
};
|
||||
|
||||
foreach (var op in DefaultKeyOps)
|
||||
{
|
||||
jwk.KeyOps.Add(op);
|
||||
}
|
||||
|
||||
jwk.X = Base64UrlEncoder.Encode(entry.PublicKey.GetEncoded());
|
||||
|
||||
return jwk;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,95 +1,95 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Cryptography.Audit;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a structured security event emitted by the Authority host and plugins.
|
||||
/// </summary>
|
||||
public sealed record AuthEventRecord
|
||||
{
|
||||
/// <summary>
|
||||
/// Canonical event identifier (e.g. <c>authority.password.grant</c>).
|
||||
/// </summary>
|
||||
public required string EventType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// UTC timestamp captured when the event occurred.
|
||||
/// </summary>
|
||||
public DateTimeOffset OccurredAt { get; init; } = DateTimeOffset.UtcNow;
|
||||
|
||||
/// <summary>
|
||||
/// Stable correlation identifier that links the event across logs, traces, and persistence.
|
||||
/// </summary>
|
||||
public string? CorrelationId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Outcome classification for the audited operation.
|
||||
/// </summary>
|
||||
public AuthEventOutcome Outcome { get; init; } = AuthEventOutcome.Unknown;
|
||||
|
||||
/// <summary>
|
||||
/// Optional human-readable reason or failure descriptor.
|
||||
/// </summary>
|
||||
public string? Reason { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Identity of the end-user (subject) involved in the event, when applicable.
|
||||
/// </summary>
|
||||
public AuthEventSubject? Subject { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// OAuth/OIDC client metadata associated with the event, when applicable.
|
||||
/// </summary>
|
||||
public AuthEventClient? Client { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Tenant identifier associated with the authenticated principal or client.
|
||||
/// </summary>
|
||||
public ClassifiedString Tenant { get; init; } = ClassifiedString.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Project identifier associated with the authenticated principal or client (optional).
|
||||
/// </summary>
|
||||
public ClassifiedString Project { get; init; } = ClassifiedString.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Granted or requested scopes tied to the event.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> Scopes { get; init; } = Array.Empty<string>();
|
||||
|
||||
/// <summary>
|
||||
/// Network attributes (remote IP, forwarded headers, user agent) captured for the request.
|
||||
/// </summary>
|
||||
public AuthEventNetwork? Network { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Additional classified properties carried with the event.
|
||||
/// </summary>
|
||||
public IReadOnlyList<AuthEventProperty> Properties { get; init; } = Array.Empty<AuthEventProperty>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Describes the outcome of an audited flow.
|
||||
/// </summary>
|
||||
public enum AuthEventOutcome
|
||||
{
|
||||
/// <summary>
|
||||
/// Outcome has not been set.
|
||||
/// </summary>
|
||||
Unknown = 0,
|
||||
|
||||
/// <summary>
|
||||
/// Operation succeeded.
|
||||
/// </summary>
|
||||
Success,
|
||||
|
||||
/// <summary>
|
||||
/// Operation failed (invalid credentials, configuration issues, etc.).
|
||||
/// </summary>
|
||||
Failure,
|
||||
|
||||
/// <summary>
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Cryptography.Audit;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a structured security event emitted by the Authority host and plugins.
|
||||
/// </summary>
|
||||
public sealed record AuthEventRecord
|
||||
{
|
||||
/// <summary>
|
||||
/// Canonical event identifier (e.g. <c>authority.password.grant</c>).
|
||||
/// </summary>
|
||||
public required string EventType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// UTC timestamp captured when the event occurred.
|
||||
/// </summary>
|
||||
public DateTimeOffset OccurredAt { get; init; } = DateTimeOffset.UtcNow;
|
||||
|
||||
/// <summary>
|
||||
/// Stable correlation identifier that links the event across logs, traces, and persistence.
|
||||
/// </summary>
|
||||
public string? CorrelationId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Outcome classification for the audited operation.
|
||||
/// </summary>
|
||||
public AuthEventOutcome Outcome { get; init; } = AuthEventOutcome.Unknown;
|
||||
|
||||
/// <summary>
|
||||
/// Optional human-readable reason or failure descriptor.
|
||||
/// </summary>
|
||||
public string? Reason { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Identity of the end-user (subject) involved in the event, when applicable.
|
||||
/// </summary>
|
||||
public AuthEventSubject? Subject { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// OAuth/OIDC client metadata associated with the event, when applicable.
|
||||
/// </summary>
|
||||
public AuthEventClient? Client { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Tenant identifier associated with the authenticated principal or client.
|
||||
/// </summary>
|
||||
public ClassifiedString Tenant { get; init; } = ClassifiedString.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Project identifier associated with the authenticated principal or client (optional).
|
||||
/// </summary>
|
||||
public ClassifiedString Project { get; init; } = ClassifiedString.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Granted or requested scopes tied to the event.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> Scopes { get; init; } = Array.Empty<string>();
|
||||
|
||||
/// <summary>
|
||||
/// Network attributes (remote IP, forwarded headers, user agent) captured for the request.
|
||||
/// </summary>
|
||||
public AuthEventNetwork? Network { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Additional classified properties carried with the event.
|
||||
/// </summary>
|
||||
public IReadOnlyList<AuthEventProperty> Properties { get; init; } = Array.Empty<AuthEventProperty>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Describes the outcome of an audited flow.
|
||||
/// </summary>
|
||||
public enum AuthEventOutcome
|
||||
{
|
||||
/// <summary>
|
||||
/// Outcome has not been set.
|
||||
/// </summary>
|
||||
Unknown = 0,
|
||||
|
||||
/// <summary>
|
||||
/// Operation succeeded.
|
||||
/// </summary>
|
||||
Success,
|
||||
|
||||
/// <summary>
|
||||
/// Operation failed (invalid credentials, configuration issues, etc.).
|
||||
/// </summary>
|
||||
Failure,
|
||||
|
||||
/// <summary>
|
||||
/// Operation failed due to a lockout policy.
|
||||
/// </summary>
|
||||
LockedOut,
|
||||
@@ -108,171 +108,171 @@ public enum AuthEventOutcome
|
||||
/// Operation was rejected due to rate limiting or throttling.
|
||||
/// </summary>
|
||||
RateLimited,
|
||||
|
||||
/// <summary>
|
||||
/// Operation encountered an unexpected error.
|
||||
/// </summary>
|
||||
Error
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a string value enriched with a data classification tag.
|
||||
/// </summary>
|
||||
public readonly record struct ClassifiedString(string? Value, AuthEventDataClassification Classification)
|
||||
{
|
||||
/// <summary>
|
||||
/// An empty classified string.
|
||||
/// </summary>
|
||||
public static ClassifiedString Empty => new(null, AuthEventDataClassification.None);
|
||||
|
||||
/// <summary>
|
||||
/// Indicates whether the classified string carries a non-empty value.
|
||||
/// </summary>
|
||||
public bool HasValue => !string.IsNullOrWhiteSpace(Value);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a classified string for public/non-sensitive data.
|
||||
/// </summary>
|
||||
public static ClassifiedString Public(string? value) => Create(value, AuthEventDataClassification.None);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a classified string tagged as personally identifiable information (PII).
|
||||
/// </summary>
|
||||
public static ClassifiedString Personal(string? value) => Create(value, AuthEventDataClassification.Personal);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a classified string tagged as sensitive (e.g. credentials, secrets).
|
||||
/// </summary>
|
||||
public static ClassifiedString Sensitive(string? value) => Create(value, AuthEventDataClassification.Sensitive);
|
||||
|
||||
private static ClassifiedString Create(string? value, AuthEventDataClassification classification)
|
||||
{
|
||||
return new ClassifiedString(Normalize(value), classification);
|
||||
}
|
||||
|
||||
private static string? Normalize(string? value)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(value) ? null : value;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Supported classifications for audit data values.
|
||||
/// </summary>
|
||||
public enum AuthEventDataClassification
|
||||
{
|
||||
/// <summary>
|
||||
/// Data is not considered sensitive.
|
||||
/// </summary>
|
||||
None = 0,
|
||||
|
||||
/// <summary>
|
||||
/// Personally identifiable information (PII) that warrants redaction in certain sinks.
|
||||
/// </summary>
|
||||
Personal,
|
||||
|
||||
/// <summary>
|
||||
/// Highly sensitive information (credentials, secrets, tokens).
|
||||
/// </summary>
|
||||
Sensitive
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Captures subject metadata for an audit event.
|
||||
/// </summary>
|
||||
public sealed record AuthEventSubject
|
||||
{
|
||||
/// <summary>
|
||||
/// Stable subject identifier (PII).
|
||||
/// </summary>
|
||||
public ClassifiedString SubjectId { get; init; } = ClassifiedString.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Username or login name (PII).
|
||||
/// </summary>
|
||||
public ClassifiedString Username { get; init; } = ClassifiedString.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Optional display name (PII).
|
||||
/// </summary>
|
||||
public ClassifiedString DisplayName { get; init; } = ClassifiedString.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Optional plugin or tenant realm controlling the subject namespace.
|
||||
/// </summary>
|
||||
public ClassifiedString Realm { get; init; } = ClassifiedString.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Additional classified attributes (e.g. email, phone).
|
||||
/// </summary>
|
||||
public IReadOnlyList<AuthEventProperty> Attributes { get; init; } = Array.Empty<AuthEventProperty>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Captures OAuth/OIDC client metadata for an audit event.
|
||||
/// </summary>
|
||||
public sealed record AuthEventClient
|
||||
{
|
||||
/// <summary>
|
||||
/// Client identifier (PII for confidential clients).
|
||||
/// </summary>
|
||||
public ClassifiedString ClientId { get; init; } = ClassifiedString.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Friendly client name (may be public).
|
||||
/// </summary>
|
||||
public ClassifiedString Name { get; init; } = ClassifiedString.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Identity provider/plugin originating the client.
|
||||
/// </summary>
|
||||
public ClassifiedString Provider { get; init; } = ClassifiedString.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Captures network metadata for an audit event.
|
||||
/// </summary>
|
||||
public sealed record AuthEventNetwork
|
||||
{
|
||||
/// <summary>
|
||||
/// Remote address observed for the request (PII).
|
||||
/// </summary>
|
||||
public ClassifiedString RemoteAddress { get; init; } = ClassifiedString.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Forwarded address supplied by proxies (PII).
|
||||
/// </summary>
|
||||
public ClassifiedString ForwardedFor { get; init; } = ClassifiedString.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// User agent string associated with the request.
|
||||
/// </summary>
|
||||
public ClassifiedString UserAgent { get; init; } = ClassifiedString.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents an additional classified property associated with the audit event.
|
||||
/// </summary>
|
||||
public sealed record AuthEventProperty
|
||||
{
|
||||
/// <summary>
|
||||
/// Property name (canonical snake-case identifier).
|
||||
/// </summary>
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Classified value assigned to the property.
|
||||
/// </summary>
|
||||
public ClassifiedString Value { get; init; } = ClassifiedString.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sink that receives completed audit event records.
|
||||
/// </summary>
|
||||
public interface IAuthEventSink
|
||||
{
|
||||
/// <summary>
|
||||
/// Persists the supplied audit event.
|
||||
/// </summary>
|
||||
ValueTask WriteAsync(AuthEventRecord record, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Operation encountered an unexpected error.
|
||||
/// </summary>
|
||||
Error
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a string value enriched with a data classification tag.
|
||||
/// </summary>
|
||||
public readonly record struct ClassifiedString(string? Value, AuthEventDataClassification Classification)
|
||||
{
|
||||
/// <summary>
|
||||
/// An empty classified string.
|
||||
/// </summary>
|
||||
public static ClassifiedString Empty => new(null, AuthEventDataClassification.None);
|
||||
|
||||
/// <summary>
|
||||
/// Indicates whether the classified string carries a non-empty value.
|
||||
/// </summary>
|
||||
public bool HasValue => !string.IsNullOrWhiteSpace(Value);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a classified string for public/non-sensitive data.
|
||||
/// </summary>
|
||||
public static ClassifiedString Public(string? value) => Create(value, AuthEventDataClassification.None);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a classified string tagged as personally identifiable information (PII).
|
||||
/// </summary>
|
||||
public static ClassifiedString Personal(string? value) => Create(value, AuthEventDataClassification.Personal);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a classified string tagged as sensitive (e.g. credentials, secrets).
|
||||
/// </summary>
|
||||
public static ClassifiedString Sensitive(string? value) => Create(value, AuthEventDataClassification.Sensitive);
|
||||
|
||||
private static ClassifiedString Create(string? value, AuthEventDataClassification classification)
|
||||
{
|
||||
return new ClassifiedString(Normalize(value), classification);
|
||||
}
|
||||
|
||||
private static string? Normalize(string? value)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(value) ? null : value;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Supported classifications for audit data values.
|
||||
/// </summary>
|
||||
public enum AuthEventDataClassification
|
||||
{
|
||||
/// <summary>
|
||||
/// Data is not considered sensitive.
|
||||
/// </summary>
|
||||
None = 0,
|
||||
|
||||
/// <summary>
|
||||
/// Personally identifiable information (PII) that warrants redaction in certain sinks.
|
||||
/// </summary>
|
||||
Personal,
|
||||
|
||||
/// <summary>
|
||||
/// Highly sensitive information (credentials, secrets, tokens).
|
||||
/// </summary>
|
||||
Sensitive
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Captures subject metadata for an audit event.
|
||||
/// </summary>
|
||||
public sealed record AuthEventSubject
|
||||
{
|
||||
/// <summary>
|
||||
/// Stable subject identifier (PII).
|
||||
/// </summary>
|
||||
public ClassifiedString SubjectId { get; init; } = ClassifiedString.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Username or login name (PII).
|
||||
/// </summary>
|
||||
public ClassifiedString Username { get; init; } = ClassifiedString.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Optional display name (PII).
|
||||
/// </summary>
|
||||
public ClassifiedString DisplayName { get; init; } = ClassifiedString.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Optional plugin or tenant realm controlling the subject namespace.
|
||||
/// </summary>
|
||||
public ClassifiedString Realm { get; init; } = ClassifiedString.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Additional classified attributes (e.g. email, phone).
|
||||
/// </summary>
|
||||
public IReadOnlyList<AuthEventProperty> Attributes { get; init; } = Array.Empty<AuthEventProperty>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Captures OAuth/OIDC client metadata for an audit event.
|
||||
/// </summary>
|
||||
public sealed record AuthEventClient
|
||||
{
|
||||
/// <summary>
|
||||
/// Client identifier (PII for confidential clients).
|
||||
/// </summary>
|
||||
public ClassifiedString ClientId { get; init; } = ClassifiedString.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Friendly client name (may be public).
|
||||
/// </summary>
|
||||
public ClassifiedString Name { get; init; } = ClassifiedString.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Identity provider/plugin originating the client.
|
||||
/// </summary>
|
||||
public ClassifiedString Provider { get; init; } = ClassifiedString.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Captures network metadata for an audit event.
|
||||
/// </summary>
|
||||
public sealed record AuthEventNetwork
|
||||
{
|
||||
/// <summary>
|
||||
/// Remote address observed for the request (PII).
|
||||
/// </summary>
|
||||
public ClassifiedString RemoteAddress { get; init; } = ClassifiedString.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Forwarded address supplied by proxies (PII).
|
||||
/// </summary>
|
||||
public ClassifiedString ForwardedFor { get; init; } = ClassifiedString.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// User agent string associated with the request.
|
||||
/// </summary>
|
||||
public ClassifiedString UserAgent { get; init; } = ClassifiedString.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents an additional classified property associated with the audit event.
|
||||
/// </summary>
|
||||
public sealed record AuthEventProperty
|
||||
{
|
||||
/// <summary>
|
||||
/// Property name (canonical snake-case identifier).
|
||||
/// </summary>
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Classified value assigned to the property.
|
||||
/// </summary>
|
||||
public ClassifiedString Value { get; init; } = ClassifiedString.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sink that receives completed audit event records.
|
||||
/// </summary>
|
||||
public interface IAuthEventSink
|
||||
{
|
||||
/// <summary>
|
||||
/// Persists the supplied audit event.
|
||||
/// </summary>
|
||||
ValueTask WriteAsync(AuthEventRecord record, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
@@ -1,176 +1,176 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace StellaOps.Cryptography;
|
||||
|
||||
/// <summary>
|
||||
/// Describes the underlying key material for a <see cref="CryptoSigningKey"/>.
|
||||
/// </summary>
|
||||
public enum CryptoSigningKeyKind
|
||||
{
|
||||
Ec,
|
||||
Raw
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents asymmetric signing key material managed by a crypto provider.
|
||||
/// </summary>
|
||||
public sealed class CryptoSigningKey
|
||||
{
|
||||
private static readonly ReadOnlyDictionary<string, string?> EmptyMetadata =
|
||||
new(new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase));
|
||||
private static readonly byte[] EmptyKey = Array.Empty<byte>();
|
||||
|
||||
private readonly byte[] privateKeyBytes;
|
||||
private readonly byte[] publicKeyBytes;
|
||||
|
||||
public CryptoSigningKey(
|
||||
CryptoKeyReference reference,
|
||||
string algorithmId,
|
||||
in ECParameters privateParameters,
|
||||
DateTimeOffset createdAt,
|
||||
DateTimeOffset? expiresAt = null,
|
||||
IReadOnlyDictionary<string, string?>? metadata = null)
|
||||
{
|
||||
Reference = reference ?? throw new ArgumentNullException(nameof(reference));
|
||||
|
||||
if (string.IsNullOrWhiteSpace(algorithmId))
|
||||
{
|
||||
throw new ArgumentException("Algorithm identifier is required.", nameof(algorithmId));
|
||||
}
|
||||
|
||||
if (privateParameters.D is null || privateParameters.D.Length == 0)
|
||||
{
|
||||
throw new ArgumentException("Private key parameters must include the scalar component.", nameof(privateParameters));
|
||||
}
|
||||
|
||||
AlgorithmId = algorithmId;
|
||||
CreatedAt = createdAt;
|
||||
ExpiresAt = expiresAt;
|
||||
Kind = CryptoSigningKeyKind.Ec;
|
||||
|
||||
privateKeyBytes = EmptyKey;
|
||||
publicKeyBytes = EmptyKey;
|
||||
|
||||
PrivateParameters = CloneParameters(privateParameters, includePrivate: true);
|
||||
PublicParameters = CloneParameters(privateParameters, includePrivate: false);
|
||||
Metadata = metadata is null
|
||||
? EmptyMetadata
|
||||
: new ReadOnlyDictionary<string, string?>(metadata.ToDictionary(
|
||||
static pair => pair.Key,
|
||||
static pair => pair.Value,
|
||||
StringComparer.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
public CryptoSigningKey(
|
||||
CryptoKeyReference reference,
|
||||
string algorithmId,
|
||||
ReadOnlyMemory<byte> privateKey,
|
||||
DateTimeOffset createdAt,
|
||||
DateTimeOffset? expiresAt = null,
|
||||
ReadOnlyMemory<byte> publicKey = default,
|
||||
IReadOnlyDictionary<string, string?>? metadata = null)
|
||||
{
|
||||
Reference = reference ?? throw new ArgumentNullException(nameof(reference));
|
||||
|
||||
if (string.IsNullOrWhiteSpace(algorithmId))
|
||||
{
|
||||
throw new ArgumentException("Algorithm identifier is required.", nameof(algorithmId));
|
||||
}
|
||||
|
||||
if (privateKey.IsEmpty)
|
||||
{
|
||||
throw new ArgumentException("Private key material must be provided.", nameof(privateKey));
|
||||
}
|
||||
|
||||
AlgorithmId = algorithmId;
|
||||
CreatedAt = createdAt;
|
||||
ExpiresAt = expiresAt;
|
||||
Kind = CryptoSigningKeyKind.Raw;
|
||||
|
||||
privateKeyBytes = privateKey.ToArray();
|
||||
publicKeyBytes = publicKey.IsEmpty ? EmptyKey : publicKey.ToArray();
|
||||
|
||||
PrivateParameters = default;
|
||||
PublicParameters = default;
|
||||
Metadata = metadata is null
|
||||
? EmptyMetadata
|
||||
: new ReadOnlyDictionary<string, string?>(metadata.ToDictionary(
|
||||
static pair => pair.Key,
|
||||
static pair => pair.Value,
|
||||
StringComparer.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the key reference (id + provider hint).
|
||||
/// </summary>
|
||||
public CryptoKeyReference Reference { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the algorithm identifier (e.g., ES256).
|
||||
/// </summary>
|
||||
public string AlgorithmId { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the private EC parameters (cloned).
|
||||
/// </summary>
|
||||
public ECParameters PrivateParameters { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the public EC parameters (cloned, no private scalar).
|
||||
/// </summary>
|
||||
public ECParameters PublicParameters { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Indicates the underlying key material representation.
|
||||
/// </summary>
|
||||
public CryptoSigningKeyKind Kind { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the raw private key bytes when available (empty for EC-backed keys).
|
||||
/// </summary>
|
||||
public ReadOnlyMemory<byte> PrivateKey => privateKeyBytes;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the raw public key bytes when available (empty for EC-backed keys or when not supplied).
|
||||
/// </summary>
|
||||
public ReadOnlyMemory<byte> PublicKey => publicKeyBytes;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the timestamp when the key was created/imported.
|
||||
/// </summary>
|
||||
public DateTimeOffset CreatedAt { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the optional expiry timestamp for the key.
|
||||
/// </summary>
|
||||
public DateTimeOffset? ExpiresAt { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets arbitrary metadata entries associated with the key.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, string?> Metadata { get; }
|
||||
|
||||
private static ECParameters CloneParameters(ECParameters source, bool includePrivate)
|
||||
{
|
||||
var clone = new ECParameters
|
||||
{
|
||||
Curve = source.Curve,
|
||||
Q = new ECPoint
|
||||
{
|
||||
X = source.Q.X is null ? null : (byte[])source.Q.X.Clone(),
|
||||
Y = source.Q.Y is null ? null : (byte[])source.Q.Y.Clone()
|
||||
}
|
||||
};
|
||||
|
||||
if (includePrivate && source.D is not null)
|
||||
{
|
||||
clone.D = (byte[])source.D.Clone();
|
||||
}
|
||||
|
||||
return clone;
|
||||
}
|
||||
}
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace StellaOps.Cryptography;
|
||||
|
||||
/// <summary>
|
||||
/// Describes the underlying key material for a <see cref="CryptoSigningKey"/>.
|
||||
/// </summary>
|
||||
public enum CryptoSigningKeyKind
|
||||
{
|
||||
Ec,
|
||||
Raw
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents asymmetric signing key material managed by a crypto provider.
|
||||
/// </summary>
|
||||
public sealed class CryptoSigningKey
|
||||
{
|
||||
private static readonly ReadOnlyDictionary<string, string?> EmptyMetadata =
|
||||
new(new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase));
|
||||
private static readonly byte[] EmptyKey = Array.Empty<byte>();
|
||||
|
||||
private readonly byte[] privateKeyBytes;
|
||||
private readonly byte[] publicKeyBytes;
|
||||
|
||||
public CryptoSigningKey(
|
||||
CryptoKeyReference reference,
|
||||
string algorithmId,
|
||||
in ECParameters privateParameters,
|
||||
DateTimeOffset createdAt,
|
||||
DateTimeOffset? expiresAt = null,
|
||||
IReadOnlyDictionary<string, string?>? metadata = null)
|
||||
{
|
||||
Reference = reference ?? throw new ArgumentNullException(nameof(reference));
|
||||
|
||||
if (string.IsNullOrWhiteSpace(algorithmId))
|
||||
{
|
||||
throw new ArgumentException("Algorithm identifier is required.", nameof(algorithmId));
|
||||
}
|
||||
|
||||
if (privateParameters.D is null || privateParameters.D.Length == 0)
|
||||
{
|
||||
throw new ArgumentException("Private key parameters must include the scalar component.", nameof(privateParameters));
|
||||
}
|
||||
|
||||
AlgorithmId = algorithmId;
|
||||
CreatedAt = createdAt;
|
||||
ExpiresAt = expiresAt;
|
||||
Kind = CryptoSigningKeyKind.Ec;
|
||||
|
||||
privateKeyBytes = EmptyKey;
|
||||
publicKeyBytes = EmptyKey;
|
||||
|
||||
PrivateParameters = CloneParameters(privateParameters, includePrivate: true);
|
||||
PublicParameters = CloneParameters(privateParameters, includePrivate: false);
|
||||
Metadata = metadata is null
|
||||
? EmptyMetadata
|
||||
: new ReadOnlyDictionary<string, string?>(metadata.ToDictionary(
|
||||
static pair => pair.Key,
|
||||
static pair => pair.Value,
|
||||
StringComparer.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
public CryptoSigningKey(
|
||||
CryptoKeyReference reference,
|
||||
string algorithmId,
|
||||
ReadOnlyMemory<byte> privateKey,
|
||||
DateTimeOffset createdAt,
|
||||
DateTimeOffset? expiresAt = null,
|
||||
ReadOnlyMemory<byte> publicKey = default,
|
||||
IReadOnlyDictionary<string, string?>? metadata = null)
|
||||
{
|
||||
Reference = reference ?? throw new ArgumentNullException(nameof(reference));
|
||||
|
||||
if (string.IsNullOrWhiteSpace(algorithmId))
|
||||
{
|
||||
throw new ArgumentException("Algorithm identifier is required.", nameof(algorithmId));
|
||||
}
|
||||
|
||||
if (privateKey.IsEmpty)
|
||||
{
|
||||
throw new ArgumentException("Private key material must be provided.", nameof(privateKey));
|
||||
}
|
||||
|
||||
AlgorithmId = algorithmId;
|
||||
CreatedAt = createdAt;
|
||||
ExpiresAt = expiresAt;
|
||||
Kind = CryptoSigningKeyKind.Raw;
|
||||
|
||||
privateKeyBytes = privateKey.ToArray();
|
||||
publicKeyBytes = publicKey.IsEmpty ? EmptyKey : publicKey.ToArray();
|
||||
|
||||
PrivateParameters = default;
|
||||
PublicParameters = default;
|
||||
Metadata = metadata is null
|
||||
? EmptyMetadata
|
||||
: new ReadOnlyDictionary<string, string?>(metadata.ToDictionary(
|
||||
static pair => pair.Key,
|
||||
static pair => pair.Value,
|
||||
StringComparer.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the key reference (id + provider hint).
|
||||
/// </summary>
|
||||
public CryptoKeyReference Reference { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the algorithm identifier (e.g., ES256).
|
||||
/// </summary>
|
||||
public string AlgorithmId { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the private EC parameters (cloned).
|
||||
/// </summary>
|
||||
public ECParameters PrivateParameters { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the public EC parameters (cloned, no private scalar).
|
||||
/// </summary>
|
||||
public ECParameters PublicParameters { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Indicates the underlying key material representation.
|
||||
/// </summary>
|
||||
public CryptoSigningKeyKind Kind { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the raw private key bytes when available (empty for EC-backed keys).
|
||||
/// </summary>
|
||||
public ReadOnlyMemory<byte> PrivateKey => privateKeyBytes;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the raw public key bytes when available (empty for EC-backed keys or when not supplied).
|
||||
/// </summary>
|
||||
public ReadOnlyMemory<byte> PublicKey => publicKeyBytes;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the timestamp when the key was created/imported.
|
||||
/// </summary>
|
||||
public DateTimeOffset CreatedAt { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the optional expiry timestamp for the key.
|
||||
/// </summary>
|
||||
public DateTimeOffset? ExpiresAt { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets arbitrary metadata entries associated with the key.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, string?> Metadata { get; }
|
||||
|
||||
private static ECParameters CloneParameters(ECParameters source, bool includePrivate)
|
||||
{
|
||||
var clone = new ECParameters
|
||||
{
|
||||
Curve = source.Curve,
|
||||
Q = new ECPoint
|
||||
{
|
||||
X = source.Q.X is null ? null : (byte[])source.Q.X.Clone(),
|
||||
Y = source.Q.Y is null ? null : (byte[])source.Q.Y.Clone()
|
||||
}
|
||||
};
|
||||
|
||||
if (includePrivate && source.D is not null)
|
||||
{
|
||||
clone.D = (byte[])source.D.Clone();
|
||||
}
|
||||
|
||||
return clone;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,181 +1,181 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace StellaOps.Cryptography;
|
||||
|
||||
/// <summary>
|
||||
/// Default in-process crypto provider exposing password hashing capabilities.
|
||||
/// </summary>
|
||||
public sealed class DefaultCryptoProvider : ICryptoProvider, ICryptoProviderDiagnostics
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, IPasswordHasher> passwordHashers;
|
||||
private readonly ConcurrentDictionary<string, CryptoSigningKey> signingKeys;
|
||||
private static readonly HashSet<string> SupportedSigningAlgorithms = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
SignatureAlgorithms.Es256
|
||||
};
|
||||
|
||||
private static readonly HashSet<string> SupportedHashAlgorithms = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
HashAlgorithms.Sha256,
|
||||
HashAlgorithms.Sha384,
|
||||
HashAlgorithms.Sha512
|
||||
};
|
||||
|
||||
public DefaultCryptoProvider()
|
||||
{
|
||||
passwordHashers = new ConcurrentDictionary<string, IPasswordHasher>(StringComparer.OrdinalIgnoreCase);
|
||||
signingKeys = new ConcurrentDictionary<string, CryptoSigningKey>(StringComparer.Ordinal);
|
||||
|
||||
var argon = new Argon2idPasswordHasher();
|
||||
var pbkdf2 = new Pbkdf2PasswordHasher();
|
||||
|
||||
passwordHashers.TryAdd(PasswordHashAlgorithm.Argon2id.ToString(), argon);
|
||||
passwordHashers.TryAdd(PasswordHashAlgorithms.Argon2id, argon);
|
||||
passwordHashers.TryAdd(PasswordHashAlgorithm.Pbkdf2.ToString(), pbkdf2);
|
||||
passwordHashers.TryAdd(PasswordHashAlgorithms.Pbkdf2Sha256, pbkdf2);
|
||||
}
|
||||
|
||||
public string Name => "default";
|
||||
|
||||
public bool Supports(CryptoCapability capability, string algorithmId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(algorithmId))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return capability switch
|
||||
{
|
||||
CryptoCapability.PasswordHashing => passwordHashers.ContainsKey(algorithmId),
|
||||
CryptoCapability.Signing or CryptoCapability.Verification => SupportedSigningAlgorithms.Contains(algorithmId),
|
||||
CryptoCapability.ContentHashing => SupportedHashAlgorithms.Contains(algorithmId),
|
||||
_ => false
|
||||
};
|
||||
}
|
||||
|
||||
public IPasswordHasher GetPasswordHasher(string algorithmId)
|
||||
{
|
||||
if (!Supports(CryptoCapability.PasswordHashing, algorithmId))
|
||||
{
|
||||
throw new InvalidOperationException($"Password hashing algorithm '{algorithmId}' is not supported by provider '{Name}'.");
|
||||
}
|
||||
|
||||
return passwordHashers[algorithmId];
|
||||
}
|
||||
|
||||
public ICryptoHasher GetHasher(string algorithmId)
|
||||
{
|
||||
if (!Supports(CryptoCapability.ContentHashing, algorithmId))
|
||||
{
|
||||
throw new InvalidOperationException($"Hash algorithm '{algorithmId}' is not supported by provider '{Name}'.");
|
||||
}
|
||||
|
||||
return new DefaultCryptoHasher(algorithmId);
|
||||
}
|
||||
|
||||
public ICryptoSigner GetSigner(string algorithmId, CryptoKeyReference keyReference)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(keyReference);
|
||||
|
||||
if (!Supports(CryptoCapability.Signing, algorithmId))
|
||||
{
|
||||
throw new InvalidOperationException($"Signing algorithm '{algorithmId}' is not supported by provider '{Name}'.");
|
||||
}
|
||||
|
||||
if (!signingKeys.TryGetValue(keyReference.KeyId, out var signingKey))
|
||||
{
|
||||
throw new KeyNotFoundException($"Signing key '{keyReference.KeyId}' is not registered with provider '{Name}'.");
|
||||
}
|
||||
|
||||
if (!string.Equals(signingKey.AlgorithmId, algorithmId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Signing key '{keyReference.KeyId}' is registered for algorithm '{signingKey.AlgorithmId}', not '{algorithmId}'.");
|
||||
}
|
||||
|
||||
return EcdsaSigner.Create(signingKey);
|
||||
}
|
||||
|
||||
public void UpsertSigningKey(CryptoSigningKey signingKey)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(signingKey);
|
||||
EnsureSigningSupported(signingKey.AlgorithmId);
|
||||
if (signingKey.Kind != CryptoSigningKeyKind.Ec)
|
||||
{
|
||||
throw new InvalidOperationException($"Provider '{Name}' only accepts EC signing keys.");
|
||||
}
|
||||
ValidateSigningKey(signingKey);
|
||||
|
||||
signingKeys.AddOrUpdate(signingKey.Reference.KeyId, signingKey, (_, _) => signingKey);
|
||||
}
|
||||
|
||||
public bool RemoveSigningKey(string keyId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(keyId))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return signingKeys.TryRemove(keyId, out _);
|
||||
}
|
||||
|
||||
public IReadOnlyCollection<CryptoSigningKey> GetSigningKeys()
|
||||
=> signingKeys.Values.ToArray();
|
||||
|
||||
public IEnumerable<CryptoProviderKeyDescriptor> DescribeKeys()
|
||||
{
|
||||
foreach (var key in signingKeys.Values)
|
||||
{
|
||||
var metadata = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["kind"] = key.Kind.ToString(),
|
||||
["createdAt"] = key.CreatedAt.UtcDateTime.ToString("O"),
|
||||
["providerHint"] = key.Reference.ProviderHint,
|
||||
["provider"] = Name
|
||||
};
|
||||
|
||||
if (key.ExpiresAt.HasValue)
|
||||
{
|
||||
metadata["expiresAt"] = key.ExpiresAt.Value.UtcDateTime.ToString("O");
|
||||
}
|
||||
|
||||
foreach (var pair in key.Metadata)
|
||||
{
|
||||
metadata[$"meta.{pair.Key}"] = pair.Value;
|
||||
}
|
||||
|
||||
yield return new CryptoProviderKeyDescriptor(
|
||||
Name,
|
||||
key.Reference.KeyId,
|
||||
key.AlgorithmId,
|
||||
metadata);
|
||||
}
|
||||
}
|
||||
|
||||
private static void EnsureSigningSupported(string algorithmId)
|
||||
{
|
||||
if (!SupportedSigningAlgorithms.Contains(algorithmId))
|
||||
{
|
||||
throw new InvalidOperationException($"Signing algorithm '{algorithmId}' is not supported by provider 'default'.");
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateSigningKey(CryptoSigningKey signingKey)
|
||||
{
|
||||
if (!string.Equals(signingKey.AlgorithmId, SignatureAlgorithms.Es256, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new InvalidOperationException($"Only ES256 signing keys are currently supported by provider 'default'.");
|
||||
}
|
||||
|
||||
var expected = ECCurve.NamedCurves.nistP256;
|
||||
var curve = signingKey.PrivateParameters.Curve;
|
||||
if (!curve.IsNamed || !string.Equals(curve.Oid.Value, expected.Oid.Value, StringComparison.Ordinal))
|
||||
{
|
||||
throw new InvalidOperationException("ES256 signing keys must use the NIST P-256 curve.");
|
||||
}
|
||||
}
|
||||
}
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace StellaOps.Cryptography;
|
||||
|
||||
/// <summary>
|
||||
/// Default in-process crypto provider exposing password hashing capabilities.
|
||||
/// </summary>
|
||||
public sealed class DefaultCryptoProvider : ICryptoProvider, ICryptoProviderDiagnostics
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, IPasswordHasher> passwordHashers;
|
||||
private readonly ConcurrentDictionary<string, CryptoSigningKey> signingKeys;
|
||||
private static readonly HashSet<string> SupportedSigningAlgorithms = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
SignatureAlgorithms.Es256
|
||||
};
|
||||
|
||||
private static readonly HashSet<string> SupportedHashAlgorithms = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
HashAlgorithms.Sha256,
|
||||
HashAlgorithms.Sha384,
|
||||
HashAlgorithms.Sha512
|
||||
};
|
||||
|
||||
public DefaultCryptoProvider()
|
||||
{
|
||||
passwordHashers = new ConcurrentDictionary<string, IPasswordHasher>(StringComparer.OrdinalIgnoreCase);
|
||||
signingKeys = new ConcurrentDictionary<string, CryptoSigningKey>(StringComparer.Ordinal);
|
||||
|
||||
var argon = new Argon2idPasswordHasher();
|
||||
var pbkdf2 = new Pbkdf2PasswordHasher();
|
||||
|
||||
passwordHashers.TryAdd(PasswordHashAlgorithm.Argon2id.ToString(), argon);
|
||||
passwordHashers.TryAdd(PasswordHashAlgorithms.Argon2id, argon);
|
||||
passwordHashers.TryAdd(PasswordHashAlgorithm.Pbkdf2.ToString(), pbkdf2);
|
||||
passwordHashers.TryAdd(PasswordHashAlgorithms.Pbkdf2Sha256, pbkdf2);
|
||||
}
|
||||
|
||||
public string Name => "default";
|
||||
|
||||
public bool Supports(CryptoCapability capability, string algorithmId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(algorithmId))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return capability switch
|
||||
{
|
||||
CryptoCapability.PasswordHashing => passwordHashers.ContainsKey(algorithmId),
|
||||
CryptoCapability.Signing or CryptoCapability.Verification => SupportedSigningAlgorithms.Contains(algorithmId),
|
||||
CryptoCapability.ContentHashing => SupportedHashAlgorithms.Contains(algorithmId),
|
||||
_ => false
|
||||
};
|
||||
}
|
||||
|
||||
public IPasswordHasher GetPasswordHasher(string algorithmId)
|
||||
{
|
||||
if (!Supports(CryptoCapability.PasswordHashing, algorithmId))
|
||||
{
|
||||
throw new InvalidOperationException($"Password hashing algorithm '{algorithmId}' is not supported by provider '{Name}'.");
|
||||
}
|
||||
|
||||
return passwordHashers[algorithmId];
|
||||
}
|
||||
|
||||
public ICryptoHasher GetHasher(string algorithmId)
|
||||
{
|
||||
if (!Supports(CryptoCapability.ContentHashing, algorithmId))
|
||||
{
|
||||
throw new InvalidOperationException($"Hash algorithm '{algorithmId}' is not supported by provider '{Name}'.");
|
||||
}
|
||||
|
||||
return new DefaultCryptoHasher(algorithmId);
|
||||
}
|
||||
|
||||
public ICryptoSigner GetSigner(string algorithmId, CryptoKeyReference keyReference)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(keyReference);
|
||||
|
||||
if (!Supports(CryptoCapability.Signing, algorithmId))
|
||||
{
|
||||
throw new InvalidOperationException($"Signing algorithm '{algorithmId}' is not supported by provider '{Name}'.");
|
||||
}
|
||||
|
||||
if (!signingKeys.TryGetValue(keyReference.KeyId, out var signingKey))
|
||||
{
|
||||
throw new KeyNotFoundException($"Signing key '{keyReference.KeyId}' is not registered with provider '{Name}'.");
|
||||
}
|
||||
|
||||
if (!string.Equals(signingKey.AlgorithmId, algorithmId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Signing key '{keyReference.KeyId}' is registered for algorithm '{signingKey.AlgorithmId}', not '{algorithmId}'.");
|
||||
}
|
||||
|
||||
return EcdsaSigner.Create(signingKey);
|
||||
}
|
||||
|
||||
public void UpsertSigningKey(CryptoSigningKey signingKey)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(signingKey);
|
||||
EnsureSigningSupported(signingKey.AlgorithmId);
|
||||
if (signingKey.Kind != CryptoSigningKeyKind.Ec)
|
||||
{
|
||||
throw new InvalidOperationException($"Provider '{Name}' only accepts EC signing keys.");
|
||||
}
|
||||
ValidateSigningKey(signingKey);
|
||||
|
||||
signingKeys.AddOrUpdate(signingKey.Reference.KeyId, signingKey, (_, _) => signingKey);
|
||||
}
|
||||
|
||||
public bool RemoveSigningKey(string keyId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(keyId))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return signingKeys.TryRemove(keyId, out _);
|
||||
}
|
||||
|
||||
public IReadOnlyCollection<CryptoSigningKey> GetSigningKeys()
|
||||
=> signingKeys.Values.ToArray();
|
||||
|
||||
public IEnumerable<CryptoProviderKeyDescriptor> DescribeKeys()
|
||||
{
|
||||
foreach (var key in signingKeys.Values)
|
||||
{
|
||||
var metadata = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["kind"] = key.Kind.ToString(),
|
||||
["createdAt"] = key.CreatedAt.UtcDateTime.ToString("O"),
|
||||
["providerHint"] = key.Reference.ProviderHint,
|
||||
["provider"] = Name
|
||||
};
|
||||
|
||||
if (key.ExpiresAt.HasValue)
|
||||
{
|
||||
metadata["expiresAt"] = key.ExpiresAt.Value.UtcDateTime.ToString("O");
|
||||
}
|
||||
|
||||
foreach (var pair in key.Metadata)
|
||||
{
|
||||
metadata[$"meta.{pair.Key}"] = pair.Value;
|
||||
}
|
||||
|
||||
yield return new CryptoProviderKeyDescriptor(
|
||||
Name,
|
||||
key.Reference.KeyId,
|
||||
key.AlgorithmId,
|
||||
metadata);
|
||||
}
|
||||
}
|
||||
|
||||
private static void EnsureSigningSupported(string algorithmId)
|
||||
{
|
||||
if (!SupportedSigningAlgorithms.Contains(algorithmId))
|
||||
{
|
||||
throw new InvalidOperationException($"Signing algorithm '{algorithmId}' is not supported by provider 'default'.");
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateSigningKey(CryptoSigningKey signingKey)
|
||||
{
|
||||
if (!string.Equals(signingKey.AlgorithmId, SignatureAlgorithms.Es256, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new InvalidOperationException($"Only ES256 signing keys are currently supported by provider 'default'.");
|
||||
}
|
||||
|
||||
var expected = ECCurve.NamedCurves.nistP256;
|
||||
var curve = signingKey.PrivateParameters.Curve;
|
||||
if (!curve.IsNamed || !string.Equals(curve.Oid.Value, expected.Oid.Value, StringComparison.Ordinal))
|
||||
{
|
||||
throw new InvalidOperationException("ES256 signing keys must use the NIST P-256 curve.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace StellaOps.DependencyInjection;
|
||||
|
||||
public interface IDependencyInjectionRoutine
|
||||
{
|
||||
IServiceCollection Register(
|
||||
IServiceCollection services,
|
||||
IConfiguration configuration);
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace StellaOps.DependencyInjection;
|
||||
|
||||
public interface IDependencyInjectionRoutine
|
||||
{
|
||||
IServiceCollection Register(
|
||||
IServiceCollection services,
|
||||
IConfiguration configuration);
|
||||
}
|
||||
@@ -1,64 +1,64 @@
|
||||
using System;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace StellaOps.DependencyInjection;
|
||||
|
||||
/// <summary>
|
||||
/// Declares how a plug-in type should be registered with the host dependency injection container.
|
||||
/// </summary>
|
||||
[AttributeUsage(AttributeTargets.Class, AllowMultiple = true, Inherited = false)]
|
||||
public sealed class ServiceBindingAttribute : Attribute
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a binding that registers the decorated type as itself with a singleton lifetime.
|
||||
/// </summary>
|
||||
public ServiceBindingAttribute()
|
||||
: this(null, ServiceLifetime.Singleton)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a binding that registers the decorated type as itself with the specified lifetime.
|
||||
/// </summary>
|
||||
public ServiceBindingAttribute(ServiceLifetime lifetime)
|
||||
: this(null, lifetime)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a binding that registers the decorated type as the specified service type with a singleton lifetime.
|
||||
/// </summary>
|
||||
public ServiceBindingAttribute(Type serviceType)
|
||||
: this(serviceType, ServiceLifetime.Singleton)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a binding that registers the decorated type as the specified service type.
|
||||
/// </summary>
|
||||
public ServiceBindingAttribute(Type? serviceType, ServiceLifetime lifetime)
|
||||
{
|
||||
ServiceType = serviceType;
|
||||
Lifetime = lifetime;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The service contract that should resolve to the decorated implementation. When <c>null</c>, the implementation registers itself.
|
||||
/// </summary>
|
||||
public Type? ServiceType { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The lifetime that should be used when registering the decorated implementation.
|
||||
/// </summary>
|
||||
public ServiceLifetime Lifetime { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Indicates whether existing descriptors for the same service type should be removed before this binding is applied.
|
||||
/// </summary>
|
||||
public bool ReplaceExisting { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When true, the implementation is also registered as itself even if a service type is specified.
|
||||
/// </summary>
|
||||
public bool RegisterAsSelf { get; init; }
|
||||
}
|
||||
using System;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace StellaOps.DependencyInjection;
|
||||
|
||||
/// <summary>
|
||||
/// Declares how a plug-in type should be registered with the host dependency injection container.
|
||||
/// </summary>
|
||||
[AttributeUsage(AttributeTargets.Class, AllowMultiple = true, Inherited = false)]
|
||||
public sealed class ServiceBindingAttribute : Attribute
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a binding that registers the decorated type as itself with a singleton lifetime.
|
||||
/// </summary>
|
||||
public ServiceBindingAttribute()
|
||||
: this(null, ServiceLifetime.Singleton)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a binding that registers the decorated type as itself with the specified lifetime.
|
||||
/// </summary>
|
||||
public ServiceBindingAttribute(ServiceLifetime lifetime)
|
||||
: this(null, lifetime)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a binding that registers the decorated type as the specified service type with a singleton lifetime.
|
||||
/// </summary>
|
||||
public ServiceBindingAttribute(Type serviceType)
|
||||
: this(serviceType, ServiceLifetime.Singleton)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a binding that registers the decorated type as the specified service type.
|
||||
/// </summary>
|
||||
public ServiceBindingAttribute(Type? serviceType, ServiceLifetime lifetime)
|
||||
{
|
||||
ServiceType = serviceType;
|
||||
Lifetime = lifetime;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The service contract that should resolve to the decorated implementation. When <c>null</c>, the implementation registers itself.
|
||||
/// </summary>
|
||||
public Type? ServiceType { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The lifetime that should be used when registering the decorated implementation.
|
||||
/// </summary>
|
||||
public ServiceLifetime Lifetime { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Indicates whether existing descriptors for the same service type should be removed before this binding is applied.
|
||||
/// </summary>
|
||||
public bool ReplaceExisting { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When true, the implementation is also registered as itself even if a service type is specified.
|
||||
/// </summary>
|
||||
public bool RegisterAsSelf { get; init; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace StellaOps.Messaging.Transport.Valkey;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for the Valkey/Redis transport.
|
||||
/// </summary>
|
||||
public class ValkeyTransportOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the connection string (e.g., "localhost:6379" or "valkey:6379,password=secret").
|
||||
/// </summary>
|
||||
[Required]
|
||||
public string ConnectionString { get; set; } = "localhost:6379";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the default database number.
|
||||
/// </summary>
|
||||
public int? Database { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the connection initialization timeout.
|
||||
/// </summary>
|
||||
public TimeSpan InitializationTimeout { get; set; } = TimeSpan.FromSeconds(30);
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the number of connection retries.
|
||||
/// </summary>
|
||||
public int ConnectRetry { get; set; } = 3;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether to abort on connect fail.
|
||||
/// </summary>
|
||||
public bool AbortOnConnectFail { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the prefix for idempotency keys.
|
||||
/// </summary>
|
||||
public string IdempotencyKeyPrefix { get; set; } = "msgq:idem:";
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||
<RootNamespace>StellaOps.Messaging.Transport.Valkey</RootNamespace>
|
||||
<AssemblyName>StellaOps.Messaging.Transport.Valkey</AssemblyName>
|
||||
<Description>Valkey/Redis transport plugin for StellaOps.Messaging</Description>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.0" />
|
||||
<PackageReference Include="StackExchange.Redis" Version="2.8.37" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../StellaOps.Messaging/StellaOps.Messaging.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,110 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StackExchange.Redis;
|
||||
|
||||
namespace StellaOps.Messaging.Transport.Valkey;
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating and managing Valkey/Redis connections.
|
||||
/// </summary>
|
||||
public sealed class ValkeyConnectionFactory : IAsyncDisposable
|
||||
{
|
||||
private readonly ValkeyTransportOptions _options;
|
||||
private readonly ILogger<ValkeyConnectionFactory>? _logger;
|
||||
private readonly SemaphoreSlim _connectionLock = new(1, 1);
|
||||
private readonly Func<ConfigurationOptions, Task<IConnectionMultiplexer>> _connectionFactory;
|
||||
|
||||
private IConnectionMultiplexer? _connection;
|
||||
private bool _disposed;
|
||||
|
||||
public ValkeyConnectionFactory(
|
||||
IOptions<ValkeyTransportOptions> options,
|
||||
ILogger<ValkeyConnectionFactory>? logger = null,
|
||||
Func<ConfigurationOptions, Task<IConnectionMultiplexer>>? connectionFactory = null)
|
||||
{
|
||||
_options = options.Value;
|
||||
_logger = logger;
|
||||
_connectionFactory = connectionFactory ??
|
||||
(config => Task.FromResult<IConnectionMultiplexer>(ConnectionMultiplexer.Connect(config)));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a database connection.
|
||||
/// </summary>
|
||||
public async ValueTask<IDatabase> GetDatabaseAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var connection = await GetConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
return connection.GetDatabase(_options.Database ?? -1);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the underlying connection multiplexer.
|
||||
/// </summary>
|
||||
public async ValueTask<IConnectionMultiplexer> GetConnectionAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_connection is not null && _connection.IsConnected)
|
||||
{
|
||||
return _connection;
|
||||
}
|
||||
|
||||
await _connectionLock.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
if (_connection is null || !_connection.IsConnected)
|
||||
{
|
||||
if (_connection is not null)
|
||||
{
|
||||
await _connection.CloseAsync().ConfigureAwait(false);
|
||||
_connection.Dispose();
|
||||
}
|
||||
|
||||
var config = ConfigurationOptions.Parse(_options.ConnectionString);
|
||||
config.AbortOnConnectFail = _options.AbortOnConnectFail;
|
||||
config.ConnectTimeout = (int)_options.InitializationTimeout.TotalMilliseconds;
|
||||
config.ConnectRetry = _options.ConnectRetry;
|
||||
|
||||
if (_options.Database.HasValue)
|
||||
{
|
||||
config.DefaultDatabase = _options.Database.Value;
|
||||
}
|
||||
|
||||
_logger?.LogDebug("Connecting to Valkey at {Endpoint}", _options.ConnectionString);
|
||||
_connection = await _connectionFactory(config).ConfigureAwait(false);
|
||||
_logger?.LogInformation("Connected to Valkey");
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_connectionLock.Release();
|
||||
}
|
||||
|
||||
return _connection;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests the connection by sending a PING command.
|
||||
/// </summary>
|
||||
public async ValueTask PingAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var db = await GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
|
||||
await db.ExecuteAsync("PING").ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_disposed = true;
|
||||
|
||||
if (_connection is not null)
|
||||
{
|
||||
await _connection.CloseAsync().ConfigureAwait(false);
|
||||
_connection.Dispose();
|
||||
}
|
||||
|
||||
_connectionLock.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
using StellaOps.Messaging.Abstractions;
|
||||
|
||||
namespace StellaOps.Messaging.Transport.Valkey;
|
||||
|
||||
/// <summary>
|
||||
/// Valkey/Redis implementation of a message lease.
|
||||
/// </summary>
|
||||
/// <typeparam name="TMessage">The message type.</typeparam>
|
||||
internal sealed class ValkeyMessageLease<TMessage> : IMessageLease<TMessage> where TMessage : class
|
||||
{
|
||||
private readonly ValkeyMessageQueue<TMessage> _queue;
|
||||
private int _completed;
|
||||
|
||||
internal ValkeyMessageLease(
|
||||
ValkeyMessageQueue<TMessage> queue,
|
||||
string messageId,
|
||||
TMessage message,
|
||||
int attempt,
|
||||
DateTimeOffset enqueuedAt,
|
||||
DateTimeOffset leaseExpiresAt,
|
||||
string consumer,
|
||||
string? tenantId,
|
||||
string? correlationId,
|
||||
IReadOnlyDictionary<string, string>? headers)
|
||||
{
|
||||
_queue = queue;
|
||||
MessageId = messageId;
|
||||
Message = message;
|
||||
Attempt = attempt;
|
||||
EnqueuedAt = enqueuedAt;
|
||||
LeaseExpiresAt = leaseExpiresAt;
|
||||
Consumer = consumer;
|
||||
TenantId = tenantId;
|
||||
CorrelationId = correlationId;
|
||||
Headers = headers;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string MessageId { get; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public TMessage Message { get; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public int Attempt { get; private set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public DateTimeOffset EnqueuedAt { get; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public DateTimeOffset LeaseExpiresAt { get; private set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Consumer { get; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public string? TenantId { get; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public string? CorrelationId { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the message headers.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, string>? Headers { get; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask AcknowledgeAsync(CancellationToken cancellationToken = default)
|
||||
=> _queue.AcknowledgeAsync(this, cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask RenewAsync(TimeSpan extension, CancellationToken cancellationToken = default)
|
||||
=> _queue.RenewLeaseAsync(this, extension, cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask ReleaseAsync(ReleaseDisposition disposition, CancellationToken cancellationToken = default)
|
||||
=> _queue.ReleaseAsync(this, disposition, cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask DeadLetterAsync(string reason, CancellationToken cancellationToken = default)
|
||||
=> _queue.DeadLetterAsync(this, reason, cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
// No resources to dispose - lease state is managed by the queue
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
internal bool TryBeginCompletion()
|
||||
=> Interlocked.CompareExchange(ref _completed, 1, 0) == 0;
|
||||
|
||||
internal void RefreshLease(DateTimeOffset expiresAt)
|
||||
=> LeaseExpiresAt = expiresAt;
|
||||
|
||||
internal void IncrementAttempt()
|
||||
=> Attempt++;
|
||||
}
|
||||
@@ -0,0 +1,640 @@
|
||||
using System.Buffers;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Messaging.Abstractions;
|
||||
using StackExchange.Redis;
|
||||
|
||||
namespace StellaOps.Messaging.Transport.Valkey;
|
||||
|
||||
/// <summary>
|
||||
/// Valkey/Redis Streams implementation of <see cref="IMessageQueue{TMessage}"/>.
|
||||
/// </summary>
|
||||
/// <typeparam name="TMessage">The message type.</typeparam>
|
||||
public sealed class ValkeyMessageQueue<TMessage> : IMessageQueue<TMessage>, IAsyncDisposable
|
||||
where TMessage : class
|
||||
{
|
||||
private const string ProviderNameValue = "valkey";
|
||||
|
||||
private static class Fields
|
||||
{
|
||||
public const string Payload = "payload";
|
||||
public const string TenantId = "tenant";
|
||||
public const string CorrelationId = "correlation";
|
||||
public const string IdempotencyKey = "idem";
|
||||
public const string Attempt = "attempt";
|
||||
public const string EnqueuedAt = "enq_at";
|
||||
public const string HeaderPrefix = "h:";
|
||||
}
|
||||
|
||||
private readonly ValkeyConnectionFactory _connectionFactory;
|
||||
private readonly MessageQueueOptions _queueOptions;
|
||||
private readonly ValkeyTransportOptions _transportOptions;
|
||||
private readonly ILogger<ValkeyMessageQueue<TMessage>>? _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly SemaphoreSlim _groupInitLock = new(1, 1);
|
||||
private readonly JsonSerializerOptions _jsonOptions;
|
||||
|
||||
private volatile bool _groupInitialized;
|
||||
private bool _disposed;
|
||||
|
||||
public ValkeyMessageQueue(
|
||||
ValkeyConnectionFactory connectionFactory,
|
||||
MessageQueueOptions queueOptions,
|
||||
ValkeyTransportOptions transportOptions,
|
||||
ILogger<ValkeyMessageQueue<TMessage>>? logger = null,
|
||||
TimeProvider? timeProvider = null,
|
||||
JsonSerializerOptions? jsonOptions = null)
|
||||
{
|
||||
_connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory));
|
||||
_queueOptions = queueOptions ?? throw new ArgumentNullException(nameof(queueOptions));
|
||||
_transportOptions = transportOptions ?? throw new ArgumentNullException(nameof(transportOptions));
|
||||
_logger = logger;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_jsonOptions = jsonOptions ?? new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ProviderName => ProviderNameValue;
|
||||
|
||||
/// <inheritdoc />
|
||||
public string QueueName => _queueOptions.QueueName;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<EnqueueResult> EnqueueAsync(
|
||||
TMessage message,
|
||||
EnqueueOptions? options = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(message);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var db = await _connectionFactory.GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
|
||||
await EnsureConsumerGroupAsync(db, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var entries = BuildEntries(message, now, 1, options);
|
||||
|
||||
var messageId = await AddToStreamAsync(
|
||||
db,
|
||||
_queueOptions.QueueName,
|
||||
entries,
|
||||
_queueOptions.ApproximateMaxLength)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
// Handle idempotency if key provided
|
||||
if (!string.IsNullOrWhiteSpace(options?.IdempotencyKey))
|
||||
{
|
||||
var idempotencyKey = BuildIdempotencyKey(options.IdempotencyKey);
|
||||
var stored = await db.StringSetAsync(
|
||||
idempotencyKey,
|
||||
messageId,
|
||||
when: When.NotExists,
|
||||
expiry: _queueOptions.IdempotencyWindow)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (!stored)
|
||||
{
|
||||
// Duplicate detected - delete the message we just added and return existing
|
||||
await db.StreamDeleteAsync(_queueOptions.QueueName, [(RedisValue)messageId]).ConfigureAwait(false);
|
||||
|
||||
var existing = await db.StringGetAsync(idempotencyKey).ConfigureAwait(false);
|
||||
var existingId = existing.IsNullOrEmpty ? messageId : existing.ToString();
|
||||
|
||||
_logger?.LogDebug(
|
||||
"Duplicate enqueue detected for queue {Queue} with key {Key}; returning existing id {MessageId}",
|
||||
_queueOptions.QueueName, idempotencyKey, existingId);
|
||||
|
||||
return EnqueueResult.Duplicate(existingId);
|
||||
}
|
||||
}
|
||||
|
||||
_logger?.LogDebug("Enqueued message to {Queue} with id {MessageId}", _queueOptions.QueueName, messageId);
|
||||
return EnqueueResult.Succeeded(messageId);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<IReadOnlyList<IMessageLease<TMessage>>> LeaseAsync(
|
||||
LeaseRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var db = await _connectionFactory.GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
|
||||
await EnsureConsumerGroupAsync(db, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var consumer = _queueOptions.ConsumerName ?? $"{Environment.MachineName}-{Environment.ProcessId}";
|
||||
|
||||
StreamEntry[] entries;
|
||||
if (request.PendingOnly)
|
||||
{
|
||||
// Read from pending only (redeliveries)
|
||||
entries = await db.StreamReadGroupAsync(
|
||||
_queueOptions.QueueName,
|
||||
_queueOptions.ConsumerGroup,
|
||||
consumer,
|
||||
position: "0",
|
||||
count: request.BatchSize)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Read new messages
|
||||
entries = await db.StreamReadGroupAsync(
|
||||
_queueOptions.QueueName,
|
||||
_queueOptions.ConsumerGroup,
|
||||
consumer,
|
||||
position: ">",
|
||||
count: request.BatchSize)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (entries is null || entries.Length == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var leaseDuration = request.LeaseDuration ?? _queueOptions.DefaultLeaseDuration;
|
||||
var leases = new List<IMessageLease<TMessage>>(entries.Length);
|
||||
|
||||
foreach (var entry in entries)
|
||||
{
|
||||
var lease = TryMapLease(entry, consumer, now, leaseDuration, attemptOverride: null);
|
||||
if (lease is null)
|
||||
{
|
||||
await HandlePoisonEntryAsync(db, entry.Id).ConfigureAwait(false);
|
||||
continue;
|
||||
}
|
||||
|
||||
leases.Add(lease);
|
||||
}
|
||||
|
||||
return leases;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<IReadOnlyList<IMessageLease<TMessage>>> ClaimExpiredAsync(
|
||||
ClaimRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var db = await _connectionFactory.GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
|
||||
await EnsureConsumerGroupAsync(db, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var consumer = _queueOptions.ConsumerName ?? $"{Environment.MachineName}-{Environment.ProcessId}";
|
||||
|
||||
var pending = await db.StreamPendingMessagesAsync(
|
||||
_queueOptions.QueueName,
|
||||
_queueOptions.ConsumerGroup,
|
||||
request.BatchSize,
|
||||
RedisValue.Null,
|
||||
(long)request.MinIdleTime.TotalMilliseconds)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (pending is null || pending.Length == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var eligible = pending
|
||||
.Where(info => info.IdleTimeInMilliseconds >= request.MinIdleTime.TotalMilliseconds
|
||||
&& info.DeliveryCount >= request.MinDeliveryAttempts)
|
||||
.ToArray();
|
||||
|
||||
if (eligible.Length == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var messageIds = eligible.Select(info => (RedisValue)info.MessageId).ToArray();
|
||||
|
||||
var claimed = await db.StreamClaimAsync(
|
||||
_queueOptions.QueueName,
|
||||
_queueOptions.ConsumerGroup,
|
||||
consumer,
|
||||
0,
|
||||
messageIds)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (claimed is null || claimed.Length == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var leaseDuration = request.LeaseDuration ?? _queueOptions.DefaultLeaseDuration;
|
||||
var attemptLookup = eligible.ToDictionary(
|
||||
info => info.MessageId.IsNullOrEmpty ? string.Empty : info.MessageId.ToString(),
|
||||
info => (int)Math.Max(1, info.DeliveryCount),
|
||||
StringComparer.Ordinal);
|
||||
|
||||
var leases = new List<IMessageLease<TMessage>>(claimed.Length);
|
||||
foreach (var entry in claimed)
|
||||
{
|
||||
var entryId = entry.Id.ToString();
|
||||
attemptLookup.TryGetValue(entryId, out var attempt);
|
||||
|
||||
var lease = TryMapLease(entry, consumer, now, leaseDuration, attemptOverride: attempt);
|
||||
if (lease is null)
|
||||
{
|
||||
await HandlePoisonEntryAsync(db, entry.Id).ConfigureAwait(false);
|
||||
continue;
|
||||
}
|
||||
|
||||
leases.Add(lease);
|
||||
}
|
||||
|
||||
return leases;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<long> GetPendingCountAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var db = await _connectionFactory.GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
|
||||
var info = await db.StreamPendingAsync(_queueOptions.QueueName, _queueOptions.ConsumerGroup).ConfigureAwait(false);
|
||||
return info.PendingMessageCount;
|
||||
}
|
||||
|
||||
internal async ValueTask AcknowledgeAsync(ValkeyMessageLease<TMessage> lease, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!lease.TryBeginCompletion())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var db = await _connectionFactory.GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await db.StreamAcknowledgeAsync(
|
||||
_queueOptions.QueueName,
|
||||
_queueOptions.ConsumerGroup,
|
||||
[(RedisValue)lease.MessageId])
|
||||
.ConfigureAwait(false);
|
||||
|
||||
await db.StreamDeleteAsync(_queueOptions.QueueName, [(RedisValue)lease.MessageId]).ConfigureAwait(false);
|
||||
|
||||
_logger?.LogDebug("Acknowledged message {MessageId} from queue {Queue}", lease.MessageId, _queueOptions.QueueName);
|
||||
}
|
||||
|
||||
internal async ValueTask RenewLeaseAsync(ValkeyMessageLease<TMessage> lease, TimeSpan extension, CancellationToken cancellationToken)
|
||||
{
|
||||
var db = await _connectionFactory.GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await db.StreamClaimAsync(
|
||||
_queueOptions.QueueName,
|
||||
_queueOptions.ConsumerGroup,
|
||||
lease.Consumer,
|
||||
0,
|
||||
[(RedisValue)lease.MessageId])
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var expires = _timeProvider.GetUtcNow().Add(extension);
|
||||
lease.RefreshLease(expires);
|
||||
}
|
||||
|
||||
internal async ValueTask ReleaseAsync(
|
||||
ValkeyMessageLease<TMessage> lease,
|
||||
ReleaseDisposition disposition,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (disposition == ReleaseDisposition.Retry && lease.Attempt >= _queueOptions.MaxDeliveryAttempts)
|
||||
{
|
||||
await DeadLetterAsync(lease, $"max-delivery-attempts:{lease.Attempt}", cancellationToken).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!lease.TryBeginCompletion())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var db = await _connectionFactory.GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Acknowledge and delete the current entry
|
||||
await db.StreamAcknowledgeAsync(
|
||||
_queueOptions.QueueName,
|
||||
_queueOptions.ConsumerGroup,
|
||||
[(RedisValue)lease.MessageId])
|
||||
.ConfigureAwait(false);
|
||||
|
||||
await db.StreamDeleteAsync(_queueOptions.QueueName, [(RedisValue)lease.MessageId]).ConfigureAwait(false);
|
||||
|
||||
if (disposition == ReleaseDisposition.Retry)
|
||||
{
|
||||
lease.IncrementAttempt();
|
||||
|
||||
// Calculate backoff delay
|
||||
var backoff = CalculateBackoff(lease.Attempt);
|
||||
if (backoff > TimeSpan.Zero)
|
||||
{
|
||||
try
|
||||
{
|
||||
await Task.Delay(backoff, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (TaskCanceledException)
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Re-enqueue with incremented attempt
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var entries = BuildEntries(lease.Message, now, lease.Attempt, null);
|
||||
|
||||
await AddToStreamAsync(db, _queueOptions.QueueName, entries, _queueOptions.ApproximateMaxLength)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
_logger?.LogDebug("Retrying message {MessageId}, attempt {Attempt}", lease.MessageId, lease.Attempt);
|
||||
}
|
||||
}
|
||||
|
||||
internal async ValueTask DeadLetterAsync(ValkeyMessageLease<TMessage> lease, string reason, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!lease.TryBeginCompletion())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var db = await _connectionFactory.GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Acknowledge and delete from main queue
|
||||
await db.StreamAcknowledgeAsync(
|
||||
_queueOptions.QueueName,
|
||||
_queueOptions.ConsumerGroup,
|
||||
[(RedisValue)lease.MessageId])
|
||||
.ConfigureAwait(false);
|
||||
|
||||
await db.StreamDeleteAsync(_queueOptions.QueueName, [(RedisValue)lease.MessageId]).ConfigureAwait(false);
|
||||
|
||||
// Move to dead-letter queue if configured
|
||||
if (!string.IsNullOrWhiteSpace(_queueOptions.DeadLetterQueue))
|
||||
{
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var entries = BuildEntries(lease.Message, now, lease.Attempt, null);
|
||||
|
||||
await AddToStreamAsync(db, _queueOptions.DeadLetterQueue, entries, null).ConfigureAwait(false);
|
||||
|
||||
_logger?.LogWarning(
|
||||
"Dead-lettered message {MessageId} after {Attempt} attempt(s): {Reason}",
|
||||
lease.MessageId, lease.Attempt, reason);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger?.LogWarning(
|
||||
"Dropped message {MessageId} after {Attempt} attempt(s); dead-letter queue not configured. Reason: {Reason}",
|
||||
lease.MessageId, lease.Attempt, reason);
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_disposed = true;
|
||||
_groupInitLock.Dispose();
|
||||
}
|
||||
|
||||
private string BuildIdempotencyKey(string key) => $"{_transportOptions.IdempotencyKeyPrefix}{key}";
|
||||
|
||||
private TimeSpan CalculateBackoff(int attempt)
|
||||
{
|
||||
if (attempt <= 1)
|
||||
{
|
||||
return _queueOptions.RetryInitialBackoff;
|
||||
}
|
||||
|
||||
var initial = _queueOptions.RetryInitialBackoff;
|
||||
var max = _queueOptions.RetryMaxBackoff;
|
||||
var multiplier = _queueOptions.RetryBackoffMultiplier;
|
||||
|
||||
var scaledTicks = initial.Ticks * Math.Pow(multiplier, attempt - 1);
|
||||
var cappedTicks = Math.Min(max.Ticks, scaledTicks);
|
||||
|
||||
return TimeSpan.FromTicks((long)Math.Max(initial.Ticks, cappedTicks));
|
||||
}
|
||||
|
||||
private async Task EnsureConsumerGroupAsync(IDatabase database, CancellationToken cancellationToken)
|
||||
{
|
||||
if (_groupInitialized)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await _groupInitLock.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
if (_groupInitialized)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await database.StreamCreateConsumerGroupAsync(
|
||||
_queueOptions.QueueName,
|
||||
_queueOptions.ConsumerGroup,
|
||||
StreamPosition.Beginning,
|
||||
createStream: true)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch (RedisServerException ex) when (ex.Message.Contains("BUSYGROUP", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// Group already exists
|
||||
}
|
||||
|
||||
_groupInitialized = true;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_groupInitLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private NameValueEntry[] BuildEntries(TMessage message, DateTimeOffset enqueuedAt, int attempt, EnqueueOptions? options)
|
||||
{
|
||||
var headerCount = options?.Headers?.Count ?? 0;
|
||||
var entries = ArrayPool<NameValueEntry>.Shared.Rent(6 + headerCount);
|
||||
var index = 0;
|
||||
|
||||
entries[index++] = new NameValueEntry(Fields.Payload, JsonSerializer.Serialize(message, _jsonOptions));
|
||||
entries[index++] = new NameValueEntry(Fields.Attempt, attempt);
|
||||
entries[index++] = new NameValueEntry(Fields.EnqueuedAt, enqueuedAt.ToUnixTimeMilliseconds());
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(options?.TenantId))
|
||||
{
|
||||
entries[index++] = new NameValueEntry(Fields.TenantId, options.TenantId);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(options?.CorrelationId))
|
||||
{
|
||||
entries[index++] = new NameValueEntry(Fields.CorrelationId, options.CorrelationId);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(options?.IdempotencyKey))
|
||||
{
|
||||
entries[index++] = new NameValueEntry(Fields.IdempotencyKey, options.IdempotencyKey);
|
||||
}
|
||||
|
||||
if (options?.Headers is not null)
|
||||
{
|
||||
foreach (var kvp in options.Headers)
|
||||
{
|
||||
entries[index++] = new NameValueEntry(Fields.HeaderPrefix + kvp.Key, kvp.Value);
|
||||
}
|
||||
}
|
||||
|
||||
var result = entries.AsSpan(0, index).ToArray();
|
||||
ArrayPool<NameValueEntry>.Shared.Return(entries, clearArray: true);
|
||||
return result;
|
||||
}
|
||||
|
||||
private ValkeyMessageLease<TMessage>? TryMapLease(
|
||||
StreamEntry entry,
|
||||
string consumer,
|
||||
DateTimeOffset now,
|
||||
TimeSpan leaseDuration,
|
||||
int? attemptOverride)
|
||||
{
|
||||
if (entry.Values is null || entry.Values.Length == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
string? payload = null;
|
||||
string? tenantId = null;
|
||||
string? correlationId = null;
|
||||
long? enqueuedAtUnix = null;
|
||||
var attempt = attemptOverride ?? 1;
|
||||
Dictionary<string, string>? headers = null;
|
||||
|
||||
foreach (var field in entry.Values)
|
||||
{
|
||||
var name = field.Name.ToString();
|
||||
var value = field.Value;
|
||||
|
||||
if (name.Equals(Fields.Payload, StringComparison.Ordinal))
|
||||
{
|
||||
payload = value.ToString();
|
||||
}
|
||||
else if (name.Equals(Fields.TenantId, StringComparison.Ordinal))
|
||||
{
|
||||
tenantId = NormalizeOptional(value.ToString());
|
||||
}
|
||||
else if (name.Equals(Fields.CorrelationId, StringComparison.Ordinal))
|
||||
{
|
||||
correlationId = NormalizeOptional(value.ToString());
|
||||
}
|
||||
else if (name.Equals(Fields.EnqueuedAt, StringComparison.Ordinal))
|
||||
{
|
||||
if (long.TryParse(value.ToString(), out var unixMs))
|
||||
{
|
||||
enqueuedAtUnix = unixMs;
|
||||
}
|
||||
}
|
||||
else if (name.Equals(Fields.Attempt, StringComparison.Ordinal))
|
||||
{
|
||||
if (int.TryParse(value.ToString(), out var parsedAttempt))
|
||||
{
|
||||
attempt = attemptOverride.HasValue
|
||||
? Math.Max(attemptOverride.Value, parsedAttempt)
|
||||
: Math.Max(1, parsedAttempt);
|
||||
}
|
||||
}
|
||||
else if (name.StartsWith(Fields.HeaderPrefix, StringComparison.Ordinal))
|
||||
{
|
||||
headers ??= new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
var key = name[Fields.HeaderPrefix.Length..];
|
||||
headers[key] = value.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
if (payload is null || enqueuedAtUnix is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
TMessage message;
|
||||
try
|
||||
{
|
||||
message = JsonSerializer.Deserialize<TMessage>(payload, _jsonOptions)!;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var enqueuedAt = DateTimeOffset.FromUnixTimeMilliseconds(enqueuedAtUnix.Value);
|
||||
var leaseExpires = now.Add(leaseDuration);
|
||||
|
||||
IReadOnlyDictionary<string, string>? headersView = headers is null || headers.Count == 0
|
||||
? null
|
||||
: new ReadOnlyDictionary<string, string>(headers);
|
||||
|
||||
return new ValkeyMessageLease<TMessage>(
|
||||
this,
|
||||
entry.Id.ToString(),
|
||||
message,
|
||||
attempt,
|
||||
enqueuedAt,
|
||||
leaseExpires,
|
||||
consumer,
|
||||
tenantId,
|
||||
correlationId,
|
||||
headersView);
|
||||
}
|
||||
|
||||
private async Task HandlePoisonEntryAsync(IDatabase database, RedisValue entryId)
|
||||
{
|
||||
await database.StreamAcknowledgeAsync(
|
||||
_queueOptions.QueueName,
|
||||
_queueOptions.ConsumerGroup,
|
||||
[entryId])
|
||||
.ConfigureAwait(false);
|
||||
|
||||
await database.StreamDeleteAsync(_queueOptions.QueueName, [entryId]).ConfigureAwait(false);
|
||||
|
||||
_logger?.LogWarning("Removed poison entry {EntryId} from queue {Queue}", entryId, _queueOptions.QueueName);
|
||||
}
|
||||
|
||||
private async Task<string> AddToStreamAsync(
|
||||
IDatabase database,
|
||||
string stream,
|
||||
NameValueEntry[] entries,
|
||||
int? maxLength)
|
||||
{
|
||||
var capacity = 4 + (entries.Length * 2);
|
||||
var args = new List<object>(capacity) { (RedisKey)stream };
|
||||
|
||||
if (maxLength.HasValue)
|
||||
{
|
||||
args.Add("MAXLEN");
|
||||
args.Add("~");
|
||||
args.Add(maxLength.Value);
|
||||
}
|
||||
|
||||
args.Add("*");
|
||||
|
||||
foreach (var entry in entries)
|
||||
{
|
||||
args.Add((RedisValue)entry.Name);
|
||||
args.Add(entry.Value);
|
||||
}
|
||||
|
||||
var result = await database.ExecuteAsync("XADD", [.. args]).ConfigureAwait(false);
|
||||
return result!.ToString()!;
|
||||
}
|
||||
|
||||
private static string? NormalizeOptional(string? value)
|
||||
=> string.IsNullOrWhiteSpace(value) ? null : value;
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
namespace StellaOps.Messaging.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Transport-agnostic distributed cache interface.
|
||||
/// </summary>
|
||||
/// <typeparam name="TKey">The key type.</typeparam>
|
||||
/// <typeparam name="TValue">The value type.</typeparam>
|
||||
public interface IDistributedCache<TKey, TValue>
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the provider name for diagnostics (e.g., "valkey", "postgres").
|
||||
/// </summary>
|
||||
string ProviderName { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value from the cache.
|
||||
/// </summary>
|
||||
/// <param name="key">The cache key.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The cache result.</returns>
|
||||
ValueTask<CacheResult<TValue>> GetAsync(TKey key, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Sets a value in the cache.
|
||||
/// </summary>
|
||||
/// <param name="key">The cache key.</param>
|
||||
/// <param name="value">The value to cache.</param>
|
||||
/// <param name="options">Optional cache entry options.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
ValueTask SetAsync(TKey key, TValue value, CacheEntryOptions? options = null, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Removes a value from the cache.
|
||||
/// </summary>
|
||||
/// <param name="key">The cache key.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>True if the key existed and was removed.</returns>
|
||||
ValueTask<bool> InvalidateAsync(TKey key, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Removes values matching a pattern from the cache.
|
||||
/// </summary>
|
||||
/// <param name="pattern">The key pattern (supports wildcards).</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The number of keys invalidated.</returns>
|
||||
ValueTask<long> InvalidateByPatternAsync(string pattern, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value in the cache, using a factory function if the value is not present.
|
||||
/// </summary>
|
||||
/// <param name="key">The cache key.</param>
|
||||
/// <param name="factory">Factory function to create the value if not cached.</param>
|
||||
/// <param name="options">Optional cache entry options.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The cached or newly created value.</returns>
|
||||
ValueTask<TValue> GetOrSetAsync(
|
||||
TKey key,
|
||||
Func<CancellationToken, ValueTask<TValue>> factory,
|
||||
CacheEntryOptions? options = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Simple string-keyed distributed cache interface.
|
||||
/// </summary>
|
||||
/// <typeparam name="TValue">The value type.</typeparam>
|
||||
public interface IDistributedCache<TValue> : IDistributedCache<string, TValue>
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
namespace StellaOps.Messaging.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a leased message from a queue.
|
||||
/// The lease provides exclusive access to process the message.
|
||||
/// </summary>
|
||||
/// <typeparam name="TMessage">The message type.</typeparam>
|
||||
public interface IMessageLease<out TMessage> : IAsyncDisposable where TMessage : class
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the unique message identifier.
|
||||
/// </summary>
|
||||
string MessageId { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the message payload.
|
||||
/// </summary>
|
||||
TMessage Message { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the delivery attempt number (1-based).
|
||||
/// </summary>
|
||||
int Attempt { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the timestamp when the message was enqueued.
|
||||
/// </summary>
|
||||
DateTimeOffset EnqueuedAt { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the timestamp when the lease expires.
|
||||
/// </summary>
|
||||
DateTimeOffset LeaseExpiresAt { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the consumer name that owns this lease.
|
||||
/// </summary>
|
||||
string Consumer { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the tenant identifier, if present.
|
||||
/// </summary>
|
||||
string? TenantId { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the correlation identifier for tracing, if present.
|
||||
/// </summary>
|
||||
string? CorrelationId { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Acknowledges successful processing of the message.
|
||||
/// The message is removed from the queue.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
ValueTask AcknowledgeAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Extends the lease duration.
|
||||
/// </summary>
|
||||
/// <param name="extension">The time to extend the lease by.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
ValueTask RenewAsync(TimeSpan extension, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Releases the lease with the specified disposition.
|
||||
/// </summary>
|
||||
/// <param name="disposition">How to handle the message after release.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
ValueTask ReleaseAsync(ReleaseDisposition disposition, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Moves the message to the dead-letter queue.
|
||||
/// </summary>
|
||||
/// <param name="reason">The reason for dead-lettering.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
ValueTask DeadLetterAsync(string reason, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Specifies how to handle a message when releasing a lease.
|
||||
/// </summary>
|
||||
public enum ReleaseDisposition
|
||||
{
|
||||
/// <summary>
|
||||
/// Retry the message (make it available for redelivery).
|
||||
/// </summary>
|
||||
Retry,
|
||||
|
||||
/// <summary>
|
||||
/// Delay the message before making it available again.
|
||||
/// </summary>
|
||||
Delay,
|
||||
|
||||
/// <summary>
|
||||
/// Abandon the message (do not retry, but don't dead-letter either).
|
||||
/// Implementation may vary by transport.
|
||||
/// </summary>
|
||||
Abandon
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
namespace StellaOps.Messaging.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Transport-agnostic message queue interface.
|
||||
/// Consumers depend only on this abstraction without knowing which transport is used.
|
||||
/// </summary>
|
||||
/// <typeparam name="TMessage">The message type.</typeparam>
|
||||
public interface IMessageQueue<TMessage> where TMessage : class
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the provider name for diagnostics (e.g., "valkey", "nats", "postgres").
|
||||
/// </summary>
|
||||
string ProviderName { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the queue/stream name.
|
||||
/// </summary>
|
||||
string QueueName { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Enqueues a message to the queue.
|
||||
/// </summary>
|
||||
/// <param name="message">The message to enqueue.</param>
|
||||
/// <param name="options">Optional enqueue options.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The result of the enqueue operation.</returns>
|
||||
ValueTask<EnqueueResult> EnqueueAsync(
|
||||
TMessage message,
|
||||
EnqueueOptions? options = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Leases messages from the queue for processing.
|
||||
/// Messages remain invisible to other consumers until acknowledged or lease expires.
|
||||
/// </summary>
|
||||
/// <param name="request">The lease request parameters.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>A list of message leases.</returns>
|
||||
ValueTask<IReadOnlyList<IMessageLease<TMessage>>> LeaseAsync(
|
||||
LeaseRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Claims expired leases from other consumers (pending entry list recovery).
|
||||
/// </summary>
|
||||
/// <param name="request">The claim request parameters.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>A list of claimed message leases.</returns>
|
||||
ValueTask<IReadOnlyList<IMessageLease<TMessage>>> ClaimExpiredAsync(
|
||||
ClaimRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the approximate number of pending messages in the queue.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The approximate pending message count.</returns>
|
||||
ValueTask<long> GetPendingCountAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
namespace StellaOps.Messaging.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating message queue instances.
|
||||
/// </summary>
|
||||
public interface IMessageQueueFactory
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the provider name for this factory.
|
||||
/// </summary>
|
||||
string ProviderName { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a message queue for the specified message type and options.
|
||||
/// </summary>
|
||||
/// <typeparam name="TMessage">The message type.</typeparam>
|
||||
/// <param name="options">The queue options.</param>
|
||||
/// <returns>A configured message queue instance.</returns>
|
||||
IMessageQueue<TMessage> Create<TMessage>(MessageQueueOptions options) where TMessage : class;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating distributed cache instances.
|
||||
/// </summary>
|
||||
public interface IDistributedCacheFactory
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the provider name for this factory.
|
||||
/// </summary>
|
||||
string ProviderName { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a distributed cache for the specified key and value types.
|
||||
/// </summary>
|
||||
/// <typeparam name="TKey">The key type.</typeparam>
|
||||
/// <typeparam name="TValue">The value type.</typeparam>
|
||||
/// <param name="options">The cache options.</param>
|
||||
/// <returns>A configured distributed cache instance.</returns>
|
||||
IDistributedCache<TKey, TValue> Create<TKey, TValue>(CacheOptions options);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a string-keyed distributed cache.
|
||||
/// </summary>
|
||||
/// <typeparam name="TValue">The value type.</typeparam>
|
||||
/// <param name="options">The cache options.</param>
|
||||
/// <returns>A configured distributed cache instance.</returns>
|
||||
IDistributedCache<TValue> Create<TValue>(CacheOptions options);
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
using System.Reflection;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Messaging.Abstractions;
|
||||
using StellaOps.Messaging.Plugins;
|
||||
|
||||
namespace StellaOps.Messaging.DependencyInjection;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for registering messaging services.
|
||||
/// </summary>
|
||||
public static class MessagingServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds messaging services with plugin-based transport discovery.
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <param name="configuration">The configuration.</param>
|
||||
/// <param name="configure">Optional configuration callback.</param>
|
||||
/// <returns>The service collection.</returns>
|
||||
public static IServiceCollection AddMessagingPlugins(
|
||||
this IServiceCollection services,
|
||||
IConfiguration configuration,
|
||||
Action<MessagingPluginOptions>? configure = null)
|
||||
{
|
||||
var options = new MessagingPluginOptions();
|
||||
configure?.Invoke(options);
|
||||
|
||||
services.AddSingleton<MessagingPluginLoader>();
|
||||
|
||||
var loader = new MessagingPluginLoader();
|
||||
var plugins = loader.LoadFromDirectory(options.PluginDirectory, options.SearchPattern);
|
||||
|
||||
// Also load from assemblies in the current domain that might contain plugins
|
||||
var domainAssemblies = AppDomain.CurrentDomain.GetAssemblies()
|
||||
.Where(a => a.GetName().Name?.StartsWith("StellaOps.Messaging.Transport.") == true);
|
||||
var domainPlugins = loader.LoadFromAssemblies(domainAssemblies);
|
||||
|
||||
var allPlugins = plugins.Concat(domainPlugins)
|
||||
.GroupBy(p => p.Name, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(g => g.First())
|
||||
.ToList();
|
||||
|
||||
var registered = loader.RegisterConfiguredTransport(
|
||||
allPlugins,
|
||||
services,
|
||||
configuration,
|
||||
options.ConfigurationSection);
|
||||
|
||||
if (!registered && options.RequireTransport)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"No messaging transport configured. Set '{options.ConfigurationSection}:transport' to one of: {string.Join(", ", allPlugins.Select(p => p.Name))}");
|
||||
}
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds messaging services with a specific transport plugin.
|
||||
/// </summary>
|
||||
/// <typeparam name="TPlugin">The transport plugin type.</typeparam>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <param name="configuration">The configuration.</param>
|
||||
/// <param name="configSection">The configuration section for the transport.</param>
|
||||
/// <returns>The service collection.</returns>
|
||||
public static IServiceCollection AddMessagingTransport<TPlugin>(
|
||||
this IServiceCollection services,
|
||||
IConfiguration configuration,
|
||||
string configSection = "messaging")
|
||||
where TPlugin : IMessagingTransportPlugin, new()
|
||||
{
|
||||
var plugin = new TPlugin();
|
||||
var context = new MessagingTransportRegistrationContext(
|
||||
services,
|
||||
configuration,
|
||||
$"{configSection}:{plugin.Name}");
|
||||
|
||||
plugin.Register(context);
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a message queue for a specific message type.
|
||||
/// </summary>
|
||||
/// <typeparam name="TMessage">The message type.</typeparam>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <param name="options">The queue options.</param>
|
||||
/// <returns>The service collection.</returns>
|
||||
public static IServiceCollection AddMessageQueue<TMessage>(
|
||||
this IServiceCollection services,
|
||||
MessageQueueOptions options)
|
||||
where TMessage : class
|
||||
{
|
||||
services.AddSingleton(sp =>
|
||||
{
|
||||
var factory = sp.GetRequiredService<IMessageQueueFactory>();
|
||||
return factory.Create<TMessage>(options);
|
||||
});
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a distributed cache for a specific value type.
|
||||
/// </summary>
|
||||
/// <typeparam name="TValue">The value type.</typeparam>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <param name="options">The cache options.</param>
|
||||
/// <returns>The service collection.</returns>
|
||||
public static IServiceCollection AddDistributedCache<TValue>(
|
||||
this IServiceCollection services,
|
||||
CacheOptions options)
|
||||
{
|
||||
services.AddSingleton(sp =>
|
||||
{
|
||||
var factory = sp.GetRequiredService<IDistributedCacheFactory>();
|
||||
return factory.Create<TValue>(options);
|
||||
});
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
59
src/__Libraries/StellaOps.Messaging/Options/CacheOptions.cs
Normal file
59
src/__Libraries/StellaOps.Messaging/Options/CacheOptions.cs
Normal file
@@ -0,0 +1,59 @@
|
||||
namespace StellaOps.Messaging;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for a distributed cache.
|
||||
/// </summary>
|
||||
public class CacheOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the key prefix for all cache entries.
|
||||
/// </summary>
|
||||
public string? KeyPrefix { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the default time-to-live for cache entries.
|
||||
/// </summary>
|
||||
public TimeSpan? DefaultTtl { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether to use sliding expiration.
|
||||
/// If true, TTL is reset on each access.
|
||||
/// </summary>
|
||||
public bool SlidingExpiration { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Options for individual cache entries.
|
||||
/// </summary>
|
||||
public class CacheEntryOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the absolute expiration time.
|
||||
/// </summary>
|
||||
public DateTimeOffset? AbsoluteExpiration { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the time-to-live relative to now.
|
||||
/// </summary>
|
||||
public TimeSpan? TimeToLive { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether to use sliding expiration for this entry.
|
||||
/// </summary>
|
||||
public bool? SlidingExpiration { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates options with a specific TTL.
|
||||
/// </summary>
|
||||
public static CacheEntryOptions WithTtl(TimeSpan ttl) => new() { TimeToLive = ttl };
|
||||
|
||||
/// <summary>
|
||||
/// Creates options with absolute expiration.
|
||||
/// </summary>
|
||||
public static CacheEntryOptions ExpiresAt(DateTimeOffset expiration) => new() { AbsoluteExpiration = expiration };
|
||||
|
||||
/// <summary>
|
||||
/// Creates options with sliding expiration.
|
||||
/// </summary>
|
||||
public static CacheEntryOptions Sliding(TimeSpan slidingWindow) => new() { TimeToLive = slidingWindow, SlidingExpiration = true };
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace StellaOps.Messaging;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for a message queue.
|
||||
/// </summary>
|
||||
public class MessageQueueOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the queue/stream name.
|
||||
/// </summary>
|
||||
[Required]
|
||||
public string QueueName { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the consumer group name.
|
||||
/// </summary>
|
||||
[Required]
|
||||
public string ConsumerGroup { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the consumer name within the group.
|
||||
/// Defaults to machine name + process ID.
|
||||
/// </summary>
|
||||
public string? ConsumerName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the dead-letter queue name.
|
||||
/// If null, dead-lettering may not be supported.
|
||||
/// </summary>
|
||||
public string? DeadLetterQueue { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the default lease duration for messages.
|
||||
/// </summary>
|
||||
public TimeSpan DefaultLeaseDuration { get; set; } = TimeSpan.FromMinutes(5);
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the maximum number of delivery attempts before dead-lettering.
|
||||
/// </summary>
|
||||
public int MaxDeliveryAttempts { get; set; } = 5;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the idempotency window for duplicate detection.
|
||||
/// </summary>
|
||||
public TimeSpan IdempotencyWindow { get; set; } = TimeSpan.FromHours(24);
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the approximate maximum queue length (stream trimming).
|
||||
/// Null means no limit.
|
||||
/// </summary>
|
||||
public int? ApproximateMaxLength { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the initial backoff for retry delays.
|
||||
/// </summary>
|
||||
public TimeSpan RetryInitialBackoff { get; set; } = TimeSpan.FromSeconds(1);
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the maximum backoff for retry delays.
|
||||
/// </summary>
|
||||
public TimeSpan RetryMaxBackoff { get; set; } = TimeSpan.FromMinutes(5);
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the backoff multiplier for exponential backoff.
|
||||
/// </summary>
|
||||
public double RetryBackoffMultiplier { get; set; } = 2.0;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
namespace StellaOps.Messaging;
|
||||
|
||||
/// <summary>
|
||||
/// Options for configuring messaging plugin discovery and loading.
|
||||
/// </summary>
|
||||
public class MessagingPluginOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the directory to search for transport plugins.
|
||||
/// </summary>
|
||||
public string PluginDirectory { get; set; } = "plugins/messaging";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the search pattern for plugin assemblies.
|
||||
/// </summary>
|
||||
public string SearchPattern { get; set; } = "StellaOps.Messaging.Transport.*.dll";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the configuration section path for messaging options.
|
||||
/// </summary>
|
||||
public string ConfigurationSection { get; set; } = "messaging";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether to throw if no transport is configured.
|
||||
/// </summary>
|
||||
public bool RequireTransport { get; set; } = true;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Plugin;
|
||||
|
||||
namespace StellaOps.Messaging.Plugins;
|
||||
|
||||
/// <summary>
|
||||
/// Plugin contract for messaging transports.
|
||||
/// Transport plugins implement this interface to provide IMessageQueue and IDistributedCache implementations.
|
||||
/// </summary>
|
||||
public interface IMessagingTransportPlugin : IAvailabilityPlugin
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the unique transport name (e.g., "valkey", "nats", "postgres", "inmemory").
|
||||
/// </summary>
|
||||
new string Name { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Registers transport services into the DI container.
|
||||
/// </summary>
|
||||
/// <param name="context">The registration context.</param>
|
||||
void Register(MessagingTransportRegistrationContext context);
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
using System.Reflection;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Plugin;
|
||||
using StellaOps.Plugin.Hosting;
|
||||
|
||||
namespace StellaOps.Messaging.Plugins;
|
||||
|
||||
/// <summary>
|
||||
/// Loads and registers messaging transport plugins.
|
||||
/// </summary>
|
||||
public sealed class MessagingPluginLoader
|
||||
{
|
||||
private readonly ILogger<MessagingPluginLoader>? _logger;
|
||||
|
||||
public MessagingPluginLoader(ILogger<MessagingPluginLoader>? logger = null)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Discovers and loads messaging transport plugins from the specified directory.
|
||||
/// </summary>
|
||||
public IReadOnlyList<IMessagingTransportPlugin> LoadFromDirectory(
|
||||
string pluginDirectory,
|
||||
string searchPattern = "StellaOps.Messaging.Transport.*.dll")
|
||||
{
|
||||
if (!Directory.Exists(pluginDirectory))
|
||||
{
|
||||
_logger?.LogWarning("Plugin directory does not exist: {Directory}", pluginDirectory);
|
||||
return [];
|
||||
}
|
||||
|
||||
var options = new PluginHostOptions
|
||||
{
|
||||
PluginsDirectory = pluginDirectory,
|
||||
EnsureDirectoryExists = false,
|
||||
RecursiveSearch = false
|
||||
};
|
||||
options.SearchPatterns.Add(searchPattern);
|
||||
|
||||
var result = PluginHost.LoadPlugins(options);
|
||||
var plugins = new List<IMessagingTransportPlugin>();
|
||||
|
||||
foreach (var pluginAssembly in result.Plugins)
|
||||
{
|
||||
var transportPlugins = PluginLoader.LoadPlugins<IMessagingTransportPlugin>(new[] { pluginAssembly.Assembly });
|
||||
plugins.AddRange(transportPlugins);
|
||||
|
||||
foreach (var plugin in transportPlugins)
|
||||
{
|
||||
_logger?.LogDebug("Loaded messaging transport plugin: {Name} from {Assembly}",
|
||||
plugin.Name, pluginAssembly.Assembly.GetName().Name);
|
||||
}
|
||||
}
|
||||
|
||||
return plugins;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Loads messaging transport plugins from the specified assemblies.
|
||||
/// </summary>
|
||||
public IReadOnlyList<IMessagingTransportPlugin> LoadFromAssemblies(IEnumerable<Assembly> assemblies)
|
||||
{
|
||||
return PluginLoader.LoadPlugins<IMessagingTransportPlugin>(assemblies);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Finds and registers the configured transport plugin.
|
||||
/// </summary>
|
||||
/// <param name="plugins">Available plugins.</param>
|
||||
/// <param name="services">Service collection.</param>
|
||||
/// <param name="configuration">Configuration.</param>
|
||||
/// <param name="configSectionPath">Configuration section path (default: "messaging").</param>
|
||||
/// <returns>True if a plugin was registered.</returns>
|
||||
public bool RegisterConfiguredTransport(
|
||||
IReadOnlyList<IMessagingTransportPlugin> plugins,
|
||||
IServiceCollection services,
|
||||
IConfiguration configuration,
|
||||
string configSectionPath = "messaging")
|
||||
{
|
||||
var messagingSection = configuration.GetSection(configSectionPath);
|
||||
var transportName = messagingSection.GetValue<string>("transport");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(transportName))
|
||||
{
|
||||
_logger?.LogWarning("No messaging transport configured at {Path}:transport", configSectionPath);
|
||||
return false;
|
||||
}
|
||||
|
||||
var plugin = plugins.FirstOrDefault(p =>
|
||||
string.Equals(p.Name, transportName, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (plugin is null)
|
||||
{
|
||||
_logger?.LogError("Messaging transport plugin '{Transport}' not found. Available: {Available}",
|
||||
transportName, string.Join(", ", plugins.Select(p => p.Name)));
|
||||
return false;
|
||||
}
|
||||
|
||||
var transportConfigSection = $"{configSectionPath}:{transportName}";
|
||||
var context = new MessagingTransportRegistrationContext(
|
||||
services,
|
||||
configuration,
|
||||
transportConfigSection);
|
||||
|
||||
plugin.Register(context);
|
||||
|
||||
_logger?.LogInformation("Registered messaging transport: {Transport}", transportName);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Messaging.Plugins;
|
||||
|
||||
/// <summary>
|
||||
/// Context provided to transport plugins during registration.
|
||||
/// </summary>
|
||||
public sealed class MessagingTransportRegistrationContext
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a new registration context.
|
||||
/// </summary>
|
||||
public MessagingTransportRegistrationContext(
|
||||
IServiceCollection services,
|
||||
IConfiguration configuration,
|
||||
string configurationSection,
|
||||
ILoggerFactory? loggerFactory = null)
|
||||
{
|
||||
Services = services;
|
||||
Configuration = configuration;
|
||||
ConfigurationSection = configurationSection;
|
||||
LoggerFactory = loggerFactory;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the service collection for registering services.
|
||||
/// </summary>
|
||||
public IServiceCollection Services { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the configuration root.
|
||||
/// </summary>
|
||||
public IConfiguration Configuration { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the configuration section path for this transport (e.g., "messaging:valkey").
|
||||
/// </summary>
|
||||
public string ConfigurationSection { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the logger factory for creating loggers during registration.
|
||||
/// </summary>
|
||||
public ILoggerFactory? LoggerFactory { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the configuration section for this transport.
|
||||
/// </summary>
|
||||
public IConfigurationSection GetTransportConfiguration() =>
|
||||
Configuration.GetSection(ConfigurationSection);
|
||||
}
|
||||
63
src/__Libraries/StellaOps.Messaging/Results/CacheResult.cs
Normal file
63
src/__Libraries/StellaOps.Messaging/Results/CacheResult.cs
Normal file
@@ -0,0 +1,63 @@
|
||||
namespace StellaOps.Messaging;
|
||||
|
||||
/// <summary>
|
||||
/// Result of a cache get operation.
|
||||
/// </summary>
|
||||
/// <typeparam name="TValue">The value type.</typeparam>
|
||||
public readonly struct CacheResult<TValue>
|
||||
{
|
||||
private readonly TValue? _value;
|
||||
|
||||
private CacheResult(TValue? value, bool hasValue)
|
||||
{
|
||||
_value = value;
|
||||
HasValue = hasValue;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether a value was found in the cache.
|
||||
/// </summary>
|
||||
public bool HasValue { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the cached value.
|
||||
/// </summary>
|
||||
/// <exception cref="InvalidOperationException">Thrown when no value is present.</exception>
|
||||
public TValue Value => HasValue
|
||||
? _value!
|
||||
: throw new InvalidOperationException("No value present in cache result.");
|
||||
|
||||
/// <summary>
|
||||
/// Gets the value or a default.
|
||||
/// </summary>
|
||||
/// <param name="defaultValue">The default value to return if not cached.</param>
|
||||
/// <returns>The cached value or the default.</returns>
|
||||
public TValue GetValueOrDefault(TValue defaultValue = default!) =>
|
||||
HasValue ? _value! : defaultValue;
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to get the value.
|
||||
/// </summary>
|
||||
/// <param name="value">The cached value, if present.</param>
|
||||
/// <returns>True if a value was present.</returns>
|
||||
public bool TryGetValue(out TValue? value)
|
||||
{
|
||||
value = _value;
|
||||
return HasValue;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a result with a value.
|
||||
/// </summary>
|
||||
public static CacheResult<TValue> Found(TValue value) => new(value, true);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a result indicating cache miss.
|
||||
/// </summary>
|
||||
public static CacheResult<TValue> Miss() => new(default, false);
|
||||
|
||||
/// <summary>
|
||||
/// Implicitly converts a value to a found result.
|
||||
/// </summary>
|
||||
public static implicit operator CacheResult<TValue>(TValue value) => Found(value);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
namespace StellaOps.Messaging;
|
||||
|
||||
/// <summary>
|
||||
/// Options for enqueue operations.
|
||||
/// </summary>
|
||||
public class EnqueueOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the idempotency key for duplicate detection.
|
||||
/// If null, no duplicate detection is performed.
|
||||
/// </summary>
|
||||
public string? IdempotencyKey { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the correlation ID for tracing.
|
||||
/// </summary>
|
||||
public string? CorrelationId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the tenant ID for multi-tenant scenarios.
|
||||
/// </summary>
|
||||
public string? TenantId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the message priority (if supported by transport).
|
||||
/// Higher values indicate higher priority.
|
||||
/// </summary>
|
||||
public int Priority { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets when the message should become visible (delayed delivery).
|
||||
/// </summary>
|
||||
public DateTimeOffset? VisibleAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets custom headers/metadata for the message.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, string>? Headers { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates options with an idempotency key.
|
||||
/// </summary>
|
||||
public static EnqueueOptions WithIdempotencyKey(string key) => new() { IdempotencyKey = key };
|
||||
|
||||
/// <summary>
|
||||
/// Creates options for delayed delivery.
|
||||
/// </summary>
|
||||
public static EnqueueOptions DelayedUntil(DateTimeOffset visibleAt) => new() { VisibleAt = visibleAt };
|
||||
|
||||
/// <summary>
|
||||
/// Creates options with correlation ID.
|
||||
/// </summary>
|
||||
public static EnqueueOptions WithCorrelation(string correlationId) => new() { CorrelationId = correlationId };
|
||||
}
|
||||
45
src/__Libraries/StellaOps.Messaging/Results/EnqueueResult.cs
Normal file
45
src/__Libraries/StellaOps.Messaging/Results/EnqueueResult.cs
Normal file
@@ -0,0 +1,45 @@
|
||||
namespace StellaOps.Messaging;
|
||||
|
||||
/// <summary>
|
||||
/// Result of an enqueue operation.
|
||||
/// </summary>
|
||||
public readonly struct EnqueueResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the message ID assigned by the queue.
|
||||
/// </summary>
|
||||
public string MessageId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether the message was enqueued successfully.
|
||||
/// </summary>
|
||||
public bool Success { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether this was a duplicate message (idempotency).
|
||||
/// </summary>
|
||||
public bool WasDuplicate { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the error message if the operation failed.
|
||||
/// </summary>
|
||||
public string? Error { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a successful result.
|
||||
/// </summary>
|
||||
public static EnqueueResult Succeeded(string messageId, bool wasDuplicate = false) =>
|
||||
new() { MessageId = messageId, Success = true, WasDuplicate = wasDuplicate };
|
||||
|
||||
/// <summary>
|
||||
/// Creates a failed result.
|
||||
/// </summary>
|
||||
public static EnqueueResult Failed(string error) =>
|
||||
new() { Success = false, Error = error, MessageId = string.Empty };
|
||||
|
||||
/// <summary>
|
||||
/// Creates a duplicate result.
|
||||
/// </summary>
|
||||
public static EnqueueResult Duplicate(string messageId) =>
|
||||
new() { MessageId = messageId, Success = true, WasDuplicate = true };
|
||||
}
|
||||
58
src/__Libraries/StellaOps.Messaging/Results/LeaseRequest.cs
Normal file
58
src/__Libraries/StellaOps.Messaging/Results/LeaseRequest.cs
Normal file
@@ -0,0 +1,58 @@
|
||||
namespace StellaOps.Messaging;
|
||||
|
||||
/// <summary>
|
||||
/// Request parameters for leasing messages.
|
||||
/// </summary>
|
||||
public class LeaseRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the maximum number of messages to lease.
|
||||
/// </summary>
|
||||
public int BatchSize { get; set; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the lease duration for the messages.
|
||||
/// If null, uses the queue's default lease duration.
|
||||
/// </summary>
|
||||
public TimeSpan? LeaseDuration { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the maximum time to wait for messages if none are available.
|
||||
/// Zero means don't wait (poll). Null means use transport default.
|
||||
/// </summary>
|
||||
public TimeSpan? WaitTimeout { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether to only return messages from the pending entry list (redeliveries).
|
||||
/// </summary>
|
||||
public bool PendingOnly { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request parameters for claiming expired leases.
|
||||
/// </summary>
|
||||
public class ClaimRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the maximum number of messages to claim.
|
||||
/// </summary>
|
||||
public int BatchSize { get; set; } = 10;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the minimum idle time for a message to be claimed.
|
||||
/// Messages must have been idle (not processed) for at least this duration.
|
||||
/// </summary>
|
||||
public TimeSpan MinIdleTime { get; set; } = TimeSpan.FromMinutes(5);
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the new lease duration for claimed messages.
|
||||
/// If null, uses the queue's default lease duration.
|
||||
/// </summary>
|
||||
public TimeSpan? LeaseDuration { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the minimum number of delivery attempts for messages to claim.
|
||||
/// This helps avoid claiming messages that are still being processed for the first time.
|
||||
/// </summary>
|
||||
public int MinDeliveryAttempts { get; set; } = 1;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||
<RootNamespace>StellaOps.Messaging</RootNamespace>
|
||||
<AssemblyName>StellaOps.Messaging</AssemblyName>
|
||||
<Description>Transport-agnostic messaging abstractions for StellaOps (queues, caching, pub/sub)</Description>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../StellaOps.Plugin/StellaOps.Plugin.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,38 +1,38 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.DependencyInjection;
|
||||
using StellaOps.Plugin.Hosting;
|
||||
using StellaOps.Plugin.Internal;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace StellaOps.Plugin.DependencyInjection;
|
||||
|
||||
public static class PluginDependencyInjectionExtensions
|
||||
{
|
||||
public static IServiceCollection RegisterPluginRoutines(
|
||||
this IServiceCollection services,
|
||||
IConfiguration configuration,
|
||||
PluginHostOptions options,
|
||||
ILogger? logger = null)
|
||||
{
|
||||
if (services == null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(services));
|
||||
}
|
||||
|
||||
if (configuration == null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(configuration));
|
||||
}
|
||||
|
||||
if (options == null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(options));
|
||||
}
|
||||
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.DependencyInjection;
|
||||
using StellaOps.Plugin.Hosting;
|
||||
using StellaOps.Plugin.Internal;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace StellaOps.Plugin.DependencyInjection;
|
||||
|
||||
public static class PluginDependencyInjectionExtensions
|
||||
{
|
||||
public static IServiceCollection RegisterPluginRoutines(
|
||||
this IServiceCollection services,
|
||||
IConfiguration configuration,
|
||||
PluginHostOptions options,
|
||||
ILogger? logger = null)
|
||||
{
|
||||
if (services == null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(services));
|
||||
}
|
||||
|
||||
if (configuration == null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(configuration));
|
||||
}
|
||||
|
||||
if (options == null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(options));
|
||||
}
|
||||
|
||||
var loadResult = PluginHost.LoadPlugins(options, logger);
|
||||
|
||||
foreach (var plugin in loadResult.Plugins)
|
||||
@@ -44,50 +44,50 @@ public static class PluginDependencyInjectionExtensions
|
||||
logger?.LogDebug(
|
||||
"Registering DI routine '{RoutineType}' from plugin '{PluginAssembly}'.",
|
||||
routine.GetType().FullName,
|
||||
plugin.Assembly.FullName);
|
||||
|
||||
routine.Register(services, configuration);
|
||||
}
|
||||
}
|
||||
|
||||
if (loadResult.MissingOrderedPlugins.Count > 0)
|
||||
{
|
||||
logger?.LogWarning(
|
||||
"Some ordered plugins were not found: {Missing}",
|
||||
string.Join(", ", loadResult.MissingOrderedPlugins));
|
||||
}
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
private static IEnumerable<IDependencyInjectionRoutine> CreateRoutines(System.Reflection.Assembly assembly)
|
||||
{
|
||||
foreach (var type in assembly.GetLoadableTypes())
|
||||
{
|
||||
if (type is null || type.IsAbstract || type.IsInterface)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!typeof(IDependencyInjectionRoutine).IsAssignableFrom(type))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
object? instance;
|
||||
try
|
||||
{
|
||||
instance = Activator.CreateInstance(type);
|
||||
}
|
||||
catch
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (instance is IDependencyInjectionRoutine routine)
|
||||
{
|
||||
yield return routine;
|
||||
}
|
||||
}
|
||||
}
|
||||
plugin.Assembly.FullName);
|
||||
|
||||
routine.Register(services, configuration);
|
||||
}
|
||||
}
|
||||
|
||||
if (loadResult.MissingOrderedPlugins.Count > 0)
|
||||
{
|
||||
logger?.LogWarning(
|
||||
"Some ordered plugins were not found: {Missing}",
|
||||
string.Join(", ", loadResult.MissingOrderedPlugins));
|
||||
}
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
private static IEnumerable<IDependencyInjectionRoutine> CreateRoutines(System.Reflection.Assembly assembly)
|
||||
{
|
||||
foreach (var type in assembly.GetLoadableTypes())
|
||||
{
|
||||
if (type is null || type.IsAbstract || type.IsInterface)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!typeof(IDependencyInjectionRoutine).IsAssignableFrom(type))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
object? instance;
|
||||
try
|
||||
{
|
||||
instance = Activator.CreateInstance(type);
|
||||
}
|
||||
catch
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (instance is IDependencyInjectionRoutine routine)
|
||||
{
|
||||
yield return routine;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,169 +1,169 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.DependencyInjection;
|
||||
using StellaOps.Plugin.Internal;
|
||||
|
||||
namespace StellaOps.Plugin.DependencyInjection;
|
||||
|
||||
public static class PluginServiceRegistration
|
||||
{
|
||||
public static void RegisterAssemblyMetadata(IServiceCollection services, Assembly assembly, ILogger? logger)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(assembly);
|
||||
|
||||
foreach (var implementationType in assembly.GetLoadableTypes())
|
||||
{
|
||||
if (implementationType is null || !implementationType.IsClass || implementationType.IsAbstract)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var attributes = implementationType.GetCustomAttributes<ServiceBindingAttribute>(inherit: false);
|
||||
if (!attributes.Any())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var attribute in attributes)
|
||||
{
|
||||
try
|
||||
{
|
||||
ApplyBinding(services, implementationType, attribute, logger);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger?.LogWarning(
|
||||
ex,
|
||||
"Failed to register service binding for implementation '{Implementation}' declared in assembly '{Assembly}'.",
|
||||
implementationType.FullName ?? implementationType.Name,
|
||||
assembly.FullName ?? assembly.GetName().Name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void ApplyBinding(
|
||||
IServiceCollection services,
|
||||
Type implementationType,
|
||||
ServiceBindingAttribute attribute,
|
||||
ILogger? logger)
|
||||
{
|
||||
var serviceType = attribute.ServiceType ?? implementationType;
|
||||
|
||||
if (!IsValidBinding(serviceType, implementationType))
|
||||
{
|
||||
logger?.LogWarning(
|
||||
"Service binding metadata ignored: implementation '{Implementation}' is not assignable to service '{Service}'.",
|
||||
implementationType.FullName ?? implementationType.Name,
|
||||
serviceType.FullName ?? serviceType.Name);
|
||||
return;
|
||||
}
|
||||
|
||||
if (attribute.ReplaceExisting)
|
||||
{
|
||||
RemoveExistingDescriptors(services, serviceType);
|
||||
}
|
||||
|
||||
AddDescriptorIfMissing(services, serviceType, implementationType, attribute.Lifetime, logger);
|
||||
|
||||
if (attribute.RegisterAsSelf && serviceType != implementationType)
|
||||
{
|
||||
AddDescriptorIfMissing(services, implementationType, implementationType, attribute.Lifetime, logger);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsValidBinding(Type serviceType, Type implementationType)
|
||||
{
|
||||
if (serviceType.IsGenericTypeDefinition)
|
||||
{
|
||||
return implementationType.IsGenericTypeDefinition
|
||||
&& implementationType.IsClass
|
||||
&& implementationType.IsAssignableToGenericTypeDefinition(serviceType);
|
||||
}
|
||||
|
||||
return serviceType.IsAssignableFrom(implementationType);
|
||||
}
|
||||
|
||||
private static void AddDescriptorIfMissing(
|
||||
IServiceCollection services,
|
||||
Type serviceType,
|
||||
Type implementationType,
|
||||
ServiceLifetime lifetime,
|
||||
ILogger? logger)
|
||||
{
|
||||
if (services.Any(descriptor =>
|
||||
descriptor.ServiceType == serviceType &&
|
||||
descriptor.ImplementationType == implementationType))
|
||||
{
|
||||
logger?.LogDebug(
|
||||
"Skipping duplicate service binding for {ServiceType} -> {ImplementationType}.",
|
||||
serviceType.FullName ?? serviceType.Name,
|
||||
implementationType.FullName ?? implementationType.Name);
|
||||
return;
|
||||
}
|
||||
|
||||
ServiceDescriptor descriptor;
|
||||
if (serviceType.IsGenericTypeDefinition || implementationType.IsGenericTypeDefinition)
|
||||
{
|
||||
descriptor = ServiceDescriptor.Describe(serviceType, implementationType, lifetime);
|
||||
}
|
||||
else
|
||||
{
|
||||
descriptor = new ServiceDescriptor(serviceType, implementationType, lifetime);
|
||||
}
|
||||
|
||||
services.Add(descriptor);
|
||||
logger?.LogDebug(
|
||||
"Registered service binding {ServiceType} -> {ImplementationType} with {Lifetime} lifetime.",
|
||||
serviceType.FullName ?? serviceType.Name,
|
||||
implementationType.FullName ?? implementationType.Name,
|
||||
lifetime);
|
||||
}
|
||||
|
||||
private static void RemoveExistingDescriptors(IServiceCollection services, Type serviceType)
|
||||
{
|
||||
for (var i = services.Count - 1; i >= 0; i--)
|
||||
{
|
||||
if (services[i].ServiceType == serviceType)
|
||||
{
|
||||
services.RemoveAt(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsAssignableToGenericTypeDefinition(this Type implementationType, Type serviceTypeDefinition)
|
||||
{
|
||||
if (!serviceTypeDefinition.IsGenericTypeDefinition)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (implementationType == serviceTypeDefinition)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (implementationType.IsGenericType && implementationType.GetGenericTypeDefinition() == serviceTypeDefinition)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var interfaces = implementationType.GetInterfaces();
|
||||
foreach (var iface in interfaces)
|
||||
{
|
||||
if (iface.IsGenericType && iface.GetGenericTypeDefinition() == serviceTypeDefinition)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
var baseType = implementationType.BaseType;
|
||||
return baseType is not null && baseType.IsGenericTypeDefinition
|
||||
? baseType.GetGenericTypeDefinition() == serviceTypeDefinition
|
||||
: baseType is not null && baseType.IsAssignableToGenericTypeDefinition(serviceTypeDefinition);
|
||||
}
|
||||
}
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.DependencyInjection;
|
||||
using StellaOps.Plugin.Internal;
|
||||
|
||||
namespace StellaOps.Plugin.DependencyInjection;
|
||||
|
||||
public static class PluginServiceRegistration
|
||||
{
|
||||
public static void RegisterAssemblyMetadata(IServiceCollection services, Assembly assembly, ILogger? logger)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(assembly);
|
||||
|
||||
foreach (var implementationType in assembly.GetLoadableTypes())
|
||||
{
|
||||
if (implementationType is null || !implementationType.IsClass || implementationType.IsAbstract)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var attributes = implementationType.GetCustomAttributes<ServiceBindingAttribute>(inherit: false);
|
||||
if (!attributes.Any())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var attribute in attributes)
|
||||
{
|
||||
try
|
||||
{
|
||||
ApplyBinding(services, implementationType, attribute, logger);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger?.LogWarning(
|
||||
ex,
|
||||
"Failed to register service binding for implementation '{Implementation}' declared in assembly '{Assembly}'.",
|
||||
implementationType.FullName ?? implementationType.Name,
|
||||
assembly.FullName ?? assembly.GetName().Name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void ApplyBinding(
|
||||
IServiceCollection services,
|
||||
Type implementationType,
|
||||
ServiceBindingAttribute attribute,
|
||||
ILogger? logger)
|
||||
{
|
||||
var serviceType = attribute.ServiceType ?? implementationType;
|
||||
|
||||
if (!IsValidBinding(serviceType, implementationType))
|
||||
{
|
||||
logger?.LogWarning(
|
||||
"Service binding metadata ignored: implementation '{Implementation}' is not assignable to service '{Service}'.",
|
||||
implementationType.FullName ?? implementationType.Name,
|
||||
serviceType.FullName ?? serviceType.Name);
|
||||
return;
|
||||
}
|
||||
|
||||
if (attribute.ReplaceExisting)
|
||||
{
|
||||
RemoveExistingDescriptors(services, serviceType);
|
||||
}
|
||||
|
||||
AddDescriptorIfMissing(services, serviceType, implementationType, attribute.Lifetime, logger);
|
||||
|
||||
if (attribute.RegisterAsSelf && serviceType != implementationType)
|
||||
{
|
||||
AddDescriptorIfMissing(services, implementationType, implementationType, attribute.Lifetime, logger);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsValidBinding(Type serviceType, Type implementationType)
|
||||
{
|
||||
if (serviceType.IsGenericTypeDefinition)
|
||||
{
|
||||
return implementationType.IsGenericTypeDefinition
|
||||
&& implementationType.IsClass
|
||||
&& implementationType.IsAssignableToGenericTypeDefinition(serviceType);
|
||||
}
|
||||
|
||||
return serviceType.IsAssignableFrom(implementationType);
|
||||
}
|
||||
|
||||
private static void AddDescriptorIfMissing(
|
||||
IServiceCollection services,
|
||||
Type serviceType,
|
||||
Type implementationType,
|
||||
ServiceLifetime lifetime,
|
||||
ILogger? logger)
|
||||
{
|
||||
if (services.Any(descriptor =>
|
||||
descriptor.ServiceType == serviceType &&
|
||||
descriptor.ImplementationType == implementationType))
|
||||
{
|
||||
logger?.LogDebug(
|
||||
"Skipping duplicate service binding for {ServiceType} -> {ImplementationType}.",
|
||||
serviceType.FullName ?? serviceType.Name,
|
||||
implementationType.FullName ?? implementationType.Name);
|
||||
return;
|
||||
}
|
||||
|
||||
ServiceDescriptor descriptor;
|
||||
if (serviceType.IsGenericTypeDefinition || implementationType.IsGenericTypeDefinition)
|
||||
{
|
||||
descriptor = ServiceDescriptor.Describe(serviceType, implementationType, lifetime);
|
||||
}
|
||||
else
|
||||
{
|
||||
descriptor = new ServiceDescriptor(serviceType, implementationType, lifetime);
|
||||
}
|
||||
|
||||
services.Add(descriptor);
|
||||
logger?.LogDebug(
|
||||
"Registered service binding {ServiceType} -> {ImplementationType} with {Lifetime} lifetime.",
|
||||
serviceType.FullName ?? serviceType.Name,
|
||||
implementationType.FullName ?? implementationType.Name,
|
||||
lifetime);
|
||||
}
|
||||
|
||||
private static void RemoveExistingDescriptors(IServiceCollection services, Type serviceType)
|
||||
{
|
||||
for (var i = services.Count - 1; i >= 0; i--)
|
||||
{
|
||||
if (services[i].ServiceType == serviceType)
|
||||
{
|
||||
services.RemoveAt(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsAssignableToGenericTypeDefinition(this Type implementationType, Type serviceTypeDefinition)
|
||||
{
|
||||
if (!serviceTypeDefinition.IsGenericTypeDefinition)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (implementationType == serviceTypeDefinition)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (implementationType.IsGenericType && implementationType.GetGenericTypeDefinition() == serviceTypeDefinition)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var interfaces = implementationType.GetInterfaces();
|
||||
foreach (var iface in interfaces)
|
||||
{
|
||||
if (iface.IsGenericType && iface.GetGenericTypeDefinition() == serviceTypeDefinition)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
var baseType = implementationType.BaseType;
|
||||
return baseType is not null && baseType.IsGenericTypeDefinition
|
||||
? baseType.GetGenericTypeDefinition() == serviceTypeDefinition
|
||||
: baseType is not null && baseType.IsAssignableToGenericTypeDefinition(serviceTypeDefinition);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,26 +1,26 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.DependencyInjection;
|
||||
|
||||
namespace StellaOps.Plugin.DependencyInjection;
|
||||
|
||||
public static class StellaOpsPluginRegistration
|
||||
{
|
||||
public static IServiceCollection RegisterStellaOpsPlugin(
|
||||
this IServiceCollection services,
|
||||
IConfiguration configuration)
|
||||
{
|
||||
// No-op today but reserved for future plugin infrastructure services.
|
||||
return services;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class DependencyInjectionRoutine : IDependencyInjectionRoutine
|
||||
{
|
||||
public IServiceCollection Register(
|
||||
IServiceCollection services,
|
||||
IConfiguration configuration)
|
||||
{
|
||||
return services.RegisterStellaOpsPlugin(configuration);
|
||||
}
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.DependencyInjection;
|
||||
|
||||
namespace StellaOps.Plugin.DependencyInjection;
|
||||
|
||||
public static class StellaOpsPluginRegistration
|
||||
{
|
||||
public static IServiceCollection RegisterStellaOpsPlugin(
|
||||
this IServiceCollection services,
|
||||
IConfiguration configuration)
|
||||
{
|
||||
// No-op today but reserved for future plugin infrastructure services.
|
||||
return services;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class DependencyInjectionRoutine : IDependencyInjectionRoutine
|
||||
{
|
||||
public IServiceCollection Register(
|
||||
IServiceCollection services,
|
||||
IConfiguration configuration)
|
||||
{
|
||||
return services.RegisterStellaOpsPlugin(configuration);
|
||||
}
|
||||
}
|
||||
@@ -1,21 +1,21 @@
|
||||
using System.Reflection;
|
||||
|
||||
namespace StellaOps.Plugin.Hosting;
|
||||
|
||||
public sealed class PluginAssembly
|
||||
{
|
||||
internal PluginAssembly(string assemblyPath, Assembly assembly, PluginLoadContext loadContext)
|
||||
{
|
||||
AssemblyPath = assemblyPath;
|
||||
Assembly = assembly;
|
||||
LoadContext = loadContext;
|
||||
}
|
||||
|
||||
public string AssemblyPath { get; }
|
||||
|
||||
public Assembly Assembly { get; }
|
||||
|
||||
internal PluginLoadContext LoadContext { get; }
|
||||
|
||||
public override string ToString() => Assembly.FullName ?? AssemblyPath;
|
||||
using System.Reflection;
|
||||
|
||||
namespace StellaOps.Plugin.Hosting;
|
||||
|
||||
public sealed class PluginAssembly
|
||||
{
|
||||
internal PluginAssembly(string assemblyPath, Assembly assembly, PluginLoadContext loadContext)
|
||||
{
|
||||
AssemblyPath = assemblyPath;
|
||||
Assembly = assembly;
|
||||
LoadContext = loadContext;
|
||||
}
|
||||
|
||||
public string AssemblyPath { get; }
|
||||
|
||||
public Assembly Assembly { get; }
|
||||
|
||||
internal PluginLoadContext LoadContext { get; }
|
||||
|
||||
public override string ToString() => Assembly.FullName ?? AssemblyPath;
|
||||
}
|
||||
@@ -1,76 +1,76 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
|
||||
namespace StellaOps.Plugin.Hosting;
|
||||
|
||||
public static class PluginHost
|
||||
{
|
||||
private static readonly object Sync = new();
|
||||
private static readonly Dictionary<string, PluginAssembly> LoadedPlugins = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public static PluginHostResult LoadPlugins(PluginHostOptions options, ILogger? logger = null)
|
||||
{
|
||||
if (options == null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(options));
|
||||
}
|
||||
|
||||
var baseDirectory = options.ResolveBaseDirectory();
|
||||
var pluginDirectory = ResolvePluginDirectory(options, baseDirectory);
|
||||
|
||||
if (options.EnsureDirectoryExists && !Directory.Exists(pluginDirectory))
|
||||
{
|
||||
Directory.CreateDirectory(pluginDirectory);
|
||||
}
|
||||
|
||||
if (!Directory.Exists(pluginDirectory))
|
||||
{
|
||||
logger?.LogWarning("Plugin directory '{PluginDirectory}' does not exist; no plugins will be loaded.", pluginDirectory);
|
||||
return new PluginHostResult(pluginDirectory, Array.Empty<string>(), Array.Empty<PluginAssembly>(), Array.Empty<string>());
|
||||
}
|
||||
|
||||
var searchPatterns = BuildSearchPatterns(options, pluginDirectory);
|
||||
var discovered = DiscoverPluginFiles(pluginDirectory, searchPatterns, options.RecursiveSearch, logger);
|
||||
var orderedFiles = ApplyExplicitOrdering(discovered, options.PluginOrder, out var missingOrderedNames);
|
||||
|
||||
var loaded = new List<PluginAssembly>(orderedFiles.Count);
|
||||
|
||||
lock (Sync)
|
||||
{
|
||||
foreach (var file in orderedFiles)
|
||||
{
|
||||
if (LoadedPlugins.TryGetValue(file, out var existing))
|
||||
{
|
||||
loaded.Add(existing);
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var loadContext = new PluginLoadContext(file);
|
||||
var assembly = loadContext.LoadFromAssemblyPath(file);
|
||||
var descriptor = new PluginAssembly(file, assembly, loadContext);
|
||||
LoadedPlugins[file] = descriptor;
|
||||
loaded.Add(descriptor);
|
||||
logger?.LogInformation("Loaded plugin assembly '{Assembly}' from '{Path}'.", assembly.FullName, file);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger?.LogError(ex, "Failed to load plugin assembly from '{Path}'.", file);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var missingOrdered = new ReadOnlyCollection<string>(missingOrderedNames);
|
||||
return new PluginHostResult(pluginDirectory, searchPatterns, new ReadOnlyCollection<PluginAssembly>(loaded), missingOrdered);
|
||||
}
|
||||
|
||||
private static string ResolvePluginDirectory(PluginHostOptions options, string baseDirectory)
|
||||
{
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
|
||||
namespace StellaOps.Plugin.Hosting;
|
||||
|
||||
public static class PluginHost
|
||||
{
|
||||
private static readonly object Sync = new();
|
||||
private static readonly Dictionary<string, PluginAssembly> LoadedPlugins = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public static PluginHostResult LoadPlugins(PluginHostOptions options, ILogger? logger = null)
|
||||
{
|
||||
if (options == null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(options));
|
||||
}
|
||||
|
||||
var baseDirectory = options.ResolveBaseDirectory();
|
||||
var pluginDirectory = ResolvePluginDirectory(options, baseDirectory);
|
||||
|
||||
if (options.EnsureDirectoryExists && !Directory.Exists(pluginDirectory))
|
||||
{
|
||||
Directory.CreateDirectory(pluginDirectory);
|
||||
}
|
||||
|
||||
if (!Directory.Exists(pluginDirectory))
|
||||
{
|
||||
logger?.LogWarning("Plugin directory '{PluginDirectory}' does not exist; no plugins will be loaded.", pluginDirectory);
|
||||
return new PluginHostResult(pluginDirectory, Array.Empty<string>(), Array.Empty<PluginAssembly>(), Array.Empty<string>());
|
||||
}
|
||||
|
||||
var searchPatterns = BuildSearchPatterns(options, pluginDirectory);
|
||||
var discovered = DiscoverPluginFiles(pluginDirectory, searchPatterns, options.RecursiveSearch, logger);
|
||||
var orderedFiles = ApplyExplicitOrdering(discovered, options.PluginOrder, out var missingOrderedNames);
|
||||
|
||||
var loaded = new List<PluginAssembly>(orderedFiles.Count);
|
||||
|
||||
lock (Sync)
|
||||
{
|
||||
foreach (var file in orderedFiles)
|
||||
{
|
||||
if (LoadedPlugins.TryGetValue(file, out var existing))
|
||||
{
|
||||
loaded.Add(existing);
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var loadContext = new PluginLoadContext(file);
|
||||
var assembly = loadContext.LoadFromAssemblyPath(file);
|
||||
var descriptor = new PluginAssembly(file, assembly, loadContext);
|
||||
LoadedPlugins[file] = descriptor;
|
||||
loaded.Add(descriptor);
|
||||
logger?.LogInformation("Loaded plugin assembly '{Assembly}' from '{Path}'.", assembly.FullName, file);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger?.LogError(ex, "Failed to load plugin assembly from '{Path}'.", file);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var missingOrdered = new ReadOnlyCollection<string>(missingOrderedNames);
|
||||
return new PluginHostResult(pluginDirectory, searchPatterns, new ReadOnlyCollection<PluginAssembly>(loaded), missingOrdered);
|
||||
}
|
||||
|
||||
private static string ResolvePluginDirectory(PluginHostOptions options, string baseDirectory)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(options.PluginsDirectory))
|
||||
{
|
||||
var defaultDirectory = !string.IsNullOrWhiteSpace(options.PrimaryPrefix)
|
||||
@@ -78,142 +78,142 @@ public static class PluginHost
|
||||
: "PluginBinaries";
|
||||
return Path.Combine(baseDirectory, defaultDirectory);
|
||||
}
|
||||
|
||||
if (Path.IsPathRooted(options.PluginsDirectory))
|
||||
{
|
||||
return options.PluginsDirectory;
|
||||
}
|
||||
|
||||
return Path.Combine(baseDirectory, options.PluginsDirectory);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> BuildSearchPatterns(PluginHostOptions options, string pluginDirectory)
|
||||
{
|
||||
var patterns = new List<string>();
|
||||
if (options.SearchPatterns.Count > 0)
|
||||
{
|
||||
patterns.AddRange(options.SearchPatterns);
|
||||
}
|
||||
else
|
||||
{
|
||||
var prefixes = new List<string>();
|
||||
if (!string.IsNullOrWhiteSpace(options.PrimaryPrefix))
|
||||
{
|
||||
prefixes.Add(options.PrimaryPrefix);
|
||||
}
|
||||
else if (System.Reflection.Assembly.GetEntryAssembly()?.GetName().Name is { } entryName)
|
||||
{
|
||||
prefixes.Add(entryName);
|
||||
}
|
||||
|
||||
prefixes.AddRange(options.AdditionalPrefixes);
|
||||
|
||||
if (prefixes.Count == 0)
|
||||
{
|
||||
// Fallback to directory name
|
||||
prefixes.Add(Path.GetFileName(pluginDirectory));
|
||||
}
|
||||
|
||||
foreach (var prefix in prefixes.Where(p => !string.IsNullOrWhiteSpace(p)))
|
||||
{
|
||||
patterns.Add($"{prefix}.Plugin.*.dll");
|
||||
}
|
||||
}
|
||||
|
||||
return new ReadOnlyCollection<string>(patterns.Distinct(StringComparer.OrdinalIgnoreCase).ToList());
|
||||
}
|
||||
|
||||
private static List<string> DiscoverPluginFiles(
|
||||
string pluginDirectory,
|
||||
IReadOnlyList<string> searchPatterns,
|
||||
bool recurse,
|
||||
ILogger? logger)
|
||||
{
|
||||
var files = new List<string>();
|
||||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var searchOption = recurse ? SearchOption.AllDirectories : SearchOption.TopDirectoryOnly;
|
||||
|
||||
foreach (var pattern in searchPatterns)
|
||||
{
|
||||
try
|
||||
{
|
||||
foreach (var file in Directory.EnumerateFiles(pluginDirectory, pattern, searchOption))
|
||||
{
|
||||
if (IsHiddenPath(file))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (seen.Add(file))
|
||||
{
|
||||
files.Add(file);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (DirectoryNotFoundException)
|
||||
{
|
||||
// Directory could be removed between the existence check and enumeration.
|
||||
logger?.LogDebug("Plugin directory '{PluginDirectory}' disappeared before enumeration.", pluginDirectory);
|
||||
}
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
private static List<string> ApplyExplicitOrdering(
|
||||
List<string> discoveredFiles,
|
||||
IList<string> pluginOrder,
|
||||
out List<string> missingNames)
|
||||
{
|
||||
if (pluginOrder.Count == 0 || discoveredFiles.Count == 0)
|
||||
{
|
||||
missingNames = new List<string>();
|
||||
discoveredFiles.Sort(StringComparer.OrdinalIgnoreCase);
|
||||
return discoveredFiles;
|
||||
}
|
||||
|
||||
var configuredSet = new HashSet<string>(pluginOrder, StringComparer.OrdinalIgnoreCase);
|
||||
var fileLookup = discoveredFiles.ToDictionary(
|
||||
k => Path.GetFileNameWithoutExtension(k),
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var specified = new List<string>();
|
||||
foreach (var name in pluginOrder)
|
||||
{
|
||||
if (fileLookup.TryGetValue(name, out var file))
|
||||
{
|
||||
specified.Add(file);
|
||||
}
|
||||
}
|
||||
|
||||
var unspecified = discoveredFiles
|
||||
.Where(f => !configuredSet.Contains(Path.GetFileNameWithoutExtension(f)))
|
||||
.OrderBy(f => f, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
missingNames = pluginOrder
|
||||
.Where(name => !fileLookup.ContainsKey(name))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
specified.AddRange(unspecified);
|
||||
return specified;
|
||||
}
|
||||
|
||||
private static bool IsHiddenPath(string filePath)
|
||||
{
|
||||
var directory = Path.GetDirectoryName(filePath);
|
||||
while (!string.IsNullOrEmpty(directory))
|
||||
{
|
||||
var name = Path.GetFileName(directory);
|
||||
if (name.StartsWith(".", StringComparison.Ordinal))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
directory = Path.GetDirectoryName(directory);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
if (Path.IsPathRooted(options.PluginsDirectory))
|
||||
{
|
||||
return options.PluginsDirectory;
|
||||
}
|
||||
|
||||
return Path.Combine(baseDirectory, options.PluginsDirectory);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> BuildSearchPatterns(PluginHostOptions options, string pluginDirectory)
|
||||
{
|
||||
var patterns = new List<string>();
|
||||
if (options.SearchPatterns.Count > 0)
|
||||
{
|
||||
patterns.AddRange(options.SearchPatterns);
|
||||
}
|
||||
else
|
||||
{
|
||||
var prefixes = new List<string>();
|
||||
if (!string.IsNullOrWhiteSpace(options.PrimaryPrefix))
|
||||
{
|
||||
prefixes.Add(options.PrimaryPrefix);
|
||||
}
|
||||
else if (System.Reflection.Assembly.GetEntryAssembly()?.GetName().Name is { } entryName)
|
||||
{
|
||||
prefixes.Add(entryName);
|
||||
}
|
||||
|
||||
prefixes.AddRange(options.AdditionalPrefixes);
|
||||
|
||||
if (prefixes.Count == 0)
|
||||
{
|
||||
// Fallback to directory name
|
||||
prefixes.Add(Path.GetFileName(pluginDirectory));
|
||||
}
|
||||
|
||||
foreach (var prefix in prefixes.Where(p => !string.IsNullOrWhiteSpace(p)))
|
||||
{
|
||||
patterns.Add($"{prefix}.Plugin.*.dll");
|
||||
}
|
||||
}
|
||||
|
||||
return new ReadOnlyCollection<string>(patterns.Distinct(StringComparer.OrdinalIgnoreCase).ToList());
|
||||
}
|
||||
|
||||
private static List<string> DiscoverPluginFiles(
|
||||
string pluginDirectory,
|
||||
IReadOnlyList<string> searchPatterns,
|
||||
bool recurse,
|
||||
ILogger? logger)
|
||||
{
|
||||
var files = new List<string>();
|
||||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var searchOption = recurse ? SearchOption.AllDirectories : SearchOption.TopDirectoryOnly;
|
||||
|
||||
foreach (var pattern in searchPatterns)
|
||||
{
|
||||
try
|
||||
{
|
||||
foreach (var file in Directory.EnumerateFiles(pluginDirectory, pattern, searchOption))
|
||||
{
|
||||
if (IsHiddenPath(file))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (seen.Add(file))
|
||||
{
|
||||
files.Add(file);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (DirectoryNotFoundException)
|
||||
{
|
||||
// Directory could be removed between the existence check and enumeration.
|
||||
logger?.LogDebug("Plugin directory '{PluginDirectory}' disappeared before enumeration.", pluginDirectory);
|
||||
}
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
private static List<string> ApplyExplicitOrdering(
|
||||
List<string> discoveredFiles,
|
||||
IList<string> pluginOrder,
|
||||
out List<string> missingNames)
|
||||
{
|
||||
if (pluginOrder.Count == 0 || discoveredFiles.Count == 0)
|
||||
{
|
||||
missingNames = new List<string>();
|
||||
discoveredFiles.Sort(StringComparer.OrdinalIgnoreCase);
|
||||
return discoveredFiles;
|
||||
}
|
||||
|
||||
var configuredSet = new HashSet<string>(pluginOrder, StringComparer.OrdinalIgnoreCase);
|
||||
var fileLookup = discoveredFiles.ToDictionary(
|
||||
k => Path.GetFileNameWithoutExtension(k),
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var specified = new List<string>();
|
||||
foreach (var name in pluginOrder)
|
||||
{
|
||||
if (fileLookup.TryGetValue(name, out var file))
|
||||
{
|
||||
specified.Add(file);
|
||||
}
|
||||
}
|
||||
|
||||
var unspecified = discoveredFiles
|
||||
.Where(f => !configuredSet.Contains(Path.GetFileNameWithoutExtension(f)))
|
||||
.OrderBy(f => f, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
missingNames = pluginOrder
|
||||
.Where(name => !fileLookup.ContainsKey(name))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
specified.AddRange(unspecified);
|
||||
return specified;
|
||||
}
|
||||
|
||||
private static bool IsHiddenPath(string filePath)
|
||||
{
|
||||
var directory = Path.GetDirectoryName(filePath);
|
||||
while (!string.IsNullOrEmpty(directory))
|
||||
{
|
||||
var name = Path.GetFileName(directory);
|
||||
if (name.StartsWith(".", StringComparison.Ordinal))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
directory = Path.GetDirectoryName(directory);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,59 +1,59 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
|
||||
namespace StellaOps.Plugin.Hosting;
|
||||
|
||||
public sealed class PluginHostOptions
|
||||
{
|
||||
private readonly List<string> additionalPrefixes = new();
|
||||
private readonly List<string> pluginOrder = new();
|
||||
private readonly List<string> searchPatterns = new();
|
||||
|
||||
/// <summary>
|
||||
/// Optional base directory used for resolving relative plugin paths. Defaults to <see cref="AppContext.BaseDirectory" />.
|
||||
/// </summary>
|
||||
public string? BaseDirectory { get; set; }
|
||||
|
||||
/// <summary>
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
|
||||
namespace StellaOps.Plugin.Hosting;
|
||||
|
||||
public sealed class PluginHostOptions
|
||||
{
|
||||
private readonly List<string> additionalPrefixes = new();
|
||||
private readonly List<string> pluginOrder = new();
|
||||
private readonly List<string> searchPatterns = new();
|
||||
|
||||
/// <summary>
|
||||
/// Optional base directory used for resolving relative plugin paths. Defaults to <see cref="AppContext.BaseDirectory" />.
|
||||
/// </summary>
|
||||
public string? BaseDirectory { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Directory that contains plugin assemblies. Relative values are resolved against <see cref="BaseDirectory" />.
|
||||
/// Defaults to <c>{PrimaryPrefix}.PluginBinaries</c> when a primary prefix is provided, otherwise <c>PluginBinaries</c>.
|
||||
/// </summary>
|
||||
public string? PluginsDirectory { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Primary prefix used to discover plugin assemblies. If not supplied, the entry assembly name is used.
|
||||
/// </summary>
|
||||
public string? PrimaryPrefix { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Additional prefixes that should be considered when building search patterns.
|
||||
/// </summary>
|
||||
public IList<string> AdditionalPrefixes => additionalPrefixes;
|
||||
|
||||
/// <summary>
|
||||
/// Explicit plugin ordering expressed as assembly names without extension.
|
||||
/// Entries that are not discovered will be reported in <see cref="PluginHostResult.MissingOrderedPlugins" />.
|
||||
/// </summary>
|
||||
public IList<string> PluginOrder => pluginOrder;
|
||||
|
||||
/// <summary>
|
||||
/// Optional explicit search patterns. When empty, they are derived from prefix settings.
|
||||
/// </summary>
|
||||
public IList<string> SearchPatterns => searchPatterns;
|
||||
|
||||
/// <summary>
|
||||
/// When true (default) the plugin directory will be created if it does not exist.
|
||||
/// </summary>
|
||||
public bool EnsureDirectoryExists { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Controls whether sub-directories should be scanned. Defaults to true.
|
||||
/// </summary>
|
||||
public bool RecursiveSearch { get; set; } = true;
|
||||
|
||||
internal string ResolveBaseDirectory()
|
||||
=> string.IsNullOrWhiteSpace(BaseDirectory)
|
||||
? AppContext.BaseDirectory
|
||||
: Path.GetFullPath(BaseDirectory);
|
||||
/// </summary>
|
||||
public string? PluginsDirectory { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Primary prefix used to discover plugin assemblies. If not supplied, the entry assembly name is used.
|
||||
/// </summary>
|
||||
public string? PrimaryPrefix { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Additional prefixes that should be considered when building search patterns.
|
||||
/// </summary>
|
||||
public IList<string> AdditionalPrefixes => additionalPrefixes;
|
||||
|
||||
/// <summary>
|
||||
/// Explicit plugin ordering expressed as assembly names without extension.
|
||||
/// Entries that are not discovered will be reported in <see cref="PluginHostResult.MissingOrderedPlugins" />.
|
||||
/// </summary>
|
||||
public IList<string> PluginOrder => pluginOrder;
|
||||
|
||||
/// <summary>
|
||||
/// Optional explicit search patterns. When empty, they are derived from prefix settings.
|
||||
/// </summary>
|
||||
public IList<string> SearchPatterns => searchPatterns;
|
||||
|
||||
/// <summary>
|
||||
/// When true (default) the plugin directory will be created if it does not exist.
|
||||
/// </summary>
|
||||
public bool EnsureDirectoryExists { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Controls whether sub-directories should be scanned. Defaults to true.
|
||||
/// </summary>
|
||||
public bool RecursiveSearch { get; set; } = true;
|
||||
|
||||
internal string ResolveBaseDirectory()
|
||||
=> string.IsNullOrWhiteSpace(BaseDirectory)
|
||||
? AppContext.BaseDirectory
|
||||
: Path.GetFullPath(BaseDirectory);
|
||||
}
|
||||
|
||||
@@ -1,26 +1,26 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Plugin.Hosting;
|
||||
|
||||
public sealed class PluginHostResult
|
||||
{
|
||||
internal PluginHostResult(
|
||||
string pluginDirectory,
|
||||
IReadOnlyList<string> searchPatterns,
|
||||
IReadOnlyList<PluginAssembly> plugins,
|
||||
IReadOnlyList<string> missingOrderedPlugins)
|
||||
{
|
||||
PluginDirectory = pluginDirectory;
|
||||
SearchPatterns = searchPatterns;
|
||||
Plugins = plugins;
|
||||
MissingOrderedPlugins = missingOrderedPlugins;
|
||||
}
|
||||
|
||||
public string PluginDirectory { get; }
|
||||
|
||||
public IReadOnlyList<string> SearchPatterns { get; }
|
||||
|
||||
public IReadOnlyList<PluginAssembly> Plugins { get; }
|
||||
|
||||
public IReadOnlyList<string> MissingOrderedPlugins { get; }
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Plugin.Hosting;
|
||||
|
||||
public sealed class PluginHostResult
|
||||
{
|
||||
internal PluginHostResult(
|
||||
string pluginDirectory,
|
||||
IReadOnlyList<string> searchPatterns,
|
||||
IReadOnlyList<PluginAssembly> plugins,
|
||||
IReadOnlyList<string> missingOrderedPlugins)
|
||||
{
|
||||
PluginDirectory = pluginDirectory;
|
||||
SearchPatterns = searchPatterns;
|
||||
Plugins = plugins;
|
||||
MissingOrderedPlugins = missingOrderedPlugins;
|
||||
}
|
||||
|
||||
public string PluginDirectory { get; }
|
||||
|
||||
public IReadOnlyList<string> SearchPatterns { get; }
|
||||
|
||||
public IReadOnlyList<PluginAssembly> Plugins { get; }
|
||||
|
||||
public IReadOnlyList<string> MissingOrderedPlugins { get; }
|
||||
}
|
||||
@@ -1,79 +1,79 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using System.Runtime.Loader;
|
||||
|
||||
namespace StellaOps.Plugin.Hosting;
|
||||
|
||||
internal sealed class PluginLoadContext : AssemblyLoadContext
|
||||
{
|
||||
private readonly AssemblyDependencyResolver resolver;
|
||||
private readonly IEnumerable<Assembly> hostAssemblies;
|
||||
|
||||
public PluginLoadContext(string pluginPath)
|
||||
: base(isCollectible: false)
|
||||
{
|
||||
resolver = new AssemblyDependencyResolver(pluginPath);
|
||||
hostAssemblies = AssemblyLoadContext.Default.Assemblies;
|
||||
}
|
||||
|
||||
protected override Assembly? Load(AssemblyName assemblyName)
|
||||
{
|
||||
// Attempt to reuse assemblies that already exist in the default context when versions are compatible.
|
||||
var existing = hostAssemblies.FirstOrDefault(a => string.Equals(
|
||||
a.GetName().Name,
|
||||
assemblyName.Name,
|
||||
StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (existing != null && IsCompatible(existing.GetName(), assemblyName))
|
||||
{
|
||||
return existing;
|
||||
}
|
||||
|
||||
var assemblyPath = resolver.ResolveAssemblyToPath(assemblyName);
|
||||
if (!string.IsNullOrEmpty(assemblyPath))
|
||||
{
|
||||
return LoadFromAssemblyPath(assemblyPath);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
protected override IntPtr LoadUnmanagedDll(string unmanagedDllName)
|
||||
{
|
||||
var libraryPath = resolver.ResolveUnmanagedDllToPath(unmanagedDllName);
|
||||
if (!string.IsNullOrEmpty(libraryPath))
|
||||
{
|
||||
return LoadUnmanagedDllFromPath(libraryPath);
|
||||
}
|
||||
|
||||
return IntPtr.Zero;
|
||||
}
|
||||
|
||||
private static bool IsCompatible(AssemblyName hostAssembly, AssemblyName pluginAssembly)
|
||||
{
|
||||
if (hostAssembly.Version == pluginAssembly.Version)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (hostAssembly.Version is null || pluginAssembly.Version is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (hostAssembly.Version.Major == pluginAssembly.Version.Major &&
|
||||
hostAssembly.Version.Minor >= pluginAssembly.Version.Minor)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (hostAssembly.Version.Major >= pluginAssembly.Version.Major)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using System.Runtime.Loader;
|
||||
|
||||
namespace StellaOps.Plugin.Hosting;
|
||||
|
||||
internal sealed class PluginLoadContext : AssemblyLoadContext
|
||||
{
|
||||
private readonly AssemblyDependencyResolver resolver;
|
||||
private readonly IEnumerable<Assembly> hostAssemblies;
|
||||
|
||||
public PluginLoadContext(string pluginPath)
|
||||
: base(isCollectible: false)
|
||||
{
|
||||
resolver = new AssemblyDependencyResolver(pluginPath);
|
||||
hostAssemblies = AssemblyLoadContext.Default.Assemblies;
|
||||
}
|
||||
|
||||
protected override Assembly? Load(AssemblyName assemblyName)
|
||||
{
|
||||
// Attempt to reuse assemblies that already exist in the default context when versions are compatible.
|
||||
var existing = hostAssemblies.FirstOrDefault(a => string.Equals(
|
||||
a.GetName().Name,
|
||||
assemblyName.Name,
|
||||
StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (existing != null && IsCompatible(existing.GetName(), assemblyName))
|
||||
{
|
||||
return existing;
|
||||
}
|
||||
|
||||
var assemblyPath = resolver.ResolveAssemblyToPath(assemblyName);
|
||||
if (!string.IsNullOrEmpty(assemblyPath))
|
||||
{
|
||||
return LoadFromAssemblyPath(assemblyPath);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
protected override IntPtr LoadUnmanagedDll(string unmanagedDllName)
|
||||
{
|
||||
var libraryPath = resolver.ResolveUnmanagedDllToPath(unmanagedDllName);
|
||||
if (!string.IsNullOrEmpty(libraryPath))
|
||||
{
|
||||
return LoadUnmanagedDllFromPath(libraryPath);
|
||||
}
|
||||
|
||||
return IntPtr.Zero;
|
||||
}
|
||||
|
||||
private static bool IsCompatible(AssemblyName hostAssembly, AssemblyName pluginAssembly)
|
||||
{
|
||||
if (hostAssembly.Version == pluginAssembly.Version)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (hostAssembly.Version is null || pluginAssembly.Version is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (hostAssembly.Version.Major == pluginAssembly.Version.Major &&
|
||||
hostAssembly.Version.Minor >= pluginAssembly.Version.Minor)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (hostAssembly.Version.Major >= pluginAssembly.Version.Major)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -1,21 +1,21 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
|
||||
namespace StellaOps.Plugin.Internal;
|
||||
|
||||
internal static class ReflectionExtensions
|
||||
{
|
||||
public static IEnumerable<Type> GetLoadableTypes(this Assembly assembly)
|
||||
{
|
||||
try
|
||||
{
|
||||
return assembly.GetTypes();
|
||||
}
|
||||
catch (ReflectionTypeLoadException ex)
|
||||
{
|
||||
return ex.Types.Where(static t => t is not null)!;
|
||||
}
|
||||
}
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
|
||||
namespace StellaOps.Plugin.Internal;
|
||||
|
||||
internal static class ReflectionExtensions
|
||||
{
|
||||
public static IEnumerable<Type> GetLoadableTypes(this Assembly assembly)
|
||||
{
|
||||
try
|
||||
{
|
||||
return assembly.GetTypes();
|
||||
}
|
||||
catch (ReflectionTypeLoadException ex)
|
||||
{
|
||||
return ex.Types.Where(static t => t is not null)!;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,172 +1,172 @@
|
||||
using StellaOps.Plugin.Hosting;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Reflection;
|
||||
using System.Threading;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Plugin;
|
||||
|
||||
public interface IAvailabilityPlugin
|
||||
{
|
||||
string Name { get; }
|
||||
bool IsAvailable(IServiceProvider services);
|
||||
}
|
||||
|
||||
public interface IFeedConnector
|
||||
{
|
||||
string SourceName { get; }
|
||||
Task FetchAsync(IServiceProvider services, CancellationToken cancellationToken);
|
||||
Task ParseAsync(IServiceProvider services, CancellationToken cancellationToken);
|
||||
Task MapAsync(IServiceProvider services, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public interface IFeedExporter
|
||||
{
|
||||
string Name { get; }
|
||||
Task ExportAsync(IServiceProvider services, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public interface IConnectorPlugin : IAvailabilityPlugin
|
||||
{
|
||||
IFeedConnector Create(IServiceProvider services);
|
||||
}
|
||||
|
||||
public interface IExporterPlugin : IAvailabilityPlugin
|
||||
{
|
||||
IFeedExporter Create(IServiceProvider services);
|
||||
}
|
||||
|
||||
public sealed class PluginCatalog
|
||||
{
|
||||
private readonly List<Assembly> _assemblies = new();
|
||||
private readonly HashSet<string> _assemblyLocations = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public PluginCatalog AddAssembly(Assembly assembly)
|
||||
{
|
||||
if (assembly == null) throw new ArgumentNullException(nameof(assembly));
|
||||
if (_assemblies.Contains(assembly))
|
||||
{
|
||||
return this;
|
||||
}
|
||||
|
||||
_assemblies.Add(assembly);
|
||||
if (!string.IsNullOrWhiteSpace(assembly.Location))
|
||||
{
|
||||
_assemblyLocations.Add(Path.GetFullPath(assembly.Location));
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
public PluginCatalog AddFromDirectory(string directory, string searchPattern = "StellaOps.Concelier.*.dll")
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(directory)) throw new ArgumentException("Directory is required", nameof(directory));
|
||||
|
||||
var fullDirectory = Path.GetFullPath(directory);
|
||||
var options = new PluginHostOptions
|
||||
{
|
||||
PluginsDirectory = fullDirectory,
|
||||
EnsureDirectoryExists = false,
|
||||
RecursiveSearch = false,
|
||||
};
|
||||
options.SearchPatterns.Add(searchPattern);
|
||||
|
||||
var result = PluginHost.LoadPlugins(options);
|
||||
|
||||
foreach (var plugin in result.Plugins)
|
||||
{
|
||||
AddAssembly(plugin.Assembly);
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
public IReadOnlyList<IConnectorPlugin> GetConnectorPlugins() => PluginLoader.LoadPlugins<IConnectorPlugin>(_assemblies);
|
||||
|
||||
public IReadOnlyList<IExporterPlugin> GetExporterPlugins() => PluginLoader.LoadPlugins<IExporterPlugin>(_assemblies);
|
||||
|
||||
public IReadOnlyList<IConnectorPlugin> GetAvailableConnectorPlugins(IServiceProvider services)
|
||||
=> FilterAvailable(GetConnectorPlugins(), services);
|
||||
|
||||
public IReadOnlyList<IExporterPlugin> GetAvailableExporterPlugins(IServiceProvider services)
|
||||
=> FilterAvailable(GetExporterPlugins(), services);
|
||||
|
||||
private static IReadOnlyList<TPlugin> FilterAvailable<TPlugin>(IEnumerable<TPlugin> plugins, IServiceProvider services)
|
||||
where TPlugin : IAvailabilityPlugin
|
||||
{
|
||||
var list = new List<TPlugin>();
|
||||
foreach (var plugin in plugins)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (plugin.IsAvailable(services))
|
||||
{
|
||||
list.Add(plugin);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Treat exceptions as plugin not available.
|
||||
}
|
||||
}
|
||||
return list;
|
||||
}
|
||||
}
|
||||
|
||||
public static class PluginLoader
|
||||
{
|
||||
public static IReadOnlyList<TPlugin> LoadPlugins<TPlugin>(IEnumerable<Assembly> assemblies)
|
||||
where TPlugin : class
|
||||
{
|
||||
if (assemblies == null) throw new ArgumentNullException(nameof(assemblies));
|
||||
|
||||
var plugins = new List<TPlugin>();
|
||||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var assembly in assemblies)
|
||||
{
|
||||
foreach (var candidate in SafeGetTypes(assembly))
|
||||
{
|
||||
if (candidate.IsAbstract || candidate.IsInterface)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!typeof(TPlugin).IsAssignableFrom(candidate))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (Activator.CreateInstance(candidate) is not TPlugin plugin)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var key = candidate.FullName ?? candidate.Name;
|
||||
if (key is null || !seen.Add(key))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
plugins.Add(plugin);
|
||||
}
|
||||
}
|
||||
|
||||
return plugins;
|
||||
}
|
||||
|
||||
private static IEnumerable<Type> SafeGetTypes(Assembly assembly)
|
||||
{
|
||||
try
|
||||
{
|
||||
return assembly.GetTypes();
|
||||
}
|
||||
catch (ReflectionTypeLoadException ex)
|
||||
{
|
||||
return ex.Types.Where(t => t is not null)!;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
using StellaOps.Plugin.Hosting;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Reflection;
|
||||
using System.Threading;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Plugin;
|
||||
|
||||
public interface IAvailabilityPlugin
|
||||
{
|
||||
string Name { get; }
|
||||
bool IsAvailable(IServiceProvider services);
|
||||
}
|
||||
|
||||
public interface IFeedConnector
|
||||
{
|
||||
string SourceName { get; }
|
||||
Task FetchAsync(IServiceProvider services, CancellationToken cancellationToken);
|
||||
Task ParseAsync(IServiceProvider services, CancellationToken cancellationToken);
|
||||
Task MapAsync(IServiceProvider services, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public interface IFeedExporter
|
||||
{
|
||||
string Name { get; }
|
||||
Task ExportAsync(IServiceProvider services, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public interface IConnectorPlugin : IAvailabilityPlugin
|
||||
{
|
||||
IFeedConnector Create(IServiceProvider services);
|
||||
}
|
||||
|
||||
public interface IExporterPlugin : IAvailabilityPlugin
|
||||
{
|
||||
IFeedExporter Create(IServiceProvider services);
|
||||
}
|
||||
|
||||
public sealed class PluginCatalog
|
||||
{
|
||||
private readonly List<Assembly> _assemblies = new();
|
||||
private readonly HashSet<string> _assemblyLocations = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public PluginCatalog AddAssembly(Assembly assembly)
|
||||
{
|
||||
if (assembly == null) throw new ArgumentNullException(nameof(assembly));
|
||||
if (_assemblies.Contains(assembly))
|
||||
{
|
||||
return this;
|
||||
}
|
||||
|
||||
_assemblies.Add(assembly);
|
||||
if (!string.IsNullOrWhiteSpace(assembly.Location))
|
||||
{
|
||||
_assemblyLocations.Add(Path.GetFullPath(assembly.Location));
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
public PluginCatalog AddFromDirectory(string directory, string searchPattern = "StellaOps.Concelier.*.dll")
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(directory)) throw new ArgumentException("Directory is required", nameof(directory));
|
||||
|
||||
var fullDirectory = Path.GetFullPath(directory);
|
||||
var options = new PluginHostOptions
|
||||
{
|
||||
PluginsDirectory = fullDirectory,
|
||||
EnsureDirectoryExists = false,
|
||||
RecursiveSearch = false,
|
||||
};
|
||||
options.SearchPatterns.Add(searchPattern);
|
||||
|
||||
var result = PluginHost.LoadPlugins(options);
|
||||
|
||||
foreach (var plugin in result.Plugins)
|
||||
{
|
||||
AddAssembly(plugin.Assembly);
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
public IReadOnlyList<IConnectorPlugin> GetConnectorPlugins() => PluginLoader.LoadPlugins<IConnectorPlugin>(_assemblies);
|
||||
|
||||
public IReadOnlyList<IExporterPlugin> GetExporterPlugins() => PluginLoader.LoadPlugins<IExporterPlugin>(_assemblies);
|
||||
|
||||
public IReadOnlyList<IConnectorPlugin> GetAvailableConnectorPlugins(IServiceProvider services)
|
||||
=> FilterAvailable(GetConnectorPlugins(), services);
|
||||
|
||||
public IReadOnlyList<IExporterPlugin> GetAvailableExporterPlugins(IServiceProvider services)
|
||||
=> FilterAvailable(GetExporterPlugins(), services);
|
||||
|
||||
private static IReadOnlyList<TPlugin> FilterAvailable<TPlugin>(IEnumerable<TPlugin> plugins, IServiceProvider services)
|
||||
where TPlugin : IAvailabilityPlugin
|
||||
{
|
||||
var list = new List<TPlugin>();
|
||||
foreach (var plugin in plugins)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (plugin.IsAvailable(services))
|
||||
{
|
||||
list.Add(plugin);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Treat exceptions as plugin not available.
|
||||
}
|
||||
}
|
||||
return list;
|
||||
}
|
||||
}
|
||||
|
||||
public static class PluginLoader
|
||||
{
|
||||
public static IReadOnlyList<TPlugin> LoadPlugins<TPlugin>(IEnumerable<Assembly> assemblies)
|
||||
where TPlugin : class
|
||||
{
|
||||
if (assemblies == null) throw new ArgumentNullException(nameof(assemblies));
|
||||
|
||||
var plugins = new List<TPlugin>();
|
||||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var assembly in assemblies)
|
||||
{
|
||||
foreach (var candidate in SafeGetTypes(assembly))
|
||||
{
|
||||
if (candidate.IsAbstract || candidate.IsInterface)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!typeof(TPlugin).IsAssignableFrom(candidate))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (Activator.CreateInstance(candidate) is not TPlugin plugin)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var key = candidate.FullName ?? candidate.Name;
|
||||
if (key is null || !seen.Add(key))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
plugins.Add(plugin);
|
||||
}
|
||||
}
|
||||
|
||||
return plugins;
|
||||
}
|
||||
|
||||
private static IEnumerable<Type> SafeGetTypes(Assembly assembly)
|
||||
{
|
||||
try
|
||||
{
|
||||
return assembly.GetTypes();
|
||||
}
|
||||
catch (ReflectionTypeLoadException ex)
|
||||
{
|
||||
return ex.Types.Where(t => t is not null)!;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("StellaOps.Plugin.Tests")]
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("StellaOps.Plugin.Tests")]
|
||||
|
||||
@@ -1,173 +0,0 @@
|
||||
using System;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace StellaOps.Provenance.Mongo;
|
||||
|
||||
// Minimal stubs to avoid MongoDB.Bson dependency while keeping callers unchanged.
|
||||
public abstract class BsonValue
|
||||
{
|
||||
public virtual object? Value => null;
|
||||
|
||||
public virtual BsonDocument AsBsonDocument =>
|
||||
this as BsonDocument ?? throw new InvalidCastException("Value is not a BsonDocument.");
|
||||
|
||||
public virtual BsonArray AsBsonArray =>
|
||||
this as BsonArray ?? throw new InvalidCastException("Value is not a BsonArray.");
|
||||
|
||||
public virtual string AsString => Value?.ToString() ?? string.Empty;
|
||||
public virtual int AsInt32 => Convert.ToInt32(Value);
|
||||
public virtual long AsInt64 => Convert.ToInt64(Value);
|
||||
public virtual double AsDouble => Convert.ToDouble(Value);
|
||||
public virtual bool AsBoolean => Convert.ToBoolean(Value);
|
||||
|
||||
internal static BsonValue Wrap(object? value) =>
|
||||
value switch
|
||||
{
|
||||
null => BsonNull.Value,
|
||||
BsonValue bson => bson,
|
||||
string s => new BsonString(s),
|
||||
bool b => new BsonBoolean(b),
|
||||
int i => new BsonInt32(i),
|
||||
long l => new BsonInt64(l),
|
||||
double d => new BsonDouble(d),
|
||||
IEnumerable<BsonValue> bsonEnumerable => new BsonArray(bsonEnumerable),
|
||||
IEnumerable enumerable => new BsonArray(enumerable.Cast<object?>()),
|
||||
_ => new BsonRaw(value)
|
||||
};
|
||||
}
|
||||
|
||||
public sealed class BsonNull : BsonValue
|
||||
{
|
||||
public static readonly BsonNull ValueInstance = new();
|
||||
public new static BsonNull Value => ValueInstance;
|
||||
}
|
||||
|
||||
public sealed class BsonString : BsonValue
|
||||
{
|
||||
public BsonString(string value) => Value = value;
|
||||
public override object? Value { get; }
|
||||
public override string ToString() => Value?.ToString() ?? string.Empty;
|
||||
}
|
||||
|
||||
public sealed class BsonBoolean : BsonValue
|
||||
{
|
||||
public BsonBoolean(bool value) => Value = value;
|
||||
public override object? Value { get; }
|
||||
}
|
||||
|
||||
public sealed class BsonInt32 : BsonValue
|
||||
{
|
||||
public BsonInt32(int value) => Value = value;
|
||||
public override object? Value { get; }
|
||||
}
|
||||
|
||||
public sealed class BsonInt64 : BsonValue
|
||||
{
|
||||
public BsonInt64(long value) => Value = value;
|
||||
public override object? Value { get; }
|
||||
}
|
||||
|
||||
public sealed class BsonDouble : BsonValue
|
||||
{
|
||||
public BsonDouble(double value) => Value = value;
|
||||
public override object? Value { get; }
|
||||
}
|
||||
|
||||
public sealed class BsonRaw : BsonValue
|
||||
{
|
||||
public BsonRaw(object value) => Value = value;
|
||||
public override object? Value { get; }
|
||||
}
|
||||
|
||||
public record struct BsonElement(string Name, BsonValue Value);
|
||||
|
||||
public class BsonDocument : BsonValue, IEnumerable<KeyValuePair<string, BsonValue>>
|
||||
{
|
||||
private readonly Dictionary<string, object?> _values = new(StringComparer.Ordinal);
|
||||
|
||||
public BsonDocument()
|
||||
{
|
||||
}
|
||||
|
||||
public BsonDocument(string key, object? value)
|
||||
{
|
||||
_values[key] = value;
|
||||
}
|
||||
|
||||
public BsonValue this[string key]
|
||||
{
|
||||
get => BsonValue.Wrap(_values[key]);
|
||||
set => _values[key] = value;
|
||||
}
|
||||
|
||||
public void Add(string key, object? value) => _values.Add(key, value);
|
||||
|
||||
public IEnumerable<BsonElement> Elements => _values.Select(kvp => new BsonElement(kvp.Key, BsonValue.Wrap(kvp.Value)));
|
||||
|
||||
public bool Contains(string key) => ContainsKey(key);
|
||||
|
||||
public bool ContainsKey(string key) => _values.ContainsKey(key);
|
||||
|
||||
public override object? Value => this;
|
||||
|
||||
public override BsonDocument AsBsonDocument => this;
|
||||
|
||||
public IEnumerator<KeyValuePair<string, BsonValue>> GetEnumerator() =>
|
||||
_values.Select(kvp => new KeyValuePair<string, BsonValue>(kvp.Key, BsonValue.Wrap(kvp.Value))).GetEnumerator();
|
||||
|
||||
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
|
||||
}
|
||||
|
||||
public class BsonArray : BsonValue, IEnumerable<BsonValue>
|
||||
{
|
||||
private readonly List<object?> _items = new();
|
||||
|
||||
public BsonArray()
|
||||
{
|
||||
}
|
||||
|
||||
public BsonArray(IEnumerable items)
|
||||
{
|
||||
foreach (var item in items)
|
||||
{
|
||||
Add(item);
|
||||
}
|
||||
}
|
||||
|
||||
public BsonArray(IEnumerable<object?> items)
|
||||
: this()
|
||||
{
|
||||
foreach (var item in items)
|
||||
{
|
||||
Add(item);
|
||||
}
|
||||
}
|
||||
|
||||
public BsonValue this[int index] => BsonValue.Wrap(_items[index]);
|
||||
|
||||
public void Add(BsonDocument doc) => _items.Add(doc);
|
||||
public void Add(object? value) => _items.Add(value);
|
||||
|
||||
public int Count => _items.Count;
|
||||
|
||||
public override object? Value => this;
|
||||
|
||||
public override BsonArray AsBsonArray => this;
|
||||
|
||||
public IEnumerator<BsonValue> GetEnumerator() => _items.Select(BsonValue.Wrap).GetEnumerator();
|
||||
|
||||
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
|
||||
}
|
||||
|
||||
public static class BsonValueExtensions
|
||||
{
|
||||
public static BsonDocument AsBsonDocument(this object? value) => BsonValue.Wrap(value).AsBsonDocument;
|
||||
public static BsonArray AsBsonArray(this object? value) => BsonValue.Wrap(value).AsBsonArray;
|
||||
public static string AsString(this object? value) => BsonValue.Wrap(value).AsString;
|
||||
public static int AsInt32(this object? value) => BsonValue.Wrap(value).AsInt32;
|
||||
public static long AsInt64(this object? value) => BsonValue.Wrap(value).AsInt64;
|
||||
public static double AsDouble(this object? value) => BsonValue.Wrap(value).AsDouble;
|
||||
public static bool AsBoolean(this object? value) => BsonValue.Wrap(value).AsBoolean;
|
||||
}
|
||||
173
src/__Libraries/StellaOps.Provenance/DocumentStubs.cs
Normal file
173
src/__Libraries/StellaOps.Provenance/DocumentStubs.cs
Normal file
@@ -0,0 +1,173 @@
|
||||
using System;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace StellaOps.Provenance;
|
||||
|
||||
// Minimal stubs for document serialization without external dependencies.
|
||||
public abstract class DocumentValue
|
||||
{
|
||||
public virtual object? Value => null;
|
||||
|
||||
public virtual DocumentObject AsDocumentObject =>
|
||||
this as DocumentObject ?? throw new InvalidCastException("Value is not a DocumentObject.");
|
||||
|
||||
public virtual DocumentArray AsDocumentArray =>
|
||||
this as DocumentArray ?? throw new InvalidCastException("Value is not a DocumentArray.");
|
||||
|
||||
public virtual string AsString => Value?.ToString() ?? string.Empty;
|
||||
public virtual int AsInt32 => Convert.ToInt32(Value);
|
||||
public virtual long AsInt64 => Convert.ToInt64(Value);
|
||||
public virtual double AsDouble => Convert.ToDouble(Value);
|
||||
public virtual bool AsBoolean => Convert.ToBoolean(Value);
|
||||
|
||||
internal static DocumentValue Wrap(object? value) =>
|
||||
value switch
|
||||
{
|
||||
null => DocumentNull.Value,
|
||||
DocumentValue doc => doc,
|
||||
string s => new DocumentString(s),
|
||||
bool b => new DocumentBoolean(b),
|
||||
int i => new DocumentInt32(i),
|
||||
long l => new DocumentInt64(l),
|
||||
double d => new DocumentDouble(d),
|
||||
IEnumerable<DocumentValue> docEnumerable => new DocumentArray(docEnumerable),
|
||||
IEnumerable enumerable => new DocumentArray(enumerable.Cast<object?>()),
|
||||
_ => new DocumentRaw(value)
|
||||
};
|
||||
}
|
||||
|
||||
public sealed class DocumentNull : DocumentValue
|
||||
{
|
||||
public static readonly DocumentNull ValueInstance = new();
|
||||
public new static DocumentNull Value => ValueInstance;
|
||||
}
|
||||
|
||||
public sealed class DocumentString : DocumentValue
|
||||
{
|
||||
public DocumentString(string value) => Value = value;
|
||||
public override object? Value { get; }
|
||||
public override string ToString() => Value?.ToString() ?? string.Empty;
|
||||
}
|
||||
|
||||
public sealed class DocumentBoolean : DocumentValue
|
||||
{
|
||||
public DocumentBoolean(bool value) => Value = value;
|
||||
public override object? Value { get; }
|
||||
}
|
||||
|
||||
public sealed class DocumentInt32 : DocumentValue
|
||||
{
|
||||
public DocumentInt32(int value) => Value = value;
|
||||
public override object? Value { get; }
|
||||
}
|
||||
|
||||
public sealed class DocumentInt64 : DocumentValue
|
||||
{
|
||||
public DocumentInt64(long value) => Value = value;
|
||||
public override object? Value { get; }
|
||||
}
|
||||
|
||||
public sealed class DocumentDouble : DocumentValue
|
||||
{
|
||||
public DocumentDouble(double value) => Value = value;
|
||||
public override object? Value { get; }
|
||||
}
|
||||
|
||||
public sealed class DocumentRaw : DocumentValue
|
||||
{
|
||||
public DocumentRaw(object value) => Value = value;
|
||||
public override object? Value { get; }
|
||||
}
|
||||
|
||||
public record struct DocumentElement(string Name, DocumentValue Value);
|
||||
|
||||
public class DocumentObject : DocumentValue, IEnumerable<KeyValuePair<string, DocumentValue>>
|
||||
{
|
||||
private readonly Dictionary<string, object?> _values = new(StringComparer.Ordinal);
|
||||
|
||||
public DocumentObject()
|
||||
{
|
||||
}
|
||||
|
||||
public DocumentObject(string key, object? value)
|
||||
{
|
||||
_values[key] = value;
|
||||
}
|
||||
|
||||
public DocumentValue this[string key]
|
||||
{
|
||||
get => DocumentValue.Wrap(_values[key]);
|
||||
set => _values[key] = value;
|
||||
}
|
||||
|
||||
public void Add(string key, object? value) => _values.Add(key, value);
|
||||
|
||||
public IEnumerable<DocumentElement> Elements => _values.Select(kvp => new DocumentElement(kvp.Key, DocumentValue.Wrap(kvp.Value)));
|
||||
|
||||
public bool Contains(string key) => ContainsKey(key);
|
||||
|
||||
public bool ContainsKey(string key) => _values.ContainsKey(key);
|
||||
|
||||
public override object? Value => this;
|
||||
|
||||
public override DocumentObject AsDocumentObject => this;
|
||||
|
||||
public IEnumerator<KeyValuePair<string, DocumentValue>> GetEnumerator() =>
|
||||
_values.Select(kvp => new KeyValuePair<string, DocumentValue>(kvp.Key, DocumentValue.Wrap(kvp.Value))).GetEnumerator();
|
||||
|
||||
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
|
||||
}
|
||||
|
||||
public class DocumentArray : DocumentValue, IEnumerable<DocumentValue>
|
||||
{
|
||||
private readonly List<object?> _items = new();
|
||||
|
||||
public DocumentArray()
|
||||
{
|
||||
}
|
||||
|
||||
public DocumentArray(IEnumerable items)
|
||||
{
|
||||
foreach (var item in items)
|
||||
{
|
||||
Add(item);
|
||||
}
|
||||
}
|
||||
|
||||
public DocumentArray(IEnumerable<object?> items)
|
||||
: this()
|
||||
{
|
||||
foreach (var item in items)
|
||||
{
|
||||
Add(item);
|
||||
}
|
||||
}
|
||||
|
||||
public DocumentValue this[int index] => DocumentValue.Wrap(_items[index]);
|
||||
|
||||
public void Add(DocumentObject doc) => _items.Add(doc);
|
||||
public void Add(object? value) => _items.Add(value);
|
||||
|
||||
public int Count => _items.Count;
|
||||
|
||||
public override object? Value => this;
|
||||
|
||||
public override DocumentArray AsDocumentArray => this;
|
||||
|
||||
public IEnumerator<DocumentValue> GetEnumerator() => _items.Select(DocumentValue.Wrap).GetEnumerator();
|
||||
|
||||
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
|
||||
}
|
||||
|
||||
public static class DocumentValueExtensions
|
||||
{
|
||||
public static DocumentObject AsDocumentObject(this object? value) => DocumentValue.Wrap(value).AsDocumentObject;
|
||||
public static DocumentArray AsDocumentArray(this object? value) => DocumentValue.Wrap(value).AsDocumentArray;
|
||||
public static string AsString(this object? value) => DocumentValue.Wrap(value).AsString;
|
||||
public static int AsInt32(this object? value) => DocumentValue.Wrap(value).AsInt32;
|
||||
public static long AsInt64(this object? value) => DocumentValue.Wrap(value).AsInt64;
|
||||
public static double AsDouble(this object? value) => DocumentValue.Wrap(value).AsDouble;
|
||||
public static bool AsBoolean(this object? value) => DocumentValue.Wrap(value).AsBoolean;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Provenance.Mongo;
|
||||
namespace StellaOps.Provenance;
|
||||
|
||||
public sealed class DsseKeyInfo
|
||||
{
|
||||
@@ -1,20 +1,20 @@
|
||||
namespace StellaOps.Provenance.Mongo;
|
||||
namespace StellaOps.Provenance;
|
||||
|
||||
public static class ProvenanceMongoExtensions
|
||||
public static class ProvenanceExtensions
|
||||
{
|
||||
private const string ProvenanceFieldName = "provenance";
|
||||
private const string DsseFieldName = "dsse";
|
||||
private const string TrustFieldName = "trust";
|
||||
private const string ChainFieldName = "chain";
|
||||
private static BsonValue StringOrNull(string? value) =>
|
||||
value is null ? BsonNull.Value : new BsonString(value);
|
||||
private static DocumentValue StringOrNull(string? value) =>
|
||||
value is null ? DocumentNull.Value : new DocumentString(value);
|
||||
|
||||
/// <summary>
|
||||
/// Attach DSSE provenance + trust info to an event document in-place.
|
||||
/// Designed for generic BsonDocument-based event envelopes.
|
||||
/// Designed for generic DocumentObject-based event envelopes.
|
||||
/// </summary>
|
||||
public static BsonDocument AttachDsseProvenance(
|
||||
this BsonDocument eventDoc,
|
||||
public static DocumentObject AttachDsseProvenance(
|
||||
this DocumentObject eventDoc,
|
||||
DsseProvenance dsse,
|
||||
TrustInfo trust)
|
||||
{
|
||||
@@ -22,11 +22,11 @@ public static class ProvenanceMongoExtensions
|
||||
if (dsse is null) throw new ArgumentNullException(nameof(dsse));
|
||||
if (trust is null) throw new ArgumentNullException(nameof(trust));
|
||||
|
||||
var dsseDoc = new BsonDocument
|
||||
var dsseDoc = new DocumentObject
|
||||
{
|
||||
{ "envelopeDigest", dsse.EnvelopeDigest },
|
||||
{ "payloadType", dsse.PayloadType },
|
||||
{ "key", new BsonDocument
|
||||
{ "key", new DocumentObject
|
||||
{
|
||||
{ "keyId", dsse.Key.KeyId },
|
||||
{ "issuer", StringOrNull(dsse.Key.Issuer) },
|
||||
@@ -37,7 +37,7 @@ public static class ProvenanceMongoExtensions
|
||||
|
||||
if (dsse.Rekor is not null)
|
||||
{
|
||||
var rekorDoc = new BsonDocument
|
||||
var rekorDoc = new DocumentObject
|
||||
{
|
||||
{ "logIndex", dsse.Rekor.LogIndex },
|
||||
{ "uuid", dsse.Rekor.Uuid }
|
||||
@@ -54,10 +54,10 @@ public static class ProvenanceMongoExtensions
|
||||
|
||||
if (dsse.Chain is not null && dsse.Chain.Count > 0)
|
||||
{
|
||||
var chainArray = new BsonArray();
|
||||
var chainArray = new DocumentArray();
|
||||
foreach (var link in dsse.Chain)
|
||||
{
|
||||
chainArray.Add(new BsonDocument
|
||||
chainArray.Add(new DocumentObject
|
||||
{
|
||||
{ "type", link.Type },
|
||||
{ "id", link.Id },
|
||||
@@ -68,7 +68,7 @@ public static class ProvenanceMongoExtensions
|
||||
dsseDoc.Add(ChainFieldName, chainArray);
|
||||
}
|
||||
|
||||
var trustDoc = new BsonDocument
|
||||
var trustDoc = new DocumentObject
|
||||
{
|
||||
{ "verified", trust.Verified },
|
||||
{ "verifier", StringOrNull(trust.Verifier) }
|
||||
@@ -80,7 +80,7 @@ public static class ProvenanceMongoExtensions
|
||||
if (trust.PolicyScore is not null)
|
||||
trustDoc.Add("policyScore", trust.PolicyScore);
|
||||
|
||||
var provenanceDoc = new BsonDocument
|
||||
var provenanceDoc = new DocumentObject
|
||||
{
|
||||
{ DsseFieldName, dsseDoc }
|
||||
};
|
||||
@@ -95,15 +95,15 @@ public static class ProvenanceMongoExtensions
|
||||
/// Helper to query for "cryptographically proven" events:
|
||||
/// kind + subject.digest.sha256 + presence of Rekor logIndex + trust.verified = true.
|
||||
/// </summary>
|
||||
public static BsonDocument BuildProvenVexFilter(
|
||||
public static DocumentObject BuildProvenVexFilter(
|
||||
string kind,
|
||||
string subjectDigestSha256)
|
||||
{
|
||||
return new BsonDocument
|
||||
return new DocumentObject
|
||||
{
|
||||
{ "kind", kind },
|
||||
{ "subject.digest.sha256", subjectDigestSha256 },
|
||||
{ $"{ProvenanceFieldName}.{DsseFieldName}.rekor.logIndex", new BsonDocument("$exists", true) },
|
||||
{ $"{ProvenanceFieldName}.{DsseFieldName}.rekor.logIndex", new DocumentObject("$exists", true) },
|
||||
{ $"{TrustFieldName}.verified", true }
|
||||
};
|
||||
}
|
||||
@@ -111,27 +111,27 @@ public static class ProvenanceMongoExtensions
|
||||
/// <summary>
|
||||
/// Helper to query for events influencing policy without solid provenance.
|
||||
/// </summary>
|
||||
public static BsonDocument BuildUnprovenEvidenceFilter(
|
||||
public static DocumentObject BuildUnprovenEvidenceFilter(
|
||||
IEnumerable<string> kinds)
|
||||
{
|
||||
var kindsArray = new BsonArray(kinds);
|
||||
var kindsArray = new DocumentArray(kinds);
|
||||
|
||||
return new BsonDocument
|
||||
return new DocumentObject
|
||||
{
|
||||
{
|
||||
"kind", new BsonDocument("$in", kindsArray)
|
||||
"kind", new DocumentObject("$in", kindsArray)
|
||||
},
|
||||
{
|
||||
"$or", new BsonArray
|
||||
"$or", new DocumentArray
|
||||
{
|
||||
new BsonDocument
|
||||
new DocumentObject
|
||||
{
|
||||
{ $"{TrustFieldName}.verified", new BsonDocument("$ne", true) }
|
||||
{ $"{TrustFieldName}.verified", new DocumentObject("$ne", true) }
|
||||
},
|
||||
new BsonDocument
|
||||
new DocumentObject
|
||||
{
|
||||
{ $"{ProvenanceFieldName}.{DsseFieldName}.rekor.logIndex",
|
||||
new BsonDocument("$exists", false) }
|
||||
new DocumentObject("$exists", false) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Provenance.Mongo;
|
||||
namespace StellaOps.Provenance;
|
||||
|
||||
public static class ProvenanceJsonParser
|
||||
{
|
||||
@@ -1,7 +1,6 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Bson.Serialization.Attributes;
|
||||
using StellaOps.Replay.Serialization;
|
||||
|
||||
namespace StellaOps.Replay.Core;
|
||||
|
||||
@@ -12,10 +11,10 @@ public static class ReplayCollections
|
||||
public const string Subjects = "replay_subjects";
|
||||
}
|
||||
|
||||
[BsonIgnoreExtraElements]
|
||||
[IgnoreExtraElements]
|
||||
public sealed class ReplayRunRecord
|
||||
{
|
||||
[BsonId]
|
||||
[Id]
|
||||
public string Id { get; set; } = string.Empty; // scan UUID
|
||||
|
||||
public string ManifestHash { get; set; } = string.Empty; // sha256:...
|
||||
@@ -48,10 +47,10 @@ public sealed class ReplaySignatureRecord
|
||||
= false;
|
||||
}
|
||||
|
||||
[BsonIgnoreExtraElements]
|
||||
[IgnoreExtraElements]
|
||||
public sealed class ReplayBundleRecord
|
||||
{
|
||||
[BsonId]
|
||||
[Id]
|
||||
public string Id { get; set; } = string.Empty; // sha256 hex
|
||||
|
||||
public string Type { get; set; } = string.Empty; // input|output|rootpack|reachability
|
||||
@@ -64,10 +63,10 @@ public sealed class ReplayBundleRecord
|
||||
public DateTime CreatedAt { get; set; } = DateTime.SpecifyKind(DateTime.UnixEpoch, DateTimeKind.Utc);
|
||||
}
|
||||
|
||||
[BsonIgnoreExtraElements]
|
||||
[IgnoreExtraElements]
|
||||
public sealed class ReplaySubjectRecord
|
||||
{
|
||||
[BsonId]
|
||||
[Id]
|
||||
public string OciDigest { get; set; } = string.Empty;
|
||||
|
||||
public List<ReplayLayerRecord> Layers { get; set; } = new();
|
||||
@@ -82,7 +81,7 @@ public sealed class ReplayLayerRecord
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Index names to keep mongod migrations deterministic.
|
||||
/// Index names to keep database migrations deterministic.
|
||||
/// </summary>
|
||||
public static class ReplayIndexes
|
||||
{
|
||||
15
src/__Libraries/StellaOps.Replay.Core/SerializationCompat.cs
Normal file
15
src/__Libraries/StellaOps.Replay.Core/SerializationCompat.cs
Normal file
@@ -0,0 +1,15 @@
|
||||
namespace StellaOps.Replay.Serialization;
|
||||
|
||||
/// <summary>
|
||||
/// Compatibility attribute to mark a property as the document ID.
|
||||
/// This is a no-op shim for document serialization.
|
||||
/// </summary>
|
||||
[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field)]
|
||||
public sealed class IdAttribute : Attribute;
|
||||
|
||||
/// <summary>
|
||||
/// Compatibility attribute to ignore extra elements during deserialization.
|
||||
/// This is a no-op shim for document serialization.
|
||||
/// </summary>
|
||||
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct)]
|
||||
public sealed class IgnoreExtraElementsAttribute : Attribute;
|
||||
@@ -6,7 +6,6 @@
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="ZstdSharp.Port" Version="0.8.6" />
|
||||
<PackageReference Include="MongoDB.Bson" Version="2.25.0" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.Cryptography\StellaOps.Cryptography.csproj" />
|
||||
|
||||
@@ -4,22 +4,22 @@ using System.Globalization;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using StellaOps.Configuration;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Configuration.Tests;
|
||||
|
||||
public class StellaOpsAuthorityOptionsTests
|
||||
{
|
||||
[Fact]
|
||||
public void Validate_Throws_When_IssuerMissing()
|
||||
{
|
||||
var options = new StellaOpsAuthorityOptions();
|
||||
|
||||
var exception = Assert.Throws<InvalidOperationException>(() => options.Validate());
|
||||
|
||||
Assert.Contains("issuer", exception.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
namespace StellaOps.Configuration.Tests;
|
||||
|
||||
public class StellaOpsAuthorityOptionsTests
|
||||
{
|
||||
[Fact]
|
||||
public void Validate_Throws_When_IssuerMissing()
|
||||
{
|
||||
var options = new StellaOpsAuthorityOptions();
|
||||
|
||||
var exception = Assert.Throws<InvalidOperationException>(() => options.Validate());
|
||||
|
||||
Assert.Contains("issuer", exception.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_Normalises_Collections()
|
||||
{
|
||||
var options = new StellaOpsAuthorityOptions
|
||||
@@ -67,35 +67,35 @@ public class StellaOpsAuthorityOptionsTests
|
||||
|
||||
Assert.Contains("remote inference", exception.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
[Fact]
|
||||
public void Validate_Normalises_PluginDescriptors()
|
||||
{
|
||||
var options = new StellaOpsAuthorityOptions
|
||||
{
|
||||
Issuer = new Uri("https://authority.stella-ops.test"),
|
||||
SchemaVersion = 1
|
||||
};
|
||||
options.Storage.ConnectionString = "mongodb://localhost:27017/authority";
|
||||
options.Signing.ActiveKeyId = "test-key";
|
||||
options.Signing.KeyPath = "/tmp/test-key.pem";
|
||||
|
||||
var descriptor = new AuthorityPluginDescriptorOptions
|
||||
{
|
||||
AssemblyName = "StellaOps.Authority.Plugin.Standard",
|
||||
ConfigFile = " standard.yaml ",
|
||||
Enabled = true
|
||||
};
|
||||
|
||||
descriptor.Capabilities.Add("password");
|
||||
descriptor.Capabilities.Add("PASSWORD");
|
||||
options.Plugins.Descriptors["standard"] = descriptor;
|
||||
|
||||
options.Validate();
|
||||
|
||||
var normalized = options.Plugins.Descriptors["standard"];
|
||||
Assert.Equal("standard.yaml", normalized.ConfigFile);
|
||||
Assert.Single(normalized.Capabilities);
|
||||
SchemaVersion = 1
|
||||
};
|
||||
options.Storage.ConnectionString = "mongodb://localhost:27017/authority";
|
||||
options.Signing.ActiveKeyId = "test-key";
|
||||
options.Signing.KeyPath = "/tmp/test-key.pem";
|
||||
|
||||
var descriptor = new AuthorityPluginDescriptorOptions
|
||||
{
|
||||
AssemblyName = "StellaOps.Authority.Plugin.Standard",
|
||||
ConfigFile = " standard.yaml ",
|
||||
Enabled = true
|
||||
};
|
||||
|
||||
descriptor.Capabilities.Add("password");
|
||||
descriptor.Capabilities.Add("PASSWORD");
|
||||
options.Plugins.Descriptors["standard"] = descriptor;
|
||||
|
||||
options.Validate();
|
||||
|
||||
var normalized = options.Plugins.Descriptors["standard"];
|
||||
Assert.Equal("standard.yaml", normalized.ConfigFile);
|
||||
Assert.Single(normalized.Capabilities);
|
||||
Assert.Equal("password", normalized.Capabilities[0]);
|
||||
}
|
||||
|
||||
@@ -163,149 +163,149 @@ public class StellaOpsAuthorityOptionsTests
|
||||
|
||||
Assert.Contains("consentVersion", exception.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_Throws_When_StorageConnectionStringMissing()
|
||||
{
|
||||
var options = new StellaOpsAuthorityOptions
|
||||
{
|
||||
Issuer = new Uri("https://authority.stella-ops.test"),
|
||||
SchemaVersion = 1
|
||||
};
|
||||
options.Signing.ActiveKeyId = "test-key";
|
||||
options.Signing.KeyPath = "/tmp/test-key.pem";
|
||||
|
||||
var exception = Assert.Throws<InvalidOperationException>(() => options.Validate());
|
||||
|
||||
Assert.Contains("Mongo connection string", exception.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_Binds_From_Configuration()
|
||||
{
|
||||
var context = StellaOpsAuthorityConfiguration.Build(options =>
|
||||
{
|
||||
options.ConfigureBuilder = builder =>
|
||||
{
|
||||
builder.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["Authority:SchemaVersion"] = "2",
|
||||
["Authority:Issuer"] = "https://authority.internal",
|
||||
["Authority:AccessTokenLifetime"] = "00:30:00",
|
||||
["Authority:RefreshTokenLifetime"] = "30.00:00:00",
|
||||
["Authority:Storage:ConnectionString"] = "mongodb://example/stellaops",
|
||||
["Authority:Storage:DatabaseName"] = "overrideDb",
|
||||
["Authority:Storage:CommandTimeout"] = "00:01:30",
|
||||
["Authority:PluginDirectories:0"] = "/var/lib/stellaops/plugins",
|
||||
["Authority:BypassNetworks:0"] = "127.0.0.1/32",
|
||||
["Authority:Security:RateLimiting:Token:PermitLimit"] = "25",
|
||||
["Authority:Security:RateLimiting:Token:Window"] = "00:00:30",
|
||||
["Authority:Security:RateLimiting:Authorize:Enabled"] = "true",
|
||||
["Authority:Security:RateLimiting:Internal:Enabled"] = "true",
|
||||
["Authority:Security:RateLimiting:Internal:PermitLimit"] = "3",
|
||||
["Authority:Signing:Enabled"] = "true",
|
||||
["Authority:Signing:ActiveKeyId"] = "authority-signing-dev",
|
||||
["Authority:Signing:KeyPath"] = "../certificates/authority-signing-dev.pem",
|
||||
["Authority:Signing:KeySource"] = "file"
|
||||
});
|
||||
};
|
||||
});
|
||||
|
||||
var options = context.Options;
|
||||
|
||||
Assert.Equal(2, options.SchemaVersion);
|
||||
Assert.Equal(new Uri("https://authority.internal"), options.Issuer);
|
||||
Assert.Equal(TimeSpan.FromMinutes(30), options.AccessTokenLifetime);
|
||||
Assert.Equal(TimeSpan.FromDays(30), options.RefreshTokenLifetime);
|
||||
Assert.Equal(new[] { "/var/lib/stellaops/plugins" }, options.PluginDirectories);
|
||||
Assert.Equal(new[] { "127.0.0.1/32" }, options.BypassNetworks);
|
||||
Assert.Equal("mongodb://example/stellaops", options.Storage.ConnectionString);
|
||||
Assert.Equal("overrideDb", options.Storage.DatabaseName);
|
||||
Assert.Equal(TimeSpan.FromMinutes(1.5), options.Storage.CommandTimeout);
|
||||
Assert.Equal(25, options.Security.RateLimiting.Token.PermitLimit);
|
||||
Assert.Equal(TimeSpan.FromSeconds(30), options.Security.RateLimiting.Token.Window);
|
||||
Assert.True(options.Security.RateLimiting.Authorize.Enabled);
|
||||
Assert.True(options.Security.RateLimiting.Internal.Enabled);
|
||||
Assert.Equal(3, options.Security.RateLimiting.Internal.PermitLimit);
|
||||
Assert.True(options.Signing.Enabled);
|
||||
Assert.Equal("authority-signing-dev", options.Signing.ActiveKeyId);
|
||||
Assert.Equal("../certificates/authority-signing-dev.pem", options.Signing.KeyPath);
|
||||
Assert.Equal("file", options.Signing.KeySource);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_Normalises_ExceptionRoutingTemplates()
|
||||
{
|
||||
var options = new StellaOpsAuthorityOptions
|
||||
{
|
||||
Issuer = new Uri("https://authority.stella-ops.test"),
|
||||
SchemaVersion = 1
|
||||
};
|
||||
options.Storage.ConnectionString = "mongodb://localhost:27017/authority";
|
||||
options.Signing.ActiveKeyId = "test-key";
|
||||
options.Signing.KeyPath = "/tmp/test-key.pem";
|
||||
|
||||
options.Exceptions.RoutingTemplates.Add(new AuthorityExceptionRoutingTemplateOptions
|
||||
{
|
||||
Id = " SecOps ",
|
||||
AuthorityRouteId = " approvals/secops ",
|
||||
RequireMfa = true,
|
||||
Description = " Security approvals "
|
||||
});
|
||||
|
||||
options.Validate();
|
||||
|
||||
Assert.True(options.Exceptions.RequiresMfaForApprovals);
|
||||
var template = Assert.Single(options.Exceptions.NormalizedRoutingTemplates);
|
||||
Assert.Equal("SecOps", template.Key);
|
||||
Assert.Equal("SecOps", template.Value.Id);
|
||||
Assert.Equal("approvals/secops", template.Value.AuthorityRouteId);
|
||||
Assert.Equal("Security approvals", template.Value.Description);
|
||||
Assert.True(template.Value.RequireMfa);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_Throws_When_ExceptionRoutingTemplatesDuplicate()
|
||||
{
|
||||
var options = new StellaOpsAuthorityOptions
|
||||
{
|
||||
Issuer = new Uri("https://authority.stella-ops.test"),
|
||||
SchemaVersion = 1
|
||||
};
|
||||
options.Storage.ConnectionString = "mongodb://localhost:27017/authority";
|
||||
options.Signing.ActiveKeyId = "test-key";
|
||||
options.Signing.KeyPath = "/tmp/test-key.pem";
|
||||
|
||||
options.Exceptions.RoutingTemplates.Add(new AuthorityExceptionRoutingTemplateOptions
|
||||
{
|
||||
Id = "secops",
|
||||
AuthorityRouteId = "route/a"
|
||||
});
|
||||
options.Exceptions.RoutingTemplates.Add(new AuthorityExceptionRoutingTemplateOptions
|
||||
{
|
||||
Id = "SecOps",
|
||||
AuthorityRouteId = "route/b"
|
||||
});
|
||||
|
||||
var exception = Assert.Throws<InvalidOperationException>(() => options.Validate());
|
||||
Assert.Contains("secops", exception.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_Throws_When_RateLimitingInvalid()
|
||||
{
|
||||
var options = new StellaOpsAuthorityOptions
|
||||
{
|
||||
Issuer = new Uri("https://authority.stella-ops.test"),
|
||||
SchemaVersion = 1
|
||||
};
|
||||
options.Storage.ConnectionString = "mongodb://localhost:27017/authority";
|
||||
options.Security.RateLimiting.Token.PermitLimit = 0;
|
||||
options.Signing.ActiveKeyId = "test-key";
|
||||
options.Signing.KeyPath = "/tmp/test-key.pem";
|
||||
|
||||
var exception = Assert.Throws<InvalidOperationException>(() => options.Validate());
|
||||
|
||||
Assert.Contains("permitLimit", exception.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_Throws_When_StorageConnectionStringMissing()
|
||||
{
|
||||
var options = new StellaOpsAuthorityOptions
|
||||
{
|
||||
Issuer = new Uri("https://authority.stella-ops.test"),
|
||||
SchemaVersion = 1
|
||||
};
|
||||
options.Signing.ActiveKeyId = "test-key";
|
||||
options.Signing.KeyPath = "/tmp/test-key.pem";
|
||||
|
||||
var exception = Assert.Throws<InvalidOperationException>(() => options.Validate());
|
||||
|
||||
Assert.Contains("Mongo connection string", exception.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_Binds_From_Configuration()
|
||||
{
|
||||
var context = StellaOpsAuthorityConfiguration.Build(options =>
|
||||
{
|
||||
options.ConfigureBuilder = builder =>
|
||||
{
|
||||
builder.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["Authority:SchemaVersion"] = "2",
|
||||
["Authority:Issuer"] = "https://authority.internal",
|
||||
["Authority:AccessTokenLifetime"] = "00:30:00",
|
||||
["Authority:RefreshTokenLifetime"] = "30.00:00:00",
|
||||
["Authority:Storage:ConnectionString"] = "mongodb://example/stellaops",
|
||||
["Authority:Storage:DatabaseName"] = "overrideDb",
|
||||
["Authority:Storage:CommandTimeout"] = "00:01:30",
|
||||
["Authority:PluginDirectories:0"] = "/var/lib/stellaops/plugins",
|
||||
["Authority:BypassNetworks:0"] = "127.0.0.1/32",
|
||||
["Authority:Security:RateLimiting:Token:PermitLimit"] = "25",
|
||||
["Authority:Security:RateLimiting:Token:Window"] = "00:00:30",
|
||||
["Authority:Security:RateLimiting:Authorize:Enabled"] = "true",
|
||||
["Authority:Security:RateLimiting:Internal:Enabled"] = "true",
|
||||
["Authority:Security:RateLimiting:Internal:PermitLimit"] = "3",
|
||||
["Authority:Signing:Enabled"] = "true",
|
||||
["Authority:Signing:ActiveKeyId"] = "authority-signing-dev",
|
||||
["Authority:Signing:KeyPath"] = "../certificates/authority-signing-dev.pem",
|
||||
["Authority:Signing:KeySource"] = "file"
|
||||
});
|
||||
};
|
||||
});
|
||||
|
||||
var options = context.Options;
|
||||
|
||||
Assert.Equal(2, options.SchemaVersion);
|
||||
Assert.Equal(new Uri("https://authority.internal"), options.Issuer);
|
||||
Assert.Equal(TimeSpan.FromMinutes(30), options.AccessTokenLifetime);
|
||||
Assert.Equal(TimeSpan.FromDays(30), options.RefreshTokenLifetime);
|
||||
Assert.Equal(new[] { "/var/lib/stellaops/plugins" }, options.PluginDirectories);
|
||||
Assert.Equal(new[] { "127.0.0.1/32" }, options.BypassNetworks);
|
||||
Assert.Equal("mongodb://example/stellaops", options.Storage.ConnectionString);
|
||||
Assert.Equal("overrideDb", options.Storage.DatabaseName);
|
||||
Assert.Equal(TimeSpan.FromMinutes(1.5), options.Storage.CommandTimeout);
|
||||
Assert.Equal(25, options.Security.RateLimiting.Token.PermitLimit);
|
||||
Assert.Equal(TimeSpan.FromSeconds(30), options.Security.RateLimiting.Token.Window);
|
||||
Assert.True(options.Security.RateLimiting.Authorize.Enabled);
|
||||
Assert.True(options.Security.RateLimiting.Internal.Enabled);
|
||||
Assert.Equal(3, options.Security.RateLimiting.Internal.PermitLimit);
|
||||
Assert.True(options.Signing.Enabled);
|
||||
Assert.Equal("authority-signing-dev", options.Signing.ActiveKeyId);
|
||||
Assert.Equal("../certificates/authority-signing-dev.pem", options.Signing.KeyPath);
|
||||
Assert.Equal("file", options.Signing.KeySource);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_Normalises_ExceptionRoutingTemplates()
|
||||
{
|
||||
var options = new StellaOpsAuthorityOptions
|
||||
{
|
||||
Issuer = new Uri("https://authority.stella-ops.test"),
|
||||
SchemaVersion = 1
|
||||
};
|
||||
options.Storage.ConnectionString = "mongodb://localhost:27017/authority";
|
||||
options.Signing.ActiveKeyId = "test-key";
|
||||
options.Signing.KeyPath = "/tmp/test-key.pem";
|
||||
|
||||
options.Exceptions.RoutingTemplates.Add(new AuthorityExceptionRoutingTemplateOptions
|
||||
{
|
||||
Id = " SecOps ",
|
||||
AuthorityRouteId = " approvals/secops ",
|
||||
RequireMfa = true,
|
||||
Description = " Security approvals "
|
||||
});
|
||||
|
||||
options.Validate();
|
||||
|
||||
Assert.True(options.Exceptions.RequiresMfaForApprovals);
|
||||
var template = Assert.Single(options.Exceptions.NormalizedRoutingTemplates);
|
||||
Assert.Equal("SecOps", template.Key);
|
||||
Assert.Equal("SecOps", template.Value.Id);
|
||||
Assert.Equal("approvals/secops", template.Value.AuthorityRouteId);
|
||||
Assert.Equal("Security approvals", template.Value.Description);
|
||||
Assert.True(template.Value.RequireMfa);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_Throws_When_ExceptionRoutingTemplatesDuplicate()
|
||||
{
|
||||
var options = new StellaOpsAuthorityOptions
|
||||
{
|
||||
Issuer = new Uri("https://authority.stella-ops.test"),
|
||||
SchemaVersion = 1
|
||||
};
|
||||
options.Storage.ConnectionString = "mongodb://localhost:27017/authority";
|
||||
options.Signing.ActiveKeyId = "test-key";
|
||||
options.Signing.KeyPath = "/tmp/test-key.pem";
|
||||
|
||||
options.Exceptions.RoutingTemplates.Add(new AuthorityExceptionRoutingTemplateOptions
|
||||
{
|
||||
Id = "secops",
|
||||
AuthorityRouteId = "route/a"
|
||||
});
|
||||
options.Exceptions.RoutingTemplates.Add(new AuthorityExceptionRoutingTemplateOptions
|
||||
{
|
||||
Id = "SecOps",
|
||||
AuthorityRouteId = "route/b"
|
||||
});
|
||||
|
||||
var exception = Assert.Throws<InvalidOperationException>(() => options.Validate());
|
||||
Assert.Contains("secops", exception.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_Throws_When_RateLimitingInvalid()
|
||||
{
|
||||
var options = new StellaOpsAuthorityOptions
|
||||
{
|
||||
Issuer = new Uri("https://authority.stella-ops.test"),
|
||||
SchemaVersion = 1
|
||||
};
|
||||
options.Storage.ConnectionString = "mongodb://localhost:27017/authority";
|
||||
options.Security.RateLimiting.Token.PermitLimit = 0;
|
||||
options.Signing.ActiveKeyId = "test-key";
|
||||
options.Signing.KeyPath = "/tmp/test-key.pem";
|
||||
|
||||
var exception = Assert.Throws<InvalidOperationException>(() => options.Validate());
|
||||
|
||||
Assert.Contains("permitLimit", exception.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,57 +1,57 @@
|
||||
using System;
|
||||
using StellaOps.Cryptography.Audit;
|
||||
|
||||
namespace StellaOps.Cryptography.Tests.Audit;
|
||||
|
||||
public class AuthEventRecordTests
|
||||
{
|
||||
[Fact]
|
||||
public void AuthEventRecord_InitializesCollections()
|
||||
{
|
||||
var record = new AuthEventRecord
|
||||
{
|
||||
EventType = "authority.test",
|
||||
Outcome = AuthEventOutcome.Success
|
||||
};
|
||||
|
||||
Assert.NotNull(record.Scopes);
|
||||
Assert.Empty(record.Scopes);
|
||||
Assert.NotNull(record.Properties);
|
||||
Assert.Empty(record.Properties);
|
||||
Assert.False(record.Tenant.HasValue);
|
||||
Assert.False(record.Project.HasValue);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClassifiedString_NormalizesWhitespace()
|
||||
{
|
||||
var value = ClassifiedString.Personal(" ");
|
||||
Assert.Null(value.Value);
|
||||
Assert.False(value.HasValue);
|
||||
Assert.Equal(AuthEventDataClassification.Personal, value.Classification);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Subject_DefaultsToEmptyCollections()
|
||||
{
|
||||
var subject = new AuthEventSubject();
|
||||
Assert.NotNull(subject.Attributes);
|
||||
Assert.Empty(subject.Attributes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Record_AssignsTimestamp_WhenNotProvided()
|
||||
{
|
||||
var record = new AuthEventRecord
|
||||
{
|
||||
EventType = "authority.test",
|
||||
Outcome = AuthEventOutcome.Success
|
||||
};
|
||||
|
||||
Assert.NotEqual(default, record.OccurredAt);
|
||||
Assert.InRange(
|
||||
record.OccurredAt,
|
||||
DateTimeOffset.UtcNow.AddSeconds(-5),
|
||||
DateTimeOffset.UtcNow.AddSeconds(5));
|
||||
}
|
||||
}
|
||||
using System;
|
||||
using StellaOps.Cryptography.Audit;
|
||||
|
||||
namespace StellaOps.Cryptography.Tests.Audit;
|
||||
|
||||
public class AuthEventRecordTests
|
||||
{
|
||||
[Fact]
|
||||
public void AuthEventRecord_InitializesCollections()
|
||||
{
|
||||
var record = new AuthEventRecord
|
||||
{
|
||||
EventType = "authority.test",
|
||||
Outcome = AuthEventOutcome.Success
|
||||
};
|
||||
|
||||
Assert.NotNull(record.Scopes);
|
||||
Assert.Empty(record.Scopes);
|
||||
Assert.NotNull(record.Properties);
|
||||
Assert.Empty(record.Properties);
|
||||
Assert.False(record.Tenant.HasValue);
|
||||
Assert.False(record.Project.HasValue);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClassifiedString_NormalizesWhitespace()
|
||||
{
|
||||
var value = ClassifiedString.Personal(" ");
|
||||
Assert.Null(value.Value);
|
||||
Assert.False(value.HasValue);
|
||||
Assert.Equal(AuthEventDataClassification.Personal, value.Classification);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Subject_DefaultsToEmptyCollections()
|
||||
{
|
||||
var subject = new AuthEventSubject();
|
||||
Assert.NotNull(subject.Attributes);
|
||||
Assert.Empty(subject.Attributes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Record_AssignsTimestamp_WhenNotProvided()
|
||||
{
|
||||
var record = new AuthEventRecord
|
||||
{
|
||||
EventType = "authority.test",
|
||||
Outcome = AuthEventOutcome.Success
|
||||
};
|
||||
|
||||
Assert.NotEqual(default, record.OccurredAt);
|
||||
Assert.InRange(
|
||||
record.OccurredAt,
|
||||
DateTimeOffset.UtcNow.AddSeconds(-5),
|
||||
DateTimeOffset.UtcNow.AddSeconds(5));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,52 +1,52 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Cryptography;
|
||||
using StellaOps.Cryptography.DependencyInjection;
|
||||
using StellaOps.Cryptography.Plugin.BouncyCastle;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Cryptography.Tests;
|
||||
|
||||
public sealed class BouncyCastleEd25519CryptoProviderTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task SignAndVerify_WithBouncyCastleProvider_Succeeds()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddStellaOpsCrypto();
|
||||
services.AddBouncyCastleEd25519Provider();
|
||||
|
||||
using var provider = services.BuildServiceProvider();
|
||||
var registry = provider.GetRequiredService<ICryptoProviderRegistry>();
|
||||
var bcProvider = provider.GetServices<ICryptoProvider>()
|
||||
.OfType<BouncyCastleEd25519CryptoProvider>()
|
||||
.Single();
|
||||
|
||||
var keyId = "ed25519-unit-test";
|
||||
var privateKeyBytes = Enumerable.Range(0, 32).Select(i => (byte)(i + 1)).ToArray();
|
||||
var keyReference = new CryptoKeyReference(keyId, bcProvider.Name);
|
||||
var signingKey = new CryptoSigningKey(
|
||||
keyReference,
|
||||
SignatureAlgorithms.Ed25519,
|
||||
privateKeyBytes,
|
||||
createdAt: DateTimeOffset.UtcNow);
|
||||
|
||||
bcProvider.UpsertSigningKey(signingKey);
|
||||
|
||||
var resolution = registry.ResolveSigner(
|
||||
CryptoCapability.Signing,
|
||||
SignatureAlgorithms.Ed25519,
|
||||
keyReference,
|
||||
bcProvider.Name);
|
||||
|
||||
var payload = new byte[] { 0x01, 0x02, 0x03, 0x04 };
|
||||
var signature = await resolution.Signer.SignAsync(payload);
|
||||
|
||||
Assert.True(await resolution.Signer.VerifyAsync(payload, signature));
|
||||
|
||||
var jwk = resolution.Signer.ExportPublicJsonWebKey();
|
||||
Assert.Equal("OKP", jwk.Kty);
|
||||
Assert.Equal("Ed25519", jwk.Crv);
|
||||
Assert.Equal(SignatureAlgorithms.EdDsa, jwk.Alg);
|
||||
Assert.Equal(keyId, jwk.Kid);
|
||||
}
|
||||
}
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Cryptography;
|
||||
using StellaOps.Cryptography.DependencyInjection;
|
||||
using StellaOps.Cryptography.Plugin.BouncyCastle;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Cryptography.Tests;
|
||||
|
||||
public sealed class BouncyCastleEd25519CryptoProviderTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task SignAndVerify_WithBouncyCastleProvider_Succeeds()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddStellaOpsCrypto();
|
||||
services.AddBouncyCastleEd25519Provider();
|
||||
|
||||
using var provider = services.BuildServiceProvider();
|
||||
var registry = provider.GetRequiredService<ICryptoProviderRegistry>();
|
||||
var bcProvider = provider.GetServices<ICryptoProvider>()
|
||||
.OfType<BouncyCastleEd25519CryptoProvider>()
|
||||
.Single();
|
||||
|
||||
var keyId = "ed25519-unit-test";
|
||||
var privateKeyBytes = Enumerable.Range(0, 32).Select(i => (byte)(i + 1)).ToArray();
|
||||
var keyReference = new CryptoKeyReference(keyId, bcProvider.Name);
|
||||
var signingKey = new CryptoSigningKey(
|
||||
keyReference,
|
||||
SignatureAlgorithms.Ed25519,
|
||||
privateKeyBytes,
|
||||
createdAt: DateTimeOffset.UtcNow);
|
||||
|
||||
bcProvider.UpsertSigningKey(signingKey);
|
||||
|
||||
var resolution = registry.ResolveSigner(
|
||||
CryptoCapability.Signing,
|
||||
SignatureAlgorithms.Ed25519,
|
||||
keyReference,
|
||||
bcProvider.Name);
|
||||
|
||||
var payload = new byte[] { 0x01, 0x02, 0x03, 0x04 };
|
||||
var signature = await resolution.Signer.SignAsync(payload);
|
||||
|
||||
Assert.True(await resolution.Signer.VerifyAsync(payload, signature));
|
||||
|
||||
var jwk = resolution.Signer.ExportPublicJsonWebKey();
|
||||
Assert.Equal("OKP", jwk.Kty);
|
||||
Assert.Equal("Ed25519", jwk.Crv);
|
||||
Assert.Equal(SignatureAlgorithms.EdDsa, jwk.Alg);
|
||||
Assert.Equal(keyId, jwk.Kid);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,112 +1,112 @@
|
||||
using System.Linq;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.DependencyInjection;
|
||||
using StellaOps.Plugin.DependencyInjection;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Plugin.Tests.DependencyInjection;
|
||||
|
||||
public sealed class PluginServiceRegistrationTests
|
||||
{
|
||||
[Fact]
|
||||
public void RegisterAssemblyMetadata_RegistersScopedDescriptor()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
|
||||
PluginServiceRegistration.RegisterAssemblyMetadata(
|
||||
services,
|
||||
typeof(ScopedTestService).Assembly,
|
||||
NullLogger.Instance);
|
||||
|
||||
var descriptor = Assert.Single(services, static d => d.ServiceType == typeof(IScopedService));
|
||||
Assert.Equal(ServiceLifetime.Scoped, descriptor.Lifetime);
|
||||
Assert.Equal(typeof(ScopedTestService), descriptor.ImplementationType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RegisterAssemblyMetadata_HonoursRegisterAsSelf()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
|
||||
PluginServiceRegistration.RegisterAssemblyMetadata(
|
||||
services,
|
||||
typeof(SelfRegisteringService).Assembly,
|
||||
NullLogger.Instance);
|
||||
|
||||
Assert.Contains(services, static d =>
|
||||
d.ServiceType == typeof(SelfRegisteringService) &&
|
||||
d.ImplementationType == typeof(SelfRegisteringService));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RegisterAssemblyMetadata_ReplacesExistingDescriptorsWhenRequested()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton<IReplacementService, ExistingReplacementService>();
|
||||
|
||||
PluginServiceRegistration.RegisterAssemblyMetadata(
|
||||
services,
|
||||
typeof(ReplacementService).Assembly,
|
||||
NullLogger.Instance);
|
||||
|
||||
var descriptor = Assert.Single(
|
||||
services,
|
||||
static d => d.ServiceType == typeof(IReplacementService) &&
|
||||
d.ImplementationType == typeof(ReplacementService));
|
||||
Assert.Equal(ServiceLifetime.Transient, descriptor.Lifetime);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RegisterAssemblyMetadata_SkipsInvalidAssignments()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
|
||||
PluginServiceRegistration.RegisterAssemblyMetadata(
|
||||
services,
|
||||
typeof(InvalidServiceBinding).Assembly,
|
||||
NullLogger.Instance);
|
||||
|
||||
Assert.DoesNotContain(services, static d => d.ServiceType == typeof(IAnotherService));
|
||||
}
|
||||
|
||||
private interface IScopedService
|
||||
{
|
||||
}
|
||||
|
||||
private interface ISelfContract
|
||||
{
|
||||
}
|
||||
|
||||
private interface IReplacementService
|
||||
{
|
||||
}
|
||||
|
||||
private interface IAnotherService
|
||||
{
|
||||
}
|
||||
|
||||
private sealed class ExistingReplacementService : IReplacementService
|
||||
{
|
||||
}
|
||||
|
||||
[ServiceBinding(typeof(IScopedService), ServiceLifetime.Scoped)]
|
||||
private sealed class ScopedTestService : IScopedService
|
||||
{
|
||||
}
|
||||
|
||||
[ServiceBinding(typeof(ISelfContract), ServiceLifetime.Singleton, RegisterAsSelf = true)]
|
||||
private sealed class SelfRegisteringService : ISelfContract
|
||||
{
|
||||
}
|
||||
|
||||
[ServiceBinding(typeof(IReplacementService), ServiceLifetime.Transient, ReplaceExisting = true)]
|
||||
private sealed class ReplacementService : IReplacementService
|
||||
{
|
||||
}
|
||||
|
||||
[ServiceBinding(typeof(IAnotherService), ServiceLifetime.Singleton)]
|
||||
private sealed class InvalidServiceBinding
|
||||
{
|
||||
}
|
||||
}
|
||||
using System.Linq;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.DependencyInjection;
|
||||
using StellaOps.Plugin.DependencyInjection;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Plugin.Tests.DependencyInjection;
|
||||
|
||||
public sealed class PluginServiceRegistrationTests
|
||||
{
|
||||
[Fact]
|
||||
public void RegisterAssemblyMetadata_RegistersScopedDescriptor()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
|
||||
PluginServiceRegistration.RegisterAssemblyMetadata(
|
||||
services,
|
||||
typeof(ScopedTestService).Assembly,
|
||||
NullLogger.Instance);
|
||||
|
||||
var descriptor = Assert.Single(services, static d => d.ServiceType == typeof(IScopedService));
|
||||
Assert.Equal(ServiceLifetime.Scoped, descriptor.Lifetime);
|
||||
Assert.Equal(typeof(ScopedTestService), descriptor.ImplementationType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RegisterAssemblyMetadata_HonoursRegisterAsSelf()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
|
||||
PluginServiceRegistration.RegisterAssemblyMetadata(
|
||||
services,
|
||||
typeof(SelfRegisteringService).Assembly,
|
||||
NullLogger.Instance);
|
||||
|
||||
Assert.Contains(services, static d =>
|
||||
d.ServiceType == typeof(SelfRegisteringService) &&
|
||||
d.ImplementationType == typeof(SelfRegisteringService));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RegisterAssemblyMetadata_ReplacesExistingDescriptorsWhenRequested()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton<IReplacementService, ExistingReplacementService>();
|
||||
|
||||
PluginServiceRegistration.RegisterAssemblyMetadata(
|
||||
services,
|
||||
typeof(ReplacementService).Assembly,
|
||||
NullLogger.Instance);
|
||||
|
||||
var descriptor = Assert.Single(
|
||||
services,
|
||||
static d => d.ServiceType == typeof(IReplacementService) &&
|
||||
d.ImplementationType == typeof(ReplacementService));
|
||||
Assert.Equal(ServiceLifetime.Transient, descriptor.Lifetime);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RegisterAssemblyMetadata_SkipsInvalidAssignments()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
|
||||
PluginServiceRegistration.RegisterAssemblyMetadata(
|
||||
services,
|
||||
typeof(InvalidServiceBinding).Assembly,
|
||||
NullLogger.Instance);
|
||||
|
||||
Assert.DoesNotContain(services, static d => d.ServiceType == typeof(IAnotherService));
|
||||
}
|
||||
|
||||
private interface IScopedService
|
||||
{
|
||||
}
|
||||
|
||||
private interface ISelfContract
|
||||
{
|
||||
}
|
||||
|
||||
private interface IReplacementService
|
||||
{
|
||||
}
|
||||
|
||||
private interface IAnotherService
|
||||
{
|
||||
}
|
||||
|
||||
private sealed class ExistingReplacementService : IReplacementService
|
||||
{
|
||||
}
|
||||
|
||||
[ServiceBinding(typeof(IScopedService), ServiceLifetime.Scoped)]
|
||||
private sealed class ScopedTestService : IScopedService
|
||||
{
|
||||
}
|
||||
|
||||
[ServiceBinding(typeof(ISelfContract), ServiceLifetime.Singleton, RegisterAsSelf = true)]
|
||||
private sealed class SelfRegisteringService : ISelfContract
|
||||
{
|
||||
}
|
||||
|
||||
[ServiceBinding(typeof(IReplacementService), ServiceLifetime.Transient, ReplaceExisting = true)]
|
||||
private sealed class ReplacementService : IReplacementService
|
||||
{
|
||||
}
|
||||
|
||||
[ServiceBinding(typeof(IAnotherService), ServiceLifetime.Singleton)]
|
||||
private sealed class InvalidServiceBinding
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user