auth0-server-python 1.0.0b15__tar.gz → 1.0.0b16__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.0b15 → auth0_server_python-1.0.0b16}/PKG-INFO +24 -1
- {auth0_server_python-1.0.0b15 → auth0_server_python-1.0.0b16}/README.md +23 -0
- {auth0_server_python-1.0.0b15 → auth0_server_python-1.0.0b16}/pyproject.toml +1 -1
- {auth0_server_python-1.0.0b15 → auth0_server_python-1.0.0b16}/src/auth0_server_python/auth_server/mfa_client.py +21 -1
- {auth0_server_python-1.0.0b15 → auth0_server_python-1.0.0b16}/src/auth0_server_python/auth_server/my_account_client.py +13 -1
- {auth0_server_python-1.0.0b15 → auth0_server_python-1.0.0b16}/src/auth0_server_python/auth_server/passwordless_client.py +7 -2
- {auth0_server_python-1.0.0b15 → auth0_server_python-1.0.0b16}/src/auth0_server_python/auth_server/server_client.py +135 -12
- {auth0_server_python-1.0.0b15 → auth0_server_python-1.0.0b16}/src/auth0_server_python/tests/test_mfa_client.py +64 -0
- {auth0_server_python-1.0.0b15 → auth0_server_python-1.0.0b16}/src/auth0_server_python/tests/test_my_account_client.py +22 -0
- {auth0_server_python-1.0.0b15 → auth0_server_python-1.0.0b16}/src/auth0_server_python/tests/test_passwordless_client.py +116 -0
- {auth0_server_python-1.0.0b15 → auth0_server_python-1.0.0b16}/src/auth0_server_python/tests/test_server_client.py +502 -0
- {auth0_server_python-1.0.0b15 → auth0_server_python-1.0.0b16}/LICENSE +0 -0
- {auth0_server_python-1.0.0b15 → auth0_server_python-1.0.0b16}/src/auth0_server_python/auth_schemes/__init__.py +0 -0
- {auth0_server_python-1.0.0b15 → auth0_server_python-1.0.0b16}/src/auth0_server_python/auth_schemes/bearer_auth.py +0 -0
- {auth0_server_python-1.0.0b15 → auth0_server_python-1.0.0b16}/src/auth0_server_python/auth_schemes/client_assertion.py +0 -0
- {auth0_server_python-1.0.0b15 → auth0_server_python-1.0.0b16}/src/auth0_server_python/auth_schemes/dpop_auth.py +0 -0
- {auth0_server_python-1.0.0b15 → auth0_server_python-1.0.0b16}/src/auth0_server_python/auth_server/__init__.py +0 -0
- {auth0_server_python-1.0.0b15 → auth0_server_python-1.0.0b16}/src/auth0_server_python/auth_types/__init__.py +0 -0
- {auth0_server_python-1.0.0b15 → auth0_server_python-1.0.0b16}/src/auth0_server_python/encryption/__init__.py +0 -0
- {auth0_server_python-1.0.0b15 → auth0_server_python-1.0.0b16}/src/auth0_server_python/encryption/encrypt.py +0 -0
- {auth0_server_python-1.0.0b15 → auth0_server_python-1.0.0b16}/src/auth0_server_python/error/__init__.py +0 -0
- {auth0_server_python-1.0.0b15 → auth0_server_python-1.0.0b16}/src/auth0_server_python/store/__init__.py +0 -0
- {auth0_server_python-1.0.0b15 → auth0_server_python-1.0.0b16}/src/auth0_server_python/store/abstract.py +0 -0
- {auth0_server_python-1.0.0b15 → auth0_server_python-1.0.0b16}/src/auth0_server_python/telemetry.py +0 -0
- {auth0_server_python-1.0.0b15 → auth0_server_python-1.0.0b16}/src/auth0_server_python/tests/test_dpop_auth.py +0 -0
- {auth0_server_python-1.0.0b15 → auth0_server_python-1.0.0b16}/src/auth0_server_python/tests/test_telemetry.py +0 -0
- {auth0_server_python-1.0.0b15 → auth0_server_python-1.0.0b16}/src/auth0_server_python/utils/__init__.py +0 -0
- {auth0_server_python-1.0.0b15 → auth0_server_python-1.0.0b16}/src/auth0_server_python/utils/helpers.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.0b16
|
|
4
4
|
Summary: Auth0 server-side Python SDK
|
|
5
5
|
License: MIT
|
|
6
6
|
License-File: LICENSE
|
|
@@ -109,6 +109,29 @@ The key must be a PKCS8 PEM private key. Register its public key on your Auth0 a
|
|
|
109
109
|
> [!IMPORTANT]
|
|
110
110
|
> Private keys must not be committed to source control. Load them from a secure secret store or an environment-provided file.
|
|
111
111
|
|
|
112
|
+
#### Authenticating with Mutual TLS (mTLS)
|
|
113
|
+
|
|
114
|
+
The SDK supports mTLS client authentication (RFC 8705): the client presents a TLS certificate during the handshake instead of a client secret. Pass `use_mtls=True` and a caller-built `ssl.SSLContext` that already has the certificate loaded:
|
|
115
|
+
|
|
116
|
+
```python
|
|
117
|
+
import ssl
|
|
118
|
+
|
|
119
|
+
ssl_context = ssl.create_default_context()
|
|
120
|
+
ssl_context.load_cert_chain("client.crt", "client.key")
|
|
121
|
+
|
|
122
|
+
auth0 = ServerClient(
|
|
123
|
+
domain="login.example.com", # self_managed_certs custom domain
|
|
124
|
+
client_id="<AUTH0_CLIENT_ID>",
|
|
125
|
+
use_mtls=True,
|
|
126
|
+
ssl_context=ssl_context,
|
|
127
|
+
secret="<AUTH0_SECRET>",
|
|
128
|
+
)
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
`use_mtls=True` requires an Enterprise tenant with the Highly Regulated Identity add-on, a `self_managed_certs` custom domain, and mTLS endpoint aliases enabled. It cannot be combined with `client_secret`, `client_assertion_signing_key`, or a per-call `dpop_key`. Each raises `ConfigurationError`. See the [Auth0 mTLS configuration docs](https://auth0.com/docs/get-started/applications/configure-mtls) for tenant-side setup steps.
|
|
132
|
+
|
|
133
|
+
See [examples/MutualTLS.md](examples/MutualTLS.md) for the full setup guide, certificate generation, and token sender-constraining details.
|
|
134
|
+
|
|
112
135
|
### 3. Add login to your Application (interactive)
|
|
113
136
|
|
|
114
137
|
Before using redirect-based login, ensure the `redirect_uri` is configured when initializing the SDK:
|
|
@@ -84,6 +84,29 @@ The key must be a PKCS8 PEM private key. Register its public key on your Auth0 a
|
|
|
84
84
|
> [!IMPORTANT]
|
|
85
85
|
> Private keys must not be committed to source control. Load them from a secure secret store or an environment-provided file.
|
|
86
86
|
|
|
87
|
+
#### Authenticating with Mutual TLS (mTLS)
|
|
88
|
+
|
|
89
|
+
The SDK supports mTLS client authentication (RFC 8705): the client presents a TLS certificate during the handshake instead of a client secret. Pass `use_mtls=True` and a caller-built `ssl.SSLContext` that already has the certificate loaded:
|
|
90
|
+
|
|
91
|
+
```python
|
|
92
|
+
import ssl
|
|
93
|
+
|
|
94
|
+
ssl_context = ssl.create_default_context()
|
|
95
|
+
ssl_context.load_cert_chain("client.crt", "client.key")
|
|
96
|
+
|
|
97
|
+
auth0 = ServerClient(
|
|
98
|
+
domain="login.example.com", # self_managed_certs custom domain
|
|
99
|
+
client_id="<AUTH0_CLIENT_ID>",
|
|
100
|
+
use_mtls=True,
|
|
101
|
+
ssl_context=ssl_context,
|
|
102
|
+
secret="<AUTH0_SECRET>",
|
|
103
|
+
)
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
`use_mtls=True` requires an Enterprise tenant with the Highly Regulated Identity add-on, a `self_managed_certs` custom domain, and mTLS endpoint aliases enabled. It cannot be combined with `client_secret`, `client_assertion_signing_key`, or a per-call `dpop_key`. Each raises `ConfigurationError`. See the [Auth0 mTLS configuration docs](https://auth0.com/docs/get-started/applications/configure-mtls) for tenant-side setup steps.
|
|
107
|
+
|
|
108
|
+
See [examples/MutualTLS.md](examples/MutualTLS.md) for the full setup guide, certificate generation, and token sender-constraining details.
|
|
109
|
+
|
|
87
110
|
### 3. Add login to your Application (interactive)
|
|
88
111
|
|
|
89
112
|
Before using redirect-based login, ensure the `redirect_uri` is configured when initializing the SDK:
|
|
@@ -4,6 +4,7 @@ Handles Multi-Factor Authentication operations against the Auth0 MFA API.
|
|
|
4
4
|
"""
|
|
5
5
|
|
|
6
6
|
import json
|
|
7
|
+
import ssl
|
|
7
8
|
import time
|
|
8
9
|
from collections.abc import Awaitable, Callable
|
|
9
10
|
from typing import TYPE_CHECKING, Any, Optional, Union
|
|
@@ -74,6 +75,9 @@ class MfaClient:
|
|
|
74
75
|
] = None,
|
|
75
76
|
mfa_token_ttl: int = DEFAULT_MFA_TOKEN_TTL,
|
|
76
77
|
apply_client_authentication: Optional[Callable] = None,
|
|
78
|
+
use_mtls: bool = False,
|
|
79
|
+
ssl_context: Optional[ssl.SSLContext] = None,
|
|
80
|
+
token_endpoint_resolver: Optional[Callable[..., Awaitable[str]]] = None,
|
|
77
81
|
):
|
|
78
82
|
if callable(domain):
|
|
79
83
|
self._domain = None
|
|
@@ -92,10 +96,15 @@ class MfaClient:
|
|
|
92
96
|
raise ConfigurationError("mfa_token_ttl must be a positive number of seconds")
|
|
93
97
|
self._mfa_token_ttl = mfa_token_ttl
|
|
94
98
|
self._apply_client_authentication = apply_client_authentication
|
|
99
|
+
self._use_mtls = use_mtls
|
|
100
|
+
self._ssl_context = ssl_context
|
|
101
|
+
self._token_endpoint_resolver = token_endpoint_resolver
|
|
95
102
|
|
|
96
103
|
def _get_http_client(self, **kwargs) -> httpx.AsyncClient:
|
|
97
104
|
"""Return an httpx.AsyncClient with default headers injected."""
|
|
98
105
|
headers = {**kwargs.pop("headers", {}), **self._headers}
|
|
106
|
+
if self._use_mtls and "verify" not in kwargs:
|
|
107
|
+
kwargs["verify"] = self._ssl_context
|
|
99
108
|
return httpx.AsyncClient(headers=headers, **kwargs)
|
|
100
109
|
|
|
101
110
|
def _apply_mfa_client_authentication(self, body: dict, base_url: str) -> None:
|
|
@@ -502,8 +511,16 @@ class MfaClient:
|
|
|
502
511
|
MfaVerifyError: When verification fails, or when dpop_key was supplied
|
|
503
512
|
but the server returned an unbound (Bearer) token.
|
|
504
513
|
MfaRequiredError: When chained MFA is required.
|
|
514
|
+
ConfigurationError: If dpop_key is combined with use_mtls. DPoP and mTLS
|
|
515
|
+
use incompatible token-binding mechanisms and cannot be used together.
|
|
505
516
|
ConfigurationError: If neither client_secret nor client_assertion_signing_key is configured.
|
|
506
517
|
"""
|
|
518
|
+
if self._use_mtls and dpop_key is not None:
|
|
519
|
+
raise ConfigurationError(
|
|
520
|
+
"dpop_key cannot be combined with use_mtls. DPoP and mTLS bind tokens "
|
|
521
|
+
"differently. DPoP would take precedence and the token would not be "
|
|
522
|
+
"certificate-bound."
|
|
523
|
+
)
|
|
507
524
|
mfa_token = options.get("mfa_token")
|
|
508
525
|
if not mfa_token:
|
|
509
526
|
raise MfaTokenInvalidError()
|
|
@@ -534,7 +551,10 @@ class MfaClient:
|
|
|
534
551
|
)
|
|
535
552
|
|
|
536
553
|
try:
|
|
537
|
-
|
|
554
|
+
if self._use_mtls and self._token_endpoint_resolver:
|
|
555
|
+
token_endpoint = await self._token_endpoint_resolver(store_options)
|
|
556
|
+
else:
|
|
557
|
+
token_endpoint = f"{base_url}/oauth/token"
|
|
538
558
|
|
|
539
559
|
async with self._get_http_client() as client:
|
|
540
560
|
headers = {"Content-Type": "application/x-www-form-urlencoded"}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import json
|
|
2
|
+
import ssl
|
|
2
3
|
from typing import TYPE_CHECKING, Optional
|
|
3
4
|
from urllib.parse import quote, unquote, urlparse
|
|
4
5
|
|
|
@@ -46,20 +47,31 @@ class MyAccountClient:
|
|
|
46
47
|
Client for interacting with the Auth0 MyAccount API.
|
|
47
48
|
"""
|
|
48
49
|
|
|
49
|
-
def __init__(
|
|
50
|
+
def __init__(
|
|
51
|
+
self,
|
|
52
|
+
domain: str,
|
|
53
|
+
headers: Optional[dict[str, str]] = None,
|
|
54
|
+
ssl_context: Optional[ssl.SSLContext] = None,
|
|
55
|
+
):
|
|
50
56
|
"""
|
|
51
57
|
Initialize the MyAccount API client.
|
|
52
58
|
|
|
53
59
|
Args:
|
|
54
60
|
domain: Auth0 domain (e.g., '<tenant>.<locality>.auth0.com')
|
|
55
61
|
headers: Optional default headers to include on every request
|
|
62
|
+
ssl_context: Optional SSL context for mTLS. When provided, the client
|
|
63
|
+
certificate is presented on every request so the My Account API can
|
|
64
|
+
verify cnf.x5t#S256 binding on cert-bound access tokens.
|
|
56
65
|
"""
|
|
57
66
|
self._domain = domain
|
|
58
67
|
self._headers = headers or {}
|
|
68
|
+
self._ssl_context = ssl_context
|
|
59
69
|
|
|
60
70
|
def _get_http_client(self, **kwargs) -> httpx.AsyncClient:
|
|
61
71
|
"""Return an httpx.AsyncClient with default headers injected."""
|
|
62
72
|
headers = {**kwargs.pop("headers", {}), **self._headers}
|
|
73
|
+
if self._ssl_context is not None and "verify" not in kwargs:
|
|
74
|
+
kwargs["verify"] = self._ssl_context
|
|
63
75
|
return httpx.AsyncClient(headers=headers, **kwargs)
|
|
64
76
|
|
|
65
77
|
@property
|
|
@@ -209,7 +209,12 @@ class PasswordlessClient:
|
|
|
209
209
|
e,
|
|
210
210
|
)
|
|
211
211
|
|
|
212
|
-
token_endpoint = metadata
|
|
212
|
+
token_endpoint = client._resolve_token_endpoint(metadata)
|
|
213
|
+
if not token_endpoint:
|
|
214
|
+
raise PasswordlessVerifyError(
|
|
215
|
+
PasswordlessErrorCode.DISCOVERY_ERROR,
|
|
216
|
+
"Token endpoint missing in OIDC metadata",
|
|
217
|
+
)
|
|
213
218
|
origin_issuer = metadata.get("issuer")
|
|
214
219
|
|
|
215
220
|
default_scope = (
|
|
@@ -393,7 +398,7 @@ class PasswordlessClient:
|
|
|
393
398
|
if not id_token:
|
|
394
399
|
raise PasswordlessVerifyError(
|
|
395
400
|
PasswordlessErrorCode.VERIFY_FAILED,
|
|
396
|
-
"Token response did not include an ID token
|
|
401
|
+
"Token response did not include an ID token. Ensure 'openid' scope is requested.",
|
|
397
402
|
)
|
|
398
403
|
|
|
399
404
|
jwks = await client._get_jwks_cached(origin_domain, metadata)
|
|
@@ -5,6 +5,8 @@ Handles authentication flows, token management, and user sessions.
|
|
|
5
5
|
|
|
6
6
|
import asyncio
|
|
7
7
|
import json
|
|
8
|
+
import logging
|
|
9
|
+
import ssl
|
|
8
10
|
import time
|
|
9
11
|
from collections import OrderedDict
|
|
10
12
|
from typing import TYPE_CHECKING, Any, Callable, Generic, Optional, TypeVar, Union
|
|
@@ -101,6 +103,8 @@ SESSION_TRANSFER_TOKEN_TYPE = "urn:auth0:params:oauth:token-type:session_transfe
|
|
|
101
103
|
# actor_token_type URN when the actor is sourced from the agent session's ID token.
|
|
102
104
|
ID_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:id_token"
|
|
103
105
|
|
|
106
|
+
logger = logging.getLogger(__name__)
|
|
107
|
+
|
|
104
108
|
|
|
105
109
|
class ServerClient(Generic[TStoreOptions]):
|
|
106
110
|
"""
|
|
@@ -133,6 +137,8 @@ class ServerClient(Generic[TStoreOptions]):
|
|
|
133
137
|
pushed_authorization_requests: bool = False,
|
|
134
138
|
organization: Optional[str] = None,
|
|
135
139
|
mfa_token_ttl: int = DEFAULT_MFA_TOKEN_TTL,
|
|
140
|
+
use_mtls: bool = False,
|
|
141
|
+
ssl_context: Optional[ssl.SSLContext] = None,
|
|
136
142
|
):
|
|
137
143
|
"""
|
|
138
144
|
Initialize the Auth0 server client.
|
|
@@ -158,6 +164,12 @@ class ServerClient(Generic[TStoreOptions]):
|
|
|
158
164
|
`mfa.verify()`/`mfa.challenge_authenticator()` reject it as expired.
|
|
159
165
|
Defaults to 300 (5 minutes). Increase for authenticator flows that
|
|
160
166
|
need more time (e.g. OOB push approval on a slow connection).
|
|
167
|
+
use_mtls: Enable mTLS (RFC 8705) client authentication. When True, the
|
|
168
|
+
client certificate in ssl_context is the sole credential - no
|
|
169
|
+
client_secret or client_assertion is sent in the request body.
|
|
170
|
+
ssl_context: TLS context carrying the client certificate and key.
|
|
171
|
+
Required when use_mtls=True. Build with ssl.create_default_context()
|
|
172
|
+
and load_cert_chain().
|
|
161
173
|
|
|
162
174
|
Raises:
|
|
163
175
|
ConfigurationError: If `mfa_token_ttl` is not a positive number of seconds.
|
|
@@ -189,6 +201,25 @@ class ServerClient(Generic[TStoreOptions]):
|
|
|
189
201
|
self._domain = domain_str
|
|
190
202
|
self._domain_resolver = None
|
|
191
203
|
|
|
204
|
+
self._use_mtls = use_mtls
|
|
205
|
+
self._ssl_context = ssl_context
|
|
206
|
+
if use_mtls:
|
|
207
|
+
if ssl_context is None:
|
|
208
|
+
raise ConfigurationError(
|
|
209
|
+
"ssl_context is required when use_mtls=True. Create an ssl.SSLContext "
|
|
210
|
+
"and call load_cert_chain() to load the client certificate."
|
|
211
|
+
)
|
|
212
|
+
if client_secret:
|
|
213
|
+
raise ConfigurationError(
|
|
214
|
+
"use_mtls cannot be combined with client_secret. The client "
|
|
215
|
+
"certificate is the sole credential under mTLS."
|
|
216
|
+
)
|
|
217
|
+
if client_assertion_signing_key:
|
|
218
|
+
raise ConfigurationError(
|
|
219
|
+
"use_mtls cannot be combined with client_assertion_signing_key. "
|
|
220
|
+
"The client certificate is the sole credential under mTLS."
|
|
221
|
+
)
|
|
222
|
+
|
|
192
223
|
self._client_id = client_id
|
|
193
224
|
self._client_secret = client_secret
|
|
194
225
|
self._client_assertion_signing_key = client_assertion_signing_key
|
|
@@ -218,10 +249,13 @@ class ServerClient(Generic[TStoreOptions]):
|
|
|
218
249
|
client_id=client_id,
|
|
219
250
|
client_secret=None if client_assertion_signing_key else client_secret,
|
|
220
251
|
headers=self._telemetry_headers,
|
|
252
|
+
**({"verify": self._ssl_context} if self._use_mtls else {}),
|
|
221
253
|
)
|
|
222
254
|
|
|
223
255
|
self._my_account_client = MyAccountClient(
|
|
224
|
-
domain=domain,
|
|
256
|
+
domain=domain,
|
|
257
|
+
headers=self._telemetry_headers,
|
|
258
|
+
**({"ssl_context": self._ssl_context} if self._use_mtls else {}),
|
|
225
259
|
)
|
|
226
260
|
|
|
227
261
|
# Unified cache for OIDC metadata and JWKS per domain (LRU eviction + TTL)
|
|
@@ -241,6 +275,9 @@ class ServerClient(Generic[TStoreOptions]):
|
|
|
241
275
|
session_establisher=self._establish_session_from_mfa_verify_response,
|
|
242
276
|
mfa_token_ttl=mfa_token_ttl,
|
|
243
277
|
apply_client_authentication=self._apply_client_authentication,
|
|
278
|
+
use_mtls=self._use_mtls,
|
|
279
|
+
ssl_context=self._ssl_context,
|
|
280
|
+
token_endpoint_resolver=self._resolve_mfa_token_endpoint if self._use_mtls else None,
|
|
244
281
|
)
|
|
245
282
|
|
|
246
283
|
self._passwordless_client = PasswordlessClient(self)
|
|
@@ -248,8 +285,56 @@ class ServerClient(Generic[TStoreOptions]):
|
|
|
248
285
|
def _get_http_client(self, **kwargs) -> httpx.AsyncClient:
|
|
249
286
|
"""Return an httpx.AsyncClient with telemetry headers injected."""
|
|
250
287
|
headers = {**kwargs.pop("headers", {}), **self._telemetry_headers}
|
|
288
|
+
if self._use_mtls and "verify" not in kwargs:
|
|
289
|
+
kwargs["verify"] = self._ssl_context
|
|
251
290
|
return httpx.AsyncClient(headers=headers, **kwargs)
|
|
252
291
|
|
|
292
|
+
async def _resolve_mfa_token_endpoint(self, store_options) -> str:
|
|
293
|
+
"""Resolve the token endpoint for MfaClient, applying the mTLS alias when enabled."""
|
|
294
|
+
domain = await self._resolve_current_domain(store_options)
|
|
295
|
+
metadata = await self._get_oidc_metadata_cached(domain)
|
|
296
|
+
return self._resolve_token_endpoint(metadata)
|
|
297
|
+
|
|
298
|
+
def _resolve_token_endpoint(self, metadata: dict) -> Optional[str]:
|
|
299
|
+
"""Return the token endpoint, routed to the mTLS alias when mTLS is enabled.
|
|
300
|
+
|
|
301
|
+
Returns:
|
|
302
|
+
The token endpoint URL, or None if not present and mTLS is not enabled.
|
|
303
|
+
|
|
304
|
+
Raises:
|
|
305
|
+
ConfigurationError: If mTLS is enabled but the discovery document does not
|
|
306
|
+
advertise mtls_endpoint_aliases.token_endpoint.
|
|
307
|
+
"""
|
|
308
|
+
if self._use_mtls:
|
|
309
|
+
aliases = metadata.get("mtls_endpoint_aliases") or {}
|
|
310
|
+
endpoint = aliases.get("token_endpoint")
|
|
311
|
+
if not endpoint:
|
|
312
|
+
raise ConfigurationError(
|
|
313
|
+
"use_mtls is enabled but the authorization server discovery document "
|
|
314
|
+
"does not advertise mtls_endpoint_aliases.token_endpoint. Ensure mTLS "
|
|
315
|
+
"endpoint aliases are enabled on your Auth0 tenant."
|
|
316
|
+
)
|
|
317
|
+
return endpoint
|
|
318
|
+
return metadata.get("token_endpoint")
|
|
319
|
+
|
|
320
|
+
def _resolve_par_endpoint(self, metadata: dict) -> Optional[str]:
|
|
321
|
+
"""Return the PAR endpoint, routed to the mTLS alias when mTLS is enabled.
|
|
322
|
+
|
|
323
|
+
Under mTLS, raises ConfigurationError immediately if the alias is absent.
|
|
324
|
+
Under standard auth, returns None if the endpoint is missing (caller's guard handles it).
|
|
325
|
+
"""
|
|
326
|
+
if self._use_mtls:
|
|
327
|
+
aliases = metadata.get("mtls_endpoint_aliases") or {}
|
|
328
|
+
endpoint = aliases.get("pushed_authorization_request_endpoint")
|
|
329
|
+
if not endpoint:
|
|
330
|
+
raise ConfigurationError(
|
|
331
|
+
"use_mtls is enabled but the authorization server discovery document "
|
|
332
|
+
"does not advertise mtls_endpoint_aliases.pushed_authorization_request_endpoint. "
|
|
333
|
+
"Ensure mTLS endpoint aliases are enabled on your Auth0 tenant."
|
|
334
|
+
)
|
|
335
|
+
return endpoint
|
|
336
|
+
return metadata.get("pushed_authorization_request_endpoint")
|
|
337
|
+
|
|
253
338
|
def _apply_client_authentication(
|
|
254
339
|
self, params: dict, issuer: str, in_body: bool = False
|
|
255
340
|
) -> Optional[tuple[str, str]]:
|
|
@@ -270,6 +355,11 @@ class ServerClient(Generic[TStoreOptions]):
|
|
|
270
355
|
for reserved in ("client_secret", "client_assertion", "client_assertion_type"):
|
|
271
356
|
params.pop(reserved, None)
|
|
272
357
|
|
|
358
|
+
if self._use_mtls:
|
|
359
|
+
# The client certificate presented in the TLS handshake is the sole
|
|
360
|
+
# credential. No body credential or HTTP basic auth is sent.
|
|
361
|
+
return None
|
|
362
|
+
|
|
273
363
|
if self._client_assertion_signing_key:
|
|
274
364
|
params["client_assertion"] = build_client_assertion(
|
|
275
365
|
self._client_assertion_signing_key,
|
|
@@ -537,6 +627,26 @@ class ServerClient(Generic[TStoreOptions]):
|
|
|
537
627
|
|
|
538
628
|
return jwks
|
|
539
629
|
|
|
630
|
+
def _warn_if_not_cert_bound(self, token_response: dict) -> None:
|
|
631
|
+
"""Warn if the access token lacks a cnf.x5t#S256 claim."""
|
|
632
|
+
access_token = token_response.get("access_token")
|
|
633
|
+
if not access_token:
|
|
634
|
+
return
|
|
635
|
+
try:
|
|
636
|
+
claims = jwt.decode(
|
|
637
|
+
access_token,
|
|
638
|
+
options={"verify_signature": False},
|
|
639
|
+
)
|
|
640
|
+
except jwt.InvalidTokenError:
|
|
641
|
+
return # opaque or unparseable token - nothing to assert
|
|
642
|
+
cnf = claims.get("cnf") if isinstance(claims, dict) else None
|
|
643
|
+
if not (isinstance(cnf, dict) and cnf.get("x5t#S256")):
|
|
644
|
+
logger.warning(
|
|
645
|
+
"mTLS is enabled but the access token does not contain a cnf.x5t#S256 "
|
|
646
|
+
"claim. The token is not certificate-bound. Configure Token "
|
|
647
|
+
"Sender-Constraining (mTLS) on the API resource server."
|
|
648
|
+
)
|
|
649
|
+
|
|
540
650
|
# ============================================================================
|
|
541
651
|
# INTERACTIVE LOGIN FLOW
|
|
542
652
|
# Handles browser-based authentication using the Authorization Code flow
|
|
@@ -638,8 +748,7 @@ class ServerClient(Generic[TStoreOptions]):
|
|
|
638
748
|
self._oauth.metadata = metadata
|
|
639
749
|
# If PAR is enabled, use the PAR endpoint
|
|
640
750
|
if self._pushed_authorization_requests:
|
|
641
|
-
par_endpoint = self._oauth.metadata
|
|
642
|
-
"pushed_authorization_request_endpoint")
|
|
751
|
+
par_endpoint = self._resolve_par_endpoint(self._oauth.metadata)
|
|
643
752
|
if not par_endpoint:
|
|
644
753
|
raise ApiError(
|
|
645
754
|
"configuration_error", "PAR is enabled but pushed_authorization_request_endpoint is missing in metadata")
|
|
@@ -752,7 +861,9 @@ class ServerClient(Generic[TStoreOptions]):
|
|
|
752
861
|
)
|
|
753
862
|
|
|
754
863
|
try:
|
|
755
|
-
token_endpoint = self._oauth.metadata
|
|
864
|
+
token_endpoint = self._resolve_token_endpoint(self._oauth.metadata)
|
|
865
|
+
if not token_endpoint:
|
|
866
|
+
raise ApiError("configuration_error", "Token endpoint missing in OIDC metadata")
|
|
756
867
|
token_response = await self._oauth.fetch_token(
|
|
757
868
|
token_endpoint,
|
|
758
869
|
code=code,
|
|
@@ -765,6 +876,9 @@ class ServerClient(Generic[TStoreOptions]):
|
|
|
765
876
|
raise ApiError(
|
|
766
877
|
"token_error", f"Token exchange failed: {str(e)}", e)
|
|
767
878
|
|
|
879
|
+
if self._use_mtls:
|
|
880
|
+
self._warn_if_not_cert_bound(token_response)
|
|
881
|
+
|
|
768
882
|
# Use the userinfo field from the token_response for user claims
|
|
769
883
|
user_info = token_response.get("userinfo")
|
|
770
884
|
user_claims = None
|
|
@@ -773,8 +887,6 @@ class ServerClient(Generic[TStoreOptions]):
|
|
|
773
887
|
# ID token `iat`, used to detect a ceiling that is already past at login.
|
|
774
888
|
issued_at = None
|
|
775
889
|
id_token = token_response.get("id_token")
|
|
776
|
-
# Verified ID token claims, retained so the session `sid` can be sourced
|
|
777
|
-
# from them (back-channel logout matches on `sid`).
|
|
778
890
|
id_token_claims = None
|
|
779
891
|
|
|
780
892
|
expected_org = transaction_data.organization
|
|
@@ -973,7 +1085,7 @@ class ServerClient(Generic[TStoreOptions]):
|
|
|
973
1085
|
id_token = token_response.get("id_token")
|
|
974
1086
|
if not id_token:
|
|
975
1087
|
raise MfaVerifyError(
|
|
976
|
-
"MFA verification response did not include an ID token
|
|
1088
|
+
"MFA verification response did not include an ID token. Cannot create a session."
|
|
977
1089
|
)
|
|
978
1090
|
|
|
979
1091
|
origin_domain = await self._resolve_current_domain(store_options)
|
|
@@ -1382,7 +1494,7 @@ class ServerClient(Generic[TStoreOptions]):
|
|
|
1382
1494
|
# Fetch OIDC metadata from the correct domain
|
|
1383
1495
|
metadata = await self._get_oidc_metadata_cached(domain)
|
|
1384
1496
|
|
|
1385
|
-
token_endpoint =
|
|
1497
|
+
token_endpoint = self._resolve_token_endpoint(metadata)
|
|
1386
1498
|
if not token_endpoint:
|
|
1387
1499
|
raise ApiError("configuration_error",
|
|
1388
1500
|
"Token endpoint missing in OIDC metadata")
|
|
@@ -1448,6 +1560,9 @@ class ServerClient(Generic[TStoreOptions]):
|
|
|
1448
1560
|
token_response["expires_at"] = int(
|
|
1449
1561
|
time.time()) + token_response["expires_in"]
|
|
1450
1562
|
|
|
1563
|
+
if self._use_mtls:
|
|
1564
|
+
self._warn_if_not_cert_bound(token_response)
|
|
1565
|
+
|
|
1451
1566
|
return token_response
|
|
1452
1567
|
|
|
1453
1568
|
except Exception as e:
|
|
@@ -1792,7 +1907,7 @@ class ServerClient(Generic[TStoreOptions]):
|
|
|
1792
1907
|
domain = await self._resolve_current_domain(store_options)
|
|
1793
1908
|
metadata = await self._get_oidc_metadata_cached(domain)
|
|
1794
1909
|
|
|
1795
|
-
token_endpoint =
|
|
1910
|
+
token_endpoint = self._resolve_token_endpoint(metadata)
|
|
1796
1911
|
if not token_endpoint:
|
|
1797
1912
|
raise ApiError("configuration_error",
|
|
1798
1913
|
"Token endpoint missing in OIDC metadata")
|
|
@@ -2241,7 +2356,7 @@ class ServerClient(Generic[TStoreOptions]):
|
|
|
2241
2356
|
# Fetch OIDC metadata from the correct domain
|
|
2242
2357
|
metadata = await self._get_oidc_metadata_cached(domain)
|
|
2243
2358
|
|
|
2244
|
-
token_endpoint =
|
|
2359
|
+
token_endpoint = self._resolve_token_endpoint(metadata)
|
|
2245
2360
|
if not token_endpoint:
|
|
2246
2361
|
raise ApiError("configuration_error",
|
|
2247
2362
|
"Token endpoint missing in OIDC metadata")
|
|
@@ -2621,7 +2736,7 @@ class ServerClient(Generic[TStoreOptions]):
|
|
|
2621
2736
|
domain = await self._resolve_current_domain(store_options)
|
|
2622
2737
|
metadata = await self._get_oidc_metadata_cached(domain)
|
|
2623
2738
|
|
|
2624
|
-
token_endpoint =
|
|
2739
|
+
token_endpoint = self._resolve_token_endpoint(metadata)
|
|
2625
2740
|
if not token_endpoint:
|
|
2626
2741
|
raise ApiError("configuration_error", "Token endpoint missing in OIDC metadata")
|
|
2627
2742
|
|
|
@@ -3281,6 +3396,8 @@ class ServerClient(Generic[TStoreOptions]):
|
|
|
3281
3396
|
|
|
3282
3397
|
Raises:
|
|
3283
3398
|
MissingRequiredArgumentError: If auth_session or authn_response is missing.
|
|
3399
|
+
ConfigurationError: If dpop_key is combined with use_mtls. DPoP and mTLS
|
|
3400
|
+
use incompatible token-binding mechanisms and cannot be used together.
|
|
3284
3401
|
PasskeyError: If token exchange or session creation fails.
|
|
3285
3402
|
OrganizationTokenValidationError: If an organization was requested but the
|
|
3286
3403
|
token response included no ID token, or the ID token's org claim does
|
|
@@ -3290,12 +3407,18 @@ class ServerClient(Generic[TStoreOptions]):
|
|
|
3290
3407
|
raise MissingRequiredArgumentError("auth_session")
|
|
3291
3408
|
if authn_response is None:
|
|
3292
3409
|
raise MissingRequiredArgumentError("authn_response")
|
|
3410
|
+
if self._use_mtls and dpop_key is not None:
|
|
3411
|
+
raise ConfigurationError(
|
|
3412
|
+
"dpop_key cannot be combined with use_mtls. DPoP and mTLS bind tokens "
|
|
3413
|
+
"differently. DPoP would take precedence and the token would not be "
|
|
3414
|
+
"certificate-bound."
|
|
3415
|
+
)
|
|
3293
3416
|
|
|
3294
3417
|
try:
|
|
3295
3418
|
domain = await self._resolve_current_domain(store_options)
|
|
3296
3419
|
metadata = await self._get_oidc_metadata_cached(domain)
|
|
3297
3420
|
|
|
3298
|
-
token_endpoint =
|
|
3421
|
+
token_endpoint = self._resolve_token_endpoint(metadata)
|
|
3299
3422
|
if not token_endpoint:
|
|
3300
3423
|
raise PasskeyError(PasskeyErrorCode.TOKEN_EXCHANGE_FAILED, "Token endpoint missing in OIDC metadata")
|
|
3301
3424
|
|
|
@@ -3,6 +3,7 @@ Tests for MfaClient — MFA API operations.
|
|
|
3
3
|
"""
|
|
4
4
|
|
|
5
5
|
import json
|
|
6
|
+
import ssl
|
|
6
7
|
from unittest.mock import AsyncMock, MagicMock
|
|
7
8
|
|
|
8
9
|
import pytest
|
|
@@ -1093,3 +1094,66 @@ class TestVerify:
|
|
|
1093
1094
|
result = await client.verify({"mfa_token": _enc(), "otp": "123456"})
|
|
1094
1095
|
assert result.token_type == "Bearer"
|
|
1095
1096
|
assert "DPoP" not in captured_request["kwargs"]["headers"]
|
|
1097
|
+
|
|
1098
|
+
|
|
1099
|
+
# ============================================================================
|
|
1100
|
+
# mTLS - MfaClient SSLContext threading + DPoP exclusion + endpoint override
|
|
1101
|
+
# ============================================================================
|
|
1102
|
+
|
|
1103
|
+
|
|
1104
|
+
def _mtls_mfa_client() -> MfaClient:
|
|
1105
|
+
return MfaClient(
|
|
1106
|
+
domain=DOMAIN,
|
|
1107
|
+
client_id=CLIENT_ID,
|
|
1108
|
+
client_secret=None,
|
|
1109
|
+
secret=SECRET,
|
|
1110
|
+
use_mtls=True,
|
|
1111
|
+
ssl_context=ssl.create_default_context(),
|
|
1112
|
+
)
|
|
1113
|
+
|
|
1114
|
+
|
|
1115
|
+
@pytest.mark.asyncio
|
|
1116
|
+
async def test_mfa_get_http_client_passes_ssl_context(mocker):
|
|
1117
|
+
mfa = _mtls_mfa_client()
|
|
1118
|
+
spy = mocker.patch("auth0_server_python.auth_server.mfa_client.httpx.AsyncClient")
|
|
1119
|
+
mfa._get_http_client()
|
|
1120
|
+
_, kwargs = spy.call_args
|
|
1121
|
+
assert kwargs.get("verify") is mfa._ssl_context
|
|
1122
|
+
|
|
1123
|
+
|
|
1124
|
+
@pytest.mark.asyncio
|
|
1125
|
+
async def test_mfa_verify_rejects_dpop_under_mtls():
|
|
1126
|
+
mfa = _mtls_mfa_client()
|
|
1127
|
+
with pytest.raises(ConfigurationError):
|
|
1128
|
+
await mfa.verify({"mfa_token": _enc(), "otp": "123456"}, dpop_key=object())
|
|
1129
|
+
|
|
1130
|
+
|
|
1131
|
+
@pytest.mark.asyncio
|
|
1132
|
+
async def test_mfa_verify_uses_token_endpoint_resolver_under_mtls(mocker):
|
|
1133
|
+
async def resolver(store_options):
|
|
1134
|
+
return "https://mtls.auth0.local/oauth/token"
|
|
1135
|
+
|
|
1136
|
+
mfa = MfaClient(
|
|
1137
|
+
domain=DOMAIN,
|
|
1138
|
+
client_id=CLIENT_ID,
|
|
1139
|
+
client_secret=None,
|
|
1140
|
+
secret=SECRET,
|
|
1141
|
+
use_mtls=True,
|
|
1142
|
+
ssl_context=ssl.create_default_context(),
|
|
1143
|
+
token_endpoint_resolver=resolver,
|
|
1144
|
+
)
|
|
1145
|
+
response = AsyncMock()
|
|
1146
|
+
response.status_code = 200
|
|
1147
|
+
response.json = MagicMock(return_value={
|
|
1148
|
+
"access_token": "at", "token_type": "Bearer", "expires_in": 3600
|
|
1149
|
+
})
|
|
1150
|
+
captured = {}
|
|
1151
|
+
|
|
1152
|
+
async def mock_post(self_client, url, **kwargs):
|
|
1153
|
+
captured["url"] = url
|
|
1154
|
+
return response
|
|
1155
|
+
|
|
1156
|
+
mocker.patch("httpx.AsyncClient.post", new=mock_post)
|
|
1157
|
+
|
|
1158
|
+
await mfa.verify({"mfa_token": _enc(), "otp": "123456"})
|
|
1159
|
+
assert captured["url"] == "https://mtls.auth0.local/oauth/token"
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import base64
|
|
2
2
|
import json
|
|
3
|
+
import ssl
|
|
3
4
|
from unittest.mock import ANY, AsyncMock, MagicMock
|
|
4
5
|
|
|
5
6
|
import httpx
|
|
@@ -1422,3 +1423,24 @@ def test_dpop_auth_flow_no_retry_on_non_401():
|
|
|
1422
1423
|
|
|
1423
1424
|
assert not retried
|
|
1424
1425
|
|
|
1426
|
+
|
|
1427
|
+
# =============================================================================
|
|
1428
|
+
# mTLS ssl_context wiring
|
|
1429
|
+
# =============================================================================
|
|
1430
|
+
|
|
1431
|
+
|
|
1432
|
+
def test_get_http_client_passes_ssl_context_when_set(mocker):
|
|
1433
|
+
ctx = ssl.create_default_context()
|
|
1434
|
+
client = MyAccountClient(domain="auth0.local", ssl_context=ctx)
|
|
1435
|
+
spy = mocker.patch("auth0_server_python.auth_server.my_account_client.httpx.AsyncClient")
|
|
1436
|
+
client._get_http_client()
|
|
1437
|
+
_, kwargs = spy.call_args
|
|
1438
|
+
assert kwargs.get("verify") is ctx
|
|
1439
|
+
|
|
1440
|
+
|
|
1441
|
+
def test_get_http_client_omits_verify_without_ssl_context(mocker):
|
|
1442
|
+
client = MyAccountClient(domain="auth0.local")
|
|
1443
|
+
spy = mocker.patch("auth0_server_python.auth_server.my_account_client.httpx.AsyncClient")
|
|
1444
|
+
client._get_http_client()
|
|
1445
|
+
_, kwargs = spy.call_args
|
|
1446
|
+
assert "verify" not in kwargs
|