v0.15.0 6b178f8

callback.go RawHistoryFolder

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("&", "&amp;", "<", "&lt;", ">", "&gt;", "\"", "&quot;")
216	return r.Replace(s)
217}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close