1package auth
2
3import (
4 "context"
5 "fmt"
6 "net"
7 "net/http"
8 "strings"
9 "sync"
10 "time"
11
12 "github.com/patriceckhart/zot/internal/assets"
13)
14
15// CallbackResult is what an OAuth callback server returns once the
16// browser hits the redirect URI.
17type CallbackResult struct {
18 Code string
19 State string
20 Err error
21 RawPath string
22}
23
24// CallbackServer is a single-shot OAuth callback listener bound to the
25// fixed port that the provider has whitelisted for its client.
26type CallbackServer struct {
27 l net.Listener
28 srv *http.Server
29 provider OAuthProvider
30 state string
31 result chan CallbackResult
32 once sync.Once
33}
34
35// NewCallbackServer starts a listener on p.RedirectPort/p.RedirectPath.
36// Returns an error if that port is already in use (e.g. another login
37// flow already running, or the official CLI is running concurrently).
38func NewCallbackServer(p OAuthProvider, expectedState string) (*CallbackServer, error) {
39 addr := fmt.Sprintf("%s:%d", p.RedirectHost, p.RedirectPort)
40 l, err := net.Listen("tcp", addr)
41 if err != nil {
42 return nil, fmt.Errorf("bind %s: %w (is another zot/claude-code/codex login already running?)", addr, err)
43 }
44 cs := &CallbackServer{
45 l: l,
46 provider: p,
47 state: expectedState,
48 result: make(chan CallbackResult, 1),
49 }
50 mux := http.NewServeMux()
51 mux.HandleFunc(p.RedirectPath, cs.handle)
52 mux.HandleFunc("/logo.png", serveLogo)
53 cs.srv = &http.Server{
54 Handler: mux,
55 ReadTimeout: 15 * time.Second,
56 WriteTimeout: 15 * time.Second,
57 }
58 go func() { _ = cs.srv.Serve(l) }()
59 return cs, nil
60}
61
62// URL returns the full redirect URI this server is listening on.
63func (cs *CallbackServer) URL() string { return cs.provider.RedirectURI() }
64
65// Result blocks until the callback is received or ctx is cancelled.
66func (cs *CallbackServer) Result(ctx context.Context) (CallbackResult, error) {
67 select {
68 case r := <-cs.result:
69 return r, nil
70 case <-ctx.Done():
71 return CallbackResult{}, ctx.Err()
72 }
73}
74
75// Shutdown stops the server. Safe to call more than once.
76func (cs *CallbackServer) Shutdown() {
77 cs.once.Do(func() {
78 ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
79 _ = cs.srv.Shutdown(ctx)
80 cancel()
81 })
82}
83
84func (cs *CallbackServer) handle(w http.ResponseWriter, r *http.Request) {
85 q := r.URL.Query()
86 w.Header().Set("content-type", "text/html; charset=utf-8")
87 if errParam := q.Get("error"); errParam != "" {
88 msg := errParam
89 if d := q.Get("error_description"); d != "" {
90 msg += ": " + d
91 }
92 w.WriteHeader(http.StatusBadRequest)
93 _, _ = w.Write([]byte(oauthErrorHTML(msg)))
94 cs.deliver(CallbackResult{Err: fmt.Errorf(msg), RawPath: r.URL.RequestURI()})
95 return
96 }
97 code := q.Get("code")
98 state := q.Get("state")
99 if code == "" {
100 w.WriteHeader(http.StatusBadRequest)
101 _, _ = w.Write([]byte(oauthErrorHTML("missing authorization code")))
102 cs.deliver(CallbackResult{Err: fmt.Errorf("missing code"), RawPath: r.URL.RequestURI()})
103 return
104 }
105 if cs.state != "" && state != cs.state {
106 w.WriteHeader(http.StatusBadRequest)
107 _, _ = w.Write([]byte(oauthErrorHTML("state mismatch")))
108 cs.deliver(CallbackResult{Err: fmt.Errorf("state mismatch"), RawPath: r.URL.RequestURI()})
109 return
110 }
111 w.WriteHeader(http.StatusOK)
112 _, _ = w.Write([]byte(oauthSuccessHTML(cs.provider.Name)))
113 cs.deliver(CallbackResult{Code: code, State: state, RawPath: r.URL.RequestURI()})
114}
115
116func (cs *CallbackServer) deliver(r CallbackResult) {
117 select {
118 case cs.result <- r:
119 default:
120 }
121}
122
123// ---- static HTML for the callback tab ----
124
125// All zot-served pages share a single dark style matching the TUI:
126// near-black background (#0a0a0a), white text, Geist Mono type, cyan
127// accent on the word "zot". Serves every step of both the api-key
128// flow and the oauth callback flow.
129const monoStyle = `<style>
130 @import url('https://fonts.googleapis.com/css2?family=Geist+Mono:wght@400;600&display=swap');
131 :root { color-scheme: dark; }
132 * { box-sizing: border-box; }
133 body {
134 font-family: 'Geist Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
135 background: #0a0a0a;
136 color: #ffffff;
137 max-width: 44rem;
138 margin: 0 auto;
139 padding: 3rem 1.5rem;
140 line-height: 1.55;
141 }
142 .logo {
143 display: block;
144 width: 120px;
145 height: auto;
146 image-rendering: pixelated;
147 image-rendering: crisp-edges;
148 margin: 0 0 1.5rem;
149 }
150 h1 {
151 font-size: 1rem;
152 font-weight: 600;
153 margin: 0 0 0.25rem;
154 letter-spacing: 0.01em;
155 }
156 h1 .mark { display: inline-block; width: 1.25rem; }
157 .zot { color: #7ed3fc; }
158 .rule { border: 0; border-top: 1px solid #ffffff; margin: 1.5rem 0; }
159 .muted { color: #9ca3af; }
160 .mono { font-family: inherit; word-break: break-all; }
161 .msg { padding: 0.75rem 0; }
162 a { color: #7ed3fc; }
163 input[type=password], input[type=text] {
164 width: 100%; padding: 0.5rem 0.6rem;
165 background: #0a0a0a; color: #ffffff;
166 border: 1px solid #ffffff;
167 font-family: inherit; font-size: 0.95rem;
168 }
169 input[type=password]:focus, input[type=text]:focus {
170 outline: none; border-color: #7ed3fc;
171 }
172 button {
173 padding: 0.5rem 1.25rem;
174 background: #ffffff; color: #0a0a0a;
175 border: 1px solid #ffffff; font-family: inherit; font-size: 0.95rem;
176 cursor: pointer;
177 }
178 button:hover { background: #0a0a0a; color: #ffffff; }
179</style>`
180
181// logoTag is the <img> element used at the top of every zot-served
182// page. The image bytes are served from /logo.png by the same server.
183const logoTag = `<img class="logo" src="/logo.png" alt="zot" />`
184
185// serveLogo writes the embedded PNG with appropriate caching headers.
186// Shared between the oauth callback server and the api-key login server.
187func serveLogo(w http.ResponseWriter, r *http.Request) {
188 w.Header().Set("content-type", "image/png")
189 w.Header().Set("cache-control", "public, max-age=86400")
190 _, _ = w.Write(assets.LogoPNG)
191}
192
193func oauthSuccessHTML(provider string) string {
194 p := strings.ToLower(provider)
195 return `<!doctype html><html lang="en"><head><meta charset="utf-8"/><title>zot - logged in</title>` + monoStyle + `</head><body>
196` + logoTag + `
197<h1><span class="mark">✓</span> logged in to ` + p + `</h1>
198<hr class="rule">
199<p class="msg"><span class="zot">zot</span> received the callback. you can close this tab.</p>
200</body></html>`
201}
202
203func oauthErrorHTML(msg string) string {
204 return `<!doctype html><html lang="en"><head><meta charset="utf-8"/><title>zot - error</title>` + monoStyle + `</head><body>
205` + logoTag + `
206<h1><span class="mark">✗</span> login failed</h1>
207<hr class="rule">
208<p class="msg mono">` + htmlEscape(msg) + `</p>
209<p class="muted">go back to <span class="zot">zot</span> and try again.</p>
210</body></html>`
211}
212
213func htmlEscape(s string) string {
214 r := strings.NewReplacer("&", "&", "<", "<", ">", ">", "\"", """)
215 return r.Replace(s)
216}