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
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
20 changes: 12 additions & 8 deletions examples/Passwordless.md
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,9 @@ user = result["state_data"]["user"]
>
> 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.

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

## 4. Custom scopes and audiences

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

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.

For magic links, pass allowed authorization parameters through `auth_params` at `start()` time:

```python
Expand Down Expand Up @@ -258,7 +263,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 +285,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
5 changes: 4 additions & 1 deletion src/auth0_server_python/auth_server/passwordless_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,10 @@ async def verify(
if options.connection == "email"
else DEFAULT_PASSWORDLESS_SMS_SCOPE
)
scope = options.scope or default_scope
# A caller-supplied scope replaces the default wholesale, so `openid`
# is re-injected the same way as the magic-link path: without it Auth0
# returns no ID token and verification fails with no claims to persist.
scope = self._ensure_openid_scope(options.scope or default_scope)
body: dict[str, Any] = {
"grant_type": PASSWORDLESS_OTP_GRANT_TYPE,
"client_id": client._client_id,
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
84 changes: 84 additions & 0 deletions src/auth0_server_python/tests/test_passwordless_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -618,6 +618,25 @@ async def test_caller_scope_and_audience_forwarded_on_verify(self, mocker):
assert data["audience"] == "https://api.example.com"
assert data["scope"] == "openid profile email offline_access read:orders"

@pytest.mark.asyncio
async def test_verify_injects_openid_when_caller_scope_omits_it(self, mocker):
client = _make_client()
claims = {"iss": ISSUER, "sub": "auth0|1", "sid": "s", "iat": 1_000}
self._patch_verify_deps(client, mocker, claims)
http = _mock_http(
client, 200, {"access_token": "at", "id_token": "idt", "expires_in": 3600}
)

await client.passwordless.verify(
VerifyPasswordlessOtpOptions(
connection="email",
email="user@example.com",
verification_code="123456",
scope="profile email",
)
)
assert http.post.call_args.kwargs["data"]["scope"] == "openid profile email"

@pytest.mark.asyncio
async def test_verify_invalid_audience_maps_to_typed_error(self, mocker):
client = _make_client()
Expand Down Expand Up @@ -835,6 +854,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