Skip to content

Commit f14a237

Browse files
[codex] Add Vault JWT local CRE test coverage
1 parent c726696 commit f14a237

42 files changed

Lines changed: 2773 additions & 208 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/cre-system-tests.yaml

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,9 @@ jobs:
160160
# http://docs.github.com/en/actions/how-tos/deploy/configure-and-manage-deployments/control-deployments#using-environments-without-deployments
161161
name: integration
162162
deployment: false
163-
timeout-minutes: 10
163+
# Bucket B now runs both legacy Vault auth and JWT auth against the same topology.
164+
# Give that matrix entry a larger budget without slowing the rest of the suite.
165+
timeout-minutes: ${{ matrix.tests.test_name == 'Test_CRE_V2_Suite_Bucket_B' && 16 || 10 }}
164166
env:
165167
ENABLE_AUTO_QUARANTINE: "true"
166168
# override Chip Ingress and Chip Config images with remote images. We have added this env var here, instead of the "Start local CRE" step, because
@@ -328,9 +330,12 @@ jobs:
328330
continue-on-error: ${{ env.ENABLE_AUTO_QUARANTINE == 'true' }}
329331
env:
330332
TEST_NAME: ${{ matrix.tests.test_name }}
331-
TEST_TIMEOUT: 7m # let's leave 3 minutes for other steps (the whole job times out after 10 minutes)
333+
TEST_TIMEOUT: ${{ matrix.tests.test_name == 'Test_CRE_V2_Suite_Bucket_B' && '12m' || '7m' }}
332334
RUN_QUARANTINED_TESTS: "true" # always run quarantined tests in CI
333335
TOPOLOGY_NAME: ${{ matrix.tests.topology }}
336+
CTF_JD_IMAGE: "${{ secrets.AWS_ACCOUNT_ID_PROD }}.dkr.ecr.${{ secrets.QA_AWS_REGION }}.amazonaws.com/job-distributor:0.22.1"
337+
CTF_CHAINLINK_IMAGE: "${{ steps.resolve-chainlink-image.outputs.resolved_image }}"
338+
CTF_CHIP_ROUTER_IMAGE: "${{ secrets.QA_AWS_ACCOUNT_NUMBER }}.dkr.ecr.${{ secrets.QA_AWS_REGION }}.amazonaws.com/local-cre-chip-router:v1.0.1"
334339
GITHUB_TOKEN: ${{ steps.github-token.outputs.access-token || '' }} # to avoid rate limiting when downloading protobuf files from GitHub
335340
PARALLEL_COUNT: "10"
336341
CRE_TEST_PARALLEL_ENABLED: "true"

core/capabilities/vault/authorizer_test.go

Lines changed: 4 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -11,29 +11,20 @@ import (
1111

1212
vaultcommon "github.com/smartcontractkit/chainlink-common/pkg/capabilities/actions/vault"
1313
jsonrpc "github.com/smartcontractkit/chainlink-common/pkg/jsonrpc2"
14-
"github.com/smartcontractkit/chainlink-common/pkg/settings/cresettings"
15-
"github.com/smartcontractkit/chainlink-common/pkg/settings/limits"
1614
vault "github.com/smartcontractkit/chainlink/v2/core/capabilities/vault"
1715
vaultmocks "github.com/smartcontractkit/chainlink/v2/core/capabilities/vault/mocks"
1816
"github.com/smartcontractkit/chainlink/v2/core/capabilities/vault/vaulttypes"
1917
"github.com/smartcontractkit/chainlink/v2/core/logger"
2018
)
2119

22-
func testLimitsFactory() limits.Factory {
23-
return limits.Factory{Settings: cresettings.DefaultGetter}
24-
}
25-
26-
func TestAuthorizer_RejectsJWTBasedAuthWhenDisabled(t *testing.T) {
20+
func TestAuthorizer_RejectsJWTBasedAuthWhenUnavailable(t *testing.T) {
2721
params, err := json.Marshal(vaultcommon.CreateSecretsRequest{})
2822
require.NoError(t, err)
2923

3024
allowListBasedAuth := vaultmocks.NewAuthorizer(t)
3125
allowListBasedAuth.EXPECT().AuthorizeRequest(mock.Anything, mock.Anything).Maybe()
3226

33-
jwtBasedAuth, err := vault.NewJWTBasedAuth(vault.JWTBasedAuthConfig{}, testLimitsFactory(), logger.TestLogger(t), vault.WithDisabledJWTBasedAuth())
34-
require.NoError(t, err)
35-
36-
a := vault.NewAuthorizer(allowListBasedAuth, jwtBasedAuth, logger.TestLogger(t))
27+
a := vault.NewAuthorizer(allowListBasedAuth, nil, logger.TestLogger(t))
3728

3829
authResult, err := a.AuthorizeRequest(t.Context(), jsonrpc.Request[json.RawMessage]{
3930
ID: "1",
@@ -42,7 +33,7 @@ func TestAuthorizer_RejectsJWTBasedAuthWhenDisabled(t *testing.T) {
4233
Auth: "jwt-token",
4334
})
4435
require.Nil(t, authResult)
45-
require.ErrorContains(t, err, "JWTBasedAuth is disabled")
36+
require.ErrorContains(t, err, "JWTBasedAuth is nil")
4637
allowListBasedAuth.AssertNotCalled(t, "AuthorizeRequest", mock.Anything, mock.Anything)
4738
}
4839

@@ -126,10 +117,7 @@ func TestAuthorizer_RejectsAllowListBasedAuthReplay(t *testing.T) {
126117
req := jsonrpc.Request[json.RawMessage]{ID: "1", Method: vaulttypes.MethodSecretsCreate}
127118
allowListBasedAuth.EXPECT().AuthorizeRequest(mock.Anything, req).Return(vault.NewAuthResult("", "0xabc", "digest-1", time.Now().Add(time.Minute).Unix()), nil).Twice()
128119

129-
jwtBasedAuth, err := vault.NewJWTBasedAuth(vault.JWTBasedAuthConfig{}, testLimitsFactory(), logger.TestLogger(t), vault.WithDisabledJWTBasedAuth())
130-
require.NoError(t, err)
131-
132-
a := vault.NewAuthorizer(allowListBasedAuth, jwtBasedAuth, logger.TestLogger(t))
120+
a := vault.NewAuthorizer(allowListBasedAuth, nil, logger.TestLogger(t))
133121

134122
authResult, err := a.AuthorizeRequest(t.Context(), req)
135123
require.NoError(t, err)

core/capabilities/vault/gw_handler.go

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ type gatewayConnector interface {
6060

6161
type gatewayHandlerConfig struct {
6262
authorizer Authorizer
63+
auth0 *Auth0Config
6364
}
6465

6566
// GatewayHandlerOption customizes GatewayHandler construction for tests and future auth extensions.
@@ -72,6 +73,13 @@ func WithAuthorizer(authorizer Authorizer) GatewayHandlerOption {
7273
}
7374
}
7475

76+
// WithJWTAuth0Config enables JWT-based request validation for the node-side Vault handler.
77+
func WithJWTAuth0Config(auth0 *Auth0Config) GatewayHandlerOption {
78+
return func(cfg *gatewayHandlerConfig) {
79+
cfg.auth0 = auth0
80+
}
81+
}
82+
7583
// GatewayHandler serves Vault requests received from the gateway on the node side.
7684
type GatewayHandler struct {
7785
services.Service
@@ -93,12 +101,21 @@ func NewGatewayHandler(secretsService vaulttypes.SecretsService, connector gatew
93101
}
94102
if cfg.authorizer == nil {
95103
allowListBasedAuth := NewAllowListBasedAuth(lggr, workflowRegistrySyncer)
96-
jwtBasedAuth, err := NewJWTBasedAuth(JWTBasedAuthConfig{}, limitsFactory, lggr, WithDisabledJWTBasedAuth())
97-
if err != nil {
98-
return nil, fmt.Errorf("failed to create JWTBasedAuth: %w", err)
104+
var jwtBasedAuth Authorizer
105+
var jwtAuthService services.Service
106+
if cfg.auth0 != nil {
107+
var err error
108+
jwtAuthService, err = NewJWTBasedAuth(JWTBasedAuthConfig{
109+
IssuerURL: cfg.auth0.IssuerURL,
110+
Audience: cfg.auth0.Audience,
111+
}, limitsFactory, lggr)
112+
if err != nil {
113+
return nil, fmt.Errorf("failed to create JWTBasedAuth: %w", err)
114+
}
115+
jwtBasedAuth = jwtAuthService.(Authorizer)
99116
}
100117
cfg.authorizer = NewAuthorizer(allowListBasedAuth, jwtBasedAuth, lggr)
101-
return newGatewayHandlerWithAuthorizer(secretsService, connector, cfg.authorizer, jwtBasedAuth, lggr)
118+
return newGatewayHandlerWithAuthorizer(secretsService, connector, cfg.authorizer, jwtAuthService, lggr)
102119
}
103120
return newGatewayHandlerWithAuthorizer(secretsService, connector, cfg.authorizer, nil, lggr)
104121
}
@@ -227,6 +244,11 @@ func (h *GatewayHandler) authorizeAndPrefixRequest(ctx context.Context, req *jso
227244
h.lggr.Errorw("failed to normalize gateway request for authorization", "method", req.Method, "requestID", originalRequestID, "error", err)
228245
return nil, err
229246
}
247+
authReq, err := StripRequestIdentity(authReq)
248+
if err != nil {
249+
h.lggr.Errorw("failed to strip authorized identity fields before authorization", "method", req.Method, "requestID", originalRequestID, "error", err)
250+
return nil, err
251+
}
230252

231253
h.lggr.Debugw("authorizing gateway request", "method", req.Method, "requestID", originalRequestID)
232254
authResult, err := h.authorizer.AuthorizeRequest(ctx, authReq)

core/capabilities/vault/gw_handler_test.go

Lines changed: 77 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,56 @@ func TestGatewayHandler_HandleGatewayMessage(t *testing.T) {
121121
},
122122
expectedError: false,
123123
},
124+
{
125+
name: "success - create secrets strips forwarded identity before reauthorization",
126+
setupMocks: func(ss *vaulttypesmocks.SecretsService, gc *connector_mocks.GatewayConnector, ra *vaultcapmocks.Authorizer) {
127+
ra.EXPECT().AuthorizeRequest(mock.Anything, mock.MatchedBy(func(req jsonrpc.Request[json.RawMessage]) bool {
128+
if req.Method != vaulttypes.MethodSecretsCreate || req.ID != "1" || req.Params == nil {
129+
return false
130+
}
131+
parsed := &vaultcommon.CreateSecretsRequest{}
132+
if err := json.Unmarshal(*req.Params, parsed); err != nil {
133+
return false
134+
}
135+
return parsed.OrgId == "" && parsed.WorkflowOwner == ""
136+
})).Return(authResult("org-1", "0xworkflow"), nil)
137+
ss.EXPECT().CreateSecrets(mock.Anything, mock.MatchedBy(func(req *vaultcommon.CreateSecretsRequest) bool {
138+
return len(req.EncryptedSecrets) == 1 &&
139+
req.EncryptedSecrets[0].Id.Key == "test-secret" &&
140+
req.EncryptedSecrets[0].Id.Owner == "org-1" &&
141+
req.RequestId == "org-1"+vaulttypes.RequestIDSeparator+"1" &&
142+
req.OrgId == "org-1" &&
143+
req.WorkflowOwner == "0xworkflow"
144+
})).Return(&vaulttypes.Response{ID: "test-secret"}, nil)
145+
146+
gc.On("SendToGateway", mock.Anything, "gateway-1", mock.MatchedBy(func(resp *jsonrpc.Response[json.RawMessage]) bool {
147+
return resp.Error == nil
148+
})).Return(nil)
149+
},
150+
request: &jsonrpc.Request[json.RawMessage]{
151+
Method: vaulttypes.MethodSecretsCreate,
152+
ID: "org-1" + vaulttypes.RequestIDSeparator + "1",
153+
Params: func() *json.RawMessage {
154+
params, _ := json.Marshal(vaultcommon.CreateSecretsRequest{
155+
RequestId: "org-1" + vaulttypes.RequestIDSeparator + "1",
156+
OrgId: "org-1",
157+
WorkflowOwner: "0xworkflow",
158+
EncryptedSecrets: []*vaultcommon.EncryptedSecret{
159+
{
160+
Id: &vaultcommon.SecretIdentifier{
161+
Key: "test-secret",
162+
Owner: "org-1",
163+
},
164+
EncryptedValue: "encrypted-value",
165+
},
166+
},
167+
})
168+
raw := json.RawMessage(params)
169+
return &raw
170+
}(),
171+
},
172+
expectedError: false,
173+
},
124174
{
125175
name: "failure - service error",
126176
setupMocks: func(ss *vaulttypesmocks.SecretsService, gc *connector_mocks.GatewayConnector, ra *vaultcapmocks.Authorizer) {
@@ -456,9 +506,6 @@ func TestGatewayHandler_HandleGatewayMessage(t *testing.T) {
456506
secretsService := vaulttypesmocks.NewSecretsService(t)
457507
gwConnector := connector_mocks.NewGatewayConnector(t)
458508
allowListBasedAuth := vaultcapmocks.NewAuthorizer(t)
459-
limitsFactory := limits.Factory{Settings: cresettings.DefaultGetter}
460-
jwtBasedAuth, err := vaultcap.NewJWTBasedAuth(vaultcap.JWTBasedAuthConfig{}, limitsFactory, lggr, vaultcap.WithDisabledJWTBasedAuth())
461-
require.NoError(t, err)
462509

463510
tt.setupMocks(secretsService, gwConnector, allowListBasedAuth)
464511

@@ -467,8 +514,8 @@ func TestGatewayHandler_HandleGatewayMessage(t *testing.T) {
467514
gwConnector,
468515
nil,
469516
lggr,
470-
limitsFactory,
471-
vaultcap.WithAuthorizer(vaultcap.NewAuthorizer(allowListBasedAuth, jwtBasedAuth, lggr)),
517+
limits.Factory{Settings: cresettings.DefaultGetter},
518+
vaultcap.WithAuthorizer(vaultcap.NewAuthorizer(allowListBasedAuth, nil, lggr)),
472519
)
473520
require.NoError(t, err)
474521

@@ -490,17 +537,14 @@ func TestGatewayHandler_Lifecycle(t *testing.T) {
490537
secretsService := vaulttypesmocks.NewSecretsService(t)
491538
gwConnector := connector_mocks.NewGatewayConnector(t)
492539
allowListBasedAuth := vaultcapmocks.NewAuthorizer(t)
493-
limitsFactory := limits.Factory{Settings: cresettings.DefaultGetter}
494-
jwtBasedAuth, err := vaultcap.NewJWTBasedAuth(vaultcap.JWTBasedAuthConfig{}, limitsFactory, lggr, vaultcap.WithDisabledJWTBasedAuth())
495-
require.NoError(t, err)
496540

497541
handler, err := vaultcap.NewGatewayHandler(
498542
secretsService,
499543
gwConnector,
500544
nil,
501545
lggr,
502-
limitsFactory,
503-
vaultcap.WithAuthorizer(vaultcap.NewAuthorizer(allowListBasedAuth, jwtBasedAuth, lggr)),
546+
limits.Factory{Settings: cresettings.DefaultGetter},
547+
vaultcap.WithAuthorizer(vaultcap.NewAuthorizer(allowListBasedAuth, nil, lggr)),
504548
)
505549
require.NoError(t, err)
506550

@@ -522,3 +566,26 @@ func TestGatewayHandler_Lifecycle(t *testing.T) {
522566
assert.Equal(t, vaultcap.HandlerName, id)
523567
})
524568
}
569+
570+
func TestGatewayHandler_Lifecycle_DefaultAuthorizer_NoJWTConfig(t *testing.T) {
571+
lggr := logger.TestLogger(t)
572+
ctx := t.Context()
573+
574+
secretsService := vaulttypesmocks.NewSecretsService(t)
575+
gwConnector := connector_mocks.NewGatewayConnector(t)
576+
577+
handler, err := vaultcap.NewGatewayHandler(
578+
secretsService,
579+
gwConnector,
580+
nil,
581+
lggr,
582+
limits.Factory{Settings: cresettings.DefaultGetter},
583+
)
584+
require.NoError(t, err)
585+
586+
gwConnector.On("AddHandler", mock.Anything, vaulttypes.Methods, handler).Return(nil).Once()
587+
require.NoError(t, handler.Start(ctx))
588+
589+
gwConnector.On("RemoveHandler", mock.Anything, vaulttypes.Methods).Return(nil).Once()
590+
require.NoError(t, handler.Close())
591+
}

core/capabilities/vault/jwt_based_auth.go

Lines changed: 16 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,12 @@ const (
3737
defaultHTTPTimeout = 5 * time.Second
3838
)
3939

40+
// Auth0Config captures the Vault JWT issuer settings shared by gateway and node handlers.
41+
type Auth0Config struct {
42+
IssuerURL string `json:"issuerURL" toml:"issuerURL" yaml:"issuerURL"`
43+
Audience string `json:"audience" toml:"audience" yaml:"audience"`
44+
}
45+
4046
// JWTBasedAuthConfig holds the configuration for JWTBasedAuth validation.
4147
type JWTBasedAuthConfig struct {
4248
IssuerURL string
@@ -84,7 +90,6 @@ type jwtBasedAuth struct {
8490
jwksURL string
8591
refreshInterval time.Duration
8692
authEnabledGate limits.GateLimiter
87-
refreshEnabled bool
8893

8994
mu sync.RWMutex
9095
keySet *jsonWebKeySet
@@ -97,8 +102,7 @@ type jwtBasedAuth struct {
97102
}
98103

99104
type jwtBasedAuthOptions struct {
100-
authEnabledGate limits.GateLimiter
101-
skipConfigChecks bool
105+
authEnabledGate limits.GateLimiter
102106
}
103107

104108
// JWTBasedAuthOption customizes JWTBasedAuth construction without multiplying constructors.
@@ -111,14 +115,6 @@ func WithJWTBasedAuthGateLimiter(gateLimiter limits.GateLimiter) JWTBasedAuthOpt
111115
}
112116
}
113117

114-
// WithDisabledJWTBasedAuth makes the constructed JWTBasedAuth fail closed without requiring issuer config.
115-
func WithDisabledJWTBasedAuth() JWTBasedAuthOption {
116-
return func(opts *jwtBasedAuthOptions) {
117-
opts.authEnabledGate = limits.NewGateLimiter(false)
118-
opts.skipConfigChecks = true
119-
}
120-
}
121-
122118
// NewJWTBasedAuth creates a JWTBasedAuth authorizer that verifies Auth0-issued JWTs
123119
// against the provider's JWKS endpoint. The JWKS is fetched lazily on first
124120
// use and refreshed on key-ID cache misses (rate-limited).
@@ -130,10 +126,10 @@ func NewJWTBasedAuth(cfg JWTBasedAuthConfig, limitsFactory limits.Factory, lggr
130126
if options.authEnabledGate == nil {
131127
options.authEnabledGate = newVaultJWTAuthEnabledGateLimiter(limitsFactory, lggr)
132128
}
133-
if !options.skipConfigChecks && cfg.IssuerURL == "" {
129+
if cfg.IssuerURL == "" {
134130
return nil, errors.New("issuer URL is required")
135131
}
136-
if !options.skipConfigChecks && cfg.Audience == "" {
132+
if cfg.Audience == "" {
137133
return nil, errors.New("audience is required")
138134
}
139135

@@ -156,7 +152,6 @@ func NewJWTBasedAuth(cfg JWTBasedAuthConfig, limitsFactory limits.Factory, lggr
156152
jwksURL: jwksURL,
157153
refreshInterval: refreshInterval,
158154
authEnabledGate: options.authEnabledGate,
159-
refreshEnabled: !options.skipConfigChecks,
160155
httpClient: httpClient,
161156
lggr: logger.Named(lggr, "VaultJWTBasedAuth"),
162157
}
@@ -180,11 +175,6 @@ func newVaultJWTAuthEnabledGateLimiter(limitsFactory limits.Factory, lggr logger
180175
}
181176

182177
func (v *jwtBasedAuth) start(context.Context) error {
183-
if !v.refreshEnabled {
184-
v.lggr.Debug("JWTBasedAuth periodic JWKS refresh disabled")
185-
return nil
186-
}
187-
188178
v.eng.GoTick(services.NewTicker(v.refreshInterval), func(ctx context.Context) {
189179
if err := v.refreshJWKS(ctx); err != nil {
190180
v.lggr.Warnw("periodic JWKS refresh failed", "error", err)
@@ -209,21 +199,21 @@ func (v *jwtBasedAuth) AuthorizeRequest(ctx context.Context, req jsonrpc.Request
209199
return nil, errors.New("JWTBasedAuth is disabled")
210200
}
211201

212-
requestDigest, err := req.Digest()
213-
if err != nil {
214-
v.lggr.Debugw("JWTBasedAuth failed to compute request digest", "method", req.Method, "requestID", req.ID, "error", err)
215-
return nil, fmt.Errorf("failed to compute request digest: %w", err)
216-
}
217-
218202
claims, err := v.validateToken(ctx, req.Auth)
219203
if err != nil {
220204
v.lggr.Debugw("JWTBasedAuth token validation failed", "method", req.Method, "requestID", req.ID, "error", err)
221205
return nil, fmt.Errorf("invalid JWT auth token: %w", err)
222206
}
223207

208+
requestDigest, err := req.Digest()
209+
if err != nil {
210+
v.lggr.Debugw("JWTBasedAuth failed to compute request digest", "method", req.Method, "requestID", req.ID, "orgID", claims.OrgID, "workflowOwner", claims.WorkflowOwner, "error", err)
211+
return nil, fmt.Errorf("failed to compute request digest: %w", err)
212+
}
213+
224214
if !strings.EqualFold(requestDigest, claims.RequestDigest) {
225215
v.lggr.Debugw("JWTBasedAuth request digest mismatch", "method", req.Method, "requestID", req.ID, "orgID", claims.OrgID, "workflowOwner", claims.WorkflowOwner, "computedDigest", requestDigest, "claimedDigest", claims.RequestDigest)
226-
return nil, errors.New("request digest mismatch")
216+
return nil, fmt.Errorf("request digest mismatch: computed=%s claimed=%s", requestDigest, claims.RequestDigest)
227217
}
228218

229219
v.lggr.Debugw("JWTBasedAuth authorization succeeded", "method", req.Method, "requestID", req.ID, "orgID", claims.OrgID, "workflowOwner", claims.WorkflowOwner, "digest", requestDigest, "expiresAt", claims.ExpiresAt.UTC().Unix())

0 commit comments

Comments
 (0)