Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 25 additions & 8 deletions cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -1894,19 +1894,36 @@ type DCRUpstreamConfig struct {
// Change it to make the proxies register a fresh client at their next
// start instead of reusing the cached one: for example after the
// upstream has dropped the registration, or when a rotation is required.
// At start ToolHive checks the cached client at the upstream's token
// endpoint and registers a new one by itself when the upstream answers
// invalid_client. Where that check cannot tell (an upstream that answers
// a dropped public client like a live one, or a private_key_jwt client),
// the stale client_id is only rejected in the user's browser, and
// changing this key is the remedy. The previous registration's row stays
// in storage, and upstream refresh tokens bound to the previous client_id
// stop refreshing, so affected users sign in again.
// At start ToolHive checks the cached client at the upstream (see
// RegistrationProbe) and registers a new one by itself when the upstream
// rejects it. Where the configured probe cannot tell, the stale client_id
// may only be rejected in the user's browser, and changing this key is
// the remedy. The previous registration's row stays in storage, and
// upstream refresh tokens bound to the previous client_id stop
// refreshing, so affected users sign in again.
// +optional
// +kubebuilder:validation:Pattern=`^[A-Za-z0-9._-]+$`
// +kubebuilder:validation:MaxLength=64
RotationKey string `json:"rotationKey,omitempty"`

// RegistrationProbe selects how ToolHive checks the cached upstream client
// at start. tokenEndpoint (the default) redeems a random authorization
// code with the cached client and reads invalid_client as "registration
// gone"; it decides only for upstreams that authenticate the client
// before validating the grant. authorizationEndpoint sends an
// authorization request for the cached client without following
// redirects and reads a direct 400, 401 or 403 that carries an RFC 6749
// JSON error object (error invalid_client, unauthorized_client or
// invalid_request) as "gone" (RFC 6749 §4.1.2.1), for upstreams whose
// token endpoint answers invalid_grant for any client_id; an HTML page or
// a bare gateway status is not a verdict. A false rejection re-registers
// at every start and, when registration also fails, fails startup. none
// skips the check; the registration is then retired only through
// RotationKey.
// +optional
// +kubebuilder:validation:Enum=tokenEndpoint;authorizationEndpoint;none
RegistrationProbe string `json:"registrationProbe,omitempty"`

// InitialAccessTokenRef is an optional reference to a Kubernetes Secret
// carrying an RFC 7591 §3 initial access token. When set, the resolver
// presents the token value as a Bearer credential on the registration
Expand Down
1 change: 1 addition & 0 deletions cmd/thv-operator/pkg/controllerutil/authserver.go
Original file line number Diff line number Diff line change
Expand Up @@ -1501,6 +1501,7 @@ func buildDCRUpstreamRunConfig(
DiscoveryURL: dcr.DiscoveryURL,
RegistrationEndpoint: dcr.RegistrationEndpoint,
RotationKey: dcr.RotationKey,
RegistrationProbe: dcr.RegistrationProbe,
SoftwareID: dcr.SoftwareID,
SoftwareStatement: dcr.SoftwareStatement,
}
Expand Down
2 changes: 2 additions & 0 deletions cmd/thv-operator/pkg/controllerutil/authserver_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1815,6 +1815,7 @@ func TestBuildAuthServerRunConfig(t *testing.T) {
DCRConfig: &mcpv1beta1.DCRUpstreamConfig{
RegistrationEndpoint: "https://idp.example.com/register",
RotationKey: "2026-10",
RegistrationProbe: "authorizationEndpoint",
},
},
},
Expand All @@ -1832,6 +1833,7 @@ func TestBuildAuthServerRunConfig(t *testing.T) {
upstream.OAuth2Config.DCRConfig.RegistrationEndpoint)
assert.Empty(t, upstream.OAuth2Config.DCRConfig.DiscoveryURL)
assert.Equal(t, "2026-10", upstream.OAuth2Config.DCRConfig.RotationKey)
assert.Equal(t, "authorizationEndpoint", upstream.OAuth2Config.DCRConfig.RegistrationProbe)
// No InitialAccessTokenRef set: env var name should stay empty.
assert.Empty(t, upstream.OAuth2Config.DCRConfig.InitialAccessTokenEnvVar)
},
Expand Down

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion docs/arch/11-auth-server-storage.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ The storage layer implements multiple interfaces from the [fosite](https://githu
- `PendingAuthorizationStorage` — In-flight authorization tracking. Interactive requests have a single-use `awaiting` consent stage before upstream login; only an `approved` record can complete a callback. Each `PendingAuthorization` carries a `BrowserBindingHash`, the SHA-256 of a random cookie value set on the user agent. The consent POST and `/oauth/callback` require the matching cookie before consuming their respective records. Only the hash is stored; an older row without a binding or approval stage cannot complete the flow and must be restarted. `PendingDeviceLogin` carries the same browser binding for the separate device-flow verification-page login, which shares `/oauth/callback`.
- `RememberedConsentStorage` — Optional server-side browser sessions and one approval per `(userID, clientID)`. The browser holds only a random `__Host-thv_consent_<sha256>` secret; storage holds its SHA-256 digest. Each approval records the redirect URI, scopes, and resource selected for that client. Memory and Redis implement this capability; decorators expose the underlying backend via `storage.Unwrap`. See [Interactive client approval](#interactive-client-approval).
- `UserStorage` — Internal user accounts and provider identity linking
- `DCRCredentialStore` — DCR client metadata and credential persistence; intentionally NOT embedded in `Storage` (each backend implements it separately and call sites reach it via an explicit `stor.(DCRCredentialStore)` type assertion). Secret-based clients store hashed secrets; `private_key_jwt` clients store their inline public JWKS and signing algorithm. Outbound (upstream) registrations resolved by `pkg/auth/dcr` are keyed by `(Issuer, UpstreamID, RedirectURI, ScopesHash)` plus the optional operator-set `dcrConfig.rotationKey`. Changing the rotation key addresses a new row, so the next start registers a fresh upstream client; the previous row stays in place and carries no TTL unless the upstream asserted `client_secret_expires_at`. On a cache hit the resolver also checks the cached client at the upstream token endpoint and replaces the row when the upstream answers `invalid_client`, provided the store implements `dcr.Replacer`. The rotation key covers the upstreams where that check cannot tell a dropped registration from a live one: an upstream that validates the code before it looks up a public client, or a `private_key_jwt` client.
- `DCRCredentialStore` — DCR client metadata and credential persistence; intentionally NOT embedded in `Storage` (each backend implements it separately and call sites reach it via an explicit `stor.(DCRCredentialStore)` type assertion). Secret-based clients store hashed secrets; `private_key_jwt` clients store their inline public JWKS and signing algorithm. Outbound (upstream) registrations resolved by `pkg/auth/dcr` are keyed by `(Issuer, UpstreamID, RedirectURI, ScopesHash)` plus the optional operator-set `dcrConfig.rotationKey`. Changing the rotation key addresses a new row, so the next start registers a fresh upstream client; the previous row stays in place and carries no TTL unless the upstream asserted `client_secret_expires_at`. On a cache hit the resolver also checks the cached client against the upstream and replaces the row when the upstream rejects it, provided the store implements `dcr.Replacer`. `dcrConfig.registrationProbe` selects the check: `tokenEndpoint` (default) redeems a random authorization code and reads `invalid_client` as "gone"; `authorizationEndpoint` sends an authorization request without following redirects and reads a direct 400/401/403 carrying an RFC 6749 JSON error object with `error` `invalid_client`, `unauthorized_client` or `invalid_request` as "gone" (RFC 6749 §4.1.2.1); `none` skips the check. Each setting has a blind spot the rotation key covers: `tokenEndpoint` misses an upstream that validates the grant before authenticating a public client (it then answers `invalid_grant` for any client_id) and skips `private_key_jwt` clients, which it cannot sign for; `authorizationEndpoint` misses an upstream that renders its unknown-client error with 200, a redirect, or a page rather than a JSON error object; with `none` the rotation key is the only way to retire a registration. A false rejection by either probe re-registers at every start and, when registration also fails, fails startup.
- `AssertionJWTConsumer` — atomic single-use consumption for validated JWT assertions. It is deliberately separate from `Storage`, so only assertion-grant composition requires replay protection.

**Implementation:**
Expand Down
3 changes: 2 additions & 1 deletion docs/operator/crd-api.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 5 additions & 1 deletion docs/server/docs.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading