3131from auth0_server_python .auth_server .anonymous import AnonymousClient
3232from auth0_server_python .auth_server .mfa_client import DEFAULT_MFA_TOKEN_TTL , MfaClient
3333from auth0_server_python .auth_server .my_account_client import MyAccountClient
34- from auth0_server_python .auth_server .passwordless_client import PasswordlessClient
34+ from auth0_server_python .auth_server .passwordless_client import (
35+ FORWARDED_FOR_HEADER ,
36+ PasswordlessClient ,
37+ )
3538from auth0_server_python .auth_types import (
3639 CompleteConnectAccountRequest ,
3740 CompleteConnectAccountResponse ,
4750 MfaRequirements ,
4851 MfaVerifyResponse ,
4952 PasskeyAuthResponse ,
53+ PasskeyDeliveryMethod ,
5054 PasskeyLoginChallengeResponse ,
5155 PasskeyLoginResult ,
5256 PasskeySignupChallengeResponse ,
@@ -141,6 +145,7 @@ class ServerClient(Generic[TStoreOptions]):
141145 GRANT_TYPE_PASSKEY = "urn:okta:params:oauth:grant-type:webauthn"
142146 PASSKEY_REGISTER_PATH = "/passkey/register"
143147 PASSKEY_CHALLENGE_PATH = "/passkey/challenge"
148+ PASSKEY_VERIFICATION_IDENTIFIERS = ("email" , "phone" )
144149
145150 # ============================================================================
146151 # INITIALIZATION
@@ -3518,6 +3523,8 @@ async def passkey_signup_challenge(
35183523 organization : Optional [str ] = None ,
35193524 user_metadata : Optional [dict [str , Any ]] = None ,
35203525 store_options : Optional [dict [str , Any ]] = None ,
3526+ delivery_method : Optional [PasskeyDeliveryMethod ] = None ,
3527+ client_ip : Optional [str ] = None ,
35213528 ) -> PasskeySignupChallengeResponse :
35223529 """
35233530 Step 1 of 2: Initiate a passkey signup challenge (POST /passkey/register).
@@ -3533,14 +3540,21 @@ async def passkey_signup_challenge(
35333540 user_metadata: Optional custom metadata added at the root of the request body,
35343541 not nested inside user_profile (per Auth0 API spec).
35353542 store_options: Optional options for domain resolution.
3543+ delivery_method: Optional channel for the phone verification code, "text" or "voice".
3544+ client_ip: Optional end user IP address, sent as the auth0-forwarded-for header.
35363545
35373546 Returns:
3538- PasskeySignupChallengeResponse with auth_session and authn_params_public_key.
3547+ PasskeySignupChallengeResponse with auth_session, authn_params_public_key and,
3548+ when the connection requires verification, verification_required.
35393549
35403550 Raises:
3541- PasskeyError: If the challenge request fails.
3551+ InvalidArgumentError: If delivery_method is set to a value other than "text" or "voice".
3552+ PasskeyError: If the challenge request fails or the request is rate limited.
35423553 EnterpriseConnectError: If the client is configured for Enterprise Connect.
35433554 """
3555+ if delivery_method is not None and delivery_method not in ("text" , "voice" ):
3556+ raise InvalidArgumentError ("delivery_method" , "delivery_method must be 'text' or 'voice'" )
3557+
35443558 try :
35453559 domain = await self ._resolve_current_domain (store_options )
35463560
@@ -3556,22 +3570,29 @@ async def passkey_signup_challenge(
35563570 body ["realm" ] = connection
35573571 if resolved_org :
35583572 body ["organization" ] = resolved_org
3573+ if delivery_method :
3574+ body ["delivery_method" ] = delivery_method
35593575
35603576 async with self ._get_http_client () as client :
35613577 url = f"https://{ domain } { self .PASSKEY_REGISTER_PATH } "
3562- response = await client .post (url , json = body )
3578+ headers = {FORWARDED_FOR_HEADER : client_ip } if client_ip else {}
3579+ response = await client .post (url , json = body , headers = headers )
35633580
35643581 if response .status_code != 200 :
35653582 try :
35663583 error_data = response .json ()
35673584 except (json .JSONDecodeError , ValueError ):
3585+ error_data = None
3586+ if not isinstance (error_data , dict ):
35683587 raise PasskeyError (
35693588 PasskeyErrorCode .CHALLENGE_FAILED ,
35703589 f"Passkey signup challenge failed with status { response .status_code } " ,
3590+ ** self ._passkey_http_error_details (response ),
35713591 )
35723592 raise PasskeyError (
35733593 error_data .get ("error" , PasskeyErrorCode .CHALLENGE_FAILED ),
35743594 error_data .get ("error_description" , "Passkey signup challenge failed" ),
3595+ ** self ._passkey_http_error_details (response ),
35753596 )
35763597
35773598 try :
@@ -3595,6 +3616,7 @@ async def passkey_login_challenge(
35953616 connection : Optional [str ] = None ,
35963617 organization : Optional [str ] = None ,
35973618 store_options : Optional [dict [str , Any ]] = None ,
3619+ client_ip : Optional [str ] = None ,
35983620 ) -> PasskeyLoginChallengeResponse :
35993621 """
36003622 Step 1 of 2: Initiate a passkey login challenge (POST /passkey/challenge).
@@ -3611,12 +3633,13 @@ async def passkey_login_challenge(
36113633 connection: Auth0 database connection name (realm).
36123634 organization: Auth0 organization ID or name.
36133635 store_options: Optional options for domain resolution.
3636+ client_ip: Optional end user IP address, sent as the auth0-forwarded-for header.
36143637
36153638 Returns:
36163639 PasskeyLoginChallengeResponse with auth_session and authn_params_public_key.
36173640
36183641 Raises:
3619- PasskeyError: If the challenge request fails.
3642+ PasskeyError: If the challenge request fails or the request is rate limited .
36203643 EnterpriseConnectError: If the client is configured for Enterprise Connect.
36213644 """
36223645 if username is not None :
@@ -3642,19 +3665,24 @@ async def passkey_login_challenge(
36423665
36433666 async with self ._get_http_client () as client :
36443667 url = f"https://{ domain } { self .PASSKEY_CHALLENGE_PATH } "
3645- response = await client .post (url , json = body )
3668+ headers = {FORWARDED_FOR_HEADER : client_ip } if client_ip else {}
3669+ response = await client .post (url , json = body , headers = headers )
36463670
36473671 if response .status_code != 200 :
36483672 try :
36493673 error_data = response .json ()
36503674 except (json .JSONDecodeError , ValueError ):
3675+ error_data = None
3676+ if not isinstance (error_data , dict ):
36513677 raise PasskeyError (
36523678 PasskeyErrorCode .CHALLENGE_FAILED ,
36533679 f"Passkey login challenge failed with status { response .status_code } " ,
3680+ ** self ._passkey_http_error_details (response ),
36543681 )
36553682 raise PasskeyError (
36563683 error_data .get ("error" , PasskeyErrorCode .CHALLENGE_FAILED ),
36573684 error_data .get ("error_description" , "Passkey login challenge failed" ),
3685+ ** self ._passkey_http_error_details (response ),
36583686 )
36593687
36603688 try :
@@ -3672,6 +3700,24 @@ async def passkey_login_challenge(
36723700 raise
36733701 raise PasskeyError (PasskeyErrorCode .CHALLENGE_FAILED , "Passkey login challenge failed" , e ) from e
36743702
3703+ @staticmethod
3704+ def _passkey_http_error_details (response ) -> dict :
3705+ try :
3706+ retry_after = int (response .headers .get ("Retry-After" ))
3707+ except (TypeError , ValueError ):
3708+ retry_after = None # missing, an HTTP date, or not a number
3709+ if retry_after is not None and retry_after < 0 :
3710+ retry_after = None
3711+ return {"status_code" : response .status_code , "retry_after" : retry_after }
3712+
3713+ @staticmethod
3714+ def _is_dpop_nonce_challenge (response ) -> bool :
3715+ try :
3716+ data = response .json ()
3717+ except ValueError :
3718+ return False
3719+ return isinstance (data , dict ) and data .get ("error" ) == "use_dpop_nonce"
3720+
36753721 async def signin_with_passkey (
36763722 self ,
36773723 auth_session : str ,
@@ -3682,6 +3728,8 @@ async def signin_with_passkey(
36823728 scope : Optional [str ] = None ,
36833729 audience : Optional [str ] = None ,
36843730 dpop_key : Optional ["jwk.JWK" ] = None ,
3731+ verification : Optional [dict [str , Optional [str ]]] = None ,
3732+ client_ip : Optional [str ] = None ,
36853733 ) -> PasskeyLoginResult :
36863734 """
36873735 Completes passkey authentication by exchanging the WebAuthn assertion
@@ -3702,6 +3750,8 @@ async def signin_with_passkey(
37023750 dpop_key: Optional EC P-256 JWK for DPoP-bound token exchange. When provided,
37033751 attaches a DPoP proof header so Auth0 issues a DPoP-bound token
37043752 (token_type: DPoP). Required when the tenant mandates DPoP binding.
3753+ verification: Optional signup codes keyed by "email" or "phone", not used for login.
3754+ client_ip: Optional end user IP address, sent as the auth0-forwarded-for header.
37053755
37063756 Returns:
37073757 PasskeyLoginResult containing state_data with user claims and token sets,
@@ -3711,7 +3761,13 @@ async def signin_with_passkey(
37113761 MissingRequiredArgumentError: If auth_session or authn_response is missing.
37123762 ConfigurationError: If dpop_key is combined with use_mtls. DPoP and mTLS
37133763 use incompatible token-binding mechanisms and cannot be used together.
3714- PasskeyError: If token exchange or session creation fails.
3764+ InvalidArgumentError: If verification has a key other than "email" or "phone",
3765+ or a code that is not a string.
3766+ PasskeyError: If token exchange or session creation fails, or the request is
3767+ rate limited.
3768+ MfaRequiredError: If the tenant requires step-up MFA after the passkey sign-in.
3769+ ApiError: If the ID token fails signature, audience, expiry or JWKS validation.
3770+ IssuerValidationError: If the ID token issuer does not match the tenant.
37153771 OrganizationTokenValidationError: If an organization was requested but the
37163772 token response included no ID token, or the ID token's org claim does
37173773 not match.
@@ -3727,6 +3783,22 @@ async def signin_with_passkey(
37273783 "differently. DPoP would take precedence and the token would not be "
37283784 "certificate-bound."
37293785 )
3786+ if verification is not None and not isinstance (verification , dict ):
3787+ raise InvalidArgumentError (
3788+ "verification" , "verification must be a dict keyed by 'email' or 'phone'"
3789+ )
3790+ verification_codes : dict [str , str ] = {}
3791+ for identifier , code in (verification or {}).items ():
3792+ if identifier not in self .PASSKEY_VERIFICATION_IDENTIFIERS :
3793+ raise InvalidArgumentError (
3794+ "verification" , "verification keys must be 'email' or 'phone'"
3795+ )
3796+ if code is not None and not isinstance (code , str ):
3797+ raise InvalidArgumentError (
3798+ "verification" , f"the '{ identifier } ' verification code must be a string"
3799+ )
3800+ if code and code .strip ():
3801+ verification_codes [identifier ] = code
37303802
37313803 try :
37323804 domain = await self ._resolve_current_domain (store_options )
@@ -3755,23 +3827,23 @@ async def signin_with_passkey(
37553827 body ["scope" ] = scope
37563828 if audience :
37573829 body ["audience" ] = audience
3830+ if verification_codes :
3831+ body ["verification" ] = verification_codes
37583832
37593833 async with self ._get_http_client () as client :
3760- headers = {}
3834+ headers = {FORWARDED_FOR_HEADER : client_ip } if client_ip else { }
37613835 if dpop_key is not None :
37623836 headers ["DPoP" ] = make_dpop_proof_for_token_endpoint (
37633837 dpop_key , "POST" , token_endpoint
37643838 )
37653839 response = await client .post (token_endpoint , json = body , headers = headers )
37663840
3767- # RFC 9449 — the authorization server signals a required nonce
3768- # with HTTP 400 + error="use_dpop_nonce" + DPoP-Nonce. Accept
3769- # 401 as well so the retry holds against servers that mirror the
3770- # resource-server status.
3841+ # RFC 9449 nonce challenge: HTTP 400 or 401 with error use_dpop_nonce and DPoP-Nonce.
37713842 if (
37723843 dpop_key is not None
37733844 and response .status_code in (400 , 401 )
37743845 and response .headers .get ("DPoP-Nonce" )
3846+ and self ._is_dpop_nonce_challenge (response )
37753847 ):
37763848 nonce = response .headers ["DPoP-Nonce" ]
37773849 headers ["DPoP" ] = make_dpop_proof_for_token_endpoint (
@@ -3783,9 +3855,12 @@ async def signin_with_passkey(
37833855 try :
37843856 error_data = response .json ()
37853857 except (json .JSONDecodeError , ValueError ):
3858+ error_data = None
3859+ if not isinstance (error_data , dict ):
37863860 raise PasskeyError (
37873861 PasskeyErrorCode .TOKEN_EXCHANGE_FAILED ,
37883862 f"Passkey token exchange failed with status { response .status_code } " ,
3863+ ** self ._passkey_http_error_details (response ),
37893864 )
37903865 error_code = error_data .get ("error" , PasskeyErrorCode .TOKEN_EXCHANGE_FAILED )
37913866 if error_code == "mfa_required" :
@@ -3803,6 +3878,9 @@ async def signin_with_passkey(
38033878 raise PasskeyError (
38043879 error_code ,
38053880 error_data .get ("error_description" , "Passkey token exchange failed" ),
3881+ auth_session = error_data .get ("auth_session" ),
3882+ verification_required = error_data .get ("verification_required" ),
3883+ ** self ._passkey_http_error_details (response ),
38063884 )
38073885
38083886 try :
0 commit comments