Add egress selector support to JWT authenticator

This change adds the StructuredAuthenticationConfigurationEgressSelector
beta feature (default on).  When enabled, each JWT authenticator
specified via the AuthenticationConfiguration.jwt array can
optionally specify either the controlplane or cluster egress
selector by setting the issuer.egressSelectorType field.  When
unset, the prior behavior of using no egress selector is retained.

Egress selection is valuable when the persona configuring the JWT
authenticator and the persona managing the control plane are
different individuals.  This change allows the latter to protect
control plane network services from unexpected connections.

Signed-off-by: Monis Khan <mok@microsoft.com>

Kubernetes-commit: b69fd9d42c4d03b8fe5b37433d59f85483835d30
This commit is contained in:
Monis Khan 2025-06-24 17:12:28 -04:00 committed by Kubernetes Publisher
parent 8253534e62
commit bcfdd8b141
12 changed files with 713 additions and 10 deletions

View File

@ -234,6 +234,7 @@ type Issuer struct {
CertificateAuthority string
Audiences []string
AudienceMatchPolicy AudienceMatchPolicyType
EgressSelectorType EgressSelectorType
}
// AudienceMatchPolicyType is a set of valid values for Issuer.AudienceMatchPolicy
@ -244,6 +245,14 @@ const (
AudienceMatchPolicyMatchAny AudienceMatchPolicyType = "MatchAny"
)
type EgressSelectorType string
const (
EgressSelectorControlPlane EgressSelectorType = "controlplane"
EgressSelectorCluster EgressSelectorType = "cluster"
)
// ClaimValidationRule provides the configuration for a single claim validation rule.
type ClaimValidationRule struct {
Claim string

View File

@ -185,6 +185,18 @@ type Issuer struct {
// example: claimValidationRule[].expression: 'sets.equivalent(claims.aud, ["bar", "foo", "baz"])' to require an exact match.
// +optional
AudienceMatchPolicy AudienceMatchPolicyType `json:"audienceMatchPolicy,omitempty"`
// egressSelectorType is an indicator of which egress selection should be used for sending all traffic related
// to this issuer (discovery, JWKS, distributed claims, etc). If unspecified, no custom dialer is used.
// When specified, the valid choices are "controlplane" and "cluster". These correspond to the associated
// values in the --egress-selector-config-file.
//
// - controlplane: for traffic intended to go to the control plane.
//
// - cluster: for traffic intended to go to the system being managed by Kubernetes.
//
// +optional
EgressSelectorType EgressSelectorType `json:"egressSelectorType,omitempty"`
}
// AudienceMatchPolicyType is a set of valid values for issuer.audienceMatchPolicy
@ -196,6 +208,17 @@ const (
AudienceMatchPolicyMatchAny AudienceMatchPolicyType = "MatchAny"
)
// EgressSelectorType is an indicator of which egress selection should be used for sending traffic.
type EgressSelectorType string
const (
// EgressSelectorControlPlane is the EgressSelectorType for traffic intended to go to the control plane.
EgressSelectorControlPlane EgressSelectorType = "controlplane"
// EgressSelectorCluster is the EgressSelectorType for traffic intended to go to the system being managed by Kubernetes.
EgressSelectorCluster EgressSelectorType = "cluster"
)
// ClaimValidationRule provides the configuration for a single claim validation rule.
type ClaimValidationRule struct {
// claim is the name of a required claim.

View File

@ -682,6 +682,7 @@ func autoConvert_v1_Issuer_To_apiserver_Issuer(in *Issuer, out *apiserver.Issuer
out.CertificateAuthority = in.CertificateAuthority
out.Audiences = *(*[]string)(unsafe.Pointer(&in.Audiences))
out.AudienceMatchPolicy = apiserver.AudienceMatchPolicyType(in.AudienceMatchPolicy)
out.EgressSelectorType = apiserver.EgressSelectorType(in.EgressSelectorType)
return nil
}
@ -698,6 +699,7 @@ func autoConvert_apiserver_Issuer_To_v1_Issuer(in *apiserver.Issuer, out *Issuer
out.CertificateAuthority = in.CertificateAuthority
out.Audiences = *(*[]string)(unsafe.Pointer(&in.Audiences))
out.AudienceMatchPolicy = AudienceMatchPolicyType(in.AudienceMatchPolicy)
out.EgressSelectorType = EgressSelectorType(in.EgressSelectorType)
return nil
}

View File

@ -295,6 +295,18 @@ type Issuer struct {
// example: claimValidationRule[].expression: 'sets.equivalent(claims.aud, ["bar", "foo", "baz"])' to require an exact match.
// +optional
AudienceMatchPolicy AudienceMatchPolicyType `json:"audienceMatchPolicy,omitempty"`
// egressSelectorType is an indicator of which egress selection should be used for sending all traffic related
// to this issuer (discovery, JWKS, distributed claims, etc). If unspecified, no custom dialer is used.
// When specified, the valid choices are "controlplane" and "cluster". These correspond to the associated
// values in the --egress-selector-config-file.
//
// - controlplane: for traffic intended to go to the control plane.
//
// - cluster: for traffic intended to go to the system being managed by Kubernetes.
//
// +optional
EgressSelectorType EgressSelectorType `json:"egressSelectorType,omitempty"`
}
// AudienceMatchPolicyType is a set of valid values for issuer.audienceMatchPolicy
@ -306,6 +318,17 @@ const (
AudienceMatchPolicyMatchAny AudienceMatchPolicyType = "MatchAny"
)
// EgressSelectorType is an indicator of which egress selection should be used for sending traffic.
type EgressSelectorType string
const (
// EgressSelectorControlPlane is the EgressSelectorType for traffic intended to go to the control plane.
EgressSelectorControlPlane EgressSelectorType = "controlplane"
// EgressSelectorCluster is the EgressSelectorType for traffic intended to go to the system being managed by Kubernetes.
EgressSelectorCluster EgressSelectorType = "cluster"
)
// ClaimValidationRule provides the configuration for a single claim validation rule.
type ClaimValidationRule struct {
// claim is the name of a required claim.

View File

@ -707,6 +707,7 @@ func autoConvert_v1alpha1_Issuer_To_apiserver_Issuer(in *Issuer, out *apiserver.
out.CertificateAuthority = in.CertificateAuthority
out.Audiences = *(*[]string)(unsafe.Pointer(&in.Audiences))
out.AudienceMatchPolicy = apiserver.AudienceMatchPolicyType(in.AudienceMatchPolicy)
out.EgressSelectorType = apiserver.EgressSelectorType(in.EgressSelectorType)
return nil
}
@ -723,6 +724,7 @@ func autoConvert_apiserver_Issuer_To_v1alpha1_Issuer(in *apiserver.Issuer, out *
out.CertificateAuthority = in.CertificateAuthority
out.Audiences = *(*[]string)(unsafe.Pointer(&in.Audiences))
out.AudienceMatchPolicy = AudienceMatchPolicyType(in.AudienceMatchPolicy)
out.EgressSelectorType = EgressSelectorType(in.EgressSelectorType)
return nil
}

View File

@ -266,6 +266,18 @@ type Issuer struct {
// example: claimValidationRule[].expression: 'sets.equivalent(claims.aud, ["bar", "foo", "baz"])' to require an exact match.
// +optional
AudienceMatchPolicy AudienceMatchPolicyType `json:"audienceMatchPolicy,omitempty"`
// egressSelectorType is an indicator of which egress selection should be used for sending all traffic related
// to this issuer (discovery, JWKS, distributed claims, etc). If unspecified, no custom dialer is used.
// When specified, the valid choices are "controlplane" and "cluster". These correspond to the associated
// values in the --egress-selector-config-file.
//
// - controlplane: for traffic intended to go to the control plane.
//
// - cluster: for traffic intended to go to the system being managed by Kubernetes.
//
// +optional
EgressSelectorType EgressSelectorType `json:"egressSelectorType,omitempty"`
}
// AudienceMatchPolicyType is a set of valid values for issuer.audienceMatchPolicy
@ -277,6 +289,17 @@ const (
AudienceMatchPolicyMatchAny AudienceMatchPolicyType = "MatchAny"
)
// EgressSelectorType is an indicator of which egress selection should be used for sending traffic.
type EgressSelectorType string
const (
// EgressSelectorControlPlane is the EgressSelectorType for traffic intended to go to the control plane.
EgressSelectorControlPlane EgressSelectorType = "controlplane"
// EgressSelectorCluster is the EgressSelectorType for traffic intended to go to the system being managed by Kubernetes.
EgressSelectorCluster EgressSelectorType = "cluster"
)
// ClaimValidationRule provides the configuration for a single claim validation rule.
type ClaimValidationRule struct {
// claim is the name of a required claim.

View File

@ -643,6 +643,7 @@ func autoConvert_v1beta1_Issuer_To_apiserver_Issuer(in *Issuer, out *apiserver.I
out.CertificateAuthority = in.CertificateAuthority
out.Audiences = *(*[]string)(unsafe.Pointer(&in.Audiences))
out.AudienceMatchPolicy = apiserver.AudienceMatchPolicyType(in.AudienceMatchPolicy)
out.EgressSelectorType = apiserver.EgressSelectorType(in.EgressSelectorType)
return nil
}
@ -659,6 +660,7 @@ func autoConvert_apiserver_Issuer_To_v1beta1_Issuer(in *apiserver.Issuer, out *I
out.CertificateAuthority = in.CertificateAuthority
out.Audiences = *(*[]string)(unsafe.Pointer(&in.Audiences))
out.AudienceMatchPolicy = AudienceMatchPolicyType(in.AudienceMatchPolicy)
out.EgressSelectorType = EgressSelectorType(in.EgressSelectorType)
return nil
}

View File

@ -61,7 +61,7 @@ func ValidateAuthenticationConfiguration(compiler authenticationcel.Compiler, c
seenDiscoveryURLs := sets.New[string]()
for i, a := range c.JWT {
fldPath := root.Index(i)
_, errs := validateJWTAuthenticator(compiler, a, fldPath, sets.New(disallowedIssuers...), utilfeature.DefaultFeatureGate.Enabled(features.StructuredAuthenticationConfiguration))
_, errs := validateJWTAuthenticator(compiler, a, fldPath, sets.New(disallowedIssuers...), utilfeature.DefaultFeatureGate.Enabled(features.StructuredAuthenticationConfiguration), utilfeature.DefaultFeatureGate.Enabled(features.StructuredAuthenticationConfigurationEgressSelector))
allErrs = append(allErrs, errs...)
if seenIssuers.Has(a.Issuer.URL) {
@ -93,15 +93,15 @@ func ValidateAuthenticationConfiguration(compiler authenticationcel.Compiler, c
// CEL expressions for claim mappings and validation rules.
// This is exported for use in oidc package.
func CompileAndValidateJWTAuthenticator(compiler authenticationcel.Compiler, authenticator api.JWTAuthenticator, disallowedIssuers []string) (authenticationcel.CELMapper, field.ErrorList) {
return validateJWTAuthenticator(compiler, authenticator, nil, sets.New(disallowedIssuers...), utilfeature.DefaultFeatureGate.Enabled(features.StructuredAuthenticationConfiguration))
return validateJWTAuthenticator(compiler, authenticator, nil, sets.New(disallowedIssuers...), utilfeature.DefaultFeatureGate.Enabled(features.StructuredAuthenticationConfiguration), utilfeature.DefaultFeatureGate.Enabled(features.StructuredAuthenticationConfigurationEgressSelector))
}
func validateJWTAuthenticator(compiler authenticationcel.Compiler, authenticator api.JWTAuthenticator, fldPath *field.Path, disallowedIssuers sets.Set[string], structuredAuthnFeatureEnabled bool) (authenticationcel.CELMapper, field.ErrorList) {
func validateJWTAuthenticator(compiler authenticationcel.Compiler, authenticator api.JWTAuthenticator, fldPath *field.Path, disallowedIssuers sets.Set[string], structuredAuthnFeatureEnabled, structuredAuthnEgressSelectorFeatureEnabled bool) (authenticationcel.CELMapper, field.ErrorList) {
var allErrs field.ErrorList
state := &validationState{}
allErrs = append(allErrs, validateIssuer(authenticator.Issuer, disallowedIssuers, fldPath.Child("issuer"), structuredAuthnFeatureEnabled)...)
allErrs = append(allErrs, validateIssuer(authenticator.Issuer, disallowedIssuers, fldPath.Child("issuer"), structuredAuthnFeatureEnabled, structuredAuthnEgressSelectorFeatureEnabled)...)
allErrs = append(allErrs, validateClaimValidationRules(compiler, state, authenticator.ClaimValidationRules, fldPath.Child("claimValidationRules"), structuredAuthnFeatureEnabled)...)
allErrs = append(allErrs, validateClaimMappings(compiler, state, authenticator.ClaimMappings, fldPath.Child("claimMappings"), structuredAuthnFeatureEnabled)...)
allErrs = append(allErrs, validateUserValidationRules(compiler, state, authenticator.UserValidationRules, fldPath.Child("userValidationRules"), structuredAuthnFeatureEnabled)...)
@ -115,13 +115,14 @@ type validationState struct {
usesEmailVerifiedClaim bool
}
func validateIssuer(issuer api.Issuer, disallowedIssuers sets.Set[string], fldPath *field.Path, structuredAuthnFeatureEnabled bool) field.ErrorList {
func validateIssuer(issuer api.Issuer, disallowedIssuers sets.Set[string], fldPath *field.Path, structuredAuthnFeatureEnabled, structuredAuthnEgressSelectorFeatureEnabled bool) field.ErrorList {
var allErrs field.ErrorList
allErrs = append(allErrs, validateIssuerURL(issuer.URL, disallowedIssuers, fldPath.Child("url"))...)
allErrs = append(allErrs, validateIssuerDiscoveryURL(issuer.URL, issuer.DiscoveryURL, fldPath.Child("discoveryURL"), structuredAuthnFeatureEnabled)...)
allErrs = append(allErrs, validateAudiences(issuer.Audiences, issuer.AudienceMatchPolicy, fldPath.Child("audiences"), fldPath.Child("audienceMatchPolicy"), structuredAuthnFeatureEnabled)...)
allErrs = append(allErrs, validateCertificateAuthority(issuer.CertificateAuthority, fldPath.Child("certificateAuthority"))...)
allErrs = append(allErrs, validateEgressSelector(issuer.EgressSelectorType, fldPath.Child("egressSelectorType"), structuredAuthnFeatureEnabled, structuredAuthnEgressSelectorFeatureEnabled)...)
return allErrs
}
@ -230,6 +231,31 @@ func validateCertificateAuthority(certificateAuthority string, fldPath *field.Pa
return allErrs
}
func validateEgressSelector(selectorType api.EgressSelectorType, fldPath *field.Path, structuredAuthnFeatureEnabled, structuredAuthnEgressSelectorFeatureEnabled bool) field.ErrorList {
var allErrs field.ErrorList
if len(selectorType) == 0 {
return allErrs
}
if !structuredAuthnFeatureEnabled {
allErrs = append(allErrs, field.Invalid(fldPath, selectorType, "egress selector is not supported when StructuredAuthenticationConfiguration feature gate is disabled"))
}
if !structuredAuthnEgressSelectorFeatureEnabled {
allErrs = append(allErrs, field.Invalid(fldPath, selectorType, "egress selector is not supported when StructuredAuthenticationConfigurationEgressSelector feature gate is disabled"))
}
switch selectorType {
case api.EgressSelectorControlPlane, api.EgressSelectorCluster:
// valid
default:
allErrs = append(allErrs, field.Invalid(fldPath, selectorType, "egress selector must be either controlplane or cluster"))
}
return allErrs
}
func validateClaimValidationRules(compiler authenticationcel.Compiler, state *validationState, rules []api.ClaimValidationRule, fldPath *field.Path, structuredAuthnFeatureEnabled bool) field.ErrorList {
var allErrs field.ErrorList

View File

@ -32,19 +32,26 @@ import (
"k8s.io/apimachinery/pkg/util/errors"
"k8s.io/apimachinery/pkg/util/sets"
"k8s.io/apimachinery/pkg/util/validation/field"
"k8s.io/apimachinery/pkg/util/version"
api "k8s.io/apiserver/pkg/apis/apiserver"
authenticationcel "k8s.io/apiserver/pkg/authentication/cel"
authorizationcel "k8s.io/apiserver/pkg/authorization/cel"
"k8s.io/apiserver/pkg/features"
utilfeature "k8s.io/apiserver/pkg/util/feature"
certutil "k8s.io/client-go/util/cert"
featuregatetesting "k8s.io/component-base/featuregate/testing"
"k8s.io/utils/ptr"
)
func TestValidateAuthenticationConfiguration(t *testing.T) {
testCases := []struct {
name string
in *api.AuthenticationConfiguration
disallowedIssuers []string
want string
name string
in *api.AuthenticationConfiguration
disallowedIssuers []string
structuredAuthnFeatureOverride *bool
structuredAuthnEgressSelectorFeatureOverride *bool
gaOnly bool
want string
}{
{
name: "jwt authenticator is empty",
@ -273,6 +280,154 @@ func TestValidateAuthenticationConfiguration(t *testing.T) {
disallowedIssuers: []string{"a", "b", "https://issuer-url", "c"},
want: `jwt[0].issuer.url: Invalid value: "https://issuer-url": URL must not overlap with disallowed issuers: [a b c https://issuer-url]`,
},
{
name: "valid authentication configuration with invalid egress type",
in: &api.AuthenticationConfiguration{
JWT: []api.JWTAuthenticator{
{
Issuer: api.Issuer{
URL: "https://issuer-url",
Audiences: []string{"audience"},
EgressSelectorType: "panda",
},
ClaimValidationRules: []api.ClaimValidationRule{
{
Claim: "foo",
RequiredValue: "bar",
},
},
ClaimMappings: api.ClaimMappings{
Username: api.PrefixedClaimOrExpression{
Claim: "sub",
Prefix: ptr.To("prefix"),
},
},
},
},
},
disallowedIssuers: []string{"a", "b", "c"},
want: `jwt[0].issuer.egressSelectorType: Invalid value: "panda": egress selector must be either controlplane or cluster`,
},
{
name: "valid authentication configuration with valid egress type with StructuredAuthenticationConfiguration feature disabled",
in: &api.AuthenticationConfiguration{
JWT: []api.JWTAuthenticator{
{
Issuer: api.Issuer{
URL: "https://issuer-url",
Audiences: []string{"audience"},
EgressSelectorType: "controlplane",
},
ClaimValidationRules: []api.ClaimValidationRule{
{
Claim: "foo",
RequiredValue: "bar",
},
},
ClaimMappings: api.ClaimMappings{
Username: api.PrefixedClaimOrExpression{
Claim: "sub",
Prefix: ptr.To("prefix"),
},
},
},
},
},
disallowedIssuers: []string{"a", "b", "c"},
structuredAuthnFeatureOverride: ptr.To(false),
want: "[" +
`jwt[0].issuer.egressSelectorType: Invalid value: "controlplane": egress selector is not supported when StructuredAuthenticationConfiguration feature gate is disabled` +
", " +
// this feature did not exist in v1.33 so it is automatically disabled as well
`jwt[0].issuer.egressSelectorType: Invalid value: "controlplane": egress selector is not supported when StructuredAuthenticationConfigurationEgressSelector feature gate is disabled` +
"]",
},
{
name: "valid authentication configuration with valid egress type with StructuredAuthenticationConfigurationEgressSelector feature disabled",
in: &api.AuthenticationConfiguration{
JWT: []api.JWTAuthenticator{
{
Issuer: api.Issuer{
URL: "https://issuer-url",
Audiences: []string{"audience"},
EgressSelectorType: "controlplane",
},
ClaimValidationRules: []api.ClaimValidationRule{
{
Claim: "foo",
RequiredValue: "bar",
},
},
ClaimMappings: api.ClaimMappings{
Username: api.PrefixedClaimOrExpression{
Claim: "sub",
Prefix: ptr.To("prefix"),
},
},
},
},
},
disallowedIssuers: []string{"a", "b", "c"},
structuredAuthnEgressSelectorFeatureOverride: ptr.To(false),
want: `jwt[0].issuer.egressSelectorType: Invalid value: "controlplane": egress selector is not supported when StructuredAuthenticationConfigurationEgressSelector feature gate is disabled`,
},
{
name: "valid authentication configuration with valid egress type with GA features only",
in: &api.AuthenticationConfiguration{
JWT: []api.JWTAuthenticator{
{
Issuer: api.Issuer{
URL: "https://issuer-url",
Audiences: []string{"audience"},
EgressSelectorType: "controlplane",
},
ClaimValidationRules: []api.ClaimValidationRule{
{
Claim: "foo",
RequiredValue: "bar",
},
},
ClaimMappings: api.ClaimMappings{
Username: api.PrefixedClaimOrExpression{
Claim: "sub",
Prefix: ptr.To("prefix"),
},
},
},
},
},
disallowedIssuers: []string{"a", "b", "c"},
gaOnly: true,
want: `jwt[0].issuer.egressSelectorType: Invalid value: "controlplane": egress selector is not supported when StructuredAuthenticationConfigurationEgressSelector feature gate is disabled`,
},
{
name: "valid authentication configuration with valid egress type",
in: &api.AuthenticationConfiguration{
JWT: []api.JWTAuthenticator{
{
Issuer: api.Issuer{
URL: "https://issuer-url",
Audiences: []string{"audience"},
EgressSelectorType: "cluster",
},
ClaimValidationRules: []api.ClaimValidationRule{
{
Claim: "foo",
RequiredValue: "bar",
},
},
ClaimMappings: api.ClaimMappings{
Username: api.PrefixedClaimOrExpression{
Claim: "sub",
Prefix: ptr.To("prefix"),
},
},
},
},
},
disallowedIssuers: []string{"a", "b", "c"},
want: "",
},
{
name: "valid authentication configuration that uses unverified email",
in: &api.AuthenticationConfiguration{
@ -665,6 +820,17 @@ func TestValidateAuthenticationConfiguration(t *testing.T) {
for _, tt := range testCases {
t.Run(tt.name, func(t *testing.T) {
if tt.structuredAuthnFeatureOverride != nil {
featuregatetesting.SetFeatureGateEmulationVersionDuringTest(t, utilfeature.DefaultFeatureGate, version.MustParse("1.33")) // go back to when the feature could be disabled
featuregatetesting.SetFeatureGateDuringTest(t, utilfeature.DefaultFeatureGate, features.StructuredAuthenticationConfiguration, *tt.structuredAuthnFeatureOverride)
}
if tt.structuredAuthnEgressSelectorFeatureOverride != nil {
featuregatetesting.SetFeatureGateDuringTest(t, utilfeature.DefaultFeatureGate, features.StructuredAuthenticationConfigurationEgressSelector, *tt.structuredAuthnEgressSelectorFeatureOverride)
}
if tt.gaOnly {
featuregatetesting.SetFeatureGateDuringTest(t, utilfeature.DefaultFeatureGate, "AllAlpha", false)
featuregatetesting.SetFeatureGateDuringTest(t, utilfeature.DefaultFeatureGate, "AllBeta", false)
}
got := ValidateAuthenticationConfiguration(authenticationcel.NewDefaultCompiler(), tt.in, tt.disallowedIssuers).ToAggregate()
if d := cmp.Diff(tt.want, errString(got)); d != "" {
t.Fatalf("AuthenticationConfiguration validation mismatch (-want +got):\n%s", d)

View File

@ -216,6 +216,12 @@ const (
// Enables Structured Authentication Configuration
StructuredAuthenticationConfiguration featuregate.Feature = "StructuredAuthenticationConfiguration"
// owner: @aramase, @enj, @nabokihms
// kep: https://kep.k8s.io/3331
//
// Enables Egress Selector in Structured Authentication Configuration
StructuredAuthenticationConfigurationEgressSelector featuregate.Feature = "StructuredAuthenticationConfigurationEgressSelector"
// owner: @palnabarun
// kep: https://kep.k8s.io/3221
//
@ -427,6 +433,10 @@ var defaultVersionedKubernetesFeatureGates = map[featuregate.Feature]featuregate
{Version: version.MustParse("1.34"), Default: true, PreRelease: featuregate.GA, LockToDefault: true}, // GA and LockToDefault in 1.34, remove in 1.37
},
StructuredAuthenticationConfigurationEgressSelector: {
{Version: version.MustParse("1.34"), Default: true, PreRelease: featuregate.Beta},
},
StructuredAuthorizationConfiguration: {
{Version: version.MustParse("1.29"), Default: false, PreRelease: featuregate.Alpha},
{Version: version.MustParse("1.30"), Default: true, PreRelease: featuregate.Beta},

View File

@ -58,6 +58,7 @@ import (
"k8s.io/apiserver/pkg/authentication/user"
"k8s.io/apiserver/pkg/cel"
"k8s.io/apiserver/pkg/cel/lazy"
"k8s.io/apiserver/pkg/server/egressselector"
certutil "k8s.io/client-go/util/cert"
"k8s.io/klog/v2"
)
@ -83,7 +84,10 @@ type Options struct {
// PEM encoded root certificate contents of the provider. Mutually exclusive with Client.
CAContentProvider CAContentProvider
// Optional http.Client used to make all requests to the remote issuer. Mutually exclusive with CAContentProvider.
// EgressLookup allows for optional opt-in egress configuration via a custom dialer. Mutually exclusive with Client.
EgressLookup egressselector.Lookup
// Optional http.Client used to make all requests to the remote issuer. Mutually exclusive with CAContentProvider and EgressLookup.
Client *http.Client
// Optional CEL compiler used to compile the CEL expressions. This is useful to use a shared instance
@ -276,6 +280,10 @@ func New(lifecycleCtx context.Context, opts Options) (AuthenticatorTokenWithHeal
return nil, fmt.Errorf("oidc: Client and CAContentProvider are mutually exclusive")
}
if opts.Client != nil && opts.EgressLookup != nil {
return nil, fmt.Errorf("oidc: Client and EgressLookup are mutually exclusive")
}
client := opts.Client
if client == nil {
@ -291,8 +299,25 @@ func New(lifecycleCtx context.Context, opts Options) (AuthenticatorTokenWithHeal
klog.Info("OIDC: No x509 certificates provided, will use host's root CA set")
}
var customDial net.DialFunc
switch et := opts.JWTAuthenticator.Issuer.EgressSelectorType; et {
case "":
// valid but nothing to do
case apiserver.EgressSelectorControlPlane:
customDial, err = egressLookupForType(opts.EgressLookup, egressselector.ControlPlane)
case apiserver.EgressSelectorCluster:
customDial, err = egressLookupForType(opts.EgressLookup, egressselector.Cluster)
default:
// this should be impossible as validation should catch this at an earlier point
return nil, fmt.Errorf("oidc: unknown egress selector type %q", et)
}
if err != nil {
return nil, err
}
// Copied from http.DefaultTransport.
tr := net.SetTransportDefaults(&http.Transport{
DialContext: customDial,
// According to golang's doc, if RootCAs is nil,
// TLS uses the host's root CA set.
TLSClientConfig: &tls.Config{RootCAs: roots},
@ -406,6 +431,22 @@ func New(lifecycleCtx context.Context, opts Options) (AuthenticatorTokenWithHeal
return newInstrumentedAuthenticator(issuerURL, authn), nil
}
func egressLookupForType(egressLookup egressselector.Lookup, egressSelector egressselector.EgressType) (net.DialFunc, error) {
if egressLookup == nil {
return nil, fmt.Errorf("oidc: egress lookup required with egress selector type %q", egressSelector)
}
customDial, err := egressLookup(egressSelector.AsNetworkContext())
if err != nil {
return nil, fmt.Errorf("oidc: egress lookup for %q failed: %w", egressSelector, err)
}
// we are stricter than other egress lookups because this is opt-in config
// we expect the user who is configuring the JWT authenticator to keep it in sync with the egress configuration
if customDial == nil {
return nil, fmt.Errorf("oidc: egress lookup for %q is not configured", egressSelector)
}
return customDial, nil
}
type errorHolder struct {
err error
}

View File

@ -25,21 +25,25 @@ import (
"encoding/json"
"encoding/pem"
"fmt"
"net"
"net/http"
"net/http/httptest"
"os"
"reflect"
"strings"
"sync/atomic"
"testing"
"text/template"
"time"
"gopkg.in/go-jose/go-jose.v2"
utilnet "k8s.io/apimachinery/pkg/util/net"
"k8s.io/apimachinery/pkg/util/wait"
"k8s.io/apiserver/pkg/apis/apiserver"
"k8s.io/apiserver/pkg/authentication/user"
"k8s.io/apiserver/pkg/server/dynamiccertificates"
"k8s.io/apiserver/pkg/server/egressselector"
"k8s.io/component-base/metrics/testutil"
"k8s.io/klog/v2"
"k8s.io/utils/ptr"
@ -3454,6 +3458,378 @@ func TestToken(t *testing.T) {
}`, valid.Unix()),
wantInitErr: `issuer.url: Invalid value: "https://auth.example.com": URL must not overlap with disallowed issuers: [https://auth.example.com]`,
},
{
name: "invalid egress type",
options: Options{
JWTAuthenticator: apiserver.JWTAuthenticator{
Issuer: apiserver.Issuer{
URL: "https://auth.example.com",
Audiences: []string{"my-client"},
EgressSelectorType: "etcd",
},
ClaimMappings: apiserver.ClaimMappings{
Username: apiserver.PrefixedClaimOrExpression{
Expression: "claims.username",
},
Groups: apiserver.PrefixedClaimOrExpression{
Expression: "claims.groups",
},
UID: apiserver.ClaimOrExpression{
Expression: "claims.uid",
},
Extra: []apiserver.ExtraMapping{
{
Key: "example.org/foo",
ValueExpression: "claims.foo",
},
{
Key: "example.org/bar",
ValueExpression: "claims.bar",
},
},
},
},
EgressLookup: func(networkContext egressselector.NetworkContext) (utilnet.DialFunc, error) {
return nil, fmt.Errorf("should not be called")
},
now: func() time.Time { return now },
},
signingKey: loadRSAPrivKey(t, "testdata/rsa_1.pem", jose.RS256),
pubKeys: []*jose.JSONWebKey{
loadRSAKey(t, "testdata/rsa_1.pem", jose.RS256),
},
claims: fmt.Sprintf(`{
"iss": "https://auth.example.com",
"aud": "my-client",
"username": "jane",
"groups": ["team1", "team2"],
"exp": %d,
"uid": "1234",
"foo": "bar",
"bar": [
"baz",
"qux"
]
}`, valid.Unix()),
wantInitErr: `issuer.egressSelectorType: Invalid value: "etcd": egress selector must be either controlplane or cluster`,
},
{
name: "valid egress type with error",
options: Options{
JWTAuthenticator: apiserver.JWTAuthenticator{
Issuer: apiserver.Issuer{
URL: "https://auth.example.com",
Audiences: []string{"my-client"},
EgressSelectorType: "cluster",
},
ClaimMappings: apiserver.ClaimMappings{
Username: apiserver.PrefixedClaimOrExpression{
Expression: "claims.username",
},
Groups: apiserver.PrefixedClaimOrExpression{
Expression: "claims.groups",
},
UID: apiserver.ClaimOrExpression{
Expression: "claims.uid",
},
Extra: []apiserver.ExtraMapping{
{
Key: "example.org/foo",
ValueExpression: "claims.foo",
},
{
Key: "example.org/bar",
ValueExpression: "claims.bar",
},
},
},
},
EgressLookup: func(networkContext egressselector.NetworkContext) (utilnet.DialFunc, error) {
if networkContext.EgressSelectionName != egressselector.Cluster {
return nil, fmt.Errorf("unexpected egress type %q", networkContext.EgressSelectionName)
}
return nil, fmt.Errorf("always fail, saw type %q", networkContext.EgressSelectionName)
},
now: func() time.Time { return now },
},
signingKey: loadRSAPrivKey(t, "testdata/rsa_1.pem", jose.RS256),
pubKeys: []*jose.JSONWebKey{
loadRSAKey(t, "testdata/rsa_1.pem", jose.RS256),
},
claims: fmt.Sprintf(`{
"iss": "https://auth.example.com",
"aud": "my-client",
"username": "jane",
"groups": ["team1", "team2"],
"exp": %d,
"uid": "1234",
"foo": "bar",
"bar": [
"baz",
"qux"
]
}`, valid.Unix()),
wantInitErr: `oidc: egress lookup for "cluster" failed: always fail, saw type "cluster"`,
},
{
name: "valid egress type with error - again",
options: Options{
JWTAuthenticator: apiserver.JWTAuthenticator{
Issuer: apiserver.Issuer{
URL: "https://auth.example.com",
Audiences: []string{"my-client"},
EgressSelectorType: "controlplane",
},
ClaimMappings: apiserver.ClaimMappings{
Username: apiserver.PrefixedClaimOrExpression{
Expression: "claims.username",
},
Groups: apiserver.PrefixedClaimOrExpression{
Expression: "claims.groups",
},
UID: apiserver.ClaimOrExpression{
Expression: "claims.uid",
},
Extra: []apiserver.ExtraMapping{
{
Key: "example.org/foo",
ValueExpression: "claims.foo",
},
{
Key: "example.org/bar",
ValueExpression: "claims.bar",
},
},
},
},
EgressLookup: func(networkContext egressselector.NetworkContext) (utilnet.DialFunc, error) {
if networkContext.EgressSelectionName != egressselector.ControlPlane {
return nil, fmt.Errorf("unexpected egress type %q", networkContext.EgressSelectionName)
}
return nil, fmt.Errorf("always fail, saw type %q", networkContext.EgressSelectionName)
},
now: func() time.Time { return now },
},
signingKey: loadRSAPrivKey(t, "testdata/rsa_1.pem", jose.RS256),
pubKeys: []*jose.JSONWebKey{
loadRSAKey(t, "testdata/rsa_1.pem", jose.RS256),
},
claims: fmt.Sprintf(`{
"iss": "https://auth.example.com",
"aud": "my-client",
"username": "jane",
"groups": ["team1", "team2"],
"exp": %d,
"uid": "1234",
"foo": "bar",
"bar": [
"baz",
"qux"
]
}`, valid.Unix()),
wantInitErr: `oidc: egress lookup for "controlplane" failed: always fail, saw type "controlplane"`,
},
{
name: "valid egress type with no egress config",
options: Options{
JWTAuthenticator: apiserver.JWTAuthenticator{
Issuer: apiserver.Issuer{
URL: "https://auth.example.com",
Audiences: []string{"my-client"},
EgressSelectorType: "controlplane",
},
ClaimMappings: apiserver.ClaimMappings{
Username: apiserver.PrefixedClaimOrExpression{
Expression: "claims.username",
},
Groups: apiserver.PrefixedClaimOrExpression{
Expression: "claims.groups",
},
UID: apiserver.ClaimOrExpression{
Expression: "claims.uid",
},
Extra: []apiserver.ExtraMapping{
{
Key: "example.org/foo",
ValueExpression: "claims.foo",
},
{
Key: "example.org/bar",
ValueExpression: "claims.bar",
},
},
},
},
EgressLookup: nil,
now: func() time.Time { return now },
},
signingKey: loadRSAPrivKey(t, "testdata/rsa_1.pem", jose.RS256),
pubKeys: []*jose.JSONWebKey{
loadRSAKey(t, "testdata/rsa_1.pem", jose.RS256),
},
claims: fmt.Sprintf(`{
"iss": "https://auth.example.com",
"aud": "my-client",
"username": "jane",
"groups": ["team1", "team2"],
"exp": %d,
"uid": "1234",
"foo": "bar",
"bar": [
"baz",
"qux"
]
}`, valid.Unix()),
wantInitErr: `oidc: egress lookup required with egress selector type "controlplane"`,
},
{
name: "valid egress type with no egress config for the given type",
options: Options{
JWTAuthenticator: apiserver.JWTAuthenticator{
Issuer: apiserver.Issuer{
URL: "https://auth.example.com",
Audiences: []string{"my-client"},
EgressSelectorType: "controlplane",
},
ClaimMappings: apiserver.ClaimMappings{
Username: apiserver.PrefixedClaimOrExpression{
Expression: "claims.username",
},
Groups: apiserver.PrefixedClaimOrExpression{
Expression: "claims.groups",
},
UID: apiserver.ClaimOrExpression{
Expression: "claims.uid",
},
Extra: []apiserver.ExtraMapping{
{
Key: "example.org/foo",
ValueExpression: "claims.foo",
},
{
Key: "example.org/bar",
ValueExpression: "claims.bar",
},
},
},
},
EgressLookup: func(networkContext egressselector.NetworkContext) (utilnet.DialFunc, error) {
return nil, nil
},
now: func() time.Time { return now },
},
signingKey: loadRSAPrivKey(t, "testdata/rsa_1.pem", jose.RS256),
pubKeys: []*jose.JSONWebKey{
loadRSAKey(t, "testdata/rsa_1.pem", jose.RS256),
},
claims: fmt.Sprintf(`{
"iss": "https://auth.example.com",
"aud": "my-client",
"username": "jane",
"groups": ["team1", "team2"],
"exp": %d,
"uid": "1234",
"foo": "bar",
"bar": [
"baz",
"qux"
]
}`, valid.Unix()),
wantInitErr: `oidc: egress lookup for "controlplane" is not configured`,
},
{
name: "valid egress type with broken dialer",
options: Options{
JWTAuthenticator: apiserver.JWTAuthenticator{
Issuer: apiserver.Issuer{
URL: "https://auth.example.com",
Audiences: []string{"my-client"},
EgressSelectorType: "controlplane",
},
ClaimMappings: apiserver.ClaimMappings{
Username: apiserver.PrefixedClaimOrExpression{
Expression: "claims.username",
},
Groups: apiserver.PrefixedClaimOrExpression{
Expression: "claims.groups",
},
UID: apiserver.ClaimOrExpression{
Expression: "claims.uid",
},
Extra: []apiserver.ExtraMapping{
{
Key: "example.org/foo",
ValueExpression: "claims.foo",
},
{
Key: "example.org/bar",
ValueExpression: "claims.bar",
},
},
},
},
EgressLookup: func(networkContext egressselector.NetworkContext) (utilnet.DialFunc, error) {
return func(ctx context.Context, net, addr string) (net.Conn, error) {
return nil, fmt.Errorf("broken dialer")
}, nil
},
now: func() time.Time { return now },
},
fetchKeysFromRemote: true,
wantHealthErrPrefix: `oidc: authenticator for issuer "https://auth.example.com" is not healthy: Get "https://auth.example.com/.well-known/openid-configuration": broken dialer`,
},
{
name: "valid egress type with working dialer",
options: Options{
JWTAuthenticator: apiserver.JWTAuthenticator{
Issuer: apiserver.Issuer{
URL: "https://auth.example.com",
DiscoveryURL: "{{.URL}}/.well-known/openid-configuration",
Audiences: []string{"my-client"},
EgressSelectorType: "cluster",
},
ClaimMappings: apiserver.ClaimMappings{
Username: apiserver.PrefixedClaimOrExpression{
Claim: "username",
Prefix: ptr.To(""),
},
},
},
EgressLookup: func() egressselector.Lookup {
var called atomic.Bool
t.Cleanup(func() {
if !called.Load() {
t.Errorf("egress lookup was not called")
}
})
return func(networkContext egressselector.NetworkContext) (utilnet.DialFunc, error) {
return func(ctx context.Context, network, address string) (net.Conn, error) {
called.Store(true)
return (&net.Dialer{}).DialContext(ctx, network, address)
}, nil
}
}(),
now: func() time.Time { return now },
},
signingKey: loadRSAPrivKey(t, "testdata/rsa_1.pem", jose.RS256),
pubKeys: []*jose.JSONWebKey{
loadRSAKey(t, "testdata/rsa_1.pem", jose.RS256),
},
claims: fmt.Sprintf(`{
"iss": "https://auth.example.com",
"aud": "my-client",
"username": "jane",
"exp": %d
}`, valid.Unix()),
openIDConfig: `{
"issuer": "https://auth.example.com",
"jwks_uri": "{{.URL}}/.testing/keys"
}`,
fetchKeysFromRemote: true,
want: &user.DefaultInfo{
Name: "jane",
},
},
{
name: "extra claim mapping, empty string value for key",
options: Options{