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