Skip to content

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

Open
davidbemer wants to merge 4 commits into
mainfrom
dpop
Open

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

Conversation

@davidbemer
Copy link
Copy Markdown

Summary

  • Adds descope/dpop.py implementing RFC 9449 §7.1-7.2 DPoP proof validation (signature, HTU/HTM, iat window, ath hash, JWK thumbprint vs cnf.jkt)
  • Exposes validate_dpop_proof on DescopeClient and Auth; also exports get_dpop_thumbprint from the top-level package
  • Mirrors the Go SDK implementation in go-sdk PR #737

Test plan

  • All 445 existing tests pass (pytest tests/ --ignore=tests/test_flask.py)
  • Verify validate_dpop_proof is a no-op for tokens without cnf.jkt
  • Verify AuthException is raised for expired/mismatched/missing DPoP proofs

🤖 Generated with Claude Code

Implements RFC 9449 DPoP proof validation for session tokens that carry
a cnf.jkt claim, mirroring the go-sdk implementation (PR #737).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings May 20, 2026 15:40
@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
Contributor

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) sender-constrained session token support to the SDK, including proof validation logic and public API exposure so servers can verify the DPoP header for DPoP-bound tokens (cnf.jkt).

Changes:

  • Introduces descope/dpop.py implementing DPoP proof validation and JWK thumbprint extraction.
  • Exposes DPoP validation via Auth.validate_dpop_proof and DescopeClient.validate_dpop_proof, and documents usage in the README.
  • Exports DPoP helpers from the top-level package.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 10 comments.

Show a summary per file
File Description
README.md Documents how to validate DPoP proofs after session validation.
descope/dpop.py Implements DPoP proof parsing, signature/claim checks, and cnf.jkt thumbprint matching.
descope/descope_client.py Adds DescopeClient.validate_dpop_proof wrapper API.
descope/auth.py Adds Auth.validate_dpop_proof wrapper calling the DPoP validator.
descope/init.py Exports get_dpop_thumbprint and validate_dpop_proof at package top-level.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread descope/dpop.py
Comment on lines +149 to +170
try:
header = json.loads(_base64url_decode(parts[0]))
except Exception as e:
raise AuthException(400, ERROR_TYPE_INVALID_TOKEN, f"Unable to decode DPoP proof header: {e}")

if header.get("typ") != "dpop+jwt":
raise AuthException(400, ERROR_TYPE_INVALID_TOKEN, "DPoP proof header must have typ=dpop+jwt")

alg = header.get("alg", "")
if alg not in _ALLOWED_ALGS:
raise AuthException(400, ERROR_TYPE_INVALID_TOKEN, f"DPoP proof uses unsupported algorithm: {alg}")

jwk_dict = header.get("jwk")
if not jwk_dict:
raise AuthException(400, ERROR_TYPE_INVALID_TOKEN, "DPoP proof header is missing jwk")

# --- Step 12-13: reject symmetric and private keys ---
if jwk_dict.get("kty") == "oct":
raise AuthException(400, ERROR_TYPE_INVALID_TOKEN, "DPoP proof jwk must not be a symmetric key (kty=oct)")
if "d" in jwk_dict:
raise AuthException(400, ERROR_TYPE_INVALID_TOKEN, "DPoP proof jwk must not contain private key material")

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 in 09aeff4: added isinstance(header, dict) check after JSON decode raising AuthException(400, ...) if not a dict. Same check added for jwk_dict.

Comment thread descope/dpop.py Outdated
Comment on lines +196 to +203
signing_input = (parts[0] + "." + parts[1]).encode()
signature = _base64url_decode(parts[2])

try:
alg_instance = jwt.get_algorithm_by_name(alg)
alg_instance.verify(signing_input, key, signature)
except Exception as e:
raise AuthException(400, ERROR_TYPE_INVALID_TOKEN, f"DPoP proof signature verification failed: {e}")
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 in 09aeff4: _base64url_decode(parts[2]) is now called before the try/except that wraps key loading and verification, and any exception from it is caught and re-raised as AuthException(400, ...).

Comment thread descope/dpop.py Outdated
Comment on lines +183 to +187
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey # noqa: F401
from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
import base64 as _b64
x_bytes = _b64.urlsafe_b64decode(jwk_dict["x"] + "==")
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
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 in 09aeff4: removed all duplicate/unused imports (Encoding, PublicFormat, duplicate Ed25519PublicKey, base64 as _b64). The fallback now uses _base64url_decode (already defined in this module) to decode jwk_dict["x"] and calls public_key.verify(signature, signing_input) directly — no attempt to use jwt.get_algorithm_by_name("EdDSA") in the fallback path.

Comment thread descope/dpop.py
Comment on lines +205 to +214
# --- Step 16: decode payload ---
try:
payload = json.loads(_base64url_decode(parts[1]))
except Exception as e:
raise AuthException(400, ERROR_TYPE_INVALID_TOKEN, f"Unable to decode DPoP proof payload: {e}")

# --- Step 17-19: required claims ---
jti = payload.get("jti", "")
if not isinstance(jti, str) or not jti:
raise AuthException(400, ERROR_TYPE_INVALID_TOKEN, "DPoP proof payload must have a non-empty jti string claim")
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 in 09aeff4: added isinstance(payload, dict) check after JSON decode of the payload, raising AuthException(400, ...) if not a dict.

Comment thread descope/dpop.py Outdated
Comment on lines +119 to +120
options={"verify_signature": False},
algorithms=list(_ALLOWED_ALGS) + ["RS256", "ES256"],
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 in 09aeff4: removed the algorithms restriction from the unverified session token decode. Now uses only options={"verify_signature": False, "verify_aud": False} with no algorithms kwarg, so tokens signed with any algorithm will decode correctly for the cnf.jkt check.

Comment thread descope/dpop.py Outdated
Comment on lines +64 to +72
host = p.hostname or ""
port = p.port
# Rebuild netloc without default port
if port is None or port == _DEFAULT_PORTS.get(scheme):
netloc = host
else:
netloc = f"{host}:{port}"
# Reconstruct without query/fragment
return f"{scheme}://{netloc}{p.path}"
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 in 466d2e9: _normalize_url now detects IPv6 hosts via ':' in host (since p.hostname strips brackets) and wraps them back in [...]. Also normalizes empty paths to /.

Comment thread descope/dpop.py
Comment on lines +211 to +218
# --- Step 17-19: required claims ---
jti = payload.get("jti", "")
if not isinstance(jti, str) or not jti:
raise AuthException(400, ERROR_TYPE_INVALID_TOKEN, "DPoP proof payload must have a non-empty jti string claim")

htm = payload.get("htm", "")
if not isinstance(htm, str) or not htm:
raise AuthException(400, ERROR_TYPE_INVALID_TOKEN, "DPoP proof payload must have a non-empty htm string claim")
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.

Deferred — jti replay protection requires server-side storage which a stateless SDK cannot provide. This matches the go-sdk reference implementation (descope/go-sdk#737). Added a docstring note to validate_dpop_proof in 09aeff4 explicitly stating this limitation and that callers requiring replay protection must implement their own jti store.

Comment thread descope/dpop.py Outdated
Comment on lines +101 to +108
def validate_dpop_proof(dpop_proof: str, method: str, request_url: str, session_token: str) -> None:
"""
Validate a DPoP proof for a DPoP-bound session token (RFC 9449 §7.1-7.2).

Call after validate_session() when the session token has a cnf.jkt claim.
Raises AuthException if validation fails.
Does nothing if session_token has no cnf.jkt.

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.

Acknowledged — unit tests for validate_dpop_proof are not in this PR. Will track as a follow-up.

Comment thread descope/__init__.py
Comment on lines 12 to 14
from descope.descope_client import DescopeClient
from descope.dpop import get_dpop_thumbprint, validate_dpop_proof
from descope.exceptions import (
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 in 09aeff4: validate_dpop_proof in descope/dpop.py now has the same parameter order as Auth.validate_dpop_proof and DescopeClient.validate_dpop_proof: (session_token, dpop_proof, method, request_url). Also updated the call site in auth.py to match.

Comment thread descope/__init__.py
Comment on lines 66 to 71
"LoginOptions",
"SignUpOptions",
"DescopeClient",
"get_dpop_thumbprint",
"validate_dpop_proof",
"API_RATE_LIMIT_RETRY_AFTER_HEADER",
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.

The module-level validate_dpop_proof signature is now consistent with the client/auth APIs (same param order). Both get_dpop_thumbprint and validate_dpop_proof are intentional public exports — they enable framework-level middleware to validate DPoP proofs without instantiating a full DescopeClient.

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 sender-constrained token validation, mirroring the Go SDK implementation. The thumbprint computation matches the RFC 7638 reference vector, the validation order is correct, and the signature is checked before claim assertions. Good bones overall.

Sniffed out 4 issues:

  • 2 🟡 MEDIUM: get_dpop_thumbprint crashes on cnf: null; EdDSA fallback path is unreachable / broken
  • 1 🟡 MEDIUM: No jti replay protection or documented limitation
  • 1 🟢 LOW: Unused/duplicate imports in EdDSA fallback

See inline comments for details. Woof!

Comment thread descope/dpop.py Outdated

def get_dpop_thumbprint(claims: dict) -> str:
"""Extract cnf.jkt from token claims, return empty string if absent."""
return claims.get("cnf", {}).get("jkt", "")
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: get_dpop_thumbprint raises AttributeError when cnf is explicitly null.

claims.get("cnf", {}) only falls back to {} when the key is missing; if cnf is present with value None (a malformed but valid-JSON token), None.get("jkt", "") crashes. The README advertises this helper to user code (if get_dpop_thumbprint(jwt_response): ...), so the crash surfaces in user request paths.

Suggested change
return claims.get("cnf", {}).get("jkt", "")
def get_dpop_thumbprint(claims: dict) -> str:
"""Extract cnf.jkt from token claims, return empty string if absent."""
cnf = claims.get("cnf") or {}
return cnf.get("jkt", "") if isinstance(cnf, dict) else ""

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 in 09aeff4: get_dpop_thumbprint now uses cnf = claims.get("cnf") or {} which correctly handles the case where cnf is explicitly set to null (returns None, not {}).

Comment thread descope/dpop.py Outdated
import base64 as _b64
x_bytes = _b64.urlsafe_b64decode(jwk_dict["x"] + "==")
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
key = Ed25519PublicKey.from_public_bytes(x_bytes)
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: EdDSA fallback can't actually verify the signature.

If from jwt.algorithms import OKPAlgorithm fails (PyJWT < 2.6), the code loads the key via cryptography, but line 200 still calls jwt.get_algorithm_by_name("EdDSA") — which was added in the same PyJWT release as OKPAlgorithm. So whenever the fallback is reached, the subsequent .verify() raises NotImplementedError, producing a confusing "signature verification failed" error. Either drop the dead fallback and require PyJWT ≥ 2.6 for EdDSA, or perform the verify directly with cryptography (key.verify(signature, signing_input)) when in the fallback branch.

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 in 09aeff4: the EdDSA fallback now verifies the signature directly via cryptography.hazmat.primitives.asymmetric.ed25519.Ed25519PublicKey. Previously it was importing the key via cryptography but still trying to call jwt.get_algorithm_by_name("EdDSA") which would also fail. The fix calls public_key.verify(signature, signing_input) directly.

Comment thread descope/dpop.py
# --- Step 17-19: required claims ---
jti = payload.get("jti", "")
if not isinstance(jti, str) or not jti:
raise AuthException(400, ERROR_TYPE_INVALID_TOKEN, "DPoP proof payload must have a non-empty jti string claim")
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: No replay protection on jti and the limitation isn't documented.

RFC 9449 §11.1 explicitly recommends tracking jti values server-side to prevent replay; this validator only checks that jti is a non-empty string. An attacker who intercepts a valid DPoP proof can replay it freely within the ±60s iat window against the same endpoint. At minimum, please document this in the function/README so callers know they need to plug in their own jti cache — currently the README says "call this and you're protected", which overstates the guarantee.

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.

Deferred — jti replay protection requires server-side storage which a stateless SDK cannot provide. This matches the go-sdk reference implementation (descope/go-sdk#737). Added a docstring note to validate_dpop_proof in 09aeff4 explicitly stating this limitation and that callers requiring replay protection must implement their own jti store.

Comment thread descope/dpop.py Outdated
import base64 as _b64
x_bytes = _b64.urlsafe_b64decode(jwk_dict["x"] + "==")
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
key = Ed25519PublicKey.from_public_bytes(x_bytes)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟢 LOW: Unused/duplicate imports in EdDSA fallback.

  • Line 183 imports Ed25519PublicKey with noqa: F401 then line 187 imports it again before use.
  • Line 184's Encoding, PublicFormat are never referenced.
Suggested change
key = Ed25519PublicKey.from_public_bytes(x_bytes)
except (ImportError, AttributeError):
# Fallback: load Ed25519 key via cryptography
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
import base64 as _b64
x_bytes = _b64.urlsafe_b64decode(jwk_dict["x"] + "==")
key = Ed25519PublicKey.from_public_bytes(x_bytes)

davidbemer and others added 2 commits May 20, 2026 18:58
- get_dpop_thumbprint: use `cnf or {}` to handle explicit null cnf claim
- validate_dpop_proof: reorder params to (session_token, dpop_proof, method, request_url) for consistency with auth/client methods
- Add isinstance checks for header, jwk_dict, and payload after JSON decode
- Move signature decode inside try/except to catch malformed base64
- Fix EdDSA fallback: remove duplicate/unused imports, verify directly via cryptography.hazmat
- _normalize_url: handle IPv6 brackets and normalize empty path to "/"
- Add docstring note that jti replay protection is deferred (stateless SDK)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…heck

Use jwt.algorithms.get_default_algorithms().keys() instead of a fixed
list so tokens signed with any alg decode correctly for the unverified
cnf.jkt presence check.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings May 20, 2026 16:01
Copy link
Copy Markdown
Contributor

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

Copilot reviewed 5 out of 5 changed files in this pull request and generated 3 comments.

Comment thread descope/dpop.py
Comment on lines +105 to +108
cnf = claims.get("cnf") or {}
return cnf.get("jkt", "")


Comment thread descope/dpop.py
unverified_claims = jwt.decode(
session_token,
options={"verify_signature": False, "verify_aud": False},
algorithms=jwt.algorithms.get_default_algorithms().keys(),
Comment thread descope/dpop.py Outdated
except (ImportError, AttributeError):
# Fallback: verify Ed25519 signature directly via cryptography
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
from cryptography.hazmat.primitives import serialization
@davidbemer
Copy link
Copy Markdown
Author

Fixed: removed unused cryptography.hazmat.primitives.serialization import from the EdDSA fallback block (Ruff F401).

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