v0.3.60 bfc7b11

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

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