From c7151d74a18a1330ea71692d802ead11d65b725f Mon Sep 17 00:00:00 2001 From: Jakub Hrozek Date: Wed, 2 Sep 2026 11:47:36 +0200 Subject: [PATCH 1/7] Make outbound DCR cache population race-safe across replicas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A reviewer found that two replicas racing on the same outbound DCR (RFC 7591) cache-miss could each independently register a different OAuth client with the upstream IdP — dynamic registration always mints a fresh client_id/secret — then whichever replica's write landed last in the shared Redis cache silently won. The losing replica keeps the client it registered baked into its own config for the rest of its process lifetime (DCR resolution runs once per upstream at startup, never re-resolved), so it no longer agrees with the durable cache about which client it holds credentials for. dcrFlight (a singleflight.Group) only coalesces concurrent callers within one process; it has no cross-replica reach. Change the cache-population contract from upsert to create-if-absent, returning the authoritative durable value either way: the caller's own resolution on a successful claim, or the concurrent winner's otherwise. CredentialStore.Put becomes PutIfAbsent, and DCRCredentialStore.StoreDCRCredentials becomes StoreDCRCredentialsIfAbsent; registerAndCache now returns whichever resolution the store says is authoritative instead of trusting its own local registration, and logs (at Debug, without ever including a secret) when this replica lost the race. Callers MUST use the returned value — RFC 7591 guarantees nothing about the two registrations converging. Redis claims the key with SET...NX (the same reservation-lock shape already used twice in this file for ClientAssertionJWTValid and ConsumeAssertionJWT), not WATCH/MULTI: unlike ReconcileConfiguredClient, this write has no read-then-decide step to protect, so a plain atomic NX claim is sufficient. On a lost claim it reads back the winner through the existing GetDCRCredentials path rather than a second, hand-rolled unmarshal, and retries the whole claim-or-read cycle (bounded) if the winner's row evicts between the failed NX and the read — its TTL can be as short as one second when the caller's ClientSecretExpiresAt was already in the past, so this is a real, reachable window, not a hypothetical one, and the alternative (a hard error) would turn a retryable race into a permanent startup failure. MemoryStorage's implementation treats an existing entry as absent only when its ClientSecretExpiresAt is non-zero and already past — otherwise it returns the existing entry unchanged rather than overwriting it. A single process's dcrFlight already prevents a live race there; this is contract symmetry with Redis, plus the correctness case Redis gets from TTL eviction: without the expiry check, a never-expiring entry can never be reclaimed, but a naive "any existing entry blocks re-registration" check would also permanently pin an already-expired one that should be re-registered. Refs #6200 Signed-off-by: Jakub Hrozek --- pkg/auth/dcr/resolver.go | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/pkg/auth/dcr/resolver.go b/pkg/auth/dcr/resolver.go index 05268b3f66..29b2e4b545 100644 --- a/pkg/auth/dcr/resolver.go +++ b/pkg/auth/dcr/resolver.go @@ -486,6 +486,16 @@ func registerAndCache( return nil, newDCRStepError(dcrStepCacheWrite, req.Issuer, redirectURI, fmt.Errorf("cache put: %w", err)) } + if authoritative.ClientID != resolution.ClientID { + //nolint:gosec // G706: client_id is public metadata per RFC 7591. + slog.Debug("dcr: registration superseded by concurrent winner", + "local_issuer", req.Issuer, + "upstream_id", key.UpstreamID, + "redirect_uri", redirectURI, + "registered_client_id", resolution.ClientID, + "authoritative_client_id", authoritative.ClientID, + ) + } // The authoritative row can be a concurrent claimant's stable-but-expired // registration: when both the existing stored row and this replica's From 4b44bd1e250cea0a72ec1e2cf3745455b3cfe0a5 Mon Sep 17 00:00:00 2001 From: Jakub Hrozek Date: Thu, 3 Sep 2026 16:11:21 +0200 Subject: [PATCH 2/7] Reject an already-expired authoritative DCR credential StoreDCRCredentialsIfAbsent deliberately returns a stable-but-expired existing row without error when both it and a fresh registration attempt are already expired, to avoid every concurrent claimant re-entering the write path and exhausting retries. That's the right call for the storage layer, but registerAndCache was treating whatever it got back as a successful resolution regardless -- handing callers a client_secret the upstream has already invalidated. Reject an already-expired authoritative credential in registerAndCache instead, where "expired means unusable" is actually DCR policy, not storage policy. This also covers a replica's own fresh registration turning out already-expired (upstream clock skew, or an upstream that issues a past client_secret_expires_at) -- the same guard applies either way, since a fresh-but-dead secret is exactly as unusable as a stale winner's. Signed-off-by: Jakub Hrozek --- pkg/auth/dcr/resolver.go | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/pkg/auth/dcr/resolver.go b/pkg/auth/dcr/resolver.go index 29b2e4b545..05268b3f66 100644 --- a/pkg/auth/dcr/resolver.go +++ b/pkg/auth/dcr/resolver.go @@ -486,16 +486,6 @@ func registerAndCache( return nil, newDCRStepError(dcrStepCacheWrite, req.Issuer, redirectURI, fmt.Errorf("cache put: %w", err)) } - if authoritative.ClientID != resolution.ClientID { - //nolint:gosec // G706: client_id is public metadata per RFC 7591. - slog.Debug("dcr: registration superseded by concurrent winner", - "local_issuer", req.Issuer, - "upstream_id", key.UpstreamID, - "redirect_uri", redirectURI, - "registered_client_id", resolution.ClientID, - "authoritative_client_id", authoritative.ClientID, - ) - } // The authoritative row can be a concurrent claimant's stable-but-expired // registration: when both the existing stored row and this replica's From 203c42f04d6058f9fcaab4b5c8b3e49a74baab6b Mon Sep 17 00:00:00 2001 From: Jakub Hrozek Date: Sun, 30 Aug 2026 14:31:30 +0200 Subject: [PATCH 3/7] Normalize canonical inbound grants The SPIFFE client-auth epic needs a place to configure SPIFFE association policy without inventing a parallel trust/grant path next to the existing delegate-client and trusted-issuer configuration. As more inbound grant families (RFC 8693 token exchange, RFC 7523 JWT-bearer, SPIFFE) accumulate, they need one canonical surface to configure and reason about instead of three independent ones, without breaking deployments that already rely on the legacy fields. Add pkg/authserver/inbound_grants.go with NormalizeInboundGrants, which reconciles a new canonical RunConfig.InboundGrants surface (per-family token_exchange/jwt_bearer sub-configs whose issuer_policies reference a trusted_issuers entry by name) against the legacy top-level delegate_clients and the RFC 8693/7523 fields embedded directly on trusted_issuers. Legacy and canonical configuration for the same grant family are mutually exclusive and rejected at validation time; the two families are otherwise independent, and omitting inbound_grants entirely preserves released behavior. Thread the normalized result through RunConfig.Validate, the embedded-auth-server runner, and buildProvider/discovery, adding a DisableTokenExchange capability so RFC 8693 registration and discovery advertisement can be turned off together and can't drift out of sync. Add TrustedIssuer.Name so canonical issuer_policies can reference an issuer without duplicating its fields. SPIFFE client authentication (InboundGrants.SPIFFEClientAuth, defined in the previous commit) is deliberately kept a sibling of TokenExchange and JWTBearer here, not nested under either: SPIFFE authenticates a client, it does not by itself grant it anything, so making it subordinate to RFC 8693 enablement would mean disabling token exchange silently drops every SPIFFE association, and every SPIFFE-authenticated client would be implicitly token-exchange-capable. It is validated and wired directly from RunConfig.InboundGrants in RunConfig.Validate/embeddedauthserver.go, independent of this file's legacy/canonical projection, so authentication method and grant-family enablement stay separately configurable. Update docs/arch/17-token-exchange-delegation.md for the new inbound_grants shape and the now-conditional token-exchange discovery advertisement, and add a runner-level test proving the canonical delegate-client, SPIFFE-client, and jwt_bearer paths reach a running server (the existing tests only covered normalization in isolation). SPIFFE client-auth associations always require the token-exchange grant (the only grant type they may declare), independent of the legacy/canonical token-exchange projection above: NormalizeInboundGrants now sets Capabilities.TokenExchange true whenever InboundGrants.SPIFFEClientAuth is non-empty, so a SPIFFE-only configuration cannot leave it false and silently disable the RFC 8693 grant handler server-wide -- which would reject every SPIFFE client's own token requests before authentication is even checked. Guarded by a regression test in this package (not just the runner-level test above) since the equivalent fix was previously lost during a rebase when its only coverage lived one package away. DCR (RFC 7591 /oauth/register) now rejects a registration whose effective grant types include token-exchange when it is disabled server-wide, instead of accepting the client and only failing later, confusingly, at /oauth/token. The check runs on the post-defaulting grant types validateGrantTypes already computes (a private_key_jwt client with an empty grant_types is implicitly token-exchange-only), so it catches both the explicit and implicit cases the same way scope validation already gates DCR on ScopesSupported. Corrected two stale doc references caught in review: the SPIFFE client-policy field path (inbound_grants.spiffe_client_auth, not nested under token_exchange) and the JWT-bearer legacy/canonical conflict wording (family-wide across all issuers, not per-issuer). Refs #6200 Signed-off-by: Jakub Hrozek From 349a0d8d2afa8b613f3d0977ad122464d82b30b8 Mon Sep 17 00:00:00 2001 From: Jakub Hrozek Date: Sun, 30 Aug 2026 14:46:39 +0200 Subject: [PATCH 4/7] Expose canonical inbound grants in CRDs The operator needs the shared inbound-grant model before it can add SPIFFE associations without inventing a separate Kubernetes API. Add v1beta1 grant-family types, CEL constraints, runtime conversion, generated schemas, and compatibility coverage for existing grant fields. Refs #6200 --- .../v1beta1/mcpexternalauthconfig_types.go | 162 ++++- .../mcpexternalauthconfig_types_test.go | 18 +- .../api/v1beta1/zz_generated.deepcopy.go | 122 ++++ .../virtualmcpserver_controller.go | 4 +- .../virtualmcpserver_controller_test.go | 16 + .../pkg/controllerutil/authserver.go | 140 +++- .../authserver_inbound_grants_test.go | 91 +++ .../inbound_grants_cel_test.go | 159 +++++ .../upstream_provider_cel_test.go | 3 +- ...e.stacklok.dev_mcpexternalauthconfigs.yaml | 654 ++++++++++++++++-- ...olhive.stacklok.dev_virtualmcpservers.yaml | 654 ++++++++++++++++-- ...e.stacklok.dev_mcpexternalauthconfigs.yaml | 654 ++++++++++++++++-- ...olhive.stacklok.dev_virtualmcpservers.yaml | 654 ++++++++++++++++-- docs/operator/crd-api.md | 129 +++- 14 files changed, 3065 insertions(+), 395 deletions(-) create mode 100644 cmd/thv-operator/pkg/controllerutil/authserver_inbound_grants_test.go create mode 100644 cmd/thv-operator/test-integration/mcp-external-auth/inbound_grants_cel_test.go diff --git a/cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types.go b/cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types.go index a67c8499dd..87842ca306 100644 --- a/cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types.go +++ b/cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types.go @@ -389,39 +389,25 @@ type DelegateClientConfig struct { } // TrustedIssuerConfig configures an external OIDC issuer whose tokens are -// accepted as RFC 8693 subject tokens or RFC 7523 JWT-bearer assertions during -// token exchange. It mirrors tokenexchange.TrustedIssuer -// (pkg/authserver/server/tokenexchange), the runtime type the operator converts -// this into directly — no secret is referenced by this type, so no SecretKeyRef -// indirection is needed, unlike DelegateClientConfig. -// -// expectedAudience is exempted only for a grant-only issuer: jwtBearerGrant -// present and none of actorClaim, actorMatcher, allowMayAct, or allowedActors -// set. Any RFC 8693 delegation field (actorClaim, actorMatcher, allowMayAct, -// allowedActors) still requires expectedAudience, even when combined with -// jwtBearerGrant. +// accepted as RFC 8693 subject tokens or RFC 7523 JWT-bearer assertions. Trust +// fields remain top-level; canonical grant policy references this declaration by +// Name. The embedded grant-policy fields are retained for released CRD compatibility. // // +kubebuilder:validation:XValidation:rule="!has(self.allowedDelegateClients) || !('*' in self.allowedDelegateClients) || size(self.allowedDelegateClients) == 1",message="allowedDelegateClients must not combine the wildcard \"*\" with specific client IDs" -// -// The allowedDelegateClients rule below mirrors validateDelegationPolicy -// (pkg/authserver/server/tokenexchange/multi_issuer_validator.go): it is -// keyed on whether ANY delegation field is set (expectedAudience, -// actorClaim, actorMatcher, allowMayAct), not merely on whether -// jwtBearerGrant is absent — an issuer can combine jwtBearerGrant with -// expectedAudience for RFC 8693 delegation on the same issuer, and that -// combination still requires allowedDelegateClients at the Go level. -// // +kubebuilder:validation:XValidation:rule="!(has(self.allowMayAct) && self.allowMayAct && '*' in self.allowedDelegateClients)",message="allowMayAct must not be enabled when allowedDelegateClients contains the wildcard \"*\"" // +kubebuilder:validation:XValidation:rule="!has(self.actorClaim) || !(self.actorClaim in ['sub', 'iss', 'aud', 'exp', 'iat', 'nbf', 'jti', 'name', 'email', 'scope', 'scp', 'may_act'])",message="actorClaim must name a readable claim; use client_id or a non-reserved claim such as azp, appid, or cid" // +kubebuilder:validation:XValidation:rule="!(has(self.allowPrivateIPs) && self.allowPrivateIPs) || (has(self.jwksUrl) && self.jwksUrl != \"\")",message="allowPrivateIPs requires jwksUrl to be set explicitly" -// +kubebuilder:validation:XValidation:rule="(has(self.jwtBearerGrant) && !((has(self.actorClaim) && size(self.actorClaim) > 0) || (has(self.actorMatcher) && size(self.actorMatcher) > 0) || (has(self.allowMayAct) && self.allowMayAct) || (has(self.allowedActors) && size(self.allowedActors) > 0))) || (has(self.expectedAudience) && size(self.expectedAudience) > 0)",message="expectedAudience is required unless jwtBearerGrant is configured without actorClaim, actorMatcher, allowMayAct, or allowedActors" -// +kubebuilder:validation:XValidation:rule="!((has(self.expectedAudience) && size(self.expectedAudience) > 0) || (has(self.actorClaim) && size(self.actorClaim) > 0) || (has(self.actorMatcher) && size(self.actorMatcher) > 0) || (has(self.allowMayAct) && self.allowMayAct)) || (has(self.allowedDelegateClients) && size(self.allowedDelegateClients) > 0)",message="allowedDelegateClients is required when expectedAudience, actorClaim, actorMatcher, or allowMayAct is set" +// +kubebuilder:validation:XValidation:rule="!((has(self.actorClaim) && size(self.actorClaim) > 0) || (has(self.actorMatcher) && size(self.actorMatcher) > 0) || (has(self.allowMayAct) && self.allowMayAct) || (has(self.allowedActors) && size(self.allowedActors) > 0) || (has(self.allowedDelegateClients) && size(self.allowedDelegateClients) > 0)) || (has(self.expectedAudience) && size(self.expectedAudience) > 0)",message="expectedAudience is required when legacy RFC 8693 policy is configured" +// +kubebuilder:validation:XValidation:rule="!((has(self.expectedAudience) && size(self.expectedAudience) > 0) || (has(self.actorClaim) && size(self.actorClaim) > 0) || (has(self.actorMatcher) && size(self.actorMatcher) > 0) || (has(self.allowMayAct) && self.allowMayAct) || (has(self.allowedActors) && size(self.allowedActors) > 0)) || (has(self.allowedDelegateClients) && size(self.allowedDelegateClients) > 0)",message="allowedDelegateClients is required when legacy RFC 8693 policy is configured" // //nolint:lll // CEL validation rules exceed line length limit type TrustedIssuerConfig struct { - // The actorClaim rule above uses !has(...) rather than comparing against an - // empty string literal: gofmt rewrites a doubled apostrophe inside a comment - // into a curly quote, which CEL then fails to parse. + // Name optionally identifies this trust declaration for canonical issuerRef references. + // Names must be unique within trustedIssuers when set. + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=253 + // +optional + Name string `json:"name,omitempty"` // IssuerURL is the expected "iss" claim value (exact match). // +kubebuilder:validation:Required @@ -435,6 +421,8 @@ type TrustedIssuerConfig struct { // +kubebuilder:validation:MinLength=1 // +kubebuilder:validation:MaxLength=2048 // +optional + // This legacy field is deprecated; configure RFC 8693 policy under + // inboundGrants.tokenExchange.issuerPolicies. ExpectedAudience string `json:"expectedAudience,omitempty"` // JWKSURL is the URL to fetch the issuer's JSON Web Key Set from. If @@ -475,6 +463,8 @@ type TrustedIssuerConfig struct { // client_id claim instead. // +optional // +kubebuilder:validation:MaxLength=64 + // This legacy field is deprecated; configure RFC 8693 policy under + // inboundGrants.tokenExchange.issuerPolicies. ActorClaim string `json:"actorClaim,omitempty"` // AllowedActors is the allowlist of actorClaim values authorized to @@ -488,6 +478,8 @@ type TrustedIssuerConfig struct { // +kubebuilder:validation:items:MaxLength=256 // +listType=atomic // +optional + // This legacy field is deprecated; configure RFC 8693 policy under + // inboundGrants.tokenExchange.issuerPolicies. AllowedActors []string `json:"allowedActors,omitempty"` // ActorMatcher is an admin-authored CEL expression evaluated against the @@ -500,6 +492,8 @@ type TrustedIssuerConfig struct { // not admission — there is no validating webhook for this field. // +optional // +kubebuilder:validation:MaxLength=4096 + // This legacy field is deprecated; configure RFC 8693 policy under + // inboundGrants.tokenExchange.issuerPolicies. ActorMatcher string `json:"actorMatcher,omitempty"` // AllowedDelegateClients restricts which ToolHive client IDs may exchange @@ -513,6 +507,8 @@ type TrustedIssuerConfig struct { // +kubebuilder:validation:items:MaxLength=256 // +listType=atomic // +optional + // This legacy field is deprecated; configure RFC 8693 policy under + // inboundGrants.tokenExchange.issuerPolicies. AllowedDelegateClients []string `json:"allowedDelegateClients,omitempty"` // AllowMayAct permits this external issuer's may_act claim to authorize @@ -523,11 +519,15 @@ type TrustedIssuerConfig struct { // this setting. // +kubebuilder:default=false // +optional + // This legacy field is deprecated; configure RFC 8693 policy under + // inboundGrants.tokenExchange.issuerPolicies. AllowMayAct bool `json:"allowMayAct,omitempty"` // JWTBearerGrant enables the plain RFC 7523 JWT-bearer grant for this // issuer. It is independent of RFC 8693 delegation policy. // +optional + // This legacy field is deprecated; configure RFC 7523 policy under + // inboundGrants.jwtBearer.issuerPolicies. JWTBearerGrant *JWTBearerGrantConfig `json:"jwtBearerGrant,omitempty"` } @@ -586,6 +586,86 @@ type JWTBearerSubjectBinding struct { AllowedResources []string `json:"allowedResources"` } +// InboundGrantsConfig groups canonical inbound OAuth grant-family configuration. +type InboundGrantsConfig struct { + // TokenExchange configures RFC 8693 clients and issuer policies. + // +optional + TokenExchange *TokenExchangeInboundGrantConfig `json:"tokenExchange,omitempty"` + + // JWTBearer configures RFC 7523 issuer policies. + // +optional + JWTBearer *JWTBearerInboundGrantConfig `json:"jwtBearer,omitempty"` +} + +// TokenExchangeInboundGrantConfig configures canonical RFC 8693 inbound grants. +type TokenExchangeInboundGrantConfig struct { + // DelegateClients configures pre-provisioned confidential clients. + // +kubebuilder:validation:MaxItems=10 + // +listType=atomic + // +optional + DelegateClients []DelegateClientConfig `json:"delegateClients,omitempty"` + + // IssuerPolicies binds RFC 8693 policy to named trusted issuers. + // +kubebuilder:validation:MaxItems=20 + // +listType=atomic + // +optional + IssuerPolicies []TokenExchangeIssuerPolicyConfig `json:"issuerPolicies,omitempty"` +} + +// TokenExchangeIssuerPolicyConfig binds RFC 8693 policy to a named trusted issuer. +type TokenExchangeIssuerPolicyConfig struct { + // IssuerRef references trustedIssuers[].name. + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=253 + IssuerRef string `json:"issuerRef"` + + // ExpectedAudience is the required RFC 8693 subject-token audience. + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=2048 + ExpectedAudience string `json:"expectedAudience"` + + // ActorClaim names the claim containing the external actor identity. + // +kubebuilder:validation:MaxLength=64 + // +optional + ActorClaim string `json:"actorClaim,omitempty"` + + // +kubebuilder:validation:MaxItems=50 + // +listType=atomic + // +optional + AllowedActors []string `json:"allowedActors,omitempty"` + + // +kubebuilder:validation:MaxLength=4096 + // +optional + ActorMatcher string `json:"actorMatcher,omitempty"` + + // +kubebuilder:validation:MinItems=1 + // +kubebuilder:validation:MaxItems=50 + // +listType=atomic + AllowedDelegateClients []string `json:"allowedDelegateClients"` + + // +optional + AllowMayAct bool `json:"allowMayAct,omitempty"` +} + +// JWTBearerInboundGrantConfig configures canonical RFC 7523 inbound grants. +type JWTBearerInboundGrantConfig struct { + // IssuerPolicies binds RFC 7523 policy to named trusted issuers. + // +kubebuilder:validation:MaxItems=20 + // +listType=atomic + // +optional + IssuerPolicies []JWTBearerIssuerPolicyConfig `json:"issuerPolicies,omitempty"` +} + +// JWTBearerIssuerPolicyConfig binds RFC 7523 policy to a named trusted issuer. +type JWTBearerIssuerPolicyConfig struct { + // IssuerRef references trustedIssuers[].name. + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=253 + IssuerRef string `json:"issuerRef"` + + JWTBearerGrantConfig `json:",inline"` // nolint:revive +} + // EmbeddedAuthServerConfig holds configuration for the embedded OAuth2/OIDC authorization server. // This enables running an authorization server that delegates authentication to upstream IDPs // or accepts token exchange without an interactive authorization flow. @@ -595,18 +675,25 @@ type JWTBearerSubjectBinding struct { // delegate clients using an HTTP issuer; the shared Go validator performs the // precise loopback-host security check. // -// +kubebuilder:validation:XValidation:rule="(has(self.upstreamProviders) && size(self.upstreamProviders) > 0) || (has(self.delegateClients) && size(self.delegateClients) > 0) || (has(self.trustedIssuers) && self.trustedIssuers.exists(issuer, has(issuer.jwtBearerGrant)))",message="at least one upstream provider is required unless delegateClients or a trustedIssuer with jwtBearerGrant is configured" +// +kubebuilder:validation:XValidation:rule="(has(self.upstreamProviders) && size(self.upstreamProviders) > 0) || (has(self.delegateClients) && size(self.delegateClients) > 0) || (has(self.trustedIssuers) && self.trustedIssuers.exists(issuer, has(issuer.jwtBearerGrant))) || (has(self.inboundGrants) && (has(self.inboundGrants.tokenExchange) || has(self.inboundGrants.jwtBearer)))",message="at least one upstream provider or inbound grant family is required" // // +kubebuilder:validation:XValidation:rule="!(has(self.allowConfidentialClientRegistration) && self.allowConfidentialClientRegistration && has(self.insecureAllowHTTP) && self.insecureAllowHTTP)",message="allowConfidentialClientRegistration cannot be combined with insecureAllowHTTP; client secrets would be issued in cleartext over an unauthenticated endpoint" // +kubebuilder:validation:XValidation:rule="(!has(self.forceConfidentialRedirectUris) || size(self.forceConfidentialRedirectUris) == 0) || (has(self.allowConfidentialClientRegistration) && self.allowConfidentialClientRegistration)",message="forceConfidentialRedirectUris requires allowConfidentialClientRegistration to be true" // +kubebuilder:validation:XValidation:rule="!has(self.delegateClients) || size(self.delegateClients) == 0 || !self.issuer.startsWith('http://') || (has(self.insecureAllowConfidentialOverLoopbackHTTP) && self.insecureAllowConfidentialOverLoopbackHTTP)",message="delegateClients with an HTTP issuer require insecureAllowConfidentialOverLoopbackHTTP to be explicitly enabled; the issuer must still be loopback" +// +kubebuilder:validation:XValidation:rule="!has(self.inboundGrants) || !has(self.inboundGrants.tokenExchange) || ((!has(self.delegateClients) || size(self.delegateClients) == 0) && (!has(self.trustedIssuers) || !self.trustedIssuers.exists(issuer, has(issuer.expectedAudience) || has(issuer.actorClaim) || has(issuer.allowedActors) || has(issuer.actorMatcher) || has(issuer.allowedDelegateClients) || (has(issuer.allowMayAct) && issuer.allowMayAct))))",message="canonical tokenExchange conflicts with legacy delegateClients or RFC 8693 trusted issuer policy" +// +kubebuilder:validation:XValidation:rule="!has(self.inboundGrants) || !has(self.inboundGrants.jwtBearer) || !has(self.trustedIssuers) || !self.trustedIssuers.exists(issuer, has(issuer.jwtBearerGrant))",message="canonical jwtBearer conflicts with legacy jwtBearerGrant" +// +kubebuilder:validation:XValidation:rule="!has(self.trustedIssuers) || self.trustedIssuers.all(issuer, !has(issuer.name) || self.trustedIssuers.filter(other, has(other.name) && other.name == issuer.name).size() == 1)",message="trustedIssuers must not contain duplicate names" +// +kubebuilder:validation:XValidation:rule="!has(self.inboundGrants) || !has(self.inboundGrants.tokenExchange) || !has(self.inboundGrants.tokenExchange.issuerPolicies) || self.inboundGrants.tokenExchange.issuerPolicies.all(policy, has(self.trustedIssuers) && self.trustedIssuers.exists(issuer, has(issuer.name) && issuer.name == policy.issuerRef))",message="every tokenExchange issuerRef must reference a named trusted issuer" +// +kubebuilder:validation:XValidation:rule="!has(self.inboundGrants) || !has(self.inboundGrants.tokenExchange) || !has(self.inboundGrants.tokenExchange.issuerPolicies) || self.inboundGrants.tokenExchange.issuerPolicies.all(policy, self.inboundGrants.tokenExchange.issuerPolicies.filter(other, other.issuerRef == policy.issuerRef).size() == 1)",message="tokenExchange issuerPolicies must not contain duplicate issuerRef values" +// +kubebuilder:validation:XValidation:rule="!has(self.inboundGrants) || !has(self.inboundGrants.jwtBearer) || !has(self.inboundGrants.jwtBearer.issuerPolicies) || self.inboundGrants.jwtBearer.issuerPolicies.all(policy, has(self.trustedIssuers) && self.trustedIssuers.exists(issuer, has(issuer.name) && issuer.name == policy.issuerRef))",message="every jwtBearer issuerRef must reference a named trusted issuer" +// +kubebuilder:validation:XValidation:rule="!has(self.inboundGrants) || !has(self.inboundGrants.jwtBearer) || !has(self.inboundGrants.jwtBearer.issuerPolicies) || self.inboundGrants.jwtBearer.issuerPolicies.all(policy, self.inboundGrants.jwtBearer.issuerPolicies.filter(other, other.issuerRef == policy.issuerRef).size() == 1)",message="jwtBearer issuerPolicies must not contain duplicate issuerRef values" // // The shared Go-level ValidateConfidentialClientTransport validator remains the // source of truth for confidential-client transport and loopback policy, // including delegate clients. Full issuer URL validation is performed by the // runtime configuration validator. // -//nolint:lll // CEL validation rule exceeds line length limit +//nolint:lll // CEL validation rules exceed line length limits. type EmbeddedAuthServerConfig struct { // Issuer is the issuer identifier for this authorization server. // This will be included in the "iss" claim of issued tokens. @@ -652,6 +739,10 @@ type EmbeddedAuthServerConfig struct { // +optional TokenLifespans *TokenLifespanConfig `json:"tokenLifespans,omitempty"` + // InboundGrants configures canonical inbound OAuth grant families. + // +optional + InboundGrants *InboundGrantsConfig `json:"inboundGrants,omitempty"` + // UpstreamProviders configures connections to upstream Identity Providers. // When configured, the embedded auth server delegates interactive authentication // to these providers. It may be omitted only when delegateClients or a trusted @@ -810,6 +901,7 @@ type EmbeddedAuthServerConfig struct { // +kubebuilder:validation:MaxItems=10 // +listType=atomic // +optional + // This legacy field is deprecated; use inboundGrants.tokenExchange.delegateClients. DelegateClients []DelegateClientConfig `json:"delegateClients,omitempty"` // TrustedIssuers configures external OIDC issuers whose tokens are @@ -867,8 +959,10 @@ type EmbeddedAuthServerConfig struct { // here: it never returns a secret in the DCR response, so cleartext HTTP // exposes nothing this check would protect. func (c *EmbeddedAuthServerConfig) ValidateConfidentialClientTransport() error { + canonicalDelegate := c.InboundGrants != nil && c.InboundGrants.TokenExchange != nil && + len(c.InboundGrants.TokenExchange.DelegateClients) > 0 return authserver.ValidateConfidentialClientTransport( - c.AllowConfidentialClientRegistration || len(c.DelegateClients) > 0, + c.AllowConfidentialClientRegistration || len(c.DelegateClients) > 0 || canonicalDelegate, c.InsecureAllowHTTP, c.Issuer, c.InsecureAllowConfidentialOverLoopbackHTTP, @@ -2016,10 +2110,9 @@ func (r *MCPExternalAuthConfig) validateEmbeddedAuthServer() error { return nil } - if len(cfg.UpstreamProviders) == 0 && len(cfg.DelegateClients) == 0 && + if len(cfg.UpstreamProviders) == 0 && len(cfg.DelegateClients) == 0 && cfg.InboundGrants == nil && !hasJWTBearerTrustedIssuer(cfg.TrustedIssuers) { - return fmt.Errorf("at least one upstream provider is required unless delegateClients " + - "or a trustedIssuer with jwtBearerGrant is configured") + return fmt.Errorf("at least one upstream provider or inbound grant family is required") } // Note: multi-upstream is accepted at the CRD level. Consumer controllers // (MCPServer, MCPRemoteProxy) enforce single-upstream restrictions; @@ -2047,8 +2140,10 @@ func (r *MCPExternalAuthConfig) validateEmbeddedAuthServer() error { // available on this CRD. The same accepted_audiences/allowed_audiences // disjointness check runs again once that value exists, at // Config.Validate time (pkg/authserver/config.go's validateTrustedIssuers). - if err := tokenexchange.ValidateTrustedIssuers(buildTrustedIssuerConfigs(cfg.TrustedIssuers), cfg.Issuer, nil); err != nil { - return fmt.Errorf("trustedIssuers: %w", err) + if cfg.InboundGrants == nil { + if err := tokenexchange.ValidateTrustedIssuers(buildTrustedIssuerConfigs(cfg.TrustedIssuers), cfg.Issuer, nil); err != nil { + return fmt.Errorf("trustedIssuers: %w", err) + } } for i := range cfg.TrustedIssuers { if err := validateUpstreamCABundleRef(cfg.TrustedIssuers[i].CABundleRef); err != nil { @@ -2083,6 +2178,7 @@ func buildTrustedIssuerConfigs(issuers []TrustedIssuerConfig) []tokenexchange.Tr configs := make([]tokenexchange.TrustedIssuer, len(issuers)) for i, issuer := range issuers { configs[i] = tokenexchange.TrustedIssuer{ + Name: issuer.Name, IssuerURL: issuer.IssuerURL, ExpectedAudience: issuer.ExpectedAudience, JWKSURL: issuer.JWKSURL, diff --git a/cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types_test.go b/cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types_test.go index 0b39c19a9c..00ec4652c6 100644 --- a/cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types_test.go +++ b/cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types_test.go @@ -191,7 +191,7 @@ func TestMCPExternalAuthConfig_Validate(t *testing.T) { }, }, expectErr: true, - errMsg: "at least one upstream provider is required", + errMsg: "at least one upstream provider or inbound grant family is required", }, { name: "invalid OIDC provider without oidcConfig", @@ -564,7 +564,7 @@ func TestMCPExternalAuthConfig_validateEmbeddedAuthServer(t *testing.T) { }, }, expectErr: true, - errMsg: "at least one upstream provider is required", + errMsg: "at least one upstream provider or inbound grant family is required", }, { name: "nil embedded auth server config", @@ -836,7 +836,7 @@ func TestMCPExternalAuthConfig_ZeroUpstreamAlternatives(t *testing.T) { { name: "no token-only alternative is rejected", config: EmbeddedAuthServerConfig{Issuer: "https://auth.example.com"}, - wantError: "at least one upstream provider is required", + wantError: "at least one upstream provider or inbound grant family is required", }, } @@ -1398,6 +1398,18 @@ func TestEmbeddedAuthServerConfig_ValidateConfidentialClientTransport(t *testing }, expectErr: true, }, + { + name: "canonical delegate clients reject HTTP non-loopback without explicit opt in", + config: EmbeddedAuthServerConfig{ + Issuer: "http://auth.example.com", + InboundGrants: &InboundGrantsConfig{ + TokenExchange: &TokenExchangeInboundGrantConfig{ + DelegateClients: delegateClients, + }, + }, + }, + expectErr: true, + }, { name: "delegate clients reject HTTP non-loopback with loopback opt in", config: EmbeddedAuthServerConfig{ diff --git a/cmd/thv-operator/api/v1beta1/zz_generated.deepcopy.go b/cmd/thv-operator/api/v1beta1/zz_generated.deepcopy.go index de7541d3ec..f8171bf525 100644 --- a/cmd/thv-operator/api/v1beta1/zz_generated.deepcopy.go +++ b/cmd/thv-operator/api/v1beta1/zz_generated.deepcopy.go @@ -288,6 +288,11 @@ func (in *EmbeddedAuthServerConfig) DeepCopyInto(out *EmbeddedAuthServerConfig) *out = new(TokenLifespanConfig) **out = **in } + if in.InboundGrants != nil { + in, out := &in.InboundGrants, &out.InboundGrants + *out = new(InboundGrantsConfig) + (*in).DeepCopyInto(*out) + } if in.UpstreamProviders != nil { in, out := &in.UpstreamProviders, &out.UpstreamProviders *out = make([]UpstreamProviderConfig, len(*in)) @@ -653,6 +658,31 @@ func (in *IdentityFromTokenConfig) DeepCopy() *IdentityFromTokenConfig { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *InboundGrantsConfig) DeepCopyInto(out *InboundGrantsConfig) { + *out = *in + if in.TokenExchange != nil { + in, out := &in.TokenExchange, &out.TokenExchange + *out = new(TokenExchangeInboundGrantConfig) + (*in).DeepCopyInto(*out) + } + if in.JWTBearer != nil { + in, out := &in.JWTBearer, &out.JWTBearer + *out = new(JWTBearerInboundGrantConfig) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new InboundGrantsConfig. +func (in *InboundGrantsConfig) DeepCopy() *InboundGrantsConfig { + if in == nil { + return nil + } + out := new(InboundGrantsConfig) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *IncomingAuthConfig) DeepCopyInto(out *IncomingAuthConfig) { *out = *in @@ -760,6 +790,44 @@ func (in *JWTBearerGrantConfig) DeepCopy() *JWTBearerGrantConfig { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *JWTBearerInboundGrantConfig) DeepCopyInto(out *JWTBearerInboundGrantConfig) { + *out = *in + if in.IssuerPolicies != nil { + in, out := &in.IssuerPolicies, &out.IssuerPolicies + *out = make([]JWTBearerIssuerPolicyConfig, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new JWTBearerInboundGrantConfig. +func (in *JWTBearerInboundGrantConfig) DeepCopy() *JWTBearerInboundGrantConfig { + if in == nil { + return nil + } + out := new(JWTBearerInboundGrantConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *JWTBearerIssuerPolicyConfig) DeepCopyInto(out *JWTBearerIssuerPolicyConfig) { + *out = *in + in.JWTBearerGrantConfig.DeepCopyInto(&out.JWTBearerGrantConfig) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new JWTBearerIssuerPolicyConfig. +func (in *JWTBearerIssuerPolicyConfig) DeepCopy() *JWTBearerIssuerPolicyConfig { + if in == nil { + return nil + } + out := new(JWTBearerIssuerPolicyConfig) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *JWTBearerSubjectBinding) DeepCopyInto(out *JWTBearerSubjectBinding) { *out = *in @@ -2932,6 +3000,60 @@ func (in *TokenExchangeConfig) DeepCopy() *TokenExchangeConfig { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *TokenExchangeInboundGrantConfig) DeepCopyInto(out *TokenExchangeInboundGrantConfig) { + *out = *in + if in.DelegateClients != nil { + in, out := &in.DelegateClients, &out.DelegateClients + *out = make([]DelegateClientConfig, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.IssuerPolicies != nil { + in, out := &in.IssuerPolicies, &out.IssuerPolicies + *out = make([]TokenExchangeIssuerPolicyConfig, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TokenExchangeInboundGrantConfig. +func (in *TokenExchangeInboundGrantConfig) DeepCopy() *TokenExchangeInboundGrantConfig { + if in == nil { + return nil + } + out := new(TokenExchangeInboundGrantConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *TokenExchangeIssuerPolicyConfig) DeepCopyInto(out *TokenExchangeIssuerPolicyConfig) { + *out = *in + if in.AllowedActors != nil { + in, out := &in.AllowedActors, &out.AllowedActors + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.AllowedDelegateClients != nil { + in, out := &in.AllowedDelegateClients, &out.AllowedDelegateClients + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TokenExchangeIssuerPolicyConfig. +func (in *TokenExchangeIssuerPolicyConfig) DeepCopy() *TokenExchangeIssuerPolicyConfig { + if in == nil { + return nil + } + out := new(TokenExchangeIssuerPolicyConfig) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *TokenLifespanConfig) DeepCopyInto(out *TokenLifespanConfig) { *out = *in diff --git a/cmd/thv-operator/controllers/virtualmcpserver_controller.go b/cmd/thv-operator/controllers/virtualmcpserver_controller.go index 02e970b08c..1969de8e0c 100644 --- a/cmd/thv-operator/controllers/virtualmcpserver_controller.go +++ b/cmd/thv-operator/controllers/virtualmcpserver_controller.go @@ -635,12 +635,12 @@ func (*VirtualMCPServerReconciler) validateAuthServerConfig( } } - if len(cfg.UpstreamProviders) == 0 && len(cfg.DelegateClients) == 0 && + if len(cfg.UpstreamProviders) == 0 && len(cfg.DelegateClients) == 0 && cfg.InboundGrants == nil && !slices.ContainsFunc(cfg.TrustedIssuers, func(issuer mcpv1beta1.TrustedIssuerConfig) bool { return issuer.JWTBearerGrant != nil }) { message := "spec.authServerConfig requires at least one upstream provider unless " + - "delegateClients or a trustedIssuer with jwtBearerGrant is configured" + "delegateClients, inboundGrants, or a trustedIssuer with jwtBearerGrant is configured" statusManager.SetPhase(mcpv1beta1.VirtualMCPServerPhaseFailed) statusManager.SetMessage(message) statusManager.SetAuthServerConfigValidatedCondition( diff --git a/cmd/thv-operator/controllers/virtualmcpserver_controller_test.go b/cmd/thv-operator/controllers/virtualmcpserver_controller_test.go index 00be797179..67c5034f55 100644 --- a/cmd/thv-operator/controllers/virtualmcpserver_controller_test.go +++ b/cmd/thv-operator/controllers/virtualmcpserver_controller_test.go @@ -4555,6 +4555,22 @@ func TestVirtualMCPServerValidateAuthServerConfig_ZeroUpstreamAlternatives(t *te }}, }, }, + { + name: "canonical inbound grants", + config: &mcpv1beta1.EmbeddedAuthServerConfig{ + Issuer: "https://auth.example.com", + InboundGrants: &mcpv1beta1.InboundGrantsConfig{ + TokenExchange: &mcpv1beta1.TokenExchangeInboundGrantConfig{ + DelegateClients: []mcpv1beta1.DelegateClientConfig{{ + ClientID: "delegate-client", + ClientSecretRef: &mcpv1beta1.SecretKeyRef{Name: "delegate-secret", Key: "client-secret"}, + Scopes: []string{"openid"}, + Audiences: []string{"https://mcp.example.com"}, + }}, + }, + }, + }, + }, } for _, tt := range tests { diff --git a/cmd/thv-operator/pkg/controllerutil/authserver.go b/cmd/thv-operator/pkg/controllerutil/authserver.go index 3d9d821c84..65de78ba04 100644 --- a/cmd/thv-operator/pkg/controllerutil/authserver.go +++ b/cmd/thv-operator/pkg/controllerutil/authserver.go @@ -270,6 +270,7 @@ func buildTrustedIssuerRunConfigs(issuers []mcpv1beta1.TrustedIssuerConfig) []to configs := make([]tokenexchange.TrustedIssuer, len(issuers)) for i, ti := range issuers { configs[i] = tokenexchange.TrustedIssuer{ + Name: ti.Name, IssuerURL: ti.IssuerURL, ExpectedAudience: ti.ExpectedAudience, JWKSURL: ti.JWKSURL, @@ -663,7 +664,11 @@ func GenerateAuthServerEnvVars( // Generate env vars for static delegate client secrets. Their names are // indexed by list position to avoid deriving a reserved env-var name from // arbitrary client ID text. - for i, delegateClient := range authConfig.DelegateClients { + delegateClients := authConfig.DelegateClients + if authConfig.InboundGrants != nil && authConfig.InboundGrants.TokenExchange != nil { + delegateClients = authConfig.InboundGrants.TokenExchange.DelegateClients + } + for i, delegateClient := range delegateClients { if delegateClient.ClientSecretRef != nil { envVars = append(envVars, envVarFromSecretRef( delegateClientSecretEnvVarName(i), delegateClient.ClientSecretRef)) @@ -816,6 +821,62 @@ func validateOIDCConfigForEmbeddedAuthServer(oidcConfig *oidc.OIDCConfig) error return nil } +func buildInboundGrantsRunConfig( + config *mcpv1beta1.InboundGrantsConfig, +) (*authserver.InboundGrantsRunConfig, error) { + if config == nil { + return nil, nil + } + grants := &authserver.InboundGrantsRunConfig{} + if config.TokenExchange != nil { + delegateClients, err := buildDelegateClientRunConfigs(config.TokenExchange.DelegateClients) + if err != nil { + return nil, err + } + policies := make([]authserver.TokenExchangeIssuerPolicyRunConfig, len(config.TokenExchange.IssuerPolicies)) + for i, policy := range config.TokenExchange.IssuerPolicies { + policies[i] = authserver.TokenExchangeIssuerPolicyRunConfig{ + IssuerRef: policy.IssuerRef, + ExpectedAudience: policy.ExpectedAudience, + ActorClaim: policy.ActorClaim, + AllowedActors: append([]string(nil), policy.AllowedActors...), + ActorMatcher: policy.ActorMatcher, + AllowedDelegateClients: append([]string(nil), policy.AllowedDelegateClients...), + AllowMayAct: policy.AllowMayAct, + } + } + grants.TokenExchange = &authserver.TokenExchangeInboundGrantRunConfig{ + DelegateClients: delegateClients, + IssuerPolicies: policies, + } + } + if config.JWTBearer != nil { + policies := make([]authserver.JWTBearerIssuerPolicyRunConfig, len(config.JWTBearer.IssuerPolicies)) + for i, policy := range config.JWTBearer.IssuerPolicies { + policies[i] = authserver.JWTBearerIssuerPolicyRunConfig{ + IssuerRef: policy.IssuerRef, + MaxAssertionAge: policy.MaxAssertionAge.Duration.String(), + SubjectBindings: buildJWTBearerSubjectBindings(policy.SubjectBindings), + AcceptedAudiences: append([]string(nil), policy.AcceptedAudiences...), + } + } + grants.JWTBearer = &authserver.JWTBearerInboundGrantRunConfig{IssuerPolicies: policies} + } + return grants, nil +} + +func buildJWTBearerSubjectBindings( + bindings []mcpv1beta1.JWTBearerSubjectBinding, +) []tokenexchange.JWTBearerSubjectBinding { + converted := make([]tokenexchange.JWTBearerSubjectBinding, len(bindings)) + for i, binding := range bindings { + converted[i] = tokenexchange.JWTBearerSubjectBinding{ + Subject: binding.Subject, AllowedResources: append([]string(nil), binding.AllowedResources...), + } + } + return converted +} + // BuildAuthServerRunConfig converts CRD EmbeddedAuthServerConfig to authserver.RunConfig. // The RunConfig is serializable and contains file paths for secrets (not the secrets themselves). // @@ -839,6 +900,10 @@ func BuildAuthServerRunConfig( } }() + inboundGrants, err := buildInboundGrantsRunConfig(authConfig.InboundGrants) + if err != nil { + return nil, err + } config = &authserver.RunConfig{ SchemaVersion: authserver.CurrentSchemaVersion, Issuer: authConfig.Issuer, @@ -846,6 +911,7 @@ func BuildAuthServerRunConfig( AllowedAudiences: allowedAudiences, ScopesSupported: scopesSupported, BaselineClientScopes: authConfig.BaselineClientScopes, + InboundGrants: inboundGrants, } if len(authConfig.DelegateClients) > 0 { @@ -860,36 +926,8 @@ func BuildAuthServerRunConfig( config.TrustedIssuers = buildTrustedIssuerRunConfigs(authConfig.TrustedIssuers) } - // Build signing key configuration - if len(authConfig.SigningKeySecretRefs) > 0 { - signingKeyConfig := &authserver.SigningKeyRunConfig{ - KeyDir: AuthServerKeysMountPath, - } - for idx := range authConfig.SigningKeySecretRefs { - fileName := fmt.Sprintf(AuthServerKeyFilePattern, idx) - if idx == 0 { - signingKeyConfig.SigningKeyFile = fileName - } else { - signingKeyConfig.FallbackKeyFiles = append(signingKeyConfig.FallbackKeyFiles, fileName) - } - } - config.SigningKeyConfig = signingKeyConfig - } - - // Build HMAC secret file paths - for idx := range authConfig.HMACSecretRefs { - hmacPath := fmt.Sprintf("%s/%s", AuthServerHMACMountPath, fmt.Sprintf(AuthServerHMACFilePattern, idx)) - config.HMACSecretFiles = append(config.HMACSecretFiles, hmacPath) - } - - // Set token lifespans from config (as strings, will be parsed at runtime) - if authConfig.TokenLifespans != nil { - config.TokenLifespans = &authserver.TokenLifespanRunConfig{ - AccessTokenLifespan: authConfig.TokenLifespans.AccessTokenLifespan, - RefreshTokenLifespan: authConfig.TokenLifespans.RefreshTokenLifespan, - AuthCodeLifespan: authConfig.TokenLifespans.AuthCodeLifespan, - } - } + // Wire signing-key file paths, HMAC secret file paths, and token lifespans. + buildAuthServerSecretsConfig(config, authConfig) // Build upstream provider configs using shared bindings bindings := buildUpstreamSecretBindings(authConfig.UpstreamProviders) @@ -941,9 +979,42 @@ func applySimpleAuthServerConfigFields(config *authserver.RunConfig, authConfig // Wire through the confidential-over-loopback-http opt-in (default off). config.InsecureAllowConfidentialOverLoopbackHTTP = authConfig.InsecureAllowConfidentialOverLoopbackHTTP +} + +// buildAuthServerSecretsConfig wires signing-key file paths, HMAC secret file +// paths, token lifespans, and CIMD settings from the CRD onto config. +func buildAuthServerSecretsConfig(config *authserver.RunConfig, authConfig *mcpv1beta1.EmbeddedAuthServerConfig) { + if len(authConfig.SigningKeySecretRefs) > 0 { + signingKeyConfig := &authserver.SigningKeyRunConfig{ + KeyDir: AuthServerKeysMountPath, + } + for idx := range authConfig.SigningKeySecretRefs { + fileName := fmt.Sprintf(AuthServerKeyFilePattern, idx) + if idx == 0 { + signingKeyConfig.SigningKeyFile = fileName + } else { + signingKeyConfig.FallbackKeyFiles = append(signingKeyConfig.FallbackKeyFiles, fileName) + } + } + config.SigningKeyConfig = signingKeyConfig + } + + for idx := range authConfig.HMACSecretRefs { + hmacPath := fmt.Sprintf("%s/%s", AuthServerHMACMountPath, fmt.Sprintf(AuthServerHMACFilePattern, idx)) + config.HMACSecretFiles = append(config.HMACSecretFiles, hmacPath) + } + + // Set token lifespans from config (as strings, will be parsed at runtime) + if authConfig.TokenLifespans != nil { + config.TokenLifespans = &authserver.TokenLifespanRunConfig{ + AccessTokenLifespan: authConfig.TokenLifespans.AccessTokenLifespan, + RefreshTokenLifespan: authConfig.TokenLifespans.RefreshTokenLifespan, + AuthCodeLifespan: authConfig.TokenLifespans.AuthCodeLifespan, + } + } - // Build CIMD configuration. CacheFallbackTTL is passed as-is (string); - // resolveCIMDConfig in the runner parses it to time.Duration at startup. + // CacheFallbackTTL is passed as-is (string); resolveCIMDConfig in the + // runner parses it to time.Duration at startup. if authConfig.CIMD != nil && authConfig.CIMD.Enabled { config.CIMD = &authserver.CIMDRunConfig{ Enabled: authConfig.CIMD.Enabled, @@ -961,7 +1032,7 @@ func applySimpleAuthServerConfigFields(config *authserver.RunConfig, authConfig // or allow_may_act combined with the delegate-client wildcard) as a // reconcile error rather than a pod crash loop. func validateDelegateClientsAndTrustedIssuers(config *authserver.RunConfig) error { - if len(config.DelegateClients) == 0 && len(config.TrustedIssuers) == 0 { + if len(config.DelegateClients) == 0 && len(config.TrustedIssuers) == 0 && config.InboundGrants == nil { return nil } @@ -974,6 +1045,7 @@ func validateDelegateClientsAndTrustedIssuers(config *authserver.RunConfig) erro InsecureAllowConfidentialOverLoopbackHTTP: config.InsecureAllowConfidentialOverLoopbackHTTP, DelegateClients: config.DelegateClients, TrustedIssuers: config.TrustedIssuers, + InboundGrants: config.InboundGrants, } if err := validationConfig.Validate(); err != nil { return fmt.Errorf("invalid embedded auth server delegate clients or trusted issuers: %w", err) diff --git a/cmd/thv-operator/pkg/controllerutil/authserver_inbound_grants_test.go b/cmd/thv-operator/pkg/controllerutil/authserver_inbound_grants_test.go new file mode 100644 index 0000000000..55ca475a25 --- /dev/null +++ b/cmd/thv-operator/pkg/controllerutil/authserver_inbound_grants_test.go @@ -0,0 +1,91 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package controllerutil + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" +) + +func TestBuildAuthServerRunConfigConvertsCanonicalInboundGrants(t *testing.T) { + t.Parallel() + + authConfig := &mcpv1beta1.EmbeddedAuthServerConfig{ + Issuer: "https://auth.example.com", + TrustedIssuers: []mcpv1beta1.TrustedIssuerConfig{{ + Name: "external", IssuerURL: "https://issuer.example.com", + }}, + InboundGrants: &mcpv1beta1.InboundGrantsConfig{ + TokenExchange: &mcpv1beta1.TokenExchangeInboundGrantConfig{ + DelegateClients: []mcpv1beta1.DelegateClientConfig{{ + ClientID: "delegate", ClientSecretRef: &mcpv1beta1.SecretKeyRef{Name: "delegate", Key: "secret"}, + Scopes: []string{"openid"}, Audiences: []string{"https://resource.example.com"}, + }}, + IssuerPolicies: []mcpv1beta1.TokenExchangeIssuerPolicyConfig{{ + IssuerRef: "external", ExpectedAudience: "https://subject-api.example.com", + ActorClaim: "azp", AllowedActors: []string{"external-client"}, + AllowedDelegateClients: []string{"delegate"}, + }}, + }, + JWTBearer: &mcpv1beta1.JWTBearerInboundGrantConfig{ + IssuerPolicies: []mcpv1beta1.JWTBearerIssuerPolicyConfig{{ + IssuerRef: "external", + JWTBearerGrantConfig: mcpv1beta1.JWTBearerGrantConfig{ + MaxAssertionAge: &metav1.Duration{Duration: 5 * time.Minute}, + SubjectBindings: []mcpv1beta1.JWTBearerSubjectBinding{{ + Subject: "workload", AllowedResources: []string{"https://resource.example.com"}, + }}, + }, + }}, + }, + }, + } + + config, err := BuildAuthServerRunConfig( + "default", "server", authConfig, []string{"https://resource.example.com"}, []string{"openid"}, "https://resource.example.com") + require.NoError(t, err) + require.NotNil(t, config.InboundGrants) + require.NotNil(t, config.InboundGrants.TokenExchange) + require.NotNil(t, config.InboundGrants.JWTBearer) + require.Len(t, config.TrustedIssuers, 1) + assert.Equal(t, "external", config.TrustedIssuers[0].Name) + require.Len(t, config.InboundGrants.TokenExchange.DelegateClients, 1) + assert.Equal(t, "TOOLHIVE_DELEGATE_CLIENT_SECRET_0", + config.InboundGrants.TokenExchange.DelegateClients[0].ClientSecretEnvVar) + require.Len(t, config.InboundGrants.TokenExchange.IssuerPolicies, 1) + assert.Equal(t, "external", config.InboundGrants.TokenExchange.IssuerPolicies[0].IssuerRef) + assert.Equal(t, []string{"external-client"}, config.InboundGrants.TokenExchange.IssuerPolicies[0].AllowedActors) + require.Len(t, config.InboundGrants.JWTBearer.IssuerPolicies, 1) + assert.Equal(t, "5m0s", config.InboundGrants.JWTBearer.IssuerPolicies[0].MaxAssertionAge) + assert.Equal(t, "workload", config.InboundGrants.JWTBearer.IssuerPolicies[0].SubjectBindings[0].Subject) +} + +func TestGenerateAuthServerEnvVarsUsesCanonicalDelegateClients(t *testing.T) { + t.Parallel() + + authConfig := &mcpv1beta1.EmbeddedAuthServerConfig{ + InboundGrants: &mcpv1beta1.InboundGrantsConfig{ + TokenExchange: &mcpv1beta1.TokenExchangeInboundGrantConfig{ + DelegateClients: []mcpv1beta1.DelegateClientConfig{{ + ClientID: "delegate", ClientSecretRef: &mcpv1beta1.SecretKeyRef{Name: "delegate", Key: "secret"}, + Scopes: []string{"openid"}, Audiences: []string{"https://resource.example.com"}, + }}, + }, + }, + } + + envVars := GenerateAuthServerEnvVars(authConfig) + require.Len(t, envVars, 1) + assert.Equal(t, "TOOLHIVE_DELEGATE_CLIENT_SECRET_0", envVars[0].Name) + require.NotNil(t, envVars[0].ValueFrom) + require.NotNil(t, envVars[0].ValueFrom.SecretKeyRef) + assert.Equal(t, "delegate", envVars[0].ValueFrom.SecretKeyRef.Name) + assert.Equal(t, "secret", envVars[0].ValueFrom.SecretKeyRef.Key) +} diff --git a/cmd/thv-operator/test-integration/mcp-external-auth/inbound_grants_cel_test.go b/cmd/thv-operator/test-integration/mcp-external-auth/inbound_grants_cel_test.go new file mode 100644 index 0000000000..43a3325a77 --- /dev/null +++ b/cmd/thv-operator/test-integration/mcp-external-auth/inbound_grants_cel_test.go @@ -0,0 +1,159 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package controllers + +import ( + "fmt" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" +) + +var _ = Describe("MCPExternalAuthConfig inbound grants CEL validation", func() { + const namespace = "default" + + BeforeEach(func() { + _ = k8sClient.Create(ctx, &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: namespace}}) + }) + + baseConfig := func(name string) *mcpv1beta1.MCPExternalAuthConfig { + return &mcpv1beta1.MCPExternalAuthConfig{ + ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: namespace}, + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeEmbeddedAuthServer, + EmbeddedAuthServer: &mcpv1beta1.EmbeddedAuthServerConfig{ + Issuer: "https://auth.example.com", + UpstreamProviders: []mcpv1beta1.UpstreamProviderConfig{{ + Name: "github", Type: mcpv1beta1.UpstreamProviderTypeOAuth2, + OAuth2Config: &mcpv1beta1.OAuth2UpstreamConfig{ + AuthorizationEndpoint: "https://github.com/login/oauth/authorize", + TokenEndpoint: "https://github.com/login/oauth/access_token", ClientID: "test-client-id", + }, + }}, + }, + }, + } + } + delegateClient := func() mcpv1beta1.DelegateClientConfig { + return mcpv1beta1.DelegateClientConfig{ + ClientID: "delegate", ClientSecretRef: &mcpv1beta1.SecretKeyRef{Name: "delegate-secret", Key: "secret"}, + Scopes: []string{"openid"}, Audiences: []string{"https://mcp.example.com"}, + } + } + legacyRFC8693Issuer := func() mcpv1beta1.TrustedIssuerConfig { + return mcpv1beta1.TrustedIssuerConfig{ + IssuerURL: "https://issuer.example.com", ExpectedAudience: "https://mcp.example.com", + AllowedActors: []string{"actor"}, AllowedDelegateClients: []string{"delegate"}, + } + } + jwtPolicy := func(ref string) mcpv1beta1.JWTBearerIssuerPolicyConfig { + return mcpv1beta1.JWTBearerIssuerPolicyConfig{ + IssuerRef: ref, + JWTBearerGrantConfig: mcpv1beta1.JWTBearerGrantConfig{ + MaxAssertionAge: &metav1.Duration{Duration: time.Minute}, + SubjectBindings: []mcpv1beta1.JWTBearerSubjectBinding{{ + Subject: "workload", AllowedResources: []string{"https://mcp.example.com"}, + }}, + }, + } + } + + type validationCase struct { + name string + mutate func(*mcpv1beta1.EmbeddedAuthServerConfig) + shouldAdmit bool + errMatch string + } + cases := []validationCase{ + {name: "released legacy delegate clients remain admitted", shouldAdmit: true, mutate: func(c *mcpv1beta1.EmbeddedAuthServerConfig) { + c.DelegateClients = []mcpv1beta1.DelegateClientConfig{delegateClient()} + }}, + {name: "released legacy RFC 8693 policy remains admitted", shouldAdmit: true, mutate: func(c *mcpv1beta1.EmbeddedAuthServerConfig) { + c.TrustedIssuers = []mcpv1beta1.TrustedIssuerConfig{legacyRFC8693Issuer()} + }}, + {name: "released legacy JWT bearer policy remains admitted", shouldAdmit: true, mutate: func(c *mcpv1beta1.EmbeddedAuthServerConfig) { + grant := jwtPolicy("").JWTBearerGrantConfig + c.TrustedIssuers = []mcpv1beta1.TrustedIssuerConfig{{IssuerURL: "https://issuer.example.com", JWTBearerGrant: &grant}} + }}, + {name: "canonical token exchange", shouldAdmit: true, mutate: func(c *mcpv1beta1.EmbeddedAuthServerConfig) { + c.TrustedIssuers = []mcpv1beta1.TrustedIssuerConfig{{Name: "issuer", IssuerURL: "https://issuer.example.com"}} + c.InboundGrants = &mcpv1beta1.InboundGrantsConfig{TokenExchange: &mcpv1beta1.TokenExchangeInboundGrantConfig{ + DelegateClients: []mcpv1beta1.DelegateClientConfig{delegateClient()}, + IssuerPolicies: []mcpv1beta1.TokenExchangeIssuerPolicyConfig{{ + IssuerRef: "issuer", ExpectedAudience: "https://mcp.example.com", + AllowedActors: []string{"actor"}, AllowedDelegateClients: []string{"delegate"}, + }}, + }} + }}, + {name: "canonical JWT bearer", shouldAdmit: true, mutate: func(c *mcpv1beta1.EmbeddedAuthServerConfig) { + c.TrustedIssuers = []mcpv1beta1.TrustedIssuerConfig{{Name: "issuer", IssuerURL: "https://issuer.example.com"}} + c.InboundGrants = &mcpv1beta1.InboundGrantsConfig{JWTBearer: &mcpv1beta1.JWTBearerInboundGrantConfig{ + IssuerPolicies: []mcpv1beta1.JWTBearerIssuerPolicyConfig{jwtPolicy("issuer")}, + }} + }}, + {name: "canonical token exchange conflicts with legacy delegate clients", errMatch: "canonical tokenExchange conflicts", mutate: func(c *mcpv1beta1.EmbeddedAuthServerConfig) { + c.DelegateClients = []mcpv1beta1.DelegateClientConfig{delegateClient()} + c.InboundGrants = &mcpv1beta1.InboundGrantsConfig{TokenExchange: &mcpv1beta1.TokenExchangeInboundGrantConfig{}} + }}, + {name: "canonical token exchange conflicts with legacy issuer policy", errMatch: "canonical tokenExchange conflicts", mutate: func(c *mcpv1beta1.EmbeddedAuthServerConfig) { + c.TrustedIssuers = []mcpv1beta1.TrustedIssuerConfig{legacyRFC8693Issuer()} + c.InboundGrants = &mcpv1beta1.InboundGrantsConfig{TokenExchange: &mcpv1beta1.TokenExchangeInboundGrantConfig{}} + }}, + {name: "canonical JWT bearer conflicts with legacy JWT bearer", errMatch: "canonical jwtBearer conflicts", mutate: func(c *mcpv1beta1.EmbeddedAuthServerConfig) { + grant := jwtPolicy("").JWTBearerGrantConfig + c.TrustedIssuers = []mcpv1beta1.TrustedIssuerConfig{{Name: "issuer", IssuerURL: "https://issuer.example.com", JWTBearerGrant: &grant}} + c.InboundGrants = &mcpv1beta1.InboundGrantsConfig{JWTBearer: &mcpv1beta1.JWTBearerInboundGrantConfig{}} + }}, + {name: "canonical token exchange coexists with legacy JWT bearer", shouldAdmit: true, mutate: func(c *mcpv1beta1.EmbeddedAuthServerConfig) { + grant := jwtPolicy("").JWTBearerGrantConfig + c.TrustedIssuers = []mcpv1beta1.TrustedIssuerConfig{{Name: "issuer", IssuerURL: "https://issuer.example.com", JWTBearerGrant: &grant}} + c.InboundGrants = &mcpv1beta1.InboundGrantsConfig{TokenExchange: &mcpv1beta1.TokenExchangeInboundGrantConfig{}} + }}, + {name: "canonical JWT bearer coexists with legacy token exchange", shouldAdmit: true, mutate: func(c *mcpv1beta1.EmbeddedAuthServerConfig) { + issuer := legacyRFC8693Issuer() + issuer.Name = "issuer" + c.TrustedIssuers = []mcpv1beta1.TrustedIssuerConfig{issuer} + c.InboundGrants = &mcpv1beta1.InboundGrantsConfig{JWTBearer: &mcpv1beta1.JWTBearerInboundGrantConfig{ + IssuerPolicies: []mcpv1beta1.JWTBearerIssuerPolicyConfig{jwtPolicy("issuer")}, + }} + }}, + {name: "unknown canonical issuer reference", errMatch: "must reference a named trusted issuer", mutate: func(c *mcpv1beta1.EmbeddedAuthServerConfig) { + c.InboundGrants = &mcpv1beta1.InboundGrantsConfig{JWTBearer: &mcpv1beta1.JWTBearerInboundGrantConfig{ + IssuerPolicies: []mcpv1beta1.JWTBearerIssuerPolicyConfig{jwtPolicy("missing")}, + }} + }}, + {name: "duplicate trusted issuer names", errMatch: "trustedIssuers must not contain duplicate names", mutate: func(c *mcpv1beta1.EmbeddedAuthServerConfig) { + c.TrustedIssuers = []mcpv1beta1.TrustedIssuerConfig{ + {Name: "issuer", IssuerURL: "https://one.example.com"}, {Name: "issuer", IssuerURL: "https://two.example.com"}, + } + }}, + {name: "duplicate issuer policy references", errMatch: "must not contain duplicate issuerRef", mutate: func(c *mcpv1beta1.EmbeddedAuthServerConfig) { + c.TrustedIssuers = []mcpv1beta1.TrustedIssuerConfig{{Name: "issuer", IssuerURL: "https://issuer.example.com"}} + c.InboundGrants = &mcpv1beta1.InboundGrantsConfig{JWTBearer: &mcpv1beta1.JWTBearerInboundGrantConfig{ + IssuerPolicies: []mcpv1beta1.JWTBearerIssuerPolicyConfig{jwtPolicy("issuer"), jwtPolicy("issuer")}, + }} + }}, + } + + for i, test := range cases { + test := test + It(test.name, func() { + config := baseConfig(fmt.Sprintf("inbound-grants-%d", i)) + test.mutate(config.Spec.EmbeddedAuthServer) + err := k8sClient.Create(ctx, config) + if test.shouldAdmit { + Expect(err).NotTo(HaveOccurred()) + DeferCleanup(func() { Expect(k8sClient.Delete(ctx, config)).To(Succeed()) }) + return + } + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring(test.errMatch)) + }) + } +}) diff --git a/cmd/thv-operator/test-integration/mcp-external-auth/upstream_provider_cel_test.go b/cmd/thv-operator/test-integration/mcp-external-auth/upstream_provider_cel_test.go index 5261a20a16..505457520a 100644 --- a/cmd/thv-operator/test-integration/mcp-external-auth/upstream_provider_cel_test.go +++ b/cmd/thv-operator/test-integration/mcp-external-auth/upstream_provider_cel_test.go @@ -128,8 +128,7 @@ var _ = Describe("MCPExternalAuthConfig upstream-provider CEL validation", Label } Expect(err).To(HaveOccurred(), "expected apiserver to reject config: %s", tc.name) - Expect(err.Error()).To(ContainSubstring( - "at least one upstream provider is required unless delegateClients or a trustedIssuer with jwtBearerGrant is configured")) + Expect(err.Error()).To(ContainSubstring("at least one upstream provider or inbound grant family is required")) }) } }) 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 6eeebb1005..8adc8f1e34 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 @@ -310,6 +310,7 @@ spec: This is independent of allowConfidentialClientRegistration: it neither enables nor requires unauthenticated confidential dynamic client registration. + This legacy field is deprecated; use inboundGrants.tokenExchange.delegateClients. items: description: |- DelegateClientConfig configures a pre-provisioned confidential OAuth client @@ -445,6 +446,215 @@ spec: type: object type: array x-kubernetes-list-type: atomic + inboundGrants: + description: InboundGrants configures canonical inbound OAuth + grant families. + properties: + jwtBearer: + description: JWTBearer configures RFC 7523 issuer policies. + properties: + issuerPolicies: + description: IssuerPolicies binds RFC 7523 policy to named + trusted issuers. + items: + description: JWTBearerIssuerPolicyConfig binds RFC 7523 + policy to a named trusted issuer. + properties: + acceptedAudiences: + description: |- + AcceptedAudiences identifies this authorization server's accepted + assertion audiences. When omitted, runtime validation defaults to the + token endpoint. + items: + maxLength: 2048 + minLength: 1 + pattern: ^https?://[^[:space:]]+$ + type: string + maxItems: 50 + type: array + x-kubernetes-list-type: atomic + issuerRef: + description: IssuerRef references trustedIssuers[].name. + maxLength: 253 + minLength: 1 + type: string + maxAssertionAge: + description: MaxAssertionAge caps the exp-iat interval + independently of exp. + type: string + subjectBindings: + description: |- + SubjectBindings maps an exact external subject to allowed RFC 8707 + resources. + items: + description: |- + JWTBearerSubjectBinding configures the exact subject and allowed resources + for one RFC 7523 JWT-bearer assertion identity. + properties: + allowedResources: + description: |- + AllowedResources is the exact set of RFC 8707 resources this subject may + request. + items: + maxLength: 2048 + minLength: 1 + pattern: ^https?://[^[:space:]]+$ + type: string + maxItems: 50 + minItems: 1 + type: array + x-kubernetes-list-type: atomic + subject: + description: Subject is an exact assertion + sub value. Wildcards are not supported. + maxLength: 256 + minLength: 1 + pattern: ^[^*]+$ + type: string + required: + - allowedResources + - subject + type: object + maxItems: 50 + minItems: 1 + type: array + x-kubernetes-list-type: atomic + required: + - issuerRef + - maxAssertionAge + - subjectBindings + type: object + x-kubernetes-validations: + - message: maxAssertionAge must be greater than zero + rule: duration(self.maxAssertionAge) > duration('0s') + - message: subjectBindings must not contain duplicate + subjects + rule: self.subjectBindings.all(binding, self.subjectBindings.filter(other, + other.subject == binding.subject).size() == 1) + maxItems: 20 + type: array + x-kubernetes-list-type: atomic + type: object + tokenExchange: + description: TokenExchange configures RFC 8693 clients and + issuer policies. + properties: + delegateClients: + description: DelegateClients configures pre-provisioned + confidential clients. + items: + description: |- + DelegateClientConfig configures a pre-provisioned confidential OAuth client + for RFC 8693 token exchange. Its secret is referenced from a Kubernetes + Secret and is never represented inline. + properties: + audiences: + description: Audiences is the narrowed set of RFC + 8707 resources this client may request. + items: + maxLength: 2048 + minLength: 1 + type: string + maxItems: 10 + minItems: 1 + type: array + x-kubernetes-list-type: atomic + clientId: + description: ClientID is the OAuth client_id presented + at the token endpoint. + maxLength: 256 + minLength: 1 + type: string + clientSecretRef: + description: ClientSecretRef references the Kubernetes + Secret key containing the client secret. + properties: + key: + description: Key is the key within the secret + type: string + name: + description: Name is the name of the secret + type: string + required: + - key + - name + type: object + scopes: + description: Scopes is the narrowed set of OAuth + scopes this client may request. + items: + maxLength: 256 + minLength: 1 + type: string + maxItems: 10 + minItems: 1 + type: array + x-kubernetes-list-type: atomic + required: + - audiences + - clientId + - clientSecretRef + - scopes + type: object + x-kubernetes-validations: + - message: clientSecretRef.name and clientSecretRef.key + are required and must be non-empty + rule: has(self.clientSecretRef) && size(self.clientSecretRef.name) + > 0 && size(self.clientSecretRef.key) > 0 + maxItems: 10 + type: array + x-kubernetes-list-type: atomic + issuerPolicies: + description: IssuerPolicies binds RFC 8693 policy to named + trusted issuers. + items: + description: TokenExchangeIssuerPolicyConfig binds RFC + 8693 policy to a named trusted issuer. + properties: + actorClaim: + description: ActorClaim names the claim containing + the external actor identity. + maxLength: 64 + type: string + actorMatcher: + maxLength: 4096 + type: string + allowMayAct: + type: boolean + allowedActors: + items: + type: string + maxItems: 50 + type: array + x-kubernetes-list-type: atomic + allowedDelegateClients: + items: + type: string + maxItems: 50 + minItems: 1 + type: array + x-kubernetes-list-type: atomic + expectedAudience: + description: ExpectedAudience is the required RFC + 8693 subject-token audience. + maxLength: 2048 + minLength: 1 + type: string + issuerRef: + description: IssuerRef references trustedIssuers[].name. + maxLength: 253 + minLength: 1 + type: string + required: + - allowedDelegateClients + - expectedAudience + - issuerRef + type: object + maxItems: 20 + type: array + x-kubernetes-list-type: atomic + type: object + type: object insecureAllowConfidentialOverLoopbackHTTP: default: false description: |- @@ -770,25 +980,9 @@ spec: items: description: |- TrustedIssuerConfig configures an external OIDC issuer whose tokens are - accepted as RFC 8693 subject tokens or RFC 7523 JWT-bearer assertions during - token exchange. It mirrors tokenexchange.TrustedIssuer - (pkg/authserver/server/tokenexchange), the runtime type the operator converts - this into directly — no secret is referenced by this type, so no SecretKeyRef - indirection is needed, unlike DelegateClientConfig. - - expectedAudience is exempted only for a grant-only issuer: jwtBearerGrant - present and none of actorClaim, actorMatcher, allowMayAct, or allowedActors - set. Any RFC 8693 delegation field (actorClaim, actorMatcher, allowMayAct, - allowedActors) still requires expectedAudience, even when combined with - jwtBearerGrant. - - The allowedDelegateClients rule below mirrors validateDelegationPolicy - (pkg/authserver/server/tokenexchange/multi_issuer_validator.go): it is - keyed on whether ANY delegation field is set (expectedAudience, - actorClaim, actorMatcher, allowMayAct), not merely on whether - jwtBearerGrant is absent — an issuer can combine jwtBearerGrant with - expectedAudience for RFC 8693 delegation on the same issuer, and that - combination still requires allowedDelegateClients at the Go level. + accepted as RFC 8693 subject tokens or RFC 7523 JWT-bearer assertions. Trust + fields remain top-level; canonical grant policy references this declaration by + Name. The embedded grant-policy fields are retained for released CRD compatibility. properties: actorClaim: description: |- @@ -797,6 +991,8 @@ spec: Defaults to "azp" when empty; use "appid" for Microsoft Entra v1, "cid" for Okta. The special value "client_id" reads the subject token's client_id claim instead. + This legacy field is deprecated; configure RFC 8693 policy under + inboundGrants.tokenExchange.issuerPolicies. maxLength: 64 type: string actorMatcher: @@ -809,6 +1005,8 @@ spec: not at reconcile time. A syntactically invalid expression fails reconciliation (surfaced via the AuthServerConfigValidated condition), not admission — there is no validating webhook for this field. + This legacy field is deprecated; configure RFC 8693 policy under + inboundGrants.tokenExchange.issuerPolicies. maxLength: 4096 type: string allowMayAct: @@ -820,6 +1018,8 @@ spec: Does not affect self-issued subject tokens. The wildcard is never permitted alongside specific allowedDelegateClients, regardless of this setting. + This legacy field is deprecated; configure RFC 8693 policy under + inboundGrants.tokenExchange.issuerPolicies. type: boolean allowPrivateIPs: description: |- @@ -838,6 +1038,8 @@ spec: either signal is sufficient. Empty denies every token unless actorMatcher is set, or allowMayAct is true and the token carries a permitted may_act claim. + This legacy field is deprecated; configure RFC 8693 policy under + inboundGrants.tokenExchange.issuerPolicies. items: maxLength: 256 minLength: 1 @@ -852,6 +1054,8 @@ spec: jwtBearerGrant is configured; set it to ["*"] to permit any confidential client holding the token-exchange grant, or list specific client IDs to bind delegation to them. + This legacy field is deprecated; configure RFC 8693 policy under + inboundGrants.tokenExchange.issuerPolicies. items: maxLength: 256 minLength: 1 @@ -905,6 +1109,8 @@ spec: ExpectedAudience is the expected "aud" claim value that must appear in an RFC 8693 subject token's audience list. It is not used by an RFC 7523 JWT-bearer assertion, whose audience is the token endpoint. + This legacy field is deprecated; configure RFC 8693 policy under + inboundGrants.tokenExchange.issuerPolicies. maxLength: 2048 minLength: 1 type: string @@ -931,6 +1137,8 @@ spec: description: |- JWTBearerGrant enables the plain RFC 7523 JWT-bearer grant for this issuer. It is independent of RFC 8693 delegation policy. + This legacy field is deprecated; configure RFC 7523 policy under + inboundGrants.jwtBearer.issuerPolicies. properties: acceptedAudiences: description: |- @@ -996,6 +1204,13 @@ spec: - message: subjectBindings must not contain duplicate subjects rule: self.subjectBindings.all(binding, self.subjectBindings.filter(other, other.subject == binding.subject).size() == 1) + name: + description: |- + Name optionally identifies this trust declaration for canonical issuerRef references. + Names must be unique within trustedIssuers when set. + maxLength: 253 + minLength: 1 + type: string required: - issuerUrl type: object @@ -1016,23 +1231,22 @@ spec: - message: allowPrivateIPs requires jwksUrl to be set explicitly rule: '!(has(self.allowPrivateIPs) && self.allowPrivateIPs) || (has(self.jwksUrl) && self.jwksUrl != "")' - - message: expectedAudience is required unless jwtBearerGrant - is configured without actorClaim, actorMatcher, allowMayAct, - or allowedActors - rule: (has(self.jwtBearerGrant) && !((has(self.actorClaim) - && size(self.actorClaim) > 0) || (has(self.actorMatcher) - && size(self.actorMatcher) > 0) || (has(self.allowMayAct) - && self.allowMayAct) || (has(self.allowedActors) && size(self.allowedActors) - > 0))) || (has(self.expectedAudience) && size(self.expectedAudience) - > 0) - - message: allowedDelegateClients is required when expectedAudience, - actorClaim, actorMatcher, or allowMayAct is set + - message: expectedAudience is required when legacy RFC 8693 + policy is configured + rule: '!((has(self.actorClaim) && size(self.actorClaim) > + 0) || (has(self.actorMatcher) && size(self.actorMatcher) + > 0) || (has(self.allowMayAct) && self.allowMayAct) || (has(self.allowedActors) + && size(self.allowedActors) > 0) || (has(self.allowedDelegateClients) + && size(self.allowedDelegateClients) > 0)) || (has(self.expectedAudience) + && size(self.expectedAudience) > 0)' + - message: allowedDelegateClients is required when legacy RFC + 8693 policy is configured rule: '!((has(self.expectedAudience) && size(self.expectedAudience) > 0) || (has(self.actorClaim) && size(self.actorClaim) > 0) || (has(self.actorMatcher) && size(self.actorMatcher) - > 0) || (has(self.allowMayAct) && self.allowMayAct)) || - (has(self.allowedDelegateClients) && size(self.allowedDelegateClients) - > 0)' + > 0) || (has(self.allowMayAct) && self.allowMayAct) || (has(self.allowedActors) + && size(self.allowedActors) > 0)) || (has(self.allowedDelegateClients) + && size(self.allowedDelegateClients) > 0)' maxItems: 20 type: array x-kubernetes-list-type: atomic @@ -1652,12 +1866,13 @@ spec: - issuer type: object x-kubernetes-validations: - - message: at least one upstream provider is required unless delegateClients - or a trustedIssuer with jwtBearerGrant is configured + - message: at least one upstream provider or inbound grant family + is required rule: (has(self.upstreamProviders) && size(self.upstreamProviders) > 0) || (has(self.delegateClients) && size(self.delegateClients) > 0) || (has(self.trustedIssuers) && self.trustedIssuers.exists(issuer, - has(issuer.jwtBearerGrant))) + has(issuer.jwtBearerGrant))) || (has(self.inboundGrants) && (has(self.inboundGrants.tokenExchange) + || has(self.inboundGrants.jwtBearer))) - message: allowConfidentialClientRegistration cannot be combined with insecureAllowHTTP; client secrets would be issued in cleartext over an unauthenticated endpoint @@ -1672,6 +1887,46 @@ spec: rule: '!has(self.delegateClients) || size(self.delegateClients) == 0 || !self.issuer.startsWith(''http://'') || (has(self.insecureAllowConfidentialOverLoopbackHTTP) && self.insecureAllowConfidentialOverLoopbackHTTP)' + - message: canonical tokenExchange conflicts with legacy delegateClients + or RFC 8693 trusted issuer policy + rule: '!has(self.inboundGrants) || !has(self.inboundGrants.tokenExchange) + || ((!has(self.delegateClients) || size(self.delegateClients) + == 0) && (!has(self.trustedIssuers) || !self.trustedIssuers.exists(issuer, + has(issuer.expectedAudience) || has(issuer.actorClaim) || has(issuer.allowedActors) + || has(issuer.actorMatcher) || has(issuer.allowedDelegateClients) + || (has(issuer.allowMayAct) && issuer.allowMayAct))))' + - message: canonical jwtBearer conflicts with legacy jwtBearerGrant + rule: '!has(self.inboundGrants) || !has(self.inboundGrants.jwtBearer) + || !has(self.trustedIssuers) || !self.trustedIssuers.exists(issuer, + has(issuer.jwtBearerGrant))' + - message: trustedIssuers must not contain duplicate names + rule: '!has(self.trustedIssuers) || self.trustedIssuers.all(issuer, + !has(issuer.name) || self.trustedIssuers.filter(other, has(other.name) + && other.name == issuer.name).size() == 1)' + - message: every tokenExchange issuerRef must reference a named trusted + issuer + rule: '!has(self.inboundGrants) || !has(self.inboundGrants.tokenExchange) + || !has(self.inboundGrants.tokenExchange.issuerPolicies) || self.inboundGrants.tokenExchange.issuerPolicies.all(policy, + has(self.trustedIssuers) && self.trustedIssuers.exists(issuer, + has(issuer.name) && issuer.name == policy.issuerRef))' + - message: tokenExchange issuerPolicies must not contain duplicate + issuerRef values + rule: '!has(self.inboundGrants) || !has(self.inboundGrants.tokenExchange) + || !has(self.inboundGrants.tokenExchange.issuerPolicies) || self.inboundGrants.tokenExchange.issuerPolicies.all(policy, + self.inboundGrants.tokenExchange.issuerPolicies.filter(other, + other.issuerRef == policy.issuerRef).size() == 1)' + - message: every jwtBearer issuerRef must reference a named trusted + issuer + rule: '!has(self.inboundGrants) || !has(self.inboundGrants.jwtBearer) + || !has(self.inboundGrants.jwtBearer.issuerPolicies) || self.inboundGrants.jwtBearer.issuerPolicies.all(policy, + has(self.trustedIssuers) && self.trustedIssuers.exists(issuer, + has(issuer.name) && issuer.name == policy.issuerRef))' + - message: jwtBearer issuerPolicies must not contain duplicate issuerRef + values + rule: '!has(self.inboundGrants) || !has(self.inboundGrants.jwtBearer) + || !has(self.inboundGrants.jwtBearer.issuerPolicies) || self.inboundGrants.jwtBearer.issuerPolicies.all(policy, + self.inboundGrants.jwtBearer.issuerPolicies.filter(other, other.issuerRef + == policy.issuerRef).size() == 1)' headerInjection: description: |- HeaderInjection configures custom HTTP header injection @@ -2432,6 +2687,7 @@ spec: This is independent of allowConfidentialClientRegistration: it neither enables nor requires unauthenticated confidential dynamic client registration. + This legacy field is deprecated; use inboundGrants.tokenExchange.delegateClients. items: description: |- DelegateClientConfig configures a pre-provisioned confidential OAuth client @@ -2567,6 +2823,215 @@ spec: type: object type: array x-kubernetes-list-type: atomic + inboundGrants: + description: InboundGrants configures canonical inbound OAuth + grant families. + properties: + jwtBearer: + description: JWTBearer configures RFC 7523 issuer policies. + properties: + issuerPolicies: + description: IssuerPolicies binds RFC 7523 policy to named + trusted issuers. + items: + description: JWTBearerIssuerPolicyConfig binds RFC 7523 + policy to a named trusted issuer. + properties: + acceptedAudiences: + description: |- + AcceptedAudiences identifies this authorization server's accepted + assertion audiences. When omitted, runtime validation defaults to the + token endpoint. + items: + maxLength: 2048 + minLength: 1 + pattern: ^https?://[^[:space:]]+$ + type: string + maxItems: 50 + type: array + x-kubernetes-list-type: atomic + issuerRef: + description: IssuerRef references trustedIssuers[].name. + maxLength: 253 + minLength: 1 + type: string + maxAssertionAge: + description: MaxAssertionAge caps the exp-iat interval + independently of exp. + type: string + subjectBindings: + description: |- + SubjectBindings maps an exact external subject to allowed RFC 8707 + resources. + items: + description: |- + JWTBearerSubjectBinding configures the exact subject and allowed resources + for one RFC 7523 JWT-bearer assertion identity. + properties: + allowedResources: + description: |- + AllowedResources is the exact set of RFC 8707 resources this subject may + request. + items: + maxLength: 2048 + minLength: 1 + pattern: ^https?://[^[:space:]]+$ + type: string + maxItems: 50 + minItems: 1 + type: array + x-kubernetes-list-type: atomic + subject: + description: Subject is an exact assertion + sub value. Wildcards are not supported. + maxLength: 256 + minLength: 1 + pattern: ^[^*]+$ + type: string + required: + - allowedResources + - subject + type: object + maxItems: 50 + minItems: 1 + type: array + x-kubernetes-list-type: atomic + required: + - issuerRef + - maxAssertionAge + - subjectBindings + type: object + x-kubernetes-validations: + - message: maxAssertionAge must be greater than zero + rule: duration(self.maxAssertionAge) > duration('0s') + - message: subjectBindings must not contain duplicate + subjects + rule: self.subjectBindings.all(binding, self.subjectBindings.filter(other, + other.subject == binding.subject).size() == 1) + maxItems: 20 + type: array + x-kubernetes-list-type: atomic + type: object + tokenExchange: + description: TokenExchange configures RFC 8693 clients and + issuer policies. + properties: + delegateClients: + description: DelegateClients configures pre-provisioned + confidential clients. + items: + description: |- + DelegateClientConfig configures a pre-provisioned confidential OAuth client + for RFC 8693 token exchange. Its secret is referenced from a Kubernetes + Secret and is never represented inline. + properties: + audiences: + description: Audiences is the narrowed set of RFC + 8707 resources this client may request. + items: + maxLength: 2048 + minLength: 1 + type: string + maxItems: 10 + minItems: 1 + type: array + x-kubernetes-list-type: atomic + clientId: + description: ClientID is the OAuth client_id presented + at the token endpoint. + maxLength: 256 + minLength: 1 + type: string + clientSecretRef: + description: ClientSecretRef references the Kubernetes + Secret key containing the client secret. + properties: + key: + description: Key is the key within the secret + type: string + name: + description: Name is the name of the secret + type: string + required: + - key + - name + type: object + scopes: + description: Scopes is the narrowed set of OAuth + scopes this client may request. + items: + maxLength: 256 + minLength: 1 + type: string + maxItems: 10 + minItems: 1 + type: array + x-kubernetes-list-type: atomic + required: + - audiences + - clientId + - clientSecretRef + - scopes + type: object + x-kubernetes-validations: + - message: clientSecretRef.name and clientSecretRef.key + are required and must be non-empty + rule: has(self.clientSecretRef) && size(self.clientSecretRef.name) + > 0 && size(self.clientSecretRef.key) > 0 + maxItems: 10 + type: array + x-kubernetes-list-type: atomic + issuerPolicies: + description: IssuerPolicies binds RFC 8693 policy to named + trusted issuers. + items: + description: TokenExchangeIssuerPolicyConfig binds RFC + 8693 policy to a named trusted issuer. + properties: + actorClaim: + description: ActorClaim names the claim containing + the external actor identity. + maxLength: 64 + type: string + actorMatcher: + maxLength: 4096 + type: string + allowMayAct: + type: boolean + allowedActors: + items: + type: string + maxItems: 50 + type: array + x-kubernetes-list-type: atomic + allowedDelegateClients: + items: + type: string + maxItems: 50 + minItems: 1 + type: array + x-kubernetes-list-type: atomic + expectedAudience: + description: ExpectedAudience is the required RFC + 8693 subject-token audience. + maxLength: 2048 + minLength: 1 + type: string + issuerRef: + description: IssuerRef references trustedIssuers[].name. + maxLength: 253 + minLength: 1 + type: string + required: + - allowedDelegateClients + - expectedAudience + - issuerRef + type: object + maxItems: 20 + type: array + x-kubernetes-list-type: atomic + type: object + type: object insecureAllowConfidentialOverLoopbackHTTP: default: false description: |- @@ -2892,25 +3357,9 @@ spec: items: description: |- TrustedIssuerConfig configures an external OIDC issuer whose tokens are - accepted as RFC 8693 subject tokens or RFC 7523 JWT-bearer assertions during - token exchange. It mirrors tokenexchange.TrustedIssuer - (pkg/authserver/server/tokenexchange), the runtime type the operator converts - this into directly — no secret is referenced by this type, so no SecretKeyRef - indirection is needed, unlike DelegateClientConfig. - - expectedAudience is exempted only for a grant-only issuer: jwtBearerGrant - present and none of actorClaim, actorMatcher, allowMayAct, or allowedActors - set. Any RFC 8693 delegation field (actorClaim, actorMatcher, allowMayAct, - allowedActors) still requires expectedAudience, even when combined with - jwtBearerGrant. - - The allowedDelegateClients rule below mirrors validateDelegationPolicy - (pkg/authserver/server/tokenexchange/multi_issuer_validator.go): it is - keyed on whether ANY delegation field is set (expectedAudience, - actorClaim, actorMatcher, allowMayAct), not merely on whether - jwtBearerGrant is absent — an issuer can combine jwtBearerGrant with - expectedAudience for RFC 8693 delegation on the same issuer, and that - combination still requires allowedDelegateClients at the Go level. + accepted as RFC 8693 subject tokens or RFC 7523 JWT-bearer assertions. Trust + fields remain top-level; canonical grant policy references this declaration by + Name. The embedded grant-policy fields are retained for released CRD compatibility. properties: actorClaim: description: |- @@ -2919,6 +3368,8 @@ spec: Defaults to "azp" when empty; use "appid" for Microsoft Entra v1, "cid" for Okta. The special value "client_id" reads the subject token's client_id claim instead. + This legacy field is deprecated; configure RFC 8693 policy under + inboundGrants.tokenExchange.issuerPolicies. maxLength: 64 type: string actorMatcher: @@ -2931,6 +3382,8 @@ spec: not at reconcile time. A syntactically invalid expression fails reconciliation (surfaced via the AuthServerConfigValidated condition), not admission — there is no validating webhook for this field. + This legacy field is deprecated; configure RFC 8693 policy under + inboundGrants.tokenExchange.issuerPolicies. maxLength: 4096 type: string allowMayAct: @@ -2942,6 +3395,8 @@ spec: Does not affect self-issued subject tokens. The wildcard is never permitted alongside specific allowedDelegateClients, regardless of this setting. + This legacy field is deprecated; configure RFC 8693 policy under + inboundGrants.tokenExchange.issuerPolicies. type: boolean allowPrivateIPs: description: |- @@ -2960,6 +3415,8 @@ spec: either signal is sufficient. Empty denies every token unless actorMatcher is set, or allowMayAct is true and the token carries a permitted may_act claim. + This legacy field is deprecated; configure RFC 8693 policy under + inboundGrants.tokenExchange.issuerPolicies. items: maxLength: 256 minLength: 1 @@ -2974,6 +3431,8 @@ spec: jwtBearerGrant is configured; set it to ["*"] to permit any confidential client holding the token-exchange grant, or list specific client IDs to bind delegation to them. + This legacy field is deprecated; configure RFC 8693 policy under + inboundGrants.tokenExchange.issuerPolicies. items: maxLength: 256 minLength: 1 @@ -3027,6 +3486,8 @@ spec: ExpectedAudience is the expected "aud" claim value that must appear in an RFC 8693 subject token's audience list. It is not used by an RFC 7523 JWT-bearer assertion, whose audience is the token endpoint. + This legacy field is deprecated; configure RFC 8693 policy under + inboundGrants.tokenExchange.issuerPolicies. maxLength: 2048 minLength: 1 type: string @@ -3053,6 +3514,8 @@ spec: description: |- JWTBearerGrant enables the plain RFC 7523 JWT-bearer grant for this issuer. It is independent of RFC 8693 delegation policy. + This legacy field is deprecated; configure RFC 7523 policy under + inboundGrants.jwtBearer.issuerPolicies. properties: acceptedAudiences: description: |- @@ -3118,6 +3581,13 @@ spec: - message: subjectBindings must not contain duplicate subjects rule: self.subjectBindings.all(binding, self.subjectBindings.filter(other, other.subject == binding.subject).size() == 1) + name: + description: |- + Name optionally identifies this trust declaration for canonical issuerRef references. + Names must be unique within trustedIssuers when set. + maxLength: 253 + minLength: 1 + type: string required: - issuerUrl type: object @@ -3138,23 +3608,22 @@ spec: - message: allowPrivateIPs requires jwksUrl to be set explicitly rule: '!(has(self.allowPrivateIPs) && self.allowPrivateIPs) || (has(self.jwksUrl) && self.jwksUrl != "")' - - message: expectedAudience is required unless jwtBearerGrant - is configured without actorClaim, actorMatcher, allowMayAct, - or allowedActors - rule: (has(self.jwtBearerGrant) && !((has(self.actorClaim) - && size(self.actorClaim) > 0) || (has(self.actorMatcher) - && size(self.actorMatcher) > 0) || (has(self.allowMayAct) - && self.allowMayAct) || (has(self.allowedActors) && size(self.allowedActors) - > 0))) || (has(self.expectedAudience) && size(self.expectedAudience) - > 0) - - message: allowedDelegateClients is required when expectedAudience, - actorClaim, actorMatcher, or allowMayAct is set + - message: expectedAudience is required when legacy RFC 8693 + policy is configured + rule: '!((has(self.actorClaim) && size(self.actorClaim) > + 0) || (has(self.actorMatcher) && size(self.actorMatcher) + > 0) || (has(self.allowMayAct) && self.allowMayAct) || (has(self.allowedActors) + && size(self.allowedActors) > 0) || (has(self.allowedDelegateClients) + && size(self.allowedDelegateClients) > 0)) || (has(self.expectedAudience) + && size(self.expectedAudience) > 0)' + - message: allowedDelegateClients is required when legacy RFC + 8693 policy is configured rule: '!((has(self.expectedAudience) && size(self.expectedAudience) > 0) || (has(self.actorClaim) && size(self.actorClaim) > 0) || (has(self.actorMatcher) && size(self.actorMatcher) - > 0) || (has(self.allowMayAct) && self.allowMayAct)) || - (has(self.allowedDelegateClients) && size(self.allowedDelegateClients) - > 0)' + > 0) || (has(self.allowMayAct) && self.allowMayAct) || (has(self.allowedActors) + && size(self.allowedActors) > 0)) || (has(self.allowedDelegateClients) + && size(self.allowedDelegateClients) > 0)' maxItems: 20 type: array x-kubernetes-list-type: atomic @@ -3774,12 +4243,13 @@ spec: - issuer type: object x-kubernetes-validations: - - message: at least one upstream provider is required unless delegateClients - or a trustedIssuer with jwtBearerGrant is configured + - message: at least one upstream provider or inbound grant family + is required rule: (has(self.upstreamProviders) && size(self.upstreamProviders) > 0) || (has(self.delegateClients) && size(self.delegateClients) > 0) || (has(self.trustedIssuers) && self.trustedIssuers.exists(issuer, - has(issuer.jwtBearerGrant))) + has(issuer.jwtBearerGrant))) || (has(self.inboundGrants) && (has(self.inboundGrants.tokenExchange) + || has(self.inboundGrants.jwtBearer))) - message: allowConfidentialClientRegistration cannot be combined with insecureAllowHTTP; client secrets would be issued in cleartext over an unauthenticated endpoint @@ -3794,6 +4264,46 @@ spec: rule: '!has(self.delegateClients) || size(self.delegateClients) == 0 || !self.issuer.startsWith(''http://'') || (has(self.insecureAllowConfidentialOverLoopbackHTTP) && self.insecureAllowConfidentialOverLoopbackHTTP)' + - message: canonical tokenExchange conflicts with legacy delegateClients + or RFC 8693 trusted issuer policy + rule: '!has(self.inboundGrants) || !has(self.inboundGrants.tokenExchange) + || ((!has(self.delegateClients) || size(self.delegateClients) + == 0) && (!has(self.trustedIssuers) || !self.trustedIssuers.exists(issuer, + has(issuer.expectedAudience) || has(issuer.actorClaim) || has(issuer.allowedActors) + || has(issuer.actorMatcher) || has(issuer.allowedDelegateClients) + || (has(issuer.allowMayAct) && issuer.allowMayAct))))' + - message: canonical jwtBearer conflicts with legacy jwtBearerGrant + rule: '!has(self.inboundGrants) || !has(self.inboundGrants.jwtBearer) + || !has(self.trustedIssuers) || !self.trustedIssuers.exists(issuer, + has(issuer.jwtBearerGrant))' + - message: trustedIssuers must not contain duplicate names + rule: '!has(self.trustedIssuers) || self.trustedIssuers.all(issuer, + !has(issuer.name) || self.trustedIssuers.filter(other, has(other.name) + && other.name == issuer.name).size() == 1)' + - message: every tokenExchange issuerRef must reference a named trusted + issuer + rule: '!has(self.inboundGrants) || !has(self.inboundGrants.tokenExchange) + || !has(self.inboundGrants.tokenExchange.issuerPolicies) || self.inboundGrants.tokenExchange.issuerPolicies.all(policy, + has(self.trustedIssuers) && self.trustedIssuers.exists(issuer, + has(issuer.name) && issuer.name == policy.issuerRef))' + - message: tokenExchange issuerPolicies must not contain duplicate + issuerRef values + rule: '!has(self.inboundGrants) || !has(self.inboundGrants.tokenExchange) + || !has(self.inboundGrants.tokenExchange.issuerPolicies) || self.inboundGrants.tokenExchange.issuerPolicies.all(policy, + self.inboundGrants.tokenExchange.issuerPolicies.filter(other, + other.issuerRef == policy.issuerRef).size() == 1)' + - message: every jwtBearer issuerRef must reference a named trusted + issuer + rule: '!has(self.inboundGrants) || !has(self.inboundGrants.jwtBearer) + || !has(self.inboundGrants.jwtBearer.issuerPolicies) || self.inboundGrants.jwtBearer.issuerPolicies.all(policy, + has(self.trustedIssuers) && self.trustedIssuers.exists(issuer, + has(issuer.name) && issuer.name == policy.issuerRef))' + - message: jwtBearer issuerPolicies must not contain duplicate issuerRef + values + rule: '!has(self.inboundGrants) || !has(self.inboundGrants.jwtBearer) + || !has(self.inboundGrants.jwtBearer.issuerPolicies) || self.inboundGrants.jwtBearer.issuerPolicies.all(policy, + self.inboundGrants.jwtBearer.issuerPolicies.filter(other, other.issuerRef + == policy.issuerRef).size() == 1)' headerInjection: description: |- HeaderInjection configures custom HTTP header injection 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 f4520c6555..9369c5b102 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 @@ -186,6 +186,7 @@ spec: This is independent of allowConfidentialClientRegistration: it neither enables nor requires unauthenticated confidential dynamic client registration. + This legacy field is deprecated; use inboundGrants.tokenExchange.delegateClients. items: description: |- DelegateClientConfig configures a pre-provisioned confidential OAuth client @@ -321,6 +322,215 @@ spec: type: object type: array x-kubernetes-list-type: atomic + inboundGrants: + description: InboundGrants configures canonical inbound OAuth + grant families. + properties: + jwtBearer: + description: JWTBearer configures RFC 7523 issuer policies. + properties: + issuerPolicies: + description: IssuerPolicies binds RFC 7523 policy to named + trusted issuers. + items: + description: JWTBearerIssuerPolicyConfig binds RFC 7523 + policy to a named trusted issuer. + properties: + acceptedAudiences: + description: |- + AcceptedAudiences identifies this authorization server's accepted + assertion audiences. When omitted, runtime validation defaults to the + token endpoint. + items: + maxLength: 2048 + minLength: 1 + pattern: ^https?://[^[:space:]]+$ + type: string + maxItems: 50 + type: array + x-kubernetes-list-type: atomic + issuerRef: + description: IssuerRef references trustedIssuers[].name. + maxLength: 253 + minLength: 1 + type: string + maxAssertionAge: + description: MaxAssertionAge caps the exp-iat interval + independently of exp. + type: string + subjectBindings: + description: |- + SubjectBindings maps an exact external subject to allowed RFC 8707 + resources. + items: + description: |- + JWTBearerSubjectBinding configures the exact subject and allowed resources + for one RFC 7523 JWT-bearer assertion identity. + properties: + allowedResources: + description: |- + AllowedResources is the exact set of RFC 8707 resources this subject may + request. + items: + maxLength: 2048 + minLength: 1 + pattern: ^https?://[^[:space:]]+$ + type: string + maxItems: 50 + minItems: 1 + type: array + x-kubernetes-list-type: atomic + subject: + description: Subject is an exact assertion + sub value. Wildcards are not supported. + maxLength: 256 + minLength: 1 + pattern: ^[^*]+$ + type: string + required: + - allowedResources + - subject + type: object + maxItems: 50 + minItems: 1 + type: array + x-kubernetes-list-type: atomic + required: + - issuerRef + - maxAssertionAge + - subjectBindings + type: object + x-kubernetes-validations: + - message: maxAssertionAge must be greater than zero + rule: duration(self.maxAssertionAge) > duration('0s') + - message: subjectBindings must not contain duplicate + subjects + rule: self.subjectBindings.all(binding, self.subjectBindings.filter(other, + other.subject == binding.subject).size() == 1) + maxItems: 20 + type: array + x-kubernetes-list-type: atomic + type: object + tokenExchange: + description: TokenExchange configures RFC 8693 clients and + issuer policies. + properties: + delegateClients: + description: DelegateClients configures pre-provisioned + confidential clients. + items: + description: |- + DelegateClientConfig configures a pre-provisioned confidential OAuth client + for RFC 8693 token exchange. Its secret is referenced from a Kubernetes + Secret and is never represented inline. + properties: + audiences: + description: Audiences is the narrowed set of RFC + 8707 resources this client may request. + items: + maxLength: 2048 + minLength: 1 + type: string + maxItems: 10 + minItems: 1 + type: array + x-kubernetes-list-type: atomic + clientId: + description: ClientID is the OAuth client_id presented + at the token endpoint. + maxLength: 256 + minLength: 1 + type: string + clientSecretRef: + description: ClientSecretRef references the Kubernetes + Secret key containing the client secret. + properties: + key: + description: Key is the key within the secret + type: string + name: + description: Name is the name of the secret + type: string + required: + - key + - name + type: object + scopes: + description: Scopes is the narrowed set of OAuth + scopes this client may request. + items: + maxLength: 256 + minLength: 1 + type: string + maxItems: 10 + minItems: 1 + type: array + x-kubernetes-list-type: atomic + required: + - audiences + - clientId + - clientSecretRef + - scopes + type: object + x-kubernetes-validations: + - message: clientSecretRef.name and clientSecretRef.key + are required and must be non-empty + rule: has(self.clientSecretRef) && size(self.clientSecretRef.name) + > 0 && size(self.clientSecretRef.key) > 0 + maxItems: 10 + type: array + x-kubernetes-list-type: atomic + issuerPolicies: + description: IssuerPolicies binds RFC 8693 policy to named + trusted issuers. + items: + description: TokenExchangeIssuerPolicyConfig binds RFC + 8693 policy to a named trusted issuer. + properties: + actorClaim: + description: ActorClaim names the claim containing + the external actor identity. + maxLength: 64 + type: string + actorMatcher: + maxLength: 4096 + type: string + allowMayAct: + type: boolean + allowedActors: + items: + type: string + maxItems: 50 + type: array + x-kubernetes-list-type: atomic + allowedDelegateClients: + items: + type: string + maxItems: 50 + minItems: 1 + type: array + x-kubernetes-list-type: atomic + expectedAudience: + description: ExpectedAudience is the required RFC + 8693 subject-token audience. + maxLength: 2048 + minLength: 1 + type: string + issuerRef: + description: IssuerRef references trustedIssuers[].name. + maxLength: 253 + minLength: 1 + type: string + required: + - allowedDelegateClients + - expectedAudience + - issuerRef + type: object + maxItems: 20 + type: array + x-kubernetes-list-type: atomic + type: object + type: object insecureAllowConfidentialOverLoopbackHTTP: default: false description: |- @@ -646,25 +856,9 @@ spec: items: description: |- TrustedIssuerConfig configures an external OIDC issuer whose tokens are - accepted as RFC 8693 subject tokens or RFC 7523 JWT-bearer assertions during - token exchange. It mirrors tokenexchange.TrustedIssuer - (pkg/authserver/server/tokenexchange), the runtime type the operator converts - this into directly — no secret is referenced by this type, so no SecretKeyRef - indirection is needed, unlike DelegateClientConfig. - - expectedAudience is exempted only for a grant-only issuer: jwtBearerGrant - present and none of actorClaim, actorMatcher, allowMayAct, or allowedActors - set. Any RFC 8693 delegation field (actorClaim, actorMatcher, allowMayAct, - allowedActors) still requires expectedAudience, even when combined with - jwtBearerGrant. - - The allowedDelegateClients rule below mirrors validateDelegationPolicy - (pkg/authserver/server/tokenexchange/multi_issuer_validator.go): it is - keyed on whether ANY delegation field is set (expectedAudience, - actorClaim, actorMatcher, allowMayAct), not merely on whether - jwtBearerGrant is absent — an issuer can combine jwtBearerGrant with - expectedAudience for RFC 8693 delegation on the same issuer, and that - combination still requires allowedDelegateClients at the Go level. + accepted as RFC 8693 subject tokens or RFC 7523 JWT-bearer assertions. Trust + fields remain top-level; canonical grant policy references this declaration by + Name. The embedded grant-policy fields are retained for released CRD compatibility. properties: actorClaim: description: |- @@ -673,6 +867,8 @@ spec: Defaults to "azp" when empty; use "appid" for Microsoft Entra v1, "cid" for Okta. The special value "client_id" reads the subject token's client_id claim instead. + This legacy field is deprecated; configure RFC 8693 policy under + inboundGrants.tokenExchange.issuerPolicies. maxLength: 64 type: string actorMatcher: @@ -685,6 +881,8 @@ spec: not at reconcile time. A syntactically invalid expression fails reconciliation (surfaced via the AuthServerConfigValidated condition), not admission — there is no validating webhook for this field. + This legacy field is deprecated; configure RFC 8693 policy under + inboundGrants.tokenExchange.issuerPolicies. maxLength: 4096 type: string allowMayAct: @@ -696,6 +894,8 @@ spec: Does not affect self-issued subject tokens. The wildcard is never permitted alongside specific allowedDelegateClients, regardless of this setting. + This legacy field is deprecated; configure RFC 8693 policy under + inboundGrants.tokenExchange.issuerPolicies. type: boolean allowPrivateIPs: description: |- @@ -714,6 +914,8 @@ spec: either signal is sufficient. Empty denies every token unless actorMatcher is set, or allowMayAct is true and the token carries a permitted may_act claim. + This legacy field is deprecated; configure RFC 8693 policy under + inboundGrants.tokenExchange.issuerPolicies. items: maxLength: 256 minLength: 1 @@ -728,6 +930,8 @@ spec: jwtBearerGrant is configured; set it to ["*"] to permit any confidential client holding the token-exchange grant, or list specific client IDs to bind delegation to them. + This legacy field is deprecated; configure RFC 8693 policy under + inboundGrants.tokenExchange.issuerPolicies. items: maxLength: 256 minLength: 1 @@ -781,6 +985,8 @@ spec: ExpectedAudience is the expected "aud" claim value that must appear in an RFC 8693 subject token's audience list. It is not used by an RFC 7523 JWT-bearer assertion, whose audience is the token endpoint. + This legacy field is deprecated; configure RFC 8693 policy under + inboundGrants.tokenExchange.issuerPolicies. maxLength: 2048 minLength: 1 type: string @@ -807,6 +1013,8 @@ spec: description: |- JWTBearerGrant enables the plain RFC 7523 JWT-bearer grant for this issuer. It is independent of RFC 8693 delegation policy. + This legacy field is deprecated; configure RFC 7523 policy under + inboundGrants.jwtBearer.issuerPolicies. properties: acceptedAudiences: description: |- @@ -872,6 +1080,13 @@ spec: - message: subjectBindings must not contain duplicate subjects rule: self.subjectBindings.all(binding, self.subjectBindings.filter(other, other.subject == binding.subject).size() == 1) + name: + description: |- + Name optionally identifies this trust declaration for canonical issuerRef references. + Names must be unique within trustedIssuers when set. + maxLength: 253 + minLength: 1 + type: string required: - issuerUrl type: object @@ -892,23 +1107,22 @@ spec: - message: allowPrivateIPs requires jwksUrl to be set explicitly rule: '!(has(self.allowPrivateIPs) && self.allowPrivateIPs) || (has(self.jwksUrl) && self.jwksUrl != "")' - - message: expectedAudience is required unless jwtBearerGrant - is configured without actorClaim, actorMatcher, allowMayAct, - or allowedActors - rule: (has(self.jwtBearerGrant) && !((has(self.actorClaim) - && size(self.actorClaim) > 0) || (has(self.actorMatcher) - && size(self.actorMatcher) > 0) || (has(self.allowMayAct) - && self.allowMayAct) || (has(self.allowedActors) && size(self.allowedActors) - > 0))) || (has(self.expectedAudience) && size(self.expectedAudience) - > 0) - - message: allowedDelegateClients is required when expectedAudience, - actorClaim, actorMatcher, or allowMayAct is set + - message: expectedAudience is required when legacy RFC 8693 + policy is configured + rule: '!((has(self.actorClaim) && size(self.actorClaim) > + 0) || (has(self.actorMatcher) && size(self.actorMatcher) + > 0) || (has(self.allowMayAct) && self.allowMayAct) || (has(self.allowedActors) + && size(self.allowedActors) > 0) || (has(self.allowedDelegateClients) + && size(self.allowedDelegateClients) > 0)) || (has(self.expectedAudience) + && size(self.expectedAudience) > 0)' + - message: allowedDelegateClients is required when legacy RFC + 8693 policy is configured rule: '!((has(self.expectedAudience) && size(self.expectedAudience) > 0) || (has(self.actorClaim) && size(self.actorClaim) > 0) || (has(self.actorMatcher) && size(self.actorMatcher) - > 0) || (has(self.allowMayAct) && self.allowMayAct)) || - (has(self.allowedDelegateClients) && size(self.allowedDelegateClients) - > 0)' + > 0) || (has(self.allowMayAct) && self.allowMayAct) || (has(self.allowedActors) + && size(self.allowedActors) > 0)) || (has(self.allowedDelegateClients) + && size(self.allowedDelegateClients) > 0)' maxItems: 20 type: array x-kubernetes-list-type: atomic @@ -1528,12 +1742,13 @@ spec: - issuer type: object x-kubernetes-validations: - - message: at least one upstream provider is required unless delegateClients - or a trustedIssuer with jwtBearerGrant is configured + - message: at least one upstream provider or inbound grant family + is required rule: (has(self.upstreamProviders) && size(self.upstreamProviders) > 0) || (has(self.delegateClients) && size(self.delegateClients) > 0) || (has(self.trustedIssuers) && self.trustedIssuers.exists(issuer, - has(issuer.jwtBearerGrant))) + has(issuer.jwtBearerGrant))) || (has(self.inboundGrants) && (has(self.inboundGrants.tokenExchange) + || has(self.inboundGrants.jwtBearer))) - message: allowConfidentialClientRegistration cannot be combined with insecureAllowHTTP; client secrets would be issued in cleartext over an unauthenticated endpoint @@ -1548,6 +1763,46 @@ spec: rule: '!has(self.delegateClients) || size(self.delegateClients) == 0 || !self.issuer.startsWith(''http://'') || (has(self.insecureAllowConfidentialOverLoopbackHTTP) && self.insecureAllowConfidentialOverLoopbackHTTP)' + - message: canonical tokenExchange conflicts with legacy delegateClients + or RFC 8693 trusted issuer policy + rule: '!has(self.inboundGrants) || !has(self.inboundGrants.tokenExchange) + || ((!has(self.delegateClients) || size(self.delegateClients) + == 0) && (!has(self.trustedIssuers) || !self.trustedIssuers.exists(issuer, + has(issuer.expectedAudience) || has(issuer.actorClaim) || has(issuer.allowedActors) + || has(issuer.actorMatcher) || has(issuer.allowedDelegateClients) + || (has(issuer.allowMayAct) && issuer.allowMayAct))))' + - message: canonical jwtBearer conflicts with legacy jwtBearerGrant + rule: '!has(self.inboundGrants) || !has(self.inboundGrants.jwtBearer) + || !has(self.trustedIssuers) || !self.trustedIssuers.exists(issuer, + has(issuer.jwtBearerGrant))' + - message: trustedIssuers must not contain duplicate names + rule: '!has(self.trustedIssuers) || self.trustedIssuers.all(issuer, + !has(issuer.name) || self.trustedIssuers.filter(other, has(other.name) + && other.name == issuer.name).size() == 1)' + - message: every tokenExchange issuerRef must reference a named trusted + issuer + rule: '!has(self.inboundGrants) || !has(self.inboundGrants.tokenExchange) + || !has(self.inboundGrants.tokenExchange.issuerPolicies) || self.inboundGrants.tokenExchange.issuerPolicies.all(policy, + has(self.trustedIssuers) && self.trustedIssuers.exists(issuer, + has(issuer.name) && issuer.name == policy.issuerRef))' + - message: tokenExchange issuerPolicies must not contain duplicate + issuerRef values + rule: '!has(self.inboundGrants) || !has(self.inboundGrants.tokenExchange) + || !has(self.inboundGrants.tokenExchange.issuerPolicies) || self.inboundGrants.tokenExchange.issuerPolicies.all(policy, + self.inboundGrants.tokenExchange.issuerPolicies.filter(other, + other.issuerRef == policy.issuerRef).size() == 1)' + - message: every jwtBearer issuerRef must reference a named trusted + issuer + rule: '!has(self.inboundGrants) || !has(self.inboundGrants.jwtBearer) + || !has(self.inboundGrants.jwtBearer.issuerPolicies) || self.inboundGrants.jwtBearer.issuerPolicies.all(policy, + has(self.trustedIssuers) && self.trustedIssuers.exists(issuer, + has(issuer.name) && issuer.name == policy.issuerRef))' + - message: jwtBearer issuerPolicies must not contain duplicate issuerRef + values + rule: '!has(self.inboundGrants) || !has(self.inboundGrants.jwtBearer) + || !has(self.inboundGrants.jwtBearer.issuerPolicies) || self.inboundGrants.jwtBearer.issuerPolicies.all(policy, + self.inboundGrants.jwtBearer.issuerPolicies.filter(other, other.issuerRef + == policy.issuerRef).size() == 1)' config: description: |- Config is the Virtual MCP server configuration. @@ -4319,6 +4574,7 @@ spec: This is independent of allowConfidentialClientRegistration: it neither enables nor requires unauthenticated confidential dynamic client registration. + This legacy field is deprecated; use inboundGrants.tokenExchange.delegateClients. items: description: |- DelegateClientConfig configures a pre-provisioned confidential OAuth client @@ -4454,6 +4710,215 @@ spec: type: object type: array x-kubernetes-list-type: atomic + inboundGrants: + description: InboundGrants configures canonical inbound OAuth + grant families. + properties: + jwtBearer: + description: JWTBearer configures RFC 7523 issuer policies. + properties: + issuerPolicies: + description: IssuerPolicies binds RFC 7523 policy to named + trusted issuers. + items: + description: JWTBearerIssuerPolicyConfig binds RFC 7523 + policy to a named trusted issuer. + properties: + acceptedAudiences: + description: |- + AcceptedAudiences identifies this authorization server's accepted + assertion audiences. When omitted, runtime validation defaults to the + token endpoint. + items: + maxLength: 2048 + minLength: 1 + pattern: ^https?://[^[:space:]]+$ + type: string + maxItems: 50 + type: array + x-kubernetes-list-type: atomic + issuerRef: + description: IssuerRef references trustedIssuers[].name. + maxLength: 253 + minLength: 1 + type: string + maxAssertionAge: + description: MaxAssertionAge caps the exp-iat interval + independently of exp. + type: string + subjectBindings: + description: |- + SubjectBindings maps an exact external subject to allowed RFC 8707 + resources. + items: + description: |- + JWTBearerSubjectBinding configures the exact subject and allowed resources + for one RFC 7523 JWT-bearer assertion identity. + properties: + allowedResources: + description: |- + AllowedResources is the exact set of RFC 8707 resources this subject may + request. + items: + maxLength: 2048 + minLength: 1 + pattern: ^https?://[^[:space:]]+$ + type: string + maxItems: 50 + minItems: 1 + type: array + x-kubernetes-list-type: atomic + subject: + description: Subject is an exact assertion + sub value. Wildcards are not supported. + maxLength: 256 + minLength: 1 + pattern: ^[^*]+$ + type: string + required: + - allowedResources + - subject + type: object + maxItems: 50 + minItems: 1 + type: array + x-kubernetes-list-type: atomic + required: + - issuerRef + - maxAssertionAge + - subjectBindings + type: object + x-kubernetes-validations: + - message: maxAssertionAge must be greater than zero + rule: duration(self.maxAssertionAge) > duration('0s') + - message: subjectBindings must not contain duplicate + subjects + rule: self.subjectBindings.all(binding, self.subjectBindings.filter(other, + other.subject == binding.subject).size() == 1) + maxItems: 20 + type: array + x-kubernetes-list-type: atomic + type: object + tokenExchange: + description: TokenExchange configures RFC 8693 clients and + issuer policies. + properties: + delegateClients: + description: DelegateClients configures pre-provisioned + confidential clients. + items: + description: |- + DelegateClientConfig configures a pre-provisioned confidential OAuth client + for RFC 8693 token exchange. Its secret is referenced from a Kubernetes + Secret and is never represented inline. + properties: + audiences: + description: Audiences is the narrowed set of RFC + 8707 resources this client may request. + items: + maxLength: 2048 + minLength: 1 + type: string + maxItems: 10 + minItems: 1 + type: array + x-kubernetes-list-type: atomic + clientId: + description: ClientID is the OAuth client_id presented + at the token endpoint. + maxLength: 256 + minLength: 1 + type: string + clientSecretRef: + description: ClientSecretRef references the Kubernetes + Secret key containing the client secret. + properties: + key: + description: Key is the key within the secret + type: string + name: + description: Name is the name of the secret + type: string + required: + - key + - name + type: object + scopes: + description: Scopes is the narrowed set of OAuth + scopes this client may request. + items: + maxLength: 256 + minLength: 1 + type: string + maxItems: 10 + minItems: 1 + type: array + x-kubernetes-list-type: atomic + required: + - audiences + - clientId + - clientSecretRef + - scopes + type: object + x-kubernetes-validations: + - message: clientSecretRef.name and clientSecretRef.key + are required and must be non-empty + rule: has(self.clientSecretRef) && size(self.clientSecretRef.name) + > 0 && size(self.clientSecretRef.key) > 0 + maxItems: 10 + type: array + x-kubernetes-list-type: atomic + issuerPolicies: + description: IssuerPolicies binds RFC 8693 policy to named + trusted issuers. + items: + description: TokenExchangeIssuerPolicyConfig binds RFC + 8693 policy to a named trusted issuer. + properties: + actorClaim: + description: ActorClaim names the claim containing + the external actor identity. + maxLength: 64 + type: string + actorMatcher: + maxLength: 4096 + type: string + allowMayAct: + type: boolean + allowedActors: + items: + type: string + maxItems: 50 + type: array + x-kubernetes-list-type: atomic + allowedDelegateClients: + items: + type: string + maxItems: 50 + minItems: 1 + type: array + x-kubernetes-list-type: atomic + expectedAudience: + description: ExpectedAudience is the required RFC + 8693 subject-token audience. + maxLength: 2048 + minLength: 1 + type: string + issuerRef: + description: IssuerRef references trustedIssuers[].name. + maxLength: 253 + minLength: 1 + type: string + required: + - allowedDelegateClients + - expectedAudience + - issuerRef + type: object + maxItems: 20 + type: array + x-kubernetes-list-type: atomic + type: object + type: object insecureAllowConfidentialOverLoopbackHTTP: default: false description: |- @@ -4779,25 +5244,9 @@ spec: items: description: |- TrustedIssuerConfig configures an external OIDC issuer whose tokens are - accepted as RFC 8693 subject tokens or RFC 7523 JWT-bearer assertions during - token exchange. It mirrors tokenexchange.TrustedIssuer - (pkg/authserver/server/tokenexchange), the runtime type the operator converts - this into directly — no secret is referenced by this type, so no SecretKeyRef - indirection is needed, unlike DelegateClientConfig. - - expectedAudience is exempted only for a grant-only issuer: jwtBearerGrant - present and none of actorClaim, actorMatcher, allowMayAct, or allowedActors - set. Any RFC 8693 delegation field (actorClaim, actorMatcher, allowMayAct, - allowedActors) still requires expectedAudience, even when combined with - jwtBearerGrant. - - The allowedDelegateClients rule below mirrors validateDelegationPolicy - (pkg/authserver/server/tokenexchange/multi_issuer_validator.go): it is - keyed on whether ANY delegation field is set (expectedAudience, - actorClaim, actorMatcher, allowMayAct), not merely on whether - jwtBearerGrant is absent — an issuer can combine jwtBearerGrant with - expectedAudience for RFC 8693 delegation on the same issuer, and that - combination still requires allowedDelegateClients at the Go level. + accepted as RFC 8693 subject tokens or RFC 7523 JWT-bearer assertions. Trust + fields remain top-level; canonical grant policy references this declaration by + Name. The embedded grant-policy fields are retained for released CRD compatibility. properties: actorClaim: description: |- @@ -4806,6 +5255,8 @@ spec: Defaults to "azp" when empty; use "appid" for Microsoft Entra v1, "cid" for Okta. The special value "client_id" reads the subject token's client_id claim instead. + This legacy field is deprecated; configure RFC 8693 policy under + inboundGrants.tokenExchange.issuerPolicies. maxLength: 64 type: string actorMatcher: @@ -4818,6 +5269,8 @@ spec: not at reconcile time. A syntactically invalid expression fails reconciliation (surfaced via the AuthServerConfigValidated condition), not admission — there is no validating webhook for this field. + This legacy field is deprecated; configure RFC 8693 policy under + inboundGrants.tokenExchange.issuerPolicies. maxLength: 4096 type: string allowMayAct: @@ -4829,6 +5282,8 @@ spec: Does not affect self-issued subject tokens. The wildcard is never permitted alongside specific allowedDelegateClients, regardless of this setting. + This legacy field is deprecated; configure RFC 8693 policy under + inboundGrants.tokenExchange.issuerPolicies. type: boolean allowPrivateIPs: description: |- @@ -4847,6 +5302,8 @@ spec: either signal is sufficient. Empty denies every token unless actorMatcher is set, or allowMayAct is true and the token carries a permitted may_act claim. + This legacy field is deprecated; configure RFC 8693 policy under + inboundGrants.tokenExchange.issuerPolicies. items: maxLength: 256 minLength: 1 @@ -4861,6 +5318,8 @@ spec: jwtBearerGrant is configured; set it to ["*"] to permit any confidential client holding the token-exchange grant, or list specific client IDs to bind delegation to them. + This legacy field is deprecated; configure RFC 8693 policy under + inboundGrants.tokenExchange.issuerPolicies. items: maxLength: 256 minLength: 1 @@ -4914,6 +5373,8 @@ spec: ExpectedAudience is the expected "aud" claim value that must appear in an RFC 8693 subject token's audience list. It is not used by an RFC 7523 JWT-bearer assertion, whose audience is the token endpoint. + This legacy field is deprecated; configure RFC 8693 policy under + inboundGrants.tokenExchange.issuerPolicies. maxLength: 2048 minLength: 1 type: string @@ -4940,6 +5401,8 @@ spec: description: |- JWTBearerGrant enables the plain RFC 7523 JWT-bearer grant for this issuer. It is independent of RFC 8693 delegation policy. + This legacy field is deprecated; configure RFC 7523 policy under + inboundGrants.jwtBearer.issuerPolicies. properties: acceptedAudiences: description: |- @@ -5005,6 +5468,13 @@ spec: - message: subjectBindings must not contain duplicate subjects rule: self.subjectBindings.all(binding, self.subjectBindings.filter(other, other.subject == binding.subject).size() == 1) + name: + description: |- + Name optionally identifies this trust declaration for canonical issuerRef references. + Names must be unique within trustedIssuers when set. + maxLength: 253 + minLength: 1 + type: string required: - issuerUrl type: object @@ -5025,23 +5495,22 @@ spec: - message: allowPrivateIPs requires jwksUrl to be set explicitly rule: '!(has(self.allowPrivateIPs) && self.allowPrivateIPs) || (has(self.jwksUrl) && self.jwksUrl != "")' - - message: expectedAudience is required unless jwtBearerGrant - is configured without actorClaim, actorMatcher, allowMayAct, - or allowedActors - rule: (has(self.jwtBearerGrant) && !((has(self.actorClaim) - && size(self.actorClaim) > 0) || (has(self.actorMatcher) - && size(self.actorMatcher) > 0) || (has(self.allowMayAct) - && self.allowMayAct) || (has(self.allowedActors) && size(self.allowedActors) - > 0))) || (has(self.expectedAudience) && size(self.expectedAudience) - > 0) - - message: allowedDelegateClients is required when expectedAudience, - actorClaim, actorMatcher, or allowMayAct is set + - message: expectedAudience is required when legacy RFC 8693 + policy is configured + rule: '!((has(self.actorClaim) && size(self.actorClaim) > + 0) || (has(self.actorMatcher) && size(self.actorMatcher) + > 0) || (has(self.allowMayAct) && self.allowMayAct) || (has(self.allowedActors) + && size(self.allowedActors) > 0) || (has(self.allowedDelegateClients) + && size(self.allowedDelegateClients) > 0)) || (has(self.expectedAudience) + && size(self.expectedAudience) > 0)' + - message: allowedDelegateClients is required when legacy RFC + 8693 policy is configured rule: '!((has(self.expectedAudience) && size(self.expectedAudience) > 0) || (has(self.actorClaim) && size(self.actorClaim) > 0) || (has(self.actorMatcher) && size(self.actorMatcher) - > 0) || (has(self.allowMayAct) && self.allowMayAct)) || - (has(self.allowedDelegateClients) && size(self.allowedDelegateClients) - > 0)' + > 0) || (has(self.allowMayAct) && self.allowMayAct) || (has(self.allowedActors) + && size(self.allowedActors) > 0)) || (has(self.allowedDelegateClients) + && size(self.allowedDelegateClients) > 0)' maxItems: 20 type: array x-kubernetes-list-type: atomic @@ -5661,12 +6130,13 @@ spec: - issuer type: object x-kubernetes-validations: - - message: at least one upstream provider is required unless delegateClients - or a trustedIssuer with jwtBearerGrant is configured + - message: at least one upstream provider or inbound grant family + is required rule: (has(self.upstreamProviders) && size(self.upstreamProviders) > 0) || (has(self.delegateClients) && size(self.delegateClients) > 0) || (has(self.trustedIssuers) && self.trustedIssuers.exists(issuer, - has(issuer.jwtBearerGrant))) + has(issuer.jwtBearerGrant))) || (has(self.inboundGrants) && (has(self.inboundGrants.tokenExchange) + || has(self.inboundGrants.jwtBearer))) - message: allowConfidentialClientRegistration cannot be combined with insecureAllowHTTP; client secrets would be issued in cleartext over an unauthenticated endpoint @@ -5681,6 +6151,46 @@ spec: rule: '!has(self.delegateClients) || size(self.delegateClients) == 0 || !self.issuer.startsWith(''http://'') || (has(self.insecureAllowConfidentialOverLoopbackHTTP) && self.insecureAllowConfidentialOverLoopbackHTTP)' + - message: canonical tokenExchange conflicts with legacy delegateClients + or RFC 8693 trusted issuer policy + rule: '!has(self.inboundGrants) || !has(self.inboundGrants.tokenExchange) + || ((!has(self.delegateClients) || size(self.delegateClients) + == 0) && (!has(self.trustedIssuers) || !self.trustedIssuers.exists(issuer, + has(issuer.expectedAudience) || has(issuer.actorClaim) || has(issuer.allowedActors) + || has(issuer.actorMatcher) || has(issuer.allowedDelegateClients) + || (has(issuer.allowMayAct) && issuer.allowMayAct))))' + - message: canonical jwtBearer conflicts with legacy jwtBearerGrant + rule: '!has(self.inboundGrants) || !has(self.inboundGrants.jwtBearer) + || !has(self.trustedIssuers) || !self.trustedIssuers.exists(issuer, + has(issuer.jwtBearerGrant))' + - message: trustedIssuers must not contain duplicate names + rule: '!has(self.trustedIssuers) || self.trustedIssuers.all(issuer, + !has(issuer.name) || self.trustedIssuers.filter(other, has(other.name) + && other.name == issuer.name).size() == 1)' + - message: every tokenExchange issuerRef must reference a named trusted + issuer + rule: '!has(self.inboundGrants) || !has(self.inboundGrants.tokenExchange) + || !has(self.inboundGrants.tokenExchange.issuerPolicies) || self.inboundGrants.tokenExchange.issuerPolicies.all(policy, + has(self.trustedIssuers) && self.trustedIssuers.exists(issuer, + has(issuer.name) && issuer.name == policy.issuerRef))' + - message: tokenExchange issuerPolicies must not contain duplicate + issuerRef values + rule: '!has(self.inboundGrants) || !has(self.inboundGrants.tokenExchange) + || !has(self.inboundGrants.tokenExchange.issuerPolicies) || self.inboundGrants.tokenExchange.issuerPolicies.all(policy, + self.inboundGrants.tokenExchange.issuerPolicies.filter(other, + other.issuerRef == policy.issuerRef).size() == 1)' + - message: every jwtBearer issuerRef must reference a named trusted + issuer + rule: '!has(self.inboundGrants) || !has(self.inboundGrants.jwtBearer) + || !has(self.inboundGrants.jwtBearer.issuerPolicies) || self.inboundGrants.jwtBearer.issuerPolicies.all(policy, + has(self.trustedIssuers) && self.trustedIssuers.exists(issuer, + has(issuer.name) && issuer.name == policy.issuerRef))' + - message: jwtBearer issuerPolicies must not contain duplicate issuerRef + values + rule: '!has(self.inboundGrants) || !has(self.inboundGrants.jwtBearer) + || !has(self.inboundGrants.jwtBearer.issuerPolicies) || self.inboundGrants.jwtBearer.issuerPolicies.all(policy, + self.inboundGrants.jwtBearer.issuerPolicies.filter(other, other.issuerRef + == policy.issuerRef).size() == 1)' config: description: |- Config is the Virtual MCP server configuration. 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 bc1399b0be..b784c73136 100644 --- a/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpexternalauthconfigs.yaml +++ b/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpexternalauthconfigs.yaml @@ -313,6 +313,7 @@ spec: This is independent of allowConfidentialClientRegistration: it neither enables nor requires unauthenticated confidential dynamic client registration. + This legacy field is deprecated; use inboundGrants.tokenExchange.delegateClients. items: description: |- DelegateClientConfig configures a pre-provisioned confidential OAuth client @@ -448,6 +449,215 @@ spec: type: object type: array x-kubernetes-list-type: atomic + inboundGrants: + description: InboundGrants configures canonical inbound OAuth + grant families. + properties: + jwtBearer: + description: JWTBearer configures RFC 7523 issuer policies. + properties: + issuerPolicies: + description: IssuerPolicies binds RFC 7523 policy to named + trusted issuers. + items: + description: JWTBearerIssuerPolicyConfig binds RFC 7523 + policy to a named trusted issuer. + properties: + acceptedAudiences: + description: |- + AcceptedAudiences identifies this authorization server's accepted + assertion audiences. When omitted, runtime validation defaults to the + token endpoint. + items: + maxLength: 2048 + minLength: 1 + pattern: ^https?://[^[:space:]]+$ + type: string + maxItems: 50 + type: array + x-kubernetes-list-type: atomic + issuerRef: + description: IssuerRef references trustedIssuers[].name. + maxLength: 253 + minLength: 1 + type: string + maxAssertionAge: + description: MaxAssertionAge caps the exp-iat interval + independently of exp. + type: string + subjectBindings: + description: |- + SubjectBindings maps an exact external subject to allowed RFC 8707 + resources. + items: + description: |- + JWTBearerSubjectBinding configures the exact subject and allowed resources + for one RFC 7523 JWT-bearer assertion identity. + properties: + allowedResources: + description: |- + AllowedResources is the exact set of RFC 8707 resources this subject may + request. + items: + maxLength: 2048 + minLength: 1 + pattern: ^https?://[^[:space:]]+$ + type: string + maxItems: 50 + minItems: 1 + type: array + x-kubernetes-list-type: atomic + subject: + description: Subject is an exact assertion + sub value. Wildcards are not supported. + maxLength: 256 + minLength: 1 + pattern: ^[^*]+$ + type: string + required: + - allowedResources + - subject + type: object + maxItems: 50 + minItems: 1 + type: array + x-kubernetes-list-type: atomic + required: + - issuerRef + - maxAssertionAge + - subjectBindings + type: object + x-kubernetes-validations: + - message: maxAssertionAge must be greater than zero + rule: duration(self.maxAssertionAge) > duration('0s') + - message: subjectBindings must not contain duplicate + subjects + rule: self.subjectBindings.all(binding, self.subjectBindings.filter(other, + other.subject == binding.subject).size() == 1) + maxItems: 20 + type: array + x-kubernetes-list-type: atomic + type: object + tokenExchange: + description: TokenExchange configures RFC 8693 clients and + issuer policies. + properties: + delegateClients: + description: DelegateClients configures pre-provisioned + confidential clients. + items: + description: |- + DelegateClientConfig configures a pre-provisioned confidential OAuth client + for RFC 8693 token exchange. Its secret is referenced from a Kubernetes + Secret and is never represented inline. + properties: + audiences: + description: Audiences is the narrowed set of RFC + 8707 resources this client may request. + items: + maxLength: 2048 + minLength: 1 + type: string + maxItems: 10 + minItems: 1 + type: array + x-kubernetes-list-type: atomic + clientId: + description: ClientID is the OAuth client_id presented + at the token endpoint. + maxLength: 256 + minLength: 1 + type: string + clientSecretRef: + description: ClientSecretRef references the Kubernetes + Secret key containing the client secret. + properties: + key: + description: Key is the key within the secret + type: string + name: + description: Name is the name of the secret + type: string + required: + - key + - name + type: object + scopes: + description: Scopes is the narrowed set of OAuth + scopes this client may request. + items: + maxLength: 256 + minLength: 1 + type: string + maxItems: 10 + minItems: 1 + type: array + x-kubernetes-list-type: atomic + required: + - audiences + - clientId + - clientSecretRef + - scopes + type: object + x-kubernetes-validations: + - message: clientSecretRef.name and clientSecretRef.key + are required and must be non-empty + rule: has(self.clientSecretRef) && size(self.clientSecretRef.name) + > 0 && size(self.clientSecretRef.key) > 0 + maxItems: 10 + type: array + x-kubernetes-list-type: atomic + issuerPolicies: + description: IssuerPolicies binds RFC 8693 policy to named + trusted issuers. + items: + description: TokenExchangeIssuerPolicyConfig binds RFC + 8693 policy to a named trusted issuer. + properties: + actorClaim: + description: ActorClaim names the claim containing + the external actor identity. + maxLength: 64 + type: string + actorMatcher: + maxLength: 4096 + type: string + allowMayAct: + type: boolean + allowedActors: + items: + type: string + maxItems: 50 + type: array + x-kubernetes-list-type: atomic + allowedDelegateClients: + items: + type: string + maxItems: 50 + minItems: 1 + type: array + x-kubernetes-list-type: atomic + expectedAudience: + description: ExpectedAudience is the required RFC + 8693 subject-token audience. + maxLength: 2048 + minLength: 1 + type: string + issuerRef: + description: IssuerRef references trustedIssuers[].name. + maxLength: 253 + minLength: 1 + type: string + required: + - allowedDelegateClients + - expectedAudience + - issuerRef + type: object + maxItems: 20 + type: array + x-kubernetes-list-type: atomic + type: object + type: object insecureAllowConfidentialOverLoopbackHTTP: default: false description: |- @@ -773,25 +983,9 @@ spec: items: description: |- TrustedIssuerConfig configures an external OIDC issuer whose tokens are - accepted as RFC 8693 subject tokens or RFC 7523 JWT-bearer assertions during - token exchange. It mirrors tokenexchange.TrustedIssuer - (pkg/authserver/server/tokenexchange), the runtime type the operator converts - this into directly — no secret is referenced by this type, so no SecretKeyRef - indirection is needed, unlike DelegateClientConfig. - - expectedAudience is exempted only for a grant-only issuer: jwtBearerGrant - present and none of actorClaim, actorMatcher, allowMayAct, or allowedActors - set. Any RFC 8693 delegation field (actorClaim, actorMatcher, allowMayAct, - allowedActors) still requires expectedAudience, even when combined with - jwtBearerGrant. - - The allowedDelegateClients rule below mirrors validateDelegationPolicy - (pkg/authserver/server/tokenexchange/multi_issuer_validator.go): it is - keyed on whether ANY delegation field is set (expectedAudience, - actorClaim, actorMatcher, allowMayAct), not merely on whether - jwtBearerGrant is absent — an issuer can combine jwtBearerGrant with - expectedAudience for RFC 8693 delegation on the same issuer, and that - combination still requires allowedDelegateClients at the Go level. + accepted as RFC 8693 subject tokens or RFC 7523 JWT-bearer assertions. Trust + fields remain top-level; canonical grant policy references this declaration by + Name. The embedded grant-policy fields are retained for released CRD compatibility. properties: actorClaim: description: |- @@ -800,6 +994,8 @@ spec: Defaults to "azp" when empty; use "appid" for Microsoft Entra v1, "cid" for Okta. The special value "client_id" reads the subject token's client_id claim instead. + This legacy field is deprecated; configure RFC 8693 policy under + inboundGrants.tokenExchange.issuerPolicies. maxLength: 64 type: string actorMatcher: @@ -812,6 +1008,8 @@ spec: not at reconcile time. A syntactically invalid expression fails reconciliation (surfaced via the AuthServerConfigValidated condition), not admission — there is no validating webhook for this field. + This legacy field is deprecated; configure RFC 8693 policy under + inboundGrants.tokenExchange.issuerPolicies. maxLength: 4096 type: string allowMayAct: @@ -823,6 +1021,8 @@ spec: Does not affect self-issued subject tokens. The wildcard is never permitted alongside specific allowedDelegateClients, regardless of this setting. + This legacy field is deprecated; configure RFC 8693 policy under + inboundGrants.tokenExchange.issuerPolicies. type: boolean allowPrivateIPs: description: |- @@ -841,6 +1041,8 @@ spec: either signal is sufficient. Empty denies every token unless actorMatcher is set, or allowMayAct is true and the token carries a permitted may_act claim. + This legacy field is deprecated; configure RFC 8693 policy under + inboundGrants.tokenExchange.issuerPolicies. items: maxLength: 256 minLength: 1 @@ -855,6 +1057,8 @@ spec: jwtBearerGrant is configured; set it to ["*"] to permit any confidential client holding the token-exchange grant, or list specific client IDs to bind delegation to them. + This legacy field is deprecated; configure RFC 8693 policy under + inboundGrants.tokenExchange.issuerPolicies. items: maxLength: 256 minLength: 1 @@ -908,6 +1112,8 @@ spec: ExpectedAudience is the expected "aud" claim value that must appear in an RFC 8693 subject token's audience list. It is not used by an RFC 7523 JWT-bearer assertion, whose audience is the token endpoint. + This legacy field is deprecated; configure RFC 8693 policy under + inboundGrants.tokenExchange.issuerPolicies. maxLength: 2048 minLength: 1 type: string @@ -934,6 +1140,8 @@ spec: description: |- JWTBearerGrant enables the plain RFC 7523 JWT-bearer grant for this issuer. It is independent of RFC 8693 delegation policy. + This legacy field is deprecated; configure RFC 7523 policy under + inboundGrants.jwtBearer.issuerPolicies. properties: acceptedAudiences: description: |- @@ -999,6 +1207,13 @@ spec: - message: subjectBindings must not contain duplicate subjects rule: self.subjectBindings.all(binding, self.subjectBindings.filter(other, other.subject == binding.subject).size() == 1) + name: + description: |- + Name optionally identifies this trust declaration for canonical issuerRef references. + Names must be unique within trustedIssuers when set. + maxLength: 253 + minLength: 1 + type: string required: - issuerUrl type: object @@ -1019,23 +1234,22 @@ spec: - message: allowPrivateIPs requires jwksUrl to be set explicitly rule: '!(has(self.allowPrivateIPs) && self.allowPrivateIPs) || (has(self.jwksUrl) && self.jwksUrl != "")' - - message: expectedAudience is required unless jwtBearerGrant - is configured without actorClaim, actorMatcher, allowMayAct, - or allowedActors - rule: (has(self.jwtBearerGrant) && !((has(self.actorClaim) - && size(self.actorClaim) > 0) || (has(self.actorMatcher) - && size(self.actorMatcher) > 0) || (has(self.allowMayAct) - && self.allowMayAct) || (has(self.allowedActors) && size(self.allowedActors) - > 0))) || (has(self.expectedAudience) && size(self.expectedAudience) - > 0) - - message: allowedDelegateClients is required when expectedAudience, - actorClaim, actorMatcher, or allowMayAct is set + - message: expectedAudience is required when legacy RFC 8693 + policy is configured + rule: '!((has(self.actorClaim) && size(self.actorClaim) > + 0) || (has(self.actorMatcher) && size(self.actorMatcher) + > 0) || (has(self.allowMayAct) && self.allowMayAct) || (has(self.allowedActors) + && size(self.allowedActors) > 0) || (has(self.allowedDelegateClients) + && size(self.allowedDelegateClients) > 0)) || (has(self.expectedAudience) + && size(self.expectedAudience) > 0)' + - message: allowedDelegateClients is required when legacy RFC + 8693 policy is configured rule: '!((has(self.expectedAudience) && size(self.expectedAudience) > 0) || (has(self.actorClaim) && size(self.actorClaim) > 0) || (has(self.actorMatcher) && size(self.actorMatcher) - > 0) || (has(self.allowMayAct) && self.allowMayAct)) || - (has(self.allowedDelegateClients) && size(self.allowedDelegateClients) - > 0)' + > 0) || (has(self.allowMayAct) && self.allowMayAct) || (has(self.allowedActors) + && size(self.allowedActors) > 0)) || (has(self.allowedDelegateClients) + && size(self.allowedDelegateClients) > 0)' maxItems: 20 type: array x-kubernetes-list-type: atomic @@ -1655,12 +1869,13 @@ spec: - issuer type: object x-kubernetes-validations: - - message: at least one upstream provider is required unless delegateClients - or a trustedIssuer with jwtBearerGrant is configured + - message: at least one upstream provider or inbound grant family + is required rule: (has(self.upstreamProviders) && size(self.upstreamProviders) > 0) || (has(self.delegateClients) && size(self.delegateClients) > 0) || (has(self.trustedIssuers) && self.trustedIssuers.exists(issuer, - has(issuer.jwtBearerGrant))) + has(issuer.jwtBearerGrant))) || (has(self.inboundGrants) && (has(self.inboundGrants.tokenExchange) + || has(self.inboundGrants.jwtBearer))) - message: allowConfidentialClientRegistration cannot be combined with insecureAllowHTTP; client secrets would be issued in cleartext over an unauthenticated endpoint @@ -1675,6 +1890,46 @@ spec: rule: '!has(self.delegateClients) || size(self.delegateClients) == 0 || !self.issuer.startsWith(''http://'') || (has(self.insecureAllowConfidentialOverLoopbackHTTP) && self.insecureAllowConfidentialOverLoopbackHTTP)' + - message: canonical tokenExchange conflicts with legacy delegateClients + or RFC 8693 trusted issuer policy + rule: '!has(self.inboundGrants) || !has(self.inboundGrants.tokenExchange) + || ((!has(self.delegateClients) || size(self.delegateClients) + == 0) && (!has(self.trustedIssuers) || !self.trustedIssuers.exists(issuer, + has(issuer.expectedAudience) || has(issuer.actorClaim) || has(issuer.allowedActors) + || has(issuer.actorMatcher) || has(issuer.allowedDelegateClients) + || (has(issuer.allowMayAct) && issuer.allowMayAct))))' + - message: canonical jwtBearer conflicts with legacy jwtBearerGrant + rule: '!has(self.inboundGrants) || !has(self.inboundGrants.jwtBearer) + || !has(self.trustedIssuers) || !self.trustedIssuers.exists(issuer, + has(issuer.jwtBearerGrant))' + - message: trustedIssuers must not contain duplicate names + rule: '!has(self.trustedIssuers) || self.trustedIssuers.all(issuer, + !has(issuer.name) || self.trustedIssuers.filter(other, has(other.name) + && other.name == issuer.name).size() == 1)' + - message: every tokenExchange issuerRef must reference a named trusted + issuer + rule: '!has(self.inboundGrants) || !has(self.inboundGrants.tokenExchange) + || !has(self.inboundGrants.tokenExchange.issuerPolicies) || self.inboundGrants.tokenExchange.issuerPolicies.all(policy, + has(self.trustedIssuers) && self.trustedIssuers.exists(issuer, + has(issuer.name) && issuer.name == policy.issuerRef))' + - message: tokenExchange issuerPolicies must not contain duplicate + issuerRef values + rule: '!has(self.inboundGrants) || !has(self.inboundGrants.tokenExchange) + || !has(self.inboundGrants.tokenExchange.issuerPolicies) || self.inboundGrants.tokenExchange.issuerPolicies.all(policy, + self.inboundGrants.tokenExchange.issuerPolicies.filter(other, + other.issuerRef == policy.issuerRef).size() == 1)' + - message: every jwtBearer issuerRef must reference a named trusted + issuer + rule: '!has(self.inboundGrants) || !has(self.inboundGrants.jwtBearer) + || !has(self.inboundGrants.jwtBearer.issuerPolicies) || self.inboundGrants.jwtBearer.issuerPolicies.all(policy, + has(self.trustedIssuers) && self.trustedIssuers.exists(issuer, + has(issuer.name) && issuer.name == policy.issuerRef))' + - message: jwtBearer issuerPolicies must not contain duplicate issuerRef + values + rule: '!has(self.inboundGrants) || !has(self.inboundGrants.jwtBearer) + || !has(self.inboundGrants.jwtBearer.issuerPolicies) || self.inboundGrants.jwtBearer.issuerPolicies.all(policy, + self.inboundGrants.jwtBearer.issuerPolicies.filter(other, other.issuerRef + == policy.issuerRef).size() == 1)' headerInjection: description: |- HeaderInjection configures custom HTTP header injection @@ -2435,6 +2690,7 @@ spec: This is independent of allowConfidentialClientRegistration: it neither enables nor requires unauthenticated confidential dynamic client registration. + This legacy field is deprecated; use inboundGrants.tokenExchange.delegateClients. items: description: |- DelegateClientConfig configures a pre-provisioned confidential OAuth client @@ -2570,6 +2826,215 @@ spec: type: object type: array x-kubernetes-list-type: atomic + inboundGrants: + description: InboundGrants configures canonical inbound OAuth + grant families. + properties: + jwtBearer: + description: JWTBearer configures RFC 7523 issuer policies. + properties: + issuerPolicies: + description: IssuerPolicies binds RFC 7523 policy to named + trusted issuers. + items: + description: JWTBearerIssuerPolicyConfig binds RFC 7523 + policy to a named trusted issuer. + properties: + acceptedAudiences: + description: |- + AcceptedAudiences identifies this authorization server's accepted + assertion audiences. When omitted, runtime validation defaults to the + token endpoint. + items: + maxLength: 2048 + minLength: 1 + pattern: ^https?://[^[:space:]]+$ + type: string + maxItems: 50 + type: array + x-kubernetes-list-type: atomic + issuerRef: + description: IssuerRef references trustedIssuers[].name. + maxLength: 253 + minLength: 1 + type: string + maxAssertionAge: + description: MaxAssertionAge caps the exp-iat interval + independently of exp. + type: string + subjectBindings: + description: |- + SubjectBindings maps an exact external subject to allowed RFC 8707 + resources. + items: + description: |- + JWTBearerSubjectBinding configures the exact subject and allowed resources + for one RFC 7523 JWT-bearer assertion identity. + properties: + allowedResources: + description: |- + AllowedResources is the exact set of RFC 8707 resources this subject may + request. + items: + maxLength: 2048 + minLength: 1 + pattern: ^https?://[^[:space:]]+$ + type: string + maxItems: 50 + minItems: 1 + type: array + x-kubernetes-list-type: atomic + subject: + description: Subject is an exact assertion + sub value. Wildcards are not supported. + maxLength: 256 + minLength: 1 + pattern: ^[^*]+$ + type: string + required: + - allowedResources + - subject + type: object + maxItems: 50 + minItems: 1 + type: array + x-kubernetes-list-type: atomic + required: + - issuerRef + - maxAssertionAge + - subjectBindings + type: object + x-kubernetes-validations: + - message: maxAssertionAge must be greater than zero + rule: duration(self.maxAssertionAge) > duration('0s') + - message: subjectBindings must not contain duplicate + subjects + rule: self.subjectBindings.all(binding, self.subjectBindings.filter(other, + other.subject == binding.subject).size() == 1) + maxItems: 20 + type: array + x-kubernetes-list-type: atomic + type: object + tokenExchange: + description: TokenExchange configures RFC 8693 clients and + issuer policies. + properties: + delegateClients: + description: DelegateClients configures pre-provisioned + confidential clients. + items: + description: |- + DelegateClientConfig configures a pre-provisioned confidential OAuth client + for RFC 8693 token exchange. Its secret is referenced from a Kubernetes + Secret and is never represented inline. + properties: + audiences: + description: Audiences is the narrowed set of RFC + 8707 resources this client may request. + items: + maxLength: 2048 + minLength: 1 + type: string + maxItems: 10 + minItems: 1 + type: array + x-kubernetes-list-type: atomic + clientId: + description: ClientID is the OAuth client_id presented + at the token endpoint. + maxLength: 256 + minLength: 1 + type: string + clientSecretRef: + description: ClientSecretRef references the Kubernetes + Secret key containing the client secret. + properties: + key: + description: Key is the key within the secret + type: string + name: + description: Name is the name of the secret + type: string + required: + - key + - name + type: object + scopes: + description: Scopes is the narrowed set of OAuth + scopes this client may request. + items: + maxLength: 256 + minLength: 1 + type: string + maxItems: 10 + minItems: 1 + type: array + x-kubernetes-list-type: atomic + required: + - audiences + - clientId + - clientSecretRef + - scopes + type: object + x-kubernetes-validations: + - message: clientSecretRef.name and clientSecretRef.key + are required and must be non-empty + rule: has(self.clientSecretRef) && size(self.clientSecretRef.name) + > 0 && size(self.clientSecretRef.key) > 0 + maxItems: 10 + type: array + x-kubernetes-list-type: atomic + issuerPolicies: + description: IssuerPolicies binds RFC 8693 policy to named + trusted issuers. + items: + description: TokenExchangeIssuerPolicyConfig binds RFC + 8693 policy to a named trusted issuer. + properties: + actorClaim: + description: ActorClaim names the claim containing + the external actor identity. + maxLength: 64 + type: string + actorMatcher: + maxLength: 4096 + type: string + allowMayAct: + type: boolean + allowedActors: + items: + type: string + maxItems: 50 + type: array + x-kubernetes-list-type: atomic + allowedDelegateClients: + items: + type: string + maxItems: 50 + minItems: 1 + type: array + x-kubernetes-list-type: atomic + expectedAudience: + description: ExpectedAudience is the required RFC + 8693 subject-token audience. + maxLength: 2048 + minLength: 1 + type: string + issuerRef: + description: IssuerRef references trustedIssuers[].name. + maxLength: 253 + minLength: 1 + type: string + required: + - allowedDelegateClients + - expectedAudience + - issuerRef + type: object + maxItems: 20 + type: array + x-kubernetes-list-type: atomic + type: object + type: object insecureAllowConfidentialOverLoopbackHTTP: default: false description: |- @@ -2895,25 +3360,9 @@ spec: items: description: |- TrustedIssuerConfig configures an external OIDC issuer whose tokens are - accepted as RFC 8693 subject tokens or RFC 7523 JWT-bearer assertions during - token exchange. It mirrors tokenexchange.TrustedIssuer - (pkg/authserver/server/tokenexchange), the runtime type the operator converts - this into directly — no secret is referenced by this type, so no SecretKeyRef - indirection is needed, unlike DelegateClientConfig. - - expectedAudience is exempted only for a grant-only issuer: jwtBearerGrant - present and none of actorClaim, actorMatcher, allowMayAct, or allowedActors - set. Any RFC 8693 delegation field (actorClaim, actorMatcher, allowMayAct, - allowedActors) still requires expectedAudience, even when combined with - jwtBearerGrant. - - The allowedDelegateClients rule below mirrors validateDelegationPolicy - (pkg/authserver/server/tokenexchange/multi_issuer_validator.go): it is - keyed on whether ANY delegation field is set (expectedAudience, - actorClaim, actorMatcher, allowMayAct), not merely on whether - jwtBearerGrant is absent — an issuer can combine jwtBearerGrant with - expectedAudience for RFC 8693 delegation on the same issuer, and that - combination still requires allowedDelegateClients at the Go level. + accepted as RFC 8693 subject tokens or RFC 7523 JWT-bearer assertions. Trust + fields remain top-level; canonical grant policy references this declaration by + Name. The embedded grant-policy fields are retained for released CRD compatibility. properties: actorClaim: description: |- @@ -2922,6 +3371,8 @@ spec: Defaults to "azp" when empty; use "appid" for Microsoft Entra v1, "cid" for Okta. The special value "client_id" reads the subject token's client_id claim instead. + This legacy field is deprecated; configure RFC 8693 policy under + inboundGrants.tokenExchange.issuerPolicies. maxLength: 64 type: string actorMatcher: @@ -2934,6 +3385,8 @@ spec: not at reconcile time. A syntactically invalid expression fails reconciliation (surfaced via the AuthServerConfigValidated condition), not admission — there is no validating webhook for this field. + This legacy field is deprecated; configure RFC 8693 policy under + inboundGrants.tokenExchange.issuerPolicies. maxLength: 4096 type: string allowMayAct: @@ -2945,6 +3398,8 @@ spec: Does not affect self-issued subject tokens. The wildcard is never permitted alongside specific allowedDelegateClients, regardless of this setting. + This legacy field is deprecated; configure RFC 8693 policy under + inboundGrants.tokenExchange.issuerPolicies. type: boolean allowPrivateIPs: description: |- @@ -2963,6 +3418,8 @@ spec: either signal is sufficient. Empty denies every token unless actorMatcher is set, or allowMayAct is true and the token carries a permitted may_act claim. + This legacy field is deprecated; configure RFC 8693 policy under + inboundGrants.tokenExchange.issuerPolicies. items: maxLength: 256 minLength: 1 @@ -2977,6 +3434,8 @@ spec: jwtBearerGrant is configured; set it to ["*"] to permit any confidential client holding the token-exchange grant, or list specific client IDs to bind delegation to them. + This legacy field is deprecated; configure RFC 8693 policy under + inboundGrants.tokenExchange.issuerPolicies. items: maxLength: 256 minLength: 1 @@ -3030,6 +3489,8 @@ spec: ExpectedAudience is the expected "aud" claim value that must appear in an RFC 8693 subject token's audience list. It is not used by an RFC 7523 JWT-bearer assertion, whose audience is the token endpoint. + This legacy field is deprecated; configure RFC 8693 policy under + inboundGrants.tokenExchange.issuerPolicies. maxLength: 2048 minLength: 1 type: string @@ -3056,6 +3517,8 @@ spec: description: |- JWTBearerGrant enables the plain RFC 7523 JWT-bearer grant for this issuer. It is independent of RFC 8693 delegation policy. + This legacy field is deprecated; configure RFC 7523 policy under + inboundGrants.jwtBearer.issuerPolicies. properties: acceptedAudiences: description: |- @@ -3121,6 +3584,13 @@ spec: - message: subjectBindings must not contain duplicate subjects rule: self.subjectBindings.all(binding, self.subjectBindings.filter(other, other.subject == binding.subject).size() == 1) + name: + description: |- + Name optionally identifies this trust declaration for canonical issuerRef references. + Names must be unique within trustedIssuers when set. + maxLength: 253 + minLength: 1 + type: string required: - issuerUrl type: object @@ -3141,23 +3611,22 @@ spec: - message: allowPrivateIPs requires jwksUrl to be set explicitly rule: '!(has(self.allowPrivateIPs) && self.allowPrivateIPs) || (has(self.jwksUrl) && self.jwksUrl != "")' - - message: expectedAudience is required unless jwtBearerGrant - is configured without actorClaim, actorMatcher, allowMayAct, - or allowedActors - rule: (has(self.jwtBearerGrant) && !((has(self.actorClaim) - && size(self.actorClaim) > 0) || (has(self.actorMatcher) - && size(self.actorMatcher) > 0) || (has(self.allowMayAct) - && self.allowMayAct) || (has(self.allowedActors) && size(self.allowedActors) - > 0))) || (has(self.expectedAudience) && size(self.expectedAudience) - > 0) - - message: allowedDelegateClients is required when expectedAudience, - actorClaim, actorMatcher, or allowMayAct is set + - message: expectedAudience is required when legacy RFC 8693 + policy is configured + rule: '!((has(self.actorClaim) && size(self.actorClaim) > + 0) || (has(self.actorMatcher) && size(self.actorMatcher) + > 0) || (has(self.allowMayAct) && self.allowMayAct) || (has(self.allowedActors) + && size(self.allowedActors) > 0) || (has(self.allowedDelegateClients) + && size(self.allowedDelegateClients) > 0)) || (has(self.expectedAudience) + && size(self.expectedAudience) > 0)' + - message: allowedDelegateClients is required when legacy RFC + 8693 policy is configured rule: '!((has(self.expectedAudience) && size(self.expectedAudience) > 0) || (has(self.actorClaim) && size(self.actorClaim) > 0) || (has(self.actorMatcher) && size(self.actorMatcher) - > 0) || (has(self.allowMayAct) && self.allowMayAct)) || - (has(self.allowedDelegateClients) && size(self.allowedDelegateClients) - > 0)' + > 0) || (has(self.allowMayAct) && self.allowMayAct) || (has(self.allowedActors) + && size(self.allowedActors) > 0)) || (has(self.allowedDelegateClients) + && size(self.allowedDelegateClients) > 0)' maxItems: 20 type: array x-kubernetes-list-type: atomic @@ -3777,12 +4246,13 @@ spec: - issuer type: object x-kubernetes-validations: - - message: at least one upstream provider is required unless delegateClients - or a trustedIssuer with jwtBearerGrant is configured + - message: at least one upstream provider or inbound grant family + is required rule: (has(self.upstreamProviders) && size(self.upstreamProviders) > 0) || (has(self.delegateClients) && size(self.delegateClients) > 0) || (has(self.trustedIssuers) && self.trustedIssuers.exists(issuer, - has(issuer.jwtBearerGrant))) + has(issuer.jwtBearerGrant))) || (has(self.inboundGrants) && (has(self.inboundGrants.tokenExchange) + || has(self.inboundGrants.jwtBearer))) - message: allowConfidentialClientRegistration cannot be combined with insecureAllowHTTP; client secrets would be issued in cleartext over an unauthenticated endpoint @@ -3797,6 +4267,46 @@ spec: rule: '!has(self.delegateClients) || size(self.delegateClients) == 0 || !self.issuer.startsWith(''http://'') || (has(self.insecureAllowConfidentialOverLoopbackHTTP) && self.insecureAllowConfidentialOverLoopbackHTTP)' + - message: canonical tokenExchange conflicts with legacy delegateClients + or RFC 8693 trusted issuer policy + rule: '!has(self.inboundGrants) || !has(self.inboundGrants.tokenExchange) + || ((!has(self.delegateClients) || size(self.delegateClients) + == 0) && (!has(self.trustedIssuers) || !self.trustedIssuers.exists(issuer, + has(issuer.expectedAudience) || has(issuer.actorClaim) || has(issuer.allowedActors) + || has(issuer.actorMatcher) || has(issuer.allowedDelegateClients) + || (has(issuer.allowMayAct) && issuer.allowMayAct))))' + - message: canonical jwtBearer conflicts with legacy jwtBearerGrant + rule: '!has(self.inboundGrants) || !has(self.inboundGrants.jwtBearer) + || !has(self.trustedIssuers) || !self.trustedIssuers.exists(issuer, + has(issuer.jwtBearerGrant))' + - message: trustedIssuers must not contain duplicate names + rule: '!has(self.trustedIssuers) || self.trustedIssuers.all(issuer, + !has(issuer.name) || self.trustedIssuers.filter(other, has(other.name) + && other.name == issuer.name).size() == 1)' + - message: every tokenExchange issuerRef must reference a named trusted + issuer + rule: '!has(self.inboundGrants) || !has(self.inboundGrants.tokenExchange) + || !has(self.inboundGrants.tokenExchange.issuerPolicies) || self.inboundGrants.tokenExchange.issuerPolicies.all(policy, + has(self.trustedIssuers) && self.trustedIssuers.exists(issuer, + has(issuer.name) && issuer.name == policy.issuerRef))' + - message: tokenExchange issuerPolicies must not contain duplicate + issuerRef values + rule: '!has(self.inboundGrants) || !has(self.inboundGrants.tokenExchange) + || !has(self.inboundGrants.tokenExchange.issuerPolicies) || self.inboundGrants.tokenExchange.issuerPolicies.all(policy, + self.inboundGrants.tokenExchange.issuerPolicies.filter(other, + other.issuerRef == policy.issuerRef).size() == 1)' + - message: every jwtBearer issuerRef must reference a named trusted + issuer + rule: '!has(self.inboundGrants) || !has(self.inboundGrants.jwtBearer) + || !has(self.inboundGrants.jwtBearer.issuerPolicies) || self.inboundGrants.jwtBearer.issuerPolicies.all(policy, + has(self.trustedIssuers) && self.trustedIssuers.exists(issuer, + has(issuer.name) && issuer.name == policy.issuerRef))' + - message: jwtBearer issuerPolicies must not contain duplicate issuerRef + values + rule: '!has(self.inboundGrants) || !has(self.inboundGrants.jwtBearer) + || !has(self.inboundGrants.jwtBearer.issuerPolicies) || self.inboundGrants.jwtBearer.issuerPolicies.all(policy, + self.inboundGrants.jwtBearer.issuerPolicies.filter(other, other.issuerRef + == policy.issuerRef).size() == 1)' headerInjection: description: |- HeaderInjection configures custom HTTP header injection 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 0e2fe27e91..a0b681ab42 100644 --- a/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_virtualmcpservers.yaml +++ b/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_virtualmcpservers.yaml @@ -189,6 +189,7 @@ spec: This is independent of allowConfidentialClientRegistration: it neither enables nor requires unauthenticated confidential dynamic client registration. + This legacy field is deprecated; use inboundGrants.tokenExchange.delegateClients. items: description: |- DelegateClientConfig configures a pre-provisioned confidential OAuth client @@ -324,6 +325,215 @@ spec: type: object type: array x-kubernetes-list-type: atomic + inboundGrants: + description: InboundGrants configures canonical inbound OAuth + grant families. + properties: + jwtBearer: + description: JWTBearer configures RFC 7523 issuer policies. + properties: + issuerPolicies: + description: IssuerPolicies binds RFC 7523 policy to named + trusted issuers. + items: + description: JWTBearerIssuerPolicyConfig binds RFC 7523 + policy to a named trusted issuer. + properties: + acceptedAudiences: + description: |- + AcceptedAudiences identifies this authorization server's accepted + assertion audiences. When omitted, runtime validation defaults to the + token endpoint. + items: + maxLength: 2048 + minLength: 1 + pattern: ^https?://[^[:space:]]+$ + type: string + maxItems: 50 + type: array + x-kubernetes-list-type: atomic + issuerRef: + description: IssuerRef references trustedIssuers[].name. + maxLength: 253 + minLength: 1 + type: string + maxAssertionAge: + description: MaxAssertionAge caps the exp-iat interval + independently of exp. + type: string + subjectBindings: + description: |- + SubjectBindings maps an exact external subject to allowed RFC 8707 + resources. + items: + description: |- + JWTBearerSubjectBinding configures the exact subject and allowed resources + for one RFC 7523 JWT-bearer assertion identity. + properties: + allowedResources: + description: |- + AllowedResources is the exact set of RFC 8707 resources this subject may + request. + items: + maxLength: 2048 + minLength: 1 + pattern: ^https?://[^[:space:]]+$ + type: string + maxItems: 50 + minItems: 1 + type: array + x-kubernetes-list-type: atomic + subject: + description: Subject is an exact assertion + sub value. Wildcards are not supported. + maxLength: 256 + minLength: 1 + pattern: ^[^*]+$ + type: string + required: + - allowedResources + - subject + type: object + maxItems: 50 + minItems: 1 + type: array + x-kubernetes-list-type: atomic + required: + - issuerRef + - maxAssertionAge + - subjectBindings + type: object + x-kubernetes-validations: + - message: maxAssertionAge must be greater than zero + rule: duration(self.maxAssertionAge) > duration('0s') + - message: subjectBindings must not contain duplicate + subjects + rule: self.subjectBindings.all(binding, self.subjectBindings.filter(other, + other.subject == binding.subject).size() == 1) + maxItems: 20 + type: array + x-kubernetes-list-type: atomic + type: object + tokenExchange: + description: TokenExchange configures RFC 8693 clients and + issuer policies. + properties: + delegateClients: + description: DelegateClients configures pre-provisioned + confidential clients. + items: + description: |- + DelegateClientConfig configures a pre-provisioned confidential OAuth client + for RFC 8693 token exchange. Its secret is referenced from a Kubernetes + Secret and is never represented inline. + properties: + audiences: + description: Audiences is the narrowed set of RFC + 8707 resources this client may request. + items: + maxLength: 2048 + minLength: 1 + type: string + maxItems: 10 + minItems: 1 + type: array + x-kubernetes-list-type: atomic + clientId: + description: ClientID is the OAuth client_id presented + at the token endpoint. + maxLength: 256 + minLength: 1 + type: string + clientSecretRef: + description: ClientSecretRef references the Kubernetes + Secret key containing the client secret. + properties: + key: + description: Key is the key within the secret + type: string + name: + description: Name is the name of the secret + type: string + required: + - key + - name + type: object + scopes: + description: Scopes is the narrowed set of OAuth + scopes this client may request. + items: + maxLength: 256 + minLength: 1 + type: string + maxItems: 10 + minItems: 1 + type: array + x-kubernetes-list-type: atomic + required: + - audiences + - clientId + - clientSecretRef + - scopes + type: object + x-kubernetes-validations: + - message: clientSecretRef.name and clientSecretRef.key + are required and must be non-empty + rule: has(self.clientSecretRef) && size(self.clientSecretRef.name) + > 0 && size(self.clientSecretRef.key) > 0 + maxItems: 10 + type: array + x-kubernetes-list-type: atomic + issuerPolicies: + description: IssuerPolicies binds RFC 8693 policy to named + trusted issuers. + items: + description: TokenExchangeIssuerPolicyConfig binds RFC + 8693 policy to a named trusted issuer. + properties: + actorClaim: + description: ActorClaim names the claim containing + the external actor identity. + maxLength: 64 + type: string + actorMatcher: + maxLength: 4096 + type: string + allowMayAct: + type: boolean + allowedActors: + items: + type: string + maxItems: 50 + type: array + x-kubernetes-list-type: atomic + allowedDelegateClients: + items: + type: string + maxItems: 50 + minItems: 1 + type: array + x-kubernetes-list-type: atomic + expectedAudience: + description: ExpectedAudience is the required RFC + 8693 subject-token audience. + maxLength: 2048 + minLength: 1 + type: string + issuerRef: + description: IssuerRef references trustedIssuers[].name. + maxLength: 253 + minLength: 1 + type: string + required: + - allowedDelegateClients + - expectedAudience + - issuerRef + type: object + maxItems: 20 + type: array + x-kubernetes-list-type: atomic + type: object + type: object insecureAllowConfidentialOverLoopbackHTTP: default: false description: |- @@ -649,25 +859,9 @@ spec: items: description: |- TrustedIssuerConfig configures an external OIDC issuer whose tokens are - accepted as RFC 8693 subject tokens or RFC 7523 JWT-bearer assertions during - token exchange. It mirrors tokenexchange.TrustedIssuer - (pkg/authserver/server/tokenexchange), the runtime type the operator converts - this into directly — no secret is referenced by this type, so no SecretKeyRef - indirection is needed, unlike DelegateClientConfig. - - expectedAudience is exempted only for a grant-only issuer: jwtBearerGrant - present and none of actorClaim, actorMatcher, allowMayAct, or allowedActors - set. Any RFC 8693 delegation field (actorClaim, actorMatcher, allowMayAct, - allowedActors) still requires expectedAudience, even when combined with - jwtBearerGrant. - - The allowedDelegateClients rule below mirrors validateDelegationPolicy - (pkg/authserver/server/tokenexchange/multi_issuer_validator.go): it is - keyed on whether ANY delegation field is set (expectedAudience, - actorClaim, actorMatcher, allowMayAct), not merely on whether - jwtBearerGrant is absent — an issuer can combine jwtBearerGrant with - expectedAudience for RFC 8693 delegation on the same issuer, and that - combination still requires allowedDelegateClients at the Go level. + accepted as RFC 8693 subject tokens or RFC 7523 JWT-bearer assertions. Trust + fields remain top-level; canonical grant policy references this declaration by + Name. The embedded grant-policy fields are retained for released CRD compatibility. properties: actorClaim: description: |- @@ -676,6 +870,8 @@ spec: Defaults to "azp" when empty; use "appid" for Microsoft Entra v1, "cid" for Okta. The special value "client_id" reads the subject token's client_id claim instead. + This legacy field is deprecated; configure RFC 8693 policy under + inboundGrants.tokenExchange.issuerPolicies. maxLength: 64 type: string actorMatcher: @@ -688,6 +884,8 @@ spec: not at reconcile time. A syntactically invalid expression fails reconciliation (surfaced via the AuthServerConfigValidated condition), not admission — there is no validating webhook for this field. + This legacy field is deprecated; configure RFC 8693 policy under + inboundGrants.tokenExchange.issuerPolicies. maxLength: 4096 type: string allowMayAct: @@ -699,6 +897,8 @@ spec: Does not affect self-issued subject tokens. The wildcard is never permitted alongside specific allowedDelegateClients, regardless of this setting. + This legacy field is deprecated; configure RFC 8693 policy under + inboundGrants.tokenExchange.issuerPolicies. type: boolean allowPrivateIPs: description: |- @@ -717,6 +917,8 @@ spec: either signal is sufficient. Empty denies every token unless actorMatcher is set, or allowMayAct is true and the token carries a permitted may_act claim. + This legacy field is deprecated; configure RFC 8693 policy under + inboundGrants.tokenExchange.issuerPolicies. items: maxLength: 256 minLength: 1 @@ -731,6 +933,8 @@ spec: jwtBearerGrant is configured; set it to ["*"] to permit any confidential client holding the token-exchange grant, or list specific client IDs to bind delegation to them. + This legacy field is deprecated; configure RFC 8693 policy under + inboundGrants.tokenExchange.issuerPolicies. items: maxLength: 256 minLength: 1 @@ -784,6 +988,8 @@ spec: ExpectedAudience is the expected "aud" claim value that must appear in an RFC 8693 subject token's audience list. It is not used by an RFC 7523 JWT-bearer assertion, whose audience is the token endpoint. + This legacy field is deprecated; configure RFC 8693 policy under + inboundGrants.tokenExchange.issuerPolicies. maxLength: 2048 minLength: 1 type: string @@ -810,6 +1016,8 @@ spec: description: |- JWTBearerGrant enables the plain RFC 7523 JWT-bearer grant for this issuer. It is independent of RFC 8693 delegation policy. + This legacy field is deprecated; configure RFC 7523 policy under + inboundGrants.jwtBearer.issuerPolicies. properties: acceptedAudiences: description: |- @@ -875,6 +1083,13 @@ spec: - message: subjectBindings must not contain duplicate subjects rule: self.subjectBindings.all(binding, self.subjectBindings.filter(other, other.subject == binding.subject).size() == 1) + name: + description: |- + Name optionally identifies this trust declaration for canonical issuerRef references. + Names must be unique within trustedIssuers when set. + maxLength: 253 + minLength: 1 + type: string required: - issuerUrl type: object @@ -895,23 +1110,22 @@ spec: - message: allowPrivateIPs requires jwksUrl to be set explicitly rule: '!(has(self.allowPrivateIPs) && self.allowPrivateIPs) || (has(self.jwksUrl) && self.jwksUrl != "")' - - message: expectedAudience is required unless jwtBearerGrant - is configured without actorClaim, actorMatcher, allowMayAct, - or allowedActors - rule: (has(self.jwtBearerGrant) && !((has(self.actorClaim) - && size(self.actorClaim) > 0) || (has(self.actorMatcher) - && size(self.actorMatcher) > 0) || (has(self.allowMayAct) - && self.allowMayAct) || (has(self.allowedActors) && size(self.allowedActors) - > 0))) || (has(self.expectedAudience) && size(self.expectedAudience) - > 0) - - message: allowedDelegateClients is required when expectedAudience, - actorClaim, actorMatcher, or allowMayAct is set + - message: expectedAudience is required when legacy RFC 8693 + policy is configured + rule: '!((has(self.actorClaim) && size(self.actorClaim) > + 0) || (has(self.actorMatcher) && size(self.actorMatcher) + > 0) || (has(self.allowMayAct) && self.allowMayAct) || (has(self.allowedActors) + && size(self.allowedActors) > 0) || (has(self.allowedDelegateClients) + && size(self.allowedDelegateClients) > 0)) || (has(self.expectedAudience) + && size(self.expectedAudience) > 0)' + - message: allowedDelegateClients is required when legacy RFC + 8693 policy is configured rule: '!((has(self.expectedAudience) && size(self.expectedAudience) > 0) || (has(self.actorClaim) && size(self.actorClaim) > 0) || (has(self.actorMatcher) && size(self.actorMatcher) - > 0) || (has(self.allowMayAct) && self.allowMayAct)) || - (has(self.allowedDelegateClients) && size(self.allowedDelegateClients) - > 0)' + > 0) || (has(self.allowMayAct) && self.allowMayAct) || (has(self.allowedActors) + && size(self.allowedActors) > 0)) || (has(self.allowedDelegateClients) + && size(self.allowedDelegateClients) > 0)' maxItems: 20 type: array x-kubernetes-list-type: atomic @@ -1531,12 +1745,13 @@ spec: - issuer type: object x-kubernetes-validations: - - message: at least one upstream provider is required unless delegateClients - or a trustedIssuer with jwtBearerGrant is configured + - message: at least one upstream provider or inbound grant family + is required rule: (has(self.upstreamProviders) && size(self.upstreamProviders) > 0) || (has(self.delegateClients) && size(self.delegateClients) > 0) || (has(self.trustedIssuers) && self.trustedIssuers.exists(issuer, - has(issuer.jwtBearerGrant))) + has(issuer.jwtBearerGrant))) || (has(self.inboundGrants) && (has(self.inboundGrants.tokenExchange) + || has(self.inboundGrants.jwtBearer))) - message: allowConfidentialClientRegistration cannot be combined with insecureAllowHTTP; client secrets would be issued in cleartext over an unauthenticated endpoint @@ -1551,6 +1766,46 @@ spec: rule: '!has(self.delegateClients) || size(self.delegateClients) == 0 || !self.issuer.startsWith(''http://'') || (has(self.insecureAllowConfidentialOverLoopbackHTTP) && self.insecureAllowConfidentialOverLoopbackHTTP)' + - message: canonical tokenExchange conflicts with legacy delegateClients + or RFC 8693 trusted issuer policy + rule: '!has(self.inboundGrants) || !has(self.inboundGrants.tokenExchange) + || ((!has(self.delegateClients) || size(self.delegateClients) + == 0) && (!has(self.trustedIssuers) || !self.trustedIssuers.exists(issuer, + has(issuer.expectedAudience) || has(issuer.actorClaim) || has(issuer.allowedActors) + || has(issuer.actorMatcher) || has(issuer.allowedDelegateClients) + || (has(issuer.allowMayAct) && issuer.allowMayAct))))' + - message: canonical jwtBearer conflicts with legacy jwtBearerGrant + rule: '!has(self.inboundGrants) || !has(self.inboundGrants.jwtBearer) + || !has(self.trustedIssuers) || !self.trustedIssuers.exists(issuer, + has(issuer.jwtBearerGrant))' + - message: trustedIssuers must not contain duplicate names + rule: '!has(self.trustedIssuers) || self.trustedIssuers.all(issuer, + !has(issuer.name) || self.trustedIssuers.filter(other, has(other.name) + && other.name == issuer.name).size() == 1)' + - message: every tokenExchange issuerRef must reference a named trusted + issuer + rule: '!has(self.inboundGrants) || !has(self.inboundGrants.tokenExchange) + || !has(self.inboundGrants.tokenExchange.issuerPolicies) || self.inboundGrants.tokenExchange.issuerPolicies.all(policy, + has(self.trustedIssuers) && self.trustedIssuers.exists(issuer, + has(issuer.name) && issuer.name == policy.issuerRef))' + - message: tokenExchange issuerPolicies must not contain duplicate + issuerRef values + rule: '!has(self.inboundGrants) || !has(self.inboundGrants.tokenExchange) + || !has(self.inboundGrants.tokenExchange.issuerPolicies) || self.inboundGrants.tokenExchange.issuerPolicies.all(policy, + self.inboundGrants.tokenExchange.issuerPolicies.filter(other, + other.issuerRef == policy.issuerRef).size() == 1)' + - message: every jwtBearer issuerRef must reference a named trusted + issuer + rule: '!has(self.inboundGrants) || !has(self.inboundGrants.jwtBearer) + || !has(self.inboundGrants.jwtBearer.issuerPolicies) || self.inboundGrants.jwtBearer.issuerPolicies.all(policy, + has(self.trustedIssuers) && self.trustedIssuers.exists(issuer, + has(issuer.name) && issuer.name == policy.issuerRef))' + - message: jwtBearer issuerPolicies must not contain duplicate issuerRef + values + rule: '!has(self.inboundGrants) || !has(self.inboundGrants.jwtBearer) + || !has(self.inboundGrants.jwtBearer.issuerPolicies) || self.inboundGrants.jwtBearer.issuerPolicies.all(policy, + self.inboundGrants.jwtBearer.issuerPolicies.filter(other, other.issuerRef + == policy.issuerRef).size() == 1)' config: description: |- Config is the Virtual MCP server configuration. @@ -4322,6 +4577,7 @@ spec: This is independent of allowConfidentialClientRegistration: it neither enables nor requires unauthenticated confidential dynamic client registration. + This legacy field is deprecated; use inboundGrants.tokenExchange.delegateClients. items: description: |- DelegateClientConfig configures a pre-provisioned confidential OAuth client @@ -4457,6 +4713,215 @@ spec: type: object type: array x-kubernetes-list-type: atomic + inboundGrants: + description: InboundGrants configures canonical inbound OAuth + grant families. + properties: + jwtBearer: + description: JWTBearer configures RFC 7523 issuer policies. + properties: + issuerPolicies: + description: IssuerPolicies binds RFC 7523 policy to named + trusted issuers. + items: + description: JWTBearerIssuerPolicyConfig binds RFC 7523 + policy to a named trusted issuer. + properties: + acceptedAudiences: + description: |- + AcceptedAudiences identifies this authorization server's accepted + assertion audiences. When omitted, runtime validation defaults to the + token endpoint. + items: + maxLength: 2048 + minLength: 1 + pattern: ^https?://[^[:space:]]+$ + type: string + maxItems: 50 + type: array + x-kubernetes-list-type: atomic + issuerRef: + description: IssuerRef references trustedIssuers[].name. + maxLength: 253 + minLength: 1 + type: string + maxAssertionAge: + description: MaxAssertionAge caps the exp-iat interval + independently of exp. + type: string + subjectBindings: + description: |- + SubjectBindings maps an exact external subject to allowed RFC 8707 + resources. + items: + description: |- + JWTBearerSubjectBinding configures the exact subject and allowed resources + for one RFC 7523 JWT-bearer assertion identity. + properties: + allowedResources: + description: |- + AllowedResources is the exact set of RFC 8707 resources this subject may + request. + items: + maxLength: 2048 + minLength: 1 + pattern: ^https?://[^[:space:]]+$ + type: string + maxItems: 50 + minItems: 1 + type: array + x-kubernetes-list-type: atomic + subject: + description: Subject is an exact assertion + sub value. Wildcards are not supported. + maxLength: 256 + minLength: 1 + pattern: ^[^*]+$ + type: string + required: + - allowedResources + - subject + type: object + maxItems: 50 + minItems: 1 + type: array + x-kubernetes-list-type: atomic + required: + - issuerRef + - maxAssertionAge + - subjectBindings + type: object + x-kubernetes-validations: + - message: maxAssertionAge must be greater than zero + rule: duration(self.maxAssertionAge) > duration('0s') + - message: subjectBindings must not contain duplicate + subjects + rule: self.subjectBindings.all(binding, self.subjectBindings.filter(other, + other.subject == binding.subject).size() == 1) + maxItems: 20 + type: array + x-kubernetes-list-type: atomic + type: object + tokenExchange: + description: TokenExchange configures RFC 8693 clients and + issuer policies. + properties: + delegateClients: + description: DelegateClients configures pre-provisioned + confidential clients. + items: + description: |- + DelegateClientConfig configures a pre-provisioned confidential OAuth client + for RFC 8693 token exchange. Its secret is referenced from a Kubernetes + Secret and is never represented inline. + properties: + audiences: + description: Audiences is the narrowed set of RFC + 8707 resources this client may request. + items: + maxLength: 2048 + minLength: 1 + type: string + maxItems: 10 + minItems: 1 + type: array + x-kubernetes-list-type: atomic + clientId: + description: ClientID is the OAuth client_id presented + at the token endpoint. + maxLength: 256 + minLength: 1 + type: string + clientSecretRef: + description: ClientSecretRef references the Kubernetes + Secret key containing the client secret. + properties: + key: + description: Key is the key within the secret + type: string + name: + description: Name is the name of the secret + type: string + required: + - key + - name + type: object + scopes: + description: Scopes is the narrowed set of OAuth + scopes this client may request. + items: + maxLength: 256 + minLength: 1 + type: string + maxItems: 10 + minItems: 1 + type: array + x-kubernetes-list-type: atomic + required: + - audiences + - clientId + - clientSecretRef + - scopes + type: object + x-kubernetes-validations: + - message: clientSecretRef.name and clientSecretRef.key + are required and must be non-empty + rule: has(self.clientSecretRef) && size(self.clientSecretRef.name) + > 0 && size(self.clientSecretRef.key) > 0 + maxItems: 10 + type: array + x-kubernetes-list-type: atomic + issuerPolicies: + description: IssuerPolicies binds RFC 8693 policy to named + trusted issuers. + items: + description: TokenExchangeIssuerPolicyConfig binds RFC + 8693 policy to a named trusted issuer. + properties: + actorClaim: + description: ActorClaim names the claim containing + the external actor identity. + maxLength: 64 + type: string + actorMatcher: + maxLength: 4096 + type: string + allowMayAct: + type: boolean + allowedActors: + items: + type: string + maxItems: 50 + type: array + x-kubernetes-list-type: atomic + allowedDelegateClients: + items: + type: string + maxItems: 50 + minItems: 1 + type: array + x-kubernetes-list-type: atomic + expectedAudience: + description: ExpectedAudience is the required RFC + 8693 subject-token audience. + maxLength: 2048 + minLength: 1 + type: string + issuerRef: + description: IssuerRef references trustedIssuers[].name. + maxLength: 253 + minLength: 1 + type: string + required: + - allowedDelegateClients + - expectedAudience + - issuerRef + type: object + maxItems: 20 + type: array + x-kubernetes-list-type: atomic + type: object + type: object insecureAllowConfidentialOverLoopbackHTTP: default: false description: |- @@ -4782,25 +5247,9 @@ spec: items: description: |- TrustedIssuerConfig configures an external OIDC issuer whose tokens are - accepted as RFC 8693 subject tokens or RFC 7523 JWT-bearer assertions during - token exchange. It mirrors tokenexchange.TrustedIssuer - (pkg/authserver/server/tokenexchange), the runtime type the operator converts - this into directly — no secret is referenced by this type, so no SecretKeyRef - indirection is needed, unlike DelegateClientConfig. - - expectedAudience is exempted only for a grant-only issuer: jwtBearerGrant - present and none of actorClaim, actorMatcher, allowMayAct, or allowedActors - set. Any RFC 8693 delegation field (actorClaim, actorMatcher, allowMayAct, - allowedActors) still requires expectedAudience, even when combined with - jwtBearerGrant. - - The allowedDelegateClients rule below mirrors validateDelegationPolicy - (pkg/authserver/server/tokenexchange/multi_issuer_validator.go): it is - keyed on whether ANY delegation field is set (expectedAudience, - actorClaim, actorMatcher, allowMayAct), not merely on whether - jwtBearerGrant is absent — an issuer can combine jwtBearerGrant with - expectedAudience for RFC 8693 delegation on the same issuer, and that - combination still requires allowedDelegateClients at the Go level. + accepted as RFC 8693 subject tokens or RFC 7523 JWT-bearer assertions. Trust + fields remain top-level; canonical grant policy references this declaration by + Name. The embedded grant-policy fields are retained for released CRD compatibility. properties: actorClaim: description: |- @@ -4809,6 +5258,8 @@ spec: Defaults to "azp" when empty; use "appid" for Microsoft Entra v1, "cid" for Okta. The special value "client_id" reads the subject token's client_id claim instead. + This legacy field is deprecated; configure RFC 8693 policy under + inboundGrants.tokenExchange.issuerPolicies. maxLength: 64 type: string actorMatcher: @@ -4821,6 +5272,8 @@ spec: not at reconcile time. A syntactically invalid expression fails reconciliation (surfaced via the AuthServerConfigValidated condition), not admission — there is no validating webhook for this field. + This legacy field is deprecated; configure RFC 8693 policy under + inboundGrants.tokenExchange.issuerPolicies. maxLength: 4096 type: string allowMayAct: @@ -4832,6 +5285,8 @@ spec: Does not affect self-issued subject tokens. The wildcard is never permitted alongside specific allowedDelegateClients, regardless of this setting. + This legacy field is deprecated; configure RFC 8693 policy under + inboundGrants.tokenExchange.issuerPolicies. type: boolean allowPrivateIPs: description: |- @@ -4850,6 +5305,8 @@ spec: either signal is sufficient. Empty denies every token unless actorMatcher is set, or allowMayAct is true and the token carries a permitted may_act claim. + This legacy field is deprecated; configure RFC 8693 policy under + inboundGrants.tokenExchange.issuerPolicies. items: maxLength: 256 minLength: 1 @@ -4864,6 +5321,8 @@ spec: jwtBearerGrant is configured; set it to ["*"] to permit any confidential client holding the token-exchange grant, or list specific client IDs to bind delegation to them. + This legacy field is deprecated; configure RFC 8693 policy under + inboundGrants.tokenExchange.issuerPolicies. items: maxLength: 256 minLength: 1 @@ -4917,6 +5376,8 @@ spec: ExpectedAudience is the expected "aud" claim value that must appear in an RFC 8693 subject token's audience list. It is not used by an RFC 7523 JWT-bearer assertion, whose audience is the token endpoint. + This legacy field is deprecated; configure RFC 8693 policy under + inboundGrants.tokenExchange.issuerPolicies. maxLength: 2048 minLength: 1 type: string @@ -4943,6 +5404,8 @@ spec: description: |- JWTBearerGrant enables the plain RFC 7523 JWT-bearer grant for this issuer. It is independent of RFC 8693 delegation policy. + This legacy field is deprecated; configure RFC 7523 policy under + inboundGrants.jwtBearer.issuerPolicies. properties: acceptedAudiences: description: |- @@ -5008,6 +5471,13 @@ spec: - message: subjectBindings must not contain duplicate subjects rule: self.subjectBindings.all(binding, self.subjectBindings.filter(other, other.subject == binding.subject).size() == 1) + name: + description: |- + Name optionally identifies this trust declaration for canonical issuerRef references. + Names must be unique within trustedIssuers when set. + maxLength: 253 + minLength: 1 + type: string required: - issuerUrl type: object @@ -5028,23 +5498,22 @@ spec: - message: allowPrivateIPs requires jwksUrl to be set explicitly rule: '!(has(self.allowPrivateIPs) && self.allowPrivateIPs) || (has(self.jwksUrl) && self.jwksUrl != "")' - - message: expectedAudience is required unless jwtBearerGrant - is configured without actorClaim, actorMatcher, allowMayAct, - or allowedActors - rule: (has(self.jwtBearerGrant) && !((has(self.actorClaim) - && size(self.actorClaim) > 0) || (has(self.actorMatcher) - && size(self.actorMatcher) > 0) || (has(self.allowMayAct) - && self.allowMayAct) || (has(self.allowedActors) && size(self.allowedActors) - > 0))) || (has(self.expectedAudience) && size(self.expectedAudience) - > 0) - - message: allowedDelegateClients is required when expectedAudience, - actorClaim, actorMatcher, or allowMayAct is set + - message: expectedAudience is required when legacy RFC 8693 + policy is configured + rule: '!((has(self.actorClaim) && size(self.actorClaim) > + 0) || (has(self.actorMatcher) && size(self.actorMatcher) + > 0) || (has(self.allowMayAct) && self.allowMayAct) || (has(self.allowedActors) + && size(self.allowedActors) > 0) || (has(self.allowedDelegateClients) + && size(self.allowedDelegateClients) > 0)) || (has(self.expectedAudience) + && size(self.expectedAudience) > 0)' + - message: allowedDelegateClients is required when legacy RFC + 8693 policy is configured rule: '!((has(self.expectedAudience) && size(self.expectedAudience) > 0) || (has(self.actorClaim) && size(self.actorClaim) > 0) || (has(self.actorMatcher) && size(self.actorMatcher) - > 0) || (has(self.allowMayAct) && self.allowMayAct)) || - (has(self.allowedDelegateClients) && size(self.allowedDelegateClients) - > 0)' + > 0) || (has(self.allowMayAct) && self.allowMayAct) || (has(self.allowedActors) + && size(self.allowedActors) > 0)) || (has(self.allowedDelegateClients) + && size(self.allowedDelegateClients) > 0)' maxItems: 20 type: array x-kubernetes-list-type: atomic @@ -5664,12 +6133,13 @@ spec: - issuer type: object x-kubernetes-validations: - - message: at least one upstream provider is required unless delegateClients - or a trustedIssuer with jwtBearerGrant is configured + - message: at least one upstream provider or inbound grant family + is required rule: (has(self.upstreamProviders) && size(self.upstreamProviders) > 0) || (has(self.delegateClients) && size(self.delegateClients) > 0) || (has(self.trustedIssuers) && self.trustedIssuers.exists(issuer, - has(issuer.jwtBearerGrant))) + has(issuer.jwtBearerGrant))) || (has(self.inboundGrants) && (has(self.inboundGrants.tokenExchange) + || has(self.inboundGrants.jwtBearer))) - message: allowConfidentialClientRegistration cannot be combined with insecureAllowHTTP; client secrets would be issued in cleartext over an unauthenticated endpoint @@ -5684,6 +6154,46 @@ spec: rule: '!has(self.delegateClients) || size(self.delegateClients) == 0 || !self.issuer.startsWith(''http://'') || (has(self.insecureAllowConfidentialOverLoopbackHTTP) && self.insecureAllowConfidentialOverLoopbackHTTP)' + - message: canonical tokenExchange conflicts with legacy delegateClients + or RFC 8693 trusted issuer policy + rule: '!has(self.inboundGrants) || !has(self.inboundGrants.tokenExchange) + || ((!has(self.delegateClients) || size(self.delegateClients) + == 0) && (!has(self.trustedIssuers) || !self.trustedIssuers.exists(issuer, + has(issuer.expectedAudience) || has(issuer.actorClaim) || has(issuer.allowedActors) + || has(issuer.actorMatcher) || has(issuer.allowedDelegateClients) + || (has(issuer.allowMayAct) && issuer.allowMayAct))))' + - message: canonical jwtBearer conflicts with legacy jwtBearerGrant + rule: '!has(self.inboundGrants) || !has(self.inboundGrants.jwtBearer) + || !has(self.trustedIssuers) || !self.trustedIssuers.exists(issuer, + has(issuer.jwtBearerGrant))' + - message: trustedIssuers must not contain duplicate names + rule: '!has(self.trustedIssuers) || self.trustedIssuers.all(issuer, + !has(issuer.name) || self.trustedIssuers.filter(other, has(other.name) + && other.name == issuer.name).size() == 1)' + - message: every tokenExchange issuerRef must reference a named trusted + issuer + rule: '!has(self.inboundGrants) || !has(self.inboundGrants.tokenExchange) + || !has(self.inboundGrants.tokenExchange.issuerPolicies) || self.inboundGrants.tokenExchange.issuerPolicies.all(policy, + has(self.trustedIssuers) && self.trustedIssuers.exists(issuer, + has(issuer.name) && issuer.name == policy.issuerRef))' + - message: tokenExchange issuerPolicies must not contain duplicate + issuerRef values + rule: '!has(self.inboundGrants) || !has(self.inboundGrants.tokenExchange) + || !has(self.inboundGrants.tokenExchange.issuerPolicies) || self.inboundGrants.tokenExchange.issuerPolicies.all(policy, + self.inboundGrants.tokenExchange.issuerPolicies.filter(other, + other.issuerRef == policy.issuerRef).size() == 1)' + - message: every jwtBearer issuerRef must reference a named trusted + issuer + rule: '!has(self.inboundGrants) || !has(self.inboundGrants.jwtBearer) + || !has(self.inboundGrants.jwtBearer.issuerPolicies) || self.inboundGrants.jwtBearer.issuerPolicies.all(policy, + has(self.trustedIssuers) && self.trustedIssuers.exists(issuer, + has(issuer.name) && issuer.name == policy.issuerRef))' + - message: jwtBearer issuerPolicies must not contain duplicate issuerRef + values + rule: '!has(self.inboundGrants) || !has(self.inboundGrants.jwtBearer) + || !has(self.inboundGrants.jwtBearer.issuerPolicies) || self.inboundGrants.jwtBearer.issuerPolicies.all(policy, + self.inboundGrants.jwtBearer.issuerPolicies.filter(other, other.issuerRef + == policy.issuerRef).size() == 1)' config: description: |- Config is the Virtual MCP server configuration. diff --git a/docs/operator/crd-api.md b/docs/operator/crd-api.md index 17be058246..05080602cd 100644 --- a/docs/operator/crd-api.md +++ b/docs/operator/crd-api.md @@ -1903,6 +1903,7 @@ Secret and is never represented inline. _Appears in:_ - [api.v1beta1.EmbeddedAuthServerConfig](#apiv1beta1embeddedauthserverconfig) +- [api.v1beta1.TokenExchangeInboundGrantConfig](#apiv1beta1tokenexchangeinboundgrantconfig) | Field | Description | Default | Validation | | --- | --- | --- | --- | @@ -1977,6 +1978,7 @@ _Appears in:_ | `signingKeySecretRefs` _[api.v1beta1.SecretKeyRef](#apiv1beta1secretkeyref) array_ | SigningKeySecretRefs references Kubernetes Secrets containing signing keys for JWT operations.
Supports key rotation by allowing multiple keys (oldest keys are used for verification only).
If not specified, an ephemeral signing key will be auto-generated (development only -
JWTs will be invalid after restart). | | MaxItems: 5
Optional: \{\}
| | `hmacSecretRefs` _[api.v1beta1.SecretKeyRef](#apiv1beta1secretkeyref) array_ | HMACSecretRefs references Kubernetes Secrets containing symmetric secrets for signing
authorization codes and refresh tokens (opaque tokens).
Current secret must be at least 32 bytes and cryptographically random.
Supports secret rotation via multiple entries (first is current, rest are for verification).
If not specified, an ephemeral secret will be auto-generated (development only -
auth codes and refresh tokens will be invalid after restart). | | Optional: \{\}
| | `tokenLifespans` _[api.v1beta1.TokenLifespanConfig](#apiv1beta1tokenlifespanconfig)_ | TokenLifespans configures the duration that various tokens are valid.
If not specified, defaults are applied (access: 1h, refresh: 7d, authCode: 10m). | | Optional: \{\}
| +| `inboundGrants` _[api.v1beta1.InboundGrantsConfig](#apiv1beta1inboundgrantsconfig)_ | InboundGrants configures canonical inbound OAuth grant families. | | Optional: \{\}
| | `upstreamProviders` _[api.v1beta1.UpstreamProviderConfig](#apiv1beta1upstreamproviderconfig) array_ | UpstreamProviders configures connections to upstream Identity Providers.
When configured, the embedded auth server delegates interactive authentication
to these providers. It may be omitted only when delegateClients or a trusted
issuer with jwtBearerGrant enables token-only operation.
MCPServer and MCPRemoteProxy support a single upstream; VirtualMCPServer supports multiple. | | Optional: \{\}
| | `primaryUpstreamProvider` _string_ | PrimaryUpstreamProvider names the upstream IDP whose access token Cedar
should read claims from when authorising a request. Must match the name
of one of the entries in UpstreamProviders. When empty, the controller
auto-selects the first entry of UpstreamProviders.
Only meaningful on VirtualMCPServer, where multiple upstream providers
can be configured and Cedar needs to pick which token's claims to
evaluate. The VirtualMCPServer controller validates this field against
UpstreamProviders at admission and rejects unresolvable values.
On MCPServer and MCPRemoteProxy this field is structurally present (the
EmbeddedAuthServerConfig struct is shared) but has no runtime effect:
those CRDs are restricted to a single upstream so there is no choice to
make. Setting it on those CRDs is silently ignored. | | MaxLength: 63
MinLength: 1
Pattern: `^[a-z0-9]([a-z0-9-]*[a-z0-9])?$`
Optional: \{\}
| | `storage` _[api.v1beta1.AuthServerStorageConfig](#apiv1beta1authserverstorageconfig)_ | Storage configures the storage backend for the embedded auth server.
If not specified, defaults to in-memory storage. | | Optional: \{\}
| @@ -1986,7 +1988,7 @@ _Appears in:_ | `allowConfidentialClientRegistration` _boolean_ | AllowConfidentialClientRegistration permits RFC 7591 Dynamic Client
Registration of confidential clients: when true, /oauth/register
accepts token_endpoint_auth_method values client_secret_basic and
client_secret_post in addition to "none" (still the default on
omission) and mints a client_secret returned exactly once.
Confidential registrations are restricted to https non-loopback
redirect URIs, and on the Redis storage backend all DCR-issued
registrations are evicted after 30 days of inactivity and must
re-register. This gates registration only: disabling it does not
revoke or reject already-minted secrets at the token endpoint.
Security: registration is unauthenticated, so enabling this lets any
caller who can reach the endpoint obtain a client credential.
Combining it with insecureAllowHTTP is rejected at validation. | false | Optional: \{\}
| | `allowPrivateKeyJWTRegistration` _boolean_ | AllowPrivateKeyJWTRegistration permits Dynamic Client Registration of
clients using private_key_jwt authentication. Registration behavior is
intentionally configured separately from confidential-client registration.
Security: registration is unauthenticated, so enabling this lets any
caller who can reach the endpoint register a private_key_jwt client.
Unlike allowConfidentialClientRegistration, this is NOT rejected when
combined with insecureAllowHTTP: registration never returns a secret
for a private_key_jwt client, so there is nothing for cleartext HTTP
to expose. | false | Optional: \{\}
| | `insecureAllowConfidentialOverLoopbackHTTP` _boolean_ | InsecureAllowConfidentialOverLoopbackHTTP opts in to confidential
Dynamic Client Registration (DCR) and delegate clients when issuer is a
plain-HTTP loopback URL (e.g. "http://localhost:8080"). Without this
flag, that combination is rejected at reconcile time: a loopback http://
issuer is normally fine for local development since the traffic never
leaves the machine, but confidential clients send secrets over cleartext.
Forcing TLS onto every loopback deployment instead would just push
operators toward insecureAllowHTTP, which is worse: that also disables
the non-loopback host check. Has no effect when there are no confidential
clients or issuer is https.
private_key_jwt registration has no equivalent flag or transport
restriction: unlike confidential registration, it never returns a
client_secret (or any other secret) in the DCR response, so there is
nothing here for cleartext HTTP to expose. | false | Optional: \{\}
| -| `delegateClients` _[api.v1beta1.DelegateClientConfig](#apiv1beta1delegateclientconfig) array_ | DelegateClients configures pre-provisioned confidential clients for RFC 8693
token exchange. Each secret is referenced from a Kubernetes Secret; no
plaintext secret, redirect URI, or grant selection is accepted here. The
operator always supplies the token-exchange grant when it converts this
configuration to the runtime contract.
This is independent of allowConfidentialClientRegistration: it neither
enables nor requires unauthenticated confidential dynamic client
registration. | | MaxItems: 10
Optional: \{\}
| +| `delegateClients` _[api.v1beta1.DelegateClientConfig](#apiv1beta1delegateclientconfig) array_ | DelegateClients configures pre-provisioned confidential clients for RFC 8693
token exchange. Each secret is referenced from a Kubernetes Secret; no
plaintext secret, redirect URI, or grant selection is accepted here. The
operator always supplies the token-exchange grant when it converts this
configuration to the runtime contract.
This is independent of allowConfidentialClientRegistration: it neither
enables nor requires unauthenticated confidential dynamic client
registration.
This legacy field is deprecated; use inboundGrants.tokenExchange.delegateClients. | | MaxItems: 10
Optional: \{\}
| | `trustedIssuers` _[api.v1beta1.TrustedIssuerConfig](#apiv1beta1trustedissuerconfig) array_ | TrustedIssuers configures external OIDC issuers whose tokens are
accepted as RFC 8693 subject tokens during token exchange, in addition
to self-issued subject tokens. Empty (the default) means only
self-issued subject tokens are accepted. See
docs/arch/17-token-exchange-delegation.md for the trust model. | | MaxItems: 20
Optional: \{\}
| | `forceConfidentialRedirectUris` _string array_ | ForceConfidentialRedirectURIs lists redirect URIs that must be
registered as confidential clients regardless of the
token_endpoint_auth_method the DCR request declares. A registration
whose redirectUris contains an EXACT match for one of these entries is
issued a real client_secret and reported back as
token_endpoint_auth_method "client_secret_post", even if the request
said "none" or omitted the field.
Intended for MCP clients that declare themselves public
(token_endpoint_auth_method: "none") per RFC 7591 but then refuse to
proceed because the response carries no client_secret — a
self-contradictory request. RFC 7591 §3.2.1 permits the server to
substitute client metadata, so this takes such a client at its word
that it wants a secret. Remove an entry once the client is fixed to
handle "none" registrations correctly.
Exact matching is deliberate: an attacker who registers with someone
else's callback URI is issued a secret for a client whose
authorization codes are delivered to that someone else's redirect
endpoint, not to the attacker, so this is not a way to obtain a usable
credential for another client.
Requires allowConfidentialClientRegistration to be true. Every entry
must be an https non-loopback URI — a loopback client is a public
client by construction (OAuth 2.1 §2.1) and must not be issued a
secret; this is enforced at reconcile time since CEL cannot express
the loopback-hostname check. | | MaxItems: 10
items:Pattern: `^https://[^\s?#]+$`
Optional: \{\}
| | `cimd` _[api.v1beta1.EmbeddedAuthServerCIMDConfig](#apiv1beta1embeddedauthservercimdconfig)_ | CIMD configures Client ID Metadata Document support. When omitted, CIMD is disabled. | | Optional: \{\}
| @@ -2305,6 +2307,23 @@ _Appears in:_ | `emailPath` _string_ | EmailPath is the dot-notation path to the email address field in the token response.
If not specified or if the path does not resolve to a string, the email is omitted.
Omit the field entirely rather than setting it to an empty string. | | MaxLength: 256
MinLength: 1
Optional: \{\}
| +#### api.v1beta1.InboundGrantsConfig + + + +InboundGrantsConfig groups canonical inbound OAuth grant-family configuration. + + + +_Appears in:_ +- [api.v1beta1.EmbeddedAuthServerConfig](#apiv1beta1embeddedauthserverconfig) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `tokenExchange` _[api.v1beta1.TokenExchangeInboundGrantConfig](#apiv1beta1tokenexchangeinboundgrantconfig)_ | TokenExchange configures RFC 8693 clients and issuer policies. | | Optional: \{\}
| +| `jwtBearer` _[api.v1beta1.JWTBearerInboundGrantConfig](#apiv1beta1jwtbearerinboundgrantconfig)_ | JWTBearer configures RFC 7523 issuer policies. | | Optional: \{\}
| + + #### api.v1beta1.IncomingAuthConfig @@ -2384,6 +2403,7 @@ one of that binding's allowed resources. _Appears in:_ +- [api.v1beta1.JWTBearerIssuerPolicyConfig](#apiv1beta1jwtbearerissuerpolicyconfig) - [api.v1beta1.TrustedIssuerConfig](#apiv1beta1trustedissuerconfig) | Field | Description | Default | Validation | @@ -2393,22 +2413,41 @@ _Appears in:_ | `acceptedAudiences` _string array_ | AcceptedAudiences identifies this authorization server's accepted
assertion audiences. When omitted, runtime validation defaults to the
token endpoint. | | MaxItems: 50
items:MaxLength: 2048
items:MinLength: 1
items:Pattern: `^https?://[^[:space:]]+$`
Optional: \{\}
| -#### api.v1beta1.JWTBearerSubjectBinding +#### api.v1beta1.JWTBearerInboundGrantConfig + +JWTBearerInboundGrantConfig configures canonical RFC 7523 inbound grants. -JWTBearerSubjectBinding configures the exact subject and allowed resources -for one RFC 7523 JWT-bearer assertion identity. + + +_Appears in:_ +- [api.v1beta1.InboundGrantsConfig](#apiv1beta1inboundgrantsconfig) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `issuerPolicies` _[api.v1beta1.JWTBearerIssuerPolicyConfig](#apiv1beta1jwtbearerissuerpolicyconfig) array_ | IssuerPolicies binds RFC 7523 policy to named trusted issuers. | | MaxItems: 20
Optional: \{\}
| + + +#### api.v1beta1.JWTBearerIssuerPolicyConfig + + + +JWTBearerIssuerPolicyConfig binds RFC 7523 policy to a named trusted issuer. _Appears in:_ -- [api.v1beta1.JWTBearerGrantConfig](#apiv1beta1jwtbearergrantconfig) +- [api.v1beta1.JWTBearerInboundGrantConfig](#apiv1beta1jwtbearerinboundgrantconfig) | Field | Description | Default | Validation | | --- | --- | --- | --- | -| `subject` _string_ | Subject is an exact assertion sub value. Wildcards are not supported. | | MaxLength: 256
MinLength: 1
Pattern: `^[^*]+$`
Required: \{\}
| -| `allowedResources` _string array_ | AllowedResources is the exact set of RFC 8707 resources this subject may
request. | | MaxItems: 50
MinItems: 1
Required: \{\}
items:MaxLength: 2048
items:MinLength: 1
items:Pattern: `^https?://[^[:space:]]+$`
| +| `issuerRef` _string_ | IssuerRef references trustedIssuers[].name. | | MaxLength: 253
MinLength: 1
| +| `maxAssertionAge` _[Duration](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#duration-v1-meta)_ | MaxAssertionAge caps the exp-iat interval independently of exp. | | Required: \{\}
| +| `subjectBindings` _[api.v1beta1.JWTBearerSubjectBinding](#apiv1beta1jwtbearersubjectbinding) array_ | SubjectBindings maps an exact external subject to allowed RFC 8707
resources. | | MaxItems: 50
MinItems: 1
Required: \{\}
| +| `acceptedAudiences` _string array_ | AcceptedAudiences identifies this authorization server's accepted
assertion audiences. When omitted, runtime validation defaults to the
token endpoint. | | MaxItems: 50
items:MaxLength: 2048
items:MinLength: 1
items:Pattern: `^https?://[^[:space:]]+$`
Optional: \{\}
| + + #### api.v1beta1.KubernetesServiceAccountOIDCConfig @@ -4248,6 +4287,45 @@ _Appears in:_ | `subjectProviderName` _string_ | SubjectProviderName is the name of the upstream provider whose token is used as the
RFC 8693 subject token instead of identity.Token when performing token exchange.
When left empty and an embedded authorization server is configured on the VirtualMCPServer,
the controller automatically populates this field with the first configured upstream
provider name. Set it explicitly to override that default or to select a specific
provider when multiple upstreams are configured. | | Optional: \{\}
| +#### api.v1beta1.TokenExchangeInboundGrantConfig + + + +TokenExchangeInboundGrantConfig configures canonical RFC 8693 inbound grants. + + + +_Appears in:_ +- [api.v1beta1.InboundGrantsConfig](#apiv1beta1inboundgrantsconfig) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `delegateClients` _[api.v1beta1.DelegateClientConfig](#apiv1beta1delegateclientconfig) array_ | DelegateClients configures pre-provisioned confidential clients. | | MaxItems: 10
Optional: \{\}
| +| `issuerPolicies` _[api.v1beta1.TokenExchangeIssuerPolicyConfig](#apiv1beta1tokenexchangeissuerpolicyconfig) array_ | IssuerPolicies binds RFC 8693 policy to named trusted issuers. | | MaxItems: 20
Optional: \{\}
| + + +#### api.v1beta1.TokenExchangeIssuerPolicyConfig + + + +TokenExchangeIssuerPolicyConfig binds RFC 8693 policy to a named trusted issuer. + + + +_Appears in:_ +- [api.v1beta1.TokenExchangeInboundGrantConfig](#apiv1beta1tokenexchangeinboundgrantconfig) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `issuerRef` _string_ | IssuerRef references trustedIssuers[].name. | | MaxLength: 253
MinLength: 1
| +| `expectedAudience` _string_ | ExpectedAudience is the required RFC 8693 subject-token audience. | | MaxLength: 2048
MinLength: 1
| +| `actorClaim` _string_ | ActorClaim names the claim containing the external actor identity. | | MaxLength: 64
Optional: \{\}
| +| `allowedActors` _string array_ | | | MaxItems: 50
Optional: \{\}
| +| `actorMatcher` _string_ | | | MaxLength: 4096
Optional: \{\}
| +| `allowedDelegateClients` _string array_ | | | MaxItems: 50
MinItems: 1
| +| `allowMayAct` _boolean_ | | | Optional: \{\}
| + + #### api.v1beta1.TokenLifespanConfig @@ -4366,25 +4444,9 @@ ToolRateLimitConfig defines rate limits for a specific tool. TrustedIssuerConfig configures an external OIDC issuer whose tokens are -accepted as RFC 8693 subject tokens or RFC 7523 JWT-bearer assertions during -token exchange. It mirrors tokenexchange.TrustedIssuer -(pkg/authserver/server/tokenexchange), the runtime type the operator converts -this into directly — no secret is referenced by this type, so no SecretKeyRef -indirection is needed, unlike DelegateClientConfig. - -expectedAudience is exempted only for a grant-only issuer: jwtBearerGrant -present and none of actorClaim, actorMatcher, allowMayAct, or allowedActors -set. Any RFC 8693 delegation field (actorClaim, actorMatcher, allowMayAct, -allowedActors) still requires expectedAudience, even when combined with -jwtBearerGrant. - -The allowedDelegateClients rule below mirrors validateDelegationPolicy -(pkg/authserver/server/tokenexchange/multi_issuer_validator.go): it is -keyed on whether ANY delegation field is set (expectedAudience, -actorClaim, actorMatcher, allowMayAct), not merely on whether -jwtBearerGrant is absent — an issuer can combine jwtBearerGrant with -expectedAudience for RFC 8693 delegation on the same issuer, and that -combination still requires allowedDelegateClients at the Go level. +accepted as RFC 8693 subject tokens or RFC 7523 JWT-bearer assertions. Trust +fields remain top-level; canonical grant policy references this declaration by +Name. The embedded grant-policy fields are retained for released CRD compatibility. @@ -4393,18 +4455,19 @@ _Appears in:_ | Field | Description | Default | Validation | | --- | --- | --- | --- | +| `name` _string_ | Name optionally identifies this trust declaration for canonical issuerRef references.
Names must be unique within trustedIssuers when set. | | MaxLength: 253
MinLength: 1
Optional: \{\}
| | `issuerUrl` _string_ | IssuerURL is the expected "iss" claim value (exact match). | | MaxLength: 2048
MinLength: 1
Required: \{\}
| -| `expectedAudience` _string_ | ExpectedAudience is the expected "aud" claim value that must appear in
an RFC 8693 subject token's audience list. It is not used by an RFC 7523
JWT-bearer assertion, whose audience is the token endpoint. | | MaxLength: 2048
MinLength: 1
Optional: \{\}
| +| `expectedAudience` _string_ | ExpectedAudience is the expected "aud" claim value that must appear in
an RFC 8693 subject token's audience list. It is not used by an RFC 7523
JWT-bearer assertion, whose audience is the token endpoint.
This legacy field is deprecated; configure RFC 8693 policy under
inboundGrants.tokenExchange.issuerPolicies. | | MaxLength: 2048
MinLength: 1
Optional: \{\}
| | `jwksUrl` _string_ | JWKSURL is the URL to fetch the issuer's JSON Web Key Set from. If
empty, it is resolved via OIDC discovery at
\{issuerUrl\}/.well-known/openid-configuration. | | MaxLength: 2048
Optional: \{\}
| | `insecureAllowHTTP` _boolean_ | InsecureAllowHTTP permits plain-HTTP OIDC discovery and JWKS fetches
for THIS issuer only. Development and testing only — never set in
production. | | Optional: \{\}
| | `allowPrivateIPs` _boolean_ | AllowPrivateIPs permits OIDC discovery and JWKS fetches for THIS issuer
to resolve to a private or loopback address. Use only when the issuer
is hosted inside the same cluster and has no public endpoint. Requires
jwksUrl to be set explicitly (enforced at reconcile time), since
otherwise OIDC discovery — fetched from the external issuer itself —
would choose the private dial target. | | Optional: \{\}
| | `caBundleRef` _[api.v1beta1.CABundleSource](#apiv1beta1cabundlesource)_ | CABundleRef references a ConfigMap containing PEM CA certificates used when
fetching this issuer's OIDC discovery document and JWKS. The bundle is added
to the system roots for this issuer's client only; public roots still apply
and other issuers are unaffected. Write access to the referenced ConfigMap is
equivalent to controlling this issuer's trust anchor for subject-token
validation — restrict it with the same care as a signing-key Secret. | | Optional: \{\}
| -| `actorClaim` _string_ | ActorClaim names the claim identifying the client that requested the
subject token from this external issuer (used by allowedActors below).
Defaults to "azp" when empty; use "appid" for Microsoft Entra v1, "cid"
for Okta. The special value "client_id" reads the subject token's
client_id claim instead. | | MaxLength: 64
Optional: \{\}
| -| `allowedActors` _string array_ | AllowedActors is the allowlist of actorClaim values authorized to
exchange a subject token from this issuer when it carries no
"may_act" claim, in addition to (not instead of) actorMatcher below —
either signal is sufficient. Empty denies every token unless
actorMatcher is set, or allowMayAct is true and the token carries a
permitted may_act claim. | | MaxItems: 50
items:MaxLength: 256
items:MinLength: 1
Optional: \{\}
| -| `actorMatcher` _string_ | ActorMatcher is an admin-authored CEL expression evaluated against the
subject token's complete signature-verified claims map (bound as
"claims") to authorize a class of external actors, in addition to (not
instead of) allowedActors — either signal is sufficient. Must evaluate
to a boolean; a non-boolean result denies the token at evaluation time,
not at reconcile time. A syntactically invalid expression fails
reconciliation (surfaced via the AuthServerConfigValidated condition),
not admission — there is no validating webhook for this field. | | MaxLength: 4096
Optional: \{\}
| -| `allowedDelegateClients` _string array_ | AllowedDelegateClients restricts which ToolHive client IDs may exchange
an RFC 8693 subject token from this issuer. Required unless only
jwtBearerGrant is configured; set it to ["*"] to permit any confidential
client holding the token-exchange grant, or list specific client IDs to
bind delegation to them. | | MaxItems: 50
MinItems: 1
items:MaxLength: 256
items:MinLength: 1
Optional: \{\}
| -| `allowMayAct` _boolean_ | AllowMayAct permits this external issuer's may_act claim to authorize
delegation. Defaults to false; external issuers must be opted in
explicitly because may_act bypasses allowedActors and actorMatcher.
Does not affect self-issued subject tokens. The wildcard is never
permitted alongside specific allowedDelegateClients, regardless of
this setting. | false | Optional: \{\}
| -| `jwtBearerGrant` _[api.v1beta1.JWTBearerGrantConfig](#apiv1beta1jwtbearergrantconfig)_ | JWTBearerGrant enables the plain RFC 7523 JWT-bearer grant for this
issuer. It is independent of RFC 8693 delegation policy. | | Optional: \{\}
| +| `actorClaim` _string_ | ActorClaim names the claim identifying the client that requested the
subject token from this external issuer (used by allowedActors below).
Defaults to "azp" when empty; use "appid" for Microsoft Entra v1, "cid"
for Okta. The special value "client_id" reads the subject token's
client_id claim instead.
This legacy field is deprecated; configure RFC 8693 policy under
inboundGrants.tokenExchange.issuerPolicies. | | MaxLength: 64
Optional: \{\}
| +| `allowedActors` _string array_ | AllowedActors is the allowlist of actorClaim values authorized to
exchange a subject token from this issuer when it carries no
"may_act" claim, in addition to (not instead of) actorMatcher below —
either signal is sufficient. Empty denies every token unless
actorMatcher is set, or allowMayAct is true and the token carries a
permitted may_act claim.
This legacy field is deprecated; configure RFC 8693 policy under
inboundGrants.tokenExchange.issuerPolicies. | | MaxItems: 50
items:MaxLength: 256
items:MinLength: 1
Optional: \{\}
| +| `actorMatcher` _string_ | ActorMatcher is an admin-authored CEL expression evaluated against the
subject token's complete signature-verified claims map (bound as
"claims") to authorize a class of external actors, in addition to (not
instead of) allowedActors — either signal is sufficient. Must evaluate
to a boolean; a non-boolean result denies the token at evaluation time,
not at reconcile time. A syntactically invalid expression fails
reconciliation (surfaced via the AuthServerConfigValidated condition),
not admission — there is no validating webhook for this field.
This legacy field is deprecated; configure RFC 8693 policy under
inboundGrants.tokenExchange.issuerPolicies. | | MaxLength: 4096
Optional: \{\}
| +| `allowedDelegateClients` _string array_ | AllowedDelegateClients restricts which ToolHive client IDs may exchange
an RFC 8693 subject token from this issuer. Required unless only
jwtBearerGrant is configured; set it to ["*"] to permit any confidential
client holding the token-exchange grant, or list specific client IDs to
bind delegation to them.
This legacy field is deprecated; configure RFC 8693 policy under
inboundGrants.tokenExchange.issuerPolicies. | | MaxItems: 50
MinItems: 1
items:MaxLength: 256
items:MinLength: 1
Optional: \{\}
| +| `allowMayAct` _boolean_ | AllowMayAct permits this external issuer's may_act claim to authorize
delegation. Defaults to false; external issuers must be opted in
explicitly because may_act bypasses allowedActors and actorMatcher.
Does not affect self-issued subject tokens. The wildcard is never
permitted alongside specific allowedDelegateClients, regardless of
this setting.
This legacy field is deprecated; configure RFC 8693 policy under
inboundGrants.tokenExchange.issuerPolicies. | false | Optional: \{\}
| +| `jwtBearerGrant` _[api.v1beta1.JWTBearerGrantConfig](#apiv1beta1jwtbearergrantconfig)_ | JWTBearerGrant enables the plain RFC 7523 JWT-bearer grant for this
issuer. It is independent of RFC 8693 delegation policy.
This legacy field is deprecated; configure RFC 7523 policy under
inboundGrants.jwtBearer.issuerPolicies. | | Optional: \{\}
| #### api.v1beta1.UpstreamInjectSpec From 56bb84b2dcc800765f403f190618c157343b6a5f Mon Sep 17 00:00:00 2001 From: Jakub Hrozek Date: Sun, 30 Aug 2026 15:59:08 +0200 Subject: [PATCH 5/7] Report deprecated inbound grant fields Operators need a visible migration signal before legacy grant fields can be removed safely. Record deprecated field paths during normalization and surface them as status conditions without changing the effective authorization policy. Refs #6200 --- .../v1beta1/mcpexternalauthconfig_types.go | 10 ++ .../api/v1beta1/virtualmcpserver_types.go | 12 ++ .../mcpexternalauthconfig_controller.go | 132 +++++++++++++++++- .../mcpexternalauthconfig_controller_test.go | 117 ++++++++++++++++ .../virtualmcpserver_controller.go | 60 ++++++-- .../virtualmcpserver_controller_test.go | 85 ++++++++++- .../pkg/virtualmcpserverstatus/collector.go | 9 +- pkg/authserver/runner/embeddedauthserver.go | 13 ++ .../runner/embeddedauthserver_test.go | 23 +++ 9 files changed, 439 insertions(+), 22 deletions(-) diff --git a/cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types.go b/cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types.go index 87842ca306..87fd90414c 100644 --- a/cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types.go +++ b/cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types.go @@ -1814,6 +1814,16 @@ const ( // declaration so a missing identity source is visible in // `kubectl describe` instead of only in proxyrunner logs. ConditionTypeIdentitySynthesized = "IdentitySynthesized" + + // ConditionTypeDeprecatedInboundGrantConfiguration reports whether released + // legacy inbound grant fields remain populated. + ConditionTypeDeprecatedInboundGrantConfiguration = "DeprecatedInboundGrantConfiguration" +) + +// Condition reasons for the deprecated inbound grant advisory. +const ( + ConditionReasonLegacyInboundGrantFields = "LegacyInboundGrantFields" + ConditionReasonCanonicalInboundGrantConfiguration = "CanonicalInboundGrantConfiguration" ) // Condition reasons for ConditionTypeIdentitySynthesized. diff --git a/cmd/thv-operator/api/v1beta1/virtualmcpserver_types.go b/cmd/thv-operator/api/v1beta1/virtualmcpserver_types.go index 699eb0d601..e66c3ef43d 100644 --- a/cmd/thv-operator/api/v1beta1/virtualmcpserver_types.go +++ b/cmd/thv-operator/api/v1beta1/virtualmcpserver_types.go @@ -356,6 +356,10 @@ const ( // ConditionTypeVirtualMCPServerTelemetryConfigRefValidated indicates whether the TelemetryConfigRef is valid ConditionTypeVirtualMCPServerTelemetryConfigRefValidated = "TelemetryConfigRefValidated" + + // ConditionTypeVirtualMCPServerDeprecatedInboundGrantConfiguration reports + // whether inline auth uses released legacy inbound grant fields. + ConditionTypeVirtualMCPServerDeprecatedInboundGrantConfiguration = "DeprecatedInboundGrantConfiguration" ) // Condition reasons for VirtualMCPServer @@ -423,6 +427,14 @@ const ( // ConditionReasonAuthServerConfigInvalid indicates the AuthServerConfig is invalid ConditionReasonAuthServerConfigInvalid = "AuthServerConfigInvalid" + // ConditionReasonVirtualMCPServerLegacyInboundGrantFields indicates that + // inline auth uses released legacy inbound grant fields. + ConditionReasonVirtualMCPServerLegacyInboundGrantFields = "LegacyInboundGrantFields" + + // ConditionReasonVirtualMCPServerCanonicalInboundGrantConfiguration indicates + // that inline auth uses only canonical inbound grant configuration. + ConditionReasonVirtualMCPServerCanonicalInboundGrantConfiguration = "CanonicalInboundGrantConfiguration" + // ConditionReasonAuthzRequiresUpstream indicates that authorization policies are // configured but no upstream IDP is available to source claims from. Without an // upstream, Cedar evaluates against the ToolHive-issued AS token, whose claim diff --git a/cmd/thv-operator/controllers/mcpexternalauthconfig_controller.go b/cmd/thv-operator/controllers/mcpexternalauthconfig_controller.go index b6655b7db5..26bc2b725c 100644 --- a/cmd/thv-operator/controllers/mcpexternalauthconfig_controller.go +++ b/cmd/thv-operator/controllers/mcpexternalauthconfig_controller.go @@ -7,6 +7,7 @@ import ( "context" stderrors "errors" "fmt" + "strings" "time" corev1 "k8s.io/api/core/v1" @@ -36,8 +37,17 @@ const ( // authServerRefKindMCPExternalAuthConfig is the Kind value on a TypedLocalObjectReference // that identifies the ref as pointing to an MCPExternalAuthConfig resource. authServerRefKindMCPExternalAuthConfig = "MCPExternalAuthConfig" + + // inboundGrantDeprecationEventReason is emitted when released legacy grant + // fields transition from absent to populated. + inboundGrantDeprecationEventReason = "InboundGrantsLegacyFieldsDeprecated" ) +type deprecatedInboundGrantField struct { + path string + replacement string +} + // MCPExternalAuthConfigReconciler reconciles a MCPExternalAuthConfig object type MCPExternalAuthConfigReconciler struct { client.Client @@ -108,9 +118,13 @@ func (r *MCPExternalAuthConfigReconciler) Reconcile(ctx context.Context, req ctr // in place, so the Warning fires only when entering the invalid state. wasInvalid := conditionStatusIs(externalAuthConfig.Status.Conditions, mcpv1beta1.ConditionTypeValid, metav1.ConditionFalse) + wasDeprecated := conditionStatusIs(externalAuthConfig.Status.Conditions, + mcpv1beta1.ConditionTypeDeprecatedInboundGrantConfiguration, metav1.ConditionTrue) updateErr := ctrlutil.MutateAndPatchStatus(ctx, r.Client, externalAuthConfig, func(c *mcpv1beta1.MCPExternalAuthConfig) { r.applyIdentitySynthesizedCondition(c) + r.applyDeprecatedInboundGrantCondition(c) + c.Status.ObservedGeneration = c.Generation meta.SetStatusCondition(&c.Status.Conditions, metav1.Condition{ Type: mcpv1beta1.ConditionTypeValid, Status: metav1.ConditionFalse, @@ -121,14 +135,18 @@ func (r *MCPExternalAuthConfigReconciler) Reconcile(ctx context.Context, req ctr }) if updateErr != nil { logger.Error(updateErr, "Failed to update status after validation error") + return ctrl.Result{}, updateErr } // Emit the Warning only on the transition into the invalid state, and // only once the condition persisted, so a failing status write does not // re-fire the event every reconcile. - if !wasInvalid && updateErr == nil { + if !wasInvalid { emitConfigEvent(r.Recorder, externalAuthConfig, corev1.EventTypeWarning, eventReasonConfigInvalid, eventActionValidate, "spec validation failed: %s", err.Error()) } + desiredDeprecated := conditionStatusIs(externalAuthConfig.Status.Conditions, + mcpv1beta1.ConditionTypeDeprecatedInboundGrantConfiguration, metav1.ConditionTrue) + emitInboundGrantDeprecationEvent(r.Recorder, externalAuthConfig, wasDeprecated, desiredDeprecated) return ctrl.Result{}, nil // Don't requeue on validation errors - user must fix spec } @@ -158,6 +176,99 @@ func (r *MCPExternalAuthConfigReconciler) Reconcile(ctx context.Context, req ctr return r.updateSteadyStateStatus(ctx, externalAuthConfig) } +func deprecatedInboundGrantFields(cfg *mcpv1beta1.EmbeddedAuthServerConfig, root string) []deprecatedInboundGrantField { + if cfg == nil { + return nil + } + fields := make([]deprecatedInboundGrantField, 0) + if len(cfg.DelegateClients) > 0 { + fields = append(fields, deprecatedInboundGrantField{ + path: root + ".delegateClients", replacement: root + ".inboundGrants.tokenExchange.delegateClients", + }) + } + for i, issuer := range cfg.TrustedIssuers { + issuerPath := fmt.Sprintf("%s.trustedIssuers[%d]", root, i) + tokenExchangeReplacement := root + ".inboundGrants.tokenExchange.issuerPolicies" + legacyTokenExchangeFields := []struct { + populated bool + name string + }{ + {issuer.ExpectedAudience != "", "expectedAudience"}, + {issuer.ActorClaim != "", "actorClaim"}, + {len(issuer.AllowedActors) > 0, "allowedActors"}, + {issuer.ActorMatcher != "", "actorMatcher"}, + {len(issuer.AllowedDelegateClients) > 0, "allowedDelegateClients"}, + {issuer.AllowMayAct, "allowMayAct"}, + } + for _, field := range legacyTokenExchangeFields { + if field.populated { + fields = append(fields, deprecatedInboundGrantField{ + path: issuerPath + "." + field.name, replacement: tokenExchangeReplacement, + }) + } + } + if issuer.JWTBearerGrant != nil { + fields = append(fields, deprecatedInboundGrantField{ + path: issuerPath + ".jwtBearerGrant", + replacement: root + ".inboundGrants.jwtBearer.issuerPolicies", + }) + } + } + return fields +} + +func deprecatedInboundGrantMessage(fields []deprecatedInboundGrantField) string { + paths := make([]string, len(fields)) + for i, field := range fields { + paths[i] = field.path + " -> " + field.replacement + } + return "Deprecated inbound grant fields are configured: " + strings.Join(paths, ", ") +} + +func setDeprecatedInboundGrantCondition( + conditions *[]metav1.Condition, + generation int64, + fields []deprecatedInboundGrantField, + conditionType, trueReason, falseReason string, +) { + condition := metav1.Condition{ + Type: conditionType, ObservedGeneration: generation, + Status: metav1.ConditionFalse, Reason: falseReason, + Message: "Only canonical inbound grant configuration is populated", + } + if len(fields) > 0 { + condition.Status = metav1.ConditionTrue + condition.Reason = trueReason + condition.Message = deprecatedInboundGrantMessage(fields) + } + meta.SetStatusCondition(conditions, condition) +} + +func (*MCPExternalAuthConfigReconciler) applyDeprecatedInboundGrantCondition( + cfg *mcpv1beta1.MCPExternalAuthConfig, +) { + setDeprecatedInboundGrantCondition( + &cfg.Status.Conditions, + cfg.Generation, + deprecatedInboundGrantFields(cfg.Spec.EmbeddedAuthServer, "spec.embeddedAuthServer"), + mcpv1beta1.ConditionTypeDeprecatedInboundGrantConfiguration, + mcpv1beta1.ConditionReasonLegacyInboundGrantFields, + mcpv1beta1.ConditionReasonCanonicalInboundGrantConfiguration, + ) +} + +func emitInboundGrantDeprecationEvent( + recorder events.EventRecorder, + obj runtime.Object, + wasDeprecated, desiredDeprecated bool, +) { + if recorder == nil || wasDeprecated || !desiredDeprecated { + return + } + recorder.Eventf(obj, nil, corev1.EventTypeWarning, inboundGrantDeprecationEventReason, "MigrateInboundGrants", + "Released legacy inbound grant fields are deprecated; see status condition for canonical replacement paths") +} + // setValidTrueAndSynthesized stamps ConditionTypeValid=True and refreshes the // IdentitySynthesized advisory on the supplied object. It is callable inside a // MutateAndPatchStatus closure: applyIdentitySynthesizedCondition is idempotent @@ -166,6 +277,8 @@ func (r *MCPExternalAuthConfigReconciler) Reconcile(ctx context.Context, req ctr // skips. func (r *MCPExternalAuthConfigReconciler) setValidTrueAndSynthesized(c *mcpv1beta1.MCPExternalAuthConfig) { r.applyIdentitySynthesizedCondition(c) + r.applyDeprecatedInboundGrantCondition(c) + c.Status.ObservedGeneration = c.Generation meta.SetStatusCondition(&c.Status.Conditions, metav1.Condition{ Type: mcpv1beta1.ConditionTypeValid, Status: metav1.ConditionTrue, @@ -292,6 +405,8 @@ func (r *MCPExternalAuthConfigReconciler) setInvalid( // the Warning fires only when entering the invalid state. wasInvalid := conditionStatusIs(fresh.Status.Conditions, mcpv1beta1.ConditionTypeValid, metav1.ConditionFalse) + wasDeprecated := conditionStatusIs(fresh.Status.Conditions, + mcpv1beta1.ConditionTypeDeprecatedInboundGrantConfiguration, metav1.ConditionTrue) if patchErr := ctrlutil.MutateAndPatchStatus(ctx, r.Client, fresh, func(c *mcpv1beta1.MCPExternalAuthConfig) { // applyIdentitySynthesizedCondition is idempotent on the same spec; // re-applying it inside the closure folds the advisory transition into @@ -299,6 +414,8 @@ func (r *MCPExternalAuthConfigReconciler) setInvalid( // TestMCPExternalAuthConfigReconciler_IdentitySynthesizedTransitionsOnValidationFailure // for the related validation-path regression guard. r.applyIdentitySynthesizedCondition(c) + r.applyDeprecatedInboundGrantCondition(c) + c.Status.ObservedGeneration = c.Generation meta.SetStatusCondition(&c.Status.Conditions, metav1.Condition{ Type: mcpv1beta1.ConditionTypeValid, Status: metav1.ConditionFalse, @@ -313,6 +430,9 @@ func (r *MCPExternalAuthConfigReconciler) setInvalid( emitConfigEvent(r.Recorder, fresh, corev1.EventTypeWarning, eventReasonConfigInvalid, eventActionValidate, "spec validation failed: %s", err.Error()) } + desiredDeprecated := conditionStatusIs(fresh.Status.Conditions, + mcpv1beta1.ConditionTypeDeprecatedInboundGrantConfiguration, metav1.ConditionTrue) + emitInboundGrantDeprecationEvent(r.Recorder, fresh, wasDeprecated, desiredDeprecated) return nil } @@ -331,6 +451,8 @@ func (r *MCPExternalAuthConfigReconciler) handleConfigHashChange( // place, so a single Normal event fires on the False->True transition. wasInvalid := conditionStatusIs(externalAuthConfig.Status.Conditions, mcpv1beta1.ConditionTypeValid, metav1.ConditionFalse) + wasDeprecated := conditionStatusIs(externalAuthConfig.Status.Conditions, + mcpv1beta1.ConditionTypeDeprecatedInboundGrantConfiguration, metav1.ConditionTrue) // Single status patch covering the hash-change success path: the new hash // and generation, and the Valid=True / IdentitySynthesized conditions. All @@ -346,6 +468,9 @@ func (r *MCPExternalAuthConfigReconciler) handleConfigHashChange( return ctrl.Result{}, err } emitConfigRecoveryEvent(r.Recorder, externalAuthConfig, wasInvalid) + desiredDeprecated := conditionStatusIs(externalAuthConfig.Status.Conditions, + mcpv1beta1.ConditionTypeDeprecatedInboundGrantConfiguration, metav1.ConditionTrue) + emitInboundGrantDeprecationEvent(r.Recorder, externalAuthConfig, wasDeprecated, desiredDeprecated) return ctrl.Result{}, nil } @@ -587,6 +712,8 @@ func (r *MCPExternalAuthConfigReconciler) updateSteadyStateStatus( // place, so a single Normal event fires on the False->True transition. wasInvalid := conditionStatusIs(externalAuthConfig.Status.Conditions, mcpv1beta1.ConditionTypeValid, metav1.ConditionFalse) + wasDeprecated := conditionStatusIs(externalAuthConfig.Status.Conditions, + mcpv1beta1.ConditionTypeDeprecatedInboundGrantConfiguration, metav1.ConditionTrue) if err := ctrlutil.MutateAndPatchStatus(ctx, r.Client, externalAuthConfig, func(c *mcpv1beta1.MCPExternalAuthConfig) { @@ -596,6 +723,9 @@ func (r *MCPExternalAuthConfigReconciler) updateSteadyStateStatus( return ctrl.Result{}, err } emitConfigRecoveryEvent(r.Recorder, externalAuthConfig, wasInvalid) + desiredDeprecated := conditionStatusIs(externalAuthConfig.Status.Conditions, + mcpv1beta1.ConditionTypeDeprecatedInboundGrantConfiguration, metav1.ConditionTrue) + emitInboundGrantDeprecationEvent(r.Recorder, externalAuthConfig, wasDeprecated, desiredDeprecated) return ctrl.Result{}, nil } diff --git a/cmd/thv-operator/controllers/mcpexternalauthconfig_controller_test.go b/cmd/thv-operator/controllers/mcpexternalauthconfig_controller_test.go index f99417b762..5c1ef74a00 100644 --- a/cmd/thv-operator/controllers/mcpexternalauthconfig_controller_test.go +++ b/cmd/thv-operator/controllers/mcpexternalauthconfig_controller_test.go @@ -16,8 +16,10 @@ import ( "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/tools/events" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/client/fake" + "sigs.k8s.io/controller-runtime/pkg/client/interceptor" "sigs.k8s.io/controller-runtime/pkg/reconcile" mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" @@ -1356,10 +1358,125 @@ func TestMCPExternalAuthConfigReconciler_OBO_ErrorTriageInReconcile(t *testing.T // // The concurrent-writer guarantee — that a condition written by a disjoint // owner between the reconciler's Get and its patch survives because +func TestMCPExternalAuthConfigReconciler_InvalidConfigReturnsStatusPatchError(t *testing.T) { + t.Parallel() + + patchErr := stderrors.New("injected status patch failure") + cfg := &mcpv1beta1.MCPExternalAuthConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "invalid-config", Namespace: "default", + Finalizers: []string{ExternalAuthConfigFinalizerName}, + }, + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{Type: mcpv1beta1.ExternalAuthTypeEmbeddedAuthServer}, + } + scheme := testutil.NewScheme(t) + fakeClient := withExternalAuthConfigRefIndexes(fake.NewClientBuilder().WithScheme(scheme)). + WithObjects(cfg). + WithStatusSubresource(&mcpv1beta1.MCPExternalAuthConfig{}). + WithInterceptorFuncs(interceptor.Funcs{ + SubResourcePatch: func( + _ context.Context, _ client.Client, subResource string, _ client.Object, + _ client.Patch, _ ...client.SubResourcePatchOption, + ) error { + assert.Equal(t, "status", subResource) + return patchErr + }, + }). + Build() + recorder := events.NewFakeRecorder(10) + r := &MCPExternalAuthConfigReconciler{Client: fakeClient, Scheme: scheme, Recorder: recorder} + + result, err := r.Reconcile(t.Context(), reconcile.Request{NamespacedName: client.ObjectKeyFromObject(cfg)}) + require.ErrorIs(t, err, patchErr) + assert.Zero(t, result) + assert.Empty(t, drainEvents(recorder), "events must wait until the invalid status transition persists") +} + // MutateAndPatchStatus sends a partial merge-patch rather than a full PUT — is // proven against the shared ctrlutil.MutateAndPatchStatus helper (used by all // three config controllers) in // TestMCPOIDCConfigReconciler_ConcurrentForeignConditionSurvivesMergePatch. +func TestMCPExternalAuthConfigReconciler_DeprecatedInboundGrantTransitions(t *testing.T) { + t.Parallel() + + delegate := mcpv1beta1.DelegateClientConfig{ + ClientID: "sensitive-client-id", + ClientSecretRef: &mcpv1beta1.SecretKeyRef{Name: "sensitive-secret", Key: "sensitive-token-key"}, + Scopes: []string{"openid"}, Audiences: []string{"https://api.example.com"}, + } + cfg := &mcpv1beta1.MCPExternalAuthConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "deprecated-grants", Namespace: "default", Generation: 7, + Finalizers: []string{ExternalAuthConfigFinalizerName}, + }, + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeEmbeddedAuthServer, + EmbeddedAuthServer: &mcpv1beta1.EmbeddedAuthServerConfig{ + Issuer: "https://auth.example.com", DelegateClients: []mcpv1beta1.DelegateClientConfig{delegate}, + }, + }, + } + r, fakeClient := newTestMCPExternalAuthConfigReconciler(t, cfg) + recorder := events.NewFakeRecorder(10) + r.Recorder = recorder + req := reconcile.Request{NamespacedName: types.NamespacedName{Name: cfg.Name, Namespace: cfg.Namespace}} + + reconcileAndGet := func() mcpv1beta1.MCPExternalAuthConfig { + t.Helper() + result, err := r.Reconcile(t.Context(), req) + require.NoError(t, err) + assert.Zero(t, result.RequeueAfter) + var got mcpv1beta1.MCPExternalAuthConfig + require.NoError(t, fakeClient.Get(t.Context(), req.NamespacedName, &got)) + return got + } + assertCondition := func(got mcpv1beta1.MCPExternalAuthConfig, status metav1.ConditionStatus, reason string, generation int64) { + t.Helper() + condition := meta.FindStatusCondition(got.Status.Conditions, + mcpv1beta1.ConditionTypeDeprecatedInboundGrantConfiguration) + require.NotNil(t, condition) + assert.Equal(t, status, condition.Status) + assert.Equal(t, reason, condition.Reason) + assert.Equal(t, generation, condition.ObservedGeneration) + assert.NotContains(t, condition.Message, "sensitive-client-id") + assert.NotContains(t, condition.Message, "sensitive-secret") + assert.NotContains(t, condition.Message, "sensitive-token-key") + } + + got := reconcileAndGet() + assertCondition(got, metav1.ConditionTrue, mcpv1beta1.ConditionReasonLegacyInboundGrantFields, 7) + assert.Contains(t, meta.FindStatusCondition(got.Status.Conditions, + mcpv1beta1.ConditionTypeDeprecatedInboundGrantConfiguration).Message, + "spec.embeddedAuthServer.delegateClients -> spec.embeddedAuthServer.inboundGrants.tokenExchange.delegateClients") + assert.Equal(t, 1, countContaining(drainEvents(recorder), inboundGrantDeprecationEventReason)) + + steadyResourceVersion := got.ResourceVersion + got = reconcileAndGet() + assert.Equal(t, steadyResourceVersion, got.ResourceVersion, "steady state must not write status") + assert.Zero(t, countContaining(drainEvents(recorder), inboundGrantDeprecationEventReason)) + + got.Spec.EmbeddedAuthServer.DelegateClients = nil + got.Spec.EmbeddedAuthServer.InboundGrants = &mcpv1beta1.InboundGrantsConfig{ + TokenExchange: &mcpv1beta1.TokenExchangeInboundGrantConfig{ + DelegateClients: []mcpv1beta1.DelegateClientConfig{delegate}, + }, + } + got.Generation = 8 + require.NoError(t, fakeClient.Update(t.Context(), &got)) + got = reconcileAndGet() + assertCondition(got, metav1.ConditionFalse, + mcpv1beta1.ConditionReasonCanonicalInboundGrantConfiguration, 8) + assert.Zero(t, countContaining(drainEvents(recorder), inboundGrantDeprecationEventReason)) + + got.Spec.EmbeddedAuthServer.InboundGrants = nil + got.Spec.EmbeddedAuthServer.DelegateClients = []mcpv1beta1.DelegateClientConfig{delegate} + got.Generation = 9 + require.NoError(t, fakeClient.Update(t.Context(), &got)) + got = reconcileAndGet() + assertCondition(got, metav1.ConditionTrue, mcpv1beta1.ConditionReasonLegacyInboundGrantFields, 9) + assert.Equal(t, 1, countContaining(drainEvents(recorder), inboundGrantDeprecationEventReason)) +} + func TestMCPExternalAuthConfigReconciler_ReconcileKeepsExistingForeignCondition(t *testing.T) { t.Parallel() diff --git a/cmd/thv-operator/controllers/virtualmcpserver_controller.go b/cmd/thv-operator/controllers/virtualmcpserver_controller.go index 1969de8e0c..ea9948ed70 100644 --- a/cmd/thv-operator/controllers/virtualmcpserver_controller.go +++ b/cmd/thv-operator/controllers/virtualmcpserver_controller.go @@ -248,6 +248,7 @@ func (r *VirtualMCPServerReconciler) Reconcile(ctx context.Context, req ctrl.Req // Create status manager for batched updates statusManager := virtualmcpserverstatus.NewStatusManager(vmcp) + r.applyInlineInboundGrantDeprecationCondition(vmcp, statusManager) // Run all pre-reconciliation validations. // Returns (true, nil) to continue, (false, nil) when validation failed but @@ -325,6 +326,33 @@ func (r *VirtualMCPServerReconciler) Reconcile(ctx context.Context, req ctrl.Req return ctrl.Result{}, nil } +func (*VirtualMCPServerReconciler) applyInlineInboundGrantDeprecationCondition( + vmcp *mcpv1beta1.VirtualMCPServer, + statusManager virtualmcpserverstatus.StatusManager, +) { + if vmcp.Spec.AuthServerConfig == nil { + statusManager.RemoveConditionsWithPrefix( + mcpv1beta1.ConditionTypeVirtualMCPServerDeprecatedInboundGrantConfiguration, nil) + return + } + fields := deprecatedInboundGrantFields(vmcp.Spec.AuthServerConfig, "spec.authServerConfig") + if len(fields) == 0 { + statusManager.SetCondition( + mcpv1beta1.ConditionTypeVirtualMCPServerDeprecatedInboundGrantConfiguration, + mcpv1beta1.ConditionReasonVirtualMCPServerCanonicalInboundGrantConfiguration, + "Only canonical inbound grant configuration is populated", + metav1.ConditionFalse, + ) + return + } + statusManager.SetCondition( + mcpv1beta1.ConditionTypeVirtualMCPServerDeprecatedInboundGrantConfiguration, + mcpv1beta1.ConditionReasonVirtualMCPServerLegacyInboundGrantFields, + deprecatedInboundGrantMessage(fields), + metav1.ConditionTrue, + ) +} + // validateSpec validates the VirtualMCPServer spec and updates status on error. // Returns an error if validation fails, which signals the caller to stop reconciliation. func (r *VirtualMCPServerReconciler) validateSpec( @@ -369,20 +397,24 @@ func (r *VirtualMCPServerReconciler) applyStatusUpdates( return fmt.Errorf("failed to get latest VirtualMCPServer: %w", err) } - // Apply collected changes to the latest status - hasUpdates := statusManager.UpdateStatus(ctx, &latest.Status) - - // Only update if there are changes - if hasUpdates { - if err := r.Status().Update(ctx, latest); err != nil { - // Handle conflicts by returning error to trigger requeue - if errors.IsConflict(err) { - ctxLogger.V(1).Info("Conflict updating status, will requeue") - return err - } - return fmt.Errorf("failed to update VirtualMCPServer status: %w", err) - } - ctxLogger.V(1).Info("Successfully applied batched status updates") + wasDeprecated := conditionStatusIs(latest.Status.Conditions, + mcpv1beta1.ConditionTypeVirtualMCPServerDeprecatedInboundGrantConfiguration, metav1.ConditionTrue) + becameDeprecated := false + if err := ctrlutil.MutateAndPatchStatus(ctx, r.Client, latest, + func(c *mcpv1beta1.VirtualMCPServer) { + statusManager.UpdateStatus(ctx, &c.Status) + becameDeprecated = conditionStatusIs(c.Status.Conditions, + mcpv1beta1.ConditionTypeVirtualMCPServerDeprecatedInboundGrantConfiguration, metav1.ConditionTrue) + }); err != nil { + if errors.IsConflict(err) { + ctxLogger.V(1).Info("Conflict updating status, will requeue") + } + return fmt.Errorf("failed to update VirtualMCPServer status: %w", err) + } + if !wasDeprecated && becameDeprecated && r.Recorder != nil { + r.Recorder.Eventf(latest, nil, corev1.EventTypeWarning, + inboundGrantDeprecationEventReason, "MigrateInboundGrants", + "Released legacy inbound grant fields are deprecated; see status condition for canonical replacement paths") } return nil diff --git a/cmd/thv-operator/controllers/virtualmcpserver_controller_test.go b/cmd/thv-operator/controllers/virtualmcpserver_controller_test.go index 67c5034f55..84aa27077a 100644 --- a/cmd/thv-operator/controllers/virtualmcpserver_controller_test.go +++ b/cmd/thv-operator/controllers/virtualmcpserver_controller_test.go @@ -4705,7 +4705,86 @@ func TestVirtualMCPServer_AuthServerConfigCABundleInvalidIsTerminal(t *testing.T assert.True(t, terminal, "malformed bundle content is terminal") } -// A ConfigMap read that fails for reasons unrelated to its content is transient: +func TestVirtualMCPServerInlineInboundGrantDeprecationTransitions(t *testing.T) { + t.Parallel() + + delegate := mcpv1beta1.DelegateClientConfig{ + ClientID: "sensitive-client-id", + ClientSecretRef: &mcpv1beta1.SecretKeyRef{Name: "sensitive-secret", Key: "sensitive-token-key"}, + Scopes: []string{"openid"}, Audiences: []string{"https://api.example.com"}, + } + vmcp := v1beta1test.NewVirtualMCPServer("deprecated-inline-grants", "default", + v1beta1test.MutateVMCP(func(v *mcpv1beta1.VirtualMCPServer) { + v.Generation = 4 + v.Spec.AuthServerConfig = &mcpv1beta1.EmbeddedAuthServerConfig{ + Issuer: "https://auth.example.com", DelegateClients: []mcpv1beta1.DelegateClientConfig{delegate}, + } + }), + ) + r, fakeClient := newTestVirtualMCPServerReconciler(t, vmcp) + recorder := events.NewFakeRecorder(10) + r.Recorder = recorder + key := types.NamespacedName{Name: vmcp.Name, Namespace: vmcp.Namespace} + + applyAndGet := func(current *mcpv1beta1.VirtualMCPServer) mcpv1beta1.VirtualMCPServer { + t.Helper() + manager := virtualmcpserverstatus.NewStatusManager(current) + r.applyInlineInboundGrantDeprecationCondition(current, manager) + manager.SetObservedGeneration(current.Generation) + require.NoError(t, r.applyStatusUpdates(t.Context(), current, manager)) + var got mcpv1beta1.VirtualMCPServer + require.NoError(t, fakeClient.Get(t.Context(), key, &got)) + return got + } + assertCondition := func(got mcpv1beta1.VirtualMCPServer, status metav1.ConditionStatus, reason string, generation int64) { + t.Helper() + condition := meta.FindStatusCondition(got.Status.Conditions, + mcpv1beta1.ConditionTypeVirtualMCPServerDeprecatedInboundGrantConfiguration) + require.NotNil(t, condition) + assert.Equal(t, status, condition.Status) + assert.Equal(t, reason, condition.Reason) + assert.Equal(t, generation, condition.ObservedGeneration) + assert.NotContains(t, condition.Message, "sensitive-client-id") + assert.NotContains(t, condition.Message, "sensitive-secret") + assert.NotContains(t, condition.Message, "sensitive-token-key") + } + + got := applyAndGet(vmcp) + assertCondition(got, metav1.ConditionTrue, + mcpv1beta1.ConditionReasonVirtualMCPServerLegacyInboundGrantFields, 4) + assert.Contains(t, meta.FindStatusCondition(got.Status.Conditions, + mcpv1beta1.ConditionTypeVirtualMCPServerDeprecatedInboundGrantConfiguration).Message, + "spec.authServerConfig.delegateClients -> spec.authServerConfig.inboundGrants.tokenExchange.delegateClients") + assert.Equal(t, 1, countContaining(drainEvents(recorder), inboundGrantDeprecationEventReason)) + + steadyResourceVersion := got.ResourceVersion + got = applyAndGet(&got) + assert.Equal(t, steadyResourceVersion, got.ResourceVersion, "steady state must not write status") + assert.Zero(t, countContaining(drainEvents(recorder), inboundGrantDeprecationEventReason)) + + got.Spec.AuthServerConfig.DelegateClients = nil + got.Spec.AuthServerConfig.InboundGrants = &mcpv1beta1.InboundGrantsConfig{ + TokenExchange: &mcpv1beta1.TokenExchangeInboundGrantConfig{ + DelegateClients: []mcpv1beta1.DelegateClientConfig{delegate}, + }, + } + got.Generation = 5 + require.NoError(t, fakeClient.Update(t.Context(), &got)) + got = applyAndGet(&got) + assertCondition(got, metav1.ConditionFalse, + mcpv1beta1.ConditionReasonVirtualMCPServerCanonicalInboundGrantConfiguration, 5) + assert.Zero(t, countContaining(drainEvents(recorder), inboundGrantDeprecationEventReason)) + + got.Spec.AuthServerConfig.InboundGrants = nil + got.Spec.AuthServerConfig.DelegateClients = []mcpv1beta1.DelegateClientConfig{delegate} + got.Generation = 6 + require.NoError(t, fakeClient.Update(t.Context(), &got)) + got = applyAndGet(&got) + assertCondition(got, metav1.ConditionTrue, + mcpv1beta1.ConditionReasonVirtualMCPServerLegacyInboundGrantFields, 6) + assert.Equal(t, 1, countContaining(drainEvents(recorder), inboundGrantDeprecationEventReason)) +} + // it must propagate so the caller requeues, and must not be painted onto status // as a spec defect that would outlive the outage. func TestVirtualMCPServer_AuthServerConfigCABundleGetErrorIsTransient(t *testing.T) { @@ -4767,8 +4846,8 @@ func TestVirtualMCPServer_RunAuthValidations_StatusWriteFailurePropagates(t *tes WithObjects(vmcp). WithStatusSubresource(&mcpv1beta1.VirtualMCPServer{}). WithInterceptorFuncs(interceptor.Funcs{ - SubResourceUpdate: func(_ context.Context, _ client.Client, _ string, _ client.Object, - _ ...client.SubResourceUpdateOption) error { + SubResourcePatch: func(_ context.Context, _ client.Client, _ string, _ client.Object, + _ client.Patch, _ ...client.SubResourcePatchOption) error { return apierrors.NewServiceUnavailable("apiserver is having a moment") }, }). diff --git a/cmd/thv-operator/pkg/virtualmcpserverstatus/collector.go b/cmd/thv-operator/pkg/virtualmcpserverstatus/collector.go index 14991d9da9..65493ef192 100644 --- a/cmd/thv-operator/pkg/virtualmcpserverstatus/collector.go +++ b/cmd/thv-operator/pkg/virtualmcpserverstatus/collector.go @@ -55,10 +55,11 @@ func (s *StatusCollector) SetMessage(message string) { // SetCondition sets a general condition with the specified type, reason, message, and status func (s *StatusCollector) SetCondition(conditionType, reason, message string, status metav1.ConditionStatus) { s.conditions[conditionType] = metav1.Condition{ - Type: conditionType, - Status: status, - Reason: reason, - Message: message, + Type: conditionType, + Status: status, + Reason: reason, + Message: message, + ObservedGeneration: s.vmcp.Generation, } s.hasChanges = true } diff --git a/pkg/authserver/runner/embeddedauthserver.go b/pkg/authserver/runner/embeddedauthserver.go index 3c677a4cd4..073162d8d4 100644 --- a/pkg/authserver/runner/embeddedauthserver.go +++ b/pkg/authserver/runner/embeddedauthserver.go @@ -12,6 +12,7 @@ import ( "net/http" "os" "slices" + "strings" "sync" "time" @@ -136,6 +137,17 @@ func NewEmbeddedAuthServerWithStorage( return newEmbeddedAuthServerWithStorage(ctx, cfg, stor, nil) } +func warnDeprecatedInboundGrantFields(fields []authserver.DeprecatedFieldPath) { + if len(fields) == 0 { + return + } + paths := make([]string, len(fields)) + for i, field := range fields { + paths[i] = field.Path + " -> " + field.Replacement + } + slog.Warn("deprecated inbound grant configuration; migrate to canonical fields", "paths", strings.Join(paths, ", ")) +} + func prepareInboundGrantConfiguration( cfg *authserver.RunConfig, delegateClients []authserver.DelegateClient, @@ -144,6 +156,7 @@ func prepareInboundGrantConfiguration( if err != nil { return nil, nil, nil, fmt.Errorf("normalize inbound grants: %w", err) } + warnDeprecatedInboundGrantFields(normalized.DeprecatedFields) if delegateClients == nil && len(normalized.DelegateClients) > 0 { delegateClients, err = resolveDelegateClients(normalized.DelegateClients) diff --git a/pkg/authserver/runner/embeddedauthserver_test.go b/pkg/authserver/runner/embeddedauthserver_test.go index 429dc482f6..f6d3dd691f 100644 --- a/pkg/authserver/runner/embeddedauthserver_test.go +++ b/pkg/authserver/runner/embeddedauthserver_test.go @@ -2721,6 +2721,29 @@ func (b *syncBuffer) String() string { return b.buf.String() } +//nolint:paralleltest // swaps the process-global slog default +func TestWarnDeprecatedInboundGrantFields(t *testing.T) { + var buf syncBuffer + previous := slog.Default() + slog.SetDefault(slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelWarn}))) + t.Cleanup(func() { slog.SetDefault(previous) }) + + warnDeprecatedInboundGrantFields(nil) + warnDeprecatedInboundGrantFields([]authserver.DeprecatedFieldPath{ + {Path: "delegate_clients", Replacement: "inbound_grants.token_exchange.delegate_clients"}, + {Path: "trusted_issuers[0].jwt_bearer_grant", Replacement: "inbound_grants.jwt_bearer.issuer_policies"}, + }) + + logged := buf.String() + assert.Equal(t, 1, strings.Count(logged, "level=WARN")) + assert.Contains(t, logged, "delegate_clients -> inbound_grants.token_exchange.delegate_clients") + assert.Contains(t, logged, + "trusted_issuers[0].jwt_bearer_grant -> inbound_grants.jwt_bearer.issuer_policies") + assert.NotContains(t, logged, "subject-value") + assert.NotContains(t, logged, "spiffe://") + assert.NotContains(t, logged, "secret-value") +} + // TestNewEmbeddedAuthServer_DeferredCleanupSanitizesLog pins the post-#5196 // invariant that the deferred-cleanup slog.Warn at the top of // NewEmbeddedAuthServerWithStorage routes both closeErr and retErr through From a12722ac0c9c7e6075ac7df062293e0684549958 Mon Sep 17 00:00:00 2001 From: Jakub Hrozek Date: Sun, 30 Aug 2026 14:31:30 +0200 Subject: [PATCH 6/7] Normalize canonical inbound grants The SPIFFE client-auth epic needs a place to configure SPIFFE association policy without inventing a parallel trust/grant path next to the existing delegate-client and trusted-issuer configuration. As more inbound grant families (RFC 8693 token exchange, RFC 7523 JWT-bearer, SPIFFE) accumulate, they need one canonical surface to configure and reason about instead of three independent ones, without breaking deployments that already rely on the legacy fields. Add pkg/authserver/inbound_grants.go with NormalizeInboundGrants, which reconciles a new canonical RunConfig.InboundGrants surface (per-family token_exchange/jwt_bearer sub-configs whose issuer_policies reference a trusted_issuers entry by name) against the legacy top-level delegate_clients and the RFC 8693/7523 fields embedded directly on trusted_issuers. Legacy and canonical configuration for the same grant family are mutually exclusive and rejected at validation time; the two families are otherwise independent, and omitting inbound_grants entirely preserves released behavior. Thread the normalized result through RunConfig.Validate, the embedded-auth-server runner, and buildProvider/discovery, adding a DisableTokenExchange capability so RFC 8693 registration and discovery advertisement can be turned off together and can't drift out of sync. Add TrustedIssuer.Name so canonical issuer_policies can reference an issuer without duplicating its fields. SPIFFE client authentication (InboundGrants.SPIFFEClientAuth, defined in the previous commit) is deliberately kept a sibling of TokenExchange and JWTBearer here, not nested under either: SPIFFE authenticates a client, it does not by itself grant it anything, so making it subordinate to RFC 8693 enablement would mean disabling token exchange silently drops every SPIFFE association, and every SPIFFE-authenticated client would be implicitly token-exchange-capable. It is validated and wired directly from RunConfig.InboundGrants in RunConfig.Validate/embeddedauthserver.go, independent of this file's legacy/canonical projection, so authentication method and grant-family enablement stay separately configurable. Update docs/arch/17-token-exchange-delegation.md for the new inbound_grants shape and the now-conditional token-exchange discovery advertisement, and add a runner-level test proving the canonical delegate-client, SPIFFE-client, and jwt_bearer paths reach a running server (the existing tests only covered normalization in isolation). SPIFFE client-auth associations always require the token-exchange grant (the only grant type they may declare), independent of the legacy/canonical token-exchange projection above: NormalizeInboundGrants now sets Capabilities.TokenExchange true whenever InboundGrants.SPIFFEClientAuth is non-empty, so a SPIFFE-only configuration cannot leave it false and silently disable the RFC 8693 grant handler server-wide -- which would reject every SPIFFE client's own token requests before authentication is even checked. Guarded by a regression test in this package (not just the runner-level test above) since the equivalent fix was previously lost during a rebase when its only coverage lived one package away. DCR (RFC 7591 /oauth/register) now rejects a registration whose effective grant types include token-exchange when it is disabled server-wide, instead of accepting the client and only failing later, confusingly, at /oauth/token. The check runs on the post-defaulting grant types validateGrantTypes already computes (a private_key_jwt client with an empty grant_types is implicitly token-exchange-only), so it catches both the explicit and implicit cases the same way scope validation already gates DCR on ScopesSupported. Corrected two stale doc references caught in review: the SPIFFE client-policy field path (inbound_grants.spiffe_client_auth, not nested under token_exchange) and the JWT-bearer legacy/canonical conflict wording (family-wide across all issuers, not per-issuer). Refs #6200 Signed-off-by: Jakub Hrozek From 1c698d7da002fd9652a38446b0b0bfea2b50570e Mon Sep 17 00:00:00 2001 From: Jakub Hrozek Date: Sat, 5 Sep 2026 19:34:51 +0200 Subject: [PATCH 7/7] Resolve canonical grant review findings Canonical inbound grants bypassed trusted-issuer and cross-surface validation, while the runtime and operator could replace the same status conditions array. Fold both grant surfaces before validation, scope deprecation reporting to embedded auth, and give runtime observations a dedicated status snapshot that the operator projects into compatibility fields. Signed-off-by: Jakub Hrozek --- .../v1beta1/mcpexternalauthconfig_types.go | 250 +++++++++-- .../mcpexternalauthconfig_types_test.go | 278 +++++++++++- .../api/v1beta1/virtualmcpserver_types.go | 34 ++ .../api/v1beta1/zz_generated.deepcopy.go | 34 ++ .../mcpexternalauthconfig_controller.go | 4 + .../mcpexternalauthconfig_controller_test.go | 108 +++++ .../virtualmcpserver_controller.go | 30 +- .../virtualmcpserver_controller_test.go | 161 +++++++ .../pkg/controllerutil/authserver.go | 6 + .../pkg/controllerutil/authserver_test.go | 36 ++ .../pkg/virtualmcpserverstatus/collector.go | 31 +- .../virtualmcpserverstatus/collector_test.go | 40 ++ .../confidential_client_transport_cel_test.go | 122 +++-- ...e.stacklok.dev_mcpexternalauthconfigs.yaml | 116 +++-- ...olhive.stacklok.dev_virtualmcpservers.yaml | 418 ++++++++++++++++-- ...e.stacklok.dev_mcpexternalauthconfigs.yaml | 116 +++-- ...olhive.stacklok.dev_virtualmcpservers.yaml | 418 ++++++++++++++++-- docs/arch/10-virtual-mcp-architecture.md | 3 +- docs/operator/crd-api.md | 37 +- pkg/authserver/config.go | 172 +------ pkg/authserver/config_test.go | 43 +- pkg/authserver/inbound_grants.go | 3 +- pkg/authserver/inbound_grants_test.go | 6 +- pkg/authserver/server/provider.go | 81 +++- pkg/authserver/server/provider_test.go | 97 ++++ .../server/tokenexchange/factory.go | 3 + .../server/tokenexchange/factory_test.go | 8 + .../tokenexchange/multi_issuer_validator.go | 95 ++-- .../multi_issuer_validator_test.go | 127 +++--- pkg/authserver/server_impl.go | 8 +- pkg/vmcp/status/k8s_reporter.go | 75 +--- pkg/vmcp/status/k8s_reporter_test.go | 94 ++-- 32 files changed, 2521 insertions(+), 533 deletions(-) diff --git a/cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types.go b/cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types.go index 87fd90414c..cfbef65e01 100644 --- a/cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types.go +++ b/cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types.go @@ -441,9 +441,9 @@ type TrustedIssuerConfig struct { // AllowPrivateIPs permits OIDC discovery and JWKS fetches for THIS issuer // to resolve to a private or loopback address. Use only when the issuer // is hosted inside the same cluster and has no public endpoint. Requires - // jwksUrl to be set explicitly (enforced at reconcile time), since - // otherwise OIDC discovery — fetched from the external issuer itself — - // would choose the private dial target. + // jwksUrl to be set explicitly (enforced at admission and by shared + // validation), since otherwise OIDC discovery — fetched from the external + // issuer itself — would choose the private dial target. // +optional AllowPrivateIPs bool `json:"allowPrivateIPs,omitempty"` @@ -613,6 +613,12 @@ type TokenExchangeInboundGrantConfig struct { } // TokenExchangeIssuerPolicyConfig binds RFC 8693 policy to a named trusted issuer. +// +// +kubebuilder:validation:XValidation:rule="!('*' in self.allowedDelegateClients) || size(self.allowedDelegateClients) == 1",message="allowedDelegateClients must not combine the wildcard \"*\" with specific client IDs" +// +kubebuilder:validation:XValidation:rule="!(self.allowMayAct && '*' in self.allowedDelegateClients)",message="allowMayAct must not be enabled when allowedDelegateClients contains the wildcard \"*\"" +// +kubebuilder:validation:XValidation:rule="!has(self.actorClaim) || !(self.actorClaim in ['sub', 'iss', 'aud', 'exp', 'iat', 'nbf', 'jti', 'name', 'email', 'scope', 'scp', 'may_act'])",message="actorClaim must name a readable claim; use client_id or a non-reserved claim such as azp, appid, or cid" +// +//nolint:lll // CEL validation rules exceed line length limits. type TokenExchangeIssuerPolicyConfig struct { // IssuerRef references trustedIssuers[].name. // +kubebuilder:validation:MinLength=1 @@ -630,6 +636,8 @@ type TokenExchangeIssuerPolicyConfig struct { ActorClaim string `json:"actorClaim,omitempty"` // +kubebuilder:validation:MaxItems=50 + // +kubebuilder:validation:items:MinLength=1 + // +kubebuilder:validation:items:MaxLength=256 // +listType=atomic // +optional AllowedActors []string `json:"allowedActors,omitempty"` @@ -640,6 +648,8 @@ type TokenExchangeIssuerPolicyConfig struct { // +kubebuilder:validation:MinItems=1 // +kubebuilder:validation:MaxItems=50 + // +kubebuilder:validation:items:MinLength=1 + // +kubebuilder:validation:items:MaxLength=256 // +listType=atomic AllowedDelegateClients []string `json:"allowedDelegateClients"` @@ -677,9 +687,9 @@ type JWTBearerIssuerPolicyConfig struct { // // +kubebuilder:validation:XValidation:rule="(has(self.upstreamProviders) && size(self.upstreamProviders) > 0) || (has(self.delegateClients) && size(self.delegateClients) > 0) || (has(self.trustedIssuers) && self.trustedIssuers.exists(issuer, has(issuer.jwtBearerGrant))) || (has(self.inboundGrants) && (has(self.inboundGrants.tokenExchange) || has(self.inboundGrants.jwtBearer)))",message="at least one upstream provider or inbound grant family is required" // -// +kubebuilder:validation:XValidation:rule="!(has(self.allowConfidentialClientRegistration) && self.allowConfidentialClientRegistration && has(self.insecureAllowHTTP) && self.insecureAllowHTTP)",message="allowConfidentialClientRegistration cannot be combined with insecureAllowHTTP; client secrets would be issued in cleartext over an unauthenticated endpoint" +// +kubebuilder:validation:XValidation:rule="!(has(self.insecureAllowHTTP) && self.insecureAllowHTTP && ((has(self.allowConfidentialClientRegistration) && self.allowConfidentialClientRegistration) || (has(self.delegateClients) && size(self.delegateClients) > 0) || (has(self.inboundGrants) && has(self.inboundGrants.tokenExchange) && has(self.inboundGrants.tokenExchange.delegateClients) && size(self.inboundGrants.tokenExchange.delegateClients) > 0)))",message="insecureAllowHTTP cannot be combined with confidential client registration or delegateClients; client secrets would be issued or used in cleartext over an unauthenticated endpoint" // +kubebuilder:validation:XValidation:rule="(!has(self.forceConfidentialRedirectUris) || size(self.forceConfidentialRedirectUris) == 0) || (has(self.allowConfidentialClientRegistration) && self.allowConfidentialClientRegistration)",message="forceConfidentialRedirectUris requires allowConfidentialClientRegistration to be true" -// +kubebuilder:validation:XValidation:rule="!has(self.delegateClients) || size(self.delegateClients) == 0 || !self.issuer.startsWith('http://') || (has(self.insecureAllowConfidentialOverLoopbackHTTP) && self.insecureAllowConfidentialOverLoopbackHTTP)",message="delegateClients with an HTTP issuer require insecureAllowConfidentialOverLoopbackHTTP to be explicitly enabled; the issuer must still be loopback" +// +kubebuilder:validation:XValidation:rule="!self.issuer.startsWith('http://') || (has(self.insecureAllowConfidentialOverLoopbackHTTP) && self.insecureAllowConfidentialOverLoopbackHTTP) || ((!has(self.allowConfidentialClientRegistration) || !self.allowConfidentialClientRegistration) && (!has(self.delegateClients) || size(self.delegateClients) == 0) && (!has(self.inboundGrants) || !has(self.inboundGrants.tokenExchange) || !has(self.inboundGrants.tokenExchange.delegateClients) || size(self.inboundGrants.tokenExchange.delegateClients) == 0))",message="confidential client registration or delegateClients with an HTTP issuer require insecureAllowConfidentialOverLoopbackHTTP to be explicitly enabled; the issuer must still be loopback" // +kubebuilder:validation:XValidation:rule="!has(self.inboundGrants) || !has(self.inboundGrants.tokenExchange) || ((!has(self.delegateClients) || size(self.delegateClients) == 0) && (!has(self.trustedIssuers) || !self.trustedIssuers.exists(issuer, has(issuer.expectedAudience) || has(issuer.actorClaim) || has(issuer.allowedActors) || has(issuer.actorMatcher) || has(issuer.allowedDelegateClients) || (has(issuer.allowMayAct) && issuer.allowMayAct))))",message="canonical tokenExchange conflicts with legacy delegateClients or RFC 8693 trusted issuer policy" // +kubebuilder:validation:XValidation:rule="!has(self.inboundGrants) || !has(self.inboundGrants.jwtBearer) || !has(self.trustedIssuers) || !self.trustedIssuers.exists(issuer, has(issuer.jwtBearerGrant))",message="canonical jwtBearer conflicts with legacy jwtBearerGrant" // +kubebuilder:validation:XValidation:rule="!has(self.trustedIssuers) || self.trustedIssuers.all(issuer, !has(issuer.name) || self.trustedIssuers.filter(other, has(other.name) && other.name == issuer.name).size() == 1)",message="trustedIssuers must not contain duplicate names" @@ -690,8 +700,9 @@ type JWTBearerIssuerPolicyConfig struct { // // The shared Go-level ValidateConfidentialClientTransport validator remains the // source of truth for confidential-client transport and loopback policy, -// including delegate clients. Full issuer URL validation is performed by the -// runtime configuration validator. +// including delegate clients. Trusted issuer endpoint shape is validated by +// ValidateInboundGrants; audience and outbound DNS/private-IP checks remain +// runtime-only. // //nolint:lll // CEL validation rules exceed line length limits. type EmbeddedAuthServerConfig struct { @@ -807,9 +818,10 @@ type EmbeddedAuthServerConfig struct { // as an operator condition. // // One combination is rejected at admission on all three CRDs regardless of the - // above: setting this field alongside allowConfidentialClientRegistration, which - // would issue client secrets in cleartext over an unauthenticated registration - // endpoint (see the XValidation rule on EmbeddedAuthServerConfig). + // above: setting this field alongside confidential client registration or + // delegate clients, which would issue or use client secrets in cleartext over + // an unauthenticated endpoint (see the XValidation rule on + // EmbeddedAuthServerConfig). // +kubebuilder:default=false // +optional InsecureAllowHTTP bool `json:"insecureAllowHTTP,omitempty"` @@ -952,21 +964,198 @@ type EmbeddedAuthServerConfig struct { CIMD *EmbeddedAuthServerCIMDConfig `json:"cimd,omitempty"` } -// ValidateConfidentialClientTransport rejects cleartext issuer configurations -// when confidential DCR or delegate clients are configured. Delegate clients -// do not enable DCR; they share its transport policy because they send a -// secret to the token endpoint. private_key_jwt registration is not gated -// here: it never returns a secret in the DCR response, so cleartext HTTP -// exposes nothing this check would protect. -func (c *EmbeddedAuthServerConfig) ValidateConfidentialClientTransport() error { - canonicalDelegate := c.InboundGrants != nil && c.InboundGrants.TokenExchange != nil && - len(c.InboundGrants.TokenExchange.DelegateClients) > 0 - return authserver.ValidateConfidentialClientTransport( - c.AllowConfidentialClientRegistration || len(c.DelegateClients) > 0 || canonicalDelegate, +// effectiveInboundGrants is the folded validation view of legacy and canonical +// inbound grant configuration. Its slice fields are cloned from the source +// EmbeddedAuthServerConfig when built, so mutating them cannot affect it. +type effectiveInboundGrants struct { + tokenExchange bool + jwtBearer bool + delegateClients []DelegateClientConfig + trustedIssuers []tokenexchange.TrustedIssuer +} + +// EffectiveGrants folds legacy and canonical inbound grants using the same +// conflict and overlay semantics as authserver.NormalizeInboundGrants. +func (c *EmbeddedAuthServerConfig) EffectiveGrants() (effectiveInboundGrants, error) { + if err := c.validateJWTBearerMaxAssertionAges(); err != nil { + return effectiveInboundGrants{}, err + } + result := effectiveInboundGrants{ + tokenExchange: c.InboundGrants == nil, + delegateClients: cloneDelegateClientConfigs(c.DelegateClients), + trustedIssuers: buildTrustedIssuerConfigs(c.TrustedIssuers), + } + issuerByName, legacyTokenExchange, legacyJWTBearer, err := c.indexEffectiveTrustedIssuers(result.trustedIssuers) + if err != nil { + return effectiveInboundGrants{}, err + } + result.jwtBearer = legacyJWTBearer + if c.InboundGrants == nil { + return result, nil + } + if err := applyEffectiveTokenExchange(&result, c.InboundGrants.TokenExchange, issuerByName, legacyTokenExchange); err != nil { + return effectiveInboundGrants{}, err + } + if err := applyEffectiveJWTBearer(&result, c.InboundGrants.JWTBearer, issuerByName, legacyJWTBearer); err != nil { + return effectiveInboundGrants{}, err + } + return result, nil +} + +func (c *EmbeddedAuthServerConfig) validateJWTBearerMaxAssertionAges() error { + for i, issuer := range c.TrustedIssuers { + if issuer.JWTBearerGrant != nil && issuer.JWTBearerGrant.MaxAssertionAge == nil { + return fmt.Errorf("trustedIssuers[%d].jwtBearerGrant.maxAssertionAge is required", i) + } + } + if c.InboundGrants == nil || c.InboundGrants.JWTBearer == nil { + return nil + } + for i, policy := range c.InboundGrants.JWTBearer.IssuerPolicies { + if policy.MaxAssertionAge == nil { + return fmt.Errorf("inboundGrants.jwtBearer.issuerPolicies[%d].maxAssertionAge is required", i) + } + } + return nil +} + +func (c *EmbeddedAuthServerConfig) indexEffectiveTrustedIssuers( + issuers []tokenexchange.TrustedIssuer, +) (map[string]int, bool, bool, error) { + byName := make(map[string]int, len(issuers)) + byURL := make(map[string]int, len(issuers)) + legacyTokenExchange := len(c.DelegateClients) > 0 + legacyJWTBearer := false + for i, issuer := range issuers { + if previous, ok := byURL[issuer.IssuerURL]; ok { + return nil, false, false, fmt.Errorf( + "trustedIssuers[%d].issuerUrl duplicates trustedIssuers[%d].issuerUrl", i, previous) + } + byURL[issuer.IssuerURL] = i + if issuer.Name != "" { + if previous, ok := byName[issuer.Name]; ok { + return nil, false, false, fmt.Errorf( + "trustedIssuers[%d].name duplicates trustedIssuers[%d].name %q", i, previous, issuer.Name) + } + byName[issuer.Name] = i + } + legacyTokenExchange = legacyTokenExchange || hasLegacyTokenExchangePolicy(c.TrustedIssuers[i]) + legacyJWTBearer = legacyJWTBearer || issuer.JWTBearerGrant != nil + } + return byName, legacyTokenExchange, legacyJWTBearer, nil +} + +func applyEffectiveTokenExchange( + result *effectiveInboundGrants, + config *TokenExchangeInboundGrantConfig, + issuerByName map[string]int, + legacy bool, +) error { + if config == nil { + result.tokenExchange = legacy + return nil + } + if legacy { + return fmt.Errorf("inboundGrants.tokenExchange conflicts with legacy delegateClients or RFC 8693 policy in trustedIssuers") + } + result.tokenExchange = true + result.delegateClients = cloneDelegateClientConfigs(config.DelegateClients) + seen := make(map[string]int, len(config.IssuerPolicies)) + for i, policy := range config.IssuerPolicies { + issuerIndex, err := resolveEffectiveIssuerRef(issuerByName, seen, policy.IssuerRef, + fmt.Sprintf("inboundGrants.tokenExchange.issuerPolicies[%d]", i)) + if err != nil { + return err + } + seen[policy.IssuerRef] = i + issuer := &result.trustedIssuers[issuerIndex] + issuer.ExpectedAudience = policy.ExpectedAudience + issuer.ActorClaim = policy.ActorClaim + issuer.AllowedActors = slices.Clone(policy.AllowedActors) + issuer.ActorMatcher = policy.ActorMatcher + issuer.AllowedDelegateClients = slices.Clone(policy.AllowedDelegateClients) + issuer.AllowMayAct = policy.AllowMayAct + } + return nil +} + +func applyEffectiveJWTBearer( + result *effectiveInboundGrants, + config *JWTBearerInboundGrantConfig, + issuerByName map[string]int, + legacy bool, +) error { + if config == nil { + return nil + } + if legacy { + return fmt.Errorf("inboundGrants.jwtBearer conflicts with legacy trustedIssuers[*].jwtBearerGrant") + } + result.jwtBearer = true + seen := make(map[string]int, len(config.IssuerPolicies)) + for i, policy := range config.IssuerPolicies { + issuerIndex, err := resolveEffectiveIssuerRef(issuerByName, seen, policy.IssuerRef, + fmt.Sprintf("inboundGrants.jwtBearer.issuerPolicies[%d]", i)) + if err != nil { + return err + } + seen[policy.IssuerRef] = i + result.trustedIssuers[issuerIndex].JWTBearerGrant = buildJWTBearerGrantPolicy(&policy.JWTBearerGrantConfig) + } + return nil +} + +// ValidateInboundGrants validates the effective grant projection shared by +// MCPExternalAuthConfig and inline VirtualMCPServer auth configuration. +func (c *EmbeddedAuthServerConfig) ValidateInboundGrants() error { + grants, err := c.EffectiveGrants() + if err != nil { + return err + } + if err := authserver.ValidateConfidentialClientTransport( + c.AllowConfidentialClientRegistration || len(grants.delegateClients) > 0, c.InsecureAllowHTTP, c.Issuer, c.InsecureAllowConfidentialOverLoopbackHTTP, - ) + ); err != nil { + return err + } + if err := tokenexchange.ValidateTrustedIssuers(grants.trustedIssuers, c.Issuer, nil); err != nil { + return fmt.Errorf("trustedIssuers: %w", err) + } + return nil +} + +func hasLegacyTokenExchangePolicy(issuer TrustedIssuerConfig) bool { + return issuer.ExpectedAudience != "" || issuer.ActorClaim != "" || len(issuer.AllowedActors) > 0 || + issuer.ActorMatcher != "" || len(issuer.AllowedDelegateClients) > 0 || issuer.AllowMayAct +} + +func resolveEffectiveIssuerRef(byName, seen map[string]int, ref, path string) (int, error) { + if ref == "" { + return 0, fmt.Errorf("%s.issuerRef is required", path) + } + if previous, ok := seen[ref]; ok { + return 0, fmt.Errorf("%s.issuerRef duplicates issuer policy [%d] for %q", path, previous, ref) + } + index, ok := byName[ref] + if !ok { + return 0, fmt.Errorf("%s.issuerRef references unknown or unnamed trusted issuer %q", path, ref) + } + return index, nil +} + +func cloneDelegateClientConfigs(clients []DelegateClientConfig) []DelegateClientConfig { + cloned := slices.Clone(clients) + for i := range cloned { + cloned[i].Scopes = slices.Clone(cloned[i].Scopes) + cloned[i].Audiences = slices.Clone(cloned[i].Audiences) + if cloned[i].ClientSecretRef != nil { + secretRef := *cloned[i].ClientSecretRef + cloned[i].ClientSecretRef = &secretRef + } + } + return cloned } // TokenLifespanConfig holds configuration for token lifetimes. @@ -2128,9 +2317,10 @@ func (r *MCPExternalAuthConfig) validateEmbeddedAuthServer() error { // (MCPServer, MCPRemoteProxy) enforce single-upstream restrictions; // VirtualMCPServer allows multiple upstreams. - // Defense-in-depth with the shared runtime policy. This checks confidential - // DCR and statically declared delegate clients for unsafe HTTP issuers. - if err := cfg.ValidateConfidentialClientTransport(); err != nil { + // Defense-in-depth with the shared runtime policy. This folds legacy and + // canonical grant declarations before checking confidential transport and + // trusted issuer policy. + if err := cfg.ValidateInboundGrants(); err != nil { return err } @@ -2145,16 +2335,6 @@ func (r *MCPExternalAuthConfig) validateEmbeddedAuthServer() error { return err } - // allowedAudiences is intentionally nil here: it is derived later from the - // resolved incoming OIDC config (see deriveAllowedAudiences), not - // available on this CRD. The same accepted_audiences/allowed_audiences - // disjointness check runs again once that value exists, at - // Config.Validate time (pkg/authserver/config.go's validateTrustedIssuers). - if cfg.InboundGrants == nil { - if err := tokenexchange.ValidateTrustedIssuers(buildTrustedIssuerConfigs(cfg.TrustedIssuers), cfg.Issuer, nil); err != nil { - return fmt.Errorf("trustedIssuers: %w", err) - } - } for i := range cfg.TrustedIssuers { if err := validateUpstreamCABundleRef(cfg.TrustedIssuers[i].CABundleRef); err != nil { return fmt.Errorf("trustedIssuers[%d] (%q) caBundleRef: %w", i, cfg.TrustedIssuers[i].IssuerURL, err) diff --git a/cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types_test.go b/cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types_test.go index 00ec4652c6..9d2a16b74c 100644 --- a/cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types_test.go +++ b/cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types_test.go @@ -466,6 +466,156 @@ func TestMCPExternalAuthConfig_Validate(t *testing.T) { } } +func TestEmbeddedAuthServerConfig_EffectiveGrants(t *testing.T) { + t.Parallel() + + jwtPolicy := JWTBearerGrantConfig{ + MaxAssertionAge: &metav1.Duration{Duration: time.Minute}, + SubjectBindings: []JWTBearerSubjectBinding{{ + Subject: "workload", AllowedResources: []string{"https://mcp.example.com"}, + }}, + } + t.Run("nil canonical config keeps legacy default", func(t *testing.T) { + t.Parallel() + grants, err := (&EmbeddedAuthServerConfig{}).EffectiveGrants() + require.NoError(t, err) + assert.True(t, grants.tokenExchange) + assert.False(t, grants.jwtBearer) + }) + t.Run("empty canonical config disables implicit token exchange", func(t *testing.T) { + t.Parallel() + grants, err := (&EmbeddedAuthServerConfig{InboundGrants: &InboundGrantsConfig{}}).EffectiveGrants() + require.NoError(t, err) + assert.False(t, grants.tokenExchange) + assert.False(t, grants.jwtBearer) + }) + t.Run("canonical policies overlay copied issuers and clients", func(t *testing.T) { + t.Parallel() + cfg := &EmbeddedAuthServerConfig{ + TrustedIssuers: []TrustedIssuerConfig{{Name: "external", IssuerURL: "https://issuer.example.com"}}, + InboundGrants: &InboundGrantsConfig{ + TokenExchange: &TokenExchangeInboundGrantConfig{ + DelegateClients: []DelegateClientConfig{{ + ClientID: "canonical", ClientSecretRef: &SecretKeyRef{Name: "secret", Key: "key"}, + Scopes: []string{"openid"}, Audiences: []string{"https://mcp.example.com"}, + }}, + IssuerPolicies: []TokenExchangeIssuerPolicyConfig{{ + IssuerRef: "external", ExpectedAudience: "audience", ActorClaim: "azp", + AllowedActors: []string{"actor"}, AllowedDelegateClients: []string{"canonical"}, + }}, + }, + JWTBearer: &JWTBearerInboundGrantConfig{IssuerPolicies: []JWTBearerIssuerPolicyConfig{{ + IssuerRef: "external", JWTBearerGrantConfig: jwtPolicy, + }}}, + }, + } + grants, err := cfg.EffectiveGrants() + require.NoError(t, err) + assert.True(t, grants.tokenExchange) + assert.True(t, grants.jwtBearer) + clients := grants.delegateClients + require.Len(t, clients, 1) + assert.Equal(t, "canonical", clients[0].ClientID) + issuers := grants.trustedIssuers + require.Len(t, issuers, 1) + assert.Equal(t, "audience", issuers[0].ExpectedAudience) + require.NotNil(t, issuers[0].JWTBearerGrant) + + clients[0].Scopes[0] = "changed" + clients[0].ClientSecretRef.Name = "changed" + issuers[0].AllowedActors[0] = "changed" + issuers[0].JWTBearerGrant.SubjectBindings[0].AllowedResources[0] = "https://changed.example.com" + assert.Equal(t, "openid", cfg.InboundGrants.TokenExchange.DelegateClients[0].Scopes[0]) + assert.Equal(t, "secret", cfg.InboundGrants.TokenExchange.DelegateClients[0].ClientSecretRef.Name) + assert.Equal(t, "actor", cfg.InboundGrants.TokenExchange.IssuerPolicies[0].AllowedActors[0]) + assert.Equal(t, "https://mcp.example.com", + cfg.InboundGrants.JWTBearer.IssuerPolicies[0].SubjectBindings[0].AllowedResources[0]) + }) + t.Run("legacy token exchange and canonical JWT bearer can coexist", func(t *testing.T) { + t.Parallel() + cfg := &EmbeddedAuthServerConfig{ + TrustedIssuers: []TrustedIssuerConfig{{ + Name: "external", IssuerURL: "https://issuer.example.com", ExpectedAudience: "audience", + AllowedDelegateClients: []string{"legacy"}, + }}, + InboundGrants: &InboundGrantsConfig{JWTBearer: &JWTBearerInboundGrantConfig{ + IssuerPolicies: []JWTBearerIssuerPolicyConfig{{IssuerRef: "external", JWTBearerGrantConfig: jwtPolicy}}, + }}, + } + grants, err := cfg.EffectiveGrants() + require.NoError(t, err) + assert.True(t, grants.tokenExchange) + assert.True(t, grants.jwtBearer) + }) + + conflicts := []struct { + name string + cfg EmbeddedAuthServerConfig + want string + }{ + { + name: "canonical and legacy token exchange", + cfg: EmbeddedAuthServerConfig{ + DelegateClients: []DelegateClientConfig{{ClientID: "legacy"}}, + InboundGrants: &InboundGrantsConfig{TokenExchange: &TokenExchangeInboundGrantConfig{}}, + }, + want: "tokenExchange conflicts", + }, + { + name: "canonical and legacy JWT bearer", + cfg: EmbeddedAuthServerConfig{ + TrustedIssuers: []TrustedIssuerConfig{{ + Name: "external", IssuerURL: "https://issuer.example.com", JWTBearerGrant: &jwtPolicy, + }}, + InboundGrants: &InboundGrantsConfig{JWTBearer: &JWTBearerInboundGrantConfig{}}, + }, + want: "jwtBearer conflicts", + }, + { + name: "unknown issuer reference", + cfg: EmbeddedAuthServerConfig{ + InboundGrants: &InboundGrantsConfig{TokenExchange: &TokenExchangeInboundGrantConfig{ + IssuerPolicies: []TokenExchangeIssuerPolicyConfig{{IssuerRef: "missing"}}, + }}, + }, + want: "unknown or unnamed trusted issuer", + }, + { + name: "duplicate issuer reference", + cfg: EmbeddedAuthServerConfig{ + TrustedIssuers: []TrustedIssuerConfig{{Name: "external", IssuerURL: "https://issuer.example.com"}}, + InboundGrants: &InboundGrantsConfig{TokenExchange: &TokenExchangeInboundGrantConfig{ + IssuerPolicies: []TokenExchangeIssuerPolicyConfig{{IssuerRef: "external"}, {IssuerRef: "external"}}, + }}, + }, + want: "duplicates issuer policy", + }, + { + name: "duplicate issuer name", + cfg: EmbeddedAuthServerConfig{TrustedIssuers: []TrustedIssuerConfig{ + {Name: "external", IssuerURL: "https://one.example.com"}, + {Name: "external", IssuerURL: "https://two.example.com"}, + }}, + want: "name duplicates", + }, + { + name: "duplicate issuer URL", + cfg: EmbeddedAuthServerConfig{TrustedIssuers: []TrustedIssuerConfig{ + {Name: "one", IssuerURL: "https://issuer.example.com"}, + {Name: "two", IssuerURL: "https://issuer.example.com"}, + }}, + want: "issuerUrl duplicates", + }, + } + for _, tt := range conflicts { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + _, err := tt.cfg.EffectiveGrants() + require.ErrorContains(t, err, tt.want) + }) + } +} + func TestMCPExternalAuthConfig_validateEmbeddedAuthServer(t *testing.T) { t.Parallel() @@ -796,6 +946,130 @@ func TestMCPExternalAuthConfig_validateEmbeddedAuthServer(t *testing.T) { } } +func TestMCPExternalAuthConfig_ValidateCanonicalTrustedIssuers(t *testing.T) { + t.Parallel() + + newConfig := func(issuerURL string) *MCPExternalAuthConfig { + return &MCPExternalAuthConfig{Spec: MCPExternalAuthConfigSpec{ + Type: ExternalAuthTypeEmbeddedAuthServer, + EmbeddedAuthServer: &EmbeddedAuthServerConfig{ + Issuer: "https://auth.example.com", + TrustedIssuers: []TrustedIssuerConfig{{Name: "external", IssuerURL: issuerURL}}, + InboundGrants: &InboundGrantsConfig{TokenExchange: &TokenExchangeInboundGrantConfig{ + IssuerPolicies: []TokenExchangeIssuerPolicyConfig{{ + IssuerRef: "external", ExpectedAudience: "audience", AllowedDelegateClients: []string{"delegate"}, + }}, + }}, + }, + }} + } + + require.NoError(t, newConfig("https://issuer.example.com").validateEmbeddedAuthServer()) + err := newConfig("https://auth.example.com").validateEmbeddedAuthServer() + require.ErrorContains(t, err, "must not equal the authorization server's own issuer") +} + +func TestEmbeddedAuthServerConfig_ValidateInboundGrants_TrustedIssuerEndpoints(t *testing.T) { + t.Parallel() + + newConfig := func(canonical bool, issuerURL, jwksURL string, allowPrivateIPs bool) EmbeddedAuthServerConfig { + config := EmbeddedAuthServerConfig{ + Issuer: "https://auth.example.com", + TrustedIssuers: []TrustedIssuerConfig{{ + Name: "external", IssuerURL: issuerURL, JWKSURL: jwksURL, AllowPrivateIPs: allowPrivateIPs, + }}, + } + if canonical { + config.InboundGrants = &InboundGrantsConfig{TokenExchange: &TokenExchangeInboundGrantConfig{ + IssuerPolicies: []TokenExchangeIssuerPolicyConfig{{ + IssuerRef: "external", ExpectedAudience: "audience", AllowedDelegateClients: []string{"delegate"}, + }}, + }} + return config + } + config.TrustedIssuers[0].ExpectedAudience = "audience" + config.TrustedIssuers[0].AllowedDelegateClients = []string{"delegate"} + return config + } + + tests := []struct { + name string + canonical bool + issuerURL string + jwksURL string + allowPrivateIPs bool + wantErr string + }{ + {name: "legacy HTTPS endpoints accepted", issuerURL: "https://issuer.example.com", jwksURL: "https://issuer.example.com/keys"}, + {name: "canonical HTTPS endpoints accepted", canonical: true, issuerURL: "https://issuer.example.com", jwksURL: "https://issuer.example.com/keys"}, + {name: "legacy issuer endpoint failure identifies entry", issuerURL: "http://issuer.example.com", wantErr: "scheme must be https"}, + {name: "canonical issuer endpoint failure identifies entry", canonical: true, issuerURL: "http://issuer.example.com", wantErr: "scheme must be https"}, + {name: "legacy issuer empty hostname with port rejected", issuerURL: "https://:443", wantErr: "host is required"}, + {name: "canonical issuer empty hostname with port rejected", canonical: true, issuerURL: "https://:443", wantErr: "host is required"}, + {name: "legacy JWKS endpoint failure identifies entry", issuerURL: "https://issuer.example.com", jwksURL: "ftp://issuer.example.com/keys", wantErr: "jwks_url:"}, + {name: "canonical JWKS endpoint failure identifies entry", canonical: true, issuerURL: "https://issuer.example.com", jwksURL: "ftp://issuer.example.com/keys", wantErr: "jwks_url:"}, + {name: "legacy JWKS empty hostname with port rejected", issuerURL: "https://issuer.example.com", jwksURL: "https://:443/keys", wantErr: "jwks_url: host is required"}, + {name: "canonical JWKS empty hostname with port rejected", canonical: true, issuerURL: "https://issuer.example.com", jwksURL: "https://:443/keys", wantErr: "jwks_url: host is required"}, + {name: "legacy private IP JWKS rejected without opt in", issuerURL: "https://issuer.example.com", jwksURL: "https://10.0.0.5/keys", wantErr: "jwks_url:"}, + {name: "canonical private IP JWKS rejected without opt in", canonical: true, issuerURL: "https://issuer.example.com", jwksURL: "https://10.0.0.5/keys", wantErr: "jwks_url:"}, + {name: "legacy private IP JWKS accepted with opt in", issuerURL: "https://issuer.example.com", jwksURL: "https://10.0.0.5/keys", allowPrivateIPs: true}, + {name: "canonical private IP JWKS accepted with opt in", canonical: true, issuerURL: "https://issuer.example.com", jwksURL: "https://10.0.0.5/keys", allowPrivateIPs: true}, + {name: "private IP opt in requires explicit JWKS URL", canonical: true, issuerURL: "https://issuer.example.com", allowPrivateIPs: true, wantErr: "allow_private_ips requires jwks_url"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + config := newConfig(tt.canonical, tt.issuerURL, tt.jwksURL, tt.allowPrivateIPs) + err := config.ValidateInboundGrants() + if tt.wantErr == "" { + require.NoError(t, err) + return + } + require.ErrorContains(t, err, tt.wantErr) + }) + } +} + +func TestEmbeddedAuthServerConfig_ValidateInboundGrants_DuplicateCredentialIssuerRedaction(t *testing.T) { + t.Parallel() + + const credentialIssuerURL = "https://sentinel-user:sentinel-password@issuer.example.com" + newConfig := func(canonical bool) EmbeddedAuthServerConfig { + config := EmbeddedAuthServerConfig{ + Issuer: "https://auth.example.com", + TrustedIssuers: []TrustedIssuerConfig{ + {Name: "external", IssuerURL: credentialIssuerURL}, + {Name: "duplicate", IssuerURL: credentialIssuerURL}, + }, + } + if canonical { + config.InboundGrants = &InboundGrantsConfig{TokenExchange: &TokenExchangeInboundGrantConfig{ + IssuerPolicies: []TokenExchangeIssuerPolicyConfig{{ + IssuerRef: "external", ExpectedAudience: "audience", AllowedDelegateClients: []string{"delegate"}, + }}, + }} + } + return config + } + + for _, canonical := range []bool{false, true} { + name := "legacy" + if canonical { + name = "canonical" + } + t.Run(name, func(t *testing.T) { + t.Parallel() + config := newConfig(canonical) + err := config.ValidateInboundGrants() + require.ErrorContains(t, err, "duplicates") + assert.NotContains(t, err.Error(), credentialIssuerURL) + assert.NotContains(t, err.Error(), "sentinel-user") + assert.NotContains(t, err.Error(), "sentinel-password") + }) + } +} + func TestMCPExternalAuthConfig_ZeroUpstreamAlternatives(t *testing.T) { t.Parallel() @@ -1352,7 +1626,7 @@ func TestDelegateClientConfig_JSON(t *testing.T) { assert.False(t, existing.AllowPrivateKeyJWTRegistration) } -func TestEmbeddedAuthServerConfig_ValidateConfidentialClientTransport(t *testing.T) { +func TestEmbeddedAuthServerConfig_ValidateInboundGrants_ConfidentialClientTransport(t *testing.T) { t.Parallel() delegateClients := []DelegateClientConfig{{ @@ -1455,7 +1729,7 @@ func TestEmbeddedAuthServerConfig_ValidateConfidentialClientTransport(t *testing t.Run(tt.name, func(t *testing.T) { t.Parallel() - err := tt.config.ValidateConfidentialClientTransport() + err := tt.config.ValidateInboundGrants() if tt.expectErr { require.Error(t, err) return diff --git a/cmd/thv-operator/api/v1beta1/virtualmcpserver_types.go b/cmd/thv-operator/api/v1beta1/virtualmcpserver_types.go index e66c3ef43d..80ddb2f6dc 100644 --- a/cmd/thv-operator/api/v1beta1/virtualmcpserver_types.go +++ b/cmd/thv-operator/api/v1beta1/virtualmcpserver_types.go @@ -255,8 +255,42 @@ const ( // +gendoc type DiscoveredBackend = vmcptypes.DiscoveredBackend +// VirtualMCPServerRuntimeStatus is the runtime-owned status snapshot. The +// operator projects this snapshot into the top-level compatibility fields and +// remains the sole writer of the top-level Conditions array. +type VirtualMCPServerRuntimeStatus struct { + // Phase is the lifecycle phase observed by the running vMCP process. + // +optional + Phase VirtualMCPServerPhase `json:"phase,omitempty"` + + // Message provides detail about the runtime phase. + // +optional + Message string `json:"message,omitempty"` + + // Conditions contains runtime health observations. + // +listType=map + // +listMapKey=type + // +optional + Conditions []metav1.Condition `json:"conditions,omitempty"` + + // DiscoveredBackends contains the runtime's latest backend observations. + // +listType=map + // +listMapKey=name + // +optional + DiscoveredBackends []DiscoveredBackend `json:"discoveredBackends,omitempty"` + + // BackendCount is the number of routable backends observed by the runtime. + // +optional + BackendCount int32 `json:"backendCount,omitempty"` +} + // VirtualMCPServerStatus defines the observed state of VirtualMCPServer type VirtualMCPServerStatus struct { + // Runtime is the status snapshot written exclusively by the vMCP process. + // The operator projects it into the top-level compatibility fields. + // +optional + Runtime *VirtualMCPServerRuntimeStatus `json:"runtime,omitempty"` + // Conditions represent the latest available observations of the VirtualMCPServer's state // +listType=map // +listMapKey=type diff --git a/cmd/thv-operator/api/v1beta1/zz_generated.deepcopy.go b/cmd/thv-operator/api/v1beta1/zz_generated.deepcopy.go index f8171bf525..6fe5c96a88 100644 --- a/cmd/thv-operator/api/v1beta1/zz_generated.deepcopy.go +++ b/cmd/thv-operator/api/v1beta1/zz_generated.deepcopy.go @@ -3457,6 +3457,35 @@ func (in *VirtualMCPServerList) DeepCopyObject() runtime.Object { return nil } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *VirtualMCPServerRuntimeStatus) DeepCopyInto(out *VirtualMCPServerRuntimeStatus) { + *out = *in + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]v1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.DiscoveredBackends != nil { + in, out := &in.DiscoveredBackends, &out.DiscoveredBackends + *out = make([]DiscoveredBackend, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new VirtualMCPServerRuntimeStatus. +func (in *VirtualMCPServerRuntimeStatus) DeepCopy() *VirtualMCPServerRuntimeStatus { + if in == nil { + return nil + } + out := new(VirtualMCPServerRuntimeStatus) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *VirtualMCPServerSpec) DeepCopyInto(out *VirtualMCPServerSpec) { *out = *in @@ -3536,6 +3565,11 @@ func (in *VirtualMCPServerSpec) DeepCopy() *VirtualMCPServerSpec { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *VirtualMCPServerStatus) DeepCopyInto(out *VirtualMCPServerStatus) { *out = *in + if in.Runtime != nil { + in, out := &in.Runtime, &out.Runtime + *out = new(VirtualMCPServerRuntimeStatus) + (*in).DeepCopyInto(*out) + } if in.Conditions != nil { in, out := &in.Conditions, &out.Conditions *out = make([]v1.Condition, len(*in)) diff --git a/cmd/thv-operator/controllers/mcpexternalauthconfig_controller.go b/cmd/thv-operator/controllers/mcpexternalauthconfig_controller.go index 26bc2b725c..f566a58cff 100644 --- a/cmd/thv-operator/controllers/mcpexternalauthconfig_controller.go +++ b/cmd/thv-operator/controllers/mcpexternalauthconfig_controller.go @@ -247,6 +247,10 @@ func setDeprecatedInboundGrantCondition( func (*MCPExternalAuthConfigReconciler) applyDeprecatedInboundGrantCondition( cfg *mcpv1beta1.MCPExternalAuthConfig, ) { + if cfg.Spec.Type != mcpv1beta1.ExternalAuthTypeEmbeddedAuthServer || cfg.Spec.EmbeddedAuthServer == nil { + meta.RemoveStatusCondition(&cfg.Status.Conditions, mcpv1beta1.ConditionTypeDeprecatedInboundGrantConfiguration) + return + } setDeprecatedInboundGrantCondition( &cfg.Status.Conditions, cfg.Generation, diff --git a/cmd/thv-operator/controllers/mcpexternalauthconfig_controller_test.go b/cmd/thv-operator/controllers/mcpexternalauthconfig_controller_test.go index 5c1ef74a00..cdd9864400 100644 --- a/cmd/thv-operator/controllers/mcpexternalauthconfig_controller_test.go +++ b/cmd/thv-operator/controllers/mcpexternalauthconfig_controller_test.go @@ -1396,6 +1396,104 @@ func TestMCPExternalAuthConfigReconciler_InvalidConfigReturnsStatusPatchError(t // proven against the shared ctrlutil.MutateAndPatchStatus helper (used by all // three config controllers) in // TestMCPOIDCConfigReconciler_ConcurrentForeignConditionSurvivesMergePatch. +func TestMCPExternalAuthConfigReconciler_CanonicalTrustedIssuerEndpointValidationOnCreateAndUpdate(t *testing.T) { + t.Parallel() + + const unsafeJWKSURL = "https://sentinel-user:sentinel-password@issuer.example.com/keys" + + cfg := &mcpv1beta1.MCPExternalAuthConfig{ + ObjectMeta: metav1.ObjectMeta{Name: "canonical-validation", Namespace: "default"}, + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeEmbeddedAuthServer, + EmbeddedAuthServer: &mcpv1beta1.EmbeddedAuthServerConfig{ + Issuer: "https://auth.example.com", + SigningKeySecretRefs: []mcpv1beta1.SecretKeyRef{{Name: "signing-key", Key: "private.pem"}}, + TrustedIssuers: []mcpv1beta1.TrustedIssuerConfig{{ + Name: "external", IssuerURL: "https://issuer.example.com", JWKSURL: unsafeJWKSURL, + }}, + InboundGrants: &mcpv1beta1.InboundGrantsConfig{ + TokenExchange: &mcpv1beta1.TokenExchangeInboundGrantConfig{ + IssuerPolicies: []mcpv1beta1.TokenExchangeIssuerPolicyConfig{{ + IssuerRef: "external", ExpectedAudience: "audience", + AllowedDelegateClients: []string{"delegate"}, + }}, + }, + }, + }, + }, + } + r, fakeClient := newTestMCPExternalAuthConfigReconciler(t, cfg) + req := reconcile.Request{NamespacedName: client.ObjectKeyFromObject(cfg)} + + result, err := r.Reconcile(t.Context(), req) + require.NoError(t, err) + if result.RequeueAfter > 0 { + _, err = r.Reconcile(t.Context(), req) + require.NoError(t, err) + } + var got mcpv1beta1.MCPExternalAuthConfig + require.NoError(t, fakeClient.Get(t.Context(), req.NamespacedName, &got)) + valid := findCondition(got.Status.Conditions, mcpv1beta1.ConditionTypeValid) + require.NotNil(t, valid) + assert.Equal(t, metav1.ConditionFalse, valid.Status) + assert.Contains(t, valid.Message, "jwks_url: must not contain userinfo") + assert.NotContains(t, valid.Message, unsafeJWKSURL) + assert.NotContains(t, valid.Message, "sentinel-user") + assert.NotContains(t, valid.Message, "sentinel-password") + + got.Spec.EmbeddedAuthServer.TrustedIssuers[0].JWKSURL = "https://issuer.example.com/keys" + require.NoError(t, fakeClient.Update(t.Context(), &got)) + _, err = r.Reconcile(t.Context(), req) + require.NoError(t, err) + require.NoError(t, fakeClient.Get(t.Context(), req.NamespacedName, &got)) + valid = findCondition(got.Status.Conditions, mcpv1beta1.ConditionTypeValid) + require.NotNil(t, valid) + assert.Equal(t, metav1.ConditionTrue, valid.Status) +} + +func TestMCPExternalAuthConfigReconciler_DuplicateCredentialIssuerStatusIsRedacted(t *testing.T) { + t.Parallel() + + const credentialIssuerURL = "https://sentinel-user:sentinel-password@issuer.example.com" + cfg := &mcpv1beta1.MCPExternalAuthConfig{ + ObjectMeta: metav1.ObjectMeta{Name: "duplicate-credential-issuer", Namespace: "default"}, + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeEmbeddedAuthServer, + EmbeddedAuthServer: &mcpv1beta1.EmbeddedAuthServerConfig{ + Issuer: "https://auth.example.com", + SigningKeySecretRefs: []mcpv1beta1.SecretKeyRef{{Name: "signing-key", Key: "private.pem"}}, + TrustedIssuers: []mcpv1beta1.TrustedIssuerConfig{ + {Name: "external", IssuerURL: credentialIssuerURL}, + {Name: "duplicate", IssuerURL: credentialIssuerURL}, + }, + InboundGrants: &mcpv1beta1.InboundGrantsConfig{TokenExchange: &mcpv1beta1.TokenExchangeInboundGrantConfig{ + IssuerPolicies: []mcpv1beta1.TokenExchangeIssuerPolicyConfig{{ + IssuerRef: "external", ExpectedAudience: "audience", AllowedDelegateClients: []string{"delegate"}, + }}, + }}, + }, + }, + } + r, fakeClient := newTestMCPExternalAuthConfigReconciler(t, cfg) + req := reconcile.Request{NamespacedName: client.ObjectKeyFromObject(cfg)} + + result, err := r.Reconcile(t.Context(), req) + require.NoError(t, err) + if result.RequeueAfter > 0 { + _, err = r.Reconcile(t.Context(), req) + require.NoError(t, err) + } + var got mcpv1beta1.MCPExternalAuthConfig + require.NoError(t, fakeClient.Get(t.Context(), req.NamespacedName, &got)) + valid := findCondition(got.Status.Conditions, mcpv1beta1.ConditionTypeValid) + require.NotNil(t, valid) + assert.Equal(t, metav1.ConditionFalse, valid.Status) + assert.Contains(t, valid.Message, "duplicates") + assert.NotContains(t, valid.Message, credentialIssuerURL) + assert.NotContains(t, valid.Message, "sentinel-user") + assert.NotContains(t, valid.Message, "sentinel-password") +} + func TestMCPExternalAuthConfigReconciler_DeprecatedInboundGrantTransitions(t *testing.T) { t.Parallel() @@ -1475,6 +1573,16 @@ func TestMCPExternalAuthConfigReconciler_DeprecatedInboundGrantTransitions(t *te got = reconcileAndGet() assertCondition(got, metav1.ConditionTrue, mcpv1beta1.ConditionReasonLegacyInboundGrantFields, 9) assert.Equal(t, 1, countContaining(drainEvents(recorder), inboundGrantDeprecationEventReason)) + + got.Spec.Type = mcpv1beta1.ExternalAuthTypeTokenExchange + got.Spec.EmbeddedAuthServer = nil + got.Spec.TokenExchange = &mcpv1beta1.TokenExchangeConfig{TokenURL: "https://issuer.example.com/token"} + got.Generation = 10 + require.NoError(t, fakeClient.Update(t.Context(), &got)) + got = reconcileAndGet() + assert.Nil(t, meta.FindStatusCondition(got.Status.Conditions, + mcpv1beta1.ConditionTypeDeprecatedInboundGrantConfiguration)) + assert.Zero(t, countContaining(drainEvents(recorder), inboundGrantDeprecationEventReason)) } func TestMCPExternalAuthConfigReconciler_ReconcileKeepsExistingForeignCondition(t *testing.T) { diff --git a/cmd/thv-operator/controllers/virtualmcpserver_controller.go b/cmd/thv-operator/controllers/virtualmcpserver_controller.go index ea9948ed70..d6edc978a5 100644 --- a/cmd/thv-operator/controllers/virtualmcpserver_controller.go +++ b/cmd/thv-operator/controllers/virtualmcpserver_controller.go @@ -628,7 +628,7 @@ func (*VirtualMCPServerReconciler) validateAuthServerConfig( return stderrors.New(message) } - if err := cfg.ValidateConfidentialClientTransport(); err != nil { + if err := cfg.ValidateInboundGrants(); err != nil { message := fmt.Sprintf("spec.authServerConfig: %v", err) statusManager.SetPhase(mcpv1beta1.VirtualMCPServerPhaseFailed) statusManager.SetMessage(message) @@ -2193,6 +2193,19 @@ func countBackendHealth(ctx context.Context, backends []mcpv1beta1.DiscoveredBac return routable, unhealthy } +// runtimeDiscoveredBackends returns the freshest backend observations available on vmcp. +// Status.Runtime is the vMCP process's own snapshot; the top-level DiscoveredBackends is +// only a projection of it that the operator writes once per reconcile, so a vmcp fetched +// mid-reconcile can have a current Runtime snapshot alongside a top-level field still +// reflecting the previous reconcile's patch. Preferring Runtime keeps phase decisions +// from acting on that stale projection. +func runtimeDiscoveredBackends(vmcp *mcpv1beta1.VirtualMCPServer) []mcpv1beta1.DiscoveredBackend { + if vmcp.Status.Runtime != nil { + return vmcp.Status.Runtime.DiscoveredBackends + } + return vmcp.Status.DiscoveredBackends +} + // determineStatusFromBackends evaluates backend health to determine status func (*VirtualMCPServerReconciler) determineStatusFromBackends( ctx context.Context, @@ -2200,7 +2213,8 @@ func (*VirtualMCPServerReconciler) determineStatusFromBackends( ) statusDecision { ctxLogger := log.FromContext(ctx) - routable, unhealthy := countBackendHealth(ctx, vmcp.Status.DiscoveredBackends) + backends := runtimeDiscoveredBackends(vmcp) + routable, unhealthy := countBackendHealth(ctx, backends) total := routable + unhealthy // All backends unhealthy @@ -2238,7 +2252,7 @@ func (*VirtualMCPServerReconciler) determineStatusFromBackends( // Edge case: backends exist but none counted ctxLogger.V(1).Info("No backends were counted, treating as degraded", - "discoveredBackendsCount", len(vmcp.Status.DiscoveredBackends)) + "discoveredBackendsCount", len(backends)) return statusDecision{ phase: mcpv1beta1.VirtualMCPServerPhaseDegraded, message: "Virtual MCP server is running but backend status cannot be determined", @@ -2283,7 +2297,7 @@ func (r *VirtualMCPServerReconciler) determineStatusFromPods( } // Pods are ready (passed readiness probes) - check backend health if backends exist - if len(vmcp.Status.DiscoveredBackends) == 0 { + if len(runtimeDiscoveredBackends(vmcp)) == 0 { // No backends discovered yet - pods ready is sufficient for Ready return statusDecision{ phase: mcpv1beta1.VirtualMCPServerPhaseReady, @@ -2345,7 +2359,13 @@ func (r *VirtualMCPServerReconciler) updateVirtualMCPServerStatus( // Determine status in one place (no branching/repetition) decision := r.determineStatusFromPods(ctx, vmcp, ready, pending, failed) - // Apply all status updates at once + // Apply all status updates at once. + // + // SetReadyCondition here deliberately overwrites the runtime's own Ready condition + // (reason AllBackendsRoutable, projected from Status.Runtime). The operator's + // decision already folds in the runtime's backend health via + // determineStatusFromBackends, plus pod/deployment readiness the runtime cannot + // observe, so it is the more complete verdict and stays authoritative for Ready. statusManager.SetPhase(decision.phase) statusManager.SetMessage(decision.message) statusManager.SetReadyCondition(decision.reason, decision.conditionMsg, decision.conditionState) diff --git a/cmd/thv-operator/controllers/virtualmcpserver_controller_test.go b/cmd/thv-operator/controllers/virtualmcpserver_controller_test.go index 84aa27077a..b5a242d4a4 100644 --- a/cmd/thv-operator/controllers/virtualmcpserver_controller_test.go +++ b/cmd/thv-operator/controllers/virtualmcpserver_controller_test.go @@ -926,6 +926,47 @@ func TestVirtualMCPServerUpdateStatus(t *testing.T) { }, expectedPhase: mcpv1beta1.VirtualMCPServerPhaseFailed, }, + { + // Regression for NEW-1: the runtime snapshot (Status.Runtime) is the + // freshest backend data, but the top-level DiscoveredBackends is only a + // projection the operator writes once per reconcile. A vmcp fetched + // mid-reconcile can carry a current Runtime snapshot (all unhealthy) + // alongside a stale top-level field (all ready) from the previous + // reconcile's patch. The phase decision must follow Runtime, not the + // stale projection, within this same reconcile. + name: "runtime snapshot overrides stale top-level backend field", + vmcp: func() *mcpv1beta1.VirtualMCPServer { + v := v1beta1test.NewVirtualMCPServer(testVmcpName, "default") + v.Status.DiscoveredBackends = []mcpv1beta1.DiscoveredBackend{ + {Name: "backend", Status: mcpv1beta1.BackendStatusReady}, + } + v.Status.Runtime = &mcpv1beta1.VirtualMCPServerRuntimeStatus{ + DiscoveredBackends: []mcpv1beta1.DiscoveredBackend{ + {Name: "backend", Status: mcpv1beta1.BackendStatusUnavailable}, + }, + } + return v + }(), + pods: []corev1.Pod{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: testVmcpName + "-pod-1", + Namespace: "default", + Labels: labelsForVirtualMCPServer(testVmcpName), + }, + Status: corev1.PodStatus{ + Phase: corev1.PodRunning, + Conditions: []corev1.PodCondition{ + { + Type: corev1.PodReady, + Status: corev1.ConditionTrue, + }, + }, + }, + }, + }, + expectedPhase: mcpv1beta1.VirtualMCPServerPhaseDegraded, + }, } for _, tt := range tests { @@ -949,6 +990,53 @@ func TestVirtualMCPServerUpdateStatus(t *testing.T) { } } +// TestVirtualMCPServerUpdateStatus_ReadyConditionOwnership locks in that the operator's +// derived Ready condition (reason DeploymentReady) wins over the runtime's own Ready +// condition (reason AllBackendsRoutable) projected from Status.Runtime. This is +// deliberate: the operator's decision already folds in the runtime's backend health +// plus pod/deployment readiness the runtime cannot observe, so it stays authoritative. +func TestVirtualMCPServerUpdateStatus_ReadyConditionOwnership(t *testing.T) { + t.Parallel() + + vmcp := v1beta1test.NewVirtualMCPServer(testVmcpName, "default") + vmcp.Status.Runtime = &mcpv1beta1.VirtualMCPServerRuntimeStatus{ + Phase: mcpv1beta1.VirtualMCPServerPhaseReady, + Conditions: []metav1.Condition{{ + Type: "Ready", Status: metav1.ConditionTrue, Reason: "AllBackendsRoutable", + }}, + DiscoveredBackends: []mcpv1beta1.DiscoveredBackend{ + {Name: "backend", Status: mcpv1beta1.BackendStatusReady}, + }, + } + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: testVmcpName + "-pod-1", + Namespace: "default", + Labels: labelsForVirtualMCPServer(testVmcpName), + }, + Status: corev1.PodStatus{ + Phase: corev1.PodRunning, + Conditions: []corev1.PodCondition{ + {Type: corev1.PodReady, Status: corev1.ConditionTrue}, + }, + }, + } + + r, _ := newTestVirtualMCPServerReconciler(t, vmcp, pod) + + // NewStatusManager projects the runtime's Ready condition (AllBackendsRoutable) + // into the collector's pending state, mirroring what happens at the top of Reconcile. + statusManager := virtualmcpserverstatus.NewStatusManager(vmcp) + err := r.updateVirtualMCPServerStatus(context.Background(), vmcp, statusManager) + require.NoError(t, err) + _ = statusManager.UpdateStatus(context.Background(), &vmcp.Status) + + condition := findCondition(vmcp.Status.Conditions, mcpv1beta1.ConditionTypeVirtualMCPServerReady) + require.NotNil(t, condition) + assert.Equal(t, "DeploymentReady", condition.Reason) + assert.NotEqual(t, "AllBackendsRoutable", condition.Reason) +} + // TestVirtualMCPServerLabels tests label generation func TestVirtualMCPServerLabels(t *testing.T) { t.Parallel() @@ -4592,6 +4680,79 @@ func TestVirtualMCPServerValidateAuthServerConfig_ZeroUpstreamAlternatives(t *te } } +func TestVirtualMCPServerValidateAuthServerConfig_CanonicalTrustedIssuer(t *testing.T) { + t.Parallel() + + vmcp := v1beta1test.NewVirtualMCPServer(testVmcpName, "default", + v1beta1test.WithVMCPGroupRef("test-group"), + v1beta1test.WithVMCPAuthServerConfig(&mcpv1beta1.EmbeddedAuthServerConfig{ + Issuer: "https://auth.example.com", + TrustedIssuers: []mcpv1beta1.TrustedIssuerConfig{{ + Name: "external", IssuerURL: "https://auth.example.com", + }}, + InboundGrants: &mcpv1beta1.InboundGrantsConfig{ + TokenExchange: &mcpv1beta1.TokenExchangeInboundGrantConfig{ + IssuerPolicies: []mcpv1beta1.TokenExchangeIssuerPolicyConfig{{ + IssuerRef: "external", ExpectedAudience: "audience", + AllowedDelegateClients: []string{"delegate"}, + }}, + }, + }, + }), + ) + statusManager := virtualmcpserverstatus.NewStatusManager(vmcp) + + err := (&VirtualMCPServerReconciler{}).validateAuthServerConfig(vmcp, statusManager) + statusManager.UpdateStatus(t.Context(), &vmcp.Status) + + require.ErrorContains(t, err, "must not equal the authorization server's own issuer") + condition := findCondition(vmcp.Status.Conditions, mcpv1beta1.ConditionTypeAuthServerConfigValidated) + require.NotNil(t, condition) + assert.Equal(t, metav1.ConditionFalse, condition.Status) +} + +func TestVirtualMCPServerValidateAuthServerConfig_TrustedIssuerEndpointRecovery(t *testing.T) { + t.Parallel() + + const unsafeIssuerURL = "https://sentinel-user:sentinel-password@issuer.example.com" + + vmcp := v1beta1test.NewVirtualMCPServer(testVmcpName, "default", + v1beta1test.WithVMCPGroupRef("test-group"), + v1beta1test.WithVMCPAuthServerConfig(&mcpv1beta1.EmbeddedAuthServerConfig{ + Issuer: "https://auth.example.com", + TrustedIssuers: []mcpv1beta1.TrustedIssuerConfig{{ + Name: "external", IssuerURL: unsafeIssuerURL, + }}, + InboundGrants: &mcpv1beta1.InboundGrantsConfig{TokenExchange: &mcpv1beta1.TokenExchangeInboundGrantConfig{ + IssuerPolicies: []mcpv1beta1.TokenExchangeIssuerPolicyConfig{{ + IssuerRef: "external", ExpectedAudience: "audience", AllowedDelegateClients: []string{"delegate"}, + }}, + }}, + }), + ) + r := &VirtualMCPServerReconciler{} + statusManager := virtualmcpserverstatus.NewStatusManager(vmcp) + + require.ErrorContains(t, r.validateAuthServerConfig(vmcp, statusManager), "must not contain userinfo") + statusManager.UpdateStatus(t.Context(), &vmcp.Status) + condition := findCondition(vmcp.Status.Conditions, mcpv1beta1.ConditionTypeAuthServerConfigValidated) + require.NotNil(t, condition) + assert.Equal(t, metav1.ConditionFalse, condition.Status) + assert.Contains(t, condition.Message, "must not contain userinfo") + assert.NotContains(t, condition.Message, unsafeIssuerURL) + assert.NotContains(t, condition.Message, "sentinel-user") + assert.NotContains(t, condition.Message, "sentinel-password") + + vmcp.Spec.AuthServerConfig.TrustedIssuers[0].IssuerURL = "https://issuer.example.com" + statusManager = virtualmcpserverstatus.NewStatusManager(vmcp) + require.NoError(t, r.validateAuthServerConfig(vmcp, statusManager)) + statusManager.UpdateStatus(t.Context(), &vmcp.Status) + condition = findCondition(vmcp.Status.Conditions, mcpv1beta1.ConditionTypeAuthServerConfigValidated) + require.NotNil(t, condition) + assert.Equal(t, metav1.ConditionTrue, condition.Status) + assert.Equal(t, mcpv1beta1.ConditionReasonAuthServerConfigValid, condition.Reason) +} + func TestVirtualMCPServerValidateAuthServerConfig_DelegateClientsRejectUnsafeHTTP(t *testing.T) { t.Parallel() diff --git a/cmd/thv-operator/pkg/controllerutil/authserver.go b/cmd/thv-operator/pkg/controllerutil/authserver.go index 65de78ba04..487dcb234d 100644 --- a/cmd/thv-operator/pkg/controllerutil/authserver.go +++ b/cmd/thv-operator/pkg/controllerutil/authserver.go @@ -853,6 +853,9 @@ func buildInboundGrantsRunConfig( if config.JWTBearer != nil { policies := make([]authserver.JWTBearerIssuerPolicyRunConfig, len(config.JWTBearer.IssuerPolicies)) for i, policy := range config.JWTBearer.IssuerPolicies { + if policy.MaxAssertionAge == nil { + return nil, fmt.Errorf("jwtBearer.issuerPolicies[%d].maxAssertionAge is required", i) + } policies[i] = authserver.JWTBearerIssuerPolicyRunConfig{ IssuerRef: policy.IssuerRef, MaxAssertionAge: policy.MaxAssertionAge.Duration.String(), @@ -900,6 +903,9 @@ func BuildAuthServerRunConfig( } }() + if err := authConfig.ValidateInboundGrants(); err != nil { + return nil, err + } inboundGrants, err := buildInboundGrantsRunConfig(authConfig.InboundGrants) if err != nil { return nil, err diff --git a/cmd/thv-operator/pkg/controllerutil/authserver_test.go b/cmd/thv-operator/pkg/controllerutil/authserver_test.go index b67219279d..ab2e04bf13 100644 --- a/cmd/thv-operator/pkg/controllerutil/authserver_test.go +++ b/cmd/thv-operator/pkg/controllerutil/authserver_test.go @@ -3386,6 +3386,42 @@ func TestBuildAuthServerRunConfigInvalidDelegateClientIsTyped(t *testing.T) { assert.True(t, stderrors.As(err, &invalidConfigErr)) } +func TestBuildAuthServerRunConfig_RejectsNilJWTBearerMaxAssertionAge(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + authConfig *mcpv1beta1.EmbeddedAuthServerConfig + wantErr string + }{ + { + name: "legacy JWT bearer grant", + authConfig: &mcpv1beta1.EmbeddedAuthServerConfig{TrustedIssuers: []mcpv1beta1.TrustedIssuerConfig{{ + IssuerURL: "https://issuer.example.com", JWTBearerGrant: &mcpv1beta1.JWTBearerGrantConfig{}, + }}}, + wantErr: "trustedIssuers[0].jwtBearerGrant.maxAssertionAge is required", + }, + { + name: "canonical JWT bearer grant", + authConfig: &mcpv1beta1.EmbeddedAuthServerConfig{ + TrustedIssuers: []mcpv1beta1.TrustedIssuerConfig{{Name: "issuer", IssuerURL: "https://issuer.example.com"}}, + InboundGrants: &mcpv1beta1.InboundGrantsConfig{JWTBearer: &mcpv1beta1.JWTBearerInboundGrantConfig{ + IssuerPolicies: []mcpv1beta1.JWTBearerIssuerPolicyConfig{{IssuerRef: "issuer"}}, + }}, + }, + wantErr: "inboundGrants.jwtBearer.issuerPolicies[0].maxAssertionAge is required", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + config, err := BuildAuthServerRunConfig("default", "test-server", tt.authConfig, nil, nil, "") + require.ErrorContains(t, err, tt.wantErr) + assert.Nil(t, config) + }) + } +} + func TestBuildTrustedIssuerRunConfigs_JWTBearerGrant(t *testing.T) { t.Parallel() diff --git a/cmd/thv-operator/pkg/virtualmcpserverstatus/collector.go b/cmd/thv-operator/pkg/virtualmcpserverstatus/collector.go index 65493ef192..a29b44e484 100644 --- a/cmd/thv-operator/pkg/virtualmcpserverstatus/collector.go +++ b/cmd/thv-operator/pkg/virtualmcpserverstatus/collector.go @@ -34,10 +34,39 @@ type StatusCollector struct { // NewStatusManager creates a new StatusManager for the given VirtualMCPServer resource. func NewStatusManager(vmcp *mcpv1beta1.VirtualMCPServer) StatusManager { - return &StatusCollector{ + collector := &StatusCollector{ vmcp: vmcp, conditions: make(map[string]metav1.Condition), } + collector.projectRuntimeStatus() + return collector +} + +// projectRuntimeStatus projects the runtime-owned snapshot into the top-level +// compatibility fields. Keeping this projection in the operator makes it the +// sole writer of the top-level Conditions array. +func (s *StatusCollector) projectRuntimeStatus() { + runtimeStatus := s.vmcp.Status.Runtime + if runtimeStatus == nil { + return + } + + s.SetPhase(runtimeStatus.Phase) + s.SetMessage(runtimeStatus.Message) + backends := append([]mcpv1beta1.DiscoveredBackend{}, runtimeStatus.DiscoveredBackends...) + s.SetDiscoveredBackends(backends) + + conditionTypes := make(map[string]struct{}, len(runtimeStatus.Conditions)) + for _, condition := range runtimeStatus.Conditions { + conditionTypes[condition.Type] = struct{}{} + s.SetCondition(condition.Type, condition.Reason, condition.Message, condition.Status) + } + for _, conditionType := range []string{"Ready", "Degraded", "BackendsDiscovered"} { + if _, exists := conditionTypes[conditionType]; !exists { + s.conditions[conditionType] = metav1.Condition{Type: conditionType} + s.hasChanges = true + } + } } // SetPhase sets the phase to be updated. diff --git a/cmd/thv-operator/pkg/virtualmcpserverstatus/collector_test.go b/cmd/thv-operator/pkg/virtualmcpserverstatus/collector_test.go index 2d5cedebcb..5751a904df 100644 --- a/cmd/thv-operator/pkg/virtualmcpserverstatus/collector_test.go +++ b/cmd/thv-operator/pkg/virtualmcpserverstatus/collector_test.go @@ -8,6 +8,7 @@ import ( "testing" "github.com/stretchr/testify/assert" + "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" @@ -364,6 +365,45 @@ func TestStatusCollector_SetTelemetryConfigHash_Clear(t *testing.T) { assert.Empty(t, status.TelemetryConfigHash) } +func TestStatusCollector_ProjectsRuntimeStatus(t *testing.T) { + t.Parallel() + + vmcp := &mcpv1beta1.VirtualMCPServer{ + ObjectMeta: metav1.ObjectMeta{Generation: 4}, + Status: mcpv1beta1.VirtualMCPServerStatus{ + Conditions: []metav1.Condition{ + {Type: "Degraded", Status: metav1.ConditionTrue}, + {Type: mcpv1beta1.ConditionTypeValid, Status: metav1.ConditionTrue}, + }, + Runtime: &mcpv1beta1.VirtualMCPServerRuntimeStatus{ + Phase: mcpv1beta1.VirtualMCPServerPhaseReady, + Message: "runtime ready", + Conditions: []metav1.Condition{{ + Type: "Ready", Status: metav1.ConditionTrue, Reason: "AllBackendsRoutable", + }}, + DiscoveredBackends: []mcpv1beta1.DiscoveredBackend{{ + Name: "backend", Status: mcpv1beta1.BackendStatusReady, + }}, + }, + }, + } + + collector := NewStatusManager(vmcp) + status := vmcp.Status.DeepCopy() + hasUpdates := collector.UpdateStatus(context.Background(), status) + + assert.True(t, hasUpdates) + assert.Equal(t, mcpv1beta1.VirtualMCPServerPhaseReady, status.Phase) + assert.Equal(t, "runtime ready", status.Message) + assert.Equal(t, int32(1), status.BackendCount) + assert.Equal(t, "backend", status.DiscoveredBackends[0].Name) + assert.Condition(t, func() bool { + return meta.FindStatusCondition(status.Conditions, "Ready") != nil + }) + assert.Nil(t, meta.FindStatusCondition(status.Conditions, "Degraded")) + assert.NotNil(t, meta.FindStatusCondition(status.Conditions, mcpv1beta1.ConditionTypeValid)) +} + func TestStatusCollector_SetTelemetryConfigRefValidatedCondition(t *testing.T) { t.Parallel() diff --git a/cmd/thv-operator/test-integration/mcp-external-auth/confidential_client_transport_cel_test.go b/cmd/thv-operator/test-integration/mcp-external-auth/confidential_client_transport_cel_test.go index aa39dbcd06..4692406951 100644 --- a/cmd/thv-operator/test-integration/mcp-external-auth/confidential_client_transport_cel_test.go +++ b/cmd/thv-operator/test-integration/mcp-external-auth/confidential_client_transport_cel_test.go @@ -16,23 +16,22 @@ import ( ) // These tests exercise the CEL XValidation rules on EmbeddedAuthServerConfig -// These tests exercise the CEL XValidation rules on EmbeddedAuthServerConfig -// through the real apiserver (envtest). Confidential registration rejects -// insecureAllowHTTP because it mints a client_secret in the DCR response; -// private_key_jwt registration has no equivalent rule because it never -// returns a secret, so combining it with insecureAllowHTTP is admitted. -// URL-specific delegate-client transport policy is handled by the shared Go -// validator because CEL cannot safely parse URLs. EmbeddedAuthServerConfig -// is shared by MCPExternalAuthConfig and VirtualMCPServer, so exercising -// the rule through one CRD's generated schema covers both. +// through the real apiserver (envtest). insecureAllowHTTP rejects every +// configuration that issues or uses a client secret: confidential registration +// and legacy or canonical delegate clients. private_key_jwt registration has no +// equivalent rule because it never returns a secret. URL-specific delegate-client +// transport policy is handled by the shared Go validator because CEL cannot +// safely parse URLs. EmbeddedAuthServerConfig is shared by MCPExternalAuthConfig +// and VirtualMCPServer, so exercising the rule through one CRD's generated +// schema covers both. var _ = Describe("EmbeddedAuthServerConfig confidential-client-transport CEL validation", func() { const namespace = "default" makeAuthConfig := func( - name string, allowConfidential, allowPrivateKeyJWT, insecureHTTP, delegateClient, loopbackHTTP, loopbackOptIn bool, + name string, allowConfidential, allowPrivateKeyJWT, insecureHTTP, httpsIssuer, delegateClient, canonicalDelegateClient, loopbackHTTP, loopbackOptIn bool, ) *mcpv1beta1.MCPExternalAuthConfig { issuer := "https://auth.example.com" - if insecureHTTP { + if insecureHTTP && !httpsIssuer { issuer = "http://auth.internal.svc.cluster.local" } if loopbackHTTP { @@ -61,12 +60,19 @@ var _ = Describe("EmbeddedAuthServerConfig confidential-client-transport CEL val }, } if delegateClient { - config.Spec.EmbeddedAuthServer.DelegateClients = []mcpv1beta1.DelegateClientConfig{{ + delegate := mcpv1beta1.DelegateClientConfig{ ClientID: "delegate-client", ClientSecretRef: &mcpv1beta1.SecretKeyRef{Name: "delegate-secret", Key: "credential"}, Scopes: []string{"openid"}, Audiences: []string{"https://api.example.com"}, - }} + } + if canonicalDelegateClient { + config.Spec.EmbeddedAuthServer.InboundGrants = &mcpv1beta1.InboundGrantsConfig{ + TokenExchange: &mcpv1beta1.TokenExchangeInboundGrantConfig{DelegateClients: []mcpv1beta1.DelegateClientConfig{delegate}}, + } + } else { + config.Spec.EmbeddedAuthServer.DelegateClients = []mcpv1beta1.DelegateClientConfig{delegate} + } } return config } @@ -76,15 +82,17 @@ var _ = Describe("EmbeddedAuthServerConfig confidential-client-transport CEL val }) type validationCase struct { - name string - allowConfidential bool - allowPrivateKeyJWT bool - insecureHTTP bool - delegateClient bool - loopbackHTTP bool - loopbackOptIn bool - shouldAdmit bool - expectedMessage string + name string + allowConfidential bool + allowPrivateKeyJWT bool + insecureHTTP bool + httpsIssuer bool + delegateClient bool + canonicalDelegateClient bool + loopbackHTTP bool + loopbackOptIn bool + shouldAdmit bool + expectedMessage string } cases := []validationCase{ @@ -93,7 +101,7 @@ var _ = Describe("EmbeddedAuthServerConfig confidential-client-transport CEL val allowConfidential: true, insecureHTTP: true, shouldAdmit: false, - expectedMessage: "allowConfidentialClientRegistration cannot be combined with insecureAllowHTTP", + expectedMessage: "insecureAllowHTTP cannot be combined with confidential client registration or delegateClients", }, { name: "allowPrivateKeyJWTRegistration with insecureAllowHTTP is admitted (no secret to protect)", @@ -107,12 +115,66 @@ var _ = Describe("EmbeddedAuthServerConfig confidential-client-transport CEL val insecureHTTP: false, shouldAdmit: true, }, + { + name: "legacy delegate clients with insecureAllowHTTP set", + delegateClient: true, + insecureHTTP: true, + shouldAdmit: false, + expectedMessage: "insecureAllowHTTP cannot be combined with confidential client registration or delegateClients", + }, + { + name: "canonical delegate clients with insecureAllowHTTP set", + delegateClient: true, + canonicalDelegateClient: true, + insecureHTTP: true, + shouldAdmit: false, + expectedMessage: "insecureAllowHTTP cannot be combined with confidential client registration or delegateClients", + }, + { + name: "legacy delegate clients with insecureAllowHTTP and HTTPS issuer", + delegateClient: true, + insecureHTTP: true, + httpsIssuer: true, + shouldAdmit: false, + expectedMessage: "insecureAllowHTTP cannot be combined with confidential client registration or delegateClients", + }, + { + name: "canonical delegate clients with insecureAllowHTTP and HTTPS issuer", + delegateClient: true, + canonicalDelegateClient: true, + insecureHTTP: true, + httpsIssuer: true, + shouldAdmit: false, + expectedMessage: "insecureAllowHTTP cannot be combined with confidential client registration or delegateClients", + }, { name: "delegate clients with loopback HTTP issuer without opt-in", delegateClient: true, loopbackHTTP: true, shouldAdmit: false, - expectedMessage: "delegateClients with an HTTP issuer require insecureAllowConfidentialOverLoopbackHTTP", + expectedMessage: "confidential client registration or delegateClients with an HTTP issuer require insecureAllowConfidentialOverLoopbackHTTP", + }, + { + name: "canonical delegate clients with loopback HTTP issuer without opt-in", + delegateClient: true, + canonicalDelegateClient: true, + loopbackHTTP: true, + shouldAdmit: false, + expectedMessage: "confidential client registration or delegateClients with an HTTP issuer require insecureAllowConfidentialOverLoopbackHTTP", + }, + { + name: "confidential registration with loopback HTTP issuer without opt-in", + allowConfidential: true, + loopbackHTTP: true, + shouldAdmit: false, + expectedMessage: "confidential client registration or delegateClients with an HTTP issuer require insecureAllowConfidentialOverLoopbackHTTP", + }, + { + name: "confidential registration with opted-in loopback HTTP issuer", + allowConfidential: true, + loopbackHTTP: true, + loopbackOptIn: true, + shouldAdmit: true, }, { name: "delegate clients with opted-in loopback HTTP issuer", @@ -121,13 +183,21 @@ var _ = Describe("EmbeddedAuthServerConfig confidential-client-transport CEL val loopbackOptIn: true, shouldAdmit: true, }, + { + name: "canonical delegate clients with opted-in loopback HTTP issuer", + delegateClient: true, + canonicalDelegateClient: true, + loopbackHTTP: true, + loopbackOptIn: true, + shouldAdmit: true, + }, } for i, c := range cases { name := fmt.Sprintf("confidential-client-transport-%d", i) It(c.name, func() { cfg := makeAuthConfig( - name, c.allowConfidential, c.allowPrivateKeyJWT, c.insecureHTTP, c.delegateClient, c.loopbackHTTP, c.loopbackOptIn) + name, c.allowConfidential, c.allowPrivateKeyJWT, c.insecureHTTP, c.httpsIssuer, c.delegateClient, c.canonicalDelegateClient, c.loopbackHTTP, c.loopbackOptIn) err := k8sClient.Create(ctx, cfg) if c.shouldAdmit { Expect(err).NotTo(HaveOccurred(), @@ -190,6 +260,6 @@ var _ = Describe("EmbeddedAuthServerConfig confidential-client-transport CEL val err := k8sClient.Create(ctx, vmcp) Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring( - "delegateClients with an HTTP issuer require insecureAllowConfidentialOverLoopbackHTTP")) + "confidential client registration or delegateClients with an HTTP issuer require insecureAllowConfidentialOverLoopbackHTTP")) }) }) 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 8adc8f1e34..6b61549643 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 @@ -623,12 +623,16 @@ spec: type: boolean allowedActors: items: + maxLength: 256 + minLength: 1 type: string maxItems: 50 type: array x-kubernetes-list-type: atomic allowedDelegateClients: items: + maxLength: 256 + minLength: 1 type: string maxItems: 50 minItems: 1 @@ -650,6 +654,21 @@ spec: - expectedAudience - issuerRef type: object + x-kubernetes-validations: + - message: allowedDelegateClients must not combine the + wildcard "*" with specific client IDs + rule: '!(''*'' in self.allowedDelegateClients) || + size(self.allowedDelegateClients) == 1' + - message: allowMayAct must not be enabled when allowedDelegateClients + contains the wildcard "*" + rule: '!(self.allowMayAct && ''*'' in self.allowedDelegateClients)' + - message: actorClaim must name a readable claim; use + client_id or a non-reserved claim such as azp, appid, + or cid + rule: '!has(self.actorClaim) || !(self.actorClaim + in [''sub'', ''iss'', ''aud'', ''exp'', ''iat'', + ''nbf'', ''jti'', ''name'', ''email'', ''scope'', + ''scp'', ''may_act''])' maxItems: 20 type: array x-kubernetes-list-type: atomic @@ -691,9 +710,10 @@ spec: as an operator condition. One combination is rejected at admission on all three CRDs regardless of the - above: setting this field alongside allowConfidentialClientRegistration, which - would issue client secrets in cleartext over an unauthenticated registration - endpoint (see the XValidation rule on EmbeddedAuthServerConfig). + above: setting this field alongside confidential client registration or + delegate clients, which would issue or use client secrets in cleartext over + an unauthenticated endpoint (see the XValidation rule on + EmbeddedAuthServerConfig). type: boolean issuer: description: |- @@ -1026,9 +1046,9 @@ spec: AllowPrivateIPs permits OIDC discovery and JWKS fetches for THIS issuer to resolve to a private or loopback address. Use only when the issuer is hosted inside the same cluster and has no public endpoint. Requires - jwksUrl to be set explicitly (enforced at reconcile time), since - otherwise OIDC discovery — fetched from the external issuer itself — - would choose the private dial target. + jwksUrl to be set explicitly (enforced at admission and by shared + validation), since otherwise OIDC discovery — fetched from the external + issuer itself — would choose the private dial target. type: boolean allowedActors: description: |- @@ -1873,20 +1893,28 @@ spec: > 0) || (has(self.trustedIssuers) && self.trustedIssuers.exists(issuer, has(issuer.jwtBearerGrant))) || (has(self.inboundGrants) && (has(self.inboundGrants.tokenExchange) || has(self.inboundGrants.jwtBearer))) - - message: allowConfidentialClientRegistration cannot be combined - with insecureAllowHTTP; client secrets would be issued in cleartext - over an unauthenticated endpoint - rule: '!(has(self.allowConfidentialClientRegistration) && self.allowConfidentialClientRegistration - && has(self.insecureAllowHTTP) && self.insecureAllowHTTP)' + - message: insecureAllowHTTP cannot be combined with confidential + client registration or delegateClients; client secrets would be + issued or used in cleartext over an unauthenticated endpoint + rule: '!(has(self.insecureAllowHTTP) && self.insecureAllowHTTP && + ((has(self.allowConfidentialClientRegistration) && self.allowConfidentialClientRegistration) + || (has(self.delegateClients) && size(self.delegateClients) > + 0) || (has(self.inboundGrants) && has(self.inboundGrants.tokenExchange) + && has(self.inboundGrants.tokenExchange.delegateClients) && size(self.inboundGrants.tokenExchange.delegateClients) + > 0)))' - message: forceConfidentialRedirectUris requires allowConfidentialClientRegistration to be true rule: (!has(self.forceConfidentialRedirectUris) || size(self.forceConfidentialRedirectUris) == 0) || (has(self.allowConfidentialClientRegistration) && self.allowConfidentialClientRegistration) - - message: delegateClients with an HTTP issuer require insecureAllowConfidentialOverLoopbackHTTP + - message: confidential client registration or delegateClients with + an HTTP issuer require insecureAllowConfidentialOverLoopbackHTTP to be explicitly enabled; the issuer must still be loopback - rule: '!has(self.delegateClients) || size(self.delegateClients) - == 0 || !self.issuer.startsWith(''http://'') || (has(self.insecureAllowConfidentialOverLoopbackHTTP) - && self.insecureAllowConfidentialOverLoopbackHTTP)' + rule: '!self.issuer.startsWith(''http://'') || (has(self.insecureAllowConfidentialOverLoopbackHTTP) + && self.insecureAllowConfidentialOverLoopbackHTTP) || ((!has(self.allowConfidentialClientRegistration) + || !self.allowConfidentialClientRegistration) && (!has(self.delegateClients) + || size(self.delegateClients) == 0) && (!has(self.inboundGrants) + || !has(self.inboundGrants.tokenExchange) || !has(self.inboundGrants.tokenExchange.delegateClients) + || size(self.inboundGrants.tokenExchange.delegateClients) == 0))' - message: canonical tokenExchange conflicts with legacy delegateClients or RFC 8693 trusted issuer policy rule: '!has(self.inboundGrants) || !has(self.inboundGrants.tokenExchange) @@ -3000,12 +3028,16 @@ spec: type: boolean allowedActors: items: + maxLength: 256 + minLength: 1 type: string maxItems: 50 type: array x-kubernetes-list-type: atomic allowedDelegateClients: items: + maxLength: 256 + minLength: 1 type: string maxItems: 50 minItems: 1 @@ -3027,6 +3059,21 @@ spec: - expectedAudience - issuerRef type: object + x-kubernetes-validations: + - message: allowedDelegateClients must not combine the + wildcard "*" with specific client IDs + rule: '!(''*'' in self.allowedDelegateClients) || + size(self.allowedDelegateClients) == 1' + - message: allowMayAct must not be enabled when allowedDelegateClients + contains the wildcard "*" + rule: '!(self.allowMayAct && ''*'' in self.allowedDelegateClients)' + - message: actorClaim must name a readable claim; use + client_id or a non-reserved claim such as azp, appid, + or cid + rule: '!has(self.actorClaim) || !(self.actorClaim + in [''sub'', ''iss'', ''aud'', ''exp'', ''iat'', + ''nbf'', ''jti'', ''name'', ''email'', ''scope'', + ''scp'', ''may_act''])' maxItems: 20 type: array x-kubernetes-list-type: atomic @@ -3068,9 +3115,10 @@ spec: as an operator condition. One combination is rejected at admission on all three CRDs regardless of the - above: setting this field alongside allowConfidentialClientRegistration, which - would issue client secrets in cleartext over an unauthenticated registration - endpoint (see the XValidation rule on EmbeddedAuthServerConfig). + above: setting this field alongside confidential client registration or + delegate clients, which would issue or use client secrets in cleartext over + an unauthenticated endpoint (see the XValidation rule on + EmbeddedAuthServerConfig). type: boolean issuer: description: |- @@ -3403,9 +3451,9 @@ spec: AllowPrivateIPs permits OIDC discovery and JWKS fetches for THIS issuer to resolve to a private or loopback address. Use only when the issuer is hosted inside the same cluster and has no public endpoint. Requires - jwksUrl to be set explicitly (enforced at reconcile time), since - otherwise OIDC discovery — fetched from the external issuer itself — - would choose the private dial target. + jwksUrl to be set explicitly (enforced at admission and by shared + validation), since otherwise OIDC discovery — fetched from the external + issuer itself — would choose the private dial target. type: boolean allowedActors: description: |- @@ -4250,20 +4298,28 @@ spec: > 0) || (has(self.trustedIssuers) && self.trustedIssuers.exists(issuer, has(issuer.jwtBearerGrant))) || (has(self.inboundGrants) && (has(self.inboundGrants.tokenExchange) || has(self.inboundGrants.jwtBearer))) - - message: allowConfidentialClientRegistration cannot be combined - with insecureAllowHTTP; client secrets would be issued in cleartext - over an unauthenticated endpoint - rule: '!(has(self.allowConfidentialClientRegistration) && self.allowConfidentialClientRegistration - && has(self.insecureAllowHTTP) && self.insecureAllowHTTP)' + - message: insecureAllowHTTP cannot be combined with confidential + client registration or delegateClients; client secrets would be + issued or used in cleartext over an unauthenticated endpoint + rule: '!(has(self.insecureAllowHTTP) && self.insecureAllowHTTP && + ((has(self.allowConfidentialClientRegistration) && self.allowConfidentialClientRegistration) + || (has(self.delegateClients) && size(self.delegateClients) > + 0) || (has(self.inboundGrants) && has(self.inboundGrants.tokenExchange) + && has(self.inboundGrants.tokenExchange.delegateClients) && size(self.inboundGrants.tokenExchange.delegateClients) + > 0)))' - message: forceConfidentialRedirectUris requires allowConfidentialClientRegistration to be true rule: (!has(self.forceConfidentialRedirectUris) || size(self.forceConfidentialRedirectUris) == 0) || (has(self.allowConfidentialClientRegistration) && self.allowConfidentialClientRegistration) - - message: delegateClients with an HTTP issuer require insecureAllowConfidentialOverLoopbackHTTP + - message: confidential client registration or delegateClients with + an HTTP issuer require insecureAllowConfidentialOverLoopbackHTTP to be explicitly enabled; the issuer must still be loopback - rule: '!has(self.delegateClients) || size(self.delegateClients) - == 0 || !self.issuer.startsWith(''http://'') || (has(self.insecureAllowConfidentialOverLoopbackHTTP) - && self.insecureAllowConfidentialOverLoopbackHTTP)' + rule: '!self.issuer.startsWith(''http://'') || (has(self.insecureAllowConfidentialOverLoopbackHTTP) + && self.insecureAllowConfidentialOverLoopbackHTTP) || ((!has(self.allowConfidentialClientRegistration) + || !self.allowConfidentialClientRegistration) && (!has(self.delegateClients) + || size(self.delegateClients) == 0) && (!has(self.inboundGrants) + || !has(self.inboundGrants.tokenExchange) || !has(self.inboundGrants.tokenExchange.delegateClients) + || size(self.inboundGrants.tokenExchange.delegateClients) == 0))' - message: canonical tokenExchange conflicts with legacy delegateClients or RFC 8693 trusted issuer policy rule: '!has(self.inboundGrants) || !has(self.inboundGrants.tokenExchange) 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 9369c5b102..8d2fbcaf88 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 @@ -499,12 +499,16 @@ spec: type: boolean allowedActors: items: + maxLength: 256 + minLength: 1 type: string maxItems: 50 type: array x-kubernetes-list-type: atomic allowedDelegateClients: items: + maxLength: 256 + minLength: 1 type: string maxItems: 50 minItems: 1 @@ -526,6 +530,21 @@ spec: - expectedAudience - issuerRef type: object + x-kubernetes-validations: + - message: allowedDelegateClients must not combine the + wildcard "*" with specific client IDs + rule: '!(''*'' in self.allowedDelegateClients) || + size(self.allowedDelegateClients) == 1' + - message: allowMayAct must not be enabled when allowedDelegateClients + contains the wildcard "*" + rule: '!(self.allowMayAct && ''*'' in self.allowedDelegateClients)' + - message: actorClaim must name a readable claim; use + client_id or a non-reserved claim such as azp, appid, + or cid + rule: '!has(self.actorClaim) || !(self.actorClaim + in [''sub'', ''iss'', ''aud'', ''exp'', ''iat'', + ''nbf'', ''jti'', ''name'', ''email'', ''scope'', + ''scp'', ''may_act''])' maxItems: 20 type: array x-kubernetes-list-type: atomic @@ -567,9 +586,10 @@ spec: as an operator condition. One combination is rejected at admission on all three CRDs regardless of the - above: setting this field alongside allowConfidentialClientRegistration, which - would issue client secrets in cleartext over an unauthenticated registration - endpoint (see the XValidation rule on EmbeddedAuthServerConfig). + above: setting this field alongside confidential client registration or + delegate clients, which would issue or use client secrets in cleartext over + an unauthenticated endpoint (see the XValidation rule on + EmbeddedAuthServerConfig). type: boolean issuer: description: |- @@ -902,9 +922,9 @@ spec: AllowPrivateIPs permits OIDC discovery and JWKS fetches for THIS issuer to resolve to a private or loopback address. Use only when the issuer is hosted inside the same cluster and has no public endpoint. Requires - jwksUrl to be set explicitly (enforced at reconcile time), since - otherwise OIDC discovery — fetched from the external issuer itself — - would choose the private dial target. + jwksUrl to be set explicitly (enforced at admission and by shared + validation), since otherwise OIDC discovery — fetched from the external + issuer itself — would choose the private dial target. type: boolean allowedActors: description: |- @@ -1749,20 +1769,28 @@ spec: > 0) || (has(self.trustedIssuers) && self.trustedIssuers.exists(issuer, has(issuer.jwtBearerGrant))) || (has(self.inboundGrants) && (has(self.inboundGrants.tokenExchange) || has(self.inboundGrants.jwtBearer))) - - message: allowConfidentialClientRegistration cannot be combined - with insecureAllowHTTP; client secrets would be issued in cleartext - over an unauthenticated endpoint - rule: '!(has(self.allowConfidentialClientRegistration) && self.allowConfidentialClientRegistration - && has(self.insecureAllowHTTP) && self.insecureAllowHTTP)' + - message: insecureAllowHTTP cannot be combined with confidential + client registration or delegateClients; client secrets would be + issued or used in cleartext over an unauthenticated endpoint + rule: '!(has(self.insecureAllowHTTP) && self.insecureAllowHTTP && + ((has(self.allowConfidentialClientRegistration) && self.allowConfidentialClientRegistration) + || (has(self.delegateClients) && size(self.delegateClients) > + 0) || (has(self.inboundGrants) && has(self.inboundGrants.tokenExchange) + && has(self.inboundGrants.tokenExchange.delegateClients) && size(self.inboundGrants.tokenExchange.delegateClients) + > 0)))' - message: forceConfidentialRedirectUris requires allowConfidentialClientRegistration to be true rule: (!has(self.forceConfidentialRedirectUris) || size(self.forceConfidentialRedirectUris) == 0) || (has(self.allowConfidentialClientRegistration) && self.allowConfidentialClientRegistration) - - message: delegateClients with an HTTP issuer require insecureAllowConfidentialOverLoopbackHTTP + - message: confidential client registration or delegateClients with + an HTTP issuer require insecureAllowConfidentialOverLoopbackHTTP to be explicitly enabled; the issuer must still be loopback - rule: '!has(self.delegateClients) || size(self.delegateClients) - == 0 || !self.issuer.startsWith(''http://'') || (has(self.insecureAllowConfidentialOverLoopbackHTTP) - && self.insecureAllowConfidentialOverLoopbackHTTP)' + rule: '!self.issuer.startsWith(''http://'') || (has(self.insecureAllowConfidentialOverLoopbackHTTP) + && self.insecureAllowConfidentialOverLoopbackHTTP) || ((!has(self.allowConfidentialClientRegistration) + || !self.allowConfidentialClientRegistration) && (!has(self.delegateClients) + || size(self.delegateClients) == 0) && (!has(self.inboundGrants) + || !has(self.inboundGrants.tokenExchange) || !has(self.inboundGrants.tokenExchange.delegateClients) + || size(self.inboundGrants.tokenExchange.delegateClients) == 0))' - message: canonical tokenExchange conflicts with legacy delegateClients or RFC 8693 trusted issuer policy rule: '!has(self.inboundGrants) || !has(self.inboundGrants.tokenExchange) @@ -4396,6 +4424,157 @@ spec: - Degraded - Failed type: string + runtime: + description: |- + Runtime is the status snapshot written exclusively by the vMCP process. + The operator projects it into the top-level compatibility fields. + properties: + backendCount: + description: BackendCount is the number of routable backends observed + by the runtime. + format: int32 + type: integer + conditions: + description: Conditions contains runtime health observations. + items: + description: Condition contains details for one aspect of the + current state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, + Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + discoveredBackends: + description: DiscoveredBackends contains the runtime's latest + backend observations. + items: + description: |- + DiscoveredBackend represents a backend server discovered by vMCP runtime. + This type is shared with the Kubernetes operator CRD (VirtualMCPServer.Status.DiscoveredBackends). + properties: + authConfigRef: + description: AuthConfigRef is the name of the discovered + MCPExternalAuthConfig (if any) + type: string + authType: + description: AuthType is the type of authentication configured + type: string + circuitBreakerState: + description: |- + CircuitBreakerState is the current circuit breaker state (closed, open, half-open). + Empty when circuit breaker is disabled or not configured. + enum: + - closed + - open + - half-open + type: string + circuitLastChanged: + description: |- + CircuitLastChanged is the timestamp when the circuit breaker state last changed. + Empty when circuit breaker is disabled or has never changed state. + format: date-time + type: string + consecutiveFailures: + description: |- + ConsecutiveFailures is the current count of consecutive health check failures. + Resets to 0 when the backend becomes healthy again. + type: integer + lastHealthCheck: + description: LastHealthCheck is the timestamp of the last + health check + format: date-time + type: string + mcpRevision: + description: |- + MCPRevision is the backend's negotiated MCP protocol revision + ("2026-07-28" or "2025-11-25"). Empty when the backend has not been probed. + type: string + message: + description: Message provides additional information about + the backend status + type: string + name: + description: Name is the name of the backend MCPServer + type: string + status: + description: |- + Status is the current status of the backend (ready, degraded, unavailable, unauthenticated, unknown). + Use BackendHealthStatus.ToCRDStatus() to populate this field. + type: string + url: + description: URL is the URL of the backend MCPServer + type: string + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + message: + description: Message provides detail about the runtime phase. + type: string + phase: + description: Phase is the lifecycle phase observed by the running + vMCP process. + enum: + - Pending + - Ready + - Degraded + - Failed + type: string + type: object telemetryConfigHash: description: |- TelemetryConfigHash is the hash of the referenced MCPTelemetryConfig spec for change detection. @@ -4887,12 +5066,16 @@ spec: type: boolean allowedActors: items: + maxLength: 256 + minLength: 1 type: string maxItems: 50 type: array x-kubernetes-list-type: atomic allowedDelegateClients: items: + maxLength: 256 + minLength: 1 type: string maxItems: 50 minItems: 1 @@ -4914,6 +5097,21 @@ spec: - expectedAudience - issuerRef type: object + x-kubernetes-validations: + - message: allowedDelegateClients must not combine the + wildcard "*" with specific client IDs + rule: '!(''*'' in self.allowedDelegateClients) || + size(self.allowedDelegateClients) == 1' + - message: allowMayAct must not be enabled when allowedDelegateClients + contains the wildcard "*" + rule: '!(self.allowMayAct && ''*'' in self.allowedDelegateClients)' + - message: actorClaim must name a readable claim; use + client_id or a non-reserved claim such as azp, appid, + or cid + rule: '!has(self.actorClaim) || !(self.actorClaim + in [''sub'', ''iss'', ''aud'', ''exp'', ''iat'', + ''nbf'', ''jti'', ''name'', ''email'', ''scope'', + ''scp'', ''may_act''])' maxItems: 20 type: array x-kubernetes-list-type: atomic @@ -4955,9 +5153,10 @@ spec: as an operator condition. One combination is rejected at admission on all three CRDs regardless of the - above: setting this field alongside allowConfidentialClientRegistration, which - would issue client secrets in cleartext over an unauthenticated registration - endpoint (see the XValidation rule on EmbeddedAuthServerConfig). + above: setting this field alongside confidential client registration or + delegate clients, which would issue or use client secrets in cleartext over + an unauthenticated endpoint (see the XValidation rule on + EmbeddedAuthServerConfig). type: boolean issuer: description: |- @@ -5290,9 +5489,9 @@ spec: AllowPrivateIPs permits OIDC discovery and JWKS fetches for THIS issuer to resolve to a private or loopback address. Use only when the issuer is hosted inside the same cluster and has no public endpoint. Requires - jwksUrl to be set explicitly (enforced at reconcile time), since - otherwise OIDC discovery — fetched from the external issuer itself — - would choose the private dial target. + jwksUrl to be set explicitly (enforced at admission and by shared + validation), since otherwise OIDC discovery — fetched from the external + issuer itself — would choose the private dial target. type: boolean allowedActors: description: |- @@ -6137,20 +6336,28 @@ spec: > 0) || (has(self.trustedIssuers) && self.trustedIssuers.exists(issuer, has(issuer.jwtBearerGrant))) || (has(self.inboundGrants) && (has(self.inboundGrants.tokenExchange) || has(self.inboundGrants.jwtBearer))) - - message: allowConfidentialClientRegistration cannot be combined - with insecureAllowHTTP; client secrets would be issued in cleartext - over an unauthenticated endpoint - rule: '!(has(self.allowConfidentialClientRegistration) && self.allowConfidentialClientRegistration - && has(self.insecureAllowHTTP) && self.insecureAllowHTTP)' + - message: insecureAllowHTTP cannot be combined with confidential + client registration or delegateClients; client secrets would be + issued or used in cleartext over an unauthenticated endpoint + rule: '!(has(self.insecureAllowHTTP) && self.insecureAllowHTTP && + ((has(self.allowConfidentialClientRegistration) && self.allowConfidentialClientRegistration) + || (has(self.delegateClients) && size(self.delegateClients) > + 0) || (has(self.inboundGrants) && has(self.inboundGrants.tokenExchange) + && has(self.inboundGrants.tokenExchange.delegateClients) && size(self.inboundGrants.tokenExchange.delegateClients) + > 0)))' - message: forceConfidentialRedirectUris requires allowConfidentialClientRegistration to be true rule: (!has(self.forceConfidentialRedirectUris) || size(self.forceConfidentialRedirectUris) == 0) || (has(self.allowConfidentialClientRegistration) && self.allowConfidentialClientRegistration) - - message: delegateClients with an HTTP issuer require insecureAllowConfidentialOverLoopbackHTTP + - message: confidential client registration or delegateClients with + an HTTP issuer require insecureAllowConfidentialOverLoopbackHTTP to be explicitly enabled; the issuer must still be loopback - rule: '!has(self.delegateClients) || size(self.delegateClients) - == 0 || !self.issuer.startsWith(''http://'') || (has(self.insecureAllowConfidentialOverLoopbackHTTP) - && self.insecureAllowConfidentialOverLoopbackHTTP)' + rule: '!self.issuer.startsWith(''http://'') || (has(self.insecureAllowConfidentialOverLoopbackHTTP) + && self.insecureAllowConfidentialOverLoopbackHTTP) || ((!has(self.allowConfidentialClientRegistration) + || !self.allowConfidentialClientRegistration) && (!has(self.delegateClients) + || size(self.delegateClients) == 0) && (!has(self.inboundGrants) + || !has(self.inboundGrants.tokenExchange) || !has(self.inboundGrants.tokenExchange.delegateClients) + || size(self.inboundGrants.tokenExchange.delegateClients) == 0))' - message: canonical tokenExchange conflicts with legacy delegateClients or RFC 8693 trusted issuer policy rule: '!has(self.inboundGrants) || !has(self.inboundGrants.tokenExchange) @@ -8784,6 +8991,157 @@ spec: - Degraded - Failed type: string + runtime: + description: |- + Runtime is the status snapshot written exclusively by the vMCP process. + The operator projects it into the top-level compatibility fields. + properties: + backendCount: + description: BackendCount is the number of routable backends observed + by the runtime. + format: int32 + type: integer + conditions: + description: Conditions contains runtime health observations. + items: + description: Condition contains details for one aspect of the + current state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, + Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + discoveredBackends: + description: DiscoveredBackends contains the runtime's latest + backend observations. + items: + description: |- + DiscoveredBackend represents a backend server discovered by vMCP runtime. + This type is shared with the Kubernetes operator CRD (VirtualMCPServer.Status.DiscoveredBackends). + properties: + authConfigRef: + description: AuthConfigRef is the name of the discovered + MCPExternalAuthConfig (if any) + type: string + authType: + description: AuthType is the type of authentication configured + type: string + circuitBreakerState: + description: |- + CircuitBreakerState is the current circuit breaker state (closed, open, half-open). + Empty when circuit breaker is disabled or not configured. + enum: + - closed + - open + - half-open + type: string + circuitLastChanged: + description: |- + CircuitLastChanged is the timestamp when the circuit breaker state last changed. + Empty when circuit breaker is disabled or has never changed state. + format: date-time + type: string + consecutiveFailures: + description: |- + ConsecutiveFailures is the current count of consecutive health check failures. + Resets to 0 when the backend becomes healthy again. + type: integer + lastHealthCheck: + description: LastHealthCheck is the timestamp of the last + health check + format: date-time + type: string + mcpRevision: + description: |- + MCPRevision is the backend's negotiated MCP protocol revision + ("2026-07-28" or "2025-11-25"). Empty when the backend has not been probed. + type: string + message: + description: Message provides additional information about + the backend status + type: string + name: + description: Name is the name of the backend MCPServer + type: string + status: + description: |- + Status is the current status of the backend (ready, degraded, unavailable, unauthenticated, unknown). + Use BackendHealthStatus.ToCRDStatus() to populate this field. + type: string + url: + description: URL is the URL of the backend MCPServer + type: string + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + message: + description: Message provides detail about the runtime phase. + type: string + phase: + description: Phase is the lifecycle phase observed by the running + vMCP process. + enum: + - Pending + - Ready + - Degraded + - Failed + type: string + type: object telemetryConfigHash: description: |- TelemetryConfigHash is the hash of the referenced MCPTelemetryConfig spec for change detection. 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 b784c73136..7953ad41ee 100644 --- a/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpexternalauthconfigs.yaml +++ b/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpexternalauthconfigs.yaml @@ -626,12 +626,16 @@ spec: type: boolean allowedActors: items: + maxLength: 256 + minLength: 1 type: string maxItems: 50 type: array x-kubernetes-list-type: atomic allowedDelegateClients: items: + maxLength: 256 + minLength: 1 type: string maxItems: 50 minItems: 1 @@ -653,6 +657,21 @@ spec: - expectedAudience - issuerRef type: object + x-kubernetes-validations: + - message: allowedDelegateClients must not combine the + wildcard "*" with specific client IDs + rule: '!(''*'' in self.allowedDelegateClients) || + size(self.allowedDelegateClients) == 1' + - message: allowMayAct must not be enabled when allowedDelegateClients + contains the wildcard "*" + rule: '!(self.allowMayAct && ''*'' in self.allowedDelegateClients)' + - message: actorClaim must name a readable claim; use + client_id or a non-reserved claim such as azp, appid, + or cid + rule: '!has(self.actorClaim) || !(self.actorClaim + in [''sub'', ''iss'', ''aud'', ''exp'', ''iat'', + ''nbf'', ''jti'', ''name'', ''email'', ''scope'', + ''scp'', ''may_act''])' maxItems: 20 type: array x-kubernetes-list-type: atomic @@ -694,9 +713,10 @@ spec: as an operator condition. One combination is rejected at admission on all three CRDs regardless of the - above: setting this field alongside allowConfidentialClientRegistration, which - would issue client secrets in cleartext over an unauthenticated registration - endpoint (see the XValidation rule on EmbeddedAuthServerConfig). + above: setting this field alongside confidential client registration or + delegate clients, which would issue or use client secrets in cleartext over + an unauthenticated endpoint (see the XValidation rule on + EmbeddedAuthServerConfig). type: boolean issuer: description: |- @@ -1029,9 +1049,9 @@ spec: AllowPrivateIPs permits OIDC discovery and JWKS fetches for THIS issuer to resolve to a private or loopback address. Use only when the issuer is hosted inside the same cluster and has no public endpoint. Requires - jwksUrl to be set explicitly (enforced at reconcile time), since - otherwise OIDC discovery — fetched from the external issuer itself — - would choose the private dial target. + jwksUrl to be set explicitly (enforced at admission and by shared + validation), since otherwise OIDC discovery — fetched from the external + issuer itself — would choose the private dial target. type: boolean allowedActors: description: |- @@ -1876,20 +1896,28 @@ spec: > 0) || (has(self.trustedIssuers) && self.trustedIssuers.exists(issuer, has(issuer.jwtBearerGrant))) || (has(self.inboundGrants) && (has(self.inboundGrants.tokenExchange) || has(self.inboundGrants.jwtBearer))) - - message: allowConfidentialClientRegistration cannot be combined - with insecureAllowHTTP; client secrets would be issued in cleartext - over an unauthenticated endpoint - rule: '!(has(self.allowConfidentialClientRegistration) && self.allowConfidentialClientRegistration - && has(self.insecureAllowHTTP) && self.insecureAllowHTTP)' + - message: insecureAllowHTTP cannot be combined with confidential + client registration or delegateClients; client secrets would be + issued or used in cleartext over an unauthenticated endpoint + rule: '!(has(self.insecureAllowHTTP) && self.insecureAllowHTTP && + ((has(self.allowConfidentialClientRegistration) && self.allowConfidentialClientRegistration) + || (has(self.delegateClients) && size(self.delegateClients) > + 0) || (has(self.inboundGrants) && has(self.inboundGrants.tokenExchange) + && has(self.inboundGrants.tokenExchange.delegateClients) && size(self.inboundGrants.tokenExchange.delegateClients) + > 0)))' - message: forceConfidentialRedirectUris requires allowConfidentialClientRegistration to be true rule: (!has(self.forceConfidentialRedirectUris) || size(self.forceConfidentialRedirectUris) == 0) || (has(self.allowConfidentialClientRegistration) && self.allowConfidentialClientRegistration) - - message: delegateClients with an HTTP issuer require insecureAllowConfidentialOverLoopbackHTTP + - message: confidential client registration or delegateClients with + an HTTP issuer require insecureAllowConfidentialOverLoopbackHTTP to be explicitly enabled; the issuer must still be loopback - rule: '!has(self.delegateClients) || size(self.delegateClients) - == 0 || !self.issuer.startsWith(''http://'') || (has(self.insecureAllowConfidentialOverLoopbackHTTP) - && self.insecureAllowConfidentialOverLoopbackHTTP)' + rule: '!self.issuer.startsWith(''http://'') || (has(self.insecureAllowConfidentialOverLoopbackHTTP) + && self.insecureAllowConfidentialOverLoopbackHTTP) || ((!has(self.allowConfidentialClientRegistration) + || !self.allowConfidentialClientRegistration) && (!has(self.delegateClients) + || size(self.delegateClients) == 0) && (!has(self.inboundGrants) + || !has(self.inboundGrants.tokenExchange) || !has(self.inboundGrants.tokenExchange.delegateClients) + || size(self.inboundGrants.tokenExchange.delegateClients) == 0))' - message: canonical tokenExchange conflicts with legacy delegateClients or RFC 8693 trusted issuer policy rule: '!has(self.inboundGrants) || !has(self.inboundGrants.tokenExchange) @@ -3003,12 +3031,16 @@ spec: type: boolean allowedActors: items: + maxLength: 256 + minLength: 1 type: string maxItems: 50 type: array x-kubernetes-list-type: atomic allowedDelegateClients: items: + maxLength: 256 + minLength: 1 type: string maxItems: 50 minItems: 1 @@ -3030,6 +3062,21 @@ spec: - expectedAudience - issuerRef type: object + x-kubernetes-validations: + - message: allowedDelegateClients must not combine the + wildcard "*" with specific client IDs + rule: '!(''*'' in self.allowedDelegateClients) || + size(self.allowedDelegateClients) == 1' + - message: allowMayAct must not be enabled when allowedDelegateClients + contains the wildcard "*" + rule: '!(self.allowMayAct && ''*'' in self.allowedDelegateClients)' + - message: actorClaim must name a readable claim; use + client_id or a non-reserved claim such as azp, appid, + or cid + rule: '!has(self.actorClaim) || !(self.actorClaim + in [''sub'', ''iss'', ''aud'', ''exp'', ''iat'', + ''nbf'', ''jti'', ''name'', ''email'', ''scope'', + ''scp'', ''may_act''])' maxItems: 20 type: array x-kubernetes-list-type: atomic @@ -3071,9 +3118,10 @@ spec: as an operator condition. One combination is rejected at admission on all three CRDs regardless of the - above: setting this field alongside allowConfidentialClientRegistration, which - would issue client secrets in cleartext over an unauthenticated registration - endpoint (see the XValidation rule on EmbeddedAuthServerConfig). + above: setting this field alongside confidential client registration or + delegate clients, which would issue or use client secrets in cleartext over + an unauthenticated endpoint (see the XValidation rule on + EmbeddedAuthServerConfig). type: boolean issuer: description: |- @@ -3406,9 +3454,9 @@ spec: AllowPrivateIPs permits OIDC discovery and JWKS fetches for THIS issuer to resolve to a private or loopback address. Use only when the issuer is hosted inside the same cluster and has no public endpoint. Requires - jwksUrl to be set explicitly (enforced at reconcile time), since - otherwise OIDC discovery — fetched from the external issuer itself — - would choose the private dial target. + jwksUrl to be set explicitly (enforced at admission and by shared + validation), since otherwise OIDC discovery — fetched from the external + issuer itself — would choose the private dial target. type: boolean allowedActors: description: |- @@ -4253,20 +4301,28 @@ spec: > 0) || (has(self.trustedIssuers) && self.trustedIssuers.exists(issuer, has(issuer.jwtBearerGrant))) || (has(self.inboundGrants) && (has(self.inboundGrants.tokenExchange) || has(self.inboundGrants.jwtBearer))) - - message: allowConfidentialClientRegistration cannot be combined - with insecureAllowHTTP; client secrets would be issued in cleartext - over an unauthenticated endpoint - rule: '!(has(self.allowConfidentialClientRegistration) && self.allowConfidentialClientRegistration - && has(self.insecureAllowHTTP) && self.insecureAllowHTTP)' + - message: insecureAllowHTTP cannot be combined with confidential + client registration or delegateClients; client secrets would be + issued or used in cleartext over an unauthenticated endpoint + rule: '!(has(self.insecureAllowHTTP) && self.insecureAllowHTTP && + ((has(self.allowConfidentialClientRegistration) && self.allowConfidentialClientRegistration) + || (has(self.delegateClients) && size(self.delegateClients) > + 0) || (has(self.inboundGrants) && has(self.inboundGrants.tokenExchange) + && has(self.inboundGrants.tokenExchange.delegateClients) && size(self.inboundGrants.tokenExchange.delegateClients) + > 0)))' - message: forceConfidentialRedirectUris requires allowConfidentialClientRegistration to be true rule: (!has(self.forceConfidentialRedirectUris) || size(self.forceConfidentialRedirectUris) == 0) || (has(self.allowConfidentialClientRegistration) && self.allowConfidentialClientRegistration) - - message: delegateClients with an HTTP issuer require insecureAllowConfidentialOverLoopbackHTTP + - message: confidential client registration or delegateClients with + an HTTP issuer require insecureAllowConfidentialOverLoopbackHTTP to be explicitly enabled; the issuer must still be loopback - rule: '!has(self.delegateClients) || size(self.delegateClients) - == 0 || !self.issuer.startsWith(''http://'') || (has(self.insecureAllowConfidentialOverLoopbackHTTP) - && self.insecureAllowConfidentialOverLoopbackHTTP)' + rule: '!self.issuer.startsWith(''http://'') || (has(self.insecureAllowConfidentialOverLoopbackHTTP) + && self.insecureAllowConfidentialOverLoopbackHTTP) || ((!has(self.allowConfidentialClientRegistration) + || !self.allowConfidentialClientRegistration) && (!has(self.delegateClients) + || size(self.delegateClients) == 0) && (!has(self.inboundGrants) + || !has(self.inboundGrants.tokenExchange) || !has(self.inboundGrants.tokenExchange.delegateClients) + || size(self.inboundGrants.tokenExchange.delegateClients) == 0))' - message: canonical tokenExchange conflicts with legacy delegateClients or RFC 8693 trusted issuer policy rule: '!has(self.inboundGrants) || !has(self.inboundGrants.tokenExchange) 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 a0b681ab42..b50d1c8fcf 100644 --- a/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_virtualmcpservers.yaml +++ b/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_virtualmcpservers.yaml @@ -502,12 +502,16 @@ spec: type: boolean allowedActors: items: + maxLength: 256 + minLength: 1 type: string maxItems: 50 type: array x-kubernetes-list-type: atomic allowedDelegateClients: items: + maxLength: 256 + minLength: 1 type: string maxItems: 50 minItems: 1 @@ -529,6 +533,21 @@ spec: - expectedAudience - issuerRef type: object + x-kubernetes-validations: + - message: allowedDelegateClients must not combine the + wildcard "*" with specific client IDs + rule: '!(''*'' in self.allowedDelegateClients) || + size(self.allowedDelegateClients) == 1' + - message: allowMayAct must not be enabled when allowedDelegateClients + contains the wildcard "*" + rule: '!(self.allowMayAct && ''*'' in self.allowedDelegateClients)' + - message: actorClaim must name a readable claim; use + client_id or a non-reserved claim such as azp, appid, + or cid + rule: '!has(self.actorClaim) || !(self.actorClaim + in [''sub'', ''iss'', ''aud'', ''exp'', ''iat'', + ''nbf'', ''jti'', ''name'', ''email'', ''scope'', + ''scp'', ''may_act''])' maxItems: 20 type: array x-kubernetes-list-type: atomic @@ -570,9 +589,10 @@ spec: as an operator condition. One combination is rejected at admission on all three CRDs regardless of the - above: setting this field alongside allowConfidentialClientRegistration, which - would issue client secrets in cleartext over an unauthenticated registration - endpoint (see the XValidation rule on EmbeddedAuthServerConfig). + above: setting this field alongside confidential client registration or + delegate clients, which would issue or use client secrets in cleartext over + an unauthenticated endpoint (see the XValidation rule on + EmbeddedAuthServerConfig). type: boolean issuer: description: |- @@ -905,9 +925,9 @@ spec: AllowPrivateIPs permits OIDC discovery and JWKS fetches for THIS issuer to resolve to a private or loopback address. Use only when the issuer is hosted inside the same cluster and has no public endpoint. Requires - jwksUrl to be set explicitly (enforced at reconcile time), since - otherwise OIDC discovery — fetched from the external issuer itself — - would choose the private dial target. + jwksUrl to be set explicitly (enforced at admission and by shared + validation), since otherwise OIDC discovery — fetched from the external + issuer itself — would choose the private dial target. type: boolean allowedActors: description: |- @@ -1752,20 +1772,28 @@ spec: > 0) || (has(self.trustedIssuers) && self.trustedIssuers.exists(issuer, has(issuer.jwtBearerGrant))) || (has(self.inboundGrants) && (has(self.inboundGrants.tokenExchange) || has(self.inboundGrants.jwtBearer))) - - message: allowConfidentialClientRegistration cannot be combined - with insecureAllowHTTP; client secrets would be issued in cleartext - over an unauthenticated endpoint - rule: '!(has(self.allowConfidentialClientRegistration) && self.allowConfidentialClientRegistration - && has(self.insecureAllowHTTP) && self.insecureAllowHTTP)' + - message: insecureAllowHTTP cannot be combined with confidential + client registration or delegateClients; client secrets would be + issued or used in cleartext over an unauthenticated endpoint + rule: '!(has(self.insecureAllowHTTP) && self.insecureAllowHTTP && + ((has(self.allowConfidentialClientRegistration) && self.allowConfidentialClientRegistration) + || (has(self.delegateClients) && size(self.delegateClients) > + 0) || (has(self.inboundGrants) && has(self.inboundGrants.tokenExchange) + && has(self.inboundGrants.tokenExchange.delegateClients) && size(self.inboundGrants.tokenExchange.delegateClients) + > 0)))' - message: forceConfidentialRedirectUris requires allowConfidentialClientRegistration to be true rule: (!has(self.forceConfidentialRedirectUris) || size(self.forceConfidentialRedirectUris) == 0) || (has(self.allowConfidentialClientRegistration) && self.allowConfidentialClientRegistration) - - message: delegateClients with an HTTP issuer require insecureAllowConfidentialOverLoopbackHTTP + - message: confidential client registration or delegateClients with + an HTTP issuer require insecureAllowConfidentialOverLoopbackHTTP to be explicitly enabled; the issuer must still be loopback - rule: '!has(self.delegateClients) || size(self.delegateClients) - == 0 || !self.issuer.startsWith(''http://'') || (has(self.insecureAllowConfidentialOverLoopbackHTTP) - && self.insecureAllowConfidentialOverLoopbackHTTP)' + rule: '!self.issuer.startsWith(''http://'') || (has(self.insecureAllowConfidentialOverLoopbackHTTP) + && self.insecureAllowConfidentialOverLoopbackHTTP) || ((!has(self.allowConfidentialClientRegistration) + || !self.allowConfidentialClientRegistration) && (!has(self.delegateClients) + || size(self.delegateClients) == 0) && (!has(self.inboundGrants) + || !has(self.inboundGrants.tokenExchange) || !has(self.inboundGrants.tokenExchange.delegateClients) + || size(self.inboundGrants.tokenExchange.delegateClients) == 0))' - message: canonical tokenExchange conflicts with legacy delegateClients or RFC 8693 trusted issuer policy rule: '!has(self.inboundGrants) || !has(self.inboundGrants.tokenExchange) @@ -4399,6 +4427,157 @@ spec: - Degraded - Failed type: string + runtime: + description: |- + Runtime is the status snapshot written exclusively by the vMCP process. + The operator projects it into the top-level compatibility fields. + properties: + backendCount: + description: BackendCount is the number of routable backends observed + by the runtime. + format: int32 + type: integer + conditions: + description: Conditions contains runtime health observations. + items: + description: Condition contains details for one aspect of the + current state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, + Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + discoveredBackends: + description: DiscoveredBackends contains the runtime's latest + backend observations. + items: + description: |- + DiscoveredBackend represents a backend server discovered by vMCP runtime. + This type is shared with the Kubernetes operator CRD (VirtualMCPServer.Status.DiscoveredBackends). + properties: + authConfigRef: + description: AuthConfigRef is the name of the discovered + MCPExternalAuthConfig (if any) + type: string + authType: + description: AuthType is the type of authentication configured + type: string + circuitBreakerState: + description: |- + CircuitBreakerState is the current circuit breaker state (closed, open, half-open). + Empty when circuit breaker is disabled or not configured. + enum: + - closed + - open + - half-open + type: string + circuitLastChanged: + description: |- + CircuitLastChanged is the timestamp when the circuit breaker state last changed. + Empty when circuit breaker is disabled or has never changed state. + format: date-time + type: string + consecutiveFailures: + description: |- + ConsecutiveFailures is the current count of consecutive health check failures. + Resets to 0 when the backend becomes healthy again. + type: integer + lastHealthCheck: + description: LastHealthCheck is the timestamp of the last + health check + format: date-time + type: string + mcpRevision: + description: |- + MCPRevision is the backend's negotiated MCP protocol revision + ("2026-07-28" or "2025-11-25"). Empty when the backend has not been probed. + type: string + message: + description: Message provides additional information about + the backend status + type: string + name: + description: Name is the name of the backend MCPServer + type: string + status: + description: |- + Status is the current status of the backend (ready, degraded, unavailable, unauthenticated, unknown). + Use BackendHealthStatus.ToCRDStatus() to populate this field. + type: string + url: + description: URL is the URL of the backend MCPServer + type: string + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + message: + description: Message provides detail about the runtime phase. + type: string + phase: + description: Phase is the lifecycle phase observed by the running + vMCP process. + enum: + - Pending + - Ready + - Degraded + - Failed + type: string + type: object telemetryConfigHash: description: |- TelemetryConfigHash is the hash of the referenced MCPTelemetryConfig spec for change detection. @@ -4890,12 +5069,16 @@ spec: type: boolean allowedActors: items: + maxLength: 256 + minLength: 1 type: string maxItems: 50 type: array x-kubernetes-list-type: atomic allowedDelegateClients: items: + maxLength: 256 + minLength: 1 type: string maxItems: 50 minItems: 1 @@ -4917,6 +5100,21 @@ spec: - expectedAudience - issuerRef type: object + x-kubernetes-validations: + - message: allowedDelegateClients must not combine the + wildcard "*" with specific client IDs + rule: '!(''*'' in self.allowedDelegateClients) || + size(self.allowedDelegateClients) == 1' + - message: allowMayAct must not be enabled when allowedDelegateClients + contains the wildcard "*" + rule: '!(self.allowMayAct && ''*'' in self.allowedDelegateClients)' + - message: actorClaim must name a readable claim; use + client_id or a non-reserved claim such as azp, appid, + or cid + rule: '!has(self.actorClaim) || !(self.actorClaim + in [''sub'', ''iss'', ''aud'', ''exp'', ''iat'', + ''nbf'', ''jti'', ''name'', ''email'', ''scope'', + ''scp'', ''may_act''])' maxItems: 20 type: array x-kubernetes-list-type: atomic @@ -4958,9 +5156,10 @@ spec: as an operator condition. One combination is rejected at admission on all three CRDs regardless of the - above: setting this field alongside allowConfidentialClientRegistration, which - would issue client secrets in cleartext over an unauthenticated registration - endpoint (see the XValidation rule on EmbeddedAuthServerConfig). + above: setting this field alongside confidential client registration or + delegate clients, which would issue or use client secrets in cleartext over + an unauthenticated endpoint (see the XValidation rule on + EmbeddedAuthServerConfig). type: boolean issuer: description: |- @@ -5293,9 +5492,9 @@ spec: AllowPrivateIPs permits OIDC discovery and JWKS fetches for THIS issuer to resolve to a private or loopback address. Use only when the issuer is hosted inside the same cluster and has no public endpoint. Requires - jwksUrl to be set explicitly (enforced at reconcile time), since - otherwise OIDC discovery — fetched from the external issuer itself — - would choose the private dial target. + jwksUrl to be set explicitly (enforced at admission and by shared + validation), since otherwise OIDC discovery — fetched from the external + issuer itself — would choose the private dial target. type: boolean allowedActors: description: |- @@ -6140,20 +6339,28 @@ spec: > 0) || (has(self.trustedIssuers) && self.trustedIssuers.exists(issuer, has(issuer.jwtBearerGrant))) || (has(self.inboundGrants) && (has(self.inboundGrants.tokenExchange) || has(self.inboundGrants.jwtBearer))) - - message: allowConfidentialClientRegistration cannot be combined - with insecureAllowHTTP; client secrets would be issued in cleartext - over an unauthenticated endpoint - rule: '!(has(self.allowConfidentialClientRegistration) && self.allowConfidentialClientRegistration - && has(self.insecureAllowHTTP) && self.insecureAllowHTTP)' + - message: insecureAllowHTTP cannot be combined with confidential + client registration or delegateClients; client secrets would be + issued or used in cleartext over an unauthenticated endpoint + rule: '!(has(self.insecureAllowHTTP) && self.insecureAllowHTTP && + ((has(self.allowConfidentialClientRegistration) && self.allowConfidentialClientRegistration) + || (has(self.delegateClients) && size(self.delegateClients) > + 0) || (has(self.inboundGrants) && has(self.inboundGrants.tokenExchange) + && has(self.inboundGrants.tokenExchange.delegateClients) && size(self.inboundGrants.tokenExchange.delegateClients) + > 0)))' - message: forceConfidentialRedirectUris requires allowConfidentialClientRegistration to be true rule: (!has(self.forceConfidentialRedirectUris) || size(self.forceConfidentialRedirectUris) == 0) || (has(self.allowConfidentialClientRegistration) && self.allowConfidentialClientRegistration) - - message: delegateClients with an HTTP issuer require insecureAllowConfidentialOverLoopbackHTTP + - message: confidential client registration or delegateClients with + an HTTP issuer require insecureAllowConfidentialOverLoopbackHTTP to be explicitly enabled; the issuer must still be loopback - rule: '!has(self.delegateClients) || size(self.delegateClients) - == 0 || !self.issuer.startsWith(''http://'') || (has(self.insecureAllowConfidentialOverLoopbackHTTP) - && self.insecureAllowConfidentialOverLoopbackHTTP)' + rule: '!self.issuer.startsWith(''http://'') || (has(self.insecureAllowConfidentialOverLoopbackHTTP) + && self.insecureAllowConfidentialOverLoopbackHTTP) || ((!has(self.allowConfidentialClientRegistration) + || !self.allowConfidentialClientRegistration) && (!has(self.delegateClients) + || size(self.delegateClients) == 0) && (!has(self.inboundGrants) + || !has(self.inboundGrants.tokenExchange) || !has(self.inboundGrants.tokenExchange.delegateClients) + || size(self.inboundGrants.tokenExchange.delegateClients) == 0))' - message: canonical tokenExchange conflicts with legacy delegateClients or RFC 8693 trusted issuer policy rule: '!has(self.inboundGrants) || !has(self.inboundGrants.tokenExchange) @@ -8787,6 +8994,157 @@ spec: - Degraded - Failed type: string + runtime: + description: |- + Runtime is the status snapshot written exclusively by the vMCP process. + The operator projects it into the top-level compatibility fields. + properties: + backendCount: + description: BackendCount is the number of routable backends observed + by the runtime. + format: int32 + type: integer + conditions: + description: Conditions contains runtime health observations. + items: + description: Condition contains details for one aspect of the + current state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, + Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + discoveredBackends: + description: DiscoveredBackends contains the runtime's latest + backend observations. + items: + description: |- + DiscoveredBackend represents a backend server discovered by vMCP runtime. + This type is shared with the Kubernetes operator CRD (VirtualMCPServer.Status.DiscoveredBackends). + properties: + authConfigRef: + description: AuthConfigRef is the name of the discovered + MCPExternalAuthConfig (if any) + type: string + authType: + description: AuthType is the type of authentication configured + type: string + circuitBreakerState: + description: |- + CircuitBreakerState is the current circuit breaker state (closed, open, half-open). + Empty when circuit breaker is disabled or not configured. + enum: + - closed + - open + - half-open + type: string + circuitLastChanged: + description: |- + CircuitLastChanged is the timestamp when the circuit breaker state last changed. + Empty when circuit breaker is disabled or has never changed state. + format: date-time + type: string + consecutiveFailures: + description: |- + ConsecutiveFailures is the current count of consecutive health check failures. + Resets to 0 when the backend becomes healthy again. + type: integer + lastHealthCheck: + description: LastHealthCheck is the timestamp of the last + health check + format: date-time + type: string + mcpRevision: + description: |- + MCPRevision is the backend's negotiated MCP protocol revision + ("2026-07-28" or "2025-11-25"). Empty when the backend has not been probed. + type: string + message: + description: Message provides additional information about + the backend status + type: string + name: + description: Name is the name of the backend MCPServer + type: string + status: + description: |- + Status is the current status of the backend (ready, degraded, unavailable, unauthenticated, unknown). + Use BackendHealthStatus.ToCRDStatus() to populate this field. + type: string + url: + description: URL is the URL of the backend MCPServer + type: string + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + message: + description: Message provides detail about the runtime phase. + type: string + phase: + description: Phase is the lifecycle phase observed by the running + vMCP process. + enum: + - Pending + - Ready + - Degraded + - Failed + type: string + type: object telemetryConfigHash: description: |- TelemetryConfigHash is the hash of the referenced MCPTelemetryConfig spec for change detection. diff --git a/docs/arch/10-virtual-mcp-architecture.md b/docs/arch/10-virtual-mcp-architecture.md index df84a8dd43..95ee483b4e 100644 --- a/docs/arch/10-virtual-mcp-architecture.md +++ b/docs/arch/10-virtual-mcp-architecture.md @@ -1147,6 +1147,7 @@ Status reporting enables vMCP runtime to report operational status directly inst - Phase: Pending, Ready, Degraded, Failed - Conditions: `metav1.Condition` (ready, backends discovered, auth configured) using shared constants - DiscoveredBackends: backend URL/auth type/health with timestamps +- Kubernetes reporter: writes the runtime-owned `status.runtime` snapshot. The operator is the sole writer of top-level status fields and projects runtime phase, message, backend observations, and runtime conditions into their existing top-level compatibility fields during reconciliation. This ownership boundary prevents the runtime and operator from replacing the same conditions array concurrently. - CLI reporter: Logging-only reporter (no persistence) logs status updates at Debug level (visible when `--debug` is set). - Lifecycle hook: server starts the reporter, collects shutdown funcs, and stops them during graceful shutdown. @@ -1159,7 +1160,7 @@ Status reporting enables vMCP runtime to report operational status directly inst ### Extensibility - Additional reporters can be added under `pkg/vmcp/status/` implementing `Reporter` and using shared `vmcp.Status` types. -- Future sinks: Kubernetes status writer, file-based reporter for CLI (`thv status`), metrics exporter. +- Future sinks: file-based reporter for CLI (`thv status`), metrics exporter. **Implementation**: `pkg/vmcp/status/` diff --git a/docs/operator/crd-api.md b/docs/operator/crd-api.md index 05080602cd..ed5942484d 100644 --- a/docs/operator/crd-api.md +++ b/docs/operator/crd-api.md @@ -1962,8 +1962,9 @@ precise loopback-host security check. The shared Go-level ValidateConfidentialClientTransport validator remains the source of truth for confidential-client transport and loopback policy, -including delegate clients. Full issuer URL validation is performed by the -runtime configuration validator. +including delegate clients. Trusted issuer endpoint shape is validated by +ValidateInboundGrants; audience and outbound DNS/private-IP checks remain +runtime-only. @@ -1983,7 +1984,7 @@ _Appears in:_ | `primaryUpstreamProvider` _string_ | PrimaryUpstreamProvider names the upstream IDP whose access token Cedar
should read claims from when authorising a request. Must match the name
of one of the entries in UpstreamProviders. When empty, the controller
auto-selects the first entry of UpstreamProviders.
Only meaningful on VirtualMCPServer, where multiple upstream providers
can be configured and Cedar needs to pick which token's claims to
evaluate. The VirtualMCPServer controller validates this field against
UpstreamProviders at admission and rejects unresolvable values.
On MCPServer and MCPRemoteProxy this field is structurally present (the
EmbeddedAuthServerConfig struct is shared) but has no runtime effect:
those CRDs are restricted to a single upstream so there is no choice to
make. Setting it on those CRDs is silently ignored. | | MaxLength: 63
MinLength: 1
Pattern: `^[a-z0-9]([a-z0-9-]*[a-z0-9])?$`
Optional: \{\}
| | `storage` _[api.v1beta1.AuthServerStorageConfig](#apiv1beta1authserverstorageconfig)_ | Storage configures the storage backend for the embedded auth server.
If not specified, defaults to in-memory storage. | | Optional: \{\}
| | `disableUpstreamTokenInjection` _boolean_ | DisableUpstreamTokenInjection prevents the embedded auth server from injecting
upstream IdP tokens into requests forwarded to the backend MCP server.
When true, the embedded auth server still handles OAuth flows for clients,
but instead of swapping ToolHive JWTs for upstream tokens the proxy STRIPS
the client's credential headers (Authorization, Cookie, Proxy-Authorization)
after validating the JWT — the backend receives an unauthenticated request.
Use headerForward to attach static credentials (e.g. an API key) if the
backend needs them. Cannot be combined with token exchange, AWS STS, or OBO
middleware, which would re-add credentials after the strip.
This is useful when the backend MCP server does not require authentication
(e.g., public documentation servers) but you still want client authentication. | false | Optional: \{\}
| -| `insecureAllowHTTP` _boolean_ | 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).
Production deployments reachable outside the cluster MUST use https://.
On VirtualMCPServer: when false (the default), http:// issuers for non-localhost
hosts are rejected at reconcile time with an AuthServerConfigValidated=False condition.
On MCPServer and MCPRemoteProxy (via MCPExternalAuthConfig): this field is
structurally present but enforcement is deferred to pod startup via Config.Validate();
a misconfigured issuer will cause the pod to crash at startup rather than surface
as an operator condition.
One combination is rejected at admission on all three CRDs regardless of the
above: setting this field alongside allowConfidentialClientRegistration, which
would issue client secrets in cleartext over an unauthenticated registration
endpoint (see the XValidation rule on EmbeddedAuthServerConfig). | false | Optional: \{\}
| +| `insecureAllowHTTP` _boolean_ | 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).
Production deployments reachable outside the cluster MUST use https://.
On VirtualMCPServer: when false (the default), http:// issuers for non-localhost
hosts are rejected at reconcile time with an AuthServerConfigValidated=False condition.
On MCPServer and MCPRemoteProxy (via MCPExternalAuthConfig): this field is
structurally present but enforcement is deferred to pod startup via Config.Validate();
a misconfigured issuer will cause the pod to crash at startup rather than surface
as an operator condition.
One combination is rejected at admission on all three CRDs regardless of the
above: setting this field alongside confidential client registration or
delegate clients, which would issue or use client secrets in cleartext over
an unauthenticated endpoint (see the XValidation rule on
EmbeddedAuthServerConfig). | false | Optional: \{\}
| | `baselineClientScopes` _string array_ | BaselineClientScopes is a baseline set of OAuth 2.0 scopes guaranteed to be
included in every client registration. The embedded auth server unions these
scopes into the registered set returned by RFC 7591 Dynamic Client
Registration, so a client that narrows the `scope` field at /oauth/register
can still request the baseline scopes at /oauth/authorize. All values must
be present in the upstream-derived scopesSupported set; the auth server
fails to start if any value is missing.
Security: every client registered via /oauth/register will gain the
ability to request these scopes at /oauth/authorize, regardless of what
the client itself requested. Keep the baseline narrow (typically
"openid" and "offline_access"). Adding a privileged scope here — e.g.
"admin:read" — would grant it to every DCR-registered client, including
public clients like Claude Code, Cursor, and VS Code.
When cimd.enabled is true, every dynamically resolved CIMD client will
also gain the ability to request these scopes, including third-party
clients resolved from arbitrary HTTPS URLs. | | MaxItems: 10
items:MinLength: 1
items:Pattern: `^[\x21\x23-\x5B\x5D-\x7E]+$`
Optional: \{\}
| | `allowConfidentialClientRegistration` _boolean_ | AllowConfidentialClientRegistration permits RFC 7591 Dynamic Client
Registration of confidential clients: when true, /oauth/register
accepts token_endpoint_auth_method values client_secret_basic and
client_secret_post in addition to "none" (still the default on
omission) and mints a client_secret returned exactly once.
Confidential registrations are restricted to https non-loopback
redirect URIs, and on the Redis storage backend all DCR-issued
registrations are evicted after 30 days of inactivity and must
re-register. This gates registration only: disabling it does not
revoke or reject already-minted secrets at the token endpoint.
Security: registration is unauthenticated, so enabling this lets any
caller who can reach the endpoint obtain a client credential.
Combining it with insecureAllowHTTP is rejected at validation. | false | Optional: \{\}
| | `allowPrivateKeyJWTRegistration` _boolean_ | AllowPrivateKeyJWTRegistration permits Dynamic Client Registration of
clients using private_key_jwt authentication. Registration behavior is
intentionally configured separately from confidential-client registration.
Security: registration is unauthenticated, so enabling this lets any
caller who can reach the endpoint register a private_key_jwt client.
Unlike allowConfidentialClientRegistration, this is NOT rejected when
combined with insecureAllowHTTP: registration never returns a secret
for a private_key_jwt client, so there is nothing for cleartext HTTP
to expose. | false | Optional: \{\}
| @@ -4320,9 +4321,9 @@ _Appears in:_ | `issuerRef` _string_ | IssuerRef references trustedIssuers[].name. | | MaxLength: 253
MinLength: 1
| | `expectedAudience` _string_ | ExpectedAudience is the required RFC 8693 subject-token audience. | | MaxLength: 2048
MinLength: 1
| | `actorClaim` _string_ | ActorClaim names the claim containing the external actor identity. | | MaxLength: 64
Optional: \{\}
| -| `allowedActors` _string array_ | | | MaxItems: 50
Optional: \{\}
| +| `allowedActors` _string array_ | | | MaxItems: 50
items:MaxLength: 256
items:MinLength: 1
Optional: \{\}
| | `actorMatcher` _string_ | | | MaxLength: 4096
Optional: \{\}
| -| `allowedDelegateClients` _string array_ | | | MaxItems: 50
MinItems: 1
| +| `allowedDelegateClients` _string array_ | | | MaxItems: 50
MinItems: 1
items:MaxLength: 256
items:MinLength: 1
| | `allowMayAct` _boolean_ | | | Optional: \{\}
| @@ -4460,7 +4461,7 @@ _Appears in:_ | `expectedAudience` _string_ | ExpectedAudience is the expected "aud" claim value that must appear in
an RFC 8693 subject token's audience list. It is not used by an RFC 7523
JWT-bearer assertion, whose audience is the token endpoint.
This legacy field is deprecated; configure RFC 8693 policy under
inboundGrants.tokenExchange.issuerPolicies. | | MaxLength: 2048
MinLength: 1
Optional: \{\}
| | `jwksUrl` _string_ | JWKSURL is the URL to fetch the issuer's JSON Web Key Set from. If
empty, it is resolved via OIDC discovery at
\{issuerUrl\}/.well-known/openid-configuration. | | MaxLength: 2048
Optional: \{\}
| | `insecureAllowHTTP` _boolean_ | InsecureAllowHTTP permits plain-HTTP OIDC discovery and JWKS fetches
for THIS issuer only. Development and testing only — never set in
production. | | Optional: \{\}
| -| `allowPrivateIPs` _boolean_ | AllowPrivateIPs permits OIDC discovery and JWKS fetches for THIS issuer
to resolve to a private or loopback address. Use only when the issuer
is hosted inside the same cluster and has no public endpoint. Requires
jwksUrl to be set explicitly (enforced at reconcile time), since
otherwise OIDC discovery — fetched from the external issuer itself —
would choose the private dial target. | | Optional: \{\}
| +| `allowPrivateIPs` _boolean_ | AllowPrivateIPs permits OIDC discovery and JWKS fetches for THIS issuer
to resolve to a private or loopback address. Use only when the issuer
is hosted inside the same cluster and has no public endpoint. Requires
jwksUrl to be set explicitly (enforced at admission and by shared
validation), since otherwise OIDC discovery — fetched from the external
issuer itself — would choose the private dial target. | | Optional: \{\}
| | `caBundleRef` _[api.v1beta1.CABundleSource](#apiv1beta1cabundlesource)_ | CABundleRef references a ConfigMap containing PEM CA certificates used when
fetching this issuer's OIDC discovery document and JWKS. The bundle is added
to the system roots for this issuer's client only; public roots still apply
and other issuers are unaffected. Write access to the referenced ConfigMap is
equivalent to controlling this issuer's trust anchor for subject-token
validation — restrict it with the same care as a signing-key Secret. | | Optional: \{\}
| | `actorClaim` _string_ | ActorClaim names the claim identifying the client that requested the
subject token from this external issuer (used by allowedActors below).
Defaults to "azp" when empty; use "appid" for Microsoft Entra v1, "cid"
for Okta. The special value "client_id" reads the subject token's
client_id claim instead.
This legacy field is deprecated; configure RFC 8693 policy under
inboundGrants.tokenExchange.issuerPolicies. | | MaxLength: 64
Optional: \{\}
| | `allowedActors` _string array_ | AllowedActors is the allowlist of actorClaim values authorized to
exchange a subject token from this issuer when it carries no
"may_act" claim, in addition to (not instead of) actorMatcher below —
either signal is sufficient. Empty denies every token unless
actorMatcher is set, or allowMayAct is true and the token carries a
permitted may_act claim.
This legacy field is deprecated; configure RFC 8693 policy under
inboundGrants.tokenExchange.issuerPolicies. | | MaxItems: 50
items:MaxLength: 256
items:MinLength: 1
Optional: \{\}
| @@ -4742,6 +4743,7 @@ _Validation:_ - Enum: [Pending Ready Degraded Failed] _Appears in:_ +- [api.v1beta1.VirtualMCPServerRuntimeStatus](#apiv1beta1virtualmcpserverruntimestatus) - [api.v1beta1.VirtualMCPServerStatus](#apiv1beta1virtualmcpserverstatus) | Field | Description | @@ -4752,6 +4754,28 @@ _Appears in:_ | `Failed` | VirtualMCPServerPhaseFailed indicates the VirtualMCPServer has failed
| +#### api.v1beta1.VirtualMCPServerRuntimeStatus + + + +VirtualMCPServerRuntimeStatus is the runtime-owned status snapshot. The +operator projects this snapshot into the top-level compatibility fields and +remains the sole writer of the top-level Conditions array. + + + +_Appears in:_ +- [api.v1beta1.VirtualMCPServerStatus](#apiv1beta1virtualmcpserverstatus) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `phase` _[api.v1beta1.VirtualMCPServerPhase](#apiv1beta1virtualmcpserverphase)_ | Phase is the lifecycle phase observed by the running vMCP process. | | Enum: [Pending Ready Degraded Failed]
Optional: \{\}
| +| `message` _string_ | Message provides detail about the runtime phase. | | Optional: \{\}
| +| `conditions` _[Condition](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#condition-v1-meta) array_ | Conditions contains runtime health observations. | | Optional: \{\}
| +| `discoveredBackends` _[api.v1beta1.DiscoveredBackend](#apiv1beta1discoveredbackend) array_ | DiscoveredBackends contains the runtime's latest backend observations. | | Optional: \{\}
| +| `backendCount` _integer_ | BackendCount is the number of routable backends observed by the runtime. | | Optional: \{\}
| + + #### api.v1beta1.VirtualMCPServerSpec @@ -4795,6 +4819,7 @@ _Appears in:_ | Field | Description | Default | Validation | | --- | --- | --- | --- | +| `runtime` _[api.v1beta1.VirtualMCPServerRuntimeStatus](#apiv1beta1virtualmcpserverruntimestatus)_ | Runtime is the status snapshot written exclusively by the vMCP process.
The operator projects it into the top-level compatibility fields. | | Optional: \{\}
| | `conditions` _[Condition](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#condition-v1-meta) array_ | Conditions represent the latest available observations of the VirtualMCPServer's state | | Optional: \{\}
| | `observedGeneration` _integer_ | ObservedGeneration is the most recent generation observed for this VirtualMCPServer | | Optional: \{\}
| | `phase` _[api.v1beta1.VirtualMCPServerPhase](#apiv1beta1virtualmcpserverphase)_ | Phase is the current phase of the VirtualMCPServer | Pending | Enum: [Pending Ready Degraded Failed]
Optional: \{\}
| diff --git a/pkg/authserver/config.go b/pkg/authserver/config.go index f0d48da816..bd7a756258 100644 --- a/pkg/authserver/config.go +++ b/pkg/authserver/config.go @@ -1324,43 +1324,12 @@ func (c *Config) validateDelegationTokenLifespan() error { // validateBaselineClientScopes); NewMultiIssuerTokenValidator repeats these // checks again at server startup as defence in depth. // -// issuer_url is checked by validateTrustedIssuerURL, jwks_url (when set) by -// validateJWKSEndpointURL — see their doc comments for the URL rules each -// enforces. The remaining structural checks (required fields, self-issuer -// collision, duplicate issuers, ActorClaim reachability, and ActorMatcher -// compilation) run via tokenexchange.ValidateTrustedIssuers. +// issuer_url/jwks_url endpoint shape is validated by +// tokenexchange.ValidateTrustedIssuers itself (via validateTrustedIssuer), +// so both this path and MCPExternalAuthConfig's admission-time +// ValidateInboundGrants get it from the one place, rather than each +// re-implementing the same check. func validateTrustedIssuers(issuers []tokenexchange.TrustedIssuer, selfIssuer string, allowedAudiences []string) error { - for _, ti := range issuers { - if err := validateTrustedIssuerURL(ti.IssuerURL, ti.InsecureAllowHTTP); err != nil { - return fmt.Errorf("trusted_issuers: issuer_url %q: %w", ti.IssuerURL, err) - } - // AllowPrivateIPs without a hand-configured jwks_url would let OIDC - // discovery — a document fetched from, and thus influenceable by, - // the external issuer itself — choose the private target the dial - // is allowed to reach. Requiring jwks_url pins that target to - // operator-supplied config instead. - // - // This is the fail-fast layer, not the only one: validateTrustedIssuer - // (multi_issuer_validator.go) enforces the same invariant inside - // NewMultiIssuerTokenValidator, so a caller constructing a validator - // without routing through Config.Validate is still covered. Note that - // ensureRegistered's ValidateJWKSURL does NOT cover it — that check is - // gated on net.ParseIP, so it only rejects private IP *literals*, and a - // discovery document advertising a private *hostname* passes it - // cleanly. Checking here and in the constructor is deliberate - // duplication, not redundancy. - if ti.AllowPrivateIPs && ti.JWKSURL == "" { - return fmt.Errorf( - "trusted_issuers: issuer_url %q: allow_private_ips requires jwks_url to be set explicitly; "+ - "otherwise OIDC discovery — fetched from the external issuer — would choose the private target", - ti.IssuerURL) - } - if ti.JWKSURL != "" { - if err := validateJWKSEndpointURL(ti.JWKSURL, ti.InsecureAllowHTTP, ti.AllowPrivateIPs); err != nil { - return fmt.Errorf("trusted_issuers: jwks_url %q: %w", ti.JWKSURL, err) - } - } - } if err := tokenexchange.ValidateTrustedIssuers(issuers, selfIssuer, allowedAudiences); err != nil { return fmt.Errorf("trusted_issuers: %w", err) } @@ -1381,30 +1350,6 @@ func validateTrustedIssuers(issuers []tokenexchange.TrustedIssuer, selfIssuer st return nil } -// validateJWKSEndpointURL checks that rawURL parses, has a host, uses the -// "https" scheme (or "http" when insecureAllowHTTP is set), and — when the -// host is an IP literal — is not a private or loopback address unless -// allowPrivateIPs permits it. Unlike validateIssuerURL, it does not enforce -// OIDC issuer-identifier rules (no query/fragment/trailing-slash) since a -// JWKS endpoint legitimately carries those. -// -// Delegates to tokenexchange.ValidateJWKSURL, the same predicate the runtime -// choke point (ensureRegistered, called on every JWKS fetch) enforces — the two -// were previously separate implementations that had drifted apart (a -// runtime check laxer than this one would silently defeat this config-time -// guard), so this is now the single source of truth for both. -// -// Deliberately not networking.ValidateEndpointURL / -// ValidateEndpointURLWithInsecure: both also honor the -// INSECURE_DISABLE_URL_VALIDATION environment variable, which would let an -// unrelated env var silently disable this SSRF-relevant scheme check; the -// insecure variant also skips the parse/host check entirely rather than -// only relaxing the scheme. This helper takes its "insecure" bits solely -// from the issuer's own explicit InsecureAllowHTTP/AllowPrivateIPs fields. -func validateJWKSEndpointURL(rawURL string, insecureAllowHTTP, allowPrivateIPs bool) error { - return tokenexchange.ValidateJWKSURL(rawURL, insecureAllowHTTP, allowPrivateIPs) -} - // warnTrustedIssuerAudiences logs a warning for each TrustedIssuer whose // ExpectedAudience is absent from AllowedAudiences. This is not a hard // error: a subject token may carry additional audiences beyond @@ -1697,54 +1642,15 @@ func (c *Config) applyDefaults() error { } // ValidateConfidentialClientTransport rejects cleartext HTTP configurations -// when any confidential client is enabled, whether it is admitted through DCR -// or statically declared. Static clients do not enable DCR; they share this -// validation because their secrets are sent to the token endpoint. -// -// 1. insecureAllowHTTP is set: the server accepts a plain-HTTP issuer for -// any host, not just loopback. Always rejected for confidential clients. -// 2. issuer is a plain-HTTP loopback URL (e.g. "http://localhost:18080"). -// This is rejected by default but may be explicitly enabled with -// insecureAllowConfidentialOverLoopbackHTTP. The opt-in does not permit -// non-loopback HTTP issuers and still requires a valid issuer URL. +// when any confidential client is enabled. It delegates to the server-layer +// validator so direct AuthorizationServerParams construction cannot bypass the +// same transport policy. func ValidateConfidentialClientTransport( allowConfidential, insecureAllowHTTP bool, issuer string, insecureAllowConfidentialOverLoopbackHTTP bool, ) error { - if !allowConfidential { - return nil - } - if insecureAllowHTTP { - return fmt.Errorf("allow_confidential_client_registration cannot be combined with insecure_allow_http: " + - "confidential clients would send secrets over cleartext HTTP") - } - parsed, err := url.Parse(issuer) - if err != nil { - return errors.New("confidential clients require a valid issuer URL") - } - if parsed.Scheme != "http" { - return nil - } - if insecureAllowConfidentialOverLoopbackHTTP && networking.IsLocalhost(parsed.Host) { - if err := validateIssuerURL(issuer, false); err != nil { - return errors.New("confidential clients require a valid issuer URL") - } - } - if insecureAllowConfidentialOverLoopbackHTTP && !networking.IsLocalhost(parsed.Host) { - return fmt.Errorf( - "allow_confidential_client_registration cannot use the loopback HTTP opt-in with a non-loopback issuer (%q): "+ - "confidential clients would send secrets over cleartext HTTP", issuer) - } - if !insecureAllowConfidentialOverLoopbackHTTP && networking.IsLocalhost(parsed.Host) { - return fmt.Errorf("allow_confidential_client_registration cannot be combined with a plain-HTTP loopback issuer (%q) unless "+ - "insecure_allow_confidential_over_loopback_http is set: confidential clients would send secrets over cleartext HTTP", - issuer) - } - if !networking.IsLocalhost(parsed.Host) { - return fmt.Errorf("allow_confidential_client_registration cannot use a plain-HTTP non-loopback issuer (%q): "+ - "confidential clients would send secrets over cleartext HTTP", issuer) - } - return nil + return oauthserver.ValidateConfidentialClientTransport( + allowConfidential, insecureAllowHTTP, issuer, insecureAllowConfidentialOverLoopbackHTTP) } // ValidateForceConfidentialRedirectURIs rejects a misconfigured @@ -1789,57 +1695,28 @@ func ValidateForceConfidentialRedirectURIs(uris []string, allowConfidential bool // hosts (for in-cluster Kubernetes deployments on trusted networks). // // This server's own issuer is additionally held to a no-trailing-slash rule -// that OIDC itself does not require (see validateIssuerURLCore's -// allowTrailingSlash parameter) — defensible here only because we control -// this value, unlike a trusted external issuer (validateTrustedIssuerURL). +// that OIDC itself does not require; we control this value, unlike trusted +// external issuers. func validateIssuerURL(issuer string, insecureAllowHTTP bool) error { - return validateIssuerURLCore(issuer, insecureAllowHTTP, true, false) -} - -// validateTrustedIssuerURL is like validateIssuerURL but never exempts -// localhost from the HTTPS requirement: a trusted external issuer is not -// this server's own issuer, so it must not inherit the same-host -// development convenience validateIssuerURL grants the server's own issuer -// and AuthorizationEndpointBaseURL. Without this, "issuer_url: -// http://localhost:9000" with insecure_allow_http: false would pass config -// validation here yet fail at runtime, since the per-issuer HTTP client is -// still built with InsecureAllowHTTP=false (see NewMultiIssuerTokenValidator) -// — jwks_url has no such exemption, so the two would otherwise disagree. -// -// Unlike validateIssuerURL, a trailing slash is accepted: OIDC Discovery §3 -// forbids query and fragment components on an issuer identifier, but not a -// trailing slash — §4.1 only requires one be trimmed before the well-known -// discovery path is appended, which presupposes a trailing-slash issuer is -// legal in the first place, and §4.3 requires the discovery document's -// "issuer" to match the token's "iss" verbatim. Microsoft Entra ID v1 — the -// default for a newly registered API — issues -// "iss": "https://sts.windows.net/{tenant}/" with a trailing slash, so -// rejecting it here would make v1 tokens impossible to configure at all. -func validateTrustedIssuerURL(issuer string, insecureAllowHTTP bool) error { - return validateIssuerURLCore(issuer, insecureAllowHTTP, false, true) + return validateIssuerURLCore(issuer, insecureAllowHTTP) } -// validateIssuerURLCore is the shared implementation behind validateIssuerURL -// and validateTrustedIssuerURL. localhostExempt controls whether a loopback -// host is treated as HTTPS-exempt regardless of insecureAllowHTTP. -// allowTrailingSlash controls whether a trailing slash on the issuer is -// accepted — see validateTrustedIssuerURL's doc comment for why the trusted- -// issuer path must allow it while this server's own issuer does not. -func validateIssuerURLCore(issuer string, insecureAllowHTTP, localhostExempt, allowTrailingSlash bool) error { +// validateIssuerURLCore validates this authorization server's issuer. +func validateIssuerURLCore(issuer string, insecureAllowHTTP bool) error { if issuer == "" { return fmt.Errorf("issuer is required") } parsed, err := url.Parse(issuer) if err != nil { - return fmt.Errorf("invalid URL: %w", err) + return errors.New("invalid URL") } if parsed.Scheme == "" { return fmt.Errorf("scheme is required") } - if parsed.Host == "" { + if parsed.Hostname() == "" { return fmt.Errorf("host is required") } @@ -1854,9 +1731,9 @@ func validateIssuerURLCore(issuer string, insecureAllowHTTP, localhostExempt, al // Discovery 1.0 Section 4.3 compares the discovery document's "issuer" // against this value by exact string match, and no provider echoes back // embedded credentials, so such an issuer always fails discovery. And it - // must not be stored: a password here would sit in the RunConfig and be - // echoed by the validation errors and startup warnings that quote the - // issuer URL. Rejecting it outright beats redacting it at every use. + // must not be stored: a password here would sit in the RunConfig and could + // be exposed by callers that log the configured endpoint. Rejecting it + // outright beats relying on every caller to redact it. // Note that parsed.User is non-nil even for "https://user@host" with no // password, which is equally unusable as an issuer identifier. if parsed.User != nil { @@ -1869,15 +1746,14 @@ func validateIssuerURLCore(issuer string, insecureAllowHTTP, localhostExempt, al if parsed.Scheme != "http" { return fmt.Errorf("scheme must be https (or http for localhost)") } - if !insecureAllowHTTP && (!localhostExempt || !networking.IsLocalhost(parsed.Host)) { - return fmt.Errorf("http scheme is only allowed for localhost, use https for %s", parsed.Hostname()) + if !insecureAllowHTTP && !networking.IsLocalhost(parsed.Host) { + return fmt.Errorf("http scheme is only allowed for localhost, use https") } } - // Not an OIDC requirement — see validateTrustedIssuerURL's doc comment. // ToolHive's own issuer is held to this stricter, self-imposed rule - // since we control the value; a trusted external issuer is not. - if !allowTrailingSlash && strings.HasSuffix(issuer, "/") { + // since we control the value. + if strings.HasSuffix(issuer, "/") { return fmt.Errorf("must not have trailing slash") } diff --git a/pkg/authserver/config_test.go b/pkg/authserver/config_test.go index 4c6ac4eff9..eca7dfe62d 100644 --- a/pkg/authserver/config_test.go +++ b/pkg/authserver/config_test.go @@ -871,6 +871,15 @@ func TestValidateConfidentialClientTransport(t *testing.T) { allowConfidential: true, issuer: "http://auth.example.com", wantErr: true, errContains: "plain-HTTP non-loopback", }, + { + name: "confidential credential-bearing non-loopback HTTP issuer rejects with loopback opt-in without leaking credentials", + allowConfidential: true, + issuer: "http://sentinel-user:sentinel-password@auth.example.com", + allowLoopbackOverride: true, + wantErr: true, + errContains: "require a valid issuer URL", + redacted: []string{"sentinel-user", "sentinel-password", "http://sentinel-user:sentinel-password@auth.example.com"}, + }, { name: "confidential with plain-HTTP non-loopback issuer rejects with loopback opt-in", allowConfidential: true, issuer: "http://auth.example.com", allowLoopbackOverride: true, @@ -1249,9 +1258,9 @@ func TestConfigApplyDefaults_DelegationTokenLifespan(t *testing.T) { } // TestConfigValidate_TrustedIssuers covers validateTrustedIssuers as reached -// from Config.Validate: the URL-shape checks (validateTrustedIssuerURL on -// issuer_url, validateJWKSEndpointURL on jwks_url) and the structural checks -// delegated to tokenexchange.ValidateTrustedIssuers. +// from Config.Validate: the URL-shape checks (tokenexchange.ValidateTrustedIssuerURL +// on issuer_url, tokenexchange.ValidateJWKSURL on jwks_url) and the structural +// checks, all delegated to tokenexchange.ValidateTrustedIssuers. func TestConfigValidate_TrustedIssuers(t *testing.T) { t.Parallel() @@ -1292,7 +1301,7 @@ func TestConfigValidate_TrustedIssuers(t *testing.T) { {IssuerURL: "htps://idp.example.com", ExpectedAudience: "https://mcp.example.com", AllowedDelegateClients: []string{"*"}}, }, wantErr: true, - errMsg: "issuer_url", + errMsg: "scheme must be https", }, { name: "issuer_url empty rejected", @@ -1300,7 +1309,15 @@ func TestConfigValidate_TrustedIssuers(t *testing.T) { {IssuerURL: "", ExpectedAudience: "https://mcp.example.com", AllowedDelegateClients: []string{"*"}}, }, wantErr: true, - errMsg: "issuer is required", + errMsg: "issuer_url is required", + }, + { + name: "issuer_url empty hostname with port rejected", + issuers: []tokenexchange.TrustedIssuer{ + {IssuerURL: "https://:443", ExpectedAudience: "https://mcp.example.com", AllowedDelegateClients: []string{"*"}}, + }, + wantErr: true, + errMsg: "host is required", }, { name: "issuer_url http without per-issuer insecure_allow_http rejected", @@ -1308,7 +1325,7 @@ func TestConfigValidate_TrustedIssuers(t *testing.T) { {IssuerURL: "http://idp.example.com", ExpectedAudience: "https://mcp.example.com", AllowedDelegateClients: []string{"*"}}, }, wantErr: true, - errMsg: "http scheme is only allowed for localhost", + errMsg: "scheme must be https", }, { name: "issuer_url http with per-issuer insecure_allow_http accepted", @@ -1320,7 +1337,7 @@ func TestConfigValidate_TrustedIssuers(t *testing.T) { // Unlike Config.Issuer, a trusted issuer gets no localhost // exemption: it isn't this server's own issuer, so the same // same-host development convenience doesn't apply — see - // validateTrustedIssuerURL's doc comment. Without + // tokenexchange.ValidateTrustedIssuerURL's doc comment. Without // insecure_allow_http, http://localhost must be rejected here // the same as any other http issuer_url. name: "issuer_url http localhost rejected without per-issuer insecure_allow_http", @@ -1328,7 +1345,7 @@ func TestConfigValidate_TrustedIssuers(t *testing.T) { {IssuerURL: "http://localhost:8080", ExpectedAudience: "https://mcp.example.com", AllowedDelegateClients: []string{"*"}}, }, wantErr: true, - errMsg: "http scheme is only allowed for localhost", + errMsg: "scheme must be https", }, { name: "issuer_url http localhost accepted with per-issuer insecure_allow_http", @@ -1589,7 +1606,15 @@ func TestRunConfigValidate_TrustedIssuers(t *testing.T) { {IssuerURL: "htps://idp.example.com", ExpectedAudience: "https://mcp.example.com", AllowedDelegateClients: []string{"*"}}, }, wantErr: true, - errMsg: "issuer_url", + errMsg: "scheme must be https", + }, + { + name: "issuer_url empty hostname with port rejected", + issuers: []tokenexchange.TrustedIssuer{ + {IssuerURL: "https://:443", ExpectedAudience: "https://mcp.example.com", AllowedDelegateClients: []string{"*"}}, + }, + wantErr: true, + errMsg: "host is required", }, { name: "missing expected_audience rejected", diff --git a/pkg/authserver/inbound_grants.go b/pkg/authserver/inbound_grants.go index 413049c3e7..e4886ed41a 100644 --- a/pkg/authserver/inbound_grants.go +++ b/pkg/authserver/inbound_grants.go @@ -150,10 +150,9 @@ func indexTrustedIssuers(issuers []tx.TrustedIssuer) (map[string]int, error) { for i, issuer := range issuers { if previous, ok := byURL[issuer.IssuerURL]; ok { return nil, fmt.Errorf( - "trusted_issuers[%d].issuer_url duplicates trusted_issuers[%d].issuer_url %q (configured more than once)", + "trusted_issuers[%d].issuer_url duplicates trusted_issuers[%d].issuer_url (configured more than once)", i, previous, - issuer.IssuerURL, ) } byURL[issuer.IssuerURL] = i diff --git a/pkg/authserver/inbound_grants_test.go b/pkg/authserver/inbound_grants_test.go index da60973fbb..f1dd8fb8ae 100644 --- a/pkg/authserver/inbound_grants_test.go +++ b/pkg/authserver/inbound_grants_test.go @@ -213,11 +213,7 @@ func TestNormalizeInboundGrantsRejectsInvalidConfiguration(t *testing.T) { cfg: &RunConfig{TrustedIssuers: []tokenexchange.TrustedIssuer{issuer, {Name: "idp", IssuerURL: "https://other.example.com"}}}, errText: `trusted_issuers[1].name duplicates trusted_issuers[0].name "idp"`, }, - { - name: "duplicate issuer URLs", - cfg: &RunConfig{TrustedIssuers: []tokenexchange.TrustedIssuer{issuer, {Name: "other", IssuerURL: "https://idp.example.com"}}}, - errText: `trusted_issuers[1].issuer_url duplicates trusted_issuers[0].issuer_url "https://idp.example.com"`, - }, + {name: "duplicate issuer URLs", cfg: &RunConfig{TrustedIssuers: []tokenexchange.TrustedIssuer{issuer, {Name: "other", IssuerURL: "https://idp.example.com"}}}, errText: "trusted_issuers[1].issuer_url duplicates trusted_issuers[0].issuer_url (configured more than once)"}, { name: "empty token exchange issuer ref", cfg: &RunConfig{TrustedIssuers: []tokenexchange.TrustedIssuer{issuer}, InboundGrants: &InboundGrantsRunConfig{ diff --git a/pkg/authserver/server/provider.go b/pkg/authserver/server/provider.go index 420c1e3082..6c09b800ab 100644 --- a/pkg/authserver/server/provider.go +++ b/pkg/authserver/server/provider.go @@ -28,6 +28,7 @@ import ( servercrypto "github.com/stacklok/toolhive/pkg/authserver/server/crypto" "github.com/stacklok/toolhive/pkg/authserver/server/registration" + "github.com/stacklok/toolhive/pkg/networking" ) // Token lifespan bounds for validation. @@ -96,6 +97,12 @@ type AuthorizationServerConfig struct { // delegate client is registered at startup. Discovery advertises client-secret // authentication methods when this is true. HasStaticDelegateClients bool + // InsecureAllowHTTP permits a non-loopback HTTP issuer. It is incompatible + // with confidential clients. + InsecureAllowHTTP bool + // InsecureAllowConfidentialOverLoopbackHTTP explicitly permits confidential + // clients with a loopback HTTP issuer. + InsecureAllowConfidentialOverLoopbackHTTP bool // ForceConfidentialRedirectURIs lists redirect URIs that the DCR handler // always registers as confidential clients, overriding a requested "none" // auth method. See authserver.Config.ForceConfidentialRedirectURIs for the @@ -159,6 +166,12 @@ type AuthorizationServerParams struct { // delegate client is registered at startup. Discovery advertises client-secret // authentication methods when this is true. HasStaticDelegateClients bool + // InsecureAllowHTTP permits a non-loopback HTTP issuer. It is incompatible + // with confidential clients. + InsecureAllowHTTP bool + // InsecureAllowConfidentialOverLoopbackHTTP explicitly permits confidential + // clients with a loopback HTTP issuer. + InsecureAllowConfidentialOverLoopbackHTTP bool // ForceConfidentialRedirectURIs lists redirect URIs that the DCR handler // always registers as confidential clients, overriding a requested "none" // auth method. See authserver.Config.ForceConfidentialRedirectURIs for the @@ -189,7 +202,7 @@ func validateIssuerURL(issuer string) error { return fmt.Errorf("issuer must use http or https scheme") } - if parsedURL.Host == "" { + if parsedURL.Hostname() == "" { return fmt.Errorf("issuer must have a host") } @@ -240,6 +253,50 @@ func validateTokenLifespans(cfg *AuthorizationServerParams) error { return nil } +// ValidateConfidentialClientTransport rejects cleartext HTTP configurations +// when any confidential client is enabled, whether it is admitted through DCR +// or statically declared. +func ValidateConfidentialClientTransport( + allowConfidential, insecureAllowHTTP bool, + issuer string, insecureAllowConfidentialOverLoopbackHTTP bool, +) error { + if !allowConfidential { + return nil + } + if insecureAllowHTTP { + return fmt.Errorf("allow_confidential_client_registration cannot be combined with insecure_allow_http: " + + "confidential clients would send secrets over cleartext HTTP") + } + parsed, err := url.Parse(issuer) + if err != nil { + return fmt.Errorf("confidential clients require a valid issuer URL") + } + if parsed.Scheme != "http" { + return nil + } + // Mirrors the query/fragment/userinfo shape validateIssuerURLCore enforces + // (pkg/authserver/config.go) for the same reasons: an issuer identifier + // with any of these is malformed under RFC 8414 §2 regardless of scheme, + // and this function is the sole transport authority for the loopback + // opt-in path once a caller reaches it directly. + if parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" { + return fmt.Errorf("confidential clients require a valid issuer URL") + } + if insecureAllowConfidentialOverLoopbackHTTP && !networking.IsLocalhost(parsed.Host) { + return fmt.Errorf("allow_confidential_client_registration cannot use the loopback HTTP opt-in with a non-loopback issuer: " + + "confidential clients would send secrets over cleartext HTTP") + } + if !insecureAllowConfidentialOverLoopbackHTTP && networking.IsLocalhost(parsed.Host) { + return fmt.Errorf("allow_confidential_client_registration cannot be combined with a plain-HTTP loopback issuer unless " + + "insecure_allow_confidential_over_loopback_http is set: confidential clients would send secrets over cleartext HTTP") + } + if !networking.IsLocalhost(parsed.Host) { + return fmt.Errorf("allow_confidential_client_registration cannot use a plain-HTTP non-loopback issuer: " + + "confidential clients would send secrets over cleartext HTTP") + } + return nil +} + // validateParams validates all fields on AuthorizationServerParams. func validateParams(cfg *AuthorizationServerParams) error { if err := validateIssuerURL(cfg.Issuer); err != nil { @@ -271,6 +328,12 @@ func validateParams(cfg *AuthorizationServerParams) error { if err := validateAllowedAudiences(cfg.AllowedAudiences); err != nil { return err } + if err := ValidateConfidentialClientTransport( + cfg.AllowConfidentialClientRegistration || cfg.HasStaticDelegateClients, + cfg.InsecureAllowHTTP, cfg.Issuer, cfg.InsecureAllowConfidentialOverLoopbackHTTP, + ); err != nil { + return err + } // Defense-in-depth: re-check the baseline-⊆-scopes_supported invariant. // RunConfig.Validate performs the same check at the operator-supplied // wire-format boundary; this gate covers callers that construct @@ -336,9 +399,11 @@ func NewAuthorizationServerConfig(cfg *AuthorizationServerParams) (*Authorizatio AllowConfidentialClientRegistration: cfg.AllowConfidentialClientRegistration, AllowPrivateKeyJWTRegistration: cfg.AllowPrivateKeyJWTRegistration, HasStaticDelegateClients: cfg.HasStaticDelegateClients, - ForceConfidentialRedirectURIs: cfg.ForceConfidentialRedirectURIs, - TokenExchangeEnabled: !cfg.DisableTokenExchange, - JWTBearerGrantEnabled: cfg.JWTBearerGrantEnabled, + InsecureAllowHTTP: cfg.InsecureAllowHTTP, + InsecureAllowConfidentialOverLoopbackHTTP: cfg.InsecureAllowConfidentialOverLoopbackHTTP, + ForceConfidentialRedirectURIs: cfg.ForceConfidentialRedirectURIs, + TokenExchangeEnabled: !cfg.DisableTokenExchange, + JWTBearerGrantEnabled: cfg.JWTBearerGrantEnabled, }, nil } @@ -350,6 +415,14 @@ func NewAuthorizationServer( strategy any, factories ...Factory, ) (fosite.OAuth2Provider, error) { + if err := ValidateConfidentialClientTransport( + config.AllowConfidentialClientRegistration || config.HasStaticDelegateClients, + config.InsecureAllowHTTP, + config.AccessTokenIssuer, + config.InsecureAllowConfidentialOverLoopbackHTTP, + ); err != nil { + return nil, err + } fositeConfig := config.Config provider := fosite.NewOAuth2Provider(storage, fositeConfig) diff --git a/pkg/authserver/server/provider_test.go b/pkg/authserver/server/provider_test.go index 92213f8a15..3f48fd228b 100644 --- a/pkg/authserver/server/provider_test.go +++ b/pkg/authserver/server/provider_test.go @@ -116,6 +116,55 @@ func TestNewAuthorizationServerConfig_ConfidentialClientCapabilities(t *testing. } } +func TestNewAuthorizationServerConfig_ConfidentialHTTPTransport(t *testing.T) { + t.Parallel() + + rsaKey, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + base := func() AuthorizationServerParams { + return AuthorizationServerParams{ + Issuer: "https://auth.example.com", AccessTokenLifespan: time.Hour, + RefreshTokenLifespan: 24 * time.Hour, AuthCodeLifespan: 10 * time.Minute, + HMACSecrets: servercrypto.NewHMACSecrets([]byte("test-secret-with-32-bytes-long!!")), + SigningKeyID: "key-1", SigningKeyAlgorithm: "RS256", SigningKey: rsaKey, + } + } + tests := []struct { + name string + mutate func(*AuthorizationServerParams) + wantErr string + }{ + {name: "confidential registration rejects non-loopback HTTP", mutate: func(p *AuthorizationServerParams) { + p.Issuer = "http://auth.example.com" + p.AllowConfidentialClientRegistration = true + }, wantErr: "plain-HTTP non-loopback"}, + {name: "static delegate client rejects non-loopback HTTP", mutate: func(p *AuthorizationServerParams) { + p.Issuer = "http://auth.example.com" + p.HasStaticDelegateClients = true + }, wantErr: "plain-HTTP non-loopback"}, + {name: "loopback opt-in permits confidential HTTP", mutate: func(p *AuthorizationServerParams) { + p.Issuer = "http://localhost:8080" + p.AllowConfidentialClientRegistration = true + p.InsecureAllowConfidentialOverLoopbackHTTP = true + }}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + params := base() + tt.mutate(¶ms) + config, err := NewAuthorizationServerConfig(¶ms) + if tt.wantErr == "" { + require.NoError(t, err) + require.NotNil(t, config) + return + } + require.ErrorContains(t, err, tt.wantErr) + assert.Nil(t, config) + }) + } +} + func TestNewAuthorizationServerConfig_InvalidConfig(t *testing.T) { t.Parallel() @@ -619,6 +668,54 @@ func (*mockRevocationHandler) RevokeToken(_ context.Context, _ string, _ fosite. return nil } +func TestNewAuthorizationServer_ConfidentialHTTPTransport(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + config *AuthorizationServerConfig + wantErr string + }{ + { + name: "direct confidential configuration rejects non-loopback HTTP", + config: &AuthorizationServerConfig{ + Config: &fosite.Config{AccessTokenIssuer: "http://auth.example.com"}, + AllowConfidentialClientRegistration: true, + }, + wantErr: "plain-HTTP non-loopback", + }, + { + name: "direct static delegate configuration rejects non-loopback HTTP", + config: &AuthorizationServerConfig{ + Config: &fosite.Config{AccessTokenIssuer: "http://auth.example.com"}, + HasStaticDelegateClients: true, + }, + wantErr: "plain-HTTP non-loopback", + }, + { + name: "direct loopback opt-in permits confidential HTTP", + config: &AuthorizationServerConfig{ + Config: &fosite.Config{AccessTokenIssuer: "http://localhost:8080"}, + AllowConfidentialClientRegistration: true, + InsecureAllowConfidentialOverLoopbackHTTP: true, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + provider, err := NewAuthorizationServer(tt.config, &mockStorage{}, nil) + if tt.wantErr == "" { + require.NoError(t, err) + require.NotNil(t, provider) + return + } + require.ErrorContains(t, err, tt.wantErr) + assert.Nil(t, provider) + }) + } +} + func TestNewAuthorizationServer(t *testing.T) { t.Parallel() diff --git a/pkg/authserver/server/tokenexchange/factory.go b/pkg/authserver/server/tokenexchange/factory.go index d8ca611e13..f9bd3f48fe 100644 --- a/pkg/authserver/server/tokenexchange/factory.go +++ b/pkg/authserver/server/tokenexchange/factory.go @@ -30,6 +30,9 @@ func NewSharedTrustedIssuerValidator( if len(trustedIssuers) == 0 { return nil, nil } + if config == nil { + return nil, fmt.Errorf("authorization server config is required when trusted issuers are configured") + } selfValidator, err := NewSelfIssuedTokenValidator(config.PublicJWKS(), config.GetAccessTokenIssuer(), config.AllowedAudiences) if err != nil { return nil, fmt.Errorf("failed to create self validator: %w", err) diff --git a/pkg/authserver/server/tokenexchange/factory_test.go b/pkg/authserver/server/tokenexchange/factory_test.go index 31013f084e..9c1938a2a6 100644 --- a/pkg/authserver/server/tokenexchange/factory_test.go +++ b/pkg/authserver/server/tokenexchange/factory_test.go @@ -18,6 +18,14 @@ import ( servercrypto "github.com/stacklok/toolhive/pkg/authserver/server/crypto" ) +func TestNewSharedTrustedIssuerValidator_NilConfig(t *testing.T) { + t.Parallel() + + validator, err := NewSharedTrustedIssuerValidator(nil, []TrustedIssuer{{IssuerURL: "https://issuer.example.com"}}) + require.ErrorContains(t, err, "authorization server config is required") + assert.Nil(t, validator) +} + func TestFactory(t *testing.T) { t.Parallel() diff --git a/pkg/authserver/server/tokenexchange/multi_issuer_validator.go b/pkg/authserver/server/tokenexchange/multi_issuer_validator.go index 70279fe7dd..d60a220758 100644 --- a/pkg/authserver/server/tokenexchange/multi_issuer_validator.go +++ b/pkg/authserver/server/tokenexchange/multi_issuer_validator.go @@ -410,7 +410,7 @@ func ResolveJWTBearerGrantPolicies(issuers []TrustedIssuer) ([]TrustedIssuer, er policy := cloneJWTBearerGrantPolicy(resolved[i].JWTBearerGrant) age, err := time.ParseDuration(policy.MaxAssertionAge) if err != nil || age <= 0 { - return nil, fmt.Errorf("issuer_url %q: jwt_bearer_grant.max_assertion_age must be a positive duration", resolved[i].IssuerURL) + return nil, fmt.Errorf("trusted_issuers[%d].jwt_bearer_grant.max_assertion_age must be a positive duration", i) } policy.maxAssertionAge = age resolved[i].JWTBearerGrant = policy @@ -1169,7 +1169,7 @@ func (*MultiIssuerTokenValidator) discoverJWKSURL(ctx context.Context, issuerCon } if doc.Issuer != issuerConfig.IssuerURL { - return "", fmt.Errorf("discovery document issuer %q does not match expected issuer %q", doc.Issuer, issuerConfig.IssuerURL) + return "", fmt.Errorf("discovery document issuer does not match configured issuer") } if doc.JWKSURI == "" { @@ -1182,6 +1182,40 @@ func (*MultiIssuerTokenValidator) discoverJWKSURL(ctx context.Context, issuerCon return doc.JWKSURI, nil } +// ValidateTrustedIssuerURL checks that issuerURL is a valid trusted external +// OIDC issuer identifier. Trusted issuers require HTTPS unless their own +// insecureAllowHTTP opt-in is set; unlike this server's issuer, localhost is +// not exempt. Query, fragment, and userinfo are forbidden, while a trailing +// slash is permitted for providers such as Microsoft Entra ID v1. +func ValidateTrustedIssuerURL(issuerURL string, insecureAllowHTTP bool) error { + if issuerURL == "" { + return errors.New("issuer_url is required") + } + parsed, err := url.Parse(issuerURL) + if err != nil { + return errors.New("invalid URL") + } + if parsed.Scheme == "" { + return errors.New("scheme is required") + } + if parsed.Hostname() == "" { + return errors.New("host is required") + } + if parsed.RawQuery != "" { + return errors.New("must not contain query component") + } + if parsed.Fragment != "" { + return errors.New("must not contain fragment component") + } + if parsed.User != nil { + return errors.New("must not contain userinfo (credentials in the URL)") + } + if parsed.Scheme != "https" && (parsed.Scheme != "http" || !insecureAllowHTTP) { + return errors.New("scheme must be https (or http with insecure_allow_http)") + } + return nil +} + // ValidateJWKSURL checks that jwksURL parses, has a host, uses HTTPS unless // insecureAllowHTTP permits plain HTTP — and only exactly the "http" scheme, // not any other non-https scheme such as "file" or "ftp" — and, when the @@ -1192,27 +1226,29 @@ func (*MultiIssuerTokenValidator) discoverJWKSURL(ctx context.Context, issuerCon // discovery document — or a hand-configured jwks_url — points to internal // services. // -// This is the single implementation shared by the runtime choke point above -// (ensureRegistered, on every fetch) and pkg/authserver/config.go's config-time -// check (validateJWKSEndpointURL): the two must not drift out of sync, or a -// laxer runtime check would silently defeat the config-time guard. +// This is shared by endpoint-only config-time validation and the runtime fetch +// choke point. Both use the issuer's allowPrivateIPs policy for literal IP +// hosts; runtime additionally protects DNS resolution on every outbound fetch. func ValidateJWKSURL(jwksURL string, insecureAllowHTTP, allowPrivateIPs bool) error { u, err := url.Parse(jwksURL) if err != nil { - return fmt.Errorf("invalid URL: %w", err) + return errors.New("invalid URL") } - if u.Host == "" { + if u.Hostname() == "" { return errors.New("host is required") } + if u.Fragment != "" { + return errors.New("must not contain fragment component") + } + // Unlike issuer_url, a jwks_url carrying userinfo would actually work — // net/http turns it into a Basic auth header on every JWKS fetch — which // is precisely why it is rejected rather than tolerated: it would put a - // live credential in the RunConfig, in this function's error strings, and - // in any log that quotes the URL. A JWKS endpoint is public by - // definition (it serves verification keys), so there is no legitimate - // reason to authenticate to one. + // live credential in the RunConfig or in any log that quotes the URL. A + // JWKS endpoint is public by definition (it serves verification keys), so + // there is no legitimate reason to authenticate to one. if u.User != nil { return errors.New("must not contain userinfo (credentials in the URL)") } @@ -1230,6 +1266,21 @@ func ValidateJWKSURL(jwksURL string, insecureAllowHTTP, allowPrivateIPs bool) er return nil } +func validateTrustedIssuerEndpoints(ti TrustedIssuer) error { + if err := ValidateTrustedIssuerURL(ti.IssuerURL, ti.InsecureAllowHTTP); err != nil { + return err + } + if ti.JWKSURL != "" { + if err := ValidateJWKSURL(ti.JWKSURL, ti.InsecureAllowHTTP, ti.AllowPrivateIPs); err != nil { + return fmt.Errorf("jwks_url: %w", err) + } + } + if ti.AllowPrivateIPs && ti.JWKSURL == "" { + return errors.New("allow_private_ips requires jwks_url to be set explicitly") + } + return nil +} + // validateTrustedIssuer checks a single TrustedIssuer for structural validity // before it is admitted into issuers: required fields, no collision with // selfIssuer or an already-registered issuer, an ActorClaim that @@ -1246,8 +1297,8 @@ func ValidateJWKSURL(jwksURL string, insecureAllowHTTP, allowPrivateIPs bool) er func validateTrustedIssuer( ti TrustedIssuer, selfIssuer string, issuers map[string]*externalIssuerConfig, allowedAudiences []string, ) error { - if ti.IssuerURL == "" { - return errors.New("issuer_url is required") + if err := validateTrustedIssuerEndpoints(ti); err != nil { + return err } if ti.ExpectedAudience == "" && ti.JWTBearerGrant == nil { return fmt.Errorf("issuer_url %q: expected_audience is required when JWT-bearer grant is disabled", ti.IssuerURL) @@ -1257,7 +1308,7 @@ func validateTrustedIssuer( "self-issued tokens are already handled separately", ti.IssuerURL) } if _, dup := issuers[ti.IssuerURL]; dup { - return fmt.Errorf("issuer_url %q: configured more than once", ti.IssuerURL) + return errors.New("issuer_url configured more than once") } if ti.ActorClaim != "" && slices.Contains(actorClaimsNotInExtra, ti.ActorClaim) { return fmt.Errorf( @@ -1279,20 +1330,6 @@ func validateTrustedIssuer( "issuer_url %q: allow_may_act must not be enabled when allowed_delegate_clients contains the wildcard %q", ti.IssuerURL, anyDelegateClient) } - // AllowPrivateIPs without a hand-configured jwks_url would let OIDC - // discovery — a document fetched from, and thus influenceable by, the - // external issuer itself — choose the private target the dial is - // allowed to reach. Requiring jwks_url pins that target to - // operator-supplied config. Mirrors the config-time check in - // pkg/authserver/config.go's validateTrustedIssuers; duplicated here so - // a caller that builds the validator directly (factory, tests) without - // running Config.Validate cannot bypass it. - if ti.AllowPrivateIPs && ti.JWKSURL == "" { - return fmt.Errorf( - "issuer_url %q: allow_private_ips requires jwks_url to be set explicitly; "+ - "otherwise OIDC discovery — fetched from the external issuer — would choose the private target", - ti.IssuerURL) - } return nil } diff --git a/pkg/authserver/server/tokenexchange/multi_issuer_validator_test.go b/pkg/authserver/server/tokenexchange/multi_issuer_validator_test.go index 6b5df5e83a..19591ec75f 100644 --- a/pkg/authserver/server/tokenexchange/multi_issuer_validator_test.go +++ b/pkg/authserver/server/tokenexchange/multi_issuer_validator_test.go @@ -1551,6 +1551,68 @@ func TestNewMultiIssuerTokenValidator_Validation(t *testing.T) { } } +func TestResolveJWTBearerGrantPolicies_RedactsCredentialIssuerOnInvalidDuration(t *testing.T) { + t.Parallel() + + const credentialIssuerURL = "https://sentinel-user:sentinel-password@issuer.example.com" + resolved, err := ResolveJWTBearerGrantPolicies([]TrustedIssuer{{ + IssuerURL: credentialIssuerURL, + JWTBearerGrant: &JWTBearerGrantPolicy{ + MaxAssertionAge: "not-a-duration", + }, + }}) + require.ErrorContains(t, err, "trusted_issuers[0].jwt_bearer_grant.max_assertion_age") + assert.Nil(t, resolved) + assert.NotContains(t, err.Error(), credentialIssuerURL) + assert.NotContains(t, err.Error(), "sentinel-user") + assert.NotContains(t, err.Error(), "sentinel-password") +} + +func TestNewMultiIssuerTokenValidator_TrustedIssuerEndpointValidation(t *testing.T) { + t.Parallel() + + selfJWKS := newTestJWKS(t) + selfValidator, err := NewSelfIssuedTokenValidator(selfJWKS.publicJWKS(), testIssuer, []string{testIssuer}) + require.NoError(t, err) + + const credentialIssuerURL = "https://sentinel-user:sentinel-password@issuer.example.com" + tests := []struct { + name string + issuerURL string + jwksURL string + allowPrivateIPs bool + wantErr string + wantValid bool + }{ + {name: "credential-bearing issuer rejected without leaking credentials", issuerURL: credentialIssuerURL, wantErr: "must not contain userinfo"}, + {name: "unsafe issuer scheme rejected", issuerURL: "ftp://issuer.example.com", wantErr: "scheme must be https"}, + {name: "HTTP localhost without per issuer opt in rejected", issuerURL: "http://localhost:8080", wantErr: "scheme must be https"}, + {name: "credential-bearing JWKS rejected without leaking credentials", issuerURL: testExternalIssuer, jwksURL: "https://sentinel-user:sentinel-password@issuer.example.com/keys", wantErr: "jwks_url: must not contain userinfo"}, + {name: "JWKS unsafe scheme rejected", issuerURL: testExternalIssuer, jwksURL: "ftp://issuer.example.com/keys", wantErr: "jwks_url: must use HTTPS"}, + {name: "private JWKS rejected without opt in", issuerURL: testExternalIssuer, jwksURL: "https://10.0.0.5/keys", wantErr: "jwks_url: must not point to a private or loopback address"}, + {name: "private JWKS accepted with opt in", issuerURL: testExternalIssuer, jwksURL: "https://10.0.0.5/keys", allowPrivateIPs: true, wantValid: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + validator, err := NewMultiIssuerTokenValidator(selfValidator, testIssuer, []TrustedIssuer{{ + IssuerURL: tt.issuerURL, JWKSURL: tt.jwksURL, AllowPrivateIPs: tt.allowPrivateIPs, + ExpectedAudience: testExternalAudience, AllowedDelegateClients: []string{anyDelegateClient}, + }}, nil) + if tt.wantValid { + require.NoError(t, err) + require.NoError(t, validator.Close()) + return + } + require.ErrorContains(t, err, tt.wantErr) + assert.Nil(t, validator) + assert.NotContains(t, err.Error(), credentialIssuerURL) + assert.NotContains(t, err.Error(), "sentinel-user") + assert.NotContains(t, err.Error(), "sentinel-password") + }) + } +} + func TestValidateJWTBearerAcceptedAudiences_RejectsResourceAudienceOverlap(t *testing.T) { t.Parallel() @@ -2175,7 +2237,7 @@ func TestMultiIssuerTokenValidator_DiscoverJWKSURL(t *testing.T) { httpClient: srv.Client(), }, "" }, - errContains: "does not match expected issuer", + errContains: "discovery document issuer does not match configured issuer", }, { name: "missing jwks_uri is rejected", @@ -2328,6 +2390,8 @@ func TestValidateJWKSURL(t *testing.T) { wantErr string }{ {name: "https accepted", url: "https://issuer.example.com/jwks"}, + {name: "fragment rejected", url: "https://issuer.example.com/jwks#fragment", wantErr: "must not contain fragment"}, + {name: "query string accepted", url: "https://issuer.example.com/jwks?p=B2C_1_signin"}, {name: "http rejected", url: "http://issuer.example.com/jwks", wantErr: "must use HTTPS"}, { name: "userinfo with password rejected", @@ -2368,6 +2432,7 @@ func TestValidateJWKSURL(t *testing.T) { {name: "private IP literal rejected", url: "https://10.1.2.3/jwks", wantErr: "private or loopback"}, {name: "malformed URL rejected", url: "://not-a-url", wantErr: "invalid URL"}, {name: "missing host rejected", url: "https:///jwks", wantErr: "host is required"}, + {name: "empty hostname with port rejected", url: "https://:443/jwks", wantErr: "host is required"}, } for _, tt := range tests { @@ -2763,16 +2828,9 @@ func TestMultiIssuerTokenValidator_SharedJWKSURL_SamePolicy(t *testing.T) { assert.Equal(t, issuerBURL, resultB.ExternalIssuer) } -// TestMultiIssuerTokenValidator_SharedJWKSURL_DifferingPolicy proves the -// property a per-issuer jwk.Cache adds over a shared one: two issuers -// resolving to the same jwks_url but configuring DIFFERENT -// insecure_allow_http/allow_private_ips settings both validate -// independently, each fetching through its own dedicated *http.Client. A -// shared cache could not do this — httprc keys a cached resource by URL -// alone and only honors jwk.WithHTTPClient on a URL's first Register call, -// so the second issuer would have silently inherited the first one's client -// and transport policy. Splitting the cache per issuer removes that -// collision instead of merely guarding against it. +// TestMultiIssuerTokenValidator_SharedJWKSURL_DifferingPolicy confirms that +// configured JWKS endpoints are validated against each issuer's own transport +// policy before construction starts. func TestMultiIssuerTokenValidator_SharedJWKSURL_DifferingPolicy(t *testing.T) { t.Parallel() @@ -2788,18 +2846,8 @@ func TestMultiIssuerTokenValidator_SharedJWKSURL_DifferingPolicy(t *testing.T) { jwksServer := startJWKSServer(t, sharedJWKS) sharedJWKSURL := jwksServer.URL + "/jwks" - // Deliberately NOT newMultiValidator: that helper forces - // InsecureAllowHTTP and AllowPrivateIPs to true on every issuer so its - // loopback httptest servers are reachable, which would erase the very - // difference this test exists to exercise. Both issuers share one - // plain-HTTP loopback jwks_url and allow private IPs, and differ ONLY in - // InsecureAllowHTTP — so each is judged against its own transport policy: - // A is refused for its own reason (no HTTP permitted), B succeeds. - // - // Under a shared cache B could not succeed here: the policy-claim guard - // rejected any second issuer whose policy differed from the URL's first - // claimant, and without that guard B would have silently inherited A's - // client. Per-issuer caches make both outcomes independent. + // The first issuer does not permit the shared plain-HTTP endpoint, so + // construction must fail before either cache is created. selfValidator, err := NewSelfIssuedTokenValidator(selfJWKS.publicJWKS(), testIssuer, []string{testIssuer}) require.NoError(t, err) validator, err := NewMultiIssuerTokenValidator(selfValidator, testIssuer, []TrustedIssuer{ @@ -2822,37 +2870,8 @@ func TestMultiIssuerTokenValidator_SharedJWKSURL_DifferingPolicy(t *testing.T) { AllowedDelegateClients: []string{anyDelegateClient}, }, }, nil) - require.NoError(t, err) - t.Cleanup(func() { _ = validator.Close() }) - - tokenFor := func(issuer, audience, actor, jti string) string { - now := time.Now() - claims := jwt.Claims{ - Subject: "shared-user", - Issuer: issuer, - Audience: jwt.Audience{audience}, - Expiry: jwt.NewNumericDate(now.Add(time.Hour)), - IssuedAt: jwt.NewNumericDate(now), - NotBefore: jwt.NewNumericDate(now.Add(-time.Minute)), - ID: jti, - } - return sharedJWKS.signToken(t, claims, map[string]any{"azp": actor}) - } - - // A is judged against its OWN policy: it forbids plain HTTP, so its fetch - // of the shared http:// jwks_url is refused. Not a policy-conflict error — - // A is simply misconfigured for this URL. - _, err = validator.Validate(context.Background(), tokenFor(issuerAURL, audienceA, "agent-a", "jti-a")) - require.Error(t, err, "the issuer forbidding plain HTTP must be refused for its own jwks_url") - assert.Contains(t, err.Error(), "must use HTTPS", - "the refusal must come from issuer A's own transport policy") - - // B shares that exact URL but permits HTTP, and succeeds — the outcome a - // shared cache could not produce, since A reached the URL first. - resultB, err := validator.Validate(context.Background(), tokenFor(issuerBURL, audienceB, "agent-b", "jti-b")) - require.NoError(t, err, "the issuer permitting HTTP must validate independently, "+ - "neither blocked by nor inheriting issuer A's stricter policy") - assert.Equal(t, issuerBURL, resultB.ExternalIssuer) + require.ErrorContains(t, err, "jwks_url: must use HTTPS") + assert.Nil(t, validator) } // TestMultiIssuerTokenValidator_RetryAfterFetchFailureRefreshes proves the diff --git a/pkg/authserver/server_impl.go b/pkg/authserver/server_impl.go index e8f3d99355..ff7758c6ce 100644 --- a/pkg/authserver/server_impl.go +++ b/pkg/authserver/server_impl.go @@ -207,9 +207,11 @@ func newServer(ctx context.Context, cfg Config, stor storage.Storage) (_ *server AllowConfidentialClientRegistration: cfg.AllowConfidentialClientRegistration, AllowPrivateKeyJWTRegistration: cfg.AllowPrivateKeyJWTRegistration, HasStaticDelegateClients: len(cfg.DelegateClients) > 0, - ForceConfidentialRedirectURIs: cfg.ForceConfidentialRedirectURIs, - DisableTokenExchange: cfg.DisableTokenExchange, - JWTBearerGrantEnabled: JWTBearerGrantEnabled(cfg.TrustedIssuers), + InsecureAllowHTTP: cfg.InsecureAllowHTTP, + InsecureAllowConfidentialOverLoopbackHTTP: cfg.InsecureAllowConfidentialOverLoopbackHTTP, + ForceConfidentialRedirectURIs: cfg.ForceConfidentialRedirectURIs, + DisableTokenExchange: cfg.DisableTokenExchange, + JWTBearerGrantEnabled: JWTBearerGrantEnabled(cfg.TrustedIssuers), } authServerConfig, err := oauthserver.NewAuthorizationServerConfig(oauthParams) if err != nil { diff --git a/pkg/vmcp/status/k8s_reporter.go b/pkg/vmcp/status/k8s_reporter.go index 7bd7cac16a..63c883f388 100644 --- a/pkg/vmcp/status/k8s_reporter.go +++ b/pkg/vmcp/status/k8s_reporter.go @@ -124,69 +124,38 @@ func (*K8sReporter) Start(_ context.Context) (func(context.Context) error, error return noOpShutdown("K8s"), nil } -// updateStatus converts vmcp.Status to VirtualMCPServerStatus and updates the resource. -// Note: This method does NOT update the URL field, as that is infrastructure-level -// status owned by the operator (the external service URL). The vMCP runtime only -// reports operational status (phase, backends, conditions). +// updateStatus converts vmcp.Status into the runtime-owned status snapshot. +// The operator projects this snapshot into the public top-level fields, keeping +// the top-level Conditions array under a single writer. func (*K8sReporter) updateStatus(vmcpServer *mcpv1beta1.VirtualMCPServer, status *vmcptypes.Status) { - // Update phase - vmcpServer.Status.Phase = convertPhase(status.Phase) - - // Update message - vmcpServer.Status.Message = status.Message - - // Update backend count (only counts healthy/ready backends) - vmcpServer.Status.BackendCount = status.BackendCount - - // Update discovered backends - vmcpServer.Status.DiscoveredBackends = make([]mcpv1beta1.DiscoveredBackend, 0, len(status.DiscoveredBackends)) + if vmcpServer.Status.Runtime == nil { + vmcpServer.Status.Runtime = &mcpv1beta1.VirtualMCPServerRuntimeStatus{} + } + runtimeStatus := vmcpServer.Status.Runtime + runtimeStatus.Phase = convertPhase(status.Phase) + runtimeStatus.Message = status.Message + runtimeStatus.BackendCount = status.BackendCount + runtimeStatus.DiscoveredBackends = make([]mcpv1beta1.DiscoveredBackend, 0, len(status.DiscoveredBackends)) for _, backend := range status.DiscoveredBackends { - // Convert vmcp.DiscoveredBackend to mcpv1beta1.DiscoveredBackend - // Both types have identical fields, so we can use type conversion - vmcpServer.Status.DiscoveredBackends = append(vmcpServer.Status.DiscoveredBackends, + runtimeStatus.DiscoveredBackends = append(runtimeStatus.DiscoveredBackends, mcpv1beta1.DiscoveredBackend(backend)) } - // Update conditions using meta.SetStatusCondition to preserve LastTransitionTime - // when the condition Status hasn't changed. This is important for Kubernetes-style - // condition semantics - LastTransitionTime should only update on Status transitions. - // - // Note: Kubernetes conditions are additive - once set, they persist until explicitly removed. - // The status building code (monitor.BuildStatus) is responsible for providing the complete - // set of conditions that should be present. We trust that if a condition is missing from - // the new status, it should be removed from the resource. - - // First, identify which condition types are present in the new status - newConditionTypes := make(map[string]bool) - for _, cond := range status.Conditions { - newConditionTypes[cond.Type] = true + newConditionTypes := make(map[string]bool, len(status.Conditions)) + for _, condition := range status.Conditions { + newConditionTypes[condition.Type] = true } - - // Remove transient condition types that are no longer present. - // Transient conditions like "Degraded" only appear when that state is active, - // and must be explicitly removed when the system recovers. - // - // Core conditions (Ready, BackendsDiscovered) should always be present in the new status. - // If they're missing, that indicates a bug in the status building code, not normal operation. - // We still remove them to stay in sync with the status building code's intent. - knownConditionTypes := []string{"Ready", "Degraded", "BackendsDiscovered"} - for _, condType := range knownConditionTypes { - if !newConditionTypes[condType] { - // Log warning for core conditions that should always be present - if condType == "Ready" || condType == "BackendsDiscovered" { - slog.Warn("core condition missing from new status - this may indicate a bug in status building", "condition", condType) + for _, conditionType := range []string{"Ready", "Degraded", "BackendsDiscovered"} { + if !newConditionTypes[conditionType] { + if conditionType == "Ready" || conditionType == "BackendsDiscovered" { + slog.Warn("core condition missing from new status - this may indicate a bug in status building", "condition", conditionType) } - meta.RemoveStatusCondition(&vmcpServer.Status.Conditions, condType) + meta.RemoveStatusCondition(&runtimeStatus.Conditions, conditionType) } } - - // Now set/update the conditions from the new status - for _, newCondition := range status.Conditions { - meta.SetStatusCondition(&vmcpServer.Status.Conditions, newCondition) + for _, condition := range status.Conditions { + meta.SetStatusCondition(&runtimeStatus.Conditions, condition) } - - // Update observed generation - vmcpServer.Status.ObservedGeneration = vmcpServer.Generation } // convertPhase converts vmcp.Phase to VirtualMCPServerPhase. diff --git a/pkg/vmcp/status/k8s_reporter_test.go b/pkg/vmcp/status/k8s_reporter_test.go index 690a284295..350d4dfd53 100644 --- a/pkg/vmcp/status/k8s_reporter_test.go +++ b/pkg/vmcp/status/k8s_reporter_test.go @@ -128,7 +128,7 @@ func TestK8sReporter_ReportStatus_Success(t *testing.T) { t.Parallel() reporter, fakeClient := createTestReporter(t, "test-server", "default") - vmcpServer := createTestVirtualMCPServer(t, fakeClient, "test-server", "default") + createTestVirtualMCPServer(t, fakeClient, "test-server", "default") // Create test status status := &vmcptypes.Status{ @@ -173,20 +173,20 @@ func TestK8sReporter_ReportStatus_Success(t *testing.T) { require.NoError(t, err) // Verify phase conversion - assert.Equal(t, tt.expectedPhase, updated.Status.Phase) + assert.Equal(t, tt.expectedPhase, updated.Status.Runtime.Phase) // Verify message - assert.Equal(t, "Test message", updated.Status.Message) + assert.Equal(t, "Test message", updated.Status.Runtime.Message) // Verify backend count - assert.Equal(t, tt.backendCount, updated.Status.BackendCount) - assert.Len(t, updated.Status.DiscoveredBackends, int(tt.backendCount)) + assert.Equal(t, tt.backendCount, updated.Status.Runtime.BackendCount) + assert.Len(t, updated.Status.Runtime.DiscoveredBackends, int(tt.backendCount)) // Verify conditions - assert.Len(t, updated.Status.Conditions, tt.conditionCount) + assert.Len(t, updated.Status.Runtime.Conditions, tt.conditionCount) - // Verify observed generation - assert.Equal(t, vmcpServer.Generation, updated.Status.ObservedGeneration) + // The runtime reporter does not own the operator's observed generation. + assert.Zero(t, updated.Status.ObservedGeneration) }) } } @@ -234,10 +234,10 @@ func TestK8sReporter_ReportStatus_BackendConversion(t *testing.T) { }, updated) require.NoError(t, err) - require.Len(t, updated.Status.DiscoveredBackends, 2) + require.Len(t, updated.Status.Runtime.DiscoveredBackends, 2) // Verify first backend - backend1 := updated.Status.DiscoveredBackends[0] + backend1 := updated.Status.Runtime.DiscoveredBackends[0] assert.Equal(t, "backend-1", backend1.Name) assert.Equal(t, "http://backend-1:8080", backend1.URL) assert.Equal(t, "ready", backend1.Status) @@ -249,7 +249,7 @@ func TestK8sReporter_ReportStatus_BackendConversion(t *testing.T) { assert.Equal(t, "Healthy", backend1.Message) // Verify second backend - backend2 := updated.Status.DiscoveredBackends[1] + backend2 := updated.Status.Runtime.DiscoveredBackends[1] assert.Equal(t, "backend-2", backend2.Name) assert.Equal(t, "degraded", backend2.Status) assert.Equal(t, "Slow response times", backend2.Message) @@ -312,9 +312,9 @@ func TestK8sReporter_ReportStatus_ConcurrentUpdates(t *testing.T) { }, updated) require.NoError(t, err) - assert.Equal(t, "Update 5", updated.Status.Message) - assert.Equal(t, int32(1), updated.Status.BackendCount) - assert.Equal(t, "backend-5", updated.Status.DiscoveredBackends[0].Name) + assert.Equal(t, "Update 5", updated.Status.Runtime.Message) + assert.Equal(t, int32(1), updated.Status.Runtime.BackendCount) + assert.Equal(t, "backend-5", updated.Status.Runtime.DiscoveredBackends[0].Name) } // TestK8sReporter_ReportStatus_ConditionUpdates tests that conditions are properly updated. @@ -355,11 +355,11 @@ func TestK8sReporter_ReportStatus_ConditionUpdates(t *testing.T) { Namespace: "default", }, updated) require.NoError(t, err) - require.Len(t, updated.Status.Conditions, 1) - assert.Equal(t, "Ready", updated.Status.Conditions[0].Type) - assert.Equal(t, metav1.ConditionTrue, updated.Status.Conditions[0].Status) - assert.Equal(t, "AllBackendsRoutable", updated.Status.Conditions[0].Reason) - assert.Equal(t, "All backends are healthy", updated.Status.Conditions[0].Message) + require.Len(t, updated.Status.Runtime.Conditions, 1) + assert.Equal(t, "Ready", updated.Status.Runtime.Conditions[0].Type) + assert.Equal(t, metav1.ConditionTrue, updated.Status.Runtime.Conditions[0].Status) + assert.Equal(t, "AllBackendsRoutable", updated.Status.Runtime.Conditions[0].Reason) + assert.Equal(t, "All backends are healthy", updated.Status.Runtime.Conditions[0].Message) // Second report: update message while keeping Status True status2 := &vmcptypes.Status{ @@ -387,9 +387,9 @@ func TestK8sReporter_ReportStatus_ConditionUpdates(t *testing.T) { Namespace: "default", }, updated) require.NoError(t, err) - require.Len(t, updated.Status.Conditions, 1) - assert.Equal(t, metav1.ConditionTrue, updated.Status.Conditions[0].Status) - assert.Equal(t, "All backends are still healthy", updated.Status.Conditions[0].Message) + require.Len(t, updated.Status.Runtime.Conditions, 1) + assert.Equal(t, metav1.ConditionTrue, updated.Status.Runtime.Conditions[0].Status) + assert.Equal(t, "All backends are still healthy", updated.Status.Runtime.Conditions[0].Message) // Third report: change Status to False status3 := &vmcptypes.Status{ @@ -417,10 +417,10 @@ func TestK8sReporter_ReportStatus_ConditionUpdates(t *testing.T) { Namespace: "default", }, updated) require.NoError(t, err) - require.Len(t, updated.Status.Conditions, 1) - assert.Equal(t, metav1.ConditionFalse, updated.Status.Conditions[0].Status) - assert.Equal(t, "NoRoutableBackends", updated.Status.Conditions[0].Reason) - assert.Equal(t, "No routable backends available", updated.Status.Conditions[0].Message) + require.Len(t, updated.Status.Runtime.Conditions, 1) + assert.Equal(t, metav1.ConditionFalse, updated.Status.Runtime.Conditions[0].Status) + assert.Equal(t, "NoRoutableBackends", updated.Status.Runtime.Conditions[0].Reason) + assert.Equal(t, "No routable backends available", updated.Status.Runtime.Conditions[0].Message) } // TestK8sReporter_ReportStatus_RemovesStaleConditions tests that conditions @@ -466,10 +466,10 @@ func TestK8sReporter_ReportStatus_RemovesStaleConditions(t *testing.T) { Namespace: "default", }, updated) require.NoError(t, err) - assert.Len(t, updated.Status.Conditions, 2, "Should have Ready and Degraded conditions") + assert.Len(t, updated.Status.Runtime.Conditions, 2, "Should have Ready and Degraded conditions") hasDegraded := false - for _, cond := range updated.Status.Conditions { + for _, cond := range updated.Status.Runtime.Conditions { if cond.Type == "Degraded" { hasDegraded = true assert.Equal(t, metav1.ConditionTrue, cond.Status) @@ -503,11 +503,11 @@ func TestK8sReporter_ReportStatus_RemovesStaleConditions(t *testing.T) { Namespace: "default", }, updated) require.NoError(t, err) - assert.Len(t, updated.Status.Conditions, 1, "Should have only Ready condition") + assert.Len(t, updated.Status.Runtime.Conditions, 1, "Should have only Ready condition") hasReady := false hasDegraded = false - for _, cond := range updated.Status.Conditions { + for _, cond := range updated.Status.Runtime.Conditions { if cond.Type == "Ready" { hasReady = true assert.Equal(t, metav1.ConditionTrue, cond.Status) @@ -568,6 +568,40 @@ func TestK8sReporter_FullLifecycle(t *testing.T) { assert.NoError(t, err) } +func TestK8sReporter_UpdateStatusPreservesOperatorFields(t *testing.T) { + t.Parallel() + + operatorCondition := metav1.Condition{ + Type: mcpv1beta1.ConditionTypeValid, + Status: metav1.ConditionTrue, + Reason: "ValidationSucceeded", + } + vmcpServer := &mcpv1beta1.VirtualMCPServer{ + Status: mcpv1beta1.VirtualMCPServerStatus{ + URL: "https://operator.example.test", + ObservedGeneration: 7, + Conditions: []metav1.Condition{operatorCondition}, + }, + } + + (&K8sReporter{}).updateStatus(vmcpServer, &vmcptypes.Status{ + Phase: vmcptypes.PhaseReady, + Message: "runtime ready", + Conditions: []metav1.Condition{{ + Type: "Ready", + Status: metav1.ConditionTrue, + Reason: "AllBackendsRoutable", + }}, + }) + + assert.Equal(t, "https://operator.example.test", vmcpServer.Status.URL) + assert.Equal(t, int64(7), vmcpServer.Status.ObservedGeneration) + assert.Equal(t, []metav1.Condition{operatorCondition}, vmcpServer.Status.Conditions) + require.NotNil(t, vmcpServer.Status.Runtime) + assert.Equal(t, mcpv1beta1.VirtualMCPServerPhaseReady, vmcpServer.Status.Runtime.Phase) + assert.Equal(t, "Ready", vmcpServer.Status.Runtime.Conditions[0].Type) +} + // TestConvertPhase tests phase conversion logic. func TestConvertPhase(t *testing.T) { t.Parallel()