v0.1.95 5293277

manager.go RawHistoryFolder

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}

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 476

↑↓ moveenter openesc close