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