From b181166dee8f5aee65b5fb87e1326d5bf53df06a Mon Sep 17 00:00:00 2001 From: Sourav Basu Date: Mon, 12 Oct 2026 22:58:59 +0530 Subject: [PATCH 1/4] feat: Added support wrappers for passkey support with identifier --- README.md | 11 + examples/Passkeys.md | 289 +++++++++++++++++++++ src/auth0_fastapi/auth/auth_client.py | 135 +++++++++- src/auth0_fastapi/errors/__init__.py | 4 + src/auth0_fastapi/test/test_auth_client.py | 223 +++++++++++++++- src/auth0_fastapi/test/test_errors.py | 58 +++++ 6 files changed, 718 insertions(+), 2 deletions(-) create mode 100644 examples/Passkeys.md create mode 100644 src/auth0_fastapi/test/test_errors.py diff --git a/README.md b/README.md index bce7aec..1d64630 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ - **Fully Integrated Auth Flows**: Automatic routes for `/auth/login`, `/auth/logout`, `/auth/callback`, etc. - **Session-Based**: Uses secure cookies to store user sessions, either stateless (all data in cookie) or stateful (data in a database). - **Custom Token Exchange**: Exchange tokens from external identity providers or legacy systems for Auth0 tokens, with or without establishing a session. +- **Passkeys**: Sign users up and in with WebAuthn passkeys, including email and phone verification at signup. - **Multiple Custom Domains (MCD)**: Support for applications using multiple custom domains on the same Auth0 tenant. - **Account Linking**: Optional routes for linking multiple social or username/password accounts into a single Auth0 profile. - **Backchannel Logout**: Receive logout tokens from Auth0 to invalidate sessions server-side. @@ -319,6 +320,16 @@ Building on token exchange, the SDK also supports: See [examples/CustomTokenExchange.md](./examples/CustomTokenExchange.md) for setup and code samples. +### Passkeys + +Sign users up and in with [WebAuthn](https://www.w3.org/TR/webauthn-2/) passkeys (Touch ID, Face ID, Windows Hello, or a security key) via `passkey_signup_challenge()`, `passkey_login_challenge()` and `signin_with_passkey()`. Signing in establishes the same session as `/auth/callback`. + +- **[Identifier verification](./examples/Passkeys.md#3-identifier-verification-optional)** - when your connection requires it, collect an email or phone code at signup and pass it to `signin_with_passkey()`. + +Rate-limit the route that requests the signup challenge, because each call sends a code. See [Security considerations](./examples/Passkeys.md#4-security-considerations). + +See [examples/Passkeys.md](./examples/Passkeys.md) for setup and code samples. + ## Feedback ### Contributing diff --git a/examples/Passkeys.md b/examples/Passkeys.md new file mode 100644 index 0000000..f31f3fe --- /dev/null +++ b/examples/Passkeys.md @@ -0,0 +1,289 @@ +# 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. Signing in with a passkey establishes the same server-side session as `/auth/callback`. + +> **NOTE**: Passkeys require a [Custom Domain](https://auth0.com/docs/customize/custom-domains) and the native passkey feature on your tenant. See the [Auth0 passkey documentation](https://auth0.com/docs/authenticate/database-connections/passkeys). + +`AuthClient` exposes three methods for this: + +| Method | Used for | Session effect | +|---|---|---| +| `passkey_signup_challenge()` | Step 1 of signup | None | +| `passkey_login_challenge()` | Step 1 of login | None | +| `signin_with_passkey()` | Step 3 of signup and login | Establishes the session | + +The methods are programmatic. The SDK does not mount passkey routes. You add the routes your front end calls, as shown below. + +- [How the flow works](#how-the-flow-works) +- [Prerequisites](#prerequisites) +- [1. Passkey Signup](#1-passkey-signup) +- [2. Passkey Login](#2-passkey-login) +- [3. Identifier verification (optional)](#3-identifier-verification-optional) +- [4. Security considerations](#4-security-considerations) +- [5. Error Handling](#5-error-handling) + +## How the flow works + +1. **Challenge** - your route calls `passkey_signup_challenge()` or `passkey_login_challenge()`. Auth0 returns an `auth_session` and the WebAuthn options (`authn_params_public_key`). +2. **Browser** - your front end passes the options to `navigator.credentials.create()` (signup) or `navigator.credentials.get()` (login). +3. **Sign in** - your route calls `signin_with_passkey()` with the `auth_session` and the credential. The SDK exchanges them for tokens and writes the session cookie. + +## Prerequisites + +- A Custom Domain on your tenant. WebAuthn binds the credential to that domain. +- Passkeys enabled on your database connection. +- The **Passkey** grant (`urn:okta:params:oauth:grant-type:webauthn`) enabled on your application. +- The SDK exception handler registered, so SDK errors become JSON responses: + +```python +from auth0_fastapi.errors import register_exception_handlers + +register_exception_handlers(app) +``` + +## 1. Passkey Signup + +The routes below share these request models and constants. The models reject unknown fields and limit lengths before anything reaches Auth0. + +```python +from typing import Literal, Optional + +from auth0_server_python.auth_types import PasskeyAuthResponse, PasskeyUserProfile +from fastapi import APIRouter, Request, Response +from pydantic import BaseModel, ConfigDict, Field + +from auth0_fastapi.auth.auth_client import AuthClient +from auth0_fastapi.errors import PasskeyError + +router = APIRouter() +CONNECTION = "Username-Password-Authentication" + + +class SignupChallengeRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + email: str = Field(max_length=254) + phone_number: Optional[str] = Field(None, min_length=4, max_length=30) + delivery_method: Optional[Literal["text", "voice"]] = None + + +class VerificationCodes(BaseModel): + model_config = ConfigDict(extra="forbid") + email: Optional[str] = Field(None, max_length=10) + phone: Optional[str] = Field(None, max_length=10) + + +class SigninRequest(BaseModel): + auth_session: str = Field(min_length=1, max_length=256) + authn_response: PasskeyAuthResponse + verification: Optional[VerificationCodes] = None +``` + +### Step 1 - Request a signup challenge + +```python +@router.post("/passkey/signup/challenge") +async def passkey_signup_challenge(body: SignupChallengeRequest, request: Request, response: Response): + auth_client: AuthClient = request.app.state.auth_client + + challenge = await auth_client.passkey_signup_challenge( + user_profile=PasskeyUserProfile(email=body.email, phone_number=body.phone_number), + connection=CONNECTION, + delivery_method=body.delivery_method, + store_options={"request": request, "response": response}, + ) + + response.headers["Cache-Control"] = "no-store" + return { + "auth_session": challenge.auth_session, + "public_key": challenge.authn_params_public_key.model_dump(by_alias=True, exclude_none=True), + "verification_required": challenge.verification_required, + } +``` + +`auth_session` is a short-lived session credential. The browser needs it for step 3. Keep it in a JavaScript variable only, not in `localStorage` or a URL. `auth_session` does not appear in `print(challenge)` or the model `repr()`. + +### Step 2 - Create the credential in the browser + +```javascript +function toAuthnResponse(credential) { + const json = credential.toJSON(); + return { + id: json.id, + rawId: json.rawId, + type: json.type, + authenticatorAttachment: json.authenticatorAttachment, + response: { + clientDataJSON: json.response.clientDataJSON, + attestationObject: json.response.attestationObject, + }, + }; +} + +const challenge = await postJson("/passkey/signup/challenge", { email }); + +const credential = await navigator.credentials.create({ + publicKey: PublicKeyCredential.parseCreationOptionsFromJSON(challenge.public_key), +}); +const authnResponse = toAuthnResponse(credential); +``` + +`postJson` stands for your app's own `fetch` helper. Send only the `response` keys shown. `PasskeyAuthResponse.response` accepts string values only. If your target browsers lack `PublicKeyCredential.parseCreationOptionsFromJSON()`, base64url-decode `challenge` and `user.id` into `ArrayBuffer`s yourself. + +### Step 3 - Sign in and establish the session + +One route serves both signup and login. + +```python +@router.post("/passkey/signin") +async def passkey_signin(body: SigninRequest, request: Request, response: Response): + auth_client: AuthClient = request.app.state.auth_client + codes = body.verification.model_dump(exclude_none=True) if body.verification else None + + try: + result = await auth_client.signin_with_passkey( + auth_session=body.auth_session, + authn_response=body.authn_response, + verification=codes or None, + connection=CONNECTION, + store_options={"request": request, "response": response}, + ) + except PasskeyError as e: + if not e.is_verification_retryable: + raise + response.status_code = 400 + response.headers["Cache-Control"] = "no-store" + return { + "error": "verification_retry", + "auth_session": e.auth_session, + "verification_required": e.verification_required, + } + + return {"sub": result.state_data["user"]["sub"]} +``` + +Return a dict, as shown, rather than building your own `JSONResponse`. The SDK writes the session cookie onto the injected `response`, and FastAPI copies it to the dict response. A new `JSONResponse` would drop it. + +The `except PasskeyError` branch handles identifier verification and is covered in [section 3](#3-identifier-verification-optional). Every other error is re-raised to the SDK exception handler. + +## 2. Passkey Login + +The login challenge takes no username, because the passkey the user picks identifies them. + +```python +@router.post("/passkey/login/challenge") +async def passkey_login_challenge(request: Request, response: Response): + auth_client: AuthClient = request.app.state.auth_client + + challenge = await auth_client.passkey_login_challenge( + connection=CONNECTION, + store_options={"request": request, "response": response}, + ) + + response.headers["Cache-Control"] = "no-store" + return { + "auth_session": challenge.auth_session, + "public_key": challenge.authn_params_public_key.model_dump(by_alias=True, exclude_none=True), + } +``` + +In the browser, call `navigator.credentials.get({ publicKey: PublicKeyCredential.parseRequestOptionsFromJSON(challenge.public_key) })`. Then post to `/passkey/signin` with `response` holding `clientDataJSON`, `authenticatorData`, `signature` and `userHandle`. Identifier verification does not apply to login. + +## 3. Identifier verification (optional) + +When your database connection requires users to verify their email or phone at signup, the signup challenge lists the identifiers that need a code in `verification_required` (for example `["email", "phone"]`). Auth0 sends one code per identifier, and you pass the codes to `signin_with_passkey()` in `verification`. Connections without verification follow section 1 unchanged. + +### Requirements + +Enable identifier verification for email, phone, or both on your database connection. For phone codes, `delivery_method` selects `"text"` or `"voice"`. + +### The flow + +1. Request the signup challenge. If `verification_required` is set, prompt the user for a code for each identifier it lists. +2. Collect every code **before** calling `navigator.credentials.create()`. Otherwise a failed verification leaves a saved passkey on the device for a signup that never completed. +3. Create the credential, then post it with the codes to `/passkey/signin`. + +```javascript +const challenge = await postJson("/passkey/signup/challenge", { email, phone_number, delivery_method: "text" }); + +let verification = challenge.verification_required + ? await promptForCodes(challenge.verification_required) + : undefined; + +const credential = await navigator.credentials.create({ + publicKey: PublicKeyCredential.parseCreationOptionsFromJSON(challenge.public_key), +}); +const authnResponse = toAuthnResponse(credential); + +let authSession = challenge.auth_session; +for (let attempt = 0; attempt < 3; attempt++) { + const res = await fetch("/passkey/signin", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ auth_session: authSession, authn_response: authnResponse, verification }), + }); + if (res.ok) return res.json(); + + const err = await res.json(); + if (err.error !== "verification_retry") throw err; + authSession = err.auth_session; + verification = await promptForCodes(err.verification_required); +} +throw new Error("Too many attempts. Start the signup again."); +``` + +`promptForCodes` stands for your app's UI that asks the user for a code per listed identifier and returns them keyed by `email` or `phone`. `toAuthnResponse` is defined in section 1, step 2. + +### 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`. Its `verification_required` lists the identifiers still unverified. The `/passkey/signin` route in section 1 returns these to the browser, and the browser resubmits: + +- the `auth_session` from the error response +- the **same** credential (no second biometric prompt) +- codes only for the identifiers still listed + +Cap the number of attempts in the browser, because Auth0 also limits them. Send one `/passkey/signin` request at a time per `auth_session`. Parallel requests can use up the allowed attempts. + +### When retrying does not help + +`is_verification_retryable` is `False` in these cases: + +- **Too many wrong codes, an expired code, or an expired session** (`invalid_grant` without `auth_session`). Start again from the signup challenge. +- **A missing code** (`invalid_request`). The session stays valid. Resubmit with the same `auth_session` and a code for every identifier in `verification_required`. +- **A server error** (`server_error`). Retry with the same `auth_session`, credential and codes. If that fails with `invalid_grant` and no `auth_session`, start again. +- **Any other failure** is raised with the code Auth0 returned. + +### Resending codes + +To resend, call the signup challenge again before the credential is created. Each call sends new codes and returns a new `auth_session` and new WebAuthn options, so use the latest values. + +## 4. Security considerations + +- **Rate-limit the signup challenge route.** Each call sends codes, so without a limit the route can be abused to send messages in bulk (SMS pumping). Limit it per IP address and per email address or phone number, for example at your reverse proxy or with a library such as `slowapi`. +- **Treat `auth_session` and verification codes as credentials.** Never log them, and never put them in URLs. Send `Cache-Control: no-store` on responses that carry `auth_session`. `PasskeyError` carries `auth_session` as an attribute, so do not log the error's attributes. +- **Validate at the route.** Use request models like the ones above: `extra="forbid"`, codes of at most 10 characters, and only the `email` and `phone` keys. The SDK also rejects any other `verification` key with `InvalidArgumentError`. +- **Keep retry details out of generic error responses.** `register_exception_handlers()` returns only `error` and `message`. Return `auth_session` only from the route that owns the retry, as in section 1. + +## 5. Error Handling + +With `register_exception_handlers(app)`, unhandled errors from the passkey methods map to these responses. The body is always `{"error": , "message": }`. + +| Error | HTTP status | +|---|---| +| `PasskeyError` with code `too_many_requests` | 429 | +| Any other `PasskeyError` (for example `invalid_grant`, `invalid_request`, `passkey_challenge_error`, `passkey_token_error`) | 400 | +| `InvalidArgumentError` | 400 | +| `OrganizationTokenValidationError` | 400 | +| `MissingRequiredArgumentError` | 422 | +| `MfaRequiredError` | 401 | +| `IssuerValidationError` | 401 | +| `ApiError` (ID token validation) | 502 | + +When the tenant requires MFA after the passkey sign-in, `signin_with_passkey()` raises `MfaRequiredError` before any session is created. Complete it with the MFA client on the underlying `ServerClient` (`auth_client.client.mfa`). On this path the SDK does not store the session for you. See [Completing MFA on a passkey login](https://github.com/auth0/auth0-server-python/blob/main/examples/Passkeys.md#completing-mfa-on-a-passkey-login-and-where-the-session-comes-from). + +To bind the issued tokens with DPoP, pass `dpop_key` to `signin_with_passkey()`. See the [auth0-server-python passkey guide](https://github.com/auth0/auth0-server-python/blob/main/examples/Passkeys.md#3-dpop-bound-passkey-tokens-optional). + +## Additional Resources + +- [Auth0 passkey documentation](https://auth0.com/docs/authenticate/database-connections/passkeys) +- [auth0-server-python: Passkey Authentication](https://github.com/auth0/auth0-server-python/blob/main/examples/Passkeys.md) +- [WebAuthn Level 2](https://www.w3.org/TR/webauthn-2/) diff --git a/src/auth0_fastapi/auth/auth_client.py b/src/auth0_fastapi/auth/auth_client.py index d98a118..11a98ca 100644 --- a/src/auth0_fastapi/auth/auth_client.py +++ b/src/auth0_fastapi/auth/auth_client.py @@ -1,5 +1,5 @@ # Imported from auth0-server-python -from typing import Optional +from typing import TYPE_CHECKING, Any, Optional from auth0_server_python.auth_server.server_client import ServerClient from auth0_server_python.auth_types import ( @@ -9,6 +9,12 @@ LoginWithCustomTokenExchangeOptions, LoginWithCustomTokenExchangeResult, LogoutOptions, + PasskeyAuthResponse, + PasskeyDeliveryMethod, + PasskeyLoginChallengeResponse, + PasskeyLoginResult, + PasskeySignupChallengeResponse, + PasskeyUserProfile, SessionTransferTokenResult, StartInteractiveLoginOptions, TokenExchangeResponse, @@ -19,6 +25,9 @@ from auth0_fastapi.stores.cookie_transaction_store import CookieTransactionStore from auth0_fastapi.stores.stateless_state_store import StatelessStateStore +if TYPE_CHECKING: + from jwcrypto import jwk + class AuthClient: """ @@ -358,3 +367,127 @@ def build_session_transfer_redirect( return self.client.build_session_transfer_redirect( target_login_url, result, organization=organization ) + + async def passkey_signup_challenge( + self, + user_profile: Optional[PasskeyUserProfile] = None, + connection: Optional[str] = None, + organization: Optional[str] = None, + user_metadata: Optional[dict[str, Any]] = None, + delivery_method: Optional[PasskeyDeliveryMethod] = None, + store_options: dict = None, + ) -> PasskeySignupChallengeResponse: + """ + Requests a passkey registration challenge to start a signup. + + Args: + user_profile: Profile of the new user (email, phone_number, name). + connection: Database connection name (optional). + organization: Organization ID or name (optional). + user_metadata: Custom metadata stored on the new user (optional). + delivery_method: Phone verification code channel, "text" or "voice" (optional). + store_options: Optional options passed to the Transaction and State + Store. Only required when using Multiple Custom Domains, where + the domain resolver needs the incoming request. + + Returns: + The PasskeySignupChallengeResponse (auth_session, authn_params_public_key, + verification_required). + + Raises: + PasskeyError: If the challenge request fails or is rate limited + (see PasskeyErrorCode). + """ + return await self.client.passkey_signup_challenge( + user_profile=user_profile, + connection=connection, + organization=organization, + user_metadata=user_metadata, + delivery_method=delivery_method, + store_options=store_options, + ) + + async def passkey_login_challenge( + self, + connection: Optional[str] = None, + organization: Optional[str] = None, + store_options: dict = None, + ) -> PasskeyLoginChallengeResponse: + """ + Requests a passkey assertion challenge to start a login. + + Args: + connection: Database connection name (optional). + organization: Organization ID or name (optional). + store_options: Optional options passed to the Transaction and State + Store. Only required when using Multiple Custom Domains, where + the domain resolver needs the incoming request. + + Returns: + The PasskeyLoginChallengeResponse (auth_session, authn_params_public_key). + + Raises: + PasskeyError: If the challenge request fails or is rate limited + (see PasskeyErrorCode). + """ + return await self.client.passkey_login_challenge( + connection=connection, + organization=organization, + store_options=store_options, + ) + + async def signin_with_passkey( + self, + auth_session: str, + authn_response: PasskeyAuthResponse, + verification: Optional[dict[str, str]] = None, + connection: Optional[str] = None, + organization: Optional[str] = None, + scope: Optional[str] = None, + audience: Optional[str] = None, + dpop_key: Optional["jwk.JWK"] = None, + store_options: dict = None, + ) -> PasskeyLoginResult: + """ + Exchanges a passkey credential for tokens and establishes a session. + + Args: + auth_session: The auth_session from the challenge, or from a retryable PasskeyError. + authn_response: The credential from navigator.credentials.create() or .get(). + verification: Signup verification codes keyed by "email" or "phone" (optional). + connection: Database connection name (optional). + organization: Organization ID or name (optional). + scope: Space-delimited scopes (optional). + audience: API audience (optional). + dpop_key: EC P-256 key to bind the tokens with DPoP (optional). + store_options: Options passed to the Transaction and State Store. + Must include {"request": request, "response": response} so the + session cookie can be written on response. + + Returns: + The PasskeyLoginResult containing the session state (including the + resulting user). + + Raises: + PasskeyError: If the token exchange fails or is rate limited + (see PasskeyErrorCode and is_verification_retryable). + MfaRequiredError: If the tenant requires MFA after the passkey sign-in. + MissingRequiredArgumentError: If auth_session or authn_response is missing. + InvalidArgumentError: If verification has a key other than "email" or "phone". + ConfigurationError: If dpop_key is combined with mTLS. + ApiError: If the ID token fails validation. + IssuerValidationError: If the ID token issuer does not match the tenant. + OrganizationTokenValidationError: If the ID token's org claim does not match. + EnterpriseConnectError: If the client is configured for Enterprise Connect. + """ + return await self.client.signin_with_passkey( + auth_session=auth_session, + authn_response=authn_response, + verification=verification, + connection=connection, + organization=organization, + scope=scope, + audience=audience, + dpop_key=dpop_key, + store_options=store_options, + ) diff --git a/src/auth0_fastapi/errors/__init__.py b/src/auth0_fastapi/errors/__init__.py index 4a8babc..bb08a20 100644 --- a/src/auth0_fastapi/errors/__init__.py +++ b/src/auth0_fastapi/errors/__init__.py @@ -11,6 +11,8 @@ IssuerValidationError, MissingRequiredArgumentError, MissingTransactionError, + PasskeyError, + PasskeyErrorCode, ) from fastapi import Request from fastapi.responses import JSONResponse @@ -48,6 +50,8 @@ def auth0_exception_handler(request: Request, exc: Auth0Error): status_code = 400 # Bad Request elif isinstance(exc, AccessTokenForConnectionError): status_code = 400 # Bad Request + elif isinstance(exc, PasskeyError) and exc.code == PasskeyErrorCode.TOO_MANY_REQUESTS: + status_code = 429 return JSONResponse( status_code=status_code, diff --git a/src/auth0_fastapi/test/test_auth_client.py b/src/auth0_fastapi/test/test_auth_client.py index 4d620d7..18c04a3 100644 --- a/src/auth0_fastapi/test/test_auth_client.py +++ b/src/auth0_fastapi/test/test_auth_client.py @@ -8,10 +8,22 @@ CustomTokenExchangeOptions, LoginWithCustomTokenExchangeOptions, LoginWithCustomTokenExchangeResult, + PasskeyAuthResponse, + PasskeyLoginChallengeResponse, + PasskeyLoginResult, + PasskeyPublicKeyOptions, + PasskeySignupChallengeResponse, + PasskeyUserProfile, SessionTransferTokenResult, TokenExchangeResponse, ) -from auth0_server_python.error import CustomTokenExchangeError, CustomTokenExchangeErrorCode, InvalidArgumentError +from auth0_server_python.error import ( + CustomTokenExchangeError, + CustomTokenExchangeErrorCode, + InvalidArgumentError, + PasskeyError, + PasskeyErrorCode, +) from fastapi import HTTPException, Request, Response from auth0_fastapi.auth.auth_client import AuthClient @@ -53,6 +65,17 @@ def auth_client(auth_config): return AuthClient(auth_config) +@pytest.fixture +def passkey_authn_response(): + """Fixture providing a WebAuthn registration credential as the browser serializes it.""" + return PasskeyAuthResponse( + id="cred_abc123", + rawId="cred_abc123", + type="public-key", + response={"clientDataJSON": "client-data", "attestationObject": "attestation"}, + ) + + class TestAuthClientInitialization: """Test AuthClient initialization and configuration security.""" @@ -991,3 +1014,201 @@ async def test_request_session_transfer_token_blank_organization_raises(self, au subject_token_type="urn:acme:customer-session", organization=" ", ) + + +class TestPasskey: + """Test passkey signup, login and identifier verification pass-through.""" + + @pytest.mark.asyncio + async def test_passkey_signup_challenge_forwards_all_arguments(self, auth_client, mock_request, mock_response): + """Test that every signup argument, including delivery_method, reaches the underlying client.""" + profile = PasskeyUserProfile(email="new.user@example.com", phone_number="+15551234567") + store_options = {"request": mock_request, "response": mock_response} + mock_result = PasskeySignupChallengeResponse( + auth_session="sess_abc123", + authn_params_public_key=PasskeyPublicKeyOptions(challenge="challenge", timeout=900000), + verification_required=["email", "phone"], + ) + + with patch.object(auth_client.client, 'passkey_signup_challenge', new_callable=AsyncMock) as mock_challenge: + mock_challenge.return_value = mock_result + + result = await auth_client.passkey_signup_challenge( + user_profile=profile, + connection="Username-Password-Authentication", + organization="org_abc123", + user_metadata={"plan": "free"}, + delivery_method="voice", + store_options=store_options, + ) + + assert result is mock_result + assert result.verification_required == ["email", "phone"] + mock_challenge.assert_called_once_with( + user_profile=profile, + connection="Username-Password-Authentication", + organization="org_abc123", + user_metadata={"plan": "free"}, + delivery_method="voice", + store_options=store_options, + ) + + @pytest.mark.asyncio + async def test_passkey_signup_challenge_defaults_to_none(self, auth_client): + """Test that optional signup arguments default to None so the SDK omits them from the request.""" + profile = PasskeyUserProfile(email="new.user@example.com") + + with patch.object(auth_client.client, 'passkey_signup_challenge', new_callable=AsyncMock) as mock_challenge: + await auth_client.passkey_signup_challenge(user_profile=profile) + + mock_challenge.assert_called_once_with( + user_profile=profile, + connection=None, + organization=None, + user_metadata=None, + delivery_method=None, + store_options=None, + ) + + @pytest.mark.asyncio + async def test_passkey_signup_challenge_rate_limit_propagates(self, auth_client): + """Test that a rate-limited challenge raises PasskeyError with too_many_requests unchanged.""" + error = PasskeyError(PasskeyErrorCode.TOO_MANY_REQUESTS, "Too many passkey signup requests") + + with patch.object(auth_client.client, 'passkey_signup_challenge', new_callable=AsyncMock) as mock_challenge: + mock_challenge.side_effect = error + + with pytest.raises(PasskeyError) as exc_info: + await auth_client.passkey_signup_challenge(user_profile=PasskeyUserProfile(email="a@example.com")) + + assert exc_info.value is error + + @pytest.mark.asyncio + async def test_passkey_login_challenge_forwards_arguments_without_username( + self, auth_client, mock_request, mock_response + ): + """Test that the login challenge forwards connection and organization and never a username.""" + store_options = {"request": mock_request, "response": mock_response} + mock_result = PasskeyLoginChallengeResponse( + auth_session="sess_abc123", + authn_params_public_key=PasskeyPublicKeyOptions(challenge="challenge"), + ) + + with patch.object(auth_client.client, 'passkey_login_challenge', new_callable=AsyncMock) as mock_challenge: + mock_challenge.return_value = mock_result + + result = await auth_client.passkey_login_challenge( + connection="Username-Password-Authentication", + organization="org_abc123", + store_options=store_options, + ) + + assert result is mock_result + mock_challenge.assert_called_once_with( + connection="Username-Password-Authentication", + organization="org_abc123", + store_options=store_options, + ) + + @pytest.mark.asyncio + async def test_signin_with_passkey_forwards_verification_codes( + self, auth_client, mock_request, mock_response, passkey_authn_response + ): + """Test that verification codes and all sign-in arguments reach the underlying client.""" + store_options = {"request": mock_request, "response": mock_response} + mock_result = PasskeyLoginResult(state_data={"user": {"sub": "auth0|abc123"}}) + + with patch.object(auth_client.client, 'signin_with_passkey', new_callable=AsyncMock) as mock_signin: + mock_signin.return_value = mock_result + + result = await auth_client.signin_with_passkey( + auth_session="sess_abc123", + authn_response=passkey_authn_response, + verification={"email": "123456", "phone": "654321"}, + connection="Username-Password-Authentication", + store_options=store_options, + ) + + assert result is mock_result + mock_signin.assert_called_once_with( + auth_session="sess_abc123", + authn_response=passkey_authn_response, + verification={"email": "123456", "phone": "654321"}, + connection="Username-Password-Authentication", + organization=None, + scope=None, + audience=None, + dpop_key=None, + store_options=store_options, + ) + + @pytest.mark.asyncio + async def test_signin_with_passkey_forwards_dpop_key(self, auth_client, passkey_authn_response): + """Test that dpop_key is forwarded as the same object so token binding is preserved.""" + dpop_key = Mock() + + with patch.object(auth_client.client, 'signin_with_passkey', new_callable=AsyncMock) as mock_signin: + await auth_client.signin_with_passkey( + auth_session="sess_abc123", + authn_response=passkey_authn_response, + dpop_key=dpop_key, + ) + + assert mock_signin.call_args.kwargs["dpop_key"] is dpop_key + assert mock_signin.call_args.kwargs["verification"] is None + + @pytest.mark.asyncio + async def test_signin_with_passkey_retryable_error_propagates_unchanged(self, auth_client, passkey_authn_response): + """Test that a wrong-code PasskeyError keeps auth_session and verification_required for the retry.""" + error = PasskeyError( + PasskeyErrorCode.INVALID_GRANT, + "Invalid or expired session", + auth_session="sess_abc123", + verification_required=["email"], + ) + + with patch.object(auth_client.client, 'signin_with_passkey', new_callable=AsyncMock) as mock_signin: + mock_signin.side_effect = error + + with pytest.raises(PasskeyError) as exc_info: + await auth_client.signin_with_passkey( + auth_session="sess_abc123", + authn_response=passkey_authn_response, + verification={"email": "000000"}, + ) + + assert exc_info.value is error + assert exc_info.value.is_verification_retryable is True + assert exc_info.value.verification_required == ["email"] + + @pytest.mark.asyncio + async def test_signin_with_passkey_terminal_error_is_not_retryable(self, auth_client, passkey_authn_response): + """Test that invalid_grant without auth_session surfaces as terminal.""" + error = PasskeyError(PasskeyErrorCode.INVALID_GRANT, "Invalid or expired session") + + with patch.object(auth_client.client, 'signin_with_passkey', new_callable=AsyncMock) as mock_signin: + mock_signin.side_effect = error + + with pytest.raises(PasskeyError) as exc_info: + await auth_client.signin_with_passkey( + auth_session="sess_abc123", + authn_response=passkey_authn_response, + verification={"email": "000000"}, + ) + + assert exc_info.value.is_verification_retryable is False + + @pytest.mark.asyncio + async def test_signin_with_passkey_invalid_verification_key_propagates(self, auth_client, passkey_authn_response): + """Test that the underlying InvalidArgumentError for an unknown verification key is not swallowed.""" + with patch.object(auth_client.client, 'signin_with_passkey', new_callable=AsyncMock) as mock_signin: + mock_signin.side_effect = InvalidArgumentError( + "verification", "verification keys must be 'email' or 'phone'" + ) + + with pytest.raises(InvalidArgumentError): + await auth_client.signin_with_passkey( + auth_session="sess_abc123", + authn_response=passkey_authn_response, + verification={"phone_number": "123456"}, + ) diff --git a/src/auth0_fastapi/test/test_errors.py b/src/auth0_fastapi/test/test_errors.py new file mode 100644 index 0000000..00bdb5d --- /dev/null +++ b/src/auth0_fastapi/test/test_errors.py @@ -0,0 +1,58 @@ +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from auth0_fastapi.errors import PasskeyError, PasskeyErrorCode, register_exception_handlers + + +def _client_raising(exc: Exception) -> TestClient: + app = FastAPI() + register_exception_handlers(app) + + @app.get("/raise") + async def raise_error(): + raise exc + + return TestClient(app) + + +class TestPasskeyErrorHandling: + """Test how register_exception_handlers renders passkey errors.""" + + def test_rate_limited_passkey_error_maps_to_429(self): + """Test that too_many_requests becomes HTTP 429 so clients can back off.""" + client = _client_raising( + PasskeyError(PasskeyErrorCode.TOO_MANY_REQUESTS, "Too many passkey signup requests") + ) + + res = client.get("/raise") + + assert res.status_code == 429 + assert res.json() == {"error": "too_many_requests", "message": "Too many passkey signup requests"} + + def test_retryable_passkey_error_body_omits_session_details(self): + """Test that an unhandled retryable error never leaks auth_session into the response.""" + client = _client_raising( + PasskeyError( + PasskeyErrorCode.INVALID_GRANT, + "Invalid or expired session", + auth_session="sess_abc123", + verification_required=["email"], + ) + ) + + res = client.get("/raise") + + assert res.status_code == 400 + assert res.json() == {"error": "invalid_grant", "message": "Invalid or expired session"} + assert "sess_abc123" not in res.text + + def test_other_passkey_errors_map_to_400(self): + """Test that non rate-limit passkey failures keep the default 400.""" + client = _client_raising( + PasskeyError(PasskeyErrorCode.TOKEN_EXCHANGE_FAILED, "Passkey token exchange failed") + ) + + res = client.get("/raise") + + assert res.status_code == 400 + assert res.json()["error"] == "passkey_token_error" From c7c786b2d5a0522433c0e5717d07a51d48ee9d88 Mon Sep 17 00:00:00 2001 From: Sourav Basu Date: Tue, 13 Oct 2026 13:10:57 +0530 Subject: [PATCH 2/4] test: add passkey login path tests Cover signin_with_passkey with a login assertion credential and rate-limit error propagation from passkey_login_challenge. Co-Authored-By: Claude --- src/auth0_fastapi/test/test_auth_client.py | 64 ++++++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/src/auth0_fastapi/test/test_auth_client.py b/src/auth0_fastapi/test/test_auth_client.py index 18c04a3..98d96b7 100644 --- a/src/auth0_fastapi/test/test_auth_client.py +++ b/src/auth0_fastapi/test/test_auth_client.py @@ -76,6 +76,22 @@ def passkey_authn_response(): ) +@pytest.fixture +def passkey_login_authn_response(): + """Fixture providing a WebAuthn assertion credential as the browser serializes it.""" + return PasskeyAuthResponse( + id="cred_abc123", + rawId="cred_abc123", + type="public-key", + response={ + "clientDataJSON": "client-data", + "authenticatorData": "authenticator-data", + "signature": "signature", + "userHandle": "user-handle", + }, + ) + + class TestAuthClientInitialization: """Test AuthClient initialization and configuration security.""" @@ -1110,6 +1126,54 @@ async def test_passkey_login_challenge_forwards_arguments_without_username( store_options=store_options, ) + @pytest.mark.asyncio + async def test_passkey_login_challenge_rate_limit_error_propagates(self, auth_client): + """Test that a too_many_requests PasskeyError from the login challenge is not swallowed.""" + error = PasskeyError(PasskeyErrorCode.TOO_MANY_REQUESTS, "Too many requests") + + with patch.object(auth_client.client, 'passkey_login_challenge', new_callable=AsyncMock) as mock_challenge: + mock_challenge.side_effect = error + + with pytest.raises(PasskeyError) as exc_info: + await auth_client.passkey_login_challenge(connection="Username-Password-Authentication") + + assert exc_info.value is error + assert exc_info.value.code == PasskeyErrorCode.TOO_MANY_REQUESTS + + @pytest.mark.asyncio + async def test_signin_with_passkey_forwards_login_assertion( + self, auth_client, mock_request, mock_response, passkey_login_authn_response + ): + """Test that a login assertion and all sign-in arguments reach the underlying client without verification.""" + store_options = {"request": mock_request, "response": mock_response} + mock_result = PasskeyLoginResult(state_data={"user": {"sub": "auth0|abc123"}}) + + with patch.object(auth_client.client, 'signin_with_passkey', new_callable=AsyncMock) as mock_signin: + mock_signin.return_value = mock_result + + result = await auth_client.signin_with_passkey( + auth_session="sess_abc123", + authn_response=passkey_login_authn_response, + connection="Username-Password-Authentication", + organization="org_abc123", + scope="openid profile email", + audience="https://api.example.com", + store_options=store_options, + ) + + assert result is mock_result + mock_signin.assert_called_once_with( + auth_session="sess_abc123", + authn_response=passkey_login_authn_response, + verification=None, + connection="Username-Password-Authentication", + organization="org_abc123", + scope="openid profile email", + audience="https://api.example.com", + dpop_key=None, + store_options=store_options, + ) + @pytest.mark.asyncio async def test_signin_with_passkey_forwards_verification_codes( self, auth_client, mock_request, mock_response, passkey_authn_response From 5f07057f4e315e3f88b11fc247e0c378cc20f011 Mon Sep 17 00:00:00 2001 From: Sourav Basu Date: Mon, 19 Oct 2026 21:15:06 +0530 Subject: [PATCH 3/4] fix: align passkey wrappers with upstream rate limit and client_ip changes auth0-server-python removed PasskeyErrorCode.TOO_MANY_REQUESTS and now reports rate limits through PasskeyError.status_code and retry_after. The exception handler read the removed attribute, so every PasskeyError raised AttributeError instead of rendering. It now maps status_code 429 to HTTP 429 and forwards retry_after as Retry-After. The passkey wrappers forward the new client_ip argument and match the upstream verification type. The passkey guide covers client_ip trust, rate limit retries, verification requirements and account existence. Co-Authored-By: Claude --- examples/Passkeys.md | 25 ++++++++++++---- src/auth0_fastapi/auth/auth_client.py | 20 +++++++++---- src/auth0_fastapi/errors/__init__.py | 6 +++- src/auth0_fastapi/test/test_auth_client.py | 33 +++++++++++++++++----- src/auth0_fastapi/test/test_errors.py | 28 +++++++++++++++--- 5 files changed, 90 insertions(+), 22 deletions(-) diff --git a/examples/Passkeys.md b/examples/Passkeys.md index f31f3fe..9c58da6 100644 --- a/examples/Passkeys.md +++ b/examples/Passkeys.md @@ -43,7 +43,7 @@ register_exception_handlers(app) ## 1. Passkey Signup -The routes below share these request models and constants. The models reject unknown fields and limit lengths before anything reaches Auth0. +The routes below share these request models, constants and the `client_ip` helper. The models reject unknown fields and limit lengths before anything reaches Auth0. Read [Forward a trusted client IP](#4-security-considerations) before you deploy the helper. ```python from typing import Literal, Optional @@ -76,6 +76,10 @@ class SigninRequest(BaseModel): auth_session: str = Field(min_length=1, max_length=256) authn_response: PasskeyAuthResponse verification: Optional[VerificationCodes] = None + + +def client_ip(request: Request) -> Optional[str]: + return request.client.host if request.client else None ``` ### Step 1 - Request a signup challenge @@ -89,6 +93,7 @@ async def passkey_signup_challenge(body: SignupChallengeRequest, request: Reques user_profile=PasskeyUserProfile(email=body.email, phone_number=body.phone_number), connection=CONNECTION, delivery_method=body.delivery_method, + client_ip=client_ip(request), store_options={"request": request, "response": response}, ) @@ -145,6 +150,7 @@ async def passkey_signin(body: SigninRequest, request: Request, response: Respon authn_response=body.authn_response, verification=codes or None, connection=CONNECTION, + client_ip=client_ip(request), store_options={"request": request, "response": response}, ) except PasskeyError as e: @@ -176,6 +182,7 @@ async def passkey_login_challenge(request: Request, response: Response): challenge = await auth_client.passkey_login_challenge( connection=CONNECTION, + client_ip=client_ip(request), store_options={"request": request, "response": response}, ) @@ -194,12 +201,16 @@ When your database connection requires users to verify their email or phone at s ### Requirements -Enable identifier verification for email, phone, or both on your database connection. For phone codes, `delivery_method` selects `"text"` or `"voice"`. +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. +- Deploy a version of your app that passes `verification` to `signin_with_passkey()` before you turn verification on. Otherwise every signup on the connection fails. +- For phone codes, `delivery_method` selects `"text"` or `"voice"`. It has no effect when only email needs a code. Any other value raises `InvalidArgumentError` before the request is sent. ### The flow 1. Request the signup challenge. If `verification_required` is set, prompt the user for a code for each identifier it lists. -2. Collect every code **before** calling `navigator.credentials.create()`. Otherwise a failed verification leaves a saved passkey on the device for a signup that never completed. +2. Collect every code **before** calling `navigator.credentials.create()`, so a user who never receives a code doesn't end up with a passkey for an account that was never created. The SDK can't enforce this order. Auth0 checks the codes only in `signin_with_passkey()`, after the credential exists, so after a wrong code the passkey has no account behind it until a retry with that same credential succeeds. 3. Create the credential, then post it with the codes to `/passkey/signin`. ```javascript @@ -250,7 +261,10 @@ Cap the number of attempts in the browser, because Auth0 also limits them. Send - **Too many wrong codes, an expired code, or an expired session** (`invalid_grant` without `auth_session`). Start again from the signup challenge. - **A missing code** (`invalid_request`). The session stays valid. Resubmit with the same `auth_session` and a code for every identifier in `verification_required`. - **A server error** (`server_error`). Retry with the same `auth_session`, credential and codes. If that fails with `invalid_grant` and no `auth_session`, start again. -- **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. The exception handler returns HTTP 429, with a `Retry-After` header when Auth0 sent one. Wait that long, then resend the same request with the same `auth_session`, credential and codes. 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 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. ### Resending codes @@ -259,6 +273,7 @@ To resend, call the signup challenge again before the credential is created. Eac ## 4. Security considerations - **Rate-limit the signup challenge route.** Each call sends codes, so without a limit the route can be abused to send messages in bulk (SMS pumping). Limit it per IP address and per email address or phone number, for example at your reverse proxy or with a library such as `slowapi`. +- **Forward a trusted client IP.** Pass `client_ip` to all three methods so Auth0 sees the user's address, not your server's. Auth0 uses it when the **Trust Token Endpoint IP Header** setting is enabled for the application, so a spoofed value lets a caller get around per-IP limits. `request.client.host` reads `X-Forwarded-For` only from the addresses in uvicorn's `--forwarded-allow-ips` (default `127.0.0.1`). Set that to your own proxies, never to `*` when clients can reach the app directly. Never copy a client-supplied header into `client_ip`. Pass an IP address only, because Auth0 can reject any other value. - **Treat `auth_session` and verification codes as credentials.** Never log them, and never put them in URLs. Send `Cache-Control: no-store` on responses that carry `auth_session`. `PasskeyError` carries `auth_session` as an attribute, so do not log the error's attributes. - **Validate at the route.** Use request models like the ones above: `extra="forbid"`, codes of at most 10 characters, and only the `email` and `phone` keys. The SDK also rejects any other `verification` key with `InvalidArgumentError`. - **Keep retry details out of generic error responses.** `register_exception_handlers()` returns only `error` and `message`. Return `auth_session` only from the route that owns the retry, as in section 1. @@ -269,7 +284,7 @@ With `register_exception_handlers(app)`, unhandled errors from the passkey metho | Error | HTTP status | |---|---| -| `PasskeyError` with code `too_many_requests` | 429 | +| `PasskeyError` with `status_code` 429 (rate limited) | 429, with `Retry-After` when Auth0 sent one | | Any other `PasskeyError` (for example `invalid_grant`, `invalid_request`, `passkey_challenge_error`, `passkey_token_error`) | 400 | | `InvalidArgumentError` | 400 | | `OrganizationTokenValidationError` | 400 | diff --git a/src/auth0_fastapi/auth/auth_client.py b/src/auth0_fastapi/auth/auth_client.py index 11a98ca..a81229a 100644 --- a/src/auth0_fastapi/auth/auth_client.py +++ b/src/auth0_fastapi/auth/auth_client.py @@ -375,6 +375,7 @@ async def passkey_signup_challenge( organization: Optional[str] = None, user_metadata: Optional[dict[str, Any]] = None, delivery_method: Optional[PasskeyDeliveryMethod] = None, + client_ip: Optional[str] = None, store_options: dict = None, ) -> PasskeySignupChallengeResponse: """ @@ -386,6 +387,7 @@ async def passkey_signup_challenge( organization: Organization ID or name (optional). user_metadata: Custom metadata stored on the new user (optional). delivery_method: Phone verification code channel, "text" or "voice" (optional). + client_ip: End user IP address, sent as the auth0-forwarded-for header (optional). store_options: Optional options passed to the Transaction and State Store. Only required when using Multiple Custom Domains, where the domain resolver needs the incoming request. @@ -396,7 +398,8 @@ async def passkey_signup_challenge( Raises: PasskeyError: If the challenge request fails or is rate limited - (see PasskeyErrorCode). + (status_code 429). + InvalidArgumentError: If delivery_method is not "text" or "voice". """ return await self.client.passkey_signup_challenge( user_profile=user_profile, @@ -404,6 +407,7 @@ async def passkey_signup_challenge( organization=organization, user_metadata=user_metadata, delivery_method=delivery_method, + client_ip=client_ip, store_options=store_options, ) @@ -411,6 +415,7 @@ async def passkey_login_challenge( self, connection: Optional[str] = None, organization: Optional[str] = None, + client_ip: Optional[str] = None, store_options: dict = None, ) -> PasskeyLoginChallengeResponse: """ @@ -419,6 +424,7 @@ async def passkey_login_challenge( Args: connection: Database connection name (optional). organization: Organization ID or name (optional). + client_ip: End user IP address, sent as the auth0-forwarded-for header (optional). store_options: Optional options passed to the Transaction and State Store. Only required when using Multiple Custom Domains, where the domain resolver needs the incoming request. @@ -428,11 +434,12 @@ async def passkey_login_challenge( Raises: PasskeyError: If the challenge request fails or is rate limited - (see PasskeyErrorCode). + (status_code 429). """ return await self.client.passkey_login_challenge( connection=connection, organization=organization, + client_ip=client_ip, store_options=store_options, ) @@ -440,12 +447,13 @@ async def signin_with_passkey( self, auth_session: str, authn_response: PasskeyAuthResponse, - verification: Optional[dict[str, str]] = None, + verification: Optional[dict[str, Optional[str]]] = None, connection: Optional[str] = None, organization: Optional[str] = None, scope: Optional[str] = None, audience: Optional[str] = None, dpop_key: Optional["jwk.JWK"] = None, + client_ip: Optional[str] = None, store_options: dict = None, ) -> PasskeyLoginResult: """ @@ -460,6 +468,7 @@ async def signin_with_passkey( scope: Space-delimited scopes (optional). audience: API audience (optional). dpop_key: EC P-256 key to bind the tokens with DPoP (optional). + client_ip: End user IP address, sent as the auth0-forwarded-for header (optional). store_options: Options passed to the Transaction and State Store. Must include {"request": request, "response": response} so the session cookie can be written on response. @@ -470,10 +479,10 @@ async def signin_with_passkey( Raises: PasskeyError: If the token exchange fails or is rate limited - (see PasskeyErrorCode and is_verification_retryable). + (status_code 429). See is_verification_retryable. MfaRequiredError: If the tenant requires MFA after the passkey sign-in. MissingRequiredArgumentError: If auth_session or authn_response is missing. - InvalidArgumentError: If verification has a key other than "email" or "phone". + InvalidArgumentError: If verification is not a dict keyed by "email" or "phone". ConfigurationError: If dpop_key is combined with mTLS. ApiError: If the ID token fails validation. IssuerValidationError: If the ID token issuer does not match the tenant. @@ -489,5 +498,6 @@ async def signin_with_passkey( scope=scope, audience=audience, dpop_key=dpop_key, + client_ip=client_ip, store_options=store_options, ) diff --git a/src/auth0_fastapi/errors/__init__.py b/src/auth0_fastapi/errors/__init__.py index bb08a20..002910d 100644 --- a/src/auth0_fastapi/errors/__init__.py +++ b/src/auth0_fastapi/errors/__init__.py @@ -35,6 +35,7 @@ def auth0_exception_handler(request: Request, exc: Auth0Error): """ # Set a default status code status_code = 400 + headers = None if isinstance(exc, MissingTransactionError): status_code = 404 # Not Found @@ -50,11 +51,14 @@ def auth0_exception_handler(request: Request, exc: Auth0Error): status_code = 400 # Bad Request elif isinstance(exc, AccessTokenForConnectionError): status_code = 400 # Bad Request - elif isinstance(exc, PasskeyError) and exc.code == PasskeyErrorCode.TOO_MANY_REQUESTS: + elif isinstance(exc, PasskeyError) and exc.status_code == 429: status_code = 429 + if exc.retry_after is not None: + headers = {"Retry-After": str(exc.retry_after)} return JSONResponse( status_code=status_code, + headers=headers, content={ "error": getattr(exc, "code", "auth_error"), "message": exc.message or "An authentication error occurred.", diff --git a/src/auth0_fastapi/test/test_auth_client.py b/src/auth0_fastapi/test/test_auth_client.py index 98d96b7..d92bca3 100644 --- a/src/auth0_fastapi/test/test_auth_client.py +++ b/src/auth0_fastapi/test/test_auth_client.py @@ -1037,7 +1037,7 @@ class TestPasskey: @pytest.mark.asyncio async def test_passkey_signup_challenge_forwards_all_arguments(self, auth_client, mock_request, mock_response): - """Test that every signup argument, including delivery_method, reaches the underlying client.""" + """Test that every signup argument, including delivery_method and client_ip, reaches the underlying client.""" profile = PasskeyUserProfile(email="new.user@example.com", phone_number="+15551234567") store_options = {"request": mock_request, "response": mock_response} mock_result = PasskeySignupChallengeResponse( @@ -1055,6 +1055,7 @@ async def test_passkey_signup_challenge_forwards_all_arguments(self, auth_client organization="org_abc123", user_metadata={"plan": "free"}, delivery_method="voice", + client_ip="203.0.113.7", store_options=store_options, ) @@ -1066,6 +1067,7 @@ async def test_passkey_signup_challenge_forwards_all_arguments(self, auth_client organization="org_abc123", user_metadata={"plan": "free"}, delivery_method="voice", + client_ip="203.0.113.7", store_options=store_options, ) @@ -1083,13 +1085,19 @@ async def test_passkey_signup_challenge_defaults_to_none(self, auth_client): organization=None, user_metadata=None, delivery_method=None, + client_ip=None, store_options=None, ) @pytest.mark.asyncio async def test_passkey_signup_challenge_rate_limit_propagates(self, auth_client): - """Test that a rate-limited challenge raises PasskeyError with too_many_requests unchanged.""" - error = PasskeyError(PasskeyErrorCode.TOO_MANY_REQUESTS, "Too many passkey signup requests") + """Test that a rate-limited challenge raises PasskeyError with status_code and retry_after unchanged.""" + error = PasskeyError( + PasskeyErrorCode.CHALLENGE_FAILED, + "Passkey signup challenge failed with status 429", + status_code=429, + retry_after=30, + ) with patch.object(auth_client.client, 'passkey_signup_challenge', new_callable=AsyncMock) as mock_challenge: mock_challenge.side_effect = error @@ -1098,12 +1106,14 @@ async def test_passkey_signup_challenge_rate_limit_propagates(self, auth_client) await auth_client.passkey_signup_challenge(user_profile=PasskeyUserProfile(email="a@example.com")) assert exc_info.value is error + assert exc_info.value.status_code == 429 + assert exc_info.value.retry_after == 30 @pytest.mark.asyncio async def test_passkey_login_challenge_forwards_arguments_without_username( self, auth_client, mock_request, mock_response ): - """Test that the login challenge forwards connection and organization and never a username.""" + """Test that the login challenge forwards connection, organization and client_ip and never a username.""" store_options = {"request": mock_request, "response": mock_response} mock_result = PasskeyLoginChallengeResponse( auth_session="sess_abc123", @@ -1116,6 +1126,7 @@ async def test_passkey_login_challenge_forwards_arguments_without_username( result = await auth_client.passkey_login_challenge( connection="Username-Password-Authentication", organization="org_abc123", + client_ip="203.0.113.7", store_options=store_options, ) @@ -1123,13 +1134,18 @@ async def test_passkey_login_challenge_forwards_arguments_without_username( mock_challenge.assert_called_once_with( connection="Username-Password-Authentication", organization="org_abc123", + client_ip="203.0.113.7", store_options=store_options, ) @pytest.mark.asyncio async def test_passkey_login_challenge_rate_limit_error_propagates(self, auth_client): - """Test that a too_many_requests PasskeyError from the login challenge is not swallowed.""" - error = PasskeyError(PasskeyErrorCode.TOO_MANY_REQUESTS, "Too many requests") + """Test that a rate-limited PasskeyError from the login challenge is not swallowed.""" + error = PasskeyError( + PasskeyErrorCode.CHALLENGE_FAILED, + "Passkey login challenge failed with status 429", + status_code=429, + ) with patch.object(auth_client.client, 'passkey_login_challenge', new_callable=AsyncMock) as mock_challenge: mock_challenge.side_effect = error @@ -1138,7 +1154,7 @@ async def test_passkey_login_challenge_rate_limit_error_propagates(self, auth_cl await auth_client.passkey_login_challenge(connection="Username-Password-Authentication") assert exc_info.value is error - assert exc_info.value.code == PasskeyErrorCode.TOO_MANY_REQUESTS + assert exc_info.value.status_code == 429 @pytest.mark.asyncio async def test_signin_with_passkey_forwards_login_assertion( @@ -1158,6 +1174,7 @@ async def test_signin_with_passkey_forwards_login_assertion( organization="org_abc123", scope="openid profile email", audience="https://api.example.com", + client_ip="203.0.113.7", store_options=store_options, ) @@ -1171,6 +1188,7 @@ async def test_signin_with_passkey_forwards_login_assertion( scope="openid profile email", audience="https://api.example.com", dpop_key=None, + client_ip="203.0.113.7", store_options=store_options, ) @@ -1203,6 +1221,7 @@ async def test_signin_with_passkey_forwards_verification_codes( scope=None, audience=None, dpop_key=None, + client_ip=None, store_options=store_options, ) diff --git a/src/auth0_fastapi/test/test_errors.py b/src/auth0_fastapi/test/test_errors.py index 00bdb5d..926578d 100644 --- a/src/auth0_fastapi/test/test_errors.py +++ b/src/auth0_fastapi/test/test_errors.py @@ -19,15 +19,35 @@ class TestPasskeyErrorHandling: """Test how register_exception_handlers renders passkey errors.""" def test_rate_limited_passkey_error_maps_to_429(self): - """Test that too_many_requests becomes HTTP 429 so clients can back off.""" + """Test that a PasskeyError with status_code 429 becomes HTTP 429 with Retry-After.""" client = _client_raising( - PasskeyError(PasskeyErrorCode.TOO_MANY_REQUESTS, "Too many passkey signup requests") + PasskeyError( + PasskeyErrorCode.CHALLENGE_FAILED, + "Passkey signup challenge failed with status 429", + status_code=429, + retry_after=30, + ) + ) + + res = client.get("/raise") + + assert res.status_code == 429 + assert res.headers["Retry-After"] == "30" + assert res.json() == { + "error": "passkey_challenge_error", + "message": "Passkey signup challenge failed with status 429", + } + + def test_rate_limited_passkey_error_without_retry_after_omits_header(self): + """Test that a 429 without retry_after sends no Retry-After header.""" + client = _client_raising( + PasskeyError(PasskeyErrorCode.TOKEN_EXCHANGE_FAILED, "Passkey token exchange failed", status_code=429) ) res = client.get("/raise") assert res.status_code == 429 - assert res.json() == {"error": "too_many_requests", "message": "Too many passkey signup requests"} + assert "Retry-After" not in res.headers def test_retryable_passkey_error_body_omits_session_details(self): """Test that an unhandled retryable error never leaks auth_session into the response.""" @@ -49,7 +69,7 @@ def test_retryable_passkey_error_body_omits_session_details(self): def test_other_passkey_errors_map_to_400(self): """Test that non rate-limit passkey failures keep the default 400.""" client = _client_raising( - PasskeyError(PasskeyErrorCode.TOKEN_EXCHANGE_FAILED, "Passkey token exchange failed") + PasskeyError(PasskeyErrorCode.TOKEN_EXCHANGE_FAILED, "Passkey token exchange failed", status_code=403) ) res = client.get("/raise") From d5ceca14ddf7e69b22c8a507093f356865d08114 Mon Sep 17 00:00:00 2001 From: Sourav Basu Date: Fri, 23 Oct 2026 23:21:53 +0530 Subject: [PATCH 4/4] build: require auth0-server-python 1.0.0b20 The passkey wrappers use client_ip, PasskeyError.status_code and retry_after, and PasskeyDeliveryMethod, which first ship in 1.0.0b20. Earlier releases fail at import time. Raise the floor in pyproject.toml and requirements.txt and update the poetry.lock entry for the release. Co-Authored-By: Claude --- poetry.lock | 8 ++++---- pyproject.toml | 2 +- requirements.txt | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/poetry.lock b/poetry.lock index 1a0b7d4..2134508 100644 --- a/poetry.lock +++ b/poetry.lock @@ -80,14 +80,14 @@ trio = ["trio (>=0.32.0)"] [[package]] name = "auth0-server-python" -version = "1.0.0b14" +version = "1.0.0b20" description = "Auth0 server-side Python SDK" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "auth0_server_python-1.0.0b14-py3-none-any.whl", hash = "sha256:89c539f2bd10321f785944527a16ea1c552562cd0d5a45c6f0bb13c8c44b408d"}, - {file = "auth0_server_python-1.0.0b14.tar.gz", hash = "sha256:8834c982c8f0466f86080f95d79ce9a0b727ae6c2db0da3e5bd28c736e91333e"}, + {file = "auth0_server_python-1.0.0b20-py3-none-any.whl", hash = "sha256:2365c6fde8933907dcde658d154ffecae1922233f7873a43391d55edfb05dc33"}, + {file = "auth0_server_python-1.0.0b20.tar.gz", hash = "sha256:685ef0f0a5db37a4aae9598e98be3ae2044b5a897fb11a5022d68f02169400a4"}, ] [package.dependencies] @@ -1426,4 +1426,4 @@ standard = ["colorama (>=0.4) ; sys_platform == \"win32\"", "httptools (>=0.6.3) [metadata] lock-version = "2.1" python-versions = ">=3.9" -content-hash = "4a7b6ce232b6fb707303f9638f5b40f57a8b71b90eb47fa8ec18dd87bb5c001d" +content-hash = "7da1352426949a93a93bd2822b031fa750d1e36b4d2892b27bcc28b4dbabab51" diff --git a/pyproject.toml b/pyproject.toml index 4d2d7d9..4add585 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,7 +12,7 @@ packages = [ [tool.poetry.dependencies] python = ">=3.9" -auth0-server-python = ">=1.0.0b14" +auth0-server-python = ">=1.0.0b20" fastapi = ">=0.115.11" pydantic = "^2.12.5" diff --git a/requirements.txt b/requirements.txt index 4f34a4b..a7c62fb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -auth0-server-python>=1.0.0b14 +auth0-server-python>=1.0.0b20 fastapi>=0.115.11 pydantic>=2.12.5 uvicorn>=0.39.0