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..9c58da6 --- /dev/null +++ b/examples/Passkeys.md @@ -0,0 +1,304 @@ +# 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, 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 + +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 + + +def client_ip(request: Request) -> Optional[str]: + return request.client.host if request.client else 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, + client_ip=client_ip(request), + 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, + client_ip=client_ip(request), + 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, + client_ip=client_ip(request), + 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 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()`, 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 +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. +- **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 + +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`. +- **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. + +## 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 `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 | +| `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/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 diff --git a/src/auth0_fastapi/auth/auth_client.py b/src/auth0_fastapi/auth/auth_client.py index d98a118..a81229a 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,137 @@ 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, + client_ip: Optional[str] = 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). + 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. + + Returns: + The PasskeySignupChallengeResponse (auth_session, authn_params_public_key, + verification_required). + + Raises: + PasskeyError: If the challenge request fails or is rate limited + (status_code 429). + InvalidArgumentError: If delivery_method is not "text" or "voice". + """ + return await self.client.passkey_signup_challenge( + user_profile=user_profile, + connection=connection, + organization=organization, + user_metadata=user_metadata, + delivery_method=delivery_method, + client_ip=client_ip, + store_options=store_options, + ) + + async def passkey_login_challenge( + self, + connection: Optional[str] = None, + organization: Optional[str] = None, + client_ip: 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). + 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. + + Returns: + The PasskeyLoginChallengeResponse (auth_session, authn_params_public_key). + + Raises: + PasskeyError: If the challenge request fails or is rate limited + (status_code 429). + """ + return await self.client.passkey_login_challenge( + connection=connection, + organization=organization, + client_ip=client_ip, + store_options=store_options, + ) + + async def signin_with_passkey( + self, + auth_session: str, + authn_response: PasskeyAuthResponse, + 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: + """ + 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). + 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. + + Returns: + The PasskeyLoginResult containing the session state (including the + resulting user). + + Raises: + PasskeyError: If the token exchange fails or is rate limited + (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 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, + authn_response=authn_response, + verification=verification, + connection=connection, + organization=organization, + 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 4a8babc..002910d 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 @@ -33,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 @@ -48,9 +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.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 4d620d7..d92bca3 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,33 @@ 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"}, + ) + + +@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.""" @@ -991,3 +1030,268 @@ 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 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( + 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", + client_ip="203.0.113.7", + 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", + client_ip="203.0.113.7", + 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, + 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 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 + + 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 + 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, organization and client_ip 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", + client_ip="203.0.113.7", + store_options=store_options, + ) + + assert result is mock_result + 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 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 + + 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.status_code == 429 + + @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", + client_ip="203.0.113.7", + 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, + client_ip="203.0.113.7", + 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, + client_ip=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..926578d --- /dev/null +++ b/src/auth0_fastapi/test/test_errors.py @@ -0,0 +1,78 @@ +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 a PasskeyError with status_code 429 becomes HTTP 429 with Retry-After.""" + client = _client_raising( + 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 "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.""" + 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", status_code=403) + ) + + res = client.get("/raise") + + assert res.status_code == 400 + assert res.json()["error"] == "passkey_token_error"