From 53f2c59577c2e807b88b9ec3ac4030038778b218 Mon Sep 17 00:00:00 2001 From: Sourav Basu Date: Sun, 11 Oct 2026 14:06:57 +0530 Subject: [PATCH 01/15] feat: Added OTP identifier verification support in Passkey --- README.md | 2 +- examples/Passkeys.md | 89 ++++- .../auth_server/server_client.py | 32 +- .../auth_types/__init__.py | 3 +- src/auth0_server_python/error/__init__.py | 17 +- src/auth0_server_python/tests/test_error.py | 53 +++ .../tests/test_server_client.py | 324 ++++++++++++++++++ 7 files changed, 514 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index db0b789a..afd65bd3 100644 --- a/README.md +++ b/README.md @@ -242,7 +242,7 @@ For more details and examples, see [examples/RetrievingData.md](examples/Retriev ### 7. Passkey Authentication -Sign users up or in with [WebAuthn](https://www.w3.org/TR/webauthn-2/) passkeys (Touch ID, Face ID, Windows Hello, or a security key) instead of a password, via [Auth0 passkeys](https://auth0.com/docs/authenticate/database-connections/passkeys). The ceremony is two steps — request a challenge, sign it in the browser, then complete sign-in — and establishes a server-side session like every other login path. For the signup and login flows, organizations, step-up MFA, and error handling, see [examples/Passkeys.md](examples/Passkeys.md). +Sign users up or in with [WebAuthn](https://www.w3.org/TR/webauthn-2/) passkeys (Touch ID, Face ID, Windows Hello, or a security key) instead of a password, via [Auth0 passkeys](https://auth0.com/docs/authenticate/database-connections/passkeys). The ceremony is two steps — request a challenge, sign it in the browser, then complete sign-in — and establishes a server-side session like every other login path. For the signup and login flows, organizations, step-up MFA, [verifying an email or phone at signup](examples/Passkeys.md#4-identifier-verification-optional), and error handling, see [examples/Passkeys.md](examples/Passkeys.md). ### 8. My Account API — Authentication Methods diff --git a/examples/Passkeys.md b/examples/Passkeys.md index c1eed9a4..5a9531f4 100644 --- a/examples/Passkeys.md +++ b/examples/Passkeys.md @@ -16,6 +16,7 @@ Passkeys let users sign up and log in with [WebAuthn](https://www.w3.org/TR/weba - [2. Passkey Login](#2-passkey-login) - [3. DPoP-bound passkey tokens (optional)](#3-dpop-bound-passkey-tokens-optional) - [Completing MFA on a passkey login (and where the session comes from)](#completing-mfa-on-a-passkey-login-and-where-the-session-comes-from) +- [4. Identifier verification (optional)](#4-identifier-verification-optional) - [Error Handling](#error-handling) ## How the flow works @@ -69,6 +70,7 @@ challenge = await server_client.passkey_signup_challenge( # Hand these to the browser: # challenge.auth_session -> opaque session credential (Tier 1, do not log) # challenge.authn_params_public_key -> pass to navigator.credentials.create() +# challenge.verification_required -> codes to collect first, when set (see section 4) ``` > [!TIP] @@ -103,6 +105,9 @@ print(f"Signed up and logged in: {user['sub']}") `signin_with_passkey` returns a `PasskeyLoginResult` whose `state_data` holds the user claims and token sets — the same shape as `complete_interactive_login` and `login_with_custom_token_exchange`. The session is persisted to your configured state store. +> [!NOTE] +> If your connection requires users to verify their email or phone at signup, `passkey_signup_challenge` also accepts a `delivery_method` and `signin_with_passkey` accepts a `verification` argument. Both are optional and covered in [section 4](#4-identifier-verification-optional). + ## 2. Passkey Login Identical shape, different endpoints. The login challenge takes an optional `username` hint (for conditional UI), and the browser uses `navigator.credentials.get()`. @@ -212,6 +217,85 @@ except MfaRequiredError as e: > [!NOTE] > Do **not** pass `persist=True` on this path. It updates an *existing* session, and a passkey-first login has none yet, so it raises `MfaVerifyError("No existing session found…")` — discarding the tokens `verify` just obtained. Use `persist=False` and store the returned tokens as shown above. (This is pre-existing MFA-client behavior, unrelated to passkeys.) +## 4. Identifier verification (optional) + +Some connections require a new user to prove they own their email address or phone number before tokens are issued. When that is on, the signup challenge lists the identifiers that need a code, Auth0 sends each code, and you pass the codes back when you complete sign-in. Connections without it behave exactly as described above. + +### Turning it on + +Enable identifier verification for the email and/or phone identifier on your database connection, and make sure passkeys are enabled on that connection and a custom domain is configured. + +### The flow + +1. Request the challenge. If `verification_required` is set, show a code field for each identifier it lists. +2. Collect the codes before you create the credential. Otherwise a user who fails verification is left with a saved passkey on their device for a signup that never finished. +3. Create the credential in the browser, then call `signin_with_passkey` with the codes in `verification`. + +```python +challenge = await server_client.passkey_signup_challenge( + user_profile=PasskeyUserProfile(email="new.user@example.com", phone_number="+15551234567"), + delivery_method="text", # optional, "text" or "voice" for the phone code + store_options={"request": request, "response": response}, +) + +# For example ["email", "phone"]. None when nothing needs verifying. +required = challenge.verification_required + +# Show a field per identifier, collect the codes, then create the credential in the browser. + +result = await server_client.signin_with_passkey( + auth_session=challenge.auth_session, + authn_response=authn_response, + verification={"email": email_code, "phone": phone_code}, + store_options={"request": request, "response": response}, +) +``` + +The keys of `verification` are `email` and `phone`, exactly as listed in `verification_required`. Do not use `phone_number` here, because Auth0 rejects a key it does not recognise with `invalid_request`. Leave `verification` out when `verification_required` is `None`. + +When verification is required the session lasts 15 minutes, and the `timeout` inside `authn_params_public_key` is 15 minutes too. A prompt built from it will stay open that long. + +### Handling a wrong code + +A wrong code does not end the signup. Auth0 keeps the session, and `signin_with_passkey` raises a `PasskeyError` where `is_verification_retryable` is `True`. The error's `verification_required` lists only the identifiers that are still unverified, so ask the user for just those and call again. + +```python +from auth0_server_python.error import PasskeyError + +MAX_ATTEMPTS = 3 +auth_session = challenge.auth_session +codes = {"email": email_code, "phone": phone_code} + +for attempt in range(MAX_ATTEMPTS): + try: + result = await server_client.signin_with_passkey( + auth_session=auth_session, + authn_response=authn_response, + verification=codes, + store_options={"request": request, "response": response}, + ) + break + except PasskeyError as e: + if not e.is_verification_retryable or attempt == MAX_ATTEMPTS - 1: + raise + auth_session = e.auth_session + codes = ask_user_for_codes(e.verification_required) +``` + +Retry with the `auth_session` from the error, which is the same value today, and with the same `authn_response`, so the user is not asked for another biometric prompt. Send codes only for the identifiers still listed. Always cap the number of attempts, because Auth0 also limits them and ends the session when the limit is reached. + +`is_verification_retryable` is `False` in these cases. + +- Too many wrong codes, an expired code, or an expired session. Start again from the signup challenge. +- A missing code raises code `invalid_request`. The session is still valid, so call again with the same `auth_session` and the missing code, but treat it as a bug in your app because it means a code Auth0 asked for was not sent. Do not loop on it. +- A server error (code `server_error`) may or may not have ended the session. If Auth0 could not reach its verification service, the session is kept. If the failure came after the codes were accepted, the session is gone. The error does not say which, so call again once or twice with the same `auth_session`, `authn_response` and codes after a short delay. If the session is gone, the call fails with `invalid_grant` and no `auth_session`, and you start again from the signup challenge. +- Any other failure is raised with the code Auth0 returned. Treat it as a general failure. + +A dropped connection after Auth0 accepts the final code also ends the session, because each code works once. The user has to start again from the signup challenge. + +> [!IMPORTANT] +> If your tenant requires proof of possession, pass `dpop_key` on this call as well, otherwise the request is rejected. The rest of the DPoP behavior in section 3 is unchanged. + ## Error Handling The three passkey methods raise `PasskeyError` (a subclass of `Auth0Error`). Input-validation failures raise `MissingRequiredArgumentError`; a required step-up raises `MfaRequiredError`. For most code, catching `Auth0Error` is enough. @@ -257,6 +341,9 @@ except Auth0Error as e: - `passkey_challenge_error` — the signup/login challenge request failed - `passkey_token_error` — token exchange failed (also used for a rejected DPoP downgrade) - `invalid_response` — Auth0 returned a response that could not be parsed +- `too_many_requests` when the signup challenge or the token exchange is rate limited + +`PasskeyError` also exposes `auth_session`, `verification_required` and `is_verification_retryable` for [identifier verification](#4-identifier-verification-optional). Rate limiting on the signup challenge or the token exchange raises code `too_many_requests`. The per-IP limit on the signup challenge applies to public clients only. Treat `auth_session` on the error like any other session credential and keep it out of logs. > [!NOTE] -> `auth_session` is a short-lived (typically ~5 min) Tier 1 credential. It is redacted in the SDK's model `repr()`, and you should never log or persist it. If the ceremony takes too long, re-request the challenge. +> `auth_session` is a short-lived (typically ~5 min, or 15 min when `verification_required` is set) credential. Never log or persist it. If the ceremony takes too long, re-request the challenge. diff --git a/src/auth0_server_python/auth_server/server_client.py b/src/auth0_server_python/auth_server/server_client.py index 231796a5..789f6983 100644 --- a/src/auth0_server_python/auth_server/server_client.py +++ b/src/auth0_server_python/auth_server/server_client.py @@ -47,6 +47,7 @@ MfaRequirements, MfaVerifyResponse, PasskeyAuthResponse, + PasskeyDeliveryMethod, PasskeyLoginChallengeResponse, PasskeyLoginResult, PasskeySignupChallengeResponse, @@ -3507,6 +3508,7 @@ async def passkey_signup_challenge( organization: Optional[str] = None, user_metadata: Optional[dict[str, Any]] = None, store_options: Optional[dict[str, Any]] = None, + delivery_method: Optional[PasskeyDeliveryMethod] = None, ) -> PasskeySignupChallengeResponse: """ Step 1 of 2: Initiate a passkey signup challenge (POST /passkey/register). @@ -3522,12 +3524,14 @@ async def passkey_signup_challenge( user_metadata: Optional custom metadata added at the root of the request body, not nested inside user_profile (per Auth0 API spec). store_options: Optional options for domain resolution. + delivery_method: How a phone verification code is sent, "text" or "voice". Returns: - PasskeySignupChallengeResponse with auth_session and authn_params_public_key. + PasskeySignupChallengeResponse with auth_session, authn_params_public_key and, + when the connection requires verification, verification_required. Raises: - PasskeyError: If the challenge request fails. + PasskeyError: If the challenge request fails or the request is rate limited. EnterpriseConnectError: If the client is configured for Enterprise Connect. """ try: @@ -3545,11 +3549,20 @@ async def passkey_signup_challenge( body["realm"] = connection if resolved_org: body["organization"] = resolved_org + if delivery_method: + body["delivery_method"] = delivery_method async with self._get_http_client() as client: url = f"https://{domain}{self.PASSKEY_REGISTER_PATH}" response = await client.post(url, json=body) + if response.status_code == 429: + # The limiter can answer with a plain-text body, so check before parsing. + raise PasskeyError( + PasskeyErrorCode.TOO_MANY_REQUESTS, + "Too many passkey signup requests", + ) + if response.status_code != 200: try: error_data = response.json() @@ -3660,6 +3673,7 @@ async def signin_with_passkey( scope: Optional[str] = None, audience: Optional[str] = None, dpop_key: Optional["jwk.JWK"] = None, + verification: Optional[dict[str, str]] = None, ) -> PasskeyLoginResult: """ Completes passkey authentication by exchanging the WebAuthn assertion @@ -3680,6 +3694,8 @@ async def signin_with_passkey( dpop_key: Optional EC P-256 JWK for DPoP-bound token exchange. When provided, attaches a DPoP proof header so Auth0 issues a DPoP-bound token (token_type: DPoP). Required when the tenant mandates DPoP binding. + verification: Codes the user entered, keyed by the identifiers listed in + verification_required, such as {"email": "123456", "phone": "654321"}. Returns: PasskeyLoginResult containing state_data with user claims and token sets, @@ -3733,6 +3749,9 @@ async def signin_with_passkey( body["scope"] = scope if audience: body["audience"] = audience + codes = {key: code for key, code in (verification or {}).items() if code is not None} + if codes: + body["verification"] = codes async with self._get_http_client() as client: headers = {} @@ -3757,6 +3776,13 @@ async def signin_with_passkey( ) response = await client.post(token_endpoint, json=body, headers=headers) + if response.status_code == 429: + # The token endpoint limiters answer with error "access_denied". + raise PasskeyError( + PasskeyErrorCode.TOO_MANY_REQUESTS, + "Too many passkey token requests", + ) + if response.status_code != 200: try: error_data = response.json() @@ -3781,6 +3807,8 @@ async def signin_with_passkey( raise PasskeyError( error_code, error_data.get("error_description", "Passkey token exchange failed"), + auth_session=error_data.get("auth_session"), + verification_required=error_data.get("verification_required"), ) try: diff --git a/src/auth0_server_python/auth_types/__init__.py b/src/auth0_server_python/auth_types/__init__.py index 4dca0120..1ba5b236 100644 --- a/src/auth0_server_python/auth_types/__init__.py +++ b/src/auth0_server_python/auth_types/__init__.py @@ -21,6 +21,7 @@ "passkey", "email", "phone", "totp", "push-notification", "recovery-code", "password" ] PreferredAuthMethod = Literal["sms", "voice"] +PasskeyDeliveryMethod = Literal["text", "voice"] # Deprecated public aliases resolved lazily (PEP 562) so access emits a warning # while imports keep working. Remove in a future release. @@ -862,7 +863,7 @@ class _PasskeyChallengeResponseBase(BaseModel): class PasskeySignupChallengeResponse(_PasskeyChallengeResponseBase): - pass + verification_required: Optional[list[str]] = None class PasskeyLoginChallengeResponse(_PasskeyChallengeResponseBase): diff --git a/src/auth0_server_python/error/__init__.py b/src/auth0_server_python/error/__init__.py index 07250e6a..cddd6016 100644 --- a/src/auth0_server_python/error/__init__.py +++ b/src/auth0_server_python/error/__init__.py @@ -411,11 +411,25 @@ class PasskeyError(Auth0Error): Error raised during passkey authentication operations. """ - def __init__(self, code: str, message: str, cause=None): + def __init__( + self, + code: str, + message: str, + cause=None, + auth_session: Optional[str] = None, + verification_required: Optional[list[str]] = None, + ): super().__init__(message) self.code = code self.name = "PasskeyError" self.cause = cause + self.auth_session = auth_session + self.verification_required = verification_required + + @property + def is_verification_retryable(self) -> bool: + """True when a code was wrong but the session is alive, so the caller can resubmit.""" + return self.code == "invalid_grant" and bool(self.auth_session) class PasskeyErrorCode: @@ -424,6 +438,7 @@ class PasskeyErrorCode: CHALLENGE_FAILED = "passkey_challenge_error" TOKEN_EXCHANGE_FAILED = "passkey_token_error" INVALID_RESPONSE = "invalid_response" + TOO_MANY_REQUESTS = "too_many_requests" # ============================================================================= diff --git a/src/auth0_server_python/tests/test_error.py b/src/auth0_server_python/tests/test_error.py index 30b87829..b0d52881 100644 --- a/src/auth0_server_python/tests/test_error.py +++ b/src/auth0_server_python/tests/test_error.py @@ -1,9 +1,13 @@ """Tests for the error module's typed exceptions.""" +import pytest + from auth0_server_python.error import ( Auth0Error, EnterpriseConnectError, EnterpriseConnectErrorCode, + PasskeyError, + PasskeyErrorCode, ) # === Enterprise Connect === @@ -34,3 +38,52 @@ def test_enterprise_connect_error_preserves_cause(): def test_enterprise_connect_error_code_is_stable(): assert EnterpriseConnectErrorCode.NOT_SUPPORTED == "enterprise_connect_not_supported" + + +# === Passkey === + + +def test_passkey_error_retry_details_default_to_none(): + err = PasskeyError("passkey_token_error", "failed") + assert err.auth_session is None + assert err.verification_required is None + assert err.is_verification_retryable is False + + +def test_passkey_error_keeps_cause_as_third_positional_argument(): + cause = ValueError("boom") + err = PasskeyError("passkey_token_error", "failed", cause) + assert err.cause is cause + + +def test_passkey_error_invalid_grant_with_auth_session_is_verification_retryable(): + err = PasskeyError( + "invalid_grant", "failed", auth_session="session_abc123", verification_required=["email"] + ) + assert err.is_verification_retryable is True + assert err.verification_required == ["email"] + + +@pytest.mark.parametrize( + ("code", "auth_session"), + [ + ("invalid_grant", None), + ("invalid_grant", ""), + ("invalid_request", None), + ("invalid_request", "session_abc123"), + ("server_error", None), + ], +) +def test_passkey_error_other_failures_are_not_verification_retryable(code, auth_session): + err = PasskeyError(code, "failed", auth_session=auth_session) + assert err.is_verification_retryable is False + + +def test_passkey_error_str_and_repr_do_not_contain_auth_session(): + err = PasskeyError("invalid_grant", "failed", auth_session="session_abc123") + assert "session_abc123" not in str(err) + assert "session_abc123" not in repr(err) + + +def test_passkey_error_code_too_many_requests_value(): + assert PasskeyErrorCode.TOO_MANY_REQUESTS == "too_many_requests" diff --git a/src/auth0_server_python/tests/test_server_client.py b/src/auth0_server_python/tests/test_server_client.py index c7ac3b47..ff138e7a 100644 --- a/src/auth0_server_python/tests/test_server_client.py +++ b/src/auth0_server_python/tests/test_server_client.py @@ -9230,6 +9230,330 @@ async def test_signin_with_passkey_rejects_dpop_under_mtls(mocker): ) +# === Passkey identifier verification === + + +def _make_passkey_client(): + return ServerClient( + domain="auth0.local", + client_id="test_client_id", + client_secret="test_client_secret", + state_store=AsyncMock(), + transaction_store=AsyncMock(), + secret="test-secret-value", + ) + + +def _make_passkey_signin_client(mocker): + client = _make_passkey_client() + mocker.patch.object( + client, + "_get_oidc_metadata_cached", + return_value={"token_endpoint": "https://auth0.local/oauth/token", "issuer": "https://auth0.local/"}, + ) + return client + + +def _mock_passkey_post(mocker, status_code, json_body=None): + """Patch the HTTP post. A json_body of None simulates a non-JSON response body.""" + mock_post = mocker.patch("httpx.AsyncClient.post", new_callable=AsyncMock) + response = AsyncMock() + response.status_code = status_code + response.headers = {} + if json_body is None: + response.json = MagicMock(side_effect=json.JSONDecodeError("bad", "", 0)) + else: + response.json = MagicMock(return_value=json_body) + mock_post.return_value = response + return mock_post + + +async def _signin_expecting_error(client, **kwargs): + with pytest.raises(PasskeyError) as exc: + await client.signin_with_passkey( + auth_session="session_abc123", + authn_response=_make_passkey_authn_response(), + **kwargs, + ) + return exc.value + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("extra", "expected"), + [ + ({}, None), + ({"verification_required": ["email"]}, ["email"]), + ({"verification_required": ["phone"]}, ["phone"]), + ({"verification_required": ["email", "phone"]}, ["email", "phone"]), + ], + ids=["absent", "email_only", "phone_only", "email_and_phone"], +) +async def test_passkey_signup_challenge_verification_required_states_parse(mocker, extra, expected): + client = _make_passkey_client() + _mock_passkey_post(mocker, 200, {**_PASSKEY_SIGNUP_CHALLENGE_RESPONSE, **extra}) + + result = await client.passkey_signup_challenge() + + assert result.verification_required == expected + + +@pytest.mark.asyncio +@pytest.mark.parametrize("delivery_method", ["text", "voice"]) +async def test_passkey_signup_challenge_delivery_method_forwarded(mocker, delivery_method): + client = _make_passkey_client() + mock_post = _mock_passkey_post(mocker, 200, _PASSKEY_SIGNUP_CHALLENGE_RESPONSE) + + await client.passkey_signup_challenge(delivery_method=delivery_method) + + assert mock_post.call_args.kwargs["json"]["delivery_method"] == delivery_method + + +@pytest.mark.asyncio +async def test_passkey_signup_challenge_unsupported_delivery_method_surfaces_server_error(mocker): + client = _make_passkey_client() + _mock_passkey_post(mocker, 400, { + "error": "invalid_request", + "error_description": "The requested media is not supported for this connection", + }) + + with pytest.raises(PasskeyError) as exc: + await client.passkey_signup_challenge(delivery_method="voice") + + assert exc.value.code == "invalid_request" + assert "media is not supported" in exc.value.message + + +@pytest.mark.asyncio +async def test_passkey_signup_challenge_rate_limited_plain_text_raises_too_many_requests(mocker): + client = _make_passkey_client() + _mock_passkey_post(mocker, 429) + + with pytest.raises(PasskeyError) as exc: + await client.passkey_signup_challenge() + + assert exc.value.code == PasskeyErrorCode.TOO_MANY_REQUESTS + + +@pytest.mark.asyncio +async def test_passkey_signup_challenge_rate_limited_json_raises_too_many_requests(mocker): + client = _make_passkey_client() + _mock_passkey_post(mocker, 429, {"error": "rate_limit_exceeded", "error_description": "slow down"}) + + with pytest.raises(PasskeyError) as exc: + await client.passkey_signup_challenge() + + assert exc.value.code == PasskeyErrorCode.TOO_MANY_REQUESTS + + +@pytest.mark.asyncio +async def test_signin_with_passkey_rate_limited_json_raises_too_many_requests(mocker): + client = _make_passkey_signin_client(mocker) + _mock_passkey_post(mocker, 429, { + "error": "access_denied", + "error_description": "Global rate limit exceeded", + }) + + error = await _signin_expecting_error(client) + + assert error.code == PasskeyErrorCode.TOO_MANY_REQUESTS + assert error.is_verification_retryable is False + + +@pytest.mark.asyncio +async def test_signin_with_passkey_rate_limited_plain_text_raises_too_many_requests(mocker): + client = _make_passkey_signin_client(mocker) + _mock_passkey_post(mocker, 429) + + error = await _signin_expecting_error(client) + + assert error.code == PasskeyErrorCode.TOO_MANY_REQUESTS + + +@pytest.mark.asyncio +async def test_signin_with_passkey_wrong_code_error_is_verification_retryable(mocker): + client = _make_passkey_signin_client(mocker) + _mock_passkey_post(mocker, 400, { + "error": "invalid_grant", + "error_description": "Invalid or expired session", + "auth_session": "session_abc123", + "verification_required": ["email"], + }) + + error = await _signin_expecting_error(client, verification={"email": "000000"}) + + assert error.is_verification_retryable is True + assert error.code == "invalid_grant" + assert error.auth_session == "session_abc123" + assert error.verification_required == ["email"] + assert "session_abc123" not in str(error) + assert "session_abc123" not in repr(error) + + +@pytest.mark.asyncio +async def test_signin_with_passkey_wrong_code_error_lists_every_remaining_identifier(mocker): + client = _make_passkey_signin_client(mocker) + _mock_passkey_post(mocker, 400, { + "error": "invalid_grant", + "error_description": "Invalid or expired session", + "auth_session": "session_abc123", + "verification_required": ["email", "phone"], + }) + + error = await _signin_expecting_error(client, verification={"email": "1", "phone": "2"}) + + assert error.verification_required == ["email", "phone"] + + +@pytest.mark.asyncio +async def test_signin_with_passkey_terminal_invalid_grant_is_not_retryable(mocker): + client = _make_passkey_signin_client(mocker) + _mock_passkey_post(mocker, 400, { + "error": "invalid_grant", + "error_description": "Invalid or expired session", + }) + + error = await _signin_expecting_error(client, verification={"email": "000000"}) + + assert error.is_verification_retryable is False + assert error.auth_session is None + assert error.verification_required is None + + +@pytest.mark.asyncio +async def test_signin_with_passkey_missing_code_is_invalid_request_and_not_retryable(mocker): + client = _make_passkey_signin_client(mocker) + _mock_passkey_post(mocker, 400, { + "error": "invalid_request", + "error_description": "Missing required parameter: verification.email", + }) + + error = await _signin_expecting_error(client) + + assert error.code == "invalid_request" + assert error.is_verification_retryable is False + assert error.auth_session is None + + +@pytest.mark.asyncio +@pytest.mark.parametrize("status_code", [400, 403]) +async def test_signin_with_passkey_unknown_session_is_not_retryable(mocker, status_code): + client = _make_passkey_signin_client(mocker) + _mock_passkey_post(mocker, status_code, { + "error": "invalid_grant", + "error_description": "Invalid or expired session", + }) + + error = await _signin_expecting_error(client) + + assert error.is_verification_retryable is False + + +@pytest.mark.asyncio +async def test_signin_with_passkey_server_error_is_not_verification_retryable(mocker): + client = _make_passkey_signin_client(mocker) + _mock_passkey_post(mocker, 500, {"error": "server_error", "error_description": "unavailable"}) + + error = await _signin_expecting_error(client, verification={"email": "000000"}) + + assert error.code == "server_error" + assert error.is_verification_retryable is False + + +@pytest.mark.asyncio +async def test_signin_with_passkey_verification_forwarded(mocker): + client = _make_passkey_signin_client(mocker) + mock_post = _mock_passkey_post(mocker, 400, {"error": "invalid_grant"}) + + await _signin_expecting_error(client, verification={"email": "123456", "phone": "654321"}) + + assert mock_post.call_args.kwargs["json"]["verification"] == {"email": "123456", "phone": "654321"} + + +@pytest.mark.asyncio +async def test_signin_with_passkey_verification_none_values_dropped(mocker): + client = _make_passkey_signin_client(mocker) + mock_post = _mock_passkey_post(mocker, 400, {"error": "invalid_grant"}) + + await _signin_expecting_error(client, verification={"email": "123456", "phone": None}) + + assert mock_post.call_args.kwargs["json"]["verification"] == {"email": "123456"} + + +@pytest.mark.asyncio +@pytest.mark.parametrize("verification", [None, {}, {"email": None}], ids=["none", "empty", "all_none"]) +async def test_signin_with_passkey_verification_omitted_when_nothing_to_send(mocker, verification): + client = _make_passkey_signin_client(mocker) + mock_post = _mock_passkey_post(mocker, 400, {"error": "invalid_grant"}) + + await _signin_expecting_error(client, verification=verification) + + assert "verification" not in mock_post.call_args.kwargs["json"] + + +@pytest.mark.asyncio +async def test_signin_with_passkey_wrong_code_then_corrected_code_succeeds_on_same_session(mocker): + client = _make_passkey_signin_client(mocker) + mocker.patch.object(client, "_get_jwks_cached", return_value={}) + mocker.patch.object(client, "_verify_and_decode_jwt", return_value={ + "sub": "auth0|user123", "iss": "https://auth0.local/" + }) + mock_post = _mock_passkey_post(mocker, 400, { + "error": "invalid_grant", + "error_description": "Invalid or expired session", + "auth_session": "session_abc123", + "verification_required": ["email"], + }) + success = AsyncMock() + success.status_code = 200 + success.headers = {} + success.json = MagicMock(return_value=_PASSKEY_TOKEN_RESPONSE) + mock_post.side_effect = [mock_post.return_value, success] + authn_response = _make_passkey_authn_response() + + with pytest.raises(PasskeyError) as exc: + await client.signin_with_passkey( + auth_session="session_abc123", + authn_response=authn_response, + verification={"email": "000000"}, + ) + assert exc.value.is_verification_retryable is True + + result = await client.signin_with_passkey( + auth_session=exc.value.auth_session, + authn_response=authn_response, + verification={"email": "123456"}, + ) + + assert isinstance(result, PasskeyLoginResult) + first_body, second_body = (call.kwargs["json"] for call in mock_post.call_args_list) + assert first_body["auth_session"] == second_body["auth_session"] == "session_abc123" + assert first_body["authn_response"] == second_body["authn_response"] + assert first_body["verification"] == {"email": "000000"} + assert second_body["verification"] == {"email": "123456"} + + +@pytest.mark.asyncio +async def test_signin_with_passkey_verification_with_dpop_key_sends_both(mocker): + client = _make_passkey_signin_client(mocker) + mocker.patch.object(client, "_get_jwks_cached", return_value={}) + mocker.patch.object(client, "_verify_and_decode_jwt", return_value={ + "sub": "auth0|user123", "iss": "https://auth0.local/" + }) + mock_post = _mock_passkey_post(mocker, 200, _PASSKEY_TOKEN_RESPONSE_DPOP) + + await client.signin_with_passkey( + auth_session="session_abc123", + authn_response=_make_passkey_authn_response(), + dpop_key=jwk.JWK.generate(kty="EC", crv="P-256"), + verification={"email": "123456"}, + ) + + kwargs = mock_post.call_args.kwargs + assert kwargs["json"]["verification"] == {"email": "123456"} + assert "DPoP" in kwargs["headers"] + + # ============================================================================= # IPSIE session_expiry enforcement # ============================================================================= From afc64af3b611c54ff8c8a8ab1eb63fe467c40f61 Mon Sep 17 00:00:00 2001 From: Sourav Basu Date: Sun, 11 Oct 2026 14:59:43 +0530 Subject: [PATCH 02/15] feat: validate passkey verification codes before sending --- examples/Passkeys.md | 2 +- .../auth_server/server_client.py | 21 ++++++- .../tests/test_server_client.py | 55 ++++++++++++++++++- 3 files changed, 73 insertions(+), 5 deletions(-) diff --git a/examples/Passkeys.md b/examples/Passkeys.md index 5a9531f4..b10ada0d 100644 --- a/examples/Passkeys.md +++ b/examples/Passkeys.md @@ -251,7 +251,7 @@ result = await server_client.signin_with_passkey( ) ``` -The keys of `verification` are `email` and `phone`, exactly as listed in `verification_required`. Do not use `phone_number` here, because Auth0 rejects a key it does not recognise with `invalid_request`. Leave `verification` out when `verification_required` is `None`. +The keys of `verification` are `email` and `phone`, exactly as listed in `verification_required`. Any other key, such as `phone_number`, raises `InvalidArgumentError` before a request is sent, and a blank code is treated as not provided. Leave `verification` out when `verification_required` is `None`. When verification is required the session lasts 15 minutes, and the `timeout` inside `authn_params_public_key` is 15 minutes too. A prompt built from it will stay open that long. diff --git a/src/auth0_server_python/auth_server/server_client.py b/src/auth0_server_python/auth_server/server_client.py index 789f6983..249523d0 100644 --- a/src/auth0_server_python/auth_server/server_client.py +++ b/src/auth0_server_python/auth_server/server_client.py @@ -142,6 +142,7 @@ class ServerClient(Generic[TStoreOptions]): GRANT_TYPE_PASSKEY = "urn:okta:params:oauth:grant-type:webauthn" PASSKEY_REGISTER_PATH = "/passkey/register" PASSKEY_CHALLENGE_PATH = "/passkey/challenge" + PASSKEY_VERIFICATION_IDENTIFIERS = ("email", "phone") # ============================================================================ # INITIALIZATION @@ -3696,6 +3697,7 @@ async def signin_with_passkey( (token_type: DPoP). Required when the tenant mandates DPoP binding. verification: Codes the user entered, keyed by the identifiers listed in verification_required, such as {"email": "123456", "phone": "654321"}. + Only "email" and "phone" are accepted, and blank codes are ignored. Returns: PasskeyLoginResult containing state_data with user claims and token sets, @@ -3705,6 +3707,8 @@ async def signin_with_passkey( MissingRequiredArgumentError: If auth_session or authn_response is missing. ConfigurationError: If dpop_key is combined with use_mtls. DPoP and mTLS use incompatible token-binding mechanisms and cannot be used together. + InvalidArgumentError: If verification has a key other than "email" or "phone", + or a code that is not a string. PasskeyError: If token exchange or session creation fails. OrganizationTokenValidationError: If an organization was requested but the token response included no ID token, or the ID token's org claim does @@ -3721,6 +3725,18 @@ async def signin_with_passkey( "differently. DPoP would take precedence and the token would not be " "certificate-bound." ) + verification_codes: dict[str, str] = {} + for identifier, code in (verification or {}).items(): + if identifier not in self.PASSKEY_VERIFICATION_IDENTIFIERS: + raise InvalidArgumentError( + "verification", "verification keys must be 'email' or 'phone'" + ) + if code is not None and not isinstance(code, str): + raise InvalidArgumentError( + "verification", f"the '{identifier}' verification code must be a string" + ) + if code and code.strip(): + verification_codes[identifier] = code try: domain = await self._resolve_current_domain(store_options) @@ -3749,9 +3765,8 @@ async def signin_with_passkey( body["scope"] = scope if audience: body["audience"] = audience - codes = {key: code for key, code in (verification or {}).items() if code is not None} - if codes: - body["verification"] = codes + if verification_codes: + body["verification"] = verification_codes async with self._get_http_client() as client: headers = {} diff --git a/src/auth0_server_python/tests/test_server_client.py b/src/auth0_server_python/tests/test_server_client.py index ff138e7a..ece434e4 100644 --- a/src/auth0_server_python/tests/test_server_client.py +++ b/src/auth0_server_python/tests/test_server_client.py @@ -9481,7 +9481,60 @@ async def test_signin_with_passkey_verification_none_values_dropped(mocker): @pytest.mark.asyncio -@pytest.mark.parametrize("verification", [None, {}, {"email": None}], ids=["none", "empty", "all_none"]) +@pytest.mark.parametrize( + "verification", + [{"phone_number": "123456"}, {"email": "123456", "otp": "654321"}, {"EMAIL": "123456"}], + ids=["phone_number", "extra_key", "wrong_case"], +) +async def test_signin_with_passkey_verification_unknown_key_raises_before_request(mocker, verification): + client = _make_passkey_signin_client(mocker) + mock_post = _mock_passkey_post(mocker, 400, {"error": "invalid_grant"}) + + with pytest.raises(InvalidArgumentError) as exc: + await client.signin_with_passkey( + auth_session="session_abc123", + authn_response=_make_passkey_authn_response(), + verification=verification, + ) + + assert exc.value.argument == "verification" + assert "123456" not in str(exc.value) + mock_post.assert_not_called() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("code", [123456, ["123456"], b"123456"], ids=["int", "list", "bytes"]) +async def test_signin_with_passkey_verification_non_string_code_raises_before_request(mocker, code): + client = _make_passkey_signin_client(mocker) + mock_post = _mock_passkey_post(mocker, 400, {"error": "invalid_grant"}) + + with pytest.raises(InvalidArgumentError) as exc: + await client.signin_with_passkey( + auth_session="session_abc123", + authn_response=_make_passkey_authn_response(), + verification={"email": code}, + ) + + assert exc.value.argument == "verification" + mock_post.assert_not_called() + + +@pytest.mark.asyncio +async def test_signin_with_passkey_verification_blank_codes_dropped(mocker): + client = _make_passkey_signin_client(mocker) + mock_post = _mock_passkey_post(mocker, 400, {"error": "invalid_grant"}) + + await _signin_expecting_error(client, verification={"email": "123456", "phone": " "}) + + assert mock_post.call_args.kwargs["json"]["verification"] == {"email": "123456"} + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "verification", + [None, {}, {"email": None}, {"email": ""}, {"email": " ", "phone": None}], + ids=["none", "empty", "all_none", "blank", "blank_and_none"], +) async def test_signin_with_passkey_verification_omitted_when_nothing_to_send(mocker, verification): client = _make_passkey_signin_client(mocker) mock_post = _mock_passkey_post(mocker, 400, {"error": "invalid_grant"}) From 0c79e821040f5dbebfea67982578ee993e9a71c0 Mon Sep 17 00:00:00 2001 From: Sourav Basu Date: Mon, 12 Oct 2026 10:13:08 +0530 Subject: [PATCH 03/15] docs: Added docstrings --- .../auth_server/server_client.py | 6 +++++- .../auth_types/__init__.py | 20 ++++++++++++++++++- src/auth0_server_python/error/__init__.py | 7 +++++++ 3 files changed, 31 insertions(+), 2 deletions(-) diff --git a/src/auth0_server_python/auth_server/server_client.py b/src/auth0_server_python/auth_server/server_client.py index 249523d0..5b31023c 100644 --- a/src/auth0_server_python/auth_server/server_client.py +++ b/src/auth0_server_python/auth_server/server_client.py @@ -3709,7 +3709,11 @@ async def signin_with_passkey( use incompatible token-binding mechanisms and cannot be used together. InvalidArgumentError: If verification has a key other than "email" or "phone", or a code that is not a string. - PasskeyError: If token exchange or session creation fails. + PasskeyError: If token exchange or session creation fails, or the request is + rate limited. + MfaRequiredError: If the tenant requires step-up MFA after the passkey sign-in. + ApiError: If the ID token fails signature, audience, expiry or JWKS validation. + IssuerValidationError: If the ID token issuer does not match the tenant. OrganizationTokenValidationError: If an organization was requested but the token response included no ID token, or the ID token's org claim does not match. diff --git a/src/auth0_server_python/auth_types/__init__.py b/src/auth0_server_python/auth_types/__init__.py index 1ba5b236..525e72dd 100644 --- a/src/auth0_server_python/auth_types/__init__.py +++ b/src/auth0_server_python/auth_types/__init__.py @@ -729,11 +729,15 @@ class PasskeyLoginResult(BaseModel): class PasskeyRpInfo(BaseModel): + """Relying party identity for a WebAuthn ceremony.""" + id: str name: str class PasskeyUserInfo(BaseModel): + """User identity for a WebAuthn registration.""" + model_config = ConfigDict(populate_by_name=True) id: str name: str @@ -741,17 +745,23 @@ class PasskeyUserInfo(BaseModel): class PasskeyPubKeyCredParam(BaseModel): + """Public key type and algorithm the relying party accepts.""" + type: str alg: int class PasskeyAuthenticatorSelection(BaseModel): + """Authenticator requirements for a WebAuthn registration.""" + model_config = ConfigDict(populate_by_name=True) resident_key: Optional[str] = Field(None, alias="residentKey") user_verification: Optional[str] = Field(None, alias="userVerification") class PasskeyPublicKeyOptions(BaseModel): + """WebAuthn public key options to pass to the browser credentials API.""" + model_config = ConfigDict(populate_by_name=True, extra="allow") challenge: str rp: Optional[PasskeyRpInfo] = None @@ -784,6 +794,8 @@ class EnrollmentChallengeResponse(BaseModel): class PasskeyAuthResponse(BaseModel): + """WebAuthn credential returned by the browser, passed to signin_with_passkey.""" + model_config = ConfigDict(populate_by_name=True) id: str raw_id: str = Field(alias="rawId") @@ -846,6 +858,8 @@ class GetFactorsResponse(BaseModel): class PasskeyUserProfile(BaseModel): + """User profile attributes sent with a passkey signup challenge.""" + model_config = ConfigDict(extra="allow") email: Optional[str] = None name: Optional[str] = None @@ -863,14 +877,18 @@ class _PasskeyChallengeResponseBase(BaseModel): class PasskeySignupChallengeResponse(_PasskeyChallengeResponseBase): + """Signup challenge, with verification_required set when the user must enter codes first.""" + verification_required: Optional[list[str]] = None class PasskeyLoginChallengeResponse(_PasskeyChallengeResponseBase): - pass + """Login challenge with the auth_session and WebAuthn options for the browser.""" class PasskeyTokenResponse(BaseModel): + """Token endpoint response for the passkey grant.""" + model_config = ConfigDict(extra="allow") access_token: str token_type: str = "Bearer" diff --git a/src/auth0_server_python/error/__init__.py b/src/auth0_server_python/error/__init__.py index cddd6016..ac98ec1b 100644 --- a/src/auth0_server_python/error/__init__.py +++ b/src/auth0_server_python/error/__init__.py @@ -409,6 +409,13 @@ class PasswordlessErrorCode: class PasskeyError(Auth0Error): """ Error raised during passkey authentication operations. + + Args: + code: Error code, see PasskeyErrorCode. + message: Human-readable description. + cause: Underlying exception, if any. + auth_session: Session to resubmit against, set when Auth0 keeps it alive after a wrong code. + verification_required: Identifiers whose codes are still unverified. """ def __init__( From 8e144729af80ab958b794255e60c7a6bd0d69d79 Mon Sep 17 00:00:00 2001 From: Sourav Basu Date: Mon, 12 Oct 2026 10:24:50 +0530 Subject: [PATCH 04/15] docs: Updated docstrings with cleaner text --- src/auth0_server_python/auth_server/server_client.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/auth0_server_python/auth_server/server_client.py b/src/auth0_server_python/auth_server/server_client.py index 5b31023c..d05e7f7f 100644 --- a/src/auth0_server_python/auth_server/server_client.py +++ b/src/auth0_server_python/auth_server/server_client.py @@ -3525,7 +3525,7 @@ async def passkey_signup_challenge( user_metadata: Optional custom metadata added at the root of the request body, not nested inside user_profile (per Auth0 API spec). store_options: Optional options for domain resolution. - delivery_method: How a phone verification code is sent, "text" or "voice". + delivery_method: Optional channel for the phone verification code, "text" or "voice". Returns: PasskeySignupChallengeResponse with auth_session, authn_params_public_key and, @@ -3695,9 +3695,7 @@ async def signin_with_passkey( dpop_key: Optional EC P-256 JWK for DPoP-bound token exchange. When provided, attaches a DPoP proof header so Auth0 issues a DPoP-bound token (token_type: DPoP). Required when the tenant mandates DPoP binding. - verification: Codes the user entered, keyed by the identifiers listed in - verification_required, such as {"email": "123456", "phone": "654321"}. - Only "email" and "phone" are accepted, and blank codes are ignored. + verification: Optional signup codes keyed by "email" or "phone", not used for login. Returns: PasskeyLoginResult containing state_data with user claims and token sets, From 89014a0406994db70a3171c17bad1ef883905091 Mon Sep 17 00:00:00 2001 From: Sourav Basu Date: Mon, 12 Oct 2026 10:39:39 +0530 Subject: [PATCH 05/15] fix: docs updates, moved error code to inside class, and added tests --- README.md | 2 +- examples/Passkeys.md | 2 +- src/auth0_server_python/auth_server/server_client.py | 1 - src/auth0_server_python/error/__init__.py | 3 ++- src/auth0_server_python/tests/test_server_client.py | 10 ++++++++++ 5 files changed, 14 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index f27efa12..5c6a44d3 100644 --- a/README.md +++ b/README.md @@ -246,7 +246,7 @@ For more details and examples, see [examples/RetrievingData.md](examples/Retriev ### 7. Passkey Authentication -Sign users up or in with [WebAuthn](https://www.w3.org/TR/webauthn-2/) passkeys (Touch ID, Face ID, Windows Hello, or a security key) instead of a password, via [Auth0 passkeys](https://auth0.com/docs/authenticate/database-connections/passkeys). The ceremony is two steps — request a challenge, sign it in the browser, then complete sign-in — and establishes a server-side session like every other login path. For the signup and login flows, organizations, step-up MFA, [verifying an email or phone at signup](examples/Passkeys.md#4-identifier-verification-optional), and error handling, see [examples/Passkeys.md](examples/Passkeys.md). +Sign users up or in with [WebAuthn](https://www.w3.org/TR/webauthn-2/) passkeys (Touch ID, Face ID, Windows Hello, or a security key) instead of a password, via [Auth0 passkeys](https://auth0.com/docs/authenticate/database-connections/passkeys). The ceremony is two steps - request a challenge, sign it in the browser, then complete sign-in - and establishes a server-side session like every other login path. For the signup and login flows, organizations, step-up MFA, [verifying an email or phone at signup](examples/Passkeys.md#4-identifier-verification-optional), and error handling, see [examples/Passkeys.md](examples/Passkeys.md). ### 8. My Account API — Authentication Methods diff --git a/examples/Passkeys.md b/examples/Passkeys.md index bad59162..047caa21 100644 --- a/examples/Passkeys.md +++ b/examples/Passkeys.md @@ -357,4 +357,4 @@ except Auth0Error as e: `PasskeyError` also exposes `auth_session`, `verification_required` and `is_verification_retryable` for [identifier verification](#4-identifier-verification-optional). Rate limiting on the signup challenge or the token exchange raises code `too_many_requests`. The per-IP limit on the signup challenge applies to public clients only. Treat `auth_session` on the error like any other session credential and keep it out of logs. > [!NOTE] -> `auth_session` is a short-lived (typically ~5 min, or 15 min when `verification_required` is set) credential. Never log or persist it. If the ceremony takes too long, re-request the challenge. +> `auth_session` is a short-lived (typically about a minute, or 15 min when `verification_required` is set) credential. Never log or persist it. If the ceremony takes too long, re-request the challenge. diff --git a/src/auth0_server_python/auth_server/server_client.py b/src/auth0_server_python/auth_server/server_client.py index f123dd60..4236845f 100644 --- a/src/auth0_server_python/auth_server/server_client.py +++ b/src/auth0_server_python/auth_server/server_client.py @@ -3816,7 +3816,6 @@ async def signin_with_passkey( response = await client.post(token_endpoint, json=body, headers=headers) if response.status_code == 429: - # The token endpoint limiters answer with error "access_denied". raise PasskeyError( PasskeyErrorCode.TOO_MANY_REQUESTS, "Too many passkey token requests", diff --git a/src/auth0_server_python/error/__init__.py b/src/auth0_server_python/error/__init__.py index fa1e20de..59a81918 100644 --- a/src/auth0_server_python/error/__init__.py +++ b/src/auth0_server_python/error/__init__.py @@ -436,7 +436,7 @@ def __init__( @property def is_verification_retryable(self) -> bool: """True when a code was wrong but the session is alive, so the caller can resubmit.""" - return self.code == "invalid_grant" and bool(self.auth_session) + return self.code == PasskeyErrorCode.INVALID_GRANT and bool(self.auth_session) class PasskeyErrorCode: @@ -446,6 +446,7 @@ class PasskeyErrorCode: TOKEN_EXCHANGE_FAILED = "passkey_token_error" INVALID_RESPONSE = "invalid_response" TOO_MANY_REQUESTS = "too_many_requests" + INVALID_GRANT = "invalid_grant" # ============================================================================= diff --git a/src/auth0_server_python/tests/test_server_client.py b/src/auth0_server_python/tests/test_server_client.py index 27b6b52d..a55568ec 100644 --- a/src/auth0_server_python/tests/test_server_client.py +++ b/src/auth0_server_python/tests/test_server_client.py @@ -9471,6 +9471,16 @@ async def test_passkey_signup_challenge_delivery_method_forwarded(mocker, delive assert mock_post.call_args.kwargs["json"]["delivery_method"] == delivery_method +@pytest.mark.asyncio +async def test_passkey_signup_challenge_delivery_method_omitted_when_not_set(mocker): + client = _make_passkey_client() + mock_post = _mock_passkey_post(mocker, 200, _PASSKEY_SIGNUP_CHALLENGE_RESPONSE) + + await client.passkey_signup_challenge() + + assert "delivery_method" not in mock_post.call_args.kwargs["json"] + + @pytest.mark.asyncio async def test_passkey_signup_challenge_unsupported_delivery_method_surfaces_server_error(mocker): client = _make_passkey_client() From 58de6e8852bec1f7c36ae2d04d9de9629dd4a9b8 Mon Sep 17 00:00:00 2001 From: Sourav Basu Date: Mon, 12 Oct 2026 11:14:51 +0530 Subject: [PATCH 06/15] docs: Optimising examples docs --- examples/Passkeys.md | 93 +++++++++---------- .../auth_server/server_client.py | 8 +- .../tests/test_server_client.py | 22 +++++ 3 files changed, 74 insertions(+), 49 deletions(-) diff --git a/examples/Passkeys.md b/examples/Passkeys.md index 047caa21..c6a24e79 100644 --- a/examples/Passkeys.md +++ b/examples/Passkeys.md @@ -1,6 +1,6 @@ # Passkey Authentication -Passkeys let users sign up and log in with [WebAuthn](https://www.w3.org/TR/webauthn-2/) credentials (Touch ID, Face ID, Windows Hello, or a hardware security key) instead of a password. This guide covers the **primary authentication** flow on `ServerClient` — signing a user up or in with a passkey and establishing a server-side session. +Passkeys let users sign up and log in with [WebAuthn](https://www.w3.org/TR/webauthn-2/) credentials (Touch ID, Face ID, Windows Hello, or a hardware security key) instead of a password. This guide covers the **primary authentication** flow on `ServerClient` - signing a user up or in with a passkey and establishing a server-side session. > [!NOTE] > Passkeys require a [Custom Domain](https://auth0.com/docs/customize/custom-domains) (WebAuthn binds the credential to the relying-party domain) and the native passkey feature enabled on your tenant. See the [Auth0 passkey documentation](https://auth0.com/docs/authenticate/database-connections/passkeys). @@ -23,9 +23,9 @@ Passkeys let users sign up and log in with [WebAuthn](https://www.w3.org/TR/weba A passkey ceremony is always **two steps**, because the WebAuthn signature happens in the browser between them: -1. **Challenge** — the SDK asks Auth0 for a challenge (`passkey_signup_challenge` / `passkey_login_challenge`). Auth0 returns an `auth_session` and the WebAuthn options (`authn_params_public_key`). -2. **Browser** — your front end passes those options to `navigator.credentials.create()` (signup) or `navigator.credentials.get()` (login). The authenticator produces a signed credential. -3. **Verify / sign-in** — the SDK exchanges the signed credential for tokens (`signin_with_passkey`) and **creates a server-side session**, exactly like every other login path. +1. **Challenge** - the SDK asks Auth0 for a challenge (`passkey_signup_challenge` / `passkey_login_challenge`). Auth0 returns an `auth_session` and the WebAuthn options (`authn_params_public_key`). +2. **Browser** - your front end passes those options to `navigator.credentials.create()` (signup) or `navigator.credentials.get()` (login). The authenticator produces a signed credential. +3. **Verify / sign-in** - the SDK exchanges the signed credential for tokens (`signin_with_passkey`) and **creates a server-side session**, exactly like every other login path. ## Prerequisites @@ -59,7 +59,7 @@ The **Passkey** grant (`urn:okta:params:oauth:grant-type:webauthn`) must be enab ## 1. Passkey Signup -### Step 1 — Request a signup challenge +### Step 1 - Request a signup challenge ```python from auth0_server_python.auth_types import PasskeyUserProfile @@ -86,13 +86,13 @@ challenge = await server_client.passkey_signup_challenge( ``` > [!TIP] -> `PasskeyUserProfile` allows extra fields — any additional profile attribute your tenant accepts (for example `given_name`, `family_name`, `picture`) passes through without an SDK change. Pass tenant-specific custom data via the separate `user_metadata` argument. +> `PasskeyUserProfile` allows extra fields - any additional profile attribute your tenant accepts (for example `given_name`, `family_name`, `picture`) passes through without an SDK change. Pass tenant-specific custom data via the separate `user_metadata` argument. -### Step 2 — Create the credential in the browser +### Step 2 - Create the credential in the browser Pass `authn_params_public_key` to `navigator.credentials.create()`. The resulting credential serializes to the shape the SDK expects in step 3 (`id`, `rawId`, `type`, and a `response` object with `clientDataJSON` + `attestationObject`). -### Step 3 — Verify and establish the session +### Step 3 - Verify and establish the session ```python from auth0_server_python.auth_types import PasskeyAuthResponse @@ -112,10 +112,9 @@ result = await server_client.signin_with_passkey( ) user = result.state_data["user"] -print(f"Signed up and logged in: {user['sub']}") ``` -`signin_with_passkey` returns a `PasskeyLoginResult` whose `state_data` holds the user claims and token sets — the same shape as `complete_interactive_login` and `login_with_custom_token_exchange`. The session is persisted to your configured state store. +`signin_with_passkey` returns a `PasskeyLoginResult` whose `state_data` holds the user claims and token sets - the same shape as `complete_interactive_login` and `login_with_custom_token_exchange`. The session is persisted to your configured state store. > [!NOTE] > If your connection requires users to verify their email or phone at signup, `passkey_signup_challenge` also accepts a `delivery_method` and `signin_with_passkey` accepts a `verification` argument. Both are optional and covered in [section 4](#4-identifier-verification-optional). @@ -125,15 +124,15 @@ print(f"Signed up and logged in: {user['sub']}") Identical shape, different endpoints. The browser uses `navigator.credentials.get()` and the user picks a passkey from the prompt. That credential identifies the user, so the login challenge takes no username. ```python -# Step 1 — login challenge +# Step 1 - login challenge challenge = await server_client.passkey_login_challenge( connection="Username-Password-Authentication", # optional store_options={"request": request, "response": response}, ) -# Step 2 — browser: navigator.credentials.get(challenge.authn_params_public_key) +# Step 2 - browser: navigator.credentials.get(challenge.authn_params_public_key) -# Step 3 — sign in. The login credential's response carries +# Step 3 - sign in. The login credential's response carries # clientDataJSON + authenticatorData + signature + userHandle. result = await server_client.signin_with_passkey( auth_session=challenge.auth_session, @@ -153,7 +152,7 @@ result = await server_client.signin_with_passkey( ``` > [!NOTE] -> The SDK is transparent to the signup-vs-login difference in the credential `response` — both flow through the same `PasskeyAuthResponse.response` dict. Send exactly the keys the browser produced. +> The SDK is transparent to the signup-vs-login difference in the credential `response` - both flow through the same `PasskeyAuthResponse.response` dict. Send exactly the keys the browser produced. ## 3. DPoP-bound passkey tokens (optional) @@ -162,7 +161,7 @@ Pass an optional `dpop_key` to bind the issued tokens to a key your server holds ```python from jwcrypto import jwk -dpop_key = jwk.JWK.generate(kty="EC", crv="P-256") # you generate and keep this key (Tier 0) +dpop_key = jwk.JWK.generate(kty="EC", crv="P-256") # you generate and keep this key result = await server_client.signin_with_passkey( auth_session=challenge.auth_session, @@ -172,17 +171,17 @@ result = await server_client.signin_with_passkey( ) ``` -When `dpop_key` is supplied, the SDK attaches a token-endpoint proof so Auth0 issues a DPoP-bound token, transparently handles the server-nonce challenge, and **rejects a Bearer downgrade** — if the server returns an unbound token, `signin_with_passkey` raises `PasskeyError` rather than silently accepting a token bound to a key it never used. +When `dpop_key` is supplied, the SDK attaches a token-endpoint proof so Auth0 issues a DPoP-bound token, transparently handles the server-nonce challenge, and **rejects a Bearer downgrade** - if the server returns an unbound token, `signin_with_passkey` raises `PasskeyError` rather than silently accepting a token bound to a key it never used. > [!TIP] -> Reuse the **same** `dpop_key` for any subsequent My Account API calls made with the resulting token — the token is bound to that one key. See [MyAccountAuthenticationMethods.md → DPoP](MyAccountAuthenticationMethods.md#dpop). +> Reuse the **same** `dpop_key` for any subsequent My Account API calls made with the resulting token - the token is bound to that one key. See [MyAccountAuthenticationMethods.md → DPoP](MyAccountAuthenticationMethods.md#dpop). > [!WARNING] -> The `dpop_key` private key is a **Tier 0 secret**. Keep it in your secret store (KMS/HSM), never log it (`repr()` is redacted, but `key.export_private()` is not), use **one key per user/session** (never share across principals), and use **EC P-256 only** — any other key type fails closed with a `ValueError` before any network call. +> The `dpop_key` private key is a **secret**. Keep it in your secret store (KMS/HSM), never log it (`repr()` is redacted, but `key.export_private()` is not), use **one key per user/session** (never share across principals), and use **EC P-256 only** - any other key type fails closed with a `ValueError` before any network call. ### Completing MFA on a passkey login (and where the session comes from) -When a passkey login needs a second factor, `signin_with_passkey` raises `MfaRequiredError` **before** it creates a session. You finish the login by challenging and verifying through `client.mfa`, then **store the returned tokens yourself** — on this path the SDK does not persist the session for you (`persist` defaults to `False`, and there is no existing session to update yet): +When a passkey login needs a second factor, `signin_with_passkey` raises `MfaRequiredError` **before** it creates a session. You finish the login by challenging and verifying through `client.mfa`, then **store the returned tokens yourself** - on this path the SDK does not persist the session for you (`persist` defaults to `False`, and there is no existing session to update yet): ```python from auth0_server_python.error import MfaRequiredError @@ -191,7 +190,7 @@ try: result = await server_client.signin_with_passkey( auth_session=auth_session, authn_response=authn_response, - dpop_key=dpop_key, # optional; omit for Bearer tokens + dpop_key=dpop_key, # optional, omit for Bearer tokens store_options={"request": request, "response": response}, ) # No MFA needed: signin_with_passkey already persisted the session for you. @@ -213,7 +212,7 @@ except MfaRequiredError as e: store_options={"request": request, "response": response}, ) - # 3. Persist the tokens into YOUR session yourself — this is the step the + # 3. Persist the tokens into YOUR session yourself - this is the step the # SDK skips on the MFA path because no session existed at login time. save_session_for_user( access_token=verify_response.access_token, @@ -223,23 +222,23 @@ except MfaRequiredError as e: ``` > [!IMPORTANT] -> Re-supply the **same** `dpop_key` to `verify`. Omitting it when the login was DPoP-bound would downgrade the result to a Bearer token; `verify` **rejects** that mismatch with `MfaVerifyError` rather than silently dropping the sender constraint. DPoP is preserved end to end — `persist=False` affects only *who writes the session*, never the token binding. +> Re-supply the **same** `dpop_key` to `verify`. Omitting it when the login was DPoP-bound would downgrade the result to a Bearer token; `verify` **rejects** that mismatch with `MfaVerifyError` rather than silently dropping the sender constraint. DPoP is preserved end to end - `persist=False` affects only *who writes the session*, never the token binding. > [!NOTE] -> Do **not** pass `persist=True` on this path. It updates an *existing* session, and a passkey-first login has none yet, so it raises `MfaVerifyError("No existing session found…")` — discarding the tokens `verify` just obtained. Use `persist=False` and store the returned tokens as shown above. (This is pre-existing MFA-client behavior, unrelated to passkeys.) +> Do **not** pass `persist=True` on this path. It updates an *existing* session, and a passkey-first login has none yet, so it raises `MfaVerifyError("No existing session found…")` - discarding the tokens `verify` just obtained. Use `persist=False` and store the returned tokens as shown above. ## 4. Identifier verification (optional) -Some connections require a new user to prove they own their email address or phone number before tokens are issued. When that is on, the signup challenge lists the identifiers that need a code, Auth0 sends each code, and you pass the codes back when you complete sign-in. Connections without it behave exactly as described above. +When a connection requires users to verify their email or phone number at signup, the signup challenge lists the identifiers that need a code, Auth0 sends each code, and you pass the codes to `signin_with_passkey`. Connections without verification follow the flow in section 1. -### Turning it on +### Requirements -Enable identifier verification for the email and/or phone identifier on your database connection, and make sure passkeys are enabled on that connection and a custom domain is configured. +Enable identifier verification for email, phone, or both on your database connection. Passkeys must be enabled on that connection, and a custom domain must be configured. ### The flow -1. Request the challenge. If `verification_required` is set, show a code field for each identifier it lists. -2. Collect the codes before you create the credential. Otherwise a user who fails verification is left with a saved passkey on their device for a signup that never finished. +1. Request the challenge. If `verification_required` is set, collect a code for each identifier it lists. +2. Collect the codes before creating the credential, so a failed verification does not leave a saved passkey for an unfinished signup. 3. Create the credential in the browser, then call `signin_with_passkey` with the codes in `verification`. ```python @@ -249,11 +248,9 @@ challenge = await server_client.passkey_signup_challenge( store_options={"request": request, "response": response}, ) -# For example ["email", "phone"]. None when nothing needs verifying. +# For example ["email", "phone"], or None when nothing needs verifying required = challenge.verification_required -# Show a field per identifier, collect the codes, then create the credential in the browser. - result = await server_client.signin_with_passkey( auth_session=challenge.auth_session, authn_response=authn_response, @@ -262,13 +259,13 @@ result = await server_client.signin_with_passkey( ) ``` -The keys of `verification` are `email` and `phone`, exactly as listed in `verification_required`. Any other key, such as `phone_number`, raises `InvalidArgumentError` before a request is sent, and a blank code is treated as not provided. Leave `verification` out when `verification_required` is `None`. +The keys of `verification` must be `email` or `phone`, as listed in `verification_required`. Any other key raises `InvalidArgumentError`, and blank codes are ignored. Omit `verification` when `verification_required` is `None`. -When verification is required the session lasts 15 minutes, and the `timeout` inside `authn_params_public_key` is 15 minutes too. A prompt built from it will stay open that long. +When verification is required, the session and the `timeout` in `authn_params_public_key` last 15 minutes. ### Handling a wrong code -A wrong code does not end the signup. Auth0 keeps the session, and `signin_with_passkey` raises a `PasskeyError` where `is_verification_retryable` is `True`. The error's `verification_required` lists only the identifiers that are still unverified, so ask the user for just those and call again. +After a wrong code the session stays valid. `signin_with_passkey` raises a `PasskeyError` with `is_verification_retryable` set to `True`, and its `verification_required` lists the identifiers still unverified. Resubmit codes for those identifiers only. ```python from auth0_server_python.error import PasskeyError @@ -293,25 +290,25 @@ for attempt in range(MAX_ATTEMPTS): codes = ask_user_for_codes(e.verification_required) ``` -Retry with the `auth_session` from the error, which is the same value today, and with the same `authn_response`, so the user is not asked for another biometric prompt. Send codes only for the identifiers still listed. Always cap the number of attempts, because Auth0 also limits them and ends the session when the limit is reached. +Retry with the `auth_session` from the error and the same `authn_response`. Cap the number of attempts, because Auth0 also limits them. `is_verification_retryable` is `False` in these cases. - Too many wrong codes, an expired code, or an expired session. Start again from the signup challenge. -- A missing code raises code `invalid_request`. The session is still valid, so call again with the same `auth_session` and the missing code, but treat it as a bug in your app because it means a code Auth0 asked for was not sent. Do not loop on it. -- A server error (code `server_error`) may or may not have ended the session. If Auth0 could not reach its verification service, the session is kept. If the failure came after the codes were accepted, the session is gone. The error does not say which, so call again once or twice with the same `auth_session`, `authn_response` and codes after a short delay. If the session is gone, the call fails with `invalid_grant` and no `auth_session`, and you start again from the signup challenge. -- Any other failure is raised with the code Auth0 returned. Treat it as a general failure. +- A missing code raises `invalid_request` and the session stays valid. Resubmit with the same `auth_session` and every required code. +- A server error (`server_error`) may or may not have ended the session. Retry with the same `auth_session`, `authn_response` and codes. If the call fails with `invalid_grant` and no `auth_session`, start again from the signup challenge. +- Any other failure is raised with the code Auth0 returned. -A dropped connection after Auth0 accepts the final code also ends the session, because each code works once. The user has to start again from the signup challenge. +If the connection drops after Auth0 accepts the final code, the session has ended because each code works once. Start again from the signup challenge. > [!IMPORTANT] -> If your tenant requires proof of possession, pass `dpop_key` on this call as well, otherwise the request is rejected. The rest of the DPoP behavior in section 3 is unchanged. +> If your tenant requires DPoP, pass `dpop_key` to `signin_with_passkey` as described in section 3. ## Error Handling -The three passkey methods raise `PasskeyError` (a subclass of `Auth0Error`). Input-validation failures raise `MissingRequiredArgumentError`; a required step-up raises `MfaRequiredError`. For most code, catching `Auth0Error` is enough. +The three passkey methods raise `PasskeyError`, a subclass of `Auth0Error`. Input-validation failures raise `MissingRequiredArgumentError` or `InvalidArgumentError`, and a required step-up raises `MfaRequiredError`. -### Basic handling (recommended) +### Basic handling ```python from auth0_server_python.error import Auth0Error @@ -338,7 +335,7 @@ try: store_options={"request": request, "response": response}, ) except MfaRequiredError as e: - return start_mfa(e.mfa_token) # step-up required — challenge + verify via client.mfa, + return start_mfa(e.mfa_token) # step-up required - challenge + verify via client.mfa, # then store the returned tokens (see "Completing MFA # on a passkey login" above) except PasskeyError as e: @@ -349,12 +346,12 @@ except Auth0Error as e: ### Common error codes (`PasskeyErrorCode`) -- `passkey_challenge_error` — the signup/login challenge request failed -- `passkey_token_error` — token exchange failed (also used for a rejected DPoP downgrade) -- `invalid_response` — Auth0 returned a response that could not be parsed -- `too_many_requests` when the signup challenge or the token exchange is rate limited +- `passkey_challenge_error` - the signup/login challenge request failed +- `passkey_token_error` - token exchange failed (also used for a rejected DPoP downgrade) +- `invalid_response` - Auth0 returned a response that could not be parsed +- `too_many_requests` when a challenge or the token exchange is rate limited -`PasskeyError` also exposes `auth_session`, `verification_required` and `is_verification_retryable` for [identifier verification](#4-identifier-verification-optional). Rate limiting on the signup challenge or the token exchange raises code `too_many_requests`. The per-IP limit on the signup challenge applies to public clients only. Treat `auth_session` on the error like any other session credential and keep it out of logs. +`PasskeyError` also exposes `auth_session`, `verification_required` and `is_verification_retryable` for [identifier verification](#4-identifier-verification-optional). Rate limiting on a challenge or the token exchange raises code `too_many_requests`. The per-IP limit on the challenge endpoints applies to public clients only. Treat `auth_session` on the error like any other session credential and keep it out of logs. > [!NOTE] > `auth_session` is a short-lived (typically about a minute, or 15 min when `verification_required` is set) credential. Never log or persist it. If the ceremony takes too long, re-request the challenge. diff --git a/src/auth0_server_python/auth_server/server_client.py b/src/auth0_server_python/auth_server/server_client.py index 4236845f..35b61b64 100644 --- a/src/auth0_server_python/auth_server/server_client.py +++ b/src/auth0_server_python/auth_server/server_client.py @@ -3630,7 +3630,7 @@ async def passkey_login_challenge( PasskeyLoginChallengeResponse with auth_session and authn_params_public_key. Raises: - PasskeyError: If the challenge request fails. + PasskeyError: If the challenge request fails or the request is rate limited. EnterpriseConnectError: If the client is configured for Enterprise Connect. """ if username is not None: @@ -3658,6 +3658,12 @@ async def passkey_login_challenge( url = f"https://{domain}{self.PASSKEY_CHALLENGE_PATH}" response = await client.post(url, json=body) + if response.status_code == 429: + raise PasskeyError( + PasskeyErrorCode.TOO_MANY_REQUESTS, + "Too many passkey login requests", + ) + if response.status_code != 200: try: error_data = response.json() diff --git a/src/auth0_server_python/tests/test_server_client.py b/src/auth0_server_python/tests/test_server_client.py index a55568ec..59b3ba8a 100644 --- a/src/auth0_server_python/tests/test_server_client.py +++ b/src/auth0_server_python/tests/test_server_client.py @@ -9518,6 +9518,28 @@ async def test_passkey_signup_challenge_rate_limited_json_raises_too_many_reques assert exc.value.code == PasskeyErrorCode.TOO_MANY_REQUESTS +@pytest.mark.asyncio +async def test_passkey_login_challenge_rate_limited_plain_text_raises_too_many_requests(mocker): + client = _make_passkey_client() + _mock_passkey_post(mocker, 429) + + with pytest.raises(PasskeyError) as exc: + await client.passkey_login_challenge() + + assert exc.value.code == PasskeyErrorCode.TOO_MANY_REQUESTS + + +@pytest.mark.asyncio +async def test_passkey_login_challenge_rate_limited_json_raises_too_many_requests(mocker): + client = _make_passkey_client() + _mock_passkey_post(mocker, 429, {"error": "access_denied", "error_description": "slow down"}) + + with pytest.raises(PasskeyError) as exc: + await client.passkey_login_challenge() + + assert exc.value.code == PasskeyErrorCode.TOO_MANY_REQUESTS + + @pytest.mark.asyncio async def test_signin_with_passkey_rate_limited_json_raises_too_many_requests(mocker): client = _make_passkey_signin_client(mocker) From 4a5fe897a0ad19930ddf7090bfe0318d23791a41 Mon Sep 17 00:00:00 2001 From: Sourav Basu Date: Mon, 12 Oct 2026 12:53:50 +0530 Subject: [PATCH 07/15] fix: Hide auth_session from accidental prints and logs --- examples/Passkeys.md | 1 + src/auth0_server_python/auth_types/__init__.py | 6 +++--- .../tests/test_server_client.py | 17 +++++++++++++++++ 3 files changed, 21 insertions(+), 3 deletions(-) diff --git a/examples/Passkeys.md b/examples/Passkeys.md index c6a24e79..329d6555 100644 --- a/examples/Passkeys.md +++ b/examples/Passkeys.md @@ -83,6 +83,7 @@ challenge = await server_client.passkey_signup_challenge( # challenge.auth_session -> opaque session credential (Tier 1, do not log) # challenge.authn_params_public_key -> pass to navigator.credentials.create() # challenge.verification_required -> codes to collect first, when set (see section 4) +# auth_session does not appear in print(challenge). To inspect it while debugging, read challenge.auth_session. ``` > [!TIP] diff --git a/src/auth0_server_python/auth_types/__init__.py b/src/auth0_server_python/auth_types/__init__.py index 271da2d6..ec5af008 100644 --- a/src/auth0_server_python/auth_types/__init__.py +++ b/src/auth0_server_python/auth_types/__init__.py @@ -878,18 +878,18 @@ class PasskeyUserProfile(BaseModel): class _PasskeyChallengeResponseBase(BaseModel): - auth_session: str + auth_session: str = Field(repr=False) authn_params_public_key: PasskeyPublicKeyOptions class PasskeySignupChallengeResponse(_PasskeyChallengeResponseBase): - """Signup challenge, with verification_required set when the user must enter codes first.""" + """Signup challenge, with verification_required set when the user must enter codes first. auth_session is hidden from repr and str.""" verification_required: Optional[list[str]] = None class PasskeyLoginChallengeResponse(_PasskeyChallengeResponseBase): - """Login challenge with the auth_session and WebAuthn options for the browser.""" + """Login challenge with the auth_session and WebAuthn options for the browser. auth_session is hidden from repr and str.""" class PasskeyTokenResponse(BaseModel): diff --git a/src/auth0_server_python/tests/test_server_client.py b/src/auth0_server_python/tests/test_server_client.py index 59b3ba8a..27e3b022 100644 --- a/src/auth0_server_python/tests/test_server_client.py +++ b/src/auth0_server_python/tests/test_server_client.py @@ -9471,6 +9471,23 @@ async def test_passkey_signup_challenge_delivery_method_forwarded(mocker, delive assert mock_post.call_args.kwargs["json"]["delivery_method"] == delivery_method +@pytest.mark.parametrize( + "model, payload", + [ + (PasskeySignupChallengeResponse, _PASSKEY_SIGNUP_CHALLENGE_RESPONSE), + (PasskeyLoginChallengeResponse, _PASSKEY_LOGIN_CHALLENGE_RESPONSE), + ], + ids=["signup", "login"], +) +def test_passkey_challenge_response_hides_auth_session_from_repr_and_str(model, payload): + challenge = model.model_validate(payload) + + assert payload["auth_session"] not in repr(challenge) + assert payload["auth_session"] not in str(challenge) + assert challenge.auth_session == payload["auth_session"] + assert challenge.model_dump()["auth_session"] == payload["auth_session"] + + @pytest.mark.asyncio async def test_passkey_signup_challenge_delivery_method_omitted_when_not_set(mocker): client = _make_passkey_client() From 4779ba20480de87b6ec5d42bcd9b8831d195d043 Mon Sep 17 00:00:00 2001 From: Sourav Basu Date: Mon, 12 Oct 2026 13:50:13 +0530 Subject: [PATCH 08/15] docs: add resend and operational notes to the passkey verification guide Co-Authored-By: Claude --- examples/Passkeys.md | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/examples/Passkeys.md b/examples/Passkeys.md index 329d6555..79030663 100644 --- a/examples/Passkeys.md +++ b/examples/Passkeys.md @@ -234,7 +234,7 @@ When a connection requires users to verify their email or phone number at signup ### Requirements -Enable identifier verification for email, phone, or both on your database connection. Passkeys must be enabled on that connection, and a custom domain must be configured. +Enable identifier verification for email, phone, or both on your database connection. ### The flow @@ -262,7 +262,9 @@ result = await server_client.signin_with_passkey( The keys of `verification` must be `email` or `phone`, as listed in `verification_required`. Any other key raises `InvalidArgumentError`, and blank codes are ignored. Omit `verification` when `verification_required` is `None`. -When verification is required, the session and the `timeout` in `authn_params_public_key` last 15 minutes. +### Resending codes + +If the user does not receive a code, call `passkey_signup_challenge` again before the passkey is created. Each call sends new codes for every identifier and returns a new `auth_session` and new `authn_params_public_key`, so use the values from the latest response. The previous session stays valid until it expires. ### Handling a wrong code @@ -293,6 +295,8 @@ for attempt in range(MAX_ATTEMPTS): Retry with the `auth_session` from the error and the same `authn_response`. Cap the number of attempts, because Auth0 also limits them. +Send one `signin_with_passkey` request at a time per `auth_session`. Parallel requests can use up the allowed attempts, or reach Auth0 after the session has already been used. + `is_verification_retryable` is `False` in these cases. - Too many wrong codes, an expired code, or an expired session. Start again from the signup challenge. @@ -302,6 +306,11 @@ Retry with the `auth_session` from the error and the same `authn_response`. Cap If the connection drops after Auth0 accepts the final code, the session has ended because each code works once. Start again from the signup challenge. +### Operational notes + +- Rate-limit the route that calls `passkey_signup_challenge`. Each call sends codes, so add limits per IP address and per email address or phone number. +- Do not log the verification codes. + > [!IMPORTANT] > If your tenant requires DPoP, pass `dpop_key` to `signin_with_passkey` as described in section 3. @@ -355,4 +364,4 @@ except Auth0Error as e: `PasskeyError` also exposes `auth_session`, `verification_required` and `is_verification_retryable` for [identifier verification](#4-identifier-verification-optional). Rate limiting on a challenge or the token exchange raises code `too_many_requests`. The per-IP limit on the challenge endpoints applies to public clients only. Treat `auth_session` on the error like any other session credential and keep it out of logs. > [!NOTE] -> `auth_session` is a short-lived (typically about a minute, or 15 min when `verification_required` is set) credential. Never log or persist it. If the ceremony takes too long, re-request the challenge. +> `auth_session` is a short-lived (typically about a minute, or 15 min when `verification_required` is set, matching the `timeout` in `authn_params_public_key`) credential. Never log or persist it. If the ceremony takes too long, re-request the challenge. From 606811521baaae6d95f05599d9cb0741ded7927e Mon Sep 17 00:00:00 2001 From: Sourav Basu Date: Mon, 19 Oct 2026 10:36:01 +0530 Subject: [PATCH 09/15] docs: document identifier verification in the passkey guide Add the verification flow, resend and wrong-code handling, and operational notes. Correct the claim that collecting codes first prevents a saved passkey, since Auth0 checks codes only in signin_with_passkey. Add the client_ip option and the rate-limit and lost-response guidance. Co-Authored-By: Claude --- examples/Passkeys.md | 45 +++++++++++++++++++++++++------------------- 1 file changed, 26 insertions(+), 19 deletions(-) diff --git a/examples/Passkeys.md b/examples/Passkeys.md index 79030663..e488cf89 100644 --- a/examples/Passkeys.md +++ b/examples/Passkeys.md @@ -234,12 +234,15 @@ When a connection requires users to verify their email or phone number at signup ### Requirements -Enable identifier verification for email, phone, or both on your database connection. +Enable identifier verification for email, phone, or both on your database connection. For email, choose the One-Time Password (OTP) method, because this flow passes codes and does not support verification links. + +- Configure an email provider for email codes and a phone provider for phone codes. Without one the code is never sent, so the user can't finish signing up. +- Upgrade your app to an SDK version whose `signin_with_passkey` accepts `verification` before you turn verification on. Older versions can't send the codes, so every signup on the connection fails. ### The flow 1. Request the challenge. If `verification_required` is set, collect a code for each identifier it lists. -2. Collect the codes before creating the credential, so a failed verification does not leave a saved passkey for an unfinished signup. +2. Collect the codes before creating the credential. This order is a UX requirement, and the SDK can't enforce it. Your front end must not call `navigator.credentials.create()` until every required code is collected. Auth0 checks the codes only in `signin_with_passkey`, after the credential exists, so a wrong code still leaves a passkey on the device with no account behind it. The retry reuses that credential. 3. Create the credential in the browser, then call `signin_with_passkey` with the codes in `verification`. ```python @@ -262,39 +265,41 @@ result = await server_client.signin_with_passkey( The keys of `verification` must be `email` or `phone`, as listed in `verification_required`. Any other key raises `InvalidArgumentError`, and blank codes are ignored. Omit `verification` when `verification_required` is `None`. +`delivery_method` applies only to the phone code. If only email needs a code, the value has no effect. + ### Resending codes If the user does not receive a code, call `passkey_signup_challenge` again before the passkey is created. Each call sends new codes for every identifier and returns a new `auth_session` and new `authn_params_public_key`, so use the values from the latest response. The previous session stays valid until it expires. ### Handling a wrong code -After a wrong code the session stays valid. `signin_with_passkey` raises a `PasskeyError` with `is_verification_retryable` set to `True`, and its `verification_required` lists the identifiers still unverified. Resubmit codes for those identifiers only. +After a wrong code the session stays valid. `signin_with_passkey` raises a `PasskeyError` with `is_verification_retryable` set to `True`, and its `verification_required` lists the identifiers still unverified. + +Each attempt is one HTTP request. On a wrong code, the handler returns `verification_required` and the `auth_session` from the error. The browser asks for those codes only and posts again with the same credential. Cap the attempts in your app, because Auth0 also limits them. ```python -from auth0_server_python.error import PasskeyError +from fastapi.responses import JSONResponse -MAX_ATTEMPTS = 3 -auth_session = challenge.auth_session -codes = {"email": email_code, "phone": phone_code} +from auth0_server_python.error import PasskeyError -for attempt in range(MAX_ATTEMPTS): +async def passkey_signin(request, response, body): try: - result = await server_client.signin_with_passkey( - auth_session=auth_session, - authn_response=authn_response, - verification=codes, + return await server_client.signin_with_passkey( + auth_session=body["auth_session"], + authn_response=body["authn_response"], + verification=body.get("verification"), store_options={"request": request, "response": response}, ) - break except PasskeyError as e: - if not e.is_verification_retryable or attempt == MAX_ATTEMPTS - 1: + if not e.is_verification_retryable: raise - auth_session = e.auth_session - codes = ask_user_for_codes(e.verification_required) + # Send the unverified identifiers back so the page asks only for those codes. + return JSONResponse( + {"verification_required": e.verification_required, "auth_session": e.auth_session}, + status_code=400, + ) ``` -Retry with the `auth_session` from the error and the same `authn_response`. Cap the number of attempts, because Auth0 also limits them. - Send one `signin_with_passkey` request at a time per `auth_session`. Parallel requests can use up the allowed attempts, or reach Auth0 after the session has already been used. `is_verification_retryable` is `False` in these cases. @@ -302,13 +307,15 @@ Send one `signin_with_passkey` request at a time per `auth_session`. Parallel re - Too many wrong codes, an expired code, or an expired session. Start again from the signup challenge. - A missing code raises `invalid_request` and the session stays valid. Resubmit with the same `auth_session` and every required code. - A server error (`server_error`) may or may not have ended the session. Retry with the same `auth_session`, `authn_response` and codes. If the call fails with `invalid_grant` and no `auth_session`, start again from the signup challenge. +- A rate limit (`too_many_requests`). Do not retry right away. Ask the user to try again later. - Any other failure is raised with the code Auth0 returned. -If the connection drops after Auth0 accepts the final code, the session has ended because each code works once. Start again from the signup challenge. +If the connection drops after Auth0 accepts the final code, or a Post Login Action denies the login, the account may or may not exist. Show a generic message that offers sign in, without confirming whether the account exists. Route a repeat signup with the same email to sign in. Signup returns `invalid_request` when the account already exists, so branch on that error code to choose the path. ### Operational notes - Rate-limit the route that calls `passkey_signup_challenge`. Each call sends codes, so add limits per IP address and per email address or phone number. +- Pass `client_ip` to `passkey_signup_challenge`, `passkey_login_challenge` and `signin_with_passkey` to forward the end user's IP address as the `auth0-forwarded-for` header. Without it Auth0 sees your server's address. Auth0 uses the header when the **Trust Token Endpoint IP Header** setting is enabled for the application, and, while identifier verification is enabled for your tenant, it rejects a value that is not a valid IP address. Forward only a trusted, normalized IP from your edge or proxy layer. - Do not log the verification codes. > [!IMPORTANT] From 15e96400a725197195ef4a97f3ab4db6e083be50 Mon Sep 17 00:00:00 2001 From: Sourav Basu Date: Mon, 19 Oct 2026 14:54:09 +0530 Subject: [PATCH 10/15] docs: mention retry_after on rate-limited passkey errors Co-Authored-By: Claude --- examples/Passkeys.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/Passkeys.md b/examples/Passkeys.md index e488cf89..a6ba7cfb 100644 --- a/examples/Passkeys.md +++ b/examples/Passkeys.md @@ -307,7 +307,7 @@ Send one `signin_with_passkey` request at a time per `auth_session`. Parallel re - Too many wrong codes, an expired code, or an expired session. Start again from the signup challenge. - A missing code raises `invalid_request` and the session stays valid. Resubmit with the same `auth_session` and every required code. - A server error (`server_error`) may or may not have ended the session. Retry with the same `auth_session`, `authn_response` and codes. If the call fails with `invalid_grant` and no `auth_session`, start again from the signup challenge. -- A rate limit (`too_many_requests`). Do not retry right away. Ask the user to try again later. +- A rate limit (`too_many_requests`). Do not retry right away. When `retry_after` is set, wait that many seconds. Otherwise ask the user to try again later. - Any other failure is raised with the code Auth0 returned. If the connection drops after Auth0 accepts the final code, or a Post Login Action denies the login, the account may or may not exist. Show a generic message that offers sign in, without confirming whether the account exists. Route a repeat signup with the same email to sign in. Signup returns `invalid_request` when the account already exists, so branch on that error code to choose the path. @@ -368,7 +368,7 @@ except Auth0Error as e: - `invalid_response` - Auth0 returned a response that could not be parsed - `too_many_requests` when a challenge or the token exchange is rate limited -`PasskeyError` also exposes `auth_session`, `verification_required` and `is_verification_retryable` for [identifier verification](#4-identifier-verification-optional). Rate limiting on a challenge or the token exchange raises code `too_many_requests`. The per-IP limit on the challenge endpoints applies to public clients only. Treat `auth_session` on the error like any other session credential and keep it out of logs. +`PasskeyError` also exposes `retry_after` (seconds, or `None` when Auth0 sends no `Retry-After` header), `auth_session`, `verification_required` and `is_verification_retryable` for [identifier verification](#4-identifier-verification-optional). Rate limiting on a challenge or the token exchange raises code `too_many_requests`. The per-IP limit on the challenge endpoints applies to public clients only. Treat `auth_session` on the error like any other session credential and keep it out of logs. > [!NOTE] > `auth_session` is a short-lived (typically about a minute, or 15 min when `verification_required` is set, matching the `timeout` in `authn_params_public_key`) credential. Never log or persist it. If the ceremony takes too long, re-request the challenge. From c757b4d9f28245b7c0290bdbf114ee96d72586e1 Mon Sep 17 00:00:00 2001 From: Sourav Basu Date: Mon, 19 Oct 2026 14:54:09 +0530 Subject: [PATCH 11/15] feat: harden passkey identifier verification after review - Add optional client_ip to the three passkey methods, sent as auth0-forwarded-for. - Reject a delivery_method other than text or voice, and a non-dict verification, before any request. - Retry the DPoP nonce on the passkey token call only for use_dpop_nonce. - Add retry_after to PasskeyError and keep Auth0's error_description on a 429. - Hide input from validation errors on the passkey challenge and token models. - Use realistic 429 bodies in tests and check verification is sent as a JSON object. Co-Authored-By: Claude --- .../auth_server/server_client.py | 78 +++-- .../auth_types/__init__.py | 3 +- src/auth0_server_python/error/__init__.py | 3 + .../tests/test_server_client.py | 266 +++++++++++++++++- 4 files changed, 324 insertions(+), 26 deletions(-) diff --git a/src/auth0_server_python/auth_server/server_client.py b/src/auth0_server_python/auth_server/server_client.py index 35b61b64..32cc3190 100644 --- a/src/auth0_server_python/auth_server/server_client.py +++ b/src/auth0_server_python/auth_server/server_client.py @@ -31,7 +31,10 @@ from auth0_server_python.auth_server.anonymous import AnonymousClient 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_server.passwordless_client import ( + FORWARDED_FOR_HEADER, + PasswordlessClient, +) from auth0_server_python.auth_types import ( CompleteConnectAccountRequest, CompleteConnectAccountResponse, @@ -3521,6 +3524,7 @@ async def passkey_signup_challenge( user_metadata: Optional[dict[str, Any]] = None, store_options: Optional[dict[str, Any]] = None, delivery_method: Optional[PasskeyDeliveryMethod] = None, + client_ip: Optional[str] = None, ) -> PasskeySignupChallengeResponse: """ Step 1 of 2: Initiate a passkey signup challenge (POST /passkey/register). @@ -3537,15 +3541,20 @@ async def passkey_signup_challenge( not nested inside user_profile (per Auth0 API spec). store_options: Optional options for domain resolution. delivery_method: Optional channel for the phone verification code, "text" or "voice". + client_ip: Optional end user IP address, sent as the auth0-forwarded-for header. Returns: PasskeySignupChallengeResponse with auth_session, authn_params_public_key and, when the connection requires verification, verification_required. Raises: + InvalidArgumentError: If delivery_method is set to a value other than "text" or "voice". PasskeyError: If the challenge request fails or the request is rate limited. EnterpriseConnectError: If the client is configured for Enterprise Connect. """ + if delivery_method is not None and delivery_method not in ("text", "voice"): + raise InvalidArgumentError("delivery_method", "delivery_method must be 'text' or 'voice'") + try: domain = await self._resolve_current_domain(store_options) @@ -3566,14 +3575,11 @@ async def passkey_signup_challenge( async with self._get_http_client() as client: url = f"https://{domain}{self.PASSKEY_REGISTER_PATH}" - response = await client.post(url, json=body) + headers = {FORWARDED_FOR_HEADER: client_ip} if client_ip else {} + response = await client.post(url, json=body, headers=headers) if response.status_code == 429: - # The limiter can answer with a plain-text body, so check before parsing. - raise PasskeyError( - PasskeyErrorCode.TOO_MANY_REQUESTS, - "Too many passkey signup requests", - ) + raise self._passkey_rate_limit_error(response, "Too many passkey signup requests") if response.status_code != 200: try: @@ -3609,6 +3615,7 @@ async def passkey_login_challenge( connection: Optional[str] = None, organization: Optional[str] = None, store_options: Optional[dict[str, Any]] = None, + client_ip: Optional[str] = None, ) -> PasskeyLoginChallengeResponse: """ Step 1 of 2: Initiate a passkey login challenge (POST /passkey/challenge). @@ -3625,6 +3632,7 @@ async def passkey_login_challenge( connection: Auth0 database connection name (realm). organization: Auth0 organization ID or name. store_options: Optional options for domain resolution. + client_ip: Optional end user IP address, sent as the auth0-forwarded-for header. Returns: PasskeyLoginChallengeResponse with auth_session and authn_params_public_key. @@ -3656,13 +3664,11 @@ async def passkey_login_challenge( async with self._get_http_client() as client: url = f"https://{domain}{self.PASSKEY_CHALLENGE_PATH}" - response = await client.post(url, json=body) + headers = {FORWARDED_FOR_HEADER: client_ip} if client_ip else {} + response = await client.post(url, json=body, headers=headers) if response.status_code == 429: - raise PasskeyError( - PasskeyErrorCode.TOO_MANY_REQUESTS, - "Too many passkey login requests", - ) + raise self._passkey_rate_limit_error(response, "Too many passkey login requests") if response.status_code != 200: try: @@ -3692,6 +3698,33 @@ async def passkey_login_challenge( raise raise PasskeyError(PasskeyErrorCode.CHALLENGE_FAILED, "Passkey login challenge failed", e) from e + @staticmethod + def _passkey_rate_limit_error(response, default_message: str) -> PasskeyError: + # The limiter can answer with a plain-text body, so a parse failure keeps the default. + message = default_message + try: + description = response.json().get("error_description") + if isinstance(description, str) and description: + message = description + except (ValueError, AttributeError): + pass + + retry_after = None + try: + retry_after = int(response.headers.get("Retry-After")) + except (TypeError, ValueError): + pass + + return PasskeyError(PasskeyErrorCode.TOO_MANY_REQUESTS, message, retry_after=retry_after) + + @staticmethod + def _is_dpop_nonce_challenge(response) -> bool: + try: + data = response.json() + except ValueError: + return False + return isinstance(data, dict) and data.get("error") == "use_dpop_nonce" + async def signin_with_passkey( self, auth_session: str, @@ -3702,7 +3735,8 @@ async def signin_with_passkey( scope: Optional[str] = None, audience: Optional[str] = None, dpop_key: Optional["jwk.JWK"] = None, - verification: Optional[dict[str, str]] = None, + verification: Optional[dict[str, Optional[str]]] = None, + client_ip: Optional[str] = None, ) -> PasskeyLoginResult: """ Completes passkey authentication by exchanging the WebAuthn assertion @@ -3724,6 +3758,7 @@ async def signin_with_passkey( attaches a DPoP proof header so Auth0 issues a DPoP-bound token (token_type: DPoP). Required when the tenant mandates DPoP binding. verification: Optional signup codes keyed by "email" or "phone", not used for login. + client_ip: Optional end user IP address, sent as the auth0-forwarded-for header. Returns: PasskeyLoginResult containing state_data with user claims and token sets, @@ -3755,6 +3790,10 @@ async def signin_with_passkey( "differently. DPoP would take precedence and the token would not be " "certificate-bound." ) + if verification is not None and not isinstance(verification, dict): + raise InvalidArgumentError( + "verification", "verification must be a dict keyed by 'email' or 'phone'" + ) verification_codes: dict[str, str] = {} for identifier, code in (verification or {}).items(): if identifier not in self.PASSKEY_VERIFICATION_IDENTIFIERS: @@ -3799,21 +3838,19 @@ async def signin_with_passkey( body["verification"] = verification_codes async with self._get_http_client() as client: - headers = {} + headers = {FORWARDED_FOR_HEADER: client_ip} if client_ip else {} if dpop_key is not None: headers["DPoP"] = make_dpop_proof_for_token_endpoint( dpop_key, "POST", token_endpoint ) response = await client.post(token_endpoint, json=body, headers=headers) - # RFC 9449 — the authorization server signals a required nonce - # with HTTP 400 + error="use_dpop_nonce" + DPoP-Nonce. Accept - # 401 as well so the retry holds against servers that mirror the - # resource-server status. + # RFC 9449 nonce challenge: HTTP 400 or 401 with error use_dpop_nonce and DPoP-Nonce. if ( dpop_key is not None and response.status_code in (400, 401) and response.headers.get("DPoP-Nonce") + and self._is_dpop_nonce_challenge(response) ): nonce = response.headers["DPoP-Nonce"] headers["DPoP"] = make_dpop_proof_for_token_endpoint( @@ -3822,10 +3859,7 @@ async def signin_with_passkey( response = await client.post(token_endpoint, json=body, headers=headers) if response.status_code == 429: - raise PasskeyError( - PasskeyErrorCode.TOO_MANY_REQUESTS, - "Too many passkey token requests", - ) + raise self._passkey_rate_limit_error(response, "Too many passkey token requests") if response.status_code != 200: try: diff --git a/src/auth0_server_python/auth_types/__init__.py b/src/auth0_server_python/auth_types/__init__.py index ec5af008..f2649c03 100644 --- a/src/auth0_server_python/auth_types/__init__.py +++ b/src/auth0_server_python/auth_types/__init__.py @@ -878,6 +878,7 @@ class PasskeyUserProfile(BaseModel): class _PasskeyChallengeResponseBase(BaseModel): + model_config = ConfigDict(hide_input_in_errors=True) auth_session: str = Field(repr=False) authn_params_public_key: PasskeyPublicKeyOptions @@ -895,7 +896,7 @@ class PasskeyLoginChallengeResponse(_PasskeyChallengeResponseBase): class PasskeyTokenResponse(BaseModel): """Token endpoint response for the passkey grant.""" - model_config = ConfigDict(extra="allow") + model_config = ConfigDict(extra="allow", hide_input_in_errors=True) access_token: str token_type: str = "Bearer" expires_in: int diff --git a/src/auth0_server_python/error/__init__.py b/src/auth0_server_python/error/__init__.py index 59a81918..58226003 100644 --- a/src/auth0_server_python/error/__init__.py +++ b/src/auth0_server_python/error/__init__.py @@ -416,6 +416,7 @@ class PasskeyError(Auth0Error): cause: Underlying exception, if any. auth_session: Session to resubmit against, set when Auth0 keeps it alive after a wrong code. verification_required: Identifiers whose codes are still unverified. + retry_after: Seconds from the Retry-After header on a 429, None when absent. """ def __init__( @@ -425,6 +426,7 @@ def __init__( cause=None, auth_session: Optional[str] = None, verification_required: Optional[list[str]] = None, + retry_after: Optional[int] = None, ): super().__init__(message) self.code = code @@ -432,6 +434,7 @@ def __init__( self.cause = cause self.auth_session = auth_session self.verification_required = verification_required + self.retry_after = retry_after # seconds @property def is_verification_retryable(self) -> bool: diff --git a/src/auth0_server_python/tests/test_server_client.py b/src/auth0_server_python/tests/test_server_client.py index 27e3b022..d7154583 100644 --- a/src/auth0_server_python/tests/test_server_client.py +++ b/src/auth0_server_python/tests/test_server_client.py @@ -15,6 +15,7 @@ from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric import ec, rsa from jwcrypto import jwk +from pydantic import ValidationError from auth0_server_python.auth_schemes.dpop_auth import DPoPAuth from auth0_server_python.auth_server.anonymous import ANON_IDENTIFIER, AnonymousClient @@ -47,6 +48,7 @@ PasskeyLoginChallengeResponse, PasskeyLoginResult, PasskeySignupChallengeResponse, + PasskeyTokenResponse, PasskeyUserProfile, SessionTransferTokenResult, StartEnterpriseLoginOptions, @@ -8459,6 +8461,41 @@ async def test_signin_with_passkey_dpop_attaches_proof_header(mocker): assert payload["htu"] == "https://auth0.local/oauth/token" +@pytest.mark.asyncio +@pytest.mark.parametrize( + "response_body", + [ + {"error": "invalid_grant", "error_description": "Wrong code", "auth_session": "session_abc123"}, + None, + ], + ids=["invalid_grant_with_nonce", "non_json_body_with_nonce"], +) +async def test_signin_with_passkey_dpop_nonce_header_not_retried_without_use_dpop_nonce( + mocker, response_body +): + client = _make_passkey_signin_client(mocker) + mock_post = mocker.patch("httpx.AsyncClient.post", new_callable=AsyncMock) + rejected = AsyncMock() + rejected.status_code = 400 + rejected.headers = {"DPoP-Nonce": "server-nonce-other"} + if response_body is None: + rejected.json = MagicMock(side_effect=json.JSONDecodeError("bad", "", 0)) + else: + rejected.json = MagicMock(return_value=response_body) + mock_post.side_effect = [rejected] + + dpop_key = jwk.JWK.generate(kty="EC", crv="P-256") + with pytest.raises(PasskeyError): + await client.signin_with_passkey( + auth_session="session_abc123", + authn_response=_make_passkey_authn_response(), + dpop_key=dpop_key, + verification={"email": "000000"}, + ) + + assert mock_post.await_count == 1 + + @pytest.mark.asyncio async def test_signin_with_passkey_dpop_nonce_retry(mocker): client = ServerClient( @@ -9488,6 +9525,28 @@ def test_passkey_challenge_response_hides_auth_session_from_repr_and_str(model, assert challenge.model_dump()["auth_session"] == payload["auth_session"] +@pytest.mark.parametrize( + "model, payload, secret", + [ + (PasskeySignupChallengeResponse, {"auth_session": "sess-secret-1"}, "sess-secret-1"), + (PasskeyLoginChallengeResponse, {"auth_session": "sess-secret-2"}, "sess-secret-2"), + (PasskeySignupChallengeResponse, "sess-secret-3", "sess-secret-3"), + ( + PasskeyTokenResponse, + {"access_token": "at-secret", "refresh_token": "rt-secret"}, + "rt-secret", + ), + ], + ids=["signup_missing_field", "login_missing_field", "signup_non_dict", "token_missing_field"], +) +def test_passkey_models_hide_input_from_validation_errors(model, payload, secret): + with pytest.raises(ValidationError) as exc: + model.model_validate(payload) + + assert secret not in str(exc.value) + assert exc.value.error_count() >= 1 + + @pytest.mark.asyncio async def test_passkey_signup_challenge_delivery_method_omitted_when_not_set(mocker): client = _make_passkey_client() @@ -9498,6 +9557,98 @@ async def test_passkey_signup_challenge_delivery_method_omitted_when_not_set(moc assert "delivery_method" not in mock_post.call_args.kwargs["json"] +@pytest.mark.asyncio +@pytest.mark.parametrize("delivery_method", ["sms", "", "Text", "VOICE"]) +async def test_passkey_signup_challenge_rejects_unsupported_delivery_method_before_request( + mocker, delivery_method +): + client = _make_passkey_client() + mock_post = _mock_passkey_post(mocker, 200, _PASSKEY_SIGNUP_CHALLENGE_RESPONSE) + + with pytest.raises(InvalidArgumentError) as exc: + await client.passkey_signup_challenge(delivery_method=delivery_method) + + assert "delivery_method" in str(exc.value) + mock_post.assert_not_called() + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "method, payload", + [ + ("passkey_signup_challenge", _PASSKEY_SIGNUP_CHALLENGE_RESPONSE), + ("passkey_login_challenge", _PASSKEY_LOGIN_CHALLENGE_RESPONSE), + ], + ids=["signup", "login"], +) +async def test_passkey_challenge_client_ip_sent_as_forwarded_for_header(mocker, method, payload): + client = _make_passkey_client() + mock_post = _mock_passkey_post(mocker, 200, payload) + + await getattr(client, method)(client_ip="203.0.113.7") + + assert mock_post.call_args.kwargs["headers"]["auth0-forwarded-for"] == "203.0.113.7" + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "method, payload", + [ + ("passkey_signup_challenge", _PASSKEY_SIGNUP_CHALLENGE_RESPONSE), + ("passkey_login_challenge", _PASSKEY_LOGIN_CHALLENGE_RESPONSE), + ], + ids=["signup", "login"], +) +async def test_passkey_challenge_forwarded_for_header_omitted_without_client_ip(mocker, method, payload): + client = _make_passkey_client() + mock_post = _mock_passkey_post(mocker, 200, payload) + + await getattr(client, method)() + + assert "auth0-forwarded-for" not in mock_post.call_args.kwargs.get("headers", {}) + + +@pytest.mark.asyncio +async def test_signin_with_passkey_client_ip_sent_as_forwarded_for_header(mocker): + client = _make_passkey_signin_client(mocker) + mock_post = _mock_passkey_post(mocker, 400, {"error": "invalid_grant"}) + + await _signin_expecting_error(client, client_ip="203.0.113.7") + + assert mock_post.call_args.kwargs["headers"]["auth0-forwarded-for"] == "203.0.113.7" + + +@pytest.mark.asyncio +async def test_signin_with_passkey_forwarded_for_header_omitted_without_client_ip(mocker): + client = _make_passkey_signin_client(mocker) + mock_post = _mock_passkey_post(mocker, 400, {"error": "invalid_grant"}) + + await _signin_expecting_error(client) + + assert "auth0-forwarded-for" not in mock_post.call_args.kwargs["headers"] + + +@pytest.mark.asyncio +async def test_signin_with_passkey_client_ip_and_dpop_key_send_both_headers(mocker): + client = _make_passkey_signin_client(mocker) + mocker.patch.object(client, "_get_jwks_cached", return_value={}) + mocker.patch.object(client, "_verify_and_decode_jwt", return_value={ + "sub": "auth0|user123", "iss": "https://auth0.local/" + }) + mock_post = _mock_passkey_post(mocker, 200, _PASSKEY_TOKEN_RESPONSE_DPOP) + + await client.signin_with_passkey( + auth_session="session_abc123", + authn_response=_make_passkey_authn_response(), + dpop_key=jwk.JWK.generate(kty="EC", crv="P-256"), + client_ip="203.0.113.7", + ) + + headers = mock_post.call_args.kwargs["headers"] + assert headers["auth0-forwarded-for"] == "203.0.113.7" + assert "DPoP" in headers + + @pytest.mark.asyncio async def test_passkey_signup_challenge_unsupported_delivery_method_surfaces_server_error(mocker): client = _make_passkey_client() @@ -9527,7 +9678,9 @@ async def test_passkey_signup_challenge_rate_limited_plain_text_raises_too_many_ @pytest.mark.asyncio async def test_passkey_signup_challenge_rate_limited_json_raises_too_many_requests(mocker): client = _make_passkey_client() - _mock_passkey_post(mocker, 429, {"error": "rate_limit_exceeded", "error_description": "slow down"}) + _mock_passkey_post( + mocker, 429, {"error": "too_many_requests", "error_description": "Global rate limit exceeded"} + ) with pytest.raises(PasskeyError) as exc: await client.passkey_signup_challenge() @@ -9549,7 +9702,9 @@ async def test_passkey_login_challenge_rate_limited_plain_text_raises_too_many_r @pytest.mark.asyncio async def test_passkey_login_challenge_rate_limited_json_raises_too_many_requests(mocker): client = _make_passkey_client() - _mock_passkey_post(mocker, 429, {"error": "access_denied", "error_description": "slow down"}) + _mock_passkey_post( + mocker, 429, {"error": "too_many_requests", "error_description": "Global rate limit exceeded"} + ) with pytest.raises(PasskeyError) as exc: await client.passkey_login_challenge() @@ -9562,7 +9717,7 @@ async def test_signin_with_passkey_rate_limited_json_raises_too_many_requests(mo client = _make_passkey_signin_client(mocker) _mock_passkey_post(mocker, 429, { "error": "access_denied", - "error_description": "Global rate limit exceeded", + "error_description": "Too Many Requests", }) error = await _signin_expecting_error(client) @@ -9581,6 +9736,67 @@ async def test_signin_with_passkey_rate_limited_plain_text_raises_too_many_reque assert error.code == PasskeyErrorCode.TOO_MANY_REQUESTS +async def _call_passkey_method(mocker, method): + if method == "signin_with_passkey": + client = _make_passkey_signin_client(mocker) + return client, lambda: _signin_expecting_error(client) + client = _make_passkey_client() + + async def call(): + with pytest.raises(PasskeyError) as exc: + await getattr(client, method)() + return exc.value + + return client, call + + +_PASSKEY_429_METHODS = ["passkey_signup_challenge", "passkey_login_challenge", "signin_with_passkey"] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("method", _PASSKEY_429_METHODS) +@pytest.mark.parametrize( + ("header", "expected"), + [("30", 30), (None, None), ("Wed, 21 Oct 2026 07:28:00 GMT", None), ("soon", None)], + ids=["seconds", "absent", "http_date", "garbage"], +) +async def test_passkey_rate_limited_exposes_retry_after(mocker, method, header, expected): + _, call = await _call_passkey_method(mocker, method) + mock_post = _mock_passkey_post(mocker, 429) + if header is not None: + mock_post.return_value.headers = {"Retry-After": header} + + error = await call() + + assert error.code == PasskeyErrorCode.TOO_MANY_REQUESTS + assert error.retry_after == expected + + +@pytest.mark.asyncio +@pytest.mark.parametrize("method", _PASSKEY_429_METHODS) +@pytest.mark.parametrize( + ("body", "expected"), + [ + ({"error": "too_many_requests", "error_description": "Global rate limit exceeded"}, "Global rate limit exceeded"), + ({"error": "too_many_requests"}, None), + ({"error_description": 429}, None), + (["not", "a", "dict"], None), + (None, None), + ], + ids=["json_description", "no_description", "non_string_description", "non_dict_json", "plain_text"], +) +async def test_passkey_rate_limited_message(mocker, method, body, expected): + _, call = await _call_passkey_method(mocker, method) + _mock_passkey_post(mocker, 429, body) + + error = await call() + + if expected is None: + assert error.message.startswith("Too many passkey") + else: + assert error.message == expected + + @pytest.mark.asyncio async def test_signin_with_passkey_wrong_code_error_is_verification_retryable(mocker): client = _make_passkey_signin_client(mocker) @@ -9681,6 +9897,28 @@ async def test_signin_with_passkey_verification_forwarded(mocker): assert mock_post.call_args.kwargs["json"]["verification"] == {"email": "123456", "phone": "654321"} +@pytest.mark.asyncio +async def test_signin_with_passkey_verification_sent_as_json_object_on_the_wire(mocker): + client = _make_passkey_signin_client(mocker) + captured = {} + + def handler(request): + captured["content_type"] = request.headers.get("content-type") + captured["body"] = json.loads(request.content) + return httpx.Response(400, json={"error": "invalid_grant"}) + + mocker.patch.object( + client, + "_get_http_client", + side_effect=lambda **kwargs: httpx.AsyncClient(transport=httpx.MockTransport(handler)), + ) + + await _signin_expecting_error(client, verification={"email": "123456", "phone": "654321"}) + + assert captured["content_type"] == "application/json" + assert captured["body"]["verification"] == {"email": "123456", "phone": "654321"} + + @pytest.mark.asyncio async def test_signin_with_passkey_verification_none_values_dropped(mocker): client = _make_passkey_signin_client(mocker) @@ -9691,6 +9929,28 @@ async def test_signin_with_passkey_verification_none_values_dropped(mocker): assert mock_post.call_args.kwargs["json"]["verification"] == {"email": "123456"} +@pytest.mark.asyncio +@pytest.mark.parametrize( + "verification", + ["123456", ["123456"], 123456], + ids=["str", "list", "int"], +) +async def test_signin_with_passkey_verification_non_dict_raises_before_request(mocker, verification): + client = _make_passkey_signin_client(mocker) + mock_post = _mock_passkey_post(mocker, 400, {"error": "invalid_grant"}) + + with pytest.raises(InvalidArgumentError) as exc: + await client.signin_with_passkey( + auth_session="session_abc123", + authn_response=_make_passkey_authn_response(), + verification=verification, + ) + + assert exc.value.argument == "verification" + assert "123456" not in str(exc.value) + mock_post.assert_not_called() + + @pytest.mark.asyncio @pytest.mark.parametrize( "verification", From 79af3b919dac0a766d80aa01e9ffc07b327a76b0 Mon Sep 17 00:00:00 2001 From: Sourav Basu Date: Mon, 19 Oct 2026 15:28:58 +0530 Subject: [PATCH 12/15] fix: keep released passkey error codes on rate limits A 429 now raises the same code as before (the server's error, or the SDK default for a non-JSON body) instead of a new too_many_requests code. PasskeyError gains status_code and retry_after on every non-200 response, so callers detect a rate limit with status_code == 429. A JSON error body that is not an object keeps the default code and the status. Removes the unreleased PasskeyErrorCode.TOO_MANY_REQUESTS and updates the passkey guide for status_code, retry_after, invalid_grant and input validation. Co-Authored-By: Claude --- examples/Passkeys.md | 16 +- .../auth_server/server_client.py | 37 ++-- src/auth0_server_python/error/__init__.py | 6 +- src/auth0_server_python/tests/test_error.py | 6 +- .../tests/test_server_client.py | 174 +++++++++--------- 5 files changed, 119 insertions(+), 120 deletions(-) diff --git a/examples/Passkeys.md b/examples/Passkeys.md index a6ba7cfb..2144a4fb 100644 --- a/examples/Passkeys.md +++ b/examples/Passkeys.md @@ -263,9 +263,9 @@ result = await server_client.signin_with_passkey( ) ``` -The keys of `verification` must be `email` or `phone`, as listed in `verification_required`. Any other key raises `InvalidArgumentError`, and blank codes are ignored. Omit `verification` when `verification_required` is `None`. +`verification` must be a dict whose keys are `email` or `phone`, as listed in `verification_required`, and whose values are strings. Anything else raises `InvalidArgumentError` before any request is sent. Blank codes are ignored. Omit `verification` when `verification_required` is `None`. -`delivery_method` applies only to the phone code. If only email needs a code, the value has no effect. +`delivery_method` applies only to the phone code. If only email needs a code, the value has no effect. Any value other than `"text"` or `"voice"` raises `InvalidArgumentError` before the request is sent. ### Resending codes @@ -307,8 +307,8 @@ Send one `signin_with_passkey` request at a time per `auth_session`. Parallel re - Too many wrong codes, an expired code, or an expired session. Start again from the signup challenge. - A missing code raises `invalid_request` and the session stays valid. Resubmit with the same `auth_session` and every required code. - A server error (`server_error`) may or may not have ended the session. Retry with the same `auth_session`, `authn_response` and codes. If the call fails with `invalid_grant` and no `auth_session`, start again from the signup challenge. -- A rate limit (`too_many_requests`). Do not retry right away. When `retry_after` is set, wait that many seconds. Otherwise ask the user to try again later. -- Any other failure is raised with the code Auth0 returned. +- A rate limit (`status_code` is 429). Auth0 rejects the request before it uses the attempt, so wait and resend the same request with the same `auth_session`, `authn_response` and codes. When `retry_after` is set, wait that many seconds. Check `status_code`, not `code`, because the code depends on which limit was hit. +- Any other failure is raised with the `error` code Auth0 returned, or `passkey_token_error` when the response has no JSON error body. If the connection drops after Auth0 accepts the final code, or a Post Login Action denies the login, the account may or may not exist. Show a generic message that offers sign in, without confirming whether the account exists. Route a repeat signup with the same email to sign in. Signup returns `invalid_request` when the account already exists, so branch on that error code to choose the path. @@ -356,7 +356,9 @@ except MfaRequiredError as e: # then store the returned tokens (see "Completing MFA # on a passkey login" above) except PasskeyError as e: - return {"error": e.code, "detail": e.message} # branch on e.code, never on message text + if e.status_code == 429: + return {"error": "rate_limited", "retry_after": e.retry_after} + return {"error": e.code, "detail": e.message} # branch on e.code or e.status_code, never on message text except Auth0Error as e: return {"error": str(e)} ``` @@ -366,9 +368,9 @@ except Auth0Error as e: - `passkey_challenge_error` - the signup/login challenge request failed - `passkey_token_error` - token exchange failed (also used for a rejected DPoP downgrade) - `invalid_response` - Auth0 returned a response that could not be parsed -- `too_many_requests` when a challenge or the token exchange is rate limited +- `invalid_grant` - a verification code was wrong, or the session is no longer valid. `is_verification_retryable` tells the two apart. -`PasskeyError` also exposes `retry_after` (seconds, or `None` when Auth0 sends no `Retry-After` header), `auth_session`, `verification_required` and `is_verification_retryable` for [identifier verification](#4-identifier-verification-optional). Rate limiting on a challenge or the token exchange raises code `too_many_requests`. The per-IP limit on the challenge endpoints applies to public clients only. Treat `auth_session` on the error like any other session credential and keep it out of logs. +`PasskeyError` also exposes `status_code` (the HTTP status when Auth0 returned an error response, otherwise `None`), `retry_after` (seconds from the `Retry-After` header, or `None` when the header is absent or not a whole number of seconds), `auth_session`, `verification_required` and `is_verification_retryable` for [identifier verification](#4-identifier-verification-optional). A rate-limited challenge or token exchange has `status_code` 429, and its `code` depends on which Auth0 limit was hit. The per-IP limit on the challenge endpoints applies to public clients only. Treat `auth_session` on the error like any other session credential and keep it out of logs. > [!NOTE] > `auth_session` is a short-lived (typically about a minute, or 15 min when `verification_required` is set, matching the `timeout` in `authn_params_public_key`) credential. Never log or persist it. If the ceremony takes too long, re-request the challenge. diff --git a/src/auth0_server_python/auth_server/server_client.py b/src/auth0_server_python/auth_server/server_client.py index 32cc3190..96406c93 100644 --- a/src/auth0_server_python/auth_server/server_client.py +++ b/src/auth0_server_python/auth_server/server_client.py @@ -3578,20 +3578,21 @@ async def passkey_signup_challenge( headers = {FORWARDED_FOR_HEADER: client_ip} if client_ip else {} response = await client.post(url, json=body, headers=headers) - if response.status_code == 429: - raise self._passkey_rate_limit_error(response, "Too many passkey signup requests") - if response.status_code != 200: try: error_data = response.json() except (json.JSONDecodeError, ValueError): + error_data = None + if not isinstance(error_data, dict): raise PasskeyError( PasskeyErrorCode.CHALLENGE_FAILED, f"Passkey signup challenge failed with status {response.status_code}", + **self._passkey_http_error_details(response), ) raise PasskeyError( error_data.get("error", PasskeyErrorCode.CHALLENGE_FAILED), error_data.get("error_description", "Passkey signup challenge failed"), + **self._passkey_http_error_details(response), ) try: @@ -3667,20 +3668,21 @@ async def passkey_login_challenge( headers = {FORWARDED_FOR_HEADER: client_ip} if client_ip else {} response = await client.post(url, json=body, headers=headers) - if response.status_code == 429: - raise self._passkey_rate_limit_error(response, "Too many passkey login requests") - if response.status_code != 200: try: error_data = response.json() except (json.JSONDecodeError, ValueError): + error_data = None + if not isinstance(error_data, dict): raise PasskeyError( PasskeyErrorCode.CHALLENGE_FAILED, f"Passkey login challenge failed with status {response.status_code}", + **self._passkey_http_error_details(response), ) raise PasskeyError( error_data.get("error", PasskeyErrorCode.CHALLENGE_FAILED), error_data.get("error_description", "Passkey login challenge failed"), + **self._passkey_http_error_details(response), ) try: @@ -3699,23 +3701,15 @@ async def passkey_login_challenge( raise PasskeyError(PasskeyErrorCode.CHALLENGE_FAILED, "Passkey login challenge failed", e) from e @staticmethod - def _passkey_rate_limit_error(response, default_message: str) -> PasskeyError: - # The limiter can answer with a plain-text body, so a parse failure keeps the default. - message = default_message - try: - description = response.json().get("error_description") - if isinstance(description, str) and description: - message = description - except (ValueError, AttributeError): - pass - + def _passkey_http_error_details(response) -> dict: retry_after = None try: retry_after = int(response.headers.get("Retry-After")) except (TypeError, ValueError): pass - - return PasskeyError(PasskeyErrorCode.TOO_MANY_REQUESTS, message, retry_after=retry_after) + if retry_after is not None and retry_after < 0: + retry_after = None + return {"status_code": response.status_code, "retry_after": retry_after} @staticmethod def _is_dpop_nonce_challenge(response) -> bool: @@ -3858,16 +3852,16 @@ async def signin_with_passkey( ) response = await client.post(token_endpoint, json=body, headers=headers) - if response.status_code == 429: - raise self._passkey_rate_limit_error(response, "Too many passkey token requests") - if response.status_code != 200: try: error_data = response.json() except (json.JSONDecodeError, ValueError): + error_data = None + if not isinstance(error_data, dict): raise PasskeyError( PasskeyErrorCode.TOKEN_EXCHANGE_FAILED, f"Passkey token exchange failed with status {response.status_code}", + **self._passkey_http_error_details(response), ) error_code = error_data.get("error", PasskeyErrorCode.TOKEN_EXCHANGE_FAILED) if error_code == "mfa_required": @@ -3887,6 +3881,7 @@ async def signin_with_passkey( error_data.get("error_description", "Passkey token exchange failed"), auth_session=error_data.get("auth_session"), verification_required=error_data.get("verification_required"), + **self._passkey_http_error_details(response), ) try: diff --git a/src/auth0_server_python/error/__init__.py b/src/auth0_server_python/error/__init__.py index 58226003..eecdbd60 100644 --- a/src/auth0_server_python/error/__init__.py +++ b/src/auth0_server_python/error/__init__.py @@ -416,7 +416,8 @@ class PasskeyError(Auth0Error): cause: Underlying exception, if any. auth_session: Session to resubmit against, set when Auth0 keeps it alive after a wrong code. verification_required: Identifiers whose codes are still unverified. - retry_after: Seconds from the Retry-After header on a 429, None when absent. + retry_after: Seconds from the Retry-After header, None when absent or not whole seconds. + status_code: HTTP status of an Auth0 error response, None otherwise. """ def __init__( @@ -427,6 +428,7 @@ def __init__( auth_session: Optional[str] = None, verification_required: Optional[list[str]] = None, retry_after: Optional[int] = None, + status_code: Optional[int] = None, ): super().__init__(message) self.code = code @@ -435,6 +437,7 @@ def __init__( self.auth_session = auth_session self.verification_required = verification_required self.retry_after = retry_after # seconds + self.status_code = status_code @property def is_verification_retryable(self) -> bool: @@ -448,7 +451,6 @@ class PasskeyErrorCode: CHALLENGE_FAILED = "passkey_challenge_error" TOKEN_EXCHANGE_FAILED = "passkey_token_error" INVALID_RESPONSE = "invalid_response" - TOO_MANY_REQUESTS = "too_many_requests" INVALID_GRANT = "invalid_grant" diff --git a/src/auth0_server_python/tests/test_error.py b/src/auth0_server_python/tests/test_error.py index b0d52881..a2d37d6f 100644 --- a/src/auth0_server_python/tests/test_error.py +++ b/src/auth0_server_python/tests/test_error.py @@ -85,5 +85,7 @@ def test_passkey_error_str_and_repr_do_not_contain_auth_session(): assert "session_abc123" not in repr(err) -def test_passkey_error_code_too_many_requests_value(): - assert PasskeyErrorCode.TOO_MANY_REQUESTS == "too_many_requests" +def test_passkey_error_http_details_default_to_none(): + err = PasskeyError(PasskeyErrorCode.INVALID_RESPONSE, "failed") + assert err.status_code is None + assert err.retry_after is None diff --git a/src/auth0_server_python/tests/test_server_client.py b/src/auth0_server_python/tests/test_server_client.py index d7154583..ea9566a9 100644 --- a/src/auth0_server_python/tests/test_server_client.py +++ b/src/auth0_server_python/tests/test_server_client.py @@ -8548,6 +8548,28 @@ async def test_signin_with_passkey_dpop_nonce_retry(mocker): assert payload["nonce"] == "server-nonce-abc" +@pytest.mark.asyncio +async def test_signin_with_passkey_rate_limited_after_dpop_nonce_retry(mocker): + client = _make_passkey_signin_client(mocker) + mock_post = mocker.patch("httpx.AsyncClient.post", new_callable=AsyncMock) + nonce_response = AsyncMock() + nonce_response.status_code = 400 + nonce_response.headers = {"DPoP-Nonce": "server-nonce-abc"} + nonce_response.json = MagicMock(return_value={"error": "use_dpop_nonce"}) + rate_limited = AsyncMock() + rate_limited.status_code = 429 + rate_limited.headers = {"Retry-After": "30"} + rate_limited.json = MagicMock(return_value={"error": "access_denied", "error_description": "Too Many Requests"}) + mock_post.side_effect = [nonce_response, rate_limited] + + error = await _signin_expecting_error(client, dpop_key=jwk.JWK.generate(kty="EC", crv="P-256")) + + assert mock_post.await_count == 2 + assert error.code == "access_denied" + assert error.status_code == 429 + assert error.retry_after == 30 + + @pytest.mark.asyncio async def test_signin_with_passkey_dpop_nonce_retry_on_401(mocker): """Token endpoint nonce retry must also hold when the server returns 401 + DPoP-Nonce.""" @@ -9664,101 +9686,80 @@ async def test_passkey_signup_challenge_unsupported_delivery_method_surfaces_ser assert "media is not supported" in exc.value.message -@pytest.mark.asyncio -async def test_passkey_signup_challenge_rate_limited_plain_text_raises_too_many_requests(mocker): - client = _make_passkey_client() - _mock_passkey_post(mocker, 429) - - with pytest.raises(PasskeyError) as exc: - await client.passkey_signup_challenge() - - assert exc.value.code == PasskeyErrorCode.TOO_MANY_REQUESTS - - -@pytest.mark.asyncio -async def test_passkey_signup_challenge_rate_limited_json_raises_too_many_requests(mocker): - client = _make_passkey_client() - _mock_passkey_post( - mocker, 429, {"error": "too_many_requests", "error_description": "Global rate limit exceeded"} - ) - - with pytest.raises(PasskeyError) as exc: - await client.passkey_signup_challenge() - - assert exc.value.code == PasskeyErrorCode.TOO_MANY_REQUESTS - - -@pytest.mark.asyncio -async def test_passkey_login_challenge_rate_limited_plain_text_raises_too_many_requests(mocker): +async def _call_passkey_method(mocker, method): + if method == "signin_with_passkey": + client = _make_passkey_signin_client(mocker) + return client, lambda: _signin_expecting_error(client) client = _make_passkey_client() - _mock_passkey_post(mocker, 429) - - with pytest.raises(PasskeyError) as exc: - await client.passkey_login_challenge() - - assert exc.value.code == PasskeyErrorCode.TOO_MANY_REQUESTS + async def call(): + with pytest.raises(PasskeyError) as exc: + await getattr(client, method)() + return exc.value -@pytest.mark.asyncio -async def test_passkey_login_challenge_rate_limited_json_raises_too_many_requests(mocker): - client = _make_passkey_client() - _mock_passkey_post( - mocker, 429, {"error": "too_many_requests", "error_description": "Global rate limit exceeded"} - ) + return client, call - with pytest.raises(PasskeyError) as exc: - await client.passkey_login_challenge() - assert exc.value.code == PasskeyErrorCode.TOO_MANY_REQUESTS +_PASSKEY_429_METHODS = ["passkey_signup_challenge", "passkey_login_challenge", "signin_with_passkey"] @pytest.mark.asyncio -async def test_signin_with_passkey_rate_limited_json_raises_too_many_requests(mocker): - client = _make_passkey_signin_client(mocker) - _mock_passkey_post(mocker, 429, { - "error": "access_denied", - "error_description": "Too Many Requests", - }) +@pytest.mark.parametrize( + ("method", "body", "code", "message"), + [ + ("passkey_signup_challenge", None, PasskeyErrorCode.CHALLENGE_FAILED, + "Passkey signup challenge failed with status 429"), + ("passkey_signup_challenge", {"error": "too_many_requests", "error_description": "Global rate limit exceeded"}, + "too_many_requests", "Global rate limit exceeded"), + ("passkey_login_challenge", None, PasskeyErrorCode.CHALLENGE_FAILED, + "Passkey login challenge failed with status 429"), + ("passkey_login_challenge", {"error": "too_many_requests", "error_description": "Global rate limit exceeded"}, + "too_many_requests", "Global rate limit exceeded"), + ("signin_with_passkey", None, PasskeyErrorCode.TOKEN_EXCHANGE_FAILED, + "Passkey token exchange failed with status 429"), + ("signin_with_passkey", {"error": "access_denied", "error_description": "Too Many Requests"}, + "access_denied", "Too Many Requests"), + ], + ids=["signup_plain_text", "signup_json", "login_plain_text", "login_json", "token_plain_text", "token_json"], +) +async def test_passkey_rate_limited_keeps_released_code_and_sets_status_code(mocker, method, body, code, message): + _, call = await _call_passkey_method(mocker, method) + _mock_passkey_post(mocker, 429, body) - error = await _signin_expecting_error(client) + error = await call() - assert error.code == PasskeyErrorCode.TOO_MANY_REQUESTS + assert error.code == code + assert error.message == message + assert error.status_code == 429 assert error.is_verification_retryable is False @pytest.mark.asyncio -async def test_signin_with_passkey_rate_limited_plain_text_raises_too_many_requests(mocker): - client = _make_passkey_signin_client(mocker) - _mock_passkey_post(mocker, 429) - - error = await _signin_expecting_error(client) - - assert error.code == PasskeyErrorCode.TOO_MANY_REQUESTS - - -async def _call_passkey_method(mocker, method): - if method == "signin_with_passkey": - client = _make_passkey_signin_client(mocker) - return client, lambda: _signin_expecting_error(client) - client = _make_passkey_client() - - async def call(): - with pytest.raises(PasskeyError) as exc: - await getattr(client, method)() - return exc.value - - return client, call +@pytest.mark.parametrize( + ("method", "code"), + [ + ("passkey_signup_challenge", PasskeyErrorCode.CHALLENGE_FAILED), + ("passkey_login_challenge", PasskeyErrorCode.CHALLENGE_FAILED), + ("signin_with_passkey", PasskeyErrorCode.TOKEN_EXCHANGE_FAILED), + ], +) +async def test_passkey_non_dict_json_error_body_keeps_status_code(mocker, method, code): + _, call = await _call_passkey_method(mocker, method) + _mock_passkey_post(mocker, 429, ["not", "a", "dict"]) + error = await call() -_PASSKEY_429_METHODS = ["passkey_signup_challenge", "passkey_login_challenge", "signin_with_passkey"] + assert error.code == code + assert error.status_code == 429 + assert error.cause is None @pytest.mark.asyncio @pytest.mark.parametrize("method", _PASSKEY_429_METHODS) @pytest.mark.parametrize( ("header", "expected"), - [("30", 30), (None, None), ("Wed, 21 Oct 2026 07:28:00 GMT", None), ("soon", None)], - ids=["seconds", "absent", "http_date", "garbage"], + [("30", 30), (None, None), ("Wed, 21 Oct 2026 07:28:00 GMT", None), ("soon", None), ("-5", None)], + ids=["seconds", "absent", "http_date", "garbage", "negative"], ) async def test_passkey_rate_limited_exposes_retry_after(mocker, method, header, expected): _, call = await _call_passkey_method(mocker, method) @@ -9768,33 +9769,29 @@ async def test_passkey_rate_limited_exposes_retry_after(mocker, method, header, error = await call() - assert error.code == PasskeyErrorCode.TOO_MANY_REQUESTS + assert error.status_code == 429 assert error.retry_after == expected @pytest.mark.asyncio -@pytest.mark.parametrize("method", _PASSKEY_429_METHODS) @pytest.mark.parametrize( - ("body", "expected"), + ("method", "code"), [ - ({"error": "too_many_requests", "error_description": "Global rate limit exceeded"}, "Global rate limit exceeded"), - ({"error": "too_many_requests"}, None), - ({"error_description": 429}, None), - (["not", "a", "dict"], None), - (None, None), + ("passkey_signup_challenge", PasskeyErrorCode.CHALLENGE_FAILED), + ("passkey_login_challenge", PasskeyErrorCode.CHALLENGE_FAILED), + ("signin_with_passkey", PasskeyErrorCode.TOKEN_EXCHANGE_FAILED), ], - ids=["json_description", "no_description", "non_string_description", "non_dict_json", "plain_text"], ) -async def test_passkey_rate_limited_message(mocker, method, body, expected): +async def test_passkey_unavailable_exposes_status_code_and_retry_after(mocker, method, code): _, call = await _call_passkey_method(mocker, method) - _mock_passkey_post(mocker, 429, body) + mock_post = _mock_passkey_post(mocker, 503) + mock_post.return_value.headers = {"Retry-After": "120"} error = await call() - if expected is None: - assert error.message.startswith("Too many passkey") - else: - assert error.message == expected + assert error.code == code + assert error.status_code == 503 + assert error.retry_after == 120 @pytest.mark.asyncio @@ -9811,6 +9808,7 @@ async def test_signin_with_passkey_wrong_code_error_is_verification_retryable(mo assert error.is_verification_retryable is True assert error.code == "invalid_grant" + assert error.status_code == 400 assert error.auth_session == "session_abc123" assert error.verification_required == ["email"] assert "session_abc123" not in str(error) From c6654c4cb6a10ada3e6d717c5c2552799ebbcbd1 Mon Sep 17 00:00:00 2001 From: Sourav Basu Date: Mon, 19 Oct 2026 15:47:08 +0530 Subject: [PATCH 13/15] docs: say what collecting passkey codes first prevents Co-Authored-By: Claude --- examples/Passkeys.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/Passkeys.md b/examples/Passkeys.md index 2144a4fb..ded5c5fe 100644 --- a/examples/Passkeys.md +++ b/examples/Passkeys.md @@ -242,7 +242,7 @@ Enable identifier verification for email, phone, or both on your database connec ### The flow 1. Request the challenge. If `verification_required` is set, collect a code for each identifier it lists. -2. Collect the codes before creating the credential. This order is a UX requirement, and the SDK can't enforce it. Your front end must not call `navigator.credentials.create()` until every required code is collected. Auth0 checks the codes only in `signin_with_passkey`, after the credential exists, so a wrong code still leaves a passkey on the device with no account behind it. The retry reuses that credential. +2. Collect the codes before creating the credential, so a user who never receives a code doesn't end up with a passkey for an account that was never created. This order is a UX requirement, and the SDK can't enforce it. Your front end must not call `navigator.credentials.create()` until every required code is collected. Auth0 checks the codes only in `signin_with_passkey`, after the credential exists, so a wrong code still leaves a passkey on the device with no account behind it. The retry reuses that credential. 3. Create the credential in the browser, then call `signin_with_passkey` with the codes in `verification`. ```python From d5cd0d26624d32d453dd77ed3be0975fcc01ef4a Mon Sep 17 00:00:00 2001 From: Sourav Basu Date: Mon, 19 Oct 2026 15:54:25 +0530 Subject: [PATCH 14/15] docs: keep passkey tokens out of the retry handler response Co-Authored-By: Claude --- examples/Passkeys.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/examples/Passkeys.md b/examples/Passkeys.md index ded5c5fe..6ea72ae6 100644 --- a/examples/Passkeys.md +++ b/examples/Passkeys.md @@ -284,7 +284,7 @@ from auth0_server_python.error import PasskeyError async def passkey_signin(request, response, body): try: - return await server_client.signin_with_passkey( + await server_client.signin_with_passkey( auth_session=body["auth_session"], authn_response=body["authn_response"], verification=body.get("verification"), @@ -298,6 +298,8 @@ async def passkey_signin(request, response, body): {"verification_required": e.verification_required, "auth_session": e.auth_session}, status_code=400, ) + # The result holds the tokens, so keep it on the server. + return {"signed_in": True} ``` Send one `signin_with_passkey` request at a time per `auth_session`. Parallel requests can use up the allowed attempts, or reach Auth0 after the session has already been used. From 7989937a39735d8fd5152584cca53d1e9d761863 Mon Sep 17 00:00:00 2001 From: Sourav Basu Date: Mon, 19 Oct 2026 16:10:40 +0530 Subject: [PATCH 15/15] docs: stop treating invalid_request as proof a passkey account exists Co-Authored-By: Claude --- examples/Passkeys.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/Passkeys.md b/examples/Passkeys.md index 6ea72ae6..79256f85 100644 --- a/examples/Passkeys.md +++ b/examples/Passkeys.md @@ -312,7 +312,7 @@ Send one `signin_with_passkey` request at a time per `auth_session`. Parallel re - A rate limit (`status_code` is 429). Auth0 rejects the request before it uses the attempt, so wait and resend the same request with the same `auth_session`, `authn_response` and codes. When `retry_after` is set, wait that many seconds. Check `status_code`, not `code`, because the code depends on which limit was hit. - Any other failure is raised with the `error` code Auth0 returned, or `passkey_token_error` when the response has no JSON error body. -If the connection drops after Auth0 accepts the final code, or a Post Login Action denies the login, the account may or may not exist. Show a generic message that offers sign in, without confirming whether the account exists. Route a repeat signup with the same email to sign in. Signup returns `invalid_request` when the account already exists, so branch on that error code to choose the path. +If the connection drops after Auth0 accepts the final code, or a Post Login Action denies the login, the account may already exist, because Auth0 creates the user before Actions run. A new signup with the same email then fails with `invalid_request`. Show a generic message that offers sign in, without confirming whether the account exists. `invalid_request` also covers invalid input, so don't treat it alone as proof that the account exists. ### Operational notes