1package auth
2
3import (
4 "context"
5 "fmt"
6 "net/url"
7 "os"
8 "os/exec"
9 "runtime"
10 "strings"
11 "sync"
12 "time"
13)
14
15// Event is delivered on Manager.Events().
16type Event struct {
17 Kind string // "started" | "browser_open" | "success" | "error" | "canceled"
18 Provider string
19 Method string
20 URL string // login URL (for "started"/"browser_open")
21 Message string // on error
22}
23
24// Manager drives a login flow end-to-end. It owns a local web server
25// (for api-key form) plus provider-specific OAuth callback servers.
26type Manager struct {
27 store *Store
28 keyServer *Server // random-port web form server (api-key flow)
29 oauthServer *CallbackServer // fixed-port callback server (oauth flow, only one at a time)
30 mu sync.Mutex
31 events chan Event
32 openBrowser bool
33
34 oauthCtx context.Context
35 oauthCancel context.CancelFunc
36
37 manualOp *OAuthProvider
38 manualPKCE PKCE
39 manualState string
40}
41
42// NewManager returns a Manager bound to store.
43func NewManager(store *Store) *Manager {
44 return &Manager{
45 store: store,
46 events: make(chan Event, 16),
47 openBrowser: true,
48 }
49}
50
51// Store returns the underlying credential store.
52func (m *Manager) Store() *Store { return m.store }
53
54// Events returns the read-only event channel.
55func (m *Manager) Events() <-chan Event { return m.events }
56
57// Close shuts down any running servers and cancels pending flows.
58func (m *Manager) Close() {
59 m.mu.Lock()
60 defer m.mu.Unlock()
61 if m.keyServer != nil {
62 ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
63 _ = m.keyServer.Shutdown(ctx)
64 cancel()
65 m.keyServer = nil
66 }
67 if m.oauthServer != nil {
68 m.oauthServer.Shutdown()
69 m.oauthServer = nil
70 }
71 if m.oauthCancel != nil {
72 m.oauthCancel()
73 m.oauthCancel = nil
74 }
75}
76
77// ---- API key flow ----
78
79// StartAPIKey launches the API-key login flow.
80func (m *Manager) StartAPIKey(provider string) (string, error) {
81 if provider != "anthropic" && provider != "openai" {
82 return "", fmt.Errorf("provider must be anthropic or openai")
83 }
84 if err := m.ensureKeyServer(); err != nil {
85 return "", err
86 }
87 u := m.keyServer.URL() + "/apikey?provider=" + provider
88 go m.maybeOpen(u)
89 m.emit(Event{Kind: "started", Provider: provider, Method: "apikey", URL: u})
90 return u, nil
91}
92
93func (m *Manager) ensureKeyServer() error {
94 m.mu.Lock()
95 defer m.mu.Unlock()
96 if m.keyServer != nil {
97 return nil
98 }
99 s, err := NewServer()
100 if err != nil {
101 return err
102 }
103 m.keyServer = s
104 go m.consumeKeyServerResults()
105 return nil
106}
107
108func (m *Manager) consumeKeyServerResults() {
109 for res := range m.keyServer.Result() {
110 if res.Err != nil {
111 m.emit(Event{Kind: "error", Provider: res.Provider, Method: res.Method, Message: res.Err.Error()})
112 continue
113 }
114 if err := m.store.SetAPIKey(res.Provider, res.APIKey); err != nil {
115 m.emit(Event{Kind: "error", Provider: res.Provider, Method: "apikey", Message: err.Error()})
116 continue
117 }
118 m.emit(Event{Kind: "success", Provider: res.Provider, Method: "apikey"})
119 }
120}
121
122// ---- OAuth flow ----
123
124// StartOAuth launches the subscription OAuth flow for provider.
125// Only one oauth flow may be in progress at a time (because the
126// callback port is fixed per provider and re-used by the official CLIs).
127func (m *Manager) StartOAuth(provider string) (string, error) {
128 var op OAuthProvider
129 switch provider {
130 case "anthropic":
131 op = AnthropicOAuth
132 case "openai":
133 op = OpenAIOAuth
134 default:
135 return "", fmt.Errorf("provider must be anthropic or openai")
136 }
137
138 m.mu.Lock()
139 if m.oauthServer != nil {
140 m.oauthServer.Shutdown()
141 m.oauthServer = nil
142 }
143 if m.oauthCancel != nil {
144 m.oauthCancel()
145 }
146 m.mu.Unlock()
147
148 pkce, err := NewPKCE()
149 if err != nil {
150 return "", err
151 }
152 authURL, state, err := op.AuthorizeURL(pkce)
153 if err != nil {
154 return "", err
155 }
156
157 cs, err := NewCallbackServer(op, state)
158 if err != nil {
159 return "", err
160 }
161
162 ctx, cancel := context.WithCancel(context.Background())
163 m.mu.Lock()
164 m.oauthServer = cs
165 m.oauthCtx = ctx
166 m.oauthCancel = cancel
167 m.mu.Unlock()
168
169 go m.awaitOAuth(ctx, op, cs, pkce, state)
170 go m.maybeOpen(authURL)
171 m.emit(Event{Kind: "started", Provider: provider, Method: "oauth", URL: authURL})
172 return authURL, nil
173}
174
175func (m *Manager) awaitOAuth(ctx context.Context, op OAuthProvider, cs *CallbackServer, pkce PKCE, state string) {
176 defer cs.Shutdown()
177
178 waitCtx, waitCancel := context.WithTimeout(ctx, 10*time.Minute)
179 defer waitCancel()
180 res, err := cs.Result(waitCtx)
181 if err != nil {
182 if ctx.Err() == nil {
183 m.emit(Event{Kind: "error", Provider: op.Name, Method: "oauth", Message: "timeout waiting for callback"})
184 }
185 return
186 }
187 if res.Err != nil {
188 m.emit(Event{Kind: "error", Provider: op.Name, Method: "oauth", Message: res.Err.Error()})
189 return
190 }
191
192 exCtx, exCancel := context.WithTimeout(context.Background(), 30*time.Second)
193 defer exCancel()
194 tok, err := op.Exchange(exCtx, res.Code, res.State, pkce)
195 if err != nil {
196 m.emit(Event{Kind: "error", Provider: op.Name, Method: "oauth", Message: err.Error()})
197 return
198 }
199 if err := m.store.SetOAuth(op.Name, *tok); err != nil {
200 m.emit(Event{Kind: "error", Provider: op.Name, Method: "oauth", Message: err.Error()})
201 return
202 }
203 m.emit(Event{Kind: "success", Provider: op.Name, Method: "oauth"})
204}
205
206// StartManualOAuth begins an OAuth flow but does NOT start a local
207// callback server or open a browser. The returned URL is shown to the
208// user so they can complete the authorization on another device; the
209// resulting code is pasted back via CompleteManualOAuth.
210func (m *Manager) StartManualOAuth(provider string) (string, error) {
211 var op OAuthProvider
212 switch provider {
213 case "anthropic":
214 op = AnthropicManualOAuth
215 case "openai":
216 op = OpenAIOAuth
217 default:
218 return "", fmt.Errorf("provider must be anthropic or openai")
219 }
220
221 pkce, err := NewPKCE()
222 if err != nil {
223 return "", err
224 }
225 authURL, state, err := op.AuthorizeURL(pkce)
226 if err != nil {
227 return "", err
228 }
229
230 m.mu.Lock()
231 m.manualOp = &op
232 m.manualPKCE = pkce
233 m.manualState = state
234 m.mu.Unlock()
235
236 m.emit(Event{Kind: "started", Provider: provider, Method: "oauth", URL: authURL})
237 return authURL, nil
238}
239
240// CompleteManualOAuth exchanges the user-pasted authorization code for
241// a token and stores it. Accepts either a raw code or a "code#state"
242// token shown by providers like Anthropic when code=true is set.
243func (m *Manager) CompleteManualOAuth(ctx context.Context, input string) error {
244 m.mu.Lock()
245 op := m.manualOp
246 pkce := m.manualPKCE
247 state := m.manualState
248 m.mu.Unlock()
249 if op == nil {
250 return fmt.Errorf("no manual oauth flow in progress")
251 }
252 code, pastedState := parseManualCodeInput(strings.TrimSpace(input))
253 if pastedState != "" {
254 state = pastedState
255 }
256 if code == "" {
257 return fmt.Errorf("empty code")
258 }
259 exCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
260 defer cancel()
261 tok, err := op.Exchange(exCtx, code, state, pkce)
262 if err != nil {
263 m.emit(Event{Kind: "error", Provider: op.Name, Method: "oauth", Message: err.Error()})
264 return err
265 }
266 if err := m.store.SetOAuth(op.Name, *tok); err != nil {
267 m.emit(Event{Kind: "error", Provider: op.Name, Method: "oauth", Message: err.Error()})
268 return err
269 }
270 m.mu.Lock()
271 m.manualOp = nil
272 m.manualPKCE = PKCE{}
273 m.manualState = ""
274 m.mu.Unlock()
275 m.emit(Event{Kind: "success", Provider: op.Name, Method: "oauth"})
276 return nil
277}
278
279// parseManualCodeInput accepts any of:
280// - a bare authorization code
281// - a "code#state" pair
282// - a full redirect URL like http(s)://host:port/callback?code=X&state=Y
283//
284// and returns the extracted code and (if any) state.
285func parseManualCodeInput(s string) (code, state string) {
286 if strings.HasPrefix(s, "http://") || strings.HasPrefix(s, "https://") {
287 if u, err := url.Parse(s); err == nil {
288 q := u.Query()
289 return q.Get("code"), q.Get("state")
290 }
291 }
292 if idx := strings.IndexByte(s, '#'); idx >= 0 {
293 return s[:idx], s[idx+1:]
294 }
295 return s, ""
296}
297
298// HasBrowser reports whether the current environment probably has a
299// working interactive browser reachable from localhost. Used by the
300// login flow to auto-switch to paste-code mode on headless boxes
301// (containers, SSH without display forwarding, etc.) instead of
302// trying to bind a callback port the user can never reach.
303func HasBrowser() bool {
304 if os.Getenv("ZOT_NO_BROWSER") != "" {
305 return false
306 }
307 if os.Getenv("ZOT_FORCE_BROWSER") != "" {
308 return true
309 }
310 if _, err := os.Stat("/.dockerenv"); err == nil {
311 return false
312 }
313 if b, err := os.ReadFile("/proc/1/cgroup"); err == nil {
314 txt := string(b)
315 if strings.Contains(txt, "docker") || strings.Contains(txt, "kubepods") || strings.Contains(txt, "containerd") {
316 return false
317 }
318 }
319 switch runtime.GOOS {
320 case "darwin", "windows":
321 return true
322 default:
323 if os.Getenv("DISPLAY") == "" && os.Getenv("WAYLAND_DISPLAY") == "" {
324 return false
325 }
326 return true
327 }
328}
329
330// CancelOAuth aborts any in-flight OAuth flow.
331func (m *Manager) CancelOAuth() {
332 m.mu.Lock()
333 defer m.mu.Unlock()
334 if m.oauthCancel != nil {
335 m.oauthCancel()
336 m.oauthCancel = nil
337 }
338 if m.oauthServer != nil {
339 m.oauthServer.Shutdown()
340 m.oauthServer = nil
341 }
342}
343
344// ---- shared ----
345
346func (m *Manager) emit(e Event) {
347 select {
348 case m.events <- e:
349 default:
350 }
351}
352
353// maybeOpen tries to open u in the system browser.
354func (m *Manager) maybeOpen(u string) {
355 if !m.openBrowser {
356 return
357 }
358 var cmd *exec.Cmd
359 switch runtime.GOOS {
360 case "darwin":
361 cmd = exec.Command("open", u)
362 case "windows":
363 cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", u)
364 default:
365 cmd = exec.Command("xdg-open", u)
366 }
367 _ = cmd.Start()
368 m.emit(Event{Kind: "browser_open", URL: u})
369}
370
371// RefreshIfNeeded returns the currently-usable credential for provider,
372// refreshing an expired OAuth access token if necessary.
373func (m *Manager) RefreshIfNeeded(ctx context.Context, provider string) (string, string, error) {
374 creds, err := m.store.Load()
375 if err != nil {
376 return "", "", err
377 }
378 p := creds.get(provider)
379 if p == nil {
380 return "", "", fmt.Errorf("unknown provider %q", provider)
381 }
382 if p.APIKey != "" {
383 return p.APIKey, "apikey", nil
384 }
385 if p.OAuth == nil {
386 return "", "", fmt.Errorf("no credentials for %s", provider)
387 }
388 if !p.OAuth.Expired() {
389 return p.OAuth.AccessToken, "oauth", nil
390 }
391 if p.OAuth.RefreshToken == "" {
392 return "", "", fmt.Errorf("%s access token expired and no refresh token is available; please /login again", provider)
393 }
394 var op OAuthProvider
395 switch provider {
396 case "anthropic":
397 op = AnthropicOAuth
398 case "openai":
399 op = OpenAIOAuth
400 }
401 tok, err := op.Refresh(ctx, p.OAuth.RefreshToken)
402 if err != nil {
403 return "", "", err
404 }
405 if tok.RefreshToken == "" {
406 tok.RefreshToken = p.OAuth.RefreshToken
407 }
408 if err := m.store.SetOAuth(provider, *tok); err != nil {
409 return "", "", err
410 }
411 return tok.AccessToken, "oauth", nil
412}