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

feat: Support creating store from a config file #47

Merged
merged 34 commits into from
Apr 21, 2023
Merged
Show file tree
Hide file tree
Changes from 26 commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
fe06818
draft dynamic store
Wwwsylvia Apr 11, 2023
8b441cc
add docs
Wwwsylvia Apr 12, 2023
db2481b
add tests
Wwwsylvia Apr 12, 2023
a9f71db
improve docs
Wwwsylvia Apr 13, 2023
1e51c1a
rebase
Wwwsylvia Apr 13, 2023
fdd8bf4
refactor config
Wwwsylvia Apr 14, 2023
edbb72b
refactor more
Wwwsylvia Apr 14, 2023
16b2c25
refactor dynamic store
Wwwsylvia Apr 14, 2023
3f4a9f2
rename fileds
Wwwsylvia Apr 17, 2023
0df1d76
marshal cred store
Wwwsylvia Apr 17, 2023
f119c09
refactor internal
Wwwsylvia Apr 17, 2023
be7367a
add tests
Wwwsylvia Apr 17, 2023
928c0e0
support default helper
Wwwsylvia Apr 18, 2023
6422339
config doc
Wwwsylvia Apr 19, 2023
895d28d
refactor dynamic store
Wwwsylvia Apr 19, 2023
7a7e7b3
fix
Wwwsylvia Apr 19, 2023
2e1c6b3
rename internal
Wwwsylvia Apr 19, 2023
ee0f73c
fix
Wwwsylvia Apr 19, 2023
263529c
fix license + reference
Wwwsylvia Apr 20, 2023
9e693d5
rename + remove unnecessary code
Wwwsylvia Apr 20, 2023
d44d906
reformat
Wwwsylvia Apr 20, 2023
129aef2
address comments
Wwwsylvia Apr 20, 2023
7851287
address comments
Wwwsylvia Apr 20, 2023
dae7c9a
rebase
Wwwsylvia Apr 20, 2023
ec7bfc9
moved to store.go
Wwwsylvia Apr 20, 2023
f47742d
minor improvement
Wwwsylvia Apr 20, 2023
c7b4d51
refactor dynamic store
Wwwsylvia Apr 21, 2023
28b49a2
update doc
Wwwsylvia Apr 21, 2023
38586e0
minor change
Wwwsylvia Apr 21, 2023
14da58b
improve tests
Wwwsylvia Apr 21, 2023
aaf7548
minor fix name
Wwwsylvia Apr 21, 2023
9a9b85b
check colons in username
Wwwsylvia Apr 21, 2023
daf6521
improve test
Wwwsylvia Apr 21, 2023
933f90d
address comments
Wwwsylvia Apr 21, 2023
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
207 changes: 15 additions & 192 deletions file_store.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,18 +16,10 @@ limitations under the License.
package credentials

import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"sync"

"github.com/oras-project/oras-credentials-go/internal/ioutil"
"github.com/oras-project/oras-credentials-go/internal/config"
"oras.land/oras-go/v2/registry/remote/auth"
)

Expand All @@ -38,120 +30,30 @@ type FileStore struct {
// If DisablePut is set to true, Put() will return ErrPlaintextPutDisabled.
DisablePut bool

// configPath is the path to the config file.
configPath string
// content is the content of the config file.
// Reference: https://github.com/docker/cli/blob/v24.0.0-beta.1/cli/config/configfile/file.go#L17-L45
content map[string]json.RawMessage
// authsCache is a cache of the auths field of the config field.
// Reference: https://github.com/docker/cli/blob/v24.0.0-beta.1/cli/config/configfile/file.go#L19
authsCache map[string]json.RawMessage
// rwLock is a read-write-lock for the file store.
rwLock sync.RWMutex
config *config.Config
}

// configFieldAuths is the "auths" field in the config file.
// Reference: https://github.com/docker/cli/blob/v24.0.0-beta.1/cli/config/configfile/file.go#L19
const configFieldAuths = "auths"

var (
// ErrInvalidConfigFormat is returned when the config format is invalid.
ErrInvalidConfigFormat = errors.New("invalid config format")
// ErrPlaintextPutDisabled is returned by Put() when DisablePut is set
// to true.
ErrPlaintextPutDisabled = errors.New("putting plaintext credentials is disabled")
)

// authConfig contains authorization information for connecting to a Registry.
// References:
// - https://github.com/docker/cli/blob/v24.0.0-beta.1/cli/config/configfile/file.go#L17-L45
// - https://github.com/docker/cli/blob/v24.0.0-beta.1/cli/config/types/authconfig.go#L3-L22
type authConfig struct {
// Auth is a base64-encoded string of "{username}:{password}".
Auth string `json:"auth,omitempty"`
// IdentityToken is used to authenticate the user and get.
// an access token for the registry.
IdentityToken string `json:"identitytoken,omitempty"`
// RegistryToken is a bearer token to be sent to a registry.
RegistryToken string `json:"registrytoken,omitempty"`

Username string `json:"username,omitempty"` // legacy field for compatibility
Password string `json:"password,omitempty"` // legacy field for compatibility
}

// newAuthConfig creates an authConfig based on cred.
func newAuthConfig(cred auth.Credential) authConfig {
return authConfig{
Auth: encodeAuth(cred.Username, cred.Password),
IdentityToken: cred.RefreshToken,
RegistryToken: cred.AccessToken,
}
}

// Credential returns an auth.Credential based on ac.
func (ac authConfig) Credential() (auth.Credential, error) {
cred := auth.Credential{
Username: ac.Username,
Password: ac.Password,
RefreshToken: ac.IdentityToken,
AccessToken: ac.RegistryToken,
}
if ac.Auth != "" {
var err error
// override username and password
cred.Username, cred.Password, err = decodeAuth(ac.Auth)
if err != nil {
return auth.EmptyCredential, fmt.Errorf("failed to decode auth field: %w: %v", ErrInvalidConfigFormat, err)
}
}
return cred, nil
}
// ErrPlaintextPutDisabled is returned by Put() when DisablePut is set
// to true.
var ErrPlaintextPutDisabled = errors.New("putting plaintext credentials is disabled")

// NewFileStore creates a new file credentials store.
func NewFileStore(configPath string) (*FileStore, error) {
fs := &FileStore{configPath: configPath}
configFile, err := os.Open(configPath)
cfg, err := config.LoadConfigFile(configPath)
if err != nil {
if os.IsNotExist(err) {
// init content map and auths cache if the content file does not exist
fs.content = make(map[string]json.RawMessage)
fs.authsCache = make(map[string]json.RawMessage)
return fs, nil
}
return nil, fmt.Errorf("failed to open config file at %s: %w", configPath, err)
return nil, err
}
defer configFile.Close()
return newFileStore(cfg), nil
}

// decode config content if the config file exists
if err := json.NewDecoder(configFile).Decode(&fs.content); err != nil {
return nil, fmt.Errorf("failed to decode config file at %s: %w: %v", configPath, ErrInvalidConfigFormat, err)
}
authsBytes, ok := fs.content[configFieldAuths]
if !ok {
// init auths cache
fs.authsCache = make(map[string]json.RawMessage)
return fs, nil
}
if err := json.Unmarshal(authsBytes, &fs.authsCache); err != nil {
return nil, fmt.Errorf("failed to unmarshal auths field: %w: %v", ErrInvalidConfigFormat, err)
}
return fs, nil
// newFileStore creates a file credentials store based on the given config instance.
func newFileStore(cfg *config.Config) *FileStore {
return &FileStore{config: cfg}
}

// Get retrieves credentials from the store for the given server address.
func (fs *FileStore) Get(_ context.Context, serverAddress string) (auth.Credential, error) {
fs.rwLock.RLock()
defer fs.rwLock.RUnlock()

authCfgBytes, ok := fs.authsCache[serverAddress]
if !ok {
return auth.EmptyCredential, nil
}
var authCfg authConfig
if err := json.Unmarshal(authCfgBytes, &authCfg); err != nil {
return auth.EmptyCredential, fmt.Errorf("failed to unmarshal auth field: %w: %v", ErrInvalidConfigFormat, err)
}
return authCfg.Credential()
return fs.config.GetCredential(serverAddress)
}

// Put saves credentials into the store for the given server address.
Expand All @@ -161,89 +63,10 @@ func (fs *FileStore) Put(_ context.Context, serverAddress string, cred auth.Cred
return ErrPlaintextPutDisabled
}

fs.rwLock.Lock()
defer fs.rwLock.Unlock()

authCfg := newAuthConfig(cred)
authCfgBytes, err := json.Marshal(authCfg)
if err != nil {
return fmt.Errorf("failed to marshal auth field: %w", err)
}
fs.authsCache[serverAddress] = authCfgBytes
return fs.saveFile()
return fs.config.PutCredential(serverAddress, cred)
}

// Delete removes credentials from the store for the given server address.
func (fs *FileStore) Delete(_ context.Context, serverAddress string) error {
fs.rwLock.Lock()
defer fs.rwLock.Unlock()

if _, ok := fs.authsCache[serverAddress]; !ok {
// no ops
return nil
}
delete(fs.authsCache, serverAddress)
return fs.saveFile()
}

// saveFile saves fs.content into fs.configPath.
func (fs *FileStore) saveFile() (returnErr error) {
// marshal content
authsBytes, err := json.Marshal(fs.authsCache)
if err != nil {
return fmt.Errorf("failed to marshal credentials: %w", err)
}
fs.content[configFieldAuths] = authsBytes
jsonBytes, err := json.MarshalIndent(fs.content, "", "\t")
if err != nil {
return fmt.Errorf("failed to marshal config: %w", err)
}

// write the content to a ingest file for atomicity
configDir := filepath.Dir(fs.configPath)
if err := os.MkdirAll(configDir, 0700); err != nil {
return fmt.Errorf("failed to make directory %s: %w", configDir, err)
}
ingest, err := ioutil.Ingest(configDir, bytes.NewReader(jsonBytes))
if err != nil {
return fmt.Errorf("failed to save config file: %w", err)
}
defer func() {
if returnErr != nil {
// clean up the ingest file in case of error
os.Remove(ingest)
}
}()

// overwrite the config file
if err := os.Rename(ingest, fs.configPath); err != nil {
return fmt.Errorf("failed to save config file: %w", err)
}
return nil
}

// encodeAuth base64-encodes username and password into base64(username:password).
func encodeAuth(username, password string) string {
if username == "" && password == "" {
return ""
}
return base64.StdEncoding.EncodeToString([]byte(username + ":" + password))
}

// decodeAuth decodes a base64 encoded string and returns username and password.
func decodeAuth(authStr string) (username string, password string, err error) {
if authStr == "" {
return "", "", nil
}

decoded, err := base64.StdEncoding.DecodeString(authStr)
if err != nil {
return "", "", err
}
decodedStr := string(decoded)
username, password, ok := strings.Cut(decodedStr, ":")
if !ok {
return "", "", fmt.Errorf("auth '%s' does not conform the base64(username:password) format", decodedStr)
}
return username, password, nil
return fs.config.DeleteCredential(serverAddress)
}
Loading