auth0-server-python 1.0.0b8__tar.gz → 1.0.0b10__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 (24) hide show
  1. {auth0_server_python-1.0.0b8 → auth0_server_python-1.0.0b10}/PKG-INFO +30 -2
  2. {auth0_server_python-1.0.0b8 → auth0_server_python-1.0.0b10}/README.md +28 -0
  3. {auth0_server_python-1.0.0b8 → auth0_server_python-1.0.0b10}/pyproject.toml +3 -3
  4. auth0_server_python-1.0.0b10/src/auth0_server_python/auth_server/__init__.py +5 -0
  5. auth0_server_python-1.0.0b10/src/auth0_server_python/auth_server/mfa_client.py +528 -0
  6. {auth0_server_python-1.0.0b8 → auth0_server_python-1.0.0b10}/src/auth0_server_python/auth_server/my_account_client.py +14 -6
  7. {auth0_server_python-1.0.0b8 → auth0_server_python-1.0.0b10}/src/auth0_server_python/auth_server/server_client.py +747 -120
  8. {auth0_server_python-1.0.0b8 → auth0_server_python-1.0.0b10}/src/auth0_server_python/auth_types/__init__.py +186 -1
  9. {auth0_server_python-1.0.0b8 → auth0_server_python-1.0.0b10}/src/auth0_server_python/error/__init__.py +127 -1
  10. {auth0_server_python-1.0.0b8 → auth0_server_python-1.0.0b10}/src/auth0_server_python/store/abstract.py +15 -2
  11. auth0_server_python-1.0.0b10/src/auth0_server_python/telemetry.py +39 -0
  12. auth0_server_python-1.0.0b10/src/auth0_server_python/tests/test_mfa_client.py +848 -0
  13. {auth0_server_python-1.0.0b8 → auth0_server_python-1.0.0b10}/src/auth0_server_python/tests/test_my_account_client.py +1 -0
  14. {auth0_server_python-1.0.0b8 → auth0_server_python-1.0.0b10}/src/auth0_server_python/tests/test_server_client.py +2084 -92
  15. auth0_server_python-1.0.0b10/src/auth0_server_python/tests/test_telemetry.py +131 -0
  16. {auth0_server_python-1.0.0b8 → auth0_server_python-1.0.0b10}/src/auth0_server_python/utils/helpers.py +69 -0
  17. auth0_server_python-1.0.0b8/src/auth0_server_python/auth_server/__init__.py +0 -4
  18. {auth0_server_python-1.0.0b8 → auth0_server_python-1.0.0b10}/LICENSE +0 -0
  19. {auth0_server_python-1.0.0b8 → auth0_server_python-1.0.0b10}/src/auth0_server_python/auth_schemes/__init__.py +0 -0
  20. {auth0_server_python-1.0.0b8 → auth0_server_python-1.0.0b10}/src/auth0_server_python/auth_schemes/bearer_auth.py +0 -0
  21. {auth0_server_python-1.0.0b8 → auth0_server_python-1.0.0b10}/src/auth0_server_python/encryption/__init__.py +0 -0
  22. {auth0_server_python-1.0.0b8 → auth0_server_python-1.0.0b10}/src/auth0_server_python/encryption/encrypt.py +0 -0
  23. {auth0_server_python-1.0.0b8 → auth0_server_python-1.0.0b10}/src/auth0_server_python/store/__init__.py +0 -0
  24. {auth0_server_python-1.0.0b8 → auth0_server_python-1.0.0b10}/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.0b8
3
+ Version: 1.0.0b10
4
4
  Summary: Auth0 server-side Python SDK
5
5
  License: MIT
6
6
  License-File: LICENSE
@@ -18,7 +18,7 @@ Classifier: Programming Language :: Python :: 3.14
18
18
  Requires-Dist: authlib (>=1.2,<2.0)
19
19
  Requires-Dist: cryptography (>=43.0.1)
20
20
  Requires-Dist: httpx (>=0.28.1,<0.29.0)
21
- Requires-Dist: jwcrypto (>=1.5.6,<2.0.0)
21
+ Requires-Dist: jwcrypto (>=1.5.7,<2.0.0)
22
22
  Requires-Dist: pydantic (>=2.10.6,<3.0.0)
23
23
  Requires-Dist: pyjwt (>=2.8.0)
24
24
  Description-Content-Type: text/markdown
@@ -170,6 +170,34 @@ print(response.access_token)
170
170
 
171
171
  For more details and examples, see [examples/CustomTokenExchange.md](examples/CustomTokenExchange.md).
172
172
 
173
+ ### 5. Multiple Custom Domains (MCD)
174
+
175
+ For applications that use multiple custom domains on the same Auth0 tenant, pass a domain resolver function instead of a static domain string:
176
+
177
+ ```python
178
+ from auth0_server_python.auth_server.server_client import ServerClient
179
+ from auth0_server_python.auth_types import DomainResolverContext
180
+
181
+ async def domain_resolver(context: DomainResolverContext) -> str:
182
+ host = context.request_headers.get('host', '').split(':')[0]
183
+ domain_map = {
184
+ "acme.yourapp.com": "acme.auth0.com",
185
+ "globex.yourapp.com": "globex.auth0.com",
186
+ }
187
+ return domain_map.get(host, "default.auth0.com")
188
+
189
+ auth0 = ServerClient(
190
+ domain=domain_resolver, # Callable enables MCD mode
191
+ client_id='<AUTH0_CLIENT_ID>',
192
+ client_secret='<AUTH0_CLIENT_SECRET>',
193
+ secret='<AUTH0_SECRET>',
194
+ )
195
+ ```
196
+
197
+ The SDK handles per-domain OIDC discovery, JWKS fetching, issuer validation, and session isolation automatically. Static string domains continue to work unchanged.
198
+
199
+ For more details and examples, see [examples/MultipleCustomDomains.md](examples/MultipleCustomDomains.md).
200
+
173
201
  ## Feedback
174
202
 
175
203
  ### Contributing
@@ -145,6 +145,34 @@ print(response.access_token)
145
145
 
146
146
  For more details and examples, see [examples/CustomTokenExchange.md](examples/CustomTokenExchange.md).
147
147
 
148
+ ### 5. Multiple Custom Domains (MCD)
149
+
150
+ For applications that use multiple custom domains on the same Auth0 tenant, pass a domain resolver function instead of a static domain string:
151
+
152
+ ```python
153
+ from auth0_server_python.auth_server.server_client import ServerClient
154
+ from auth0_server_python.auth_types import DomainResolverContext
155
+
156
+ async def domain_resolver(context: DomainResolverContext) -> str:
157
+ host = context.request_headers.get('host', '').split(':')[0]
158
+ domain_map = {
159
+ "acme.yourapp.com": "acme.auth0.com",
160
+ "globex.yourapp.com": "globex.auth0.com",
161
+ }
162
+ return domain_map.get(host, "default.auth0.com")
163
+
164
+ auth0 = ServerClient(
165
+ domain=domain_resolver, # Callable enables MCD mode
166
+ client_id='<AUTH0_CLIENT_ID>',
167
+ client_secret='<AUTH0_CLIENT_SECRET>',
168
+ secret='<AUTH0_SECRET>',
169
+ )
170
+ ```
171
+
172
+ The SDK handles per-domain OIDC discovery, JWKS fetching, issuer validation, and session isolation automatically. Static string domains continue to work unchanged.
173
+
174
+ For more details and examples, see [examples/MultipleCustomDomains.md](examples/MultipleCustomDomains.md).
175
+
148
176
  ## Feedback
149
177
 
150
178
  ### Contributing
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "auth0-server-python"
3
- version = "1.0.0.b8"
3
+ version = "1.0.0b10"
4
4
  description = "Auth0 server-side Python SDK"
5
5
  readme = "README.md"
6
6
  authors = ["Auth0 <support@okta.com>"]
@@ -17,7 +17,7 @@ pyjwt = ">=2.8.0"
17
17
  authlib = "^1.2"
18
18
  httpx = "^0.28.1"
19
19
  pydantic = "^2.10.6"
20
- jwcrypto = "^1.5.6"
20
+ jwcrypto = "^1.5.7"
21
21
 
22
22
  [tool.poetry.group.dev.dependencies]
23
23
  pytest = "^7.2"
@@ -25,7 +25,7 @@ pytest-cov = "^4.0"
25
25
  pytest-asyncio = ">=0.20.3,<0.24.0"
26
26
  pytest-mock = "^3.14.0"
27
27
  twine = "^6.1.0"
28
- ruff = "^0.1.0"
28
+ ruff = ">=0.1"
29
29
 
30
30
  [tool.pytest.ini_options]
31
31
  addopts = "--cov=auth0_server_python --cov-report=term-missing:skip-covered --cov-report=xml"
@@ -0,0 +1,5 @@
1
+ from .mfa_client import MfaClient
2
+ from .my_account_client import MyAccountClient
3
+ from .server_client import ServerClient
4
+
5
+ __all__ = ["ServerClient", "MyAccountClient", "MfaClient"]
@@ -0,0 +1,528 @@
1
+ """
2
+ MFA Client for auth0-server-python SDK.
3
+ Handles Multi-Factor Authentication operations against the Auth0 MFA API.
4
+ """
5
+
6
+ import time
7
+ from typing import Any, Callable, Optional, Union
8
+
9
+ import httpx
10
+
11
+ from auth0_server_python.auth_schemes.bearer_auth import BearerAuth
12
+ from auth0_server_python.auth_types import (
13
+ AuthenticatorResponse,
14
+ ChallengeResponse,
15
+ EnrollmentResponse,
16
+ MfaRequirements,
17
+ MfaTokenContext,
18
+ MfaVerifyResponse,
19
+ OobEnrollmentResponse,
20
+ OtpEnrollmentResponse,
21
+ StateData,
22
+ TokenSet,
23
+ )
24
+ from auth0_server_python.encryption.encrypt import decrypt, encrypt
25
+ from auth0_server_python.error import (
26
+ DomainResolverError,
27
+ MfaChallengeError,
28
+ MfaEnrollmentError,
29
+ MfaListAuthenticatorsError,
30
+ MfaRequiredError,
31
+ MfaTokenExpiredError,
32
+ MfaTokenInvalidError,
33
+ MfaVerifyError,
34
+ )
35
+ from auth0_server_python.utils.helpers import (
36
+ build_domain_resolver_context,
37
+ validate_resolved_domain_value,
38
+ )
39
+
40
+ DEFAULT_MFA_TOKEN_TTL = 300 # 5 minutes
41
+
42
+
43
+ class MfaClient:
44
+ """
45
+ Client for Auth0 MFA API operations.
46
+
47
+ Provides methods for listing authenticators, enrolling new authenticators,
48
+ deleting authenticators, challenging authenticators, and verifying MFA codes.
49
+
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.
53
+ """
54
+
55
+ def __init__(
56
+ self,
57
+ domain: Union[str, Callable, None],
58
+ client_id: str,
59
+ client_secret: str,
60
+ secret: str,
61
+ state_store=None,
62
+ state_identifier: str = "_a0_session",
63
+ headers: Optional[dict[str, str]] = None
64
+ ):
65
+ if callable(domain):
66
+ self._domain = None
67
+ self._domain_resolver = domain
68
+ else:
69
+ self._domain = domain
70
+ self._domain_resolver = None
71
+ self._client_id = client_id
72
+ self._client_secret = client_secret
73
+ self._secret = secret
74
+ self._state_store = state_store
75
+ self._state_identifier = state_identifier
76
+ self._headers = headers or {}
77
+
78
+ def _get_http_client(self, **kwargs) -> httpx.AsyncClient:
79
+ """Return an httpx.AsyncClient with default headers injected."""
80
+ headers = {**kwargs.pop("headers", {}), **self._headers}
81
+ return httpx.AsyncClient(headers=headers, **kwargs)
82
+
83
+ async def _resolve_base_url(
84
+ self,
85
+ store_options: Optional[dict[str, Any]] = None
86
+ ) -> str:
87
+ """Resolve domain and return base URL for API calls."""
88
+ if self._domain_resolver:
89
+ context = build_domain_resolver_context(store_options)
90
+ try:
91
+ resolved = await self._domain_resolver(context)
92
+ domain = validate_resolved_domain_value(resolved)
93
+ except DomainResolverError:
94
+ raise
95
+ except Exception as e:
96
+ raise DomainResolverError(
97
+ f"Domain resolver function raised an exception: {str(e)}",
98
+ original_error=e
99
+ )
100
+ else:
101
+ domain = self._domain
102
+ return f"https://{domain}"
103
+
104
+ # ============================================================================
105
+ # MFA TOKEN ENCRYPTION / DECRYPTION
106
+ # ============================================================================
107
+
108
+ def _encrypt_mfa_token(
109
+ self,
110
+ raw_mfa_token: str,
111
+ audience: str,
112
+ scope: str,
113
+ mfa_requirements: Optional[MfaRequirements] = None,
114
+ ) -> str:
115
+ """Encrypt an MFA token with context for secure client-side storage."""
116
+ context = MfaTokenContext(
117
+ mfa_token=raw_mfa_token,
118
+ audience=audience,
119
+ scope=scope,
120
+ mfa_requirements=mfa_requirements,
121
+ created_at=int(time.time())
122
+ )
123
+ return encrypt(context.model_dump(), self._secret, "mfa_token")
124
+
125
+ def decrypt_mfa_token(self, encrypted_token: str) -> MfaTokenContext:
126
+ """Decrypt an MFA token and validate TTL."""
127
+ try:
128
+ payload = decrypt(encrypted_token, self._secret, "mfa_token")
129
+ context = MfaTokenContext(**payload)
130
+ except Exception:
131
+ raise MfaTokenInvalidError()
132
+
133
+ # Check TTL
134
+ elapsed = int(time.time()) - context.created_at
135
+ if elapsed > DEFAULT_MFA_TOKEN_TTL:
136
+ raise MfaTokenExpiredError()
137
+
138
+ return context
139
+
140
+ # ============================================================================
141
+ # MFA API OPERATIONS
142
+ # ============================================================================
143
+
144
+ async def list_authenticators(
145
+ self,
146
+ options: dict[str, Any],
147
+ store_options: Optional[dict[str, Any]] = None
148
+ ) -> list[AuthenticatorResponse]:
149
+ """
150
+ Lists all MFA authenticators enrolled by the user.
151
+
152
+ Args:
153
+ options: Dict containing 'mfa_token' (raw, decrypted).
154
+ store_options: Optional options passed to the State Store (e.g. request/response).
155
+
156
+ Returns:
157
+ List of enrolled authenticators.
158
+
159
+ Raises:
160
+ MfaListAuthenticatorsError: When the request fails.
161
+ """
162
+ mfa_token = options["mfa_token"]
163
+ base_url = await self._resolve_base_url(store_options)
164
+ url = f"{base_url}/mfa/authenticators"
165
+
166
+ try:
167
+ async with self._get_http_client() as client:
168
+ response = await client.get(
169
+ url,
170
+ auth=BearerAuth(mfa_token)
171
+ )
172
+
173
+ if response.status_code != 200:
174
+ error_data = response.json()
175
+ raise MfaListAuthenticatorsError(
176
+ error_data.get("error_description", "Failed to list authenticators"),
177
+ error_data
178
+ )
179
+
180
+ api_response = response.json()
181
+ return [AuthenticatorResponse(**auth) for auth in api_response]
182
+
183
+ except MfaListAuthenticatorsError:
184
+ raise
185
+ except Exception as e:
186
+ raise MfaListAuthenticatorsError(
187
+ f"Unexpected error listing authenticators: {str(e)}"
188
+ )
189
+
190
+ async def enroll_authenticator(
191
+ self,
192
+ options: dict[str, Any],
193
+ store_options: Optional[dict[str, Any]] = None
194
+ ) -> EnrollmentResponse:
195
+ """
196
+ Enrolls a new MFA authenticator for the user.
197
+
198
+ Args:
199
+ options: Dict containing enrollment parameters.
200
+ Required: 'mfa_token', 'factor_type' (otp, sms, voice, email, auth0).
201
+ Optional: 'phone_number', 'email'.
202
+ store_options: Optional options passed to the State Store (e.g. request/response).
203
+
204
+ Returns:
205
+ OtpEnrollmentResponse or OobEnrollmentResponse.
206
+
207
+ Raises:
208
+ MfaEnrollmentError: When enrollment fails.
209
+ """
210
+ mfa_token = options["mfa_token"]
211
+ factor_type = options["factor_type"]
212
+ base_url = await self._resolve_base_url(store_options)
213
+ url = f"{base_url}/mfa/associate"
214
+
215
+ # Map factor_type to Auth0 API parameters
216
+ if factor_type == "otp":
217
+ authenticator_type = "otp"
218
+ oob_channels = None
219
+ elif factor_type in ["sms", "voice", "email", "auth0"]:
220
+ authenticator_type = "oob"
221
+ oob_channels = factor_type
222
+ else:
223
+ raise MfaEnrollmentError(
224
+ f"Unsupported factor_type: {factor_type}. Supported types: otp, sms, voice, email, auth0"
225
+ )
226
+
227
+ # Build API request body
228
+ body: dict[str, Any] = {
229
+ "authenticator_types": [authenticator_type]
230
+ }
231
+
232
+ if oob_channels:
233
+ body["oob_channels"] = [oob_channels]
234
+
235
+ if "phone_number" in options and options["phone_number"]:
236
+ body["phone_number"] = options["phone_number"]
237
+
238
+ if "email" in options and options["email"]:
239
+ body["email"] = options["email"]
240
+
241
+ try:
242
+ async with self._get_http_client() as client:
243
+ response = await client.post(
244
+ url,
245
+ json=body,
246
+ auth=BearerAuth(mfa_token),
247
+ headers={"Content-Type": "application/json"}
248
+ )
249
+
250
+ if response.status_code != 200:
251
+ error_data = response.json()
252
+ raise MfaEnrollmentError(
253
+ error_data.get("error_description", "Failed to enroll authenticator"),
254
+ error_data
255
+ )
256
+
257
+ api_response = response.json()
258
+ authenticator_type = api_response.get("authenticator_type")
259
+
260
+ if authenticator_type == "otp":
261
+ return OtpEnrollmentResponse(**api_response)
262
+ elif authenticator_type == "oob":
263
+ return OobEnrollmentResponse(**api_response)
264
+ else:
265
+ raise MfaEnrollmentError(
266
+ f"Unexpected authenticator type: {authenticator_type}"
267
+ )
268
+
269
+ except MfaEnrollmentError:
270
+ raise
271
+ except Exception as e:
272
+ raise MfaEnrollmentError(
273
+ f"Unexpected error enrolling authenticator: {str(e)}"
274
+ )
275
+
276
+ async def challenge_authenticator(
277
+ self,
278
+ options: dict[str, Any],
279
+ store_options: Optional[dict[str, Any]] = None
280
+ ) -> ChallengeResponse:
281
+ """
282
+ Initiates an MFA challenge for user verification.
283
+
284
+ Args:
285
+ options: Dict containing 'mfa_token', 'factor_type' (otp, sms, voice, email, auth0),
286
+ and optionally 'authenticator_id'.
287
+ store_options: Optional options passed to the State Store (e.g. request/response).
288
+
289
+ Returns:
290
+ ChallengeResponse with challenge details.
291
+
292
+ Raises:
293
+ MfaChallengeError: When the challenge fails.
294
+ """
295
+ mfa_token = options["mfa_token"]
296
+ factor_type = options["factor_type"]
297
+ base_url = await self._resolve_base_url(store_options)
298
+ url = f"{base_url}/mfa/challenge"
299
+
300
+ # Map factor_type to Auth0 API challenge_type
301
+ if factor_type == "otp":
302
+ challenge_type = "otp"
303
+ elif factor_type in ["sms", "voice", "email", "auth0"]:
304
+ challenge_type = "oob"
305
+ else:
306
+ raise MfaChallengeError(
307
+ f"Unsupported factor_type: {factor_type}. Supported types: otp, sms, voice, email, auth0"
308
+ )
309
+
310
+ body: dict[str, Any] = {
311
+ "mfa_token": mfa_token,
312
+ "client_id": self._client_id,
313
+ "client_secret": self._client_secret,
314
+ "challenge_type": challenge_type
315
+ }
316
+
317
+ if "authenticator_id" in options and options["authenticator_id"]:
318
+ body["authenticator_id"] = options["authenticator_id"]
319
+
320
+ try:
321
+ async with self._get_http_client() as client:
322
+ response = await client.post(
323
+ url,
324
+ json=body,
325
+ headers={"Content-Type": "application/json"}
326
+ )
327
+
328
+ if response.status_code != 200:
329
+ error_data = response.json()
330
+ raise MfaChallengeError(
331
+ error_data.get("error_description", "Failed to challenge authenticator"),
332
+ error_data
333
+ )
334
+
335
+ api_response = response.json()
336
+ return ChallengeResponse(**api_response)
337
+
338
+ except MfaChallengeError:
339
+ raise
340
+ except Exception as e:
341
+ raise MfaChallengeError(
342
+ f"Unexpected error challenging authenticator: {str(e)}"
343
+ )
344
+
345
+ async def verify(
346
+ self,
347
+ options: dict[str, Any],
348
+ store_options: Optional[dict[str, Any]] = None
349
+ ) -> MfaVerifyResponse:
350
+ """
351
+ Verifies an MFA code and completes authentication.
352
+
353
+ Supports OTP, OOB (with binding code), and recovery code verification.
354
+
355
+ If Auth0 returns 'mfa_required' again (chained MFA), raises MfaRequiredError
356
+ with a raw mfa_token. The framework SDK (e.g. auth0-fastapi) is responsible
357
+ for encrypting the token before returning it to the client.
358
+
359
+ Args:
360
+ options: Dict containing 'mfa_token' and one of:
361
+ - 'otp': OTP code
362
+ - 'oob_code' + 'binding_code': OOB verification
363
+ - 'recovery_code': Recovery code
364
+ - 'persist': bool (optional, default=False) - Persist tokens to state store
365
+ - 'audience': str (optional, required if persist=True) - Audience for token_set
366
+ - 'scope': str (optional) - Scope for token_set
367
+ store_options: Optional options passed to the State Store (e.g. request/response).
368
+
369
+ Returns:
370
+ MfaVerifyResponse with access_token, token_type, etc.
371
+
372
+ Raises:
373
+ MfaVerifyError: When verification fails.
374
+ MfaRequiredError: When chained MFA is required.
375
+ """
376
+ mfa_token = options["mfa_token"]
377
+
378
+ # Determine grant type and build body
379
+ body: dict[str, Any] = {
380
+ "client_id": self._client_id,
381
+ "client_secret": self._client_secret,
382
+ "mfa_token": mfa_token
383
+ }
384
+
385
+ if "otp" in options:
386
+ body["grant_type"] = "http://auth0.com/oauth/grant-type/mfa-otp"
387
+ body["otp"] = options["otp"]
388
+ elif "oob_code" in options:
389
+ body["grant_type"] = "http://auth0.com/oauth/grant-type/mfa-oob"
390
+ body["oob_code"] = options["oob_code"]
391
+ if options.get("binding_code"):
392
+ body["binding_code"] = options["binding_code"]
393
+ elif "recovery_code" in options:
394
+ body["grant_type"] = "http://auth0.com/oauth/grant-type/mfa-recovery-code"
395
+ body["recovery_code"] = options["recovery_code"]
396
+ else:
397
+ raise MfaVerifyError(
398
+ "No verification credential provided (otp, oob_code, or recovery_code)"
399
+ )
400
+
401
+ try:
402
+ base_url = await self._resolve_base_url(store_options)
403
+ token_endpoint = f"{base_url}/oauth/token"
404
+
405
+ async with self._get_http_client() as client:
406
+ response = await client.post(
407
+ token_endpoint,
408
+ data=body,
409
+ headers={"Content-Type": "application/x-www-form-urlencoded"}
410
+ )
411
+
412
+ if response.status_code != 200:
413
+ error_data = response.json()
414
+
415
+ # Handle chained MFA — token is raw; encryption is the
416
+ # framework SDK's responsibility (see ServerClient.get_access_token).
417
+ 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
428
+ )
429
+
430
+ raise MfaVerifyError(
431
+ error_data.get("error_description", "MFA verification failed"),
432
+ error_data
433
+ )
434
+
435
+ token_response = response.json()
436
+ verify_response = MfaVerifyResponse(**token_response)
437
+
438
+ # Persist tokens to state store if requested
439
+ if options.get("persist") and self._state_store:
440
+ await self._persist_mfa_tokens(
441
+ verify_response=verify_response,
442
+ options=options,
443
+ store_options=store_options
444
+ )
445
+
446
+ return verify_response
447
+
448
+ except (MfaVerifyError, MfaRequiredError):
449
+ raise
450
+ except Exception as e:
451
+ raise MfaVerifyError(
452
+ f"Unexpected error during MFA verification: {str(e)}"
453
+ )
454
+
455
+ async def _persist_mfa_tokens(
456
+ self,
457
+ verify_response: MfaVerifyResponse,
458
+ options: dict[str, Any],
459
+ store_options: Optional[dict[str, Any]] = None
460
+ ) -> None:
461
+ """
462
+ Persist MFA verification tokens to the state store.
463
+
464
+ Updates the session with the new access_token and id_token from MFA verification.
465
+
466
+ Args:
467
+ verify_response: The response from verify() containing tokens
468
+ options: Dict containing:
469
+ - 'audience': str - Audience for token_set
470
+ - 'scope': str (optional) - Scope for token_set
471
+ store_options: Optional options passed to the State Store (e.g. request/response).
472
+ """
473
+ audience = options.get("audience")
474
+ scope = options.get("scope")
475
+
476
+ if not audience:
477
+ raise MfaVerifyError(
478
+ "audience is required when persist=True"
479
+ )
480
+
481
+ try:
482
+ # Get existing state
483
+ state_data = await self._state_store.get(
484
+ self._state_identifier,
485
+ store_options
486
+ )
487
+
488
+ if not state_data:
489
+ raise MfaVerifyError(
490
+ "No existing session found to update with MFA tokens"
491
+ )
492
+
493
+ # Parse state data
494
+ existing_state = StateData(**state_data) if isinstance(state_data, dict) else state_data
495
+
496
+ # Update id_token if present
497
+ if verify_response.id_token:
498
+ existing_state.id_token = verify_response.id_token
499
+
500
+ # Create token_set for the access_token
501
+ expires_at = int(time.time()) + verify_response.expires_in
502
+
503
+ new_token_set = TokenSet(
504
+ audience=audience,
505
+ access_token=verify_response.access_token,
506
+ scope=scope,
507
+ expires_at=expires_at
508
+ )
509
+
510
+ # Add to token_sets, replacing any existing token_set for this audience
511
+ existing_state.token_sets = [
512
+ ts for ts in existing_state.token_sets if ts.audience != audience
513
+ ]
514
+ existing_state.token_sets.append(new_token_set)
515
+
516
+ # Persist updated state
517
+ await self._state_store.set(
518
+ self._state_identifier,
519
+ existing_state.model_dump(),
520
+ options=store_options
521
+ )
522
+
523
+ except MfaVerifyError:
524
+ raise
525
+ except Exception as e:
526
+ raise MfaVerifyError(
527
+ f"Failed to persist MFA tokens to state store: {str(e)}"
528
+ )