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

feat: implement native store #41

Merged
merged 4 commits into from
Apr 4, 2023
Merged
Show file tree
Hide file tree
Changes from 3 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
10 changes: 8 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@ module github.com/oras-project/oras-credentials-go

go 1.19

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

require golang.org/x/sync v0.1.0 // indirect
require (
golang.org/x/sync v0.1.0 // indirect
golang.org/x/sys v0.6.0 // indirect
)
4 changes: 4 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,4 +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=
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=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
oras.land/oras-go/v2 v2.0.2 h1:3aSQdJ7EUC0ft2e9PjJB9Jzastz5ojPA4LzZ3Q4YbUc=
oras.land/oras-go/v2 v2.0.2/go.mod h1:PWnWc/Kyyg7wUTUsDHshrsJkzuxXzreeMd6NrfdnFSo=
83 changes: 83 additions & 0 deletions native_store.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
/*
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"

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

const (
remoteCredentialsPrefix = "docker-credential-"
emptyUsername = "<token>"
)

// nativeAuthStore implements a credentials store using native keychain to keep
// credentials secure.
type NativeStore struct {
programFunc client.ProgramFunc
}

// NewNativeStore creates a new native store that uses a remote helper program to
// manage credentials.
func NewNativeStore(helperSuffix string) Store {
return &NativeStore{
programFunc: client.NewShellProgramFunc(remoteCredentialsPrefix + helperSuffix),
}
}

// 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)
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
if dockerCred.Username == emptyUsername {
cred.RefreshToken = dockerCred.Secret
} else {
cred.Username = dockerCred.Username
cred.Password = dockerCred.Secret
}
return cred, nil
}

// Put saves credentials into the store
func (ns *NativeStore) Put(_ context.Context, serverAddress string, cred auth.Credential) error {
dockerCred := &credentials.Credentials{
ServerURL: serverAddress,
Username: cred.Username,
Secret: cred.Password,
}
if cred.RefreshToken != "" {
dockerCred.Username = emptyUsername
dockerCred.Secret = cred.RefreshToken
}
return client.Store(ns.programFunc, dockerCred)
}

// 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)
}
162 changes: 162 additions & 0 deletions native_store_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
/*
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/json"
"fmt"
"io"
"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"
testUsername = "test_username"
testPassword = "test_password"
testRefreshToken = "test_token"
)

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

// testCommand implements the Program interface for testing purpose.
// It simulates interactions between the docker client and a remote
// credentials helper.
type testCommand 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 *testCommand) Output() ([]byte, error) {
in, err := io.ReadAll(m.input)
if err != nil {
return nil, err
}
inS := string(in)
switch m.arg {
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
default:
return []byte("program failed"), errCommandExited
}
case "store":
var c credentials.Credentials
err := json.NewDecoder(strings.NewReader(inS)).Decode(&c)
if err != nil {
return []byte("program failed"), errCommandExited
}
switch c.ServerURL {
case basicAuthHost, bearerAuthHost:
return nil, nil
default:
return []byte("program failed"), errCommandExited
}
case "erase":
switch inS {
case basicAuthHost, bearerAuthHost:
return nil, nil
default:
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],
}
}

func TestNativeStore_interface(t *testing.T) {
var ns interface{} = &NativeStore{}
if _, ok := ns.(Store); !ok {
t.Error("&NativeStore{} does not conform Store")
}
}

func TestNativeStore_basicAuth(t *testing.T) {
ns := &NativeStore{
programFunc: testCommandFn,
}
// Put
err := ns.Put(context.Background(), basicAuthHost, auth.Credential{Username: testUsername, Password: testPassword})
if err != nil {
t.Fatalf("basic auth test ns.Put fails: %v", err)
}
// Get
cred, err := ns.Get(context.Background(), basicAuthHost)
if err != nil {
t.Fatalf("basic auth test ns.Get fails: %v", err)
}
if cred.Username != testUsername {
t.Fatal("incorrect username")
}
if cred.Password != testPassword {
t.Fatal("incorrect password")
}
// Delete
err = ns.Delete(context.Background(), basicAuthHost)
if err != nil {
t.Fatalf("basic auth test ns.Delete fails: %v", err)
}
}

func TestNativeStore_refreshToken(t *testing.T) {
ns := &NativeStore{
programFunc: testCommandFn,
}
// Put
err := ns.Put(context.Background(), bearerAuthHost, auth.Credential{RefreshToken: testRefreshToken})
if err != nil {
t.Fatalf("refresh token test ns.Put fails: %v", err)
}
// Get
cred, err := ns.Get(context.Background(), bearerAuthHost)
if err != nil {
t.Fatalf("refresh token test ns.Get fails: %v", err)
}
if cred.Username != "" {
t.Fatalf("expect username to be empty, got %s", cred.Username)
}
if cred.RefreshToken != testRefreshToken {
t.Fatal("incorrect refresh token")
}
// Delete
err = ns.Delete(context.Background(), basicAuthHost)
if err != nil {
t.Fatalf("refresh token test ns.Delete fails: %v", err)
}
}