v0.4.3 2a19df1

manager.go RawHistoryFolder

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

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 477

↑↓ moveenter openesc close