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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions cmd/thv-operator/pkg/vmcpconfig/converter.go
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,7 @@ func mapResolvedOIDCToVmcpConfigFromRef(
JwksAllowPrivateIP: resolved.JWKSAllowPrivateIP,
InsecureAllowHTTP: resolved.InsecureAllowHTTP,
Scopes: resolved.Scopes,
CABundlePath: resolved.ThvCABundlePath,
}

// MCPOIDCConfig inline type may have a client secret
Expand Down
46 changes: 46 additions & 0 deletions cmd/thv-operator/pkg/vmcpconfig/converter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,52 @@ func TestConverter_OIDCResolution(t *testing.T) {
assert.Equal(t, "VMCP_OIDC_CLIENT_SECRET", config.IncomingAuth.OIDC.ClientSecretEnv)
},
},
{
name: "inline resolved ThvCABundlePath maps to CABundlePath",
oidcConfigRef: &mcpv1alpha1.MCPOIDCConfigReference{Name: oidcConfigName, Audience: "test-audience"},
oidcConfig: newTestMCPOIDCConfig(mcpv1alpha1.MCPOIDCConfigTypeInline),
mockReturn: &oidc.OIDCConfig{
Issuer: "https://issuer.example.com",
ThvCABundlePath: "/config/certs/example-ca/ca.crt",
},
validate: func(t *testing.T, config *vmcpconfig.Config, err error) {
t.Helper()
require.NoError(t, err)
require.NotNil(t, config.IncomingAuth.OIDC)
assert.Equal(t, "/config/certs/example-ca/ca.crt", config.IncomingAuth.OIDC.CABundlePath)
},
},
{
name: "k8s service account ThvCABundlePath maps to CABundlePath",
oidcConfigRef: &mcpv1alpha1.MCPOIDCConfigReference{Name: oidcConfigName, Audience: "test-audience"},
oidcConfig: newTestMCPOIDCConfig(mcpv1alpha1.MCPOIDCConfigTypeKubernetesServiceAccount),
mockReturn: &oidc.OIDCConfig{
Issuer: "https://kubernetes.default.svc",
ThvCABundlePath: "/var/run/secrets/kubernetes.io/serviceaccount/ca.crt",
},
validate: func(t *testing.T, config *vmcpconfig.Config, err error) {
t.Helper()
require.NoError(t, err)
require.NotNil(t, config.IncomingAuth.OIDC)
assert.Equal(t,
"/var/run/secrets/kubernetes.io/serviceaccount/ca.crt",
config.IncomingAuth.OIDC.CABundlePath)
},
},
{
name: "empty ThvCABundlePath results in empty CABundlePath",
oidcConfigRef: &mcpv1alpha1.MCPOIDCConfigReference{Name: oidcConfigName, Audience: "test-audience"},
oidcConfig: newTestMCPOIDCConfig(mcpv1alpha1.MCPOIDCConfigTypeInline),
mockReturn: &oidc.OIDCConfig{
Issuer: "https://issuer.example.com",
},
validate: func(t *testing.T, config *vmcpconfig.Config, err error) {
t.Helper()
require.NoError(t, err)
require.NotNil(t, config.IncomingAuth.OIDC)
assert.Empty(t, config.IncomingAuth.OIDC.CABundlePath)
},
},
{
name: "non-inline type does not set ClientSecretEnv",
oidcConfigRef: &mcpv1alpha1.MCPOIDCConfigReference{Name: oidcConfigName, Audience: "test-audience"},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ package controllers
import (
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
appsv1 "k8s.io/api/apps/v1"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/api/meta"
Expand Down Expand Up @@ -731,4 +732,181 @@ var _ = Describe("MCPOIDCConfig and VirtualMCPServer Cross-Resource Integration
}, timeout, interval).Should(BeTrue())
})
})

Context("When MCPOIDCConfig inline.caBundleRef is set", Ordered, func() {
var (
namespace string
configName string
vmcpName string
groupName string
caCMName string
caCMKey string
caConfigMap *corev1.ConfigMap
oidcConfig *mcpv1alpha1.MCPOIDCConfig
vmcpServer *mcpv1alpha1.VirtualMCPServer
mcpGroup *mcpv1alpha1.MCPGroup
ns *corev1.Namespace
)

BeforeAll(func() {
ns = &corev1.Namespace{
ObjectMeta: metav1.ObjectMeta{GenerateName: "test-vmcp-oidc-cabundle-"},
}
Expect(k8sClient.Create(ctx, ns)).Should(Succeed())
namespace = ns.Name

configName = testOIDCConfigName
vmcpName = testVMCPServerName
groupName = testVMCPGroupName
caCMName = "vmcp-oidc-ca"
caCMKey = "ca.crt"

// ConfigMap holding the CA bundle. Content is a placeholder — the operator
// only cares about mounting the ConfigMap at the right path.
caConfigMap = &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{
Name: caCMName,
Namespace: namespace,
},
Data: map[string]string{
caCMKey: "-----BEGIN CERTIFICATE-----\nplaceholder\n-----END CERTIFICATE-----\n",
},
}
Expect(k8sClient.Create(ctx, caConfigMap)).Should(Succeed())

mcpGroup = &mcpv1alpha1.MCPGroup{
ObjectMeta: metav1.ObjectMeta{
Name: groupName,
Namespace: namespace,
},
}
Expect(k8sClient.Create(ctx, mcpGroup)).Should(Succeed())

oidcConfig = &mcpv1alpha1.MCPOIDCConfig{
ObjectMeta: metav1.ObjectMeta{
Name: configName,
Namespace: namespace,
},
Spec: mcpv1alpha1.MCPOIDCConfigSpec{
Type: mcpv1alpha1.MCPOIDCConfigTypeInline,
Inline: &mcpv1alpha1.InlineOIDCSharedConfig{
Issuer: "https://auth.example.internal/realms/demo",
ClientID: "test-client",
CABundleRef: &mcpv1alpha1.CABundleSource{
ConfigMapRef: &corev1.ConfigMapKeySelector{
LocalObjectReference: corev1.LocalObjectReference{Name: caCMName},
Key: caCMKey,
},
},
JWKSAllowPrivateIP: true,
},
},
}
Expect(k8sClient.Create(ctx, oidcConfig)).Should(Succeed())

Eventually(func() bool {
updated := &mcpv1alpha1.MCPOIDCConfig{}
err := k8sClient.Get(ctx, types.NamespacedName{
Name: configName,
Namespace: namespace,
}, updated)
if err != nil || updated.Status.ConfigHash == "" {
return false
}
for _, cond := range updated.Status.Conditions {
if cond.Type == mcpv1alpha1.ConditionTypeOIDCConfigValid &&
cond.Status == metav1.ConditionTrue {
return true
}
}
return false
}, timeout, interval).Should(BeTrue())

vmcpServer = &mcpv1alpha1.VirtualMCPServer{
ObjectMeta: metav1.ObjectMeta{
Name: vmcpName,
Namespace: namespace,
},
Spec: mcpv1alpha1.VirtualMCPServerSpec{
GroupRef: &mcpv1alpha1.MCPGroupRef{Name: groupName},
Config: vmcpconfig.Config{Group: groupName},
IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{
Type: "oidc",
OIDCConfigRef: &mcpv1alpha1.MCPOIDCConfigReference{
Name: configName,
Audience: "test-vmcp-audience",
Scopes: []string{"openid"},
},
},
},
}
Expect(k8sClient.Create(ctx, vmcpServer)).Should(Succeed())
})

AfterAll(func() {
_ = k8sClient.Delete(ctx, vmcpServer)
_ = k8sClient.Delete(ctx, oidcConfig)
_ = k8sClient.Delete(ctx, mcpGroup)
_ = k8sClient.Delete(ctx, caConfigMap)
Expect(k8sClient.Delete(ctx, ns)).Should(Succeed())
})

It("should render the mounted CA path into the vmcp ConfigMap's OIDC config", func() {
configMapName := vmcpName + "-vmcp-config"
expectedPath := "/config/certs/" + caCMName + "/" + caCMKey

Eventually(func(g Gomega) {
cm := &corev1.ConfigMap{}
g.Expect(k8sClient.Get(ctx, types.NamespacedName{
Name: configMapName,
Namespace: namespace,
}, cm)).To(Succeed())
g.Expect(cm.Data).To(HaveKey("config.yaml"))

var config vmcpconfig.Config
g.Expect(yaml.Unmarshal([]byte(cm.Data["config.yaml"]), &config)).To(Succeed())
g.Expect(config.IncomingAuth).NotTo(BeNil())
g.Expect(config.IncomingAuth.OIDC).NotTo(BeNil())
g.Expect(config.IncomingAuth.OIDC.CABundlePath).To(Equal(expectedPath),
"rendered vmcp config must contain the mounted CA path so the OIDC middleware can trust the issuer")
}, timeout, interval).Should(Succeed())
})

It("should mount the CA ConfigMap as a read-only volume at /config/certs/<cm-name>", func() {
expectedMountPath := "/config/certs/" + caCMName

Eventually(func(g Gomega) {
deployment := &appsv1.Deployment{}
g.Expect(k8sClient.Get(ctx, types.NamespacedName{
Name: vmcpName,
Namespace: namespace,
}, deployment)).To(Succeed())

// The CA ConfigMap must be projected into a volume that sources from caCMName.
found := false
var volumeName string
for _, v := range deployment.Spec.Template.Spec.Volumes {
if v.ConfigMap != nil && v.ConfigMap.Name == caCMName {
found = true
volumeName = v.Name
break
}
}
g.Expect(found).To(BeTrue(), "Deployment must have a Volume sourcing from ConfigMap %q", caCMName)

// The same volume must be mounted read-only at /config/certs/<cm-name>.
var mount *corev1.VolumeMount
for i := range deployment.Spec.Template.Spec.Containers[0].VolumeMounts {
m := &deployment.Spec.Template.Spec.Containers[0].VolumeMounts[i]
if m.Name == volumeName {
mount = m
break
}
}
g.Expect(mount).NotTo(BeNil(), "Deployment container must mount the CA volume")
g.Expect(mount.MountPath).To(Equal(expectedMountPath))
g.Expect(mount.ReadOnly).To(BeTrue(), "CA bundle mount must be read-only")
}, timeout, interval).Should(Succeed())
})
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -1245,6 +1245,16 @@ spec:
audience:
description: Audience is the required token audience.
type: string
caBundlePath:
description: |-
CABundlePath is the absolute file path to a PEM-encoded CA certificate bundle
used when the OIDC middleware performs HTTPS requests to the issuer
(OIDC discovery, JWKS fetch, token introspection). When set, the CA bundle
at this path is added to the trust store used for verifying the issuer's
TLS certificate. Typically populated by the Kubernetes operator from
MCPOIDCConfig.spec.inline.caBundleRef (ConfigMap) or from the in-cluster
service-account CA when using Kubernetes service-account auth.
type: string
clientId:
description: ClientID is the OAuth client ID.
type: string
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1248,6 +1248,16 @@ spec:
audience:
description: Audience is the required token audience.
type: string
caBundlePath:
description: |-
CABundlePath is the absolute file path to a PEM-encoded CA certificate bundle
used when the OIDC middleware performs HTTPS requests to the issuer
(OIDC discovery, JWKS fetch, token introspection). When set, the CA bundle
at this path is added to the trust store used for verifying the issuer's
TLS certificate. Typically populated by the Kubernetes operator from
MCPOIDCConfig.spec.inline.caBundleRef (ConfigMap) or from the in-cluster
service-account CA when using Kubernetes service-account auth.
type: string
clientId:
description: ClientID is the OAuth client ID.
type: string
Expand Down
1 change: 1 addition & 0 deletions docs/operator/crd-api.md

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

1 change: 1 addition & 0 deletions pkg/vmcp/auth/factory/incoming.go
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,7 @@ func newOIDCAuthMiddleware(
AllowPrivateIP: oidcCfg.ProtectedResourceAllowPrivateIP || oidcCfg.JwksAllowPrivateIP,
InsecureAllowHTTP: oidcCfg.InsecureAllowHTTP,
Scopes: oidcCfg.Scopes,
CACertPath: oidcCfg.CABundlePath,
}

// Wire optional dependencies from the embedded auth server so the JWT
Expand Down
Loading
Loading