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