feat: passkeys, myaccount with dpop support#116
Conversation
| raise IssuerValidationError( | ||
| "ID token issuer mismatch. Ensure your Auth0 domain is configured correctly." | ||
| ) | ||
| user_claims = UserClaims.model_validate(claims) |
There was a problem hiding this comment.
We forward organization to the passkey grant above, but here we decode the ID token and never validate the org claim. The complete_interactive_login path in this same PR calls validate_org_claims(claims, expected_org) for exactly this reason, and the README says org claim validation is enforced automatically at callback.
As it stands, a passkey login pinned to an organization does not actually verify the returned token belongs to that org. In a multi org tenant that is an authorization gap. Can we add a validate_org_claims call here when organization was passed, and a test that asserts a mismatch raises OrganizationTokenValidationError and no session is stored ?
| if connection: | ||
| body["realm"] = connection | ||
| if organization: | ||
| body["organization"] = organization |
There was a problem hiding this comment.
The constructor stores self._organization and start_interactive_login uses options.organization or self._organization. The passkey methods only read the local organization argument, so the client level default is silently ignored here.
This means a client configured with a default org gets it applied to interactive logins but not to passkey logins, which is surprising. Can we apply the self._organization fallback here too so the behavior is consistent ?
| ): | ||
| nonce = response.headers["DPoP-Nonce"] | ||
| request.headers["DPoP"] = self._make_proof(request.method, str(request.url), nonce=nonce) | ||
| yield request |
There was a problem hiding this comment.
The nonce retry re-yields the same request object after it was already sent once. For the MyAccount calls that POST or PATCH a JSON body, I want to make sure the body is actually re-sent on the retry and not consumed.
The token endpoint path in signin_with_passkey sidesteps this by rebuilding the request with a fresh client.post(...), but here we reuse the instance. On tenants that require a nonce the first call always 401s and retries, so this path runs every session. Could we add a test that drives a real bodied POST through DPoPAuth against a mock that returns 401 then 200, and asserts the second request still carries the original body ? If httpx does not re-stream it, we should rebuild the request instead.
| audience=audience or self.DEFAULT_AUDIENCE_STATE_KEY, | ||
| access_token=token_response.access_token, | ||
| scope=token_response.scope or scope or "", | ||
| expires_at=token_response.expires_at if token_response.expires_at is not None else int(time.time()) + token_response.expires_in, |
There was a problem hiding this comment.
Small one. expires_in is a required int on PasskeyTokenResponse, so by the time we get here token_response.expires_in is always an int and the earlier block already backfilled expires_at. The else int(time.time()) + token_response.expires_in branch cannot really be reached with a None, so this reads as dead defensive code.
If a server that returns only expires_at without expires_in is a real case, the model would reject it before this line anyway. Can we either make expires_in optional and keep one fallback, or drop the redundant branch ?
| authorization_params: Additional OAuth parameters (optional) | ||
| """ | ||
|
|
||
| subject_token: str |
There was a problem hiding this comment.
This drops the field_validator and model_validator (Bearer prefix, whitespace, actor_token_type) and the min_length=1 constraint, and the checks were moved inline into custom_token_exchange. That means anyone building this model directly no longer gets validation at construction, and it only fires inside that one method.
Can we confirm login_with_custom_token_exchange still enforces the same rules on its own path ? If there was a specific reason for moving these out of the model (for example a Pydantic v2 change), it would help to note it in the PR description. Otherwise keeping them on the model is entry point agnostic and avoids duplicating the logic.
There was a problem hiding this comment.
Will raise a seperate PR for CTE related fix.
There was a problem hiding this comment.
Can we confirm login_with_custom_token_exchange still enforces the same rules on its own path ?
@nandan-bhat Yes, it does.
| PasskeyLoginChallengeResponse with auth_session and authn_params_public_key. | ||
|
|
||
| Raises: | ||
| ApiError: If the challenge request fails. |
There was a problem hiding this comment.
The Raises section says ApiError, but the method body raises PasskeyError on a failed challenge. passkey_signup_challenge documents PasskeyError correctly. Can we fix this to PasskeyError ?
|
|
||
| For the full enroll/verify ceremony, listing, updating, deleting, and error handling, see [examples/MyAccountAuthenticationMethods.md](examples/MyAccountAuthenticationMethods.md). | ||
|
|
||
| ### 8. DPoP — Sender-Constrained Tokens |
There was a problem hiding this comment.
We should explicitly mention that this is with passkeys in the heading so that the users aren't confused or exclusively keep this section in Passkeys.md?
| mock_response.json = MagicMock(return_value=_PASSKEY_TOKEN_RESPONSE) | ||
| mock_post.return_value = mock_response | ||
|
|
||
| with pytest.raises(Exception) as exc: |
There was a problem hiding this comment.
This catches broad Exception and only checks that "issuer" shows up in the message, though the docstring says it must raise IssuerValidationError. It would pass on an unrelated KeyError/AttributeError whose message happens to mention issuer. pytest.raises(IssuerValidationError) would pin the actual contract.
A couple of the passkey error tests nearby also lean on assert X in str(exc) or Y in str(exc), asserting on exc.value.code instead would keep them from passing under the wrong failure mode.
| authorization_params: Additional OAuth parameters (optional) | ||
| """ | ||
|
|
||
| subject_token: str |
There was a problem hiding this comment.
Can we confirm login_with_custom_token_exchange still enforces the same rules on its own path ?
@nandan-bhat Yes, it does.
| # A genuinely absent/optional act claim or a benign decode | ||
| # gap leaves act None. Anything outside these types (an | ||
| # unexpected verify failure) surfaces rather than being masked. |
There was a problem hiding this comment.
We can trim down this comment to a crisp version.
| scope=merged_scope or "", | ||
| default_description="MFA required", | ||
| ) | ||
| error.mfa_token = error_data.get("mfa_token") |
There was a problem hiding this comment.
This seems to be like a major strip down. What is the intention or alternate flow to achieve this?
| # Check for mfa_required error from token refresh | ||
| if isinstance(e, ApiError) and e.code == "mfa_required": | ||
| raw_mfa_token = getattr(e, "mfa_token", None) | ||
| mfa_requirements = getattr(e, "mfa_requirements", None) | ||
|
|
||
| if raw_mfa_token: | ||
| encrypted_token = self._mfa_client._encrypt_mfa_token( | ||
| raw_mfa_token=raw_mfa_token, | ||
| audience=audience or self.DEFAULT_AUDIENCE_STATE_KEY, | ||
| scope=merged_scope or "", | ||
| mfa_requirements=mfa_requirements | ||
| ) | ||
| raise MfaRequiredError( | ||
| "Multifactor authentication required", | ||
| mfa_token=encrypted_token, | ||
| mfa_requirements=mfa_requirements | ||
| ) | ||
|
|
There was a problem hiding this comment.
This seems to be like a major strip down. What is the intention or alternate flow to achieve this?
| ```python | ||
| from auth0_server_python.auth_server.my_account_client import MyAccountClient | ||
| from auth0_server_python.auth_types import EnrollAuthenticationMethodRequest | ||
|
|
||
| # Obtain a My Account-scoped token for the current session (MRRT) | ||
| access_token = await auth0.get_access_token( | ||
| store_options={"request": request, "response": response}, | ||
| audience=f"https://{YOUR_CUSTOM_DOMAIN}/me/", | ||
| scope="create:me:authentication-methods read:me:authentication-methods", | ||
| ) | ||
|
|
||
| my_account = MyAccountClient(domain=YOUR_CUSTOM_DOMAIN) | ||
|
|
||
| # Start enrolling a passkey (then sign it in the browser and verify) | ||
| challenge = await my_account.enroll_authentication_method( | ||
| access_token=access_token, | ||
| request=EnrollAuthenticationMethodRequest(type="passkey"), | ||
| ) | ||
| ``` |
There was a problem hiding this comment.
Is the same code snippet which is already there in the .md file. If yes, please omit it from here as it will be a dupllication
|
|
||
| ```python | ||
| from auth0_server_python.auth_types import PasskeyUserProfile, PasskeyAuthResponse | ||
|
|
||
| # Step 1 — request a challenge | ||
| challenge = await auth0.passkey_login_challenge( | ||
| store_options={"request": request, "response": response} | ||
| ) | ||
|
|
||
| # Step 2 — browser signs: navigator.credentials.get(challenge.authn_params_public_key) | ||
|
|
||
| # Step 3 — complete sign-in and establish the session | ||
| result = await auth0.signin_with_passkey( | ||
| auth_session=challenge.auth_session, | ||
| authn_response=PasskeyAuthResponse(**credential), | ||
| store_options={"request": request, "response": response} | ||
| ) | ||
|
|
||
| user = result.state_data["user"] | ||
| ``` |
| auth_session: str | ||
| authn_params_public_key: PasskeyPublicKeyOptions | ||
|
|
||
| def __repr__(self) -> str: |
There was a problem hiding this comment.
Redaction code added which needs to be removed
There was a problem hiding this comment.
Will perform the redaction via Field param.
| id_token: Optional[str] = None | ||
| refresh_token: Optional[str] = None | ||
|
|
||
| @model_validator(mode="before") |
There was a problem hiding this comment.
Every other token path in the SDK computes expires_at in the client method right after the HTTP response (see the int(time.time()) + expires_in spots in server_client.py). This is the only place that backfills it inside the model via a mode="before" validator. We should be consistent.
|
|
||
| ```python | ||
| from auth0_server_python.auth_types import PasskeyUserProfile, PasskeyAuthResponse | ||
|
|
||
| # Step 1 — request a challenge | ||
| challenge = await auth0.passkey_login_challenge( | ||
| store_options={"request": request, "response": response} | ||
| ) | ||
|
|
||
| # Step 2 — browser signs: navigator.credentials.get(challenge.authn_params_public_key) | ||
|
|
||
| # Step 3 — complete sign-in and establish the session | ||
| result = await auth0.signin_with_passkey( | ||
| auth_session=challenge.auth_session, | ||
| authn_response=PasskeyAuthResponse(**credential), | ||
| store_options={"request": request, "response": response} | ||
| ) | ||
|
|
||
| user = result.state_data["user"] | ||
| ``` |
| # MFA STATE | ||
| # ============================================================================ | ||
|
|
||
| async def store_pending_mfa( |
There was a problem hiding this comment.
I can see this function used only once. Can we not include the function's logic there itself? Do we anticipate need of this helper function in future?
| MfaEnrollmentError: When enrollment fails. | ||
| """ | ||
| mfa_token = options["mfa_token"] | ||
| context = self.decrypt_mfa_token(self._resolve_encrypted_token(options)) |
| MfaChallengeError: When the challenge fails. | ||
| """ | ||
| mfa_token = options["mfa_token"] | ||
| context = self.decrypt_mfa_token(self._resolve_encrypted_token(options)) |
| MfaRequiredError: When chained MFA is required. | ||
| """ | ||
| mfa_token = options["mfa_token"] | ||
| context = self.decrypt_mfa_token(self._resolve_encrypted_token(options)) |
| raise MfaEnrollmentError( | ||
| f"Unexpected error enrolling authenticator: {str(e)}" | ||
| ) | ||
| "Unexpected error enrolling authenticator" |
There was a problem hiding this comment.
Are we loosing the exact error message store in e here? Please check once
| return public_jwk | ||
|
|
||
|
|
||
| def make_dpop_proof_for_token_endpoint( |
There was a problem hiding this comment.
This is nearly a line-for-line duplicate of DPoPAuth._make_proof below
| MfaListAuthenticatorsError: When the request fails. | ||
| """ | ||
| mfa_token = options["mfa_token"] | ||
| context = self.decrypt_mfa_token(self._resolve_encrypted_token(options)) |
There was a problem hiding this comment.
Yes and that's fine. It would have made more sense if the validation cases list was longer
📋 Changes
This PR adds Passkey Authentication and My Account API Authentication Methods
support to auth0-server-python. Passkey sign-in is a two-step challenge/verify
ceremony that establishes a server-side session. The My Account surface lets a
logged-in user enroll, list, update, and delete their own authentication
methods. Both surfaces support DPoP (RFC 9449) sender-constrained tokens via
an optional parameter.
Features
ServerClient):passkey_signup_challenge,passkey_login_challenge, andsignin_with_passkey. Callpasskey_signup_challengeorpasskey_login_challengeto get anauth_sessionand WebAuthn options for the browser, then callsignin_with_passkeywith the signed credential to complete sign-in andcreate a session.
MyAccountClient):get_factors,list_authentication_methods,get_authentication_method,delete_authentication_method,update_authentication_method,enroll_authentication_method,verify_authentication_method. Enrollmentand verification are generic across types (passkey, email, phone, TOTP,
push, recovery-code, password) — the type is set via the request model.
DPoPAuthauth scheme. Every passkey and MyAccount method takes an optional
dpop_key(EC P-256 key); when supplied,the SDK attaches a DPoP proof to the request. Omitting
dpop_keyuses theexisting Bearer flow.
signin_with_passkeyandget_token_by_refresh_tokennow raiseMfaRequiredErrorwhen a secondfactor is required, matching
get_access_token.API Changes
ServerClient.passkey_signup_challenge,passkey_login_challenge,signin_with_passkey;MyAccountClient.get_factors,list_authentication_methods,get_authentication_method,delete_authentication_method,update_authentication_method,enroll_authentication_method,verify_authentication_method.PasskeyUserProfile,PasskeySignupChallengeResponse,PasskeyLoginChallengeResponse,PasskeyAuthResponse,PasskeyTokenResponse,PasskeyLoginResult,Factor,GetFactorsResponse,AuthenticationMethod,ListAuthenticationMethodsResponse,EnrollAuthenticationMethodRequest,EnrollmentChallengeResponse,VerifyAuthenticationMethodRequest,UpdateAuthenticationMethodRequest.PasskeyError,PasskeyErrorCode. Raised by the threeServerClientpasskey methods.MyAccountClientmethods continue to raiseMyAccountApiError/ApiError.auth_schemes.DPoPAuth.dpop_keyparameter on every passkey and My Account method.AuthenticatorResponse.authenticator_typeandChallengeResponse.challenge_typeare now typed
str(previouslyLiteral), so an authenticator/challenge typeAuth0 adds later no longer raises a
ValidationError. No change to valuesreturned today.
Deprecation
Deprecation (non-breaking): the
AuthenticatorTypealias still imports andresolves to its previous
Literal["otp", "oob", "recovery-code"], but now emitsa
DeprecationWarningon access and is scheduled for removal in a future majorrelease. It no longer constrains a model field. Existing imports keep working.