Skip to content

Commit d61310d

Browse files
committed
fix: enable Vault JWT auth without Spire for KMS signing
When signers.kms.auth.oidc.path and signers.kms.auth.oidc.role are set in the Chains ConfigMap without Spire, the controller now reads the Kubernetes service account token from the pod filesystem and uses it for Vault JWT auth login. Previously, the OIDC token field was only populated by Spire, causing the code to fall through to VAULT_TOKEN lookup and fail with "read .vault-token file: no such file or directory". Signed-off-by: Shubham Bhardwaj <shubbhar@redhat.com>
1 parent 58bca7a commit d61310d

5 files changed

Lines changed: 260 additions & 3 deletions

File tree

docs/config.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,7 @@ chains.tekton.dev/transparency-upload: "true"
177177
| `signers.kms.auth.token-path` | Path to store KMS server Auth token (e.g. `/etc/kms-secrets`) | |
178178
| `signers.kms.auth.oidc.path` | Path used for OIDC authentication (e.g. `jwt` for Vault) | |
179179
| `signers.kms.auth.oidc.role` | Role used for OIDC authentication | |
180+
| `signers.kms.auth.oidc.token-path`| Path to a file containing the JWT token for OIDC authentication. If not set, defaults to the Kubernetes service account token at `/var/run/secrets/kubernetes.io/serviceaccount/token`. | | `/var/run/secrets/kubernetes.io/serviceaccount/token` |
180181
| `signers.kms.auth.spire.sock` | URI of the Spire socket used for KMS token (e.g. `unix:///tmp/spire-agent/public/api.sock`) | |
181182
| `signers.kms.auth.spire.audience` | Audience for requesting a SVID from Spire | |
182183

pkg/chains/signing/kms/kms.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@ import (
3838
"github.com/tektoncd/chains/pkg/chains/signing"
3939
)
4040

41+
const defaultOIDCTokenPath = "/var/run/secrets/kubernetes.io/serviceaccount/token" //nolint:gosec // Not a credential, this is the path to read the K8s SA token file from
42+
4143
// Signer exposes methods to sign payloads using a KMS
4244
type Signer struct {
4345
signature.SignerVerifier
@@ -125,6 +127,23 @@ func NewSigner(ctx context.Context, cfg config.KMSSigner) (*Signer, error) {
125127
}
126128
rpcAuth.OIDC.Token = token
127129
}
130+
131+
if rpcAuth.OIDC.Token == "" && rpcAuth.Token == "" && (cfg.Auth.OIDC.Path != "" || cfg.Auth.OIDC.Role != "") {
132+
tokenPath := cfg.Auth.OIDC.TokenPath
133+
if tokenPath == "" {
134+
tokenPath = defaultOIDCTokenPath
135+
}
136+
token, err := os.ReadFile(tokenPath)
137+
if err != nil {
138+
return nil, fmt.Errorf("reading OIDC token from %q: %w", tokenPath, err)
139+
}
140+
oidcToken := strings.TrimSpace(string(token))
141+
if oidcToken == "" {
142+
return nil, fmt.Errorf("OIDC token file %q is empty", tokenPath)
143+
}
144+
rpcAuth.OIDC.Token = oidcToken
145+
}
146+
128147
kmsOpts = append(kmsOpts, options.WithRPCAuthOpts(rpcAuth))
129148
// get the signer/verifier from sigstore
130149
k, err := kms.Get(ctx, cfg.KMSRef, crypto.SHA256, kmsOpts...)

pkg/chains/signing/kms/kms_test.go

Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,14 @@ package kms
1717

1818
import (
1919
"context"
20+
"encoding/json"
2021
"net"
2122
"net/http"
2223
"net/http/httptest"
2324
"os"
25+
"sync"
2426
"testing"
27+
"time"
2528

2629
"github.com/stretchr/testify/assert"
2730
"github.com/tektoncd/chains/pkg/config"
@@ -126,6 +129,216 @@ func TestValidVaultAddressConnection(t *testing.T) {
126129
})
127130
}
128131

132+
// TestOIDCTokenEndToEnd proves the full flow: JWT file → rpcAuth.OIDC.Token
133+
// → ApplyRPCAuthOpts → oidcLogin → Vault HTTP request.
134+
// A mock Vault server captures the login request and verifies the JWT, role,
135+
// and auth path arrive exactly as configured.
136+
func TestOIDCTokenEndToEnd(t *testing.T) {
137+
var mu sync.Mutex
138+
var loginCalled bool
139+
var receivedJWT, receivedRole, receivedPath string
140+
141+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
142+
mu.Lock()
143+
defer mu.Unlock()
144+
145+
switch {
146+
case r.URL.Path == "/v1/auth/jwt/login" && r.Method == http.MethodPut:
147+
loginCalled = true
148+
receivedPath = "jwt"
149+
150+
var body map[string]interface{}
151+
if err := json.NewDecoder(r.Body).Decode(&body); err == nil {
152+
if v, ok := body["jwt"].(string); ok {
153+
receivedJWT = v
154+
}
155+
if v, ok := body["role"].(string); ok {
156+
receivedRole = v
157+
}
158+
}
159+
160+
w.Header().Set("Content-Type", "application/json")
161+
resp := `{"auth":{"client_token":"hvs.mock-vault-token","policies":["default"],"lease_duration":3600,"renewable":true}}`
162+
w.Write([]byte(resp))
163+
default:
164+
w.WriteHeader(http.StatusNotFound)
165+
}
166+
}))
167+
defer server.Close()
168+
169+
tokenFile, err := os.CreateTemp("", "jwt-token")
170+
if err != nil {
171+
t.Fatalf("creating temp file: %v", err)
172+
}
173+
defer os.Remove(tokenFile.Name())
174+
175+
err = os.WriteFile(tokenFile.Name(), []byte("eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.test-payload\n"), 0644)
176+
if err != nil {
177+
t.Fatalf("writing temp file: %v", err)
178+
}
179+
180+
cfg := config.KMSSigner{
181+
KMSRef: "hashivault://supply-chain",
182+
Auth: config.KMSAuth{
183+
Address: server.URL,
184+
OIDC: config.KMSAuthOIDC{
185+
Path: "jwt",
186+
Role: "tekton-chains",
187+
TokenPath: tokenFile.Name(),
188+
},
189+
},
190+
}
191+
192+
signer, err := NewSigner(context.Background(), cfg)
193+
194+
// The signer should be created successfully — oidcLogin exchanges the
195+
// JWT for a Vault token, newHashivaultClient stores it, no transit API
196+
// call happens during construction.
197+
if err != nil {
198+
t.Fatalf("NewSigner should succeed when OIDC login returns a valid token, got: %v", err)
199+
}
200+
if signer == nil {
201+
t.Fatal("signer must not be nil")
202+
}
203+
204+
mu.Lock()
205+
defer mu.Unlock()
206+
assert.True(t, loginCalled, "Vault auth/jwt/login endpoint must have been called")
207+
assert.Equal(t, "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.test-payload", receivedJWT,
208+
"JWT sent to Vault must match the trimmed file contents")
209+
assert.Equal(t, "tekton-chains", receivedRole,
210+
"role sent to Vault must match the configured OIDC role")
211+
assert.Equal(t, "jwt", receivedPath,
212+
"auth path must match the configured OIDC path")
213+
}
214+
215+
// TestOIDCTokenFallbackToDefaultPath proves that when oidc.path/role are set
216+
// but no token-path is given, the code tries the default K8s SA token path.
217+
func TestOIDCTokenFallbackToDefaultPath(t *testing.T) {
218+
cfg := config.KMSSigner{
219+
Auth: config.KMSAuth{
220+
OIDC: config.KMSAuthOIDC{
221+
Path: "jwt",
222+
Role: "tekton-chains",
223+
},
224+
},
225+
}
226+
227+
_, err := NewSigner(context.Background(), cfg)
228+
229+
if err == nil {
230+
t.Fatal("expected error when default SA token path does not exist")
231+
}
232+
assert.Contains(t, err.Error(), "reading OIDC token")
233+
assert.Contains(t, err.Error(), defaultOIDCTokenPath,
234+
"error must reference the default K8s SA token path")
235+
}
236+
237+
// TestOIDCTokenSkippedWhenNotConfigured proves the OIDC block is not entered
238+
// when neither oidc.path nor oidc.role are set.
239+
func TestOIDCTokenSkippedWhenNotConfigured(t *testing.T) {
240+
cfg := config.KMSSigner{}
241+
242+
_, err := NewSigner(context.Background(), cfg)
243+
244+
if err == nil {
245+
t.Fatal("expected error when no KMS config is set")
246+
}
247+
assert.NotContains(t, err.Error(), "reading OIDC token",
248+
"OIDC reading must not be attempted when OIDC is not configured")
249+
assert.NotContains(t, err.Error(), "OIDC token file",
250+
"OIDC empty-file check must not be reached when OIDC is not configured")
251+
}
252+
253+
// TestOIDCTokenSkippedWhenStaticTokenSet proves that the file-based OIDC
254+
// token reading is skipped when a static Vault token is already set, even if
255+
// oidc.path/oidc.role are configured. This prevents breaking existing users
256+
// who have both a static token and leftover OIDC config.
257+
func TestOIDCTokenSkippedWhenStaticTokenSet(t *testing.T) {
258+
cfg := config.KMSSigner{
259+
Auth: config.KMSAuth{
260+
Token: "my-static-vault-token",
261+
OIDC: config.KMSAuthOIDC{
262+
Path: "jwt",
263+
Role: "tekton-chains",
264+
},
265+
},
266+
}
267+
268+
_, err := NewSigner(context.Background(), cfg)
269+
270+
if err == nil {
271+
t.Fatal("expected error (no KMSRef), but should NOT be an OIDC reading error")
272+
}
273+
assert.NotContains(t, err.Error(), "reading OIDC token",
274+
"OIDC file reading must be skipped when a static token is set")
275+
assert.NotContains(t, err.Error(), "OIDC token file",
276+
"OIDC empty check must be skipped when a static token is set")
277+
}
278+
279+
// TestOIDCTokenEmptyFileErrors proves that an empty token file produces a
280+
// clear error rather than silently breaking OIDC.
281+
func TestOIDCTokenEmptyFileErrors(t *testing.T) {
282+
tokenFile, err := os.CreateTemp("", "empty-jwt")
283+
if err != nil {
284+
t.Fatalf("creating temp file: %v", err)
285+
}
286+
defer os.Remove(tokenFile.Name())
287+
288+
cfg := config.KMSSigner{
289+
Auth: config.KMSAuth{
290+
OIDC: config.KMSAuthOIDC{
291+
Path: "jwt",
292+
Role: "tekton-chains",
293+
TokenPath: tokenFile.Name(),
294+
},
295+
},
296+
}
297+
298+
_, err = NewSigner(context.Background(), cfg)
299+
300+
if err == nil {
301+
t.Fatal("expected error for empty OIDC token file")
302+
}
303+
assert.Contains(t, err.Error(), "OIDC token file")
304+
assert.Contains(t, err.Error(), "is empty")
305+
}
306+
307+
// TestOIDCTokenNotReadWhenSpireConfigured proves that the file-based OIDC
308+
// token reading is skipped when Spire is configured (Spire takes precedence).
309+
// The Spire gRPC client retries indefinitely, so we use a short-lived context
310+
// to make it fail quickly and verify the error is about Spire, not file-based
311+
// OIDC.
312+
func TestOIDCTokenNotReadWhenSpireConfigured(t *testing.T) {
313+
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
314+
defer cancel()
315+
316+
cfg := config.KMSSigner{
317+
Auth: config.KMSAuth{
318+
OIDC: config.KMSAuthOIDC{
319+
Path: "jwt",
320+
Role: "tekton-chains",
321+
},
322+
Spire: config.KMSAuthSpire{
323+
Sock: "unix:///tmp/nonexistent-spire.sock",
324+
Audience: "test",
325+
},
326+
},
327+
}
328+
329+
_, err := NewSigner(ctx, cfg)
330+
331+
if err == nil {
332+
t.Fatal("expected error when Spire socket does not exist")
333+
}
334+
// The error should be about Spire/context, NOT about reading an OIDC
335+
// token file — proving the Spire block runs and the file block is skipped.
336+
assert.NotContains(t, err.Error(), "reading OIDC token",
337+
"file-based OIDC reading must be skipped when Spire is configured")
338+
assert.NotContains(t, err.Error(), "OIDC token file",
339+
"file-based OIDC empty check must be skipped when Spire is configured")
340+
}
341+
129342
// Test for getKMSAuthToken with non-directory path
130343
func TestGetKMSAuthToken_NotADirectory(t *testing.T) {
131344
tempFile, err := os.CreateTemp("", "not-a-dir")

pkg/config/config.go

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -101,8 +101,9 @@ type KMSAuth struct {
101101

102102
// KMSAuthOIDC configures settings to authenticate with OIDC
103103
type KMSAuthOIDC struct {
104-
Path string
105-
Role string
104+
Path string
105+
Role string
106+
TokenPath string
106107
}
107108

108109
// KMSAuthSpire configures settings to get an auth token from spire
@@ -204,8 +205,9 @@ const (
204205
kmsAuthAddress = "signers.kms.auth.address"
205206
kmsAuthToken = "signers.kms.auth.token"
206207
kmsAuthOIDCPath = "signers.kms.auth.oidc.path"
207-
kmsAuthTokenPath = "signers.kms.auth.token-path" // #nosec G101
208208
kmsAuthOIDCRole = "signers.kms.auth.oidc.role"
209+
kmsAuthOIDCTokenPath = "signers.kms.auth.oidc.token-path" // #nosec G101
210+
kmsAuthTokenPath = "signers.kms.auth.token-path" // #nosec G101
209211
kmsAuthSpireSock = "signers.kms.auth.spire.sock"
210212
kmsAuthSpireAudience = "signers.kms.auth.spire.audience"
211213

@@ -335,6 +337,7 @@ func NewConfigFromMap(data map[string]string) (*Config, error) {
335337
asString(kmsAuthTokenPath, &cfg.Signers.KMS.Auth.TokenPath),
336338
asString(kmsAuthOIDCPath, &cfg.Signers.KMS.Auth.OIDC.Path),
337339
asString(kmsAuthOIDCRole, &cfg.Signers.KMS.Auth.OIDC.Role),
340+
asString(kmsAuthOIDCTokenPath, &cfg.Signers.KMS.Auth.OIDC.TokenPath),
338341
asString(kmsAuthSpireSock, &cfg.Signers.KMS.Auth.Spire.Sock),
339342
asString(kmsAuthSpireAudience, &cfg.Signers.KMS.Auth.Spire.Audience),
340343

pkg/config/config_test.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,3 +85,24 @@ func TestArtifact_Enabled(t *testing.T) {
8585
})
8686
}
8787
}
88+
89+
func TestNewConfigFromMap_KMSAuthOIDC(t *testing.T) {
90+
data := map[string]string{
91+
"signers.kms.auth.oidc.path": "jwt",
92+
"signers.kms.auth.oidc.role": "tekton-chains",
93+
"signers.kms.auth.oidc.token-path": "/var/run/secrets/tokens/vault-token",
94+
}
95+
cfg, err := NewConfigFromMap(data)
96+
if err != nil {
97+
t.Fatalf("NewConfigFromMap() error = %v", err)
98+
}
99+
if cfg.Signers.KMS.Auth.OIDC.Path != "jwt" {
100+
t.Errorf("OIDC.Path = %q, want %q", cfg.Signers.KMS.Auth.OIDC.Path, "jwt")
101+
}
102+
if cfg.Signers.KMS.Auth.OIDC.Role != "tekton-chains" {
103+
t.Errorf("OIDC.Role = %q, want %q", cfg.Signers.KMS.Auth.OIDC.Role, "tekton-chains")
104+
}
105+
if cfg.Signers.KMS.Auth.OIDC.TokenPath != "/var/run/secrets/tokens/vault-token" {
106+
t.Errorf("OIDC.TokenPath = %q, want %q", cfg.Signers.KMS.Auth.OIDC.TokenPath, "/var/run/secrets/tokens/vault-token")
107+
}
108+
}

0 commit comments

Comments
 (0)