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.

237 lines · 12.1 KBRaw History
1import type { Env } from "../env.js";
2import { Directory, type Account, type TokenScope } from "../lib/directory.js";
3import { HttpError, b64url, newId, now, randomToken, sha256 } from "../lib/util.js";
4import { TOKEN_SCOPES } from "./principal.js";
5
6/**
7 * Signing the CLI in through the browser, so nobody copies tokens by hand. Two flows, both ending
8 * in a token minted for the CLI and listed in Settings → Access tokens:
9 *
10 * - Browser (`diffinery login`): the CLI listens on 127.0.0.1, opens /login/cli with a PKCE
11 * challenge, and the person approves. Diffinery sends the browser to the CLI's local callback
12 * with a one-time code that only works together with the verifier the CLI kept (RFC 8252).
13 * A link someone else crafted leads nowhere: the code goes to the clicking person's own machine.
14 * - Device (`diffinery login --device`, for machines without a browser): the CLI gets a device
15 * code it keeps and a short user code it shows; the person types the user code on
16 * /login/device in any browser and approves, while the CLI polls (RFC 8628). The code is never
17 * put in a link, so a phishing link can't carry one.
18 *
19 * Approving needs a passkey sign-in from the last 2 hours, like creating any token. Only hashes
20 * of the codes are stored, codes expire within minutes and work once, and the token itself is
21 * minted when the CLI trades its secret, so it is never stored or shown anywhere but the CLI.
22 */
23
24/** How long a device code waits for the person, and how often the CLI may ask. */
25export const DEVICE_TTL_S = 600;
26export const DEVICE_INTERVAL_S = 5;
27/** How long the browser flow's one-time code waits for the CLI to trade it. */
28export const CODE_TTL_S = 120;
29/** CLI tokens last this long; the expiry reminder email says to run `diffinery login` again. */
30export const CLI_TOKEN_DAYS = 90;
31
32/** RFC 8628's suggestion: consonants only (no vowels, so no words), without easily confused letters. */
33const USER_CODE_ALPHABET = "BCDFGHJKLMNPQRSTVWXZ";
34const USER_CODE_LENGTH = 8;
35
36export interface CliClient {
37 host: string;
38 os: string;
39 version: string;
40}
41
42export interface CliLoginRow {
43 id: string;
44 flow: "browser" | "device";
45 user_code: string | null;
46 client: string;
47 requested_scope: string;
48 ip: string | null;
49 location: string | null;
50 status: "pending" | "approved" | "denied" | "used";
51 account_id: string | null;
52 scope: string | null;
53 created_at: string;
54 expires_at: string;
55 code_challenge: string | null;
56}
57
58/** What the CLI reported about itself, trimmed to something safe to show and to name a token after. */
59export function cleanClient(input: { host?: unknown; os?: unknown; version?: unknown }): CliClient {
60 const clean = (v: unknown, max: number) =>
61 (typeof v === "string" ? v : "")
62 .replace(/[^\w .,:@()+/-]/g, "")
63 .trim()
64 .slice(0, max);
65 return { host: clean(input.host, 64) || "unknown host", os: clean(input.os, 40), version: clean(input.version, 20) };
66}
67
68export function parseClient(json: string): CliClient {
69 try {
70 return cleanClient(JSON.parse(json) as Record<string, unknown>);
71 } catch {
72 return cleanClient({});
73 }
74}
75
76export function scopeOf(v: unknown, fallback: TokenScope = "write"): TokenScope {
77 return TOKEN_SCOPES.includes(v as TokenScope) ? (v as TokenScope) : fallback;
78}
79
80/** The name the token gets, e.g. "Diffinery CLI on alices-mbp". */
81export const tokenName = (c: CliClient) => `Diffinery CLI on ${c.host}`.slice(0, 80);
82
83/** A user code as typed: any case, with or without the dash or spaces. Null when it can't be one. */
84export function normalizeUserCode(input: string): string | null {
85 const s = input.toUpperCase().replace(/[\s-]/g, "");
86 if (s.length !== USER_CODE_LENGTH) return null;
87 for (const ch of s) if (!USER_CODE_ALPHABET.includes(ch)) return null;
88 return s;
89}
90
91export const formatUserCode = (s: string) => `${s.slice(0, 4)}-${s.slice(4)}`;
92
93function newUserCode(): string {
94 // 256 is not a multiple of 20: reject the top values so every letter is equally likely.
95 let out = "";
96 while (out.length < USER_CODE_LENGTH) {
97 for (const b of crypto.getRandomValues(new Uint8Array(16))) {
98 if (b < 240 && out.length < USER_CODE_LENGTH) out += USER_CODE_ALPHABET[b % 20];
99 }
100 }
101 return out;
102}
103
104/** The PKCE S256 challenge of a verifier (RFC 7636). */
105export async function pkceChallenge(verifier: string): Promise<string> {
106 return b64url(new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier))));
107}
108
109/** A loopback port the browser may be sent to: unprivileged, so not a system service's. */
110export function loopbackPort(v: unknown): number | null {
111 const n = typeof v === "string" && /^\d{4,5}$/.test(v) ? Number(v) : NaN;
112 return n >= 1024 && n <= 65535 ? n : null;
113}
114
115/** A PKCE S256 challenge or an OAuth state: base64url, 43 characters for 32 bytes, at most 128. */
116export const isB64urlParam = (v: unknown, min = 16): v is string => typeof v === "string" && v.length >= min && v.length <= 128 && /^[A-Za-z0-9_-]+$/.test(v);
117
118/** Errors the token endpoint answers with, as in RFC 8628 and RFC 6749. */
119export type TokenError = "authorization_pending" | "slow_down" | "access_denied" | "expired_token" | "invalid_grant" | "invalid_request";
120
121export class CliLoginError extends Error {
122 constructor(
123 readonly error: TokenError,
124 readonly description: string,
125 ) {
126 super(description);
127 }
128}
129
130export class CliLogins {
131 constructor(
132 private env: Env,
133 private dir = new Directory(env),
134 ) {}
135
136 private get db(): D1Database {
137 return this.env.DB;
138 }
139
140 /** The CLI starts a device sign-in. Returns the device code (only the CLI ever sees it) and the user code. */
141 async startDevice(client: CliClient, scope: TokenScope, from: { ip?: string; location?: string }): Promise<{ deviceCode: string; userCode: string; expiresIn: number; interval: number }> {
142 const deviceCode = randomToken(32);
143 for (let attempt = 0; ; attempt++) {
144 const userCode = newUserCode();
145 try {
146 await this.db
147 .prepare("INSERT INTO cli_logins (id, flow, secret_hash, user_code, client, requested_scope, ip, location, status, created_at, expires_at) VALUES (?, 'device', ?, ?, ?, ?, ?, ?, 'pending', ?, ?)")
148 .bind(newId(), await sha256(deviceCode), userCode, JSON.stringify(client), scope, from.ip ?? null, from.location ?? null, now(), new Date(Date.now() + DEVICE_TTL_S * 1000).toISOString())
149 .run();
150 return { deviceCode, userCode, expiresIn: DEVICE_TTL_S, interval: DEVICE_INTERVAL_S };
151 } catch (e) {
152 // A user code still in the table (they're kept a day): pick another.
153 if (attempt < 3 && /UNIQUE/i.test(String(e))) continue;
154 throw e;
155 }
156 }
157 }
158
159 /** The pending device sign-in a typed code names, or null (unknown, expired or already decided). */
160 async pendingDevice(userCode: string): Promise<CliLoginRow | null> {
161 const code = normalizeUserCode(userCode);
162 if (!code) return null;
163 return this.db.prepare("SELECT * FROM cli_logins WHERE user_code = ? AND flow = 'device' AND status = 'pending' AND expires_at > ?").bind(code, now()).first<CliLoginRow>();
164 }
165
166 /** The person approves or denies a device sign-in. False when it's no longer pending. */
167 async decideDevice(id: string, person: Account, approve: boolean, scope: TokenScope): Promise<boolean> {
168 const r = await this.db
169 .prepare("UPDATE cli_logins SET status = ?, account_id = ?, scope = ?, decided_at = ? WHERE id = ? AND flow = 'device' AND status = 'pending' AND expires_at > ?")
170 .bind(approve ? "approved" : "denied", person.id, approve ? scope : null, now(), id, now())
171 .run();
172 return (r.meta.changes ?? 0) > 0;
173 }
174
175 /** The person approved a browser sign-in: a one-time code for the CLI's callback, bound to its PKCE challenge. */
176 async approveBrowser(person: Account, client: CliClient, scope: TokenScope, challenge: string): Promise<string> {
177 const code = randomToken(32);
178 await this.db
179 .prepare("INSERT INTO cli_logins (id, flow, secret_hash, code_challenge, client, requested_scope, status, account_id, scope, created_at, expires_at, decided_at) VALUES (?, 'browser', ?, ?, ?, ?, 'approved', ?, ?, ?, ?, ?)")
180 .bind(newId(), await sha256(code), challenge, JSON.stringify(client), scope, person.id, scope, now(), new Date(Date.now() + CODE_TTL_S * 1000).toISOString(), now())
181 .run();
182 return code;
183 }
184
185 /**
186 * The CLI trades its secret for a token: the browser flow's code with the PKCE verifier, or the
187 * device code. Each works once; the claim is a single conditional write, so two requests racing
188 * with the same secret can't both get a token.
189 */
190 async exchange(grant: { type: "code"; code: string; verifier: string } | { type: "device"; deviceCode: string }): Promise<{ token: string; scope: TokenScope; expiresAt: string; login: string; tokenId: string }> {
191 const hash = await sha256(grant.type === "code" ? grant.code : grant.deviceCode);
192 const flow = grant.type === "code" ? "browser" : "device";
193 const at = now();
194 const claimed = await this.db
195 .prepare("UPDATE cli_logins SET status = 'used', used_at = ? WHERE secret_hash = ? AND flow = ? AND status = 'approved' AND expires_at > ? RETURNING *")
196 .bind(at, hash, flow, at)
197 .first<CliLoginRow>();
198 if (!claimed) {
199 const row = await this.db.prepare("SELECT status, expires_at FROM cli_logins WHERE secret_hash = ? AND flow = ?").bind(hash, flow).first<{ status: string; expires_at: string }>();
200 if (!row) throw new CliLoginError("invalid_grant", "unknown or already used code");
201 if (row.status === "denied") throw new CliLoginError("access_denied", "the sign-in was denied in the browser");
202 if (row.status === "used") throw new CliLoginError("invalid_grant", "this code was already used");
203 if (row.expires_at <= at) throw new CliLoginError("expired_token", "the code expired; run `diffinery login` again");
204 throw new CliLoginError("authorization_pending", "waiting for you to approve the sign-in in the browser");
205 }
206 // The verifier is checked after the claim so a wrong guess burns the code rather than allowing retries.
207 if (grant.type === "code" && (!claimed.code_challenge || (await pkceChallenge(grant.verifier)) !== claimed.code_challenge)) {
208 throw new CliLoginError("invalid_grant", "the code verifier doesn't match");
209 }
210 const person = claimed.account_id ? await this.dir.account(claimed.account_id) : null;
211 if (!person || person.status !== "active" || person.kind !== "user") throw new CliLoginError("access_denied", "the account that approved this sign-in can't sign in");
212 const scope = scopeOf(claimed.scope);
213 const client = parseClient(claimed.client);
214 const t = await this.dir.createToken(person, { name: tokenName(client), scopes: [scope], expiresInDays: CLI_TOKEN_DAYS, client: "cli" });
215 await this.db.prepare("UPDATE cli_logins SET token_id = ? WHERE id = ?").bind(t.id, claimed.id).run();
216 await this.dir.audit({ actorId: person.id, actorLogin: person.login, action: "token.create", target: person.login, detail: { name: tokenName(client), scope, via: `cli ${flow} sign-in` } });
217 return { token: t.token, scope, expiresAt: t.expiresAt, login: person.login, tokenId: t.id };
218 }
219
220 /** `diffinery logout`: a CLI token revokes itself. Other tokens are left alone (they may be used elsewhere). */
221 async revokeCliToken(accountId: string, tokenId: string): Promise<boolean> {
222 const r = await this.db.prepare("UPDATE tokens SET revoked_at = ? WHERE id = ? AND account_id = ? AND client = 'cli' AND revoked_at IS NULL").bind(now(), tokenId, accountId).run();
223 return (r.meta.changes ?? 0) > 0;
224 }
225
226 /** Housekeeping: rows a day past their expiry. */
227 async prune(): Promise<void> {
228 await this.db.prepare("DELETE FROM cli_logins WHERE expires_at < ?").bind(new Date(Date.now() - 86400_000).toISOString()).run();
229 }
230}
231
232/** Throws a 422 for a scope that isn't one of read, write and admin. */
233export function requireScopeName(v: unknown): TokenScope {
234 if (v === undefined || v === null || v === "") return "write";
235 if (!TOKEN_SCOPES.includes(v as TokenScope)) throw new HttpError(422, `scope must be one of: ${TOKEN_SCOPES.join(", ")}`);
236 return v as TokenScope;
237}