Repository navigation
Re-register DCR clients the upstream evicted - #6822
Conversation
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #6822 +/- ##
========================================
Coverage 79.62% 79.63%
========================================
Files 815 816 +1
Lines 82955 83072 +117
========================================
+ Hits 66056 66157 +101
- Misses 16894 16910 +16
Partials 5 5 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
A cached DCR registration was reused forever, but a ToolHive auth server evicts idle DCR clients after DefaultDCRClientTTL while advertising client_secret_expires_at=0. Once evicted, every authorization was rejected upstream with invalid_client and nothing recovered. On a cache hit the resolver now redeems a random authorization code at the token endpoint with the cached credentials. invalid_client means the registration is gone, so it registers a new client and replaces the stale row through UpdateDCRCredentialsIfUnchanged. invalid_grant or any inconclusive answer keeps the cached client. Fixes #6821 Signed-off-by: Giuseppe Scuglia <peppescg@gmail.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Only servers that authenticate the client before validating the grant answer invalid_client for a deleted registration. For public clients some servers validate the code first, and the probe then keeps the cached registration as before. Signed-off-by: Giuseppe Scuglia <peppescg@gmail.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
9fdb193 to
d9aa0f3
Compare
jhrozek
left a comment
There was a problem hiding this comment.
Looks good overall. The probe is a sensible way to catch this, and the e2e test covering the chained case is a nice touch. I left a few inline notes, none blocking. Below are some things that aren't tied to a changed line.
Docs that the probe now contradicts. The rotationKey docs from #6825 say a dropped registration is "only visible in the user's browser, so the resolver cannot detect it" (pkg/authserver/config.go:906, mcpexternalauthconfig_types.go:1862, and the generated CRD, swagger and docs/operator/crd-api.md copies). With this PR that's only true when the upstream answers invalid_grant for any client, or the client uses private_key_jwt. Something like "at start ToolHive probes the cached client at the token endpoint and replaces it when the upstream answers invalid_client; where it can't tell, the rejection is only visible in the user's browser" would be accurate. Two similar spots: request.go:95-102 and storage/types.go:239-248 call rotation the way to retire a dropped registration. The stale-age warning in resolver.go ("consider rotating via RFC 7592 deregistration and re-registering at next startup") was never true on main, since a hit didn't re-register. It becomes true with this PR, and it could mention rotationKey too. If regenerating the docs makes this PR bigger than you'd like, a follow-up is fine.
Two definitions of "gone". This PR infers it from a token-endpoint invalid_client, and #6825 leaves it to the operator, who is usually reacting to the browser's authorize-endpoint error. It would help for the merged comment and the docs to say which situations the probe catches and which need a rotation.
Tests and the issue link. The e2e test only covers a confidential client (client_secret_basic). A public-client variant would cover the path the fosite reading relies on, which is the case #6821's reporter hit. And with rotationKey as the lever for upstreams where the probe can't tell, Refs #6821 might read more honestly than Fixes. Your call.
Adding ReplaceIfUnchanged to dcr.CredentialStore broke every store implemented outside this package, and a wrapper that embeds the interface would have inherited the method silently, so a replacement would bypass whatever the wrapper does around writes. Move it to a separate Replacer interface that both in-package stores implement. The resolver checks for it before probing: a store that cannot act on a rejection keeps today's cache-hit behaviour and the upstream is not contacted at all. Read the cache inside the singleflight, so a caller can no longer revalidate a row it read before an earlier flight for the same key replaced it and register a second client. The recheck in registerAndCache becomes redundant and goes. Log the probe outcome and the stale-age warning with the rotation key, point that warning at rotationKey instead of RFC 7592, and reduce an OAuth error response from the probe to its HTTP status and error code instead of logging the server's error_description or body. Add a public-client end-to-end test against a ToolHive upstream. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Signed-off-by: Giuseppe Scuglia <peppescg@gmail.com>
The rotationKey docs said a dropped registration is only visible in the user's browser, so the resolver cannot detect it. With the token endpoint probe that holds only where the probe cannot tell: an upstream that answers a dropped public client like a live one, or a private_key_jwt client. Say which cases the probe handles on its own and which need a rotation, and regenerate the CRDs, CRD reference, swagger and Go SDK. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Signed-off-by: Giuseppe Scuglia <peppescg@gmail.com>
|
Thanks for the review, I pushed two commits. cb686b0 covers the inline points, see the replies there. 80ffcca updates the rotationKey docs (config.go, the CRD type, request.go, storage/types.go, docs/arch) and regenerates the CRDs, crd-api.md, swagger and the SDK. I kept it in this PR since it's mostly regenerated text. They now say that at start ToolHive checks the cached client at the token endpoint and replaces it on I added a public-client variant of the e2e test. It drives the resolver with PublicClient the way the CLI does, since the embedded server always registers a confidential client, and it fails without the probe. On Fixes vs Refs I'd keep Fixes. The scenario in #6821 is a ToolHive upstream evicting an idle client, and both e2e tests now cover it. The vendor case from the comment is the one rotationKey covers. Happy to switch if you feel strongly. |
Summary
When an embedded auth server uses DCR against an upstream ToolHive auth server, the two sides disagree about how long a registration lives. The downstream caches it forever. The upstream evicts DCR clients after
DefaultDCRClientTTL(30 days) without a token exchange, while advertisingclient_secret_expires_at: 0. After an eviction, or any loss of the upstream's client storage, every authorization is rejected withinvalid_client. Only the browser sees that error, so nothing recovers until someone deletes the cached row by hand.On a cache hit,
ResolveCredentialsnow checks the cached client against the upstream token endpoint, when the credential store implements the new optionaldcr.Replacerinterface. It redeems a random authorization code with the cached credentials. Servers that authenticate the client before they validate the grant answer:invalid_clientwhen the registration is gone, so the resolver re-registers;invalid_grantwhen it is alive, so the cached client is kept.ToolHive's own auth server (fosite) behaves this way, for public and confidential clients alike. Anything else (5xx, 429, non-OAuth bodies, network errors, redirects) is inconclusive and keeps the cached client, so an unreachable upstream never blocks startup.
On
invalid_clientthe resolver registers a new client and replaces the stale row throughReplacer.ReplaceIfUnchanged. That method is built onUpdateDCRCredentialsIfUnchanged(Add compare-and-set update for DCR credentials #6758), so concurrent replicas converge on one registration and never overwrite a newer one.dcr.CredentialStoreitself is unchanged.Replaceris a separate interface, likehttp.Flusher, implemented by both in-tree stores. A store that does not implement it keeps today's cache-hit behaviour: no probe, no request to the upstream.The cache read and the check run inside the existing singleflight, so concurrent callers in one process share a single read, check and registration.
Registration code is split out of
registerAndCacheintoregisterClient/checkAuthoritativeResolution, so the cache-miss and cache-hit paths share it.Fixes #6821
Type of change
Test plan
task test)task test-e2e)task lint-fix)task testpasses exceptTestGetOverlayMounts(pkg/ignore, not touched here). It failed once in the full run and passes on repeated isolated runs with and without this change.New tests:
pkg/authserver/runner/dcr_evicted_registration_test.gois an end-to-end reproduction. It chains two in-process auth servers, each with its own miniredis. The downstream registers on the upstream via DCR, the upstream's Redis is fast-forwarded pastDefaultDCRClientTTL, and the downstream restarts on its persisted cache. Before the fix the restarted downstream presents the evictedclient_idand the upstream answers401 invalid_client. With the fix the upstream accepts the new client.token_endpoint_auth_method=none, driven through the resolver as the CLI flow does) against the same ToolHive upstream: a live client is kept, an evicted one is replaced. It fails without the probe.validate_test.gois table-driven over every probe outcome (invalid_grant,invalid_client, other OAuth errors, 5xx, non-OAuth bodies, refused redirects). It covers each auth method (client_secret_basic,client_secret_post,none, plusprivate_key_jwt, which is skipped) and a failed re-registration. It also checks that the probe uses the configured token endpoint rather than the cached one, that a rejected row is never returned, that a store withoutReplacer(including one that embedsCredentialStore) is never probed, that a second caller arriving after the first has read the rejected row causes exactly one new registration (two if the cache is read outside the flight), and that the probe's log attributes carry only the HTTP status and OAuth error code, never the server's response text.store_test.gotestsReplaceIfUnchangedon both stores: replace, keep a concurrent winner, claim a vanished row, reject nil input.Changes
pkg/auth/dcr/validate.gooautherrand the guarded DCR HTTP client)pkg/auth/dcr/resolver.goReplacer, re-register on rejection; registration extracted intoregisterClientpkg/auth/dcr/store.goReplacerinterface (ReplaceIfUnchangedon top ofUpdateDCRCredentialsIfUnchanged), implemented by both storespkg/authserver/runner/embeddedauthserver_test.gorotationKeydocs (pkg/authserver/config.go, CRD type,request.go,storage/types.go,docs/arch) + regenerated CRDs, CRD reference, swagger, Go SDKDoes this introduce a user-facing change?
An embedded auth server whose cached upstream DCR client was deleted upstream now registers a new client at startup instead of failing every authorization with
invalid_client. Each resolution of a cached DCR registration through a store that implementsReplacermakes one extra token-endpoint request; for the embedded auth server that is once per upstream per start.Special notes for reviewers
none) there is nothing to authenticate, and some servers validate the code first: they answerinvalid_grantfor anyclient_id, live, deleted or made up. Examples are the vendor reported in Cached upstream DCR registration never recovers after the upstream evicts it #6821 and better-auth. Against those servers a deleted registration still reads as alive, so the probe is a no-op and behavior is unchanged from today, not a regression. Add dcrConfig.rotationKey to re-register upstream DCR clients #6825 (dcrConfig.rotationKey) is the complementary operator lever for that case. Longer term, Client ID Metadata Documents avoid the problem entirely, since the upstream keeps no stored registration; the MCP spec now deprecates DCR in favour of CIMD. That is out of scope here.invalid_grant), logged at Debug by ToolHive. No token can be issued for the random code.CredentialStorekeepsGetandPutIfAbsent, so stores implemented outside this repo keep compiling. A wrapper that embedsCredentialStoredoes not pick upReplacereither, so it can never inherit a replacement that skips what it does around writes; to opt in, it implements and forwardsReplaceIfUnchangedon purpose. Callers that resolve often (on a poll rather than at start) stay unprobed until they do.🤖 Generated with Claude Code