v0.2.84 efd311e

store.go RawHistoryFolder

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

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 477

↑↓ moveenter openesc close