Skip to content

Commit 3f86462

Browse files
authored
Add options, exposed through operator CRD, to use a CA bundle and authentication when fetching JWKs (#978)
1 parent 926ee9c commit 3f86462

File tree

20 files changed

+1043
-121
lines changed

20 files changed

+1043
-121
lines changed

cmd/thv-operator/api/v1alpha1/mcpserver_types.go

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -299,9 +299,16 @@ type KubernetesOIDCConfig struct {
299299
Issuer string `json:"issuer,omitempty"`
300300

301301
// JWKSURL is the URL to fetch the JWKS from
302-
// +kubebuilder:default="https://kubernetes.default.svc/openid/v1/jwks"
302+
// If empty, OIDC discovery will be used to automatically determine the JWKS URL
303303
// +optional
304304
JWKSURL string `json:"jwksUrl,omitempty"`
305+
306+
// UseClusterAuth enables using the Kubernetes cluster's CA bundle and service account token
307+
// When true, uses /var/run/secrets/kubernetes.io/serviceaccount/ca.crt for TLS verification
308+
// and /var/run/secrets/kubernetes.io/serviceaccount/token for bearer token authentication
309+
// Defaults to true if not specified
310+
// +optional
311+
UseClusterAuth *bool `json:"useClusterAuth"`
305312
}
306313

307314
// ConfigMapOIDCRef references a ConfigMap containing OIDC configuration
@@ -333,6 +340,22 @@ type InlineOIDCConfig struct {
333340
// ClientID is deprecated and will be removed in a future release.
334341
// +optional
335342
ClientID string `json:"clientId,omitempty"`
343+
344+
// ThvCABundlePath is the path to CA certificate bundle file for HTTPS requests
345+
// The file must be mounted into the pod (e.g., via ConfigMap or Secret volume)
346+
// +optional
347+
ThvCABundlePath string `json:"thvCABundlePath,omitempty"`
348+
349+
// JWKSAuthTokenPath is the path to file containing bearer token for JWKS/OIDC requests
350+
// The file must be mounted into the pod (e.g., via Secret volume)
351+
// +optional
352+
JWKSAuthTokenPath string `json:"jwksAuthTokenPath,omitempty"`
353+
354+
// JWKSAllowPrivateIP allows JWKS/OIDC endpoints on private IP addresses
355+
// Use with caution - only enable for trusted internal IDPs
356+
// +kubebuilder:default=false
357+
// +optional
358+
JWKSAllowPrivateIP bool `json:"jwksAllowPrivateIP"`
336359
}
337360

338361
// AuthzConfigRef defines a reference to authorization configuration

cmd/thv-operator/api/v1alpha1/zz_generated.deepcopy.go

Lines changed: 6 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

cmd/thv-operator/controllers/mcpserver_controller.go

Lines changed: 49 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1147,7 +1147,17 @@ func (*MCPServerReconciler) generateKubernetesOIDCArgs(m *mcpv1alpha1.MCPServer)
11471147

11481148
// Set defaults if config is nil
11491149
if config == nil {
1150-
config = &mcpv1alpha1.KubernetesOIDCConfig{}
1150+
logger.Infof("Kubernetes OIDCConfig is nil for MCPServer %s, using default configuration", m.Name)
1151+
defaultUseClusterAuth := true
1152+
config = &mcpv1alpha1.KubernetesOIDCConfig{
1153+
UseClusterAuth: &defaultUseClusterAuth, // Default to true
1154+
}
1155+
}
1156+
1157+
// Handle UseClusterAuth with default of true if nil
1158+
useClusterAuth := true // default value
1159+
if config.UseClusterAuth != nil {
1160+
useClusterAuth = *config.UseClusterAuth
11511161
}
11521162

11531163
// Issuer (default: https://kubernetes.default.svc)
@@ -1164,18 +1174,27 @@ func (*MCPServerReconciler) generateKubernetesOIDCArgs(m *mcpv1alpha1.MCPServer)
11641174
}
11651175
args = append(args, fmt.Sprintf("--oidc-audience=%s", audience))
11661176

1167-
// JWKS URL (default: https://kubernetes.default.svc/openid/v1/jwks)
1177+
// JWKS URL (optional - if empty, thv will use OIDC discovery)
11681178
jwksURL := config.JWKSURL
1169-
if jwksURL == "" {
1170-
jwksURL = "https://kubernetes.default.svc/openid/v1/jwks"
1179+
if jwksURL != "" {
1180+
args = append(args, fmt.Sprintf("--oidc-jwks-url=%s", jwksURL))
1181+
}
1182+
1183+
// Add cluster auth flags if enabled (default is true)
1184+
if useClusterAuth {
1185+
args = append(args, "--thv-ca-bundle=/var/run/secrets/kubernetes.io/serviceaccount/ca.crt")
1186+
args = append(args, "--jwks-auth-token-file=/var/run/secrets/kubernetes.io/serviceaccount/token")
1187+
args = append(args, "--jwks-allow-private-ip")
11711188
}
1172-
args = append(args, fmt.Sprintf("--oidc-jwks-url=%s", jwksURL))
11731189

11741190
return args
11751191
}
11761192

11771193
// generateConfigMapOIDCArgs generates OIDC args for ConfigMap-based configuration
1178-
func (r *MCPServerReconciler) generateConfigMapOIDCArgs(ctx context.Context, m *mcpv1alpha1.MCPServer) []string {
1194+
func (r *MCPServerReconciler) generateConfigMapOIDCArgs( // nolint:gocyclo
1195+
ctx context.Context,
1196+
m *mcpv1alpha1.MCPServer,
1197+
) []string {
11791198
var args []string
11801199
config := m.Spec.OIDCConfig.ConfigMap
11811200

@@ -1207,6 +1226,15 @@ func (r *MCPServerReconciler) generateConfigMapOIDCArgs(ctx context.Context, m *
12071226
if clientID, exists := configMap.Data["clientId"]; exists && clientID != "" {
12081227
args = append(args, fmt.Sprintf("--oidc-client-id=%s", clientID))
12091228
}
1229+
if thvCABundlePath, exists := configMap.Data["thvCABundlePath"]; exists && thvCABundlePath != "" {
1230+
args = append(args, fmt.Sprintf("--thv-ca-bundle=%s", thvCABundlePath))
1231+
}
1232+
if jwksAuthTokenPath, exists := configMap.Data["jwksAuthTokenPath"]; exists && jwksAuthTokenPath != "" {
1233+
args = append(args, fmt.Sprintf("--jwks-auth-token-file=%s", jwksAuthTokenPath))
1234+
}
1235+
if jwksAllowPrivateIP, exists := configMap.Data["jwksAllowPrivateIP"]; exists && jwksAllowPrivateIP == "true" {
1236+
args = append(args, "--jwks-allow-private-ip")
1237+
}
12101238

12111239
return args
12121240
}
@@ -1235,6 +1263,21 @@ func (*MCPServerReconciler) generateInlineOIDCArgs(m *mcpv1alpha1.MCPServer) []s
12351263
args = append(args, fmt.Sprintf("--oidc-jwks-url=%s", config.JWKSURL))
12361264
}
12371265

1266+
// CA Bundle path (optional)
1267+
if config.ThvCABundlePath != "" {
1268+
args = append(args, fmt.Sprintf("--thv-ca-bundle=%s", config.ThvCABundlePath))
1269+
}
1270+
1271+
// Auth token path (optional)
1272+
if config.JWKSAuthTokenPath != "" {
1273+
args = append(args, fmt.Sprintf("--jwks-auth-token-file=%s", config.JWKSAuthTokenPath))
1274+
}
1275+
1276+
// Allow private IP access (optional)
1277+
if config.JWKSAllowPrivateIP {
1278+
args = append(args, "--jwks-allow-private-ip")
1279+
}
1280+
12381281
return args
12391282
}
12401283

cmd/thv-operator/controllers/mcpserver_oidc_test.go

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,14 @@ import (
2727
"sigs.k8s.io/controller-runtime/pkg/client/fake"
2828

2929
mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1"
30+
"github.com/stacklok/toolhive/pkg/logger"
3031
)
3132

33+
func init() {
34+
// Initialize logger for tests
35+
logger.Initialize()
36+
}
37+
3238
func TestGenerateOIDCArgs(t *testing.T) {
3339
t.Parallel()
3440

@@ -73,7 +79,9 @@ func TestGenerateOIDCArgs(t *testing.T) {
7379
expectedArgs: []string{
7480
"--oidc-issuer=https://kubernetes.default.svc",
7581
"--oidc-audience=toolhive",
76-
"--oidc-jwks-url=https://kubernetes.default.svc/openid/v1/jwks",
82+
"--thv-ca-bundle=/var/run/secrets/kubernetes.io/serviceaccount/ca.crt",
83+
"--jwks-auth-token-file=/var/run/secrets/kubernetes.io/serviceaccount/token",
84+
"--jwks-allow-private-ip",
7785
},
7886
},
7987
{
@@ -100,6 +108,9 @@ func TestGenerateOIDCArgs(t *testing.T) {
100108
"--oidc-issuer=https://custom.issuer.com",
101109
"--oidc-audience=custom-audience",
102110
"--oidc-jwks-url=https://custom.issuer.com/jwks",
111+
"--thv-ca-bundle=/var/run/secrets/kubernetes.io/serviceaccount/ca.crt",
112+
"--jwks-auth-token-file=/var/run/secrets/kubernetes.io/serviceaccount/token",
113+
"--jwks-allow-private-ip",
103114
},
104115
},
105116
{
@@ -258,7 +269,9 @@ func TestGenerateKubernetesOIDCArgs(t *testing.T) {
258269
expectedArgs: []string{
259270
"--oidc-issuer=https://kubernetes.default.svc",
260271
"--oidc-audience=toolhive",
261-
"--oidc-jwks-url=https://kubernetes.default.svc/openid/v1/jwks",
272+
"--thv-ca-bundle=/var/run/secrets/kubernetes.io/serviceaccount/ca.crt",
273+
"--jwks-auth-token-file=/var/run/secrets/kubernetes.io/serviceaccount/token",
274+
"--jwks-allow-private-ip",
262275
},
263276
},
264277
{
@@ -278,7 +291,9 @@ func TestGenerateKubernetesOIDCArgs(t *testing.T) {
278291
expectedArgs: []string{
279292
"--oidc-issuer=https://kubernetes.default.svc",
280293
"--oidc-audience=toolhive",
281-
"--oidc-jwks-url=https://kubernetes.default.svc/openid/v1/jwks",
294+
"--thv-ca-bundle=/var/run/secrets/kubernetes.io/serviceaccount/ca.crt",
295+
"--jwks-auth-token-file=/var/run/secrets/kubernetes.io/serviceaccount/token",
296+
"--jwks-allow-private-ip",
282297
},
283298
},
284299
{
@@ -298,7 +313,9 @@ func TestGenerateKubernetesOIDCArgs(t *testing.T) {
298313
expectedArgs: []string{
299314
"--oidc-issuer=https://kubernetes.default.svc",
300315
"--oidc-audience=toolhive",
301-
"--oidc-jwks-url=https://kubernetes.default.svc/openid/v1/jwks",
316+
"--thv-ca-bundle=/var/run/secrets/kubernetes.io/serviceaccount/ca.crt",
317+
"--jwks-auth-token-file=/var/run/secrets/kubernetes.io/serviceaccount/token",
318+
"--jwks-allow-private-ip",
302319
},
303320
},
304321
{
@@ -320,7 +337,9 @@ func TestGenerateKubernetesOIDCArgs(t *testing.T) {
320337
expectedArgs: []string{
321338
"--oidc-issuer=https://kubernetes.default.svc",
322339
"--oidc-audience=toolhive",
323-
"--oidc-jwks-url=https://kubernetes.default.svc/openid/v1/jwks",
340+
"--thv-ca-bundle=/var/run/secrets/kubernetes.io/serviceaccount/ca.crt",
341+
"--jwks-auth-token-file=/var/run/secrets/kubernetes.io/serviceaccount/token",
342+
"--jwks-allow-private-ip",
324343
},
325344
},
326345
}

cmd/thv/app/config.go

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -285,8 +285,11 @@ func setRegistryURLCmdFunc(_ *cobra.Command, args []string) error {
285285
}
286286

287287
if !allowPrivateRegistryIp {
288-
registryClient := networking.GetHttpClient(false)
289-
_, err := registryClient.Get(registryURL)
288+
registryClient, err := networking.NewHttpClientBuilder().Build()
289+
if err != nil {
290+
return fmt.Errorf("failed to create HTTP client: %w", err)
291+
}
292+
_, err = registryClient.Get(registryURL)
290293
if err != nil && strings.Contains(fmt.Sprint(err), networking.ErrPrivateIpAddress) {
291294
return err
292295
}

cmd/thv/app/run.go

Lines changed: 43 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -58,25 +58,28 @@ permission profile. Additional configuration can be provided via flags.`,
5858
}
5959

6060
var (
61-
runTransport string
62-
runProxyMode string
63-
runName string
64-
runHost string
65-
runPort int
66-
runProxyPort int
67-
runTargetPort int
68-
runTargetHost string
69-
runPermissionProfile string
70-
runEnv []string
71-
runForeground bool
72-
runVolumes []string
73-
runSecrets []string
74-
runAuthzConfig string
75-
runAuditConfig string
76-
runEnableAudit bool
77-
runK8sPodPatch string
78-
runCACertPath string
79-
runVerifyImage string
61+
runTransport string
62+
runProxyMode string
63+
runName string
64+
runHost string
65+
runPort int
66+
runProxyPort int
67+
runTargetPort int
68+
runTargetHost string
69+
runPermissionProfile string
70+
runEnv []string
71+
runForeground bool
72+
runVolumes []string
73+
runSecrets []string
74+
runAuthzConfig string
75+
runAuditConfig string
76+
runEnableAudit bool
77+
runK8sPodPatch string
78+
runCACertPath string
79+
runVerifyImage string
80+
runThvCABundle string
81+
runJWKSAuthTokenFile string
82+
runJWKSAllowPrivateIP bool
8083

8184
// OpenTelemetry flags
8285
runOtelEndpoint string
@@ -176,6 +179,24 @@ func init() {
176179
retriever.VerifyImageDisabled,
177180
),
178181
)
182+
runCmd.Flags().StringVar(
183+
&runThvCABundle,
184+
"thv-ca-bundle",
185+
"",
186+
"Path to CA certificate bundle for ToolHive HTTP operations (JWKS, OIDC discovery, etc.)",
187+
)
188+
runCmd.Flags().StringVar(
189+
&runJWKSAuthTokenFile,
190+
"jwks-auth-token-file",
191+
"",
192+
"Path to file containing bearer token for authenticating JWKS/OIDC requests",
193+
)
194+
runCmd.Flags().BoolVar(
195+
&runJWKSAllowPrivateIP,
196+
"jwks-allow-private-ip",
197+
false,
198+
"Allow JWKS/OIDC endpoints on private IP addresses (use with caution)",
199+
)
179200

180201
// This is used for the K8s operator which wraps the run command, but shouldn't be visible to users.
181202
if err := runCmd.Flags().MarkHidden("k8s-pod-patch"); err != nil {
@@ -359,6 +380,9 @@ func runCmdFunc(cmd *cobra.Command, args []string) error {
359380
finalOtelEnvironmentVariables,
360381
runIsolateNetwork,
361382
runK8sPodPatch,
383+
runThvCABundle,
384+
runJWKSAuthTokenFile,
385+
runJWKSAllowPrivateIP,
362386
envVarValidator,
363387
types.ProxyMode(runProxyMode),
364388
)

deploy/charts/operator-crds/Chart.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,5 @@ apiVersion: v2
22
name: toolhive-operator-crds
33
description: A Helm chart for installing the ToolHive Operator CRDs into Kubernetes.
44
type: application
5-
version: 0.0.9
5+
version: 0.0.10
66
appVersion: "0.0.1"

deploy/charts/operator-crds/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11

22
# ToolHive Operator CRDs Helm Chart
33

4-
![Version: 0.0.9](https://img.shields.io/badge/Version-0.0.9-informational?style=flat-square)
4+
![Version: 0.0.10](https://img.shields.io/badge/Version-0.0.10-informational?style=flat-square)
55
![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square)
66

77
A Helm chart for installing the ToolHive Operator CRDs into Kubernetes.

deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpservers.yaml

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -158,9 +158,25 @@ spec:
158158
issuer:
159159
description: Issuer is the OIDC issuer URL
160160
type: string
161+
jwksAllowPrivateIP:
162+
default: false
163+
description: |-
164+
JWKSAllowPrivateIP allows JWKS/OIDC endpoints on private IP addresses
165+
Use with caution - only enable for trusted internal IDPs
166+
type: boolean
167+
jwksAuthTokenPath:
168+
description: |-
169+
JWKSAuthTokenPath is the path to file containing bearer token for JWKS/OIDC requests
170+
The file must be mounted into the pod (e.g., via Secret volume)
171+
type: string
161172
jwksUrl:
162173
description: JWKSURL is the URL to fetch the JWKS from
163174
type: string
175+
thvCABundlePath:
176+
description: |-
177+
ThvCABundlePath is the path to CA certificate bundle file for HTTPS requests
178+
The file must be mounted into the pod (e.g., via ConfigMap or Secret volume)
179+
type: string
164180
required:
165181
- issuer
166182
type: object
@@ -178,8 +194,9 @@ spec:
178194
description: Issuer is the OIDC issuer URL
179195
type: string
180196
jwksUrl:
181-
default: https://kubernetes.default.svc/openid/v1/jwks
182-
description: JWKSURL is the URL to fetch the JWKS from
197+
description: |-
198+
JWKSURL is the URL to fetch the JWKS from
199+
If empty, OIDC discovery will be used to automatically determine the JWKS URL
183200
type: string
184201
namespace:
185202
description: |-
@@ -190,6 +207,13 @@ spec:
190207
description: ServiceAccount is deprecated and will be removed
191208
in a future release.
192209
type: string
210+
useClusterAuth:
211+
description: |-
212+
UseClusterAuth enables using the Kubernetes cluster's CA bundle and service account token
213+
When true, uses /var/run/secrets/kubernetes.io/serviceaccount/ca.crt for TLS verification
214+
and /var/run/secrets/kubernetes.io/serviceaccount/token for bearer token authentication
215+
Defaults to true if not specified
216+
type: boolean
193217
type: object
194218
type:
195219
default: kubernetes

0 commit comments

Comments
 (0)