Skip to content

Commit 436d951

Browse files
authored
feat(webapp): github app installation flow (#2463)
* Add schemas for gh app installations * Implement gh app installation flow * Make the gh app configs optional * Add additional org check on gh app installation callback * Save account handle and repo default branch on install * Do repo hard deletes in favor of simplicity * Disable github app by default * Fix gh env schema union issue * Use octokit's iterator for paginating repos * Parse gh app install callback with a discriminated union * Remove duplicate env vars * Use bigint for github integer IDs * Sanitize redirect paths in the gh installation and auth flow * Regenerate migration after rebase on main to fix ordering * Handle gh install updates separately from new installs
1 parent cf9398b commit 436d951

File tree

13 files changed

+2114
-1114
lines changed

13 files changed

+2114
-1114
lines changed

apps/webapp/app/env.server.ts

Lines changed: 1168 additions & 1094 deletions
Large diffs are not rendered by default.
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import { type LoaderFunctionArgs } from "@remix-run/node";
2+
import { z } from "zod";
3+
import { validateGitHubAppInstallSession } from "~/services/gitHubSession.server";
4+
import { linkGitHubAppInstallation, updateGitHubAppInstallation } from "~/services/gitHub.server";
5+
import { logger } from "~/services/logger.server";
6+
import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server";
7+
import { tryCatch } from "@trigger.dev/core";
8+
import { $replica } from "~/db.server";
9+
import { requireUser } from "~/services/session.server";
10+
import { sanitizeRedirectPath } from "~/utils";
11+
12+
const QuerySchema = z.discriminatedUnion("setup_action", [
13+
z.object({
14+
setup_action: z.literal("install"),
15+
installation_id: z.coerce.number(),
16+
state: z.string(),
17+
}),
18+
z.object({
19+
setup_action: z.literal("update"),
20+
installation_id: z.coerce.number(),
21+
state: z.string(),
22+
}),
23+
z.object({
24+
setup_action: z.literal("request"),
25+
state: z.string(),
26+
}),
27+
]);
28+
29+
export async function loader({ request }: LoaderFunctionArgs) {
30+
const url = new URL(request.url);
31+
const queryParams = Object.fromEntries(url.searchParams);
32+
const cookieHeader = request.headers.get("Cookie");
33+
34+
const result = QuerySchema.safeParse(queryParams);
35+
36+
if (!result.success) {
37+
logger.warn("GitHub App callback with invalid params", {
38+
queryParams,
39+
});
40+
return redirectWithErrorMessage("/", request, "Failed to install GitHub App");
41+
}
42+
43+
const callbackData = result.data;
44+
45+
const sessionResult = await validateGitHubAppInstallSession(cookieHeader, callbackData.state);
46+
47+
if (!sessionResult.valid) {
48+
logger.error("GitHub App callback with invalid session", {
49+
callbackData,
50+
error: sessionResult.error,
51+
});
52+
53+
return redirectWithErrorMessage("/", request, "Failed to install GitHub App");
54+
}
55+
56+
const { organizationId, redirectTo: unsafeRedirectTo } = sessionResult;
57+
const redirectTo = sanitizeRedirectPath(unsafeRedirectTo);
58+
59+
const user = await requireUser(request);
60+
const org = await $replica.organization.findFirst({
61+
where: { id: organizationId, members: { some: { userId: user.id } }, deletedAt: null },
62+
orderBy: { createdAt: "desc" },
63+
select: {
64+
id: true,
65+
},
66+
});
67+
68+
if (!org) {
69+
// the secure cookie approach should already protect against this
70+
// just an additional check
71+
logger.error("GitHub app installation attempt on unauthenticated org", {
72+
userId: user.id,
73+
organizationId,
74+
});
75+
return redirectWithErrorMessage(redirectTo, request, "Failed to install GitHub App");
76+
}
77+
78+
switch (callbackData.setup_action) {
79+
case "install": {
80+
const [error] = await tryCatch(
81+
linkGitHubAppInstallation(callbackData.installation_id, organizationId)
82+
);
83+
84+
if (error) {
85+
logger.error("Failed to link GitHub App installation", {
86+
error,
87+
});
88+
return redirectWithErrorMessage(redirectTo, request, "Failed to install GitHub App");
89+
}
90+
91+
return redirectWithSuccessMessage(redirectTo, request, "GitHub App installed successfully");
92+
}
93+
94+
case "update": {
95+
const [error] = await tryCatch(updateGitHubAppInstallation(callbackData.installation_id));
96+
97+
if (error) {
98+
logger.error("Failed to update GitHub App installation", {
99+
error,
100+
});
101+
return redirectWithErrorMessage(redirectTo, request, "Failed to update GitHub App");
102+
}
103+
104+
return redirectWithSuccessMessage(redirectTo, request, "GitHub App updated successfully");
105+
}
106+
107+
case "request": {
108+
// This happens when a non-admin user requests installation
109+
// The installation_id won't be available until an admin approves
110+
logger.info("GitHub App installation requested, awaiting approval", {
111+
callbackData,
112+
});
113+
114+
return redirectWithSuccessMessage(redirectTo, request, "GitHub App installation requested");
115+
}
116+
117+
default:
118+
callbackData satisfies never;
119+
return redirectWithErrorMessage(redirectTo, request, "Failed to install GitHub App");
120+
}
121+
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
2+
import { redirect } from "remix-typedjson";
3+
import { z } from "zod";
4+
import { $replica } from "~/db.server";
5+
import { createGitHubAppInstallSession } from "~/services/gitHubSession.server";
6+
import { requireUser } from "~/services/session.server";
7+
import { newOrganizationPath } from "~/utils/pathBuilder";
8+
import { logger } from "~/services/logger.server";
9+
import { sanitizeRedirectPath } from "~/utils";
10+
11+
const QuerySchema = z.object({
12+
org_slug: z.string(),
13+
redirect_to: z.string().refine((value) => value === sanitizeRedirectPath(value), {
14+
message: "Invalid redirect path",
15+
}),
16+
});
17+
18+
export const loader = async ({ request }: LoaderFunctionArgs) => {
19+
const searchParams = new URL(request.url).searchParams;
20+
const parsed = QuerySchema.safeParse(Object.fromEntries(searchParams));
21+
22+
if (!parsed.success) {
23+
logger.warn("GitHub App installation redirect with invalid params", {
24+
searchParams,
25+
error: parsed.error,
26+
});
27+
throw redirect("/");
28+
}
29+
30+
const { org_slug, redirect_to } = parsed.data;
31+
const user = await requireUser(request);
32+
33+
const org = await $replica.organization.findFirst({
34+
where: { slug: org_slug, members: { some: { userId: user.id } }, deletedAt: null },
35+
orderBy: { createdAt: "desc" },
36+
select: {
37+
id: true,
38+
},
39+
});
40+
41+
if (!org) {
42+
throw redirect(newOrganizationPath());
43+
}
44+
45+
const { url, cookieHeader } = await createGitHubAppInstallSession(org.id, redirect_to);
46+
47+
return redirect(url, {
48+
headers: {
49+
"Set-Cookie": cookieHeader,
50+
},
51+
});
52+
};

apps/webapp/app/routes/auth.github.callback.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,12 @@ import { getSession, redirectWithErrorMessage } from "~/models/message.server";
55
import { authenticator } from "~/services/auth.server";
66
import { commitSession } from "~/services/sessionStorage.server";
77
import { redirectCookie } from "./auth.github";
8+
import { sanitizeRedirectPath } from "~/utils";
89

910
export let loader: LoaderFunction = async ({ request }) => {
1011
const cookie = request.headers.get("Cookie");
1112
const redirectValue = await redirectCookie.parse(cookie);
12-
const redirectTo = redirectValue ?? "/";
13+
const redirectTo = sanitizeRedirectPath(redirectValue);
1314

1415
const auth = await authenticator.authenticate("github", request, {
1516
failureRedirect: "/login", // If auth fails, the failureRedirect will be thrown as a Response

apps/webapp/app/routes/auth.github.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,4 @@
1-
import type { ActionFunction, LoaderFunction } from "@remix-run/node";
2-
import { createCookie } from "@remix-run/node";
3-
import { redirect } from "@remix-run/node";
1+
import { type ActionFunction, type LoaderFunction, redirect, createCookie } from "@remix-run/node";
42
import { authenticator } from "~/services/auth.server";
53

64
export let loader: LoaderFunction = () => redirect("/login");
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
import { App, type Octokit } from "octokit";
2+
import { env } from "../env.server";
3+
import { prisma } from "~/db.server";
4+
import { logger } from "./logger.server";
5+
6+
export const githubApp =
7+
env.GITHUB_APP_ENABLED === "1"
8+
? new App({
9+
appId: env.GITHUB_APP_ID,
10+
privateKey: env.GITHUB_APP_PRIVATE_KEY,
11+
webhooks: {
12+
secret: env.GITHUB_APP_WEBHOOK_SECRET,
13+
},
14+
})
15+
: null;
16+
17+
/**
18+
* Links a GitHub App installation to a Trigger organization
19+
*/
20+
export async function linkGitHubAppInstallation(
21+
installationId: number,
22+
organizationId: string
23+
): Promise<void> {
24+
if (!githubApp) {
25+
throw new Error("GitHub App is not enabled");
26+
}
27+
28+
const octokit = await githubApp.getInstallationOctokit(installationId);
29+
const { data: installation } = await octokit.rest.apps.getInstallation({
30+
installation_id: installationId,
31+
});
32+
33+
const repositories = await fetchInstallationRepositories(octokit, installationId);
34+
35+
const repositorySelection = installation.repository_selection === "all" ? "ALL" : "SELECTED";
36+
37+
await prisma.githubAppInstallation.create({
38+
data: {
39+
appInstallationId: installationId,
40+
organizationId,
41+
targetId: installation.target_id,
42+
targetType: installation.target_type,
43+
accountHandle: installation.account
44+
? "login" in installation.account
45+
? installation.account.login
46+
: "slug" in installation.account
47+
? installation.account.slug
48+
: "-"
49+
: "-",
50+
permissions: installation.permissions,
51+
repositorySelection,
52+
repositories: {
53+
create: repositories,
54+
},
55+
},
56+
});
57+
}
58+
59+
/**
60+
* Links a GitHub App installation to a Trigger organization
61+
*/
62+
export async function updateGitHubAppInstallation(installationId: number): Promise<void> {
63+
if (!githubApp) {
64+
throw new Error("GitHub App is not enabled");
65+
}
66+
67+
const octokit = await githubApp.getInstallationOctokit(installationId);
68+
const { data: installation } = await octokit.rest.apps.getInstallation({
69+
installation_id: installationId,
70+
});
71+
72+
const existingInstallation = await prisma.githubAppInstallation.findFirst({
73+
where: { appInstallationId: installationId },
74+
});
75+
76+
if (!existingInstallation) {
77+
throw new Error("GitHub App installation not found");
78+
}
79+
80+
const repositorySelection = installation.repository_selection === "all" ? "ALL" : "SELECTED";
81+
82+
// repos are updated asynchronously via webhook events
83+
await prisma.githubAppInstallation.update({
84+
where: { id: existingInstallation?.id },
85+
data: {
86+
appInstallationId: installationId,
87+
targetId: installation.target_id,
88+
targetType: installation.target_type,
89+
accountHandle: installation.account
90+
? "login" in installation.account
91+
? installation.account.login
92+
: "slug" in installation.account
93+
? installation.account.slug
94+
: "-"
95+
: "-",
96+
permissions: installation.permissions,
97+
suspendedAt: existingInstallation?.suspendedAt,
98+
repositorySelection,
99+
},
100+
});
101+
}
102+
103+
async function fetchInstallationRepositories(octokit: Octokit, installationId: number) {
104+
const iterator = octokit.paginate.iterator(octokit.rest.apps.listReposAccessibleToInstallation, {
105+
installation_id: installationId,
106+
per_page: 100,
107+
});
108+
109+
const allRepos = [];
110+
const maxPages = 3;
111+
let pageCount = 0;
112+
113+
for await (const { data } of iterator) {
114+
pageCount++;
115+
allRepos.push(...data);
116+
117+
if (maxPages && pageCount >= maxPages) {
118+
logger.warn("GitHub installation repository fetch truncated", {
119+
installationId,
120+
maxPages,
121+
totalReposFetched: allRepos.length,
122+
});
123+
break;
124+
}
125+
}
126+
127+
return allRepos.map((repo) => ({
128+
githubId: repo.id,
129+
name: repo.name,
130+
fullName: repo.full_name,
131+
htmlUrl: repo.html_url,
132+
private: repo.private,
133+
defaultBranch: repo.default_branch,
134+
}));
135+
}

0 commit comments

Comments
 (0)