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}