Skip to content

Commit cb8a8f9

Browse files
committed
Doc changes and minor code optimizations
1 parent f8b76c4 commit cb8a8f9

8 files changed

Lines changed: 127 additions & 221 deletions

File tree

README.md

Lines changed: 1 addition & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -185,29 +185,7 @@ For more details and examples, see [examples/RetrievingData.md](examples/Retriev
185185

186186
### 7. Passkey Authentication
187187

188-
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. 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:
189-
190-
```python
191-
from auth0_server_python.auth_types import PasskeyUserProfile, PasskeyAuthResponse
192-
193-
# Step 1 — request a challenge
194-
challenge = await auth0.passkey_login_challenge(
195-
store_options={"request": request, "response": response}
196-
)
197-
198-
# Step 2 — browser signs: navigator.credentials.get(challenge.authn_params_public_key)
199-
200-
# Step 3 — complete sign-in and establish the session
201-
result = await auth0.signin_with_passkey(
202-
auth_session=challenge.auth_session,
203-
authn_response=PasskeyAuthResponse(**credential),
204-
store_options={"request": request, "response": response}
205-
)
206-
207-
user = result.state_data["user"]
208-
```
209-
210-
For signup, organizations, step-up MFA, and error handling, see [examples/Passkeys.md](examples/Passkeys.md).
188+
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).
211189

212190
### 8. My Account API — Authentication Methods
213191

src/auth0_server_python/auth_schemes/dpop_auth.py

Lines changed: 25 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -21,15 +21,17 @@ def _validate_dpop_key(key: "jwk.JWK") -> dict:
2121
return public_jwk
2222

2323

24-
def make_dpop_proof_for_token_endpoint(
25-
key: "jwk.JWK", method: str, url: str, nonce: Optional[str] = None
24+
def _build_dpop_proof(
25+
key: "jwk.JWK",
26+
public_jwk: dict,
27+
method: str,
28+
url: str,
29+
*,
30+
ath: Optional[str] = None,
31+
nonce: Optional[str] = None,
2632
) -> str:
27-
"""
28-
Build a DPoP proof JWT for use at the token endpoint (RFC 9449 §4.2).
29-
Unlike resource-server proofs, token-endpoint proofs do NOT include `ath`
30-
because no access token exists yet at issuance time.
31-
"""
32-
public_jwk = _validate_dpop_key(key)
33+
"""Sign a DPoP proof JWT (RFC 9449 §4.2). `ath` binds the proof to an
34+
access token and is omitted for token-endpoint proofs."""
3335
htu = url.split("?")[0].split("#")[0]
3436
header = {"typ": "dpop+jwt", "alg": "ES256", "jwk": public_jwk}
3537
payload = {
@@ -38,13 +40,27 @@ def make_dpop_proof_for_token_endpoint(
3840
"htu": htu,
3941
"iat": int(time.time()),
4042
}
43+
if ath is not None:
44+
payload["ath"] = ath
4145
if nonce is not None:
4246
payload["nonce"] = nonce
4347
token = jwcrypto_jwt.JWT(header=header, claims=payload)
4448
token.make_signed_token(key)
4549
return token.serialize()
4650

4751

52+
def make_dpop_proof_for_token_endpoint(
53+
key: "jwk.JWK", method: str, url: str, nonce: Optional[str] = None
54+
) -> str:
55+
"""
56+
Build a DPoP proof JWT for use at the token endpoint (RFC 9449 §4.2).
57+
Unlike resource-server proofs, token-endpoint proofs do NOT include `ath`
58+
because no access token exists yet at issuance time.
59+
"""
60+
public_jwk = _validate_dpop_key(key)
61+
return _build_dpop_proof(key, public_jwk, method, url, nonce=nonce)
62+
63+
4864
class DPoPAuth(httpx.Auth):
4965
# Buffer the body (sync/async-aware) so the nonce retry can resend it.
5066
requires_request_body = True
@@ -59,12 +75,6 @@ def __init__(self, token: str, key: "jwk.JWK") -> None:
5975
self._key = key
6076
self._public_jwk = public_jwk
6177

62-
def __repr__(self) -> str:
63-
return "DPoPAuth(token=[REDACTED], key=[REDACTED])"
64-
65-
def __str__(self) -> str:
66-
return "DPoPAuth(token=[REDACTED], key=[REDACTED])"
67-
6878
def auth_flow(self, request: httpx.Request):
6979
proof = self._make_proof(request.method, str(request.url))
7080
request.headers["Authorization"] = f"DPoP {self._token}"
@@ -80,20 +90,5 @@ def auth_flow(self, request: httpx.Request):
8090
yield request
8191

8292
def _make_proof(self, method: str, url: str, nonce: Optional[str] = None) -> str:
83-
htu = url.split("?")[0].split("#")[0]
8493
ath = _base64url(hashlib.sha256(self._token.encode("ascii")).digest())
85-
86-
header = {"typ": "dpop+jwt", "alg": "ES256", "jwk": self._public_jwk}
87-
payload = {
88-
"jti": str(uuid.uuid4()),
89-
"htm": method.upper(),
90-
"htu": htu,
91-
"iat": int(time.time()),
92-
"ath": ath,
93-
}
94-
if nonce is not None:
95-
payload["nonce"] = nonce
96-
97-
token = jwcrypto_jwt.JWT(header=header, claims=payload)
98-
token.make_signed_token(self._key)
99-
return token.serialize()
94+
return _build_dpop_proof(self._key, self._public_jwk, method, url, ath=ath, nonce=nonce)

src/auth0_server_python/auth_server/mfa_client.py

Lines changed: 43 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -142,39 +142,6 @@ def decrypt_mfa_token(self, encrypted_token: str) -> MfaTokenContext:
142142
# MFA STATE
143143
# ============================================================================
144144

145-
async def store_pending_mfa(
146-
self,
147-
encrypted_token: str,
148-
store_options: Optional[dict[str, Any]] = None,
149-
) -> None:
150-
"""Save an in-progress MFA token so challenge and verify can proceed without the client carrying the token."""
151-
if self._state_store:
152-
await self._state_store.set(
153-
MFA_PENDING_IDENTIFIER,
154-
{"mfa_token": encrypted_token},
155-
options=store_options,
156-
)
157-
158-
async def get_pending_mfa(
159-
self,
160-
store_options: Optional[dict[str, Any]] = None,
161-
) -> Optional[str]:
162-
"""Retrieve the in-progress MFA token if a challenge is pending for the current session, or None."""
163-
if not self._state_store:
164-
return None
165-
data = await self._state_store.get(MFA_PENDING_IDENTIFIER, store_options)
166-
if data and isinstance(data, dict):
167-
return data.get("mfa_token")
168-
return None
169-
170-
async def _clear_pending_mfa(
171-
self,
172-
store_options: Optional[dict[str, Any]] = None,
173-
) -> None:
174-
"""Clear the in-progress MFA state after successful verification."""
175-
if self._state_store:
176-
await self._state_store.delete(MFA_PENDING_IDENTIFIER, store_options)
177-
178145
def _resolve_encrypted_token(
179146
self,
180147
options: dict[str, Any],
@@ -192,12 +159,18 @@ def _resolve_encrypted_token(
192159
@staticmethod
193160
def _parse_error_body(response: httpx.Response) -> dict[str, Any]:
194161
"""
195-
Parse an error response body as JSON, falling back to a status-coded
196-
stub when the body is not JSON (e.g. a gateway 502/504 HTML page).
162+
Parse an error response body as JSON.
163+
164+
Falls back to a status-coded stub when the body is not JSON (e.g. a
165+
gateway 502/504 HTML page), so the caller always gets a readable dict
166+
rather than a JSON-parser exception folded into the message.
197167
198-
Never raises — the caller always gets a dict it can read error fields
199-
from, so a non-JSON error surfaces the real HTTP status rather than a
200-
JSON-parser exception folded into the message.
168+
Args:
169+
response: The HTTP error response to parse.
170+
171+
Returns:
172+
The parsed JSON object, or a stub dict whose 'error_description'
173+
names the HTTP status when the body is not a JSON object.
201174
"""
202175
try:
203176
data = response.json()
@@ -223,14 +196,25 @@ async def _raise_mfa_required(
223196
Encrypt the server-issued mfa_token and raise MfaRequiredError.
224197
225198
Shared by every site that handles an `mfa_required` response so the
226-
encrypt-then-raise behaviour cannot drift between entry points. Returns
227-
only when the response carries no mfa_token (caller then falls through
228-
to its own typed error).
229-
230-
store_pending controls whether the encrypted token is persisted to the
231-
state store before raising. It is an explicit argument so the difference
232-
between entry points is visible: the passkey grant persists it here,
233-
while the refresh-token path relies on its get_access_token caller.
199+
encrypt-then-raise behaviour cannot drift between entry points.
200+
201+
Args:
202+
error_data: The parsed `mfa_required` error body from Auth0.
203+
audience: Audience to bind into the encrypted token context.
204+
scope: Scope to bind into the encrypted token context.
205+
default_description: Message used when the response omits
206+
'error_description'.
207+
store_pending: When True, persist the encrypted token to the state
208+
store before raising (the passkey grant does; the refresh-token
209+
path relies on its get_access_token caller instead).
210+
store_options: Optional options passed to the State Store.
211+
212+
Returns:
213+
None. Returns without raising only when the response carries no
214+
mfa_token, so the caller can fall through to its own typed error.
215+
216+
Raises:
217+
MfaRequiredError: When the response carries an mfa_token.
234218
"""
235219
raw_mfa_token = error_data.get("mfa_token")
236220
if not raw_mfa_token:
@@ -245,8 +229,14 @@ async def _raise_mfa_required(
245229
scope=scope,
246230
mfa_requirements=mfa_requirements,
247231
)
248-
if store_pending:
249-
await self.store_pending_mfa(encrypted_token, store_options)
232+
if store_pending and self._state_store:
233+
# Persist the in-progress MFA token so challenge and verify can
234+
# proceed without the client carrying the token.
235+
await self._state_store.set(
236+
MFA_PENDING_IDENTIFIER,
237+
{"mfa_token": encrypted_token},
238+
options=store_options,
239+
)
250240
raise MfaRequiredError(
251241
error_data.get("error_description", default_description),
252242
mfa_token=encrypted_token,
@@ -545,7 +535,9 @@ async def verify(
545535
token_response = response.json()
546536
verify_response = MfaVerifyResponse(**token_response)
547537

548-
await self._clear_pending_mfa(store_options)
538+
# Clear the in-progress MFA state after successful verification.
539+
if self._state_store:
540+
await self._state_store.delete(MFA_PENDING_IDENTIFIER, store_options)
549541

550542
if options.get("persist") and self._state_store:
551543
await self._persist_mfa_tokens(
@@ -635,5 +627,5 @@ async def _persist_mfa_tokens(
635627
raise
636628
except Exception as e:
637629
raise MfaVerifyError(
638-
f"Failed to persist MFA tokens to state store: {str(e)}"
639-
)
630+
"Failed to persist MFA tokens to state store"
631+
) from e

src/auth0_server_python/auth_server/server_client.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2925,14 +2925,28 @@ async def signin_with_passkey(
29252925
PasskeyErrorCode.INVALID_RESPONSE, "Failed to parse passkey token response as JSON"
29262926
)
29272927

2928+
# Add required fields if they are missing
2929+
if "expires_in" in token_data and "expires_at" not in token_data:
2930+
token_data["expires_at"] = int(time.time()) + token_data["expires_in"]
2931+
29282932
token_response = PasskeyTokenResponse.model_validate(token_data)
29292933

2930-
if dpop_key is not None and token_response.token_type.lower() != "dpop":
2934+
token_is_dpop = token_response.token_type.lower() == "dpop"
2935+
if dpop_key is not None and not token_is_dpop:
29312936
raise PasskeyError(
29322937
PasskeyErrorCode.TOKEN_EXCHANGE_FAILED,
29332938
f"DPoP token binding failed: expected token_type 'DPoP', "
29342939
f"got '{token_response.token_type}'",
29352940
)
2941+
if dpop_key is None and token_is_dpop:
2942+
# Server issued a DPoP-bound token but no proof key was
2943+
# supplied — we cannot prove possession on later calls, so
2944+
# storing it as Bearer would silently fail open. Reject.
2945+
raise PasskeyError(
2946+
PasskeyErrorCode.TOKEN_EXCHANGE_FAILED,
2947+
"Server returned a DPoP-bound token but no dpop_key was "
2948+
"provided; pass dpop_key to signin_with_passkey to bind it",
2949+
)
29362950

29372951
if resolved_org and not token_response.id_token:
29382952
raise OrganizationTokenValidationError(

src/auth0_server_python/auth_types/__init__.py

Lines changed: 9 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,21 @@
33
These Pydantic models provide type safety and validation for all SDK data structures.
44
"""
55

6-
import time
76
from typing import Any, Literal, Optional, Union
87

9-
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
8+
from pydantic import BaseModel, ConfigDict, Field, field_validator
109

1110
# Upper bound (Unix seconds) for a plausible session_expiry
1211
SESSION_EXPIRY_MAX_PLAUSIBLE = 10_000_000_000
1312

13+
# Type aliases using Literal types. Used to validate caller-supplied input.
14+
# Server-controlled response fields use plain str instead, so a new factor or
15+
# challenge type (e.g. a future webauthn second factor) does not fail closed.
16+
OobChannel = Literal["sms", "voice", "auth0", "email"]
17+
ChallengeType = Literal["otp", "oob"]
18+
EnrollmentType = Literal["passkey", "email", "phone", "totp", "push-notification", "recovery-code", "password"]
19+
PreferredAuthMethod = Literal["sms", "voice"]
20+
1421

1522
class UserClaims(BaseModel):
1623
"""
@@ -471,12 +478,6 @@ class ListConnectedAccountConnectionsResponse(BaseModel):
471478
# MFA Types
472479
# =============================================================================
473480

474-
# Type aliases using Literal types. Used to validate caller-supplied input.
475-
# Server-controlled response fields use plain str instead, so a new factor or
476-
# challenge type (e.g. a future webauthn second factor) does not fail closed.
477-
OobChannel = Literal["sms", "voice", "auth0", "email"]
478-
ChallengeType = Literal["otp", "oob"]
479-
480481

481482
class AuthenticatorResponse(BaseModel):
482483
"""Represents an MFA authenticator enrolled by a user."""
@@ -701,10 +702,6 @@ class PasskeyPublicKeyOptions(BaseModel):
701702
user_verification: Optional[str] = Field(None, alias="userVerification")
702703

703704

704-
EnrollmentType = Literal["passkey", "email", "phone", "totp", "push-notification", "recovery-code", "password"]
705-
PreferredAuthMethod = Literal["sms", "voice"]
706-
707-
708705
class EnrollAuthenticationMethodRequest(BaseModel):
709706
type: EnrollmentType
710707
email: Optional[str] = None
@@ -720,14 +717,6 @@ class EnrollmentChallengeResponse(BaseModel):
720717
auth_session: str
721718
authn_params_public_key: Optional[PasskeyPublicKeyOptions] = None
722719

723-
def __repr__(self) -> str:
724-
return (
725-
f"EnrollmentChallengeResponse("
726-
f"authentication_method_id={self.authentication_method_id!r}, "
727-
f"auth_session=[REDACTED], "
728-
f"authn_params_public_key={self.authn_params_public_key!r})"
729-
)
730-
731720

732721
class PasskeyAuthResponse(BaseModel):
733722
model_config = ConfigDict(populate_by_name=True)
@@ -807,13 +796,6 @@ class _PasskeyChallengeResponseBase(BaseModel):
807796
auth_session: str
808797
authn_params_public_key: PasskeyPublicKeyOptions
809798

810-
def __repr__(self) -> str:
811-
return (
812-
f"{self.__class__.__name__}("
813-
f"auth_session=[REDACTED], "
814-
f"authn_params_public_key={self.authn_params_public_key!r})"
815-
)
816-
817799

818800
class PasskeySignupChallengeResponse(_PasskeyChallengeResponseBase):
819801
pass
@@ -832,22 +814,3 @@ class PasskeyTokenResponse(BaseModel):
832814
scope: Optional[str] = None
833815
id_token: Optional[str] = None
834816
refresh_token: Optional[str] = None
835-
836-
@model_validator(mode="before")
837-
@classmethod
838-
def _backfill_expires_at(cls, data: Any) -> Any:
839-
if isinstance(data, dict) and "expires_at" not in data and "expires_in" in data:
840-
data["expires_at"] = int(time.time()) + int(data["expires_in"])
841-
return data
842-
843-
def __repr__(self) -> str:
844-
return (
845-
f"PasskeyTokenResponse("
846-
f"token_type={self.token_type!r}, "
847-
f"expires_in={self.expires_in!r}, "
848-
f"expires_at={self.expires_at!r}, "
849-
f"scope={self.scope!r}, "
850-
f"access_token=[REDACTED], "
851-
f"id_token=[REDACTED], "
852-
f"refresh_token=[REDACTED])"
853-
)

src/auth0_server_python/tests/test_dpop_auth.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -157,12 +157,10 @@ def test_token_endpoint_proof_rejects_non_ec_key():
157157
make_dpop_proof_for_token_endpoint(rsa_key, "POST", "https://example.com/oauth/token")
158158

159159

160-
def test_dpop_repr_redacts_credentials(ec_key):
160+
def test_dpop_repr_does_not_leak_credentials(ec_key):
161161
auth = DPoPAuth(token="secret_access_token_value", key=ec_key)
162162
assert "secret_access_token_value" not in repr(auth)
163163
assert "secret_access_token_value" not in str(auth)
164-
assert "[REDACTED]" in repr(auth)
165-
assert "[REDACTED]" in str(auth)
166164

167165

168166
def test_dpop_rejects_non_ec_key():

0 commit comments

Comments
 (0)