Files
triggerdotdev--trigger.dev/apps/webapp/app/services/gitHubSession.server.ts
T
Saadi Myftija 436d951b65 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
2025-09-02 16:35:33 +02:00

125 lines
3.3 KiB
TypeScript

import { createCookieSessionStorage } from "@remix-run/node";
import { randomBytes } from "crypto";
import { env } from "../env.server";
import { logger } from "./logger.server";
const sessionStorage = createCookieSessionStorage({
cookie: {
name: "__github_app_install",
httpOnly: true,
maxAge: 60 * 60, // 1 hour
path: "/",
sameSite: "lax",
secrets: [env.SESSION_SECRET],
secure: env.NODE_ENV === "production",
},
});
/**
* Creates a secure session for GitHub App installation with organization tracking
*/
export async function createGitHubAppInstallSession(
organizationId: string,
redirectTo: string
): Promise<{ url: string; cookieHeader: string }> {
if (env.GITHUB_APP_ENABLED !== "1") {
throw new Error("GitHub App is not enabled");
}
const state = randomBytes(32).toString("hex");
const session = await sessionStorage.getSession();
session.set("organizationId", organizationId);
session.set("redirectTo", redirectTo);
session.set("state", state);
session.set("createdAt", Date.now());
const githubAppSlug = env.GITHUB_APP_SLUG;
// the state query param gets passed through to the installation callback
const url = `https://github.com/apps/${githubAppSlug}/installations/new?state=${state}`;
const cookieHeader = await sessionStorage.commitSession(session);
return { url, cookieHeader };
}
/**
* Validates and retrieves the GitHub App installation session
*/
export async function validateGitHubAppInstallSession(
cookieHeader: string | null,
state: string
): Promise<
{ valid: true; organizationId: string; redirectTo: string } | { valid: false; error?: string }
> {
if (!cookieHeader) {
return {
valid: false,
error: "No installation session cookie found",
};
}
const session = await sessionStorage.getSession(cookieHeader);
const sessionState = session.get("state");
const organizationId = session.get("organizationId");
const redirectTo = session.get("redirectTo");
const createdAt = session.get("createdAt");
if (!sessionState || !organizationId || !createdAt || !redirectTo) {
logger.warn("GitHub App installation session missing required fields", {
hasState: !!sessionState,
hasOrgId: !!organizationId,
hasCreatedAt: !!createdAt,
hasRedirectTo: !!redirectTo,
});
return {
valid: false,
error: "invalid_session_data",
};
}
if (sessionState !== state) {
logger.warn("GitHub App installation state mismatch", {
expectedState: sessionState,
receivedState: state,
});
return {
valid: false,
error: "state_mismatch",
};
}
const expirationTime = createdAt + 60 * 60 * 1000;
if (Date.now() > expirationTime) {
logger.warn("GitHub App installation session expired", {
createdAt: new Date(createdAt),
now: new Date(),
});
return {
valid: false,
error: "session_expired",
};
}
return {
valid: true,
organizationId,
redirectTo,
};
}
/**
* Destroys the GitHub App installation cookie session
*/
export async function destroyGitHubAppInstallSession(cookieHeader: string | null): Promise<string> {
if (!cookieHeader) {
return "";
}
const session = await sessionStorage.getSession(cookieHeader);
return await sessionStorage.destroySession(session);
}