Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
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 @@ -229,6 +229,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
366 changes: 366 additions & 0 deletions examples/Passwordless.md

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions references/docs-update.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,3 +63,4 @@ it from `README.md`'s section for the feature. Name the file after the flow, mat
| CIBA | `examples/ClientInitiatedBackChannelLogin.md` |
| MCD domain resolver | `examples/MultipleCustomDomains.md` |
| Account linking / unlinking | `examples/UserLinking.md` |
| Passwordless email/SMS OTP + magic link | `examples/Passwordless.md` |
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 @@ -67,7 +69,11 @@ def __init__(
state_store=None,
state_identifier: str = "_a0_session",
headers: Optional[dict[str, str]] = None,
apply_client_authentication: Optional[Callable] = None
session_establisher: Optional[
Callable[..., Awaitable[None]]
] = None,
mfa_token_ttl: int = DEFAULT_MFA_TOKEN_TTL,
apply_client_authentication: Optional[Callable] = None,
):
if callable(domain):
self._domain = None
Expand All @@ -81,6 +87,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
self._apply_client_authentication = apply_client_authentication

def _get_http_client(self, **kwargs) -> httpx.AsyncClient:
Expand Down Expand Up @@ -146,7 +156,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 @@ -638,6 +648,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
Loading