v0.1.82 25e7da2

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	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}

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