v0.1.24 ec79818

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

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