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.

402 lines · 18.0 KBRaw History
1import * as http from "node:http";
2import { randomUUID } from "node:crypto";
3import * as vscode from "vscode";
4import { Api } from "./api";
5import { chooseServer } from "./servers";
6import { authorizeUrl, clientId, editorRedirect, normalizeServer, pkce, readCallback } from "./util";
7
8export const AUTH_ID = "diffinery";
9/** What the extension asks for: push and pull, issues and pull requests. The person can lower it on the consent page. */
10export const SCOPES = ["write"];
11const SIGN_IN_TIMEOUT_MS = 10 * 60_000;
12/** Refresh this long before the access token expires (they last 8 hours). */
13const REFRESH_MARGIN_MS = 10 * 60_000;
14
15/** A signed-in account as kept in the editor's secret storage. */
16export interface Stored {
17 id: string;
18 server: string;
19 login: string;
20 accountId: string;
21 /** "oauth": approved in the browser, renews itself. "token": an access token someone pasted. */
22 kind: "oauth" | "token";
23 accessToken: string;
24 refreshToken?: string;
25 /** Epoch ms. */
26 expiresAt?: number;
27}
28
29interface TokenResponse {
30 access_token: string;
31 refresh_token?: string;
32 expires_in?: number;
33 error?: string;
34 error_description?: string;
35}
36
37export class SignInError extends Error {}
38
39/**
40 * Diffinery as an account provider in the editor (the Accounts menu), signed in through Diffinery's OAuth
41 * (the browser asks the person to approve, with their passkey) or with a pasted access token. Tokens from
42 * OAuth last 8 hours and are renewed with the refresh token before they run out, so Git never sees an
43 * expired one while the editor is open.
44 */
45export class DiffineryAuth implements vscode.AuthenticationProvider, vscode.Disposable {
46 private readonly changes = new vscode.EventEmitter<vscode.AuthenticationProviderAuthenticationSessionsChangeEvent>();
47 readonly onDidChangeSessions = this.changes.event;
48 /** Fired in the window that changed the stored account (sign-in, renewal, sign-out), for Git's credential store. */
49 private readonly stored = new vscode.EventEmitter<{ before?: Stored; after?: Stored }>();
50 readonly onDidStore = this.stored.event;
51
52 private readonly callbacks = new Map<string, (query: string) => void>();
53 private refreshing: Promise<Stored | undefined> | undefined;
54 private timer: NodeJS.Timeout | undefined;
55 private last: Stored | undefined;
56 private readonly disposables: vscode.Disposable[] = [];
57
58 constructor(
59 private readonly ctx: vscode.ExtensionContext,
60 private readonly log: vscode.LogOutputChannel,
61 private readonly version: string,
62 ) {
63 // Another window signed in, renewed or signed out: the secret storage is shared, so follow it.
64 this.disposables.push(
65 ctx.secrets.onDidChange(async (e) => {
66 if (e.key === this.key()) await this.syncFromStorage();
67 }),
68 vscode.workspace.onDidChangeConfiguration(async (e) => {
69 if (e.affectsConfiguration("diffinery.serverUrl")) await this.syncFromStorage();
70 }),
71 );
72 void this.syncFromStorage();
73 }
74
75 dispose(): void {
76 if (this.timer) clearTimeout(this.timer);
77 this.disposables.forEach((d) => d.dispose());
78 this.changes.dispose();
79 this.stored.dispose();
80 }
81
82 server(): string {
83 return normalizeServer(vscode.workspace.getConfiguration("diffinery").get<string>("serverUrl"));
84 }
85
86 private key(): string {
87 let server: string;
88 try {
89 server = this.server();
90 } catch {
91 server = "invalid";
92 }
93 return `diffinery.account:${server}`;
94 }
95
96 private async load(): Promise<Stored | undefined> {
97 const raw = await this.ctx.secrets.get(this.key());
98 if (!raw) return undefined;
99 try {
100 const s = JSON.parse(raw) as Stored;
101 return s.server === this.server() && s.accessToken ? s : undefined;
102 } catch {
103 return undefined;
104 }
105 }
106
107 private async save(s: Stored | undefined): Promise<void> {
108 const before = this.last;
109 if (s) await this.ctx.secrets.store(this.key(), JSON.stringify(s));
110 else await this.ctx.secrets.delete(this.key());
111 this.announce(s);
112 this.stored.fire({ before, after: s });
113 }
114
115 /** Tells the editor what changed since it last heard, and plans the next renewal. */
116 private announce(s: Stored | undefined): void {
117 const before = this.last;
118 this.last = s;
119 this.schedule(s);
120 if (!before && s) this.changes.fire({ added: [toSession(s)], removed: [], changed: [] });
121 else if (before && !s) this.changes.fire({ added: [], removed: [toSession(before)], changed: [] });
122 else if (before && s && before.id !== s.id) this.changes.fire({ added: [toSession(s)], removed: [toSession(before)], changed: [] });
123 else if (before && s && before.accessToken !== s.accessToken) this.changes.fire({ added: [], removed: [], changed: [toSession(s)] });
124 }
125
126 private async syncFromStorage(): Promise<void> {
127 try {
128 this.announce(await this.load());
129 } catch (e) {
130 this.log.error("reading the stored Diffinery account failed", e);
131 }
132 }
133
134 private schedule(s: Stored | undefined): void {
135 if (this.timer) clearTimeout(this.timer);
136 this.timer = undefined;
137 if (!s || s.kind !== "oauth" || !s.expiresAt) return;
138 // A little jitter, so several windows don't all renew at the same moment.
139 const wait = Math.max(5_000, s.expiresAt - REFRESH_MARGIN_MS - Date.now() + Math.random() * 60_000);
140 this.timer = setTimeout(() => void this.fresh().catch((e) => this.log.warn("renewing the Diffinery sign-in failed", e)), Math.min(wait, 2 ** 31 - 1));
141 }
142
143 /** The stored account, renewed first when its token is about to expire. Undefined when signed out. */
144 async fresh(): Promise<Stored | undefined> {
145 const s = await this.load();
146 if (!s || s.kind !== "oauth" || !s.expiresAt || s.expiresAt - Date.now() > REFRESH_MARGIN_MS) return s;
147 this.refreshing ??= this.refresh(s).finally(() => (this.refreshing = undefined));
148 return this.refreshing;
149 }
150
151 private async refresh(s: Stored): Promise<Stored | undefined> {
152 if (!s.refreshToken) return s;
153 let res: TokenResponse;
154 try {
155 res = await tokenRequest(s.server, { grant_type: "refresh_token", refresh_token: s.refreshToken, client_id: clientId(s.server) });
156 } catch (e) {
157 if (!(e instanceof SignInError)) {
158 // Offline or the server is unreachable: keep the account; Git gets the old token while it lasts.
159 this.log.warn("could not reach Diffinery to renew the sign-in", e);
160 return s;
161 }
162 // Another window may have renewed with the same refresh token a moment earlier: use what it stored.
163 await new Promise((r) => setTimeout(r, 1500));
164 const now = await this.load();
165 if (now && now.refreshToken !== s.refreshToken) return now;
166 this.log.warn("the Diffinery sign-in ended", e.message);
167 await this.save(undefined);
168 void vscode.window.showWarningMessage("Your Diffinery sign-in has ended (it was disconnected, or unused for 30 days). Sign in again to keep cloning and pushing.", "Sign In").then((pick) => {
169 if (pick) void vscode.commands.executeCommand("diffinery.signIn");
170 });
171 return undefined;
172 }
173 const next: Stored = { ...s, accessToken: res.access_token, refreshToken: res.refresh_token ?? s.refreshToken, expiresAt: Date.now() + (res.expires_in ?? 8 * 3600) * 1000 };
174 await this.save(next);
175 this.log.info(`renewed the Diffinery sign-in for ${s.login}`);
176 return next;
177 }
178
179 // ------------------------------------------------------------------ AuthenticationProvider
180
181 async getSessions(): Promise<vscode.AuthenticationSession[]> {
182 const s = await this.fresh();
183 if (s !== this.last && (s?.accessToken !== this.last?.accessToken || s?.id !== this.last?.id)) this.announce(s);
184 return s ? [toSession(s)] : [];
185 }
186
187 async createSession(): Promise<vscode.AuthenticationSession> {
188 // Every way in (the Accounts menu, the clone picker, Publish, the commands) first asks which Diffinery, until
189 // someone has chosen, so a self-hosted one needs no trip to the settings. It may change diffinery.serverUrl, and
190 // with it this.key().
191 await chooseServer(this.ctx.globalState);
192 const s = this.withToken ? await this.askForToken() : await this.signInWithBrowser();
193 // Signing in again replaces the old sign-in, and ends its connection on the server.
194 const old = await this.load();
195 if (old) await this.removeSession(old.id);
196 await this.save(s);
197 void vscode.window.showInformationMessage(`Signed in to Diffinery as ${s.login}.`);
198 return toSession(s);
199 }
200
201 /** Set while "Sign In with an Access Token" runs, so createSession asks for a token instead of opening the browser. */
202 private withToken = false;
203
204 async removeSession(id: string): Promise<void> {
205 const s = await this.load();
206 if (!s || s.id !== id) return;
207 await this.save(undefined);
208 // Ends the connection on the server too (Settings → Access tokens → Connected apps). A pasted token is the
209 // person's own and may be used elsewhere, so it's only forgotten here.
210 if (s.kind === "oauth" && s.refreshToken) {
211 await fetch(`${s.server}/oauth/revoke`, {
212 method: "POST",
213 headers: { "content-type": "application/x-www-form-urlencoded", "user-agent": `Diffinery-VSCode/${this.version}` },
214 body: new URLSearchParams({ token: s.refreshToken, client_id: clientId(s.server) }),
215 signal: AbortSignal.timeout(10_000),
216 }).catch((e) => this.log.warn("revoking the Diffinery connection failed", e));
217 }
218 }
219
220 // ------------------------------------------------------------------ signing in
221
222 /** The editor's URL handler: /oauth is where the browser comes back after approving. */
223 handleUri(uri: vscode.Uri): boolean {
224 if (uri.path !== "/oauth") return false;
225 const state = new URLSearchParams(uri.query).get("state") ?? "";
226 const done = this.callbacks.get(state);
227 if (!done) {
228 void vscode.window.showWarningMessage("This Diffinery sign-in link is from an earlier attempt. Sign in again from the editor.");
229 return true;
230 }
231 done(uri.query);
232 return true;
233 }
234
235 private async signInWithBrowser(): Promise<Stored> {
236 if (vscode.env.uiKind === vscode.UIKind.Web) throw new SignInError("Signing in through the browser needs the desktop editor. In a browser-based editor, run “Diffinery: Sign In with an Access Token”.");
237 const server = this.server();
238 const { verifier, challenge, state } = pkce();
239 // The browser comes back to a one-time listener on this computer (RFC 8252): no prompt to open the editor, and
240 // no "Allow the extension to open this URI?" dialog. Where that can't work (a browser-based remote), it comes
241 // back through the editor's own URL scheme instead.
242 let redirectUri: string | null = null;
243 let wait: Promise<string> | undefined;
244 const loopback = await listenOnce(state).catch((e) => {
245 this.log.warn("couldn't listen on 127.0.0.1 for the sign-in", e);
246 return undefined;
247 });
248 if (loopback) {
249 const external = new URL((await vscode.env.asExternalUri(vscode.Uri.parse(loopback.uri))).toString(true));
250 if (external.protocol === "http:" && /^(127\.0\.0\.1|localhost)$/.test(external.hostname)) {
251 redirectUri = `http://127.0.0.1:${external.port || loopback.port}/callback`;
252 wait = loopback.result;
253 } else {
254 loopback.close();
255 }
256 }
257 if (!redirectUri) {
258 redirectUri = editorRedirect(vscode.env.uriScheme);
259 if (!redirectUri) throw new SignInError("This editor can't receive Diffinery's sign-in. Run “Diffinery: Sign In with an Access Token” instead.");
260 wait = new Promise((resolve) => this.callbacks.set(state, resolve));
261 }
262 try {
263 const url = authorizeUrl(server, redirectUri, challenge, state);
264 this.log.info(`opening ${server}/oauth/authorize for the editor's sign-in (redirect ${redirectUri})`);
265 const opened = await vscode.env.openExternal(vscode.Uri.parse(url, true));
266 const query = await vscode.window.withProgress(
267 { location: vscode.ProgressLocation.Notification, title: "Signing in to Diffinery", cancellable: true },
268 async (progress, cancel) => {
269 progress.report({ message: opened ? "Approve the connection in your browser…" : `Open ${url} in your browser and approve the connection…` });
270 return Promise.race([
271 wait!,
272 new Promise<never>((_, reject) => cancel.onCancellationRequested(() => reject(new vscode.CancellationError()))),
273 new Promise<never>((_, reject) => setTimeout(() => reject(new SignInError("Signing in to Diffinery timed out. Try again.")), SIGN_IN_TIMEOUT_MS)),
274 ]);
275 },
276 );
277 const r = readCallback(query, { state, server });
278 if ("error" in r) throw new SignInError(r.error);
279 const tok = await tokenRequest(server, { grant_type: "authorization_code", code: r.code, code_verifier: verifier, client_id: clientId(server), redirect_uri: redirectUri });
280 const user = await new Api(server, tok.access_token, this.version).user();
281 return {
282 id: randomUUID(),
283 server,
284 login: user.login,
285 accountId: String(user.id),
286 kind: "oauth",
287 accessToken: tok.access_token,
288 refreshToken: tok.refresh_token,
289 expiresAt: Date.now() + (tok.expires_in ?? 8 * 3600) * 1000,
290 };
291 } finally {
292 this.callbacks.delete(state);
293 loopback?.close();
294 }
295 }
296
297 /**
298 * Signs in with an access token someone pastes (for an agent account, a self-hosted server, or a browser-based
299 * editor). It goes through the editor's own sign-in, like the browser sign-in, so the editor records that this
300 * extension may use the account and doesn't ask again on the first clone.
301 */
302 async signInWithToken(): Promise<void> {
303 const signedIn = await this.load();
304 this.withToken = true;
305 try {
306 await vscode.authentication.getSession(AUTH_ID, SCOPES, signedIn ? { forceNewSession: { detail: `Replace ${signedIn.login}'s sign-in with an access token.` } } : { createIfNone: true });
307 } finally {
308 this.withToken = false;
309 }
310 }
311
312 private async askForToken(): Promise<Stored> {
313 const server = this.server();
314 const token = (
315 await vscode.window.showInputBox({
316 title: "Sign in to Diffinery with an access token",
317 prompt: `Paste a token from ${server}/settings/tokens (write access lets you push). It stays in the editor's secret storage.`,
318 password: true,
319 ignoreFocusOut: true,
320 validateInput: (v) => (/^\s*\S+\s*$/.test(v) ? null : "Paste the whole token, without spaces."),
321 })
322 )?.trim();
323 if (!token) throw new vscode.CancellationError();
324 const user = await vscode.window.withProgress({ location: vscode.ProgressLocation.Notification, title: "Checking the token with Diffinery…" }, () => new Api(server, token, this.version).user());
325 return { id: randomUUID(), server, login: user.login, accountId: String(user.id), kind: "token", accessToken: token };
326 }
327
328 /** Who is signed in to the server in the settings, if anyone. */
329 async login(): Promise<string | undefined> {
330 return (await this.load())?.login;
331 }
332
333 async signOut(): Promise<void> {
334 const s = await this.load();
335 if (!s) {
336 void vscode.window.showInformationMessage("You aren't signed in to Diffinery.");
337 return;
338 }
339 await this.removeSession(s.id);
340 void vscode.window.showInformationMessage(`Signed out of Diffinery (${s.login}).`);
341 }
342
343 /** An API client for the signed-in account, asking the person to sign in when `ask` and nobody is. */
344 async api(ask: boolean): Promise<Api | undefined> {
345 const session = await vscode.authentication.getSession(AUTH_ID, SCOPES, ask ? { createIfNone: true } : { silent: true });
346 return session ? new Api(this.server(), session.accessToken, this.version) : undefined;
347 }
348}
349
350const toSession = (s: Stored): vscode.AuthenticationSession => ({ id: s.id, accessToken: s.accessToken, account: { id: s.accountId, label: s.login }, scopes: SCOPES });
351
352/** POSTs to the token endpoint. SignInError when the server refuses (the grant is gone); other errors mean it wasn't reached. */
353async function tokenRequest(server: string, form: Record<string, string>): Promise<TokenResponse> {
354 const res = await fetch(`${server}/oauth/token`, {
355 method: "POST",
356 headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json" },
357 body: new URLSearchParams(form),
358 signal: AbortSignal.timeout(30_000),
359 });
360 let body: TokenResponse;
361 try {
362 body = (await res.json()) as TokenResponse;
363 } catch {
364 throw new Error(`Diffinery's token endpoint answered ${res.status}`);
365 }
366 if (res.status >= 500) throw new Error(`Diffinery's token endpoint answered ${res.status}`);
367 if (!res.ok || !body.access_token) throw new SignInError(body.error_description || body.error || `Diffinery refused the sign-in (${res.status}).`);
368 return body;
369}
370
371interface Loopback {
372 uri: string;
373 port: number;
374 result: Promise<string>;
375 close(): void;
376}
377
378/** A listener on 127.0.0.1 that takes one browser callback for this sign-in and says it can be closed. */
379function listenOnce(state: string): Promise<Loopback> {
380 return new Promise((resolve, reject) => {
381 let done: (q: string) => void;
382 const result = new Promise<string>((r) => (done = r));
383 const server = http.createServer((req, res) => {
384 const u = new URL(req.url ?? "/", "http://127.0.0.1");
385 if (u.pathname !== "/callback" || u.searchParams.get("state") !== state) {
386 res.writeHead(404).end();
387 return;
388 }
389 const ok = !u.searchParams.has("error");
390 res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
391 res.end(
392 `<!doctype html><meta charset="utf-8"><title>Diffinery</title><body style="font:16px system-ui,sans-serif;margin:15vh auto;max-width:28rem;text-align:center"><h1 style="font-size:20px">${ok ? "You're signed in to Diffinery" : "Sign-in cancelled"}</h1><p>You can close this tab and go back to your editor.</p></body>`,
393 );
394 done(u.search.slice(1));
395 });
396 server.on("error", reject);
397 server.listen(0, "127.0.0.1", () => {
398 const port = (server.address() as { port: number }).port;
399 resolve({ uri: `http://127.0.0.1:${port}/callback`, port, result, close: () => server.close() });
400 });
401 });
402}