diff --git a/src/Microsoft.IdentityModel.Dpop/DPoPConstants.cs b/src/Microsoft.IdentityModel.Dpop/DPoPConstants.cs
index f0f415d8ed..c8cb6b7312 100644
--- a/src/Microsoft.IdentityModel.Dpop/DPoPConstants.cs
+++ b/src/Microsoft.IdentityModel.Dpop/DPoPConstants.cs
@@ -26,4 +26,32 @@ public static class DPoPConstants
/// The DPoP nonce HTTP header name.
///
public const string DPoPNonceHeaderName = "DPoP-Nonce";
+
+ ///
+ /// The default maximum DPoP proof lifetime in seconds, measured from the iat claim (5 minutes).
+ ///
+ public const int DefaultMaxLifetimeInSeconds = 300;
+
+ ///
+ /// The default clock skew tolerance in seconds applied to DPoP proof timestamp validation (5 minutes).
+ ///
+ public const int DefaultClockSkewInSeconds = 300;
+
+ ///
+ /// The default maximum DPoP proof JWT size in bytes (8 KiB).
+ /// Realistic proofs are well under 2 KiB; the cap bounds parse work for malformed input.
+ ///
+ public const int DefaultMaxProofTokenSizeInBytes = 8 * 1024;
+
+ ///
+ /// The default maximum RSA modulus size in bits permitted for DPoP proof signing keys.
+ /// Bounds the cost of verifying client-controlled keys (verification cost is super-linear in modulus size).
+ ///
+ public const int DefaultMaxRsaKeySizeInBits = 4096;
+
+ ///
+ /// The default minimum RSA modulus size in bits permitted for DPoP proof signing keys.
+ /// Matches NIST SP 800-131A guidance for asymmetric signature verification beyond 2030.
+ ///
+ public const int DefaultMinRsaKeySizeInBits = 2048;
}
diff --git a/src/Microsoft.IdentityModel.Dpop/DPoPProofValidator.cs b/src/Microsoft.IdentityModel.Dpop/DPoPProofValidator.cs
index 142ab35a76..e06de559bb 100644
--- a/src/Microsoft.IdentityModel.Dpop/DPoPProofValidator.cs
+++ b/src/Microsoft.IdentityModel.Dpop/DPoPProofValidator.cs
@@ -2,6 +2,7 @@
// Licensed under the MIT License.
using System;
+using System.Buffers;
using System.Security.Cryptography;
using System.Text;
using System.Threading;
@@ -59,6 +60,9 @@ public virtual async Task ValidateAsync(
if (string.IsNullOrWhiteSpace(dpopProofJwt))
return DPoPValidationResult.Failed("DPoP proof is empty.");
+ if (dpopProofJwt.Length > options.MaxProofTokenSizeInBytes)
+ return DPoPValidationResult.Failed("DPoP proof exceeds the maximum allowed size.");
+
if (string.IsNullOrWhiteSpace(accessToken))
return DPoPValidationResult.Failed("Access token is empty.");
@@ -92,9 +96,23 @@ internal static string ComputeAccessTokenHash(string accessToken)
{
_ = accessToken ?? throw new ArgumentNullException(nameof(accessToken));
- // Encoding.ASCII is safe here — access tokens are JWTs (base64url-encoded segments
- // separated by dots), so every character is guaranteed ASCII.
- var tokenBytes = Encoding.ASCII.GetBytes(accessToken);
+#if NET6_0_OR_GREATER
+ // Typical access tokens (1-2 KB) hash without any heap allocation on the input side;
+ // larger tokens fall through to the heap path below. 1 KB cap keeps the stack budget
+ // safe for use under deep ASP.NET pipelines.
+ const int StackAllocThreshold = 1024;
+ int maxBytes = Encoding.UTF8.GetMaxByteCount(accessToken.Length);
+ if (maxBytes <= StackAllocThreshold)
+ {
+ Span tokenSpan = stackalloc byte[StackAllocThreshold];
+ int written = Encoding.UTF8.GetBytes(accessToken, tokenSpan);
+ Span hashSpan = stackalloc byte[32];
+ SHA256.HashData(tokenSpan.Slice(0, written), hashSpan);
+ return Base64UrlEncoder.Encode(hashSpan.ToArray());
+ }
+#endif
+
+ var tokenBytes = Encoding.UTF8.GetBytes(accessToken);
#if NET6_0_OR_GREATER
var hash = SHA256.HashData(tokenBytes);
#else
@@ -128,6 +146,142 @@ internal static bool ContainsPrivateKeyMaterial(JsonWebKey jwk)
!string.IsNullOrEmpty(jwk.QI);
}
+ ///
+ /// Converts a to an asymmetric from its
+ /// public key parameters (n/e for RSA, crv/x/y for EC).
+ ///
+ internal static bool TryConvertToAsymmetricKeyFromBareParameters(JsonWebKey jwk, out SecurityKey key)
+ {
+ _ = jwk ?? throw new ArgumentNullException(nameof(jwk));
+
+ key = null;
+
+ if (JsonWebAlgorithmsKeyTypes.RSA.Equals(jwk.Kty))
+ {
+ return JsonWebKeyConverter.TryCreateToRsaSecurityKey(jwk, out key);
+ }
+ else if (JsonWebAlgorithmsKeyTypes.EllipticCurve.Equals(jwk.Kty))
+ {
+ return JsonWebKeyConverter.TryConvertToECDsaSecurityKey(jwk, out key);
+ }
+
+ return false;
+ }
+
+ ///
+ /// Verifies the DPoP proof signature using a pooled buffer for the signing input.
+ ///
+ private static bool VerifyProofSignature(JsonWebToken proofToken, SignatureProvider signatureProvider)
+ {
+ string encodedToken = proofToken.EncodedToken;
+ string encodedSignature = proofToken.EncodedSignature;
+ // JWS signing input is "header.payload"; subtract the signature length plus 1 for the '.' separator that precedes it.
+ int signingInputLength = encodedToken.Length - encodedSignature.Length - 1;
+ if (signingInputLength <= 0)
+ {
+ // Defensive: JsonWebToken parsing should already guarantee header.payload.signature shape,
+ // but bail out before Rent(negative) would throw if a malformed token ever reached us.
+ return false;
+ }
+
+ byte[] messageBuffer = ArrayPool.Shared.Rent(signingInputLength);
+ try
+ {
+ Encoding.ASCII.GetBytes(encodedToken, 0, signingInputLength, messageBuffer, 0);
+ byte[] signatureBytes = Base64UrlEncoder.DecodeBytes(encodedSignature);
+ return signatureProvider.Verify(messageBuffer, 0, signingInputLength, signatureBytes, 0, signatureBytes.Length);
+ }
+ finally
+ {
+ ArrayPool.Shared.Return(messageBuffer);
+ }
+ }
+
+ ///
+ /// Constant-time UTF-8 comparison of two strings. On TFMs that support span-based encoding the
+ /// UTF-8 bytes are produced into stack-allocated buffers so the hot comparison paths
+ /// (ath, nonce, cnf.jkt) avoid heap allocations entirely. Larger inputs and older TFMs fall back
+ /// to the allocating helper.
+ ///
+ private static bool AreEqualUtf8(string a, string b)
+ {
+ if (a == null || b == null)
+ {
+ return false;
+ }
+
+#if NET6_0_OR_GREATER
+ // 256 bytes covers ath (~43), nonce (typically ≤ 128), and cnf.jkt (~43) with headroom.
+ const int StackAllocThreshold = 256;
+ int maxBytesA = Encoding.UTF8.GetMaxByteCount(a.Length);
+ int maxBytesB = Encoding.UTF8.GetMaxByteCount(b.Length);
+ if (maxBytesA <= StackAllocThreshold && maxBytesB <= StackAllocThreshold)
+ {
+ Span bufA = stackalloc byte[StackAllocThreshold];
+ Span bufB = stackalloc byte[StackAllocThreshold];
+ int lenA = Encoding.UTF8.GetBytes(a, bufA);
+ int lenB = Encoding.UTF8.GetBytes(b, bufB);
+ return System.Security.Cryptography.CryptographicOperations.FixedTimeEquals(bufA.Slice(0, lenA), bufB.Slice(0, lenB));
+ }
+#endif
+ return Utility.AreEqual(Encoding.UTF8.GetBytes(a), Encoding.UTF8.GetBytes(b));
+ }
+
+ ///
+ /// Validates that the JWK is acceptable for the given algorithm — kty/alg/crv consistency
+ /// plus configured RSA modulus bounds. Returns a human-readable reason on rejection, or null on success.
+ ///
+ private static string ValidateJwkForAlgorithm(string alg, JsonWebKey jwk, DPoPValidationOptions options)
+ {
+ if (jwk.Kty != JsonWebAlgorithmsKeyTypes.RSA && jwk.Kty != JsonWebAlgorithmsKeyTypes.EllipticCurve)
+ return $"DPoP proof JWK 'kty' '{jwk.Kty}' is not supported; expected 'RSA' or 'EC'.";
+
+ if (!jwk.IsSupportedAlgorithm(alg))
+ return $"DPoP proof algorithm '{alg}' is not supported by the JWK (kty '{jwk.Kty}').";
+
+ if (jwk.Kty == JsonWebAlgorithmsKeyTypes.RSA)
+ {
+ if (jwk.N == null)
+ return "DPoP proof RSA JWK is missing the 'n' parameter.";
+
+ // Bound the RSA modulus size without decoding base64url. Each base64url character carries
+ // 6 bits of payload, so N.Length * 6 is an upper bound on the encoded modulus bit length
+ // (the true value is within 8 bits of this ceiling because the final base64url char may
+ // contribute fewer than 6 significant bits and the decoded byte string can include a leading
+ // 0x00). We add 8 to the maximum to avoid rejecting keys at the boundary, and compare the
+ // ceiling directly to the minimum so we only reject when even the most generous interpretation
+ // is below the floor. Runs before key import to bound DoS cost on client-controlled keys.
+ int encodedBitCeiling = jwk.N.Length * 6;
+ if (encodedBitCeiling > options.MaxRsaKeySizeInBits + 8)
+ return "DPoP proof RSA key exceeds the maximum allowed size.";
+
+ if (encodedBitCeiling < options.MinRsaKeySizeInBits)
+ return "DPoP proof RSA key is below the minimum allowed size.";
+
+ return null;
+ }
+
+ // EC: pin curve to alg.
+ string expectedCrv = alg switch
+ {
+ SecurityAlgorithms.EcdsaSha256 or SecurityAlgorithms.EcdsaSha256Signature => JsonWebKeyECTypes.P256,
+ SecurityAlgorithms.EcdsaSha384 or SecurityAlgorithms.EcdsaSha384Signature => JsonWebKeyECTypes.P384,
+ SecurityAlgorithms.EcdsaSha512 or SecurityAlgorithms.EcdsaSha512Signature => JsonWebKeyECTypes.P521,
+ _ => null,
+ };
+
+ if (expectedCrv == null)
+ return $"DPoP proof algorithm '{alg}' has no curve binding defined for EC keys.";
+
+ bool crvMatches = jwk.Crv == expectedCrv
+ || (expectedCrv == JsonWebKeyECTypes.P521 && jwk.Crv == JsonWebKeyECTypes.P512);
+
+ if (!crvMatches)
+ return $"DPoP proof algorithm '{alg}' requires curve '{expectedCrv}' but JWK 'crv' is '{jwk.Crv}'.";
+
+ return null;
+ }
+
///
/// Computes the base64url-encoded SHA-256 JWK thumbprint per RFC 7638.
///
@@ -207,23 +361,37 @@ private static async Task ValidateCoreAsync(
return DPoPValidationResult.Failed("DPoP proof JWK must not contain private key material.");
}
- // Validate signature using extracted JWK
- var validationParams = new TokenValidationParameters
+ string jwkRejectReason = ValidateJwkForAlgorithm(alg, jwk, options);
+ if (jwkRejectReason != null)
{
- ValidateIssuer = false,
- ValidateAudience = false,
- ValidateLifetime = false,
- IssuerSigningKey = jwk,
- ValidAlgorithms = new string[] { alg },
- };
+ return DPoPValidationResult.Failed(jwkRejectReason);
+ }
- var signatureResult = await s_tokenHandler
- .ValidateTokenAsync(proofToken, validationParams, cancellationToken)
- .ConfigureAwait(false);
+ // Convert the JWK to a SecurityKey from its public key parameters.
+ if (!TryConvertToAsymmetricKeyFromBareParameters(jwk, out SecurityKey signingKey))
+ {
+ return DPoPValidationResult.Failed("DPoP proof JWK could not be converted to a supported asymmetric key.");
+ }
- if (!signatureResult.IsValid)
+ // Verify the proof signature without caching the SignatureProvider.
+ CryptoProviderFactory cryptoProviderFactory = signingKey.CryptoProviderFactory ?? CryptoProviderFactory.Default;
+ SignatureProvider signatureProvider = null;
+ try
+ {
+ signatureProvider = cryptoProviderFactory.CreateForVerifying(signingKey, alg, cacheProvider: false);
+ if (!VerifyProofSignature(proofToken, signatureProvider))
+ {
+ return DPoPValidationResult.Failed("DPoP proof signature validation failed.");
+ }
+ }
+ catch (Exception ex)
+ {
+ return DPoPValidationResult.Failed("DPoP proof signature validation failed.", ex);
+ }
+ finally
{
- return DPoPValidationResult.Failed("DPoP proof signature validation failed.");
+ if (signatureProvider != null)
+ cryptoProviderFactory.ReleaseSignatureProvider(signatureProvider);
}
// Validate htm matches HTTP method
@@ -284,20 +452,6 @@ private static async Task ValidateCoreAsync(
return DPoPValidationResult.Failed("DPoP proof is missing the 'jti' claim.");
}
- // Replay protection
- if (options.JtiReplayCache != null)
- {
- var jtiExpiration = issuedAt.Add(maxAge);
- bool added = await options.JtiReplayCache
- .TryAddAsync(jtiValue, jtiExpiration, cancellationToken)
- .ConfigureAwait(false);
-
- if (!added)
- {
- return DPoPValidationResult.Failed("DPoP proof 'jti' has already been used (replay detected).");
- }
- }
-
// Validate nonce if expected (null = skip nonce validation)
if (options.ExpectedNonce != null)
{
@@ -312,7 +466,7 @@ private static async Task ValidateCoreAsync(
return DPoPValidationResult.NonceRequired();
}
- if (!string.Equals(options.ExpectedNonce, nonceValue, StringComparison.Ordinal))
+ if (!AreEqualUtf8(options.ExpectedNonce, nonceValue))
{
return DPoPValidationResult.NonceValidationFailed();
}
@@ -326,18 +480,32 @@ private static async Task ValidateCoreAsync(
}
var expectedAth = ComputeAccessTokenHash(accessToken);
- if (!string.Equals(athValue, expectedAth, StringComparison.Ordinal))
+ if (!AreEqualUtf8(athValue, expectedAth))
{
return DPoPValidationResult.Failed("DPoP proof 'ath' claim does not match the access token hash.");
}
// Compute thumbprint and validate cnf.jkt binding
var thumbprint = ComputeJwkThumbprint(jwk);
- if (!Utility.AreEqual(Encoding.UTF8.GetBytes(expectedCnfJkt), Encoding.UTF8.GetBytes(thumbprint)))
+ if (!AreEqualUtf8(expectedCnfJkt, thumbprint))
{
return DPoPValidationResult.Failed("DPoP proof JWK thumbprint does not match the access token cnf.jkt claim.");
}
+ // Replay protection
+ if (options.JtiReplayCache != null)
+ {
+ var jtiExpiration = issuedAt.Add(maxAge);
+ bool added = await options.JtiReplayCache
+ .TryAddAsync(jtiValue, jtiExpiration, cancellationToken)
+ .ConfigureAwait(false);
+
+ if (!added)
+ {
+ return DPoPValidationResult.Failed("DPoP proof 'jti' has already been used (replay detected).");
+ }
+ }
+
string proofNonceForResult = proofToken.TryGetPayloadValue(DPoPClaimTypes.Nonce, out string proofNonce) ? proofNonce : null;
return DPoPValidationResult.Success(proofNonceForResult);
}
diff --git a/src/Microsoft.IdentityModel.Dpop/DPoPValidationOptions.cs b/src/Microsoft.IdentityModel.Dpop/DPoPValidationOptions.cs
index 9df8985539..5d219d7f96 100644
--- a/src/Microsoft.IdentityModel.Dpop/DPoPValidationOptions.cs
+++ b/src/Microsoft.IdentityModel.Dpop/DPoPValidationOptions.cs
@@ -19,18 +19,79 @@ public class DPoPValidationOptions
public ISet AllowedSigningAlgorithms { get; set; }
= new HashSet(StringComparer.Ordinal) { "ES256", "PS256", "RS256" };
+ private int _maxLifetimeInSeconds = DPoPConstants.DefaultMaxLifetimeInSeconds;
+ private int _clockSkewInSeconds = DPoPConstants.DefaultClockSkewInSeconds;
+ private int _maxProofTokenSizeInBytes = DPoPConstants.DefaultMaxProofTokenSizeInBytes;
+ private int _maxRsaKeySizeInBits = DPoPConstants.DefaultMaxRsaKeySizeInBits;
+ private int _minRsaKeySizeInBits = DPoPConstants.DefaultMinRsaKeySizeInBits;
+
///
/// Gets or sets the maximum proof lifetime in seconds, measured from the iat claim.
- /// Default: 300 (5 minutes).
+ /// Default: .
+ ///
+ /// Thrown when set to a value less than 1.
+ public int MaxLifetimeInSeconds
+ {
+ get => _maxLifetimeInSeconds;
+ set => _maxLifetimeInSeconds = value < 1
+ ? throw new ArgumentOutOfRangeException(nameof(value), value, "MaxLifetimeInSeconds must be greater than zero.")
+ : value;
+ }
+
+ ///
+ /// Gets or sets the maximum DPoP proof JWT size in bytes.
+ /// Proofs exceeding this size are rejected before parsing.
+ /// Default: .
+ ///
+ /// Thrown when set to a value less than 1.
+ public int MaxProofTokenSizeInBytes
+ {
+ get => _maxProofTokenSizeInBytes;
+ set => _maxProofTokenSizeInBytes = value < 1
+ ? throw new ArgumentOutOfRangeException(nameof(value), value, "MaxProofTokenSizeInBytes must be greater than zero.")
+ : value;
+ }
+
+ ///
+ /// Gets or sets the maximum RSA modulus size in bits permitted for DPoP proof signing keys.
+ /// Bounds the cost of verifying client-controlled keys.
+ /// Default: .
+ ///
+ /// Thrown when set to a value less than 1.
+ public int MaxRsaKeySizeInBits
+ {
+ get => _maxRsaKeySizeInBits;
+ set => _maxRsaKeySizeInBits = value < 1
+ ? throw new ArgumentOutOfRangeException(nameof(value), value, "MaxRsaKeySizeInBits must be greater than zero.")
+ : value;
+ }
+
+ ///
+ /// Gets or sets the minimum RSA modulus size in bits required for DPoP proof signing keys.
+ /// Default: .
///
- public int MaxLifetimeInSeconds { get; set; } = 300;
+ /// Thrown when set to a value less than 1.
+ public int MinRsaKeySizeInBits
+ {
+ get => _minRsaKeySizeInBits;
+ set => _minRsaKeySizeInBits = value < 1
+ ? throw new ArgumentOutOfRangeException(nameof(value), value, "MinRsaKeySizeInBits must be greater than zero.")
+ : value;
+ }
///
/// Gets or sets the clock skew tolerance in seconds.
/// Applied both for proofs that are slightly too old and for proofs issued slightly in the future.
- /// Default: 300 (5 minutes).
+ /// Default: .
///
- public int ClockSkewInSeconds { get; set; } = 300;
+ /// Thrown when set to a negative value.
+ public int ClockSkewInSeconds
+ {
+ get => _clockSkewInSeconds;
+ set => _clockSkewInSeconds = value < 0
+ ? throw new ArgumentOutOfRangeException(nameof(value), value, "ClockSkewInSeconds must be non-negative.")
+ : value;
+ }
///
/// Gets or sets the expected nonce value (RP-provided).
diff --git a/src/Microsoft.IdentityModel.Dpop/InternalAPI.Unshipped.txt b/src/Microsoft.IdentityModel.Dpop/InternalAPI.Unshipped.txt
index 8b6c4916fe..357c697e68 100644
--- a/src/Microsoft.IdentityModel.Dpop/InternalAPI.Unshipped.txt
+++ b/src/Microsoft.IdentityModel.Dpop/InternalAPI.Unshipped.txt
@@ -1,3 +1,4 @@
static Microsoft.IdentityModel.Dpop.DPoPProofValidator.ComputeAccessTokenHash(string accessToken) -> string
static Microsoft.IdentityModel.Dpop.DPoPProofValidator.ComputeJwkThumbprint(Microsoft.IdentityModel.Tokens.JsonWebKey jwk) -> string
static Microsoft.IdentityModel.Dpop.DPoPProofValidator.ContainsPrivateKeyMaterial(Microsoft.IdentityModel.Tokens.JsonWebKey jwk) -> bool
+static Microsoft.IdentityModel.Dpop.DPoPProofValidator.TryConvertToAsymmetricKeyFromBareParameters(Microsoft.IdentityModel.Tokens.JsonWebKey jwk, out Microsoft.IdentityModel.Tokens.SecurityKey key) -> bool
diff --git a/src/Microsoft.IdentityModel.Dpop/PublicAPI.Unshipped.txt b/src/Microsoft.IdentityModel.Dpop/PublicAPI.Unshipped.txt
index 0617f5ce63..8c5c7fbd72 100644
--- a/src/Microsoft.IdentityModel.Dpop/PublicAPI.Unshipped.txt
+++ b/src/Microsoft.IdentityModel.Dpop/PublicAPI.Unshipped.txt
@@ -4,6 +4,11 @@ const Microsoft.IdentityModel.Dpop.DPoPClaimTypes.Htu = "htu" -> string
const Microsoft.IdentityModel.Dpop.DPoPClaimTypes.Iat = "iat" -> string
const Microsoft.IdentityModel.Dpop.DPoPClaimTypes.Jti = "jti" -> string
const Microsoft.IdentityModel.Dpop.DPoPClaimTypes.Nonce = "nonce" -> string
+const Microsoft.IdentityModel.Dpop.DPoPConstants.DefaultClockSkewInSeconds = 300 -> int
+const Microsoft.IdentityModel.Dpop.DPoPConstants.DefaultMaxLifetimeInSeconds = 300 -> int
+const Microsoft.IdentityModel.Dpop.DPoPConstants.DefaultMaxProofTokenSizeInBytes = 8192 -> int
+const Microsoft.IdentityModel.Dpop.DPoPConstants.DefaultMaxRsaKeySizeInBits = 4096 -> int
+const Microsoft.IdentityModel.Dpop.DPoPConstants.DefaultMinRsaKeySizeInBits = 2048 -> int
const Microsoft.IdentityModel.Dpop.DPoPConstants.DPoPNonceHeaderName = "DPoP-Nonce" -> string
const Microsoft.IdentityModel.Dpop.DPoPConstants.DPoPProofTokenType = "dpop+jwt" -> string
const Microsoft.IdentityModel.Dpop.DPoPConstants.DPoPTokenType = "DPoP" -> string
@@ -27,6 +32,12 @@ Microsoft.IdentityModel.Dpop.DPoPValidationOptions.JtiReplayCache.get -> Microso
Microsoft.IdentityModel.Dpop.DPoPValidationOptions.JtiReplayCache.set -> void
Microsoft.IdentityModel.Dpop.DPoPValidationOptions.MaxLifetimeInSeconds.get -> int
Microsoft.IdentityModel.Dpop.DPoPValidationOptions.MaxLifetimeInSeconds.set -> void
+Microsoft.IdentityModel.Dpop.DPoPValidationOptions.MaxProofTokenSizeInBytes.get -> int
+Microsoft.IdentityModel.Dpop.DPoPValidationOptions.MaxProofTokenSizeInBytes.set -> void
+Microsoft.IdentityModel.Dpop.DPoPValidationOptions.MaxRsaKeySizeInBits.get -> int
+Microsoft.IdentityModel.Dpop.DPoPValidationOptions.MaxRsaKeySizeInBits.set -> void
+Microsoft.IdentityModel.Dpop.DPoPValidationOptions.MinRsaKeySizeInBits.get -> int
+Microsoft.IdentityModel.Dpop.DPoPValidationOptions.MinRsaKeySizeInBits.set -> void
Microsoft.IdentityModel.Dpop.DPoPValidationResult
Microsoft.IdentityModel.Dpop.DPoPValidationResult.Error.get -> string
Microsoft.IdentityModel.Dpop.DPoPValidationResult.ErrorCode.get -> string
diff --git a/test/Microsoft.IdentityModel.Dpop.Tests/DPoPE2ETests.cs b/test/Microsoft.IdentityModel.Dpop.Tests/DPoPE2ETests.cs
index f650e2c0e2..04a1e44f68 100644
--- a/test/Microsoft.IdentityModel.Dpop.Tests/DPoPE2ETests.cs
+++ b/test/Microsoft.IdentityModel.Dpop.Tests/DPoPE2ETests.cs
@@ -4,6 +4,7 @@
using System;
using System.Collections.Generic;
using System.Security.Cryptography;
+using System.Text;
using System.Threading.Tasks;
using Microsoft.IdentityModel.JsonWebTokens;
using Microsoft.IdentityModel.Tokens;
@@ -228,4 +229,188 @@ public async Task E2E_NonceMismatch_ReturnsNonceRequired()
Assert.False(result.IsValid);
Assert.True(result.IsNonceRequired);
}
-}
\ No newline at end of file
+
+ [Fact]
+ public async Task E2E_JwkWithMismatchedX5cAndBareKey_BindingFails()
+ {
+ var (accessToken, originalProofKey, _) = CreateAccessTokenWithCnfJkt();
+ var nonce = "server-provided-nonce-xyz789";
+ var method = "POST";
+ var uri = new Uri("https://api.example.com/data");
+
+ var handler = new JsonWebTokenHandler();
+ var at = handler.ReadJsonWebToken(accessToken);
+ Assert.True(at.TryGetPayloadValue("cnf", out System.Text.Json.JsonElement cnf));
+ var jkt = cnf.GetProperty("jkt").GetString();
+
+ var validator = new DPoPProofValidator();
+ var options = new DPoPValidationOptions
+ {
+ AllowedSigningAlgorithms = new HashSet(StringComparer.Ordinal) { "RS256" },
+ ExpectedNonce = nonce,
+ };
+
+ var otherKey = CreateTestRsa();
+ var x5cValue = Convert.ToBase64String(new byte[] { 0x30, 0x82, 0x01, 0x00 });
+
+ byte[] athHash;
+ using (var sha = SHA256.Create())
+ athHash = sha.ComputeHash(Encoding.ASCII.GetBytes(accessToken));
+ var athValue = Base64UrlEncoder.Encode(athHash);
+
+ var originalJwk = JsonWebKeyConverter.ConvertFromSecurityKey(new RsaSecurityKey(originalProofKey));
+ var htu = uri.GetLeftPart(UriPartial.Path);
+ var iat = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
+ var jti = Guid.NewGuid().ToString();
+
+ var headerJson =
+ "{\"typ\":\"dpop+jwt\",\"alg\":\"RS256\",\"jwk\":{" +
+ "\"kty\":\"" + originalJwk.Kty + "\"," +
+ "\"e\":\"" + originalJwk.E + "\"," +
+ "\"n\":\"" + originalJwk.N + "\"," +
+ "\"x5c\":[\"" + x5cValue + "\"]" +
+ "}}";
+
+ var payloadJson =
+ "{\"htm\":\"" + method + "\"," +
+ "\"htu\":\"" + htu + "\"," +
+ "\"iat\":" + iat + "," +
+ "\"jti\":\"" + jti + "\"," +
+ "\"ath\":\"" + athValue + "\"," +
+ "\"nonce\":\"" + nonce + "\"}";
+
+ var encodedHeader = Base64UrlEncoder.Encode(Encoding.UTF8.GetBytes(headerJson));
+ var encodedPayload = Base64UrlEncoder.Encode(Encoding.UTF8.GetBytes(payloadJson));
+ var signingInput = encodedHeader + "." + encodedPayload;
+ var signature = otherKey.SignData(
+ Encoding.ASCII.GetBytes(signingInput),
+ HashAlgorithmName.SHA256,
+ RSASignaturePadding.Pkcs1);
+ var mismatchedProofJwt = signingInput + "." + Base64UrlEncoder.Encode(signature);
+
+ var mismatchedResult = await validator.ValidateAsync(
+ mismatchedProofJwt, method, uri, accessToken, jkt, options);
+
+ Assert.False(
+ mismatchedResult.IsValid,
+ $"Proof should be rejected. Error was: {mismatchedResult.Error ?? ""}");
+
+ var proofCreator = new DPoPProofCreator(new DPoPProofCreatorOptions
+ {
+ SigningCredentials = new SigningCredentials(new RsaSecurityKey(originalProofKey), SecurityAlgorithms.RsaSha256),
+ IncludeNonce = true,
+ Nonce = nonce,
+ });
+ var wellFormedProofJwt = proofCreator.CreateProof(method, uri, accessToken);
+ var wellFormedResult = await validator.ValidateAsync(
+ wellFormedProofJwt, method, uri, accessToken, jkt, options);
+ Assert.True(wellFormedResult.IsValid, $"Well-formed proof should validate: {wellFormedResult.Error}");
+ }
+
+ [Fact]
+ public async Task E2E_ValidateProof_DoesNotCacheSignatureProvider()
+ {
+ var (accessToken, proofKey, _) = CreateAccessTokenWithCnfJkt();
+ var nonce = "nonce-cache-single";
+ var method = "GET";
+ var uri = new Uri("https://api.example.com/cache-single");
+
+ var handler = new JsonWebTokenHandler();
+ var at = handler.ReadJsonWebToken(accessToken);
+ Assert.True(at.TryGetPayloadValue("cnf", out System.Text.Json.JsonElement cnf));
+ var jkt = cnf.GetProperty("jkt").GetString();
+
+ var options = new DPoPValidationOptions
+ {
+ AllowedSigningAlgorithms = new HashSet(StringComparer.Ordinal) { "RS256" },
+ ExpectedNonce = nonce,
+ };
+
+ var proofCreator = new DPoPProofCreator(new DPoPProofCreatorOptions
+ {
+ SigningCredentials = new SigningCredentials(new RsaSecurityKey(proofKey), SecurityAlgorithms.RsaSha256),
+ IncludeNonce = true,
+ Nonce = nonce,
+ });
+ var proofJwt = proofCreator.CreateProof(method, uri, accessToken);
+
+ var probeKey = new RsaSecurityKey(proofKey);
+ var providerTypeName = typeof(AsymmetricSignatureProvider).ToString();
+ Assert.False(
+ CryptoProviderFactory.Default.CryptoProviderCache.TryGetSignatureProvider(
+ probeKey, SecurityAlgorithms.RsaSha256, providerTypeName, willCreateSignatures: false, out _),
+ "Pre-validation: the verifying SignatureProvider should not already be cached.");
+
+ var validator = new DPoPProofValidator();
+ var result = await validator.ValidateAsync(proofJwt, method, uri, accessToken, jkt, options);
+ Assert.True(result.IsValid, $"Proof should validate: {result.Error}");
+
+ Assert.False(
+ CryptoProviderFactory.Default.CryptoProviderCache.TryGetSignatureProvider(
+ probeKey, SecurityAlgorithms.RsaSha256, providerTypeName, willCreateSignatures: false, out _),
+ "Post-validation: validating a DPoP proof must not add the verifying SignatureProvider to the cache.");
+ }
+
+ [Fact]
+ public async Task E2E_ValidateManyProofs_DoesNotPopulateCacheWithEphemeralKeys()
+ {
+ const int proofCount = 25;
+ var validator = new DPoPProofValidator();
+ var providerTypeName = typeof(AsymmetricSignatureProvider).ToString();
+ var proofKeys = new List();
+
+ try
+ {
+ for (int i = 0; i < proofCount; i++)
+ {
+ var (accessToken, proofKey, _) = CreateAccessTokenWithCnfJkt();
+ proofKeys.Add(proofKey);
+
+ var nonce = "nonce-cache-many-" + i;
+ var method = "POST";
+ var uri = new Uri("https://api.example.com/cache-many-" + i);
+
+ var handler = new JsonWebTokenHandler();
+ var at = handler.ReadJsonWebToken(accessToken);
+ Assert.True(at.TryGetPayloadValue("cnf", out System.Text.Json.JsonElement cnf));
+ var jkt = cnf.GetProperty("jkt").GetString();
+
+ var options = new DPoPValidationOptions
+ {
+ AllowedSigningAlgorithms = new HashSet(StringComparer.Ordinal) { "RS256" },
+ ExpectedNonce = nonce,
+ };
+
+ var proofCreator = new DPoPProofCreator(new DPoPProofCreatorOptions
+ {
+ SigningCredentials = new SigningCredentials(new RsaSecurityKey(proofKey), SecurityAlgorithms.RsaSha256),
+ IncludeNonce = true,
+ Nonce = nonce,
+ });
+ var proofJwt = proofCreator.CreateProof(method, uri, accessToken);
+
+ var result = await validator.ValidateAsync(proofJwt, method, uri, accessToken, jkt, options);
+ Assert.True(result.IsValid, $"Proof #{i} should validate: {result.Error}");
+ }
+
+ int cachedCount = 0;
+ foreach (var key in proofKeys)
+ {
+ var probeKey = new RsaSecurityKey(key);
+ if (CryptoProviderFactory.Default.CryptoProviderCache.TryGetSignatureProvider(
+ probeKey, SecurityAlgorithms.RsaSha256, providerTypeName,
+ willCreateSignatures: false, out _))
+ {
+ cachedCount++;
+ }
+ }
+
+ Assert.Equal(0, cachedCount);
+ }
+ finally
+ {
+ foreach (var key in proofKeys)
+ key.Dispose();
+ }
+ }
+}
diff --git a/test/Microsoft.IdentityModel.Dpop.Tests/DPoPProofValidatorTests.cs b/test/Microsoft.IdentityModel.Dpop.Tests/DPoPProofValidatorTests.cs
index 34c7d86932..dbea4635ba 100644
--- a/test/Microsoft.IdentityModel.Dpop.Tests/DPoPProofValidatorTests.cs
+++ b/test/Microsoft.IdentityModel.Dpop.Tests/DPoPProofValidatorTests.cs
@@ -991,6 +991,197 @@ public async Task ValidateAsync_RsaKey_Succeeds()
#endregion
+ #region Size Limits & Granular Errors
+
+ [Fact]
+ public async Task ValidateAsync_ProofExceedsMaxSize_ReturnsInvalid()
+ {
+ var (proof, accessToken, cnfJkt) = CreateProofAndAccessToken();
+ var options = DefaultOptions();
+ options.MaxProofTokenSizeInBytes = 16;
+
+ var result = await _validator.ValidateAsync(
+ proof, "GET", new Uri("https://resource.example.org/api"), accessToken, cnfJkt, options);
+
+ Assert.False(result.IsValid);
+ Assert.Contains("maximum allowed size", result.Error);
+ }
+
+ [Fact]
+ public async Task ValidateAsync_ProofUnderMaxSize_Succeeds()
+ {
+ var (proof, accessToken, cnfJkt) = CreateProofAndAccessToken();
+ var options = DefaultOptions();
+ options.MaxProofTokenSizeInBytes = proof.Length + 1;
+
+ var result = await _validator.ValidateAsync(
+ proof, "GET", new Uri("https://resource.example.org/api"), accessToken, cnfJkt, options);
+
+ Assert.True(result.IsValid);
+ }
+
+ [Fact]
+ public async Task ValidateAsync_RsaModulusBelowMin_ReturnsInvalid()
+ {
+ // 2048-bit key + an artificially high minimum to trigger the floor.
+ var (proof, accessToken, cnfJkt) = CreateProofAndAccessToken();
+ var options = DefaultOptions();
+ options.MinRsaKeySizeInBits = 4096;
+
+ var result = await _validator.ValidateAsync(
+ proof, "GET", new Uri("https://resource.example.org/api"), accessToken, cnfJkt, options);
+
+ Assert.False(result.IsValid);
+ Assert.Contains("below the minimum allowed size", result.Error);
+ }
+
+ [Fact]
+ public async Task ValidateAsync_RsaModulusAboveMax_ReturnsInvalid()
+ {
+ // 2048-bit key + an artificially low maximum to trigger the ceiling.
+ var (proof, accessToken, cnfJkt) = CreateProofAndAccessToken();
+ var options = DefaultOptions();
+ options.MaxRsaKeySizeInBits = 1024;
+
+ var result = await _validator.ValidateAsync(
+ proof, "GET", new Uri("https://resource.example.org/api"), accessToken, cnfJkt, options);
+
+ Assert.False(result.IsValid);
+ Assert.Contains("exceeds the maximum allowed size", result.Error);
+ }
+
+ [Fact]
+ public async Task ValidateAsync_RsaModulusWithinBounds_Succeeds()
+ {
+ // 2048-bit key with defaults (Min=2048, Max=4096) should succeed.
+ var (proof, accessToken, cnfJkt) = CreateProofAndAccessToken();
+ var options = DefaultOptions();
+
+ var result = await _validator.ValidateAsync(
+ proof, "GET", new Uri("https://resource.example.org/api"), accessToken, cnfJkt, options);
+
+ Assert.True(result.IsValid);
+ }
+
+ [Theory]
+ [InlineData(0)]
+ [InlineData(-1)]
+ public void MaxLifetimeInSeconds_InvalidValue_Throws(int value)
+ {
+ var options = new DPoPValidationOptions();
+ Assert.Throws(() => options.MaxLifetimeInSeconds = value);
+ }
+
+ [Theory]
+ [InlineData(-1)]
+ [InlineData(-300)]
+ public void ClockSkewInSeconds_NegativeValue_Throws(int value)
+ {
+ var options = new DPoPValidationOptions();
+ Assert.Throws(() => options.ClockSkewInSeconds = value);
+ }
+
+ [Fact]
+ public void Defaults_MatchConstants()
+ {
+ var options = new DPoPValidationOptions();
+ Assert.Equal(DPoPConstants.DefaultMaxLifetimeInSeconds, options.MaxLifetimeInSeconds);
+ Assert.Equal(DPoPConstants.DefaultClockSkewInSeconds, options.ClockSkewInSeconds);
+ Assert.Equal(DPoPConstants.DefaultMaxProofTokenSizeInBytes, options.MaxProofTokenSizeInBytes);
+ Assert.Equal(DPoPConstants.DefaultMaxRsaKeySizeInBits, options.MaxRsaKeySizeInBits);
+ Assert.Equal(DPoPConstants.DefaultMinRsaKeySizeInBits, options.MinRsaKeySizeInBits);
+ }
+
+ [Theory]
+ [InlineData(0)]
+ [InlineData(-1)]
+ public void MaxProofTokenSizeInBytes_InvalidValue_Throws(int value)
+ {
+ var options = new DPoPValidationOptions();
+ Assert.Throws(() => options.MaxProofTokenSizeInBytes = value);
+ }
+
+ [Theory]
+ [InlineData(0)]
+ [InlineData(-1)]
+ public void MaxRsaKeySizeInBits_InvalidValue_Throws(int value)
+ {
+ var options = new DPoPValidationOptions();
+ Assert.Throws(() => options.MaxRsaKeySizeInBits = value);
+ }
+
+ [Theory]
+ [InlineData(0)]
+ [InlineData(-1)]
+ public void MinRsaKeySizeInBits_InvalidValue_Throws(int value)
+ {
+ var options = new DPoPValidationOptions();
+ Assert.Throws(() => options.MinRsaKeySizeInBits = value);
+ }
+
+#if !NET462
+ [Fact]
+ public async Task ValidateAsync_EcAlgWithMismatchedCurve_ReturnsCurveSpecificError()
+ {
+ // Sign with ES256 (requires P-256) but advertise a P-384 JWK in the header.
+ var p256 = ECDsa.Create(ECCurve.NamedCurves.nistP256);
+ var p384 = ECDsa.Create(ECCurve.NamedCurves.nistP384);
+ var (accessToken, cnfJkt) = CreateSimpleAccessToken(CreateTestRsa());
+
+ var signingCredentials = new SigningCredentials(
+ new ECDsaSecurityKey(p256), SecurityAlgorithms.EcdsaSha256);
+
+ var p384Params = p384.ExportParameters(false);
+ var jwkForHeader = new System.Text.Json.Nodes.JsonObject
+ {
+ ["kty"] = "EC",
+ ["crv"] = "P-384",
+ ["x"] = Base64UrlEncoder.Encode(p384Params.Q.X),
+ ["y"] = Base64UrlEncoder.Encode(p384Params.Q.Y),
+ };
+
+ var now = DateTimeOffset.UtcNow;
+ var claims = new Dictionary
+ {
+ ["htm"] = "GET",
+ ["htu"] = "https://resource.example.org/api",
+ ["iat"] = now.ToUnixTimeSeconds(),
+ ["jti"] = Guid.NewGuid().ToString(),
+ ["nonce"] = DefaultTestNonce,
+ };
+
+ var descriptor = new SecurityTokenDescriptor
+ {
+ IncludeKeyIdInHeader = false,
+ Claims = claims,
+ AdditionalHeaderClaims = new Dictionary
+ {
+ { "typ", "dpop+jwt" },
+ { "jwk", jwkForHeader },
+ },
+ SigningCredentials = signingCredentials,
+ };
+ var handler = new JsonWebTokenHandler { SetDefaultTimesOnTokenCreation = false };
+ var proof = handler.CreateToken(descriptor);
+
+ var options = new DPoPValidationOptions
+ {
+ AllowedSigningAlgorithms = new HashSet(StringComparer.Ordinal) { "ES256" },
+ ExpectedNonce = DefaultTestNonce,
+ };
+
+ var result = await _validator.ValidateAsync(
+ proof, "GET", new Uri("https://resource.example.org/api"), accessToken, cnfJkt, options);
+
+ Assert.False(result.IsValid);
+ Assert.Contains("requires curve", result.Error);
+ }
+#endif
+
+ #endregion
+
+
+
#region Test Doubles
///