// Copyright 2018 Google LLC All Rights Reserved. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package authn import ( "encoding/base64" "errors" "fmt" "log" "os" "path" "path/filepath" "reflect" "testing" "time" "github.com/google/go-containerregistry/pkg/name" ) var ( fresh = 0 testRegistry, _ = name.NewRegistry("test.io", name.WeakValidation) testRepo, _ = name.NewRepository("test.io/my-repo", name.WeakValidation) defaultRegistry, _ = name.NewRegistry(name.DefaultRegistry, name.WeakValidation) ) func TestMain(m *testing.M) { // Set $HOME to a temp empty dir, to ensure $HOME/.docker/config.json // isn't unexpectedly found. tmp, err := os.MkdirTemp("", "keychain_test_home") if err != nil { log.Fatal(err) } os.Setenv("HOME", tmp) os.Exit(func() int { defer os.RemoveAll(tmp) return m.Run() }()) } // setupConfigDir sets up an isolated configDir() for this test. func setupConfigDir(t *testing.T) string { tmpdir := os.Getenv("TEST_TMPDIR") if tmpdir == "" { tmpdir = t.TempDir() } fresh++ p := filepath.Join(tmpdir, fmt.Sprintf("%d", fresh)) t.Logf("DOCKER_CONFIG=%s", p) t.Setenv("DOCKER_CONFIG", p) if err := os.Mkdir(p, 0777); err != nil { t.Fatalf("mkdir %q: %v", p, err) } return p } func setupConfigFile(t *testing.T, content string) string { cd := setupConfigDir(t) p := filepath.Join(cd, "config.json") if err := os.WriteFile(p, []byte(content), 0600); err != nil { t.Fatalf("write %q: %v", p, err) } // return the config dir so we can clean up return cd } func TestNoConfig(t *testing.T) { cd := setupConfigDir(t) defer os.RemoveAll(filepath.Dir(cd)) auth, err := DefaultKeychain.Resolve(testRegistry) if err != nil { t.Fatalf("Resolve() = %v", err) } if auth != Anonymous { t.Errorf("expected Anonymous, got %v", auth) } } func TestPodmanConfig(t *testing.T) { tmpdir := os.Getenv("TEST_TMPDIR") if tmpdir == "" { tmpdir = t.TempDir() } fresh++ p := filepath.Join(tmpdir, fmt.Sprintf("%d", fresh)) t.Setenv("XDG_RUNTIME_DIR", p) os.Unsetenv("DOCKER_CONFIG") if err := os.MkdirAll(filepath.Join(p, "containers"), 0777); err != nil { t.Fatalf("mkdir %s/containers: %v", p, err) } cfg := filepath.Join(p, "containers/auth.json") content := fmt.Sprintf(`{"auths": {"test.io": {"auth": %q}}}`, encode("foo", "bar")) if err := os.WriteFile(cfg, []byte(content), 0600); err != nil { t.Fatalf("write %q: %v", cfg, err) } // At first, $DOCKER_CONFIG is unset and $HOME/.docker/config.json isn't // found, but Podman auth is configured. This should return Podman's // auth. auth, err := DefaultKeychain.Resolve(testRegistry) if err != nil { t.Fatalf("Resolve() = %v", err) } got, err := auth.Authorization() if err != nil { t.Fatal(err) } want := &AuthConfig{ Username: "foo", Password: "bar", } if !reflect.DeepEqual(got, want) { t.Errorf("got %+v, want %+v", got, want) } // Now, configure $HOME/.docker/config.json, which should override // Podman auth and be used. if err := os.MkdirAll(filepath.Join(os.Getenv("HOME"), ".docker"), 0777); err != nil { t.Fatalf("mkdir $HOME/.docker: %v", err) } cfg = filepath.Join(os.Getenv("HOME"), ".docker/config.json") content = fmt.Sprintf(`{"auths": {"test.io": {"auth": %q}}}`, encode("home-foo", "home-bar")) if err := os.WriteFile(cfg, []byte(content), 0600); err != nil { t.Fatalf("write %q: %v", cfg, err) } defer func() { os.Remove(cfg) }() auth, err = DefaultKeychain.Resolve(testRegistry) if err != nil { t.Fatalf("Resolve() = %v", err) } got, err = auth.Authorization() if err != nil { t.Fatal(err) } want = &AuthConfig{ Username: "home-foo", Password: "home-bar", } if !reflect.DeepEqual(got, want) { t.Errorf("got %+v, want %+v", got, want) } // Then, configure DOCKER_CONFIG with a valid config file with different // auth configured. // This demonstrates that DOCKER_CONFIG is preferred over Podman auth // and $HOME/.docker/config.json. content = fmt.Sprintf(`{"auths": {"test.io": {"auth": %q}}}`, encode("another-foo", "another-bar")) cd := setupConfigFile(t, content) defer os.RemoveAll(filepath.Dir(cd)) auth, err = DefaultKeychain.Resolve(testRegistry) if err != nil { t.Fatalf("Resolve() = %v", err) } got, err = auth.Authorization() if err != nil { t.Fatal(err) } want = &AuthConfig{ Username: "another-foo", Password: "another-bar", } if !reflect.DeepEqual(got, want) { t.Errorf("got %+v, want %+v", got, want) } } func encode(user, pass string) string { delimited := fmt.Sprintf("%s:%s", user, pass) return base64.StdEncoding.EncodeToString([]byte(delimited)) } func TestVariousPaths(t *testing.T) { tests := []struct { desc string content string wantErr bool target Resource cfg *AuthConfig anonymous bool }{{ desc: "invalid config file", target: testRegistry, content: `}{`, wantErr: true, }, { desc: "creds store does not exist", target: testRegistry, content: `{"credsStore":"#definitely-does-not-exist"}`, wantErr: true, }, { desc: "valid config file", target: testRegistry, content: fmt.Sprintf(`{"auths": {"test.io": {"auth": %q}}}`, encode("foo", "bar")), cfg: &AuthConfig{ Username: "foo", Password: "bar", }, }, { desc: "valid config file; default registry", target: defaultRegistry, content: fmt.Sprintf(`{"auths": {"%s": {"auth": %q}}}`, DefaultAuthKey, encode("foo", "bar")), cfg: &AuthConfig{ Username: "foo", Password: "bar", }, }, { desc: "valid config file; matches registry w/ v1", target: testRegistry, content: fmt.Sprintf(`{ "auths": { "http://test.io/v1/": {"auth": %q} } }`, encode("baz", "quux")), cfg: &AuthConfig{ Username: "baz", Password: "quux", }, }, { desc: "valid config file; matches registry w/ v2", target: testRegistry, content: fmt.Sprintf(`{ "auths": { "http://test.io/v2/": {"auth": %q} } }`, encode("baz", "quux")), cfg: &AuthConfig{ Username: "baz", Password: "quux", }, }, { desc: "valid config file; matches repo", target: testRepo, content: fmt.Sprintf(`{ "auths": { "test.io/my-repo": {"auth": %q}, "test.io/another-repo": {"auth": %q}, "test.io": {"auth": %q} } }`, encode("foo", "bar"), encode("bar", "baz"), encode("baz", "quux")), cfg: &AuthConfig{ Username: "foo", Password: "bar", }, }, { desc: "ignore unrelated repo", target: testRepo, content: fmt.Sprintf(`{ "auths": { "test.io/another-repo": {"auth": %q}, "test.io": {} } }`, encode("bar", "baz")), cfg: &AuthConfig{}, anonymous: true, }} for _, test := range tests { t.Run(test.desc, func(t *testing.T) { cd := setupConfigFile(t, test.content) // For some reason, these tempdirs don't get cleaned up. defer os.RemoveAll(filepath.Dir(cd)) auth, err := DefaultKeychain.Resolve(test.target) if test.wantErr { if err == nil { t.Fatal("wanted err, got nil") } else if err != nil { // success return } } if err != nil { t.Fatalf("wanted nil, got err: %v", err) } cfg, err := auth.Authorization() if err != nil { t.Fatal(err) } if !reflect.DeepEqual(cfg, test.cfg) { t.Errorf("got %+v, want %+v", cfg, test.cfg) } if test.anonymous != (auth == Anonymous) { t.Fatalf("unexpected anonymous authenticator") } }) } } type helper struct { u, p string err error } func (h helper) Get(serverURL string) (string, string, error) { if serverURL != "example.com" { return "", "", fmt.Errorf("unexpected serverURL: %s", serverURL) } return h.u, h.p, h.err } func TestNewKeychainFromHelper(t *testing.T) { var repo = name.MustParseReference("example.com/my/repo").Context() t.Run("success", func(t *testing.T) { kc := NewKeychainFromHelper(helper{"username", "password", nil}) auth, err := kc.Resolve(repo) if err != nil { t.Fatalf("Resolve(%q): %v", repo, err) } cfg, err := auth.Authorization() if err != nil { t.Fatalf("Authorization: %v", err) } if got, want := cfg.Username, "username"; got != want { t.Errorf("Username: got %q, want %q", got, want) } if got, want := cfg.IdentityToken, ""; got != want { t.Errorf("IdentityToken: got %q, want %q", got, want) } if got, want := cfg.Password, "password"; got != want { t.Errorf("Password: got %q, want %q", got, want) } }) t.Run("success; identity token", func(t *testing.T) { kc := NewKeychainFromHelper(helper{"", "idtoken", nil}) auth, err := kc.Resolve(repo) if err != nil { t.Fatalf("Resolve(%q): %v", repo, err) } cfg, err := auth.Authorization() if err != nil { t.Fatalf("Authorization: %v", err) } if got, want := cfg.Username, ""; got != want { t.Errorf("Username: got %q, want %q", got, want) } if got, want := cfg.IdentityToken, "idtoken"; got != want { t.Errorf("IdentityToken: got %q, want %q", got, want) } if got, want := cfg.Password, ""; got != want { t.Errorf("Password: got %q, want %q", got, want) } }) t.Run("failure", func(t *testing.T) { kc := NewKeychainFromHelper(helper{"", "", errors.New("oh no bad")}) auth, err := kc.Resolve(repo) if err != nil { t.Fatalf("Resolve(%q): %v", repo, err) } if auth != Anonymous { t.Errorf("Resolve: got %v, want %v", auth, Anonymous) } }) } func TestConfigFileIsADir(t *testing.T) { tmpdir := setupConfigDir(t) // Create "config.json" as a directory, not a file to simulate optional // secrets in Kubernetes. err := os.Mkdir(path.Join(tmpdir, "config.json"), 0777) if err != nil { t.Fatal(err) } auth, err := DefaultKeychain.Resolve(testRegistry) if err != nil { t.Fatalf("Resolve() = %v", err) } if auth != Anonymous { t.Errorf("expected Anonymous, got %v", auth) } } type fakeKeychain struct { auth Authenticator err error count int } func (k *fakeKeychain) Resolve(_ Resource) (Authenticator, error) { k.count++ return k.auth, k.err } func TestRefreshingAuth(t *testing.T) { repo := name.MustParseReference("example.com/my/repo").Context() last := time.Now() // Increments by 1 minute each invocation. clock := func() time.Time { last = last.Add(1 * time.Minute) return last } want := AuthConfig{ Username: "foo", Password: "secret", } keychain := &fakeKeychain{FromConfig(want), nil, 0} rk := RefreshingKeychain(keychain, 5*time.Minute) rk.(*refreshingKeychain).clock = clock auth, err := rk.Resolve(repo) if err != nil { t.Fatal(err) } for i := 0; i < 10; i++ { got, err := auth.Authorization() if err != nil { t.Fatal(err) } if *got != want { t.Errorf("got %+v, want %+v", got, want) } } if got, want := keychain.count, 2; got != want { t.Errorf("refreshed %d times, wanted %d", got, want) } }