1package auth
2
3import (
4 "context"
5 "errors"
6 "fmt"
7 "html/template"
8 "net"
9 "net/http"
10 "net/url"
11 "strings"
12 "sync"
13 "time"
14)
15
16// LoginResult is delivered on the channel returned by Server.Result().
17type LoginResult struct {
18 Provider string
19 Method string // "apikey" | "oauth"
20 APIKey string // populated when Method == "apikey"
21 Code string // populated when Method == "oauth"
22 State string // OAuth state (caller should verify)
23 Err error
24}
25
26// Server is a tiny local HTTP server used by the login flows. It binds
27// to 127.0.0.1 on a random free port and serves:
28//
29// GET / landing page (menu)
30// GET /apikey?provider=... API key form
31// POST /apikey form submit -> probes -> stores via caller
32// GET /callback OAuth callback (query: code, state)
33// GET /success generic success page
34// GET /error generic error page
35//
36// The caller receives login events on Result(). The server stays up
37// until Shutdown() is called.
38type Server struct {
39 l net.Listener
40 srv *http.Server
41 baseURL string
42 results chan LoginResult
43 probeFn func(ctx context.Context, provider, key string) error
44 mu sync.Mutex
45 shutdown bool
46}
47
48// NewServer starts a new login server on a random free port bound to loopback.
49func NewServer() (*Server, error) {
50 l, err := net.Listen("tcp", "127.0.0.1:0")
51 if err != nil {
52 return nil, err
53 }
54 s := &Server{
55 l: l,
56 baseURL: "http://" + l.Addr().String(),
57 results: make(chan LoginResult, 4),
58 probeFn: ProbeAPIKey,
59 }
60 mux := http.NewServeMux()
61 mux.HandleFunc("/", s.handleIndex)
62 mux.HandleFunc("/apikey", s.handleAPIKey)
63 mux.HandleFunc("/callback", s.handleCallback)
64 mux.HandleFunc("/success", s.handleSuccess)
65 mux.HandleFunc("/error", s.handleError)
66 mux.HandleFunc("/logo.png", serveLogo)
67 s.srv = &http.Server{
68 Handler: mux,
69 ReadTimeout: 15 * time.Second,
70 WriteTimeout: 15 * time.Second,
71 }
72 go func() { _ = s.srv.Serve(l) }()
73 return s, nil
74}
75
76// URL returns the base URL the server is listening on.
77func (s *Server) URL() string { return s.baseURL }
78
79// Port returns the TCP port the server is bound to.
80func (s *Server) Port() int {
81 return s.l.Addr().(*net.TCPAddr).Port
82}
83
84// Result returns the channel receiving LoginResult events.
85func (s *Server) Result() <-chan LoginResult { return s.results }
86
87// Shutdown stops the server. It is safe to call multiple times.
88func (s *Server) Shutdown(ctx context.Context) error {
89 s.mu.Lock()
90 if s.shutdown {
91 s.mu.Unlock()
92 return nil
93 }
94 s.shutdown = true
95 s.mu.Unlock()
96 return s.srv.Shutdown(ctx)
97}
98
99// extraAPIKeyProviders holds custom provider names registered at
100// startup from models.json. These are accepted by the login flow
101// alongside the built-in APIKeyProviders list.
102var extraAPIKeyProviders []string
103
104// SetExtraAPIKeyProviders registers additional provider names that
105// should be accepted by the API-key login flow. Call after loading user
106// models so custom providers appear in the /login picker.
107func SetExtraAPIKeyProviders(names []string) {
108 extraAPIKeyProviders = names
109}
110
111func isExtraAPIKeyProvider(p string) bool {
112 for _, name := range extraAPIKeyProviders {
113 if p == name {
114 return true
115 }
116 }
117 return false
118}
119
120// isKnownAPIKeyProvider reports whether the given provider supports
121// API-key login through the loopback flow. OAuth-only paths are handled
122// elsewhere (manager.StartOAuth).
123func isKnownAPIKeyProvider(p string) bool {
124 for _, provider := range APIKeyProviders() {
125 if p == provider {
126 return true
127 }
128 }
129 return isExtraAPIKeyProvider(p)
130}
131
132func apiKeyProviderMessage() string {
133 return "provider must be one of: " + strings.Join(APIKeyProviders(), ", ")
134}
135
136// APIKeyProviders is the ordered list shown by /login -> api key.
137func APIKeyProviders() []string {
138 return []string{
139 "anthropic", "openai", "kimi", "deepseek", "google", "llama.cpp",
140 "moonshotai", "moonshotai-cn", "groq", "cerebras", "xai", "together",
141 "huggingface", "openrouter", "mistral", "zai",
142 "xiaomi", "xiaomi-token-plan-ams", "xiaomi-token-plan-cn", "xiaomi-token-plan-sgp",
143 "minimax", "minimax-cn", "fireworks", "vercel-ai-gateway",
144 "opencode", "opencode-go", "amazon-bedrock", "google-vertex", "azure-openai-responses",
145 "github-copilot", "cloudflare-workers-ai", "cloudflare-ai-gateway",
146 }
147}
148
149// ---- handlers ----
150
151func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
152 if r.URL.Path != "/" {
153 http.NotFound(w, r)
154 return
155 }
156 tpl.ExecuteTemplate(w, "index", map[string]any{
157 "Port": s.Port(),
158 })
159}
160
161func (s *Server) handleAPIKey(w http.ResponseWriter, r *http.Request) {
162 switch r.Method {
163 case http.MethodGet:
164 provider := r.URL.Query().Get("provider")
165 if !isKnownAPIKeyProvider(provider) {
166 http.Error(w, apiKeyProviderMessage(), http.StatusBadRequest)
167 return
168 }
169 tpl.ExecuteTemplate(w, "apikey", map[string]any{"Provider": provider})
170 case http.MethodPost:
171 if err := r.ParseForm(); err != nil {
172 http.Error(w, err.Error(), http.StatusBadRequest)
173 return
174 }
175 provider := strings.TrimSpace(r.FormValue("provider"))
176 key := strings.TrimSpace(r.FormValue("api_key"))
177 if provider == "" || key == "" {
178 s.errorPage(w, "missing provider or api key")
179 return
180 }
181 ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
182 defer cancel()
183 if err := s.probeFn(ctx, provider, key); err != nil {
184 s.errorPage(w, err.Error())
185 s.results <- LoginResult{Provider: provider, Method: "apikey", Err: err}
186 return
187 }
188 s.successPage(w, provider, "api key")
189 s.results <- LoginResult{Provider: provider, Method: "apikey", APIKey: key}
190 default:
191 http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
192 }
193}
194
195func (s *Server) handleCallback(w http.ResponseWriter, r *http.Request) {
196 q := r.URL.Query()
197 provider := q.Get("provider")
198 if provider == "" {
199 // Some OAuth providers don't echo back custom params; try state-encoded form.
200 provider = decodeStateProvider(q.Get("state"))
201 }
202 if errParam := q.Get("error"); errParam != "" {
203 msg := errParam
204 if d := q.Get("error_description"); d != "" {
205 msg += ": " + d
206 }
207 s.errorPage(w, msg)
208 s.results <- LoginResult{Provider: provider, Method: "oauth", Err: errors.New(msg)}
209 return
210 }
211 code := q.Get("code")
212 state := q.Get("state")
213 if code == "" {
214 s.errorPage(w, "missing authorization code")
215 s.results <- LoginResult{Provider: provider, Method: "oauth", Err: fmt.Errorf("missing code")}
216 return
217 }
218 s.successPage(w, provider, "subscription")
219 s.results <- LoginResult{Provider: provider, Method: "oauth", Code: code, State: state}
220}
221
222func (s *Server) handleSuccess(w http.ResponseWriter, r *http.Request) {
223 q := r.URL.Query()
224 s.successPage(w, q.Get("provider"), q.Get("method"))
225}
226
227func (s *Server) handleError(w http.ResponseWriter, r *http.Request) {
228 s.errorPage(w, r.URL.Query().Get("message"))
229}
230
231func (s *Server) successPage(w http.ResponseWriter, provider, method string) {
232 w.Header().Set("content-type", "text/html; charset=utf-8")
233 tpl.ExecuteTemplate(w, "success", map[string]any{
234 "Provider": provider,
235 "Method": method,
236 })
237}
238
239func (s *Server) errorPage(w http.ResponseWriter, msg string) {
240 w.Header().Set("content-type", "text/html; charset=utf-8")
241 w.WriteHeader(http.StatusBadRequest)
242 tpl.ExecuteTemplate(w, "error", map[string]any{"Message": msg})
243}
244
245// decodeStateProvider extracts the provider from a state string of the
246// form "<provider>:<nonce>".
247func decodeStateProvider(state string) string {
248 if i := strings.IndexByte(state, ':'); i > 0 {
249 return state[:i]
250 }
251 return ""
252}
253
254// BuildRedirectURI returns the callback URL the OAuth server should
255// redirect to.
256func (s *Server) BuildRedirectURI() string {
257 return s.baseURL + "/callback"
258}
259
260// Redirect sends an HTTP redirect to u. Used by the TUI to tell the
261// browser to bounce through our local server. Not currently used; kept
262// for future flows.
263func Redirect(w http.ResponseWriter, r *http.Request, u *url.URL) {
264 http.Redirect(w, r, u.String(), http.StatusFound)
265}
266
267// ---- templates ----
268//
269// All pages share the monochrome monoStyle defined in callback.go so
270// the browser tab looks like the tui: black on white, monospace, thin
271// rules, no rounded boxes, no color.
272
273var tpl = template.Must(template.New("index").Parse(`<!doctype html><html lang="en"><head><meta charset="utf-8"/><title>zut login</title>` + monoStyle + `</head><body>
274` + logoTag + `
275<h1><span class="zut">zut</span> login</h1>
276<hr class="rule">
277<p>paste an api key for anthropic, openai, kimi, deepseek, or google. <span class="zut">zut</span> probes the provider once, then saves the key to <span class="mono">~/Library/Application Support/zut/auth.json</span>.</p>
278<p>
279 <a href="/apikey?provider=anthropic">anthropic api key →</a><br>
280 <a href="/apikey?provider=openai">openai api key →</a><br>
281 <a href="/apikey?provider=kimi">kimi api key →</a><br>
282 <a href="/apikey?provider=deepseek">deepseek api key →</a><br>
283 <a href="/apikey?provider=google">google gemini api key →</a>
284</p>
285<hr class="rule">
286<p class="muted">for a subscription login (claude pro/max - chatgpt plus/pro - kimi code - github copilot), close this tab and run /login inside <span class="zut">zut</span>.</p>
287</body></html>`))
288
289func init() {
290 template.Must(tpl.New("apikey").Parse(`<!doctype html><html lang="en"><head><meta charset="utf-8"/><title>zut login</title>` + monoStyle + `<style>
291 form { display: flex; flex-direction: column; gap: 0.75rem; }
292 label { font-size: 0.875rem; }
293</style></head><body>
294` + logoTag + `
295<h1><span class="zut">zut</span> login - {{.Provider}} api key</h1>
296<hr class="rule">
297<p>paste your {{.Provider}} api key. <span class="zut">zut</span> will probe the provider with it once, then save it if the key is accepted.</p>
298<form method="POST" action="/apikey">
299 <input type="hidden" name="provider" value="{{.Provider}}" />
300 <label for="api_key">api key</label>
301 <input id="api_key" name="api_key" type="password" autocomplete="off" autofocus />
302 <button type="submit">log in</button>
303</form>
304</body></html>`))
305
306 template.Must(tpl.New("success").Parse(`<!doctype html><html lang="en"><head><meta charset="utf-8"/><title>zut - logged in</title>` + monoStyle + `</head><body>
307` + logoTag + `
308<h1><span class="mark">✓</span> logged in to {{.Provider}}</h1>
309<hr class="rule">
310<p class="msg">method: {{.Method}}</p>
311<p class="muted"><span class="zut">zut</span> received the callback. you can close this tab and return to the terminal.</p>
312</body></html>`))
313
314 template.Must(tpl.New("error").Parse(`<!doctype html><html lang="en"><head><meta charset="utf-8"/><title>zut - error</title>` + monoStyle + `</head><body>
315` + logoTag + `
316<h1><span class="mark">✗</span> login failed</h1>
317<hr class="rule">
318<p class="msg mono">{{.Message}}</p>
319<p class="muted">go back to <span class="zut">zut</span> and try again.</p>
320</body></html>`))
321}