v0.1.88 14ffdb6

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}
25
26// ProviderCreds holds credentials for a single provider. Most providers
27// use either APIKey or OAuth; OpenAI may store both so the public API
28// route and ChatGPT/Codex subscription route can coexist.
29type ProviderCreds struct {
30	APIKey string      `json:"api_key,omitempty"`
31	OAuth  *OAuthToken `json:"oauth,omitempty"`
32}
33
34// OAuthToken is an OAuth 2 token set with refresh support.
35type OAuthToken struct {
36	AccessToken  string    `json:"access_token"`
37	RefreshToken string    `json:"refresh_token,omitempty"`
38	TokenType    string    `json:"token_type,omitempty"`
39	Expiry       time.Time `json:"expiry,omitempty"`
40	Scope        string    `json:"scope,omitempty"`
41	// ClientID that issued this token (informational).
42	ClientID string `json:"client_id,omitempty"`
43	// IDToken is the OIDC id_token (provider-specific; currently only
44	// used by the OpenAI Codex flow to derive the ChatGPT account id).
45	IDToken string `json:"id_token,omitempty"`
46	// AccountID is the ChatGPT account id extracted from IDToken, used
47	// as the `chatgpt-account-id` header when calling chatgpt.com/backend-api.
48	AccountID string `json:"account_id,omitempty"`
49}
50
51// Expired reports whether the token has passed its expiry (with a 60s
52// safety margin). Zero expiry is treated as non-expiring.
53func (t *OAuthToken) Expired() bool {
54	if t == nil || t.Expiry.IsZero() {
55		return false
56	}
57	return time.Now().After(t.Expiry.Add(-60 * time.Second))
58}
59
60// Has reports whether at least one credential is present for provider.
61func (c *Credentials) Has(provider string) bool {
62	p := c.get(provider)
63	return p != nil && (p.APIKey != "" || p.OAuth != nil)
64}
65
66// Method returns "apikey", "oauth", or "" for the given provider.
67func (c *Credentials) Method(provider string) string {
68	p := c.get(provider)
69	if p == nil {
70		return ""
71	}
72	if p.APIKey != "" {
73		return "apikey"
74	}
75	if p.OAuth != nil {
76		return "oauth"
77	}
78	return ""
79}
80
81func (c *Credentials) get(provider string) *ProviderCreds {
82	switch provider {
83	case "anthropic":
84		return &c.Anthropic
85	case "openai":
86		return &c.OpenAI
87	case "kimi":
88		return &c.Kimi
89	case "google":
90		return &c.Google
91	case "deepseek":
92		return &c.DeepSeek
93	}
94	return nil
95}
96
97// Store is a mutex-guarded read/write handle to the auth file.
98type Store struct {
99	path string
100	mu   sync.Mutex
101}
102
103// NewStore returns a Store bound to path.
104func NewStore(path string) *Store { return &Store{path: path} }
105
106// Load reads the current credentials. Returns a zero Credentials if the
107// file does not exist.
108func (s *Store) Load() (Credentials, error) {
109	s.mu.Lock()
110	defer s.mu.Unlock()
111	return s.loadLocked()
112}
113
114func (s *Store) loadLocked() (Credentials, error) {
115	var c Credentials
116	b, err := os.ReadFile(s.path)
117	if errors.Is(err, os.ErrNotExist) {
118		return c, nil
119	}
120	if err != nil {
121		return c, err
122	}
123	if err := json.Unmarshal(b, &c); err != nil {
124		return c, fmt.Errorf("parse %s: %w", s.path, err)
125	}
126	return c, nil
127}
128
129// SetAPIKey replaces the API key for provider and saves to disk.
130func (s *Store) SetAPIKey(provider, key string) error {
131	s.mu.Lock()
132	defer s.mu.Unlock()
133	c, err := s.loadLocked()
134	if err != nil {
135		return err
136	}
137	p := c.get(provider)
138	if p == nil {
139		return fmt.Errorf("unknown provider %q", provider)
140	}
141	p.APIKey = key
142	if provider != "openai" {
143		p.OAuth = nil
144	}
145	return s.saveLocked(c)
146}
147
148// SetOAuth replaces the OAuth token for provider and saves to disk.
149func (s *Store) SetOAuth(provider string, tok OAuthToken) error {
150	s.mu.Lock()
151	defer s.mu.Unlock()
152	c, err := s.loadLocked()
153	if err != nil {
154		return err
155	}
156	p := c.get(provider)
157	if p == nil {
158		return fmt.Errorf("unknown provider %q", provider)
159	}
160	if provider != "openai" {
161		p.APIKey = ""
162	}
163	p.OAuth = &tok
164	return s.saveLocked(c)
165}
166
167// Clear removes all credentials for provider.
168func (s *Store) Clear(provider string) error {
169	s.mu.Lock()
170	defer s.mu.Unlock()
171	c, err := s.loadLocked()
172	if err != nil {
173		return err
174	}
175	p := c.get(provider)
176	if p == nil {
177		return fmt.Errorf("unknown provider %q", provider)
178	}
179	*p = ProviderCreds{}
180	return s.saveLocked(c)
181}
182
183// ClearAPIKey removes only the API key for provider, preserving any OAuth token.
184func (s *Store) ClearAPIKey(provider 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	p := c.get(provider)
192	if p == nil {
193		return fmt.Errorf("unknown provider %q", provider)
194	}
195	p.APIKey = ""
196	return s.saveLocked(c)
197}
198
199// ClearOAuth removes only the OAuth token for provider, preserving any API key.
200func (s *Store) ClearOAuth(provider string) error {
201	s.mu.Lock()
202	defer s.mu.Unlock()
203	c, err := s.loadLocked()
204	if err != nil {
205		return err
206	}
207	p := c.get(provider)
208	if p == nil {
209		return fmt.Errorf("unknown provider %q", provider)
210	}
211	p.OAuth = nil
212	return s.saveLocked(c)
213}
214
215func (s *Store) saveLocked(c Credentials) error {
216	if err := os.MkdirAll(filepath.Dir(s.path), 0o755); err != nil {
217		return err
218	}
219	b, err := json.MarshalIndent(c, "", "  ")
220	if err != nil {
221		return err
222	}
223	// Write atomically: write temp then rename.
224	tmp := s.path + ".tmp"
225	if err := os.WriteFile(tmp, b, 0o600); err != nil {
226		return err
227	}
228	return os.Rename(tmp, s.path)
229}

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