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.

69 lines · 3.7 KBRaw History
1import { Hono } from "hono";
2import type { Env } from "../env.js";
3import type { AppVars } from "../auth/principal.js";
4import { CliLoginError, CliLogins, cleanClient, formatUserCode, isB64urlParam, requireScopeName } from "../auth/cli-login.js";
5import { HttpError } from "../lib/util.js";
6
7/**
8 * What the CLI calls to sign in through the browser (auth/cli-login.ts): start a device sign-in,
9 * trade a code for a token, and revoke its own token on `diffinery logout`. JSON in and out, no
10 * cookies; the pages people approve on are in web/cli-login.tsx.
11 */
12export const cliLogin = new Hono<{ Bindings: Env; Variables: AppVars }>();
13
14const NO_STORE = { "cache-control": "no-store", pragma: "no-cache" };
15
16async function jsonBody(req: Request): Promise<Record<string, unknown>> {
17 if (!/^application\/json\b/i.test(req.headers.get("content-type") ?? "")) throw new HttpError(415, "expected JSON");
18 const len = Number(req.headers.get("content-length") ?? "0");
19 if (len > 8192) throw new HttpError(413, "request too large");
20 const b = await req.json().catch(() => null);
21 if (!b || typeof b !== "object" || Array.isArray(b)) throw new HttpError(400, "invalid JSON");
22 return b as Record<string, unknown>;
23}
24
25cliLogin.post("/-/cli/device", async (c) => {
26 const b = await jsonBody(c.req.raw);
27 const client = cleanClient((b.client ?? {}) as Record<string, unknown>);
28 const scope = requireScopeName(b.scope);
29 const cf = (c.req.raw as { cf?: { city?: string; country?: string } }).cf;
30 const location = [cf?.city, cf?.country].filter(Boolean).join(", ") || undefined;
31 const d = await new CliLogins(c.env, c.get("dir")).startDevice(client, scope, { ip: c.req.header("cf-connecting-ip") ?? undefined, location });
32 const base = c.env.PUBLIC_URL.replace(/\/$/, "");
33 return c.json(
34 {
35 device_code: d.deviceCode,
36 user_code: formatUserCode(d.userCode),
37 // No verification_uri_complete: the code is typed, never carried in a link someone could send.
38 verification_uri: `${base}/login/device`,
39 expires_in: d.expiresIn,
40 interval: d.interval,
41 },
42 200,
43 NO_STORE,
44 );
45});
46
47cliLogin.post("/-/cli/token", async (c) => {
48 const b = await jsonBody(c.req.raw);
49 const logins = new CliLogins(c.env, c.get("dir"));
50 try {
51 let grant: Parameters<CliLogins["exchange"]>[0];
52 if (b.grant_type === "authorization_code" && typeof b.code === "string" && isB64urlParam(b.code_verifier, 43)) grant = { type: "code", code: b.code, verifier: b.code_verifier };
53 else if (b.grant_type === "urn:ietf:params:oauth:grant-type:device_code" && typeof b.device_code === "string") grant = { type: "device", deviceCode: b.device_code };
54 else throw new CliLoginError("invalid_request", "send grant_type authorization_code with code and code_verifier, or the device code grant with device_code");
55 const t = await logins.exchange(grant);
56 return c.json({ access_token: t.token, token_type: "bearer", scope: t.scope, expires_at: t.expiresAt, login: t.login }, 200, NO_STORE);
57 } catch (e) {
58 if (!(e instanceof CliLoginError)) throw e;
59 return c.json({ error: e.error, error_description: e.description }, 400, NO_STORE);
60 }
61});
62
63cliLogin.delete("/-/cli/token", async (c) => {
64 const p = c.get("principal");
65 if (p.type !== "account" || p.via !== "token" || !p.tokenId) throw new HttpError(401, "send the token to revoke as the Authorization header");
66 const revoked = await new CliLogins(c.env, c.get("dir")).revokeCliToken(p.account.id, p.tokenId);
67 if (revoked) c.executionCtx.waitUntil(c.get("dir").audit({ actorId: p.account.id, actorLogin: p.account.login, action: "token.revoke", target: p.tokenId, detail: { via: "diffinery logout" } }));
68 return c.json({ revoked }, 200, NO_STORE);
69});