Skip to content

Commit e9b2ae6

Browse files
committed
My Account API support
1 parent 7b9de6b commit e9b2ae6

13 files changed

Lines changed: 18 additions & 1218 deletions

README.md

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -191,10 +191,6 @@ 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-
198194
## Feedback
199195

200196
### Contributing

examples/MFA.md

Lines changed: 0 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,6 @@ 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)
3837
- [Session Persistence](#session-persistence)
3938
- [Automatic Session Update](#automatic-session-update)
4039
- [Manual Session Update](#manual-session-update)
@@ -520,27 +519,6 @@ async def poll_push_verification(server_client, mfa_token, oob_code, timeout=60)
520519
> [!NOTE]
521520
> 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.
522521
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-
544522
## Session Persistence
545523

546524
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: 1 addition & 20 deletions
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). To **bind these calls to a held key**, pass an optional `dpop_key` — see [DPoP](#dpop) below.
9+
> To **sign in** with a passkey (rather than manage one), see [examples/Passkeys.md](Passkeys.md).
1010
1111
## Table of Contents
1212

@@ -18,7 +18,6 @@ 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)
2221
- [Error Handling](#error-handling)
2322

2423
## Prerequisites
@@ -175,24 +174,6 @@ await my_account.delete_authentication_method(
175174
# Returns None on success (HTTP 204).
176175
```
177176

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-
196177
## Error Handling
197178

198179
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: 3 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@ 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)
1817
- [Completing MFA on a passkey login (and where the session comes from)](#completing-mfa-on-a-passkey-login-and-where-the-session-comes-from)
1918
- [Error Handling](#error-handling)
2019

@@ -136,31 +135,6 @@ result = await server_client.signin_with_passkey(
136135
> [!NOTE]
137136
> 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.
138137
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-
164138
### Completing MFA on a passkey login (and where the session comes from)
165139

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

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.
161+
# 2. Verify the user's code. persist=False (the default) → the SDK
162+
# returns the tokens instead of writing a session.
191163
verify_response = await server_client.mfa.verify(
192164
{"mfa_token": e.mfa_token, "otp": otp_code},
193-
dpop_key=dpop_key, # same key given to signin_with_passkey
194165
store_options={"request": request, "response": response},
195166
)
196167

@@ -203,9 +174,6 @@ except MfaRequiredError as e:
203174
)
204175
```
205176

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-
209177
> [!NOTE]
210178
> 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.)
211179
@@ -252,7 +220,7 @@ except Auth0Error as e:
252220
### Common error codes (`PasskeyErrorCode`)
253221

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

258226
> [!NOTE]
Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
11
from .bearer_auth import BearerAuth
2-
from .dpop_auth import DPoPAuth
32

4-
__all__ = ["BearerAuth", "DPoPAuth"]
3+
__all__ = ["BearerAuth"]

src/auth0_server_python/auth_schemes/dpop_auth.py

Lines changed: 0 additions & 94 deletions
This file was deleted.

src/auth0_server_python/auth_server/mfa_client.py

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

66
import json
77
import time
8-
from typing import TYPE_CHECKING, Any, Callable, Optional, Union
8+
from typing import 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
1713
from auth0_server_python.auth_types import (
1814
AuthenticatorResponse,
1915
ChallengeResponse,
@@ -451,7 +447,6 @@ async def verify(
451447
self,
452448
options: dict[str, Any],
453449
store_options: Optional[dict[str, Any]] = None,
454-
dpop_key: Optional["jwk.JWK"] = None,
455450
) -> MfaVerifyResponse:
456451
"""
457452
Verifies an MFA code and completes authentication.
@@ -471,16 +466,12 @@ async def verify(
471466
- 'audience': str (optional, required if persist=True) - Audience for token_set
472467
- 'scope': str (optional) - Scope for token_set
473468
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.
477469
478470
Returns:
479471
MfaVerifyResponse with access_token, token_type, etc.
480472
481473
Raises:
482-
MfaVerifyError: When verification fails, or when dpop_key was supplied
483-
but the server returned an unbound (Bearer) token.
474+
MfaVerifyError: When verification fails.
484475
MfaRequiredError: When chained MFA is required.
485476
"""
486477
mfa_token = options.get("mfa_token")
@@ -515,33 +506,12 @@ async def verify(
515506
token_endpoint = f"{base_url}/oauth/token"
516507

517508
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-
)
523509
response = await client.post(
524510
token_endpoint,
525511
data=body,
526-
headers=headers
512+
headers={"Content-Type": "application/x-www-form-urlencoded"}
527513
)
528514

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-
545515
if response.status_code != 200:
546516
error_data = self._parse_error_body(response)
547517

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

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-
575536
# Clear the in-progress MFA state after successful verification.
576537
if self._state_store:
577538
await self._state_store.delete(MFA_PENDING_IDENTIFIER, store_options)

0 commit comments

Comments
 (0)