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}