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