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