Skip to content

feat: add passkey signup and login with identifier verification - #174

Merged
rmad17 merged 23 commits into
mainfrom
feat/passkey-identifier
Oct 26, 2026
Merged

rmad17 merged 23 commits into
mainfrom
feat/passkey-identifier

Conversation

@rmad17

@rmad17 rmad17 commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Adds passkey signup and login to AuthClient, including identifier verification at signup. When a database connection requires email or phone verification, the signup challenge returns verification_required and signin_with_passkey() accepts the codes the user received.

The new methods pass their arguments straight through to ServerClient in auth0-server-python, following the existing custom token exchange methods. The SDK does not mount passkey routes. Apps add their own routes, as shown in the new guide.

Changes

  • AuthClient (src/auth0_fastapi/auth/auth_client.py): three methods added at the end of the class. Each forwards every argument by keyword to ServerClient.
    • passkey_signup_challenge() accepts user_profile, connection, organization, user_metadata, delivery_method ("text" or "voice"), client_ip and store_options. The response includes verification_required when the connection requires verification.
    • passkey_login_challenge() accepts connection, organization, client_ip and store_options.
    • signin_with_passkey() exchanges the credential for tokens and establishes the session for both signup and login. It accepts optional verification codes, connection, organization, scope, audience, dpop_key, client_ip and store_options.
    • client_ip is forwarded as the auth0-forwarded-for header. The SDK does not derive it from the request.
  • Errors (src/auth0_fastapi/errors/__init__.py): re-exports PasskeyError and PasskeyErrorCode. auth0_exception_handler returns a PasskeyError with status_code 429 as HTTP 429 and sets Retry-After when Auth0 sent one. Any other PasskeyError that carries an Auth0 status between 400 and 599 is returned with that same status. A failure with no Auth0 response, such as a network error, stays 400. The response body still carries only error and message, so auth_session never appears in a generic error response.
  • Dependency: raises the minimum auth0-server-python to 1.0.0b20 in pyproject.toml, requirements.txt and poetry.lock.
  • Docs:
    • New examples/Passkeys.md covers:
      • signup and login, with the browser-side helpers for both credentials
      • identifier verification: requirements, collecting codes before creating the credential, the retry loop, retryable vs terminal errors, rate-limit retries and resending codes
      • security considerations, including bounded request models for the sign-in body, forwarding a trusted client IP, and treating auth_session and codes as credentials
      • error-to-status mapping, and completing MFA after a passkey sign-in with the matching routes
      • organizations and DPoP
    • README.md gains a Passkeys feature bullet and a Passkeys section that links to the guide.

Testing

  • test_auth_client.py: new TestPasskey class with 11 tests. They cover:
    • argument forwarding and defaults for all three methods, including client_ip and a login credential
    • dpop_key passed through as the same object
    • retryable and terminal PasskeyError propagation, with auth_session and verification_required intact
    • rate-limit propagation (status_code, retry_after) from both challenges
    • InvalidArgumentError for an unknown verification key
  • test_errors.py (new, 14 tests): 429 with and without Retry-After, Auth0 statuses relaybody leaves out auth_session, and a missing or non-error status staying at 400.
  • test_passkey_http.py (new, 17 tests): runs the real AuthClient and ServerClient against a mocked Auth0 (httpx.MockTransport), so a renamed ServerClient keyword fails them. They cover:
    • the challenge and token requests, including the auth0-forwarded-for header and request
    • 429 handling, the wrong-code retry, organization claim checks and DPoP
    • the stored session after sign-in
    • the MFA path: sign-in stopping at mfa_required, mfa.verify with persist creating the session, and a rejected code leaving none
  • Full suite: 173 passed against auth0-server-python 1.0.0b20 on Python 3.9, 3.10 and 3.12.
  • The guide's Python routes were run against a mocked Auth0 and its JavaScript was syntax-checked and run with stubbed browser APIs. This is not part of the test suite.

Tested flows

# Flow Scenario
1 Happy path Passkey signup (register) and passkey login
2 Organization Passkey signup and login with organization set
3 Global MFA Passkey sign-in with tenant-wide MFA required
4 Step-up MFA Passkey sign-in followed by an MFA challenge for elevated access
5 DPoP Passkey sign-in with dpop_key, issuing DPoP-bound tokens

rmad17 and others added 4 commits October 12, 2026 22:58
Cover signin_with_passkey with a login assertion credential and
rate-limit error propagation from passkey_login_challenge.

Co-Authored-By: Claude <noreply@anthropic.com>
…anges

auth0-server-python removed PasskeyErrorCode.TOO_MANY_REQUESTS and now
reports rate limits through PasskeyError.status_code and retry_after. The
exception handler read the removed attribute, so every PasskeyError raised
AttributeError instead of rendering. It now maps status_code 429 to HTTP
429 and forwards retry_after as Retry-After.

The passkey wrappers forward the new client_ip argument and match the
upstream verification type. The passkey guide covers client_ip trust,
rate limit retries, verification requirements and account existence.

Co-Authored-By: Claude <noreply@anthropic.com>
The passkey wrappers use client_ip, PasskeyError.status_code and
retry_after, and PasskeyDeliveryMethod, which first ship in 1.0.0b20.
Earlier releases fail at import time. Raise the floor in pyproject.toml
and requirements.txt and update the poetry.lock entry for the release.

Co-Authored-By: Claude <noreply@anthropic.com>
@rmad17
rmad17 marked this pull request as ready for review October 23, 2026 17:56
@rmad17
rmad17 requested a review from a team as a code owner October 23, 2026 17:56
NandanPrabhu
NandanPrabhu previously approved these changes Oct 24, 2026
Comment thread examples/Passkeys.md Resolved
Comment thread examples/Passkeys.md Resolved
Comment thread examples/Passkeys.md Resolved
Comment thread examples/Passkeys.md Resolved
Comment thread examples/Passkeys.md Resolved
Comment thread src/auth0_fastapi/errors/__init__.py Outdated
status_code = 400 # Bad Request
elif isinstance(exc, AccessTokenForConnectionError):
status_code = 400 # Bad Request
elif isinstance(exc, PasskeyError) and exc.status_code == 429:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question on the other PasskeyError cases. An Auth0 5xx, a gateway error or a network failure also reaches the browser as 400 (the table in the guide says the same). For the user, a temporary Auth0 outage then looks like a bad request.

Should we return 502 when status_code is None or 500 and above? I feel it is more correct than 400.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would agree that 400 is not correct status code but status code is the platforms call which SDK should not modify.

Comment thread examples/Passkeys.md Resolved
Comment thread examples/Passkeys.md Resolved
Comment thread examples/Passkeys.md Resolved
Comment thread src/auth0_fastapi/auth/auth_client.py Resolved
rmad17 and others added 18 commits October 26, 2026 20:54
SigninRequest used PasskeyAuthResponse directly, which accepts unknown
keys and has no length limits, so an oversized or padded credential was
forwarded to Auth0 unchanged. Add strict AssertionData and AuthnResponse
models with extra="forbid" and max_length, and build PasskeyAuthResponse
from the validated body in the route.

Co-Authored-By: Claude <noreply@anthropic.com>
request.client.host can hold unknown, an ip:port pair or a client-set
X-Forwarded-For entry, and the helper passed it to Auth0 unchanged.
Return the address only when ipaddress.ip_address accepts it. Reword the
proxy advice to never use * for --forwarded-allow-ips, because uvicorn
then trusts the first X-Forwarded-For entry, which the client sets.

Co-Authored-By: Claude <noreply@anthropic.com>
The forwarded-for guidance named uvicorn flags as if every app used
uvicorn. Say it applies when uvicorn is the server and tell readers of
other servers to check which forwarding headers their server trusts.

Co-Authored-By: Claude <noreply@anthropic.com>
signin_with_passkey does not take the Auth0Config audience or scope the
way start_login does. Without openid in the scope no ID token comes
back, so the guide route raised TypeError on state_data["user"], and
without the audience get_access_token cannot find the stored token.
Pass scope and audience in the sign-in route, handle a missing user, and
stop describing the session as identical to /auth/callback.

Co-Authored-By: Claude <noreply@anthropic.com>
Explain how /auth/login and signin_with_passkey differ instead of
instructing the reader, and say why openid and the audience matter.

Co-Authored-By: Claude <noreply@anthropic.com>
PasskeyLoginResult carries the access, refresh and ID tokens and its
repr prints them. Say the route should keep it on the server and never
return or log it.

Co-Authored-By: Claude <noreply@anthropic.com>
The login section only described the response keys, so a copied null
userHandle would fail validation. Add toLoginResponse, which builds the
body from credential.toJSON() and leaves userHandle out when it is empty,
and post the result to the shared sign-in route.

Co-Authored-By: Claude <noreply@anthropic.com>
The identifier verification snippet used return and await at the top
level, so it was not valid JavaScript as written. Wrap the flow in
signUpWithPasskey so the return is legal and the snippet can be pasted
and run.

Co-Authored-By: Claude <noreply@anthropic.com>
A tenant that requires MFA got a bare 401 from the sign-in route and the
guide said the SDK does not store the session, which is wrong for
auth0-server-python 1.0.0b20. Catch MfaRequiredError in the sign-in
route and return the mfa_token, add a verify route that calls mfa.verify
with persist so the SDK creates the session, and note that StatefulStateStore
cannot save the pending MFA state yet.

Co-Authored-By: Claude <noreply@anthropic.com>
Run the real AuthClient and ServerClient against a mocked Auth0 so the
tests catch wiring and release changes that the AsyncMock tests cannot.
Cover sign-in stopping at mfa_required with an encrypted token, mfa.verify
with persist creating the session, the raw token and one-time password
grant sent to Auth0, and a rejected code leaving no session.

Co-Authored-By: Claude <noreply@anthropic.com>
Show how to pass organization to the signup challenge, login challenge
and sign-in calls and why the sign-in call matters. Replace the one-line
DPoP pointer with notes on keeping the key per session on the server,
passing it to mfa.verify, and the fact that get_access_token takes no
dpop_key and sends no proof when it refreshes a token.

Co-Authored-By: Claude <noreply@anthropic.com>
AuthClient never enables mTLS or Enterprise Connect, so ConfigurationError
for dpop_key with mTLS and EnterpriseConnectError cannot reach a caller.

Co-Authored-By: Claude <noreply@anthropic.com>
A PasskeyError from an Auth0 5xx, or from a failure with no HTTP
response such as a network error or an unreadable body, reached the
browser as 400, so a temporary Auth0 outage looked like a bad request.
Map those to 502 like ApiError, keep 429 with Retry-After, and keep
other Auth0 4xx answers as 400. Move the passkey mapping into a helper
to stay under the handler's complexity limit and split the guide's
error table row.

Co-Authored-By: Claude <noreply@anthropic.com>
Reverts 222c468. Which HTTP status an app returns for an Auth0 outage or
a network failure is the server's decision, so the handler only passes
Auth0's own 429 through with Retry-After and leaves other PasskeyErrors
at the default 400.

Co-Authored-By: Claude <noreply@anthropic.com>
The AsyncMock tests only prove AuthClient calls a fake with the given
keywords, so a renamed ServerClient keyword would pass them. Run the real
AuthClient and ServerClient against a mocked Auth0 and check the challenge
and token requests, the forwarded-for header, 429 handling, the wrong-code
retry, organization claim checks, DPoP, and the stored session. Rename the
file from test_passkey_mfa.py because it now covers more than MFA.

Co-Authored-By: Claude <noreply@anthropic.com>
ruff S107 flags a string default on a parameter called token_type. Call it
scheme, which is what the value is.

Co-Authored-By: Claude <noreply@anthropic.com>
The guide only told readers to test the refresh path. Auth0's DPoP
documentation says a refresh request for DPoP-bound tokens requires a
proof made with the same key, and get_access_token takes no dpop_key, so
it sends none. Say so, link the Auth0 section, and tell readers not to
rely on get_access_token to refresh such a session.

Co-Authored-By: Claude <noreply@anthropic.com>
The refresh bullet said Auth0 requires a DPoP proof on refresh, which does
not hold for a confidential client such as this SDK. Keep what is verified,
that get_access_token takes no dpop_key and sends no proof, and the
instruction not to rely on it for a DPoP-bound session. Scope the
PasskeyError check to signin_with_passkey, and note that mfa.verify only
raises when a key is passed and the token comes back unbound.

Co-Authored-By: Claude <noreply@anthropic.com>
A PasskeyError from an Auth0 5xx reached the browser as 400, so an outage
looked like a bad request. Return the status Auth0 sent for any
PasskeyError other than 429, which still adds Retry-After. Relay only 400
to 599, and keep 400 when there is no Auth0 response, such as a network
failure. The status is Auth0's own, so no status is invented. Update the
guide's error table and add tests for the relayed statuses.

Co-Authored-By: Claude <noreply@anthropic.com>
@rmad17
rmad17 merged commit d780720 into main Oct 26, 2026
9 checks passed
@rmad17 rmad17 mentioned this pull request Oct 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants