Skip to content

Commit 85ae1bb

Browse files
authored
fix: relax jwtAuth private_key requirement and add CEL validation (#406)
1 parent 44b9754 commit 85ae1bb

6 files changed

Lines changed: 356 additions & 7 deletions

File tree

api/adc/plugin_types.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ type JwtAuthConsumerConfig struct {
6868
Key string `json:"key" yaml:"key"`
6969
Secret string `json:"secret,omitempty" yaml:"secret,omitempty"`
7070
PublicKey string `json:"public_key,omitempty" yaml:"public_key,omitempty"`
71-
PrivateKey string `json:"private_key" yaml:"private_key,omitempty"`
71+
PrivateKey string `json:"private_key,omitempty" yaml:"private_key,omitempty"`
7272
Algorithm string `json:"algorithm,omitempty" yaml:"algorithm,omitempty"`
7373
Exp int64 `json:"exp,omitempty" yaml:"exp,omitempty"`
7474
Base64Secret bool `json:"base64_secret,omitempty" yaml:"base64_secret,omitempty"`

api/v2/apisixconsumer_types.go

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,11 @@ type ApisixConsumerJwtAuth struct {
130130
}
131131

132132
// ApisixConsumerJwtAuthValue defines configuration for JWT authentication.
133+
// For asymmetric algorithms (RS*, ES*, PS*, EdDSA), at least one of public_key
134+
// or private_key must be provided. Symmetric algorithms (HS256, HS384, HS512)
135+
// and unset algorithm do not require any key field.
136+
//
137+
// +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"
133138
type ApisixConsumerJwtAuthValue struct {
134139
// Key is the unique identifier for the JWT credential.
135140
Key string `json:"key" yaml:"key"`
@@ -138,10 +143,9 @@ type ApisixConsumerJwtAuthValue struct {
138143
// PublicKey is the public key used to verify JWT signatures (for asymmetric algorithms).
139144
PublicKey string `json:"public_key,omitempty" yaml:"public_key,omitempty"`
140145
// PrivateKey is the private key used to sign the JWT (for asymmetric algorithms).
141-
PrivateKey string `json:"private_key" yaml:"private_key,omitempty"`
146+
PrivateKey string `json:"private_key,omitempty" yaml:"private_key,omitempty"`
142147
// Algorithm specifies the signing algorithm.
143148
// Can be `HS256`, `HS384`, `HS512`, `RS256`, `RS384`, `RS512`, `ES256`, `ES384`, `ES512`, `PS256`, `PS384`, `PS512`, or `EdDSA`.
144-
// Currently APISIX only supports `HS256`, `HS512`, `RS256`, and `ES256`. API7 Enterprise supports all algorithms.
145149
Algorithm string `json:"algorithm,omitempty" yaml:"algorithm,omitempty"`
146150
// Exp is the token expiration period in seconds.
147151
Exp int64 `json:"exp,omitempty" yaml:"exp,omitempty"`
Lines changed: 337 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,337 @@
1+
// Licensed to the Apache Software Foundation (ASF) under one or more
2+
// contributor license agreements. See the NOTICE file distributed with
3+
// this work for additional information regarding copyright ownership.
4+
// The ASF licenses this file to You under the Apache License, Version 2.0
5+
// (the "License"); you may not use this file except in compliance with
6+
// the License. You may obtain a copy of the License at
7+
//
8+
// http://www.apache.org/licenses/LICENSE-2.0
9+
//
10+
// Unless required by applicable law or agreed to in writing, software
11+
// distributed under the License is distributed on an "AS IS" BASIS,
12+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
// See the License for the specific language governing permissions and
14+
// limitations under the License.
15+
16+
package v2_test
17+
18+
import (
19+
"context"
20+
"encoding/json"
21+
"os"
22+
"path/filepath"
23+
"runtime"
24+
"testing"
25+
26+
"github.com/stretchr/testify/assert"
27+
"github.com/stretchr/testify/require"
28+
apiextensions "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions"
29+
apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
30+
structuralschema "k8s.io/apiextensions-apiserver/pkg/apiserver/schema"
31+
"k8s.io/apiextensions-apiserver/pkg/apiserver/schema/cel"
32+
"k8s.io/apiextensions-apiserver/pkg/apiserver/validation"
33+
celconfig "k8s.io/apiserver/pkg/apis/cel"
34+
sigsyaml "sigs.k8s.io/yaml"
35+
36+
apisixv2 "github.com/apache/apisix-ingress-controller/api/v2"
37+
)
38+
39+
// consumerSchemaValidator holds the parsed CRD schema for ApisixConsumer
40+
// and provides a Validate method for use in tests.
41+
type consumerSchemaValidator struct {
42+
structural *structuralschema.Structural
43+
internal *apiextensions.JSONSchemaProps
44+
}
45+
46+
func (v *consumerSchemaValidator) Validate(t *testing.T, ac *apisixv2.ApisixConsumer) error {
47+
t.Helper()
48+
49+
data, err := json.Marshal(ac)
50+
require.NoError(t, err, "failed to marshal ApisixConsumer")
51+
52+
var obj map[string]interface{}
53+
require.NoError(t, json.Unmarshal(data, &obj), "failed to unmarshal to map")
54+
55+
schemaValidator, _, err := validation.NewSchemaValidator(v.internal)
56+
require.NoError(t, err, "failed to build schema validator")
57+
58+
if errs := validation.ValidateCustomResource(nil, obj, schemaValidator); len(errs) > 0 {
59+
return errs.ToAggregate()
60+
}
61+
62+
celValidator := cel.NewValidator(v.structural, false, celconfig.PerCallLimit)
63+
celErrs, _ := celValidator.Validate(context.Background(), nil, v.structural, obj, nil, celconfig.RuntimeCELCostBudget)
64+
if len(celErrs) > 0 {
65+
return celErrs.ToAggregate()
66+
}
67+
return nil
68+
}
69+
70+
// loadApisixConsumerSchema reads the ApisixConsumer CRD YAML and returns a
71+
// validator backed by the real generated schema.
72+
func loadApisixConsumerSchema(t *testing.T) *consumerSchemaValidator {
73+
t.Helper()
74+
75+
_, thisFile, _, _ := runtime.Caller(0)
76+
crdPath := filepath.Join(filepath.Dir(thisFile), "..", "..",
77+
"config", "crd", "bases", "apisix.apache.org_apisixconsumers.yaml")
78+
79+
data, err := os.ReadFile(crdPath)
80+
require.NoError(t, err, "failed to read CRD file: %s", crdPath)
81+
82+
jsonData, err := sigsyaml.YAMLToJSON(data)
83+
require.NoError(t, err, "failed to convert CRD YAML to JSON")
84+
85+
var crd apiextensionsv1.CustomResourceDefinition
86+
require.NoError(t, json.Unmarshal(jsonData, &crd), "failed to unmarshal CRD")
87+
88+
var v1Schema *apiextensionsv1.JSONSchemaProps
89+
for _, v := range crd.Spec.Versions {
90+
if v.Name == "v2" {
91+
v1Schema = v.Schema.OpenAPIV3Schema
92+
break
93+
}
94+
}
95+
require.NotNil(t, v1Schema, "v2 schema not found in CRD")
96+
97+
var internal apiextensions.JSONSchemaProps
98+
require.NoError(t,
99+
apiextensionsv1.Convert_v1_JSONSchemaProps_To_apiextensions_JSONSchemaProps(v1Schema, &internal, nil),
100+
"failed to convert v1 schema to internal",
101+
)
102+
103+
structural, err := structuralschema.NewStructural(&internal)
104+
require.NoError(t, err, "failed to build structural schema")
105+
return &consumerSchemaValidator{structural: structural, internal: &internal}
106+
}
107+
108+
func TestApisixConsumer_JwtAuth_SymmetricHS256(t *testing.T) {
109+
v := loadApisixConsumerSchema(t)
110+
ac := &apisixv2.ApisixConsumer{
111+
Spec: apisixv2.ApisixConsumerSpec{
112+
AuthParameter: apisixv2.ApisixConsumerAuthParameter{
113+
JwtAuth: &apisixv2.ApisixConsumerJwtAuth{
114+
Value: &apisixv2.ApisixConsumerJwtAuthValue{
115+
Key: "my-key",
116+
Secret: "my-secret",
117+
Algorithm: "HS256",
118+
},
119+
},
120+
},
121+
},
122+
}
123+
assert.NoError(t, v.Validate(t, ac))
124+
}
125+
126+
// TestApisixConsumer_JwtAuth_AsymmetricWithWhitespaceOnlyPublicKey verifies
127+
// that a whitespace-only public_key is treated as absent and rejected for
128+
// asymmetric algorithms.
129+
func TestApisixConsumer_JwtAuth_AsymmetricWithWhitespaceOnlyPublicKey(t *testing.T) {
130+
v := loadApisixConsumerSchema(t)
131+
ac := &apisixv2.ApisixConsumer{
132+
Spec: apisixv2.ApisixConsumerSpec{
133+
AuthParameter: apisixv2.ApisixConsumerAuthParameter{
134+
JwtAuth: &apisixv2.ApisixConsumerJwtAuth{
135+
Value: &apisixv2.ApisixConsumerJwtAuthValue{
136+
Key: "my-key",
137+
Algorithm: "RS256",
138+
PublicKey: " ",
139+
},
140+
},
141+
},
142+
},
143+
}
144+
err := v.Validate(t, ac)
145+
require.Error(t, err)
146+
assert.Contains(t, err.Error(), "algorithms other than HS256/HS384/HS512")
147+
}
148+
149+
func TestApisixConsumer_JwtAuth_SymmetricHS512(t *testing.T) {
150+
v := loadApisixConsumerSchema(t)
151+
ac := &apisixv2.ApisixConsumer{
152+
Spec: apisixv2.ApisixConsumerSpec{
153+
AuthParameter: apisixv2.ApisixConsumerAuthParameter{
154+
JwtAuth: &apisixv2.ApisixConsumerJwtAuth{
155+
Value: &apisixv2.ApisixConsumerJwtAuthValue{
156+
Key: "my-key",
157+
Secret: "my-secret",
158+
Algorithm: "HS512",
159+
},
160+
},
161+
},
162+
},
163+
}
164+
assert.NoError(t, v.Validate(t, ac))
165+
}
166+
167+
func TestApisixConsumer_JwtAuth_NoAlgorithmDefaultsToSymmetric(t *testing.T) {
168+
v := loadApisixConsumerSchema(t)
169+
ac := &apisixv2.ApisixConsumer{
170+
Spec: apisixv2.ApisixConsumerSpec{
171+
AuthParameter: apisixv2.ApisixConsumerAuthParameter{
172+
JwtAuth: &apisixv2.ApisixConsumerJwtAuth{
173+
Value: &apisixv2.ApisixConsumerJwtAuthValue{
174+
Key: "my-key",
175+
Secret: "my-secret",
176+
},
177+
},
178+
},
179+
},
180+
}
181+
assert.NoError(t, v.Validate(t, ac))
182+
}
183+
184+
func TestApisixConsumer_JwtAuth_AsymmetricRS256WithPublicKey(t *testing.T) {
185+
v := loadApisixConsumerSchema(t)
186+
ac := &apisixv2.ApisixConsumer{
187+
Spec: apisixv2.ApisixConsumerSpec{
188+
AuthParameter: apisixv2.ApisixConsumerAuthParameter{
189+
JwtAuth: &apisixv2.ApisixConsumerJwtAuth{
190+
Value: &apisixv2.ApisixConsumerJwtAuthValue{
191+
Key: "my-key",
192+
PublicKey: "test-public-key",
193+
Algorithm: "RS256",
194+
},
195+
},
196+
},
197+
},
198+
}
199+
assert.NoError(t, v.Validate(t, ac))
200+
}
201+
202+
func TestApisixConsumer_JwtAuth_AsymmetricRS256WithPrivateKey(t *testing.T) {
203+
v := loadApisixConsumerSchema(t)
204+
ac := &apisixv2.ApisixConsumer{
205+
Spec: apisixv2.ApisixConsumerSpec{
206+
AuthParameter: apisixv2.ApisixConsumerAuthParameter{
207+
JwtAuth: &apisixv2.ApisixConsumerJwtAuth{
208+
Value: &apisixv2.ApisixConsumerJwtAuthValue{
209+
Key: "my-key",
210+
PrivateKey: "test-private-key",
211+
Algorithm: "RS256",
212+
},
213+
},
214+
},
215+
},
216+
}
217+
assert.NoError(t, v.Validate(t, ac))
218+
}
219+
220+
func TestApisixConsumer_JwtAuth_AsymmetricRS256WithBothKeys(t *testing.T) {
221+
v := loadApisixConsumerSchema(t)
222+
ac := &apisixv2.ApisixConsumer{
223+
Spec: apisixv2.ApisixConsumerSpec{
224+
AuthParameter: apisixv2.ApisixConsumerAuthParameter{
225+
JwtAuth: &apisixv2.ApisixConsumerJwtAuth{
226+
Value: &apisixv2.ApisixConsumerJwtAuthValue{
227+
Key: "my-key",
228+
PublicKey: "test-public-key",
229+
PrivateKey: "test-private-key",
230+
Algorithm: "RS256",
231+
},
232+
},
233+
},
234+
},
235+
}
236+
assert.NoError(t, v.Validate(t, ac))
237+
}
238+
239+
func TestApisixConsumer_JwtAuth_AsymmetricRS256WithoutAnyKey(t *testing.T) {
240+
v := loadApisixConsumerSchema(t)
241+
ac := &apisixv2.ApisixConsumer{
242+
Spec: apisixv2.ApisixConsumerSpec{
243+
AuthParameter: apisixv2.ApisixConsumerAuthParameter{
244+
JwtAuth: &apisixv2.ApisixConsumerJwtAuth{
245+
Value: &apisixv2.ApisixConsumerJwtAuthValue{
246+
Key: "my-key",
247+
Algorithm: "RS256",
248+
},
249+
},
250+
},
251+
},
252+
}
253+
err := v.Validate(t, ac)
254+
require.Error(t, err)
255+
assert.Contains(t, err.Error(), "algorithms other than HS256/HS384/HS512")
256+
}
257+
258+
func TestApisixConsumer_JwtAuth_AsymmetricES256WithoutAnyKey(t *testing.T) {
259+
v := loadApisixConsumerSchema(t)
260+
ac := &apisixv2.ApisixConsumer{
261+
Spec: apisixv2.ApisixConsumerSpec{
262+
AuthParameter: apisixv2.ApisixConsumerAuthParameter{
263+
JwtAuth: &apisixv2.ApisixConsumerJwtAuth{
264+
Value: &apisixv2.ApisixConsumerJwtAuthValue{
265+
Key: "my-key",
266+
Algorithm: "ES256",
267+
},
268+
},
269+
},
270+
},
271+
}
272+
err := v.Validate(t, ac)
273+
require.Error(t, err)
274+
assert.Contains(t, err.Error(), "algorithms other than HS256/HS384/HS512")
275+
}
276+
277+
func TestApisixConsumer_JwtAuth_AsymmetricEdDSAWithoutAnyKey(t *testing.T) {
278+
v := loadApisixConsumerSchema(t)
279+
ac := &apisixv2.ApisixConsumer{
280+
Spec: apisixv2.ApisixConsumerSpec{
281+
AuthParameter: apisixv2.ApisixConsumerAuthParameter{
282+
JwtAuth: &apisixv2.ApisixConsumerJwtAuth{
283+
Value: &apisixv2.ApisixConsumerJwtAuthValue{
284+
Key: "my-key",
285+
Algorithm: "EdDSA",
286+
},
287+
},
288+
},
289+
},
290+
}
291+
err := v.Validate(t, ac)
292+
require.Error(t, err)
293+
assert.Contains(t, err.Error(), "algorithms other than HS256/HS384/HS512")
294+
}
295+
296+
func TestApisixConsumer_JwtAuth_AsymmetricWithEmptyPublicKey(t *testing.T) {
297+
v := loadApisixConsumerSchema(t)
298+
ac := &apisixv2.ApisixConsumer{
299+
Spec: apisixv2.ApisixConsumerSpec{
300+
AuthParameter: apisixv2.ApisixConsumerAuthParameter{
301+
JwtAuth: &apisixv2.ApisixConsumerJwtAuth{
302+
Value: &apisixv2.ApisixConsumerJwtAuthValue{
303+
Key: "my-key",
304+
Algorithm: "RS256",
305+
// PublicKey is empty string — omitempty means it won't appear
306+
// in the serialized JSON, same effect as not set
307+
},
308+
},
309+
},
310+
},
311+
}
312+
err := v.Validate(t, ac)
313+
require.Error(t, err)
314+
assert.Contains(t, err.Error(), "algorithms other than HS256/HS384/HS512")
315+
}
316+
317+
// TestApisixConsumer_JwtAuth_EmptyAlgorithmTreatedAsSymmetric verifies that an
318+
// explicitly empty algorithm string is treated the same as an unset algorithm
319+
// (defaults to HS256) and does not require public_key or private_key.
320+
func TestApisixConsumer_JwtAuth_EmptyAlgorithmTreatedAsSymmetric(t *testing.T) {
321+
v := loadApisixConsumerSchema(t)
322+
ac := &apisixv2.ApisixConsumer{
323+
Spec: apisixv2.ApisixConsumerSpec{
324+
AuthParameter: apisixv2.ApisixConsumerAuthParameter{
325+
JwtAuth: &apisixv2.ApisixConsumerJwtAuth{
326+
Value: &apisixv2.ApisixConsumerJwtAuthValue{
327+
Key: "my-key",
328+
Secret: "my-secret",
329+
// Algorithm is explicitly empty string — should be treated as
330+
// unset and not require asymmetric keys.
331+
},
332+
},
333+
},
334+
},
335+
}
336+
assert.NoError(t, v.Validate(t, ac))
337+
}

config/crd-nocel/apisix.apache.org_v2.yaml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -180,7 +180,6 @@ spec:
180180
description: |-
181181
Algorithm specifies the signing algorithm.
182182
Can be `HS256`, `HS384`, `HS512`, `RS256`, `RS384`, `RS512`, `ES256`, `ES384`, `ES512`, `PS256`, `PS384`, `PS512`, or `EdDSA`.
183-
Currently APISIX only supports `HS256`, `HS512`, `RS256`, and `ES256`. API7 Enterprise supports all algorithms.
184183
type: string
185184
base64_secret:
186185
description: Base64Secret indicates whether the secret

0 commit comments

Comments
 (0)