From b181166dee8f5aee65b5fb87e1326d5bf53df06a Mon Sep 17 00:00:00 2001 From: Sourav Basu Date: Mon, 12 Oct 2026 22:58:59 +0530 Subject: [PATCH 01/23] 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 02/23] 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 03/23] 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 04/23] 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 From 4e9f0f2198c13d8683fb18de61e262a329e33418 Mon Sep 17 00:00:00 2001 From: Sourav Basu Date: Mon, 26 Oct 2026 20:54:04 +0530 Subject: [PATCH 05/23] docs: bound the passkey sign-in body in the guide SigninRequest used PasskeyAuthResponse directly, which accepts unknown keys and has no length limits, so an oversized or padded credential was forwarded to Auth0 unchanged. Add strict AssertionData and AuthnResponse models with extra="forbid" and max_length, and build PasskeyAuthResponse from the validated body in the route. Co-Authored-By: Claude --- examples/Passkeys.md | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/examples/Passkeys.md b/examples/Passkeys.md index 9c58da6..5331597 100644 --- a/examples/Passkeys.md +++ b/examples/Passkeys.md @@ -72,9 +72,27 @@ class VerificationCodes(BaseModel): phone: Optional[str] = Field(None, max_length=10) +class AssertionData(BaseModel): + model_config = ConfigDict(extra="forbid") + clientDataJSON: str = Field(max_length=2048) + attestationObject: Optional[str] = Field(None, max_length=4096) + authenticatorData: Optional[str] = Field(None, max_length=2048) + signature: Optional[str] = Field(None, max_length=2048) + userHandle: Optional[str] = Field(None, max_length=2048) + + +class AuthnResponse(BaseModel): + model_config = ConfigDict(extra="forbid") + id: str = Field(max_length=1400) + rawId: str = Field(max_length=1400) + type: Literal["public-key"] + authenticatorAttachment: Optional[str] = Field(None, max_length=30) + response: AssertionData + + class SigninRequest(BaseModel): auth_session: str = Field(min_length=1, max_length=256) - authn_response: PasskeyAuthResponse + authn_response: AuthnResponse verification: Optional[VerificationCodes] = None @@ -132,7 +150,7 @@ const credential = await navigator.credentials.create({ 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. +`postJson` stands for your app's own `fetch` helper. Send only the `response` keys shown. The route rejects any other key and any value that is not a string, and drops a `null` optional value. 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 @@ -147,7 +165,7 @@ async def passkey_signin(body: SigninRequest, request: Request, response: Respon try: result = await auth_client.signin_with_passkey( auth_session=body.auth_session, - authn_response=body.authn_response, + authn_response=PasskeyAuthResponse(**body.authn_response.model_dump(exclude_none=True)), verification=codes or None, connection=CONNECTION, client_ip=client_ip(request), From 82c673dc736d1cf904b303d005d127d45fdf5839 Mon Sep 17 00:00:00 2001 From: Sourav Basu Date: Mon, 26 Oct 2026 20:59:16 +0530 Subject: [PATCH 06/23] docs: validate the forwarded client IP in the passkey guide request.client.host can hold unknown, an ip:port pair or a client-set X-Forwarded-For entry, and the helper passed it to Auth0 unchanged. Return the address only when ipaddress.ip_address accepts it. Reword the proxy advice to never use * for --forwarded-allow-ips, because uvicorn then trusts the first X-Forwarded-For entry, which the client sets. Co-Authored-By: Claude --- examples/Passkeys.md | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/examples/Passkeys.md b/examples/Passkeys.md index 5331597..cfb6710 100644 --- a/examples/Passkeys.md +++ b/examples/Passkeys.md @@ -46,6 +46,7 @@ register_exception_handlers(app) 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 +import ipaddress from typing import Literal, Optional from auth0_server_python.auth_types import PasskeyAuthResponse, PasskeyUserProfile @@ -97,7 +98,11 @@ class SigninRequest(BaseModel): def client_ip(request: Request) -> Optional[str]: - return request.client.host if request.client else None + host = request.client.host if request.client else None + try: + return str(ipaddress.ip_address(host)) if host else None + except ValueError: + return None ``` ### Step 1 - Request a signup challenge @@ -291,7 +296,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. +- **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 the addresses of your own proxies and never to `*`, which makes uvicorn trust the first `X-Forwarded-For` entry, and the client can set that entry. Uvicorn passes the header value through unchecked, so the `client_ip` helper returns the address only when it is a valid IP and `None` otherwise, because Auth0 can reject any other value. Never copy a client-supplied header into `client_ip`. - **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. From 9beb7ee0807866828481960f2afd4890dfa84c85 Mon Sep 17 00:00:00 2001 From: Sourav Basu Date: Mon, 26 Oct 2026 21:00:45 +0530 Subject: [PATCH 07/23] docs: scope the passkey proxy advice to uvicorn The forwarded-for guidance named uvicorn flags as if every app used uvicorn. Say it applies when uvicorn is the server and tell readers of other servers to check which forwarding headers their server trusts. 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 cfb6710..089da55 100644 --- a/examples/Passkeys.md +++ b/examples/Passkeys.md @@ -296,7 +296,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 the addresses of your own proxies and never to `*`, which makes uvicorn trust the first `X-Forwarded-For` entry, and the client can set that entry. Uvicorn passes the header value through unchecked, so the `client_ip` helper returns the address only when it is a valid IP and `None` otherwise, because Auth0 can reject any other value. Never copy a client-supplied header into `client_ip`. +- **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. How `request.client.host` is derived depends on your server. If you use uvicorn, it reads `X-Forwarded-For` only from the addresses in `--forwarded-allow-ips` (default `127.0.0.1`). Set that to the addresses of your own proxies and never to `*`, which makes uvicorn trust the first `X-Forwarded-For` entry, and the client can set that entry. With another server, check which forwarding headers it trusts and from whom. The value may not be a valid IP address (uvicorn passes the header through unchecked), so the `client_ip` helper returns the address only when it is valid and `None` otherwise, because Auth0 can reject any other value. Never copy a client-supplied header into `client_ip`. - **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. From 368e272c8d6a67de25865ce1502a8c0d12764ef6 Mon Sep 17 00:00:00 2001 From: Sourav Basu Date: Mon, 26 Oct 2026 21:04:30 +0530 Subject: [PATCH 08/23] docs: request scope and audience on passkey sign-in signin_with_passkey does not take the Auth0Config audience or scope the way start_login does. Without openid in the scope no ID token comes back, so the guide route raised TypeError on state_data["user"], and without the audience get_access_token cannot find the stored token. Pass scope and audience in the sign-in route, handle a missing user, and stop describing the session as identical to /auth/callback. Co-Authored-By: Claude --- README.md | 2 +- examples/Passkeys.md | 9 +++++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 1d64630..ce3bfc0 100644 --- a/README.md +++ b/README.md @@ -322,7 +322,7 @@ See [examples/CustomTokenExchange.md](./examples/CustomTokenExchange.md) for set ### 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`. +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 stores the session in the same state store as `/auth/callback`. Pass `scope` with `openid` and your `audience` to `signin_with_passkey()` to get the same tokens as a regular login. - **[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()`. diff --git a/examples/Passkeys.md b/examples/Passkeys.md index 089da55..83bb78c 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. Signing in with a passkey establishes the same server-side session as `/auth/callback`. +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 stores the session in the same state store as `/auth/callback`. Request the `openid` scope and your `audience` when you sign in, as shown in [step 3](#step-3---sign-in-and-establish-the-session). > **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). @@ -173,6 +173,8 @@ async def passkey_signin(body: SigninRequest, request: Request, response: Respon authn_response=PasskeyAuthResponse(**body.authn_response.model_dump(exclude_none=True)), verification=codes or None, connection=CONNECTION, + scope="openid profile email offline_access", + audience=auth_client.config.audience, client_ip=client_ip(request), store_options={"request": request, "response": response}, ) @@ -187,9 +189,12 @@ async def passkey_signin(body: SigninRequest, request: Request, response: Respon "verification_required": e.verification_required, } - return {"sub": result.state_data["user"]["sub"]} + user = result.state_data.get("user") + return {"sub": user["sub"] if user else None} ``` +Unlike `/auth/login`, the SDK does not add your `Auth0Config` scope or audience to this call, so pass them yourself. Auth0 issues an ID token, and so the user in `state_data`, only when the scope includes `openid`. Pass the same `audience` as your `Auth0Config`, so `get_access_token()` finds the token this sign-in stored. Adjust `scope` to what your app needs and drop `offline_access` if you do not want a refresh token. + 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. From 1491c7958bea5a675676b7f8110dd220066d3dbf Mon Sep 17 00:00:00 2001 From: Sourav Basu Date: Mon, 26 Oct 2026 21:05:11 +0530 Subject: [PATCH 09/23] docs: reword the passkey scope and audience note Explain how /auth/login and signin_with_passkey differ instead of instructing the reader, and say why openid and the audience matter. 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 83bb78c..a0adf45 100644 --- a/examples/Passkeys.md +++ b/examples/Passkeys.md @@ -193,7 +193,7 @@ async def passkey_signin(body: SigninRequest, request: Request, response: Respon return {"sub": user["sub"] if user else None} ``` -Unlike `/auth/login`, the SDK does not add your `Auth0Config` scope or audience to this call, so pass them yourself. Auth0 issues an ID token, and so the user in `state_data`, only when the scope includes `openid`. Pass the same `audience` as your `Auth0Config`, so `get_access_token()` finds the token this sign-in stored. Adjust `scope` to what your app needs and drop `offline_access` if you do not want a refresh token. +`/auth/login` takes the `audience` and `authorization_params` from your `Auth0Config`, while `signin_with_passkey()` sends only the `scope` and `audience` you give it. Include `openid` in the scope, because Auth0 issues an ID token, and so the user in `state_data`, only with it. Use the same `audience` as your `Auth0Config`, so that `get_access_token()` finds the token this sign-in stored. Adjust `scope` to what your app needs, and drop `offline_access` if you do not want a refresh token. 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. From 8de3969bf06149b919dc628a116c85ecef27dbbd Mon Sep 17 00:00:00 2001 From: Sourav Basu Date: Mon, 26 Oct 2026 21:06:50 +0530 Subject: [PATCH 10/23] docs: warn that the passkey sign-in result holds tokens PasskeyLoginResult carries the access, refresh and ID tokens and its repr prints them. Say the route should keep it on the server and never return or log it. Co-Authored-By: Claude --- examples/Passkeys.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/examples/Passkeys.md b/examples/Passkeys.md index a0adf45..4e10ee8 100644 --- a/examples/Passkeys.md +++ b/examples/Passkeys.md @@ -195,6 +195,8 @@ async def passkey_signin(body: SigninRequest, request: Request, response: Respon `/auth/login` takes the `audience` and `authorization_params` from your `Auth0Config`, while `signin_with_passkey()` sends only the `scope` and `audience` you give it. Include `openid` in the scope, because Auth0 issues an ID token, and so the user in `state_data`, only with it. Use the same `audience` as your `Auth0Config`, so that `get_access_token()` finds the token this sign-in stored. Adjust `scope` to what your app needs, and drop `offline_access` if you do not want a refresh token. +`result` holds the user's access, refresh and ID tokens, and `repr(result)` prints them. Keep it on the server and never return or log it. The route above returns only the `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. From e3b49a1b682bee63f6bc46015373ab0a62116272 Mon Sep 17 00:00:00 2001 From: Sourav Basu Date: Mon, 26 Oct 2026 21:09:10 +0530 Subject: [PATCH 11/23] docs: add a login credential helper to the passkey guide The login section only described the response keys, so a copied null userHandle would fail validation. Add toLoginResponse, which builds the body from credential.toJSON() and leaves userHandle out when it is empty, and post the result to the shared sign-in route. Co-Authored-By: Claude --- examples/Passkeys.md | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/examples/Passkeys.md b/examples/Passkeys.md index 4e10ee8..3d002da 100644 --- a/examples/Passkeys.md +++ b/examples/Passkeys.md @@ -223,7 +223,33 @@ async def passkey_login_challenge(request: Request, response: Response): } ``` -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. +In the browser, get the credential with `navigator.credentials.get()` and post it to the same `/passkey/signin` route as signup: + +```javascript +function toLoginResponse(credential) { + const json = credential.toJSON(); + const { clientDataJSON, authenticatorData, signature, userHandle } = json.response; + return { + id: json.id, + rawId: json.rawId, + type: json.type, + authenticatorAttachment: json.authenticatorAttachment, + response: { clientDataJSON, authenticatorData, signature, ...(userHandle ? { userHandle } : {}) }, + }; +} + +const challenge = await postJson("/passkey/login/challenge"); + +const credential = await navigator.credentials.get({ + publicKey: PublicKeyCredential.parseRequestOptionsFromJSON(challenge.public_key), +}); +await postJson("/passkey/signin", { + auth_session: challenge.auth_session, + authn_response: toLoginResponse(credential), +}); +``` + +`userHandle` can be `null`. The helper leaves it out in that case, and the route drops a `null` value as well. Identifier verification does not apply to login. ## 3. Identifier verification (optional) From f4d1d894170e88bcef76daf5e3c4963f34a4c6cb Mon Sep 17 00:00:00 2001 From: Sourav Basu Date: Mon, 26 Oct 2026 21:12:11 +0530 Subject: [PATCH 12/23] docs: wrap the passkey retry snippet in a function The identifier verification snippet used return and await at the top level, so it was not valid JavaScript as written. Wrap the flow in signUpWithPasskey so the return is legal and the snippet can be pasted and run. Co-Authored-By: Claude --- examples/Passkeys.md | 46 +++++++++++++++++++++++--------------------- 1 file changed, 24 insertions(+), 22 deletions(-) diff --git a/examples/Passkeys.md b/examples/Passkeys.md index 3d002da..1c0998f 100644 --- a/examples/Passkeys.md +++ b/examples/Passkeys.md @@ -270,32 +270,34 @@ Enable identifier verification for email, phone, or both on your database connec 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" }); +async function signUpWithPasskey(email, phone_number) { + const challenge = await postJson("/passkey/signup/challenge", { email, phone_number, delivery_method: "text" }); -let verification = challenge.verification_required - ? await promptForCodes(challenge.verification_required) - : undefined; + 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 }), + const credential = await navigator.credentials.create({ + publicKey: PublicKeyCredential.parseCreationOptionsFromJSON(challenge.public_key), }); - 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); + 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."); } -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. From bd70622b85b6f0e5ece8cf04f6414ae279f1b2d6 Mon Sep 17 00:00:00 2001 From: Sourav Basu Date: Mon, 26 Oct 2026 21:15:09 +0530 Subject: [PATCH 13/23] docs: add MFA completion routes to the passkey guide A tenant that requires MFA got a bare 401 from the sign-in route and the guide said the SDK does not store the session, which is wrong for auth0-server-python 1.0.0b20. Catch MfaRequiredError in the sign-in route and return the mfa_token, add a verify route that calls mfa.verify with persist so the SDK creates the session, and note that StatefulStateStore cannot save the pending MFA state yet. Co-Authored-By: Claude --- examples/Passkeys.md | 39 ++++++++++++++++++++++++++++++++++++--- 1 file changed, 36 insertions(+), 3 deletions(-) diff --git a/examples/Passkeys.md b/examples/Passkeys.md index 1c0998f..811d79f 100644 --- a/examples/Passkeys.md +++ b/examples/Passkeys.md @@ -50,6 +50,7 @@ import ipaddress from typing import Literal, Optional from auth0_server_python.auth_types import PasskeyAuthResponse, PasskeyUserProfile +from auth0_server_python.error import MfaRequiredError from fastapi import APIRouter, Request, Response from pydantic import BaseModel, ConfigDict, Field @@ -188,6 +189,10 @@ async def passkey_signin(body: SigninRequest, request: Request, response: Respon "auth_session": e.auth_session, "verification_required": e.verification_required, } + except MfaRequiredError as e: + response.status_code = 401 + response.headers["Cache-Control"] = "no-store" + return {"error": "mfa_required", "mfa_token": e.mfa_token} user = result.state_data.get("user") return {"sub": user["sub"] if user else None} @@ -199,7 +204,7 @@ async def passkey_signin(body: SigninRequest, request: Request, response: Respon 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. +The `except PasskeyError` branch handles identifier verification and is covered in [section 3](#3-identifier-verification-optional). The `except MfaRequiredError` branch is for tenants that require MFA and is covered in [Completing MFA](#completing-mfa). Every other error is re-raised to the SDK exception handler. ## 2. Passkey Login @@ -347,11 +352,39 @@ With `register_exception_handlers(app)`, unhandled errors from the passkey metho | `InvalidArgumentError` | 400 | | `OrganizationTokenValidationError` | 400 | | `MissingRequiredArgumentError` | 422 | -| `MfaRequiredError` | 401 | +| `MfaRequiredError`, when the route does not catch it | 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). +### Completing MFA + +When the tenant requires MFA after the passkey sign-in, `signin_with_passkey()` raises `MfaRequiredError` before any session exists. The sign-in route in section 1 catches it and returns the `mfa_token` with a 401, so the browser has something to continue with. Without that branch the exception handler returns a 401 with only `error` and `message`. + +The browser sends the `mfa_token` back with the user's code. Treat the token as a credential: send `Cache-Control: no-store` and never log it. This route finishes the sign-in through the MFA client on the underlying `ServerClient` (`auth_client.client.mfa`). With `persist` set, `verify()` creates the session from the ID token in its response, so you do not store tokens yourself. Keep `openid` in the sign-in scope for that reason. + +```python +class MfaVerifyRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + mfa_token: str = Field(min_length=1, max_length=8192) + otp: str = Field(min_length=1, max_length=12) + + +@router.post("/passkey/mfa/verify") +async def passkey_mfa_verify(body: MfaVerifyRequest, request: Request, response: Response): + auth_client: AuthClient = request.app.state.auth_client + + await auth_client.client.mfa.verify( + {"mfa_token": body.mfa_token, "otp": body.otp, "persist": True}, + store_options={"request": request, "response": response}, + ) + + response.headers["Cache-Control"] = "no-store" + return {"signed_in": True} +``` + +Some factors need a call to `mfa.challenge_authenticator()` before `verify()`, and recovery codes and out-of-band factors use other keys. See the [auth0-server-python MFA guide](https://github.com/auth0/auth0-server-python/blob/main/examples/MFA.md) for the options. + +MFA on a passkey login needs the default cookie state store for now. `StatefulStateStore` cannot save the pending MFA state, so the sign-in fails with a generic error before `MfaRequiredError` reaches your route. 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). From a7bb13e5d2c074d5e1484a792809e128cabe9262 Mon Sep 17 00:00:00 2001 From: Sourav Basu Date: Mon, 26 Oct 2026 21:19:11 +0530 Subject: [PATCH 14/23] test: cover MFA completion on a passkey login Run the real AuthClient and ServerClient against a mocked Auth0 so the tests catch wiring and release changes that the AsyncMock tests cannot. Cover sign-in stopping at mfa_required with an encrypted token, mfa.verify with persist creating the session, the raw token and one-time password grant sent to Auth0, and a rejected code leaving no session. Co-Authored-By: Claude --- src/auth0_fastapi/test/test_passkey_mfa.py | 218 +++++++++++++++++++++ 1 file changed, 218 insertions(+) create mode 100644 src/auth0_fastapi/test/test_passkey_mfa.py diff --git a/src/auth0_fastapi/test/test_passkey_mfa.py b/src/auth0_fastapi/test/test_passkey_mfa.py new file mode 100644 index 0000000..660f182 --- /dev/null +++ b/src/auth0_fastapi/test/test_passkey_mfa.py @@ -0,0 +1,218 @@ +import json +import time +from http.cookies import SimpleCookie +from urllib.parse import parse_qs + +import httpx +import jwt +import pytest +from auth0_server_python.auth_types import PasskeyAuthResponse +from auth0_server_python.error import MfaRequiredError, MfaVerifyError +from fastapi import Request, Response +from jwcrypto import jwk + +from auth0_fastapi.auth.auth_client import AuthClient +from auth0_fastapi.config import Auth0Config + +DOMAIN = "test.auth0.com" +CLIENT_ID = "test_client_id" +AUDIENCE = "https://api.example.com" +RAW_MFA_TOKEN = "raw-mfa-token-issued-by-auth0" +SCOPE = "openid profile email offline_access" + + +class MockAuth0: + """Answers the Auth0 endpoints the passkey sign-in and MFA verification call.""" + + def __init__(self, signing_key): + self.signing_key = signing_key + self.token_responses = [] + self.token_requests = [] + + def id_token(self): + now = int(time.time()) + claims = { + "iss": f"https://{DOMAIN}/", + "aud": CLIENT_ID, + "sub": "auth0|abc123", + "iat": now, + "exp": now + 600, + } + pem = self.signing_key.export_to_pem(private_key=True, password=None) + return jwt.encode(claims, pem, algorithm="RS256", headers={"kid": self.signing_key.key_id}) + + def handle(self, request): + path = request.url.path + if path == "/.well-known/openid-configuration": + return httpx.Response(200, json={ + "issuer": f"https://{DOMAIN}/", + "token_endpoint": f"https://{DOMAIN}/oauth/token", + "jwks_uri": f"https://{DOMAIN}/.well-known/jwks.json", + }) + if path == "/.well-known/jwks.json": + return httpx.Response(200, json={"keys": [json.loads(self.signing_key.export_public())]}) + if path == "/oauth/token": + self.token_requests.append(request) + return self.token_responses.pop(0) + return httpx.Response(404) + + +def mfa_required_response(): + return httpx.Response(403, json={ + "error": "mfa_required", + "error_description": "Multifactor authentication required", + "mfa_token": RAW_MFA_TOKEN, + }) + + +def tokens_response(mock): + return httpx.Response(200, json={ + "access_token": "access-token", + "token_type": "Bearer", + "expires_in": 3600, + "refresh_token": "refresh-token", + "scope": SCOPE, + "id_token": mock.id_token(), + }) + + +def request_with_cookies(response=None): + jar = {} + for header in response.headers.getlist("set-cookie") if response is not None else []: + cookie = SimpleCookie(header) + for name, morsel in cookie.items(): + if morsel["max-age"] == "0" or not morsel.value: + jar.pop(name, None) + else: + jar[name] = morsel.value + headers = [(b"cookie", "; ".join(f"{k}={v}" for k, v in jar.items()).encode())] if jar else [] + return Request({"type": "http", "headers": headers}) + + +async def sign_in_requiring_mfa(auth_client, mock): + mock.token_responses.append(mfa_required_response()) + response = Response() + with pytest.raises(MfaRequiredError) as exc_info: + await auth_client.signin_with_passkey( + auth_session="sess_abc123", + authn_response=PasskeyAuthResponse( + id="cred_abc123", + rawId="cred_abc123", + type="public-key", + response={"clientDataJSON": "client-data", "authenticatorData": "auth-data", "signature": "sig"}, + ), + scope=SCOPE, + audience=AUDIENCE, + store_options={"request": request_with_cookies(), "response": response}, + ) + return exc_info.value, response + + +@pytest.fixture(scope="module") +def signing_key(): + return jwk.JWK.generate(kty="RSA", size=2048, kid="test-key") + + +@pytest.fixture +def auth_client(): + """Fixture providing an AuthClient with an audience configured.""" + return AuthClient(Auth0Config( + domain=DOMAIN, + client_id=CLIENT_ID, + client_secret="test_client_secret", + app_base_url="https://example.com", + secret="test_secret_key_minimum_32_characters", + audience=AUDIENCE, + )) + + +@pytest.fixture +def auth0(auth_client, signing_key, monkeypatch): + """Fixture routing the SDK's HTTP calls to a MockAuth0 once the client is built.""" + mock = MockAuth0(signing_key) + real_client = httpx.AsyncClient + + class MockedAsyncClient(real_client): + def __init__(self, *args, **kwargs): + kwargs["transport"] = httpx.MockTransport(mock.handle) + super().__init__(*args, **kwargs) + + monkeypatch.setattr(httpx, "AsyncClient", MockedAsyncClient) + return mock + + +class TestPasskeyMfa: + """Test passkey sign-in and MFA completion through the real ServerClient with a mocked Auth0.""" + + @pytest.mark.asyncio + async def test_signin_with_passkey_mfa_required_raises_with_encrypted_token(self, auth_client, auth0): + """Test that an mfa_required response raises MfaRequiredError carrying a token other than the raw one.""" + error, _ = await sign_in_requiring_mfa(auth_client, auth0) + + assert error.mfa_token + assert error.mfa_token != RAW_MFA_TOKEN + + @pytest.mark.asyncio + async def test_signin_with_passkey_mfa_required_creates_no_session(self, auth_client, auth0): + """Test that no user session exists after sign-in stops at the MFA requirement.""" + _, response = await sign_in_requiring_mfa(auth_client, auth0) + + user = await auth_client.client.get_user( + store_options={"request": request_with_cookies(response), "response": Response()} + ) + + assert user is None + + @pytest.mark.asyncio + async def test_mfa_verify_with_persist_creates_session(self, auth_client, auth0): + """Test that mfa.verify with persist creates the session and get_access_token finds its token.""" + error, response = await sign_in_requiring_mfa(auth_client, auth0) + auth0.token_responses.append(tokens_response(auth0)) + + await auth_client.client.mfa.verify( + {"mfa_token": error.mfa_token, "otp": "123456", "persist": True}, + store_options={"request": request_with_cookies(response), "response": response}, + ) + + store_options = {"request": request_with_cookies(response), "response": Response()} + user = await auth_client.client.get_user(store_options=store_options) + access_token = await auth_client.client.get_access_token(store_options=store_options) + assert user["sub"] == "auth0|abc123" + assert access_token == "access-token" + + @pytest.mark.asyncio + async def test_mfa_verify_sends_the_raw_token_and_the_code(self, auth_client, auth0): + """Test that verify decrypts the token for Auth0 and sends the one-time password grant.""" + error, response = await sign_in_requiring_mfa(auth_client, auth0) + auth0.token_responses.append(tokens_response(auth0)) + + await auth_client.client.mfa.verify( + {"mfa_token": error.mfa_token, "otp": "123456", "persist": True}, + store_options={"request": request_with_cookies(response), "response": response}, + ) + + sent = parse_qs(auth0.token_requests[-1].content.decode()) + assert sent["grant_type"] == ["http://auth0.com/oauth/grant-type/mfa-otp"] + assert sent["mfa_token"] == [RAW_MFA_TOKEN] + assert sent["otp"] == ["123456"] + + @pytest.mark.asyncio + async def test_mfa_verify_wrong_code_raises_and_creates_no_session(self, auth_client, auth0): + """Test that a rejected code raises MfaVerifyError and leaves no session behind.""" + error, response = await sign_in_requiring_mfa(auth_client, auth0) + auth0.token_responses.append(httpx.Response(403, json={ + "error": "invalid_grant", + "error_description": "Wrong one-time password", + })) + verify_response = Response() + + with pytest.raises(MfaVerifyError): + await auth_client.client.mfa.verify( + {"mfa_token": error.mfa_token, "otp": "000000", "persist": True}, + store_options={"request": request_with_cookies(response), "response": verify_response}, + ) + + user = await auth_client.client.get_user( + store_options={"request": request_with_cookies(verify_response), "response": Response()} + ) + assert user is None From b7c9deece4af9d83a0fa782cd0ceb6025405a15d Mon Sep 17 00:00:00 2001 From: Sourav Basu Date: Mon, 26 Oct 2026 21:21:54 +0530 Subject: [PATCH 15/23] docs: add organization and DPoP guidance to the passkey guide Show how to pass organization to the signup challenge, login challenge and sign-in calls and why the sign-in call matters. Replace the one-line DPoP pointer with notes on keeping the key per session on the server, passing it to mfa.verify, and the fact that get_access_token takes no dpop_key and sends no proof when it refreshes a token. Co-Authored-By: Claude --- examples/Passkeys.md | 40 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/examples/Passkeys.md b/examples/Passkeys.md index 811d79f..e44fc1b 100644 --- a/examples/Passkeys.md +++ b/examples/Passkeys.md @@ -21,6 +21,7 @@ The methods are programmatic. The SDK does not mount passkey routes. You add the - [3. Identifier verification (optional)](#3-identifier-verification-optional) - [4. Security considerations](#4-security-considerations) - [5. Error Handling](#5-error-handling) +- [6. Organizations and DPoP (optional)](#6-organizations-and-dpop-optional) ## How the flow works @@ -386,7 +387,44 @@ Some factors need a call to `mfa.challenge_authenticator()` before `verify()`, a MFA on a passkey login needs the default cookie state store for now. `StatefulStateStore` cannot save the pending MFA state, so the sign-in fails with a generic error before `MfaRequiredError` reaches your route. -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). +## 6. Organizations and DPoP (optional) + +### Organizations + +To sign users up and in within an organization, pass the same `organization` (ID or name) to the signup challenge, the login challenge and `signin_with_passkey()`. Your database connection must be enabled for the organization. + +Define the organization next to `CONNECTION` and add it to the signup route: + +```diff + CONNECTION = "Username-Password-Authentication" ++ORGANIZATION = "org_abc123" +... + challenge = await auth_client.passkey_signup_challenge( + user_profile=PasskeyUserProfile(email=body.email, phone_number=body.phone_number), + connection=CONNECTION, ++ organization=ORGANIZATION, + delivery_method=body.delivery_method, +``` + +Add `organization=ORGANIZATION` to the login challenge and sign-in calls in the same way. The sign-in call matters most. With `organization` set, `signin_with_passkey()` checks the ID token and raises `OrganizationTokenValidationError` when the organization claim is missing or different, or when no ID token comes back. A signup that did not end up in the organization then fails instead of creating a session outside it. The exception handler returns 400 for that error. + +### DPoP + +Pass a `dpop_key` to `signin_with_passkey()` to get DPoP-bound tokens. The tokens work only with that key, so you need the same key for every later call that uses them. + +```python +from jwcrypto import jwk + +dpop_key = jwk.JWK.generate(kty="EC", crv="P-256") +``` + +Add `dpop_key=dpop_key` to the sign-in call in section 1. Then keep these points in mind: + +- Generate one key per user session and keep it on the server, next to the session, in a store you control. Do not send it to the browser or put it in a cookie. +- The private key is a secret, so never log it. The [auth0-server-python passkey guide](https://github.com/auth0/auth0-server-python/blob/main/examples/Passkeys.md#3-dpop-bound-passkey-tokens-optional) covers key storage. +- If the tenant also requires MFA, pass the same key to `mfa.verify()` through its `dpop_key` argument. +- `get_access_token()` takes no `dpop_key`, so it sends no DPoP proof when it refreshes a token. Test the refresh path with DPoP-bound tokens before you rely on it. +- The SDK raises a `PasskeyError` when the token type does not match whether you passed a key, so a token is never stored without the key it needs. ## Additional Resources From d7757982a732cdf0ed3f82414188cd53cbf208f4 Mon Sep 17 00:00:00 2001 From: Sourav Basu Date: Mon, 26 Oct 2026 21:22:59 +0530 Subject: [PATCH 16/23] docs: drop unreachable errors from the signin_with_passkey docstring AuthClient never enables mTLS or Enterprise Connect, so ConfigurationError for dpop_key with mTLS and EnterpriseConnectError cannot reach a caller. Co-Authored-By: Claude --- src/auth0_fastapi/auth/auth_client.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/auth0_fastapi/auth/auth_client.py b/src/auth0_fastapi/auth/auth_client.py index a81229a..12c822f 100644 --- a/src/auth0_fastapi/auth/auth_client.py +++ b/src/auth0_fastapi/auth/auth_client.py @@ -483,11 +483,9 @@ async def signin_with_passkey( MfaRequiredError: If the tenant requires MFA after the passkey sign-in. MissingRequiredArgumentError: If auth_session or authn_response is missing. 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. 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, From 222c4682f642205ca92b9f72d352d707e70c0331 Mon Sep 17 00:00:00 2001 From: Sourav Basu Date: Mon, 26 Oct 2026 21:26:44 +0530 Subject: [PATCH 17/23] fix: return 502 for passkey errors caused by Auth0 or the network A PasskeyError from an Auth0 5xx, or from a failure with no HTTP response such as a network error or an unreadable body, reached the browser as 400, so a temporary Auth0 outage looked like a bad request. Map those to 502 like ApiError, keep 429 with Retry-After, and keep other Auth0 4xx answers as 400. Move the passkey mapping into a helper to stay under the handler's complexity limit and split the guide's error table row. Co-Authored-By: Claude --- examples/Passkeys.md | 3 ++- src/auth0_fastapi/errors/__init__.py | 17 ++++++++++--- src/auth0_fastapi/test/test_errors.py | 35 ++++++++++++++++++++++++++- 3 files changed, 49 insertions(+), 6 deletions(-) diff --git a/examples/Passkeys.md b/examples/Passkeys.md index e44fc1b..691620e 100644 --- a/examples/Passkeys.md +++ b/examples/Passkeys.md @@ -349,7 +349,8 @@ With `register_exception_handlers(app)`, unhandled errors from the passkey metho | Error | HTTP status | |---|---| | `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 | +| `PasskeyError` from an Auth0 4xx response (for example `invalid_grant`, `invalid_request`) | 400 | +| `PasskeyError` from an Auth0 5xx response, or with no HTTP response at all (a network failure or an unreadable body) | 502 | | `InvalidArgumentError` | 400 | | `OrganizationTokenValidationError` | 400 | | `MissingRequiredArgumentError` | 422 | diff --git a/src/auth0_fastapi/errors/__init__.py b/src/auth0_fastapi/errors/__init__.py index 002910d..9a6e447 100644 --- a/src/auth0_fastapi/errors/__init__.py +++ b/src/auth0_fastapi/errors/__init__.py @@ -1,3 +1,5 @@ +from typing import Optional + #Imported from auth0-server-python from auth0_server_python.error import ( AccessTokenError, @@ -28,6 +30,15 @@ def __init__(self, message=None): super().__init__(message or "An invalid configuration was provided.") self.name = "ConfigurationError" +def _passkey_error_response(exc: PasskeyError) -> tuple[int, Optional[dict[str, str]]]: + """Return the HTTP status and headers for a PasskeyError.""" + if exc.status_code == 429: + headers = {"Retry-After": str(exc.retry_after)} if exc.retry_after is not None else None + return 429, headers + if exc.status_code is None or exc.status_code >= 500: + return 502, None + return 400, None + def auth0_exception_handler(request: Request, exc: Auth0Error): """ Exception handler for Auth0 SDK errors. @@ -51,10 +62,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.status_code == 429: - status_code = 429 - if exc.retry_after is not None: - headers = {"Retry-After": str(exc.retry_after)} + elif isinstance(exc, PasskeyError): + status_code, headers = _passkey_error_response(exc) return JSONResponse( status_code=status_code, diff --git a/src/auth0_fastapi/test/test_errors.py b/src/auth0_fastapi/test/test_errors.py index 926578d..043610f 100644 --- a/src/auth0_fastapi/test/test_errors.py +++ b/src/auth0_fastapi/test/test_errors.py @@ -1,3 +1,4 @@ +import pytest from fastapi import FastAPI from fastapi.testclient import TestClient @@ -57,6 +58,7 @@ def test_retryable_passkey_error_body_omits_session_details(self): "Invalid or expired session", auth_session="sess_abc123", verification_required=["email"], + status_code=400, ) ) @@ -67,7 +69,7 @@ def test_retryable_passkey_error_body_omits_session_details(self): 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.""" + """Test that passkey failures answered with an Auth0 4xx keep the default 400.""" client = _client_raising( PasskeyError(PasskeyErrorCode.TOKEN_EXCHANGE_FAILED, "Passkey token exchange failed", status_code=403) ) @@ -76,3 +78,34 @@ def test_other_passkey_errors_map_to_400(self): assert res.status_code == 400 assert res.json()["error"] == "passkey_token_error" + + @pytest.mark.parametrize("auth0_status", [500, 502, 503]) + def test_passkey_error_from_auth0_server_failure_maps_to_502(self, auth0_status): + """Test that an Auth0 5xx reaches the browser as a bad gateway, not as a bad request.""" + client = _client_raising( + PasskeyError( + PasskeyErrorCode.TOKEN_EXCHANGE_FAILED, + "Passkey token exchange failed", + status_code=auth0_status, + ) + ) + + res = client.get("/raise") + + assert res.status_code == 502 + assert res.json() == {"error": "passkey_token_error", "message": "Passkey token exchange failed"} + + def test_passkey_error_without_http_status_maps_to_502(self): + """Test that a failure with no Auth0 response, such as a network error, is a bad gateway.""" + client = _client_raising( + PasskeyError( + PasskeyErrorCode.CHALLENGE_FAILED, + "Passkey login challenge failed", + ConnectionError("connection refused"), + ) + ) + + res = client.get("/raise") + + assert res.status_code == 502 + assert res.json()["error"] == "passkey_challenge_error" From bff1048a75861f1e3a9efb18596d5e2a3459bd97 Mon Sep 17 00:00:00 2001 From: Sourav Basu Date: Mon, 26 Oct 2026 21:28:54 +0530 Subject: [PATCH 18/23] revert: keep non-429 passkey errors at the default 400 Reverts 222c468. Which HTTP status an app returns for an Auth0 outage or a network failure is the server's decision, so the handler only passes Auth0's own 429 through with Retry-After and leaves other PasskeyErrors at the default 400. Co-Authored-By: Claude --- examples/Passkeys.md | 3 +-- src/auth0_fastapi/errors/__init__.py | 17 +++---------- src/auth0_fastapi/test/test_errors.py | 35 +-------------------------- 3 files changed, 6 insertions(+), 49 deletions(-) diff --git a/examples/Passkeys.md b/examples/Passkeys.md index 691620e..e44fc1b 100644 --- a/examples/Passkeys.md +++ b/examples/Passkeys.md @@ -349,8 +349,7 @@ With `register_exception_handlers(app)`, unhandled errors from the passkey metho | Error | HTTP status | |---|---| | `PasskeyError` with `status_code` 429 (rate limited) | 429, with `Retry-After` when Auth0 sent one | -| `PasskeyError` from an Auth0 4xx response (for example `invalid_grant`, `invalid_request`) | 400 | -| `PasskeyError` from an Auth0 5xx response, or with no HTTP response at all (a network failure or an unreadable body) | 502 | +| Any other `PasskeyError` (for example `invalid_grant`, `invalid_request`, `passkey_challenge_error`, `passkey_token_error`) | 400 | | `InvalidArgumentError` | 400 | | `OrganizationTokenValidationError` | 400 | | `MissingRequiredArgumentError` | 422 | diff --git a/src/auth0_fastapi/errors/__init__.py b/src/auth0_fastapi/errors/__init__.py index 9a6e447..002910d 100644 --- a/src/auth0_fastapi/errors/__init__.py +++ b/src/auth0_fastapi/errors/__init__.py @@ -1,5 +1,3 @@ -from typing import Optional - #Imported from auth0-server-python from auth0_server_python.error import ( AccessTokenError, @@ -30,15 +28,6 @@ def __init__(self, message=None): super().__init__(message or "An invalid configuration was provided.") self.name = "ConfigurationError" -def _passkey_error_response(exc: PasskeyError) -> tuple[int, Optional[dict[str, str]]]: - """Return the HTTP status and headers for a PasskeyError.""" - if exc.status_code == 429: - headers = {"Retry-After": str(exc.retry_after)} if exc.retry_after is not None else None - return 429, headers - if exc.status_code is None or exc.status_code >= 500: - return 502, None - return 400, None - def auth0_exception_handler(request: Request, exc: Auth0Error): """ Exception handler for Auth0 SDK errors. @@ -62,8 +51,10 @@ 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): - status_code, headers = _passkey_error_response(exc) + 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, diff --git a/src/auth0_fastapi/test/test_errors.py b/src/auth0_fastapi/test/test_errors.py index 043610f..926578d 100644 --- a/src/auth0_fastapi/test/test_errors.py +++ b/src/auth0_fastapi/test/test_errors.py @@ -1,4 +1,3 @@ -import pytest from fastapi import FastAPI from fastapi.testclient import TestClient @@ -58,7 +57,6 @@ def test_retryable_passkey_error_body_omits_session_details(self): "Invalid or expired session", auth_session="sess_abc123", verification_required=["email"], - status_code=400, ) ) @@ -69,7 +67,7 @@ def test_retryable_passkey_error_body_omits_session_details(self): assert "sess_abc123" not in res.text def test_other_passkey_errors_map_to_400(self): - """Test that passkey failures answered with an Auth0 4xx keep the default 400.""" + """Test that non rate-limit passkey failures keep the default 400.""" client = _client_raising( PasskeyError(PasskeyErrorCode.TOKEN_EXCHANGE_FAILED, "Passkey token exchange failed", status_code=403) ) @@ -78,34 +76,3 @@ def test_other_passkey_errors_map_to_400(self): assert res.status_code == 400 assert res.json()["error"] == "passkey_token_error" - - @pytest.mark.parametrize("auth0_status", [500, 502, 503]) - def test_passkey_error_from_auth0_server_failure_maps_to_502(self, auth0_status): - """Test that an Auth0 5xx reaches the browser as a bad gateway, not as a bad request.""" - client = _client_raising( - PasskeyError( - PasskeyErrorCode.TOKEN_EXCHANGE_FAILED, - "Passkey token exchange failed", - status_code=auth0_status, - ) - ) - - res = client.get("/raise") - - assert res.status_code == 502 - assert res.json() == {"error": "passkey_token_error", "message": "Passkey token exchange failed"} - - def test_passkey_error_without_http_status_maps_to_502(self): - """Test that a failure with no Auth0 response, such as a network error, is a bad gateway.""" - client = _client_raising( - PasskeyError( - PasskeyErrorCode.CHALLENGE_FAILED, - "Passkey login challenge failed", - ConnectionError("connection refused"), - ) - ) - - res = client.get("/raise") - - assert res.status_code == 502 - assert res.json()["error"] == "passkey_challenge_error" From a47001538e6ba2975bd049c51638de32154d903c Mon Sep 17 00:00:00 2001 From: Sourav Basu Date: Mon, 26 Oct 2026 21:34:39 +0530 Subject: [PATCH 19/23] test: exercise the passkey methods through the real ServerClient The AsyncMock tests only prove AuthClient calls a fake with the given keywords, so a renamed ServerClient keyword would pass them. Run the real AuthClient and ServerClient against a mocked Auth0 and check the challenge and token requests, the forwarded-for header, 429 handling, the wrong-code retry, organization claim checks, DPoP, and the stored session. Rename the file from test_passkey_mfa.py because it now covers more than MFA. Co-Authored-By: Claude --- src/auth0_fastapi/test/test_passkey_http.py | 452 ++++++++++++++++++++ src/auth0_fastapi/test/test_passkey_mfa.py | 218 ---------- 2 files changed, 452 insertions(+), 218 deletions(-) create mode 100644 src/auth0_fastapi/test/test_passkey_http.py delete mode 100644 src/auth0_fastapi/test/test_passkey_mfa.py diff --git a/src/auth0_fastapi/test/test_passkey_http.py b/src/auth0_fastapi/test/test_passkey_http.py new file mode 100644 index 0000000..c4c6241 --- /dev/null +++ b/src/auth0_fastapi/test/test_passkey_http.py @@ -0,0 +1,452 @@ +import json +import time +from http.cookies import SimpleCookie +from urllib.parse import parse_qs + +import httpx +import jwt +import pytest +from auth0_server_python.auth_types import PasskeyAuthResponse, PasskeyUserProfile +from auth0_server_python.error import ( + MfaRequiredError, + MfaVerifyError, + OrganizationTokenValidationError, + PasskeyError, +) +from fastapi import Request, Response +from jwcrypto import jwk + +from auth0_fastapi.auth.auth_client import AuthClient +from auth0_fastapi.config import Auth0Config + +DOMAIN = "test.auth0.com" +CLIENT_ID = "test_client_id" +AUDIENCE = "https://api.example.com" +CONNECTION = "Username-Password-Authentication" +RAW_MFA_TOKEN = "raw-mfa-token-issued-by-auth0" +SCOPE = "openid profile email offline_access" +PUBLIC_KEY = {"challenge": "Y2hhbGxlbmdl", "rpId": DOMAIN, "timeout": 60000} + + +class MockAuth0: + """Answers the Auth0 endpoints the passkey challenges, sign-in and MFA verification call.""" + + def __init__(self, signing_key): + self.signing_key = signing_key + self.challenge_responses = [] + self.challenge_requests = [] + self.token_responses = [] + self.token_requests = [] + + def id_token(self, **extra_claims): + now = int(time.time()) + claims = { + "iss": f"https://{DOMAIN}/", + "aud": CLIENT_ID, + "sub": "auth0|abc123", + "iat": now, + "exp": now + 600, + **extra_claims, + } + pem = self.signing_key.export_to_pem(private_key=True, password=None) + return jwt.encode(claims, pem, algorithm="RS256", headers={"kid": self.signing_key.key_id}) + + def handle(self, request): + path = request.url.path + if path == "/.well-known/openid-configuration": + return httpx.Response(200, json={ + "issuer": f"https://{DOMAIN}/", + "token_endpoint": f"https://{DOMAIN}/oauth/token", + "jwks_uri": f"https://{DOMAIN}/.well-known/jwks.json", + }) + if path == "/.well-known/jwks.json": + return httpx.Response(200, json={"keys": [json.loads(self.signing_key.export_public())]}) + if path in ("/passkey/register", "/passkey/challenge"): + self.challenge_requests.append(request) + return self.challenge_responses.pop(0) + if path == "/oauth/token": + self.token_requests.append(request) + return self.token_responses.pop(0) + return httpx.Response(404) + + +def challenge_response(**extra): + return httpx.Response(200, json={ + "auth_session": "sess_abc123", + "authn_params_public_key": PUBLIC_KEY, + **extra, + }) + + +def mfa_required_response(): + return httpx.Response(403, json={ + "error": "mfa_required", + "error_description": "Multifactor authentication required", + "mfa_token": RAW_MFA_TOKEN, + }) + + +def tokens_response(mock, token_type="Bearer", **claims): + return httpx.Response(200, json={ + "access_token": "access-token", + "token_type": token_type, + "expires_in": 3600, + "refresh_token": "refresh-token", + "scope": SCOPE, + "id_token": mock.id_token(**claims), + }) + + +def authn_response(): + return PasskeyAuthResponse( + id="cred_abc123", + rawId="cred_abc123", + type="public-key", + response={"clientDataJSON": "client-data", "authenticatorData": "auth-data", "signature": "sig"}, + ) + + +def request_with_cookies(response=None): + jar = {} + for header in response.headers.getlist("set-cookie") if response is not None else []: + cookie = SimpleCookie(header) + for name, morsel in cookie.items(): + if morsel["max-age"] == "0" or not morsel.value: + jar.pop(name, None) + else: + jar[name] = morsel.value + headers = [(b"cookie", "; ".join(f"{k}={v}" for k, v in jar.items()).encode())] if jar else [] + return Request({"type": "http", "headers": headers}) + + +def store_options(response=None): + return {"request": request_with_cookies(response), "response": Response()} + + +async def sign_in_requiring_mfa(auth_client, mock): + mock.token_responses.append(mfa_required_response()) + response = Response() + with pytest.raises(MfaRequiredError) as exc_info: + await auth_client.signin_with_passkey( + auth_session="sess_abc123", + authn_response=authn_response(), + scope=SCOPE, + audience=AUDIENCE, + store_options={"request": request_with_cookies(), "response": response}, + ) + return exc_info.value, response + + +@pytest.fixture(scope="module") +def signing_key(): + return jwk.JWK.generate(kty="RSA", size=2048, kid="test-key") + + +@pytest.fixture +def auth_client(): + """Fixture providing an AuthClient with an audience configured.""" + return AuthClient(Auth0Config( + domain=DOMAIN, + client_id=CLIENT_ID, + client_secret="test_client_secret", + app_base_url="https://example.com", + secret="test_secret_key_minimum_32_characters", + audience=AUDIENCE, + )) + + +@pytest.fixture +def auth0(auth_client, signing_key, monkeypatch): + """Fixture routing the SDK's HTTP calls to a MockAuth0 once the client is built.""" + mock = MockAuth0(signing_key) + real_client = httpx.AsyncClient + + class MockedAsyncClient(real_client): + def __init__(self, *args, **kwargs): + kwargs["transport"] = httpx.MockTransport(mock.handle) + super().__init__(*args, **kwargs) + + monkeypatch.setattr(httpx, "AsyncClient", MockedAsyncClient) + return mock + + +class TestPasskeyHttp: + """Test the requests the real ServerClient sends to a mocked Auth0 for the passkey methods.""" + + @pytest.mark.asyncio + async def test_passkey_signup_challenge_sends_profile_connection_and_client_ip(self, auth_client, auth0): + """Test that the signup challenge body and forwarded-for header carry every argument.""" + auth0.challenge_responses.append(challenge_response(verification_required=["email", "phone"])) + + result = await auth_client.passkey_signup_challenge( + user_profile=PasskeyUserProfile(email="new.user@example.com", phone_number="+15551234567"), + connection=CONNECTION, + organization="org_abc123", + delivery_method="voice", + client_ip="203.0.113.7", + store_options=store_options(), + ) + + request = auth0.challenge_requests[-1] + sent = json.loads(request.content) + assert result.verification_required == ["email", "phone"] + assert request.headers["auth0-forwarded-for"] == "203.0.113.7" + assert sent["user_profile"] == {"email": "new.user@example.com", "phone_number": "+15551234567"} + assert sent["realm"] == CONNECTION + assert sent["organization"] == "org_abc123" + assert sent["delivery_method"] == "voice" + + @pytest.mark.asyncio + async def test_passkey_login_challenge_sends_connection_and_client_ip_without_username(self, auth_client, auth0): + """Test that the login challenge forwards the IP and connection and never sends a username.""" + auth0.challenge_responses.append(challenge_response()) + + await auth_client.passkey_login_challenge( + connection=CONNECTION, + organization="org_abc123", + client_ip="203.0.113.7", + store_options=store_options(), + ) + + request = auth0.challenge_requests[-1] + sent = json.loads(request.content) + assert request.headers["auth0-forwarded-for"] == "203.0.113.7" + assert sent["realm"] == CONNECTION + assert sent["organization"] == "org_abc123" + assert "username" not in sent + + @pytest.mark.asyncio + async def test_passkey_challenge_without_client_ip_sends_no_forwarded_for_header(self, auth_client, auth0): + """Test that the forwarded-for header is left out when client_ip is not given.""" + auth0.challenge_responses.append(challenge_response()) + + await auth_client.passkey_login_challenge(store_options=store_options()) + + assert "auth0-forwarded-for" not in auth0.challenge_requests[-1].headers + + @pytest.mark.asyncio + async def test_passkey_signup_challenge_rate_limit_raises_with_status_and_retry_after(self, auth_client, auth0): + """Test that a 429 on the challenge surfaces status_code and retry_after on PasskeyError.""" + auth0.challenge_responses.append(httpx.Response(429, text="Too Many Requests", headers={"Retry-After": "7"})) + + with pytest.raises(PasskeyError) as exc_info: + await auth_client.passkey_signup_challenge( + user_profile=PasskeyUserProfile(email="new.user@example.com"), + store_options=store_options(), + ) + + assert exc_info.value.status_code == 429 + assert exc_info.value.retry_after == 7 + + @pytest.mark.asyncio + async def test_signin_with_passkey_sends_codes_scope_audience_and_client_ip(self, auth_client, auth0): + """Test that the token request carries the verification codes, scope, audience and forwarded-for.""" + auth0.token_responses.append(tokens_response(auth0)) + + await auth_client.signin_with_passkey( + auth_session="sess_abc123", + authn_response=authn_response(), + verification={"email": "123456", "phone": "654321"}, + connection=CONNECTION, + scope=SCOPE, + audience=AUDIENCE, + client_ip="203.0.113.7", + store_options=store_options(), + ) + + request = auth0.token_requests[-1] + sent = json.loads(request.content) + assert request.headers["auth0-forwarded-for"] == "203.0.113.7" + assert sent["auth_session"] == "sess_abc123" + assert sent["verification"] == {"email": "123456", "phone": "654321"} + assert sent["realm"] == CONNECTION + assert sent["scope"] == SCOPE + assert sent["audience"] == AUDIENCE + + @pytest.mark.asyncio + async def test_signin_with_passkey_success_stores_the_session(self, auth_client, auth0): + """Test that a successful sign-in stores a session whose user and token can be read back.""" + auth0.token_responses.append(tokens_response(auth0)) + response = Response() + + await auth_client.signin_with_passkey( + auth_session="sess_abc123", + authn_response=authn_response(), + scope=SCOPE, + audience=AUDIENCE, + store_options={"request": request_with_cookies(), "response": response}, + ) + + user = await auth_client.client.get_user(store_options=store_options(response)) + access_token = await auth_client.client.get_access_token(store_options=store_options(response)) + assert user["sub"] == "auth0|abc123" + assert access_token == "access-token" + + @pytest.mark.asyncio + async def test_signin_with_passkey_rate_limit_raises_with_status_and_retry_after(self, auth_client, auth0): + """Test that a 429 on the token request surfaces status_code and retry_after on PasskeyError.""" + auth0.token_responses.append(httpx.Response(429, text="Too Many Requests", headers={"Retry-After": "7"})) + + with pytest.raises(PasskeyError) as exc_info: + await auth_client.signin_with_passkey( + auth_session="sess_abc123", + authn_response=authn_response(), + store_options=store_options(), + ) + + assert exc_info.value.status_code == 429 + assert exc_info.value.retry_after == 7 + + @pytest.mark.asyncio + async def test_signin_with_passkey_wrong_code_is_retryable_with_the_new_session(self, auth_client, auth0): + """Test that a wrong code keeps the session alive and exposes what is still unverified.""" + auth0.token_responses.append(httpx.Response(400, json={ + "error": "invalid_grant", + "error_description": "Wrong code", + "auth_session": "sess_new", + "verification_required": ["email"], + })) + + with pytest.raises(PasskeyError) as exc_info: + await auth_client.signin_with_passkey( + auth_session="sess_abc123", + authn_response=authn_response(), + verification={"email": "000000"}, + store_options=store_options(), + ) + + assert exc_info.value.is_verification_retryable is True + assert exc_info.value.auth_session == "sess_new" + assert exc_info.value.verification_required == ["email"] + + @pytest.mark.asyncio + async def test_signin_with_passkey_expired_session_is_not_retryable(self, auth_client, auth0): + """Test that invalid_grant without an auth_session is terminal.""" + auth0.token_responses.append(httpx.Response(400, json={ + "error": "invalid_grant", + "error_description": "Session expired", + })) + + with pytest.raises(PasskeyError) as exc_info: + await auth_client.signin_with_passkey( + auth_session="sess_abc123", + authn_response=authn_response(), + store_options=store_options(), + ) + + assert exc_info.value.is_verification_retryable is False + + @pytest.mark.asyncio + async def test_signin_with_passkey_organization_claim_mismatch_raises(self, auth_client, auth0): + """Test that a requested organization fails closed when the ID token carries another one.""" + auth0.token_responses.append(tokens_response(auth0, org_id="org_other")) + + with pytest.raises(OrganizationTokenValidationError): + await auth_client.signin_with_passkey( + auth_session="sess_abc123", + authn_response=authn_response(), + organization="org_abc123", + store_options=store_options(), + ) + + @pytest.mark.asyncio + async def test_signin_with_passkey_organization_claim_match_succeeds(self, auth_client, auth0): + """Test that the requested organization is sent and accepted when the ID token matches.""" + auth0.token_responses.append(tokens_response(auth0, org_id="org_abc123")) + + await auth_client.signin_with_passkey( + auth_session="sess_abc123", + authn_response=authn_response(), + organization="org_abc123", + store_options=store_options(), + ) + + assert json.loads(auth0.token_requests[-1].content)["organization"] == "org_abc123" + + @pytest.mark.asyncio + async def test_signin_with_passkey_dpop_key_sends_a_proof_and_rejects_a_bearer_token(self, auth_client, auth0): + """Test that dpop_key adds the DPoP header and a non-DPoP token response is refused.""" + dpop_key = jwk.JWK.generate(kty="EC", crv="P-256") + auth0.token_responses.append(tokens_response(auth0, token_type="Bearer")) + + with pytest.raises(PasskeyError): + await auth_client.signin_with_passkey( + auth_session="sess_abc123", + authn_response=authn_response(), + dpop_key=dpop_key, + store_options=store_options(), + ) + + assert "dpop" in auth0.token_requests[-1].headers + + +class TestPasskeyMfa: + """Test passkey sign-in and MFA completion through the real ServerClient with a mocked Auth0.""" + + @pytest.mark.asyncio + async def test_signin_with_passkey_mfa_required_raises_with_encrypted_token(self, auth_client, auth0): + """Test that an mfa_required response raises MfaRequiredError carrying a token other than the raw one.""" + error, _ = await sign_in_requiring_mfa(auth_client, auth0) + + assert error.mfa_token + assert error.mfa_token != RAW_MFA_TOKEN + + @pytest.mark.asyncio + async def test_signin_with_passkey_mfa_required_creates_no_session(self, auth_client, auth0): + """Test that no user session exists after sign-in stops at the MFA requirement.""" + _, response = await sign_in_requiring_mfa(auth_client, auth0) + + user = await auth_client.client.get_user(store_options=store_options(response)) + + assert user is None + + @pytest.mark.asyncio + async def test_mfa_verify_with_persist_creates_session(self, auth_client, auth0): + """Test that mfa.verify with persist creates the session and get_access_token finds its token.""" + error, response = await sign_in_requiring_mfa(auth_client, auth0) + auth0.token_responses.append(tokens_response(auth0)) + + await auth_client.client.mfa.verify( + {"mfa_token": error.mfa_token, "otp": "123456", "persist": True}, + store_options={"request": request_with_cookies(response), "response": response}, + ) + + user = await auth_client.client.get_user(store_options=store_options(response)) + access_token = await auth_client.client.get_access_token(store_options=store_options(response)) + assert user["sub"] == "auth0|abc123" + assert access_token == "access-token" + + @pytest.mark.asyncio + async def test_mfa_verify_sends_the_raw_token_and_the_code(self, auth_client, auth0): + """Test that verify decrypts the token for Auth0 and sends the one-time password grant.""" + error, response = await sign_in_requiring_mfa(auth_client, auth0) + auth0.token_responses.append(tokens_response(auth0)) + + await auth_client.client.mfa.verify( + {"mfa_token": error.mfa_token, "otp": "123456", "persist": True}, + store_options={"request": request_with_cookies(response), "response": response}, + ) + + sent = parse_qs(auth0.token_requests[-1].content.decode()) + assert sent["grant_type"] == ["http://auth0.com/oauth/grant-type/mfa-otp"] + assert sent["mfa_token"] == [RAW_MFA_TOKEN] + assert sent["otp"] == ["123456"] + + @pytest.mark.asyncio + async def test_mfa_verify_wrong_code_raises_and_creates_no_session(self, auth_client, auth0): + """Test that a rejected code raises MfaVerifyError and leaves no session behind.""" + error, response = await sign_in_requiring_mfa(auth_client, auth0) + auth0.token_responses.append(httpx.Response(403, json={ + "error": "invalid_grant", + "error_description": "Wrong one-time password", + })) + verify_response = Response() + + with pytest.raises(MfaVerifyError): + await auth_client.client.mfa.verify( + {"mfa_token": error.mfa_token, "otp": "000000", "persist": True}, + store_options={"request": request_with_cookies(response), "response": verify_response}, + ) + + user = await auth_client.client.get_user(store_options=store_options(verify_response)) + assert user is None diff --git a/src/auth0_fastapi/test/test_passkey_mfa.py b/src/auth0_fastapi/test/test_passkey_mfa.py deleted file mode 100644 index 660f182..0000000 --- a/src/auth0_fastapi/test/test_passkey_mfa.py +++ /dev/null @@ -1,218 +0,0 @@ -import json -import time -from http.cookies import SimpleCookie -from urllib.parse import parse_qs - -import httpx -import jwt -import pytest -from auth0_server_python.auth_types import PasskeyAuthResponse -from auth0_server_python.error import MfaRequiredError, MfaVerifyError -from fastapi import Request, Response -from jwcrypto import jwk - -from auth0_fastapi.auth.auth_client import AuthClient -from auth0_fastapi.config import Auth0Config - -DOMAIN = "test.auth0.com" -CLIENT_ID = "test_client_id" -AUDIENCE = "https://api.example.com" -RAW_MFA_TOKEN = "raw-mfa-token-issued-by-auth0" -SCOPE = "openid profile email offline_access" - - -class MockAuth0: - """Answers the Auth0 endpoints the passkey sign-in and MFA verification call.""" - - def __init__(self, signing_key): - self.signing_key = signing_key - self.token_responses = [] - self.token_requests = [] - - def id_token(self): - now = int(time.time()) - claims = { - "iss": f"https://{DOMAIN}/", - "aud": CLIENT_ID, - "sub": "auth0|abc123", - "iat": now, - "exp": now + 600, - } - pem = self.signing_key.export_to_pem(private_key=True, password=None) - return jwt.encode(claims, pem, algorithm="RS256", headers={"kid": self.signing_key.key_id}) - - def handle(self, request): - path = request.url.path - if path == "/.well-known/openid-configuration": - return httpx.Response(200, json={ - "issuer": f"https://{DOMAIN}/", - "token_endpoint": f"https://{DOMAIN}/oauth/token", - "jwks_uri": f"https://{DOMAIN}/.well-known/jwks.json", - }) - if path == "/.well-known/jwks.json": - return httpx.Response(200, json={"keys": [json.loads(self.signing_key.export_public())]}) - if path == "/oauth/token": - self.token_requests.append(request) - return self.token_responses.pop(0) - return httpx.Response(404) - - -def mfa_required_response(): - return httpx.Response(403, json={ - "error": "mfa_required", - "error_description": "Multifactor authentication required", - "mfa_token": RAW_MFA_TOKEN, - }) - - -def tokens_response(mock): - return httpx.Response(200, json={ - "access_token": "access-token", - "token_type": "Bearer", - "expires_in": 3600, - "refresh_token": "refresh-token", - "scope": SCOPE, - "id_token": mock.id_token(), - }) - - -def request_with_cookies(response=None): - jar = {} - for header in response.headers.getlist("set-cookie") if response is not None else []: - cookie = SimpleCookie(header) - for name, morsel in cookie.items(): - if morsel["max-age"] == "0" or not morsel.value: - jar.pop(name, None) - else: - jar[name] = morsel.value - headers = [(b"cookie", "; ".join(f"{k}={v}" for k, v in jar.items()).encode())] if jar else [] - return Request({"type": "http", "headers": headers}) - - -async def sign_in_requiring_mfa(auth_client, mock): - mock.token_responses.append(mfa_required_response()) - response = Response() - with pytest.raises(MfaRequiredError) as exc_info: - await auth_client.signin_with_passkey( - auth_session="sess_abc123", - authn_response=PasskeyAuthResponse( - id="cred_abc123", - rawId="cred_abc123", - type="public-key", - response={"clientDataJSON": "client-data", "authenticatorData": "auth-data", "signature": "sig"}, - ), - scope=SCOPE, - audience=AUDIENCE, - store_options={"request": request_with_cookies(), "response": response}, - ) - return exc_info.value, response - - -@pytest.fixture(scope="module") -def signing_key(): - return jwk.JWK.generate(kty="RSA", size=2048, kid="test-key") - - -@pytest.fixture -def auth_client(): - """Fixture providing an AuthClient with an audience configured.""" - return AuthClient(Auth0Config( - domain=DOMAIN, - client_id=CLIENT_ID, - client_secret="test_client_secret", - app_base_url="https://example.com", - secret="test_secret_key_minimum_32_characters", - audience=AUDIENCE, - )) - - -@pytest.fixture -def auth0(auth_client, signing_key, monkeypatch): - """Fixture routing the SDK's HTTP calls to a MockAuth0 once the client is built.""" - mock = MockAuth0(signing_key) - real_client = httpx.AsyncClient - - class MockedAsyncClient(real_client): - def __init__(self, *args, **kwargs): - kwargs["transport"] = httpx.MockTransport(mock.handle) - super().__init__(*args, **kwargs) - - monkeypatch.setattr(httpx, "AsyncClient", MockedAsyncClient) - return mock - - -class TestPasskeyMfa: - """Test passkey sign-in and MFA completion through the real ServerClient with a mocked Auth0.""" - - @pytest.mark.asyncio - async def test_signin_with_passkey_mfa_required_raises_with_encrypted_token(self, auth_client, auth0): - """Test that an mfa_required response raises MfaRequiredError carrying a token other than the raw one.""" - error, _ = await sign_in_requiring_mfa(auth_client, auth0) - - assert error.mfa_token - assert error.mfa_token != RAW_MFA_TOKEN - - @pytest.mark.asyncio - async def test_signin_with_passkey_mfa_required_creates_no_session(self, auth_client, auth0): - """Test that no user session exists after sign-in stops at the MFA requirement.""" - _, response = await sign_in_requiring_mfa(auth_client, auth0) - - user = await auth_client.client.get_user( - store_options={"request": request_with_cookies(response), "response": Response()} - ) - - assert user is None - - @pytest.mark.asyncio - async def test_mfa_verify_with_persist_creates_session(self, auth_client, auth0): - """Test that mfa.verify with persist creates the session and get_access_token finds its token.""" - error, response = await sign_in_requiring_mfa(auth_client, auth0) - auth0.token_responses.append(tokens_response(auth0)) - - await auth_client.client.mfa.verify( - {"mfa_token": error.mfa_token, "otp": "123456", "persist": True}, - store_options={"request": request_with_cookies(response), "response": response}, - ) - - store_options = {"request": request_with_cookies(response), "response": Response()} - user = await auth_client.client.get_user(store_options=store_options) - access_token = await auth_client.client.get_access_token(store_options=store_options) - assert user["sub"] == "auth0|abc123" - assert access_token == "access-token" - - @pytest.mark.asyncio - async def test_mfa_verify_sends_the_raw_token_and_the_code(self, auth_client, auth0): - """Test that verify decrypts the token for Auth0 and sends the one-time password grant.""" - error, response = await sign_in_requiring_mfa(auth_client, auth0) - auth0.token_responses.append(tokens_response(auth0)) - - await auth_client.client.mfa.verify( - {"mfa_token": error.mfa_token, "otp": "123456", "persist": True}, - store_options={"request": request_with_cookies(response), "response": response}, - ) - - sent = parse_qs(auth0.token_requests[-1].content.decode()) - assert sent["grant_type"] == ["http://auth0.com/oauth/grant-type/mfa-otp"] - assert sent["mfa_token"] == [RAW_MFA_TOKEN] - assert sent["otp"] == ["123456"] - - @pytest.mark.asyncio - async def test_mfa_verify_wrong_code_raises_and_creates_no_session(self, auth_client, auth0): - """Test that a rejected code raises MfaVerifyError and leaves no session behind.""" - error, response = await sign_in_requiring_mfa(auth_client, auth0) - auth0.token_responses.append(httpx.Response(403, json={ - "error": "invalid_grant", - "error_description": "Wrong one-time password", - })) - verify_response = Response() - - with pytest.raises(MfaVerifyError): - await auth_client.client.mfa.verify( - {"mfa_token": error.mfa_token, "otp": "000000", "persist": True}, - store_options={"request": request_with_cookies(response), "response": verify_response}, - ) - - user = await auth_client.client.get_user( - store_options={"request": request_with_cookies(verify_response), "response": Response()} - ) - assert user is None From d873857b4482965abf74f7fb32080dc82dbe14d1 Mon Sep 17 00:00:00 2001 From: Sourav Basu Date: Mon, 26 Oct 2026 21:35:19 +0530 Subject: [PATCH 20/23] test: rename a helper parameter that ruff reads as a password ruff S107 flags a string default on a parameter called token_type. Call it scheme, which is what the value is. Co-Authored-By: Claude --- src/auth0_fastapi/test/test_passkey_http.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/auth0_fastapi/test/test_passkey_http.py b/src/auth0_fastapi/test/test_passkey_http.py index c4c6241..f3aeeb9 100644 --- a/src/auth0_fastapi/test/test_passkey_http.py +++ b/src/auth0_fastapi/test/test_passkey_http.py @@ -86,10 +86,10 @@ def mfa_required_response(): }) -def tokens_response(mock, token_type="Bearer", **claims): +def tokens_response(mock, scheme="Bearer", **claims): return httpx.Response(200, json={ "access_token": "access-token", - "token_type": token_type, + "token_type": scheme, "expires_in": 3600, "refresh_token": "refresh-token", "scope": SCOPE, @@ -367,7 +367,7 @@ async def test_signin_with_passkey_organization_claim_match_succeeds(self, auth_ async def test_signin_with_passkey_dpop_key_sends_a_proof_and_rejects_a_bearer_token(self, auth_client, auth0): """Test that dpop_key adds the DPoP header and a non-DPoP token response is refused.""" dpop_key = jwk.JWK.generate(kty="EC", crv="P-256") - auth0.token_responses.append(tokens_response(auth0, token_type="Bearer")) + auth0.token_responses.append(tokens_response(auth0, scheme="Bearer")) with pytest.raises(PasskeyError): await auth_client.signin_with_passkey( From 989382d66188420655e198cd0c461f27eb3c4201 Mon Sep 17 00:00:00 2001 From: Sourav Basu Date: Mon, 26 Oct 2026 21:46:36 +0530 Subject: [PATCH 21/23] docs: say that refreshing DPoP-bound tokens needs a proof The guide only told readers to test the refresh path. Auth0's DPoP documentation says a refresh request for DPoP-bound tokens requires a proof made with the same key, and get_access_token takes no dpop_key, so it sends none. Say so, link the Auth0 section, and tell readers not to rely on get_access_token to refresh such a session. 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 e44fc1b..4ef5638 100644 --- a/examples/Passkeys.md +++ b/examples/Passkeys.md @@ -423,7 +423,7 @@ Add `dpop_key=dpop_key` to the sign-in call in section 1. Then keep these points - Generate one key per user session and keep it on the server, next to the session, in a store you control. Do not send it to the browser or put it in a cookie. - The private key is a secret, so never log it. The [auth0-server-python passkey guide](https://github.com/auth0/auth0-server-python/blob/main/examples/Passkeys.md#3-dpop-bound-passkey-tokens-optional) covers key storage. - If the tenant also requires MFA, pass the same key to `mfa.verify()` through its `dpop_key` argument. -- `get_access_token()` takes no `dpop_key`, so it sends no DPoP proof when it refreshes a token. Test the refresh path with DPoP-bound tokens before you rely on it. +- Auth0 requires a DPoP proof made with the same key on a refresh request for DPoP-bound tokens ([Handle token refresh with DPoP](https://auth0.com/docs/secure/sender-constraining/demonstrating-proof-of-possession-dpop#step-6-handle-token-refresh-with-dpop)). `get_access_token()` takes no `dpop_key`, so it sends no proof. Do not rely on it to refresh a session whose tokens are DPoP-bound. - The SDK raises a `PasskeyError` when the token type does not match whether you passed a key, so a token is never stored without the key it needs. ## Additional Resources From 9848ae8bd087042066c99e0d1feb6d2c9eb0a8a9 Mon Sep 17 00:00:00 2001 From: Sourav Basu Date: Mon, 26 Oct 2026 22:03:57 +0530 Subject: [PATCH 22/23] docs: state only the verified DPoP facts in the passkey guide The refresh bullet said Auth0 requires a DPoP proof on refresh, which does not hold for a confidential client such as this SDK. Keep what is verified, that get_access_token takes no dpop_key and sends no proof, and the instruction not to rely on it for a DPoP-bound session. Scope the PasskeyError check to signin_with_passkey, and note that mfa.verify only raises when a key is passed and the token comes back unbound. Co-Authored-By: Claude --- examples/Passkeys.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/examples/Passkeys.md b/examples/Passkeys.md index 4ef5638..a6d8d73 100644 --- a/examples/Passkeys.md +++ b/examples/Passkeys.md @@ -422,9 +422,9 @@ Add `dpop_key=dpop_key` to the sign-in call in section 1. Then keep these points - Generate one key per user session and keep it on the server, next to the session, in a store you control. Do not send it to the browser or put it in a cookie. - The private key is a secret, so never log it. The [auth0-server-python passkey guide](https://github.com/auth0/auth0-server-python/blob/main/examples/Passkeys.md#3-dpop-bound-passkey-tokens-optional) covers key storage. -- If the tenant also requires MFA, pass the same key to `mfa.verify()` through its `dpop_key` argument. -- Auth0 requires a DPoP proof made with the same key on a refresh request for DPoP-bound tokens ([Handle token refresh with DPoP](https://auth0.com/docs/secure/sender-constraining/demonstrating-proof-of-possession-dpop#step-6-handle-token-refresh-with-dpop)). `get_access_token()` takes no `dpop_key`, so it sends no proof. Do not rely on it to refresh a session whose tokens are DPoP-bound. -- The SDK raises a `PasskeyError` when the token type does not match whether you passed a key, so a token is never stored without the key it needs. +- If the tenant also requires MFA, pass the same key to `mfa.verify()` through its `dpop_key` argument. It raises only when you pass a key and the token comes back without a DPoP binding, so it does not notice a missing key. +- `get_access_token()` takes no `dpop_key`, so it sends no DPoP proof when it refreshes a token. Do not rely on it to refresh a session whose tokens are DPoP-bound. +- `signin_with_passkey()` raises a `PasskeyError` when the token type does not match whether you passed a key. ## Additional Resources From eebdf7895276cdc50a093846872311ea9f4ad59c Mon Sep 17 00:00:00 2001 From: Sourav Basu Date: Mon, 26 Oct 2026 22:14:14 +0530 Subject: [PATCH 23/23] fix: relay the status Auth0 returned for passkey errors A PasskeyError from an Auth0 5xx reached the browser as 400, so an outage looked like a bad request. Return the status Auth0 sent for any PasskeyError other than 429, which still adds Retry-After. Relay only 400 to 599, and keep 400 when there is no Auth0 response, such as a network failure. The status is Auth0's own, so no status is invented. Update the guide's error table and add tests for the relayed statuses. Co-Authored-By: Claude --- examples/Passkeys.md | 5 ++- src/auth0_fastapi/errors/__init__.py | 17 +++++++--- src/auth0_fastapi/test/test_errors.py | 45 ++++++++++++++++++++++++--- 3 files changed, 58 insertions(+), 9 deletions(-) diff --git a/examples/Passkeys.md b/examples/Passkeys.md index a6d8d73..94f4e31 100644 --- a/examples/Passkeys.md +++ b/examples/Passkeys.md @@ -349,7 +349,8 @@ With `register_exception_handlers(app)`, unhandled errors from the passkey metho | Error | HTTP status | |---|---| | `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 | +| Any other `PasskeyError` that carries an Auth0 `status_code` | The status Auth0 returned, for example 400, 401, 403 or 503 | +| `PasskeyError` with no Auth0 HTTP status (for example a network failure) | 400 | | `InvalidArgumentError` | 400 | | `OrganizationTokenValidationError` | 400 | | `MissingRequiredArgumentError` | 422 | @@ -357,6 +358,8 @@ With `register_exception_handlers(app)`, unhandled errors from the passkey metho | `IssuerValidationError` | 401 | | `ApiError` (ID token validation) | 502 | +The `PasskeyError` status is Auth0's own, so a `401` or `403` from Auth0 (for example when it rejects the client) reaches the browser as that status. If a proxy in front of your app replaces 5xx responses with its own page, handle a non-JSON error body in the browser. + ### Completing MFA When the tenant requires MFA after the passkey sign-in, `signin_with_passkey()` raises `MfaRequiredError` before any session exists. The sign-in route in section 1 catches it and returns the `mfa_token` with a 401, so the browser has something to continue with. Without that branch the exception handler returns a 401 with only `error` and `message`. diff --git a/src/auth0_fastapi/errors/__init__.py b/src/auth0_fastapi/errors/__init__.py index 002910d..73686d8 100644 --- a/src/auth0_fastapi/errors/__init__.py +++ b/src/auth0_fastapi/errors/__init__.py @@ -1,3 +1,5 @@ +from typing import Optional + #Imported from auth0-server-python from auth0_server_python.error import ( AccessTokenError, @@ -28,6 +30,15 @@ def __init__(self, message=None): super().__init__(message or "An invalid configuration was provided.") self.name = "ConfigurationError" +def _passkey_error_response(exc: PasskeyError) -> tuple[int, Optional[dict[str, str]]]: + """Return the HTTP status and headers for a PasskeyError, relaying the status Auth0 returned.""" + if exc.status_code == 429: + headers = {"Retry-After": str(exc.retry_after)} if exc.retry_after is not None else None + return 429, headers + if exc.status_code is not None and 400 <= exc.status_code <= 599: + return exc.status_code, None + return 400, None + def auth0_exception_handler(request: Request, exc: Auth0Error): """ Exception handler for Auth0 SDK errors. @@ -51,10 +62,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.status_code == 429: - status_code = 429 - if exc.retry_after is not None: - headers = {"Retry-After": str(exc.retry_after)} + elif isinstance(exc, PasskeyError): + status_code, headers = _passkey_error_response(exc) return JSONResponse( status_code=status_code, diff --git a/src/auth0_fastapi/test/test_errors.py b/src/auth0_fastapi/test/test_errors.py index 926578d..c806507 100644 --- a/src/auth0_fastapi/test/test_errors.py +++ b/src/auth0_fastapi/test/test_errors.py @@ -1,3 +1,4 @@ +import pytest from fastapi import FastAPI from fastapi.testclient import TestClient @@ -66,13 +67,49 @@ def test_retryable_passkey_error_body_omits_session_details(self): 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.""" + @pytest.mark.parametrize("auth0_status", [400, 401, 403, 404, 500, 502, 503]) + def test_passkey_error_relays_the_status_auth0_returned(self, auth0_status): + """Test that a non rate-limit PasskeyError keeps the status Auth0 returned.""" client = _client_raising( - PasskeyError(PasskeyErrorCode.TOKEN_EXCHANGE_FAILED, "Passkey token exchange failed", status_code=403) + PasskeyError( + PasskeyErrorCode.TOKEN_EXCHANGE_FAILED, + "Passkey token exchange failed", + status_code=auth0_status, + ) + ) + + res = client.get("/raise") + + assert res.status_code == auth0_status + assert res.json() == {"error": "passkey_token_error", "message": "Passkey token exchange failed"} + assert "Retry-After" not in res.headers + + def test_passkey_error_without_http_status_maps_to_400(self): + """Test that a PasskeyError with no Auth0 response keeps the default 400.""" + client = _client_raising( + PasskeyError( + PasskeyErrorCode.CHALLENGE_FAILED, + "Passkey login challenge failed", + ConnectionError("connection refused"), + ) + ) + + res = client.get("/raise") + + assert res.status_code == 400 + assert res.json()["error"] == "passkey_challenge_error" + + @pytest.mark.parametrize("auth0_status", [200, 302, 600]) + def test_passkey_error_with_a_non_error_status_maps_to_400(self, auth0_status): + """Test that only 400 to 599 is relayed, so a stray 2xx or 3xx cannot reach the browser.""" + client = _client_raising( + PasskeyError( + PasskeyErrorCode.TOKEN_EXCHANGE_FAILED, + "Passkey token exchange failed", + status_code=auth0_status, + ) ) res = client.get("/raise") assert res.status_code == 400 - assert res.json()["error"] == "passkey_token_error"