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
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,10 @@ Let a logged-in user manage their own enrolled authentication methods — enroll

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

### 10. Passwordless Authentication

Sign users in with a one-time code sent by email or SMS, or with a magic link sent by email, via [Auth0 embedded passwordless login](https://auth0.com/docs/authenticate/passwordless/implement-login/embedded-login/relevant-api-endpoints). OTP verification and the magic-link callback each establish a server-side session like every other login path. For prerequisites, both flows, custom scopes/audiences, step-up MFA, and error handling, see [examples/Passwordless.md](examples/Passwordless.md).

## Feedback

### Contributing
Expand Down
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
383 changes: 383 additions & 0 deletions examples/Passwordless.md

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion src/auth0_server_python/auth_server/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from .mfa_client import MfaClient
from .my_account_client import MyAccountClient
from .passwordless_client import PasswordlessClient
from .server_client import ServerClient

__all__ = ["ServerClient", "MyAccountClient", "MfaClient"]
__all__ = ["ServerClient", "MyAccountClient", "MfaClient", "PasswordlessClient"]
24 changes: 21 additions & 3 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 All @@ -28,6 +29,7 @@
)
from auth0_server_python.encryption.encrypt import decrypt, encrypt
from auth0_server_python.error import (
ConfigurationError,
DomainResolverError,
MfaChallengeError,
MfaEnrollmentError,
Expand Down Expand Up @@ -66,7 +68,11 @@ 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,
mfa_token_ttl: int = DEFAULT_MFA_TOKEN_TTL,
):
if callable(domain):
self._domain = None
Expand All @@ -80,6 +86,10 @@ def __init__(
self._state_store = state_store
self._state_identifier = state_identifier
self._headers = headers or {}
self._session_establisher = session_establisher
if mfa_token_ttl <= 0:
raise ConfigurationError("mfa_token_ttl must be a positive number of seconds")
self._mfa_token_ttl = mfa_token_ttl

def _get_http_client(self, **kwargs) -> httpx.AsyncClient:
"""Return an httpx.AsyncClient with default headers injected."""
Expand Down Expand Up @@ -137,7 +147,7 @@ def decrypt_mfa_token(self, encrypted_token: str) -> MfaTokenContext:
raise MfaTokenInvalidError()

elapsed = int(time.time()) - context.created_at
if elapsed > DEFAULT_MFA_TOKEN_TTL:
if elapsed > self._mfa_token_ttl:
raise MfaTokenExpiredError()

return context
Expand Down Expand Up @@ -626,6 +636,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
Loading