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
11 changes: 6 additions & 5 deletions examples/MFA.md
Original file line number Diff line number Diff line change
Expand Up @@ -545,15 +545,16 @@ The SDK does not store your private key, so you must re-supply it on the `verify

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

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

### Automatic Session Update

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

```python
verify_response = await server_client.mfa.verify(
Expand Down
15 changes: 7 additions & 8 deletions examples/Passwordless.md
Original file line number Diff line number Diff line change
Expand Up @@ -258,7 +258,7 @@ result = await server_client.passwordless.verify(

## Completing MFA during passwordless login

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

```python
from auth0_server_python.error import MfaRequiredError
Expand All @@ -280,20 +280,19 @@ except MfaRequiredError as e:
store_options={"request": request, "response": response},
)

verify_response = await server_client.mfa.verify(
{"mfa_token": e.mfa_token, "otp": mfa_code},
await server_client.mfa.verify(
{"mfa_token": e.mfa_token, "otp": mfa_code, "persist": True},
store_options={"request": request, "response": response},
)

save_session_for_user(
access_token=verify_response.access_token,
id_token=verify_response.id_token,
refresh_token=verify_response.refresh_token,
session = await server_client.get_session(
store_options={"request": request, "response": response},
)
user = session["user"]
```

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

## Error Handling

Expand Down
17 changes: 15 additions & 2 deletions src/auth0_server_python/auth_server/mfa_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@

import json
import time
from typing import TYPE_CHECKING, Any, Callable, Optional, Union
from collections.abc import Awaitable, Callable
from typing import TYPE_CHECKING, Any, Optional, Union

import httpx

Expand Down Expand Up @@ -66,7 +67,10 @@ def __init__(
secret: str,
state_store=None,
state_identifier: str = "_a0_session",
headers: Optional[dict[str, str]] = None
headers: Optional[dict[str, str]] = None,
session_establisher: Optional[
Callable[..., Awaitable[None]]
] = None,
):
if callable(domain):
self._domain = None
Expand All @@ -80,6 +84,7 @@ def __init__(
self._state_store = state_store
self._state_identifier = state_identifier
self._headers = headers or {}
self._session_establisher = session_establisher

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

if not state_data:
if self._session_establisher:
await self._session_establisher(
verify_response=verify_response,
audience=audience,
scope=scope,
store_options=store_options,
)
return
raise MfaVerifyError(
"No existing session found to update with MFA tokens"
)
Expand Down
62 changes: 62 additions & 0 deletions src/auth0_server_python/auth_server/server_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
LogoutOptions,
LogoutTokenClaims,
MfaRequirements,
MfaVerifyResponse,
PasskeyAuthResponse,
PasskeyLoginChallengeResponse,
PasskeyLoginResult,
Expand Down Expand Up @@ -64,6 +65,7 @@
InvalidArgumentError,
IssuerValidationError,
MfaRequiredError,
MfaVerifyError,
MissingRequiredArgumentError,
MissingTransactionError,
OrganizationTokenValidationError,
Expand Down Expand Up @@ -213,6 +215,7 @@ def __init__(
state_store=self._state_store,
state_identifier=self._state_identifier,
headers=self._telemetry_headers,
session_establisher=self._establish_session_from_mfa_verify_response,
)

# Initialize Passwordless client (composes this client)
Expand Down Expand Up @@ -680,6 +683,65 @@ async def _persist_session_from_token_response(
)
return state_data

async def _establish_session_from_mfa_verify_response(
self,
*,
verify_response: MfaVerifyResponse,
audience: str,
scope: Optional[str],
store_options: Optional[dict[str, Any]] = None,
) -> None:
"""
Create the initial SDK session after first-login MFA completes.

Step-up MFA updates an existing session. First-login MFA flows such as
passwordless OTP and passkey can reach MFA before any SDK session exists,
so the final MFA token response must be validated and persisted as the
initial session.
"""
token_response = verify_response.model_dump(exclude_none=True)
id_token = token_response.get("id_token")
if not id_token:
raise MfaVerifyError(
"MFA verification response did not include an ID token; cannot create a session"
)

origin_domain = await self._resolve_current_domain(store_options)
metadata = await self._get_oidc_metadata_cached(origin_domain)
origin_issuer = metadata.get("issuer")
jwks = await self._get_jwks_cached(origin_domain, metadata)

try:
claims = await self._verify_and_decode_jwt(
id_token, jwks, audience=self._client_id
)
except ValueError as e:
raise MfaVerifyError(str(e)) from e
except jwt.InvalidAudienceError as e:
raise MfaVerifyError(
"ID token audience mismatch. Ensure your client_id is configured correctly."
) from e
except jwt.InvalidTokenError as e:
raise MfaVerifyError(f"ID token verification failed: {str(e)}") from e

token_issuer = claims.get("iss", "")
if self._normalize_url(token_issuer) != self._normalize_url(origin_issuer):
raise MfaVerifyError(
"ID token issuer mismatch. Ensure your Auth0 domain is configured correctly."
)

user_claims = UserClaims.model_validate(claims)
await self._persist_session_from_token_response(
token_response=token_response,
user_claims=user_claims,
origin_domain=origin_domain,
audience=audience,
session_expires_at=user_claims.session_expiry,
issued_at=claims.get("iat"),
id_token_claims=claims,
store_options=store_options,
)

async def complete_interactive_login(
self,
url: str,
Expand Down
65 changes: 65 additions & 0 deletions src/auth0_server_python/tests/test_passwordless_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -835,6 +835,71 @@ async def test_verify_mfa_required_raises_typed_error(self, mocker):
assert decrypted.mfa_token == "raw_server_mfa_token"
client._state_store.set.assert_not_awaited()

@pytest.mark.asyncio
async def test_passwordless_mfa_verify_persist_creates_session(self, mocker):
client = _make_client()
mocker.patch.object(client, "_get_oidc_metadata_cached", return_value=METADATA)
mocker.patch.object(
client,
"_get_jwks_cached",
return_value={"keys": [{"kty": "RSA", "kid": "k1"}]},
)
mocker.patch.object(
client,
"_verify_and_decode_jwt",
return_value={
"iss": ISSUER,
"sub": "auth0|mfa-user",
"sid": "SID-MFA",
"iat": 1_000,
"email": "user@example.com",
},
)
_mock_http(
client,
403,
{
"error": "mfa_required",
"error_description": "Additional factor required",
"mfa_token": "raw_server_mfa_token",
},
)

with pytest.raises(MfaRequiredError) as exc:
await client.passwordless.verify(
VerifyPasswordlessOtpOptions(
connection="email", email="user@example.com", verification_code="123456"
),
store_options={},
)

client._state_store.get = AsyncMock(return_value=None)
mfa_response = AsyncMock()
mfa_response.status_code = 200
mfa_response.headers = {}
mfa_response.json = MagicMock(
return_value={
"access_token": "mfa_at",
"id_token": "mfa_idt",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "openid profile email",
}
)
mocker.patch("httpx.AsyncClient.post", new_callable=AsyncMock, return_value=mfa_response)

await client.mfa.verify(
{"mfa_token": exc.value.mfa_token, "otp": "654321", "persist": True},
store_options={},
)

client._state_store.set.assert_awaited_once()
saved_state = client._state_store.set.await_args.args[1]
assert saved_state.user.sub == "auth0|mfa-user"
assert saved_state.id_token == "mfa_idt"
assert saved_state.internal.sid == "SID-MFA"
assert saved_state.token_sets[0].access_token == "mfa_at"

@pytest.mark.asyncio
async def test_verify_mfa_required_without_token_falls_through(self, mocker):
# Third-party-strict / flex-commands-with-FF-off: 403 mfa_required with
Expand Down