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}