Skip to content
Merged
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
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,10 @@ Sign users up or in with [WebAuthn](https://www.w3.org/TR/webauthn-2/) passkeys

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

### 9. DPoP β€” Sender-Constrained Tokens (Passkeys & MyAccount)

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

## Feedback

### Contributing
Expand Down
22 changes: 22 additions & 0 deletions examples/MFA.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ The Auth0 MFA API allows you to manage multi-factor authentication for users in
- [Verify with OTP](#verify-with-otp)
- [Verify with Recovery Code](#verify-with-recovery-code)
- [Verify with Push Notification (Polling)](#verify-with-push-notification-polling)
- [Verify with DPoP (sender-constrained tokens)](#verify-with-dpop-sender-constrained-tokens)
- [Session Persistence](#session-persistence)
- [Automatic Session Update](#automatic-session-update)
- [Manual Session Update](#manual-session-update)
Expand Down Expand Up @@ -519,6 +520,27 @@ async def poll_push_verification(server_client, mfa_token, oob_code, timeout=60)
> [!NOTE]
> 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.

### Verify with DPoP (sender-constrained tokens)

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:

```python
verify_response = await server_client.mfa.verify(
{
"mfa_token": mfa_token,
"otp": "123456",
},
dpop_key=dpop_key, # the same EC P-256 key the login was bound to
)

assert verify_response.token_type == "DPoP"
```

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.

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

## Session Persistence

By default, `verify()` returns tokens without persisting them to the session store. However, you can automatically persist tokens by setting `persist=True`.
Expand Down
21 changes: 20 additions & 1 deletion examples/MyAccountAuthenticationMethods.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ The [My Account API](https://auth0.com/docs/manage-users/my-account-api) lets a
> 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.

> [!NOTE]
> To **sign in** with a passkey (rather than manage one), see [examples/Passkeys.md](Passkeys.md).
> 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.

## Table of Contents

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

## Prerequisites
Expand Down Expand Up @@ -174,6 +175,24 @@ await my_account.delete_authentication_method(
# Returns None on success (HTTP 204).
```

## DPoP

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:

```python
from jwcrypto import jwk

dpop_key = jwk.JWK.generate(kty="EC", crv="P-256") # the key the token was bound to

methods = await my_account.list_authentication_methods(
access_token=access_token,
dpop_key=dpop_key,
)
```

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

## Error Handling

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`.
Expand Down
38 changes: 35 additions & 3 deletions examples/Passkeys.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ Passkeys let users sign up and log in with [WebAuthn](https://www.w3.org/TR/weba
- [Prerequisites](#prerequisites)
- [1. Passkey Signup](#1-passkey-signup)
- [2. Passkey Login](#2-passkey-login)
- [3. DPoP-bound passkey tokens (optional)](#3-dpop-bound-passkey-tokens-optional)
- [Completing MFA on a passkey login (and where the session comes from)](#completing-mfa-on-a-passkey-login-and-where-the-session-comes-from)
- [Error Handling](#error-handling)

Expand Down Expand Up @@ -135,6 +136,31 @@ result = await server_client.signin_with_passkey(
> [!NOTE]
> 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.

## 3. DPoP-bound passkey tokens (optional)

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.

```python
from jwcrypto import jwk

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

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},
)
```

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.

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

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

### Completing MFA on a passkey login (and where the session comes from)

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):
Expand All @@ -146,6 +172,7 @@ try:
result = await server_client.signin_with_passkey(
auth_session=auth_session,
authn_response=authn_response,
dpop_key=dpop_key, # optional; omit for Bearer tokens
store_options={"request": request, "response": response},
)
# No MFA needed: signin_with_passkey already persisted the session for you.
Expand All @@ -158,10 +185,12 @@ except MfaRequiredError as e:
store_options={"request": request, "response": response},
)

# 2. Verify the user's code. persist=False (the default) β†’ the SDK
# returns the tokens instead of writing a session.
# 2. Verify the user's code. Re-supply the SAME dpop_key so the issued
# token stays DPoP-bound. persist=False (the default) β†’ the SDK returns
# the tokens instead of writing a session.
verify_response = await server_client.mfa.verify(
{"mfa_token": e.mfa_token, "otp": otp_code},
dpop_key=dpop_key, # same key given to signin_with_passkey
store_options={"request": request, "response": response},
)

Expand All @@ -174,6 +203,9 @@ except MfaRequiredError as e:
)
```

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

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

Expand Down Expand Up @@ -220,7 +252,7 @@ except Auth0Error as e:
### Common error codes (`PasskeyErrorCode`)

- `passkey_challenge_error` β€” the signup/login challenge request failed
- `passkey_token_error` β€” token exchange failed
- `passkey_token_error` β€” token exchange failed (also used for a rejected DPoP downgrade)
- `invalid_response` β€” Auth0 returned a response that could not be parsed

> [!NOTE]
Expand Down
3 changes: 2 additions & 1 deletion src/auth0_server_python/auth_schemes/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from .bearer_auth import BearerAuth
from .dpop_auth import DPoPAuth

__all__ = ["BearerAuth"]
__all__ = ["BearerAuth", "DPoPAuth"]
94 changes: 94 additions & 0 deletions src/auth0_server_python/auth_schemes/dpop_auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import base64
import hashlib
import time
import uuid
from typing import Optional

import httpx
from jwcrypto import jwk
from jwcrypto import jwt as jwcrypto_jwt


def _base64url(data: bytes) -> str:
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")


def _validate_dpop_key(key: "jwk.JWK") -> dict:
"""Return the public JWK after enforcing the EC P-256 requirement (ES256)."""
public_jwk = key.export_public(as_dict=True)
if public_jwk.get("kty") != "EC" or public_jwk.get("crv") != "P-256":
raise ValueError("DPoP key must be an EC P-256 key")
return public_jwk


def _build_dpop_proof(
key: "jwk.JWK",
public_jwk: dict,
method: str,
url: str,
*,
ath: Optional[str] = None,
nonce: Optional[str] = None,
) -> str:
"""Sign a DPoP proof JWT (RFC 9449 Β§4.2). `ath` binds the proof to an
access token and is omitted for token-endpoint proofs."""
htu = url.split("?")[0].split("#")[0]
header = {"typ": "dpop+jwt", "alg": "ES256", "jwk": public_jwk}
payload = {
"jti": str(uuid.uuid4()),
"htm": method.upper(),
"htu": htu,
"iat": int(time.time()),
}
if ath is not None:
payload["ath"] = ath
if nonce is not None:
payload["nonce"] = nonce
token = jwcrypto_jwt.JWT(header=header, claims=payload)
token.make_signed_token(key)
return token.serialize()


def make_dpop_proof_for_token_endpoint(
key: "jwk.JWK", method: str, url: str, nonce: Optional[str] = None
) -> str:
"""
Build a DPoP proof JWT for use at the token endpoint (RFC 9449 Β§4.2).
Unlike resource-server proofs, token-endpoint proofs do NOT include `ath`
because no access token exists yet at issuance time.
"""
public_jwk = _validate_dpop_key(key)
return _build_dpop_proof(key, public_jwk, method, url, nonce=nonce)


class DPoPAuth(httpx.Auth):
# Buffer the body (sync/async-aware) so the nonce retry can resend it.
requires_request_body = True

def __init__(self, token: str, key: "jwk.JWK") -> None:
public_jwk = _validate_dpop_key(key)
try:
token.encode("ascii")
except UnicodeEncodeError:
raise ValueError("Access token must contain only ASCII characters")
self._token = token
self._key = key
self._public_jwk = public_jwk

def auth_flow(self, request: httpx.Request):
proof = self._make_proof(request.method, str(request.url))
request.headers["Authorization"] = f"DPoP {self._token}"
request.headers["DPoP"] = proof
response = yield request

# RFC 9449 Β§8.2 β€” server-nonce retry
if response.status_code == 401 and response.headers.get("DPoP-Nonce"):
nonce = response.headers["DPoP-Nonce"]
request.headers["DPoP"] = self._make_proof(
request.method, str(request.url), nonce=nonce
)
yield request

def _make_proof(self, method: str, url: str, nonce: Optional[str] = None) -> str:
ath = _base64url(hashlib.sha256(self._token.encode("ascii")).digest())
return _build_dpop_proof(self._key, self._public_jwk, method, url, ath=ath, nonce=nonce)
45 changes: 42 additions & 3 deletions src/auth0_server_python/auth_server/mfa_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,15 @@

import json
import time
from typing import Any, Callable, Optional, Union
from typing import TYPE_CHECKING, Any, Callable, Optional, Union

import httpx

from auth0_server_python.auth_schemes.bearer_auth import BearerAuth
from auth0_server_python.auth_schemes.dpop_auth import make_dpop_proof_for_token_endpoint

if TYPE_CHECKING:
from jwcrypto import jwk
from auth0_server_python.auth_types import (
AuthenticatorResponse,
ChallengeResponse,
Expand Down Expand Up @@ -447,6 +451,7 @@ async def verify(
self,
options: dict[str, Any],
store_options: Optional[dict[str, Any]] = None,
dpop_key: Optional["jwk.JWK"] = None,
) -> MfaVerifyResponse:
"""
Verifies an MFA code and completes authentication.
Expand All @@ -466,12 +471,16 @@ async def verify(
- 'audience': str (optional, required if persist=True) - Audience for token_set
- 'scope': str (optional) - Scope for token_set
store_options: Optional options passed to the State Store (e.g. request/response).
dpop_key: Optional EC P-256 JWK to DPoP-bind the token. Pass the same
key used at login (e.g. given to signin_with_passkey) to preserve
the sender constraint through step-up. Never stored by the SDK.

Returns:
MfaVerifyResponse with access_token, token_type, etc.

Raises:
MfaVerifyError: When verification fails.
MfaVerifyError: When verification fails, or when dpop_key was supplied
but the server returned an unbound (Bearer) token.
MfaRequiredError: When chained MFA is required.
"""
mfa_token = options.get("mfa_token")
Expand Down Expand Up @@ -506,12 +515,33 @@ async def verify(
token_endpoint = f"{base_url}/oauth/token"

async with self._get_http_client() as client:
headers = {"Content-Type": "application/x-www-form-urlencoded"}
if dpop_key is not None:
headers["DPoP"] = make_dpop_proof_for_token_endpoint(
dpop_key, "POST", token_endpoint
)
response = await client.post(
token_endpoint,
data=body,
headers={"Content-Type": "application/x-www-form-urlencoded"}
headers=headers
)

# Rebuild the proof with the nonce and retry once.
if (
dpop_key is not None
and response.status_code in (400, 401)
and response.headers.get("DPoP-Nonce")
):
nonce = response.headers["DPoP-Nonce"]
headers["DPoP"] = make_dpop_proof_for_token_endpoint(
dpop_key, "POST", token_endpoint, nonce=nonce
)
response = await client.post(
token_endpoint,
data=body,
headers=headers
)

if response.status_code != 200:
error_data = self._parse_error_body(response)

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

# Reject a Bearer downgrade when a DPoP token was requested
# (RFC 9449: a bound token has token_type "DPoP").
token_is_dpop = verify_response.token_type.lower() == "dpop"
if dpop_key is not None and not token_is_dpop:
raise MfaVerifyError(
"DPoP token binding failed: expected token_type 'DPoP', "
f"got '{verify_response.token_type}'"
)

# Clear the in-progress MFA state after successful verification.
if self._state_store:
await self._state_store.delete(MFA_PENDING_IDENTIFIER, store_options)
Expand Down
Loading