Skip to content

feat: passkeys, myaccount with dpop support#116

Open
rmad17 wants to merge 29 commits into
mainfrom
SDK-8780-passkey-feature
Open

feat: passkeys, myaccount with dpop support#116
rmad17 wants to merge 29 commits into
mainfrom
SDK-8780-passkey-feature

Conversation

@rmad17

@rmad17 rmad17 commented Jun 1, 2026

Copy link
Copy Markdown
Contributor

📋 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

  • Passkey sign-in (ServerClient): passkey_signup_challenge,
    passkey_login_challenge, and signin_with_passkey. Call
    passkey_signup_challenge or passkey_login_challenge to get an
    auth_session and WebAuthn options for the browser, then call
    signin_with_passkey with the signed credential to complete sign-in and
    create a session.
  • My Account API — Authentication Methods & Factors (MyAccountClient):
    get_factors, list_authentication_methods, get_authentication_method,
    delete_authentication_method, update_authentication_method,
    enroll_authentication_method, verify_authentication_method. Enrollment
    and verification are generic across types (passkey, email, phone, TOTP,
    push, recovery-code, password) — the type is set via the request model.
  • DPoP (RFC 9449): new DPoPAuth auth scheme. Every passkey and My
    Account method takes an optional dpop_key (EC P-256 key); when supplied,
    the SDK attaches a DPoP proof to the request. Omitting dpop_key uses the
    existing Bearer flow.
  • MFA on passkey sign-in and refresh: signin_with_passkey and
    get_token_by_refresh_token now raise MfaRequiredError when a second
    factor is required, matching get_access_token.

API Changes

  • New public methods: 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.
  • New public models: PasskeyUserProfile, PasskeySignupChallengeResponse,
    PasskeyLoginChallengeResponse, PasskeyAuthResponse,
    PasskeyTokenResponse, PasskeyLoginResult, Factor, GetFactorsResponse,
    AuthenticationMethod, ListAuthenticationMethodsResponse,
    EnrollAuthenticationMethodRequest, EnrollmentChallengeResponse,
    VerifyAuthenticationMethodRequest, UpdateAuthenticationMethodRequest.
  • New error types: PasskeyError, PasskeyErrorCode. Raised by the three
    ServerClient passkey methods. MyAccountClient methods continue to raise
    MyAccountApiError / ApiError.
  • New export: auth_schemes.DPoPAuth.
  • New optional dpop_key parameter on every passkey and My Account method.
  • AuthenticatorResponse.authenticator_type and ChallengeResponse.challenge_type
    are now typed str (previously Literal), so an authenticator/challenge type
    Auth0 adds later no longer raises a ValidationError. No change to values
    returned today.

Deprecation

Deprecation (non-breaking): the AuthenticatorType alias still imports and
resolves to its previous Literal["otp", "oob", "recovery-code"], but now emits
a DeprecationWarning on access and is scheduled for removal in a future major
release. It no longer constrains a model field. Existing imports keep working.

@rmad17 rmad17 self-assigned this Jun 2, 2026
@rmad17 rmad17 marked this pull request as ready for review June 2, 2026 05:38
@rmad17 rmad17 requested a review from a team as a code owner June 2, 2026 05:38
Comment thread src/auth0_server_python/auth_types/__init__.py Outdated
Comment thread src/auth0_server_python/auth_schemes/dpop_auth.py
Comment thread src/auth0_server_python/tests/test_passkey_server_client.py Outdated
Comment thread src/auth0_server_python/tests/test_passkey_my_account.py Outdated
Comment thread src/auth0_server_python/auth_server/server_client.py Outdated
Comment thread src/auth0_server_python/auth_server/server_client.py
Comment thread src/auth0_server_python/auth_server/server_client.py Outdated
Comment thread src/auth0_server_python/auth_server/server_client.py Outdated
Comment thread src/auth0_server_python/auth_server/server_client.py Outdated
Comment thread src/auth0_server_python/auth_server/server_client.py Outdated
Comment thread src/auth0_server_python/auth_server/server_client.py Fixed
@rmad17 rmad17 changed the title Changes for passkey implementation feat: passkeys, myaccount with dpop support Jun 30, 2026
raise IssuerValidationError(
"ID token issuer mismatch. Ensure your Auth0 domain is configured correctly."
)
user_claims = UserClaims.model_validate(claims)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.

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.

Will raise a seperate PR for CTE related fix.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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 ?

nandan-bhat
nandan-bhat previously approved these changes Jul 2, 2026
Comment thread README.md Outdated

For the full enroll/verify ceremony, listing, updating, deleting, and error handling, see [examples/MyAccountAuthenticationMethods.md](examples/MyAccountAuthenticationMethods.md).

### 8. DPoP — Sender-Constrained Tokens

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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?

Comment thread examples/Passkeys.md Outdated
Comment thread src/auth0_server_python/auth_server/server_client.py Outdated
Comment thread src/auth0_server_python/auth_server/server_client.py
mock_response.json = MagicMock(return_value=_PASSKEY_TOKEN_RESPONSE)
mock_post.return_value = mock_response

with pytest.raises(Exception) as exc:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.

Comment thread src/auth0_server_python/auth_types/__init__.py Outdated
Comment thread src/auth0_server_python/auth_schemes/dpop_auth.py Outdated
Comment thread examples/MyAccountAuthenticationMethods.md
authorization_params: Additional OAuth parameters (optional)
"""

subject_token: str

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Can we confirm login_with_custom_token_exchange still enforces the same rules on its own path ?

@nandan-bhat Yes, it does.

Comment on lines +2451 to +2453
# 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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This seems to be like a major strip down. What is the intention or alternate flow to achieve this?

Comment on lines -1103 to -1120
# 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
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This seems to be like a major strip down. What is the intention or alternate flow to achieve this?

Comment thread README.md Outdated
Comment on lines +210 to +228
```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"),
)
```

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

Comment thread README.md Outdated
Comment on lines +183 to +202

```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"]
```

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Same as here

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

@rmad17 Is this intentional?

Comment thread README.md
Comment thread src/auth0_server_python/auth_types/__init__.py Outdated
auth_session: str
authn_params_public_key: PasskeyPublicKeyOptions

def __repr__(self) -> str:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Redaction code added which needs to be removed

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.

Will perform the redaction via Field param.

Comment thread src/auth0_server_python/auth_types/__init__.py Outdated
id_token: Optional[str] = None
refresh_token: Optional[str] = None

@model_validator(mode="before")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.

Comment thread README.md Outdated
Comment on lines +183 to +202

```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"]
```

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

@rmad17 Is this intentional?

Comment thread src/auth0_server_python/auth_schemes/dpop_auth.py Outdated
# MFA STATE
# ============================================================================

async def store_pending_mfa(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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?

Comment thread src/auth0_server_python/auth_server/mfa_client.py Outdated
Comment thread src/auth0_server_python/auth_server/mfa_client.py Outdated
Comment thread src/auth0_server_python/auth_server/mfa_client.py Outdated
MfaEnrollmentError: When enrollment fails.
"""
mfa_token = options["mfa_token"]
context = self.decrypt_mfa_token(self._resolve_encrypted_token(options))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Same as here

MfaChallengeError: When the challenge fails.
"""
mfa_token = options["mfa_token"]
context = self.decrypt_mfa_token(self._resolve_encrypted_token(options))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Same as here

MfaRequiredError: When chained MFA is required.
"""
mfa_token = options["mfa_token"]
context = self.decrypt_mfa_token(self._resolve_encrypted_token(options))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Same as here

raise MfaEnrollmentError(
f"Unexpected error enrolling authenticator: {str(e)}"
)
"Unexpected error enrolling authenticator"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Are we loosing the exact error message store in e here? Please check once

return public_jwk


def make_dpop_proof_for_token_endpoint(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This is nearly a line-for-line duplicate of DPoPAuth._make_proof below

@rmad17 rmad17 requested a review from kishore7snehil July 13, 2026 10:55
MfaListAuthenticatorsError: When the request fails.
"""
mfa_token = options["mfa_token"]
context = self.decrypt_mfa_token(self._resolve_encrypted_token(options))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Yes and that's fine. It would have made more sense if the validation cases list was longer

@rmad17 rmad17 requested a review from kishore7snehil July 13, 2026 14:38
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.

4 participants