diff --git a/README.md b/README.md index fd6d496f..5c6a44d3 100644 --- a/README.md +++ b/README.md @@ -246,7 +246,7 @@ For more details and examples, see [examples/RetrievingData.md](examples/Retriev ### 7. Passkey Authentication -Sign users up or in with [WebAuthn](https://www.w3.org/TR/webauthn-2/) passkeys (Touch ID, Face ID, Windows Hello, or a security key) instead of a password, via [Auth0 passkeys](https://auth0.com/docs/authenticate/database-connections/passkeys). The ceremony is two steps — request a challenge, sign it in the browser, then complete sign-in — and establishes a server-side session like every other login path. For the signup and login flows, organizations, step-up MFA, and error handling, see [examples/Passkeys.md](examples/Passkeys.md). +Sign users up or in with [WebAuthn](https://www.w3.org/TR/webauthn-2/) passkeys (Touch ID, Face ID, Windows Hello, or a security key) instead of a password, via [Auth0 passkeys](https://auth0.com/docs/authenticate/database-connections/passkeys). The ceremony is two steps - request a challenge, sign it in the browser, then complete sign-in - and establishes a server-side session like every other login path. For the signup and login flows, organizations, step-up MFA, [verifying an email or phone at signup](examples/Passkeys.md#4-identifier-verification-optional), and error handling, see [examples/Passkeys.md](examples/Passkeys.md). ### 8. My Account API — Authentication Methods diff --git a/examples/Passkeys.md b/examples/Passkeys.md index 95baa5c1..79256f85 100644 --- a/examples/Passkeys.md +++ b/examples/Passkeys.md @@ -1,6 +1,6 @@ # Passkey Authentication -Passkeys let users sign up and log in with [WebAuthn](https://www.w3.org/TR/webauthn-2/) credentials (Touch ID, Face ID, Windows Hello, or a hardware security key) instead of a password. This guide covers the **primary authentication** flow on `ServerClient` — signing a user up or in with a passkey and establishing a server-side session. +Passkeys let users sign up and log in with [WebAuthn](https://www.w3.org/TR/webauthn-2/) credentials (Touch ID, Face ID, Windows Hello, or a hardware security key) instead of a password. This guide covers the **primary authentication** flow on `ServerClient` - signing a user up or in with a passkey and establishing a server-side session. > [!NOTE] > Passkeys require a [Custom Domain](https://auth0.com/docs/customize/custom-domains) (WebAuthn binds the credential to the relying-party domain) and the native passkey feature enabled on your tenant. See the [Auth0 passkey documentation](https://auth0.com/docs/authenticate/database-connections/passkeys). @@ -16,15 +16,16 @@ Passkeys let users sign up and log in with [WebAuthn](https://www.w3.org/TR/weba - [2. Passkey Login](#2-passkey-login) - [3. DPoP-bound passkey tokens (optional)](#3-dpop-bound-passkey-tokens-optional) - [Completing MFA on a passkey login (and where the session comes from)](#completing-mfa-on-a-passkey-login-and-where-the-session-comes-from) +- [4. Identifier verification (optional)](#4-identifier-verification-optional) - [Error Handling](#error-handling) ## How the flow works A passkey ceremony is always **two steps**, because the WebAuthn signature happens in the browser between them: -1. **Challenge** — the SDK asks Auth0 for a challenge (`passkey_signup_challenge` / `passkey_login_challenge`). Auth0 returns an `auth_session` and the WebAuthn options (`authn_params_public_key`). -2. **Browser** — your front end passes those options to `navigator.credentials.create()` (signup) or `navigator.credentials.get()` (login). The authenticator produces a signed credential. -3. **Verify / sign-in** — the SDK exchanges the signed credential for tokens (`signin_with_passkey`) and **creates a server-side session**, exactly like every other login path. +1. **Challenge** - the SDK asks Auth0 for a challenge (`passkey_signup_challenge` / `passkey_login_challenge`). Auth0 returns an `auth_session` and the WebAuthn options (`authn_params_public_key`). +2. **Browser** - your front end passes those options to `navigator.credentials.create()` (signup) or `navigator.credentials.get()` (login). The authenticator produces a signed credential. +3. **Verify / sign-in** - the SDK exchanges the signed credential for tokens (`signin_with_passkey`) and **creates a server-side session**, exactly like every other login path. ## Prerequisites @@ -58,7 +59,7 @@ The **Passkey** grant (`urn:okta:params:oauth:grant-type:webauthn`) must be enab ## 1. Passkey Signup -### Step 1 — Request a signup challenge +### Step 1 - Request a signup challenge ```python from auth0_server_python.auth_types import PasskeyUserProfile @@ -81,16 +82,18 @@ challenge = await server_client.passkey_signup_challenge( # Hand these to the browser: # challenge.auth_session -> opaque session credential (Tier 1, do not log) # challenge.authn_params_public_key -> pass to navigator.credentials.create() +# challenge.verification_required -> codes to collect first, when set (see section 4) +# auth_session does not appear in print(challenge). To inspect it while debugging, read challenge.auth_session. ``` > [!TIP] -> `PasskeyUserProfile` allows extra fields — any additional profile attribute your tenant accepts (for example `given_name`, `family_name`, `picture`) passes through without an SDK change. Pass tenant-specific custom data via the separate `user_metadata` argument. +> `PasskeyUserProfile` allows extra fields - any additional profile attribute your tenant accepts (for example `given_name`, `family_name`, `picture`) passes through without an SDK change. Pass tenant-specific custom data via the separate `user_metadata` argument. -### Step 2 — Create the credential in the browser +### Step 2 - Create the credential in the browser Pass `authn_params_public_key` to `navigator.credentials.create()`. The resulting credential serializes to the shape the SDK expects in step 3 (`id`, `rawId`, `type`, and a `response` object with `clientDataJSON` + `attestationObject`). -### Step 3 — Verify and establish the session +### Step 3 - Verify and establish the session ```python from auth0_server_python.auth_types import PasskeyAuthResponse @@ -110,25 +113,27 @@ result = await server_client.signin_with_passkey( ) user = result.state_data["user"] -print(f"Signed up and logged in: {user['sub']}") ``` -`signin_with_passkey` returns a `PasskeyLoginResult` whose `state_data` holds the user claims and token sets — the same shape as `complete_interactive_login` and `login_with_custom_token_exchange`. The session is persisted to your configured state store. +`signin_with_passkey` returns a `PasskeyLoginResult` whose `state_data` holds the user claims and token sets - the same shape as `complete_interactive_login` and `login_with_custom_token_exchange`. The session is persisted to your configured state store. + +> [!NOTE] +> If your connection requires users to verify their email or phone at signup, `passkey_signup_challenge` also accepts a `delivery_method` and `signin_with_passkey` accepts a `verification` argument. Both are optional and covered in [section 4](#4-identifier-verification-optional). ## 2. Passkey Login Identical shape, different endpoints. The browser uses `navigator.credentials.get()` and the user picks a passkey from the prompt. That credential identifies the user, so the login challenge takes no username. ```python -# Step 1 — login challenge +# Step 1 - login challenge challenge = await server_client.passkey_login_challenge( connection="Username-Password-Authentication", # optional store_options={"request": request, "response": response}, ) -# Step 2 — browser: navigator.credentials.get(challenge.authn_params_public_key) +# Step 2 - browser: navigator.credentials.get(challenge.authn_params_public_key) -# Step 3 — sign in. The login credential's response carries +# Step 3 - sign in. The login credential's response carries # clientDataJSON + authenticatorData + signature + userHandle. result = await server_client.signin_with_passkey( auth_session=challenge.auth_session, @@ -148,7 +153,7 @@ result = await server_client.signin_with_passkey( ``` > [!NOTE] -> The SDK is transparent to the signup-vs-login difference in the credential `response` — both flow through the same `PasskeyAuthResponse.response` dict. Send exactly the keys the browser produced. +> The SDK is transparent to the signup-vs-login difference in the credential `response` - both flow through the same `PasskeyAuthResponse.response` dict. Send exactly the keys the browser produced. ## 3. DPoP-bound passkey tokens (optional) @@ -157,7 +162,7 @@ Pass an optional `dpop_key` to bind the issued tokens to a key your server holds ```python from jwcrypto import jwk -dpop_key = jwk.JWK.generate(kty="EC", crv="P-256") # you generate and keep this key (Tier 0) +dpop_key = jwk.JWK.generate(kty="EC", crv="P-256") # you generate and keep this key result = await server_client.signin_with_passkey( auth_session=challenge.auth_session, @@ -167,17 +172,17 @@ result = await server_client.signin_with_passkey( ) ``` -When `dpop_key` is supplied, the SDK attaches a token-endpoint proof so Auth0 issues a DPoP-bound token, transparently handles the server-nonce challenge, and **rejects a Bearer downgrade** — if the server returns an unbound token, `signin_with_passkey` raises `PasskeyError` rather than silently accepting a token bound to a key it never used. +When `dpop_key` is supplied, the SDK attaches a token-endpoint proof so Auth0 issues a DPoP-bound token, transparently handles the server-nonce challenge, and **rejects a Bearer downgrade** - if the server returns an unbound token, `signin_with_passkey` raises `PasskeyError` rather than silently accepting a token bound to a key it never used. > [!TIP] -> Reuse the **same** `dpop_key` for any subsequent My Account API calls made with the resulting token — the token is bound to that one key. See [MyAccountAuthenticationMethods.md → DPoP](MyAccountAuthenticationMethods.md#dpop). +> Reuse the **same** `dpop_key` for any subsequent My Account API calls made with the resulting token - the token is bound to that one key. See [MyAccountAuthenticationMethods.md → DPoP](MyAccountAuthenticationMethods.md#dpop). > [!WARNING] -> The `dpop_key` private key is a **Tier 0 secret**. Keep it in your secret store (KMS/HSM), never log it (`repr()` is redacted, but `key.export_private()` is not), use **one key per user/session** (never share across principals), and use **EC P-256 only** — any other key type fails closed with a `ValueError` before any network call. +> The `dpop_key` private key is a **secret**. Keep it in your secret store (KMS/HSM), never log it (`repr()` is redacted, but `key.export_private()` is not), use **one key per user/session** (never share across principals), and use **EC P-256 only** - any other key type fails closed with a `ValueError` before any network call. ### Completing MFA on a passkey login (and where the session comes from) -When a passkey login needs a second factor, `signin_with_passkey` raises `MfaRequiredError` **before** it creates a session. You finish the login by challenging and verifying through `client.mfa`, then **store the returned tokens yourself** — on this path the SDK does not persist the session for you (`persist` defaults to `False`, and there is no existing session to update yet): +When a passkey login needs a second factor, `signin_with_passkey` raises `MfaRequiredError` **before** it creates a session. You finish the login by challenging and verifying through `client.mfa`, then **store the returned tokens yourself** - on this path the SDK does not persist the session for you (`persist` defaults to `False`, and there is no existing session to update yet): ```python from auth0_server_python.error import MfaRequiredError @@ -186,7 +191,7 @@ try: result = await server_client.signin_with_passkey( auth_session=auth_session, authn_response=authn_response, - dpop_key=dpop_key, # optional; omit for Bearer tokens + dpop_key=dpop_key, # optional, omit for Bearer tokens store_options={"request": request, "response": response}, ) # No MFA needed: signin_with_passkey already persisted the session for you. @@ -208,7 +213,7 @@ except MfaRequiredError as e: store_options={"request": request, "response": response}, ) - # 3. Persist the tokens into YOUR session yourself — this is the step the + # 3. Persist the tokens into YOUR session yourself - this is the step the # SDK skips on the MFA path because no session existed at login time. save_session_for_user( access_token=verify_response.access_token, @@ -218,16 +223,111 @@ except MfaRequiredError as e: ``` > [!IMPORTANT] -> Re-supply the **same** `dpop_key` to `verify`. Omitting it when the login was DPoP-bound would downgrade the result to a Bearer token; `verify` **rejects** that mismatch with `MfaVerifyError` rather than silently dropping the sender constraint. DPoP is preserved end to end — `persist=False` affects only *who writes the session*, never the token binding. +> Re-supply the **same** `dpop_key` to `verify`. Omitting it when the login was DPoP-bound would downgrade the result to a Bearer token; `verify` **rejects** that mismatch with `MfaVerifyError` rather than silently dropping the sender constraint. DPoP is preserved end to end - `persist=False` affects only *who writes the session*, never the token binding. > [!NOTE] -> Do **not** pass `persist=True` on this path. It updates an *existing* session, and a passkey-first login has none yet, so it raises `MfaVerifyError("No existing session found…")` — discarding the tokens `verify` just obtained. Use `persist=False` and store the returned tokens as shown above. (This is pre-existing MFA-client behavior, unrelated to passkeys.) +> Do **not** pass `persist=True` on this path. It updates an *existing* session, and a passkey-first login has none yet, so it raises `MfaVerifyError("No existing session found…")` - discarding the tokens `verify` just obtained. Use `persist=False` and store the returned tokens as shown above. + +## 4. Identifier verification (optional) + +When a connection requires users to verify their email or phone number at signup, the signup challenge lists the identifiers that need a code, Auth0 sends each code, and you pass the codes to `signin_with_passkey`. Connections without verification follow the flow in section 1. + +### 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. +- Upgrade your app to an SDK version whose `signin_with_passkey` accepts `verification` before you turn verification on. Older versions can't send the codes, so every signup on the connection fails. + +### The flow + +1. Request the challenge. If `verification_required` is set, collect a code for each identifier it lists. +2. Collect the codes before creating the credential, so a user who never receives a code doesn't end up with a passkey for an account that was never created. This order is a UX requirement, and the SDK can't enforce it. Your front end must not call `navigator.credentials.create()` until every required code is collected. Auth0 checks the codes only in `signin_with_passkey`, after the credential exists, so a wrong code still leaves a passkey on the device with no account behind it. The retry reuses that credential. +3. Create the credential in the browser, then call `signin_with_passkey` with the codes in `verification`. + +```python +challenge = await server_client.passkey_signup_challenge( + user_profile=PasskeyUserProfile(email="new.user@example.com", phone_number="+15551234567"), + delivery_method="text", # optional, "text" or "voice" for the phone code + store_options={"request": request, "response": response}, +) + +# For example ["email", "phone"], or None when nothing needs verifying +required = challenge.verification_required + +result = await server_client.signin_with_passkey( + auth_session=challenge.auth_session, + authn_response=authn_response, + verification={"email": email_code, "phone": phone_code}, + store_options={"request": request, "response": response}, +) +``` + +`verification` must be a dict whose keys are `email` or `phone`, as listed in `verification_required`, and whose values are strings. Anything else raises `InvalidArgumentError` before any request is sent. Blank codes are ignored. Omit `verification` when `verification_required` is `None`. + +`delivery_method` applies only to the phone code. If only email needs a code, the value has no effect. Any value other than `"text"` or `"voice"` raises `InvalidArgumentError` before the request is sent. + +### Resending codes + +If the user does not receive a code, call `passkey_signup_challenge` again before the passkey is created. Each call sends new codes for every identifier and returns a new `auth_session` and new `authn_params_public_key`, so use the values from the latest response. The previous session stays valid until it expires. + +### Handling a wrong code + +After a wrong code the session stays valid. `signin_with_passkey` raises a `PasskeyError` with `is_verification_retryable` set to `True`, and its `verification_required` lists the identifiers still unverified. + +Each attempt is one HTTP request. On a wrong code, the handler returns `verification_required` and the `auth_session` from the error. The browser asks for those codes only and posts again with the same credential. Cap the attempts in your app, because Auth0 also limits them. + +```python +from fastapi.responses import JSONResponse + +from auth0_server_python.error import PasskeyError + +async def passkey_signin(request, response, body): + try: + await server_client.signin_with_passkey( + auth_session=body["auth_session"], + authn_response=body["authn_response"], + verification=body.get("verification"), + store_options={"request": request, "response": response}, + ) + except PasskeyError as e: + if not e.is_verification_retryable: + raise + # Send the unverified identifiers back so the page asks only for those codes. + return JSONResponse( + {"verification_required": e.verification_required, "auth_session": e.auth_session}, + status_code=400, + ) + # The result holds the tokens, so keep it on the server. + return {"signed_in": True} +``` + +Send one `signin_with_passkey` request at a time per `auth_session`. Parallel requests can use up the allowed attempts, or reach Auth0 after the session has already been used. + +`is_verification_retryable` is `False` in these cases. + +- Too many wrong codes, an expired code, or an expired session. Start again from the signup challenge. +- A missing code raises `invalid_request` and the session stays valid. Resubmit with the same `auth_session` and every required code. +- A server error (`server_error`) may or may not have ended the session. Retry with the same `auth_session`, `authn_response` and codes. If the call fails with `invalid_grant` and no `auth_session`, start again from the signup challenge. +- A rate limit (`status_code` is 429). Auth0 rejects the request before it uses the attempt, so wait and resend the same request with the same `auth_session`, `authn_response` and codes. When `retry_after` is set, wait that many seconds. Check `status_code`, not `code`, because the code depends on which limit was hit. +- Any other failure is raised with the `error` code Auth0 returned, or `passkey_token_error` when the response has no JSON error body. + +If the connection drops after Auth0 accepts the final code, or a Post Login Action denies the login, the account may already exist, because Auth0 creates the user before Actions run. A new signup with the same email then fails with `invalid_request`. Show a generic message that offers sign in, without confirming whether the account exists. `invalid_request` also covers invalid input, so don't treat it alone as proof that the account exists. + +### Operational notes + +- Rate-limit the route that calls `passkey_signup_challenge`. Each call sends codes, so add limits per IP address and per email address or phone number. +- Pass `client_ip` to `passkey_signup_challenge`, `passkey_login_challenge` and `signin_with_passkey` to forward the end user's IP address as the `auth0-forwarded-for` header. Without it Auth0 sees your server's address. Auth0 uses the header when the **Trust Token Endpoint IP Header** setting is enabled for the application, and, while identifier verification is enabled for your tenant, it rejects a value that is not a valid IP address. Forward only a trusted, normalized IP from your edge or proxy layer. +- Do not log the verification codes. + +> [!IMPORTANT] +> If your tenant requires DPoP, pass `dpop_key` to `signin_with_passkey` as described in section 3. ## Error Handling -The three passkey methods raise `PasskeyError` (a subclass of `Auth0Error`). Input-validation failures raise `MissingRequiredArgumentError`; a required step-up raises `MfaRequiredError`. For most code, catching `Auth0Error` is enough. +The three passkey methods raise `PasskeyError`, a subclass of `Auth0Error`. Input-validation failures raise `MissingRequiredArgumentError` or `InvalidArgumentError`, and a required step-up raises `MfaRequiredError`. -### Basic handling (recommended) +### Basic handling ```python from auth0_server_python.error import Auth0Error @@ -254,20 +354,25 @@ try: store_options={"request": request, "response": response}, ) except MfaRequiredError as e: - return start_mfa(e.mfa_token) # step-up required — challenge + verify via client.mfa, + return start_mfa(e.mfa_token) # step-up required - challenge + verify via client.mfa, # then store the returned tokens (see "Completing MFA # on a passkey login" above) except PasskeyError as e: - return {"error": e.code, "detail": e.message} # branch on e.code, never on message text + if e.status_code == 429: + return {"error": "rate_limited", "retry_after": e.retry_after} + return {"error": e.code, "detail": e.message} # branch on e.code or e.status_code, never on message text except Auth0Error as e: return {"error": str(e)} ``` ### Common error codes (`PasskeyErrorCode`) -- `passkey_challenge_error` — the signup/login challenge request failed -- `passkey_token_error` — token exchange failed (also used for a rejected DPoP downgrade) -- `invalid_response` — Auth0 returned a response that could not be parsed +- `passkey_challenge_error` - the signup/login challenge request failed +- `passkey_token_error` - token exchange failed (also used for a rejected DPoP downgrade) +- `invalid_response` - Auth0 returned a response that could not be parsed +- `invalid_grant` - a verification code was wrong, or the session is no longer valid. `is_verification_retryable` tells the two apart. + +`PasskeyError` also exposes `status_code` (the HTTP status when Auth0 returned an error response, otherwise `None`), `retry_after` (seconds from the `Retry-After` header, or `None` when the header is absent or not a whole number of seconds), `auth_session`, `verification_required` and `is_verification_retryable` for [identifier verification](#4-identifier-verification-optional). A rate-limited challenge or token exchange has `status_code` 429, and its `code` depends on which Auth0 limit was hit. The per-IP limit on the challenge endpoints applies to public clients only. Treat `auth_session` on the error like any other session credential and keep it out of logs. > [!NOTE] -> `auth_session` is a short-lived (typically ~5 min) Tier 1 credential. It is redacted in the SDK's model `repr()`, and you should never log or persist it. If the ceremony takes too long, re-request the challenge. +> `auth_session` is a short-lived (typically about a minute, or 15 min when `verification_required` is set, matching the `timeout` in `authn_params_public_key`) credential. Never log or persist it. If the ceremony takes too long, re-request the challenge. diff --git a/src/auth0_server_python/auth_server/server_client.py b/src/auth0_server_python/auth_server/server_client.py index 5e9bf14e..96406c93 100644 --- a/src/auth0_server_python/auth_server/server_client.py +++ b/src/auth0_server_python/auth_server/server_client.py @@ -31,7 +31,10 @@ from auth0_server_python.auth_server.anonymous import AnonymousClient from auth0_server_python.auth_server.mfa_client import DEFAULT_MFA_TOKEN_TTL, MfaClient from auth0_server_python.auth_server.my_account_client import MyAccountClient -from auth0_server_python.auth_server.passwordless_client import PasswordlessClient +from auth0_server_python.auth_server.passwordless_client import ( + FORWARDED_FOR_HEADER, + PasswordlessClient, +) from auth0_server_python.auth_types import ( CompleteConnectAccountRequest, CompleteConnectAccountResponse, @@ -47,6 +50,7 @@ MfaRequirements, MfaVerifyResponse, PasskeyAuthResponse, + PasskeyDeliveryMethod, PasskeyLoginChallengeResponse, PasskeyLoginResult, PasskeySignupChallengeResponse, @@ -141,6 +145,7 @@ class ServerClient(Generic[TStoreOptions]): GRANT_TYPE_PASSKEY = "urn:okta:params:oauth:grant-type:webauthn" PASSKEY_REGISTER_PATH = "/passkey/register" PASSKEY_CHALLENGE_PATH = "/passkey/challenge" + PASSKEY_VERIFICATION_IDENTIFIERS = ("email", "phone") # ============================================================================ # INITIALIZATION @@ -3518,6 +3523,8 @@ async def passkey_signup_challenge( organization: Optional[str] = None, user_metadata: Optional[dict[str, Any]] = None, store_options: Optional[dict[str, Any]] = None, + delivery_method: Optional[PasskeyDeliveryMethod] = None, + client_ip: Optional[str] = None, ) -> PasskeySignupChallengeResponse: """ Step 1 of 2: Initiate a passkey signup challenge (POST /passkey/register). @@ -3533,14 +3540,21 @@ async def passkey_signup_challenge( user_metadata: Optional custom metadata added at the root of the request body, not nested inside user_profile (per Auth0 API spec). store_options: Optional options for domain resolution. + delivery_method: Optional channel for the phone verification code, "text" or "voice". + client_ip: Optional end user IP address, sent as the auth0-forwarded-for header. Returns: - PasskeySignupChallengeResponse with auth_session and authn_params_public_key. + PasskeySignupChallengeResponse with auth_session, authn_params_public_key and, + when the connection requires verification, verification_required. Raises: - PasskeyError: If the challenge request fails. + InvalidArgumentError: If delivery_method is set to a value other than "text" or "voice". + PasskeyError: If the challenge request fails or the request is rate limited. EnterpriseConnectError: If the client is configured for Enterprise Connect. """ + if delivery_method is not None and delivery_method not in ("text", "voice"): + raise InvalidArgumentError("delivery_method", "delivery_method must be 'text' or 'voice'") + try: domain = await self._resolve_current_domain(store_options) @@ -3556,22 +3570,29 @@ async def passkey_signup_challenge( body["realm"] = connection if resolved_org: body["organization"] = resolved_org + if delivery_method: + body["delivery_method"] = delivery_method async with self._get_http_client() as client: url = f"https://{domain}{self.PASSKEY_REGISTER_PATH}" - response = await client.post(url, json=body) + headers = {FORWARDED_FOR_HEADER: client_ip} if client_ip else {} + response = await client.post(url, json=body, headers=headers) if response.status_code != 200: try: error_data = response.json() except (json.JSONDecodeError, ValueError): + error_data = None + if not isinstance(error_data, dict): raise PasskeyError( PasskeyErrorCode.CHALLENGE_FAILED, f"Passkey signup challenge failed with status {response.status_code}", + **self._passkey_http_error_details(response), ) raise PasskeyError( error_data.get("error", PasskeyErrorCode.CHALLENGE_FAILED), error_data.get("error_description", "Passkey signup challenge failed"), + **self._passkey_http_error_details(response), ) try: @@ -3595,6 +3616,7 @@ async def passkey_login_challenge( connection: Optional[str] = None, organization: Optional[str] = None, store_options: Optional[dict[str, Any]] = None, + client_ip: Optional[str] = None, ) -> PasskeyLoginChallengeResponse: """ Step 1 of 2: Initiate a passkey login challenge (POST /passkey/challenge). @@ -3611,12 +3633,13 @@ async def passkey_login_challenge( connection: Auth0 database connection name (realm). organization: Auth0 organization ID or name. store_options: Optional options for domain resolution. + client_ip: Optional end user IP address, sent as the auth0-forwarded-for header. Returns: PasskeyLoginChallengeResponse with auth_session and authn_params_public_key. Raises: - PasskeyError: If the challenge request fails. + PasskeyError: If the challenge request fails or the request is rate limited. EnterpriseConnectError: If the client is configured for Enterprise Connect. """ if username is not None: @@ -3642,19 +3665,24 @@ async def passkey_login_challenge( async with self._get_http_client() as client: url = f"https://{domain}{self.PASSKEY_CHALLENGE_PATH}" - response = await client.post(url, json=body) + headers = {FORWARDED_FOR_HEADER: client_ip} if client_ip else {} + response = await client.post(url, json=body, headers=headers) if response.status_code != 200: try: error_data = response.json() except (json.JSONDecodeError, ValueError): + error_data = None + if not isinstance(error_data, dict): raise PasskeyError( PasskeyErrorCode.CHALLENGE_FAILED, f"Passkey login challenge failed with status {response.status_code}", + **self._passkey_http_error_details(response), ) raise PasskeyError( error_data.get("error", PasskeyErrorCode.CHALLENGE_FAILED), error_data.get("error_description", "Passkey login challenge failed"), + **self._passkey_http_error_details(response), ) try: @@ -3672,6 +3700,25 @@ async def passkey_login_challenge( raise raise PasskeyError(PasskeyErrorCode.CHALLENGE_FAILED, "Passkey login challenge failed", e) from e + @staticmethod + def _passkey_http_error_details(response) -> dict: + retry_after = None + try: + retry_after = int(response.headers.get("Retry-After")) + except (TypeError, ValueError): + pass + if retry_after is not None and retry_after < 0: + retry_after = None + return {"status_code": response.status_code, "retry_after": retry_after} + + @staticmethod + def _is_dpop_nonce_challenge(response) -> bool: + try: + data = response.json() + except ValueError: + return False + return isinstance(data, dict) and data.get("error") == "use_dpop_nonce" + async def signin_with_passkey( self, auth_session: str, @@ -3682,6 +3729,8 @@ async def signin_with_passkey( scope: Optional[str] = None, audience: Optional[str] = None, dpop_key: Optional["jwk.JWK"] = None, + verification: Optional[dict[str, Optional[str]]] = None, + client_ip: Optional[str] = None, ) -> PasskeyLoginResult: """ Completes passkey authentication by exchanging the WebAuthn assertion @@ -3702,6 +3751,8 @@ async def signin_with_passkey( dpop_key: Optional EC P-256 JWK for DPoP-bound token exchange. When provided, attaches a DPoP proof header so Auth0 issues a DPoP-bound token (token_type: DPoP). Required when the tenant mandates DPoP binding. + verification: Optional signup codes keyed by "email" or "phone", not used for login. + client_ip: Optional end user IP address, sent as the auth0-forwarded-for header. Returns: PasskeyLoginResult containing state_data with user claims and token sets, @@ -3711,7 +3762,13 @@ async def signin_with_passkey( MissingRequiredArgumentError: If auth_session or authn_response is missing. ConfigurationError: If dpop_key is combined with use_mtls. DPoP and mTLS use incompatible token-binding mechanisms and cannot be used together. - PasskeyError: If token exchange or session creation fails. + InvalidArgumentError: If verification has a key other than "email" or "phone", + or a code that is not a string. + PasskeyError: If token exchange or session creation fails, or the request is + rate limited. + MfaRequiredError: If the tenant requires step-up MFA after the passkey sign-in. + ApiError: If the ID token fails signature, audience, expiry or JWKS validation. + IssuerValidationError: If the ID token issuer does not match the tenant. OrganizationTokenValidationError: If an organization was requested but the token response included no ID token, or the ID token's org claim does not match. @@ -3727,6 +3784,22 @@ async def signin_with_passkey( "differently. DPoP would take precedence and the token would not be " "certificate-bound." ) + if verification is not None and not isinstance(verification, dict): + raise InvalidArgumentError( + "verification", "verification must be a dict keyed by 'email' or 'phone'" + ) + verification_codes: dict[str, str] = {} + for identifier, code in (verification or {}).items(): + if identifier not in self.PASSKEY_VERIFICATION_IDENTIFIERS: + raise InvalidArgumentError( + "verification", "verification keys must be 'email' or 'phone'" + ) + if code is not None and not isinstance(code, str): + raise InvalidArgumentError( + "verification", f"the '{identifier}' verification code must be a string" + ) + if code and code.strip(): + verification_codes[identifier] = code try: domain = await self._resolve_current_domain(store_options) @@ -3755,23 +3828,23 @@ async def signin_with_passkey( body["scope"] = scope if audience: body["audience"] = audience + if verification_codes: + body["verification"] = verification_codes async with self._get_http_client() as client: - headers = {} + headers = {FORWARDED_FOR_HEADER: client_ip} if client_ip else {} if dpop_key is not None: headers["DPoP"] = make_dpop_proof_for_token_endpoint( dpop_key, "POST", token_endpoint ) response = await client.post(token_endpoint, json=body, headers=headers) - # RFC 9449 — the authorization server signals a required nonce - # with HTTP 400 + error="use_dpop_nonce" + DPoP-Nonce. Accept - # 401 as well so the retry holds against servers that mirror the - # resource-server status. + # RFC 9449 nonce challenge: HTTP 400 or 401 with error use_dpop_nonce and DPoP-Nonce. if ( dpop_key is not None and response.status_code in (400, 401) and response.headers.get("DPoP-Nonce") + and self._is_dpop_nonce_challenge(response) ): nonce = response.headers["DPoP-Nonce"] headers["DPoP"] = make_dpop_proof_for_token_endpoint( @@ -3783,9 +3856,12 @@ async def signin_with_passkey( try: error_data = response.json() except (json.JSONDecodeError, ValueError): + error_data = None + if not isinstance(error_data, dict): raise PasskeyError( PasskeyErrorCode.TOKEN_EXCHANGE_FAILED, f"Passkey token exchange failed with status {response.status_code}", + **self._passkey_http_error_details(response), ) error_code = error_data.get("error", PasskeyErrorCode.TOKEN_EXCHANGE_FAILED) if error_code == "mfa_required": @@ -3803,6 +3879,9 @@ async def signin_with_passkey( raise PasskeyError( error_code, error_data.get("error_description", "Passkey token exchange failed"), + auth_session=error_data.get("auth_session"), + verification_required=error_data.get("verification_required"), + **self._passkey_http_error_details(response), ) try: diff --git a/src/auth0_server_python/auth_types/__init__.py b/src/auth0_server_python/auth_types/__init__.py index e55091ca..f2649c03 100644 --- a/src/auth0_server_python/auth_types/__init__.py +++ b/src/auth0_server_python/auth_types/__init__.py @@ -21,6 +21,7 @@ "passkey", "email", "phone", "totp", "push-notification", "recovery-code", "password" ] PreferredAuthMethod = Literal["sms", "voice"] +PasskeyDeliveryMethod = Literal["text", "voice"] # Deprecated public aliases resolved lazily (PEP 562) so access emits a warning # while imports keep working. Remove in a future release. @@ -734,11 +735,15 @@ class PasskeyLoginResult(BaseModel): class PasskeyRpInfo(BaseModel): + """Relying party identity for a WebAuthn ceremony.""" + id: str name: str class PasskeyUserInfo(BaseModel): + """User identity for a WebAuthn registration.""" + model_config = ConfigDict(populate_by_name=True) id: str name: str @@ -746,17 +751,23 @@ class PasskeyUserInfo(BaseModel): class PasskeyPubKeyCredParam(BaseModel): + """Public key type and algorithm the relying party accepts.""" + type: str alg: int class PasskeyAuthenticatorSelection(BaseModel): + """Authenticator requirements for a WebAuthn registration.""" + model_config = ConfigDict(populate_by_name=True) resident_key: Optional[str] = Field(None, alias="residentKey") user_verification: Optional[str] = Field(None, alias="userVerification") class PasskeyPublicKeyOptions(BaseModel): + """WebAuthn public key options to pass to the browser credentials API.""" + model_config = ConfigDict(populate_by_name=True, extra="allow") challenge: str rp: Optional[PasskeyRpInfo] = None @@ -789,6 +800,8 @@ class EnrollmentChallengeResponse(BaseModel): class PasskeyAuthResponse(BaseModel): + """WebAuthn credential returned by the browser, passed to signin_with_passkey.""" + model_config = ConfigDict(populate_by_name=True) id: str raw_id: str = Field(alias="rawId") @@ -851,6 +864,8 @@ class GetFactorsResponse(BaseModel): class PasskeyUserProfile(BaseModel): + """User profile attributes sent with a passkey signup challenge.""" + model_config = ConfigDict(extra="allow") email: Optional[str] = None name: Optional[str] = None @@ -863,20 +878,25 @@ class PasskeyUserProfile(BaseModel): class _PasskeyChallengeResponseBase(BaseModel): - auth_session: str + model_config = ConfigDict(hide_input_in_errors=True) + auth_session: str = Field(repr=False) authn_params_public_key: PasskeyPublicKeyOptions class PasskeySignupChallengeResponse(_PasskeyChallengeResponseBase): - pass + """Signup challenge, with verification_required set when the user must enter codes first. auth_session is hidden from repr and str.""" + + verification_required: Optional[list[str]] = None class PasskeyLoginChallengeResponse(_PasskeyChallengeResponseBase): - pass + """Login challenge with the auth_session and WebAuthn options for the browser. auth_session is hidden from repr and str.""" class PasskeyTokenResponse(BaseModel): - model_config = ConfigDict(extra="allow") + """Token endpoint response for the passkey grant.""" + + model_config = ConfigDict(extra="allow", hide_input_in_errors=True) access_token: str token_type: str = "Bearer" expires_in: int diff --git a/src/auth0_server_python/error/__init__.py b/src/auth0_server_python/error/__init__.py index 1b48251a..eecdbd60 100644 --- a/src/auth0_server_python/error/__init__.py +++ b/src/auth0_server_python/error/__init__.py @@ -409,13 +409,40 @@ class PasswordlessErrorCode: class PasskeyError(Auth0Error): """ Error raised during passkey authentication operations. + + Args: + code: Error code, see PasskeyErrorCode. + message: Human-readable description. + cause: Underlying exception, if any. + auth_session: Session to resubmit against, set when Auth0 keeps it alive after a wrong code. + verification_required: Identifiers whose codes are still unverified. + retry_after: Seconds from the Retry-After header, None when absent or not whole seconds. + status_code: HTTP status of an Auth0 error response, None otherwise. """ - def __init__(self, code: str, message: str, cause=None): + def __init__( + self, + code: str, + message: str, + cause=None, + auth_session: Optional[str] = None, + verification_required: Optional[list[str]] = None, + retry_after: Optional[int] = None, + status_code: Optional[int] = None, + ): super().__init__(message) self.code = code self.name = "PasskeyError" self.cause = cause + self.auth_session = auth_session + self.verification_required = verification_required + self.retry_after = retry_after # seconds + self.status_code = status_code + + @property + def is_verification_retryable(self) -> bool: + """True when a code was wrong but the session is alive, so the caller can resubmit.""" + return self.code == PasskeyErrorCode.INVALID_GRANT and bool(self.auth_session) class PasskeyErrorCode: @@ -424,6 +451,7 @@ class PasskeyErrorCode: CHALLENGE_FAILED = "passkey_challenge_error" TOKEN_EXCHANGE_FAILED = "passkey_token_error" INVALID_RESPONSE = "invalid_response" + INVALID_GRANT = "invalid_grant" # ============================================================================= diff --git a/src/auth0_server_python/tests/test_error.py b/src/auth0_server_python/tests/test_error.py index 30b87829..a2d37d6f 100644 --- a/src/auth0_server_python/tests/test_error.py +++ b/src/auth0_server_python/tests/test_error.py @@ -1,9 +1,13 @@ """Tests for the error module's typed exceptions.""" +import pytest + from auth0_server_python.error import ( Auth0Error, EnterpriseConnectError, EnterpriseConnectErrorCode, + PasskeyError, + PasskeyErrorCode, ) # === Enterprise Connect === @@ -34,3 +38,54 @@ def test_enterprise_connect_error_preserves_cause(): def test_enterprise_connect_error_code_is_stable(): assert EnterpriseConnectErrorCode.NOT_SUPPORTED == "enterprise_connect_not_supported" + + +# === Passkey === + + +def test_passkey_error_retry_details_default_to_none(): + err = PasskeyError("passkey_token_error", "failed") + assert err.auth_session is None + assert err.verification_required is None + assert err.is_verification_retryable is False + + +def test_passkey_error_keeps_cause_as_third_positional_argument(): + cause = ValueError("boom") + err = PasskeyError("passkey_token_error", "failed", cause) + assert err.cause is cause + + +def test_passkey_error_invalid_grant_with_auth_session_is_verification_retryable(): + err = PasskeyError( + "invalid_grant", "failed", auth_session="session_abc123", verification_required=["email"] + ) + assert err.is_verification_retryable is True + assert err.verification_required == ["email"] + + +@pytest.mark.parametrize( + ("code", "auth_session"), + [ + ("invalid_grant", None), + ("invalid_grant", ""), + ("invalid_request", None), + ("invalid_request", "session_abc123"), + ("server_error", None), + ], +) +def test_passkey_error_other_failures_are_not_verification_retryable(code, auth_session): + err = PasskeyError(code, "failed", auth_session=auth_session) + assert err.is_verification_retryable is False + + +def test_passkey_error_str_and_repr_do_not_contain_auth_session(): + err = PasskeyError("invalid_grant", "failed", auth_session="session_abc123") + assert "session_abc123" not in str(err) + assert "session_abc123" not in repr(err) + + +def test_passkey_error_http_details_default_to_none(): + err = PasskeyError(PasskeyErrorCode.INVALID_RESPONSE, "failed") + assert err.status_code is None + assert err.retry_after is None diff --git a/src/auth0_server_python/tests/test_server_client.py b/src/auth0_server_python/tests/test_server_client.py index 1b31389d..ea9566a9 100644 --- a/src/auth0_server_python/tests/test_server_client.py +++ b/src/auth0_server_python/tests/test_server_client.py @@ -15,6 +15,7 @@ from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric import ec, rsa from jwcrypto import jwk +from pydantic import ValidationError from auth0_server_python.auth_schemes.dpop_auth import DPoPAuth from auth0_server_python.auth_server.anonymous import ANON_IDENTIFIER, AnonymousClient @@ -47,6 +48,7 @@ PasskeyLoginChallengeResponse, PasskeyLoginResult, PasskeySignupChallengeResponse, + PasskeyTokenResponse, PasskeyUserProfile, SessionTransferTokenResult, StartEnterpriseLoginOptions, @@ -8459,6 +8461,41 @@ async def test_signin_with_passkey_dpop_attaches_proof_header(mocker): assert payload["htu"] == "https://auth0.local/oauth/token" +@pytest.mark.asyncio +@pytest.mark.parametrize( + "response_body", + [ + {"error": "invalid_grant", "error_description": "Wrong code", "auth_session": "session_abc123"}, + None, + ], + ids=["invalid_grant_with_nonce", "non_json_body_with_nonce"], +) +async def test_signin_with_passkey_dpop_nonce_header_not_retried_without_use_dpop_nonce( + mocker, response_body +): + client = _make_passkey_signin_client(mocker) + mock_post = mocker.patch("httpx.AsyncClient.post", new_callable=AsyncMock) + rejected = AsyncMock() + rejected.status_code = 400 + rejected.headers = {"DPoP-Nonce": "server-nonce-other"} + if response_body is None: + rejected.json = MagicMock(side_effect=json.JSONDecodeError("bad", "", 0)) + else: + rejected.json = MagicMock(return_value=response_body) + mock_post.side_effect = [rejected] + + dpop_key = jwk.JWK.generate(kty="EC", crv="P-256") + with pytest.raises(PasskeyError): + await client.signin_with_passkey( + auth_session="session_abc123", + authn_response=_make_passkey_authn_response(), + dpop_key=dpop_key, + verification={"email": "000000"}, + ) + + assert mock_post.await_count == 1 + + @pytest.mark.asyncio async def test_signin_with_passkey_dpop_nonce_retry(mocker): client = ServerClient( @@ -8511,6 +8548,28 @@ async def test_signin_with_passkey_dpop_nonce_retry(mocker): assert payload["nonce"] == "server-nonce-abc" +@pytest.mark.asyncio +async def test_signin_with_passkey_rate_limited_after_dpop_nonce_retry(mocker): + client = _make_passkey_signin_client(mocker) + mock_post = mocker.patch("httpx.AsyncClient.post", new_callable=AsyncMock) + nonce_response = AsyncMock() + nonce_response.status_code = 400 + nonce_response.headers = {"DPoP-Nonce": "server-nonce-abc"} + nonce_response.json = MagicMock(return_value={"error": "use_dpop_nonce"}) + rate_limited = AsyncMock() + rate_limited.status_code = 429 + rate_limited.headers = {"Retry-After": "30"} + rate_limited.json = MagicMock(return_value={"error": "access_denied", "error_description": "Too Many Requests"}) + mock_post.side_effect = [nonce_response, rate_limited] + + error = await _signin_expecting_error(client, dpop_key=jwk.JWK.generate(kty="EC", crv="P-256")) + + assert mock_post.await_count == 2 + assert error.code == "access_denied" + assert error.status_code == 429 + assert error.retry_after == 30 + + @pytest.mark.asyncio async def test_signin_with_passkey_dpop_nonce_retry_on_401(mocker): """Token endpoint nonce retry must also hold when the server returns 401 + DPoP-Nonce.""" @@ -9392,6 +9451,631 @@ async def test_signin_with_passkey_rejects_dpop_under_mtls(mocker): ) +# === Passkey identifier verification === + + +def _make_passkey_client(): + return ServerClient( + domain="auth0.local", + client_id="test_client_id", + client_secret="test_client_secret", + state_store=AsyncMock(), + transaction_store=AsyncMock(), + secret="test-secret-value", + ) + + +def _make_passkey_signin_client(mocker): + client = _make_passkey_client() + mocker.patch.object( + client, + "_get_oidc_metadata_cached", + return_value={"token_endpoint": "https://auth0.local/oauth/token", "issuer": "https://auth0.local/"}, + ) + return client + + +def _mock_passkey_post(mocker, status_code, json_body=None): + """Patch the HTTP post. A json_body of None simulates a non-JSON response body.""" + mock_post = mocker.patch("httpx.AsyncClient.post", new_callable=AsyncMock) + response = AsyncMock() + response.status_code = status_code + response.headers = {} + if json_body is None: + response.json = MagicMock(side_effect=json.JSONDecodeError("bad", "", 0)) + else: + response.json = MagicMock(return_value=json_body) + mock_post.return_value = response + return mock_post + + +async def _signin_expecting_error(client, **kwargs): + with pytest.raises(PasskeyError) as exc: + await client.signin_with_passkey( + auth_session="session_abc123", + authn_response=_make_passkey_authn_response(), + **kwargs, + ) + return exc.value + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("extra", "expected"), + [ + ({}, None), + ({"verification_required": ["email"]}, ["email"]), + ({"verification_required": ["phone"]}, ["phone"]), + ({"verification_required": ["email", "phone"]}, ["email", "phone"]), + ], + ids=["absent", "email_only", "phone_only", "email_and_phone"], +) +async def test_passkey_signup_challenge_verification_required_states_parse(mocker, extra, expected): + client = _make_passkey_client() + _mock_passkey_post(mocker, 200, {**_PASSKEY_SIGNUP_CHALLENGE_RESPONSE, **extra}) + + result = await client.passkey_signup_challenge() + + assert result.verification_required == expected + + +@pytest.mark.asyncio +@pytest.mark.parametrize("delivery_method", ["text", "voice"]) +async def test_passkey_signup_challenge_delivery_method_forwarded(mocker, delivery_method): + client = _make_passkey_client() + mock_post = _mock_passkey_post(mocker, 200, _PASSKEY_SIGNUP_CHALLENGE_RESPONSE) + + await client.passkey_signup_challenge(delivery_method=delivery_method) + + assert mock_post.call_args.kwargs["json"]["delivery_method"] == delivery_method + + +@pytest.mark.parametrize( + "model, payload", + [ + (PasskeySignupChallengeResponse, _PASSKEY_SIGNUP_CHALLENGE_RESPONSE), + (PasskeyLoginChallengeResponse, _PASSKEY_LOGIN_CHALLENGE_RESPONSE), + ], + ids=["signup", "login"], +) +def test_passkey_challenge_response_hides_auth_session_from_repr_and_str(model, payload): + challenge = model.model_validate(payload) + + assert payload["auth_session"] not in repr(challenge) + assert payload["auth_session"] not in str(challenge) + assert challenge.auth_session == payload["auth_session"] + assert challenge.model_dump()["auth_session"] == payload["auth_session"] + + +@pytest.mark.parametrize( + "model, payload, secret", + [ + (PasskeySignupChallengeResponse, {"auth_session": "sess-secret-1"}, "sess-secret-1"), + (PasskeyLoginChallengeResponse, {"auth_session": "sess-secret-2"}, "sess-secret-2"), + (PasskeySignupChallengeResponse, "sess-secret-3", "sess-secret-3"), + ( + PasskeyTokenResponse, + {"access_token": "at-secret", "refresh_token": "rt-secret"}, + "rt-secret", + ), + ], + ids=["signup_missing_field", "login_missing_field", "signup_non_dict", "token_missing_field"], +) +def test_passkey_models_hide_input_from_validation_errors(model, payload, secret): + with pytest.raises(ValidationError) as exc: + model.model_validate(payload) + + assert secret not in str(exc.value) + assert exc.value.error_count() >= 1 + + +@pytest.mark.asyncio +async def test_passkey_signup_challenge_delivery_method_omitted_when_not_set(mocker): + client = _make_passkey_client() + mock_post = _mock_passkey_post(mocker, 200, _PASSKEY_SIGNUP_CHALLENGE_RESPONSE) + + await client.passkey_signup_challenge() + + assert "delivery_method" not in mock_post.call_args.kwargs["json"] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("delivery_method", ["sms", "", "Text", "VOICE"]) +async def test_passkey_signup_challenge_rejects_unsupported_delivery_method_before_request( + mocker, delivery_method +): + client = _make_passkey_client() + mock_post = _mock_passkey_post(mocker, 200, _PASSKEY_SIGNUP_CHALLENGE_RESPONSE) + + with pytest.raises(InvalidArgumentError) as exc: + await client.passkey_signup_challenge(delivery_method=delivery_method) + + assert "delivery_method" in str(exc.value) + mock_post.assert_not_called() + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "method, payload", + [ + ("passkey_signup_challenge", _PASSKEY_SIGNUP_CHALLENGE_RESPONSE), + ("passkey_login_challenge", _PASSKEY_LOGIN_CHALLENGE_RESPONSE), + ], + ids=["signup", "login"], +) +async def test_passkey_challenge_client_ip_sent_as_forwarded_for_header(mocker, method, payload): + client = _make_passkey_client() + mock_post = _mock_passkey_post(mocker, 200, payload) + + await getattr(client, method)(client_ip="203.0.113.7") + + assert mock_post.call_args.kwargs["headers"]["auth0-forwarded-for"] == "203.0.113.7" + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "method, payload", + [ + ("passkey_signup_challenge", _PASSKEY_SIGNUP_CHALLENGE_RESPONSE), + ("passkey_login_challenge", _PASSKEY_LOGIN_CHALLENGE_RESPONSE), + ], + ids=["signup", "login"], +) +async def test_passkey_challenge_forwarded_for_header_omitted_without_client_ip(mocker, method, payload): + client = _make_passkey_client() + mock_post = _mock_passkey_post(mocker, 200, payload) + + await getattr(client, method)() + + assert "auth0-forwarded-for" not in mock_post.call_args.kwargs.get("headers", {}) + + +@pytest.mark.asyncio +async def test_signin_with_passkey_client_ip_sent_as_forwarded_for_header(mocker): + client = _make_passkey_signin_client(mocker) + mock_post = _mock_passkey_post(mocker, 400, {"error": "invalid_grant"}) + + await _signin_expecting_error(client, client_ip="203.0.113.7") + + assert mock_post.call_args.kwargs["headers"]["auth0-forwarded-for"] == "203.0.113.7" + + +@pytest.mark.asyncio +async def test_signin_with_passkey_forwarded_for_header_omitted_without_client_ip(mocker): + client = _make_passkey_signin_client(mocker) + mock_post = _mock_passkey_post(mocker, 400, {"error": "invalid_grant"}) + + await _signin_expecting_error(client) + + assert "auth0-forwarded-for" not in mock_post.call_args.kwargs["headers"] + + +@pytest.mark.asyncio +async def test_signin_with_passkey_client_ip_and_dpop_key_send_both_headers(mocker): + client = _make_passkey_signin_client(mocker) + mocker.patch.object(client, "_get_jwks_cached", return_value={}) + mocker.patch.object(client, "_verify_and_decode_jwt", return_value={ + "sub": "auth0|user123", "iss": "https://auth0.local/" + }) + mock_post = _mock_passkey_post(mocker, 200, _PASSKEY_TOKEN_RESPONSE_DPOP) + + await client.signin_with_passkey( + auth_session="session_abc123", + authn_response=_make_passkey_authn_response(), + dpop_key=jwk.JWK.generate(kty="EC", crv="P-256"), + client_ip="203.0.113.7", + ) + + headers = mock_post.call_args.kwargs["headers"] + assert headers["auth0-forwarded-for"] == "203.0.113.7" + assert "DPoP" in headers + + +@pytest.mark.asyncio +async def test_passkey_signup_challenge_unsupported_delivery_method_surfaces_server_error(mocker): + client = _make_passkey_client() + _mock_passkey_post(mocker, 400, { + "error": "invalid_request", + "error_description": "The requested media is not supported for this connection", + }) + + with pytest.raises(PasskeyError) as exc: + await client.passkey_signup_challenge(delivery_method="voice") + + assert exc.value.code == "invalid_request" + assert "media is not supported" in exc.value.message + + +async def _call_passkey_method(mocker, method): + if method == "signin_with_passkey": + client = _make_passkey_signin_client(mocker) + return client, lambda: _signin_expecting_error(client) + client = _make_passkey_client() + + async def call(): + with pytest.raises(PasskeyError) as exc: + await getattr(client, method)() + return exc.value + + return client, call + + +_PASSKEY_429_METHODS = ["passkey_signup_challenge", "passkey_login_challenge", "signin_with_passkey"] + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("method", "body", "code", "message"), + [ + ("passkey_signup_challenge", None, PasskeyErrorCode.CHALLENGE_FAILED, + "Passkey signup challenge failed with status 429"), + ("passkey_signup_challenge", {"error": "too_many_requests", "error_description": "Global rate limit exceeded"}, + "too_many_requests", "Global rate limit exceeded"), + ("passkey_login_challenge", None, PasskeyErrorCode.CHALLENGE_FAILED, + "Passkey login challenge failed with status 429"), + ("passkey_login_challenge", {"error": "too_many_requests", "error_description": "Global rate limit exceeded"}, + "too_many_requests", "Global rate limit exceeded"), + ("signin_with_passkey", None, PasskeyErrorCode.TOKEN_EXCHANGE_FAILED, + "Passkey token exchange failed with status 429"), + ("signin_with_passkey", {"error": "access_denied", "error_description": "Too Many Requests"}, + "access_denied", "Too Many Requests"), + ], + ids=["signup_plain_text", "signup_json", "login_plain_text", "login_json", "token_plain_text", "token_json"], +) +async def test_passkey_rate_limited_keeps_released_code_and_sets_status_code(mocker, method, body, code, message): + _, call = await _call_passkey_method(mocker, method) + _mock_passkey_post(mocker, 429, body) + + error = await call() + + assert error.code == code + assert error.message == message + assert error.status_code == 429 + assert error.is_verification_retryable is False + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("method", "code"), + [ + ("passkey_signup_challenge", PasskeyErrorCode.CHALLENGE_FAILED), + ("passkey_login_challenge", PasskeyErrorCode.CHALLENGE_FAILED), + ("signin_with_passkey", PasskeyErrorCode.TOKEN_EXCHANGE_FAILED), + ], +) +async def test_passkey_non_dict_json_error_body_keeps_status_code(mocker, method, code): + _, call = await _call_passkey_method(mocker, method) + _mock_passkey_post(mocker, 429, ["not", "a", "dict"]) + + error = await call() + + assert error.code == code + assert error.status_code == 429 + assert error.cause is None + + +@pytest.mark.asyncio +@pytest.mark.parametrize("method", _PASSKEY_429_METHODS) +@pytest.mark.parametrize( + ("header", "expected"), + [("30", 30), (None, None), ("Wed, 21 Oct 2026 07:28:00 GMT", None), ("soon", None), ("-5", None)], + ids=["seconds", "absent", "http_date", "garbage", "negative"], +) +async def test_passkey_rate_limited_exposes_retry_after(mocker, method, header, expected): + _, call = await _call_passkey_method(mocker, method) + mock_post = _mock_passkey_post(mocker, 429) + if header is not None: + mock_post.return_value.headers = {"Retry-After": header} + + error = await call() + + assert error.status_code == 429 + assert error.retry_after == expected + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("method", "code"), + [ + ("passkey_signup_challenge", PasskeyErrorCode.CHALLENGE_FAILED), + ("passkey_login_challenge", PasskeyErrorCode.CHALLENGE_FAILED), + ("signin_with_passkey", PasskeyErrorCode.TOKEN_EXCHANGE_FAILED), + ], +) +async def test_passkey_unavailable_exposes_status_code_and_retry_after(mocker, method, code): + _, call = await _call_passkey_method(mocker, method) + mock_post = _mock_passkey_post(mocker, 503) + mock_post.return_value.headers = {"Retry-After": "120"} + + error = await call() + + assert error.code == code + assert error.status_code == 503 + assert error.retry_after == 120 + + +@pytest.mark.asyncio +async def test_signin_with_passkey_wrong_code_error_is_verification_retryable(mocker): + client = _make_passkey_signin_client(mocker) + _mock_passkey_post(mocker, 400, { + "error": "invalid_grant", + "error_description": "Invalid or expired session", + "auth_session": "session_abc123", + "verification_required": ["email"], + }) + + error = await _signin_expecting_error(client, verification={"email": "000000"}) + + assert error.is_verification_retryable is True + assert error.code == "invalid_grant" + assert error.status_code == 400 + assert error.auth_session == "session_abc123" + assert error.verification_required == ["email"] + assert "session_abc123" not in str(error) + assert "session_abc123" not in repr(error) + + +@pytest.mark.asyncio +async def test_signin_with_passkey_wrong_code_error_lists_every_remaining_identifier(mocker): + client = _make_passkey_signin_client(mocker) + _mock_passkey_post(mocker, 400, { + "error": "invalid_grant", + "error_description": "Invalid or expired session", + "auth_session": "session_abc123", + "verification_required": ["email", "phone"], + }) + + error = await _signin_expecting_error(client, verification={"email": "1", "phone": "2"}) + + assert error.verification_required == ["email", "phone"] + + +@pytest.mark.asyncio +async def test_signin_with_passkey_terminal_invalid_grant_is_not_retryable(mocker): + client = _make_passkey_signin_client(mocker) + _mock_passkey_post(mocker, 400, { + "error": "invalid_grant", + "error_description": "Invalid or expired session", + }) + + error = await _signin_expecting_error(client, verification={"email": "000000"}) + + assert error.is_verification_retryable is False + assert error.auth_session is None + assert error.verification_required is None + + +@pytest.mark.asyncio +async def test_signin_with_passkey_missing_code_is_invalid_request_and_not_retryable(mocker): + client = _make_passkey_signin_client(mocker) + _mock_passkey_post(mocker, 400, { + "error": "invalid_request", + "error_description": "Missing required parameter: verification.email", + }) + + error = await _signin_expecting_error(client) + + assert error.code == "invalid_request" + assert error.is_verification_retryable is False + assert error.auth_session is None + + +@pytest.mark.asyncio +@pytest.mark.parametrize("status_code", [400, 403]) +async def test_signin_with_passkey_unknown_session_is_not_retryable(mocker, status_code): + client = _make_passkey_signin_client(mocker) + _mock_passkey_post(mocker, status_code, { + "error": "invalid_grant", + "error_description": "Invalid or expired session", + }) + + error = await _signin_expecting_error(client) + + assert error.is_verification_retryable is False + + +@pytest.mark.asyncio +async def test_signin_with_passkey_server_error_is_not_verification_retryable(mocker): + client = _make_passkey_signin_client(mocker) + _mock_passkey_post(mocker, 500, {"error": "server_error", "error_description": "unavailable"}) + + error = await _signin_expecting_error(client, verification={"email": "000000"}) + + assert error.code == "server_error" + assert error.is_verification_retryable is False + + +@pytest.mark.asyncio +async def test_signin_with_passkey_verification_forwarded(mocker): + client = _make_passkey_signin_client(mocker) + mock_post = _mock_passkey_post(mocker, 400, {"error": "invalid_grant"}) + + await _signin_expecting_error(client, verification={"email": "123456", "phone": "654321"}) + + assert mock_post.call_args.kwargs["json"]["verification"] == {"email": "123456", "phone": "654321"} + + +@pytest.mark.asyncio +async def test_signin_with_passkey_verification_sent_as_json_object_on_the_wire(mocker): + client = _make_passkey_signin_client(mocker) + captured = {} + + def handler(request): + captured["content_type"] = request.headers.get("content-type") + captured["body"] = json.loads(request.content) + return httpx.Response(400, json={"error": "invalid_grant"}) + + mocker.patch.object( + client, + "_get_http_client", + side_effect=lambda **kwargs: httpx.AsyncClient(transport=httpx.MockTransport(handler)), + ) + + await _signin_expecting_error(client, verification={"email": "123456", "phone": "654321"}) + + assert captured["content_type"] == "application/json" + assert captured["body"]["verification"] == {"email": "123456", "phone": "654321"} + + +@pytest.mark.asyncio +async def test_signin_with_passkey_verification_none_values_dropped(mocker): + client = _make_passkey_signin_client(mocker) + mock_post = _mock_passkey_post(mocker, 400, {"error": "invalid_grant"}) + + await _signin_expecting_error(client, verification={"email": "123456", "phone": None}) + + assert mock_post.call_args.kwargs["json"]["verification"] == {"email": "123456"} + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "verification", + ["123456", ["123456"], 123456], + ids=["str", "list", "int"], +) +async def test_signin_with_passkey_verification_non_dict_raises_before_request(mocker, verification): + client = _make_passkey_signin_client(mocker) + mock_post = _mock_passkey_post(mocker, 400, {"error": "invalid_grant"}) + + with pytest.raises(InvalidArgumentError) as exc: + await client.signin_with_passkey( + auth_session="session_abc123", + authn_response=_make_passkey_authn_response(), + verification=verification, + ) + + assert exc.value.argument == "verification" + assert "123456" not in str(exc.value) + mock_post.assert_not_called() + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "verification", + [{"phone_number": "123456"}, {"email": "123456", "otp": "654321"}, {"EMAIL": "123456"}], + ids=["phone_number", "extra_key", "wrong_case"], +) +async def test_signin_with_passkey_verification_unknown_key_raises_before_request(mocker, verification): + client = _make_passkey_signin_client(mocker) + mock_post = _mock_passkey_post(mocker, 400, {"error": "invalid_grant"}) + + with pytest.raises(InvalidArgumentError) as exc: + await client.signin_with_passkey( + auth_session="session_abc123", + authn_response=_make_passkey_authn_response(), + verification=verification, + ) + + assert exc.value.argument == "verification" + assert "123456" not in str(exc.value) + mock_post.assert_not_called() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("code", [123456, ["123456"], b"123456"], ids=["int", "list", "bytes"]) +async def test_signin_with_passkey_verification_non_string_code_raises_before_request(mocker, code): + client = _make_passkey_signin_client(mocker) + mock_post = _mock_passkey_post(mocker, 400, {"error": "invalid_grant"}) + + with pytest.raises(InvalidArgumentError) as exc: + await client.signin_with_passkey( + auth_session="session_abc123", + authn_response=_make_passkey_authn_response(), + verification={"email": code}, + ) + + assert exc.value.argument == "verification" + mock_post.assert_not_called() + + +@pytest.mark.asyncio +async def test_signin_with_passkey_verification_blank_codes_dropped(mocker): + client = _make_passkey_signin_client(mocker) + mock_post = _mock_passkey_post(mocker, 400, {"error": "invalid_grant"}) + + await _signin_expecting_error(client, verification={"email": "123456", "phone": " "}) + + assert mock_post.call_args.kwargs["json"]["verification"] == {"email": "123456"} + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "verification", + [None, {}, {"email": None}, {"email": ""}, {"email": " ", "phone": None}], + ids=["none", "empty", "all_none", "blank", "blank_and_none"], +) +async def test_signin_with_passkey_verification_omitted_when_nothing_to_send(mocker, verification): + client = _make_passkey_signin_client(mocker) + mock_post = _mock_passkey_post(mocker, 400, {"error": "invalid_grant"}) + + await _signin_expecting_error(client, verification=verification) + + assert "verification" not in mock_post.call_args.kwargs["json"] + + +@pytest.mark.asyncio +async def test_signin_with_passkey_wrong_code_then_corrected_code_succeeds_on_same_session(mocker): + client = _make_passkey_signin_client(mocker) + mocker.patch.object(client, "_get_jwks_cached", return_value={}) + mocker.patch.object(client, "_verify_and_decode_jwt", return_value={ + "sub": "auth0|user123", "iss": "https://auth0.local/" + }) + mock_post = _mock_passkey_post(mocker, 400, { + "error": "invalid_grant", + "error_description": "Invalid or expired session", + "auth_session": "session_abc123", + "verification_required": ["email"], + }) + success = AsyncMock() + success.status_code = 200 + success.headers = {} + success.json = MagicMock(return_value=_PASSKEY_TOKEN_RESPONSE) + mock_post.side_effect = [mock_post.return_value, success] + authn_response = _make_passkey_authn_response() + + with pytest.raises(PasskeyError) as exc: + await client.signin_with_passkey( + auth_session="session_abc123", + authn_response=authn_response, + verification={"email": "000000"}, + ) + assert exc.value.is_verification_retryable is True + + result = await client.signin_with_passkey( + auth_session=exc.value.auth_session, + authn_response=authn_response, + verification={"email": "123456"}, + ) + + assert isinstance(result, PasskeyLoginResult) + first_body, second_body = (call.kwargs["json"] for call in mock_post.call_args_list) + assert first_body["auth_session"] == second_body["auth_session"] == "session_abc123" + assert first_body["authn_response"] == second_body["authn_response"] + assert first_body["verification"] == {"email": "000000"} + assert second_body["verification"] == {"email": "123456"} + + +@pytest.mark.asyncio +async def test_signin_with_passkey_verification_with_dpop_key_sends_both(mocker): + client = _make_passkey_signin_client(mocker) + mocker.patch.object(client, "_get_jwks_cached", return_value={}) + mocker.patch.object(client, "_verify_and_decode_jwt", return_value={ + "sub": "auth0|user123", "iss": "https://auth0.local/" + }) + mock_post = _mock_passkey_post(mocker, 200, _PASSKEY_TOKEN_RESPONSE_DPOP) + + await client.signin_with_passkey( + auth_session="session_abc123", + authn_response=_make_passkey_authn_response(), + dpop_key=jwk.JWK.generate(kty="EC", crv="P-256"), + verification={"email": "123456"}, + ) + + kwargs = mock_post.call_args.kwargs + assert kwargs["json"]["verification"] == {"email": "123456"} + assert "DPoP" in kwargs["headers"] + + # ============================================================================= # IPSIE session_expiry enforcement # =============================================================================