Skip to content

Consolidate duplicate JWKS fetch and cache machinery - #6459

Open
lorenzozanee wants to merge 2 commits into
stacklok:mainfrom
lorenzozanee:fix/consolidate-jwks-fetch-cache
Open

Consolidate duplicate JWKS fetch and cache machinery#6459
lorenzozanee wants to merge 2 commits into
stacklok:mainfrom
lorenzozanee:fix/consolidate-jwks-fetch-cache

Conversation

@lorenzozanee

Copy link
Copy Markdown
Contributor

Summary

ToolHive fetched and cached remote JWKS in two places, built independently on the same jwx/httprc libraries: the inbound TokenValidator (pkg/auth/token.go) and the token-exchange multi-issuer validator (pkg/authserver/server/tokenexchange/multi_issuer_validator.go). The two had drifted: the token-exchange side gained hardening (response-body cap, rate-limited refresh on unknown kid, URL validation, fetch-failure backoff) that the inbound side never received, and every jwx/httprc pitfall had to be learned twice.

This extracts one shared fetch-and-cache type, jwks.Fetcher in the new pkg/auth/jwks package, and moves both call sites onto it:

  • One Fetcher per issuer/validator; each owns its own jwk.Cache and *http.Client, so two issuers sharing a JWKS URL can no longer silently inherit each other's transport policy.
  • The SSRF-guarded client still comes from networking.NewHttpClientBuilder() with per-caller flags; INSECURE_DISABLE_URL_VALIDATION still cannot widen private-IP dialing.
  • ValidateJWKSURL and limitedBodyTransport move verbatim into the package; ValidateJWKSURL stays env-immune.
  • Stale-on-error comes from httprc/jwx semantics (a failed refresh leaves the last known good set in place); the fetcher documents this rather than re-implementing it.

Intentional hardening on the inbound path (declared behavior changes, per the issue's acceptance criteria): the previously token-exchange-only protections now apply to pkg/auth too - JWKS URL validation on every fetch/refresh, 1 MiB response-body cap, 100-key cap, 30s rate-limited refresh on unknown kid, and a 30s fetch-failure backoff before first success. One recovery-window nuance: a first fetch that misses the 5s registration budget is now treated as a fetch failure under the backoff gate (previously the next request could succeed as soon as the background fetch landed), so a slow IdP can take up to ~30s longer on first contact. The token-exchange path is behavior-preserving; its discovery-failure gate is kept in the caller.

pkg/authserver/upstream/oidc.go (coreos/go-oidc RemoteKeySet) is the third mechanism and is deliberately left alone, with a code comment saying so - replacing it would mean reimplementing ID-token verification and nonce handling.

Fixes #6319

Type of change

  • Bug fix
  • New feature
  • Refactoring (no behavior change)
  • Dependency update
  • Documentation
  • Other (describe):

Behavior-preserving for the token-exchange path. The inbound path gains the hardening listed above, aligning the two paths per #6319's acceptance criteria - checked as a bug fix because the missing inbound protections are the gap this PR closes.

Test plan

  • Unit tests (task test)
  • Linting (task lint-fix)

Scoped commands actually run: go test ./pkg/auth/... ./pkg/authserver/... -count=1 (all packages ok), go test -race ./pkg/auth ./pkg/auth/jwks ./pkg/authserver/server/tokenexchange -count=1 (clean), golangci-lint v2.12.2 on the changed packages (0 issues).

New coverage: pkg/auth/jwks seam tests (kid lookup + rate-limited unknown-kid refresh, fetch-failure backoff with error replay, stale-on-error incl. failed kid-refresh preserving the stale set, body cap, per-instance flag isolation, ValidateJWKSURL table, key-count cap, pinned refresh interval) and inbound-path tests through ValidateToken (body cap, key rotation with rate-limited refresh, stale-on-fetch-error, slow-first-fetch, re-discovery to a new JWKS URL re-registers).

Changes

File Change
pkg/auth/jwks/jwks.go New shared fetcher: cache lifecycle, registration strategy, rate-limited kid refresh, key-count cap
pkg/auth/jwks/options.go Functional options (per-instance transport/SSRF flags, caps, gates)
pkg/auth/jwks/url.go ValidateJWKSURL moved from tokenexchange
pkg/auth/jwks/transport.go limitedBodyTransport moved from tokenexchange
pkg/auth/token.go Inbound validator migrated to jwks.Fetcher; registration flag removed
pkg/authserver/server/tokenexchange/multi_issuer_validator.go Per-issuer validator migrated to jwks.Fetcher; duplicated machinery deleted
pkg/authserver/config.go Config-time JWKS URL check delegates to jwks.ValidateJWKSURL
pkg/authserver/upstream/oidc.go Comment: go-oidc key set intentionally out of scope
pkg/auth/token_test.go, pkg/auth/token_jwks_test.go Tests moved/adapted; new inbound hardening tests
pkg/auth/jwks/jwks_test.go New package seam tests
multi_issuer_validator_test.go Tests updated to exported behavior

Does this introduce a user-facing change?

Yes - on the inbound path, a plain-HTTP jwks_uri without explicit InsecureAllowHTTP is now rejected (fail-closed URL validation), and a slow first fetch from the IdP can take up to ~30s longer before token validation succeeds. Configuration semantics are otherwise unchanged.

Special notes for reviewers

  • The first-fetch recovery-window nuance above is the one semantic worth looking at hardest; it is covered by TestTokenValidator_SlowFirstFetchStillSucceeds and the backoff tests.
  • Size: this is one atomic logical change - the issue requires both call sites converted with no third implementation left, and a split would leave exactly that mid-migration state. Roughly half the added lines are tests.

The inbound TokenValidator and the token-exchange multi-issuer
validator each carried a full JWKS fetch-and-cache stack built on the
same jwx/httprc libraries, and had drifted: the token-exchange side
gained a response-body cap, rate-limited refresh on unknown kid,
URL validation and fetch-failure backoff that the inbound side never
received, and every jwx/httprc pitfall had to be fixed twice.

Extract one shared fetcher into pkg/auth/jwks and move both call
sites onto it. Per-issuer InsecureAllowHTTP and AllowPrivateIPs
survive, each fetcher owns its own jwk.Cache so transport policy can
never leak across issuers sharing a JWKS URL, and the response-body
cap, key-count cap, rate-limited kid refresh and stale-on-error
semantics now apply to both paths.

ValidateJWKSURL moves from tokenexchange to pkg/auth/jwks.

Signed-off-by: lorenzozanee <wyz0707@proton.me>
@codecov

codecov Bot commented Aug 29, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 92.60870% with 17 lines in your changes missing coverage. Please review.
✅ Project coverage is 78.03%. Comparing base (6c14396) to head (4234987).

Files with missing lines Patch % Lines
pkg/auth/jwks/jwks.go 92.80% 9 Missing ⚠️
pkg/auth/jwks/options.go 88.23% 4 Missing ⚠️
...ver/server/tokenexchange/multi_issuer_validator.go 92.30% 3 Missing ⚠️
pkg/auth/token.go 91.66% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #6459      +/-   ##
==========================================
- Coverage   78.07%   78.03%   -0.05%     
==========================================
  Files         767      771       +4     
  Lines       74269    74370     +101     
==========================================
+ Hits        57988    58034      +46     
- Misses      16276    16331      +55     
  Partials        5        5              

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

The inbound TokenValidator and the token-exchange multi-issuer
validator each carried a full JWKS fetch-and-cache stack built on the
same jwx/httprc libraries, and had drifted: the token-exchange side
gained a response-body cap, rate-limited refresh on unknown kid,
URL validation and fetch-failure backoff that the inbound side never
received, and every jwx/httprc pitfall had to be fixed twice.

Extract one shared fetcher into pkg/auth/jwks and move both call
sites onto it. Per-issuer InsecureAllowHTTP and AllowPrivateIPs
survive, each fetcher owns its own jwk.Cache so transport policy can
never leak across issuers sharing a JWKS URL, and the response-body
cap, key-count cap, rate-limited kid refresh and stale-on-error
semantics now apply to both paths.

ValidateJWKSURL moves from tokenexchange to pkg/auth/jwks.

Signed-off-by: lorenzozanee <wyz0707@proton.me>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Consolidate duplicate JWKS fetch and cache machinery

1 participant