cuelang.org/go@v0.10.1/internal/cueconfig/config.go (about)

     1  // Package cueconfig holds internal API relating to CUE configuration.
     2  package cueconfig
     3  
     4  import (
     5  	"encoding/json"
     6  	"errors"
     7  	"fmt"
     8  	"io/fs"
     9  	"os"
    10  	"path/filepath"
    11  	"time"
    12  
    13  	"cuelang.org/go/internal/golangorgx/tools/robustio"
    14  	"cuelang.org/go/internal/mod/modresolve"
    15  	"github.com/rogpeppe/go-internal/lockedfile"
    16  	"golang.org/x/oauth2"
    17  )
    18  
    19  // Logins holds the login information as stored in $CUE_CONFIG_DIR/logins.cue.
    20  type Logins struct {
    21  	// TODO: perhaps add a version string to simplify making changes in the future
    22  
    23  	// TODO: Sooner or later we will likely need more than one token per registry,
    24  	// such as when our central registry starts using scopes.
    25  
    26  	Registries map[string]RegistryLogin `json:"registries"`
    27  }
    28  
    29  // RegistryLogin holds the login information for one registry.
    30  type RegistryLogin struct {
    31  	// These fields mirror [oauth2.Token].
    32  	// We don't directly reference the type so we can be in control of our file format.
    33  	// Note that Expiry is a pointer, so omitempty can work as intended.
    34  
    35  	AccessToken string `json:"access_token"`
    36  
    37  	TokenType string `json:"token_type,omitempty"`
    38  
    39  	RefreshToken string `json:"refresh_token,omitempty"`
    40  
    41  	Expiry *time.Time `json:"expiry,omitempty"`
    42  }
    43  
    44  func LoginConfigPath(getenv func(string) string) (string, error) {
    45  	configDir, err := ConfigDir(getenv)
    46  	if err != nil {
    47  		return "", err
    48  	}
    49  	return filepath.Join(configDir, "logins.json"), nil
    50  }
    51  
    52  func ConfigDir(getenv func(string) string) (string, error) {
    53  	if dir := getenv("CUE_CONFIG_DIR"); dir != "" {
    54  		return dir, nil
    55  	}
    56  	dir, err := os.UserConfigDir()
    57  	if err != nil {
    58  		return "", fmt.Errorf("cannot determine system config directory: %v", err)
    59  	}
    60  	return filepath.Join(dir, "cue"), nil
    61  }
    62  
    63  func CacheDir(getenv func(string) string) (string, error) {
    64  	if dir := getenv("CUE_CACHE_DIR"); dir != "" {
    65  		return dir, nil
    66  	}
    67  	dir, err := os.UserCacheDir()
    68  	if err != nil {
    69  		return "", fmt.Errorf("cannot determine system cache directory: %v", err)
    70  	}
    71  	return filepath.Join(dir, "cue"), nil
    72  }
    73  
    74  func ReadLogins(path string) (*Logins, error) {
    75  	// Note that we read logins.json without holding a file lock,
    76  	// as the file lock is only held for writes. Prevent ephemeral errors on Windows.
    77  	body, err := robustio.ReadFile(path)
    78  	if err != nil {
    79  		return nil, err
    80  	}
    81  	logins := &Logins{
    82  		// Initialize the map so we can insert entries.
    83  		Registries: map[string]RegistryLogin{},
    84  	}
    85  	if err := json.Unmarshal(body, logins); err != nil {
    86  		return nil, err
    87  	}
    88  	// Sanity-check the read data.
    89  	for regName, regLogin := range logins.Registries {
    90  		if regLogin.AccessToken == "" {
    91  			return nil, fmt.Errorf("invalid %s: missing access_token for registry %s", path, regName)
    92  		}
    93  	}
    94  	return logins, nil
    95  }
    96  
    97  func WriteLogins(path string, logins *Logins) error {
    98  	if err := os.MkdirAll(filepath.Dir(path), 0o777); err != nil {
    99  		return err
   100  	}
   101  
   102  	unlock, err := lockedfile.MutexAt(path + ".lock").Lock()
   103  	if err != nil {
   104  		return err
   105  	}
   106  	defer unlock()
   107  
   108  	return writeLoginsUnlocked(path, logins)
   109  }
   110  
   111  func writeLoginsUnlocked(path string, logins *Logins) error {
   112  	// Indenting and a trailing newline are not necessary, but nicer to humans.
   113  	body, err := json.MarshalIndent(logins, "", "\t")
   114  	if err != nil {
   115  		return err
   116  	}
   117  	body = append(body, '\n')
   118  
   119  	// Write to a temp file and then try to atomically rename to avoid races
   120  	// with parallel reading since we don't lock at FS level in ReadLogins.
   121  	if err := os.WriteFile(path+".tmp", body, 0o600); err != nil {
   122  		return err
   123  	}
   124  	// TODO: on non-POSIX platforms os.Rename might not be atomic. Might need to
   125  	// find another solution. Note that Windows NTFS is also atomic.
   126  	if err := robustio.Rename(path+".tmp", path); err != nil {
   127  		return err
   128  	}
   129  
   130  	return nil
   131  }
   132  
   133  // UpdateRegistryLogin atomically updates a single registry token in the logins.json file.
   134  func UpdateRegistryLogin(path string, key string, new *oauth2.Token) (*Logins, error) {
   135  	if err := os.MkdirAll(filepath.Dir(path), 0o777); err != nil {
   136  		return nil, err
   137  	}
   138  
   139  	unlock, err := lockedfile.MutexAt(path + ".lock").Lock()
   140  	if err != nil {
   141  		return nil, err
   142  	}
   143  	defer unlock()
   144  
   145  	logins, err := ReadLogins(path)
   146  	if errors.Is(err, fs.ErrNotExist) {
   147  		// No config file yet; create an empty one.
   148  		logins = &Logins{Registries: make(map[string]RegistryLogin)}
   149  	} else if err != nil {
   150  		return nil, err
   151  	}
   152  
   153  	logins.Registries[key] = LoginFromToken(new)
   154  
   155  	err = writeLoginsUnlocked(path, logins)
   156  	if err != nil {
   157  		return nil, err
   158  	}
   159  
   160  	return logins, nil
   161  }
   162  
   163  // RegistryOAuthConfig returns the oauth2 configuration
   164  // suitable for talking to the central registry.
   165  func RegistryOAuthConfig(host modresolve.Host) oauth2.Config {
   166  	// For now, we use the OAuth endpoints as implemented by registry.cue.works,
   167  	// but other OCI registries may support the OAuth device flow with different ones.
   168  	//
   169  	// TODO: Query /.well-known/oauth-authorization-server to obtain
   170  	// token_endpoint and device_authorization_endpoint per the Oauth RFCs:
   171  	// * https://datatracker.ietf.org/doc/html/rfc8414#section-3
   172  	// * https://datatracker.ietf.org/doc/html/rfc8628#section-4
   173  	scheme := "https://"
   174  	if host.Insecure {
   175  		scheme = "http://"
   176  	}
   177  	return oauth2.Config{
   178  		Endpoint: oauth2.Endpoint{
   179  			DeviceAuthURL: scheme + host.Name + "/login/device/code",
   180  			TokenURL:      scheme + host.Name + "/login/oauth/token",
   181  		},
   182  	}
   183  }
   184  
   185  // TODO: Encrypt the JSON file if the system has a secret store available,
   186  // such as libsecret on Linux. Such secret stores tend to have low size limits,
   187  // so rather than store the entire JSON blob there, store an encryption key.
   188  // There are a number of Go packages which integrate with multiple OS keychains.
   189  //
   190  // The encrypted form of logins.json can be logins.json.enc, for example.
   191  // If a user has an existing logins.json file and encryption is available,
   192  // we should replace the file with logins.json.enc transparently.
   193  
   194  // TODO: When running "cue login", try to prevent overwriting concurrent changes
   195  // when writing to the file on disk. For example, grab a lock, or check if the size
   196  // changed between reading and writing the file.
   197  
   198  func TokenFromLogin(login RegistryLogin) *oauth2.Token {
   199  	tok := &oauth2.Token{
   200  		AccessToken:  login.AccessToken,
   201  		TokenType:    login.TokenType,
   202  		RefreshToken: login.RefreshToken,
   203  	}
   204  	if login.Expiry != nil {
   205  		tok.Expiry = *login.Expiry
   206  	}
   207  	return tok
   208  }
   209  
   210  func LoginFromToken(tok *oauth2.Token) RegistryLogin {
   211  	login := RegistryLogin{
   212  		AccessToken:  tok.AccessToken,
   213  		TokenType:    tok.TokenType,
   214  		RefreshToken: tok.RefreshToken,
   215  	}
   216  	if !tok.Expiry.IsZero() {
   217  		login.Expiry = &tok.Expiry
   218  	}
   219  	return login
   220  }