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.

270 lines · 13.0 KBRaw History
1/** @jsxImportSource hono/jsx */
2import { raw } from "hono/html";
3import type { Child, FC, PropsWithChildren } from "hono/jsx";
4import { requireAccount, requireRecentSignIn, type AppContext } from "../auth/principal.js";
5import { CLI_TOKEN_DAYS, CliLogins, cleanClient, formatUserCode, isB64urlParam, loopbackPort, normalizeUserCode, parseClient, scopeOf, type CliClient } from "../auth/cli-login.js";
6import type { Account, TokenScope } from "../lib/directory.js";
7import { HttpError } from "../lib/util.js";
8import { ICONS } from "./assets.js";
9import { csrfToken, type FormData } from "./csrf.js";
10import { KRadio } from "./kumo.js";
11import { Form, ago, post, render, type WebApp } from "./ui.js";
12
13/**
14 * The pages a person approves a CLI sign-in on (auth/cli-login.ts has the flows):
15 * /login/cli `diffinery login` opened this with its loopback port and PKCE challenge
16 * /login/device type the code a `diffinery login --device` printed
17 * /login/cli/done where the CLI sends the browser once it has its token
18 * Both need a passkey sign-in from the last 2 hours, like creating a token in Settings.
19 */
20
21const SCOPE_ITEMS = [
22 { value: "read", label: "Read", description: "Clone, and view repositories, issues, pull requests and runs." },
23 { value: "write", label: "Write", description: "Also push, open and merge pull requests, comment, and run workflows (up to maintain)." },
24 { value: "admin", label: "Admin", description: "Everything you can do, including settings, secrets and variables." },
25];
26
27const Card: FC<PropsWithChildren<{ title: string }>> = ({ title, children }) => (
28 <div class="authcard box">
29 <div class="body">
30 <div class="authmark" aria-hidden="true">
31 {raw(ICONS.code)}
32 </div>
33 <h1>{title}</h1>
34 {children}
35 </div>
36 </div>
37);
38
39const ClientDetails: FC<{ client: CliClient; from?: { ip: string | null; location: string | null; at: string } }> = ({ client, from }) => (
40 <dl class="kv mb">
41 <dt>Computer</dt>
42 <dd>
43 <b>{client.host}</b>
44 {client.os ? <span class="muted"> · {client.os}</span> : null}
45 </dd>
46 {client.version ? (
47 <>
48 <dt>CLI</dt>
49 <dd>diffinery {client.version}</dd>
50 </>
51 ) : null}
52 {from ? (
53 <>
54 <dt>Requested</dt>
55 <dd>
56 {ago(from.at)}
57 {from.location || from.ip ? <span class="muted"> from {[from.location, from.ip].filter(Boolean).join(" · ")}</span> : null}
58 </dd>
59 </>
60 ) : null}
61 </dl>
62);
63
64/**
65 * Where a device code was asked from, compared with the browser approving it. Device-code phishing
66 * means someone else's code: it was almost always asked for from another network, often another
67 * country, so the page says so plainly.
68 */
69export function placeMismatch(from: { ip: string | null; location: string | null }, here: { ip?: string; country?: string }): "country" | "network" | null {
70 // Stored as "City, CC" or "CC": the country is the two-letter code after the last comma. A city with no code is no country.
71 const last = from.location?.split(",").at(-1)?.trim();
72 const theirs = last && /^[A-Z]{2}$/.test(last) ? last : null;
73 if (theirs && here.country && theirs !== here.country) return "country";
74 if (from.ip && here.ip && from.ip !== here.ip) return "network";
75 return null;
76}
77
78/** A phisher's CLI can ask for any scope, so the page never preselects more than write: admin is always a deliberate pick. */
79export const preselectedScope = (requested: TokenScope): TokenScope => (requested === "admin" ? "write" : requested);
80
81/** The approval form both flows share: the details, a scope, and Authorize or Cancel. */
82const Approve: FC<{ c: AppContext; me: Account; action: string; client: CliClient; scope: TokenScope; hidden: Record<string, string>; from?: { ip: string | null; location: string | null; at: string }; code?: string }> = (p) => {
83 const t = csrfToken(p.c);
84 // Only the edge's request.cf: a client can send a CF-IPCountry header of its choosing.
85 const cf = (p.c.req.raw as { cf?: { country?: string } }).cf;
86 const mismatch = p.from ? placeMismatch(p.from, { ip: p.c.req.header("cf-connecting-ip"), country: cf?.country }) : null;
87 return (
88 <Card title="Authorize the Diffinery CLI">
89 <p class="muted">
90 A Diffinery command-line client wants to sign in as <b>{p.me.login}</b>.
91 </p>
92 {p.code ? (
93 <p>
94 Check that your terminal shows this code: <span class="usercode">{p.code}</span>
95 </p>
96 ) : null}
97 <ClientDetails client={p.client} from={p.from} />
98 {mismatch === "country" ? (
99 <div class="flash error" role="alert">
100 <b>This code was asked for from another country</b> ({p.from!.location}) than the one you're in now. If you didn't run <code>diffinery login --device</code> there yourself, someone may be trying to get into your account: click Cancel.
101 </div>
102 ) : mismatch === "network" ? (
103 <div class="flash warn">This code was asked for from a different network ({p.from!.ip}) than this browser's. That's expected when the computer and this browser are on different connections; if you didn't start it yourself, click Cancel.</div>
104 ) : null}
105 <Form action={p.action} t={t}>
106 {Object.entries(p.hidden).map(([k, v]) => (
107 <input type="hidden" name={k} value={v} />
108 ))}
109 <div class="field">
110 <label>Access</label>
111 <KRadio name="scope" label="Access" value={preselectedScope(p.scope)} items={SCOPE_ITEMS} />
112 {p.scope === "admin" ? <div class="hint">The CLI asked for Admin. Choose it above only if you want this computer to change settings, secrets and variables.</div> : null}
113 </div>
114 <p class="muted small">
115 The CLI gets its own token, valid for {CLI_TOKEN_DAYS} days. It's listed under <a href="/settings/tokens">Settings → Access tokens</a>, where you can revoke it, and <code>diffinery logout</code> revokes it too.
116 </p>
117 <div class="flash warn small">Only authorize a sign-in you started yourself by running <code>diffinery login</code>. Never approve one because someone asked you to.</div>
118 <div class="row">
119 <button class="btn primary grow" name="decision" value="approve">
120 Authorize
121 </button>
122 <button class="btn" name="decision" value="deny">
123 Cancel
124 </button>
125 </div>
126 </Form>
127 </Card>
128 );
129};
130
131const Done: FC<{ title: string; children: Child }> = ({ title, children }) => <Card title={title}>{children}</Card>;
132
133/** The browser flow's parameters, validated; null when anything is missing or malformed. */
134function browserParams(get: (k: string) => string | undefined): { port: number; state: string; challenge: string; client: CliClient; scope: TokenScope } | null {
135 const port = loopbackPort(get("port"));
136 const state = get("state");
137 const challenge = get("code_challenge");
138 const method = get("code_challenge_method");
139 if (!port || !isB64urlParam(state) || !isB64urlParam(challenge, 43) || (method !== undefined && method !== "" && method !== "S256")) return null;
140 return { port, state, challenge, client: cleanClient({ host: get("host"), os: get("os"), version: get("version") }), scope: scopeOf(get("scope")) };
141}
142
143/** The CLI's callback on this computer. Only 127.0.0.1, so the code never leaves the machine. */
144const callback = (port: number, q: Record<string, string>) => `http://127.0.0.1:${port}/callback?${new URLSearchParams(q)}`;
145
146function person(c: AppContext, next: string): Account {
147 const me = requireAccount(c);
148 if (me.kind !== "user") throw new HttpError(403, "only people can sign the CLI in; agents use their own tokens");
149 requireRecentSignIn(c, next);
150 return me;
151}
152
153const badLink = (c: AppContext) =>
154 render(
155 c,
156 { title: "Sign-in link incomplete", status: 400 },
157 <Done title="This sign-in link is incomplete">
158 <p class="muted">
159 Run <code>diffinery login</code> again and use the page it opens.
160 </p>
161 </Done>,
162 );
163
164const DeviceForm: FC<{ c: AppContext; error?: string; value?: string }> = ({ c, error, value }) => (
165 <Card title="Sign in the Diffinery CLI">
166 <p class="muted">
167 Enter the code your terminal shows after <code>diffinery login --device</code>.
168 </p>
169 {error ? (
170 <div class="flash error" role="alert">
171 {error}
172 </div>
173 ) : null}
174 <Form action="/login/device" t={csrfToken(c)}>
175 <div class="field">
176 <label for="code">Code</label>
177 <input id="code" name="code" type="text" class="usercode-input" required autocomplete="off" autocapitalize="characters" spellcheck={false} maxlength={12} placeholder="XXXX-XXXX" value={value ?? ""} autofocus />
178 </div>
179 <button class="btn primary block">Continue</button>
180 </Form>
181 </Card>
182);
183
184export function registerCliLogin(web: WebApp): void {
185 // ---------------------------------------------------------------- browser flow
186
187 web.get("/login/cli", async (c) => {
188 const q = browserParams((k) => c.req.query(k));
189 if (!q) return badLink(c);
190 const url = new URL(c.req.url);
191 const me = person(c, url.pathname + url.search);
192 return render(
193 c,
194 // Authorize leads the browser to the CLI's callback on this computer.
195 { title: "Authorize the Diffinery CLI", formAction: `http://127.0.0.1:${q.port}` },
196 <Approve c={c} me={me} action="/login/cli" client={q.client} scope={q.scope} hidden={{ port: String(q.port), state: q.state, code_challenge: q.challenge, host: q.client.host, os: q.client.os, version: q.client.version }} />,
197 );
198 });
199
200 post(web, "/login/cli", async (c, f: FormData) => {
201 const q = browserParams((k) => f.s(k));
202 if (!q) return badLink(c);
203 const back = `/login/cli?${new URLSearchParams({ port: String(q.port), state: q.state, code_challenge: q.challenge, code_challenge_method: "S256", scope: q.scope, host: q.client.host, os: q.client.os, version: q.client.version })}`;
204 const me = person(c, back);
205 if (f.s("decision") !== "approve") return c.redirect(callback(q.port, { error: "access_denied", state: q.state }), 303);
206 const code = await new CliLogins(c.env, c.get("dir")).approveBrowser(me, q.client, scopeOf(f.s("scope"), q.scope), q.challenge);
207 return c.redirect(callback(q.port, { code, state: q.state }), 303);
208 });
209
210 // Where the CLI sends the browser when it's done; it says which outcome, nothing else.
211 web.get("/login/cli/done", (c) => {
212 const result = c.req.query("result");
213 if (result === "ok") {
214 return render(
215 c,
216 { title: "CLI signed in" },
217 <Done title="You're signed in">
218 <p class="muted">The Diffinery CLI is signed in. You can close this tab and go back to your terminal.</p>
219 </Done>,
220 );
221 }
222 const denied = result === "denied";
223 return render(
224 c,
225 { title: denied ? "Sign-in cancelled" : "Sign-in failed" },
226 <Done title={denied ? "Sign-in cancelled" : "The CLI couldn't finish signing in"}>
227 <p class="muted">{denied ? "Nothing was signed in. You can close this tab." : "Go back to your terminal for the details, then run diffinery login again."}</p>
228 </Done>,
229 );
230 });
231
232 // ---------------------------------------------------------------- device flow
233
234 web.get("/login/device", async (c) => {
235 person(c, "/login/device");
236 return render(c, { title: "Sign in the Diffinery CLI" }, <DeviceForm c={c} />);
237 });
238
239 post(web, "/login/device", async (c, f) => {
240 const me = person(c, "/login/device");
241 const typed = f.s("code").trim();
242 const row = normalizeUserCode(typed) ? await new CliLogins(c.env, c.get("dir")).pendingDevice(typed) : null;
243 if (!row) return render(c, { title: "Sign in the Diffinery CLI", status: 404 }, <DeviceForm c={c} value={typed.slice(0, 12)} error="That code isn't valid or has expired. Check the code in your terminal, or run diffinery login --device again." />);
244 return render(
245 c,
246 { title: "Authorize the Diffinery CLI" },
247 <Approve c={c} me={me} action="/login/device/decide" client={parseClient(row.client)} scope={scopeOf(row.requested_scope)} hidden={{ id: row.id }} from={{ ip: row.ip, location: row.location, at: row.created_at }} code={formatUserCode(row.user_code ?? "")} />,
248 );
249 });
250
251 post(web, "/login/device/decide", async (c, f) => {
252 const me = person(c, "/login/device");
253 const approve = f.s("decision") === "approve";
254 const ok = await new CliLogins(c.env, c.get("dir")).decideDevice(f.s("id"), me, approve, scopeOf(f.s("scope")));
255 if (!ok) return render(c, { title: "Sign in the Diffinery CLI", status: 410 }, <DeviceForm c={c} error="That sign-in expired or was already answered. Run diffinery login --device again." />);
256 return render(
257 c,
258 { title: approve ? "CLI signed in" : "Sign-in cancelled" },
259 approve ? (
260 <Done title="You're signed in">
261 <p class="muted">The Diffinery CLI will finish signing in within a few seconds. You can close this tab.</p>
262 </Done>
263 ) : (
264 <Done title="Sign-in cancelled">
265 <p class="muted">The CLI was not signed in. You can close this tab.</p>
266 </Done>
267 ),
268 );
269 });
270}