diff --git a/.github/upstream-projects.yaml b/.github/upstream-projects.yaml index 7a683733..0673e75c 100644 --- a/.github/upstream-projects.yaml +++ b/.github/upstream-projects.yaml @@ -44,7 +44,7 @@ projects: - id: toolhive repo: stacklok/toolhive - version: v0.42.1 + version: v0.43.0 # toolhive is a monorepo covering the CLI, the Kubernetes # operator, and the vMCP gateway. It also introduces cross- # cutting features that land in concepts/, integrations/, diff --git a/docs/toolhive/guides-cli/skills-management.mdx b/docs/toolhive/guides-cli/skills-management.mdx index 633a751f..cc452769 100644 --- a/docs/toolhive/guides-cli/skills-management.mdx +++ b/docs/toolhive/guides-cli/skills-management.mdx @@ -223,24 +223,71 @@ thv skill info This shows the skill's name, version, description, scope, status, source reference, installation date, and associated clients. -## Upgrade skills +## Reproduce and upgrade project-scoped skills -Skill upgrades are experimental and require `TOOLHIVE_SKILLS_LOCK_ENABLED=true` -on the ToolHive API server. Upgrade project-scoped skills from the project -directory: +Project-scoped installs record their source, resolved digest, and signer +identity in a `toolhive.lock.yaml` file at the project root. This lets you +reinstall the same skill content on another machine, detect drift in CI, and +upgrade to newer content when the catalog moves forward. User-scoped installs do +not participate in the lock file. + +### Restore skills from the lock file + +To reinstall the skills recorded in the lock file (for example, after cloning +the project on a new machine), run `thv skill sync` from the project directory: + +```bash +thv skill sync --project-root . +``` + +Missing or drifted skills are reinstalled at their pinned digest. Sync prompts +for confirmation before installing; pass `--yes` in non-interactive environments +such as CI. + +Use `--check` to report drift without installing or writing anything. This is +useful as a CI gate: + +```bash +thv skill sync --project-root . --check +``` + +If you have project-scoped skills that were installed before the lock file +existed, `--adopt` records lock entries for them so subsequent syncs include +them: + +```bash +thv skill sync --project-root . --adopt +``` + +Use `--prune` to remove installs that are no longer in the lock file. + +See the [`thv skill sync` command reference](../reference/cli/thv_skill_sync.md) +for all options. + +### Upgrade project-scoped skills + +Upgrade the project's skills to newer pinned content: ```bash thv skill upgrade --project-root . ``` A version or tag change within the same OCI repository proceeds without -`--allow-ref-change`, including a move to an older tag. ToolHive still pins the +`--allow-ref-change`, including a move to an older tag. ToolHive pins the resolved digest and blocks signer changes. If the catalog moves the skill to a different repository, organization, or registry, the upgrade is blocked. Review the new source, then repeat the command with `--allow-ref-change` if you intend to permit that repository move. ToolHive prompts before installing the planned changes; pass `--yes` in non-interactive environments. +Skills pinned to an immutable reference (an OCI digest or a full Git commit +hash) are reported as not upgradable, because there is nothing newer to resolve +to. + +Use `--preview` to see what would change without persisting anything, or +`--fail-on-changes` as a CI freshness gate that reports pending upgrades without +installing them. + See the [`thv skill upgrade` command reference](../reference/cli/thv_skill_upgrade.md) for all options. @@ -335,6 +382,24 @@ After building, push the artifact to a remote OCI registry: thv skill push ghcr.io/my-org/skills/my-skill:v1.0.0 ``` +`thv skill push` signs the pushed artifact by default. Pass `--key` to use a +cosign private key on disk, or `--no-sign` to push without signing: + +```bash +# Sign with a cosign key on disk. Set COSIGN_PASSWORD in the thv serve +# environment to decrypt an encrypted key. +thv skill push ghcr.io/my-org/skills/my-skill:v1.0.0 \ + --key cosign.key + +# Push without a signature +thv skill push ghcr.io/my-org/skills/my-skill:v1.0.0 --no-sign +``` + +Signatures let consumers verify who published a skill before installing it. When +a consumer installs an unsigned skill project-scoped, they must pass +`--allow-unsigned` to `thv skill install`. User-scoped installs do not enforce +signatures. + :::note Pushing to a remote registry uses your existing container registry credentials diff --git a/docs/toolhive/guides-k8s/embedded-auth-server-k8s.mdx b/docs/toolhive/guides-k8s/embedded-auth-server-k8s.mdx index f1144409..04c72a30 100644 --- a/docs/toolhive/guides-k8s/embedded-auth-server-k8s.mdx +++ b/docs/toolhive/guides-k8s/embedded-auth-server-k8s.mdx @@ -454,6 +454,88 @@ For the two-layer trust model behind CIMD (why the upstream IdP never sees the client's CIMD URL), see [Client ID Metadata Document (CIMD)](../concepts/embedded-auth-server.mdx#client-id-metadata-document-cimd). +### Allow confidential DCR clients + +By default, DCR only issues public clients: registrations must use +`token_endpoint_auth_method: "none"`, and the response carries no +`client_secret`. Set `allowConfidentialClientRegistration: true` to also accept +`client_secret_basic` and `client_secret_post` registrations and mint a real +`client_secret` (returned exactly once in the registration response): + +```yaml title="MCPExternalAuthConfig: confidential DCR" +spec: + embeddedAuthServer: + allowConfidentialClientRegistration: true +``` + +`/oauth/register` is unauthenticated, so enabling this lets any caller who can +reach the endpoint mint a client credential. Confidential registrations are +restricted to `https` non-loopback redirect URIs, and (with the Redis storage +backend) any DCR registration idle for more than 30 days is evicted and must +re-register. + +The following combinations are rejected at admission: + +- `allowConfidentialClientRegistration: true` alongside + `insecureAllowHTTP: true` - client secrets would be issued in cleartext. +- `allowConfidentialClientRegistration: true` with a plain-HTTP loopback issuer + (for example, `http://localhost:8080`). Set + `insecureAllowConfidentialOverLoopbackHTTP: true` to opt in to this + combination for local development, where the traffic never leaves the machine. + +Some MCP clients declare themselves public +(`token_endpoint_auth_method: "none"`) at registration but then refuse to +proceed because the response carries no `client_secret`. RFC 7591 permits the +server to substitute client metadata; `forceConfidentialRedirectUris` takes such +a client at its word and issues a real `client_secret` when its `redirect_uris` +contains an exact match for a listed URI: + +```yaml title="MCPExternalAuthConfig: force confidential for specific redirects" +spec: + embeddedAuthServer: + allowConfidentialClientRegistration: true + forceConfidentialRedirectUris: + - https://client.example.com/oauth/callback +``` + +Every entry must be an `https` non-loopback URI. Matching is exact. Remove an +entry once the client is fixed to handle public (`"none"`) registrations +correctly. + +### Pre-provision confidential clients for token exchange + +Confidential DCR is registration-driven and unauthenticated. For a client that +you control and know in advance (typically a backend service that performs RFC +8693 token exchange against the embedded auth server), pre-provision it with +`delegateClients` instead. Each entry references a Kubernetes Secret for the +client secret and narrows the audiences and scopes the client may request: + +```yaml title="MCPExternalAuthConfig: delegate clients" +spec: + embeddedAuthServer: + delegateClients: + - clientId: backend-exchange + clientSecretRef: + name: backend-exchange-client-secret + key: client-secret + audiences: + - https://backend.example.com + scopes: + - backend-api:read +``` + +`clientId`, `clientSecretRef`, `audiences`, and `scopes` are all required. +`audiences` and `scopes` narrow what the client can request at the token +endpoint; leaving them empty is not permitted because a declared client must not +receive every allowed audience or scope by default. + +Delegate clients are independent of `allowConfidentialClientRegistration`: +declaring one here does not enable self-service confidential DCR, and enabling +DCR does not seed any delegate client. The two features govern different +endpoints. The delegate-clients validation blocks a plaintext `http://` issuer +categorically at admission. The CEL admission rule cannot express the loopback +exception, so use an `https://` issuer whenever you configure delegate clients. + ### Enable baseline scopes for DCR clients Some MCP clients (for example, Claude Code) register via DCR with a narrowed diff --git a/docs/toolhive/guides-vmcp/embedded-auth-server-vmcp.mdx b/docs/toolhive/guides-vmcp/embedded-auth-server-vmcp.mdx index ad4dfd41..1a27d490 100644 --- a/docs/toolhive/guides-vmcp/embedded-auth-server-vmcp.mdx +++ b/docs/toolhive/guides-vmcp/embedded-auth-server-vmcp.mdx @@ -304,6 +304,56 @@ For an explanation of how ToolHive resolves CIMD documents and why the OAuth provider never sees the MCP client's CIMD URL, see [Client ID Metadata Document (CIMD)](../concepts/embedded-auth-server.mdx#client-id-metadata-document-cimd). +### Allow confidential DCR clients + +To configure a VirtualMCPServer for confidential DCR, set +`allowConfidentialClientRegistration: true`: + +```yaml title="VirtualMCPServer: confidential DCR" +spec: + authServerConfig: + allowConfidentialClientRegistration: true +``` + +To force confidential registration for clients that use listed redirect URIs, +add `forceConfidentialRedirectUris`: + +```yaml title="VirtualMCPServer: force confidential for specific redirects" +spec: + authServerConfig: + allowConfidentialClientRegistration: true + forceConfidentialRedirectUris: + - https://client.example.com/oauth/callback +``` + +For the configuration's validation rules, security implications, and behavior, +see +[Allow confidential DCR clients](../guides-k8s/embedded-auth-server-k8s.mdx#allow-confidential-dcr-clients). + +### Pre-provision confidential clients for token exchange + +To pre-provision a confidential client that performs RFC 8693 token exchange +against the vMCP embedded auth server (typically a backend service you control), +add a `delegateClients` entry: + +```yaml title="VirtualMCPServer: delegate clients" +spec: + authServerConfig: + delegateClients: + - clientId: backend-exchange + clientSecretRef: + name: backend-exchange-client-secret + key: client-secret + audiences: + - https://backend.example.com + scopes: + - backend-api:read +``` + +All four fields are required. Delegate clients are independent of +`allowConfidentialClientRegistration`. The issuer must use `https://`; delegate +clients are rejected at admission when the issuer is plaintext HTTP. + ### Enable baseline scopes for DCR clients If your MCP clients register via DCR with a narrowed `scope` value and then diff --git a/docs/toolhive/reference/cli/thv_skill.md b/docs/toolhive/reference/cli/thv_skill.md index 2717cddc..a97336a7 100644 --- a/docs/toolhive/reference/cli/thv_skill.md +++ b/docs/toolhive/reference/cli/thv_skill.md @@ -38,8 +38,8 @@ The skill command provides subcommands to manage skills. * [thv skill install](thv_skill_install.md) - Install a skill * [thv skill list](thv_skill_list.md) - List installed skills * [thv skill push](thv_skill_push.md) - Push a built skill -* [thv skill sync](thv_skill_sync.md) - Restore project skills to match the lock file (experimental) +* [thv skill sync](thv_skill_sync.md) - Restore project skills to match the lock file * [thv skill uninstall](thv_skill_uninstall.md) - Uninstall a skill -* [thv skill upgrade](thv_skill_upgrade.md) - Upgrade project skills to newer pinned content (experimental) +* [thv skill upgrade](thv_skill_upgrade.md) - Upgrade project skills to newer pinned content * [thv skill validate](thv_skill_validate.md) - Validate a skill definition diff --git a/docs/toolhive/reference/cli/thv_skill_push.md b/docs/toolhive/reference/cli/thv_skill_push.md index b370b1b7..9a34faa2 100644 --- a/docs/toolhive/reference/cli/thv_skill_push.md +++ b/docs/toolhive/reference/cli/thv_skill_push.md @@ -24,7 +24,9 @@ thv skill push [reference] [flags] ### Options ``` - -h, --help help for push + -h, --help help for push + --key string Path to a cosign private key to sign the pushed artifact. Encrypted keys are decrypted with COSIGN_PASSWORD read from the 'thv serve' process, which performs the signing + --no-sign Push without signing (consumers will need an explicit unsigned exception to install project-scoped) ``` ### Options inherited from parent commands diff --git a/docs/toolhive/reference/cli/thv_skill_sync.md b/docs/toolhive/reference/cli/thv_skill_sync.md index 8cf0f6be..d2f2f39b 100644 --- a/docs/toolhive/reference/cli/thv_skill_sync.md +++ b/docs/toolhive/reference/cli/thv_skill_sync.md @@ -11,15 +11,12 @@ mdx: ## thv skill sync -Restore project skills to match the lock file (experimental) +Restore project skills to match the lock file ### Synopsis Restore a project's installed skills to match toolhive.lock.yaml. -Experimental: requires TOOLHIVE_SKILLS_LOCK_ENABLED=true on the ToolHive -server while the lock file feature rolls out. - Missing or drifted skills are reinstalled at their pinned digest. Use --check to report drift without installing anything (suitable for CI). Use --adopt to record lock entries for existing unmanaged installs, and diff --git a/docs/toolhive/reference/cli/thv_skill_upgrade.md b/docs/toolhive/reference/cli/thv_skill_upgrade.md index 51059c3b..75076c28 100644 --- a/docs/toolhive/reference/cli/thv_skill_upgrade.md +++ b/docs/toolhive/reference/cli/thv_skill_upgrade.md @@ -11,15 +11,12 @@ mdx: ## thv skill upgrade -Upgrade project skills to newer pinned content (experimental) +Upgrade project skills to newer pinned content ### Synopsis Re-resolve a project's lock entries and install newer content where available. -Experimental: requires TOOLHIVE_SKILLS_LOCK_ENABLED=true on the ToolHive -server while the lock file feature rolls out. - Skills pinned to an immutable reference (an OCI digest or a full git commit hash) are reported not-upgradable — there is nothing newer to resolve to. Use --preview to see what would change without persisting anything (OCI diff --git a/static/api-specs/toolhive-api.yaml b/static/api-specs/toolhive-api.yaml index 5a8d3ad9..fde91175 100644 --- a/static/api-specs/toolhive-api.yaml +++ b/static/api-specs/toolhive-api.yaml @@ -407,6 +407,43 @@ components: server trusts. type: string type: object + github_com_stacklok_toolhive_pkg_authserver.DelegateClientRunConfig: + properties: + audiences: + description: |- + Audiences are the RFC 8707 resource values this client may request a + token for. Required, and must be a subset of RunConfig.AllowedAudiences: + a declared client must not receive every allowed audience just because + this was left empty. + items: + type: string + type: array + uniqueItems: false + client_id: + description: ClientID is the OAuth client_id this client presents at the + token endpoint. + type: string + client_secret_env_var: + description: |- + ClientSecretEnvVar is the name of an environment variable containing + the client secret. One of ClientSecretFile or ClientSecretEnvVar is + required. + type: string + client_secret_file: + description: |- + ClientSecretFile is the path to a file containing the client secret. + If both this and ClientSecretEnvVar are set, the file takes precedence. + type: string + scopes: + description: |- + Scopes are the OAuth scopes this client may request. Required, and + must be a subset of RunConfig.ScopesSupported: a declared client must + not receive every supported scope just because this was left empty. + items: + type: string + type: array + uniqueItems: false + type: object github_com_stacklok_toolhive_pkg_authserver.IdentityFromTokenRunConfig: description: |- IdentityFromToken extracts user identity (subject, name, email) directly from the @@ -580,6 +617,23 @@ components: When set, the proxy runner will start an embedded auth server that delegates to upstream IDPs. This is the serializable RunConfig; secrets are referenced by file paths or env var names. properties: + allow_confidential_client_registration: + description: |- + AllowConfidentialClientRegistration permits Dynamic Client Registration + of confidential clients: when true, /oauth/register accepts + token_endpoint_auth_method values client_secret_basic and + client_secret_post in addition to "none" (still the default on + omission) and mints a client_secret returned exactly once. Confidential + clients are restricted to https non-loopback redirect URIs, and + registrations idle for more than DefaultDCRClientTTL (30 days) are + evicted and must re-register. This gates registration only: disabling + it does not revoke or reject already-minted secrets at the token + endpoint. + + Security: /oauth/register is unauthenticated, so this issues client + secrets to any caller. Combining it with InsecureAllowHTTP is rejected + by Validate. + type: boolean allowed_audiences: description: |- AllowedAudiences is the list of valid resource URIs that tokens can be issued for. @@ -612,6 +666,24 @@ components: uniqueItems: false cimd: $ref: '#/components/schemas/github_com_stacklok_toolhive_pkg_authserver.CIMDRunConfig' + delegate_clients: + description: |- + DelegateClients declares confidential OAuth clients to register at + authorization-server startup, including clients intended for RFC 8693 + token exchange. + + Independent of AllowConfidentialClientRegistration: declaring a client + here does not require or enable self-service confidential DCR, and + setting that flag does not declare or enable any client here. They + govern different endpoints — this field is static configuration the + operator controls directly, while the flag is admission policy for the + unauthenticated /oauth/register endpoint. + + See DelegateClientRunConfig for the per-client field reference. + items: + $ref: '#/components/schemas/github_com_stacklok_toolhive_pkg_authserver.DelegateClientRunConfig' + type: array + uniqueItems: false delegation_token_lifespan: description: |- DelegationTokenLifespan is the maximum lifetime for delegated tokens issued @@ -627,6 +699,39 @@ components: backend receives an unauthenticated request. Incompatible with token exchange and AWS STS, which would re-add credentials after the strip. type: boolean + force_confidential_redirect_uris: + description: |- + ForceConfidentialRedirectURIs lists redirect URIs that must be registered + as confidential clients regardless of the token_endpoint_auth_method the + DCR request declares. A registration whose redirect_uris contains an + EXACT match for one of these entries is issued a real client_secret and + reported back as token_endpoint_auth_method "client_secret_post", even + if the request said "none" or omitted the field. + + This exists for MCP clients (Perplexity is the known case) that declare + themselves public (token_endpoint_auth_method: "none") per RFC 7591 but + then refuse to proceed because the response carries no client_secret — + a self-contradictory request no conformant server can satisfy as + written. RFC 7591 §3.2.1 permits the server to substitute metadata, so + this takes such a client at its word that it wants a secret. + + Exact matching is deliberate: it is not a way to obtain a usable + credential for another client. An attacker who registers with someone + else's callback URI is issued a secret for a client whose authorization + codes are delivered to that someone else's redirect endpoint, not to + the attacker — the secret is useless without also controlling the + callback. + + Requires AllowConfidentialClientRegistration; every entry must be a + valid https non-loopback URI (Validate rejects loopback entries — the + same restriction AllowConfidentialClientRegistration itself enforces + exists so secrets do not land in distributed native apps, and this + override must not bypass it). Remove an entry once the client is fixed + to handle "none" registrations correctly. + items: + type: string + type: array + uniqueItems: false hmac_secret_files: description: |- HMACSecretFiles contains file paths to HMAC secrets for signing authorization codes @@ -638,6 +743,20 @@ components: type: string type: array uniqueItems: false + insecure_allow_confidential_over_loopback_http: + description: |- + InsecureAllowConfidentialOverLoopbackHTTP opts in to confidential clients + when Issuer is a plain-HTTP loopback URL. Without this flag, that + combination is rejected: a loopback http:// issuer is normally fine for + local development (the traffic never leaves the machine), but client + secrets would otherwise travel over cleartext. Defaults to false. Has no + effect when there are no confidential clients or Issuer is https. + + Applies identically to delegate clients and DCR-registered clients; the + Kubernetes CRD blocks this combination unconditionally only because CEL + cannot express the loopback exception, not because delegate clients need + a stricter policy — see EmbeddedAuthServerConfig's doc comment. + type: boolean insecure_allow_http: description: |- InsecureAllowHTTP permits an http:// issuer URL for non-localhost hosts. @@ -673,13 +792,6 @@ components: subject tokens during RFC 8693 token exchange. Empty (the default) means only self-issued subject tokens are accepted. - Prerequisite: the token-exchange grant requires a confidential client, - and no supported deployment path provisions one today (DCR and CIMD - clients are both public-only, and there is no client-seeding field on - this RunConfig), so this grant is not yet usable end to end for - self-issued or external subject tokens alike. Tracked in - https://github.com/stacklok/toolhive/issues/6082. - See tokenexchange.TrustedIssuer for the per-issuer field reference, and docs/arch/17-token-exchange-delegation.md for the trust model, consent signals, and operator-facing constraints (audience/scope bounding, @@ -1113,6 +1225,13 @@ components: installed_at: description: InstalledAt is the timestamp when the plugin was installed. type: string + managed: + description: |- + Managed indicates this install is tracked in the project's + toolhive.lock.yaml plugins: key. Only ever true for project-scoped + installs. No omitempty: false is an observable state (unmanaged), + not an absence. + type: boolean metadata: $ref: '#/components/schemas/github_com_stacklok_toolhive_pkg_plugins.PluginMetadata' project_root: @@ -1712,6 +1831,7 @@ components: - lock-write-failed - signature-invalid - signer-mismatch + - provenance-field-mismatch - unsigned-rejected - unknown type: string @@ -1722,6 +1842,7 @@ components: - FailureReasonLockWriteFailed - FailureReasonSignatureInvalid - FailureReasonSignerMismatch + - FailureReasonProvenanceFieldMismatch - FailureReasonUnsignedRejected - FailureReasonUnknown github_com_stacklok_toolhive_pkg_skills.InstallStatus: @@ -1802,6 +1923,45 @@ components: if available. type: string type: object + github_com_stacklok_toolhive_pkg_skills.ProvenanceInfo: + description: |- + Provenance is the signer identity the project's lock file records + for this skill, when project-scoped and lock-managed. + properties: + cert_issuer: + description: CertIssuer is the OIDC issuer that authenticated the signer. + type: string + provisional: + description: |- + Provisional marks provenance with a documented verification gap + (git signatures until transparency-log validation lands). + type: boolean + repository_ref: + description: |- + RepositoryRef is the git ref the signing workflow ran on, from Fulcio + certificate extension 1.3.6.1.4.1.57264.1.14. Empty means + unconstrained, matching lock files written before the field existed. + type: string + repository_uri: + description: |- + RepositoryURI is the source repository from the certificate + extensions, when present. + type: string + runner_environment: + description: |- + RunnerEnvironment is the runner class the signing workflow executed in + (e.g. "github-hosted"), from Fulcio certificate extension + 1.3.6.1.4.1.57264.1.11. Empty means unconstrained. + type: string + signer_identity: + description: |- + SignerIdentity is the certificate subject identity (workflow path for + GitHub Actions certificates, SAN verbatim otherwise). + type: string + sigstore_url: + description: SigstoreURL is the Sigstore instance the signature chains to. + type: string + type: object github_com_stacklok_toolhive_pkg_skills.Scope: description: Scope for the installation enum: @@ -1851,6 +2011,13 @@ components: $ref: '#/components/schemas/github_com_stacklok_toolhive_pkg_skills.InstalledSkill' metadata: $ref: '#/components/schemas/github_com_stacklok_toolhive_pkg_skills.SkillMetadata' + provenance: + $ref: '#/components/schemas/github_com_stacklok_toolhive_pkg_skills.ProvenanceInfo' + unsigned: + description: |- + Unsigned reports that the lock file records an explicit unsigned + exception for this skill. + type: boolean type: object github_com_stacklok_toolhive_pkg_skills.SkillMetadata: description: Metadata contains the skill's metadata. @@ -2958,8 +3125,13 @@ components: pkg_api_v1.installSkillResponse: description: Response after successfully installing a skill properties: + provenance: + $ref: '#/components/schemas/github_com_stacklok_toolhive_pkg_skills.ProvenanceInfo' skill: $ref: '#/components/schemas/github_com_stacklok_toolhive_pkg_skills.InstalledSkill' + unsigned: + description: Whether the install was recorded as an explicit unsigned exception. + type: boolean type: object pkg_api_v1.listSecretsResponse: description: Response containing a list of secret keys @@ -3089,6 +3261,14 @@ components: pkg_api_v1.pushSkillRequest: description: Request to push a built skill artifact properties: + key: + description: |- + Key is the path to a cosign private key used to sign the pushed + artifact + type: string + no_sign: + description: NoSign pushes without signing + type: boolean reference: description: OCI reference to push type: string diff --git a/static/api-specs/toolhive-crds/mcpexternalauthconfigs.schema.json b/static/api-specs/toolhive-crds/mcpexternalauthconfigs.schema.json index d5ad9f87..da0c4268 100644 --- a/static/api-specs/toolhive-crds/mcpexternalauthconfigs.schema.json +++ b/static/api-specs/toolhive-crds/mcpexternalauthconfigs.schema.json @@ -127,6 +127,11 @@ "embeddedAuthServer": { "description": "EmbeddedAuthServer configures an embedded OAuth2/OIDC authorization server\nOnly used when Type is \"embeddedAuthServer\"", "properties": { + "allowConfidentialClientRegistration": { + "default": false, + "description": "AllowConfidentialClientRegistration permits RFC 7591 Dynamic Client\nRegistration of confidential clients: when true, /oauth/register\naccepts token_endpoint_auth_method values client_secret_basic and\nclient_secret_post in addition to \"none\" (still the default on\nomission) and mints a client_secret returned exactly once.\nConfidential registrations are restricted to https non-loopback\nredirect URIs, and on the Redis storage backend all DCR-issued\nregistrations are evicted after 30 days of inactivity and must\nre-register. This gates registration only: disabling it does not\nrevoke or reject already-minted secrets at the token endpoint.\n\nSecurity: registration is unauthenticated, so enabling this lets any\ncaller who can reach the endpoint obtain a client credential.\nCombining it with insecureAllowHTTP is rejected at validation.", + "type": "boolean" + }, "authorizationEndpointBaseUrl": { "description": "AuthorizationEndpointBaseURL overrides the base URL used for the authorization_endpoint\nin the OAuth discovery document. When set, the discovery document will advertise\n`{authorizationEndpointBaseUrl}/oauth/authorize` instead of `{issuer}/oauth/authorize`.\nAll other endpoints (token, registration, JWKS) remain derived from the issuer.\nThis is useful when the browser-facing authorization endpoint needs to be on a\ndifferent host than the issuer used for backend-to-backend calls.\nMust be a valid HTTPS URL (or HTTP for localhost, or HTTP for trusted in-cluster hosts\nwhen insecureAllowHTTP is true) without query, fragment, or trailing slash.", "pattern": "^https?://[^\\s?#]+[^/\\s?#]$", @@ -167,11 +172,93 @@ ], "type": "object" }, + "delegateClients": { + "description": "DelegateClients configures pre-provisioned confidential clients for RFC 8693\ntoken exchange. Each secret is referenced from a Kubernetes Secret; no\nplaintext secret, redirect URI, or grant selection is accepted here. The\noperator always supplies the token-exchange grant when it converts this\nconfiguration to the runtime contract.\n\nThis is independent of allowConfidentialClientRegistration: it neither\nenables nor requires unauthenticated confidential dynamic client\nregistration.", + "items": { + "description": "DelegateClientConfig configures a pre-provisioned confidential OAuth client\nfor RFC 8693 token exchange. Its secret is referenced from a Kubernetes\nSecret and is never represented inline.", + "properties": { + "audiences": { + "description": "Audiences is the narrowed set of RFC 8707 resources this client may request.", + "items": { + "maxLength": 2048, + "minLength": 1, + "type": "string" + }, + "maxItems": 10, + "minItems": 1, + "type": "array", + "x-kubernetes-list-type": "atomic" + }, + "clientId": { + "description": "ClientID is the OAuth client_id presented at the token endpoint.", + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "clientSecretRef": { + "description": "ClientSecretRef references the Kubernetes Secret key containing the client secret.", + "properties": { + "key": { + "description": "Key is the key within the secret", + "type": "string" + }, + "name": { + "description": "Name is the name of the secret", + "type": "string" + } + }, + "required": [ + "key", + "name" + ], + "type": "object" + }, + "scopes": { + "description": "Scopes is the narrowed set of OAuth scopes this client may request.", + "items": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "maxItems": 10, + "minItems": 1, + "type": "array", + "x-kubernetes-list-type": "atomic" + } + }, + "required": [ + "audiences", + "clientId", + "clientSecretRef", + "scopes" + ], + "type": "object", + "x-kubernetes-validations": [ + { + "message": "clientSecretRef.name and clientSecretRef.key are required and must be non-empty", + "rule": "has(self.clientSecretRef) && size(self.clientSecretRef.name) > 0 && size(self.clientSecretRef.key) > 0" + } + ] + }, + "maxItems": 10, + "type": "array", + "x-kubernetes-list-type": "atomic" + }, "disableUpstreamTokenInjection": { "default": false, "description": "DisableUpstreamTokenInjection prevents the embedded auth server from injecting\nupstream IdP tokens into requests forwarded to the backend MCP server.\nWhen true, the embedded auth server still handles OAuth flows for clients,\nbut instead of swapping ToolHive JWTs for upstream tokens the proxy STRIPS\nthe client's credential headers (Authorization, Cookie, Proxy-Authorization)\nafter validating the JWT — the backend receives an unauthenticated request.\nUse headerForward to attach static credentials (e.g. an API key) if the\nbackend needs them. Cannot be combined with token exchange or AWS STS,\nwhich would re-add credentials after the strip.\nThis is useful when the backend MCP server does not require authentication\n(e.g., public documentation servers) but you still want client authentication.", "type": "boolean" }, + "forceConfidentialRedirectUris": { + "description": "ForceConfidentialRedirectURIs lists redirect URIs that must be\nregistered as confidential clients regardless of the\ntoken_endpoint_auth_method the DCR request declares. A registration\nwhose redirectUris contains an EXACT match for one of these entries is\nissued a real client_secret and reported back as\ntoken_endpoint_auth_method \"client_secret_post\", even if the request\nsaid \"none\" or omitted the field.\n\nIntended for MCP clients that declare themselves public\n(token_endpoint_auth_method: \"none\") per RFC 7591 but then refuse to\nproceed because the response carries no client_secret — a\nself-contradictory request. RFC 7591 §3.2.1 permits the server to\nsubstitute client metadata, so this takes such a client at its word\nthat it wants a secret. Remove an entry once the client is fixed to\nhandle \"none\" registrations correctly.\n\nExact matching is deliberate: an attacker who registers with someone\nelse's callback URI is issued a secret for a client whose\nauthorization codes are delivered to that someone else's redirect\nendpoint, not to the attacker, so this is not a way to obtain a usable\ncredential for another client.\n\nRequires allowConfidentialClientRegistration to be true. Every entry\nmust be an https non-loopback URI — a loopback client is a public\nclient by construction (OAuth 2.1 §2.1) and must not be issued a\nsecret; this is enforced at reconcile time since CEL cannot express\nthe loopback-hostname check.", + "items": { + "pattern": "^https://[^\\s?#]+$", + "type": "string" + }, + "maxItems": 10, + "type": "array", + "x-kubernetes-list-type": "atomic" + }, "hmacSecretRefs": { "description": "HMACSecretRefs references Kubernetes Secrets containing symmetric secrets for signing\nauthorization codes and refresh tokens (opaque tokens).\nCurrent secret must be at least 32 bytes and cryptographically random.\nSupports secret rotation via multiple entries (first is current, rest are for verification).\nIf not specified, an ephemeral secret will be auto-generated (development only -\nauth codes and refresh tokens will be invalid after restart).", "items": { @@ -195,9 +282,14 @@ "type": "array", "x-kubernetes-list-type": "atomic" }, + "insecureAllowConfidentialOverLoopbackHTTP": { + "default": false, + "description": "InsecureAllowConfidentialOverLoopbackHTTP opts in to\nallowConfidentialClientRegistration when issuer is a plain-HTTP loopback\nURL (e.g. \"http://localhost:8080\"). Without this flag, that combination\nis rejected at reconcile time: a loopback http:// issuer is normally\nfine for local development since the traffic never leaves the machine,\nbut combined with confidential registration it means /oauth/register —\nwhich is unauthenticated — mints client secrets over cleartext. Forcing\nTLS onto every loopback deployment instead would just push operators\ntoward insecureAllowHTTP, which is worse: that also disables the\nnon-loopback host check. Has no effect when\nallowConfidentialClientRegistration is false or issuer is https.", + "type": "boolean" + }, "insecureAllowHTTP": { "default": false, - "description": "InsecureAllowHTTP permits an http:// issuer URL for non-localhost hosts.\nOnly set this for in-cluster Kubernetes deployments where traffic between\npods traverses a trusted network (e.g. the in-cluster service mesh).\nProduction deployments reachable outside the cluster MUST use https://.\n\nOn VirtualMCPServer: when false (the default), http:// issuers for non-localhost\nhosts are rejected at reconcile time with an AuthServerConfigValidated=False condition.\n\nOn MCPServer and MCPRemoteProxy (via MCPExternalAuthConfig): this field is\nstructurally present but enforcement is deferred to pod startup via Config.Validate();\na misconfigured issuer will cause the pod to crash at startup rather than surface\nas an operator condition.", + "description": "InsecureAllowHTTP permits an http:// issuer URL for non-localhost hosts.\nOnly set this for in-cluster Kubernetes deployments where traffic between\npods traverses a trusted network (e.g. the in-cluster service mesh).\nProduction deployments reachable outside the cluster MUST use https://.\n\nOn VirtualMCPServer: when false (the default), http:// issuers for non-localhost\nhosts are rejected at reconcile time with an AuthServerConfigValidated=False condition.\n\nOn MCPServer and MCPRemoteProxy (via MCPExternalAuthConfig): this field is\nstructurally present but enforcement is deferred to pod startup via Config.Validate();\na misconfigured issuer will cause the pod to crash at startup rather than surface\nas an operator condition.\n\nOne combination is rejected at admission on all three CRDs regardless of the\nabove: setting this field alongside allowConfidentialClientRegistration, which\nwould issue client secrets in cleartext over an unauthenticated registration\nendpoint (see the XValidation rule on EmbeddedAuthServerConfig).", "type": "boolean" }, "issuer": { @@ -491,6 +583,10 @@ "maxProperties": 16, "type": "object" }, + "allowPrivateIPs": { + "description": "AllowPrivateIPs permits the upstream provider's HTTP client to connect to\nprivate IP ranges (RFC-1918, link-local). Use only when the upstream is\nhosted inside the same cluster and has no public endpoint. HTTP-scheme\nrestrictions are unchanged — HTTPS is still required for non-localhost\nhosts unless InsecureAllowHTTP is set. Defaults to false.", + "type": "boolean" + }, "authorizationEndpoint": { "description": "AuthorizationEndpoint is the URL for the OAuth authorization endpoint.", "pattern": "^https?://.*$", @@ -597,6 +693,10 @@ ], "type": "object" }, + "insecureAllowHTTP": { + "description": "InsecureAllowHTTP permits plain-HTTP authorization and token endpoint URLs\nfor this upstream. Only for in-cluster development environments (e.g. an\nOAuth2 provider served over HTTP in a kind cluster) where TLS is not\navailable. Never set this in production.", + "type": "boolean" + }, "redirectUri": { "description": "RedirectURI is the callback URL where the upstream IdP will redirect after authentication.\nWhen not specified, defaults to `{resourceUrl}/oauth/callback` where `resourceUrl` is the\nURL associated with the resource (e.g., MCPServer or vMCP) using this config.", "type": "string" @@ -871,7 +971,21 @@ "issuer", "upstreamProviders" ], - "type": "object" + "type": "object", + "x-kubernetes-validations": [ + { + "message": "allowConfidentialClientRegistration cannot be combined with insecureAllowHTTP; client secrets would be issued in cleartext over an unauthenticated endpoint", + "rule": "!(has(self.allowConfidentialClientRegistration) && self.allowConfidentialClientRegistration && has(self.insecureAllowHTTP) && self.insecureAllowHTTP)" + }, + { + "message": "delegateClients require an https:// issuer; delegate client secrets must not be sent over plaintext HTTP", + "rule": "!has(self.delegateClients) || size(self.delegateClients) == 0 || !self.issuer.startsWith('http://')" + }, + { + "message": "forceConfidentialRedirectUris requires allowConfidentialClientRegistration to be true", + "rule": "(!has(self.forceConfidentialRedirectUris) || size(self.forceConfidentialRedirectUris) == 0) || (has(self.allowConfidentialClientRegistration) && self.allowConfidentialClientRegistration)" + } + ] }, "headerInjection": { "description": "HeaderInjection configures custom HTTP header injection\nOnly used when Type is \"headerInjection\"", diff --git a/static/api-specs/toolhive-crds/virtualmcpservers.schema.json b/static/api-specs/toolhive-crds/virtualmcpservers.schema.json index 656db8fb..049977ab 100644 --- a/static/api-specs/toolhive-crds/virtualmcpservers.schema.json +++ b/static/api-specs/toolhive-crds/virtualmcpservers.schema.json @@ -18,6 +18,11 @@ "authServerConfig": { "description": "AuthServerConfig configures an embedded OAuth authorization server.\nWhen set, the vMCP server acts as an OIDC issuer, drives users through\nupstream IDPs, and issues ToolHive JWTs. The embedded AS becomes the\nIncomingAuth OIDC provider — its issuer must match IncomingAuth.OIDCConfigRef\nso that tokens it issues are accepted by the vMCP's incoming auth middleware.\nWhen nil, IncomingAuth uses an external IDP and behavior is unchanged.", "properties": { + "allowConfidentialClientRegistration": { + "default": false, + "description": "AllowConfidentialClientRegistration permits RFC 7591 Dynamic Client\nRegistration of confidential clients: when true, /oauth/register\naccepts token_endpoint_auth_method values client_secret_basic and\nclient_secret_post in addition to \"none\" (still the default on\nomission) and mints a client_secret returned exactly once.\nConfidential registrations are restricted to https non-loopback\nredirect URIs, and on the Redis storage backend all DCR-issued\nregistrations are evicted after 30 days of inactivity and must\nre-register. This gates registration only: disabling it does not\nrevoke or reject already-minted secrets at the token endpoint.\n\nSecurity: registration is unauthenticated, so enabling this lets any\ncaller who can reach the endpoint obtain a client credential.\nCombining it with insecureAllowHTTP is rejected at validation.", + "type": "boolean" + }, "authorizationEndpointBaseUrl": { "description": "AuthorizationEndpointBaseURL overrides the base URL used for the authorization_endpoint\nin the OAuth discovery document. When set, the discovery document will advertise\n`{authorizationEndpointBaseUrl}/oauth/authorize` instead of `{issuer}/oauth/authorize`.\nAll other endpoints (token, registration, JWKS) remain derived from the issuer.\nThis is useful when the browser-facing authorization endpoint needs to be on a\ndifferent host than the issuer used for backend-to-backend calls.\nMust be a valid HTTPS URL (or HTTP for localhost, or HTTP for trusted in-cluster hosts\nwhen insecureAllowHTTP is true) without query, fragment, or trailing slash.", "pattern": "^https?://[^\\s?#]+[^/\\s?#]$", @@ -58,11 +63,93 @@ ], "type": "object" }, + "delegateClients": { + "description": "DelegateClients configures pre-provisioned confidential clients for RFC 8693\ntoken exchange. Each secret is referenced from a Kubernetes Secret; no\nplaintext secret, redirect URI, or grant selection is accepted here. The\noperator always supplies the token-exchange grant when it converts this\nconfiguration to the runtime contract.\n\nThis is independent of allowConfidentialClientRegistration: it neither\nenables nor requires unauthenticated confidential dynamic client\nregistration.", + "items": { + "description": "DelegateClientConfig configures a pre-provisioned confidential OAuth client\nfor RFC 8693 token exchange. Its secret is referenced from a Kubernetes\nSecret and is never represented inline.", + "properties": { + "audiences": { + "description": "Audiences is the narrowed set of RFC 8707 resources this client may request.", + "items": { + "maxLength": 2048, + "minLength": 1, + "type": "string" + }, + "maxItems": 10, + "minItems": 1, + "type": "array", + "x-kubernetes-list-type": "atomic" + }, + "clientId": { + "description": "ClientID is the OAuth client_id presented at the token endpoint.", + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "clientSecretRef": { + "description": "ClientSecretRef references the Kubernetes Secret key containing the client secret.", + "properties": { + "key": { + "description": "Key is the key within the secret", + "type": "string" + }, + "name": { + "description": "Name is the name of the secret", + "type": "string" + } + }, + "required": [ + "key", + "name" + ], + "type": "object" + }, + "scopes": { + "description": "Scopes is the narrowed set of OAuth scopes this client may request.", + "items": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "maxItems": 10, + "minItems": 1, + "type": "array", + "x-kubernetes-list-type": "atomic" + } + }, + "required": [ + "audiences", + "clientId", + "clientSecretRef", + "scopes" + ], + "type": "object", + "x-kubernetes-validations": [ + { + "message": "clientSecretRef.name and clientSecretRef.key are required and must be non-empty", + "rule": "has(self.clientSecretRef) && size(self.clientSecretRef.name) > 0 && size(self.clientSecretRef.key) > 0" + } + ] + }, + "maxItems": 10, + "type": "array", + "x-kubernetes-list-type": "atomic" + }, "disableUpstreamTokenInjection": { "default": false, "description": "DisableUpstreamTokenInjection prevents the embedded auth server from injecting\nupstream IdP tokens into requests forwarded to the backend MCP server.\nWhen true, the embedded auth server still handles OAuth flows for clients,\nbut instead of swapping ToolHive JWTs for upstream tokens the proxy STRIPS\nthe client's credential headers (Authorization, Cookie, Proxy-Authorization)\nafter validating the JWT — the backend receives an unauthenticated request.\nUse headerForward to attach static credentials (e.g. an API key) if the\nbackend needs them. Cannot be combined with token exchange or AWS STS,\nwhich would re-add credentials after the strip.\nThis is useful when the backend MCP server does not require authentication\n(e.g., public documentation servers) but you still want client authentication.", "type": "boolean" }, + "forceConfidentialRedirectUris": { + "description": "ForceConfidentialRedirectURIs lists redirect URIs that must be\nregistered as confidential clients regardless of the\ntoken_endpoint_auth_method the DCR request declares. A registration\nwhose redirectUris contains an EXACT match for one of these entries is\nissued a real client_secret and reported back as\ntoken_endpoint_auth_method \"client_secret_post\", even if the request\nsaid \"none\" or omitted the field.\n\nIntended for MCP clients that declare themselves public\n(token_endpoint_auth_method: \"none\") per RFC 7591 but then refuse to\nproceed because the response carries no client_secret — a\nself-contradictory request. RFC 7591 §3.2.1 permits the server to\nsubstitute client metadata, so this takes such a client at its word\nthat it wants a secret. Remove an entry once the client is fixed to\nhandle \"none\" registrations correctly.\n\nExact matching is deliberate: an attacker who registers with someone\nelse's callback URI is issued a secret for a client whose\nauthorization codes are delivered to that someone else's redirect\nendpoint, not to the attacker, so this is not a way to obtain a usable\ncredential for another client.\n\nRequires allowConfidentialClientRegistration to be true. Every entry\nmust be an https non-loopback URI — a loopback client is a public\nclient by construction (OAuth 2.1 §2.1) and must not be issued a\nsecret; this is enforced at reconcile time since CEL cannot express\nthe loopback-hostname check.", + "items": { + "pattern": "^https://[^\\s?#]+$", + "type": "string" + }, + "maxItems": 10, + "type": "array", + "x-kubernetes-list-type": "atomic" + }, "hmacSecretRefs": { "description": "HMACSecretRefs references Kubernetes Secrets containing symmetric secrets for signing\nauthorization codes and refresh tokens (opaque tokens).\nCurrent secret must be at least 32 bytes and cryptographically random.\nSupports secret rotation via multiple entries (first is current, rest are for verification).\nIf not specified, an ephemeral secret will be auto-generated (development only -\nauth codes and refresh tokens will be invalid after restart).", "items": { @@ -86,9 +173,14 @@ "type": "array", "x-kubernetes-list-type": "atomic" }, + "insecureAllowConfidentialOverLoopbackHTTP": { + "default": false, + "description": "InsecureAllowConfidentialOverLoopbackHTTP opts in to\nallowConfidentialClientRegistration when issuer is a plain-HTTP loopback\nURL (e.g. \"http://localhost:8080\"). Without this flag, that combination\nis rejected at reconcile time: a loopback http:// issuer is normally\nfine for local development since the traffic never leaves the machine,\nbut combined with confidential registration it means /oauth/register —\nwhich is unauthenticated — mints client secrets over cleartext. Forcing\nTLS onto every loopback deployment instead would just push operators\ntoward insecureAllowHTTP, which is worse: that also disables the\nnon-loopback host check. Has no effect when\nallowConfidentialClientRegistration is false or issuer is https.", + "type": "boolean" + }, "insecureAllowHTTP": { "default": false, - "description": "InsecureAllowHTTP permits an http:// issuer URL for non-localhost hosts.\nOnly set this for in-cluster Kubernetes deployments where traffic between\npods traverses a trusted network (e.g. the in-cluster service mesh).\nProduction deployments reachable outside the cluster MUST use https://.\n\nOn VirtualMCPServer: when false (the default), http:// issuers for non-localhost\nhosts are rejected at reconcile time with an AuthServerConfigValidated=False condition.\n\nOn MCPServer and MCPRemoteProxy (via MCPExternalAuthConfig): this field is\nstructurally present but enforcement is deferred to pod startup via Config.Validate();\na misconfigured issuer will cause the pod to crash at startup rather than surface\nas an operator condition.", + "description": "InsecureAllowHTTP permits an http:// issuer URL for non-localhost hosts.\nOnly set this for in-cluster Kubernetes deployments where traffic between\npods traverses a trusted network (e.g. the in-cluster service mesh).\nProduction deployments reachable outside the cluster MUST use https://.\n\nOn VirtualMCPServer: when false (the default), http:// issuers for non-localhost\nhosts are rejected at reconcile time with an AuthServerConfigValidated=False condition.\n\nOn MCPServer and MCPRemoteProxy (via MCPExternalAuthConfig): this field is\nstructurally present but enforcement is deferred to pod startup via Config.Validate();\na misconfigured issuer will cause the pod to crash at startup rather than surface\nas an operator condition.\n\nOne combination is rejected at admission on all three CRDs regardless of the\nabove: setting this field alongside allowConfidentialClientRegistration, which\nwould issue client secrets in cleartext over an unauthenticated registration\nendpoint (see the XValidation rule on EmbeddedAuthServerConfig).", "type": "boolean" }, "issuer": { @@ -382,6 +474,10 @@ "maxProperties": 16, "type": "object" }, + "allowPrivateIPs": { + "description": "AllowPrivateIPs permits the upstream provider's HTTP client to connect to\nprivate IP ranges (RFC-1918, link-local). Use only when the upstream is\nhosted inside the same cluster and has no public endpoint. HTTP-scheme\nrestrictions are unchanged — HTTPS is still required for non-localhost\nhosts unless InsecureAllowHTTP is set. Defaults to false.", + "type": "boolean" + }, "authorizationEndpoint": { "description": "AuthorizationEndpoint is the URL for the OAuth authorization endpoint.", "pattern": "^https?://.*$", @@ -488,6 +584,10 @@ ], "type": "object" }, + "insecureAllowHTTP": { + "description": "InsecureAllowHTTP permits plain-HTTP authorization and token endpoint URLs\nfor this upstream. Only for in-cluster development environments (e.g. an\nOAuth2 provider served over HTTP in a kind cluster) where TLS is not\navailable. Never set this in production.", + "type": "boolean" + }, "redirectUri": { "description": "RedirectURI is the callback URL where the upstream IdP will redirect after authentication.\nWhen not specified, defaults to `{resourceUrl}/oauth/callback` where `resourceUrl` is the\nURL associated with the resource (e.g., MCPServer or vMCP) using this config.", "type": "string" @@ -762,7 +862,21 @@ "issuer", "upstreamProviders" ], - "type": "object" + "type": "object", + "x-kubernetes-validations": [ + { + "message": "allowConfidentialClientRegistration cannot be combined with insecureAllowHTTP; client secrets would be issued in cleartext over an unauthenticated endpoint", + "rule": "!(has(self.allowConfidentialClientRegistration) && self.allowConfidentialClientRegistration && has(self.insecureAllowHTTP) && self.insecureAllowHTTP)" + }, + { + "message": "delegateClients require an https:// issuer; delegate client secrets must not be sent over plaintext HTTP", + "rule": "!has(self.delegateClients) || size(self.delegateClients) == 0 || !self.issuer.startsWith('http://')" + }, + { + "message": "forceConfidentialRedirectUris requires allowConfidentialClientRegistration to be true", + "rule": "(!has(self.forceConfidentialRedirectUris) || size(self.forceConfidentialRedirectUris) == 0) || (has(self.allowConfidentialClientRegistration) && self.allowConfidentialClientRegistration)" + } + ] }, "config": { "description": "Config is the Virtual MCP server configuration.\nThe audit config from here is also supported, but not required.",