fix: refresh OIDC access token on request path#548
Conversation
The OIDC access token was captured once at vm.init() and reused for the life of the process, with no expiry check or refresh on the request path. Once the provider's token lifetime elapsed (e.g. Okta's 1h), every API call failed until the user manually re-ran vm.init(), and the failure surfaced as a generic APIRequestError with no indication the token expired. Add request-path refresh: _get/_post/_ping proactively refresh via the cached refresh token (offline_access, the default scope since #538) when the token is within the 120s expiry skew; _get/_ping additionally force a refresh and retry once on a 401. A rejected OIDC token now raises a clear ValidMindAuthError telling the user to re-run vm.init(). Refresh is serialized under a lock to avoid a refresh stampede across threads.
|
Pull requests must include at least one of the required labels: |
Add a deterministic test for the in-lock double-check that collapses a refresh stampede: a caller that passed the staleness check but finds the token already refreshed under the lock must adopt it rather than issue a second refresh. Constructs that exact state (stale in-memory expiry + fresh cached entry) directly, so it verifies the guard in one run without relying on thread scheduling. Mutual exclusion itself is threading.Lock's guarantee and is not re-tested here.
There was a problem hiding this comment.
looks great, minor gaps that would suggest covering in this PR (last one ok to defer to followup PR):
- Sync requests.post paths are still uncovered
The PR hooks _get, _post, and _ping, but these sync callers still go straight to requests with no refresh:
api_client.py
Lines 657-667
def generate_qualitative_text(text_generation_data: Dict[str, Any]) -> Dict[str, Any]:
"""Generate qualitative text using the ValidMind AI API."""
r = requests.post(
url=_get_url("ai/generate/qualitative_text_generation"),
headers=_get_api_headers(),
json=text_generation_data,
)Same pattern for generate_test_result_description. A notebook that only hits AI generation endpoints after token expiry could still hit the old failure mode. Suggest either adding _ensure_fresh_oidc_token() there or extracting a small shared “make authenticated request” wrapper.
- Refresh-failure behavior differs from
init()path
In _obtain_oidc_tokens, refresh failure deletes the cache entry and falls back to device flow. In _ensure_fresh_oidc_token, refresh failure returns False and leaves cache intact:
except ValidMindAuthError:
return FalseThat’s defensible for transient errors, but on invalid_grant (revoked refresh token) every request will retry refresh until a 401 surfaces. Aligning with init’s delete_cached_entry on hard failure would be cleaner — or at least document the intentional difference.
- Tests cover the helper, not the wiring
The six new tests exercise _ensure_fresh_oidc_token well, but none assert that _get / _post / _ping actually call it or that 401 retry works end-to-end. Low risk given the thin wiring, but one integration-style test per path would strengthen confidence.
- Optional DRY (not required)
The refresh block in _ensure_fresh_oidc_token mirrors the refresh branch in _obtain_oidc_tokens. A shared _refresh_oidc_from_cache(...) helper could reduce drift later, but that’s a follow-up — not redundancy today.
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. 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. DRY of the shared refresh block (review point 4) deferred to follow-up as suggested. 37 tests pass; flake8/black/isort clean.
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. 37 tests pass; flake8/black/isort clean.
|
All good points. Updated. One thing is that the request-path refresh can't fall back to the device flow if the refresh fails like |
Remaining nits (non-blocking)
|
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.
PR SummaryThis PR introduces significant enhancements to the API client's handling of OIDC authentication, specifically focusing on refreshing access tokens when they are expired or otherwise invalid. The changes include: • Adding explicit calls to a new helper (_ensure_fresh_oidc_token) in the GET, POST, and ping request paths to proactively refresh tokens if needed or retry requests when a 401 is received. • Implementing a locking mechanism (using threading.Lock) to ensure that concurrent requests do not trigger multiple token refreshes in parallel. This is complemented by in-memory token expiry tracking to quickly decide whether a refresh is needed. • Introducing new utility functions (_set_oidc_access_token, _clear_oidc_access_token, _oidc_token_is_stale, and _refresh_oidc_from_cache) that centralize token adoption and caching behavior, ensuring consistency between interactive flows (via init()) and the request path. • Enhancing error handling via a customized _raise_for_api_error function that provides clear instructions (e.g., suggesting re-running vm.init()) in case of an OIDC 401 error. • Extending the test suite with several test cases that simulate token refresh scenarios, including forced refreshes on 401, proper cache adoption in concurrent refresh scenarios, and failure handling when the refresh token is missing or invalid. This helps ensure the end-to-end token refresh workflow functions as expected. Overall, the PR strengthens the robustness and reliability of the authentication mechanism against expired or revoked tokens while maintaining backward compatibility with API key mode. Test Suggestions
|
Pull Request Description
What and why?
When authenticating with OIDC (device flow), the ValidMind library captured the access token exactly once, inside
vm.init(), and reused it verbatim for the entire life of the process._get_api_headers()read the cached token unconditionally — there was no expiry check and no refresh anywhere on the request path. All of the refresh machinery already existed (try_refresh_cached_tokens,is_expiredwith a 120s skew), but it was only reachable frominit().Before: a long-running session (notebook, batch job) that outlived the provider's access-token lifetime — Okta's default is 1 hour — started failing on every subsequent API call once the token expired. The only recovery was manually re-running
vm.init(). Worse, the failure surfaced as a genericAPIRequestErrorcarrying the backend's description, with no hint that the token had expired, so users couldn't self-diagnose (the reporter found the cause by reading our source).After: the token is refreshed on the request path.
_get/_post/_pingproactively refresh via the cached refresh token when the current token is within the expiry skew, so a valid bearer is attached before the request goes out._get/_pingadditionally force one refresh-and-retry if a401still comes back (e.g. a token revoked ahead of its stated expiry). A genuinely rejected OIDC token now raises a clearValidMindAuthErrortelling the user to re-runvm.init().This depends on a refresh token being available, which is why it pairs with #538 (adds
offline_accessto the default scope, shipped in v2.13.8). #538 alone let a manualvm.init()re-run refresh silently; this change makes refresh happen transparently mid-session, which is the behavior the ticket actually asked for.How to test
offline_access):vm.log_metadata(...)or anything that hits_get/_post). Previously this failed; it should now transparently refresh and succeed, with~/.validmind/credentials.jsonshowing an updatedexpires_at._get/_pingshould force-refresh and retry once; if refresh is impossible, you should get a clearValidMindAuthErrormentioningvm.init()rather than a generic error..venv/bin/python -m unittest tests.test_api_client— 37 tests pass, including new ones covering proactive refresh, forced refresh, the no-refresh-token path, api-key no-op, the clear 401 error, the stampede-guard double-check, drop-cache-on-refresh-failure, and end-to-end_get/_post/_pingrefresh-and-retry wiring.What needs special review?
The main review surface is shared mutable auth state under concurrency.
_access_tokenis a module global and the aiohttp session bakes the bearer header at construction, so refresh has to mutate the global and invalidate the pooled session (_set_oidc_access_token). Points worth a close read:_oidc_refresh_lock(athreading.Lock) with a double-check inside the lock, so concurrent callers that all see an expired token don't stampede the token endpoint — the first refreshes, the rest observe the freshly-cached token. The lock is a plainthreading.Lockrather than anasyncio.Lockbecause_ensure_fresh_oidc_tokenperforms noawaitwhile held (the refresh HTTP call is synchronousrequests), so it is never held across an await point; it exists to serialize refreshes across threads (e.g. multiplerun_asynccallers). The trade-off is that the synchronous refresh briefly blocks the event loop — acceptable given it happens roughly once per token lifetime._getand_ping(bodyless GETs) force-refresh and retry once on a 401._postdeliberately does not retry, because the request body / upload stream may already be consumed — replaying it isn't safe. For_post, proactive refresh covers the expiry case, and a residual 401 raises the clear auth error. If reviewers want transparent retry on writes too, that needs body/stream re-materialization (e.g.seek(0)on uploadBytesIO) and should be a follow-up._oidc_expires_at— no file I/O, no lock. File reads and the lock only happen when the token is actually stale.Dependencies, breaking changes, and deployment notes
Complements #538 (
offline_accessdefault scope, v2.13.8) — refresh requires a cached refresh token, which that scope enables. No breaking changes; API-key auth is entirely unaffected (all new logic is gated on_auth_mode == "oidc"). No deployment considerations.Release notes
bugThe ValidMind library now automatically refreshes an expired OIDC access token during a session instead of failing every API call until
vm.init()is re-run, and reports a clear, actionable error when a token is rejected.Checklist
Closes ZD #682 / sc-16892
🤖 Generated with Claude Code