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}