oauth.ts252 lines · main
1import { randomBytes, randomInt } from 'node:crypto';
2import { createServer, type Server } from 'node:http';
3import { hostname } from 'node:os';
4
5import { writeUserCredential } from './config.js';
6
7interface ResultOk {
8 ok: true;
9 token: string;
10}
11interface ResultErr {
12 ok: false;
13 reason: string;
14}
15export type CallbackResult = ResultOk | ResultErr;
16
17export function generateState(): string {
18 return randomBytes(32).toString('hex');
19}
20
21export function isLoopback(url: string): boolean {
22 try {
23 const u = new URL(url);
24 if (u.protocol !== 'http:') return false;
25 return u.hostname === '127.0.0.1' || u.hostname === 'localhost';
26 } catch {
27 return false;
28 }
29}
30
31export function handleCallback(req: Request, expectedState: string): CallbackResult {
32 const url = new URL(req.url);
33 const state = url.searchParams.get('state') ?? '';
34 if (state !== expectedState) return { ok: false, reason: 'state mismatch' };
35 if (url.searchParams.get('denied') === '1') return { ok: false, reason: 'user denied' };
36 const token = url.searchParams.get('token');
37 if (!token) return { ok: false, reason: 'no token in callback' };
38 return { ok: true, token };
39}
40
41export interface OAuthOptions {
42 apiOrigin: string;
43 dashboardOrigin: string;
44 /** Override for tests — when set, do NOT actually open a browser. */
45 openBrowser?: (url: string) => Promise<void>;
46 /** ms before throwing — defaults to 180s. */
47 timeoutMs?: number;
48}
49
50export interface OAuthSuccess {
51 token: string;
52 apiOrigin: string;
53}
54
55/**
56 * Runs the full OAuth handshake. Resolves with the captured token on
57 * success; throws on timeout / denial. Persists nothing on its own —
58 * caller decides whether to writeUserCredential().
59 */
60export async function runOAuth(opts: OAuthOptions): Promise<OAuthSuccess> {
61 const state = generateState();
62 const port = await bindFreePort();
63 const dashUrl = new URL('/cli-auth', opts.dashboardOrigin);
64 dashUrl.searchParams.set('redirect', `http://127.0.0.1:${port}/cb`);
65 dashUrl.searchParams.set('state', state);
66 dashUrl.searchParams.set('host', hostname());
67
68 const opener = opts.openBrowser ?? defaultOpen;
69 const captured: { token?: string; error?: string } = {};
70 const server = await startServer(port, (req) => {
71 const result = handleCallback(req, state);
72 if (result.ok) {
73 captured.token = result.token;
74 return htmlResponse(200, {
75 title: 'CLI authorized',
76 heading: "you're in",
77 body: 'The briven CLI on this computer is authorized. Return to the terminal — you can close this tab.',
78 ok: true,
79 });
80 }
81 captured.error = result.reason;
82 return htmlResponse(400, {
83 title: 'CLI authorization failed',
84 heading: 'not authorized',
85 body: `Authorization failed: ${result.reason}. Close this tab and run the CLI again.`,
86 ok: false,
87 });
88 });
89
90 try {
91 await opener(dashUrl.toString());
92 } catch {
93 // best-effort; user can still copy URL from stdout
94 }
95 process.stdout.write(`\nOpened ${dashUrl.toString()}\nWaiting for authorization…\n`);
96
97 const timeoutMs = opts.timeoutMs ?? 180_000;
98 const deadline = Date.now() + timeoutMs;
99 while (!captured.token && !captured.error && Date.now() < deadline) {
100 await new Promise((r) => setTimeout(r, 200));
101 }
102 server.close();
103 if (captured.token) return { token: captured.token, apiOrigin: opts.apiOrigin };
104 if (captured.error) throw new Error(`oauth: ${captured.error}`);
105 throw new Error('oauth: timed out waiting for callback');
106}
107
108/** Dark Briven-styled local callback page (localhost after Allow). */
109function htmlResponse(
110 status: number,
111 content: { title: string; heading: string; body: string; ok: boolean },
112): Response {
113 const accent = content.ok ? '#FFFD74' : '#f87171';
114 const body = `<!doctype html>
115<html lang="en">
116<head>
117 <meta charset="utf-8" />
118 <meta name="viewport" content="width=device-width, initial-scale=1" />
119 <meta name="color-scheme" content="dark" />
120 <title>${escapeHtml(content.title)} · briven</title>
121 <style>
122 :root { color-scheme: dark; }
123 * { box-sizing: border-box; }
124 body {
125 margin: 0; min-height: 100dvh; display: flex; align-items: center; justify-content: center;
126 font: 14px/1.5 ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
127 background: #0a0b0d; color: #e8e8ea; padding: 1.5rem;
128 }
129 .card {
130 width: 100%; max-width: 26rem; border: 1px solid #2a2c32; border-radius: 10px;
131 background: #12141a; padding: 1.5rem;
132 }
133 .brand { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 1.25rem;
134 color: #9a9ca3; font-size: 12px; letter-spacing: 0.04em; text-transform: lowercase; }
135 .dot { width: 8px; height: 8px; border-radius: 999px; background: ${accent}; }
136 h1 { margin: 0 0 0.5rem; font-size: 1.15rem; font-weight: 500; color: #f4f4f5; }
137 p { margin: 0 0 1.25rem; color: #9a9ca3; font-size: 13px; }
138 button {
139 width: 100%; border: 0; border-radius: 8px; padding: 0.7rem 1rem; cursor: pointer;
140 font: inherit; font-size: 13px; background: ${accent}; color: #111;
141 }
142 button:hover { filter: brightness(1.05); }
143 button:disabled { opacity: 0.7; cursor: default; }
144 .hint { margin-top: 0.85rem; font-size: 11px; color: #6b6e76; text-align: center; }
145 kbd {
146 display: inline-block; padding: 0.1em 0.4em; border: 1px solid #3a3d45; border-radius: 4px;
147 background: #1a1c22; font: inherit; font-size: 11px; color: #e8e8ea;
148 }
149 </style>
150</head>
151<body>
152 <main class="card" id="card">
153 <div class="brand"><span class="dot" aria-hidden="true"></span>briven · cli</div>
154 <h1 id="heading">${escapeHtml(content.heading)}</h1>
155 <p id="body">${escapeHtml(content.body)}</p>
156 <button type="button" id="close-btn">done — close this tab</button>
157 <p class="hint" id="hint">
158 tip: browsers often block auto-close. use the tab × or
159 <kbd>⌘W</kbd> / <kbd>Ctrl+W</kbd> — the terminal already finished.
160 </p>
161 </main>
162 <script>
163 (function () {
164 var btn = document.getElementById('close-btn');
165 var heading = document.getElementById('heading');
166 var body = document.getElementById('body');
167 var hint = document.getElementById('hint');
168 if (!btn) return;
169
170 function showManualCloseHelp() {
171 if (heading) heading.textContent = "you're done";
172 if (body) {
173 body.innerHTML =
174 'Login already succeeded in the terminal. ' +
175 'This tab cannot close itself (browser rule). ' +
176 'Press <kbd>⌘W</kbd> (Mac) or <kbd>Ctrl+W</kbd> (Windows/Linux), or click the tab ×.';
177 }
178 btn.textContent = 'ok — use ⌘W / Ctrl+W to close';
179 btn.disabled = true;
180 if (hint) hint.textContent = 'safe to ignore this page — briven is already connected';
181 }
182
183 function tryClose() {
184 // Tabs opened by redirect (not window.open) are usually not closable by script.
185 // That is a browser security rule — not a Briven bug.
186 try { window.close(); } catch (e) {}
187 try {
188 window.open('', '_self');
189 window.close();
190 } catch (e2) {}
191 }
192
193 btn.addEventListener('click', function () {
194 tryClose();
195 // If we're still here, the browser blocked close — explain clearly.
196 setTimeout(function () {
197 if (!window.closed) showManualCloseHelp();
198 }, 150);
199 });
200 })();
201 </script>
202</body>
203</html>`;
204 return new Response(body, {
205 status,
206 headers: { 'content-type': 'text/html; charset=utf-8' },
207 });
208}
209
210function escapeHtml(s: string): string {
211 return s
212 .replace(/&/g, '&amp;')
213 .replace(/</g, '&lt;')
214 .replace(/>/g, '&gt;')
215 .replace(/"/g, '&quot;');
216}
217
218async function bindFreePort(): Promise<number> {
219 for (let attempt = 0; attempt < 5; attempt += 1) {
220 const port = randomInt(20000, 60000);
221 if (await isPortFree(port)) return port;
222 }
223 throw new Error('oauth: could not find a free localhost port after 5 attempts');
224}
225
226function isPortFree(port: number): Promise<boolean> {
227 return new Promise((resolve) => {
228 const srv = createServer();
229 srv.once('error', () => resolve(false));
230 srv.listen(port, '127.0.0.1', () => srv.close(() => resolve(true)));
231 });
232}
233
234function startServer(port: number, handler: (req: Request) => Response): Promise<Server> {
235 return new Promise((resolve) => {
236 const srv = createServer(async (req, res) => {
237 const url = `http://127.0.0.1:${port}${req.url ?? '/'}`;
238 const r = handler(new Request(url, { method: req.method ?? 'GET' }));
239 res.writeHead(r.status, Object.fromEntries(r.headers.entries()));
240 res.end(await r.text());
241 });
242 srv.listen(port, '127.0.0.1', () => resolve(srv));
243 });
244}
245
246async function defaultOpen(url: string): Promise<void> {
247 const open = (await import('open')).default;
248 await open(url);
249}
250
251/** Re-export so callers can persist the token after wizard logic. */
252export { writeUserCredential };