feat: add DPoP sender-constrained token support (RFC 9449)#1544
feat: add DPoP sender-constrained token support (RFC 9449)#1544davidbemer wants to merge 4 commits into
Conversation
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>
|
🐕 Review complete — View session on Shuni Portal 🐾 |
There was a problem hiding this comment.
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.pyimplementing DPoP proof validation and JWK thumbprint extraction. - Exposes DPoP validation via
Auth.validate_dpop_proofandDescopeClient.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.
| 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") | ||
|
|
There was a problem hiding this comment.
Fixed in 09aeff4: added isinstance(header, dict) check after JSON decode raising AuthException(400, ...) if not a dict. Same check added for jwk_dict.
| 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}") |
There was a problem hiding this comment.
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, ...).
| 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 |
There was a problem hiding this comment.
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.
| # --- 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") |
There was a problem hiding this comment.
Fixed in 09aeff4: added isinstance(payload, dict) check after JSON decode of the payload, raising AuthException(400, ...) if not a dict.
| options={"verify_signature": False}, | ||
| algorithms=list(_ALLOWED_ALGS) + ["RS256", "ES256"], |
There was a problem hiding this comment.
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.
| 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}" |
There was a problem hiding this comment.
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 /.
| # --- 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") |
There was a problem hiding this comment.
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.
| 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. | ||
|
|
There was a problem hiding this comment.
Acknowledged — unit tests for validate_dpop_proof are not in this PR. Will track as a follow-up.
| from descope.descope_client import DescopeClient | ||
| from descope.dpop import get_dpop_thumbprint, validate_dpop_proof | ||
| from descope.exceptions import ( |
There was a problem hiding this comment.
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.
| "LoginOptions", | ||
| "SignUpOptions", | ||
| "DescopeClient", | ||
| "get_dpop_thumbprint", | ||
| "validate_dpop_proof", | ||
| "API_RATE_LIMIT_RETRY_AFTER_HEADER", |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
🐕 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_thumbprintcrashes oncnf: null; EdDSA fallback path is unreachable / broken - 1 🟡 MEDIUM: No
jtireplay protection or documented limitation - 1 🟢 LOW: Unused/duplicate imports in EdDSA fallback
See inline comments for details. Woof!
|
|
||
| def get_dpop_thumbprint(claims: dict) -> str: | ||
| """Extract cnf.jkt from token claims, return empty string if absent.""" | ||
| return claims.get("cnf", {}).get("jkt", "") |
There was a problem hiding this comment.
🟡 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.
| 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 "" |
There was a problem hiding this comment.
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 {}).
| 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) |
There was a problem hiding this comment.
🟡 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.
There was a problem hiding this comment.
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.
| # --- 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") |
There was a problem hiding this comment.
🟡 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.
There was a problem hiding this comment.
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.
| 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) |
There was a problem hiding this comment.
🟢 LOW: Unused/duplicate imports in EdDSA fallback.
- Line 183 imports
Ed25519PublicKeywithnoqa: F401then line 187 imports it again before use. - Line 184's
Encoding, PublicFormatare never referenced.
| 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) |
- 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>
| cnf = claims.get("cnf") or {} | ||
| return cnf.get("jkt", "") | ||
|
|
||
|
|
| unverified_claims = jwt.decode( | ||
| session_token, | ||
| options={"verify_signature": False, "verify_aud": False}, | ||
| algorithms=jwt.algorithms.get_default_algorithms().keys(), |
| except (ImportError, AttributeError): | ||
| # Fallback: verify Ed25519 signature directly via cryptography | ||
| from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey | ||
| from cryptography.hazmat.primitives import serialization |
|
Fixed: removed unused |
Summary
descope/dpop.pyimplementing RFC 9449 §7.1-7.2 DPoP proof validation (signature, HTU/HTM, iat window, ath hash, JWK thumbprint vscnf.jkt)validate_dpop_proofonDescopeClientandAuth; also exportsget_dpop_thumbprintfrom the top-level packageTest plan
pytest tests/ --ignore=tests/test_flask.py)validate_dpop_proofis a no-op for tokens withoutcnf.jktAuthExceptionis raised for expired/mismatched/missing DPoP proofs🤖 Generated with Claude Code