feat: add DPoP sender-constrained token support (RFC 9449)#331
feat: add DPoP sender-constrained token support (RFC 9449)#331davidbemer wants to merge 3 commits into
Conversation
- Add DPoPUtils.java with full RFC 9449 §7.1-7.2 proof validation (RS256/384/512, ES256/384/512, PS256/384/512, EdDSA) - Add validateDPoP() to AuthenticationService interface and AuthenticationServiceImpl - Add DPoPUtilsTest.java with unit tests for valid/invalid proofs (ES256, RS256) - Update README with DPoP usage section Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
🐕 Review complete — View session on Shuni Portal 🐾 |
There was a problem hiding this comment.
🐕 Shuni's Review
Adds RFC 9449 DPoP proof validation mirroring the Go SDK. Crypto logic looks solid for RS*/ES*/PS*; tests cover the main paths.
Sniffed out 2 issues:
- 1 🟠 HIGH: EdDSA OKP key decoding doesn't clear the x-sign bit before computing y — rejects ~50% of valid Ed25519/Ed448 proofs
- 1 🟡 MEDIUM: Unchecked
(String)/(Number)casts on JWT claims leakClassCastExceptioninstead ofClientFunctionalExceptionfor malformed proofs
See inline comments for details. Woof!
| new java.security.spec.EdECPoint( | ||
| (xBytes[xBytes.length - 1] & 0x80) != 0, | ||
| new BigInteger(1, reverseBytes(xBytes)) | ||
| )); |
There was a problem hiding this comment.
🟠 HIGH: The x-sign bit is not cleared before computing the y coordinate. Per RFC 8032 §5.1.2, the encoded Ed25519/Ed448 public key stores the x-sign in the high bit of the last (little-endian) byte; that bit must be masked off before the remaining bits are interpreted as y. As written, when the sign bit is set, BigInteger(1, reverseBytes(xBytes)) produces y_expected + 2^255 (Ed25519), which is outside the valid range and will cause KeyFactory.generatePublic to throw — so roughly half of otherwise-valid EdDSA proofs will be rejected as signature verification failed. EdDSA is advertised in ALLOWED_ALGS (line 35) but never exercised by tests, which is why this slipped through.
| new java.security.spec.EdECPoint( | |
| (xBytes[xBytes.length - 1] & 0x80) != 0, | |
| new BigInteger(1, reverseBytes(xBytes)) | |
| )); | |
| byte[] yBytes = xBytes.clone(); | |
| yBytes[yBytes.length - 1] &= 0x7F; // clear x-sign bit before computing y | |
| java.security.spec.EdECPublicKeySpec edSpec = | |
| new java.security.spec.EdECPublicKeySpec(namedSpec, | |
| new java.security.spec.EdECPoint( | |
| (xBytes[xBytes.length - 1] & 0x80) != 0, | |
| new BigInteger(1, reverseBytes(yBytes)) | |
| )); |
There was a problem hiding this comment.
Fixed. Replaced the Java 15+ EdECPublicKeySpec/NamedParameterSpec/EdECPoint approach entirely with BouncyCastle (bcprov-jdk18on, already in pom.xml, scope changed from runtime to compile). The new importOKPKey builds a minimal DER SubjectPublicKeyInfo (OID + BIT STRING of the raw key bytes), then uses KeyFactory.getInstance("Ed25519", "BC") with an X509EncodedKeySpec. BouncyCastle's Ed25519 decoder handles the RFC 8032 §5.1.2 compressed encoding natively — the sign bit in the MSB of the last byte is consumed correctly without any manual masking needed.
| throw ClientFunctionalException.invalidToken( | ||
| new IllegalArgumentException("missing iat")); | ||
| } | ||
| long iat = ((Number) iatObj).longValue(); |
There was a problem hiding this comment.
🟡 MEDIUM: Several JWT claim casts are not wrapped in a try/catch and will leak ClassCastException (or NullPointerException for method) instead of ClientFunctionalException.invalidToken if a caller supplies a malformed proof:
- L127
(String) header.get("alg")— non-stringalg - L135
(Map<String, Object>) header.get("jwk")— non-objectjwk - L181 / L188 / L201 / L226
(String) payload.get(...)forjti/htm/htu/ath - L195
method.equals(htm)— NPE if caller passesmethod == null - L217
((Number) iatObj).longValue()— non-numericiat
A malicious or buggy client can change the exception type observed by callers (callers catching DescopeException/ClientFunctionalException will miss the error and may leak a stack trace). Wrap the post-parse extraction in a try/catch that maps unexpected types to ClientFunctionalException.invalidToken(...), or validate types explicitly (e.g. if (!(header.get("alg") instanceof String))).
There was a problem hiding this comment.
Fixed. All unchecked casts in the payload and header parsing are now guarded by instanceof checks. Specifically: alg uses instanceof String, jwk uses instanceof Map (the separate null check was removed as instanceof handles null), jti/htm/htu/ath all use instanceof String, and iat uses instanceof Number. Any type mismatch now throws ClientFunctionalException.invalidToken instead of ClassCastException.
There was a problem hiding this comment.
Pull request overview
Adds DPoP (RFC 9449) proof validation to the Java SDK so session tokens with cnf.jkt can be sender-constrained, exposing a new AuthenticationService.validateDPoP(...) API plus documentation and unit tests.
Changes:
- Introduces
DPoPUtils.validateDPoPProof(...)to validate DPoP proof JWS headers/claims, signature,ath, and JWK thumbprint (jkt). - Exposes DPoP validation via
AuthenticationService.validateDPoP(...)and wires it inAuthenticationServiceImpl. - Adds README usage docs and a new
DPoPUtilsTestcovering several success/error paths.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
| src/main/java/com/descope/utils/DPoPUtils.java | New DPoP validation utility implementing proof parsing, signature verification, URL/method checks, and JWK thumbprint binding. |
| src/test/java/com/descope/utils/DPoPUtilsTest.java | New unit tests for DPoP thumbprint extraction and core validation scenarios (ES256/RS256 + error cases). |
| src/main/java/com/descope/sdk/auth/AuthenticationService.java | Adds public API method to validate DPoP proofs for DPoP-bound session tokens. |
| src/main/java/com/descope/sdk/auth/impl/AuthenticationServiceImpl.java | Implements the new validateDPoP API and delegates to DPoPUtils. |
| README.md | Documents how to call validateDPoP after validating a session token. |
Comments suppressed due to low confidence (1)
src/test/java/com/descope/utils/DPoPUtilsTest.java:110
- This file uses Java 11+ APIs (e.g., String.repeat) and will not compile with the project’s Java 8 target. Replace with a Java 8-compatible way to generate a long string (e.g., loop/StringBuilder) to keep tests compiling under <maven.compiler.source>1.8.</comment_content>
String sessionToken = buildRawJwtWithJkt(jkt);
String longProof = "a".repeat(8193);
assertThrows(ClientFunctionalException.class, () ->
DPoPUtils.validateDPoPProof(longProof, "GET", "https://api.example.com/resource", sessionToken));
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| import com.fasterxml.jackson.databind.ObjectMapper; | ||
| import java.math.BigInteger; | ||
| import java.net.URI; | ||
| import java.nio.charset.StandardCharsets; | ||
| import java.security.AlgorithmParameters; | ||
| import java.security.KeyPair; | ||
| import java.security.KeyPairGenerator; | ||
| import java.security.MessageDigest; | ||
| import java.security.Signature; | ||
| import java.security.interfaces.ECPrivateKey; | ||
| import java.security.interfaces.ECPublicKey; | ||
| import java.security.interfaces.RSAPrivateKey; | ||
| import java.security.interfaces.RSAPublicKey; | ||
| import java.security.spec.ECGenParameterSpec; | ||
| import java.security.spec.ECParameterSpec; | ||
| import java.util.Base64; |
There was a problem hiding this comment.
Fixed. Removed unused imports BigInteger, URI, AlgorithmParameters, and ECParameterSpec from DPoPUtilsTest.java. These were only referenced in a comment (BigInteger) or not at all.
| // Use NamedParameterSpec via reflection to support Java 11+ with BC provider | ||
| java.security.spec.NamedParameterSpec namedSpec = | ||
| new java.security.spec.NamedParameterSpec(algorithmName); | ||
| java.security.spec.EdECPublicKeySpec edSpec = | ||
| new java.security.spec.EdECPublicKeySpec(namedSpec, | ||
| new java.security.spec.EdECPoint( | ||
| (xBytes[xBytes.length - 1] & 0x80) != 0, | ||
| new BigInteger(1, reverseBytes(xBytes)) | ||
| )); | ||
| return KeyFactory.getInstance("EdDSA").generatePublic(edSpec); |
There was a problem hiding this comment.
Fixed. Replaced all Java 15+ APIs (NamedParameterSpec, EdECPublicKeySpec, EdECPoint) with a BouncyCastle-based approach that is Java 8 compatible. The new implementation builds a DER-encoded SubjectPublicKeyInfo for the Ed25519/Ed448 key and uses KeyFactory.getInstance(algorithm, "BC") with an X509EncodedKeySpec. BouncyCastle (bcprov-jdk18on 1.84) is already in pom.xml; its scope was changed from runtime to compile since we now reference it at compile time.
| )); | ||
| return KeyFactory.getInstance("EdDSA").generatePublic(edSpec); | ||
| } catch (Exception e) { | ||
| throw new IllegalArgumentException("failed to import OKP key for curve " + crv + ": " + e.getMessage()); |
There was a problem hiding this comment.
Fixed. All lines in DPoPUtils.java have been verified to be within the 120-character Checkstyle limit. The new OKP implementation uses intermediate variables and wrapped method calls to stay within the limit.
| // Step 20: match htm | ||
| if (!method.equals(htm)) { | ||
| throw ClientFunctionalException.invalidToken( | ||
| new IllegalArgumentException("htm mismatch")); | ||
| } |
There was a problem hiding this comment.
Fixed. Changed method.equals(htm) to htm.equals(method). Since htm is already validated as non-null/non-empty by the instanceof+isEmpty check immediately above, this is safe and also avoids NPE if the caller passes null for method (which is now also guarded upstream in AuthenticationServiceImpl.validateDPoP).
| if (iatObj == null) { | ||
| throw ClientFunctionalException.invalidToken( | ||
| new IllegalArgumentException("missing iat")); | ||
| } |
There was a problem hiding this comment.
Fixed. Added instanceof Number check before casting iat: if the claim is absent, null, or not a Number, the code now throws ClientFunctionalException.invalidToken with message 'missing iat'.
| void validateDPoP(String sessionToken, String dpopProof, String method, String requestUrl) | ||
| throws DescopeException; |
There was a problem hiding this comment.
Fixed. Changed validateDPoP from an abstract method to a default method in the AuthenticationService interface. The default implementation throws UnsupportedOperationException, which preserves backward compatibility: existing interface implementations that predate DPoP support will compile without change and fail loudly at runtime only if the new method is actually called.
| throws DescopeException { | ||
| if (StringUtils.isBlank(sessionToken)) { | ||
| throw ServerCommonException.invalidArgument("sessionToken"); | ||
| } |
There was a problem hiding this comment.
Fixed. Added StringUtils.isBlank checks for both method and requestUrl in AuthenticationServiceImpl.validateDPoP, throwing ServerCommonException.invalidArgument for either missing value. This prevents NPE from propagating into DPoPUtils and returns a clear error to the caller.
| /** | ||
| * Imports an OKP (EdDSA) key. Requires Java 15+ or BouncyCastle provider. | ||
| * Falls back to BouncyCastle's XDHPublicKeyParameters for Ed25519/Ed448. | ||
| */ | ||
| private static PublicKey importOKPKey(Map<String, Object> jwk) throws Exception { |
There was a problem hiding this comment.
Fixed. Rewrote the Javadoc to accurately describe the implementation: BouncyCastle is used directly (via SubjectPublicKeyInfo + X509EncodedKeySpec pattern) for Java 8 compatibility. Removed the incorrect mention of a 'fallback' — BouncyCastle is the primary and only implementation.
- Replace Java 15+ EdECPublicKeySpec/NamedParameterSpec with BouncyCastle (bcprov-jdk18on) for Java 8 compatible OKP/EdDSA key import; use DER-encoded SubjectPublicKeyInfo + X509EncodedKeySpec pattern; BC handles the RFC 8032 §5.1.2 sign bit in the compressed encoding natively - Change BC pom.xml scope from runtime to compile (now referenced at compile time) - Fix unchecked casts in header/payload parsing (alg, jwk, jti, htm, htu, ath, iat) to use instanceof checks and throw invalidToken on malformed input - Fix NPE: change method.equals(htm) to htm.equals(method) - Add instanceof+Number check for iat claim before cast - Make AuthenticationService.validateDPoP a default method to preserve backward compatibility with existing interface implementations - Add null/blank validation for method and requestUrl in AuthenticationServiceImpl - Add jti replay-protection note to validateDPoPProof Javadoc - Fix wrong Javadoc on importOKPKey (removed mention of non-existent BC fallback) - Remove unused imports (BigInteger, URI, AlgorithmParameters, ECParameterSpec) from DPoPUtilsTest.java Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
Replay protection (jti) — Deferred jti replay tracking requires server-side storage (e.g. a distributed cache of seen jti values within the proof's validity window) which a stateless SDK cannot provide, matching the go-sdk reference implementation (descope/go-sdk#737). A note has been added to the |
…lity in DPoP Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
Fixed: resolved checkstyle violations (OneStatementPerLine in switch cases, LocalVariableName for EC coordinates, JavadocParagraph) and replaced String.repeat() with Java 8 compatible alternative. |
Summary
DPoPUtils.validateDPoPProof()implementing the full RFC 9449 §7.1-7.2 algorithm (all JOSE asymmetric algorithms: RS*, ES*, PS*, EdDSA)AuthenticationService.validateDPoP()— validates the DPoP proof after session token validation; no-op when the token has nocnf.jktclaimTest plan
DPoPUtilsTest: valid ES256 proof, valid RS256 proof, wrong method, wrong URL, wrong key, empty/malformed proof, max-length exceeded, default-port normalization (:443== no port)mvn test)🤖 Generated with Claude Code