Skip to content
This repository has been archived by the owner on Apr 7, 2024. It is now read-only.

feat: remove dependency of docker-credentials-helper and support context #68

Merged
merged 22 commits into from
Jun 8, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 1 addition & 5 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,10 @@ module github.com/oras-project/oras-credentials-go

go 1.19

require (
github.com/docker/docker-credential-helpers v0.7.0
oras.land/oras-go/v2 v2.2.0
)
require oras.land/oras-go/v2 v2.2.0

require (
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.0-rc.3 // indirect
golang.org/x/sync v0.2.0 // indirect
golang.org/x/sys v0.8.0 // indirect
)
4 changes: 0 additions & 4 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,12 +1,8 @@
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-rc.3 h1:GT9Xon8YrLxz6N7sErbN81V8J4lOQKGUZQmI3ioviqU=
github.com/opencontainers/image-spec v1.1.0-rc.3/go.mod h1:X4pATf0uXsnn3g5aiGIsVnJBR4mxhKzfwmvK/B2NTm8=
golang.org/x/sync v0.2.0 h1:PUR+T4wwASmuSTYdKjYHI5TD22Wy5ogLU5qZCOLxBrI=
golang.org/x/sync v0.2.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.8.0 h1:EBmGv8NaZBZTWvrbjNoL6HVt+IVy3QDQpJs7VRIw3tU=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
oras.land/oras-go/v2 v2.2.0 h1:E1fqITD56Eg5neZbxBtAdZVgDHD6wBabJo6xESTcQyo=
oras.land/oras-go/v2 v2.2.0/go.mod h1:pXjn0+KfarspMHHNR3A56j3tgvr+mxArHuI8qVn59v8=
61 changes: 61 additions & 0 deletions internal/executer/executer.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
/*
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 executer is an abstraction for the docker credential helper protocol
// binaries. It is used by nativeStore to interact with installed binaries.
package executer
shizhMSFT marked this conversation as resolved.
Show resolved Hide resolved

import (
"bytes"
"context"
"errors"
"io"
"os"
"os/exec"
)

// Executer is an interface that simulates an executable binary.
type Executer interface {
Execute(ctx context.Context, input io.Reader, action string) ([]byte, error)
}

// executable implements the Executer interface.
type executable struct {
name string
}

// New returns a new Executer instance.
func New(name string) Executer {
return &executable{
Copy link
Member

@Wwwsylvia Wwwsylvia Jun 7, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A question: Do weed need to check if the executable can be found? Like using exec.LookPath?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we need more discussion on that? This is worth a future "feat" PR. I'll leave it as it is for this PR.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is a good idea and is necessary for detecting desktop.exe. However, we can leave it in the next PR.

name: name,
}
}

// Execute operates on an executable binary and supports context.
func (c *executable) Execute(ctx context.Context, input io.Reader, action string) ([]byte, error) {
cmd := exec.CommandContext(ctx, c.name, action)
cmd.Stdin = input
cmd.Stderr = os.Stderr
output, err := cmd.Output()
if err != nil {
if _, ok := err.(*exec.ExitError); ok {
if errMessage := string(bytes.TrimSpace(output)); errMessage != "" {
err = errors.New(errMessage)
}
}
return nil, err
}
return output, nil
}
50 changes: 34 additions & 16 deletions native_store.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,13 @@ limitations under the License.
package credentials

import (
"bytes"
"context"
"encoding/json"
"os/exec"
"strings"

"github.com/docker/docker-credential-helpers/client"
"github.com/docker/docker-credential-helpers/credentials"
"github.com/oras-project/oras-credentials-go/internal/executer"
"oras.land/oras-go/v2/registry/remote/auth"
)

Expand All @@ -29,10 +31,20 @@ const (
emptyUsername = "<token>"
wangxiaoxuan273 marked this conversation as resolved.
Show resolved Hide resolved
)

// dockerCredentials mimics how docker credential helper binaries store
// credential information.
Comment on lines +34 to +35
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added the reference link.

// Reference:
// - https://docs.docker.com/engine/reference/commandline/login/#credential-helper-protocol
type dockerCredentials struct {
ServerURL string `json:"ServerURL"`
Username string `json:"Username"`
Secret string `json:"Secret"`
}

// nativeStore implements a credentials store using native keychain to keep
// credentials secure.
type nativeStore struct {
programFunc client.ProgramFunc
exec executer.Executer
}

// NewNativeStore creates a new native store that uses a remote helper program to
Expand All @@ -46,22 +58,22 @@ type nativeStore struct {
// - https://docs.docker.com/engine/reference/commandline/login#credentials-store
func NewNativeStore(helperSuffix string) Store {
return &nativeStore{
programFunc: client.NewShellProgramFunc(remoteCredentialsPrefix + helperSuffix),
exec: executer.New(remoteCredentialsPrefix + helperSuffix),
}
}

// Get retrieves credentials from the store for the given server.
func (ns *nativeStore) Get(_ context.Context, serverAddress string) (auth.Credential, error) {
func (ns *nativeStore) Get(ctx context.Context, serverAddress string) (auth.Credential, error) {
var cred auth.Credential
dockerCred, err := client.Get(ns.programFunc, serverAddress)
out, err := ns.exec.Execute(ctx, strings.NewReader(serverAddress), "get")
if err != nil {
if credentials.IsErrCredentialsNotFound(err) {
// do not return an error if the credentials are not in the keychain.
return auth.EmptyCredential, nil
}
return auth.EmptyCredential, err
}
// bearer auth is used if the username is emptyUsername
var dockerCred dockerCredentials
if err := json.Unmarshal(out, &dockerCred); err != nil {
return auth.EmptyCredential, err
}
// bearer auth is used if the username is "<token>"
if dockerCred.Username == emptyUsername {
cred.RefreshToken = dockerCred.Secret
} else {
Expand All @@ -72,8 +84,8 @@ func (ns *nativeStore) Get(_ context.Context, serverAddress string) (auth.Creden
}

// Put saves credentials into the store.
func (ns *nativeStore) Put(_ context.Context, serverAddress string, cred auth.Credential) error {
dockerCred := &credentials.Credentials{
func (ns *nativeStore) Put(ctx context.Context, serverAddress string, cred auth.Credential) error {
dockerCred := &dockerCredentials{
ServerURL: serverAddress,
Username: cred.Username,
Secret: cred.Password,
Expand All @@ -82,12 +94,18 @@ func (ns *nativeStore) Put(_ context.Context, serverAddress string, cred auth.Cr
dockerCred.Username = emptyUsername
dockerCred.Secret = cred.RefreshToken
}
return client.Store(ns.programFunc, dockerCred)
credJSON, err := json.Marshal(dockerCred)
if err != nil {
return err
}
_, err = ns.exec.Execute(ctx, bytes.NewReader(credJSON), "store")
return err
}

// 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)
func (ns *nativeStore) Delete(ctx context.Context, serverAddress string) error {
_, err := ns.exec.Execute(ctx, strings.NewReader(serverAddress), "erase")
return err
}

// getDefaultHelperSuffix returns the default credential helper suffix.
Expand Down
61 changes: 34 additions & 27 deletions native_store_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,57 +23,59 @@ import (
"strings"
"testing"

"github.com/docker/docker-credential-helpers/client"
"github.com/docker/docker-credential-helpers/credentials"
"oras.land/oras-go/v2/registry/remote/auth"
)

const (
basicAuthHost = "localhost:2333"
bearerAuthHost = "localhost:6666"
exeErrorHost = "localhost:500/exeError"
jsonErrorHost = "localhost:500/jsonError"
testUsername = "test_username"
testPassword = "test_password"
testRefreshToken = "test_token"
)

var (
errCommandExited = fmt.Errorf("exited with error")
exeErr = fmt.Errorf("Execute failed")
)

// testCommand implements the Program interface for testing purpose.
// testExecuter implements the Executer interface for testing purpose.
// It simulates interactions between the docker client and a remote
// credentials helper.
type testCommand struct {
arg string
input io.Reader
}
type testExecuter struct{}

// Output returns responses from the remote credentials helper.
// Execute returns responses from the remote credentials helper.
// It mocks those responses based in the input in the mock.
func (m *testCommand) Output() ([]byte, error) {
in, err := io.ReadAll(m.input)
func (e *testExecuter) Execute(ctx context.Context, input io.Reader, action string) ([]byte, error) {
in, err := io.ReadAll(input)
if err != nil {
return nil, err
}
inS := string(in)
switch m.arg {
switch action {
case "get":
switch inS {
case basicAuthHost:
return []byte(`{"Username": "test_username", "Secret": "test_password"}`), nil
case bearerAuthHost:
return []byte(`{"Username": "<token>", "Secret": "test_token"}`), nil
case exeErrorHost:
return []byte("Execute failed"), exeErr
case jsonErrorHost:
return []byte("json.Unmarshal failed"), nil
default:
return []byte("program failed"), errCommandExited
}
case "store":
var c credentials.Credentials
var c dockerCredentials
err := json.NewDecoder(strings.NewReader(inS)).Decode(&c)
if err != nil {
return []byte("program failed"), errCommandExited
}
switch c.ServerURL {
case basicAuthHost, bearerAuthHost:
case basicAuthHost, bearerAuthHost, exeErrorHost:
return nil, nil
default:
return []byte("program failed"), errCommandExited
Expand All @@ -86,18 +88,7 @@ func (m *testCommand) Output() ([]byte, error) {
return []byte("program failed"), errCommandExited
}
}
return []byte(fmt.Sprintf("unknown argument %q with %q", m.arg, inS)), errCommandExited
}

// Input sets the input to send to a remote credentials helper.
func (m *testCommand) Input(in io.Reader) {
m.input = in
}

func testCommandFn(args ...string) client.Program {
return &testCommand{
arg: args[0],
}
return []byte(fmt.Sprintf("unknown argument %q with %q", action, inS)), errCommandExited
}

func TestNativeStore_interface(t *testing.T) {
Expand All @@ -109,7 +100,7 @@ func TestNativeStore_interface(t *testing.T) {

func TestNativeStore_basicAuth(t *testing.T) {
ns := &nativeStore{
programFunc: testCommandFn,
&testExecuter{},
}
// Put
err := ns.Put(context.Background(), basicAuthHost, auth.Credential{Username: testUsername, Password: testPassword})
Expand All @@ -136,7 +127,7 @@ func TestNativeStore_basicAuth(t *testing.T) {

func TestNativeStore_refreshToken(t *testing.T) {
ns := &nativeStore{
programFunc: testCommandFn,
&testExecuter{},
}
// Put
err := ns.Put(context.Background(), bearerAuthHost, auth.Credential{RefreshToken: testRefreshToken})
Expand All @@ -160,3 +151,19 @@ func TestNativeStore_refreshToken(t *testing.T) {
t.Fatalf("refresh token test ns.Delete fails: %v", err)
}
}

func TestNativeStore_errorHandling(t *testing.T) {
ns := &nativeStore{
&testExecuter{},
}
// Get Error: Execute error
_, err := ns.Get(context.Background(), exeErrorHost)
if err != exeErr {
t.Fatalf("got error: %v, should get exeErr", err)
}
// Get Error: json.Unmarshal
_, err = ns.Get(context.Background(), jsonErrorHost)
if err == nil {
t.Fatalf("should get error from json.Unmarshal")
}
}