Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 44 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,11 +58,12 @@ These sections show how to use the SDK to perform various authentication/authori
6. [TOTP Authentication](#totp-authentication)
7. [Passwords](#passwords)
8. [Session Validation](#session-validation)
9. [Roles & Permission Validation](#roles--permission-validation)
10. [Tenant selection](#tenant-selection)
11. [Logging Out](#logging-out)
12. [History](#history)
13. [My Tenants](#my-tenants)
9. [DPoP Sender-Constrained Tokens](#dpop-sender-constrained-tokens)
10. [Roles & Permission Validation](#roles--permission-validation)
11. [Tenant selection](#tenant-selection)
12. [Logging Out](#logging-out)
13. [History](#history)
14. [My Tenants](#my-tenants)

## API Management Function

Expand Down Expand Up @@ -407,6 +408,44 @@ The implementation can defer according to your framework of choice. See our [sam
If Roles & Permissions are used, validate them immediately after validating the session. See the [next section](#roles--permission-validation)
for more information.

### DPoP Sender-Constrained Tokens

[DPoP (Demonstrated Proof of Possession, RFC 9449)](https://datatracker.ietf.org/doc/html/rfc9449) allows
session tokens to be _sender-constrained_ — a client must prove on every request that it holds the private key
corresponding to the public key thumbprint embedded in the token's `cnf.jkt` claim.

When a session token contains a `cnf.jkt` claim you must call `validate_dpop_proof` after `validate_session`
to verify the DPoP proof the client sends in the `DPoP` HTTP header.

```python
from descope import AuthException, get_dpop_thumbprint

# 1. Validate the session as usual
try:
jwt_response = descope_client.validate_session(session_token)
except AuthException:
# Session is invalid
raise

# 2. Check whether the token is DPoP-bound
if get_dpop_thumbprint(jwt_response):
# 3. Validate the DPoP proof from the incoming request
dpop_header = request.headers.get("DPoP", "") # framework-specific
try:
descope_client.validate_dpop_proof(
session_token=session_token,
dpop_proof=dpop_header,
method=request.method, # e.g. "GET"
request_url=request.url, # full URL including path
)
except AuthException:
# DPoP proof is invalid — reject the request
raise
```

`validate_dpop_proof` raises `AuthException` if the proof is missing, forged, expired, or bound to the
wrong token. It is a no-op when the token has no `cnf.jkt` claim, so it is safe to call unconditionally.

### Roles & Permission Validation

When using Roles & Permission, it's important to validate the user has the required
Expand Down
3 changes: 3 additions & 0 deletions descope/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
SignUpOptions,
)
from descope.descope_client import DescopeClient
from descope.dpop import get_dpop_thumbprint, validate_dpop_proof
from descope.exceptions import (
Comment on lines 12 to 14
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.

API_RATE_LIMIT_RETRY_AFTER_HEADER,
ERROR_TYPE_API_RATE_LIMIT,
Expand Down Expand Up @@ -65,6 +66,8 @@
"LoginOptions",
"SignUpOptions",
"DescopeClient",
"get_dpop_thumbprint",
"validate_dpop_proof",
"API_RATE_LIMIT_RETRY_AFTER_HEADER",
Comment on lines 66 to 71
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.

"ERROR_TYPE_API_RATE_LIMIT",
"ERROR_TYPE_SERVER_ERROR",
Expand Down
17 changes: 17 additions & 0 deletions descope/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
AuthException,
RateLimitException,
)
from descope.dpop import validate_dpop_proof as dpop_validate_proof
from descope.http_client import HTTPClient
from descope.jwt_common import adjust_properties as jwt_adjust_properties
from descope.jwt_common import generate_auth_info as jwt_generate_auth_info
Expand Down Expand Up @@ -491,6 +492,22 @@ def refresh_session(self, refresh_token: str, audience: str | None | Iterable[st
refresh_token = response.cookies.get(REFRESH_SESSION_COOKIE_NAME, None) or refresh_token
return self.generate_jwt_response(resp, refresh_token, audience)

def validate_dpop_proof(
self,
session_token: str,
dpop_proof: str,
method: str,
request_url: 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.
"""
dpop_validate_proof(session_token, dpop_proof, method, request_url)

def validate_and_refresh_session(
self,
session_token: str,
Expand Down
25 changes: 25 additions & 0 deletions descope/descope_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -431,6 +431,31 @@ def validate_and_refresh_session(
"""
return self._auth.validate_and_refresh_session(session_token, refresh_token, audience)

def validate_dpop_proof(
self,
session_token: str,
dpop_proof: str,
method: str,
request_url: str,
) -> None:
"""
Validate a DPoP proof for a DPoP-bound session token (RFC 9449 §7.1-7.2).

Call this after validate_session() when the session token has a cnf.jkt claim
to verify that the client possesses the private key bound to the token.
Does nothing if session_token has no cnf.jkt (i.e. not DPoP-bound).

Args:
session_token (str): The raw session JWT string.
dpop_proof (str): The value of the DPoP HTTP header from the incoming request.
method (str): HTTP method of the incoming request (e.g. "GET", "POST").
request_url (str): Full URL of the incoming request.

Raise:
AuthException: Exception is raised if the DPoP proof is invalid.
"""
self._auth.validate_dpop_proof(session_token, dpop_proof, method, request_url)

def logout(self, refresh_token: str) -> httpx.Response:
"""
Logout user from current session and revoke the refresh_token. After calling this function,
Expand Down
Loading
Loading