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
71 changes: 71 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,77 @@ The SDK handles per-domain OIDC discovery, JWKS fetching, issuer validation, and

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

### 6. Passkey Authentication

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:

```python
from auth0_server_python.auth_types import PasskeyUserProfile, PasskeyAuthResponse

# Step 1 — request a challenge
challenge = await auth0.passkey_login_challenge(
store_options={"request": request, "response": response}
)

# Step 2 — browser signs: navigator.credentials.get(challenge.authn_params_public_key)

# Step 3 — complete sign-in and establish the session
result = await auth0.signin_with_passkey(
auth_session=challenge.auth_session,
authn_response=PasskeyAuthResponse(**credential),
store_options={"request": request, "response": response}
)

user = result.state_data["user"]
```

For signup, organizations, step-up MFA, and error handling, see [examples/Passkeys.md](examples/Passkeys.md).

### 7. My Account API — Authentication Methods

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):

```python
from auth0_server_python.auth_server.my_account_client import MyAccountClient
from auth0_server_python.auth_types import EnrollAuthenticationMethodRequest

# Obtain a My Account-scoped token for the current session (MRRT)
access_token = await auth0.get_access_token(
store_options={"request": request, "response": response},
audience=f"https://{YOUR_CUSTOM_DOMAIN}/me/",
scope="create:me:authentication-methods read:me:authentication-methods",
)

my_account = MyAccountClient(domain=YOUR_CUSTOM_DOMAIN)

# Start enrolling a passkey (then sign it in the browser and verify)
challenge = await my_account.enroll_authentication_method(
access_token=access_token,
request=EnrollAuthenticationMethodRequest(type="passkey"),
)
```

For the full enroll/verify ceremony, listing, updating, deleting, and error handling, see [examples/MyAccountAuthenticationMethods.md](examples/MyAccountAuthenticationMethods.md).

### 8. DPoP — Sender-Constrained Tokens

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:

```python
from jwcrypto import jwk

dpop_key = jwk.JWK.generate(kty="EC", crv="P-256") # you create and keep this key

result = await auth0.signin_with_passkey(
auth_session=challenge.auth_session,
authn_response=authn_response,
dpop_key=dpop_key,
store_options={"request": request, "response": response}
)
```

For the `dpop_key` vs `dpop_proof` distinction, key lifecycle, nonce handling, and error handling, see [examples/DPoP.md](examples/DPoP.md).

## Feedback

### Contributing
Expand Down
133 changes: 133 additions & 0 deletions examples/DPoP.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
# DPoP — Sender-Constrained Tokens

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.

This SDK supports DPoP for **passkey sign-in** (`ServerClient.signin_with_passkey`) and for every **My Account API** call (`MyAccountClient`).

> [!NOTE]
> 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.).

## Table of Contents

- [`dpop_key` vs `dpop_proof`](#dpop_key-vs-dpop_proof)
- [1. Generate a key](#1-generate-a-key)
- [2. DPoP-bound passkey sign-in](#2-dpop-bound-passkey-sign-in)
- [3. DPoP on My Account API calls](#3-dpop-on-my-account-api-calls)
- [4. Generating a proof manually](#4-generating-a-proof-manually)
- [Key lifecycle and security](#key-lifecycle-and-security)
- [Error Handling](#error-handling)
- [Additional Resources](#additional-resources)

## `dpop_key` vs `dpop_proof`

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.

| | `dpop_key` | `dpop_proof` |
|---|------------|--------------|
| What it is | A long-lived **EC P-256 key pair** | A signed **JWT**, created fresh for one request |
| Lifetime | Reused across sign-in and every API call | Single-use — one per HTTP request |
| Who holds it | You (the private key never leaves your server) | Sent on the wire in the `DPoP:` header |
| Sensitivity | **Tier 0** — it is a secret | Not a stored secret — a short-lived derived artifact |
| In the SDK | The `dpop_key` parameter you pass in | Built internally — you never construct one |

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.

## 1. Generate a key

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.

```python
from jwcrypto import jwk

dpop_key = jwk.JWK.generate(kty="EC", crv="P-256")
```

> [!NOTE]
> 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.

## 2. DPoP-bound passkey sign-in

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.

```python
result = await server_client.signin_with_passkey(
auth_session=challenge.auth_session,
authn_response=authn_response,
dpop_key=dpop_key,
store_options={"request": request, "response": response},
)
```

See [examples/Passkeys.md](Passkeys.md) for the full passkey flow.

## 3. DPoP on My Account API calls

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.

```python
from auth0_server_python.auth_server.my_account_client import MyAccountClient

my_account = MyAccountClient(domain="YOUR_CUSTOM_DOMAIN")

methods = await my_account.list_authentication_methods(
access_token=access_token, # a DPoP-bound token from sign-in / MRRT
dpop_key=dpop_key, # the SAME key the token was bound to
)
```

> [!NOTE]
> 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.

## 4. Generating a proof manually

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:

```python
from auth0_server_python.auth_schemes.dpop_auth import make_dpop_proof_for_token_endpoint

proof = make_dpop_proof_for_token_endpoint(
dpop_key,
"POST",
"https://YOUR_CUSTOM_DOMAIN/oauth/token",
# nonce="..." # supply when the server returned a DPoP-Nonce
)
# send as the "DPoP" request header
```

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`.

## Key lifecycle and security

- **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.
- **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`).
- **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.
- **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()`.

## Error Handling

DPoP failures surface through the error type of the operation that used the key:

```python
from auth0_server_python.error import PasskeyError, MyAccountApiError, Auth0Error

# Wrong key type — fails closed before any request
try:
await server_client.signin_with_passkey(
auth_session=auth_session, authn_response=authn_response,
dpop_key=rsa_key, # not EC P-256
)
except ValueError as e:
print(e) # "DPoP key must be an EC P-256 key"

# Bearer downgrade when DPoP was requested
except PasskeyError as e:
print(e.code, e.message) # passkey_token_error — "DPoP token binding failed..."
```

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.

## Additional Resources

- [Passkey Authentication](Passkeys.md)
- [My Account — Authentication Methods](MyAccountAuthenticationMethods.md)
- [RFC 9449 — OAuth 2.0 Demonstrating Proof of Possession (DPoP)](https://www.rfc-editor.org/rfc/rfc9449)
Loading
Loading