Skip to content

Commit 44dfefb

Browse files
committed
fix(auth): exchange any non-JWT credential, not just hd_
1 parent 83fae96 commit 44dfefb

3 files changed

Lines changed: 56 additions & 38 deletions

File tree

hotdata/_auth.py

Lines changed: 18 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,11 @@
1313
1414
Key behaviors:
1515
16-
* **Pass-through** -- only ``hd_`` API tokens are exchanged. A credential that
17-
already looks like a JWT (``eyJ`` prefix), or any other non-``hd_`` value
18-
(local/dev/test credentials), is returned unchanged and never exchanged, so
19-
local setups and the rollout window keep working.
16+
* **Pass-through** -- a credential that already looks like a JWT (``eyJ``
17+
prefix, matching the Gateway's own ``^Bearer eyJ.*`` detection) is returned
18+
unchanged and never exchanged. Every other (opaque) credential is treated as
19+
an API token and exchanged; set ``HOTDATA_DISABLE_JWT_EXCHANGE`` to force a
20+
raw, non-JWT credential through as-is (local/dev setups, rollback).
2021
* **Opt-out** -- if ``HOTDATA_DISABLE_JWT_EXCHANGE`` is set to any truthy value,
2122
the credential is always returned as-is (hard escape hatch for rollout).
2223
* **In-memory cache only** -- no disk writes. The server already de-duplicates
@@ -44,7 +45,6 @@
4445
_LEEWAY = 30 # refresh when <30s of life remains
4546
_TIMEOUT = 30.0 # seconds -- never let a stalled token endpoint hang every request
4647
_CLIENT_ID = "hotdata-python-sdk"
47-
_API_TOKEN_PREFIX = "hd_" # only credentials with this prefix are exchanged
4848

4949
# Env var that disables exchange entirely (any truthy value). Used as a hard
5050
# escape hatch during the rollout window and for local/dev setups.
@@ -56,7 +56,7 @@
5656

5757

5858
class TokenExchangeError(Exception):
59-
"""Raised when an ``hd_`` API token cannot be exchanged for a JWT.
59+
"""Raised when an API token cannot be exchanged for a JWT.
6060
6161
Surfacing ``invalid_grant`` (expired/revoked API token) here keeps the
6262
failure clear instead of a confusing downstream 401.
@@ -123,9 +123,10 @@ def _pool_from_config(configuration):
123123
class _TokenManager:
124124
"""Exchanges an API token for short-lived JWTs and keeps them fresh.
125125
126-
Only ``hd_`` API tokens are exchanged; anything else (raw ``eyJ`` JWTs,
127-
local/dev/test credentials) is passed through unchanged, as is any
128-
credential when ``HOTDATA_DISABLE_JWT_EXCHANGE`` is set.
126+
A credential that already looks like a JWT (``eyJ`` prefix) is passed
127+
through unchanged, as is any credential when
128+
``HOTDATA_DISABLE_JWT_EXCHANGE`` is set; every other (opaque) API token is
129+
exchanged.
129130
"""
130131

131132
def __init__(self, credential, configuration, pool=None):
@@ -143,12 +144,14 @@ def _needs_exchange(self):
143144
# send the credential as-is, never touching the token endpoint.
144145
if os.environ.get(_DISABLE_ENV):
145146
return False
146-
# Exchange only real ``hd_`` API tokens. Everything else is passed
147-
# through untouched: raw JWTs (``eyJ`` prefix) are already what we want
148-
# on the wire, and non-``hd_`` values (local/dev/test credentials) must
149-
# not be sent to the token endpoint -- doing so would break local setups
150-
# and the rollout window (see the design's pass-through edge case).
151-
return isinstance(self._credential, str) and self._credential.startswith(_API_TOKEN_PREFIX)
147+
# A compact JWT always starts with "eyJ" (base64 of '{"'), matching the
148+
# Gateway's own ``^Bearer eyJ.*`` detection -- those already are what we
149+
# want on the wire, so pass them through. Everything else is an opaque
150+
# API token to be exchanged. (Hotdata API tokens are bare hex; the
151+
# ``hd_`` prefix seen in docs/comments is cosmetic and not enforced by
152+
# the server, so we must not gate on it.) Use HOTDATA_DISABLE_JWT_EXCHANGE
153+
# to force a raw, non-JWT credential through unchanged (local/dev).
154+
return isinstance(self._credential, str) and not self._credential.startswith("eyJ")
152155

153156
def bearer_value(self):
154157
"""Return a live JWT (exchanging + caching), or the credential as-is.

tests/test_arrow.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,11 @@ def _install_fake_response(
8282
) -> None:
8383
"""Replace RESTClientObject.request with a stub that records the call."""
8484

85+
# The api_key used here ("test-key") is a dummy, not a real token. Disable
86+
# transparent JWT exchange so auth_settings() does not try to mint one
87+
# against a non-existent endpoint when the (stubbed) request is built.
88+
monkeypatch.setenv("HOTDATA_DISABLE_JWT_EXCHANGE", "1")
89+
8590
from hotdata import rest
8691

8792
def fake_request(

tests/test_auth.py

Lines changed: 33 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,8 @@
77
88
They verify the pinned public contract:
99
10-
* first mint -- an ``hd_`` credential POSTs an ``api_token`` grant to
11-
``/v1/auth/jwt`` (form-encoded, correct Content-Type and
10+
* first mint -- an opaque (non-JWT) credential POSTs an ``api_token`` grant
11+
to ``/v1/auth/jwt`` (form-encoded, correct Content-Type and
1212
``client_id``) and returns the minted ``access_token``;
1313
* cache hit -- a second ``bearer_value()`` within TTL does not re-hit the
1414
pool;
@@ -400,35 +400,45 @@ def test_deepcopied_manager_credential_still_mints() -> None:
400400

401401

402402
# --------------------------------------------------------------------------
403-
# Non-hd_ credentials pass through (only real API tokens are exchanged)
403+
# Opaque (non-JWT) credentials are exchanged -- the prefix is not gated
404404
# --------------------------------------------------------------------------
405405

406406

407-
def test_non_hd_credential_is_passed_through_unchanged() -> None:
408-
"""Only ``hd_`` API tokens are exchanged. A non-``hd_`` value (e.g. a
409-
local/dev/test credential) must be sent as-is and never hit the token
410-
endpoint -- otherwise local setups and the rollout window break."""
411-
pool = _FakePool([_mint_response()])
412-
mgr = _TokenManager("test-key", _config(), pool=pool)
407+
def test_bare_hex_token_is_exchanged() -> None:
408+
"""Hotdata API tokens are bare hex with no ``hd_`` prefix (the prefix in
409+
the docs is cosmetic and not enforced by the server). Any opaque, non-JWT
410+
credential must therefore be exchanged, not passed through."""
411+
raw = "8a4bfd9cfa6926344f770d6b9a093c2b559dafc4de2a69137acb93e7e9821c7b"
412+
pool = _FakePool([_mint_response(access_token="eyJ.minted.jwt")])
413+
mgr = _TokenManager(raw, _config(), pool=pool)
413414

414-
assert mgr.bearer_value() == "test-key"
415-
assert pool.calls == []
415+
assert mgr.bearer_value() == "eyJ.minted.jwt"
416+
assert len(pool.calls) == 1
417+
assert _form(pool.calls[0]["body"])["api_token"] == [raw]
416418

417419

418-
def test_configuration_with_non_hd_key_never_mints() -> None:
419-
"""End-to-end regression for the predicate: building a Configuration with a
420-
non-``hd_`` key (as the arrow tests do) must not trigger a network mint when
421-
``auth_settings()`` reads ``api_key``."""
422-
cfg = Configuration(host="https://api.hotdata.test", api_key="test-key")
423-
# Wire a recording pool in; if exchange were (wrongly) attempted it would
424-
# show up here instead of trying a real socket.
425-
pool = _FakePool([_mint_response()])
420+
def test_configuration_exchanges_bare_token_then_opt_out_passes_through(
421+
monkeypatch: pytest.MonkeyPatch,
422+
) -> None:
423+
"""End-to-end at the Configuration level: a bare token is exchanged so
424+
``auth_settings()`` carries the minted JWT; with the opt-out env var set the
425+
raw token is sent unchanged (the arrow-test style dummy-key setup)."""
426+
raw = "8a4bfd9c0bare0token"
427+
428+
# Exchange path: auth_settings() carries the minted JWT, not the raw token.
429+
pool = _FakePool([_mint_response(access_token="eyJ.live.jwt")])
430+
cfg = Configuration(host="https://api.hotdata.test", api_key=raw)
426431
cfg._token_manager._pool = pool
432+
assert _bearer_from(cfg.auth_settings()) == "Bearer eyJ.live.jwt"
433+
assert len(pool.calls) == 1
427434

428-
assert cfg.api_key == "test-key"
429-
bearer = _bearer_from(cfg.auth_settings())
430-
assert bearer == "Bearer test-key"
431-
assert pool.calls == []
435+
# Opt-out path: same raw token, exchange disabled -> sent as-is, no mint.
436+
monkeypatch.setenv("HOTDATA_DISABLE_JWT_EXCHANGE", "1")
437+
pool2 = _FakePool([_mint_response()])
438+
cfg2 = Configuration(host="https://api.hotdata.test", api_key=raw)
439+
cfg2._token_manager._pool = pool2
440+
assert _bearer_from(cfg2.auth_settings()) == f"Bearer {raw}"
441+
assert pool2.calls == []
432442

433443

434444
# --------------------------------------------------------------------------

0 commit comments

Comments
 (0)