Skip to content
2 changes: 1 addition & 1 deletion api/adc/plugin_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down
7 changes: 6 additions & 1 deletion api/v2/apisixconsumer_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand All @@ -138,7 +143,7 @@ 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"`
Comment thread
AlinsRan marked this conversation as resolved.
// 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.
Expand Down
337 changes: 337 additions & 0 deletions api/v2/apisixconsumer_validation_test.go
Original file line number Diff line number Diff line change
@@ -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
},
},
},
},
}
Comment on lines +298 to +311
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))
Comment on lines +317 to +336
}
9 changes: 8 additions & 1 deletion config/crd/bases/apisix.apache.org_apisixconsumers.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -210,8 +210,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.
Expand Down
3 changes: 3 additions & 0 deletions docs/en/latest/reference/api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.



Expand Down
Loading