Skip to content

Commit ef30738

Browse files
authored
Merge pull request #152 from auth0/feat/passwordless-mfa
feat: MFA support in passwordless
2 parents a84f79e + 313f52b commit ef30738

6 files changed

Lines changed: 183 additions & 16 deletions

File tree

examples/MFA.md

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -545,15 +545,16 @@ The SDK does not store your private key, so you must re-supply it on the `verify
545545

546546
By default, `verify()` returns tokens without persisting them to the session store. However, you can automatically persist tokens by setting `persist=True`.
547547

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).
548+
> [!NOTE]
549+
> `persist=True` updates an existing session when one is present. For first-login MFA flows where the SDK has not created a session yet, `ServerClient.mfa` can create the initial session from the final MFA token response when that response includes an ID token.
550550
551551
### Automatic Session Update
552552

553553
When you set `persist=True`, the SDK will:
554-
1. Update the session's `access_token` for the specified audience
555-
2. Update the session's `id_token` if present
556-
3. Add the token to the `token_sets` array with expiration information
554+
1. Update an existing session, or create the initial session when the MFA flow completed a first login
555+
2. Persist the `access_token` for the specified audience
556+
3. Persist the `id_token` if present
557+
4. Add the token to the `token_sets` array with expiration information
557558

558559
```python
559560
verify_response = await server_client.mfa.verify(

examples/Passwordless.md

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,9 @@ user = result["state_data"]["user"]
189189
>
190190
> This matters more than it looks: Auth0 treats magic-link `state` as a pure echo and does not validate it server-side, and the clicked link's query string can overwrite whatever the browser originally stored. The SDK's single-use, `state`-keyed transaction plus the exact-match `redirect_uri` are therefore the *entire* CSRF / authorization-code-interception defense on this flow — Auth0 will not catch a bypass for you.
191191
192+
> [!NOTE]
193+
> If the callback fails (expired link, JWKS unavailable, a rejected ID token), have the user restart the flow from `start()` rather than retrying the same link — a failed callback does not guarantee the transaction was cleaned up, so re-submitting the same callback URL can produce a confusing error instead of a clear "session expired, please try again."
194+
192195
## 4. Custom scopes and audiences
193196

194197
For OTP flows, pass `scope` and `audience` to `verify()`. These become the `/oauth/token` request parameters.
@@ -206,6 +209,8 @@ result = await server_client.passwordless.verify(
206209
)
207210
```
208211

212+
A caller-supplied OTP `scope` **replaces** the default wholesale rather than merging with it. The SDK re-injects `openid` when your scope omits it, for the same reason as magic link below: without it, Auth0 returns no ID token and `verify()` fails.
213+
209214
For magic links, pass allowed authorization parameters through `auth_params` at `start()` time:
210215

211216
```python
@@ -258,7 +263,7 @@ result = await server_client.passwordless.verify(
258263
259264
## Completing MFA during passwordless login
260265

261-
Auth0 can require MFA during passwordless OTP verification. In that case, the SDK raises `MfaRequiredError` before it creates a session. Complete the MFA challenge with `server_client.mfa`, then persist the returned tokens according to your framework's session integration.
266+
Auth0 can require MFA during passwordless OTP verification. In that case, the SDK raises `MfaRequiredError` before it creates a session. Complete the MFA challenge with `server_client.mfa` and pass `persist=True` on verification so the SDK creates the session from the final MFA token response.
262267

263268
```python
264269
from auth0_server_python.error import MfaRequiredError
@@ -280,20 +285,19 @@ except MfaRequiredError as e:
280285
store_options={"request": request, "response": response},
281286
)
282287

283-
verify_response = await server_client.mfa.verify(
284-
{"mfa_token": e.mfa_token, "otp": mfa_code},
288+
await server_client.mfa.verify(
289+
{"mfa_token": e.mfa_token, "otp": mfa_code, "persist": True},
285290
store_options={"request": request, "response": response},
286291
)
287292

288-
save_session_for_user(
289-
access_token=verify_response.access_token,
290-
id_token=verify_response.id_token,
291-
refresh_token=verify_response.refresh_token,
293+
session = await server_client.get_session(
294+
store_options={"request": request, "response": response},
292295
)
296+
user = session["user"]
293297
```
294298

295299
> [!NOTE]
296-
> Passwordless OTP MFA is like passkey-first MFA: there is no existing application session yet. Use the returned MFA tokens to create the session in your framework layer rather than trying to update a session that does not exist.
300+
> Passwordless OTP MFA is like passkey-first MFA: there is no existing application session until MFA verification succeeds. `persist=True` creates the initial SDK session when the MFA response includes an ID token.
297301
298302
## Error Handling
299303

src/auth0_server_python/auth_server/mfa_client.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@
55

66
import json
77
import time
8-
from typing import TYPE_CHECKING, Any, Callable, Optional, Union
8+
from collections.abc import Awaitable, Callable
9+
from typing import TYPE_CHECKING, Any, Optional, Union
910

1011
import httpx
1112

@@ -66,7 +67,10 @@ def __init__(
6667
secret: str,
6768
state_store=None,
6869
state_identifier: str = "_a0_session",
69-
headers: Optional[dict[str, str]] = None
70+
headers: Optional[dict[str, str]] = None,
71+
session_establisher: Optional[
72+
Callable[..., Awaitable[None]]
73+
] = None,
7074
):
7175
if callable(domain):
7276
self._domain = None
@@ -80,6 +84,7 @@ def __init__(
8084
self._state_store = state_store
8185
self._state_identifier = state_identifier
8286
self._headers = headers or {}
87+
self._session_establisher = session_establisher
8388

8489
def _get_http_client(self, **kwargs) -> httpx.AsyncClient:
8590
"""Return an httpx.AsyncClient with default headers injected."""
@@ -626,6 +631,14 @@ async def _persist_mfa_tokens(
626631
)
627632

628633
if not state_data:
634+
if self._session_establisher:
635+
await self._session_establisher(
636+
verify_response=verify_response,
637+
audience=audience,
638+
scope=scope,
639+
store_options=store_options,
640+
)
641+
return
629642
raise MfaVerifyError(
630643
"No existing session found to update with MFA tokens"
631644
)

src/auth0_server_python/auth_server/passwordless_client.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -223,7 +223,10 @@ async def verify(
223223
if options.connection == "email"
224224
else DEFAULT_PASSWORDLESS_SMS_SCOPE
225225
)
226-
scope = options.scope or default_scope
226+
# A caller-supplied scope replaces the default wholesale, so `openid`
227+
# is re-injected the same way as the magic-link path: without it Auth0
228+
# returns no ID token and verification fails with no claims to persist.
229+
scope = self._ensure_openid_scope(options.scope or default_scope)
227230
body: dict[str, Any] = {
228231
"grant_type": PASSWORDLESS_OTP_GRANT_TYPE,
229232
"client_id": client._client_id,

src/auth0_server_python/auth_server/server_client.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
LogoutOptions,
3737
LogoutTokenClaims,
3838
MfaRequirements,
39+
MfaVerifyResponse,
3940
PasskeyAuthResponse,
4041
PasskeyLoginChallengeResponse,
4142
PasskeyLoginResult,
@@ -64,6 +65,7 @@
6465
InvalidArgumentError,
6566
IssuerValidationError,
6667
MfaRequiredError,
68+
MfaVerifyError,
6769
MissingRequiredArgumentError,
6870
MissingTransactionError,
6971
OrganizationTokenValidationError,
@@ -213,6 +215,7 @@ def __init__(
213215
state_store=self._state_store,
214216
state_identifier=self._state_identifier,
215217
headers=self._telemetry_headers,
218+
session_establisher=self._establish_session_from_mfa_verify_response,
216219
)
217220

218221
# Initialize Passwordless client (composes this client)
@@ -680,6 +683,65 @@ async def _persist_session_from_token_response(
680683
)
681684
return state_data
682685

686+
async def _establish_session_from_mfa_verify_response(
687+
self,
688+
*,
689+
verify_response: MfaVerifyResponse,
690+
audience: str,
691+
scope: Optional[str],
692+
store_options: Optional[dict[str, Any]] = None,
693+
) -> None:
694+
"""
695+
Create the initial SDK session after first-login MFA completes.
696+
697+
Step-up MFA updates an existing session. First-login MFA flows such as
698+
passwordless OTP and passkey can reach MFA before any SDK session exists,
699+
so the final MFA token response must be validated and persisted as the
700+
initial session.
701+
"""
702+
token_response = verify_response.model_dump(exclude_none=True)
703+
id_token = token_response.get("id_token")
704+
if not id_token:
705+
raise MfaVerifyError(
706+
"MFA verification response did not include an ID token; cannot create a session"
707+
)
708+
709+
origin_domain = await self._resolve_current_domain(store_options)
710+
metadata = await self._get_oidc_metadata_cached(origin_domain)
711+
origin_issuer = metadata.get("issuer")
712+
jwks = await self._get_jwks_cached(origin_domain, metadata)
713+
714+
try:
715+
claims = await self._verify_and_decode_jwt(
716+
id_token, jwks, audience=self._client_id
717+
)
718+
except ValueError as e:
719+
raise MfaVerifyError(str(e)) from e
720+
except jwt.InvalidAudienceError as e:
721+
raise MfaVerifyError(
722+
"ID token audience mismatch. Ensure your client_id is configured correctly."
723+
) from e
724+
except jwt.InvalidTokenError as e:
725+
raise MfaVerifyError(f"ID token verification failed: {str(e)}") from e
726+
727+
token_issuer = claims.get("iss", "")
728+
if self._normalize_url(token_issuer) != self._normalize_url(origin_issuer):
729+
raise MfaVerifyError(
730+
"ID token issuer mismatch. Ensure your Auth0 domain is configured correctly."
731+
)
732+
733+
user_claims = UserClaims.model_validate(claims)
734+
await self._persist_session_from_token_response(
735+
token_response=token_response,
736+
user_claims=user_claims,
737+
origin_domain=origin_domain,
738+
audience=audience,
739+
session_expires_at=user_claims.session_expiry,
740+
issued_at=claims.get("iat"),
741+
id_token_claims=claims,
742+
store_options=store_options,
743+
)
744+
683745
async def complete_interactive_login(
684746
self,
685747
url: str,

src/auth0_server_python/tests/test_passwordless_client.py

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -618,6 +618,25 @@ async def test_caller_scope_and_audience_forwarded_on_verify(self, mocker):
618618
assert data["audience"] == "https://api.example.com"
619619
assert data["scope"] == "openid profile email offline_access read:orders"
620620

621+
@pytest.mark.asyncio
622+
async def test_verify_injects_openid_when_caller_scope_omits_it(self, mocker):
623+
client = _make_client()
624+
claims = {"iss": ISSUER, "sub": "auth0|1", "sid": "s", "iat": 1_000}
625+
self._patch_verify_deps(client, mocker, claims)
626+
http = _mock_http(
627+
client, 200, {"access_token": "at", "id_token": "idt", "expires_in": 3600}
628+
)
629+
630+
await client.passwordless.verify(
631+
VerifyPasswordlessOtpOptions(
632+
connection="email",
633+
email="user@example.com",
634+
verification_code="123456",
635+
scope="profile email",
636+
)
637+
)
638+
assert http.post.call_args.kwargs["data"]["scope"] == "openid profile email"
639+
621640
@pytest.mark.asyncio
622641
async def test_verify_invalid_audience_maps_to_typed_error(self, mocker):
623642
client = _make_client()
@@ -835,6 +854,71 @@ async def test_verify_mfa_required_raises_typed_error(self, mocker):
835854
assert decrypted.mfa_token == "raw_server_mfa_token"
836855
client._state_store.set.assert_not_awaited()
837856

857+
@pytest.mark.asyncio
858+
async def test_passwordless_mfa_verify_persist_creates_session(self, mocker):
859+
client = _make_client()
860+
mocker.patch.object(client, "_get_oidc_metadata_cached", return_value=METADATA)
861+
mocker.patch.object(
862+
client,
863+
"_get_jwks_cached",
864+
return_value={"keys": [{"kty": "RSA", "kid": "k1"}]},
865+
)
866+
mocker.patch.object(
867+
client,
868+
"_verify_and_decode_jwt",
869+
return_value={
870+
"iss": ISSUER,
871+
"sub": "auth0|mfa-user",
872+
"sid": "SID-MFA",
873+
"iat": 1_000,
874+
"email": "user@example.com",
875+
},
876+
)
877+
_mock_http(
878+
client,
879+
403,
880+
{
881+
"error": "mfa_required",
882+
"error_description": "Additional factor required",
883+
"mfa_token": "raw_server_mfa_token",
884+
},
885+
)
886+
887+
with pytest.raises(MfaRequiredError) as exc:
888+
await client.passwordless.verify(
889+
VerifyPasswordlessOtpOptions(
890+
connection="email", email="user@example.com", verification_code="123456"
891+
),
892+
store_options={},
893+
)
894+
895+
client._state_store.get = AsyncMock(return_value=None)
896+
mfa_response = AsyncMock()
897+
mfa_response.status_code = 200
898+
mfa_response.headers = {}
899+
mfa_response.json = MagicMock(
900+
return_value={
901+
"access_token": "mfa_at",
902+
"id_token": "mfa_idt",
903+
"token_type": "Bearer",
904+
"expires_in": 3600,
905+
"scope": "openid profile email",
906+
}
907+
)
908+
mocker.patch("httpx.AsyncClient.post", new_callable=AsyncMock, return_value=mfa_response)
909+
910+
await client.mfa.verify(
911+
{"mfa_token": exc.value.mfa_token, "otp": "654321", "persist": True},
912+
store_options={},
913+
)
914+
915+
client._state_store.set.assert_awaited_once()
916+
saved_state = client._state_store.set.await_args.args[1]
917+
assert saved_state.user.sub == "auth0|mfa-user"
918+
assert saved_state.id_token == "mfa_idt"
919+
assert saved_state.internal.sid == "SID-MFA"
920+
assert saved_state.token_sets[0].access_token == "mfa_at"
921+
838922
@pytest.mark.asyncio
839923
async def test_verify_mfa_required_without_token_falls_through(self, mocker):
840924
# Third-party-strict / flex-commands-with-FF-off: 403 mfa_required with

0 commit comments

Comments
 (0)