diff --git a/cmd/thv-operator/pkg/vmcpconfig/converter.go b/cmd/thv-operator/pkg/vmcpconfig/converter.go
index e4f7ebf775..940a5ef5a8 100644
--- a/cmd/thv-operator/pkg/vmcpconfig/converter.go
+++ b/cmd/thv-operator/pkg/vmcpconfig/converter.go
@@ -237,6 +237,8 @@ func mapResolvedOIDCToVmcpConfig(
ClientID: resolved.ClientID,
Audience: resolved.Audience,
Resource: resolved.ResourceURL,
+ JWKSURL: resolved.JWKSURL,
+ IntrospectionURL: resolved.IntrospectionURL,
ProtectedResourceAllowPrivateIP: resolved.JWKSAllowPrivateIP,
InsecureAllowHTTP: resolved.InsecureAllowHTTP,
Scopes: resolved.Scopes,
diff --git a/cmd/thv-operator/pkg/vmcpconfig/converter_test.go b/cmd/thv-operator/pkg/vmcpconfig/converter_test.go
index 1e8a44abe1..aff80f5368 100644
--- a/cmd/thv-operator/pkg/vmcpconfig/converter_test.go
+++ b/cmd/thv-operator/pkg/vmcpconfig/converter_test.go
@@ -89,6 +89,7 @@ func TestConverter_OIDCResolution(t *testing.T) {
mockReturn: &oidc.OIDCConfig{
Issuer: "https://issuer.example.com", Audience: "my-audience",
ResourceURL: "https://resource.example.com", JWKSAllowPrivateIP: true,
+ JWKSURL: "https://issuer.example.com/jwks", IntrospectionURL: "https://issuer.example.com/introspect",
},
validate: func(t *testing.T, config *vmcpconfig.Config, err error) {
t.Helper()
@@ -97,6 +98,8 @@ func TestConverter_OIDCResolution(t *testing.T) {
assert.Equal(t, "https://issuer.example.com", config.IncomingAuth.OIDC.Issuer)
assert.Equal(t, "my-audience", config.IncomingAuth.OIDC.Audience)
assert.Equal(t, "https://resource.example.com", config.IncomingAuth.OIDC.Resource)
+ assert.Equal(t, "https://issuer.example.com/jwks", config.IncomingAuth.OIDC.JWKSURL)
+ assert.Equal(t, "https://issuer.example.com/introspect", config.IncomingAuth.OIDC.IntrospectionURL)
assert.True(t, config.IncomingAuth.OIDC.ProtectedResourceAllowPrivateIP)
},
},
@@ -310,6 +313,31 @@ func TestConverter_IncomingAuthRequired(t *testing.T) {
},
description: "Should correctly convert OIDC auth config with scopes",
},
+ {
+ name: "oidc auth with jwksUrl and introspectionUrl",
+ incomingAuth: &mcpv1alpha1.IncomingAuthConfig{
+ Type: "oidc",
+ OIDCConfig: &mcpv1alpha1.OIDCConfigRef{
+ Type: "inline",
+ Inline: &mcpv1alpha1.InlineOIDCConfig{
+ Issuer: "https://auth.example.com",
+ ClientID: "test-client",
+ Audience: "test-audience",
+ JWKSURL: "https://auth.example.com/custom/jwks",
+ IntrospectionURL: "https://auth.example.com/custom/introspect",
+ },
+ },
+ },
+ expectedAuthType: "oidc",
+ expectedOIDCConfig: &vmcpconfig.OIDCConfig{
+ Issuer: "https://auth.example.com",
+ ClientID: "test-client",
+ Audience: "test-audience",
+ JWKSURL: "https://auth.example.com/custom/jwks",
+ IntrospectionURL: "https://auth.example.com/custom/introspect",
+ },
+ description: "Should correctly convert OIDC auth config with jwksUrl and introspectionUrl",
+ },
}
for _, tt := range tests {
@@ -334,10 +362,12 @@ func TestConverter_IncomingAuthRequired(t *testing.T) {
// Configure mock to return expected OIDC config
if tt.expectedOIDCConfig != nil {
mockResolver.EXPECT().Resolve(gomock.Any(), gomock.Any()).Return(&oidc.OIDCConfig{
- Issuer: tt.expectedOIDCConfig.Issuer,
- ClientID: tt.expectedOIDCConfig.ClientID,
- Audience: tt.expectedOIDCConfig.Audience,
- Scopes: tt.expectedOIDCConfig.Scopes,
+ Issuer: tt.expectedOIDCConfig.Issuer,
+ ClientID: tt.expectedOIDCConfig.ClientID,
+ Audience: tt.expectedOIDCConfig.Audience,
+ JWKSURL: tt.expectedOIDCConfig.JWKSURL,
+ IntrospectionURL: tt.expectedOIDCConfig.IntrospectionURL,
+ Scopes: tt.expectedOIDCConfig.Scopes,
}, nil)
} else {
mockResolver.EXPECT().Resolve(gomock.Any(), gomock.Any()).Return(nil, nil).AnyTimes()
@@ -361,6 +391,8 @@ func TestConverter_IncomingAuthRequired(t *testing.T) {
assert.Equal(t, tt.expectedOIDCConfig.Issuer, config.IncomingAuth.OIDC.Issuer, tt.description)
assert.Equal(t, tt.expectedOIDCConfig.ClientID, config.IncomingAuth.OIDC.ClientID, tt.description)
assert.Equal(t, tt.expectedOIDCConfig.Audience, config.IncomingAuth.OIDC.Audience, tt.description)
+ assert.Equal(t, tt.expectedOIDCConfig.JWKSURL, config.IncomingAuth.OIDC.JWKSURL, tt.description)
+ assert.Equal(t, tt.expectedOIDCConfig.IntrospectionURL, config.IncomingAuth.OIDC.IntrospectionURL, tt.description)
assert.Equal(t, tt.expectedOIDCConfig.Scopes, config.IncomingAuth.OIDC.Scopes, tt.description)
} else {
assert.Nil(t, config.IncomingAuth.OIDC, tt.description)
diff --git a/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_virtualmcpservers.yaml b/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_virtualmcpservers.yaml
index fe998a5076..aced9163fb 100644
--- a/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_virtualmcpservers.yaml
+++ b/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_virtualmcpservers.yaml
@@ -1196,6 +1196,11 @@ spec:
InsecureAllowHTTP allows HTTP (non-HTTPS) OIDC issuers for development/testing
WARNING: This is insecure and should NEVER be used in production
type: boolean
+ introspectionUrl:
+ description: |-
+ IntrospectionURL is the token introspection endpoint URL (RFC 7662).
+ When set, enables token introspection for opaque (non-JWT) tokens.
+ type: string
issuer:
description: Issuer is the OIDC issuer URL.
pattern: ^https?://
@@ -1207,6 +1212,12 @@ spec:
the OIDC middleware needs to fetch its JWKS from that address.
Use with caution - only enable for trusted internal IDPs or testing.
type: boolean
+ jwksUrl:
+ description: |-
+ JWKSURL is the explicit JWKS endpoint URL.
+ When set, skips OIDC discovery and fetches the JWKS directly from this URL.
+ This is useful when the OIDC issuer does not serve a /.well-known/openid-configuration.
+ type: string
protectedResourceAllowPrivateIp:
description: |-
ProtectedResourceAllowPrivateIP allows protected resource endpoint on private IP addresses
diff --git a/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_virtualmcpservers.yaml b/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_virtualmcpservers.yaml
index f6fee39825..07b4a3a2ee 100644
--- a/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_virtualmcpservers.yaml
+++ b/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_virtualmcpservers.yaml
@@ -1199,6 +1199,11 @@ spec:
InsecureAllowHTTP allows HTTP (non-HTTPS) OIDC issuers for development/testing
WARNING: This is insecure and should NEVER be used in production
type: boolean
+ introspectionUrl:
+ description: |-
+ IntrospectionURL is the token introspection endpoint URL (RFC 7662).
+ When set, enables token introspection for opaque (non-JWT) tokens.
+ type: string
issuer:
description: Issuer is the OIDC issuer URL.
pattern: ^https?://
@@ -1210,6 +1215,12 @@ spec:
the OIDC middleware needs to fetch its JWKS from that address.
Use with caution - only enable for trusted internal IDPs or testing.
type: boolean
+ jwksUrl:
+ description: |-
+ JWKSURL is the explicit JWKS endpoint URL.
+ When set, skips OIDC discovery and fetches the JWKS directly from this URL.
+ This is useful when the OIDC issuer does not serve a /.well-known/openid-configuration.
+ type: string
protectedResourceAllowPrivateIp:
description: |-
ProtectedResourceAllowPrivateIP allows protected resource endpoint on private IP addresses
diff --git a/docs/operator/crd-api.md b/docs/operator/crd-api.md
index 758140f336..a38af578c3 100644
--- a/docs/operator/crd-api.md
+++ b/docs/operator/crd-api.md
@@ -381,6 +381,8 @@ _Appears in:_
| `clientSecretEnv` _string_ | ClientSecretEnv is the name of the environment variable containing the client secret.
This is the secure way to reference secrets - the actual secret value is never stored
in configuration files, only the environment variable name.
The secret value will be resolved from this environment variable at runtime. | | |
| `audience` _string_ | Audience is the required token audience. | | |
| `resource` _string_ | Resource is the OAuth 2.0 resource indicator (RFC 8707).
Used in WWW-Authenticate header and OAuth discovery metadata (RFC 9728).
If not specified, defaults to Audience. | | |
+| `jwksUrl` _string_ | JWKSURL is the explicit JWKS endpoint URL.
When set, skips OIDC discovery and fetches the JWKS directly from this URL.
This is useful when the OIDC issuer does not serve a /.well-known/openid-configuration. | | Optional: \{\}
|
+| `introspectionUrl` _string_ | IntrospectionURL is the token introspection endpoint URL (RFC 7662).
When set, enables token introspection for opaque (non-JWT) tokens. | | Optional: \{\}
|
| `scopes` _string array_ | Scopes are the required OAuth scopes. | | |
| `protectedResourceAllowPrivateIp` _boolean_ | ProtectedResourceAllowPrivateIP allows protected resource endpoint on private IP addresses
Use with caution - only enable for trusted internal IDPs or testing | | |
| `jwksAllowPrivateIp` _boolean_ | JwksAllowPrivateIP allows OIDC discovery and JWKS fetches to private IP addresses.
Enable when the embedded auth server runs on a loopback address and
the OIDC middleware needs to fetch its JWKS from that address.
Use with caution - only enable for trusted internal IDPs or testing. | | |
diff --git a/pkg/vmcp/auth/factory/incoming.go b/pkg/vmcp/auth/factory/incoming.go
index 823582caff..5b899894f1 100644
--- a/pkg/vmcp/auth/factory/incoming.go
+++ b/pkg/vmcp/auth/factory/incoming.go
@@ -161,6 +161,8 @@ func newOIDCAuthMiddleware(
ClientID: oidcCfg.ClientID,
Audience: oidcCfg.Audience,
ResourceURL: oidcCfg.Resource,
+ JWKSURL: oidcCfg.JWKSURL,
+ IntrospectionURL: oidcCfg.IntrospectionURL,
AllowPrivateIP: oidcCfg.ProtectedResourceAllowPrivateIP || oidcCfg.JwksAllowPrivateIP,
InsecureAllowHTTP: oidcCfg.InsecureAllowHTTP,
Scopes: oidcCfg.Scopes,
diff --git a/pkg/vmcp/config/config.go b/pkg/vmcp/config/config.go
index 390bd9660f..ef98861794 100644
--- a/pkg/vmcp/config/config.go
+++ b/pkg/vmcp/config/config.go
@@ -218,6 +218,17 @@ type OIDCConfig struct {
// If not specified, defaults to Audience.
Resource string `json:"resource,omitempty" yaml:"resource,omitempty"`
+ // JWKSURL is the explicit JWKS endpoint URL.
+ // When set, skips OIDC discovery and fetches the JWKS directly from this URL.
+ // This is useful when the OIDC issuer does not serve a /.well-known/openid-configuration.
+ // +optional
+ JWKSURL string `json:"jwksUrl,omitempty" yaml:"jwksUrl,omitempty"`
+
+ // IntrospectionURL is the token introspection endpoint URL (RFC 7662).
+ // When set, enables token introspection for opaque (non-JWT) tokens.
+ // +optional
+ IntrospectionURL string `json:"introspectionUrl,omitempty" yaml:"introspectionUrl,omitempty"`
+
// Scopes are the required OAuth scopes.
Scopes []string `json:"scopes,omitempty" yaml:"scopes,omitempty"`
diff --git a/pkg/vmcp/config/yaml_loader_test.go b/pkg/vmcp/config/yaml_loader_test.go
index 4fe04ed3b0..ac6976492e 100644
--- a/pkg/vmcp/config/yaml_loader_test.go
+++ b/pkg/vmcp/config/yaml_loader_test.go
@@ -130,6 +130,45 @@ aggregation:
},
wantErr: false,
},
+ {
+ name: "valid OIDC configuration with jwksUrl and introspectionUrl",
+ yaml: `
+name: test-vmcp
+groupRef: test-group
+
+incomingAuth:
+ type: oidc
+ oidc:
+ issuer: https://auth.example.com
+ clientId: test-client
+ audience: vmcp
+ jwksUrl: https://auth.example.com/custom/jwks
+ introspectionUrl: https://auth.example.com/custom/introspect
+
+outgoingAuth:
+ source: inline
+ default:
+ type: unauthenticated
+
+aggregation:
+ conflictResolution: prefix
+ conflictResolutionConfig:
+ prefixFormat: "{workload}_"
+`,
+ want: func(t *testing.T, cfg *Config) {
+ t.Helper()
+ if cfg.IncomingAuth.OIDC == nil {
+ t.Fatal("IncomingAuth.OIDC is nil")
+ }
+ if cfg.IncomingAuth.OIDC.JWKSURL != "https://auth.example.com/custom/jwks" {
+ t.Errorf("OIDC.JWKSURL = %v, want https://auth.example.com/custom/jwks", cfg.IncomingAuth.OIDC.JWKSURL)
+ }
+ if cfg.IncomingAuth.OIDC.IntrospectionURL != "https://auth.example.com/custom/introspect" {
+ t.Errorf("OIDC.IntrospectionURL = %v, want https://auth.example.com/custom/introspect", cfg.IncomingAuth.OIDC.IntrospectionURL)
+ }
+ },
+ wantErr: false,
+ },
{
name: "partial operational config gets defaults for missing fields",
yaml: `