Skip to content

fix: refresh OIDC access token on request path#548

Merged
hunner merged 3 commits into
mainfrom
agents/zd-682
Jul 24, 2026
Merged

fix: refresh OIDC access token on request path#548
hunner merged 3 commits into
mainfrom
agents/zd-682

Conversation

@hunner

@hunner hunner commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

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_expired with a 120s skew), but it was only reachable from init().

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 generic APIRequestError carrying 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/_ping proactively 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/_ping additionally force one refresh-and-retry if a 401 still comes back (e.g. a token revoked ahead of its stated expiry). A genuinely rejected OIDC token now raises a clear ValidMindAuthError telling the user to re-run vm.init().

This depends on a refresh token being available, which is why it pairs with #538 (adds offline_access to the default scope, shipped in v2.13.8). #538 alone let a manual vm.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

  1. Authenticate against an OIDC provider that issues short-lived access tokens plus a refresh token (default scope now includes offline_access):
    import validmind as vm
    vm.init(issuer="https://<your-okta>/oauth2/default", client_id="<cid>", model="<cuid>")
  2. Wait past the access-token lifetime (or configure a short one), then make any API call (e.g. vm.log_metadata(...) or anything that hits _get/_post). Previously this failed; it should now transparently refresh and succeed, with ~/.validmind/credentials.json showing an updated expires_at.
  3. Revoke the token server-side mid-session and make a call: _get/_ping should force-refresh and retry once; if refresh is impossible, you should get a clear ValidMindAuthError mentioning vm.init() rather than a generic error.
  4. .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/_ping refresh-and-retry wiring.

What needs special review?

The main review surface is shared mutable auth state under concurrency. _access_token is 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:

  • Refresh serialization. Refresh runs under _oidc_refresh_lock (a threading.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 plain threading.Lock rather than an asyncio.Lock because _ensure_fresh_oidc_token performs no await while held (the refresh HTTP call is synchronous requests), so it is never held across an await point; it exists to serialize refreshes across threads (e.g. multiple run_async callers). The trade-off is that the synchronous refresh briefly blocks the event loop — acceptable given it happens roughly once per token lifetime.
  • Asymmetric 401 handling. _get and _ping (bodyless GETs) force-refresh and retry once on a 401. _post deliberately 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 upload BytesIO) and should be a follow-up.
  • Hot-path cost. The common (token-valid) path is a cheap in-memory expiry check against _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_access default 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

bug

The 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

  • What and why
  • Screenshots or videos (Frontend)
  • How to test
  • What needs special review
  • Dependencies, breaking changes, and deployment notes
  • Labels applied
  • PR linked to Shortcut
  • Unit tests added (Backend)
  • Tested locally
  • Documentation updated (if required)
  • Environment variable additions/changes documented (if required)

Closes ZD #682 / sc-16892

🤖 Generated with Claude Code

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.
@github-actions

Copy link
Copy Markdown
Contributor

Pull requests must include at least one of the required labels: internal (no release notes required), highlight, enhancement, bug, deprecation, documentation. Except for internal, pull requests must also include a description in the release notes section.

@hunner hunner added bug Something isn't working support Support-related PR labels Jul 24, 2026
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.
@hunner
hunner requested a review from jamadriz July 24, 2026 17:12

@jamadriz jamadriz left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

looks great, minor gaps that would suggest covering in this PR (last one ok to defer to followup PR):

  1. 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.

  1. 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 False

That’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.

  1. 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.

  1. 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.

hunner pushed a commit that referenced this pull request Jul 24, 2026
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.
hunner pushed a commit that referenced this pull request Jul 24, 2026
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.
@hunner

hunner commented Jul 24, 2026

Copy link
Copy Markdown
Contributor Author

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 init() can, so that just bails.

@hunner
hunner requested a review from jamadriz July 24, 2026 17:48

@jamadriz jamadriz left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

looks good 🚀

@jamadriz

Copy link
Copy Markdown
Contributor

Remaining nits (non-blocking)

  • PR description is stale — still says "32 tests"; the branch now has 37. Worth a one-line update before merge.
  • AI-gen endpoints lack 401 retry — same intentional constraint as _post (no safe body replay). Proactive refresh covers normal expiry; residual 401 gets the clear error. Fine as-is.
  • In-memory token not cleared on refresh failure — after delete_cached_entry, _access_token / _oidc_expires_at still hold stale values until vm.init(). Harmless since the next request 401s with a clear message, but clearing in-memory state on hard failure would be slightly cleaner (optional follow-up).

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.
@github-actions

Copy link
Copy Markdown
Contributor

PR Summary

This 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

  • Test token refresh behavior when receiving a 401 status in GET, POST, and ping endpoints.
  • Simulate concurrent requests that trigger the locked section to ensure only one refresh occurs and subsequent callers adopt the newly refreshed token.
  • Validate that a missing or invalid refresh token results in a clear error and that cached tokens are dropped properly.
  • Ensure that in non-OIDC (API key) mode, _ensure_fresh_oidc_token does not trigger any refresh attempts.
  • Test the behavior of _raise_for_api_error for both OIDC and API key modes to confirm error messages are informative.

@hunner hunner mentioned this pull request Jul 24, 2026
11 tasks
@hunner
hunner merged commit 24a2b72 into main Jul 24, 2026
21 checks passed
@hunner
hunner deleted the agents/zd-682 branch July 24, 2026 18:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working support Support-related PR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants