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