Skip to content

Commit 4e05b3b

Browse files
authored
feat(auth): transparent JWT exchange (#87)
* feat(auth): exchange API token for JWT transparently * ci(regenerate): guard JWT-exchange code against regen drift * docs: add transparent JWT exchange design and monopoly brief * fix(auth): exchange only hd_ tokens; reuse TLS/SNI * fix(auth): exchange any non-JWT credential, not just hd_ * fix(config): read bearer token once, not twice * fix(auth): re-mint on any refresh failure * docs: remove JWT exchange design notes from SDK repo * fix(auth): forward socket_options, strict opt-out * fix(config): read token manager once in getter
1 parent 8fdc2c4 commit 4e05b3b

7 files changed

Lines changed: 1015 additions & 8 deletions

File tree

.github/workflows/regenerate.yml

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,86 @@ jobs:
113113
- name: Patch ApiClient close lifecycle
114114
run: python3 scripts/patch_api_client_close.py
115115

116+
- name: Verify JWT-exchange code survived regeneration
117+
run: |
118+
python3 - <<'PY'
119+
import ast, pathlib, sys
120+
121+
errors = []
122+
123+
# 1. The hand-written, regen-immune auth module must survive.
124+
if not pathlib.Path("hotdata/_auth.py").is_file():
125+
errors.append("hotdata/_auth.py is missing (regen overwrote/dropped it)")
126+
127+
config = pathlib.Path("hotdata/configuration.py")
128+
if not config.is_file():
129+
errors.append("hotdata/configuration.py is missing")
130+
else:
131+
tree = ast.parse(config.read_text())
132+
cls = next(
133+
(n for n in tree.body
134+
if isinstance(n, ast.ClassDef) and n.name == "Configuration"),
135+
None,
136+
)
137+
if cls is None:
138+
errors.append("Configuration class not found in configuration.py")
139+
else:
140+
# 2. api_key must be a property (decorated getter), so every
141+
# request transparently exchanges for a fresh JWT.
142+
api_key_is_property = any(
143+
isinstance(n, ast.FunctionDef)
144+
and n.name == "api_key"
145+
and any(
146+
isinstance(d, ast.Name) and d.id == "property"
147+
for d in n.decorator_list
148+
)
149+
for n in cls.body
150+
)
151+
if not api_key_is_property:
152+
errors.append("Configuration.api_key is not a @property (template drift)")
153+
154+
# 3. The token manager must be created eagerly in __init__
155+
# (lazy creation has a concurrent-first-request race).
156+
init = next(
157+
(n for n in cls.body
158+
if isinstance(n, ast.FunctionDef) and n.name == "__init__"),
159+
None,
160+
)
161+
init_src = ast.get_source_segment(config.read_text(), init) if init else ""
162+
if "self._token_manager = _TokenManager(" not in (init_src or ""):
163+
errors.append("eager self._token_manager assignment missing from __init__")
164+
165+
# 4. __deepcopy__ must skip _token_manager (lock + PoolManager
166+
# are not deepcopy-able) and rebuild it.
167+
deepcopy = next(
168+
(n for n in cls.body
169+
if isinstance(n, ast.FunctionDef) and n.name == "__deepcopy__"),
170+
None,
171+
)
172+
if deepcopy is None:
173+
errors.append("__deepcopy__ missing from Configuration")
174+
else:
175+
# Look for _token_manager as a real identifier/string in the
176+
# body (AST, so comments mentioning it don't count) — proves
177+
# the lock/PoolManager skip-and-rebuild actually survived.
178+
refs = any(
179+
(isinstance(n, ast.Constant) and n.value == "_token_manager")
180+
or (isinstance(n, ast.Attribute) and n.attr == "_token_manager")
181+
for n in ast.walk(deepcopy)
182+
)
183+
if not refs:
184+
errors.append("__deepcopy__ does not skip/rebuild _token_manager")
185+
186+
if errors:
187+
print("::error::JWT-exchange regen-safety check failed:")
188+
for e in errors:
189+
print(f" - {e}")
190+
sys.exit(1)
191+
print("JWT-exchange code survived regeneration: "
192+
"_auth.py present, api_key property, eager _token_manager, "
193+
"__deepcopy__ handling all intact.")
194+
PY
195+
116196
- name: Clean up generated artifacts
117197
run: |
118198
rm -f openapi.yaml

.openapi-generator-ignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@
22
git_push.sh
33
README.md
44
setup.py
5+
hotdata/_auth.py

.openapi-generator-templates/configuration.mustache

Lines changed: 40 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -321,7 +321,12 @@ conf = {{packageName}}.Configuration(
321321
self.temp_folder_path = None
322322
"""Temp file folder for downloading files
323323
"""
324-
self.api_key = api_key
324+
# Transparent API-token -> JWT exchange. `api_key` is a property whose
325+
# getter returns a live JWT minted from this credential (see _auth.py);
326+
# the manager is created eagerly here (never lazily in the getter) so
327+
# concurrent first requests don't each build one. The setter rebuilds it.
328+
from {{packageName}}._auth import _TokenManager
329+
self._token_manager = _TokenManager(api_key, self) if api_key is not None else None
325330
"""Hotdata API key, sent as `Authorization: Bearer <key>`."""
326331
# apiKey-security values (X-Workspace-Id, X-Session-Id), keyed by
327332
# scheme name. Read by the generated `auth_settings()` below.
@@ -451,13 +456,20 @@ conf = {{packageName}}.Configuration(
451456
result = cls.__new__(cls)
452457
memo[id(self)] = result
453458
for k, v in self.__dict__.items():
454-
if k not in ('logger', 'logger_file_handler'):
459+
# _token_manager holds a threading.Lock and a urllib3 PoolManager,
460+
# neither of which is deepcopy-able; rebuild it below from the
461+
# (deepcopy-safe) credential string instead.
462+
if k not in ('logger', 'logger_file_handler', '_token_manager'):
455463
setattr(result, k, copy.deepcopy(v, memo))
456464
# shallow copy of loggers
457465
result.logger = copy.copy(self.logger)
458466
# use setters to configure loggers
459467
result.logger_file = self.logger_file
460468
result.debug = self.debug
469+
# rebuild the token manager bound to the copy (never deepcopy lock/pool)
470+
from {{packageName}}._auth import _TokenManager
471+
tm = self._token_manager
472+
result._token_manager = _TokenManager(tm._credential, result) if tm else None
461473
return result
462474

463475
def __setattr__(self, name: str, value: Any) -> None:
@@ -608,6 +620,26 @@ conf = {{packageName}}.Configuration(
608620

609621
return None
610622

623+
@property
624+
def api_key(self) -> Optional[str]:
625+
"""Live bearer credential, sent as `Authorization: Bearer <value>`.
626+
627+
Backed by the regeneration-immune `_TokenManager` (see `{{packageName}}._auth`):
628+
an opaque API token is transparently exchanged for a short-lived JWT and
629+
kept fresh, while a credential already shaped like a JWT (or exchange
630+
opted out) is returned unchanged. `auth_settings()` reads this on every
631+
request, so the wire always carries a current token.
632+
"""
633+
# Read the manager once: a concurrent `api_key` reset could otherwise
634+
# set it to None between the check and the `.bearer_value()` call.
635+
tm = self._token_manager
636+
return None if tm is None else tm.bearer_value()
637+
638+
@api_key.setter
639+
def api_key(self, value: Optional[str]) -> None:
640+
from {{packageName}}._auth import _TokenManager
641+
self._token_manager = _TokenManager(value, self) if value is not None else None
642+
611643
@property
612644
def workspace_id(self) -> Optional[str]:
613645
"""Public id of the target workspace (sent as `X-Workspace-Id`)."""
@@ -689,15 +721,19 @@ conf = {{packageName}}.Configuration(
689721
}
690722
{{/isBasicBasic}}
691723
{{#isBasicBearer}}
692-
if self.api_key is not None:
724+
# Resolve the bearer token once: `api_key` is a property that may mint a
725+
# JWT and take the token-manager lock, so a second read would lock twice
726+
# and could race a concurrent `api_key` reset (yielding `Bearer None`).
727+
{{name}}_token = self.api_key
728+
if {{name}}_token is not None:
693729
auth['{{name}}'] = {
694730
'type': 'bearer',
695731
'in': 'header',
696732
{{#bearerFormat}}
697733
'format': '{{.}}',
698734
{{/bearerFormat}}
699735
'key': 'Authorization',
700-
'value': 'Bearer ' + self.api_key
736+
'value': 'Bearer ' + {{name}}_token
701737
}
702738
{{/isBasicBearer}}
703739
{{#isHttpSignature}}

0 commit comments

Comments
 (0)