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.

310 lines · 19.7 KBRaw History
1/**
2 * Signing the CLI in through the browser (auth/cli-login.ts): the loopback flow with PKCE, the device
3 * code flow, single use, expiry, denial, the recent sign-in rule, and `diffinery logout` revoking only
4 * CLI tokens. Runs the real router over node:sqlite with every migration.
5 */
6import { createHash, randomBytes } from "node:crypto";
7import { describe, expect, it } from "vitest";
8import { app } from "../../src/app.js";
9import { web } from "../../src/web/routes.js";
10import type { Env } from "../../src/env.js";
11import { cleanClient, formatUserCode, normalizeUserCode, pkceChallenge } from "../../src/auth/cli-login.js";
12import { placeMismatch, preselectedScope } from "../../src/web/cli-login.js";
13import { bucketsFor } from "../../src/lib/rate-limit.js";
14import { testD1 } from "./stubs/d1.js";
15
16app.route("/", web);
17
18const ORIGIN = "https://diffinery.example";
19const hash = (s: string) => createHash("sha256").update(s).digest("hex");
20const CSRF = "csrf-token-0123456789abcdef";
21
22function setup() {
23 const DB = testD1();
24 const at = new Date().toISOString();
25 const old = new Date(Date.now() - 5 * 3600_000).toISOString();
26 const later = new Date(Date.now() + 30 * 86400_000).toISOString();
27 DB.sqlite.exec(`INSERT INTO accounts (id, login, kind, name, email, status, created_at) VALUES ('u-ana', 'ana', 'user', 'Ana', 'ana@example.com', 'active', '${at}')`);
28 DB.sqlite.exec(`INSERT INTO sessions (id, account_id, method, created_at, last_seen_at, expires_at) VALUES ('${hash("ana-fresh-session-0000000000001")}', 'u-ana', 'passkey', '${at}', '${at}', '${later}')`);
29 DB.sqlite.exec(`INSERT INTO sessions (id, account_id, method, created_at, last_seen_at, expires_at) VALUES ('${hash("ana-stale-session-0000000000002")}', 'u-ana', 'passkey', '${old}', '${at}', '${later}')`);
30 DB.sqlite.exec(`INSERT INTO tokens (id, account_id, name, token_hash, prefix, scopes, expires_at, created_at) VALUES ('t-ci', 'u-ana', 'ci', '${hash("dfy_ana_ci")}', 'dfy_ana_ci', '["write"]', '${later}', '${at}')`);
31 const env = { PUBLIC_URL: ORIGIN, DB, BOOTSTRAP_ADMINS: "" } as unknown as Env;
32 const waits: Promise<unknown>[] = [];
33 const ctx = { waitUntil: (p: Promise<unknown>) => void waits.push(p), passThroughOnException() {}, props: {} } as unknown as ExecutionContext;
34 // `cf` stands in for the edge's request.cf, which a Worker gets and a client can't set.
35 const fetch = async (path: string, { cf, ...init }: RequestInit & { cf?: Record<string, string> } = {}) => {
36 const req = new Request(`${ORIGIN}${path}`, { redirect: "manual", ...init });
37 if (cf) Object.defineProperty(req, "cf", { value: cf });
38 const res = await app.fetch(req, env, ctx);
39 await Promise.allSettled(waits);
40 return res;
41 };
42 const browser = (session = "ana-fresh-session-0000000000001") => ({
43 get: (path: string) => fetch(path, { headers: { cookie: `__Host-dfy_session=${session}; dfy_csrf=${CSRF}` } }),
44 post: (path: string, form: Record<string, string>) => {
45 const body = new URLSearchParams({ _csrf: CSRF, ...form }).toString();
46 return fetch(path, { method: "POST", headers: { cookie: `__Host-dfy_session=${session}; dfy_csrf=${CSRF}`, "content-type": "application/x-www-form-urlencoded", "content-length": String(Buffer.byteLength(body)), origin: ORIGIN, "sec-fetch-site": "same-origin" }, body });
47 },
48 });
49 const cli = (path: string, body: unknown, method = "POST", token?: string) => fetch(path, { method, headers: { "content-type": "application/json", ...(token ? { authorization: `Bearer ${token}` } : {}) }, body: method === "DELETE" ? undefined : JSON.stringify(body) });
50 const tokens = () => DB.sqlite.prepare("SELECT id, name, scopes, client, revoked_at FROM tokens WHERE account_id = 'u-ana' ORDER BY created_at").all() as Array<{ id: string; name: string; scopes: string; client: string | null; revoked_at: string | null }>;
51 return { DB, fetch, browser, cli, tokens };
52}
53
54/** What the CLI does before opening the browser. */
55async function pkce() {
56 const verifier = randomBytes(32).toString("base64url");
57 const state = randomBytes(16).toString("base64url");
58 return { verifier, state, challenge: await pkceChallenge(verifier) };
59}
60
61const authorizeUrl = (p: { state: string; challenge: string }, extra: Record<string, string> = {}) =>
62 `/login/cli?${new URLSearchParams({ port: "53123", state: p.state, code_challenge: p.challenge, code_challenge_method: "S256", scope: "write", host: "ana-laptop", os: "darwin-arm64", version: "0.1.0", ...extra })}`;
63
64describe("CLI sign-in in the browser", () => {
65 it("shows who is asking, and sends the approval to the CLI's callback on 127.0.0.1 only", async () => {
66 const t = setup();
67 const p = await pkce();
68 const page = await t.browser().get(authorizeUrl(p));
69 expect(page.status).toBe(200);
70 const html = await page.text();
71 expect(html).toContain("Authorize the Diffinery CLI");
72 expect(html).toContain("ana-laptop");
73 expect(html).toContain("darwin-arm64");
74 // The page's forms may lead to the CLI's port, nowhere else.
75 expect(page.headers.get("content-security-policy")).toContain("form-action 'self' http://127.0.0.1:53123;");
76 expect(page.headers.get("x-frame-options")).toBe("DENY");
77
78 const res = await t.browser().post("/login/cli", { port: "53123", state: p.state, code_challenge: p.challenge, host: "ana-laptop", os: "darwin-arm64", version: "0.1.0", scope: "admin", decision: "approve" });
79 expect(res.status).toBe(303);
80 const to = new URL(res.headers.get("location")!);
81 expect(to.origin).toBe("http://127.0.0.1:53123");
82 expect(to.pathname).toBe("/callback");
83 expect(to.searchParams.get("state")).toBe(p.state);
84 const code = to.searchParams.get("code")!;
85 expect(code.length).toBeGreaterThan(40);
86 // Nothing is minted until the CLI trades the code.
87 expect(t.tokens().filter((k) => k.client === "cli")).toHaveLength(0);
88
89 // The code alone is useless: a wrong verifier burns it.
90 const wrong = await t.cli("/-/cli/token", { grant_type: "authorization_code", code, code_verifier: randomBytes(32).toString("base64url") });
91 expect(wrong.status).toBe(400);
92 expect((await wrong.json()) as unknown).toMatchObject({ error: "invalid_grant" });
93 const after = await t.cli("/-/cli/token", { grant_type: "authorization_code", code, code_verifier: p.verifier });
94 expect(((await after.json()) as { error: string }).error).toBe("invalid_grant");
95 expect(t.tokens().filter((k) => k.client === "cli")).toHaveLength(0);
96 });
97
98 it("trades the code and verifier for a CLI token once, with the scope the person chose", async () => {
99 const t = setup();
100 const p = await pkce();
101 const res = await t.browser().post("/login/cli", { port: "53123", state: p.state, code_challenge: p.challenge, host: "ana-laptop", os: "linux-x64", version: "0.1.0", scope: "read", decision: "approve" });
102 const code = new URL(res.headers.get("location")!).searchParams.get("code")!;
103 const ok = await t.cli("/-/cli/token", { grant_type: "authorization_code", code, code_verifier: p.verifier });
104 expect(ok.status).toBe(200);
105 expect(ok.headers.get("cache-control")).toBe("no-store");
106 const body = (await ok.json()) as { access_token: string; scope: string; login: string; expires_at: string };
107 // Letters and digits after the prefix, so `gh auth status` masks all of it.
108 expect(body.access_token).toMatch(/^dfy_[A-Za-z0-9]{40}$/);
109 expect(body).toMatchObject({ scope: "read", login: "ana" });
110 expect(Date.parse(body.expires_at) - Date.now()).toBeGreaterThan(89 * 86400_000);
111 const cli = t.tokens().find((k) => k.client === "cli")!;
112 expect(cli).toMatchObject({ name: "Diffinery CLI on ana-laptop", scopes: '["read"]', revoked_at: null });
113 // The token works, as the person, with its scope.
114 const me = await t.fetch("/api/v3/user", { headers: { authorization: `Bearer ${body.access_token}` } });
115 expect(((await me.json()) as { login: string }).login).toBe("ana");
116 // Single use.
117 const again = await t.cli("/-/cli/token", { grant_type: "authorization_code", code, code_verifier: p.verifier });
118 expect(((await again.json()) as { error: string }).error).toBe("invalid_grant");
119 // It shows up in Settings → Access tokens as a CLI sign-in.
120 const list = await (await t.browser().get("/settings/tokens")).text();
121 expect(list).toContain("Diffinery CLI on ana-laptop");
122 expect(list).toContain("CLI sign-in");
123 // The sign-in is audited.
124 const audit = t.DB.sqlite.prepare("SELECT detail FROM audit_log WHERE action = 'token.create'").all() as Array<{ detail: string }>;
125 expect(audit.map((a) => a.detail).join()).toContain("cli browser sign-in");
126 });
127
128 it("Cancel tells the CLI it was denied and creates nothing", async () => {
129 const t = setup();
130 const p = await pkce();
131 const res = await t.browser().post("/login/cli", { port: "53123", state: p.state, code_challenge: p.challenge, host: "h", decision: "deny" });
132 const to = new URL(res.headers.get("location")!);
133 expect(to.origin).toBe("http://127.0.0.1:53123");
134 expect(Object.fromEntries(to.searchParams)).toEqual({ error: "access_denied", state: p.state });
135 expect(t.DB.sqlite.prepare("SELECT count(*) AS n FROM cli_logins").get()).toEqual({ n: 0 });
136 });
137
138 it("needs a recent passkey sign-in, and sends signed-out people to sign in first", async () => {
139 const t = setup();
140 const p = await pkce();
141 const stale = await t.browser("ana-stale-session-0000000000002").get(authorizeUrl(p));
142 expect(stale.status).toBe(303);
143 const confirm = new URL(stale.headers.get("location")!, ORIGIN);
144 expect(confirm.pathname).toBe("/login");
145 expect(confirm.searchParams.get("confirm")).toBe("1");
146 expect(confirm.searchParams.get("next")).toBe(authorizeUrl(p));
147 const post = await t.browser("ana-stale-session-0000000000002").post("/login/cli", { port: "53123", state: p.state, code_challenge: p.challenge, decision: "approve" });
148 expect(new URL(post.headers.get("location")!, ORIGIN).pathname).toBe("/login");
149 expect(t.DB.sqlite.prepare("SELECT count(*) AS n FROM cli_logins").get()).toEqual({ n: 0 });
150
151 const anon = await t.fetch(authorizeUrl(p));
152 expect(anon.status).toBe(302);
153 expect(new URL(anon.headers.get("location")!, ORIGIN).searchParams.get("next")).toBe(authorizeUrl(p));
154 });
155
156 it("refuses links that could send the code anywhere but a local port", async () => {
157 const t = setup();
158 const p = await pkce();
159 for (const extra of [{ port: "80" }, { port: "70000" }, { port: "53123@evil.example" }, { code_challenge: "short" }, { code_challenge_method: "plain" }, { state: "" }]) {
160 const res = await t.browser().get(authorizeUrl(p, extra));
161 expect(res.status, JSON.stringify(extra)).toBe(400);
162 expect(await res.text()).toContain("This sign-in link is incomplete");
163 }
164 const res = await t.browser().post("/login/cli", { port: "22", state: p.state, code_challenge: p.challenge, decision: "approve" });
165 expect(res.status).toBe(400);
166 });
167
168 it("a token can't approve: the form needs a browser", async () => {
169 const t = setup();
170 const p = await pkce();
171 const body = new URLSearchParams({ port: "53123", state: p.state, code_challenge: p.challenge, decision: "approve" }).toString();
172 const res = await t.fetch("/login/cli", { method: "POST", headers: { authorization: "Bearer dfy_ana_ci", "content-type": "application/x-www-form-urlencoded", "content-length": String(body.length) }, body });
173 expect(res.status).toBe(403);
174 });
175});
176
177describe("CLI sign-in with a device code", () => {
178 const start = async (t: ReturnType<typeof setup>, scope = "write") => {
179 const r = await t.cli("/-/cli/device", { client: { host: "build-box", os: "linux-x64", version: "0.1.0" }, scope });
180 expect(r.status).toBe(200);
181 return (await r.json()) as { device_code: string; user_code: string; verification_uri: string; expires_in: number; interval: number };
182 };
183 const poll = async (t: ReturnType<typeof setup>, device_code: string) => (await t.cli("/-/cli/token", { grant_type: "urn:ietf:params:oauth:grant-type:device_code", device_code })).json() as Promise<Record<string, string>>;
184
185 it("waits for the person, then hands the CLI its token once", async () => {
186 const t = setup();
187 const d = await start(t);
188 expect(d.user_code).toMatch(/^[BCDFGHJKLMNPQRSTVWXZ]{4}-[BCDFGHJKLMNPQRSTVWXZ]{4}$/);
189 expect(d.verification_uri).toBe(`${ORIGIN}/login/device`);
190 expect(d).not.toHaveProperty("verification_uri_complete");
191 expect(d).toMatchObject({ expires_in: 600, interval: 5 });
192 expect((await poll(t, d.device_code)).error).toBe("authorization_pending");
193
194 // Typed in any case, with or without the dash.
195 const page = await t.browser().post("/login/device", { code: d.user_code.replace("-", "").toLowerCase() });
196 expect(page.status).toBe(200);
197 const html = await page.text();
198 expect(html).toContain("build-box");
199 expect(html).toContain(d.user_code);
200 const id = /name="id" value="([^"]+)"/.exec(html)![1]!;
201 const done = await t.browser().post("/login/device/decide", { id, scope: "admin", decision: "approve" });
202 expect(await done.text()).toMatch(/You(&#39;|')re signed in/);
203
204 const got = await poll(t, d.device_code);
205 expect(got.access_token).toMatch(/^dfy_/);
206 expect(got).toMatchObject({ scope: "admin", login: "ana" });
207 expect((await poll(t, d.device_code)).error).toBe("invalid_grant");
208 // Approving again changes nothing.
209 expect(await (await t.browser().post("/login/device/decide", { id, decision: "approve" })).text()).toContain("already answered");
210 });
211
212 it("tells the CLI when the person cancels, and an unknown code finds nothing", async () => {
213 const t = setup();
214 const d = await start(t, "read");
215 const html = await (await t.browser().post("/login/device", { code: d.user_code })).text();
216 const id = /name="id" value="([^"]+)"/.exec(html)![1]!;
217 await t.browser().post("/login/device/decide", { id, decision: "deny" });
218 expect((await poll(t, d.device_code)).error).toBe("access_denied");
219 expect((await poll(t, "not-a-device-code")).error).toBe("invalid_grant");
220 const unknown = await t.browser().post("/login/device", { code: "BCDF-GHJK" });
221 expect(unknown.status).toBe(404);
222 expect(await unknown.text()).toMatch(/isn(&#39;|')t valid or has expired/);
223 });
224
225 it("expires", async () => {
226 const t = setup();
227 const d = await start(t);
228 t.DB.sqlite.exec(`UPDATE cli_logins SET expires_at = '${new Date(Date.now() - 1000).toISOString()}'`);
229 expect((await poll(t, d.device_code)).error).toBe("expired_token");
230 expect((await t.browser().post("/login/device", { code: d.user_code })).status).toBe(404);
231 });
232
233 it("refuses an unknown scope and counts starts against the sign-in rate limit", async () => {
234 const t = setup();
235 expect((await t.cli("/-/cli/device", { scope: "root" })).status).toBe(422);
236 expect(bucketsFor("POST", "/-/cli/device", { type: "anonymous" }, "203.0.113.1")).toEqual([["RL_AUTH", "ip:203.0.113.1"]]);
237 expect(bucketsFor("POST", "/login/device", { type: "anonymous" }, "203.0.113.1")).toEqual([["RL_AUTH", "ip:203.0.113.1"]]);
238 });
239});
240
241describe("diffinery logout", () => {
242 it("revokes a CLI token with itself, and leaves other tokens alone", async () => {
243 const t = setup();
244 const p = await pkce();
245 const res = await t.browser().post("/login/cli", { port: "53123", state: p.state, code_challenge: p.challenge, host: "h", decision: "approve" });
246 const code = new URL(res.headers.get("location")!).searchParams.get("code")!;
247 const { access_token } = (await (await t.cli("/-/cli/token", { grant_type: "authorization_code", code, code_verifier: p.verifier })).json()) as { access_token: string };
248 expect(await (await t.cli("/-/cli/token", null, "DELETE", "dfy_ana_ci")).json()).toEqual({ revoked: false });
249 expect(t.tokens().find((k) => k.id === "t-ci")!.revoked_at).toBeNull();
250 expect(await (await t.cli("/-/cli/token", null, "DELETE", access_token)).json()).toEqual({ revoked: true });
251 expect((await t.fetch("/api/v3/user", { headers: { authorization: `Bearer ${access_token}` } })).status).toBe(401);
252 expect((await t.cli("/-/cli/token", null, "DELETE")).status).toBe(401);
253 });
254});
255
256describe("guards against approving someone else's sign-in", () => {
257 it("never preselects admin, and says when the CLI asked for it", async () => {
258 const t = setup();
259 const p = await pkce();
260 const html = await (await t.browser().get(authorizeUrl(p, { scope: "admin" }))).text();
261 expect(html).toContain("&quot;value&quot;:&quot;write&quot;");
262 expect(html).not.toContain("&quot;value&quot;:&quot;admin&quot;,&quot;items");
263 expect(html).toContain("The CLI asked for Admin");
264 expect(preselectedScope("admin")).toBe("write");
265 expect(preselectedScope("read")).toBe("read");
266 const plain = await (await t.browser().get(authorizeUrl(p))).text();
267 expect(plain).not.toContain("The CLI asked for Admin");
268 });
269
270 it("warns when the device code came from another network or country", async () => {
271 const t = setup();
272 const r = await t.fetch("/-/cli/device", { method: "POST", cf: { city: "Lagos", country: "NG" }, headers: { "content-type": "application/json", "cf-connecting-ip": "203.0.113.5" }, body: JSON.stringify({ client: { host: "far-away" } }) });
273 const d = (await r.json()) as { user_code: string };
274 const approve = (ip: string, country?: string, header?: string) =>
275 t.fetch("/login/device", {
276 method: "POST",
277 cf: country ? { country } : undefined,
278 headers: { cookie: `__Host-dfy_session=ana-fresh-session-0000000000001; dfy_csrf=${CSRF}`, "content-type": "application/x-www-form-urlencoded", "content-length": String(new URLSearchParams({ _csrf: CSRF, code: d.user_code }).toString().length), origin: ORIGIN, "cf-connecting-ip": ip, ...(header ? { "cf-ipcountry": header } : {}) },
279 body: new URLSearchParams({ _csrf: CSRF, code: d.user_code }).toString(),
280 });
281 expect(await (await approve("198.51.100.7")).text()).toContain("from a different network (203.0.113.5)");
282 expect(await (await approve("203.0.113.5")).text()).not.toContain("different network");
283 // The country comes from the edge's request.cf only; a CF-IPCountry header the client sends is ignored.
284 expect(await (await approve("198.51.100.7", "TR")).text()).toContain("asked for from another country</b> (Lagos, NG)");
285 expect(await (await approve("198.51.100.7", "NG")).text()).not.toContain("another country");
286 expect(await (await approve("198.51.100.7", undefined, "TR")).text()).not.toContain("another country");
287 expect(placeMismatch({ ip: "1.1.1.1", location: "Lagos, NG" }, { ip: "2.2.2.2", country: "TR" })).toBe("country");
288 expect(placeMismatch({ ip: "1.1.1.1", location: "TR" }, { ip: "2.2.2.2", country: "TR" })).toBe("network");
289 expect(placeMismatch({ ip: "1.1.1.1", location: "Istanbul, TR" }, { ip: "1.1.1.1", country: "TR" })).toBeNull();
290 expect(placeMismatch({ ip: null, location: null }, { ip: "1.1.1.1" })).toBeNull();
291 expect(placeMismatch({ ip: "1.1.1.1", location: "Istanbul, TR" }, { ip: "2.2.2.2", country: "TR" })).toBe("network");
292 expect(placeMismatch({ ip: "1.1.1.1", location: "Istanbul" }, { ip: "1.1.1.1", country: "TR" })).toBeNull();
293 });
294});
295
296describe("codes and client details", () => {
297 it("normalizes typed codes", () => {
298 expect(normalizeUserCode("bcdf-ghjk")).toBe("BCDFGHJK");
299 expect(normalizeUserCode(" BCDF GHJK ")).toBe("BCDFGHJK");
300 expect(normalizeUserCode("BCDF-GHJA")).toBeNull();
301 expect(normalizeUserCode("BCDF-GHJ")).toBeNull();
302 expect(formatUserCode("BCDFGHJK")).toBe("BCDF-GHJK");
303 });
304
305 it("keeps what the CLI says about itself short and plain", () => {
306 expect(cleanClient({ host: "<script>alert(1)</script>", os: 42, version: "0.1.0" })).toEqual({ host: "scriptalert(1)/script", os: "", version: "0.1.0" });
307 expect(cleanClient({}).host).toBe("unknown host");
308 expect(cleanClient({ host: "x".repeat(200) }).host).toHaveLength(64);
309 });
310});