diff --git a/README.md b/README.md index bce7aec..ce3bfc0 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 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()`. + +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..94f4e31 --- /dev/null +++ b/examples/Passkeys.md @@ -0,0 +1,436 @@ +# 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 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). + +`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) +- [6. Organizations and DPoP (optional)](#6-organizations-and-dpop-optional) + +## 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 +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 + +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 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: AuthnResponse + verification: Optional[VerificationCodes] = None + + +def client_ip(request: Request) -> Optional[str]: + 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 + +```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. 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 + +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=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}, + ) + 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, + } + 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} +``` + +`/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). 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 + +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, 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) + +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 +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; + + 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. 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. + +## 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` 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 | +| `MfaRequiredError`, when the route does not catch it | 401 | +| `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`. + +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. + +## 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. 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 + +- [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..12c822f 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,135 @@ 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". + 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. + """ + 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..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, @@ -11,6 +13,8 @@ IssuerValidationError, MissingRequiredArgumentError, MissingTransactionError, + PasskeyError, + PasskeyErrorCode, ) from fastapi import Request from fastapi.responses import JSONResponse @@ -26,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. @@ -33,6 +46,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 +62,12 @@ 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) 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..c806507 --- /dev/null +++ b/src/auth0_fastapi/test/test_errors.py @@ -0,0 +1,115 @@ +import pytest +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 + + @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=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 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..f3aeeb9 --- /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, scheme="Bearer", **claims): + return httpx.Response(200, json={ + "access_token": "access-token", + "token_type": scheme, + "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, scheme="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