Skip to content

Commit 1f89a44

Browse files
committed
Implement gh app installation flow
1 parent 960fd54 commit 1f89a44

File tree

7 files changed

+714
-9
lines changed

7 files changed

+714
-9
lines changed

apps/webapp/app/env.server.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,12 @@ const EnvironmentSchema = z.object({
5959
SMTP_USER: z.string().optional(),
6060
SMTP_PASSWORD: z.string().optional(),
6161

62+
// GitHub App
63+
GITHUB_APP_ID: z.string(),
64+
GITHUB_APP_PRIVATE_KEY: z.string(),
65+
GITHUB_APP_WEBHOOK_SECRET: z.string(),
66+
GITHUB_APP_SLUG: z.string(),
67+
6268
PLAIN_API_KEY: z.string().optional(),
6369
WORKER_SCHEMA: z.string().default("graphile_worker"),
6470
WORKER_CONCURRENCY: z.coerce.number().int().default(10),
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { type LoaderFunctionArgs } from "@remix-run/node";
2+
import { z } from "zod";
3+
import { validateGitHubAppInstallSession } from "~/services/gitHubSession.server";
4+
import { linkGitHubAppInstallation } 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+
9+
const QuerySchema = z.object({
10+
installation_id: z.coerce.number(),
11+
setup_action: z.enum(["install", "update", "request"]),
12+
state: z.string(),
13+
});
14+
15+
export async function loader({ request }: LoaderFunctionArgs) {
16+
const url = new URL(request.url);
17+
const queryParams = Object.fromEntries(url.searchParams);
18+
const cookieHeader = request.headers.get("Cookie");
19+
20+
const result = QuerySchema.safeParse(queryParams);
21+
22+
if (!result.success) {
23+
logger.warn("GitHub App callback with invalid params", {
24+
queryParams,
25+
});
26+
return redirectWithErrorMessage("/", request, "Failed to install GitHub App");
27+
}
28+
29+
const { installation_id, setup_action, state } = result.data;
30+
31+
const sessionResult = await validateGitHubAppInstallSession(cookieHeader, state);
32+
33+
if (!sessionResult.valid) {
34+
logger.error("GitHub App callback with invalid session", {
35+
state,
36+
installation_id,
37+
error: sessionResult.error,
38+
});
39+
40+
return redirectWithErrorMessage("/", request, "Failed to install GitHub App");
41+
}
42+
43+
const { organizationId, redirectTo } = sessionResult;
44+
45+
switch (setup_action) {
46+
case "install":
47+
case "update": {
48+
const [error] = await tryCatch(linkGitHubAppInstallation(installation_id, organizationId));
49+
50+
if (error) {
51+
logger.error("Failed to link GitHub App installation", {
52+
error,
53+
});
54+
return redirectWithErrorMessage(redirectTo, request, "Failed to install GitHub App");
55+
}
56+
57+
return redirectWithSuccessMessage(redirectTo, request, "GitHub App installed successfully");
58+
}
59+
60+
case "request": {
61+
// This happens when a non-admin user requests installation
62+
// The installation_id won't be available until an admin approves
63+
logger.info("GitHub App installation requested, awaiting approval", {
64+
state,
65+
});
66+
67+
return redirectWithSuccessMessage(redirectTo, request, "GitHub App installation requested");
68+
}
69+
70+
default:
71+
setup_action satisfies never;
72+
return redirectWithErrorMessage(redirectTo, request, "Failed to install GitHub App");
73+
}
74+
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
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+
10+
const QuerySchema = z.object({
11+
org_slug: z.string(),
12+
redirect_to: z.string(),
13+
});
14+
15+
export const loader = async ({ request }: LoaderFunctionArgs) => {
16+
const searchParams = new URL(request.url).searchParams;
17+
const parsed = QuerySchema.safeParse(Object.fromEntries(searchParams));
18+
19+
if (!parsed.success) {
20+
logger.warn("GitHub App installation redirect with invalid params", {
21+
searchParams,
22+
error: parsed.error,
23+
});
24+
throw redirect("/");
25+
}
26+
27+
const { org_slug, redirect_to } = parsed.data;
28+
const user = await requireUser(request);
29+
30+
const org = await $replica.organization.findFirst({
31+
where: { slug: org_slug, members: { some: { userId: user.id } }, deletedAt: null },
32+
orderBy: { createdAt: "desc" },
33+
select: {
34+
id: true,
35+
},
36+
});
37+
38+
if (!org) {
39+
throw redirect(newOrganizationPath());
40+
}
41+
42+
const { url, cookieHeader } = await createGitHubAppInstallSession(org.id, redirect_to);
43+
44+
return redirect(url, {
45+
headers: {
46+
"Set-Cookie": cookieHeader,
47+
},
48+
});
49+
};
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { App, type Octokit } from "octokit";
2+
import { env } from "../env.server";
3+
import { prisma } from "~/db.server";
4+
5+
export const githubApp = new App({
6+
appId: env.GITHUB_APP_ID,
7+
privateKey: env.GITHUB_APP_PRIVATE_KEY,
8+
webhooks: {
9+
secret: env.GITHUB_APP_WEBHOOK_SECRET,
10+
},
11+
});
12+
13+
/**
14+
* Links a GitHub App installation to a Trigger organization
15+
*/
16+
export async function linkGitHubAppInstallation(
17+
installationId: number,
18+
organizationId: string
19+
): Promise<void> {
20+
const octokit = await githubApp.getInstallationOctokit(installationId);
21+
const { data: installation } = await octokit.rest.apps.getInstallation({
22+
installation_id: installationId,
23+
});
24+
25+
const repositories = await fetchInstallationRepositories(octokit, installationId);
26+
27+
const repositorySelection = installation.repository_selection === "all" ? "ALL" : "SELECTED";
28+
29+
await prisma.githubAppInstallation.create({
30+
data: {
31+
appInstallationId: installationId,
32+
organizationId,
33+
targetId: installation.target_id,
34+
targetType: installation.target_type,
35+
permissions: installation.permissions,
36+
repositorySelection,
37+
repositories: {
38+
create: repositories,
39+
},
40+
},
41+
});
42+
}
43+
44+
async function fetchInstallationRepositories(octokit: Octokit, installationId: number) {
45+
const all = [];
46+
let page = 1;
47+
const perPage = 100;
48+
const maxPages = 3;
49+
50+
while (page <= maxPages) {
51+
const { data: repoData } = await octokit.rest.apps.listReposAccessibleToInstallation({
52+
installation_id: installationId,
53+
per_page: perPage,
54+
page,
55+
});
56+
57+
all.push(...repoData.repositories);
58+
59+
if (repoData.repositories.length < perPage) {
60+
break;
61+
}
62+
63+
page++;
64+
}
65+
66+
return all.map((repo) => ({
67+
githubId: repo.id,
68+
name: repo.name,
69+
fullName: repo.full_name,
70+
htmlUrl: repo.html_url,
71+
private: repo.private,
72+
}));
73+
}
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import { createCookieSessionStorage } from "@remix-run/node";
2+
import { randomBytes } from "crypto";
3+
import { env } from "../env.server";
4+
import { logger } from "./logger.server";
5+
6+
const sessionStorage = createCookieSessionStorage({
7+
cookie: {
8+
name: "__github_app_install",
9+
httpOnly: true,
10+
maxAge: 60 * 60, // 1 hour
11+
path: "/",
12+
sameSite: "lax",
13+
secrets: [env.SESSION_SECRET],
14+
secure: env.NODE_ENV === "production",
15+
},
16+
});
17+
18+
/**
19+
* Creates a secure session for GitHub App installation with organization tracking
20+
*/
21+
export async function createGitHubAppInstallSession(
22+
organizationId: string,
23+
redirectTo: string
24+
): Promise<{ url: string; cookieHeader: string }> {
25+
const state = randomBytes(32).toString("hex");
26+
27+
const session = await sessionStorage.getSession();
28+
session.set("organizationId", organizationId);
29+
session.set("redirectTo", redirectTo);
30+
session.set("state", state);
31+
session.set("createdAt", Date.now());
32+
33+
const githubAppSlug = env.GITHUB_APP_SLUG;
34+
35+
// the state query param gets passed through to the installation callback
36+
const url = `https://github.com/apps/${githubAppSlug}/installations/new?state=${state}`;
37+
38+
const cookieHeader = await sessionStorage.commitSession(session);
39+
40+
return { url, cookieHeader };
41+
}
42+
43+
/**
44+
* Validates and retrieves the GitHub App installation session
45+
*/
46+
export async function validateGitHubAppInstallSession(
47+
cookieHeader: string | null,
48+
state: string
49+
): Promise<
50+
{ valid: true; organizationId: string; redirectTo: string } | { valid: false; error?: string }
51+
> {
52+
if (!cookieHeader) {
53+
return {
54+
valid: false,
55+
error: "No installation session cookie found",
56+
};
57+
}
58+
59+
const session = await sessionStorage.getSession(cookieHeader);
60+
61+
const sessionState = session.get("state");
62+
const organizationId = session.get("organizationId");
63+
const redirectTo = session.get("redirectTo");
64+
const createdAt = session.get("createdAt");
65+
66+
if (!sessionState || !organizationId || !createdAt || !redirectTo) {
67+
logger.warn("GitHub App installation session missing required fields", {
68+
hasState: !!sessionState,
69+
hasOrgId: !!organizationId,
70+
hasCreatedAt: !!createdAt,
71+
hasRedirectTo: !!redirectTo,
72+
});
73+
74+
return {
75+
valid: false,
76+
error: "invalid_session_data",
77+
};
78+
}
79+
80+
if (sessionState !== state) {
81+
logger.warn("GitHub App installation state mismatch", {
82+
expectedState: sessionState,
83+
receivedState: state,
84+
});
85+
return {
86+
valid: false,
87+
error: "state_mismatch",
88+
};
89+
}
90+
91+
const expirationTime = createdAt + 60 * 60 * 1000;
92+
if (Date.now() > expirationTime) {
93+
logger.warn("GitHub App installation session expired", {
94+
createdAt: new Date(createdAt),
95+
now: new Date(),
96+
});
97+
return {
98+
valid: false,
99+
error: "session_expired",
100+
};
101+
}
102+
103+
return {
104+
valid: true,
105+
organizationId,
106+
redirectTo,
107+
};
108+
}
109+
110+
/**
111+
* Destroys the GitHub App installation cookie session
112+
*/
113+
export async function destroyGitHubAppInstallSession(cookieHeader: string | null): Promise<string> {
114+
if (!cookieHeader) {
115+
return "";
116+
}
117+
118+
const session = await sessionStorage.getSession(cookieHeader);
119+
return await sessionStorage.destroySession(session);
120+
}

apps/webapp/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,8 +53,8 @@
5353
"@electric-sql/react": "^0.3.5",
5454
"@headlessui/react": "^1.7.8",
5555
"@heroicons/react": "^2.0.12",
56-
"@internal/redis": "workspace:*",
5756
"@internal/cache": "workspace:*",
57+
"@internal/redis": "workspace:*",
5858
"@internal/run-engine": "workspace:*",
5959
"@internal/schedule-engine": "workspace:*",
6060
"@internal/tracing": "workspace:*",
@@ -157,6 +157,7 @@
157157
"morgan": "^1.10.0",
158158
"nanoid": "3.3.8",
159159
"non.geist": "^1.0.2",
160+
"octokit": "^3.2.1",
160161
"ohash": "^1.1.3",
161162
"openai": "^4.33.1",
162163
"p-limit": "^6.2.0",

0 commit comments

Comments
 (0)