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

Commit

Permalink
feat: implement native store (#41)
Browse files Browse the repository at this point in the history
Resolves #29

---------

Signed-off-by: wangxiaoxuan273 <[email protected]>
  • Loading branch information
wangxiaoxuan273 authored Apr 4, 2023
1 parent 0ad40e9 commit aead42c
Show file tree
Hide file tree
Showing 4 changed files with 257 additions and 2 deletions.
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>"
)

// NativeStore 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)
}
}

0 comments on commit aead42c

Please sign in to comment.