Skip to content

Commit 6c4e023

Browse files
authored
Wire jwksUrl and introspectionUrl from vMCP inline OIDC config to runtime (#4501)
* Wire jwksUrl and introspectionUrl from vMCP inline OIDC config to runtime Implements changes for issue #4485: - Add JwksUrl and IntrospectionUrl fields to vmcpconfig.OIDCConfig - Map resolved JWKSURL/IntrospectionURL in CRD-to-vMCP converter - Pass JWKSURL/IntrospectionURL to auth.TokenValidatorConfig in auth factory - Add converter and YAML loader tests for the new fields - Fix pre-existing goconst lint issue in crd_cli_roundtrip_test.go * Address code review feedback Fixed issues from code review: - MEDIUM: Rename Go struct fields JwksUrl -> JWKSURL and IntrospectionUrl -> IntrospectionURL to follow codebase acronym conventions (JSON tags unchanged) - MEDIUM: Revert unrelated crd_cli_roundtrip_test.go cleanup that replaced "oidc" string literals with IncomingAuthTypeOIDC constant * Run `task operator-manifests` and `task crdref-gen`
1 parent b0f138b commit 6c4e023

8 files changed

Lines changed: 114 additions & 4 deletions

File tree

cmd/thv-operator/pkg/vmcpconfig/converter.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,6 +274,8 @@ func mapResolvedOIDCToVmcpConfig(
274274
ClientID: resolved.ClientID,
275275
Audience: resolved.Audience,
276276
Resource: resolved.ResourceURL,
277+
JWKSURL: resolved.JWKSURL,
278+
IntrospectionURL: resolved.IntrospectionURL,
277279
ProtectedResourceAllowPrivateIP: resolved.JWKSAllowPrivateIP,
278280
InsecureAllowHTTP: resolved.InsecureAllowHTTP,
279281
Scopes: resolved.Scopes,

cmd/thv-operator/pkg/vmcpconfig/converter_test.go

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@ func TestConverter_OIDCResolution(t *testing.T) {
8989
mockReturn: &oidc.OIDCConfig{
9090
Issuer: "https://issuer.example.com", Audience: "my-audience",
9191
ResourceURL: "https://resource.example.com", JWKSAllowPrivateIP: true,
92+
JWKSURL: "https://issuer.example.com/jwks", IntrospectionURL: "https://issuer.example.com/introspect",
9293
},
9394
validate: func(t *testing.T, config *vmcpconfig.Config, err error) {
9495
t.Helper()
@@ -97,6 +98,8 @@ func TestConverter_OIDCResolution(t *testing.T) {
9798
assert.Equal(t, "https://issuer.example.com", config.IncomingAuth.OIDC.Issuer)
9899
assert.Equal(t, "my-audience", config.IncomingAuth.OIDC.Audience)
99100
assert.Equal(t, "https://resource.example.com", config.IncomingAuth.OIDC.Resource)
101+
assert.Equal(t, "https://issuer.example.com/jwks", config.IncomingAuth.OIDC.JWKSURL)
102+
assert.Equal(t, "https://issuer.example.com/introspect", config.IncomingAuth.OIDC.IntrospectionURL)
100103
assert.True(t, config.IncomingAuth.OIDC.ProtectedResourceAllowPrivateIP)
101104
},
102105
},
@@ -310,6 +313,31 @@ func TestConverter_IncomingAuthRequired(t *testing.T) {
310313
},
311314
description: "Should correctly convert OIDC auth config with scopes",
312315
},
316+
{
317+
name: "oidc auth with jwksUrl and introspectionUrl",
318+
incomingAuth: &mcpv1alpha1.IncomingAuthConfig{
319+
Type: "oidc",
320+
OIDCConfig: &mcpv1alpha1.OIDCConfigRef{
321+
Type: "inline",
322+
Inline: &mcpv1alpha1.InlineOIDCConfig{
323+
Issuer: "https://auth.example.com",
324+
ClientID: "test-client",
325+
Audience: "test-audience",
326+
JWKSURL: "https://auth.example.com/custom/jwks",
327+
IntrospectionURL: "https://auth.example.com/custom/introspect",
328+
},
329+
},
330+
},
331+
expectedAuthType: "oidc",
332+
expectedOIDCConfig: &vmcpconfig.OIDCConfig{
333+
Issuer: "https://auth.example.com",
334+
ClientID: "test-client",
335+
Audience: "test-audience",
336+
JWKSURL: "https://auth.example.com/custom/jwks",
337+
IntrospectionURL: "https://auth.example.com/custom/introspect",
338+
},
339+
description: "Should correctly convert OIDC auth config with jwksUrl and introspectionUrl",
340+
},
313341
}
314342

315343
for _, tt := range tests {
@@ -334,10 +362,12 @@ func TestConverter_IncomingAuthRequired(t *testing.T) {
334362
// Configure mock to return expected OIDC config
335363
if tt.expectedOIDCConfig != nil {
336364
mockResolver.EXPECT().Resolve(gomock.Any(), gomock.Any()).Return(&oidc.OIDCConfig{
337-
Issuer: tt.expectedOIDCConfig.Issuer,
338-
ClientID: tt.expectedOIDCConfig.ClientID,
339-
Audience: tt.expectedOIDCConfig.Audience,
340-
Scopes: tt.expectedOIDCConfig.Scopes,
365+
Issuer: tt.expectedOIDCConfig.Issuer,
366+
ClientID: tt.expectedOIDCConfig.ClientID,
367+
Audience: tt.expectedOIDCConfig.Audience,
368+
JWKSURL: tt.expectedOIDCConfig.JWKSURL,
369+
IntrospectionURL: tt.expectedOIDCConfig.IntrospectionURL,
370+
Scopes: tt.expectedOIDCConfig.Scopes,
341371
}, nil)
342372
} else {
343373
mockResolver.EXPECT().Resolve(gomock.Any(), gomock.Any()).Return(nil, nil).AnyTimes()
@@ -361,6 +391,8 @@ func TestConverter_IncomingAuthRequired(t *testing.T) {
361391
assert.Equal(t, tt.expectedOIDCConfig.Issuer, config.IncomingAuth.OIDC.Issuer, tt.description)
362392
assert.Equal(t, tt.expectedOIDCConfig.ClientID, config.IncomingAuth.OIDC.ClientID, tt.description)
363393
assert.Equal(t, tt.expectedOIDCConfig.Audience, config.IncomingAuth.OIDC.Audience, tt.description)
394+
assert.Equal(t, tt.expectedOIDCConfig.JWKSURL, config.IncomingAuth.OIDC.JWKSURL, tt.description)
395+
assert.Equal(t, tt.expectedOIDCConfig.IntrospectionURL, config.IncomingAuth.OIDC.IntrospectionURL, tt.description)
364396
assert.Equal(t, tt.expectedOIDCConfig.Scopes, config.IncomingAuth.OIDC.Scopes, tt.description)
365397
} else {
366398
assert.Nil(t, config.IncomingAuth.OIDC, tt.description)

deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_virtualmcpservers.yaml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1196,6 +1196,11 @@ spec:
11961196
InsecureAllowHTTP allows HTTP (non-HTTPS) OIDC issuers for development/testing
11971197
WARNING: This is insecure and should NEVER be used in production
11981198
type: boolean
1199+
introspectionUrl:
1200+
description: |-
1201+
IntrospectionURL is the token introspection endpoint URL (RFC 7662).
1202+
When set, enables token introspection for opaque (non-JWT) tokens.
1203+
type: string
11991204
issuer:
12001205
description: Issuer is the OIDC issuer URL.
12011206
pattern: ^https?://
@@ -1207,6 +1212,12 @@ spec:
12071212
the OIDC middleware needs to fetch its JWKS from that address.
12081213
Use with caution - only enable for trusted internal IDPs or testing.
12091214
type: boolean
1215+
jwksUrl:
1216+
description: |-
1217+
JWKSURL is the explicit JWKS endpoint URL.
1218+
When set, skips OIDC discovery and fetches the JWKS directly from this URL.
1219+
This is useful when the OIDC issuer does not serve a /.well-known/openid-configuration.
1220+
type: string
12101221
protectedResourceAllowPrivateIp:
12111222
description: |-
12121223
ProtectedResourceAllowPrivateIP allows protected resource endpoint on private IP addresses

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

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1199,6 +1199,11 @@ spec:
11991199
InsecureAllowHTTP allows HTTP (non-HTTPS) OIDC issuers for development/testing
12001200
WARNING: This is insecure and should NEVER be used in production
12011201
type: boolean
1202+
introspectionUrl:
1203+
description: |-
1204+
IntrospectionURL is the token introspection endpoint URL (RFC 7662).
1205+
When set, enables token introspection for opaque (non-JWT) tokens.
1206+
type: string
12021207
issuer:
12031208
description: Issuer is the OIDC issuer URL.
12041209
pattern: ^https?://
@@ -1210,6 +1215,12 @@ spec:
12101215
the OIDC middleware needs to fetch its JWKS from that address.
12111216
Use with caution - only enable for trusted internal IDPs or testing.
12121217
type: boolean
1218+
jwksUrl:
1219+
description: |-
1220+
JWKSURL is the explicit JWKS endpoint URL.
1221+
When set, skips OIDC discovery and fetches the JWKS directly from this URL.
1222+
This is useful when the OIDC issuer does not serve a /.well-known/openid-configuration.
1223+
type: string
12131224
protectedResourceAllowPrivateIp:
12141225
description: |-
12151226
ProtectedResourceAllowPrivateIP allows protected resource endpoint on private IP addresses

docs/operator/crd-api.md

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

pkg/vmcp/auth/factory/incoming.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,8 @@ func newOIDCAuthMiddleware(
168168
ClientID: oidcCfg.ClientID,
169169
Audience: oidcCfg.Audience,
170170
ResourceURL: oidcCfg.Resource,
171+
JWKSURL: oidcCfg.JWKSURL,
172+
IntrospectionURL: oidcCfg.IntrospectionURL,
171173
AllowPrivateIP: oidcCfg.ProtectedResourceAllowPrivateIP || oidcCfg.JwksAllowPrivateIP,
172174
InsecureAllowHTTP: oidcCfg.InsecureAllowHTTP,
173175
Scopes: oidcCfg.Scopes,

pkg/vmcp/config/config.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,17 @@ type OIDCConfig struct {
218218
// If not specified, defaults to Audience.
219219
Resource string `json:"resource,omitempty" yaml:"resource,omitempty"`
220220

221+
// JWKSURL is the explicit JWKS endpoint URL.
222+
// When set, skips OIDC discovery and fetches the JWKS directly from this URL.
223+
// This is useful when the OIDC issuer does not serve a /.well-known/openid-configuration.
224+
// +optional
225+
JWKSURL string `json:"jwksUrl,omitempty" yaml:"jwksUrl,omitempty"`
226+
227+
// IntrospectionURL is the token introspection endpoint URL (RFC 7662).
228+
// When set, enables token introspection for opaque (non-JWT) tokens.
229+
// +optional
230+
IntrospectionURL string `json:"introspectionUrl,omitempty" yaml:"introspectionUrl,omitempty"`
231+
221232
// Scopes are the required OAuth scopes.
222233
Scopes []string `json:"scopes,omitempty" yaml:"scopes,omitempty"`
223234

pkg/vmcp/config/yaml_loader_test.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,45 @@ aggregation:
130130
},
131131
wantErr: false,
132132
},
133+
{
134+
name: "valid OIDC configuration with jwksUrl and introspectionUrl",
135+
yaml: `
136+
name: test-vmcp
137+
groupRef: test-group
138+
139+
incomingAuth:
140+
type: oidc
141+
oidc:
142+
issuer: https://auth.example.com
143+
clientId: test-client
144+
audience: vmcp
145+
jwksUrl: https://auth.example.com/custom/jwks
146+
introspectionUrl: https://auth.example.com/custom/introspect
147+
148+
outgoingAuth:
149+
source: inline
150+
default:
151+
type: unauthenticated
152+
153+
aggregation:
154+
conflictResolution: prefix
155+
conflictResolutionConfig:
156+
prefixFormat: "{workload}_"
157+
`,
158+
want: func(t *testing.T, cfg *Config) {
159+
t.Helper()
160+
if cfg.IncomingAuth.OIDC == nil {
161+
t.Fatal("IncomingAuth.OIDC is nil")
162+
}
163+
if cfg.IncomingAuth.OIDC.JWKSURL != "https://auth.example.com/custom/jwks" {
164+
t.Errorf("OIDC.JWKSURL = %v, want https://auth.example.com/custom/jwks", cfg.IncomingAuth.OIDC.JWKSURL)
165+
}
166+
if cfg.IncomingAuth.OIDC.IntrospectionURL != "https://auth.example.com/custom/introspect" {
167+
t.Errorf("OIDC.IntrospectionURL = %v, want https://auth.example.com/custom/introspect", cfg.IncomingAuth.OIDC.IntrospectionURL)
168+
}
169+
},
170+
wantErr: false,
171+
},
133172
{
134173
name: "partial operational config gets defaults for missing fields",
135174
yaml: `

0 commit comments

Comments
 (0)