From e0c874a502d9c363650d59d5421948c6a272328e Mon Sep 17 00:00:00 2001 From: Chetan Sharma Date: Fri, 21 Aug 2026 12:15:34 +0530 Subject: [PATCH 1/9] 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 d9785a32b133c2e87850754c463b6c7385145bb2 Mon Sep 17 00:00:00 2001 From: Chetan Sharma Date: Fri, 21 Aug 2026 12:17:02 +0530 Subject: [PATCH 2/9] 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 7f7f2b8996d9af76e7de0d2b4a4e57aa6c8ae833 Mon Sep 17 00:00:00 2001 From: Chetan Sharma Date: Fri, 21 Aug 2026 12:18:35 +0530 Subject: [PATCH 3/9] 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 f61fad1465fa4588a0a0613ddc3291d77cde77ae Mon Sep 17 00:00:00 2001 From: Chetan Sharma Date: Fri, 21 Aug 2026 12:19:16 +0530 Subject: [PATCH 4/9] 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 01dbec35ef0f44c7693e81bfd362571716022f26 Mon Sep 17 00:00:00 2001 From: Chetan Sharma Date: Fri, 21 Aug 2026 12:57:47 +0530 Subject: [PATCH 5/9] 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 4148bffcd14c3caa1854f22aa7c4da1c83d257ee Mon Sep 17 00:00:00 2001 From: Chetan Sharma Date: Fri, 21 Aug 2026 12:59:14 +0530 Subject: [PATCH 6/9] 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 bfda663e4f89ba0ccbc1201e18197206d849053b Mon Sep 17 00:00:00 2001 From: Chetan Sharma Date: Fri, 21 Aug 2026 14:30:09 +0530 Subject: [PATCH 7/9] 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 1a4bb00a3cdeab58b4ebf1b690c01e71076be08d Mon Sep 17 00:00:00 2001 From: Chetan Sharma Date: Fri, 21 Aug 2026 14:43:51 +0530 Subject: [PATCH 8/9] 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 0915865bf773716f210644d561e3181bcafeac12 Mon Sep 17 00:00:00 2001 From: Chetan Sharma Date: Fri, 21 Aug 2026 14:51:06 +0530 Subject: [PATCH 9/9] 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