Skip to content

Commit 64feb73

Browse files
tgrunnagleclaude
andcommitted
Expose DCR config in operator CRD for OAuth2 upstreams
Implements changes for issue #5040 (Phase 2 DCR CRD surface): - Add DCRUpstreamConfig CRD type (discoveryUrl, registrationEndpoint, initialAccessTokenRef, softwareId, softwareStatement) and a new dcrConfig field on OAuth2UpstreamConfig so Kubernetes users can configure RFC 7591 Dynamic Client Registration on upstream providers. - Make OAuth2UpstreamConfig.clientId optional and add CEL validation requiring exactly one of clientId or dcrConfig, and exactly one of discoveryUrl or registrationEndpoint inside dcrConfig. Mirror the checks at runtime via validateOAuth2DCRConfig for defense-in-depth. - Wire the conversion in controllerutil/authserver.go so DCRConfig is mapped onto authserver.DCRUpstreamConfig. InitialAccessTokenRef is resolved to an env var (TOOLHIVE_UPSTREAM_DCR_INITIAL_ACCESS_TOKEN_*) populated from the referenced Secret, mirroring the ClientSecretRef pattern. Extract small helpers for env-var generation to keep cyclomatic complexity within lint limits. - Regenerate zz_generated.deepcopy.go, CRD YAML manifests, and CRD API reference docs. - Add table-driven validation tests covering DCR+ClientID conflict, both endpoints set, neither endpoint set, valid single-endpoint cases, and neither-auth configuration. Add conversion tests covering DCR discoveryUrl/registrationEndpoint paths and initial-access-token env var wiring. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Address code review feedback Fixed issues from code review of the DCR CRD surface commit: - CRITICAL: CEL markers contained a Unicode smart quote (U+201D) that gofmt's doc-comment formatter reintroduced on every lint-fix. Rewrote both markers to use CEL's size(...) > 0 idiom instead of `!= ''`, which sidesteps the typographic normalization entirely and keeps regeneration idempotent. Verified no U+2018-U+201F characters remain in source or CRDs. - HIGH: buildUpstreamRunConfig now calls the exported mcpv1beta1.ValidateOAuth2DCRConfig before producing a RunConfig, so malformed ClientID/DCRConfig pairs that bypass admission fail at reconcile time rather than at authserver startup. Error propagation threaded through BuildAuthServerRunConfig; split OIDC and OAuth2 branches into helpers to stay under the gocyclo limit. - HIGH: Added table case exercising validateUpstreamProvider rejection of an OIDC-typed provider whose OAuth2Config carries a DCRConfig. - MEDIUM: Added kubebuilder CEL XValidation on UpstreamProviderConfig enforcing oidcConfig/oauth2Config mutual exclusivity paired to the declared type, closing the silent-pod-failure YAML-apply gap. - MEDIUM: Added MaxLength=255 to SoftwareID and MaxLength=4096 to SoftwareStatement to prevent unbounded input from inflating CRs beyond etcd object limits. - MEDIUM: Pinned the "neither ClientID nor DCRConfig" error assertion to the scoped `oauth2Config:` prefix; added a regression case exercising the non-DCR OAuth2 path (ClientID only, DCRConfig nil); added a new TestBuildAuthServerRunConfig_InvalidDCR suite covering all four invalid DCR/ClientID pairings at the conversion layer. - MEDIUM: Renamed UpstreamDCRInitialAccessTokenEnvVar to UpstreamDCRInitialAccessTokenEnvVarPrefix and expanded the godoc on both prefix constants to show the resolved <prefix>_<PROVIDER> form. All task lint/lint-fix/license-check pass; regenerated CRDs and deepcopy are idempotent; affected unit tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Address iteration-2 review feedback Polish items raised in the second review pass: - MEDIUM: Trim duplicate upstream name from reconcile-time DCR validation errors. Added scopedFieldPath() helper in cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types.go so ValidateOAuth2DCRConfig prepends a dotted prefix only when one is given, and the conversion call site now passes an empty prefix so BuildAuthServerRunConfig's outer "upstream %q: %w" wrap is the only mention of the upstream name. Strengthened TestBuildAuthServerRunConfig_InvalidDCR to assert the upstream name appears exactly once in the error string. - MEDIUM: Make the UpstreamProviderConfig CEL rule fail closed for unrecognized future provider types. Restructured the rule from a binary discriminator into a chain of equality checks ending in an explicit `false`, and updated the message to "type must be 'oidc' or 'oauth2'; ...". Added a contributor-facing doc comment reminding future authors to extend both the rule and validateUpstreamProvider when adding a new UpstreamProviderType. - MEDIUM: Refresh the godoc on extractUpstreamSecretRefs to describe the actual invariants that hold post-CEL: OIDC providers can only return a clientSecretRef; OAuth2 providers can return both independently; other (currently unreachable) types return nil/nil. Cross-linked to the CEL rule and noted that BuildAuthServerRunConfig is the reconcile-time backstop callers should not rely on this helper to enforce. Regenerated CRD YAMLs and crd-api.md prose. task lint, lint-fix, license-check, and the affected unit tests pass; regeneration is idempotent. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 5f1d5ff commit 64feb73

10 files changed

Lines changed: 1674 additions & 136 deletions

cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types.go

Lines changed: 154 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -273,6 +273,21 @@ const (
273273
)
274274

275275
// UpstreamProviderConfig defines configuration for an upstream Identity Provider.
276+
//
277+
// Exactly one of OIDCConfig or OAuth2Config must be set and must match the
278+
// declared Type: oidc-typed providers set OIDCConfig, oauth2-typed providers
279+
// set OAuth2Config. The CEL rule below enforces the pairing at admission; the
280+
// matching Go-level check in validateUpstreamProvider provides defense-in-depth
281+
// for stored objects.
282+
//
283+
// The rule is structured as a chain of equality checks ending in an explicit
284+
// `false`, so adding a new UpstreamProviderType value without extending this
285+
// rule fails admission instead of silently demanding the OAuth2 shape. When
286+
// adding a new type, extend both this rule and validateUpstreamProvider.
287+
//
288+
// +kubebuilder:validation:XValidation:rule="self.type == 'oidc' ? (has(self.oidcConfig) && !has(self.oauth2Config)) : self.type == 'oauth2' ? (has(self.oauth2Config) && !has(self.oidcConfig)) : false",message="type must be 'oidc' or 'oauth2'; oidcConfig must be set when type is 'oidc' and oauth2Config must be set when type is 'oauth2' (and the other must not be set)"
289+
//
290+
//nolint:lll // CEL validation rules exceed line length limit
276291
type UpstreamProviderConfig struct {
277292
// Name uniquely identifies this upstream provider.
278293
// Used for routing decisions and session binding in multi-upstream scenarios.
@@ -354,6 +369,14 @@ type OIDCUpstreamConfig struct {
354369

355370
// OAuth2UpstreamConfig contains configuration for pure OAuth 2.0 providers.
356371
// OAuth 2.0 providers require explicit endpoint configuration.
372+
//
373+
// Exactly one of ClientID or DCRConfig must be set: ClientID is used when the
374+
// client is pre-provisioned out of band, DCRConfig enables RFC 7591 Dynamic
375+
// Client Registration at runtime.
376+
//
377+
// +kubebuilder:validation:XValidation:rule="(has(self.clientId) && size(self.clientId) > 0) ? !has(self.dcrConfig) : has(self.dcrConfig)",message="exactly one of clientId or dcrConfig must be set"
378+
//
379+
//nolint:lll // CEL validation rules exceed line length limit
357380
type OAuth2UpstreamConfig struct {
358381
// AuthorizationEndpoint is the URL for the OAuth authorization endpoint.
359382
// +kubebuilder:validation:Required
@@ -374,8 +397,10 @@ type OAuth2UpstreamConfig struct {
374397
UserInfo *UserInfoConfig `json:"userInfo,omitempty"`
375398

376399
// ClientID is the OAuth 2.0 client identifier registered with the upstream IDP.
377-
// +kubebuilder:validation:Required
378-
ClientID string `json:"clientId"`
400+
// Mutually exclusive with DCRConfig: when DCRConfig is set, ClientID is obtained
401+
// at runtime via RFC 7591 Dynamic Client Registration and must be left empty.
402+
// +optional
403+
ClientID string `json:"clientId,omitempty"`
379404

380405
// ClientSecretRef references a Kubernetes Secret containing the OAuth 2.0 client secret.
381406
// Optional for public clients using PKCE instead of client secret.
@@ -410,6 +435,81 @@ type OAuth2UpstreamConfig struct {
410435
// +kubebuilder:validation:MaxProperties=16
411436
// +optional
412437
AdditionalAuthorizationParams map[string]string `json:"additionalAuthorizationParams,omitempty"`
438+
439+
// DCRConfig enables RFC 7591 Dynamic Client Registration against the upstream
440+
// authorization server. When set, the client credentials are obtained at
441+
// runtime rather than being pre-provisioned, and ClientID must be left empty.
442+
// Mutually exclusive with ClientID.
443+
// +optional
444+
DCRConfig *DCRUpstreamConfig `json:"dcrConfig,omitempty"`
445+
}
446+
447+
// DCRUpstreamConfig configures RFC 7591 Dynamic Client Registration for an
448+
// OAuth 2.0 upstream. When present on an OAuth2 upstream, the authserver
449+
// performs registration at runtime to obtain client credentials, replacing
450+
// the need to pre-provision a ClientID.
451+
//
452+
// Exactly one of DiscoveryURL or RegistrationEndpoint must be set. DiscoveryURL
453+
// points at an RFC 8414 / OIDC Discovery document from which the registration
454+
// endpoint is resolved; RegistrationEndpoint is used directly when the upstream
455+
// does not publish discovery metadata.
456+
//
457+
// The XOR rule uses has() alone (not has() + size() > 0) to keep the rule's
458+
// estimated CEL cost under the apiserver's per-rule static budget. With
459+
// `omitempty` on both fields, an unset field is absent on the wire and has()
460+
// returns false; the explicit-empty-string edge case is rejected at reconcile
461+
// time by ValidateOAuth2DCRConfig.
462+
//
463+
// +kubebuilder:validation:XValidation:rule="has(self.discoveryUrl) != has(self.registrationEndpoint)",message="exactly one of discoveryUrl or registrationEndpoint must be set"
464+
//
465+
//nolint:lll // CEL validation rules exceed line length limit
466+
type DCRUpstreamConfig struct {
467+
// DiscoveryURL is the RFC 8414 / OIDC Discovery document URL. The resolver
468+
// issues a single GET against this URL (no well-known-path fallback) and
469+
// reads registration_endpoint, authorization_endpoint, token_endpoint,
470+
// token_endpoint_auth_methods_supported, and scopes_supported from the
471+
// response.
472+
// Mutually exclusive with RegistrationEndpoint.
473+
// MaxLength bounds CEL cost estimation on the parent struct's
474+
// XValidation rule and matches the conventional URL length cap.
475+
// +optional
476+
// +kubebuilder:validation:Pattern=`^https?://.*$`
477+
// +kubebuilder:validation:MaxLength=2048
478+
DiscoveryURL string `json:"discoveryUrl,omitempty"`
479+
480+
// RegistrationEndpoint is the RFC 7591 registration endpoint URL used
481+
// directly, bypassing discovery. When using this field, the caller is
482+
// expected to also supply AuthorizationEndpoint, TokenEndpoint, and an
483+
// explicit Scopes list on the parent OAuth2UpstreamConfig.
484+
// Mutually exclusive with DiscoveryURL.
485+
// MaxLength bounds CEL cost estimation on the parent struct's
486+
// XValidation rule and matches the conventional URL length cap.
487+
// +optional
488+
// +kubebuilder:validation:Pattern=`^https?://.*$`
489+
// +kubebuilder:validation:MaxLength=2048
490+
RegistrationEndpoint string `json:"registrationEndpoint,omitempty"`
491+
492+
// InitialAccessTokenRef is an optional reference to a Kubernetes Secret
493+
// carrying an RFC 7591 §3 initial access token. When set, the resolver
494+
// presents the token value as a Bearer credential on the registration
495+
// request. Mirrors the ClientSecretRef pattern.
496+
// +optional
497+
InitialAccessTokenRef *SecretKeyRef `json:"initialAccessTokenRef,omitempty"`
498+
499+
// SoftwareID is the RFC 7591 "software_id" registration metadata value,
500+
// identifying the client software independent of any particular
501+
// registration instance. Typically a UUID or short identifier.
502+
// +optional
503+
// +kubebuilder:validation:MaxLength=255
504+
SoftwareID string `json:"softwareId,omitempty"`
505+
506+
// SoftwareStatement is the RFC 7591 "software_statement" JWT asserting
507+
// metadata about the client software, signed by a party the authorization
508+
// server trusts. Bounded to 4096 characters to prevent unbounded
509+
// user input from inflating the CR beyond etcd object limits.
510+
// +optional
511+
// +kubebuilder:validation:MaxLength=4096
512+
SoftwareStatement string `json:"softwareStatement,omitempty"`
413513
}
414514

415515
// TokenResponseMapping maps non-standard token response fields to standard OAuth 2.0 fields
@@ -956,10 +1056,62 @@ func (*MCPExternalAuthConfig) validateUpstreamProvider(index int, provider *Upst
9561056
return fmt.Errorf("%s: unsupported provider type: %s", prefix, provider.Type)
9571057
}
9581058

1059+
// Validate OAuth2-specific DCR / ClientID constraints (defense-in-depth with CEL).
1060+
if provider.Type == UpstreamProviderTypeOAuth2 && provider.OAuth2Config != nil {
1061+
if err := ValidateOAuth2DCRConfig(prefix, provider.OAuth2Config); err != nil {
1062+
return err
1063+
}
1064+
}
1065+
9591066
// Validate additionalAuthorizationParams does not contain reserved keys
9601067
return ValidateAdditionalAuthorizationParams(prefix, provider.AdditionalAuthorizationParams())
9611068
}
9621069

1070+
// ValidateOAuth2DCRConfig enforces the mutual exclusivity between ClientID and
1071+
// DCRConfig and, when DCRConfig is present, between DiscoveryURL and
1072+
// RegistrationEndpoint. These rules mirror the CEL validation on
1073+
// OAuth2UpstreamConfig and DCRUpstreamConfig and provide defense-in-depth for
1074+
// stored objects (e.g., objects stored before CEL rules were added or
1075+
// validated through code paths that bypass admission).
1076+
//
1077+
// The prefix is embedded in error messages to identify the offending upstream
1078+
// (e.g., "upstreamProviders[i]"). Pass an empty string when the caller already
1079+
// wraps the error with the upstream identifier, to avoid duplicating it.
1080+
// Exported so the controllerutil conversion layer can reuse the same
1081+
// invariants when building runtime configs, rejecting malformed objects at
1082+
// reconcile time rather than waiting until the authserver process parses them.
1083+
func ValidateOAuth2DCRConfig(prefix string, cfg *OAuth2UpstreamConfig) error {
1084+
hasClientID := cfg.ClientID != ""
1085+
hasDCR := cfg.DCRConfig != nil
1086+
1087+
scoped := scopedFieldPath(prefix, "oauth2Config")
1088+
if hasClientID == hasDCR {
1089+
return fmt.Errorf("%s: exactly one of clientId or dcrConfig must be set", scoped)
1090+
}
1091+
1092+
if !hasDCR {
1093+
return nil
1094+
}
1095+
1096+
hasDiscoveryURL := cfg.DCRConfig.DiscoveryURL != ""
1097+
hasRegistrationEndpoint := cfg.DCRConfig.RegistrationEndpoint != ""
1098+
if hasDiscoveryURL == hasRegistrationEndpoint {
1099+
return fmt.Errorf("%s.dcrConfig: exactly one of discoveryUrl or registrationEndpoint must be set", scoped)
1100+
}
1101+
return nil
1102+
}
1103+
1104+
// scopedFieldPath joins a non-empty prefix to a field name with a dot. When
1105+
// the prefix is empty, it returns just the field name so callers that already
1106+
// supply their own scope (e.g., a wrapping `fmt.Errorf("upstream %q: %w", ...)`)
1107+
// don't end up with a leading dot.
1108+
func scopedFieldPath(prefix, field string) string {
1109+
if prefix == "" {
1110+
return field
1111+
}
1112+
return prefix + "." + field
1113+
}
1114+
9631115
// AdditionalAuthorizationParams returns the additional authorization parameters
9641116
// from whichever upstream config is set, or nil if none.
9651117
func (p *UpstreamProviderConfig) AdditionalAuthorizationParams() map[string]string {

cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types_test.go

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -571,6 +571,125 @@ func TestMCPExternalAuthConfig_validateUpstreamProvider(t *testing.T) {
571571
},
572572
expectErr: false,
573573
},
574+
{
575+
name: "OAuth2 provider with valid DCRConfig (discoveryUrl only)",
576+
provider: UpstreamProviderConfig{
577+
Name: "dcr-discovery",
578+
Type: UpstreamProviderTypeOAuth2,
579+
OAuth2Config: &OAuth2UpstreamConfig{
580+
AuthorizationEndpoint: "https://idp.example.com/authorize",
581+
TokenEndpoint: "https://idp.example.com/token",
582+
UserInfo: &UserInfoConfig{EndpointURL: "https://idp.example.com/userinfo"},
583+
DCRConfig: &DCRUpstreamConfig{
584+
DiscoveryURL: "https://idp.example.com/.well-known/openid-configuration",
585+
},
586+
},
587+
},
588+
expectErr: false,
589+
},
590+
{
591+
name: "OAuth2 provider with valid DCRConfig (registrationEndpoint only)",
592+
provider: UpstreamProviderConfig{
593+
Name: "dcr-endpoint",
594+
Type: UpstreamProviderTypeOAuth2,
595+
OAuth2Config: &OAuth2UpstreamConfig{
596+
AuthorizationEndpoint: "https://idp.example.com/authorize",
597+
TokenEndpoint: "https://idp.example.com/token",
598+
UserInfo: &UserInfoConfig{EndpointURL: "https://idp.example.com/userinfo"},
599+
Scopes: []string{"openid"},
600+
DCRConfig: &DCRUpstreamConfig{
601+
RegistrationEndpoint: "https://idp.example.com/register",
602+
},
603+
},
604+
},
605+
expectErr: false,
606+
},
607+
{
608+
name: "OAuth2 provider with DCRConfig and non-empty ClientID",
609+
provider: UpstreamProviderConfig{
610+
Name: "dcr-with-clientid",
611+
Type: UpstreamProviderTypeOAuth2,
612+
OAuth2Config: &OAuth2UpstreamConfig{
613+
AuthorizationEndpoint: "https://idp.example.com/authorize",
614+
TokenEndpoint: "https://idp.example.com/token",
615+
UserInfo: &UserInfoConfig{EndpointURL: "https://idp.example.com/userinfo"},
616+
ClientID: "pre-provisioned-id",
617+
DCRConfig: &DCRUpstreamConfig{
618+
DiscoveryURL: "https://idp.example.com/.well-known/openid-configuration",
619+
},
620+
},
621+
},
622+
expectErr: true,
623+
errMsg: "exactly one of clientId or dcrConfig must be set",
624+
},
625+
{
626+
name: "OAuth2 provider with DCRConfig specifying both endpoints",
627+
provider: UpstreamProviderConfig{
628+
Name: "dcr-both-endpoints",
629+
Type: UpstreamProviderTypeOAuth2,
630+
OAuth2Config: &OAuth2UpstreamConfig{
631+
AuthorizationEndpoint: "https://idp.example.com/authorize",
632+
TokenEndpoint: "https://idp.example.com/token",
633+
UserInfo: &UserInfoConfig{EndpointURL: "https://idp.example.com/userinfo"},
634+
DCRConfig: &DCRUpstreamConfig{
635+
DiscoveryURL: "https://idp.example.com/.well-known/openid-configuration",
636+
RegistrationEndpoint: "https://idp.example.com/register",
637+
},
638+
},
639+
},
640+
expectErr: true,
641+
errMsg: "exactly one of discoveryUrl or registrationEndpoint must be set",
642+
},
643+
{
644+
name: "OAuth2 provider with DCRConfig specifying neither endpoint",
645+
provider: UpstreamProviderConfig{
646+
Name: "dcr-neither-endpoint",
647+
Type: UpstreamProviderTypeOAuth2,
648+
OAuth2Config: &OAuth2UpstreamConfig{
649+
AuthorizationEndpoint: "https://idp.example.com/authorize",
650+
TokenEndpoint: "https://idp.example.com/token",
651+
UserInfo: &UserInfoConfig{EndpointURL: "https://idp.example.com/userinfo"},
652+
DCRConfig: &DCRUpstreamConfig{},
653+
},
654+
},
655+
expectErr: true,
656+
errMsg: "exactly one of discoveryUrl or registrationEndpoint must be set",
657+
},
658+
{
659+
name: "OAuth2 provider with neither ClientID nor DCRConfig",
660+
provider: UpstreamProviderConfig{
661+
Name: "oauth2-missing-auth",
662+
Type: UpstreamProviderTypeOAuth2,
663+
OAuth2Config: &OAuth2UpstreamConfig{
664+
AuthorizationEndpoint: "https://idp.example.com/authorize",
665+
TokenEndpoint: "https://idp.example.com/token",
666+
UserInfo: &UserInfoConfig{EndpointURL: "https://idp.example.com/userinfo"},
667+
},
668+
},
669+
expectErr: true,
670+
// Pin the scoped prefix so a rename of the oauth2Config label surfaces as a test failure.
671+
errMsg: "oauth2Config: exactly one of clientId or dcrConfig must be set",
672+
},
673+
{
674+
// Regression guard: conversion only maps DCRConfig in the OAuth2
675+
// branch, so an OIDC-typed provider carrying OAuth2Config/DCRConfig
676+
// must be rejected at validate time rather than silently dropped.
677+
name: "OIDC provider with OAuth2Config carrying DCRConfig is rejected",
678+
provider: UpstreamProviderConfig{
679+
Name: "mismatched-oidc",
680+
Type: UpstreamProviderTypeOIDC,
681+
OAuth2Config: &OAuth2UpstreamConfig{
682+
AuthorizationEndpoint: "https://idp.example.com/authorize",
683+
TokenEndpoint: "https://idp.example.com/token",
684+
UserInfo: &UserInfoConfig{EndpointURL: "https://idp.example.com/userinfo"},
685+
DCRConfig: &DCRUpstreamConfig{
686+
DiscoveryURL: "https://idp.example.com/.well-known/openid-configuration",
687+
},
688+
},
689+
},
690+
expectErr: true,
691+
errMsg: "oidcConfig must be set when type is 'oidc' and must not be set otherwise",
692+
},
574693
}
575694

576695
for _, tt := range tests {

cmd/thv-operator/api/v1beta1/zz_generated.deepcopy.go

Lines changed: 25 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)