import { env } from "cloudflare:workers"; import { z } from "zod"; import { cookieAttributes, cookieName, cookieValue, viewerFromSessionCookie } from "../auth"; import { randomToken, timingSafeEqualStrings } from "../crypto"; import { DEFAULT_INTEGRATION_EVENTS, DEFAULT_FILTERS, DEFAULT_LINEAR_MAPPING } from "~/lib/integrations"; import { isSuccess, outboundFetch } from "./outbound"; import { linearGraphql } from "./linear"; import { requireIntegrationScope } from "./scope"; import type { ResolvedScope } from "./scope"; import { insertIntegration } from "./store"; import { publicUrl } from "../events/emit"; const STATE_TTL_SECONDS = 600; const STATE_COOKIE = "gitfor_integration_oauth"; type Provider = "slack" | "linear" | "github"; type PendingInstall = { provider: Provider; viewerId: string; scope: ResolvedScope }; function callbackUrl(provider: Provider): string { return publicUrl(`/integrations/${provider}/callback`); } function redirect(location: string, cookie?: string): Response { const headers: Record = { location }; if (cookie) headers["set-cookie"] = cookie; return new Response(null, { status: 302, headers }); } function failure(message: string, status = 400): Response { return new Response(`${message}\n`, { status, headers: { "content-type": "text/plain; charset=utf-8" } }); } /** Starts an install: checks the viewer administers the scope, then binds a one-time state to KV and a cookie. */ async function begin(request: Request, provider: Provider, authorizeUrl: (state: string) => string | null) { const url = new URL(request.url); const viewer = await viewerFromSessionCookie(request); const scope = await requireIntegrationScope( { owner: url.searchParams.get("owner") ?? "", repo: url.searchParams.get("repo") || null }, viewer, ); const state = randomToken("intg"); const target = authorizeUrl(state); if (!target || !viewer) return failure(`${provider} is not configured on this instance`, 404); const pending: PendingInstall = { provider, viewerId: viewer.id, scope }; await env.KV.put(`integrations:oauth:${state}`, JSON.stringify(pending), { expirationTtl: STATE_TTL_SECONDS }); return redirect(target, `${cookieName(STATE_COOKIE)}=${state}; ${cookieAttributes(STATE_TTL_SECONDS)}`); } async function finish(request: Request, provider: Provider): Promise { const url = new URL(request.url); const state = url.searchParams.get("state") ?? ""; const cookieState = cookieValue(request, cookieName(STATE_COOKIE)) ?? ""; if (!state || !timingSafeEqualStrings(state, cookieState)) return failure("Invalid or expired install state"); const key = `integrations:oauth:${state}`; const pending = await env.KV.get(key, "json"); await env.KV.delete(key); const viewer = await viewerFromSessionCookie(request); if (!pending || pending.provider !== provider || !viewer || viewer.id !== pending.viewerId) { return failure("Invalid or expired install state"); } return pending; } function done(scope: ResolvedScope): Response { return redirect(scope.settingsPath, `${cookieName(STATE_COOKIE)}=; ${cookieAttributes(0)}`); } const SLACK_SCOPES = "chat:write,commands,incoming-webhook"; const slackAccess = z.object({ ok: z.boolean(), error: z.string().optional(), access_token: z.string().optional(), team: z.object({ id: z.string(), name: z.string() }).optional(), incoming_webhook: z.object({ channel: z.string(), channel_id: z.string() }).optional(), }); async function finishSlack(request: Request): Promise { const pending = await finish(request, "slack"); if (pending instanceof Response) return pending; const code = new URL(request.url).searchParams.get("code"); if (!code || !env.SLACK_CLIENT_ID || !env.SLACK_CLIENT_SECRET) return failure("Slack install was cancelled"); const result = await outboundFetch("https://slack.com/api/oauth.v2.access", { headers: { "content-type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ code, client_id: env.SLACK_CLIENT_ID, client_secret: env.SLACK_CLIENT_SECRET, redirect_uri: callbackUrl("slack"), }).toString(), }); const parsed = isSuccess(result) ? slackAccess.safeParse(JSON.parse(result.body)) : null; if (!parsed?.success || !parsed.data.ok || !parsed.data.access_token || !parsed.data.team) { return failure(`Slack did not complete the install${parsed?.success ? `: ${parsed.data.error ?? ""}` : ""}`, 502); } const channel = parsed.data.incoming_webhook; await insertIntegration({ scopeType: pending.scope.scopeType, scopeId: pending.scope.scopeId, kind: "slack", name: `Slack · ${parsed.data.team.name}${channel ? ` ${channel.channel}` : ""}`, config: { filters: DEFAULT_FILTERS, slackTeam: parsed.data.team.id, slackChannels: channel ? [{ channel: channel.channel_id, events: DEFAULT_INTEGRATION_EVENTS }] : [], }, events: DEFAULT_INTEGRATION_EVENTS, credentials: { signingSecret: randomToken("whsec"), botToken: parsed.data.access_token }, createdBy: pending.viewerId, }); return done(pending.scope); } const linearToken = z.object({ access_token: z.string(), refresh_token: z.string().optional() }); async function finishLinear(request: Request): Promise { const pending = await finish(request, "linear"); if (pending instanceof Response) return pending; const code = new URL(request.url).searchParams.get("code"); if (!code || !env.LINEAR_CLIENT_ID || !env.LINEAR_CLIENT_SECRET) return failure("Linear install was cancelled"); const result = await outboundFetch("https://api.linear.app/oauth/token", { headers: { "content-type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ code, client_id: env.LINEAR_CLIENT_ID, client_secret: env.LINEAR_CLIENT_SECRET, redirect_uri: callbackUrl("linear"), grant_type: "authorization_code", }).toString(), }); const parsed = isSuccess(result) ? linearToken.safeParse(JSON.parse(result.body)) : null; if (!parsed?.success) return failure("Linear did not complete the install", 502); const token = parsed.data.access_token; const info = await linearGraphql<{ organization: { name: string }; teams: { nodes: Array<{ key: string }> } }>( token, "query { organization { name } teams(first: 100) { nodes { key } } }", {}, ); const inboundSecret = randomToken("lin"); const id = await insertIntegration({ scopeType: pending.scope.scopeType, scopeId: pending.scope.scopeId, kind: "linear", name: `Linear · ${info.data?.organization.name ?? "workspace"}`, config: { filters: DEFAULT_FILTERS, linearMapping: DEFAULT_LINEAR_MAPPING, linearTeamKeys: info.data?.teams.nodes.map((team) => team.key) ?? [], linearOrganization: info.data?.organization.name, }, events: ["pull_request"], credentials: { signingSecret: randomToken("whsec"), accessToken: token, refreshToken: parsed.data.refresh_token, inboundSecret, }, createdBy: pending.viewerId, }); await linearGraphql(token, "mutation($input: WebhookCreateInput!) { webhookCreate(input: $input) { success } }", { input: { url: publicUrl(`/integrations/hooks/linear/${id}`), secret: inboundSecret, resourceTypes: ["Issue"], allPublicTeams: true, label: `gitfor ${pending.scope.label}`, }, }); return done(pending.scope); } const MAX_REPO_PAGES = 10; const REPOS_PER_PAGE = 100; const userRepos = z.object({ repositories: z.array( z.object({ full_name: z.string(), permissions: z.object({ push: z.boolean().optional(), admin: z.boolean().optional() }).optional(), }), ), }); const githubUser = z.object({ login: z.string().min(1), id: z.number() }); type AllowedRepo = { name: string; push: boolean }; /** Repos this GitHub user can reach via the installation, so others' private repos can't be mirrored. */ async function githubPages( userToken: string, path: string, pick: (body: unknown) => T[] | null, ): Promise { const items: T[] = []; for (let page = 1; page <= MAX_REPO_PAGES; page++) { const separator = path.includes("?") ? "&" : "?"; const result = await outboundFetch( `https://api.github.com${path}${separator}per_page=${REPOS_PER_PAGE}&page=${page}`, { method: "GET", headers: { accept: "application/vnd.github+json", authorization: `Bearer ${userToken}` } }, 2 * 1024 * 1024, ); if (!isSuccess(result)) return null; let picked: T[] | null; try { picked = pick(JSON.parse(result.body)); } catch { return null; } if (!picked) return null; items.push(...picked); if (picked.length < REPOS_PER_PAGE) return items; } return items; } const userInstallations = z.object({ installations: z.array(z.object({ id: z.number() })) }); /** * The setup redirect's `installation_id` is attacker-controllable, so ownership is proven by exchanging the * user-authorization `code` and checking that installation is visible to that GitHub user. */ async function finishGithub(request: Request): Promise { const pending = await finish(request, "github"); if (pending instanceof Response) return pending; const url = new URL(request.url); const installationId = Number(url.searchParams.get("installation_id")); const code = url.searchParams.get("code"); if (!Number.isSafeInteger(installationId) || installationId <= 0 || !code) { return failure("GitHub did not return an installation. Enable 'Request user authorization during installation'."); } if (!env.GITHUB_APP_CLIENT_ID || !env.GITHUB_APP_CLIENT_SECRET) return failure("The GitHub App is not configured", 404); const exchange = await outboundFetch("https://github.com/login/oauth/access_token", { headers: { "content-type": "application/json", accept: "application/json" }, body: JSON.stringify({ client_id: env.GITHUB_APP_CLIENT_ID, client_secret: env.GITHUB_APP_CLIENT_SECRET, code }), }); const userToken = isSuccess(exchange) ? (JSON.parse(exchange.body) as { access_token?: string }).access_token : null; if (!userToken) return failure("GitHub did not authorize the installation", 502); const visible = await githubPages(userToken, "/user/installations", (body) => { const parsed = userInstallations.safeParse(body); return parsed.success ? parsed.data.installations.map((item) => item.id) : null; }); if (!visible?.includes(installationId)) { return failure("That GitHub App installation is not accessible to your GitHub account", 403); } const allowed = await githubPages( userToken, `/user/installations/${installationId}/repositories`, (body) => { const parsed = userRepos.safeParse(body); return parsed.success ? parsed.data.repositories.map((repo) => ({ name: repo.full_name.toLowerCase(), push: repo.permissions?.push === true || repo.permissions?.admin === true, })) : null; }, ); if (!allowed) return failure("Could not list the repositories this installation gives you access to", 502); const me = await outboundFetch("https://api.github.com/user", { method: "GET", headers: { accept: "application/vnd.github+json", authorization: `Bearer ${userToken}` }, }); const login = isSuccess(me) ? githubUser.safeParse(JSON.parse(me.body)) : null; if (!login?.success) return failure("Could not identify your GitHub account", 502); await insertIntegration({ scopeType: pending.scope.scopeType, scopeId: pending.scope.scopeId, kind: "github", name: `GitHub installation ${installationId}`, config: { filters: DEFAULT_FILTERS, githubInstallationId: installationId, githubMirror: "none", githubAllowedRepos: allowed.map((repo) => repo.name), githubPushRepos: allowed.filter((repo) => repo.push).map((repo) => repo.name), githubLogin: login.data.login, }, events: [], credentials: { signingSecret: randomToken("whsec") }, createdBy: pending.viewerId, }); return done(pending.scope); } export async function handleIntegrationOAuth(request: Request, url: URL): Promise { switch (url.pathname) { case "/integrations/slack/install": return begin(request, "slack", (state) => { if (!env.SLACK_CLIENT_ID) return null; const target = new URL("https://slack.com/oauth/v2/authorize"); target.searchParams.set("client_id", env.SLACK_CLIENT_ID); target.searchParams.set("scope", SLACK_SCOPES); target.searchParams.set("redirect_uri", callbackUrl("slack")); target.searchParams.set("state", state); return target.toString(); }); case "/integrations/slack/callback": return finishSlack(request); case "/integrations/linear/install": return begin(request, "linear", (state) => { if (!env.LINEAR_CLIENT_ID) return null; const target = new URL("https://linear.app/oauth/authorize"); target.searchParams.set("client_id", env.LINEAR_CLIENT_ID); target.searchParams.set("redirect_uri", callbackUrl("linear")); target.searchParams.set("response_type", "code"); target.searchParams.set("scope", "read,write"); target.searchParams.set("state", state); target.searchParams.set("actor", "app"); return target.toString(); }); case "/integrations/linear/callback": return finishLinear(request); case "/integrations/github/install": return begin(request, "github", (state) => { if (!env.GITHUB_APP_SLUG) return null; const target = new URL(`https://github.com/apps/${encodeURIComponent(env.GITHUB_APP_SLUG)}/installations/new`); target.searchParams.set("state", state); return target.toString(); }); case "/integrations/github/callback": return finishGithub(request); default: return null; } }