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