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.

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