diff --git a/go.mod b/go.mod index 09bdc8b..ed376c5 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,8 @@ require ( ) require ( + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.0-rc2 // indirect golang.org/x/sync v0.1.0 // indirect golang.org/x/sys v0.6.0 // indirect ) diff --git a/go.sum b/go.sum index 6a9f107..5e27cba 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,9 @@ github.com/docker/docker-credential-helpers v0.7.0 h1:xtCHsjxogADNZcdv1pKUHXryefjlVRqWqIhk/uXJp0A= github.com/docker/docker-credential-helpers v0.7.0/go.mod h1:rETQfLdHNT3foU5kuNkFR1R1V12OJRRO5lzt2D1b5X0= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.0-rc2 h1:2zx/Stx4Wc5pIPDvIxHXvXtQFW/7XWJGmnM7r3wg034= +github.com/opencontainers/image-spec v1.1.0-rc2/go.mod h1:3OVijpioIKYWTqjiG0zfF6wvoJ4fAXGbjdZuI2NgsRQ= golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.6.0 h1:MVltZSvRTcU2ljQOhs94SXPftV6DCNnZViHeQps87pQ= diff --git a/native_store.go b/native_store.go index 1b5d71c..fa2e441 100644 --- a/native_store.go +++ b/native_store.go @@ -42,7 +42,7 @@ func NewNativeStore(helperSuffix string) Store { } } -// Get retrieves credentials from the store for the given server +// Get retrieves credentials from the store for the given server. func (ns *NativeStore) Get(_ context.Context, serverAddress string) (auth.Credential, error) { var cred auth.Credential dockerCred, err := client.Get(ns.programFunc, serverAddress) @@ -63,7 +63,7 @@ func (ns *NativeStore) Get(_ context.Context, serverAddress string) (auth.Creden return cred, nil } -// Put saves credentials into the store +// Put saves credentials into the store. func (ns *NativeStore) Put(_ context.Context, serverAddress string, cred auth.Credential) error { dockerCred := &credentials.Credentials{ ServerURL: serverAddress, @@ -77,7 +77,7 @@ func (ns *NativeStore) Put(_ context.Context, serverAddress string, cred auth.Cr return client.Store(ns.programFunc, dockerCred) } -// Delete removes credentials from the store for the given server +// Delete removes credentials from the store for the given server. func (ns *NativeStore) Delete(_ context.Context, serverAddress string) error { return client.Erase(ns.programFunc, serverAddress) } diff --git a/registry.go b/registry.go new file mode 100644 index 0000000..60e6123 --- /dev/null +++ b/registry.go @@ -0,0 +1,65 @@ +/* +Copyright The ORAS Authors. +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 credentials + +import ( + "context" + "errors" + "fmt" + + "oras.land/oras-go/v2/registry/remote" + "oras.land/oras-go/v2/registry/remote/auth" +) + +// Login provides the login functionality with the given credentials. The target +// registry's client should be nil or of type *auth.Client. Login uses +// a client local to the function and will not modify the original client of +// the registry. +func Login(ctx context.Context, store Store, reg *remote.Registry, cred auth.Credential) error { + // create a clone of the original registry for login purpose + regClone := *reg + // we use the original client if applicable, otherwise use a default client + var authClient auth.Client + if reg.Client == nil { + authClient = *auth.DefaultClient + authClient.Cache = nil // no cache + } else if client, ok := reg.Client.(*auth.Client); ok { + authClient = *client + } else { + return errors.New("client type not supported") + } + regClone.Client = &authClient + // update credentials with the client + authClient.Credential = auth.StaticCredential(reg.Reference.Registry, cred) + // login and store credential + if err := regClone.Ping(ctx); err != nil { + return fmt.Errorf("unable to login to the registry %s: %w", regClone.Reference.Registry, err) + } + hostname := mapHostname(regClone.Reference.Registry) + if err := store.Put(ctx, hostname, cred); err != nil { + return fmt.Errorf("unable to store the credential for %s: %w", hostname, err) + } + return nil +} + +func mapHostname(hostname string) string { + // The Docker CLI expects that the 'docker.io' credential + // will be added under the key "https://index.docker.io/v1/" + if hostname == "docker.io" { + return "https://index.docker.io/v1/" + } + return hostname +} diff --git a/registry_test.go b/registry_test.go new file mode 100644 index 0000000..4d84078 --- /dev/null +++ b/registry_test.go @@ -0,0 +1,143 @@ +/* +Copyright The ORAS Authors. +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 credentials + +import ( + "context" + "encoding/base64" + "net/http" + "net/http/httptest" + "net/url" + "reflect" + "testing" + + "oras.land/oras-go/v2/registry/remote" + "oras.land/oras-go/v2/registry/remote/auth" +) + +// testStore implements the Store interface, used for testing purpose. +type testStore struct { + storage map[string]auth.Credential +} + +func (t *testStore) Get(ctx context.Context, serverAddress string) (auth.Credential, error) { + return t.storage[serverAddress], nil +} + +func (t *testStore) Put(ctx context.Context, serverAddress string, cred auth.Credential) error { + if len(t.storage) == 0 { + t.storage = make(map[string]auth.Credential) + } + t.storage[serverAddress] = cred + return nil +} + +func (t *testStore) Delete(ctx context.Context, serverAddress string) error { + delete(t.storage, serverAddress) + return nil +} + +func TestLogin(t *testing.T) { + // create a test registry + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + wantedAuthHeader := "Basic " + base64.StdEncoding.EncodeToString([]byte(testUsername+":"+testPassword)) + authHeader := r.Header.Get("Authorization") + if authHeader != wantedAuthHeader { + w.Header().Set("Www-Authenticate", `Basic realm="Test Server"`) + w.WriteHeader(http.StatusUnauthorized) + } + })) + defer ts.Close() + uri, _ := url.Parse(ts.URL) + reg, err := remote.NewRegistry(uri.Host) + if err != nil { + t.Fatalf("cannot create test registry: %v", err) + } + reg.PlainHTTP = true + // create a test store + ns := &testStore{} + tests := []struct { + name string + ctx context.Context + registry *remote.Registry + cred auth.Credential + wantErr bool + }{ + { + name: "login succeeds", + ctx: context.Background(), + registry: reg, + cred: auth.Credential{Username: testUsername, Password: testPassword}, + wantErr: false, + }, + { + name: "login fails (incorrect password)", + ctx: context.Background(), + registry: reg, + cred: auth.Credential{Username: testUsername, Password: "whatever"}, + wantErr: true, + }, + { + name: "login fails (nil context makes remote.Ping fails)", + ctx: nil, + registry: reg, + cred: auth.Credential{Username: testUsername, Password: testPassword}, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // login to test registry + err := Login(tt.ctx, ns, reg, tt.cred) + if (err != nil) != tt.wantErr { + t.Fatalf("Login() error = %v, wantErr %v", err, tt.wantErr) + } + if err != nil { + return + } + if got := ns.storage[reg.Reference.Registry]; !reflect.DeepEqual(got, tt.cred) { + t.Fatalf("Stored credential = %v, want %v", got, tt.cred) + } + ns.Delete(tt.ctx, reg.Reference.Registry) + }) + } +} + +func Test_mapHostname(t *testing.T) { + tests := []struct { + name string + host string + want string + }{ + { + "map docker.io to https://index.docker.io/v1/", + "docker.io", + "https://index.docker.io/v1/", + }, + { + "do not map other host names", + "localhost:2333", + "localhost:2333", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := mapHostname(tt.host); got != tt.want { + t.Errorf("mapHostname() = %v, want %v", got, tt.want) + } + }) + } +}