diff --git a/README.md b/README.md index 0b48887..9c11e1f 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,8 @@ $ make osxkeychain ## Usage +### With the Docker Engine + Set the `credsStore` option in your `.docker/config.json` file with the suffix of the program you want to use. For instance, set it to `osxkeychain` if you want to use `docker-credential-osxkeychain`. ```json @@ -35,11 +37,24 @@ Set the `credsStore` option in your `.docker/config.json` file with the suffix o } ``` +### With other command line applications + +The sub-package [client](https://godoc.org/github.com/docker/docker-credential-helpers/client) includes +functions to call external programs from your own command line applications. + +There are three things you need to know if you need to interact with a helper: + +1. The name of the program to execute, for instance `docker-credential-osxkeychain`. +2. The server address to identify the credentials, for instance `https://example.com`. +3. The username and secret to store, when you want to store credentials. + +You can see examples of each function in the [client](https://godoc.org/github.com/docker/docker-credential-helpers/client) documentation. + ### Available programs 1. osxkeychain: Provides a helper to use the OS X keychain as credentials store. -1. secretservice: Provides a helper to use the D-Bus secret service as credentials store. -2. wincred: Provides a helper to use Windows credentials manager as store. +2. secretservice: Provides a helper to use the D-Bus secret service as credentials store. +3. wincred: Provides a helper to use Windows credentials manager as store. ## Development diff --git a/client/client.go b/client/client.go new file mode 100644 index 0000000..ddd30bb --- /dev/null +++ b/client/client.go @@ -0,0 +1,70 @@ +package client + +import ( + "bytes" + "encoding/json" + "fmt" + "strings" + + "github.com/docker/docker-credential-helpers/credentials" +) + +// Store uses an external program to save credentials. +func Store(program ProgramFunc, credentials *credentials.Credentials) error { + cmd := program("store") + + buffer := new(bytes.Buffer) + if err := json.NewEncoder(buffer).Encode(credentials); err != nil { + return err + } + cmd.Input(buffer) + + out, err := cmd.Output() + if err != nil { + t := strings.TrimSpace(string(out)) + return fmt.Errorf("error storing credentials - err: %v, out: `%s`", err, t) + } + + return nil +} + +// Get executes an external program to get the credentials from a native store. +func Get(program ProgramFunc, serverURL string) (*credentials.Credentials, error) { + cmd := program("get") + cmd.Input(strings.NewReader(serverURL)) + + out, err := cmd.Output() + if err != nil { + t := strings.TrimSpace(string(out)) + + if credentials.IsErrCredentialsNotFoundMessage(t) { + return nil, credentials.NewErrCredentialsNotFound() + } + + return nil, fmt.Errorf("error getting credentials - err: %v, out: `%s`", err, t) + } + + resp := &credentials.Credentials{ + ServerURL: serverURL, + } + + if err := json.NewDecoder(bytes.NewReader(out)).Decode(resp); err != nil { + return nil, err + } + + return resp, nil +} + +// Erase executes a program to remove the server credentails from the native store. +func Erase(program ProgramFunc, serverURL string) error { + cmd := program("erase") + cmd.Input(strings.NewReader(serverURL)) + + out, err := cmd.Output() + if err != nil { + t := strings.TrimSpace(string(out)) + return fmt.Errorf("error erasing credentials - err: %v, out: `%s`", err, t) + } + + return nil +} diff --git a/client/client_test.go b/client/client_test.go new file mode 100644 index 0000000..1f2ee53 --- /dev/null +++ b/client/client_test.go @@ -0,0 +1,192 @@ +package client + +import ( + "encoding/json" + "fmt" + "io" + "io/ioutil" + "strings" + "testing" + + "github.com/docker/docker-credential-helpers/credentials" +) + +const ( + validServerAddress = "https://index.docker.io/v1" + validServerAddress2 = "https://example.com:5002" + invalidServerAddress = "https://foobar.example.com" + missingCredsAddress = "https://missing.docker.io/v1" +) + +var errProgramExited = fmt.Errorf("exited 1") + +// mockProgram simulates interactions between the docker client and a remote +// credentials helper. +// Unit tests inject this mocked command into the remote to control execution. +type mockProgram struct { + arg string + input io.Reader +} + +// Output returns responses from the remote credentials helper. +// It mocks those responses based in the input in the mock. +func (m *mockProgram) Output() ([]byte, error) { + in, err := ioutil.ReadAll(m.input) + if err != nil { + return nil, err + } + inS := string(in) + + switch m.arg { + case "erase": + switch inS { + case validServerAddress: + return nil, nil + default: + return []byte("program failed"), errProgramExited + } + case "get": + switch inS { + case validServerAddress: + return []byte(`{"Username": "foo", "Secret": "bar"}`), nil + case validServerAddress2: + return []byte(`{"Username": "", "Secret": "abcd1234"}`), nil + case missingCredsAddress: + return []byte(credentials.NewErrCredentialsNotFound().Error()), errProgramExited + case invalidServerAddress: + return []byte("program failed"), errProgramExited + } + case "store": + var c credentials.Credentials + err := json.NewDecoder(strings.NewReader(inS)).Decode(&c) + if err != nil { + return []byte("error storing credentials"), errProgramExited + } + switch c.ServerURL { + case validServerAddress: + return nil, nil + case validServerAddress2: + return nil, nil + default: + return []byte("error storing credentials"), errProgramExited + } + } + + return []byte(fmt.Sprintf("unknown argument %q with %q", m.arg, inS)), errProgramExited +} + +// Input sets the input to send to a remote credentials helper. +func (m *mockProgram) Input(in io.Reader) { + m.input = in +} + +func mockProgramFn(args ...string) Program { + return &mockProgram{ + arg: args[0], + } +} + +func ExampleStore() { + p := NewShellProgramFunc("docker-credential-secretservice") + + c := &credentials.Credentials{ + ServerURL: "https://example.com", + Username: "calavera", + Secret: "my super secret token", + } + + if err := Store(p, c); err != nil { + fmt.Println(err) + } +} + +func TestStore(t *testing.T) { + valid := []credentials.Credentials{ + {validServerAddress, "foo", "bar"}, + {validServerAddress2, "", "abcd1234"}, + } + + for _, v := range valid { + if err := Store(mockProgramFn, &v); err != nil { + t.Fatal(err) + } + } + + invalid := []credentials.Credentials{ + {invalidServerAddress, "foo", "bar"}, + } + + for _, v := range invalid { + if err := Store(mockProgramFn, &v); err == nil { + t.Fatalf("Expected error for server %s, got nil", v.ServerURL) + } + } +} + +func ExampleGet() { + p := NewShellProgramFunc("docker-credential-secretservice") + + creds, err := Get(p, "https://example.com") + if err != nil { + fmt.Println(err) + } + + fmt.Printf("Got credentials for user `%s` in `%s`\n", creds.Username, creds.ServerURL) +} + +func TestGet(t *testing.T) { + valid := []credentials.Credentials{ + {validServerAddress, "foo", "bar"}, + {validServerAddress2, "", "abcd1234"}, + } + + for _, v := range valid { + c, err := Get(mockProgramFn, v.ServerURL) + if err != nil { + t.Fatal(err) + } + + if c.Username != v.Username { + t.Fatalf("expected username `%s`, got %s", v.Username, c.Username) + } + if c.Secret != v.Secret { + t.Fatalf("expected secret `%s`, got %s", v.Secret, c.Secret) + } + } + + invalid := []struct { + serverURL string + err string + }{ + {missingCredsAddress, credentials.NewErrCredentialsNotFound().Error()}, + {invalidServerAddress, "error getting credentials - err: exited 1, out: `program failed`"}, + } + + for _, v := range invalid { + _, err := Get(mockProgramFn, v.serverURL) + if err == nil { + t.Fatalf("Expected error for server %s, got nil", v.serverURL) + } + if err.Error() != v.err { + t.Fatalf("Expected error `%s`, got `%v`", v.err, err) + } + } +} + +func ExampleErase() { + p := NewShellProgramFunc("docker-credential-secretservice") + + if err := Erase(p, "https://example.com"); err != nil { + fmt.Println(err) + } +} + +func TestErase(t *testing.T) { + if err := Erase(mockProgramFn, validServerAddress); err != nil { + t.Fatal(err) + } + + if err := Erase(mockProgramFn, invalidServerAddress); err == nil { + t.Fatalf("Expected error for server %s, got nil", invalidServerAddress) + } +} diff --git a/client/command.go b/client/command.go new file mode 100644 index 0000000..8983da6 --- /dev/null +++ b/client/command.go @@ -0,0 +1,37 @@ +package client + +import ( + "io" + "os/exec" +) + +// Program is an interface to execute external programs. +type Program interface { + Output() ([]byte, error) + Input(in io.Reader) +} + +// ProgramFunc is a type of function that initializes programs based on arguments. +type ProgramFunc func(args ...string) Program + +// NewShellProgramFunc creates programs that are executed in a Shell. +func NewShellProgramFunc(name string) ProgramFunc { + return func(args ...string) Program { + return &Shell{cmd: exec.Command(name, args...)} + } +} + +// Shell invokes shell commands to talk with a remote credentials helper. +type Shell struct { + cmd *exec.Cmd +} + +// Output returns responses from the remote credentials helper. +func (s *Shell) Output() ([]byte, error) { + return s.cmd.Output() +} + +// Input sets the input to send to a remote credentials helper. +func (s *Shell) Input(in io.Reader) { + s.cmd.Stdin = in +} diff --git a/credentials/credentials.go b/credentials/credentials.go index bd6b214..b14f495 100644 --- a/credentials/credentials.go +++ b/credentials/credentials.go @@ -10,6 +10,13 @@ import ( "strings" ) +// Credentials holds the information shared between docker and the credentials store. +type Credentials struct { + ServerURL string + Username string + Secret string +} + // Serve initializes the credentials helper and parses the action argument. // This function is designed to be called from a command line interface. // It uses os.Args[1] as the key for the action. diff --git a/credentials/error.go b/credentials/error.go new file mode 100644 index 0000000..d24bf16 --- /dev/null +++ b/credentials/error.go @@ -0,0 +1,37 @@ +package credentials + +// ErrCredentialsNotFound standarizes the not found error, so every helper returns +// the same message and docker can handle it properly. +const errCredentialsNotFoundMessage = "credentials not found in native keychain" + +// errCredentialsNotFound represents an error +// raised when credentials are not in the store. +type errCredentialsNotFound struct{} + +// Error returns the standard error message +// for when the credentials are not in the store. +func (errCredentialsNotFound) Error() string { + return errCredentialsNotFoundMessage +} + +// NewErrCredentialsNotFound creates a new error +// for when the credentials are not in the store. +func NewErrCredentialsNotFound() error { + return errCredentialsNotFound{} +} + +// IsErrCredentialsNotFound returns true if the error +// was caused by not having a set of credentials in a store. +func IsErrCredentialsNotFound(err error) bool { + _, ok := err.(errCredentialsNotFound) + return ok +} + +// IsErrCredentialsNotFoundMessage returns true if the error +// was caused by not having a set of credentials in a store. +// +// This function helps to check messages returned by an +// external program via its standard output. +func IsErrCredentialsNotFoundMessage(err string) bool { + return err == errCredentialsNotFoundMessage +} diff --git a/credentials/helper.go b/credentials/helper.go index 3bd46e8..8a69671 100644 --- a/credentials/helper.go +++ b/credentials/helper.go @@ -1,14 +1,5 @@ package credentials -import "errors" - -// Credentials holds the information shared between docker and the credentials store. -type Credentials struct { - ServerURL string - Username string - Secret string -} - // Helper is the interface a credentials store helper must implement. type Helper interface { // Add appends credentials to the store. @@ -19,7 +10,3 @@ type Helper interface { // It returns username and secret as strings. Get(serverURL string) (string, string, error) } - -// ErrCredentialsNotFound standarizes the not found error, so every helper returns -// the same message and docker can handle it properly. -var ErrCredentialsNotFound = errors.New("credentials not found in native keychain") diff --git a/osxkeychain/osxkeychain_darwin.go b/osxkeychain/osxkeychain_darwin.go index 5567541..4d1d6bf 100644 --- a/osxkeychain/osxkeychain_darwin.go +++ b/osxkeychain/osxkeychain_darwin.go @@ -85,7 +85,7 @@ func (h Osxkeychain) Get(serverURL string) (string, string, error) { goMsg := C.GoString(errMsg) if goMsg == errCredentialsNotFound { - return "", "", credentials.ErrCredentialsNotFound + return "", "", credentials.NewErrCredentialsNotFound() } return "", "", errors.New(goMsg) diff --git a/osxkeychain/osxkeychain_darwin_test.go b/osxkeychain/osxkeychain_darwin_test.go index f2d7f41..175da26 100644 --- a/osxkeychain/osxkeychain_darwin_test.go +++ b/osxkeychain/osxkeychain_darwin_test.go @@ -39,7 +39,7 @@ func TestOSXKeychainHelper(t *testing.T) { func TestMissingCredentials(t *testing.T) { helper := Osxkeychain{} _, _, err := helper.Get("https://adsfasdf.wrewerwer.com/asdfsdddd") - if err != credentials.ErrCredentialsNotFound { - t.Fatalf("exptected ErrCredentialsNotFound, got %v", err) + if !credentials.IsErrCredentialsNotFound(err) { + t.Fatalf("expected ErrCredentialsNotFound, got %v", err) } } diff --git a/secretservice/secretservice_linux.go b/secretservice/secretservice_linux.go index 637077a..dcd682e 100644 --- a/secretservice/secretservice_linux.go +++ b/secretservice/secretservice_linux.go @@ -74,7 +74,7 @@ func (h Secretservice) Get(serverURL string) (string, string, error) { user := C.GoString(username) pass := C.GoString(secret) if pass == "" { - return "", "", credentials.ErrCredentialsNotFound + return "", "", credentials.NewErrCredentialsNotFound() } return user, pass, nil } diff --git a/secretservice/secretservice_linux_test.go b/secretservice/secretservice_linux_test.go index ee273b1..e6a1664 100644 --- a/secretservice/secretservice_linux_test.go +++ b/secretservice/secretservice_linux_test.go @@ -43,7 +43,7 @@ func TestMissingCredentials(t *testing.T) { helper := Secretservice{} _, _, err := helper.Get("https://adsfasdf.wrewerwer.com/asdfsdddd") - if err != credentials.ErrCredentialsNotFound { - t.Fatalf("exptected ErrCredentialsNotFound, got %v", err) + if !credentials.IsErrCredentialsNotFound(err) { + t.Fatalf("expected ErrCredentialsNotFound, got %v", err) } } diff --git a/wincred/wincred_windows.go b/wincred/wincred_windows.go index d05821c..0587cf0 100644 --- a/wincred/wincred_windows.go +++ b/wincred/wincred_windows.go @@ -33,7 +33,7 @@ func (h Wincred) Delete(serverURL string) error { func (h Wincred) Get(serverURL string) (string, string, error) { g, _ := winc.GetGenericCredential(serverURL) if g == nil { - return "", "", credentials.ErrCredentialsNotFound + return "", "", credentials.NewErrCredentialsNotFound() } return g.UserName, string(g.CredentialBlob), nil } diff --git a/wincred/wincred_windows_test.go b/wincred/wincred_windows_test.go index a89298b..b2ff86f 100644 --- a/wincred/wincred_windows_test.go +++ b/wincred/wincred_windows_test.go @@ -39,7 +39,7 @@ func TestWinCredHelper(t *testing.T) { func TestMissingCredentials(t *testing.T) { helper := Wincred{} _, _, err := helper.Get("https://adsfasdf.wrewerwer.com/asdfsdddd") - if err != credentials.ErrCredentialsNotFound { - t.Fatalf("exptected ErrCredentialsNotFound, got %v", err) + if !credentials.IsErrCredentialsNotFound(err) { + t.Fatalf("expected ErrCredentialsNotFound, got %v", err) } }