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 }