Skip to content

Commit e73d4f6

Browse files
committed
fix(auth): forward socket_options, strict opt-out
1 parent 81df380 commit e73d4f6

2 files changed

Lines changed: 53 additions & 7 deletions

File tree

hotdata/_auth.py

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,9 @@
1818
unchanged and never exchanged. Every other (opaque) credential is treated as
1919
an API token and exchanged; set ``HOTDATA_DISABLE_JWT_EXCHANGE`` to force a
2020
raw, non-JWT credential through as-is (local/dev setups, rollback).
21-
* **Opt-out** -- if ``HOTDATA_DISABLE_JWT_EXCHANGE`` is set to any truthy value,
22-
the credential is always returned as-is (hard escape hatch for rollout).
21+
* **Opt-out** -- if ``HOTDATA_DISABLE_JWT_EXCHANGE`` is set to an affirmative
22+
value (``1``/``true``/``yes``/``on``), the credential is always returned
23+
as-is (hard escape hatch for rollout); ``0``/``false``/empty do not opt out.
2324
* **In-memory cache only** -- no disk writes. The server already de-duplicates
2425
mints (keyed by ``sha256(api_token)``), so per-process caching is sufficient.
2526
* **Thread-safe** -- a :class:`threading.Lock` with single-flight mint covers
@@ -46,9 +47,11 @@
4647
_TIMEOUT = 30.0 # seconds -- never let a stalled token endpoint hang every request
4748
_CLIENT_ID = "hotdata-python-sdk"
4849

49-
# Env var that disables exchange entirely (any truthy value). Used as a hard
50-
# escape hatch during the rollout window and for local/dev setups.
50+
# Env var that disables exchange entirely. Used as a hard escape hatch during
51+
# the rollout window and for local/dev setups. Only affirmative values opt out
52+
# (see _DISABLE_VALUES) so that ``=0`` / ``=false`` do NOT silently disable it.
5153
_DISABLE_ENV = "HOTDATA_DISABLE_JWT_EXCHANGE"
54+
_DISABLE_VALUES = {"1", "true", "yes", "on"}
5255

5356
# The SOCKS schemes urllib3 routes through SOCKSProxyManager rather than the
5457
# plain ProxyManager. Mirrors hotdata/rest.py's SUPPORTED_SOCKS_PROXIES.
@@ -104,6 +107,8 @@ def _pool_from_config(configuration):
104107
pool_args["assert_hostname"] = configuration.assert_hostname
105108
if configuration.tls_server_name:
106109
pool_args["server_hostname"] = configuration.tls_server_name
110+
if configuration.socket_options is not None:
111+
pool_args["socket_options"] = configuration.socket_options
107112
# `retries`/`maxsize` are intentionally not mirrored: the exchange is a
108113
# single bounded-timeout request that fails fast rather than retrying.
109114

@@ -140,9 +145,10 @@ def __init__(self, credential, configuration, pool=None):
140145

141146
@property
142147
def _needs_exchange(self):
143-
# Opt-out wins outright: any truthy HOTDATA_DISABLE_JWT_EXCHANGE means
144-
# send the credential as-is, never touching the token endpoint.
145-
if os.environ.get(_DISABLE_ENV):
148+
# Opt-out wins outright: an affirmative HOTDATA_DISABLE_JWT_EXCHANGE
149+
# (1/true/yes/on) means send the credential as-is, never touching the
150+
# token endpoint. Other values (incl. 0/false/empty) do not opt out.
151+
if os.environ.get(_DISABLE_ENV, "").strip().lower() in _DISABLE_VALUES:
146152
return False
147153
# A compact JWT always starts with "eyJ" (base64 of '{"'), matching the
148154
# Gateway's own ``^Bearer eyJ.*`` detection -- those already are what we

tests/test_auth.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -311,6 +311,32 @@ def test_opt_out_env_var_returns_credential_unchanged(
311311
assert pool.calls == []
312312

313313

314+
@pytest.mark.parametrize("value", ["1", "true", "TRUE", "yes", "on", " on "])
315+
def test_opt_out_affirmative_values_disable(
316+
monkeypatch: pytest.MonkeyPatch, value: str
317+
) -> None:
318+
monkeypatch.setenv("HOTDATA_DISABLE_JWT_EXCHANGE", value)
319+
pool = _FakePool([_mint_response()])
320+
mgr = _TokenManager("opaque_token", _config(), pool=pool)
321+
322+
assert mgr.bearer_value() == "opaque_token"
323+
assert pool.calls == []
324+
325+
326+
@pytest.mark.parametrize("value", ["0", "false", "no", "off", ""])
327+
def test_opt_out_non_affirmative_values_still_exchange(
328+
monkeypatch: pytest.MonkeyPatch, value: str
329+
) -> None:
330+
"""``=0`` / ``=false`` etc. must NOT silently disable exchange -- a footgun
331+
if users set them expecting to *enable* it. Exchange still happens."""
332+
monkeypatch.setenv("HOTDATA_DISABLE_JWT_EXCHANGE", value)
333+
pool = _FakePool([_mint_response(access_token="eyJ.minted.jwt")])
334+
mgr = _TokenManager("opaque_token", _config(), pool=pool)
335+
336+
assert mgr.bearer_value() == "eyJ.minted.jwt"
337+
assert len(pool.calls) == 1
338+
339+
314340
# --------------------------------------------------------------------------
315341
# Concurrency: single-flight mint
316342
# --------------------------------------------------------------------------
@@ -490,6 +516,20 @@ def test_pool_from_config_omits_hostname_args_when_unset() -> None:
490516
assert "server_hostname" not in kw
491517

492518

519+
def test_pool_from_config_forwards_socket_options() -> None:
520+
"""socket_options (e.g. TCP keepalive) the user set for all SDK requests
521+
must also apply to the exchange pool, matching rest.py."""
522+
import socket
523+
524+
cfg = _config()
525+
cfg.socket_options = [(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)]
526+
pool = _pool_from_config(cfg)
527+
528+
assert pool.connection_pool_kw.get("socket_options") == [
529+
(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
530+
]
531+
532+
493533
# --------------------------------------------------------------------------
494534
# Malformed (non-JSON) success body
495535
# --------------------------------------------------------------------------

0 commit comments

Comments
 (0)