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: `