Changes for passkey implementation#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.
Changes
Passkey Authentication (ServerClient)
MyAccount Credential Management (MyAccountClient)
References