Skip to content

Commit c867576

Browse files
authored
Merge pull request #137 from auth0/dpop-support
Added dpop support for myaccount and passkeys
2 parents 5db36d4 + 7782d52 commit c867576

13 files changed

Lines changed: 1218 additions & 18 deletions

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,10 @@ Sign users up or in with [WebAuthn](https://www.w3.org/TR/webauthn-2/) passkeys
191191

192192
Let a logged-in user manage their own enrolled authentication methods — enroll a new passkey (or other factor), list, rename, and delete — via the [My Account API](https://auth0.com/docs/manage-users/my-account-api). For obtaining a scoped token, the enroll/verify ceremony, listing, updating, deleting, and error handling, see [examples/MyAccountAuthenticationMethods.md](examples/MyAccountAuthenticationMethods.md).
193193

194+
### 9. DPoP — Sender-Constrained Tokens (Passkeys & MyAccount)
195+
196+
Bind tokens to a key your server holds ([RFC 9449](https://www.rfc-editor.org/rfc/rfc9449)) so a stolen token alone cannot be replayed. DPoP is supported for Passkey sign-in (`signin_with_passkey`) and the authentication-methods/factors methods on `MyAccountClient`. For key generation and usage, see [examples/Passkeys.md](examples/Passkeys.md#3-dpop-bound-passkey-tokens-optional) and [examples/MyAccountAuthenticationMethods.md](examples/MyAccountAuthenticationMethods.md#dpop).
197+
194198
## Feedback
195199

196200
### Contributing

examples/MFA.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ The Auth0 MFA API allows you to manage multi-factor authentication for users in
3434
- [Verify with OTP](#verify-with-otp)
3535
- [Verify with Recovery Code](#verify-with-recovery-code)
3636
- [Verify with Push Notification (Polling)](#verify-with-push-notification-polling)
37+
- [Verify with DPoP (sender-constrained tokens)](#verify-with-dpop-sender-constrained-tokens)
3738
- [Session Persistence](#session-persistence)
3839
- [Automatic Session Update](#automatic-session-update)
3940
- [Manual Session Update](#manual-session-update)
@@ -519,6 +520,27 @@ async def poll_push_verification(server_client, mfa_token, oob_code, timeout=60)
519520
> [!NOTE]
520521
> When polling for push notification approval, the API returns an `authorization_pending` error until the user approves or denies the request. A `slow_down` error indicates you should increase the polling interval.
521522
523+
### Verify with DPoP (sender-constrained tokens)
524+
525+
When the login that triggered MFA was DPoP-bound (for example a `signin_with_passkey(dpop_key=...)` that returned `MfaRequiredError`), pass the **same** `dpop_key` to `verify()` so the token minted by the MFA step-up stays sender-constrained:
526+
527+
```python
528+
verify_response = await server_client.mfa.verify(
529+
{
530+
"mfa_token": mfa_token,
531+
"otp": "123456",
532+
},
533+
dpop_key=dpop_key, # the same EC P-256 key the login was bound to
534+
)
535+
536+
assert verify_response.token_type == "DPoP"
537+
```
538+
539+
The SDK does not store your private key, so you must re-supply it on the `verify()` call. It attaches a token-endpoint DPoP proof, transparently handles the server-nonce challenge, and **rejects a Bearer downgrade** — if `dpop_key` was supplied but the server returned an unbound token (or vice versa), `verify()` raises `MfaVerifyError` instead of silently dropping the sender constraint.
540+
541+
> [!WARNING]
542+
> The `dpop_key` is a **Tier 0 secret**. Keep it in your secret store, never log it, use one key per user/session, and use **EC P-256 only**.
543+
522544
## Session Persistence
523545

524546
By default, `verify()` returns tokens without persisting them to the session store. However, you can automatically persist tokens by setting `persist=True`.

examples/MyAccountAuthenticationMethods.md

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ The [My Account API](https://auth0.com/docs/manage-users/my-account-api) lets a
66
> This is a different My Account resource from [Connected Accounts](ConnectedAccounts.md) (Token Vault). Connected-accounts management is exposed as convenience methods on `ServerClient`; **authentication-method management is on `MyAccountClient` directly**, because each call takes a user access token you obtain yourself. The two share the same My Account setup (activation, MRRT, scopes, `MyAccountApiError`) — see [ConnectedAccounts.md → Pre-requisites](ConnectedAccounts.md#pre-requisites) for that common setup.
77
88
> [!NOTE]
9-
> To **sign in** with a passkey (rather than manage one), see [examples/Passkeys.md](Passkeys.md).
9+
> To **sign in** with a passkey (rather than manage one), see [examples/Passkeys.md](Passkeys.md). To **bind these calls to a held key**, pass an optional `dpop_key` — see [DPoP](#dpop) below.
1010
1111
## Table of Contents
1212

@@ -18,6 +18,7 @@ The [My Account API](https://auth0.com/docs/manage-users/my-account-api) lets a
1818
- [4. Get a single authentication method](#4-get-a-single-authentication-method)
1919
- [5. Update (rename) an authentication method](#5-update-rename-an-authentication-method)
2020
- [6. Delete an authentication method](#6-delete-an-authentication-method)
21+
- [DPoP](#dpop)
2122
- [Error Handling](#error-handling)
2223

2324
## Prerequisites
@@ -174,6 +175,24 @@ await my_account.delete_authentication_method(
174175
# Returns None on success (HTTP 204).
175176
```
176177

178+
## DPoP
179+
180+
Every method above accepts an optional `dpop_key` to present a sender-constrained token (`Authorization: DPoP` + a per-request proof) instead of a Bearer token ([RFC 9449](https://www.rfc-editor.org/rfc/rfc9449)). Pass the **same key** the access token was bound to at sign-in:
181+
182+
```python
183+
from jwcrypto import jwk
184+
185+
dpop_key = jwk.JWK.generate(kty="EC", crv="P-256") # the key the token was bound to
186+
187+
methods = await my_account.list_authentication_methods(
188+
access_token=access_token,
189+
dpop_key=dpop_key,
190+
)
191+
```
192+
193+
> [!WARNING]
194+
> The `dpop_key` private key is a **Tier 0 secret**. Keep it in your secret store (KMS/HSM), never log it (`repr()` is redacted, but `key.export_private()` is not), use **one key per user/session** (never share across principals), and use **EC P-256 only** — any other key type fails closed with a `ValueError`.
195+
177196
## Error Handling
178197

179198
All errors inherit from `Auth0Error`. My Account API errors are `MyAccountApiError` (RFC 7807 problem-details, carrying `status`, `detail`, and optional `validation_errors`); missing arguments raise `MissingRequiredArgumentError`; transport or non-JSON responses surface as `ApiError`.

examples/Passkeys.md

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ Passkeys let users sign up and log in with [WebAuthn](https://www.w3.org/TR/weba
1414
- [Prerequisites](#prerequisites)
1515
- [1. Passkey Signup](#1-passkey-signup)
1616
- [2. Passkey Login](#2-passkey-login)
17+
- [3. DPoP-bound passkey tokens (optional)](#3-dpop-bound-passkey-tokens-optional)
1718
- [Completing MFA on a passkey login (and where the session comes from)](#completing-mfa-on-a-passkey-login-and-where-the-session-comes-from)
1819
- [Error Handling](#error-handling)
1920

@@ -135,6 +136,31 @@ result = await server_client.signin_with_passkey(
135136
> [!NOTE]
136137
> The SDK is transparent to the signup-vs-login difference in the credential `response` — both flow through the same `PasskeyAuthResponse.response` dict. Send exactly the keys the browser produced.
137138
139+
## 3. DPoP-bound passkey tokens (optional)
140+
141+
Pass an optional `dpop_key` to bind the issued tokens to a key your server holds ([RFC 9449](https://www.rfc-editor.org/rfc/rfc9449)), so a stolen token alone cannot be replayed. DPoP is **opt-in**: omit `dpop_key` and sign-in returns ordinary Bearer tokens with no behaviour change.
142+
143+
```python
144+
from jwcrypto import jwk
145+
146+
dpop_key = jwk.JWK.generate(kty="EC", crv="P-256") # you generate and keep this key (Tier 0)
147+
148+
result = await server_client.signin_with_passkey(
149+
auth_session=challenge.auth_session,
150+
authn_response=authn_response,
151+
dpop_key=dpop_key,
152+
store_options={"request": request, "response": response},
153+
)
154+
```
155+
156+
When `dpop_key` is supplied, the SDK attaches a token-endpoint proof so Auth0 issues a DPoP-bound token, transparently handles the server-nonce challenge, and **rejects a Bearer downgrade** — if the server returns an unbound token, `signin_with_passkey` raises `PasskeyError` rather than silently accepting a token bound to a key it never used.
157+
158+
> [!TIP]
159+
> Reuse the **same** `dpop_key` for any subsequent My Account API calls made with the resulting token — the token is bound to that one key. See [MyAccountAuthenticationMethods.md → DPoP](MyAccountAuthenticationMethods.md#dpop).
160+
161+
> [!WARNING]
162+
> The `dpop_key` private key is a **Tier 0 secret**. Keep it in your secret store (KMS/HSM), never log it (`repr()` is redacted, but `key.export_private()` is not), use **one key per user/session** (never share across principals), and use **EC P-256 only** — any other key type fails closed with a `ValueError` before any network call.
163+
138164
### Completing MFA on a passkey login (and where the session comes from)
139165

140166
When a passkey login needs a second factor, `signin_with_passkey` raises `MfaRequiredError` **before** it creates a session. You finish the login by challenging and verifying through `client.mfa`, then **store the returned tokens yourself** — on this path the SDK does not persist the session for you (`persist` defaults to `False`, and there is no existing session to update yet):
@@ -146,6 +172,7 @@ try:
146172
result = await server_client.signin_with_passkey(
147173
auth_session=auth_session,
148174
authn_response=authn_response,
175+
dpop_key=dpop_key, # optional; omit for Bearer tokens
149176
store_options={"request": request, "response": response},
150177
)
151178
# No MFA needed: signin_with_passkey already persisted the session for you.
@@ -158,10 +185,12 @@ except MfaRequiredError as e:
158185
store_options={"request": request, "response": response},
159186
)
160187

161-
# 2. Verify the user's code. persist=False (the default) → the SDK
162-
# returns the tokens instead of writing a session.
188+
# 2. Verify the user's code. Re-supply the SAME dpop_key so the issued
189+
# token stays DPoP-bound. persist=False (the default) → the SDK returns
190+
# the tokens instead of writing a session.
163191
verify_response = await server_client.mfa.verify(
164192
{"mfa_token": e.mfa_token, "otp": otp_code},
193+
dpop_key=dpop_key, # same key given to signin_with_passkey
165194
store_options={"request": request, "response": response},
166195
)
167196

@@ -174,6 +203,9 @@ except MfaRequiredError as e:
174203
)
175204
```
176205

206+
> [!IMPORTANT]
207+
> Re-supply the **same** `dpop_key` to `verify`. Omitting it when the login was DPoP-bound would downgrade the result to a Bearer token; `verify` **rejects** that mismatch with `MfaVerifyError` rather than silently dropping the sender constraint. DPoP is preserved end to end — `persist=False` affects only *who writes the session*, never the token binding.
208+
177209
> [!NOTE]
178210
> Do **not** pass `persist=True` on this path. It updates an *existing* session, and a passkey-first login has none yet, so it raises `MfaVerifyError("No existing session found…")` — discarding the tokens `verify` just obtained. Use `persist=False` and store the returned tokens as shown above. (This is pre-existing MFA-client behavior, unrelated to passkeys.)
179211
@@ -220,7 +252,7 @@ except Auth0Error as e:
220252
### Common error codes (`PasskeyErrorCode`)
221253

222254
- `passkey_challenge_error` — the signup/login challenge request failed
223-
- `passkey_token_error` — token exchange failed
255+
- `passkey_token_error` — token exchange failed (also used for a rejected DPoP downgrade)
224256
- `invalid_response` — Auth0 returned a response that could not be parsed
225257

226258
> [!NOTE]
Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
from .bearer_auth import BearerAuth
2+
from .dpop_auth import DPoPAuth
23

3-
__all__ = ["BearerAuth"]
4+
__all__ = ["BearerAuth", "DPoPAuth"]
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import base64
2+
import hashlib
3+
import time
4+
import uuid
5+
from typing import Optional
6+
7+
import httpx
8+
from jwcrypto import jwk
9+
from jwcrypto import jwt as jwcrypto_jwt
10+
11+
12+
def _base64url(data: bytes) -> str:
13+
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
14+
15+
16+
def _validate_dpop_key(key: "jwk.JWK") -> dict:
17+
"""Return the public JWK after enforcing the EC P-256 requirement (ES256)."""
18+
public_jwk = key.export_public(as_dict=True)
19+
if public_jwk.get("kty") != "EC" or public_jwk.get("crv") != "P-256":
20+
raise ValueError("DPoP key must be an EC P-256 key")
21+
return public_jwk
22+
23+
24+
def _build_dpop_proof(
25+
key: "jwk.JWK",
26+
public_jwk: dict,
27+
method: str,
28+
url: str,
29+
*,
30+
ath: Optional[str] = None,
31+
nonce: Optional[str] = None,
32+
) -> str:
33+
"""Sign a DPoP proof JWT (RFC 9449 §4.2). `ath` binds the proof to an
34+
access token and is omitted for token-endpoint proofs."""
35+
htu = url.split("?")[0].split("#")[0]
36+
header = {"typ": "dpop+jwt", "alg": "ES256", "jwk": public_jwk}
37+
payload = {
38+
"jti": str(uuid.uuid4()),
39+
"htm": method.upper(),
40+
"htu": htu,
41+
"iat": int(time.time()),
42+
}
43+
if ath is not None:
44+
payload["ath"] = ath
45+
if nonce is not None:
46+
payload["nonce"] = nonce
47+
token = jwcrypto_jwt.JWT(header=header, claims=payload)
48+
token.make_signed_token(key)
49+
return token.serialize()
50+
51+
52+
def make_dpop_proof_for_token_endpoint(
53+
key: "jwk.JWK", method: str, url: str, nonce: Optional[str] = None
54+
) -> str:
55+
"""
56+
Build a DPoP proof JWT for use at the token endpoint (RFC 9449 §4.2).
57+
Unlike resource-server proofs, token-endpoint proofs do NOT include `ath`
58+
because no access token exists yet at issuance time.
59+
"""
60+
public_jwk = _validate_dpop_key(key)
61+
return _build_dpop_proof(key, public_jwk, method, url, nonce=nonce)
62+
63+
64+
class DPoPAuth(httpx.Auth):
65+
# Buffer the body (sync/async-aware) so the nonce retry can resend it.
66+
requires_request_body = True
67+
68+
def __init__(self, token: str, key: "jwk.JWK") -> None:
69+
public_jwk = _validate_dpop_key(key)
70+
try:
71+
token.encode("ascii")
72+
except UnicodeEncodeError:
73+
raise ValueError("Access token must contain only ASCII characters")
74+
self._token = token
75+
self._key = key
76+
self._public_jwk = public_jwk
77+
78+
def auth_flow(self, request: httpx.Request):
79+
proof = self._make_proof(request.method, str(request.url))
80+
request.headers["Authorization"] = f"DPoP {self._token}"
81+
request.headers["DPoP"] = proof
82+
response = yield request
83+
84+
# RFC 9449 §8.2 — server-nonce retry
85+
if response.status_code == 401 and response.headers.get("DPoP-Nonce"):
86+
nonce = response.headers["DPoP-Nonce"]
87+
request.headers["DPoP"] = self._make_proof(
88+
request.method, str(request.url), nonce=nonce
89+
)
90+
yield request
91+
92+
def _make_proof(self, method: str, url: str, nonce: Optional[str] = None) -> str:
93+
ath = _base64url(hashlib.sha256(self._token.encode("ascii")).digest())
94+
return _build_dpop_proof(self._key, self._public_jwk, method, url, ath=ath, nonce=nonce)

src/auth0_server_python/auth_server/mfa_client.py

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,15 @@
55

66
import json
77
import time
8-
from typing import Any, Callable, Optional, Union
8+
from typing import TYPE_CHECKING, Any, Callable, Optional, Union
99

1010
import httpx
1111

1212
from auth0_server_python.auth_schemes.bearer_auth import BearerAuth
13+
from auth0_server_python.auth_schemes.dpop_auth import make_dpop_proof_for_token_endpoint
14+
15+
if TYPE_CHECKING:
16+
from jwcrypto import jwk
1317
from auth0_server_python.auth_types import (
1418
AuthenticatorResponse,
1519
ChallengeResponse,
@@ -447,6 +451,7 @@ async def verify(
447451
self,
448452
options: dict[str, Any],
449453
store_options: Optional[dict[str, Any]] = None,
454+
dpop_key: Optional["jwk.JWK"] = None,
450455
) -> MfaVerifyResponse:
451456
"""
452457
Verifies an MFA code and completes authentication.
@@ -466,12 +471,16 @@ async def verify(
466471
- 'audience': str (optional, required if persist=True) - Audience for token_set
467472
- 'scope': str (optional) - Scope for token_set
468473
store_options: Optional options passed to the State Store (e.g. request/response).
474+
dpop_key: Optional EC P-256 JWK to DPoP-bind the token. Pass the same
475+
key used at login (e.g. given to signin_with_passkey) to preserve
476+
the sender constraint through step-up. Never stored by the SDK.
469477
470478
Returns:
471479
MfaVerifyResponse with access_token, token_type, etc.
472480
473481
Raises:
474-
MfaVerifyError: When verification fails.
482+
MfaVerifyError: When verification fails, or when dpop_key was supplied
483+
but the server returned an unbound (Bearer) token.
475484
MfaRequiredError: When chained MFA is required.
476485
"""
477486
mfa_token = options.get("mfa_token")
@@ -506,12 +515,33 @@ async def verify(
506515
token_endpoint = f"{base_url}/oauth/token"
507516

508517
async with self._get_http_client() as client:
518+
headers = {"Content-Type": "application/x-www-form-urlencoded"}
519+
if dpop_key is not None:
520+
headers["DPoP"] = make_dpop_proof_for_token_endpoint(
521+
dpop_key, "POST", token_endpoint
522+
)
509523
response = await client.post(
510524
token_endpoint,
511525
data=body,
512-
headers={"Content-Type": "application/x-www-form-urlencoded"}
526+
headers=headers
513527
)
514528

529+
# Rebuild the proof with the nonce and retry once.
530+
if (
531+
dpop_key is not None
532+
and response.status_code in (400, 401)
533+
and response.headers.get("DPoP-Nonce")
534+
):
535+
nonce = response.headers["DPoP-Nonce"]
536+
headers["DPoP"] = make_dpop_proof_for_token_endpoint(
537+
dpop_key, "POST", token_endpoint, nonce=nonce
538+
)
539+
response = await client.post(
540+
token_endpoint,
541+
data=body,
542+
headers=headers
543+
)
544+
515545
if response.status_code != 200:
516546
error_data = self._parse_error_body(response)
517547

@@ -533,6 +563,15 @@ async def verify(
533563
token_response = response.json()
534564
verify_response = MfaVerifyResponse(**token_response)
535565

566+
# Reject a Bearer downgrade when a DPoP token was requested
567+
# (RFC 9449: a bound token has token_type "DPoP").
568+
token_is_dpop = verify_response.token_type.lower() == "dpop"
569+
if dpop_key is not None and not token_is_dpop:
570+
raise MfaVerifyError(
571+
"DPoP token binding failed: expected token_type 'DPoP', "
572+
f"got '{verify_response.token_type}'"
573+
)
574+
536575
# Clear the in-progress MFA state after successful verification.
537576
if self._state_store:
538577
await self._state_store.delete(MFA_PENDING_IDENTIFIER, store_options)

0 commit comments

Comments
 (0)