v0.3.2 6af4e0f

store.go RawHistoryFolder

1// Package auth handles credential storage and the login methods
2// supported by zot: API keys and provider-specific subscription OAuth.
3//
4// All credentials live in $ZOT_HOME/auth.json (mode 0600).
5package auth
6
7import (
8	"encoding/json"
9	"errors"
10	"fmt"
11	"os"
12	"path/filepath"
13	"sync"
14	"time"
15)
16
17// Credentials is the on-disk schema.
18type Credentials struct {
19	Anthropic             ProviderCreds            `json:"anthropic,omitempty"`
20	OpenAI                ProviderCreds            `json:"openai,omitempty"`
21	Kimi                  ProviderCreds            `json:"kimi,omitempty"`
22	Google                ProviderCreds            `json:"google,omitempty"`
23	DeepSeek              ProviderCreds            `json:"deepseek,omitempty"`
24	GithubCopilot         ProviderCreds            `json:"github_copilot,omitempty"`
25	AdditionalAPIKeyCreds map[string]ProviderCreds `json:"additional_api_key_creds,omitempty"`
26}
27
28// ProviderCreds holds credentials for a single provider. Most providers
29// use either APIKey or OAuth; OpenAI may store both so the public API
30// route and ChatGPT/Codex subscription route can coexist.
31type ProviderCreds struct {
32	APIKey  string      `json:"api_key,omitempty"`
33	BaseURL string      `json:"base_url,omitempty"`
34	OAuth   *OAuthToken `json:"oauth,omitempty"`
35}
36
37// OAuthToken is an OAuth 2 token set with refresh support.
38type OAuthToken struct {
39	AccessToken  string    `json:"access_token"`
40	RefreshToken string    `json:"refresh_token,omitempty"`
41	TokenType    string    `json:"token_type,omitempty"`
42	Expiry       time.Time `json:"expiry,omitempty"`
43	Scope        string    `json:"scope,omitempty"`
44	// ClientID that issued this token (informational).
45	ClientID string `json:"client_id,omitempty"`
46	// IDToken is the OIDC id_token (provider-specific; currently only
47	// used by the OpenAI Codex flow to derive the ChatGPT account id).
48	IDToken string `json:"id_token,omitempty"`
49	// AccountID is the ChatGPT account id extracted from IDToken, used
50	// as the `chatgpt-account-id` header when calling chatgpt.com/backend-api.
51	AccountID string `json:"account_id,omitempty"`
52}
53
54// Expired reports whether the token has passed its expiry (with a 60s
55// safety margin). Zero expiry is treated as non-expiring.
56func (t *OAuthToken) Expired() bool {
57	if t == nil || t.Expiry.IsZero() {
58		return false
59	}
60	return time.Now().After(t.Expiry.Add(-60 * time.Second))
61}
62
63// Has reports whether at least one credential is present for provider.
64func (c *Credentials) Has(provider string) bool {
65	p := c.get(provider)
66	return p != nil && (p.APIKey != "" || p.BaseURL != "" || p.OAuth != nil)
67}
68
69// Method returns "apikey", "oauth", or "" for the given provider.
70func (c *Credentials) Method(provider string) string {
71	p := c.get(provider)
72	if p == nil {
73		return ""
74	}
75	if p.APIKey != "" || p.BaseURL != "" {
76		return "apikey"
77	}
78	if p.OAuth != nil {
79		return "oauth"
80	}
81	return ""
82}
83
84func (c *Credentials) get(provider string) *ProviderCreds {
85	switch provider {
86	case "anthropic":
87		return &c.Anthropic
88	case "openai":
89		return &c.OpenAI
90	case "kimi":
91		return &c.Kimi
92	case "google":
93		return &c.Google
94	case "deepseek":
95		return &c.DeepSeek
96	case "github-copilot":
97		return &c.GithubCopilot
98	}
99	if c.AdditionalAPIKeyCreds != nil {
100		if p, ok := c.AdditionalAPIKeyCreds[provider]; ok {
101			return &p
102		}
103	}
104	return nil
105}
106
107func (c *Credentials) setAdditional(provider string, p ProviderCreds) {
108	if c.AdditionalAPIKeyCreds == nil {
109		c.AdditionalAPIKeyCreds = map[string]ProviderCreds{}
110	}
111	if p.APIKey == "" && p.BaseURL == "" && p.OAuth == nil {
112		delete(c.AdditionalAPIKeyCreds, provider)
113		if len(c.AdditionalAPIKeyCreds) == 0 {
114			c.AdditionalAPIKeyCreds = nil
115		}
116		return
117	}
118	c.AdditionalAPIKeyCreds[provider] = p
119}
120
121// Store is a mutex-guarded read/write handle to the auth file.
122type Store struct {
123	path string
124	mu   sync.Mutex
125}
126
127// NewStore returns a Store bound to path.
128func NewStore(path string) *Store { return &Store{path: path} }
129
130// Load reads the current credentials. Returns a zero Credentials if the
131// file does not exist.
132func (s *Store) Load() (Credentials, error) {
133	s.mu.Lock()
134	defer s.mu.Unlock()
135	return s.loadLocked()
136}
137
138func (s *Store) loadLocked() (Credentials, error) {
139	var c Credentials
140	b, err := os.ReadFile(s.path)
141	if errors.Is(err, os.ErrNotExist) {
142		return c, nil
143	}
144	if err != nil {
145		return c, err
146	}
147	if err := json.Unmarshal(b, &c); err != nil {
148		return c, fmt.Errorf("parse %s: %w", s.path, err)
149	}
150	return c, nil
151}
152
153// SetAPIKey replaces the API key for provider and saves to disk.
154func (s *Store) SetAPIKey(provider, key string) error {
155	s.mu.Lock()
156	defer s.mu.Unlock()
157	c, err := s.loadLocked()
158	if err != nil {
159		return err
160	}
161	if cur, ok := c.AdditionalAPIKeyCreds[provider]; ok {
162		cur.APIKey = key
163		cur.OAuth = nil
164		c.setAdditional(provider, cur)
165		return s.saveLocked(c)
166	}
167	p := c.get(provider)
168	if p == nil {
169		c.setAdditional(provider, ProviderCreds{APIKey: key})
170		return s.saveLocked(c)
171	}
172	p.APIKey = key
173	if provider != "openai" {
174		p.OAuth = nil
175	}
176	return s.saveLocked(c)
177}
178
179// SetEndpointCredential stores a base URL and optional API key for a
180// self-hosted provider.
181func (s *Store) SetEndpointCredential(provider, baseURL, key string) error {
182	s.mu.Lock()
183	defer s.mu.Unlock()
184	c, err := s.loadLocked()
185	if err != nil {
186		return err
187	}
188	c.setAdditional(provider, ProviderCreds{APIKey: key, BaseURL: baseURL})
189	return s.saveLocked(c)
190}
191
192// EndpointCredential returns a stored base URL and optional API key.
193func (s *Store) EndpointCredential(provider string) (baseURL, key string, err error) {
194	c, err := s.Load()
195	if err != nil {
196		return "", "", err
197	}
198	p := c.get(provider)
199	if p == nil {
200		return "", "", nil
201	}
202	return p.BaseURL, p.APIKey, nil
203}
204
205// SetOAuth replaces the OAuth token for provider and saves to disk.
206func (s *Store) SetOAuth(provider string, tok OAuthToken) error {
207	s.mu.Lock()
208	defer s.mu.Unlock()
209	c, err := s.loadLocked()
210	if err != nil {
211		return err
212	}
213	if cur, ok := c.AdditionalAPIKeyCreds[provider]; ok {
214		cur.APIKey = ""
215		cur.OAuth = &tok
216		c.setAdditional(provider, cur)
217		return s.saveLocked(c)
218	}
219	p := c.get(provider)
220	if p == nil {
221		c.setAdditional(provider, ProviderCreds{OAuth: &tok})
222		return s.saveLocked(c)
223	}
224	if provider != "openai" {
225		p.APIKey = ""
226	}
227	p.OAuth = &tok
228	return s.saveLocked(c)
229}
230
231// Clear removes all credentials for provider.
232func (s *Store) Clear(provider string) error {
233	s.mu.Lock()
234	defer s.mu.Unlock()
235	c, err := s.loadLocked()
236	if err != nil {
237		return err
238	}
239	if _, ok := c.AdditionalAPIKeyCreds[provider]; ok {
240		c.setAdditional(provider, ProviderCreds{})
241		return s.saveLocked(c)
242	}
243	p := c.get(provider)
244	if p == nil {
245		c.setAdditional(provider, ProviderCreds{})
246		return s.saveLocked(c)
247	}
248	*p = ProviderCreds{}
249	return s.saveLocked(c)
250}
251
252// ClearAPIKey removes only the API key for provider, preserving any OAuth token.
253func (s *Store) ClearAPIKey(provider string) error {
254	s.mu.Lock()
255	defer s.mu.Unlock()
256	c, err := s.loadLocked()
257	if err != nil {
258		return err
259	}
260	if cur, ok := c.AdditionalAPIKeyCreds[provider]; ok {
261		cur.APIKey = ""
262		c.setAdditional(provider, cur)
263		return s.saveLocked(c)
264	}
265	p := c.get(provider)
266	if p == nil {
267		return nil
268	}
269	p.APIKey = ""
270	return s.saveLocked(c)
271}
272
273// ClearOAuth removes only the OAuth token for provider, preserving any API key.
274func (s *Store) ClearOAuth(provider string) error {
275	s.mu.Lock()
276	defer s.mu.Unlock()
277	c, err := s.loadLocked()
278	if err != nil {
279		return err
280	}
281	if cur, ok := c.AdditionalAPIKeyCreds[provider]; ok {
282		cur.OAuth = nil
283		c.setAdditional(provider, cur)
284		return s.saveLocked(c)
285	}
286	p := c.get(provider)
287	if p == nil {
288		return nil
289	}
290	p.OAuth = nil
291	return s.saveLocked(c)
292}
293
294func (s *Store) saveLocked(c Credentials) error {
295	if err := os.MkdirAll(filepath.Dir(s.path), 0o755); err != nil {
296		return err
297	}
298	b, err := json.MarshalIndent(c, "", "  ")
299	if err != nil {
300		return err
301	}
302	// Write atomically: write temp then rename.
303	tmp := s.path + ".tmp"
304	if err := os.WriteFile(tmp, b, 0o600); err != nil {
305		return err
306	}
307	return os.Rename(tmp, s.path)
308}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close