auth0-server-python 1.0.0b11__tar.gz → 1.0.0b13__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (28) hide show
  1. {auth0_server_python-1.0.0b11 → auth0_server_python-1.0.0b13}/PKG-INFO +19 -1
  2. {auth0_server_python-1.0.0b11 → auth0_server_python-1.0.0b13}/README.md +18 -0
  3. {auth0_server_python-1.0.0b11 → auth0_server_python-1.0.0b13}/pyproject.toml +1 -1
  4. auth0_server_python-1.0.0b13/src/auth0_server_python/auth_schemes/__init__.py +4 -0
  5. auth0_server_python-1.0.0b13/src/auth0_server_python/auth_schemes/dpop_auth.py +94 -0
  6. {auth0_server_python-1.0.0b11 → auth0_server_python-1.0.0b13}/src/auth0_server_python/auth_server/mfa_client.py +185 -45
  7. auth0_server_python-1.0.0b13/src/auth0_server_python/auth_server/my_account_client.py +822 -0
  8. {auth0_server_python-1.0.0b11 → auth0_server_python-1.0.0b13}/src/auth0_server_python/auth_server/server_client.py +433 -3
  9. {auth0_server_python-1.0.0b11 → auth0_server_python-1.0.0b13}/src/auth0_server_python/auth_types/__init__.py +289 -9
  10. {auth0_server_python-1.0.0b11 → auth0_server_python-1.0.0b13}/src/auth0_server_python/error/__init__.py +36 -0
  11. auth0_server_python-1.0.0b13/src/auth0_server_python/tests/test_dpop_auth.py +185 -0
  12. {auth0_server_python-1.0.0b11 → auth0_server_python-1.0.0b13}/src/auth0_server_python/tests/test_mfa_client.py +190 -40
  13. auth0_server_python-1.0.0b13/src/auth0_server_python/tests/test_my_account_client.py +1424 -0
  14. {auth0_server_python-1.0.0b11 → auth0_server_python-1.0.0b13}/src/auth0_server_python/tests/test_server_client.py +2399 -1
  15. {auth0_server_python-1.0.0b11 → auth0_server_python-1.0.0b13}/src/auth0_server_python/utils/helpers.py +40 -1
  16. auth0_server_python-1.0.0b11/src/auth0_server_python/auth_schemes/__init__.py +0 -3
  17. auth0_server_python-1.0.0b11/src/auth0_server_python/auth_server/my_account_client.py +0 -342
  18. auth0_server_python-1.0.0b11/src/auth0_server_python/tests/test_my_account_client.py +0 -504
  19. {auth0_server_python-1.0.0b11 → auth0_server_python-1.0.0b13}/LICENSE +0 -0
  20. {auth0_server_python-1.0.0b11 → auth0_server_python-1.0.0b13}/src/auth0_server_python/auth_schemes/bearer_auth.py +0 -0
  21. {auth0_server_python-1.0.0b11 → auth0_server_python-1.0.0b13}/src/auth0_server_python/auth_server/__init__.py +0 -0
  22. {auth0_server_python-1.0.0b11 → auth0_server_python-1.0.0b13}/src/auth0_server_python/encryption/__init__.py +0 -0
  23. {auth0_server_python-1.0.0b11 → auth0_server_python-1.0.0b13}/src/auth0_server_python/encryption/encrypt.py +0 -0
  24. {auth0_server_python-1.0.0b11 → auth0_server_python-1.0.0b13}/src/auth0_server_python/store/__init__.py +0 -0
  25. {auth0_server_python-1.0.0b11 → auth0_server_python-1.0.0b13}/src/auth0_server_python/store/abstract.py +0 -0
  26. {auth0_server_python-1.0.0b11 → auth0_server_python-1.0.0b13}/src/auth0_server_python/telemetry.py +0 -0
  27. {auth0_server_python-1.0.0b11 → auth0_server_python-1.0.0b13}/src/auth0_server_python/tests/test_telemetry.py +0 -0
  28. {auth0_server_python-1.0.0b11 → auth0_server_python-1.0.0b13}/src/auth0_server_python/utils/__init__.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: auth0-server-python
3
- Version: 1.0.0b11
3
+ Version: 1.0.0b13
4
4
  Summary: Auth0 server-side Python SDK
5
5
  License: MIT
6
6
  License-File: LICENSE
@@ -202,6 +202,24 @@ The SDK handles per-domain OIDC discovery, JWKS fetching, issuer validation, and
202
202
 
203
203
  For more details and examples, see [examples/MultipleCustomDomains.md](examples/MultipleCustomDomains.md).
204
204
 
205
+ ### 6. Session Expiry from the Upstream IdP
206
+
207
+ For enterprise connections, the upstream identity provider can cap how long a user's session lives. When the connection is configured to honor it, Auth0 includes a `session_expiry` claim in the ID token, and the SDK enforces this ceiling on every session read. Once it is reached, `get_user()` and `get_session()` return `None`, and `get_access_token()` raises an `AccessTokenError` with code `session_expired`. If the asserted ceiling is already in the past at login, `complete_interactive_login()` raises a `SessionExpiredError` instead of persisting an already-expired session.
208
+
209
+ For more details and examples, see [examples/RetrievingData.md](examples/RetrievingData.md#session-expiry-from-the-upstream-idp).
210
+
211
+ ### 7. Passkey Authentication
212
+
213
+ 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).
214
+
215
+ ### 8. My Account API — Authentication Methods
216
+
217
+ Let a logged-in user manage their own enrolled authentication methods — enroll a new passkey (or other factor), list, rename, and delete — via the [My Account API](https://auth0.com/docs/manage-users/my-account-api). For obtaining a scoped token, the enroll/verify ceremony, listing, updating, deleting, and error handling, see [examples/MyAccountAuthenticationMethods.md](examples/MyAccountAuthenticationMethods.md).
218
+
219
+ ### 9. DPoP — Sender-Constrained Tokens (Passkeys & MyAccount)
220
+
221
+ Bind tokens to a key your server holds ([RFC 9449](https://www.rfc-editor.org/rfc/rfc9449)) so a stolen token alone cannot be replayed. DPoP is supported for Passkey sign-in (`signin_with_passkey`) and the authentication-methods/factors methods on `MyAccountClient`. For key generation and usage, see [examples/Passkeys.md](examples/Passkeys.md#3-dpop-bound-passkey-tokens-optional) and [examples/MyAccountAuthenticationMethods.md](examples/MyAccountAuthenticationMethods.md#dpop).
222
+
205
223
  ## Feedback
206
224
 
207
225
  ### Contributing
@@ -177,6 +177,24 @@ The SDK handles per-domain OIDC discovery, JWKS fetching, issuer validation, and
177
177
 
178
178
  For more details and examples, see [examples/MultipleCustomDomains.md](examples/MultipleCustomDomains.md).
179
179
 
180
+ ### 6. Session Expiry from the Upstream IdP
181
+
182
+ For enterprise connections, the upstream identity provider can cap how long a user's session lives. When the connection is configured to honor it, Auth0 includes a `session_expiry` claim in the ID token, and the SDK enforces this ceiling on every session read. Once it is reached, `get_user()` and `get_session()` return `None`, and `get_access_token()` raises an `AccessTokenError` with code `session_expired`. If the asserted ceiling is already in the past at login, `complete_interactive_login()` raises a `SessionExpiredError` instead of persisting an already-expired session.
183
+
184
+ For more details and examples, see [examples/RetrievingData.md](examples/RetrievingData.md#session-expiry-from-the-upstream-idp).
185
+
186
+ ### 7. Passkey Authentication
187
+
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).
189
+
190
+ ### 8. My Account API — Authentication Methods
191
+
192
+ Let a logged-in user manage their own enrolled authentication methods — enroll a new passkey (or other factor), list, rename, and delete — via the [My Account API](https://auth0.com/docs/manage-users/my-account-api). For obtaining a scoped token, the enroll/verify ceremony, listing, updating, deleting, and error handling, see [examples/MyAccountAuthenticationMethods.md](examples/MyAccountAuthenticationMethods.md).
193
+
194
+ ### 9. DPoP — Sender-Constrained Tokens (Passkeys & MyAccount)
195
+
196
+ Bind tokens to a key your server holds ([RFC 9449](https://www.rfc-editor.org/rfc/rfc9449)) so a stolen token alone cannot be replayed. DPoP is supported for Passkey sign-in (`signin_with_passkey`) and the authentication-methods/factors methods on `MyAccountClient`. For key generation and usage, see [examples/Passkeys.md](examples/Passkeys.md#3-dpop-bound-passkey-tokens-optional) and [examples/MyAccountAuthenticationMethods.md](examples/MyAccountAuthenticationMethods.md#dpop).
197
+
180
198
  ## Feedback
181
199
 
182
200
  ### Contributing
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "auth0-server-python"
3
- version = "1.0.0b11"
3
+ version = "1.0.0b13"
4
4
  description = "Auth0 server-side Python SDK"
5
5
  readme = "README.md"
6
6
  authors = ["Auth0 <support@okta.com>"]
@@ -0,0 +1,4 @@
1
+ from .bearer_auth import BearerAuth
2
+ from .dpop_auth import DPoPAuth
3
+
4
+ __all__ = ["BearerAuth", "DPoPAuth"]
@@ -0,0 +1,94 @@
1
+ import base64
2
+ import hashlib
3
+ import time
4
+ import uuid
5
+ from typing import Optional
6
+
7
+ import httpx
8
+ from jwcrypto import jwk
9
+ from jwcrypto import jwt as jwcrypto_jwt
10
+
11
+
12
+ def _base64url(data: bytes) -> str:
13
+ return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
14
+
15
+
16
+ def _validate_dpop_key(key: "jwk.JWK") -> dict:
17
+ """Return the public JWK after enforcing the EC P-256 requirement (ES256)."""
18
+ public_jwk = key.export_public(as_dict=True)
19
+ if public_jwk.get("kty") != "EC" or public_jwk.get("crv") != "P-256":
20
+ raise ValueError("DPoP key must be an EC P-256 key")
21
+ return public_jwk
22
+
23
+
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,
32
+ ) -> str:
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."""
35
+ htu = url.split("?")[0].split("#")[0]
36
+ header = {"typ": "dpop+jwt", "alg": "ES256", "jwk": public_jwk}
37
+ payload = {
38
+ "jti": str(uuid.uuid4()),
39
+ "htm": method.upper(),
40
+ "htu": htu,
41
+ "iat": int(time.time()),
42
+ }
43
+ if ath is not None:
44
+ payload["ath"] = ath
45
+ if nonce is not None:
46
+ payload["nonce"] = nonce
47
+ token = jwcrypto_jwt.JWT(header=header, claims=payload)
48
+ token.make_signed_token(key)
49
+ return token.serialize()
50
+
51
+
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
+
64
+ class DPoPAuth(httpx.Auth):
65
+ # Buffer the body (sync/async-aware) so the nonce retry can resend it.
66
+ requires_request_body = True
67
+
68
+ def __init__(self, token: str, key: "jwk.JWK") -> None:
69
+ public_jwk = _validate_dpop_key(key)
70
+ try:
71
+ token.encode("ascii")
72
+ except UnicodeEncodeError:
73
+ raise ValueError("Access token must contain only ASCII characters")
74
+ self._token = token
75
+ self._key = key
76
+ self._public_jwk = public_jwk
77
+
78
+ def auth_flow(self, request: httpx.Request):
79
+ proof = self._make_proof(request.method, str(request.url))
80
+ request.headers["Authorization"] = f"DPoP {self._token}"
81
+ request.headers["DPoP"] = proof
82
+ response = yield request
83
+
84
+ # RFC 9449 §8.2 — server-nonce retry
85
+ if response.status_code == 401 and response.headers.get("DPoP-Nonce"):
86
+ nonce = response.headers["DPoP-Nonce"]
87
+ request.headers["DPoP"] = self._make_proof(
88
+ request.method, str(request.url), nonce=nonce
89
+ )
90
+ yield request
91
+
92
+ def _make_proof(self, method: str, url: str, nonce: Optional[str] = None) -> str:
93
+ ath = _base64url(hashlib.sha256(self._token.encode("ascii")).digest())
94
+ return _build_dpop_proof(self._key, self._public_jwk, method, url, ath=ath, nonce=nonce)
@@ -3,12 +3,17 @@ MFA Client for auth0-server-python SDK.
3
3
  Handles Multi-Factor Authentication operations against the Auth0 MFA API.
4
4
  """
5
5
 
6
+ import json
6
7
  import time
7
- from typing import Any, Callable, Optional, Union
8
+ from typing import TYPE_CHECKING, Any, Callable, Optional, Union
8
9
 
9
10
  import httpx
10
11
 
11
12
  from auth0_server_python.auth_schemes.bearer_auth import BearerAuth
13
+ from auth0_server_python.auth_schemes.dpop_auth import make_dpop_proof_for_token_endpoint
14
+
15
+ if TYPE_CHECKING:
16
+ from jwcrypto import jwk
12
17
  from auth0_server_python.auth_types import (
13
18
  AuthenticatorResponse,
14
19
  ChallengeResponse,
@@ -38,6 +43,7 @@ from auth0_server_python.utils.helpers import (
38
43
  )
39
44
 
40
45
  DEFAULT_MFA_TOKEN_TTL = 300 # 5 minutes
46
+ MFA_PENDING_IDENTIFIER = "_a0_mfa_pending"
41
47
 
42
48
 
43
49
  class MfaClient:
@@ -47,9 +53,9 @@ class MfaClient:
47
53
  Provides methods for listing authenticators, enrolling new authenticators,
48
54
  deleting authenticators, challenging authenticators, and verifying MFA codes.
49
55
 
50
- All API operations require a raw mfa_token. If the token was encrypted
51
- (e.g. from MfaRequiredError raised by get_access_token()), use
52
- decrypt_mfa_token() first to obtain the raw token.
56
+ All public API methods accept an encrypted mfa_token (as issued by
57
+ MfaRequiredError) and decrypt it internally. Callers never handle the
58
+ raw Auth0 mfa_token directly.
53
59
  """
54
60
 
55
61
  def __init__(
@@ -130,13 +136,103 @@ class MfaClient:
130
136
  except Exception:
131
137
  raise MfaTokenInvalidError()
132
138
 
133
- # Check TTL
134
139
  elapsed = int(time.time()) - context.created_at
135
140
  if elapsed > DEFAULT_MFA_TOKEN_TTL:
136
141
  raise MfaTokenExpiredError()
137
142
 
138
143
  return context
139
144
 
145
+ # ============================================================================
146
+ # MFA STATE
147
+ # ============================================================================
148
+
149
+ @staticmethod
150
+ def _parse_error_body(response: httpx.Response) -> dict[str, Any]:
151
+ """
152
+ Parse an error response body as JSON.
153
+
154
+ Falls back to a status-coded stub when the body is not JSON (e.g. a
155
+ gateway 502/504 HTML page), so the caller always gets a readable dict
156
+ rather than a JSON-parser exception folded into the message.
157
+
158
+ Args:
159
+ response: The HTTP error response to parse.
160
+
161
+ Returns:
162
+ The parsed JSON object, or a stub dict whose 'error_description'
163
+ names the HTTP status when the body is not a JSON object.
164
+ """
165
+ try:
166
+ data = response.json()
167
+ except (json.JSONDecodeError, ValueError):
168
+ data = None
169
+ if not isinstance(data, dict):
170
+ return {
171
+ "error_description": f"Request failed with status {response.status_code}",
172
+ }
173
+ return data
174
+
175
+ async def _raise_mfa_required(
176
+ self,
177
+ error_data: dict[str, Any],
178
+ *,
179
+ audience: str,
180
+ scope: str,
181
+ default_description: str,
182
+ store_pending: bool = False,
183
+ store_options: Optional[dict[str, Any]] = None,
184
+ ) -> None:
185
+ """
186
+ Encrypt the server-issued mfa_token and raise MfaRequiredError.
187
+
188
+ Shared by every site that handles an `mfa_required` response so the
189
+ encrypt-then-raise behaviour cannot drift between entry points.
190
+
191
+ Args:
192
+ error_data: The parsed `mfa_required` error body from Auth0.
193
+ audience: Audience to bind into the encrypted token context.
194
+ scope: Scope to bind into the encrypted token context.
195
+ default_description: Message used when the response omits
196
+ 'error_description'.
197
+ store_pending: When True, persist the encrypted token to the state
198
+ store before raising (the passkey grant does; the refresh-token
199
+ path relies on its get_access_token caller instead).
200
+ store_options: Optional options passed to the State Store.
201
+
202
+ Returns:
203
+ None. Returns without raising only when the response carries no
204
+ mfa_token, so the caller can fall through to its own typed error.
205
+
206
+ Raises:
207
+ MfaRequiredError: When the response carries an mfa_token.
208
+ """
209
+ raw_mfa_token = error_data.get("mfa_token")
210
+ if not raw_mfa_token:
211
+ return
212
+ mfa_requirements_data = error_data.get("mfa_requirements")
213
+ mfa_requirements = (
214
+ MfaRequirements(**mfa_requirements_data) if mfa_requirements_data else None
215
+ )
216
+ encrypted_token = self._encrypt_mfa_token(
217
+ raw_mfa_token=raw_mfa_token,
218
+ audience=audience,
219
+ scope=scope,
220
+ mfa_requirements=mfa_requirements,
221
+ )
222
+ if store_pending and self._state_store:
223
+ # Persist the in-progress MFA token so challenge and verify can
224
+ # proceed without the client carrying the token.
225
+ await self._state_store.set(
226
+ MFA_PENDING_IDENTIFIER,
227
+ {"mfa_token": encrypted_token},
228
+ options=store_options,
229
+ )
230
+ raise MfaRequiredError(
231
+ error_data.get("error_description", default_description),
232
+ mfa_token=encrypted_token,
233
+ mfa_requirements=mfa_requirements,
234
+ )
235
+
140
236
  # ============================================================================
141
237
  # MFA API OPERATIONS
142
238
  # ============================================================================
@@ -159,7 +255,10 @@ class MfaClient:
159
255
  Raises:
160
256
  MfaListAuthenticatorsError: When the request fails.
161
257
  """
162
- mfa_token = options["mfa_token"]
258
+ mfa_token = options.get("mfa_token")
259
+ if not mfa_token:
260
+ raise MfaTokenInvalidError()
261
+ context = self.decrypt_mfa_token(mfa_token)
163
262
  base_url = await self._resolve_base_url(store_options)
164
263
  url = f"{base_url}/mfa/authenticators"
165
264
 
@@ -167,11 +266,11 @@ class MfaClient:
167
266
  async with self._get_http_client() as client:
168
267
  response = await client.get(
169
268
  url,
170
- auth=BearerAuth(mfa_token)
269
+ auth=BearerAuth(context.mfa_token)
171
270
  )
172
271
 
173
272
  if response.status_code != 200:
174
- error_data = response.json()
273
+ error_data = self._parse_error_body(response)
175
274
  raise MfaListAuthenticatorsError(
176
275
  error_data.get("error_description", "Failed to list authenticators"),
177
276
  error_data
@@ -184,8 +283,8 @@ class MfaClient:
184
283
  raise
185
284
  except Exception as e:
186
285
  raise MfaListAuthenticatorsError(
187
- f"Unexpected error listing authenticators: {str(e)}"
188
- )
286
+ "Unexpected error listing authenticators"
287
+ ) from e
189
288
 
190
289
  async def enroll_authenticator(
191
290
  self,
@@ -207,7 +306,10 @@ class MfaClient:
207
306
  Raises:
208
307
  MfaEnrollmentError: When enrollment fails.
209
308
  """
210
- mfa_token = options["mfa_token"]
309
+ mfa_token = options.get("mfa_token")
310
+ if not mfa_token:
311
+ raise MfaTokenInvalidError()
312
+ context = self.decrypt_mfa_token(mfa_token)
211
313
  factor_type = options["factor_type"]
212
314
  base_url = await self._resolve_base_url(store_options)
213
315
  url = f"{base_url}/mfa/associate"
@@ -243,12 +345,12 @@ class MfaClient:
243
345
  response = await client.post(
244
346
  url,
245
347
  json=body,
246
- auth=BearerAuth(mfa_token),
348
+ auth=BearerAuth(context.mfa_token),
247
349
  headers={"Content-Type": "application/json"}
248
350
  )
249
351
 
250
352
  if response.status_code != 200:
251
- error_data = response.json()
353
+ error_data = self._parse_error_body(response)
252
354
  raise MfaEnrollmentError(
253
355
  error_data.get("error_description", "Failed to enroll authenticator"),
254
356
  error_data
@@ -270,8 +372,8 @@ class MfaClient:
270
372
  raise
271
373
  except Exception as e:
272
374
  raise MfaEnrollmentError(
273
- f"Unexpected error enrolling authenticator: {str(e)}"
274
- )
375
+ "Unexpected error enrolling authenticator"
376
+ ) from e
275
377
 
276
378
  async def challenge_authenticator(
277
379
  self,
@@ -292,7 +394,10 @@ class MfaClient:
292
394
  Raises:
293
395
  MfaChallengeError: When the challenge fails.
294
396
  """
295
- mfa_token = options["mfa_token"]
397
+ mfa_token = options.get("mfa_token")
398
+ if not mfa_token:
399
+ raise MfaTokenInvalidError()
400
+ context = self.decrypt_mfa_token(mfa_token)
296
401
  factor_type = options["factor_type"]
297
402
  base_url = await self._resolve_base_url(store_options)
298
403
  url = f"{base_url}/mfa/challenge"
@@ -308,7 +413,7 @@ class MfaClient:
308
413
  )
309
414
 
310
415
  body: dict[str, Any] = {
311
- "mfa_token": mfa_token,
416
+ "mfa_token": context.mfa_token,
312
417
  "client_id": self._client_id,
313
418
  "client_secret": self._client_secret,
314
419
  "challenge_type": challenge_type
@@ -326,7 +431,7 @@ class MfaClient:
326
431
  )
327
432
 
328
433
  if response.status_code != 200:
329
- error_data = response.json()
434
+ error_data = self._parse_error_body(response)
330
435
  raise MfaChallengeError(
331
436
  error_data.get("error_description", "Failed to challenge authenticator"),
332
437
  error_data
@@ -339,13 +444,14 @@ class MfaClient:
339
444
  raise
340
445
  except Exception as e:
341
446
  raise MfaChallengeError(
342
- f"Unexpected error challenging authenticator: {str(e)}"
343
- )
447
+ "Unexpected error challenging authenticator"
448
+ ) from e
344
449
 
345
450
  async def verify(
346
451
  self,
347
452
  options: dict[str, Any],
348
- store_options: Optional[dict[str, Any]] = None
453
+ store_options: Optional[dict[str, Any]] = None,
454
+ dpop_key: Optional["jwk.JWK"] = None,
349
455
  ) -> MfaVerifyResponse:
350
456
  """
351
457
  Verifies an MFA code and completes authentication.
@@ -365,21 +471,27 @@ class MfaClient:
365
471
  - 'audience': str (optional, required if persist=True) - Audience for token_set
366
472
  - 'scope': str (optional) - Scope for token_set
367
473
  store_options: Optional options passed to the State Store (e.g. request/response).
474
+ dpop_key: Optional EC P-256 JWK to DPoP-bind the token. Pass the same
475
+ key used at login (e.g. given to signin_with_passkey) to preserve
476
+ the sender constraint through step-up. Never stored by the SDK.
368
477
 
369
478
  Returns:
370
479
  MfaVerifyResponse with access_token, token_type, etc.
371
480
 
372
481
  Raises:
373
- MfaVerifyError: When verification fails.
482
+ MfaVerifyError: When verification fails, or when dpop_key was supplied
483
+ but the server returned an unbound (Bearer) token.
374
484
  MfaRequiredError: When chained MFA is required.
375
485
  """
376
- mfa_token = options["mfa_token"]
486
+ mfa_token = options.get("mfa_token")
487
+ if not mfa_token:
488
+ raise MfaTokenInvalidError()
489
+ context = self.decrypt_mfa_token(mfa_token)
377
490
 
378
- # Determine grant type and build body
379
491
  body: dict[str, Any] = {
380
492
  "client_id": self._client_id,
381
493
  "client_secret": self._client_secret,
382
- "mfa_token": mfa_token
494
+ "mfa_token": context.mfa_token
383
495
  }
384
496
 
385
497
  if "otp" in options:
@@ -403,28 +515,44 @@ class MfaClient:
403
515
  token_endpoint = f"{base_url}/oauth/token"
404
516
 
405
517
  async with self._get_http_client() as client:
518
+ headers = {"Content-Type": "application/x-www-form-urlencoded"}
519
+ if dpop_key is not None:
520
+ headers["DPoP"] = make_dpop_proof_for_token_endpoint(
521
+ dpop_key, "POST", token_endpoint
522
+ )
406
523
  response = await client.post(
407
524
  token_endpoint,
408
525
  data=body,
409
- headers={"Content-Type": "application/x-www-form-urlencoded"}
526
+ headers=headers
410
527
  )
411
528
 
529
+ # Rebuild the proof with the nonce and retry once.
530
+ if (
531
+ dpop_key is not None
532
+ and response.status_code in (400, 401)
533
+ and response.headers.get("DPoP-Nonce")
534
+ ):
535
+ nonce = response.headers["DPoP-Nonce"]
536
+ headers["DPoP"] = make_dpop_proof_for_token_endpoint(
537
+ dpop_key, "POST", token_endpoint, nonce=nonce
538
+ )
539
+ response = await client.post(
540
+ token_endpoint,
541
+ data=body,
542
+ headers=headers
543
+ )
544
+
412
545
  if response.status_code != 200:
413
- error_data = response.json()
546
+ error_data = self._parse_error_body(response)
414
547
 
415
- # Handle chained MFA — token is raw; encryption is the
416
- # framework SDK's responsibility (see ServerClient.get_access_token).
417
548
  if error_data.get("error") == "mfa_required":
418
- new_mfa_token = error_data.get("mfa_token")
419
- mfa_requirements_data = error_data.get("mfa_requirements")
420
- mfa_requirements = None
421
- if mfa_requirements_data:
422
- mfa_requirements = MfaRequirements(**mfa_requirements_data)
423
-
424
- raise MfaRequiredError(
425
- error_data.get("error_description", "Additional MFA factor required"),
426
- mfa_token=new_mfa_token,
427
- mfa_requirements=mfa_requirements
549
+ # Chained MFA: re-encrypt the new token with the original
550
+ # audience/scope from the incoming context before raising.
551
+ await self._raise_mfa_required(
552
+ error_data,
553
+ audience=context.audience,
554
+ scope=context.scope,
555
+ default_description="Additional MFA factor required",
428
556
  )
429
557
 
430
558
  raise MfaVerifyError(
@@ -435,11 +563,23 @@ class MfaClient:
435
563
  token_response = response.json()
436
564
  verify_response = MfaVerifyResponse(**token_response)
437
565
 
438
- # Persist tokens to state store if requested
566
+ # Reject a Bearer downgrade when a DPoP token was requested
567
+ # (RFC 9449: a bound token has token_type "DPoP").
568
+ token_is_dpop = verify_response.token_type.lower() == "dpop"
569
+ if dpop_key is not None and not token_is_dpop:
570
+ raise MfaVerifyError(
571
+ "DPoP token binding failed: expected token_type 'DPoP', "
572
+ f"got '{verify_response.token_type}'"
573
+ )
574
+
575
+ # Clear the in-progress MFA state after successful verification.
576
+ if self._state_store:
577
+ await self._state_store.delete(MFA_PENDING_IDENTIFIER, store_options)
578
+
439
579
  if options.get("persist") and self._state_store:
440
580
  await self._persist_mfa_tokens(
441
581
  verify_response=verify_response,
442
- options=options,
582
+ options={**options, "audience": options.get("audience") or context.audience, "scope": options.get("scope") or context.scope},
443
583
  store_options=store_options
444
584
  )
445
585
 
@@ -449,8 +589,8 @@ class MfaClient:
449
589
  raise
450
590
  except Exception as e:
451
591
  raise MfaVerifyError(
452
- f"Unexpected error during MFA verification: {str(e)}"
453
- )
592
+ "Unexpected error during MFA verification"
593
+ ) from e
454
594
 
455
595
  async def _persist_mfa_tokens(
456
596
  self,
@@ -524,5 +664,5 @@ class MfaClient:
524
664
  raise
525
665
  except Exception as e:
526
666
  raise MfaVerifyError(
527
- f"Failed to persist MFA tokens to state store: {str(e)}"
528
- )
667
+ "Failed to persist MFA tokens to state store"
668
+ ) from e