|
7 | 7 | import json |
8 | 8 | import ssl |
9 | 9 | import time |
| 10 | +import warnings |
10 | 11 | from collections import OrderedDict |
11 | 12 | from typing import TYPE_CHECKING, Any, Callable, Generic, Optional, TypeVar, Union |
12 | 13 |
|
@@ -441,6 +442,31 @@ async def _verify_and_decode_jwt( |
441 | 442 |
|
442 | 443 | return jwt.decode(token, signing_key.key, **kwargs) |
443 | 444 |
|
| 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 | + |
444 | 470 | async def _fetch_oidc_metadata(self, domain: str) -> dict: |
445 | 471 | """Fetch OIDC metadata from domain.""" |
446 | 472 | normalized_domain = self._normalize_url(domain) |
@@ -813,6 +839,8 @@ async def complete_interactive_login( |
813 | 839 | raise ApiError( |
814 | 840 | "token_error", f"Token exchange failed: {str(e)}", e) |
815 | 841 |
|
| 842 | + self._warn_if_not_cert_bound(token_response.get("access_token")) |
| 843 | + |
816 | 844 | # Use the userinfo field from the token_response for user claims |
817 | 845 | user_info = token_response.get("userinfo") |
818 | 846 | user_claims = None |
@@ -1491,6 +1519,8 @@ async def get_token_by_refresh_token(self, options: dict[str, Any]) -> dict[str, |
1491 | 1519 |
|
1492 | 1520 | token_response = response.json() |
1493 | 1521 |
|
| 1522 | + self._warn_if_not_cert_bound(token_response.get("access_token")) |
| 1523 | + |
1494 | 1524 | # Add required fields if they are missing |
1495 | 1525 | if "expires_in" in token_response and "expires_at" not in token_response: |
1496 | 1526 | token_response["expires_at"] = int( |
|
0 commit comments