Skip to content

Commit bfda663

Browse files
committed
feat: warn when mTLS token lacks cnf.x5t#S256 binding
1 parent 4148bff commit bfda663

2 files changed

Lines changed: 71 additions & 0 deletions

File tree

src/auth0_server_python/auth_server/server_client.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import json
88
import ssl
99
import time
10+
import warnings
1011
from collections import OrderedDict
1112
from typing import TYPE_CHECKING, Any, Callable, Generic, Optional, TypeVar, Union
1213

@@ -441,6 +442,31 @@ async def _verify_and_decode_jwt(
441442

442443
return jwt.decode(token, signing_key.key, **kwargs)
443444

445+
def _warn_if_not_cert_bound(self, access_token: Optional[str]) -> None:
446+
"""Advisory warning when mTLS is on but the access token is not certificate-bound.
447+
448+
Silent on opaque (non-JWT) tokens and when mTLS is off; never raises.
449+
"""
450+
if not self._use_mtls or not access_token:
451+
return
452+
try:
453+
claims = jwt.decode(
454+
access_token,
455+
options={"verify_signature": False},
456+
algorithms=["HS256", "RS256", "ES256", "PS256"],
457+
)
458+
except Exception:
459+
return # opaque or unparseable token — nothing to assert
460+
cnf = claims.get("cnf") if isinstance(claims, dict) else None
461+
if not (isinstance(cnf, dict) and cnf.get("x5t#S256")):
462+
warnings.warn(
463+
"mTLS is enabled but the access token is not certificate-bound "
464+
"(no cnf.x5t#S256). Sender-constraining is not active — configure "
465+
"Token Sender-Constraining (mTLS) on the API resource server.",
466+
UserWarning,
467+
stacklevel=2,
468+
)
469+
444470
async def _fetch_oidc_metadata(self, domain: str) -> dict:
445471
"""Fetch OIDC metadata from domain."""
446472
normalized_domain = self._normalize_url(domain)
@@ -813,6 +839,8 @@ async def complete_interactive_login(
813839
raise ApiError(
814840
"token_error", f"Token exchange failed: {str(e)}", e)
815841

842+
self._warn_if_not_cert_bound(token_response.get("access_token"))
843+
816844
# Use the userinfo field from the token_response for user claims
817845
user_info = token_response.get("userinfo")
818846
user_claims = None
@@ -1491,6 +1519,8 @@ async def get_token_by_refresh_token(self, options: dict[str, Any]) -> dict[str,
14911519

14921520
token_response = response.json()
14931521

1522+
self._warn_if_not_cert_bound(token_response.get("access_token"))
1523+
14941524
# Add required fields if they are missing
14951525
if "expires_in" in token_response and "expires_at" not in token_response:
14961526
token_response["expires_at"] = int(

src/auth0_server_python/tests/test_server_client.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9805,6 +9805,47 @@ async def test_apply_client_auth_mtls_returns_none_and_strips_creds():
98059805
assert "client_assertion_type" not in params
98069806

98079807

9808+
_TEST_JWT_KEY = "test-signing-key-for-mtls-tests-32b" # ≥32 bytes avoids InsecureKeyLengthWarning
9809+
9810+
9811+
@pytest.mark.asyncio
9812+
async def test_warn_when_jwt_missing_cnf_under_mtls(recwarn):
9813+
client = _mtls_client()
9814+
token = jwt.encode({"sub": "u", "aud": "api"}, _TEST_JWT_KEY, algorithm="HS256")
9815+
client._warn_if_not_cert_bound(token)
9816+
assert any(
9817+
"cnf" in str(w.message).lower() or "certificate-bound" in str(w.message).lower()
9818+
for w in recwarn.list
9819+
)
9820+
9821+
9822+
@pytest.mark.asyncio
9823+
async def test_no_warn_when_jwt_has_cnf(recwarn):
9824+
client = _mtls_client()
9825+
token = jwt.encode({"sub": "u", "cnf": {"x5t#S256": "abc"}}, _TEST_JWT_KEY, algorithm="HS256")
9826+
client._warn_if_not_cert_bound(token)
9827+
assert len(recwarn.list) == 0
9828+
9829+
9830+
@pytest.mark.asyncio
9831+
async def test_no_warn_on_opaque_token(recwarn):
9832+
client = _mtls_client()
9833+
client._warn_if_not_cert_bound("opaque-not-a-jwt")
9834+
assert len(recwarn.list) == 0
9835+
9836+
9837+
@pytest.mark.asyncio
9838+
async def test_warn_never_raises_and_silent_when_not_mtls(recwarn):
9839+
non_mtls = ServerClient(
9840+
domain="auth0.local",
9841+
client_id="<client_id>",
9842+
client_secret="<client_secret>",
9843+
secret="<secret>",
9844+
)
9845+
non_mtls._warn_if_not_cert_bound(None)
9846+
assert len(recwarn.list) == 0
9847+
9848+
98089849
@pytest.mark.asyncio
98099850
async def test_signin_with_passkey_rejects_dpop_under_mtls(mocker):
98109851
client = _mtls_client()

0 commit comments

Comments
 (0)