Resolve Concelier/Excititor merge conflicts

This commit is contained in:
root
2025-10-20 14:19:25 +03:00
2687 changed files with 212646 additions and 85913 deletions

View File

@@ -0,0 +1,50 @@
using System;
namespace StellaOps.Auth.Security.Dpop;
/// <summary>
/// Represents the outcome of attempting to consume a DPoP nonce.
/// </summary>
public sealed class DpopNonceConsumeResult
{
private DpopNonceConsumeResult(DpopNonceConsumeStatus status, DateTimeOffset? issuedAt, DateTimeOffset? expiresAt)
{
Status = status;
IssuedAt = issuedAt;
ExpiresAt = expiresAt;
}
/// <summary>
/// Consumption status.
/// </summary>
public DpopNonceConsumeStatus Status { get; }
/// <summary>
/// Timestamp the nonce was originally issued (when available).
/// </summary>
public DateTimeOffset? IssuedAt { get; }
/// <summary>
/// Expiry timestamp for the nonce (when available).
/// </summary>
public DateTimeOffset? ExpiresAt { get; }
public static DpopNonceConsumeResult Success(DateTimeOffset issuedAt, DateTimeOffset expiresAt)
=> new(DpopNonceConsumeStatus.Success, issuedAt, expiresAt);
public static DpopNonceConsumeResult Expired(DateTimeOffset? issuedAt, DateTimeOffset expiresAt)
=> new(DpopNonceConsumeStatus.Expired, issuedAt, expiresAt);
public static DpopNonceConsumeResult NotFound()
=> new(DpopNonceConsumeStatus.NotFound, null, null);
}
/// <summary>
/// Known statuses for nonce consumption attempts.
/// </summary>
public enum DpopNonceConsumeStatus
{
Success,
Expired,
NotFound
}

View File

@@ -0,0 +1,56 @@
using System;
namespace StellaOps.Auth.Security.Dpop;
/// <summary>
/// Represents the result of issuing a DPoP nonce.
/// </summary>
public sealed class DpopNonceIssueResult
{
private DpopNonceIssueResult(DpopNonceIssueStatus status, string? nonce, DateTimeOffset? expiresAt, string? error)
{
Status = status;
Nonce = nonce;
ExpiresAt = expiresAt;
Error = error;
}
/// <summary>
/// Issue status.
/// </summary>
public DpopNonceIssueStatus Status { get; }
/// <summary>
/// Issued nonce when <see cref="Status"/> is <see cref="DpopNonceIssueStatus.Success"/>.
/// </summary>
public string? Nonce { get; }
/// <summary>
/// Expiry timestamp for the issued nonce (UTC).
/// </summary>
public DateTimeOffset? ExpiresAt { get; }
/// <summary>
/// Additional failure information, where applicable.
/// </summary>
public string? Error { get; }
public static DpopNonceIssueResult Success(string nonce, DateTimeOffset expiresAt)
=> new(DpopNonceIssueStatus.Success, nonce, expiresAt, null);
public static DpopNonceIssueResult RateLimited(string? error = null)
=> new(DpopNonceIssueStatus.RateLimited, null, null, error);
public static DpopNonceIssueResult Failure(string? error = null)
=> new(DpopNonceIssueStatus.Failure, null, null, error);
}
/// <summary>
/// Known statuses for nonce issuance.
/// </summary>
public enum DpopNonceIssueStatus
{
Success,
RateLimited,
Failure
}

View File

@@ -0,0 +1,66 @@
using System;
using System.Security.Cryptography;
using System.Text;
namespace StellaOps.Auth.Security.Dpop;
internal static class DpopNonceUtilities
{
private static readonly char[] Base64Padding = { '=' };
internal static string GenerateNonce()
{
Span<byte> buffer = stackalloc byte[32];
RandomNumberGenerator.Fill(buffer);
return Convert.ToBase64String(buffer)
.TrimEnd(Base64Padding)
.Replace('+', '-')
.Replace('/', '_');
}
internal static byte[] ComputeNonceHash(string nonce)
{
ArgumentException.ThrowIfNullOrWhiteSpace(nonce);
var bytes = Encoding.UTF8.GetBytes(nonce);
return SHA256.HashData(bytes);
}
internal static string EncodeHash(ReadOnlySpan<byte> hash)
=> Convert.ToHexString(hash);
internal static string ComputeStorageKey(string audience, string clientId, string keyThumbprint)
{
ArgumentException.ThrowIfNullOrWhiteSpace(audience);
ArgumentException.ThrowIfNullOrWhiteSpace(clientId);
ArgumentException.ThrowIfNullOrWhiteSpace(keyThumbprint);
return string.Create(
"dpop-nonce:".Length + audience.Length + clientId.Length + keyThumbprint.Length + 2,
(audience.Trim(), clientId.Trim(), keyThumbprint.Trim()),
static (span, parts) =>
{
var index = 0;
const string Prefix = "dpop-nonce:";
Prefix.CopyTo(span);
index += Prefix.Length;
index = Append(span, index, parts.Item1);
span[index++] = ':';
index = Append(span, index, parts.Item2);
span[index++] = ':';
_ = Append(span, index, parts.Item3);
});
static int Append(Span<char> span, int index, string value)
{
if (value.Length == 0)
{
throw new ArgumentException("Value must not be empty after trimming.");
}
value.AsSpan().CopyTo(span[index..]);
return index + value.Length;
}
}
}

View File

@@ -0,0 +1,258 @@
using System.IdentityModel.Tokens.Jwt;
using System.Linq;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
namespace StellaOps.Auth.Security.Dpop;
/// <summary>
/// Validates DPoP proofs following RFC 9449.
/// </summary>
public sealed class DpopProofValidator : IDpopProofValidator
{
private static readonly string ProofType = "dpop+jwt";
private readonly DpopValidationOptions options;
private readonly IDpopReplayCache replayCache;
private readonly TimeProvider timeProvider;
private readonly ILogger<DpopProofValidator>? logger;
private readonly JwtSecurityTokenHandler tokenHandler = new();
public DpopProofValidator(
IOptions<DpopValidationOptions> options,
IDpopReplayCache? replayCache = null,
TimeProvider? timeProvider = null,
ILogger<DpopProofValidator>? logger = null)
{
ArgumentNullException.ThrowIfNull(options);
var cloned = options.Value ?? throw new InvalidOperationException("DPoP options must be provided.");
cloned.Validate();
this.options = cloned;
this.replayCache = replayCache ?? NullReplayCache.Instance;
this.timeProvider = timeProvider ?? TimeProvider.System;
this.logger = logger;
}
public async ValueTask<DpopValidationResult> ValidateAsync(string proof, string httpMethod, Uri httpUri, string? nonce = null, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(proof);
ArgumentException.ThrowIfNullOrWhiteSpace(httpMethod);
ArgumentNullException.ThrowIfNull(httpUri);
var now = timeProvider.GetUtcNow();
if (!TryDecodeSegment(proof, segmentIndex: 0, out var headerElement, out var headerError))
{
logger?.LogWarning("DPoP header decode failure: {Error}", headerError);
return DpopValidationResult.Failure("invalid_header", headerError ?? "Unable to decode header.");
}
if (!headerElement.TryGetProperty("typ", out var typElement) || !string.Equals(typElement.GetString(), ProofType, StringComparison.OrdinalIgnoreCase))
{
return DpopValidationResult.Failure("invalid_header", "DPoP proof missing typ=dpop+jwt header.");
}
if (!headerElement.TryGetProperty("alg", out var algElement))
{
return DpopValidationResult.Failure("invalid_header", "DPoP proof missing alg header.");
}
var algorithm = algElement.GetString()?.Trim().ToUpperInvariant();
if (string.IsNullOrEmpty(algorithm) || !options.NormalizedAlgorithms.Contains(algorithm))
{
return DpopValidationResult.Failure("invalid_header", "Unsupported DPoP algorithm.");
}
if (!headerElement.TryGetProperty("jwk", out var jwkElement))
{
return DpopValidationResult.Failure("invalid_header", "DPoP proof missing jwk header.");
}
JsonWebKey jwk;
try
{
jwk = new JsonWebKey(jwkElement.GetRawText());
}
catch (Exception ex)
{
logger?.LogWarning(ex, "Failed to parse DPoP jwk header.");
return DpopValidationResult.Failure("invalid_header", "DPoP proof jwk header is invalid.");
}
if (!TryDecodeSegment(proof, segmentIndex: 1, out var payloadElement, out var payloadError))
{
logger?.LogWarning("DPoP payload decode failure: {Error}", payloadError);
return DpopValidationResult.Failure("invalid_payload", payloadError ?? "Unable to decode payload.");
}
if (!payloadElement.TryGetProperty("htm", out var htmElement))
{
return DpopValidationResult.Failure("invalid_payload", "DPoP proof missing htm claim.");
}
var method = httpMethod.Trim().ToUpperInvariant();
if (!string.Equals(htmElement.GetString(), method, StringComparison.Ordinal))
{
return DpopValidationResult.Failure("invalid_payload", "DPoP htm does not match request method.");
}
if (!payloadElement.TryGetProperty("htu", out var htuElement))
{
return DpopValidationResult.Failure("invalid_payload", "DPoP proof missing htu claim.");
}
var normalizedHtu = NormalizeHtu(httpUri);
if (!string.Equals(htuElement.GetString(), normalizedHtu, StringComparison.Ordinal))
{
return DpopValidationResult.Failure("invalid_payload", "DPoP htu does not match request URI.");
}
if (!payloadElement.TryGetProperty("iat", out var iatElement) || iatElement.ValueKind is not JsonValueKind.Number)
{
return DpopValidationResult.Failure("invalid_payload", "DPoP proof missing iat claim.");
}
if (!payloadElement.TryGetProperty("jti", out var jtiElement) || jtiElement.ValueKind != JsonValueKind.String)
{
return DpopValidationResult.Failure("invalid_payload", "DPoP proof missing jti claim.");
}
long iatSeconds;
try
{
iatSeconds = iatElement.GetInt64();
}
catch (Exception)
{
return DpopValidationResult.Failure("invalid_payload", "DPoP proof iat claim is not a valid number.");
}
var issuedAt = DateTimeOffset.FromUnixTimeSeconds(iatSeconds).ToUniversalTime();
if (issuedAt - options.AllowedClockSkew > now)
{
return DpopValidationResult.Failure("invalid_token", "DPoP proof issued in the future.");
}
if (now - issuedAt > options.GetMaximumAge())
{
return DpopValidationResult.Failure("invalid_token", "DPoP proof expired.");
}
string? actualNonce = null;
if (nonce is not null)
{
if (!payloadElement.TryGetProperty("nonce", out var nonceElement) || nonceElement.ValueKind != JsonValueKind.String)
{
return DpopValidationResult.Failure("invalid_token", "DPoP proof missing nonce claim.");
}
actualNonce = nonceElement.GetString();
if (!string.Equals(actualNonce, nonce, StringComparison.Ordinal))
{
return DpopValidationResult.Failure("invalid_token", "DPoP nonce mismatch.");
}
}
else if (payloadElement.TryGetProperty("nonce", out var nonceElement) && nonceElement.ValueKind == JsonValueKind.String)
{
actualNonce = nonceElement.GetString();
}
var jwtId = jtiElement.GetString()!;
try
{
var parameters = new TokenValidationParameters
{
ValidateAudience = false,
ValidateIssuer = false,
ValidateLifetime = false,
ValidateTokenReplay = false,
RequireSignedTokens = true,
ValidateIssuerSigningKey = true,
IssuerSigningKey = jwk,
ValidAlgorithms = options.NormalizedAlgorithms.ToArray()
};
tokenHandler.ValidateToken(proof, parameters, out _);
}
catch (Exception ex)
{
logger?.LogWarning(ex, "DPoP proof signature validation failed.");
return DpopValidationResult.Failure("invalid_signature", "DPoP proof signature validation failed.");
}
if (!await replayCache.TryStoreAsync(jwtId, issuedAt + options.ReplayWindow, cancellationToken).ConfigureAwait(false))
{
return DpopValidationResult.Failure("replay", "DPoP proof already used.");
}
return DpopValidationResult.Success(jwk, jwtId, issuedAt, actualNonce);
}
private static string NormalizeHtu(Uri uri)
{
var builder = new UriBuilder(uri)
{
Fragment = null,
Query = null
};
return builder.Uri.ToString();
}
private static bool TryDecodeSegment(string token, int segmentIndex, out JsonElement element, out string? error)
{
element = default;
error = null;
var segments = token.Split('.');
if (segments.Length != 3)
{
error = "Token must contain three segments.";
return false;
}
if (segmentIndex < 0 || segmentIndex > 2)
{
error = "Segment index out of range.";
return false;
}
try
{
var json = Base64UrlEncoder.Decode(segments[segmentIndex]);
using var document = JsonDocument.Parse(json);
element = document.RootElement.Clone();
return true;
}
catch (Exception ex)
{
error = ex.Message;
return false;
}
}
private static class NullReplayCache
{
public static readonly IDpopReplayCache Instance = new Noop();
private sealed class Noop : IDpopReplayCache
{
public ValueTask<bool> TryStoreAsync(string jwtId, DateTimeOffset expiresAt, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(jwtId);
return ValueTask.FromResult(true);
}
}
}
}
file static class DpopValidationOptionsExtensions
{
public static TimeSpan GetMaximumAge(this DpopValidationOptions options)
=> options.ProofLifetime + options.AllowedClockSkew;
}

View File

@@ -0,0 +1,77 @@
using System.Collections.Immutable;
using System.Collections.Generic;
using System.Linq;
namespace StellaOps.Auth.Security.Dpop;
/// <summary>
/// Configures acceptable algorithms and replay windows for DPoP proof validation.
/// </summary>
public sealed class DpopValidationOptions
{
private readonly HashSet<string> allowedAlgorithms = new(StringComparer.Ordinal);
public DpopValidationOptions()
{
allowedAlgorithms.Add("ES256");
allowedAlgorithms.Add("ES384");
}
/// <summary>
/// Maximum age a proof is considered valid relative to <see cref="IssuedAt"/>.
/// </summary>
public TimeSpan ProofLifetime { get; set; } = TimeSpan.FromMinutes(2);
/// <summary>
/// Allowed clock skew when evaluating <c>iat</c>.
/// </summary>
public TimeSpan AllowedClockSkew { get; set; } = TimeSpan.FromSeconds(30);
/// <summary>
/// Duration a successfully validated proof is tracked to prevent replay.
/// </summary>
public TimeSpan ReplayWindow { get; set; } = TimeSpan.FromMinutes(5);
/// <summary>
/// Algorithms (JWA) permitted for DPoP proofs.
/// </summary>
public ISet<string> AllowedAlgorithms => allowedAlgorithms;
/// <summary>
/// Normalised, upper-case representation of allowed algorithms.
/// </summary>
public IReadOnlySet<string> NormalizedAlgorithms { get; private set; } = ImmutableHashSet<string>.Empty;
public void Validate()
{
if (ProofLifetime <= TimeSpan.Zero)
{
throw new InvalidOperationException("DPoP proof lifetime must be greater than zero.");
}
if (AllowedClockSkew < TimeSpan.Zero || AllowedClockSkew > TimeSpan.FromMinutes(5))
{
throw new InvalidOperationException("DPoP allowed clock skew must be between 0 seconds and 5 minutes.");
}
if (ReplayWindow < TimeSpan.Zero)
{
throw new InvalidOperationException("DPoP replay window must be greater than or equal to zero.");
}
if (allowedAlgorithms.Count == 0)
{
throw new InvalidOperationException("At least one allowed DPoP algorithm must be configured.");
}
NormalizedAlgorithms = allowedAlgorithms
.Select(static algorithm => algorithm.Trim().ToUpperInvariant())
.Where(static algorithm => algorithm.Length > 0)
.ToImmutableHashSet(StringComparer.Ordinal);
if (NormalizedAlgorithms.Count == 0)
{
throw new InvalidOperationException("Allowed DPoP algorithms cannot be empty after normalization.");
}
}
}

View File

@@ -0,0 +1,40 @@
using Microsoft.IdentityModel.Tokens;
namespace StellaOps.Auth.Security.Dpop;
/// <summary>
/// Represents the outcome of DPoP proof validation.
/// </summary>
public sealed class DpopValidationResult
{
private DpopValidationResult(bool success, string? errorCode, string? errorDescription, SecurityKey? key, string? jwtId, DateTimeOffset? issuedAt, string? nonce)
{
IsValid = success;
ErrorCode = errorCode;
ErrorDescription = errorDescription;
PublicKey = key;
JwtId = jwtId;
IssuedAt = issuedAt;
Nonce = nonce;
}
public bool IsValid { get; }
public string? ErrorCode { get; }
public string? ErrorDescription { get; }
public SecurityKey? PublicKey { get; }
public string? JwtId { get; }
public DateTimeOffset? IssuedAt { get; }
public string? Nonce { get; }
public static DpopValidationResult Success(SecurityKey key, string jwtId, DateTimeOffset issuedAt, string? nonce)
=> new(true, null, null, key, jwtId, issuedAt, nonce);
public static DpopValidationResult Failure(string code, string description)
=> new(false, code, description, null, null, null, null);
}

View File

@@ -0,0 +1,45 @@
using System;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Auth.Security.Dpop;
/// <summary>
/// Provides persistence and validation for DPoP nonces.
/// </summary>
public interface IDpopNonceStore
{
/// <summary>
/// Issues a nonce tied to the specified audience, client, and DPoP key thumbprint.
/// </summary>
/// <param name="audience">Audience the nonce applies to.</param>
/// <param name="clientId">Client identifier requesting the nonce.</param>
/// <param name="keyThumbprint">Thumbprint of the DPoP public key.</param>
/// <param name="ttl">Time-to-live for the nonce.</param>
/// <param name="maxIssuancePerMinute">Maximum number of nonces that can be issued within a one-minute window for the tuple.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Outcome describing the issued nonce.</returns>
ValueTask<DpopNonceIssueResult> IssueAsync(
string audience,
string clientId,
string keyThumbprint,
TimeSpan ttl,
int maxIssuancePerMinute,
CancellationToken cancellationToken = default);
/// <summary>
/// Attempts to consume a nonce previously issued for the tuple.
/// </summary>
/// <param name="nonce">Nonce supplied by the client.</param>
/// <param name="audience">Audience the nonce should match.</param>
/// <param name="clientId">Client identifier.</param>
/// <param name="keyThumbprint">Thumbprint of the DPoP public key.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Outcome describing whether the nonce was accepted.</returns>
ValueTask<DpopNonceConsumeResult> TryConsumeAsync(
string nonce,
string audience,
string clientId,
string keyThumbprint,
CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,6 @@
namespace StellaOps.Auth.Security.Dpop;
public interface IDpopProofValidator
{
ValueTask<DpopValidationResult> ValidateAsync(string proof, string httpMethod, Uri httpUri, string? nonce = null, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,6 @@
namespace StellaOps.Auth.Security.Dpop;
public interface IDpopReplayCache
{
ValueTask<bool> TryStoreAsync(string jwtId, DateTimeOffset expiresAt, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,176 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Security.Cryptography;
using System.Text;
using System.Threading;
using Microsoft.Extensions.Logging;
using System.Threading.Tasks;
namespace StellaOps.Auth.Security.Dpop;
/// <summary>
/// In-memory implementation of <see cref="IDpopNonceStore"/> suitable for single-host or test environments.
/// </summary>
public sealed class InMemoryDpopNonceStore : IDpopNonceStore
{
private static readonly TimeSpan IssuanceWindow = TimeSpan.FromMinutes(1);
private readonly ConcurrentDictionary<string, StoredNonce> nonces = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<string, IssuanceBucket> issuanceBuckets = new(StringComparer.Ordinal);
private readonly TimeProvider timeProvider;
private readonly ILogger<InMemoryDpopNonceStore>? logger;
public InMemoryDpopNonceStore(TimeProvider? timeProvider = null, ILogger<InMemoryDpopNonceStore>? logger = null)
{
this.timeProvider = timeProvider ?? TimeProvider.System;
this.logger = logger;
}
public ValueTask<DpopNonceIssueResult> IssueAsync(
string audience,
string clientId,
string keyThumbprint,
TimeSpan ttl,
int maxIssuancePerMinute,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(audience);
ArgumentException.ThrowIfNullOrWhiteSpace(clientId);
ArgumentException.ThrowIfNullOrWhiteSpace(keyThumbprint);
if (ttl <= TimeSpan.Zero)
{
throw new ArgumentOutOfRangeException(nameof(ttl), "Nonce TTL must be greater than zero.");
}
if (maxIssuancePerMinute < 1)
{
throw new ArgumentOutOfRangeException(nameof(maxIssuancePerMinute), "Max issuance per minute must be at least 1.");
}
cancellationToken.ThrowIfCancellationRequested();
var now = timeProvider.GetUtcNow();
var bucketKey = BuildBucketKey(audience, clientId, keyThumbprint);
var bucket = issuanceBuckets.GetOrAdd(bucketKey, static _ => new IssuanceBucket());
bool allowed;
lock (bucket.SyncRoot)
{
bucket.Prune(now - IssuanceWindow);
if (bucket.IssuanceTimes.Count >= maxIssuancePerMinute)
{
allowed = false;
}
else
{
bucket.IssuanceTimes.Enqueue(now);
allowed = true;
}
}
if (!allowed)
{
logger?.LogDebug("DPoP nonce issuance throttled for {BucketKey}.", bucketKey);
return ValueTask.FromResult(DpopNonceIssueResult.RateLimited("rate_limited"));
}
var nonce = GenerateNonce();
var nonceKey = BuildNonceKey(audience, clientId, keyThumbprint, nonce);
var expiresAt = now + ttl;
nonces[nonceKey] = new StoredNonce(now, expiresAt);
return ValueTask.FromResult(DpopNonceIssueResult.Success(nonce, expiresAt));
}
public ValueTask<DpopNonceConsumeResult> TryConsumeAsync(
string nonce,
string audience,
string clientId,
string keyThumbprint,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(nonce);
ArgumentException.ThrowIfNullOrWhiteSpace(audience);
ArgumentException.ThrowIfNullOrWhiteSpace(clientId);
ArgumentException.ThrowIfNullOrWhiteSpace(keyThumbprint);
cancellationToken.ThrowIfCancellationRequested();
var now = timeProvider.GetUtcNow();
var nonceKey = BuildNonceKey(audience, clientId, keyThumbprint, nonce);
if (!nonces.TryRemove(nonceKey, out var stored))
{
logger?.LogDebug("DPoP nonce {NonceKey} not found during consumption.", nonceKey);
return ValueTask.FromResult(DpopNonceConsumeResult.NotFound());
}
if (stored.ExpiresAt <= now)
{
logger?.LogDebug("DPoP nonce {NonceKey} expired at {ExpiresAt:o}.", nonceKey, stored.ExpiresAt);
return ValueTask.FromResult(DpopNonceConsumeResult.Expired(stored.IssuedAt, stored.ExpiresAt));
}
return ValueTask.FromResult(DpopNonceConsumeResult.Success(stored.IssuedAt, stored.ExpiresAt));
}
private static string BuildBucketKey(string audience, string clientId, string keyThumbprint)
=> $"{audience.Trim().ToLowerInvariant()}::{clientId.Trim().ToLowerInvariant()}::{keyThumbprint.Trim().ToLowerInvariant()}";
private static string BuildNonceKey(string audience, string clientId, string keyThumbprint, string nonce)
{
var bucketKey = BuildBucketKey(audience, clientId, keyThumbprint);
var digest = ComputeSha256(nonce);
return $"{bucketKey}::{digest}";
}
private static string ComputeSha256(string value)
{
var bytes = Encoding.UTF8.GetBytes(value);
var hash = SHA256.HashData(bytes);
return Base64UrlEncode(hash);
}
private static string Base64UrlEncode(ReadOnlySpan<byte> bytes)
{
return Convert.ToBase64String(bytes)
.TrimEnd('=')
.Replace('+', '-')
.Replace('/', '_');
}
private static string GenerateNonce()
{
Span<byte> buffer = stackalloc byte[32];
RandomNumberGenerator.Fill(buffer);
return Base64UrlEncode(buffer);
}
private sealed class StoredNonce
{
internal StoredNonce(DateTimeOffset issuedAt, DateTimeOffset expiresAt)
{
IssuedAt = issuedAt;
ExpiresAt = expiresAt;
}
internal DateTimeOffset IssuedAt { get; }
internal DateTimeOffset ExpiresAt { get; }
}
private sealed class IssuanceBucket
{
internal object SyncRoot { get; } = new();
internal Queue<DateTimeOffset> IssuanceTimes { get; } = new();
internal void Prune(DateTimeOffset threshold)
{
while (IssuanceTimes.Count > 0 && IssuanceTimes.Peek() < threshold)
{
IssuanceTimes.Dequeue();
}
}
}
}

View File

@@ -0,0 +1,66 @@
using System.Collections.Concurrent;
namespace StellaOps.Auth.Security.Dpop;
/// <summary>
/// In-memory replay cache intended for single-process deployments or tests.
/// </summary>
public sealed class InMemoryDpopReplayCache : IDpopReplayCache
{
private readonly ConcurrentDictionary<string, DateTimeOffset> entries = new(StringComparer.Ordinal);
private readonly TimeProvider timeProvider;
public InMemoryDpopReplayCache(TimeProvider? timeProvider = null)
{
this.timeProvider = timeProvider ?? TimeProvider.System;
}
public ValueTask<bool> TryStoreAsync(string jwtId, DateTimeOffset expiresAt, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(jwtId);
var now = timeProvider.GetUtcNow();
RemoveExpired(now);
if (entries.TryAdd(jwtId, expiresAt))
{
return ValueTask.FromResult(true);
}
while (!cancellationToken.IsCancellationRequested)
{
if (!entries.TryGetValue(jwtId, out var existing))
{
if (entries.TryAdd(jwtId, expiresAt))
{
return ValueTask.FromResult(true);
}
continue;
}
if (existing > now)
{
return ValueTask.FromResult(false);
}
if (entries.TryUpdate(jwtId, expiresAt, existing))
{
return ValueTask.FromResult(true);
}
}
return ValueTask.FromResult(false);
}
private void RemoveExpired(DateTimeOffset now)
{
foreach (var entry in entries)
{
if (entry.Value <= now)
{
entries.TryRemove(entry.Key, out _);
}
}
}
}

View File

@@ -0,0 +1,138 @@
using System;
using System.Globalization;
using System.Threading;
using System.Threading.Tasks;
using StackExchange.Redis;
namespace StellaOps.Auth.Security.Dpop;
/// <summary>
/// Redis-backed implementation of <see cref="IDpopNonceStore"/> that supports multi-node deployments.
/// </summary>
public sealed class RedisDpopNonceStore : IDpopNonceStore
{
private const string ConsumeScript = @"
local value = redis.call('GET', KEYS[1])
if value ~= false and value == ARGV[1] then
redis.call('DEL', KEYS[1])
return 1
end
return 0";
private readonly IConnectionMultiplexer connection;
private readonly TimeProvider timeProvider;
public RedisDpopNonceStore(IConnectionMultiplexer connection, TimeProvider? timeProvider = null)
{
this.connection = connection ?? throw new ArgumentNullException(nameof(connection));
this.timeProvider = timeProvider ?? TimeProvider.System;
}
public async ValueTask<DpopNonceIssueResult> IssueAsync(
string audience,
string clientId,
string keyThumbprint,
TimeSpan ttl,
int maxIssuancePerMinute,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(audience);
ArgumentException.ThrowIfNullOrWhiteSpace(clientId);
ArgumentException.ThrowIfNullOrWhiteSpace(keyThumbprint);
if (ttl <= TimeSpan.Zero)
{
throw new ArgumentOutOfRangeException(nameof(ttl), "Nonce TTL must be greater than zero.");
}
if (maxIssuancePerMinute < 1)
{
throw new ArgumentOutOfRangeException(nameof(maxIssuancePerMinute), "Max issuance per minute must be at least 1.");
}
cancellationToken.ThrowIfCancellationRequested();
var database = connection.GetDatabase();
var issuedAt = timeProvider.GetUtcNow();
var baseKey = DpopNonceUtilities.ComputeStorageKey(audience, clientId, keyThumbprint);
var nonceKey = (RedisKey)baseKey;
var metadataKey = (RedisKey)(baseKey + ":meta");
var rateKey = (RedisKey)(baseKey + ":rate");
var rateCount = await database.StringIncrementAsync(rateKey, flags: CommandFlags.DemandMaster).ConfigureAwait(false);
if (rateCount == 1)
{
await database.KeyExpireAsync(rateKey, TimeSpan.FromMinutes(1), CommandFlags.DemandMaster).ConfigureAwait(false);
}
if (rateCount > maxIssuancePerMinute)
{
return DpopNonceIssueResult.RateLimited("rate_limited");
}
var nonce = DpopNonceUtilities.GenerateNonce();
var hash = (RedisValue)DpopNonceUtilities.EncodeHash(DpopNonceUtilities.ComputeNonceHash(nonce));
var expiresAt = issuedAt + ttl;
await database.StringSetAsync(nonceKey, hash, ttl, When.Always, CommandFlags.DemandMaster).ConfigureAwait(false);
var metadataValue = FormattableString.Invariant($"{issuedAt.UtcTicks}|{ttl.Ticks}");
await database.StringSetAsync(metadataKey, metadataValue, ttl, When.Always, CommandFlags.DemandMaster).ConfigureAwait(false);
return DpopNonceIssueResult.Success(nonce, expiresAt);
}
public async ValueTask<DpopNonceConsumeResult> TryConsumeAsync(
string nonce,
string audience,
string clientId,
string keyThumbprint,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(nonce);
ArgumentException.ThrowIfNullOrWhiteSpace(audience);
ArgumentException.ThrowIfNullOrWhiteSpace(clientId);
ArgumentException.ThrowIfNullOrWhiteSpace(keyThumbprint);
cancellationToken.ThrowIfCancellationRequested();
var database = connection.GetDatabase();
var baseKey = DpopNonceUtilities.ComputeStorageKey(audience, clientId, keyThumbprint);
var nonceKey = (RedisKey)baseKey;
var metadataKey = (RedisKey)(baseKey + ":meta");
var hash = (RedisValue)DpopNonceUtilities.EncodeHash(DpopNonceUtilities.ComputeNonceHash(nonce));
var rawResult = await database.ScriptEvaluateAsync(
ConsumeScript,
new[] { nonceKey },
new RedisValue[] { hash }).ConfigureAwait(false);
if (rawResult.IsNull || (long)rawResult != 1)
{
return DpopNonceConsumeResult.NotFound();
}
var metadata = await database.StringGetAsync(metadataKey).ConfigureAwait(false);
await database.KeyDeleteAsync(metadataKey, CommandFlags.DemandMaster).ConfigureAwait(false);
if (!metadata.IsNull)
{
var parts = metadata.ToString()
.Split('|', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (parts.Length == 2 &&
long.TryParse(parts[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out var issuedTicks) &&
long.TryParse(parts[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out var ttlTicks))
{
var issuedAt = new DateTimeOffset(issuedTicks, TimeSpan.Zero);
var expiresAt = issuedAt + TimeSpan.FromTicks(ttlTicks);
return expiresAt <= timeProvider.GetUtcNow()
? DpopNonceConsumeResult.Expired(issuedAt, expiresAt)
: DpopNonceConsumeResult.Success(issuedAt, expiresAt);
}
}
return DpopNonceConsumeResult.Success(timeProvider.GetUtcNow(), timeProvider.GetUtcNow());
}
}

View File

@@ -0,0 +1,3 @@
# StellaOps.Auth.Security
Shared sender-constraint helpers (DPoP proof validation, replay caches, future mTLS utilities) used by Authority, Scanner, Signer, and other StellaOps services. This package centralises primitives so services remain deterministic while honouring proof-of-possession guarantees.

View File

@@ -0,0 +1,38 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<PropertyGroup>
<Description>Sender-constrained authentication primitives (DPoP, mTLS) shared across StellaOps services.</Description>
<PackageId>StellaOps.Auth.Security</PackageId>
<Authors>StellaOps</Authors>
<Company>StellaOps</Company>
<PackageTags>stellaops;dpop;mtls;oauth2;security</PackageTags>
<PackageLicenseExpression>AGPL-3.0-or-later</PackageLicenseExpression>
<PackageProjectUrl>https://stella-ops.org</PackageProjectUrl>
<RepositoryUrl>https://git.stella-ops.org/stella-ops.org/git.stella-ops.org</RepositoryUrl>
<RepositoryType>git</RepositoryType>
<PublishRepositoryUrl>true</PublishRepositoryUrl>
<EmbedUntrackedSources>true</EmbedUntrackedSources>
<IncludeSymbols>true</IncludeSymbols>
<SymbolPackageFormat>snupkg</SymbolPackageFormat>
<PackageReadmeFile>README.md</PackageReadmeFile>
<VersionPrefix>1.0.0-preview.1</VersionPrefix>
</PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.IdentityModel.Tokens" Version="7.2.0" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="7.2.0" />
<PackageReference Include="StackExchange.Redis" Version="2.8.24" />
<PackageReference Include="Microsoft.SourceLink.GitLab" Version="8.0.0" PrivateAssets="All" />
</ItemGroup>
<ItemGroup>
<None Include="README.md" Pack="true" PackagePath="" />
</ItemGroup>
</Project>