feat: passkeys, myaccount with dpop support#116
Conversation
| client_extension_results: Optional[dict] = Field(None, alias="clientExtensionResults") | ||
|
|
||
|
|
||
| class VerifyAuthenticationMethodRequest(BaseModel): |
There was a problem hiding this comment.
Are these patterns necessary, or generated boilerplate?
A few things on the new models look possibly over-engineered — can you confirm each is intentional?
__repr__redaction ([REDACTED]) is added to the new passkey models, but existing secret-bearing models in this same file (TokenSet,MfaVerifyResponse— both holdaccess_token/refresh_token/id_token) have no such redaction. Either we adopt redaction project-wide or drop it here for consistency; having it only on the new models is inconsistent.@model_validator(_check_at_least_one_method) here — is this a real requirement or speculative validation?model_config = ConfigDict(populate_by_name=True)— what's the reason this is needed on these models? If nothing populates by field name (vs alias), it can be removed.EnrollAuthenticationMethodRequest.typeis a free-formstrwhile this file already usesLiteral[...]for closed sets (AuthenticatorType,OobChannel, etc.).type(andpreferred_authentication_method, which the spec restricts tosms/voice) could beLiteralso bad values fail at construction instead of as a remote API error.- Minor: the enroll/verify docstrings say
create:me:authentication_methods(underscore); the spec scope uses a hyphen —create:me:authentication-methods.
There was a problem hiding this comment.
__repr__redaction - I have not made any changes to pre existing patterns to avoid accidentaly breaking code in a large PR. Though I feel the redaction should be there in all cases. It does not cost us anything rather its a better approach in terms of security.@model_validator (_check_at_least_one_method)This was added for better clarity on the error rather than a generic 400 for better DX. However, this breaks push notifications - Removed it.model_config = ConfigDict(populate_by_name=True)- Copy paste mistake. Since it does not break anything this did not get caught in tests.EnrollAuthenticationMethodRequest.type- Added literals.- Updated the underscore to hyphens .
|
|
||
| def auth_flow(self, request: httpx.Request): | ||
| proof = self._make_proof(request.method, str(request.url)) | ||
| request.headers["Authorization"] = f"DPoP {self._token}" |
There was a problem hiding this comment.
Two DPoP gaps:
DPoPAuthhas noDPoP-Noncehandling. Auth0/Okta commonly responds401+ aDPoP-Nonceheader and expects the proof retried with that nonce, so the first call against a nonce-requiring resource server will fail. Can we track nonce-retry even if it's deferred past GA?- Separately,
signin_with_passkeydoes a plain token POST with no DPoP proof, andPasskeyTokenResponse.token_typedefaults toBearer, but the Test Plan says/oauth/token(webauthn) returns Bearer or DPoP based on tenant setup. If the tenant issues a DPoP-bound token, the SDK ends up holding a token bound to a key it never used. Can we confirm the GA scope for DPoP-bound passkey tokens?
| @@ -0,0 +1,585 @@ | |||
| import time | |||
There was a problem hiding this comment.
Tests for methods that live in server_client.py should be added to test_server_client.py as a new section that mirrors the existing test patterns in that file (same fixtures, mocker.patch("httpx.AsyncClient.post", ...) / auth=ANY style). This file uses a different mocking idiom (patching _get_http_client with a hand-built async context manager) than the rest of the suite. let's keep it consistent.
Also note: the connect-account methods in my_account_client.py were reshaped alongside the formatting pass - please confirm no behavior changed there.
| @@ -0,0 +1,830 @@ | |||
| from unittest.mock import AsyncMock, MagicMock | |||
There was a problem hiding this comment.
Let's not introduce feature-scoped test files (test_passkey_my_account.py, test_passkey_server_client.py) at this point. We can keep using the existing test_my_account_client.py and test_server_client.py until we have a concrete reason to split files. Please fold these tests into the existing files.
| # PASSKEY AUTHENTICATION | ||
| # ============================================================================ | ||
|
|
||
| GRANT_TYPE_PASSKEY = "urn:okta:params:oauth:grant-type:webauthn" |
There was a problem hiding this comment.
Please pull these up to a module/class-level constant rather than constructing them in the middle of the method body, consistent with how the rest of the client references endpoints.
| # ============================================================================ | ||
|
|
||
| GRANT_TYPE_PASSKEY = "urn:okta:params:oauth:grant-type:webauthn" | ||
|
|
There was a problem hiding this comment.
These three methods implement the passkey login/signup ceremony: passkey_signup_challenge / passkey_login_challenge (step 1: get auth_session + the WebAuthn challenge), then the app runs navigator.credentials.create/get, then signin_with_passkey (step 2 :exchange the signed credential for tokens via the webauthn grant). Two questions:
- Can you confirm this is the intended end-to-end flow and that all three methods are exercised by it?
signin_with_passkeyreturns aPasskeyTokenResponsebut, unlike complete_interactive_login, never persists to the state store or creates a session. Is that intentional (caller owns the session), or should passkey sign-in integrate with the SDK's existing session/state handling like every other login path here?
For an RWA SDK whose value-add is session/state management, returning bare tokens reads as an inconsistency.
| if email is not None: | ||
| user_profile["email"] = email | ||
| if name is not None: | ||
| user_profile["name"] = name | ||
| if username is not None: | ||
| user_profile["username"] = username | ||
| if phone_number is not None: | ||
| user_profile["phone_number"] = phone_number | ||
| if given_name is not None: | ||
| user_profile["given_name"] = given_name | ||
| if family_name is not None: | ||
| user_profile["family_name"] = family_name | ||
| if nickname is not None: | ||
| user_profile["nickname"] = nickname | ||
| if picture is not None: | ||
| user_profile["picture"] = picture | ||
| if user_metadata is not None: | ||
| user_profile["user_metadata"] = user_metadata | ||
|
|
||
| body: dict[str, Any] = {"client_id": self._client_id} | ||
| if self._client_secret: | ||
| body["client_secret"] = self._client_secret | ||
| if user_profile: | ||
| body["user_profile"] = user_profile | ||
| if connection: | ||
| body["realm"] = connection | ||
| if organization: | ||
| body["organization"] = organization | ||
|
|
There was a problem hiding this comment.
These if <field> is not None: user_profile[...] = <field> assignments are manually replicating what Pydantic gives for free. If we model user_profile as a typed sub-model on a request object (e.g. PasskeySignupChallengeOptions), this whole block reduces to:
if options.user_profile:
body["user_profile"] = options.user_profile.model_dump(exclude_none=True)
exclude_none=True preserves the current "omit unset keys" behavior, and each field gets real typing instead of an untyped Optional[str] kwarg.
We can use ConfigDict(extra="allow") to the sub-model also which will allow new profile fields pass through without touching this method.
| if organization: | ||
| body["organization"] = organization | ||
|
|
||
| url = f"https://{domain}/passkey/register" |
| "passkey_challenge_error", | ||
| f"Passkey signup challenge failed with status {response.status_code}", | ||
| ) | ||
| raise ApiError( |
There was a problem hiding this comment.
Consider a dedicated PasskeyError(Auth0Error) class (mirroring CustomTokenExchangeError) instead of ApiError with a passkey_challenge_error string code, so callers can catch the type directly.
| except Exception as e: | ||
| if isinstance(e, (ApiError, MissingRequiredArgumentError, ValidationError)): | ||
| raise | ||
| raise ApiError("passkey_challenge_error", "Passkey signup challenge failed", e) |
There was a problem hiding this comment.
When we catch an unexpected error here (like a network timeout) and re-raise it as an ApiError, the original error gets hidden, the caller only sees "Passkey signup challenge failed" and can't tell it was actually a connection problem. Adding from e (raise ApiError(...) from e) keeps the original error linked in the traceback so it's still debuggable.
Same applies to the equivalent line in passkey_login_challenge and signin_with_passkey.
| 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.
| 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 ?
Changes
Passkey Authentication (ServerClient)
MyAccount Credential Management (MyAccountClient)
References