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