Skip to content

fix: Reuse IdP-issued client tokens until near expiry - #6687

Open
larrysingleton007 wants to merge 1 commit into
feast-dev:masterfrom
larrysingleton007:fix/oidc-client-token-reuse
Open

fix: Reuse IdP-issued client tokens until near expiry#6687
larrysingleton007 wants to merge 1 commit into
feast-dev:masterfrom
larrysingleton007:fix/oidc-client-token-reuse

Conversation

@larrysingleton007

Copy link
Copy Markdown
Contributor

What this PR does / why we need it

This is the client-side sibling of #6683. On the client_secret (client credentials / ROPC) branch, every outbound RPC builds a fresh auth manager and throws away the token it fetched:

  • get_auth_token constructs a new factory, manager, and OIDCDiscoveryService per call (client_auth_token.py).
  • All three interceptors call it per request: the gRPC interceptor on every intercepted call, the Arrow Flight middleware in sending_headers, and the HTTP wrapper on every session reuse.

So each request pays a discovery GET plus a token POST against the IdP for a token that is typically valid for an hour. Measured with counting mocks on master: 5 outbound calls produce 5 discovery GETs and 5 token POSTs. Beyond the added latency, a batch loop multiplies IdP load by request count, which can trip provider rate limits.

The fix caches issued tokens keyed by the token-request identity (discovery URL, client id and secret, username, password) and reuses each until shortly before expiry, taken from the token's own exp claim with the token response's expires_in as a fallback. After the change the same measurement performs 1 discovery GET and 1 token POST.

The cache is module level because the interceptors construct a fresh manager per RPC, so instance state cannot survive between calls. Only the client_secret branch is affected; static tokens, token_env_var, and mounted service account tokens were already cheap and are untouched.

A few details worth reviewer attention:

  • token_refresh_margin_seconds (default 30) is exposed on OidcClientAuthConfig rather than hardcoded, following the same request you made on fix: Reuse the OIDC JWKS client across requests #6683. It rejects non-positive values, since a zero or negative margin would allow reuse right up to or past expiry. It sits on the client config rather than the shared OidcAuthConfig because only clients fetch tokens from the IdP.
  • A token whose expiry cannot be determined (opaque, and no expires_in) is deliberately not cached, preserving today's per-call behavior rather than guessing a lifetime.
  • Errors from the IdP propagate before any cache write, so a failed fetch never poisons the cache.
  • Concurrent misses for the same identity may fetch in parallel and the last write wins. That costs at most a redundant fetch and never returns a wrong-identity token, which is still strictly better than the guaranteed fetch per request it replaces.

One interaction worth flagging: HttpSessionManager.get_session re-calls get_auth_token on every session cache hit, with a comment from #5895 explaining it does so "in case it expired". After this change that call returns a cached token rather than a freshly fetched one. The intent still holds, because the cache never returns a token with less than the margin remaining, but the mechanism changes and it seemed better to say so than to let a reviewer find it.

Testing: 7 new unit tests covering reuse until expiry, refusal to cache inside the refresh margin, the expires_in fallback for opaque tokens, no caching when expiry is unknowable, cache keying across distinct configs, the configurable margin changing reuse behavior, and rejection of non-positive margins. An autouse fixture resets the module cache around every test so no test inherits or leaks a cached token. 315 permissions tests pass; ruff and mypy are clean.

Which issue(s) this PR fixes

Fixes #6684

@larrysingleton007
larrysingleton007 requested a review from a team as a code owner July 31, 2026 17:15
@codecov-commenter

codecov-commenter commented Jul 31, 2026

Copy link
Copy Markdown

⚠️ Please install the 'codecov app svg image' to ensure uploads and comments are reliably processed by Codecov.

Codecov Report

❌ Patch coverage is 88.52459% with 7 lines in your changes missing coverage. Please review.
✅ Project coverage is 46.85%. Comparing base (b8dfcb0) to head (202e30d).

Files with missing lines Patch % Lines
...permissions/client/grpc_client_auth_interceptor.py 40.00% 5 Missing and 1 partial ⚠️
...sions/client/oidc_authentication_client_manager.py 97.82% 0 Missing and 1 partial ⚠️
❗ Your organization needs to install the Codecov GitHub app to enable full functionality.
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##           master    #6687      +/-   ##
==========================================
+ Coverage   46.81%   46.85%   +0.04%     
==========================================
  Files         415      415              
  Lines       50399    50456      +57     
  Branches     7214     7224      +10     
==========================================
+ Hits        23592    23643      +51     
- Misses      25157    25159       +2     
- Partials     1650     1654       +4     
Flag Coverage Δ
go-feature-server 30.58% <ø> (ø)
python-unit 48.18% <88.52%> (+0.05%) ⬆️
Files with missing lines Coverage Δ
sdk/python/feast/permissions/auth_model.py 100.00% <100.00%> (ø)
...thon/feast/permissions/client/client_auth_token.py 100.00% <100.00%> (ø)
...sions/client/oidc_authentication_client_manager.py 83.33% <97.82%> (+10.45%) ⬆️
...permissions/client/grpc_client_auth_interceptor.py 55.81% <40.00%> (-5.96%) ⬇️

... and 1 file with indirect coverage changes


Continue to review full report in Codecov by Harness.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update b8dfcb0...202e30d. Read the comment docs.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@franciscojavierarceo franciscojavierarceo left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The cache key omits token_refresh_margin_seconds, but the stored deadline is calculated using that value. If two configs share the same IdP/client credentials and use different margins—the new option is explicitly configurable—the stricter config can reuse a token until the looser config's deadline. That violates the requested safety margin. We should include the margin in the key or store the token's true expiry and apply each caller's margin when reading; add a regression that uses both configs without manually clearing the cache.

@larrysingleton007

larrysingleton007 commented Aug 3, 2026

Copy link
Copy Markdown
Contributor Author

@franciscojavierarceo
Fixed by storing the token's true expiry and applying each caller's margin on read, the second option you suggested. The cache key is unchanged, so configs sharing credentials still share tokens, but a wider margin can no longer ride a narrower one's deadline. _token_refresh_deadline became _token_expiry and no longer takes a margin.

One behavior change worth flagging: a token already inside the margin now gets cached instead of skipped. Every read still rejects it for that caller so nothing observable changes, and a config with a smaller margin can legitimately use it.

The regression exercises both configs against the shared entry without clearing the cache, and also checks the reverse direction stays cheap, with the narrow-margin config reusing what the wide one wrote. I verified it fails on the old code, with the wide config receiving the narrow config's token. I also dropped the manual cache clear from test_refresh_margin_is_configurable, since that clear is what hid this; it now uses distinct credentials to isolate the two margins.

326 permissions tests pass.

@larrysingleton007

Copy link
Copy Markdown
Contributor Author

@franciscojavierarceo @ntkathole
Addressed the cache key issue yesterday. The cache now stores the token's true expiry and each config applies its own margin on read, so a config with a wider margin can no longer ride a narrower one's deadline. The regression drives both configs through the shared entry without clearing the cache, and I verified it fails on the pre-fix code.

Green and ready for another look. It also still needs a kind/ label, which I can't add as an outside contributor.

@larrysingleton007

Copy link
Copy Markdown
Contributor Author

@franciscojavierarceo @ntkathole
Still open and ready whenever you have a moment. The cache-key fix has been in since 5 August: the cache now stores the token's true expiry and each config applies its own margin on read, with a regression that I verified fails against the pre-fix code.

All checks green, no conflicts with current master. It also still needs a kind/ label, which I can't add as an outside contributor.

@ntkathole

Copy link
Copy Markdown
Member

@larrysingleton007 please resolve the conflicts

@larrysingleton007

Copy link
Copy Markdown
Contributor Author

@ntkathole done, master merged and pushed.

The only conflict was .secrets.baseline, which is generated: master had picked up newer entries while this branch carried two shifted line numbers for test_oidc_auth_client.py plus a newer generated_at. I took master's baseline and re-ran the detect-secrets hook rather than hand-merging, which reapplied exactly the two shifts this branch needs (29 to 44, 31 to 46). Hand-editing generated line numbers would have been guesswork.

326 permissions tests pass on the merged tree, with ruff and mypy clean. The cache-key fix and its regression are unchanged.

The other two, #6689 and #6690, merge cleanly against current master, so nothing needed there.

@larrysingleton007

Copy link
Copy Markdown
Contributor Author

@ntkathole both addressed in 2a73cb7.

Pruning. Inserting on a miss now first drops entries whose stored expiry has passed. Keys are credential identities, so the cache was already bounded by the number of distinct configs, but a long-lived process that rotates credentials would have retained every retired one.

Revocation. Added OidcAuthClientManager.invalidate_token() plus a transport-agnostic invalidate_auth_token(auth_config) in client_auth_token.py, and wired it into the gRPC interceptor's existing error path: an UNAUTHENTICATED status drops the cached token, so the next call refetches. That bounds the staleness to the one request that was rejected rather than the remainder of the token's life.

One deliberate deviation from what you asked, and I'd rather flag it than quietly do less. I did not retry the rejected call. All four interceptor methods share _handle_call, and for the stream variants the request_iterator may already be partially consumed, so a retry there could replay a partial stream. Retry seems like it belongs per-transport, where the caller knows whether the request is replayable, rather than in the shared handler. The invalidation is the part that fixes the correctness problem; the retry only saves the caller one visible error. Happy to add it for the unary paths in this PR if you'd prefer, or as a follow-up covering all three transports, since the Arrow Flight and requests wrappers have no 401 handling at all today.

Two new tests: one asserting invalidation forces a refetch and is idempotent, one asserting an expired entry is pruned while an unexpired one survives. 328 permissions tests pass, ruff and mypy clean.

@ntkathole

Copy link
Copy Markdown
Member

@larrysingleton007 not able to rebase and merge, can you rebase and possibly squash the commit?

On the client_secret branch every outbound RPC built a fresh auth-token
factory, manager and OIDCDiscoveryService, paying a discovery GET plus a
token POST per call and discarding the token. All three auth interceptors
invoke it per RPC, so a batch materialization loop multiplied IdP load by
request count and could trip IdP rate limits. Measured before: 5 calls =
5 discovery GETs + 5 token POSTs. After: 1 and 1.

Caches IdP tokens in a module-level dict keyed by the token-request
identity, since the interceptors build a fresh manager per call and
instance state would not survive. Expiry comes from the token's own exp
claim, falling back to the token endpoint's expires_in; a token whose
expiry is unknowable is not cached, preserving per-call behaviour for
opaque tokens.

The cache stores true expiry and applies the caller's
token_refresh_margin_seconds on read, rather than storing a deadline. The
margin is not part of the key, so baking it in let a config with a wider
margin reuse a token past its own safety window when another config
sharing the same credentials had written the entry.

token_refresh_margin_seconds is configurable on OidcClientAuthConfig
(default 30, gt=0) rather than hardcoded.

Inserting on a miss prunes entries whose stored expiry has passed. Keys
are credential identities so the cache is bounded by distinct configs,
but a long-lived process rotating credentials would otherwise retain
every retired identity.

Reuse means a token the IdP revokes mid-life keeps being presented until
its own expiry, where fetching per call self-corrected. Adds
OidcAuthClientManager.invalidate_token and a transport-agnostic
invalidate_auth_token(auth_config), wired into the gRPC interceptor:
an UNAUTHENTICATED response drops the cached token so the next call
refetches, bounding staleness to the rejected request. The call is not
retried, because all four interceptor methods share that path and a
stream's request_iterator may already be consumed.

329 permissions tests pass.

Signed-off-by: Larry Singleton <166439969+larrysingleton007@users.noreply.github.com>
@larrysingleton007
larrysingleton007 force-pushed the fix/oidc-client-token-reuse branch from 2a73cb7 to 202e30d Compare August 14, 2026 08:25
@larrysingleton007

Copy link
Copy Markdown
Contributor Author

@ntkathole rebased onto current master and squashed to a single commit, 202e30d. The branch is no longer behind, and the three merge commits are gone.

One note on how I did it, since it affects what you're reviewing. Master had moved three commits past this branch's last merge, so I brought those in first and then collapsed everything onto upstream/master. A straight squash of the old branch tip would have reverted those three. The net diff is unchanged: 6 files, +414/-9, all of it this PR's own work.

Re-verified after the rebase, since master's newer commits touched permissions/user.py: 329 permissions tests pass, ruff and mypy clean.

@larrysingleton007

Copy link
Copy Markdown
Contributor Author

@ntkathole both review threads are resolved now. I'd replied in the conversation rather than inside the threads, which is why they stayed open — my mistake.

Summary of where they landed: pruning on cache miss is implemented and covered by a test; eviction on UNAUTHENTICATED is implemented via invalidate_token() and invalidate_auth_token(), wired into the gRPC interceptor. The retry is the one piece I left out, for the stream-replay reason in the thread, and I'm happy to add it either here for the unary paths or as a follow-up across all three transports.

Two things still block the merge, neither of them code in this PR.

The failing check is unit-test-python (3.10, ubuntu-latest) on test_http_session_manager::test_thread_safety, which asserts len(sessions) == 10 and got 9. It can't be this PR: that test builds its session from NoAuthConfig(), which takes the AuthType.NONE branch and never calls get_auth_token, so the token cache isn't in its code path. Its own assert len(errors) == 0 passed, so no thread raised — one thread just didn't record a result, which is a race in the test's own accounting. The test came in with #5895. Could you re-run that job?

@franciscojavierarceo the reviewDecision is still CHANGES_REQUESTED from your 3 August review of the cache key. That was fixed on 5 August: the cache now stores the token's true expiry and applies each config's margin on read, with a regression that I verified fails against the pre-fix code. Would you mind taking another look so the review state clears?

@larrysingleton007

Copy link
Copy Markdown
Contributor Author

Following up on the red check with evidence rather than just asking for a re-run.

unit-test-python fails intermittently on master, on a different test each time:

run date failing test
31768603920 14 Aug test_key_encoding_utils.py::test_performance_bounds_single_entityassert 0.206 < 0.2
31501371702 11 Aug test_feature_server.py::test_push_batched_matriximportlib_metadata.MetadataNotFound

Both are on master with no PR involved. The failure here, test_http_session_manager::test_thread_safety, is a third instance of the same class: tests that assert on wall-clock timing or thread scheduling, which are sensitive to how loaded the runner is when 8 xdist workers share it.

For this PR specifically, that test builds its session from NoAuthConfig(), which takes the AuthType.NONE branch and never calls get_auth_token, so nothing in this change is in its code path. I also ran it 88 times locally, 40 in isolation and 48 under deliberate CPU contention, without a single failure — consistent with it needing a loaded shared runner rather than being deterministic.

So a re-run should clear it. Separately, test_performance_bounds_single_entity looks worth fixing on its own: a 0.2s wall-clock bound cannot be made reliable by raising it a little, since the runner's available CPU isn't bounded. Happy to open an issue and PR for that if it's useful.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Client-side OIDC auth pays a discovery fetch and a token fetch per outbound request

4 participants