Skip to content

Commit 4571696

Browse files
yroblataskbot
andauthored
fix oidc and token exchange config for k8s (#2537)
* allow to consume the resource url for crd * fixes from review * fix bugs * fix tests * add token type to exchange * properly set token exchange --------- Co-authored-by: taskbot <[email protected]>
1 parent c40b04e commit 4571696

File tree

18 files changed

+176
-67
lines changed

18 files changed

+176
-67
lines changed

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,16 @@ type TokenExchangeConfig struct {
5252
// +optional
5353
Scopes []string `json:"scopes,omitempty"`
5454

55+
// SubjectTokenType is the type of the incoming subject token.
56+
// Accepts short forms: "access_token" (default), "id_token", "jwt"
57+
// Or full URNs: "urn:ietf:params:oauth:token-type:access_token",
58+
// "urn:ietf:params:oauth:token-type:id_token",
59+
// "urn:ietf:params:oauth:token-type:jwt"
60+
// For Google Workload Identity Federation with OIDC providers (like Okta), use "id_token"
61+
// +kubebuilder:validation:Pattern=`^(access_token|id_token|jwt|urn:ietf:params:oauth:token-type:(access_token|id_token|jwt))?$`
62+
// +optional
63+
SubjectTokenType string `json:"subjectTokenType,omitempty"`
64+
5565
// ExternalTokenHeaderName is the name of the custom header to use for the exchanged token.
5666
// If set, the exchanged token will be added to this custom header (e.g., "X-Upstream-Token").
5767
// If empty or not set, the exchanged token will replace the Authorization header (default behavior).

cmd/thv-operator/controllers/mcpremoteproxy_runconfig.go

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -200,13 +200,24 @@ func (r *MCPRemoteProxyReconciler) createRunConfigFromMCPRemoteProxy(
200200
// Use the RunConfigBuilder for operator context
201201
// Deployer is nil for remote proxies because they connect to external services
202202
// and do not require container deployment (unlike MCPServer which deploys containers)
203-
return runner.NewOperatorRunConfigBuilder(
203+
runConfig, err := runner.NewOperatorRunConfigBuilder(
204204
context.Background(),
205205
nil,
206206
nil,
207207
nil,
208208
options...,
209209
)
210+
if err != nil {
211+
return nil, err
212+
}
213+
214+
// Populate middleware configs from the configuration fields
215+
// This ensures that middleware_configs is properly set for serialization
216+
if err := runner.PopulateMiddlewareConfigs(runConfig); err != nil {
217+
return nil, fmt.Errorf("failed to populate middleware configs: %w", err)
218+
}
219+
220+
return runConfig, nil
210221
}
211222

212223
// validateRunConfigForRemoteProxy validates a RunConfig for remote proxy deployments

cmd/thv-operator/controllers/mcpserver_externalauth_runconfig_test.go

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import (
2929
ctrlutil "github.com/stacklok/toolhive/cmd/thv-operator/pkg/controllerutil"
3030
"github.com/stacklok/toolhive/pkg/container/kubernetes"
3131
"github.com/stacklok/toolhive/pkg/runner"
32+
"github.com/stacklok/toolhive/pkg/transport/types"
3233
)
3334

3435
// TestAddExternalAuthConfigOptions tests the addExternalAuthConfigOptions function
@@ -532,14 +533,23 @@ func TestCreateRunConfigFromMCPServer_WithExternalAuth(t *testing.T) {
532533
assert.Equal(t, "external-auth-server", config.Name)
533534
assert.Equal(t, "test:v1", config.Image)
534535

535-
// Verify middleware config was added
536+
// Verify middleware configs are populated (auth, tokenexchange, mcp-parser, usagemetrics)
536537
assert.NotNil(t, config.MiddlewareConfigs)
537-
assert.Len(t, config.MiddlewareConfigs, 1)
538-
assert.Equal(t, "tokenexchange", config.MiddlewareConfigs[0].Type)
538+
assert.GreaterOrEqual(t, len(config.MiddlewareConfigs), 1, "Should have at least tokenexchange middleware")
539+
540+
// Find the tokenexchange middleware
541+
var tokenExchangeMw *types.MiddlewareConfig
542+
for i := range config.MiddlewareConfigs {
543+
if config.MiddlewareConfigs[i].Type == "tokenexchange" {
544+
tokenExchangeMw = &config.MiddlewareConfigs[i]
545+
break
546+
}
547+
}
548+
require.NotNil(t, tokenExchangeMw, "tokenexchange middleware should be present")
539549

540550
// Verify middleware parameters
541551
var params map[string]interface{}
542-
err := json.Unmarshal(config.MiddlewareConfigs[0].Parameters, &params)
552+
err := json.Unmarshal(tokenExchangeMw.Parameters, &params)
543553
require.NoError(t, err)
544554

545555
tokenExchangeConfig, ok := params["token_exchange_config"].(map[string]interface{})

cmd/thv-operator/controllers/mcpserver_runconfig.go

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -218,13 +218,24 @@ func (r *MCPServerReconciler) createRunConfigFromMCPServer(m *mcpv1alpha1.MCPSer
218218
}
219219

220220
// Use the RunConfigBuilder for operator context with full builder pattern
221-
return runner.NewOperatorRunConfigBuilder(
221+
runConfig, err := runner.NewOperatorRunConfigBuilder(
222222
context.Background(),
223223
nil,
224224
envVars,
225225
nil,
226226
options...,
227227
)
228+
if err != nil {
229+
return nil, err
230+
}
231+
232+
// Populate middleware configs from the configuration fields
233+
// This ensures that middleware_configs is properly set for serialization
234+
if err := runner.PopulateMiddlewareConfigs(runConfig); err != nil {
235+
return nil, fmt.Errorf("failed to populate middleware configs: %w", err)
236+
}
237+
238+
return runConfig, nil
228239
}
229240

230241
// labelsForRunConfig returns labels for run config ConfigMap

cmd/thv-operator/pkg/controllerutil/tokenexchange.go

Lines changed: 21 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ package controllerutil
22

33
import (
44
"context"
5-
"encoding/json"
65
"fmt"
76

87
corev1 "k8s.io/api/core/v1"
@@ -12,7 +11,6 @@ import (
1211
mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1"
1312
"github.com/stacklok/toolhive/pkg/auth/tokenexchange"
1413
"github.com/stacklok/toolhive/pkg/runner"
15-
transporttypes "github.com/stacklok/toolhive/pkg/transport/types"
1614
)
1715

1816
// GenerateOpenTelemetryEnvVars generates OpenTelemetry environment variables
@@ -93,7 +91,8 @@ func GenerateTokenExchangeEnvVars(
9391
}
9492

9593
// AddExternalAuthConfigOptions adds external authentication configuration options to builder options
96-
// This creates middleware configuration for token exchange and is shared between MCPServer and MCPRemoteProxy
94+
// This creates token exchange configuration which will be automatically converted to middleware by
95+
// PopulateMiddlewareConfigs() when the runner starts. This ensures correct middleware ordering.
9796
func AddExternalAuthConfigOptions(
9897
ctx context.Context,
9998
c client.Client,
@@ -138,55 +137,34 @@ func AddExternalAuthConfigOptions(
138137
}
139138
}
140139

141-
// Use scopes array directly from spec
142-
scopes := tokenExchangeSpec.Scopes
143-
144140
// Determine header strategy based on ExternalTokenHeaderName
145141
headerStrategy := "replace" // Default strategy
146142
if tokenExchangeSpec.ExternalTokenHeaderName != "" {
147143
headerStrategy = "custom"
148144
}
149145

150-
// Build token exchange middleware configuration
151-
// Client secret is provided via TOOLHIVE_TOKEN_EXCHANGE_CLIENT_SECRET environment variable
152-
// to avoid embedding plaintext secrets in the ConfigMap
153-
tokenExchangeConfig := map[string]interface{}{
154-
"token_url": tokenExchangeSpec.TokenURL,
155-
"client_id": tokenExchangeSpec.ClientID,
156-
"audience": tokenExchangeSpec.Audience,
157-
}
158-
159-
if len(scopes) > 0 {
160-
tokenExchangeConfig["scopes"] = scopes
161-
}
162-
163-
if headerStrategy != "" {
164-
tokenExchangeConfig["header_strategy"] = headerStrategy
165-
}
166-
167-
if tokenExchangeSpec.ExternalTokenHeaderName != "" {
168-
tokenExchangeConfig["external_token_header_name"] = tokenExchangeSpec.ExternalTokenHeaderName
169-
}
170-
171-
// Create middleware parameters
172-
middlewareParams := map[string]interface{}{
173-
"token_exchange_config": tokenExchangeConfig,
174-
}
175-
176-
// Marshal parameters to JSON
177-
paramsJSON, err := json.Marshal(middlewareParams)
146+
// Normalize SubjectTokenType to full URN (accepts both short forms and full URNs)
147+
normalizedTokenType, err := tokenexchange.NormalizeTokenType(tokenExchangeSpec.SubjectTokenType)
178148
if err != nil {
179-
return fmt.Errorf("failed to marshal token exchange middleware parameters: %w", err)
149+
return fmt.Errorf("invalid subject token type: %w", err)
180150
}
181151

182-
// Create middleware config
183-
middlewareConfig := transporttypes.MiddlewareConfig{
184-
Type: tokenexchange.MiddlewareType,
185-
Parameters: json.RawMessage(paramsJSON),
186-
}
187-
188-
// Add to options using the WithMiddlewareConfig builder option
189-
*options = append(*options, runner.WithMiddlewareConfig([]transporttypes.MiddlewareConfig{middlewareConfig}))
152+
// Build token exchange configuration
153+
// Client secret is provided via TOOLHIVE_TOKEN_EXCHANGE_CLIENT_SECRET environment variable
154+
// to avoid embedding plaintext secrets in the ConfigMap
155+
tokenExchangeConfig := &tokenexchange.Config{
156+
TokenURL: tokenExchangeSpec.TokenURL,
157+
ClientID: tokenExchangeSpec.ClientID,
158+
Audience: tokenExchangeSpec.Audience,
159+
Scopes: tokenExchangeSpec.Scopes,
160+
SubjectTokenType: normalizedTokenType,
161+
HeaderStrategy: headerStrategy,
162+
ExternalTokenHeaderName: tokenExchangeSpec.ExternalTokenHeaderName,
163+
}
164+
165+
// Use WithTokenExchangeConfig to add configuration
166+
// The middleware will be automatically created by PopulateMiddlewareConfigs() in the correct order
167+
*options = append(*options, runner.WithTokenExchangeConfig(tokenExchangeConfig))
190168

191169
return nil
192170
}

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.54
5+
version: 0.0.55
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.54](https://img.shields.io/badge/Version-0.0.54-informational?style=flat-square)
4+
![Version: 0.0.55](https://img.shields.io/badge/Version-0.0.55-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/crds/toolhive.stacklok.dev_mcpexternalauthconfigs.yaml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,16 @@ spec:
9797
items:
9898
type: string
9999
type: array
100+
subjectTokenType:
101+
description: |-
102+
SubjectTokenType is the type of the incoming subject token.
103+
Accepts short forms: "access_token" (default), "id_token", "jwt"
104+
Or full URNs: "urn:ietf:params:oauth:token-type:access_token",
105+
"urn:ietf:params:oauth:token-type:id_token",
106+
"urn:ietf:params:oauth:token-type:jwt"
107+
For Google Workload Identity Federation with OIDC providers (like Okta), use "id_token"
108+
pattern: ^(access_token|id_token|jwt|urn:ietf:params:oauth:token-type:(access_token|id_token|jwt))?$
109+
type: string
100110
tokenUrl:
101111
description: TokenURL is the OAuth 2.0 token endpoint URL for
102112
token exchange

docs/operator/crd-api.md

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

docs/server/docs.go

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

0 commit comments

Comments
 (0)