You are viewing a plain text version of this content. The canonical link for it is here.
Posted to notifications@apisix.apache.org by al...@apache.org on 2026/05/09 04:45:37 UTC

(apisix-ingress-controller) branch master updated: fix: relax jwtAuth private_key requirement and add CEL validation (#2759)

This is an automated email from the ASF dual-hosted git repository.

AlinsRan pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/apisix-ingress-controller.git


The following commit(s) were added to refs/heads/master by this push:
     new e89f0f24 fix: relax jwtAuth private_key requirement and add CEL validation (#2759)
e89f0f24 is described below

commit e89f0f24ec4f6086b4539feb12a786eca6e079f3
Author: AlinsRan <al...@apache.org>
AuthorDate: Sat May 9 12:45:32 2026 +0800

    fix: relax jwtAuth private_key requirement and add CEL validation (#2759)
---
 api/adc/plugin_types.go                            |   2 +-
 api/v2/apisixconsumer_types.go                     |   9 +-
 api/v2/apisixconsumer_validation_test.go           | 337 +++++++++++++++++++++
 .../bases/apisix.apache.org_apisixconsumers.yaml   |  11 +-
 docs/en/latest/reference/api-reference.md          |   5 +-
 5 files changed, 358 insertions(+), 6 deletions(-)

diff --git a/api/adc/plugin_types.go b/api/adc/plugin_types.go
index 6d230886..ee68a746 100644
--- a/api/adc/plugin_types.go
+++ b/api/adc/plugin_types.go
@@ -68,7 +68,7 @@ type JwtAuthConsumerConfig struct {
 	Key                 string `json:"key" yaml:"key"`
 	Secret              string `json:"secret,omitempty" yaml:"secret,omitempty"`
 	PublicKey           string `json:"public_key,omitempty" yaml:"public_key,omitempty"`
-	PrivateKey          string `json:"private_key" yaml:"private_key,omitempty"`
+	PrivateKey          string `json:"private_key,omitempty" yaml:"private_key,omitempty"`
 	Algorithm           string `json:"algorithm,omitempty" yaml:"algorithm,omitempty"`
 	Exp                 int64  `json:"exp,omitempty" yaml:"exp,omitempty"`
 	Base64Secret        bool   `json:"base64_secret,omitempty" yaml:"base64_secret,omitempty"`
diff --git a/api/v2/apisixconsumer_types.go b/api/v2/apisixconsumer_types.go
index c9e4afba..7a05f9ca 100644
--- a/api/v2/apisixconsumer_types.go
+++ b/api/v2/apisixconsumer_types.go
@@ -130,6 +130,11 @@ type ApisixConsumerJwtAuth struct {
 }
 
 // ApisixConsumerJwtAuthValue defines configuration for JWT authentication.
+// For asymmetric algorithms (RS*, ES*, PS*, EdDSA), at least one of public_key
+// or private_key must be provided. Symmetric algorithms (HS256, HS384, HS512)
+// and unset algorithm do not require any key field.
+//
+// +kubebuilder:validation:XValidation:rule="!has(self.algorithm) || size(self.algorithm) == 0 || self.algorithm in ['HS256','HS384','HS512'] || (has(self.public_key) && size(self.public_key.trim()) > 0) || (has(self.private_key) && size(self.private_key.trim()) > 0)",message="algorithms other than HS256/HS384/HS512 require at least one non-empty public_key or private_key"
 type ApisixConsumerJwtAuthValue struct {
 	// Key is the unique identifier for the JWT credential.
 	Key string `json:"key" yaml:"key"`
@@ -138,9 +143,9 @@ type ApisixConsumerJwtAuthValue struct {
 	// PublicKey is the public key used to verify JWT signatures (for asymmetric algorithms).
 	PublicKey string `json:"public_key,omitempty" yaml:"public_key,omitempty"`
 	// PrivateKey is the private key used to sign the JWT (for asymmetric algorithms).
-	PrivateKey string `json:"private_key" yaml:"private_key,omitempty"`
+	PrivateKey string `json:"private_key,omitempty" yaml:"private_key,omitempty"`
 	// Algorithm specifies the signing algorithm.
-	// Can be `HS256`, `HS512`, `RS256`, or `ES256`.
+	// Can be `HS256`, `HS384`, `HS512`, `RS256`, `RS384`, `RS512`, `ES256`, `ES384`, `ES512`, `PS256`, `PS384`, `PS512`, or `EdDSA`.
 	Algorithm string `json:"algorithm,omitempty" yaml:"algorithm,omitempty"`
 	// Exp is the token expiration period in seconds.
 	Exp int64 `json:"exp,omitempty" yaml:"exp,omitempty"`
diff --git a/api/v2/apisixconsumer_validation_test.go b/api/v2/apisixconsumer_validation_test.go
new file mode 100644
index 00000000..88fdd1d6
--- /dev/null
+++ b/api/v2/apisixconsumer_validation_test.go
@@ -0,0 +1,337 @@
+// Licensed to the Apache Software Foundation (ASF) under one or more
+// contributor license agreements.  See the NOTICE file distributed with
+// this work for additional information regarding copyright ownership.
+// The ASF licenses this file to You 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 v2_test
+
+import (
+	"context"
+	"encoding/json"
+	"os"
+	"path/filepath"
+	"runtime"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+	apiextensions "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions"
+	apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
+	structuralschema "k8s.io/apiextensions-apiserver/pkg/apiserver/schema"
+	"k8s.io/apiextensions-apiserver/pkg/apiserver/schema/cel"
+	"k8s.io/apiextensions-apiserver/pkg/apiserver/validation"
+	celconfig "k8s.io/apiserver/pkg/apis/cel"
+	sigsyaml "sigs.k8s.io/yaml"
+
+	apisixv2 "github.com/apache/apisix-ingress-controller/api/v2"
+)
+
+// consumerSchemaValidator holds the parsed CRD schema for ApisixConsumer
+// and provides a Validate method for use in tests.
+type consumerSchemaValidator struct {
+	structural *structuralschema.Structural
+	internal   *apiextensions.JSONSchemaProps
+}
+
+func (v *consumerSchemaValidator) Validate(t *testing.T, ac *apisixv2.ApisixConsumer) error {
+	t.Helper()
+
+	data, err := json.Marshal(ac)
+	require.NoError(t, err, "failed to marshal ApisixConsumer")
+
+	var obj map[string]interface{}
+	require.NoError(t, json.Unmarshal(data, &obj), "failed to unmarshal to map")
+
+	schemaValidator, _, err := validation.NewSchemaValidator(v.internal)
+	require.NoError(t, err, "failed to build schema validator")
+
+	if errs := validation.ValidateCustomResource(nil, obj, schemaValidator); len(errs) > 0 {
+		return errs.ToAggregate()
+	}
+
+	celValidator := cel.NewValidator(v.structural, false, celconfig.PerCallLimit)
+	celErrs, _ := celValidator.Validate(context.Background(), nil, v.structural, obj, nil, celconfig.RuntimeCELCostBudget)
+	if len(celErrs) > 0 {
+		return celErrs.ToAggregate()
+	}
+	return nil
+}
+
+// loadApisixConsumerSchema reads the ApisixConsumer CRD YAML and returns a
+// validator backed by the real generated schema.
+func loadApisixConsumerSchema(t *testing.T) *consumerSchemaValidator {
+	t.Helper()
+
+	_, thisFile, _, _ := runtime.Caller(0)
+	crdPath := filepath.Join(filepath.Dir(thisFile), "..", "..",
+		"config", "crd", "bases", "apisix.apache.org_apisixconsumers.yaml")
+
+	data, err := os.ReadFile(crdPath)
+	require.NoError(t, err, "failed to read CRD file: %s", crdPath)
+
+	jsonData, err := sigsyaml.YAMLToJSON(data)
+	require.NoError(t, err, "failed to convert CRD YAML to JSON")
+
+	var crd apiextensionsv1.CustomResourceDefinition
+	require.NoError(t, json.Unmarshal(jsonData, &crd), "failed to unmarshal CRD")
+
+	var v1Schema *apiextensionsv1.JSONSchemaProps
+	for _, v := range crd.Spec.Versions {
+		if v.Name == "v2" {
+			v1Schema = v.Schema.OpenAPIV3Schema
+			break
+		}
+	}
+	require.NotNil(t, v1Schema, "v2 schema not found in CRD")
+
+	var internal apiextensions.JSONSchemaProps
+	require.NoError(t,
+		apiextensionsv1.Convert_v1_JSONSchemaProps_To_apiextensions_JSONSchemaProps(v1Schema, &internal, nil),
+		"failed to convert v1 schema to internal",
+	)
+
+	structural, err := structuralschema.NewStructural(&internal)
+	require.NoError(t, err, "failed to build structural schema")
+	return &consumerSchemaValidator{structural: structural, internal: &internal}
+}
+
+func TestApisixConsumer_JwtAuth_SymmetricHS256(t *testing.T) {
+	v := loadApisixConsumerSchema(t)
+	ac := &apisixv2.ApisixConsumer{
+		Spec: apisixv2.ApisixConsumerSpec{
+			AuthParameter: apisixv2.ApisixConsumerAuthParameter{
+				JwtAuth: &apisixv2.ApisixConsumerJwtAuth{
+					Value: &apisixv2.ApisixConsumerJwtAuthValue{
+						Key:       "my-key",
+						Secret:    "my-secret",
+						Algorithm: "HS256",
+					},
+				},
+			},
+		},
+	}
+	assert.NoError(t, v.Validate(t, ac))
+}
+
+// TestApisixConsumer_JwtAuth_AsymmetricWithWhitespaceOnlyPublicKey verifies
+// that a whitespace-only public_key is treated as absent and rejected for
+// asymmetric algorithms.
+func TestApisixConsumer_JwtAuth_AsymmetricWithWhitespaceOnlyPublicKey(t *testing.T) {
+	v := loadApisixConsumerSchema(t)
+	ac := &apisixv2.ApisixConsumer{
+		Spec: apisixv2.ApisixConsumerSpec{
+			AuthParameter: apisixv2.ApisixConsumerAuthParameter{
+				JwtAuth: &apisixv2.ApisixConsumerJwtAuth{
+					Value: &apisixv2.ApisixConsumerJwtAuthValue{
+						Key:       "my-key",
+						Algorithm: "RS256",
+						PublicKey: "   ",
+					},
+				},
+			},
+		},
+	}
+	err := v.Validate(t, ac)
+	require.Error(t, err)
+	assert.Contains(t, err.Error(), "algorithms other than HS256/HS384/HS512")
+}
+
+func TestApisixConsumer_JwtAuth_SymmetricHS512(t *testing.T) {
+	v := loadApisixConsumerSchema(t)
+	ac := &apisixv2.ApisixConsumer{
+		Spec: apisixv2.ApisixConsumerSpec{
+			AuthParameter: apisixv2.ApisixConsumerAuthParameter{
+				JwtAuth: &apisixv2.ApisixConsumerJwtAuth{
+					Value: &apisixv2.ApisixConsumerJwtAuthValue{
+						Key:       "my-key",
+						Secret:    "my-secret",
+						Algorithm: "HS512",
+					},
+				},
+			},
+		},
+	}
+	assert.NoError(t, v.Validate(t, ac))
+}
+
+func TestApisixConsumer_JwtAuth_NoAlgorithmDefaultsToSymmetric(t *testing.T) {
+	v := loadApisixConsumerSchema(t)
+	ac := &apisixv2.ApisixConsumer{
+		Spec: apisixv2.ApisixConsumerSpec{
+			AuthParameter: apisixv2.ApisixConsumerAuthParameter{
+				JwtAuth: &apisixv2.ApisixConsumerJwtAuth{
+					Value: &apisixv2.ApisixConsumerJwtAuthValue{
+						Key:    "my-key",
+						Secret: "my-secret",
+					},
+				},
+			},
+		},
+	}
+	assert.NoError(t, v.Validate(t, ac))
+}
+
+func TestApisixConsumer_JwtAuth_AsymmetricRS256WithPublicKey(t *testing.T) {
+	v := loadApisixConsumerSchema(t)
+	ac := &apisixv2.ApisixConsumer{
+		Spec: apisixv2.ApisixConsumerSpec{
+			AuthParameter: apisixv2.ApisixConsumerAuthParameter{
+				JwtAuth: &apisixv2.ApisixConsumerJwtAuth{
+					Value: &apisixv2.ApisixConsumerJwtAuthValue{
+						Key:       "my-key",
+						PublicKey: "test-public-key",
+						Algorithm: "RS256",
+					},
+				},
+			},
+		},
+	}
+	assert.NoError(t, v.Validate(t, ac))
+}
+
+func TestApisixConsumer_JwtAuth_AsymmetricRS256WithPrivateKey(t *testing.T) {
+	v := loadApisixConsumerSchema(t)
+	ac := &apisixv2.ApisixConsumer{
+		Spec: apisixv2.ApisixConsumerSpec{
+			AuthParameter: apisixv2.ApisixConsumerAuthParameter{
+				JwtAuth: &apisixv2.ApisixConsumerJwtAuth{
+					Value: &apisixv2.ApisixConsumerJwtAuthValue{
+						Key:        "my-key",
+						PrivateKey: "test-private-key",
+						Algorithm:  "RS256",
+					},
+				},
+			},
+		},
+	}
+	assert.NoError(t, v.Validate(t, ac))
+}
+
+func TestApisixConsumer_JwtAuth_AsymmetricRS256WithBothKeys(t *testing.T) {
+	v := loadApisixConsumerSchema(t)
+	ac := &apisixv2.ApisixConsumer{
+		Spec: apisixv2.ApisixConsumerSpec{
+			AuthParameter: apisixv2.ApisixConsumerAuthParameter{
+				JwtAuth: &apisixv2.ApisixConsumerJwtAuth{
+					Value: &apisixv2.ApisixConsumerJwtAuthValue{
+						Key:        "my-key",
+						PublicKey:  "test-public-key",
+						PrivateKey: "test-private-key",
+						Algorithm:  "RS256",
+					},
+				},
+			},
+		},
+	}
+	assert.NoError(t, v.Validate(t, ac))
+}
+
+func TestApisixConsumer_JwtAuth_AsymmetricRS256WithoutAnyKey(t *testing.T) {
+	v := loadApisixConsumerSchema(t)
+	ac := &apisixv2.ApisixConsumer{
+		Spec: apisixv2.ApisixConsumerSpec{
+			AuthParameter: apisixv2.ApisixConsumerAuthParameter{
+				JwtAuth: &apisixv2.ApisixConsumerJwtAuth{
+					Value: &apisixv2.ApisixConsumerJwtAuthValue{
+						Key:       "my-key",
+						Algorithm: "RS256",
+					},
+				},
+			},
+		},
+	}
+	err := v.Validate(t, ac)
+	require.Error(t, err)
+	assert.Contains(t, err.Error(), "algorithms other than HS256/HS384/HS512")
+}
+
+func TestApisixConsumer_JwtAuth_AsymmetricES256WithoutAnyKey(t *testing.T) {
+	v := loadApisixConsumerSchema(t)
+	ac := &apisixv2.ApisixConsumer{
+		Spec: apisixv2.ApisixConsumerSpec{
+			AuthParameter: apisixv2.ApisixConsumerAuthParameter{
+				JwtAuth: &apisixv2.ApisixConsumerJwtAuth{
+					Value: &apisixv2.ApisixConsumerJwtAuthValue{
+						Key:       "my-key",
+						Algorithm: "ES256",
+					},
+				},
+			},
+		},
+	}
+	err := v.Validate(t, ac)
+	require.Error(t, err)
+	assert.Contains(t, err.Error(), "algorithms other than HS256/HS384/HS512")
+}
+
+func TestApisixConsumer_JwtAuth_AsymmetricEdDSAWithoutAnyKey(t *testing.T) {
+	v := loadApisixConsumerSchema(t)
+	ac := &apisixv2.ApisixConsumer{
+		Spec: apisixv2.ApisixConsumerSpec{
+			AuthParameter: apisixv2.ApisixConsumerAuthParameter{
+				JwtAuth: &apisixv2.ApisixConsumerJwtAuth{
+					Value: &apisixv2.ApisixConsumerJwtAuthValue{
+						Key:       "my-key",
+						Algorithm: "EdDSA",
+					},
+				},
+			},
+		},
+	}
+	err := v.Validate(t, ac)
+	require.Error(t, err)
+	assert.Contains(t, err.Error(), "algorithms other than HS256/HS384/HS512")
+}
+
+func TestApisixConsumer_JwtAuth_AsymmetricWithEmptyPublicKey(t *testing.T) {
+	v := loadApisixConsumerSchema(t)
+	ac := &apisixv2.ApisixConsumer{
+		Spec: apisixv2.ApisixConsumerSpec{
+			AuthParameter: apisixv2.ApisixConsumerAuthParameter{
+				JwtAuth: &apisixv2.ApisixConsumerJwtAuth{
+					Value: &apisixv2.ApisixConsumerJwtAuthValue{
+						Key:       "my-key",
+						Algorithm: "RS256",
+						// PublicKey is empty string — omitempty means it won't appear
+						// in the serialized JSON, same effect as not set
+					},
+				},
+			},
+		},
+	}
+	err := v.Validate(t, ac)
+	require.Error(t, err)
+	assert.Contains(t, err.Error(), "algorithms other than HS256/HS384/HS512")
+}
+
+// TestApisixConsumer_JwtAuth_EmptyAlgorithmTreatedAsSymmetric verifies that an
+// explicitly empty algorithm string is treated the same as an unset algorithm
+// (defaults to HS256) and does not require public_key or private_key.
+func TestApisixConsumer_JwtAuth_EmptyAlgorithmTreatedAsSymmetric(t *testing.T) {
+	v := loadApisixConsumerSchema(t)
+	ac := &apisixv2.ApisixConsumer{
+		Spec: apisixv2.ApisixConsumerSpec{
+			AuthParameter: apisixv2.ApisixConsumerAuthParameter{
+				JwtAuth: &apisixv2.ApisixConsumerJwtAuth{
+					Value: &apisixv2.ApisixConsumerJwtAuthValue{
+						Key:    "my-key",
+						Secret: "my-secret",
+						// Algorithm is explicitly empty string — should be treated as
+						// unset and not require asymmetric keys.
+					},
+				},
+			},
+		},
+	}
+	assert.NoError(t, v.Validate(t, ac))
+}
diff --git a/config/crd/bases/apisix.apache.org_apisixconsumers.yaml b/config/crd/bases/apisix.apache.org_apisixconsumers.yaml
index 9235ef27..db0ec861 100644
--- a/config/crd/bases/apisix.apache.org_apisixconsumers.yaml
+++ b/config/crd/bases/apisix.apache.org_apisixconsumers.yaml
@@ -176,7 +176,7 @@ spec:
                           algorithm:
                             description: |-
                               Algorithm specifies the signing algorithm.
-                              Can be `HS256`, `HS512`, `RS256`, or `ES256`.
+                              Can be `HS256`, `HS384`, `HS512`, `RS256`, `RS384`, `RS512`, `ES256`, `ES384`, `ES512`, `PS256`, `PS384`, `PS512`, or `EdDSA`.
                             type: string
                           base64_secret:
                             description: Base64Secret indicates whether the secret
@@ -209,8 +209,15 @@ spec:
                             type: string
                         required:
                         - key
-                        - private_key
                         type: object
+                        x-kubernetes-validations:
+                        - message: algorithms other than HS256/HS384/HS512 require
+                            at least one non-empty public_key or private_key
+                          rule: '!has(self.algorithm) || size(self.algorithm) == 0
+                            || self.algorithm in [''HS256'',''HS384'',''HS512''] ||
+                            (has(self.public_key) && size(self.public_key.trim())
+                            > 0) || (has(self.private_key) && size(self.private_key.trim())
+                            > 0)'
                     type: object
                   keyAuth:
                     description: KeyAuth configures the key authentication details.
diff --git a/docs/en/latest/reference/api-reference.md b/docs/en/latest/reference/api-reference.md
index f385468d..4ee8a633 100644
--- a/docs/en/latest/reference/api-reference.md
+++ b/docs/en/latest/reference/api-reference.md
@@ -781,6 +781,9 @@ _Appears in:_
 
 
 ApisixConsumerJwtAuthValue defines configuration for JWT authentication.
+For asymmetric algorithms (RS*, ES*, PS*, EdDSA), at least one of public_key
+or private_key must be provided. Symmetric algorithms (HS256, HS384, HS512)
+and unset algorithm do not require any key field.
 
 
 
@@ -790,7 +793,7 @@ ApisixConsumerJwtAuthValue defines configuration for JWT authentication.
 | `secret` _string_ | Secret is the shared secret used to sign the JWT (for symmetric algorithms). |
 | `public_key` _string_ | PublicKey is the public key used to verify JWT signatures (for asymmetric algorithms). |
 | `private_key` _string_ | PrivateKey is the private key used to sign the JWT (for asymmetric algorithms). |
-| `algorithm` _string_ | Algorithm specifies the signing algorithm. Can be `HS256`, `HS512`, `RS256`, or `ES256`. |
+| `algorithm` _string_ | Algorithm specifies the signing algorithm. Can be `HS256`, `HS384`, `HS512`, `RS256`, `RS384`, `RS512`, `ES256`, `ES384`, `ES512`, `PS256`, `PS384`, `PS512`, or `EdDSA`. |
 | `exp` _integer_ | Exp is the token expiration period in seconds. |
 | `base64_secret` _boolean_ | Base64Secret indicates whether the secret is base64-encoded. |
 | `lifetime_grace_period` _integer_ | LifetimeGracePeriod is the allowed clock skew in seconds for token expiration. |