stabilizaiton work - projects rework for maintenanceability and ui livening
This commit is contained in:
@@ -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; }
|
||||
}
|
||||
@@ -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('/', '_');
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace StellaOps.Auth.Security.Dpop;
|
||||
|
||||
internal static class DpopValidationOptionsExtensions
|
||||
{
|
||||
public static TimeSpan GetMaximumAge(this DpopValidationOptions options)
|
||||
=> options.ProofLifetime + options.AllowedClockSkew;
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 _);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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). |
|
||||
|
||||
Reference in New Issue
Block a user