From 2d44676ac777b8dcc5a8c87db9a397ed96008214 Mon Sep 17 00:00:00 2001 From: Trey Date: Sat, 27 Jun 2026 11:28:11 -0700 Subject: [PATCH 1/4] Expose InsecureAllowHTTP on EmbeddedAuthServerConfig for VirtualMCPServer Implements changes for issue #5668: - Add InsecureAllowHTTP bool to EmbeddedAuthServerConfig CRD field so deployers can explicitly opt in to http:// issuers for in-cluster Kubernetes deployments on trusted pod networks - Add admission-time validation in validateAuthServerConfig that sets AuthServerConfigValidated=False when an http:// non-localhost issuer is used without InsecureAllowHTTP, surfacing the error via operator conditions instead of a pod startup crash - Add InsecureAllowHTTP to authserver.RunConfig and authserver.Config; update validateIssuerURL to accept and respect the flag - Wire InsecureAllowHTTP from the CRD field through BuildAuthServerRunConfig and the embedded authserver runner into Config.Validate() - Regenerate deepcopy, CRD YAML manifests, and CRD API docs Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .../v1beta1/mcpexternalauthconfig_types.go | 13 ++- .../virtualmcpserver_controller.go | 30 +++++- .../virtualmcpserver_controller_test.go | 100 ++++++++++++++++++ .../pkg/controllerutil/authserver.go | 4 + ...e.stacklok.dev_mcpexternalauthconfigs.yaml | 26 ++++- ...olhive.stacklok.dev_virtualmcpservers.yaml | 26 ++++- ...e.stacklok.dev_mcpexternalauthconfigs.yaml | 26 ++++- ...olhive.stacklok.dev_virtualmcpservers.yaml | 26 ++++- docs/operator/crd-api.md | 3 +- pkg/authserver/config.go | 24 ++++- pkg/authserver/config_test.go | 26 +++-- pkg/authserver/runner/embeddedauthserver.go | 1 + pkg/vmcp/auth/types/zz_generated.deepcopy.go | 50 ++++----- 13 files changed, 306 insertions(+), 49 deletions(-) diff --git a/cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types.go b/cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types.go index caa123ddb4..9e779746d5 100644 --- a/cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types.go +++ b/cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types.go @@ -341,7 +341,8 @@ type BearerTokenConfig struct { type EmbeddedAuthServerConfig struct { // Issuer is the issuer identifier for this authorization server. // This will be included in the "iss" claim of issued tokens. - // Must be a valid HTTPS URL (or HTTP for localhost) without query, fragment, or trailing slash (per RFC 8414). + // Must be a valid HTTPS URL (or HTTP for localhost, or HTTP for trusted in-cluster hosts when + // insecureAllowHTTP is true) without query, fragment, or trailing slash (per RFC 8414). // +kubebuilder:validation:Required // +kubebuilder:validation:Pattern=`^https?://[^\s?#]+[^/\s?#]$` Issuer string `json:"issuer"` @@ -430,6 +431,16 @@ type EmbeddedAuthServerConfig struct { // +optional DisableUpstreamTokenInjection bool `json:"disableUpstreamTokenInjection,omitempty"` + // InsecureAllowHTTP permits an http:// issuer URL for non-localhost hosts. + // Only set this for in-cluster Kubernetes deployments where traffic between + // pods traverses a trusted network (e.g. the in-cluster service mesh). + // Production deployments reachable outside the cluster MUST use https://. + // When false (the default), http:// issuers are rejected at admission for any + // non-localhost host, and the pod will crash at startup with a validation error. + // +kubebuilder:default=false + // +optional + InsecureAllowHTTP bool `json:"insecureAllowHTTP,omitempty"` + // BaselineClientScopes is a baseline set of OAuth 2.0 scopes guaranteed to be // included in every client registration. The embedded auth server unions these // scopes into the registered set returned by RFC 7591 Dynamic Client diff --git a/cmd/thv-operator/controllers/virtualmcpserver_controller.go b/cmd/thv-operator/controllers/virtualmcpserver_controller.go index ea94504456..9c4f6f48e0 100644 --- a/cmd/thv-operator/controllers/virtualmcpserver_controller.go +++ b/cmd/thv-operator/controllers/virtualmcpserver_controller.go @@ -12,6 +12,7 @@ import ( stderrors "errors" "fmt" "maps" + "net/url" "reflect" "slices" "strings" @@ -42,6 +43,7 @@ import ( "github.com/stacklok/toolhive/cmd/thv-operator/pkg/virtualmcpserverstatus" operatorvmcpconfig "github.com/stacklok/toolhive/cmd/thv-operator/pkg/vmcpconfig" "github.com/stacklok/toolhive/pkg/authserver" + "github.com/stacklok/toolhive/pkg/networking" vmcptypes "github.com/stacklok/toolhive/pkg/vmcp" "github.com/stacklok/toolhive/pkg/vmcp/auth/converters" authtypes "github.com/stacklok/toolhive/pkg/vmcp/auth/types" @@ -491,6 +493,30 @@ func (*VirtualMCPServerReconciler) validateAuthServerConfig( return fmt.Errorf("%s", message) } + // Admission-time check: http:// issuers for non-localhost hosts require + // insecureAllowHTTP to be set explicitly. Without it the proxyrunner pod + // will crash at startup with a validateIssuerURL failure. + if strings.HasPrefix(cfg.Issuer, "http://") { + parsed, err := url.Parse(cfg.Issuer) + if err == nil && !networking.IsLocalhost(parsed.Host) && !cfg.InsecureAllowHTTP { + message := fmt.Sprintf( + "spec.authServerConfig.issuer %q uses http:// with a non-localhost host; "+ + "set spec.authServerConfig.insecureAllowHTTP: true to allow this for trusted "+ + "in-cluster deployments, or use https:// for production deployments", + cfg.Issuer, + ) + statusManager.SetPhase(mcpv1beta1.VirtualMCPServerPhaseFailed) + statusManager.SetMessage(message) + statusManager.SetAuthServerConfigValidatedCondition( + mcpv1beta1.ConditionReasonAuthServerConfigInvalid, + message, + metav1.ConditionFalse, + ) + statusManager.SetObservedGeneration(vmcp.Generation) + return fmt.Errorf("%s", message) + } + } + if len(cfg.UpstreamProviders) == 0 { message := "spec.authServerConfig.upstreamProviders is required" statusManager.SetPhase(mcpv1beta1.VirtualMCPServerPhaseFailed) @@ -1579,8 +1605,8 @@ func (*VirtualMCPServerReconciler) ensureServiceURL( statusManager virtualmcpserverstatus.StatusManager, ) { if vmcp.Status.URL == "" { - url := createVmcpServiceURL(vmcp.Name, vmcp.Namespace, vmcpDefaultPort) - statusManager.SetURL(url) + serviceURL := createVmcpServiceURL(vmcp.Name, vmcp.Namespace, vmcpDefaultPort) + statusManager.SetURL(serviceURL) } } diff --git a/cmd/thv-operator/controllers/virtualmcpserver_controller_test.go b/cmd/thv-operator/controllers/virtualmcpserver_controller_test.go index ccf61555d4..400f01472f 100644 --- a/cmd/thv-operator/controllers/virtualmcpserver_controller_test.go +++ b/cmd/thv-operator/controllers/virtualmcpserver_controller_test.go @@ -3848,3 +3848,103 @@ func TestVirtualMCPServerReconciler_IdentitySynthesizedTransitionsOnValidationFa assert.NotContains(t, cond.Message, "atlassian", "stale message naming the now-removed upstream must not survive the broken edit") } + +// TestVirtualMCPServerValidateAuthServerConfig_InsecureAllowHTTP exercises the +// admission-time check that rejects http:// issuers for non-localhost hosts +// unless insecureAllowHTTP is explicitly set. +func TestVirtualMCPServerValidateAuthServerConfig_InsecureAllowHTTP(t *testing.T) { + t.Parallel() + + validUpstreams := []mcpv1beta1.UpstreamProviderConfig{ + { + Name: "dex", + Type: mcpv1beta1.UpstreamProviderTypeOIDC, + OIDCConfig: &mcpv1beta1.OIDCUpstreamConfig{ + IssuerURL: "https://dex.example.com", + ClientID: "test-client", + }, + }, + } + + tests := []struct { + name string + issuer string + insecureAllowHTTP bool + wantErr bool + wantCondition metav1.ConditionStatus + }{ + { + name: "https issuer: always valid", + issuer: "https://authserver.example.com", + wantCondition: metav1.ConditionTrue, + }, + { + name: "http localhost issuer: valid without flag", + issuer: "http://localhost:4483", + wantCondition: metav1.ConditionTrue, + }, + { + name: "http in-cluster issuer without flag: rejected", + issuer: "http://vmcp-test.default.svc.cluster.local:4483", + wantErr: true, + wantCondition: metav1.ConditionFalse, + }, + { + name: "http in-cluster issuer with flag: accepted", + issuer: "http://vmcp-test.default.svc.cluster.local:4483", + insecureAllowHTTP: true, + wantCondition: metav1.ConditionTrue, + }, + { + name: "http non-localhost issuer without flag: rejected", + issuer: "http://authserver.example.com", + wantErr: true, + wantCondition: metav1.ConditionFalse, + }, + { + name: "http non-localhost issuer with flag: accepted", + issuer: "http://authserver.example.com", + insecureAllowHTTP: true, + wantCondition: metav1.ConditionTrue, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + vmcp := v1beta1test.NewVirtualMCPServer(testVmcpName, "default", + v1beta1test.WithVMCPGroupRef("test-group"), + v1beta1test.WithVMCPAuthServerConfig(&mcpv1beta1.EmbeddedAuthServerConfig{ + Issuer: tt.issuer, + InsecureAllowHTTP: tt.insecureAllowHTTP, + UpstreamProviders: validUpstreams, + }), + v1beta1test.MutateVMCP(func(v *mcpv1beta1.VirtualMCPServer) { + v.Generation = 1 + }), + ) + + r := &VirtualMCPServerReconciler{} + statusManager := virtualmcpserverstatus.NewStatusManager(vmcp) + err := r.validateAuthServerConfig(vmcp, statusManager) + statusManager.UpdateStatus(t.Context(), &vmcp.Status) + + if tt.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } + + cond := findCondition(vmcp.Status.Conditions, mcpv1beta1.ConditionTypeAuthServerConfigValidated) + require.NotNil(t, cond, "AuthServerConfigValidated condition must be set") + assert.Equal(t, tt.wantCondition, cond.Status) + + if tt.wantErr { + assert.Equal(t, mcpv1beta1.ConditionReasonAuthServerConfigInvalid, cond.Reason) + assert.Contains(t, cond.Message, "insecureAllowHTTP", + "rejection message must guide the user to the fix") + } + }) + } +} diff --git a/cmd/thv-operator/pkg/controllerutil/authserver.go b/cmd/thv-operator/pkg/controllerutil/authserver.go index baf32c853b..97a3692b96 100644 --- a/cmd/thv-operator/pkg/controllerutil/authserver.go +++ b/cmd/thv-operator/pkg/controllerutil/authserver.go @@ -585,6 +585,10 @@ func BuildAuthServerRunConfig( // Wire through upstream token injection flag config.DisableUpstreamTokenInjection = authConfig.DisableUpstreamTokenInjection + // Wire through the insecure HTTP issuer flag from the CRD field. + // This replaces any auto-inference and moves control to the deployer. + config.InsecureAllowHTTP = authConfig.InsecureAllowHTTP + // Build CIMD configuration. CacheFallbackTTL is passed as-is (string); // resolveCIMDConfig in the runner parses it to time.Duration at startup. if authConfig.CIMD != nil && authConfig.CIMD.Enabled { diff --git a/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpexternalauthconfigs.yaml b/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpexternalauthconfigs.yaml index d88668e8dd..867114b075 100644 --- a/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpexternalauthconfigs.yaml +++ b/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpexternalauthconfigs.yaml @@ -307,11 +307,22 @@ spec: type: object type: array x-kubernetes-list-type: atomic + insecureAllowHTTP: + default: false + description: |- + InsecureAllowHTTP permits an http:// issuer URL for non-localhost hosts. + Only set this for in-cluster Kubernetes deployments where traffic between + pods traverses a trusted network (e.g. the in-cluster service mesh). + Production deployments reachable outside the cluster MUST use https://. + When false (the default), http:// issuers are rejected at admission for any + non-localhost host, and the pod will crash at startup with a validation error. + type: boolean issuer: description: |- Issuer is the issuer identifier for this authorization server. This will be included in the "iss" claim of issued tokens. - Must be a valid HTTPS URL (or HTTP for localhost) without query, fragment, or trailing slash (per RFC 8414). + Must be a valid HTTPS URL (or HTTP for localhost, or HTTP for trusted in-cluster hosts when + insecureAllowHTTP is true) without query, fragment, or trailing slash (per RFC 8414). pattern: ^https?://[^\s?#]+[^/\s?#]$ type: string primaryUpstreamProvider: @@ -1787,11 +1798,22 @@ spec: type: object type: array x-kubernetes-list-type: atomic + insecureAllowHTTP: + default: false + description: |- + InsecureAllowHTTP permits an http:// issuer URL for non-localhost hosts. + Only set this for in-cluster Kubernetes deployments where traffic between + pods traverses a trusted network (e.g. the in-cluster service mesh). + Production deployments reachable outside the cluster MUST use https://. + When false (the default), http:// issuers are rejected at admission for any + non-localhost host, and the pod will crash at startup with a validation error. + type: boolean issuer: description: |- Issuer is the issuer identifier for this authorization server. This will be included in the "iss" claim of issued tokens. - Must be a valid HTTPS URL (or HTTP for localhost) without query, fragment, or trailing slash (per RFC 8414). + Must be a valid HTTPS URL (or HTTP for localhost, or HTTP for trusted in-cluster hosts when + insecureAllowHTTP is true) without query, fragment, or trailing slash (per RFC 8414). pattern: ^https?://[^\s?#]+[^/\s?#]$ type: string primaryUpstreamProvider: diff --git a/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_virtualmcpservers.yaml b/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_virtualmcpservers.yaml index 98d5e23f31..7a3bfb63c5 100644 --- a/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_virtualmcpservers.yaml +++ b/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_virtualmcpservers.yaml @@ -180,11 +180,22 @@ spec: type: object type: array x-kubernetes-list-type: atomic + insecureAllowHTTP: + default: false + description: |- + InsecureAllowHTTP permits an http:// issuer URL for non-localhost hosts. + Only set this for in-cluster Kubernetes deployments where traffic between + pods traverses a trusted network (e.g. the in-cluster service mesh). + Production deployments reachable outside the cluster MUST use https://. + When false (the default), http:// issuers are rejected at admission for any + non-localhost host, and the pod will crash at startup with a validation error. + type: boolean issuer: description: |- Issuer is the issuer identifier for this authorization server. This will be included in the "iss" claim of issued tokens. - Must be a valid HTTPS URL (or HTTP for localhost) without query, fragment, or trailing slash (per RFC 8414). + Must be a valid HTTPS URL (or HTTP for localhost, or HTTP for trusted in-cluster hosts when + insecureAllowHTTP is true) without query, fragment, or trailing slash (per RFC 8414). pattern: ^https?://[^\s?#]+[^/\s?#]$ type: string primaryUpstreamProvider: @@ -3412,11 +3423,22 @@ spec: type: object type: array x-kubernetes-list-type: atomic + insecureAllowHTTP: + default: false + description: |- + InsecureAllowHTTP permits an http:// issuer URL for non-localhost hosts. + Only set this for in-cluster Kubernetes deployments where traffic between + pods traverses a trusted network (e.g. the in-cluster service mesh). + Production deployments reachable outside the cluster MUST use https://. + When false (the default), http:// issuers are rejected at admission for any + non-localhost host, and the pod will crash at startup with a validation error. + type: boolean issuer: description: |- Issuer is the issuer identifier for this authorization server. This will be included in the "iss" claim of issued tokens. - Must be a valid HTTPS URL (or HTTP for localhost) without query, fragment, or trailing slash (per RFC 8414). + Must be a valid HTTPS URL (or HTTP for localhost, or HTTP for trusted in-cluster hosts when + insecureAllowHTTP is true) without query, fragment, or trailing slash (per RFC 8414). pattern: ^https?://[^\s?#]+[^/\s?#]$ type: string primaryUpstreamProvider: diff --git a/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpexternalauthconfigs.yaml b/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpexternalauthconfigs.yaml index 27ec4c33c4..772f9d7fca 100644 --- a/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpexternalauthconfigs.yaml +++ b/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpexternalauthconfigs.yaml @@ -310,11 +310,22 @@ spec: type: object type: array x-kubernetes-list-type: atomic + insecureAllowHTTP: + default: false + description: |- + InsecureAllowHTTP permits an http:// issuer URL for non-localhost hosts. + Only set this for in-cluster Kubernetes deployments where traffic between + pods traverses a trusted network (e.g. the in-cluster service mesh). + Production deployments reachable outside the cluster MUST use https://. + When false (the default), http:// issuers are rejected at admission for any + non-localhost host, and the pod will crash at startup with a validation error. + type: boolean issuer: description: |- Issuer is the issuer identifier for this authorization server. This will be included in the "iss" claim of issued tokens. - Must be a valid HTTPS URL (or HTTP for localhost) without query, fragment, or trailing slash (per RFC 8414). + Must be a valid HTTPS URL (or HTTP for localhost, or HTTP for trusted in-cluster hosts when + insecureAllowHTTP is true) without query, fragment, or trailing slash (per RFC 8414). pattern: ^https?://[^\s?#]+[^/\s?#]$ type: string primaryUpstreamProvider: @@ -1790,11 +1801,22 @@ spec: type: object type: array x-kubernetes-list-type: atomic + insecureAllowHTTP: + default: false + description: |- + InsecureAllowHTTP permits an http:// issuer URL for non-localhost hosts. + Only set this for in-cluster Kubernetes deployments where traffic between + pods traverses a trusted network (e.g. the in-cluster service mesh). + Production deployments reachable outside the cluster MUST use https://. + When false (the default), http:// issuers are rejected at admission for any + non-localhost host, and the pod will crash at startup with a validation error. + type: boolean issuer: description: |- Issuer is the issuer identifier for this authorization server. This will be included in the "iss" claim of issued tokens. - Must be a valid HTTPS URL (or HTTP for localhost) without query, fragment, or trailing slash (per RFC 8414). + Must be a valid HTTPS URL (or HTTP for localhost, or HTTP for trusted in-cluster hosts when + insecureAllowHTTP is true) without query, fragment, or trailing slash (per RFC 8414). pattern: ^https?://[^\s?#]+[^/\s?#]$ type: string primaryUpstreamProvider: diff --git a/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_virtualmcpservers.yaml b/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_virtualmcpservers.yaml index 131ea6de6c..2b516b8b90 100644 --- a/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_virtualmcpservers.yaml +++ b/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_virtualmcpservers.yaml @@ -183,11 +183,22 @@ spec: type: object type: array x-kubernetes-list-type: atomic + insecureAllowHTTP: + default: false + description: |- + InsecureAllowHTTP permits an http:// issuer URL for non-localhost hosts. + Only set this for in-cluster Kubernetes deployments where traffic between + pods traverses a trusted network (e.g. the in-cluster service mesh). + Production deployments reachable outside the cluster MUST use https://. + When false (the default), http:// issuers are rejected at admission for any + non-localhost host, and the pod will crash at startup with a validation error. + type: boolean issuer: description: |- Issuer is the issuer identifier for this authorization server. This will be included in the "iss" claim of issued tokens. - Must be a valid HTTPS URL (or HTTP for localhost) without query, fragment, or trailing slash (per RFC 8414). + Must be a valid HTTPS URL (or HTTP for localhost, or HTTP for trusted in-cluster hosts when + insecureAllowHTTP is true) without query, fragment, or trailing slash (per RFC 8414). pattern: ^https?://[^\s?#]+[^/\s?#]$ type: string primaryUpstreamProvider: @@ -3415,11 +3426,22 @@ spec: type: object type: array x-kubernetes-list-type: atomic + insecureAllowHTTP: + default: false + description: |- + InsecureAllowHTTP permits an http:// issuer URL for non-localhost hosts. + Only set this for in-cluster Kubernetes deployments where traffic between + pods traverses a trusted network (e.g. the in-cluster service mesh). + Production deployments reachable outside the cluster MUST use https://. + When false (the default), http:// issuers are rejected at admission for any + non-localhost host, and the pod will crash at startup with a validation error. + type: boolean issuer: description: |- Issuer is the issuer identifier for this authorization server. This will be included in the "iss" claim of issued tokens. - Must be a valid HTTPS URL (or HTTP for localhost) without query, fragment, or trailing slash (per RFC 8414). + Must be a valid HTTPS URL (or HTTP for localhost, or HTTP for trusted in-cluster hosts when + insecureAllowHTTP is true) without query, fragment, or trailing slash (per RFC 8414). pattern: ^https?://[^\s?#]+[^/\s?#]$ type: string primaryUpstreamProvider: diff --git a/docs/operator/crd-api.md b/docs/operator/crd-api.md index a71e09a627..c7ac01532d 100644 --- a/docs/operator/crd-api.md +++ b/docs/operator/crd-api.md @@ -1283,7 +1283,7 @@ _Appears in:_ | Field | Description | Default | Validation | | --- | --- | --- | --- | -| `issuer` _string_ | Issuer is the issuer identifier for this authorization server.
This will be included in the "iss" claim of issued tokens.
Must be a valid HTTPS URL (or HTTP for localhost) without query, fragment, or trailing slash (per RFC 8414). | | Pattern: `^https?://[^\s?#]+[^/\s?#]$`
Required: \{\}
| +| `issuer` _string_ | Issuer is the issuer identifier for this authorization server.
This will be included in the "iss" claim of issued tokens.
Must be a valid HTTPS URL (or HTTP for localhost, or HTTP for trusted in-cluster hosts when
insecureAllowHTTP is true) without query, fragment, or trailing slash (per RFC 8414). | | Pattern: `^https?://[^\s?#]+[^/\s?#]$`
Required: \{\}
| | `authorizationEndpointBaseUrl` _string_ | AuthorizationEndpointBaseURL overrides the base URL used for the authorization_endpoint
in the OAuth discovery document. When set, the discovery document will advertise
`\{authorizationEndpointBaseUrl\}/oauth/authorize` instead of `\{issuer\}/oauth/authorize`.
All other endpoints (token, registration, JWKS) remain derived from the issuer.
This is useful when the browser-facing authorization endpoint needs to be on a
different host than the issuer used for backend-to-backend calls.
Must be a valid HTTPS URL (or HTTP for localhost) without query, fragment, or trailing slash. | | Pattern: `^https?://[^\s?#]+[^/\s?#]$`
Optional: \{\}
| | `signingKeySecretRefs` _[api.v1beta1.SecretKeyRef](#apiv1beta1secretkeyref) array_ | SigningKeySecretRefs references Kubernetes Secrets containing signing keys for JWT operations.
Supports key rotation by allowing multiple keys (oldest keys are used for verification only).
If not specified, an ephemeral signing key will be auto-generated (development only -
JWTs will be invalid after restart). | | MaxItems: 5
Optional: \{\}
| | `hmacSecretRefs` _[api.v1beta1.SecretKeyRef](#apiv1beta1secretkeyref) array_ | HMACSecretRefs references Kubernetes Secrets containing symmetric secrets for signing
authorization codes and refresh tokens (opaque tokens).
Current secret must be at least 32 bytes and cryptographically random.
Supports secret rotation via multiple entries (first is current, rest are for verification).
If not specified, an ephemeral secret will be auto-generated (development only -
auth codes and refresh tokens will be invalid after restart). | | Optional: \{\}
| @@ -1292,6 +1292,7 @@ _Appears in:_ | `primaryUpstreamProvider` _string_ | PrimaryUpstreamProvider names the upstream IDP whose access token Cedar
should read claims from when authorising a request. Must match the name
of one of the entries in UpstreamProviders. When empty, the controller
auto-selects the first entry of UpstreamProviders.
Only meaningful on VirtualMCPServer, where multiple upstream providers
can be configured and Cedar needs to pick which token's claims to
evaluate. The VirtualMCPServer controller validates this field against
UpstreamProviders at admission and rejects unresolvable values.
On MCPServer and MCPRemoteProxy this field is structurally present (the
EmbeddedAuthServerConfig struct is shared) but has no runtime effect:
those CRDs are restricted to a single upstream so there is no choice to
make. Setting it on those CRDs is silently ignored. | | MaxLength: 63
MinLength: 1
Pattern: `^[a-z0-9]([a-z0-9-]*[a-z0-9])?$`
Optional: \{\}
| | `storage` _[api.v1beta1.AuthServerStorageConfig](#apiv1beta1authserverstorageconfig)_ | Storage configures the storage backend for the embedded auth server.
If not specified, defaults to in-memory storage. | | Optional: \{\}
| | `disableUpstreamTokenInjection` _boolean_ | DisableUpstreamTokenInjection prevents the embedded auth server from injecting
upstream IdP tokens into requests forwarded to the backend MCP server.
When true, the embedded auth server still handles OAuth flows for clients,
but instead of swapping ToolHive JWTs for upstream tokens the proxy STRIPS
the client's credential headers (Authorization, Cookie, Proxy-Authorization)
after validating the JWT — the backend receives an unauthenticated request.
Use headerForward to attach static credentials (e.g. an API key) if the
backend needs them. Cannot be combined with token exchange or AWS STS,
which would re-add credentials after the strip.
This is useful when the backend MCP server does not require authentication
(e.g., public documentation servers) but you still want client authentication. | false | Optional: \{\}
| +| `insecureAllowHTTP` _boolean_ | InsecureAllowHTTP permits an http:// issuer URL for non-localhost hosts.
Only set this for in-cluster Kubernetes deployments where traffic between
pods traverses a trusted network (e.g. the in-cluster service mesh).
Production deployments reachable outside the cluster MUST use https://.
When false (the default), http:// issuers are rejected at admission for any
non-localhost host, and the pod will crash at startup with a validation error. | false | Optional: \{\}
| | `baselineClientScopes` _string array_ | BaselineClientScopes is a baseline set of OAuth 2.0 scopes guaranteed to be
included in every client registration. The embedded auth server unions these
scopes into the registered set returned by RFC 7591 Dynamic Client
Registration, so a client that narrows the `scope` field at /oauth/register
can still request the baseline scopes at /oauth/authorize. All values must
be present in the upstream-derived scopesSupported set; the auth server
fails to start if any value is missing.
Security: every client registered via /oauth/register will gain the
ability to request these scopes at /oauth/authorize, regardless of what
the client itself requested. Keep the baseline narrow (typically
"openid" and "offline_access"). Adding a privileged scope here — e.g.
"admin:read" — would grant it to every DCR-registered client, including
public clients like Claude Code, Cursor, and VS Code.
When cimd.enabled is true, every dynamically resolved CIMD client will
also gain the ability to request these scopes, including third-party
clients resolved from arbitrary HTTPS URLs. | | MaxItems: 10
items:MinLength: 1
items:Pattern: `^[\x21\x23-\x5B\x5D-\x7E]+$`
Optional: \{\}
| | `cimd` _[api.v1beta1.EmbeddedAuthServerCIMDConfig](#apiv1beta1embeddedauthservercimdconfig)_ | CIMD configures Client ID Metadata Document support. When omitted, CIMD is disabled. | | Optional: \{\}
| diff --git a/pkg/authserver/config.go b/pkg/authserver/config.go index ee3be3d965..bd9e6811f9 100644 --- a/pkg/authserver/config.go +++ b/pkg/authserver/config.go @@ -104,6 +104,12 @@ type RunConfig struct { // embedded authorization server accepts HTTPS URLs as client_id values // and resolves them via the CIMD protocol instead of requiring DCR. CIMD *CIMDRunConfig `json:"cimd,omitempty" yaml:"cimd,omitempty"` + + // InsecureAllowHTTP permits an http:// issuer URL for non-localhost hosts. + // Only set this for in-cluster Kubernetes deployments on a trusted network. + // Production deployments reachable outside the cluster MUST use https://. + //nolint:lll // field tags require full JSON+YAML names + InsecureAllowHTTP bool `json:"insecure_allow_http,omitempty" yaml:"insecure_allow_http,omitempty"` } // Validate checks that the on-disk RunConfig is internally consistent. Called @@ -625,18 +631,23 @@ type Config struct { // (Cache-Control header parsing is not yet implemented). Zero is replaced by // a default (5 minutes) in applyDefaults when CIMDEnabled is true. CIMDCacheFallbackTTL time.Duration + + // InsecureAllowHTTP permits an http:// issuer URL for non-localhost hosts. + // Only set this for in-cluster Kubernetes deployments on a trusted network. + // Production deployments reachable outside the cluster MUST use https://. + InsecureAllowHTTP bool } // Validate checks that the Config is valid. func (c *Config) Validate() error { slog.Debug("validating authserver config", "issuer", c.Issuer) - if err := validateIssuerURL(c.Issuer); err != nil { + if err := validateIssuerURL(c.Issuer, c.InsecureAllowHTTP); err != nil { return fmt.Errorf("issuer: %w", err) } if c.AuthorizationEndpointBaseURL != "" { - if err := validateIssuerURL(c.AuthorizationEndpointBaseURL); err != nil { + if err := validateIssuerURL(c.AuthorizationEndpointBaseURL, c.InsecureAllowHTTP); err != nil { return fmt.Errorf("authorization_endpoint_base_url: %w", err) } } @@ -928,7 +939,9 @@ func (c *Config) applyDefaults() error { // validateIssuerURL validates that the issuer is a valid URL. // Per OIDC Core Section 3.1.2.1 and RFC 8414 Section 2, the issuer // MUST use the "https" scheme, except for localhost during development. -func validateIssuerURL(issuer string) error { +// When insecureAllowHTTP is true, http:// is also permitted for non-localhost +// hosts (for in-cluster Kubernetes deployments on trusted networks). +func validateIssuerURL(issuer string, insecureAllowHTTP bool) error { if issuer == "" { return fmt.Errorf("issuer is required") } @@ -954,12 +967,13 @@ func validateIssuerURL(issuer string) error { return fmt.Errorf("must not contain fragment component") } - // HTTPS is required unless it's a loopback address (for development) + // HTTPS is required unless it's a loopback address (for development) or + // insecureAllowHTTP is explicitly set for trusted in-cluster deployments. if parsed.Scheme != "https" { if parsed.Scheme != "http" { return fmt.Errorf("scheme must be https (or http for localhost)") } - if !networking.IsLocalhost(parsed.Host) { + if !networking.IsLocalhost(parsed.Host) && !insecureAllowHTTP { return fmt.Errorf("http scheme is only allowed for localhost, use https for %s", parsed.Hostname()) } } diff --git a/pkg/authserver/config_test.go b/pkg/authserver/config_test.go index 3c270771a4..ffb8e666e8 100644 --- a/pkg/authserver/config_test.go +++ b/pkg/authserver/config_test.go @@ -21,12 +21,13 @@ func TestValidateIssuerURL(t *testing.T) { t.Parallel() tests := []struct { - name string - issuer string - wantErr bool - errMsg string + name string + issuer string + insecureAllowHTTP bool + wantErr bool + errMsg string }{ - // Valid + // Valid — strict mode (insecureAllowHTTP=false) {name: "https", issuer: "https://example.com"}, {name: "https with port", issuer: "https://example.com:8443"}, {name: "https with path", issuer: "https://example.com/auth"}, @@ -35,7 +36,7 @@ func TestValidateIssuerURL(t *testing.T) { {name: "http 127.0.0.1", issuer: "http://127.0.0.1:8080"}, {name: "http IPv6 loopback", issuer: "http://[::1]:8080"}, - // Invalid + // Invalid — strict mode {name: "empty", issuer: "", wantErr: true, errMsg: "issuer is required"}, {name: "missing scheme", issuer: "example.com", wantErr: true, errMsg: "scheme is required"}, {name: "missing host", issuer: "https://", wantErr: true, errMsg: "host is required"}, @@ -44,12 +45,23 @@ func TestValidateIssuerURL(t *testing.T) { {name: "http non-localhost", issuer: "http://example.com", wantErr: true, errMsg: "http scheme is only allowed for localhost"}, {name: "ftp scheme", issuer: "ftp://example.com", wantErr: true, errMsg: "scheme must be https"}, {name: "trailing slash", issuer: "https://example.com/", wantErr: true, errMsg: "must not have trailing slash"}, + + // Valid — insecureAllowHTTP=true permits http for non-localhost + {name: "http in-cluster insecure allowed", issuer: "http://vmcp-test.default.svc.cluster.local:4483", insecureAllowHTTP: true}, + {name: "http non-localhost insecure allowed", issuer: "http://example.com", insecureAllowHTTP: true}, + {name: "https still valid with insecure flag", issuer: "https://example.com", insecureAllowHTTP: true}, + {name: "http localhost still valid with insecure flag", issuer: "http://localhost:8080", insecureAllowHTTP: true}, + + // Invalid — insecureAllowHTTP=true still enforces other rules + {name: "trailing slash insecure", issuer: "http://example.com/", insecureAllowHTTP: true, wantErr: true, errMsg: "must not have trailing slash"}, + {name: "ftp scheme insecure", issuer: "ftp://example.com", insecureAllowHTTP: true, wantErr: true, errMsg: "scheme must be https"}, + {name: "empty insecure", issuer: "", insecureAllowHTTP: true, wantErr: true, errMsg: "issuer is required"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() - err := validateIssuerURL(tt.issuer) + err := validateIssuerURL(tt.issuer, tt.insecureAllowHTTP) assertError(t, err, tt.wantErr, tt.errMsg) }) } diff --git a/pkg/authserver/runner/embeddedauthserver.go b/pkg/authserver/runner/embeddedauthserver.go index f5ee27a0a4..6d2145e1b0 100644 --- a/pkg/authserver/runner/embeddedauthserver.go +++ b/pkg/authserver/runner/embeddedauthserver.go @@ -228,6 +228,7 @@ func NewEmbeddedAuthServerWithStorage( CIMDEnabled: cimdEnabled, CIMDCacheMaxSize: cimdCacheMaxSize, CIMDCacheFallbackTTL: cimdCacheFallbackTTL, + InsecureAllowHTTP: cfg.InsecureAllowHTTP, } // 7. Create the auth server. authserver.New also asserts the DCR diff --git a/pkg/vmcp/auth/types/zz_generated.deepcopy.go b/pkg/vmcp/auth/types/zz_generated.deepcopy.go index cff8a0cdc5..8a38c5b871 100644 --- a/pkg/vmcp/auth/types/zz_generated.deepcopy.go +++ b/pkg/vmcp/auth/types/zz_generated.deepcopy.go @@ -22,31 +22,6 @@ package types import () -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *OBOConfig) DeepCopyInto(out *OBOConfig) { - *out = *in - if in.Scopes != nil { - in, out := &in.Scopes, &out.Scopes - *out = make([]string, len(*in)) - copy(*out, *in) - } - if in.CacheSkewSeconds != nil { - in, out := &in.CacheSkewSeconds, &out.CacheSkewSeconds - *out = new(int32) - **out = **in - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OBOConfig. -func (in *OBOConfig) DeepCopy() *OBOConfig { - if in == nil { - return nil - } - out := new(OBOConfig) - in.DeepCopyInto(out) - return out -} - // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *AwsStsConfig) DeepCopyInto(out *AwsStsConfig) { *out = *in @@ -129,6 +104,31 @@ func (in *HeaderInjectionConfig) DeepCopy() *HeaderInjectionConfig { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *OBOConfig) DeepCopyInto(out *OBOConfig) { + *out = *in + if in.Scopes != nil { + in, out := &in.Scopes, &out.Scopes + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.CacheSkewSeconds != nil { + in, out := &in.CacheSkewSeconds, &out.CacheSkewSeconds + *out = new(int32) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OBOConfig. +func (in *OBOConfig) DeepCopy() *OBOConfig { + if in == nil { + return nil + } + out := new(OBOConfig) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *RoleMapping) DeepCopyInto(out *RoleMapping) { *out = *in From 2f3f01dfac1e418467d501d2ceabf1a438ff2239 Mon Sep 17 00:00:00 2001 From: Trey Date: Sat, 27 Jun 2026 11:35:47 -0700 Subject: [PATCH 2/4] Address review feedback for InsecureAllowHTTP - Add InsecureAllowHTTP test cases to TestBuildAuthServerRunConfig to verify the CRD field propagates to RunConfig - Add comment explaining why the url.Parse error guard in validateAuthServerConfig is safe (CRD regex pre-validates at admission) Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .../virtualmcpserver_controller.go | 4 +++ .../pkg/controllerutil/authserver_test.go | 34 +++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/cmd/thv-operator/controllers/virtualmcpserver_controller.go b/cmd/thv-operator/controllers/virtualmcpserver_controller.go index 9c4f6f48e0..e7e66c230b 100644 --- a/cmd/thv-operator/controllers/virtualmcpserver_controller.go +++ b/cmd/thv-operator/controllers/virtualmcpserver_controller.go @@ -497,6 +497,10 @@ func (*VirtualMCPServerReconciler) validateAuthServerConfig( // insecureAllowHTTP to be set explicitly. Without it the proxyrunner pod // will crash at startup with a validateIssuerURL failure. if strings.HasPrefix(cfg.Issuer, "http://") { + // url.Parse is expected to succeed here because the CRD regex + // (^https?://[^\s?#]+[^/\s?#]$) already rejects structurally invalid + // URLs at admission time; if parsing does fail, skip this check and + // let the runtime validator catch it at startup. parsed, err := url.Parse(cfg.Issuer) if err == nil && !networking.IsLocalhost(parsed.Host) && !cfg.InsecureAllowHTTP { message := fmt.Sprintf( diff --git a/cmd/thv-operator/pkg/controllerutil/authserver_test.go b/cmd/thv-operator/pkg/controllerutil/authserver_test.go index 2a6b795bc0..b79d01584b 100644 --- a/cmd/thv-operator/pkg/controllerutil/authserver_test.go +++ b/cmd/thv-operator/pkg/controllerutil/authserver_test.go @@ -1610,6 +1610,40 @@ func TestBuildAuthServerRunConfig(t *testing.T) { "DisableUpstreamTokenInjection should default to false") }, }, + { + name: "insecureAllowHTTP true is propagated to RunConfig", + authConfig: &mcpv1beta1.EmbeddedAuthServerConfig{ + Issuer: "http://vmcp-test.default.svc.cluster.local:4483", + InsecureAllowHTTP: true, + HMACSecretRefs: []mcpv1beta1.SecretKeyRef{ + {Name: "hmac-secret", Key: "hmac"}, + }, + }, + allowedAudiences: defaultAudiences, + scopesSupported: defaultScopes, + checkFunc: func(t *testing.T, config *authserver.RunConfig) { + t.Helper() + assert.True(t, config.InsecureAllowHTTP, + "InsecureAllowHTTP must propagate from CRD field to RunConfig") + }, + }, + { + name: "insecureAllowHTTP false is propagated to RunConfig", + authConfig: &mcpv1beta1.EmbeddedAuthServerConfig{ + Issuer: "https://authserver.example.com", + InsecureAllowHTTP: false, + HMACSecretRefs: []mcpv1beta1.SecretKeyRef{ + {Name: "hmac-secret", Key: "hmac"}, + }, + }, + allowedAudiences: defaultAudiences, + scopesSupported: defaultScopes, + checkFunc: func(t *testing.T, config *authserver.RunConfig) { + t.Helper() + assert.False(t, config.InsecureAllowHTTP, + "InsecureAllowHTTP false must propagate from CRD field to RunConfig") + }, + }, } for _, tt := range tests { From 63ac53fc320261d08586eb2b7b109bb242531c5a Mon Sep 17 00:00:00 2001 From: Trey Date: Sat, 27 Jun 2026 11:46:26 -0700 Subject: [PATCH 3/4] Regenerate server API docs after InsecureAllowHTTP changes Co-Authored-By: Claude Sonnet 4.6 (1M context) --- docs/server/docs.go | 4 ++++ docs/server/swagger.json | 4 ++++ docs/server/swagger.yaml | 6 ++++++ 3 files changed, 14 insertions(+) diff --git a/docs/server/docs.go b/docs/server/docs.go index 8a5e866faa..94a2b15384 100644 --- a/docs/server/docs.go +++ b/docs/server/docs.go @@ -563,6 +563,10 @@ const docTemplate = `{ "type": "array", "uniqueItems": false }, + "insecure_allow_http": { + "description": "InsecureAllowHTTP permits an http:// issuer URL for non-localhost hosts.\nOnly set this for in-cluster Kubernetes deployments on a trusted network.\nProduction deployments reachable outside the cluster MUST use https://.", + "type": "boolean" + }, "issuer": { "description": "Issuer is the issuer identifier for this authorization server.\nThis will be included in the \"iss\" claim of issued tokens.\nMust be a valid HTTPS URL (or HTTP for localhost) without query, fragment, or trailing slash.", "type": "string" diff --git a/docs/server/swagger.json b/docs/server/swagger.json index 8dc3869b85..cc7d7b56aa 100644 --- a/docs/server/swagger.json +++ b/docs/server/swagger.json @@ -556,6 +556,10 @@ "type": "array", "uniqueItems": false }, + "insecure_allow_http": { + "description": "InsecureAllowHTTP permits an http:// issuer URL for non-localhost hosts.\nOnly set this for in-cluster Kubernetes deployments on a trusted network.\nProduction deployments reachable outside the cluster MUST use https://.", + "type": "boolean" + }, "issuer": { "description": "Issuer is the issuer identifier for this authorization server.\nThis will be included in the \"iss\" claim of issued tokens.\nMust be a valid HTTPS URL (or HTTP for localhost) without query, fragment, or trailing slash.", "type": "string" diff --git a/docs/server/swagger.yaml b/docs/server/swagger.yaml index 63d3dd713f..a790f89ccf 100644 --- a/docs/server/swagger.yaml +++ b/docs/server/swagger.yaml @@ -640,6 +640,12 @@ components: type: string type: array uniqueItems: false + insecure_allow_http: + description: |- + InsecureAllowHTTP permits an http:// issuer URL for non-localhost hosts. + Only set this for in-cluster Kubernetes deployments on a trusted network. + Production deployments reachable outside the cluster MUST use https://. + type: boolean issuer: description: |- Issuer is the issuer identifier for this authorization server. From 87021cd59a21650ee827aa4fcda4ba0e875e0373 Mon Sep 17 00:00:00 2001 From: Trey Date: Sat, 27 Jun 2026 19:50:13 -0700 Subject: [PATCH 4/4] Fix doc scoping, guard correctness, and error idioms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses stacklok/toolhive#5671 review comments: - MEDIUM mcpexternalauthconfig_types.go (3487247015): Scope InsecureAllowHTTP admission check description to VirtualMCPServer; note MCPServer/MCPRemoteProxy defer to pod-startup Config.Validate(), matching the precedent set by the primaryUpstreamProvider comment above it. Regenerate CRD YAML and docs. - LOW mcpexternalauthconfig_types.go (body:F2): Note insecureAllowHTTP also relaxes the HTTP constraint on authorizationEndpointBaseURL. - LOW virtualmcpserver_controller.go (3487247019): Replace fmt.Errorf("%s",…) with stderrors.New in all four validateAuthServerConfig returns. - LOW virtualmcpserver_controller.go (3487247017): Add parsed.Host != "" guard to the http:// issuer check; simplify the comment. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .../v1beta1/mcpexternalauthconfig_types.go | 13 +++++++--- .../virtualmcpserver_controller.go | 16 +++++------- ...e.stacklok.dev_mcpexternalauthconfigs.yaml | 26 ++++++++++++++----- ...olhive.stacklok.dev_virtualmcpservers.yaml | 26 ++++++++++++++----- ...e.stacklok.dev_mcpexternalauthconfigs.yaml | 26 ++++++++++++++----- ...olhive.stacklok.dev_virtualmcpservers.yaml | 26 ++++++++++++++----- docs/operator/crd-api.md | 4 +-- 7 files changed, 99 insertions(+), 38 deletions(-) diff --git a/cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types.go b/cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types.go index 9e779746d5..46d422a736 100644 --- a/cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types.go +++ b/cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types.go @@ -353,7 +353,8 @@ type EmbeddedAuthServerConfig struct { // All other endpoints (token, registration, JWKS) remain derived from the issuer. // This is useful when the browser-facing authorization endpoint needs to be on a // different host than the issuer used for backend-to-backend calls. - // Must be a valid HTTPS URL (or HTTP for localhost) without query, fragment, or trailing slash. + // Must be a valid HTTPS URL (or HTTP for localhost, or HTTP for trusted in-cluster hosts + // when insecureAllowHTTP is true) without query, fragment, or trailing slash. // +kubebuilder:validation:Pattern=`^https?://[^\s?#]+[^/\s?#]$` // +optional AuthorizationEndpointBaseURL string `json:"authorizationEndpointBaseUrl,omitempty"` @@ -435,8 +436,14 @@ type EmbeddedAuthServerConfig struct { // Only set this for in-cluster Kubernetes deployments where traffic between // pods traverses a trusted network (e.g. the in-cluster service mesh). // Production deployments reachable outside the cluster MUST use https://. - // When false (the default), http:// issuers are rejected at admission for any - // non-localhost host, and the pod will crash at startup with a validation error. + // + // On VirtualMCPServer: when false (the default), http:// issuers for non-localhost + // hosts are rejected at reconcile time with an AuthServerConfigValidated=False condition. + // + // On MCPServer and MCPRemoteProxy (via MCPExternalAuthConfig): this field is + // structurally present but enforcement is deferred to pod startup via Config.Validate(); + // a misconfigured issuer will cause the pod to crash at startup rather than surface + // as an operator condition. // +kubebuilder:default=false // +optional InsecureAllowHTTP bool `json:"insecureAllowHTTP,omitempty"` diff --git a/cmd/thv-operator/controllers/virtualmcpserver_controller.go b/cmd/thv-operator/controllers/virtualmcpserver_controller.go index e7e66c230b..9080096b65 100644 --- a/cmd/thv-operator/controllers/virtualmcpserver_controller.go +++ b/cmd/thv-operator/controllers/virtualmcpserver_controller.go @@ -490,19 +490,17 @@ func (*VirtualMCPServerReconciler) validateAuthServerConfig( metav1.ConditionFalse, ) statusManager.SetObservedGeneration(vmcp.Generation) - return fmt.Errorf("%s", message) + return stderrors.New(message) } // Admission-time check: http:// issuers for non-localhost hosts require // insecureAllowHTTP to be set explicitly. Without it the proxyrunner pod // will crash at startup with a validateIssuerURL failure. if strings.HasPrefix(cfg.Issuer, "http://") { - // url.Parse is expected to succeed here because the CRD regex - // (^https?://[^\s?#]+[^/\s?#]$) already rejects structurally invalid - // URLs at admission time; if parsing does fail, skip this check and - // let the runtime validator catch it at startup. + // url.Parse succeeds for any URL that passes the CRD regex; the + // parsed.Host != "" guard defends against the degenerate empty-host case. parsed, err := url.Parse(cfg.Issuer) - if err == nil && !networking.IsLocalhost(parsed.Host) && !cfg.InsecureAllowHTTP { + if err == nil && parsed.Host != "" && !networking.IsLocalhost(parsed.Host) && !cfg.InsecureAllowHTTP { message := fmt.Sprintf( "spec.authServerConfig.issuer %q uses http:// with a non-localhost host; "+ "set spec.authServerConfig.insecureAllowHTTP: true to allow this for trusted "+ @@ -517,7 +515,7 @@ func (*VirtualMCPServerReconciler) validateAuthServerConfig( metav1.ConditionFalse, ) statusManager.SetObservedGeneration(vmcp.Generation) - return fmt.Errorf("%s", message) + return stderrors.New(message) } } @@ -531,7 +529,7 @@ func (*VirtualMCPServerReconciler) validateAuthServerConfig( metav1.ConditionFalse, ) statusManager.SetObservedGeneration(vmcp.Generation) - return fmt.Errorf("%s", message) + return stderrors.New(message) } // Validate additionalAuthorizationParams on each upstream provider @@ -548,7 +546,7 @@ func (*VirtualMCPServerReconciler) validateAuthServerConfig( metav1.ConditionFalse, ) statusManager.SetObservedGeneration(vmcp.Generation) - return fmt.Errorf("%s", message) + return stderrors.New(message) } } diff --git a/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpexternalauthconfigs.yaml b/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpexternalauthconfigs.yaml index 867114b075..6a825d9800 100644 --- a/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpexternalauthconfigs.yaml +++ b/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpexternalauthconfigs.yaml @@ -213,7 +213,8 @@ spec: All other endpoints (token, registration, JWKS) remain derived from the issuer. This is useful when the browser-facing authorization endpoint needs to be on a different host than the issuer used for backend-to-backend calls. - Must be a valid HTTPS URL (or HTTP for localhost) without query, fragment, or trailing slash. + Must be a valid HTTPS URL (or HTTP for localhost, or HTTP for trusted in-cluster hosts + when insecureAllowHTTP is true) without query, fragment, or trailing slash. pattern: ^https?://[^\s?#]+[^/\s?#]$ type: string baselineClientScopes: @@ -314,8 +315,14 @@ spec: Only set this for in-cluster Kubernetes deployments where traffic between pods traverses a trusted network (e.g. the in-cluster service mesh). Production deployments reachable outside the cluster MUST use https://. - When false (the default), http:// issuers are rejected at admission for any - non-localhost host, and the pod will crash at startup with a validation error. + + On VirtualMCPServer: when false (the default), http:// issuers for non-localhost + hosts are rejected at reconcile time with an AuthServerConfigValidated=False condition. + + On MCPServer and MCPRemoteProxy (via MCPExternalAuthConfig): this field is + structurally present but enforcement is deferred to pod startup via Config.Validate(); + a misconfigured issuer will cause the pod to crash at startup rather than surface + as an operator condition. type: boolean issuer: description: |- @@ -1704,7 +1711,8 @@ spec: All other endpoints (token, registration, JWKS) remain derived from the issuer. This is useful when the browser-facing authorization endpoint needs to be on a different host than the issuer used for backend-to-backend calls. - Must be a valid HTTPS URL (or HTTP for localhost) without query, fragment, or trailing slash. + Must be a valid HTTPS URL (or HTTP for localhost, or HTTP for trusted in-cluster hosts + when insecureAllowHTTP is true) without query, fragment, or trailing slash. pattern: ^https?://[^\s?#]+[^/\s?#]$ type: string baselineClientScopes: @@ -1805,8 +1813,14 @@ spec: Only set this for in-cluster Kubernetes deployments where traffic between pods traverses a trusted network (e.g. the in-cluster service mesh). Production deployments reachable outside the cluster MUST use https://. - When false (the default), http:// issuers are rejected at admission for any - non-localhost host, and the pod will crash at startup with a validation error. + + On VirtualMCPServer: when false (the default), http:// issuers for non-localhost + hosts are rejected at reconcile time with an AuthServerConfigValidated=False condition. + + On MCPServer and MCPRemoteProxy (via MCPExternalAuthConfig): this field is + structurally present but enforcement is deferred to pod startup via Config.Validate(); + a misconfigured issuer will cause the pod to crash at startup rather than surface + as an operator condition. type: boolean issuer: description: |- diff --git a/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_virtualmcpservers.yaml b/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_virtualmcpservers.yaml index 7a3bfb63c5..1ef661ca40 100644 --- a/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_virtualmcpservers.yaml +++ b/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_virtualmcpservers.yaml @@ -86,7 +86,8 @@ spec: All other endpoints (token, registration, JWKS) remain derived from the issuer. This is useful when the browser-facing authorization endpoint needs to be on a different host than the issuer used for backend-to-backend calls. - Must be a valid HTTPS URL (or HTTP for localhost) without query, fragment, or trailing slash. + Must be a valid HTTPS URL (or HTTP for localhost, or HTTP for trusted in-cluster hosts + when insecureAllowHTTP is true) without query, fragment, or trailing slash. pattern: ^https?://[^\s?#]+[^/\s?#]$ type: string baselineClientScopes: @@ -187,8 +188,14 @@ spec: Only set this for in-cluster Kubernetes deployments where traffic between pods traverses a trusted network (e.g. the in-cluster service mesh). Production deployments reachable outside the cluster MUST use https://. - When false (the default), http:// issuers are rejected at admission for any - non-localhost host, and the pod will crash at startup with a validation error. + + On VirtualMCPServer: when false (the default), http:// issuers for non-localhost + hosts are rejected at reconcile time with an AuthServerConfigValidated=False condition. + + On MCPServer and MCPRemoteProxy (via MCPExternalAuthConfig): this field is + structurally present but enforcement is deferred to pod startup via Config.Validate(); + a misconfigured issuer will cause the pod to crash at startup rather than surface + as an operator condition. type: boolean issuer: description: |- @@ -3329,7 +3336,8 @@ spec: All other endpoints (token, registration, JWKS) remain derived from the issuer. This is useful when the browser-facing authorization endpoint needs to be on a different host than the issuer used for backend-to-backend calls. - Must be a valid HTTPS URL (or HTTP for localhost) without query, fragment, or trailing slash. + Must be a valid HTTPS URL (or HTTP for localhost, or HTTP for trusted in-cluster hosts + when insecureAllowHTTP is true) without query, fragment, or trailing slash. pattern: ^https?://[^\s?#]+[^/\s?#]$ type: string baselineClientScopes: @@ -3430,8 +3438,14 @@ spec: Only set this for in-cluster Kubernetes deployments where traffic between pods traverses a trusted network (e.g. the in-cluster service mesh). Production deployments reachable outside the cluster MUST use https://. - When false (the default), http:// issuers are rejected at admission for any - non-localhost host, and the pod will crash at startup with a validation error. + + On VirtualMCPServer: when false (the default), http:// issuers for non-localhost + hosts are rejected at reconcile time with an AuthServerConfigValidated=False condition. + + On MCPServer and MCPRemoteProxy (via MCPExternalAuthConfig): this field is + structurally present but enforcement is deferred to pod startup via Config.Validate(); + a misconfigured issuer will cause the pod to crash at startup rather than surface + as an operator condition. type: boolean issuer: description: |- diff --git a/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpexternalauthconfigs.yaml b/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpexternalauthconfigs.yaml index 772f9d7fca..3d21527444 100644 --- a/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpexternalauthconfigs.yaml +++ b/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpexternalauthconfigs.yaml @@ -216,7 +216,8 @@ spec: All other endpoints (token, registration, JWKS) remain derived from the issuer. This is useful when the browser-facing authorization endpoint needs to be on a different host than the issuer used for backend-to-backend calls. - Must be a valid HTTPS URL (or HTTP for localhost) without query, fragment, or trailing slash. + Must be a valid HTTPS URL (or HTTP for localhost, or HTTP for trusted in-cluster hosts + when insecureAllowHTTP is true) without query, fragment, or trailing slash. pattern: ^https?://[^\s?#]+[^/\s?#]$ type: string baselineClientScopes: @@ -317,8 +318,14 @@ spec: Only set this for in-cluster Kubernetes deployments where traffic between pods traverses a trusted network (e.g. the in-cluster service mesh). Production deployments reachable outside the cluster MUST use https://. - When false (the default), http:// issuers are rejected at admission for any - non-localhost host, and the pod will crash at startup with a validation error. + + On VirtualMCPServer: when false (the default), http:// issuers for non-localhost + hosts are rejected at reconcile time with an AuthServerConfigValidated=False condition. + + On MCPServer and MCPRemoteProxy (via MCPExternalAuthConfig): this field is + structurally present but enforcement is deferred to pod startup via Config.Validate(); + a misconfigured issuer will cause the pod to crash at startup rather than surface + as an operator condition. type: boolean issuer: description: |- @@ -1707,7 +1714,8 @@ spec: All other endpoints (token, registration, JWKS) remain derived from the issuer. This is useful when the browser-facing authorization endpoint needs to be on a different host than the issuer used for backend-to-backend calls. - Must be a valid HTTPS URL (or HTTP for localhost) without query, fragment, or trailing slash. + Must be a valid HTTPS URL (or HTTP for localhost, or HTTP for trusted in-cluster hosts + when insecureAllowHTTP is true) without query, fragment, or trailing slash. pattern: ^https?://[^\s?#]+[^/\s?#]$ type: string baselineClientScopes: @@ -1808,8 +1816,14 @@ spec: Only set this for in-cluster Kubernetes deployments where traffic between pods traverses a trusted network (e.g. the in-cluster service mesh). Production deployments reachable outside the cluster MUST use https://. - When false (the default), http:// issuers are rejected at admission for any - non-localhost host, and the pod will crash at startup with a validation error. + + On VirtualMCPServer: when false (the default), http:// issuers for non-localhost + hosts are rejected at reconcile time with an AuthServerConfigValidated=False condition. + + On MCPServer and MCPRemoteProxy (via MCPExternalAuthConfig): this field is + structurally present but enforcement is deferred to pod startup via Config.Validate(); + a misconfigured issuer will cause the pod to crash at startup rather than surface + as an operator condition. type: boolean issuer: description: |- diff --git a/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_virtualmcpservers.yaml b/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_virtualmcpservers.yaml index 2b516b8b90..0b4e98d12c 100644 --- a/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_virtualmcpservers.yaml +++ b/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_virtualmcpservers.yaml @@ -89,7 +89,8 @@ spec: All other endpoints (token, registration, JWKS) remain derived from the issuer. This is useful when the browser-facing authorization endpoint needs to be on a different host than the issuer used for backend-to-backend calls. - Must be a valid HTTPS URL (or HTTP for localhost) without query, fragment, or trailing slash. + Must be a valid HTTPS URL (or HTTP for localhost, or HTTP for trusted in-cluster hosts + when insecureAllowHTTP is true) without query, fragment, or trailing slash. pattern: ^https?://[^\s?#]+[^/\s?#]$ type: string baselineClientScopes: @@ -190,8 +191,14 @@ spec: Only set this for in-cluster Kubernetes deployments where traffic between pods traverses a trusted network (e.g. the in-cluster service mesh). Production deployments reachable outside the cluster MUST use https://. - When false (the default), http:// issuers are rejected at admission for any - non-localhost host, and the pod will crash at startup with a validation error. + + On VirtualMCPServer: when false (the default), http:// issuers for non-localhost + hosts are rejected at reconcile time with an AuthServerConfigValidated=False condition. + + On MCPServer and MCPRemoteProxy (via MCPExternalAuthConfig): this field is + structurally present but enforcement is deferred to pod startup via Config.Validate(); + a misconfigured issuer will cause the pod to crash at startup rather than surface + as an operator condition. type: boolean issuer: description: |- @@ -3332,7 +3339,8 @@ spec: All other endpoints (token, registration, JWKS) remain derived from the issuer. This is useful when the browser-facing authorization endpoint needs to be on a different host than the issuer used for backend-to-backend calls. - Must be a valid HTTPS URL (or HTTP for localhost) without query, fragment, or trailing slash. + Must be a valid HTTPS URL (or HTTP for localhost, or HTTP for trusted in-cluster hosts + when insecureAllowHTTP is true) without query, fragment, or trailing slash. pattern: ^https?://[^\s?#]+[^/\s?#]$ type: string baselineClientScopes: @@ -3433,8 +3441,14 @@ spec: Only set this for in-cluster Kubernetes deployments where traffic between pods traverses a trusted network (e.g. the in-cluster service mesh). Production deployments reachable outside the cluster MUST use https://. - When false (the default), http:// issuers are rejected at admission for any - non-localhost host, and the pod will crash at startup with a validation error. + + On VirtualMCPServer: when false (the default), http:// issuers for non-localhost + hosts are rejected at reconcile time with an AuthServerConfigValidated=False condition. + + On MCPServer and MCPRemoteProxy (via MCPExternalAuthConfig): this field is + structurally present but enforcement is deferred to pod startup via Config.Validate(); + a misconfigured issuer will cause the pod to crash at startup rather than surface + as an operator condition. type: boolean issuer: description: |- diff --git a/docs/operator/crd-api.md b/docs/operator/crd-api.md index c7ac01532d..00a989b2fe 100644 --- a/docs/operator/crd-api.md +++ b/docs/operator/crd-api.md @@ -1284,7 +1284,7 @@ _Appears in:_ | Field | Description | Default | Validation | | --- | --- | --- | --- | | `issuer` _string_ | Issuer is the issuer identifier for this authorization server.
This will be included in the "iss" claim of issued tokens.
Must be a valid HTTPS URL (or HTTP for localhost, or HTTP for trusted in-cluster hosts when
insecureAllowHTTP is true) without query, fragment, or trailing slash (per RFC 8414). | | Pattern: `^https?://[^\s?#]+[^/\s?#]$`
Required: \{\}
| -| `authorizationEndpointBaseUrl` _string_ | AuthorizationEndpointBaseURL overrides the base URL used for the authorization_endpoint
in the OAuth discovery document. When set, the discovery document will advertise
`\{authorizationEndpointBaseUrl\}/oauth/authorize` instead of `\{issuer\}/oauth/authorize`.
All other endpoints (token, registration, JWKS) remain derived from the issuer.
This is useful when the browser-facing authorization endpoint needs to be on a
different host than the issuer used for backend-to-backend calls.
Must be a valid HTTPS URL (or HTTP for localhost) without query, fragment, or trailing slash. | | Pattern: `^https?://[^\s?#]+[^/\s?#]$`
Optional: \{\}
| +| `authorizationEndpointBaseUrl` _string_ | AuthorizationEndpointBaseURL overrides the base URL used for the authorization_endpoint
in the OAuth discovery document. When set, the discovery document will advertise
`\{authorizationEndpointBaseUrl\}/oauth/authorize` instead of `\{issuer\}/oauth/authorize`.
All other endpoints (token, registration, JWKS) remain derived from the issuer.
This is useful when the browser-facing authorization endpoint needs to be on a
different host than the issuer used for backend-to-backend calls.
Must be a valid HTTPS URL (or HTTP for localhost, or HTTP for trusted in-cluster hosts
when insecureAllowHTTP is true) without query, fragment, or trailing slash. | | Pattern: `^https?://[^\s?#]+[^/\s?#]$`
Optional: \{\}
| | `signingKeySecretRefs` _[api.v1beta1.SecretKeyRef](#apiv1beta1secretkeyref) array_ | SigningKeySecretRefs references Kubernetes Secrets containing signing keys for JWT operations.
Supports key rotation by allowing multiple keys (oldest keys are used for verification only).
If not specified, an ephemeral signing key will be auto-generated (development only -
JWTs will be invalid after restart). | | MaxItems: 5
Optional: \{\}
| | `hmacSecretRefs` _[api.v1beta1.SecretKeyRef](#apiv1beta1secretkeyref) array_ | HMACSecretRefs references Kubernetes Secrets containing symmetric secrets for signing
authorization codes and refresh tokens (opaque tokens).
Current secret must be at least 32 bytes and cryptographically random.
Supports secret rotation via multiple entries (first is current, rest are for verification).
If not specified, an ephemeral secret will be auto-generated (development only -
auth codes and refresh tokens will be invalid after restart). | | Optional: \{\}
| | `tokenLifespans` _[api.v1beta1.TokenLifespanConfig](#apiv1beta1tokenlifespanconfig)_ | TokenLifespans configures the duration that various tokens are valid.
If not specified, defaults are applied (access: 1h, refresh: 7d, authCode: 10m). | | Optional: \{\}
| @@ -1292,7 +1292,7 @@ _Appears in:_ | `primaryUpstreamProvider` _string_ | PrimaryUpstreamProvider names the upstream IDP whose access token Cedar
should read claims from when authorising a request. Must match the name
of one of the entries in UpstreamProviders. When empty, the controller
auto-selects the first entry of UpstreamProviders.
Only meaningful on VirtualMCPServer, where multiple upstream providers
can be configured and Cedar needs to pick which token's claims to
evaluate. The VirtualMCPServer controller validates this field against
UpstreamProviders at admission and rejects unresolvable values.
On MCPServer and MCPRemoteProxy this field is structurally present (the
EmbeddedAuthServerConfig struct is shared) but has no runtime effect:
those CRDs are restricted to a single upstream so there is no choice to
make. Setting it on those CRDs is silently ignored. | | MaxLength: 63
MinLength: 1
Pattern: `^[a-z0-9]([a-z0-9-]*[a-z0-9])?$`
Optional: \{\}
| | `storage` _[api.v1beta1.AuthServerStorageConfig](#apiv1beta1authserverstorageconfig)_ | Storage configures the storage backend for the embedded auth server.
If not specified, defaults to in-memory storage. | | Optional: \{\}
| | `disableUpstreamTokenInjection` _boolean_ | DisableUpstreamTokenInjection prevents the embedded auth server from injecting
upstream IdP tokens into requests forwarded to the backend MCP server.
When true, the embedded auth server still handles OAuth flows for clients,
but instead of swapping ToolHive JWTs for upstream tokens the proxy STRIPS
the client's credential headers (Authorization, Cookie, Proxy-Authorization)
after validating the JWT — the backend receives an unauthenticated request.
Use headerForward to attach static credentials (e.g. an API key) if the
backend needs them. Cannot be combined with token exchange or AWS STS,
which would re-add credentials after the strip.
This is useful when the backend MCP server does not require authentication
(e.g., public documentation servers) but you still want client authentication. | false | Optional: \{\}
| -| `insecureAllowHTTP` _boolean_ | InsecureAllowHTTP permits an http:// issuer URL for non-localhost hosts.
Only set this for in-cluster Kubernetes deployments where traffic between
pods traverses a trusted network (e.g. the in-cluster service mesh).
Production deployments reachable outside the cluster MUST use https://.
When false (the default), http:// issuers are rejected at admission for any
non-localhost host, and the pod will crash at startup with a validation error. | false | Optional: \{\}
| +| `insecureAllowHTTP` _boolean_ | InsecureAllowHTTP permits an http:// issuer URL for non-localhost hosts.
Only set this for in-cluster Kubernetes deployments where traffic between
pods traverses a trusted network (e.g. the in-cluster service mesh).
Production deployments reachable outside the cluster MUST use https://.
On VirtualMCPServer: when false (the default), http:// issuers for non-localhost
hosts are rejected at reconcile time with an AuthServerConfigValidated=False condition.
On MCPServer and MCPRemoteProxy (via MCPExternalAuthConfig): this field is
structurally present but enforcement is deferred to pod startup via Config.Validate();
a misconfigured issuer will cause the pod to crash at startup rather than surface
as an operator condition. | false | Optional: \{\}
| | `baselineClientScopes` _string array_ | BaselineClientScopes is a baseline set of OAuth 2.0 scopes guaranteed to be
included in every client registration. The embedded auth server unions these
scopes into the registered set returned by RFC 7591 Dynamic Client
Registration, so a client that narrows the `scope` field at /oauth/register
can still request the baseline scopes at /oauth/authorize. All values must
be present in the upstream-derived scopesSupported set; the auth server
fails to start if any value is missing.
Security: every client registered via /oauth/register will gain the
ability to request these scopes at /oauth/authorize, regardless of what
the client itself requested. Keep the baseline narrow (typically
"openid" and "offline_access"). Adding a privileged scope here — e.g.
"admin:read" — would grant it to every DCR-registered client, including
public clients like Claude Code, Cursor, and VS Code.
When cimd.enabled is true, every dynamically resolved CIMD client will
also gain the ability to request these scopes, including third-party
clients resolved from arbitrary HTTPS URLs. | | MaxItems: 10
items:MinLength: 1
items:Pattern: `^[\x21\x23-\x5B\x5D-\x7E]+$`
Optional: \{\}
| | `cimd` _[api.v1beta1.EmbeddedAuthServerCIMDConfig](#apiv1beta1embeddedauthservercimdconfig)_ | CIMD configures Client ID Metadata Document support. When omitted, CIMD is disabled. | | Optional: \{\}
|