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.

360 lines · 16.1 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 = await this.signInWithBrowser();
188 await this.save(s);
189 void vscode.window.showInformationMessage(`Signed in to Diffinery as ${s.login}.`);
190 return toSession(s);
191 }
192
193 async removeSession(id: string): Promise<void> {
194 const s = await this.load();
195 if (!s || s.id !== id) return;
196 await this.save(undefined);
197 // Ends the connection on the server too (Settings → Access tokens → Connected apps). A pasted token is the
198 // person's own and may be used elsewhere, so it's only forgotten here.
199 if (s.kind === "oauth" && s.refreshToken) {
200 await fetch(`${s.server}/oauth/revoke`, {
201 method: "POST",
202 headers: { "content-type": "application/x-www-form-urlencoded", "user-agent": `Diffinery-VSCode/${this.version}` },
203 body: new URLSearchParams({ token: s.refreshToken, client_id: clientId(s.server) }),
204 signal: AbortSignal.timeout(10_000),
205 }).catch((e) => this.log.warn("revoking the Diffinery connection failed", e));
206 }
207 }
208
209 // ------------------------------------------------------------------ signing in
210
211 /** The editor's URL handler: /oauth is where the browser comes back after approving. */
212 handleUri(uri: vscode.Uri): boolean {
213 if (uri.path !== "/oauth") return false;
214 const state = new URLSearchParams(uri.query).get("state") ?? "";
215 const done = this.callbacks.get(state);
216 if (!done) {
217 void vscode.window.showWarningMessage("This Diffinery sign-in link is from an earlier attempt. Sign in again from the editor.");
218 return true;
219 }
220 done(uri.query);
221 return true;
222 }
223
224 private async signInWithBrowser(): Promise<Stored> {
225 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”.");
226 const server = this.server();
227 const { verifier, challenge, state } = pkce();
228 let redirectUri = editorRedirect(vscode.env.uriScheme);
229 let wait: Promise<string>;
230 let loopback: Loopback | undefined;
231 if (redirectUri) {
232 wait = new Promise((resolve) => this.callbacks.set(state, resolve));
233 } else {
234 // An editor the server has no scheme for: a one-time listener on this computer instead (RFC 8252).
235 loopback = await listenOnce(state);
236 const external = await vscode.env.asExternalUri(vscode.Uri.parse(loopback.uri));
237 redirectUri = loopback.uri.replace(/:\d+\//, `:${new URL(external.toString(true)).port || loopback.port}/`);
238 wait = loopback.result;
239 }
240 try {
241 const url = authorizeUrl(server, redirectUri, challenge, state);
242 this.log.info(`opening ${server}/oauth/authorize for the editor's sign-in (redirect ${redirectUri})`);
243 const opened = await vscode.env.openExternal(vscode.Uri.parse(url, true));
244 const query = await vscode.window.withProgress(
245 { location: vscode.ProgressLocation.Notification, title: "Signing in to Diffinery", cancellable: true },
246 async (progress, cancel) => {
247 progress.report({ message: opened ? "Approve the connection in your browser…" : `Open ${url} in your browser and approve the connection…` });
248 return Promise.race([
249 wait,
250 new Promise<never>((_, reject) => cancel.onCancellationRequested(() => reject(new vscode.CancellationError()))),
251 new Promise<never>((_, reject) => setTimeout(() => reject(new SignInError("Signing in to Diffinery timed out. Try again.")), SIGN_IN_TIMEOUT_MS)),
252 ]);
253 },
254 );
255 const r = readCallback(query, { state, server });
256 if ("error" in r) throw new SignInError(r.error);
257 const tok = await tokenRequest(server, { grant_type: "authorization_code", code: r.code, code_verifier: verifier, client_id: clientId(server), redirect_uri: redirectUri });
258 const user = await new Api(server, tok.access_token, this.version).user();
259 return {
260 id: randomUUID(),
261 server,
262 login: user.login,
263 accountId: String(user.id),
264 kind: "oauth",
265 accessToken: tok.access_token,
266 refreshToken: tok.refresh_token,
267 expiresAt: Date.now() + (tok.expires_in ?? 8 * 3600) * 1000,
268 };
269 } finally {
270 this.callbacks.delete(state);
271 loopback?.close();
272 }
273 }
274
275 /** Signs in with an access token someone pastes (for an agent account, a self-hosted server, or a browser-based editor). */
276 async signInWithToken(): Promise<void> {
277 const server = this.server();
278 const token = await vscode.window.showInputBox({
279 title: "Sign in to Diffinery with an access token",
280 prompt: `Paste a token from ${server}/settings/tokens (write access lets you push). It stays in the editor's secret storage.`,
281 password: true,
282 ignoreFocusOut: true,
283 validateInput: (v) => (/^\s*\S+\s*$/.test(v) ? null : "Paste the whole token, without spaces."),
284 });
285 if (!token) return;
286 const user = await vscode.window.withProgress({ location: vscode.ProgressLocation.Notification, title: "Checking the token with Diffinery…" }, () => new Api(server, token.trim(), this.version).user());
287 await this.save({ id: randomUUID(), server, login: user.login, accountId: String(user.id), kind: "token", accessToken: token.trim() });
288 void vscode.window.showInformationMessage(`Signed in to Diffinery as ${user.login}.`);
289 }
290
291 async signOut(): Promise<void> {
292 const s = await this.load();
293 if (!s) {
294 void vscode.window.showInformationMessage("You aren't signed in to Diffinery.");
295 return;
296 }
297 await this.removeSession(s.id);
298 void vscode.window.showInformationMessage(`Signed out of Diffinery (${s.login}).`);
299 }
300
301 /** An API client for the signed-in account, asking the person to sign in when `ask` and nobody is. */
302 async api(ask: boolean): Promise<Api | undefined> {
303 const session = await vscode.authentication.getSession(AUTH_ID, SCOPES, ask ? { createIfNone: true } : { silent: true });
304 return session ? new Api(this.server(), session.accessToken, this.version) : undefined;
305 }
306}
307
308const toSession = (s: Stored): vscode.AuthenticationSession => ({ id: s.id, accessToken: s.accessToken, account: { id: s.accountId, label: s.login }, scopes: SCOPES });
309
310/** POSTs to the token endpoint. SignInError when the server refuses (the grant is gone); other errors mean it wasn't reached. */
311async function tokenRequest(server: string, form: Record<string, string>): Promise<TokenResponse> {
312 const res = await fetch(`${server}/oauth/token`, {
313 method: "POST",
314 headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json" },
315 body: new URLSearchParams(form),
316 signal: AbortSignal.timeout(30_000),
317 });
318 let body: TokenResponse;
319 try {
320 body = (await res.json()) as TokenResponse;
321 } catch {
322 throw new Error(`Diffinery's token endpoint answered ${res.status}`);
323 }
324 if (res.status >= 500) throw new Error(`Diffinery's token endpoint answered ${res.status}`);
325 if (!res.ok || !body.access_token) throw new SignInError(body.error_description || body.error || `Diffinery refused the sign-in (${res.status}).`);
326 return body;
327}
328
329interface Loopback {
330 uri: string;
331 port: number;
332 result: Promise<string>;
333 close(): void;
334}
335
336/** A listener on 127.0.0.1 that takes one browser callback for this sign-in and says it can be closed. */
337function listenOnce(state: string): Promise<Loopback> {
338 return new Promise((resolve, reject) => {
339 let done: (q: string) => void;
340 const result = new Promise<string>((r) => (done = r));
341 const server = http.createServer((req, res) => {
342 const u = new URL(req.url ?? "/", "http://127.0.0.1");
343 if (u.pathname !== "/callback" || u.searchParams.get("state") !== state) {
344 res.writeHead(404).end();
345 return;
346 }
347 const ok = !u.searchParams.has("error");
348 res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
349 res.end(
350 `<!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>`,
351 );
352 done(u.search.slice(1));
353 });
354 server.on("error", reject);
355 server.listen(0, "127.0.0.1", () => {
356 const port = (server.address() as { port: number }).port;
357 resolve({ uri: `http://127.0.0.1:${port}/callback`, port, result, close: () => server.close() });
358 });
359 });
360}