Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ require (
sigs.k8s.io/kube-storage-version-migrator v0.0.6-0.20230721195810-5c8923c5ff96
)

replace github.com/openshift/api => github.com/ShazaAldawamneh/api v0.0.0-20251105172922-8847fb59bcff

require (
cel.dev/expr v0.24.0 // indirect
github.com/NYTimes/gziphandler v1.1.1 // indirect
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ github.com/PaesslerAG/jsonpath v0.1.1 h1:c1/AToHQMVsduPAa4Vh6xp2U0evy4t8SWp8imEs
github.com/PaesslerAG/jsonpath v0.1.1/go.mod h1:lVboNxFGal/VwW6d9JzIy56bUsYAP6tH/x80vjnCseY=
github.com/RangelReale/osincli v0.0.0-20160924135400-fababb0555f2 h1:x8Brv0YNEe6jY3V/hQglIG2nd8g5E2Zj5ubGKkPQctQ=
github.com/RangelReale/osincli v0.0.0-20160924135400-fababb0555f2/go.mod h1:XyjUkMA8GN+tOOPXvnbi3XuRxWFvTJntqvTFnjmhzbk=
github.com/ShazaAldawamneh/api v0.0.0-20251105172922-8847fb59bcff h1:HvT7pgcTmEsgNLgyCPaB/znHfwdZd8MBwYwbj3GEqMc=
github.com/ShazaAldawamneh/api v0.0.0-20251105172922-8847fb59bcff/go.mod h1:d5uzF0YN2nQQFA0jIEWzzOZ+edmo6wzlGLvx5Fhz4uY=
github.com/antlr4-go/antlr/v4 v4.13.0 h1:lxCg3LAv+EUK6t1i0y1V6/SLeUi0eKEKdhQAlS8TVTI=
github.com/antlr4-go/antlr/v4 v4.13.0/go.mod h1:pfChB/xh/Unjila75QW7+VU4TSnWnnk9UTnmpPaOR2g=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
Expand Down Expand Up @@ -147,8 +149,6 @@ github.com/onsi/ginkgo/v2 v2.21.0 h1:7rg/4f3rB88pb5obDgNZrNHrQ4e6WpjonchcpuBRnZM
github.com/onsi/ginkgo/v2 v2.21.0/go.mod h1:7Du3c42kxCUegi0IImZ1wUQzMBVecgIHjR1C+NkhLQo=
github.com/onsi/gomega v1.35.1 h1:Cwbd75ZBPxFSuZ6T+rN/WCb/gOc6YgFBXLlZLhC7Ds4=
github.com/onsi/gomega v1.35.1/go.mod h1:PvZbdDc8J6XJEpDK4HCuRBm8a6Fzp9/DmhC9C7yFlog=
github.com/openshift/api v0.0.0-20251106190826-ebe535b08719 h1:KEwYyKaJniwhoyLB75tAMmJn9pMlk0PUlRfrsXYOhwM=
github.com/openshift/api v0.0.0-20251106190826-ebe535b08719/go.mod h1:d5uzF0YN2nQQFA0jIEWzzOZ+edmo6wzlGLvx5Fhz4uY=
github.com/openshift/build-machinery-go v0.0.0-20250530140348-dc5b2804eeee h1:+Sp5GGnjHDhT/a/nQ1xdp43UscBMr7G5wxsYotyhzJ4=
github.com/openshift/build-machinery-go v0.0.0-20250530140348-dc5b2804eeee/go.mod h1:8jcm8UPtg2mCAsxfqKil1xrmRMI3a+XU2TZ9fF8A7TE=
github.com/openshift/client-go v0.0.0-20251015124057-db0dee36e235 h1:9JBeIXmnHlpXTQPi7LPmu1jdxznBhAE7bb1K+3D8gxY=
Expand Down
86 changes: 78 additions & 8 deletions pkg/controllers/externaloidc/externaloidc_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,7 @@ func (c *externalOIDCController) generateAuthConfig(auth configv1.Authentication
func generateJWTForProvider(provider configv1.OIDCProvider, configMapLister corev1listers.ConfigMapLister, featureGates featuregates.FeatureGate, serviceAccountIssuer string) (apiserverv1beta1.JWTAuthenticator, error) {
out := apiserverv1beta1.JWTAuthenticator{}

issuer, err := generateIssuer(provider.Issuer, configMapLister, serviceAccountIssuer)
issuer, err := generateIssuer(provider.Issuer, featureGates, configMapLister, serviceAccountIssuer)
if err != nil {
return apiserverv1beta1.JWTAuthenticator{}, fmt.Errorf("generating issuer for provider %q: %v", provider.Name, err)
}
Expand All @@ -188,19 +188,27 @@ func generateJWTForProvider(provider configv1.OIDCProvider, configMapLister core
return apiserverv1beta1.JWTAuthenticator{}, fmt.Errorf("generating claimMappings for provider %q: %v", provider.Name, err)
}

claimValidationRules, err := generateClaimValidationRules(provider.ClaimValidationRules...)
claimValidationRules, err := generateClaimValidationRules(featureGates, provider.ClaimValidationRules...)
if err != nil {
return apiserverv1beta1.JWTAuthenticator{}, fmt.Errorf("generating claimValidationRules for provider %q: %v", provider.Name, err)
}

var userValidationRules []apiserverv1beta1.UserValidationRule
if featureGates.Enabled(features.FeatureGateExternalOIDCWithUpstreamParity) {
userValidationRules, err = generateUserValidationRules(featureGates, provider.UserValidationRules)
if err != nil {
return apiserverv1beta1.JWTAuthenticator{}, fmt.Errorf("generating userValidationRules for provider %q: %v", provider.Name, err)
}
}
out.Issuer = issuer
out.ClaimMappings = claimMappings
out.ClaimValidationRules = claimValidationRules
out.UserValidationRules = userValidationRules

return out, nil
}

func generateIssuer(issuer configv1.TokenIssuer, configMapLister corev1listers.ConfigMapLister, serviceAccountIssuer string) (apiserverv1beta1.Issuer, error) {
func generateIssuer(issuer configv1.TokenIssuer, featureGates featuregates.FeatureGate, configMapLister corev1listers.ConfigMapLister, serviceAccountIssuer string) (apiserverv1beta1.Issuer, error) {
out := apiserverv1beta1.Issuer{}

if len(serviceAccountIssuer) > 0 {
Expand All @@ -215,6 +223,11 @@ func generateIssuer(issuer configv1.TokenIssuer, configMapLister corev1listers.C
for _, audience := range issuer.Audiences {
out.Audiences = append(out.Audiences, string(audience))
}
if featureGates.Enabled(features.FeatureGateExternalOIDCWithUpstreamParity) {
if issuer.DiscoveryURL != "" {
out.DiscoveryURL = &issuer.DiscoveryURL
}
}

if len(issuer.CertificateAuthority.Name) > 0 {
ca, err := getCertificateAuthorityFromConfigMap(issuer.CertificateAuthority.Name, configMapLister)
Expand Down Expand Up @@ -394,11 +407,11 @@ func generateExtraMapping(extraMapping configv1.ExtraMapping) (apiserverv1beta1.
return out, nil
}

func generateClaimValidationRules(claimValidationRules ...configv1.TokenClaimValidationRule) ([]apiserverv1beta1.ClaimValidationRule, error) {
func generateClaimValidationRules(featureGates featuregates.FeatureGate, claimValidationRules ...configv1.TokenClaimValidationRule) ([]apiserverv1beta1.ClaimValidationRule, error) {
out := []apiserverv1beta1.ClaimValidationRule{}
errs := []error{}
for _, claimValidationRule := range claimValidationRules {
rule, err := generateClaimValidationRule(claimValidationRule)
rule, err := generateClaimValidationRule(claimValidationRule, featureGates)
if err != nil {
errs = append(errs, fmt.Errorf("generating claimValidationRule: %v", err))
continue
Expand All @@ -414,27 +427,84 @@ func generateClaimValidationRules(claimValidationRules ...configv1.TokenClaimVal
return out, nil
}

func generateClaimValidationRule(claimValidationRule configv1.TokenClaimValidationRule) (apiserverv1beta1.ClaimValidationRule, error) {
func generateClaimValidationRule(claimValidationRule configv1.TokenClaimValidationRule, featureGates featuregates.FeatureGate) (apiserverv1beta1.ClaimValidationRule, error) {
out := apiserverv1beta1.ClaimValidationRule{}

// Currently, the authentications.config.openshift.io CRD only allows setting a claim and required value for the
// validation rule and does not allow setting a CEL expression and message like the upstream.
// This is likely to change in the near future to also allow setting a CEL expression.
switch claimValidationRule.Type {
case configv1.TokenValidationRuleTypeRequiredClaim:
case configv1.TokenValidationRuleRequiredClaim:
if claimValidationRule.RequiredClaim == nil {
return apiserverv1beta1.ClaimValidationRule{}, fmt.Errorf("claimValidationRule.type is %s and requiredClaim is not set", configv1.TokenValidationRuleTypeRequiredClaim)
return apiserverv1beta1.ClaimValidationRule{}, fmt.Errorf("claimValidationRule.type is %s and requiredClaim is not set", configv1.TokenValidationRuleRequiredClaim)
}

out.Claim = claimValidationRule.RequiredClaim.Claim
out.RequiredValue = claimValidationRule.RequiredClaim.RequiredValue
case configv1.TokenValidationRuleExpression:
if !featureGates.Enabled(features.FeatureGateExternalOIDCWithUpstreamParity) {
// skip CEL expression handling if the feature is disabled
return apiserverv1beta1.ClaimValidationRule{}, fmt.Errorf(
"TokenValidationRuleExpression is not enabled without the feature gate")
}
if len(claimValidationRule.Expression.Expression) == 0 {
return apiserverv1beta1.ClaimValidationRule{}, fmt.Errorf("claimValidationRule.type is %s and expression is not set", configv1.TokenValidationRuleExpression)
}

// validate CEL expression
if err := validateCELExpression(&authenticationcel.ClaimMappingExpression{
Expression: claimValidationRule.Expression.Expression,
}); err != nil {
return apiserverv1beta1.ClaimValidationRule{}, fmt.Errorf("invalid CEL expression: %v", err)
}
Comment on lines +454 to +459
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As a note, we should update https://github.com/openshift/kubernetes/blob/891f5bb0306166d5625b89fc8dc86bbc8c85f549/openshift-kube-apiserver/admission/customresourcevalidation/authentication/validate_authentication.go#L240 to perform admission time validation of these expressions.

It doesn't hurt to also include the runtime validations here, but admission validations should be sufficient here because we are embedding that in the kube-apiserver

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not entirely sure what you mean here, can you explain more in details on what needs to be done ?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I mean that we will also need to take an action item to update our admission plugin that we inject into the Kubernetes API server to validate that the CEL expressions here compile successfully.

There is nothing that needs to be done in this PR to address this comment.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see, make sense, will make a note on that!


out.Expression = claimValidationRule.Expression.Expression
out.Message = claimValidationRule.Expression.Message

default:
return apiserverv1beta1.ClaimValidationRule{}, fmt.Errorf("unknown claimValidationRule type %q", claimValidationRule.Type)
}

return out, nil
}

func generateUserValidationRule(rule configv1.TokenUserValidationRule, featureGates featuregates.FeatureGate) (apiserverv1beta1.UserValidationRule, error) {
if len(rule.Expression) == 0 {
return apiserverv1beta1.UserValidationRule{}, fmt.Errorf("userValidationRule expression must be non-empty")
}

// Optional: only enable user validation rules if a feature gate is active
if !featureGates.Enabled(features.FeatureGateExternalOIDCWithUpstreamParity) {
return apiserverv1beta1.UserValidationRule{}, fmt.Errorf(
"userValidationRule cannot be used without the feature gate")
}

return apiserverv1beta1.UserValidationRule{
Expression: rule.Expression,
Message: rule.Message,
}, nil
}

func generateUserValidationRules(featureGates featuregates.FeatureGate, rules []configv1.TokenUserValidationRule) ([]apiserverv1beta1.UserValidationRule, error) {
out := []apiserverv1beta1.UserValidationRule{}
errs := []error{}

for _, r := range rules {
uvr, err := generateUserValidationRule(r, featureGates)
if err != nil {
errs = append(errs, fmt.Errorf("generating userValidationRule: %v", err))
continue
}
out = append(out, uvr)
}

if len(errs) > 0 {
return nil, errors.Join(errs...)
}

return out, nil
}

// getExpectedApplyConfig serializes the input authConfig into JSON and creates an apply configuration
// for a configmap with the serialized authConfig in the right key.
func getExpectedApplyConfig(authConfig apiserverv1beta1.AuthenticationConfiguration) (*corev1ac.ConfigMapApplyConfiguration, error) {
Expand Down
76 changes: 70 additions & 6 deletions pkg/controllers/externaloidc/externaloidc_controller_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -93,14 +93,14 @@ var (
},
ClaimValidationRules: []configv1.TokenClaimValidationRule{
{
Type: configv1.TokenValidationRuleTypeRequiredClaim,
Type: configv1.TokenValidationRuleRequiredClaim,
RequiredClaim: &configv1.TokenRequiredClaim{
Claim: "username",
RequiredValue: "test-username",
},
},
{
Type: configv1.TokenValidationRuleTypeRequiredClaim,
Type: configv1.TokenValidationRuleRequiredClaim,
RequiredClaim: &configv1.TokenRequiredClaim{
Claim: "email",
RequiredValue: "test-email",
Expand Down Expand Up @@ -690,10 +690,13 @@ func TestExternalOIDCController_generateAuthConfig(t *testing.T) {
if len(copy.Spec.OIDCProviders[i].ClaimValidationRules) == 0 {
copy.Spec.OIDCProviders[i].ClaimValidationRules = make([]configv1.TokenClaimValidationRule, 0)
}
copy.Spec.OIDCProviders[i].ClaimValidationRules = append(copy.Spec.OIDCProviders[i].ClaimValidationRules, configv1.TokenClaimValidationRule{
Type: configv1.TokenValidationRuleTypeRequiredClaim,
RequiredClaim: nil,
})
copy.Spec.OIDCProviders[i].ClaimValidationRules = append(
copy.Spec.OIDCProviders[i].ClaimValidationRules,
configv1.TokenClaimValidationRule{
Type: configv1.TokenValidationRuleRequiredClaim,
RequiredClaim: nil,
},
)
}
},
}),
Expand Down Expand Up @@ -1056,6 +1059,67 @@ func TestExternalOIDCController_generateAuthConfig(t *testing.T) {
[]configv1.FeatureGateName{},
),
},
{
name: "invalid discovery URL (empty)",
auth: *authWithUpdates(baseAuthResource, []func(auth *configv1.Authentication){
func(auth *configv1.Authentication) {
auth.Spec.OIDCProviders[0].Issuer.URL = ""
},
}),
expectError: true,
featureGates: featuregates.NewFeatureGate(
[]configv1.FeatureGateName{features.FeatureGateExternalOIDCWithUpstreamParity},
[]configv1.FeatureGateName{},
),
},
{
name: "invalid discovery URL (http instead of https)",
auth: *authWithUpdates(baseAuthResource, []func(auth *configv1.Authentication){
func(auth *configv1.Authentication) {
auth.Spec.OIDCProviders[0].Issuer.URL = "http://insecure-url.com"
},
}),
expectError: true,
featureGates: featuregates.NewFeatureGate(
[]configv1.FeatureGateName{features.FeatureGateExternalOIDCWithUpstreamParity},
[]configv1.FeatureGateName{},
),
},
{
name: "claim validation rule missing required claim",
auth: *authWithUpdates(baseAuthResource, []func(auth *configv1.Authentication){
func(auth *configv1.Authentication) {
auth.Spec.OIDCProviders[0].ClaimValidationRules = append(
auth.Spec.OIDCProviders[0].ClaimValidationRules,
configv1.TokenClaimValidationRule{
Type: configv1.TokenValidationRuleRequiredClaim,
RequiredClaim: nil,
},
)
},
}),
expectError: true,
featureGates: featuregates.NewFeatureGate(
[]configv1.FeatureGateName{features.FeatureGateExternalOIDCWithUpstreamParity},
[]configv1.FeatureGateName{},
),
},
{
name: "user validation rule invalid username prefix",
auth: *authWithUpdates(baseAuthResource, []func(auth *configv1.Authentication){
func(auth *configv1.Authentication) {
auth.Spec.OIDCProviders[0].ClaimMappings.Username = configv1.UsernameClaimMapping{
Claim: "username",
PrefixPolicy: configv1.UsernamePrefixPolicy("invalid-policy"),
}
},
}),
expectError: true,
featureGates: featuregates.NewFeatureGate(
[]configv1.FeatureGateName{features.FeatureGateExternalOIDCWithUpstreamParity},
[]configv1.FeatureGateName{},
),
},
} {
t.Run(tt.name, func(t *testing.T) {
if tt.configMapIndexer == nil {
Expand Down
2 changes: 1 addition & 1 deletion pkg/operator/starter.go
Original file line number Diff line number Diff line change
Expand Up @@ -773,7 +773,7 @@ func prepareExternalOIDC(
return nil, nil, fmt.Errorf("timed out waiting for FeatureGate detection")
}

if !(featureGates.Enabled(features.FeatureGateExternalOIDC) || featureGates.Enabled(features.FeatureGateExternalOIDCWithAdditionalClaimMappings)) {
if !(featureGates.Enabled(features.FeatureGateExternalOIDC) || featureGates.Enabled(features.FeatureGateExternalOIDCWithAdditionalClaimMappings) || featureGates.Enabled(features.FeatureGateExternalOIDCWithUpstreamParity)) {
return nil, nil, nil
}

Expand Down
Loading