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}