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