-
Notifications
You must be signed in to change notification settings - Fork 36
Expand file tree
/
Copy pathoptions.go
More file actions
448 lines (363 loc) · 14.1 KB
/
options.go
File metadata and controls
448 lines (363 loc) · 14.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
// Copyright AGNTCY Contributors (https://github.com/agntcy)
// SPDX-License-Identifier: Apache-2.0
package client
import (
"context"
"crypto/tls"
"crypto/x509"
"encoding/base64"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"io"
"os"
"strings"
"github.com/agntcy/dir/utils/logging"
"github.com/agntcy/dir/utils/spiffe"
"github.com/spiffe/go-spiffe/v2/spiffegrpc/grpccredentials"
"github.com/spiffe/go-spiffe/v2/spiffetls/tlsconfig"
"github.com/spiffe/go-spiffe/v2/workloadapi"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/credentials/insecure"
)
var authLogger = logging.Logger("client.auth")
type Option func(*options) error
// TODO: options need to be granular per key rather than for full config.
type options struct {
config *Config
authOpts []grpc.DialOption
authClient *workloadapi.Client
// SPIFFE sources for cleanup
bundleSrc io.Closer
x509Src io.Closer
jwtSource io.Closer
}
func WithEnvConfig() Option {
return func(opts *options) error {
var err error
opts.config, err = LoadConfig()
return err
}
}
func WithConfig(config *Config) Option {
return func(opts *options) error {
opts.config = config
return nil
}
}
func withAuth(ctx context.Context) Option {
return func(o *options) error {
// Validate config exists before dereferencing
if o.config == nil {
return errors.New("config is required: use WithConfig() or WithEnvConfig()")
}
// Setup authentication based on AuthMode
switch o.config.AuthMode {
case "jwt":
// NOTE: jwt source must live for the entire client lifetime, not just the initialization phase
return o.setupJWTAuth(ctx) //nolint:contextcheck
case "x509":
// NOTE: x509 source must live for the entire client lifetime, not just the initialization phase
return o.setupX509Auth(ctx) //nolint:contextcheck
case "token":
return o.setupSpiffeAuth(ctx)
case "tls":
return o.setupTlsAuth(ctx)
case "oidc":
return o.setupOIDCAuth(ctx)
case "insecure", "none", "":
// Insecure/none/empty auth mode - try auto-detection first, fallback to insecure
return o.setupAutoDetectAuth(ctx)
default:
// Invalid auth mode specified - return error to prevent silent security issues
return fmt.Errorf("unsupported auth mode: %s (supported: 'jwt', 'x509', 'token', 'tls', 'oidc', 'insecure', 'none', or empty for auto-detect)", o.config.AuthMode)
}
}
}
// setupAutoDetectAuth attempts to auto-detect available credentials and falls back to insecure if none found.
// This is used when auth mode is empty, "insecure", or "none".
func (o *options) setupAutoDetectAuth(ctx context.Context) error {
// For explicit "insecure" or "none" mode, skip auto-detection
if o.config.AuthMode == "insecure" || o.config.AuthMode == "none" {
authLogger.Debug("Using insecure connection (explicit mode)", "auth_mode", o.config.AuthMode)
o.authOpts = append(o.authOpts, grpc.WithTransportCredentials(insecure.NewCredentials()))
return nil
}
shouldTryOIDC, err := o.shouldAutoDetectOIDC()
if err != nil {
return err
}
if shouldTryOIDC {
authLogger.Debug("Auto-detected OIDC authentication signals; using OIDC auth")
if err := o.setupOIDCAuth(ctx); err != nil {
return fmt.Errorf("failed to setup auto-detected OIDC auth: %w", err)
}
return nil
}
// No secure auth signals found; use insecure connection (local development only)
authLogger.Debug("No auto-detected credentials found, using insecure connection")
o.authOpts = append(o.authOpts, grpc.WithTransportCredentials(insecure.NewCredentials()))
return nil
}
func (o *options) shouldAutoDetectOIDC() (bool, error) {
if strings.TrimSpace(o.config.OIDCToken) != "" {
return true, nil
}
if o.hasMachineOIDCConfig() {
return true, nil
}
cache := NewTokenCache()
tok, err := cache.GetValidToken()
if err != nil {
return false, fmt.Errorf("failed to read OIDC token cache: %w", err)
}
return tok != nil && strings.TrimSpace(tok.AccessToken) != "", nil
}
func (o *options) setupJWTAuth(ctx context.Context) error {
// Validate SPIFFE socket path is set
if o.config.SpiffeSocketPath == "" {
return errors.New("spiffe socket path is required for JWT authentication")
}
// Validate JWT audience is set
if o.config.JWTAudience == "" {
return errors.New("JWT audience is required for JWT authentication")
}
// Create SPIFFE client
client, err := workloadapi.New(ctx, workloadapi.WithAddr(o.config.SpiffeSocketPath))
if err != nil {
return fmt.Errorf("failed to create SPIFFE client: %w", err)
}
// Create bundle source for verifying server's TLS certificate (X.509-SVID)
// Note: Use context.Background() because this source must live for the entire client lifetime,
// not just the initialization phase. It will be properly closed in client.Close().
bundleSrc, err := workloadapi.NewBundleSource(context.Background(), workloadapi.WithClient(client)) //nolint:contextcheck
if err != nil {
_ = client.Close()
return fmt.Errorf("failed to create bundle source: %w", err)
}
// Create JWT source for fetching JWT-SVIDs
// Note: Use context.Background() because this source must live for the entire client lifetime,
// not just the initialization phase. It will be properly closed in client.Close().
jwtSource, err := workloadapi.NewJWTSource(context.Background(), workloadapi.WithClient(client)) //nolint:contextcheck
if err != nil {
_ = client.Close()
_ = bundleSrc.Close()
return fmt.Errorf("failed to create JWT source: %w", err)
}
// Use TLS for transport security (server presents X.509-SVID)
// Client authenticates with JWT-SVID via PerRPCCredentials
o.authClient = client
o.bundleSrc = bundleSrc
o.jwtSource = jwtSource
o.authOpts = append(o.authOpts,
grpc.WithTransportCredentials(
grpccredentials.TLSClientCredentials(bundleSrc, tlsconfig.AuthorizeAny()),
),
grpc.WithPerRPCCredentials(newJWTCredentials(jwtSource, o.config.JWTAudience)),
)
return nil
}
func (o *options) setupX509Auth(ctx context.Context) error {
// Validate SPIFFE socket path is set
if o.config.SpiffeSocketPath == "" {
return errors.New("spiffe socket path is required for x509 authentication")
}
authLogger.Debug("Setting up X509 authentication", "spiffe_socket_path", o.config.SpiffeSocketPath)
// Create SPIFFE client
client, err := workloadapi.New(ctx, workloadapi.WithAddr(o.config.SpiffeSocketPath))
if err != nil {
return fmt.Errorf("failed to create SPIFFE client: %w", err)
}
authLogger.Debug("Created SPIFFE workload API client")
// Create SPIFFE x509 services
// Note: Use context.Background() because this source must live for the entire client lifetime,
// not just the initialization phase. It will be properly closed in client.Close().
x509Src, err := workloadapi.NewX509Source(context.Background(), workloadapi.WithClient(client)) //nolint:contextcheck
if err != nil {
_ = client.Close()
return fmt.Errorf("failed to create x509 source: %w", err)
}
authLogger.Debug("Created X509 source, starting retry logic to get valid SVID")
// Wait for X509-SVID to be available with retry logic
// This handles timing issues where the SPIRE entry hasn't been synced to the agent yet
// (common with CronJobs and other short-lived workloads)
// The agent may return a certificate without a URI SAN (SPIFFE ID) if the entry hasn't synced,
// so we must validate that the certificate actually contains a valid SPIFFE ID.
svid, svidErr := spiffe.GetX509SVIDWithRetry(
x509Src,
spiffe.DefaultMaxRetries,
spiffe.DefaultInitialBackoff,
spiffe.DefaultMaxBackoff,
authLogger,
)
if svidErr != nil {
_ = client.Close()
_ = x509Src.Close()
authLogger.Error("Failed to get valid X509-SVID after retries", "error", svidErr, "max_retries", spiffe.DefaultMaxRetries)
return fmt.Errorf("failed to get valid X509-SVID after retries (SPIRE entry may not be synced yet): %w", svidErr)
}
authLogger.Info("Successfully obtained valid X509-SVID", "spiffe_id", svid.ID.String())
// Wrap x509Src with retry logic so GetX509SVID() calls during TLS handshake also retry
// This is critical because grpccredentials.MTLSClientCredentials calls GetX509SVID()
// during the actual TLS handshake, not just during setup. Without this wrapper,
// the TLS handshake may fail if the certificate doesn't have a URI SAN at that moment.
//
// Connection flow: dirctl → Ingress (TLS passthrough) → apiserver pod
// The TLS handshake happens between dirctl and apiserver, and during this handshake,
// grpccredentials.MTLSClientCredentials calls GetX509SVID() again.
//
// Note: x509Src is *workloadapi.X509Source (concrete type that implements x509svid.Source).
// We use it directly as the Source interface and also as io.Closer.
wrappedX509Src := spiffe.NewX509SourceWithRetry(
x509Src, // Use pointer directly (implements x509svid.Source)
x509Src, // Same pointer (implements io.Closer)
authLogger,
spiffe.DefaultMaxRetries,
spiffe.DefaultInitialBackoff,
spiffe.DefaultMaxBackoff,
)
authLogger.Debug("Created X509SourceWithRetry wrapper",
"wrapped_type", fmt.Sprintf("%T", wrappedX509Src),
"src_type", fmt.Sprintf("%T", x509Src),
"implements_source", true)
// Create SPIFFE bundle services
// Note: Use context.Background() because this source must live for the entire client lifetime,
// not just the initialization phase. It will be properly closed in client.Close().
bundleSrc, err := workloadapi.NewBundleSource(context.Background(), workloadapi.WithClient(client)) //nolint:contextcheck
if err != nil {
_ = client.Close()
_ = x509Src.Close() // Fix Issue #4: Close x509Src on error
return fmt.Errorf("failed to create bundle source: %w", err)
}
// Update options
o.authClient = client
o.x509Src = wrappedX509Src // Store wrapped source for cleanup
o.bundleSrc = bundleSrc
authLogger.Debug("Creating MTLSClientCredentials with wrapped source",
"wrapped_source_type", fmt.Sprintf("%T", wrappedX509Src),
"wrapped_implements_source", true)
creds := grpccredentials.MTLSClientCredentials(wrappedX509Src, bundleSrc, tlsconfig.AuthorizeAny())
o.authOpts = append(o.authOpts, grpc.WithTransportCredentials(creds))
authLogger.Debug("MTLSClientCredentials created successfully, wrapper will be used for TLS handshake")
return nil
}
func (o *options) setupSpiffeAuth(_ context.Context) error {
// Validate token file is set
if o.config.SpiffeToken == "" {
return errors.New("spiffe token file path is required for token authentication")
}
// Read token file
tokenData, err := os.ReadFile(o.config.SpiffeToken)
if err != nil {
return fmt.Errorf("failed to read SPIFFE token file: %w", err)
}
// SpiffeTokenData represents the structure of SPIFFE token JSON
//nolint:gosec // G117: intentional private key field
type SpiffeTokenData struct {
X509SVID []string `json:"x509_svid"` // DER-encoded certificates in base64
PrivateKey string `json:"private_key"` // DER-encoded private key in base64
RootCAs []string `json:"root_cas"` // DER-encoded root CA certificates in base64
}
// Parse SPIFFE token JSON
var spiffeData []SpiffeTokenData
if err := json.Unmarshal(tokenData, &spiffeData); err != nil {
return fmt.Errorf("failed to parse SPIFFE token: %w", err)
}
if len(spiffeData) == 0 {
return errors.New("no SPIFFE data found in token")
}
// Use the first SPIFFE data entry
data := spiffeData[0]
// Parse the certificate chain
if len(data.X509SVID) == 0 {
return errors.New("no X.509 SVID certificates found")
}
// From base64 DER to PEM
certDER, err := base64.StdEncoding.DecodeString(data.X509SVID[0])
if err != nil {
return fmt.Errorf("failed to decode certificate: %w", err)
}
certPEM := pem.EncodeToMemory(&pem.Block{
Type: "CERTIFICATE",
Bytes: certDER,
})
// The private key is base64-encoded DER format
keyDER, err := base64.StdEncoding.DecodeString(data.PrivateKey)
if err != nil {
return fmt.Errorf("failed to decode private key: %w", err)
}
keyPEM := pem.EncodeToMemory(&pem.Block{
Type: "PRIVATE KEY",
Bytes: keyDER,
})
// Create certificate from PEM data
cert, err := tls.X509KeyPair(certPEM, keyPEM)
if err != nil {
return fmt.Errorf("failed to create certificate from SPIFFE data: %w", err)
}
// Create CA pool from root CAs
capool := x509.NewCertPool()
for _, rootCA := range data.RootCAs {
// Root CAs are also base64-encoded DER
caDER, err := base64.StdEncoding.DecodeString(rootCA)
if err != nil {
return fmt.Errorf("failed to decode root CA: %w", err)
}
caPEM := pem.EncodeToMemory(&pem.Block{
Type: "CERTIFICATE",
Bytes: caDER,
})
if !capool.AppendCertsFromPEM(caPEM) {
return errors.New("failed to append root CA certificate to CA pool")
}
}
// Create TLS config
tlsConfig := &tls.Config{
Certificates: []tls.Certificate{cert},
RootCAs: capool,
InsecureSkipVerify: o.config.TlsSkipVerify, //nolint:gosec
}
// Update options
o.authOpts = append(o.authOpts, grpc.WithTransportCredentials(credentials.NewTLS(tlsConfig)))
return nil
}
func (o *options) setupTlsAuth(_ context.Context) error {
// Validate TLS config is set
if o.config.TlsCAFile == "" || o.config.TlsCertFile == "" || o.config.TlsKeyFile == "" {
return errors.New("TLS CA, cert, and key file paths are required for TLS authentication")
}
// Load TLS data for tlsConfig
caData, err := os.ReadFile(o.config.TlsCAFile)
if err != nil {
return fmt.Errorf("failed to read TLS CA file: %w", err)
}
certData, err := os.ReadFile(o.config.TlsCertFile)
if err != nil {
return fmt.Errorf("failed to read TLS cert file: %w", err)
}
keyData, err := os.ReadFile(o.config.TlsKeyFile)
if err != nil {
return fmt.Errorf("failed to read TLS key file: %w", err)
}
// Create certificate from PEM data
cert, err := tls.X509KeyPair(certData, keyData)
if err != nil {
return fmt.Errorf("failed to create certificate from TLS data: %w", err)
}
// Create CA pool from root CAs
capool := x509.NewCertPool()
if !capool.AppendCertsFromPEM(caData) {
return errors.New("failed to append root CA certificate to CA pool")
}
// Create TLS config
tlsConfig := &tls.Config{
Certificates: []tls.Certificate{cert},
RootCAs: capool,
InsecureSkipVerify: o.config.TlsSkipVerify, //nolint:gosec
}
// Update options
o.authOpts = append(o.authOpts, grpc.WithTransportCredentials(credentials.NewTLS(tlsConfig)))
return nil
}