Skip to content

Commit 1a4bb00

Browse files
committed
feat: thread mTLS ssl_context and alias routing through MFA verify
1 parent bfda663 commit 1a4bb00

3 files changed

Lines changed: 73 additions & 1 deletion

File tree

src/auth0_server_python/auth_server/mfa_client.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
"""
55

66
import json
7+
import ssl
78
import time
89
from collections.abc import Awaitable, Callable
910
from typing import TYPE_CHECKING, Any, Optional, Union
@@ -74,6 +75,8 @@ def __init__(
7475
] = None,
7576
mfa_token_ttl: int = DEFAULT_MFA_TOKEN_TTL,
7677
apply_client_authentication: Optional[Callable] = None,
78+
use_mtls: bool = False,
79+
ssl_context: Optional[ssl.SSLContext] = None,
7780
):
7881
if callable(domain):
7982
self._domain = None
@@ -92,10 +95,14 @@ def __init__(
9295
raise ConfigurationError("mfa_token_ttl must be a positive number of seconds")
9396
self._mfa_token_ttl = mfa_token_ttl
9497
self._apply_client_authentication = apply_client_authentication
98+
self._use_mtls = use_mtls
99+
self._ssl_context = ssl_context
95100

96101
def _get_http_client(self, **kwargs) -> httpx.AsyncClient:
97102
"""Return an httpx.AsyncClient with default headers injected."""
98103
headers = {**kwargs.pop("headers", {}), **self._headers}
104+
if self._use_mtls and "verify" not in kwargs:
105+
kwargs["verify"] = self._ssl_context
99106
return httpx.AsyncClient(headers=headers, **kwargs)
100107

101108
def _apply_mfa_client_authentication(self, body: dict, base_url: str) -> None:
@@ -472,6 +479,7 @@ async def verify(
472479
options: dict[str, Any],
473480
store_options: Optional[dict[str, Any]] = None,
474481
dpop_key: Optional["jwk.JWK"] = None,
482+
token_endpoint_override: Optional[str] = None,
475483
) -> MfaVerifyResponse:
476484
"""
477485
Verifies an MFA code and completes authentication.
@@ -504,6 +512,12 @@ async def verify(
504512
MfaRequiredError: When chained MFA is required.
505513
ConfigurationError: If neither client_secret nor client_assertion_signing_key is configured.
506514
"""
515+
if self._use_mtls and dpop_key is not None:
516+
raise ConfigurationError(
517+
"dpop_key cannot be combined with use_mtls. DPoP and mTLS bind tokens "
518+
"differently; DPoP would take precedence and the token would not be "
519+
"certificate-bound."
520+
)
507521
mfa_token = options.get("mfa_token")
508522
if not mfa_token:
509523
raise MfaTokenInvalidError()
@@ -534,7 +548,7 @@ async def verify(
534548
)
535549

536550
try:
537-
token_endpoint = f"{base_url}/oauth/token"
551+
token_endpoint = token_endpoint_override or f"{base_url}/oauth/token"
538552

539553
async with self._get_http_client() as client:
540554
headers = {"Content-Type": "application/x-www-form-urlencoded"}

src/auth0_server_python/auth_server/server_client.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,8 @@ def __init__(
265265
session_establisher=self._establish_session_from_mfa_verify_response,
266266
mfa_token_ttl=mfa_token_ttl,
267267
apply_client_authentication=self._apply_client_authentication,
268+
use_mtls=self._use_mtls,
269+
ssl_context=self._ssl_context,
268270
)
269271

270272
self._passwordless_client = PasswordlessClient(self)

src/auth0_server_python/tests/test_mfa_client.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
"""
44

55
import json
6+
import ssl
67
from unittest.mock import AsyncMock, MagicMock
78

89
import pytest
@@ -1093,3 +1094,58 @@ async def mock_post(self_client, url, **kwargs):
10931094
result = await client.verify({"mfa_token": _enc(), "otp": "123456"})
10941095
assert result.token_type == "Bearer"
10951096
assert "DPoP" not in captured_request["kwargs"]["headers"]
1097+
1098+
1099+
# ============================================================================
1100+
# mTLS — MfaClient SSLContext threading + DPoP exclusion + endpoint override
1101+
# ============================================================================
1102+
1103+
1104+
def _mtls_mfa_client() -> MfaClient:
1105+
return MfaClient(
1106+
domain=DOMAIN,
1107+
client_id=CLIENT_ID,
1108+
client_secret=None,
1109+
secret=SECRET,
1110+
use_mtls=True,
1111+
ssl_context=ssl.create_default_context(),
1112+
)
1113+
1114+
1115+
@pytest.mark.asyncio
1116+
async def test_mfa_get_http_client_passes_ssl_context(mocker):
1117+
mfa = _mtls_mfa_client()
1118+
spy = mocker.patch("auth0_server_python.auth_server.mfa_client.httpx.AsyncClient")
1119+
mfa._get_http_client()
1120+
_, kwargs = spy.call_args
1121+
assert kwargs.get("verify") is mfa._ssl_context
1122+
1123+
1124+
@pytest.mark.asyncio
1125+
async def test_mfa_verify_rejects_dpop_under_mtls():
1126+
mfa = _mtls_mfa_client()
1127+
with pytest.raises(ConfigurationError):
1128+
await mfa.verify({"mfa_token": _enc(), "otp": "123456"}, dpop_key=object())
1129+
1130+
1131+
@pytest.mark.asyncio
1132+
async def test_mfa_verify_uses_token_endpoint_override(mocker):
1133+
mfa = _mtls_mfa_client()
1134+
response = AsyncMock()
1135+
response.status_code = 200
1136+
response.json = MagicMock(return_value={
1137+
"access_token": "at", "token_type": "Bearer", "expires_in": 3600
1138+
})
1139+
captured = {}
1140+
1141+
async def mock_post(self_client, url, **kwargs):
1142+
captured["url"] = url
1143+
return response
1144+
1145+
mocker.patch("httpx.AsyncClient.post", new=mock_post)
1146+
1147+
await mfa.verify(
1148+
{"mfa_token": _enc(), "otp": "123456"},
1149+
token_endpoint_override="https://mtls.auth0.local/oauth/token",
1150+
)
1151+
assert captured["url"] == "https://mtls.auth0.local/oauth/token"

0 commit comments

Comments
 (0)