diff --git a/examples/MFA.md b/examples/MFA.md index a91ffe0..a2b9687 100644 --- a/examples/MFA.md +++ b/examples/MFA.md @@ -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( diff --git a/examples/Passwordless.md b/examples/Passwordless.md index b190e66..4a73c3f 100644 --- a/examples/Passwordless.md +++ b/examples/Passwordless.md @@ -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. @@ -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 @@ -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 @@ -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 diff --git a/src/auth0_server_python/auth_server/mfa_client.py b/src/auth0_server_python/auth_server/mfa_client.py index 18e198f..e0c1864 100644 --- a/src/auth0_server_python/auth_server/mfa_client.py +++ b/src/auth0_server_python/auth_server/mfa_client.py @@ -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 @@ -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 @@ -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.""" @@ -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" ) diff --git a/src/auth0_server_python/auth_server/passwordless_client.py b/src/auth0_server_python/auth_server/passwordless_client.py index d5ba80e..1232ff2 100644 --- a/src/auth0_server_python/auth_server/passwordless_client.py +++ b/src/auth0_server_python/auth_server/passwordless_client.py @@ -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, diff --git a/src/auth0_server_python/auth_server/server_client.py b/src/auth0_server_python/auth_server/server_client.py index 6580858..53a4a69 100644 --- a/src/auth0_server_python/auth_server/server_client.py +++ b/src/auth0_server_python/auth_server/server_client.py @@ -36,6 +36,7 @@ LogoutOptions, LogoutTokenClaims, MfaRequirements, + MfaVerifyResponse, PasskeyAuthResponse, PasskeyLoginChallengeResponse, PasskeyLoginResult, @@ -64,6 +65,7 @@ InvalidArgumentError, IssuerValidationError, MfaRequiredError, + MfaVerifyError, MissingRequiredArgumentError, MissingTransactionError, OrganizationTokenValidationError, @@ -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) @@ -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, diff --git a/src/auth0_server_python/tests/test_passwordless_client.py b/src/auth0_server_python/tests/test_passwordless_client.py index 5c2e34f..f59ba60 100644 --- a/src/auth0_server_python/tests/test_passwordless_client.py +++ b/src/auth0_server_python/tests/test_passwordless_client.py @@ -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() @@ -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