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