auth0-server-python 1.0.0b9__tar.gz → 1.0.0b11__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.
- {auth0_server_python-1.0.0b9 → auth0_server_python-1.0.0b11}/PKG-INFO +5 -1
- {auth0_server_python-1.0.0b9 → auth0_server_python-1.0.0b11}/README.md +4 -0
- {auth0_server_python-1.0.0b9 → auth0_server_python-1.0.0b11}/pyproject.toml +1 -1
- auth0_server_python-1.0.0b11/src/auth0_server_python/auth_server/__init__.py +5 -0
- auth0_server_python-1.0.0b11/src/auth0_server_python/auth_server/mfa_client.py +528 -0
- {auth0_server_python-1.0.0b9 → auth0_server_python-1.0.0b11}/src/auth0_server_python/auth_server/my_account_client.py +13 -6
- {auth0_server_python-1.0.0b9 → auth0_server_python-1.0.0b11}/src/auth0_server_python/auth_server/server_client.py +184 -15
- {auth0_server_python-1.0.0b9 → auth0_server_python-1.0.0b11}/src/auth0_server_python/auth_types/__init__.py +172 -42
- {auth0_server_python-1.0.0b9 → auth0_server_python-1.0.0b11}/src/auth0_server_python/error/__init__.py +97 -1
- auth0_server_python-1.0.0b11/src/auth0_server_python/telemetry.py +39 -0
- auth0_server_python-1.0.0b11/src/auth0_server_python/tests/test_mfa_client.py +848 -0
- {auth0_server_python-1.0.0b9 → auth0_server_python-1.0.0b11}/src/auth0_server_python/tests/test_server_client.py +1566 -35
- auth0_server_python-1.0.0b11/src/auth0_server_python/tests/test_telemetry.py +131 -0
- {auth0_server_python-1.0.0b9 → auth0_server_python-1.0.0b11}/src/auth0_server_python/utils/helpers.py +42 -1
- auth0_server_python-1.0.0b9/src/auth0_server_python/auth_server/__init__.py +0 -4
- {auth0_server_python-1.0.0b9 → auth0_server_python-1.0.0b11}/LICENSE +0 -0
- {auth0_server_python-1.0.0b9 → auth0_server_python-1.0.0b11}/src/auth0_server_python/auth_schemes/__init__.py +0 -0
- {auth0_server_python-1.0.0b9 → auth0_server_python-1.0.0b11}/src/auth0_server_python/auth_schemes/bearer_auth.py +0 -0
- {auth0_server_python-1.0.0b9 → auth0_server_python-1.0.0b11}/src/auth0_server_python/encryption/__init__.py +0 -0
- {auth0_server_python-1.0.0b9 → auth0_server_python-1.0.0b11}/src/auth0_server_python/encryption/encrypt.py +0 -0
- {auth0_server_python-1.0.0b9 → auth0_server_python-1.0.0b11}/src/auth0_server_python/store/__init__.py +0 -0
- {auth0_server_python-1.0.0b9 → auth0_server_python-1.0.0b11}/src/auth0_server_python/store/abstract.py +0 -0
- {auth0_server_python-1.0.0b9 → auth0_server_python-1.0.0b11}/src/auth0_server_python/tests/test_my_account_client.py +0 -0
- {auth0_server_python-1.0.0b9 → auth0_server_python-1.0.0b11}/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.
|
|
3
|
+
Version: 1.0.0b11
|
|
4
4
|
Summary: Auth0 server-side Python SDK
|
|
5
5
|
License: MIT
|
|
6
6
|
License-File: LICENSE
|
|
@@ -129,6 +129,10 @@ async def callback(request: Request):
|
|
|
129
129
|
return RedirectResponse(url="/")
|
|
130
130
|
```
|
|
131
131
|
|
|
132
|
+
#### Organizations
|
|
133
|
+
|
|
134
|
+
The SDK supports [Auth0 Organizations](https://auth0.com/docs/organizations) with first-class `organization` and `invitation` parameters on `ServerClient` and `StartInteractiveLoginOptions`. Token claim validation is enforced automatically at callback. For setup, invitation flows, error handling, and reading org data from the session, see [examples/InteractiveLogin.md](examples/InteractiveLogin.md#8-organizations).
|
|
135
|
+
|
|
132
136
|
### 4. Login with Custom Token Exchange
|
|
133
137
|
|
|
134
138
|
If you're migrating from a legacy authentication system or integrating with a custom identity provider, you can exchange external tokens for Auth0 tokens using the OAuth 2.0 Token Exchange specification (RFC 8693):
|
|
@@ -104,6 +104,10 @@ async def callback(request: Request):
|
|
|
104
104
|
return RedirectResponse(url="/")
|
|
105
105
|
```
|
|
106
106
|
|
|
107
|
+
#### Organizations
|
|
108
|
+
|
|
109
|
+
The SDK supports [Auth0 Organizations](https://auth0.com/docs/organizations) with first-class `organization` and `invitation` parameters on `ServerClient` and `StartInteractiveLoginOptions`. Token claim validation is enforced automatically at callback. For setup, invitation flows, error handling, and reading org data from the session, see [examples/InteractiveLogin.md](examples/InteractiveLogin.md#8-organizations).
|
|
110
|
+
|
|
107
111
|
### 4. Login with Custom Token Exchange
|
|
108
112
|
|
|
109
113
|
If you're migrating from a legacy authentication system or integrating with a custom identity provider, you can exchange external tokens for Auth0 tokens using the OAuth 2.0 Token Exchange specification (RFC 8693):
|
|
@@ -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
|
+
)
|
|
@@ -25,14 +25,21 @@ class MyAccountClient:
|
|
|
25
25
|
Client for interacting with the Auth0 MyAccount API.
|
|
26
26
|
"""
|
|
27
27
|
|
|
28
|
-
def __init__(self, domain: str):
|
|
28
|
+
def __init__(self, domain: str, headers: Optional[dict[str, str]] = None):
|
|
29
29
|
"""
|
|
30
30
|
Initialize the MyAccount API client.
|
|
31
31
|
|
|
32
32
|
Args:
|
|
33
33
|
domain: Auth0 domain (e.g., '<tenant>.<locality>.auth0.com')
|
|
34
|
+
headers: Optional default headers to include on every request
|
|
34
35
|
"""
|
|
35
36
|
self._domain = domain
|
|
37
|
+
self._headers = headers or {}
|
|
38
|
+
|
|
39
|
+
def _get_http_client(self, **kwargs) -> httpx.AsyncClient:
|
|
40
|
+
"""Return an httpx.AsyncClient with default headers injected."""
|
|
41
|
+
headers = {**kwargs.pop("headers", {}), **self._headers}
|
|
42
|
+
return httpx.AsyncClient(headers=headers, **kwargs)
|
|
36
43
|
|
|
37
44
|
@property
|
|
38
45
|
def audience(self):
|
|
@@ -64,7 +71,7 @@ class MyAccountClient:
|
|
|
64
71
|
ApiError: If the request fails due to network or other issues
|
|
65
72
|
"""
|
|
66
73
|
try:
|
|
67
|
-
async with
|
|
74
|
+
async with self._get_http_client() as client:
|
|
68
75
|
response = await client.post(
|
|
69
76
|
url=f"{self.audience}v1/connected-accounts/connect",
|
|
70
77
|
json=request.model_dump(exclude_none=True),
|
|
@@ -114,7 +121,7 @@ class MyAccountClient:
|
|
|
114
121
|
ApiError: If the request fails due to network or other issues
|
|
115
122
|
"""
|
|
116
123
|
try:
|
|
117
|
-
async with
|
|
124
|
+
async with self._get_http_client() as client:
|
|
118
125
|
response = await client.post(
|
|
119
126
|
url=f"{self.audience}v1/connected-accounts/complete",
|
|
120
127
|
json=request.model_dump(exclude_none=True),
|
|
@@ -176,7 +183,7 @@ class MyAccountClient:
|
|
|
176
183
|
raise InvalidArgumentError("take", "The 'take' parameter must be a positive integer.")
|
|
177
184
|
|
|
178
185
|
try:
|
|
179
|
-
async with
|
|
186
|
+
async with self._get_http_client() as client:
|
|
180
187
|
params = {}
|
|
181
188
|
if connection:
|
|
182
189
|
params["connection"] = connection
|
|
@@ -243,7 +250,7 @@ class MyAccountClient:
|
|
|
243
250
|
raise MissingRequiredArgumentError("connected_account_id")
|
|
244
251
|
|
|
245
252
|
try:
|
|
246
|
-
async with
|
|
253
|
+
async with self._get_http_client() as client:
|
|
247
254
|
response = await client.delete(
|
|
248
255
|
url=f"{self.audience}v1/connected-accounts/accounts/{connected_account_id}",
|
|
249
256
|
auth=BearerAuth(access_token)
|
|
@@ -298,7 +305,7 @@ class MyAccountClient:
|
|
|
298
305
|
raise InvalidArgumentError("take", "The 'take' parameter must be a positive integer.")
|
|
299
306
|
|
|
300
307
|
try:
|
|
301
|
-
async with
|
|
308
|
+
async with self._get_http_client() as client:
|
|
302
309
|
params = {}
|
|
303
310
|
if from_param:
|
|
304
311
|
params["from"] = from_param
|