diff --git a/staging/src/k8s.io/apiserver/pkg/apis/apiserver/types.go b/staging/src/k8s.io/apiserver/pkg/apis/apiserver/types.go
index 1b2b4fd5e49..f3b4ae321ef 100644
--- a/staging/src/k8s.io/apiserver/pkg/apis/apiserver/types.go
+++ b/staging/src/k8s.io/apiserver/pkg/apis/apiserver/types.go
@@ -172,6 +172,7 @@ type JWTAuthenticator struct {
Issuer Issuer
ClaimValidationRules []ClaimValidationRule
ClaimMappings ClaimMappings
+ UserValidationRules []UserValidationRule
}
// Issuer provides the configuration for a external provider specific settings.
@@ -185,18 +186,43 @@ type Issuer struct {
type ClaimValidationRule struct {
Claim string
RequiredValue string
+
+ Expression string
+ Message string
}
// ClaimMappings provides the configuration for claim mapping
type ClaimMappings struct {
Username PrefixedClaimOrExpression
Groups PrefixedClaimOrExpression
+ UID ClaimOrExpression
+ Extra []ExtraMapping
}
// PrefixedClaimOrExpression provides the configuration for a single prefixed claim or expression.
type PrefixedClaimOrExpression struct {
Claim string
Prefix *string
+
+ Expression string
+}
+
+// ClaimOrExpression provides the configuration for a single claim or expression.
+type ClaimOrExpression struct {
+ Claim string
+ Expression string
+}
+
+// ExtraMapping provides the configuration for a single extra mapping.
+type ExtraMapping struct {
+ Key string
+ ValueExpression string
+}
+
+// UserValidationRule provides the configuration for a single user validation rule.
+type UserValidationRule struct {
+ Expression string
+ Message string
}
// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object
diff --git a/staging/src/k8s.io/apiserver/pkg/apis/apiserver/v1alpha1/types.go b/staging/src/k8s.io/apiserver/pkg/apis/apiserver/v1alpha1/types.go
index 22ac6eee340..9394ba6f70a 100644
--- a/staging/src/k8s.io/apiserver/pkg/apis/apiserver/v1alpha1/types.go
+++ b/staging/src/k8s.io/apiserver/pkg/apis/apiserver/v1alpha1/types.go
@@ -192,6 +192,13 @@ type JWTAuthenticator struct {
// claimMappings points claims of a token to be treated as user attributes.
// +required
ClaimMappings ClaimMappings `json:"claimMappings"`
+
+ // userValidationRules are rules that are applied to final user before completing authentication.
+ // These allow invariants to be applied to incoming identities such as preventing the
+ // use of the system: prefix that is commonly used by Kubernetes components.
+ // The validation rules are logically ANDed together and must all return true for the validation to pass.
+ // +optional
+ UserValidationRules []UserValidationRule `json:"userValidationRules,omitempty"`
}
// Issuer provides the configuration for a external provider specific settings.
@@ -225,14 +232,36 @@ type ClaimValidationRule struct {
// claim is the name of a required claim.
// Same as --oidc-required-claim flag.
// Only string claim keys are supported.
- // +required
- Claim string `json:"claim"`
+ // Mutually exclusive with expression and message.
+ // +optional
+ Claim string `json:"claim,omitempty"`
// requiredValue is the value of a required claim.
// Same as --oidc-required-claim flag.
// Only string claim values are supported.
// If claim is set and requiredValue is not set, the claim must be present with a value set to the empty string.
+ // Mutually exclusive with expression and message.
// +optional
- RequiredValue string `json:"requiredValue"`
+ RequiredValue string `json:"requiredValue,omitempty"`
+
+ // expression represents the expression which will be evaluated by CEL.
+ // Must produce a boolean.
+ //
+ // CEL expressions have access to the contents of the token claims, organized into CEL variable:
+ // - 'claims' is a map of claim names to claim values.
+ // For example, a variable named 'sub' can be accessed as 'claims.sub'.
+ // Nested claims can be accessed using dot notation, e.g. 'claims.email.verified'.
+ // Must return true for the validation to pass.
+ //
+ // Documentation on CEL: https://kubernetes.io/docs/reference/using-api/cel/
+ //
+ // Mutually exclusive with claim and requiredValue.
+ // +optional
+ Expression string `json:"expression,omitempty"`
+ // message customizes the returned error message when expression returns false.
+ // message is a literal string.
+ // Mutually exclusive with claim and requiredValue.
+ // +optional
+ Message string `json:"message,omitempty"`
}
// ClaimMappings provides the configuration for claim mapping
@@ -240,6 +269,7 @@ type ClaimMappings struct {
// username represents an option for the username attribute.
// The claim's value must be a singular string.
// Same as the --oidc-username-claim and --oidc-username-prefix flags.
+ // If username.expression is set, the expression must produce a string value.
//
// In the flag based approach, the --oidc-username-claim and --oidc-username-prefix are optional. If --oidc-username-claim is not set,
// the default value is "sub". For the authentication config, there is no defaulting for claim or prefix. The claim and prefix must be set explicitly.
@@ -254,19 +284,136 @@ type ClaimMappings struct {
Username PrefixedClaimOrExpression `json:"username"`
// groups represents an option for the groups attribute.
// The claim's value must be a string or string array claim.
- // // If groups.claim is set, the prefix must be specified (and can be the empty string).
+ // If groups.claim is set, the prefix must be specified (and can be the empty string).
+ // If groups.expression is set, the expression must produce a string or string array value.
+ // "", [], and null values are treated as the group mapping not being present.
// +optional
Groups PrefixedClaimOrExpression `json:"groups,omitempty"`
+
+ // uid represents an option for the uid attribute.
+ // Claim must be a singular string claim.
+ // If uid.expression is set, the expression must produce a string value.
+ // +optional
+ UID ClaimOrExpression `json:"uid"`
+
+ // extra represents an option for the extra attribute.
+ // expression must produce a string or string array value.
+ // If the value is empty, the extra mapping will not be present.
+ //
+ // hard-coded extra key/value
+ // - key: "foo"
+ // valueExpression: "'bar'"
+ // This will result in an extra attribute - foo: ["bar"]
+ //
+ // hard-coded key, value copying claim value
+ // - key: "foo"
+ // valueExpression: "claims.some_claim"
+ // This will result in an extra attribute - foo: [value of some_claim]
+ //
+ // hard-coded key, value derived from claim value
+ // - key: "admin"
+ // valueExpression: '(has(claims.is_admin) && claims.is_admin) ? "true":""'
+ // This will result in:
+ // - if is_admin claim is present and true, extra attribute - admin: ["true"]
+ // - if is_admin claim is present and false or is_admin claim is not present, no extra attribute will be added
+ //
+ // +optional
+ Extra []ExtraMapping `json:"extra,omitempty"`
}
// PrefixedClaimOrExpression provides the configuration for a single prefixed claim or expression.
type PrefixedClaimOrExpression struct {
// claim is the JWT claim to use.
+ // Mutually exclusive with expression.
// +optional
- Claim string `json:"claim"`
+ Claim string `json:"claim,omitempty"`
// prefix is prepended to claim's value to prevent clashes with existing names.
+ // prefix needs to be set if claim is set and can be the empty string.
+ // Mutually exclusive with expression.
+ // +optional
+ Prefix *string `json:"prefix,omitempty"`
+
+ // expression represents the expression which will be evaluated by CEL.
+ //
+ // CEL expressions have access to the contents of the token claims, organized into CEL variable:
+ // - 'claims' is a map of claim names to claim values.
+ // For example, a variable named 'sub' can be accessed as 'claims.sub'.
+ // Nested claims can be accessed using dot notation, e.g. 'claims.email.verified'.
+ //
+ // Documentation on CEL: https://kubernetes.io/docs/reference/using-api/cel/
+ //
+ // Mutually exclusive with claim and prefix.
+ // +optional
+ Expression string `json:"expression,omitempty"`
+}
+
+// ClaimOrExpression provides the configuration for a single claim or expression.
+type ClaimOrExpression struct {
+ // claim is the JWT claim to use.
+ // Either claim or expression must be set.
+ // Mutually exclusive with expression.
+ // +optional
+ Claim string `json:"claim,omitempty"`
+
+ // expression represents the expression which will be evaluated by CEL.
+ //
+ // CEL expressions have access to the contents of the token claims, organized into CEL variable:
+ // - 'claims' is a map of claim names to claim values.
+ // For example, a variable named 'sub' can be accessed as 'claims.sub'.
+ // Nested claims can be accessed using dot notation, e.g. 'claims.email.verified'.
+ //
+ // Documentation on CEL: https://kubernetes.io/docs/reference/using-api/cel/
+ //
+ // Mutually exclusive with claim.
+ // +optional
+ Expression string `json:"expression,omitempty"`
+}
+
+// ExtraMapping provides the configuration for a single extra mapping.
+type ExtraMapping struct {
+ // key is a string to use as the extra attribute key.
+ // key must be a domain-prefix path (e.g. example.org/foo). All characters before the first "/" must be a valid
+ // subdomain as defined by RFC 1123. All characters trailing the first "/" must
+ // be valid HTTP Path characters as defined by RFC 3986.
+ // key must be lowercase.
// +required
- Prefix *string `json:"prefix"`
+ Key string `json:"key"`
+
+ // valueExpression is a CEL expression to extract extra attribute value.
+ // valueExpression must produce a string or string array value.
+ // "", [], and null values are treated as the extra mapping not being present.
+ // Empty string values contained within a string array are filtered out.
+ //
+ // CEL expressions have access to the contents of the token claims, organized into CEL variable:
+ // - 'claims' is a map of claim names to claim values.
+ // For example, a variable named 'sub' can be accessed as 'claims.sub'.
+ // Nested claims can be accessed using dot notation, e.g. 'claims.email.verified'.
+ //
+ // Documentation on CEL: https://kubernetes.io/docs/reference/using-api/cel/
+ //
+ // +required
+ ValueExpression string `json:"valueExpression"`
+}
+
+// UserValidationRule provides the configuration for a single user info validation rule.
+type UserValidationRule struct {
+ // expression represents the expression which will be evaluated by CEL.
+ // Must return true for the validation to pass.
+ //
+ // CEL expressions have access to the contents of UserInfo, organized into CEL variable:
+ // - 'user' - authentication.k8s.io/v1, Kind=UserInfo object
+ // Refer to https://github.com/kubernetes/api/blob/release-1.28/authentication/v1/types.go#L105-L122 for the definition.
+ // API documentation: https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.28/#userinfo-v1-authentication-k8s-io
+ //
+ // Documentation on CEL: https://kubernetes.io/docs/reference/using-api/cel/
+ //
+ // +required
+ Expression string `json:"expression"`
+
+ // message customizes the returned error message when rule returns false.
+ // message is a literal string.
+ // +optional
+ Message string `json:"message,omitempty"`
}
// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object
diff --git a/staging/src/k8s.io/apiserver/pkg/apis/apiserver/v1alpha1/zz_generated.conversion.go b/staging/src/k8s.io/apiserver/pkg/apis/apiserver/v1alpha1/zz_generated.conversion.go
index a7a09ad0eed..92060206840 100644
--- a/staging/src/k8s.io/apiserver/pkg/apis/apiserver/v1alpha1/zz_generated.conversion.go
+++ b/staging/src/k8s.io/apiserver/pkg/apis/apiserver/v1alpha1/zz_generated.conversion.go
@@ -96,6 +96,16 @@ func RegisterConversions(s *runtime.Scheme) error {
}); err != nil {
return err
}
+ if err := s.AddGeneratedConversionFunc((*ClaimOrExpression)(nil), (*apiserver.ClaimOrExpression)(nil), func(a, b interface{}, scope conversion.Scope) error {
+ return Convert_v1alpha1_ClaimOrExpression_To_apiserver_ClaimOrExpression(a.(*ClaimOrExpression), b.(*apiserver.ClaimOrExpression), scope)
+ }); err != nil {
+ return err
+ }
+ if err := s.AddGeneratedConversionFunc((*apiserver.ClaimOrExpression)(nil), (*ClaimOrExpression)(nil), func(a, b interface{}, scope conversion.Scope) error {
+ return Convert_apiserver_ClaimOrExpression_To_v1alpha1_ClaimOrExpression(a.(*apiserver.ClaimOrExpression), b.(*ClaimOrExpression), scope)
+ }); err != nil {
+ return err
+ }
if err := s.AddGeneratedConversionFunc((*ClaimValidationRule)(nil), (*apiserver.ClaimValidationRule)(nil), func(a, b interface{}, scope conversion.Scope) error {
return Convert_v1alpha1_ClaimValidationRule_To_apiserver_ClaimValidationRule(a.(*ClaimValidationRule), b.(*apiserver.ClaimValidationRule), scope)
}); err != nil {
@@ -131,6 +141,16 @@ func RegisterConversions(s *runtime.Scheme) error {
}); err != nil {
return err
}
+ if err := s.AddGeneratedConversionFunc((*ExtraMapping)(nil), (*apiserver.ExtraMapping)(nil), func(a, b interface{}, scope conversion.Scope) error {
+ return Convert_v1alpha1_ExtraMapping_To_apiserver_ExtraMapping(a.(*ExtraMapping), b.(*apiserver.ExtraMapping), scope)
+ }); err != nil {
+ return err
+ }
+ if err := s.AddGeneratedConversionFunc((*apiserver.ExtraMapping)(nil), (*ExtraMapping)(nil), func(a, b interface{}, scope conversion.Scope) error {
+ return Convert_apiserver_ExtraMapping_To_v1alpha1_ExtraMapping(a.(*apiserver.ExtraMapping), b.(*ExtraMapping), scope)
+ }); err != nil {
+ return err
+ }
if err := s.AddGeneratedConversionFunc((*Issuer)(nil), (*apiserver.Issuer)(nil), func(a, b interface{}, scope conversion.Scope) error {
return Convert_v1alpha1_Issuer_To_apiserver_Issuer(a.(*Issuer), b.(*apiserver.Issuer), scope)
}); err != nil {
@@ -211,6 +231,16 @@ func RegisterConversions(s *runtime.Scheme) error {
}); err != nil {
return err
}
+ if err := s.AddGeneratedConversionFunc((*UserValidationRule)(nil), (*apiserver.UserValidationRule)(nil), func(a, b interface{}, scope conversion.Scope) error {
+ return Convert_v1alpha1_UserValidationRule_To_apiserver_UserValidationRule(a.(*UserValidationRule), b.(*apiserver.UserValidationRule), scope)
+ }); err != nil {
+ return err
+ }
+ if err := s.AddGeneratedConversionFunc((*apiserver.UserValidationRule)(nil), (*UserValidationRule)(nil), func(a, b interface{}, scope conversion.Scope) error {
+ return Convert_apiserver_UserValidationRule_To_v1alpha1_UserValidationRule(a.(*apiserver.UserValidationRule), b.(*UserValidationRule), scope)
+ }); err != nil {
+ return err
+ }
if err := s.AddGeneratedConversionFunc((*WebhookConfiguration)(nil), (*apiserver.WebhookConfiguration)(nil), func(a, b interface{}, scope conversion.Scope) error {
return Convert_v1alpha1_WebhookConfiguration_To_apiserver_WebhookConfiguration(a.(*WebhookConfiguration), b.(*apiserver.WebhookConfiguration), scope)
}); err != nil {
@@ -364,6 +394,10 @@ func autoConvert_v1alpha1_ClaimMappings_To_apiserver_ClaimMappings(in *ClaimMapp
if err := Convert_v1alpha1_PrefixedClaimOrExpression_To_apiserver_PrefixedClaimOrExpression(&in.Groups, &out.Groups, s); err != nil {
return err
}
+ if err := Convert_v1alpha1_ClaimOrExpression_To_apiserver_ClaimOrExpression(&in.UID, &out.UID, s); err != nil {
+ return err
+ }
+ out.Extra = *(*[]apiserver.ExtraMapping)(unsafe.Pointer(&in.Extra))
return nil
}
@@ -379,6 +413,10 @@ func autoConvert_apiserver_ClaimMappings_To_v1alpha1_ClaimMappings(in *apiserver
if err := Convert_apiserver_PrefixedClaimOrExpression_To_v1alpha1_PrefixedClaimOrExpression(&in.Groups, &out.Groups, s); err != nil {
return err
}
+ if err := Convert_apiserver_ClaimOrExpression_To_v1alpha1_ClaimOrExpression(&in.UID, &out.UID, s); err != nil {
+ return err
+ }
+ out.Extra = *(*[]ExtraMapping)(unsafe.Pointer(&in.Extra))
return nil
}
@@ -387,9 +425,33 @@ func Convert_apiserver_ClaimMappings_To_v1alpha1_ClaimMappings(in *apiserver.Cla
return autoConvert_apiserver_ClaimMappings_To_v1alpha1_ClaimMappings(in, out, s)
}
+func autoConvert_v1alpha1_ClaimOrExpression_To_apiserver_ClaimOrExpression(in *ClaimOrExpression, out *apiserver.ClaimOrExpression, s conversion.Scope) error {
+ out.Claim = in.Claim
+ out.Expression = in.Expression
+ return nil
+}
+
+// Convert_v1alpha1_ClaimOrExpression_To_apiserver_ClaimOrExpression is an autogenerated conversion function.
+func Convert_v1alpha1_ClaimOrExpression_To_apiserver_ClaimOrExpression(in *ClaimOrExpression, out *apiserver.ClaimOrExpression, s conversion.Scope) error {
+ return autoConvert_v1alpha1_ClaimOrExpression_To_apiserver_ClaimOrExpression(in, out, s)
+}
+
+func autoConvert_apiserver_ClaimOrExpression_To_v1alpha1_ClaimOrExpression(in *apiserver.ClaimOrExpression, out *ClaimOrExpression, s conversion.Scope) error {
+ out.Claim = in.Claim
+ out.Expression = in.Expression
+ return nil
+}
+
+// Convert_apiserver_ClaimOrExpression_To_v1alpha1_ClaimOrExpression is an autogenerated conversion function.
+func Convert_apiserver_ClaimOrExpression_To_v1alpha1_ClaimOrExpression(in *apiserver.ClaimOrExpression, out *ClaimOrExpression, s conversion.Scope) error {
+ return autoConvert_apiserver_ClaimOrExpression_To_v1alpha1_ClaimOrExpression(in, out, s)
+}
+
func autoConvert_v1alpha1_ClaimValidationRule_To_apiserver_ClaimValidationRule(in *ClaimValidationRule, out *apiserver.ClaimValidationRule, s conversion.Scope) error {
out.Claim = in.Claim
out.RequiredValue = in.RequiredValue
+ out.Expression = in.Expression
+ out.Message = in.Message
return nil
}
@@ -401,6 +463,8 @@ func Convert_v1alpha1_ClaimValidationRule_To_apiserver_ClaimValidationRule(in *C
func autoConvert_apiserver_ClaimValidationRule_To_v1alpha1_ClaimValidationRule(in *apiserver.ClaimValidationRule, out *ClaimValidationRule, s conversion.Scope) error {
out.Claim = in.Claim
out.RequiredValue = in.RequiredValue
+ out.Expression = in.Expression
+ out.Message = in.Message
return nil
}
@@ -492,6 +556,28 @@ func Convert_apiserver_EgressSelectorConfiguration_To_v1alpha1_EgressSelectorCon
return autoConvert_apiserver_EgressSelectorConfiguration_To_v1alpha1_EgressSelectorConfiguration(in, out, s)
}
+func autoConvert_v1alpha1_ExtraMapping_To_apiserver_ExtraMapping(in *ExtraMapping, out *apiserver.ExtraMapping, s conversion.Scope) error {
+ out.Key = in.Key
+ out.ValueExpression = in.ValueExpression
+ return nil
+}
+
+// Convert_v1alpha1_ExtraMapping_To_apiserver_ExtraMapping is an autogenerated conversion function.
+func Convert_v1alpha1_ExtraMapping_To_apiserver_ExtraMapping(in *ExtraMapping, out *apiserver.ExtraMapping, s conversion.Scope) error {
+ return autoConvert_v1alpha1_ExtraMapping_To_apiserver_ExtraMapping(in, out, s)
+}
+
+func autoConvert_apiserver_ExtraMapping_To_v1alpha1_ExtraMapping(in *apiserver.ExtraMapping, out *ExtraMapping, s conversion.Scope) error {
+ out.Key = in.Key
+ out.ValueExpression = in.ValueExpression
+ return nil
+}
+
+// Convert_apiserver_ExtraMapping_To_v1alpha1_ExtraMapping is an autogenerated conversion function.
+func Convert_apiserver_ExtraMapping_To_v1alpha1_ExtraMapping(in *apiserver.ExtraMapping, out *ExtraMapping, s conversion.Scope) error {
+ return autoConvert_apiserver_ExtraMapping_To_v1alpha1_ExtraMapping(in, out, s)
+}
+
func autoConvert_v1alpha1_Issuer_To_apiserver_Issuer(in *Issuer, out *apiserver.Issuer, s conversion.Scope) error {
out.URL = in.URL
out.CertificateAuthority = in.CertificateAuthority
@@ -524,6 +610,7 @@ func autoConvert_v1alpha1_JWTAuthenticator_To_apiserver_JWTAuthenticator(in *JWT
if err := Convert_v1alpha1_ClaimMappings_To_apiserver_ClaimMappings(&in.ClaimMappings, &out.ClaimMappings, s); err != nil {
return err
}
+ out.UserValidationRules = *(*[]apiserver.UserValidationRule)(unsafe.Pointer(&in.UserValidationRules))
return nil
}
@@ -540,6 +627,7 @@ func autoConvert_apiserver_JWTAuthenticator_To_v1alpha1_JWTAuthenticator(in *api
if err := Convert_apiserver_ClaimMappings_To_v1alpha1_ClaimMappings(&in.ClaimMappings, &out.ClaimMappings, s); err != nil {
return err
}
+ out.UserValidationRules = *(*[]UserValidationRule)(unsafe.Pointer(&in.UserValidationRules))
return nil
}
@@ -551,6 +639,7 @@ func Convert_apiserver_JWTAuthenticator_To_v1alpha1_JWTAuthenticator(in *apiserv
func autoConvert_v1alpha1_PrefixedClaimOrExpression_To_apiserver_PrefixedClaimOrExpression(in *PrefixedClaimOrExpression, out *apiserver.PrefixedClaimOrExpression, s conversion.Scope) error {
out.Claim = in.Claim
out.Prefix = (*string)(unsafe.Pointer(in.Prefix))
+ out.Expression = in.Expression
return nil
}
@@ -562,6 +651,7 @@ func Convert_v1alpha1_PrefixedClaimOrExpression_To_apiserver_PrefixedClaimOrExpr
func autoConvert_apiserver_PrefixedClaimOrExpression_To_v1alpha1_PrefixedClaimOrExpression(in *apiserver.PrefixedClaimOrExpression, out *PrefixedClaimOrExpression, s conversion.Scope) error {
out.Claim = in.Claim
out.Prefix = (*string)(unsafe.Pointer(in.Prefix))
+ out.Expression = in.Expression
return nil
}
@@ -678,6 +768,28 @@ func Convert_apiserver_UDSTransport_To_v1alpha1_UDSTransport(in *apiserver.UDSTr
return autoConvert_apiserver_UDSTransport_To_v1alpha1_UDSTransport(in, out, s)
}
+func autoConvert_v1alpha1_UserValidationRule_To_apiserver_UserValidationRule(in *UserValidationRule, out *apiserver.UserValidationRule, s conversion.Scope) error {
+ out.Expression = in.Expression
+ out.Message = in.Message
+ return nil
+}
+
+// Convert_v1alpha1_UserValidationRule_To_apiserver_UserValidationRule is an autogenerated conversion function.
+func Convert_v1alpha1_UserValidationRule_To_apiserver_UserValidationRule(in *UserValidationRule, out *apiserver.UserValidationRule, s conversion.Scope) error {
+ return autoConvert_v1alpha1_UserValidationRule_To_apiserver_UserValidationRule(in, out, s)
+}
+
+func autoConvert_apiserver_UserValidationRule_To_v1alpha1_UserValidationRule(in *apiserver.UserValidationRule, out *UserValidationRule, s conversion.Scope) error {
+ out.Expression = in.Expression
+ out.Message = in.Message
+ return nil
+}
+
+// Convert_apiserver_UserValidationRule_To_v1alpha1_UserValidationRule is an autogenerated conversion function.
+func Convert_apiserver_UserValidationRule_To_v1alpha1_UserValidationRule(in *apiserver.UserValidationRule, out *UserValidationRule, s conversion.Scope) error {
+ return autoConvert_apiserver_UserValidationRule_To_v1alpha1_UserValidationRule(in, out, s)
+}
+
func autoConvert_v1alpha1_WebhookConfiguration_To_apiserver_WebhookConfiguration(in *WebhookConfiguration, out *apiserver.WebhookConfiguration, s conversion.Scope) error {
out.AuthorizedTTL = in.AuthorizedTTL
out.UnauthorizedTTL = in.UnauthorizedTTL
diff --git a/staging/src/k8s.io/apiserver/pkg/apis/apiserver/v1alpha1/zz_generated.deepcopy.go b/staging/src/k8s.io/apiserver/pkg/apis/apiserver/v1alpha1/zz_generated.deepcopy.go
index 5121d05e7d3..932af612707 100644
--- a/staging/src/k8s.io/apiserver/pkg/apis/apiserver/v1alpha1/zz_generated.deepcopy.go
+++ b/staging/src/k8s.io/apiserver/pkg/apis/apiserver/v1alpha1/zz_generated.deepcopy.go
@@ -168,6 +168,12 @@ func (in *ClaimMappings) DeepCopyInto(out *ClaimMappings) {
*out = *in
in.Username.DeepCopyInto(&out.Username)
in.Groups.DeepCopyInto(&out.Groups)
+ out.UID = in.UID
+ if in.Extra != nil {
+ in, out := &in.Extra, &out.Extra
+ *out = make([]ExtraMapping, len(*in))
+ copy(*out, *in)
+ }
return
}
@@ -181,6 +187,22 @@ func (in *ClaimMappings) DeepCopy() *ClaimMappings {
return out
}
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *ClaimOrExpression) DeepCopyInto(out *ClaimOrExpression) {
+ *out = *in
+ return
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ClaimOrExpression.
+func (in *ClaimOrExpression) DeepCopy() *ClaimOrExpression {
+ if in == nil {
+ return nil
+ }
+ out := new(ClaimOrExpression)
+ in.DeepCopyInto(out)
+ return out
+}
+
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *ClaimValidationRule) DeepCopyInto(out *ClaimValidationRule) {
*out = *in
@@ -267,6 +289,22 @@ func (in *EgressSelectorConfiguration) DeepCopyObject() runtime.Object {
return nil
}
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *ExtraMapping) DeepCopyInto(out *ExtraMapping) {
+ *out = *in
+ return
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ExtraMapping.
+func (in *ExtraMapping) DeepCopy() *ExtraMapping {
+ if in == nil {
+ return nil
+ }
+ out := new(ExtraMapping)
+ in.DeepCopyInto(out)
+ return out
+}
+
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *Issuer) DeepCopyInto(out *Issuer) {
*out = *in
@@ -298,6 +336,11 @@ func (in *JWTAuthenticator) DeepCopyInto(out *JWTAuthenticator) {
copy(*out, *in)
}
in.ClaimMappings.DeepCopyInto(&out.ClaimMappings)
+ if in.UserValidationRules != nil {
+ in, out := &in.UserValidationRules, &out.UserValidationRules
+ *out = make([]UserValidationRule, len(*in))
+ copy(*out, *in)
+ }
return
}
@@ -437,6 +480,22 @@ func (in *UDSTransport) DeepCopy() *UDSTransport {
return out
}
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *UserValidationRule) DeepCopyInto(out *UserValidationRule) {
+ *out = *in
+ return
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new UserValidationRule.
+func (in *UserValidationRule) DeepCopy() *UserValidationRule {
+ if in == nil {
+ return nil
+ }
+ out := new(UserValidationRule)
+ in.DeepCopyInto(out)
+ return out
+}
+
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *WebhookConfiguration) DeepCopyInto(out *WebhookConfiguration) {
*out = *in
diff --git a/staging/src/k8s.io/apiserver/pkg/apis/apiserver/validation/validation.go b/staging/src/k8s.io/apiserver/pkg/apis/apiserver/validation/validation.go
index ddf0b4b100d..7b22a200f96 100644
--- a/staging/src/k8s.io/apiserver/pkg/apis/apiserver/validation/validation.go
+++ b/staging/src/k8s.io/apiserver/pkg/apis/apiserver/validation/validation.go
@@ -31,6 +31,7 @@ import (
utilvalidation "k8s.io/apimachinery/pkg/util/validation"
"k8s.io/apimachinery/pkg/util/validation/field"
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/cel"
"k8s.io/apiserver/pkg/cel/environment"
@@ -75,26 +76,32 @@ func ValidateAuthenticationConfiguration(c *api.AuthenticationConfiguration) fie
// check and add validation for duplicate issuers.
for i, a := range c.JWT {
fldPath := root.Index(i)
- allErrs = append(allErrs, validateJWTAuthenticator(a, fldPath)...)
+ _, errs := validateJWTAuthenticator(a, fldPath, utilfeature.DefaultFeatureGate.Enabled(features.StructuredAuthenticationConfiguration))
+ allErrs = append(allErrs, errs...)
}
return allErrs
}
-// ValidateJWTAuthenticator validates a given JWTAuthenticator.
+// CompileAndValidateJWTAuthenticator validates a given JWTAuthenticator and returns a CELMapper with the compiled
+// CEL expressions for claim mappings and validation rules.
// This is exported for use in oidc package.
-func ValidateJWTAuthenticator(authenticator api.JWTAuthenticator) field.ErrorList {
- return validateJWTAuthenticator(authenticator, nil)
+func CompileAndValidateJWTAuthenticator(authenticator api.JWTAuthenticator) (authenticationcel.CELMapper, field.ErrorList) {
+ return validateJWTAuthenticator(authenticator, nil, utilfeature.DefaultFeatureGate.Enabled(features.StructuredAuthenticationConfiguration))
}
-func validateJWTAuthenticator(authenticator api.JWTAuthenticator, fldPath *field.Path) field.ErrorList {
+func validateJWTAuthenticator(authenticator api.JWTAuthenticator, fldPath *field.Path, structuredAuthnFeatureEnabled bool) (authenticationcel.CELMapper, field.ErrorList) {
var allErrs field.ErrorList
- allErrs = append(allErrs, validateIssuer(authenticator.Issuer, fldPath.Child("issuer"))...)
- allErrs = append(allErrs, validateClaimValidationRules(authenticator.ClaimValidationRules, fldPath.Child("claimValidationRules"))...)
- allErrs = append(allErrs, validateClaimMappings(authenticator.ClaimMappings, fldPath.Child("claimMappings"))...)
+ compiler := authenticationcel.NewCompiler(environment.MustBaseEnvSet(environment.DefaultCompatibilityVersion()))
+ mapper := &authenticationcel.CELMapper{}
- return allErrs
+ allErrs = append(allErrs, validateIssuer(authenticator.Issuer, fldPath.Child("issuer"))...)
+ allErrs = append(allErrs, validateClaimValidationRules(compiler, mapper, authenticator.ClaimValidationRules, fldPath.Child("claimValidationRules"), structuredAuthnFeatureEnabled)...)
+ allErrs = append(allErrs, validateClaimMappings(compiler, mapper, authenticator.ClaimMappings, fldPath.Child("claimMappings"), structuredAuthnFeatureEnabled)...)
+ allErrs = append(allErrs, validateUserValidationRules(compiler, mapper, authenticator.UserValidationRules, fldPath.Child("userValidationRules"), structuredAuthnFeatureEnabled)...)
+
+ return *mapper, allErrs
}
func validateIssuer(issuer api.Issuer, fldPath *field.Path) field.ErrorList {
@@ -174,48 +181,250 @@ func validateCertificateAuthority(certificateAuthority string, fldPath *field.Pa
return allErrs
}
-func validateClaimValidationRules(rules []api.ClaimValidationRule, fldPath *field.Path) field.ErrorList {
+func validateClaimValidationRules(compiler authenticationcel.Compiler, celMapper *authenticationcel.CELMapper, rules []api.ClaimValidationRule, fldPath *field.Path, structuredAuthnFeatureEnabled bool) field.ErrorList {
var allErrs field.ErrorList
seenClaims := sets.NewString()
+ seenExpressions := sets.NewString()
+ var compilationResults []authenticationcel.CompilationResult
+
for i, rule := range rules {
fldPath := fldPath.Index(i)
- if len(rule.Claim) == 0 {
- allErrs = append(allErrs, field.Required(fldPath.Child("claim"), "claim name is required"))
- continue
+ if len(rule.Expression) > 0 && !structuredAuthnFeatureEnabled {
+ allErrs = append(allErrs, field.Invalid(fldPath.Child("expression"), rule.Expression, "expression is not supported when StructuredAuthenticationConfiguration feature gate is disabled"))
}
- if seenClaims.Has(rule.Claim) {
- allErrs = append(allErrs, field.Duplicate(fldPath.Child("claim"), rule.Claim))
- continue
+ switch {
+ case len(rule.Claim) > 0 && len(rule.Expression) > 0:
+ allErrs = append(allErrs, field.Invalid(fldPath, rule.Claim, "claim and expression can't both be set"))
+ case len(rule.Claim) == 0 && len(rule.Expression) == 0:
+ allErrs = append(allErrs, field.Required(fldPath, "claim or expression is required"))
+ case len(rule.Claim) > 0:
+ if len(rule.Message) > 0 {
+ allErrs = append(allErrs, field.Invalid(fldPath.Child("message"), rule.Message, "message can't be set when claim is set"))
+ }
+ if seenClaims.Has(rule.Claim) {
+ allErrs = append(allErrs, field.Duplicate(fldPath.Child("claim"), rule.Claim))
+ }
+ seenClaims.Insert(rule.Claim)
+ case len(rule.Expression) > 0:
+ if len(rule.RequiredValue) > 0 {
+ allErrs = append(allErrs, field.Invalid(fldPath.Child("requiredValue"), rule.RequiredValue, "requiredValue can't be set when expression is set"))
+ }
+ if seenExpressions.Has(rule.Expression) {
+ allErrs = append(allErrs, field.Duplicate(fldPath.Child("expression"), rule.Expression))
+ continue
+ }
+ seenExpressions.Insert(rule.Expression)
+
+ compilationResult, err := compileClaimsCELExpression(compiler, &authenticationcel.ClaimValidationCondition{
+ Expression: rule.Expression,
+ }, fldPath.Child("expression"))
+
+ if err != nil {
+ allErrs = append(allErrs, err)
+ continue
+ }
+ if compilationResult != nil {
+ compilationResults = append(compilationResults, *compilationResult)
+ }
}
- seenClaims.Insert(rule.Claim)
+ }
+
+ if structuredAuthnFeatureEnabled && len(compilationResults) > 0 {
+ celMapper.ClaimValidationRules = authenticationcel.NewClaimsMapper(compilationResults)
}
return allErrs
}
-func validateClaimMappings(m api.ClaimMappings, fldPath *field.Path) field.ErrorList {
+func validateClaimMappings(compiler authenticationcel.Compiler, celMapper *authenticationcel.CELMapper, m api.ClaimMappings, fldPath *field.Path, structuredAuthnFeatureEnabled bool) field.ErrorList {
var allErrs field.ErrorList
- if len(m.Username.Claim) == 0 {
- allErrs = append(allErrs, field.Required(fldPath.Child("username", "claim"), "claim name is required"))
+ if !structuredAuthnFeatureEnabled {
+ if len(m.Username.Expression) > 0 {
+ allErrs = append(allErrs, field.Invalid(fldPath.Child("username").Child("expression"), m.Username.Expression, "expression is not supported when StructuredAuthenticationConfiguration feature gate is disabled"))
+ }
+ if len(m.Groups.Expression) > 0 {
+ allErrs = append(allErrs, field.Invalid(fldPath.Child("groups").Child("expression"), m.Groups.Expression, "expression is not supported when StructuredAuthenticationConfiguration feature gate is disabled"))
+ }
+ if len(m.UID.Claim) > 0 || len(m.UID.Expression) > 0 {
+ allErrs = append(allErrs, field.Invalid(fldPath.Child("uid"), "", "uid claim mapping is not supported when StructuredAuthenticationConfiguration feature gate is disabled"))
+ }
+ if len(m.Extra) > 0 {
+ allErrs = append(allErrs, field.Invalid(fldPath.Child("extra"), "", "extra claim mapping is not supported when StructuredAuthenticationConfiguration feature gate is disabled"))
+ }
}
- // TODO(aramase): when Expression is added to PrefixedClaimOrExpression, check prefix and expression are not both set.
- if m.Username.Prefix == nil {
- allErrs = append(allErrs, field.Required(fldPath.Child("username", "prefix"), "prefix is required"))
+
+ compilationResult, err := validatePrefixClaimOrExpression(compiler, m.Username, fldPath.Child("username"), true, structuredAuthnFeatureEnabled)
+ if err != nil {
+ allErrs = append(allErrs, err...)
+ } else if compilationResult != nil && structuredAuthnFeatureEnabled {
+ celMapper.Username = authenticationcel.NewClaimsMapper([]authenticationcel.CompilationResult{*compilationResult})
}
- if len(m.Groups.Claim) > 0 && m.Groups.Prefix == nil {
- allErrs = append(allErrs, field.Required(fldPath.Child("groups", "prefix"), "prefix is required when claim is set"))
+
+ compilationResult, err = validatePrefixClaimOrExpression(compiler, m.Groups, fldPath.Child("groups"), false, structuredAuthnFeatureEnabled)
+ if err != nil {
+ allErrs = append(allErrs, err...)
+ } else if compilationResult != nil && structuredAuthnFeatureEnabled {
+ celMapper.Groups = authenticationcel.NewClaimsMapper([]authenticationcel.CompilationResult{*compilationResult})
}
- if m.Groups.Prefix != nil && len(m.Groups.Claim) == 0 {
- allErrs = append(allErrs, field.Required(fldPath.Child("groups", "claim"), "non-empty claim name is required when prefix is set"))
+
+ switch {
+ case len(m.UID.Claim) > 0 && len(m.UID.Expression) > 0:
+ allErrs = append(allErrs, field.Invalid(fldPath.Child("uid"), "", "claim and expression can't both be set"))
+ case len(m.UID.Expression) > 0:
+ compilationResult, err := compileClaimsCELExpression(compiler, &authenticationcel.ClaimMappingExpression{
+ Expression: m.UID.Expression,
+ }, fldPath.Child("uid").Child("expression"))
+
+ if err != nil {
+ allErrs = append(allErrs, err)
+ } else if structuredAuthnFeatureEnabled && compilationResult != nil {
+ celMapper.UID = authenticationcel.NewClaimsMapper([]authenticationcel.CompilationResult{*compilationResult})
+ }
+ }
+
+ var extraCompilationResults []authenticationcel.CompilationResult
+ seenExtraKeys := sets.NewString()
+
+ for i, mapping := range m.Extra {
+ fldPath := fldPath.Child("extra").Index(i)
+ // Key should be namespaced to the authenticator or authenticator/authorizer pair making use of them.
+ // For instance: "example.org/foo" instead of "foo".
+ // xref: https://github.com/kubernetes/kubernetes/blob/3825e206cb162a7ad7431a5bdf6a065ae8422cf7/staging/src/k8s.io/apiserver/pkg/authentication/user/user.go#L31-L41
+ // IsDomainPrefixedPath checks for non-empty key and that the key is prefixed with a domain name.
+ allErrs = append(allErrs, utilvalidation.IsDomainPrefixedPath(fldPath.Child("key"), mapping.Key)...)
+ if mapping.Key != strings.ToLower(mapping.Key) {
+ allErrs = append(allErrs, field.Invalid(fldPath.Child("key"), mapping.Key, "key must be lowercase"))
+ }
+ if seenExtraKeys.Has(mapping.Key) {
+ allErrs = append(allErrs, field.Duplicate(fldPath.Child("key"), mapping.Key))
+ continue
+ }
+ seenExtraKeys.Insert(mapping.Key)
+
+ if len(mapping.ValueExpression) == 0 {
+ allErrs = append(allErrs, field.Required(fldPath.Child("valueExpression"), "valueExpression is required"))
+ continue
+ }
+
+ compilationResult, err := compileClaimsCELExpression(compiler, &authenticationcel.ExtraMappingExpression{
+ Key: mapping.Key,
+ Expression: mapping.ValueExpression,
+ }, fldPath.Child("valueExpression"))
+
+ if err != nil {
+ allErrs = append(allErrs, err)
+ continue
+ }
+
+ if compilationResult != nil {
+ extraCompilationResults = append(extraCompilationResults, *compilationResult)
+ }
+ }
+
+ if structuredAuthnFeatureEnabled && len(extraCompilationResults) > 0 {
+ celMapper.Extra = authenticationcel.NewClaimsMapper(extraCompilationResults)
}
return allErrs
}
+func validatePrefixClaimOrExpression(compiler authenticationcel.Compiler, mapping api.PrefixedClaimOrExpression, fldPath *field.Path, claimOrExpressionRequired, structuredAuthnFeatureEnabled bool) (*authenticationcel.CompilationResult, field.ErrorList) {
+ var allErrs field.ErrorList
+
+ var compilationResult *authenticationcel.CompilationResult
+ switch {
+ case len(mapping.Expression) > 0 && len(mapping.Claim) > 0:
+ allErrs = append(allErrs, field.Invalid(fldPath, "", "claim and expression can't both be set"))
+ case len(mapping.Expression) == 0 && len(mapping.Claim) == 0 && claimOrExpressionRequired:
+ allErrs = append(allErrs, field.Required(fldPath, "claim or expression is required"))
+ case len(mapping.Expression) > 0:
+ var err *field.Error
+
+ if mapping.Prefix != nil {
+ allErrs = append(allErrs, field.Invalid(fldPath.Child("prefix"), *mapping.Prefix, "prefix can't be set when expression is set"))
+ }
+ compilationResult, err = compileClaimsCELExpression(compiler, &authenticationcel.ClaimMappingExpression{
+ Expression: mapping.Expression,
+ }, fldPath.Child("expression"))
+
+ if err != nil {
+ allErrs = append(allErrs, err)
+ }
+
+ case len(mapping.Claim) > 0:
+ if mapping.Prefix == nil {
+ allErrs = append(allErrs, field.Required(fldPath.Child("prefix"), "prefix is required when claim is set. It can be set to an empty string to disable prefixing"))
+ }
+ }
+
+ return compilationResult, allErrs
+}
+
+func validateUserValidationRules(compiler authenticationcel.Compiler, celMapper *authenticationcel.CELMapper, rules []api.UserValidationRule, fldPath *field.Path, structuredAuthnFeatureEnabled bool) field.ErrorList {
+ var allErrs field.ErrorList
+ var compilationResults []authenticationcel.CompilationResult
+
+ if len(rules) > 0 && !structuredAuthnFeatureEnabled {
+ allErrs = append(allErrs, field.Invalid(fldPath, "", "user validation rules are not supported when StructuredAuthenticationConfiguration feature gate is disabled"))
+ }
+
+ seenExpressions := sets.NewString()
+ for i, rule := range rules {
+ fldPath := fldPath.Index(i)
+
+ if len(rule.Expression) == 0 {
+ allErrs = append(allErrs, field.Required(fldPath.Child("expression"), "expression is required"))
+ continue
+ }
+
+ if seenExpressions.Has(rule.Expression) {
+ allErrs = append(allErrs, field.Duplicate(fldPath.Child("expression"), rule.Expression))
+ continue
+ }
+ seenExpressions.Insert(rule.Expression)
+
+ compilationResult, err := compileUserCELExpression(compiler, &authenticationcel.UserValidationCondition{
+ Expression: rule.Expression,
+ Message: rule.Message,
+ }, fldPath.Child("expression"))
+
+ if err != nil {
+ allErrs = append(allErrs, err)
+ continue
+ }
+
+ if compilationResult != nil {
+ compilationResults = append(compilationResults, *compilationResult)
+ }
+ }
+
+ if structuredAuthnFeatureEnabled && len(compilationResults) > 0 {
+ celMapper.UserValidationRules = authenticationcel.NewUserMapper(compilationResults)
+ }
+
+ return allErrs
+}
+
+func compileClaimsCELExpression(compiler authenticationcel.Compiler, expression authenticationcel.ExpressionAccessor, fldPath *field.Path) (*authenticationcel.CompilationResult, *field.Error) {
+ compilationResult, err := compiler.CompileClaimsExpression(expression)
+ if err != nil {
+ return nil, convertCELErrorToValidationError(fldPath, expression, err)
+ }
+ return &compilationResult, nil
+}
+
+func compileUserCELExpression(compiler authenticationcel.Compiler, expression authenticationcel.ExpressionAccessor, fldPath *field.Path) (*authenticationcel.CompilationResult, *field.Error) {
+ compilationResult, err := compiler.CompileUserExpression(expression)
+ if err != nil {
+ return nil, convertCELErrorToValidationError(fldPath, expression, err)
+ }
+ return &compilationResult, nil
+}
+
// ValidateAuthorizationConfiguration validates a given AuthorizationConfiguration.
func ValidateAuthorizationConfiguration(fldPath *field.Path, c *api.AuthorizationConfiguration, knownTypes sets.String, repeatableTypes sets.String) field.ErrorList {
allErrs := field.ErrorList{}
diff --git a/staging/src/k8s.io/apiserver/pkg/apis/apiserver/validation/validation_test.go b/staging/src/k8s.io/apiserver/pkg/apis/apiserver/validation/validation_test.go
index 22630309d28..ca688a0bee5 100644
--- a/staging/src/k8s.io/apiserver/pkg/apis/apiserver/validation/validation_test.go
+++ b/staging/src/k8s.io/apiserver/pkg/apis/apiserver/validation/validation_test.go
@@ -21,6 +21,7 @@ import (
"crypto/elliptic"
"crypto/rand"
"encoding/pem"
+ "fmt"
"os"
"testing"
"time"
@@ -32,6 +33,8 @@ import (
"k8s.io/apimachinery/pkg/util/sets"
"k8s.io/apimachinery/pkg/util/validation/field"
api "k8s.io/apiserver/pkg/apis/apiserver"
+ authenticationcel "k8s.io/apiserver/pkg/authentication/cel"
+ "k8s.io/apiserver/pkg/cel/environment"
"k8s.io/apiserver/pkg/features"
utilfeature "k8s.io/apiserver/pkg/util/feature"
certutil "k8s.io/client-go/util/cert"
@@ -39,7 +42,13 @@ import (
"k8s.io/utils/pointer"
)
+var (
+ compiler = authenticationcel.NewCompiler(environment.MustBaseEnvSet(environment.DefaultCompatibilityVersion()))
+)
+
func TestValidateAuthenticationConfiguration(t *testing.T) {
+ defer featuregatetesting.SetFeatureGateDuringTest(t, utilfeature.DefaultFeatureGate, features.StructuredAuthenticationConfiguration, true)()
+
testCases := []struct {
name string
in *api.AuthenticationConfiguration
@@ -133,7 +142,37 @@ func TestValidateAuthenticationConfiguration(t *testing.T) {
},
},
},
- want: "jwt[0].claimMappings.username.claim: Required value: claim name is required",
+ want: "jwt[0].claimMappings.username: Required value: claim or expression is required",
+ },
+ {
+ name: "failed userValidationRule validation",
+ in: &api.AuthenticationConfiguration{
+ JWT: []api.JWTAuthenticator{
+ {
+ Issuer: api.Issuer{
+ URL: "https://issuer-url",
+ Audiences: []string{"audience"},
+ },
+ ClaimValidationRules: []api.ClaimValidationRule{
+ {
+ Claim: "foo",
+ RequiredValue: "bar",
+ },
+ },
+ ClaimMappings: api.ClaimMappings{
+ Username: api.PrefixedClaimOrExpression{
+ Claim: "sub",
+ Prefix: pointer.String("prefix"),
+ },
+ },
+ UserValidationRules: []api.UserValidationRule{
+ {Expression: "user.username == 'foo'"},
+ {Expression: "user.username == 'foo'"},
+ },
+ },
+ },
+ },
+ want: `jwt[0].userValidationRules[1].expression: Duplicate value: "user.username == 'foo'"`,
},
{
name: "valid authentication configuration",
@@ -313,31 +352,98 @@ func TestValidateCertificateAuthority(t *testing.T) {
}
}
-func TestClaimValidationRules(t *testing.T) {
+func TestValidateClaimValidationRules(t *testing.T) {
fldPath := field.NewPath("issuer", "claimValidationRules")
testCases := []struct {
- name string
- in []api.ClaimValidationRule
- want string
+ name string
+ in []api.ClaimValidationRule
+ structuredAuthnFeatureEnabled bool
+ want string
+ wantCELMapper bool
}{
{
- name: "claim validation rule claim is empty",
- in: []api.ClaimValidationRule{{Claim: ""}},
- want: "issuer.claimValidationRules[0].claim: Required value: claim name is required",
+ name: "claim and expression are empty, structured authn feature enabled",
+ in: []api.ClaimValidationRule{{}},
+ structuredAuthnFeatureEnabled: true,
+ want: "issuer.claimValidationRules[0]: Required value: claim or expression is required",
+ },
+ {
+ name: "claim and expression are set",
+ in: []api.ClaimValidationRule{
+ {Claim: "claim", Expression: "expression"},
+ },
+ structuredAuthnFeatureEnabled: true,
+ want: `issuer.claimValidationRules[0]: Invalid value: "claim": claim and expression can't both be set`,
+ },
+ {
+ name: "message set when claim is set",
+ in: []api.ClaimValidationRule{
+ {Claim: "claim", Message: "message"},
+ },
+ structuredAuthnFeatureEnabled: true,
+ want: `issuer.claimValidationRules[0].message: Invalid value: "message": message can't be set when claim is set`,
+ },
+ {
+ name: "requiredValue set when expression is set",
+ in: []api.ClaimValidationRule{
+ {Expression: "claims.foo == 'bar'", RequiredValue: "value"},
+ },
+ structuredAuthnFeatureEnabled: true,
+ want: `issuer.claimValidationRules[0].requiredValue: Invalid value: "value": requiredValue can't be set when expression is set`,
},
{
name: "duplicate claim",
- in: []api.ClaimValidationRule{{
- Claim: "claim", RequiredValue: "value1"},
- {Claim: "claim", RequiredValue: "value2"},
+ in: []api.ClaimValidationRule{
+ {Claim: "claim"},
+ {Claim: "claim"},
},
- want: `issuer.claimValidationRules[1].claim: Duplicate value: "claim"`,
+ structuredAuthnFeatureEnabled: true,
+ want: `issuer.claimValidationRules[1].claim: Duplicate value: "claim"`,
},
{
- name: "valid claim validation rule",
- in: []api.ClaimValidationRule{{Claim: "claim", RequiredValue: "value"}},
- want: "",
+ name: "duplicate expression",
+ in: []api.ClaimValidationRule{
+ {Expression: "claims.foo == 'bar'"},
+ {Expression: "claims.foo == 'bar'"},
+ },
+ structuredAuthnFeatureEnabled: true,
+ want: `issuer.claimValidationRules[1].expression: Duplicate value: "claims.foo == 'bar'"`,
+ },
+ {
+ name: "expression set when structured authn feature is disabled",
+ in: []api.ClaimValidationRule{
+ {Expression: "claims.foo == 'bar'"},
+ },
+ structuredAuthnFeatureEnabled: false,
+ want: `issuer.claimValidationRules[0].expression: Invalid value: "claims.foo == 'bar'": expression is not supported when StructuredAuthenticationConfiguration feature gate is disabled`,
+ },
+ {
+ name: "CEL expression compilation error",
+ in: []api.ClaimValidationRule{
+ {Expression: "foo.bar"},
+ },
+ structuredAuthnFeatureEnabled: true,
+ want: `issuer.claimValidationRules[0].expression: Invalid value: "foo.bar": compilation failed: ERROR: :1:1: undeclared reference to 'foo' (in container '')
+ | foo.bar
+ | ^`,
+ },
+ {
+ name: "expression does not evaluate to bool",
+ in: []api.ClaimValidationRule{
+ {Expression: "claims.foo"},
+ },
+ structuredAuthnFeatureEnabled: true,
+ want: `issuer.claimValidationRules[0].expression: Invalid value: "claims.foo": must evaluate to bool`,
+ },
+ {
+ name: "valid claim validation rule with expression",
+ in: []api.ClaimValidationRule{
+ {Expression: "claims.foo == 'bar'"},
+ },
+ structuredAuthnFeatureEnabled: true,
+ want: "",
+ wantCELMapper: true,
},
{
name: "valid claim validation rule with multiple rules",
@@ -345,16 +451,21 @@ func TestClaimValidationRules(t *testing.T) {
{Claim: "claim1", RequiredValue: "value1"},
{Claim: "claim2", RequiredValue: "value2"},
},
- want: "",
+ structuredAuthnFeatureEnabled: true,
+ want: "",
},
}
for _, tt := range testCases {
t.Run(tt.name, func(t *testing.T) {
- got := validateClaimValidationRules(tt.in, fldPath).ToAggregate()
+ celMapper := &authenticationcel.CELMapper{}
+ got := validateClaimValidationRules(compiler, celMapper, tt.in, fldPath, tt.structuredAuthnFeatureEnabled).ToAggregate()
if d := cmp.Diff(tt.want, errString(got)); d != "" {
t.Fatalf("ClaimValidationRules validation mismatch (-want +got):\n%s", d)
}
+ if tt.wantCELMapper && celMapper.ClaimValidationRules == nil {
+ t.Fatalf("ClaimValidationRules validation mismatch: CELMapper.ClaimValidationRules is nil")
+ }
})
}
}
@@ -363,52 +474,421 @@ func TestValidateClaimMappings(t *testing.T) {
fldPath := field.NewPath("issuer", "claimMappings")
testCases := []struct {
- name string
- in api.ClaimMappings
- want string
+ name string
+ in api.ClaimMappings
+ structuredAuthnFeatureEnabled bool
+ want string
+ wantCELMapper bool
}{
{
- name: "username claim is empty",
- in: api.ClaimMappings{Username: api.PrefixedClaimOrExpression{Claim: "", Prefix: pointer.String("prefix")}},
- want: "issuer.claimMappings.username.claim: Required value: claim name is required",
- },
- {
- name: "username prefix is empty",
- in: api.ClaimMappings{Username: api.PrefixedClaimOrExpression{Claim: "claim"}},
- want: "issuer.claimMappings.username.prefix: Required value: prefix is required",
- },
- {
- name: "groups prefix is empty",
+ name: "username expression and claim are set",
in: api.ClaimMappings{
- Username: api.PrefixedClaimOrExpression{Claim: "claim", Prefix: pointer.String("prefix")},
- Groups: api.PrefixedClaimOrExpression{Claim: "claim"},
+ Username: api.PrefixedClaimOrExpression{
+ Claim: "claim",
+ Expression: "claims.username",
+ },
},
- want: "issuer.claimMappings.groups.prefix: Required value: prefix is required when claim is set",
+ structuredAuthnFeatureEnabled: true,
+ want: `issuer.claimMappings.username: Invalid value: "": claim and expression can't both be set`,
},
{
- name: "groups prefix set but claim is empty",
+ name: "username expression and claim are empty",
+ in: api.ClaimMappings{Username: api.PrefixedClaimOrExpression{}},
+ structuredAuthnFeatureEnabled: true,
+ want: "issuer.claimMappings.username: Required value: claim or expression is required",
+ },
+ {
+ name: "username prefix set when expression is set",
in: api.ClaimMappings{
- Username: api.PrefixedClaimOrExpression{Claim: "claim", Prefix: pointer.String("prefix")},
- Groups: api.PrefixedClaimOrExpression{Prefix: pointer.String("prefix")},
+ Username: api.PrefixedClaimOrExpression{
+ Expression: "claims.username",
+ Prefix: pointer.String("prefix"),
+ },
},
- want: "issuer.claimMappings.groups.claim: Required value: non-empty claim name is required when prefix is set",
+ structuredAuthnFeatureEnabled: true,
+ want: `issuer.claimMappings.username.prefix: Invalid value: "prefix": prefix can't be set when expression is set`,
+ },
+ {
+ name: "username prefix is nil when claim is set",
+ in: api.ClaimMappings{
+ Username: api.PrefixedClaimOrExpression{
+ Claim: "claim",
+ },
+ },
+ structuredAuthnFeatureEnabled: true,
+ want: `issuer.claimMappings.username.prefix: Required value: prefix is required when claim is set. It can be set to an empty string to disable prefixing`,
+ },
+ {
+ name: "username expression is invalid",
+ in: api.ClaimMappings{
+ Username: api.PrefixedClaimOrExpression{
+ Expression: "foo.bar",
+ },
+ },
+ structuredAuthnFeatureEnabled: true,
+ want: `issuer.claimMappings.username.expression: Invalid value: "foo.bar": compilation failed: ERROR: :1:1: undeclared reference to 'foo' (in container '')
+ | foo.bar
+ | ^`,
+ },
+ {
+ name: "groups expression and claim are set",
+ in: api.ClaimMappings{
+ Username: api.PrefixedClaimOrExpression{
+ Claim: "claim",
+ Prefix: pointer.String("prefix"),
+ },
+ Groups: api.PrefixedClaimOrExpression{
+ Claim: "claim",
+ Expression: "claims.groups",
+ },
+ },
+ structuredAuthnFeatureEnabled: true,
+ want: `issuer.claimMappings.groups: Invalid value: "": claim and expression can't both be set`,
+ },
+ {
+ name: "groups prefix set when expression is set",
+ in: api.ClaimMappings{
+ Username: api.PrefixedClaimOrExpression{
+ Claim: "claim",
+ Prefix: pointer.String("prefix"),
+ },
+ Groups: api.PrefixedClaimOrExpression{
+ Expression: "claims.groups",
+ Prefix: pointer.String("prefix"),
+ },
+ },
+ structuredAuthnFeatureEnabled: true,
+ want: `issuer.claimMappings.groups.prefix: Invalid value: "prefix": prefix can't be set when expression is set`,
+ },
+ {
+ name: "groups prefix is nil when claim is set",
+ in: api.ClaimMappings{
+ Username: api.PrefixedClaimOrExpression{
+ Claim: "claim",
+ Prefix: pointer.String("prefix"),
+ },
+ Groups: api.PrefixedClaimOrExpression{
+ Claim: "claim",
+ },
+ },
+ structuredAuthnFeatureEnabled: true,
+ want: `issuer.claimMappings.groups.prefix: Required value: prefix is required when claim is set. It can be set to an empty string to disable prefixing`,
+ },
+ {
+ name: "groups expression is invalid",
+ in: api.ClaimMappings{
+ Username: api.PrefixedClaimOrExpression{
+ Claim: "claim",
+ Prefix: pointer.String("prefix"),
+ },
+ Groups: api.PrefixedClaimOrExpression{
+ Expression: "foo.bar",
+ },
+ },
+ structuredAuthnFeatureEnabled: true,
+ want: `issuer.claimMappings.groups.expression: Invalid value: "foo.bar": compilation failed: ERROR: :1:1: undeclared reference to 'foo' (in container '')
+ | foo.bar
+ | ^`,
+ },
+ {
+ name: "uid claim and expression are set",
+ in: api.ClaimMappings{
+ Username: api.PrefixedClaimOrExpression{
+ Claim: "claim",
+ Prefix: pointer.String("prefix"),
+ },
+ UID: api.ClaimOrExpression{
+ Claim: "claim",
+ Expression: "claims.uid",
+ },
+ },
+ structuredAuthnFeatureEnabled: true,
+ want: `issuer.claimMappings.uid: Invalid value: "": claim and expression can't both be set`,
+ },
+ {
+ name: "uid expression is invalid",
+ in: api.ClaimMappings{
+ Username: api.PrefixedClaimOrExpression{
+ Claim: "claim",
+ Prefix: pointer.String("prefix"),
+ },
+ UID: api.ClaimOrExpression{
+ Expression: "foo.bar",
+ },
+ },
+ structuredAuthnFeatureEnabled: true,
+ want: `issuer.claimMappings.uid.expression: Invalid value: "foo.bar": compilation failed: ERROR: :1:1: undeclared reference to 'foo' (in container '')
+ | foo.bar
+ | ^`,
+ },
+ {
+ name: "extra mapping key is empty",
+ in: api.ClaimMappings{
+ Username: api.PrefixedClaimOrExpression{
+ Claim: "claim",
+ Prefix: pointer.String("prefix"),
+ },
+ Extra: []api.ExtraMapping{
+ {Key: "", ValueExpression: "claims.extra"},
+ },
+ },
+ structuredAuthnFeatureEnabled: true,
+ want: `issuer.claimMappings.extra[0].key: Required value`,
+ },
+ {
+ name: "extra mapping value expression is empty",
+ in: api.ClaimMappings{
+ Username: api.PrefixedClaimOrExpression{
+ Claim: "claim",
+ Prefix: pointer.String("prefix"),
+ },
+ Extra: []api.ExtraMapping{
+ {Key: "example.org/foo", ValueExpression: ""},
+ },
+ },
+ structuredAuthnFeatureEnabled: true,
+ want: `issuer.claimMappings.extra[0].valueExpression: Required value: valueExpression is required`,
+ },
+ {
+ name: "extra mapping value expression is invalid",
+ in: api.ClaimMappings{
+ Username: api.PrefixedClaimOrExpression{
+ Claim: "claim",
+ Prefix: pointer.String("prefix"),
+ },
+ Extra: []api.ExtraMapping{
+ {Key: "example.org/foo", ValueExpression: "foo.bar"},
+ },
+ },
+ structuredAuthnFeatureEnabled: true,
+ want: `issuer.claimMappings.extra[0].valueExpression: Invalid value: "foo.bar": compilation failed: ERROR: :1:1: undeclared reference to 'foo' (in container '')
+ | foo.bar
+ | ^`,
+ },
+ {
+ name: "username expression is invalid when structured authn feature is disabled",
+ in: api.ClaimMappings{
+ Username: api.PrefixedClaimOrExpression{
+ Expression: "foo.bar",
+ },
+ },
+ structuredAuthnFeatureEnabled: false,
+ want: `[issuer.claimMappings.username.expression: Invalid value: "foo.bar": expression is not supported when StructuredAuthenticationConfiguration feature gate is disabled, issuer.claimMappings.username.expression: Invalid value: "foo.bar": compilation failed: ERROR: :1:1: undeclared reference to 'foo' (in container '')
+ | foo.bar
+ | ^]`,
+ },
+ {
+ name: "groups expression is invalid when structured authn feature is disabled",
+ in: api.ClaimMappings{
+ Username: api.PrefixedClaimOrExpression{
+ Claim: "claim",
+ Prefix: pointer.String("prefix"),
+ },
+ Groups: api.PrefixedClaimOrExpression{
+ Expression: "foo.bar",
+ },
+ },
+ structuredAuthnFeatureEnabled: false,
+ want: `[issuer.claimMappings.groups.expression: Invalid value: "foo.bar": expression is not supported when StructuredAuthenticationConfiguration feature gate is disabled, issuer.claimMappings.groups.expression: Invalid value: "foo.bar": compilation failed: ERROR: :1:1: undeclared reference to 'foo' (in container '')
+ | foo.bar
+ | ^]`,
+ },
+ {
+ name: "uid expression is invalid when structured authn feature is disabled",
+ in: api.ClaimMappings{
+ Username: api.PrefixedClaimOrExpression{
+ Claim: "claim",
+ Prefix: pointer.String("prefix"),
+ },
+ UID: api.ClaimOrExpression{
+ Expression: "foo.bar",
+ },
+ },
+ structuredAuthnFeatureEnabled: false,
+ want: `[issuer.claimMappings.uid: Invalid value: "": uid claim mapping is not supported when StructuredAuthenticationConfiguration feature gate is disabled, issuer.claimMappings.uid.expression: Invalid value: "foo.bar": compilation failed: ERROR: :1:1: undeclared reference to 'foo' (in container '')
+ | foo.bar
+ | ^]`,
+ },
+ {
+ name: "uid claim is invalid when structured authn feature is disabled",
+ in: api.ClaimMappings{
+ Username: api.PrefixedClaimOrExpression{
+ Claim: "claim",
+ Prefix: pointer.String("prefix"),
+ },
+ UID: api.ClaimOrExpression{
+ Claim: "claim",
+ },
+ },
+ structuredAuthnFeatureEnabled: false,
+ want: `issuer.claimMappings.uid: Invalid value: "": uid claim mapping is not supported when StructuredAuthenticationConfiguration feature gate is disabled`,
+ },
+ {
+ name: "extra mapping is invalid when structured authn feature is disabled",
+ in: api.ClaimMappings{
+ Username: api.PrefixedClaimOrExpression{
+ Claim: "claim",
+ Prefix: pointer.String("prefix"),
+ },
+ Extra: []api.ExtraMapping{
+ {Key: "example.org/foo", ValueExpression: "claims.extra"},
+ },
+ },
+ structuredAuthnFeatureEnabled: false,
+ want: `issuer.claimMappings.extra: Invalid value: "": extra claim mapping is not supported when StructuredAuthenticationConfiguration feature gate is disabled`,
+ },
+ {
+ name: "duplicate extra mapping key",
+ in: api.ClaimMappings{
+ Username: api.PrefixedClaimOrExpression{Expression: "claims.username"},
+ Groups: api.PrefixedClaimOrExpression{Expression: "claims.groups"},
+ Extra: []api.ExtraMapping{
+ {Key: "example.org/foo", ValueExpression: "claims.extra"},
+ {Key: "example.org/foo", ValueExpression: "claims.extras"},
+ },
+ },
+ structuredAuthnFeatureEnabled: true,
+ want: `issuer.claimMappings.extra[1].key: Duplicate value: "example.org/foo"`,
+ },
+ {
+ name: "extra mapping key is not domain prefix path",
+ in: api.ClaimMappings{
+ Username: api.PrefixedClaimOrExpression{Expression: "claims.username"},
+ Groups: api.PrefixedClaimOrExpression{Expression: "claims.groups"},
+ Extra: []api.ExtraMapping{
+ {Key: "foo", ValueExpression: "claims.extra"},
+ },
+ },
+ structuredAuthnFeatureEnabled: true,
+ want: `issuer.claimMappings.extra[0].key: Invalid value: "foo": must be a domain-prefixed path (such as "acme.io/foo")`,
+ },
+ {
+ name: "extra mapping key is not lower case",
+ in: api.ClaimMappings{
+ Username: api.PrefixedClaimOrExpression{Expression: "claims.username"},
+ Groups: api.PrefixedClaimOrExpression{Expression: "claims.groups"},
+ Extra: []api.ExtraMapping{
+ {Key: "example.org/Foo", ValueExpression: "claims.extra"},
+ },
+ },
+ structuredAuthnFeatureEnabled: true,
+ want: `issuer.claimMappings.extra[0].key: Invalid value: "example.org/Foo": key must be lowercase`,
},
{
name: "valid claim mappings",
in: api.ClaimMappings{
- Username: api.PrefixedClaimOrExpression{Claim: "claim", Prefix: pointer.String("prefix")},
- Groups: api.PrefixedClaimOrExpression{Claim: "claim", Prefix: pointer.String("prefix")},
+ Username: api.PrefixedClaimOrExpression{Expression: "claims.username"},
+ Groups: api.PrefixedClaimOrExpression{Expression: "claims.groups"},
+ UID: api.ClaimOrExpression{Expression: "claims.uid"},
+ Extra: []api.ExtraMapping{
+ {Key: "example.org/foo", ValueExpression: "claims.extra"},
+ },
},
- want: "",
+ structuredAuthnFeatureEnabled: true,
+ wantCELMapper: true,
+ want: "",
},
}
for _, tt := range testCases {
t.Run(tt.name, func(t *testing.T) {
- got := validateClaimMappings(tt.in, fldPath).ToAggregate()
+ celMapper := &authenticationcel.CELMapper{}
+ got := validateClaimMappings(compiler, celMapper, tt.in, fldPath, tt.structuredAuthnFeatureEnabled).ToAggregate()
if d := cmp.Diff(tt.want, errString(got)); d != "" {
+ fmt.Println(errString(got))
t.Fatalf("ClaimMappings validation mismatch (-want +got):\n%s", d)
}
+ if tt.wantCELMapper {
+ if len(tt.in.Username.Expression) > 0 && celMapper.Username == nil {
+ t.Fatalf("ClaimMappings validation mismatch: CELMapper.Username is nil")
+ }
+ if len(tt.in.Groups.Expression) > 0 && celMapper.Groups == nil {
+ t.Fatalf("ClaimMappings validation mismatch: CELMapper.Groups is nil")
+ }
+ if len(tt.in.UID.Expression) > 0 && celMapper.UID == nil {
+ t.Fatalf("ClaimMappings validation mismatch: CELMapper.UID is nil")
+ }
+ if len(tt.in.Extra) > 0 && celMapper.Extra == nil {
+ t.Fatalf("ClaimMappings validation mismatch: CELMapper.Extra is nil")
+ }
+ }
+ })
+ }
+}
+
+func TestValidateUserValidationRules(t *testing.T) {
+ fldPath := field.NewPath("issuer", "userValidationRules")
+
+ testCases := []struct {
+ name string
+ in []api.UserValidationRule
+ structuredAuthnFeatureEnabled bool
+ want string
+ wantCELMapper bool
+ }{
+ {
+ name: "user info validation rule, expression is empty",
+ in: []api.UserValidationRule{{}},
+ structuredAuthnFeatureEnabled: true,
+ want: "issuer.userValidationRules[0].expression: Required value: expression is required",
+ },
+ {
+ name: "duplicate expression",
+ in: []api.UserValidationRule{
+ {Expression: "user.username == 'foo'"},
+ {Expression: "user.username == 'foo'"},
+ },
+ structuredAuthnFeatureEnabled: true,
+ want: `issuer.userValidationRules[1].expression: Duplicate value: "user.username == 'foo'"`,
+ },
+ {
+ name: "user validation rule is invalid when structured authn feature is disabled",
+ in: []api.UserValidationRule{
+ {Expression: "user.username == 'foo'"},
+ },
+ structuredAuthnFeatureEnabled: false,
+ want: `issuer.userValidationRules: Invalid value: "": user validation rules are not supported when StructuredAuthenticationConfiguration feature gate is disabled`,
+ },
+ {
+ name: "expression is invalid",
+ in: []api.UserValidationRule{
+ {Expression: "foo.bar"},
+ },
+ structuredAuthnFeatureEnabled: true,
+ want: `issuer.userValidationRules[0].expression: Invalid value: "foo.bar": compilation failed: ERROR: :1:1: undeclared reference to 'foo' (in container '')
+ | foo.bar
+ | ^`,
+ },
+ {
+ name: "expression does not return bool",
+ in: []api.UserValidationRule{
+ {Expression: "user.username"},
+ },
+ structuredAuthnFeatureEnabled: true,
+ want: `issuer.userValidationRules[0].expression: Invalid value: "user.username": must evaluate to bool`,
+ },
+ {
+ name: "valid user info validation rule",
+ in: []api.UserValidationRule{
+ {Expression: "user.username == 'foo'"},
+ {Expression: "!user.username.startsWith('system:')", Message: "username cannot used reserved system: prefix"},
+ },
+ structuredAuthnFeatureEnabled: true,
+ want: "",
+ wantCELMapper: true,
+ },
+ }
+
+ for _, tt := range testCases {
+ t.Run(tt.name, func(t *testing.T) {
+ celMapper := &authenticationcel.CELMapper{}
+ got := validateUserValidationRules(compiler, celMapper, tt.in, fldPath, tt.structuredAuthnFeatureEnabled).ToAggregate()
+ if d := cmp.Diff(tt.want, errString(got)); d != "" {
+ t.Fatalf("UserValidationRules validation mismatch (-want +got):\n%s", d)
+ }
+ if tt.wantCELMapper && celMapper.UserValidationRules == nil {
+ t.Fatalf("UserValidationRules validation mismatch: CELMapper.UserValidationRules is nil")
+ }
})
}
}
diff --git a/staging/src/k8s.io/apiserver/pkg/apis/apiserver/zz_generated.deepcopy.go b/staging/src/k8s.io/apiserver/pkg/apis/apiserver/zz_generated.deepcopy.go
index 87b41f7ef6b..77e5c314219 100644
--- a/staging/src/k8s.io/apiserver/pkg/apis/apiserver/zz_generated.deepcopy.go
+++ b/staging/src/k8s.io/apiserver/pkg/apis/apiserver/zz_generated.deepcopy.go
@@ -168,6 +168,12 @@ func (in *ClaimMappings) DeepCopyInto(out *ClaimMappings) {
*out = *in
in.Username.DeepCopyInto(&out.Username)
in.Groups.DeepCopyInto(&out.Groups)
+ out.UID = in.UID
+ if in.Extra != nil {
+ in, out := &in.Extra, &out.Extra
+ *out = make([]ExtraMapping, len(*in))
+ copy(*out, *in)
+ }
return
}
@@ -181,6 +187,22 @@ func (in *ClaimMappings) DeepCopy() *ClaimMappings {
return out
}
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *ClaimOrExpression) DeepCopyInto(out *ClaimOrExpression) {
+ *out = *in
+ return
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ClaimOrExpression.
+func (in *ClaimOrExpression) DeepCopy() *ClaimOrExpression {
+ if in == nil {
+ return nil
+ }
+ out := new(ClaimOrExpression)
+ in.DeepCopyInto(out)
+ return out
+}
+
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *ClaimValidationRule) DeepCopyInto(out *ClaimValidationRule) {
*out = *in
@@ -267,6 +289,22 @@ func (in *EgressSelectorConfiguration) DeepCopyObject() runtime.Object {
return nil
}
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *ExtraMapping) DeepCopyInto(out *ExtraMapping) {
+ *out = *in
+ return
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ExtraMapping.
+func (in *ExtraMapping) DeepCopy() *ExtraMapping {
+ if in == nil {
+ return nil
+ }
+ out := new(ExtraMapping)
+ in.DeepCopyInto(out)
+ return out
+}
+
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *Issuer) DeepCopyInto(out *Issuer) {
*out = *in
@@ -298,6 +336,11 @@ func (in *JWTAuthenticator) DeepCopyInto(out *JWTAuthenticator) {
copy(*out, *in)
}
in.ClaimMappings.DeepCopyInto(&out.ClaimMappings)
+ if in.UserValidationRules != nil {
+ in, out := &in.UserValidationRules, &out.UserValidationRules
+ *out = make([]UserValidationRule, len(*in))
+ copy(*out, *in)
+ }
return
}
@@ -437,6 +480,22 @@ func (in *UDSTransport) DeepCopy() *UDSTransport {
return out
}
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *UserValidationRule) DeepCopyInto(out *UserValidationRule) {
+ *out = *in
+ return
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new UserValidationRule.
+func (in *UserValidationRule) DeepCopy() *UserValidationRule {
+ if in == nil {
+ return nil
+ }
+ out := new(UserValidationRule)
+ in.DeepCopyInto(out)
+ return out
+}
+
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *WebhookConfiguration) DeepCopyInto(out *WebhookConfiguration) {
*out = *in
diff --git a/staging/src/k8s.io/apiserver/pkg/authentication/cel/compile.go b/staging/src/k8s.io/apiserver/pkg/authentication/cel/compile.go
new file mode 100644
index 00000000000..3bcff5e9051
--- /dev/null
+++ b/staging/src/k8s.io/apiserver/pkg/authentication/cel/compile.go
@@ -0,0 +1,154 @@
+/*
+Copyright 2023 The Kubernetes Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package cel
+
+import (
+ "fmt"
+
+ "github.com/google/cel-go/cel"
+
+ "k8s.io/apimachinery/pkg/util/version"
+ apiservercel "k8s.io/apiserver/pkg/cel"
+ "k8s.io/apiserver/pkg/cel/environment"
+)
+
+const (
+ claimsVarName = "claims"
+ userVarName = "user"
+)
+
+// compiler implements the Compiler interface.
+type compiler struct {
+ // varEnvs is a map of CEL environments, keyed by the name of the CEL variable.
+ // The CEL variable is available to the expression.
+ // We have 2 environments, one for claims and one for user.
+ varEnvs map[string]*environment.EnvSet
+}
+
+// NewCompiler returns a new Compiler.
+func NewCompiler(env *environment.EnvSet) Compiler {
+ return &compiler{
+ varEnvs: mustBuildEnvs(env),
+ }
+}
+
+// CompileClaimsExpression compiles the given expressionAccessor into a CEL program that can be evaluated.
+// The claims CEL variable is available to the expression.
+func (c compiler) CompileClaimsExpression(expressionAccessor ExpressionAccessor) (CompilationResult, error) {
+ return c.compile(expressionAccessor, claimsVarName)
+}
+
+// CompileUserExpression compiles the given expressionAccessor into a CEL program that can be evaluated.
+// The user CEL variable is available to the expression.
+func (c compiler) CompileUserExpression(expressionAccessor ExpressionAccessor) (CompilationResult, error) {
+ return c.compile(expressionAccessor, userVarName)
+}
+
+func (c compiler) compile(expressionAccessor ExpressionAccessor, envVarName string) (CompilationResult, error) {
+ resultError := func(errorString string, errType apiservercel.ErrorType) (CompilationResult, error) {
+ return CompilationResult{}, &apiservercel.Error{
+ Type: errType,
+ Detail: errorString,
+ }
+ }
+
+ env, err := c.varEnvs[envVarName].Env(environment.StoredExpressions)
+ if err != nil {
+ return resultError(fmt.Sprintf("unexpected error loading CEL environment: %v", err), apiservercel.ErrorTypeInternal)
+ }
+
+ ast, issues := env.Compile(expressionAccessor.GetExpression())
+ if issues != nil {
+ return resultError("compilation failed: "+issues.String(), apiservercel.ErrorTypeInvalid)
+ }
+
+ found := false
+ returnTypes := expressionAccessor.ReturnTypes()
+ for _, returnType := range returnTypes {
+ if ast.OutputType() == returnType || cel.AnyType == returnType {
+ found = true
+ break
+ }
+ }
+ if !found {
+ var reason string
+ if len(returnTypes) == 1 {
+ reason = fmt.Sprintf("must evaluate to %v", returnTypes[0].String())
+ } else {
+ reason = fmt.Sprintf("must evaluate to one of %v", returnTypes)
+ }
+
+ return resultError(reason, apiservercel.ErrorTypeInvalid)
+ }
+
+ if _, err = cel.AstToCheckedExpr(ast); err != nil {
+ // should be impossible since env.Compile returned no issues
+ return resultError("unexpected compilation error: "+err.Error(), apiservercel.ErrorTypeInternal)
+ }
+ prog, err := env.Program(ast)
+ if err != nil {
+ return resultError("program instantiation failed: "+err.Error(), apiservercel.ErrorTypeInternal)
+ }
+
+ return CompilationResult{
+ Program: prog,
+ ExpressionAccessor: expressionAccessor,
+ }, nil
+}
+
+func buildUserType() *apiservercel.DeclType {
+ field := func(name string, declType *apiservercel.DeclType, required bool) *apiservercel.DeclField {
+ return apiservercel.NewDeclField(name, declType, required, nil, nil)
+ }
+ fields := func(fields ...*apiservercel.DeclField) map[string]*apiservercel.DeclField {
+ result := make(map[string]*apiservercel.DeclField, len(fields))
+ for _, f := range fields {
+ result[f.Name] = f
+ }
+ return result
+ }
+
+ return apiservercel.NewObjectType("kubernetes.UserInfo", fields(
+ field("username", apiservercel.StringType, false),
+ field("uid", apiservercel.StringType, false),
+ field("groups", apiservercel.NewListType(apiservercel.StringType, -1), false),
+ field("extra", apiservercel.NewMapType(apiservercel.StringType, apiservercel.NewListType(apiservercel.StringType, -1), -1), false),
+ ))
+}
+
+func mustBuildEnvs(baseEnv *environment.EnvSet) map[string]*environment.EnvSet {
+ buildEnvSet := func(envOpts []cel.EnvOption, declTypes []*apiservercel.DeclType) *environment.EnvSet {
+ env, err := baseEnv.Extend(environment.VersionedOptions{
+ IntroducedVersion: version.MajorMinor(1, 0),
+ EnvOptions: envOpts,
+ DeclTypes: declTypes,
+ })
+ if err != nil {
+ panic(fmt.Sprintf("environment misconfigured: %v", err))
+ }
+ return env
+ }
+
+ userType := buildUserType()
+ claimsType := apiservercel.NewMapType(apiservercel.StringType, apiservercel.AnyType, -1)
+
+ envs := make(map[string]*environment.EnvSet, 2) // build two environments, one for claims and one for user
+ envs[claimsVarName] = buildEnvSet([]cel.EnvOption{cel.Variable(claimsVarName, claimsType.CelType())}, []*apiservercel.DeclType{claimsType})
+ envs[userVarName] = buildEnvSet([]cel.EnvOption{cel.Variable(userVarName, userType.CelType())}, []*apiservercel.DeclType{userType})
+
+ return envs
+}
diff --git a/staging/src/k8s.io/apiserver/pkg/authentication/cel/compile_test.go b/staging/src/k8s.io/apiserver/pkg/authentication/cel/compile_test.go
new file mode 100644
index 00000000000..c8659aa830b
--- /dev/null
+++ b/staging/src/k8s.io/apiserver/pkg/authentication/cel/compile_test.go
@@ -0,0 +1,263 @@
+/*
+Copyright 2023 The Kubernetes Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package cel
+
+import (
+ "reflect"
+ "strings"
+ "testing"
+
+ authenticationv1 "k8s.io/api/authentication/v1"
+ apiservercel "k8s.io/apiserver/pkg/cel"
+ "k8s.io/apiserver/pkg/cel/environment"
+)
+
+func TestCompileClaimsExpression(t *testing.T) {
+ testCases := []struct {
+ name string
+ expressionAccessors []ExpressionAccessor
+ }{
+ {
+ name: "valid ClaimMappingCondition",
+ expressionAccessors: []ExpressionAccessor{
+ &ClaimMappingExpression{
+ Expression: "claims.foo",
+ },
+ },
+ },
+ {
+ name: "valid ClaimValidationCondition",
+ expressionAccessors: []ExpressionAccessor{
+ &ClaimValidationCondition{
+ Expression: "claims.foo == 'bar'",
+ },
+ },
+ },
+ {
+ name: "valid ExtraMapppingCondition",
+ expressionAccessors: []ExpressionAccessor{
+ &ExtraMappingExpression{
+ Expression: "claims.foo",
+ },
+ },
+ },
+ }
+
+ compiler := NewCompiler(environment.MustBaseEnvSet(environment.DefaultCompatibilityVersion()))
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ for _, expressionAccessor := range tc.expressionAccessors {
+ _, err := compiler.CompileClaimsExpression(expressionAccessor)
+ if err != nil {
+ t.Errorf("unexpected error: %v", err)
+ }
+ }
+ })
+ }
+}
+
+func TestCompileUserExpression(t *testing.T) {
+ testCases := []struct {
+ name string
+ expressionAccessors []ExpressionAccessor
+ }{
+ {
+ name: "valid UserValidationCondition",
+ expressionAccessors: []ExpressionAccessor{
+ &ExtraMappingExpression{
+ Expression: "user.username == 'bar'",
+ },
+ },
+ },
+ }
+
+ compiler := NewCompiler(environment.MustBaseEnvSet(environment.DefaultCompatibilityVersion()))
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ for _, expressionAccessor := range tc.expressionAccessors {
+ _, err := compiler.CompileUserExpression(expressionAccessor)
+ if err != nil {
+ t.Errorf("unexpected error: %v", err)
+ }
+ }
+ })
+ }
+}
+
+func TestCompileClaimsExpressionError(t *testing.T) {
+ testCases := []struct {
+ name string
+ expressionAccessors []ExpressionAccessor
+ wantErr string
+ }{
+ {
+ name: "invalid ClaimValidationCondition",
+ expressionAccessors: []ExpressionAccessor{
+ &ClaimValidationCondition{
+ Expression: "claims.foo",
+ },
+ },
+ wantErr: "must evaluate to bool",
+ },
+ {
+ name: "UserValidationCondition with wrong env",
+ expressionAccessors: []ExpressionAccessor{
+ &UserValidationCondition{
+ Expression: "user.username == 'foo'",
+ },
+ },
+ wantErr: `compilation failed: ERROR: :1:1: undeclared reference to 'user' (in container '')`,
+ },
+ {
+ name: "invalid ClaimMappingCondition",
+ expressionAccessors: []ExpressionAccessor{
+ &ClaimMappingExpression{
+ Expression: "claims + 1",
+ },
+ },
+ wantErr: `compilation failed: ERROR: :1:8: found no matching overload for '_+_' applied to '(map(string, any), int)'`,
+ },
+ }
+
+ compiler := NewCompiler(environment.MustBaseEnvSet(environment.DefaultCompatibilityVersion()))
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ for _, expressionAccessor := range tc.expressionAccessors {
+ _, err := compiler.CompileClaimsExpression(expressionAccessor)
+ if err == nil {
+ t.Errorf("expected error but got nil")
+ }
+ if !strings.Contains(err.Error(), tc.wantErr) {
+ t.Errorf("expected error to contain %q but got %q", tc.wantErr, err.Error())
+ }
+ }
+ })
+ }
+}
+
+func TestCompileUserExpressionError(t *testing.T) {
+ testCases := []struct {
+ name string
+ expressionAccessors []ExpressionAccessor
+ wantErr string
+ }{
+ {
+ name: "invalid UserValidationCondition",
+ expressionAccessors: []ExpressionAccessor{
+ &UserValidationCondition{
+ Expression: "user.username",
+ },
+ },
+ wantErr: "must evaluate to bool",
+ },
+ {
+ name: "ClamMappingCondition with wrong env",
+ expressionAccessors: []ExpressionAccessor{
+ &ClaimMappingExpression{
+ Expression: "claims.foo",
+ },
+ },
+ wantErr: `compilation failed: ERROR: :1:1: undeclared reference to 'claims' (in container '')`,
+ },
+ {
+ name: "ExtraMappingCondition with wrong env",
+ expressionAccessors: []ExpressionAccessor{
+ &ExtraMappingExpression{
+ Expression: "claims.foo",
+ },
+ },
+ wantErr: `compilation failed: ERROR: :1:1: undeclared reference to 'claims' (in container '')`,
+ },
+ {
+ name: "ClaimValidationCondition with wrong env",
+ expressionAccessors: []ExpressionAccessor{
+ &ClaimValidationCondition{
+ Expression: "claims.foo == 'bar'",
+ },
+ },
+ wantErr: `compilation failed: ERROR: :1:1: undeclared reference to 'claims' (in container '')`,
+ },
+ {
+ name: "UserValidationCondition expression with unknown field",
+ expressionAccessors: []ExpressionAccessor{
+ &UserValidationCondition{
+ Expression: "user.unknown == 'foo'",
+ },
+ },
+ wantErr: `compilation failed: ERROR: :1:5: undefined field 'unknown'`,
+ },
+ }
+
+ compiler := NewCompiler(environment.MustBaseEnvSet(environment.DefaultCompatibilityVersion()))
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ for _, expressionAccessor := range tc.expressionAccessors {
+ _, err := compiler.CompileUserExpression(expressionAccessor)
+ if err == nil {
+ t.Errorf("expected error but got nil")
+ }
+ if !strings.Contains(err.Error(), tc.wantErr) {
+ t.Errorf("expected error to contain %q but got %q", tc.wantErr, err.Error())
+ }
+ }
+ })
+ }
+}
+
+func TestBuildUserType(t *testing.T) {
+ userDeclType := buildUserType()
+ userType := reflect.TypeOf(authenticationv1.UserInfo{})
+
+ if len(userDeclType.Fields) != userType.NumField() {
+ t.Errorf("expected %d fields, got %d", userType.NumField(), len(userDeclType.Fields))
+ }
+
+ for i := 0; i < userType.NumField(); i++ {
+ field := userType.Field(i)
+ jsonTagParts := strings.Split(field.Tag.Get("json"), ",")
+ if len(jsonTagParts) < 1 {
+ t.Fatal("expected json tag to be present")
+ }
+ fieldName := jsonTagParts[0]
+
+ declField, ok := userDeclType.Fields[fieldName]
+ if !ok {
+ t.Errorf("expected field %q to be present", field.Name)
+ }
+ if nativeTypeToCELType(t, field.Type).CelType().Equal(declField.Type.CelType()).Value() != true {
+ t.Errorf("expected field %q to have type %v, got %v", field.Name, field.Type, declField.Type)
+ }
+ }
+}
+
+func nativeTypeToCELType(t *testing.T, nativeType reflect.Type) *apiservercel.DeclType {
+ switch nativeType {
+ case reflect.TypeOf(""):
+ return apiservercel.StringType
+ case reflect.TypeOf([]string{}):
+ return apiservercel.NewListType(apiservercel.StringType, -1)
+ case reflect.TypeOf(map[string]authenticationv1.ExtraValue{}):
+ return apiservercel.NewMapType(apiservercel.StringType, apiservercel.AnyType, -1)
+ default:
+ t.Fatalf("unsupported type %v", nativeType)
+ }
+ return nil
+}
diff --git a/staging/src/k8s.io/apiserver/pkg/authentication/cel/interface.go b/staging/src/k8s.io/apiserver/pkg/authentication/cel/interface.go
new file mode 100644
index 00000000000..7ec0c9af6af
--- /dev/null
+++ b/staging/src/k8s.io/apiserver/pkg/authentication/cel/interface.go
@@ -0,0 +1,147 @@
+/*
+Copyright 2023 The Kubernetes Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+// Package cel contains the CEL related interfaces and structs for authentication.
+package cel
+
+import (
+ "context"
+
+ celgo "github.com/google/cel-go/cel"
+ "github.com/google/cel-go/common/types/ref"
+
+ "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
+)
+
+// ExpressionAccessor is an interface that provides access to a CEL expression.
+type ExpressionAccessor interface {
+ GetExpression() string
+ ReturnTypes() []*celgo.Type
+}
+
+// CompilationResult represents a compiled validations expression.
+type CompilationResult struct {
+ Program celgo.Program
+ ExpressionAccessor ExpressionAccessor
+}
+
+// EvaluationResult contains the minimal required fields and metadata of a cel evaluation
+type EvaluationResult struct {
+ EvalResult ref.Val
+ ExpressionAccessor ExpressionAccessor
+}
+
+// Compiler provides a CEL expression compiler configured with the desired authentication related CEL variables.
+type Compiler interface {
+ CompileClaimsExpression(expressionAccessor ExpressionAccessor) (CompilationResult, error)
+ CompileUserExpression(expressionAccessor ExpressionAccessor) (CompilationResult, error)
+}
+
+// ClaimsMapper provides a CEL expression mapper configured with the claims CEL variable.
+type ClaimsMapper interface {
+ // EvalClaimMapping evaluates the given claim mapping expression and returns a EvaluationResult.
+ // This is used for username, groups and uid claim mapping that contains a single expression.
+ EvalClaimMapping(ctx context.Context, claims *unstructured.Unstructured) (EvaluationResult, error)
+ // EvalClaimMappings evaluates the given expressions and returns a list of EvaluationResult.
+ // This is used for extra claim mapping and claim validation that contains a list of expressions.
+ EvalClaimMappings(ctx context.Context, claims *unstructured.Unstructured) ([]EvaluationResult, error)
+}
+
+// UserMapper provides a CEL expression mapper configured with the user CEL variable.
+type UserMapper interface {
+ // EvalUser evaluates the given user expressions and returns a list of EvaluationResult.
+ // This is used for user validation that contains a list of expressions.
+ EvalUser(ctx context.Context, userInfo *unstructured.Unstructured) ([]EvaluationResult, error)
+}
+
+var _ ExpressionAccessor = &ClaimMappingExpression{}
+
+// ClaimMappingExpression is a CEL expression that maps a claim.
+type ClaimMappingExpression struct {
+ Expression string
+}
+
+// GetExpression returns the CEL expression.
+func (v *ClaimMappingExpression) GetExpression() string {
+ return v.Expression
+}
+
+// ReturnTypes returns the CEL expression return types.
+func (v *ClaimMappingExpression) ReturnTypes() []*celgo.Type {
+ // return types is only used for validation. The claims variable that's available
+ // to the claim mapping expressions is a map[string]interface{}, so we can't
+ // really know what the return type is during compilation. Strict type checking
+ // is done during evaluation.
+ return []*celgo.Type{celgo.AnyType}
+}
+
+var _ ExpressionAccessor = &ClaimValidationCondition{}
+
+// ClaimValidationCondition is a CEL expression that validates a claim.
+type ClaimValidationCondition struct {
+ Expression string
+ Message string
+}
+
+// GetExpression returns the CEL expression.
+func (v *ClaimValidationCondition) GetExpression() string {
+ return v.Expression
+}
+
+// ReturnTypes returns the CEL expression return types.
+func (v *ClaimValidationCondition) ReturnTypes() []*celgo.Type {
+ return []*celgo.Type{celgo.BoolType}
+}
+
+var _ ExpressionAccessor = &ExtraMappingExpression{}
+
+// ExtraMappingExpression is a CEL expression that maps an extra to a list of values.
+type ExtraMappingExpression struct {
+ Key string
+ Expression string
+}
+
+// GetExpression returns the CEL expression.
+func (v *ExtraMappingExpression) GetExpression() string {
+ return v.Expression
+}
+
+// ReturnTypes returns the CEL expression return types.
+func (v *ExtraMappingExpression) ReturnTypes() []*celgo.Type {
+ // return types is only used for validation. The claims variable that's available
+ // to the claim mapping expressions is a map[string]interface{}, so we can't
+ // really know what the return type is during compilation. Strict type checking
+ // is done during evaluation.
+ return []*celgo.Type{celgo.AnyType}
+}
+
+var _ ExpressionAccessor = &UserValidationCondition{}
+
+// UserValidationCondition is a CEL expression that validates a User.
+type UserValidationCondition struct {
+ Expression string
+ Message string
+}
+
+// GetExpression returns the CEL expression.
+func (v *UserValidationCondition) GetExpression() string {
+ return v.Expression
+}
+
+// ReturnTypes returns the CEL expression return types.
+func (v *UserValidationCondition) ReturnTypes() []*celgo.Type {
+ return []*celgo.Type{celgo.BoolType}
+}
diff --git a/staging/src/k8s.io/apiserver/pkg/authentication/cel/mapper.go b/staging/src/k8s.io/apiserver/pkg/authentication/cel/mapper.go
new file mode 100644
index 00000000000..ab308bb7f0f
--- /dev/null
+++ b/staging/src/k8s.io/apiserver/pkg/authentication/cel/mapper.go
@@ -0,0 +1,97 @@
+/*
+Copyright 2023 The Kubernetes Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package cel
+
+import (
+ "context"
+ "fmt"
+
+ "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
+)
+
+var _ ClaimsMapper = &mapper{}
+var _ UserMapper = &mapper{}
+
+// mapper implements the ClaimsMapper and UserMapper interface.
+type mapper struct {
+ compilationResults []CompilationResult
+}
+
+// CELMapper is a struct that holds the compiled expressions for
+// username, groups, uid, extra, claimValidation and userValidation
+type CELMapper struct {
+ Username ClaimsMapper
+ Groups ClaimsMapper
+ UID ClaimsMapper
+ Extra ClaimsMapper
+ ClaimValidationRules ClaimsMapper
+ UserValidationRules UserMapper
+}
+
+// NewClaimsMapper returns a new ClaimsMapper.
+func NewClaimsMapper(compilationResults []CompilationResult) ClaimsMapper {
+ return &mapper{
+ compilationResults: compilationResults,
+ }
+}
+
+// NewUserMapper returns a new UserMapper.
+func NewUserMapper(compilationResults []CompilationResult) UserMapper {
+ return &mapper{
+ compilationResults: compilationResults,
+ }
+}
+
+// EvalClaimMapping evaluates the given claim mapping expression and returns a EvaluationResult.
+func (m *mapper) EvalClaimMapping(ctx context.Context, claims *unstructured.Unstructured) (EvaluationResult, error) {
+ results, err := m.eval(ctx, map[string]interface{}{claimsVarName: claims.Object})
+ if err != nil {
+ return EvaluationResult{}, err
+ }
+ if len(results) != 1 {
+ return EvaluationResult{}, fmt.Errorf("expected 1 evaluation result, got %d", len(results))
+ }
+ return results[0], nil
+}
+
+// EvalClaimMappings evaluates the given expressions and returns a list of EvaluationResult.
+func (m *mapper) EvalClaimMappings(ctx context.Context, claims *unstructured.Unstructured) ([]EvaluationResult, error) {
+ return m.eval(ctx, map[string]interface{}{claimsVarName: claims.Object})
+}
+
+// EvalUser evaluates the given user expressions and returns a list of EvaluationResult.
+func (m *mapper) EvalUser(ctx context.Context, userInfo *unstructured.Unstructured) ([]EvaluationResult, error) {
+ return m.eval(ctx, map[string]interface{}{userVarName: userInfo.Object})
+}
+
+func (m *mapper) eval(ctx context.Context, input map[string]interface{}) ([]EvaluationResult, error) {
+ evaluations := make([]EvaluationResult, len(m.compilationResults))
+
+ for i, compilationResult := range m.compilationResults {
+ var evaluation = &evaluations[i]
+ evaluation.ExpressionAccessor = compilationResult.ExpressionAccessor
+
+ evalResult, _, err := compilationResult.Program.ContextEval(ctx, input)
+ if err != nil {
+ return nil, fmt.Errorf("expression '%s' resulted in error: %w", compilationResult.ExpressionAccessor.GetExpression(), err)
+ }
+
+ evaluation.EvalResult = evalResult
+ }
+
+ return evaluations, nil
+}
diff --git a/staging/src/k8s.io/apiserver/plugin/pkg/authenticator/token/oidc/oidc.go b/staging/src/k8s.io/apiserver/plugin/pkg/authenticator/token/oidc/oidc.go
index 148ae79dfc6..76fc3cdd592 100644
--- a/staging/src/k8s.io/apiserver/plugin/pkg/authenticator/token/oidc/oidc.go
+++ b/staging/src/k8s.io/apiserver/plugin/pkg/authenticator/token/oidc/oidc.go
@@ -35,18 +35,25 @@ import (
"fmt"
"io/ioutil"
"net/http"
+ "reflect"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/coreos/go-oidc"
+ celgo "github.com/google/cel-go/cel"
+ "github.com/google/cel-go/common/types/ref"
+ authenticationv1 "k8s.io/api/authentication/v1"
+ "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
+ "k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/util/net"
"k8s.io/apimachinery/pkg/util/wait"
"k8s.io/apiserver/pkg/apis/apiserver"
apiservervalidation "k8s.io/apiserver/pkg/apis/apiserver/validation"
"k8s.io/apiserver/pkg/authentication/authenticator"
+ authenticationcel "k8s.io/apiserver/pkg/authentication/cel"
"k8s.io/apiserver/pkg/authentication/user"
certutil "k8s.io/client-go/util/cert"
"k8s.io/klog/v2"
@@ -165,6 +172,13 @@ type Authenticator struct {
// resolver is used to resolve distributed claims.
resolver *claimResolver
+
+ // celMapper contains the compiled CEL expressions for
+ // username, groups, uid, extra, claimMapping and claimValidation
+ celMapper authenticationcel.CELMapper
+
+ // requiredClaims contains the list of claims that must be present in the token.
+ requiredClaims map[string]string
}
func (a *Authenticator) setVerifier(v *oidc.IDTokenVerifier) {
@@ -197,7 +211,8 @@ var allowedSigningAlgs = map[string]bool{
}
func New(opts Options) (*Authenticator, error) {
- if err := apiservervalidation.ValidateJWTAuthenticator(opts.JWTAuthenticator).ToAggregate(); err != nil {
+ celMapper, fieldErr := apiservervalidation.CompileAndValidateJWTAuthenticator(opts.JWTAuthenticator)
+ if err := fieldErr.ToAggregate(); err != nil {
return nil, err
}
@@ -262,10 +277,19 @@ func New(opts Options) (*Authenticator, error) {
resolver = newClaimResolver(groupsClaim, client, verifierConfig)
}
+ requiredClaims := make(map[string]string)
+ for _, claimValidationRule := range opts.JWTAuthenticator.ClaimValidationRules {
+ if len(claimValidationRule.Claim) > 0 {
+ requiredClaims[claimValidationRule.Claim] = claimValidationRule.RequiredValue
+ }
+ }
+
authenticator := &Authenticator{
jwtAuthenticator: opts.JWTAuthenticator,
cancel: cancel,
resolver: resolver,
+ celMapper: celMapper,
+ requiredClaims: requiredClaims,
}
if opts.KeySet != nil {
@@ -521,10 +545,130 @@ func (a *Authenticator) AuthenticateToken(ctx context.Context, token string) (*a
}
}
+ var claimsUnstructured *unstructured.Unstructured
+ // Convert the claims to unstructured so that we can evaluate the CEL expressions
+ // against the claims. This is done once here so that we don't have to convert
+ // the claims to unstructured multiple times in the CEL mapper for each mapping.
+ // Only perform this conversion if any of the mapping or validation rules contain
+ // CEL expressions.
+ // TODO(aramase): In the future when we look into making distributed claims work,
+ // we should see if we can skip this function and use a dynamic type resolver for
+ // both json.RawMessage and the distributed claim fetching.
+ if a.celMapper.Username != nil || a.celMapper.Groups != nil || a.celMapper.UID != nil || a.celMapper.Extra != nil || a.celMapper.ClaimValidationRules != nil {
+ if claimsUnstructured, err = convertObjectToUnstructured(&c); err != nil {
+ return nil, false, fmt.Errorf("oidc: could not convert claims to unstructured: %w", err)
+ }
+ }
+
+ var username string
+ if username, err = a.getUsername(ctx, c, claimsUnstructured); err != nil {
+ return nil, false, err
+ }
+
+ info := &user.DefaultInfo{Name: username}
+ if info.Groups, err = a.getGroups(ctx, c, claimsUnstructured); err != nil {
+ return nil, false, err
+ }
+
+ if info.UID, err = a.getUID(ctx, c, claimsUnstructured); err != nil {
+ return nil, false, err
+ }
+
+ extra, err := a.getExtra(ctx, claimsUnstructured)
+ if err != nil {
+ return nil, false, err
+ }
+ if len(extra) > 0 {
+ info.Extra = extra
+ }
+
+ // check to ensure all required claims are present in the ID token and have matching values.
+ for claim, value := range a.requiredClaims {
+ if !c.hasClaim(claim) {
+ return nil, false, fmt.Errorf("oidc: required claim %s not present in ID token", claim)
+ }
+
+ // NOTE: Only string values are supported as valid required claim values.
+ var claimValue string
+ if err := c.unmarshalClaim(claim, &claimValue); err != nil {
+ return nil, false, fmt.Errorf("oidc: parse claim %s: %w", claim, err)
+ }
+ if claimValue != value {
+ return nil, false, fmt.Errorf("oidc: required claim %s value does not match. Got = %s, want = %s", claim, claimValue, value)
+ }
+ }
+
+ if a.celMapper.ClaimValidationRules != nil {
+ evalResult, err := a.celMapper.ClaimValidationRules.EvalClaimMappings(ctx, claimsUnstructured)
+ if err != nil {
+ return nil, false, fmt.Errorf("oidc: error evaluating claim validation expression: %w", err)
+ }
+ if err := checkValidationRulesEvaluation(evalResult, func(a authenticationcel.ExpressionAccessor) (string, error) {
+ claimValidationCondition, ok := a.(*authenticationcel.ClaimValidationCondition)
+ if !ok {
+ return "", fmt.Errorf("invalid type conversion, expected ClaimValidationCondition")
+ }
+ return claimValidationCondition.Message, nil
+ }); err != nil {
+ return nil, false, fmt.Errorf("oidc: error evaluating claim validation expression: %w", err)
+ }
+ }
+
+ if a.celMapper.UserValidationRules != nil {
+ userInfo := &authenticationv1.UserInfo{
+ Extra: make(map[string]authenticationv1.ExtraValue),
+ Groups: info.GetGroups(),
+ UID: info.GetUID(),
+ Username: info.GetName(),
+ }
+ // Convert the extra information in the user object
+ for key, val := range info.GetExtra() {
+ userInfo.Extra[key] = authenticationv1.ExtraValue(val)
+ }
+
+ // Convert the user info to unstructured so that we can evaluate the CEL expressions
+ // against the user info. This is done once here so that we don't have to convert
+ // the user info to unstructured multiple times in the CEL mapper for each mapping.
+ userInfoUnstructured, err := convertObjectToUnstructured(userInfo)
+ if err != nil {
+ return nil, false, fmt.Errorf("oidc: could not convert user info to unstructured: %v", err)
+ }
+
+ evalResult, err := a.celMapper.UserValidationRules.EvalUser(ctx, userInfoUnstructured)
+ if err != nil {
+ return nil, false, fmt.Errorf("oidc: error evaluating user info validation rule: %w", err)
+ }
+ if err := checkValidationRulesEvaluation(evalResult, func(a authenticationcel.ExpressionAccessor) (string, error) {
+ userValidationCondition, ok := a.(*authenticationcel.UserValidationCondition)
+ if !ok {
+ return "", fmt.Errorf("invalid type conversion, expected UserValidationCondition")
+ }
+ return userValidationCondition.Message, nil
+ }); err != nil {
+ return nil, false, fmt.Errorf("oidc: error evaluating user info validation rule: %w", err)
+ }
+ }
+
+ return &authenticator.Response{User: info}, true, nil
+}
+
+func (a *Authenticator) getUsername(ctx context.Context, c claims, claimsUnstructured *unstructured.Unstructured) (string, error) {
+ if a.celMapper.Username != nil {
+ evalResult, err := a.celMapper.Username.EvalClaimMapping(ctx, claimsUnstructured)
+ if err != nil {
+ return "", fmt.Errorf("oidc: error evaluating username claim expression: %w", err)
+ }
+ if evalResult.EvalResult.Type() != celgo.StringType {
+ return "", fmt.Errorf("oidc: error evaluating username claim expression: %w", fmt.Errorf("username claim expression must return a string"))
+ }
+
+ return evalResult.EvalResult.Value().(string), nil
+ }
+
var username string
usernameClaim := a.jwtAuthenticator.ClaimMappings.Username.Claim
if err := c.unmarshalClaim(usernameClaim, &username); err != nil {
- return nil, false, fmt.Errorf("oidc: parse username claims %q: %v", usernameClaim, err)
+ return "", fmt.Errorf("oidc: parse username claims %q: %v", usernameClaim, err)
}
if usernameClaim == "email" {
@@ -533,24 +677,26 @@ func (a *Authenticator) AuthenticateToken(ctx context.Context, token string) (*a
if hasEmailVerified := c.hasClaim("email_verified"); hasEmailVerified {
var emailVerified bool
if err := c.unmarshalClaim("email_verified", &emailVerified); err != nil {
- return nil, false, fmt.Errorf("oidc: parse 'email_verified' claim: %v", err)
+ return "", fmt.Errorf("oidc: parse 'email_verified' claim: %v", err)
}
// If the email_verified claim is present we have to verify it is set to `true`.
if !emailVerified {
- return nil, false, fmt.Errorf("oidc: email not verified")
+ return "", fmt.Errorf("oidc: email not verified")
}
}
}
userNamePrefix := a.jwtAuthenticator.ClaimMappings.Username.Prefix
if userNamePrefix != nil && *userNamePrefix != "" {
- username = *userNamePrefix + username
+ return *userNamePrefix + username, nil
}
+ return username, nil
+}
- info := &user.DefaultInfo{Name: username}
+func (a *Authenticator) getGroups(ctx context.Context, c claims, claimsUnstructured *unstructured.Unstructured) ([]string, error) {
groupsClaim := a.jwtAuthenticator.ClaimMappings.Groups.Claim
- if groupsClaim != "" {
+ if len(groupsClaim) > 0 {
if _, ok := c[groupsClaim]; ok {
// Some admins want to use string claims like "role" as the group value.
// Allow the group claim to be a single string instead of an array.
@@ -558,39 +704,91 @@ func (a *Authenticator) AuthenticateToken(ctx context.Context, token string) (*a
// See: https://github.com/kubernetes/kubernetes/issues/33290
var groups stringOrArray
if err := c.unmarshalClaim(groupsClaim, &groups); err != nil {
- return nil, false, fmt.Errorf("oidc: parse groups claim %q: %v", groupsClaim, err)
+ return nil, fmt.Errorf("oidc: parse groups claim %q: %w", groupsClaim, err)
}
- info.Groups = []string(groups)
+
+ prefix := a.jwtAuthenticator.ClaimMappings.Groups.Prefix
+ if prefix != nil && *prefix != "" {
+ for i, group := range groups {
+ groups[i] = *prefix + group
+ }
+ }
+
+ return []string(groups), nil
}
}
- groupsPrefix := a.jwtAuthenticator.ClaimMappings.Groups.Prefix
- if groupsPrefix != nil && *groupsPrefix != "" {
- for i, group := range info.Groups {
- info.Groups[i] = *groupsPrefix + group
- }
+ if a.celMapper.Groups == nil {
+ return nil, nil
}
- // check to ensure all required claims are present in the ID token and have matching values.
- for _, claimValidationRule := range a.jwtAuthenticator.ClaimValidationRules {
- claim := claimValidationRule.Claim
- value := claimValidationRule.RequiredValue
-
- if !c.hasClaim(claim) {
- return nil, false, fmt.Errorf("oidc: required claim %s not present in ID token", claim)
- }
-
- // NOTE: Only string values are supported as valid required claim values.
- var claimValue string
- if err := c.unmarshalClaim(claim, &claimValue); err != nil {
- return nil, false, fmt.Errorf("oidc: parse claim %s: %v", claim, err)
- }
- if claimValue != value {
- return nil, false, fmt.Errorf("oidc: required claim %s value does not match. Got = %s, want = %s", claim, claimValue, value)
- }
+ evalResult, err := a.celMapper.Groups.EvalClaimMapping(ctx, claimsUnstructured)
+ if err != nil {
+ return nil, fmt.Errorf("oidc: error evaluating group claim expression: %w", err)
}
- return &authenticator.Response{User: info}, true, nil
+ groups, err := convertCELValueToStringList(evalResult.EvalResult)
+ if err != nil {
+ return nil, fmt.Errorf("oidc: error evaluating group claim expression: %w", err)
+ }
+ return groups, nil
+}
+
+func (a *Authenticator) getUID(ctx context.Context, c claims, claimsUnstructured *unstructured.Unstructured) (string, error) {
+ uidClaim := a.jwtAuthenticator.ClaimMappings.UID.Claim
+ if len(uidClaim) > 0 {
+ var uid string
+ if err := c.unmarshalClaim(uidClaim, &uid); err != nil {
+ return "", fmt.Errorf("oidc: parse uid claim %q: %w", uidClaim, err)
+ }
+ return uid, nil
+ }
+
+ if a.celMapper.UID == nil {
+ return "", nil
+ }
+
+ evalResult, err := a.celMapper.UID.EvalClaimMapping(ctx, claimsUnstructured)
+ if err != nil {
+ return "", fmt.Errorf("oidc: error evaluating uid claim expression: %w", err)
+ }
+ if evalResult.EvalResult.Type() != celgo.StringType {
+ return "", fmt.Errorf("oidc: error evaluating uid claim expression: %w", fmt.Errorf("uid claim expression must return a string"))
+ }
+
+ return evalResult.EvalResult.Value().(string), nil
+}
+
+func (a *Authenticator) getExtra(ctx context.Context, claimsUnstructured *unstructured.Unstructured) (map[string][]string, error) {
+ if a.celMapper.Extra == nil {
+ return nil, nil
+ }
+
+ evalResult, err := a.celMapper.Extra.EvalClaimMappings(ctx, claimsUnstructured)
+ if err != nil {
+ return nil, err
+ }
+
+ extra := make(map[string][]string, len(evalResult))
+ for _, result := range evalResult {
+ extraMapping, ok := result.ExpressionAccessor.(*authenticationcel.ExtraMappingExpression)
+ if !ok {
+ return nil, fmt.Errorf("oidc: error evaluating extra claim expression: %w", fmt.Errorf("invalid type conversion, expected ExtraMappingCondition"))
+ }
+
+ extraValues, err := convertCELValueToStringList(result.EvalResult)
+ if err != nil {
+ return nil, fmt.Errorf("oidc: error evaluating extra claim expression: %s: %w", extraMapping.Expression, err)
+ }
+
+ if len(extraValues) == 0 {
+ continue
+ }
+
+ extra[extraMapping.Key] = extraValues
+ }
+
+ return extra, nil
}
// getClaimJWT gets a distributed claim JWT from url, using the supplied access
@@ -655,3 +853,94 @@ func (c claims) hasClaim(name string) bool {
}
return true
}
+
+// convertCELValueToStringList converts the CEL value to a string list.
+// The CEL value needs to be either a string or a list of strings.
+// "", [] are treated as not being present and will return nil.
+// Empty string in a list of strings is treated as not being present and will be filtered out.
+func convertCELValueToStringList(val ref.Val) ([]string, error) {
+ switch val.Type().TypeName() {
+ case celgo.StringType.TypeName():
+ out := val.Value().(string)
+ if len(out) == 0 {
+ return nil, nil
+ }
+ return []string{out}, nil
+
+ case celgo.ListType(nil).TypeName():
+ var result []string
+ switch val.Value().(type) {
+ case []interface{}:
+ for _, v := range val.Value().([]interface{}) {
+ out, ok := v.(string)
+ if !ok {
+ return nil, fmt.Errorf("expression must return a string or a list of strings")
+ }
+ if len(out) == 0 {
+ continue
+ }
+ result = append(result, out)
+ }
+ case []ref.Val:
+ for _, v := range val.Value().([]ref.Val) {
+ out, ok := v.Value().(string)
+ if !ok {
+ return nil, fmt.Errorf("expression must return a string or a list of strings")
+ }
+ if len(out) == 0 {
+ continue
+ }
+ result = append(result, out)
+ }
+ default:
+ return nil, fmt.Errorf("expression must return a string or a list of strings")
+ }
+
+ if len(result) == 0 {
+ return nil, nil
+ }
+
+ return result, nil
+ case celgo.NullType.TypeName():
+ return nil, nil
+ default:
+ return nil, fmt.Errorf("expression must return a string or a list of strings")
+ }
+}
+
+// messageFunc is a function that returns a message for a validation rule.
+type messageFunc func(authenticationcel.ExpressionAccessor) (string, error)
+
+// checkValidationRulesEvaluation checks if the validation rules evaluation results
+// are valid. If the validation rules evaluation results are not valid, it returns
+// an error with an optional message that was set in the validation rule.
+func checkValidationRulesEvaluation(results []authenticationcel.EvaluationResult, messageFn messageFunc) error {
+ for _, result := range results {
+ if result.EvalResult.Type() != celgo.BoolType {
+ return fmt.Errorf("validation expression must return a boolean")
+ }
+ if !result.EvalResult.Value().(bool) {
+ expression := result.ExpressionAccessor.GetExpression()
+
+ message, err := messageFn(result.ExpressionAccessor)
+ if err != nil {
+ return err
+ }
+
+ return fmt.Errorf("validation expression '%s' failed: %s", expression, message)
+ }
+ }
+
+ return nil
+}
+
+func convertObjectToUnstructured(obj interface{}) (*unstructured.Unstructured, error) {
+ if obj == nil || reflect.ValueOf(obj).IsNil() {
+ return &unstructured.Unstructured{Object: nil}, nil
+ }
+ ret, err := runtime.DefaultUnstructuredConverter.ToUnstructured(obj)
+ if err != nil {
+ return nil, err
+ }
+ return &unstructured.Unstructured{Object: ret}, nil
+}
diff --git a/staging/src/k8s.io/apiserver/plugin/pkg/authenticator/token/oidc/oidc_test.go b/staging/src/k8s.io/apiserver/plugin/pkg/authenticator/token/oidc/oidc_test.go
index 692b5cea1a4..4826ced7c5b 100644
--- a/staging/src/k8s.io/apiserver/plugin/pkg/authenticator/token/oidc/oidc_test.go
+++ b/staging/src/k8s.io/apiserver/plugin/pkg/authenticator/token/oidc/oidc_test.go
@@ -38,7 +38,10 @@ import (
"k8s.io/apiserver/pkg/apis/apiserver"
"k8s.io/apiserver/pkg/authentication/user"
+ "k8s.io/apiserver/pkg/features"
"k8s.io/apiserver/pkg/server/dynamiccertificates"
+ utilfeature "k8s.io/apiserver/pkg/util/feature"
+ featuregatetesting "k8s.io/component-base/featuregate/testing"
"k8s.io/klog/v2"
"k8s.io/utils/pointer"
)
@@ -288,6 +291,11 @@ func (c *claimsTest) run(t *testing.T) {
t.Fatalf("wanted initialization error %q but got none", c.wantInitErr)
}
+ claims := struct{}{}
+ if err := json.Unmarshal([]byte(c.claims), &claims); err != nil {
+ t.Fatalf("failed to unmarshal claims: %v", err)
+ }
+
// Sign and serialize the claims in a JWT.
jws, err := signer.Sign([]byte(c.claims))
if err != nil {
@@ -333,6 +341,8 @@ func (c *claimsTest) run(t *testing.T) {
}
func TestToken(t *testing.T) {
+ defer featuregatetesting.SetFeatureGateDuringTest(t, utilfeature.DefaultFeatureGate, features.StructuredAuthenticationConfiguration, true)()
+
synchronizeTokenIDVerifierForTest = true
tests := []claimsTest{
{
@@ -1801,7 +1811,7 @@ func TestToken(t *testing.T) {
pubKeys: []*jose.JSONWebKey{
loadRSAKey(t, "testdata/rsa_1.pem", jose.RS256),
},
- wantInitErr: `claimMappings.username.claim: Required value: claim name is required`,
+ wantInitErr: `claimMappings.username: Required value: claim or expression is required`,
},
{
name: "invalid-sig-alg",
@@ -1910,6 +1920,813 @@ func TestToken(t *testing.T) {
}`, valid.Unix()),
wantErr: `oidc: verify token: oidc: expected audience "my-client" got ["my-wrong-client"]`,
},
+ {
+ name: "user validation rule fails for user.username",
+ options: Options{
+ JWTAuthenticator: apiserver.JWTAuthenticator{
+ Issuer: apiserver.Issuer{
+ URL: "https://auth.example.com",
+ Audiences: []string{"my-client"},
+ },
+ ClaimMappings: apiserver.ClaimMappings{
+ Username: apiserver.PrefixedClaimOrExpression{
+ Claim: "username",
+ Prefix: pointer.String("system:"),
+ },
+ },
+ UserValidationRules: []apiserver.UserValidationRule{
+ {
+ Expression: "!user.username.startsWith('system:')",
+ Message: "username cannot used reserved system: prefix",
+ },
+ },
+ },
+ 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()),
+ wantErr: `oidc: error evaluating user info validation rule: validation expression '!user.username.startsWith('system:')' failed: username cannot used reserved system: prefix`,
+ },
+ {
+ name: "user validation rule fails for user.groups",
+ options: Options{
+ JWTAuthenticator: apiserver.JWTAuthenticator{
+ Issuer: apiserver.Issuer{
+ URL: "https://auth.example.com",
+ Audiences: []string{"my-client"},
+ },
+ ClaimMappings: apiserver.ClaimMappings{
+ Username: apiserver.PrefixedClaimOrExpression{
+ Expression: "claims.username",
+ },
+ Groups: apiserver.PrefixedClaimOrExpression{
+ Claim: "groups",
+ Prefix: pointer.String("system:"),
+ },
+ },
+ UserValidationRules: []apiserver.UserValidationRule{
+ {
+ Expression: "user.groups.all(group, !group.startsWith('system:'))",
+ Message: "groups cannot used reserved system: prefix",
+ },
+ },
+ },
+ 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,
+ "groups": ["team1", "team2"]
+ }`, valid.Unix()),
+ wantErr: `oidc: error evaluating user info validation rule: validation expression 'user.groups.all(group, !group.startsWith('system:'))' failed: groups cannot used reserved system: prefix`,
+ },
+ {
+ name: "claim validation rule with expression fails",
+ options: Options{
+ JWTAuthenticator: apiserver.JWTAuthenticator{
+ Issuer: apiserver.Issuer{
+ URL: "https://auth.example.com",
+ Audiences: []string{"my-client"},
+ },
+ ClaimMappings: apiserver.ClaimMappings{
+ Username: apiserver.PrefixedClaimOrExpression{
+ Claim: "username",
+ Prefix: pointer.String(""),
+ },
+ },
+ ClaimValidationRules: []apiserver.ClaimValidationRule{
+ {
+ Expression: `claims.hd == "example.com"`,
+ Message: "hd claim must be example.com",
+ },
+ },
+ },
+ 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()),
+ wantErr: `oidc: error evaluating claim validation expression: expression 'claims.hd == "example.com"' resulted in error: no such key: hd`,
+ },
+ {
+ name: "claim validation rule with expression",
+ options: Options{
+ JWTAuthenticator: apiserver.JWTAuthenticator{
+ Issuer: apiserver.Issuer{
+ URL: "https://auth.example.com",
+ Audiences: []string{"my-client"},
+ },
+ ClaimMappings: apiserver.ClaimMappings{
+ Username: apiserver.PrefixedClaimOrExpression{
+ Claim: "username",
+ Prefix: pointer.String(""),
+ },
+ },
+ ClaimValidationRules: []apiserver.ClaimValidationRule{
+ {
+ Expression: `claims.hd == "example.com"`,
+ Message: "hd claim must be example.com",
+ },
+ },
+ },
+ 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,
+ "hd": "example.com"
+ }`, valid.Unix()),
+ want: &user.DefaultInfo{
+ Name: "jane",
+ },
+ },
+ {
+ name: "claim validation rule with expression and nested claims",
+ options: Options{
+ JWTAuthenticator: apiserver.JWTAuthenticator{
+ Issuer: apiserver.Issuer{
+ URL: "https://auth.example.com",
+ Audiences: []string{"my-client"},
+ },
+ ClaimMappings: apiserver.ClaimMappings{
+ Username: apiserver.PrefixedClaimOrExpression{
+ Claim: "username",
+ Prefix: pointer.String(""),
+ },
+ },
+ ClaimValidationRules: []apiserver.ClaimValidationRule{
+ {
+ Expression: `claims.foo.bar == "baz"`,
+ Message: "foo.bar claim must be baz",
+ },
+ },
+ },
+ 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,
+ "hd": "example.com",
+ "foo": {
+ "bar": "baz"
+ }
+ }`, valid.Unix()),
+ want: &user.DefaultInfo{
+ Name: "jane",
+ },
+ },
+ {
+ name: "claim validation rule with mix of expression and claim",
+ options: Options{
+ JWTAuthenticator: apiserver.JWTAuthenticator{
+ Issuer: apiserver.Issuer{
+ URL: "https://auth.example.com",
+ Audiences: []string{"my-client"},
+ },
+ ClaimMappings: apiserver.ClaimMappings{
+ Username: apiserver.PrefixedClaimOrExpression{
+ Claim: "username",
+ Prefix: pointer.String(""),
+ },
+ },
+ ClaimValidationRules: []apiserver.ClaimValidationRule{
+ {
+ Expression: `claims.foo.bar == "baz"`,
+ Message: "foo.bar claim must be baz",
+ },
+ {
+ Claim: "hd",
+ RequiredValue: "example.com",
+ },
+ },
+ },
+ 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,
+ "hd": "example.com",
+ "foo": {
+ "bar": "baz"
+ }
+ }`, valid.Unix()),
+ want: &user.DefaultInfo{
+ Name: "jane",
+ },
+ },
+ {
+ name: "username claim mapping with expression",
+ options: Options{
+ JWTAuthenticator: apiserver.JWTAuthenticator{
+ Issuer: apiserver.Issuer{
+ URL: "https://auth.example.com",
+ Audiences: []string{"my-client"},
+ },
+ ClaimMappings: apiserver.ClaimMappings{
+ Username: apiserver.PrefixedClaimOrExpression{
+ Expression: "claims.username",
+ },
+ },
+ },
+ 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()),
+ want: &user.DefaultInfo{
+ Name: "jane",
+ },
+ },
+ {
+ name: "username claim mapping with expression and nested claim",
+ options: Options{
+ JWTAuthenticator: apiserver.JWTAuthenticator{
+ Issuer: apiserver.Issuer{
+ URL: "https://auth.example.com",
+ Audiences: []string{"my-client"},
+ },
+ ClaimMappings: apiserver.ClaimMappings{
+ Username: apiserver.PrefixedClaimOrExpression{
+ Expression: "claims.foo.username",
+ },
+ },
+ },
+ 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,
+ "foo": {
+ "username": "jane"
+ }
+ }`, valid.Unix()),
+ want: &user.DefaultInfo{
+ Name: "jane",
+ },
+ },
+ {
+ name: "groups claim mapping with expression",
+ options: Options{
+ JWTAuthenticator: apiserver.JWTAuthenticator{
+ Issuer: apiserver.Issuer{
+ URL: "https://auth.example.com",
+ Audiences: []string{"my-client"},
+ },
+ ClaimMappings: apiserver.ClaimMappings{
+ Username: apiserver.PrefixedClaimOrExpression{
+ Expression: "claims.username",
+ },
+ Groups: apiserver.PrefixedClaimOrExpression{
+ Expression: "claims.groups",
+ },
+ },
+ },
+ 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
+ }`, valid.Unix()),
+ want: &user.DefaultInfo{
+ Name: "jane",
+ Groups: []string{"team1", "team2"},
+ },
+ },
+ {
+ name: "groups claim with expression",
+ options: Options{
+ JWTAuthenticator: apiserver.JWTAuthenticator{
+ Issuer: apiserver.Issuer{
+ URL: "https://auth.example.com",
+ Audiences: []string{"my-client"},
+ },
+ ClaimMappings: apiserver.ClaimMappings{
+ Username: apiserver.PrefixedClaimOrExpression{
+ Claim: "username",
+ Prefix: pointer.String("oidc:"),
+ },
+ Groups: apiserver.PrefixedClaimOrExpression{
+ Expression: `(claims.roles.split(",") + claims.other_roles.split(",")).map(role, "groups:" + role)`,
+ },
+ },
+ },
+ 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",
+ "roles": "foo,bar",
+ "other_roles": "baz,qux",
+ "exp": %d
+ }`, valid.Unix()),
+ want: &user.DefaultInfo{
+ Name: "oidc:jane",
+ Groups: []string{"groups:foo", "groups:bar", "groups:baz", "groups:qux"},
+ },
+ },
+ {
+ name: "uid claim mapping with expression",
+ options: Options{
+ JWTAuthenticator: apiserver.JWTAuthenticator{
+ Issuer: apiserver.Issuer{
+ URL: "https://auth.example.com",
+ Audiences: []string{"my-client"},
+ },
+ ClaimMappings: apiserver.ClaimMappings{
+ Username: apiserver.PrefixedClaimOrExpression{
+ Expression: "claims.username",
+ },
+ Groups: apiserver.PrefixedClaimOrExpression{
+ Expression: "claims.groups",
+ },
+ UID: apiserver.ClaimOrExpression{
+ Expression: "claims.uid",
+ },
+ },
+ },
+ 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"
+ }`, valid.Unix()),
+ want: &user.DefaultInfo{
+ Name: "jane",
+ Groups: []string{"team1", "team2"},
+ UID: "1234",
+ },
+ },
+ {
+ name: "uid claim mapping with claim",
+ options: Options{
+ JWTAuthenticator: apiserver.JWTAuthenticator{
+ Issuer: apiserver.Issuer{
+ URL: "https://auth.example.com",
+ Audiences: []string{"my-client"},
+ },
+ ClaimMappings: apiserver.ClaimMappings{
+ Username: apiserver.PrefixedClaimOrExpression{
+ Expression: "claims.username",
+ },
+ Groups: apiserver.PrefixedClaimOrExpression{
+ Expression: "claims.groups",
+ },
+ UID: apiserver.ClaimOrExpression{
+ Claim: "uid",
+ },
+ },
+ },
+ 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"
+ }`, valid.Unix()),
+ want: &user.DefaultInfo{
+ Name: "jane",
+ Groups: []string{"team1", "team2"},
+ UID: "1234",
+ },
+ },
+ {
+ name: "extra claim mapping with expression",
+ options: Options{
+ JWTAuthenticator: apiserver.JWTAuthenticator{
+ Issuer: apiserver.Issuer{
+ URL: "https://auth.example.com",
+ Audiences: []string{"my-client"},
+ },
+ 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",
+ },
+ },
+ },
+ },
+ 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()),
+ want: &user.DefaultInfo{
+ Name: "jane",
+ Groups: []string{"team1", "team2"},
+ UID: "1234",
+ Extra: map[string][]string{
+ "example.org/foo": {"bar"},
+ "example.org/bar": {"baz", "qux"},
+ },
+ },
+ },
+ {
+ name: "extra claim mapping, value derived from claim value",
+ options: Options{
+ JWTAuthenticator: apiserver.JWTAuthenticator{
+ Issuer: apiserver.Issuer{
+ URL: "https://auth.example.com",
+ Audiences: []string{"my-client"},
+ },
+ ClaimMappings: apiserver.ClaimMappings{
+ Username: apiserver.PrefixedClaimOrExpression{
+ Expression: "claims.username",
+ },
+ Extra: []apiserver.ExtraMapping{
+ {
+ Key: "example.org/admin",
+ ValueExpression: `(has(claims.is_admin) && claims.is_admin) ? "true":""`,
+ },
+ {
+ Key: "example.org/admin_1",
+ ValueExpression: `claims.?is_admin.orValue(false) == true ? "true":""`,
+ },
+ {
+ Key: "example.org/non_existent",
+ ValueExpression: `claims.?non_existent.orValue("default") == "default" ? "true":""`,
+ },
+ },
+ },
+ },
+ 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,
+ "is_admin": true
+ }`, valid.Unix()),
+ want: &user.DefaultInfo{
+ Name: "jane",
+ Extra: map[string][]string{
+ "example.org/admin": {"true"},
+ "example.org/admin_1": {"true"},
+ "example.org/non_existent": {"true"},
+ },
+ },
+ },
+ {
+ name: "hardcoded extra claim mapping",
+ options: Options{
+ JWTAuthenticator: apiserver.JWTAuthenticator{
+ Issuer: apiserver.Issuer{
+ URL: "https://auth.example.com",
+ Audiences: []string{"my-client"},
+ },
+ ClaimMappings: apiserver.ClaimMappings{
+ Username: apiserver.PrefixedClaimOrExpression{
+ Expression: "claims.username",
+ },
+ Extra: []apiserver.ExtraMapping{
+ {
+ Key: "example.org/admin",
+ ValueExpression: `"true"`,
+ },
+ },
+ },
+ },
+ 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,
+ "is_admin": true
+ }`, valid.Unix()),
+ want: &user.DefaultInfo{
+ Name: "jane",
+ Extra: map[string][]string{
+ "example.org/admin": {"true"},
+ },
+ },
+ },
+ {
+ name: "extra claim mapping, multiple expressions for same key",
+ options: Options{
+ JWTAuthenticator: apiserver.JWTAuthenticator{
+ Issuer: apiserver.Issuer{
+ URL: "https://auth.example.com",
+ Audiences: []string{"my-client"},
+ },
+ 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",
+ },
+ {
+ Key: "example.org/foo",
+ ValueExpression: "claims.bar",
+ },
+ },
+ },
+ },
+ 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: `claimMappings.extra[2].key: Duplicate value: "example.org/foo"`,
+ },
+ {
+ name: "extra claim mapping, empty string value for key",
+ options: Options{
+ JWTAuthenticator: apiserver.JWTAuthenticator{
+ Issuer: apiserver.Issuer{
+ URL: "https://auth.example.com",
+ Audiences: []string{"my-client"},
+ },
+ 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",
+ },
+ },
+ },
+ },
+ 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": [
+ "baz",
+ "qux"
+ ]
+ }`, valid.Unix()),
+ want: &user.DefaultInfo{
+ Name: "jane",
+ Groups: []string{"team1", "team2"},
+ UID: "1234",
+ Extra: map[string][]string{
+ "example.org/bar": {"baz", "qux"},
+ },
+ },
+ },
+ {
+ name: "extra claim mapping with user validation rule succeeds",
+ options: Options{
+ JWTAuthenticator: apiserver.JWTAuthenticator{
+ Issuer: apiserver.Issuer{
+ URL: "https://auth.example.com",
+ Audiences: []string{"my-client"},
+ },
+ 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: "'bar'",
+ },
+ {
+ Key: "example.org/baz",
+ ValueExpression: "claims.baz",
+ },
+ },
+ },
+ UserValidationRules: []apiserver.UserValidationRule{
+ {
+ Expression: "'bar' in user.extra['example.org/foo'] && 'qux' in user.extra['example.org/baz']",
+ Message: "example.org/foo must be bar and example.org/baz must be qux",
+ },
+ },
+ },
+ 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",
+ "baz": "qux"
+ }`, valid.Unix()),
+ want: &user.DefaultInfo{
+ Name: "jane",
+ Groups: []string{"team1", "team2"},
+ UID: "1234",
+ Extra: map[string][]string{
+ "example.org/foo": {"bar"},
+ "example.org/baz": {"qux"},
+ },
+ },
+ },
+ {
+ name: "groups expression returns null",
+ options: Options{
+ JWTAuthenticator: apiserver.JWTAuthenticator{
+ Issuer: apiserver.Issuer{
+ URL: "https://auth.example.com",
+ Audiences: []string{"my-client"},
+ },
+ ClaimMappings: apiserver.ClaimMappings{
+ Username: apiserver.PrefixedClaimOrExpression{
+ Expression: "claims.username",
+ },
+ Groups: apiserver.PrefixedClaimOrExpression{
+ Expression: "claims.groups",
+ },
+ },
+ },
+ 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": null,
+ "exp": %d,
+ "uid": "1234",
+ "baz": "qux"
+ }`, valid.Unix()),
+ want: &user.DefaultInfo{
+ Name: "jane",
+ },
+ },
}
for _, test := range tests {
t.Run(test.name, test.run)
diff --git a/test/integration/apiserver/oidc/oidc_test.go b/test/integration/apiserver/oidc/oidc_test.go
index b8df34bdfc1..9f7ec3c6a36 100644
--- a/test/integration/apiserver/oidc/oidc_test.go
+++ b/test/integration/apiserver/oidc/oidc_test.go
@@ -36,6 +36,7 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
+ authenticationv1 "k8s.io/api/authentication/v1"
rbacv1 "k8s.io/api/rbac/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
@@ -99,6 +100,9 @@ var (
}
)
+// authenticationConfigFunc is a function that returns a string representation of an authentication config.
+type authenticationConfigFunc func(t *testing.T, issuerURL, caCert string) string
+
func TestOIDC(t *testing.T) {
t.Log("Testing OIDC authenticator with --oidc-* flags")
runTests(t, false)
@@ -114,7 +118,7 @@ func TestStructuredAuthenticationConfig(t *testing.T) {
func runTests(t *testing.T, useAuthenticationConfig bool) {
var tests = []struct {
name string
- configureInfrastructure func(t *testing.T, useAuthenticationConfig bool) (
+ configureInfrastructure func(t *testing.T, fn authenticationConfigFunc) (
oidcServer *utilsoidc.TestServer,
apiServer *kubeapiserverapptesting.TestServer,
signingPrivateKey *rsa.PrivateKey,
@@ -129,27 +133,29 @@ func runTests(t *testing.T, useAuthenticationConfig bool) {
certPath,
oidcServerURL,
oidcServerTokenURL string,
- ) *kubernetes.Clientset
- asserErrFn func(t *testing.T, errorToCheck error)
+ ) kubernetes.Interface
+ assertErrFn func(t *testing.T, errorToCheck error)
}{
{
name: "ID token is ok",
configureInfrastructure: configureTestInfrastructure,
configureOIDCServerBehaviour: func(t *testing.T, oidcServer *utilsoidc.TestServer, signingPrivateKey *rsa.PrivateKey) {
idTokenLifetime := time.Second * 1200
- oidcServer.TokenHandler().EXPECT().Token().Times(1).DoAndReturn(utilsoidc.TokenHandlerBehaviourReturningPredefinedJWT(
+ oidcServer.TokenHandler().EXPECT().Token().Times(1).DoAndReturn(utilsoidc.TokenHandlerBehaviorReturningPredefinedJWT(
t,
signingPrivateKey,
- oidcServer.URL(),
- defaultOIDCClientID,
- defaultOIDCClaimedUsername,
+ map[string]interface{}{
+ "iss": oidcServer.URL(),
+ "sub": defaultOIDCClaimedUsername,
+ "aud": defaultOIDCClientID,
+ "exp": time.Now().Add(idTokenLifetime).Unix(),
+ },
defaultStubAccessToken,
defaultStubRefreshToken,
- time.Now().Add(idTokenLifetime).Unix(),
))
},
configureClient: configureClientFetchingOIDCCredentials,
- asserErrFn: func(t *testing.T, errorToCheck error) {
+ assertErrFn: func(t *testing.T, errorToCheck error) {
assert.NoError(t, errorToCheck)
},
},
@@ -160,7 +166,7 @@ func runTests(t *testing.T, useAuthenticationConfig bool) {
configureOIDCServerToReturnExpiredIDToken(t, 2, oidcServer, signingPrivateKey)
},
configureClient: configureClientFetchingOIDCCredentials,
- asserErrFn: func(t *testing.T, errorToCheck error) {
+ assertErrFn: func(t *testing.T, errorToCheck error) {
assert.True(t, apierrors.IsUnauthorized(errorToCheck), errorToCheck)
},
},
@@ -171,7 +177,7 @@ func runTests(t *testing.T, useAuthenticationConfig bool) {
oidcServer.TokenHandler().EXPECT().Token().Times(2).Return(utilsoidc.Token{}, utilsoidc.ErrBadClientID)
},
configureClient: configureClientWithEmptyIDToken,
- asserErrFn: func(t *testing.T, errorToCheck error) {
+ assertErrFn: func(t *testing.T, errorToCheck error) {
urlError, ok := errorToCheck.(*url.Error)
require.True(t, ok)
assert.Equal(
@@ -185,7 +191,7 @@ func runTests(t *testing.T, useAuthenticationConfig bool) {
name: "client has wrong CA",
configureInfrastructure: configureTestInfrastructure,
configureOIDCServerBehaviour: func(t *testing.T, oidcServer *utilsoidc.TestServer, _ *rsa.PrivateKey) {},
- configureClient: func(t *testing.T, restCfg *rest.Config, caCert []byte, _, oidcServerURL, oidcServerTokenURL string) *kubernetes.Clientset {
+ configureClient: func(t *testing.T, restCfg *rest.Config, caCert []byte, _, oidcServerURL, oidcServerTokenURL string) kubernetes.Interface {
tempDir := t.TempDir()
certFilePath := filepath.Join(tempDir, "localhost_127.0.0.1_.crt")
@@ -194,7 +200,7 @@ func runTests(t *testing.T, useAuthenticationConfig bool) {
return configureClientWithEmptyIDToken(t, restCfg, caCert, certFilePath, oidcServerURL, oidcServerTokenURL)
},
- asserErrFn: func(t *testing.T, errorToCheck error) {
+ assertErrFn: func(t *testing.T, errorToCheck error) {
expectedErr := new(x509.UnknownAuthorityError)
assert.ErrorAs(t, errorToCheck, expectedErr)
},
@@ -212,7 +218,7 @@ func runTests(t *testing.T, useAuthenticationConfig bool) {
}, nil)
},
configureClient: configureClientFetchingOIDCCredentials,
- asserErrFn: func(t *testing.T, errorToCheck error) {
+ assertErrFn: func(t *testing.T, errorToCheck error) {
expectedError := new(apierrors.StatusError)
assert.ErrorAs(t, errorToCheck, &expectedError)
assert.Equal(
@@ -224,7 +230,7 @@ func runTests(t *testing.T, useAuthenticationConfig bool) {
},
{
name: "ID token signature can not be verified due to wrong JWKs",
- configureInfrastructure: func(t *testing.T, useAuthenticationConfig bool) (
+ configureInfrastructure: func(t *testing.T, fn authenticationConfigFunc) (
oidcServer *utilsoidc.TestServer,
apiServer *kubeapiserverapptesting.TestServer,
signingPrivateKey *rsa.PrivateKey,
@@ -239,7 +245,21 @@ func runTests(t *testing.T, useAuthenticationConfig bool) {
oidcServer = utilsoidc.BuildAndRunTestServer(t, caFilePath, caKeyFilePath)
if useAuthenticationConfig {
- authenticationConfig := generateAuthenticationConfig(t, oidcServer.URL(), defaultOIDCClientID, string(caCertContent), defaultOIDCUsernamePrefix)
+ authenticationConfig := fmt.Sprintf(`
+apiVersion: apiserver.config.k8s.io/v1alpha1
+kind: AuthenticationConfiguration
+jwt:
+- issuer:
+ url: %s
+ audiences:
+ - %s
+ certificateAuthority: |
+ %s
+ claimMappings:
+ username:
+ claim: sub
+ prefix: %s
+`, oidcServer.URL(), defaultOIDCClientID, indentCertificateAuthority(string(caCertContent)), defaultOIDCUsernamePrefix)
apiServer = startTestAPIServerForOIDC(t, "", "", "", authenticationConfig)
} else {
apiServer = startTestAPIServerForOIDC(t, oidcServer.URL(), defaultOIDCClientID, caFilePath, "")
@@ -250,24 +270,26 @@ func runTests(t *testing.T, useAuthenticationConfig bool) {
anotherSigningPrivateKey, wantErr := rsa.GenerateKey(rand.Reader, rsaKeyBitSize)
require.NoError(t, wantErr)
- oidcServer.JwksHandler().EXPECT().KeySet().AnyTimes().DoAndReturn(utilsoidc.DefaultJwksHandlerBehaviour(t, &anotherSigningPrivateKey.PublicKey))
+ oidcServer.JwksHandler().EXPECT().KeySet().AnyTimes().DoAndReturn(utilsoidc.DefaultJwksHandlerBehavior(t, &anotherSigningPrivateKey.PublicKey))
return oidcServer, apiServer, signingPrivateKey, caCertContent, caFilePath
},
configureOIDCServerBehaviour: func(t *testing.T, oidcServer *utilsoidc.TestServer, signingPrivateKey *rsa.PrivateKey) {
- oidcServer.TokenHandler().EXPECT().Token().Times(1).DoAndReturn(utilsoidc.TokenHandlerBehaviourReturningPredefinedJWT(
+ oidcServer.TokenHandler().EXPECT().Token().Times(1).DoAndReturn(utilsoidc.TokenHandlerBehaviorReturningPredefinedJWT(
t,
signingPrivateKey,
- oidcServer.URL(),
- defaultOIDCClientID,
- defaultOIDCClaimedUsername,
+ map[string]interface{}{
+ "iss": oidcServer.URL(),
+ "sub": defaultOIDCClaimedUsername,
+ "aud": defaultOIDCClientID,
+ "exp": time.Now().Add(time.Second * 1200).Unix(),
+ },
defaultStubAccessToken,
defaultStubRefreshToken,
- time.Now().Add(time.Second*1200).Unix(),
))
},
configureClient: configureClientFetchingOIDCCredentials,
- asserErrFn: func(t *testing.T, errorToCheck error) {
+ assertErrFn: func(t *testing.T, errorToCheck error) {
assert.True(t, apierrors.IsUnauthorized(errorToCheck), errorToCheck)
},
},
@@ -275,7 +297,27 @@ func runTests(t *testing.T, useAuthenticationConfig bool) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
- oidcServer, apiServer, signingPrivateKey, caCert, certPath := tt.configureInfrastructure(t, useAuthenticationConfig)
+ fn := func(t *testing.T, issuerURL, caCert string) string { return "" }
+ if useAuthenticationConfig {
+ fn = func(t *testing.T, issuerURL, caCert string) string {
+ return fmt.Sprintf(`
+apiVersion: apiserver.config.k8s.io/v1alpha1
+kind: AuthenticationConfiguration
+jwt:
+- issuer:
+ url: %s
+ audiences:
+ - %s
+ certificateAuthority: |
+ %s
+ claimMappings:
+ username:
+ claim: sub
+ prefix: %s
+`, issuerURL, defaultOIDCClientID, indentCertificateAuthority(caCert), defaultOIDCUsernamePrefix)
+ }
+ }
+ oidcServer, apiServer, signingPrivateKey, caCert, certPath := tt.configureInfrastructure(t, fn)
tt.configureOIDCServerBehaviour(t, oidcServer, signingPrivateKey)
@@ -284,12 +326,10 @@ func runTests(t *testing.T, useAuthenticationConfig bool) {
client := tt.configureClient(t, apiServer.ClientConfig, caCert, certPath, oidcServer.URL(), tokenURL)
- ctx, cancel := context.WithTimeout(context.Background(), time.Second*30)
- defer cancel()
-
+ ctx := testContext(t)
_, err = client.CoreV1().Pods(defaultNamespace).List(ctx, metav1.ListOptions{})
- tt.asserErrFn(t, err)
+ tt.assertErrFn(t, err)
})
}
}
@@ -298,24 +338,26 @@ func TestUpdatingRefreshTokenInCaseOfExpiredIDToken(t *testing.T) {
var tests = []struct {
name string
configureUpdatingTokenBehaviour func(t *testing.T, oidcServer *utilsoidc.TestServer, signingPrivateKey *rsa.PrivateKey)
- asserErrFn func(t *testing.T, errorToCheck error)
+ assertErrFn func(t *testing.T, errorToCheck error)
}{
{
name: "cache returns stale client if refresh token is not updated in config",
configureUpdatingTokenBehaviour: func(t *testing.T, oidcServer *utilsoidc.TestServer, signingPrivateKey *rsa.PrivateKey) {
- oidcServer.TokenHandler().EXPECT().Token().Times(1).DoAndReturn(utilsoidc.TokenHandlerBehaviourReturningPredefinedJWT(
+ oidcServer.TokenHandler().EXPECT().Token().Times(1).DoAndReturn(utilsoidc.TokenHandlerBehaviorReturningPredefinedJWT(
t,
signingPrivateKey,
- oidcServer.URL(),
- defaultOIDCClientID,
- defaultOIDCClaimedUsername,
+ map[string]interface{}{
+ "iss": oidcServer.URL(),
+ "sub": defaultOIDCClaimedUsername,
+ "aud": defaultOIDCClientID,
+ "exp": time.Now().Add(time.Second * 1200).Unix(),
+ },
defaultStubAccessToken,
defaultStubRefreshToken,
- time.Now().Add(time.Second*1200).Unix(),
))
configureOIDCServerToReturnExpiredRefreshTokenErrorOnTryingToUpdateIDToken(oidcServer)
},
- asserErrFn: func(t *testing.T, errorToCheck error) {
+ assertErrFn: func(t *testing.T, errorToCheck error) {
urlError, ok := errorToCheck.(*url.Error)
require.True(t, ok)
assert.Equal(
@@ -327,7 +369,7 @@ func TestUpdatingRefreshTokenInCaseOfExpiredIDToken(t *testing.T) {
},
}
- oidcServer, apiServer, signingPrivateKey, caCert, certPath := configureTestInfrastructure(t, false)
+ oidcServer, apiServer, signingPrivateKey, caCert, certPath := configureTestInfrastructure(t, func(t *testing.T, _, _ string) string { return "" })
tokenURL, err := oidcServer.TokenURL()
require.NoError(t, err)
@@ -339,9 +381,7 @@ func TestUpdatingRefreshTokenInCaseOfExpiredIDToken(t *testing.T) {
expiredClient := kubernetes.NewForConfigOrDie(clientConfig)
configureOIDCServerToReturnExpiredRefreshTokenErrorOnTryingToUpdateIDToken(oidcServer)
- ctx, cancel := context.WithTimeout(context.Background(), time.Second*30)
- defer cancel()
-
+ ctx := testContext(t)
_, err = expiredClient.CoreV1().Pods(defaultNamespace).List(ctx, metav1.ListOptions{})
assert.Error(t, err)
@@ -351,12 +391,347 @@ func TestUpdatingRefreshTokenInCaseOfExpiredIDToken(t *testing.T) {
expectedOkClient := kubernetes.NewForConfigOrDie(clientConfig)
_, err = expectedOkClient.CoreV1().Pods(defaultNamespace).List(ctx, metav1.ListOptions{})
- tt.asserErrFn(t, err)
+ tt.assertErrFn(t, err)
})
}
}
-func configureTestInfrastructure(t *testing.T, useAuthenticationConfig bool) (
+func TestStructuredAuthenticationConfigCEL(t *testing.T) {
+ defer featuregatetesting.SetFeatureGateDuringTest(t, utilfeature.DefaultFeatureGate, features.StructuredAuthenticationConfiguration, true)()
+
+ tests := []struct {
+ name string
+ authConfigFn authenticationConfigFunc
+ configureInfrastructure func(t *testing.T, fn authenticationConfigFunc) (
+ oidcServer *utilsoidc.TestServer,
+ apiServer *kubeapiserverapptesting.TestServer,
+ signingPrivateKey *rsa.PrivateKey,
+ caCertContent []byte,
+ caFilePath string,
+ )
+ configureOIDCServerBehaviour func(t *testing.T, oidcServer *utilsoidc.TestServer, signingPrivateKey *rsa.PrivateKey)
+ configureClient func(
+ t *testing.T,
+ restCfg *rest.Config,
+ caCert []byte,
+ certPath,
+ oidcServerURL,
+ oidcServerTokenURL string,
+ ) kubernetes.Interface
+ assertErrFn func(t *testing.T, errorToCheck error)
+ wantUser *authenticationv1.UserInfo
+ }{
+ {
+ name: "username CEL expression is ok",
+ authConfigFn: func(t *testing.T, issuerURL, caCert string) string {
+ return fmt.Sprintf(`
+apiVersion: apiserver.config.k8s.io/v1alpha1
+kind: AuthenticationConfiguration
+jwt:
+- issuer:
+ url: %s
+ audiences:
+ - %s
+ certificateAuthority: |
+ %s
+ claimMappings:
+ username:
+ expression: "'k8s-' + claims.sub"
+`, issuerURL, defaultOIDCClientID, indentCertificateAuthority(caCert))
+ },
+ configureInfrastructure: configureTestInfrastructure,
+ configureOIDCServerBehaviour: func(t *testing.T, oidcServer *utilsoidc.TestServer, signingPrivateKey *rsa.PrivateKey) {
+ idTokenLifetime := time.Second * 1200
+ oidcServer.TokenHandler().EXPECT().Token().Times(1).DoAndReturn(utilsoidc.TokenHandlerBehaviorReturningPredefinedJWT(
+ t,
+ signingPrivateKey,
+ map[string]interface{}{
+ "iss": oidcServer.URL(),
+ "sub": defaultOIDCClaimedUsername,
+ "aud": defaultOIDCClientID,
+ "exp": time.Now().Add(idTokenLifetime).Unix(),
+ },
+ defaultStubAccessToken,
+ defaultStubRefreshToken,
+ ))
+ },
+ configureClient: configureClientFetchingOIDCCredentials,
+ assertErrFn: func(t *testing.T, errorToCheck error) {
+ assert.NoError(t, errorToCheck)
+ },
+ wantUser: &authenticationv1.UserInfo{
+ Username: "k8s-john_doe",
+ Groups: []string{"system:authenticated"},
+ },
+ },
+ {
+ name: "groups CEL expression is ok",
+ authConfigFn: func(t *testing.T, issuerURL, caCert string) string {
+ return fmt.Sprintf(`
+apiVersion: apiserver.config.k8s.io/v1alpha1
+kind: AuthenticationConfiguration
+jwt:
+- issuer:
+ url: %s
+ audiences:
+ - %s
+ certificateAuthority: |
+ %s
+ claimMappings:
+ username:
+ expression: "'k8s-' + claims.sub"
+ groups:
+ expression: '(claims.roles.split(",") + claims.other_roles.split(",")).map(role, "prefix:" + role)'
+`, issuerURL, defaultOIDCClientID, indentCertificateAuthority(caCert))
+ },
+ configureInfrastructure: configureTestInfrastructure,
+ configureOIDCServerBehaviour: func(t *testing.T, oidcServer *utilsoidc.TestServer, signingPrivateKey *rsa.PrivateKey) {
+ idTokenLifetime := time.Second * 1200
+ oidcServer.TokenHandler().EXPECT().Token().Times(1).DoAndReturn(utilsoidc.TokenHandlerBehaviorReturningPredefinedJWT(
+ t,
+ signingPrivateKey,
+ map[string]interface{}{
+ "iss": oidcServer.URL(),
+ "sub": defaultOIDCClaimedUsername,
+ "aud": defaultOIDCClientID,
+ "exp": time.Now().Add(idTokenLifetime).Unix(),
+ "roles": "foo,bar",
+ "other_roles": "baz,qux",
+ },
+ defaultStubAccessToken,
+ defaultStubRefreshToken,
+ ))
+ },
+ configureClient: configureClientFetchingOIDCCredentials,
+ assertErrFn: func(t *testing.T, errorToCheck error) {
+ assert.NoError(t, errorToCheck)
+ },
+ wantUser: &authenticationv1.UserInfo{
+ Username: "k8s-john_doe",
+ Groups: []string{"prefix:foo", "prefix:bar", "prefix:baz", "prefix:qux", "system:authenticated"},
+ },
+ },
+ {
+ name: "claim validation rule fails",
+ authConfigFn: func(t *testing.T, issuerURL, caCert string) string {
+ return fmt.Sprintf(`
+apiVersion: apiserver.config.k8s.io/v1alpha1
+kind: AuthenticationConfiguration
+jwt:
+- issuer:
+ url: %s
+ audiences:
+ - %s
+ certificateAuthority: |
+ %s
+ claimMappings:
+ username:
+ expression: "'k8s-' + claims.sub"
+ claimValidationRules:
+ - expression: 'claims.hd == "example.com"'
+ message: "the hd claim must be set to example.com"
+`, issuerURL, defaultOIDCClientID, indentCertificateAuthority(caCert))
+ },
+ configureInfrastructure: configureTestInfrastructure,
+ configureOIDCServerBehaviour: func(t *testing.T, oidcServer *utilsoidc.TestServer, signingPrivateKey *rsa.PrivateKey) {
+ idTokenLifetime := time.Second * 1200
+ oidcServer.TokenHandler().EXPECT().Token().Times(1).DoAndReturn(utilsoidc.TokenHandlerBehaviorReturningPredefinedJWT(
+ t,
+ signingPrivateKey,
+ map[string]interface{}{
+ "iss": oidcServer.URL(),
+ "sub": defaultOIDCClaimedUsername,
+ "aud": defaultOIDCClientID,
+ "exp": time.Now().Add(idTokenLifetime).Unix(),
+ "hd": "notexample.com",
+ },
+ defaultStubAccessToken,
+ defaultStubRefreshToken,
+ ))
+ },
+ configureClient: configureClientFetchingOIDCCredentials,
+ assertErrFn: func(t *testing.T, errorToCheck error) {
+ assert.True(t, apierrors.IsUnauthorized(errorToCheck), errorToCheck)
+ },
+ },
+ {
+ name: "extra mapping CEL expressions are ok",
+ authConfigFn: func(t *testing.T, issuerURL, caCert string) string {
+ return fmt.Sprintf(`
+apiVersion: apiserver.config.k8s.io/v1alpha1
+kind: AuthenticationConfiguration
+jwt:
+- issuer:
+ url: %s
+ audiences:
+ - %s
+ certificateAuthority: |
+ %s
+ claimMappings:
+ username:
+ expression: "'k8s-' + claims.sub"
+ extra:
+ - key: "example.org/foo"
+ valueExpression: "'bar'"
+ - key: "example.org/baz"
+ valueExpression: "claims.baz"
+ userValidationRules:
+ - expression: "'bar' in user.extra['example.org/foo'] && 'qux' in user.extra['example.org/baz']"
+ message: "example.org/foo must be bar and example.org/baz must be qux"
+`, issuerURL, defaultOIDCClientID, indentCertificateAuthority(caCert))
+ },
+ configureInfrastructure: configureTestInfrastructure,
+ configureOIDCServerBehaviour: func(t *testing.T, oidcServer *utilsoidc.TestServer, signingPrivateKey *rsa.PrivateKey) {
+ idTokenLifetime := time.Second * 1200
+ oidcServer.TokenHandler().EXPECT().Token().Times(1).DoAndReturn(utilsoidc.TokenHandlerBehaviorReturningPredefinedJWT(
+ t,
+ signingPrivateKey,
+ map[string]interface{}{
+ "iss": oidcServer.URL(),
+ "sub": defaultOIDCClaimedUsername,
+ "aud": defaultOIDCClientID,
+ "exp": time.Now().Add(idTokenLifetime).Unix(),
+ "baz": "qux",
+ },
+ defaultStubAccessToken,
+ defaultStubRefreshToken,
+ ))
+ },
+ configureClient: configureClientFetchingOIDCCredentials,
+ assertErrFn: func(t *testing.T, errorToCheck error) {
+ assert.NoError(t, errorToCheck)
+ },
+ wantUser: &authenticationv1.UserInfo{
+ Username: "k8s-john_doe",
+ Groups: []string{"system:authenticated"},
+ Extra: map[string]authenticationv1.ExtraValue{
+ "example.org/foo": {"bar"},
+ "example.org/baz": {"qux"},
+ },
+ },
+ },
+ {
+ name: "uid CEL expression is ok",
+ authConfigFn: func(t *testing.T, issuerURL, caCert string) string {
+ return fmt.Sprintf(`
+apiVersion: apiserver.config.k8s.io/v1alpha1
+kind: AuthenticationConfiguration
+jwt:
+- issuer:
+ url: %s
+ audiences:
+ - %s
+ certificateAuthority: |
+ %s
+ claimMappings:
+ username:
+ expression: "'k8s-' + claims.sub"
+ uid:
+ expression: "claims.uid"
+`, issuerURL, defaultOIDCClientID, indentCertificateAuthority(caCert))
+ },
+ configureInfrastructure: configureTestInfrastructure,
+ configureOIDCServerBehaviour: func(t *testing.T, oidcServer *utilsoidc.TestServer, signingPrivateKey *rsa.PrivateKey) {
+ idTokenLifetime := time.Second * 1200
+ oidcServer.TokenHandler().EXPECT().Token().Times(1).DoAndReturn(utilsoidc.TokenHandlerBehaviorReturningPredefinedJWT(
+ t,
+ signingPrivateKey,
+ map[string]interface{}{
+ "iss": oidcServer.URL(),
+ "sub": defaultOIDCClaimedUsername,
+ "aud": defaultOIDCClientID,
+ "exp": time.Now().Add(idTokenLifetime).Unix(),
+ "uid": "1234",
+ },
+ defaultStubAccessToken,
+ defaultStubRefreshToken,
+ ))
+ },
+ configureClient: configureClientFetchingOIDCCredentials,
+ assertErrFn: func(t *testing.T, errorToCheck error) {
+ assert.NoError(t, errorToCheck)
+ },
+ wantUser: &authenticationv1.UserInfo{
+ Username: "k8s-john_doe",
+ Groups: []string{"system:authenticated"},
+ UID: "1234",
+ },
+ },
+ {
+ name: "user validation rule fails",
+ authConfigFn: func(t *testing.T, issuerURL, caCert string) string {
+ return fmt.Sprintf(`
+apiVersion: apiserver.config.k8s.io/v1alpha1
+kind: AuthenticationConfiguration
+jwt:
+- issuer:
+ url: %s
+ audiences:
+ - %s
+ certificateAuthority: |
+ %s
+ claimMappings:
+ username:
+ expression: "'k8s-' + claims.sub"
+ groups:
+ expression: '(claims.roles.split(",") + claims.other_roles.split(",")).map(role, "system:" + role)'
+ userValidationRules:
+ - expression: "user.groups.all(group, !group.startsWith('system:'))"
+ message: "groups cannot used reserved system: prefix"
+`, issuerURL, defaultOIDCClientID, indentCertificateAuthority(caCert))
+ },
+ configureInfrastructure: configureTestInfrastructure,
+ configureOIDCServerBehaviour: func(t *testing.T, oidcServer *utilsoidc.TestServer, signingPrivateKey *rsa.PrivateKey) {
+ idTokenLifetime := time.Second * 1200
+ oidcServer.TokenHandler().EXPECT().Token().Times(1).DoAndReturn(utilsoidc.TokenHandlerBehaviorReturningPredefinedJWT(
+ t,
+ signingPrivateKey,
+ map[string]interface{}{
+ "iss": oidcServer.URL(),
+ "sub": defaultOIDCClaimedUsername,
+ "aud": defaultOIDCClientID,
+ "exp": time.Now().Add(idTokenLifetime).Unix(),
+ "roles": "foo,bar",
+ "other_roles": "baz,qux",
+ },
+ defaultStubAccessToken,
+ defaultStubRefreshToken,
+ ))
+ },
+ configureClient: configureClientFetchingOIDCCredentials,
+ assertErrFn: func(t *testing.T, errorToCheck error) {
+ assert.True(t, apierrors.IsUnauthorized(errorToCheck), errorToCheck)
+ },
+ wantUser: nil,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ oidcServer, apiServer, signingPrivateKey, caCert, certPath := tt.configureInfrastructure(t, tt.authConfigFn)
+
+ tt.configureOIDCServerBehaviour(t, oidcServer, signingPrivateKey)
+
+ tokenURL, err := oidcServer.TokenURL()
+ require.NoError(t, err)
+
+ client := tt.configureClient(t, apiServer.ClientConfig, caCert, certPath, oidcServer.URL(), tokenURL)
+
+ ctx := testContext(t)
+
+ if tt.wantUser != nil {
+ res, err := client.AuthenticationV1().SelfSubjectReviews().Create(ctx, &authenticationv1.SelfSubjectReview{}, metav1.CreateOptions{})
+ require.NoError(t, err)
+ assert.Equal(t, *tt.wantUser, res.Status.UserInfo)
+ }
+
+ _, err = client.CoreV1().Pods(defaultNamespace).List(ctx, metav1.ListOptions{})
+ tt.assertErrFn(t, err)
+ })
+ }
+}
+
+func configureTestInfrastructure(t *testing.T, fn authenticationConfigFunc) (
oidcServer *utilsoidc.TestServer,
apiServer *kubeapiserverapptesting.TestServer,
signingPrivateKey *rsa.PrivateKey,
@@ -372,14 +747,14 @@ func configureTestInfrastructure(t *testing.T, useAuthenticationConfig bool) (
oidcServer = utilsoidc.BuildAndRunTestServer(t, caFilePath, caKeyFilePath)
- if useAuthenticationConfig {
- authenticationConfig := generateAuthenticationConfig(t, oidcServer.URL(), defaultOIDCClientID, string(caCertContent), defaultOIDCUsernamePrefix)
+ authenticationConfig := fn(t, oidcServer.URL(), string(caCertContent))
+ if len(authenticationConfig) > 0 {
apiServer = startTestAPIServerForOIDC(t, "", "", "", authenticationConfig)
} else {
apiServer = startTestAPIServerForOIDC(t, oidcServer.URL(), defaultOIDCClientID, caFilePath, "")
}
- oidcServer.JwksHandler().EXPECT().KeySet().AnyTimes().DoAndReturn(utilsoidc.DefaultJwksHandlerBehaviour(t, &signingPrivateKey.PublicKey))
+ oidcServer.JwksHandler().EXPECT().KeySet().AnyTimes().DoAndReturn(utilsoidc.DefaultJwksHandlerBehavior(t, &signingPrivateKey.PublicKey))
adminClient := kubernetes.NewForConfigOrDie(apiServer.ClientConfig)
configureRBAC(t, adminClient, defaultRole, defaultRoleBinding)
@@ -387,19 +762,19 @@ func configureTestInfrastructure(t *testing.T, useAuthenticationConfig bool) (
return oidcServer, apiServer, signingPrivateKey, caCertContent, caFilePath
}
-func configureClientFetchingOIDCCredentials(t *testing.T, restCfg *rest.Config, caCert []byte, certPath, oidcServerURL, oidcServerTokenURL string) *kubernetes.Clientset {
+func configureClientFetchingOIDCCredentials(t *testing.T, restCfg *rest.Config, caCert []byte, certPath, oidcServerURL, oidcServerTokenURL string) kubernetes.Interface {
idToken, stubRefreshToken := fetchOIDCCredentials(t, oidcServerTokenURL, caCert)
clientConfig := configureClientConfigForOIDC(t, restCfg, defaultOIDCClientID, certPath, idToken, stubRefreshToken, oidcServerURL)
return kubernetes.NewForConfigOrDie(clientConfig)
}
-func configureClientWithEmptyIDToken(t *testing.T, restCfg *rest.Config, _ []byte, certPath, oidcServerURL, _ string) *kubernetes.Clientset {
+func configureClientWithEmptyIDToken(t *testing.T, restCfg *rest.Config, _ []byte, certPath, oidcServerURL, _ string) kubernetes.Interface {
emptyIDToken, stubRefreshToken := "", defaultStubRefreshToken
clientConfig := configureClientConfigForOIDC(t, restCfg, defaultOIDCClientID, certPath, emptyIDToken, stubRefreshToken, oidcServerURL)
return kubernetes.NewForConfigOrDie(clientConfig)
}
-func configureRBAC(t *testing.T, clientset *kubernetes.Clientset, role *rbacv1.Role, binding *rbacv1.RoleBinding) {
+func configureRBAC(t *testing.T, clientset kubernetes.Interface, role *rbacv1.Role, binding *rbacv1.RoleBinding) {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), time.Second*30)
@@ -500,15 +875,17 @@ func configureOIDCServerToReturnExpiredIDToken(t *testing.T, returningExpiredTok
t.Helper()
oidcServer.TokenHandler().EXPECT().Token().Times(returningExpiredTokenTimes).DoAndReturn(func() (utilsoidc.Token, error) {
- token, err := utilsoidc.TokenHandlerBehaviourReturningPredefinedJWT(
+ token, err := utilsoidc.TokenHandlerBehaviorReturningPredefinedJWT(
t,
signingPrivateKey,
- oidcServer.URL(),
- defaultOIDCClientID,
- defaultOIDCClaimedUsername,
+ map[string]interface{}{
+ "iss": oidcServer.URL(),
+ "sub": defaultOIDCClaimedUsername,
+ "aud": defaultOIDCClientID,
+ "exp": time.Now().Add(-time.Millisecond).Unix(),
+ },
defaultStubAccessToken,
defaultStubRefreshToken,
- time.Now().Add(-time.Millisecond).Unix(),
)()
return token, err
})
@@ -548,26 +925,14 @@ func writeTempFile(t *testing.T, content string) string {
return file.Name()
}
-func generateAuthenticationConfig(t *testing.T, issuerURL, clientID, caCert, usernamePrefix string) string {
- t.Helper()
-
- // Indent the certificate authority to match the format of the generated
- // authentication config.
- caCert = strings.ReplaceAll(caCert, "\n", "\n ")
-
- return fmt.Sprintf(`
-apiVersion: apiserver.config.k8s.io/v1alpha1
-kind: AuthenticationConfiguration
-jwt:
-- issuer:
- url: %s
- audiences:
- - %s
- certificateAuthority: |
- %s
- claimMappings:
- username:
- claim: sub
- prefix: %s
-`, issuerURL, clientID, string(caCert), usernamePrefix)
+// indentCertificateAuthority indents the certificate authority to match
+// the format of the generated authentication config.
+func indentCertificateAuthority(caCert string) string {
+ return strings.ReplaceAll(caCert, "\n", "\n ")
+}
+
+func testContext(t *testing.T) context.Context {
+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+ t.Cleanup(cancel)
+ return ctx
}
diff --git a/test/utils/oidc/testserver.go b/test/utils/oidc/testserver.go
index aa957dbd95f..8eb1167accb 100644
--- a/test/utils/oidc/testserver.go
+++ b/test/utils/oidc/testserver.go
@@ -170,17 +170,12 @@ func BuildAndRunTestServer(t *testing.T, caPath, caKeyPath string) *TestServer {
return oidcServer
}
-// TokenHandlerBehaviourReturningPredefinedJWT describes the scenario when signed JWT token is being created.
-// This behaviour should being applied to the MockTokenHandler.
-func TokenHandlerBehaviourReturningPredefinedJWT(
+// TokenHandlerBehaviorReturningPredefinedJWT describes the scenario when signed JWT token is being created.
+// This behavior should being applied to the MockTokenHandler.
+func TokenHandlerBehaviorReturningPredefinedJWT(
t *testing.T,
rsaPrivateKey *rsa.PrivateKey,
- issClaim,
- audClaim,
- subClaim,
- accessToken,
- refreshToken string,
- expClaim int64,
+ claims map[string]interface{}, accessToken, refreshToken string,
) func() (Token, error) {
t.Helper()
@@ -188,18 +183,7 @@ func TokenHandlerBehaviourReturningPredefinedJWT(
signer, err := jose.NewSigner(jose.SigningKey{Algorithm: jose.RS256, Key: rsaPrivateKey}, nil)
require.NoError(t, err)
- payload := struct {
- Iss string `json:"iss"`
- Aud string `json:"aud"`
- Sub string `json:"sub"`
- Exp int64 `json:"exp"`
- }{
- Iss: issClaim,
- Aud: audClaim,
- Sub: subClaim,
- Exp: expClaim,
- }
- payloadJSON, err := json.Marshal(payload)
+ payloadJSON, err := json.Marshal(claims)
require.NoError(t, err)
idTokenSignature, err := signer.Sign(payloadJSON)
@@ -215,9 +199,9 @@ func TokenHandlerBehaviourReturningPredefinedJWT(
}
}
-// DefaultJwksHandlerBehaviour describes the scenario when JSON Web Key Set token is being returned.
-// This behaviour should being applied to the MockJWKsHandler.
-func DefaultJwksHandlerBehaviour(t *testing.T, verificationPublicKey *rsa.PublicKey) func() jose.JSONWebKeySet {
+// DefaultJwksHandlerBehavior describes the scenario when JSON Web Key Set token is being returned.
+// This behavior should being applied to the MockJWKsHandler.
+func DefaultJwksHandlerBehavior(t *testing.T, verificationPublicKey *rsa.PublicKey) func() jose.JSONWebKeySet {
t.Helper()
return func() jose.JSONWebKeySet {
diff --git a/vendor/modules.txt b/vendor/modules.txt
index 58b36562b77..0a303d68e10 100644
--- a/vendor/modules.txt
+++ b/vendor/modules.txt
@@ -1501,6 +1501,7 @@ k8s.io/apiserver/pkg/audit
k8s.io/apiserver/pkg/audit/policy
k8s.io/apiserver/pkg/authentication/authenticator
k8s.io/apiserver/pkg/authentication/authenticatorfactory
+k8s.io/apiserver/pkg/authentication/cel
k8s.io/apiserver/pkg/authentication/group
k8s.io/apiserver/pkg/authentication/request/anonymous
k8s.io/apiserver/pkg/authentication/request/bearertoken