Repository navigation
Conversation
…-server-python into feat/passkey-id-verification
Co-Authored-By: Claude <noreply@anthropic.com>
|
|
||
| error = await _signin_expecting_error(client) | ||
|
|
||
| assert error.code == "invalid_request" |
There was a problem hiding this comment.
good case if invalid_request is returned and auth_session is present then it is retryable
There was a problem hiding this comment.
I think we should keep it as it is. Auth0 never sends auth_session with invalid_request. For a missing code the body has only error and error_description, but the session is still valid, so the app can retry with the same auth_session. The docs already say this.
invalid_grant with auth_session is also the rule in auth-js (isRetryable), so the SDKs behave the same.
There was a problem hiding this comment.
Leaving it as it is for now.
| url = f"https://{domain}{self.PASSKEY_REGISTER_PATH}" | ||
| response = await client.post(url, json=body) | ||
|
|
||
| if response.status_code == 429: |
There was a problem hiding this comment.
This changes the code we raise for a 429. Before this PR, the token call gave access_denied, and a plain text 429 on the challenge calls gave passkey_challenge_error. Now all of them give too_many_requests.
As per the repo guidelines (the Ask First section), changing the code of an existing error is a breaking change and needs an ok from the maintainers first. I am fine with too_many_requests since it matches PasswordlessErrorCode.TOO_MANY_REQUESTS. Can we get that sign off here and add a line in the release notes?
Also, the "Behavior changes" list in the PR description does not mention passkey_signup_challenge currently, will be updating it.
There was a problem hiding this comment.
Thanks for flagging this. I went with the non-breaking option, so there's no code change to sign off on. A 429 now raises the same code and message as before this PR: the server's error and error_description when the body is JSON (for example access_denied from the token endpoint limits), or passkey_challenge_error / passkey_token_error when it isn't.
To detect a rate limit, PasskeyError now has status_code (the HTTP status of the error response) and retry_after, so the check is e.status_code == 429. Both are new attributes that default to None. That matches auth0-server-js#307, which keeps the server's error and checks statusCode === 429. I removed PasskeyErrorCode.TOO_MANY_REQUESTS. It was never released, and it suggested a single code that the plain-text and access_denied responses don't use. Fixed in 79af3b9.
| organization: Optional[str] = None, | ||
| user_metadata: Optional[dict[str, Any]] = None, | ||
| store_options: Optional[dict[str, Any]] = None, | ||
| delivery_method: Optional[PasskeyDeliveryMethod] = None, |
There was a problem hiding this comment.
In the PR description we decided not to forward the end user IP. But for confidential clients with the "Trust Token Endpoint IP Header" setting, Auth0 uses auth0-forwarded-for as the end user IP. Our own Passwordless.md explains this, and passwordless already has client_ip for it.
With identifier verification every signup sends a code, so this matters more now. Without it all of them look like they come from our server IP.
Can we add the same optional client_ip to the three passkey methods? A follow up PR is also fine, but then please update the reason in the description.
There was a problem hiding this comment.
Added in this PR (c757b4d). passkey_signup_challenge, passkey_login_challenge and signin_with_passkey take an optional client_ip and send it as auth0-forwarded-for, using the same header constant as passwordless. The header is left out when client_ip isn't set. The Operational notes in Passkeys.md explain the Trust Token Endpoint IP Header setting and say to forward only a trusted, normalized IP, because with identifier verification on, Auth0 rejects a value that isn't a valid IP address.
| if resolved_org: | ||
| body["organization"] = resolved_org | ||
| if delivery_method: | ||
| body["delivery_method"] = delivery_method |
There was a problem hiding this comment.
Small one. PreferredAuthMethod in the same module uses "sms", so it is easy to pass delivery_method="sms" here by mistake.
Today we send it as it is and Auth0 rejects it with invalid_request. A quick check for "text" or "voice" will give a clearer error.
| "certificate-bound." | ||
| ) | ||
| verification_codes: dict[str, str] = {} | ||
| for identifier, code in (verification or {}).items(): |
There was a problem hiding this comment.
If someone passes a string or a list here, this fails with AttributeError: 'str' object has no attribute 'items' instead of InvalidArgumentError. Can we check isinstance(verification, dict) first?
Also, the type hint says dict[str, str], but None values are allowed and tested. dict[str, Optional[str]], or a small TypedDict with email and phone, will be more correct.
| headers["DPoP"] = make_dpop_proof_for_token_endpoint( | ||
| dpop_key, "POST", token_endpoint, nonce=nonce | ||
| ) | ||
| response = await client.post(token_endpoint, json=body, headers=headers) |
There was a problem hiding this comment.
Not from this PR, but it matters more now. This retry runs for any 400 or 401 with a DPoP-Nonce header, without checking for use_dpop_nonce. RFC 9449 allows the server to send a new nonce on other responses also.
Today we never reuse an old nonce, so it does not happen. But if nonce caching is added later, a wrong code will be sent twice and will use two attempts. Can we check error == "use_dpop_nonce" before retrying?
| if not e.is_verification_retryable or attempt == MAX_ATTEMPTS - 1: | ||
| raise | ||
| auth_session = e.auth_session | ||
| codes = ask_user_for_codes(e.verification_required) |
There was a problem hiding this comment.
This loop is fine for a script, but in a web app the new code comes in a new HTTP request from the browser. ask_user_for_codes() cannot wait inside the same request.
Can we show it as two requests? The first one returns verification_required to the browser. The next one calls signin_with_passkey again with the auth_session from the error and the same credential.
There was a problem hiding this comment.
Done. The loop is gone, and the example is now a handler that makes one signin_with_passkey call per request. On a wrong code it returns a 400 JSONResponse with verification_required and the auth_session from the error, and the browser asks for those codes only and posts again with the same credential. On success it returns no tokens, since the session is already stored server side. The guide also says to cap attempts in the app and to send one request at a time per auth_session. Fixed in 6068115 and d5cd0d2.
| - 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. | ||
| - Any other failure is raised with the code Auth0 returned. |
There was a problem hiding this comment.
Can we add the 429 case to this list also? For signin_with_passkey, Auth0 checks the rate limit before it uses the session, so the session is still valid after a too_many_requests error.
The app can wait and send the same auth_session, credential and codes again. With the loop above it will raise instead, and the user may start a new signup, which sends new codes again.
There was a problem hiding this comment.
@nandan-bhat 429 is a terminal error right?
There was a problem hiding this comment.
Added the 429 case to the list. The app waits (retry_after seconds when Auth0 sends Retry-After) and resends the same auth_session, credential and codes. The guide says to check status_code == 429 rather than code, because the code depends on which limit was hit. The token endpoint limits answer with access_denied, not too_many_requests (see the error-code thread in server_client.py). Fixed in 6068115, 15e9640 and 79af3b9.
@NandanPrabhu, I believe is that it isn't terminal. The /oauth/token rate limits run before client authentication and before the passkey grant claims the auth_session, so a 429 should leave the session and its attempts untouched. The one limit would be if the session expires before the app resends, the app starts again from the signup challenge. Is there a case where a 429 does end the session?
| - 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. | ||
| - Any other failure is raised with the code Auth0 returned. | ||
|
|
||
| If the connection drops after Auth0 accepts the final code, the session has ended because each code works once. Start again from the signup challenge. |
There was a problem hiding this comment.
If the response is lost after Auth0 accepts the final code, the user is most likely created already. Starting the signup again with the same email then fails with invalid_request ("User signup failed"), so the user gets stuck.
The same happens when a Post Login Action denies the login (access_denied with a 500), because the user is created before the Actions run.
Can we say here that the account may already exist, so the app should offer sign in instead of a new signup?
There was a problem hiding this comment.
Added after the list. It covers both cases you named: the connection drops after Auth0 accepts the final code, or a Post Login Action denies the login. In both the account may already exist, because Auth0 creates the user before Actions run, so a new signup with the same email fails with invalid_request. The app should show a generic message that offers sign in, without confirming whether the account exists. I also noted that invalid_request covers invalid input too, so apps shouldn't read it alone as "the account exists". Fixed in 6068115 and 7989937.
| @pytest.mark.asyncio | ||
| async def test_passkey_signup_challenge_rate_limited_json_raises_too_many_requests(mocker): | ||
| client = _make_passkey_client() | ||
| _mock_passkey_post(mocker, 429, {"error": "rate_limit_exceeded", "error_description": "slow down"}) |
There was a problem hiding this comment.
Small thing. Auth0 does not send rate_limit_exceeded. For the challenge endpoints a 429 is either plain text or too_many_requests (same for the access_denied body at line 9552). Not a blocker since we ignore the body now, but real bodies make these tests easier to trust.
Can we also add one test which checks that verification goes on the wire as a JSON object? All the tests here patch AsyncClient.post, so the encoding is never checked. auth-js had this exact bug.
There was a problem hiding this comment.
Done. rate_limit_exceeded is gone, and the 429 test now uses the bodies you listed: plain text or too_many_requests for the two challenges, and plain text or access_denied ("Too Many Requests") for the token call. The bodies matter again now that the error code comes from Auth0's response, and the test asserts that code plus status_code == 429 for each case.
test_signin_with_passkey_verification_sent_as_json_object_on_the_wire patches _get_http_client to return a real httpx.AsyncClient on an httpx.MockTransport, so httpx does the encoding. It parses the actual request bytes and asserts the application/json content type and that verification is a JSON object with the codes. Fixed in c757b4d and 79af3b9.
|
|
||
| error = await _signin_expecting_error(client) | ||
|
|
||
| assert error.code == "invalid_request" |
There was a problem hiding this comment.
I think we should keep it as it is. Auth0 never sends auth_session with invalid_request. For a missing code the body has only error and error_description, but the session is still valid, so the app can retry with the same auth_session. The docs already say this.
invalid_grant with auth_session is also the rule in auth-js (isRetryable), so the SDKs behave the same.
|
|
||
| > [!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. |
There was a problem hiding this comment.
@rmad17 are these changes necessary? these look like fixing '-' symbol
There was a problem hiding this comment.
Not necessary but good to have.
- We usually avoid putting em/en dashes in the repo.
- Tier0 is not a Auth0 classification, and is misleading here.
| ```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 |
There was a problem hiding this comment.
we can add that in case developer passes voice for email the flow will still succeed with text but delivery method is specifically for phone identifier
There was a problem hiding this comment.
Added below the example: "delivery_method applies only to the phone code. If only email needs a code, the value has no effect." The same paragraph now also says that any value other than "text" or "voice" raises InvalidArgumentError before the request is sent (see the delivery_method thread in server_client.py). Fixed in 6068115 and 79af3b9.
Add the verification flow, resend and wrong-code handling, and operational notes. Correct the claim that collecting codes first prevents a saved passkey, since Auth0 checks codes only in signin_with_passkey. Add the client_ip option and the rate-limit and lost-response guidance. Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
- Add optional client_ip to the three passkey methods, sent as auth0-forwarded-for. - Reject a delivery_method other than text or voice, and a non-dict verification, before any request. - Retry the DPoP nonce on the passkey token call only for use_dpop_nonce. - Add retry_after to PasskeyError and keep Auth0's error_description on a 429. - Hide input from validation errors on the passkey challenge and token models. - Use realistic 429 bodies in tests and check verification is sent as a JSON object. Co-Authored-By: Claude <noreply@anthropic.com>
A 429 now raises the same code as before (the server's error, or the SDK default for a non-JSON body) instead of a new too_many_requests code. PasskeyError gains status_code and retry_after on every non-200 response, so callers detect a rate limit with status_code == 429. A JSON error body that is not an object keeps the default code and the status. Removes the unreleased PasskeyErrorCode.TOO_MANY_REQUESTS and updates the passkey guide for status_code, retry_after, invalid_grant and input validation. Co-Authored-By: Claude <noreply@anthropic.com>
| retry_after = None | ||
| try: | ||
| retry_after = int(response.headers.get("Retry-After")) | ||
| except (TypeError, ValueError): |
Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
Summary
Native passkey signup can now verify a user's email or phone before any tokens are issued. When a connection requires it, the signup challenge tells you which identifiers need a code, and the sign-in call takes the codes the user entered. Connections that don't require verification follow the same flow as before.
A wrong code doesn't end the signup. Auth0 keeps the session alive, and PasskeyError now carries the details a caller needs to re-prompt for just the identifiers still unverified. Every error response from the three passkey calls also sets status_code and retry_after on PasskeyError, so a caller detects a rate limit with e.status_code == 429. The three passkey methods also take an optional client_ip, which is forwarded as the auth0-forwarded-for header.
Behavior changes to be aware of:
Changelog
Requests
Added
Changed
Testing
poetry run pytest passes (837 tests) and poetry run ruff check . is clean.
New tests cover:
Manually Tested Flows
delivery_method: text