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.
Files changed (28) hide show
  1. {auth0_server_python-1.0.0b15 → auth0_server_python-1.0.0b16}/PKG-INFO +24 -1
  2. {auth0_server_python-1.0.0b15 → auth0_server_python-1.0.0b16}/README.md +23 -0
  3. {auth0_server_python-1.0.0b15 → auth0_server_python-1.0.0b16}/pyproject.toml +1 -1
  4. {auth0_server_python-1.0.0b15 → auth0_server_python-1.0.0b16}/src/auth0_server_python/auth_server/mfa_client.py +21 -1
  5. {auth0_server_python-1.0.0b15 → auth0_server_python-1.0.0b16}/src/auth0_server_python/auth_server/my_account_client.py +13 -1
  6. {auth0_server_python-1.0.0b15 → auth0_server_python-1.0.0b16}/src/auth0_server_python/auth_server/passwordless_client.py +7 -2
  7. {auth0_server_python-1.0.0b15 → auth0_server_python-1.0.0b16}/src/auth0_server_python/auth_server/server_client.py +135 -12
  8. {auth0_server_python-1.0.0b15 → auth0_server_python-1.0.0b16}/src/auth0_server_python/tests/test_mfa_client.py +64 -0
  9. {auth0_server_python-1.0.0b15 → auth0_server_python-1.0.0b16}/src/auth0_server_python/tests/test_my_account_client.py +22 -0
  10. {auth0_server_python-1.0.0b15 → auth0_server_python-1.0.0b16}/src/auth0_server_python/tests/test_passwordless_client.py +116 -0
  11. {auth0_server_python-1.0.0b15 → auth0_server_python-1.0.0b16}/src/auth0_server_python/tests/test_server_client.py +502 -0
  12. {auth0_server_python-1.0.0b15 → auth0_server_python-1.0.0b16}/LICENSE +0 -0
  13. {auth0_server_python-1.0.0b15 → auth0_server_python-1.0.0b16}/src/auth0_server_python/auth_schemes/__init__.py +0 -0
  14. {auth0_server_python-1.0.0b15 → auth0_server_python-1.0.0b16}/src/auth0_server_python/auth_schemes/bearer_auth.py +0 -0
  15. {auth0_server_python-1.0.0b15 → auth0_server_python-1.0.0b16}/src/auth0_server_python/auth_schemes/client_assertion.py +0 -0
  16. {auth0_server_python-1.0.0b15 → auth0_server_python-1.0.0b16}/src/auth0_server_python/auth_schemes/dpop_auth.py +0 -0
  17. {auth0_server_python-1.0.0b15 → auth0_server_python-1.0.0b16}/src/auth0_server_python/auth_server/__init__.py +0 -0
  18. {auth0_server_python-1.0.0b15 → auth0_server_python-1.0.0b16}/src/auth0_server_python/auth_types/__init__.py +0 -0
  19. {auth0_server_python-1.0.0b15 → auth0_server_python-1.0.0b16}/src/auth0_server_python/encryption/__init__.py +0 -0
  20. {auth0_server_python-1.0.0b15 → auth0_server_python-1.0.0b16}/src/auth0_server_python/encryption/encrypt.py +0 -0
  21. {auth0_server_python-1.0.0b15 → auth0_server_python-1.0.0b16}/src/auth0_server_python/error/__init__.py +0 -0
  22. {auth0_server_python-1.0.0b15 → auth0_server_python-1.0.0b16}/src/auth0_server_python/store/__init__.py +0 -0
  23. {auth0_server_python-1.0.0b15 → auth0_server_python-1.0.0b16}/src/auth0_server_python/store/abstract.py +0 -0
  24. {auth0_server_python-1.0.0b15 → auth0_server_python-1.0.0b16}/src/auth0_server_python/telemetry.py +0 -0
  25. {auth0_server_python-1.0.0b15 → auth0_server_python-1.0.0b16}/src/auth0_server_python/tests/test_dpop_auth.py +0 -0
  26. {auth0_server_python-1.0.0b15 → auth0_server_python-1.0.0b16}/src/auth0_server_python/tests/test_telemetry.py +0 -0
  27. {auth0_server_python-1.0.0b15 → auth0_server_python-1.0.0b16}/src/auth0_server_python/utils/__init__.py +0 -0
  28. {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.0b15
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:
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "auth0-server-python"
3
- version = "1.0.0b15"
3
+ version = "1.0.0b16"
4
4
  description = "Auth0 server-side Python SDK"
5
5
  readme = "README.md"
6
6
  authors = ["Auth0 <support@okta.com>"]
@@ -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
- token_endpoint = f"{base_url}/oauth/token"
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__(self, domain: str, headers: Optional[dict[str, str]] = None):
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["token_endpoint"]
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; ensure 'openid' scope is requested",
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, headers=self._telemetry_headers
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.get(
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["token_endpoint"]
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; cannot create a session"
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 = metadata.get("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 = metadata.get("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 = metadata.get("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 = metadata.get("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 = metadata.get("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