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.

309 lines · 19.6 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 expect(body.access_token).toMatch(/^dfy_/);
108 expect(body).toMatchObject({ scope: "read", login: "ana" });
109 expect(Date.parse(body.expires_at) - Date.now()).toBeGreaterThan(89 * 86400_000);
110 const cli = t.tokens().find((k) => k.client === "cli")!;
111 expect(cli).toMatchObject({ name: "Diffinery CLI on ana-laptop", scopes: '["read"]', revoked_at: null });
112 // The token works, as the person, with its scope.
113 const me = await t.fetch("/api/v3/user", { headers: { authorization: `Bearer ${body.access_token}` } });
114 expect(((await me.json()) as { login: string }).login).toBe("ana");
115 // Single use.
116 const again = await t.cli("/-/cli/token", { grant_type: "authorization_code", code, code_verifier: p.verifier });
117 expect(((await again.json()) as { error: string }).error).toBe("invalid_grant");
118 // It shows up in Settings → Access tokens as a CLI sign-in.
119 const list = await (await t.browser().get("/settings/tokens")).text();
120 expect(list).toContain("Diffinery CLI on ana-laptop");
121 expect(list).toContain("CLI sign-in");
122 // The sign-in is audited.
123 const audit = t.DB.sqlite.prepare("SELECT detail FROM audit_log WHERE action = 'token.create'").all() as Array<{ detail: string }>;
124 expect(audit.map((a) => a.detail).join()).toContain("cli browser sign-in");
125 });
126
127 it("Cancel tells the CLI it was denied and creates nothing", async () => {
128 const t = setup();
129 const p = await pkce();
130 const res = await t.browser().post("/login/cli", { port: "53123", state: p.state, code_challenge: p.challenge, host: "h", decision: "deny" });
131 const to = new URL(res.headers.get("location")!);
132 expect(to.origin).toBe("http://127.0.0.1:53123");
133 expect(Object.fromEntries(to.searchParams)).toEqual({ error: "access_denied", state: p.state });
134 expect(t.DB.sqlite.prepare("SELECT count(*) AS n FROM cli_logins").get()).toEqual({ n: 0 });
135 });
136
137 it("needs a recent passkey sign-in, and sends signed-out people to sign in first", async () => {
138 const t = setup();
139 const p = await pkce();
140 const stale = await t.browser("ana-stale-session-0000000000002").get(authorizeUrl(p));
141 expect(stale.status).toBe(303);
142 const confirm = new URL(stale.headers.get("location")!, ORIGIN);
143 expect(confirm.pathname).toBe("/login");
144 expect(confirm.searchParams.get("confirm")).toBe("1");
145 expect(confirm.searchParams.get("next")).toBe(authorizeUrl(p));
146 const post = await t.browser("ana-stale-session-0000000000002").post("/login/cli", { port: "53123", state: p.state, code_challenge: p.challenge, decision: "approve" });
147 expect(new URL(post.headers.get("location")!, ORIGIN).pathname).toBe("/login");
148 expect(t.DB.sqlite.prepare("SELECT count(*) AS n FROM cli_logins").get()).toEqual({ n: 0 });
149
150 const anon = await t.fetch(authorizeUrl(p));
151 expect(anon.status).toBe(302);
152 expect(new URL(anon.headers.get("location")!, ORIGIN).searchParams.get("next")).toBe(authorizeUrl(p));
153 });
154
155 it("refuses links that could send the code anywhere but a local port", async () => {
156 const t = setup();
157 const p = await pkce();
158 for (const extra of [{ port: "80" }, { port: "70000" }, { port: "53123@evil.example" }, { code_challenge: "short" }, { code_challenge_method: "plain" }, { state: "" }]) {
159 const res = await t.browser().get(authorizeUrl(p, extra));
160 expect(res.status, JSON.stringify(extra)).toBe(400);
161 expect(await res.text()).toContain("This sign-in link is incomplete");
162 }
163 const res = await t.browser().post("/login/cli", { port: "22", state: p.state, code_challenge: p.challenge, decision: "approve" });
164 expect(res.status).toBe(400);
165 });
166
167 it("a token can't approve: the form needs a browser", async () => {
168 const t = setup();
169 const p = await pkce();
170 const body = new URLSearchParams({ port: "53123", state: p.state, code_challenge: p.challenge, decision: "approve" }).toString();
171 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 });
172 expect(res.status).toBe(403);
173 });
174});
175
176describe("CLI sign-in with a device code", () => {
177 const start = async (t: ReturnType<typeof setup>, scope = "write") => {
178 const r = await t.cli("/-/cli/device", { client: { host: "build-box", os: "linux-x64", version: "0.1.0" }, scope });
179 expect(r.status).toBe(200);
180 return (await r.json()) as { device_code: string; user_code: string; verification_uri: string; expires_in: number; interval: number };
181 };
182 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>>;
183
184 it("waits for the person, then hands the CLI its token once", async () => {
185 const t = setup();
186 const d = await start(t);
187 expect(d.user_code).toMatch(/^[BCDFGHJKLMNPQRSTVWXZ]{4}-[BCDFGHJKLMNPQRSTVWXZ]{4}$/);
188 expect(d.verification_uri).toBe(`${ORIGIN}/login/device`);
189 expect(d).not.toHaveProperty("verification_uri_complete");
190 expect(d).toMatchObject({ expires_in: 600, interval: 5 });
191 expect((await poll(t, d.device_code)).error).toBe("authorization_pending");
192
193 // Typed in any case, with or without the dash.
194 const page = await t.browser().post("/login/device", { code: d.user_code.replace("-", "").toLowerCase() });
195 expect(page.status).toBe(200);
196 const html = await page.text();
197 expect(html).toContain("build-box");
198 expect(html).toContain(d.user_code);
199 const id = /name="id" value="([^"]+)"/.exec(html)![1]!;
200 const done = await t.browser().post("/login/device/decide", { id, scope: "admin", decision: "approve" });
201 expect(await done.text()).toMatch(/You(&#39;|')re signed in/);
202
203 const got = await poll(t, d.device_code);
204 expect(got.access_token).toMatch(/^dfy_/);
205 expect(got).toMatchObject({ scope: "admin", login: "ana" });
206 expect((await poll(t, d.device_code)).error).toBe("invalid_grant");
207 // Approving again changes nothing.
208 expect(await (await t.browser().post("/login/device/decide", { id, decision: "approve" })).text()).toContain("already answered");
209 });
210
211 it("tells the CLI when the person cancels, and an unknown code finds nothing", async () => {
212 const t = setup();
213 const d = await start(t, "read");
214 const html = await (await t.browser().post("/login/device", { code: d.user_code })).text();
215 const id = /name="id" value="([^"]+)"/.exec(html)![1]!;
216 await t.browser().post("/login/device/decide", { id, decision: "deny" });
217 expect((await poll(t, d.device_code)).error).toBe("access_denied");
218 expect((await poll(t, "not-a-device-code")).error).toBe("invalid_grant");
219 const unknown = await t.browser().post("/login/device", { code: "BCDF-GHJK" });
220 expect(unknown.status).toBe(404);
221 expect(await unknown.text()).toMatch(/isn(&#39;|')t valid or has expired/);
222 });
223
224 it("expires", async () => {
225 const t = setup();
226 const d = await start(t);
227 t.DB.sqlite.exec(`UPDATE cli_logins SET expires_at = '${new Date(Date.now() - 1000).toISOString()}'`);
228 expect((await poll(t, d.device_code)).error).toBe("expired_token");
229 expect((await t.browser().post("/login/device", { code: d.user_code })).status).toBe(404);
230 });
231
232 it("refuses an unknown scope and counts starts against the sign-in rate limit", async () => {
233 const t = setup();
234 expect((await t.cli("/-/cli/device", { scope: "root" })).status).toBe(422);
235 expect(bucketsFor("POST", "/-/cli/device", { type: "anonymous" }, "203.0.113.1")).toEqual([["RL_AUTH", "ip:203.0.113.1"]]);
236 expect(bucketsFor("POST", "/login/device", { type: "anonymous" }, "203.0.113.1")).toEqual([["RL_AUTH", "ip:203.0.113.1"]]);
237 });
238});
239
240describe("diffinery logout", () => {
241 it("revokes a CLI token with itself, and leaves other tokens alone", async () => {
242 const t = setup();
243 const p = await pkce();
244 const res = await t.browser().post("/login/cli", { port: "53123", state: p.state, code_challenge: p.challenge, host: "h", decision: "approve" });
245 const code = new URL(res.headers.get("location")!).searchParams.get("code")!;
246 const { access_token } = (await (await t.cli("/-/cli/token", { grant_type: "authorization_code", code, code_verifier: p.verifier })).json()) as { access_token: string };
247 expect(await (await t.cli("/-/cli/token", null, "DELETE", "dfy_ana_ci")).json()).toEqual({ revoked: false });
248 expect(t.tokens().find((k) => k.id === "t-ci")!.revoked_at).toBeNull();
249 expect(await (await t.cli("/-/cli/token", null, "DELETE", access_token)).json()).toEqual({ revoked: true });
250 expect((await t.fetch("/api/v3/user", { headers: { authorization: `Bearer ${access_token}` } })).status).toBe(401);
251 expect((await t.cli("/-/cli/token", null, "DELETE")).status).toBe(401);
252 });
253});
254
255describe("guards against approving someone else's sign-in", () => {
256 it("never preselects admin, and says when the CLI asked for it", async () => {
257 const t = setup();
258 const p = await pkce();
259 const html = await (await t.browser().get(authorizeUrl(p, { scope: "admin" }))).text();
260 expect(html).toContain("&quot;value&quot;:&quot;write&quot;");
261 expect(html).not.toContain("&quot;value&quot;:&quot;admin&quot;,&quot;items");
262 expect(html).toContain("The CLI asked for Admin");
263 expect(preselectedScope("admin")).toBe("write");
264 expect(preselectedScope("read")).toBe("read");
265 const plain = await (await t.browser().get(authorizeUrl(p))).text();
266 expect(plain).not.toContain("The CLI asked for Admin");
267 });
268
269 it("warns when the device code came from another network or country", async () => {
270 const t = setup();
271 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" } }) });
272 const d = (await r.json()) as { user_code: string };
273 const approve = (ip: string, country?: string, header?: string) =>
274 t.fetch("/login/device", {
275 method: "POST",
276 cf: country ? { country } : undefined,
277 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 } : {}) },
278 body: new URLSearchParams({ _csrf: CSRF, code: d.user_code }).toString(),
279 });
280 expect(await (await approve("198.51.100.7")).text()).toContain("from a different network (203.0.113.5)");
281 expect(await (await approve("203.0.113.5")).text()).not.toContain("different network");
282 // The country comes from the edge's request.cf only; a CF-IPCountry header the client sends is ignored.
283 expect(await (await approve("198.51.100.7", "TR")).text()).toContain("asked for from another country</b> (Lagos, NG)");
284 expect(await (await approve("198.51.100.7", "NG")).text()).not.toContain("another country");
285 expect(await (await approve("198.51.100.7", undefined, "TR")).text()).not.toContain("another country");
286 expect(placeMismatch({ ip: "1.1.1.1", location: "Lagos, NG" }, { ip: "2.2.2.2", country: "TR" })).toBe("country");
287 expect(placeMismatch({ ip: "1.1.1.1", location: "TR" }, { ip: "2.2.2.2", country: "TR" })).toBe("network");
288 expect(placeMismatch({ ip: "1.1.1.1", location: "Istanbul, TR" }, { ip: "1.1.1.1", country: "TR" })).toBeNull();
289 expect(placeMismatch({ ip: null, location: null }, { ip: "1.1.1.1" })).toBeNull();
290 expect(placeMismatch({ ip: "1.1.1.1", location: "Istanbul, TR" }, { ip: "2.2.2.2", country: "TR" })).toBe("network");
291 expect(placeMismatch({ ip: "1.1.1.1", location: "Istanbul" }, { ip: "1.1.1.1", country: "TR" })).toBeNull();
292 });
293});
294
295describe("codes and client details", () => {
296 it("normalizes typed codes", () => {
297 expect(normalizeUserCode("bcdf-ghjk")).toBe("BCDFGHJK");
298 expect(normalizeUserCode(" BCDF GHJK ")).toBe("BCDFGHJK");
299 expect(normalizeUserCode("BCDF-GHJA")).toBeNull();
300 expect(normalizeUserCode("BCDF-GHJ")).toBeNull();
301 expect(formatUserCode("BCDFGHJK")).toBe("BCDF-GHJK");
302 });
303
304 it("keeps what the CLI says about itself short and plain", () => {
305 expect(cleanClient({ host: "<script>alert(1)</script>", os: 42, version: "0.1.0" })).toEqual({ host: "scriptalert(1)/script", os: "", version: "0.1.0" });
306 expect(cleanClient({}).host).toBe("unknown host");
307 expect(cleanClient({ host: "x".repeat(200) }).host).toHaveLength(64);
308 });
309});