| 1 | import * as http from "node:http"; |
| 2 | import { randomUUID } from "node:crypto"; |
| 3 | import * as vscode from "vscode"; |
| 4 | import { Api } from "./api"; |
| 5 | import { authorizeUrl, clientId, editorRedirect, normalizeServer, pkce, readCallback } from "./util"; |
| 6 | |
| 7 | export 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. */ |
| 9 | export const SCOPES = ["write"]; |
| 10 | const SIGN_IN_TIMEOUT_MS = 10 * 60_000; |
| 11 | /** Refresh this long before the access token expires (they last 8 hours). */ |
| 12 | const REFRESH_MARGIN_MS = 10 * 60_000; |
| 13 | |
| 14 | /** A signed-in account as kept in the editor's secret storage. */ |
| 15 | export 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 | |
| 28 | interface TokenResponse { |
| 29 | access_token: string; |
| 30 | refresh_token?: string; |
| 31 | expires_in?: number; |
| 32 | error?: string; |
| 33 | error_description?: string; |
| 34 | } |
| 35 | |
| 36 | export 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 | */ |
| 44 | export 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 | |
| 308 | const 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. */ |
| 311 | async 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 | |
| 329 | interface 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. */ |
| 337 | function 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 | } |