File: config.go

package info (click to toggle)
golang-github-cue-lang-cue 0.12.0.-1
  • links: PTS, VCS
  • area: main
  • in suites: forky, sid, trixie
  • size: 19,072 kB
  • sloc: sh: 57; makefile: 17
file content (224 lines) | stat: -rw-r--r-- 6,886 bytes parent folder | download
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
// Package cueconfig holds internal API relating to CUE configuration.
package cueconfig

import (
	"encoding/json"
	"errors"
	"fmt"
	"io/fs"
	"os"
	"path/filepath"
	"time"

	"cuelang.org/go/internal/golangorgx/tools/robustio"
	"cuelang.org/go/internal/mod/modresolve"
	"github.com/rogpeppe/go-internal/lockedfile"
	"golang.org/x/oauth2"
)

// Logins holds the login information as stored in $CUE_CONFIG_DIR/logins.cue.
type Logins struct {
	// TODO: perhaps add a version string to simplify making changes in the future

	// TODO: Sooner or later we will likely need more than one token per registry,
	// such as when our central registry starts using scopes.

	Registries map[string]RegistryLogin `json:"registries"`
}

// RegistryLogin holds the login information for one registry.
type RegistryLogin struct {
	// These fields mirror [oauth2.Token].
	// We don't directly reference the type so we can be in control of our file format.
	// Note that Expiry is a pointer, so omitempty can work as intended.
	// TODO(mvdan): drop the pointer once we can use json's omitzero: https://go.dev/issue/45669
	// Note that we store Expiry at rest as an absolute timestamp in UTC,
	// rather than the ExpiresIn field following the RFC's wire format,
	// a duration in seconds relative to the current time which is not useful at rest.

	AccessToken string `json:"access_token"`

	TokenType string `json:"token_type,omitempty"`

	RefreshToken string `json:"refresh_token,omitempty"`

	Expiry *time.Time `json:"expiry,omitempty"`
}

func LoginConfigPath(getenv func(string) string) (string, error) {
	configDir, err := ConfigDir(getenv)
	if err != nil {
		return "", err
	}
	return filepath.Join(configDir, "logins.json"), nil
}

func ConfigDir(getenv func(string) string) (string, error) {
	if dir := getenv("CUE_CONFIG_DIR"); dir != "" {
		return dir, nil
	}
	dir, err := os.UserConfigDir()
	if err != nil {
		return "", fmt.Errorf("cannot determine system config directory: %v", err)
	}
	return filepath.Join(dir, "cue"), nil
}

func CacheDir(getenv func(string) string) (string, error) {
	if dir := getenv("CUE_CACHE_DIR"); dir != "" {
		return dir, nil
	}
	dir, err := os.UserCacheDir()
	if err != nil {
		return "", fmt.Errorf("cannot determine system cache directory: %v", err)
	}
	return filepath.Join(dir, "cue"), nil
}

func ReadLogins(path string) (*Logins, error) {
	// Note that we read logins.json without holding a file lock,
	// as the file lock is only held for writes. Prevent ephemeral errors on Windows.
	body, err := robustio.ReadFile(path)
	if err != nil {
		return nil, err
	}
	logins := &Logins{
		// Initialize the map so we can insert entries.
		Registries: map[string]RegistryLogin{},
	}
	if err := json.Unmarshal(body, logins); err != nil {
		return nil, err
	}
	// Sanity-check the read data.
	for regName, regLogin := range logins.Registries {
		if regLogin.AccessToken == "" {
			return nil, fmt.Errorf("invalid %s: missing access_token for registry %s", path, regName)
		}
	}
	return logins, nil
}

func WriteLogins(path string, logins *Logins) error {
	if err := os.MkdirAll(filepath.Dir(path), 0o777); err != nil {
		return err
	}

	unlock, err := lockedfile.MutexAt(path + ".lock").Lock()
	if err != nil {
		return err
	}
	defer unlock()

	return writeLoginsUnlocked(path, logins)
}

func writeLoginsUnlocked(path string, logins *Logins) error {
	// Indenting and a trailing newline are not necessary, but nicer to humans.
	body, err := json.MarshalIndent(logins, "", "\t")
	if err != nil {
		return err
	}
	body = append(body, '\n')

	// Write to a temp file and then try to atomically rename to avoid races
	// with parallel reading since we don't lock at FS level in ReadLogins.
	if err := os.WriteFile(path+".tmp", body, 0o600); err != nil {
		return err
	}
	// TODO: on non-POSIX platforms os.Rename might not be atomic. Might need to
	// find another solution. Note that Windows NTFS is also atomic.
	if err := robustio.Rename(path+".tmp", path); err != nil {
		return err
	}

	return nil
}

// UpdateRegistryLogin atomically updates a single registry token in the logins.json file.
func UpdateRegistryLogin(path string, key string, new *oauth2.Token) (*Logins, error) {
	if err := os.MkdirAll(filepath.Dir(path), 0o777); err != nil {
		return nil, err
	}

	unlock, err := lockedfile.MutexAt(path + ".lock").Lock()
	if err != nil {
		return nil, err
	}
	defer unlock()

	logins, err := ReadLogins(path)
	if errors.Is(err, fs.ErrNotExist) {
		// No config file yet; create an empty one.
		logins = &Logins{Registries: make(map[string]RegistryLogin)}
	} else if err != nil {
		return nil, err
	}

	logins.Registries[key] = LoginFromToken(new)

	err = writeLoginsUnlocked(path, logins)
	if err != nil {
		return nil, err
	}

	return logins, nil
}

// RegistryOAuthConfig returns the oauth2 configuration
// suitable for talking to the central registry.
func RegistryOAuthConfig(host modresolve.Host) oauth2.Config {
	// For now, we use the OAuth endpoints as implemented by registry.cue.works,
	// but other OCI registries may support the OAuth device flow with different ones.
	//
	// TODO: Query /.well-known/oauth-authorization-server to obtain
	// token_endpoint and device_authorization_endpoint per the Oauth RFCs:
	// * https://datatracker.ietf.org/doc/html/rfc8414#section-3
	// * https://datatracker.ietf.org/doc/html/rfc8628#section-4
	scheme := "https://"
	if host.Insecure {
		scheme = "http://"
	}
	return oauth2.Config{
		Endpoint: oauth2.Endpoint{
			DeviceAuthURL: scheme + host.Name + "/login/device/code",
			TokenURL:      scheme + host.Name + "/login/oauth/token",
		},
	}
}

// TODO: Encrypt the JSON file if the system has a secret store available,
// such as libsecret on Linux. Such secret stores tend to have low size limits,
// so rather than store the entire JSON blob there, store an encryption key.
// There are a number of Go packages which integrate with multiple OS keychains.
//
// The encrypted form of logins.json can be logins.json.enc, for example.
// If a user has an existing logins.json file and encryption is available,
// we should replace the file with logins.json.enc transparently.

// TODO: When running "cue login", try to prevent overwriting concurrent changes
// when writing to the file on disk. For example, grab a lock, or check if the size
// changed between reading and writing the file.

func TokenFromLogin(login RegistryLogin) *oauth2.Token {
	tok := &oauth2.Token{
		AccessToken:  login.AccessToken,
		TokenType:    login.TokenType,
		RefreshToken: login.RefreshToken,
	}
	if login.Expiry != nil {
		tok.Expiry = *login.Expiry
	}
	return tok
}

func LoginFromToken(tok *oauth2.Token) RegistryLogin {
	login := RegistryLogin{
		AccessToken:  tok.AccessToken,
		TokenType:    tok.TokenType,
		RefreshToken: tok.RefreshToken,
	}
	if !tok.Expiry.IsZero() {
		login.Expiry = &tok.Expiry
	}
	return login
}