Description
ToolHive fetches and caches remote JWKS in two places, built independently on
the same libraries and converging on the same design. Neither knows about the
other.
pkg/auth/token.go — the inbound TokenValidator used by every proxy and CLI
path. Holds a jwk.Cache (jwx v3) created over
httprc.NewClient(httprc.WithHTTPClient(...)), registers the JWKS URL in
ensureJWKSRegistered (:698), and resolves keys in getKeyFromJWKS (:896) via
Lookup then LookupKeyID. Its HTTP client comes from
networking.NewHttpClientBuilder(), gated by AllowPrivateIP and
InsecureAllowHTTP. Discovery retries with cenkalti/backoff.
pkg/authserver/server/tokenexchange/multi_issuer_validator.go — the
token-exchange subject-token validator. Holds a jwk.Cache per issuer
(externalIssuerConfig.jwksCache, :212), registered in registerOrRefresh
(:631) and reached through ensureRegistered (:589) and lookupJWKS (:700).
Adds a rate-limited refreshOnUnknownKid (:757), a fetch-failure backoff,
discoverJWKSURL (:803), ValidateJWKSURL (:860), a limitedBodyTransport
response cap, and per-issuer InsecureAllowHTTP / AllowPrivateIPs.
Same problem, same libraries, two solutions. The second is the more complete
one, and its extra machinery — the response cap, the rate-limited kid refresh,
per-issuer rather than global HTTP and private-IP flags — is exactly the kind
of hardening the first would want and does not have.
Scope
Extract one shared JWKS fetch-and-cache type and move both call sites onto it.
The API surface both implementations already imply:
- resolve a key set for an issuer or JWKS URL, with caching
- look up by
kid, refreshing once on a miss, rate-limited
- serve the last known good set when a fetch fails
- an SSRF-guarded HTTP client from
networking, with a bounded response body
- HTTP and private-IP relaxations declared per issuer, never globally
Take the extraction from the two implementations on main. Do not regress
anything the token-exchange validator already does.
Explicitly out of scope
pkg/authserver/upstream/oidc.go is a third mechanism: it uses
coreos/go-oidc, whose IDTokenVerifier carries its own RemoteKeySet with
its own cache and refresh policy. It already receives an SSRF-guarded client
through oidc.ClientContext (:200). Replacing it means reimplementing ID token
verification, nonce handling and the rest of the verifier, which is a much
larger change with real risk for no clear gain. Leave it alone and say so in
the code.
Prior art, and what not to lift
The unpushed xaa-spike-1 branch has pkg/oauthproto/jwks.go (commit
6fc952750), a JWKSFetcher with per-issuer caching, refresh on kid miss,
HTTPS enforcement, a dial-time private-address guard and a stale-on-error path.
Do not cherry-pick it. It was written on 2026-08-04, before #6149 rebuilt
the token-exchange validator, and it is hand-rolled sync.Mutex caching over
go-jose where both live implementations use jwx jwk.Cache + httprc.
Adopting it converts working code backwards: it would drop httprc's background
refresh, the response cap, and the validator's per-issuer flags in favour of a
single global one.
What it did get right is the API shape — resolve by issuer, look up by kid,
refresh once on miss, serve stale on error. Use it as a sketch of the interface
and nothing else.
Acceptance criteria
- One shared type, both call sites converted, no third implementation left.
- Per-issuer
InsecureAllowHTTP and AllowPrivateIPs survive; neither becomes
a process-wide switch.
- The response-body cap applies to both call sites.
- Refresh on unknown
kid stays rate-limited, so an attacker supplying random
kid values cannot drive unbounded fetches.
- A fetch failure serves the last known good key set rather than failing all
validation.
- Key rotation still works end to end for both call sites.
- No key material, token, or assertion is logged.
- Existing token validation behaviour is unchanged; this is a refactor, not a
behaviour change.
Why separately
pkg/auth/token.go is on the request path for every proxied workload, so this
wants its own review and its own test story rather than riding along inside a
feature branch.
Related
Description
ToolHive fetches and caches remote JWKS in two places, built independently on
the same libraries and converging on the same design. Neither knows about the
other.
pkg/auth/token.go— the inboundTokenValidatorused by every proxy and CLIpath. Holds a
jwk.Cache(jwx v3) created overhttprc.NewClient(httprc.WithHTTPClient(...)), registers the JWKS URL inensureJWKSRegistered(:698), and resolves keys ingetKeyFromJWKS(:896) viaLookupthenLookupKeyID. Its HTTP client comes fromnetworking.NewHttpClientBuilder(), gated byAllowPrivateIPandInsecureAllowHTTP. Discovery retries withcenkalti/backoff.pkg/authserver/server/tokenexchange/multi_issuer_validator.go— thetoken-exchange subject-token validator. Holds a
jwk.Cacheper issuer(
externalIssuerConfig.jwksCache, :212), registered inregisterOrRefresh(:631) and reached through
ensureRegistered(:589) andlookupJWKS(:700).Adds a rate-limited
refreshOnUnknownKid(:757), a fetch-failure backoff,discoverJWKSURL(:803),ValidateJWKSURL(:860), alimitedBodyTransportresponse cap, and per-issuer
InsecureAllowHTTP/AllowPrivateIPs.Same problem, same libraries, two solutions. The second is the more complete
one, and its extra machinery — the response cap, the rate-limited kid refresh,
per-issuer rather than global HTTP and private-IP flags — is exactly the kind
of hardening the first would want and does not have.
Scope
Extract one shared JWKS fetch-and-cache type and move both call sites onto it.
The API surface both implementations already imply:
kid, refreshing once on a miss, rate-limitednetworking, with a bounded response bodyTake the extraction from the two implementations on
main. Do not regressanything the token-exchange validator already does.
Explicitly out of scope
pkg/authserver/upstream/oidc.gois a third mechanism: it usescoreos/go-oidc, whoseIDTokenVerifiercarries its ownRemoteKeySetwithits own cache and refresh policy. It already receives an SSRF-guarded client
through
oidc.ClientContext(:200). Replacing it means reimplementing ID tokenverification, nonce handling and the rest of the verifier, which is a much
larger change with real risk for no clear gain. Leave it alone and say so in
the code.
Prior art, and what not to lift
The unpushed
xaa-spike-1branch haspkg/oauthproto/jwks.go(commit6fc952750), aJWKSFetcherwith per-issuer caching, refresh onkidmiss,HTTPS enforcement, a dial-time private-address guard and a stale-on-error path.
Do not cherry-pick it. It was written on 2026-08-04, before #6149 rebuilt
the token-exchange validator, and it is hand-rolled
sync.Mutexcaching overgo-jose where both live implementations use jwx
jwk.Cache+httprc.Adopting it converts working code backwards: it would drop httprc's background
refresh, the response cap, and the validator's per-issuer flags in favour of a
single global one.
What it did get right is the API shape — resolve by issuer, look up by
kid,refresh once on miss, serve stale on error. Use it as a sketch of the interface
and nothing else.
Acceptance criteria
InsecureAllowHTTPandAllowPrivateIPssurvive; neither becomesa process-wide switch.
kidstays rate-limited, so an attacker supplying randomkidvalues cannot drive unbounded fetches.validation.
behaviour change.
Why separately
pkg/auth/token.gois on the request path for every proxied workload, so thiswants its own review and its own test story rather than riding along inside a
feature branch.
Related
what that left duplicated.
authorities come from the trust bundle, not from a JWKS URI, so
jwtsvid.ParseAndValidatemakes no HTTP request. Implement JWT-SVID OAuth client authentication (spiffe_jwt) #6203's stated dependency ona shared JWKS fetcher is mistaken and is being corrected separately.