oauth.ts137 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, 'Authorized. You can close this tab.');
75 }
76 captured.error = result.reason;
77 return htmlResponse(400, `Authorization failed: ${result.reason}.`);
78 });
79
80 try {
81 await opener(dashUrl.toString());
82 } catch {
83 // best-effort; user can still copy URL from stdout
84 }
85 process.stdout.write(`\nOpened ${dashUrl.toString()}\nWaiting for authorization…\n`);
86
87 const timeoutMs = opts.timeoutMs ?? 180_000;
88 const deadline = Date.now() + timeoutMs;
89 while (!captured.token && !captured.error && Date.now() < deadline) {
90 await new Promise((r) => setTimeout(r, 200));
91 }
92 server.close();
93 if (captured.token) return { token: captured.token, apiOrigin: opts.apiOrigin };
94 if (captured.error) throw new Error(`oauth: ${captured.error}`);
95 throw new Error('oauth: timed out waiting for callback');
96}
97
98function htmlResponse(status: number, msg: string): Response {
99 const body = `<!doctype html><meta charset=utf-8><title>briven cli</title><body style="font:14px/1.4 system-ui;padding:2em">${msg}</body>`;
100 return new Response(body, { status, headers: { 'content-type': 'text/html' } });
101}
102
103async function bindFreePort(): Promise<number> {
104 for (let attempt = 0; attempt < 5; attempt += 1) {
105 const port = randomInt(20000, 60000);
106 if (await isPortFree(port)) return port;
107 }
108 throw new Error('oauth: could not find a free localhost port after 5 attempts');
109}
110
111function isPortFree(port: number): Promise<boolean> {
112 return new Promise((resolve) => {
113 const srv = createServer();
114 srv.once('error', () => resolve(false));
115 srv.listen(port, '127.0.0.1', () => srv.close(() => resolve(true)));
116 });
117}
118
119function startServer(port: number, handler: (req: Request) => Response): Promise<Server> {
120 return new Promise((resolve) => {
121 const srv = createServer(async (req, res) => {
122 const url = `http://127.0.0.1:${port}${req.url ?? '/'}`;
123 const r = handler(new Request(url, { method: req.method ?? 'GET' }));
124 res.writeHead(r.status, Object.fromEntries(r.headers.entries()));
125 res.end(await r.text());
126 });
127 srv.listen(port, '127.0.0.1', () => resolve(srv));
128 });
129}
130
131async function defaultOpen(url: string): Promise<void> {
132 const open = (await import('open')).default;
133 await open(url);
134}
135
136/** Re-export so callers can persist the token after wizard logic. */
137export { writeUserCredential };