Diffinery

codician/diffinerypublic

Open-source, agent-first GitHub and GitHub Actions alternative that runs on your own Cloudflare account: git hosting, pull requests, code review and drop-in GitHub Actions YAML on Workers, Durable Objects, R2 and Containers.

179 lines · 8.6 KBRaw History
1import { spawn } from "node:child_process";
2import { createHash, randomBytes } from "node:crypto";
3import { createServer, type Server } from "node:http";
4import type { AddressInfo } from "node:net";
5import { arch, hostname, platform } from "node:os";
6import { Api } from "./api.js";
7import type { Ctx } from "./cli.js";
8import { CliError, type Io } from "./io.js";
9import { VERSION } from "./version.js";
10
11/**
12 * Signing in through the browser, so nobody copies a token by hand (the server side is
13 * apps/diffinery/src/auth/cli-login.ts):
14 *
15 * - browserLogin: listen on 127.0.0.1, open /login/cli with a PKCE challenge, and wait for the
16 * browser to come back to /callback with a one-time code; trade it, with the verifier only this
17 * process knows, for a token.
18 * - deviceLogin: for machines without a browser. Print a short code to type on /login/device in
19 * any browser, and poll until the person approves.
20 */
21
22export type Scope = "read" | "write" | "admin";
23
24export interface TokenGrant {
25 access_token: string;
26 scope: Scope;
27 expires_at: string;
28 login: string;
29}
30
31interface TokenError {
32 error: string;
33 error_description?: string;
34}
35
36/** How long to wait for the person before giving up. */
37const BROWSER_TIMEOUT_MS = 10 * 60_000;
38
39const b64url = (b: Buffer) => b.toString("base64url");
40
41/** What the authorize page shows about this computer, and what the token is named after. */
42export function clientInfo(): { host: string; os: string; version: string } {
43 return { host: hostname().replace(/\.local$/, "") || "unknown host", os: `${platform()}-${arch()}`, version: VERSION };
44}
45
46/**
47 * Opens `url` in the default browser: `BROWSER` when set (`BROWSER=none` opens nothing), else
48 * open (macOS), rundll32 (Windows, which avoids cmd's handling of `&`) or xdg-open. True when a
49 * browser command started; the caller prints the URL either way.
50 */
51export function openBrowser(io: Io, url: string): Promise<boolean> {
52 if (io.openUrl) return io.openUrl(url);
53 const pref = io.env.BROWSER?.trim();
54 if (pref === "none") return Promise.resolve(false);
55 const [cmd, args] = pref ? [pref, [url]] : process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["rundll32", ["url.dll,FileProtocolHandler", url]] : ["xdg-open", [url]];
56 return new Promise((resolve) => {
57 try {
58 const child = spawn(cmd, args, { stdio: "ignore", detached: true });
59 child.once("error", () => resolve(false));
60 child.once("spawn", () => {
61 child.unref();
62 resolve(true);
63 });
64 } catch {
65 resolve(false);
66 }
67 });
68}
69
70/** Whether a browser on this machine can reach a server on this machine's 127.0.0.1. */
71export function browserLikely(io: Io): boolean {
72 if (io.openUrl) return true;
73 if (io.env.BROWSER?.trim() && io.env.BROWSER.trim() !== "none") return true;
74 // Over SSH the browser runs on another computer, where 127.0.0.1 is not this one.
75 if (io.env.SSH_CONNECTION || io.env.SSH_TTY) return false;
76 if (process.platform === "linux" || process.platform === "freebsd" || process.platform === "openbsd") return !!(io.env.DISPLAY || io.env.WAYLAND_DISPLAY);
77 return true;
78}
79
80async function exchange(url: string, body: Record<string, string>): Promise<TokenGrant | TokenError> {
81 const res = await new Api(url, null).raw<TokenGrant | TokenError>("POST", "/-/cli/token", { body, allow: [400] });
82 return res.data;
83}
84
85const isGrant = (v: TokenGrant | TokenError): v is TokenGrant => typeof (v as TokenGrant).access_token === "string";
86
87const page = (title: string, text: string) =>
88 `<!doctype html><meta charset="utf-8"><title>${title}</title><body style="font:16px system-ui,sans-serif;max-width:32rem;margin:15vh auto;padding:0 1rem"><h1 style="font-size:1.4rem">${title}</h1><p>${text}</p></body>`;
89
90export async function browserLogin(ctx: Ctx, url: string, scope: Scope): Promise<TokenGrant> {
91 const verifier = b64url(randomBytes(32));
92 const challenge = b64url(createHash("sha256").update(verifier).digest());
93 const state = b64url(randomBytes(24));
94 let settle!: { ok: (g: TokenGrant) => void; fail: (e: Error) => void };
95 const done = new Promise<TokenGrant>((ok, fail) => (settle = { ok, fail }));
96 done.catch(() => {}); // awaited below; this only keeps an early failure from counting as unhandled
97 let busy = false;
98 const server: Server = createServer(async (req, res) => {
99 const u = new URL(req.url ?? "/", "http://127.0.0.1");
100 if (req.method !== "GET" || u.pathname !== "/callback") {
101 res.writeHead(404, { "content-type": "text/plain" }).end("not found");
102 return;
103 }
104 // Only the browser this process sent out carries its state; anything else is ignored.
105 if (u.searchParams.get("state") !== state || busy) {
106 res.writeHead(400, { "content-type": "text/html; charset=utf-8" }).end(page("Not this sign-in", "This link doesn't belong to the <code>diffinery login</code> that is waiting. Run it again if you need to."));
107 return;
108 }
109 busy = true;
110 const back = (result: string) => res.writeHead(302, { location: `${url}/login/cli/done?result=${result}`, "cache-control": "no-store" }).end();
111 const error = u.searchParams.get("error");
112 if (error) {
113 back(error === "access_denied" ? "denied" : "failed");
114 return settle.fail(new CliError(error === "access_denied" ? "the sign-in was cancelled in the browser" : `the browser returned an error: ${error}`));
115 }
116 try {
117 const got = await exchange(url, { grant_type: "authorization_code", code: u.searchParams.get("code") ?? "", code_verifier: verifier });
118 if (!isGrant(got)) throw new CliError(`sign-in failed: ${got.error_description ?? got.error}`);
119 back("ok");
120 settle.ok(got);
121 } catch (e) {
122 back("failed");
123 settle.fail(e instanceof Error ? e : new Error(String(e)));
124 }
125 });
126 await new Promise<void>((ok, fail) => {
127 server.once("error", fail);
128 server.listen(0, "127.0.0.1", () => ok());
129 });
130 const port = (server.address() as AddressInfo).port;
131 const client = clientInfo();
132 const authorize = `${url}/login/cli?${new URLSearchParams({ port: String(port), state, code_challenge: challenge, code_challenge_method: "S256", scope, host: client.host, os: client.os, version: client.version })}`;
133 const timer = setTimeout(() => settle.fail(new CliError("timed out waiting for the browser; run `diffinery login` again")), BROWSER_TIMEOUT_MS);
134 try {
135 const opened = await openBrowser(ctx.io, authorize);
136 ctx.err(opened ? `Opening your browser to sign in to ${ctx.c.bold(new URL(url).host)}…` : `Open this link in a browser on this computer to sign in:`);
137 ctx.err(opened ? ctx.c.dim(`If it didn't open, visit:\n${authorize}`) : authorize);
138 ctx.err(ctx.c.dim("Waiting for you to authorize (Ctrl+C to cancel). No browser here? Run `diffinery login --device`."));
139 return await done;
140 } finally {
141 clearTimeout(timer);
142 server.closeAllConnections();
143 server.close();
144 }
145}
146
147export async function deviceLogin(ctx: Ctx, url: string, scope: Scope): Promise<TokenGrant> {
148 const api = new Api(url, null);
149 const d = await api.post<{ device_code: string; user_code: string; verification_uri: string; expires_in: number; interval: number }>("/-/cli/device", { client: clientInfo(), scope });
150 ctx.err(`Your one-time code: ${ctx.c.bold(d.user_code)}`);
151 ctx.err(`Open ${ctx.c.bold(d.verification_uri)} in a browser on any device, sign in, and enter the code.`);
152 ctx.err(ctx.c.dim("Waiting for you to approve (Ctrl+C to cancel)…"));
153 let interval = Math.max(1, d.interval) * 1000;
154 const end = Date.now() + d.expires_in * 1000;
155 while (Date.now() < end) {
156 await ctx.io.sleep(interval);
157 const got = await exchange(url, { grant_type: "urn:ietf:params:oauth:grant-type:device_code", device_code: d.device_code });
158 if (isGrant(got)) return got;
159 if (got.error === "authorization_pending") continue;
160 if (got.error === "slow_down") {
161 interval += 5000;
162 continue;
163 }
164 if (got.error === "access_denied") throw new CliError("the sign-in was cancelled in the browser");
165 if (got.error === "expired_token") break;
166 throw new CliError(`sign-in failed: ${got.error_description ?? got.error}`);
167 }
168 throw new CliError("the code expired; run `diffinery login --device` again");
169}
170
171/** `diffinery logout`: a token a CLI sign-in created is revoked with itself. False for any other token, or when the server can't be reached. */
172export async function revokeCliToken(url: string, token: string): Promise<boolean> {
173 try {
174 const r = await new Api(url, token).raw<{ revoked?: boolean }>("DELETE", "/-/cli/token", { allow: [401, 403, 404] });
175 return r.status === 200 && r.data?.revoked === true;
176 } catch {
177 return false;
178 }
179}