This commit is contained in:
StellaOps Bot
2025-12-13 02:22:15 +02:00
parent 564df71bfb
commit 999e26a48e
395 changed files with 25045 additions and 2224 deletions

View File

@@ -0,0 +1,163 @@
using Microsoft.Extensions.Logging;
using StellaOps.Messaging;
using StellaOps.Messaging.Abstractions;
namespace StellaOps.Auth.Security.Dpop;
/// <summary>
/// Transport-agnostic implementation of <see cref="IDpopNonceStore"/> using StellaOps.Messaging abstractions.
/// Works with any configured transport (Valkey, PostgreSQL, InMemory).
/// </summary>
public sealed class MessagingDpopNonceStore : IDpopNonceStore
{
private static readonly TimeSpan RateLimitWindow = TimeSpan.FromMinutes(1);
private readonly IRateLimiter _rateLimiter;
private readonly IAtomicTokenStore<DpopNonceMetadata> _tokenStore;
private readonly TimeProvider _timeProvider;
private readonly ILogger<MessagingDpopNonceStore>? _logger;
public MessagingDpopNonceStore(
IRateLimiter rateLimiter,
IAtomicTokenStore<DpopNonceMetadata> tokenStore,
TimeProvider? timeProvider = null,
ILogger<MessagingDpopNonceStore>? logger = null)
{
_rateLimiter = rateLimiter ?? throw new ArgumentNullException(nameof(rateLimiter));
_tokenStore = tokenStore ?? throw new ArgumentNullException(nameof(tokenStore));
_timeProvider = timeProvider ?? TimeProvider.System;
_logger = logger;
}
/// <inheritdoc />
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 storageKey = DpopNonceUtilities.ComputeStorageKey(audience, clientId, keyThumbprint);
var rateKey = $"{storageKey}:rate";
// Check rate limit
var ratePolicy = new RateLimitPolicy(maxIssuancePerMinute, RateLimitWindow);
var rateLimitResult = await _rateLimiter.TryAcquireAsync(rateKey, ratePolicy, cancellationToken).ConfigureAwait(false);
if (!rateLimitResult.IsAllowed)
{
_logger?.LogDebug(
"DPoP nonce issuance rate-limited for key {StorageKey}. Current: {Current}, Max: {Max}",
storageKey, rateLimitResult.CurrentCount, maxIssuancePerMinute);
return DpopNonceIssueResult.RateLimited("rate_limited");
}
// Generate nonce and compute hash for storage
var nonce = DpopNonceUtilities.GenerateNonce();
var nonceHash = DpopNonceUtilities.EncodeHash(DpopNonceUtilities.ComputeNonceHash(nonce));
var now = _timeProvider.GetUtcNow();
var expiresAt = now.Add(ttl);
var metadata = new DpopNonceMetadata
{
IssuedAt = now,
Ttl = ttl
};
// Store the nonce hash as the token (caller-provided)
var storeResult = await _tokenStore.StoreAsync(storageKey, nonceHash, metadata, ttl, cancellationToken).ConfigureAwait(false);
if (!storeResult.Success)
{
_logger?.LogWarning("Failed to store DPoP nonce for key {StorageKey}", storageKey);
return DpopNonceIssueResult.Failure("storage_error");
}
_logger?.LogDebug("Issued DPoP nonce for key {StorageKey}, expires at {ExpiresAt:o}", storageKey, expiresAt);
return DpopNonceIssueResult.Success(nonce, expiresAt);
}
/// <inheritdoc />
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 storageKey = DpopNonceUtilities.ComputeStorageKey(audience, clientId, keyThumbprint);
var nonceHash = DpopNonceUtilities.EncodeHash(DpopNonceUtilities.ComputeNonceHash(nonce));
// Try to consume the token atomically
var consumeResult = await _tokenStore.TryConsumeAsync(storageKey, nonceHash, cancellationToken).ConfigureAwait(false);
switch (consumeResult.Status)
{
case TokenConsumeStatus.Success:
_logger?.LogDebug("Successfully consumed DPoP nonce for key {StorageKey}", storageKey);
return DpopNonceConsumeResult.Success(
consumeResult.IssuedAt ?? _timeProvider.GetUtcNow(),
consumeResult.ExpiresAt ?? _timeProvider.GetUtcNow());
case TokenConsumeStatus.Expired:
_logger?.LogDebug("DPoP nonce expired for key {StorageKey}", storageKey);
return DpopNonceConsumeResult.Expired(
consumeResult.IssuedAt,
consumeResult.ExpiresAt ?? _timeProvider.GetUtcNow());
case TokenConsumeStatus.NotFound:
_logger?.LogDebug("DPoP nonce not found for key {StorageKey}", storageKey);
return DpopNonceConsumeResult.NotFound();
case TokenConsumeStatus.Mismatch:
// Token exists but hash doesn't match - treat as not found
_logger?.LogDebug("DPoP nonce hash mismatch for key {StorageKey}", storageKey);
return DpopNonceConsumeResult.NotFound();
default:
_logger?.LogWarning("Unknown consume status {Status} for key {StorageKey}", consumeResult.Status, storageKey);
return DpopNonceConsumeResult.NotFound();
}
}
}
/// <summary>
/// Metadata stored with DPoP nonces.
/// </summary>
public sealed class DpopNonceMetadata
{
/// <summary>
/// When the nonce was issued.
/// </summary>
public DateTimeOffset IssuedAt { get; init; }
/// <summary>
/// The configured TTL for the nonce.
/// </summary>
public TimeSpan Ttl { get; init; }
}

View File

@@ -29,10 +29,13 @@
<ItemGroup>
<PackageReference Include="Microsoft.IdentityModel.Tokens" Version="8.15.0" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="7.2.0" />
<PackageReference Include="StackExchange.Redis" Version="2.8.24" />
<PackageReference Include="StackExchange.Redis" Version="2.8.37" />
<PackageReference Include="Microsoft.SourceLink.GitLab" Version="8.0.0" PrivateAssets="All" />
</ItemGroup>
<ItemGroup>
<None Include="README.md" Pack="true" PackagePath="" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Messaging\StellaOps.Messaging.csproj" />
</ItemGroup>
</Project>