stabilizaiton work - projects rework for maintenanceability and ui livening

This commit is contained in:
master
2026-02-03 23:40:04 +02:00
parent 074ce117ba
commit 557feefdc3
3305 changed files with 186813 additions and 107843 deletions

View File

@@ -0,0 +1,17 @@
namespace StellaOps.Auth.Security.Dpop;
/// <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

@@ -6,7 +6,7 @@ namespace StellaOps.Auth.Security.Dpop;
internal static class DpopNonceUtilities
{
private static readonly char[] Base64Padding = { '=' };
private static readonly char[] _base64Padding = { '=' };
internal static string GenerateNonce()
{
@@ -14,7 +14,7 @@ internal static class DpopNonceUtilities
RandomNumberGenerator.Fill(buffer);
return Convert.ToBase64String(buffer)
.TrimEnd(Base64Padding)
.TrimEnd(_base64Padding)
.Replace('+', '-')
.Replace('/', '_');
}

View File

@@ -0,0 +1,7 @@
using Microsoft.IdentityModel.Tokens;
namespace StellaOps.Auth.Security.Dpop;
internal sealed record DpopProofHeader(string Algorithm, JsonWebKey Key);
internal sealed record DpopProofPayload(string JwtId, DateTimeOffset IssuedAt, string? Nonce);

View File

@@ -0,0 +1,69 @@
using Microsoft.Extensions.Logging;
using Microsoft.IdentityModel.Tokens;
using System.Text.Json;
namespace StellaOps.Auth.Security.Dpop;
public sealed partial class DpopProofValidator
{
private bool TryReadHeader(
string proof,
out DpopProofHeader header,
out DpopValidationResult failure)
{
header = default!;
failure = default!;
if (!TryDecodeSegment(proof, segmentIndex: 0, out var headerElement, out var headerError))
{
_logger?.LogWarning("DPoP header decode failure: {Error}", headerError);
failure = DpopValidationResult.Failure(
"invalid_header",
headerError ?? "Unable to decode header.");
return false;
}
if (!headerElement.TryGetProperty("typ", out var typElement) ||
typElement.ValueKind != JsonValueKind.String ||
!string.Equals(typElement.GetString(), ProofType, StringComparison.OrdinalIgnoreCase))
{
failure = DpopValidationResult.Failure("invalid_header", "DPoP proof missing typ=dpop+jwt header.");
return false;
}
if (!headerElement.TryGetProperty("alg", out var algElement) ||
algElement.ValueKind != JsonValueKind.String)
{
failure = DpopValidationResult.Failure("invalid_header", "DPoP proof missing alg header.");
return false;
}
var algorithm = algElement.GetString()?.Trim().ToUpperInvariant();
if (string.IsNullOrEmpty(algorithm) || !_options.NormalizedAlgorithms.Contains(algorithm))
{
failure = DpopValidationResult.Failure("invalid_header", "Unsupported DPoP algorithm.");
return false;
}
if (!headerElement.TryGetProperty("jwk", out var jwkElement))
{
failure = DpopValidationResult.Failure("invalid_header", "DPoP proof missing jwk header.");
return false;
}
JsonWebKey jwk;
try
{
jwk = new JsonWebKey(jwkElement.GetRawText());
}
catch (Exception ex)
{
_logger?.LogWarning(ex, "Failed to parse DPoP jwk header.");
failure = DpopValidationResult.Failure("invalid_header", "DPoP proof jwk header is invalid.");
return false;
}
header = new DpopProofHeader(algorithm, jwk);
return true;
}
}

View File

@@ -0,0 +1,53 @@
using Microsoft.IdentityModel.Tokens;
using System.Text.Json;
namespace StellaOps.Auth.Security.Dpop;
public sealed partial class DpopProofValidator
{
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;
}
}
}

View File

@@ -0,0 +1,43 @@
using System.Text.Json;
namespace StellaOps.Auth.Security.Dpop;
public sealed partial class DpopProofValidator
{
private bool TryReadNonce(
JsonElement payloadElement,
string? expectedNonce,
out string? actualNonce,
out DpopValidationResult failure)
{
actualNonce = null;
failure = default!;
if (expectedNonce is not null)
{
if (!payloadElement.TryGetProperty("nonce", out var nonceElement) ||
nonceElement.ValueKind != JsonValueKind.String)
{
failure = DpopValidationResult.Failure("invalid_token", "DPoP proof missing nonce claim.");
return false;
}
actualNonce = nonceElement.GetString();
if (!string.Equals(actualNonce, expectedNonce, StringComparison.Ordinal))
{
failure = DpopValidationResult.Failure("invalid_token", "DPoP nonce mismatch.");
return false;
}
return true;
}
if (payloadElement.TryGetProperty("nonce", out var optionalNonce) &&
optionalNonce.ValueKind == JsonValueKind.String)
{
actualNonce = optionalNonce.GetString();
}
return true;
}
}

View File

@@ -0,0 +1,21 @@
namespace StellaOps.Auth.Security.Dpop;
public sealed partial class DpopProofValidator
{
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);
}
}
}
}

View File

@@ -0,0 +1,93 @@
using Microsoft.Extensions.Logging;
using System.Text.Json;
namespace StellaOps.Auth.Security.Dpop;
public sealed partial class DpopProofValidator
{
private bool TryReadPayload(
string proof,
string httpMethod,
Uri httpUri,
string? expectedNonce,
out DpopProofPayload payload,
out DpopValidationResult failure)
{
payload = default!;
failure = default!;
if (!TryDecodeSegment(proof, segmentIndex: 1, out var payloadElement, out var payloadError))
{
_logger?.LogWarning("DPoP payload decode failure: {Error}", payloadError);
failure = DpopValidationResult.Failure(
"invalid_payload",
payloadError ?? "Unable to decode payload.");
return false;
}
if (!payloadElement.TryGetProperty("htm", out var htmElement) ||
htmElement.ValueKind != JsonValueKind.String)
{
failure = DpopValidationResult.Failure("invalid_payload", "DPoP proof missing htm claim.");
return false;
}
var method = httpMethod.Trim().ToUpperInvariant();
if (!string.Equals(htmElement.GetString(), method, StringComparison.Ordinal))
{
failure = DpopValidationResult.Failure("invalid_payload", "DPoP htm does not match request method.");
return false;
}
if (!payloadElement.TryGetProperty("htu", out var htuElement) ||
htuElement.ValueKind != JsonValueKind.String)
{
failure = DpopValidationResult.Failure("invalid_payload", "DPoP proof missing htu claim.");
return false;
}
var normalizedHtu = NormalizeHtu(httpUri);
if (!string.Equals(htuElement.GetString(), normalizedHtu, StringComparison.Ordinal))
{
failure = DpopValidationResult.Failure("invalid_payload", "DPoP htu does not match request URI.");
return false;
}
if (!payloadElement.TryGetProperty("iat", out var iatElement) ||
iatElement.ValueKind is not JsonValueKind.Number)
{
failure = DpopValidationResult.Failure("invalid_payload", "DPoP proof missing iat claim.");
return false;
}
if (!payloadElement.TryGetProperty("jti", out var jtiElement) ||
jtiElement.ValueKind != JsonValueKind.String)
{
failure = DpopValidationResult.Failure("invalid_payload", "DPoP proof missing jti claim.");
return false;
}
long iatSeconds;
try
{
iatSeconds = iatElement.GetInt64();
}
catch (Exception)
{
failure = DpopValidationResult.Failure("invalid_payload", "DPoP proof iat claim is not a valid number.");
return false;
}
var issuedAt = DateTimeOffset.FromUnixTimeSeconds(iatSeconds).ToUniversalTime();
var jwtId = jtiElement.GetString()!;
if (!TryReadNonce(payloadElement, expectedNonce, out var actualNonce, out var nonceFailure))
{
failure = nonceFailure;
return false;
}
payload = new DpopProofPayload(jwtId, issuedAt, actualNonce);
return true;
}
}

View File

@@ -0,0 +1,19 @@
namespace StellaOps.Auth.Security.Dpop;
public sealed partial class DpopProofValidator
{
private async ValueTask<DpopValidationResult?> TryRecordReplayAsync(
string jwtId,
DateTimeOffset issuedAt,
CancellationToken cancellationToken)
{
if (!await _replayCache
.TryStoreAsync(jwtId, issuedAt + _options.ReplayWindow, cancellationToken)
.ConfigureAwait(false))
{
return DpopValidationResult.Failure("replay", "DPoP proof already used.");
}
return null;
}
}

View File

@@ -0,0 +1,34 @@
using Microsoft.Extensions.Logging;
using Microsoft.IdentityModel.Tokens;
using System.Linq;
namespace StellaOps.Auth.Security.Dpop;
public sealed partial class DpopProofValidator
{
private DpopValidationResult? ValidateSignature(string proof, DpopProofHeader header)
{
try
{
var parameters = new TokenValidationParameters
{
ValidateAudience = false,
ValidateIssuer = false,
ValidateLifetime = false,
ValidateTokenReplay = false,
RequireSignedTokens = true,
ValidateIssuerSigningKey = true,
IssuerSigningKey = header.Key,
ValidAlgorithms = _options.NormalizedAlgorithms.ToArray()
};
_tokenHandler.ValidateToken(proof, parameters, out _);
return null;
}
catch (Exception ex)
{
_logger?.LogWarning(ex, "DPoP proof signature validation failed.");
return DpopValidationResult.Failure("invalid_signature", "DPoP proof signature validation failed.");
}
}
}

View File

@@ -0,0 +1,19 @@
namespace StellaOps.Auth.Security.Dpop;
public sealed partial class DpopProofValidator
{
private DpopValidationResult? ValidateIssuedAt(DateTimeOffset issuedAt, DateTimeOffset now)
{
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.");
}
return null;
}
}

View File

@@ -0,0 +1,15 @@
namespace StellaOps.Auth.Security.Dpop;
public sealed partial class DpopProofValidator
{
public async ValueTask<DpopValidationResult> ValidateAsync(
string proof,
string httpMethod,
Uri httpUri,
string? nonce = null,
CancellationToken cancellationToken = default)
{
return await ValidateInternalAsync(proof, httpMethod, httpUri, nonce, cancellationToken)
.ConfigureAwait(false);
}
}

View File

@@ -0,0 +1,49 @@
namespace StellaOps.Auth.Security.Dpop;
public sealed partial class DpopProofValidator
{
private async ValueTask<DpopValidationResult> ValidateInternalAsync(
string proof,
string httpMethod,
Uri httpUri,
string? nonce,
CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(proof);
ArgumentException.ThrowIfNullOrWhiteSpace(httpMethod);
ArgumentNullException.ThrowIfNull(httpUri);
var now = _timeProvider.GetUtcNow();
if (!TryReadHeader(proof, out var header, out var headerFailure))
{
return headerFailure;
}
if (!TryReadPayload(proof, httpMethod, httpUri, nonce, out var payload, out var payloadFailure))
{
return payloadFailure;
}
var timeFailure = ValidateIssuedAt(payload.IssuedAt, now);
if (timeFailure is not null)
{
return timeFailure;
}
var signatureFailure = ValidateSignature(proof, header);
if (signatureFailure is not null)
{
return signatureFailure;
}
var replayFailure = await TryRecordReplayAsync(payload.JwtId, payload.IssuedAt, cancellationToken)
.ConfigureAwait(false);
if (replayFailure is not null)
{
return replayFailure;
}
return DpopValidationResult.Success(header.Key, payload.JwtId, payload.IssuedAt, payload.Nonce);
}
}

View File

@@ -1,24 +1,20 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using System.IdentityModel.Tokens.Jwt;
using System.Linq;
using System.Text.Json;
namespace StellaOps.Auth.Security.Dpop;
/// <summary>
/// Validates DPoP proofs following RFC 9449.
/// </summary>
public sealed class DpopProofValidator : IDpopProofValidator
public sealed partial 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();
private const 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,
@@ -29,231 +25,9 @@ public sealed class DpopProofValidator : IDpopProofValidator
ArgumentNullException.ThrowIfNull(options);
var snapshot = options.Value ?? throw new InvalidOperationException("DPoP options must be provided.");
this.options = snapshot.Snapshot();
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) ||
typElement.ValueKind != JsonValueKind.String ||
!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) || algElement.ValueKind != JsonValueKind.String)
{
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) || htmElement.ValueKind != JsonValueKind.String)
{
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) || htuElement.ValueKind != JsonValueKind.String)
{
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);
}
}
_options = snapshot.Snapshot();
_replayCache = replayCache ?? NullReplayCache.Instance;
_timeProvider = timeProvider ?? TimeProvider.System;
_logger = logger;
}
}
file static class DpopValidationOptionsExtensions
{
public static TimeSpan GetMaximumAge(this DpopValidationOptions options)
=> options.ProofLifetime + options.AllowedClockSkew;
}

View File

@@ -10,12 +10,12 @@ namespace StellaOps.Auth.Security.Dpop;
/// </summary>
public sealed class DpopValidationOptions
{
private readonly HashSet<string> allowedAlgorithms = new(StringComparer.Ordinal);
private readonly HashSet<string> _allowedAlgorithms = new(StringComparer.Ordinal);
public DpopValidationOptions()
{
allowedAlgorithms.Add("ES256");
allowedAlgorithms.Add("ES384");
_allowedAlgorithms.Add("ES256");
_allowedAlgorithms.Add("ES384");
}
/// <summary>
@@ -36,7 +36,7 @@ public sealed class DpopValidationOptions
/// <summary>
/// Algorithms (JWA) permitted for DPoP proofs.
/// </summary>
public ISet<string> AllowedAlgorithms => allowedAlgorithms;
public ISet<string> AllowedAlgorithms => _allowedAlgorithms;
/// <summary>
/// Normalised, upper-case representation of allowed algorithms.
@@ -52,10 +52,10 @@ public sealed class DpopValidationOptions
ReplayWindow = ReplayWindow
};
clone.allowedAlgorithms.Clear();
foreach (var algorithm in allowedAlgorithms)
clone._allowedAlgorithms.Clear();
foreach (var algorithm in _allowedAlgorithms)
{
clone.allowedAlgorithms.Add(algorithm);
clone._allowedAlgorithms.Add(algorithm);
}
clone.Validate();
@@ -79,12 +79,12 @@ public sealed class DpopValidationOptions
throw new InvalidOperationException("DPoP replay window must be greater than or equal to zero.");
}
if (allowedAlgorithms.Count == 0)
if (_allowedAlgorithms.Count == 0)
{
throw new InvalidOperationException("At least one allowed DPoP algorithm must be configured.");
}
NormalizedAlgorithms = allowedAlgorithms
NormalizedAlgorithms = _allowedAlgorithms
.Select(static algorithm => algorithm.Trim().ToUpperInvariant())
.Where(static algorithm => algorithm.Length > 0)
.ToImmutableHashSet(StringComparer.Ordinal);

View File

@@ -0,0 +1,7 @@
namespace StellaOps.Auth.Security.Dpop;
internal static class DpopValidationOptionsExtensions
{
public static TimeSpan GetMaximumAge(this DpopValidationOptions options)
=> options.ProofLifetime + options.AllowedClockSkew;
}

View File

@@ -0,0 +1,38 @@
using Microsoft.Extensions.Logging;
namespace StellaOps.Auth.Security.Dpop;
public sealed partial class InMemoryDpopNonceStore
{
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));
}
}

View File

@@ -0,0 +1,39 @@
using System.Security.Cryptography;
using System.Text;
namespace StellaOps.Auth.Security.Dpop;
public sealed partial class InMemoryDpopNonceStore
{
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);
}
}

View File

@@ -0,0 +1,63 @@
using Microsoft.Extensions.Logging;
namespace StellaOps.Auth.Security.Dpop;
public sealed partial class InMemoryDpopNonceStore
{
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));
}
}

View File

@@ -0,0 +1,30 @@
namespace StellaOps.Auth.Security.Dpop;
public sealed partial class InMemoryDpopNonceStore
{
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();
}
}
}
}

View File

@@ -1,177 +1,22 @@
using Microsoft.Extensions.Logging;
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Security.Cryptography;
using System.Text;
using System.Threading;
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
public sealed partial 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;
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();
}
}
_timeProvider = timeProvider ?? TimeProvider.System;
_logger = logger;
}
}

View File

@@ -7,31 +7,31 @@ namespace StellaOps.Auth.Security.Dpop;
/// </summary>
public sealed class InMemoryDpopReplayCache : IDpopReplayCache
{
private readonly ConcurrentDictionary<string, DateTimeOffset> entries = new(StringComparer.Ordinal);
private readonly TimeProvider timeProvider;
private readonly ConcurrentDictionary<string, DateTimeOffset> _entries = new(StringComparer.Ordinal);
private readonly TimeProvider _timeProvider;
public InMemoryDpopReplayCache(TimeProvider? timeProvider = null)
{
this.timeProvider = timeProvider ?? TimeProvider.System;
_timeProvider = timeProvider ?? TimeProvider.System;
}
public ValueTask<bool> TryStoreAsync(string jwtId, DateTimeOffset expiresAt, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(jwtId);
var now = timeProvider.GetUtcNow();
var now = _timeProvider.GetUtcNow();
RemoveExpired(now);
if (entries.TryAdd(jwtId, expiresAt))
if (_entries.TryAdd(jwtId, expiresAt))
{
return ValueTask.FromResult(true);
}
while (!cancellationToken.IsCancellationRequested)
{
if (!entries.TryGetValue(jwtId, out var existing))
if (!_entries.TryGetValue(jwtId, out var existing))
{
if (entries.TryAdd(jwtId, expiresAt))
if (_entries.TryAdd(jwtId, expiresAt))
{
return ValueTask.FromResult(true);
}
@@ -44,7 +44,7 @@ public sealed class InMemoryDpopReplayCache : IDpopReplayCache
return ValueTask.FromResult(false);
}
if (entries.TryUpdate(jwtId, expiresAt, existing))
if (_entries.TryUpdate(jwtId, expiresAt, existing))
{
return ValueTask.FromResult(true);
}
@@ -55,11 +55,11 @@ public sealed class InMemoryDpopReplayCache : IDpopReplayCache
private void RemoveExpired(DateTimeOffset now)
{
foreach (var entry in entries)
foreach (var entry in _entries)
{
if (entry.Value <= now)
{
entries.TryRemove(entry.Key, out _);
_entries.TryRemove(entry.Key, out _);
}
}
}

View File

@@ -0,0 +1,57 @@
using Microsoft.Extensions.Logging;
using StellaOps.Messaging;
namespace StellaOps.Auth.Security.Dpop;
public sealed partial class MessagingDpopNonceStore
{
/// <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));
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:
_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();
}
}
}

View File

@@ -0,0 +1,76 @@
using Microsoft.Extensions.Logging;
using StellaOps.Messaging;
namespace StellaOps.Auth.Security.Dpop;
public sealed partial class MessagingDpopNonceStore
{
/// <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";
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");
}
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
};
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);
}
}

View File

@@ -8,10 +8,9 @@ namespace StellaOps.Auth.Security.Dpop;
/// 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
public sealed partial class MessagingDpopNonceStore : IDpopNonceStore
{
private static readonly TimeSpan RateLimitWindow = TimeSpan.FromMinutes(1);
private static readonly TimeSpan _rateLimitWindow = TimeSpan.FromMinutes(1);
private readonly IRateLimiter _rateLimiter;
private readonly IAtomicTokenStore<DpopNonceMetadata> _tokenStore;
private readonly TimeProvider _timeProvider;
@@ -28,136 +27,4 @@ public sealed class MessagingDpopNonceStore : IDpopNonceStore
_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

@@ -0,0 +1,61 @@
using StackExchange.Redis;
using System.Globalization;
namespace StellaOps.Auth.Security.Dpop;
public sealed partial class RedisDpopNonceStore
{
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);
}
}
var now = _timeProvider.GetUtcNow();
return DpopNonceConsumeResult.Success(now, now);
}
}

View File

@@ -0,0 +1,69 @@
using StackExchange.Redis;
namespace StellaOps.Auth.Security.Dpop;
public sealed partial class RedisDpopNonceStore
{
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);
}
}

View File

@@ -1,16 +1,11 @@
using StackExchange.Redis;
using System;
using System.Globalization;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Auth.Security.Dpop;
/// <summary>
/// Redis-backed implementation of <see cref="IDpopNonceStore"/> that supports multi-node deployments.
/// </summary>
public sealed class RedisDpopNonceStore : IDpopNonceStore
public sealed partial class RedisDpopNonceStore : IDpopNonceStore
{
private const string ConsumeScript = @"
local value = redis.call('GET', KEYS[1])
@@ -20,120 +15,12 @@ if value ~= false and value == ARGV[1] then
end
return 0";
private readonly IConnectionMultiplexer connection;
private readonly TimeProvider timeProvider;
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());
_connection = connection ?? throw new ArgumentNullException(nameof(connection));
_timeProvider = timeProvider ?? TimeProvider.System;
}
}

View File

@@ -9,3 +9,4 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
| AUDIT-0045-T | DONE | Revalidated 2026-01-08 (tests cover DPoP validation and replay cache). |
| AUDIT-0045-A | TODO | Requires MAINT/TEST + approval. |
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
| REMED-07 | DONE | DPoP remediation (validator/stores split <= 100 lines, private fields renamed, nonce store tests added). |