v0.0.3 c0f685f

manager.go RawHistoryFolder

1package auth
2
3import (
4	"context"
5	"fmt"
6	"os/exec"
7	"runtime"
8	"sync"
9	"time"
10)
11
12// Event is delivered on Manager.Events().
13type Event struct {
14	Kind     string // "started" | "browser_open" | "success" | "error" | "canceled"
15	Provider string
16	Method   string
17	URL      string // login URL (for "started"/"browser_open")
18	Message  string // on error
19}
20
21// Manager drives a login flow end-to-end. It owns a local web server
22// (for api-key form) plus provider-specific OAuth callback servers.
23type Manager struct {
24	store       *Store
25	keyServer   *Server         // random-port web form server (api-key flow)
26	oauthServer *CallbackServer // fixed-port callback server (oauth flow, only one at a time)
27	mu          sync.Mutex
28	events      chan Event
29	openBrowser bool
30
31	oauthCtx    context.Context
32	oauthCancel context.CancelFunc
33}
34
35// NewManager returns a Manager bound to store.
36func NewManager(store *Store) *Manager {
37	return &Manager{
38		store:       store,
39		events:      make(chan Event, 16),
40		openBrowser: true,
41	}
42}
43
44// Store returns the underlying credential store.
45func (m *Manager) Store() *Store { return m.store }
46
47// Events returns the read-only event channel.
48func (m *Manager) Events() <-chan Event { return m.events }
49
50// Close shuts down any running servers and cancels pending flows.
51func (m *Manager) Close() {
52	m.mu.Lock()
53	defer m.mu.Unlock()
54	if m.keyServer != nil {
55		ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
56		_ = m.keyServer.Shutdown(ctx)
57		cancel()
58		m.keyServer = nil
59	}
60	if m.oauthServer != nil {
61		m.oauthServer.Shutdown()
62		m.oauthServer = nil
63	}
64	if m.oauthCancel != nil {
65		m.oauthCancel()
66		m.oauthCancel = nil
67	}
68}
69
70// ---- API key flow ----
71
72// StartAPIKey launches the API-key login flow.
73func (m *Manager) StartAPIKey(provider string) (string, error) {
74	if provider != "anthropic" && provider != "openai" {
75		return "", fmt.Errorf("provider must be anthropic or openai")
76	}
77	if err := m.ensureKeyServer(); err != nil {
78		return "", err
79	}
80	u := m.keyServer.URL() + "/apikey?provider=" + provider
81	go m.maybeOpen(u)
82	m.emit(Event{Kind: "started", Provider: provider, Method: "apikey", URL: u})
83	return u, nil
84}
85
86func (m *Manager) ensureKeyServer() error {
87	m.mu.Lock()
88	defer m.mu.Unlock()
89	if m.keyServer != nil {
90		return nil
91	}
92	s, err := NewServer()
93	if err != nil {
94		return err
95	}
96	m.keyServer = s
97	go m.consumeKeyServerResults()
98	return nil
99}
100
101func (m *Manager) consumeKeyServerResults() {
102	for res := range m.keyServer.Result() {
103		if res.Err != nil {
104			m.emit(Event{Kind: "error", Provider: res.Provider, Method: res.Method, Message: res.Err.Error()})
105			continue
106		}
107		if err := m.store.SetAPIKey(res.Provider, res.APIKey); err != nil {
108			m.emit(Event{Kind: "error", Provider: res.Provider, Method: "apikey", Message: err.Error()})
109			continue
110		}
111		m.emit(Event{Kind: "success", Provider: res.Provider, Method: "apikey"})
112	}
113}
114
115// ---- OAuth flow ----
116
117// StartOAuth launches the subscription OAuth flow for provider.
118// Only one oauth flow may be in progress at a time (because the
119// callback port is fixed per provider and re-used by the official CLIs).
120func (m *Manager) StartOAuth(provider string) (string, error) {
121	var op OAuthProvider
122	switch provider {
123	case "anthropic":
124		op = AnthropicOAuth
125	case "openai":
126		op = OpenAIOAuth
127	default:
128		return "", fmt.Errorf("provider must be anthropic or openai")
129	}
130
131	m.mu.Lock()
132	if m.oauthServer != nil {
133		m.oauthServer.Shutdown()
134		m.oauthServer = nil
135	}
136	if m.oauthCancel != nil {
137		m.oauthCancel()
138	}
139	m.mu.Unlock()
140
141	pkce, err := NewPKCE()
142	if err != nil {
143		return "", err
144	}
145	authURL, state, err := op.AuthorizeURL(pkce)
146	if err != nil {
147		return "", err
148	}
149
150	cs, err := NewCallbackServer(op, state)
151	if err != nil {
152		return "", err
153	}
154
155	ctx, cancel := context.WithCancel(context.Background())
156	m.mu.Lock()
157	m.oauthServer = cs
158	m.oauthCtx = ctx
159	m.oauthCancel = cancel
160	m.mu.Unlock()
161
162	go m.awaitOAuth(ctx, op, cs, pkce, state)
163	go m.maybeOpen(authURL)
164	m.emit(Event{Kind: "started", Provider: provider, Method: "oauth", URL: authURL})
165	return authURL, nil
166}
167
168func (m *Manager) awaitOAuth(ctx context.Context, op OAuthProvider, cs *CallbackServer, pkce PKCE, state string) {
169	defer cs.Shutdown()
170
171	waitCtx, waitCancel := context.WithTimeout(ctx, 10*time.Minute)
172	defer waitCancel()
173	res, err := cs.Result(waitCtx)
174	if err != nil {
175		if ctx.Err() == nil {
176			m.emit(Event{Kind: "error", Provider: op.Name, Method: "oauth", Message: "timeout waiting for callback"})
177		}
178		return
179	}
180	if res.Err != nil {
181		m.emit(Event{Kind: "error", Provider: op.Name, Method: "oauth", Message: res.Err.Error()})
182		return
183	}
184
185	exCtx, exCancel := context.WithTimeout(context.Background(), 30*time.Second)
186	defer exCancel()
187	tok, err := op.Exchange(exCtx, res.Code, res.State, pkce)
188	if err != nil {
189		m.emit(Event{Kind: "error", Provider: op.Name, Method: "oauth", Message: err.Error()})
190		return
191	}
192	if err := m.store.SetOAuth(op.Name, *tok); err != nil {
193		m.emit(Event{Kind: "error", Provider: op.Name, Method: "oauth", Message: err.Error()})
194		return
195	}
196	m.emit(Event{Kind: "success", Provider: op.Name, Method: "oauth"})
197}
198
199// CancelOAuth aborts any in-flight OAuth flow.
200func (m *Manager) CancelOAuth() {
201	m.mu.Lock()
202	defer m.mu.Unlock()
203	if m.oauthCancel != nil {
204		m.oauthCancel()
205		m.oauthCancel = nil
206	}
207	if m.oauthServer != nil {
208		m.oauthServer.Shutdown()
209		m.oauthServer = nil
210	}
211}
212
213// ---- shared ----
214
215func (m *Manager) emit(e Event) {
216	select {
217	case m.events <- e:
218	default:
219	}
220}
221
222// maybeOpen tries to open u in the system browser.
223func (m *Manager) maybeOpen(u string) {
224	if !m.openBrowser {
225		return
226	}
227	var cmd *exec.Cmd
228	switch runtime.GOOS {
229	case "darwin":
230		cmd = exec.Command("open", u)
231	case "windows":
232		cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", u)
233	default:
234		cmd = exec.Command("xdg-open", u)
235	}
236	_ = cmd.Start()
237	m.emit(Event{Kind: "browser_open", URL: u})
238}
239
240// RefreshIfNeeded returns the currently-usable credential for provider,
241// refreshing an expired OAuth access token if necessary.
242func (m *Manager) RefreshIfNeeded(ctx context.Context, provider string) (string, string, error) {
243	creds, err := m.store.Load()
244	if err != nil {
245		return "", "", err
246	}
247	p := creds.get(provider)
248	if p == nil {
249		return "", "", fmt.Errorf("unknown provider %q", provider)
250	}
251	if p.APIKey != "" {
252		return p.APIKey, "apikey", nil
253	}
254	if p.OAuth == nil {
255		return "", "", fmt.Errorf("no credentials for %s", provider)
256	}
257	if !p.OAuth.Expired() {
258		return p.OAuth.AccessToken, "oauth", nil
259	}
260	if p.OAuth.RefreshToken == "" {
261		return "", "", fmt.Errorf("%s access token expired and no refresh token is available; please /login again", provider)
262	}
263	var op OAuthProvider
264	switch provider {
265	case "anthropic":
266		op = AnthropicOAuth
267	case "openai":
268		op = OpenAIOAuth
269	}
270	tok, err := op.Refresh(ctx, p.OAuth.RefreshToken)
271	if err != nil {
272		return "", "", err
273	}
274	if tok.RefreshToken == "" {
275		tok.RefreshToken = p.OAuth.RefreshToken
276	}
277	if err := m.store.SetOAuth(provider, *tok); err != nil {
278		return "", "", err
279	}
280	return tok.AccessToken, "oauth", nil
281}

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