Skip to content

feat: add DPoP sender-constrained token support (RFC 9449)#331

Open
davidbemer wants to merge 3 commits into
mainfrom
dpop
Open

feat: add DPoP sender-constrained token support (RFC 9449)#331
davidbemer wants to merge 3 commits into
mainfrom
dpop

Conversation

@davidbemer
Copy link
Copy Markdown

Summary

  • Implements DPoP (Demonstrated Proof of Possession, RFC 9449) proof validation for sender-constrained session tokens
  • Adds DPoPUtils.validateDPoPProof() implementing the full RFC 9449 §7.1-7.2 algorithm (all JOSE asymmetric algorithms: RS*, ES*, PS*, EdDSA)
  • Adds AuthenticationService.validateDPoP() — validates the DPoP proof after session token validation; no-op when the token has no cnf.jkt claim
  • Mirrors the Go SDK implementation: feat: add DPoP Proof Validation (RFC 9449) go-sdk#737

Test plan

  • Unit tests in 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)
  • Verify existing tests continue to pass (mvn test)

🤖 Generated with Claude Code

- 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>
Copilot AI review requested due to automatic review settings May 20, 2026 15:45
@shuni-bot-dev
Copy link
Copy Markdown

shuni-bot-dev Bot commented May 20, 2026

🐕 Review complete — View session on Shuni Portal 🐾

Copy link
Copy Markdown

@shuni-bot-dev shuni-bot-dev Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🐕 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 leak ClassCastException instead of ClientFunctionalException for malformed proofs

See inline comments for details. Woof!

Comment on lines +440 to +443
new java.security.spec.EdECPoint(
(xBytes[xBytes.length - 1] & 0x80) != 0,
new BigInteger(1, reverseBytes(xBytes))
));
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 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.

Suggested change
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))
));

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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();
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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-string alg
  • L135 (Map<String, Object>) header.get("jwk") — non-object jwk
  • L181 / L188 / L201 / L226 (String) payload.get(...) for jti/htm/htu/ath
  • L195 method.equals(htm) — NPE if caller passes method == null
  • L217 ((Number) iatObj).longValue() — non-numeric iat

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))).

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 in AuthenticationServiceImpl.
  • Adds README usage docs and a new DPoPUtilsTest covering 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.

Comment on lines +8 to +23
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;
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. Removed unused imports BigInteger, URI, AlgorithmParameters, and ECParameterSpec from DPoPUtilsTest.java. These were only referenced in a comment (BigInteger) or not at all.

Comment on lines +435 to +444
// 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);
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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());
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +194 to +198
// Step 20: match htm
if (!method.equals(htm)) {
throw ClientFunctionalException.invalidToken(
new IllegalArgumentException("htm mismatch"));
}
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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"));
}
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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'.

Comment on lines +291 to +292
void validateDPoP(String sessionToken, String dpopProof, String method, String requestUrl)
throws DescopeException;
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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");
}
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +417 to +421
/**
* 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 {
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
@davidbemer
Copy link
Copy Markdown
Author

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 validateDPoPProof Javadoc calling this out explicitly so callers are aware they need to handle replay protection in their own infrastructure if required.

…lity in DPoP

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@davidbemer
Copy link
Copy Markdown
Author

Fixed: resolved checkstyle violations (OneStatementPerLine in switch cases, LocalVariableName for EC coordinates, JavadocParagraph) and replaced String.repeat() with Java 8 compatible alternative.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants