From 4e7fb3cb9d6c4eb776e7a09b68ad70dfbc95dc30 Mon Sep 17 00:00:00 2001
From: Pablo Sanchez <494757+onepabz@users.noreply.github.com>
Date: Thu, 9 Jul 2026 15:00:23 +1200
Subject: [PATCH 1/2] Add loginPolicy to upstream provider CRD config
The embedded auth server walks every configured upstream provider at
every login: the first authorize leg always targets upstreams[0] and
callback.go's continueChainOrComplete then walks all remaining
upstreams. Since #5725/#5733 the server honors Config.UpstreamFilter to
narrow that chain, but nothing feeds it: the runner never sets the
field and no CRD, Helm, or file surface reaches it. A declarative
operator deployment therefore cannot stop a secondary SaaS provider
(e.g. GitHub next to the identity IdP) from forcing its consent screen
on every user at every login.
Expose the existing narrowing through the CRD surface:
- UpstreamProviderConfig gains an optional loginPolicy enum field
(required|onDemand; empty means required, today's behavior).
- The operator copies it into authserver.UpstreamRunConfig via the
shared BuildAuthServerRunConfig, covering MCPServer, MCPRemoteProxy,
and VirtualMCPServer alike.
- The runner translates onDemand entries into a StaticUpstreamFilter
(new, implements handlers.UpstreamFilter) and sets
Config.UpstreamFilter, activating the previously dormant wiring.
- Validation rejects onDemand on the first upstream (it anchors the
chain and resolves the user's identity) at CRD validation time, at
VirtualMCPServer reconcile time, and at RunConfig load.
Tokens previously stored for an onDemand provider are still injected
and refreshed at MCP-request time; only login-time acquisition is
skipped. There is intentionally no in-band flow yet for a user to
authorize an onDemand provider after login - that is the linked-account
half of #5383 and is left for maintainer direction.
Toward #5383.
Signed-off-by: Pablo Sanchez <494757+onepabz@users.noreply.github.com>
---
.../v1beta1/mcpexternalauthconfig_types.go | 49 ++++++++
.../mcpexternalauthconfig_types_test.go | 60 ++++++++++
.../pkg/controllerutil/authserver.go | 5 +
.../pkg/controllerutil/authserver_test.go | 44 +++++++
...e.stacklok.dev_mcpexternalauthconfigs.yaml | 50 ++++++++
...olhive.stacklok.dev_virtualmcpservers.yaml | 50 ++++++++
...e.stacklok.dev_mcpexternalauthconfigs.yaml | 50 ++++++++
...olhive.stacklok.dev_virtualmcpservers.yaml | 50 ++++++++
docs/operator/crd-api.md | 19 +++
pkg/authserver/config.go | 61 ++++++++++
pkg/authserver/config_test.go | 47 ++++++++
pkg/authserver/runner/embeddedauthserver.go | 40 +++++++
.../runner/embeddedauthserver_test.go | 111 ++++++++++++++++++
pkg/authserver/staticfilter.go | 48 ++++++++
pkg/authserver/staticfilter_test.go | 67 +++++++++++
pkg/vmcp/config/validator.go | 6 +-
pkg/vmcp/config/validator_test.go | 50 ++++++++
17 files changed, 806 insertions(+), 1 deletion(-)
create mode 100644 pkg/authserver/staticfilter.go
create mode 100644 pkg/authserver/staticfilter_test.go
diff --git a/cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types.go b/cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types.go
index 8266fa8ab2..8505ad2e94 100644
--- a/cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types.go
+++ b/cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types.go
@@ -549,6 +549,22 @@ const (
UpstreamProviderTypeOAuth2 UpstreamProviderType = "oauth2"
)
+// UpstreamLoginPolicy controls whether an upstream provider participates in
+// every login authorization chain.
+type UpstreamLoginPolicy string
+
+const (
+ // UpstreamLoginPolicyRequired walks the provider on every login
+ // authorization chain (the default, and the behavior before this field
+ // existed).
+ UpstreamLoginPolicyRequired UpstreamLoginPolicy = "required"
+
+ // UpstreamLoginPolicyOnDemand excludes the provider from the login
+ // authorization chain, so users are not sent through its consent screen at
+ // every login.
+ UpstreamLoginPolicyOnDemand UpstreamLoginPolicy = "onDemand"
+)
+
// UpstreamProviderConfig defines configuration for an upstream Identity Provider.
//
// Exactly one of OIDCConfig or OAuth2Config must be set and must match the
@@ -589,6 +605,29 @@ type UpstreamProviderConfig struct {
// Required when Type is "oauth2", must be nil when Type is "oidc".
// +optional
OAuth2Config *OAuth2UpstreamConfig `json:"oauth2Config,omitempty"`
+
+ // LoginPolicy controls whether this provider participates in every login
+ // authorization chain. When empty or "required" (the default), the embedded
+ // auth server walks the provider on every login — the behavior before this
+ // field existed. When "onDemand", the provider is excluded from the login
+ // chain: users are not sent through its consent screen at login, and any
+ // token previously stored for it (for example from an earlier login while
+ // the provider was still required, or from a future on-demand connect flow)
+ // continues to be injected and refreshed at MCP-request time.
+ //
+ // Note that this field controls only login-time acquisition. There is not
+ // yet an in-band flow for a user to authorize an onDemand provider after
+ // login; a request to a backend that needs a token the user never granted
+ // fails until such a flow exists.
+ //
+ // Must not be "onDemand" on the first entry of UpstreamProviders: the first
+ // provider anchors the chain and resolves the user's identity, so it can
+ // never be filtered out. Only meaningful when multiple upstream providers
+ // are configured (VirtualMCPServer); on single-upstream resources the only
+ // provider is the first, where "onDemand" is rejected.
+ // +kubebuilder:validation:Enum=required;onDemand
+ // +optional
+ LoginPolicy UpstreamLoginPolicy `json:"loginPolicy,omitempty"`
}
// OIDCUpstreamConfig contains configuration for OIDC providers.
@@ -1609,6 +1648,16 @@ func (r *MCPExternalAuthConfig) validateEmbeddedAuthServer() error {
// (MCPServer, MCPRemoteProxy) enforce single-upstream restrictions;
// VirtualMCPServer allows multiple upstreams.
+ // The first provider anchors the authorization chain and resolves the
+ // user's identity, so it can never be excluded from the chain. This is a
+ // cross-item (positional) rule, which is why it lives here next to the
+ // duplicate-name check rather than in per-provider CEL.
+ if cfg.UpstreamProviders[0].LoginPolicy == UpstreamLoginPolicyOnDemand {
+ return fmt.Errorf("upstreamProviders[0]: loginPolicy %q is not allowed on the first provider; "+
+ "the first provider anchors the authorization chain and cannot be filtered out",
+ UpstreamLoginPolicyOnDemand)
+ }
+
seen := make(map[string]bool, len(cfg.UpstreamProviders))
for i, provider := range cfg.UpstreamProviders {
if seen[provider.Name] {
diff --git a/cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types_test.go b/cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types_test.go
index ac22cd92f0..a5887bd1e5 100644
--- a/cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types_test.go
+++ b/cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types_test.go
@@ -173,6 +173,66 @@ func TestMCPExternalAuthConfig_Validate(t *testing.T) {
},
expectErr: false,
},
+ {
+ name: "embeddedAuthServer with onDemand loginPolicy on non-first provider - valid",
+ config: &MCPExternalAuthConfig{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: "test-embedded-ondemand",
+ Namespace: "default",
+ },
+ Spec: MCPExternalAuthConfigSpec{
+ Type: ExternalAuthTypeEmbeddedAuthServer,
+ EmbeddedAuthServer: &EmbeddedAuthServerConfig{
+ Issuer: "https://auth.example.com",
+ UpstreamProviders: []UpstreamProviderConfig{
+ {
+ Name: "okta",
+ Type: UpstreamProviderTypeOIDC,
+ LoginPolicy: UpstreamLoginPolicyRequired,
+ OIDCConfig: &OIDCUpstreamConfig{IssuerURL: "https://okta.example.com", ClientID: "id1"},
+ },
+ {
+ Name: "github",
+ Type: UpstreamProviderTypeOIDC,
+ LoginPolicy: UpstreamLoginPolicyOnDemand,
+ OIDCConfig: &OIDCUpstreamConfig{IssuerURL: "https://github.com", ClientID: "id2"},
+ },
+ },
+ },
+ },
+ },
+ expectErr: false,
+ },
+ {
+ name: "invalid embeddedAuthServer with onDemand loginPolicy on first provider",
+ config: &MCPExternalAuthConfig{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: "test-embedded-ondemand-first",
+ Namespace: "default",
+ },
+ Spec: MCPExternalAuthConfigSpec{
+ Type: ExternalAuthTypeEmbeddedAuthServer,
+ EmbeddedAuthServer: &EmbeddedAuthServerConfig{
+ Issuer: "https://auth.example.com",
+ UpstreamProviders: []UpstreamProviderConfig{
+ {
+ Name: "okta",
+ Type: UpstreamProviderTypeOIDC,
+ LoginPolicy: UpstreamLoginPolicyOnDemand,
+ OIDCConfig: &OIDCUpstreamConfig{IssuerURL: "https://okta.example.com", ClientID: "id1"},
+ },
+ {
+ Name: "github",
+ Type: UpstreamProviderTypeOIDC,
+ OIDCConfig: &OIDCUpstreamConfig{IssuerURL: "https://github.com", ClientID: "id2"},
+ },
+ },
+ },
+ },
+ },
+ expectErr: true,
+ errMsg: "loginPolicy \"onDemand\" is not allowed on the first provider",
+ },
{
name: "invalid embeddedAuthServer with no providers",
config: &MCPExternalAuthConfig{
diff --git a/cmd/thv-operator/pkg/controllerutil/authserver.go b/cmd/thv-operator/pkg/controllerutil/authserver.go
index 97a3692b96..c5cb2bcb3a 100644
--- a/cmd/thv-operator/pkg/controllerutil/authserver.go
+++ b/cmd/thv-operator/pkg/controllerutil/authserver.go
@@ -752,6 +752,11 @@ func buildUpstreamRunConfig(
config := &authserver.UpstreamRunConfig{
Name: provider.Name,
Type: authserver.UpstreamProviderType(provider.Type),
+ // LoginPolicy passes through verbatim: the CRD enum and the runtime
+ // constants share the same values ("required"/"onDemand"), admission
+ // rejects anything else, and CRD-type/vmcp validation rejects
+ // "onDemand" on the first provider before this conversion runs.
+ LoginPolicy: authserver.UpstreamLoginPolicy(provider.LoginPolicy),
}
switch provider.Type {
diff --git a/cmd/thv-operator/pkg/controllerutil/authserver_test.go b/cmd/thv-operator/pkg/controllerutil/authserver_test.go
index b79d01584b..9df73f8b3c 100644
--- a/cmd/thv-operator/pkg/controllerutil/authserver_test.go
+++ b/cmd/thv-operator/pkg/controllerutil/authserver_test.go
@@ -923,6 +923,50 @@ func TestBuildAuthServerRunConfig(t *testing.T) {
assert.Equal(t, "https://okta.example.com", upstream.OIDCConfig.IssuerURL)
assert.Equal(t, "client-id", upstream.OIDCConfig.ClientID)
assert.Equal(t, []string{"openid", "profile"}, upstream.OIDCConfig.Scopes)
+ // LoginPolicy was not set on the CRD provider, so it must stay
+ // empty (the "required" default) in the RunConfig.
+ assert.Empty(t, upstream.LoginPolicy)
+ },
+ },
+ {
+ name: "upstream provider loginPolicy propagates to RunConfig",
+ resourceURL: defaultResourceURL,
+ authConfig: &mcpv1beta1.EmbeddedAuthServerConfig{
+ Issuer: "https://auth.example.com",
+ SigningKeySecretRefs: []mcpv1beta1.SecretKeyRef{
+ {Name: "signing-key", Key: "private.pem"},
+ },
+ HMACSecretRefs: []mcpv1beta1.SecretKeyRef{
+ {Name: "hmac-secret", Key: "hmac"},
+ },
+ UpstreamProviders: []mcpv1beta1.UpstreamProviderConfig{
+ {
+ Name: "okta",
+ Type: mcpv1beta1.UpstreamProviderTypeOIDC,
+ LoginPolicy: mcpv1beta1.UpstreamLoginPolicyRequired,
+ OIDCConfig: &mcpv1beta1.OIDCUpstreamConfig{
+ IssuerURL: "https://okta.example.com",
+ ClientID: "client-id",
+ },
+ },
+ {
+ Name: "github",
+ Type: mcpv1beta1.UpstreamProviderTypeOIDC,
+ LoginPolicy: mcpv1beta1.UpstreamLoginPolicyOnDemand,
+ OIDCConfig: &mcpv1beta1.OIDCUpstreamConfig{
+ IssuerURL: "https://github.com",
+ ClientID: "client-id-2",
+ },
+ },
+ },
+ },
+ allowedAudiences: defaultAudiences,
+ scopesSupported: defaultScopes,
+ checkFunc: func(t *testing.T, config *authserver.RunConfig) {
+ t.Helper()
+ require.Len(t, config.Upstreams, 2)
+ assert.Equal(t, authserver.UpstreamLoginPolicyRequired, config.Upstreams[0].LoginPolicy)
+ assert.Equal(t, authserver.UpstreamLoginPolicyOnDemand, config.Upstreams[1].LoginPolicy)
},
},
{
diff --git a/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpexternalauthconfigs.yaml b/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpexternalauthconfigs.yaml
index d47545b4d6..22d9b912d9 100644
--- a/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpexternalauthconfigs.yaml
+++ b/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpexternalauthconfigs.yaml
@@ -619,6 +619,31 @@ spec:
rule fails admission instead of silently demanding the OAuth2 shape. When
adding a new type, extend both this rule and validateUpstreamProvider.
properties:
+ loginPolicy:
+ description: |-
+ LoginPolicy controls whether this provider participates in every login
+ authorization chain. When empty or "required" (the default), the embedded
+ auth server walks the provider on every login — the behavior before this
+ field existed. When "onDemand", the provider is excluded from the login
+ chain: users are not sent through its consent screen at login, and any
+ token previously stored for it (for example from an earlier login while
+ the provider was still required, or from a future on-demand connect flow)
+ continues to be injected and refreshed at MCP-request time.
+
+ Note that this field controls only login-time acquisition. There is not
+ yet an in-band flow for a user to authorize an onDemand provider after
+ login; a request to a backend that needs a token the user never granted
+ fails until such a flow exists.
+
+ Must not be "onDemand" on the first entry of UpstreamProviders: the first
+ provider anchors the chain and resolves the user's identity, so it can
+ never be filtered out. Only meaningful when multiple upstream providers
+ are configured (VirtualMCPServer); on single-upstream resources the only
+ provider is the first, where "onDemand" is rejected.
+ enum:
+ - required
+ - onDemand
+ type: string
name:
description: |-
Name uniquely identifies this upstream provider.
@@ -2221,6 +2246,31 @@ spec:
rule fails admission instead of silently demanding the OAuth2 shape. When
adding a new type, extend both this rule and validateUpstreamProvider.
properties:
+ loginPolicy:
+ description: |-
+ LoginPolicy controls whether this provider participates in every login
+ authorization chain. When empty or "required" (the default), the embedded
+ auth server walks the provider on every login — the behavior before this
+ field existed. When "onDemand", the provider is excluded from the login
+ chain: users are not sent through its consent screen at login, and any
+ token previously stored for it (for example from an earlier login while
+ the provider was still required, or from a future on-demand connect flow)
+ continues to be injected and refreshed at MCP-request time.
+
+ Note that this field controls only login-time acquisition. There is not
+ yet an in-band flow for a user to authorize an onDemand provider after
+ login; a request to a backend that needs a token the user never granted
+ fails until such a flow exists.
+
+ Must not be "onDemand" on the first entry of UpstreamProviders: the first
+ provider anchors the chain and resolves the user's identity, so it can
+ never be filtered out. Only meaningful when multiple upstream providers
+ are configured (VirtualMCPServer); on single-upstream resources the only
+ provider is the first, where "onDemand" is rejected.
+ enum:
+ - required
+ - onDemand
+ type: string
name:
description: |-
Name uniquely identifies this upstream provider.
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 17c372dbc5..0ecfeb83e7 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
@@ -492,6 +492,31 @@ spec:
rule fails admission instead of silently demanding the OAuth2 shape. When
adding a new type, extend both this rule and validateUpstreamProvider.
properties:
+ loginPolicy:
+ description: |-
+ LoginPolicy controls whether this provider participates in every login
+ authorization chain. When empty or "required" (the default), the embedded
+ auth server walks the provider on every login — the behavior before this
+ field existed. When "onDemand", the provider is excluded from the login
+ chain: users are not sent through its consent screen at login, and any
+ token previously stored for it (for example from an earlier login while
+ the provider was still required, or from a future on-demand connect flow)
+ continues to be injected and refreshed at MCP-request time.
+
+ Note that this field controls only login-time acquisition. There is not
+ yet an in-band flow for a user to authorize an onDemand provider after
+ login; a request to a backend that needs a token the user never granted
+ fails until such a flow exists.
+
+ Must not be "onDemand" on the first entry of UpstreamProviders: the first
+ provider anchors the chain and resolves the user's identity, so it can
+ never be filtered out. Only meaningful when multiple upstream providers
+ are configured (VirtualMCPServer); on single-upstream resources the only
+ provider is the first, where "onDemand" is rejected.
+ enum:
+ - required
+ - onDemand
+ type: string
name:
description: |-
Name uniquely identifies this upstream provider.
@@ -3924,6 +3949,31 @@ spec:
rule fails admission instead of silently demanding the OAuth2 shape. When
adding a new type, extend both this rule and validateUpstreamProvider.
properties:
+ loginPolicy:
+ description: |-
+ LoginPolicy controls whether this provider participates in every login
+ authorization chain. When empty or "required" (the default), the embedded
+ auth server walks the provider on every login — the behavior before this
+ field existed. When "onDemand", the provider is excluded from the login
+ chain: users are not sent through its consent screen at login, and any
+ token previously stored for it (for example from an earlier login while
+ the provider was still required, or from a future on-demand connect flow)
+ continues to be injected and refreshed at MCP-request time.
+
+ Note that this field controls only login-time acquisition. There is not
+ yet an in-band flow for a user to authorize an onDemand provider after
+ login; a request to a backend that needs a token the user never granted
+ fails until such a flow exists.
+
+ Must not be "onDemand" on the first entry of UpstreamProviders: the first
+ provider anchors the chain and resolves the user's identity, so it can
+ never be filtered out. Only meaningful when multiple upstream providers
+ are configured (VirtualMCPServer); on single-upstream resources the only
+ provider is the first, where "onDemand" is rejected.
+ enum:
+ - required
+ - onDemand
+ type: string
name:
description: |-
Name uniquely identifies this upstream provider.
diff --git a/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpexternalauthconfigs.yaml b/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpexternalauthconfigs.yaml
index b73e66445b..bc7e1c2cd2 100644
--- a/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpexternalauthconfigs.yaml
+++ b/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpexternalauthconfigs.yaml
@@ -622,6 +622,31 @@ spec:
rule fails admission instead of silently demanding the OAuth2 shape. When
adding a new type, extend both this rule and validateUpstreamProvider.
properties:
+ loginPolicy:
+ description: |-
+ LoginPolicy controls whether this provider participates in every login
+ authorization chain. When empty or "required" (the default), the embedded
+ auth server walks the provider on every login — the behavior before this
+ field existed. When "onDemand", the provider is excluded from the login
+ chain: users are not sent through its consent screen at login, and any
+ token previously stored for it (for example from an earlier login while
+ the provider was still required, or from a future on-demand connect flow)
+ continues to be injected and refreshed at MCP-request time.
+
+ Note that this field controls only login-time acquisition. There is not
+ yet an in-band flow for a user to authorize an onDemand provider after
+ login; a request to a backend that needs a token the user never granted
+ fails until such a flow exists.
+
+ Must not be "onDemand" on the first entry of UpstreamProviders: the first
+ provider anchors the chain and resolves the user's identity, so it can
+ never be filtered out. Only meaningful when multiple upstream providers
+ are configured (VirtualMCPServer); on single-upstream resources the only
+ provider is the first, where "onDemand" is rejected.
+ enum:
+ - required
+ - onDemand
+ type: string
name:
description: |-
Name uniquely identifies this upstream provider.
@@ -2224,6 +2249,31 @@ spec:
rule fails admission instead of silently demanding the OAuth2 shape. When
adding a new type, extend both this rule and validateUpstreamProvider.
properties:
+ loginPolicy:
+ description: |-
+ LoginPolicy controls whether this provider participates in every login
+ authorization chain. When empty or "required" (the default), the embedded
+ auth server walks the provider on every login — the behavior before this
+ field existed. When "onDemand", the provider is excluded from the login
+ chain: users are not sent through its consent screen at login, and any
+ token previously stored for it (for example from an earlier login while
+ the provider was still required, or from a future on-demand connect flow)
+ continues to be injected and refreshed at MCP-request time.
+
+ Note that this field controls only login-time acquisition. There is not
+ yet an in-band flow for a user to authorize an onDemand provider after
+ login; a request to a backend that needs a token the user never granted
+ fails until such a flow exists.
+
+ Must not be "onDemand" on the first entry of UpstreamProviders: the first
+ provider anchors the chain and resolves the user's identity, so it can
+ never be filtered out. Only meaningful when multiple upstream providers
+ are configured (VirtualMCPServer); on single-upstream resources the only
+ provider is the first, where "onDemand" is rejected.
+ enum:
+ - required
+ - onDemand
+ type: string
name:
description: |-
Name uniquely identifies this upstream provider.
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 de1f36d54a..ac3f1bdf92 100644
--- a/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_virtualmcpservers.yaml
+++ b/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_virtualmcpservers.yaml
@@ -495,6 +495,31 @@ spec:
rule fails admission instead of silently demanding the OAuth2 shape. When
adding a new type, extend both this rule and validateUpstreamProvider.
properties:
+ loginPolicy:
+ description: |-
+ LoginPolicy controls whether this provider participates in every login
+ authorization chain. When empty or "required" (the default), the embedded
+ auth server walks the provider on every login — the behavior before this
+ field existed. When "onDemand", the provider is excluded from the login
+ chain: users are not sent through its consent screen at login, and any
+ token previously stored for it (for example from an earlier login while
+ the provider was still required, or from a future on-demand connect flow)
+ continues to be injected and refreshed at MCP-request time.
+
+ Note that this field controls only login-time acquisition. There is not
+ yet an in-band flow for a user to authorize an onDemand provider after
+ login; a request to a backend that needs a token the user never granted
+ fails until such a flow exists.
+
+ Must not be "onDemand" on the first entry of UpstreamProviders: the first
+ provider anchors the chain and resolves the user's identity, so it can
+ never be filtered out. Only meaningful when multiple upstream providers
+ are configured (VirtualMCPServer); on single-upstream resources the only
+ provider is the first, where "onDemand" is rejected.
+ enum:
+ - required
+ - onDemand
+ type: string
name:
description: |-
Name uniquely identifies this upstream provider.
@@ -3927,6 +3952,31 @@ spec:
rule fails admission instead of silently demanding the OAuth2 shape. When
adding a new type, extend both this rule and validateUpstreamProvider.
properties:
+ loginPolicy:
+ description: |-
+ LoginPolicy controls whether this provider participates in every login
+ authorization chain. When empty or "required" (the default), the embedded
+ auth server walks the provider on every login — the behavior before this
+ field existed. When "onDemand", the provider is excluded from the login
+ chain: users are not sent through its consent screen at login, and any
+ token previously stored for it (for example from an earlier login while
+ the provider was still required, or from a future on-demand connect flow)
+ continues to be injected and refreshed at MCP-request time.
+
+ Note that this field controls only login-time acquisition. There is not
+ yet an in-band flow for a user to authorize an onDemand provider after
+ login; a request to a backend that needs a token the user never granted
+ fails until such a flow exists.
+
+ Must not be "onDemand" on the first entry of UpstreamProviders: the first
+ provider anchors the chain and resolves the user's identity, so it can
+ never be filtered out. Only meaningful when multiple upstream providers
+ are configured (VirtualMCPServer); on single-upstream resources the only
+ provider is the first, where "onDemand" is rejected.
+ enum:
+ - required
+ - onDemand
+ type: string
name:
description: |-
Name uniquely identifies this upstream provider.
diff --git a/docs/operator/crd-api.md b/docs/operator/crd-api.md
index 423a6d3b61..b4abda953f 100644
--- a/docs/operator/crd-api.md
+++ b/docs/operator/crd-api.md
@@ -4270,6 +4270,24 @@ _Appears in:_
| `providerName` _string_ | ProviderName is the name of the upstream IdP provider whose access token
should be injected as the Authorization: Bearer header. | | MinLength: 1
Required: \{\}
|
+#### api.v1beta1.UpstreamLoginPolicy
+
+_Underlying type:_ _string_
+
+UpstreamLoginPolicy controls whether an upstream provider participates in
+every login authorization chain.
+
+
+
+_Appears in:_
+- [api.v1beta1.UpstreamProviderConfig](#apiv1beta1upstreamproviderconfig)
+
+| Field | Description |
+| --- | --- |
+| `required` | UpstreamLoginPolicyRequired walks the provider on every login
authorization chain (the default, and the behavior before this field
existed).
|
+| `onDemand` | UpstreamLoginPolicyOnDemand excludes the provider from the login
authorization chain, so users are not sent through its consent screen at
every login.
|
+
+
#### api.v1beta1.UpstreamProviderConfig
@@ -4298,6 +4316,7 @@ _Appears in:_
| `type` _[api.v1beta1.UpstreamProviderType](#apiv1beta1upstreamprovidertype)_ | Type specifies the provider type: "oidc" or "oauth2" | | Enum: [oidc oauth2]
Required: \{\}
|
| `oidcConfig` _[api.v1beta1.OIDCUpstreamConfig](#apiv1beta1oidcupstreamconfig)_ | OIDCConfig contains OIDC-specific configuration.
Required when Type is "oidc", must be nil when Type is "oauth2". | | Optional: \{\}
|
| `oauth2Config` _[api.v1beta1.OAuth2UpstreamConfig](#apiv1beta1oauth2upstreamconfig)_ | OAuth2Config contains OAuth 2.0-specific configuration.
Required when Type is "oauth2", must be nil when Type is "oidc". | | Optional: \{\}
|
+| `loginPolicy` _[api.v1beta1.UpstreamLoginPolicy](#apiv1beta1upstreamloginpolicy)_ | LoginPolicy controls whether this provider participates in every login
authorization chain. When empty or "required" (the default), the embedded
auth server walks the provider on every login — the behavior before this
field existed. When "onDemand", the provider is excluded from the login
chain: users are not sent through its consent screen at login, and any
token previously stored for it (for example from an earlier login while
the provider was still required, or from a future on-demand connect flow)
continues to be injected and refreshed at MCP-request time.
Note that this field controls only login-time acquisition. There is not
yet an in-band flow for a user to authorize an onDemand provider after
login; a request to a backend that needs a token the user never granted
fails until such a flow exists.
Must not be "onDemand" on the first entry of UpstreamProviders: the first
provider anchors the chain and resolves the user's identity, so it can
never be filtered out. Only meaningful when multiple upstream providers
are configured (VirtualMCPServer); on single-upstream resources the only
provider is the first, where "onDemand" is rejected. | | Enum: [required onDemand]
Optional: \{\}
|
#### api.v1beta1.UpstreamProviderType
diff --git a/pkg/authserver/config.go b/pkg/authserver/config.go
index 42848b84c1..fb5769aa40 100644
--- a/pkg/authserver/config.go
+++ b/pkg/authserver/config.go
@@ -123,9 +123,45 @@ func (c *RunConfig) Validate() error {
return fmt.Errorf("cimd: %w", err)
}
}
+ if err := ValidateUpstreamLoginPolicies(c.Upstreams); err != nil {
+ return err
+ }
return c.validateBaselineClientScopes()
}
+// ValidateUpstreamLoginPolicies rejects unknown LoginPolicy values and an
+// "onDemand" policy on the first upstream. The first upstream anchors the
+// authorization chain and resolves the user's identity — handlers.computeChain
+// never passes it to the filter — so marking it onDemand could only ever be a
+// misconfiguration that would otherwise no-op silently.
+//
+// Exported so reconcile-time validators (e.g. the VirtualMCPServer
+// controller's auth server integration check) can reject the same
+// misconfiguration as a status condition instead of a pod startup crash;
+// RunConfig.Validate re-checks it in the pod as the runtime backstop.
+func ValidateUpstreamLoginPolicies(upstreams []UpstreamRunConfig) error {
+ for i := range upstreams {
+ switch upstreams[i].LoginPolicy {
+ case "", UpstreamLoginPolicyRequired:
+ // Default behavior: walked on every login.
+ case UpstreamLoginPolicyOnDemand:
+ if i == 0 {
+ return fmt.Errorf(
+ "upstreams[0] (%s): login_policy %q is not allowed on the first upstream; "+
+ "the first upstream anchors the authorization chain and cannot be filtered out",
+ ResolveUpstreamName(upstreams[i].Name), UpstreamLoginPolicyOnDemand,
+ )
+ }
+ default:
+ return fmt.Errorf("upstreams[%d] (%s): invalid login_policy %q (must be %q or %q)",
+ i, ResolveUpstreamName(upstreams[i].Name), upstreams[i].LoginPolicy,
+ UpstreamLoginPolicyRequired, UpstreamLoginPolicyOnDemand,
+ )
+ }
+ }
+ return nil
+}
+
// validateBaselineClientScopes ensures every entry in BaselineClientScopes is
// also present in ScopesSupported. If a baseline scope is not advertised by
// ScopesSupported, the embedded DCR handler would later try to register a
@@ -225,6 +261,24 @@ const (
UpstreamProviderTypeOAuth2 UpstreamProviderType = "oauth2"
)
+// UpstreamLoginPolicy controls whether an upstream provider participates in
+// every login authorization chain or is narrowed out of it.
+type UpstreamLoginPolicy string
+
+const (
+ // UpstreamLoginPolicyRequired walks the upstream on every authorization
+ // chain (the default, and the behavior before this field existed).
+ UpstreamLoginPolicyRequired UpstreamLoginPolicy = "required"
+
+ // UpstreamLoginPolicyOnDemand excludes the upstream from the login
+ // authorization chain via the static upstream filter. Tokens previously
+ // stored for the provider are still injected (and refreshed) at MCP-request
+ // time; only login-time acquisition is skipped. The first configured
+ // upstream must not use this policy — it anchors the chain and resolves the
+ // user's identity, so it can never be filtered out.
+ UpstreamLoginPolicyOnDemand UpstreamLoginPolicy = "onDemand"
+)
+
// DefaultUpstreamName is the name assigned to a single unnamed upstream.
const DefaultUpstreamName = "default"
@@ -269,6 +323,13 @@ type UpstreamRunConfig struct {
// OAuth2Config contains OAuth 2.0-specific configuration.
// Required when Type is "oauth2", must be nil when Type is "oidc".
OAuth2Config *OAuth2UpstreamRunConfig `json:"oauth2_config,omitempty" yaml:"oauth2_config,omitempty"`
+
+ // LoginPolicy controls whether this upstream participates in every login
+ // authorization chain ("required", the default when empty) or is excluded
+ // from it ("onDemand"). When any upstream is "onDemand", the runner
+ // installs a static upstream filter that narrows the chain to the
+ // "required" upstreams. Must be empty or "required" on the first upstream.
+ LoginPolicy UpstreamLoginPolicy `json:"login_policy,omitempty" yaml:"login_policy,omitempty"`
}
// OIDCUpstreamRunConfig contains OIDC provider configuration.
diff --git a/pkg/authserver/config_test.go b/pkg/authserver/config_test.go
index 850a7d0347..f60db585fe 100644
--- a/pkg/authserver/config_test.go
+++ b/pkg/authserver/config_test.go
@@ -465,6 +465,53 @@ func TestRunConfigValidate(t *testing.T) {
{name: "CIMD enabled negative TTL rejected", config: RunConfig{CIMD: &CIMDRunConfig{Enabled: true, CacheFallbackTTL: "-5m"}}, wantErr: true, errMsg: "cache_fallback_ttl"},
{name: "CIMD enabled valid passes", config: RunConfig{CIMD: &CIMDRunConfig{Enabled: true, CacheMaxSize: 64, CacheFallbackTTL: "5m"}}},
{name: "CIMD enabled omitted optional fields pass", config: RunConfig{CIMD: &CIMDRunConfig{Enabled: true}}},
+ // Upstream login policy validation
+ {
+ name: "empty login policy passes",
+ config: RunConfig{Upstreams: []UpstreamRunConfig{
+ {Name: "idp"}, {Name: "saas"},
+ }},
+ },
+ {
+ name: "explicit required login policy passes",
+ config: RunConfig{Upstreams: []UpstreamRunConfig{
+ {Name: "idp", LoginPolicy: UpstreamLoginPolicyRequired},
+ {Name: "saas", LoginPolicy: UpstreamLoginPolicyRequired},
+ }},
+ },
+ {
+ name: "onDemand on non-first upstream passes",
+ config: RunConfig{Upstreams: []UpstreamRunConfig{
+ {Name: "idp"},
+ {Name: "saas", LoginPolicy: UpstreamLoginPolicyOnDemand},
+ }},
+ },
+ {
+ name: "onDemand on first upstream rejected",
+ config: RunConfig{Upstreams: []UpstreamRunConfig{
+ {Name: "idp", LoginPolicy: UpstreamLoginPolicyOnDemand},
+ {Name: "saas"},
+ }},
+ wantErr: true,
+ errMsg: "not allowed on the first upstream",
+ },
+ {
+ name: "onDemand on sole upstream rejected",
+ config: RunConfig{Upstreams: []UpstreamRunConfig{
+ {Name: "idp", LoginPolicy: UpstreamLoginPolicyOnDemand},
+ }},
+ wantErr: true,
+ errMsg: "not allowed on the first upstream",
+ },
+ {
+ name: "unknown login policy rejected",
+ config: RunConfig{Upstreams: []UpstreamRunConfig{
+ {Name: "idp"},
+ {Name: "saas", LoginPolicy: "sometimes"},
+ }},
+ wantErr: true,
+ errMsg: `invalid login_policy "sometimes"`,
+ },
}
for _, tt := range tests {
diff --git a/pkg/authserver/runner/embeddedauthserver.go b/pkg/authserver/runner/embeddedauthserver.go
index 74b92c405d..a511531939 100644
--- a/pkg/authserver/runner/embeddedauthserver.go
+++ b/pkg/authserver/runner/embeddedauthserver.go
@@ -233,6 +233,14 @@ func NewEmbeddedAuthServerWithStorage(
InsecureAllowHTTP: cfg.InsecureAllowHTTP,
}
+ // Install a static upstream filter when any upstream opts out of the login
+ // chain via LoginPolicy. Assigned only when non-nil: Config.UpstreamFilter
+ // must stay untyped-nil for the no-filter case (see its doc comment), and
+ // nil here preserves the pre-filter behavior of walking every upstream.
+ if filter := buildUpstreamFilter(cfg.Upstreams); filter != nil {
+ resolvedCfg.UpstreamFilter = filter
+ }
+
// 7. Create the auth server. authserver.New also asserts the DCR
// capability internally so its DCRStore() accessor returns the same
// asserted handle this constructor used for buildUpstreamConfigs.
@@ -422,6 +430,38 @@ func parseTokenLifespans(cfg *authserver.TokenLifespanRunConfig) (access, refres
return access, refresh, authCode, nil
}
+// buildUpstreamFilter translates per-upstream LoginPolicy values into a
+// handlers.UpstreamFilter. It returns a StaticUpstreamFilter keeping the
+// non-first upstreams whose policy is required (or unset) when at least one
+// non-first upstream is marked onDemand, and nil otherwise — so a RunConfig
+// that never uses LoginPolicy yields a nil filter and the exact pre-filter
+// chain behavior. RunConfig.Validate has already rejected onDemand on the
+// first upstream and unknown policy values before this runs.
+//
+// Names are used verbatim: with two or more upstreams (the only case that
+// reaches filter construction) authserver.Config validation requires explicit
+// unique names, and handlers.computeChain matches chain entries against those
+// same configured names.
+func buildUpstreamFilter(upstreams []authserver.UpstreamRunConfig) handlers.UpstreamFilter {
+ if len(upstreams) < 2 {
+ return nil
+ }
+ onDemand := false
+ keep := make([]string, 0, len(upstreams)-1)
+ for i := range upstreams[1:] {
+ u := &upstreams[1+i]
+ if u.LoginPolicy == authserver.UpstreamLoginPolicyOnDemand {
+ onDemand = true
+ continue
+ }
+ keep = append(keep, u.Name)
+ }
+ if !onDemand {
+ return nil
+ }
+ return authserver.NewStaticUpstreamFilter(keep)
+}
+
// buildUpstreamConfigs converts UpstreamRunConfig slice to UpstreamConfig slice.
// It preserves the provider type so the factory can create the correct provider
// (OIDCProviderImpl for OIDC, BaseOAuth2Provider for OAuth2).
diff --git a/pkg/authserver/runner/embeddedauthserver_test.go b/pkg/authserver/runner/embeddedauthserver_test.go
index 13b8e1acde..8d13ab20a1 100644
--- a/pkg/authserver/runner/embeddedauthserver_test.go
+++ b/pkg/authserver/runner/embeddedauthserver_test.go
@@ -26,6 +26,7 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
+ "github.com/stacklok/toolhive/pkg/auth"
"github.com/stacklok/toolhive/pkg/authserver"
servercrypto "github.com/stacklok/toolhive/pkg/authserver/server/crypto"
"github.com/stacklok/toolhive/pkg/authserver/server/keys"
@@ -2121,3 +2122,113 @@ func TestResolveCIMDConfig(t *testing.T) {
assert.Equal(t, 10*time.Minute, ttl)
})
}
+
+func TestBuildUpstreamFilter(t *testing.T) {
+ t.Parallel()
+
+ upstream := func(name string, policy authserver.UpstreamLoginPolicy) authserver.UpstreamRunConfig {
+ return authserver.UpstreamRunConfig{Name: name, LoginPolicy: policy}
+ }
+
+ tests := []struct {
+ name string
+ upstreams []authserver.UpstreamRunConfig
+ // wantKeep is the expected FilterUpstreams result when a filter is
+ // expected; wantNil asserts no filter is installed at all.
+ wantNil bool
+ wantKeep []string
+ }{
+ {
+ name: "no upstreams yields no filter",
+ wantNil: true,
+ },
+ {
+ name: "single upstream yields no filter",
+ upstreams: []authserver.UpstreamRunConfig{upstream("idp", "")},
+ wantNil: true,
+ },
+ {
+ name: "no onDemand upstream yields no filter",
+ upstreams: []authserver.UpstreamRunConfig{
+ upstream("idp", ""),
+ upstream("github", authserver.UpstreamLoginPolicyRequired),
+ },
+ wantNil: true,
+ },
+ {
+ name: "onDemand upstream is excluded from the keep set",
+ upstreams: []authserver.UpstreamRunConfig{
+ upstream("idp", ""),
+ upstream("github", authserver.UpstreamLoginPolicyOnDemand),
+ upstream("internal", ""),
+ },
+ wantKeep: []string{"internal"},
+ },
+ {
+ name: "all non-first onDemand keeps only the first upstream",
+ upstreams: []authserver.UpstreamRunConfig{
+ upstream("idp", authserver.UpstreamLoginPolicyRequired),
+ upstream("github", authserver.UpstreamLoginPolicyOnDemand),
+ upstream("slack", authserver.UpstreamLoginPolicyOnDemand),
+ },
+ wantKeep: []string{},
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+
+ filter := buildUpstreamFilter(tt.upstreams)
+ if tt.wantNil {
+ assert.Nil(t, filter)
+ return
+ }
+ require.NotNil(t, filter)
+
+ keep, err := filter.FilterUpstreams(context.Background(), auth.PrincipalInfo{}, nil)
+ require.NoError(t, err)
+ assert.Equal(t, tt.wantKeep, keep)
+ })
+ }
+}
+
+func TestNewEmbeddedAuthServerWithStorage_LoginPolicy(t *testing.T) {
+ t.Parallel()
+
+ // A RunConfig whose first upstream is onDemand must be rejected by the
+ // fail-loud validation gate before any server construction happens.
+ cfg := &authserver.RunConfig{
+ SchemaVersion: authserver.CurrentSchemaVersion,
+ Issuer: "http://localhost:8080",
+ Upstreams: []authserver.UpstreamRunConfig{
+ {
+ Name: "idp",
+ Type: authserver.UpstreamProviderTypeOAuth2,
+ LoginPolicy: authserver.UpstreamLoginPolicyOnDemand,
+ OAuth2Config: &authserver.OAuth2UpstreamRunConfig{
+ AuthorizationEndpoint: "https://example.com/authorize",
+ TokenEndpoint: "https://example.com/token",
+ ClientID: "test-client-id",
+ RedirectURI: "http://localhost:8080/oauth/callback",
+ },
+ },
+ {
+ Name: "saas",
+ Type: authserver.UpstreamProviderTypeOAuth2,
+ OAuth2Config: &authserver.OAuth2UpstreamRunConfig{
+ AuthorizationEndpoint: "https://saas.example.com/authorize",
+ TokenEndpoint: "https://saas.example.com/token",
+ ClientID: "saas-client-id",
+ RedirectURI: "http://localhost:8080/oauth/callback",
+ },
+ },
+ },
+ AllowedAudiences: []string{"https://mcp.example.com"},
+ }
+
+ server, err := NewEmbeddedAuthServer(context.Background(), cfg)
+ require.Error(t, err)
+ assert.Nil(t, server)
+ assert.Contains(t, err.Error(), "not allowed on the first upstream")
+}
diff --git a/pkg/authserver/staticfilter.go b/pkg/authserver/staticfilter.go
new file mode 100644
index 0000000000..6c1037f8b8
--- /dev/null
+++ b/pkg/authserver/staticfilter.go
@@ -0,0 +1,48 @@
+// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+package authserver
+
+import (
+ "context"
+ "slices"
+
+ "github.com/stacklok/toolhive/pkg/auth"
+ "github.com/stacklok/toolhive/pkg/authserver/server/handlers"
+)
+
+// StaticUpstreamFilter narrows the authorization chain to a fixed set of
+// non-first upstream names, independent of the authenticated principal. It is
+// the declarative counterpart of handlers.UpstreamFilter: a deployment that
+// knows at configuration time which upstreams must be walked at login (and
+// which are acquired on demand) can express that without writing Go.
+//
+// The handler intersects the returned set with the configured non-first
+// upstreams and preserves configured order (see handlers.computeChain), so a
+// static keep-set cannot reorder or extend the chain. An empty keep-set
+// narrows the chain to just the first (identity) upstream.
+type StaticUpstreamFilter struct {
+ keep []string
+}
+
+// Compile-time check that StaticUpstreamFilter satisfies the handler contract.
+var _ handlers.UpstreamFilter = (*StaticUpstreamFilter)(nil)
+
+// NewStaticUpstreamFilter returns a StaticUpstreamFilter that keeps exactly
+// the given non-first upstream names in every authorization chain. The slice
+// is cloned so later caller mutations cannot change filtering behavior.
+func NewStaticUpstreamFilter(keep []string) *StaticUpstreamFilter {
+ return &StaticUpstreamFilter{keep: slices.Clone(keep)}
+}
+
+// FilterUpstreams implements handlers.UpstreamFilter. The principal and the
+// configured set are ignored: the kept subset is fixed at construction time.
+// A fresh clone is returned on every call so the handler (or a future caller)
+// can never mutate the filter's internal state through the returned slice.
+func (f *StaticUpstreamFilter) FilterUpstreams(
+ _ context.Context,
+ _ auth.PrincipalInfo,
+ _ []string,
+) ([]string, error) {
+ return slices.Clone(f.keep), nil
+}
diff --git a/pkg/authserver/staticfilter_test.go b/pkg/authserver/staticfilter_test.go
new file mode 100644
index 0000000000..b45ebbe597
--- /dev/null
+++ b/pkg/authserver/staticfilter_test.go
@@ -0,0 +1,67 @@
+// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+package authserver
+
+import (
+ "context"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "github.com/stacklok/toolhive/pkg/auth"
+)
+
+func TestStaticUpstreamFilter(t *testing.T) {
+ t.Parallel()
+
+ t.Run("returns the configured keep set regardless of principal and configured set", func(t *testing.T) {
+ t.Parallel()
+
+ filter := NewStaticUpstreamFilter([]string{"github", "slack"})
+
+ keep, err := filter.FilterUpstreams(
+ context.Background(),
+ auth.PrincipalInfo{PlatformUserID: "user-1"},
+ []string{"github", "slack", "notion"},
+ )
+ require.NoError(t, err)
+ assert.Equal(t, []string{"github", "slack"}, keep)
+ })
+
+ t.Run("empty keep set narrows to just the first upstream", func(t *testing.T) {
+ t.Parallel()
+
+ filter := NewStaticUpstreamFilter(nil)
+
+ keep, err := filter.FilterUpstreams(
+ context.Background(),
+ auth.PrincipalInfo{PlatformUserID: "user-1"},
+ []string{"github"},
+ )
+ require.NoError(t, err)
+ assert.Empty(t, keep)
+ })
+
+ t.Run("caller mutations cannot change filtering behavior", func(t *testing.T) {
+ t.Parallel()
+
+ input := []string{"github"}
+ filter := NewStaticUpstreamFilter(input)
+
+ // Mutating the constructor argument after construction must not leak in.
+ input[0] = "mutated-input"
+
+ keep, err := filter.FilterUpstreams(context.Background(), auth.PrincipalInfo{}, nil)
+ require.NoError(t, err)
+ require.Equal(t, []string{"github"}, keep)
+
+ // Mutating a returned slice must not affect subsequent calls.
+ keep[0] = "mutated-output"
+
+ again, err := filter.FilterUpstreams(context.Background(), auth.PrincipalInfo{}, nil)
+ require.NoError(t, err)
+ assert.Equal(t, []string{"github"}, again)
+ })
+}
diff --git a/pkg/vmcp/config/validator.go b/pkg/vmcp/config/validator.go
index 22ff9588ec..a718fb0e92 100644
--- a/pkg/vmcp/config/validator.go
+++ b/pkg/vmcp/config/validator.go
@@ -602,7 +602,11 @@ func validateAuthServerRunConfig(rc *authserver.RunConfig) error {
if len(rc.AllowedAudiences) == 0 {
return fmt.Errorf("auth server requires at least one allowed audience (MCP clients must send RFC 8707 resource parameter)")
}
- return nil
+ // Reject invalid upstream login policies (unknown values, onDemand on the
+ // first upstream) at reconcile time so a misconfigured VirtualMCPServer
+ // surfaces as AuthServerConfigValidated=False rather than a pod startup
+ // crash. RunConfig.Validate re-checks this in the pod.
+ return authserver.ValidateUpstreamLoginPolicies(rc.Upstreams)
}
// validateUpstreamInjectProviders checks that every upstream_inject strategy
diff --git a/pkg/vmcp/config/validator_test.go b/pkg/vmcp/config/validator_test.go
index ffabe98696..90aa98dfaa 100644
--- a/pkg/vmcp/config/validator_test.go
+++ b/pkg/vmcp/config/validator_test.go
@@ -1082,6 +1082,56 @@ func TestValidateAuthServerIntegration(t *testing.T) {
wantErr: true,
errMsg: "issuer is required",
},
+ {
+ name: "login_policy_ondemand_on_first_upstream_rejected",
+ cfg: &Config{
+ OutgoingAuth: &OutgoingAuthConfig{
+ Source: "inline",
+ },
+ },
+ rc: &authserver.RunConfig{
+ Issuer: "http://localhost:9090",
+ Upstreams: []authserver.UpstreamRunConfig{
+ {
+ Name: "okta",
+ Type: authserver.UpstreamProviderTypeOIDC,
+ LoginPolicy: authserver.UpstreamLoginPolicyOnDemand,
+ },
+ {Name: "github", Type: authserver.UpstreamProviderTypeOAuth2},
+ },
+ AllowedAudiences: []string{"https://my-vmcp"},
+ },
+ wantErr: true,
+ errMsg: "not allowed on the first upstream",
+ },
+ {
+ name: "login_policy_ondemand_on_non_first_upstream_passes",
+ cfg: &Config{
+ IncomingAuth: &IncomingAuthConfig{
+ Type: IncomingAuthTypeOIDC,
+ OIDC: &OIDCConfig{
+ Issuer: "http://localhost:9090",
+ Audience: "https://my-vmcp",
+ },
+ },
+ OutgoingAuth: &OutgoingAuthConfig{
+ Source: "inline",
+ },
+ },
+ rc: &authserver.RunConfig{
+ Issuer: "http://localhost:9090",
+ Upstreams: []authserver.UpstreamRunConfig{
+ {Name: "okta", Type: authserver.UpstreamProviderTypeOIDC},
+ {
+ Name: "github",
+ Type: authserver.UpstreamProviderTypeOAuth2,
+ LoginPolicy: authserver.UpstreamLoginPolicyOnDemand,
+ },
+ },
+ AllowedAudiences: []string{"https://my-vmcp"},
+ },
+ wantErr: false,
+ },
{
name: "v05_no_upstreams",
cfg: &Config{
From f391f8db9a1ffb04b3f4e4538160e5f141cb5484 Mon Sep 17 00:00:00 2001
From: Pablo Sanchez <494757+onepabz@users.noreply.github.com>
Date: Tue, 14 Jul 2026 21:09:14 +1200
Subject: [PATCH 2/2] docs: regenerate swagger for loginPolicy upstream field
---
docs/server/docs.go | 15 +++++++++++++++
docs/server/swagger.json | 15 +++++++++++++++
docs/server/swagger.yaml | 16 ++++++++++++++++
3 files changed, 46 insertions(+)
diff --git a/docs/server/docs.go b/docs/server/docs.go
index a0c023d1b9..24aa5c77a9 100644
--- a/docs/server/docs.go
+++ b/docs/server/docs.go
@@ -665,6 +665,18 @@ const docTemplate = `{
},
"type": "object"
},
+ "github_com_stacklok_toolhive_pkg_authserver.UpstreamLoginPolicy": {
+ "description": "LoginPolicy controls whether this upstream participates in every login\nauthorization chain (\"required\", the default when empty) or is excluded\nfrom it (\"onDemand\"). When any upstream is \"onDemand\", the runner\ninstalls a static upstream filter that narrows the chain to the\n\"required\" upstreams. Must be empty or \"required\" on the first upstream.",
+ "enum": [
+ "required",
+ "onDemand"
+ ],
+ "type": "string",
+ "x-enum-varnames": [
+ "UpstreamLoginPolicyRequired",
+ "UpstreamLoginPolicyOnDemand"
+ ]
+ },
"github_com_stacklok_toolhive_pkg_authserver.UpstreamProviderType": {
"description": "Type specifies the provider type: \"oidc\" or \"oauth2\".",
"enum": [
@@ -679,6 +691,9 @@ const docTemplate = `{
},
"github_com_stacklok_toolhive_pkg_authserver.UpstreamRunConfig": {
"properties": {
+ "login_policy": {
+ "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_authserver.UpstreamLoginPolicy"
+ },
"name": {
"description": "Name uniquely identifies this upstream.\nUsed for routing decisions and session binding in multi-upstream scenarios.\nIf empty when only one upstream is configured, defaults to \"default\".",
"type": "string"
diff --git a/docs/server/swagger.json b/docs/server/swagger.json
index 54cf8b6e4c..84d836c2a0 100644
--- a/docs/server/swagger.json
+++ b/docs/server/swagger.json
@@ -658,6 +658,18 @@
},
"type": "object"
},
+ "github_com_stacklok_toolhive_pkg_authserver.UpstreamLoginPolicy": {
+ "description": "LoginPolicy controls whether this upstream participates in every login\nauthorization chain (\"required\", the default when empty) or is excluded\nfrom it (\"onDemand\"). When any upstream is \"onDemand\", the runner\ninstalls a static upstream filter that narrows the chain to the\n\"required\" upstreams. Must be empty or \"required\" on the first upstream.",
+ "enum": [
+ "required",
+ "onDemand"
+ ],
+ "type": "string",
+ "x-enum-varnames": [
+ "UpstreamLoginPolicyRequired",
+ "UpstreamLoginPolicyOnDemand"
+ ]
+ },
"github_com_stacklok_toolhive_pkg_authserver.UpstreamProviderType": {
"description": "Type specifies the provider type: \"oidc\" or \"oauth2\".",
"enum": [
@@ -672,6 +684,9 @@
},
"github_com_stacklok_toolhive_pkg_authserver.UpstreamRunConfig": {
"properties": {
+ "login_policy": {
+ "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_authserver.UpstreamLoginPolicy"
+ },
"name": {
"description": "Name uniquely identifies this upstream.\nUsed for routing decisions and session binding in multi-upstream scenarios.\nIf empty when only one upstream is configured, defaults to \"default\".",
"type": "string"
diff --git a/docs/server/swagger.yaml b/docs/server/swagger.yaml
index b70a631aa5..41c151eacf 100644
--- a/docs/server/swagger.yaml
+++ b/docs/server/swagger.yaml
@@ -749,6 +749,20 @@ components:
"scope".
type: string
type: object
+ github_com_stacklok_toolhive_pkg_authserver.UpstreamLoginPolicy:
+ description: |-
+ LoginPolicy controls whether this upstream participates in every login
+ authorization chain ("required", the default when empty) or is excluded
+ from it ("onDemand"). When any upstream is "onDemand", the runner
+ installs a static upstream filter that narrows the chain to the
+ "required" upstreams. Must be empty or "required" on the first upstream.
+ enum:
+ - required
+ - onDemand
+ type: string
+ x-enum-varnames:
+ - UpstreamLoginPolicyRequired
+ - UpstreamLoginPolicyOnDemand
github_com_stacklok_toolhive_pkg_authserver.UpstreamProviderType:
description: 'Type specifies the provider type: "oidc" or "oauth2".'
enum:
@@ -760,6 +774,8 @@ components:
- UpstreamProviderTypeOAuth2
github_com_stacklok_toolhive_pkg_authserver.UpstreamRunConfig:
properties:
+ login_policy:
+ $ref: '#/components/schemas/github_com_stacklok_toolhive_pkg_authserver.UpstreamLoginPolicy'
name:
description: |-
Name uniquely identifies this upstream.