Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
b181166
feat: Added support wrappers for passkey support with identifier
rmad17 Oct 12, 2026
c7c786b
test: add passkey login path tests
rmad17 Oct 13, 2026
5f07057
fix: align passkey wrappers with upstream rate limit and client_ip ch…
rmad17 Oct 19, 2026
d5ceca1
build: require auth0-server-python 1.0.0b20
rmad17 Oct 23, 2026
4e9f0f2
docs: bound the passkey sign-in body in the guide
rmad17 Oct 26, 2026
82c673d
docs: validate the forwarded client IP in the passkey guide
rmad17 Oct 26, 2026
9beb7ee
docs: scope the passkey proxy advice to uvicorn
rmad17 Oct 26, 2026
368e272
docs: request scope and audience on passkey sign-in
rmad17 Oct 26, 2026
1491c79
docs: reword the passkey scope and audience note
rmad17 Oct 26, 2026
8de3969
docs: warn that the passkey sign-in result holds tokens
rmad17 Oct 26, 2026
e3b49a1
docs: add a login credential helper to the passkey guide
rmad17 Oct 26, 2026
f4d1d89
docs: wrap the passkey retry snippet in a function
rmad17 Oct 26, 2026
bd70622
docs: add MFA completion routes to the passkey guide
rmad17 Oct 26, 2026
a7bb13e
test: cover MFA completion on a passkey login
rmad17 Oct 26, 2026
b7c9dee
docs: add organization and DPoP guidance to the passkey guide
rmad17 Oct 26, 2026
d775798
docs: drop unreachable errors from the signin_with_passkey docstring
rmad17 Oct 26, 2026
222c468
fix: return 502 for passkey errors caused by Auth0 or the network
rmad17 Oct 26, 2026
bff1048
revert: keep non-429 passkey errors at the default 400
rmad17 Oct 26, 2026
a470015
test: exercise the passkey methods through the real ServerClient
rmad17 Oct 26, 2026
d873857
test: rename a helper parameter that ruff reads as a password
rmad17 Oct 26, 2026
989382d
docs: say that refreshing DPoP-bound tokens needs a proof
rmad17 Oct 26, 2026
9848ae8
docs: state only the verified DPoP facts in the passkey guide
rmad17 Oct 26, 2026
eebdf78
fix: relay the status Auth0 returned for passkey errors
rmad17 Oct 26, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
436 changes: 436 additions & 0 deletions examples/Passkeys.md

Large diffs are not rendered by default.

8 changes: 4 additions & 4 deletions poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
2 changes: 1 addition & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
@@ -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
Expand Down
143 changes: 142 additions & 1 deletion src/auth0_fastapi/auth/auth_client.py
Original file line number Diff line number Diff line change
@@ -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 (
Expand All @@ -9,6 +9,12 @@
LoginWithCustomTokenExchangeOptions,
LoginWithCustomTokenExchangeResult,
LogoutOptions,
PasskeyAuthResponse,
PasskeyDeliveryMethod,
PasskeyLoginChallengeResponse,
PasskeyLoginResult,
PasskeySignupChallengeResponse,
PasskeyUserProfile,
SessionTransferTokenResult,
StartInteractiveLoginOptions,
TokenExchangeResponse,
Expand All @@ -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:
"""
Expand Down Expand Up @@ -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,
)
17 changes: 17 additions & 0 deletions src/auth0_fastapi/errors/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from typing import Optional

#Imported from auth0-server-python
from auth0_server_python.error import (
AccessTokenError,
Expand All @@ -11,6 +13,8 @@
IssuerValidationError,
MissingRequiredArgumentError,
MissingTransactionError,
PasskeyError,
PasskeyErrorCode,
)
from fastapi import Request
from fastapi.responses import JSONResponse
Expand All @@ -26,13 +30,23 @@ 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.
Maps different Auth0 errors to appropriate HTTP status codes.
"""
# Set a default status code
status_code = 400
headers = None

if isinstance(exc, MissingTransactionError):
status_code = 404 # Not Found
Expand All @@ -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.",
Expand Down
Loading
Loading