Skip to content

Commit 88e6ecb

Browse files
committed
Added MFA support in passkeys with dpop
1 parent 8459df0 commit 88e6ecb

5 files changed

Lines changed: 326 additions & 7 deletions

File tree

examples/MFA.md

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ 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)
3738
- [Session Persistence](#session-persistence)
3839
- [Automatic Session Update](#automatic-session-update)
3940
- [Manual Session Update](#manual-session-update)
@@ -123,6 +124,9 @@ except MfaRequiredError as error:
123124
mfa_token = context.mfa_token # Raw token for MFA API calls
124125
```
125126

127+
> [!NOTE]
128+
> `get_access_token()` is not the only origin of `MfaRequiredError`. A passkey login (`signin_with_passkey`) raises the same error when a second factor is required — but there is **no session yet** at that point, which changes how you complete and persist the flow. See [Passkeys.md → Completing MFA on a passkey login](Passkeys.md#completing-mfa-on-a-passkey-login-and-where-the-session-comes-from).
129+
126130
### MFA Token Encryption Design
127131

128132
The MFA token returned by `MfaRequiredError` from `get_access_token()` is **encrypted** by `ServerClient` before it reaches the caller. This prevents token tampering and replay when the token is stored client-side (e.g. in a cookie or hidden form field).
@@ -414,7 +418,7 @@ except Exception as error:
414418
```
415419

416420
> [!NOTE]
417-
> Setting `persist=True` automatically updates the session store with the new tokens, similar to nextjs-auth0 and auth0-spa-js SDKs. This eliminates the need for manual token management after MFA verification.
421+
> Setting `persist=True` automatically updates the session store with the new tokens, eliminating the need for manual token management after MFA verification.
418422
419423
> [!TIP]
420424
> The `verify()` response may include a `recovery_code` field. This is returned when a user completes their first MFA enrollment, or when they verify using a recovery code (a new one is generated to replace the used code). Always check for this field and display it to the user.
@@ -516,9 +520,33 @@ async def poll_push_verification(server_client, mfa_token, oob_code, timeout=60)
516520
> [!NOTE]
517521
> 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.
518522
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+
519544
## Session Persistence
520545

521-
By default, `verify()` returns tokens without persisting them to the session store. However, you can automatically persist tokens by setting `persist=True`, similar to how nextjs-auth0 and auth0-spa-js handle MFA.
546+
By default, `verify()` returns tokens without persisting them to the session store. However, you can automatically persist tokens by setting `persist=True`.
547+
548+
> [!WARNING]
549+
> `persist=True` **updates an existing session** — it does not create one. On a passkey-first login (`signin_with_passkey``MfaRequiredError`) no session exists yet, so `persist=True` raises `MfaVerifyError("No existing session found to update with MFA tokens")` and discards the tokens `verify()` just obtained. On that path, use `persist=False` (the default) and store the returned tokens yourself — see [Passkeys.md → Completing MFA on a passkey login](Passkeys.md#completing-mfa-on-a-passkey-login-and-where-the-session-comes-from).
522550
523551
### Automatic Session Update
524552

examples/Passkeys.md

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ Passkeys let users sign up and log in with [WebAuthn](https://www.w3.org/TR/weba
1515
- [1. Passkey Signup](#1-passkey-signup)
1616
- [2. Passkey Login](#2-passkey-login)
1717
- [3. DPoP-bound passkey tokens (optional)](#3-dpop-bound-passkey-tokens-optional)
18+
- [Completing MFA on a passkey login (and where the session comes from)](#completing-mfa-on-a-passkey-login-and-where-the-session-comes-from)
1819
- [Error Handling](#error-handling)
1920

2021
## How the flow works
@@ -160,6 +161,54 @@ When `dpop_key` is supplied, the SDK attaches a token-endpoint proof so Auth0 is
160161
> [!WARNING]
161162
> 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.
162163
164+
### Completing MFA on a passkey login (and where the session comes from)
165+
166+
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):
167+
168+
```python
169+
from auth0_server_python.error import MfaRequiredError
170+
171+
try:
172+
result = await server_client.signin_with_passkey(
173+
auth_session=auth_session,
174+
authn_response=authn_response,
175+
dpop_key=dpop_key, # optional; omit for Bearer tokens
176+
store_options={"request": request, "response": response},
177+
)
178+
# No MFA needed: signin_with_passkey already persisted the session for you.
179+
user = result.state_data["user"]
180+
181+
except MfaRequiredError as e:
182+
# 1. Challenge the factor (e.g. an authenticator-app OTP).
183+
await server_client.mfa.challenge_authenticator(
184+
{"mfa_token": e.mfa_token, "factor_type": "otp"},
185+
store_options={"request": request, "response": response},
186+
)
187+
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.
191+
verify_response = await server_client.mfa.verify(
192+
{"mfa_token": e.mfa_token, "otp": otp_code},
193+
dpop_key=dpop_key, # same key given to signin_with_passkey
194+
store_options={"request": request, "response": response},
195+
)
196+
197+
# 3. Persist the tokens into YOUR session yourself — this is the step the
198+
# SDK skips on the MFA path because no session existed at login time.
199+
save_session_for_user(
200+
access_token=verify_response.access_token,
201+
id_token=verify_response.id_token,
202+
refresh_token=verify_response.refresh_token,
203+
)
204+
```
205+
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+
209+
> [!NOTE]
210+
> 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.)
211+
163212
## Error Handling
164213

165214
The three passkey methods raise `PasskeyError` (a subclass of `Auth0Error`). Input-validation failures raise `MissingRequiredArgumentError`; a required step-up raises `MfaRequiredError`. For most code, catching `Auth0Error` is enough.
@@ -191,7 +240,9 @@ try:
191240
store_options={"request": request, "response": response},
192241
)
193242
except MfaRequiredError as e:
194-
return start_mfa(e.mfa_token) # step-up required — continue with MfaClient
243+
return start_mfa(e.mfa_token) # step-up required — challenge + verify via client.mfa,
244+
# then store the returned tokens (see "Completing MFA
245+
# on a passkey login" above)
195246
except PasskeyError as e:
196247
return {"error": e.code, "detail": e.message} # branch on e.code, never on message text
197248
except Auth0Error as e:

src/auth0_server_python/auth_server/mfa_client.py

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

66
import json
77
import time
8-
from typing import Any, Callable, Optional, Union
8+
from typing import TYPE_CHECKING, 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
1317
from auth0_server_python.auth_types import (
1418
AuthenticatorResponse,
1519
ChallengeResponse,
@@ -446,7 +450,8 @@ async def challenge_authenticator(
446450
async def verify(
447451
self,
448452
options: dict[str, Any],
449-
store_options: Optional[dict[str, Any]] = None
453+
store_options: Optional[dict[str, Any]] = None,
454+
dpop_key: Optional["jwk.JWK"] = None,
450455
) -> MfaVerifyResponse:
451456
"""
452457
Verifies an MFA code and completes authentication.
@@ -466,12 +471,20 @@ async def verify(
466471
- 'audience': str (optional, required if persist=True) - Audience for token_set
467472
- 'scope': str (optional) - Scope for token_set
468473
store_options: Optional options passed to the State Store (e.g. request/response).
474+
dpop_key: Optional EC P-256 JWK for DPoP-bound token exchange. Pass the
475+
same key the login flow was bound to (e.g. the dpop_key given to
476+
signin_with_passkey) so the MFA step-up preserves the sender
477+
constraint. When provided, attaches a DPoP proof so Auth0 issues a
478+
DPoP-bound token (token_type: DPoP); the SDK never stores this
479+
Tier 0 key — the caller re-supplies it, consistent with every other
480+
DPoP entry point.
469481
470482
Returns:
471483
MfaVerifyResponse with access_token, token_type, etc.
472484
473485
Raises:
474-
MfaVerifyError: When verification fails.
486+
MfaVerifyError: When verification fails, or when dpop_key was supplied
487+
but the server returned an unbound (Bearer) token.
475488
MfaRequiredError: When chained MFA is required.
476489
"""
477490
mfa_token = options.get("mfa_token")
@@ -506,12 +519,35 @@ async def verify(
506519
token_endpoint = f"{base_url}/oauth/token"
507520

508521
async with self._get_http_client() as client:
522+
headers = {"Content-Type": "application/x-www-form-urlencoded"}
523+
if dpop_key is not None:
524+
headers["DPoP"] = make_dpop_proof_for_token_endpoint(
525+
dpop_key, "POST", token_endpoint
526+
)
509527
response = await client.post(
510528
token_endpoint,
511529
data=body,
512-
headers={"Content-Type": "application/x-www-form-urlencoded"}
530+
headers=headers
513531
)
514532

533+
# RFC 9449 §8.2 — the authorization server signals a required
534+
# nonce with HTTP 400/401 + a DPoP-Nonce header. Rebuild the proof
535+
# with the nonce and retry once.
536+
if (
537+
dpop_key is not None
538+
and response.status_code in (400, 401)
539+
and response.headers.get("DPoP-Nonce")
540+
):
541+
nonce = response.headers["DPoP-Nonce"]
542+
headers["DPoP"] = make_dpop_proof_for_token_endpoint(
543+
dpop_key, "POST", token_endpoint, nonce=nonce
544+
)
545+
response = await client.post(
546+
token_endpoint,
547+
data=body,
548+
headers=headers
549+
)
550+
515551
if response.status_code != 200:
516552
error_data = self._parse_error_body(response)
517553

@@ -533,6 +569,25 @@ async def verify(
533569
token_response = response.json()
534570
verify_response = MfaVerifyResponse(**token_response)
535571

572+
# DPoP binding must be consistent in both directions. token_type
573+
# is the documented OAuth response field; per RFC 9449 a
574+
# sender-constrained token is returned as token_type "DPoP".
575+
token_is_dpop = verify_response.token_type.lower() == "dpop"
576+
if dpop_key is not None and not token_is_dpop:
577+
# We asked for a bound token but got Bearer — cannot prove
578+
# possession on later calls; reject rather than downgrade.
579+
raise MfaVerifyError(
580+
"DPoP token binding failed: expected token_type 'DPoP', "
581+
f"got '{verify_response.token_type}'"
582+
)
583+
if dpop_key is None and token_is_dpop:
584+
# Server issued a DPoP-bound token but no key was supplied —
585+
# we cannot prove possession, so accepting it would fail open.
586+
raise MfaVerifyError(
587+
"Server returned a DPoP-bound token but no dpop_key was "
588+
"provided; pass dpop_key to verify to bind it"
589+
)
590+
536591
# Clear the in-progress MFA state after successful verification.
537592
if self._state_store:
538593
await self._state_store.delete(MFA_PENDING_IDENTIFIER, store_options)

src/auth0_server_python/tests/test_mfa_client.py

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from unittest.mock import AsyncMock, MagicMock
77

88
import pytest
9+
from jwcrypto import jwk
910

1011
from auth0_server_python.auth_server.mfa_client import DEFAULT_MFA_TOKEN_TTL, MfaClient
1112
from auth0_server_python.auth_types import (
@@ -892,3 +893,121 @@ async def test_verify_persist_store_failure_raises(self, mocker):
892893
{"mfa_token": _enc(), "otp": "123456",
893894
"persist": True, "audience": "https://api.example.com"}
894895
)
896+
897+
@pytest.mark.asyncio
898+
async def test_verify_dpop_attaches_proof_header(self, mocker):
899+
"""When dpop_key is supplied, a DPoP proof header is sent and a bound token accepted."""
900+
client = _make_client()
901+
dpop_key = jwk.JWK.generate(kty="EC", crv="P-256")
902+
response = AsyncMock()
903+
response.status_code = 200
904+
response.headers = {}
905+
response.json = MagicMock(return_value={
906+
"access_token": "bound_at", "token_type": "DPoP", "expires_in": 3600
907+
})
908+
909+
captured_request = {}
910+
911+
async def mock_post(self_client, url, **kwargs):
912+
captured_request["kwargs"] = kwargs
913+
return response
914+
915+
mocker.patch("httpx.AsyncClient.post", new=mock_post)
916+
917+
result = await client.verify(
918+
{"mfa_token": _enc(), "otp": "123456"},
919+
dpop_key=dpop_key,
920+
)
921+
assert result.token_type == "DPoP"
922+
assert "DPoP" in captured_request["kwargs"]["headers"]
923+
924+
@pytest.mark.asyncio
925+
async def test_verify_dpop_nonce_retry(self, mocker):
926+
"""RFC 9449 §8.2: a DPoP-Nonce challenge triggers exactly one retry with the nonce."""
927+
client = _make_client()
928+
dpop_key = jwk.JWK.generate(kty="EC", crv="P-256")
929+
930+
challenge = AsyncMock()
931+
challenge.status_code = 400
932+
challenge.headers = {"DPoP-Nonce": "server-nonce-123"}
933+
challenge.json = MagicMock(return_value={"error": "use_dpop_nonce"})
934+
935+
success = AsyncMock()
936+
success.status_code = 200
937+
success.headers = {}
938+
success.json = MagicMock(return_value={
939+
"access_token": "bound_at", "token_type": "DPoP", "expires_in": 3600
940+
})
941+
942+
proofs = []
943+
944+
async def mock_post(self_client, url, **kwargs):
945+
proofs.append(kwargs["headers"].get("DPoP"))
946+
return challenge if len(proofs) == 1 else success
947+
948+
mocker.patch("httpx.AsyncClient.post", new=mock_post)
949+
950+
result = await client.verify(
951+
{"mfa_token": _enc(), "otp": "123456"},
952+
dpop_key=dpop_key,
953+
)
954+
assert result.token_type == "DPoP"
955+
assert len(proofs) == 2
956+
assert proofs[0] != proofs[1]
957+
958+
@pytest.mark.asyncio
959+
async def test_verify_dpop_rejects_bearer_downgrade(self, mocker):
960+
"""dpop_key supplied but server returns Bearer: reject rather than downgrade."""
961+
client = _make_client()
962+
dpop_key = jwk.JWK.generate(kty="EC", crv="P-256")
963+
response = AsyncMock()
964+
response.status_code = 200
965+
response.headers = {}
966+
response.json = MagicMock(return_value={
967+
"access_token": "at", "token_type": "Bearer", "expires_in": 3600
968+
})
969+
mocker.patch("httpx.AsyncClient.post", new_callable=AsyncMock, return_value=response)
970+
971+
with pytest.raises(MfaVerifyError, match="DPoP token binding failed"):
972+
await client.verify(
973+
{"mfa_token": _enc(), "otp": "123456"},
974+
dpop_key=dpop_key,
975+
)
976+
977+
@pytest.mark.asyncio
978+
async def test_verify_dpop_bound_token_without_key_rejected(self, mocker):
979+
"""Server returns a DPoP-bound token but no key supplied: fail closed, don't accept."""
980+
client = _make_client()
981+
response = AsyncMock()
982+
response.status_code = 200
983+
response.headers = {}
984+
response.json = MagicMock(return_value={
985+
"access_token": "bound_at", "token_type": "DPoP", "expires_in": 3600
986+
})
987+
mocker.patch("httpx.AsyncClient.post", new_callable=AsyncMock, return_value=response)
988+
989+
with pytest.raises(MfaVerifyError, match="no dpop_key was"):
990+
await client.verify({"mfa_token": _enc(), "otp": "123456"})
991+
992+
@pytest.mark.asyncio
993+
async def test_verify_without_dpop_no_dpop_header(self, mocker):
994+
"""Without dpop_key the request carries no DPoP header and Bearer is accepted."""
995+
client = _make_client()
996+
response = AsyncMock()
997+
response.status_code = 200
998+
response.headers = {}
999+
response.json = MagicMock(return_value={
1000+
"access_token": "at", "token_type": "Bearer", "expires_in": 3600
1001+
})
1002+
1003+
captured_request = {}
1004+
1005+
async def mock_post(self_client, url, **kwargs):
1006+
captured_request["kwargs"] = kwargs
1007+
return response
1008+
1009+
mocker.patch("httpx.AsyncClient.post", new=mock_post)
1010+
1011+
result = await client.verify({"mfa_token": _enc(), "otp": "123456"})
1012+
assert result.token_type == "Bearer"
1013+
assert "DPoP" not in captured_request["kwargs"]["headers"]

0 commit comments

Comments
 (0)