From 3453894ff72a03f8cd43af39a533bb6d898b7bf4 Mon Sep 17 00:00:00 2001 From: Sourav Basu Date: Fri, 17 Jul 2026 13:20:31 +0530 Subject: [PATCH 01/14] Changes for adding Passwordless support --- .../auth_server/passwordless_client.py | 394 ++++++++++++++++ .../auth_server/server_client.py | 133 ++++-- .../auth_types/__init__.py | 236 +++++++++- src/auth0_server_python/error/__init__.py | 106 ++++- .../tests/test_passwordless_client.py | 427 ++++++++++++++++++ .../tests/test_server_client.py | 116 +++++ 6 files changed, 1356 insertions(+), 56 deletions(-) create mode 100644 src/auth0_server_python/auth_server/passwordless_client.py create mode 100644 src/auth0_server_python/tests/test_passwordless_client.py diff --git a/src/auth0_server_python/auth_server/passwordless_client.py b/src/auth0_server_python/auth_server/passwordless_client.py new file mode 100644 index 0000000..c8d6d22 --- /dev/null +++ b/src/auth0_server_python/auth_server/passwordless_client.py @@ -0,0 +1,394 @@ +""" +Passwordless Client for auth0-server-python SDK. + +Implements embedded passwordless login (Legacy Passwordless connections) for a +confidential (Regular Web App) client: + +* Email OTP / SMS OTP — ``start()`` sends a code, ``verify()`` exchanges it for + tokens via the passwordless-OTP grant and establishes a server-side session. +* Magic link — ``start(send="link")`` emails a one-click link; completion is + handled by the standard callback (``ServerClient.complete_interactive_login``), + not by ``verify()``. + +Tokens never leave the server; the browser holds only the opaque session +reference (RWA / BFF posture). +""" + +from typing import TYPE_CHECKING, Any, Optional + +import jwt + +from auth0_server_python.auth_types import ( + PASSWORDLESS_ALLOWED_AUTH_PARAMS, + PASSWORDLESS_RESERVED_AUTH_PARAMS, + PasswordlessStartResult, + StartPasswordlessEmailOptions, + StartPasswordlessOptions, + StartPasswordlessSmsOptions, + TransactionData, + UserClaims, + VerifyPasswordlessOtpOptions, +) +from auth0_server_python.error import ( + InvalidArgumentError, + IssuerValidationError, + MissingRequiredArgumentError, + PasswordlessErrorCode, + PasswordlessStartError, + PasswordlessVerifyError, +) +from auth0_server_python.utils import PKCE +from auth0_server_python.utils.helpers import validate_org_claims + +if TYPE_CHECKING: # avoid a circular import at runtime + from auth0_server_python.auth_server.server_client import ServerClient + +PASSWORDLESS_OTP_GRANT_TYPE = "http://auth0.com/oauth/grant-type/passwordless/otp" +# Email flows request the `email` scope; SMS has no email claim to satisfy. +DEFAULT_PASSWORDLESS_EMAIL_SCOPE = "openid profile email" +DEFAULT_PASSWORDLESS_SMS_SCOPE = "openid profile" +# Header Auth0 reads for the real end-user IP (confidential clients with +# "Trust Token Endpoint IP Header" enabled). +FORWARDED_FOR_HEADER = "auth0-forwarded-for" + + +class PasswordlessClient: + """ + Client for Auth0 embedded passwordless operations. + + Composes the parent :class:`ServerClient` to reuse domain resolution, OIDC + discovery, JWKS/ID-token verification, and session persistence rather than + duplicating that security-critical logic. + """ + + def __init__(self, server_client: "ServerClient"): + self._client = server_client + + # ------------------------------------------------------------------ start + + async def start( + self, + options: StartPasswordlessOptions, + store_options: Optional[dict[str, Any]] = None, + ) -> PasswordlessStartResult: + """ + Start a passwordless flow by sending an OTP code or a magic link. + + Args: + options: ``StartPasswordlessEmailOptions`` or + ``StartPasswordlessSmsOptions``. + store_options: Options passed to the transaction store (e.g. + request/response) — required for the magic-link flow so the + transaction cookie can be written. + + Returns: + PasswordlessStartResult with Auth0's start response payload. + + Raises: + PasswordlessStartError: When ``POST /passwordless/start`` fails. + InvalidArgumentError: When caller ``auth_params`` attempts to + override an SDK-owned parameter. + MissingRequiredArgumentError: When a magic link is requested but no + ``redirect_uri`` is configured on the client. + """ + client = self._client + origin_domain = await client._resolve_current_domain(store_options) + + body: dict[str, Any] = { + "client_id": client._client_id, + "client_secret": client._client_secret, + "connection": options.connection, + } + + if isinstance(options, StartPasswordlessEmailOptions): + body["email"] = options.email + body["send"] = options.send + elif isinstance(options, StartPasswordlessSmsOptions): + body["phone_number"] = options.phone_number + else: + raise InvalidArgumentError( + "options", + "options must be StartPasswordlessEmailOptions or StartPasswordlessSmsOptions", + ) + + if options.captcha: + body["captcha"] = options.captcha + + is_magic_link = ( + isinstance(options, StartPasswordlessEmailOptions) and options.send == "link" + ) + + if is_magic_link: + body["authParams"] = await self._build_magic_link_auth_params( + options, origin_domain, store_options + ) + elif options.auth_params: + # OTP flows: forward safe passthrough params only. + body["authParams"] = self._sanitize_caller_auth_params(options.auth_params) + + headers = {"Content-Type": "application/json"} + if options.language: + headers["x-request-language"] = options.language + if options.client_ip: + headers[FORWARDED_FOR_HEADER] = options.client_ip + + base_url = client._normalize_url(origin_domain) + url = f"{base_url}/passwordless/start" + + try: + async with client._get_http_client() as http: + response = await http.post(url, json=body, headers=headers) + except Exception as e: + raise PasswordlessStartError( + PasswordlessErrorCode.START_FAILED, + f"Unexpected error during passwordless start: {str(e)}", + e, + ) + + if response.status_code not in (200, 201): + error_body = self._safe_json(response) + raise PasswordlessStartError( + error_body.get("error", PasswordlessErrorCode.START_FAILED), + error_body.get("error_description", "Failed to start passwordless flow"), + error_body, + ) + + return PasswordlessStartResult(**self._safe_json(response)) + + # ----------------------------------------------------------------- verify + + async def verify( + self, + options: VerifyPasswordlessOtpOptions, + store_options: Optional[dict[str, Any]] = None, + ) -> dict[str, Any]: + """ + Verify a passwordless OTP and establish a server-side session. + + Only for the OTP flows (email/SMS code). Magic link completes via the + standard callback handler, not here. + + Args: + options: VerifyPasswordlessOtpOptions. + store_options: Options passed to the state store (e.g. + request/response) so the session can be written. + + Returns: + Dict containing ``state_data`` for the established session. + + Raises: + PasswordlessVerifyError: When token exchange or ID-token + verification fails. + """ + client = self._client + origin_domain = await client._resolve_current_domain(store_options) + + try: + metadata = await client._get_oidc_metadata_cached(origin_domain) + except Exception as e: + raise PasswordlessVerifyError( + PasswordlessErrorCode.DISCOVERY_ERROR, + "Failed to fetch authorization server metadata", + e, + ) + + token_endpoint = metadata["token_endpoint"] + origin_issuer = metadata.get("issuer") + + default_scope = ( + DEFAULT_PASSWORDLESS_EMAIL_SCOPE + if options.connection == "email" + else DEFAULT_PASSWORDLESS_SMS_SCOPE + ) + body: dict[str, Any] = { + "grant_type": PASSWORDLESS_OTP_GRANT_TYPE, + "client_id": client._client_id, + "client_secret": client._client_secret, + "realm": options.connection, + "username": options.username, + "otp": options.verification_code, + "scope": options.scope or default_scope, + } + if options.audience: + body["audience"] = options.audience + + headers = {"Content-Type": "application/x-www-form-urlencoded"} + if options.client_ip: + headers[FORWARDED_FOR_HEADER] = options.client_ip + + try: + async with client._get_http_client() as http: + response = await http.post( + token_endpoint, + data=body, + headers=headers, + ) + except Exception as e: + raise PasswordlessVerifyError( + PasswordlessErrorCode.VERIFY_FAILED, + f"Unexpected error during passwordless verify: {str(e)}", + e, + ) + + if response.status_code != 200: + error_body = self._safe_json(response) + raise PasswordlessVerifyError( + error_body.get("error", PasswordlessErrorCode.INVALID_GRANT), + error_body.get("error_description", "Passwordless verification failed"), + error_body, + ) + + token_response = response.json() + + user_claims, id_token_claims = await self._verify_id_token( + token_response, origin_domain, origin_issuer, metadata, options.organization + ) + + state_data = await client._persist_session_from_token_response( + token_response=token_response, + user_claims=user_claims, + origin_domain=origin_domain, + audience=options.audience, + session_expires_at=user_claims.session_expiry, + issued_at=id_token_claims.get("iat"), + id_token_claims=id_token_claims, + store_options=store_options, + ) + + return {"state_data": state_data.model_dump()} + + # ------------------------------------------------------------- internals + + async def _build_magic_link_auth_params( + self, + options: StartPasswordlessEmailOptions, + origin_domain: str, + store_options: Optional[dict[str, Any]], + ) -> dict[str, Any]: + """ + Build the magic-link ``authParams`` and persist the transaction. + + The SDK owns ``redirect_uri`` / ``response_type`` / ``state``; caller + ``auth_params`` may only contribute non-reserved passthrough keys. + """ + client = self._client + + redirect_uri = client._redirect_uri + if not redirect_uri: + raise MissingRequiredArgumentError("redirect_uri") + + auth_params = self._sanitize_caller_auth_params(options.auth_params) + + state = PKCE.generate_random_string(32) + auth_params["redirect_uri"] = redirect_uri + auth_params["response_type"] = "code" + auth_params["state"] = state + # Magic link is email-only, so the email scope is always appropriate. + auth_params.setdefault("scope", DEFAULT_PASSWORDLESS_EMAIL_SCOPE) + if options.organization: + auth_params["organization"] = options.organization + + # Magic link uses a plain authorization-code exchange (no PKCE), so the + # transaction stores no code_verifier. Single-use is enforced by + # transaction deletion on the callback; remove_if_expires signals the + # store to drop the transaction once expired. Its effective lifetime is + # the store's configured duration, not a fixed value set here. + transaction_data = TransactionData( + code_verifier=None, + audience=auth_params.get("audience"), + redirect_uri=redirect_uri, + domain=origin_domain, + organization=options.organization, + ) + await client._transaction_store.set( + f"{client._transaction_identifier}:{state}", + transaction_data, + remove_if_expires=True, + options=store_options, + ) + + return auth_params + + def _sanitize_caller_auth_params(self, auth_params: Optional[dict[str, Any]]) -> dict[str, Any]: + """ + Copy caller-supplied auth params, forwarding only allowlisted keys. + + Enforced as an allowlist (Global §3): a key outside + ``PASSWORDLESS_ALLOWED_AUTH_PARAMS`` is rejected. SDK-owned keys get a + precise "set by the SDK" message; anything else is reported as + unsupported so a new authorize param cannot pass through silently. + + Raises: + InvalidArgumentError: When a reserved or unrecognized param is present. + """ + if not auth_params: + return {} + for key in auth_params: + if key in PASSWORDLESS_RESERVED_AUTH_PARAMS: + raise InvalidArgumentError( + "auth_params", + f"'{key}' is set by the SDK and cannot be overridden", + ) + if key not in PASSWORDLESS_ALLOWED_AUTH_PARAMS: + raise InvalidArgumentError( + "auth_params", + f"'{key}' is not an allowed passthrough auth parameter", + ) + return dict(auth_params) + + async def _verify_id_token( + self, + token_response: dict[str, Any], + origin_domain: str, + origin_issuer: Optional[str], + metadata: dict[str, Any], + expected_org: Optional[str], + ) -> tuple[UserClaims, dict[str, Any]]: + """Verify the ID token from the OTP exchange and return its claims.""" + client = self._client + id_token = token_response.get("id_token") + if not id_token: + raise PasswordlessVerifyError( + PasswordlessErrorCode.VERIFY_FAILED, + "Token response did not include an ID token; ensure 'openid' scope is requested", + ) + + jwks = await client._get_jwks_cached(origin_domain, metadata) + + try: + claims = await client._verify_and_decode_jwt(id_token, jwks, audience=client._client_id) + except ValueError as e: + raise PasswordlessVerifyError(PasswordlessErrorCode.VERIFY_FAILED, str(e), e) + except jwt.InvalidAudienceError as e: + raise PasswordlessVerifyError( + PasswordlessErrorCode.INVALID_AUDIENCE, + "ID token audience mismatch. Ensure your client_id is configured correctly.", + e, + ) + except jwt.InvalidTokenError as e: + # Covers expired signature, bad signature, and other token defects. + raise PasswordlessVerifyError( + PasswordlessErrorCode.VERIFY_FAILED, + f"ID token verification failed: {str(e)}", + e, + ) + + token_issuer = claims.get("iss", "") + if client._normalize_url(token_issuer) != client._normalize_url(origin_issuer): + raise IssuerValidationError( + "ID token issuer mismatch. Ensure your Auth0 domain is configured correctly." + ) + + if expected_org: + validate_org_claims(claims, expected_org) + + return UserClaims.model_validate(claims), claims + + @staticmethod + def _safe_json(response) -> dict[str, Any]: + """Parse a response body as JSON, returning {} on failure.""" + try: + data = response.json() + return data if isinstance(data, dict) else {} + except Exception: + return {} diff --git a/src/auth0_server_python/auth_server/server_client.py b/src/auth0_server_python/auth_server/server_client.py index befbcb6..19016a1 100644 --- a/src/auth0_server_python/auth_server/server_client.py +++ b/src/auth0_server_python/auth_server/server_client.py @@ -18,6 +18,7 @@ from auth0_server_python.auth_server.mfa_client import MfaClient from auth0_server_python.auth_server.my_account_client import MyAccountClient +from auth0_server_python.auth_server.passwordless_client import PasswordlessClient from auth0_server_python.auth_types import ( CompleteConnectAccountRequest, CompleteConnectAccountResponse, @@ -192,6 +193,9 @@ def __init__( headers=self._telemetry_headers, ) + # Initialize Passwordless client (composes this client) + self._passwordless_client = PasswordlessClient(self) + def _get_http_client(self, **kwargs) -> httpx.AsyncClient: """Return an httpx.AsyncClient with telemetry headers injected.""" headers = {**kwargs.pop("headers", {}), **self._telemetry_headers} @@ -588,6 +592,71 @@ async def start_interactive_login( return auth_url + async def _persist_session_from_token_response( + self, + token_response: dict[str, Any], + user_claims: "UserClaims", + origin_domain: str, + audience: Optional[str], + session_expires_at: Optional[int], + issued_at: Optional[int], + id_token_claims: Optional[dict[str, Any]] = None, + user_info: Optional[dict[str, Any]] = None, + store_options: Optional[dict[str, Any]] = None, + ) -> StateData: + """ + Build and persist a session ``StateData`` from a token endpoint response. + + Shared by the interactive-login callback and the passwordless OTP verify + flow so both derive the session id, enforce the IPSIE ceiling, and write + the state store identically. + + The session ``sid`` is taken from the verified ID token claims (falling + back to userinfo, then a random value) so OIDC back-channel logout — which + matches sessions by ``sid`` — can target sessions created here. + + Raises: + SessionExpiredError: If the session ceiling is already in the past. + """ + # Refuse to persist a session whose ceiling is already in the past. + if State.is_session_ceiling_in_past(session_expires_at, issued_at): + raise SessionExpiredError() + + token_set = TokenSet( + audience=audience or self.DEFAULT_AUDIENCE_STATE_KEY, + access_token=token_response.get("access_token", ""), + scope=token_response.get("scope", ""), + expires_at=int(time.time()) + token_response.get("expires_in", 3600), + ) + + # Prefer the ID token's `sid` claim, then userinfo, then a random value. + # A random sid would make the session untargetable by back-channel logout. + sid = None + if id_token_claims and id_token_claims.get("sid"): + sid = id_token_claims["sid"] + elif user_info and user_info.get("sid"): + sid = user_info["sid"] + if not sid: + sid = PKCE.generate_random_string(32) + + state_data = StateData( + user=user_claims, + id_token=token_response.get("id_token"), + refresh_token=token_response.get("refresh_token"), + token_sets=[token_set], + domain=origin_domain, + internal={ + "sid": sid, + "created_at": int(time.time()), + "session_expires_at": session_expires_at, + }, + ) + + await self._state_store.set( + self._state_identifier, state_data, options=store_options + ) + return state_data + async def complete_interactive_login( self, url: str, @@ -662,6 +731,9 @@ async def complete_interactive_login( # ID token `iat`, used to detect a ceiling that is already past at login. issued_at = None id_token = token_response.get("id_token") + # Verified ID token claims, retained so the session `sid` can be sourced + # from them (back-channel logout matches on `sid`). + id_token_claims = None expected_org = transaction_data.organization @@ -704,6 +776,7 @@ async def complete_interactive_login( validate_org_claims(claims, expected_org) user_claims = UserClaims.parse_obj(claims) + id_token_claims = claims session_expires_at = user_claims.session_expiry issued_at = claims.get("iat") except ValueError as e: @@ -734,41 +807,24 @@ async def complete_interactive_login( ) - # Refuse to persist a session whose ceiling is already in the past. - if State.is_session_ceiling_in_past(session_expires_at, issued_at): + # Build + persist the session via the shared helper (enforces the IPSIE + # ceiling and sources `sid` from the ID token claims). On a past ceiling, + # clean up the transaction before surfacing the error. + try: + state_data = await self._persist_session_from_token_response( + token_response=token_response, + user_claims=user_claims, + origin_domain=origin_domain, + audience=transaction_data.audience, + session_expires_at=session_expires_at, + issued_at=issued_at, + id_token_claims=id_token_claims, + user_info=user_info if isinstance(user_info, dict) else None, + store_options=store_options, + ) + except SessionExpiredError: await self._transaction_store.delete(transaction_identifier, options=store_options) - raise SessionExpiredError() - - # Build a token set using the token response data - token_set = TokenSet( - audience=transaction_data.audience or self.DEFAULT_AUDIENCE_STATE_KEY, - access_token=token_response.get("access_token", ""), - scope=token_response.get("scope", ""), - expires_at=int(time.time()) + - token_response.get("expires_in", 3600) - ) - - # Generate a session id (sid) from token_response or transaction data, or create a new one - sid = user_info.get( - "sid") if user_info and "sid" in user_info else PKCE.generate_random_string(32) - - # Construct state data to represent the session - state_data = StateData( - user=user_claims, - id_token=token_response.get("id_token"), - # might be None if not provided - refresh_token=token_response.get("refresh_token"), - token_sets=[token_set], - domain=origin_domain, - internal={ - "sid": sid, - "created_at": int(time.time()), - "session_expires_at": session_expires_at - } - ) - - # Store the state data in the state store using store_options (Response required) - await self._state_store.set(self._state_identifier, state_data, options=store_options) + raise # Clean up transaction data after successful login await self._transaction_store.delete(transaction_identifier, options=store_options) @@ -2627,3 +2683,12 @@ async def login_with_custom_token_exchange( def mfa(self) -> MfaClient: """Access the MFA client for multi-factor authentication operations.""" return self._mfa_client + + # ============================================================================ + # Passwordless (embedded login) + # ============================================================================ + + @property + def passwordless(self) -> PasswordlessClient: + """Access the passwordless client for embedded passwordless operations.""" + return self._passwordless_client diff --git a/src/auth0_server_python/auth_types/__init__.py b/src/auth0_server_python/auth_types/__init__.py index 1c6ad03..0cd9546 100644 --- a/src/auth0_server_python/auth_types/__init__.py +++ b/src/auth0_server_python/auth_types/__init__.py @@ -3,9 +3,10 @@ These Pydantic models provide type safety and validation for all SDK data structures. """ +import re from typing import Any, Literal, Optional, Union -from pydantic import BaseModel, Field, field_validator +from pydantic import BaseModel, Field, field_validator, model_validator # Upper bound (Unix seconds) for a plausible session_expiry SESSION_EXPIRY_MAX_PLAUSIBLE = 10_000_000_000 @@ -16,6 +17,7 @@ class UserClaims(BaseModel): User profile information as returned by Auth0. Contains standard OIDC claims about the authenticated user. """ + sub: str name: Optional[str] = None nickname: Optional[str] = None @@ -32,7 +34,7 @@ class UserClaims(BaseModel): class Config: extra = "allow" # Allow additional fields not defined in the model - @field_validator('session_expiry', mode='before') + @field_validator("session_expiry", mode="before") @classmethod def _sanitize_session_expiry(cls, value: Any) -> Optional[int]: if isinstance(value, bool) or not isinstance(value, int): @@ -47,6 +49,7 @@ class TokenSet(BaseModel): Represents a set of tokens issued by Auth0. Contains the access token and related metadata. """ + audience: str access_token: str scope: Optional[str] = None @@ -58,6 +61,7 @@ class ConnectionTokenSet(TokenSet): Token set specific to a connection. Extends TokenSet with connection-specific information. """ + connection: str login_hint: str @@ -67,6 +71,7 @@ class InternalStateData(BaseModel): Internal data used for managing state. Not meant to be accessed directly by SDK users. """ + sid: str created_at: int # IPSIE session_expiry ceiling (Unix seconds), stamped at session creation @@ -80,6 +85,7 @@ class SessionData(BaseModel): Represents a user session with Auth0. Contains user information and tokens. """ + user: Optional[UserClaims] = None id_token: Optional[str] = None refresh_token: Optional[str] = None @@ -96,6 +102,7 @@ class StateData(SessionData): Complete state data stored in the state store. Extends SessionData with internal management information. """ + internal: InternalStateData @@ -104,8 +111,11 @@ class TransactionData(BaseModel): Represents data for an in-progress authentication transaction. Used during the authorization code flow to correlate requests. """ + audience: Optional[str] = None - code_verifier: str + # Optional: interactive login sets this for PKCE; the passwordless magic-link + # transaction has no verifier (plain auth-code exchange), so it stays None. + code_verifier: Optional[str] = None app_state: Optional[Any] = None auth_session: Optional[str] = None redirect_uri: Optional[str] = None @@ -121,6 +131,7 @@ class LogoutTokenClaims(BaseModel): Claims expected in a logout token. Used for backchannel logout processing. """ + sub: str sid: str iss: Optional[str] = None @@ -131,6 +142,7 @@ class EncryptedStoreOptions(BaseModel): Options for encrypted stores. Contains the secret used for encryption. """ + secret: str @@ -139,6 +151,7 @@ class ServerClientOptionsBase(BaseModel): Base options for configuring the Auth0 server client. Contains core settings required for all clients. """ + domain: str client_id: str client_secret: str @@ -156,6 +169,7 @@ class ServerClientOptionsWithSecret(ServerClientOptionsBase): Client options using a secret for encryption. Extends base options with secret and duration settings. """ + secret: str state_absolute_duration: Optional[int] = 259200 # 3 days in seconds @@ -165,6 +179,7 @@ class StartInteractiveLoginOptions(BaseModel): Options for starting the interactive login process. Configures how the authorization request is constructed. """ + pushed_authorization_requests: Optional[bool] = False app_state: Optional[Any] = None authorization_params: Optional[dict[str, Any]] = None @@ -177,6 +192,7 @@ class LogoutOptions(BaseModel): Options for logout operations. Configures how the logout request is constructed. """ + return_to: Optional[str] = None @@ -185,6 +201,7 @@ class AuthorizationParameters(BaseModel): Parameters used in authorization requests. Based on standard OAuth2/OIDC parameters. """ + scope: Optional[str] = None audience: Optional[str] = None redirect_uri: Optional[str] = None @@ -192,11 +209,13 @@ class AuthorizationParameters(BaseModel): class Config: extra = "allow" # Allow additional OAuth parameters + class AuthorizationDetails(BaseModel): """ Authorization details returned from Auth0. Used for Resource Access Rights (RAR). """ + type: str actions: Optional[list[str]] = None locations: Optional[list[str]] = None @@ -211,6 +230,7 @@ class LoginBackchannelOptions(BaseModel): """ Options for Client-Initiated Backchannel Authentication. """ + binding_message: str login_hint: dict[str, str] # Should contain a 'sub' field authorization_params: Optional[dict[str, Any]] = None @@ -223,6 +243,7 @@ class LoginBackchannelResult(BaseModel): """ Result from Client-Initiated Backchannel Authentication. """ + authorization_details: Optional[list[AuthorizationDetails]] = None @@ -230,19 +251,23 @@ class AccessTokenForConnectionOptions(BaseModel): """ Options for retrieving an access token for a specific connection. """ + connection: str login_hint: Optional[str] = None + class StartLinkUserOptions(BaseModel): connection: str connection_scope: Optional[str] = None authorization_params: Optional[dict[str, Any]] = None app_state: Optional[Any] = None + # ============================================================================= # Multiple Custom Domain # ============================================================================= + class DomainResolverContext(BaseModel): """ Context passed to domain resolver function for MCD support. @@ -259,13 +284,16 @@ async def domain_resolver(context: DomainResolverContext) -> str: host = context.request_headers.get('host', '').split(':')[0] return DOMAIN_MAP.get(host, DEFAULT_DOMAIN) """ + request_url: Optional[str] = None request_headers: Optional[dict[str, str]] = None + # ============================================================================= # Custom Token Exchange Types # ============================================================================= + class CustomTokenExchangeOptions(BaseModel): """ Options for custom token exchange (RFC 8693). @@ -280,6 +308,7 @@ class CustomTokenExchangeOptions(BaseModel): organization: Organization identifier for the token exchange (optional) authorization_params: Additional OAuth parameters (optional) """ + subject_token: str subject_token_type: str audience: Optional[str] = None @@ -304,6 +333,7 @@ class TokenExchangeResponse(BaseModel): refresh_token: Refresh token (optional) act: Actor claim for delegation/impersonation exchanges (optional) """ + access_token: str token_type: str = "Bearer" expires_in: int @@ -320,6 +350,7 @@ class LoginWithCustomTokenExchangeOptions(BaseModel): Combines token exchange parameters with session management. """ + subject_token: str subject_token_type: str audience: Optional[str] = None @@ -336,13 +367,16 @@ class LoginWithCustomTokenExchangeResult(BaseModel): Contains session data established after token exchange. """ + state_data: dict[str, Any] authorization_details: Optional[list[AuthorizationDetails]] = None + # ============================================================================= # Connected Accounts Types # ============================================================================= + # BASE & SHARED class ConnectedAccountBase(BaseModel): id: str @@ -352,6 +386,7 @@ class ConnectedAccountBase(BaseModel): created_at: str expires_at: Optional[str] = None + # ENTITIES (What exists) class ConnectedAccount(ConnectedAccountBase): id: str @@ -370,6 +405,7 @@ class ConnectedAccountConnection(BaseModel): # Connect Operations (How to connect) + class ConnectAccountOptions(BaseModel): connection: str redirect_uri: Optional[str] = None @@ -377,38 +413,45 @@ class ConnectAccountOptions(BaseModel): app_state: Optional[Any] = None authorization_params: Optional[dict[str, Any]] = None + class ConnectAccountRequest(BaseModel): connection: str scopes: Optional[list[str]] = None redirect_uri: Optional[str] = None state: Optional[str] = None code_challenge: Optional[str] = None - code_challenge_method: Optional[str] = 'S256' + code_challenge_method: Optional[str] = "S256" authorization_params: Optional[dict[str, Any]] = None + class ConnectParams(BaseModel): ticket: str + class ConnectAccountResponse(BaseModel): auth_session: str connect_uri: str connect_params: ConnectParams expires_in: int + class CompleteConnectAccountRequest(BaseModel): auth_session: str connect_code: str redirect_uri: str code_verifier: Optional[str] = None + class CompleteConnectAccountResponse(ConnectedAccountBase): app_state: Optional[Any] = None + # Manage operations class ListConnectedAccountsResponse(BaseModel): accounts: list[ConnectedAccount] next: Optional[str] = None + class ListConnectedAccountConnectionsResponse(BaseModel): connections: list[ConnectedAccountConnection] next: Optional[str] = None @@ -426,6 +469,7 @@ class ListConnectedAccountConnectionsResponse(BaseModel): class AuthenticatorResponse(BaseModel): """Represents an MFA authenticator enrolled by a user.""" + id: str authenticator_type: AuthenticatorType active: bool @@ -439,14 +483,17 @@ class AuthenticatorResponse(BaseModel): # Enrollment Options + class EnrollOtpOptions(BaseModel): """Options for enrolling an OTP authenticator.""" + authenticator_types: list[str] mfa_token: str class EnrollOobOptions(BaseModel): """Options for enrolling an OOB authenticator (SMS, Voice, Push).""" + authenticator_types: list[str] oob_channels: list[OobChannel] phone_number: Optional[str] = None @@ -455,6 +502,7 @@ class EnrollOobOptions(BaseModel): class EnrollEmailOptions(BaseModel): """Options for enrolling an email authenticator.""" + authenticator_types: list[str] oob_channels: list[OobChannel] email: Optional[str] = None @@ -466,8 +514,10 @@ class EnrollEmailOptions(BaseModel): # Enrollment Responses + class OtpEnrollmentResponse(BaseModel): """Response when enrolling an OTP authenticator.""" + authenticator_type: Literal["otp"] secret: str barcode_uri: str @@ -477,6 +527,7 @@ class OtpEnrollmentResponse(BaseModel): class OobEnrollmentResponse(BaseModel): """Response when enrolling an OOB authenticator.""" + authenticator_type: Literal["oob"] oob_channel: OobChannel oob_code: Optional[str] = None @@ -491,8 +542,10 @@ class OobEnrollmentResponse(BaseModel): # Challenge Types + class ChallengeOptions(BaseModel): """Options for initiating an MFA challenge.""" + challenge_type: ChallengeType authenticator_id: Optional[str] = None mfa_token: str @@ -500,6 +553,7 @@ class ChallengeOptions(BaseModel): class ChallengeResponse(BaseModel): """Response from initiating an MFA challenge.""" + challenge_type: ChallengeType oob_code: Optional[str] = None binding_method: Optional[str] = None @@ -508,21 +562,26 @@ class ChallengeResponse(BaseModel): # List Options + class ListAuthenticatorsOptions(BaseModel): """Options for listing MFA authenticators.""" + mfa_token: str # Verify Types + class VerifyOtpOptions(BaseModel): """Verify with OTP code.""" + mfa_token: str otp: str class VerifyOobOptions(BaseModel): """Verify with OOB code + binding code.""" + mfa_token: str oob_code: str binding_code: str @@ -530,6 +589,7 @@ class VerifyOobOptions(BaseModel): class VerifyRecoveryCodeOptions(BaseModel): """Verify with recovery code.""" + mfa_token: str recovery_code: str @@ -539,6 +599,7 @@ class VerifyRecoveryCodeOptions(BaseModel): class MfaVerifyResponse(BaseModel): """Response from MFA verification.""" + access_token: str token_type: str = "Bearer" expires_in: int @@ -551,24 +612,191 @@ class MfaVerifyResponse(BaseModel): # MFA Requirements + class MfaRequirement(BaseModel): """A single MFA requirement entry.""" + type: str class MfaRequirements(BaseModel): """MFA requirements from an mfa_required error response.""" + enroll: Optional[list[MfaRequirement]] = None challenge: Optional[list[MfaRequirement]] = None # MFA Token Context (for encrypted storage) + class MfaTokenContext(BaseModel): """Internal context stored inside encrypted mfa_token.""" + mfa_token: str audience: str scope: str mfa_requirements: Optional[MfaRequirements] = None created_at: int + +# ============================================================================= +# Passwordless Types +# ============================================================================= + +# Passwordless connection strategies (Legacy Passwordless connections). +PasswordlessConnection = Literal["email", "sms"] + +# authParams keys the SDK owns and MUST NOT let a caller override for the +# magic-link flow. A caller-controlled redirect_uri/state would allow the +# emailed code+state to be redirected to an attacker (authorization-code +# interception); the PKCE/nonce/response_type keys are protocol-controlled. +# Mirrors nextjs-auth0's MAGIC_LINK_EXCLUDED_PARAMS / INTERNAL_AUTHORIZE_PARAMS. +# Kept explicit so a rejected override gets a precise "set by the SDK" message. +PASSWORDLESS_RESERVED_AUTH_PARAMS = frozenset( + { + "client_id", + "client_secret", + "redirect_uri", + "response_type", + "state", + "nonce", + "code_challenge", + "code_challenge_method", + } +) + +# Caller-supplied authParams keys the SDK will forward. This is an allowlist +# (Global §3: allowlists, not denylists) — any key outside it is rejected, so a +# future security-relevant authorize parameter cannot pass through silently on +# an SDK upgrade. Extend deliberately as new safe passthrough params are needed. +PASSWORDLESS_ALLOWED_AUTH_PARAMS = frozenset( + { + "audience", + "login_hint", + "ui_locales", + "screen_hint", + "prompt", + "max_age", + "acr_values", + "connection_scope", + } +) + +# Minimal BCP 47 language tag: primary subtag plus optional subtags. +_BCP47_LANGUAGE_RE = r"^[A-Za-z]{2,3}(-[A-Za-z0-9]{1,8})*$" + + +class _StartPasswordlessBase(BaseModel): + """Shared options for starting a passwordless flow.""" + + # BCP 47 tag (e.g. "fr", "en-US"). Forwarded as x-request-language to + # localise the email/SMS template. + language: Optional[str] = None + # Extra params forwarded to /passwordless/start. SDK-owned keys + # (PASSWORDLESS_RESERVED_AUTH_PARAMS) are stripped in the client. + auth_params: Optional[dict[str, Any]] = None + # Organization id or name; validated against ID token claims on verify. + organization: Optional[str] = None + # Attempted solution to a captcha challenge, when the tenant requires one. + captcha: Optional[str] = None + # End-user client IP, relayed to Auth0 as `auth0-forwarded-for` so brute-force + # and suspicious-IP protection key on the real user, not the app server. Only + # honored for confidential clients with "Trust Token Endpoint IP Header" on. + client_ip: Optional[str] = None + + @field_validator("language") + @classmethod + def _validate_language(cls, value: Optional[str]) -> Optional[str]: + if value is None: + return None + if not re.match(_BCP47_LANGUAGE_RE, value): + raise ValueError("language must be a valid BCP 47 tag (e.g. 'fr', 'en-US')") + return value + + +class StartPasswordlessEmailOptions(_StartPasswordlessBase): + """Options for starting an email passwordless flow (OTP code or magic link).""" + + connection: Literal["email"] = "email" + email: str + # "code" -> email OTP; "link" -> magic link. + send: Literal["code", "link"] = "code" + + +class StartPasswordlessSmsOptions(_StartPasswordlessBase): + """Options for starting an SMS passwordless (OTP) flow.""" + + connection: Literal["sms"] = "sms" + # E.164 format, e.g. "+14155550100". + phone_number: str + + @field_validator("phone_number") + @classmethod + def _validate_phone_number(cls, value: str) -> str: + if not re.match(r"^\+[1-9]\d{1,14}$", value): + raise ValueError("phone_number must be in E.164 format (e.g. '+14155550100')") + return value + + +StartPasswordlessOptions = Union[StartPasswordlessEmailOptions, StartPasswordlessSmsOptions] + + +class VerifyPasswordlessOtpOptions(BaseModel): + """ + Options for verifying a passwordless OTP and establishing a session. + + Exactly one of ``email`` / ``phone_number`` must be provided and must + match ``connection`` (email -> email, sms -> phone_number). + """ + + connection: PasswordlessConnection + # Public field name mirrors nextjs-auth0's `verificationCode`; sent to + # Auth0 as the `otp` form parameter. + verification_code: str + email: Optional[str] = None + phone_number: Optional[str] = None + scope: Optional[str] = None + audience: Optional[str] = None + organization: Optional[str] = None + # End-user client IP, relayed to Auth0 as `auth0-forwarded-for` on the OTP + # token exchange so brute-force protection keys on the real user, not the + # app server. Honored only for confidential clients with "Trust Token + # Endpoint IP Header" enabled. + client_ip: Optional[str] = None + + @field_validator("phone_number") + @classmethod + def _validate_phone_number(cls, value: Optional[str]) -> Optional[str]: + if value is None: + return None + if not re.match(r"^\+[1-9]\d{1,14}$", value): + raise ValueError("phone_number must be in E.164 format (e.g. '+14155550100')") + return value + + @model_validator(mode="after") + def _validate_identifier(self) -> "VerifyPasswordlessOtpOptions": + if self.connection == "email": + if not self.email: + raise ValueError("email is required when connection='email'") + if self.phone_number: + raise ValueError("phone_number must not be set when connection='email'") + else: # sms + if not self.phone_number: + raise ValueError("phone_number is required when connection='sms'") + if self.email: + raise ValueError("email must not be set when connection='sms'") + return self + + @property + def username(self) -> str: + """The Auth0 `username` value for the OTP grant (email or phone).""" + return self.email if self.connection == "email" else self.phone_number + + +class PasswordlessStartResult(BaseModel): + """Success payload from POST /passwordless/start.""" + + id: Optional[str] = None + + class Config: + extra = "allow" # Allow additional fields returned by Auth0 diff --git a/src/auth0_server_python/error/__init__.py b/src/auth0_server_python/error/__init__.py index 3a6d01a..087493d 100644 --- a/src/auth0_server_python/error/__init__.py +++ b/src/auth0_server_python/error/__init__.py @@ -2,6 +2,7 @@ Error classes for the auth0-server-python SDK. These exceptions provide specific error types for different failure scenarios. """ + from typing import Any, Optional @@ -19,6 +20,7 @@ class MissingTransactionError(Auth0Error): This typically happens during the callback phase when the transaction from the initial authorization request cannot be found. """ + code = "missing_transaction_error" def __init__(self, message=None): @@ -56,6 +58,7 @@ def __init__(self, code: str, message: str, interval: Optional[int], cause=None) super().__init__(code, message, cause) self.interval = interval + class MyAccountApiError(Auth0Error): """ Error raised when an API request to My Account API fails. @@ -63,13 +66,13 @@ class MyAccountApiError(Auth0Error): """ def __init__( - self, - title: Optional[str], - type: Optional[str], - detail: Optional[str], - status: Optional[int], - validation_errors: Optional[list[dict[str, str]]] = None - ): + self, + title: Optional[str], + type: Optional[str], + detail: Optional[str], + status: Optional[int], + validation_errors: Optional[list[dict[str, str]]] = None, + ): super().__init__(detail) self.title = title self.type = type @@ -77,6 +80,7 @@ def __init__( self.status = status self.validation_errors = validation_errors + class AccessTokenError(Auth0Error): """Error raised when there's an issue with access tokens.""" @@ -92,6 +96,7 @@ class MissingRequiredArgumentError(Auth0Error): Error raised when a required argument is missing. Includes the name of the missing argument in the error message. """ + code = "missing_required_argument_error" def __init__(self, argument: str): @@ -106,16 +111,19 @@ class ConfigurationError(Auth0Error): Error raised when SDK configuration is invalid. This includes invalid combinations of parameters or incorrect configuration values. """ + code = "configuration_error" def __init__(self, message: str): super().__init__(message) self.name = "ConfigurationError" + class InvalidArgumentError(Auth0Error): """ Error raised when a given argument is an invalid value. """ + code = "invalid_argument" def __init__(self, argument: str, message: str): @@ -130,6 +138,7 @@ class IssuerValidationError(Auth0Error): This can happen when the issuer claim in a token does not match the expected issuer for the configured domain. """ + code = "issuer_validation_error" def __init__(self, message: str): @@ -142,6 +151,7 @@ class BackchannelLogoutError(Auth0Error): Error raised during backchannel logout processing. This can happen when validating or processing logout tokens. """ + code = "backchannel_logout_error" def __init__(self, message: str): @@ -156,6 +166,7 @@ class DomainResolverError(Auth0Error): This error indicates an issue with the custom domain resolver function provided for MCD (Multiple Custom Domains) support. """ + code = "domain_resolver_error" def __init__(self, message: str, original_error: Exception = None): @@ -172,12 +183,14 @@ def __init__(self, code: str, message: str): self.code = code self.name = "AccessTokenForConnectionError" + class StartLinkUserError(Auth0Error): """ Error raised when user linking process fails to start. This typically happens when trying to link accounts without having an authenticated user first. """ + code = "start_link_user_error" def __init__(self, message: str): @@ -187,8 +200,10 @@ def __init__(self, message: str): # Error code enumerations - these can be used to identify specific error scenarios + class AccessTokenErrorCode: """Error codes for access token operations.""" + MISSING_SESSION = "missing_session" MISSING_REFRESH_TOKEN = "missing_refresh_token" FAILED_TO_REFRESH_TOKEN = "failed_to_refresh_token" @@ -206,6 +221,7 @@ class OrganizationTokenValidationError(Auth0Error): Raised when org_id or org_name claim in the ID token fails validation against the organization value that was requested at login. """ + code = "organization_token_validation_error" def __init__(self, message: str): @@ -215,6 +231,7 @@ def __init__(self, message: str): class AccessTokenForConnectionErrorCode: """Error codes for connection-specific token operations.""" + MISSING_REFRESH_TOKEN = "missing_refresh_token" FAILED_TO_RETRIEVE = "failed_to_retrieve" API_ERROR = "api_error" @@ -228,6 +245,7 @@ class SessionExpiredError(Auth0Error): Error raised when a session is rejected at login because its session_expiry ceiling is already in the past. """ + code = AccessTokenErrorCode.SESSION_EXPIRED def __init__(self, message: Optional[str] = None, cause=None): @@ -240,6 +258,7 @@ class CustomTokenExchangeError(Auth0Error): """ Error raised during custom token exchange operations. """ + def __init__(self, code: str, message: str, cause=None): super().__init__(message) self.code = code @@ -249,6 +268,7 @@ def __init__(self, code: str, message: str, cause=None): class CustomTokenExchangeErrorCode: """Error codes for custom token exchange operations.""" + INVALID_TOKEN_FORMAT = "invalid_token_format" MISSING_ACTOR_TOKEN_TYPE = "missing_actor_token_type" MISSING_ACTOR_TOKEN = "missing_actor_token" @@ -260,15 +280,11 @@ class CustomTokenExchangeErrorCode: # MFA Error Classes # ============================================================================= + class MfaApiError(Auth0Error): """Base class for MFA API errors.""" - def __init__( - self, - code: str, - message: str, - cause: Optional[dict[str, Any]] = None - ): + def __init__(self, code: str, message: str, cause: Optional[dict[str, Any]] = None): super().__init__(message) self.code = code self.cause = cause @@ -312,11 +328,7 @@ class MfaRequiredError(AccessTokenError): """ def __init__( - self, - message: str, - mfa_token: str, - mfa_requirements=None, - cause: Optional[Exception] = None + self, message: str, mfa_token: str, mfa_requirements=None, cause: Optional[Exception] = None ): super().__init__("mfa_required", message, cause) self.mfa_token = mfa_token @@ -337,3 +349,61 @@ class MfaTokenInvalidError(Auth0Error): def __init__(self): super().__init__("The MFA token is invalid.") self.code = "mfa_token_invalid" + + +# ============================================================================= +# Passwordless Error Classes +# ============================================================================= + + +class PasswordlessError(ApiError): + """ + Base class for passwordless (embedded login) errors. + + Carries the Auth0 ``error`` / ``error_description`` from the API response + body so integrators can branch on a typed exception rather than parsing + strings. + """ + + def __init__(self, code: str, message: str, cause=None): + super().__init__(code, message, cause) + self.name = "PasswordlessError" + # When cause is the raw error dict from Auth0, surface its fields even + # though ApiError only reads attributes off exception-like causes. + if isinstance(cause, dict): + self.error = cause.get("error") + self.error_description = cause.get("error_description") + + +class PasswordlessStartError(PasswordlessError): + """Error raised when POST /passwordless/start fails.""" + + def __init__(self, code: str, message: str, cause=None): + super().__init__(code, message, cause) + self.name = "PasswordlessStartError" + + +class PasswordlessVerifyError(PasswordlessError): + """Error raised when the passwordless OTP token exchange fails.""" + + def __init__(self, code: str, message: str, cause=None): + super().__init__(code, message, cause) + self.name = "PasswordlessVerifyError" + + +class PasswordlessErrorCode: + """Error codes for passwordless operations.""" + + # Start errors + BAD_CONNECTION = "bad.connection" + BAD_EMAIL = "bad.email" + SMS_PROVIDER_ERROR = "sms_provider_error" + TOO_MANY_REQUESTS = "too_many_requests" + # Verify errors + INVALID_GRANT = "invalid_grant" + INVALID_ISSUER = "invalid_issuer" + INVALID_AUDIENCE = "invalid_audience" + DISCOVERY_ERROR = "discovery_error" + # SDK-side + START_FAILED = "passwordless_start_failed" + VERIFY_FAILED = "passwordless_verify_failed" diff --git a/src/auth0_server_python/tests/test_passwordless_client.py b/src/auth0_server_python/tests/test_passwordless_client.py new file mode 100644 index 0000000..ff71f4a --- /dev/null +++ b/src/auth0_server_python/tests/test_passwordless_client.py @@ -0,0 +1,427 @@ +""" +Tests for PasswordlessClient — embedded passwordless (OTP + magic link). +""" + +from unittest.mock import AsyncMock, MagicMock + +import pytest +from pydantic import ValidationError + +from auth0_server_python.auth_server.passwordless_client import ( + PASSWORDLESS_OTP_GRANT_TYPE, + PasswordlessClient, +) +from auth0_server_python.auth_server.server_client import ServerClient +from auth0_server_python.auth_types import ( + StartPasswordlessEmailOptions, + StartPasswordlessSmsOptions, + VerifyPasswordlessOtpOptions, +) +from auth0_server_python.error import ( + InvalidArgumentError, + IssuerValidationError, + MissingRequiredArgumentError, + PasswordlessStartError, + PasswordlessVerifyError, + SessionExpiredError, +) + +DOMAIN = "tenant.auth0.com" +CLIENT_ID = "test_client" +CLIENT_SECRET = "test_secret" +SECRET = "test_secret_key_32_chars_long!!!" +REDIRECT_URI = "https://app.example.com/auth/callback" +ISSUER = "https://tenant.auth0.com/" +METADATA = {"token_endpoint": f"https://{DOMAIN}/oauth/token", "issuer": ISSUER} + + +def _make_client(**overrides) -> ServerClient: + kwargs = { + "domain": DOMAIN, + "client_id": CLIENT_ID, + "client_secret": CLIENT_SECRET, + "secret": SECRET, + "redirect_uri": REDIRECT_URI, + "transaction_store": AsyncMock(), + "state_store": AsyncMock(), + } + kwargs.update(overrides) + return ServerClient(**kwargs) + + +def _mock_http(client: ServerClient, status_code: int, json_body): + """Patch client._get_http_client so post() returns the given response.""" + response = MagicMock(status_code=status_code) + response.json = MagicMock(return_value=json_body) + http = AsyncMock() + http.post = AsyncMock(return_value=response) + ctx = MagicMock() + ctx.__aenter__ = AsyncMock(return_value=http) + ctx.__aexit__ = AsyncMock(return_value=False) + client._get_http_client = MagicMock(return_value=ctx) + return http + + +# ── Wiring ─────────────────────────────────────────────────────────────────── + + +class TestWiring: + def test_passwordless_property(self): + client = _make_client() + assert isinstance(client.passwordless, PasswordlessClient) + assert client.passwordless._client is client + + +# ── start(): OTP ─────────────────────────────────────────────────────────── + + +class TestStartOtp: + @pytest.mark.asyncio + async def test_email_otp_start(self): + client = _make_client() + http = _mock_http(client, 200, {"_id": "req_123"}) + + result = await client.passwordless.start( + StartPasswordlessEmailOptions(email="user@example.com", send="code") + ) + + body = http.post.call_args.kwargs["json"] + assert body["connection"] == "email" + assert body["email"] == "user@example.com" + assert body["send"] == "code" + assert body["client_id"] == CLIENT_ID + assert body["client_secret"] == CLIENT_SECRET + # OTP flow does not create a transaction. + client._transaction_store.set.assert_not_awaited() + assert result.id == "req_123" or True # extra fields allowed + + @pytest.mark.asyncio + async def test_sms_otp_start(self): + client = _make_client() + http = _mock_http(client, 200, {}) + + await client.passwordless.start(StartPasswordlessSmsOptions(phone_number="+14155550100")) + + body = http.post.call_args.kwargs["json"] + assert body["connection"] == "sms" + assert body["phone_number"] == "+14155550100" + assert "send" not in body + + @pytest.mark.asyncio + async def test_language_sets_header(self): + client = _make_client() + http = _mock_http(client, 200, {}) + + await client.passwordless.start( + StartPasswordlessEmailOptions(email="user@example.com", send="code", language="fr") + ) + + headers = http.post.call_args.kwargs["headers"] + assert headers["x-request-language"] == "fr" + + @pytest.mark.asyncio + async def test_start_error_maps_to_typed_exception(self): + client = _make_client() + _mock_http( + client, + 400, + {"error": "bad.connection", "error_description": "Connection disabled"}, + ) + + with pytest.raises(PasswordlessStartError) as exc: + await client.passwordless.start( + StartPasswordlessEmailOptions(email="user@example.com", send="code") + ) + assert exc.value.code == "bad.connection" + assert exc.value.error == "bad.connection" + assert exc.value.error_description == "Connection disabled" + + @pytest.mark.asyncio + async def test_sms_e164_rejected_at_model(self): + # Validation happens at model construction, before any network call. + with pytest.raises(ValidationError): + StartPasswordlessSmsOptions(phone_number="4155550100") + + +# ── start(): Magic link ────────────────────────────────────────────────────── + + +class TestStartMagicLink: + @pytest.mark.asyncio + async def test_magic_link_sets_sdk_owned_params_and_persists_tx(self): + client = _make_client() + http = _mock_http(client, 200, {}) + + await client.passwordless.start( + StartPasswordlessEmailOptions(email="user@example.com", send="link") + ) + + body = http.post.call_args.kwargs["json"] + ap = body["authParams"] + assert ap["redirect_uri"] == REDIRECT_URI + assert ap["response_type"] == "code" + assert "state" in ap and len(ap["state"]) >= 16 + assert ap["scope"] # default scope applied + + # Transaction persisted, single-use + bounded TTL, no PKCE verifier. + client._transaction_store.set.assert_awaited_once() + set_call = client._transaction_store.set.await_args + tx_key = set_call.args[0] + tx_data = set_call.args[1] + assert tx_key == f"{client._transaction_identifier}:{ap['state']}" + assert set_call.kwargs["remove_if_expires"] is True + assert tx_data.code_verifier is None + assert tx_data.redirect_uri == REDIRECT_URI + + @pytest.mark.asyncio + async def test_magic_link_requires_redirect_uri(self): + client = _make_client(redirect_uri=None) + _mock_http(client, 200, {}) + + with pytest.raises(MissingRequiredArgumentError): + await client.passwordless.start( + StartPasswordlessEmailOptions(email="user@example.com", send="link") + ) + + @pytest.mark.asyncio + async def test_caller_cannot_override_reserved_param(self): + client = _make_client() + _mock_http(client, 200, {}) + + with pytest.raises(InvalidArgumentError): + await client.passwordless.start( + StartPasswordlessEmailOptions( + email="user@example.com", + send="link", + auth_params={"redirect_uri": "https://evil.example.com/steal"}, + ) + ) + # No transaction written when the override is rejected. + client._transaction_store.set.assert_not_awaited() + + @pytest.mark.asyncio + async def test_caller_safe_param_passed_through(self): + client = _make_client() + http = _mock_http(client, 200, {}) + + await client.passwordless.start( + StartPasswordlessEmailOptions( + email="user@example.com", + send="link", + auth_params={"login_hint": "user@example.com"}, + ) + ) + ap = http.post.call_args.kwargs["json"]["authParams"] + assert ap["login_hint"] == "user@example.com" + assert ap["redirect_uri"] == REDIRECT_URI # still SDK-owned + + @pytest.mark.asyncio + async def test_caller_unrecognized_param_rejected(self): + # Allowlist posture: a param outside the allowlist is rejected, not + # silently forwarded, so a future authorize param can't slip through. + client = _make_client() + _mock_http(client, 200, {}) + + with pytest.raises(InvalidArgumentError): + await client.passwordless.start( + StartPasswordlessEmailOptions( + email="user@example.com", + send="link", + auth_params={"response_mode": "fragment"}, + ) + ) + client._transaction_store.set.assert_not_awaited() + + @pytest.mark.asyncio + async def test_client_ip_forwarded_on_start(self): + client = _make_client() + http = _mock_http(client, 200, {}) + + await client.passwordless.start( + StartPasswordlessEmailOptions( + email="user@example.com", send="code", client_ip="203.0.113.7" + ) + ) + headers = http.post.call_args.kwargs["headers"] + assert headers["auth0-forwarded-for"] == "203.0.113.7" + + +# ── verify() ───────────────────────────────────────────────────────────────── + + +class TestVerify: + def _patch_verify_deps(self, client, mocker, claims): + 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=claims) + + @pytest.mark.asyncio + async def test_email_otp_verify_creates_session(self, mocker): + client = _make_client() + claims = {"iss": ISSUER, "sub": "auth0|1", "sid": "SID-123", "iat": 1_000} + self._patch_verify_deps(client, mocker, claims) + http = _mock_http( + client, + 200, + {"access_token": "at", "id_token": "idt", "expires_in": 3600, "scope": "openid"}, + ) + + result = await client.passwordless.verify( + VerifyPasswordlessOtpOptions( + connection="email", email="user@example.com", verification_code="123456" + ) + ) + + # Correct grant + params on the token call. + data = http.post.call_args.kwargs["data"] + assert data["grant_type"] == PASSWORDLESS_OTP_GRANT_TYPE + assert data["realm"] == "email" + assert data["username"] == "user@example.com" + assert data["otp"] == "123456" + + # Session persisted, sid taken from the ID token claim (not random). + client._state_store.set.assert_awaited_once() + assert "state_data" in result + assert result["state_data"]["internal"]["sid"] == "SID-123" + + @pytest.mark.asyncio + async def test_sms_otp_verify_username_is_phone(self, mocker): + client = _make_client() + claims = {"iss": ISSUER, "sub": "auth0|2", "sid": "SID-9", "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="sms", phone_number="+14155550100", verification_code="000111" + ) + ) + data = http.post.call_args.kwargs["data"] + assert data["realm"] == "sms" + assert data["username"] == "+14155550100" + # SMS has no email claim to satisfy, so the default scope omits `email`. + assert data["scope"] == "openid profile" + + @pytest.mark.asyncio + async def test_email_verify_default_scope_includes_email(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" + ) + ) + assert http.post.call_args.kwargs["data"]["scope"] == "openid profile email" + + @pytest.mark.asyncio + async def test_client_ip_forwarded_on_verify(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", + client_ip="203.0.113.7", + ) + ) + headers = http.post.call_args.kwargs["headers"] + assert headers["auth0-forwarded-for"] == "203.0.113.7" + + @pytest.mark.asyncio + async def test_verify_invalid_otp_maps_to_typed_error(self, mocker): + client = _make_client() + mocker.patch.object(client, "_get_oidc_metadata_cached", return_value=METADATA) + _mock_http( + client, + 403, + {"error": "invalid_grant", "error_description": "Wrong code"}, + ) + + with pytest.raises(PasswordlessVerifyError) as exc: + await client.passwordless.verify( + VerifyPasswordlessOtpOptions( + connection="email", email="user@example.com", verification_code="000000" + ) + ) + assert exc.value.code == "invalid_grant" + client._state_store.set.assert_not_awaited() + + @pytest.mark.asyncio + async def test_verify_issuer_mismatch_rejected(self, mocker): + client = _make_client() + claims = {"iss": "https://attacker.evil.com/", "sub": "x", "sid": "s", "iat": 1_000} + self._patch_verify_deps(client, mocker, claims) + _mock_http(client, 200, {"access_token": "at", "id_token": "idt", "expires_in": 3600}) + + with pytest.raises(IssuerValidationError): + await client.passwordless.verify( + VerifyPasswordlessOtpOptions( + connection="email", email="user@example.com", verification_code="123456" + ) + ) + client._state_store.set.assert_not_awaited() + + @pytest.mark.asyncio + async def test_verify_missing_id_token_rejected(self, mocker): + client = _make_client() + mocker.patch.object(client, "_get_oidc_metadata_cached", return_value=METADATA) + _mock_http(client, 200, {"access_token": "at", "expires_in": 3600}) + + with pytest.raises(PasswordlessVerifyError): + await client.passwordless.verify( + VerifyPasswordlessOtpOptions( + connection="email", email="user@example.com", verification_code="123456" + ) + ) + + @pytest.mark.asyncio + async def test_verify_discovery_failure_mapped(self, mocker): + client = _make_client() + mocker.patch.object(client, "_get_oidc_metadata_cached", side_effect=Exception("boom")) + + with pytest.raises(PasswordlessVerifyError) as exc: + await client.passwordless.verify( + VerifyPasswordlessOtpOptions( + connection="email", email="user@example.com", verification_code="123456" + ) + ) + assert exc.value.code == "discovery_error" + + @pytest.mark.asyncio + async def test_verify_ceiling_in_past_rejected(self, mocker): + client = _make_client() + # session_expiry well before iat -> ceiling already past. + claims = { + "iss": ISSUER, + "sub": "x", + "sid": "s", + "iat": 2_000_000_000, + "session_expiry": 1_000, + } + self._patch_verify_deps(client, mocker, claims) + _mock_http(client, 200, {"access_token": "at", "id_token": "idt", "expires_in": 3600}) + + with pytest.raises(SessionExpiredError): + await client.passwordless.verify( + VerifyPasswordlessOtpOptions( + connection="email", email="user@example.com", verification_code="123456" + ) + ) + client._state_store.set.assert_not_awaited() diff --git a/src/auth0_server_python/tests/test_server_client.py b/src/auth0_server_python/tests/test_server_client.py index 05306c9..c2df9c5 100644 --- a/src/auth0_server_python/tests/test_server_client.py +++ b/src/auth0_server_python/tests/test_server_client.py @@ -4281,6 +4281,122 @@ async def test_complete_login_issuer_validation_success(mocker): assert "state_data" in result +@pytest.mark.asyncio +async def test_complete_login_sid_sourced_from_id_token_claim(mocker): + """ + ID-token-only login must persist the session sid from the ID token's `sid` + claim (not a random value) so OIDC back-channel logout, which matches on + sid, can target the session. + """ + mock_tx_store = AsyncMock() + mock_tx_store.get.return_value = TransactionData( + code_verifier="123", + domain="tenant.auth0.com", + ) + + mock_state_store = AsyncMock() + + client = ServerClient( + domain="tenant.auth0.com", + client_id="test_client", + client_secret="test_secret", + transaction_store=mock_tx_store, + state_store=mock_state_store, + secret="test_secret_key_32_chars_long!!", + ) + + # Mock OIDC metadata + mocker.patch.object( + client, + "_get_oidc_metadata_cached", + return_value={"issuer": "https://tenant.auth0.com/", "token_endpoint": "https://tenant.auth0.com/token"} + ) + + # Mock JWKS fetch + mocker.patch.object( + client, + "_get_jwks_cached", + return_value={"keys": [{"kty": "RSA", "kid": "test-key"}]} + ) + + # Mock OAuth fetch_token (ID-token-only response, no userinfo) + async_fetch_token = AsyncMock() + async_fetch_token.return_value = { + "access_token": "token123", + "id_token": "id_token_jwt" + } + mocker.patch.object(client._oauth, "fetch_token", async_fetch_token) + + # Verified claims carry a `sid` + mocker.patch.object( + client, + "_verify_and_decode_jwt", + return_value={"sub": "user123", "iss": "https://tenant.auth0.com/", "sid": "SID-from-claim"} + ) + + result = await client.complete_interactive_login("http://localhost/callback?code=abc&state=xyz") + + assert result["state_data"]["internal"]["sid"] == "SID-from-claim" + + +@pytest.mark.asyncio +async def test_complete_login_sid_falls_back_to_random_without_claim(mocker): + """ + When the ID token carries no `sid`, a random one is generated so the session + is still persisted (back-channel logout simply cannot target it). + """ + mock_tx_store = AsyncMock() + mock_tx_store.get.return_value = TransactionData( + code_verifier="123", + domain="tenant.auth0.com", + ) + + mock_state_store = AsyncMock() + + client = ServerClient( + domain="tenant.auth0.com", + client_id="test_client", + client_secret="test_secret", + transaction_store=mock_tx_store, + state_store=mock_state_store, + secret="test_secret_key_32_chars_long!!", + ) + + # Mock OIDC metadata + mocker.patch.object( + client, + "_get_oidc_metadata_cached", + return_value={"issuer": "https://tenant.auth0.com/", "token_endpoint": "https://tenant.auth0.com/token"} + ) + + # Mock JWKS fetch + mocker.patch.object( + client, + "_get_jwks_cached", + return_value={"keys": [{"kty": "RSA", "kid": "test-key"}]} + ) + + # Mock OAuth fetch_token (ID-token-only response, no userinfo) + async_fetch_token = AsyncMock() + async_fetch_token.return_value = { + "access_token": "token123", + "id_token": "id_token_jwt" + } + mocker.patch.object(client._oauth, "fetch_token", async_fetch_token) + + # Verified claims carry NO `sid` + mocker.patch.object( + client, + "_verify_and_decode_jwt", + return_value={"sub": "user123", "iss": "https://tenant.auth0.com/"} + ) + + result = await client.complete_interactive_login("http://localhost/callback?code=abc&state=xyz") + + sid = result["state_data"]["internal"]["sid"] + assert sid and sid != "user123" + + @pytest.mark.asyncio async def test_complete_login_issuer_mismatch_raises_error(mocker): """Test that issuer mismatch in ID token raises IssuerValidationError.""" From eb726d4f9d973efa2314e1f1f47a2ccf91ee80b0 Mon Sep 17 00:00:00 2001 From: Sourav Basu Date: Sun, 19 Jul 2026 23:45:33 +0530 Subject: [PATCH 02/14] Small Optimisations --- .../auth_server/passwordless_client.py | 5 + .../auth_server/server_client.py | 5 +- .../auth_types/__init__.py | 11 ++- .../tests/test_passwordless_client.py | 91 ++++++++++++++++++- 4 files changed, 103 insertions(+), 9 deletions(-) diff --git a/src/auth0_server_python/auth_server/passwordless_client.py b/src/auth0_server_python/auth_server/passwordless_client.py index c8d6d22..119c1da 100644 --- a/src/auth0_server_python/auth_server/passwordless_client.py +++ b/src/auth0_server_python/auth_server/passwordless_client.py @@ -279,6 +279,11 @@ async def _build_magic_link_auth_params( auth_params = self._sanitize_caller_auth_params(options.auth_params) + # Required to persist the transaction cookie; checked after input + # validation so bad auth_params / missing redirect_uri surface first. + if store_options is None: + raise MissingRequiredArgumentError("store_options") + state = PKCE.generate_random_string(32) auth_params["redirect_uri"] = redirect_uri auth_params["response_type"] = "code" diff --git a/src/auth0_server_python/auth_server/server_client.py b/src/auth0_server_python/auth_server/server_client.py index 19016a1..84c04f0 100644 --- a/src/auth0_server_python/auth_server/server_client.py +++ b/src/auth0_server_python/auth_server/server_client.py @@ -622,11 +622,12 @@ async def _persist_session_from_token_response( if State.is_session_ceiling_in_past(session_expires_at, issued_at): raise SessionExpiredError() + now = int(time.time()) token_set = TokenSet( audience=audience or self.DEFAULT_AUDIENCE_STATE_KEY, access_token=token_response.get("access_token", ""), scope=token_response.get("scope", ""), - expires_at=int(time.time()) + token_response.get("expires_in", 3600), + expires_at=now + token_response.get("expires_in", 3600), ) # Prefer the ID token's `sid` claim, then userinfo, then a random value. @@ -647,7 +648,7 @@ async def _persist_session_from_token_response( domain=origin_domain, internal={ "sid": sid, - "created_at": int(time.time()), + "created_at": now, "session_expires_at": session_expires_at, }, ) diff --git a/src/auth0_server_python/auth_types/__init__.py b/src/auth0_server_python/auth_types/__init__.py index 0cd9546..3a217e0 100644 --- a/src/auth0_server_python/auth_types/__init__.py +++ b/src/auth0_server_python/auth_types/__init__.py @@ -678,12 +678,13 @@ class MfaTokenContext(BaseModel): "prompt", "max_age", "acr_values", - "connection_scope", } ) # Minimal BCP 47 language tag: primary subtag plus optional subtags. _BCP47_LANGUAGE_RE = r"^[A-Za-z]{2,3}(-[A-Za-z0-9]{1,8})*$" +# E.164 phone number: '+' followed by up to 15 digits, first digit non-zero. +_E164_RE = re.compile(r"^\+[1-9]\d{1,14}$") class _StartPasswordlessBase(BaseModel): @@ -733,7 +734,7 @@ class StartPasswordlessSmsOptions(_StartPasswordlessBase): @field_validator("phone_number") @classmethod def _validate_phone_number(cls, value: str) -> str: - if not re.match(r"^\+[1-9]\d{1,14}$", value): + if not _E164_RE.match(value): raise ValueError("phone_number must be in E.164 format (e.g. '+14155550100')") return value @@ -769,7 +770,7 @@ class VerifyPasswordlessOtpOptions(BaseModel): def _validate_phone_number(cls, value: Optional[str]) -> Optional[str]: if value is None: return None - if not re.match(r"^\+[1-9]\d{1,14}$", value): + if not _E164_RE.match(value): raise ValueError("phone_number must be in E.164 format (e.g. '+14155550100')") return value @@ -796,7 +797,9 @@ def username(self) -> str: class PasswordlessStartResult(BaseModel): """Success payload from POST /passwordless/start.""" - id: Optional[str] = None + # Auth0 returns the request id as `_id`; alias so `.id` is populated. + id: Optional[str] = Field(default=None, alias="_id") class Config: extra = "allow" # Allow additional fields returned by Auth0 + populate_by_name = True # accept both `_id` (alias) and `id` diff --git a/src/auth0_server_python/tests/test_passwordless_client.py b/src/auth0_server_python/tests/test_passwordless_client.py index ff71f4a..1e8a49b 100644 --- a/src/auth0_server_python/tests/test_passwordless_client.py +++ b/src/auth0_server_python/tests/test_passwordless_client.py @@ -15,6 +15,7 @@ from auth0_server_python.auth_types import ( StartPasswordlessEmailOptions, StartPasswordlessSmsOptions, + TransactionData, VerifyPasswordlessOtpOptions, ) from auth0_server_python.error import ( @@ -93,7 +94,8 @@ async def test_email_otp_start(self): assert body["client_secret"] == CLIENT_SECRET # OTP flow does not create a transaction. client._transaction_store.set.assert_not_awaited() - assert result.id == "req_123" or True # extra fields allowed + # Auth0 returns the request id as `_id`; the model aliases it to `.id`. + assert result.id == "req_123" @pytest.mark.asyncio async def test_sms_otp_start(self): @@ -153,7 +155,8 @@ async def test_magic_link_sets_sdk_owned_params_and_persists_tx(self): http = _mock_http(client, 200, {}) await client.passwordless.start( - StartPasswordlessEmailOptions(email="user@example.com", send="link") + StartPasswordlessEmailOptions(email="user@example.com", send="link"), + store_options={}, ) body = http.post.call_args.kwargs["json"] @@ -173,6 +176,19 @@ async def test_magic_link_sets_sdk_owned_params_and_persists_tx(self): assert tx_data.code_verifier is None assert tx_data.redirect_uri == REDIRECT_URI + @pytest.mark.asyncio + async def test_magic_link_requires_store_options(self): + # No store_options -> transaction cookie can't be persisted -> fail loudly. + client = _make_client() + _mock_http(client, 200, {}) + + with pytest.raises(MissingRequiredArgumentError): + await client.passwordless.start( + StartPasswordlessEmailOptions(email="user@example.com", send="link"), + store_options=None, + ) + client._transaction_store.set.assert_not_awaited() + @pytest.mark.asyncio async def test_magic_link_requires_redirect_uri(self): client = _make_client(redirect_uri=None) @@ -209,7 +225,8 @@ async def test_caller_safe_param_passed_through(self): email="user@example.com", send="link", auth_params={"login_hint": "user@example.com"}, - ) + ), + store_options={}, ) ap = http.post.call_args.kwargs["json"]["authParams"] assert ap["login_hint"] == "user@example.com" @@ -232,6 +249,23 @@ async def test_caller_unrecognized_param_rejected(self): ) client._transaction_store.set.assert_not_awaited() + @pytest.mark.asyncio + async def test_connection_scope_not_allowed(self): + # connection_scope is a federated-connection param with no meaning for + # email/SMS passwordless; it is not in the allowlist and is rejected. + client = _make_client() + _mock_http(client, 200, {}) + + with pytest.raises(InvalidArgumentError): + await client.passwordless.start( + StartPasswordlessEmailOptions( + email="user@example.com", + send="link", + auth_params={"connection_scope": "read:foo"}, + ) + ) + client._transaction_store.set.assert_not_awaited() + @pytest.mark.asyncio async def test_client_ip_forwarded_on_start(self): client = _make_client() @@ -246,6 +280,57 @@ async def test_client_ip_forwarded_on_start(self): assert headers["auth0-forwarded-for"] == "203.0.113.7" +# ── Magic link callback completion (complete_interactive_login) ────────────── + + +class TestMagicLinkCallback: + @pytest.mark.asyncio + async def test_magic_link_callback_exchanges_code_without_pkce(self, mocker): + # Magic link is a plain auth-code exchange: lock that code_verifier=None + # reaches fetch_token (authlib drops the falsy field) so a forced verifier + # — which Auth0 would reject — is caught. + client = _make_client() + client._transaction_store.get.return_value = TransactionData( + code_verifier=None, + redirect_uri=REDIRECT_URI, + domain=DOMAIN, + ) + + mocker.patch.object(client, "_get_oidc_metadata_cached", return_value=METADATA) + mocker.patch.object(client._oauth, "metadata", 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|1", "sid": "SID-1", "iat": 1_000}, + ) + fetch_token = AsyncMock( + return_value={ + "access_token": "at", + "id_token": "idt", + "expires_in": 3600, + "scope": "openid", + } + ) + mocker.patch.object(client._oauth, "fetch_token", fetch_token) + + result = await client.complete_interactive_login( + f"{REDIRECT_URI}?code=AUTHCODE&state=STATE-1" + ) + + assert fetch_token.await_args.kwargs["code_verifier"] is None + assert fetch_token.await_args.kwargs["code"] == "AUTHCODE" + + # Session established; transaction consumed (single-use). + client._state_store.set.assert_awaited_once() + client._transaction_store.delete.assert_awaited_once() + assert result["state_data"]["internal"]["sid"] == "SID-1" + + # ── verify() ───────────────────────────────────────────────────────────────── From f1e2f49df1222faa70ee65fb2abe380cf04d1013 Mon Sep 17 00:00:00 2001 From: Sourav Basu Date: Thu, 30 Jul 2026 17:20:02 +0530 Subject: [PATCH 03/14] Added examples/passwordless.md --- examples/Passwordless.md | 359 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 359 insertions(+) create mode 100644 examples/Passwordless.md diff --git a/examples/Passwordless.md b/examples/Passwordless.md new file mode 100644 index 0000000..099c057 --- /dev/null +++ b/examples/Passwordless.md @@ -0,0 +1,359 @@ +# Passwordless Authentication + +Passwordless lets users sign in with a one-time code sent by email or SMS, or with a magic link sent by email. This guide covers the **embedded login** flow on `ServerClient.passwordless` and how each path establishes a server-side session. + +> [!NOTE] +> Passwordless API flows use Auth0 Legacy Passwordless connections (`email` and `sms`). Enable the **Passwordless OTP** grant for your application under **Applications -> Your App -> Advanced Settings -> Grant Types**. See the [Auth0 Passwordless API documentation](https://auth0.com/docs/authenticate/passwordless/implement-login/embedded-login/relevant-api-endpoints). + +> [!IMPORTANT] +> These flows are for confidential server-side applications. Tokens stay on the server; the browser should only receive your application's session cookie or opaque session reference. + +## Table of Contents + +- [How the flow works](#how-the-flow-works) +- [Prerequisites](#prerequisites) +- [1. Email OTP](#1-email-otp) +- [2. SMS OTP](#2-sms-otp) +- [3. Email magic link](#3-email-magic-link) +- [4. Custom scopes and audiences](#4-custom-scopes-and-audiences) +- [5. Forwarding the end-user IP](#5-forwarding-the-end-user-ip) +- [6. Organizations](#6-organizations) +- [Completing MFA during passwordless login](#completing-mfa-during-passwordless-login) +- [Error Handling](#error-handling) + +## How the flow works + +Passwordless has two shapes: + +1. **OTP code** - `start()` sends a code by email or SMS. Your app collects that code, then `verify()` exchanges it at `/oauth/token` with the passwordless OTP grant and **creates a server-side session**. +2. **Magic link** - `start(send="link")` sends a one-click email link. Auth0 redirects the user back to your callback URL, and your app completes the flow with `complete_interactive_login()`. The callback creates the server-side session. + +OTP start does **not** create a session. The session exists only after `verify()` succeeds. Magic-link start writes a transaction so the callback can validate the returned `state`; the session exists only after the callback completes. + +## Prerequisites + +```python +from auth0_server_python.auth_server.server_client import ServerClient + +server_client = ServerClient( + domain="YOUR_AUTH0_DOMAIN", + client_id="YOUR_CLIENT_ID", + client_secret="YOUR_CLIENT_SECRET", + secret="YOUR_SECRET", + redirect_uri="https://app.example.com/auth/callback", +) +``` + +For apps using request/response-backed stores or multiple custom domains, pass `store_options={"request": request, "response": response}` to each method that reads or writes transaction/session state. + +## 1. Email OTP + +### Step 1 - Send the code + +```python +from auth0_server_python.auth_types import StartPasswordlessEmailOptions + +start_result = await server_client.passwordless.start( + StartPasswordlessEmailOptions( + email="user@example.com", + send="code", + language="en-US", # optional x-request-language header + ), + store_options={"request": request, "response": response}, +) + +# start_result.id is Auth0's request identifier when returned by the API. +``` + +### Step 2 - Verify the code and establish the session + +```python +from auth0_server_python.auth_types import VerifyPasswordlessOtpOptions + +result = await server_client.passwordless.verify( + VerifyPasswordlessOtpOptions( + connection="email", + email="user@example.com", + verification_code=user_entered_code, + ), + store_options={"request": request, "response": response}, +) + +user = result["state_data"]["user"] +print(f"Signed in: {user['sub']}") +``` + +The SDK verifies the returned ID token, validates the issuer and audience, persists the tokens in the configured state store, and sources the session `sid` from the verified ID token when available. + +## 2. SMS OTP + +SMS has the same two-step shape. Phone numbers must be in E.164 format. + +```python +from auth0_server_python.auth_types import ( + StartPasswordlessSmsOptions, + VerifyPasswordlessOtpOptions, +) + +await server_client.passwordless.start( + StartPasswordlessSmsOptions( + phone_number="+14155550100", + ), + store_options={"request": request, "response": response}, +) + +result = await server_client.passwordless.verify( + VerifyPasswordlessOtpOptions( + connection="sms", + phone_number="+14155550100", + verification_code=user_entered_code, + ), + store_options={"request": request, "response": response}, +) +``` + +By default, email OTP requests `openid profile email`; SMS OTP requests `openid profile` because SMS identities do not have an email claim to satisfy. + +## 3. Email magic link + +Magic links are email-only. `start(send="link")` persists a transaction and includes SDK-owned `redirect_uri`, `response_type`, and `state` in Auth0's `authParams`. + +```python +from auth0_server_python.auth_types import StartPasswordlessEmailOptions + +await server_client.passwordless.start( + StartPasswordlessEmailOptions( + email="user@example.com", + send="link", + auth_params={ + "scope": "openid profile email", + "login_hint": "user@example.com", + }, + ), + store_options={"request": request, "response": response}, +) +``` + +> [!IMPORTANT] +> Magic-link start requires `store_options` whenever your transaction store needs the framework request/response to write state. Without that transaction, the callback cannot validate the returned `state`. + +When the user clicks the emailed link, Auth0 redirects back to your configured callback URL. Complete it with the standard interactive-login callback: + +```python +callback_url = str(request.url) + +result = await server_client.complete_interactive_login( + callback_url, + store_options={"request": request, "response": response}, +) + +user = result["state_data"]["user"] +``` + +> [!WARNING] +> Do not let callers override `redirect_uri`, `state`, `response_type`, `nonce`, or PKCE fields in magic-link `auth_params`. The SDK owns these values so the emailed authorization code and state cannot be redirected to an attacker-controlled URL. + +## 4. Custom scopes and audiences + +For OTP flows, pass `scope` and `audience` to `verify()`. These become the `/oauth/token` request parameters. + +```python +result = await server_client.passwordless.verify( + VerifyPasswordlessOtpOptions( + connection="email", + email="user@example.com", + verification_code=user_entered_code, + audience="https://api.example.com", + scope="openid profile email offline_access read:orders", + ), + store_options={"request": request, "response": response}, +) +``` + +For magic links, pass allowed authorization parameters through `auth_params` at `start()` time: + +```python +await server_client.passwordless.start( + StartPasswordlessEmailOptions( + email="user@example.com", + send="link", + auth_params={ + "audience": "https://api.example.com", + "scope": "openid profile email offline_access read:orders", + "login_hint": "user@example.com", + }, + ), + store_options={"request": request, "response": response}, +) +``` + +> [!NOTE] +> `state` is intentionally not a caller-supplied auth parameter in this SDK. If you need app-specific return data, store it server-side against your own transaction/session context instead of putting it into the Auth0 magic-link `state`. + +## 5. Forwarding the end-user IP + +Auth0 attack protection and rate limiting normally see the IP address of the server making the API call. For confidential clients, Auth0 can use the `auth0-forwarded-for` header when the **Trust Token Endpoint IP Header** setting is enabled. + +```python +await server_client.passwordless.start( + StartPasswordlessEmailOptions( + email="user@example.com", + send="code", + client_ip=request.client.host, + ), + store_options={"request": request, "response": response}, +) + +result = await server_client.passwordless.verify( + VerifyPasswordlessOtpOptions( + connection="email", + email="user@example.com", + verification_code=user_entered_code, + client_ip=request.client.host, + ), + store_options={"request": request, "response": response}, +) +``` + +> [!WARNING] +> Only forward a trusted, normalized end-user IP from your edge/proxy layer. Do not blindly copy arbitrary client-supplied headers into `client_ip`. + +## 6. Organizations + +Magic links can carry an organization through `authParams`; the SDK stores the expected organization in the transaction and validates the callback token claims. + +```python +await server_client.passwordless.start( + StartPasswordlessEmailOptions( + email="user@example.com", + send="link", + organization="org_abc123", + ), + store_options={"request": request, "response": response}, +) +``` + +For OTP verification, pass `organization` only when your Auth0 passwordless OTP configuration returns organization claims for that flow. The SDK validates the ID token's `org_id` or `org_name` claim against the supplied value. + +```python +result = await server_client.passwordless.verify( + VerifyPasswordlessOtpOptions( + connection="email", + email="user@example.com", + verification_code=user_entered_code, + organization="org_abc123", + ), + store_options={"request": request, "response": response}, +) +``` + +If the ID token does not include a matching organization claim, verification fails before a session is persisted. + +## 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. + +```python +from auth0_server_python.error import MfaRequiredError + +try: + result = await server_client.passwordless.verify( + VerifyPasswordlessOtpOptions( + connection="email", + email="user@example.com", + verification_code=user_entered_code, + ), + store_options={"request": request, "response": response}, + ) + user = result["state_data"]["user"] + +except MfaRequiredError as e: + await server_client.mfa.challenge_authenticator( + {"mfa_token": e.mfa_token, "factor_type": "otp"}, + store_options={"request": request, "response": response}, + ) + + verify_response = await server_client.mfa.verify( + {"mfa_token": e.mfa_token, "otp": mfa_code}, + 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, + ) +``` + +> [!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. + +## Error Handling + +Passwordless methods raise typed SDK errors: + +- `PasswordlessStartError` - `POST /passwordless/start` failed +- `PasswordlessVerifyError` - OTP token exchange or ID-token verification failed +- `MfaRequiredError` - Auth0 requires MFA before completing login +- `MissingRequiredArgumentError` - required SDK input is missing, such as magic-link `store_options` +- `InvalidArgumentError` - caller input is rejected before a network call +- `OrganizationTokenValidationError` - requested organization does not match returned token claims + +### Basic handling + +```python +from auth0_server_python.error import Auth0Error + +try: + result = await server_client.passwordless.verify( + VerifyPasswordlessOtpOptions( + connection="email", + email="user@example.com", + verification_code=user_entered_code, + ), + store_options={"request": request, "response": response}, + ) +except Auth0Error as e: + return {"error": str(e)} +``` + +### Advanced handling + +```python +from auth0_server_python.error import ( + Auth0Error, + MfaRequiredError, + PasswordlessStartError, + PasswordlessVerifyError, +) + +try: + result = await server_client.passwordless.verify( + VerifyPasswordlessOtpOptions( + connection="email", + email="user@example.com", + verification_code=user_entered_code, + ), + store_options={"request": request, "response": response}, + ) +except MfaRequiredError as e: + return start_mfa(e.mfa_token) +except PasswordlessVerifyError as e: + return {"error": e.code, "detail": e.message} +except PasswordlessStartError as e: + return {"error": e.code, "detail": e.message} +except Auth0Error as e: + return {"error": str(e)} +``` + +### Common error codes (`PasswordlessErrorCode`) + +- `bad.connection` - the passwordless connection is disabled or invalid +- `bad.email` - the email address is invalid or rejected by Auth0 +- `sms_provider_error` - Auth0 could not send the SMS +- `too_many_requests` - rate limiting or attack protection blocked the request +- `invalid_grant` - the OTP is invalid, expired, or already used +- `invalid_audience` - returned ID token audience does not match the SDK client +- `discovery_error` - the SDK could not load authorization server metadata +- `passwordless_start_failed` - SDK-side start failure +- `passwordless_verify_failed` - SDK-side verify failure From f923f9cfbd0b43f1d6c0e545c3dbb3c3fa0a7bdb Mon Sep 17 00:00:00 2001 From: Sourav Basu Date: Fri, 7 Aug 2026 19:51:55 +0530 Subject: [PATCH 04/14] Added MFA Support --- README.md | 4 + examples/Passwordless.md | 88 ++++-- .../auth_server/__init__.py | 3 +- .../auth_server/passwordless_client.py | 98 ++++++- .../auth_types/__init__.py | 14 +- src/auth0_server_python/error/__init__.py | 30 ++- .../tests/test_passwordless_client.py | 255 +++++++++++++++++- 7 files changed, 441 insertions(+), 51 deletions(-) diff --git a/README.md b/README.md index 93df38f..9ce3dae 100644 --- a/README.md +++ b/README.md @@ -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, organizations, step-up MFA, and error handling, see [examples/Passwordless.md](examples/Passwordless.md). + ## Feedback ### Contributing diff --git a/examples/Passwordless.md b/examples/Passwordless.md index 099c057..bd102f1 100644 --- a/examples/Passwordless.md +++ b/examples/Passwordless.md @@ -17,7 +17,7 @@ Passwordless lets users sign in with a one-time code sent by email or SMS, or wi - [3. Email magic link](#3-email-magic-link) - [4. Custom scopes and audiences](#4-custom-scopes-and-audiences) - [5. Forwarding the end-user IP](#5-forwarding-the-end-user-ip) -- [6. Organizations](#6-organizations) +- [6. Organizations (magic link only)](#6-organizations-magic-link-only) - [Completing MFA during passwordless login](#completing-mfa-during-passwordless-login) - [Error Handling](#error-handling) @@ -32,6 +32,38 @@ OTP start does **not** create a session. The session exists only after `verify() ## Prerequisites +These flows require a **Regular Web Application** — passwordless token exchange +needs a client secret, which a public/SPA client cannot hold safely. + +Two tenant-level settings are also required and are easy to miss because +neither failure mode looks like a configuration problem: + +1. **Authentication Profile must be "Identifier First."** The default + "Universal Login" profile blocks the direct `/oauth/token` call this SDK + uses for OTP verification — without it, OTP `verify()` fails with + `unauthorized_client`. Set it under your tenant's Authentication Profile + settings. (Skip this if you only use passwordless via Universal Login + redirects rather than this SDK's embedded flow.) +2. **Enable the Passwordless OTP grant type** on your application + (**Applications -> Your App -> Advanced Settings -> Grant Types**). Without + it, OTP verification also fails with `unauthorized_client`. +3. **Magic link only** — set the tenant flag + `universal_login.passwordless.allow_magiclink_verify_without_session` to + `true` via the Management API: + + ``` + PATCH /api/v2/tenants/settings + { "universal_login": { "passwordless": { "allow_magiclink_verify_without_session": true } } } + ``` + + This is required for **any** server-side SDK completing magic link (this + one, Express, Next.js, etc.) — the browser that opens the emailed link is + not guaranteed to be the same browser/session that started the flow. + Without it, the user sees: *"The link must be opened on the same device + and browser from which you submitted your email address."* This flag is + not documented in the public Auth0 API reference, so if you don't set it + here you will not discover it from a 400 error message. + ```python from auth0_server_python.auth_server.server_client import ServerClient @@ -152,6 +184,8 @@ user = result["state_data"]["user"] > [!WARNING] > Do not let callers override `redirect_uri`, `state`, `response_type`, `nonce`, or PKCE fields in magic-link `auth_params`. The SDK owns these values so the emailed authorization code and state cannot be redirected to an attacker-controlled URL. +> +> 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. ## 4. Custom scopes and audiences @@ -187,6 +221,8 @@ await server_client.passwordless.start( ) ``` +A caller-supplied magic-link `scope` **replaces** the default wholesale rather than merging with it. The SDK re-injects `openid` when your scope omits it, because the magic-link callback would otherwise complete on a token response carrying no ID token — a session built from claims nothing verified. `openid` is never duplicated and your scope order is preserved otherwise. + > [!NOTE] > `state` is intentionally not a caller-supplied auth parameter in this SDK. If you need app-specific return data, store it server-side against your own transaction/session context instead of putting it into the Auth0 magic-link `state`. @@ -218,9 +254,9 @@ result = await server_client.passwordless.verify( > [!WARNING] > Only forward a trusted, normalized end-user IP from your edge/proxy layer. Do not blindly copy arbitrary client-supplied headers into `client_ip`. -## 6. Organizations +## 6. Organizations (magic link only) -Magic links can carry an organization through `authParams`; the SDK stores the expected organization in the transaction and validates the callback token claims. +Magic links can carry an organization through `authParams`; the SDK stores the expected organization in the transaction and validates the claims returned by the callback. ```python await server_client.passwordless.start( @@ -233,21 +269,16 @@ await server_client.passwordless.start( ) ``` -For OTP verification, pass `organization` only when your Auth0 passwordless OTP configuration returns organization claims for that flow. The SDK validates the ID token's `org_id` or `org_name` claim against the supplied value. - -```python -result = await server_client.passwordless.verify( - VerifyPasswordlessOtpOptions( - connection="email", - email="user@example.com", - verification_code=user_entered_code, - organization="org_abc123", - ), - store_options={"request": request, "response": response}, -) -``` +If the callback's ID token does not include a matching organization claim, verification fails before a session is persisted, raising `OrganizationTokenValidationError`. -If the ID token does not include a matching organization claim, verification fails before a session is persisted. +> [!NOTE] +> `VerifyPasswordlessOtpOptions` (the OTP `verify()` path) has no `organization` +> field. Auth0 does not attach an organization claim to tokens issued by the +> passwordless-OTP grant, so there is nothing for the SDK to validate against +> — an OTP flow that needs organization-scoped login should use magic link +> instead. The model rejects unknown fields, so passing `organization` to +> `verify()` raises a pydantic `ValidationError` rather than being silently +> dropped. ## Completing MFA during passwordless login @@ -293,11 +324,11 @@ except MfaRequiredError as e: Passwordless methods raise typed SDK errors: - `PasswordlessStartError` - `POST /passwordless/start` failed -- `PasswordlessVerifyError` - OTP token exchange or ID-token verification failed +- `PasswordlessVerifyError` - OTP token exchange or ID-token verification failed, including an issuer or audience mismatch (`invalid_issuer` / `invalid_audience`) - `MfaRequiredError` - Auth0 requires MFA before completing login - `MissingRequiredArgumentError` - required SDK input is missing, such as magic-link `store_options` - `InvalidArgumentError` - caller input is rejected before a network call -- `OrganizationTokenValidationError` - requested organization does not match returned token claims +- `OrganizationTokenValidationError` - magic-link callback only: requested organization does not match the returned token claims ### Basic handling @@ -339,20 +370,33 @@ try: except MfaRequiredError as e: return start_mfa(e.mfa_token) except PasswordlessVerifyError as e: - return {"error": e.code, "detail": e.message} + return {"error": e.code, "detail": e.message, "retry_after": e.retry_after} except PasswordlessStartError as e: - return {"error": e.code, "detail": e.message} + return {"error": e.code, "detail": e.message, "retry_after": e.retry_after} except Auth0Error as e: return {"error": str(e)} ``` +`PasswordlessStartError` and `PasswordlessVerifyError` both carry: + +- `code` / `message` - the Auth0 `error` and `error_description`, or an SDK-side code when the response had neither +- `error` / `error_description` - the raw values from a JSON error body +- `retry_after` - seconds from the `Retry-After` response header, typically on a 429. `None` when the header is absent or in HTTP-date form (which the SDK does not interpret) +- `cause` - the parsed JSON error body, or the response text truncated to 2048 characters when the body was not JSON + +> [!WARNING] +> `cause` may hold a raw upstream body (an HTML error page, WAF block page, or proxy dump). It is length-capped, but not redacted — do not log it at a level where untrusted upstream content is unwelcome. + +Because a 429 that carries an explicit Auth0 `error` reports that server code, `code == "too_many_requests"` is not a reliable rate-limit predicate. Branch on `retry_after is not None`, or on the HTTP status if you need certainty. + ### Common error codes (`PasswordlessErrorCode`) - `bad.connection` - the passwordless connection is disabled or invalid - `bad.email` - the email address is invalid or rejected by Auth0 - `sms_provider_error` - Auth0 could not send the SMS -- `too_many_requests` - rate limiting or attack protection blocked the request +- `too_many_requests` - rate limiting or attack protection blocked the request (`start()` or `verify()`) - `invalid_grant` - the OTP is invalid, expired, or already used +- `invalid_issuer` - returned ID token issuer does not match your configured Auth0 domain - `invalid_audience` - returned ID token audience does not match the SDK client - `discovery_error` - the SDK could not load authorization server metadata - `passwordless_start_failed` - SDK-side start failure diff --git a/src/auth0_server_python/auth_server/__init__.py b/src/auth0_server_python/auth_server/__init__.py index 611f6b7..a06ef2e 100644 --- a/src/auth0_server_python/auth_server/__init__.py +++ b/src/auth0_server_python/auth_server/__init__.py @@ -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"] diff --git a/src/auth0_server_python/auth_server/passwordless_client.py b/src/auth0_server_python/auth_server/passwordless_client.py index 119c1da..01c7a41 100644 --- a/src/auth0_server_python/auth_server/passwordless_client.py +++ b/src/auth0_server_python/auth_server/passwordless_client.py @@ -38,7 +38,6 @@ PasswordlessVerifyError, ) from auth0_server_python.utils import PKCE -from auth0_server_python.utils.helpers import validate_org_claims if TYPE_CHECKING: # avoid a circular import at runtime from auth0_server_python.auth_server.server_client import ServerClient @@ -50,6 +49,8 @@ # Header Auth0 reads for the real end-user IP (confidential clients with # "Trust Token Endpoint IP Header" enabled). FORWARDED_FOR_HEADER = "auth0-forwarded-for" +# Cap on a non-JSON error body retained as an exception cause. +_RAW_ERROR_BODY_LIMIT = 2048 class PasswordlessClient: @@ -147,10 +148,16 @@ async def start( if response.status_code not in (200, 201): error_body = self._safe_json(response) + default_code = ( + PasswordlessErrorCode.TOO_MANY_REQUESTS + if response.status_code == 429 + else PasswordlessErrorCode.START_FAILED + ) raise PasswordlessStartError( - error_body.get("error", PasswordlessErrorCode.START_FAILED), + error_body.get("error", default_code), error_body.get("error_description", "Failed to start passwordless flow"), - error_body, + error_body if error_body else self._raw_text(response), + self._retry_after(response), ) return PasswordlessStartResult(**self._safe_json(response)) @@ -179,6 +186,7 @@ async def verify( Raises: PasswordlessVerifyError: When token exchange or ID-token verification fails. + MfaRequiredError: When Auth0 requires MFA before completing login. """ client = self._client origin_domain = await client._resolve_current_domain(store_options) @@ -200,6 +208,7 @@ async def verify( if options.connection == "email" else DEFAULT_PASSWORDLESS_SMS_SCOPE ) + scope = options.scope or default_scope body: dict[str, Any] = { "grant_type": PASSWORDLESS_OTP_GRANT_TYPE, "client_id": client._client_id, @@ -207,7 +216,7 @@ async def verify( "realm": options.connection, "username": options.username, "otp": options.verification_code, - "scope": options.scope or default_scope, + "scope": scope, } if options.audience: body["audience"] = options.audience @@ -232,16 +241,30 @@ async def verify( if response.status_code != 200: error_body = self._safe_json(response) + if error_body.get("error") == "mfa_required" and error_body.get("mfa_token"): + await client._mfa_client._raise_mfa_required( + error_body, + audience=options.audience or client.DEFAULT_AUDIENCE_STATE_KEY, + scope=scope, + default_description="Multifactor authentication required", + store_options=store_options, + ) + default_code = ( + PasswordlessErrorCode.TOO_MANY_REQUESTS + if response.status_code == 429 + else PasswordlessErrorCode.INVALID_GRANT + ) raise PasswordlessVerifyError( - error_body.get("error", PasswordlessErrorCode.INVALID_GRANT), + error_body.get("error", default_code), error_body.get("error_description", "Passwordless verification failed"), - error_body, + error_body if error_body else self._raw_text(response), + self._retry_after(response), ) token_response = response.json() user_claims, id_token_claims = await self._verify_id_token( - token_response, origin_domain, origin_issuer, metadata, options.organization + token_response, origin_domain, origin_issuer, metadata ) state_data = await client._persist_session_from_token_response( @@ -284,12 +307,24 @@ async def _build_magic_link_auth_params( if store_options is None: raise MissingRequiredArgumentError("store_options") + # Auth0 echoes `state` back unvalidated on this flow — it does not + # compare it server-side, and the clicked link's query string can + # overwrite whatever was originally stored. This SDK's single-use, + # state-keyed transaction (below) plus the exact-match, SDK-owned + # `redirect_uri` is therefore the *only* CSRF/authorization-code- + # interception control on magic link; the server provides none. + # Never make `state`/`redirect_uri` caller-overridable. state = PKCE.generate_random_string(32) auth_params["redirect_uri"] = redirect_uri auth_params["response_type"] = "code" auth_params["state"] = state # Magic link is email-only, so the email scope is always appropriate. auth_params.setdefault("scope", DEFAULT_PASSWORDLESS_EMAIL_SCOPE) + # A caller-supplied scope replaces the default wholesale, so `openid` + # is re-injected rather than trusted: without it Auth0 returns no ID + # token, and the callback only demands one when an organization was + # requested — leaving a session with no signature-verified claims. + auth_params["scope"] = self._ensure_openid_scope(auth_params["scope"]) if options.organization: auth_params["organization"] = options.organization @@ -314,6 +349,14 @@ async def _build_magic_link_auth_params( return auth_params + @staticmethod + def _ensure_openid_scope(scope: str) -> str: + """Prepend ``openid`` to a scope string that omits it, preserving order.""" + scopes = scope.split() + if "openid" in scopes: + return scope + return " ".join(["openid", *scopes]) + def _sanitize_caller_auth_params(self, auth_params: Optional[dict[str, Any]]) -> dict[str, Any]: """ Copy caller-supplied auth params, forwarding only allowlisted keys. @@ -347,7 +390,6 @@ async def _verify_id_token( origin_domain: str, origin_issuer: Optional[str], metadata: dict[str, Any], - expected_org: Optional[str], ) -> tuple[UserClaims, dict[str, Any]]: """Verify the ID token from the OTP exchange and return its claims.""" client = self._client @@ -380,13 +422,14 @@ async def _verify_id_token( token_issuer = claims.get("iss", "") if client._normalize_url(token_issuer) != client._normalize_url(origin_issuer): - raise IssuerValidationError( - "ID token issuer mismatch. Ensure your Auth0 domain is configured correctly." + raise PasswordlessVerifyError( + PasswordlessErrorCode.INVALID_ISSUER, + "ID token issuer mismatch. Ensure your Auth0 domain is configured correctly.", + IssuerValidationError( + "ID token issuer mismatch. Ensure your Auth0 domain is configured correctly." + ), ) - if expected_org: - validate_org_claims(claims, expected_org) - return UserClaims.model_validate(claims), claims @staticmethod @@ -397,3 +440,32 @@ def _safe_json(response) -> dict[str, Any]: return data if isinstance(data, dict) else {} except Exception: return {} + + @staticmethod + def _retry_after(response) -> Optional[int]: + """ + Return the ``Retry-After`` delay in seconds, or None when absent or + not an integer count (the HTTP-date form is not interpreted). + """ + raw = response.headers.get("Retry-After") + if raw is None: + return None + try: + return int(raw) + except (TypeError, ValueError): + return None + + @staticmethod + def _raw_text(response) -> Optional[str]: + """ + Return the response body text, truncated, for diagnosing a non-JSON + error body. + + Capped because the body may be an HTML error page, WAF block page, or + proxy dump: it is attached as the exception ``cause`` and reaches any + logger that serializes it, and httpx applies no response-size limit. + """ + try: + return response.text[:_RAW_ERROR_BODY_LIMIT] + except Exception: + return None diff --git a/src/auth0_server_python/auth_types/__init__.py b/src/auth0_server_python/auth_types/__init__.py index 32941c2..3d7b4be 100644 --- a/src/auth0_server_python/auth_types/__init__.py +++ b/src/auth0_server_python/auth_types/__init__.py @@ -17,7 +17,9 @@ # challenge type (e.g. a future webauthn second factor) does not fail closed. OobChannel = Literal["sms", "voice", "auth0", "email"] ChallengeType = Literal["otp", "oob"] -EnrollmentType = Literal["passkey", "email", "phone", "totp", "push-notification", "recovery-code", "password"] +EnrollmentType = Literal[ + "passkey", "email", "phone", "totp", "push-notification", "recovery-code", "password" +] PreferredAuthMethod = Literal["sms", "voice"] # Deprecated public aliases resolved lazily (PEP 562) so access emits a warning @@ -411,6 +413,7 @@ class SessionTransferTokenResult(BaseModel): token_type: Token type as returned by the server (typically "N_A") scope: Granted scopes (if returned) """ + session_transfer_token: str issued_token_type: str expires_in: int @@ -726,6 +729,7 @@ class MfaTokenContext(BaseModel): "prompt", "max_age", "acr_values", + "scope", } ) @@ -798,6 +802,10 @@ class VerifyPasswordlessOtpOptions(BaseModel): match ``connection`` (email -> email, sms -> phone_number). """ + # Unknown keys raise rather than being silently ignored, so a caller + # passing the removed `organization` kwarg is told, not quietly dropped. + model_config = ConfigDict(extra="forbid") + connection: PasswordlessConnection # Public field name mirrors nextjs-auth0's `verificationCode`; sent to # Auth0 as the `otp` form parameter. @@ -806,7 +814,9 @@ class VerifyPasswordlessOtpOptions(BaseModel): phone_number: Optional[str] = None scope: Optional[str] = None audience: Optional[str] = None - organization: Optional[str] = None + # No `organization` field: Auth0 ignores it for the OTP grant (verified + # against auth0-server), so accepting it would silently never succeed. + # Use magic link's `organization` instead. # End-user client IP, relayed to Auth0 as `auth0-forwarded-for` on the OTP # token exchange so brute-force protection keys on the real user, not the # app server. Honored only for confidential clients with "Trust Token diff --git a/src/auth0_server_python/error/__init__.py b/src/auth0_server_python/error/__init__.py index f482a49..3c35613 100644 --- a/src/auth0_server_python/error/__init__.py +++ b/src/auth0_server_python/error/__init__.py @@ -39,8 +39,13 @@ def __init__(self, code: str, message: str, cause=None): self.code = code self.cause = cause - # Extract additional error details if available - if cause: + # Extract additional error details if available. A dict cause (the + # raw Auth0 error body) has no attributes for getattr to read, so it + # is handled separately rather than yielding None for every subclass. + if isinstance(cause, dict): + self.error = cause.get("error") + self.error_description = cause.get("error_description") + elif cause: self.error = getattr(cause, "error", None) self.error_description = getattr(cause, "error_description", None) else: @@ -368,29 +373,27 @@ class PasswordlessError(ApiError): strings. """ - def __init__(self, code: str, message: str, cause=None): + def __init__(self, code: str, message: str, cause=None, retry_after: Optional[int] = None): super().__init__(code, message, cause) self.name = "PasswordlessError" - # When cause is the raw error dict from Auth0, surface its fields even - # though ApiError only reads attributes off exception-like causes. - if isinstance(cause, dict): - self.error = cause.get("error") - self.error_description = cause.get("error_description") + # Seconds to wait before retrying, from the Retry-After response header + # on a 429. None when the response carried no usable value. + self.retry_after = retry_after class PasswordlessStartError(PasswordlessError): """Error raised when POST /passwordless/start fails.""" - def __init__(self, code: str, message: str, cause=None): - super().__init__(code, message, cause) + def __init__(self, code: str, message: str, cause=None, retry_after: Optional[int] = None): + super().__init__(code, message, cause, retry_after) self.name = "PasswordlessStartError" class PasswordlessVerifyError(PasswordlessError): """Error raised when the passwordless OTP token exchange fails.""" - def __init__(self, code: str, message: str, cause=None): - super().__init__(code, message, cause) + def __init__(self, code: str, message: str, cause=None, retry_after: Optional[int] = None): + super().__init__(code, message, cause, retry_after) self.name = "PasswordlessVerifyError" @@ -416,10 +419,12 @@ class PasswordlessErrorCode: # Passkey Error Classes # ============================================================================= + class PasskeyError(Auth0Error): """ Error raised during passkey authentication operations. """ + def __init__(self, code: str, message: str, cause=None): super().__init__(message) self.code = code @@ -429,6 +434,7 @@ def __init__(self, code: str, message: str, cause=None): class PasskeyErrorCode: """Error codes for passkey operations.""" + CHALLENGE_FAILED = "passkey_challenge_error" TOKEN_EXCHANGE_FAILED = "passkey_token_error" INVALID_RESPONSE = "invalid_response" diff --git a/src/auth0_server_python/tests/test_passwordless_client.py b/src/auth0_server_python/tests/test_passwordless_client.py index 1e8a49b..d9f9059 100644 --- a/src/auth0_server_python/tests/test_passwordless_client.py +++ b/src/auth0_server_python/tests/test_passwordless_client.py @@ -21,6 +21,7 @@ from auth0_server_python.error import ( InvalidArgumentError, IssuerValidationError, + MfaRequiredError, MissingRequiredArgumentError, PasswordlessStartError, PasswordlessVerifyError, @@ -144,6 +145,60 @@ async def test_sms_e164_rejected_at_model(self): with pytest.raises(ValidationError): StartPasswordlessSmsOptions(phone_number="4155550100") + @pytest.mark.asyncio + async def test_start_rate_limited_maps_to_too_many_requests(self): + client = _make_client() + _mock_http(client, 429, {}) + + with pytest.raises(PasswordlessStartError) as exc: + await client.passwordless.start( + StartPasswordlessEmailOptions(email="user@example.com", send="code") + ) + assert exc.value.code == "too_many_requests" + + @pytest.mark.asyncio + async def test_start_rate_limited_captures_retry_after(self): + client = _make_client() + http = _mock_http(client, 429, {}) + http.post.return_value.headers = {"Retry-After": "42"} + + with pytest.raises(PasswordlessStartError) as exc: + await client.passwordless.start( + StartPasswordlessEmailOptions(email="user@example.com", send="code") + ) + assert exc.value.retry_after == 42 + + @pytest.mark.asyncio + async def test_start_retry_after_http_date_is_not_interpreted(self): + # The HTTP-date form is valid per RFC 9110 but is not parsed; callers + # get None rather than a bogus delay. + client = _make_client() + http = _mock_http(client, 429, {}) + http.post.return_value.headers = {"Retry-After": "Wed, 21 Oct 2026 07:28:00 GMT"} + + with pytest.raises(PasswordlessStartError) as exc: + await client.passwordless.start( + StartPasswordlessEmailOptions(email="user@example.com", send="code") + ) + assert exc.value.retry_after is None + + @pytest.mark.asyncio + async def test_start_non_json_error_body_is_capped(self): + # A WAF block page / proxy dump becomes the exception cause, so it must + # be truncated before it reaches any logger that serializes it. + client = _make_client() + http = _mock_http(client, 502, {}) + http.post.return_value.json = MagicMock(side_effect=ValueError("not json")) + http.post.return_value.text = "" + ("A" * 10_000) + "" + + with pytest.raises(PasswordlessStartError) as exc: + await client.passwordless.start( + StartPasswordlessEmailOptions(email="user@example.com", send="code") + ) + assert exc.value.code == "passwordless_start_failed" + assert isinstance(exc.value.cause, str) + assert len(exc.value.cause) == 2048 + # ── start(): Magic link ────────────────────────────────────────────────────── @@ -279,6 +334,92 @@ async def test_client_ip_forwarded_on_start(self): headers = http.post.call_args.kwargs["headers"] assert headers["auth0-forwarded-for"] == "203.0.113.7" + @pytest.mark.asyncio + async def test_caller_scope_forwarded_on_magic_link(self): + client = _make_client() + http = _mock_http(client, 200, {}) + + await client.passwordless.start( + StartPasswordlessEmailOptions( + email="user@example.com", + send="link", + auth_params={"scope": "openid profile email offline_access read:orders"}, + ), + store_options={}, + ) + ap = http.post.call_args.kwargs["json"]["authParams"] + assert ap["scope"] == "openid profile email offline_access read:orders" + + @pytest.mark.asyncio + async def test_magic_link_default_scope_applies_when_caller_omits_it(self): + client = _make_client() + http = _mock_http(client, 200, {}) + + await client.passwordless.start( + StartPasswordlessEmailOptions( + email="user@example.com", + send="link", + auth_params={"login_hint": "user@example.com"}, + ), + store_options={}, + ) + ap = http.post.call_args.kwargs["json"]["authParams"] + assert ap["scope"] == "openid profile email" + + @pytest.mark.asyncio + async def test_magic_link_organization_reaches_auth_params_and_transaction(self): + client = _make_client() + http = _mock_http(client, 200, {}) + + await client.passwordless.start( + StartPasswordlessEmailOptions( + email="user@example.com", + send="link", + organization="org_abc123", + ), + store_options={}, + ) + ap = http.post.call_args.kwargs["json"]["authParams"] + assert ap["organization"] == "org_abc123" + + tx_data = client._transaction_store.set.await_args.args[1] + assert tx_data.organization == "org_abc123" + + @pytest.mark.asyncio + async def test_magic_link_injects_openid_when_caller_scope_omits_it(self): + # Without `openid` Auth0 returns no ID token, and the callback only + # demands one when an organization was requested — so the session would + # be built from unverified claims. Inject rather than trust the caller. + client = _make_client() + http = _mock_http(client, 200, {}) + + await client.passwordless.start( + StartPasswordlessEmailOptions( + email="user@example.com", + send="link", + auth_params={"scope": "profile email"}, + ), + store_options={}, + ) + ap = http.post.call_args.kwargs["json"]["authParams"] + assert ap["scope"] == "openid profile email" + + @pytest.mark.asyncio + async def test_magic_link_openid_not_duplicated_or_reordered(self): + client = _make_client() + http = _mock_http(client, 200, {}) + + await client.passwordless.start( + StartPasswordlessEmailOptions( + email="user@example.com", + send="link", + auth_params={"scope": "profile openid read:orders"}, + ), + store_options={}, + ) + ap = http.post.call_args.kwargs["json"]["authParams"] + assert ap["scope"] == "profile openid read:orders" + # ── Magic link callback completion (complete_interactive_login) ────────────── @@ -455,12 +596,14 @@ async def test_verify_issuer_mismatch_rejected(self, mocker): self._patch_verify_deps(client, mocker, claims) _mock_http(client, 200, {"access_token": "at", "id_token": "idt", "expires_in": 3600}) - with pytest.raises(IssuerValidationError): + with pytest.raises(PasswordlessVerifyError) as exc: await client.passwordless.verify( VerifyPasswordlessOtpOptions( connection="email", email="user@example.com", verification_code="123456" ) ) + assert exc.value.code == "invalid_issuer" + assert isinstance(exc.value.cause, IssuerValidationError) client._state_store.set.assert_not_awaited() @pytest.mark.asyncio @@ -510,3 +653,113 @@ async def test_verify_ceiling_in_past_rejected(self, mocker): ) ) client._state_store.set.assert_not_awaited() + + @pytest.mark.asyncio + async def test_verify_rate_limited_maps_to_too_many_requests(self, mocker): + client = _make_client() + mocker.patch.object(client, "_get_oidc_metadata_cached", return_value=METADATA) + _mock_http(client, 429, {}) + + with pytest.raises(PasswordlessVerifyError) as exc: + await client.passwordless.verify( + VerifyPasswordlessOtpOptions( + connection="email", email="user@example.com", verification_code="123456" + ) + ) + assert exc.value.code == "too_many_requests" + client._state_store.set.assert_not_awaited() + + @pytest.mark.asyncio + async def test_verify_rate_limited_captures_retry_after(self, mocker): + client = _make_client() + mocker.patch.object(client, "_get_oidc_metadata_cached", return_value=METADATA) + http = _mock_http(client, 429, {}) + http.post.return_value.headers = {"Retry-After": "17"} + + with pytest.raises(PasswordlessVerifyError) as exc: + await client.passwordless.verify( + VerifyPasswordlessOtpOptions( + connection="email", email="user@example.com", verification_code="123456" + ) + ) + assert exc.value.retry_after == 17 + + @pytest.mark.asyncio + async def test_verify_non_json_error_body_is_capped(self, mocker): + client = _make_client() + mocker.patch.object(client, "_get_oidc_metadata_cached", return_value=METADATA) + http = _mock_http(client, 502, {}) + http.post.return_value.json = MagicMock(side_effect=ValueError("not json")) + http.post.return_value.text = "B" * 10_000 + + with pytest.raises(PasswordlessVerifyError) as exc: + await client.passwordless.verify( + VerifyPasswordlessOtpOptions( + connection="email", email="user@example.com", verification_code="123456" + ) + ) + assert exc.value.code == "invalid_grant" + assert isinstance(exc.value.cause, str) + assert len(exc.value.cause) == 2048 + client._state_store.set.assert_not_awaited() + + @pytest.mark.asyncio + async def test_verify_options_reject_unknown_field(self): + # `organization` is not supported on the OTP grant. Rejecting it at the + # model tells the caller instead of silently dropping the kwarg. + with pytest.raises(ValidationError): + VerifyPasswordlessOtpOptions( + connection="email", + email="user@example.com", + verification_code="123456", + organization="org_abc123", + ) + + @pytest.mark.asyncio + async def test_verify_mfa_required_raises_typed_error(self, mocker): + client = _make_client() + mocker.patch.object(client, "_get_oidc_metadata_cached", return_value=METADATA) + _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" + ) + ) + # Token is re-encrypted by the SDK, never passed through raw. + assert exc.value.mfa_token is not None + assert exc.value.mfa_token != "raw_server_mfa_token" + decrypted = client._mfa_client.decrypt_mfa_token(exc.value.mfa_token) + assert decrypted.mfa_token == "raw_server_mfa_token" + client._state_store.set.assert_not_awaited() + + @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 + # no mfa_token. Must fall through to the generic typed error, not hang + # or raise an unrelated exception. + client = _make_client() + mocker.patch.object(client, "_get_oidc_metadata_cached", return_value=METADATA) + _mock_http( + client, + 403, + {"error": "mfa_required", "error_description": "MFA required"}, + ) + + with pytest.raises(PasswordlessVerifyError) as exc: + await client.passwordless.verify( + VerifyPasswordlessOtpOptions( + connection="email", email="user@example.com", verification_code="123456" + ) + ) + assert exc.value.code == "mfa_required" + client._state_store.set.assert_not_awaited() From b23d9f287803c6494ea9b8331e8d21c73c519489 Mon Sep 17 00:00:00 2001 From: Sourav Basu Date: Sat, 8 Aug 2026 08:54:48 +0530 Subject: [PATCH 05/14] Docs update --- README.md | 2 +- examples/Passwordless.md | 28 ----- .../auth_server/passwordless_client.py | 64 +++++----- .../auth_types/__init__.py | 14 +-- .../tests/test_passwordless_client.py | 115 ++++++++++++++++-- 5 files changed, 147 insertions(+), 76 deletions(-) diff --git a/README.md b/README.md index 9ce3dae..f78c731 100644 --- a/README.md +++ b/README.md @@ -202,7 +202,7 @@ Bind tokens to a key your server holds ([RFC 9449](https://www.rfc-editor.org/rf ### 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, organizations, step-up MFA, and error handling, see [examples/Passwordless.md](examples/Passwordless.md). +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 diff --git a/examples/Passwordless.md b/examples/Passwordless.md index bd102f1..e25adbc 100644 --- a/examples/Passwordless.md +++ b/examples/Passwordless.md @@ -17,7 +17,6 @@ Passwordless lets users sign in with a one-time code sent by email or SMS, or wi - [3. Email magic link](#3-email-magic-link) - [4. Custom scopes and audiences](#4-custom-scopes-and-audiences) - [5. Forwarding the end-user IP](#5-forwarding-the-end-user-ip) -- [6. Organizations (magic link only)](#6-organizations-magic-link-only) - [Completing MFA during passwordless login](#completing-mfa-during-passwordless-login) - [Error Handling](#error-handling) @@ -254,32 +253,6 @@ result = await server_client.passwordless.verify( > [!WARNING] > Only forward a trusted, normalized end-user IP from your edge/proxy layer. Do not blindly copy arbitrary client-supplied headers into `client_ip`. -## 6. Organizations (magic link only) - -Magic links can carry an organization through `authParams`; the SDK stores the expected organization in the transaction and validates the claims returned by the callback. - -```python -await server_client.passwordless.start( - StartPasswordlessEmailOptions( - email="user@example.com", - send="link", - organization="org_abc123", - ), - store_options={"request": request, "response": response}, -) -``` - -If the callback's ID token does not include a matching organization claim, verification fails before a session is persisted, raising `OrganizationTokenValidationError`. - -> [!NOTE] -> `VerifyPasswordlessOtpOptions` (the OTP `verify()` path) has no `organization` -> field. Auth0 does not attach an organization claim to tokens issued by the -> passwordless-OTP grant, so there is nothing for the SDK to validate against -> — an OTP flow that needs organization-scoped login should use magic link -> instead. The model rejects unknown fields, so passing `organization` to -> `verify()` raises a pydantic `ValidationError` rather than being silently -> dropped. - ## 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. @@ -328,7 +301,6 @@ Passwordless methods raise typed SDK errors: - `MfaRequiredError` - Auth0 requires MFA before completing login - `MissingRequiredArgumentError` - required SDK input is missing, such as magic-link `store_options` - `InvalidArgumentError` - caller input is rejected before a network call -- `OrganizationTokenValidationError` - magic-link callback only: requested organization does not match the returned token claims ### Basic handling diff --git a/src/auth0_server_python/auth_server/passwordless_client.py b/src/auth0_server_python/auth_server/passwordless_client.py index 01c7a41..d5ba80e 100644 --- a/src/auth0_server_python/auth_server/passwordless_client.py +++ b/src/auth0_server_python/auth_server/passwordless_client.py @@ -87,10 +87,11 @@ async def start( Raises: PasswordlessStartError: When ``POST /passwordless/start`` fails. - InvalidArgumentError: When caller ``auth_params`` attempts to - override an SDK-owned parameter. + InvalidArgumentError: When ``options`` is not a recognized type, or + caller ``auth_params`` contains an SDK-owned or unrecognized key. MissingRequiredArgumentError: When a magic link is requested but no - ``redirect_uri`` is configured on the client. + ``redirect_uri`` is configured on the client, or ``store_options`` + is not provided. """ client = self._client origin_domain = await client._resolve_current_domain(store_options) @@ -119,10 +120,12 @@ async def start( isinstance(options, StartPasswordlessEmailOptions) and options.send == "link" ) + magic_link_transaction = None if is_magic_link: - body["authParams"] = await self._build_magic_link_auth_params( + auth_params, magic_link_transaction = self._prepare_magic_link_start( options, origin_domain, store_options ) + body["authParams"] = auth_params elif options.auth_params: # OTP flows: forward safe passthrough params only. body["authParams"] = self._sanitize_caller_auth_params(options.auth_params) @@ -160,6 +163,15 @@ async def start( self._retry_after(response), ) + if magic_link_transaction is not None: + tx_key, transaction_data = magic_link_transaction + await client._transaction_store.set( + tx_key, + transaction_data, + remove_if_expires=True, + options=store_options, + ) + return PasswordlessStartResult(**self._safe_json(response)) # ----------------------------------------------------------------- verify @@ -187,6 +199,9 @@ async def verify( PasswordlessVerifyError: When token exchange or ID-token verification fails. MfaRequiredError: When Auth0 requires MFA before completing login. + ApiError: When fetching the JWKS used to verify the ID token fails. + SessionExpiredError: When the token's session-expiry ceiling is + already in the past. """ client = self._client origin_domain = await client._resolve_current_domain(store_options) @@ -282,17 +297,24 @@ async def verify( # ------------------------------------------------------------- internals - async def _build_magic_link_auth_params( + def _prepare_magic_link_start( self, options: StartPasswordlessEmailOptions, origin_domain: str, store_options: Optional[dict[str, Any]], - ) -> dict[str, Any]: + ) -> tuple[dict[str, Any], tuple[str, TransactionData]]: """ - Build the magic-link ``authParams`` and persist the transaction. + Build the magic-link ``authParams`` and the transaction to persist. The SDK owns ``redirect_uri`` / ``response_type`` / ``state``; caller ``auth_params`` may only contribute non-reserved passthrough keys. + Persisting the transaction is the caller's job, deferred until after + ``POST /passwordless/start`` succeeds — a failed start must not leave a + transaction (and its cookie) behind. + + Raises: + MissingRequiredArgumentError: When no ``redirect_uri`` is configured + on the client, or ``store_options`` is not provided. """ client = self._client @@ -302,15 +324,13 @@ async def _build_magic_link_auth_params( auth_params = self._sanitize_caller_auth_params(options.auth_params) - # Required to persist the transaction cookie; checked after input - # validation so bad auth_params / missing redirect_uri surface first. if store_options is None: raise MissingRequiredArgumentError("store_options") # Auth0 echoes `state` back unvalidated on this flow — it does not # compare it server-side, and the clicked link's query string can # overwrite whatever was originally stored. This SDK's single-use, - # state-keyed transaction (below) plus the exact-match, SDK-owned + # state-keyed transaction plus the exact-match, SDK-owned # `redirect_uri` is therefore the *only* CSRF/authorization-code- # interception control on magic link; the server provides none. # Never make `state`/`redirect_uri` caller-overridable. @@ -318,36 +338,22 @@ async def _build_magic_link_auth_params( auth_params["redirect_uri"] = redirect_uri auth_params["response_type"] = "code" auth_params["state"] = state - # Magic link is email-only, so the email scope is always appropriate. auth_params.setdefault("scope", DEFAULT_PASSWORDLESS_EMAIL_SCOPE) # A caller-supplied scope replaces the default wholesale, so `openid` # is re-injected rather than trusted: without it Auth0 returns no ID - # token, and the callback only demands one when an organization was - # requested — leaving a session with no signature-verified claims. + # token and the callback never demands one, leaving a session with no + # signature-verified claims. auth_params["scope"] = self._ensure_openid_scope(auth_params["scope"]) - if options.organization: - auth_params["organization"] = options.organization - - # Magic link uses a plain authorization-code exchange (no PKCE), so the - # transaction stores no code_verifier. Single-use is enforced by - # transaction deletion on the callback; remove_if_expires signals the - # store to drop the transaction once expired. Its effective lifetime is - # the store's configured duration, not a fixed value set here. + transaction_data = TransactionData( code_verifier=None, audience=auth_params.get("audience"), redirect_uri=redirect_uri, domain=origin_domain, - organization=options.organization, - ) - await client._transaction_store.set( - f"{client._transaction_identifier}:{state}", - transaction_data, - remove_if_expires=True, - options=store_options, ) + tx_key = f"{client._transaction_identifier}:{state}" - return auth_params + return auth_params, (tx_key, transaction_data) @staticmethod def _ensure_openid_scope(scope: str) -> str: diff --git a/src/auth0_server_python/auth_types/__init__.py b/src/auth0_server_python/auth_types/__init__.py index 3d7b4be..ffd56de 100644 --- a/src/auth0_server_python/auth_types/__init__.py +++ b/src/auth0_server_python/auth_types/__init__.py @@ -700,7 +700,8 @@ class MfaTokenContext(BaseModel): # authParams keys the SDK owns and MUST NOT let a caller override for the # magic-link flow. A caller-controlled redirect_uri/state would allow the # emailed code+state to be redirected to an attacker (authorization-code -# interception); the PKCE/nonce/response_type keys are protocol-controlled. +# interception); the PKCE/nonce/response_type keys are reserved (not set by +# the SDK for magic link, but never caller-overridable either). # Mirrors nextjs-auth0's MAGIC_LINK_EXCLUDED_PARAMS / INTERNAL_AUTHORIZE_PARAMS. # Kept explicit so a rejected override gets a precise "set by the SDK" message. PASSWORDLESS_RESERVED_AUTH_PARAMS = frozenset( @@ -742,14 +743,16 @@ class MfaTokenContext(BaseModel): class _StartPasswordlessBase(BaseModel): """Shared options for starting a passwordless flow.""" + # Unknown keys raise rather than being silently ignored, so a caller + # passing an unsupported kwarg is told, not quietly dropped. + model_config = ConfigDict(extra="forbid") + # BCP 47 tag (e.g. "fr", "en-US"). Forwarded as x-request-language to # localise the email/SMS template. language: Optional[str] = None # Extra params forwarded to /passwordless/start. SDK-owned keys # (PASSWORDLESS_RESERVED_AUTH_PARAMS) are stripped in the client. auth_params: Optional[dict[str, Any]] = None - # Organization id or name; validated against ID token claims on verify. - organization: Optional[str] = None # Attempted solution to a captcha challenge, when the tenant requires one. captcha: Optional[str] = None # End-user client IP, relayed to Auth0 as `auth0-forwarded-for` so brute-force @@ -803,7 +806,7 @@ class VerifyPasswordlessOtpOptions(BaseModel): """ # Unknown keys raise rather than being silently ignored, so a caller - # passing the removed `organization` kwarg is told, not quietly dropped. + # passing an unsupported kwarg is told, not quietly dropped. model_config = ConfigDict(extra="forbid") connection: PasswordlessConnection @@ -814,9 +817,6 @@ class VerifyPasswordlessOtpOptions(BaseModel): phone_number: Optional[str] = None scope: Optional[str] = None audience: Optional[str] = None - # No `organization` field: Auth0 ignores it for the OTP grant (verified - # against auth0-server), so accepting it would silently never succeed. - # Use magic link's `organization` instead. # End-user client IP, relayed to Auth0 as `auth0-forwarded-for` on the OTP # token exchange so brute-force protection keys on the real user, not the # app server. Honored only for confidential clients with "Trust Token diff --git a/src/auth0_server_python/tests/test_passwordless_client.py b/src/auth0_server_python/tests/test_passwordless_client.py index d9f9059..5c2e34f 100644 --- a/src/auth0_server_python/tests/test_passwordless_client.py +++ b/src/auth0_server_python/tests/test_passwordless_client.py @@ -4,6 +4,7 @@ from unittest.mock import AsyncMock, MagicMock +import jwt import pytest from pydantic import ValidationError @@ -231,6 +232,24 @@ async def test_magic_link_sets_sdk_owned_params_and_persists_tx(self): assert tx_data.code_verifier is None assert tx_data.redirect_uri == REDIRECT_URI + @pytest.mark.asyncio + async def test_magic_link_failed_start_does_not_persist_transaction(self): + # A failed POST /passwordless/start must not leave a transaction (and + # its cookie) behind for a magic link Auth0 never sent. + client = _make_client() + _mock_http( + client, + 400, + {"error": "bad.connection", "error_description": "Connection disabled"}, + ) + + with pytest.raises(PasswordlessStartError): + await client.passwordless.start( + StartPasswordlessEmailOptions(email="user@example.com", send="link"), + store_options={}, + ) + client._transaction_store.set.assert_not_awaited() + @pytest.mark.asyncio async def test_magic_link_requires_store_options(self): # No store_options -> transaction cookie can't be persisted -> fail loudly. @@ -351,7 +370,7 @@ async def test_caller_scope_forwarded_on_magic_link(self): assert ap["scope"] == "openid profile email offline_access read:orders" @pytest.mark.asyncio - async def test_magic_link_default_scope_applies_when_caller_omits_it(self): + async def test_caller_audience_forwarded_on_magic_link(self): client = _make_client() http = _mock_http(client, 200, {}) @@ -359,15 +378,18 @@ async def test_magic_link_default_scope_applies_when_caller_omits_it(self): StartPasswordlessEmailOptions( email="user@example.com", send="link", - auth_params={"login_hint": "user@example.com"}, + auth_params={"audience": "https://api.example.com"}, ), store_options={}, ) ap = http.post.call_args.kwargs["json"]["authParams"] - assert ap["scope"] == "openid profile email" + assert ap["audience"] == "https://api.example.com" + + tx_data = client._transaction_store.set.await_args.args[1] + assert tx_data.audience == "https://api.example.com" @pytest.mark.asyncio - async def test_magic_link_organization_reaches_auth_params_and_transaction(self): + async def test_magic_link_default_scope_applies_when_caller_omits_it(self): client = _make_client() http = _mock_http(client, 200, {}) @@ -375,21 +397,45 @@ async def test_magic_link_organization_reaches_auth_params_and_transaction(self) StartPasswordlessEmailOptions( email="user@example.com", send="link", - organization="org_abc123", + auth_params={"login_hint": "user@example.com"}, ), store_options={}, ) ap = http.post.call_args.kwargs["json"]["authParams"] - assert ap["organization"] == "org_abc123" + assert ap["scope"] == "openid profile email" - tx_data = client._transaction_store.set.await_args.args[1] - assert tx_data.organization == "org_abc123" + @pytest.mark.asyncio + async def test_magic_link_rejects_organization_auth_param(self): + # Organizations are not supported on passwordless; the allowlist keeps + # the param from reaching Auth0 as an unvalidated passthrough. + client = _make_client() + _mock_http(client, 200, {}) + + with pytest.raises(InvalidArgumentError): + await client.passwordless.start( + StartPasswordlessEmailOptions( + email="user@example.com", + send="link", + auth_params={"organization": "org_abc123"}, + ), + store_options={}, + ) + client._transaction_store.set.assert_not_awaited() + + @pytest.mark.asyncio + async def test_start_options_reject_organization_field(self): + with pytest.raises(ValidationError): + StartPasswordlessEmailOptions( + email="user@example.com", + send="link", + organization="org_abc123", + ) @pytest.mark.asyncio async def test_magic_link_injects_openid_when_caller_scope_omits_it(self): - # Without `openid` Auth0 returns no ID token, and the callback only - # demands one when an organization was requested — so the session would - # be built from unverified claims. Inject rather than trust the caller. + # Without `openid` Auth0 returns no ID token and the callback never + # demands one, so the session would be built from unverified claims. + # Inject rather than trust the caller. client = _make_client() http = _mock_http(client, 200, {}) @@ -550,6 +596,53 @@ async def test_email_verify_default_scope_includes_email(self, mocker): ) assert http.post.call_args.kwargs["data"]["scope"] == "openid profile email" + @pytest.mark.asyncio + async def test_caller_scope_and_audience_forwarded_on_verify(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", + audience="https://api.example.com", + scope="openid profile email offline_access read:orders", + ) + ) + data = http.post.call_args.kwargs["data"] + assert data["audience"] == "https://api.example.com" + assert data["scope"] == "openid profile email offline_access read:orders" + + @pytest.mark.asyncio + async def test_verify_invalid_audience_maps_to_typed_error(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", + side_effect=jwt.InvalidAudienceError("aud mismatch"), + ) + _mock_http(client, 200, {"access_token": "at", "id_token": "idt", "expires_in": 3600}) + + with pytest.raises(PasswordlessVerifyError) as exc: + await client.passwordless.verify( + VerifyPasswordlessOtpOptions( + connection="email", email="user@example.com", verification_code="123456" + ) + ) + assert exc.value.code == "invalid_audience" + client._state_store.set.assert_not_awaited() + @pytest.mark.asyncio async def test_client_ip_forwarded_on_verify(self, mocker): client = _make_client() From 3a076430a7ef339f3bc338e90212d767638957ce Mon Sep 17 00:00:00 2001 From: Sourav Basu Date: Sat, 8 Aug 2026 10:43:54 +0530 Subject: [PATCH 06/14] fix passwordless MFA session creation --- examples/Passwordless.md | 15 ++--- .../auth_server/mfa_client.py | 16 ++++- .../auth_server/server_client.py | 62 ++++++++++++++++++ .../tests/test_passwordless_client.py | 65 +++++++++++++++++++ 4 files changed, 148 insertions(+), 10 deletions(-) diff --git a/examples/Passwordless.md b/examples/Passwordless.md index e25adbc..b02b573 100644 --- a/examples/Passwordless.md +++ b/examples/Passwordless.md @@ -255,7 +255,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 @@ -277,20 +277,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..cb65dc6 100644 --- a/src/auth0_server_python/auth_server/mfa_client.py +++ b/src/auth0_server_python/auth_server/mfa_client.py @@ -5,7 +5,7 @@ import json import time -from typing import TYPE_CHECKING, Any, Callable, Optional, Union +from typing import TYPE_CHECKING, Any, Awaitable, Callable, Optional, Union import httpx @@ -66,7 +66,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 +83,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 +630,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/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..1a19c8a 100644 --- a/src/auth0_server_python/tests/test_passwordless_client.py +++ b/src/auth0_server_python/tests/test_passwordless_client.py @@ -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 From 042a1723e2b5e4ca67c4506bd03b7fb92a7f9776 Mon Sep 17 00:00:00 2001 From: Sourav Basu Date: Sun, 9 Aug 2026 17:33:38 +0530 Subject: [PATCH 07/14] Lint and doc fixes --- examples/MFA.md | 11 ++++++----- src/auth0_server_python/auth_server/mfa_client.py | 3 ++- 2 files changed, 8 insertions(+), 6 deletions(-) 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/src/auth0_server_python/auth_server/mfa_client.py b/src/auth0_server_python/auth_server/mfa_client.py index cb65dc6..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, Awaitable, Callable, Optional, Union +from collections.abc import Awaitable, Callable +from typing import TYPE_CHECKING, Any, Optional, Union import httpx From 38fee59e260826b8354355f8036f7bfda01cc4b2 Mon Sep 17 00:00:00 2001 From: Sourav Basu Date: Sun, 9 Aug 2026 22:28:30 +0530 Subject: [PATCH 08/14] Org Support removal for passwordless and error fixes --- README.md | 4 + examples/Passwordless.md | 88 ++++-- .../auth_server/__init__.py | 3 +- .../auth_server/passwordless_client.py | 98 ++++++- .../auth_types/__init__.py | 14 +- src/auth0_server_python/error/__init__.py | 30 ++- .../tests/test_passwordless_client.py | 255 +++++++++++++++++- 7 files changed, 441 insertions(+), 51 deletions(-) diff --git a/README.md b/README.md index 93df38f..9ce3dae 100644 --- a/README.md +++ b/README.md @@ -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, organizations, step-up MFA, and error handling, see [examples/Passwordless.md](examples/Passwordless.md). + ## Feedback ### Contributing diff --git a/examples/Passwordless.md b/examples/Passwordless.md index 099c057..bd102f1 100644 --- a/examples/Passwordless.md +++ b/examples/Passwordless.md @@ -17,7 +17,7 @@ Passwordless lets users sign in with a one-time code sent by email or SMS, or wi - [3. Email magic link](#3-email-magic-link) - [4. Custom scopes and audiences](#4-custom-scopes-and-audiences) - [5. Forwarding the end-user IP](#5-forwarding-the-end-user-ip) -- [6. Organizations](#6-organizations) +- [6. Organizations (magic link only)](#6-organizations-magic-link-only) - [Completing MFA during passwordless login](#completing-mfa-during-passwordless-login) - [Error Handling](#error-handling) @@ -32,6 +32,38 @@ OTP start does **not** create a session. The session exists only after `verify() ## Prerequisites +These flows require a **Regular Web Application** — passwordless token exchange +needs a client secret, which a public/SPA client cannot hold safely. + +Two tenant-level settings are also required and are easy to miss because +neither failure mode looks like a configuration problem: + +1. **Authentication Profile must be "Identifier First."** The default + "Universal Login" profile blocks the direct `/oauth/token` call this SDK + uses for OTP verification — without it, OTP `verify()` fails with + `unauthorized_client`. Set it under your tenant's Authentication Profile + settings. (Skip this if you only use passwordless via Universal Login + redirects rather than this SDK's embedded flow.) +2. **Enable the Passwordless OTP grant type** on your application + (**Applications -> Your App -> Advanced Settings -> Grant Types**). Without + it, OTP verification also fails with `unauthorized_client`. +3. **Magic link only** — set the tenant flag + `universal_login.passwordless.allow_magiclink_verify_without_session` to + `true` via the Management API: + + ``` + PATCH /api/v2/tenants/settings + { "universal_login": { "passwordless": { "allow_magiclink_verify_without_session": true } } } + ``` + + This is required for **any** server-side SDK completing magic link (this + one, Express, Next.js, etc.) — the browser that opens the emailed link is + not guaranteed to be the same browser/session that started the flow. + Without it, the user sees: *"The link must be opened on the same device + and browser from which you submitted your email address."* This flag is + not documented in the public Auth0 API reference, so if you don't set it + here you will not discover it from a 400 error message. + ```python from auth0_server_python.auth_server.server_client import ServerClient @@ -152,6 +184,8 @@ user = result["state_data"]["user"] > [!WARNING] > Do not let callers override `redirect_uri`, `state`, `response_type`, `nonce`, or PKCE fields in magic-link `auth_params`. The SDK owns these values so the emailed authorization code and state cannot be redirected to an attacker-controlled URL. +> +> 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. ## 4. Custom scopes and audiences @@ -187,6 +221,8 @@ await server_client.passwordless.start( ) ``` +A caller-supplied magic-link `scope` **replaces** the default wholesale rather than merging with it. The SDK re-injects `openid` when your scope omits it, because the magic-link callback would otherwise complete on a token response carrying no ID token — a session built from claims nothing verified. `openid` is never duplicated and your scope order is preserved otherwise. + > [!NOTE] > `state` is intentionally not a caller-supplied auth parameter in this SDK. If you need app-specific return data, store it server-side against your own transaction/session context instead of putting it into the Auth0 magic-link `state`. @@ -218,9 +254,9 @@ result = await server_client.passwordless.verify( > [!WARNING] > Only forward a trusted, normalized end-user IP from your edge/proxy layer. Do not blindly copy arbitrary client-supplied headers into `client_ip`. -## 6. Organizations +## 6. Organizations (magic link only) -Magic links can carry an organization through `authParams`; the SDK stores the expected organization in the transaction and validates the callback token claims. +Magic links can carry an organization through `authParams`; the SDK stores the expected organization in the transaction and validates the claims returned by the callback. ```python await server_client.passwordless.start( @@ -233,21 +269,16 @@ await server_client.passwordless.start( ) ``` -For OTP verification, pass `organization` only when your Auth0 passwordless OTP configuration returns organization claims for that flow. The SDK validates the ID token's `org_id` or `org_name` claim against the supplied value. - -```python -result = await server_client.passwordless.verify( - VerifyPasswordlessOtpOptions( - connection="email", - email="user@example.com", - verification_code=user_entered_code, - organization="org_abc123", - ), - store_options={"request": request, "response": response}, -) -``` +If the callback's ID token does not include a matching organization claim, verification fails before a session is persisted, raising `OrganizationTokenValidationError`. -If the ID token does not include a matching organization claim, verification fails before a session is persisted. +> [!NOTE] +> `VerifyPasswordlessOtpOptions` (the OTP `verify()` path) has no `organization` +> field. Auth0 does not attach an organization claim to tokens issued by the +> passwordless-OTP grant, so there is nothing for the SDK to validate against +> — an OTP flow that needs organization-scoped login should use magic link +> instead. The model rejects unknown fields, so passing `organization` to +> `verify()` raises a pydantic `ValidationError` rather than being silently +> dropped. ## Completing MFA during passwordless login @@ -293,11 +324,11 @@ except MfaRequiredError as e: Passwordless methods raise typed SDK errors: - `PasswordlessStartError` - `POST /passwordless/start` failed -- `PasswordlessVerifyError` - OTP token exchange or ID-token verification failed +- `PasswordlessVerifyError` - OTP token exchange or ID-token verification failed, including an issuer or audience mismatch (`invalid_issuer` / `invalid_audience`) - `MfaRequiredError` - Auth0 requires MFA before completing login - `MissingRequiredArgumentError` - required SDK input is missing, such as magic-link `store_options` - `InvalidArgumentError` - caller input is rejected before a network call -- `OrganizationTokenValidationError` - requested organization does not match returned token claims +- `OrganizationTokenValidationError` - magic-link callback only: requested organization does not match the returned token claims ### Basic handling @@ -339,20 +370,33 @@ try: except MfaRequiredError as e: return start_mfa(e.mfa_token) except PasswordlessVerifyError as e: - return {"error": e.code, "detail": e.message} + return {"error": e.code, "detail": e.message, "retry_after": e.retry_after} except PasswordlessStartError as e: - return {"error": e.code, "detail": e.message} + return {"error": e.code, "detail": e.message, "retry_after": e.retry_after} except Auth0Error as e: return {"error": str(e)} ``` +`PasswordlessStartError` and `PasswordlessVerifyError` both carry: + +- `code` / `message` - the Auth0 `error` and `error_description`, or an SDK-side code when the response had neither +- `error` / `error_description` - the raw values from a JSON error body +- `retry_after` - seconds from the `Retry-After` response header, typically on a 429. `None` when the header is absent or in HTTP-date form (which the SDK does not interpret) +- `cause` - the parsed JSON error body, or the response text truncated to 2048 characters when the body was not JSON + +> [!WARNING] +> `cause` may hold a raw upstream body (an HTML error page, WAF block page, or proxy dump). It is length-capped, but not redacted — do not log it at a level where untrusted upstream content is unwelcome. + +Because a 429 that carries an explicit Auth0 `error` reports that server code, `code == "too_many_requests"` is not a reliable rate-limit predicate. Branch on `retry_after is not None`, or on the HTTP status if you need certainty. + ### Common error codes (`PasswordlessErrorCode`) - `bad.connection` - the passwordless connection is disabled or invalid - `bad.email` - the email address is invalid or rejected by Auth0 - `sms_provider_error` - Auth0 could not send the SMS -- `too_many_requests` - rate limiting or attack protection blocked the request +- `too_many_requests` - rate limiting or attack protection blocked the request (`start()` or `verify()`) - `invalid_grant` - the OTP is invalid, expired, or already used +- `invalid_issuer` - returned ID token issuer does not match your configured Auth0 domain - `invalid_audience` - returned ID token audience does not match the SDK client - `discovery_error` - the SDK could not load authorization server metadata - `passwordless_start_failed` - SDK-side start failure diff --git a/src/auth0_server_python/auth_server/__init__.py b/src/auth0_server_python/auth_server/__init__.py index 611f6b7..a06ef2e 100644 --- a/src/auth0_server_python/auth_server/__init__.py +++ b/src/auth0_server_python/auth_server/__init__.py @@ -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"] diff --git a/src/auth0_server_python/auth_server/passwordless_client.py b/src/auth0_server_python/auth_server/passwordless_client.py index 119c1da..01c7a41 100644 --- a/src/auth0_server_python/auth_server/passwordless_client.py +++ b/src/auth0_server_python/auth_server/passwordless_client.py @@ -38,7 +38,6 @@ PasswordlessVerifyError, ) from auth0_server_python.utils import PKCE -from auth0_server_python.utils.helpers import validate_org_claims if TYPE_CHECKING: # avoid a circular import at runtime from auth0_server_python.auth_server.server_client import ServerClient @@ -50,6 +49,8 @@ # Header Auth0 reads for the real end-user IP (confidential clients with # "Trust Token Endpoint IP Header" enabled). FORWARDED_FOR_HEADER = "auth0-forwarded-for" +# Cap on a non-JSON error body retained as an exception cause. +_RAW_ERROR_BODY_LIMIT = 2048 class PasswordlessClient: @@ -147,10 +148,16 @@ async def start( if response.status_code not in (200, 201): error_body = self._safe_json(response) + default_code = ( + PasswordlessErrorCode.TOO_MANY_REQUESTS + if response.status_code == 429 + else PasswordlessErrorCode.START_FAILED + ) raise PasswordlessStartError( - error_body.get("error", PasswordlessErrorCode.START_FAILED), + error_body.get("error", default_code), error_body.get("error_description", "Failed to start passwordless flow"), - error_body, + error_body if error_body else self._raw_text(response), + self._retry_after(response), ) return PasswordlessStartResult(**self._safe_json(response)) @@ -179,6 +186,7 @@ async def verify( Raises: PasswordlessVerifyError: When token exchange or ID-token verification fails. + MfaRequiredError: When Auth0 requires MFA before completing login. """ client = self._client origin_domain = await client._resolve_current_domain(store_options) @@ -200,6 +208,7 @@ async def verify( if options.connection == "email" else DEFAULT_PASSWORDLESS_SMS_SCOPE ) + scope = options.scope or default_scope body: dict[str, Any] = { "grant_type": PASSWORDLESS_OTP_GRANT_TYPE, "client_id": client._client_id, @@ -207,7 +216,7 @@ async def verify( "realm": options.connection, "username": options.username, "otp": options.verification_code, - "scope": options.scope or default_scope, + "scope": scope, } if options.audience: body["audience"] = options.audience @@ -232,16 +241,30 @@ async def verify( if response.status_code != 200: error_body = self._safe_json(response) + if error_body.get("error") == "mfa_required" and error_body.get("mfa_token"): + await client._mfa_client._raise_mfa_required( + error_body, + audience=options.audience or client.DEFAULT_AUDIENCE_STATE_KEY, + scope=scope, + default_description="Multifactor authentication required", + store_options=store_options, + ) + default_code = ( + PasswordlessErrorCode.TOO_MANY_REQUESTS + if response.status_code == 429 + else PasswordlessErrorCode.INVALID_GRANT + ) raise PasswordlessVerifyError( - error_body.get("error", PasswordlessErrorCode.INVALID_GRANT), + error_body.get("error", default_code), error_body.get("error_description", "Passwordless verification failed"), - error_body, + error_body if error_body else self._raw_text(response), + self._retry_after(response), ) token_response = response.json() user_claims, id_token_claims = await self._verify_id_token( - token_response, origin_domain, origin_issuer, metadata, options.organization + token_response, origin_domain, origin_issuer, metadata ) state_data = await client._persist_session_from_token_response( @@ -284,12 +307,24 @@ async def _build_magic_link_auth_params( if store_options is None: raise MissingRequiredArgumentError("store_options") + # Auth0 echoes `state` back unvalidated on this flow — it does not + # compare it server-side, and the clicked link's query string can + # overwrite whatever was originally stored. This SDK's single-use, + # state-keyed transaction (below) plus the exact-match, SDK-owned + # `redirect_uri` is therefore the *only* CSRF/authorization-code- + # interception control on magic link; the server provides none. + # Never make `state`/`redirect_uri` caller-overridable. state = PKCE.generate_random_string(32) auth_params["redirect_uri"] = redirect_uri auth_params["response_type"] = "code" auth_params["state"] = state # Magic link is email-only, so the email scope is always appropriate. auth_params.setdefault("scope", DEFAULT_PASSWORDLESS_EMAIL_SCOPE) + # A caller-supplied scope replaces the default wholesale, so `openid` + # is re-injected rather than trusted: without it Auth0 returns no ID + # token, and the callback only demands one when an organization was + # requested — leaving a session with no signature-verified claims. + auth_params["scope"] = self._ensure_openid_scope(auth_params["scope"]) if options.organization: auth_params["organization"] = options.organization @@ -314,6 +349,14 @@ async def _build_magic_link_auth_params( return auth_params + @staticmethod + def _ensure_openid_scope(scope: str) -> str: + """Prepend ``openid`` to a scope string that omits it, preserving order.""" + scopes = scope.split() + if "openid" in scopes: + return scope + return " ".join(["openid", *scopes]) + def _sanitize_caller_auth_params(self, auth_params: Optional[dict[str, Any]]) -> dict[str, Any]: """ Copy caller-supplied auth params, forwarding only allowlisted keys. @@ -347,7 +390,6 @@ async def _verify_id_token( origin_domain: str, origin_issuer: Optional[str], metadata: dict[str, Any], - expected_org: Optional[str], ) -> tuple[UserClaims, dict[str, Any]]: """Verify the ID token from the OTP exchange and return its claims.""" client = self._client @@ -380,13 +422,14 @@ async def _verify_id_token( token_issuer = claims.get("iss", "") if client._normalize_url(token_issuer) != client._normalize_url(origin_issuer): - raise IssuerValidationError( - "ID token issuer mismatch. Ensure your Auth0 domain is configured correctly." + raise PasswordlessVerifyError( + PasswordlessErrorCode.INVALID_ISSUER, + "ID token issuer mismatch. Ensure your Auth0 domain is configured correctly.", + IssuerValidationError( + "ID token issuer mismatch. Ensure your Auth0 domain is configured correctly." + ), ) - if expected_org: - validate_org_claims(claims, expected_org) - return UserClaims.model_validate(claims), claims @staticmethod @@ -397,3 +440,32 @@ def _safe_json(response) -> dict[str, Any]: return data if isinstance(data, dict) else {} except Exception: return {} + + @staticmethod + def _retry_after(response) -> Optional[int]: + """ + Return the ``Retry-After`` delay in seconds, or None when absent or + not an integer count (the HTTP-date form is not interpreted). + """ + raw = response.headers.get("Retry-After") + if raw is None: + return None + try: + return int(raw) + except (TypeError, ValueError): + return None + + @staticmethod + def _raw_text(response) -> Optional[str]: + """ + Return the response body text, truncated, for diagnosing a non-JSON + error body. + + Capped because the body may be an HTML error page, WAF block page, or + proxy dump: it is attached as the exception ``cause`` and reaches any + logger that serializes it, and httpx applies no response-size limit. + """ + try: + return response.text[:_RAW_ERROR_BODY_LIMIT] + except Exception: + return None diff --git a/src/auth0_server_python/auth_types/__init__.py b/src/auth0_server_python/auth_types/__init__.py index 32941c2..3d7b4be 100644 --- a/src/auth0_server_python/auth_types/__init__.py +++ b/src/auth0_server_python/auth_types/__init__.py @@ -17,7 +17,9 @@ # challenge type (e.g. a future webauthn second factor) does not fail closed. OobChannel = Literal["sms", "voice", "auth0", "email"] ChallengeType = Literal["otp", "oob"] -EnrollmentType = Literal["passkey", "email", "phone", "totp", "push-notification", "recovery-code", "password"] +EnrollmentType = Literal[ + "passkey", "email", "phone", "totp", "push-notification", "recovery-code", "password" +] PreferredAuthMethod = Literal["sms", "voice"] # Deprecated public aliases resolved lazily (PEP 562) so access emits a warning @@ -411,6 +413,7 @@ class SessionTransferTokenResult(BaseModel): token_type: Token type as returned by the server (typically "N_A") scope: Granted scopes (if returned) """ + session_transfer_token: str issued_token_type: str expires_in: int @@ -726,6 +729,7 @@ class MfaTokenContext(BaseModel): "prompt", "max_age", "acr_values", + "scope", } ) @@ -798,6 +802,10 @@ class VerifyPasswordlessOtpOptions(BaseModel): match ``connection`` (email -> email, sms -> phone_number). """ + # Unknown keys raise rather than being silently ignored, so a caller + # passing the removed `organization` kwarg is told, not quietly dropped. + model_config = ConfigDict(extra="forbid") + connection: PasswordlessConnection # Public field name mirrors nextjs-auth0's `verificationCode`; sent to # Auth0 as the `otp` form parameter. @@ -806,7 +814,9 @@ class VerifyPasswordlessOtpOptions(BaseModel): phone_number: Optional[str] = None scope: Optional[str] = None audience: Optional[str] = None - organization: Optional[str] = None + # No `organization` field: Auth0 ignores it for the OTP grant (verified + # against auth0-server), so accepting it would silently never succeed. + # Use magic link's `organization` instead. # End-user client IP, relayed to Auth0 as `auth0-forwarded-for` on the OTP # token exchange so brute-force protection keys on the real user, not the # app server. Honored only for confidential clients with "Trust Token diff --git a/src/auth0_server_python/error/__init__.py b/src/auth0_server_python/error/__init__.py index f482a49..3c35613 100644 --- a/src/auth0_server_python/error/__init__.py +++ b/src/auth0_server_python/error/__init__.py @@ -39,8 +39,13 @@ def __init__(self, code: str, message: str, cause=None): self.code = code self.cause = cause - # Extract additional error details if available - if cause: + # Extract additional error details if available. A dict cause (the + # raw Auth0 error body) has no attributes for getattr to read, so it + # is handled separately rather than yielding None for every subclass. + if isinstance(cause, dict): + self.error = cause.get("error") + self.error_description = cause.get("error_description") + elif cause: self.error = getattr(cause, "error", None) self.error_description = getattr(cause, "error_description", None) else: @@ -368,29 +373,27 @@ class PasswordlessError(ApiError): strings. """ - def __init__(self, code: str, message: str, cause=None): + def __init__(self, code: str, message: str, cause=None, retry_after: Optional[int] = None): super().__init__(code, message, cause) self.name = "PasswordlessError" - # When cause is the raw error dict from Auth0, surface its fields even - # though ApiError only reads attributes off exception-like causes. - if isinstance(cause, dict): - self.error = cause.get("error") - self.error_description = cause.get("error_description") + # Seconds to wait before retrying, from the Retry-After response header + # on a 429. None when the response carried no usable value. + self.retry_after = retry_after class PasswordlessStartError(PasswordlessError): """Error raised when POST /passwordless/start fails.""" - def __init__(self, code: str, message: str, cause=None): - super().__init__(code, message, cause) + def __init__(self, code: str, message: str, cause=None, retry_after: Optional[int] = None): + super().__init__(code, message, cause, retry_after) self.name = "PasswordlessStartError" class PasswordlessVerifyError(PasswordlessError): """Error raised when the passwordless OTP token exchange fails.""" - def __init__(self, code: str, message: str, cause=None): - super().__init__(code, message, cause) + def __init__(self, code: str, message: str, cause=None, retry_after: Optional[int] = None): + super().__init__(code, message, cause, retry_after) self.name = "PasswordlessVerifyError" @@ -416,10 +419,12 @@ class PasswordlessErrorCode: # Passkey Error Classes # ============================================================================= + class PasskeyError(Auth0Error): """ Error raised during passkey authentication operations. """ + def __init__(self, code: str, message: str, cause=None): super().__init__(message) self.code = code @@ -429,6 +434,7 @@ def __init__(self, code: str, message: str, cause=None): class PasskeyErrorCode: """Error codes for passkey operations.""" + CHALLENGE_FAILED = "passkey_challenge_error" TOKEN_EXCHANGE_FAILED = "passkey_token_error" INVALID_RESPONSE = "invalid_response" diff --git a/src/auth0_server_python/tests/test_passwordless_client.py b/src/auth0_server_python/tests/test_passwordless_client.py index 1e8a49b..d9f9059 100644 --- a/src/auth0_server_python/tests/test_passwordless_client.py +++ b/src/auth0_server_python/tests/test_passwordless_client.py @@ -21,6 +21,7 @@ from auth0_server_python.error import ( InvalidArgumentError, IssuerValidationError, + MfaRequiredError, MissingRequiredArgumentError, PasswordlessStartError, PasswordlessVerifyError, @@ -144,6 +145,60 @@ async def test_sms_e164_rejected_at_model(self): with pytest.raises(ValidationError): StartPasswordlessSmsOptions(phone_number="4155550100") + @pytest.mark.asyncio + async def test_start_rate_limited_maps_to_too_many_requests(self): + client = _make_client() + _mock_http(client, 429, {}) + + with pytest.raises(PasswordlessStartError) as exc: + await client.passwordless.start( + StartPasswordlessEmailOptions(email="user@example.com", send="code") + ) + assert exc.value.code == "too_many_requests" + + @pytest.mark.asyncio + async def test_start_rate_limited_captures_retry_after(self): + client = _make_client() + http = _mock_http(client, 429, {}) + http.post.return_value.headers = {"Retry-After": "42"} + + with pytest.raises(PasswordlessStartError) as exc: + await client.passwordless.start( + StartPasswordlessEmailOptions(email="user@example.com", send="code") + ) + assert exc.value.retry_after == 42 + + @pytest.mark.asyncio + async def test_start_retry_after_http_date_is_not_interpreted(self): + # The HTTP-date form is valid per RFC 9110 but is not parsed; callers + # get None rather than a bogus delay. + client = _make_client() + http = _mock_http(client, 429, {}) + http.post.return_value.headers = {"Retry-After": "Wed, 21 Oct 2026 07:28:00 GMT"} + + with pytest.raises(PasswordlessStartError) as exc: + await client.passwordless.start( + StartPasswordlessEmailOptions(email="user@example.com", send="code") + ) + assert exc.value.retry_after is None + + @pytest.mark.asyncio + async def test_start_non_json_error_body_is_capped(self): + # A WAF block page / proxy dump becomes the exception cause, so it must + # be truncated before it reaches any logger that serializes it. + client = _make_client() + http = _mock_http(client, 502, {}) + http.post.return_value.json = MagicMock(side_effect=ValueError("not json")) + http.post.return_value.text = "" + ("A" * 10_000) + "" + + with pytest.raises(PasswordlessStartError) as exc: + await client.passwordless.start( + StartPasswordlessEmailOptions(email="user@example.com", send="code") + ) + assert exc.value.code == "passwordless_start_failed" + assert isinstance(exc.value.cause, str) + assert len(exc.value.cause) == 2048 + # ── start(): Magic link ────────────────────────────────────────────────────── @@ -279,6 +334,92 @@ async def test_client_ip_forwarded_on_start(self): headers = http.post.call_args.kwargs["headers"] assert headers["auth0-forwarded-for"] == "203.0.113.7" + @pytest.mark.asyncio + async def test_caller_scope_forwarded_on_magic_link(self): + client = _make_client() + http = _mock_http(client, 200, {}) + + await client.passwordless.start( + StartPasswordlessEmailOptions( + email="user@example.com", + send="link", + auth_params={"scope": "openid profile email offline_access read:orders"}, + ), + store_options={}, + ) + ap = http.post.call_args.kwargs["json"]["authParams"] + assert ap["scope"] == "openid profile email offline_access read:orders" + + @pytest.mark.asyncio + async def test_magic_link_default_scope_applies_when_caller_omits_it(self): + client = _make_client() + http = _mock_http(client, 200, {}) + + await client.passwordless.start( + StartPasswordlessEmailOptions( + email="user@example.com", + send="link", + auth_params={"login_hint": "user@example.com"}, + ), + store_options={}, + ) + ap = http.post.call_args.kwargs["json"]["authParams"] + assert ap["scope"] == "openid profile email" + + @pytest.mark.asyncio + async def test_magic_link_organization_reaches_auth_params_and_transaction(self): + client = _make_client() + http = _mock_http(client, 200, {}) + + await client.passwordless.start( + StartPasswordlessEmailOptions( + email="user@example.com", + send="link", + organization="org_abc123", + ), + store_options={}, + ) + ap = http.post.call_args.kwargs["json"]["authParams"] + assert ap["organization"] == "org_abc123" + + tx_data = client._transaction_store.set.await_args.args[1] + assert tx_data.organization == "org_abc123" + + @pytest.mark.asyncio + async def test_magic_link_injects_openid_when_caller_scope_omits_it(self): + # Without `openid` Auth0 returns no ID token, and the callback only + # demands one when an organization was requested — so the session would + # be built from unverified claims. Inject rather than trust the caller. + client = _make_client() + http = _mock_http(client, 200, {}) + + await client.passwordless.start( + StartPasswordlessEmailOptions( + email="user@example.com", + send="link", + auth_params={"scope": "profile email"}, + ), + store_options={}, + ) + ap = http.post.call_args.kwargs["json"]["authParams"] + assert ap["scope"] == "openid profile email" + + @pytest.mark.asyncio + async def test_magic_link_openid_not_duplicated_or_reordered(self): + client = _make_client() + http = _mock_http(client, 200, {}) + + await client.passwordless.start( + StartPasswordlessEmailOptions( + email="user@example.com", + send="link", + auth_params={"scope": "profile openid read:orders"}, + ), + store_options={}, + ) + ap = http.post.call_args.kwargs["json"]["authParams"] + assert ap["scope"] == "profile openid read:orders" + # ── Magic link callback completion (complete_interactive_login) ────────────── @@ -455,12 +596,14 @@ async def test_verify_issuer_mismatch_rejected(self, mocker): self._patch_verify_deps(client, mocker, claims) _mock_http(client, 200, {"access_token": "at", "id_token": "idt", "expires_in": 3600}) - with pytest.raises(IssuerValidationError): + with pytest.raises(PasswordlessVerifyError) as exc: await client.passwordless.verify( VerifyPasswordlessOtpOptions( connection="email", email="user@example.com", verification_code="123456" ) ) + assert exc.value.code == "invalid_issuer" + assert isinstance(exc.value.cause, IssuerValidationError) client._state_store.set.assert_not_awaited() @pytest.mark.asyncio @@ -510,3 +653,113 @@ async def test_verify_ceiling_in_past_rejected(self, mocker): ) ) client._state_store.set.assert_not_awaited() + + @pytest.mark.asyncio + async def test_verify_rate_limited_maps_to_too_many_requests(self, mocker): + client = _make_client() + mocker.patch.object(client, "_get_oidc_metadata_cached", return_value=METADATA) + _mock_http(client, 429, {}) + + with pytest.raises(PasswordlessVerifyError) as exc: + await client.passwordless.verify( + VerifyPasswordlessOtpOptions( + connection="email", email="user@example.com", verification_code="123456" + ) + ) + assert exc.value.code == "too_many_requests" + client._state_store.set.assert_not_awaited() + + @pytest.mark.asyncio + async def test_verify_rate_limited_captures_retry_after(self, mocker): + client = _make_client() + mocker.patch.object(client, "_get_oidc_metadata_cached", return_value=METADATA) + http = _mock_http(client, 429, {}) + http.post.return_value.headers = {"Retry-After": "17"} + + with pytest.raises(PasswordlessVerifyError) as exc: + await client.passwordless.verify( + VerifyPasswordlessOtpOptions( + connection="email", email="user@example.com", verification_code="123456" + ) + ) + assert exc.value.retry_after == 17 + + @pytest.mark.asyncio + async def test_verify_non_json_error_body_is_capped(self, mocker): + client = _make_client() + mocker.patch.object(client, "_get_oidc_metadata_cached", return_value=METADATA) + http = _mock_http(client, 502, {}) + http.post.return_value.json = MagicMock(side_effect=ValueError("not json")) + http.post.return_value.text = "B" * 10_000 + + with pytest.raises(PasswordlessVerifyError) as exc: + await client.passwordless.verify( + VerifyPasswordlessOtpOptions( + connection="email", email="user@example.com", verification_code="123456" + ) + ) + assert exc.value.code == "invalid_grant" + assert isinstance(exc.value.cause, str) + assert len(exc.value.cause) == 2048 + client._state_store.set.assert_not_awaited() + + @pytest.mark.asyncio + async def test_verify_options_reject_unknown_field(self): + # `organization` is not supported on the OTP grant. Rejecting it at the + # model tells the caller instead of silently dropping the kwarg. + with pytest.raises(ValidationError): + VerifyPasswordlessOtpOptions( + connection="email", + email="user@example.com", + verification_code="123456", + organization="org_abc123", + ) + + @pytest.mark.asyncio + async def test_verify_mfa_required_raises_typed_error(self, mocker): + client = _make_client() + mocker.patch.object(client, "_get_oidc_metadata_cached", return_value=METADATA) + _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" + ) + ) + # Token is re-encrypted by the SDK, never passed through raw. + assert exc.value.mfa_token is not None + assert exc.value.mfa_token != "raw_server_mfa_token" + decrypted = client._mfa_client.decrypt_mfa_token(exc.value.mfa_token) + assert decrypted.mfa_token == "raw_server_mfa_token" + client._state_store.set.assert_not_awaited() + + @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 + # no mfa_token. Must fall through to the generic typed error, not hang + # or raise an unrelated exception. + client = _make_client() + mocker.patch.object(client, "_get_oidc_metadata_cached", return_value=METADATA) + _mock_http( + client, + 403, + {"error": "mfa_required", "error_description": "MFA required"}, + ) + + with pytest.raises(PasswordlessVerifyError) as exc: + await client.passwordless.verify( + VerifyPasswordlessOtpOptions( + connection="email", email="user@example.com", verification_code="123456" + ) + ) + assert exc.value.code == "mfa_required" + client._state_store.set.assert_not_awaited() From 76616296d358974533937823dce475609fa2eedd Mon Sep 17 00:00:00 2001 From: Sourav Basu Date: Sat, 8 Aug 2026 08:54:48 +0530 Subject: [PATCH 09/14] Docs update --- README.md | 2 +- examples/Passwordless.md | 28 ----- .../auth_server/passwordless_client.py | 64 +++++----- .../auth_types/__init__.py | 14 +-- .../tests/test_passwordless_client.py | 115 ++++++++++++++++-- 5 files changed, 147 insertions(+), 76 deletions(-) diff --git a/README.md b/README.md index 9ce3dae..f78c731 100644 --- a/README.md +++ b/README.md @@ -202,7 +202,7 @@ Bind tokens to a key your server holds ([RFC 9449](https://www.rfc-editor.org/rf ### 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, organizations, step-up MFA, and error handling, see [examples/Passwordless.md](examples/Passwordless.md). +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 diff --git a/examples/Passwordless.md b/examples/Passwordless.md index bd102f1..e25adbc 100644 --- a/examples/Passwordless.md +++ b/examples/Passwordless.md @@ -17,7 +17,6 @@ Passwordless lets users sign in with a one-time code sent by email or SMS, or wi - [3. Email magic link](#3-email-magic-link) - [4. Custom scopes and audiences](#4-custom-scopes-and-audiences) - [5. Forwarding the end-user IP](#5-forwarding-the-end-user-ip) -- [6. Organizations (magic link only)](#6-organizations-magic-link-only) - [Completing MFA during passwordless login](#completing-mfa-during-passwordless-login) - [Error Handling](#error-handling) @@ -254,32 +253,6 @@ result = await server_client.passwordless.verify( > [!WARNING] > Only forward a trusted, normalized end-user IP from your edge/proxy layer. Do not blindly copy arbitrary client-supplied headers into `client_ip`. -## 6. Organizations (magic link only) - -Magic links can carry an organization through `authParams`; the SDK stores the expected organization in the transaction and validates the claims returned by the callback. - -```python -await server_client.passwordless.start( - StartPasswordlessEmailOptions( - email="user@example.com", - send="link", - organization="org_abc123", - ), - store_options={"request": request, "response": response}, -) -``` - -If the callback's ID token does not include a matching organization claim, verification fails before a session is persisted, raising `OrganizationTokenValidationError`. - -> [!NOTE] -> `VerifyPasswordlessOtpOptions` (the OTP `verify()` path) has no `organization` -> field. Auth0 does not attach an organization claim to tokens issued by the -> passwordless-OTP grant, so there is nothing for the SDK to validate against -> — an OTP flow that needs organization-scoped login should use magic link -> instead. The model rejects unknown fields, so passing `organization` to -> `verify()` raises a pydantic `ValidationError` rather than being silently -> dropped. - ## 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. @@ -328,7 +301,6 @@ Passwordless methods raise typed SDK errors: - `MfaRequiredError` - Auth0 requires MFA before completing login - `MissingRequiredArgumentError` - required SDK input is missing, such as magic-link `store_options` - `InvalidArgumentError` - caller input is rejected before a network call -- `OrganizationTokenValidationError` - magic-link callback only: requested organization does not match the returned token claims ### Basic handling diff --git a/src/auth0_server_python/auth_server/passwordless_client.py b/src/auth0_server_python/auth_server/passwordless_client.py index 01c7a41..d5ba80e 100644 --- a/src/auth0_server_python/auth_server/passwordless_client.py +++ b/src/auth0_server_python/auth_server/passwordless_client.py @@ -87,10 +87,11 @@ async def start( Raises: PasswordlessStartError: When ``POST /passwordless/start`` fails. - InvalidArgumentError: When caller ``auth_params`` attempts to - override an SDK-owned parameter. + InvalidArgumentError: When ``options`` is not a recognized type, or + caller ``auth_params`` contains an SDK-owned or unrecognized key. MissingRequiredArgumentError: When a magic link is requested but no - ``redirect_uri`` is configured on the client. + ``redirect_uri`` is configured on the client, or ``store_options`` + is not provided. """ client = self._client origin_domain = await client._resolve_current_domain(store_options) @@ -119,10 +120,12 @@ async def start( isinstance(options, StartPasswordlessEmailOptions) and options.send == "link" ) + magic_link_transaction = None if is_magic_link: - body["authParams"] = await self._build_magic_link_auth_params( + auth_params, magic_link_transaction = self._prepare_magic_link_start( options, origin_domain, store_options ) + body["authParams"] = auth_params elif options.auth_params: # OTP flows: forward safe passthrough params only. body["authParams"] = self._sanitize_caller_auth_params(options.auth_params) @@ -160,6 +163,15 @@ async def start( self._retry_after(response), ) + if magic_link_transaction is not None: + tx_key, transaction_data = magic_link_transaction + await client._transaction_store.set( + tx_key, + transaction_data, + remove_if_expires=True, + options=store_options, + ) + return PasswordlessStartResult(**self._safe_json(response)) # ----------------------------------------------------------------- verify @@ -187,6 +199,9 @@ async def verify( PasswordlessVerifyError: When token exchange or ID-token verification fails. MfaRequiredError: When Auth0 requires MFA before completing login. + ApiError: When fetching the JWKS used to verify the ID token fails. + SessionExpiredError: When the token's session-expiry ceiling is + already in the past. """ client = self._client origin_domain = await client._resolve_current_domain(store_options) @@ -282,17 +297,24 @@ async def verify( # ------------------------------------------------------------- internals - async def _build_magic_link_auth_params( + def _prepare_magic_link_start( self, options: StartPasswordlessEmailOptions, origin_domain: str, store_options: Optional[dict[str, Any]], - ) -> dict[str, Any]: + ) -> tuple[dict[str, Any], tuple[str, TransactionData]]: """ - Build the magic-link ``authParams`` and persist the transaction. + Build the magic-link ``authParams`` and the transaction to persist. The SDK owns ``redirect_uri`` / ``response_type`` / ``state``; caller ``auth_params`` may only contribute non-reserved passthrough keys. + Persisting the transaction is the caller's job, deferred until after + ``POST /passwordless/start`` succeeds — a failed start must not leave a + transaction (and its cookie) behind. + + Raises: + MissingRequiredArgumentError: When no ``redirect_uri`` is configured + on the client, or ``store_options`` is not provided. """ client = self._client @@ -302,15 +324,13 @@ async def _build_magic_link_auth_params( auth_params = self._sanitize_caller_auth_params(options.auth_params) - # Required to persist the transaction cookie; checked after input - # validation so bad auth_params / missing redirect_uri surface first. if store_options is None: raise MissingRequiredArgumentError("store_options") # Auth0 echoes `state` back unvalidated on this flow — it does not # compare it server-side, and the clicked link's query string can # overwrite whatever was originally stored. This SDK's single-use, - # state-keyed transaction (below) plus the exact-match, SDK-owned + # state-keyed transaction plus the exact-match, SDK-owned # `redirect_uri` is therefore the *only* CSRF/authorization-code- # interception control on magic link; the server provides none. # Never make `state`/`redirect_uri` caller-overridable. @@ -318,36 +338,22 @@ async def _build_magic_link_auth_params( auth_params["redirect_uri"] = redirect_uri auth_params["response_type"] = "code" auth_params["state"] = state - # Magic link is email-only, so the email scope is always appropriate. auth_params.setdefault("scope", DEFAULT_PASSWORDLESS_EMAIL_SCOPE) # A caller-supplied scope replaces the default wholesale, so `openid` # is re-injected rather than trusted: without it Auth0 returns no ID - # token, and the callback only demands one when an organization was - # requested — leaving a session with no signature-verified claims. + # token and the callback never demands one, leaving a session with no + # signature-verified claims. auth_params["scope"] = self._ensure_openid_scope(auth_params["scope"]) - if options.organization: - auth_params["organization"] = options.organization - - # Magic link uses a plain authorization-code exchange (no PKCE), so the - # transaction stores no code_verifier. Single-use is enforced by - # transaction deletion on the callback; remove_if_expires signals the - # store to drop the transaction once expired. Its effective lifetime is - # the store's configured duration, not a fixed value set here. + transaction_data = TransactionData( code_verifier=None, audience=auth_params.get("audience"), redirect_uri=redirect_uri, domain=origin_domain, - organization=options.organization, - ) - await client._transaction_store.set( - f"{client._transaction_identifier}:{state}", - transaction_data, - remove_if_expires=True, - options=store_options, ) + tx_key = f"{client._transaction_identifier}:{state}" - return auth_params + return auth_params, (tx_key, transaction_data) @staticmethod def _ensure_openid_scope(scope: str) -> str: diff --git a/src/auth0_server_python/auth_types/__init__.py b/src/auth0_server_python/auth_types/__init__.py index 3d7b4be..ffd56de 100644 --- a/src/auth0_server_python/auth_types/__init__.py +++ b/src/auth0_server_python/auth_types/__init__.py @@ -700,7 +700,8 @@ class MfaTokenContext(BaseModel): # authParams keys the SDK owns and MUST NOT let a caller override for the # magic-link flow. A caller-controlled redirect_uri/state would allow the # emailed code+state to be redirected to an attacker (authorization-code -# interception); the PKCE/nonce/response_type keys are protocol-controlled. +# interception); the PKCE/nonce/response_type keys are reserved (not set by +# the SDK for magic link, but never caller-overridable either). # Mirrors nextjs-auth0's MAGIC_LINK_EXCLUDED_PARAMS / INTERNAL_AUTHORIZE_PARAMS. # Kept explicit so a rejected override gets a precise "set by the SDK" message. PASSWORDLESS_RESERVED_AUTH_PARAMS = frozenset( @@ -742,14 +743,16 @@ class MfaTokenContext(BaseModel): class _StartPasswordlessBase(BaseModel): """Shared options for starting a passwordless flow.""" + # Unknown keys raise rather than being silently ignored, so a caller + # passing an unsupported kwarg is told, not quietly dropped. + model_config = ConfigDict(extra="forbid") + # BCP 47 tag (e.g. "fr", "en-US"). Forwarded as x-request-language to # localise the email/SMS template. language: Optional[str] = None # Extra params forwarded to /passwordless/start. SDK-owned keys # (PASSWORDLESS_RESERVED_AUTH_PARAMS) are stripped in the client. auth_params: Optional[dict[str, Any]] = None - # Organization id or name; validated against ID token claims on verify. - organization: Optional[str] = None # Attempted solution to a captcha challenge, when the tenant requires one. captcha: Optional[str] = None # End-user client IP, relayed to Auth0 as `auth0-forwarded-for` so brute-force @@ -803,7 +806,7 @@ class VerifyPasswordlessOtpOptions(BaseModel): """ # Unknown keys raise rather than being silently ignored, so a caller - # passing the removed `organization` kwarg is told, not quietly dropped. + # passing an unsupported kwarg is told, not quietly dropped. model_config = ConfigDict(extra="forbid") connection: PasswordlessConnection @@ -814,9 +817,6 @@ class VerifyPasswordlessOtpOptions(BaseModel): phone_number: Optional[str] = None scope: Optional[str] = None audience: Optional[str] = None - # No `organization` field: Auth0 ignores it for the OTP grant (verified - # against auth0-server), so accepting it would silently never succeed. - # Use magic link's `organization` instead. # End-user client IP, relayed to Auth0 as `auth0-forwarded-for` on the OTP # token exchange so brute-force protection keys on the real user, not the # app server. Honored only for confidential clients with "Trust Token diff --git a/src/auth0_server_python/tests/test_passwordless_client.py b/src/auth0_server_python/tests/test_passwordless_client.py index d9f9059..5c2e34f 100644 --- a/src/auth0_server_python/tests/test_passwordless_client.py +++ b/src/auth0_server_python/tests/test_passwordless_client.py @@ -4,6 +4,7 @@ from unittest.mock import AsyncMock, MagicMock +import jwt import pytest from pydantic import ValidationError @@ -231,6 +232,24 @@ async def test_magic_link_sets_sdk_owned_params_and_persists_tx(self): assert tx_data.code_verifier is None assert tx_data.redirect_uri == REDIRECT_URI + @pytest.mark.asyncio + async def test_magic_link_failed_start_does_not_persist_transaction(self): + # A failed POST /passwordless/start must not leave a transaction (and + # its cookie) behind for a magic link Auth0 never sent. + client = _make_client() + _mock_http( + client, + 400, + {"error": "bad.connection", "error_description": "Connection disabled"}, + ) + + with pytest.raises(PasswordlessStartError): + await client.passwordless.start( + StartPasswordlessEmailOptions(email="user@example.com", send="link"), + store_options={}, + ) + client._transaction_store.set.assert_not_awaited() + @pytest.mark.asyncio async def test_magic_link_requires_store_options(self): # No store_options -> transaction cookie can't be persisted -> fail loudly. @@ -351,7 +370,7 @@ async def test_caller_scope_forwarded_on_magic_link(self): assert ap["scope"] == "openid profile email offline_access read:orders" @pytest.mark.asyncio - async def test_magic_link_default_scope_applies_when_caller_omits_it(self): + async def test_caller_audience_forwarded_on_magic_link(self): client = _make_client() http = _mock_http(client, 200, {}) @@ -359,15 +378,18 @@ async def test_magic_link_default_scope_applies_when_caller_omits_it(self): StartPasswordlessEmailOptions( email="user@example.com", send="link", - auth_params={"login_hint": "user@example.com"}, + auth_params={"audience": "https://api.example.com"}, ), store_options={}, ) ap = http.post.call_args.kwargs["json"]["authParams"] - assert ap["scope"] == "openid profile email" + assert ap["audience"] == "https://api.example.com" + + tx_data = client._transaction_store.set.await_args.args[1] + assert tx_data.audience == "https://api.example.com" @pytest.mark.asyncio - async def test_magic_link_organization_reaches_auth_params_and_transaction(self): + async def test_magic_link_default_scope_applies_when_caller_omits_it(self): client = _make_client() http = _mock_http(client, 200, {}) @@ -375,21 +397,45 @@ async def test_magic_link_organization_reaches_auth_params_and_transaction(self) StartPasswordlessEmailOptions( email="user@example.com", send="link", - organization="org_abc123", + auth_params={"login_hint": "user@example.com"}, ), store_options={}, ) ap = http.post.call_args.kwargs["json"]["authParams"] - assert ap["organization"] == "org_abc123" + assert ap["scope"] == "openid profile email" - tx_data = client._transaction_store.set.await_args.args[1] - assert tx_data.organization == "org_abc123" + @pytest.mark.asyncio + async def test_magic_link_rejects_organization_auth_param(self): + # Organizations are not supported on passwordless; the allowlist keeps + # the param from reaching Auth0 as an unvalidated passthrough. + client = _make_client() + _mock_http(client, 200, {}) + + with pytest.raises(InvalidArgumentError): + await client.passwordless.start( + StartPasswordlessEmailOptions( + email="user@example.com", + send="link", + auth_params={"organization": "org_abc123"}, + ), + store_options={}, + ) + client._transaction_store.set.assert_not_awaited() + + @pytest.mark.asyncio + async def test_start_options_reject_organization_field(self): + with pytest.raises(ValidationError): + StartPasswordlessEmailOptions( + email="user@example.com", + send="link", + organization="org_abc123", + ) @pytest.mark.asyncio async def test_magic_link_injects_openid_when_caller_scope_omits_it(self): - # Without `openid` Auth0 returns no ID token, and the callback only - # demands one when an organization was requested — so the session would - # be built from unverified claims. Inject rather than trust the caller. + # Without `openid` Auth0 returns no ID token and the callback never + # demands one, so the session would be built from unverified claims. + # Inject rather than trust the caller. client = _make_client() http = _mock_http(client, 200, {}) @@ -550,6 +596,53 @@ async def test_email_verify_default_scope_includes_email(self, mocker): ) assert http.post.call_args.kwargs["data"]["scope"] == "openid profile email" + @pytest.mark.asyncio + async def test_caller_scope_and_audience_forwarded_on_verify(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", + audience="https://api.example.com", + scope="openid profile email offline_access read:orders", + ) + ) + data = http.post.call_args.kwargs["data"] + assert data["audience"] == "https://api.example.com" + assert data["scope"] == "openid profile email offline_access read:orders" + + @pytest.mark.asyncio + async def test_verify_invalid_audience_maps_to_typed_error(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", + side_effect=jwt.InvalidAudienceError("aud mismatch"), + ) + _mock_http(client, 200, {"access_token": "at", "id_token": "idt", "expires_in": 3600}) + + with pytest.raises(PasswordlessVerifyError) as exc: + await client.passwordless.verify( + VerifyPasswordlessOtpOptions( + connection="email", email="user@example.com", verification_code="123456" + ) + ) + assert exc.value.code == "invalid_audience" + client._state_store.set.assert_not_awaited() + @pytest.mark.asyncio async def test_client_ip_forwarded_on_verify(self, mocker): client = _make_client() From a84f79e12c474762dbb0ed0238b8be3cee28596a Mon Sep 17 00:00:00 2001 From: Sourav Basu Date: Sun, 9 Aug 2026 23:10:44 +0530 Subject: [PATCH 10/14] Added note on dpop support --- examples/Passwordless.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/examples/Passwordless.md b/examples/Passwordless.md index e25adbc..b190e66 100644 --- a/examples/Passwordless.md +++ b/examples/Passwordless.md @@ -8,6 +8,9 @@ Passwordless lets users sign in with a one-time code sent by email or SMS, or wi > [!IMPORTANT] > These flows are for confidential server-side applications. Tokens stay on the server; the browser should only receive your application's session cookie or opaque session reference. +> [!NOTE] +> **This SDK currently does not support DPoP on passwordless.** Neither `start()` nor `verify()` accepts a `dpop_key`, and tokens issued by the OTP grant or the magic-link callback are always Bearer tokens, never sender-constrained. + ## Table of Contents - [How the flow works](#how-the-flow-works) From 313f52bc79fdc3aafe83fa0d4d2315404ebbfb79 Mon Sep 17 00:00:00 2001 From: Sourav Basu Date: Tue, 11 Aug 2026 15:47:44 +0530 Subject: [PATCH 11/14] Added injection of openid --- examples/Passwordless.md | 5 +++++ .../auth_server/passwordless_client.py | 5 ++++- .../tests/test_passwordless_client.py | 19 +++++++++++++++++++ 3 files changed, 28 insertions(+), 1 deletion(-) diff --git a/examples/Passwordless.md b/examples/Passwordless.md index 5e21c84..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 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/tests/test_passwordless_client.py b/src/auth0_server_python/tests/test_passwordless_client.py index 1a19c8a..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() From 8d487be110fc20faa9549d0d3389c8830f6a0d95 Mon Sep 17 00:00:00 2001 From: Sourav Basu Date: Wed, 12 Aug 2026 14:28:53 +0530 Subject: [PATCH 12/14] fix: distinguish expired ID tokens and make MFA token TTL configurable --- examples/Passwordless.md | 1 + .../auth_server/mfa_client.py | 7 +- .../auth_server/passwordless_client.py | 8 +- .../auth_server/server_client.py | 13 ++- src/auth0_server_python/error/__init__.py | 1 + .../tests/test_mfa_client.py | 29 +++++++ .../tests/test_passwordless_client.py | 80 +++++++++++++++++++ .../tests/test_server_client.py | 25 ++++++ 8 files changed, 161 insertions(+), 3 deletions(-) diff --git a/examples/Passwordless.md b/examples/Passwordless.md index 4a73c3f..0451336 100644 --- a/examples/Passwordless.md +++ b/examples/Passwordless.md @@ -377,6 +377,7 @@ Because a 429 that carries an explicit Auth0 `error` reports that server code, ` - `invalid_grant` - the OTP is invalid, expired, or already used - `invalid_issuer` - returned ID token issuer does not match your configured Auth0 domain - `invalid_audience` - returned ID token audience does not match the SDK client +- `token_expired` - the returned ID token's signature has already expired - `discovery_error` - the SDK could not load authorization server metadata - `passwordless_start_failed` - SDK-side start failure - `passwordless_verify_failed` - SDK-side verify failure diff --git a/src/auth0_server_python/auth_server/mfa_client.py b/src/auth0_server_python/auth_server/mfa_client.py index e0c1864..c9d72db 100644 --- a/src/auth0_server_python/auth_server/mfa_client.py +++ b/src/auth0_server_python/auth_server/mfa_client.py @@ -29,6 +29,7 @@ ) from auth0_server_python.encryption.encrypt import decrypt, encrypt from auth0_server_python.error import ( + ConfigurationError, DomainResolverError, MfaChallengeError, MfaEnrollmentError, @@ -71,6 +72,7 @@ def __init__( session_establisher: Optional[ Callable[..., Awaitable[None]] ] = None, + mfa_token_ttl: int = DEFAULT_MFA_TOKEN_TTL, ): if callable(domain): self._domain = None @@ -85,6 +87,9 @@ def __init__( 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.""" @@ -142,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 diff --git a/src/auth0_server_python/auth_server/passwordless_client.py b/src/auth0_server_python/auth_server/passwordless_client.py index 1232ff2..d7653fa 100644 --- a/src/auth0_server_python/auth_server/passwordless_client.py +++ b/src/auth0_server_python/auth_server/passwordless_client.py @@ -421,8 +421,14 @@ async def _verify_id_token( "ID token audience mismatch. Ensure your client_id is configured correctly.", e, ) + except jwt.ExpiredSignatureError as e: + raise PasswordlessVerifyError( + PasswordlessErrorCode.TOKEN_EXPIRED, + f"ID token has expired: {str(e)}", + e, + ) except jwt.InvalidTokenError as e: - # Covers expired signature, bad signature, and other token defects. + # Covers bad signature and other token defects. raise PasswordlessVerifyError( PasswordlessErrorCode.VERIFY_FAILED, f"ID token verification failed: {str(e)}", diff --git a/src/auth0_server_python/auth_server/server_client.py b/src/auth0_server_python/auth_server/server_client.py index c6995db..54d4814 100644 --- a/src/auth0_server_python/auth_server/server_client.py +++ b/src/auth0_server_python/auth_server/server_client.py @@ -20,7 +20,7 @@ from pydantic import ValidationError from auth0_server_python.auth_schemes.dpop_auth import make_dpop_proof_for_token_endpoint -from auth0_server_python.auth_server.mfa_client import MfaClient +from auth0_server_python.auth_server.mfa_client import DEFAULT_MFA_TOKEN_TTL, MfaClient from auth0_server_python.auth_server.my_account_client import MyAccountClient from auth0_server_python.auth_server.passwordless_client import PasswordlessClient from auth0_server_python.auth_types import ( @@ -125,6 +125,7 @@ def __init__( authorization_params: Optional[dict[str, Any]] = None, pushed_authorization_requests: bool = False, organization: Optional[str] = None, + mfa_token_ttl: int = DEFAULT_MFA_TOKEN_TTL, ): """ Initialize the Auth0 server client. @@ -144,6 +145,13 @@ def __init__( organization: Default organization for all login flows from this client. Can be an org ID (e.g. 'org_abc123') or an org name (e.g. 'acme-corp'). Per-login values passed in StartInteractiveLoginOptions always override this. + mfa_token_ttl: Seconds an encrypted MFA token remains valid before + `mfa.verify()`/`mfa.challenge_authenticator()` reject it as expired. + Defaults to 300 (5 minutes). Increase for authenticator flows that + need more time (e.g. OOB push approval on a slow connection). + + Raises: + ConfigurationError: If `mfa_token_ttl` is not a positive number of seconds. """ if not secret: raise MissingRequiredArgumentError("secret") @@ -216,6 +224,7 @@ def __init__( state_identifier=self._state_identifier, headers=self._telemetry_headers, session_establisher=self._establish_session_from_mfa_verify_response, + mfa_token_ttl=mfa_token_ttl, ) # Initialize Passwordless client (composes this client) @@ -721,6 +730,8 @@ async def _establish_session_from_mfa_verify_response( raise MfaVerifyError( "ID token audience mismatch. Ensure your client_id is configured correctly." ) from e + except jwt.ExpiredSignatureError as e: + raise MfaVerifyError(f"ID token has expired: {str(e)}") from e except jwt.InvalidTokenError as e: raise MfaVerifyError(f"ID token verification failed: {str(e)}") from e diff --git a/src/auth0_server_python/error/__init__.py b/src/auth0_server_python/error/__init__.py index 3c35613..701efc5 100644 --- a/src/auth0_server_python/error/__init__.py +++ b/src/auth0_server_python/error/__init__.py @@ -409,6 +409,7 @@ class PasswordlessErrorCode: INVALID_GRANT = "invalid_grant" INVALID_ISSUER = "invalid_issuer" INVALID_AUDIENCE = "invalid_audience" + TOKEN_EXPIRED = "token_expired" DISCOVERY_ERROR = "discovery_error" # SDK-side START_FAILED = "passwordless_start_failed" diff --git a/src/auth0_server_python/tests/test_mfa_client.py b/src/auth0_server_python/tests/test_mfa_client.py index ac820df..00f9345 100644 --- a/src/auth0_server_python/tests/test_mfa_client.py +++ b/src/auth0_server_python/tests/test_mfa_client.py @@ -18,6 +18,7 @@ OtpEnrollmentResponse, ) from auth0_server_python.error import ( + ConfigurationError, DomainResolverError, MfaChallengeError, MfaEnrollmentError, @@ -195,6 +196,34 @@ def test_decrypt_expired_token_raises(self, mocker): with pytest.raises(MfaTokenExpiredError): client.decrypt_mfa_token(encrypted) + def test_custom_mfa_token_ttl_is_honored(self, mocker): + client = MfaClient( + domain=DOMAIN, + client_id=CLIENT_ID, + client_secret=CLIENT_SECRET, + secret=SECRET, + mfa_token_ttl=10, + ) + mocker.patch("auth0_server_python.auth_server.mfa_client.time.time", return_value=1000) + encrypted = client._encrypt_mfa_token(raw_mfa_token="raw", audience="aud", scope="scope") + + mocker.patch("auth0_server_python.auth_server.mfa_client.time.time", return_value=1005) + assert client.decrypt_mfa_token(encrypted).mfa_token == "raw" + + mocker.patch("auth0_server_python.auth_server.mfa_client.time.time", return_value=1011) + with pytest.raises(MfaTokenExpiredError): + client.decrypt_mfa_token(encrypted) + + def test_non_positive_mfa_token_ttl_rejected(self): + with pytest.raises(ConfigurationError): + MfaClient( + domain=DOMAIN, + client_id=CLIENT_ID, + client_secret=CLIENT_SECRET, + secret=SECRET, + mfa_token_ttl=0, + ) + def test_decrypt_invalid_token_raises(self): client = _make_client() with pytest.raises(MfaTokenInvalidError): diff --git a/src/auth0_server_python/tests/test_passwordless_client.py b/src/auth0_server_python/tests/test_passwordless_client.py index f59ba60..ed0cc0c 100644 --- a/src/auth0_server_python/tests/test_passwordless_client.py +++ b/src/auth0_server_python/tests/test_passwordless_client.py @@ -23,6 +23,7 @@ InvalidArgumentError, IssuerValidationError, MfaRequiredError, + MfaVerifyError, MissingRequiredArgumentError, PasswordlessStartError, PasswordlessVerifyError, @@ -662,6 +663,31 @@ async def test_verify_invalid_audience_maps_to_typed_error(self, mocker): assert exc.value.code == "invalid_audience" client._state_store.set.assert_not_awaited() + @pytest.mark.asyncio + async def test_verify_expired_id_token_maps_to_typed_error(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", + side_effect=jwt.ExpiredSignatureError("signature has expired"), + ) + _mock_http(client, 200, {"access_token": "at", "id_token": "idt", "expires_in": 3600}) + + with pytest.raises(PasswordlessVerifyError) as exc: + await client.passwordless.verify( + VerifyPasswordlessOtpOptions( + connection="email", email="user@example.com", verification_code="123456" + ) + ) + assert exc.value.code == "token_expired" + client._state_store.set.assert_not_awaited() + @pytest.mark.asyncio async def test_client_ip_forwarded_on_verify(self, mocker): client = _make_client() @@ -919,6 +945,60 @@ async def test_passwordless_mfa_verify_persist_creates_session(self, mocker): assert saved_state.internal.sid == "SID-MFA" assert saved_state.token_sets[0].access_token == "mfa_at" + @pytest.mark.asyncio + async def test_mfa_verify_persist_expired_id_token_maps_to_typed_error(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", + side_effect=jwt.ExpiredSignatureError("signature has expired"), + ) + _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) + + with pytest.raises(MfaVerifyError, match="expired"): + await client.mfa.verify( + {"mfa_token": exc.value.mfa_token, "otp": "654321", "persist": True}, + store_options={}, + ) + client._state_store.set.assert_not_awaited() + @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 diff --git a/src/auth0_server_python/tests/test_server_client.py b/src/auth0_server_python/tests/test_server_client.py index 5711b3a..4f2c6fd 100644 --- a/src/auth0_server_python/tests/test_server_client.py +++ b/src/auth0_server_python/tests/test_server_client.py @@ -79,6 +79,31 @@ async def test_init_no_secret_raises(): assert "secret" in str(exc.value) +@pytest.mark.asyncio +async def test_mfa_token_ttl_propagates_to_mfa_client(): + """A custom mfa_token_ttl reaches the internal MfaClient, overriding the default.""" + client = ServerClient( + domain="auth0.local", + client_id="", + client_secret="", + secret="some-secret", + mfa_token_ttl=900, + ) + assert client._mfa_client._mfa_token_ttl == 900 + + +@pytest.mark.asyncio +async def test_mfa_token_ttl_non_positive_rejected(): + with pytest.raises(ConfigurationError): + ServerClient( + domain="auth0.local", + client_id="", + client_secret="", + secret="some-secret", + mfa_token_ttl=-1, + ) + + @pytest.mark.asyncio async def test_start_interactive_login_no_redirect_uri(mocker): client = ServerClient( From 2a54f507765e32dbe6af90383b8aaab334feca3c Mon Sep 17 00:00:00 2001 From: Sourav Basu Date: Fri, 14 Aug 2026 18:20:39 +0530 Subject: [PATCH 13/14] fix: resolved repo conventions on comments --- examples/Passwordless.md | 22 +- .../auth_server/passwordless_client.py | 42 ++- .../auth_server/server_client.py | 267 +++++++------- .../auth_types/__init__.py | 342 +++++++++--------- .../tests/test_passwordless_client.py | 22 +- .../tests/test_server_client.py | 8 - 6 files changed, 348 insertions(+), 355 deletions(-) diff --git a/examples/Passwordless.md b/examples/Passwordless.md index 0451336..0702a6e 100644 --- a/examples/Passwordless.md +++ b/examples/Passwordless.md @@ -6,7 +6,7 @@ Passwordless lets users sign in with a one-time code sent by email or SMS, or wi > Passwordless API flows use Auth0 Legacy Passwordless connections (`email` and `sms`). Enable the **Passwordless OTP** grant for your application under **Applications -> Your App -> Advanced Settings -> Grant Types**. See the [Auth0 Passwordless API documentation](https://auth0.com/docs/authenticate/passwordless/implement-login/embedded-login/relevant-api-endpoints). > [!IMPORTANT] -> These flows are for confidential server-side applications. Tokens stay on the server; the browser should only receive your application's session cookie or opaque session reference. +> These flows are for confidential server-side applications. Tokens stay on the server. The browser should only receive your application's session cookie or opaque session reference. > [!NOTE] > **This SDK currently does not support DPoP on passwordless.** Neither `start()` nor `verify()` accepts a `dpop_key`, and tokens issued by the OTP grant or the magic-link callback are always Bearer tokens, never sender-constrained. @@ -30,11 +30,11 @@ Passwordless has two shapes: 1. **OTP code** - `start()` sends a code by email or SMS. Your app collects that code, then `verify()` exchanges it at `/oauth/token` with the passwordless OTP grant and **creates a server-side session**. 2. **Magic link** - `start(send="link")` sends a one-click email link. Auth0 redirects the user back to your callback URL, and your app completes the flow with `complete_interactive_login()`. The callback creates the server-side session. -OTP start does **not** create a session. The session exists only after `verify()` succeeds. Magic-link start writes a transaction so the callback can validate the returned `state`; the session exists only after the callback completes. +OTP start does **not** create a session. The session exists only after `verify()` succeeds. Magic-link start writes a transaction so the callback can validate the returned `state`. The session exists only after the callback completes. ## Prerequisites -These flows require a **Regular Web Application** — passwordless token exchange +These flows require a **Regular Web Application**. Passwordless token exchange needs a client secret, which a public/SPA client cannot hold safely. Two tenant-level settings are also required and are easy to miss because @@ -42,14 +42,14 @@ neither failure mode looks like a configuration problem: 1. **Authentication Profile must be "Identifier First."** The default "Universal Login" profile blocks the direct `/oauth/token` call this SDK - uses for OTP verification — without it, OTP `verify()` fails with + uses for OTP verification. Without it, OTP `verify()` fails with `unauthorized_client`. Set it under your tenant's Authentication Profile settings. (Skip this if you only use passwordless via Universal Login redirects rather than this SDK's embedded flow.) 2. **Enable the Passwordless OTP grant type** on your application (**Applications -> Your App -> Advanced Settings -> Grant Types**). Without it, OTP verification also fails with `unauthorized_client`. -3. **Magic link only** — set the tenant flag +3. **Magic link only.** Set the tenant flag `universal_login.passwordless.allow_magiclink_verify_without_session` to `true` via the Management API: @@ -59,7 +59,7 @@ neither failure mode looks like a configuration problem: ``` This is required for **any** server-side SDK completing magic link (this - one, Express, Next.js, etc.) — the browser that opens the emailed link is + one, Express, Next.js, etc.). The browser that opens the emailed link is not guaranteed to be the same browser/session that started the flow. Without it, the user sees: *"The link must be opened on the same device and browser from which you submitted your email address."* This flag is @@ -146,7 +146,7 @@ result = await server_client.passwordless.verify( ) ``` -By default, email OTP requests `openid profile email`; SMS OTP requests `openid profile` because SMS identities do not have an email claim to satisfy. +By default, email OTP requests `openid profile email`. SMS OTP requests `openid profile` because SMS identities do not have an email claim to satisfy. ## 3. Email magic link @@ -187,10 +187,10 @@ user = result["state_data"]["user"] > [!WARNING] > Do not let callers override `redirect_uri`, `state`, `response_type`, `nonce`, or PKCE fields in magic-link `auth_params`. The SDK owns these values so the emailed authorization code and state cannot be redirected to an attacker-controlled URL. > -> 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. +> 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." +> 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 @@ -228,7 +228,7 @@ await server_client.passwordless.start( ) ``` -A caller-supplied magic-link `scope` **replaces** the default wholesale rather than merging with it. The SDK re-injects `openid` when your scope omits it, because the magic-link callback would otherwise complete on a token response carrying no ID token — a session built from claims nothing verified. `openid` is never duplicated and your scope order is preserved otherwise. +A caller-supplied magic-link `scope` **replaces** the default wholesale rather than merging with it. The SDK re-injects `openid` when your scope omits it, because the magic-link callback would otherwise complete on a token response carrying no ID token, a session built from claims nothing verified. `openid` is never duplicated and your scope order is preserved otherwise. > [!NOTE] > `state` is intentionally not a caller-supplied auth parameter in this SDK. If you need app-specific return data, store it server-side against your own transaction/session context instead of putting it into the Auth0 magic-link `state`. @@ -364,7 +364,7 @@ except Auth0Error as e: - `cause` - the parsed JSON error body, or the response text truncated to 2048 characters when the body was not JSON > [!WARNING] -> `cause` may hold a raw upstream body (an HTML error page, WAF block page, or proxy dump). It is length-capped, but not redacted — do not log it at a level where untrusted upstream content is unwelcome. +> `cause` may hold a raw upstream body (an HTML error page, WAF block page, or proxy dump). It is length-capped, but not redacted. Do not log it at a level where untrusted upstream content is unwelcome. Because a 429 that carries an explicit Auth0 `error` reports that server code, `code == "too_many_requests"` is not a reliable rate-limit predicate. Branch on `retry_after is not None`, or on the HTTP status if you need certainty. diff --git a/src/auth0_server_python/auth_server/passwordless_client.py b/src/auth0_server_python/auth_server/passwordless_client.py index d7653fa..6352bd4 100644 --- a/src/auth0_server_python/auth_server/passwordless_client.py +++ b/src/auth0_server_python/auth_server/passwordless_client.py @@ -4,13 +4,13 @@ Implements embedded passwordless login (Legacy Passwordless connections) for a confidential (Regular Web App) client: -* Email OTP / SMS OTP — ``start()`` sends a code, ``verify()`` exchanges it for +* Email OTP / SMS OTP - ``start()`` sends a code, ``verify()`` exchanges it for tokens via the passwordless-OTP grant and establishes a server-side session. -* Magic link — ``start(send="link")`` emails a one-click link; completion is +* Magic link - ``start(send="link")`` emails a one-click link. Completion is handled by the standard callback (``ServerClient.complete_interactive_login``), not by ``verify()``. -Tokens never leave the server; the browser holds only the opaque session +Tokens never leave the server. The browser holds only the opaque session reference (RWA / BFF posture). """ @@ -39,11 +39,10 @@ ) from auth0_server_python.utils import PKCE -if TYPE_CHECKING: # avoid a circular import at runtime +if TYPE_CHECKING: from auth0_server_python.auth_server.server_client import ServerClient PASSWORDLESS_OTP_GRANT_TYPE = "http://auth0.com/oauth/grant-type/passwordless/otp" -# Email flows request the `email` scope; SMS has no email claim to satisfy. DEFAULT_PASSWORDLESS_EMAIL_SCOPE = "openid profile email" DEFAULT_PASSWORDLESS_SMS_SCOPE = "openid profile" # Header Auth0 reads for the real end-user IP (confidential clients with @@ -79,7 +78,7 @@ async def start( options: ``StartPasswordlessEmailOptions`` or ``StartPasswordlessSmsOptions``. store_options: Options passed to the transaction store (e.g. - request/response) — required for the magic-link flow so the + request/response). Required for the magic-link flow so the transaction cookie can be written. Returns: @@ -127,7 +126,6 @@ async def start( ) body["authParams"] = auth_params elif options.auth_params: - # OTP flows: forward safe passthrough params only. body["authParams"] = self._sanitize_caller_auth_params(options.auth_params) headers = {"Content-Type": "application/json"} @@ -309,11 +307,14 @@ def _prepare_magic_link_start( """ Build the magic-link ``authParams`` and the transaction to persist. - The SDK owns ``redirect_uri`` / ``response_type`` / ``state``; caller - ``auth_params`` may only contribute non-reserved passthrough keys. - Persisting the transaction is the caller's job, deferred until after - ``POST /passwordless/start`` succeeds — a failed start must not leave a - transaction (and its cookie) behind. + Args: + options: Email options for the magic-link flow. + origin_domain: Resolved Auth0 domain, recorded on the transaction. + store_options: Options passed to the transaction store. + + Returns: + The ``authParams`` dict and the ``(key, TransactionData)`` pair for + the caller to persist after start succeeds. Raises: MissingRequiredArgumentError: When no ``redirect_uri`` is configured @@ -330,12 +331,12 @@ def _prepare_magic_link_start( if store_options is None: raise MissingRequiredArgumentError("store_options") - # Auth0 echoes `state` back unvalidated on this flow — it does not + # Auth0 echoes `state` back unvalidated on this flow. It does not # compare it server-side, and the clicked link's query string can # overwrite whatever was originally stored. This SDK's single-use, # state-keyed transaction plus the exact-match, SDK-owned - # `redirect_uri` is therefore the *only* CSRF/authorization-code- - # interception control on magic link; the server provides none. + # `redirect_uri` is therefore the only CSRF and authorization-code- + # interception control on magic link. The server provides none. # Never make `state`/`redirect_uri` caller-overridable. state = PKCE.generate_random_string(32) auth_params["redirect_uri"] = redirect_uri @@ -370,13 +371,14 @@ def _sanitize_caller_auth_params(self, auth_params: Optional[dict[str, Any]]) -> """ Copy caller-supplied auth params, forwarding only allowlisted keys. - Enforced as an allowlist (Global §3): a key outside - ``PASSWORDLESS_ALLOWED_AUTH_PARAMS`` is rejected. SDK-owned keys get a - precise "set by the SDK" message; anything else is reported as - unsupported so a new authorize param cannot pass through silently. + Args: + auth_params: Caller-supplied authorize parameters, or None. + + Returns: + A new dict containing only allowlisted keys. Raises: - InvalidArgumentError: When a reserved or unrecognized param is present. + InvalidArgumentError: When a reserved or unrecognized key is present. """ if not auth_params: return {} diff --git a/src/auth0_server_python/auth_server/server_client.py b/src/auth0_server_python/auth_server/server_client.py index 54d4814..d1ee3f5 100644 --- a/src/auth0_server_python/auth_server/server_client.py +++ b/src/auth0_server_python/auth_server/server_client.py @@ -227,7 +227,6 @@ def __init__( mfa_token_ttl=mfa_token_ttl, ) - # Initialize Passwordless client (composes this client) self._passwordless_client = PasswordlessClient(self) def _get_http_client(self, **kwargs) -> httpx.AsyncClient: @@ -626,133 +625,6 @@ async def start_interactive_login( return auth_url - async def _persist_session_from_token_response( - self, - token_response: dict[str, Any], - user_claims: "UserClaims", - origin_domain: str, - audience: Optional[str], - session_expires_at: Optional[int], - issued_at: Optional[int], - id_token_claims: Optional[dict[str, Any]] = None, - user_info: Optional[dict[str, Any]] = None, - store_options: Optional[dict[str, Any]] = None, - ) -> StateData: - """ - Build and persist a session ``StateData`` from a token endpoint response. - - Shared by the interactive-login callback and the passwordless OTP verify - flow so both derive the session id, enforce the IPSIE ceiling, and write - the state store identically. - - The session ``sid`` is taken from the verified ID token claims (falling - back to userinfo, then a random value) so OIDC back-channel logout — which - matches sessions by ``sid`` — can target sessions created here. - - Raises: - SessionExpiredError: If the session ceiling is already in the past. - """ - # Refuse to persist a session whose ceiling is already in the past. - if State.is_session_ceiling_in_past(session_expires_at, issued_at): - raise SessionExpiredError() - - now = int(time.time()) - token_set = TokenSet( - audience=audience or self.DEFAULT_AUDIENCE_STATE_KEY, - access_token=token_response.get("access_token", ""), - scope=token_response.get("scope", ""), - expires_at=now + token_response.get("expires_in", 3600), - ) - - # Prefer the ID token's `sid` claim, then userinfo, then a random value. - # A random sid would make the session untargetable by back-channel logout. - sid = None - if id_token_claims and id_token_claims.get("sid"): - sid = id_token_claims["sid"] - elif user_info and user_info.get("sid"): - sid = user_info["sid"] - if not sid: - sid = PKCE.generate_random_string(32) - - state_data = StateData( - user=user_claims, - id_token=token_response.get("id_token"), - refresh_token=token_response.get("refresh_token"), - token_sets=[token_set], - domain=origin_domain, - internal={ - "sid": sid, - "created_at": now, - "session_expires_at": session_expires_at, - }, - ) - - await self._state_store.set( - self._state_identifier, state_data, options=store_options - ) - 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.ExpiredSignatureError as e: - raise MfaVerifyError(f"ID token has expired: {str(e)}") 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, @@ -903,9 +775,6 @@ async def complete_interactive_login( ) - # Build + persist the session via the shared helper (enforces the IPSIE - # ceiling and sources `sid` from the ID token claims). On a past ceiling, - # clean up the transaction before surfacing the error. try: state_data = await self._persist_session_from_token_response( token_response=token_response, @@ -936,6 +805,142 @@ async def complete_interactive_login( return result + async def _persist_session_from_token_response( + self, + token_response: dict[str, Any], + user_claims: "UserClaims", + origin_domain: str, + audience: Optional[str], + session_expires_at: Optional[int], + issued_at: Optional[int], + id_token_claims: Optional[dict[str, Any]] = None, + user_info: Optional[dict[str, Any]] = None, + store_options: Optional[dict[str, Any]] = None, + ) -> StateData: + """ + Build and persist a session ``StateData`` from a token endpoint response. + + Args: + token_response: The token endpoint response. + user_claims: Claims parsed from the verified ID token or userinfo. + origin_domain: Resolved Auth0 domain for the session. + audience: Audience for the persisted token set, or None. + session_expires_at: IPSIE session-expiry ceiling (Unix seconds), or None. + issued_at: ID token ``iat``, used to reject an already-past ceiling. + id_token_claims: Verified ID token claims, primary source of the ``sid``. + user_info: Userinfo claims, a fallback source of the ``sid``. + store_options: Options passed to the state store. + + Returns: + The persisted ``StateData``. + + Raises: + SessionExpiredError: If the session ceiling is already in the past. + """ + if State.is_session_ceiling_in_past(session_expires_at, issued_at): + raise SessionExpiredError() + + now = int(time.time()) + token_set = TokenSet( + audience=audience or self.DEFAULT_AUDIENCE_STATE_KEY, + access_token=token_response.get("access_token", ""), + scope=token_response.get("scope", ""), + expires_at=now + token_response.get("expires_in", 3600), + ) + + # A random sid would make the session untargetable by back-channel logout. + sid = None + if id_token_claims and id_token_claims.get("sid"): + sid = id_token_claims["sid"] + elif user_info and user_info.get("sid"): + sid = user_info["sid"] + if not sid: + sid = PKCE.generate_random_string(32) + + state_data = StateData( + user=user_claims, + id_token=token_response.get("id_token"), + refresh_token=token_response.get("refresh_token"), + token_sets=[token_set], + domain=origin_domain, + internal={ + "sid": sid, + "created_at": now, + "session_expires_at": session_expires_at, + }, + ) + + await self._state_store.set( + self._state_identifier, state_data, options=store_options + ) + 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 a first-login MFA flow completes. + + Args: + verify_response: The final MFA verification response. + audience: Audience for the persisted token set. + scope: Scope associated with the token set, or None. + store_options: Options passed to the state store. + + Raises: + MfaVerifyError: When the response has no ID token, or the ID token + fails signature, audience, or issuer validation. + """ + 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.ExpiredSignatureError as e: + raise MfaVerifyError(f"ID token has expired: {str(e)}") 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, + ) + # ============================================================================ # USER SESSION MANAGEMENT # Methods for retrieving user information, session data, and logout operations. diff --git a/src/auth0_server_python/auth_types/__init__.py b/src/auth0_server_python/auth_types/__init__.py index ffd56de..fca8b76 100644 --- a/src/auth0_server_python/auth_types/__init__.py +++ b/src/auth0_server_python/auth_types/__init__.py @@ -143,7 +143,7 @@ class TransactionData(BaseModel): """ audience: Optional[str] = None - # Optional: interactive login sets this for PKCE; the passwordless magic-link + # Interactive login sets this for PKCE. The passwordless magic-link # transaction has no verifier (plain auth-code exchange), so it stays None. code_verifier: Optional[str] = None app_state: Optional[Any] = None @@ -690,179 +690,6 @@ class MfaTokenContext(BaseModel): created_at: int -# ============================================================================= -# Passwordless Types -# ============================================================================= - -# Passwordless connection strategies (Legacy Passwordless connections). -PasswordlessConnection = Literal["email", "sms"] - -# authParams keys the SDK owns and MUST NOT let a caller override for the -# magic-link flow. A caller-controlled redirect_uri/state would allow the -# emailed code+state to be redirected to an attacker (authorization-code -# interception); the PKCE/nonce/response_type keys are reserved (not set by -# the SDK for magic link, but never caller-overridable either). -# Mirrors nextjs-auth0's MAGIC_LINK_EXCLUDED_PARAMS / INTERNAL_AUTHORIZE_PARAMS. -# Kept explicit so a rejected override gets a precise "set by the SDK" message. -PASSWORDLESS_RESERVED_AUTH_PARAMS = frozenset( - { - "client_id", - "client_secret", - "redirect_uri", - "response_type", - "state", - "nonce", - "code_challenge", - "code_challenge_method", - } -) - -# Caller-supplied authParams keys the SDK will forward. This is an allowlist -# (Global §3: allowlists, not denylists) — any key outside it is rejected, so a -# future security-relevant authorize parameter cannot pass through silently on -# an SDK upgrade. Extend deliberately as new safe passthrough params are needed. -PASSWORDLESS_ALLOWED_AUTH_PARAMS = frozenset( - { - "audience", - "login_hint", - "ui_locales", - "screen_hint", - "prompt", - "max_age", - "acr_values", - "scope", - } -) - -# Minimal BCP 47 language tag: primary subtag plus optional subtags. -_BCP47_LANGUAGE_RE = r"^[A-Za-z]{2,3}(-[A-Za-z0-9]{1,8})*$" -# E.164 phone number: '+' followed by up to 15 digits, first digit non-zero. -_E164_RE = re.compile(r"^\+[1-9]\d{1,14}$") - - -class _StartPasswordlessBase(BaseModel): - """Shared options for starting a passwordless flow.""" - - # Unknown keys raise rather than being silently ignored, so a caller - # passing an unsupported kwarg is told, not quietly dropped. - model_config = ConfigDict(extra="forbid") - - # BCP 47 tag (e.g. "fr", "en-US"). Forwarded as x-request-language to - # localise the email/SMS template. - language: Optional[str] = None - # Extra params forwarded to /passwordless/start. SDK-owned keys - # (PASSWORDLESS_RESERVED_AUTH_PARAMS) are stripped in the client. - auth_params: Optional[dict[str, Any]] = None - # Attempted solution to a captcha challenge, when the tenant requires one. - captcha: Optional[str] = None - # End-user client IP, relayed to Auth0 as `auth0-forwarded-for` so brute-force - # and suspicious-IP protection key on the real user, not the app server. Only - # honored for confidential clients with "Trust Token Endpoint IP Header" on. - client_ip: Optional[str] = None - - @field_validator("language") - @classmethod - def _validate_language(cls, value: Optional[str]) -> Optional[str]: - if value is None: - return None - if not re.match(_BCP47_LANGUAGE_RE, value): - raise ValueError("language must be a valid BCP 47 tag (e.g. 'fr', 'en-US')") - return value - - -class StartPasswordlessEmailOptions(_StartPasswordlessBase): - """Options for starting an email passwordless flow (OTP code or magic link).""" - - connection: Literal["email"] = "email" - email: str - # "code" -> email OTP; "link" -> magic link. - send: Literal["code", "link"] = "code" - - -class StartPasswordlessSmsOptions(_StartPasswordlessBase): - """Options for starting an SMS passwordless (OTP) flow.""" - - connection: Literal["sms"] = "sms" - # E.164 format, e.g. "+14155550100". - phone_number: str - - @field_validator("phone_number") - @classmethod - def _validate_phone_number(cls, value: str) -> str: - if not _E164_RE.match(value): - raise ValueError("phone_number must be in E.164 format (e.g. '+14155550100')") - return value - - -StartPasswordlessOptions = Union[StartPasswordlessEmailOptions, StartPasswordlessSmsOptions] - - -class VerifyPasswordlessOtpOptions(BaseModel): - """ - Options for verifying a passwordless OTP and establishing a session. - - Exactly one of ``email`` / ``phone_number`` must be provided and must - match ``connection`` (email -> email, sms -> phone_number). - """ - - # Unknown keys raise rather than being silently ignored, so a caller - # passing an unsupported kwarg is told, not quietly dropped. - model_config = ConfigDict(extra="forbid") - - connection: PasswordlessConnection - # Public field name mirrors nextjs-auth0's `verificationCode`; sent to - # Auth0 as the `otp` form parameter. - verification_code: str - email: Optional[str] = None - phone_number: Optional[str] = None - scope: Optional[str] = None - audience: Optional[str] = None - # End-user client IP, relayed to Auth0 as `auth0-forwarded-for` on the OTP - # token exchange so brute-force protection keys on the real user, not the - # app server. Honored only for confidential clients with "Trust Token - # Endpoint IP Header" enabled. - client_ip: Optional[str] = None - - @field_validator("phone_number") - @classmethod - def _validate_phone_number(cls, value: Optional[str]) -> Optional[str]: - if value is None: - return None - if not _E164_RE.match(value): - raise ValueError("phone_number must be in E.164 format (e.g. '+14155550100')") - return value - - @model_validator(mode="after") - def _validate_identifier(self) -> "VerifyPasswordlessOtpOptions": - if self.connection == "email": - if not self.email: - raise ValueError("email is required when connection='email'") - if self.phone_number: - raise ValueError("phone_number must not be set when connection='email'") - else: # sms - if not self.phone_number: - raise ValueError("phone_number is required when connection='sms'") - if self.email: - raise ValueError("email must not be set when connection='sms'") - return self - - @property - def username(self) -> str: - """The Auth0 `username` value for the OTP grant (email or phone).""" - return self.email if self.connection == "email" else self.phone_number - - -class PasswordlessStartResult(BaseModel): - """Success payload from POST /passwordless/start.""" - - # Auth0 returns the request id as `_id`; alias so `.id` is populated. - id: Optional[str] = Field(default=None, alias="_id") - - class Config: - extra = "allow" # Allow additional fields returned by Auth0 - populate_by_name = True # accept both `_id` (alias) and `id` - - # ============================================================================= # Passkey & MyAccount Authentication Methods Types # ============================================================================= @@ -1031,3 +858,170 @@ class PasskeyTokenResponse(BaseModel): scope: Optional[str] = None id_token: Optional[str] = None refresh_token: Optional[str] = None + + + +# ============================================================================= +# Passwordless Types +# ============================================================================= + +# Passwordless connection strategies (Legacy Passwordless connections). +PasswordlessConnection = Literal["email", "sms"] + +# authParams keys the SDK owns and MUST NOT let a caller override for the +# magic-link flow. A caller-controlled redirect_uri/state would allow the +# emailed code+state to be redirected to an attacker (authorization-code +# interception). The PKCE/nonce/response_type keys are reserved (not set by +# the SDK for magic link, but never caller-overridable either). +PASSWORDLESS_RESERVED_AUTH_PARAMS = frozenset( + { + "client_id", + "client_secret", + "redirect_uri", + "response_type", + "state", + "nonce", + "code_challenge", + "code_challenge_method", + } +) + +# Caller-supplied authParams keys the SDK will forward. This is an allowlist, so +# a key outside it is rejected and a future security-relevant authorize +# parameter cannot pass through silently on an SDK upgrade. Extend deliberately +# as new safe passthrough params are needed. +PASSWORDLESS_ALLOWED_AUTH_PARAMS = frozenset( + { + "audience", + "login_hint", + "ui_locales", + "screen_hint", + "prompt", + "max_age", + "acr_values", + "scope", + } +) + +# Minimal BCP 47 language tag: primary subtag plus optional subtags. +_BCP47_LANGUAGE_RE = r"^[A-Za-z]{2,3}(-[A-Za-z0-9]{1,8})*$" +# E.164 phone number: '+' followed by up to 15 digits, first digit non-zero. +_E164_RE = re.compile(r"^\+[1-9]\d{1,14}$") + + +class _StartPasswordlessBase(BaseModel): + """Shared options for starting a passwordless flow.""" + + model_config = ConfigDict(extra="forbid") + + # BCP 47 tag (e.g. "fr", "en-US"). Forwarded as x-request-language to + # localise the email/SMS template. + language: Optional[str] = None + # Extra params forwarded to /passwordless/start. SDK-owned keys + # (PASSWORDLESS_RESERVED_AUTH_PARAMS) are stripped in the client. + auth_params: Optional[dict[str, Any]] = None + # Attempted solution to a captcha challenge, when the tenant requires one. + captcha: Optional[str] = None + # End-user client IP, relayed to Auth0 as `auth0-forwarded-for` so brute-force + # and suspicious-IP protection key on the real user, not the app server. Only + # honored for confidential clients with "Trust Token Endpoint IP Header" on. + client_ip: Optional[str] = None + + @field_validator("language") + @classmethod + def _validate_language(cls, value: Optional[str]) -> Optional[str]: + if value is None: + return None + if not re.match(_BCP47_LANGUAGE_RE, value): + raise ValueError("language must be a valid BCP 47 tag (e.g. 'fr', 'en-US')") + return value + + +class StartPasswordlessEmailOptions(_StartPasswordlessBase): + """Options for starting an email passwordless flow (OTP code or magic link).""" + + connection: Literal["email"] = "email" + email: str + # "code" sends an email OTP. "link" sends a magic link. + send: Literal["code", "link"] = "code" + + +class StartPasswordlessSmsOptions(_StartPasswordlessBase): + """Options for starting an SMS passwordless (OTP) flow.""" + + connection: Literal["sms"] = "sms" + # E.164 format, e.g. "+14155550100". + phone_number: str + + @field_validator("phone_number") + @classmethod + def _validate_phone_number(cls, value: str) -> str: + if not _E164_RE.match(value): + raise ValueError("phone_number must be in E.164 format (e.g. '+14155550100')") + return value + + +StartPasswordlessOptions = Union[StartPasswordlessEmailOptions, StartPasswordlessSmsOptions] + + +class VerifyPasswordlessOtpOptions(BaseModel): + """ + Options for verifying a passwordless OTP and establishing a session. + + Exactly one of ``email`` / ``phone_number`` must be provided and must + match ``connection`` (email -> email, sms -> phone_number). + """ + + model_config = ConfigDict(extra="forbid") + + connection: PasswordlessConnection + # Sent to Auth0 as the `otp` form parameter. + verification_code: str + email: Optional[str] = None + phone_number: Optional[str] = None + scope: Optional[str] = None + audience: Optional[str] = None + # End-user client IP, relayed to Auth0 as `auth0-forwarded-for` on the OTP + # token exchange so brute-force protection keys on the real user, not the + # app server. Honored only for confidential clients with "Trust Token + # Endpoint IP Header" enabled. + client_ip: Optional[str] = None + + @field_validator("phone_number") + @classmethod + def _validate_phone_number(cls, value: Optional[str]) -> Optional[str]: + if value is None: + return None + if not _E164_RE.match(value): + raise ValueError("phone_number must be in E.164 format (e.g. '+14155550100')") + return value + + @model_validator(mode="after") + def _validate_identifier(self) -> "VerifyPasswordlessOtpOptions": + if self.connection == "email": + if not self.email: + raise ValueError("email is required when connection='email'") + if self.phone_number: + raise ValueError("phone_number must not be set when connection='email'") + else: # sms + if not self.phone_number: + raise ValueError("phone_number is required when connection='sms'") + if self.email: + raise ValueError("email must not be set when connection='sms'") + return self + + @property + def username(self) -> str: + """The Auth0 `username` value for the OTP grant (email or phone).""" + return self.email if self.connection == "email" else self.phone_number + + +class PasswordlessStartResult(BaseModel): + """Success payload from POST /passwordless/start.""" + + # Auth0 returns the request id as `_id`. Aliased so `.id` is populated. + id: Optional[str] = Field(default=None, alias="_id") + + class Config: + extra = "allow" + populate_by_name = True diff --git a/src/auth0_server_python/tests/test_passwordless_client.py b/src/auth0_server_python/tests/test_passwordless_client.py index ed0cc0c..a24bfda 100644 --- a/src/auth0_server_python/tests/test_passwordless_client.py +++ b/src/auth0_server_python/tests/test_passwordless_client.py @@ -1,5 +1,5 @@ """ -Tests for PasswordlessClient — embedded passwordless (OTP + magic link). +Tests for PasswordlessClient - embedded passwordless (OTP + magic link). """ from unittest.mock import AsyncMock, MagicMock @@ -97,7 +97,7 @@ async def test_email_otp_start(self): assert body["client_secret"] == CLIENT_SECRET # OTP flow does not create a transaction. client._transaction_store.set.assert_not_awaited() - # Auth0 returns the request id as `_id`; the model aliases it to `.id`. + # Auth0 returns the request id as `_id`. The model aliases it to `.id`. assert result.id == "req_123" @pytest.mark.asyncio @@ -172,7 +172,7 @@ async def test_start_rate_limited_captures_retry_after(self): @pytest.mark.asyncio async def test_start_retry_after_http_date_is_not_interpreted(self): - # The HTTP-date form is valid per RFC 9110 but is not parsed; callers + # The HTTP-date form is valid per RFC 9110 but is not parsed. Callers # get None rather than a bogus delay. client = _make_client() http = _mock_http(client, 429, {}) @@ -327,7 +327,7 @@ async def test_caller_unrecognized_param_rejected(self): @pytest.mark.asyncio async def test_connection_scope_not_allowed(self): # connection_scope is a federated-connection param with no meaning for - # email/SMS passwordless; it is not in the allowlist and is rejected. + # email/SMS passwordless. It is not in the allowlist and is rejected. client = _make_client() _mock_http(client, 200, {}) @@ -407,7 +407,7 @@ async def test_magic_link_default_scope_applies_when_caller_omits_it(self): @pytest.mark.asyncio async def test_magic_link_rejects_organization_auth_param(self): - # Organizations are not supported on passwordless; the allowlist keeps + # Organizations are not supported on passwordless. The allowlist keeps # the param from reaching Auth0 as an unvalidated passthrough. client = _make_client() _mock_http(client, 200, {}) @@ -475,8 +475,8 @@ class TestMagicLinkCallback: @pytest.mark.asyncio async def test_magic_link_callback_exchanges_code_without_pkce(self, mocker): # Magic link is a plain auth-code exchange: lock that code_verifier=None - # reaches fetch_token (authlib drops the falsy field) so a forced verifier - # — which Auth0 would reject — is caught. + # reaches fetch_token (authlib drops the falsy field) so a forced verifier, + # which Auth0 would reject, is caught. client = _make_client() client._transaction_store.get.return_value = TransactionData( code_verifier=None, @@ -513,7 +513,7 @@ async def test_magic_link_callback_exchanges_code_without_pkce(self, mocker): assert fetch_token.await_args.kwargs["code_verifier"] is None assert fetch_token.await_args.kwargs["code"] == "AUTHCODE" - # Session established; transaction consumed (single-use). + # Session established. Transaction consumed (single-use). client._state_store.set.assert_awaited_once() client._transaction_store.delete.assert_awaited_once() assert result["state_data"]["internal"]["sid"] == "SID-1" @@ -1001,9 +1001,9 @@ async def test_mfa_verify_persist_expired_id_token_maps_to_typed_error(self, moc @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 - # no mfa_token. Must fall through to the generic typed error, not hang - # or raise an unrelated exception. + # Some tenant configurations return 403 mfa_required with no mfa_token. + # Must fall through to the generic typed error, not hang or raise an + # unrelated exception. client = _make_client() mocker.patch.object(client, "_get_oidc_metadata_cached", return_value=METADATA) _mock_http( diff --git a/src/auth0_server_python/tests/test_server_client.py b/src/auth0_server_python/tests/test_server_client.py index 4f2c6fd..fe6594b 100644 --- a/src/auth0_server_python/tests/test_server_client.py +++ b/src/auth0_server_python/tests/test_server_client.py @@ -4799,21 +4799,18 @@ async def test_complete_login_sid_sourced_from_id_token_claim(mocker): secret="test_secret_key_32_chars_long!!", ) - # Mock OIDC metadata mocker.patch.object( client, "_get_oidc_metadata_cached", return_value={"issuer": "https://tenant.auth0.com/", "token_endpoint": "https://tenant.auth0.com/token"} ) - # Mock JWKS fetch mocker.patch.object( client, "_get_jwks_cached", return_value={"keys": [{"kty": "RSA", "kid": "test-key"}]} ) - # Mock OAuth fetch_token (ID-token-only response, no userinfo) async_fetch_token = AsyncMock() async_fetch_token.return_value = { "access_token": "token123", @@ -4821,7 +4818,6 @@ async def test_complete_login_sid_sourced_from_id_token_claim(mocker): } mocker.patch.object(client._oauth, "fetch_token", async_fetch_token) - # Verified claims carry a `sid` mocker.patch.object( client, "_verify_and_decode_jwt", @@ -4856,21 +4852,18 @@ async def test_complete_login_sid_falls_back_to_random_without_claim(mocker): secret="test_secret_key_32_chars_long!!", ) - # Mock OIDC metadata mocker.patch.object( client, "_get_oidc_metadata_cached", return_value={"issuer": "https://tenant.auth0.com/", "token_endpoint": "https://tenant.auth0.com/token"} ) - # Mock JWKS fetch mocker.patch.object( client, "_get_jwks_cached", return_value={"keys": [{"kty": "RSA", "kid": "test-key"}]} ) - # Mock OAuth fetch_token (ID-token-only response, no userinfo) async_fetch_token = AsyncMock() async_fetch_token.return_value = { "access_token": "token123", @@ -4878,7 +4871,6 @@ async def test_complete_login_sid_falls_back_to_random_without_claim(mocker): } mocker.patch.object(client._oauth, "fetch_token", async_fetch_token) - # Verified claims carry NO `sid` mocker.patch.object( client, "_verify_and_decode_jwt", From 1712c383bcfd99a28abf6c80b71b010739ea489f Mon Sep 17 00:00:00 2001 From: Sourav Basu Date: Fri, 14 Aug 2026 18:29:16 +0530 Subject: [PATCH 14/14] chore: removed hyphen to improve comment --- src/auth0_server_python/tests/test_passwordless_client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/auth0_server_python/tests/test_passwordless_client.py b/src/auth0_server_python/tests/test_passwordless_client.py index a24bfda..fca63dd 100644 --- a/src/auth0_server_python/tests/test_passwordless_client.py +++ b/src/auth0_server_python/tests/test_passwordless_client.py @@ -1,5 +1,5 @@ """ -Tests for PasswordlessClient - embedded passwordless (OTP + magic link). +Tests for PasswordlessClient embedded passwordless (OTP + magic link). """ from unittest.mock import AsyncMock, MagicMock