|
| 1 | +# DPoP — Sender-Constrained Tokens |
| 2 | + |
| 3 | +DPoP (Demonstrating Proof of Possession, [RFC 9449](https://www.rfc-editor.org/rfc/rfc9449)) binds an access token to a cryptographic key the client holds. A normal **Bearer** token is usable by anyone who holds it; a **DPoP-bound** token is useless without a matching proof signed by the private key — so a stolen token alone cannot be replayed. |
| 4 | + |
| 5 | +This SDK supports DPoP for **passkey sign-in** (`ServerClient.signin_with_passkey`) and for every **My Account API** call (`MyAccountClient`). |
| 6 | + |
| 7 | +> [!NOTE] |
| 8 | +> DPoP is a confidential-client (Regular Web App) capability here: your server holds the key. The SDK does not store the key for you — you generate it and pass it in, so it lives in whatever secret store you choose (KMS/HSM/etc.). |
| 9 | +
|
| 10 | +## Table of Contents |
| 11 | + |
| 12 | +- [`dpop_key` vs `dpop_proof`](#dpop_key-vs-dpop_proof) |
| 13 | +- [1. Generate a key](#1-generate-a-key) |
| 14 | +- [2. DPoP-bound passkey sign-in](#2-dpop-bound-passkey-sign-in) |
| 15 | +- [3. DPoP on My Account API calls](#3-dpop-on-my-account-api-calls) |
| 16 | +- [4. Generating a proof manually](#4-generating-a-proof-manually) |
| 17 | +- [Key lifecycle and security](#key-lifecycle-and-security) |
| 18 | +- [Error Handling](#error-handling) |
| 19 | +- [Additional Resources](#additional-resources) |
| 20 | + |
| 21 | +## `dpop_key` vs `dpop_proof` |
| 22 | + |
| 23 | +These are **different things**, and the distinction is the whole mental model. You only ever handle the **key**; the SDK derives a fresh **proof** from it on every request. |
| 24 | + |
| 25 | +| | `dpop_key` | `dpop_proof` | |
| 26 | +|---|------------|--------------| |
| 27 | +| What it is | A long-lived **EC P-256 key pair** | A signed **JWT**, created fresh for one request | |
| 28 | +| Lifetime | Reused across sign-in and every API call | Single-use — one per HTTP request | |
| 29 | +| Who holds it | You (the private key never leaves your server) | Sent on the wire in the `DPoP:` header | |
| 30 | +| Sensitivity | **Tier 0** — it is a secret | Not a stored secret — a short-lived derived artifact | |
| 31 | +| In the SDK | The `dpop_key` parameter you pass in | Built internally — you never construct one | |
| 32 | + |
| 33 | +Think of `dpop_key` as a **signet ring** you keep, and `dpop_proof` as the **wax seal** you stamp on each letter: verifiably yours, but the seal from one letter is worthless on another. Each request the SDK mints a new proof (binding the HTTP method, the URL, a unique id, a timestamp, and — at the resource server — a hash of the access token), so a captured proof cannot be reused elsewhere. |
| 34 | + |
| 35 | +## 1. Generate a key |
| 36 | + |
| 37 | +The SDK uses `jwcrypto` (already a dependency). Generate one EC P-256 key and reuse the **same instance** for sign-in and for all subsequent API calls — the token is bound to that key. |
| 38 | + |
| 39 | +```python |
| 40 | +from jwcrypto import jwk |
| 41 | + |
| 42 | +dpop_key = jwk.JWK.generate(kty="EC", crv="P-256") |
| 43 | +``` |
| 44 | + |
| 45 | +> [!NOTE] |
| 46 | +> The key **must** be EC P-256 (Auth0 advertises `ES256` only). Passing an RSA or P-384 key raises `ValueError` before any network call — it fails closed. |
| 47 | +
|
| 48 | +## 2. DPoP-bound passkey sign-in |
| 49 | + |
| 50 | +Pass `dpop_key` to `signin_with_passkey`. The SDK attaches a token-endpoint DPoP proof so Auth0 issues a DPoP-bound token, and **rejects a Bearer downgrade**: if a key was supplied but the server returns `token_type: Bearer`, it raises instead of silently accepting an unbound token. |
| 51 | + |
| 52 | +```python |
| 53 | +result = await server_client.signin_with_passkey( |
| 54 | + auth_session=challenge.auth_session, |
| 55 | + authn_response=authn_response, |
| 56 | + dpop_key=dpop_key, |
| 57 | + store_options={"request": request, "response": response}, |
| 58 | +) |
| 59 | +``` |
| 60 | + |
| 61 | +See [examples/Passkeys.md](Passkeys.md) for the full passkey flow. |
| 62 | + |
| 63 | +## 3. DPoP on My Account API calls |
| 64 | + |
| 65 | +Every `MyAccountClient` method takes an optional `dpop_key`. Supply it and the call sends `Authorization: DPoP <token>` plus a fresh `DPoP:` proof header; omit it and the call uses a plain `Authorization: Bearer <token>` — no behaviour change for callers that don't need DPoP. |
| 66 | + |
| 67 | +```python |
| 68 | +from auth0_server_python.auth_server.my_account_client import MyAccountClient |
| 69 | + |
| 70 | +my_account = MyAccountClient(domain="YOUR_CUSTOM_DOMAIN") |
| 71 | + |
| 72 | +methods = await my_account.list_authentication_methods( |
| 73 | + access_token=access_token, # a DPoP-bound token from sign-in / MRRT |
| 74 | + dpop_key=dpop_key, # the SAME key the token was bound to |
| 75 | +) |
| 76 | +``` |
| 77 | + |
| 78 | +> [!NOTE] |
| 79 | +> If a `/me/v1/...` call is answered with `401 + DPoP-Nonce` (the server demanding a nonce), the SDK transparently retries the request **once** with the nonce embedded in the proof (RFC 9449 §9.1). The token endpoint nonce challenge (`400 + DPoP-Nonce`, §8.1) is handled the same way during sign-in. There is never more than one retry — it will not loop. |
| 80 | +
|
| 81 | +## 4. Generating a proof manually |
| 82 | + |
| 83 | +For the token endpoint specifically (no access token exists yet, so the proof omits the `ath` claim), the SDK exposes a helper. You rarely need this — `signin_with_passkey` and the `MyAccountClient` methods build proofs for you — but it is available for custom token requests: |
| 84 | + |
| 85 | +```python |
| 86 | +from auth0_server_python.auth_schemes.dpop_auth import make_dpop_proof_for_token_endpoint |
| 87 | + |
| 88 | +proof = make_dpop_proof_for_token_endpoint( |
| 89 | + dpop_key, |
| 90 | + "POST", |
| 91 | + "https://YOUR_CUSTOM_DOMAIN/oauth/token", |
| 92 | + # nonce="..." # supply when the server returned a DPoP-Nonce |
| 93 | +) |
| 94 | +# send as the "DPoP" request header |
| 95 | +``` |
| 96 | + |
| 97 | +For resource-server requests, the `DPoPAuth` httpx handler (also exported from `auth_schemes`) builds the proof — including the `ath` token-hash claim — automatically. The `MyAccountClient` methods select it internally when you pass `dpop_key`. |
| 98 | + |
| 99 | +## Key lifecycle and security |
| 100 | + |
| 101 | +- **You own the key.** Generate it, store it in your secret store, and reuse the same instance for the bound token's lifetime. Discard it when the session ends. |
| 102 | +- **One key, one bound token.** The token is bound to the key; using a different key on a later API call will be rejected by the resource server (`401 invalid_dpop_proof`). |
| 103 | +- **The proof is request-specific.** Method, URL, a unique `jti`, and a timestamp are baked into every proof, so it cannot be replayed against a different endpoint or reused. |
| 104 | +- **Never log the private key or a proof.** Treat the key as Tier 0 and proofs as transient secrets. The SDK's auth handlers redact the key and token in their `repr()`. |
| 105 | + |
| 106 | +## Error Handling |
| 107 | + |
| 108 | +DPoP failures surface through the error type of the operation that used the key: |
| 109 | + |
| 110 | +```python |
| 111 | +from auth0_server_python.error import PasskeyError, MyAccountApiError, Auth0Error |
| 112 | + |
| 113 | +# Wrong key type — fails closed before any request |
| 114 | +try: |
| 115 | + await server_client.signin_with_passkey( |
| 116 | + auth_session=auth_session, authn_response=authn_response, |
| 117 | + dpop_key=rsa_key, # not EC P-256 |
| 118 | + ) |
| 119 | +except ValueError as e: |
| 120 | + print(e) # "DPoP key must be an EC P-256 key" |
| 121 | + |
| 122 | +# Bearer downgrade when DPoP was requested |
| 123 | +except PasskeyError as e: |
| 124 | + print(e.code, e.message) # passkey_token_error — "DPoP token binding failed..." |
| 125 | +``` |
| 126 | + |
| 127 | +On the My Account surface, a key mismatch or a DPoP-required endpoint reached without binding surfaces as `MyAccountApiError` (typically `status=401`). Catch `Auth0Error` for uniform handling. |
| 128 | + |
| 129 | +## Additional Resources |
| 130 | + |
| 131 | +- [Passkey Authentication](Passkeys.md) |
| 132 | +- [My Account — Authentication Methods](MyAccountAuthenticationMethods.md) |
| 133 | +- [RFC 9449 — OAuth 2.0 Demonstrating Proof of Possession (DPoP)](https://www.rfc-editor.org/rfc/rfc9449) |
0 commit comments