From 5d84e76545fdb201290bb143a70c6d02e336f179 Mon Sep 17 00:00:00 2001 From: Jakub Hrozek Date: Mon, 2 Nov 2026 10:59:35 +0100 Subject: [PATCH 01/10] Correct the keySigningAlgorithm doc comment The comment said a key implies its signing algorithm. An RSA key without alg does not imply RS256; RS256 is only the default when the keys do not say, because RS and PS are both possible. Say what the function returns. Co-authored-by: Mecatl --- pkg/authserver/server/registration/configured_client.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/authserver/server/registration/configured_client.go b/pkg/authserver/server/registration/configured_client.go index b914fa0e06..b71a78f531 100644 --- a/pkg/authserver/server/registration/configured_client.go +++ b/pkg/authserver/server/registration/configured_client.go @@ -193,8 +193,8 @@ func resolveConfiguredSigningAlgorithm(configured string, jwks *jose.JSONWebKeyS "set token_endpoint_auth_signing_alg", strings.Join(found, ", ")) } -// keySigningAlgorithm returns the algorithm a key implies, or "" for a key -// type that implies none. +// keySigningAlgorithm returns the algorithm a key carries or its curve fixes, +// RS256 for an RSA key without alg, or "" for any other key type. func keySigningAlgorithm(key *jose.JSONWebKey) string { if key.Algorithm != "" { return key.Algorithm From e481a23f32bda0c2a6f029c599a8bfb3f894374b Mon Sep 17 00:00:00 2001 From: Jakub Hrozek Date: Mon, 2 Nov 2026 10:59:35 +0100 Subject: [PATCH 02/10] Name the migration step in the trust-mode error A private_key_jwt delegate without a token_exchange block is refused since the trust-mode rule landed. Users upgrading from an earlier release may have such a client that relied on the implicit any behaviour, so the error now names the one-line change that restores it. Co-authored-by: Mecatl --- pkg/authserver/config.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/pkg/authserver/config.go b/pkg/authserver/config.go index b80b5c028b..6a79358c5c 100644 --- a/pkg/authserver/config.go +++ b/pkg/authserver/config.go @@ -906,8 +906,9 @@ func requireDeclaredTrustMode(client delegateClientView) error { client.ClientID, SelfIssuedSubjectTokensAny, SelfIssuedSubjectTokensRestricted) } return fmt.Errorf("delegate_clients: client_id %q: private_key_jwt clients must declare a token_exchange block "+ - "with self_issued_subject_tokens (%q or %q)", - client.ClientID, SelfIssuedSubjectTokensAny, SelfIssuedSubjectTokensRestricted) + "with self_issued_subject_tokens (%q or %q); to keep the implicit any behaviour of earlier releases, "+ + "set token_exchange.self_issued_subject_tokens to %q", + client.ClientID, SelfIssuedSubjectTokensAny, SelfIssuedSubjectTokensRestricted, SelfIssuedSubjectTokensAny) } // trustModeRequired reports whether a delegate client with these grant types From 01f0c1297e9ffe5e607a9a1b42bd7a81b3ec07f7 Mon Sep 17 00:00:00 2001 From: Jakub Hrozek Date: Mon, 2 Nov 2026 11:10:57 +0100 Subject: [PATCH 03/10] Expose configured delegate clients in the CRD A broker that keeps one client_id across key rotation needs the pre-provisioned client fields on the operator API, not only in the RunConfig. Delegate clients on inboundGrants.tokenExchange.delegateClients now accept tokenEndpointAuthMethod, jwksRef, tokenEndpointAuthSigningAlg, grantTypes, redirectUris and a tokenExchange source policy. jwksRef is a ConfigMap key holding the public JWKS. The operator mounts it read-only under its own base path and sets jwks_file to match. Editing the ConfigMap does not restart the pod yet. Admission mirrors the runtime validator so the CRD cannot produce a config the auth server refuses. clientSecretRef is now optional in the schema: CEL requires it unless the method is private_key_jwt, where it and jwksRef swap places, and jwksRef.optional is rejected. grantTypes, when set, must include token exchange; redirectUris and refresh_token need authorization_code; tokenEndpointAuthSigningAlg needs private_key_jwt and a supported algorithm. A client that sets grantTypes or uses private_key_jwt must declare tokenExchange.selfIssuedSubjectTokens, any or restricted. Every delegate client, private_key_jwt included, still counts as confidential for the cleartext-transport rule. The deprecated top-level delegateClients shares the type, so the new fields, the CEL rules and the JWKS mount apply to it as well. effectiveDelegateClients picks the canonical list or falls back to the deprecated one, so a jwksRef there never leaves jwks_file pointing at a missing file. Co-authored-by: Mecatl --- .../v1beta1/mcpexternalauthconfig_types.go | 275 +++++- .../mcpexternalauthconfig_types_test.go | 199 ++++ .../api/v1beta1/zz_generated.deepcopy.go | 55 ++ .../virtualmcpserver_controller.go | 12 + .../virtualmcpserver_controller_test.go | 56 ++ .../virtualmcpserver_deployment.go | 2 +- .../virtualmcpserver_deployment_test.go | 52 ++ .../pkg/controllerutil/authserver.go | 115 ++- .../pkg/controllerutil/authserver_test.go | 222 +++++ cmd/thv-operator/pkg/vmcpconfig/converter.go | 2 +- .../confidential_client_transport_cel_test.go | 66 +- .../delegate_client_cel_test.go | 329 +++++++ ...e.stacklok.dev_mcpexternalauthconfigs.yaml | 858 +++++++++++++++++- ...olhive.stacklok.dev_virtualmcpservers.yaml | 858 +++++++++++++++++- ...e.stacklok.dev_mcpexternalauthconfigs.yaml | 858 +++++++++++++++++- ...olhive.stacklok.dev_virtualmcpservers.yaml | 858 +++++++++++++++++- docs/arch/09-operator-architecture.md | 2 + docs/arch/17-token-exchange-delegation.md | 110 ++- docs/operator/crd-api.md | 47 +- pkg/authserver/server/handlers/dcr_test.go | 4 +- pkg/authserver/server/registration/client.go | 4 +- .../server/registration/configured_client.go | 4 +- .../registration/configured_client_test.go | 12 +- pkg/authserver/server/registration/dcr.go | 4 +- pkg/vmcp/config/validator.go | 7 +- pkg/vmcp/config/validator_test.go | 44 + 26 files changed, 4765 insertions(+), 290 deletions(-) create mode 100644 cmd/thv-operator/test-integration/mcp-external-auth/delegate_client_cel_test.go diff --git a/cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types.go b/cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types.go index 7687cbab26..0c6f4c4025 100644 --- a/cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types.go +++ b/cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types.go @@ -12,7 +12,9 @@ import ( "github.com/stacklok/toolhive/pkg/authserver" "github.com/stacklok/toolhive/pkg/authserver/oauthparams" + "github.com/stacklok/toolhive/pkg/authserver/server/registration" "github.com/stacklok/toolhive/pkg/authserver/server/tokenexchange" + "github.com/stacklok/toolhive/pkg/oauthproto" ) // External auth configuration types @@ -351,11 +353,21 @@ type BearerTokenConfig struct { TokenSecretRef *SecretKeyRef `json:"tokenSecretRef"` } -// 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. +// DelegateClientConfig configures a pre-provisioned confidential OAuth client. +// By default it is a token-exchange client (RFC 8693). Its credential is either +// a client secret, referenced from a Kubernetes Secret and never represented +// inline, or a public JWKS for private_key_jwt, referenced from a ConfigMap. // -// +kubebuilder:validation:XValidation:rule="has(self.clientSecretRef) && size(self.clientSecretRef.name) > 0 && size(self.clientSecretRef.key) > 0",message="clientSecretRef.name and clientSecretRef.key are required and must be non-empty" +// +kubebuilder:validation:XValidation:rule="has(self.tokenEndpointAuthMethod) && self.tokenEndpointAuthMethod == 'private_key_jwt' ? (has(self.jwksRef) && !has(self.clientSecretRef)) : (has(self.clientSecretRef) && size(self.clientSecretRef.name) > 0 && size(self.clientSecretRef.key) > 0 && !has(self.jwksRef))",message="private_key_jwt requires a non-empty jwksRef and forbids clientSecretRef; any other tokenEndpointAuthMethod (or none) requires a non-empty clientSecretRef and forbids jwksRef" +// +kubebuilder:validation:XValidation:rule="!has(self.grantTypes) || size(self.grantTypes) == 0 || 'urn:ietf:params:oauth:grant-type:token-exchange' in self.grantTypes",message="grantTypes must include urn:ietf:params:oauth:grant-type:token-exchange when set" +// +kubebuilder:validation:XValidation:rule="!has(self.grantTypes) || !('authorization_code' in self.grantTypes) || (has(self.redirectUris) && size(self.redirectUris) > 0)",message="redirectUris is required when grantTypes includes authorization_code" +// +kubebuilder:validation:XValidation:rule="!has(self.grantTypes) || size(self.grantTypes) == 0 || has(self.tokenExchange)",message="tokenExchange.selfIssuedSubjectTokens is required when grantTypes is set" +// +kubebuilder:validation:XValidation:rule="!(has(self.tokenEndpointAuthMethod) && self.tokenEndpointAuthMethod == 'private_key_jwt') || has(self.tokenExchange)",message="tokenExchange.selfIssuedSubjectTokens is required for private_key_jwt clients" +// +kubebuilder:validation:XValidation:rule="!has(self.tokenExchange) || !has(self.tokenExchange.apiAudience) || size(self.tokenExchange.apiAudience) == 0 || self.tokenExchange.apiAudience in self.audiences",message="tokenExchange.apiAudience must be one of the client's audiences" +// +kubebuilder:validation:XValidation:rule="!has(self.tokenExchange) || !has(self.tokenExchange.allowedSourceClients) || (!(self.clientId in self.tokenExchange.allowedSourceClients) && !('*' in self.tokenExchange.allowedSourceClients))",message="tokenExchange.allowedSourceClients must not contain the client's own clientId or the \"*\" wildcard" +// +kubebuilder:validation:XValidation:rule="!has(self.redirectUris) || size(self.redirectUris) == 0 || (has(self.grantTypes) && 'authorization_code' in self.grantTypes)",message="redirectUris is only allowed with the authorization_code grant" +// +kubebuilder:validation:XValidation:rule="!has(self.grantTypes) || !('refresh_token' in self.grantTypes) || 'authorization_code' in self.grantTypes",message="grantTypes must include authorization_code with refresh_token" +// +kubebuilder:validation:XValidation:rule="!has(self.tokenEndpointAuthSigningAlg) || size(self.tokenEndpointAuthSigningAlg) == 0 || (has(self.tokenEndpointAuthMethod) && self.tokenEndpointAuthMethod == 'private_key_jwt')",message="tokenEndpointAuthSigningAlg is only allowed with private_key_jwt" // //nolint:lll // CEL validation rule exceeds line length limit type DelegateClientConfig struct { @@ -365,9 +377,61 @@ type DelegateClientConfig struct { // +kubebuilder:validation:MaxLength=256 ClientID string `json:"clientId"` + // TokenEndpointAuthMethod pins how the client authenticates at the token + // endpoint. If omitted, the client authenticates with its secret by either + // HTTP Basic or form body. + // +kubebuilder:validation:Enum=client_secret_basic;client_secret_post;private_key_jwt + // +optional + TokenEndpointAuthMethod string `json:"tokenEndpointAuthMethod,omitempty"` + // ClientSecretRef references the Kubernetes Secret key containing the client secret. - // +kubebuilder:validation:Required - ClientSecretRef *SecretKeyRef `json:"clientSecretRef"` + // Required unless tokenEndpointAuthMethod is private_key_jwt, where it is forbidden. + // +optional + ClientSecretRef *SecretKeyRef `json:"clientSecretRef,omitempty"` + + // JWKSRef references the ConfigMap key holding the client's public JWKS + // document. Required with private_key_jwt and forbidden otherwise. The + // document must contain public keys only. + // +optional + JWKSRef *ConfigMapKeyRef `json:"jwksRef,omitempty"` + + // TokenEndpointAuthSigningAlg is the private_key_jwt signing algorithm, + // for example RS256 or ES256. If omitted, it is derived from the keys: + // each key's alg, or ES256, ES384 or ES512 from an EC key's curve. RSA + // keys without alg use RS256. Keys that disagree must set it. + // +kubebuilder:validation:Enum=RS256;RS384;RS512;PS256;PS384;PS512;ES256;ES384;ES512 + // +optional + TokenEndpointAuthSigningAlg string `json:"tokenEndpointAuthSigningAlg,omitempty"` + + // GrantTypes are the grants this client may use. If omitted or empty, the + // client is token-exchange only. When set, the list must include + // urn:ietf:params:oauth:grant-type:token-exchange: a delegate client is a + // token-exchange client that may also log in or redeem other grants. When set, + // the list is complete: include urn:ietf:params:oauth:grant-type:jwt-bearer to + // keep ID-JAG redemption. + // +kubebuilder:validation:MaxItems=4 + // +kubebuilder:validation:items:Enum=authorization_code;refresh_token;"urn:ietf:params:oauth:grant-type:token-exchange";"urn:ietf:params:oauth:grant-type:jwt-bearer" + // +listType=set + // +optional + GrantTypes []string `json:"grantTypes,omitempty"` + + // RedirectURIs are the registered redirect URIs. They are required with the + // authorization_code grant and forbidden without it. They sit here, under + // tokenExchange.delegateClients, because a delegate client is a + // token-exchange client that may also log in (authorization_code). + // + // The pattern is stricter than the runtime for uppercase "HTTPS://": the + // runtime accepts it, the pattern does not. The scheme check is + // case-sensitive, as in forceConfidentialRedirectUris. Query strings and + // fragments behave the same as the runtime: a query string is allowed, and + // a fragment is rejected. + // +kubebuilder:validation:MaxItems=10 + // +kubebuilder:validation:items:MinLength=1 + // +kubebuilder:validation:items:MaxLength=2048 + // +kubebuilder:validation:items:Pattern=`^https://[^\s#]+$` + // +listType=set + // +optional + RedirectURIs []string `json:"redirectUris,omitempty"` // Scopes is the narrowed set of OAuth scopes this client may request. // +kubebuilder:validation:Required @@ -386,6 +450,54 @@ type DelegateClientConfig struct { // +kubebuilder:validation:items:MaxLength=2048 // +listType=atomic Audiences []string `json:"audiences"` + + // TokenExchange is the client's token-exchange policy. Its + // selfIssuedSubjectTokens field is required whenever the block is present. + // The block may be omitted only by a secret-authenticated client without + // grantTypes; such a client keeps the legacy rule (any) and may exchange any + // self-issued token. A client that sets grantTypes or uses private_key_jwt + // must declare the block. + // +optional + TokenExchange *DelegateClientTokenExchangeConfig `json:"tokenExchange,omitempty"` +} + +// DelegateClientTokenExchangeConfig is the token-exchange policy of one +// delegate client. External-issuer subject tokens are not affected by it: the +// trusted issuer's own policy decides those. +// +// +kubebuilder:validation:XValidation:rule="self.selfIssuedSubjectTokens == 'restricted' || ((!has(self.allowedSourceClients) || size(self.allowedSourceClients) == 0) && (!has(self.apiAudience) || size(self.apiAudience) == 0))",message="tokenExchange.allowedSourceClients and tokenExchange.apiAudience are valid only when selfIssuedSubjectTokens is restricted" +// +//nolint:lll // CEL validation rule exceeds line length limit +type DelegateClientTokenExchangeConfig struct { + // SelfIssuedSubjectTokens selects which self-issued subject tokens the + // client may exchange. With restricted, a self-issued subject token must + // carry the client's own client_id or one of allowedSourceClients, and a + // may_act claim naming this client does not lift that restriction. With + // any, the client may exchange any self-issued token. It has no default + // and must be set explicitly. + // +kubebuilder:validation:Required + // +kubebuilder:validation:Enum=any;restricted + SelfIssuedSubjectTokens string `json:"selfIssuedSubjectTokens"` + + // AllowedSourceClients are client_id values, as they appear in the subject + // token's client_id claim, whose self-issued tokens this client may + // exchange in addition to its own. They must be user-facing clients that + // register at runtime (DCR or CIMD). A configured delegate client cannot be + // listed here. Valid only with restricted; empty means the client's own + // tokens only. + // +kubebuilder:validation:MaxItems=20 + // +kubebuilder:validation:items:MinLength=1 + // +kubebuilder:validation:items:MaxLength=256 + // +listType=set + // +optional + AllowedSourceClients []string `json:"allowedSourceClients,omitempty"` + + // APIAudience is the client's own API audience. The client may request it + // even when the subject token does not cover it. It must be one of the + // client's audiences. Valid only with restricted. + // +kubebuilder:validation:MaxLength=2048 + // +optional + APIAudience string `json:"apiAudience,omitempty"` } // TrustedIssuerConfig configures an external OIDC issuer whose tokens are @@ -939,6 +1051,7 @@ type JWTBearerIssuerPolicyConfig struct { // +kubebuilder:validation:XValidation:rule="!has(self.inboundGrants) || !has(self.inboundGrants.spiffeClientAuth) || self.inboundGrants.spiffeClientAuth.all(client, self.inboundGrants.spiffeClientAuth.filter(other, other.principalPattern == client.principalPattern).size() == 1)",message="spiffeClientAuth must not contain duplicate principal patterns" // +kubebuilder:validation:XValidation:rule="!has(self.inboundGrants) || !has(self.inboundGrants.spiffeClientAuth) || self.inboundGrants.spiffeClientAuth.all(client, !client.clientId.startsWith('synthetic:'))",message="spiffeClientAuth clientId must not use the reserved synthetic: prefix" // +kubebuilder:validation:XValidation:rule="!has(self.inboundGrants) || !has(self.inboundGrants.spiffeClientAuth) || self.inboundGrants.spiffeClientAuth.all(client, !client.clientId.matches('^[A-Za-z][A-Za-z0-9+.-]*://.+'))",message="spiffeClientAuth clientId must not be an absolute URL" +// +kubebuilder:validation:XValidation:rule="!has(self.delegateClients) || self.delegateClients.all(c, !has(c.jwksRef) && !has(c.tokenEndpointAuthMethod) && !has(c.grantTypes) && !has(c.redirectUris) && !has(c.tokenExchange) && !has(c.tokenEndpointAuthSigningAlg))",message="delegateClients is deprecated and accepts only clientId, clientSecretRef, scopes and audiences; use inboundGrants.tokenExchange.delegateClients for jwksRef, tokenEndpointAuthMethod, grantTypes, redirectUris, tokenExchange and tokenEndpointAuthSigningAlg" // // +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" @@ -1160,19 +1273,24 @@ type EmbeddedAuthServerConfig struct { // 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 + // private_key_jwt registration (DCR) 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. + // nothing here for cleartext HTTP to expose. Delegate clients count as + // confidential whatever their tokenEndpointAuthMethod, private_key_jwt + // included, because they can hold authorization_code and refresh_token grants. // +kubebuilder:default=false // +optional InsecureAllowConfidentialOverLoopbackHTTP bool `json:"insecureAllowConfidentialOverLoopbackHTTP,omitempty"` // 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. + // plaintext secret is accepted here. The supported path is + // inboundGrants.tokenExchange.delegateClients. This field shares its type + // with the canonical list, but accepts only clientId, clientSecretRef, + // scopes and audiences. The newer fields (tokenEndpointAuthMethod, + // tokenEndpointAuthSigningAlg, jwksRef, grantTypes, redirectUris, + // tokenExchange) are rejected here; set them on the canonical list. // // This is independent of allowConfidentialClientRegistration: it neither // enables nor requires unauthenticated confidential dynamic client @@ -1428,14 +1546,12 @@ func resolveEffectiveIssuerRef(byName, seen map[string]int, ref, path string) (i } 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 - } + if clients == nil { + return nil + } + cloned := make([]DelegateClientConfig, len(clients)) + for i := range clients { + cloned[i] = *clients[i].DeepCopy() } return cloned } @@ -2317,6 +2433,19 @@ type SecretKeyRef struct { Key string `json:"key"` } +// ConfigMapKeyRef is a reference to a key within a ConfigMap in the same namespace. +type ConfigMapKeyRef struct { + // Name is the name of the ConfigMap + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinLength=1 + Name string `json:"name"` + + // Key is the key within the ConfigMap + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinLength=1 + Key string `json:"key"` +} + // AWSStsConfig holds configuration for AWS STS authentication with SigV4 request signing. // This configuration exchanges incoming authentication tokens (typically OIDC JWT) for AWS STS // temporary credentials, then signs requests to AWS services using SigV4. @@ -2742,6 +2871,8 @@ func (r *MCPExternalAuthConfig) validateTypeConfigConsistency() error { // validateEmbeddedAuthServer validates embeddedAuthServer type configuration. // This performs complex business logic validation that CEL cannot express. +// +//nolint:gocyclo // a flat sequence of independent spec checks, each delegated to a helper func (r *MCPExternalAuthConfig) validateEmbeddedAuthServer() error { // Validate upstream providers cfg := r.Spec.EmbeddedAuthServer @@ -2791,6 +2922,9 @@ func (r *MCPExternalAuthConfig) validateEmbeddedAuthServer() error { ); err != nil { return err } + if err := validateEmbeddedDelegateClientTrustModes(cfg); err != nil { + return err + } seen := make(map[string]bool, len(cfg.UpstreamProviders)) for i, provider := range cfg.UpstreamProviders { @@ -2812,6 +2946,109 @@ func (r *MCPExternalAuthConfig) validateEmbeddedAuthServer() error { return nil } +// ValidateEmbeddedAuthServerDelegateClients runs the delegate client checks +// (trust modes, loopback redirect URIs, sibling source clients) over both the +// deprecated and the canonical delegate client lists. It is the same check +// MCPExternalAuthConfig runs, exported so VirtualMCPServer reports the same +// failures. +func ValidateEmbeddedAuthServerDelegateClients(cfg *EmbeddedAuthServerConfig) error { + return validateEmbeddedDelegateClientTrustModes(cfg) +} + +// validateEmbeddedDelegateClientTrustModes checks both the deprecated and the +// canonical delegate client lists, which share DelegateClientConfig. +func validateEmbeddedDelegateClientTrustModes(cfg *EmbeddedAuthServerConfig) error { + if err := validateDelegateClientTrustModes(cfg.DelegateClients, "delegateClients"); err != nil { + return err + } + if cfg.InboundGrants == nil || cfg.InboundGrants.TokenExchange == nil { + return nil + } + return validateDelegateClientTrustModes( + cfg.InboundGrants.TokenExchange.DelegateClients, "inboundGrants.tokenExchange.delegateClients") +} + +// validateDelegateClientTrustModes checks the trust mode of each delegate +// client's tokenExchange block. Defense-in-depth with CEL: the +// DelegateClientConfig and DelegateClientTokenExchangeConfig XValidation rules +// and the selfIssuedSubjectTokens Required/Enum markers enforce the same rules +// at admission; this catches an object stored before those rules existed, so +// the condition names the field instead of failing later in RunConfig +// validation. Keep the missing-block conditions in step with +// requireDeclaredTrustMode in pkg/authserver/config.go. +func validateDelegateClientTrustModes(clients []DelegateClientConfig, path string) error { + for i, client := range clients { + if err := validateDelegateClientRedirectURIs(client, path, i); err != nil { + return err + } + if err := validateDelegateClientSourceClients(client, clients, path, i); err != nil { + return err + } + policy := client.TokenExchange + if policy == nil { + if len(client.GrantTypes) > 0 { + return fmt.Errorf("%s[%d] (%q): tokenExchange.selfIssuedSubjectTokens is required when grantTypes is set", + path, i, client.ClientID) + } + if client.TokenEndpointAuthMethod == oauthproto.TokenEndpointAuthMethodPrivateKeyJWT { + return fmt.Errorf("%s[%d] (%q): tokenExchange.selfIssuedSubjectTokens is required for private_key_jwt clients", + path, i, client.ClientID) + } + continue + } + switch authserver.SelfIssuedSubjectTokens(policy.SelfIssuedSubjectTokens) { + case authserver.SelfIssuedSubjectTokensRestricted: + case authserver.SelfIssuedSubjectTokensAny: + if len(policy.AllowedSourceClients) > 0 || policy.APIAudience != "" { + return fmt.Errorf("%s[%d] (%q): tokenExchange.allowedSourceClients and tokenExchange.apiAudience "+ + "are valid only when selfIssuedSubjectTokens is restricted", path, i, client.ClientID) + } + default: + return fmt.Errorf("%s[%d] (%q): tokenExchange.selfIssuedSubjectTokens must be %q or %q, got %q", + path, i, client.ClientID, authserver.SelfIssuedSubjectTokensAny, + authserver.SelfIssuedSubjectTokensRestricted, policy.SelfIssuedSubjectTokens) + } + } + return nil +} + +// validateDelegateClientRedirectURIs rejects loopback redirect URIs, which the +// auth server refuses at startup. Not CEL-expressible: it needs URL parsing +// and IP-literal detection. The https scheme is enforced by CEL. +func validateDelegateClientRedirectURIs(client DelegateClientConfig, path string, i int) error { + for _, uri := range client.RedirectURIs { + if registration.IsLoopbackRedirectURI(uri) { + return fmt.Errorf("%s[%d] (%q): redirectUris must not be loopback (offending redirect URI: %s)", + path, i, client.ClientID, uri) + } + } + return nil +} + +// validateDelegateClientSourceClients rejects an allowedSourceClients entry +// that names another configured delegate client. Defense-in-depth with the +// auth server's own check; CEL cannot see sibling list items from the nested +// client. The own-clientId and wildcard cases are covered by CEL. +func validateDelegateClientSourceClients( + client DelegateClientConfig, clients []DelegateClientConfig, path string, i int, +) error { + if client.TokenExchange == nil { + return nil + } + for _, id := range client.TokenExchange.AllowedSourceClients { + if id == client.ClientID { + continue + } + for _, other := range clients { + if other.ClientID == id { + return fmt.Errorf("%s[%d] (%q): tokenExchange.allowedSourceClients lists %q, a configured delegate client; "+ + "source clients must be user-facing clients", path, i, client.ClientID, id) + } + } + } + return nil +} + // validateSPIFFEBundleEndpoints rejects a structurally invalid SPIFFE Bundle // Endpoint URL (wrong scheme, an IP-literal or loopback host, credentials, // a query, or a fragment) at admission time. Not CEL-expressible: it needs diff --git a/cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types_test.go b/cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types_test.go index 131a70de1f..03ef5c258d 100644 --- a/cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types_test.go +++ b/cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types_test.go @@ -5,6 +5,7 @@ package v1beta1 import ( "encoding/json" + "slices" "strings" "testing" "time" @@ -2022,11 +2023,59 @@ func TestEmbeddedAuthServerConfig_ValidateInboundGrants_ConfidentialClientTransp Scopes: []string{"openid"}, Audiences: []string{"https://api.example.com"}, }} + jwtClient := DelegateClientConfig{ + ClientID: "jwt-client", + TokenEndpointAuthMethod: "private_key_jwt", + JWKSRef: &ConfigMapKeyRef{Name: "jwks", Key: "jwks.json"}, + Scopes: []string{"openid"}, + Audiences: []string{"https://api.example.com"}, + TokenExchange: &DelegateClientTokenExchangeConfig{SelfIssuedSubjectTokens: "any"}, + } + jwtOnly := []DelegateClientConfig{jwtClient} + mixed := append(slices.Clone(delegateClients), jwtClient) tests := []struct { name string config EmbeddedAuthServerConfig expectErr bool }{ + { + name: "private_key_jwt delegate clients reject plain-HTTP loopback issuer without opt in", + config: EmbeddedAuthServerConfig{ + Issuer: "http://127.0.0.1:8080", + DelegateClients: jwtOnly, + }, + expectErr: true, + }, + { + name: "canonical private_key_jwt delegate clients reject insecure HTTP", + config: EmbeddedAuthServerConfig{ + Issuer: "http://auth.example.com", + InsecureAllowHTTP: true, + InboundGrants: &InboundGrantsConfig{ + TokenExchange: &TokenExchangeInboundGrantConfig{DelegateClients: jwtOnly}, + }, + }, + expectErr: true, + }, + { + name: "mixed delegate clients reject plain-HTTP loopback issuer without opt in", + config: EmbeddedAuthServerConfig{ + Issuer: "http://127.0.0.1:8080", + DelegateClients: mixed, + }, + expectErr: true, + }, + { + name: "canonical mixed delegate clients reject insecure HTTP", + config: EmbeddedAuthServerConfig{ + Issuer: "http://auth.example.com", + InsecureAllowHTTP: true, + InboundGrants: &InboundGrantsConfig{ + TokenExchange: &TokenExchangeInboundGrantConfig{DelegateClients: mixed}, + }, + }, + expectErr: true, + }, { name: "delegate clients remain independent of confidential DCR", config: EmbeddedAuthServerConfig{ @@ -2155,6 +2204,156 @@ func TestMCPExternalAuthConfig_DelegateClientsRejectUnsafeHTTP(t *testing.T) { assert.Contains(t, err.Error(), "insecure_allow_http") } +func TestMCPExternalAuthConfig_DelegateClientTrustModes(t *testing.T) { + t.Parallel() + + exchangeOnly := []string{"urn:ietf:params:oauth:grant-type:token-exchange"} + client := func(grants []string, policy *DelegateClientTokenExchangeConfig) DelegateClientConfig { + return DelegateClientConfig{ + ClientID: "broker", + ClientSecretRef: &SecretKeyRef{Name: "broker-secret", Key: "client-secret"}, + GrantTypes: grants, + Scopes: []string{"openid"}, + Audiences: []string{"https://api.example.com"}, + TokenExchange: policy, + } + } + jwtClient := func(policy *DelegateClientTokenExchangeConfig) DelegateClientConfig { + return DelegateClientConfig{ + ClientID: "jwt-broker", + TokenEndpointAuthMethod: "private_key_jwt", + JWKSRef: &ConfigMapKeyRef{Name: "jwks", Key: "jwks.json"}, + Scopes: []string{"openid"}, + Audiences: []string{"https://api.example.com"}, + TokenExchange: policy, + } + } + tests := []struct { + name string + client DelegateClientConfig + legacy bool + wantErr string + }{ + {name: "no grants and no block keeps legacy trust", client: client(nil, nil)}, + {name: "private_key_jwt without block", client: jwtClient(nil), + wantErr: "tokenExchange.selfIssuedSubjectTokens is required for private_key_jwt clients"}, + {name: "private_key_jwt without block in legacy list", client: jwtClient(nil), legacy: true, + wantErr: "delegateClients[0] (\"jwt-broker\"): tokenExchange.selfIssuedSubjectTokens is required for private_key_jwt clients"}, + {name: "private_key_jwt with any", client: jwtClient(&DelegateClientTokenExchangeConfig{ + SelfIssuedSubjectTokens: "any", + })}, + {name: "grants without block", client: client(exchangeOnly, nil), + wantErr: "tokenExchange.selfIssuedSubjectTokens is required when grantTypes is set"}, + {name: "legacy list grants without block", client: client(exchangeOnly, nil), legacy: true, + wantErr: "delegateClients[0]"}, + {name: "empty block", client: client(nil, &DelegateClientTokenExchangeConfig{}), + wantErr: "tokenExchange.selfIssuedSubjectTokens must be"}, + {name: "grants with explicit any", client: client(exchangeOnly, + &DelegateClientTokenExchangeConfig{SelfIssuedSubjectTokens: "any"})}, + {name: "restricted with empty list", client: client(exchangeOnly, + &DelegateClientTokenExchangeConfig{SelfIssuedSubjectTokens: "restricted", AllowedSourceClients: []string{}})}, + {name: "any with source clients", client: client(exchangeOnly, &DelegateClientTokenExchangeConfig{ + SelfIssuedSubjectTokens: "any", AllowedSourceClients: []string{"tui"}, + }), wantErr: "valid only when selfIssuedSubjectTokens is restricted"}, + {name: "any with api audience", client: client(exchangeOnly, &DelegateClientTokenExchangeConfig{ + SelfIssuedSubjectTokens: "any", APIAudience: "https://api.example.com", + }), wantErr: "valid only when selfIssuedSubjectTokens is restricted"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + embedded := &EmbeddedAuthServerConfig{Issuer: "https://auth.example.com"} + if tt.legacy { + embedded.DelegateClients = []DelegateClientConfig{tt.client} + } else { + embedded.InboundGrants = &InboundGrantsConfig{TokenExchange: &TokenExchangeInboundGrantConfig{ + DelegateClients: []DelegateClientConfig{tt.client}, + }} + } + config := &MCPExternalAuthConfig{Spec: MCPExternalAuthConfigSpec{ + Type: ExternalAuthTypeEmbeddedAuthServer, EmbeddedAuthServer: embedded, + }} + err := config.Validate() + if tt.wantErr == "" { + require.NoError(t, err) + return + } + require.ErrorContains(t, err, tt.wantErr) + }) + } +} + +func TestMCPExternalAuthConfig_DelegateClientAdmissionGoChecks(t *testing.T) { + t.Parallel() + + base := func(id string) DelegateClientConfig { + return DelegateClientConfig{ + ClientID: id, + ClientSecretRef: &SecretKeyRef{Name: "secret-" + id, Key: "client-secret"}, + Scopes: []string{"openid"}, + Audiences: []string{"https://api.example.com"}, + } + } + loopback := func(uri string) DelegateClientConfig { + c := base("broker") + c.GrantTypes = []string{"authorization_code", "urn:ietf:params:oauth:grant-type:token-exchange"} + c.RedirectURIs = []string{uri} + c.TokenExchange = &DelegateClientTokenExchangeConfig{SelfIssuedSubjectTokens: "any"} + return c + } + listsOther := base("broker") + listsOther.TokenExchange = &DelegateClientTokenExchangeConfig{ + SelfIssuedSubjectTokens: "restricted", AllowedSourceClients: []string{"other"}, + } + listsUnknown := base("broker") + listsUnknown.TokenExchange = &DelegateClientTokenExchangeConfig{ + SelfIssuedSubjectTokens: "restricted", AllowedSourceClients: []string{"tui"}, + } + + tests := []struct { + name string + clients []DelegateClientConfig + wantErr string + }{ + {name: "public https redirect", clients: []DelegateClientConfig{loopback("https://broker.example.com/cb")}}, + {name: "localhost redirect", clients: []DelegateClientConfig{loopback("https://localhost/cb")}, + wantErr: "redirectUris must not be loopback"}, + {name: "mixed-case localhost redirect", clients: []DelegateClientConfig{loopback("https://LocalHost:8443/cb")}, + wantErr: "redirectUris must not be loopback"}, + {name: "127.0.0.1 redirect", clients: []DelegateClientConfig{loopback("https://127.0.0.1:8443/cb")}, + wantErr: "redirectUris must not be loopback"}, + {name: "IPv6 loopback redirect", clients: []DelegateClientConfig{loopback("https://[::1]/cb")}, + wantErr: "redirectUris must not be loopback"}, + {name: "source client names another delegate client", clients: []DelegateClientConfig{listsOther, base("other")}, + wantErr: "a configured delegate client"}, + {name: "source client is not a delegate client", clients: []DelegateClientConfig{listsUnknown, base("other")}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + config := &MCPExternalAuthConfig{Spec: MCPExternalAuthConfigSpec{ + Type: ExternalAuthTypeEmbeddedAuthServer, + EmbeddedAuthServer: &EmbeddedAuthServerConfig{ + Issuer: "https://auth.example.com", + InboundGrants: &InboundGrantsConfig{TokenExchange: &TokenExchangeInboundGrantConfig{ + DelegateClients: tt.clients, + }}, + }, + }} + err := config.Validate() + if tt.wantErr == "" { + require.NoError(t, err) + return + } + require.ErrorContains(t, err, tt.wantErr) + }) + } +} + func TestEmbeddedAuthServerConfig_SyntheticIdentityUpstreams(t *testing.T) { t.Parallel() diff --git a/cmd/thv-operator/api/v1beta1/zz_generated.deepcopy.go b/cmd/thv-operator/api/v1beta1/zz_generated.deepcopy.go index 96a443ac89..2e6e5d2608 100644 --- a/cmd/thv-operator/api/v1beta1/zz_generated.deepcopy.go +++ b/cmd/thv-operator/api/v1beta1/zz_generated.deepcopy.go @@ -237,6 +237,21 @@ func (in *ConfigMapAuthzRef) DeepCopy() *ConfigMapAuthzRef { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ConfigMapKeyRef) DeepCopyInto(out *ConfigMapKeyRef) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ConfigMapKeyRef. +func (in *ConfigMapKeyRef) DeepCopy() *ConfigMapKeyRef { + if in == nil { + return nil + } + out := new(ConfigMapKeyRef) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *DCRUpstreamConfig) DeepCopyInto(out *DCRUpstreamConfig) { *out = *in @@ -270,6 +285,21 @@ func (in *DelegateClientConfig) DeepCopyInto(out *DelegateClientConfig) { *out = new(SecretKeyRef) **out = **in } + if in.JWKSRef != nil { + in, out := &in.JWKSRef, &out.JWKSRef + *out = new(ConfigMapKeyRef) + **out = **in + } + if in.GrantTypes != nil { + in, out := &in.GrantTypes, &out.GrantTypes + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.RedirectURIs != nil { + in, out := &in.RedirectURIs, &out.RedirectURIs + *out = make([]string, len(*in)) + copy(*out, *in) + } if in.Scopes != nil { in, out := &in.Scopes, &out.Scopes *out = make([]string, len(*in)) @@ -280,6 +310,11 @@ func (in *DelegateClientConfig) DeepCopyInto(out *DelegateClientConfig) { *out = make([]string, len(*in)) copy(*out, *in) } + if in.TokenExchange != nil { + in, out := &in.TokenExchange, &out.TokenExchange + *out = new(DelegateClientTokenExchangeConfig) + (*in).DeepCopyInto(*out) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DelegateClientConfig. @@ -292,6 +327,26 @@ func (in *DelegateClientConfig) DeepCopy() *DelegateClientConfig { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *DelegateClientTokenExchangeConfig) DeepCopyInto(out *DelegateClientTokenExchangeConfig) { + *out = *in + if in.AllowedSourceClients != nil { + in, out := &in.AllowedSourceClients, &out.AllowedSourceClients + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DelegateClientTokenExchangeConfig. +func (in *DelegateClientTokenExchangeConfig) DeepCopy() *DelegateClientTokenExchangeConfig { + if in == nil { + return nil + } + out := new(DelegateClientTokenExchangeConfig) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *EmbeddedAuthServerCIMDConfig) DeepCopyInto(out *EmbeddedAuthServerCIMDConfig) { *out = *in diff --git a/cmd/thv-operator/controllers/virtualmcpserver_controller.go b/cmd/thv-operator/controllers/virtualmcpserver_controller.go index cc1179175d..23f6076e0a 100644 --- a/cmd/thv-operator/controllers/virtualmcpserver_controller.go +++ b/cmd/thv-operator/controllers/virtualmcpserver_controller.go @@ -717,6 +717,18 @@ func (*VirtualMCPServerReconciler) validateAuthServerConfig( statusManager.SetObservedGeneration(vmcp.Generation) return stderrors.New(message) } + if err := mcpv1beta1.ValidateEmbeddedAuthServerDelegateClients(cfg); err != nil { + message := fmt.Sprintf("spec.authServerConfig: %v", err) + statusManager.SetPhase(mcpv1beta1.VirtualMCPServerPhaseFailed) + statusManager.SetMessage(message) + statusManager.SetAuthServerConfigValidatedCondition( + mcpv1beta1.ConditionReasonAuthServerConfigInvalid, + message, + metav1.ConditionFalse, + ) + statusManager.SetObservedGeneration(vmcp.Generation) + return stderrors.New(message) + } // Admission-time check: http:// issuers for non-localhost hosts require // insecureAllowHTTP to be set explicitly. Without it the proxyrunner pod diff --git a/cmd/thv-operator/controllers/virtualmcpserver_controller_test.go b/cmd/thv-operator/controllers/virtualmcpserver_controller_test.go index e37274e9d3..e4a2797a6b 100644 --- a/cmd/thv-operator/controllers/virtualmcpserver_controller_test.go +++ b/cmd/thv-operator/controllers/virtualmcpserver_controller_test.go @@ -4799,6 +4799,62 @@ func TestVirtualMCPServerValidateAuthServerConfig_InsecureAllowHTTP(t *testing.T } } +func TestVirtualMCPServerValidateAuthServerConfig_DelegateClients(t *testing.T) { + t.Parallel() + + const tokenExchangeGrant = "urn:ietf:params:oauth:grant-type:token-exchange" + loopbackClient := mcpv1beta1.DelegateClientConfig{ + ClientID: "delegate", + ClientSecretRef: &mcpv1beta1.SecretKeyRef{Name: "delegate-secret", Key: "secret"}, + GrantTypes: []string{"authorization_code", tokenExchangeGrant}, + RedirectURIs: []string{"https://localhost/callback"}, + TokenExchange: &mcpv1beta1.DelegateClientTokenExchangeConfig{SelfIssuedSubjectTokens: "any"}, + } + + tests := []struct { + name string + cfg *mcpv1beta1.EmbeddedAuthServerConfig + }{ + { + name: "loopback https redirect URI on a canonical delegate client", + cfg: &mcpv1beta1.EmbeddedAuthServerConfig{ + Issuer: "https://authserver.example.com", + InboundGrants: &mcpv1beta1.InboundGrantsConfig{ + TokenExchange: &mcpv1beta1.TokenExchangeInboundGrantConfig{ + DelegateClients: []mcpv1beta1.DelegateClientConfig{loopbackClient}, + }, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + vmcp := v1beta1test.NewVirtualMCPServer(testVmcpName, "default", + v1beta1test.WithVMCPGroupRef("test-group"), + v1beta1test.WithVMCPAuthServerConfig(tt.cfg), + v1beta1test.MutateVMCP(func(v *mcpv1beta1.VirtualMCPServer) { + v.Generation = 1 + }), + ) + + statusManager := virtualmcpserverstatus.NewStatusManager(vmcp) + err := (&VirtualMCPServerReconciler{}).validateAuthServerConfig(vmcp, statusManager) + statusManager.UpdateStatus(t.Context(), &vmcp.Status) + + require.Error(t, err) + cond := findCondition(vmcp.Status.Conditions, mcpv1beta1.ConditionTypeAuthServerConfigValidated) + require.NotNil(t, cond, "AuthServerConfigValidated condition must be set") + assert.Equal(t, metav1.ConditionFalse, cond.Status) + assert.Equal(t, mcpv1beta1.ConditionReasonAuthServerConfigInvalid, cond.Reason) + assert.Contains(t, cond.Message, "inboundGrants.tokenExchange.delegateClients[0]") + assert.Contains(t, cond.Message, "redirectUris must not be loopback") + }) + } +} + func TestVirtualMCPServerValidateAuthServerConfig_ZeroUpstreamAlternatives(t *testing.T) { t.Parallel() diff --git a/cmd/thv-operator/controllers/virtualmcpserver_deployment.go b/cmd/thv-operator/controllers/virtualmcpserver_deployment.go index 035916ef11..3588336ea0 100644 --- a/cmd/thv-operator/controllers/virtualmcpserver_deployment.go +++ b/cmd/thv-operator/controllers/virtualmcpserver_deployment.go @@ -1229,7 +1229,7 @@ func (r *VirtualMCPServerReconciler) validateSecretReferences( // controller checks only the Secret metadata and key presence; it never reads // or copies credential values. if vmcp.Spec.AuthServerConfig != nil { - for _, delegateClient := range vmcp.Spec.AuthServerConfig.DelegateClients { + for _, delegateClient := range ctrlutil.EffectiveDelegateClients(vmcp.Spec.AuthServerConfig) { if err := r.validateSecretKeyRef(ctx, vmcp.Namespace, delegateClient.ClientSecretRef, fmt.Sprintf("delegate client %q", delegateClient.ClientID)); err != nil { return err diff --git a/cmd/thv-operator/controllers/virtualmcpserver_deployment_test.go b/cmd/thv-operator/controllers/virtualmcpserver_deployment_test.go index 68805005ff..02b484f5c0 100644 --- a/cmd/thv-operator/controllers/virtualmcpserver_deployment_test.go +++ b/cmd/thv-operator/controllers/virtualmcpserver_deployment_test.go @@ -124,6 +124,58 @@ func TestDeploymentForVirtualMCPServer_WithDelegateClientSecrets(t *testing.T) { t.Fatal("delegate client secret environment variable was not generated") } +func TestDeploymentForVirtualMCPServer_WithDelegateClientJWKS(t *testing.T) { + t.Parallel() + + vmcp := v1beta1test.NewVirtualMCPServer("test-vmcp", "default", + v1beta1test.WithVMCPGroupRef("test-group"), + ) + vmcp.Spec.AuthServerConfig = &mcpv1beta1.EmbeddedAuthServerConfig{ + InboundGrants: &mcpv1beta1.InboundGrantsConfig{ + TokenExchange: &mcpv1beta1.TokenExchangeInboundGrantConfig{ + DelegateClients: []mcpv1beta1.DelegateClientConfig{ + { + ClientID: "broker", + TokenEndpointAuthMethod: "private_key_jwt", + JWKSRef: &mcpv1beta1.ConfigMapKeyRef{Name: "broker-jwks", Key: "jwks.json"}, + Scopes: []string{"openid"}, + Audiences: []string{"https://resource.example.com"}, + TokenExchange: &mcpv1beta1.DelegateClientTokenExchangeConfig{SelfIssuedSubjectTokens: "any"}, + }, + }, + }, + }, + } + + r := &VirtualMCPServerReconciler{ + Scheme: testutil.NewScheme(t), + PlatformDetector: ctrlutil.NewSharedPlatformDetector(), + } + deployment := r.deploymentForVirtualMCPServer(context.Background(), vmcp, "test-checksum", "", nil, nil) + require.NotNil(t, deployment) + + podSpec := deployment.Spec.Template.Spec + var volumeName string + for _, volume := range podSpec.Volumes { + if volume.ConfigMap != nil && volume.ConfigMap.Name == "broker-jwks" { + volumeName = volume.Name + } + } + require.NotEmpty(t, volumeName, "JWKS ConfigMap volume was not added to the pod") + assert.Equal(t, ctrlutil.AuthServerDelegateClientJWKSVolumePrefix+"0", volumeName) + + require.Len(t, podSpec.Containers, 1) + var mountFound bool + for _, mount := range podSpec.Containers[0].VolumeMounts { + if mount.Name == volumeName { + assert.Equal(t, "/etc/toolhive/authserver/delegate-jwks/0/jwks.json", mount.MountPath) + assert.True(t, mount.ReadOnly) + mountFound = true + } + } + assert.True(t, mountFound, "JWKS volume was not mounted into the container") +} + // TestDeploymentForVirtualMCPServer_WithRedisPassword tests that the deployment pod // spec includes THV_SESSION_REDIS_PASSWORD when spec.sessionStorage has a passwordRef. func TestDeploymentForVirtualMCPServer_WithRedisPassword(t *testing.T) { diff --git a/cmd/thv-operator/pkg/controllerutil/authserver.go b/cmd/thv-operator/pkg/controllerutil/authserver.go index 5694ef7e01..b183a9c9cc 100644 --- a/cmd/thv-operator/pkg/controllerutil/authserver.go +++ b/cmd/thv-operator/pkg/controllerutil/authserver.go @@ -8,6 +8,7 @@ import ( "crypto/sha256" "encoding/hex" "fmt" + "slices" "strings" corev1 "k8s.io/api/core/v1" @@ -22,6 +23,7 @@ import ( authrunner "github.com/stacklok/toolhive/pkg/authserver/runner" "github.com/stacklok/toolhive/pkg/authserver/server/tokenexchange" "github.com/stacklok/toolhive/pkg/authserver/storage" + "github.com/stacklok/toolhive/pkg/oauthproto" "github.com/stacklok/toolhive/pkg/runner" ) @@ -72,6 +74,15 @@ const ( // AuthServerTrustedIssuerCABundleMountPath is the base path for trusted issuer CA bundles. AuthServerTrustedIssuerCABundleMountPath = "/etc/toolhive/authserver/issuer-ca" + // AuthServerDelegateClientJWKSVolumePrefix is the prefix for delegate-client JWKS ConfigMap volume names. + AuthServerDelegateClientJWKSVolumePrefix = "authserver-delegate-jwks-" + + // AuthServerDelegateClientJWKSMountPath is the base directory for delegate-client public JWKS files. + AuthServerDelegateClientJWKSMountPath = "/etc/toolhive/authserver/delegate-jwks" + + // AuthServerDelegateClientJWKSFileName is the controlled filename for a projected delegate-client JWKS. + AuthServerDelegateClientJWKSFileName = "jwks.json" + // AuthServerConfigMapChecksumAnnotation triggers a rollout when a selected // auth server ConfigMap (today: a CA bundle) changes. The value keeps its // original "ca" name; changing it would roll every embedded-auth pod on upgrade. @@ -262,16 +273,37 @@ func buildDelegateClientRunConfigs( ) ([]authserver.DelegateClientRunConfig, error) { configs := make([]authserver.DelegateClientRunConfig, len(clients)) for i, delegateClient := range clients { - secretRef := delegateClient.ClientSecretRef - if secretRef == nil || secretRef.Name == "" || secretRef.Key == "" { - return nil, fmt.Errorf("delegateClients[%d].clientSecretRef.name and clientSecretRef.key are required", i) + runConfig := authserver.DelegateClientRunConfig{ + ClientID: delegateClient.ClientID, + TokenEndpointAuthMethod: delegateClient.TokenEndpointAuthMethod, + TokenEndpointAuthSigningAlg: delegateClient.TokenEndpointAuthSigningAlg, + GrantTypes: slices.Clone(delegateClient.GrantTypes), + RedirectURIs: slices.Clone(delegateClient.RedirectURIs), + Scopes: slices.Clone(delegateClient.Scopes), + Audiences: slices.Clone(delegateClient.Audiences), + } + if delegateClient.TokenEndpointAuthMethod == oauthproto.TokenEndpointAuthMethodPrivateKeyJWT { + ref := delegateClient.JWKSRef + if ref == nil || ref.Name == "" || ref.Key == "" { + return nil, fmt.Errorf("delegateClients[%d].jwksRef.name and jwksRef.key are required for private_key_jwt", i) + } + runConfig.JWKSFile = delegateClientJWKSFilePath(i) + } else { + secretRef := delegateClient.ClientSecretRef + if secretRef == nil || secretRef.Name == "" || secretRef.Key == "" { + return nil, fmt.Errorf("delegateClients[%d].clientSecretRef.name and clientSecretRef.key are required", i) + } + runConfig.ClientSecretEnvVar = delegateClientSecretEnvVarName(i) } - configs[i] = authserver.DelegateClientRunConfig{ - ClientID: delegateClient.ClientID, - ClientSecretEnvVar: delegateClientSecretEnvVarName(i), - Scopes: append([]string(nil), delegateClient.Scopes...), - Audiences: append([]string(nil), delegateClient.Audiences...), + if delegateClient.TokenExchange != nil { + runConfig.TokenExchange = &authserver.DelegateClientTokenExchange{ + SelfIssuedSubjectTokens: authserver.SelfIssuedSubjectTokens( + delegateClient.TokenExchange.SelfIssuedSubjectTokens), + AllowedSourceClients: slices.Clone(delegateClient.TokenExchange.AllowedSourceClients), + APIAudience: delegateClient.TokenExchange.APIAudience, + } } + configs[i] = runConfig } return configs, nil } @@ -644,10 +676,16 @@ func GenerateAuthServerVolumes( if err != nil { return nil, nil, err } + jwksVolumes, jwksMounts, err := generateDelegateClientJWKSVolumes(effectiveDelegateClients(authConfig)) + if err != nil { + return nil, nil, err + } volumes = append(volumes, upstreamVolumes...) volumes = append(volumes, trustedIssuerVolumes...) + volumes = append(volumes, jwksVolumes...) volumeMounts = append(volumeMounts, upstreamMounts...) volumeMounts = append(volumeMounts, trustedIssuerMounts...) + volumeMounts = append(volumeMounts, jwksMounts...) return volumes, volumeMounts, nil } @@ -706,6 +744,61 @@ func generateCABundleVolumes( return volumes, mounts, nil } +// effectiveDelegateClients returns the delegate clients the runtime config is +// built from: the canonical inboundGrants list when the block is present, +// otherwise the deprecated top-level list. +func effectiveDelegateClients(authConfig *mcpv1beta1.EmbeddedAuthServerConfig) []mcpv1beta1.DelegateClientConfig { + if authConfig.InboundGrants != nil && authConfig.InboundGrants.TokenExchange != nil { + return authConfig.InboundGrants.TokenExchange.DelegateClients + } + return authConfig.DelegateClients +} + +// EffectiveDelegateClients returns the delegate clients the runtime config is +// built from: the canonical inboundGrants list when the block is present, +// otherwise the deprecated top-level list. +func EffectiveDelegateClients(authConfig *mcpv1beta1.EmbeddedAuthServerConfig) []mcpv1beta1.DelegateClientConfig { + return effectiveDelegateClients(authConfig) +} + +// generateDelegateClientJWKSVolumes mounts each private_key_jwt delegate +// client's public JWKS ConfigMap key as a read-only file. The index in the +// path is the client's position in the list, matching buildDelegateClientRunConfigs. +func generateDelegateClientJWKSVolumes( + clients []mcpv1beta1.DelegateClientConfig, +) ([]corev1.Volume, []corev1.VolumeMount, error) { + var volumes []corev1.Volume + var mounts []corev1.VolumeMount + for index, delegate := range clients { + ref := delegate.JWKSRef + if ref == nil { + continue + } + if ref.Name == "" || ref.Key == "" { + return nil, nil, fmt.Errorf("delegateClients[%d].jwksRef.name and jwksRef.key are required", index) + } + volumeName := fmt.Sprintf("%s%d", AuthServerDelegateClientJWKSVolumePrefix, index) + volumes = append(volumes, corev1.Volume{ + Name: volumeName, + VolumeSource: corev1.VolumeSource{ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{Name: ref.Name}, + Items: []corev1.KeyToPath{{Key: ref.Key, Path: AuthServerDelegateClientJWKSFileName}}, + }}, + }) + mounts = append(mounts, corev1.VolumeMount{ + Name: volumeName, MountPath: delegateClientJWKSFilePath(index), + SubPath: AuthServerDelegateClientJWKSFileName, ReadOnly: true, + }) + } + return volumes, mounts, nil +} + +// delegateClientJWKSFilePath returns the JWKS path for the delegate client at +// the given index. The index must match the client's position in the list. +func delegateClientJWKSFilePath(index int) string { + return fmt.Sprintf("%s/%d/%s", AuthServerDelegateClientJWKSMountPath, index, AuthServerDelegateClientJWKSFileName) +} + // upstreamCABundleFilePath returns the path for the provider at the given index. // The index must match the provider's position in UpstreamProviders. func upstreamCABundleFilePath(index int) string { @@ -745,11 +838,7 @@ 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. - delegateClients := authConfig.DelegateClients - if authConfig.InboundGrants != nil && authConfig.InboundGrants.TokenExchange != nil { - delegateClients = authConfig.InboundGrants.TokenExchange.DelegateClients - } - for i, delegateClient := range delegateClients { + for i, delegateClient := range effectiveDelegateClients(authConfig) { if delegateClient.ClientSecretRef != nil { envVars = append(envVars, envVarFromSecretRef( delegateClientSecretEnvVarName(i), delegateClient.ClientSecretRef)) diff --git a/cmd/thv-operator/pkg/controllerutil/authserver_test.go b/cmd/thv-operator/pkg/controllerutil/authserver_test.go index fdb714e943..fbe147dbcf 100644 --- a/cmd/thv-operator/pkg/controllerutil/authserver_test.go +++ b/cmd/thv-operator/pkg/controllerutil/authserver_test.go @@ -3829,3 +3829,225 @@ func TestAuthServerConfigMapChecksumAnnotationValue(t *testing.T) { // Changing this value rolls every embedded-auth pod on operator upgrade. assert.Equal(t, "toolhive.stacklok.dev/authserver-ca-checksum", AuthServerConfigMapChecksumAnnotation) } + +func delegateJWKSTestRef(name, key string) *mcpv1beta1.ConfigMapKeyRef { + return &mcpv1beta1.ConfigMapKeyRef{Name: name, Key: key} +} + +func TestBuildAuthServerRunConfig_CanonicalDelegateClientFields(t *testing.T) { + t.Parallel() + + authConfig := &mcpv1beta1.EmbeddedAuthServerConfig{ + Issuer: "https://auth.example.com", + InboundGrants: &mcpv1beta1.InboundGrantsConfig{TokenExchange: &mcpv1beta1.TokenExchangeInboundGrantConfig{ + DelegateClients: []mcpv1beta1.DelegateClientConfig{ + { + ClientID: "broker", + TokenEndpointAuthMethod: "private_key_jwt", + JWKSRef: delegateJWKSTestRef("broker-jwks", "jwks.json"), + TokenEndpointAuthSigningAlg: "ES256", + GrantTypes: []string{ + "authorization_code", "refresh_token", "urn:ietf:params:oauth:grant-type:token-exchange", + }, + RedirectURIs: []string{"https://broker.example.com/callback"}, + Scopes: []string{"openid"}, + Audiences: []string{"https://resource.example.com"}, + TokenExchange: &mcpv1beta1.DelegateClientTokenExchangeConfig{ + SelfIssuedSubjectTokens: "restricted", + AllowedSourceClients: []string{"tui"}, APIAudience: "https://resource.example.com", + }, + }, + { + ClientID: "secret-client", + ClientSecretRef: &mcpv1beta1.SecretKeyRef{Name: "s", Key: "k"}, + Scopes: []string{"openid"}, + Audiences: []string{"https://resource.example.com"}, + TokenExchange: &mcpv1beta1.DelegateClientTokenExchangeConfig{ + SelfIssuedSubjectTokens: "restricted", AllowedSourceClients: []string{}, + }, + }, + { + ClientID: "legacy-trust", + ClientSecretRef: &mcpv1beta1.SecretKeyRef{Name: "s2", Key: "k"}, + Scopes: []string{"openid"}, + Audiences: []string{"https://resource.example.com"}, + }, + }, + }}, + } + + config, err := BuildAuthServerRunConfig( + "default", "test-server", authConfig, + []string{"https://resource.example.com"}, + []string{"openid"}, "https://resource.example.com", + ) + require.NoError(t, err) + clients := config.InboundGrants.TokenExchange.DelegateClients + require.Len(t, clients, 3) + + broker := clients[0] + assert.Equal(t, "private_key_jwt", broker.TokenEndpointAuthMethod) + assert.Equal(t, "ES256", broker.TokenEndpointAuthSigningAlg) + assert.Equal(t, "/etc/toolhive/authserver/delegate-jwks/0/jwks.json", broker.JWKSFile) + assert.Empty(t, broker.ClientSecretEnvVar) + assert.Equal(t, []string{ + "authorization_code", "refresh_token", "urn:ietf:params:oauth:grant-type:token-exchange", + }, broker.GrantTypes) + assert.Equal(t, []string{"https://broker.example.com/callback"}, broker.RedirectURIs) + require.NotNil(t, broker.TokenExchange) + assert.Equal(t, authserver.SelfIssuedSubjectTokensRestricted, broker.TokenExchange.SelfIssuedSubjectTokens) + assert.Equal(t, []string{"tui"}, broker.TokenExchange.AllowedSourceClients) + assert.Equal(t, "https://resource.example.com", broker.TokenExchange.APIAudience) + + // restricted with an empty source list maps to own tokens only, while an + // absent block keeps the legacy blanket trust. + assert.Empty(t, clients[1].JWKSFile) + assert.Equal(t, "TOOLHIVE_DELEGATE_CLIENT_SECRET_1", clients[1].ClientSecretEnvVar) + require.NotNil(t, clients[1].TokenExchange) + assert.True(t, clients[1].TokenExchange.Restricted()) + assert.Empty(t, clients[1].TokenExchange.AllowedSourceClients) + assert.Nil(t, clients[2].TokenExchange) + + // The secret env vars keep their list positions, and the JWKS client has none. + envVars := GenerateAuthServerEnvVars(authConfig) + require.Len(t, envVars, 2) + assert.Equal(t, "TOOLHIVE_DELEGATE_CLIENT_SECRET_1", envVars[0].Name) + assert.Equal(t, "TOOLHIVE_DELEGATE_CLIENT_SECRET_2", envVars[1].Name) + + // The RunConfig path must match the mount. + volumes, mounts, err := GenerateAuthServerVolumes(authConfig) + require.NoError(t, err) + require.Len(t, volumes, 1) + require.Len(t, mounts, 1) + assert.Equal(t, broker.JWKSFile, mounts[0].MountPath) +} + +func TestBuildAuthServerRunConfig_DeprecatedDelegateClientJWKS(t *testing.T) { + t.Parallel() + + authConfig := &mcpv1beta1.EmbeddedAuthServerConfig{ + Issuer: "https://auth.example.com", + DelegateClients: []mcpv1beta1.DelegateClientConfig{ + { + ClientID: "broker", + TokenEndpointAuthMethod: "private_key_jwt", + JWKSRef: delegateJWKSTestRef("broker-jwks", "jwks.json"), + Scopes: []string{"openid"}, + Audiences: []string{"https://resource.example.com"}, + TokenExchange: &mcpv1beta1.DelegateClientTokenExchangeConfig{SelfIssuedSubjectTokens: "any"}, + }, + { + ClientID: "secret-client", + ClientSecretRef: &mcpv1beta1.SecretKeyRef{Name: "s", Key: "k"}, + Scopes: []string{"openid"}, + Audiences: []string{"https://resource.example.com"}, + }, + }, + } + + config, err := BuildAuthServerRunConfig( + "default", "test-server", authConfig, + []string{"https://resource.example.com"}, + []string{"openid"}, "https://resource.example.com", + ) + require.NoError(t, err) + require.Len(t, config.DelegateClients, 2) + assert.Equal(t, "/etc/toolhive/authserver/delegate-jwks/0/jwks.json", config.DelegateClients[0].JWKSFile) + assert.Empty(t, config.DelegateClients[0].ClientSecretEnvVar) + assert.Equal(t, "TOOLHIVE_DELEGATE_CLIENT_SECRET_1", config.DelegateClients[1].ClientSecretEnvVar) + + envVars := GenerateAuthServerEnvVars(authConfig) + require.Len(t, envVars, 1) + assert.Equal(t, "TOOLHIVE_DELEGATE_CLIENT_SECRET_1", envVars[0].Name) + + volumes, mounts, err := GenerateAuthServerVolumes(authConfig) + require.NoError(t, err) + require.Len(t, volumes, 1) + require.Len(t, mounts, 1) + assert.Equal(t, config.DelegateClients[0].JWKSFile, mounts[0].MountPath) +} + +func TestBuildDelegateClientRunConfigs_PrivateKeyJWTRequiresJWKSRef(t *testing.T) { + t.Parallel() + + for name, ref := range map[string]*mcpv1beta1.ConfigMapKeyRef{ + "nil": nil, + "empty name": delegateJWKSTestRef("", "jwks.json"), + "empty key": delegateJWKSTestRef("cm", ""), + } { + t.Run(name, func(t *testing.T) { + t.Parallel() + _, err := buildDelegateClientRunConfigs([]mcpv1beta1.DelegateClientConfig{{ + ClientID: "broker", TokenEndpointAuthMethod: "private_key_jwt", JWKSRef: ref, + TokenExchange: &mcpv1beta1.DelegateClientTokenExchangeConfig{SelfIssuedSubjectTokens: "any"}, + }}) + require.ErrorContains(t, err, "delegateClients[0].jwksRef") + }) + } +} + +func TestGenerateDelegateClientJWKSVolumes(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + clients []mcpv1beta1.DelegateClientConfig + wantNames []string + wantPaths []string + wantErrMsg string + }{ + { + name: "no jwksRef means no mount", + clients: []mcpv1beta1.DelegateClientConfig{{ClientID: "a"}, {ClientID: "b"}}, + }, + { + name: "clients get distinct stable paths by list position", + clients: []mcpv1beta1.DelegateClientConfig{ + {ClientID: "a", JWKSRef: delegateJWKSTestRef("jwks-a", "a.json")}, + {ClientID: "secret"}, + {ClientID: "c", JWKSRef: delegateJWKSTestRef("jwks-c", "c.json")}, + }, + wantNames: []string{"authserver-delegate-jwks-0", "authserver-delegate-jwks-2"}, + wantPaths: []string{ + "/etc/toolhive/authserver/delegate-jwks/0/jwks.json", + "/etc/toolhive/authserver/delegate-jwks/2/jwks.json", + }, + }, + { + name: "empty configmap name is rejected", + clients: []mcpv1beta1.DelegateClientConfig{{ClientID: "a", JWKSRef: delegateJWKSTestRef("", "a.json")}}, + wantErrMsg: "delegateClients[0].jwksRef", + }, + { + name: "empty key is rejected", + clients: []mcpv1beta1.DelegateClientConfig{{ClientID: "a", JWKSRef: delegateJWKSTestRef("cm", "")}}, + wantErrMsg: "delegateClients[0].jwksRef", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + volumes, mounts, err := generateDelegateClientJWKSVolumes(tt.clients) + if tt.wantErrMsg != "" { + require.ErrorContains(t, err, tt.wantErrMsg) + return + } + require.NoError(t, err) + require.Len(t, volumes, len(tt.wantNames)) + require.Len(t, mounts, len(tt.wantNames)) + for i := range tt.wantNames { + assert.Equal(t, tt.wantNames[i], volumes[i].Name) + assert.Equal(t, tt.wantNames[i], mounts[i].Name) + assert.Equal(t, tt.wantPaths[i], mounts[i].MountPath) + assert.Equal(t, AuthServerDelegateClientJWKSFileName, mounts[i].SubPath) + assert.True(t, mounts[i].ReadOnly) + require.NotNil(t, volumes[i].ConfigMap) + } + if len(tt.wantNames) > 0 { + assert.Equal(t, "jwks-a", volumes[0].ConfigMap.Name) + assert.Equal(t, []corev1.KeyToPath{{Key: "a.json", Path: AuthServerDelegateClientJWKSFileName}}, + volumes[0].ConfigMap.Items) + } + }) + } +} diff --git a/cmd/thv-operator/pkg/vmcpconfig/converter.go b/cmd/thv-operator/pkg/vmcpconfig/converter.go index 23a76037a7..29d1dbc75f 100644 --- a/cmd/thv-operator/pkg/vmcpconfig/converter.go +++ b/cmd/thv-operator/pkg/vmcpconfig/converter.go @@ -194,7 +194,7 @@ func (c *Converter) Convert( if vmcp.Spec.AuthServerConfig != nil { rc, err := c.convertAuthServerConfig(vmcp, config) if err != nil { - if len(vmcp.Spec.AuthServerConfig.DelegateClients) > 0 { + if len(controllerutil.EffectiveDelegateClients(vmcp.Spec.AuthServerConfig)) > 0 { return nil, nil, &DelegateClientConfigValidationError{Err: err} } return nil, nil, fmt.Errorf("failed to convert auth server config: %w", err) 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 4692406951..afee83e60b 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 @@ -18,17 +18,22 @@ import ( // These tests exercise the CEL XValidation rules on EmbeddedAuthServerConfig // 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 legacy or canonical delegate clients, whatever their auth method. +// 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" + const ( + namespace = "default" + delegateKindJWT = "private_key_jwt" + delegateKindMixed = "mixed" + ) makeAuthConfig := func( name string, allowConfidential, allowPrivateKeyJWT, insecureHTTP, httpsIssuer, delegateClient, canonicalDelegateClient, loopbackHTTP, loopbackOptIn bool, + delegateKind string, ) *mcpv1beta1.MCPExternalAuthConfig { issuer := "https://auth.example.com" if insecureHTTP && !httpsIssuer { @@ -60,18 +65,33 @@ var _ = Describe("EmbeddedAuthServerConfig confidential-client-transport CEL val }, } if delegateClient { - delegate := mcpv1beta1.DelegateClientConfig{ + secretDelegate := mcpv1beta1.DelegateClientConfig{ ClientID: "delegate-client", ClientSecretRef: &mcpv1beta1.SecretKeyRef{Name: "delegate-secret", Key: "credential"}, Scopes: []string{"openid"}, Audiences: []string{"https://api.example.com"}, } + jwtDelegate := mcpv1beta1.DelegateClientConfig{ + ClientID: "jwt-delegate-client", + TokenEndpointAuthMethod: "private_key_jwt", + JWKSRef: &mcpv1beta1.ConfigMapKeyRef{Name: "jwt-delegate-jwks", Key: "jwks.json"}, + Scopes: []string{"openid"}, + Audiences: []string{"https://api.example.com"}, + TokenExchange: &mcpv1beta1.DelegateClientTokenExchangeConfig{SelfIssuedSubjectTokens: "any"}, + } + delegates := []mcpv1beta1.DelegateClientConfig{secretDelegate} + switch delegateKind { + case delegateKindJWT: + delegates = []mcpv1beta1.DelegateClientConfig{jwtDelegate} + case delegateKindMixed: + delegates = []mcpv1beta1.DelegateClientConfig{jwtDelegate, secretDelegate} + } if canonicalDelegateClient { config.Spec.EmbeddedAuthServer.InboundGrants = &mcpv1beta1.InboundGrantsConfig{ - TokenExchange: &mcpv1beta1.TokenExchangeInboundGrantConfig{DelegateClients: []mcpv1beta1.DelegateClientConfig{delegate}}, + TokenExchange: &mcpv1beta1.TokenExchangeInboundGrantConfig{DelegateClients: delegates}, } } else { - config.Spec.EmbeddedAuthServer.DelegateClients = []mcpv1beta1.DelegateClientConfig{delegate} + config.Spec.EmbeddedAuthServer.DelegateClients = delegates } } return config @@ -91,6 +111,7 @@ var _ = Describe("EmbeddedAuthServerConfig confidential-client-transport CEL val canonicalDelegateClient bool loopbackHTTP bool loopbackOptIn bool + delegateKind string shouldAdmit bool expectedMessage string } @@ -191,13 +212,40 @@ var _ = Describe("EmbeddedAuthServerConfig confidential-client-transport CEL val loopbackOptIn: true, shouldAdmit: true, }, + { + name: "private_key_jwt delegate clients with loopback HTTP issuer without opt-in", + delegateClient: true, + canonicalDelegateClient: true, + delegateKind: delegateKindJWT, + loopbackHTTP: true, + shouldAdmit: false, + expectedMessage: "confidential client registration or delegateClients with an HTTP issuer require insecureAllowConfidentialOverLoopbackHTTP", + }, + { + name: "private_key_jwt delegate clients with insecureAllowHTTP", + delegateClient: true, + canonicalDelegateClient: true, + delegateKind: delegateKindJWT, + insecureHTTP: true, + shouldAdmit: false, + expectedMessage: "insecureAllowHTTP cannot be combined with confidential client registration or delegateClients", + }, + { + name: "mixed delegate clients with loopback HTTP issuer without opt-in", + delegateClient: true, + canonicalDelegateClient: true, + delegateKind: delegateKindMixed, + loopbackHTTP: true, + shouldAdmit: false, + expectedMessage: "confidential client registration or delegateClients with an HTTP issuer require insecureAllowConfidentialOverLoopbackHTTP", + }, } 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.httpsIssuer, c.delegateClient, c.canonicalDelegateClient, c.loopbackHTTP, c.loopbackOptIn) + name, c.allowConfidential, c.allowPrivateKeyJWT, c.insecureHTTP, c.httpsIssuer, c.delegateClient, c.canonicalDelegateClient, c.loopbackHTTP, c.loopbackOptIn, c.delegateKind) err := k8sClient.Create(ctx, cfg) if c.shouldAdmit { Expect(err).NotTo(HaveOccurred(), diff --git a/cmd/thv-operator/test-integration/mcp-external-auth/delegate_client_cel_test.go b/cmd/thv-operator/test-integration/mcp-external-auth/delegate_client_cel_test.go new file mode 100644 index 0000000000..69be8feb4a --- /dev/null +++ b/cmd/thv-operator/test-integration/mcp-external-auth/delegate_client_cel_test.go @@ -0,0 +1,329 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package controllers + +import ( + "fmt" + "strings" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" +) + +var _ = Describe("MCPExternalAuthConfig delegate client CEL validation", func() { + const ( + namespace = "default" + tokenExchange = "urn:ietf:params:oauth:grant-type:token-exchange" + ) + + BeforeEach(func() { + _ = k8sClient.Create(ctx, &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: namespace}}) + }) + + secretClient := 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"}, + } + } + jwksClient := func() mcpv1beta1.DelegateClientConfig { + c := secretClient() + c.ClientSecretRef = nil + c.TokenEndpointAuthMethod = "private_key_jwt" + c.JWKSRef = &mcpv1beta1.ConfigMapKeyRef{Name: "delegate-jwks", Key: "jwks.json"} + c.TokenExchange = &mcpv1beta1.DelegateClientTokenExchangeConfig{SelfIssuedSubjectTokens: "any"} + return c + } + + cases := []struct { + name string + mutate func(*mcpv1beta1.DelegateClientConfig) + base func() mcpv1beta1.DelegateClientConfig + errMatch string + }{ + {name: "secret client without a method", base: secretClient, mutate: func(*mcpv1beta1.DelegateClientConfig) {}}, + {name: "secret client with an explicit method", base: secretClient, mutate: func(c *mcpv1beta1.DelegateClientConfig) { + c.TokenEndpointAuthMethod = "client_secret_post" + }}, + {name: "secret client without clientSecretRef", base: secretClient, errMatch: "requires a non-empty clientSecretRef", + mutate: func(c *mcpv1beta1.DelegateClientConfig) { c.ClientSecretRef = nil }}, + {name: "secret client with jwksRef", base: secretClient, errMatch: "forbids jwksRef", + mutate: func(c *mcpv1beta1.DelegateClientConfig) { c.JWKSRef = jwksClient().JWKSRef }}, + {name: "private_key_jwt client", base: jwksClient, mutate: func(*mcpv1beta1.DelegateClientConfig) {}}, + {name: "private_key_jwt without a tokenExchange block", base: jwksClient, + errMatch: "tokenExchange.selfIssuedSubjectTokens is required for private_key_jwt clients", + mutate: func(c *mcpv1beta1.DelegateClientConfig) { c.TokenExchange = nil }}, + {name: "private_key_jwt with restricted tokenExchange", base: jwksClient, + mutate: func(c *mcpv1beta1.DelegateClientConfig) { + c.TokenExchange = &mcpv1beta1.DelegateClientTokenExchangeConfig{SelfIssuedSubjectTokens: "restricted"} + }}, + {name: "jwksRef with an empty key", base: jwksClient, errMatch: "jwksRef.key", + mutate: func(c *mcpv1beta1.DelegateClientConfig) { c.JWKSRef.Key = "" }}, + {name: "jwksRef with an empty name", base: jwksClient, errMatch: "jwksRef.name", + mutate: func(c *mcpv1beta1.DelegateClientConfig) { c.JWKSRef.Name = "" }}, + {name: "apiAudience not in audiences", base: secretClient, + errMatch: "tokenExchange.apiAudience must be one of the client's audiences", + mutate: func(c *mcpv1beta1.DelegateClientConfig) { + c.TokenExchange = &mcpv1beta1.DelegateClientTokenExchangeConfig{ + SelfIssuedSubjectTokens: "restricted", APIAudience: "https://other.example.com", + } + }}, + {name: "redirect URI with a query string", base: secretClient, + mutate: func(c *mcpv1beta1.DelegateClientConfig) { + c.GrantTypes = []string{"authorization_code", tokenExchange} + c.RedirectURIs = []string{"https://app.example.com/cb?tenant=a"} + c.TokenExchange = &mcpv1beta1.DelegateClientTokenExchangeConfig{SelfIssuedSubjectTokens: "any"} + }}, + {name: "redirect URI with a fragment", base: secretClient, errMatch: "should match", + mutate: func(c *mcpv1beta1.DelegateClientConfig) { + c.GrantTypes = []string{"authorization_code", tokenExchange} + c.RedirectURIs = []string{"https://app.example.com/cb#frag"} + c.TokenExchange = &mcpv1beta1.DelegateClientTokenExchangeConfig{SelfIssuedSubjectTokens: "any"} + }}, + {name: "allowedSourceClients containing the client's own id", base: secretClient, + errMatch: "must not contain the client's own clientId", + mutate: func(c *mcpv1beta1.DelegateClientConfig) { + c.TokenExchange = &mcpv1beta1.DelegateClientTokenExchangeConfig{ + SelfIssuedSubjectTokens: "restricted", AllowedSourceClients: []string{"delegate"}, + } + }}, + {name: "allowedSourceClients containing the wildcard", base: secretClient, + errMatch: "must not contain the client's own clientId or the \"*\" wildcard", + mutate: func(c *mcpv1beta1.DelegateClientConfig) { + c.TokenExchange = &mcpv1beta1.DelegateClientTokenExchangeConfig{ + SelfIssuedSubjectTokens: "restricted", AllowedSourceClients: []string{"*"}, + } + }}, + {name: "http redirect URI", base: secretClient, errMatch: "should match", + mutate: func(c *mcpv1beta1.DelegateClientConfig) { + c.GrantTypes = []string{"authorization_code", tokenExchange} + c.RedirectURIs = []string{"http://broker.example.com/callback"} + c.TokenExchange = &mcpv1beta1.DelegateClientTokenExchangeConfig{SelfIssuedSubjectTokens: "any"} + }}, + {name: "private_key_jwt without jwksRef", base: jwksClient, errMatch: "private_key_jwt requires a non-empty jwksRef", + mutate: func(c *mcpv1beta1.DelegateClientConfig) { c.JWKSRef = nil }}, + {name: "private_key_jwt with clientSecretRef", base: jwksClient, errMatch: "forbids clientSecretRef", + mutate: func(c *mcpv1beta1.DelegateClientConfig) { c.ClientSecretRef = secretClient().ClientSecretRef }}, + {name: "grantTypes with token exchange and login", base: secretClient, mutate: func(c *mcpv1beta1.DelegateClientConfig) { + c.GrantTypes = []string{"authorization_code", "refresh_token", tokenExchange} + c.RedirectURIs = []string{"https://broker.example.com/callback"} + c.TokenExchange = &mcpv1beta1.DelegateClientTokenExchangeConfig{SelfIssuedSubjectTokens: "any"} + }}, + {name: "grantTypes without a tokenExchange block", base: secretClient, + errMatch: "tokenExchange.selfIssuedSubjectTokens is required when grantTypes is set", + mutate: func(c *mcpv1beta1.DelegateClientConfig) { c.GrantTypes = []string{tokenExchange} }}, + {name: "unsupported selfIssuedSubjectTokens", base: secretClient, errMatch: "Unsupported value", + mutate: func(c *mcpv1beta1.DelegateClientConfig) { + c.TokenExchange = &mcpv1beta1.DelegateClientTokenExchangeConfig{SelfIssuedSubjectTokens: "all"} + }}, + {name: "restricted with source clients and api audience", base: secretClient, + mutate: func(c *mcpv1beta1.DelegateClientConfig) { + c.GrantTypes = []string{tokenExchange} + c.TokenExchange = &mcpv1beta1.DelegateClientTokenExchangeConfig{ + SelfIssuedSubjectTokens: "restricted", AllowedSourceClients: []string{"tui"}, + APIAudience: "https://mcp.example.com", + } + }}, + {name: "any with source clients", base: secretClient, errMatch: "valid only when selfIssuedSubjectTokens is restricted", + mutate: func(c *mcpv1beta1.DelegateClientConfig) { + c.TokenExchange = &mcpv1beta1.DelegateClientTokenExchangeConfig{ + SelfIssuedSubjectTokens: "any", AllowedSourceClients: []string{"tui"}, + } + }}, + {name: "any with api audience", base: secretClient, errMatch: "valid only when selfIssuedSubjectTokens is restricted", + mutate: func(c *mcpv1beta1.DelegateClientConfig) { + c.TokenExchange = &mcpv1beta1.DelegateClientTokenExchangeConfig{ + SelfIssuedSubjectTokens: "any", APIAudience: "https://mcp.example.com", + } + }}, + {name: "grantTypes without token exchange", base: secretClient, errMatch: "grantTypes must include", + mutate: func(c *mcpv1beta1.DelegateClientConfig) { c.GrantTypes = []string{"refresh_token"} }}, + {name: "grantTypes outside the allowed set", base: secretClient, errMatch: "Unsupported value", + mutate: func(c *mcpv1beta1.DelegateClientConfig) { c.GrantTypes = []string{tokenExchange, "password"} }}, + {name: "authorization_code without redirectUris", base: secretClient, errMatch: "redirectUris is required", + mutate: func(c *mcpv1beta1.DelegateClientConfig) { c.GrantTypes = []string{"authorization_code", tokenExchange} }}, + {name: "private_key_jwt client with an ES256 signing alg", base: jwksClient, + mutate: func(c *mcpv1beta1.DelegateClientConfig) { c.TokenEndpointAuthSigningAlg = "ES256" }}, + {name: "signing alg on a secret client", base: secretClient, + errMatch: "tokenEndpointAuthSigningAlg is only allowed with private_key_jwt", + mutate: func(c *mcpv1beta1.DelegateClientConfig) { c.TokenEndpointAuthSigningAlg = "RS256" }}, + {name: "private_key_jwt client with an unsupported signing alg", base: jwksClient, + errMatch: `"HS256"`, + mutate: func(c *mcpv1beta1.DelegateClientConfig) { c.TokenEndpointAuthSigningAlg = "HS256" }}, + {name: "redirectUris without the authorization_code grant", base: secretClient, + errMatch: "redirectUris is only allowed with the authorization_code grant", + mutate: func(c *mcpv1beta1.DelegateClientConfig) { + c.GrantTypes = []string{tokenExchange} + c.RedirectURIs = []string{"https://broker.example.com/callback"} + c.TokenExchange = &mcpv1beta1.DelegateClientTokenExchangeConfig{SelfIssuedSubjectTokens: "any"} + }}, + {name: "refresh_token without authorization_code", base: secretClient, + errMatch: "grantTypes must include authorization_code with refresh_token", + mutate: func(c *mcpv1beta1.DelegateClientConfig) { + c.GrantTypes = []string{"refresh_token", tokenExchange} + c.TokenExchange = &mcpv1beta1.DelegateClientTokenExchangeConfig{SelfIssuedSubjectTokens: "any"} + }}, + } + + for i, test := range cases { + It(test.name, func() { + client := test.base() + test.mutate(&client) + config := &mcpv1beta1.MCPExternalAuthConfig{ + ObjectMeta: metav1.ObjectMeta{Name: fmt.Sprintf("delegate-cel-%d", i), Namespace: namespace}, + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeEmbeddedAuthServer, + EmbeddedAuthServer: &mcpv1beta1.EmbeddedAuthServerConfig{ + Issuer: "https://auth.example.com", + InboundGrants: &mcpv1beta1.InboundGrantsConfig{ + TokenExchange: &mcpv1beta1.TokenExchangeInboundGrantConfig{ + DelegateClients: []mcpv1beta1.DelegateClientConfig{client}, + }, + }, + }, + }, + } + err := k8sClient.Create(ctx, config) + if test.errMatch == "" { + 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)) + }) + } + + // The deprecated top-level list accepts only the original fields. Each row is + // valid per client, so the deprecated-list rule is the only one that fires. + // A row that sets one field also sets the fields the per-client rules require + // alongside it (grantTypes needs tokenExchange, redirectUris needs + // authorization_code, private_key_jwt needs jwksRef and tokenExchange). + deprecatedCases := []struct { + name string + base func() mcpv1beta1.DelegateClientConfig + set func(*mcpv1beta1.DelegateClientConfig) + }{ + {name: "jwksRef", base: jwksClient, set: func(*mcpv1beta1.DelegateClientConfig) {}}, + {name: "tokenEndpointAuthMethod", base: secretClient, set: func(c *mcpv1beta1.DelegateClientConfig) { + c.TokenEndpointAuthMethod = "client_secret_post" + }}, + {name: "grantTypes", base: secretClient, set: func(c *mcpv1beta1.DelegateClientConfig) { + c.GrantTypes = []string{tokenExchange} + c.TokenExchange = &mcpv1beta1.DelegateClientTokenExchangeConfig{SelfIssuedSubjectTokens: "any"} + }}, + {name: "redirectUris", base: secretClient, set: func(c *mcpv1beta1.DelegateClientConfig) { + c.GrantTypes = []string{"authorization_code", tokenExchange} + c.RedirectURIs = []string{"https://broker.example.com/callback"} + c.TokenExchange = &mcpv1beta1.DelegateClientTokenExchangeConfig{SelfIssuedSubjectTokens: "any"} + }}, + {name: "tokenExchange", base: secretClient, set: func(c *mcpv1beta1.DelegateClientConfig) { + c.TokenExchange = &mcpv1beta1.DelegateClientTokenExchangeConfig{SelfIssuedSubjectTokens: "any"} + }}, + {name: "tokenEndpointAuthSigningAlg", base: jwksClient, set: func(c *mcpv1beta1.DelegateClientConfig) { + c.TokenEndpointAuthSigningAlg = "ES256" + }}, + } + + for _, test := range deprecatedCases { + It("rejects "+test.name+" on the deprecated delegateClients list", func() { + client := test.base() + test.set(&client) + config := &mcpv1beta1.MCPExternalAuthConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "delegate-cel-deprecated-" + strings.ToLower(test.name), Namespace: namespace, + }, + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeEmbeddedAuthServer, + EmbeddedAuthServer: &mcpv1beta1.EmbeddedAuthServerConfig{ + Issuer: "https://auth.example.com", + DelegateClients: []mcpv1beta1.DelegateClientConfig{client}, + }, + }, + } + err := k8sClient.Create(ctx, config) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("use inboundGrants.tokenExchange.delegateClients")) + }) + } + + It("accepts the original fields on the deprecated delegateClients list", func() { + config := &mcpv1beta1.MCPExternalAuthConfig{ + ObjectMeta: metav1.ObjectMeta{Name: "delegate-cel-deprecated-ok", Namespace: namespace}, + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeEmbeddedAuthServer, + EmbeddedAuthServer: &mcpv1beta1.EmbeddedAuthServerConfig{ + Issuer: "https://auth.example.com", + DelegateClients: []mcpv1beta1.DelegateClientConfig{secretClient()}, + }, + }, + } + Expect(k8sClient.Create(ctx, config)).To(Succeed()) + DeferCleanup(func() { Expect(k8sClient.Delete(ctx, config)).To(Succeed()) }) + }) + + // These cases need fields the typed client cannot send: an empty object, or + // an explicit empty list that omitempty would drop. + rawCases := []struct { + name string + tokenExchange map[string]interface{} + errMatch string + }{ + {name: "empty tokenExchange block", tokenExchange: map[string]interface{}{}, + errMatch: "selfIssuedSubjectTokens: Required value"}, + {name: "restricted with an explicit empty source list", tokenExchange: map[string]interface{}{ + "selfIssuedSubjectTokens": "restricted", "allowedSourceClients": []interface{}{}, + }}, + {name: "restricted with an explicit empty api audience", tokenExchange: map[string]interface{}{ + "selfIssuedSubjectTokens": "restricted", "apiAudience": "", + }}, + {name: "any with an explicit empty source list and api audience", tokenExchange: map[string]interface{}{ + "selfIssuedSubjectTokens": "any", "allowedSourceClients": []interface{}{}, "apiAudience": "", + }}, + } + + for i, test := range rawCases { + It(test.name, func() { + config := &unstructured.Unstructured{Object: map[string]interface{}{ + "apiVersion": "toolhive.stacklok.dev/v1beta1", + "kind": "MCPExternalAuthConfig", + "metadata": map[string]interface{}{ + "name": fmt.Sprintf("delegate-cel-raw-%d", i), "namespace": namespace, + }, + "spec": map[string]interface{}{ + "type": "embeddedAuthServer", + "embeddedAuthServer": map[string]interface{}{ + "issuer": "https://auth.example.com", + "inboundGrants": map[string]interface{}{ + "tokenExchange": map[string]interface{}{ + "delegateClients": []interface{}{map[string]interface{}{ + "clientId": "delegate", + "clientSecretRef": map[string]interface{}{"name": "delegate-secret", "key": "secret"}, + "scopes": []interface{}{"openid"}, + "audiences": []interface{}{"https://mcp.example.com"}, + "grantTypes": []interface{}{tokenExchange}, + "tokenExchange": test.tokenExchange, + }}, + }, + }, + }, + }, + }} + err := k8sClient.Create(ctx, config) + if test.errMatch == "" { + 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/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpexternalauthconfigs.yaml b/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpexternalauthconfigs.yaml index c9e703a36d..c822e1d8fd 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 @@ -307,9 +307,12 @@ spec: description: |- 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. + plaintext secret is accepted here. The supported path is + inboundGrants.tokenExchange.delegateClients. This field shares its type + with the canonical list, but accepts only clientId, clientSecretRef, + scopes and audiences. The newer fields (tokenEndpointAuthMethod, + tokenEndpointAuthSigningAlg, jwksRef, grantTypes, redirectUris, + tokenExchange) are rejected here; set them on the canonical list. This is independent of allowConfidentialClientRegistration: it neither enables nor requires unauthenticated confidential dynamic client @@ -317,9 +320,10 @@ spec: This legacy field is deprecated; use inboundGrants.tokenExchange.delegateClients. 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. + DelegateClientConfig configures a pre-provisioned confidential OAuth client. + By default it is a token-exchange client (RFC 8693). Its credential is either + a client secret, referenced from a Kubernetes Secret and never represented + inline, or a public JWKS for private_key_jwt, referenced from a ConfigMap. properties: audiences: description: Audiences is the narrowed set of RFC 8707 resources @@ -339,8 +343,9 @@ spec: minLength: 1 type: string clientSecretRef: - description: ClientSecretRef references the Kubernetes Secret - key containing the client secret. + description: |- + ClientSecretRef references the Kubernetes Secret key containing the client secret. + Required unless tokenEndpointAuthMethod is private_key_jwt, where it is forbidden. properties: key: description: Key is the key within the secret @@ -352,6 +357,62 @@ spec: - key - name type: object + grantTypes: + description: |- + GrantTypes are the grants this client may use. If omitted or empty, the + client is token-exchange only. When set, the list must include + urn:ietf:params:oauth:grant-type:token-exchange: a delegate client is a + token-exchange client that may also log in or redeem other grants. When set, + the list is complete: include urn:ietf:params:oauth:grant-type:jwt-bearer to + keep ID-JAG redemption. + items: + enum: + - authorization_code + - refresh_token + - urn:ietf:params:oauth:grant-type:token-exchange + - urn:ietf:params:oauth:grant-type:jwt-bearer + type: string + maxItems: 4 + type: array + x-kubernetes-list-type: set + jwksRef: + description: |- + JWKSRef references the ConfigMap key holding the client's public JWKS + document. Required with private_key_jwt and forbidden otherwise. The + document must contain public keys only. + properties: + key: + description: Key is the key within the ConfigMap + minLength: 1 + type: string + name: + description: Name is the name of the ConfigMap + minLength: 1 + type: string + required: + - key + - name + type: object + redirectUris: + description: |- + RedirectURIs are the registered redirect URIs. They are required with the + authorization_code grant and forbidden without it. They sit here, under + tokenExchange.delegateClients, because a delegate client is a + token-exchange client that may also log in (authorization_code). + + The pattern is stricter than the runtime for uppercase "HTTPS://": the + runtime accepts it, the pattern does not. The scheme check is + case-sensitive, as in forceConfidentialRedirectUris. Query strings and + fragments behave the same as the runtime: a query string is allowed, and + a fragment is rejected. + items: + maxLength: 2048 + minLength: 1 + pattern: ^https://[^\s#]+$ + type: string + maxItems: 10 + type: array + x-kubernetes-list-type: set scopes: description: Scopes is the narrowed set of OAuth scopes this client may request. @@ -363,17 +424,141 @@ spec: minItems: 1 type: array x-kubernetes-list-type: atomic + tokenEndpointAuthMethod: + description: |- + TokenEndpointAuthMethod pins how the client authenticates at the token + endpoint. If omitted, the client authenticates with its secret by either + HTTP Basic or form body. + enum: + - client_secret_basic + - client_secret_post + - private_key_jwt + type: string + tokenEndpointAuthSigningAlg: + description: |- + TokenEndpointAuthSigningAlg is the private_key_jwt signing algorithm, + for example RS256 or ES256. If omitted, it is derived from the keys: + each key's alg, or ES256, ES384 or ES512 from an EC key's curve. RSA + keys without alg use RS256. Keys that disagree must set it. + enum: + - RS256 + - RS384 + - RS512 + - PS256 + - PS384 + - PS512 + - ES256 + - ES384 + - ES512 + type: string + tokenExchange: + description: |- + TokenExchange is the client's token-exchange policy. Its + selfIssuedSubjectTokens field is required whenever the block is present. + The block may be omitted only by a secret-authenticated client without + grantTypes; such a client keeps the legacy rule (any) and may exchange any + self-issued token. A client that sets grantTypes or uses private_key_jwt + must declare the block. + properties: + allowedSourceClients: + description: |- + AllowedSourceClients are client_id values, as they appear in the subject + token's client_id claim, whose self-issued tokens this client may + exchange in addition to its own. They must be user-facing clients that + register at runtime (DCR or CIMD). A configured delegate client cannot be + listed here. Valid only with restricted; empty means the client's own + tokens only. + items: + maxLength: 256 + minLength: 1 + type: string + maxItems: 20 + type: array + x-kubernetes-list-type: set + apiAudience: + description: |- + APIAudience is the client's own API audience. The client may request it + even when the subject token does not cover it. It must be one of the + client's audiences. Valid only with restricted. + maxLength: 2048 + type: string + selfIssuedSubjectTokens: + description: |- + SelfIssuedSubjectTokens selects which self-issued subject tokens the + client may exchange. With restricted, a self-issued subject token must + carry the client's own client_id or one of allowedSourceClients, and a + may_act claim naming this client does not lift that restriction. With + any, the client may exchange any self-issued token. It has no default + and must be set explicitly. + enum: + - any + - restricted + type: string + required: + - selfIssuedSubjectTokens + type: object + x-kubernetes-validations: + - message: tokenExchange.allowedSourceClients and tokenExchange.apiAudience + are valid only when selfIssuedSubjectTokens is restricted + rule: self.selfIssuedSubjectTokens == 'restricted' || + ((!has(self.allowedSourceClients) || size(self.allowedSourceClients) + == 0) && (!has(self.apiAudience) || size(self.apiAudience) + == 0)) 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 + - message: private_key_jwt requires a non-empty jwksRef and + forbids clientSecretRef; any other tokenEndpointAuthMethod + (or none) requires a non-empty clientSecretRef and forbids + jwksRef + rule: 'has(self.tokenEndpointAuthMethod) && self.tokenEndpointAuthMethod + == ''private_key_jwt'' ? (has(self.jwksRef) && !has(self.clientSecretRef)) + : (has(self.clientSecretRef) && size(self.clientSecretRef.name) + > 0 && size(self.clientSecretRef.key) > 0 && !has(self.jwksRef))' + - message: grantTypes must include urn:ietf:params:oauth:grant-type:token-exchange + when set + rule: '!has(self.grantTypes) || size(self.grantTypes) == 0 + || ''urn:ietf:params:oauth:grant-type:token-exchange'' in + self.grantTypes' + - message: redirectUris is required when grantTypes includes + authorization_code + rule: '!has(self.grantTypes) || !(''authorization_code'' in + self.grantTypes) || (has(self.redirectUris) && size(self.redirectUris) + > 0)' + - message: tokenExchange.selfIssuedSubjectTokens is required + when grantTypes is set + rule: '!has(self.grantTypes) || size(self.grantTypes) == 0 + || has(self.tokenExchange)' + - message: tokenExchange.selfIssuedSubjectTokens is required + for private_key_jwt clients + rule: '!(has(self.tokenEndpointAuthMethod) && self.tokenEndpointAuthMethod + == ''private_key_jwt'') || has(self.tokenExchange)' + - message: tokenExchange.apiAudience must be one of the client's + audiences + rule: '!has(self.tokenExchange) || !has(self.tokenExchange.apiAudience) + || size(self.tokenExchange.apiAudience) == 0 || self.tokenExchange.apiAudience + in self.audiences' + - message: tokenExchange.allowedSourceClients must not contain + the client's own clientId or the "*" wildcard + rule: '!has(self.tokenExchange) || !has(self.tokenExchange.allowedSourceClients) + || (!(self.clientId in self.tokenExchange.allowedSourceClients) + && !(''*'' in self.tokenExchange.allowedSourceClients))' + - message: redirectUris is only allowed with the authorization_code + grant + rule: '!has(self.redirectUris) || size(self.redirectUris) + == 0 || (has(self.grantTypes) && ''authorization_code'' + in self.grantTypes)' + - message: grantTypes must include authorization_code with refresh_token + rule: '!has(self.grantTypes) || !(''refresh_token'' in self.grantTypes) + || ''authorization_code'' in self.grantTypes' + - message: tokenEndpointAuthSigningAlg is only allowed with + private_key_jwt + rule: '!has(self.tokenEndpointAuthSigningAlg) || size(self.tokenEndpointAuthSigningAlg) + == 0 || (has(self.tokenEndpointAuthMethod) && self.tokenEndpointAuthMethod + == ''private_key_jwt'')' maxItems: 10 type: array x-kubernetes-list-type: atomic @@ -662,9 +847,10 @@ spec: 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. + DelegateClientConfig configures a pre-provisioned confidential OAuth client. + By default it is a token-exchange client (RFC 8693). Its credential is either + a client secret, referenced from a Kubernetes Secret and never represented + inline, or a public JWKS for private_key_jwt, referenced from a ConfigMap. properties: audiences: description: Audiences is the narrowed set of RFC @@ -684,8 +870,9 @@ spec: minLength: 1 type: string clientSecretRef: - description: ClientSecretRef references the Kubernetes - Secret key containing the client secret. + description: |- + ClientSecretRef references the Kubernetes Secret key containing the client secret. + Required unless tokenEndpointAuthMethod is private_key_jwt, where it is forbidden. properties: key: description: Key is the key within the secret @@ -697,6 +884,62 @@ spec: - key - name type: object + grantTypes: + description: |- + GrantTypes are the grants this client may use. If omitted or empty, the + client is token-exchange only. When set, the list must include + urn:ietf:params:oauth:grant-type:token-exchange: a delegate client is a + token-exchange client that may also log in or redeem other grants. When set, + the list is complete: include urn:ietf:params:oauth:grant-type:jwt-bearer to + keep ID-JAG redemption. + items: + enum: + - authorization_code + - refresh_token + - urn:ietf:params:oauth:grant-type:token-exchange + - urn:ietf:params:oauth:grant-type:jwt-bearer + type: string + maxItems: 4 + type: array + x-kubernetes-list-type: set + jwksRef: + description: |- + JWKSRef references the ConfigMap key holding the client's public JWKS + document. Required with private_key_jwt and forbidden otherwise. The + document must contain public keys only. + properties: + key: + description: Key is the key within the ConfigMap + minLength: 1 + type: string + name: + description: Name is the name of the ConfigMap + minLength: 1 + type: string + required: + - key + - name + type: object + redirectUris: + description: |- + RedirectURIs are the registered redirect URIs. They are required with the + authorization_code grant and forbidden without it. They sit here, under + tokenExchange.delegateClients, because a delegate client is a + token-exchange client that may also log in (authorization_code). + + The pattern is stricter than the runtime for uppercase "HTTPS://": the + runtime accepts it, the pattern does not. The scheme check is + case-sensitive, as in forceConfidentialRedirectUris. Query strings and + fragments behave the same as the runtime: a query string is allowed, and + a fragment is rejected. + items: + maxLength: 2048 + minLength: 1 + pattern: ^https://[^\s#]+$ + type: string + maxItems: 10 + type: array + x-kubernetes-list-type: set scopes: description: Scopes is the narrowed set of OAuth scopes this client may request. @@ -708,17 +951,144 @@ spec: minItems: 1 type: array x-kubernetes-list-type: atomic + tokenEndpointAuthMethod: + description: |- + TokenEndpointAuthMethod pins how the client authenticates at the token + endpoint. If omitted, the client authenticates with its secret by either + HTTP Basic or form body. + enum: + - client_secret_basic + - client_secret_post + - private_key_jwt + type: string + tokenEndpointAuthSigningAlg: + description: |- + TokenEndpointAuthSigningAlg is the private_key_jwt signing algorithm, + for example RS256 or ES256. If omitted, it is derived from the keys: + each key's alg, or ES256, ES384 or ES512 from an EC key's curve. RSA + keys without alg use RS256. Keys that disagree must set it. + enum: + - RS256 + - RS384 + - RS512 + - PS256 + - PS384 + - PS512 + - ES256 + - ES384 + - ES512 + type: string + tokenExchange: + description: |- + TokenExchange is the client's token-exchange policy. Its + selfIssuedSubjectTokens field is required whenever the block is present. + The block may be omitted only by a secret-authenticated client without + grantTypes; such a client keeps the legacy rule (any) and may exchange any + self-issued token. A client that sets grantTypes or uses private_key_jwt + must declare the block. + properties: + allowedSourceClients: + description: |- + AllowedSourceClients are client_id values, as they appear in the subject + token's client_id claim, whose self-issued tokens this client may + exchange in addition to its own. They must be user-facing clients that + register at runtime (DCR or CIMD). A configured delegate client cannot be + listed here. Valid only with restricted; empty means the client's own + tokens only. + items: + maxLength: 256 + minLength: 1 + type: string + maxItems: 20 + type: array + x-kubernetes-list-type: set + apiAudience: + description: |- + APIAudience is the client's own API audience. The client may request it + even when the subject token does not cover it. It must be one of the + client's audiences. Valid only with restricted. + maxLength: 2048 + type: string + selfIssuedSubjectTokens: + description: |- + SelfIssuedSubjectTokens selects which self-issued subject tokens the + client may exchange. With restricted, a self-issued subject token must + carry the client's own client_id or one of allowedSourceClients, and a + may_act claim naming this client does not lift that restriction. With + any, the client may exchange any self-issued token. It has no default + and must be set explicitly. + enum: + - any + - restricted + type: string + required: + - selfIssuedSubjectTokens + type: object + x-kubernetes-validations: + - message: tokenExchange.allowedSourceClients and + tokenExchange.apiAudience are valid only when + selfIssuedSubjectTokens is restricted + rule: self.selfIssuedSubjectTokens == 'restricted' + || ((!has(self.allowedSourceClients) || size(self.allowedSourceClients) + == 0) && (!has(self.apiAudience) || size(self.apiAudience) + == 0)) 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 + - message: private_key_jwt requires a non-empty jwksRef + and forbids clientSecretRef; any other tokenEndpointAuthMethod + (or none) requires a non-empty clientSecretRef and + forbids jwksRef + rule: 'has(self.tokenEndpointAuthMethod) && self.tokenEndpointAuthMethod + == ''private_key_jwt'' ? (has(self.jwksRef) && !has(self.clientSecretRef)) + : (has(self.clientSecretRef) && size(self.clientSecretRef.name) + > 0 && size(self.clientSecretRef.key) > 0 && !has(self.jwksRef))' + - message: grantTypes must include urn:ietf:params:oauth:grant-type:token-exchange + when set + rule: '!has(self.grantTypes) || size(self.grantTypes) + == 0 || ''urn:ietf:params:oauth:grant-type:token-exchange'' + in self.grantTypes' + - message: redirectUris is required when grantTypes + includes authorization_code + rule: '!has(self.grantTypes) || !(''authorization_code'' + in self.grantTypes) || (has(self.redirectUris) && + size(self.redirectUris) > 0)' + - message: tokenExchange.selfIssuedSubjectTokens is + required when grantTypes is set + rule: '!has(self.grantTypes) || size(self.grantTypes) + == 0 || has(self.tokenExchange)' + - message: tokenExchange.selfIssuedSubjectTokens is + required for private_key_jwt clients + rule: '!(has(self.tokenEndpointAuthMethod) && self.tokenEndpointAuthMethod + == ''private_key_jwt'') || has(self.tokenExchange)' + - message: tokenExchange.apiAudience must be one of + the client's audiences + rule: '!has(self.tokenExchange) || !has(self.tokenExchange.apiAudience) + || size(self.tokenExchange.apiAudience) == 0 || + self.tokenExchange.apiAudience in self.audiences' + - message: tokenExchange.allowedSourceClients must not + contain the client's own clientId or the "*" wildcard + rule: '!has(self.tokenExchange) || !has(self.tokenExchange.allowedSourceClients) + || (!(self.clientId in self.tokenExchange.allowedSourceClients) + && !(''*'' in self.tokenExchange.allowedSourceClients))' + - message: redirectUris is only allowed with the authorization_code + grant + rule: '!has(self.redirectUris) || size(self.redirectUris) + == 0 || (has(self.grantTypes) && ''authorization_code'' + in self.grantTypes)' + - message: grantTypes must include authorization_code + with refresh_token + rule: '!has(self.grantTypes) || !(''refresh_token'' + in self.grantTypes) || ''authorization_code'' in + self.grantTypes' + - message: tokenEndpointAuthSigningAlg is only allowed + with private_key_jwt + rule: '!has(self.tokenEndpointAuthSigningAlg) || size(self.tokenEndpointAuthSigningAlg) + == 0 || (has(self.tokenEndpointAuthMethod) && self.tokenEndpointAuthMethod + == ''private_key_jwt'')' maxItems: 10 type: array x-kubernetes-list-type: atomic @@ -807,10 +1177,12 @@ spec: 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 + private_key_jwt registration (DCR) 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. + nothing here for cleartext HTTP to expose. Delegate clients count as + confidential whatever their tokenEndpointAuthMethod, private_key_jwt + included, because they can hold authorization_code and refresh_token grants. type: boolean insecureAllowHTTP: default: false @@ -2581,6 +2953,13 @@ spec: - message: spiffeClientAuth clientId must not be an absolute URL rule: '!has(self.inboundGrants) || !has(self.inboundGrants.spiffeClientAuth) || self.inboundGrants.spiffeClientAuth.all(client, !client.clientId.matches(''^[A-Za-z][A-Za-z0-9+.-]*://.+''))' + - message: delegateClients is deprecated and accepts only clientId, + clientSecretRef, scopes and audiences; use inboundGrants.tokenExchange.delegateClients + for jwksRef, tokenEndpointAuthMethod, grantTypes, redirectUris, + tokenExchange and tokenEndpointAuthSigningAlg + rule: '!has(self.delegateClients) || self.delegateClients.all(c, + !has(c.jwksRef) && !has(c.tokenEndpointAuthMethod) && !has(c.grantTypes) + && !has(c.redirectUris) && !has(c.tokenExchange) && !has(c.tokenEndpointAuthSigningAlg))' - message: insecureAllowHTTP cannot be combined with confidential client registration or delegateClients; client secrets would be issued or used in cleartext over an unauthenticated endpoint @@ -3400,9 +3779,12 @@ spec: description: |- 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. + plaintext secret is accepted here. The supported path is + inboundGrants.tokenExchange.delegateClients. This field shares its type + with the canonical list, but accepts only clientId, clientSecretRef, + scopes and audiences. The newer fields (tokenEndpointAuthMethod, + tokenEndpointAuthSigningAlg, jwksRef, grantTypes, redirectUris, + tokenExchange) are rejected here; set them on the canonical list. This is independent of allowConfidentialClientRegistration: it neither enables nor requires unauthenticated confidential dynamic client @@ -3410,9 +3792,10 @@ spec: This legacy field is deprecated; use inboundGrants.tokenExchange.delegateClients. 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. + DelegateClientConfig configures a pre-provisioned confidential OAuth client. + By default it is a token-exchange client (RFC 8693). Its credential is either + a client secret, referenced from a Kubernetes Secret and never represented + inline, or a public JWKS for private_key_jwt, referenced from a ConfigMap. properties: audiences: description: Audiences is the narrowed set of RFC 8707 resources @@ -3432,8 +3815,9 @@ spec: minLength: 1 type: string clientSecretRef: - description: ClientSecretRef references the Kubernetes Secret - key containing the client secret. + description: |- + ClientSecretRef references the Kubernetes Secret key containing the client secret. + Required unless tokenEndpointAuthMethod is private_key_jwt, where it is forbidden. properties: key: description: Key is the key within the secret @@ -3445,6 +3829,62 @@ spec: - key - name type: object + grantTypes: + description: |- + GrantTypes are the grants this client may use. If omitted or empty, the + client is token-exchange only. When set, the list must include + urn:ietf:params:oauth:grant-type:token-exchange: a delegate client is a + token-exchange client that may also log in or redeem other grants. When set, + the list is complete: include urn:ietf:params:oauth:grant-type:jwt-bearer to + keep ID-JAG redemption. + items: + enum: + - authorization_code + - refresh_token + - urn:ietf:params:oauth:grant-type:token-exchange + - urn:ietf:params:oauth:grant-type:jwt-bearer + type: string + maxItems: 4 + type: array + x-kubernetes-list-type: set + jwksRef: + description: |- + JWKSRef references the ConfigMap key holding the client's public JWKS + document. Required with private_key_jwt and forbidden otherwise. The + document must contain public keys only. + properties: + key: + description: Key is the key within the ConfigMap + minLength: 1 + type: string + name: + description: Name is the name of the ConfigMap + minLength: 1 + type: string + required: + - key + - name + type: object + redirectUris: + description: |- + RedirectURIs are the registered redirect URIs. They are required with the + authorization_code grant and forbidden without it. They sit here, under + tokenExchange.delegateClients, because a delegate client is a + token-exchange client that may also log in (authorization_code). + + The pattern is stricter than the runtime for uppercase "HTTPS://": the + runtime accepts it, the pattern does not. The scheme check is + case-sensitive, as in forceConfidentialRedirectUris. Query strings and + fragments behave the same as the runtime: a query string is allowed, and + a fragment is rejected. + items: + maxLength: 2048 + minLength: 1 + pattern: ^https://[^\s#]+$ + type: string + maxItems: 10 + type: array + x-kubernetes-list-type: set scopes: description: Scopes is the narrowed set of OAuth scopes this client may request. @@ -3456,17 +3896,141 @@ spec: minItems: 1 type: array x-kubernetes-list-type: atomic + tokenEndpointAuthMethod: + description: |- + TokenEndpointAuthMethod pins how the client authenticates at the token + endpoint. If omitted, the client authenticates with its secret by either + HTTP Basic or form body. + enum: + - client_secret_basic + - client_secret_post + - private_key_jwt + type: string + tokenEndpointAuthSigningAlg: + description: |- + TokenEndpointAuthSigningAlg is the private_key_jwt signing algorithm, + for example RS256 or ES256. If omitted, it is derived from the keys: + each key's alg, or ES256, ES384 or ES512 from an EC key's curve. RSA + keys without alg use RS256. Keys that disagree must set it. + enum: + - RS256 + - RS384 + - RS512 + - PS256 + - PS384 + - PS512 + - ES256 + - ES384 + - ES512 + type: string + tokenExchange: + description: |- + TokenExchange is the client's token-exchange policy. Its + selfIssuedSubjectTokens field is required whenever the block is present. + The block may be omitted only by a secret-authenticated client without + grantTypes; such a client keeps the legacy rule (any) and may exchange any + self-issued token. A client that sets grantTypes or uses private_key_jwt + must declare the block. + properties: + allowedSourceClients: + description: |- + AllowedSourceClients are client_id values, as they appear in the subject + token's client_id claim, whose self-issued tokens this client may + exchange in addition to its own. They must be user-facing clients that + register at runtime (DCR or CIMD). A configured delegate client cannot be + listed here. Valid only with restricted; empty means the client's own + tokens only. + items: + maxLength: 256 + minLength: 1 + type: string + maxItems: 20 + type: array + x-kubernetes-list-type: set + apiAudience: + description: |- + APIAudience is the client's own API audience. The client may request it + even when the subject token does not cover it. It must be one of the + client's audiences. Valid only with restricted. + maxLength: 2048 + type: string + selfIssuedSubjectTokens: + description: |- + SelfIssuedSubjectTokens selects which self-issued subject tokens the + client may exchange. With restricted, a self-issued subject token must + carry the client's own client_id or one of allowedSourceClients, and a + may_act claim naming this client does not lift that restriction. With + any, the client may exchange any self-issued token. It has no default + and must be set explicitly. + enum: + - any + - restricted + type: string + required: + - selfIssuedSubjectTokens + type: object + x-kubernetes-validations: + - message: tokenExchange.allowedSourceClients and tokenExchange.apiAudience + are valid only when selfIssuedSubjectTokens is restricted + rule: self.selfIssuedSubjectTokens == 'restricted' || + ((!has(self.allowedSourceClients) || size(self.allowedSourceClients) + == 0) && (!has(self.apiAudience) || size(self.apiAudience) + == 0)) 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 + - message: private_key_jwt requires a non-empty jwksRef and + forbids clientSecretRef; any other tokenEndpointAuthMethod + (or none) requires a non-empty clientSecretRef and forbids + jwksRef + rule: 'has(self.tokenEndpointAuthMethod) && self.tokenEndpointAuthMethod + == ''private_key_jwt'' ? (has(self.jwksRef) && !has(self.clientSecretRef)) + : (has(self.clientSecretRef) && size(self.clientSecretRef.name) + > 0 && size(self.clientSecretRef.key) > 0 && !has(self.jwksRef))' + - message: grantTypes must include urn:ietf:params:oauth:grant-type:token-exchange + when set + rule: '!has(self.grantTypes) || size(self.grantTypes) == 0 + || ''urn:ietf:params:oauth:grant-type:token-exchange'' in + self.grantTypes' + - message: redirectUris is required when grantTypes includes + authorization_code + rule: '!has(self.grantTypes) || !(''authorization_code'' in + self.grantTypes) || (has(self.redirectUris) && size(self.redirectUris) + > 0)' + - message: tokenExchange.selfIssuedSubjectTokens is required + when grantTypes is set + rule: '!has(self.grantTypes) || size(self.grantTypes) == 0 + || has(self.tokenExchange)' + - message: tokenExchange.selfIssuedSubjectTokens is required + for private_key_jwt clients + rule: '!(has(self.tokenEndpointAuthMethod) && self.tokenEndpointAuthMethod + == ''private_key_jwt'') || has(self.tokenExchange)' + - message: tokenExchange.apiAudience must be one of the client's + audiences + rule: '!has(self.tokenExchange) || !has(self.tokenExchange.apiAudience) + || size(self.tokenExchange.apiAudience) == 0 || self.tokenExchange.apiAudience + in self.audiences' + - message: tokenExchange.allowedSourceClients must not contain + the client's own clientId or the "*" wildcard + rule: '!has(self.tokenExchange) || !has(self.tokenExchange.allowedSourceClients) + || (!(self.clientId in self.tokenExchange.allowedSourceClients) + && !(''*'' in self.tokenExchange.allowedSourceClients))' + - message: redirectUris is only allowed with the authorization_code + grant + rule: '!has(self.redirectUris) || size(self.redirectUris) + == 0 || (has(self.grantTypes) && ''authorization_code'' + in self.grantTypes)' + - message: grantTypes must include authorization_code with refresh_token + rule: '!has(self.grantTypes) || !(''refresh_token'' in self.grantTypes) + || ''authorization_code'' in self.grantTypes' + - message: tokenEndpointAuthSigningAlg is only allowed with + private_key_jwt + rule: '!has(self.tokenEndpointAuthSigningAlg) || size(self.tokenEndpointAuthSigningAlg) + == 0 || (has(self.tokenEndpointAuthMethod) && self.tokenEndpointAuthMethod + == ''private_key_jwt'')' maxItems: 10 type: array x-kubernetes-list-type: atomic @@ -3755,9 +4319,10 @@ spec: 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. + DelegateClientConfig configures a pre-provisioned confidential OAuth client. + By default it is a token-exchange client (RFC 8693). Its credential is either + a client secret, referenced from a Kubernetes Secret and never represented + inline, or a public JWKS for private_key_jwt, referenced from a ConfigMap. properties: audiences: description: Audiences is the narrowed set of RFC @@ -3777,8 +4342,9 @@ spec: minLength: 1 type: string clientSecretRef: - description: ClientSecretRef references the Kubernetes - Secret key containing the client secret. + description: |- + ClientSecretRef references the Kubernetes Secret key containing the client secret. + Required unless tokenEndpointAuthMethod is private_key_jwt, where it is forbidden. properties: key: description: Key is the key within the secret @@ -3790,6 +4356,62 @@ spec: - key - name type: object + grantTypes: + description: |- + GrantTypes are the grants this client may use. If omitted or empty, the + client is token-exchange only. When set, the list must include + urn:ietf:params:oauth:grant-type:token-exchange: a delegate client is a + token-exchange client that may also log in or redeem other grants. When set, + the list is complete: include urn:ietf:params:oauth:grant-type:jwt-bearer to + keep ID-JAG redemption. + items: + enum: + - authorization_code + - refresh_token + - urn:ietf:params:oauth:grant-type:token-exchange + - urn:ietf:params:oauth:grant-type:jwt-bearer + type: string + maxItems: 4 + type: array + x-kubernetes-list-type: set + jwksRef: + description: |- + JWKSRef references the ConfigMap key holding the client's public JWKS + document. Required with private_key_jwt and forbidden otherwise. The + document must contain public keys only. + properties: + key: + description: Key is the key within the ConfigMap + minLength: 1 + type: string + name: + description: Name is the name of the ConfigMap + minLength: 1 + type: string + required: + - key + - name + type: object + redirectUris: + description: |- + RedirectURIs are the registered redirect URIs. They are required with the + authorization_code grant and forbidden without it. They sit here, under + tokenExchange.delegateClients, because a delegate client is a + token-exchange client that may also log in (authorization_code). + + The pattern is stricter than the runtime for uppercase "HTTPS://": the + runtime accepts it, the pattern does not. The scheme check is + case-sensitive, as in forceConfidentialRedirectUris. Query strings and + fragments behave the same as the runtime: a query string is allowed, and + a fragment is rejected. + items: + maxLength: 2048 + minLength: 1 + pattern: ^https://[^\s#]+$ + type: string + maxItems: 10 + type: array + x-kubernetes-list-type: set scopes: description: Scopes is the narrowed set of OAuth scopes this client may request. @@ -3801,17 +4423,144 @@ spec: minItems: 1 type: array x-kubernetes-list-type: atomic + tokenEndpointAuthMethod: + description: |- + TokenEndpointAuthMethod pins how the client authenticates at the token + endpoint. If omitted, the client authenticates with its secret by either + HTTP Basic or form body. + enum: + - client_secret_basic + - client_secret_post + - private_key_jwt + type: string + tokenEndpointAuthSigningAlg: + description: |- + TokenEndpointAuthSigningAlg is the private_key_jwt signing algorithm, + for example RS256 or ES256. If omitted, it is derived from the keys: + each key's alg, or ES256, ES384 or ES512 from an EC key's curve. RSA + keys without alg use RS256. Keys that disagree must set it. + enum: + - RS256 + - RS384 + - RS512 + - PS256 + - PS384 + - PS512 + - ES256 + - ES384 + - ES512 + type: string + tokenExchange: + description: |- + TokenExchange is the client's token-exchange policy. Its + selfIssuedSubjectTokens field is required whenever the block is present. + The block may be omitted only by a secret-authenticated client without + grantTypes; such a client keeps the legacy rule (any) and may exchange any + self-issued token. A client that sets grantTypes or uses private_key_jwt + must declare the block. + properties: + allowedSourceClients: + description: |- + AllowedSourceClients are client_id values, as they appear in the subject + token's client_id claim, whose self-issued tokens this client may + exchange in addition to its own. They must be user-facing clients that + register at runtime (DCR or CIMD). A configured delegate client cannot be + listed here. Valid only with restricted; empty means the client's own + tokens only. + items: + maxLength: 256 + minLength: 1 + type: string + maxItems: 20 + type: array + x-kubernetes-list-type: set + apiAudience: + description: |- + APIAudience is the client's own API audience. The client may request it + even when the subject token does not cover it. It must be one of the + client's audiences. Valid only with restricted. + maxLength: 2048 + type: string + selfIssuedSubjectTokens: + description: |- + SelfIssuedSubjectTokens selects which self-issued subject tokens the + client may exchange. With restricted, a self-issued subject token must + carry the client's own client_id or one of allowedSourceClients, and a + may_act claim naming this client does not lift that restriction. With + any, the client may exchange any self-issued token. It has no default + and must be set explicitly. + enum: + - any + - restricted + type: string + required: + - selfIssuedSubjectTokens + type: object + x-kubernetes-validations: + - message: tokenExchange.allowedSourceClients and + tokenExchange.apiAudience are valid only when + selfIssuedSubjectTokens is restricted + rule: self.selfIssuedSubjectTokens == 'restricted' + || ((!has(self.allowedSourceClients) || size(self.allowedSourceClients) + == 0) && (!has(self.apiAudience) || size(self.apiAudience) + == 0)) 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 + - message: private_key_jwt requires a non-empty jwksRef + and forbids clientSecretRef; any other tokenEndpointAuthMethod + (or none) requires a non-empty clientSecretRef and + forbids jwksRef + rule: 'has(self.tokenEndpointAuthMethod) && self.tokenEndpointAuthMethod + == ''private_key_jwt'' ? (has(self.jwksRef) && !has(self.clientSecretRef)) + : (has(self.clientSecretRef) && size(self.clientSecretRef.name) + > 0 && size(self.clientSecretRef.key) > 0 && !has(self.jwksRef))' + - message: grantTypes must include urn:ietf:params:oauth:grant-type:token-exchange + when set + rule: '!has(self.grantTypes) || size(self.grantTypes) + == 0 || ''urn:ietf:params:oauth:grant-type:token-exchange'' + in self.grantTypes' + - message: redirectUris is required when grantTypes + includes authorization_code + rule: '!has(self.grantTypes) || !(''authorization_code'' + in self.grantTypes) || (has(self.redirectUris) && + size(self.redirectUris) > 0)' + - message: tokenExchange.selfIssuedSubjectTokens is + required when grantTypes is set + rule: '!has(self.grantTypes) || size(self.grantTypes) + == 0 || has(self.tokenExchange)' + - message: tokenExchange.selfIssuedSubjectTokens is + required for private_key_jwt clients + rule: '!(has(self.tokenEndpointAuthMethod) && self.tokenEndpointAuthMethod + == ''private_key_jwt'') || has(self.tokenExchange)' + - message: tokenExchange.apiAudience must be one of + the client's audiences + rule: '!has(self.tokenExchange) || !has(self.tokenExchange.apiAudience) + || size(self.tokenExchange.apiAudience) == 0 || + self.tokenExchange.apiAudience in self.audiences' + - message: tokenExchange.allowedSourceClients must not + contain the client's own clientId or the "*" wildcard + rule: '!has(self.tokenExchange) || !has(self.tokenExchange.allowedSourceClients) + || (!(self.clientId in self.tokenExchange.allowedSourceClients) + && !(''*'' in self.tokenExchange.allowedSourceClients))' + - message: redirectUris is only allowed with the authorization_code + grant + rule: '!has(self.redirectUris) || size(self.redirectUris) + == 0 || (has(self.grantTypes) && ''authorization_code'' + in self.grantTypes)' + - message: grantTypes must include authorization_code + with refresh_token + rule: '!has(self.grantTypes) || !(''refresh_token'' + in self.grantTypes) || ''authorization_code'' in + self.grantTypes' + - message: tokenEndpointAuthSigningAlg is only allowed + with private_key_jwt + rule: '!has(self.tokenEndpointAuthSigningAlg) || size(self.tokenEndpointAuthSigningAlg) + == 0 || (has(self.tokenEndpointAuthMethod) && self.tokenEndpointAuthMethod + == ''private_key_jwt'')' maxItems: 10 type: array x-kubernetes-list-type: atomic @@ -3900,10 +4649,12 @@ spec: 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 + private_key_jwt registration (DCR) 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. + nothing here for cleartext HTTP to expose. Delegate clients count as + confidential whatever their tokenEndpointAuthMethod, private_key_jwt + included, because they can hold authorization_code and refresh_token grants. type: boolean insecureAllowHTTP: default: false @@ -5674,6 +6425,13 @@ spec: - message: spiffeClientAuth clientId must not be an absolute URL rule: '!has(self.inboundGrants) || !has(self.inboundGrants.spiffeClientAuth) || self.inboundGrants.spiffeClientAuth.all(client, !client.clientId.matches(''^[A-Za-z][A-Za-z0-9+.-]*://.+''))' + - message: delegateClients is deprecated and accepts only clientId, + clientSecretRef, scopes and audiences; use inboundGrants.tokenExchange.delegateClients + for jwksRef, tokenEndpointAuthMethod, grantTypes, redirectUris, + tokenExchange and tokenEndpointAuthSigningAlg + rule: '!has(self.delegateClients) || self.delegateClients.all(c, + !has(c.jwksRef) && !has(c.tokenEndpointAuthMethod) && !has(c.grantTypes) + && !has(c.redirectUris) && !has(c.tokenExchange) && !has(c.tokenEndpointAuthSigningAlg))' - message: insecureAllowHTTP cannot be combined with confidential client registration or delegateClients; client secrets would be issued or used in cleartext over an unauthenticated endpoint 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 c6c78aeba9..f59e6351ac 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 @@ -194,9 +194,12 @@ spec: description: |- 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. + plaintext secret is accepted here. The supported path is + inboundGrants.tokenExchange.delegateClients. This field shares its type + with the canonical list, but accepts only clientId, clientSecretRef, + scopes and audiences. The newer fields (tokenEndpointAuthMethod, + tokenEndpointAuthSigningAlg, jwksRef, grantTypes, redirectUris, + tokenExchange) are rejected here; set them on the canonical list. This is independent of allowConfidentialClientRegistration: it neither enables nor requires unauthenticated confidential dynamic client @@ -204,9 +207,10 @@ spec: This legacy field is deprecated; use inboundGrants.tokenExchange.delegateClients. 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. + DelegateClientConfig configures a pre-provisioned confidential OAuth client. + By default it is a token-exchange client (RFC 8693). Its credential is either + a client secret, referenced from a Kubernetes Secret and never represented + inline, or a public JWKS for private_key_jwt, referenced from a ConfigMap. properties: audiences: description: Audiences is the narrowed set of RFC 8707 resources @@ -226,8 +230,9 @@ spec: minLength: 1 type: string clientSecretRef: - description: ClientSecretRef references the Kubernetes Secret - key containing the client secret. + description: |- + ClientSecretRef references the Kubernetes Secret key containing the client secret. + Required unless tokenEndpointAuthMethod is private_key_jwt, where it is forbidden. properties: key: description: Key is the key within the secret @@ -239,6 +244,62 @@ spec: - key - name type: object + grantTypes: + description: |- + GrantTypes are the grants this client may use. If omitted or empty, the + client is token-exchange only. When set, the list must include + urn:ietf:params:oauth:grant-type:token-exchange: a delegate client is a + token-exchange client that may also log in or redeem other grants. When set, + the list is complete: include urn:ietf:params:oauth:grant-type:jwt-bearer to + keep ID-JAG redemption. + items: + enum: + - authorization_code + - refresh_token + - urn:ietf:params:oauth:grant-type:token-exchange + - urn:ietf:params:oauth:grant-type:jwt-bearer + type: string + maxItems: 4 + type: array + x-kubernetes-list-type: set + jwksRef: + description: |- + JWKSRef references the ConfigMap key holding the client's public JWKS + document. Required with private_key_jwt and forbidden otherwise. The + document must contain public keys only. + properties: + key: + description: Key is the key within the ConfigMap + minLength: 1 + type: string + name: + description: Name is the name of the ConfigMap + minLength: 1 + type: string + required: + - key + - name + type: object + redirectUris: + description: |- + RedirectURIs are the registered redirect URIs. They are required with the + authorization_code grant and forbidden without it. They sit here, under + tokenExchange.delegateClients, because a delegate client is a + token-exchange client that may also log in (authorization_code). + + The pattern is stricter than the runtime for uppercase "HTTPS://": the + runtime accepts it, the pattern does not. The scheme check is + case-sensitive, as in forceConfidentialRedirectUris. Query strings and + fragments behave the same as the runtime: a query string is allowed, and + a fragment is rejected. + items: + maxLength: 2048 + minLength: 1 + pattern: ^https://[^\s#]+$ + type: string + maxItems: 10 + type: array + x-kubernetes-list-type: set scopes: description: Scopes is the narrowed set of OAuth scopes this client may request. @@ -250,17 +311,141 @@ spec: minItems: 1 type: array x-kubernetes-list-type: atomic + tokenEndpointAuthMethod: + description: |- + TokenEndpointAuthMethod pins how the client authenticates at the token + endpoint. If omitted, the client authenticates with its secret by either + HTTP Basic or form body. + enum: + - client_secret_basic + - client_secret_post + - private_key_jwt + type: string + tokenEndpointAuthSigningAlg: + description: |- + TokenEndpointAuthSigningAlg is the private_key_jwt signing algorithm, + for example RS256 or ES256. If omitted, it is derived from the keys: + each key's alg, or ES256, ES384 or ES512 from an EC key's curve. RSA + keys without alg use RS256. Keys that disagree must set it. + enum: + - RS256 + - RS384 + - RS512 + - PS256 + - PS384 + - PS512 + - ES256 + - ES384 + - ES512 + type: string + tokenExchange: + description: |- + TokenExchange is the client's token-exchange policy. Its + selfIssuedSubjectTokens field is required whenever the block is present. + The block may be omitted only by a secret-authenticated client without + grantTypes; such a client keeps the legacy rule (any) and may exchange any + self-issued token. A client that sets grantTypes or uses private_key_jwt + must declare the block. + properties: + allowedSourceClients: + description: |- + AllowedSourceClients are client_id values, as they appear in the subject + token's client_id claim, whose self-issued tokens this client may + exchange in addition to its own. They must be user-facing clients that + register at runtime (DCR or CIMD). A configured delegate client cannot be + listed here. Valid only with restricted; empty means the client's own + tokens only. + items: + maxLength: 256 + minLength: 1 + type: string + maxItems: 20 + type: array + x-kubernetes-list-type: set + apiAudience: + description: |- + APIAudience is the client's own API audience. The client may request it + even when the subject token does not cover it. It must be one of the + client's audiences. Valid only with restricted. + maxLength: 2048 + type: string + selfIssuedSubjectTokens: + description: |- + SelfIssuedSubjectTokens selects which self-issued subject tokens the + client may exchange. With restricted, a self-issued subject token must + carry the client's own client_id or one of allowedSourceClients, and a + may_act claim naming this client does not lift that restriction. With + any, the client may exchange any self-issued token. It has no default + and must be set explicitly. + enum: + - any + - restricted + type: string + required: + - selfIssuedSubjectTokens + type: object + x-kubernetes-validations: + - message: tokenExchange.allowedSourceClients and tokenExchange.apiAudience + are valid only when selfIssuedSubjectTokens is restricted + rule: self.selfIssuedSubjectTokens == 'restricted' || + ((!has(self.allowedSourceClients) || size(self.allowedSourceClients) + == 0) && (!has(self.apiAudience) || size(self.apiAudience) + == 0)) 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 + - message: private_key_jwt requires a non-empty jwksRef and + forbids clientSecretRef; any other tokenEndpointAuthMethod + (or none) requires a non-empty clientSecretRef and forbids + jwksRef + rule: 'has(self.tokenEndpointAuthMethod) && self.tokenEndpointAuthMethod + == ''private_key_jwt'' ? (has(self.jwksRef) && !has(self.clientSecretRef)) + : (has(self.clientSecretRef) && size(self.clientSecretRef.name) + > 0 && size(self.clientSecretRef.key) > 0 && !has(self.jwksRef))' + - message: grantTypes must include urn:ietf:params:oauth:grant-type:token-exchange + when set + rule: '!has(self.grantTypes) || size(self.grantTypes) == 0 + || ''urn:ietf:params:oauth:grant-type:token-exchange'' in + self.grantTypes' + - message: redirectUris is required when grantTypes includes + authorization_code + rule: '!has(self.grantTypes) || !(''authorization_code'' in + self.grantTypes) || (has(self.redirectUris) && size(self.redirectUris) + > 0)' + - message: tokenExchange.selfIssuedSubjectTokens is required + when grantTypes is set + rule: '!has(self.grantTypes) || size(self.grantTypes) == 0 + || has(self.tokenExchange)' + - message: tokenExchange.selfIssuedSubjectTokens is required + for private_key_jwt clients + rule: '!(has(self.tokenEndpointAuthMethod) && self.tokenEndpointAuthMethod + == ''private_key_jwt'') || has(self.tokenExchange)' + - message: tokenExchange.apiAudience must be one of the client's + audiences + rule: '!has(self.tokenExchange) || !has(self.tokenExchange.apiAudience) + || size(self.tokenExchange.apiAudience) == 0 || self.tokenExchange.apiAudience + in self.audiences' + - message: tokenExchange.allowedSourceClients must not contain + the client's own clientId or the "*" wildcard + rule: '!has(self.tokenExchange) || !has(self.tokenExchange.allowedSourceClients) + || (!(self.clientId in self.tokenExchange.allowedSourceClients) + && !(''*'' in self.tokenExchange.allowedSourceClients))' + - message: redirectUris is only allowed with the authorization_code + grant + rule: '!has(self.redirectUris) || size(self.redirectUris) + == 0 || (has(self.grantTypes) && ''authorization_code'' + in self.grantTypes)' + - message: grantTypes must include authorization_code with refresh_token + rule: '!has(self.grantTypes) || !(''refresh_token'' in self.grantTypes) + || ''authorization_code'' in self.grantTypes' + - message: tokenEndpointAuthSigningAlg is only allowed with + private_key_jwt + rule: '!has(self.tokenEndpointAuthSigningAlg) || size(self.tokenEndpointAuthSigningAlg) + == 0 || (has(self.tokenEndpointAuthMethod) && self.tokenEndpointAuthMethod + == ''private_key_jwt'')' maxItems: 10 type: array x-kubernetes-list-type: atomic @@ -549,9 +734,10 @@ spec: 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. + DelegateClientConfig configures a pre-provisioned confidential OAuth client. + By default it is a token-exchange client (RFC 8693). Its credential is either + a client secret, referenced from a Kubernetes Secret and never represented + inline, or a public JWKS for private_key_jwt, referenced from a ConfigMap. properties: audiences: description: Audiences is the narrowed set of RFC @@ -571,8 +757,9 @@ spec: minLength: 1 type: string clientSecretRef: - description: ClientSecretRef references the Kubernetes - Secret key containing the client secret. + description: |- + ClientSecretRef references the Kubernetes Secret key containing the client secret. + Required unless tokenEndpointAuthMethod is private_key_jwt, where it is forbidden. properties: key: description: Key is the key within the secret @@ -584,6 +771,62 @@ spec: - key - name type: object + grantTypes: + description: |- + GrantTypes are the grants this client may use. If omitted or empty, the + client is token-exchange only. When set, the list must include + urn:ietf:params:oauth:grant-type:token-exchange: a delegate client is a + token-exchange client that may also log in or redeem other grants. When set, + the list is complete: include urn:ietf:params:oauth:grant-type:jwt-bearer to + keep ID-JAG redemption. + items: + enum: + - authorization_code + - refresh_token + - urn:ietf:params:oauth:grant-type:token-exchange + - urn:ietf:params:oauth:grant-type:jwt-bearer + type: string + maxItems: 4 + type: array + x-kubernetes-list-type: set + jwksRef: + description: |- + JWKSRef references the ConfigMap key holding the client's public JWKS + document. Required with private_key_jwt and forbidden otherwise. The + document must contain public keys only. + properties: + key: + description: Key is the key within the ConfigMap + minLength: 1 + type: string + name: + description: Name is the name of the ConfigMap + minLength: 1 + type: string + required: + - key + - name + type: object + redirectUris: + description: |- + RedirectURIs are the registered redirect URIs. They are required with the + authorization_code grant and forbidden without it. They sit here, under + tokenExchange.delegateClients, because a delegate client is a + token-exchange client that may also log in (authorization_code). + + The pattern is stricter than the runtime for uppercase "HTTPS://": the + runtime accepts it, the pattern does not. The scheme check is + case-sensitive, as in forceConfidentialRedirectUris. Query strings and + fragments behave the same as the runtime: a query string is allowed, and + a fragment is rejected. + items: + maxLength: 2048 + minLength: 1 + pattern: ^https://[^\s#]+$ + type: string + maxItems: 10 + type: array + x-kubernetes-list-type: set scopes: description: Scopes is the narrowed set of OAuth scopes this client may request. @@ -595,17 +838,144 @@ spec: minItems: 1 type: array x-kubernetes-list-type: atomic + tokenEndpointAuthMethod: + description: |- + TokenEndpointAuthMethod pins how the client authenticates at the token + endpoint. If omitted, the client authenticates with its secret by either + HTTP Basic or form body. + enum: + - client_secret_basic + - client_secret_post + - private_key_jwt + type: string + tokenEndpointAuthSigningAlg: + description: |- + TokenEndpointAuthSigningAlg is the private_key_jwt signing algorithm, + for example RS256 or ES256. If omitted, it is derived from the keys: + each key's alg, or ES256, ES384 or ES512 from an EC key's curve. RSA + keys without alg use RS256. Keys that disagree must set it. + enum: + - RS256 + - RS384 + - RS512 + - PS256 + - PS384 + - PS512 + - ES256 + - ES384 + - ES512 + type: string + tokenExchange: + description: |- + TokenExchange is the client's token-exchange policy. Its + selfIssuedSubjectTokens field is required whenever the block is present. + The block may be omitted only by a secret-authenticated client without + grantTypes; such a client keeps the legacy rule (any) and may exchange any + self-issued token. A client that sets grantTypes or uses private_key_jwt + must declare the block. + properties: + allowedSourceClients: + description: |- + AllowedSourceClients are client_id values, as they appear in the subject + token's client_id claim, whose self-issued tokens this client may + exchange in addition to its own. They must be user-facing clients that + register at runtime (DCR or CIMD). A configured delegate client cannot be + listed here. Valid only with restricted; empty means the client's own + tokens only. + items: + maxLength: 256 + minLength: 1 + type: string + maxItems: 20 + type: array + x-kubernetes-list-type: set + apiAudience: + description: |- + APIAudience is the client's own API audience. The client may request it + even when the subject token does not cover it. It must be one of the + client's audiences. Valid only with restricted. + maxLength: 2048 + type: string + selfIssuedSubjectTokens: + description: |- + SelfIssuedSubjectTokens selects which self-issued subject tokens the + client may exchange. With restricted, a self-issued subject token must + carry the client's own client_id or one of allowedSourceClients, and a + may_act claim naming this client does not lift that restriction. With + any, the client may exchange any self-issued token. It has no default + and must be set explicitly. + enum: + - any + - restricted + type: string + required: + - selfIssuedSubjectTokens + type: object + x-kubernetes-validations: + - message: tokenExchange.allowedSourceClients and + tokenExchange.apiAudience are valid only when + selfIssuedSubjectTokens is restricted + rule: self.selfIssuedSubjectTokens == 'restricted' + || ((!has(self.allowedSourceClients) || size(self.allowedSourceClients) + == 0) && (!has(self.apiAudience) || size(self.apiAudience) + == 0)) 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 + - message: private_key_jwt requires a non-empty jwksRef + and forbids clientSecretRef; any other tokenEndpointAuthMethod + (or none) requires a non-empty clientSecretRef and + forbids jwksRef + rule: 'has(self.tokenEndpointAuthMethod) && self.tokenEndpointAuthMethod + == ''private_key_jwt'' ? (has(self.jwksRef) && !has(self.clientSecretRef)) + : (has(self.clientSecretRef) && size(self.clientSecretRef.name) + > 0 && size(self.clientSecretRef.key) > 0 && !has(self.jwksRef))' + - message: grantTypes must include urn:ietf:params:oauth:grant-type:token-exchange + when set + rule: '!has(self.grantTypes) || size(self.grantTypes) + == 0 || ''urn:ietf:params:oauth:grant-type:token-exchange'' + in self.grantTypes' + - message: redirectUris is required when grantTypes + includes authorization_code + rule: '!has(self.grantTypes) || !(''authorization_code'' + in self.grantTypes) || (has(self.redirectUris) && + size(self.redirectUris) > 0)' + - message: tokenExchange.selfIssuedSubjectTokens is + required when grantTypes is set + rule: '!has(self.grantTypes) || size(self.grantTypes) + == 0 || has(self.tokenExchange)' + - message: tokenExchange.selfIssuedSubjectTokens is + required for private_key_jwt clients + rule: '!(has(self.tokenEndpointAuthMethod) && self.tokenEndpointAuthMethod + == ''private_key_jwt'') || has(self.tokenExchange)' + - message: tokenExchange.apiAudience must be one of + the client's audiences + rule: '!has(self.tokenExchange) || !has(self.tokenExchange.apiAudience) + || size(self.tokenExchange.apiAudience) == 0 || + self.tokenExchange.apiAudience in self.audiences' + - message: tokenExchange.allowedSourceClients must not + contain the client's own clientId or the "*" wildcard + rule: '!has(self.tokenExchange) || !has(self.tokenExchange.allowedSourceClients) + || (!(self.clientId in self.tokenExchange.allowedSourceClients) + && !(''*'' in self.tokenExchange.allowedSourceClients))' + - message: redirectUris is only allowed with the authorization_code + grant + rule: '!has(self.redirectUris) || size(self.redirectUris) + == 0 || (has(self.grantTypes) && ''authorization_code'' + in self.grantTypes)' + - message: grantTypes must include authorization_code + with refresh_token + rule: '!has(self.grantTypes) || !(''refresh_token'' + in self.grantTypes) || ''authorization_code'' in + self.grantTypes' + - message: tokenEndpointAuthSigningAlg is only allowed + with private_key_jwt + rule: '!has(self.tokenEndpointAuthSigningAlg) || size(self.tokenEndpointAuthSigningAlg) + == 0 || (has(self.tokenEndpointAuthMethod) && self.tokenEndpointAuthMethod + == ''private_key_jwt'')' maxItems: 10 type: array x-kubernetes-list-type: atomic @@ -694,10 +1064,12 @@ spec: 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 + private_key_jwt registration (DCR) 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. + nothing here for cleartext HTTP to expose. Delegate clients count as + confidential whatever their tokenEndpointAuthMethod, private_key_jwt + included, because they can hold authorization_code and refresh_token grants. type: boolean insecureAllowHTTP: default: false @@ -2468,6 +2840,13 @@ spec: - message: spiffeClientAuth clientId must not be an absolute URL rule: '!has(self.inboundGrants) || !has(self.inboundGrants.spiffeClientAuth) || self.inboundGrants.spiffeClientAuth.all(client, !client.clientId.matches(''^[A-Za-z][A-Za-z0-9+.-]*://.+''))' + - message: delegateClients is deprecated and accepts only clientId, + clientSecretRef, scopes and audiences; use inboundGrants.tokenExchange.delegateClients + for jwksRef, tokenEndpointAuthMethod, grantTypes, redirectUris, + tokenExchange and tokenEndpointAuthSigningAlg + rule: '!has(self.delegateClients) || self.delegateClients.all(c, + !has(c.jwksRef) && !has(c.tokenEndpointAuthMethod) && !has(c.grantTypes) + && !has(c.redirectUris) && !has(c.tokenExchange) && !has(c.tokenEndpointAuthSigningAlg))' - message: insecureAllowHTTP cannot be combined with confidential client registration or delegateClients; client secrets would be issued or used in cleartext over an unauthenticated endpoint @@ -5679,9 +6058,12 @@ spec: description: |- 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. + plaintext secret is accepted here. The supported path is + inboundGrants.tokenExchange.delegateClients. This field shares its type + with the canonical list, but accepts only clientId, clientSecretRef, + scopes and audiences. The newer fields (tokenEndpointAuthMethod, + tokenEndpointAuthSigningAlg, jwksRef, grantTypes, redirectUris, + tokenExchange) are rejected here; set them on the canonical list. This is independent of allowConfidentialClientRegistration: it neither enables nor requires unauthenticated confidential dynamic client @@ -5689,9 +6071,10 @@ spec: This legacy field is deprecated; use inboundGrants.tokenExchange.delegateClients. 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. + DelegateClientConfig configures a pre-provisioned confidential OAuth client. + By default it is a token-exchange client (RFC 8693). Its credential is either + a client secret, referenced from a Kubernetes Secret and never represented + inline, or a public JWKS for private_key_jwt, referenced from a ConfigMap. properties: audiences: description: Audiences is the narrowed set of RFC 8707 resources @@ -5711,8 +6094,9 @@ spec: minLength: 1 type: string clientSecretRef: - description: ClientSecretRef references the Kubernetes Secret - key containing the client secret. + description: |- + ClientSecretRef references the Kubernetes Secret key containing the client secret. + Required unless tokenEndpointAuthMethod is private_key_jwt, where it is forbidden. properties: key: description: Key is the key within the secret @@ -5724,6 +6108,62 @@ spec: - key - name type: object + grantTypes: + description: |- + GrantTypes are the grants this client may use. If omitted or empty, the + client is token-exchange only. When set, the list must include + urn:ietf:params:oauth:grant-type:token-exchange: a delegate client is a + token-exchange client that may also log in or redeem other grants. When set, + the list is complete: include urn:ietf:params:oauth:grant-type:jwt-bearer to + keep ID-JAG redemption. + items: + enum: + - authorization_code + - refresh_token + - urn:ietf:params:oauth:grant-type:token-exchange + - urn:ietf:params:oauth:grant-type:jwt-bearer + type: string + maxItems: 4 + type: array + x-kubernetes-list-type: set + jwksRef: + description: |- + JWKSRef references the ConfigMap key holding the client's public JWKS + document. Required with private_key_jwt and forbidden otherwise. The + document must contain public keys only. + properties: + key: + description: Key is the key within the ConfigMap + minLength: 1 + type: string + name: + description: Name is the name of the ConfigMap + minLength: 1 + type: string + required: + - key + - name + type: object + redirectUris: + description: |- + RedirectURIs are the registered redirect URIs. They are required with the + authorization_code grant and forbidden without it. They sit here, under + tokenExchange.delegateClients, because a delegate client is a + token-exchange client that may also log in (authorization_code). + + The pattern is stricter than the runtime for uppercase "HTTPS://": the + runtime accepts it, the pattern does not. The scheme check is + case-sensitive, as in forceConfidentialRedirectUris. Query strings and + fragments behave the same as the runtime: a query string is allowed, and + a fragment is rejected. + items: + maxLength: 2048 + minLength: 1 + pattern: ^https://[^\s#]+$ + type: string + maxItems: 10 + type: array + x-kubernetes-list-type: set scopes: description: Scopes is the narrowed set of OAuth scopes this client may request. @@ -5735,17 +6175,141 @@ spec: minItems: 1 type: array x-kubernetes-list-type: atomic + tokenEndpointAuthMethod: + description: |- + TokenEndpointAuthMethod pins how the client authenticates at the token + endpoint. If omitted, the client authenticates with its secret by either + HTTP Basic or form body. + enum: + - client_secret_basic + - client_secret_post + - private_key_jwt + type: string + tokenEndpointAuthSigningAlg: + description: |- + TokenEndpointAuthSigningAlg is the private_key_jwt signing algorithm, + for example RS256 or ES256. If omitted, it is derived from the keys: + each key's alg, or ES256, ES384 or ES512 from an EC key's curve. RSA + keys without alg use RS256. Keys that disagree must set it. + enum: + - RS256 + - RS384 + - RS512 + - PS256 + - PS384 + - PS512 + - ES256 + - ES384 + - ES512 + type: string + tokenExchange: + description: |- + TokenExchange is the client's token-exchange policy. Its + selfIssuedSubjectTokens field is required whenever the block is present. + The block may be omitted only by a secret-authenticated client without + grantTypes; such a client keeps the legacy rule (any) and may exchange any + self-issued token. A client that sets grantTypes or uses private_key_jwt + must declare the block. + properties: + allowedSourceClients: + description: |- + AllowedSourceClients are client_id values, as they appear in the subject + token's client_id claim, whose self-issued tokens this client may + exchange in addition to its own. They must be user-facing clients that + register at runtime (DCR or CIMD). A configured delegate client cannot be + listed here. Valid only with restricted; empty means the client's own + tokens only. + items: + maxLength: 256 + minLength: 1 + type: string + maxItems: 20 + type: array + x-kubernetes-list-type: set + apiAudience: + description: |- + APIAudience is the client's own API audience. The client may request it + even when the subject token does not cover it. It must be one of the + client's audiences. Valid only with restricted. + maxLength: 2048 + type: string + selfIssuedSubjectTokens: + description: |- + SelfIssuedSubjectTokens selects which self-issued subject tokens the + client may exchange. With restricted, a self-issued subject token must + carry the client's own client_id or one of allowedSourceClients, and a + may_act claim naming this client does not lift that restriction. With + any, the client may exchange any self-issued token. It has no default + and must be set explicitly. + enum: + - any + - restricted + type: string + required: + - selfIssuedSubjectTokens + type: object + x-kubernetes-validations: + - message: tokenExchange.allowedSourceClients and tokenExchange.apiAudience + are valid only when selfIssuedSubjectTokens is restricted + rule: self.selfIssuedSubjectTokens == 'restricted' || + ((!has(self.allowedSourceClients) || size(self.allowedSourceClients) + == 0) && (!has(self.apiAudience) || size(self.apiAudience) + == 0)) 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 + - message: private_key_jwt requires a non-empty jwksRef and + forbids clientSecretRef; any other tokenEndpointAuthMethod + (or none) requires a non-empty clientSecretRef and forbids + jwksRef + rule: 'has(self.tokenEndpointAuthMethod) && self.tokenEndpointAuthMethod + == ''private_key_jwt'' ? (has(self.jwksRef) && !has(self.clientSecretRef)) + : (has(self.clientSecretRef) && size(self.clientSecretRef.name) + > 0 && size(self.clientSecretRef.key) > 0 && !has(self.jwksRef))' + - message: grantTypes must include urn:ietf:params:oauth:grant-type:token-exchange + when set + rule: '!has(self.grantTypes) || size(self.grantTypes) == 0 + || ''urn:ietf:params:oauth:grant-type:token-exchange'' in + self.grantTypes' + - message: redirectUris is required when grantTypes includes + authorization_code + rule: '!has(self.grantTypes) || !(''authorization_code'' in + self.grantTypes) || (has(self.redirectUris) && size(self.redirectUris) + > 0)' + - message: tokenExchange.selfIssuedSubjectTokens is required + when grantTypes is set + rule: '!has(self.grantTypes) || size(self.grantTypes) == 0 + || has(self.tokenExchange)' + - message: tokenExchange.selfIssuedSubjectTokens is required + for private_key_jwt clients + rule: '!(has(self.tokenEndpointAuthMethod) && self.tokenEndpointAuthMethod + == ''private_key_jwt'') || has(self.tokenExchange)' + - message: tokenExchange.apiAudience must be one of the client's + audiences + rule: '!has(self.tokenExchange) || !has(self.tokenExchange.apiAudience) + || size(self.tokenExchange.apiAudience) == 0 || self.tokenExchange.apiAudience + in self.audiences' + - message: tokenExchange.allowedSourceClients must not contain + the client's own clientId or the "*" wildcard + rule: '!has(self.tokenExchange) || !has(self.tokenExchange.allowedSourceClients) + || (!(self.clientId in self.tokenExchange.allowedSourceClients) + && !(''*'' in self.tokenExchange.allowedSourceClients))' + - message: redirectUris is only allowed with the authorization_code + grant + rule: '!has(self.redirectUris) || size(self.redirectUris) + == 0 || (has(self.grantTypes) && ''authorization_code'' + in self.grantTypes)' + - message: grantTypes must include authorization_code with refresh_token + rule: '!has(self.grantTypes) || !(''refresh_token'' in self.grantTypes) + || ''authorization_code'' in self.grantTypes' + - message: tokenEndpointAuthSigningAlg is only allowed with + private_key_jwt + rule: '!has(self.tokenEndpointAuthSigningAlg) || size(self.tokenEndpointAuthSigningAlg) + == 0 || (has(self.tokenEndpointAuthMethod) && self.tokenEndpointAuthMethod + == ''private_key_jwt'')' maxItems: 10 type: array x-kubernetes-list-type: atomic @@ -6034,9 +6598,10 @@ spec: 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. + DelegateClientConfig configures a pre-provisioned confidential OAuth client. + By default it is a token-exchange client (RFC 8693). Its credential is either + a client secret, referenced from a Kubernetes Secret and never represented + inline, or a public JWKS for private_key_jwt, referenced from a ConfigMap. properties: audiences: description: Audiences is the narrowed set of RFC @@ -6056,8 +6621,9 @@ spec: minLength: 1 type: string clientSecretRef: - description: ClientSecretRef references the Kubernetes - Secret key containing the client secret. + description: |- + ClientSecretRef references the Kubernetes Secret key containing the client secret. + Required unless tokenEndpointAuthMethod is private_key_jwt, where it is forbidden. properties: key: description: Key is the key within the secret @@ -6069,6 +6635,62 @@ spec: - key - name type: object + grantTypes: + description: |- + GrantTypes are the grants this client may use. If omitted or empty, the + client is token-exchange only. When set, the list must include + urn:ietf:params:oauth:grant-type:token-exchange: a delegate client is a + token-exchange client that may also log in or redeem other grants. When set, + the list is complete: include urn:ietf:params:oauth:grant-type:jwt-bearer to + keep ID-JAG redemption. + items: + enum: + - authorization_code + - refresh_token + - urn:ietf:params:oauth:grant-type:token-exchange + - urn:ietf:params:oauth:grant-type:jwt-bearer + type: string + maxItems: 4 + type: array + x-kubernetes-list-type: set + jwksRef: + description: |- + JWKSRef references the ConfigMap key holding the client's public JWKS + document. Required with private_key_jwt and forbidden otherwise. The + document must contain public keys only. + properties: + key: + description: Key is the key within the ConfigMap + minLength: 1 + type: string + name: + description: Name is the name of the ConfigMap + minLength: 1 + type: string + required: + - key + - name + type: object + redirectUris: + description: |- + RedirectURIs are the registered redirect URIs. They are required with the + authorization_code grant and forbidden without it. They sit here, under + tokenExchange.delegateClients, because a delegate client is a + token-exchange client that may also log in (authorization_code). + + The pattern is stricter than the runtime for uppercase "HTTPS://": the + runtime accepts it, the pattern does not. The scheme check is + case-sensitive, as in forceConfidentialRedirectUris. Query strings and + fragments behave the same as the runtime: a query string is allowed, and + a fragment is rejected. + items: + maxLength: 2048 + minLength: 1 + pattern: ^https://[^\s#]+$ + type: string + maxItems: 10 + type: array + x-kubernetes-list-type: set scopes: description: Scopes is the narrowed set of OAuth scopes this client may request. @@ -6080,17 +6702,144 @@ spec: minItems: 1 type: array x-kubernetes-list-type: atomic + tokenEndpointAuthMethod: + description: |- + TokenEndpointAuthMethod pins how the client authenticates at the token + endpoint. If omitted, the client authenticates with its secret by either + HTTP Basic or form body. + enum: + - client_secret_basic + - client_secret_post + - private_key_jwt + type: string + tokenEndpointAuthSigningAlg: + description: |- + TokenEndpointAuthSigningAlg is the private_key_jwt signing algorithm, + for example RS256 or ES256. If omitted, it is derived from the keys: + each key's alg, or ES256, ES384 or ES512 from an EC key's curve. RSA + keys without alg use RS256. Keys that disagree must set it. + enum: + - RS256 + - RS384 + - RS512 + - PS256 + - PS384 + - PS512 + - ES256 + - ES384 + - ES512 + type: string + tokenExchange: + description: |- + TokenExchange is the client's token-exchange policy. Its + selfIssuedSubjectTokens field is required whenever the block is present. + The block may be omitted only by a secret-authenticated client without + grantTypes; such a client keeps the legacy rule (any) and may exchange any + self-issued token. A client that sets grantTypes or uses private_key_jwt + must declare the block. + properties: + allowedSourceClients: + description: |- + AllowedSourceClients are client_id values, as they appear in the subject + token's client_id claim, whose self-issued tokens this client may + exchange in addition to its own. They must be user-facing clients that + register at runtime (DCR or CIMD). A configured delegate client cannot be + listed here. Valid only with restricted; empty means the client's own + tokens only. + items: + maxLength: 256 + minLength: 1 + type: string + maxItems: 20 + type: array + x-kubernetes-list-type: set + apiAudience: + description: |- + APIAudience is the client's own API audience. The client may request it + even when the subject token does not cover it. It must be one of the + client's audiences. Valid only with restricted. + maxLength: 2048 + type: string + selfIssuedSubjectTokens: + description: |- + SelfIssuedSubjectTokens selects which self-issued subject tokens the + client may exchange. With restricted, a self-issued subject token must + carry the client's own client_id or one of allowedSourceClients, and a + may_act claim naming this client does not lift that restriction. With + any, the client may exchange any self-issued token. It has no default + and must be set explicitly. + enum: + - any + - restricted + type: string + required: + - selfIssuedSubjectTokens + type: object + x-kubernetes-validations: + - message: tokenExchange.allowedSourceClients and + tokenExchange.apiAudience are valid only when + selfIssuedSubjectTokens is restricted + rule: self.selfIssuedSubjectTokens == 'restricted' + || ((!has(self.allowedSourceClients) || size(self.allowedSourceClients) + == 0) && (!has(self.apiAudience) || size(self.apiAudience) + == 0)) 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 + - message: private_key_jwt requires a non-empty jwksRef + and forbids clientSecretRef; any other tokenEndpointAuthMethod + (or none) requires a non-empty clientSecretRef and + forbids jwksRef + rule: 'has(self.tokenEndpointAuthMethod) && self.tokenEndpointAuthMethod + == ''private_key_jwt'' ? (has(self.jwksRef) && !has(self.clientSecretRef)) + : (has(self.clientSecretRef) && size(self.clientSecretRef.name) + > 0 && size(self.clientSecretRef.key) > 0 && !has(self.jwksRef))' + - message: grantTypes must include urn:ietf:params:oauth:grant-type:token-exchange + when set + rule: '!has(self.grantTypes) || size(self.grantTypes) + == 0 || ''urn:ietf:params:oauth:grant-type:token-exchange'' + in self.grantTypes' + - message: redirectUris is required when grantTypes + includes authorization_code + rule: '!has(self.grantTypes) || !(''authorization_code'' + in self.grantTypes) || (has(self.redirectUris) && + size(self.redirectUris) > 0)' + - message: tokenExchange.selfIssuedSubjectTokens is + required when grantTypes is set + rule: '!has(self.grantTypes) || size(self.grantTypes) + == 0 || has(self.tokenExchange)' + - message: tokenExchange.selfIssuedSubjectTokens is + required for private_key_jwt clients + rule: '!(has(self.tokenEndpointAuthMethod) && self.tokenEndpointAuthMethod + == ''private_key_jwt'') || has(self.tokenExchange)' + - message: tokenExchange.apiAudience must be one of + the client's audiences + rule: '!has(self.tokenExchange) || !has(self.tokenExchange.apiAudience) + || size(self.tokenExchange.apiAudience) == 0 || + self.tokenExchange.apiAudience in self.audiences' + - message: tokenExchange.allowedSourceClients must not + contain the client's own clientId or the "*" wildcard + rule: '!has(self.tokenExchange) || !has(self.tokenExchange.allowedSourceClients) + || (!(self.clientId in self.tokenExchange.allowedSourceClients) + && !(''*'' in self.tokenExchange.allowedSourceClients))' + - message: redirectUris is only allowed with the authorization_code + grant + rule: '!has(self.redirectUris) || size(self.redirectUris) + == 0 || (has(self.grantTypes) && ''authorization_code'' + in self.grantTypes)' + - message: grantTypes must include authorization_code + with refresh_token + rule: '!has(self.grantTypes) || !(''refresh_token'' + in self.grantTypes) || ''authorization_code'' in + self.grantTypes' + - message: tokenEndpointAuthSigningAlg is only allowed + with private_key_jwt + rule: '!has(self.tokenEndpointAuthSigningAlg) || size(self.tokenEndpointAuthSigningAlg) + == 0 || (has(self.tokenEndpointAuthMethod) && self.tokenEndpointAuthMethod + == ''private_key_jwt'')' maxItems: 10 type: array x-kubernetes-list-type: atomic @@ -6179,10 +6928,12 @@ spec: 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 + private_key_jwt registration (DCR) 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. + nothing here for cleartext HTTP to expose. Delegate clients count as + confidential whatever their tokenEndpointAuthMethod, private_key_jwt + included, because they can hold authorization_code and refresh_token grants. type: boolean insecureAllowHTTP: default: false @@ -7953,6 +8704,13 @@ spec: - message: spiffeClientAuth clientId must not be an absolute URL rule: '!has(self.inboundGrants) || !has(self.inboundGrants.spiffeClientAuth) || self.inboundGrants.spiffeClientAuth.all(client, !client.clientId.matches(''^[A-Za-z][A-Za-z0-9+.-]*://.+''))' + - message: delegateClients is deprecated and accepts only clientId, + clientSecretRef, scopes and audiences; use inboundGrants.tokenExchange.delegateClients + for jwksRef, tokenEndpointAuthMethod, grantTypes, redirectUris, + tokenExchange and tokenEndpointAuthSigningAlg + rule: '!has(self.delegateClients) || self.delegateClients.all(c, + !has(c.jwksRef) && !has(c.tokenEndpointAuthMethod) && !has(c.grantTypes) + && !has(c.redirectUris) && !has(c.tokenExchange) && !has(c.tokenEndpointAuthSigningAlg))' - message: insecureAllowHTTP cannot be combined with confidential client registration or delegateClients; client secrets would be issued or used in cleartext over an unauthenticated endpoint 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 e9714b2185..3597d95a32 100644 --- a/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpexternalauthconfigs.yaml +++ b/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpexternalauthconfigs.yaml @@ -310,9 +310,12 @@ spec: description: |- 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. + plaintext secret is accepted here. The supported path is + inboundGrants.tokenExchange.delegateClients. This field shares its type + with the canonical list, but accepts only clientId, clientSecretRef, + scopes and audiences. The newer fields (tokenEndpointAuthMethod, + tokenEndpointAuthSigningAlg, jwksRef, grantTypes, redirectUris, + tokenExchange) are rejected here; set them on the canonical list. This is independent of allowConfidentialClientRegistration: it neither enables nor requires unauthenticated confidential dynamic client @@ -320,9 +323,10 @@ spec: This legacy field is deprecated; use inboundGrants.tokenExchange.delegateClients. 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. + DelegateClientConfig configures a pre-provisioned confidential OAuth client. + By default it is a token-exchange client (RFC 8693). Its credential is either + a client secret, referenced from a Kubernetes Secret and never represented + inline, or a public JWKS for private_key_jwt, referenced from a ConfigMap. properties: audiences: description: Audiences is the narrowed set of RFC 8707 resources @@ -342,8 +346,9 @@ spec: minLength: 1 type: string clientSecretRef: - description: ClientSecretRef references the Kubernetes Secret - key containing the client secret. + description: |- + ClientSecretRef references the Kubernetes Secret key containing the client secret. + Required unless tokenEndpointAuthMethod is private_key_jwt, where it is forbidden. properties: key: description: Key is the key within the secret @@ -355,6 +360,62 @@ spec: - key - name type: object + grantTypes: + description: |- + GrantTypes are the grants this client may use. If omitted or empty, the + client is token-exchange only. When set, the list must include + urn:ietf:params:oauth:grant-type:token-exchange: a delegate client is a + token-exchange client that may also log in or redeem other grants. When set, + the list is complete: include urn:ietf:params:oauth:grant-type:jwt-bearer to + keep ID-JAG redemption. + items: + enum: + - authorization_code + - refresh_token + - urn:ietf:params:oauth:grant-type:token-exchange + - urn:ietf:params:oauth:grant-type:jwt-bearer + type: string + maxItems: 4 + type: array + x-kubernetes-list-type: set + jwksRef: + description: |- + JWKSRef references the ConfigMap key holding the client's public JWKS + document. Required with private_key_jwt and forbidden otherwise. The + document must contain public keys only. + properties: + key: + description: Key is the key within the ConfigMap + minLength: 1 + type: string + name: + description: Name is the name of the ConfigMap + minLength: 1 + type: string + required: + - key + - name + type: object + redirectUris: + description: |- + RedirectURIs are the registered redirect URIs. They are required with the + authorization_code grant and forbidden without it. They sit here, under + tokenExchange.delegateClients, because a delegate client is a + token-exchange client that may also log in (authorization_code). + + The pattern is stricter than the runtime for uppercase "HTTPS://": the + runtime accepts it, the pattern does not. The scheme check is + case-sensitive, as in forceConfidentialRedirectUris. Query strings and + fragments behave the same as the runtime: a query string is allowed, and + a fragment is rejected. + items: + maxLength: 2048 + minLength: 1 + pattern: ^https://[^\s#]+$ + type: string + maxItems: 10 + type: array + x-kubernetes-list-type: set scopes: description: Scopes is the narrowed set of OAuth scopes this client may request. @@ -366,17 +427,141 @@ spec: minItems: 1 type: array x-kubernetes-list-type: atomic + tokenEndpointAuthMethod: + description: |- + TokenEndpointAuthMethod pins how the client authenticates at the token + endpoint. If omitted, the client authenticates with its secret by either + HTTP Basic or form body. + enum: + - client_secret_basic + - client_secret_post + - private_key_jwt + type: string + tokenEndpointAuthSigningAlg: + description: |- + TokenEndpointAuthSigningAlg is the private_key_jwt signing algorithm, + for example RS256 or ES256. If omitted, it is derived from the keys: + each key's alg, or ES256, ES384 or ES512 from an EC key's curve. RSA + keys without alg use RS256. Keys that disagree must set it. + enum: + - RS256 + - RS384 + - RS512 + - PS256 + - PS384 + - PS512 + - ES256 + - ES384 + - ES512 + type: string + tokenExchange: + description: |- + TokenExchange is the client's token-exchange policy. Its + selfIssuedSubjectTokens field is required whenever the block is present. + The block may be omitted only by a secret-authenticated client without + grantTypes; such a client keeps the legacy rule (any) and may exchange any + self-issued token. A client that sets grantTypes or uses private_key_jwt + must declare the block. + properties: + allowedSourceClients: + description: |- + AllowedSourceClients are client_id values, as they appear in the subject + token's client_id claim, whose self-issued tokens this client may + exchange in addition to its own. They must be user-facing clients that + register at runtime (DCR or CIMD). A configured delegate client cannot be + listed here. Valid only with restricted; empty means the client's own + tokens only. + items: + maxLength: 256 + minLength: 1 + type: string + maxItems: 20 + type: array + x-kubernetes-list-type: set + apiAudience: + description: |- + APIAudience is the client's own API audience. The client may request it + even when the subject token does not cover it. It must be one of the + client's audiences. Valid only with restricted. + maxLength: 2048 + type: string + selfIssuedSubjectTokens: + description: |- + SelfIssuedSubjectTokens selects which self-issued subject tokens the + client may exchange. With restricted, a self-issued subject token must + carry the client's own client_id or one of allowedSourceClients, and a + may_act claim naming this client does not lift that restriction. With + any, the client may exchange any self-issued token. It has no default + and must be set explicitly. + enum: + - any + - restricted + type: string + required: + - selfIssuedSubjectTokens + type: object + x-kubernetes-validations: + - message: tokenExchange.allowedSourceClients and tokenExchange.apiAudience + are valid only when selfIssuedSubjectTokens is restricted + rule: self.selfIssuedSubjectTokens == 'restricted' || + ((!has(self.allowedSourceClients) || size(self.allowedSourceClients) + == 0) && (!has(self.apiAudience) || size(self.apiAudience) + == 0)) 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 + - message: private_key_jwt requires a non-empty jwksRef and + forbids clientSecretRef; any other tokenEndpointAuthMethod + (or none) requires a non-empty clientSecretRef and forbids + jwksRef + rule: 'has(self.tokenEndpointAuthMethod) && self.tokenEndpointAuthMethod + == ''private_key_jwt'' ? (has(self.jwksRef) && !has(self.clientSecretRef)) + : (has(self.clientSecretRef) && size(self.clientSecretRef.name) + > 0 && size(self.clientSecretRef.key) > 0 && !has(self.jwksRef))' + - message: grantTypes must include urn:ietf:params:oauth:grant-type:token-exchange + when set + rule: '!has(self.grantTypes) || size(self.grantTypes) == 0 + || ''urn:ietf:params:oauth:grant-type:token-exchange'' in + self.grantTypes' + - message: redirectUris is required when grantTypes includes + authorization_code + rule: '!has(self.grantTypes) || !(''authorization_code'' in + self.grantTypes) || (has(self.redirectUris) && size(self.redirectUris) + > 0)' + - message: tokenExchange.selfIssuedSubjectTokens is required + when grantTypes is set + rule: '!has(self.grantTypes) || size(self.grantTypes) == 0 + || has(self.tokenExchange)' + - message: tokenExchange.selfIssuedSubjectTokens is required + for private_key_jwt clients + rule: '!(has(self.tokenEndpointAuthMethod) && self.tokenEndpointAuthMethod + == ''private_key_jwt'') || has(self.tokenExchange)' + - message: tokenExchange.apiAudience must be one of the client's + audiences + rule: '!has(self.tokenExchange) || !has(self.tokenExchange.apiAudience) + || size(self.tokenExchange.apiAudience) == 0 || self.tokenExchange.apiAudience + in self.audiences' + - message: tokenExchange.allowedSourceClients must not contain + the client's own clientId or the "*" wildcard + rule: '!has(self.tokenExchange) || !has(self.tokenExchange.allowedSourceClients) + || (!(self.clientId in self.tokenExchange.allowedSourceClients) + && !(''*'' in self.tokenExchange.allowedSourceClients))' + - message: redirectUris is only allowed with the authorization_code + grant + rule: '!has(self.redirectUris) || size(self.redirectUris) + == 0 || (has(self.grantTypes) && ''authorization_code'' + in self.grantTypes)' + - message: grantTypes must include authorization_code with refresh_token + rule: '!has(self.grantTypes) || !(''refresh_token'' in self.grantTypes) + || ''authorization_code'' in self.grantTypes' + - message: tokenEndpointAuthSigningAlg is only allowed with + private_key_jwt + rule: '!has(self.tokenEndpointAuthSigningAlg) || size(self.tokenEndpointAuthSigningAlg) + == 0 || (has(self.tokenEndpointAuthMethod) && self.tokenEndpointAuthMethod + == ''private_key_jwt'')' maxItems: 10 type: array x-kubernetes-list-type: atomic @@ -665,9 +850,10 @@ spec: 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. + DelegateClientConfig configures a pre-provisioned confidential OAuth client. + By default it is a token-exchange client (RFC 8693). Its credential is either + a client secret, referenced from a Kubernetes Secret and never represented + inline, or a public JWKS for private_key_jwt, referenced from a ConfigMap. properties: audiences: description: Audiences is the narrowed set of RFC @@ -687,8 +873,9 @@ spec: minLength: 1 type: string clientSecretRef: - description: ClientSecretRef references the Kubernetes - Secret key containing the client secret. + description: |- + ClientSecretRef references the Kubernetes Secret key containing the client secret. + Required unless tokenEndpointAuthMethod is private_key_jwt, where it is forbidden. properties: key: description: Key is the key within the secret @@ -700,6 +887,62 @@ spec: - key - name type: object + grantTypes: + description: |- + GrantTypes are the grants this client may use. If omitted or empty, the + client is token-exchange only. When set, the list must include + urn:ietf:params:oauth:grant-type:token-exchange: a delegate client is a + token-exchange client that may also log in or redeem other grants. When set, + the list is complete: include urn:ietf:params:oauth:grant-type:jwt-bearer to + keep ID-JAG redemption. + items: + enum: + - authorization_code + - refresh_token + - urn:ietf:params:oauth:grant-type:token-exchange + - urn:ietf:params:oauth:grant-type:jwt-bearer + type: string + maxItems: 4 + type: array + x-kubernetes-list-type: set + jwksRef: + description: |- + JWKSRef references the ConfigMap key holding the client's public JWKS + document. Required with private_key_jwt and forbidden otherwise. The + document must contain public keys only. + properties: + key: + description: Key is the key within the ConfigMap + minLength: 1 + type: string + name: + description: Name is the name of the ConfigMap + minLength: 1 + type: string + required: + - key + - name + type: object + redirectUris: + description: |- + RedirectURIs are the registered redirect URIs. They are required with the + authorization_code grant and forbidden without it. They sit here, under + tokenExchange.delegateClients, because a delegate client is a + token-exchange client that may also log in (authorization_code). + + The pattern is stricter than the runtime for uppercase "HTTPS://": the + runtime accepts it, the pattern does not. The scheme check is + case-sensitive, as in forceConfidentialRedirectUris. Query strings and + fragments behave the same as the runtime: a query string is allowed, and + a fragment is rejected. + items: + maxLength: 2048 + minLength: 1 + pattern: ^https://[^\s#]+$ + type: string + maxItems: 10 + type: array + x-kubernetes-list-type: set scopes: description: Scopes is the narrowed set of OAuth scopes this client may request. @@ -711,17 +954,144 @@ spec: minItems: 1 type: array x-kubernetes-list-type: atomic + tokenEndpointAuthMethod: + description: |- + TokenEndpointAuthMethod pins how the client authenticates at the token + endpoint. If omitted, the client authenticates with its secret by either + HTTP Basic or form body. + enum: + - client_secret_basic + - client_secret_post + - private_key_jwt + type: string + tokenEndpointAuthSigningAlg: + description: |- + TokenEndpointAuthSigningAlg is the private_key_jwt signing algorithm, + for example RS256 or ES256. If omitted, it is derived from the keys: + each key's alg, or ES256, ES384 or ES512 from an EC key's curve. RSA + keys without alg use RS256. Keys that disagree must set it. + enum: + - RS256 + - RS384 + - RS512 + - PS256 + - PS384 + - PS512 + - ES256 + - ES384 + - ES512 + type: string + tokenExchange: + description: |- + TokenExchange is the client's token-exchange policy. Its + selfIssuedSubjectTokens field is required whenever the block is present. + The block may be omitted only by a secret-authenticated client without + grantTypes; such a client keeps the legacy rule (any) and may exchange any + self-issued token. A client that sets grantTypes or uses private_key_jwt + must declare the block. + properties: + allowedSourceClients: + description: |- + AllowedSourceClients are client_id values, as they appear in the subject + token's client_id claim, whose self-issued tokens this client may + exchange in addition to its own. They must be user-facing clients that + register at runtime (DCR or CIMD). A configured delegate client cannot be + listed here. Valid only with restricted; empty means the client's own + tokens only. + items: + maxLength: 256 + minLength: 1 + type: string + maxItems: 20 + type: array + x-kubernetes-list-type: set + apiAudience: + description: |- + APIAudience is the client's own API audience. The client may request it + even when the subject token does not cover it. It must be one of the + client's audiences. Valid only with restricted. + maxLength: 2048 + type: string + selfIssuedSubjectTokens: + description: |- + SelfIssuedSubjectTokens selects which self-issued subject tokens the + client may exchange. With restricted, a self-issued subject token must + carry the client's own client_id or one of allowedSourceClients, and a + may_act claim naming this client does not lift that restriction. With + any, the client may exchange any self-issued token. It has no default + and must be set explicitly. + enum: + - any + - restricted + type: string + required: + - selfIssuedSubjectTokens + type: object + x-kubernetes-validations: + - message: tokenExchange.allowedSourceClients and + tokenExchange.apiAudience are valid only when + selfIssuedSubjectTokens is restricted + rule: self.selfIssuedSubjectTokens == 'restricted' + || ((!has(self.allowedSourceClients) || size(self.allowedSourceClients) + == 0) && (!has(self.apiAudience) || size(self.apiAudience) + == 0)) 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 + - message: private_key_jwt requires a non-empty jwksRef + and forbids clientSecretRef; any other tokenEndpointAuthMethod + (or none) requires a non-empty clientSecretRef and + forbids jwksRef + rule: 'has(self.tokenEndpointAuthMethod) && self.tokenEndpointAuthMethod + == ''private_key_jwt'' ? (has(self.jwksRef) && !has(self.clientSecretRef)) + : (has(self.clientSecretRef) && size(self.clientSecretRef.name) + > 0 && size(self.clientSecretRef.key) > 0 && !has(self.jwksRef))' + - message: grantTypes must include urn:ietf:params:oauth:grant-type:token-exchange + when set + rule: '!has(self.grantTypes) || size(self.grantTypes) + == 0 || ''urn:ietf:params:oauth:grant-type:token-exchange'' + in self.grantTypes' + - message: redirectUris is required when grantTypes + includes authorization_code + rule: '!has(self.grantTypes) || !(''authorization_code'' + in self.grantTypes) || (has(self.redirectUris) && + size(self.redirectUris) > 0)' + - message: tokenExchange.selfIssuedSubjectTokens is + required when grantTypes is set + rule: '!has(self.grantTypes) || size(self.grantTypes) + == 0 || has(self.tokenExchange)' + - message: tokenExchange.selfIssuedSubjectTokens is + required for private_key_jwt clients + rule: '!(has(self.tokenEndpointAuthMethod) && self.tokenEndpointAuthMethod + == ''private_key_jwt'') || has(self.tokenExchange)' + - message: tokenExchange.apiAudience must be one of + the client's audiences + rule: '!has(self.tokenExchange) || !has(self.tokenExchange.apiAudience) + || size(self.tokenExchange.apiAudience) == 0 || + self.tokenExchange.apiAudience in self.audiences' + - message: tokenExchange.allowedSourceClients must not + contain the client's own clientId or the "*" wildcard + rule: '!has(self.tokenExchange) || !has(self.tokenExchange.allowedSourceClients) + || (!(self.clientId in self.tokenExchange.allowedSourceClients) + && !(''*'' in self.tokenExchange.allowedSourceClients))' + - message: redirectUris is only allowed with the authorization_code + grant + rule: '!has(self.redirectUris) || size(self.redirectUris) + == 0 || (has(self.grantTypes) && ''authorization_code'' + in self.grantTypes)' + - message: grantTypes must include authorization_code + with refresh_token + rule: '!has(self.grantTypes) || !(''refresh_token'' + in self.grantTypes) || ''authorization_code'' in + self.grantTypes' + - message: tokenEndpointAuthSigningAlg is only allowed + with private_key_jwt + rule: '!has(self.tokenEndpointAuthSigningAlg) || size(self.tokenEndpointAuthSigningAlg) + == 0 || (has(self.tokenEndpointAuthMethod) && self.tokenEndpointAuthMethod + == ''private_key_jwt'')' maxItems: 10 type: array x-kubernetes-list-type: atomic @@ -810,10 +1180,12 @@ spec: 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 + private_key_jwt registration (DCR) 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. + nothing here for cleartext HTTP to expose. Delegate clients count as + confidential whatever their tokenEndpointAuthMethod, private_key_jwt + included, because they can hold authorization_code and refresh_token grants. type: boolean insecureAllowHTTP: default: false @@ -2584,6 +2956,13 @@ spec: - message: spiffeClientAuth clientId must not be an absolute URL rule: '!has(self.inboundGrants) || !has(self.inboundGrants.spiffeClientAuth) || self.inboundGrants.spiffeClientAuth.all(client, !client.clientId.matches(''^[A-Za-z][A-Za-z0-9+.-]*://.+''))' + - message: delegateClients is deprecated and accepts only clientId, + clientSecretRef, scopes and audiences; use inboundGrants.tokenExchange.delegateClients + for jwksRef, tokenEndpointAuthMethod, grantTypes, redirectUris, + tokenExchange and tokenEndpointAuthSigningAlg + rule: '!has(self.delegateClients) || self.delegateClients.all(c, + !has(c.jwksRef) && !has(c.tokenEndpointAuthMethod) && !has(c.grantTypes) + && !has(c.redirectUris) && !has(c.tokenExchange) && !has(c.tokenEndpointAuthSigningAlg))' - message: insecureAllowHTTP cannot be combined with confidential client registration or delegateClients; client secrets would be issued or used in cleartext over an unauthenticated endpoint @@ -3403,9 +3782,12 @@ spec: description: |- 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. + plaintext secret is accepted here. The supported path is + inboundGrants.tokenExchange.delegateClients. This field shares its type + with the canonical list, but accepts only clientId, clientSecretRef, + scopes and audiences. The newer fields (tokenEndpointAuthMethod, + tokenEndpointAuthSigningAlg, jwksRef, grantTypes, redirectUris, + tokenExchange) are rejected here; set them on the canonical list. This is independent of allowConfidentialClientRegistration: it neither enables nor requires unauthenticated confidential dynamic client @@ -3413,9 +3795,10 @@ spec: This legacy field is deprecated; use inboundGrants.tokenExchange.delegateClients. 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. + DelegateClientConfig configures a pre-provisioned confidential OAuth client. + By default it is a token-exchange client (RFC 8693). Its credential is either + a client secret, referenced from a Kubernetes Secret and never represented + inline, or a public JWKS for private_key_jwt, referenced from a ConfigMap. properties: audiences: description: Audiences is the narrowed set of RFC 8707 resources @@ -3435,8 +3818,9 @@ spec: minLength: 1 type: string clientSecretRef: - description: ClientSecretRef references the Kubernetes Secret - key containing the client secret. + description: |- + ClientSecretRef references the Kubernetes Secret key containing the client secret. + Required unless tokenEndpointAuthMethod is private_key_jwt, where it is forbidden. properties: key: description: Key is the key within the secret @@ -3448,6 +3832,62 @@ spec: - key - name type: object + grantTypes: + description: |- + GrantTypes are the grants this client may use. If omitted or empty, the + client is token-exchange only. When set, the list must include + urn:ietf:params:oauth:grant-type:token-exchange: a delegate client is a + token-exchange client that may also log in or redeem other grants. When set, + the list is complete: include urn:ietf:params:oauth:grant-type:jwt-bearer to + keep ID-JAG redemption. + items: + enum: + - authorization_code + - refresh_token + - urn:ietf:params:oauth:grant-type:token-exchange + - urn:ietf:params:oauth:grant-type:jwt-bearer + type: string + maxItems: 4 + type: array + x-kubernetes-list-type: set + jwksRef: + description: |- + JWKSRef references the ConfigMap key holding the client's public JWKS + document. Required with private_key_jwt and forbidden otherwise. The + document must contain public keys only. + properties: + key: + description: Key is the key within the ConfigMap + minLength: 1 + type: string + name: + description: Name is the name of the ConfigMap + minLength: 1 + type: string + required: + - key + - name + type: object + redirectUris: + description: |- + RedirectURIs are the registered redirect URIs. They are required with the + authorization_code grant and forbidden without it. They sit here, under + tokenExchange.delegateClients, because a delegate client is a + token-exchange client that may also log in (authorization_code). + + The pattern is stricter than the runtime for uppercase "HTTPS://": the + runtime accepts it, the pattern does not. The scheme check is + case-sensitive, as in forceConfidentialRedirectUris. Query strings and + fragments behave the same as the runtime: a query string is allowed, and + a fragment is rejected. + items: + maxLength: 2048 + minLength: 1 + pattern: ^https://[^\s#]+$ + type: string + maxItems: 10 + type: array + x-kubernetes-list-type: set scopes: description: Scopes is the narrowed set of OAuth scopes this client may request. @@ -3459,17 +3899,141 @@ spec: minItems: 1 type: array x-kubernetes-list-type: atomic + tokenEndpointAuthMethod: + description: |- + TokenEndpointAuthMethod pins how the client authenticates at the token + endpoint. If omitted, the client authenticates with its secret by either + HTTP Basic or form body. + enum: + - client_secret_basic + - client_secret_post + - private_key_jwt + type: string + tokenEndpointAuthSigningAlg: + description: |- + TokenEndpointAuthSigningAlg is the private_key_jwt signing algorithm, + for example RS256 or ES256. If omitted, it is derived from the keys: + each key's alg, or ES256, ES384 or ES512 from an EC key's curve. RSA + keys without alg use RS256. Keys that disagree must set it. + enum: + - RS256 + - RS384 + - RS512 + - PS256 + - PS384 + - PS512 + - ES256 + - ES384 + - ES512 + type: string + tokenExchange: + description: |- + TokenExchange is the client's token-exchange policy. Its + selfIssuedSubjectTokens field is required whenever the block is present. + The block may be omitted only by a secret-authenticated client without + grantTypes; such a client keeps the legacy rule (any) and may exchange any + self-issued token. A client that sets grantTypes or uses private_key_jwt + must declare the block. + properties: + allowedSourceClients: + description: |- + AllowedSourceClients are client_id values, as they appear in the subject + token's client_id claim, whose self-issued tokens this client may + exchange in addition to its own. They must be user-facing clients that + register at runtime (DCR or CIMD). A configured delegate client cannot be + listed here. Valid only with restricted; empty means the client's own + tokens only. + items: + maxLength: 256 + minLength: 1 + type: string + maxItems: 20 + type: array + x-kubernetes-list-type: set + apiAudience: + description: |- + APIAudience is the client's own API audience. The client may request it + even when the subject token does not cover it. It must be one of the + client's audiences. Valid only with restricted. + maxLength: 2048 + type: string + selfIssuedSubjectTokens: + description: |- + SelfIssuedSubjectTokens selects which self-issued subject tokens the + client may exchange. With restricted, a self-issued subject token must + carry the client's own client_id or one of allowedSourceClients, and a + may_act claim naming this client does not lift that restriction. With + any, the client may exchange any self-issued token. It has no default + and must be set explicitly. + enum: + - any + - restricted + type: string + required: + - selfIssuedSubjectTokens + type: object + x-kubernetes-validations: + - message: tokenExchange.allowedSourceClients and tokenExchange.apiAudience + are valid only when selfIssuedSubjectTokens is restricted + rule: self.selfIssuedSubjectTokens == 'restricted' || + ((!has(self.allowedSourceClients) || size(self.allowedSourceClients) + == 0) && (!has(self.apiAudience) || size(self.apiAudience) + == 0)) 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 + - message: private_key_jwt requires a non-empty jwksRef and + forbids clientSecretRef; any other tokenEndpointAuthMethod + (or none) requires a non-empty clientSecretRef and forbids + jwksRef + rule: 'has(self.tokenEndpointAuthMethod) && self.tokenEndpointAuthMethod + == ''private_key_jwt'' ? (has(self.jwksRef) && !has(self.clientSecretRef)) + : (has(self.clientSecretRef) && size(self.clientSecretRef.name) + > 0 && size(self.clientSecretRef.key) > 0 && !has(self.jwksRef))' + - message: grantTypes must include urn:ietf:params:oauth:grant-type:token-exchange + when set + rule: '!has(self.grantTypes) || size(self.grantTypes) == 0 + || ''urn:ietf:params:oauth:grant-type:token-exchange'' in + self.grantTypes' + - message: redirectUris is required when grantTypes includes + authorization_code + rule: '!has(self.grantTypes) || !(''authorization_code'' in + self.grantTypes) || (has(self.redirectUris) && size(self.redirectUris) + > 0)' + - message: tokenExchange.selfIssuedSubjectTokens is required + when grantTypes is set + rule: '!has(self.grantTypes) || size(self.grantTypes) == 0 + || has(self.tokenExchange)' + - message: tokenExchange.selfIssuedSubjectTokens is required + for private_key_jwt clients + rule: '!(has(self.tokenEndpointAuthMethod) && self.tokenEndpointAuthMethod + == ''private_key_jwt'') || has(self.tokenExchange)' + - message: tokenExchange.apiAudience must be one of the client's + audiences + rule: '!has(self.tokenExchange) || !has(self.tokenExchange.apiAudience) + || size(self.tokenExchange.apiAudience) == 0 || self.tokenExchange.apiAudience + in self.audiences' + - message: tokenExchange.allowedSourceClients must not contain + the client's own clientId or the "*" wildcard + rule: '!has(self.tokenExchange) || !has(self.tokenExchange.allowedSourceClients) + || (!(self.clientId in self.tokenExchange.allowedSourceClients) + && !(''*'' in self.tokenExchange.allowedSourceClients))' + - message: redirectUris is only allowed with the authorization_code + grant + rule: '!has(self.redirectUris) || size(self.redirectUris) + == 0 || (has(self.grantTypes) && ''authorization_code'' + in self.grantTypes)' + - message: grantTypes must include authorization_code with refresh_token + rule: '!has(self.grantTypes) || !(''refresh_token'' in self.grantTypes) + || ''authorization_code'' in self.grantTypes' + - message: tokenEndpointAuthSigningAlg is only allowed with + private_key_jwt + rule: '!has(self.tokenEndpointAuthSigningAlg) || size(self.tokenEndpointAuthSigningAlg) + == 0 || (has(self.tokenEndpointAuthMethod) && self.tokenEndpointAuthMethod + == ''private_key_jwt'')' maxItems: 10 type: array x-kubernetes-list-type: atomic @@ -3758,9 +4322,10 @@ spec: 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. + DelegateClientConfig configures a pre-provisioned confidential OAuth client. + By default it is a token-exchange client (RFC 8693). Its credential is either + a client secret, referenced from a Kubernetes Secret and never represented + inline, or a public JWKS for private_key_jwt, referenced from a ConfigMap. properties: audiences: description: Audiences is the narrowed set of RFC @@ -3780,8 +4345,9 @@ spec: minLength: 1 type: string clientSecretRef: - description: ClientSecretRef references the Kubernetes - Secret key containing the client secret. + description: |- + ClientSecretRef references the Kubernetes Secret key containing the client secret. + Required unless tokenEndpointAuthMethod is private_key_jwt, where it is forbidden. properties: key: description: Key is the key within the secret @@ -3793,6 +4359,62 @@ spec: - key - name type: object + grantTypes: + description: |- + GrantTypes are the grants this client may use. If omitted or empty, the + client is token-exchange only. When set, the list must include + urn:ietf:params:oauth:grant-type:token-exchange: a delegate client is a + token-exchange client that may also log in or redeem other grants. When set, + the list is complete: include urn:ietf:params:oauth:grant-type:jwt-bearer to + keep ID-JAG redemption. + items: + enum: + - authorization_code + - refresh_token + - urn:ietf:params:oauth:grant-type:token-exchange + - urn:ietf:params:oauth:grant-type:jwt-bearer + type: string + maxItems: 4 + type: array + x-kubernetes-list-type: set + jwksRef: + description: |- + JWKSRef references the ConfigMap key holding the client's public JWKS + document. Required with private_key_jwt and forbidden otherwise. The + document must contain public keys only. + properties: + key: + description: Key is the key within the ConfigMap + minLength: 1 + type: string + name: + description: Name is the name of the ConfigMap + minLength: 1 + type: string + required: + - key + - name + type: object + redirectUris: + description: |- + RedirectURIs are the registered redirect URIs. They are required with the + authorization_code grant and forbidden without it. They sit here, under + tokenExchange.delegateClients, because a delegate client is a + token-exchange client that may also log in (authorization_code). + + The pattern is stricter than the runtime for uppercase "HTTPS://": the + runtime accepts it, the pattern does not. The scheme check is + case-sensitive, as in forceConfidentialRedirectUris. Query strings and + fragments behave the same as the runtime: a query string is allowed, and + a fragment is rejected. + items: + maxLength: 2048 + minLength: 1 + pattern: ^https://[^\s#]+$ + type: string + maxItems: 10 + type: array + x-kubernetes-list-type: set scopes: description: Scopes is the narrowed set of OAuth scopes this client may request. @@ -3804,17 +4426,144 @@ spec: minItems: 1 type: array x-kubernetes-list-type: atomic + tokenEndpointAuthMethod: + description: |- + TokenEndpointAuthMethod pins how the client authenticates at the token + endpoint. If omitted, the client authenticates with its secret by either + HTTP Basic or form body. + enum: + - client_secret_basic + - client_secret_post + - private_key_jwt + type: string + tokenEndpointAuthSigningAlg: + description: |- + TokenEndpointAuthSigningAlg is the private_key_jwt signing algorithm, + for example RS256 or ES256. If omitted, it is derived from the keys: + each key's alg, or ES256, ES384 or ES512 from an EC key's curve. RSA + keys without alg use RS256. Keys that disagree must set it. + enum: + - RS256 + - RS384 + - RS512 + - PS256 + - PS384 + - PS512 + - ES256 + - ES384 + - ES512 + type: string + tokenExchange: + description: |- + TokenExchange is the client's token-exchange policy. Its + selfIssuedSubjectTokens field is required whenever the block is present. + The block may be omitted only by a secret-authenticated client without + grantTypes; such a client keeps the legacy rule (any) and may exchange any + self-issued token. A client that sets grantTypes or uses private_key_jwt + must declare the block. + properties: + allowedSourceClients: + description: |- + AllowedSourceClients are client_id values, as they appear in the subject + token's client_id claim, whose self-issued tokens this client may + exchange in addition to its own. They must be user-facing clients that + register at runtime (DCR or CIMD). A configured delegate client cannot be + listed here. Valid only with restricted; empty means the client's own + tokens only. + items: + maxLength: 256 + minLength: 1 + type: string + maxItems: 20 + type: array + x-kubernetes-list-type: set + apiAudience: + description: |- + APIAudience is the client's own API audience. The client may request it + even when the subject token does not cover it. It must be one of the + client's audiences. Valid only with restricted. + maxLength: 2048 + type: string + selfIssuedSubjectTokens: + description: |- + SelfIssuedSubjectTokens selects which self-issued subject tokens the + client may exchange. With restricted, a self-issued subject token must + carry the client's own client_id or one of allowedSourceClients, and a + may_act claim naming this client does not lift that restriction. With + any, the client may exchange any self-issued token. It has no default + and must be set explicitly. + enum: + - any + - restricted + type: string + required: + - selfIssuedSubjectTokens + type: object + x-kubernetes-validations: + - message: tokenExchange.allowedSourceClients and + tokenExchange.apiAudience are valid only when + selfIssuedSubjectTokens is restricted + rule: self.selfIssuedSubjectTokens == 'restricted' + || ((!has(self.allowedSourceClients) || size(self.allowedSourceClients) + == 0) && (!has(self.apiAudience) || size(self.apiAudience) + == 0)) 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 + - message: private_key_jwt requires a non-empty jwksRef + and forbids clientSecretRef; any other tokenEndpointAuthMethod + (or none) requires a non-empty clientSecretRef and + forbids jwksRef + rule: 'has(self.tokenEndpointAuthMethod) && self.tokenEndpointAuthMethod + == ''private_key_jwt'' ? (has(self.jwksRef) && !has(self.clientSecretRef)) + : (has(self.clientSecretRef) && size(self.clientSecretRef.name) + > 0 && size(self.clientSecretRef.key) > 0 && !has(self.jwksRef))' + - message: grantTypes must include urn:ietf:params:oauth:grant-type:token-exchange + when set + rule: '!has(self.grantTypes) || size(self.grantTypes) + == 0 || ''urn:ietf:params:oauth:grant-type:token-exchange'' + in self.grantTypes' + - message: redirectUris is required when grantTypes + includes authorization_code + rule: '!has(self.grantTypes) || !(''authorization_code'' + in self.grantTypes) || (has(self.redirectUris) && + size(self.redirectUris) > 0)' + - message: tokenExchange.selfIssuedSubjectTokens is + required when grantTypes is set + rule: '!has(self.grantTypes) || size(self.grantTypes) + == 0 || has(self.tokenExchange)' + - message: tokenExchange.selfIssuedSubjectTokens is + required for private_key_jwt clients + rule: '!(has(self.tokenEndpointAuthMethod) && self.tokenEndpointAuthMethod + == ''private_key_jwt'') || has(self.tokenExchange)' + - message: tokenExchange.apiAudience must be one of + the client's audiences + rule: '!has(self.tokenExchange) || !has(self.tokenExchange.apiAudience) + || size(self.tokenExchange.apiAudience) == 0 || + self.tokenExchange.apiAudience in self.audiences' + - message: tokenExchange.allowedSourceClients must not + contain the client's own clientId or the "*" wildcard + rule: '!has(self.tokenExchange) || !has(self.tokenExchange.allowedSourceClients) + || (!(self.clientId in self.tokenExchange.allowedSourceClients) + && !(''*'' in self.tokenExchange.allowedSourceClients))' + - message: redirectUris is only allowed with the authorization_code + grant + rule: '!has(self.redirectUris) || size(self.redirectUris) + == 0 || (has(self.grantTypes) && ''authorization_code'' + in self.grantTypes)' + - message: grantTypes must include authorization_code + with refresh_token + rule: '!has(self.grantTypes) || !(''refresh_token'' + in self.grantTypes) || ''authorization_code'' in + self.grantTypes' + - message: tokenEndpointAuthSigningAlg is only allowed + with private_key_jwt + rule: '!has(self.tokenEndpointAuthSigningAlg) || size(self.tokenEndpointAuthSigningAlg) + == 0 || (has(self.tokenEndpointAuthMethod) && self.tokenEndpointAuthMethod + == ''private_key_jwt'')' maxItems: 10 type: array x-kubernetes-list-type: atomic @@ -3903,10 +4652,12 @@ spec: 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 + private_key_jwt registration (DCR) 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. + nothing here for cleartext HTTP to expose. Delegate clients count as + confidential whatever their tokenEndpointAuthMethod, private_key_jwt + included, because they can hold authorization_code and refresh_token grants. type: boolean insecureAllowHTTP: default: false @@ -5677,6 +6428,13 @@ spec: - message: spiffeClientAuth clientId must not be an absolute URL rule: '!has(self.inboundGrants) || !has(self.inboundGrants.spiffeClientAuth) || self.inboundGrants.spiffeClientAuth.all(client, !client.clientId.matches(''^[A-Za-z][A-Za-z0-9+.-]*://.+''))' + - message: delegateClients is deprecated and accepts only clientId, + clientSecretRef, scopes and audiences; use inboundGrants.tokenExchange.delegateClients + for jwksRef, tokenEndpointAuthMethod, grantTypes, redirectUris, + tokenExchange and tokenEndpointAuthSigningAlg + rule: '!has(self.delegateClients) || self.delegateClients.all(c, + !has(c.jwksRef) && !has(c.tokenEndpointAuthMethod) && !has(c.grantTypes) + && !has(c.redirectUris) && !has(c.tokenExchange) && !has(c.tokenEndpointAuthSigningAlg))' - message: insecureAllowHTTP cannot be combined with confidential client registration or delegateClients; client secrets would be issued or used in cleartext over an unauthenticated endpoint 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 ca48bf3909..1ae4e5a6d8 100644 --- a/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_virtualmcpservers.yaml +++ b/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_virtualmcpservers.yaml @@ -197,9 +197,12 @@ spec: description: |- 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. + plaintext secret is accepted here. The supported path is + inboundGrants.tokenExchange.delegateClients. This field shares its type + with the canonical list, but accepts only clientId, clientSecretRef, + scopes and audiences. The newer fields (tokenEndpointAuthMethod, + tokenEndpointAuthSigningAlg, jwksRef, grantTypes, redirectUris, + tokenExchange) are rejected here; set them on the canonical list. This is independent of allowConfidentialClientRegistration: it neither enables nor requires unauthenticated confidential dynamic client @@ -207,9 +210,10 @@ spec: This legacy field is deprecated; use inboundGrants.tokenExchange.delegateClients. 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. + DelegateClientConfig configures a pre-provisioned confidential OAuth client. + By default it is a token-exchange client (RFC 8693). Its credential is either + a client secret, referenced from a Kubernetes Secret and never represented + inline, or a public JWKS for private_key_jwt, referenced from a ConfigMap. properties: audiences: description: Audiences is the narrowed set of RFC 8707 resources @@ -229,8 +233,9 @@ spec: minLength: 1 type: string clientSecretRef: - description: ClientSecretRef references the Kubernetes Secret - key containing the client secret. + description: |- + ClientSecretRef references the Kubernetes Secret key containing the client secret. + Required unless tokenEndpointAuthMethod is private_key_jwt, where it is forbidden. properties: key: description: Key is the key within the secret @@ -242,6 +247,62 @@ spec: - key - name type: object + grantTypes: + description: |- + GrantTypes are the grants this client may use. If omitted or empty, the + client is token-exchange only. When set, the list must include + urn:ietf:params:oauth:grant-type:token-exchange: a delegate client is a + token-exchange client that may also log in or redeem other grants. When set, + the list is complete: include urn:ietf:params:oauth:grant-type:jwt-bearer to + keep ID-JAG redemption. + items: + enum: + - authorization_code + - refresh_token + - urn:ietf:params:oauth:grant-type:token-exchange + - urn:ietf:params:oauth:grant-type:jwt-bearer + type: string + maxItems: 4 + type: array + x-kubernetes-list-type: set + jwksRef: + description: |- + JWKSRef references the ConfigMap key holding the client's public JWKS + document. Required with private_key_jwt and forbidden otherwise. The + document must contain public keys only. + properties: + key: + description: Key is the key within the ConfigMap + minLength: 1 + type: string + name: + description: Name is the name of the ConfigMap + minLength: 1 + type: string + required: + - key + - name + type: object + redirectUris: + description: |- + RedirectURIs are the registered redirect URIs. They are required with the + authorization_code grant and forbidden without it. They sit here, under + tokenExchange.delegateClients, because a delegate client is a + token-exchange client that may also log in (authorization_code). + + The pattern is stricter than the runtime for uppercase "HTTPS://": the + runtime accepts it, the pattern does not. The scheme check is + case-sensitive, as in forceConfidentialRedirectUris. Query strings and + fragments behave the same as the runtime: a query string is allowed, and + a fragment is rejected. + items: + maxLength: 2048 + minLength: 1 + pattern: ^https://[^\s#]+$ + type: string + maxItems: 10 + type: array + x-kubernetes-list-type: set scopes: description: Scopes is the narrowed set of OAuth scopes this client may request. @@ -253,17 +314,141 @@ spec: minItems: 1 type: array x-kubernetes-list-type: atomic + tokenEndpointAuthMethod: + description: |- + TokenEndpointAuthMethod pins how the client authenticates at the token + endpoint. If omitted, the client authenticates with its secret by either + HTTP Basic or form body. + enum: + - client_secret_basic + - client_secret_post + - private_key_jwt + type: string + tokenEndpointAuthSigningAlg: + description: |- + TokenEndpointAuthSigningAlg is the private_key_jwt signing algorithm, + for example RS256 or ES256. If omitted, it is derived from the keys: + each key's alg, or ES256, ES384 or ES512 from an EC key's curve. RSA + keys without alg use RS256. Keys that disagree must set it. + enum: + - RS256 + - RS384 + - RS512 + - PS256 + - PS384 + - PS512 + - ES256 + - ES384 + - ES512 + type: string + tokenExchange: + description: |- + TokenExchange is the client's token-exchange policy. Its + selfIssuedSubjectTokens field is required whenever the block is present. + The block may be omitted only by a secret-authenticated client without + grantTypes; such a client keeps the legacy rule (any) and may exchange any + self-issued token. A client that sets grantTypes or uses private_key_jwt + must declare the block. + properties: + allowedSourceClients: + description: |- + AllowedSourceClients are client_id values, as they appear in the subject + token's client_id claim, whose self-issued tokens this client may + exchange in addition to its own. They must be user-facing clients that + register at runtime (DCR or CIMD). A configured delegate client cannot be + listed here. Valid only with restricted; empty means the client's own + tokens only. + items: + maxLength: 256 + minLength: 1 + type: string + maxItems: 20 + type: array + x-kubernetes-list-type: set + apiAudience: + description: |- + APIAudience is the client's own API audience. The client may request it + even when the subject token does not cover it. It must be one of the + client's audiences. Valid only with restricted. + maxLength: 2048 + type: string + selfIssuedSubjectTokens: + description: |- + SelfIssuedSubjectTokens selects which self-issued subject tokens the + client may exchange. With restricted, a self-issued subject token must + carry the client's own client_id or one of allowedSourceClients, and a + may_act claim naming this client does not lift that restriction. With + any, the client may exchange any self-issued token. It has no default + and must be set explicitly. + enum: + - any + - restricted + type: string + required: + - selfIssuedSubjectTokens + type: object + x-kubernetes-validations: + - message: tokenExchange.allowedSourceClients and tokenExchange.apiAudience + are valid only when selfIssuedSubjectTokens is restricted + rule: self.selfIssuedSubjectTokens == 'restricted' || + ((!has(self.allowedSourceClients) || size(self.allowedSourceClients) + == 0) && (!has(self.apiAudience) || size(self.apiAudience) + == 0)) 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 + - message: private_key_jwt requires a non-empty jwksRef and + forbids clientSecretRef; any other tokenEndpointAuthMethod + (or none) requires a non-empty clientSecretRef and forbids + jwksRef + rule: 'has(self.tokenEndpointAuthMethod) && self.tokenEndpointAuthMethod + == ''private_key_jwt'' ? (has(self.jwksRef) && !has(self.clientSecretRef)) + : (has(self.clientSecretRef) && size(self.clientSecretRef.name) + > 0 && size(self.clientSecretRef.key) > 0 && !has(self.jwksRef))' + - message: grantTypes must include urn:ietf:params:oauth:grant-type:token-exchange + when set + rule: '!has(self.grantTypes) || size(self.grantTypes) == 0 + || ''urn:ietf:params:oauth:grant-type:token-exchange'' in + self.grantTypes' + - message: redirectUris is required when grantTypes includes + authorization_code + rule: '!has(self.grantTypes) || !(''authorization_code'' in + self.grantTypes) || (has(self.redirectUris) && size(self.redirectUris) + > 0)' + - message: tokenExchange.selfIssuedSubjectTokens is required + when grantTypes is set + rule: '!has(self.grantTypes) || size(self.grantTypes) == 0 + || has(self.tokenExchange)' + - message: tokenExchange.selfIssuedSubjectTokens is required + for private_key_jwt clients + rule: '!(has(self.tokenEndpointAuthMethod) && self.tokenEndpointAuthMethod + == ''private_key_jwt'') || has(self.tokenExchange)' + - message: tokenExchange.apiAudience must be one of the client's + audiences + rule: '!has(self.tokenExchange) || !has(self.tokenExchange.apiAudience) + || size(self.tokenExchange.apiAudience) == 0 || self.tokenExchange.apiAudience + in self.audiences' + - message: tokenExchange.allowedSourceClients must not contain + the client's own clientId or the "*" wildcard + rule: '!has(self.tokenExchange) || !has(self.tokenExchange.allowedSourceClients) + || (!(self.clientId in self.tokenExchange.allowedSourceClients) + && !(''*'' in self.tokenExchange.allowedSourceClients))' + - message: redirectUris is only allowed with the authorization_code + grant + rule: '!has(self.redirectUris) || size(self.redirectUris) + == 0 || (has(self.grantTypes) && ''authorization_code'' + in self.grantTypes)' + - message: grantTypes must include authorization_code with refresh_token + rule: '!has(self.grantTypes) || !(''refresh_token'' in self.grantTypes) + || ''authorization_code'' in self.grantTypes' + - message: tokenEndpointAuthSigningAlg is only allowed with + private_key_jwt + rule: '!has(self.tokenEndpointAuthSigningAlg) || size(self.tokenEndpointAuthSigningAlg) + == 0 || (has(self.tokenEndpointAuthMethod) && self.tokenEndpointAuthMethod + == ''private_key_jwt'')' maxItems: 10 type: array x-kubernetes-list-type: atomic @@ -552,9 +737,10 @@ spec: 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. + DelegateClientConfig configures a pre-provisioned confidential OAuth client. + By default it is a token-exchange client (RFC 8693). Its credential is either + a client secret, referenced from a Kubernetes Secret and never represented + inline, or a public JWKS for private_key_jwt, referenced from a ConfigMap. properties: audiences: description: Audiences is the narrowed set of RFC @@ -574,8 +760,9 @@ spec: minLength: 1 type: string clientSecretRef: - description: ClientSecretRef references the Kubernetes - Secret key containing the client secret. + description: |- + ClientSecretRef references the Kubernetes Secret key containing the client secret. + Required unless tokenEndpointAuthMethod is private_key_jwt, where it is forbidden. properties: key: description: Key is the key within the secret @@ -587,6 +774,62 @@ spec: - key - name type: object + grantTypes: + description: |- + GrantTypes are the grants this client may use. If omitted or empty, the + client is token-exchange only. When set, the list must include + urn:ietf:params:oauth:grant-type:token-exchange: a delegate client is a + token-exchange client that may also log in or redeem other grants. When set, + the list is complete: include urn:ietf:params:oauth:grant-type:jwt-bearer to + keep ID-JAG redemption. + items: + enum: + - authorization_code + - refresh_token + - urn:ietf:params:oauth:grant-type:token-exchange + - urn:ietf:params:oauth:grant-type:jwt-bearer + type: string + maxItems: 4 + type: array + x-kubernetes-list-type: set + jwksRef: + description: |- + JWKSRef references the ConfigMap key holding the client's public JWKS + document. Required with private_key_jwt and forbidden otherwise. The + document must contain public keys only. + properties: + key: + description: Key is the key within the ConfigMap + minLength: 1 + type: string + name: + description: Name is the name of the ConfigMap + minLength: 1 + type: string + required: + - key + - name + type: object + redirectUris: + description: |- + RedirectURIs are the registered redirect URIs. They are required with the + authorization_code grant and forbidden without it. They sit here, under + tokenExchange.delegateClients, because a delegate client is a + token-exchange client that may also log in (authorization_code). + + The pattern is stricter than the runtime for uppercase "HTTPS://": the + runtime accepts it, the pattern does not. The scheme check is + case-sensitive, as in forceConfidentialRedirectUris. Query strings and + fragments behave the same as the runtime: a query string is allowed, and + a fragment is rejected. + items: + maxLength: 2048 + minLength: 1 + pattern: ^https://[^\s#]+$ + type: string + maxItems: 10 + type: array + x-kubernetes-list-type: set scopes: description: Scopes is the narrowed set of OAuth scopes this client may request. @@ -598,17 +841,144 @@ spec: minItems: 1 type: array x-kubernetes-list-type: atomic + tokenEndpointAuthMethod: + description: |- + TokenEndpointAuthMethod pins how the client authenticates at the token + endpoint. If omitted, the client authenticates with its secret by either + HTTP Basic or form body. + enum: + - client_secret_basic + - client_secret_post + - private_key_jwt + type: string + tokenEndpointAuthSigningAlg: + description: |- + TokenEndpointAuthSigningAlg is the private_key_jwt signing algorithm, + for example RS256 or ES256. If omitted, it is derived from the keys: + each key's alg, or ES256, ES384 or ES512 from an EC key's curve. RSA + keys without alg use RS256. Keys that disagree must set it. + enum: + - RS256 + - RS384 + - RS512 + - PS256 + - PS384 + - PS512 + - ES256 + - ES384 + - ES512 + type: string + tokenExchange: + description: |- + TokenExchange is the client's token-exchange policy. Its + selfIssuedSubjectTokens field is required whenever the block is present. + The block may be omitted only by a secret-authenticated client without + grantTypes; such a client keeps the legacy rule (any) and may exchange any + self-issued token. A client that sets grantTypes or uses private_key_jwt + must declare the block. + properties: + allowedSourceClients: + description: |- + AllowedSourceClients are client_id values, as they appear in the subject + token's client_id claim, whose self-issued tokens this client may + exchange in addition to its own. They must be user-facing clients that + register at runtime (DCR or CIMD). A configured delegate client cannot be + listed here. Valid only with restricted; empty means the client's own + tokens only. + items: + maxLength: 256 + minLength: 1 + type: string + maxItems: 20 + type: array + x-kubernetes-list-type: set + apiAudience: + description: |- + APIAudience is the client's own API audience. The client may request it + even when the subject token does not cover it. It must be one of the + client's audiences. Valid only with restricted. + maxLength: 2048 + type: string + selfIssuedSubjectTokens: + description: |- + SelfIssuedSubjectTokens selects which self-issued subject tokens the + client may exchange. With restricted, a self-issued subject token must + carry the client's own client_id or one of allowedSourceClients, and a + may_act claim naming this client does not lift that restriction. With + any, the client may exchange any self-issued token. It has no default + and must be set explicitly. + enum: + - any + - restricted + type: string + required: + - selfIssuedSubjectTokens + type: object + x-kubernetes-validations: + - message: tokenExchange.allowedSourceClients and + tokenExchange.apiAudience are valid only when + selfIssuedSubjectTokens is restricted + rule: self.selfIssuedSubjectTokens == 'restricted' + || ((!has(self.allowedSourceClients) || size(self.allowedSourceClients) + == 0) && (!has(self.apiAudience) || size(self.apiAudience) + == 0)) 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 + - message: private_key_jwt requires a non-empty jwksRef + and forbids clientSecretRef; any other tokenEndpointAuthMethod + (or none) requires a non-empty clientSecretRef and + forbids jwksRef + rule: 'has(self.tokenEndpointAuthMethod) && self.tokenEndpointAuthMethod + == ''private_key_jwt'' ? (has(self.jwksRef) && !has(self.clientSecretRef)) + : (has(self.clientSecretRef) && size(self.clientSecretRef.name) + > 0 && size(self.clientSecretRef.key) > 0 && !has(self.jwksRef))' + - message: grantTypes must include urn:ietf:params:oauth:grant-type:token-exchange + when set + rule: '!has(self.grantTypes) || size(self.grantTypes) + == 0 || ''urn:ietf:params:oauth:grant-type:token-exchange'' + in self.grantTypes' + - message: redirectUris is required when grantTypes + includes authorization_code + rule: '!has(self.grantTypes) || !(''authorization_code'' + in self.grantTypes) || (has(self.redirectUris) && + size(self.redirectUris) > 0)' + - message: tokenExchange.selfIssuedSubjectTokens is + required when grantTypes is set + rule: '!has(self.grantTypes) || size(self.grantTypes) + == 0 || has(self.tokenExchange)' + - message: tokenExchange.selfIssuedSubjectTokens is + required for private_key_jwt clients + rule: '!(has(self.tokenEndpointAuthMethod) && self.tokenEndpointAuthMethod + == ''private_key_jwt'') || has(self.tokenExchange)' + - message: tokenExchange.apiAudience must be one of + the client's audiences + rule: '!has(self.tokenExchange) || !has(self.tokenExchange.apiAudience) + || size(self.tokenExchange.apiAudience) == 0 || + self.tokenExchange.apiAudience in self.audiences' + - message: tokenExchange.allowedSourceClients must not + contain the client's own clientId or the "*" wildcard + rule: '!has(self.tokenExchange) || !has(self.tokenExchange.allowedSourceClients) + || (!(self.clientId in self.tokenExchange.allowedSourceClients) + && !(''*'' in self.tokenExchange.allowedSourceClients))' + - message: redirectUris is only allowed with the authorization_code + grant + rule: '!has(self.redirectUris) || size(self.redirectUris) + == 0 || (has(self.grantTypes) && ''authorization_code'' + in self.grantTypes)' + - message: grantTypes must include authorization_code + with refresh_token + rule: '!has(self.grantTypes) || !(''refresh_token'' + in self.grantTypes) || ''authorization_code'' in + self.grantTypes' + - message: tokenEndpointAuthSigningAlg is only allowed + with private_key_jwt + rule: '!has(self.tokenEndpointAuthSigningAlg) || size(self.tokenEndpointAuthSigningAlg) + == 0 || (has(self.tokenEndpointAuthMethod) && self.tokenEndpointAuthMethod + == ''private_key_jwt'')' maxItems: 10 type: array x-kubernetes-list-type: atomic @@ -697,10 +1067,12 @@ spec: 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 + private_key_jwt registration (DCR) 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. + nothing here for cleartext HTTP to expose. Delegate clients count as + confidential whatever their tokenEndpointAuthMethod, private_key_jwt + included, because they can hold authorization_code and refresh_token grants. type: boolean insecureAllowHTTP: default: false @@ -2471,6 +2843,13 @@ spec: - message: spiffeClientAuth clientId must not be an absolute URL rule: '!has(self.inboundGrants) || !has(self.inboundGrants.spiffeClientAuth) || self.inboundGrants.spiffeClientAuth.all(client, !client.clientId.matches(''^[A-Za-z][A-Za-z0-9+.-]*://.+''))' + - message: delegateClients is deprecated and accepts only clientId, + clientSecretRef, scopes and audiences; use inboundGrants.tokenExchange.delegateClients + for jwksRef, tokenEndpointAuthMethod, grantTypes, redirectUris, + tokenExchange and tokenEndpointAuthSigningAlg + rule: '!has(self.delegateClients) || self.delegateClients.all(c, + !has(c.jwksRef) && !has(c.tokenEndpointAuthMethod) && !has(c.grantTypes) + && !has(c.redirectUris) && !has(c.tokenExchange) && !has(c.tokenEndpointAuthSigningAlg))' - message: insecureAllowHTTP cannot be combined with confidential client registration or delegateClients; client secrets would be issued or used in cleartext over an unauthenticated endpoint @@ -5682,9 +6061,12 @@ spec: description: |- 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. + plaintext secret is accepted here. The supported path is + inboundGrants.tokenExchange.delegateClients. This field shares its type + with the canonical list, but accepts only clientId, clientSecretRef, + scopes and audiences. The newer fields (tokenEndpointAuthMethod, + tokenEndpointAuthSigningAlg, jwksRef, grantTypes, redirectUris, + tokenExchange) are rejected here; set them on the canonical list. This is independent of allowConfidentialClientRegistration: it neither enables nor requires unauthenticated confidential dynamic client @@ -5692,9 +6074,10 @@ spec: This legacy field is deprecated; use inboundGrants.tokenExchange.delegateClients. 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. + DelegateClientConfig configures a pre-provisioned confidential OAuth client. + By default it is a token-exchange client (RFC 8693). Its credential is either + a client secret, referenced from a Kubernetes Secret and never represented + inline, or a public JWKS for private_key_jwt, referenced from a ConfigMap. properties: audiences: description: Audiences is the narrowed set of RFC 8707 resources @@ -5714,8 +6097,9 @@ spec: minLength: 1 type: string clientSecretRef: - description: ClientSecretRef references the Kubernetes Secret - key containing the client secret. + description: |- + ClientSecretRef references the Kubernetes Secret key containing the client secret. + Required unless tokenEndpointAuthMethod is private_key_jwt, where it is forbidden. properties: key: description: Key is the key within the secret @@ -5727,6 +6111,62 @@ spec: - key - name type: object + grantTypes: + description: |- + GrantTypes are the grants this client may use. If omitted or empty, the + client is token-exchange only. When set, the list must include + urn:ietf:params:oauth:grant-type:token-exchange: a delegate client is a + token-exchange client that may also log in or redeem other grants. When set, + the list is complete: include urn:ietf:params:oauth:grant-type:jwt-bearer to + keep ID-JAG redemption. + items: + enum: + - authorization_code + - refresh_token + - urn:ietf:params:oauth:grant-type:token-exchange + - urn:ietf:params:oauth:grant-type:jwt-bearer + type: string + maxItems: 4 + type: array + x-kubernetes-list-type: set + jwksRef: + description: |- + JWKSRef references the ConfigMap key holding the client's public JWKS + document. Required with private_key_jwt and forbidden otherwise. The + document must contain public keys only. + properties: + key: + description: Key is the key within the ConfigMap + minLength: 1 + type: string + name: + description: Name is the name of the ConfigMap + minLength: 1 + type: string + required: + - key + - name + type: object + redirectUris: + description: |- + RedirectURIs are the registered redirect URIs. They are required with the + authorization_code grant and forbidden without it. They sit here, under + tokenExchange.delegateClients, because a delegate client is a + token-exchange client that may also log in (authorization_code). + + The pattern is stricter than the runtime for uppercase "HTTPS://": the + runtime accepts it, the pattern does not. The scheme check is + case-sensitive, as in forceConfidentialRedirectUris. Query strings and + fragments behave the same as the runtime: a query string is allowed, and + a fragment is rejected. + items: + maxLength: 2048 + minLength: 1 + pattern: ^https://[^\s#]+$ + type: string + maxItems: 10 + type: array + x-kubernetes-list-type: set scopes: description: Scopes is the narrowed set of OAuth scopes this client may request. @@ -5738,17 +6178,141 @@ spec: minItems: 1 type: array x-kubernetes-list-type: atomic + tokenEndpointAuthMethod: + description: |- + TokenEndpointAuthMethod pins how the client authenticates at the token + endpoint. If omitted, the client authenticates with its secret by either + HTTP Basic or form body. + enum: + - client_secret_basic + - client_secret_post + - private_key_jwt + type: string + tokenEndpointAuthSigningAlg: + description: |- + TokenEndpointAuthSigningAlg is the private_key_jwt signing algorithm, + for example RS256 or ES256. If omitted, it is derived from the keys: + each key's alg, or ES256, ES384 or ES512 from an EC key's curve. RSA + keys without alg use RS256. Keys that disagree must set it. + enum: + - RS256 + - RS384 + - RS512 + - PS256 + - PS384 + - PS512 + - ES256 + - ES384 + - ES512 + type: string + tokenExchange: + description: |- + TokenExchange is the client's token-exchange policy. Its + selfIssuedSubjectTokens field is required whenever the block is present. + The block may be omitted only by a secret-authenticated client without + grantTypes; such a client keeps the legacy rule (any) and may exchange any + self-issued token. A client that sets grantTypes or uses private_key_jwt + must declare the block. + properties: + allowedSourceClients: + description: |- + AllowedSourceClients are client_id values, as they appear in the subject + token's client_id claim, whose self-issued tokens this client may + exchange in addition to its own. They must be user-facing clients that + register at runtime (DCR or CIMD). A configured delegate client cannot be + listed here. Valid only with restricted; empty means the client's own + tokens only. + items: + maxLength: 256 + minLength: 1 + type: string + maxItems: 20 + type: array + x-kubernetes-list-type: set + apiAudience: + description: |- + APIAudience is the client's own API audience. The client may request it + even when the subject token does not cover it. It must be one of the + client's audiences. Valid only with restricted. + maxLength: 2048 + type: string + selfIssuedSubjectTokens: + description: |- + SelfIssuedSubjectTokens selects which self-issued subject tokens the + client may exchange. With restricted, a self-issued subject token must + carry the client's own client_id or one of allowedSourceClients, and a + may_act claim naming this client does not lift that restriction. With + any, the client may exchange any self-issued token. It has no default + and must be set explicitly. + enum: + - any + - restricted + type: string + required: + - selfIssuedSubjectTokens + type: object + x-kubernetes-validations: + - message: tokenExchange.allowedSourceClients and tokenExchange.apiAudience + are valid only when selfIssuedSubjectTokens is restricted + rule: self.selfIssuedSubjectTokens == 'restricted' || + ((!has(self.allowedSourceClients) || size(self.allowedSourceClients) + == 0) && (!has(self.apiAudience) || size(self.apiAudience) + == 0)) 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 + - message: private_key_jwt requires a non-empty jwksRef and + forbids clientSecretRef; any other tokenEndpointAuthMethod + (or none) requires a non-empty clientSecretRef and forbids + jwksRef + rule: 'has(self.tokenEndpointAuthMethod) && self.tokenEndpointAuthMethod + == ''private_key_jwt'' ? (has(self.jwksRef) && !has(self.clientSecretRef)) + : (has(self.clientSecretRef) && size(self.clientSecretRef.name) + > 0 && size(self.clientSecretRef.key) > 0 && !has(self.jwksRef))' + - message: grantTypes must include urn:ietf:params:oauth:grant-type:token-exchange + when set + rule: '!has(self.grantTypes) || size(self.grantTypes) == 0 + || ''urn:ietf:params:oauth:grant-type:token-exchange'' in + self.grantTypes' + - message: redirectUris is required when grantTypes includes + authorization_code + rule: '!has(self.grantTypes) || !(''authorization_code'' in + self.grantTypes) || (has(self.redirectUris) && size(self.redirectUris) + > 0)' + - message: tokenExchange.selfIssuedSubjectTokens is required + when grantTypes is set + rule: '!has(self.grantTypes) || size(self.grantTypes) == 0 + || has(self.tokenExchange)' + - message: tokenExchange.selfIssuedSubjectTokens is required + for private_key_jwt clients + rule: '!(has(self.tokenEndpointAuthMethod) && self.tokenEndpointAuthMethod + == ''private_key_jwt'') || has(self.tokenExchange)' + - message: tokenExchange.apiAudience must be one of the client's + audiences + rule: '!has(self.tokenExchange) || !has(self.tokenExchange.apiAudience) + || size(self.tokenExchange.apiAudience) == 0 || self.tokenExchange.apiAudience + in self.audiences' + - message: tokenExchange.allowedSourceClients must not contain + the client's own clientId or the "*" wildcard + rule: '!has(self.tokenExchange) || !has(self.tokenExchange.allowedSourceClients) + || (!(self.clientId in self.tokenExchange.allowedSourceClients) + && !(''*'' in self.tokenExchange.allowedSourceClients))' + - message: redirectUris is only allowed with the authorization_code + grant + rule: '!has(self.redirectUris) || size(self.redirectUris) + == 0 || (has(self.grantTypes) && ''authorization_code'' + in self.grantTypes)' + - message: grantTypes must include authorization_code with refresh_token + rule: '!has(self.grantTypes) || !(''refresh_token'' in self.grantTypes) + || ''authorization_code'' in self.grantTypes' + - message: tokenEndpointAuthSigningAlg is only allowed with + private_key_jwt + rule: '!has(self.tokenEndpointAuthSigningAlg) || size(self.tokenEndpointAuthSigningAlg) + == 0 || (has(self.tokenEndpointAuthMethod) && self.tokenEndpointAuthMethod + == ''private_key_jwt'')' maxItems: 10 type: array x-kubernetes-list-type: atomic @@ -6037,9 +6601,10 @@ spec: 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. + DelegateClientConfig configures a pre-provisioned confidential OAuth client. + By default it is a token-exchange client (RFC 8693). Its credential is either + a client secret, referenced from a Kubernetes Secret and never represented + inline, or a public JWKS for private_key_jwt, referenced from a ConfigMap. properties: audiences: description: Audiences is the narrowed set of RFC @@ -6059,8 +6624,9 @@ spec: minLength: 1 type: string clientSecretRef: - description: ClientSecretRef references the Kubernetes - Secret key containing the client secret. + description: |- + ClientSecretRef references the Kubernetes Secret key containing the client secret. + Required unless tokenEndpointAuthMethod is private_key_jwt, where it is forbidden. properties: key: description: Key is the key within the secret @@ -6072,6 +6638,62 @@ spec: - key - name type: object + grantTypes: + description: |- + GrantTypes are the grants this client may use. If omitted or empty, the + client is token-exchange only. When set, the list must include + urn:ietf:params:oauth:grant-type:token-exchange: a delegate client is a + token-exchange client that may also log in or redeem other grants. When set, + the list is complete: include urn:ietf:params:oauth:grant-type:jwt-bearer to + keep ID-JAG redemption. + items: + enum: + - authorization_code + - refresh_token + - urn:ietf:params:oauth:grant-type:token-exchange + - urn:ietf:params:oauth:grant-type:jwt-bearer + type: string + maxItems: 4 + type: array + x-kubernetes-list-type: set + jwksRef: + description: |- + JWKSRef references the ConfigMap key holding the client's public JWKS + document. Required with private_key_jwt and forbidden otherwise. The + document must contain public keys only. + properties: + key: + description: Key is the key within the ConfigMap + minLength: 1 + type: string + name: + description: Name is the name of the ConfigMap + minLength: 1 + type: string + required: + - key + - name + type: object + redirectUris: + description: |- + RedirectURIs are the registered redirect URIs. They are required with the + authorization_code grant and forbidden without it. They sit here, under + tokenExchange.delegateClients, because a delegate client is a + token-exchange client that may also log in (authorization_code). + + The pattern is stricter than the runtime for uppercase "HTTPS://": the + runtime accepts it, the pattern does not. The scheme check is + case-sensitive, as in forceConfidentialRedirectUris. Query strings and + fragments behave the same as the runtime: a query string is allowed, and + a fragment is rejected. + items: + maxLength: 2048 + minLength: 1 + pattern: ^https://[^\s#]+$ + type: string + maxItems: 10 + type: array + x-kubernetes-list-type: set scopes: description: Scopes is the narrowed set of OAuth scopes this client may request. @@ -6083,17 +6705,144 @@ spec: minItems: 1 type: array x-kubernetes-list-type: atomic + tokenEndpointAuthMethod: + description: |- + TokenEndpointAuthMethod pins how the client authenticates at the token + endpoint. If omitted, the client authenticates with its secret by either + HTTP Basic or form body. + enum: + - client_secret_basic + - client_secret_post + - private_key_jwt + type: string + tokenEndpointAuthSigningAlg: + description: |- + TokenEndpointAuthSigningAlg is the private_key_jwt signing algorithm, + for example RS256 or ES256. If omitted, it is derived from the keys: + each key's alg, or ES256, ES384 or ES512 from an EC key's curve. RSA + keys without alg use RS256. Keys that disagree must set it. + enum: + - RS256 + - RS384 + - RS512 + - PS256 + - PS384 + - PS512 + - ES256 + - ES384 + - ES512 + type: string + tokenExchange: + description: |- + TokenExchange is the client's token-exchange policy. Its + selfIssuedSubjectTokens field is required whenever the block is present. + The block may be omitted only by a secret-authenticated client without + grantTypes; such a client keeps the legacy rule (any) and may exchange any + self-issued token. A client that sets grantTypes or uses private_key_jwt + must declare the block. + properties: + allowedSourceClients: + description: |- + AllowedSourceClients are client_id values, as they appear in the subject + token's client_id claim, whose self-issued tokens this client may + exchange in addition to its own. They must be user-facing clients that + register at runtime (DCR or CIMD). A configured delegate client cannot be + listed here. Valid only with restricted; empty means the client's own + tokens only. + items: + maxLength: 256 + minLength: 1 + type: string + maxItems: 20 + type: array + x-kubernetes-list-type: set + apiAudience: + description: |- + APIAudience is the client's own API audience. The client may request it + even when the subject token does not cover it. It must be one of the + client's audiences. Valid only with restricted. + maxLength: 2048 + type: string + selfIssuedSubjectTokens: + description: |- + SelfIssuedSubjectTokens selects which self-issued subject tokens the + client may exchange. With restricted, a self-issued subject token must + carry the client's own client_id or one of allowedSourceClients, and a + may_act claim naming this client does not lift that restriction. With + any, the client may exchange any self-issued token. It has no default + and must be set explicitly. + enum: + - any + - restricted + type: string + required: + - selfIssuedSubjectTokens + type: object + x-kubernetes-validations: + - message: tokenExchange.allowedSourceClients and + tokenExchange.apiAudience are valid only when + selfIssuedSubjectTokens is restricted + rule: self.selfIssuedSubjectTokens == 'restricted' + || ((!has(self.allowedSourceClients) || size(self.allowedSourceClients) + == 0) && (!has(self.apiAudience) || size(self.apiAudience) + == 0)) 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 + - message: private_key_jwt requires a non-empty jwksRef + and forbids clientSecretRef; any other tokenEndpointAuthMethod + (or none) requires a non-empty clientSecretRef and + forbids jwksRef + rule: 'has(self.tokenEndpointAuthMethod) && self.tokenEndpointAuthMethod + == ''private_key_jwt'' ? (has(self.jwksRef) && !has(self.clientSecretRef)) + : (has(self.clientSecretRef) && size(self.clientSecretRef.name) + > 0 && size(self.clientSecretRef.key) > 0 && !has(self.jwksRef))' + - message: grantTypes must include urn:ietf:params:oauth:grant-type:token-exchange + when set + rule: '!has(self.grantTypes) || size(self.grantTypes) + == 0 || ''urn:ietf:params:oauth:grant-type:token-exchange'' + in self.grantTypes' + - message: redirectUris is required when grantTypes + includes authorization_code + rule: '!has(self.grantTypes) || !(''authorization_code'' + in self.grantTypes) || (has(self.redirectUris) && + size(self.redirectUris) > 0)' + - message: tokenExchange.selfIssuedSubjectTokens is + required when grantTypes is set + rule: '!has(self.grantTypes) || size(self.grantTypes) + == 0 || has(self.tokenExchange)' + - message: tokenExchange.selfIssuedSubjectTokens is + required for private_key_jwt clients + rule: '!(has(self.tokenEndpointAuthMethod) && self.tokenEndpointAuthMethod + == ''private_key_jwt'') || has(self.tokenExchange)' + - message: tokenExchange.apiAudience must be one of + the client's audiences + rule: '!has(self.tokenExchange) || !has(self.tokenExchange.apiAudience) + || size(self.tokenExchange.apiAudience) == 0 || + self.tokenExchange.apiAudience in self.audiences' + - message: tokenExchange.allowedSourceClients must not + contain the client's own clientId or the "*" wildcard + rule: '!has(self.tokenExchange) || !has(self.tokenExchange.allowedSourceClients) + || (!(self.clientId in self.tokenExchange.allowedSourceClients) + && !(''*'' in self.tokenExchange.allowedSourceClients))' + - message: redirectUris is only allowed with the authorization_code + grant + rule: '!has(self.redirectUris) || size(self.redirectUris) + == 0 || (has(self.grantTypes) && ''authorization_code'' + in self.grantTypes)' + - message: grantTypes must include authorization_code + with refresh_token + rule: '!has(self.grantTypes) || !(''refresh_token'' + in self.grantTypes) || ''authorization_code'' in + self.grantTypes' + - message: tokenEndpointAuthSigningAlg is only allowed + with private_key_jwt + rule: '!has(self.tokenEndpointAuthSigningAlg) || size(self.tokenEndpointAuthSigningAlg) + == 0 || (has(self.tokenEndpointAuthMethod) && self.tokenEndpointAuthMethod + == ''private_key_jwt'')' maxItems: 10 type: array x-kubernetes-list-type: atomic @@ -6182,10 +6931,12 @@ spec: 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 + private_key_jwt registration (DCR) 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. + nothing here for cleartext HTTP to expose. Delegate clients count as + confidential whatever their tokenEndpointAuthMethod, private_key_jwt + included, because they can hold authorization_code and refresh_token grants. type: boolean insecureAllowHTTP: default: false @@ -7956,6 +8707,13 @@ spec: - message: spiffeClientAuth clientId must not be an absolute URL rule: '!has(self.inboundGrants) || !has(self.inboundGrants.spiffeClientAuth) || self.inboundGrants.spiffeClientAuth.all(client, !client.clientId.matches(''^[A-Za-z][A-Za-z0-9+.-]*://.+''))' + - message: delegateClients is deprecated and accepts only clientId, + clientSecretRef, scopes and audiences; use inboundGrants.tokenExchange.delegateClients + for jwksRef, tokenEndpointAuthMethod, grantTypes, redirectUris, + tokenExchange and tokenEndpointAuthSigningAlg + rule: '!has(self.delegateClients) || self.delegateClients.all(c, + !has(c.jwksRef) && !has(c.tokenEndpointAuthMethod) && !has(c.grantTypes) + && !has(c.redirectUris) && !has(c.tokenExchange) && !has(c.tokenEndpointAuthSigningAlg))' - message: insecureAllowHTTP cannot be combined with confidential client registration or delegateClients; client secrets would be issued or used in cleartext over an unauthenticated endpoint diff --git a/docs/arch/09-operator-architecture.md b/docs/arch/09-operator-architecture.md index 6d359f37f3..d4f801cca8 100644 --- a/docs/arch/09-operator-architecture.md +++ b/docs/arch/09-operator-architecture.md @@ -228,6 +228,8 @@ When the referenced CA data changes, the operator watches the ConfigMap, compute For a complete example, see [`examples/operator/external-auth/mcpexternalauthconfig_private_ca.yaml`](../../examples/operator/external-auth/mcpexternalauthconfig_private_ca.yaml). +An embedded auth server's pre-provisioned delegate clients (`inboundGrants.tokenExchange.delegateClients`) can authenticate with `private_key_jwt`. The client sets `tokenEndpointAuthMethod: private_key_jwt` and `jwksRef`, a ConfigMap key holding its public JWKS, instead of `clientSecretRef`. The operator mounts that key read-only at `/etc/toolhive/authserver/delegate-jwks//jwks.json` and points the RunConfig `jwks_file` at it. These clients can also set `grantTypes`, `redirectUris` and a `tokenExchange` source policy (a client that sets `grantTypes`, or uses `private_key_jwt`, must declare `tokenExchange.selfIssuedSubjectTokens` as `any` or `restricted`); see [Token Exchange and Delegation](17-token-exchange-delegation.md). Editing the JWKS ConfigMap does not restart the pod; restart it to load new keys. + Because appending a certificate to a referenced ConfigMap makes that CA a trust anchor — for an upstream provider's login, or for a trusted issuer's subject-token validation — restrict write access to a `caBundleRef` ConfigMap with the same RBAC care as the auth server's signing-key Secret. MCPExternalAuthConfig resources can be referenced via two paths: diff --git a/docs/arch/17-token-exchange-delegation.md b/docs/arch/17-token-exchange-delegation.md index a6065fe59e..1c2b8cd8df 100644 --- a/docs/arch/17-token-exchange-delegation.md +++ b/docs/arch/17-token-exchange-delegation.md @@ -167,16 +167,16 @@ remain incompatible because they would add credentials after the strip. The operator exposes delegate clients on the shared `EmbeddedAuthServerConfig`, which has two supported consumers: -- `MCPExternalAuthConfig.spec.embeddedAuthServer.delegateClients`, for an - embedded authorization server referenced by an `MCPServer` or +- `MCPExternalAuthConfig.spec.embeddedAuthServer.inboundGrants.tokenExchange.delegateClients`, + for an embedded authorization server referenced by an `MCPServer` or `MCPRemoteProxy`. -- `VirtualMCPServer.spec.authServerConfig.delegateClients`, for a vMCP's inline - embedded authorization server. +- `VirtualMCPServer.spec.authServerConfig.inboundGrants.tokenExchange.delegateClients`, + for a vMCP's inline embedded authorization server. The following `MCPExternalAuthConfig` excerpt declares the same client. The operator converts the Secret reference into a pod environment-variable reference -and always supplies the token-exchange grant; it does not copy the secret into -the generated ConfigMap or `RunConfig`. +and copies `grantTypes` as written; an omitted list means token exchange only. It does +not copy the secret into the generated ConfigMap or `RunConfig`. ```yaml apiVersion: toolhive.stacklok.dev/v1beta1 @@ -189,13 +189,15 @@ spec: issuer: https://auth.example.com # Other required embedded-auth-server fields, including upstreamProviders, # are omitted here. - delegateClients: - - clientId: reporting-delegate - clientSecretRef: - name: reporting-delegate-secret - key: client-secret - scopes: [openid] - audiences: [https://mcp.example.com] + inboundGrants: + tokenExchange: + delegateClients: + - clientId: reporting-delegate + clientSecretRef: + name: reporting-delegate-secret + key: client-secret + scopes: [openid] + audiences: [https://mcp.example.com] ``` For a `VirtualMCPServer`, place the list under `spec.authServerConfig`: @@ -211,25 +213,79 @@ spec: issuer: https://auth.example.com # Other required embedded-auth-server fields, including upstreamProviders, # are omitted here. - delegateClients: - - clientId: reporting-delegate - clientSecretRef: - name: reporting-delegate-secret - key: client-secret - scopes: [openid] - audiences: [https://mcp.example.com] + inboundGrants: + tokenExchange: + delegateClients: + - clientId: reporting-delegate + clientSecretRef: + name: reporting-delegate-secret + key: client-secret + scopes: [openid] + audiences: [https://mcp.example.com] ``` A token-only vMCP cannot use Cedar authorization: grant-only issuance does not create the provenance-bound upstream-token entries Cedar needs for upstream-derived claims. -The CRD accepts Secret references only: no plaintext secret, redirect URI, or -arbitrary grant selection is available. A non-empty `clientSecretRef.name` and -`.key`, at least one scope, and at least one audience are required. Delegate -clients require an HTTPS issuer at admission. Every delegate client, including -`private_key_jwt` ones, counts as confidential for the cleartext-transport rule, -in both the CRD and the RunConfig/`Config` validation paths. +The CRD accepts no plaintext credentials. A client secret is a Secret reference +(`clientSecretRef`), and a public JWKS is a ConfigMap reference (`jwksRef`). Each +reference needs a non-empty `name` and `key`. Every delegate client needs at +least one scope and at least one audience. A `private_key_jwt` client sets `jwksRef` instead of +`clientSecretRef`, and the CRD forbids a secret on it. Every delegate client, whatever its +auth method, requires an HTTPS issuer, or the loopback opt-in +`insecureAllowConfidentialOverLoopbackHTTP` for a loopback HTTP issuer, and is +rejected with `insecureAllowHTTP`. + +The CRD admission rules, the RunConfig validator and the `Config` validator all +count every delegate client as confidential for the cleartext-transport rule, +`private_key_jwt` included. Such a client sends no secret, but it can still hold +`authorization_code` and `refresh_token` grants, so codes and tokens would cross +plain HTTP too. + +#### Pre-provisioned clients on `inboundGrants.tokenExchange.delegateClients` + +The canonical list `inboundGrants.tokenExchange.delegateClients` accepts more +fields. The deprecated top-level `delegateClients` accepts only `clientId`, +`clientSecretRef`, `scopes` and `audiences`; the API rejects the fields in the +table below on it. Put them on the canonical list. + +| Field | Meaning | +|---|---| +| `tokenEndpointAuthMethod` | `client_secret_basic`, `client_secret_post` or `private_key_jwt`. | +| `jwksRef` | ConfigMap key holding the client's public JWKS. Required for `private_key_jwt`; forbidden otherwise. The field `optional` does not exist; an object that sets it has the key removed by the API server. | +| `tokenEndpointAuthSigningAlg` | The `private_key_jwt` signing algorithm. If omitted, it is derived from the keys: each key's `alg`, or `ES256`, `ES384` or `ES512` from an EC key's curve. RSA keys without `alg` use `RS256`. Keys that disagree must set it. | +| `grantTypes` | A subset of `authorization_code`, `refresh_token`, token exchange and JWT-bearer. When set, it must include token exchange. When set, the list is complete: include the JWT-bearer grant (`urn:ietf:params:oauth:grant-type:jwt-bearer`) to keep ID-JAG redemption. | +| `redirectUris` | Required with `authorization_code`. They sit under `tokenExchange.delegateClients` because a delegate client is a token-exchange client that may also log in. | +| `tokenExchange` | `selfIssuedSubjectTokens` (`any` or `restricted`, required in the block, no default), `allowedSourceClients` and `apiAudience`. The block is required when `grantTypes` is set and for every `private_key_jwt` client. It may be omitted only by a secret-authenticated client without `grantTypes`, which keeps the legacy blanket trust (`any`). See [Source policy and API audience](#source-policy-and-api-audience). | + +`clientSecretRef` is required unless the method is `private_key_jwt`, where it is +forbidden. CEL admission enforces this, the token-exchange rule for `grantTypes`, +`redirectUris` (required with `authorization_code` and forbidden without it), +`refresh_token` only together with `authorization_code`, the `tokenExchange` block +when `grantTypes` is set or the method is `private_key_jwt`, `allowedSourceClients` and `apiAudience` only with +`restricted`, and `tokenEndpointAuthSigningAlg` only with `private_key_jwt` and +only from the supported algorithms (`RS256`, `RS384`, `RS512`, `PS256`, `PS384`, +`PS512`, `ES256`, `ES384`, `ES512`). `selfIssuedSubjectTokens` has no schema default on purpose: a +default would be applied before CEL runs, so "set it explicitly" could not be +enforced, and it would also be applied to objects read back from etcd, turning a +stored `tokenExchange: {}` into blanket trust. The shared Go validator checks +everything else (public keys only, redirect-URI policy, source-client rules). + +Admission checks three rules for each delegate client. `apiAudience` must be one +of the client's `audiences`. `allowedSourceClients` must not contain the client's +own `clientId` or `*`. Redirect URIs must use `https` and must not contain a +fragment. The operator checks two more rules when it reconciles, not at +admission: `allowedSourceClients` must not name another configured delegate +client, and a loopback redirect URI is refused. A `VirtualMCPServer` reports +`AuthServerConfigValidated=False` for this; an `MCPExternalAuthConfig` reports +`Valid=False`. + +For `jwksRef`, the operator mounts the ConfigMap key read-only at +`/etc/toolhive/authserver/delegate-jwks//jwks.json` and sets `jwks_file` to +that path, where `` is the client's position in the list. The runner reads +the file at startup. The file is public key material, so the ConfigMap needs no +special protection beyond controlling who can write it. `trustedIssuers` is exposed the same way, on the same shared `EmbeddedAuthServerConfig`, and converts directly to `[]tokenexchange.TrustedIssuer` @@ -585,7 +641,7 @@ optional; a client that sets none of them is the same client as before. |---|---| | `token_endpoint_auth_method` | Omitted: the client authenticates with its secret by HTTP Basic or form body. `client_secret_basic` or `client_secret_post` pins one of them. `private_key_jwt` authenticates with a signed assertion instead of a secret. | | `jwks_file` | Path to the client's public JWKS. Required for `private_key_jwt`, forbidden otherwise. Each key must have `use: sig`. Private and symmetric keys are rejected, at most 10 keys are allowed, and the keys must match `token_endpoint_auth_signing_alg`. When that field is omitted, it is derived from the keys: each key's `alg`, or `ES256`, `ES384` or `ES512` from an EC key's curve; RSA keys without `alg` use `RS256`; keys that disagree are rejected and the field must be set. When the file holds more than one key, every key must have a unique, non-empty `kid`, and the client must send the `kid` of its signing key in the assertion header: fosite checks an assertion that has no `kid` only against the first signing key of the matching type, so a key added during a rotation would never be used otherwise. The `kid` rule is checked when the keys are loaded at startup, not when `RunConfig` is validated. | -| `grant_types` | Omitted or empty means token exchange only. When set, the list must include token exchange. The other allowed values are `authorization_code`, `refresh_token` and the JWT-bearer grant. `refresh_token` is allowed only together with `authorization_code`. A client that sets `grant_types`, or uses `private_key_jwt`, must also declare a `token_exchange` block with `self_issued_subject_tokens` (see [Source policy and subject token audiences](#source-policy-and-subject-token-audiences)). | +| `grant_types` | Omitted or empty means token exchange only. When set, the list must include token exchange. The other allowed values are `authorization_code`, `refresh_token` and the JWT-bearer grant. `refresh_token` is allowed only together with `authorization_code`. A client that sets `grant_types`, or uses `private_key_jwt`, must also declare a `token_exchange` block with `self_issued_subject_tokens` (see [Source policy and subject token audiences](#source-policy-and-subject-token-audiences)). When set, the list is complete: include the JWT-bearer grant to keep ID-JAG redemption. | | `redirect_uris` | Required with `authorization_code`, forbidden without it. Each URI must be https and not loopback, as for any other confidential client. | A delegate's `audiences` limit what it may request. List the narrowest diff --git a/docs/operator/crd-api.md b/docs/operator/crd-api.md index 8a1d5d30dc..9ed419f665 100644 --- a/docs/operator/crd-api.md +++ b/docs/operator/crd-api.md @@ -1933,6 +1933,19 @@ _Appears in:_ | `key` _string_ | Key is the key in the ConfigMap that contains the authorization configuration | authz.json | Optional: \{\}
| +#### api.v1beta1.ConfigMapKeyRef + +_Underlying type:_ _struct{Name string "json:\"name\""; Key string "json:\"key\""}_ + +ConfigMapKeyRef is a reference to a key within a ConfigMap in the same namespace. + + + +_Appears in:_ +- [api.v1beta1.DelegateClientConfig](#apiv1beta1delegateclientconfig) + + + #### api.v1beta1.DCRUpstreamConfig @@ -1974,9 +1987,10 @@ _Appears in:_ -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. +DelegateClientConfig configures a pre-provisioned confidential OAuth client. +By default it is a token-exchange client (RFC 8693). Its credential is either +a client secret, referenced from a Kubernetes Secret and never represented +inline, or a public JWKS for private_key_jwt, referenced from a ConfigMap. @@ -1987,9 +2001,30 @@ _Appears in:_ | Field | Description | Default | Validation | | --- | --- | --- | --- | | `clientId` _string_ | ClientID is the OAuth client_id presented at the token endpoint. | | MaxLength: 256
MinLength: 1
Required: \{\}
| -| `clientSecretRef` _[api.v1beta1.SecretKeyRef](#apiv1beta1secretkeyref)_ | ClientSecretRef references the Kubernetes Secret key containing the client secret. | | Required: \{\}
| +| `tokenEndpointAuthMethod` _string_ | TokenEndpointAuthMethod pins how the client authenticates at the token
endpoint. If omitted, the client authenticates with its secret by either
HTTP Basic or form body. | | Enum: [client_secret_basic client_secret_post private_key_jwt]
Optional: \{\}
| +| `clientSecretRef` _[api.v1beta1.SecretKeyRef](#apiv1beta1secretkeyref)_ | ClientSecretRef references the Kubernetes Secret key containing the client secret.
Required unless tokenEndpointAuthMethod is private_key_jwt, where it is forbidden. | | Optional: \{\}
| +| `jwksRef` _[api.v1beta1.ConfigMapKeyRef](#apiv1beta1configmapkeyref)_ | JWKSRef references the ConfigMap key holding the client's public JWKS
document. Required with private_key_jwt and forbidden otherwise. The
document must contain public keys only. | | Optional: \{\}
| +| `tokenEndpointAuthSigningAlg` _string_ | TokenEndpointAuthSigningAlg is the private_key_jwt signing algorithm,
for example RS256 or ES256. If omitted, it is derived from the keys:
each key's alg, or ES256, ES384 or ES512 from an EC key's curve. RSA
keys without alg use RS256. Keys that disagree must set it. | | Enum: [RS256 RS384 RS512 PS256 PS384 PS512 ES256 ES384 ES512]
Optional: \{\}
| +| `grantTypes` _string array_ | GrantTypes are the grants this client may use. If omitted or empty, the
client is token-exchange only. When set, the list must include
urn:ietf:params:oauth:grant-type:token-exchange: a delegate client is a
token-exchange client that may also log in or redeem other grants. When set,
the list is complete: include urn:ietf:params:oauth:grant-type:jwt-bearer to
keep ID-JAG redemption. | | MaxItems: 4
items:Enum: [authorization_code refresh_token urn:ietf:params:oauth:grant-type:token-exchange urn:ietf:params:oauth:grant-type:jwt-bearer]
Optional: \{\}
| +| `redirectUris` _string array_ | RedirectURIs are the registered redirect URIs. They are required with the
authorization_code grant and forbidden without it. They sit here, under
tokenExchange.delegateClients, because a delegate client is a
token-exchange client that may also log in (authorization_code).
The pattern is stricter than the runtime for uppercase "HTTPS://": the
runtime accepts it, the pattern does not. The scheme check is
case-sensitive, as in forceConfidentialRedirectUris. Query strings and
fragments behave the same as the runtime: a query string is allowed, and
a fragment is rejected. | | MaxItems: 10
items:MaxLength: 2048
items:MinLength: 1
items:Pattern: `^https://[^\s#]+$`
Optional: \{\}
| | `scopes` _string array_ | Scopes is the narrowed set of OAuth scopes this client may request. | | MaxItems: 10
MinItems: 1
Required: \{\}
items:MaxLength: 256
items:MinLength: 1
| | `audiences` _string array_ | Audiences is the narrowed set of RFC 8707 resources this client may request. | | MaxItems: 10
MinItems: 1
Required: \{\}
items:MaxLength: 2048
items:MinLength: 1
| +| `tokenExchange` _[api.v1beta1.DelegateClientTokenExchangeConfig](#apiv1beta1delegateclienttokenexchangeconfig)_ | TokenExchange is the client's token-exchange policy. Its
selfIssuedSubjectTokens field is required whenever the block is present.
The block may be omitted only by a secret-authenticated client without
grantTypes; such a client keeps the legacy rule (any) and may exchange any
self-issued token. A client that sets grantTypes or uses private_key_jwt
must declare the block. | | Optional: \{\}
| + + +#### api.v1beta1.DelegateClientTokenExchangeConfig + +_Underlying type:_ _struct{SelfIssuedSubjectTokens string "json:\"selfIssuedSubjectTokens\""; AllowedSourceClients []string "json:\"allowedSourceClients,omitempty\""; APIAudience string "json:\"apiAudience,omitempty\""}_ + +DelegateClientTokenExchangeConfig is the token-exchange policy of one +delegate client. External-issuer subject tokens are not affected by it: the +trusted issuer's own policy decides those. + + + +_Appears in:_ +- [api.v1beta1.DelegateClientConfig](#apiv1beta1delegateclientconfig) + #### api.v1beta1.DiscoveredBackend @@ -2068,8 +2103,8 @@ _Appears in:_ | `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: \{\}
| -| `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.
This legacy field is deprecated; use inboundGrants.tokenExchange.delegateClients. | | MaxItems: 10
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 (DCR) 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. Delegate clients count as
confidential whatever their tokenEndpointAuthMethod, private_key_jwt
included, because they can hold authorization_code and refresh_token grants. | 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 is accepted here. The supported path is
inboundGrants.tokenExchange.delegateClients. This field shares its type
with the canonical list, but accepts only clientId, clientSecretRef,
scopes and audiences. The newer fields (tokenEndpointAuthMethod,
tokenEndpointAuthSigningAlg, jwksRef, grantTypes, redirectUris,
tokenExchange) are rejected here; set them on the canonical list.
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: \{\}
| diff --git a/pkg/authserver/server/handlers/dcr_test.go b/pkg/authserver/server/handlers/dcr_test.go index f7ba8cfd78..2a1aca3be8 100644 --- a/pkg/authserver/server/handlers/dcr_test.go +++ b/pkg/authserver/server/handlers/dcr_test.go @@ -884,7 +884,7 @@ func TestRegisterClientHandler_ConfidentialDCR(t *testing.T) { // public (none) registrations — a secret must not ship inside a distributed // native binary. This is a rejection about the redirect URI itself (RFC // 7591 §3.2.2's invalid_redirect_uri), whether it is caught by the - // isLoopbackURI check or by strict ValidateRedirectURI. + // IsLoopbackRedirectURI check or by strict ValidateRedirectURI. for _, tc := range []struct { name string uris []string @@ -892,7 +892,7 @@ func TestRegisterClientHandler_ConfidentialDCR(t *testing.T) { }{ {"loopback localhost", []string{"http://localhost:1234/cb"}, true}, {"loopback 127.0.0.1", []string{"http://127.0.0.1/cb"}, true}, - // Passes RedirectURIPolicyStrict and is caught only by the isLoopbackURI + // Passes RedirectURIPolicyStrict and is caught only by the IsLoopbackRedirectURI // check (bracket-stripping + IsLoopback, three indirections deep). {"IPv6 loopback", []string{"https://[::1]/cb"}, true}, // Every URI in the list must be checked, not just redirectURIs[0]. diff --git a/pkg/authserver/server/registration/client.go b/pkg/authserver/server/registration/client.go index ebdcf39f5a..deaadca9f7 100644 --- a/pkg/authserver/server/registration/client.go +++ b/pkg/authserver/server/registration/client.go @@ -592,12 +592,12 @@ func matchesAsLoopback(requestedURI, registeredURI string) bool { return true } -// isLoopbackURI reports whether uri's host is a loopback address (an IP +// IsLoopbackRedirectURI reports whether uri's host is a loopback address (an IP // loopback literal or "localhost", case-insensitively) — a hostname-only // check, with no DNS resolution. Used by ValidateConfidentialRedirectURIs in // dcr.go to reject a confidential registration's redirect_uri that targets // loopback, which RFC 8252 §7.3 reserves for native/public clients. -func isLoopbackURI(uri string) bool { +func IsLoopbackRedirectURI(uri string) bool { parsed, err := url.Parse(uri) if err != nil { return false diff --git a/pkg/authserver/server/registration/configured_client.go b/pkg/authserver/server/registration/configured_client.go index b71a78f531..240deac168 100644 --- a/pkg/authserver/server/registration/configured_client.go +++ b/pkg/authserver/server/registration/configured_client.go @@ -190,7 +190,7 @@ func resolveConfiguredSigningAlgorithm(configured string, jwks *jose.JSONWebKeyS } slices.Sort(found) return "", fmt.Errorf("jwks_file keys have different signing algorithms (%s): "+ - "set token_endpoint_auth_signing_alg", strings.Join(found, ", ")) + "set token_endpoint_auth_signing_alg (tokenEndpointAuthSigningAlg on the CRD)", strings.Join(found, ", ")) } // keySigningAlgorithm returns the algorithm a key carries or its curve fixes, @@ -270,7 +270,7 @@ func validateConfiguredRedirectURIs(redirectURIs []string) error { if err := oauthproto.ValidateRedirectURI(uri, oauthproto.RedirectURIPolicyStrict); err != nil { return fmt.Errorf("redirect_uris: %w", err) } - if isLoopbackURI(uri) { + if IsLoopbackRedirectURI(uri) { return fmt.Errorf("redirect_uris must be https and not loopback (offending redirect_uri: %s)", uri) } } diff --git a/pkg/authserver/server/registration/configured_client_test.go b/pkg/authserver/server/registration/configured_client_test.go index 39c6ff65a5..2a22b601bc 100644 --- a/pkg/authserver/server/registration/configured_client_test.go +++ b/pkg/authserver/server/registration/configured_client_test.go @@ -58,7 +58,7 @@ func TestResolveConfiguredSigningAlgorithm(t *testing.T) { {name: "unsupported key type is left to the key-set check", jwks: set(oct), want: "RS256"}, { name: "RSA and EC keys disagree", jwks: set(rsaPlain, ec256), - wantErr: []string{"ES256, RS256", "have different signing algorithms", "token_endpoint_auth_signing_alg"}, + wantErr: []string{"ES256, RS256", "have different signing algorithms", "token_endpoint_auth_signing_alg", "tokenEndpointAuthSigningAlg on the CRD"}, }, { name: "found algorithms are sorted and deduplicated", jwks: set(rsaRS256, ec521, rsaPlain, ec384, ec521), @@ -152,11 +152,11 @@ func TestConfiguredClientDerivedSigningAlgorithm(t *testing.T) { name string jwks *jose.JSONWebKeySet wantAlg string - wantErrMsg string + wantErrMsg []string }{ {name: "EC key without alg", jwks: set(ec256), wantAlg: "ES256"}, {name: "RSA key without alg", jwks: set(rsaKey), wantAlg: "RS256"}, - {name: "keys that disagree", jwks: set(ec256, rsaKey), wantErrMsg: "have different signing algorithms"}, + {name: "keys that disagree", jwks: set(ec256, rsaKey), wantErrMsg: []string{"have different signing algorithms", "tokenEndpointAuthSigningAlg on the CRD"}}, } for _, tt := range tests { @@ -167,9 +167,11 @@ func TestConfiguredClientDerivedSigningAlgorithm(t *testing.T) { ID: "configured", Scopes: []string{"openid"}, Audience: []string{"https://mcp.example"}, TokenEndpointAuthMethod: pkjwt, JSONWebKeys: tt.jwks, }) - if tt.wantErrMsg != "" { + if len(tt.wantErrMsg) > 0 { require.Error(t, err) - assert.Contains(t, err.Error(), tt.wantErrMsg) + for _, want := range tt.wantErrMsg { + assert.Contains(t, err.Error(), want) + } return } require.NoError(t, err) diff --git a/pkg/authserver/server/registration/dcr.go b/pkg/authserver/server/registration/dcr.go index 740f3e2d2d..e99d1a2757 100644 --- a/pkg/authserver/server/registration/dcr.go +++ b/pkg/authserver/server/registration/dcr.go @@ -329,7 +329,7 @@ func ValidateConfidentialRedirectURIs(redirectURIs []string, authMethod string) ErrorDescription: err.Error(), } } - // isLoopbackURI is a literal-string check (127.0.0.0/8, ::1, + // IsLoopbackRedirectURI is a literal-string check (127.0.0.0/8, ::1, // ::ffff:127.0.0.1, localhost); it does not resolve the hostname, // so a registrant-controlled name that merely resolves to a // loopback address slips through. This is acceptable: the @@ -339,7 +339,7 @@ func ValidateConfidentialRedirectURIs(redirectURIs []string, authMethod string) // registration time would add an unreliable (TTL/rebinding) and // unnecessary network dependency to a request that should stay // purely local. - if isLoopbackURI(uri) { + if IsLoopbackRedirectURI(uri) { return &DCRError{ Error: DCRErrorInvalidRedirectURI, ErrorDescription: "token_endpoint_auth_method '" + authMethod + diff --git a/pkg/vmcp/config/validator.go b/pkg/vmcp/config/validator.go index 33974def5d..51dc6b6bea 100644 --- a/pkg/vmcp/config/validator.go +++ b/pkg/vmcp/config/validator.go @@ -673,7 +673,12 @@ func validateAuthServerRunConfig(rc *authserver.RunConfig) error { if rc.Issuer == "" { return fmt.Errorf("auth server issuer is required") } - if len(rc.Upstreams) == 0 && len(rc.DelegateClients) == 0 && + // Delegate clients live in the deprecated top-level list or, when the + // operator builds the config from inboundGrants, in the canonical list. + hasDelegateClients := len(rc.DelegateClients) > 0 || + (rc.InboundGrants != nil && rc.InboundGrants.TokenExchange != nil && + len(rc.InboundGrants.TokenExchange.DelegateClients) > 0) + if len(rc.Upstreams) == 0 && !hasDelegateClients && !authserver.JWTBearerGrantEnabled(rc.TrustedIssuers) { return fmt.Errorf("auth server requires at least one upstream unless delegate clients or a " + "trusted issuer with JWT bearer grant is configured") diff --git a/pkg/vmcp/config/validator_test.go b/pkg/vmcp/config/validator_test.go index 4dbc2a83dc..710848c896 100644 --- a/pkg/vmcp/config/validator_test.go +++ b/pkg/vmcp/config/validator_test.go @@ -1706,3 +1706,47 @@ func TestValidator_ValidateStaticBackends(t *testing.T) { }) } } + +func TestValidateAuthServerRunConfig_DelegateClients(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + rc *authserver.RunConfig + wantErr bool + }{ + { + name: "canonical delegate client satisfies the upstream requirement", + rc: &authserver.RunConfig{ + Issuer: "https://auth.example.com", + AllowedAudiences: []string{"https://my-vmcp"}, + InboundGrants: &authserver.InboundGrantsRunConfig{ + TokenExchange: &authserver.TokenExchangeInboundGrantRunConfig{ + DelegateClients: []authserver.DelegateClientRunConfig{{ClientID: "delegate"}}, + }, + }, + }, + }, + { + name: "no upstream, delegate client or trusted issuer fails", + rc: &authserver.RunConfig{ + Issuer: "https://auth.example.com", + AllowedAudiences: []string{"https://my-vmcp"}, + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + err := validateAuthServerRunConfig(tt.rc) + if tt.wantErr { + require.Error(t, err) + assert.Contains(t, err.Error(), "auth server requires at least one upstream unless delegate clients") + return + } + require.NoError(t, err) + }) + } +} From 53efbb99f0b040b6f1248a1438ec4deb2b801861 Mon Sep 17 00:00:00 2001 From: Jakub Hrozek Date: Mon, 2 Nov 2026 11:13:29 +0100 Subject: [PATCH 04/10] Roll the pod on delegate client JWKS edits A delegate client's jwksRef ConfigMap is mounted with subPath, so a running pod never sees an edit. Without a pod-template change and a reconcile on the edit, rotating the broker's key would need a manual restart. The embedded-auth ConfigMap checksum now also covers the JWKS bytes of the delegate clients the runtime config is built from. The section is hashed only when a jwksRef exists, behind its own marker, so the checksum of every config without one is byte-identical and no existing pod rolls on upgrade. A missing ConfigMap or key is reported as a terminal InvalidCABundle error through ValidateEmbeddedAuthServerCABundles, as CA bundles are. The ConfigMap indexes for MCPExternalAuthConfig and VirtualMCPServer include the JWKS ConfigMap names, so editing one enqueues the dependent reconciles. Co-authored-by: Mecatl --- .../embedded_auth_configmap_index.go | 4 +- .../virtualmcpserver_authz_configmap.go | 7 +- .../pkg/controllerutil/authserver.go | 68 +++++++- .../controllerutil/authserver_jwks_test.go | 145 ++++++++++++++++++ .../pkg/controllerutil/ca_bundle.go | 14 +- 5 files changed, 232 insertions(+), 6 deletions(-) create mode 100644 cmd/thv-operator/pkg/controllerutil/authserver_jwks_test.go diff --git a/cmd/thv-operator/controllers/embedded_auth_configmap_index.go b/cmd/thv-operator/controllers/embedded_auth_configmap_index.go index ace75fdeaa..c49b1527f6 100644 --- a/cmd/thv-operator/controllers/embedded_auth_configmap_index.go +++ b/cmd/thv-operator/controllers/embedded_auth_configmap_index.go @@ -18,7 +18,7 @@ const ( ) // IndexEmbeddedAuthConfigMapRefs returns the ConfigMap names referenced by -// an MCPExternalAuthConfig's embedded-auth CA bundles. +// an MCPExternalAuthConfig's embedded-auth CA bundles and delegate client JWKS. func IndexEmbeddedAuthConfigMapRefs(obj client.Object) []string { config, ok := obj.(*mcpv1beta1.MCPExternalAuthConfig) if !ok || config.Spec.EmbeddedAuthServer == nil { @@ -36,7 +36,7 @@ func IndexEmbeddedAuthConfigMapRefs(obj client.Object) []string { keys = append(keys, ref.ConfigMapRef.Name) } } - return keys + return append(keys, ctrlutil.DelegateClientJWKSConfigMapNames(config.Spec.EmbeddedAuthServer)...) } func indexMCPServerEmbeddedAuthConfig(obj client.Object) []string { diff --git a/cmd/thv-operator/controllers/virtualmcpserver_authz_configmap.go b/cmd/thv-operator/controllers/virtualmcpserver_authz_configmap.go index 5db9ba897d..ee6ec7dccc 100644 --- a/cmd/thv-operator/controllers/virtualmcpserver_authz_configmap.go +++ b/cmd/thv-operator/controllers/virtualmcpserver_authz_configmap.go @@ -16,12 +16,14 @@ import ( "sigs.k8s.io/controller-runtime/pkg/reconcile" mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" + ctrlutil "github.com/stacklok/toolhive/cmd/thv-operator/pkg/controllerutil" ) const virtualMCPServerConfigMapIndex = "toolhive.stacklok.dev/virtualmcpserver-configmap" // indexVirtualMCPServerConfigMaps returns namespace-local ConfigMap references -// whose data changes require the VirtualMCPServer to reconcile. +// whose data changes require the VirtualMCPServer to reconcile: the authz +// ConfigMap, and the auth server's CA bundle and delegate client JWKS ConfigMaps. func indexVirtualMCPServerConfigMaps(obj client.Object) []string { vmcp, ok := obj.(*mcpv1beta1.VirtualMCPServer) if !ok { @@ -47,6 +49,9 @@ func indexVirtualMCPServerConfigMaps(obj client.Object) []string { names[ref.ConfigMapRef.Name] = struct{}{} } } + for _, name := range ctrlutil.DelegateClientJWKSConfigMapNames(vmcp.Spec.AuthServerConfig) { + names[name] = struct{}{} + } } result := make([]string, 0, len(names)) diff --git a/cmd/thv-operator/pkg/controllerutil/authserver.go b/cmd/thv-operator/pkg/controllerutil/authserver.go index b183a9c9cc..b189362660 100644 --- a/cmd/thv-operator/pkg/controllerutil/authserver.go +++ b/cmd/thv-operator/pkg/controllerutil/authserver.go @@ -506,8 +506,9 @@ func EmbeddedAuthServerConfigMapChecksum( } // EmbeddedAuthServerConfigMapChecksumForConfig returns a checksum of the selected -// bytes in all CA ConfigMaps used by an inline embedded auth server. ConfigMap -// metadata and unselected keys are deliberately excluded. +// bytes in all CA ConfigMaps and delegate client JWKS ConfigMaps used by an inline +// embedded auth server. ConfigMap metadata and unselected keys are deliberately +// excluded. func EmbeddedAuthServerConfigMapChecksumForConfig( ctx context.Context, c client.Client, namespace string, config *mcpv1beta1.EmbeddedAuthServerConfig, ) (string, error) { @@ -548,12 +549,75 @@ func EmbeddedAuthServerConfigMapChecksumForConfig( _, _ = hash.Write([]byte{0}) found = true } + // The JWKS section is hashed only when a delegate client references one, behind + // its own marker, so the checksum of every config without a jwksRef is unchanged. + wroteJWKSMarker := false + delegateClients := effectiveDelegateClients(config) + for i := range delegateClients { + ref := delegateClients[i].JWKSRef + if ref == nil { + continue + } + value, err := resolveDelegateClientJWKS(ctx, c, namespace, i, ref) + if err != nil { + return "", err + } + if !wroteJWKSMarker { + _, _ = hash.Write([]byte("delegateClientJWKS\x00")) + wroteJWKSMarker = true + } + _, _ = hash.Write(value) + _, _ = hash.Write([]byte{0}) + found = true + } if !found { return "", nil } return hex.EncodeToString(hash.Sum(nil)), nil } +// resolveDelegateClientJWKS returns the bytes of a delegate client's JWKS +// ConfigMap key, looking in data first and then binaryData. A missing key is an +// *InvalidCABundleError (terminal); a failed ConfigMap read is returned +// unwrapped because it may be transient. +func resolveDelegateClientJWKS( + ctx context.Context, c client.Reader, namespace string, index int, ref *mcpv1beta1.ConfigMapKeyRef, +) ([]byte, error) { + if ref.Name == "" || ref.Key == "" { + return nil, invalidCABundleError(fmt.Errorf("delegateClients[%d].jwksRef.name and jwksRef.key are required", index)) + } + configMap := &corev1.ConfigMap{} + if err := c.Get(ctx, client.ObjectKey{Namespace: namespace, Name: ref.Name}, configMap); err != nil { + return nil, fmt.Errorf("delegateClients[%d].jwksRef: failed to get ConfigMap %q in namespace %q: %w", + index, ref.Name, namespace, err) + } + if value, ok := configMap.Data[ref.Key]; ok { + return []byte(value), nil + } + if value, ok := configMap.BinaryData[ref.Key]; ok { + return value, nil + } + return nil, invalidCABundleError(fmt.Errorf( + "delegateClients[%d].jwksRef: ConfigMap %q does not contain key %q in data or binaryData", index, ref.Name, ref.Key)) +} + +// DelegateClientJWKSConfigMapNames returns the names of the ConfigMaps that +// hold the public JWKS of the delegate clients the runtime config is built +// from (the canonical list, or the deprecated one when the canonical block is +// absent). Field indexes use it so a JWKS edit enqueues reconciles. +func DelegateClientJWKSConfigMapNames(config *mcpv1beta1.EmbeddedAuthServerConfig) []string { + if config == nil { + return nil + } + var names []string + for _, delegateClient := range effectiveDelegateClients(config) { + if ref := delegateClient.JWKSRef; ref != nil && ref.Name != "" { + names = append(names, ref.Name) + } + } + return names +} + // GenerateAuthServerVolumes generates volumes and mounts for auth server // signing keys, HMAC secrets, Redis CA certificates, and CA bundles. // Returns an error when a CA bundle reference is malformed. diff --git a/cmd/thv-operator/pkg/controllerutil/authserver_jwks_test.go b/cmd/thv-operator/pkg/controllerutil/authserver_jwks_test.go new file mode 100644 index 0000000000..7c627d140c --- /dev/null +++ b/cmd/thv-operator/pkg/controllerutil/authserver_jwks_test.go @@ -0,0 +1,145 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package controllerutil + +import ( + "crypto/sha256" + "encoding/hex" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" +) + +func jwksChecksumTestClient(t *testing.T, objects ...client.Object) client.Client { + t.Helper() + scheme := runtime.NewScheme() + require.NoError(t, corev1.AddToScheme(scheme)) + return fake.NewClientBuilder().WithScheme(scheme).WithObjects(objects...).Build() +} + +func jwksTestConfigMap(data string) *corev1.ConfigMap { + return &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{Name: "jwks", Namespace: "ns"}, + Data: map[string]string{"jwks.json": data, "other": "unselected"}, + } +} + +func TestEmbeddedAuthServerConfigMapChecksum_DelegateClientJWKS(t *testing.T) { + t.Parallel() + + clients := []mcpv1beta1.DelegateClientConfig{{ClientID: "broker", JWKSRef: delegateJWKSTestRef("jwks", "jwks.json")}} + canonical := &mcpv1beta1.EmbeddedAuthServerConfig{InboundGrants: &mcpv1beta1.InboundGrantsConfig{ + TokenExchange: &mcpv1beta1.TokenExchangeInboundGrantConfig{DelegateClients: clients}, + }} + deprecated := &mcpv1beta1.EmbeddedAuthServerConfig{DelegateClients: clients} + + for name, cfg := range map[string]*mcpv1beta1.EmbeddedAuthServerConfig{"canonical": canonical, "deprecated": deprecated} { + t.Run(name, func(t *testing.T) { + t.Parallel() + cm := jwksTestConfigMap(`{"keys":[1]}`) + first, err := EmbeddedAuthServerConfigMapChecksumForConfig(t.Context(), jwksChecksumTestClient(t, cm), "ns", cfg) + require.NoError(t, err) + assert.NotEmpty(t, first) + + cm.Data["other"] = "changed" + same, err := EmbeddedAuthServerConfigMapChecksumForConfig(t.Context(), jwksChecksumTestClient(t, cm), "ns", cfg) + require.NoError(t, err) + assert.Equal(t, first, same, "unselected keys must not roll the pod") + + cm.Data["jwks.json"] = `{"keys":[2]}` + rotated, err := EmbeddedAuthServerConfigMapChecksumForConfig(t.Context(), jwksChecksumTestClient(t, cm), "ns", cfg) + require.NoError(t, err) + assert.NotEqual(t, first, rotated, "a JWKS edit must change the checksum") + }) + } +} + +// TestEmbeddedAuthServerConfigMapChecksum_UnchangedWithoutJWKS pins the checksum +// of configs without a delegate client jwksRef to the pre-JWKS byte sequence: a +// sha256 over each CA bundle followed by a NUL byte, with the trusted issuer +// bundles behind a "trustedIssuers\x00" marker. Changing it would roll every +// embedded-auth pod on operator upgrade. The expected value is a hand-written +// hash, not a call to the function under test. +func TestEmbeddedAuthServerConfigMapChecksum_UnchangedWithoutJWKS(t *testing.T) { + t.Parallel() + + upstreamPEM := testCertificatePEM(t) + issuerPEM := testCertificatePEM(t) + config := &mcpv1beta1.EmbeddedAuthServerConfig{ + UpstreamProviders: []mcpv1beta1.UpstreamProviderConfig{{ + Name: "issuer", Type: mcpv1beta1.UpstreamProviderTypeOIDC, + OIDCConfig: &mcpv1beta1.OIDCUpstreamConfig{CABundleRef: caBundleTestRef("")}, + }}, + TrustedIssuers: []mcpv1beta1.TrustedIssuerConfig{{CABundleRef: &mcpv1beta1.CABundleSource{ + ConfigMapRef: &corev1.ConfigMapKeySelector{LocalObjectReference: corev1.LocalObjectReference{Name: "issuer-ca"}}, + }}}, + InboundGrants: &mcpv1beta1.InboundGrantsConfig{TokenExchange: &mcpv1beta1.TokenExchangeInboundGrantConfig{ + DelegateClients: []mcpv1beta1.DelegateClientConfig{{ClientID: "secret-client"}}, + }}, + } + upstreamCM := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{Name: "bundle", Namespace: "ns"}, Data: map[string]string{"ca.crt": string(upstreamPEM)}, + } + issuerCM := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{Name: "issuer-ca", Namespace: "ns"}, Data: map[string]string{"ca.crt": string(issuerPEM)}, + } + + var want []byte + want = append(want, upstreamPEM...) + want = append(want, 0) + want = append(want, []byte("trustedIssuers\x00")...) + want = append(want, issuerPEM...) + want = append(want, 0) + sum := sha256.Sum256(want) + + got, err := EmbeddedAuthServerConfigMapChecksumForConfig( + t.Context(), jwksChecksumTestClient(t, upstreamCM, issuerCM), "ns", config) + require.NoError(t, err) + assert.Equal(t, hex.EncodeToString(sum[:]), got) +} + +func TestValidateEmbeddedAuthServerCABundles_DelegateClientJWKS(t *testing.T) { + t.Parallel() + + cfg := &mcpv1beta1.EmbeddedAuthServerConfig{DelegateClients: []mcpv1beta1.DelegateClientConfig{ + {ClientID: "broker", JWKSRef: delegateJWKSTestRef("jwks", "jwks.json")}, + }} + + require.NoError(t, ValidateEmbeddedAuthServerCABundles(t.Context(), jwksChecksumTestClient(t, jwksTestConfigMap("x")), "ns", cfg)) + + missingKey := jwksTestConfigMap("x") + delete(missingKey.Data, "jwks.json") + err := ValidateEmbeddedAuthServerCABundles(t.Context(), jwksChecksumTestClient(t, missingKey), "ns", cfg) + require.ErrorContains(t, err, `does not contain key "jwks.json"`) + var invalid *InvalidCABundleError + assert.ErrorAs(t, err, &invalid, "a missing key is terminal") + + err = ValidateEmbeddedAuthServerCABundles(t.Context(), jwksChecksumTestClient(t), "ns", cfg) + require.Error(t, err) + assert.NotErrorAs(t, err, &invalid, "a missing ConfigMap read may be transient") +} + +func TestDelegateClientJWKSConfigMapNames(t *testing.T) { + t.Parallel() + + clients := []mcpv1beta1.DelegateClientConfig{ + {ClientID: "a", JWKSRef: delegateJWKSTestRef("jwks-a", "k")}, + {ClientID: "secret"}, + {ClientID: "c", JWKSRef: delegateJWKSTestRef("jwks-c", "k")}, + } + assert.Nil(t, DelegateClientJWKSConfigMapNames(nil)) + assert.Equal(t, []string{"jwks-a", "jwks-c"}, DelegateClientJWKSConfigMapNames(&mcpv1beta1.EmbeddedAuthServerConfig{ + InboundGrants: &mcpv1beta1.InboundGrantsConfig{TokenExchange: &mcpv1beta1.TokenExchangeInboundGrantConfig{DelegateClients: clients}}, + })) + assert.Equal(t, []string{"jwks-a", "jwks-c"}, DelegateClientJWKSConfigMapNames( + &mcpv1beta1.EmbeddedAuthServerConfig{DelegateClients: clients})) +} diff --git a/cmd/thv-operator/pkg/controllerutil/ca_bundle.go b/cmd/thv-operator/pkg/controllerutil/ca_bundle.go index b083334a4f..4318562460 100644 --- a/cmd/thv-operator/pkg/controllerutil/ca_bundle.go +++ b/cmd/thv-operator/pkg/controllerutil/ca_bundle.go @@ -109,7 +109,11 @@ func validatePEMCertificates(value []byte) error { return nil } -// ValidateEmbeddedAuthServerCABundles resolves every embedded auth server CA reference. +// ValidateEmbeddedAuthServerCABundles resolves every embedded auth server CA +// reference and every delegate client JWKS reference. The JWKS references share +// this entry point so every caller that gates on CA bundles gets the same +// terminal-vs-transient handling: a missing key is an *InvalidCABundleError, a +// failed ConfigMap read is not. func ValidateEmbeddedAuthServerCABundles( ctx context.Context, c client.Reader, namespace string, cfg *mcpv1beta1.EmbeddedAuthServerConfig, ) error { @@ -133,5 +137,13 @@ func ValidateEmbeddedAuthServerCABundles( } } } + delegateClients := effectiveDelegateClients(cfg) + for i := range delegateClients { + if ref := delegateClients[i].JWKSRef; ref != nil { + if _, err := resolveDelegateClientJWKS(ctx, c, namespace, i, ref); err != nil { + return err + } + } + } return nil } From 16725923d318cc6fb36266bc9d1156f5ee1f6024 Mon Sep 17 00:00:00 2001 From: Jakub Hrozek Date: Mon, 2 Nov 2026 11:15:39 +0100 Subject: [PATCH 05/10] Describe the JWKS rollout in the auth server docs The operator now rolls the auth server pod when a delegate client's JWKS ConfigMap is edited, but the architecture docs still said an edit needs a manual restart. Say what happens on an operator-managed edit, and keep the restart instruction for the RunConfig path, where the runner still reads jwks_file once at startup. Co-authored-by: Mecatl --- docs/arch/09-operator-architecture.md | 2 +- docs/arch/17-token-exchange-delegation.md | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/arch/09-operator-architecture.md b/docs/arch/09-operator-architecture.md index d4f801cca8..00a3563f4d 100644 --- a/docs/arch/09-operator-architecture.md +++ b/docs/arch/09-operator-architecture.md @@ -228,7 +228,7 @@ When the referenced CA data changes, the operator watches the ConfigMap, compute For a complete example, see [`examples/operator/external-auth/mcpexternalauthconfig_private_ca.yaml`](../../examples/operator/external-auth/mcpexternalauthconfig_private_ca.yaml). -An embedded auth server's pre-provisioned delegate clients (`inboundGrants.tokenExchange.delegateClients`) can authenticate with `private_key_jwt`. The client sets `tokenEndpointAuthMethod: private_key_jwt` and `jwksRef`, a ConfigMap key holding its public JWKS, instead of `clientSecretRef`. The operator mounts that key read-only at `/etc/toolhive/authserver/delegate-jwks//jwks.json` and points the RunConfig `jwks_file` at it. These clients can also set `grantTypes`, `redirectUris` and a `tokenExchange` source policy (a client that sets `grantTypes`, or uses `private_key_jwt`, must declare `tokenExchange.selfIssuedSubjectTokens` as `any` or `restricted`); see [Token Exchange and Delegation](17-token-exchange-delegation.md). Editing the JWKS ConfigMap does not restart the pod; restart it to load new keys. +An embedded auth server's pre-provisioned delegate clients (`inboundGrants.tokenExchange.delegateClients`) can authenticate with `private_key_jwt`. The client sets `tokenEndpointAuthMethod: private_key_jwt` and `jwksRef`, a ConfigMap key holding its public JWKS, instead of `clientSecretRef`. The operator mounts that key read-only at `/etc/toolhive/authserver/delegate-jwks//jwks.json` and points the RunConfig `jwks_file` at it. These clients can also set `grantTypes`, `redirectUris` and a `tokenExchange` source policy (a client that sets `grantTypes`, or uses `private_key_jwt`, must declare `tokenExchange.selfIssuedSubjectTokens` as `any` or `restricted`); see [Token Exchange and Delegation](17-token-exchange-delegation.md). Editing the JWKS ConfigMap rolls the pod: the JWKS bytes are part of the pod-template checksum annotation, so the next reconcile restarts the auth server with the new keys. The stored client keeps its `client_id`. Because appending a certificate to a referenced ConfigMap makes that CA a trust anchor — for an upstream provider's login, or for a trusted issuer's subject-token validation — restrict write access to a `caBundleRef` ConfigMap with the same RBAC care as the auth server's signing-key Secret. diff --git a/docs/arch/17-token-exchange-delegation.md b/docs/arch/17-token-exchange-delegation.md index 1c2b8cd8df..247e564341 100644 --- a/docs/arch/17-token-exchange-delegation.md +++ b/docs/arch/17-token-exchange-delegation.md @@ -656,8 +656,10 @@ DCR-issued, so it has no TTL and does not count against `max_dcr_clients`. At startup the runner reads each `jwks_file` next to the existing `client_secret_file` handling, so rotating a key means replacing the file and -restarting. The stored client keeps its `client_id`; the new key set replaces -the old one. Discovery lists the client authentication methods as described in +restarting. When the operator manages the client, edit the JWKS ConfigMap +instead: the pod rolls on the edit, because the JWKS bytes are part of the +pod-template checksum. The stored client keeps its `client_id`; the new key set +replaces the old one. Discovery lists the client authentication methods as described in [Discovery and secret rotation](#discovery-and-secret-rotation). The cleartext-transport rule is separate: every delegate client, `private_key_jwt` ones included, counts as confidential there. From 16c7e5cd00d9d92959fa98e8b5845468d906cbea Mon Sep 17 00:00:00 2001 From: Jakub Hrozek Date: Mon, 2 Nov 2026 15:42:45 +0100 Subject: [PATCH 06/10] Test delegate client JWKS rotation and exchange on kind The delegate client JWKS feature depends on behaviour a fake API server cannot show: the auth server pod reading the mounted key at the path the operator sets, and a pod roll when the JWKS ConfigMap changes. This adds a kind suite on a VirtualMCPServer with a restricted private_key_jwt delegate that covers the mount and a matching-key exchange, a wrong key under a registered kid, a foreign subject token that restricted refuses, and a two-stage key rotation that waits for the roll before it checks which key is trusted. A retired kid is no longer registered, so the server refuses it as a malformed request (400 invalid_request), not as a wrong key under a registered kid (401 invalid_client). Co-authored-by: Mecatl --- .../virtualmcp_delegate_jwks_test.go | 435 ++++++++++++++++++ 1 file changed, 435 insertions(+) create mode 100644 test/e2e/thv-operator/virtualmcp/virtualmcp_delegate_jwks_test.go diff --git a/test/e2e/thv-operator/virtualmcp/virtualmcp_delegate_jwks_test.go b/test/e2e/thv-operator/virtualmcp/virtualmcp_delegate_jwks_test.go new file mode 100644 index 0000000000..dc5657de47 --- /dev/null +++ b/test/e2e/thv-operator/virtualmcp/virtualmcp_delegate_jwks_test.go @@ -0,0 +1,435 @@ +// SPDX-FileCopyrightText: Copyright 2026 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package virtualmcp + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/rsa" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "github.com/go-jose/go-jose/v4" + "github.com/go-jose/go-jose/v4/jwt" + "github.com/onsi/ginkgo/v2" + "github.com/onsi/gomega" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" + "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1/v1beta1test" + ctrlutil "github.com/stacklok/toolhive/cmd/thv-operator/pkg/controllerutil" + "github.com/stacklok/toolhive/pkg/oauthproto" + "github.com/stacklok/toolhive/test/e2e/images" + "github.com/stacklok/toolhive/test/e2e/thv-operator/testutil" +) + +// This test drives a private_key_jwt delegate client whose public JWKS lives in +// a ConfigMap (jwksRef) on a VirtualMCPServer's embedded authorization server. +// The client authenticates the way an embedding broker does: an ES256 client +// assertion with a kid header, exchanging a self-issued subject token. Admission +// rules are covered by envtest; these cases prove the runtime behaviour. +var _ = ginkgo.Describe("VirtualMCPServer delegate client jwksRef", ginkgo.Ordered, func() { + const ( + timeout = 5 * time.Minute + pollInterval = 2 * time.Second + delegateID = "e2e-jwks-delegate" + allowedSource = "e2e-jwks-allowed-source" + foreignSource = "e2e-jwks-foreign-source" + subject = "jwks-subject" + jwksKey = "jwks.json" + kidA = "e2e-key-a" + kidB = "e2e-key-b" + ) + + var ( + backendName, groupName, hmacSecretName, oidcConfigName string + signingKeySecretName, jwksConfigMapName, vmcpName string + issuer string + serverKey *rsa.PrivateKey + keyA, keyB, strangerKey *ecdsa.PrivateKey + ) + + ginkgo.BeforeAll(func() { + suffix := fmt.Sprintf("%d-%d", ginkgo.GinkgoParallelProcess(), time.Now().UnixNano()) + backendName = "e2e-jwks-backend-" + suffix + groupName = "e2e-jwks-group-" + suffix + hmacSecretName = "e2e-jwks-hmac-" + suffix + oidcConfigName = "e2e-jwks-oidc-" + suffix + signingKeySecretName = "e2e-jwks-key-" + suffix + jwksConfigMapName = "e2e-jwks-cm-" + suffix + vmcpName = "e2e-jwks-vmcp-" + suffix + issuer = fmt.Sprintf("https://vmcp-%s.%s.svc.cluster.local:4483", vmcpName, defaultNamespace) + + var err error + serverKey, err = rsa.GenerateKey(rand.Reader, 2048) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + keyA = newDelegateJWKSKey() + keyB = newDelegateJWKSKey() + strangerKey = newDelegateJWKSKey() + + hmac := make([]byte, 32) + _, err = rand.Read(hmac) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + createFixture := func(obj client.Object) { + gomega.Expect(k8sClient.Create(ctx, obj)).To(gomega.Succeed()) + ginkgo.DeferCleanup(func() { + gomega.Expect(client.IgnoreNotFound(k8sClient.Delete(ctx, obj))).To(gomega.Succeed()) + }) + } + + ginkgo.By("creating the signing key, HMAC secret and the public-only JWKS ConfigMap") + createFixture(&corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: signingKeySecretName, Namespace: defaultNamespace}, + Data: map[string][]byte{"private-key": pemEncodePKCS1(serverKey)}, + }) + createFixture(&corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: hmacSecretName, Namespace: defaultNamespace}, + Data: map[string][]byte{"hmac": hmac}, + }) + createFixture(&corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{Name: jwksConfigMapName, Namespace: defaultNamespace}, + Data: map[string]string{jwksKey: delegateJWKSDocument(delegateJWKSEntry{kidA, keyA})}, + }) + createFixture(&mcpv1beta1.MCPOIDCConfig{ + ObjectMeta: metav1.ObjectMeta{Name: oidcConfigName, Namespace: defaultNamespace}, + Spec: mcpv1beta1.MCPOIDCConfigSpec{Type: mcpv1beta1.MCPOIDCConfigTypeInline, + Inline: &mcpv1beta1.InlineOIDCSharedConfig{Issuer: issuer, JWKSAllowPrivateIP: true}}, + }) + + CreateMCPGroupAndWait(ctx, k8sClient, groupName, defaultNamespace, "delegate jwksRef e2e group", timeout, pollInterval) + ginkgo.DeferCleanup(func() { + group := &mcpv1beta1.MCPGroup{ObjectMeta: metav1.ObjectMeta{Name: groupName, Namespace: defaultNamespace}} + gomega.Expect(client.IgnoreNotFound(k8sClient.Delete(ctx, group))).To(gomega.Succeed()) + WaitForObjectDeletion(ctx, k8sClient, group, timeout, pollInterval) + }) + createFixture(v1beta1test.NewMCPServer(backendName, defaultNamespace, + v1beta1test.WithImage(images.YardstickServerImage), + v1beta1test.WithTransport("streamable-http"), + v1beta1test.WithProxyPort(8080), + v1beta1test.WithMCPPort(8080), + v1beta1test.WithMCPGroupRef(groupName), + )) + gomega.Eventually(func() mcpv1beta1.MCPServerPhase { + server := &mcpv1beta1.MCPServer{} + gomega.Expect(k8sClient.Get(ctx, types.NamespacedName{Name: backendName, Namespace: defaultNamespace}, server)).To(gomega.Succeed()) + return server.Status.Phase + }, timeout, pollInterval).Should(gomega.Equal(mcpv1beta1.MCPServerPhaseReady)) + + ginkgo.By("creating the VirtualMCPServer with a restricted private_key_jwt delegate client") + vmcp := v1beta1test.NewVirtualMCPServer(vmcpName, defaultNamespace, + v1beta1test.WithVMCPGroupRef(groupName), + v1beta1test.WithVMCPIncomingAuth(&mcpv1beta1.IncomingAuthConfig{Type: "oidc", OIDCConfigRef: &mcpv1beta1.MCPOIDCConfigReference{ + Name: oidcConfigName, Audience: issuer, ResourceURL: issuer, + }}), + v1beta1test.WithVMCPAuthServerConfig(&mcpv1beta1.EmbeddedAuthServerConfig{ + Issuer: issuer, + SigningKeySecretRefs: []mcpv1beta1.SecretKeyRef{{Name: signingKeySecretName, Key: "private-key"}}, + HMACSecretRefs: []mcpv1beta1.SecretKeyRef{{Name: hmacSecretName, Key: "hmac"}}, + InboundGrants: &mcpv1beta1.InboundGrantsConfig{TokenExchange: &mcpv1beta1.TokenExchangeInboundGrantConfig{ + DelegateClients: []mcpv1beta1.DelegateClientConfig{{ + ClientID: delegateID, + TokenEndpointAuthMethod: oauthproto.TokenEndpointAuthMethodPrivateKeyJWT, + JWKSRef: &mcpv1beta1.ConfigMapKeyRef{Name: jwksConfigMapName, Key: jwksKey}, + Scopes: []string{"profile"}, + Audiences: []string{issuer}, + TokenExchange: &mcpv1beta1.DelegateClientTokenExchangeConfig{ + SelfIssuedSubjectTokens: "restricted", + AllowedSourceClients: []string{allowedSource}, + }, + }}, + }}, + }), + ) + gomega.Expect(k8sClient.Create(ctx, vmcp)).To(gomega.Succeed()) + ginkgo.DeferCleanup(func() { + gomega.Expect(client.IgnoreNotFound(k8sClient.Delete(ctx, vmcp))).To(gomega.Succeed()) + WaitForObjectDeletion(ctx, k8sClient, vmcp, timeout, pollInterval) + }) + WaitForVirtualMCPServerReady(ctx, k8sClient, vmcpName, defaultNamespace, timeout, pollInterval) + WaitForCondition(ctx, k8sClient, vmcpName, defaultNamespace, mcpv1beta1.ConditionTypeAuthServerConfigValidated, "True", timeout, pollInterval) + }) + + // subjectToken is a self-issued subject token whose client_id claim is sourceClient. + subjectToken := func(sourceClient string) string { + return signSelfIssuedSubjectToken(serverKey, issuer, sourceClient, subject) + } + + ginkgo.It("mounts the JWKS read-only and accepts an assertion signed with the matching key", func() { + deployment := &appsv1.Deployment{} + gomega.Expect(k8sClient.Get(ctx, types.NamespacedName{Name: vmcpName, Namespace: defaultNamespace}, deployment)).To(gomega.Succeed()) + + var volume *corev1.Volume + for i := range deployment.Spec.Template.Spec.Volumes { + if deployment.Spec.Template.Spec.Volumes[i].Name == ctrlutil.AuthServerDelegateClientJWKSVolumePrefix+"0" { + volume = &deployment.Spec.Template.Spec.Volumes[i] + } + } + gomega.Expect(volume).NotTo(gomega.BeNil()) + gomega.Expect(volume.ConfigMap).NotTo(gomega.BeNil()) + gomega.Expect(volume.ConfigMap.Name).To(gomega.Equal(jwksConfigMapName)) + gomega.Expect(volume.ConfigMap.Items).To(gomega.Equal([]corev1.KeyToPath{{Key: jwksKey, Path: ctrlutil.AuthServerDelegateClientJWKSFileName}})) + + const jwksPath = "/etc/toolhive/authserver/delegate-jwks/0/jwks.json" + var mount *corev1.VolumeMount + for _, container := range deployment.Spec.Template.Spec.Containers { + for i := range container.VolumeMounts { + if container.VolumeMounts[i].MountPath == jwksPath { + mount = &container.VolumeMounts[i] + } + } + } + gomega.Expect(mount).NotTo(gomega.BeNil()) + gomega.Expect(mount.Name).To(gomega.Equal(volume.Name)) + gomega.Expect(mount.ReadOnly).To(gomega.BeTrue()) + gomega.Expect(mount.SubPath).To(gomega.Equal(ctrlutil.AuthServerDelegateClientJWKSFileName)) + + configMap := &corev1.ConfigMap{} + gomega.Expect(k8sClient.Get(ctx, types.NamespacedName{Name: vmcpName + "-vmcp-config", Namespace: defaultNamespace}, configMap)).To(gomega.Succeed()) + gomega.Expect(configMap.Data["authserver-config.yaml"]).To(gomega.ContainSubstring(jwksPath)) + + localURL, cleanup := portForwardDelegateAuthServer(vmcpName) + defer cleanup() + status, body, err := exchangeWithDelegateAssertion(localURL, delegateID, + signDelegateAssertion(keyA, kidA, delegateID, issuer), subjectToken(delegateID), issuer) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + gomega.Expect(status).To(gomega.Equal(http.StatusOK), string(body)) + var token struct { + AccessToken string `json:"access_token"` + } + gomega.Expect(json.Unmarshal(body, &token)).To(gomega.Succeed()) + claims := verifiedJWTClaims(token.AccessToken, &serverKey.PublicKey) + gomega.Expect(claims["iss"]).To(gomega.Equal(issuer)) + gomega.Expect(claims["sub"]).To(gomega.Equal(subject)) + act, ok := claims["act"].(map[string]any) + gomega.Expect(ok).To(gomega.BeTrue(), "token has no act claim object: %v", claims) + gomega.Expect(act["sub"]).To(gomega.Equal(delegateID)) + }) + + ginkgo.It("rejects an assertion signed with a different key under the registered kid", func() { + localURL, cleanup := portForwardDelegateAuthServer(vmcpName) + defer cleanup() + + status, body, err := exchangeWithDelegateAssertion(localURL, delegateID, + signDelegateAssertion(strangerKey, kidA, delegateID, issuer), subjectToken(delegateID), issuer) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + gomega.Expect(status).To(gomega.Equal(http.StatusUnauthorized), string(body)) + gomega.Expect(delegateOAuthError(body)).To(gomega.Equal("invalid_client"), string(body)) + + ginkgo.By("accepting the same subject token with the registered key") + status, body, err = exchangeWithDelegateAssertion(localURL, delegateID, + signDelegateAssertion(keyA, kidA, delegateID, issuer), subjectToken(delegateID), issuer) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + gomega.Expect(status).To(gomega.Equal(http.StatusOK), string(body)) + }) + + ginkgo.It("refuses a self-issued subject token from a client that is neither the delegate nor an allowed source", func() { + localURL, cleanup := portForwardDelegateAuthServer(vmcpName) + defer cleanup() + exchange := func(sourceClient string) (int, []byte) { + status, body, err := exchangeWithDelegateAssertion(localURL, delegateID, + signDelegateAssertion(keyA, kidA, delegateID, issuer), subjectToken(sourceClient), issuer) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + return status, body + } + + status, body := exchange(foreignSource) + gomega.Expect(status).To(gomega.Equal(http.StatusBadRequest), string(body)) + gomega.Expect(delegateOAuthError(body)).To(gomega.Equal("invalid_grant"), string(body)) + gomega.Expect(string(body)).To(gomega.ContainSubstring("not issued to this client or to one of its allowed source clients")) + + ginkgo.By("accepting a token from an allowedSourceClients entry") + status, body = exchange(allowedSource) + gomega.Expect(status).To(gomega.Equal(http.StatusOK), string(body)) + }) + + ginkgo.It("rolls the pod on a JWKS edit and moves trust from the old key to the new one", func() { + rotate := func(entries ...delegateJWKSEntry) { + before := delegatePodTemplateChecksum(vmcpName) + cm := &corev1.ConfigMap{} + gomega.Expect(k8sClient.Get(ctx, types.NamespacedName{Name: jwksConfigMapName, Namespace: defaultNamespace}, cm)).To(gomega.Succeed()) + cm.Data[jwksKey] = delegateJWKSDocument(entries...) + gomega.Expect(k8sClient.Update(ctx, cm)).To(gomega.Succeed()) + + gomega.Eventually(func() string { + return delegatePodTemplateChecksum(vmcpName) + }, timeout, pollInterval).ShouldNot(gomega.Or(gomega.Equal(before), gomega.BeEmpty())) + gomega.Eventually(func() bool { + return delegateDeploymentRolledOut(vmcpName) + }, timeout, pollInterval).Should(gomega.BeTrue()) + delegateWaitForSingleReadyPod(vmcpName, timeout, pollInterval) + WaitForVirtualMCPServerReady(ctx, k8sClient, vmcpName, defaultNamespace, timeout, pollInterval) + } + + ginkgo.By("publishing the new key next to the old one") + rotate(delegateJWKSEntry{kidA, keyA}, delegateJWKSEntry{kidB, keyB}) + localURL, cleanup := portForwardDelegateAuthServer(vmcpName) + defer cleanup() + // keyB is the load-bearing check: it only succeeds once the new JWKS is loaded. + eventuallyDelegateExchange(localURL, delegateID, issuer, subjectToken(delegateID), keyB, kidB, http.StatusOK) + // keyA is a control: the old key must keep working during the overlap. + eventuallyDelegateExchange(localURL, delegateID, issuer, subjectToken(delegateID), keyA, kidA, http.StatusOK) + cleanup() + + ginkgo.By("retiring the old key") + rotate(delegateJWKSEntry{kidB, keyB}) + localURL, cleanupAfter := portForwardDelegateAuthServer(vmcpName) + defer cleanupAfter() + // keyA rejection is the load-bearing check: it only holds once the retired key is gone. + // The kid is no longer registered, so fosite rejects the assertion as a malformed + // request (400 invalid_request, "kid ... could not be found"), not as a wrong key + // under a registered kid (401 invalid_client, as in the wrong-key case above). + body := eventuallyDelegateExchange(localURL, delegateID, issuer, subjectToken(delegateID), keyA, kidA, http.StatusBadRequest) + gomega.Expect(delegateOAuthError(body)).To(gomega.Equal("invalid_request"), string(body)) + // keyB is a control: the surviving key must still be accepted. + eventuallyDelegateExchange(localURL, delegateID, issuer, subjectToken(delegateID), keyB, kidB, http.StatusOK) + }) +}) + +type delegateJWKSEntry struct { + kid string + key *ecdsa.PrivateKey +} + +func newDelegateJWKSKey() *ecdsa.PrivateKey { + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + return key +} + +// delegateJWKSDocument serialises the public halves of the given keys as a +// JWKS. Private material never reaches the ConfigMap. +func delegateJWKSDocument(entries ...delegateJWKSEntry) string { + set := jose.JSONWebKeySet{} + for _, entry := range entries { + set.Keys = append(set.Keys, jose.JSONWebKey{Key: &entry.key.PublicKey, KeyID: entry.kid, Algorithm: "ES256", Use: "sig"}) + } + doc, err := json.Marshal(set) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + gomega.Expect(string(doc)).NotTo(gomega.ContainSubstring(`"d"`)) + return string(doc) +} + +func signDelegateAssertion(key *ecdsa.PrivateKey, kid, clientID, audience string) string { + signer, err := jose.NewSigner(jose.SigningKey{Algorithm: jose.ES256, Key: key}, + (&jose.SignerOptions{}).WithType("client-authentication+jwt").WithHeader("kid", kid)) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + now := time.Now() + assertion, err := jwt.Signed(signer).Claims(jwt.Claims{ + Issuer: clientID, Subject: clientID, Audience: jwt.Audience{audience}, + IssuedAt: jwt.NewNumericDate(now), Expiry: jwt.NewNumericDate(now.Add(time.Minute)), ID: rand.Text(), + }).Serialize() + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + return assertion +} + +// exchangeWithDelegateAssertion returns transport errors instead of asserting +// so callers can poll across a pod roll. +func exchangeWithDelegateAssertion(endpoint, clientID, assertion, subjectToken, audience string) (int, []byte, error) { + form := url.Values{ + "grant_type": {oauthproto.GrantTypeTokenExchange}, "subject_token": {subjectToken}, + "subject_token_type": {oauthproto.TokenTypeJWT}, "audience": {audience}, "client_id": {clientID}, + "client_assertion_type": {"urn:ietf:params:oauth:client-assertion-type:jwt-bearer"}, "client_assertion": {assertion}, + } + request, err := http.NewRequestWithContext(context.Background(), http.MethodPost, endpoint+"/oauth/token", strings.NewReader(form.Encode())) + if err != nil { + return 0, nil, err + } + request.Header.Set("Content-Type", "application/x-www-form-urlencoded") + response, err := privateKeyJWTHTTPClient.Do(request) + if err != nil { + return 0, nil, err + } + defer response.Body.Close() + body, err := io.ReadAll(response.Body) + return response.StatusCode, body, err +} + +// eventuallyDelegateExchange retries with a fresh assertion each attempt until +// the endpoint answers wantStatus, and returns that response body. +func eventuallyDelegateExchange( + endpoint, clientID, audience, subjectToken string, key *ecdsa.PrivateKey, kid string, wantStatus int, +) []byte { + var lastBody []byte + gomega.Eventually(func() int { + status, body, err := exchangeWithDelegateAssertion(endpoint, clientID, + signDelegateAssertion(key, kid, clientID, audience), subjectToken, audience) + if err != nil { + return 0 + } + lastBody = body + return status + }, 2*time.Minute, time.Second).Should(gomega.Equal(wantStatus), func() string { return string(lastBody) }) + return lastBody +} + +func delegateOAuthError(body []byte) string { + var parsed struct { + Error string `json:"error"` + } + gomega.Expect(json.Unmarshal(body, &parsed)).To(gomega.Succeed(), string(body)) + return parsed.Error +} + +func delegatePodTemplateChecksum(vmcpName string) string { + deployment := &appsv1.Deployment{} + if err := k8sClient.Get(ctx, types.NamespacedName{Name: vmcpName, Namespace: defaultNamespace}, deployment); err != nil { + gomega.Expect(apierrors.IsNotFound(err)).To(gomega.BeTrue()) + return "" + } + return deployment.Spec.Template.Annotations[ctrlutil.AuthServerConfigMapChecksumAnnotation] +} + +// delegateWaitForSingleReadyPod waits until exactly one vMCP pod exists, it is +// Ready, and no pod is terminating. Deployment status does not count +// terminating pods, so a rollout check alone can pass while the old pod is +// still alive. +func delegateWaitForSingleReadyPod(vmcpName string, timeout, pollInterval time.Duration) { + labels := map[string]string{ + "app.kubernetes.io/name": "virtualmcpserver", + "app.kubernetes.io/instance": vmcpName, + } + gomega.Eventually(func() error { + pods := &corev1.PodList{} + if err := k8sClient.List(ctx, pods, client.InNamespace(defaultNamespace), client.MatchingLabels(labels)); err != nil { + return err + } + for i := range pods.Items { + if pods.Items[i].DeletionTimestamp != nil { + return fmt.Errorf("pod %s is still terminating", pods.Items[i].Name) + } + } + if len(pods.Items) != 1 { + return fmt.Errorf("expected exactly 1 pod, found %d", len(pods.Items)) + } + return testutil.CheckPodsReadyCount(ctx, k8sClient, defaultNamespace, labels, 1) + }, timeout, pollInterval).Should(gomega.Succeed()) +} + +// delegateDeploymentRolledOut reports whether the Deployment has finished +// rolling its current template: no old replicas remain and all new ones are available. +func delegateDeploymentRolledOut(vmcpName string) bool { + deployment := &appsv1.Deployment{} + if err := k8sClient.Get(ctx, types.NamespacedName{Name: vmcpName, Namespace: defaultNamespace}, deployment); err != nil { + return false + } + want := int32(1) + if deployment.Spec.Replicas != nil { + want = *deployment.Spec.Replicas + } + status := deployment.Status + return status.ObservedGeneration >= deployment.Generation && + status.UpdatedReplicas == want && status.Replicas == want && status.AvailableReplicas == want +} From bf46917892ca6c16a34390ab113c77ad944f5eda Mon Sep 17 00:00:00 2001 From: Jakub Hrozek Date: Mon, 2 Nov 2026 16:23:52 +0100 Subject: [PATCH 07/10] Fix lint findings in the delegate JWKS change CI lint failed on three findings in the delegate JWKS change: a field alignment that gci rejects in the deployment test, and two functions over the cyclomatic complexity limit. Split the VirtualMCPServer ConfigMap indexer into an authz helper and an auth server helper, and move the spec-level auth server checks into validateAuthServerSpec so the reconciler has one branch for them. The checks and their messages are unchanged. The kind test's exchange helper always received the same client ID, so it now uses a suite constant and the parameter is gone. Co-authored-by: Mecatl --- .../virtualmcpserver_authz_configmap.go | 55 ++++++++++++------- .../virtualmcpserver_controller.go | 46 +++++++--------- .../virtualmcpserver_deployment_test.go | 6 +- .../virtualmcp_delegate_jwks_test.go | 20 ++++--- 4 files changed, 69 insertions(+), 58 deletions(-) diff --git a/cmd/thv-operator/controllers/virtualmcpserver_authz_configmap.go b/cmd/thv-operator/controllers/virtualmcpserver_authz_configmap.go index ee6ec7dccc..f635f01414 100644 --- a/cmd/thv-operator/controllers/virtualmcpserver_authz_configmap.go +++ b/cmd/thv-operator/controllers/virtualmcpserver_authz_configmap.go @@ -31,27 +31,11 @@ func indexVirtualMCPServerConfigMaps(obj client.Object) []string { } names := make(map[string]struct{}) - if vmcp.Spec.IncomingAuth != nil && vmcp.Spec.IncomingAuth.AuthzConfig != nil && - vmcp.Spec.IncomingAuth.AuthzConfig.Type == mcpv1beta1.AuthzConfigTypeConfigMap && - vmcp.Spec.IncomingAuth.AuthzConfig.ConfigMap != nil && - vmcp.Spec.IncomingAuth.AuthzConfig.ConfigMap.Name != "" { - names[vmcp.Spec.IncomingAuth.AuthzConfig.ConfigMap.Name] = struct{}{} + if name := authzConfigMapName(vmcp); name != "" { + names[name] = struct{}{} } - if vmcp.Spec.AuthServerConfig != nil { - for _, provider := range vmcp.Spec.AuthServerConfig.UpstreamProviders { - if name := providerCABundleConfigMapName(provider); name != "" { - names[name] = struct{}{} - } - } - for i := range vmcp.Spec.AuthServerConfig.TrustedIssuers { - ref := vmcp.Spec.AuthServerConfig.TrustedIssuers[i].CABundleRef - if ref != nil && ref.ConfigMapRef != nil && ref.ConfigMapRef.Name != "" { - names[ref.ConfigMapRef.Name] = struct{}{} - } - } - for _, name := range ctrlutil.DelegateClientJWKSConfigMapNames(vmcp.Spec.AuthServerConfig) { - names[name] = struct{}{} - } + for _, name := range authServerConfigMapNames(vmcp.Spec.AuthServerConfig) { + names[name] = struct{}{} } result := make([]string, 0, len(names)) @@ -61,6 +45,37 @@ func indexVirtualMCPServerConfigMaps(obj client.Object) []string { return result } +// authzConfigMapName returns the authz ConfigMap name the server references, or "". +func authzConfigMapName(vmcp *mcpv1beta1.VirtualMCPServer) string { + if vmcp.Spec.IncomingAuth == nil || vmcp.Spec.IncomingAuth.AuthzConfig == nil || + vmcp.Spec.IncomingAuth.AuthzConfig.Type != mcpv1beta1.AuthzConfigTypeConfigMap || + vmcp.Spec.IncomingAuth.AuthzConfig.ConfigMap == nil { + return "" + } + return vmcp.Spec.IncomingAuth.AuthzConfig.ConfigMap.Name +} + +// authServerConfigMapNames returns the ConfigMaps the inline auth server reads: +// upstream and trusted-issuer CA bundles, and delegate client JWKS. +func authServerConfigMapNames(cfg *mcpv1beta1.EmbeddedAuthServerConfig) []string { + if cfg == nil { + return nil + } + var names []string + for _, provider := range cfg.UpstreamProviders { + if name := providerCABundleConfigMapName(provider); name != "" { + names = append(names, name) + } + } + for i := range cfg.TrustedIssuers { + ref := cfg.TrustedIssuers[i].CABundleRef + if ref != nil && ref.ConfigMapRef != nil && ref.ConfigMapRef.Name != "" { + names = append(names, ref.ConfigMapRef.Name) + } + } + return append(names, ctrlutil.DelegateClientJWKSConfigMapNames(cfg)...) +} + // mapAuthzConfigMapToVirtualMCPServer maps ConfigMap changes to VirtualMCPServer reconciliation // requests. Used by SetupWithManager to trigger reconciliation when a ConfigMap referenced via // spec.incomingAuth.authzConfig.configMap is updated, so the converter can re-resolve policies diff --git a/cmd/thv-operator/controllers/virtualmcpserver_controller.go b/cmd/thv-operator/controllers/virtualmcpserver_controller.go index 23f6076e0a..97f5c518ae 100644 --- a/cmd/thv-operator/controllers/virtualmcpserver_controller.go +++ b/cmd/thv-operator/controllers/virtualmcpserver_controller.go @@ -683,6 +683,23 @@ func (*VirtualMCPServerReconciler) validateSessionStorageForReplicas( } } +// validateAuthServerSpec runs the spec-level checks on an inline AuthServerConfig: +// the issuer is set, the inbound grants are consistent, and the delegate clients +// (including the redirect URI and source client rules) are valid. The messages are +// the ones the AuthServerConfigValidated condition reports. +func validateAuthServerSpec(cfg *mcpv1beta1.EmbeddedAuthServerConfig) error { + if cfg.Issuer == "" { + return stderrors.New("spec.authServerConfig.issuer is required") + } + if err := cfg.ValidateInboundGrants(); err != nil { + return fmt.Errorf("spec.authServerConfig: %w", err) + } + if err := mcpv1beta1.ValidateEmbeddedAuthServerDelegateClients(cfg); err != nil { + return fmt.Errorf("spec.authServerConfig: %w", err) + } + return nil +} + // validateAuthServerConfig validates inline AuthServerConfig and sets the // AuthServerConfigValidated condition. Returns an error when validation fails // (caller should NOT requeue — user must fix the spec). @@ -692,33 +709,8 @@ func (*VirtualMCPServerReconciler) validateAuthServerConfig( ) error { cfg := vmcp.Spec.AuthServerConfig - if cfg.Issuer == "" { - message := "spec.authServerConfig.issuer is required" - statusManager.SetPhase(mcpv1beta1.VirtualMCPServerPhaseFailed) - statusManager.SetMessage(message) - statusManager.SetAuthServerConfigValidatedCondition( - mcpv1beta1.ConditionReasonAuthServerConfigInvalid, - message, - metav1.ConditionFalse, - ) - statusManager.SetObservedGeneration(vmcp.Generation) - return stderrors.New(message) - } - - if err := cfg.ValidateInboundGrants(); err != nil { - message := fmt.Sprintf("spec.authServerConfig: %v", err) - statusManager.SetPhase(mcpv1beta1.VirtualMCPServerPhaseFailed) - statusManager.SetMessage(message) - statusManager.SetAuthServerConfigValidatedCondition( - mcpv1beta1.ConditionReasonAuthServerConfigInvalid, - message, - metav1.ConditionFalse, - ) - statusManager.SetObservedGeneration(vmcp.Generation) - return stderrors.New(message) - } - if err := mcpv1beta1.ValidateEmbeddedAuthServerDelegateClients(cfg); err != nil { - message := fmt.Sprintf("spec.authServerConfig: %v", err) + if err := validateAuthServerSpec(cfg); err != nil { + message := err.Error() statusManager.SetPhase(mcpv1beta1.VirtualMCPServerPhaseFailed) statusManager.SetMessage(message) statusManager.SetAuthServerConfigValidatedCondition( diff --git a/cmd/thv-operator/controllers/virtualmcpserver_deployment_test.go b/cmd/thv-operator/controllers/virtualmcpserver_deployment_test.go index 02b484f5c0..2a962d4f7c 100644 --- a/cmd/thv-operator/controllers/virtualmcpserver_deployment_test.go +++ b/cmd/thv-operator/controllers/virtualmcpserver_deployment_test.go @@ -138,9 +138,9 @@ func TestDeploymentForVirtualMCPServer_WithDelegateClientJWKS(t *testing.T) { ClientID: "broker", TokenEndpointAuthMethod: "private_key_jwt", JWKSRef: &mcpv1beta1.ConfigMapKeyRef{Name: "broker-jwks", Key: "jwks.json"}, - Scopes: []string{"openid"}, - Audiences: []string{"https://resource.example.com"}, - TokenExchange: &mcpv1beta1.DelegateClientTokenExchangeConfig{SelfIssuedSubjectTokens: "any"}, + Scopes: []string{"openid"}, + Audiences: []string{"https://resource.example.com"}, + TokenExchange: &mcpv1beta1.DelegateClientTokenExchangeConfig{SelfIssuedSubjectTokens: "any"}, }, }, }, diff --git a/test/e2e/thv-operator/virtualmcp/virtualmcp_delegate_jwks_test.go b/test/e2e/thv-operator/virtualmcp/virtualmcp_delegate_jwks_test.go index dc5657de47..2794d6cc6c 100644 --- a/test/e2e/thv-operator/virtualmcp/virtualmcp_delegate_jwks_test.go +++ b/test/e2e/thv-operator/virtualmcp/virtualmcp_delegate_jwks_test.go @@ -41,11 +41,15 @@ import ( // The client authenticates the way an embedding broker does: an ES256 client // assertion with a kid header, exchanging a self-issued subject token. Admission // rules are covered by envtest; these cases prove the runtime behaviour. + +// jwksDelegateClientID is the delegate client every case in this suite exchanges as. +const jwksDelegateClientID = "e2e-jwks-delegate" + var _ = ginkgo.Describe("VirtualMCPServer delegate client jwksRef", ginkgo.Ordered, func() { const ( timeout = 5 * time.Minute pollInterval = 2 * time.Second - delegateID = "e2e-jwks-delegate" + delegateID = jwksDelegateClientID allowedSource = "e2e-jwks-allowed-source" foreignSource = "e2e-jwks-foreign-source" subject = "jwks-subject" @@ -278,9 +282,9 @@ var _ = ginkgo.Describe("VirtualMCPServer delegate client jwksRef", ginkgo.Order localURL, cleanup := portForwardDelegateAuthServer(vmcpName) defer cleanup() // keyB is the load-bearing check: it only succeeds once the new JWKS is loaded. - eventuallyDelegateExchange(localURL, delegateID, issuer, subjectToken(delegateID), keyB, kidB, http.StatusOK) + eventuallyDelegateExchange(localURL, issuer, subjectToken(delegateID), keyB, kidB, http.StatusOK) // keyA is a control: the old key must keep working during the overlap. - eventuallyDelegateExchange(localURL, delegateID, issuer, subjectToken(delegateID), keyA, kidA, http.StatusOK) + eventuallyDelegateExchange(localURL, issuer, subjectToken(delegateID), keyA, kidA, http.StatusOK) cleanup() ginkgo.By("retiring the old key") @@ -291,10 +295,10 @@ var _ = ginkgo.Describe("VirtualMCPServer delegate client jwksRef", ginkgo.Order // The kid is no longer registered, so fosite rejects the assertion as a malformed // request (400 invalid_request, "kid ... could not be found"), not as a wrong key // under a registered kid (401 invalid_client, as in the wrong-key case above). - body := eventuallyDelegateExchange(localURL, delegateID, issuer, subjectToken(delegateID), keyA, kidA, http.StatusBadRequest) + body := eventuallyDelegateExchange(localURL, issuer, subjectToken(delegateID), keyA, kidA, http.StatusBadRequest) gomega.Expect(delegateOAuthError(body)).To(gomega.Equal("invalid_request"), string(body)) // keyB is a control: the surviving key must still be accepted. - eventuallyDelegateExchange(localURL, delegateID, issuer, subjectToken(delegateID), keyB, kidB, http.StatusOK) + eventuallyDelegateExchange(localURL, issuer, subjectToken(delegateID), keyB, kidB, http.StatusOK) }) }) @@ -360,12 +364,12 @@ func exchangeWithDelegateAssertion(endpoint, clientID, assertion, subjectToken, // eventuallyDelegateExchange retries with a fresh assertion each attempt until // the endpoint answers wantStatus, and returns that response body. func eventuallyDelegateExchange( - endpoint, clientID, audience, subjectToken string, key *ecdsa.PrivateKey, kid string, wantStatus int, + endpoint, audience, subjectToken string, key *ecdsa.PrivateKey, kid string, wantStatus int, ) []byte { var lastBody []byte gomega.Eventually(func() int { - status, body, err := exchangeWithDelegateAssertion(endpoint, clientID, - signDelegateAssertion(key, kid, clientID, audience), subjectToken, audience) + status, body, err := exchangeWithDelegateAssertion(endpoint, jwksDelegateClientID, + signDelegateAssertion(key, kid, jwksDelegateClientID, audience), subjectToken, audience) if err != nil { return 0 } From 0e78ee10ce83d5637dc10dd94e665acaf5f57427 Mon Sep 17 00:00:00 2001 From: Jakub Hrozek Date: Mon, 2 Nov 2026 16:36:46 +0100 Subject: [PATCH 08/10] Drop unused client ID parameters from the kind test helpers Lint still flagged two helpers in the delegate JWKS kind suite whose client ID argument always received the suite's delegate client. Both now use the suite constant, so the parameter is gone from each signature and its call sites. Co-authored-by: Mecatl --- .../virtualmcp_delegate_jwks_test.go | 26 ++++++++++--------- 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/test/e2e/thv-operator/virtualmcp/virtualmcp_delegate_jwks_test.go b/test/e2e/thv-operator/virtualmcp/virtualmcp_delegate_jwks_test.go index 2794d6cc6c..819413460e 100644 --- a/test/e2e/thv-operator/virtualmcp/virtualmcp_delegate_jwks_test.go +++ b/test/e2e/thv-operator/virtualmcp/virtualmcp_delegate_jwks_test.go @@ -206,8 +206,8 @@ var _ = ginkgo.Describe("VirtualMCPServer delegate client jwksRef", ginkgo.Order localURL, cleanup := portForwardDelegateAuthServer(vmcpName) defer cleanup() - status, body, err := exchangeWithDelegateAssertion(localURL, delegateID, - signDelegateAssertion(keyA, kidA, delegateID, issuer), subjectToken(delegateID), issuer) + status, body, err := exchangeWithDelegateAssertion(localURL, + signDelegateAssertion(keyA, kidA, issuer), subjectToken(delegateID), issuer) gomega.Expect(err).NotTo(gomega.HaveOccurred()) gomega.Expect(status).To(gomega.Equal(http.StatusOK), string(body)) var token struct { @@ -226,15 +226,15 @@ var _ = ginkgo.Describe("VirtualMCPServer delegate client jwksRef", ginkgo.Order localURL, cleanup := portForwardDelegateAuthServer(vmcpName) defer cleanup() - status, body, err := exchangeWithDelegateAssertion(localURL, delegateID, - signDelegateAssertion(strangerKey, kidA, delegateID, issuer), subjectToken(delegateID), issuer) + status, body, err := exchangeWithDelegateAssertion(localURL, + signDelegateAssertion(strangerKey, kidA, issuer), subjectToken(delegateID), issuer) gomega.Expect(err).NotTo(gomega.HaveOccurred()) gomega.Expect(status).To(gomega.Equal(http.StatusUnauthorized), string(body)) gomega.Expect(delegateOAuthError(body)).To(gomega.Equal("invalid_client"), string(body)) ginkgo.By("accepting the same subject token with the registered key") - status, body, err = exchangeWithDelegateAssertion(localURL, delegateID, - signDelegateAssertion(keyA, kidA, delegateID, issuer), subjectToken(delegateID), issuer) + status, body, err = exchangeWithDelegateAssertion(localURL, + signDelegateAssertion(keyA, kidA, issuer), subjectToken(delegateID), issuer) gomega.Expect(err).NotTo(gomega.HaveOccurred()) gomega.Expect(status).To(gomega.Equal(http.StatusOK), string(body)) }) @@ -243,8 +243,8 @@ var _ = ginkgo.Describe("VirtualMCPServer delegate client jwksRef", ginkgo.Order localURL, cleanup := portForwardDelegateAuthServer(vmcpName) defer cleanup() exchange := func(sourceClient string) (int, []byte) { - status, body, err := exchangeWithDelegateAssertion(localURL, delegateID, - signDelegateAssertion(keyA, kidA, delegateID, issuer), subjectToken(sourceClient), issuer) + status, body, err := exchangeWithDelegateAssertion(localURL, + signDelegateAssertion(keyA, kidA, issuer), subjectToken(sourceClient), issuer) gomega.Expect(err).NotTo(gomega.HaveOccurred()) return status, body } @@ -326,7 +326,8 @@ func delegateJWKSDocument(entries ...delegateJWKSEntry) string { return string(doc) } -func signDelegateAssertion(key *ecdsa.PrivateKey, kid, clientID, audience string) string { +func signDelegateAssertion(key *ecdsa.PrivateKey, kid, audience string) string { + clientID := jwksDelegateClientID signer, err := jose.NewSigner(jose.SigningKey{Algorithm: jose.ES256, Key: key}, (&jose.SignerOptions{}).WithType("client-authentication+jwt").WithHeader("kid", kid)) gomega.Expect(err).NotTo(gomega.HaveOccurred()) @@ -341,7 +342,8 @@ func signDelegateAssertion(key *ecdsa.PrivateKey, kid, clientID, audience string // exchangeWithDelegateAssertion returns transport errors instead of asserting // so callers can poll across a pod roll. -func exchangeWithDelegateAssertion(endpoint, clientID, assertion, subjectToken, audience string) (int, []byte, error) { +func exchangeWithDelegateAssertion(endpoint, assertion, subjectToken, audience string) (int, []byte, error) { + clientID := jwksDelegateClientID form := url.Values{ "grant_type": {oauthproto.GrantTypeTokenExchange}, "subject_token": {subjectToken}, "subject_token_type": {oauthproto.TokenTypeJWT}, "audience": {audience}, "client_id": {clientID}, @@ -368,8 +370,8 @@ func eventuallyDelegateExchange( ) []byte { var lastBody []byte gomega.Eventually(func() int { - status, body, err := exchangeWithDelegateAssertion(endpoint, jwksDelegateClientID, - signDelegateAssertion(key, kid, jwksDelegateClientID, audience), subjectToken, audience) + status, body, err := exchangeWithDelegateAssertion(endpoint, + signDelegateAssertion(key, kid, audience), subjectToken, audience) if err != nil { return 0 } From 48c3db6cd2df91282f8e6e3ce7f80f772af71bc1 Mon Sep 17 00:00:00 2001 From: Jakub Hrozek Date: Tue, 3 Nov 2026 13:49:42 +0100 Subject: [PATCH 09/10] Make delegate subject token audiences a list The delegate client CRD exposed a single apiAudience that had to be one of the client's own audiences. That rule made the field grant nothing: the client could already request that audience. What the server needs is the audience a self-issued subject token must carry, so the field becomes subjectTokenAudiences, a list matched exactly against the subject token's aud claim. The CEL rule tying it to audiences is gone, and the restricted-only rule now covers the new field. The field was never released, so existing objects need no migration. Co-authored-by: Mecatl --- .../v1beta1/mcpexternalauthconfig_types.go | 25 ++-- .../mcpexternalauthconfig_types_test.go | 4 +- .../api/v1beta1/zz_generated.deepcopy.go | 5 + .../pkg/controllerutil/authserver.go | 4 +- .../pkg/controllerutil/authserver_test.go | 5 +- .../delegate_client_cel_test.go | 27 ++-- ...e.stacklok.dev_mcpexternalauthconfigs.yaml | 140 ++++++++++-------- ...olhive.stacklok.dev_virtualmcpservers.yaml | 140 ++++++++++-------- ...e.stacklok.dev_mcpexternalauthconfigs.yaml | 140 ++++++++++-------- ...olhive.stacklok.dev_virtualmcpservers.yaml | 140 ++++++++++-------- docs/arch/17-token-exchange-delegation.md | 13 +- docs/operator/crd-api.md | 2 +- 12 files changed, 366 insertions(+), 279 deletions(-) diff --git a/cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types.go b/cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types.go index 0c6f4c4025..31d500aca5 100644 --- a/cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types.go +++ b/cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types.go @@ -363,7 +363,6 @@ type BearerTokenConfig struct { // +kubebuilder:validation:XValidation:rule="!has(self.grantTypes) || !('authorization_code' in self.grantTypes) || (has(self.redirectUris) && size(self.redirectUris) > 0)",message="redirectUris is required when grantTypes includes authorization_code" // +kubebuilder:validation:XValidation:rule="!has(self.grantTypes) || size(self.grantTypes) == 0 || has(self.tokenExchange)",message="tokenExchange.selfIssuedSubjectTokens is required when grantTypes is set" // +kubebuilder:validation:XValidation:rule="!(has(self.tokenEndpointAuthMethod) && self.tokenEndpointAuthMethod == 'private_key_jwt') || has(self.tokenExchange)",message="tokenExchange.selfIssuedSubjectTokens is required for private_key_jwt clients" -// +kubebuilder:validation:XValidation:rule="!has(self.tokenExchange) || !has(self.tokenExchange.apiAudience) || size(self.tokenExchange.apiAudience) == 0 || self.tokenExchange.apiAudience in self.audiences",message="tokenExchange.apiAudience must be one of the client's audiences" // +kubebuilder:validation:XValidation:rule="!has(self.tokenExchange) || !has(self.tokenExchange.allowedSourceClients) || (!(self.clientId in self.tokenExchange.allowedSourceClients) && !('*' in self.tokenExchange.allowedSourceClients))",message="tokenExchange.allowedSourceClients must not contain the client's own clientId or the \"*\" wildcard" // +kubebuilder:validation:XValidation:rule="!has(self.redirectUris) || size(self.redirectUris) == 0 || (has(self.grantTypes) && 'authorization_code' in self.grantTypes)",message="redirectUris is only allowed with the authorization_code grant" // +kubebuilder:validation:XValidation:rule="!has(self.grantTypes) || !('refresh_token' in self.grantTypes) || 'authorization_code' in self.grantTypes",message="grantTypes must include authorization_code with refresh_token" @@ -465,7 +464,7 @@ type DelegateClientConfig struct { // delegate client. External-issuer subject tokens are not affected by it: the // trusted issuer's own policy decides those. // -// +kubebuilder:validation:XValidation:rule="self.selfIssuedSubjectTokens == 'restricted' || ((!has(self.allowedSourceClients) || size(self.allowedSourceClients) == 0) && (!has(self.apiAudience) || size(self.apiAudience) == 0))",message="tokenExchange.allowedSourceClients and tokenExchange.apiAudience are valid only when selfIssuedSubjectTokens is restricted" +// +kubebuilder:validation:XValidation:rule="self.selfIssuedSubjectTokens == 'restricted' || ((!has(self.allowedSourceClients) || size(self.allowedSourceClients) == 0) && (!has(self.subjectTokenAudiences) || size(self.subjectTokenAudiences) == 0))",message="tokenExchange.allowedSourceClients and tokenExchange.subjectTokenAudiences are valid only when selfIssuedSubjectTokens is restricted" // //nolint:lll // CEL validation rule exceeds line length limit type DelegateClientTokenExchangeConfig struct { @@ -492,12 +491,20 @@ type DelegateClientTokenExchangeConfig struct { // +optional AllowedSourceClients []string `json:"allowedSourceClients,omitempty"` - // APIAudience is the client's own API audience. The client may request it - // even when the subject token does not cover it. It must be one of the - // client's audiences. Valid only with restricted. - // +kubebuilder:validation:MaxLength=2048 + // SubjectTokenAudiences lists audiences that a self-issued subject token's + // aud claim must contain at least one of, matched exactly. Listing a value + // grants nothing: the client can receive that audience only if its own + // audiences also cover it. Each entry must be one of the server's allowed + // audiences; the server checks that when it loads the configuration. + // Valid only with restricted. An explicit empty list is refused; omit the + // field for no requirement. + // +kubebuilder:validation:MinItems=1 + // +kubebuilder:validation:MaxItems=20 + // +kubebuilder:validation:items:MinLength=1 + // +kubebuilder:validation:items:MaxLength=2048 + // +listType=set // +optional - APIAudience string `json:"apiAudience,omitempty"` + SubjectTokenAudiences []string `json:"subjectTokenAudiences,omitempty"` } // TrustedIssuerConfig configures an external OIDC issuer whose tokens are @@ -2999,8 +3006,8 @@ func validateDelegateClientTrustModes(clients []DelegateClientConfig, path strin switch authserver.SelfIssuedSubjectTokens(policy.SelfIssuedSubjectTokens) { case authserver.SelfIssuedSubjectTokensRestricted: case authserver.SelfIssuedSubjectTokensAny: - if len(policy.AllowedSourceClients) > 0 || policy.APIAudience != "" { - return fmt.Errorf("%s[%d] (%q): tokenExchange.allowedSourceClients and tokenExchange.apiAudience "+ + if len(policy.AllowedSourceClients) > 0 || len(policy.SubjectTokenAudiences) > 0 { + return fmt.Errorf("%s[%d] (%q): tokenExchange.allowedSourceClients and tokenExchange.subjectTokenAudiences "+ "are valid only when selfIssuedSubjectTokens is restricted", path, i, client.ClientID) } default: diff --git a/cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types_test.go b/cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types_test.go index 03ef5c258d..e1ca16fe79 100644 --- a/cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types_test.go +++ b/cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types_test.go @@ -2255,8 +2255,8 @@ func TestMCPExternalAuthConfig_DelegateClientTrustModes(t *testing.T) { {name: "any with source clients", client: client(exchangeOnly, &DelegateClientTokenExchangeConfig{ SelfIssuedSubjectTokens: "any", AllowedSourceClients: []string{"tui"}, }), wantErr: "valid only when selfIssuedSubjectTokens is restricted"}, - {name: "any with api audience", client: client(exchangeOnly, &DelegateClientTokenExchangeConfig{ - SelfIssuedSubjectTokens: "any", APIAudience: "https://api.example.com", + {name: "any with subject token audiences", client: client(exchangeOnly, &DelegateClientTokenExchangeConfig{ + SelfIssuedSubjectTokens: "any", SubjectTokenAudiences: []string{"https://api.example.com"}, }), wantErr: "valid only when selfIssuedSubjectTokens is restricted"}, } diff --git a/cmd/thv-operator/api/v1beta1/zz_generated.deepcopy.go b/cmd/thv-operator/api/v1beta1/zz_generated.deepcopy.go index 2e6e5d2608..38b12ada33 100644 --- a/cmd/thv-operator/api/v1beta1/zz_generated.deepcopy.go +++ b/cmd/thv-operator/api/v1beta1/zz_generated.deepcopy.go @@ -335,6 +335,11 @@ func (in *DelegateClientTokenExchangeConfig) DeepCopyInto(out *DelegateClientTok *out = make([]string, len(*in)) copy(*out, *in) } + if in.SubjectTokenAudiences != nil { + in, out := &in.SubjectTokenAudiences, &out.SubjectTokenAudiences + *out = make([]string, len(*in)) + copy(*out, *in) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DelegateClientTokenExchangeConfig. diff --git a/cmd/thv-operator/pkg/controllerutil/authserver.go b/cmd/thv-operator/pkg/controllerutil/authserver.go index b189362660..04dcfab313 100644 --- a/cmd/thv-operator/pkg/controllerutil/authserver.go +++ b/cmd/thv-operator/pkg/controllerutil/authserver.go @@ -299,8 +299,8 @@ func buildDelegateClientRunConfigs( runConfig.TokenExchange = &authserver.DelegateClientTokenExchange{ SelfIssuedSubjectTokens: authserver.SelfIssuedSubjectTokens( delegateClient.TokenExchange.SelfIssuedSubjectTokens), - AllowedSourceClients: slices.Clone(delegateClient.TokenExchange.AllowedSourceClients), - APIAudience: delegateClient.TokenExchange.APIAudience, + AllowedSourceClients: slices.Clone(delegateClient.TokenExchange.AllowedSourceClients), + SubjectTokenAudiences: slices.Clone(delegateClient.TokenExchange.SubjectTokenAudiences), } } configs[i] = runConfig diff --git a/cmd/thv-operator/pkg/controllerutil/authserver_test.go b/cmd/thv-operator/pkg/controllerutil/authserver_test.go index fbe147dbcf..49d698caf0 100644 --- a/cmd/thv-operator/pkg/controllerutil/authserver_test.go +++ b/cmd/thv-operator/pkg/controllerutil/authserver_test.go @@ -3854,7 +3854,8 @@ func TestBuildAuthServerRunConfig_CanonicalDelegateClientFields(t *testing.T) { Audiences: []string{"https://resource.example.com"}, TokenExchange: &mcpv1beta1.DelegateClientTokenExchangeConfig{ SelfIssuedSubjectTokens: "restricted", - AllowedSourceClients: []string{"tui"}, APIAudience: "https://resource.example.com", + AllowedSourceClients: []string{"tui"}, + SubjectTokenAudiences: []string{"https://resource.example.com"}, }, }, { @@ -3897,7 +3898,7 @@ func TestBuildAuthServerRunConfig_CanonicalDelegateClientFields(t *testing.T) { require.NotNil(t, broker.TokenExchange) assert.Equal(t, authserver.SelfIssuedSubjectTokensRestricted, broker.TokenExchange.SelfIssuedSubjectTokens) assert.Equal(t, []string{"tui"}, broker.TokenExchange.AllowedSourceClients) - assert.Equal(t, "https://resource.example.com", broker.TokenExchange.APIAudience) + assert.Equal(t, []string{"https://resource.example.com"}, broker.TokenExchange.SubjectTokenAudiences) // restricted with an empty source list maps to own tokens only, while an // absent block keeps the legacy blanket trust. diff --git a/cmd/thv-operator/test-integration/mcp-external-auth/delegate_client_cel_test.go b/cmd/thv-operator/test-integration/mcp-external-auth/delegate_client_cel_test.go index 69be8feb4a..3b82be7f87 100644 --- a/cmd/thv-operator/test-integration/mcp-external-auth/delegate_client_cel_test.go +++ b/cmd/thv-operator/test-integration/mcp-external-auth/delegate_client_cel_test.go @@ -68,13 +68,6 @@ var _ = Describe("MCPExternalAuthConfig delegate client CEL validation", func() mutate: func(c *mcpv1beta1.DelegateClientConfig) { c.JWKSRef.Key = "" }}, {name: "jwksRef with an empty name", base: jwksClient, errMatch: "jwksRef.name", mutate: func(c *mcpv1beta1.DelegateClientConfig) { c.JWKSRef.Name = "" }}, - {name: "apiAudience not in audiences", base: secretClient, - errMatch: "tokenExchange.apiAudience must be one of the client's audiences", - mutate: func(c *mcpv1beta1.DelegateClientConfig) { - c.TokenExchange = &mcpv1beta1.DelegateClientTokenExchangeConfig{ - SelfIssuedSubjectTokens: "restricted", APIAudience: "https://other.example.com", - } - }}, {name: "redirect URI with a query string", base: secretClient, mutate: func(c *mcpv1beta1.DelegateClientConfig) { c.GrantTypes = []string{"authorization_code", tokenExchange} @@ -123,12 +116,12 @@ var _ = Describe("MCPExternalAuthConfig delegate client CEL validation", func() mutate: func(c *mcpv1beta1.DelegateClientConfig) { c.TokenExchange = &mcpv1beta1.DelegateClientTokenExchangeConfig{SelfIssuedSubjectTokens: "all"} }}, - {name: "restricted with source clients and api audience", base: secretClient, + {name: "restricted with source clients and subject token audiences", base: secretClient, mutate: func(c *mcpv1beta1.DelegateClientConfig) { c.GrantTypes = []string{tokenExchange} c.TokenExchange = &mcpv1beta1.DelegateClientTokenExchangeConfig{ SelfIssuedSubjectTokens: "restricted", AllowedSourceClients: []string{"tui"}, - APIAudience: "https://mcp.example.com", + SubjectTokenAudiences: []string{"https://mcp.example.com"}, } }}, {name: "any with source clients", base: secretClient, errMatch: "valid only when selfIssuedSubjectTokens is restricted", @@ -137,10 +130,10 @@ var _ = Describe("MCPExternalAuthConfig delegate client CEL validation", func() SelfIssuedSubjectTokens: "any", AllowedSourceClients: []string{"tui"}, } }}, - {name: "any with api audience", base: secretClient, errMatch: "valid only when selfIssuedSubjectTokens is restricted", + {name: "any with subject token audiences", base: secretClient, errMatch: "valid only when selfIssuedSubjectTokens is restricted", mutate: func(c *mcpv1beta1.DelegateClientConfig) { c.TokenExchange = &mcpv1beta1.DelegateClientTokenExchangeConfig{ - SelfIssuedSubjectTokens: "any", APIAudience: "https://mcp.example.com", + SelfIssuedSubjectTokens: "any", SubjectTokenAudiences: []string{"https://mcp.example.com"}, } }}, {name: "grantTypes without token exchange", base: secretClient, errMatch: "grantTypes must include", @@ -281,12 +274,12 @@ var _ = Describe("MCPExternalAuthConfig delegate client CEL validation", func() {name: "restricted with an explicit empty source list", tokenExchange: map[string]interface{}{ "selfIssuedSubjectTokens": "restricted", "allowedSourceClients": []interface{}{}, }}, - {name: "restricted with an explicit empty api audience", tokenExchange: map[string]interface{}{ - "selfIssuedSubjectTokens": "restricted", "apiAudience": "", - }}, - {name: "any with an explicit empty source list and api audience", tokenExchange: map[string]interface{}{ - "selfIssuedSubjectTokens": "any", "allowedSourceClients": []interface{}{}, "apiAudience": "", - }}, + {name: "restricted with an explicit empty subject token audiences", tokenExchange: map[string]interface{}{ + "selfIssuedSubjectTokens": "restricted", "subjectTokenAudiences": []interface{}{}, + }, errMatch: "subjectTokenAudiences"}, + {name: "any with an explicit empty source list and subject token audiences", tokenExchange: map[string]interface{}{ + "selfIssuedSubjectTokens": "any", "allowedSourceClients": []interface{}{}, "subjectTokenAudiences": []interface{}{}, + }, errMatch: "subjectTokenAudiences"}, } for i, test := range rawCases { 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 c822e1d8fd..892576a8b3 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 @@ -475,13 +475,6 @@ spec: maxItems: 20 type: array x-kubernetes-list-type: set - apiAudience: - description: |- - APIAudience is the client's own API audience. The client may request it - even when the subject token does not cover it. It must be one of the - client's audiences. Valid only with restricted. - maxLength: 2048 - type: string selfIssuedSubjectTokens: description: |- SelfIssuedSubjectTokens selects which self-issued subject tokens the @@ -494,15 +487,32 @@ spec: - any - restricted type: string + subjectTokenAudiences: + description: |- + SubjectTokenAudiences lists audiences that a self-issued subject token's + aud claim must contain at least one of, matched exactly. Listing a value + grants nothing: the client can receive that audience only if its own + audiences also cover it. Each entry must be one of the server's allowed + audiences; the server checks that when it loads the configuration. + Valid only with restricted. An explicit empty list is refused; omit the + field for no requirement. + items: + maxLength: 2048 + minLength: 1 + type: string + maxItems: 20 + minItems: 1 + type: array + x-kubernetes-list-type: set required: - selfIssuedSubjectTokens type: object x-kubernetes-validations: - - message: tokenExchange.allowedSourceClients and tokenExchange.apiAudience + - message: tokenExchange.allowedSourceClients and tokenExchange.subjectTokenAudiences are valid only when selfIssuedSubjectTokens is restricted rule: self.selfIssuedSubjectTokens == 'restricted' || ((!has(self.allowedSourceClients) || size(self.allowedSourceClients) - == 0) && (!has(self.apiAudience) || size(self.apiAudience) + == 0) && (!has(self.subjectTokenAudiences) || size(self.subjectTokenAudiences) == 0)) required: - audiences @@ -536,11 +546,6 @@ spec: for private_key_jwt clients rule: '!(has(self.tokenEndpointAuthMethod) && self.tokenEndpointAuthMethod == ''private_key_jwt'') || has(self.tokenExchange)' - - message: tokenExchange.apiAudience must be one of the client's - audiences - rule: '!has(self.tokenExchange) || !has(self.tokenExchange.apiAudience) - || size(self.tokenExchange.apiAudience) == 0 || self.tokenExchange.apiAudience - in self.audiences' - message: tokenExchange.allowedSourceClients must not contain the client's own clientId or the "*" wildcard rule: '!has(self.tokenExchange) || !has(self.tokenExchange.allowedSourceClients) @@ -1002,13 +1007,6 @@ spec: maxItems: 20 type: array x-kubernetes-list-type: set - apiAudience: - description: |- - APIAudience is the client's own API audience. The client may request it - even when the subject token does not cover it. It must be one of the - client's audiences. Valid only with restricted. - maxLength: 2048 - type: string selfIssuedSubjectTokens: description: |- SelfIssuedSubjectTokens selects which self-issued subject tokens the @@ -1021,17 +1019,34 @@ spec: - any - restricted type: string + subjectTokenAudiences: + description: |- + SubjectTokenAudiences lists audiences that a self-issued subject token's + aud claim must contain at least one of, matched exactly. Listing a value + grants nothing: the client can receive that audience only if its own + audiences also cover it. Each entry must be one of the server's allowed + audiences; the server checks that when it loads the configuration. + Valid only with restricted. An explicit empty list is refused; omit the + field for no requirement. + items: + maxLength: 2048 + minLength: 1 + type: string + maxItems: 20 + minItems: 1 + type: array + x-kubernetes-list-type: set required: - selfIssuedSubjectTokens type: object x-kubernetes-validations: - message: tokenExchange.allowedSourceClients and - tokenExchange.apiAudience are valid only when - selfIssuedSubjectTokens is restricted + tokenExchange.subjectTokenAudiences are valid + only when selfIssuedSubjectTokens is restricted rule: self.selfIssuedSubjectTokens == 'restricted' || ((!has(self.allowedSourceClients) || size(self.allowedSourceClients) - == 0) && (!has(self.apiAudience) || size(self.apiAudience) - == 0)) + == 0) && (!has(self.subjectTokenAudiences) || + size(self.subjectTokenAudiences) == 0)) required: - audiences - clientId @@ -1064,11 +1079,6 @@ spec: required for private_key_jwt clients rule: '!(has(self.tokenEndpointAuthMethod) && self.tokenEndpointAuthMethod == ''private_key_jwt'') || has(self.tokenExchange)' - - message: tokenExchange.apiAudience must be one of - the client's audiences - rule: '!has(self.tokenExchange) || !has(self.tokenExchange.apiAudience) - || size(self.tokenExchange.apiAudience) == 0 || - self.tokenExchange.apiAudience in self.audiences' - message: tokenExchange.allowedSourceClients must not contain the client's own clientId or the "*" wildcard rule: '!has(self.tokenExchange) || !has(self.tokenExchange.allowedSourceClients) @@ -3947,13 +3957,6 @@ spec: maxItems: 20 type: array x-kubernetes-list-type: set - apiAudience: - description: |- - APIAudience is the client's own API audience. The client may request it - even when the subject token does not cover it. It must be one of the - client's audiences. Valid only with restricted. - maxLength: 2048 - type: string selfIssuedSubjectTokens: description: |- SelfIssuedSubjectTokens selects which self-issued subject tokens the @@ -3966,15 +3969,32 @@ spec: - any - restricted type: string + subjectTokenAudiences: + description: |- + SubjectTokenAudiences lists audiences that a self-issued subject token's + aud claim must contain at least one of, matched exactly. Listing a value + grants nothing: the client can receive that audience only if its own + audiences also cover it. Each entry must be one of the server's allowed + audiences; the server checks that when it loads the configuration. + Valid only with restricted. An explicit empty list is refused; omit the + field for no requirement. + items: + maxLength: 2048 + minLength: 1 + type: string + maxItems: 20 + minItems: 1 + type: array + x-kubernetes-list-type: set required: - selfIssuedSubjectTokens type: object x-kubernetes-validations: - - message: tokenExchange.allowedSourceClients and tokenExchange.apiAudience + - message: tokenExchange.allowedSourceClients and tokenExchange.subjectTokenAudiences are valid only when selfIssuedSubjectTokens is restricted rule: self.selfIssuedSubjectTokens == 'restricted' || ((!has(self.allowedSourceClients) || size(self.allowedSourceClients) - == 0) && (!has(self.apiAudience) || size(self.apiAudience) + == 0) && (!has(self.subjectTokenAudiences) || size(self.subjectTokenAudiences) == 0)) required: - audiences @@ -4008,11 +4028,6 @@ spec: for private_key_jwt clients rule: '!(has(self.tokenEndpointAuthMethod) && self.tokenEndpointAuthMethod == ''private_key_jwt'') || has(self.tokenExchange)' - - message: tokenExchange.apiAudience must be one of the client's - audiences - rule: '!has(self.tokenExchange) || !has(self.tokenExchange.apiAudience) - || size(self.tokenExchange.apiAudience) == 0 || self.tokenExchange.apiAudience - in self.audiences' - message: tokenExchange.allowedSourceClients must not contain the client's own clientId or the "*" wildcard rule: '!has(self.tokenExchange) || !has(self.tokenExchange.allowedSourceClients) @@ -4474,13 +4489,6 @@ spec: maxItems: 20 type: array x-kubernetes-list-type: set - apiAudience: - description: |- - APIAudience is the client's own API audience. The client may request it - even when the subject token does not cover it. It must be one of the - client's audiences. Valid only with restricted. - maxLength: 2048 - type: string selfIssuedSubjectTokens: description: |- SelfIssuedSubjectTokens selects which self-issued subject tokens the @@ -4493,17 +4501,34 @@ spec: - any - restricted type: string + subjectTokenAudiences: + description: |- + SubjectTokenAudiences lists audiences that a self-issued subject token's + aud claim must contain at least one of, matched exactly. Listing a value + grants nothing: the client can receive that audience only if its own + audiences also cover it. Each entry must be one of the server's allowed + audiences; the server checks that when it loads the configuration. + Valid only with restricted. An explicit empty list is refused; omit the + field for no requirement. + items: + maxLength: 2048 + minLength: 1 + type: string + maxItems: 20 + minItems: 1 + type: array + x-kubernetes-list-type: set required: - selfIssuedSubjectTokens type: object x-kubernetes-validations: - message: tokenExchange.allowedSourceClients and - tokenExchange.apiAudience are valid only when - selfIssuedSubjectTokens is restricted + tokenExchange.subjectTokenAudiences are valid + only when selfIssuedSubjectTokens is restricted rule: self.selfIssuedSubjectTokens == 'restricted' || ((!has(self.allowedSourceClients) || size(self.allowedSourceClients) - == 0) && (!has(self.apiAudience) || size(self.apiAudience) - == 0)) + == 0) && (!has(self.subjectTokenAudiences) || + size(self.subjectTokenAudiences) == 0)) required: - audiences - clientId @@ -4536,11 +4561,6 @@ spec: required for private_key_jwt clients rule: '!(has(self.tokenEndpointAuthMethod) && self.tokenEndpointAuthMethod == ''private_key_jwt'') || has(self.tokenExchange)' - - message: tokenExchange.apiAudience must be one of - the client's audiences - rule: '!has(self.tokenExchange) || !has(self.tokenExchange.apiAudience) - || size(self.tokenExchange.apiAudience) == 0 || - self.tokenExchange.apiAudience in self.audiences' - message: tokenExchange.allowedSourceClients must not contain the client's own clientId or the "*" wildcard rule: '!has(self.tokenExchange) || !has(self.tokenExchange.allowedSourceClients) 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 f59e6351ac..ccd435b178 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 @@ -362,13 +362,6 @@ spec: maxItems: 20 type: array x-kubernetes-list-type: set - apiAudience: - description: |- - APIAudience is the client's own API audience. The client may request it - even when the subject token does not cover it. It must be one of the - client's audiences. Valid only with restricted. - maxLength: 2048 - type: string selfIssuedSubjectTokens: description: |- SelfIssuedSubjectTokens selects which self-issued subject tokens the @@ -381,15 +374,32 @@ spec: - any - restricted type: string + subjectTokenAudiences: + description: |- + SubjectTokenAudiences lists audiences that a self-issued subject token's + aud claim must contain at least one of, matched exactly. Listing a value + grants nothing: the client can receive that audience only if its own + audiences also cover it. Each entry must be one of the server's allowed + audiences; the server checks that when it loads the configuration. + Valid only with restricted. An explicit empty list is refused; omit the + field for no requirement. + items: + maxLength: 2048 + minLength: 1 + type: string + maxItems: 20 + minItems: 1 + type: array + x-kubernetes-list-type: set required: - selfIssuedSubjectTokens type: object x-kubernetes-validations: - - message: tokenExchange.allowedSourceClients and tokenExchange.apiAudience + - message: tokenExchange.allowedSourceClients and tokenExchange.subjectTokenAudiences are valid only when selfIssuedSubjectTokens is restricted rule: self.selfIssuedSubjectTokens == 'restricted' || ((!has(self.allowedSourceClients) || size(self.allowedSourceClients) - == 0) && (!has(self.apiAudience) || size(self.apiAudience) + == 0) && (!has(self.subjectTokenAudiences) || size(self.subjectTokenAudiences) == 0)) required: - audiences @@ -423,11 +433,6 @@ spec: for private_key_jwt clients rule: '!(has(self.tokenEndpointAuthMethod) && self.tokenEndpointAuthMethod == ''private_key_jwt'') || has(self.tokenExchange)' - - message: tokenExchange.apiAudience must be one of the client's - audiences - rule: '!has(self.tokenExchange) || !has(self.tokenExchange.apiAudience) - || size(self.tokenExchange.apiAudience) == 0 || self.tokenExchange.apiAudience - in self.audiences' - message: tokenExchange.allowedSourceClients must not contain the client's own clientId or the "*" wildcard rule: '!has(self.tokenExchange) || !has(self.tokenExchange.allowedSourceClients) @@ -889,13 +894,6 @@ spec: maxItems: 20 type: array x-kubernetes-list-type: set - apiAudience: - description: |- - APIAudience is the client's own API audience. The client may request it - even when the subject token does not cover it. It must be one of the - client's audiences. Valid only with restricted. - maxLength: 2048 - type: string selfIssuedSubjectTokens: description: |- SelfIssuedSubjectTokens selects which self-issued subject tokens the @@ -908,17 +906,34 @@ spec: - any - restricted type: string + subjectTokenAudiences: + description: |- + SubjectTokenAudiences lists audiences that a self-issued subject token's + aud claim must contain at least one of, matched exactly. Listing a value + grants nothing: the client can receive that audience only if its own + audiences also cover it. Each entry must be one of the server's allowed + audiences; the server checks that when it loads the configuration. + Valid only with restricted. An explicit empty list is refused; omit the + field for no requirement. + items: + maxLength: 2048 + minLength: 1 + type: string + maxItems: 20 + minItems: 1 + type: array + x-kubernetes-list-type: set required: - selfIssuedSubjectTokens type: object x-kubernetes-validations: - message: tokenExchange.allowedSourceClients and - tokenExchange.apiAudience are valid only when - selfIssuedSubjectTokens is restricted + tokenExchange.subjectTokenAudiences are valid + only when selfIssuedSubjectTokens is restricted rule: self.selfIssuedSubjectTokens == 'restricted' || ((!has(self.allowedSourceClients) || size(self.allowedSourceClients) - == 0) && (!has(self.apiAudience) || size(self.apiAudience) - == 0)) + == 0) && (!has(self.subjectTokenAudiences) || + size(self.subjectTokenAudiences) == 0)) required: - audiences - clientId @@ -951,11 +966,6 @@ spec: required for private_key_jwt clients rule: '!(has(self.tokenEndpointAuthMethod) && self.tokenEndpointAuthMethod == ''private_key_jwt'') || has(self.tokenExchange)' - - message: tokenExchange.apiAudience must be one of - the client's audiences - rule: '!has(self.tokenExchange) || !has(self.tokenExchange.apiAudience) - || size(self.tokenExchange.apiAudience) == 0 || - self.tokenExchange.apiAudience in self.audiences' - message: tokenExchange.allowedSourceClients must not contain the client's own clientId or the "*" wildcard rule: '!has(self.tokenExchange) || !has(self.tokenExchange.allowedSourceClients) @@ -6226,13 +6236,6 @@ spec: maxItems: 20 type: array x-kubernetes-list-type: set - apiAudience: - description: |- - APIAudience is the client's own API audience. The client may request it - even when the subject token does not cover it. It must be one of the - client's audiences. Valid only with restricted. - maxLength: 2048 - type: string selfIssuedSubjectTokens: description: |- SelfIssuedSubjectTokens selects which self-issued subject tokens the @@ -6245,15 +6248,32 @@ spec: - any - restricted type: string + subjectTokenAudiences: + description: |- + SubjectTokenAudiences lists audiences that a self-issued subject token's + aud claim must contain at least one of, matched exactly. Listing a value + grants nothing: the client can receive that audience only if its own + audiences also cover it. Each entry must be one of the server's allowed + audiences; the server checks that when it loads the configuration. + Valid only with restricted. An explicit empty list is refused; omit the + field for no requirement. + items: + maxLength: 2048 + minLength: 1 + type: string + maxItems: 20 + minItems: 1 + type: array + x-kubernetes-list-type: set required: - selfIssuedSubjectTokens type: object x-kubernetes-validations: - - message: tokenExchange.allowedSourceClients and tokenExchange.apiAudience + - message: tokenExchange.allowedSourceClients and tokenExchange.subjectTokenAudiences are valid only when selfIssuedSubjectTokens is restricted rule: self.selfIssuedSubjectTokens == 'restricted' || ((!has(self.allowedSourceClients) || size(self.allowedSourceClients) - == 0) && (!has(self.apiAudience) || size(self.apiAudience) + == 0) && (!has(self.subjectTokenAudiences) || size(self.subjectTokenAudiences) == 0)) required: - audiences @@ -6287,11 +6307,6 @@ spec: for private_key_jwt clients rule: '!(has(self.tokenEndpointAuthMethod) && self.tokenEndpointAuthMethod == ''private_key_jwt'') || has(self.tokenExchange)' - - message: tokenExchange.apiAudience must be one of the client's - audiences - rule: '!has(self.tokenExchange) || !has(self.tokenExchange.apiAudience) - || size(self.tokenExchange.apiAudience) == 0 || self.tokenExchange.apiAudience - in self.audiences' - message: tokenExchange.allowedSourceClients must not contain the client's own clientId or the "*" wildcard rule: '!has(self.tokenExchange) || !has(self.tokenExchange.allowedSourceClients) @@ -6753,13 +6768,6 @@ spec: maxItems: 20 type: array x-kubernetes-list-type: set - apiAudience: - description: |- - APIAudience is the client's own API audience. The client may request it - even when the subject token does not cover it. It must be one of the - client's audiences. Valid only with restricted. - maxLength: 2048 - type: string selfIssuedSubjectTokens: description: |- SelfIssuedSubjectTokens selects which self-issued subject tokens the @@ -6772,17 +6780,34 @@ spec: - any - restricted type: string + subjectTokenAudiences: + description: |- + SubjectTokenAudiences lists audiences that a self-issued subject token's + aud claim must contain at least one of, matched exactly. Listing a value + grants nothing: the client can receive that audience only if its own + audiences also cover it. Each entry must be one of the server's allowed + audiences; the server checks that when it loads the configuration. + Valid only with restricted. An explicit empty list is refused; omit the + field for no requirement. + items: + maxLength: 2048 + minLength: 1 + type: string + maxItems: 20 + minItems: 1 + type: array + x-kubernetes-list-type: set required: - selfIssuedSubjectTokens type: object x-kubernetes-validations: - message: tokenExchange.allowedSourceClients and - tokenExchange.apiAudience are valid only when - selfIssuedSubjectTokens is restricted + tokenExchange.subjectTokenAudiences are valid + only when selfIssuedSubjectTokens is restricted rule: self.selfIssuedSubjectTokens == 'restricted' || ((!has(self.allowedSourceClients) || size(self.allowedSourceClients) - == 0) && (!has(self.apiAudience) || size(self.apiAudience) - == 0)) + == 0) && (!has(self.subjectTokenAudiences) || + size(self.subjectTokenAudiences) == 0)) required: - audiences - clientId @@ -6815,11 +6840,6 @@ spec: required for private_key_jwt clients rule: '!(has(self.tokenEndpointAuthMethod) && self.tokenEndpointAuthMethod == ''private_key_jwt'') || has(self.tokenExchange)' - - message: tokenExchange.apiAudience must be one of - the client's audiences - rule: '!has(self.tokenExchange) || !has(self.tokenExchange.apiAudience) - || size(self.tokenExchange.apiAudience) == 0 || - self.tokenExchange.apiAudience in self.audiences' - message: tokenExchange.allowedSourceClients must not contain the client's own clientId or the "*" wildcard rule: '!has(self.tokenExchange) || !has(self.tokenExchange.allowedSourceClients) 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 3597d95a32..5280b25799 100644 --- a/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpexternalauthconfigs.yaml +++ b/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpexternalauthconfigs.yaml @@ -478,13 +478,6 @@ spec: maxItems: 20 type: array x-kubernetes-list-type: set - apiAudience: - description: |- - APIAudience is the client's own API audience. The client may request it - even when the subject token does not cover it. It must be one of the - client's audiences. Valid only with restricted. - maxLength: 2048 - type: string selfIssuedSubjectTokens: description: |- SelfIssuedSubjectTokens selects which self-issued subject tokens the @@ -497,15 +490,32 @@ spec: - any - restricted type: string + subjectTokenAudiences: + description: |- + SubjectTokenAudiences lists audiences that a self-issued subject token's + aud claim must contain at least one of, matched exactly. Listing a value + grants nothing: the client can receive that audience only if its own + audiences also cover it. Each entry must be one of the server's allowed + audiences; the server checks that when it loads the configuration. + Valid only with restricted. An explicit empty list is refused; omit the + field for no requirement. + items: + maxLength: 2048 + minLength: 1 + type: string + maxItems: 20 + minItems: 1 + type: array + x-kubernetes-list-type: set required: - selfIssuedSubjectTokens type: object x-kubernetes-validations: - - message: tokenExchange.allowedSourceClients and tokenExchange.apiAudience + - message: tokenExchange.allowedSourceClients and tokenExchange.subjectTokenAudiences are valid only when selfIssuedSubjectTokens is restricted rule: self.selfIssuedSubjectTokens == 'restricted' || ((!has(self.allowedSourceClients) || size(self.allowedSourceClients) - == 0) && (!has(self.apiAudience) || size(self.apiAudience) + == 0) && (!has(self.subjectTokenAudiences) || size(self.subjectTokenAudiences) == 0)) required: - audiences @@ -539,11 +549,6 @@ spec: for private_key_jwt clients rule: '!(has(self.tokenEndpointAuthMethod) && self.tokenEndpointAuthMethod == ''private_key_jwt'') || has(self.tokenExchange)' - - message: tokenExchange.apiAudience must be one of the client's - audiences - rule: '!has(self.tokenExchange) || !has(self.tokenExchange.apiAudience) - || size(self.tokenExchange.apiAudience) == 0 || self.tokenExchange.apiAudience - in self.audiences' - message: tokenExchange.allowedSourceClients must not contain the client's own clientId or the "*" wildcard rule: '!has(self.tokenExchange) || !has(self.tokenExchange.allowedSourceClients) @@ -1005,13 +1010,6 @@ spec: maxItems: 20 type: array x-kubernetes-list-type: set - apiAudience: - description: |- - APIAudience is the client's own API audience. The client may request it - even when the subject token does not cover it. It must be one of the - client's audiences. Valid only with restricted. - maxLength: 2048 - type: string selfIssuedSubjectTokens: description: |- SelfIssuedSubjectTokens selects which self-issued subject tokens the @@ -1024,17 +1022,34 @@ spec: - any - restricted type: string + subjectTokenAudiences: + description: |- + SubjectTokenAudiences lists audiences that a self-issued subject token's + aud claim must contain at least one of, matched exactly. Listing a value + grants nothing: the client can receive that audience only if its own + audiences also cover it. Each entry must be one of the server's allowed + audiences; the server checks that when it loads the configuration. + Valid only with restricted. An explicit empty list is refused; omit the + field for no requirement. + items: + maxLength: 2048 + minLength: 1 + type: string + maxItems: 20 + minItems: 1 + type: array + x-kubernetes-list-type: set required: - selfIssuedSubjectTokens type: object x-kubernetes-validations: - message: tokenExchange.allowedSourceClients and - tokenExchange.apiAudience are valid only when - selfIssuedSubjectTokens is restricted + tokenExchange.subjectTokenAudiences are valid + only when selfIssuedSubjectTokens is restricted rule: self.selfIssuedSubjectTokens == 'restricted' || ((!has(self.allowedSourceClients) || size(self.allowedSourceClients) - == 0) && (!has(self.apiAudience) || size(self.apiAudience) - == 0)) + == 0) && (!has(self.subjectTokenAudiences) || + size(self.subjectTokenAudiences) == 0)) required: - audiences - clientId @@ -1067,11 +1082,6 @@ spec: required for private_key_jwt clients rule: '!(has(self.tokenEndpointAuthMethod) && self.tokenEndpointAuthMethod == ''private_key_jwt'') || has(self.tokenExchange)' - - message: tokenExchange.apiAudience must be one of - the client's audiences - rule: '!has(self.tokenExchange) || !has(self.tokenExchange.apiAudience) - || size(self.tokenExchange.apiAudience) == 0 || - self.tokenExchange.apiAudience in self.audiences' - message: tokenExchange.allowedSourceClients must not contain the client's own clientId or the "*" wildcard rule: '!has(self.tokenExchange) || !has(self.tokenExchange.allowedSourceClients) @@ -3950,13 +3960,6 @@ spec: maxItems: 20 type: array x-kubernetes-list-type: set - apiAudience: - description: |- - APIAudience is the client's own API audience. The client may request it - even when the subject token does not cover it. It must be one of the - client's audiences. Valid only with restricted. - maxLength: 2048 - type: string selfIssuedSubjectTokens: description: |- SelfIssuedSubjectTokens selects which self-issued subject tokens the @@ -3969,15 +3972,32 @@ spec: - any - restricted type: string + subjectTokenAudiences: + description: |- + SubjectTokenAudiences lists audiences that a self-issued subject token's + aud claim must contain at least one of, matched exactly. Listing a value + grants nothing: the client can receive that audience only if its own + audiences also cover it. Each entry must be one of the server's allowed + audiences; the server checks that when it loads the configuration. + Valid only with restricted. An explicit empty list is refused; omit the + field for no requirement. + items: + maxLength: 2048 + minLength: 1 + type: string + maxItems: 20 + minItems: 1 + type: array + x-kubernetes-list-type: set required: - selfIssuedSubjectTokens type: object x-kubernetes-validations: - - message: tokenExchange.allowedSourceClients and tokenExchange.apiAudience + - message: tokenExchange.allowedSourceClients and tokenExchange.subjectTokenAudiences are valid only when selfIssuedSubjectTokens is restricted rule: self.selfIssuedSubjectTokens == 'restricted' || ((!has(self.allowedSourceClients) || size(self.allowedSourceClients) - == 0) && (!has(self.apiAudience) || size(self.apiAudience) + == 0) && (!has(self.subjectTokenAudiences) || size(self.subjectTokenAudiences) == 0)) required: - audiences @@ -4011,11 +4031,6 @@ spec: for private_key_jwt clients rule: '!(has(self.tokenEndpointAuthMethod) && self.tokenEndpointAuthMethod == ''private_key_jwt'') || has(self.tokenExchange)' - - message: tokenExchange.apiAudience must be one of the client's - audiences - rule: '!has(self.tokenExchange) || !has(self.tokenExchange.apiAudience) - || size(self.tokenExchange.apiAudience) == 0 || self.tokenExchange.apiAudience - in self.audiences' - message: tokenExchange.allowedSourceClients must not contain the client's own clientId or the "*" wildcard rule: '!has(self.tokenExchange) || !has(self.tokenExchange.allowedSourceClients) @@ -4477,13 +4492,6 @@ spec: maxItems: 20 type: array x-kubernetes-list-type: set - apiAudience: - description: |- - APIAudience is the client's own API audience. The client may request it - even when the subject token does not cover it. It must be one of the - client's audiences. Valid only with restricted. - maxLength: 2048 - type: string selfIssuedSubjectTokens: description: |- SelfIssuedSubjectTokens selects which self-issued subject tokens the @@ -4496,17 +4504,34 @@ spec: - any - restricted type: string + subjectTokenAudiences: + description: |- + SubjectTokenAudiences lists audiences that a self-issued subject token's + aud claim must contain at least one of, matched exactly. Listing a value + grants nothing: the client can receive that audience only if its own + audiences also cover it. Each entry must be one of the server's allowed + audiences; the server checks that when it loads the configuration. + Valid only with restricted. An explicit empty list is refused; omit the + field for no requirement. + items: + maxLength: 2048 + minLength: 1 + type: string + maxItems: 20 + minItems: 1 + type: array + x-kubernetes-list-type: set required: - selfIssuedSubjectTokens type: object x-kubernetes-validations: - message: tokenExchange.allowedSourceClients and - tokenExchange.apiAudience are valid only when - selfIssuedSubjectTokens is restricted + tokenExchange.subjectTokenAudiences are valid + only when selfIssuedSubjectTokens is restricted rule: self.selfIssuedSubjectTokens == 'restricted' || ((!has(self.allowedSourceClients) || size(self.allowedSourceClients) - == 0) && (!has(self.apiAudience) || size(self.apiAudience) - == 0)) + == 0) && (!has(self.subjectTokenAudiences) || + size(self.subjectTokenAudiences) == 0)) required: - audiences - clientId @@ -4539,11 +4564,6 @@ spec: required for private_key_jwt clients rule: '!(has(self.tokenEndpointAuthMethod) && self.tokenEndpointAuthMethod == ''private_key_jwt'') || has(self.tokenExchange)' - - message: tokenExchange.apiAudience must be one of - the client's audiences - rule: '!has(self.tokenExchange) || !has(self.tokenExchange.apiAudience) - || size(self.tokenExchange.apiAudience) == 0 || - self.tokenExchange.apiAudience in self.audiences' - message: tokenExchange.allowedSourceClients must not contain the client's own clientId or the "*" wildcard rule: '!has(self.tokenExchange) || !has(self.tokenExchange.allowedSourceClients) 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 1ae4e5a6d8..4f7b749155 100644 --- a/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_virtualmcpservers.yaml +++ b/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_virtualmcpservers.yaml @@ -365,13 +365,6 @@ spec: maxItems: 20 type: array x-kubernetes-list-type: set - apiAudience: - description: |- - APIAudience is the client's own API audience. The client may request it - even when the subject token does not cover it. It must be one of the - client's audiences. Valid only with restricted. - maxLength: 2048 - type: string selfIssuedSubjectTokens: description: |- SelfIssuedSubjectTokens selects which self-issued subject tokens the @@ -384,15 +377,32 @@ spec: - any - restricted type: string + subjectTokenAudiences: + description: |- + SubjectTokenAudiences lists audiences that a self-issued subject token's + aud claim must contain at least one of, matched exactly. Listing a value + grants nothing: the client can receive that audience only if its own + audiences also cover it. Each entry must be one of the server's allowed + audiences; the server checks that when it loads the configuration. + Valid only with restricted. An explicit empty list is refused; omit the + field for no requirement. + items: + maxLength: 2048 + minLength: 1 + type: string + maxItems: 20 + minItems: 1 + type: array + x-kubernetes-list-type: set required: - selfIssuedSubjectTokens type: object x-kubernetes-validations: - - message: tokenExchange.allowedSourceClients and tokenExchange.apiAudience + - message: tokenExchange.allowedSourceClients and tokenExchange.subjectTokenAudiences are valid only when selfIssuedSubjectTokens is restricted rule: self.selfIssuedSubjectTokens == 'restricted' || ((!has(self.allowedSourceClients) || size(self.allowedSourceClients) - == 0) && (!has(self.apiAudience) || size(self.apiAudience) + == 0) && (!has(self.subjectTokenAudiences) || size(self.subjectTokenAudiences) == 0)) required: - audiences @@ -426,11 +436,6 @@ spec: for private_key_jwt clients rule: '!(has(self.tokenEndpointAuthMethod) && self.tokenEndpointAuthMethod == ''private_key_jwt'') || has(self.tokenExchange)' - - message: tokenExchange.apiAudience must be one of the client's - audiences - rule: '!has(self.tokenExchange) || !has(self.tokenExchange.apiAudience) - || size(self.tokenExchange.apiAudience) == 0 || self.tokenExchange.apiAudience - in self.audiences' - message: tokenExchange.allowedSourceClients must not contain the client's own clientId or the "*" wildcard rule: '!has(self.tokenExchange) || !has(self.tokenExchange.allowedSourceClients) @@ -892,13 +897,6 @@ spec: maxItems: 20 type: array x-kubernetes-list-type: set - apiAudience: - description: |- - APIAudience is the client's own API audience. The client may request it - even when the subject token does not cover it. It must be one of the - client's audiences. Valid only with restricted. - maxLength: 2048 - type: string selfIssuedSubjectTokens: description: |- SelfIssuedSubjectTokens selects which self-issued subject tokens the @@ -911,17 +909,34 @@ spec: - any - restricted type: string + subjectTokenAudiences: + description: |- + SubjectTokenAudiences lists audiences that a self-issued subject token's + aud claim must contain at least one of, matched exactly. Listing a value + grants nothing: the client can receive that audience only if its own + audiences also cover it. Each entry must be one of the server's allowed + audiences; the server checks that when it loads the configuration. + Valid only with restricted. An explicit empty list is refused; omit the + field for no requirement. + items: + maxLength: 2048 + minLength: 1 + type: string + maxItems: 20 + minItems: 1 + type: array + x-kubernetes-list-type: set required: - selfIssuedSubjectTokens type: object x-kubernetes-validations: - message: tokenExchange.allowedSourceClients and - tokenExchange.apiAudience are valid only when - selfIssuedSubjectTokens is restricted + tokenExchange.subjectTokenAudiences are valid + only when selfIssuedSubjectTokens is restricted rule: self.selfIssuedSubjectTokens == 'restricted' || ((!has(self.allowedSourceClients) || size(self.allowedSourceClients) - == 0) && (!has(self.apiAudience) || size(self.apiAudience) - == 0)) + == 0) && (!has(self.subjectTokenAudiences) || + size(self.subjectTokenAudiences) == 0)) required: - audiences - clientId @@ -954,11 +969,6 @@ spec: required for private_key_jwt clients rule: '!(has(self.tokenEndpointAuthMethod) && self.tokenEndpointAuthMethod == ''private_key_jwt'') || has(self.tokenExchange)' - - message: tokenExchange.apiAudience must be one of - the client's audiences - rule: '!has(self.tokenExchange) || !has(self.tokenExchange.apiAudience) - || size(self.tokenExchange.apiAudience) == 0 || - self.tokenExchange.apiAudience in self.audiences' - message: tokenExchange.allowedSourceClients must not contain the client's own clientId or the "*" wildcard rule: '!has(self.tokenExchange) || !has(self.tokenExchange.allowedSourceClients) @@ -6229,13 +6239,6 @@ spec: maxItems: 20 type: array x-kubernetes-list-type: set - apiAudience: - description: |- - APIAudience is the client's own API audience. The client may request it - even when the subject token does not cover it. It must be one of the - client's audiences. Valid only with restricted. - maxLength: 2048 - type: string selfIssuedSubjectTokens: description: |- SelfIssuedSubjectTokens selects which self-issued subject tokens the @@ -6248,15 +6251,32 @@ spec: - any - restricted type: string + subjectTokenAudiences: + description: |- + SubjectTokenAudiences lists audiences that a self-issued subject token's + aud claim must contain at least one of, matched exactly. Listing a value + grants nothing: the client can receive that audience only if its own + audiences also cover it. Each entry must be one of the server's allowed + audiences; the server checks that when it loads the configuration. + Valid only with restricted. An explicit empty list is refused; omit the + field for no requirement. + items: + maxLength: 2048 + minLength: 1 + type: string + maxItems: 20 + minItems: 1 + type: array + x-kubernetes-list-type: set required: - selfIssuedSubjectTokens type: object x-kubernetes-validations: - - message: tokenExchange.allowedSourceClients and tokenExchange.apiAudience + - message: tokenExchange.allowedSourceClients and tokenExchange.subjectTokenAudiences are valid only when selfIssuedSubjectTokens is restricted rule: self.selfIssuedSubjectTokens == 'restricted' || ((!has(self.allowedSourceClients) || size(self.allowedSourceClients) - == 0) && (!has(self.apiAudience) || size(self.apiAudience) + == 0) && (!has(self.subjectTokenAudiences) || size(self.subjectTokenAudiences) == 0)) required: - audiences @@ -6290,11 +6310,6 @@ spec: for private_key_jwt clients rule: '!(has(self.tokenEndpointAuthMethod) && self.tokenEndpointAuthMethod == ''private_key_jwt'') || has(self.tokenExchange)' - - message: tokenExchange.apiAudience must be one of the client's - audiences - rule: '!has(self.tokenExchange) || !has(self.tokenExchange.apiAudience) - || size(self.tokenExchange.apiAudience) == 0 || self.tokenExchange.apiAudience - in self.audiences' - message: tokenExchange.allowedSourceClients must not contain the client's own clientId or the "*" wildcard rule: '!has(self.tokenExchange) || !has(self.tokenExchange.allowedSourceClients) @@ -6756,13 +6771,6 @@ spec: maxItems: 20 type: array x-kubernetes-list-type: set - apiAudience: - description: |- - APIAudience is the client's own API audience. The client may request it - even when the subject token does not cover it. It must be one of the - client's audiences. Valid only with restricted. - maxLength: 2048 - type: string selfIssuedSubjectTokens: description: |- SelfIssuedSubjectTokens selects which self-issued subject tokens the @@ -6775,17 +6783,34 @@ spec: - any - restricted type: string + subjectTokenAudiences: + description: |- + SubjectTokenAudiences lists audiences that a self-issued subject token's + aud claim must contain at least one of, matched exactly. Listing a value + grants nothing: the client can receive that audience only if its own + audiences also cover it. Each entry must be one of the server's allowed + audiences; the server checks that when it loads the configuration. + Valid only with restricted. An explicit empty list is refused; omit the + field for no requirement. + items: + maxLength: 2048 + minLength: 1 + type: string + maxItems: 20 + minItems: 1 + type: array + x-kubernetes-list-type: set required: - selfIssuedSubjectTokens type: object x-kubernetes-validations: - message: tokenExchange.allowedSourceClients and - tokenExchange.apiAudience are valid only when - selfIssuedSubjectTokens is restricted + tokenExchange.subjectTokenAudiences are valid + only when selfIssuedSubjectTokens is restricted rule: self.selfIssuedSubjectTokens == 'restricted' || ((!has(self.allowedSourceClients) || size(self.allowedSourceClients) - == 0) && (!has(self.apiAudience) || size(self.apiAudience) - == 0)) + == 0) && (!has(self.subjectTokenAudiences) || + size(self.subjectTokenAudiences) == 0)) required: - audiences - clientId @@ -6818,11 +6843,6 @@ spec: required for private_key_jwt clients rule: '!(has(self.tokenEndpointAuthMethod) && self.tokenEndpointAuthMethod == ''private_key_jwt'') || has(self.tokenExchange)' - - message: tokenExchange.apiAudience must be one of - the client's audiences - rule: '!has(self.tokenExchange) || !has(self.tokenExchange.apiAudience) - || size(self.tokenExchange.apiAudience) == 0 || - self.tokenExchange.apiAudience in self.audiences' - message: tokenExchange.allowedSourceClients must not contain the client's own clientId or the "*" wildcard rule: '!has(self.tokenExchange) || !has(self.tokenExchange.allowedSourceClients) diff --git a/docs/arch/17-token-exchange-delegation.md b/docs/arch/17-token-exchange-delegation.md index 247e564341..66317cc4df 100644 --- a/docs/arch/17-token-exchange-delegation.md +++ b/docs/arch/17-token-exchange-delegation.md @@ -257,14 +257,15 @@ table below on it. Put them on the canonical list. | `tokenEndpointAuthSigningAlg` | The `private_key_jwt` signing algorithm. If omitted, it is derived from the keys: each key's `alg`, or `ES256`, `ES384` or `ES512` from an EC key's curve. RSA keys without `alg` use `RS256`. Keys that disagree must set it. | | `grantTypes` | A subset of `authorization_code`, `refresh_token`, token exchange and JWT-bearer. When set, it must include token exchange. When set, the list is complete: include the JWT-bearer grant (`urn:ietf:params:oauth:grant-type:jwt-bearer`) to keep ID-JAG redemption. | | `redirectUris` | Required with `authorization_code`. They sit under `tokenExchange.delegateClients` because a delegate client is a token-exchange client that may also log in. | -| `tokenExchange` | `selfIssuedSubjectTokens` (`any` or `restricted`, required in the block, no default), `allowedSourceClients` and `apiAudience`. The block is required when `grantTypes` is set and for every `private_key_jwt` client. It may be omitted only by a secret-authenticated client without `grantTypes`, which keeps the legacy blanket trust (`any`). See [Source policy and API audience](#source-policy-and-api-audience). | +| `tokenExchange` | `selfIssuedSubjectTokens` (`any` or `restricted`, required in the block, no default), `allowedSourceClients` and `subjectTokenAudiences`. The block is required when `grantTypes` is set and for every `private_key_jwt` client. It may be omitted only by a secret-authenticated client without `grantTypes`, which keeps the legacy blanket trust (`any`). See [Source policy and subject token audiences](#source-policy-and-subject-token-audiences). | `clientSecretRef` is required unless the method is `private_key_jwt`, where it is forbidden. CEL admission enforces this, the token-exchange rule for `grantTypes`, `redirectUris` (required with `authorization_code` and forbidden without it), `refresh_token` only together with `authorization_code`, the `tokenExchange` block -when `grantTypes` is set or the method is `private_key_jwt`, `allowedSourceClients` and `apiAudience` only with -`restricted`, and `tokenEndpointAuthSigningAlg` only with `private_key_jwt` and +when `grantTypes` is set or the method is `private_key_jwt`, `allowedSourceClients` +and `subjectTokenAudiences` only with `restricted`, and `tokenEndpointAuthSigningAlg` +only with `private_key_jwt` and only from the supported algorithms (`RS256`, `RS384`, `RS512`, `PS256`, `PS384`, `PS512`, `ES256`, `ES384`, `ES512`). `selfIssuedSubjectTokens` has no schema default on purpose: a default would be applied before CEL runs, so "set it explicitly" could not be @@ -272,9 +273,9 @@ enforced, and it would also be applied to objects read back from etcd, turning a stored `tokenExchange: {}` into blanket trust. The shared Go validator checks everything else (public keys only, redirect-URI policy, source-client rules). -Admission checks three rules for each delegate client. `apiAudience` must be one -of the client's `audiences`. `allowedSourceClients` must not contain the client's -own `clientId` or `*`. Redirect URIs must use `https` and must not contain a +Admission checks three rules for each delegate client. `subjectTokenAudiences` +holds 1 to 20 entries. `allowedSourceClients` must not contain the client's own +`clientId` or `*`. Redirect URIs must use `https` and must not contain a fragment. The operator checks two more rules when it reconciles, not at admission: `allowedSourceClients` must not name another configured delegate client, and a loopback redirect URI is refused. A `VirtualMCPServer` reports diff --git a/docs/operator/crd-api.md b/docs/operator/crd-api.md index 9ed419f665..bcc8ccfb47 100644 --- a/docs/operator/crd-api.md +++ b/docs/operator/crd-api.md @@ -2014,7 +2014,7 @@ _Appears in:_ #### api.v1beta1.DelegateClientTokenExchangeConfig -_Underlying type:_ _struct{SelfIssuedSubjectTokens string "json:\"selfIssuedSubjectTokens\""; AllowedSourceClients []string "json:\"allowedSourceClients,omitempty\""; APIAudience string "json:\"apiAudience,omitempty\""}_ +_Underlying type:_ _struct{SelfIssuedSubjectTokens string "json:\"selfIssuedSubjectTokens\""; AllowedSourceClients []string "json:\"allowedSourceClients,omitempty\""; SubjectTokenAudiences []string "json:\"subjectTokenAudiences,omitempty\""}_ DelegateClientTokenExchangeConfig is the token-exchange policy of one delegate client. External-issuer subject tokens are not affected by it: the From 4ea59c0738646d0d6346f3fc3ee76a6ff8cf49ba Mon Sep 17 00:00:00 2001 From: Jakub Hrozek Date: Tue, 3 Nov 2026 16:09:35 +0100 Subject: [PATCH 10/10] Expect 401 invalid_client for an unknown assertion kid Failed client assertions now return 401 invalid_client (#7036), including when the kid is no longer registered. The JWKS rollout e2e spec still expected 400 invalid_request for the retired key, so it failed on every Kubernetes version. Co-authored-by: Mecatl --- .../virtualmcp/virtualmcp_delegate_jwks_test.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/test/e2e/thv-operator/virtualmcp/virtualmcp_delegate_jwks_test.go b/test/e2e/thv-operator/virtualmcp/virtualmcp_delegate_jwks_test.go index 819413460e..b42e34ad9f 100644 --- a/test/e2e/thv-operator/virtualmcp/virtualmcp_delegate_jwks_test.go +++ b/test/e2e/thv-operator/virtualmcp/virtualmcp_delegate_jwks_test.go @@ -292,11 +292,11 @@ var _ = ginkgo.Describe("VirtualMCPServer delegate client jwksRef", ginkgo.Order localURL, cleanupAfter := portForwardDelegateAuthServer(vmcpName) defer cleanupAfter() // keyA rejection is the load-bearing check: it only holds once the retired key is gone. - // The kid is no longer registered, so fosite rejects the assertion as a malformed - // request (400 invalid_request, "kid ... could not be found"), not as a wrong key - // under a registered kid (401 invalid_client, as in the wrong-key case above). - body := eventuallyDelegateExchange(localURL, issuer, subjectToken(delegateID), keyA, kidA, http.StatusBadRequest) - gomega.Expect(delegateOAuthError(body)).To(gomega.Equal("invalid_request"), string(body)) + // The kid is no longer registered. A failed client assertion is answered as + // 401 invalid_client whether the kid is unknown or the key is wrong, the same + // as the wrong-key case above. + body := eventuallyDelegateExchange(localURL, issuer, subjectToken(delegateID), keyA, kidA, http.StatusUnauthorized) + gomega.Expect(delegateOAuthError(body)).To(gomega.Equal("invalid_client"), string(body)) // keyB is a control: the surviving key must still be accepted. eventuallyDelegateExchange(localURL, issuer, subjectToken(delegateID), keyB, kidB, http.StatusOK) })