Skip to content

Commit 6144bba

Browse files
ValidMind Support Agenthunner
authored andcommitted
fix: harden request-path OIDC refresh per PR review (ZD-682)
Address PR #548 review (jamadriz): 1. Sync AI-generation endpoints (generate_qualitative_text, generate_test_result_description) called requests.post with _get_api_headers() but skipped the refresh hook, so a session hitting only those after token expiry kept the old failure. Add _ensure_fresh_oidc_token() + the clear 401 error to both. Every auth-bearing request path (_get/_post/_ping + both AI-gen) now refreshes. 2. Align refresh-failure with init(): on a failed refresh (e.g. revoked token / invalid_grant) drop the cached entry so it isn't re-attempted on every request. Recovery is re-running vm.init() (per the clear 401 error); the request path can't do interactive device flow, so it stops. 3. Add wiring/integration tests asserting _get/_post/_ping invoke the refresh hook and that the 401 retry fires end-to-end, plus a drop-cache-on-refresh-failure test. 4. Extract shared _refresh_oidc_from_cache() used by both _obtain_oidc_tokens (init) and _ensure_fresh_oidc_token (request path) so the refresh + cache-persist + drop-on-failure logic can't drift. Also clear the in-memory token (_access_token/_oidc_expires_at) on hard refresh failure so the next request fails fast at header build with the re-auth message instead of sending a doomed request (review nit). 37 tests pass; flake8/black/isort clean.
1 parent ead7607 commit 6144bba

2 files changed

Lines changed: 137 additions & 27 deletions

File tree

tests/test_api_client.py

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,6 @@
2424
from validmind.utils import md_to_html
2525
from validmind.vm_models.figure import Figure
2626

27-
2827
loop = asyncio.new_event_loop()
2928

3029

@@ -392,6 +391,50 @@ def test_log_text_rejects_invalid_context(self):
392391
context={"content_ids": ["valid", ""]},
393392
)
394393

394+
# -- request-path refresh wiring (ZD-682) -----------------------------
395+
# These assert the request wrappers actually invoke the refresh hook and
396+
# that the 401 retry fires end-to-end, complementing the unit tests that
397+
# exercise _ensure_fresh_oidc_token in isolation.
398+
399+
@patch("validmind.api_client._ensure_fresh_oidc_token")
400+
@patch("aiohttp.ClientSession.get")
401+
def test_get_refreshes_before_request_and_retries_on_401(
402+
self, mock_get, mock_ensure
403+
):
404+
mock_ensure.return_value = True # forced refresh reports success -> retry
405+
mock_get.side_effect = [
406+
MockAsyncResponse(401, text="unauthorized"),
407+
MockAsyncResponse(200, json={"ok": True}),
408+
]
409+
result = self.run_async(api_client._get, "endpoint")
410+
self.assertEqual(result, {"ok": True})
411+
self.assertEqual(mock_get.call_count, 2)
412+
self.assertEqual(mock_ensure.call_count, 2)
413+
mock_ensure.assert_any_call(force=True)
414+
415+
@patch("validmind.api_client._ensure_fresh_oidc_token")
416+
@patch("aiohttp.ClientSession.post")
417+
def test_post_refreshes_before_request(self, mock_post, mock_ensure):
418+
mock_post.return_value = MockAsyncResponse(200, json={"ok": True})
419+
result = self.run_async(api_client._post, "endpoint", data={"a": "b"})
420+
self.assertEqual(result, {"ok": True})
421+
mock_ensure.assert_called_once_with()
422+
423+
@patch("validmind.api_client._ensure_fresh_oidc_token")
424+
@patch("requests.get")
425+
def test_ping_refreshes_before_request_and_retries_on_401(
426+
self, mock_get, mock_ensure
427+
):
428+
mock_ensure.return_value = True
429+
mock_get.side_effect = [
430+
MockResponse(401, text="unauthorized"),
431+
MockResponse(200, json={"model": {"name": "n", "cuid": "c"}}),
432+
]
433+
api_client._ping()
434+
self.assertEqual(mock_get.call_count, 2)
435+
self.assertEqual(mock_ensure.call_count, 2)
436+
mock_ensure.assert_any_call(force=True)
437+
395438

396439
class TestAPIClientOIDC(unittest.TestCase):
397440
"""OIDC device-flow authentication via vm.init()."""
@@ -820,6 +863,33 @@ def test_locked_caller_adopts_concurrently_refreshed_token(self):
820863
# Adopted the token another caller already refreshed, no second fetch.
821864
self.assertEqual(api_client._access_token, "new-tok")
822865

866+
def test_ensure_fresh_oidc_token_drops_cache_on_refresh_failure(self):
867+
# On a failed refresh (e.g. revoked refresh token / invalid_grant), the
868+
# cached entry is deleted so it isn't re-attempted on every request —
869+
# matching init()'s _obtain_oidc_tokens.
870+
self._init_oidc(expires_at="2000-01-01T00:00:00+00:00")
871+
expired = {
872+
"issuer": "https://issuer.example.com/",
873+
"client_id": "cid",
874+
"access_token": "old-tok",
875+
"expires_at": "2000-01-01T00:00:00+00:00",
876+
"refresh_token": "refresh-token",
877+
}
878+
with (
879+
patch("validmind.credentials_store.get_cached_entry", return_value=expired),
880+
patch("validmind.credentials_store.delete_cached_entry") as mock_delete,
881+
patch(
882+
"validmind.oidc_device.try_refresh_cached_tokens",
883+
side_effect=ValidMindAuthError("invalid_grant"),
884+
),
885+
):
886+
self.assertFalse(api_client._ensure_fresh_oidc_token())
887+
mock_delete.assert_called_once()
888+
# In-memory token is cleared too, so the next request fails fast at header
889+
# build with the re-auth message rather than sending a doomed request.
890+
self.assertIsNone(api_client._access_token)
891+
self.assertIsNone(api_client._oidc_expires_at)
892+
823893

824894
if __name__ == "__main__":
825895
unittest.main()

validmind/api_client.py

Lines changed: 66 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,36 @@ def _ping() -> Dict[str, Any]:
248248
)
249249

250250

251+
def _refresh_oidc_from_cache(
252+
issuer: str,
253+
client_id: str,
254+
refresh_token: str,
255+
scope: Optional[str],
256+
audience: Optional[str] = None,
257+
) -> Optional[Dict[str, Any]]:
258+
"""Exchange a cached refresh token for new tokens, persisting the result.
259+
260+
Shared by the ``init()`` path (``_obtain_oidc_tokens``) and the request path
261+
(``_ensure_fresh_oidc_token``) so their refresh behaviour can't drift. On
262+
success the refreshed entry is cached and returned. On a failed refresh (e.g.
263+
revoked token / ``invalid_grant``) the stale entry is deleted and None is
264+
returned, so a dead refresh token isn't re-attempted; the caller decides how
265+
to recover (``init()`` falls back to device flow, the request path stops).
266+
"""
267+
from .credentials_store import delete_cached_entry, upsert_cached_entry
268+
from .oidc_device import try_refresh_cached_tokens
269+
270+
try:
271+
new_tokens = try_refresh_cached_tokens(
272+
issuer, client_id, refresh_token, scope, audience=audience
273+
)
274+
except ValidMindAuthError:
275+
delete_cached_entry(issuer, client_id, audience=audience)
276+
return None
277+
upsert_cached_entry(issuer, client_id, new_tokens, audience=audience)
278+
return new_tokens
279+
280+
251281
def _obtain_oidc_tokens(
252282
issuer: str,
253283
client_id: str,
@@ -256,35 +286,25 @@ def _obtain_oidc_tokens(
256286
) -> Dict[str, Any]:
257287
"""Return a credentials entry dict with access_token, expires_at, refresh_token, etc."""
258288
from .credentials_store import (
259-
delete_cached_entry,
260289
get_cached_entry,
261290
is_expired,
262291
normalize_client_id,
263292
normalize_issuer,
264293
upsert_cached_entry,
265294
)
266-
from .oidc_device import run_device_flow, try_refresh_cached_tokens
295+
from .oidc_device import run_device_flow
267296

268297
norm_issuer = normalize_issuer(issuer)
269298
norm_client_id = normalize_client_id(client_id)
270299
cached = get_cached_entry(norm_issuer, norm_client_id, audience=audience)
271300
if cached and not is_expired(cached):
272301
return cached
273302
if cached and cached.get("refresh_token"):
274-
try:
275-
new_tokens = try_refresh_cached_tokens(
276-
norm_issuer,
277-
norm_client_id,
278-
cached["refresh_token"],
279-
scope,
280-
audience=audience,
281-
)
282-
upsert_cached_entry(
283-
norm_issuer, norm_client_id, new_tokens, audience=audience
284-
)
303+
new_tokens = _refresh_oidc_from_cache(
304+
norm_issuer, norm_client_id, cached["refresh_token"], scope, audience
305+
)
306+
if new_tokens is not None:
285307
return new_tokens
286-
except ValidMindAuthError:
287-
delete_cached_entry(norm_issuer, norm_client_id, audience=audience)
288308
tokens = run_device_flow(norm_issuer, norm_client_id, scope, audience=audience)
289309
upsert_cached_entry(norm_issuer, norm_client_id, tokens, audience=audience)
290310
return tokens
@@ -312,6 +332,19 @@ def _set_oidc_access_token(entry: Dict[str, Any]) -> None:
312332
_invalidate_async_session()
313333

314334

335+
def _clear_oidc_access_token() -> None:
336+
"""Drop the in-memory OIDC token and pooled session after an unrecoverable refresh.
337+
338+
Pairs with deleting the cached entry: with no usable token in memory, the next
339+
request fails fast at header build with the clear "run vm.init()" error instead
340+
of sending a doomed request just to get a 401 back.
341+
"""
342+
global _access_token, _oidc_expires_at
343+
_access_token = None
344+
_oidc_expires_at = None
345+
_invalidate_async_session()
346+
347+
315348
def _oidc_token_is_stale() -> bool:
316349
"""Cheap in-memory expiry check (no file I/O) using the 120s skew in is_expired."""
317350
from .credentials_store import is_expired
@@ -327,16 +360,16 @@ def _ensure_fresh_oidc_token(force: bool = False) -> bool:
327360
starts failing on every call until ``init()`` is re-run. This uses the cached
328361
refresh token (requires ``offline_access``, the default scope) to obtain a new
329362
access token in place. Returns True when a usable token is in place, False when
330-
no refresh was possible (non-OIDC mode, or no cached refresh token).
363+
no refresh was possible (non-OIDC mode, no cached refresh token, or a failed
364+
refresh — in which case the cached entry is dropped, matching init()).
331365
"""
332366
if _auth_mode != "oidc" or _oidc_login_context is None:
333367
return False
334368
# Hot path: skip file I/O and the lock while the current token is still valid.
335369
if not force and not _oidc_token_is_stale():
336370
return True
337371

338-
from .credentials_store import get_cached_entry, is_expired, upsert_cached_entry
339-
from .oidc_device import try_refresh_cached_tokens
372+
from .credentials_store import get_cached_entry, is_expired
340373

341374
ctx = _oidc_login_context
342375
issuer = ctx["issuer"]
@@ -355,13 +388,18 @@ def _ensure_fresh_oidc_token(force: bool = False) -> bool:
355388
refresh_token = entry.get("refresh_token")
356389
if not refresh_token:
357390
return False
358-
try:
359-
new_tokens = try_refresh_cached_tokens(
360-
issuer, client_id, refresh_token, scope, audience=audience
361-
)
362-
except ValidMindAuthError:
391+
# Shared with init(): on failure the cached entry is dropped so a
392+
# bad/revoked refresh token isn't re-attempted on every request. Recovery
393+
# is re-running vm.init() (surfaced by the clear 401 auth error); unlike
394+
# init() the request path can't fall back to interactive device flow.
395+
new_tokens = _refresh_oidc_from_cache(
396+
issuer, client_id, refresh_token, scope, audience
397+
)
398+
if new_tokens is None:
399+
# Hard failure: the cache entry was dropped; clear the in-memory token
400+
# too so the next request fails fast with the clear re-auth message.
401+
_clear_oidc_access_token()
363402
return False
364-
upsert_cached_entry(issuer, client_id, new_tokens, audience=audience)
365403
_set_oidc_access_token(new_tokens)
366404
return True
367405

@@ -757,14 +795,15 @@ def _validate_log_text_context(
757795

758796
def generate_qualitative_text(text_generation_data: Dict[str, Any]) -> Dict[str, Any]:
759797
"""Generate qualitative text using the ValidMind AI API."""
798+
_ensure_fresh_oidc_token()
760799
r = requests.post(
761800
url=_get_url("ai/generate/qualitative_text_generation"),
762801
headers=_get_api_headers(),
763802
json=text_generation_data,
764803
)
765804

766805
if r.status_code != 200:
767-
raise_api_error(r.text)
806+
_raise_for_api_error(r.status_code, r.text)
768807

769808
return r.json()
770809

@@ -1002,13 +1041,14 @@ def log_metric(
10021041

10031042

10041043
def generate_test_result_description(test_result_data: Dict[str, Any]) -> str:
1044+
_ensure_fresh_oidc_token()
10051045
r = requests.post(
10061046
url=_get_url("ai/generate/test_result_description"),
10071047
headers=_get_api_headers(),
10081048
json=test_result_data,
10091049
)
10101050

10111051
if r.status_code != 200:
1012-
raise_api_error(r.text)
1052+
_raise_for_api_error(r.status_code, r.text)
10131053

10141054
return r.json()

0 commit comments

Comments
 (0)