1package auth
2
3import (
4 "context"
5 "errors"
6 "fmt"
7 "net/url"
8 "os"
9 "os/exec"
10 "runtime"
11 "strings"
12 "sync"
13 "time"
14)
15
16// Event is delivered on Manager.Events().
17type Event struct {
18 Kind string // "started" | "browser_open" | "success" | "error" | "canceled"
19 Provider string
20 Method string
21 URL string // login URL (for "started"/"browser_open")
22 Message string // on error
23}
24
25// Manager drives a login flow end-to-end. It owns a local web server
26// (for api-key form) plus provider-specific OAuth callback servers.
27type Manager struct {
28 store *Store
29 keyServer *Server // random-port web form server (api-key flow)
30 oauthServer *CallbackServer // fixed-port callback server (oauth flow, only one at a time)
31 mu sync.Mutex
32 events chan Event
33 openBrowser bool
34
35 oauthCtx context.Context
36 oauthCancel context.CancelFunc
37
38 manualOp *OAuthProvider
39 manualStoreProvider string
40 manualEventProvider string
41 manualPKCE PKCE
42 manualState string
43}
44
45// NewManager returns a Manager bound to store.
46func NewManager(store *Store) *Manager {
47 return &Manager{
48 store: store,
49 events: make(chan Event, 16),
50 openBrowser: true,
51 }
52}
53
54// Store returns the underlying credential store.
55func (m *Manager) Store() *Store { return m.store }
56
57// Events returns the read-only event channel.
58func (m *Manager) Events() <-chan Event { return m.events }
59
60// Close shuts down any running servers and cancels pending flows.
61func (m *Manager) Close() {
62 m.mu.Lock()
63 defer m.mu.Unlock()
64 if m.keyServer != nil {
65 ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
66 _ = m.keyServer.Shutdown(ctx)
67 cancel()
68 m.keyServer = nil
69 }
70 if m.oauthServer != nil {
71 m.oauthServer.Shutdown()
72 m.oauthServer = nil
73 }
74 if m.oauthCancel != nil {
75 m.oauthCancel()
76 m.oauthCancel = nil
77 }
78 m.clearManualOAuthLocked()
79}
80
81// ---- API key flow ----
82
83// StartAPIKey launches the API-key login flow.
84func (m *Manager) StartAPIKey(provider string) (string, error) {
85 if !isKnownAPIKeyProvider(provider) {
86 return "", errors.New(apiKeyProviderMessage())
87 }
88 if err := m.ensureKeyServer(); err != nil {
89 return "", err
90 }
91 u := m.keyServer.URL() + "/apikey?provider=" + provider
92 go m.maybeOpen(u)
93 m.emit(Event{Kind: "started", Provider: provider, Method: "apikey", URL: u})
94 return u, nil
95}
96
97func (m *Manager) ensureKeyServer() error {
98 m.mu.Lock()
99 defer m.mu.Unlock()
100 if m.keyServer != nil {
101 return nil
102 }
103 s, err := NewServer()
104 if err != nil {
105 return err
106 }
107 m.keyServer = s
108 go m.consumeKeyServerResults()
109 return nil
110}
111
112func (m *Manager) consumeKeyServerResults() {
113 for res := range m.keyServer.Result() {
114 if res.Err != nil {
115 m.emit(Event{Kind: "error", Provider: res.Provider, Method: res.Method, Message: res.Err.Error()})
116 continue
117 }
118 if err := m.store.SetAPIKey(res.Provider, res.APIKey); err != nil {
119 m.emit(Event{Kind: "error", Provider: res.Provider, Method: "apikey", Message: err.Error()})
120 continue
121 }
122 m.emit(Event{Kind: "success", Provider: res.Provider, Method: "apikey"})
123 }
124}
125
126// ---- OAuth flow ----
127
128// StartOAuth launches the subscription OAuth flow for provider.
129// Only one oauth flow may be in progress at a time (because the
130// callback port is fixed per provider and re-used by the official CLIs).
131//
132// Note: "google" is intentionally not supported here. Google does not
133// offer a subscription OAuth that exchanges a Google One AI / Gemini
134// Advanced login for usable Generative Language API credentials, so
135// the only supported google login path is the API-key flow.
136func (m *Manager) StartOAuth(provider string) (string, error) {
137 if provider == "kimi" {
138 return m.StartKimiDeviceOAuth()
139 }
140 if provider == "xai" {
141 return m.StartXAIDeviceOAuth()
142 }
143 if provider == "github-copilot" {
144 return m.StartGitHubCopilotDeviceOAuth()
145 }
146 storeProvider := provider
147 var op OAuthProvider
148 switch provider {
149 case "anthropic":
150 op = AnthropicOAuth
151 case "openai", "openai-codex":
152 op = OpenAIOAuth
153 storeProvider = "openai"
154 case "google":
155 return "", fmt.Errorf("google login is api-key only; use api key login for gemini")
156 case "deepseek":
157 return "", fmt.Errorf("deepseek login is api-key only; use api key login")
158 default:
159 return "", fmt.Errorf("provider must be anthropic, openai, openai-codex, kimi, xai, github-copilot, deepseek, or google")
160 }
161
162 m.mu.Lock()
163 if m.oauthServer != nil {
164 m.oauthServer.Shutdown()
165 m.oauthServer = nil
166 }
167 if m.oauthCancel != nil {
168 m.oauthCancel()
169 }
170 m.clearManualOAuthLocked()
171 m.mu.Unlock()
172
173 pkce, err := NewPKCE()
174 if err != nil {
175 return "", err
176 }
177 authURL, state, err := op.AuthorizeURL(pkce)
178 if err != nil {
179 return "", err
180 }
181
182 cs, err := NewCallbackServer(op, state)
183 if err != nil {
184 return "", err
185 }
186
187 ctx, cancel := context.WithCancel(context.Background())
188 m.mu.Lock()
189 m.oauthServer = cs
190 m.oauthCtx = ctx
191 m.oauthCancel = cancel
192 m.manualOp = &op
193 m.manualStoreProvider = storeProvider
194 m.manualEventProvider = provider
195 m.manualPKCE = pkce
196 m.manualState = state
197 m.mu.Unlock()
198
199 go m.awaitOAuth(ctx, op, storeProvider, provider, cs, pkce, state)
200 go m.maybeOpen(authURL)
201 m.emit(Event{Kind: "started", Provider: provider, Method: "oauth", URL: authURL})
202 return authURL, nil
203}
204
205func (m *Manager) awaitOAuth(ctx context.Context, op OAuthProvider, storeProvider, eventProvider string, cs *CallbackServer, pkce PKCE, state string) {
206 defer cs.Shutdown()
207
208 waitCtx, waitCancel := context.WithTimeout(ctx, 10*time.Minute)
209 defer waitCancel()
210 res, err := cs.Result(waitCtx)
211 if err != nil {
212 if ctx.Err() == nil {
213 m.mu.Lock()
214 if m.oauthCtx == ctx {
215 m.clearManualOAuthLocked()
216 }
217 m.mu.Unlock()
218 m.emit(Event{Kind: "error", Provider: eventProvider, Method: "oauth", Message: "timeout waiting for callback"})
219 }
220 return
221 }
222 if ctx.Err() != nil {
223 return
224 }
225 if res.Err != nil {
226 m.mu.Lock()
227 if m.oauthCtx == ctx {
228 m.clearManualOAuthLocked()
229 }
230 m.mu.Unlock()
231 m.emit(Event{Kind: "error", Provider: eventProvider, Method: "oauth", Message: res.Err.Error()})
232 return
233 }
234 m.mu.Lock()
235 if m.oauthCtx != ctx || m.manualOp == nil {
236 m.mu.Unlock()
237 return
238 }
239 m.clearManualOAuthLocked()
240 m.mu.Unlock()
241
242 exCtx, exCancel := context.WithTimeout(context.Background(), 30*time.Second)
243 defer exCancel()
244 tok, err := op.Exchange(exCtx, res.Code, res.State, pkce)
245 if err != nil {
246 m.emit(Event{Kind: "error", Provider: eventProvider, Method: "oauth", Message: err.Error()})
247 return
248 }
249 if err := m.store.SetOAuth(storeProvider, *tok); err != nil {
250 m.emit(Event{Kind: "error", Provider: eventProvider, Method: "oauth", Message: err.Error()})
251 return
252 }
253 m.emit(Event{Kind: "success", Provider: eventProvider, Method: "oauth"})
254}
255
256// StartKimiDeviceOAuth starts Kimi Code's device-code subscription login.
257func (m *Manager) StartKimiDeviceOAuth() (string, error) {
258 ctx, cancel := context.WithCancel(context.Background())
259 m.mu.Lock()
260 if m.oauthCancel != nil {
261 m.oauthCancel()
262 }
263 m.oauthCtx = ctx
264 m.oauthCancel = cancel
265 m.mu.Unlock()
266
267 dev, err := RequestKimiDeviceAuthorization(ctx)
268 if err != nil {
269 return "", err
270 }
271 url := dev.VerificationURIComplete
272 go m.maybeOpen(url)
273 m.emit(Event{Kind: "started", Provider: "kimi", Method: "oauth", URL: url})
274 go func() {
275 tok, err := PollKimiDeviceToken(ctx, dev)
276 if err != nil {
277 if ctx.Err() == nil {
278 m.emit(Event{Kind: "error", Provider: "kimi", Method: "oauth", Message: err.Error()})
279 }
280 return
281 }
282 if err := m.store.SetOAuth("kimi", *tok); err != nil {
283 m.emit(Event{Kind: "error", Provider: "kimi", Method: "oauth", Message: err.Error()})
284 return
285 }
286 m.emit(Event{Kind: "success", Provider: "kimi", Method: "oauth"})
287 }()
288 return url, nil
289}
290
291// StartXAIDeviceOAuth starts xAI's device-code subscription login.
292func (m *Manager) StartXAIDeviceOAuth() (string, error) {
293 ctx, cancel := context.WithCancel(context.Background())
294 m.mu.Lock()
295 if m.oauthCancel != nil {
296 m.oauthCancel()
297 }
298 m.oauthCtx = ctx
299 m.oauthCancel = cancel
300 m.mu.Unlock()
301
302 dev, err := RequestXAIDeviceAuthorization(ctx)
303 if err != nil {
304 return "", err
305 }
306 loginURL := dev.VerificationURIComplete
307 go m.maybeOpen(loginURL)
308 m.emit(Event{Kind: "started", Provider: "xai", Method: "oauth", URL: loginURL})
309 go func() {
310 tok, err := PollXAIDeviceToken(ctx, dev)
311 if err != nil {
312 if ctx.Err() == nil {
313 m.emit(Event{Kind: "error", Provider: "xai", Method: "oauth", Message: err.Error()})
314 }
315 return
316 }
317 if err := m.store.SetOAuth("xai", *tok); err != nil {
318 m.emit(Event{Kind: "error", Provider: "xai", Method: "oauth", Message: err.Error()})
319 return
320 }
321 m.emit(Event{Kind: "success", Provider: "xai", Method: "oauth"})
322 }()
323 return loginURL, nil
324}
325
326// StartGitHubCopilotDeviceOAuth starts GitHub Copilot's device-code subscription login.
327func (m *Manager) StartGitHubCopilotDeviceOAuth() (string, error) {
328 ctx, cancel := context.WithCancel(context.Background())
329 m.mu.Lock()
330 if m.oauthCancel != nil {
331 m.oauthCancel()
332 }
333 m.oauthCtx = ctx
334 m.oauthCancel = cancel
335 m.mu.Unlock()
336
337 dev, err := RequestGitHubCopilotDeviceAuthorization(ctx)
338 if err != nil {
339 return "", err
340 }
341 loginURL := dev.VerificationURI
342 if dev.UserCode != "" {
343 loginURL += "?user_code=" + url.QueryEscape(dev.UserCode)
344 }
345 go m.maybeOpen(loginURL)
346 m.emit(Event{Kind: "started", Provider: "github-copilot", Method: "oauth", URL: loginURL})
347 go func() {
348 tok, err := PollGitHubCopilotDeviceToken(ctx, dev)
349 if err != nil {
350 if ctx.Err() == nil {
351 m.emit(Event{Kind: "error", Provider: "github-copilot", Method: "oauth", Message: err.Error()})
352 }
353 return
354 }
355 if err := m.store.SetOAuth("github-copilot", *tok); err != nil {
356 m.emit(Event{Kind: "error", Provider: "github-copilot", Method: "oauth", Message: err.Error()})
357 return
358 }
359 m.emit(Event{Kind: "success", Provider: "github-copilot", Method: "oauth"})
360 }()
361 return loginURL, nil
362}
363
364// StartManualOAuth begins an OAuth flow but does NOT start a local
365// callback server or open a browser. The returned URL is shown to the
366// user so they can complete the authorization on another device; the
367// resulting code is pasted back via CompleteManualOAuth.
368func (m *Manager) StartManualOAuth(provider string) (string, error) {
369 if provider == "kimi" {
370 return m.StartKimiDeviceOAuth()
371 }
372 if provider == "xai" {
373 return m.StartXAIDeviceOAuth()
374 }
375 if provider == "github-copilot" {
376 return m.StartGitHubCopilotDeviceOAuth()
377 }
378 storeProvider := provider
379 var op OAuthProvider
380 switch provider {
381 case "anthropic":
382 op = AnthropicManualOAuth
383 case "openai", "openai-codex":
384 return "", fmt.Errorf("openai login requires the browser callback flow; manual code login is not supported")
385 case "google":
386 return "", fmt.Errorf("google login is api-key only; use api key login for gemini")
387 case "deepseek":
388 return "", fmt.Errorf("deepseek login is api-key only; use api key login")
389 default:
390 return "", fmt.Errorf("provider must be anthropic, openai, openai-codex, kimi, xai, github-copilot, deepseek, or google")
391 }
392
393 m.CancelOAuth()
394 pkce, err := NewPKCE()
395 if err != nil {
396 return "", err
397 }
398 authURL, state, err := op.AuthorizeURL(pkce)
399 if err != nil {
400 return "", err
401 }
402
403 m.mu.Lock()
404 m.manualOp = &op
405 m.manualStoreProvider = storeProvider
406 m.manualEventProvider = provider
407 m.manualPKCE = pkce
408 m.manualState = state
409 m.mu.Unlock()
410
411 m.emit(Event{Kind: "started", Provider: provider, Method: "oauth", URL: authURL})
412 return authURL, nil
413}
414
415// CompleteManualOAuth exchanges the user-pasted authorization code for
416// a token and stores it. Accepts either a raw code or a "code#state"
417// token shown by providers like Anthropic when code=true is set.
418func (m *Manager) CompleteManualOAuth(ctx context.Context, input string) error {
419 code, pastedState := parseManualCodeInput(strings.TrimSpace(input))
420 m.mu.Lock()
421 if m.manualOp == nil {
422 m.mu.Unlock()
423 return fmt.Errorf("no manual oauth flow in progress")
424 }
425 if pastedState != "" && pastedState != m.manualState {
426 m.mu.Unlock()
427 return fmt.Errorf("oauth state mismatch")
428 }
429 if code == "" {
430 m.mu.Unlock()
431 return fmt.Errorf("empty code")
432 }
433 op := *m.manualOp
434 storeProvider := m.manualStoreProvider
435 eventProvider := m.manualEventProvider
436 pkce := m.manualPKCE
437 state := m.manualState
438 m.clearManualOAuthLocked()
439 if m.oauthServer != nil {
440 if m.oauthCancel != nil {
441 m.oauthCancel()
442 m.oauthCancel = nil
443 }
444 m.oauthServer.Shutdown()
445 m.oauthServer = nil
446 }
447 m.mu.Unlock()
448 exCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
449 defer cancel()
450 tok, err := op.Exchange(exCtx, code, state, pkce)
451 if err != nil {
452 m.emit(Event{Kind: "error", Provider: eventProvider, Method: "oauth", Message: err.Error()})
453 return err
454 }
455 if err := m.store.SetOAuth(storeProvider, *tok); err != nil {
456 m.emit(Event{Kind: "error", Provider: eventProvider, Method: "oauth", Message: err.Error()})
457 return err
458 }
459 m.emit(Event{Kind: "success", Provider: eventProvider, Method: "oauth"})
460 return nil
461}
462
463// parseManualCodeInput accepts any of:
464// - a bare authorization code
465// - a "code#state" pair
466// - a full redirect URL like http(s)://host:port/callback?code=X&state=Y
467//
468// and returns the extracted code and (if any) state.
469func parseManualCodeInput(s string) (code, state string) {
470 if strings.HasPrefix(s, "http://") || strings.HasPrefix(s, "https://") {
471 if u, err := url.Parse(s); err == nil {
472 q := u.Query()
473 return q.Get("code"), q.Get("state")
474 }
475 }
476 if idx := strings.IndexByte(s, '#'); idx >= 0 {
477 return s[:idx], s[idx+1:]
478 }
479 return s, ""
480}
481
482// HasBrowser reports whether the current environment probably has a
483// working interactive browser reachable from localhost. Used by the
484// login flow to auto-switch to paste-code mode on headless boxes
485// (containers, SSH without display forwarding, etc.) instead of
486// trying to bind a callback port the user can never reach.
487func HasBrowser() bool {
488 if os.Getenv("ZOT_NO_BROWSER") != "" {
489 return false
490 }
491 if os.Getenv("ZOT_FORCE_BROWSER") != "" {
492 return true
493 }
494 if _, err := os.Stat("/.dockerenv"); err == nil {
495 return false
496 }
497 if b, err := os.ReadFile("/proc/1/cgroup"); err == nil {
498 txt := string(b)
499 if strings.Contains(txt, "docker") || strings.Contains(txt, "kubepods") || strings.Contains(txt, "containerd") {
500 return false
501 }
502 }
503 switch runtime.GOOS {
504 case "darwin", "windows":
505 return true
506 default:
507 if os.Getenv("DISPLAY") == "" && os.Getenv("WAYLAND_DISPLAY") == "" {
508 return false
509 }
510 return true
511 }
512}
513
514// CancelOAuth aborts any in-flight OAuth flow.
515func (m *Manager) CancelOAuth() {
516 m.mu.Lock()
517 defer m.mu.Unlock()
518 if m.oauthCancel != nil {
519 m.oauthCancel()
520 m.oauthCancel = nil
521 }
522 if m.oauthServer != nil {
523 m.oauthServer.Shutdown()
524 m.oauthServer = nil
525 }
526 m.clearManualOAuthLocked()
527}
528
529func (m *Manager) clearManualOAuthLocked() {
530 m.manualOp = nil
531 m.manualStoreProvider = ""
532 m.manualEventProvider = ""
533 m.manualPKCE = PKCE{}
534 m.manualState = ""
535}
536
537// ---- shared ----
538
539func (m *Manager) emit(e Event) {
540 select {
541 case m.events <- e:
542 default:
543 }
544}
545
546// maybeOpen tries to open u in the system browser.
547func (m *Manager) maybeOpen(u string) {
548 if !m.openBrowser {
549 return
550 }
551 var cmd *exec.Cmd
552 switch runtime.GOOS {
553 case "darwin":
554 cmd = exec.Command("open", u)
555 case "windows":
556 cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", u)
557 default:
558 cmd = exec.Command("xdg-open", u)
559 }
560 _ = cmd.Start()
561 m.emit(Event{Kind: "browser_open", URL: u})
562}
563
564// RefreshIfNeeded returns the currently-usable credential for provider,
565// refreshing an expired OAuth access token if necessary.
566func (m *Manager) RefreshIfNeeded(ctx context.Context, provider string) (string, string, error) {
567 creds, err := m.store.Load()
568 if err != nil {
569 return "", "", err
570 }
571 p := creds.get(provider)
572 if p == nil {
573 return "", "", fmt.Errorf("unknown provider %q", provider)
574 }
575 if p.APIKey != "" {
576 return p.APIKey, "apikey", nil
577 }
578 if p.APIKeyCommand != nil {
579 key, err := ResolveAPIKeyCommand(ctx, *p.APIKeyCommand)
580 if err != nil {
581 return "", "", fmt.Errorf("resolve api key for %s: %w", provider, err)
582 }
583 return key, "apikey", nil
584 }
585 if p.OAuth == nil {
586 return "", "", fmt.Errorf("no credentials for %s", provider)
587 }
588 if !p.OAuth.Expired() {
589 return p.OAuth.AccessToken, "oauth", nil
590 }
591 if p.OAuth.RefreshToken == "" {
592 return "", "", fmt.Errorf("%s access token expired and no refresh token is available; please /login again", provider)
593 }
594 var op OAuthProvider
595 switch provider {
596 case "anthropic":
597 op = AnthropicOAuth
598 case "openai":
599 op = OpenAIOAuth
600 }
601 tok, err := op.Refresh(ctx, p.OAuth.RefreshToken)
602 if err != nil {
603 return "", "", err
604 }
605 if tok.RefreshToken == "" {
606 tok.RefreshToken = p.OAuth.RefreshToken
607 }
608 if err := m.store.SetOAuth(provider, *tok); err != nil {
609 return "", "", err
610 }
611 return tok.AccessToken, "oauth", nil
612}