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