Skip to content

Commit f638c69

Browse files
authored
Adds Missing OIDC RunConfig for Operator (#2201)
* adds auth to runconfig Signed-off-by: ChrisJBurns <29541485+ChrisJBurns@users.noreply.github.com> * adds oidc config resolver with tests Signed-off-by: ChrisJBurns <29541485+ChrisJBurns@users.noreply.github.com> * lint Signed-off-by: ChrisJBurns <29541485+ChrisJBurns@users.noreply.github.com> --------- Signed-off-by: ChrisJBurns <29541485+ChrisJBurns@users.noreply.github.com>
1 parent 261fd65 commit f638c69

File tree

4 files changed

+696
-53
lines changed

4 files changed

+696
-53
lines changed

cmd/thv-operator/controllers/mcpserver_runconfig.go

Lines changed: 31 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import (
2121
"sigs.k8s.io/controller-runtime/pkg/log"
2222

2323
mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1"
24+
"github.com/stacklok/toolhive/cmd/thv-operator/pkg/oidc"
2425
"github.com/stacklok/toolhive/pkg/authz"
2526
"github.com/stacklok/toolhive/pkg/operator/accessors"
2627
"github.com/stacklok/toolhive/pkg/runner"
@@ -306,8 +307,9 @@ func (r *MCPServerReconciler) createRunConfigFromMCPServer(m *mcpv1alpha1.MCPSer
306307
return nil, fmt.Errorf("failed to process AuthzConfig: %w", err)
307308
}
308309

309-
// Add OIDC authentication configuration if specified
310-
addOIDCConfigOptions(&options, m.Spec.OIDCConfig)
310+
if err := r.addOIDCConfigOptions(ctx, &options, m); err != nil {
311+
return nil, fmt.Errorf("failed to process OIDCConfig: %w", err)
312+
}
311313

312314
// Add audit configuration if specified
313315
addAuditConfigOptions(&options, m.Spec.Audit)
@@ -732,34 +734,38 @@ func (r *MCPServerReconciler) addAuthzConfigOptions(
732734
}
733735

734736
// addOIDCConfigOptions adds OIDC authentication configuration options to the builder options
735-
func addOIDCConfigOptions(
737+
func (r *MCPServerReconciler) addOIDCConfigOptions(
738+
ctx context.Context,
736739
options *[]runner.RunConfigBuilderOption,
737-
oidcConfig *mcpv1alpha1.OIDCConfigRef,
738-
) {
739-
if oidcConfig == nil {
740-
return
741-
}
740+
m *mcpv1alpha1.MCPServer,
741+
) error {
742742

743-
// Handle inline OIDC configuration
744-
if oidcConfig.Type == mcpv1alpha1.OIDCConfigTypeInline && oidcConfig.Inline != nil {
745-
inline := oidcConfig.Inline
743+
// Use the OIDC resolver to get configuration
744+
resolver := oidc.NewResolver(r.Client)
745+
oidcConfig, err := resolver.Resolve(ctx, m)
746+
if err != nil {
747+
return fmt.Errorf("failed to resolve OIDC configuration: %w", err)
748+
}
746749

747-
// Add OIDC config to options
748-
*options = append(*options, runner.WithOIDCConfig(
749-
inline.Issuer,
750-
inline.Audience,
751-
inline.JWKSURL,
752-
inline.IntrospectionURL,
753-
inline.ClientID,
754-
inline.ClientSecret,
755-
inline.ThvCABundlePath,
756-
inline.JWKSAuthTokenPath,
757-
"", // resourceURL - not available in InlineOIDCConfig
758-
inline.JWKSAllowPrivateIP,
759-
))
750+
if oidcConfig == nil {
751+
return nil
760752
}
761753

762-
// ConfigMap and Kubernetes types are not currently supported for OIDC configuration
754+
// Add OIDC config to options
755+
*options = append(*options, runner.WithOIDCConfig(
756+
oidcConfig.Issuer,
757+
oidcConfig.Audience,
758+
oidcConfig.JWKSURL,
759+
oidcConfig.IntrospectionURL,
760+
oidcConfig.ClientID,
761+
oidcConfig.ClientSecret,
762+
oidcConfig.ThvCABundlePath,
763+
oidcConfig.JWKSAuthTokenPath,
764+
oidcConfig.ResourceURL,
765+
oidcConfig.JWKSAllowPrivateIP,
766+
))
767+
768+
return nil
763769
}
764770

765771
// addAuditConfigOptions adds audit configuration options to the builder options

cmd/thv-operator/controllers/mcpserver_runconfig_test.go

Lines changed: 0 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -526,34 +526,6 @@ func TestCreateRunConfigFromMCPServer(t *testing.T) {
526526
assert.True(t, config.OIDCConfig.AllowPrivateIP)
527527
},
528528
},
529-
{
530-
name: "with configmap OIDC authentication configuration",
531-
mcpServer: &mcpv1alpha1.MCPServer{
532-
ObjectMeta: metav1.ObjectMeta{
533-
Name: "oidc-configmap-server",
534-
Namespace: "test-ns",
535-
},
536-
Spec: mcpv1alpha1.MCPServerSpec{
537-
Image: testImage,
538-
Transport: stdioTransport,
539-
Port: 8080,
540-
OIDCConfig: &mcpv1alpha1.OIDCConfigRef{
541-
Type: mcpv1alpha1.OIDCConfigTypeConfigMap,
542-
ConfigMap: &mcpv1alpha1.ConfigMapOIDCRef{
543-
Name: "test-oidc-config",
544-
Key: "oidc.json",
545-
},
546-
},
547-
},
548-
},
549-
//nolint:thelper // We want to see the error at the specific line
550-
expected: func(t *testing.T, config *runner.RunConfig) {
551-
assert.Equal(t, "oidc-configmap-server", config.Name)
552-
// For ConfigMap type, OIDC config should not be set directly in RunConfig
553-
// since it will be handled by proxyrunner when reading from ConfigMap
554-
assert.Nil(t, config.OIDCConfig)
555-
},
556-
},
557529
{
558530
name: "with audit configuration enabled",
559531
mcpServer: &mcpv1alpha1.MCPServer{
Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
// Package oidc provides utilities for resolving OIDC configuration from various sources
2+
// including Kubernetes service accounts, ConfigMaps, and inline configurations.
3+
package oidc
4+
5+
import (
6+
"context"
7+
"fmt"
8+
9+
corev1 "k8s.io/api/core/v1"
10+
"k8s.io/apimachinery/pkg/types"
11+
"sigs.k8s.io/controller-runtime/pkg/client"
12+
"sigs.k8s.io/controller-runtime/pkg/log"
13+
14+
mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1"
15+
)
16+
17+
const (
18+
// K8s service account paths
19+
defaultK8sCABundlePath = "/var/run/secrets/kubernetes.io/serviceaccount/ca.crt"
20+
defaultK8sTokenPath = "/var/run/secrets/kubernetes.io/serviceaccount/token" //nolint:gosec
21+
defaultK8sIssuer = "https://kubernetes.default.svc"
22+
defaultK8sAudience = "toolhive"
23+
)
24+
25+
// OIDCConfig represents the resolved OIDC configuration values
26+
type OIDCConfig struct { //nolint:revive // Keeping OIDCConfig name for backward compatibility
27+
Issuer string
28+
Audience string
29+
JWKSURL string
30+
IntrospectionURL string
31+
ClientID string
32+
ClientSecret string
33+
ThvCABundlePath string
34+
JWKSAuthTokenPath string
35+
ResourceURL string
36+
JWKSAllowPrivateIP bool
37+
}
38+
39+
// Resolver is the interface for resolving OIDC configuration from various sources
40+
type Resolver interface {
41+
// Resolve takes an MCPServer and its OIDC configuration reference and returns the resolved OIDC config
42+
Resolve(ctx context.Context, mcpServer *mcpv1alpha1.MCPServer) (*OIDCConfig, error)
43+
}
44+
45+
// NewResolver creates a new OIDC configuration resolver
46+
// It accepts an optional Kubernetes client for ConfigMap resolution
47+
func NewResolver(k8sClient client.Client) Resolver {
48+
return &resolver{
49+
client: k8sClient,
50+
}
51+
}
52+
53+
// resolver is the concrete implementation of the Resolver interface
54+
type resolver struct {
55+
client client.Client
56+
}
57+
58+
// Resolve resolves the OIDC configuration based on the type specified in OIDCConfigRef
59+
func (r *resolver) Resolve(ctx context.Context, mcpServer *mcpv1alpha1.MCPServer) (*OIDCConfig, error) {
60+
if mcpServer.Spec.OIDCConfig == nil {
61+
return nil, nil
62+
}
63+
64+
oidcConfig := mcpServer.Spec.OIDCConfig
65+
66+
// Calculate resource URL for RFC 9728 compliance
67+
resourceURL := oidcConfig.ResourceURL
68+
if resourceURL == "" {
69+
resourceURL = createServiceURL(mcpServer.Name, mcpServer.Namespace, mcpServer.Spec.Port)
70+
}
71+
72+
switch oidcConfig.Type {
73+
case mcpv1alpha1.OIDCConfigTypeKubernetes:
74+
return r.resolveKubernetesConfig(ctx, oidcConfig.Kubernetes, resourceURL, mcpServer)
75+
case mcpv1alpha1.OIDCConfigTypeConfigMap:
76+
return r.resolveConfigMapConfig(ctx, oidcConfig.ConfigMap, resourceURL, mcpServer)
77+
case mcpv1alpha1.OIDCConfigTypeInline:
78+
return r.resolveInlineConfig(oidcConfig.Inline, resourceURL)
79+
default:
80+
return nil, fmt.Errorf("unknown OIDC config type: %s", oidcConfig.Type)
81+
}
82+
}
83+
84+
// resolveKubernetesConfig resolves OIDC configuration for Kubernetes type
85+
func (*resolver) resolveKubernetesConfig(
86+
ctx context.Context,
87+
config *mcpv1alpha1.KubernetesOIDCConfig,
88+
resourceURL string,
89+
mcpServer *mcpv1alpha1.MCPServer,
90+
) (*OIDCConfig, error) {
91+
// Set defaults if config is nil
92+
if config == nil {
93+
ctxLogger := log.FromContext(ctx)
94+
ctxLogger.Info("Kubernetes OIDCConfig is nil, using default configuration", "mcpServer", mcpServer.Name)
95+
defaultUseClusterAuth := true
96+
config = &mcpv1alpha1.KubernetesOIDCConfig{
97+
UseClusterAuth: &defaultUseClusterAuth,
98+
}
99+
}
100+
101+
// Handle UseClusterAuth with default of true if nil
102+
useClusterAuth := true // default value
103+
if config.UseClusterAuth != nil {
104+
useClusterAuth = *config.UseClusterAuth
105+
}
106+
107+
result := &OIDCConfig{
108+
ResourceURL: resourceURL,
109+
}
110+
111+
// Set issuer with default
112+
result.Issuer = config.Issuer
113+
if result.Issuer == "" {
114+
result.Issuer = defaultK8sIssuer
115+
}
116+
117+
// Set audience with default
118+
result.Audience = config.Audience
119+
if result.Audience == "" {
120+
result.Audience = defaultK8sAudience
121+
}
122+
123+
// Set JWKS and introspection URLs
124+
result.JWKSURL = config.JWKSURL
125+
result.IntrospectionURL = config.IntrospectionURL
126+
127+
// Apply cluster auth settings if enabled
128+
if useClusterAuth {
129+
result.ThvCABundlePath = defaultK8sCABundlePath
130+
result.JWKSAuthTokenPath = defaultK8sTokenPath
131+
result.JWKSAllowPrivateIP = true
132+
}
133+
134+
return result, nil
135+
}
136+
137+
// resolveConfigMapConfig resolves OIDC configuration from a ConfigMap
138+
func (r *resolver) resolveConfigMapConfig(
139+
ctx context.Context,
140+
configRef *mcpv1alpha1.ConfigMapOIDCRef,
141+
resourceURL string,
142+
mcpServer *mcpv1alpha1.MCPServer,
143+
) (*OIDCConfig, error) {
144+
if configRef == nil {
145+
return nil, nil
146+
}
147+
148+
if r.client == nil {
149+
return nil, fmt.Errorf("kubernetes client is required for ConfigMap OIDC resolution")
150+
}
151+
152+
// Read the ConfigMap
153+
configMap := &corev1.ConfigMap{}
154+
err := r.client.Get(ctx, types.NamespacedName{
155+
Name: configRef.Name,
156+
Namespace: mcpServer.Namespace,
157+
}, configMap)
158+
if err != nil {
159+
return nil, fmt.Errorf("failed to get OIDC ConfigMap %s/%s: %w",
160+
mcpServer.Namespace, configRef.Name, err)
161+
}
162+
163+
config := &OIDCConfig{
164+
ResourceURL: resourceURL,
165+
}
166+
167+
// Extract string values
168+
config.Issuer = getMapValue(configMap.Data, "issuer")
169+
config.Audience = getMapValue(configMap.Data, "audience")
170+
config.JWKSURL = getMapValue(configMap.Data, "jwksUrl")
171+
config.IntrospectionURL = getMapValue(configMap.Data, "introspectionUrl")
172+
config.ClientID = getMapValue(configMap.Data, "clientId")
173+
config.ClientSecret = getMapValue(configMap.Data, "clientSecret")
174+
config.ThvCABundlePath = getMapValue(configMap.Data, "thvCABundlePath")
175+
//nolint:gosec // This is just a config key name, not a credential
176+
config.JWKSAuthTokenPath = getMapValue(configMap.Data, "jwksAuthTokenPath")
177+
178+
// Handle boolean value
179+
if v, exists := configMap.Data["jwksAllowPrivateIP"]; exists && v == "true" {
180+
config.JWKSAllowPrivateIP = true
181+
}
182+
183+
return config, nil
184+
}
185+
186+
// resolveInlineConfig resolves inline OIDC configuration
187+
func (*resolver) resolveInlineConfig(
188+
config *mcpv1alpha1.InlineOIDCConfig,
189+
resourceURL string,
190+
) (*OIDCConfig, error) {
191+
if config == nil {
192+
return nil, nil
193+
}
194+
195+
return &OIDCConfig{
196+
Issuer: config.Issuer,
197+
Audience: config.Audience,
198+
JWKSURL: config.JWKSURL,
199+
IntrospectionURL: config.IntrospectionURL,
200+
ClientID: config.ClientID,
201+
ClientSecret: config.ClientSecret,
202+
ThvCABundlePath: config.ThvCABundlePath,
203+
JWKSAuthTokenPath: config.JWKSAuthTokenPath,
204+
ResourceURL: resourceURL,
205+
JWKSAllowPrivateIP: config.JWKSAllowPrivateIP,
206+
}, nil
207+
}
208+
209+
// getMapValue is a helper to extract string values from a map
210+
func getMapValue(data map[string]string, key string) string {
211+
if v, exists := data[key]; exists && v != "" {
212+
return v
213+
}
214+
return ""
215+
}
216+
217+
// createServiceURL creates a service URL from MCPServer details
218+
func createServiceURL(name, namespace string, port int32) string {
219+
if port == 0 {
220+
port = 8080
221+
}
222+
return fmt.Sprintf("http://%s.%s.svc.cluster.local:%d", name, namespace, port)
223+
}

0 commit comments

Comments
 (0)