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.
- {auth0_server_python-1.0.0b11 → auth0_server_python-1.0.0b13}/PKG-INFO +19 -1
- {auth0_server_python-1.0.0b11 → auth0_server_python-1.0.0b13}/README.md +18 -0
- {auth0_server_python-1.0.0b11 → auth0_server_python-1.0.0b13}/pyproject.toml +1 -1
- auth0_server_python-1.0.0b13/src/auth0_server_python/auth_schemes/__init__.py +4 -0
- auth0_server_python-1.0.0b13/src/auth0_server_python/auth_schemes/dpop_auth.py +94 -0
- {auth0_server_python-1.0.0b11 → auth0_server_python-1.0.0b13}/src/auth0_server_python/auth_server/mfa_client.py +185 -45
- auth0_server_python-1.0.0b13/src/auth0_server_python/auth_server/my_account_client.py +822 -0
- {auth0_server_python-1.0.0b11 → auth0_server_python-1.0.0b13}/src/auth0_server_python/auth_server/server_client.py +433 -3
- {auth0_server_python-1.0.0b11 → auth0_server_python-1.0.0b13}/src/auth0_server_python/auth_types/__init__.py +289 -9
- {auth0_server_python-1.0.0b11 → auth0_server_python-1.0.0b13}/src/auth0_server_python/error/__init__.py +36 -0
- auth0_server_python-1.0.0b13/src/auth0_server_python/tests/test_dpop_auth.py +185 -0
- {auth0_server_python-1.0.0b11 → auth0_server_python-1.0.0b13}/src/auth0_server_python/tests/test_mfa_client.py +190 -40
- auth0_server_python-1.0.0b13/src/auth0_server_python/tests/test_my_account_client.py +1424 -0
- {auth0_server_python-1.0.0b11 → auth0_server_python-1.0.0b13}/src/auth0_server_python/tests/test_server_client.py +2399 -1
- {auth0_server_python-1.0.0b11 → auth0_server_python-1.0.0b13}/src/auth0_server_python/utils/helpers.py +40 -1
- auth0_server_python-1.0.0b11/src/auth0_server_python/auth_schemes/__init__.py +0 -3
- auth0_server_python-1.0.0b11/src/auth0_server_python/auth_server/my_account_client.py +0 -342
- auth0_server_python-1.0.0b11/src/auth0_server_python/tests/test_my_account_client.py +0 -504
- {auth0_server_python-1.0.0b11 → auth0_server_python-1.0.0b13}/LICENSE +0 -0
- {auth0_server_python-1.0.0b11 → auth0_server_python-1.0.0b13}/src/auth0_server_python/auth_schemes/bearer_auth.py +0 -0
- {auth0_server_python-1.0.0b11 → auth0_server_python-1.0.0b13}/src/auth0_server_python/auth_server/__init__.py +0 -0
- {auth0_server_python-1.0.0b11 → auth0_server_python-1.0.0b13}/src/auth0_server_python/encryption/__init__.py +0 -0
- {auth0_server_python-1.0.0b11 → auth0_server_python-1.0.0b13}/src/auth0_server_python/encryption/encrypt.py +0 -0
- {auth0_server_python-1.0.0b11 → auth0_server_python-1.0.0b13}/src/auth0_server_python/store/__init__.py +0 -0
- {auth0_server_python-1.0.0b11 → auth0_server_python-1.0.0b13}/src/auth0_server_python/store/abstract.py +0 -0
- {auth0_server_python-1.0.0b11 → auth0_server_python-1.0.0b13}/src/auth0_server_python/telemetry.py +0 -0
- {auth0_server_python-1.0.0b11 → auth0_server_python-1.0.0b13}/src/auth0_server_python/tests/test_telemetry.py +0 -0
- {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.
|
|
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
|
|
@@ -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
|
|
51
|
-
|
|
52
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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=
|
|
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 =
|
|
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
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
528
|
-
)
|
|
667
|
+
"Failed to persist MFA tokens to state store"
|
|
668
|
+
) from e
|