Skip to content

Commit ef3e8ad

Browse files
authored
Merge pull request #183 from auth0/feat/passkey-id-verification
feat: passkey id verification
2 parents db7a49e + 475ecf0 commit ef3e8ad

7 files changed

Lines changed: 1027 additions & 49 deletions

File tree

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -246,7 +246,7 @@ For more details and examples, see [examples/RetrievingData.md](examples/Retriev
246246

247247
### 7. Passkey Authentication
248248

249-
Sign users up or in with [WebAuthn](https://www.w3.org/TR/webauthn-2/) passkeys (Touch ID, Face ID, Windows Hello, or a security key) instead of a password, via [Auth0 passkeys](https://auth0.com/docs/authenticate/database-connections/passkeys). The ceremony is two steps — request a challenge, sign it in the browser, then complete sign-in — and establishes a server-side session like every other login path. For the signup and login flows, organizations, step-up MFA, and error handling, see [examples/Passkeys.md](examples/Passkeys.md).
249+
Sign users up or in with [WebAuthn](https://www.w3.org/TR/webauthn-2/) passkeys (Touch ID, Face ID, Windows Hello, or a security key) instead of a password, via [Auth0 passkeys](https://auth0.com/docs/authenticate/database-connections/passkeys). The ceremony is two steps - request a challenge, sign it in the browser, then complete sign-in - and establishes a server-side session like every other login path. For the signup and login flows, organizations, step-up MFA, [verifying an email or phone at signup](examples/Passkeys.md#4-identifier-verification-optional), and error handling, see [examples/Passkeys.md](examples/Passkeys.md).
250250

251251
### 8. My Account API — Authentication Methods
252252

‎examples/Passkeys.md‎

Lines changed: 143 additions & 31 deletions
Large diffs are not rendered by default.

‎src/auth0_server_python/auth_server/server_client.py‎

Lines changed: 90 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,10 @@
3131
from auth0_server_python.auth_server.anonymous import AnonymousClient
3232
from auth0_server_python.auth_server.mfa_client import DEFAULT_MFA_TOKEN_TTL, MfaClient
3333
from 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+
)
3538
from auth0_server_python.auth_types import (
3639
CompleteConnectAccountRequest,
3740
CompleteConnectAccountResponse,
@@ -47,6 +50,7 @@
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:

‎src/auth0_server_python/auth_types/__init__.py‎

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
"passkey", "email", "phone", "totp", "push-notification", "recovery-code", "password"
2222
]
2323
PreferredAuthMethod = Literal["sms", "voice"]
24+
PasskeyDeliveryMethod = Literal["text", "voice"]
2425

2526
# Deprecated public aliases resolved lazily (PEP 562) so access emits a warning
2627
# while imports keep working. Remove in a future release.
@@ -734,29 +735,39 @@ class PasskeyLoginResult(BaseModel):
734735

735736

736737
class PasskeyRpInfo(BaseModel):
738+
"""Relying party identity for a WebAuthn ceremony."""
739+
737740
id: str
738741
name: str
739742

740743

741744
class PasskeyUserInfo(BaseModel):
745+
"""User identity for a WebAuthn registration."""
746+
742747
model_config = ConfigDict(populate_by_name=True)
743748
id: str
744749
name: str
745750
display_name: Optional[str] = Field(None, alias="displayName")
746751

747752

748753
class PasskeyPubKeyCredParam(BaseModel):
754+
"""Public key type and algorithm the relying party accepts."""
755+
749756
type: str
750757
alg: int
751758

752759

753760
class PasskeyAuthenticatorSelection(BaseModel):
761+
"""Authenticator requirements for a WebAuthn registration."""
762+
754763
model_config = ConfigDict(populate_by_name=True)
755764
resident_key: Optional[str] = Field(None, alias="residentKey")
756765
user_verification: Optional[str] = Field(None, alias="userVerification")
757766

758767

759768
class PasskeyPublicKeyOptions(BaseModel):
769+
"""WebAuthn public key options to pass to the browser credentials API."""
770+
760771
model_config = ConfigDict(populate_by_name=True, extra="allow")
761772
challenge: str
762773
rp: Optional[PasskeyRpInfo] = None
@@ -789,6 +800,8 @@ class EnrollmentChallengeResponse(BaseModel):
789800

790801

791802
class PasskeyAuthResponse(BaseModel):
803+
"""WebAuthn credential returned by the browser, passed to signin_with_passkey."""
804+
792805
model_config = ConfigDict(populate_by_name=True)
793806
id: str
794807
raw_id: str = Field(alias="rawId")
@@ -851,6 +864,8 @@ class GetFactorsResponse(BaseModel):
851864

852865

853866
class PasskeyUserProfile(BaseModel):
867+
"""User profile attributes sent with a passkey signup challenge."""
868+
854869
model_config = ConfigDict(extra="allow")
855870
email: Optional[str] = None
856871
name: Optional[str] = None
@@ -863,20 +878,25 @@ class PasskeyUserProfile(BaseModel):
863878

864879

865880
class _PasskeyChallengeResponseBase(BaseModel):
866-
auth_session: str
881+
model_config = ConfigDict(hide_input_in_errors=True)
882+
auth_session: str = Field(repr=False)
867883
authn_params_public_key: PasskeyPublicKeyOptions
868884

869885

870886
class PasskeySignupChallengeResponse(_PasskeyChallengeResponseBase):
871-
pass
887+
"""Signup challenge, with verification_required set when the user must enter codes first. auth_session is hidden from repr and str."""
888+
889+
verification_required: Optional[list[str]] = None
872890

873891

874892
class PasskeyLoginChallengeResponse(_PasskeyChallengeResponseBase):
875-
pass
893+
"""Login challenge with the auth_session and WebAuthn options for the browser. auth_session is hidden from repr and str."""
876894

877895

878896
class PasskeyTokenResponse(BaseModel):
879-
model_config = ConfigDict(extra="allow")
897+
"""Token endpoint response for the passkey grant."""
898+
899+
model_config = ConfigDict(extra="allow", hide_input_in_errors=True)
880900
access_token: str
881901
token_type: str = "Bearer"
882902
expires_in: int

0 commit comments

Comments
 (0)