Skip to content

Commit 077fcbe

Browse files
Add Vault JWT local CRE test coverage
1 parent f0afcfa commit 077fcbe

27 files changed

Lines changed: 1970 additions & 54 deletions

File tree

core/capabilities/vault/gw_handler.go

Lines changed: 9 additions & 1 deletion
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,7 +101,7 @@ 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())
104+
jwtBasedAuth, err := NewJWTBasedAuthFromAuth0Config(cfg.auth0, limitsFactory, lggr)
97105
if err != nil {
98106
return nil, fmt.Errorf("failed to create JWTBasedAuth: %w", err)
99107
}

core/capabilities/vault/jwt_based_auth.go

Lines changed: 32 additions & 7 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
@@ -119,6 +125,19 @@ func WithDisabledJWTBasedAuth() JWTBasedAuthOption {
119125
}
120126
}
121127

128+
// NewJWTBasedAuthFromAuth0Config constructs JWT auth from an optional Auth0 config.
129+
// When auth0 is not configured, the validator is created in a fail-closed disabled state.
130+
func NewJWTBasedAuthFromAuth0Config(auth0 *Auth0Config, limitsFactory limits.Factory, lggr logger.Logger) (*jwtBasedAuth, error) {
131+
if auth0 == nil || (auth0.IssuerURL == "" && auth0.Audience == "") {
132+
return NewJWTBasedAuth(JWTBasedAuthConfig{}, limitsFactory, lggr, WithDisabledJWTBasedAuth())
133+
}
134+
135+
return NewJWTBasedAuth(JWTBasedAuthConfig{
136+
IssuerURL: auth0.IssuerURL,
137+
Audience: auth0.Audience,
138+
}, limitsFactory, lggr)
139+
}
140+
122141
// NewJWTBasedAuth creates a JWTBasedAuth authorizer that verifies Auth0-issued JWTs
123142
// against the provider's JWKS endpoint. The JWKS is fetched lazily on first
124143
// use and refreshed on key-ID cache misses (rate-limited).
@@ -209,21 +228,27 @@ func (v *jwtBasedAuth) AuthorizeRequest(ctx context.Context, req jsonrpc.Request
209228
return nil, errors.New("JWTBasedAuth is disabled")
210229
}
211230

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-
218231
claims, err := v.validateToken(ctx, req.Auth)
219232
if err != nil {
220233
v.lggr.Debugw("JWTBasedAuth token validation failed", "method", req.Method, "requestID", req.ID, "error", err)
221234
return nil, fmt.Errorf("invalid JWT auth token: %w", err)
222235
}
223236

237+
normalizedReq, err := NormalizeRequestWithIdentity(req, claims.OrgID, claims.WorkflowOwner)
238+
if err != nil {
239+
v.lggr.Debugw("JWTBasedAuth failed to normalize request identity", "method", req.Method, "requestID", req.ID, "orgID", claims.OrgID, "workflowOwner", claims.WorkflowOwner, "error", err)
240+
return nil, fmt.Errorf("failed to normalize request identity: %w", err)
241+
}
242+
243+
requestDigest, err := normalizedReq.Digest()
244+
if err != nil {
245+
v.lggr.Debugw("JWTBasedAuth failed to compute request digest", "method", req.Method, "requestID", req.ID, "orgID", claims.OrgID, "workflowOwner", claims.WorkflowOwner, "error", err)
246+
return nil, fmt.Errorf("failed to compute request digest: %w", err)
247+
}
248+
224249
if !strings.EqualFold(requestDigest, claims.RequestDigest) {
225250
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")
251+
return nil, fmt.Errorf("request digest mismatch: computed=%s claimed=%s", requestDigest, claims.RequestDigest)
227252
}
228253

229254
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())

core/capabilities/vault/jwt_based_auth_test.go

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -519,6 +519,51 @@ func TestNewJWTBasedAuth_UsesVaultJWTAuthEnabledLimiter_Enabled(t *testing.T) {
519519
require.ErrorContains(t, err, ErrMissingToken.Error())
520520
}
521521

522+
func TestJWTBasedAuth_AuthorizeCreateRequestFromRawJSON(t *testing.T) {
523+
rsaKey := generateTestRSAKey(t, "key-1")
524+
jwksServer := newTestJWKSServer(t, rsaKey)
525+
526+
issuer := jwksServer.URL() + "/"
527+
audience := "https://vault.test.chain.link"
528+
v := newTestValidator(t, issuer, audience)
529+
530+
rawRequest := []byte(`{"jsonrpc":"2.0","id":"req-1","method":"vault.secrets.create","params":{"request_id":"req-1","encrypted_secrets":[{"id":{"key":"7611","namespace":"main","owner":"org-123"},"encrypted_value":"cipher+/=="}]}}`)
531+
req, err := jsonrpc.DecodeRequest[json.RawMessage](rawRequest, "")
532+
require.NoError(t, err)
533+
534+
normalizedReq, err := NormalizeRequestWithIdentity(req, "org-123", "0xAbCdEf0123456789AbCdEf0123456789AbCdEf01")
535+
require.NoError(t, err)
536+
537+
digest, err := normalizedReq.Digest()
538+
require.NoError(t, err)
539+
540+
token := createTestJWT(t, rsaKey, jwt.MapClaims{
541+
"iss": issuer,
542+
"aud": audience,
543+
"exp": jwt.NewNumericDate(time.Now().Add(5 * time.Minute)),
544+
"iat": jwt.NewNumericDate(time.Now()),
545+
"org_id": "org-123",
546+
"authorization_details": []interface{}{
547+
map[string]interface{}{
548+
"type": "request_digest",
549+
"value": digest,
550+
},
551+
map[string]interface{}{
552+
"type": "workflow_owner",
553+
"value": "0xAbCdEf0123456789AbCdEf0123456789AbCdEf01",
554+
},
555+
},
556+
})
557+
558+
req, err = jsonrpc.DecodeRequest[json.RawMessage](rawRequest, token)
559+
require.NoError(t, err)
560+
561+
authResult, err := v.AuthorizeRequest(t.Context(), req)
562+
require.NoError(t, err)
563+
require.Equal(t, "org-123", authResult.OrgID())
564+
require.Equal(t, digest, authResult.Digest())
565+
}
566+
522567
func setDefaultGetter(t *testing.T, payload string) {
523568
t.Helper()
524569

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
package vault
2+
3+
import (
4+
"encoding/json"
5+
6+
vaultcommon "github.com/smartcontractkit/chainlink-common/pkg/capabilities/actions/vault"
7+
jsonrpc "github.com/smartcontractkit/chainlink-common/pkg/jsonrpc2"
8+
"github.com/smartcontractkit/chainlink/v2/core/capabilities/vault/vaulttypes"
9+
)
10+
11+
// NormalizeRequestWithIdentity returns a copy of req with the JWT-derived identity
12+
// fields materialized into params for Vault secret-management methods. Requests with
13+
// malformed params are returned unchanged so downstream parsing can surface the
14+
// existing method-specific error paths.
15+
func NormalizeRequestWithIdentity(req jsonrpc.Request[json.RawMessage], orgID, workflowOwner string) (jsonrpc.Request[json.RawMessage], error) {
16+
if req.Params == nil {
17+
return req, nil
18+
}
19+
20+
rewrite := func(payload any) error {
21+
b, err := json.Marshal(payload)
22+
if err != nil {
23+
return err
24+
}
25+
raw := json.RawMessage(b)
26+
req.Params = &raw
27+
return nil
28+
}
29+
30+
switch req.Method {
31+
case vaulttypes.MethodSecretsCreate:
32+
parsed := &vaultcommon.CreateSecretsRequest{}
33+
if err := json.Unmarshal(*req.Params, parsed); err != nil {
34+
return req, nil
35+
}
36+
parsed.OrgId = orgID
37+
parsed.WorkflowOwner = workflowOwner
38+
return req, rewrite(parsed)
39+
case vaulttypes.MethodSecretsUpdate:
40+
parsed := &vaultcommon.UpdateSecretsRequest{}
41+
if err := json.Unmarshal(*req.Params, parsed); err != nil {
42+
return req, nil
43+
}
44+
parsed.OrgId = orgID
45+
parsed.WorkflowOwner = workflowOwner
46+
return req, rewrite(parsed)
47+
case vaulttypes.MethodSecretsDelete:
48+
parsed := &vaultcommon.DeleteSecretsRequest{}
49+
if err := json.Unmarshal(*req.Params, parsed); err != nil {
50+
return req, nil
51+
}
52+
parsed.OrgId = orgID
53+
parsed.WorkflowOwner = workflowOwner
54+
return req, rewrite(parsed)
55+
case vaulttypes.MethodSecretsList:
56+
parsed := &vaultcommon.ListSecretIdentifiersRequest{}
57+
if err := json.Unmarshal(*req.Params, parsed); err != nil {
58+
return req, nil
59+
}
60+
parsed.OrgId = orgID
61+
parsed.WorkflowOwner = workflowOwner
62+
return req, rewrite(parsed)
63+
default:
64+
return req, nil
65+
}
66+
}

core/capabilities/vault/validator.go

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,16 +23,16 @@ type RequestValidator struct {
2323
}
2424

2525
func (r *RequestValidator) ValidateCreateSecretsRequest(publicKey *tdh2easy.PublicKey, request *vaultcommon.CreateSecretsRequest) error {
26-
return r.validateWriteRequest(publicKey, request.RequestId, request.EncryptedSecrets)
26+
return r.validateWriteRequest(publicKey, request.RequestId, request.OrgId, request.WorkflowOwner, request.EncryptedSecrets)
2727
}
2828

2929
func (r *RequestValidator) ValidateUpdateSecretsRequest(publicKey *tdh2easy.PublicKey, request *vaultcommon.UpdateSecretsRequest) error {
30-
return r.validateWriteRequest(publicKey, request.RequestId, request.EncryptedSecrets)
30+
return r.validateWriteRequest(publicKey, request.RequestId, request.OrgId, request.WorkflowOwner, request.EncryptedSecrets)
3131
}
3232

3333
// validateWriteRequest performs common validation for CreateSecrets and UpdateSecrets requests
3434
// It treats publicKey as optional, since it can be nil if the gateway nodes don't have the public key cached yet
35-
func (r *RequestValidator) validateWriteRequest(publicKey *tdh2easy.PublicKey, id string, encryptedSecrets []*vaultcommon.EncryptedSecret) error {
35+
func (r *RequestValidator) validateWriteRequest(publicKey *tdh2easy.PublicKey, id string, orgID string, workflowOwner string, encryptedSecrets []*vaultcommon.EncryptedSecret) error {
3636
if id == "" {
3737
return errors.New("request ID must not be empty")
3838
}
@@ -66,7 +66,11 @@ func (r *RequestValidator) validateWriteRequest(publicKey *tdh2easy.PublicKey, i
6666
if err := r.validateCiphertextSize(req.EncryptedValue); err != nil {
6767
return fmt.Errorf("secret encrypted value at index %d is invalid: %w", idx, err)
6868
}
69-
err := EnsureRightLabelOnSecret(publicKey, req.EncryptedValue, req.Id.Owner, "")
69+
expectedWorkflowOwner := workflowOwner
70+
if expectedWorkflowOwner == "" && orgID == "" {
71+
expectedWorkflowOwner = req.Id.Owner
72+
}
73+
err := EnsureRightLabelOnSecret(publicKey, req.EncryptedValue, expectedWorkflowOwner, orgID)
7074
if err != nil {
7175
return errors.New("Encrypted Secret at index [" + strconv.Itoa(idx) + "] doesn't have owner as the label. Error: " + err.Error())
7276
}

core/capabilities/vault/validator_test.go

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -298,3 +298,60 @@ func TestRequestValidator_CiphertextSizeLimit(t *testing.T) {
298298
})
299299
}
300300
}
301+
302+
func TestRequestValidator_ValidateCreateSecretsRequest_UsesRequestIdentityForOrgLabels(t *testing.T) {
303+
pk, _ := generateTestKeys(t)
304+
validator := NewRequestValidator(
305+
limits.NewUpperBoundLimiter(10),
306+
limits.NewUpperBoundLimiter[pkgconfig.Size](10*pkgconfig.KByte),
307+
)
308+
309+
orgID := "org_2xAbCdEfGhIjKlMnOpQrStUvWxYz"
310+
workflowOwner := "0x0001020304050607080900010203040506070809"
311+
encrypted := encryptWithOrgIDLabel(t, pk, orgID)
312+
313+
err := validator.ValidateCreateSecretsRequest(pk, &vaultcommon.CreateSecretsRequest{
314+
RequestId: "request-id",
315+
OrgId: orgID,
316+
WorkflowOwner: workflowOwner,
317+
EncryptedSecrets: []*vaultcommon.EncryptedSecret{
318+
{
319+
Id: &vaultcommon.SecretIdentifier{
320+
Key: "key",
321+
Namespace: "namespace",
322+
Owner: orgID,
323+
},
324+
EncryptedValue: encrypted,
325+
},
326+
},
327+
})
328+
329+
require.NoError(t, err)
330+
}
331+
332+
func TestRequestValidator_ValidateCreateSecretsRequest_FallsBackToSecretOwnerForLegacyRequests(t *testing.T) {
333+
pk, _ := generateTestKeys(t)
334+
validator := NewRequestValidator(
335+
limits.NewUpperBoundLimiter(10),
336+
limits.NewUpperBoundLimiter[pkgconfig.Size](10*pkgconfig.KByte),
337+
)
338+
339+
workflowOwner := "0x0001020304050607080900010203040506070809"
340+
encrypted := encryptWithEthAddressLabel(t, pk, workflowOwner)
341+
342+
err := validator.ValidateCreateSecretsRequest(pk, &vaultcommon.CreateSecretsRequest{
343+
RequestId: "request-id",
344+
EncryptedSecrets: []*vaultcommon.EncryptedSecret{
345+
{
346+
Id: &vaultcommon.SecretIdentifier{
347+
Key: "key",
348+
Namespace: "namespace",
349+
Owner: workflowOwner,
350+
},
351+
EncryptedValue: encrypted,
352+
},
353+
},
354+
})
355+
356+
require.NoError(t, err)
357+
}

core/scripts/cre/environment/environment/setup.go

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@ import (
1818
"github.com/Masterminds/semver/v3"
1919
"github.com/docker/docker/api/types/image"
2020
"github.com/docker/docker/client"
21-
"github.com/ethereum/go-ethereum/log"
2221
"github.com/pelletier/go-toml/v2"
2322
"github.com/pkg/errors"
2423
"github.com/rs/zerolog"
@@ -289,15 +288,12 @@ func (c BuildConfig) Build(ctx context.Context) (localImage string, err error) {
289288
}
290289

291290
// Build Docker image
292-
args := []string{"build", "-t", c.LocalImage, "-f", c.Dockerfile, c.DockerCtx}
293-
if c.RequireGithubToken {
294-
args = append(args, "--build-arg", "GITHUB_TOKEN="+os.Getenv("GITHUB_TOKEN"))
295-
}
291+
args := c.dockerBuildArgs()
296292

297293
cmd := exec.CommandContext(ctx, "docker", args...)
298294
cmd.Stdout = os.Stdout
299295
cmd.Stderr = os.Stderr
300-
log.Info("Running command:", "cmd", cmd.String(), "dir", workingDir)
296+
logger.Info().Str("cmd", cmd.String()).Str("dir", workingDir).Msg("Running docker build command")
301297
if err := cmd.Run(); err != nil {
302298
return "", fmt.Errorf("failed to build Docker image: %w", err)
303299
}
@@ -306,6 +302,14 @@ func (c BuildConfig) Build(ctx context.Context) (localImage string, err error) {
306302
return c.LocalImage, nil
307303
}
308304

305+
func (c BuildConfig) dockerBuildArgs() []string {
306+
args := []string{"build", "-t", c.LocalImage, "-f", c.Dockerfile}
307+
if c.RequireGithubToken {
308+
args = append(args, "--build-arg", "GITHUB_TOKEN="+os.Getenv("GITHUB_TOKEN"))
309+
}
310+
return append(args, c.DockerCtx)
311+
}
312+
309313
type PullConfig struct {
310314
LocalImage string `toml:"local_image"`
311315
EcrImage string `toml:"ecr_image"`
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
package environment
2+
3+
import (
4+
"os"
5+
"testing"
6+
7+
"github.com/stretchr/testify/require"
8+
)
9+
10+
func TestBuildConfigDockerBuildArgs(t *testing.T) {
11+
t.Setenv("GITHUB_TOKEN", "test-token")
12+
13+
cfg := BuildConfig{
14+
LocalImage: "test-image:latest",
15+
Dockerfile: "Dockerfile.test",
16+
DockerCtx: ".",
17+
RequireGithubToken: true,
18+
}
19+
20+
require.Equal(t, []string{
21+
"build",
22+
"-t", "test-image:latest",
23+
"-f", "Dockerfile.test",
24+
"--build-arg", "GITHUB_TOKEN=test-token",
25+
".",
26+
}, cfg.dockerBuildArgs())
27+
}
28+
29+
func TestBuildConfigDockerBuildArgs_WithoutGithubToken(t *testing.T) {
30+
require.NoError(t, os.Unsetenv("GITHUB_TOKEN"))
31+
32+
cfg := BuildConfig{
33+
LocalImage: "test-image:latest",
34+
Dockerfile: "Dockerfile.test",
35+
DockerCtx: ".",
36+
}
37+
38+
require.Equal(t, []string{
39+
"build",
40+
"-t", "test-image:latest",
41+
"-f", "Dockerfile.test",
42+
".",
43+
}, cfg.dockerBuildArgs())
44+
}

0 commit comments

Comments
 (0)