Repository navigation
Add dcrConfig.registrationProbe with an authorization-endpoint strategy - #6834
Conversation
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #6834 +/- ##
==========================================
- Coverage 79.94% 79.92% -0.03%
==========================================
Files 833 834 +1
Lines 84977 85063 +86
==========================================
+ Hits 67938 67984 +46
- Misses 17034 17074 +40
Partials 5 5 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
23437a9 to
b5828d3
Compare
|
@jhrozek checking in — does cf6d46c address the three points above? Happy to iterate further if not. One more data point in case it's useful for prioritizing: the #6821 vendor just purged its registered-client store a third time (Oct 19, worked around again by deleting the Redis row and restarting). The gap between purges is shrinking — 49 days, then 12, then 5 — so this is becoming a recurring manual fire drill on our side rather than a one-off. Keen to get this merged so the next purge heals itself at the next pod restart instead. |
The cache-hit probe redeems a random authorization code at the token endpoint and reads invalid_client as "registration gone". It decides only for upstreams that authenticate the client before validating the grant; a vendor whose token endpoint answers invalid_grant for any client_id keeps presenting a dropped registration, and the user sees the rejection in the browser. dcrConfig.registrationProbe selects the strategy per upstream: tokenEndpoint (the default, unchanged), authorizationEndpoint, or none. The authorization-endpoint probe sends an authorization request for the cached client without following redirects: RFC 6749 §4.1.2.1 has the server answer an unknown client_id or redirect_uri directly, so a direct 4xx (other than 404, 405 and 429) means the registration is gone and the resolver re-registers through the same replacement path; 2xx and 3xx mean the client is known. The request carries no credentials and the response body is never read or logged. Also loosen the rotationKey docs now that a probe can detect some rejections, and log the configured probe on the cache-hit records. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: Aron Gates <aron@muonspace.com>
RFC 6749 §4.1.2.1 only says an invalid client_id or redirect_uri must not be redirected; a direct 4xx on its own does not mean the client is invalid, since a gateway, an IP policy or bot protection in front of the server answers that way for a live client too. The probe now treats a direct answer as a rejection only when the server itself spoke: status 400, 401 or 403 with an RFC 6749 JSON error object whose error is invalid_client, unauthorized_client or invalid_request. A server that had validated client_id and redirect_uri would have delivered the error by redirect, so the direct structured error is the server reporting it could not. HTML pages, bare gateway statuses and other error codes are unverified and keep the cached registration. The probe reads up to 16 KiB of the body for the error code and repeats only a code from a short lower-case alphabet in its error and logs, never the description. Scope the private_key_jwt comments to the token-endpoint probe (the authorization-endpoint probe sends no credentials and covers every auth method) and spell out each probe setting's blind spot in the field docs and the storage arch doc, including that a false rejection re-registers at every start and fails startup when registration also fails. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: Aron <aron@muonspace.com>
cf6d46c to
57fdf8c
Compare
|
I apologize for the long delay @aron-muon - there was too many things going on at once. I fixed the conflict and merged the PR, so it will be out in the next release (we release several times a week these days) |
Summary
#6822 checks a cached upstream DCR registration at startup by redeeming a random authorization code at the token endpoint:
invalid_clientmeans the registration is gone,invalid_grantmeans it is alive. That decides only for upstreams that authenticate the client before validating the grant. A vendor whose token endpoint answersinvalid_grantfor anyclient_id(the production case in #6821: dead, live and made-up clients all get400 invalid_grant "Invalid authorization code") keeps presenting a dropped registration, and the user sees the rejection in the browser while the resolver sees nothing.dcrConfig.registrationProbeonMCPExternalAuthConfigupstream providers (and the VirtualMCPServer inline copy) andregistration_probeon theauthserver.DCRUpstreamConfigrun-config, with valuestokenEndpoint(default, exactly Re-register DCR clients the upstream evicted #6822's behaviour),authorizationEndpointandnone. Enum-validated at admission and mirrored in the run-configValidateand the resolver's input check, so an unknown value fails at startup instead of silently probing the wrong endpoint.authorizationEndpointsends an authorization request for the cached client (response_type=code,client_id, the registeredredirect_uri, the configured scopes, a throwawaystateand S256 PKCE challenge) and reads the first response without following redirects. RFC 6749 §4.1.2.1 has the server answer an unknownclient_idorredirect_uridirectly rather than by redirect, so a direct 400, 401 or 403 carrying an RFC 6749 JSON error object witherrorinvalid_client,unauthorized_clientorinvalid_requestmeans the registration is gone, and the resolver re-registers through the sameReplacer.ReplaceIfUnchangedpath as the token-endpoint probe. 2xx (a login page) and 3xx (to the identity provider, or back toredirect_uriwith an error) mean the client is known. Everything else is inconclusive and keeps the cached registration: other 4xx, a 4xx without a JSON error object (an HTML page, a bare gateway status), a JSON error outside that set, 5xx and transport failures. The request carries no credentials; up to 16 KiB of the body is read for the error code, which is repeated in logs only when it matches^[a-z_]{1,40}$; the description is never logged. A false rejection re-registers at every start and, when registration also fails, fails startup, which is why the verdict is narrow. Because it sends no credentials it also coversprivate_key_jwtclients, which the token-endpoint probe skips.noneskips the check for upstreams that should not see probe traffic; the registration is then retired only throughrotationKey.rotationKeydocs now say the rejection "may not be visible to the resolver" and point at the probe setting; the cache-hit log records carryregistration_probe.Related to #6821. Builds on #6822.
Type of change
Test plan
task test) — every touched package passes with-race(pkg/auth/dcr,pkg/authserver,pkg/authserver/storage,pkg/authserver/runner,cmd/thv-operator/pkg/controllerutil,cmd/thv-operator/api/...). New coverage inpkg/auth/dcr/validate_test.go:TestResolveCredentials_CacheHitAuthorizationEndpointProbe: 200, 302 to the IdP, 302 back toredirect_uriwith an error, 400invalid_request, 401invalid_client, 403unauthorized_client, 403 HTML page, bodiless gateway 401, 400server_error, 400 non-error JSON, 404, 405, 429, 500, failed re-registration; asserts the request shape, that no credentials are sent, that the token endpoint is never called and that redirects are not followed...._AuthorizationProbeVerifiesPrivateKeyJWTClients,..._CacheHitProbesConfiguredAuthorizationEndpoint(decoy cached endpoint),..._AuthorizationProbeTransportErrorKeepsRegistration,..._ProbeNoneSkipsUpstream.TestClassifyAuthorizationProbe(status and body matrix),TestAuthorizationProbeErrorAttrs_OmitsServerResponse,..._RepeatsOnlyTheErrorCode,TestVerifyCachedRegistration_*(rejected probe endpoints, redirect-URI fallback).newDCRRequestpropagation and the operator CRD-to-run-config conversion each gained the new field.task test-e2e)task lint-fix)API Compatibility
v1beta1API, OR theapi-break-allowedlabel is applied and the migration guidance is described above.registrationProbeis a new optional enum string onDCRUpstreamConfig; an absent or empty value keeps #6822's token-endpoint probe. No storage format or Redis key change.Changes
pkg/auth/dcr/request.goRegistrationProbetype, constants,Validate;Request.RegistrationProbepkg/auth/dcr/validate.goverifyViaAuthorizationEndpoint,classifyAuthorizationProbe; guarded client builder shared by both probespkg/auth/dcr/resolver.govalidateResolveInputs;registration_probelog attribute;ProbeNonelogs at Debugpkg/authserver/config.goregistration_probeon the run-config, validated through the resolver's enumpkg/authserver/runner/dcr_adapter.godcr.Requestcmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types.goregistrationProbewith an Enum marker; rotationKey wordingcmd/thv-operator/pkg/controllerutil/authserver.gopkg/authserver/storage/types.godeploy/charts/operator-crds/**,docs/operator/crd-api.md,docs/server/*,sdk/go/*docs/arch/11-auth-server-storage.mdDoes this introduce a user-facing change?
Yes. Operators can set
dcrConfig.registrationProbe: authorizationEndpointon an upstream whose token endpoint cannot tell a dropped public client from a live one; the proxy then re-registers at its next start instead of presenting the deadclient_iduntil someone rotates it. The default is unchanged. The authorization endpoint sees one abandoned authorization request per upstream per start.Special notes for reviewers
400 {"error":"invalid_request","error_description":"redirect_uri is not in the allowlist..."}, a ToolHive auth server with401 {"error":"invalid_client", ...}; both decide. Servers that render an HTML page (Keycloak, Auth0), a gateway's 401/403, or a 200/redirect error page read as unverified or alive, which is today's behaviour;rotationKeyremains the remedy there.noneis included because an operator may not want probe traffic against a vendor that logs or alerts on failed requests; it is one case in the dispatcher and the resolver logs it at Debug rather than Warn.newProbeHTTPClient; each sets its ownCheckRedirect(refuse for the token probe, which carries credentials;ErrUseLastResponsefor the authorization probe, where the redirect is the answer).🤖 Generated with Claude Code