up
This commit is contained in:
@@ -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; }
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user