Resolve Concelier/Excititor merge conflicts
This commit is contained in:
50
src/StellaOps.Auth.Security/Dpop/DpopNonceConsumeResult.cs
Normal file
50
src/StellaOps.Auth.Security/Dpop/DpopNonceConsumeResult.cs
Normal 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
|
||||
}
|
||||
56
src/StellaOps.Auth.Security/Dpop/DpopNonceIssueResult.cs
Normal file
56
src/StellaOps.Auth.Security/Dpop/DpopNonceIssueResult.cs
Normal 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
|
||||
}
|
||||
66
src/StellaOps.Auth.Security/Dpop/DpopNonceUtilities.cs
Normal file
66
src/StellaOps.Auth.Security/Dpop/DpopNonceUtilities.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
258
src/StellaOps.Auth.Security/Dpop/DpopProofValidator.cs
Normal file
258
src/StellaOps.Auth.Security/Dpop/DpopProofValidator.cs
Normal 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;
|
||||
}
|
||||
77
src/StellaOps.Auth.Security/Dpop/DpopValidationOptions.cs
Normal file
77
src/StellaOps.Auth.Security/Dpop/DpopValidationOptions.cs
Normal 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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
40
src/StellaOps.Auth.Security/Dpop/DpopValidationResult.cs
Normal file
40
src/StellaOps.Auth.Security/Dpop/DpopValidationResult.cs
Normal 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);
|
||||
}
|
||||
45
src/StellaOps.Auth.Security/Dpop/IDpopNonceStore.cs
Normal file
45
src/StellaOps.Auth.Security/Dpop/IDpopNonceStore.cs
Normal 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);
|
||||
}
|
||||
6
src/StellaOps.Auth.Security/Dpop/IDpopProofValidator.cs
Normal file
6
src/StellaOps.Auth.Security/Dpop/IDpopProofValidator.cs
Normal 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);
|
||||
}
|
||||
6
src/StellaOps.Auth.Security/Dpop/IDpopReplayCache.cs
Normal file
6
src/StellaOps.Auth.Security/Dpop/IDpopReplayCache.cs
Normal file
@@ -0,0 +1,6 @@
|
||||
namespace StellaOps.Auth.Security.Dpop;
|
||||
|
||||
public interface IDpopReplayCache
|
||||
{
|
||||
ValueTask<bool> TryStoreAsync(string jwtId, DateTimeOffset expiresAt, CancellationToken cancellationToken = default);
|
||||
}
|
||||
176
src/StellaOps.Auth.Security/Dpop/InMemoryDpopNonceStore.cs
Normal file
176
src/StellaOps.Auth.Security/Dpop/InMemoryDpopNonceStore.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
66
src/StellaOps.Auth.Security/Dpop/InMemoryDpopReplayCache.cs
Normal file
66
src/StellaOps.Auth.Security/Dpop/InMemoryDpopReplayCache.cs
Normal 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 _);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
138
src/StellaOps.Auth.Security/Dpop/RedisDpopNonceStore.cs
Normal file
138
src/StellaOps.Auth.Security/Dpop/RedisDpopNonceStore.cs
Normal 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());
|
||||
}
|
||||
}
|
||||
3
src/StellaOps.Auth.Security/README.md
Normal file
3
src/StellaOps.Auth.Security/README.md
Normal 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.
|
||||
38
src/StellaOps.Auth.Security/StellaOps.Auth.Security.csproj
Normal file
38
src/StellaOps.Auth.Security/StellaOps.Auth.Security.csproj
Normal 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>
|
||||
Reference in New Issue
Block a user