Skip to content

Commit 69ea847

Browse files
committed
Added docs and feedback changes
1 parent a0100b7 commit 69ea847

11 files changed

Lines changed: 962 additions & 85 deletions

File tree

README.md

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,77 @@ The SDK handles per-domain OIDC discovery, JWKS fetching, issuer validation, and
177177

178178
For more details and examples, see [examples/MultipleCustomDomains.md](examples/MultipleCustomDomains.md).
179179

180+
### 6. Passkey Authentication
181+
182+
Sign users up or in with [WebAuthn](https://www.w3.org/TR/webauthn-2/) passkeys (Touch ID, Face ID, Windows Hello, or a security key) instead of a password. The ceremony is two steps — request a challenge, sign it in the browser, then complete sign-in — and establishes a server-side session like every other login path:
183+
184+
```python
185+
from auth0_server_python.auth_types import PasskeyUserProfile, PasskeyAuthResponse
186+
187+
# Step 1 — request a challenge
188+
challenge = await auth0.passkey_login_challenge(
189+
store_options={"request": request, "response": response}
190+
)
191+
192+
# Step 2 — browser signs: navigator.credentials.get(challenge.authn_params_public_key)
193+
194+
# Step 3 — complete sign-in and establish the session
195+
result = await auth0.signin_with_passkey(
196+
auth_session=challenge.auth_session,
197+
authn_response=PasskeyAuthResponse(**credential),
198+
store_options={"request": request, "response": response}
199+
)
200+
201+
user = result.state_data["user"]
202+
```
203+
204+
For signup, organizations, step-up MFA, and error handling, see [examples/Passkeys.md](examples/Passkeys.md).
205+
206+
### 7. My Account API — Authentication Methods
207+
208+
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):
209+
210+
```python
211+
from auth0_server_python.auth_server.my_account_client import MyAccountClient
212+
from auth0_server_python.auth_types import EnrollAuthenticationMethodRequest
213+
214+
# Obtain a My Account-scoped token for the current session (MRRT)
215+
access_token = await auth0.get_access_token(
216+
store_options={"request": request, "response": response},
217+
audience=f"https://{YOUR_CUSTOM_DOMAIN}/me/",
218+
scope="create:me:authentication-methods read:me:authentication-methods",
219+
)
220+
221+
my_account = MyAccountClient(domain=YOUR_CUSTOM_DOMAIN)
222+
223+
# Start enrolling a passkey (then sign it in the browser and verify)
224+
challenge = await my_account.enroll_authentication_method(
225+
access_token=access_token,
226+
request=EnrollAuthenticationMethodRequest(type="passkey"),
227+
)
228+
```
229+
230+
For the full enroll/verify ceremony, listing, updating, deleting, and error handling, see [examples/MyAccountAuthenticationMethods.md](examples/MyAccountAuthenticationMethods.md).
231+
232+
### 8. DPoP — Sender-Constrained Tokens
233+
234+
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. Generate an EC P-256 key and pass it to passkey sign-in or any My Account API call:
235+
236+
```python
237+
from jwcrypto import jwk
238+
239+
dpop_key = jwk.JWK.generate(kty="EC", crv="P-256") # you create and keep this key
240+
241+
result = await auth0.signin_with_passkey(
242+
auth_session=challenge.auth_session,
243+
authn_response=authn_response,
244+
dpop_key=dpop_key,
245+
store_options={"request": request, "response": response}
246+
)
247+
```
248+
249+
For the `dpop_key` vs `dpop_proof` distinction, key lifecycle, nonce handling, and error handling, see [examples/DPoP.md](examples/DPoP.md).
250+
180251
## Feedback
181252

182253
### Contributing

examples/DPoP.md

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
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

Comments
 (0)