diff --git a/pkg/vendir/config/data.go b/pkg/vendir/config/data.go index 62ac44a3..1b9519e8 100644 --- a/pkg/vendir/config/data.go +++ b/pkg/vendir/config/data.go @@ -6,6 +6,7 @@ package config const ( SecretK8sCorev1BasicAuthUsernameKey = "username" SecretK8sCorev1BasicAuthPasswordKey = "password" + SecretK8sCorev1HTTPBearerTokenKey = "token" SecretK8sCoreV1SSHAuthPrivateKey = "ssh-privatekey" SecretSSHAuthKnownHosts = "ssh-knownhosts" // not part of k8s diff --git a/pkg/vendir/fetch/http/sync.go b/pkg/vendir/fetch/http/sync.go index f2eb664a..b7250940 100644 --- a/pkg/vendir/fetch/http/sync.go +++ b/pkg/vendir/fetch/http/sync.go @@ -148,14 +148,56 @@ func (t *Sync) addAuth(req *http.Request) error { switch name { case ctlconf.SecretK8sCorev1BasicAuthUsernameKey: case ctlconf.SecretK8sCorev1BasicAuthPasswordKey: + case ctlconf.SecretK8sCorev1HTTPBearerTokenKey: default: return fmt.Errorf("Unknown secret field '%s' in secret '%s'", name, secret.Metadata.Name) } } - if _, found := secret.Data[ctlconf.SecretK8sCorev1BasicAuthUsernameKey]; found { - req.SetBasicAuth(string(secret.Data[ctlconf.SecretK8sCorev1BasicAuthUsernameKey]), - string(secret.Data[ctlconf.SecretK8sCorev1BasicAuthPasswordKey])) + _, hasUser := secret.Data[ctlconf.SecretK8sCorev1BasicAuthUsernameKey] + _, hasPass := secret.Data[ctlconf.SecretK8sCorev1BasicAuthPasswordKey] + token, hasToken := secret.Data[ctlconf.SecretK8sCorev1HTTPBearerTokenKey] + + // Validate that token is not empty if provided. + if hasToken && len(token) == 0 { + return fmt.Errorf( + "Secret '%s' contains empty '%s'", + secret.Metadata.Name, + ctlconf.SecretK8sCorev1HTTPBearerTokenKey, + ) + } + + // Basic auth requires a username if password is provided, but password is optional. + if hasPass && !hasUser { + return fmt.Errorf( + "Secret '%s' contains '%s' but is missing '%s'", + secret.Metadata.Name, + ctlconf.SecretK8sCorev1BasicAuthPasswordKey, + ctlconf.SecretK8sCorev1BasicAuthUsernameKey, + ) + } + + // Do not allow mixing basic auth and bearer token in the same secret. + if hasToken && hasUser { + return fmt.Errorf( + "Secret '%s' must not contain both basic auth (username/password) and token", + secret.Metadata.Name, + ) + } + + // Bearer token auth + if hasToken { + req.Header.Set("Authorization", "Bearer "+string(token)) + return nil + } + + // Basic auth — password is optional, defaults to empty string + if hasUser { + password := string(secret.Data[ctlconf.SecretK8sCorev1BasicAuthPasswordKey]) + req.SetBasicAuth( + string(secret.Data[ctlconf.SecretK8sCorev1BasicAuthUsernameKey]), + password, + ) } return nil diff --git a/pkg/vendir/fetch/http/sync_test.go b/pkg/vendir/fetch/http/sync_test.go new file mode 100644 index 00000000..18784d87 --- /dev/null +++ b/pkg/vendir/fetch/http/sync_test.go @@ -0,0 +1,197 @@ +// Copyright 2024 The Carvel Authors. +// SPDX-License-Identifier: Apache-2.0 + +package http_test + +import ( + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + ctlconf "carvel.dev/vendir/pkg/vendir/config" + vendirhttp "carvel.dev/vendir/pkg/vendir/fetch/http" + "github.com/stretchr/testify/require" +) + +type fakeRefFetcher struct { + secrets map[string]ctlconf.Secret + configMaps map[string]ctlconf.ConfigMap +} + +func (f fakeRefFetcher) GetSecret(name string) (ctlconf.Secret, error) { + s, ok := f.secrets[name] + if !ok { + return ctlconf.Secret{}, fmt.Errorf("secret %q not found", name) + } + return s, nil +} + +func (f fakeRefFetcher) GetConfigMap(name string) (ctlconf.ConfigMap, error) { + if f.configMaps == nil { + return ctlconf.ConfigMap{}, fmt.Errorf("configmap %q not found", name) + } + + cm, ok := f.configMaps[name] + if !ok { + return ctlconf.ConfigMap{}, fmt.Errorf("configmap %q not found", name) + } + return cm, nil +} + +type fakeTempArea struct { + baseDir string +} + +func (f fakeTempArea) NewTempDir(prefix string) (string, error) { + return os.MkdirTemp(f.baseDir, prefix) +} + +func (f fakeTempArea) NewTempFile(prefix string) (*os.File, error) { + return os.CreateTemp(f.baseDir, prefix) +} + +func secretRef(name string) *ctlconf.DirectoryContentsLocalRef { + return &ctlconf.DirectoryContentsLocalRef{Name: name} +} + +type syncTest struct { + name string + secret ctlconf.Secret + expectedBody string + expectedError string + validateReq func(t *testing.T, r *http.Request) +} + +func TestSync_HTTPAuth(t *testing.T) { + allTests := []syncTest{ + { + name: "when basic auth username and password are provided, it succeeds", + secret: ctlconf.Secret{ + Metadata: ctlconf.GenericMetadata{Name: "http-auth"}, + Data: map[string][]byte{ + ctlconf.SecretK8sCorev1BasicAuthUsernameKey: []byte("admin"), + ctlconf.SecretK8sCorev1BasicAuthPasswordKey: []byte("password"), + }, + }, + expectedBody: "ok", + validateReq: func(t *testing.T, r *http.Request) { + user, pass, ok := r.BasicAuth() + require.True(t, ok) + require.Equal(t, "admin", user) + require.Equal(t, "password", pass) + }, + }, + { + name: "when bearer token is provided, it succeeds", + secret: ctlconf.Secret{ + Metadata: ctlconf.GenericMetadata{Name: "http-auth"}, + Data: map[string][]byte{ + ctlconf.SecretK8sCorev1HTTPBearerTokenKey: []byte("abc123"), + }, + }, + expectedBody: "ok", + validateReq: func(t *testing.T, r *http.Request) { + require.Equal(t, "Bearer abc123", r.Header.Get("Authorization")) + }, + }, + { + name: "when username is provided without password, it uses empty password and succeeds", + secret: ctlconf.Secret{ + Metadata: ctlconf.GenericMetadata{Name: "http-auth"}, + Data: map[string][]byte{ + ctlconf.SecretK8sCorev1BasicAuthUsernameKey: []byte("admin"), + }, + }, + expectedBody: "ok", + validateReq: func(t *testing.T, r *http.Request) { + user, pass, ok := r.BasicAuth() + require.True(t, ok) + require.Equal(t, "admin", user) + require.Equal(t, "", pass) + }, + }, + { + name: "when basic auth and bearer token are mixed, it fails", + secret: ctlconf.Secret{ + Metadata: ctlconf.GenericMetadata{Name: "http-auth"}, + Data: map[string][]byte{ + ctlconf.SecretK8sCorev1BasicAuthUsernameKey: []byte("admin"), + ctlconf.SecretK8sCorev1BasicAuthPasswordKey: []byte("password"), + ctlconf.SecretK8sCorev1HTTPBearerTokenKey: []byte("abc123"), + }, + }, + expectedError: "must not contain both basic auth", + }, + { + name: "when bearer token is empty, it fails", + secret: ctlconf.Secret{ + Metadata: ctlconf.GenericMetadata{Name: "http-auth"}, + Data: map[string][]byte{ + ctlconf.SecretK8sCorev1HTTPBearerTokenKey: []byte(""), + }, + }, + expectedError: "contains empty 'token'", + }, + { + name: "when password is provided without username, it fails", + secret: ctlconf.Secret{ + Metadata: ctlconf.GenericMetadata{Name: "http-auth"}, + Data: map[string][]byte{ + ctlconf.SecretK8sCorev1BasicAuthPasswordKey: []byte("password"), + }, + }, + expectedError: "is missing 'username'", + }, + } + + for _, test := range allTests { + t.Run(test.name, func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if test.expectedError != "" { + t.Fatalf("server should not be reached when auth setup fails") + } + + test.validateReq(t, r) + + w.WriteHeader(http.StatusOK) + _, err := w.Write([]byte(test.expectedBody)) + require.NoError(t, err) + })) + defer srv.Close() + + ref := fakeRefFetcher{ + secrets: map[string]ctlconf.Secret{ + "http-auth": test.secret, + }, + } + + subject := vendirhttp.NewSync(ctlconf.DirectoryContentsHTTP{ + URL: srv.URL, + SecretRef: secretRef("http-auth"), + DisableUnpack: true, + }, ref) + + tempRoot, err := os.MkdirTemp("", "vendir-http-test") + require.NoError(t, err) + defer os.RemoveAll(tempRoot) + + dstPath := filepath.Join(tempRoot, "dst") + _, err = subject.Sync(dstPath, fakeTempArea{baseDir: tempRoot}) + + if test.expectedError != "" { + require.Error(t, err) + require.Contains(t, err.Error(), test.expectedError) + return + } + + require.NoError(t, err) + + bs, err := os.ReadFile(filepath.Join(dstPath, filepath.Base(srv.URL))) + require.NoError(t, err) + require.Equal(t, test.expectedBody, string(bs)) + }) + } +}