Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions cmd/thv-operator/pkg/vmcpconfig/converter.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
40 changes: 36 additions & 4 deletions cmd/thv-operator/pkg/vmcpconfig/converter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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)
},
},
Expand Down Expand Up @@ -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 {
Expand All @@ -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()
Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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?://
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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?://
Expand All @@ -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
Expand Down
2 changes: 2 additions & 0 deletions docs/operator/crd-api.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions pkg/vmcp/auth/factory/incoming.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
11 changes: 11 additions & 0 deletions pkg/vmcp/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`

Expand Down
39 changes: 39 additions & 0 deletions pkg/vmcp/config/yaml_loader_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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: `
Expand Down
Loading