Skip to content
Open
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
96 changes: 96 additions & 0 deletions cmd/thv-operator/api/v1beta1/crd_schema_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
// SPDX-License-Identifier: Apache-2.0

package v1beta1

import (
"os"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sigs.k8s.io/yaml"
)

// loadCRDManifest reads a generated CRD manifest and decodes it into a
// generic JSON-like tree so the assertions stay independent of the typed
// API types the manifest is generated from.
func loadCRDManifest(t *testing.T, path string) map[string]any {
t.Helper()
data, err := os.ReadFile(path)
require.NoError(t, err)
var doc map[string]any
require.NoError(t, yaml.Unmarshal(data, &doc))
return doc
}

// collectSchemasForProperty walks a generic schema tree and collects every
// nested schema object registered under the given property name.
func collectSchemasForProperty(node any, propertyName string, out *[]map[string]any) {
switch v := node.(type) {
case map[string]any:
if prop, ok := v[propertyName]; ok {
if schema, ok := prop.(map[string]any); ok {
*out = append(*out, schema)
}
}
for _, child := range v {
collectSchemasForProperty(child, propertyName, out)
}
case []any:
for _, item := range v {
collectSchemasForProperty(item, propertyName, out)
}
}
}

// TestCRDUpstreamCredentialScopeSchema asserts the generated CRD schemas
// expose upstreamCredentialScope with the session/platformUser enum and the
// permanent "session" default. This is a generated-artifact assertion: it
// fails until controller-gen regenerates the manifests.
func TestCRDUpstreamCredentialScopeSchema(t *testing.T) {
t.Parallel()

for _, tc := range []struct {
name string
path string
container string
minMatches int
}{
{
name: "mcpexternalauthconfigs",
path: "../../../../deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpexternalauthconfigs.yaml",
container: "embeddedAuthServer",
minMatches: 1,
},
{
name: "virtualmcpservers",
path: "../../../../deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_virtualmcpservers.yaml",
container: "authServerConfig",
minMatches: 1,
},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
doc := loadCRDManifest(t, tc.path)

var schemas []map[string]any
collectSchemasForProperty(doc, tc.container, &schemas)
require.GreaterOrEqual(t, len(schemas), tc.minMatches,
"expected at least one %q schema in %s", tc.container, tc.path)

for i, schema := range schemas {
props, ok := schema["properties"].(map[string]any)
require.Truef(t, ok, "%q schema %d has no properties", tc.container, i)

field, ok := props["upstreamCredentialScope"].(map[string]any)
require.Truef(t, ok, "%q schema %d has no upstreamCredentialScope property", tc.container, i)

assert.Equal(t, []any{"session", "platformUser"}, field["enum"],
"%q schema %d enum mismatch", tc.container, i)
assert.Equal(t, "session", field["default"],
"%q schema %d default mismatch", tc.container, i)
}
})
}
}
20 changes: 20 additions & 0 deletions cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -702,6 +702,19 @@ type EmbeddedAuthServerConfig struct {
// +optional
DisableUpstreamTokenInjection bool `json:"disableUpstreamTokenInjection,omitempty"`

// UpstreamCredentialScope selects which identity model upstream credential
// lookup trusts. `session` (the permanent default) keeps today's behavior:
// the session-based TokenReader stays wired as-is and no platform-user
// trust checks run. `platformUser` is the explicit opt-in to the future
// durable platform-user credential model; it is validated and propagated
// but runtime activation fails as unsupported until platform-user storage
// is implemented. Only a genuinely absent value maps to `session`; unknown
// non-empty values are rejected, never reinterpreted as the default.
// +kubebuilder:default=session
// +optional
// +kubebuilder:validation:Enum=session;platformUser
UpstreamCredentialScope authserver.UpstreamCredentialScope `json:"upstreamCredentialScope,omitempty"`

// InsecureAllowHTTP permits an http:// issuer URL for non-localhost hosts.
// Only set this for in-cluster Kubernetes deployments where traffic between
// pods traverses a trusted network (e.g. the in-cluster service mesh).
Expand Down Expand Up @@ -875,6 +888,13 @@ func (c *EmbeddedAuthServerConfig) ValidateConfidentialClientTransport() error {
)
}

// EffectiveUpstreamCredentialScope maps an absent scope to session. The CRD
// default is applied by Kubernetes, but direct Go construction and older
// serialized objects may still carry an empty value.
func (c *EmbeddedAuthServerConfig) EffectiveUpstreamCredentialScope() (authserver.UpstreamCredentialScope, error) {
return authserver.EffectiveUpstreamCredentialScope(string(c.UpstreamCredentialScope))
}

// TokenLifespanConfig holds configuration for token lifetimes.
type TokenLifespanConfig struct {
// AccessTokenLifespan is the duration that access tokens are valid.
Expand Down
35 changes: 30 additions & 5 deletions cmd/thv-operator/pkg/controllerutil/authserver.go
Original file line number Diff line number Diff line change
Expand Up @@ -757,7 +757,7 @@ func AddEmbeddedAuthServerConfigOptions(
)
}

if err := validateOIDCConfigForEmbeddedAuthServer(oidcConfig); err != nil {
if err := validateOIDCConfigForEmbeddedAuthServer(oidcConfig, authServerConfig); err != nil {
return invalidEmbeddedAuthServerConfigFrom(EmbeddedAuthServerConfigSourceExternalAuthConfigRef, err)
}

Expand Down Expand Up @@ -792,7 +792,9 @@ func AddEmbeddedAuthServerConfigOptions(
// overriding Audience with ResourceURL) so that operators see exactly what
// values are in play and control both sides explicitly. This mirrors the
// existing vMCP inline config validation (ValidateAuthServerIntegration).
func validateOIDCConfigForEmbeddedAuthServer(oidcConfig *oidc.OIDCConfig) error {
//
//nolint:lll // parameter list exceeds the line limit
func validateOIDCConfigForEmbeddedAuthServer(oidcConfig *oidc.OIDCConfig, authServerConfig *mcpv1beta1.EmbeddedAuthServerConfig) error {
if oidcConfig == nil {
return fmt.Errorf("OIDC config is required for embedded auth server: OIDCConfigRef must be set on the MCPServer")
}
Expand All @@ -813,6 +815,25 @@ func validateOIDCConfigForEmbeddedAuthServer(oidcConfig *oidc.OIDCConfig) error
oidcConfig.Audience, oidcConfig.ResourceURL, oidcConfig.ResourceURL,
)
}
if authServerConfig != nil {
scope, err := authServerConfig.EffectiveUpstreamCredentialScope()
if err != nil {
return err
}
if scope == authserver.UpstreamCredentialScopePlatformUser {
if oidcConfig.Issuer == "" {
return fmt.Errorf("oidcConfigRef.issuer is required when upstreamCredentialScope is platformUser: " +
"the token carrying the platform-user identity must be validated as issued by the embedded auth server")
}
if oidcConfig.Issuer != authServerConfig.Issuer {
return fmt.Errorf(
"oidcConfigRef.issuer %q must exactly match the embedded auth server issuer %q "+
"when upstreamCredentialScope is platformUser (no normalization or trailing-slash tolerance)",
oidcConfig.Issuer, authServerConfig.Issuer,
)
}
}
}
return nil
}

Expand Down Expand Up @@ -926,6 +947,9 @@ func applySimpleAuthServerConfigFields(config *authserver.RunConfig, authConfig
// Wire through upstream token injection flag
config.DisableUpstreamTokenInjection = authConfig.DisableUpstreamTokenInjection

// Wire through the upstream credential scope (session/platformUser).
config.UpstreamCredentialScope = authConfig.UpstreamCredentialScope

// Wire through the insecure HTTP issuer flag from the CRD field.
// This replaces any auto-inference and moves control to the deployer.
config.InsecureAllowHTTP = authConfig.InsecureAllowHTTP
Expand Down Expand Up @@ -972,8 +996,9 @@ func validateDelegateClientsAndTrustedIssuers(config *authserver.RunConfig) erro
InsecureAllowHTTP: config.InsecureAllowHTTP,
AllowPrivateKeyJWTRegistration: config.AllowPrivateKeyJWTRegistration,
InsecureAllowConfidentialOverLoopbackHTTP: config.InsecureAllowConfidentialOverLoopbackHTTP,
DelegateClients: config.DelegateClients,
TrustedIssuers: config.TrustedIssuers,
UpstreamCredentialScope: config.UpstreamCredentialScope,
DelegateClients: config.DelegateClients,
TrustedIssuers: config.TrustedIssuers,
}
if err := validationConfig.Validate(); err != nil {
return fmt.Errorf("invalid embedded auth server delegate clients or trusted issuers: %w", err)
Expand Down Expand Up @@ -1378,7 +1403,7 @@ func AddAuthServerRefOptions(
)
}

if err := validateOIDCConfigForEmbeddedAuthServer(oidcConfig); err != nil {
if err := validateOIDCConfigForEmbeddedAuthServer(oidcConfig, authServerConfig); err != nil {
return invalidEmbeddedAuthServerConfigFrom(EmbeddedAuthServerConfigSourceAuthServerRef, err)
}

Expand Down
130 changes: 130 additions & 0 deletions cmd/thv-operator/pkg/controllerutil/authserver_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3368,3 +3368,133 @@ func TestBuildTrustedIssuerRunConfigs_JWTBearerGrant(t *testing.T) {
acceptedAudiences[0] = "https://auth.example.com/source-mutated"
assert.Equal(t, "https://auth.example.com/legacy-token", configs[0].JWTBearerGrant.AcceptedAudiences[0])
}

func TestBuildAuthServerRunConfig_UpstreamCredentialScope(t *testing.T) {
t.Parallel()

tests := []struct {
name string
scope authserver.UpstreamCredentialScope
}{
{name: "empty stays empty", scope: ""},
{name: "session propagated", scope: authserver.UpstreamCredentialScopeSession},
{name: "platformUser propagated", scope: authserver.UpstreamCredentialScopePlatformUser},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
config, err := BuildAuthServerRunConfig(
"default", "test-server",
&mcpv1beta1.EmbeddedAuthServerConfig{
Issuer: "https://auth.example.com",
UpstreamCredentialScope: tt.scope,
},
[]string{"https://resource.example.com"}, []string{"openid"}, "https://resource.example.com",
)
require.NoError(t, err)
assert.Equal(t, tt.scope, config.UpstreamCredentialScope)
})
}
}

// The reconcile-time partial validation copy must carry the scope: a
// platformUser config with delegate clients has to fail at reconcile with the
// unsupported-capability error instead of silently reaching pod start.
func TestBuildAuthServerRunConfig_PlatformUserWithDelegateClientsRejected(t *testing.T) {
t.Parallel()

_, err := BuildAuthServerRunConfig(
"default", "test-server",
&mcpv1beta1.EmbeddedAuthServerConfig{
Issuer: "https://auth.example.com",
UpstreamCredentialScope: authserver.UpstreamCredentialScopePlatformUser,
DelegateClients: []mcpv1beta1.DelegateClientConfig{{
ClientID: "delegate",
ClientSecretRef: &mcpv1beta1.SecretKeyRef{Name: "delegate-secret", Key: "credential"},
Scopes: []string{"openid"},
Audiences: []string{"https://resource.example.com"},
}},
},
[]string{"https://resource.example.com"}, []string{"openid"}, "https://resource.example.com",
)

require.Error(t, err)
assert.Contains(t, err.Error(), "not supported")
}

func TestValidateOIDCConfigForEmbeddedAuthServer_UpstreamCredentialScope(t *testing.T) {
t.Parallel()

oidcConfig := func(issuer string) *oidc.OIDCConfig {
return &oidc.OIDCConfig{
ResourceURL: "https://my-vmcp",
Audience: "https://my-vmcp",
Issuer: issuer,
}
}
authServerConfig := func(scope authserver.UpstreamCredentialScope, issuer string) *mcpv1beta1.EmbeddedAuthServerConfig {
return &mcpv1beta1.EmbeddedAuthServerConfig{
Issuer: issuer,
UpstreamCredentialScope: scope,
}
}

tests := []struct {
name string
oidcConfig *oidc.OIDCConfig
authServerConfig *mcpv1beta1.EmbeddedAuthServerConfig
wantErr bool
errMsg string
}{
{
name: "session scope with mismatched issuer still passes",
oidcConfig: oidcConfig("https://login.corp.example"),
authServerConfig: authServerConfig(authserver.UpstreamCredentialScopeSession, "https://auth.example"),
},
{
name: "empty scope with mismatched issuer still passes",
oidcConfig: oidcConfig("https://login.corp.example"),
authServerConfig: authServerConfig("", "https://auth.example"),
},
{
name: "platformUser requires oidc issuer",
oidcConfig: oidcConfig(""),
authServerConfig: authServerConfig(authserver.UpstreamCredentialScopePlatformUser, "https://auth.example"),
wantErr: true,
errMsg: "oidcConfigRef.issuer is required",
},
{
name: "platformUser rejects mismatched issuer",
oidcConfig: oidcConfig("https://login.corp.example"),
authServerConfig: authServerConfig(authserver.UpstreamCredentialScopePlatformUser, "https://auth.example"),
wantErr: true,
errMsg: "must exactly match",
},
{
name: "platformUser accepts exact match",
oidcConfig: oidcConfig("https://auth.example"),
authServerConfig: authServerConfig(authserver.UpstreamCredentialScopePlatformUser, "https://auth.example"),
},
{
name: "garbage scope rejected",
oidcConfig: oidcConfig("https://auth.example"),
authServerConfig: authServerConfig("garbage", "https://auth.example"),
wantErr: true,
errMsg: "invalid upstreamCredentialScope",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
err := validateOIDCConfigForEmbeddedAuthServer(tt.oidcConfig, tt.authServerConfig)
if tt.wantErr {
require.Error(t, err)
require.Contains(t, err.Error(), tt.errMsg)
return
}
require.NoError(t, err)
})
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -1036,6 +1036,21 @@ spec:
maxItems: 20
type: array
x-kubernetes-list-type: atomic
upstreamCredentialScope:
default: session
description: |-
UpstreamCredentialScope selects which identity model upstream credential
lookup trusts. `session` (the permanent default) keeps today's behavior:
the session-based TokenReader stays wired as-is and no platform-user
trust checks run. `platformUser` is the explicit opt-in to the future
durable platform-user credential model; it is validated and propagated
but runtime activation fails as unsupported until platform-user storage
is implemented. Only a genuinely absent value maps to `session`; unknown
non-empty values are rejected, never reinterpreted as the default.
enum:
- session
- platformUser
type: string
upstreamProviders:
description: |-
UpstreamProviders configures connections to upstream Identity Providers.
Expand Down Expand Up @@ -3158,6 +3173,21 @@ spec:
maxItems: 20
type: array
x-kubernetes-list-type: atomic
upstreamCredentialScope:
default: session
description: |-
UpstreamCredentialScope selects which identity model upstream credential
lookup trusts. `session` (the permanent default) keeps today's behavior:
the session-based TokenReader stays wired as-is and no platform-user
trust checks run. `platformUser` is the explicit opt-in to the future
durable platform-user credential model; it is validated and propagated
but runtime activation fails as unsupported until platform-user storage
is implemented. Only a genuinely absent value maps to `session`; unknown
non-empty values are rejected, never reinterpreted as the default.
enum:
- session
- platformUser
type: string
upstreamProviders:
description: |-
UpstreamProviders configures connections to upstream Identity Providers.
Expand Down
Loading
Loading