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