From 7fbe52a216259bfb8e7a98e5ea0f03f6f33e77b0 Mon Sep 17 00:00:00 2001 From: Chetan Sharma Date: Fri, 21 Aug 2026 12:15:34 +0530 Subject: [PATCH 01/30] feat: add use_mtls and ssl_context constructor args with validation --- .../auth_server/server_client.py | 22 +++++++ .../tests/test_server_client.py | 60 +++++++++++++++++++ 2 files changed, 82 insertions(+) diff --git a/src/auth0_server_python/auth_server/server_client.py b/src/auth0_server_python/auth_server/server_client.py index fb984b5..eca77d2 100644 --- a/src/auth0_server_python/auth_server/server_client.py +++ b/src/auth0_server_python/auth_server/server_client.py @@ -5,6 +5,7 @@ import asyncio import json +import ssl import time from collections import OrderedDict from typing import TYPE_CHECKING, Any, Callable, Generic, Optional, TypeVar, Union @@ -133,6 +134,8 @@ def __init__( pushed_authorization_requests: bool = False, organization: Optional[str] = None, mfa_token_ttl: int = DEFAULT_MFA_TOKEN_TTL, + use_mtls: bool = False, + ssl_context: Optional[ssl.SSLContext] = None, ): """ Initialize the Auth0 server client. @@ -189,6 +192,25 @@ def __init__( self._domain = domain_str self._domain_resolver = None + self._use_mtls = use_mtls + self._ssl_context = ssl_context + if use_mtls: + if ssl_context is None: + raise ConfigurationError( + "use_mtls=True requires an ssl_context with the client certificate " + "loaded (ssl.create_default_context() + load_cert_chain())." + ) + if client_secret: + raise ConfigurationError( + "use_mtls cannot be combined with client_secret. The client " + "certificate is the sole credential under mTLS." + ) + if client_assertion_signing_key: + raise ConfigurationError( + "use_mtls cannot be combined with client_assertion_signing_key. " + "The client certificate is the sole credential under mTLS." + ) + self._client_id = client_id self._client_secret = client_secret self._client_assertion_signing_key = client_assertion_signing_key diff --git a/src/auth0_server_python/tests/test_server_client.py b/src/auth0_server_python/tests/test_server_client.py index 02b8d53..993a9a0 100644 --- a/src/auth0_server_python/tests/test_server_client.py +++ b/src/auth0_server_python/tests/test_server_client.py @@ -1,5 +1,6 @@ import base64 import json +import ssl import time import unicodedata from unittest.mock import ANY, AsyncMock, MagicMock, patch @@ -9677,3 +9678,62 @@ async def test_complete_interactive_login_milliseconds_ceiling_fails_open(mocker mock_state_store.set.assert_awaited_once() stored_state = mock_state_store.set.call_args.args[1] assert stored_state.internal.session_expires_at is None + + +# ============================================================================ +# mTLS CLIENT AUTHENTICATION +# ============================================================================ + + +def _dummy_ssl_context(): + return ssl.create_default_context() + + +@pytest.mark.asyncio +async def test_mtls_requires_ssl_context(): + with pytest.raises(ConfigurationError): + ServerClient( + domain="auth0.local", + client_id="", + use_mtls=True, + secret="", + ) + + +@pytest.mark.asyncio +async def test_mtls_rejects_client_secret(): + with pytest.raises(ConfigurationError): + ServerClient( + domain="auth0.local", + client_id="", + client_secret="", + use_mtls=True, + ssl_context=_dummy_ssl_context(), + secret="", + ) + + +@pytest.mark.asyncio +async def test_mtls_rejects_client_assertion_signing_key(): + with pytest.raises(ConfigurationError): + ServerClient( + domain="auth0.local", + client_id="", + client_assertion_signing_key="", + use_mtls=True, + ssl_context=_dummy_ssl_context(), + secret="", + ) + + +@pytest.mark.asyncio +async def test_mtls_happy_path_constructs(): + client = ServerClient( + domain="auth0.local", + client_id="", + use_mtls=True, + ssl_context=_dummy_ssl_context(), + secret="", + ) + assert client._use_mtls is True + assert client._ssl_context is not None From 19b148475bed203153ffc4003a6cee545e4f5fd5 Mon Sep 17 00:00:00 2001 From: Chetan Sharma Date: Fri, 21 Aug 2026 12:17:02 +0530 Subject: [PATCH 02/30] feat: pass mTLS ssl_context to httpx and authlib clients --- .../auth_server/server_client.py | 3 +++ .../tests/test_server_client.py | 16 ++++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/src/auth0_server_python/auth_server/server_client.py b/src/auth0_server_python/auth_server/server_client.py index eca77d2..aac6f8c 100644 --- a/src/auth0_server_python/auth_server/server_client.py +++ b/src/auth0_server_python/auth_server/server_client.py @@ -240,6 +240,7 @@ def __init__( client_id=client_id, client_secret=None if client_assertion_signing_key else client_secret, headers=self._telemetry_headers, + **({"verify": self._ssl_context} if self._use_mtls else {}), ) self._my_account_client = MyAccountClient( @@ -270,6 +271,8 @@ def __init__( def _get_http_client(self, **kwargs) -> httpx.AsyncClient: """Return an httpx.AsyncClient with telemetry headers injected.""" headers = {**kwargs.pop("headers", {}), **self._telemetry_headers} + if self._use_mtls and "verify" not in kwargs: + kwargs["verify"] = self._ssl_context return httpx.AsyncClient(headers=headers, **kwargs) def _apply_client_authentication( diff --git a/src/auth0_server_python/tests/test_server_client.py b/src/auth0_server_python/tests/test_server_client.py index 993a9a0..3805cc0 100644 --- a/src/auth0_server_python/tests/test_server_client.py +++ b/src/auth0_server_python/tests/test_server_client.py @@ -9737,3 +9737,19 @@ async def test_mtls_happy_path_constructs(): ) assert client._use_mtls is True assert client._ssl_context is not None + + +@pytest.mark.asyncio +async def test_mtls_get_http_client_passes_ssl_context(mocker): + ctx = _dummy_ssl_context() + client = ServerClient( + domain="auth0.local", + client_id="", + use_mtls=True, + ssl_context=ctx, + secret="", + ) + spy = mocker.patch("auth0_server_python.auth_server.server_client.httpx.AsyncClient") + client._get_http_client() + _, kwargs = spy.call_args + assert kwargs.get("verify") is ctx From 7f18573732a59fa49f6f11c575886fe2764931a4 Mon Sep 17 00:00:00 2001 From: Chetan Sharma Date: Fri, 21 Aug 2026 12:18:35 +0530 Subject: [PATCH 03/30] feat: add _resolve_token_endpoint mTLS alias resolver --- .../auth_server/server_client.py | 14 +++++++ .../tests/test_server_client.py | 39 +++++++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/src/auth0_server_python/auth_server/server_client.py b/src/auth0_server_python/auth_server/server_client.py index aac6f8c..341af94 100644 --- a/src/auth0_server_python/auth_server/server_client.py +++ b/src/auth0_server_python/auth_server/server_client.py @@ -275,6 +275,20 @@ def _get_http_client(self, **kwargs) -> httpx.AsyncClient: kwargs["verify"] = self._ssl_context return httpx.AsyncClient(headers=headers, **kwargs) + def _resolve_token_endpoint(self, metadata: dict) -> str: + """Return the token endpoint, routed to the mTLS alias when mTLS is enabled.""" + if self._use_mtls: + aliases = metadata.get("mtls_endpoint_aliases") or {} + endpoint = aliases.get("token_endpoint") + if not endpoint: + raise ConfigurationError( + "use_mtls is enabled but the authorization server discovery document " + "does not advertise mtls_endpoint_aliases.token_endpoint. Ensure mTLS " + "endpoint aliases are enabled on your Auth0 tenant." + ) + return endpoint + return metadata["token_endpoint"] + def _apply_client_authentication( self, params: dict, issuer: str, in_body: bool = False ) -> Optional[tuple[str, str]]: diff --git a/src/auth0_server_python/tests/test_server_client.py b/src/auth0_server_python/tests/test_server_client.py index 3805cc0..4a88f32 100644 --- a/src/auth0_server_python/tests/test_server_client.py +++ b/src/auth0_server_python/tests/test_server_client.py @@ -9753,3 +9753,42 @@ async def test_mtls_get_http_client_passes_ssl_context(mocker): client._get_http_client() _, kwargs = spy.call_args assert kwargs.get("verify") is ctx + + +def _mtls_client(): + return ServerClient( + domain="auth0.local", + client_id="", + use_mtls=True, + ssl_context=_dummy_ssl_context(), + secret="", + ) + + +@pytest.mark.asyncio +async def test_resolve_token_endpoint_uses_alias_under_mtls(): + client = _mtls_client() + metadata = { + "token_endpoint": "https://auth0.local/oauth/token", + "mtls_endpoint_aliases": {"token_endpoint": "https://mtls.auth0.local/oauth/token"}, + } + assert client._resolve_token_endpoint(metadata) == "https://mtls.auth0.local/oauth/token" + + +@pytest.mark.asyncio +async def test_resolve_token_endpoint_raises_when_alias_missing(): + client = _mtls_client() + with pytest.raises(ConfigurationError): + client._resolve_token_endpoint({"token_endpoint": "https://auth0.local/oauth/token"}) + + +@pytest.mark.asyncio +async def test_resolve_token_endpoint_standard_when_not_mtls(): + client = ServerClient( + domain="auth0.local", + client_id="", + client_secret="", + secret="", + ) + metadata = {"token_endpoint": "https://auth0.local/oauth/token"} + assert client._resolve_token_endpoint(metadata) == "https://auth0.local/oauth/token" From 8acc86b8d5c09ca91a7bacfb13d4b2d9b1e04ed3 Mon Sep 17 00:00:00 2001 From: Chetan Sharma Date: Fri, 21 Aug 2026 12:19:16 +0530 Subject: [PATCH 04/30] feat: return no body credential under mTLS in client auth resolver --- src/auth0_server_python/auth_server/server_client.py | 5 +++++ src/auth0_server_python/tests/test_server_client.py | 11 +++++++++++ 2 files changed, 16 insertions(+) diff --git a/src/auth0_server_python/auth_server/server_client.py b/src/auth0_server_python/auth_server/server_client.py index 341af94..90d6756 100644 --- a/src/auth0_server_python/auth_server/server_client.py +++ b/src/auth0_server_python/auth_server/server_client.py @@ -309,6 +309,11 @@ def _apply_client_authentication( for reserved in ("client_secret", "client_assertion", "client_assertion_type"): params.pop(reserved, None) + if self._use_mtls: + # The client certificate presented in the TLS handshake is the sole + # credential; no body credential or HTTP basic auth is sent. + return None + if self._client_assertion_signing_key: params["client_assertion"] = build_client_assertion( self._client_assertion_signing_key, diff --git a/src/auth0_server_python/tests/test_server_client.py b/src/auth0_server_python/tests/test_server_client.py index 4a88f32..fbc9431 100644 --- a/src/auth0_server_python/tests/test_server_client.py +++ b/src/auth0_server_python/tests/test_server_client.py @@ -9792,3 +9792,14 @@ async def test_resolve_token_endpoint_standard_when_not_mtls(): ) metadata = {"token_endpoint": "https://auth0.local/oauth/token"} assert client._resolve_token_endpoint(metadata) == "https://auth0.local/oauth/token" + + +@pytest.mark.asyncio +async def test_apply_client_auth_mtls_returns_none_and_strips_creds(): + client = _mtls_client() + params = {"grant_type": "refresh_token", "client_secret": "leaked", "client_assertion": "x"} + result = client._apply_client_authentication(params, "https://auth0.local/") + assert result is None + assert "client_secret" not in params + assert "client_assertion" not in params + assert "client_assertion_type" not in params From be1fee849dc5a028c23da1f233cbc80789066e14 Mon Sep 17 00:00:00 2001 From: Chetan Sharma Date: Fri, 21 Aug 2026 12:57:47 +0530 Subject: [PATCH 05/30] feat: route all token-endpoint calls through mTLS alias resolver --- .../auth_server/server_client.py | 22 ++++++---- .../tests/test_server_client.py | 41 +++++++++++++++++++ 2 files changed, 54 insertions(+), 9 deletions(-) diff --git a/src/auth0_server_python/auth_server/server_client.py b/src/auth0_server_python/auth_server/server_client.py index 90d6756..6508110 100644 --- a/src/auth0_server_python/auth_server/server_client.py +++ b/src/auth0_server_python/auth_server/server_client.py @@ -275,8 +275,12 @@ def _get_http_client(self, **kwargs) -> httpx.AsyncClient: kwargs["verify"] = self._ssl_context return httpx.AsyncClient(headers=headers, **kwargs) - def _resolve_token_endpoint(self, metadata: dict) -> str: - """Return the token endpoint, routed to the mTLS alias when mTLS is enabled.""" + def _resolve_token_endpoint(self, metadata: dict) -> Optional[str]: + """Return the token endpoint, routed to the mTLS alias when mTLS is enabled. + + Under mTLS, raises ConfigurationError immediately if the alias is absent. + Under standard auth, returns None if token_endpoint is missing (caller's guard handles it). + """ if self._use_mtls: aliases = metadata.get("mtls_endpoint_aliases") or {} endpoint = aliases.get("token_endpoint") @@ -287,7 +291,7 @@ def _resolve_token_endpoint(self, metadata: dict) -> str: "endpoint aliases are enabled on your Auth0 tenant." ) return endpoint - return metadata["token_endpoint"] + return metadata.get("token_endpoint") def _apply_client_authentication( self, params: dict, issuer: str, in_body: bool = False @@ -796,7 +800,7 @@ async def complete_interactive_login( ) try: - token_endpoint = self._oauth.metadata["token_endpoint"] + token_endpoint = self._resolve_token_endpoint(self._oauth.metadata) token_response = await self._oauth.fetch_token( token_endpoint, code=code, @@ -1426,7 +1430,7 @@ async def get_token_by_refresh_token(self, options: dict[str, Any]) -> dict[str, # Fetch OIDC metadata from the correct domain metadata = await self._get_oidc_metadata_cached(domain) - token_endpoint = metadata.get("token_endpoint") + token_endpoint = self._resolve_token_endpoint(metadata) if not token_endpoint: raise ApiError("configuration_error", "Token endpoint missing in OIDC metadata") @@ -1836,7 +1840,7 @@ async def backchannel_authentication_grant( domain = await self._resolve_current_domain(store_options) metadata = await self._get_oidc_metadata_cached(domain) - token_endpoint = metadata.get("token_endpoint") + token_endpoint = self._resolve_token_endpoint(metadata) if not token_endpoint: raise ApiError("configuration_error", "Token endpoint missing in OIDC metadata") @@ -2285,7 +2289,7 @@ async def get_token_for_connection(self, options: dict[str, Any]) -> dict[str, A # Fetch OIDC metadata from the correct domain metadata = await self._get_oidc_metadata_cached(domain) - token_endpoint = metadata.get("token_endpoint") + token_endpoint = self._resolve_token_endpoint(metadata) if not token_endpoint: raise ApiError("configuration_error", "Token endpoint missing in OIDC metadata") @@ -2665,7 +2669,7 @@ async def custom_token_exchange( domain = await self._resolve_current_domain(store_options) metadata = await self._get_oidc_metadata_cached(domain) - token_endpoint = metadata.get("token_endpoint") + token_endpoint = self._resolve_token_endpoint(metadata) if not token_endpoint: raise ApiError("configuration_error", "Token endpoint missing in OIDC metadata") @@ -3339,7 +3343,7 @@ async def signin_with_passkey( domain = await self._resolve_current_domain(store_options) metadata = await self._get_oidc_metadata_cached(domain) - token_endpoint = metadata.get("token_endpoint") + token_endpoint = self._resolve_token_endpoint(metadata) if not token_endpoint: raise PasskeyError(PasskeyErrorCode.TOKEN_EXCHANGE_FAILED, "Token endpoint missing in OIDC metadata") diff --git a/src/auth0_server_python/tests/test_server_client.py b/src/auth0_server_python/tests/test_server_client.py index fbc9431..580d195 100644 --- a/src/auth0_server_python/tests/test_server_client.py +++ b/src/auth0_server_python/tests/test_server_client.py @@ -9803,3 +9803,44 @@ async def test_apply_client_auth_mtls_returns_none_and_strips_creds(): assert "client_secret" not in params assert "client_assertion" not in params assert "client_assertion_type" not in params + + +@pytest.mark.asyncio +async def test_complete_interactive_login_uses_mtls_token_endpoint(mocker): + mock_tx_store = AsyncMock() + mock_tx_store.get.return_value = TransactionData( + code_verifier="cv", + domain="auth0.local", + app_state=None, + ) + mock_tx_store.delete = AsyncMock() + mock_state_store = AsyncMock() + mock_state_store.get = AsyncMock(return_value=None) + mock_state_store.set = AsyncMock() + + client = ServerClient( + domain="auth0.local", + client_id="", + use_mtls=True, + ssl_context=_dummy_ssl_context(), + secret="", + redirect_uri="https://app/cb", + transaction_store=mock_tx_store, + state_store=mock_state_store, + ) + + mtls_metadata = { + "issuer": "https://auth0.local/", + "token_endpoint": "https://auth0.local/oauth/token", + "mtls_endpoint_aliases": {"token_endpoint": "https://mtls.auth0.local/oauth/token"}, + } + mocker.patch.object(client, "_get_oidc_metadata_cached", AsyncMock(return_value=mtls_metadata)) + mocker.patch.object(client._oauth, "metadata", mtls_metadata) + + fetch_token = AsyncMock(return_value={"access_token": "at", "expires_in": 3600}) + mocker.patch.object(client._oauth, "fetch_token", fetch_token) + + await client.complete_interactive_login("https://app/cb?code=abc&state=xyz") + + called_endpoint = fetch_token.call_args[0][0] + assert called_endpoint == "https://mtls.auth0.local/oauth/token" From 8f786c9ec89cd3e8441517f0a06bf1ea08f7aea7 Mon Sep 17 00:00:00 2001 From: Chetan Sharma Date: Fri, 21 Aug 2026 12:59:14 +0530 Subject: [PATCH 06/30] feat: reject dpop_key + use_mtls in signin_with_passkey --- src/auth0_server_python/auth_server/server_client.py | 6 ++++++ src/auth0_server_python/tests/test_server_client.py | 11 +++++++++++ 2 files changed, 17 insertions(+) diff --git a/src/auth0_server_python/auth_server/server_client.py b/src/auth0_server_python/auth_server/server_client.py index 6508110..b0adcb3 100644 --- a/src/auth0_server_python/auth_server/server_client.py +++ b/src/auth0_server_python/auth_server/server_client.py @@ -3338,6 +3338,12 @@ async def signin_with_passkey( raise MissingRequiredArgumentError("auth_session") if authn_response is None: raise MissingRequiredArgumentError("authn_response") + if self._use_mtls and dpop_key is not None: + raise ConfigurationError( + "dpop_key cannot be combined with use_mtls. DPoP and mTLS bind tokens " + "differently; DPoP would take precedence and the token would not be " + "certificate-bound." + ) try: domain = await self._resolve_current_domain(store_options) diff --git a/src/auth0_server_python/tests/test_server_client.py b/src/auth0_server_python/tests/test_server_client.py index 580d195..76c4d9c 100644 --- a/src/auth0_server_python/tests/test_server_client.py +++ b/src/auth0_server_python/tests/test_server_client.py @@ -9805,6 +9805,17 @@ async def test_apply_client_auth_mtls_returns_none_and_strips_creds(): assert "client_assertion_type" not in params +@pytest.mark.asyncio +async def test_signin_with_passkey_rejects_dpop_under_mtls(mocker): + client = _mtls_client() + with pytest.raises(ConfigurationError): + await client.signin_with_passkey( + auth_session="sess", + authn_response=mocker.Mock(), + dpop_key=object(), + ) + + @pytest.mark.asyncio async def test_complete_interactive_login_uses_mtls_token_endpoint(mocker): mock_tx_store = AsyncMock() From 3849b96514e7dea5aa9596f2a7944eccaa53705f Mon Sep 17 00:00:00 2001 From: Chetan Sharma Date: Fri, 21 Aug 2026 14:30:09 +0530 Subject: [PATCH 07/30] feat: warn when mTLS token lacks cnf.x5t#S256 binding --- .../auth_server/server_client.py | 30 ++++++++++++++ .../tests/test_server_client.py | 41 +++++++++++++++++++ 2 files changed, 71 insertions(+) diff --git a/src/auth0_server_python/auth_server/server_client.py b/src/auth0_server_python/auth_server/server_client.py index b0adcb3..1641834 100644 --- a/src/auth0_server_python/auth_server/server_client.py +++ b/src/auth0_server_python/auth_server/server_client.py @@ -7,6 +7,7 @@ import json import ssl import time +import warnings from collections import OrderedDict from typing import TYPE_CHECKING, Any, Callable, Generic, Optional, TypeVar, Union @@ -441,6 +442,31 @@ async def _verify_and_decode_jwt( return jwt.decode(token, signing_key.key, **kwargs) + def _warn_if_not_cert_bound(self, access_token: Optional[str]) -> None: + """Advisory warning when mTLS is on but the access token is not certificate-bound. + + Silent on opaque (non-JWT) tokens and when mTLS is off; never raises. + """ + if not self._use_mtls or not access_token: + return + try: + claims = jwt.decode( + access_token, + options={"verify_signature": False}, + algorithms=["HS256", "RS256", "ES256", "PS256"], + ) + except Exception: + return # opaque or unparseable token — nothing to assert + cnf = claims.get("cnf") if isinstance(claims, dict) else None + if not (isinstance(cnf, dict) and cnf.get("x5t#S256")): + warnings.warn( + "mTLS is enabled but the access token is not certificate-bound " + "(no cnf.x5t#S256). Sender-constraining is not active — configure " + "Token Sender-Constraining (mTLS) on the API resource server.", + UserWarning, + stacklevel=2, + ) + async def _fetch_oidc_metadata(self, domain: str) -> dict: """Fetch OIDC metadata from domain.""" normalized_domain = self._normalize_url(domain) @@ -813,6 +839,8 @@ async def complete_interactive_login( raise ApiError( "token_error", f"Token exchange failed: {str(e)}", e) + self._warn_if_not_cert_bound(token_response.get("access_token")) + # Use the userinfo field from the token_response for user claims user_info = token_response.get("userinfo") user_claims = None @@ -1491,6 +1519,8 @@ async def get_token_by_refresh_token(self, options: dict[str, Any]) -> dict[str, token_response = response.json() + self._warn_if_not_cert_bound(token_response.get("access_token")) + # Add required fields if they are missing if "expires_in" in token_response and "expires_at" not in token_response: token_response["expires_at"] = int( diff --git a/src/auth0_server_python/tests/test_server_client.py b/src/auth0_server_python/tests/test_server_client.py index 76c4d9c..ffdba8f 100644 --- a/src/auth0_server_python/tests/test_server_client.py +++ b/src/auth0_server_python/tests/test_server_client.py @@ -9805,6 +9805,47 @@ async def test_apply_client_auth_mtls_returns_none_and_strips_creds(): assert "client_assertion_type" not in params +_TEST_JWT_KEY = "test-signing-key-for-mtls-tests-32b" # ≥32 bytes avoids InsecureKeyLengthWarning + + +@pytest.mark.asyncio +async def test_warn_when_jwt_missing_cnf_under_mtls(recwarn): + client = _mtls_client() + token = jwt.encode({"sub": "u", "aud": "api"}, _TEST_JWT_KEY, algorithm="HS256") + client._warn_if_not_cert_bound(token) + assert any( + "cnf" in str(w.message).lower() or "certificate-bound" in str(w.message).lower() + for w in recwarn.list + ) + + +@pytest.mark.asyncio +async def test_no_warn_when_jwt_has_cnf(recwarn): + client = _mtls_client() + token = jwt.encode({"sub": "u", "cnf": {"x5t#S256": "abc"}}, _TEST_JWT_KEY, algorithm="HS256") + client._warn_if_not_cert_bound(token) + assert len(recwarn.list) == 0 + + +@pytest.mark.asyncio +async def test_no_warn_on_opaque_token(recwarn): + client = _mtls_client() + client._warn_if_not_cert_bound("opaque-not-a-jwt") + assert len(recwarn.list) == 0 + + +@pytest.mark.asyncio +async def test_warn_never_raises_and_silent_when_not_mtls(recwarn): + non_mtls = ServerClient( + domain="auth0.local", + client_id="", + client_secret="", + secret="", + ) + non_mtls._warn_if_not_cert_bound(None) + assert len(recwarn.list) == 0 + + @pytest.mark.asyncio async def test_signin_with_passkey_rejects_dpop_under_mtls(mocker): client = _mtls_client() From 09bf72f1a25e2ebaf804d2a13345f6758991ad7b Mon Sep 17 00:00:00 2001 From: Chetan Sharma Date: Fri, 21 Aug 2026 14:43:51 +0530 Subject: [PATCH 08/30] feat: thread mTLS ssl_context and alias routing through MFA verify --- .../auth_server/mfa_client.py | 16 +++++- .../auth_server/server_client.py | 2 + .../tests/test_mfa_client.py | 56 +++++++++++++++++++ 3 files changed, 73 insertions(+), 1 deletion(-) diff --git a/src/auth0_server_python/auth_server/mfa_client.py b/src/auth0_server_python/auth_server/mfa_client.py index a4e1dd7..60a07cc 100644 --- a/src/auth0_server_python/auth_server/mfa_client.py +++ b/src/auth0_server_python/auth_server/mfa_client.py @@ -4,6 +4,7 @@ """ import json +import ssl import time from collections.abc import Awaitable, Callable from typing import TYPE_CHECKING, Any, Optional, Union @@ -74,6 +75,8 @@ def __init__( ] = None, mfa_token_ttl: int = DEFAULT_MFA_TOKEN_TTL, apply_client_authentication: Optional[Callable] = None, + use_mtls: bool = False, + ssl_context: Optional[ssl.SSLContext] = None, ): if callable(domain): self._domain = None @@ -92,10 +95,14 @@ def __init__( raise ConfigurationError("mfa_token_ttl must be a positive number of seconds") self._mfa_token_ttl = mfa_token_ttl self._apply_client_authentication = apply_client_authentication + self._use_mtls = use_mtls + self._ssl_context = ssl_context def _get_http_client(self, **kwargs) -> httpx.AsyncClient: """Return an httpx.AsyncClient with default headers injected.""" headers = {**kwargs.pop("headers", {}), **self._headers} + if self._use_mtls and "verify" not in kwargs: + kwargs["verify"] = self._ssl_context return httpx.AsyncClient(headers=headers, **kwargs) def _apply_mfa_client_authentication(self, body: dict, base_url: str) -> None: @@ -472,6 +479,7 @@ async def verify( options: dict[str, Any], store_options: Optional[dict[str, Any]] = None, dpop_key: Optional["jwk.JWK"] = None, + token_endpoint_override: Optional[str] = None, ) -> MfaVerifyResponse: """ Verifies an MFA code and completes authentication. @@ -504,6 +512,12 @@ async def verify( MfaRequiredError: When chained MFA is required. ConfigurationError: If neither client_secret nor client_assertion_signing_key is configured. """ + if self._use_mtls and dpop_key is not None: + raise ConfigurationError( + "dpop_key cannot be combined with use_mtls. DPoP and mTLS bind tokens " + "differently; DPoP would take precedence and the token would not be " + "certificate-bound." + ) mfa_token = options.get("mfa_token") if not mfa_token: raise MfaTokenInvalidError() @@ -534,7 +548,7 @@ async def verify( ) try: - token_endpoint = f"{base_url}/oauth/token" + token_endpoint = token_endpoint_override or f"{base_url}/oauth/token" async with self._get_http_client() as client: headers = {"Content-Type": "application/x-www-form-urlencoded"} diff --git a/src/auth0_server_python/auth_server/server_client.py b/src/auth0_server_python/auth_server/server_client.py index 1641834..0d7e822 100644 --- a/src/auth0_server_python/auth_server/server_client.py +++ b/src/auth0_server_python/auth_server/server_client.py @@ -265,6 +265,8 @@ def __init__( session_establisher=self._establish_session_from_mfa_verify_response, mfa_token_ttl=mfa_token_ttl, apply_client_authentication=self._apply_client_authentication, + use_mtls=self._use_mtls, + ssl_context=self._ssl_context, ) self._passwordless_client = PasswordlessClient(self) diff --git a/src/auth0_server_python/tests/test_mfa_client.py b/src/auth0_server_python/tests/test_mfa_client.py index 8db3394..0b9fd26 100644 --- a/src/auth0_server_python/tests/test_mfa_client.py +++ b/src/auth0_server_python/tests/test_mfa_client.py @@ -3,6 +3,7 @@ """ import json +import ssl from unittest.mock import AsyncMock, MagicMock import pytest @@ -1093,3 +1094,58 @@ async def mock_post(self_client, url, **kwargs): result = await client.verify({"mfa_token": _enc(), "otp": "123456"}) assert result.token_type == "Bearer" assert "DPoP" not in captured_request["kwargs"]["headers"] + + +# ============================================================================ +# mTLS — MfaClient SSLContext threading + DPoP exclusion + endpoint override +# ============================================================================ + + +def _mtls_mfa_client() -> MfaClient: + return MfaClient( + domain=DOMAIN, + client_id=CLIENT_ID, + client_secret=None, + secret=SECRET, + use_mtls=True, + ssl_context=ssl.create_default_context(), + ) + + +@pytest.mark.asyncio +async def test_mfa_get_http_client_passes_ssl_context(mocker): + mfa = _mtls_mfa_client() + spy = mocker.patch("auth0_server_python.auth_server.mfa_client.httpx.AsyncClient") + mfa._get_http_client() + _, kwargs = spy.call_args + assert kwargs.get("verify") is mfa._ssl_context + + +@pytest.mark.asyncio +async def test_mfa_verify_rejects_dpop_under_mtls(): + mfa = _mtls_mfa_client() + with pytest.raises(ConfigurationError): + await mfa.verify({"mfa_token": _enc(), "otp": "123456"}, dpop_key=object()) + + +@pytest.mark.asyncio +async def test_mfa_verify_uses_token_endpoint_override(mocker): + mfa = _mtls_mfa_client() + response = AsyncMock() + response.status_code = 200 + response.json = MagicMock(return_value={ + "access_token": "at", "token_type": "Bearer", "expires_in": 3600 + }) + captured = {} + + async def mock_post(self_client, url, **kwargs): + captured["url"] = url + return response + + mocker.patch("httpx.AsyncClient.post", new=mock_post) + + await mfa.verify( + {"mfa_token": _enc(), "otp": "123456"}, + token_endpoint_override="https://mtls.auth0.local/oauth/token", + ) + assert captured["url"] == "https://mtls.auth0.local/oauth/token" From 5758f6300231f05bb83d21df06132279ac811101 Mon Sep 17 00:00:00 2001 From: Chetan Sharma Date: Fri, 21 Aug 2026 14:51:06 +0530 Subject: [PATCH 09/30] docs: document mTLS client authentication --- README.md | 23 +++++++++++ examples/MutualTLS.md | 87 ++++++++++++++++++++++++++++++++++++++++++ references/flow-map.md | 1 + 3 files changed, 111 insertions(+) create mode 100644 examples/MutualTLS.md diff --git a/README.md b/README.md index 2ff26cf..2c526f7 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,29 @@ The key must be a PKCS8 PEM private key. Register its public key on your Auth0 a > [!IMPORTANT] > Private keys must not be committed to source control. Load them from a secure secret store or an environment-provided file. +#### Authenticating with Mutual TLS (mTLS) + +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: + +```python +import ssl + +ssl_context = ssl.create_default_context() +ssl_context.load_cert_chain("client.crt", "client.key") + +auth0 = ServerClient( + domain="login.example.com", # self_managed_certs custom domain + client_id="", + use_mtls=True, + ssl_context=ssl_context, + secret="", +) +``` + +`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 [examples/MutualTLS.md](examples/MutualTLS.md) for the full setup guide, certificate generation, and token sender-constraining details. + ### 3. Add login to your Application (interactive) Before using redirect-based login, ensure the `redirect_uri` is configured when initializing the SDK: diff --git a/examples/MutualTLS.md b/examples/MutualTLS.md new file mode 100644 index 0000000..b3005e2 --- /dev/null +++ b/examples/MutualTLS.md @@ -0,0 +1,87 @@ +# Mutual TLS (mTLS) Client Authentication + +Authenticate to Auth0 with a TLS client certificate instead of a client secret (RFC 8705). The certificate is presented during the TLS handshake; no credential travels in the request body. + +## Prerequisites + +- Auth0 **Enterprise** tenant with the **Highly Regulated Identity** add-on +- A `self_managed_certs` **custom domain** configured on the tenant +- **Allow mTLS Endpoint Aliases** enabled on the tenant (Dashboard → Settings → Advanced) +- Client application's authentication method set to **mTLS** in Dashboard → Applications → Settings → Credentials + +## Generating a client certificate (development) + +```bash +# Self-signed CA + client cert (development only — use your PKI in production) +openssl req -x509 -newkey rsa:4096 -keyout ca.key -out ca.crt -days 365 -nodes \ + -subj "/CN=dev-ca" +openssl req -newkey rsa:2048 -keyout client.key -out client.csr -nodes \ + -subj "/CN=my-app-client" +openssl x509 -req -in client.csr -CA ca.crt -CAkey ca.key -CAcreateserial \ + -out client.crt -days 365 +``` + +## Wiring into `ServerClient` + +```python +import ssl +from auth0_server_python.auth_server.server_client import ServerClient + +ssl_context = ssl.create_default_context() # trusts system/public CAs for the server side +ssl_context.load_cert_chain("client.crt", "client.key") # attaches the client identity + +auth0 = ServerClient( + domain="login.example.com", # self_managed_certs custom domain + client_id="", + use_mtls=True, + ssl_context=ssl_context, + secret="", + authorization_params={ + "audience": "", + "scope": "openid profile email offline_access", + }, +) +``` + +The SDK passes `ssl_context` as `verify=ssl_context` to every `httpx.AsyncClient` it constructs, including the authlib client used for the authorization-code exchange. You never call `load_cert_chain` inside the SDK — the caller owns the TLS material. + +## Mutual exclusion + +`use_mtls=True` cannot be combined with: + +| Parameter | Reason | +|-----------|--------| +| `client_secret` | One client-auth method only — Auth0 rejects requests carrying both. | +| `client_assertion_signing_key` | Same — one method only. | +| `dpop_key` (per-call on `signin_with_passkey` / `mfa.verify`) | DPoP binds to its own key (`cnf.jkt`) and suppresses `cnf.x5t#S256`; combining them silently defeats mTLS token binding. | + +All three raise `ConfigurationError` immediately (constructor for the first two, at the call site for DPoP). + +## Token sender-constraining + +When the target API has **Token Sender-Constraining (mTLS)** enabled, issued access tokens carry a `cnf.x5t#S256` claim binding the token to the certificate thumbprint. The SDK warns if it receives a token that lacks this claim: + +> `UserWarning: mTLS is enabled but the access token is not certificate-bound (no cnf.x5t#S256). Sender-constraining is not active — configure Token Sender-Constraining (mTLS) on the API resource server.` + +To verify the thumbprint yourself: + +```bash +openssl x509 -in client.crt -outform DER | openssl dgst -sha256 -binary | openssl enc -base64 | tr '+/' '-_' | tr -d '=' +# Compare the output to the cnf.x5t#S256 claim in the decoded access token. +``` + +## MFA under mTLS + +The client certificate is presented on all MFA API calls. Only the token-endpoint call inside `mfa.verify` is routed through the mTLS alias; challenge and enrollment calls stay on the standard host (the standard host does not request a client certificate, so the loaded context is inert on those calls). + +When calling `client.mfa.verify` directly (rather than through the SDK's built-in flow), pass the resolved mTLS token endpoint: + +```python +metadata = await auth0._get_oidc_metadata_cached(domain) +mtls_token_endpoint = auth0._resolve_token_endpoint(metadata) + +await auth0.mfa.verify( + {"mfa_token": encrypted_token, "otp": "123456"}, + token_endpoint_override=mtls_token_endpoint, +) +``` diff --git a/references/flow-map.md b/references/flow-map.md index 6f9c8c1..9567948 100644 --- a/references/flow-map.md +++ b/references/flow-map.md @@ -17,6 +17,7 @@ Before working on a flow, read its entry points and supporting modules. Every fl | Passkeys | `passkey_signup_challenge`, `passkey_login_challenge`, `signin_with_passkey` | `auth_schemes/dpop_auth.py` — passkey sign-in is the DPoP-bound path | `examples/Passkeys.md` | | My Account | `MyAccountClient` (factors, authentication methods, enroll/verify) | `auth_schemes/dpop_auth.py`; stateless — every call takes a user token | `examples/MyAccountAuthenticationMethods.md` | | MCD | any flow — `domain` may be an async resolver | `_resolve_current_domain`, pitfall 5 in `references/pitfalls.md` | `examples/MultipleCustomDomains.md` | +| mTLS client auth | constructor `use_mtls` + `ssl_context` | `_resolve_token_endpoint`, `_apply_client_authentication`, `_warn_if_not_cert_bound`, `mfa_client.py` (`use_mtls`, `ssl_context`, `verify` `token_endpoint_override`) | `examples/MutualTLS.md` | Two rules cut across every flow above, so check them on any change here: resolve the domain through `await self._resolve_current_domain(store_options)` rather than reading `self._domain`, and accept From 14430d6abf99a36a92f905553f9bd8f3a3630245 Mon Sep 17 00:00:00 2001 From: Chetan Sharma Date: Mon, 31 Aug 2026 08:45:08 +0530 Subject: [PATCH 10/30] docs: document token_endpoint_override and dpop+mTLS ConfigurationError in docstrings Add missing token_endpoint_override param and ConfigurationError (dpop_key + use_mtls) to MfaClient.verify() docstring; add same ConfigurationError to signin_with_passkey() Raises section. --- src/auth0_server_python/auth_server/mfa_client.py | 5 +++++ src/auth0_server_python/auth_server/server_client.py | 2 ++ 2 files changed, 7 insertions(+) diff --git a/src/auth0_server_python/auth_server/mfa_client.py b/src/auth0_server_python/auth_server/mfa_client.py index 60a07cc..417d765 100644 --- a/src/auth0_server_python/auth_server/mfa_client.py +++ b/src/auth0_server_python/auth_server/mfa_client.py @@ -502,6 +502,9 @@ async def verify( dpop_key: Optional EC P-256 JWK to DPoP-bind the token. Pass the same key used at login (e.g. given to signin_with_passkey) to preserve the sender constraint through step-up. Never stored by the SDK. + token_endpoint_override: Optional token endpoint URL. When provided, overrides + the default ``{base_url}/oauth/token``. Used by ServerClient to supply the + mTLS endpoint alias when use_mtls is enabled. Returns: MfaVerifyResponse with access_token, token_type, etc. @@ -510,6 +513,8 @@ async def verify( MfaVerifyError: When verification fails, or when dpop_key was supplied but the server returned an unbound (Bearer) token. MfaRequiredError: When chained MFA is required. + ConfigurationError: If dpop_key is combined with use_mtls. DPoP and mTLS + use incompatible token-binding mechanisms and cannot be used together. ConfigurationError: If neither client_secret nor client_assertion_signing_key is configured. """ if self._use_mtls and dpop_key is not None: diff --git a/src/auth0_server_python/auth_server/server_client.py b/src/auth0_server_python/auth_server/server_client.py index 0d7e822..f8249ca 100644 --- a/src/auth0_server_python/auth_server/server_client.py +++ b/src/auth0_server_python/auth_server/server_client.py @@ -3361,6 +3361,8 @@ async def signin_with_passkey( Raises: MissingRequiredArgumentError: If auth_session or authn_response is missing. + ConfigurationError: If dpop_key is combined with use_mtls. DPoP and mTLS + use incompatible token-binding mechanisms and cannot be used together. PasskeyError: If token exchange or session creation fails. OrganizationTokenValidationError: If an organization was requested but the token response included no ID token, or the ID token's org claim does From 0398bc6cf76f28cb039dd1086d0c7a2e72faf935 Mon Sep 17 00:00:00 2001 From: Chetan Sharma Date: Mon, 31 Aug 2026 08:54:30 +0530 Subject: [PATCH 11/30] style: apply repo conventions to mTLS code and docs Replace em dashes with plain hyphens (Rule 4), split semicolon-spliced clauses into separate sentences (Rule 5), and reword the cryptic ssl_context error message in plain direct voice (Rule 9). --- README.md | 2 +- examples/MutualTLS.md | 10 +++++----- src/auth0_server_python/auth_server/mfa_client.py | 2 +- src/auth0_server_python/auth_server/server_client.py | 10 +++++----- 4 files changed, 12 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 2c526f7..725a1e1 100644 --- a/README.md +++ b/README.md @@ -103,7 +103,7 @@ auth0 = ServerClient( ) ``` -`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`. +`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 [examples/MutualTLS.md](examples/MutualTLS.md) for the full setup guide, certificate generation, and token sender-constraining details. diff --git a/examples/MutualTLS.md b/examples/MutualTLS.md index b3005e2..3543074 100644 --- a/examples/MutualTLS.md +++ b/examples/MutualTLS.md @@ -12,7 +12,7 @@ Authenticate to Auth0 with a TLS client certificate instead of a client secret ( ## Generating a client certificate (development) ```bash -# Self-signed CA + client cert (development only — use your PKI in production) +# Self-signed CA + client cert (development only - use your PKI in production) openssl req -x509 -newkey rsa:4096 -keyout ca.key -out ca.crt -days 365 -nodes \ -subj "/CN=dev-ca" openssl req -newkey rsa:2048 -keyout client.key -out client.csr -nodes \ @@ -43,7 +43,7 @@ auth0 = ServerClient( ) ``` -The SDK passes `ssl_context` as `verify=ssl_context` to every `httpx.AsyncClient` it constructs, including the authlib client used for the authorization-code exchange. You never call `load_cert_chain` inside the SDK — the caller owns the TLS material. +The SDK passes `ssl_context` as `verify=ssl_context` to every `httpx.AsyncClient` it constructs, including the authlib client used for the authorization-code exchange. You never call `load_cert_chain` inside the SDK - the caller owns the TLS material. ## Mutual exclusion @@ -51,8 +51,8 @@ The SDK passes `ssl_context` as `verify=ssl_context` to every `httpx.AsyncClient | Parameter | Reason | |-----------|--------| -| `client_secret` | One client-auth method only — Auth0 rejects requests carrying both. | -| `client_assertion_signing_key` | Same — one method only. | +| `client_secret` | One client-auth method only - Auth0 rejects requests carrying both. | +| `client_assertion_signing_key` | Same - one method only. | | `dpop_key` (per-call on `signin_with_passkey` / `mfa.verify`) | DPoP binds to its own key (`cnf.jkt`) and suppresses `cnf.x5t#S256`; combining them silently defeats mTLS token binding. | All three raise `ConfigurationError` immediately (constructor for the first two, at the call site for DPoP). @@ -61,7 +61,7 @@ All three raise `ConfigurationError` immediately (constructor for the first two, When the target API has **Token Sender-Constraining (mTLS)** enabled, issued access tokens carry a `cnf.x5t#S256` claim binding the token to the certificate thumbprint. The SDK warns if it receives a token that lacks this claim: -> `UserWarning: mTLS is enabled but the access token is not certificate-bound (no cnf.x5t#S256). Sender-constraining is not active — configure Token Sender-Constraining (mTLS) on the API resource server.` +> `UserWarning: mTLS is enabled but the access token is not certificate-bound (no cnf.x5t#S256). Sender-constraining is not active - configure Token Sender-Constraining (mTLS) on the API resource server.` To verify the thumbprint yourself: diff --git a/src/auth0_server_python/auth_server/mfa_client.py b/src/auth0_server_python/auth_server/mfa_client.py index 417d765..484979d 100644 --- a/src/auth0_server_python/auth_server/mfa_client.py +++ b/src/auth0_server_python/auth_server/mfa_client.py @@ -520,7 +520,7 @@ async def verify( if self._use_mtls and dpop_key is not None: raise ConfigurationError( "dpop_key cannot be combined with use_mtls. DPoP and mTLS bind tokens " - "differently; DPoP would take precedence and the token would not be " + "differently. DPoP would take precedence and the token would not be " "certificate-bound." ) mfa_token = options.get("mfa_token") diff --git a/src/auth0_server_python/auth_server/server_client.py b/src/auth0_server_python/auth_server/server_client.py index f8249ca..affcbfb 100644 --- a/src/auth0_server_python/auth_server/server_client.py +++ b/src/auth0_server_python/auth_server/server_client.py @@ -198,8 +198,8 @@ def __init__( if use_mtls: if ssl_context is None: raise ConfigurationError( - "use_mtls=True requires an ssl_context with the client certificate " - "loaded (ssl.create_default_context() + load_cert_chain())." + "ssl_context is required when use_mtls=True. Create an ssl.SSLContext " + "and call load_cert_chain() to load the client certificate." ) if client_secret: raise ConfigurationError( @@ -458,12 +458,12 @@ def _warn_if_not_cert_bound(self, access_token: Optional[str]) -> None: algorithms=["HS256", "RS256", "ES256", "PS256"], ) except Exception: - return # opaque or unparseable token — nothing to assert + return # opaque or unparseable token - nothing to assert cnf = claims.get("cnf") if isinstance(claims, dict) else None if not (isinstance(cnf, dict) and cnf.get("x5t#S256")): warnings.warn( "mTLS is enabled but the access token is not certificate-bound " - "(no cnf.x5t#S256). Sender-constraining is not active — configure " + "(no cnf.x5t#S256). Sender-constraining is not active - configure " "Token Sender-Constraining (mTLS) on the API resource server.", UserWarning, stacklevel=2, @@ -3375,7 +3375,7 @@ async def signin_with_passkey( if self._use_mtls and dpop_key is not None: raise ConfigurationError( "dpop_key cannot be combined with use_mtls. DPoP and mTLS bind tokens " - "differently; DPoP would take precedence and the token would not be " + "differently. DPoP would take precedence and the token would not be " "certificate-bound." ) From f557dd1dac6b3c336b10971fb14d2ab6c4bf3b26 Mon Sep 17 00:00:00 2001 From: Chetan Sharma Date: Mon, 31 Aug 2026 15:41:19 +0530 Subject: [PATCH 12/30] refactor(tests): distribute mTLS tests next to their surfaces Move test_signin_with_passkey_rejects_dpop_under_mtls into the PASSKEY AUTHENTICATION section and test_complete_interactive_login_uses_mtls_token_endpoint into the IPSIE section, next to the other complete_interactive_login tests. The remaining mTLS tests (constructor, resolver, credential-drop, cert-bound warning) stay in the mTLS section. --- .../tests/test_server_client.py | 108 ++++++++++-------- 1 file changed, 58 insertions(+), 50 deletions(-) diff --git a/src/auth0_server_python/tests/test_server_client.py b/src/auth0_server_python/tests/test_server_client.py index ffdba8f..4873a99 100644 --- a/src/auth0_server_python/tests/test_server_client.py +++ b/src/auth0_server_python/tests/test_server_client.py @@ -9002,6 +9002,23 @@ async def test_signin_with_passkey_client_default_org_is_validated_against_id_to state_store.set.assert_not_awaited() +@pytest.mark.asyncio +async def test_signin_with_passkey_rejects_dpop_under_mtls(mocker): + client = ServerClient( + domain="auth0.local", + client_id="", + use_mtls=True, + ssl_context=ssl.create_default_context(), + secret="", + ) + with pytest.raises(ConfigurationError): + await client.signin_with_passkey( + auth_session="sess", + authn_response=mocker.Mock(), + dpop_key=object(), + ) + + # ============================================================================= # IPSIE session_expiry enforcement # ============================================================================= @@ -9680,6 +9697,47 @@ async def test_complete_interactive_login_milliseconds_ceiling_fails_open(mocker assert stored_state.internal.session_expires_at is None +@pytest.mark.asyncio +async def test_complete_interactive_login_uses_mtls_token_endpoint(mocker): + mock_tx_store = AsyncMock() + mock_tx_store.get.return_value = TransactionData( + code_verifier="cv", + domain="auth0.local", + app_state=None, + ) + mock_tx_store.delete = AsyncMock() + mock_state_store = AsyncMock() + mock_state_store.get = AsyncMock(return_value=None) + mock_state_store.set = AsyncMock() + + client = ServerClient( + domain="auth0.local", + client_id="", + use_mtls=True, + ssl_context=ssl.create_default_context(), + secret="", + redirect_uri="https://app/cb", + transaction_store=mock_tx_store, + state_store=mock_state_store, + ) + + mtls_metadata = { + "issuer": "https://auth0.local/", + "token_endpoint": "https://auth0.local/oauth/token", + "mtls_endpoint_aliases": {"token_endpoint": "https://mtls.auth0.local/oauth/token"}, + } + mocker.patch.object(client, "_get_oidc_metadata_cached", AsyncMock(return_value=mtls_metadata)) + mocker.patch.object(client._oauth, "metadata", mtls_metadata) + + fetch_token = AsyncMock(return_value={"access_token": "at", "expires_in": 3600}) + mocker.patch.object(client._oauth, "fetch_token", fetch_token) + + await client.complete_interactive_login("https://app/cb?code=abc&state=xyz") + + called_endpoint = fetch_token.call_args[0][0] + assert called_endpoint == "https://mtls.auth0.local/oauth/token" + + # ============================================================================ # mTLS CLIENT AUTHENTICATION # ============================================================================ @@ -9846,53 +9904,3 @@ async def test_warn_never_raises_and_silent_when_not_mtls(recwarn): assert len(recwarn.list) == 0 -@pytest.mark.asyncio -async def test_signin_with_passkey_rejects_dpop_under_mtls(mocker): - client = _mtls_client() - with pytest.raises(ConfigurationError): - await client.signin_with_passkey( - auth_session="sess", - authn_response=mocker.Mock(), - dpop_key=object(), - ) - - -@pytest.mark.asyncio -async def test_complete_interactive_login_uses_mtls_token_endpoint(mocker): - mock_tx_store = AsyncMock() - mock_tx_store.get.return_value = TransactionData( - code_verifier="cv", - domain="auth0.local", - app_state=None, - ) - mock_tx_store.delete = AsyncMock() - mock_state_store = AsyncMock() - mock_state_store.get = AsyncMock(return_value=None) - mock_state_store.set = AsyncMock() - - client = ServerClient( - domain="auth0.local", - client_id="", - use_mtls=True, - ssl_context=_dummy_ssl_context(), - secret="", - redirect_uri="https://app/cb", - transaction_store=mock_tx_store, - state_store=mock_state_store, - ) - - mtls_metadata = { - "issuer": "https://auth0.local/", - "token_endpoint": "https://auth0.local/oauth/token", - "mtls_endpoint_aliases": {"token_endpoint": "https://mtls.auth0.local/oauth/token"}, - } - mocker.patch.object(client, "_get_oidc_metadata_cached", AsyncMock(return_value=mtls_metadata)) - mocker.patch.object(client._oauth, "metadata", mtls_metadata) - - fetch_token = AsyncMock(return_value={"access_token": "at", "expires_in": 3600}) - mocker.patch.object(client._oauth, "fetch_token", fetch_token) - - await client.complete_interactive_login("https://app/cb?code=abc&state=xyz") - - called_endpoint = fetch_token.call_args[0][0] - assert called_endpoint == "https://mtls.auth0.local/oauth/token" From 2f268755c5a842dc1cafe72359a1877867851f8a Mon Sep 17 00:00:00 2001 From: Chetan Sharma Date: Mon, 31 Aug 2026 15:43:34 +0530 Subject: [PATCH 13/30] docs: link to Auth0 mTLS configuration docs in README --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 725a1e1..4e3bf8f 100644 --- a/README.md +++ b/README.md @@ -103,7 +103,7 @@ auth0 = ServerClient( ) ``` -`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`. +`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. See [examples/MutualTLS.md](examples/MutualTLS.md) for the full setup guide, certificate generation, and token sender-constraining details. From 5bc3fec292d11eb82675060204a708453d8c32e2 Mon Sep 17 00:00:00 2001 From: Chetan Sharma Date: Mon, 31 Aug 2026 16:54:34 +0530 Subject: [PATCH 14/30] fix: route mTLS token calls through alias resolver in MFA, passwordless, and interactive login C22: inject token_endpoint_resolver into MfaClient so mfa.verify() resolves the mTLS alias automatically via ServerClient._resolve_mfa_token_endpoint, keeping metadata fetching and caching in ServerClient where it belongs. C24: add missing null-check on token_endpoint in complete_interactive_login, consistent with the other five call sites. C1: switch passwordless verify from metadata["token_endpoint"] to client._resolve_token_endpoint(metadata) so it routes through the mTLS alias. Credential-drop was already handled by _apply_client_authentication. --- examples/MutualTLS.md | 8 +------- .../auth_server/mfa_client.py | 11 +++++----- .../auth_server/passwordless_client.py | 7 ++++++- .../auth_server/server_client.py | 9 +++++++++ .../tests/test_mfa_client.py | 20 +++++++++++++------ 5 files changed, 36 insertions(+), 19 deletions(-) diff --git a/examples/MutualTLS.md b/examples/MutualTLS.md index 3543074..98300a2 100644 --- a/examples/MutualTLS.md +++ b/examples/MutualTLS.md @@ -72,16 +72,10 @@ openssl x509 -in client.crt -outform DER | openssl dgst -sha256 -binary | openss ## MFA under mTLS -The client certificate is presented on all MFA API calls. Only the token-endpoint call inside `mfa.verify` is routed through the mTLS alias; challenge and enrollment calls stay on the standard host (the standard host does not request a client certificate, so the loaded context is inert on those calls). - -When calling `client.mfa.verify` directly (rather than through the SDK's built-in flow), pass the resolved mTLS token endpoint: +The client certificate is presented on all MFA API calls. The token-endpoint call inside `mfa.verify` is routed through the mTLS alias automatically. Challenge and enrollment calls stay on the standard host, which does not request a client certificate. ```python -metadata = await auth0._get_oidc_metadata_cached(domain) -mtls_token_endpoint = auth0._resolve_token_endpoint(metadata) - await auth0.mfa.verify( {"mfa_token": encrypted_token, "otp": "123456"}, - token_endpoint_override=mtls_token_endpoint, ) ``` diff --git a/src/auth0_server_python/auth_server/mfa_client.py b/src/auth0_server_python/auth_server/mfa_client.py index 484979d..943923d 100644 --- a/src/auth0_server_python/auth_server/mfa_client.py +++ b/src/auth0_server_python/auth_server/mfa_client.py @@ -77,6 +77,7 @@ def __init__( apply_client_authentication: Optional[Callable] = None, use_mtls: bool = False, ssl_context: Optional[ssl.SSLContext] = None, + token_endpoint_resolver: Optional[Callable[..., Awaitable[str]]] = None, ): if callable(domain): self._domain = None @@ -97,6 +98,7 @@ def __init__( self._apply_client_authentication = apply_client_authentication self._use_mtls = use_mtls self._ssl_context = ssl_context + self._token_endpoint_resolver = token_endpoint_resolver def _get_http_client(self, **kwargs) -> httpx.AsyncClient: """Return an httpx.AsyncClient with default headers injected.""" @@ -479,7 +481,6 @@ async def verify( options: dict[str, Any], store_options: Optional[dict[str, Any]] = None, dpop_key: Optional["jwk.JWK"] = None, - token_endpoint_override: Optional[str] = None, ) -> MfaVerifyResponse: """ Verifies an MFA code and completes authentication. @@ -502,9 +503,6 @@ async def verify( dpop_key: Optional EC P-256 JWK to DPoP-bind the token. Pass the same key used at login (e.g. given to signin_with_passkey) to preserve the sender constraint through step-up. Never stored by the SDK. - token_endpoint_override: Optional token endpoint URL. When provided, overrides - the default ``{base_url}/oauth/token``. Used by ServerClient to supply the - mTLS endpoint alias when use_mtls is enabled. Returns: MfaVerifyResponse with access_token, token_type, etc. @@ -553,7 +551,10 @@ async def verify( ) try: - token_endpoint = token_endpoint_override or f"{base_url}/oauth/token" + if self._use_mtls and self._token_endpoint_resolver: + token_endpoint = await self._token_endpoint_resolver(store_options) + else: + token_endpoint = f"{base_url}/oauth/token" async with self._get_http_client() as client: headers = {"Content-Type": "application/x-www-form-urlencoded"} diff --git a/src/auth0_server_python/auth_server/passwordless_client.py b/src/auth0_server_python/auth_server/passwordless_client.py index 5d4ed23..4f8fe54 100644 --- a/src/auth0_server_python/auth_server/passwordless_client.py +++ b/src/auth0_server_python/auth_server/passwordless_client.py @@ -209,7 +209,12 @@ async def verify( e, ) - token_endpoint = metadata["token_endpoint"] + token_endpoint = client._resolve_token_endpoint(metadata) + if not token_endpoint: + raise PasswordlessVerifyError( + PasswordlessErrorCode.DISCOVERY_ERROR, + "Token endpoint missing in OIDC metadata", + ) origin_issuer = metadata.get("issuer") default_scope = ( diff --git a/src/auth0_server_python/auth_server/server_client.py b/src/auth0_server_python/auth_server/server_client.py index affcbfb..0f3894e 100644 --- a/src/auth0_server_python/auth_server/server_client.py +++ b/src/auth0_server_python/auth_server/server_client.py @@ -267,6 +267,7 @@ def __init__( apply_client_authentication=self._apply_client_authentication, use_mtls=self._use_mtls, ssl_context=self._ssl_context, + token_endpoint_resolver=self._resolve_mfa_token_endpoint if self._use_mtls else None, ) self._passwordless_client = PasswordlessClient(self) @@ -278,6 +279,12 @@ def _get_http_client(self, **kwargs) -> httpx.AsyncClient: kwargs["verify"] = self._ssl_context return httpx.AsyncClient(headers=headers, **kwargs) + async def _resolve_mfa_token_endpoint(self, store_options) -> str: + """Resolve the token endpoint for MfaClient, applying the mTLS alias when enabled.""" + domain = await self._resolve_current_domain(store_options) + metadata = await self._get_oidc_metadata_cached(domain) + return self._resolve_token_endpoint(metadata) + def _resolve_token_endpoint(self, metadata: dict) -> Optional[str]: """Return the token endpoint, routed to the mTLS alias when mTLS is enabled. @@ -829,6 +836,8 @@ async def complete_interactive_login( try: token_endpoint = self._resolve_token_endpoint(self._oauth.metadata) + if not token_endpoint: + raise ApiError("configuration_error", "Token endpoint missing in OIDC metadata") token_response = await self._oauth.fetch_token( token_endpoint, code=code, diff --git a/src/auth0_server_python/tests/test_mfa_client.py b/src/auth0_server_python/tests/test_mfa_client.py index 0b9fd26..8c301ad 100644 --- a/src/auth0_server_python/tests/test_mfa_client.py +++ b/src/auth0_server_python/tests/test_mfa_client.py @@ -1129,8 +1129,19 @@ async def test_mfa_verify_rejects_dpop_under_mtls(): @pytest.mark.asyncio -async def test_mfa_verify_uses_token_endpoint_override(mocker): - mfa = _mtls_mfa_client() +async def test_mfa_verify_uses_token_endpoint_resolver_under_mtls(mocker): + async def resolver(store_options): + return "https://mtls.auth0.local/oauth/token" + + mfa = MfaClient( + domain=DOMAIN, + client_id=CLIENT_ID, + client_secret=None, + secret=SECRET, + use_mtls=True, + ssl_context=ssl.create_default_context(), + token_endpoint_resolver=resolver, + ) response = AsyncMock() response.status_code = 200 response.json = MagicMock(return_value={ @@ -1144,8 +1155,5 @@ async def mock_post(self_client, url, **kwargs): mocker.patch("httpx.AsyncClient.post", new=mock_post) - await mfa.verify( - {"mfa_token": _enc(), "otp": "123456"}, - token_endpoint_override="https://mtls.auth0.local/oauth/token", - ) + await mfa.verify({"mfa_token": _enc(), "otp": "123456"}) assert captured["url"] == "https://mtls.auth0.local/oauth/token" From 4a03a5a6cbb877de5ab53b1946db527972e70760 Mon Sep 17 00:00:00 2001 From: Chetan Sharma Date: Mon, 31 Aug 2026 17:11:37 +0530 Subject: [PATCH 15/30] refactor: replace cnf.x5t#S256 UserWarning with documentation The runtime warning flags a resource server misconfiguration the SDK has no control over, using a once-only mechanism inconsistent with the rest of the SDK. MutualTLS.md now states the requirement directly. --- examples/MutualTLS.md | 4 +- .../auth_server/server_client.py | 30 -------------- .../tests/test_server_client.py | 40 ------------------- 3 files changed, 1 insertion(+), 73 deletions(-) diff --git a/examples/MutualTLS.md b/examples/MutualTLS.md index 98300a2..d5436c4 100644 --- a/examples/MutualTLS.md +++ b/examples/MutualTLS.md @@ -59,9 +59,7 @@ All three raise `ConfigurationError` immediately (constructor for the first two, ## Token sender-constraining -When the target API has **Token Sender-Constraining (mTLS)** enabled, issued access tokens carry a `cnf.x5t#S256` claim binding the token to the certificate thumbprint. The SDK warns if it receives a token that lacks this claim: - -> `UserWarning: mTLS is enabled but the access token is not certificate-bound (no cnf.x5t#S256). Sender-constraining is not active - configure Token Sender-Constraining (mTLS) on the API resource server.` +When the target API has **Token Sender-Constraining (mTLS)** enabled, issued access tokens carry a `cnf.x5t#S256` claim binding the token to the certificate thumbprint. If your tokens do not contain this claim, enable **Token Sender-Constraining (mTLS)** on the API resource server in the Auth0 dashboard. To verify the thumbprint yourself: diff --git a/src/auth0_server_python/auth_server/server_client.py b/src/auth0_server_python/auth_server/server_client.py index 0f3894e..11ec9c9 100644 --- a/src/auth0_server_python/auth_server/server_client.py +++ b/src/auth0_server_python/auth_server/server_client.py @@ -7,7 +7,6 @@ import json import ssl import time -import warnings from collections import OrderedDict from typing import TYPE_CHECKING, Any, Callable, Generic, Optional, TypeVar, Union @@ -451,31 +450,6 @@ async def _verify_and_decode_jwt( return jwt.decode(token, signing_key.key, **kwargs) - def _warn_if_not_cert_bound(self, access_token: Optional[str]) -> None: - """Advisory warning when mTLS is on but the access token is not certificate-bound. - - Silent on opaque (non-JWT) tokens and when mTLS is off; never raises. - """ - if not self._use_mtls or not access_token: - return - try: - claims = jwt.decode( - access_token, - options={"verify_signature": False}, - algorithms=["HS256", "RS256", "ES256", "PS256"], - ) - except Exception: - return # opaque or unparseable token - nothing to assert - cnf = claims.get("cnf") if isinstance(claims, dict) else None - if not (isinstance(cnf, dict) and cnf.get("x5t#S256")): - warnings.warn( - "mTLS is enabled but the access token is not certificate-bound " - "(no cnf.x5t#S256). Sender-constraining is not active - configure " - "Token Sender-Constraining (mTLS) on the API resource server.", - UserWarning, - stacklevel=2, - ) - async def _fetch_oidc_metadata(self, domain: str) -> dict: """Fetch OIDC metadata from domain.""" normalized_domain = self._normalize_url(domain) @@ -850,8 +824,6 @@ async def complete_interactive_login( raise ApiError( "token_error", f"Token exchange failed: {str(e)}", e) - self._warn_if_not_cert_bound(token_response.get("access_token")) - # Use the userinfo field from the token_response for user claims user_info = token_response.get("userinfo") user_claims = None @@ -1530,8 +1502,6 @@ async def get_token_by_refresh_token(self, options: dict[str, Any]) -> dict[str, token_response = response.json() - self._warn_if_not_cert_bound(token_response.get("access_token")) - # Add required fields if they are missing if "expires_in" in token_response and "expires_at" not in token_response: token_response["expires_at"] = int( diff --git a/src/auth0_server_python/tests/test_server_client.py b/src/auth0_server_python/tests/test_server_client.py index 4873a99..2663322 100644 --- a/src/auth0_server_python/tests/test_server_client.py +++ b/src/auth0_server_python/tests/test_server_client.py @@ -9863,44 +9863,4 @@ async def test_apply_client_auth_mtls_returns_none_and_strips_creds(): assert "client_assertion_type" not in params -_TEST_JWT_KEY = "test-signing-key-for-mtls-tests-32b" # ≥32 bytes avoids InsecureKeyLengthWarning - - -@pytest.mark.asyncio -async def test_warn_when_jwt_missing_cnf_under_mtls(recwarn): - client = _mtls_client() - token = jwt.encode({"sub": "u", "aud": "api"}, _TEST_JWT_KEY, algorithm="HS256") - client._warn_if_not_cert_bound(token) - assert any( - "cnf" in str(w.message).lower() or "certificate-bound" in str(w.message).lower() - for w in recwarn.list - ) - - -@pytest.mark.asyncio -async def test_no_warn_when_jwt_has_cnf(recwarn): - client = _mtls_client() - token = jwt.encode({"sub": "u", "cnf": {"x5t#S256": "abc"}}, _TEST_JWT_KEY, algorithm="HS256") - client._warn_if_not_cert_bound(token) - assert len(recwarn.list) == 0 - - -@pytest.mark.asyncio -async def test_no_warn_on_opaque_token(recwarn): - client = _mtls_client() - client._warn_if_not_cert_bound("opaque-not-a-jwt") - assert len(recwarn.list) == 0 - - -@pytest.mark.asyncio -async def test_warn_never_raises_and_silent_when_not_mtls(recwarn): - non_mtls = ServerClient( - domain="auth0.local", - client_id="", - client_secret="", - secret="", - ) - non_mtls._warn_if_not_cert_bound(None) - assert len(recwarn.list) == 0 - From e620c26d570fa0b1646842b5c676b023d825a88c Mon Sep 17 00:00:00 2001 From: Chetan Sharma Date: Mon, 31 Aug 2026 17:55:23 +0530 Subject: [PATCH 16/30] docs: document passkey challenge/register incompatibility with mTLS-only configuration --- examples/MutualTLS.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/examples/MutualTLS.md b/examples/MutualTLS.md index d5436c4..96e97fc 100644 --- a/examples/MutualTLS.md +++ b/examples/MutualTLS.md @@ -77,3 +77,11 @@ await auth0.mfa.verify( {"mfa_token": encrypted_token, "otp": "123456"}, ) ``` + +## Passkeys under mTLS + +`/passkey/challenge` and `/passkey/register` are not served on the mTLS endpoint aliases. Auth0 only accepts `client_secret` as the credential on those endpoints - the client certificate is not a valid credential there. + +Because `use_mtls=True` forbids `client_secret` at construction time, an mTLS-configured client has no valid credential for `passkey_login_challenge` and `passkey_signup_challenge`. Those calls will be rejected by Auth0 if the application is registered as a confidential client. + +`signin_with_passkey` (the token-exchange step) is not affected - it calls the token endpoint, which is served on the mTLS alias and routed correctly. From 9cd5951776741e2e19a736edb018ba3c6275e332 Mon Sep 17 00:00:00 2001 From: Chetan Sharma Date: Mon, 31 Aug 2026 18:21:30 +0530 Subject: [PATCH 17/30] feat: wire ssl_context through MyAccountClient for mTLS cert-bound token support --- .../auth_server/my_account_client.py | 14 ++++++++++- .../auth_server/server_client.py | 4 +++- .../tests/test_my_account_client.py | 23 +++++++++++++++++++ 3 files changed, 39 insertions(+), 2 deletions(-) diff --git a/src/auth0_server_python/auth_server/my_account_client.py b/src/auth0_server_python/auth_server/my_account_client.py index e4e10f2..d17f842 100644 --- a/src/auth0_server_python/auth_server/my_account_client.py +++ b/src/auth0_server_python/auth_server/my_account_client.py @@ -1,4 +1,5 @@ import json +import ssl from typing import TYPE_CHECKING, Optional from urllib.parse import quote, unquote, urlparse @@ -46,20 +47,31 @@ class MyAccountClient: Client for interacting with the Auth0 MyAccount API. """ - def __init__(self, domain: str, headers: Optional[dict[str, str]] = None): + def __init__( + self, + domain: str, + headers: Optional[dict[str, str]] = None, + ssl_context: Optional[ssl.SSLContext] = None, + ): """ Initialize the MyAccount API client. Args: domain: Auth0 domain (e.g., '..auth0.com') headers: Optional default headers to include on every request + ssl_context: Optional SSL context for mTLS. When provided, the client + certificate is presented on every request so the My Account API can + verify cnf.x5t#S256 binding on cert-bound access tokens. """ self._domain = domain self._headers = headers or {} + self._ssl_context = ssl_context def _get_http_client(self, **kwargs) -> httpx.AsyncClient: """Return an httpx.AsyncClient with default headers injected.""" headers = {**kwargs.pop("headers", {}), **self._headers} + if self._ssl_context is not None and "verify" not in kwargs: + kwargs["verify"] = self._ssl_context return httpx.AsyncClient(headers=headers, **kwargs) @property diff --git a/src/auth0_server_python/auth_server/server_client.py b/src/auth0_server_python/auth_server/server_client.py index 11ec9c9..8e0df3d 100644 --- a/src/auth0_server_python/auth_server/server_client.py +++ b/src/auth0_server_python/auth_server/server_client.py @@ -244,7 +244,9 @@ def __init__( ) self._my_account_client = MyAccountClient( - domain=domain, headers=self._telemetry_headers + domain=domain, + headers=self._telemetry_headers, + **({"ssl_context": self._ssl_context} if self._use_mtls else {}), ) # Unified cache for OIDC metadata and JWKS per domain (LRU eviction + TTL) diff --git a/src/auth0_server_python/tests/test_my_account_client.py b/src/auth0_server_python/tests/test_my_account_client.py index f917eef..0249f2a 100644 --- a/src/auth0_server_python/tests/test_my_account_client.py +++ b/src/auth0_server_python/tests/test_my_account_client.py @@ -1,5 +1,6 @@ import base64 import json +import ssl from unittest.mock import ANY, AsyncMock, MagicMock import httpx @@ -1422,3 +1423,25 @@ def test_dpop_auth_flow_no_retry_on_non_401(): assert not retried + + +# ============================================================================= +# mTLS ssl_context wiring +# ============================================================================= + + +def test_get_http_client_passes_ssl_context_when_set(mocker): + ctx = ssl.create_default_context() + client = MyAccountClient(domain="auth0.local", ssl_context=ctx) + spy = mocker.patch("auth0_server_python.auth_server.my_account_client.httpx.AsyncClient") + client._get_http_client() + _, kwargs = spy.call_args + assert kwargs.get("verify") is ctx + + +def test_get_http_client_omits_verify_without_ssl_context(mocker): + client = MyAccountClient(domain="auth0.local") + spy = mocker.patch("auth0_server_python.auth_server.my_account_client.httpx.AsyncClient") + client._get_http_client() + _, kwargs = spy.call_args + assert "verify" not in kwargs From e46e28bcef41c9625286161235ee77717261335c Mon Sep 17 00:00:00 2001 From: Chetan Sharma Date: Tue, 1 Sep 2026 07:40:16 +0530 Subject: [PATCH 18/30] fix: route PAR endpoint through mTLS alias when use_mtls is enabled --- .../auth_server/server_client.py | 21 ++++++- .../tests/test_server_client.py | 62 +++++++++++++++++++ 2 files changed, 81 insertions(+), 2 deletions(-) diff --git a/src/auth0_server_python/auth_server/server_client.py b/src/auth0_server_python/auth_server/server_client.py index 8e0df3d..4e8fba3 100644 --- a/src/auth0_server_python/auth_server/server_client.py +++ b/src/auth0_server_python/auth_server/server_client.py @@ -304,6 +304,24 @@ def _resolve_token_endpoint(self, metadata: dict) -> Optional[str]: return endpoint return metadata.get("token_endpoint") + def _resolve_par_endpoint(self, metadata: dict) -> Optional[str]: + """Return the PAR endpoint, routed to the mTLS alias when mTLS is enabled. + + Under mTLS, raises ConfigurationError immediately if the alias is absent. + Under standard auth, returns None if the endpoint is missing (caller's guard handles it). + """ + if self._use_mtls: + aliases = metadata.get("mtls_endpoint_aliases") or {} + endpoint = aliases.get("pushed_authorization_request_endpoint") + if not endpoint: + raise ConfigurationError( + "use_mtls is enabled but the authorization server discovery document " + "does not advertise mtls_endpoint_aliases.pushed_authorization_request_endpoint. " + "Ensure mTLS endpoint aliases are enabled on your Auth0 tenant." + ) + return endpoint + return metadata.get("pushed_authorization_request_endpoint") + def _apply_client_authentication( self, params: dict, issuer: str, in_body: bool = False ) -> Optional[tuple[str, str]]: @@ -697,8 +715,7 @@ async def start_interactive_login( self._oauth.metadata = metadata # If PAR is enabled, use the PAR endpoint if self._pushed_authorization_requests: - par_endpoint = self._oauth.metadata.get( - "pushed_authorization_request_endpoint") + par_endpoint = self._resolve_par_endpoint(self._oauth.metadata) if not par_endpoint: raise ApiError( "configuration_error", "PAR is enabled but pushed_authorization_request_endpoint is missing in metadata") diff --git a/src/auth0_server_python/tests/test_server_client.py b/src/auth0_server_python/tests/test_server_client.py index 2663322..bd45b5c 100644 --- a/src/auth0_server_python/tests/test_server_client.py +++ b/src/auth0_server_python/tests/test_server_client.py @@ -278,6 +278,42 @@ async def test_par_request_caller_cannot_inject_client_assertion(mocker): assert "client_assertion_type" not in posted +@pytest.mark.asyncio +async def test_par_request_uses_mtls_alias_endpoint(mocker): + client = ServerClient( + domain="auth0.local", + client_id="", + use_mtls=True, + ssl_context=ssl.create_default_context(), + secret="", + pushed_authorization_requests=True, + authorization_params={"redirect_uri": "https://app/cb"}, + state_store=AsyncMock(), + transaction_store=AsyncMock(), + ) + mtls_metadata = { + "issuer": "https://auth0.local/", + "authorization_endpoint": "https://auth0.local/authorize", + "pushed_authorization_request_endpoint": "https://auth0.local/oauth/par", + "mtls_endpoint_aliases": { + "pushed_authorization_request_endpoint": "https://mtls.auth0.local/oauth/par", + }, + } + mocker.patch.object(client, "_get_oidc_metadata_cached", AsyncMock(return_value=mtls_metadata)) + mocker.patch.object(client._oauth, "metadata", mtls_metadata) + + mock_post = mocker.patch("httpx.AsyncClient.post", new_callable=AsyncMock) + par_response = AsyncMock() + par_response.status_code = 201 + par_response.json = MagicMock(return_value={"request_uri": "urn:req:abc", "expires_in": 60}) + mock_post.return_value = par_response + + await client.start_interactive_login() + + called_url = mock_post.call_args[0][0] + assert called_url == "https://mtls.auth0.local/oauth/par" + + @pytest.mark.asyncio async def test_complete_interactive_login_no_transaction(): mock_transaction_store = AsyncMock() @@ -9852,6 +9888,32 @@ async def test_resolve_token_endpoint_standard_when_not_mtls(): assert client._resolve_token_endpoint(metadata) == "https://auth0.local/oauth/token" +def test_resolve_par_endpoint_uses_alias_under_mtls(): + client = _mtls_client() + metadata = { + "pushed_authorization_request_endpoint": "https://auth0.local/oauth/par", + "mtls_endpoint_aliases": {"pushed_authorization_request_endpoint": "https://mtls.auth0.local/oauth/par"}, + } + assert client._resolve_par_endpoint(metadata) == "https://mtls.auth0.local/oauth/par" + + +def test_resolve_par_endpoint_raises_when_mtls_alias_missing(): + client = _mtls_client() + with pytest.raises(ConfigurationError): + client._resolve_par_endpoint({"pushed_authorization_request_endpoint": "https://auth0.local/oauth/par"}) + + +def test_resolve_par_endpoint_standard_when_not_mtls(): + client = ServerClient( + domain="auth0.local", + client_id="", + client_secret="", + secret="", + ) + metadata = {"pushed_authorization_request_endpoint": "https://auth0.local/oauth/par"} + assert client._resolve_par_endpoint(metadata) == "https://auth0.local/oauth/par" + + @pytest.mark.asyncio async def test_apply_client_auth_mtls_returns_none_and_strips_creds(): client = _mtls_client() From 82cd04c1742fa55a061c58282aabbaaccc210d9b Mon Sep 17 00:00:00 2001 From: Chetan Sharma Date: Tue, 1 Sep 2026 13:29:14 +0530 Subject: [PATCH 19/30] test: add mTLS token endpoint routing assertions for refresh, backchannel, connection, and custom-exchange flows --- .../tests/test_server_client.py | 122 ++++++++++++++++++ 1 file changed, 122 insertions(+) diff --git a/src/auth0_server_python/tests/test_server_client.py b/src/auth0_server_python/tests/test_server_client.py index bd45b5c..846f48e 100644 --- a/src/auth0_server_python/tests/test_server_client.py +++ b/src/auth0_server_python/tests/test_server_client.py @@ -2454,6 +2454,33 @@ async def test_backchannel_authentication_grant_json_decode_error(mocker): assert exc.value.code == "invalid_response" assert "Failed to parse token response as JSON" in str(exc.value) +@pytest.mark.asyncio +async def test_backchannel_authentication_grant_uses_mtls_token_endpoint(mocker): + client = ServerClient( + domain="auth0.local", + client_id="", + use_mtls=True, + ssl_context=ssl.create_default_context(), + secret="some-secret", + ) + mocker.patch.object( + client, + "_get_oidc_metadata_cached", + return_value={ + "token_endpoint": "https://auth0.local/oauth/token", + "mtls_endpoint_aliases": {"token_endpoint": "https://mtls.auth0.local/oauth/token"}, + }, + ) + mock_post = mocker.patch("httpx.AsyncClient.post", new_callable=AsyncMock) + mock_response = AsyncMock() + mock_response.status_code = 200 + mock_response.json = MagicMock(return_value={"access_token": "at", "expires_in": 3600}) + mock_post.return_value = mock_response + + await client.backchannel_authentication_grant("auth_req_123") + + assert mock_post.call_args[0][0] == "https://mtls.auth0.local/oauth/token" + @pytest.mark.asyncio async def test_get_token_for_connection_success(mocker): client = ServerClient( @@ -2539,6 +2566,34 @@ async def test_get_token_for_connection_exchange_failed(mocker): mock_post.assert_awaited_once() +@pytest.mark.asyncio +async def test_get_token_for_connection_uses_mtls_token_endpoint(mocker): + client = ServerClient( + domain="auth0.local", + client_id="", + use_mtls=True, + ssl_context=ssl.create_default_context(), + secret="some-secret", + ) + mocker.patch.object( + client, + "_get_oidc_metadata_cached", + return_value={ + "token_endpoint": "https://auth0.local/oauth/token", + "mtls_endpoint_aliases": {"token_endpoint": "https://mtls.auth0.local/oauth/token"}, + }, + ) + mock_post = mocker.patch("httpx.AsyncClient.post", new_callable=AsyncMock) + success_response = AsyncMock() + success_response.status_code = 200 + success_response.json = MagicMock(return_value={"access_token": "at", "expires_in": 3600}) + success_response.headers = {} + mock_post.return_value = success_response + + await client.get_token_for_connection({"connection": "github", "refresh_token": "rt"}) + + assert mock_post.call_args[0][0] == "https://mtls.auth0.local/oauth/token" + @pytest.mark.asyncio async def test_get_token_by_refresh_token_success(mocker): client = ServerClient( @@ -2652,6 +2707,33 @@ async def test_get_token_by_refresh_token_mfa_required_raises_api_error_with_raw assert exc.value.mfa_token == "raw_server_mfa_token" assert exc.value.mfa_requirements is None +@pytest.mark.asyncio +async def test_get_token_by_refresh_token_uses_mtls_token_endpoint(mocker): + client = ServerClient( + domain="auth0.local", + client_id="", + use_mtls=True, + ssl_context=ssl.create_default_context(), + secret="some-secret", + ) + mocker.patch.object( + client, + "_get_oidc_metadata_cached", + return_value={ + "token_endpoint": "https://auth0.local/oauth/token", + "mtls_endpoint_aliases": {"token_endpoint": "https://mtls.auth0.local/oauth/token"}, + }, + ) + mock_post = mocker.patch("httpx.AsyncClient.post", new_callable=AsyncMock) + success_response = AsyncMock() + success_response.status_code = 200 + success_response.json = MagicMock(return_value={"access_token": "at", "expires_in": 3600}) + mock_post.return_value = success_response + + await client.get_token_by_refresh_token({"refresh_token": "abc"}) + + assert mock_post.call_args[0][0] == "https://mtls.auth0.local/oauth/token" + # ============================================================================= # Private Key JWT (client assertion) Client Authentication @@ -4301,6 +4383,46 @@ async def test_custom_token_exchange_act_dropped_on_issuer_mismatch(mocker): assert result.act is None +@pytest.mark.asyncio +async def test_custom_token_exchange_uses_mtls_token_endpoint(mocker): + client = ServerClient( + domain="auth0.local", + client_id="", + use_mtls=True, + ssl_context=ssl.create_default_context(), + secret="some-secret", + ) + mocker.patch.object( + client, + "_get_oidc_metadata_cached", + return_value={ + "token_endpoint": "https://auth0.local/oauth/token", + "mtls_endpoint_aliases": {"token_endpoint": "https://mtls.auth0.local/oauth/token"}, + }, + ) + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "access_token": "at", + "token_type": "Bearer", + "expires_in": 3600, + "issued_token_type": "urn:ietf:params:oauth:token-type:access_token", + } + mock_response.headers.get.return_value = "application/json" + mock_httpx_client = AsyncMock() + mock_httpx_client.__aenter__.return_value = mock_httpx_client + mock_httpx_client.__aexit__.return_value = None + mock_httpx_client.post.return_value = mock_response + mocker.patch("httpx.AsyncClient", return_value=mock_httpx_client) + + await client.custom_token_exchange(CustomTokenExchangeOptions( + subject_token="custom-token", + subject_token_type="urn:acme:token", + audience="https://api.example.com", + )) + + assert mock_httpx_client.post.call_args[0][0] == "https://mtls.auth0.local/oauth/token" + # ============================================================================= # Login with Custom Token Exchange Tests From 66696aa31741752182ce1235db860f38070dd8a7 Mon Sep 17 00:00:00 2001 From: Chetan Sharma Date: Tue, 1 Sep 2026 14:43:47 +0530 Subject: [PATCH 20/30] docs: document use_mtls and ssl_context in ServerClient.__init__ docstring --- src/auth0_server_python/auth_server/server_client.py | 6 ++++++ src/auth0_server_python/tests/test_my_account_client.py | 1 - 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/auth0_server_python/auth_server/server_client.py b/src/auth0_server_python/auth_server/server_client.py index 4e8fba3..aa40943 100644 --- a/src/auth0_server_python/auth_server/server_client.py +++ b/src/auth0_server_python/auth_server/server_client.py @@ -161,6 +161,12 @@ def __init__( `mfa.verify()`/`mfa.challenge_authenticator()` reject it as expired. Defaults to 300 (5 minutes). Increase for authenticator flows that need more time (e.g. OOB push approval on a slow connection). + use_mtls: Enable mTLS (RFC 8705) client authentication. When True, the + client certificate in ssl_context is the sole credential - no + client_secret or client_assertion is sent in the request body. + ssl_context: TLS context carrying the client certificate and key. + Required when use_mtls=True. Build with ssl.create_default_context() + and load_cert_chain(). Raises: ConfigurationError: If `mfa_token_ttl` is not a positive number of seconds. diff --git a/src/auth0_server_python/tests/test_my_account_client.py b/src/auth0_server_python/tests/test_my_account_client.py index 0249f2a..fc314f1 100644 --- a/src/auth0_server_python/tests/test_my_account_client.py +++ b/src/auth0_server_python/tests/test_my_account_client.py @@ -1424,7 +1424,6 @@ def test_dpop_auth_flow_no_retry_on_non_401(): assert not retried - # ============================================================================= # mTLS ssl_context wiring # ============================================================================= From c3cf72ca856c0d6f8dc0600a4c5f334f2ddc9240 Mon Sep 17 00:00:00 2001 From: Chetan Sharma Date: Fri, 4 Sep 2026 17:09:39 +0530 Subject: [PATCH 21/30] feat: log warning when mTLS access token lacks cnf.x5t#S256 claim Add _warn_if_not_cert_bound to emit a logger.warning when use_mtls is enabled but the returned access token is not certificate-bound. Called from complete_interactive_login and get_token_by_refresh_token only, matching the grant types covered by RFC 8705 sender-constraining. --- .../auth_server/server_client.py | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/src/auth0_server_python/auth_server/server_client.py b/src/auth0_server_python/auth_server/server_client.py index aa40943..547765e 100644 --- a/src/auth0_server_python/auth_server/server_client.py +++ b/src/auth0_server_python/auth_server/server_client.py @@ -5,6 +5,7 @@ import asyncio import json +import logging import ssl import time from collections import OrderedDict @@ -102,6 +103,8 @@ # actor_token_type URN when the actor is sourced from the agent session's ID token. ID_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:id_token" +logger = logging.getLogger(__name__) + class ServerClient(Generic[TStoreOptions]): """ @@ -620,6 +623,27 @@ async def _get_jwks_cached(self, domain: str, metadata: dict = None) -> dict: return jwks + def _warn_if_not_cert_bound(self, token_response: dict) -> None: + """Warn if the access token lacks a cnf.x5t#S256 claim.""" + access_token = token_response.get("access_token") + if not access_token: + return + try: + claims = jwt.decode( + access_token, + options={"verify_signature": False}, + algorithms=["RS256", "ES256"], + ) + except jwt.InvalidTokenError: + return # opaque or unparseable token - nothing to assert + cnf = claims.get("cnf") if isinstance(claims, dict) else None + if not (isinstance(cnf, dict) and cnf.get("x5t#S256")): + logger.warning( + "mTLS is enabled but the access token does not contain a cnf.x5t#S256 " + "claim. The token is not certificate-bound. Configure Token " + "Sender-Constraining (mTLS) on the API resource server." + ) + # ============================================================================ # INTERACTIVE LOGIN FLOW # Handles browser-based authentication using the Authorization Code flow @@ -849,6 +873,9 @@ async def complete_interactive_login( raise ApiError( "token_error", f"Token exchange failed: {str(e)}", e) + if self._use_mtls: + self._warn_if_not_cert_bound(token_response) + # Use the userinfo field from the token_response for user claims user_info = token_response.get("userinfo") user_claims = None @@ -1532,6 +1559,9 @@ async def get_token_by_refresh_token(self, options: dict[str, Any]) -> dict[str, token_response["expires_at"] = int( time.time()) + token_response["expires_in"] + if self._use_mtls: + self._warn_if_not_cert_bound(token_response) + return token_response except Exception as e: From 16fef8e92793fc8738499420e04bad3e6e932f54 Mon Sep 17 00:00:00 2001 From: Chetan Sharma Date: Fri, 4 Sep 2026 17:10:07 +0530 Subject: [PATCH 22/30] test: add cert-bound warning tests for _warn_if_not_cert_bound Tests distributed next to their surfaces: call-site assertions for complete_interactive_login and get_token_by_refresh_token placed next to their existing mTLS routing tests; unit tests for the method itself in the mTLS section covering warn/no-warn/opaque/missing-token cases. --- .../tests/test_server_client.py | 110 ++++++++++++++++++ 1 file changed, 110 insertions(+) diff --git a/src/auth0_server_python/tests/test_server_client.py b/src/auth0_server_python/tests/test_server_client.py index 846f48e..c6ea1fc 100644 --- a/src/auth0_server_python/tests/test_server_client.py +++ b/src/auth0_server_python/tests/test_server_client.py @@ -2735,6 +2735,39 @@ async def test_get_token_by_refresh_token_uses_mtls_token_endpoint(mocker): assert mock_post.call_args[0][0] == "https://mtls.auth0.local/oauth/token" +@pytest.mark.asyncio +async def test_get_token_by_refresh_token_warns_when_token_not_cert_bound(mocker): + client = ServerClient( + domain="auth0.local", + client_id="", + use_mtls=True, + ssl_context=ssl.create_default_context(), + secret="", + ) + mocker.patch.object( + client, "_get_oidc_metadata_cached", + return_value={ + "token_endpoint": "https://auth0.local/oauth/token", + "mtls_endpoint_aliases": {"token_endpoint": "https://mtls.auth0.local/oauth/token"}, + }, + ) + mock_post = mocker.patch("httpx.AsyncClient.post", new_callable=AsyncMock) + response = AsyncMock() + response.status_code = 200 + response.json = MagicMock( + return_value={ + "access_token": jwt.encode({"sub": "user123"}, "s", algorithm="HS256"), + "expires_in": 3600, + } + ) + mock_post.return_value = response + mock_logger = mocker.patch("auth0_server_python.auth_server.server_client.logger") + + await client.get_token_by_refresh_token({"refresh_token": "abc"}) + + mock_logger.warning.assert_called_once() + + # ============================================================================= # Private Key JWT (client assertion) Client Authentication # ============================================================================= @@ -9896,6 +9929,48 @@ async def test_complete_interactive_login_uses_mtls_token_endpoint(mocker): assert called_endpoint == "https://mtls.auth0.local/oauth/token" +@pytest.mark.asyncio +async def test_complete_interactive_login_warns_when_token_not_cert_bound(mocker): + mock_tx_store = AsyncMock() + mock_tx_store.get.return_value = TransactionData( + code_verifier="cv", domain="auth0.local", app_state=None + ) + mock_tx_store.delete = AsyncMock() + mock_state_store = AsyncMock() + mock_state_store.get = AsyncMock(return_value=None) + mock_state_store.set = AsyncMock() + + client = ServerClient( + domain="auth0.local", + client_id="", + use_mtls=True, + ssl_context=ssl.create_default_context(), + secret="", + redirect_uri="https://app/cb", + transaction_store=mock_tx_store, + state_store=mock_state_store, + ) + mtls_metadata = { + "issuer": "https://auth0.local/", + "token_endpoint": "https://auth0.local/oauth/token", + "mtls_endpoint_aliases": {"token_endpoint": "https://mtls.auth0.local/oauth/token"}, + } + mocker.patch.object(client, "_get_oidc_metadata_cached", AsyncMock(return_value=mtls_metadata)) + mocker.patch.object(client._oauth, "metadata", mtls_metadata) + mocker.patch.object( + client._oauth, "fetch_token", + AsyncMock(return_value={ + "access_token": jwt.encode({"sub": "user123"}, "s", algorithm="HS256"), + "expires_in": 3600, + }) + ) + mock_logger = mocker.patch("auth0_server_python.auth_server.server_client.logger") + + await client.complete_interactive_login("https://app/cb?code=abc&state=xyz") + + mock_logger.warning.assert_called_once() + + # ============================================================================ # mTLS CLIENT AUTHENTICATION # ============================================================================ @@ -10047,4 +10122,39 @@ async def test_apply_client_auth_mtls_returns_none_and_strips_creds(): assert "client_assertion_type" not in params +def _make_access_token(cnf=None): + payload = {"sub": "user123"} + if cnf is not None: + payload["cnf"] = cnf + return jwt.encode(payload, "test-secret", algorithm="HS256") + + +def test_warn_if_not_cert_bound_warns_when_cnf_absent(mocker): + client = _mtls_client() + mock_logger = mocker.patch("auth0_server_python.auth_server.server_client.logger") + client._warn_if_not_cert_bound({"access_token": _make_access_token()}) + mock_logger.warning.assert_called_once() + + +def test_warn_if_not_cert_bound_no_warn_when_cert_bound(mocker): + client = _mtls_client() + mock_logger = mocker.patch("auth0_server_python.auth_server.server_client.logger") + client._warn_if_not_cert_bound({"access_token": _make_access_token(cnf={"x5t#S256": "abc123"})}) + mock_logger.warning.assert_not_called() + + +def test_warn_if_not_cert_bound_silent_on_opaque_token(mocker): + client = _mtls_client() + mock_logger = mocker.patch("auth0_server_python.auth_server.server_client.logger") + client._warn_if_not_cert_bound({"access_token": "opaque-token"}) + mock_logger.warning.assert_not_called() + + +def test_warn_if_not_cert_bound_silent_when_no_access_token(mocker): + client = _mtls_client() + mock_logger = mocker.patch("auth0_server_python.auth_server.server_client.logger") + client._warn_if_not_cert_bound({}) + mock_logger.warning.assert_not_called() + + From dee7c51ce81894eb4572d7f8d1105d268e9b39cf Mon Sep 17 00:00:00 2001 From: Chetan Sharma Date: Fri, 4 Sep 2026 17:10:29 +0530 Subject: [PATCH 23/30] docs: update MutualTLS.md with logger warning, MFA proxy note, and passwordless caveat Replace manual openssl thumbprint step with reference to the SDK logger warning. Clarify that MFA challenge/enrollment calls go to the standard host with cert presented in TLS handshake, proxy forwarding outside SDK control. Add passwordless section documenting enforce_client_authentication tenant flag caveat. --- examples/MutualTLS.md | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/examples/MutualTLS.md b/examples/MutualTLS.md index 96e97fc..79ca8d5 100644 --- a/examples/MutualTLS.md +++ b/examples/MutualTLS.md @@ -59,18 +59,13 @@ All three raise `ConfigurationError` immediately (constructor for the first two, ## Token sender-constraining -When the target API has **Token Sender-Constraining (mTLS)** enabled, issued access tokens carry a `cnf.x5t#S256` claim binding the token to the certificate thumbprint. If your tokens do not contain this claim, enable **Token Sender-Constraining (mTLS)** on the API resource server in the Auth0 dashboard. +When the target API has **Token Sender-Constraining (mTLS)** enabled, issued access tokens carry a `cnf.x5t#S256` claim binding the token to the certificate thumbprint. -To verify the thumbprint yourself: - -```bash -openssl x509 -in client.crt -outform DER | openssl dgst -sha256 -binary | openssl enc -base64 | tr '+/' '-_' | tr -d '=' -# Compare the output to the cnf.x5t#S256 claim in the decoded access token. -``` +The SDK logs a warning at the `auth0_server_python.auth_server.server_client` logger whenever an access token returned by the authorization-code or refresh-token flow does not contain `cnf.x5t#S256`. If you see that warning, enable **Token Sender-Constraining (mTLS)** on the API resource server in the Auth0 Dashboard. ## MFA under mTLS -The client certificate is presented on all MFA API calls. The token-endpoint call inside `mfa.verify` is routed through the mTLS alias automatically. Challenge and enrollment calls stay on the standard host, which does not request a client certificate. +The client certificate is presented on all MFA API calls. The token-endpoint call inside `mfa.verify` is routed through the mTLS alias automatically. Challenge and enrollment calls (`/mfa/challenge`, `/mfa/associate`) go to the standard host. The certificate is still included in the TLS handshake, but whether it reaches the Auth0 backend depends on the proxy configuration. ```python await auth0.mfa.verify( @@ -85,3 +80,9 @@ await auth0.mfa.verify( Because `use_mtls=True` forbids `client_secret` at construction time, an mTLS-configured client has no valid credential for `passkey_login_challenge` and `passkey_signup_challenge`. Those calls will be rejected by Auth0 if the application is registered as a confidential client. `signin_with_passkey` (the token-exchange step) is not affected - it calls the token endpoint, which is served on the mTLS alias and routed correctly. + +## Passwordless under mTLS + +By default, Auth0 does not require client authentication on `/passwordless/start`. If the `enforce_client_authentication_on_passwordless_start` tenant flag is enabled on your tenant, the call will fail because `/passwordless/start` does not support certificate-based client authentication. + +The verify step (`passwordless_client.verify`) calls the token endpoint, which is served on the mTLS alias and routed correctly. From 84148c6ff510627ce88e176bc03936bcd151bb85 Mon Sep 17 00:00:00 2001 From: Chetan Sharma Date: Fri, 4 Sep 2026 17:11:05 +0530 Subject: [PATCH 24/30] docs: fix stale token_endpoint_override reference in flow-map Replace token_endpoint_override with token_endpoint_resolver, which is the actual parameter name injected into MfaClient under mTLS. --- references/flow-map.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/references/flow-map.md b/references/flow-map.md index 9567948..c3d6b15 100644 --- a/references/flow-map.md +++ b/references/flow-map.md @@ -17,7 +17,7 @@ Before working on a flow, read its entry points and supporting modules. Every fl | Passkeys | `passkey_signup_challenge`, `passkey_login_challenge`, `signin_with_passkey` | `auth_schemes/dpop_auth.py` — passkey sign-in is the DPoP-bound path | `examples/Passkeys.md` | | My Account | `MyAccountClient` (factors, authentication methods, enroll/verify) | `auth_schemes/dpop_auth.py`; stateless — every call takes a user token | `examples/MyAccountAuthenticationMethods.md` | | MCD | any flow — `domain` may be an async resolver | `_resolve_current_domain`, pitfall 5 in `references/pitfalls.md` | `examples/MultipleCustomDomains.md` | -| mTLS client auth | constructor `use_mtls` + `ssl_context` | `_resolve_token_endpoint`, `_apply_client_authentication`, `_warn_if_not_cert_bound`, `mfa_client.py` (`use_mtls`, `ssl_context`, `verify` `token_endpoint_override`) | `examples/MutualTLS.md` | +| mTLS client auth | constructor `use_mtls` + `ssl_context` | `_resolve_token_endpoint`, `_apply_client_authentication`, `_warn_if_not_cert_bound`, `mfa_client.py` (`use_mtls`, `ssl_context`, `verify`, `token_endpoint_resolver`) | `examples/MutualTLS.md` | Two rules cut across every flow above, so check them on any change here: resolve the domain through `await self._resolve_current_domain(store_options)` rather than reading `self._domain`, and accept From 7e0713aa0674b64ec60d3c35b9b9ea50f86453b2 Mon Sep 17 00:00:00 2001 From: Chetan Sharma Date: Fri, 4 Sep 2026 17:27:33 +0530 Subject: [PATCH 25/30] test: add mTLS routing, credential, and ssl_context assertions for passwordless flows --- .../tests/test_passwordless_client.py | 116 ++++++++++++++++++ 1 file changed, 116 insertions(+) diff --git a/src/auth0_server_python/tests/test_passwordless_client.py b/src/auth0_server_python/tests/test_passwordless_client.py index c875c99..954de27 100644 --- a/src/auth0_server_python/tests/test_passwordless_client.py +++ b/src/auth0_server_python/tests/test_passwordless_client.py @@ -2,6 +2,7 @@ Tests for PasswordlessClient embedded passwordless (OTP + magic link). """ +import ssl from unittest.mock import AsyncMock, MagicMock import jwt @@ -1182,3 +1183,118 @@ async def test_verify_no_client_auth_configured_raises_configuration_error(self, connection="email", email="user@example.com", verification_code="123456" ) ) + + +# ── mTLS ───────────────────────────────────────────────────────────────────── + +MTLS_METADATA = { + "token_endpoint": f"https://{DOMAIN}/oauth/token", + "issuer": ISSUER, + "mtls_endpoint_aliases": {"token_endpoint": f"https://mtls.{DOMAIN}/oauth/token"}, +} + + +def _make_mtls_client(**overrides) -> ServerClient: + kwargs = { + "domain": DOMAIN, + "client_id": CLIENT_ID, + "use_mtls": True, + "ssl_context": ssl.create_default_context(), + "secret": SECRET, + "redirect_uri": REDIRECT_URI, + "transaction_store": AsyncMock(), + "state_store": AsyncMock(), + } + kwargs.update(overrides) + return ServerClient(**kwargs) + + +def _mock_http_via_httpx(mocker, mock_response): + """Patch httpx.AsyncClient directly and return (spy, mock_http).""" + mock_http = AsyncMock() + mock_http.post = AsyncMock(return_value=mock_response) + mock_ctx_mgr = MagicMock() + mock_ctx_mgr.__aenter__ = AsyncMock(return_value=mock_http) + mock_ctx_mgr.__aexit__ = AsyncMock(return_value=False) + spy = mocker.patch("httpx.AsyncClient", return_value=mock_ctx_mgr) + return spy, mock_http + + +class TestMtls: + def _patch_verify_deps(self, client, mocker, claims): + mocker.patch.object(client, "_get_oidc_metadata_cached", return_value=MTLS_METADATA) + mocker.patch.object( + client, "_get_jwks_cached", return_value={"keys": [{"kty": "RSA", "kid": "k1"}]} + ) + mocker.patch.object(client, "_verify_and_decode_jwt", return_value=claims) + + def _token_response(self): + return MagicMock( + status_code=200, + json=MagicMock( + return_value={"access_token": "at", "id_token": "idt", "expires_in": 3600, "scope": "openid"} + ), + ) + + @pytest.mark.asyncio + async def test_verify_routes_token_endpoint_through_mtls_alias(self, mocker): + client = _make_mtls_client() + claims = {"iss": ISSUER, "sub": "auth0|1", "sid": "s1", "iat": 1_000} + self._patch_verify_deps(client, mocker, claims) + http = _mock_http(client, 200, {"access_token": "at", "id_token": "idt", "expires_in": 3600, "scope": "openid"}) + + await client.passwordless.verify( + VerifyPasswordlessOtpOptions( + connection="email", email="user@example.com", verification_code="123456" + ) + ) + + assert http.post.call_args.args[0] == f"https://mtls.{DOMAIN}/oauth/token" + + @pytest.mark.asyncio + async def test_start_omits_client_secret_and_uses_ssl_context_under_mtls(self, mocker): + ctx = ssl.create_default_context() + client = _make_mtls_client(ssl_context=ctx) + spy, mock_http = _mock_http_via_httpx(mocker, MagicMock(status_code=200, json=MagicMock(return_value={"_id": "req_1"}))) + + await client.passwordless.start( + StartPasswordlessEmailOptions(email="user@example.com", send="code") + ) + + _, kwargs = spy.call_args + assert kwargs.get("verify") is ctx + body = mock_http.post.call_args.kwargs["json"] + assert "client_secret" not in body + + @pytest.mark.asyncio + async def test_verify_omits_client_secret_under_mtls(self, mocker): + client = _make_mtls_client() + claims = {"iss": ISSUER, "sub": "auth0|1", "sid": "s1", "iat": 1_000} + self._patch_verify_deps(client, mocker, claims) + http = _mock_http(client, 200, {"access_token": "at", "id_token": "idt", "expires_in": 3600, "scope": "openid"}) + + await client.passwordless.verify( + VerifyPasswordlessOtpOptions( + connection="email", email="user@example.com", verification_code="123456" + ) + ) + + body = http.post.call_args.kwargs["data"] + assert "client_secret" not in body + + @pytest.mark.asyncio + async def test_verify_presents_ssl_context_under_mtls(self, mocker): + ctx = ssl.create_default_context() + client = _make_mtls_client(ssl_context=ctx) + claims = {"iss": ISSUER, "sub": "auth0|1", "sid": "s1", "iat": 1_000} + self._patch_verify_deps(client, mocker, claims) + spy, _ = _mock_http_via_httpx(mocker, self._token_response()) + + await client.passwordless.verify( + VerifyPasswordlessOtpOptions( + connection="email", email="user@example.com", verification_code="123456" + ) + ) + + _, kwargs = spy.call_args + assert kwargs.get("verify") is ctx From 38d5e93d595ae171866fac5d8216664c779b3665 Mon Sep 17 00:00:00 2001 From: Chetan Sharma Date: Fri, 4 Sep 2026 17:36:49 +0530 Subject: [PATCH 26/30] test: add body credential assertions and authlib constructor verification for mTLS paths --- .../tests/test_server_client.py | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/src/auth0_server_python/tests/test_server_client.py b/src/auth0_server_python/tests/test_server_client.py index c6ea1fc..bcce061 100644 --- a/src/auth0_server_python/tests/test_server_client.py +++ b/src/auth0_server_python/tests/test_server_client.py @@ -312,6 +312,7 @@ async def test_par_request_uses_mtls_alias_endpoint(mocker): called_url = mock_post.call_args[0][0] assert called_url == "https://mtls.auth0.local/oauth/par" + assert "client_secret" not in mock_post.call_args.kwargs["data"] @pytest.mark.asyncio @@ -2480,6 +2481,7 @@ async def test_backchannel_authentication_grant_uses_mtls_token_endpoint(mocker) await client.backchannel_authentication_grant("auth_req_123") assert mock_post.call_args[0][0] == "https://mtls.auth0.local/oauth/token" + assert "client_secret" not in mock_post.call_args.kwargs["data"] @pytest.mark.asyncio async def test_get_token_for_connection_success(mocker): @@ -2593,6 +2595,7 @@ async def test_get_token_for_connection_uses_mtls_token_endpoint(mocker): await client.get_token_for_connection({"connection": "github", "refresh_token": "rt"}) assert mock_post.call_args[0][0] == "https://mtls.auth0.local/oauth/token" + assert "client_secret" not in mock_post.call_args.kwargs["data"] @pytest.mark.asyncio async def test_get_token_by_refresh_token_success(mocker): @@ -2733,6 +2736,7 @@ async def test_get_token_by_refresh_token_uses_mtls_token_endpoint(mocker): await client.get_token_by_refresh_token({"refresh_token": "abc"}) assert mock_post.call_args[0][0] == "https://mtls.auth0.local/oauth/token" + assert "client_secret" not in mock_post.call_args.kwargs["data"] @pytest.mark.asyncio @@ -4455,6 +4459,7 @@ async def test_custom_token_exchange_uses_mtls_token_endpoint(mocker): )) assert mock_httpx_client.post.call_args[0][0] == "https://mtls.auth0.local/oauth/token" + assert "client_secret" not in mock_httpx_client.post.call_args.kwargs["data"] # ============================================================================= @@ -10030,6 +10035,22 @@ async def test_mtls_happy_path_constructs(): assert client._ssl_context is not None +@pytest.mark.asyncio +async def test_mtls_oauth_client_constructed_with_no_credential_and_ssl_context(mocker): + ctx = _dummy_ssl_context() + spy = mocker.patch("auth0_server_python.auth_server.server_client.AsyncOAuth2Client") + ServerClient( + domain="auth0.local", + client_id="", + use_mtls=True, + ssl_context=ctx, + secret="", + ) + _, kwargs = spy.call_args + assert kwargs.get("client_secret") is None + assert kwargs.get("verify") is ctx + + @pytest.mark.asyncio async def test_mtls_get_http_client_passes_ssl_context(mocker): ctx = _dummy_ssl_context() From 0bb948113e356645142249dbc479beefaefaae31 Mon Sep 17 00:00:00 2001 From: Chetan Sharma Date: Mon, 7 Sep 2026 12:10:47 +0530 Subject: [PATCH 27/30] style: fix repo convention violations flagged in PR review - Replace em dash with plain hyphen in test_mfa_client.py section header - Split semicolon clause-splices into two sentences in MutualTLS.md (x2), server_client.py comment, and MfaVerifyError message - Restructure _resolve_token_endpoint docstring with Returns:/Raises: sections --- examples/MutualTLS.md | 4 ++-- src/auth0_server_python/auth_server/server_client.py | 12 ++++++++---- src/auth0_server_python/tests/test_mfa_client.py | 2 +- 3 files changed, 11 insertions(+), 7 deletions(-) diff --git a/examples/MutualTLS.md b/examples/MutualTLS.md index 79ca8d5..fadd7a8 100644 --- a/examples/MutualTLS.md +++ b/examples/MutualTLS.md @@ -1,6 +1,6 @@ # Mutual TLS (mTLS) Client Authentication -Authenticate to Auth0 with a TLS client certificate instead of a client secret (RFC 8705). The certificate is presented during the TLS handshake; no credential travels in the request body. +Authenticate to Auth0 with a TLS client certificate instead of a client secret (RFC 8705). The certificate is presented during the TLS handshake. No credential travels in the request body. ## Prerequisites @@ -53,7 +53,7 @@ The SDK passes `ssl_context` as `verify=ssl_context` to every `httpx.AsyncClient |-----------|--------| | `client_secret` | One client-auth method only - Auth0 rejects requests carrying both. | | `client_assertion_signing_key` | Same - one method only. | -| `dpop_key` (per-call on `signin_with_passkey` / `mfa.verify`) | DPoP binds to its own key (`cnf.jkt`) and suppresses `cnf.x5t#S256`; combining them silently defeats mTLS token binding. | +| `dpop_key` (per-call on `signin_with_passkey` / `mfa.verify`) | DPoP binds to its own key (`cnf.jkt`) and suppresses `cnf.x5t#S256`. Combining them silently defeats mTLS token binding. | All three raise `ConfigurationError` immediately (constructor for the first two, at the call site for DPoP). diff --git a/src/auth0_server_python/auth_server/server_client.py b/src/auth0_server_python/auth_server/server_client.py index 547765e..8fd0e3b 100644 --- a/src/auth0_server_python/auth_server/server_client.py +++ b/src/auth0_server_python/auth_server/server_client.py @@ -298,8 +298,12 @@ async def _resolve_mfa_token_endpoint(self, store_options) -> str: def _resolve_token_endpoint(self, metadata: dict) -> Optional[str]: """Return the token endpoint, routed to the mTLS alias when mTLS is enabled. - Under mTLS, raises ConfigurationError immediately if the alias is absent. - Under standard auth, returns None if token_endpoint is missing (caller's guard handles it). + Returns: + The token endpoint URL, or None if not present and mTLS is not enabled. + + Raises: + ConfigurationError: If mTLS is enabled but the discovery document does not + advertise mtls_endpoint_aliases.token_endpoint. """ if self._use_mtls: aliases = metadata.get("mtls_endpoint_aliases") or {} @@ -353,7 +357,7 @@ def _apply_client_authentication( if self._use_mtls: # The client certificate presented in the TLS handshake is the sole - # credential; no body credential or HTTP basic auth is sent. + # credential. No body credential or HTTP basic auth is sent. return None if self._client_assertion_signing_key: @@ -1084,7 +1088,7 @@ async def _establish_session_from_mfa_verify_response( id_token = token_response.get("id_token") if not id_token: raise MfaVerifyError( - "MFA verification response did not include an ID token; cannot create a session" + "MFA verification response did not include an ID token. Cannot create a session." ) origin_domain = await self._resolve_current_domain(store_options) diff --git a/src/auth0_server_python/tests/test_mfa_client.py b/src/auth0_server_python/tests/test_mfa_client.py index 8c301ad..b8bcb1b 100644 --- a/src/auth0_server_python/tests/test_mfa_client.py +++ b/src/auth0_server_python/tests/test_mfa_client.py @@ -1097,7 +1097,7 @@ async def mock_post(self_client, url, **kwargs): # ============================================================================ -# mTLS — MfaClient SSLContext threading + DPoP exclusion + endpoint override +# mTLS - MfaClient SSLContext threading + DPoP exclusion + endpoint override # ============================================================================ From 092472d10c57f065cb1fd5f7b8d75029a39c6b8e Mon Sep 17 00:00:00 2001 From: Chetan Sharma Date: Mon, 7 Sep 2026 12:46:01 +0530 Subject: [PATCH 28/30] refactor: remove redundant algorithms arg from unverified jwt.decode call algorithms is a no-op when verify_signature=False - PyJWT only validates alg against the allowlist when performing signature verification. --- src/auth0_server_python/auth_server/server_client.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/auth0_server_python/auth_server/server_client.py b/src/auth0_server_python/auth_server/server_client.py index 8fd0e3b..77fedaa 100644 --- a/src/auth0_server_python/auth_server/server_client.py +++ b/src/auth0_server_python/auth_server/server_client.py @@ -636,7 +636,6 @@ def _warn_if_not_cert_bound(self, token_response: dict) -> None: claims = jwt.decode( access_token, options={"verify_signature": False}, - algorithms=["RS256", "ES256"], ) except jwt.InvalidTokenError: return # opaque or unparseable token - nothing to assert From f80df61331c8191d62394dcead7fa8f2999c0917 Mon Sep 17 00:00:00 2001 From: Chetan Sharma Date: Mon, 7 Sep 2026 12:51:12 +0530 Subject: [PATCH 29/30] style: fix semicolon clause-splice in passwordless verify error message --- src/auth0_server_python/auth_server/passwordless_client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/auth0_server_python/auth_server/passwordless_client.py b/src/auth0_server_python/auth_server/passwordless_client.py index 4f8fe54..ba900da 100644 --- a/src/auth0_server_python/auth_server/passwordless_client.py +++ b/src/auth0_server_python/auth_server/passwordless_client.py @@ -398,7 +398,7 @@ async def _verify_id_token( if not id_token: raise PasswordlessVerifyError( PasswordlessErrorCode.VERIFY_FAILED, - "Token response did not include an ID token; ensure 'openid' scope is requested", + "Token response did not include an ID token. Ensure 'openid' scope is requested.", ) jwks = await client._get_jwks_cached(origin_domain, metadata) From ae23056103d89e8e1dcfefb165a30f4a68cf592d Mon Sep 17 00:00:00 2001 From: Chetan Sharma Date: Mon, 7 Sep 2026 15:05:15 +0530 Subject: [PATCH 30/30] docs: fix passkeys section and remove what-comment in server_client --- examples/MutualTLS.md | 6 ++---- src/auth0_server_python/auth_server/server_client.py | 2 -- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/examples/MutualTLS.md b/examples/MutualTLS.md index fadd7a8..dba59b2 100644 --- a/examples/MutualTLS.md +++ b/examples/MutualTLS.md @@ -75,11 +75,9 @@ await auth0.mfa.verify( ## Passkeys under mTLS -`/passkey/challenge` and `/passkey/register` are not served on the mTLS endpoint aliases. Auth0 only accepts `client_secret` as the credential on those endpoints - the client certificate is not a valid credential there. +The client certificate is presented on all passkey calls. The token-exchange step (`signin_with_passkey`) calls the token endpoint, which is served on the mTLS alias and routed correctly. -Because `use_mtls=True` forbids `client_secret` at construction time, an mTLS-configured client has no valid credential for `passkey_login_challenge` and `passkey_signup_challenge`. Those calls will be rejected by Auth0 if the application is registered as a confidential client. - -`signin_with_passkey` (the token-exchange step) is not affected - it calls the token endpoint, which is served on the mTLS alias and routed correctly. +Challenge and enrollment calls (`/passkey/challenge`, `/passkey/register`) go to the standard host - they are not listed in `mtls_endpoint_aliases`. The certificate is still included in the TLS handshake, but whether it reaches the Auth0 backend depends on the proxy configuration - the same behaviour as `/mfa/challenge`. ## Passwordless under mTLS diff --git a/src/auth0_server_python/auth_server/server_client.py b/src/auth0_server_python/auth_server/server_client.py index 77fedaa..8ab5dcb 100644 --- a/src/auth0_server_python/auth_server/server_client.py +++ b/src/auth0_server_python/auth_server/server_client.py @@ -887,8 +887,6 @@ async def complete_interactive_login( # ID token `iat`, used to detect a ceiling that is already past at login. issued_at = None id_token = token_response.get("id_token") - # Verified ID token claims, retained so the session `sid` can be sourced - # from them (back-channel logout matches on `sid`). id_token_claims = None expected_org = transaction_data.organization