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 }