Skip to content
Merged
184 changes: 184 additions & 0 deletions api/v2/apisixconsumer_jwtauth_cel_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
// 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"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
apiextensions "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions"
structuralschema "k8s.io/apiextensions-apiserver/pkg/apiserver/schema"
"k8s.io/apiextensions-apiserver/pkg/apiserver/schema/cel"
celconfig "k8s.io/apiserver/pkg/apis/cel"
)

// jwtAuthValueSchema mirrors the CEL rule on ApisixConsumerJwtAuthValue.
// It must be kept in sync with the +kubebuilder:validation:XValidation marker
// on that type.
var jwtAuthValueSchema = &apiextensions.JSONSchemaProps{
Type: "object",
Properties: map[string]apiextensions.JSONSchemaProps{
"key": {Type: "string"},
"secret": {Type: "string"},
"public_key": {Type: "string"},
"private_key": {Type: "string"},
"algorithm": {Type: "string"},
"exp": {Type: "integer", Format: "int64"},
"base64_secret": {Type: "boolean"},
"lifetime_grace_period": {Type: "integer", Format: "int64"},
},
Required: []string{"key"},
XValidations: []apiextensions.ValidationRule{
{
Rule: "!has(self.algorithm) || self.algorithm in ['HS256','HS384','HS512'] || (has(self.public_key) && self.public_key != '') || (has(self.private_key) && self.private_key != '')",
Comment thread
AlinsRan marked this conversation as resolved.
Outdated
Message: "asymmetric JWT algorithms (RS*/ES*/PS*/EdDSA) require at least one of public_key or private_key",
},
},
}

func validateJwtAuthValue(t *testing.T, obj map[string]interface{}) error {
t.Helper()
structural, err := structuralschema.NewStructural(jwtAuthValueSchema)
require.NoError(t, err, "failed to build structural schema")

celValidator := cel.NewValidator(structural, false, celconfig.PerCallLimit)
errs, _ := celValidator.Validate(context.Background(), nil, structural, obj, nil, celconfig.RuntimeCELCostBudget)
if len(errs) > 0 {
return errs.ToAggregate()
}
return nil
}

// TestJwtAuthCEL_SymmetricHS256WithSecret verifies that HS256 + secret
// without private_key passes CEL validation.
func TestJwtAuthCEL_SymmetricHS256WithSecret(t *testing.T) {
obj := map[string]interface{}{
"key": "my-key",
"secret": "my-secret",
"algorithm": "HS256",
}
assert.NoError(t, validateJwtAuthValue(t, obj))
}

// TestJwtAuthCEL_SymmetricHS384WithSecret verifies that HS384 + secret passes.
func TestJwtAuthCEL_SymmetricHS384WithSecret(t *testing.T) {
obj := map[string]interface{}{
"key": "my-key",
"secret": "my-secret",
"algorithm": "HS384",
}
assert.NoError(t, validateJwtAuthValue(t, obj))
}

// TestJwtAuthCEL_SymmetricHS512WithSecret verifies that HS512 + secret passes.
func TestJwtAuthCEL_SymmetricHS512WithSecret(t *testing.T) {
obj := map[string]interface{}{
"key": "my-key",
"secret": "my-secret",
"algorithm": "HS512",
}
assert.NoError(t, validateJwtAuthValue(t, obj))
}

// TestJwtAuthCEL_NoAlgorithmDefaultsToSymmetric verifies that omitting
// algorithm (defaults to HS256 server-side) passes CEL validation.
func TestJwtAuthCEL_NoAlgorithmDefaultsToSymmetric(t *testing.T) {
obj := map[string]interface{}{
"key": "my-key",
"secret": "my-secret",
}
assert.NoError(t, validateJwtAuthValue(t, obj))
}

// TestJwtAuthCEL_AsymmetricRS256WithPublicKey verifies that RS256 + public_key passes.
func TestJwtAuthCEL_AsymmetricRS256WithPublicKey(t *testing.T) {
obj := map[string]interface{}{
"key": "my-key",
"public_key": "-----BEGIN PUBLIC KEY-----\nMFww\n-----END PUBLIC KEY-----",
"algorithm": "RS256",
}
assert.NoError(t, validateJwtAuthValue(t, obj))
}

// TestJwtAuthCEL_AsymmetricRS256WithPrivateKey verifies that RS256 + private_key passes
// (backward compatibility: existing configurations only have private_key).
func TestJwtAuthCEL_AsymmetricRS256WithPrivateKey(t *testing.T) {
obj := map[string]interface{}{
"key": "my-key",
"private_key": "-----BEGIN RSA PRIVATE KEY-----\nMIIE\n-----END RSA PRIVATE KEY-----",
"algorithm": "RS256",
}
assert.NoError(t, validateJwtAuthValue(t, obj))
}

// TestJwtAuthCEL_AsymmetricRS256WithBothKeys verifies that RS256 + both keys passes.
func TestJwtAuthCEL_AsymmetricRS256WithBothKeys(t *testing.T) {
obj := map[string]interface{}{
"key": "my-key",
"public_key": "-----BEGIN PUBLIC KEY-----\nMFww\n-----END PUBLIC KEY-----",
"private_key": "-----BEGIN RSA PRIVATE KEY-----\nMIIE\n-----END RSA PRIVATE KEY-----",
"algorithm": "RS256",
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
}
assert.NoError(t, validateJwtAuthValue(t, obj))
}

// TestJwtAuthCEL_AsymmetricRS256WithoutAnyKey verifies that RS256 without
// any key is rejected by CEL validation.
func TestJwtAuthCEL_AsymmetricRS256WithoutAnyKey(t *testing.T) {
obj := map[string]interface{}{
"key": "my-key",
"algorithm": "RS256",
}
err := validateJwtAuthValue(t, obj)
assert.Error(t, err, "RS256 without public_key or private_key should be rejected")
assert.Contains(t, err.Error(), "asymmetric JWT algorithms")
}

// TestJwtAuthCEL_AsymmetricES256WithoutAnyKey verifies that ES256 without
// any key is rejected.
func TestJwtAuthCEL_AsymmetricES256WithoutAnyKey(t *testing.T) {
obj := map[string]interface{}{
"key": "my-key",
"algorithm": "ES256",
}
err := validateJwtAuthValue(t, obj)
assert.Error(t, err, "ES256 without public_key or private_key should be rejected")
}

// TestJwtAuthCEL_AsymmetricEdDSAWithoutAnyKey verifies that EdDSA without
// any key is rejected.
func TestJwtAuthCEL_AsymmetricEdDSAWithoutAnyKey(t *testing.T) {
obj := map[string]interface{}{
"key": "my-key",
"algorithm": "EdDSA",
}
err := validateJwtAuthValue(t, obj)
assert.Error(t, err, "EdDSA without public_key or private_key should be rejected")
}

// TestJwtAuthCEL_AsymmetricWithEmptyPublicKey verifies that an asymmetric
// algorithm with an empty public_key string is rejected (same as absent).
func TestJwtAuthCEL_AsymmetricWithEmptyPublicKey(t *testing.T) {
obj := map[string]interface{}{
"key": "my-key",
"public_key": "",
"algorithm": "RS256",
}
err := validateJwtAuthValue(t, obj)
assert.Error(t, err, "RS256 with empty public_key should be rejected")
}
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.
// Exactly one of the following must be provided depending on the algorithm:
// - For symmetric algorithms (HS256, HS384, HS512): use secret. private_key and public_key are not required.
// - For asymmetric algorithms (RS*, ES*, PS*, EdDSA): at least one of public_key or private_key must be provided.
Comment thread
AlinsRan marked this conversation as resolved.
Outdated
Comment thread
AlinsRan marked this conversation as resolved.
Outdated
//
Comment thread
AlinsRan marked this conversation as resolved.
Outdated
// +kubebuilder:validation:XValidation:rule="!has(self.algorithm) || self.algorithm in ['HS256','HS384','HS512'] || (has(self.public_key) && self.public_key != ”) || (has(self.private_key) && self.private_key != ”)",message="asymmetric JWT algorithms (RS*/ES*/PS*/EdDSA) require at least one of public_key or private_key"
Comment thread
AlinsRan marked this conversation as resolved.
Outdated
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
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
7 changes: 6 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,13 @@ spec:
type: string
required:
- key
- private_key
type: object
x-kubernetes-validations:
- message: asymmetric JWT algorithms (RS*/ES*/PS*/EdDSA) require
at least one of public_key or private_key
rule: '!has(self.algorithm) || self.algorithm in [''HS256'',''HS384'',''HS512'']
|| (has(self.public_key) && self.public_key != ”) || (has(self.private_key)
&& self.private_key != ”)'
Comment thread
AlinsRan marked this conversation as resolved.
Outdated
type: object
keyAuth:
description: KeyAuth configures the key authentication details.
Expand Down
Loading