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.
- {auth0_server_python-1.0.0b8 → auth0_server_python-1.0.0b10}/PKG-INFO +30 -2
- {auth0_server_python-1.0.0b8 → auth0_server_python-1.0.0b10}/README.md +28 -0
- {auth0_server_python-1.0.0b8 → auth0_server_python-1.0.0b10}/pyproject.toml +3 -3
- auth0_server_python-1.0.0b10/src/auth0_server_python/auth_server/__init__.py +5 -0
- auth0_server_python-1.0.0b10/src/auth0_server_python/auth_server/mfa_client.py +528 -0
- {auth0_server_python-1.0.0b8 → auth0_server_python-1.0.0b10}/src/auth0_server_python/auth_server/my_account_client.py +14 -6
- {auth0_server_python-1.0.0b8 → auth0_server_python-1.0.0b10}/src/auth0_server_python/auth_server/server_client.py +747 -120
- {auth0_server_python-1.0.0b8 → auth0_server_python-1.0.0b10}/src/auth0_server_python/auth_types/__init__.py +186 -1
- {auth0_server_python-1.0.0b8 → auth0_server_python-1.0.0b10}/src/auth0_server_python/error/__init__.py +127 -1
- {auth0_server_python-1.0.0b8 → auth0_server_python-1.0.0b10}/src/auth0_server_python/store/abstract.py +15 -2
- auth0_server_python-1.0.0b10/src/auth0_server_python/telemetry.py +39 -0
- auth0_server_python-1.0.0b10/src/auth0_server_python/tests/test_mfa_client.py +848 -0
- {auth0_server_python-1.0.0b8 → auth0_server_python-1.0.0b10}/src/auth0_server_python/tests/test_my_account_client.py +1 -0
- {auth0_server_python-1.0.0b8 → auth0_server_python-1.0.0b10}/src/auth0_server_python/tests/test_server_client.py +2084 -92
- auth0_server_python-1.0.0b10/src/auth0_server_python/tests/test_telemetry.py +131 -0
- {auth0_server_python-1.0.0b8 → auth0_server_python-1.0.0b10}/src/auth0_server_python/utils/helpers.py +69 -0
- auth0_server_python-1.0.0b8/src/auth0_server_python/auth_server/__init__.py +0 -4
- {auth0_server_python-1.0.0b8 → auth0_server_python-1.0.0b10}/LICENSE +0 -0
- {auth0_server_python-1.0.0b8 → auth0_server_python-1.0.0b10}/src/auth0_server_python/auth_schemes/__init__.py +0 -0
- {auth0_server_python-1.0.0b8 → auth0_server_python-1.0.0b10}/src/auth0_server_python/auth_schemes/bearer_auth.py +0 -0
- {auth0_server_python-1.0.0b8 → auth0_server_python-1.0.0b10}/src/auth0_server_python/encryption/__init__.py +0 -0
- {auth0_server_python-1.0.0b8 → auth0_server_python-1.0.0b10}/src/auth0_server_python/encryption/encrypt.py +0 -0
- {auth0_server_python-1.0.0b8 → auth0_server_python-1.0.0b10}/src/auth0_server_python/store/__init__.py +0 -0
- {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.
|
|
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.
|
|
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.
|
|
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.
|
|
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 = "
|
|
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,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
|
+
)
|