diff --git a/api/adc/plugin_types.go b/api/adc/plugin_types.go index 6d2308864..ee68a7463 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 03c508d4a..7a05f9ca0 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,10 +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`, `HS384`, `HS512`, `RS256`, `RS384`, `RS512`, `ES256`, `ES384`, `ES512`, `PS256`, `PS384`, `PS512`, or `EdDSA`. - // Currently APISIX only supports `HS256`, `HS512`, `RS256`, and `ES256`. API7 Enterprise supports all algorithms. 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 000000000..88fdd1d60 --- /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-nocel/apisix.apache.org_v2.yaml b/config/crd-nocel/apisix.apache.org_v2.yaml index d34db6256..9e4cc186c 100644 --- a/config/crd-nocel/apisix.apache.org_v2.yaml +++ b/config/crd-nocel/apisix.apache.org_v2.yaml @@ -180,7 +180,6 @@ spec: description: |- Algorithm specifies the signing algorithm. Can be `HS256`, `HS384`, `HS512`, `RS256`, `RS384`, `RS512`, `ES256`, `ES384`, `ES512`, `PS256`, `PS384`, `PS512`, or `EdDSA`. - Currently APISIX only supports `HS256`, `HS512`, `RS256`, and `ES256`. API7 Enterprise supports all algorithms. type: string base64_secret: description: Base64Secret indicates whether the secret diff --git a/config/crd/bases/apisix.apache.org_apisixconsumers.yaml b/config/crd/bases/apisix.apache.org_apisixconsumers.yaml index 960a3e364..db0ec861f 100644 --- a/config/crd/bases/apisix.apache.org_apisixconsumers.yaml +++ b/config/crd/bases/apisix.apache.org_apisixconsumers.yaml @@ -177,7 +177,6 @@ spec: description: |- Algorithm specifies the signing algorithm. Can be `HS256`, `HS384`, `HS512`, `RS256`, `RS384`, `RS512`, `ES256`, `ES384`, `ES512`, `PS256`, `PS384`, `PS512`, or `EdDSA`. - Currently APISIX only supports `HS256`, `HS512`, `RS256`, and `ES256`. API7 Enterprise supports all algorithms. type: string base64_secret: description: Base64Secret indicates whether the secret @@ -210,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 0556b716a..9482e1d33 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`, `HS384`, `HS512`, `RS256`, `RS384`, `RS512`, `ES256`, `ES384`, `ES512`, `PS256`, `PS384`, `PS512`, or `EdDSA`. Currently APISIX only supports `HS256`, `HS512`, `RS256`, and `ES256`. API7 Enterprise supports all algorithms. | +| `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. |