diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f09a1cc..976f824 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -41,9 +41,21 @@ DiffKit is a **pnpm monorepo** managed with **Turborepo**: - **TanStack Router** — File-based routing in `apps/dashboard/src/routes/` - **TanStack Query** — Server state management and caching - **Drizzle ORM** — Database schema and migrations in `apps/dashboard/src/db/` and `apps/dashboard/drizzle/` -- **Better Auth** — Authentication with a GitHub OAuth App (+ GitHub App for webhooks) +- **Better Auth** — Authentication with a GitHub OAuth App, plus GitHub App user and installation tokens for installed repos - **Cloudflare D1** — SQLite database at the edge +### GitHub Integration + +DiffKit uses a hybrid GitHub auth model: + +- The **GitHub OAuth App** signs users in and powers broad user-context reads, including public or external repositories where the GitHub App is not installed. +- The **GitHub App user token** powers installation discovery, including `GET /user/installations`. +- The **GitHub App installation token** is preferred for repo-scoped reads and writes when the app is installed for that owner. + +Local development requires both app configs. The OAuth App callback is `/api/auth/callback/github`. The GitHub App user authorization callback is `/api/github/app/callback`, and the GitHub App setup URL is `/?show-org-setup=true` with **Redirect on update** enabled. + +Required local variables are documented in `apps/dashboard/.dev.vars.example`. Do not commit real `.dev.vars` values or private keys. If a private key is exposed, revoke it in GitHub App settings and generate a replacement. + ### Adding a New Route Routes live in `apps/dashboard/src/routes/`. TanStack Router uses file-based routing — create a new file and the route is automatically registered. diff --git a/README.md b/README.md index c7694aa..434c558 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,9 @@ A fast, design-first GitHub dashboard for developers who want to stay on top of GITHUB_OAUTH_CLIENT_SECRET=your_oauth_app_client_secret GITHUB_APP_CLIENT_ID=your_github_app_client_id GITHUB_APP_CLIENT_SECRET=your_github_app_client_secret + GITHUB_APP_ID=your_numeric_github_app_id + GITHUB_APP_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----\n...\n-----END RSA PRIVATE KEY-----\n" + GITHUB_APP_SLUG=your_github_app_slug GITHUB_WEBHOOK_SECRET=your_github_webhook_secret BETTER_AUTH_SECRET=a_random_32_character_string BETTER_AUTH_URL=http://localhost:3000 @@ -74,16 +77,30 @@ A fast, design-first GitHub dashboard for developers who want to stay on top of - Set the callback URL to `http://localhost:3000/api/auth/callback/github` - Note the **Client ID** and generate a **Client Secret** - The OAuth App handles user login and provides a token with `repo` scope, which gives broad read access to public repositories (needed for cross-references and timeline events). + The OAuth App handles user login and broad user-context reads. DiffKit requests `repo`, `read:org`, and `user:email` scopes. OAuth is also the fallback path for public or external repositories where the GitHub App is not installed, such as upstream open source repositories. 5. **Create and install the GitHub App** (for webhooks and installations) In [GitHub App settings](https://github.com/settings/apps): - - Set the callback URL to `http://localhost:3000/api/auth/callback/github` - - Grant the account permission `Email addresses: Read-only` + - Set the callback URL to `http://localhost:3000/api/github/app/callback` + - Set the setup URL to `http://localhost:3000/?show-org-setup=true` + - Enable **Redirect on update** + - Leave **Request user authorization (OAuth) during installation** unchecked + - Note the **Client ID**, generate a **Client Secret**, note the numeric **App ID**, and generate a private key - Install the app on the repositories or organizations you want DiffKit to access + The GitHub App user authorization flow stores a `ghu_` user-to-server token for installation discovery. Repo-scoped reads and writes prefer GitHub App installation tokens when the app is installed, and fall back to OAuth for external/public repositories. + + Store the downloaded private key as an escaped single-line value in `.dev.vars`. GitHub commonly downloads a PKCS#1 key with `BEGIN RSA PRIVATE KEY`; DiffKit normalizes it to the PKCS#8 format required by the GitHub App JWT library at runtime. + + ```bash + printf 'GITHUB_APP_PRIVATE_KEY="' > /tmp/github-app-private-key.env + sed 's/$/\\n/' /path/to/github-app-private-key.pem | tr -d '\n' >> /tmp/github-app-private-key.env + printf '"\n' >> /tmp/github-app-private-key.env + cat /tmp/github-app-private-key.env + ``` + Recommended GitHub App permissions derived from the current roadmap: | Roadmap area | Roadmap items | GitHub App permission | Level | Notes | diff --git a/apps/dashboard/.dev.vars.example b/apps/dashboard/.dev.vars.example index 11948ac..858759e 100644 --- a/apps/dashboard/.dev.vars.example +++ b/apps/dashboard/.dev.vars.example @@ -2,18 +2,29 @@ # 1. Go to https://github.com/settings/developers > OAuth Apps > New OAuth App # 2. Set the callback URL to http://localhost:3000/api/auth/callback/github # 3. Note the Client ID and generate a Client Secret -# OAuth App tokens support scopes (repo, user:email) and don't expire. +# OAuth App tokens support scopes (repo, read:org, user:email) and don't expire. +# Used for login plus public/external repository reads where the GitHub App is not installed. GITHUB_OAUTH_CLIENT_ID= GITHUB_OAUTH_CLIENT_SECRET= -# GitHub App credentials (used for webhooks and installation management) +# GitHub App credentials (used for installation discovery and app-scoped repo access) # 1. Go to https://github.com/settings/apps # 2. Create a new GitHub App (or use an existing one) -# 3. Set the callback URL to http://localhost:3000/api/auth/callback/github -# 4. Under Permissions & events, grant the permissions listed in the README -# 5. Install the app on the repositories or organizations you want DiffKit to access +# 3. Set the callback URL to http://localhost:3000/api/github/app/callback +# 4. Set the setup URL to http://localhost:3000/?show-org-setup=true +# 5. Enable "Redirect on update" +# 6. Leave "Request user authorization (OAuth) during installation" unchecked +# 7. Under Permissions & events, grant the permissions listed in the README +# 8. Install the app on the repositories or organizations you want DiffKit to access GITHUB_APP_CLIENT_ID= GITHUB_APP_CLIENT_SECRET= +# Numeric App ID from the GitHub App settings page +GITHUB_APP_ID= +# Private key generated from the GitHub App settings page. +# Store as a quoted value with escaped newlines: +# GITHUB_APP_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----\n...\n-----END RSA PRIVATE KEY-----\n" +# GitHub commonly downloads PKCS#1 ("BEGIN RSA PRIVATE KEY"); DiffKit normalizes it for Octokit at runtime. +GITHUB_APP_PRIVATE_KEY= # The slug from your GitHub App URL (https://github.com/apps/) GITHUB_APP_SLUG= diff --git a/apps/dashboard/README.md b/apps/dashboard/README.md index 3cbdeca..9d346b9 100644 --- a/apps/dashboard/README.md +++ b/apps/dashboard/README.md @@ -9,6 +9,28 @@ pnpm install pnpm dev ``` +## GitHub Configuration + +The dashboard requires both a GitHub OAuth App and a GitHub App. + +OAuth App: + +- Callback URL: `http://localhost:3000/api/auth/callback/github` +- Environment variables: `GITHUB_OAUTH_CLIENT_ID`, `GITHUB_OAUTH_CLIENT_SECRET` +- Used for login and public/external repository reads. + +GitHub App: + +- Callback URL: `http://localhost:3000/api/github/app/callback` +- Setup URL: `http://localhost:3000/?show-org-setup=true` +- Enable **Redirect on update** +- Leave **Request user authorization (OAuth) during installation** unchecked +- Environment variables: `GITHUB_APP_CLIENT_ID`, `GITHUB_APP_CLIENT_SECRET`, `GITHUB_APP_ID`, `GITHUB_APP_PRIVATE_KEY`, `GITHUB_APP_SLUG`, `GITHUB_WEBHOOK_SECRET` +- Webhook URL: `/api/webhooks/github` +- Used for installation discovery and app-scoped repo access. + +Copy `.dev.vars.example` to `.dev.vars` and fill in the real values. GitHub commonly downloads a PKCS#1 private key with `BEGIN RSA PRIVATE KEY`; the dashboard normalizes it for Octokit at runtime. Never commit `.dev.vars` or private keys. + # Building For Production To build this application for production: diff --git a/apps/dashboard/src/components/layouts/dashboard-layout.tsx b/apps/dashboard/src/components/layouts/dashboard-layout.tsx index d53a4f4..b780a99 100644 --- a/apps/dashboard/src/components/layouts/dashboard-layout.tsx +++ b/apps/dashboard/src/components/layouts/dashboard-layout.tsx @@ -1,10 +1,13 @@ import { useQuery } from "@tanstack/react-query"; import { getRouteApi, Outlet } from "@tanstack/react-router"; -import { lazy, Suspense } from "react"; +import { lazy, Suspense, useEffect, useRef } from "react"; +import { getGitHubAppAccessState } from "#/lib/github.functions"; import { githubMyIssuesQueryOptions, githubMyPullsQueryOptions, } from "#/lib/github.query"; +import { useShowOrgSetupQueryState } from "#/lib/github-access-dialog-query"; +import { openGitHubAccessPrompt } from "#/lib/github-access-modal-store"; import { useGitHubRevalidation } from "#/lib/use-github-revalidation"; import { useHasMounted } from "#/lib/use-has-mounted"; import { DashboardBottomBar } from "./dashboard-bottombar"; @@ -28,8 +31,16 @@ export function DashboardLayout() { const { user } = routeApi.useRouteContext(); const scope = { userId: user.id }; const hasMounted = useHasMounted(); + const missingAppAuthPromptedRef = useRef(false); + const [showOrgSetup, setShowOrgSetup] = useShowOrgSetupQueryState(); useGitHubRevalidation(user.id); + const githubAccessQuery = useQuery({ + queryKey: ["github-app-access-state", user.id], + queryFn: () => getGitHubAppAccessState(), + enabled: hasMounted, + staleTime: 5 * 60 * 1000, + }); const pullsQuery = useQuery({ ...githubMyPullsQueryOptions(scope), enabled: hasMounted, @@ -52,6 +63,22 @@ export function DashboardLayout() { : undefined; const tabsReady = hasMounted && Boolean(pullsQuery.data && issuesQuery.data); + useEffect(() => { + if ( + !hasMounted || + showOrgSetup || + missingAppAuthPromptedRef.current || + !githubAccessQuery.data || + githubAccessQuery.data.installationsAvailable + ) { + return; + } + + missingAppAuthPromptedRef.current = true; + openGitHubAccessPrompt({ source: "onboarding" }); + void setShowOrgSetup(true); + }, [githubAccessQuery.data, hasMounted, setShowOrgSetup, showOrgSetup]); + return (
@@ -135,8 +149,8 @@ export function GitHubAccessDialog({ userId }: { userId: string }) { {primaryHref ? ( ) : null} @@ -257,8 +271,12 @@ function AccessList({ size="xs" className="shrink-0" > - - {target.status === "installed" ? "Manage" : "Configure"} + + {target.status === "installed" + ? "Manage" + : target.status === "unknown" + ? "Authorize" + : "Configure"} ) : null} diff --git a/apps/dashboard/src/components/pulls/detail/pull-detail-activity.tsx b/apps/dashboard/src/components/pulls/detail/pull-detail-activity.tsx index af09bdd..fce3572 100644 --- a/apps/dashboard/src/components/pulls/detail/pull-detail-activity.tsx +++ b/apps/dashboard/src/components/pulls/detail/pull-detail-activity.tsx @@ -843,7 +843,13 @@ function MergeFooter({ setIsMerging(true); try { const result = await mergePullRequest({ - data: { owner, repo, pullNumber, mergeMethod }, + data: { + owner, + repo, + pullNumber, + mergeMethod, + bypassProtections: bypassChecks, + }, }); if (result.ok) { await queryClient.invalidateQueries({ queryKey: ["github"] }); diff --git a/apps/dashboard/src/env.d.ts b/apps/dashboard/src/env.d.ts index d4f6216..b768ad2 100644 --- a/apps/dashboard/src/env.d.ts +++ b/apps/dashboard/src/env.d.ts @@ -7,6 +7,8 @@ declare namespace Cloudflare { GITHUB_OAUTH_CLIENT_SECRET?: string; GITHUB_APP_CLIENT_ID?: string; GITHUB_APP_CLIENT_SECRET?: string; + GITHUB_APP_ID?: string; + GITHUB_APP_PRIVATE_KEY?: string; GITHUB_APP_SLUG?: string; GITHUB_WEBHOOK_SECRET?: string; GITHUB_CLIENT_ID?: string; diff --git a/apps/dashboard/src/lib/auth-runtime.ts b/apps/dashboard/src/lib/auth-runtime.ts index 18ba1af..adb2343 100644 --- a/apps/dashboard/src/lib/auth-runtime.ts +++ b/apps/dashboard/src/lib/auth-runtime.ts @@ -9,6 +9,7 @@ import { Octokit } from "octokit"; import * as schema from "../db/schema"; import { getGitHubAccessTokenByUserId, + getGitHubAppUserAccessTokenByUserId, getGitHubOAuthConfig, } from "./github-app.server"; @@ -27,7 +28,7 @@ function createAuth() { github: { clientId: github.clientId, clientSecret: github.clientSecret, - scope: ["repo", "user:email"], + scope: ["repo", "read:org", "user:email"], }, }, plugins: [tanstackStartCookies()], @@ -57,3 +58,18 @@ export async function getGitHubClientByUserId( throttle: { enabled: false }, }); } + +export async function getGitHubAppUserClientByUserId( + userId: string, +): Promise { + const token = await getGitHubAppUserAccessTokenByUserId(userId); + if (!token) { + return null; + } + + return new Octokit({ + auth: token, + retry: { enabled: false }, + throttle: { enabled: false }, + }); +} diff --git a/apps/dashboard/src/lib/auth.server.ts b/apps/dashboard/src/lib/auth.server.ts index 8d75cac..f68cf45 100644 --- a/apps/dashboard/src/lib/auth.server.ts +++ b/apps/dashboard/src/lib/auth.server.ts @@ -21,7 +21,7 @@ export function getAuth() { github: { clientId: github.clientId, clientSecret: github.clientSecret, - scope: ["repo", "user:email"], + scope: ["repo", "read:org", "user:email"], }, }, plugins: [tanstackStartCookies()], diff --git a/apps/dashboard/src/lib/github-access.test.ts b/apps/dashboard/src/lib/github-access.test.ts index 2c9ae2d..cd70cc0 100644 --- a/apps/dashboard/src/lib/github-access.test.ts +++ b/apps/dashboard/src/lib/github-access.test.ts @@ -10,11 +10,14 @@ import { const state: GitHubAppAccessState = { viewerLogin: "adn", appSlug: "diff-kit", + appAuthorizationUrl: + "/api/github/app/authorize?returnTo=%2F%3Fshow-org-setup%3Dtrue", publicInstallUrl: "https://github.com/apps/diff-kit/installations/new", installationsAvailable: true, personalInstallation: { id: 1, account: { + id: 100, login: "adn", name: null, avatarUrl: null, @@ -29,6 +32,7 @@ const state: GitHubAppAccessState = { { id: 2, account: { + id: 200, login: "supabase", name: null, avatarUrl: null, @@ -84,6 +88,15 @@ describe("getAccessHrefForOwner", () => { getAccessHrefForOwner(null, "vercel", "https://fallback.example"), ).toBe("https://fallback.example"); }); + + it("uses app authorization when installation status is unavailable", () => { + expect( + getAccessHrefForOwner( + { ...state, installationsAvailable: false }, + "supabase", + ), + ).toBe("/api/github/app/authorize?returnTo=%2F%3Fshow-org-setup%3Dtrue"); + }); }); describe("buildGitHubOrganizationInstallationsUrl", () => { diff --git a/apps/dashboard/src/lib/github-access.ts b/apps/dashboard/src/lib/github-access.ts index 703eaa8..768b6ab 100644 --- a/apps/dashboard/src/lib/github-access.ts +++ b/apps/dashboard/src/lib/github-access.ts @@ -3,6 +3,7 @@ export type GitHubInstallationTargetType = "Organization" | "User" | "Unknown"; export type GitHubAppInstallation = { id: number; account: { + id: number | null; login: string; name: string | null; avatarUrl: string | null; @@ -23,6 +24,7 @@ export type GitHubOrganization = { export type GitHubAppAccessState = { viewerLogin: string; appSlug: string | null; + appAuthorizationUrl: string | null; publicInstallUrl: string | null; /** Whether the installations endpoint was reachable (false with OAuth App tokens). */ installationsAvailable: boolean; @@ -36,6 +38,13 @@ export function buildGitHubAppInstallUrl(slug: string | null | undefined) { return slug ? `https://github.com/apps/${slug}/installations/new` : null; } +export function buildGitHubAppAuthorizePath( + returnTo = "/?show-org-setup=true", +) { + const params = new URLSearchParams({ returnTo }); + return `/api/github/app/authorize?${params.toString()}`; +} + export function buildGitHubOrganizationInstallationsUrl(login: string) { return `https://github.com/organizations/${login}/settings/installations`; } @@ -72,6 +81,10 @@ export function getAccessHrefForOwner( } const normalizedOwner = normalizeLogin(owner); + if (!state.installationsAvailable && state.appAuthorizationUrl) { + return state.appAuthorizationUrl; + } + const installation = findInstallationForOwner(state, owner); if (installation?.manageUrl) { return installation.manageUrl; diff --git a/apps/dashboard/src/lib/github-app.server.test.ts b/apps/dashboard/src/lib/github-app.server.test.ts new file mode 100644 index 0000000..d2944ad --- /dev/null +++ b/apps/dashboard/src/lib/github-app.server.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; +import { normalizeGitHubAppPrivateKey } from "./github-private-key"; + +describe("normalizeGitHubAppPrivateKey", () => { + it("converts PKCS#1 RSA private keys to PKCS#8", () => { + const normalized = normalizeGitHubAppPrivateKey( + "-----BEGIN RSA PRIVATE KEY-----\\nAQID\\n-----END RSA PRIVATE KEY-----", + ); + + expect(normalized).toContain("-----BEGIN PRIVATE KEY-----"); + expect(normalized).toContain("-----END PRIVATE KEY-----"); + expect(normalized).not.toContain("RSA PRIVATE KEY"); + expect(normalized).not.toContain("AQID"); + }); + + it("preserves PKCS#8 private keys while normalizing escaped newlines", () => { + expect( + normalizeGitHubAppPrivateKey( + "-----BEGIN PRIVATE KEY-----\\nAQID\\n-----END PRIVATE KEY-----", + ), + ).toBe("-----BEGIN PRIVATE KEY-----\nAQID\n-----END PRIVATE KEY-----"); + }); +}); diff --git a/apps/dashboard/src/lib/github-app.server.ts b/apps/dashboard/src/lib/github-app.server.ts index 628bd4d..6ea0cda 100644 --- a/apps/dashboard/src/lib/github-app.server.ts +++ b/apps/dashboard/src/lib/github-app.server.ts @@ -3,9 +3,24 @@ import { env } from "cloudflare:workers"; import { and, eq } from "drizzle-orm"; import { getDb } from "../db"; import { account } from "../db/schema"; +import { normalizeGitHubAppPrivateKey } from "./github-private-key"; type WorkerEnvRecord = typeof env & Record; +export const GITHUB_OAUTH_PROVIDER_ID = "github"; +export const GITHUB_APP_USER_PROVIDER_ID = "github-app"; + +type GitHubTokenResponse = { + access_token?: string; + token_type?: string; + expires_in?: number; + refresh_token?: string; + refresh_token_expires_in?: number; + scope?: string; + error?: string; + error_description?: string; +}; + function getWorkerEnv() { return env as WorkerEnvRecord; } @@ -55,6 +70,15 @@ export function getGitHubAppAuthConfig() { return { clientId, clientSecret }; } +export function getGitHubAppId(): string | null { + return pickFirstNonEmpty(getWorkerEnv().GITHUB_APP_ID) ?? null; +} + +export function getGitHubAppPrivateKey(): string | null { + const privateKey = pickFirstNonEmpty(getWorkerEnv().GITHUB_APP_PRIVATE_KEY); + return privateKey ? normalizeGitHubAppPrivateKey(privateKey) : null; +} + export function getGitHubAppSlug(): string | null { return pickFirstNonEmpty(getWorkerEnv().GITHUB_APP_SLUG) ?? null; } @@ -64,18 +88,192 @@ export function getGitHubWebhookSecret() { } export async function getGitHubAccessTokenByUserId(userId: string) { + const githubAccount = await getGitHubOAuthAccountByUserId(userId); + + if (!githubAccount?.accessToken) { + throw new Error("No GitHub account linked"); + } + + return githubAccount.accessToken; +} + +export async function getGitHubOAuthAccountByUserId(userId: string) { const db = getDb(); - const githubAccount = await db + + return db .select() .from(account) - .where(and(eq(account.userId, userId), eq(account.providerId, "github"))) + .where( + and( + eq(account.userId, userId), + eq(account.providerId, GITHUB_OAUTH_PROVIDER_ID), + ), + ) .get(); +} + +async function getGitHubAppUserAccountByUserId(userId: string) { + const db = getDb(); + + return db + .select() + .from(account) + .where( + and( + eq(account.userId, userId), + eq(account.providerId, GITHUB_APP_USER_PROVIDER_ID), + ), + ) + .get(); +} + +function getTokenExpiresAt(expiresInSeconds: number | undefined) { + if (!expiresInSeconds || expiresInSeconds <= 0) { + return null; + } + + return new Date(Date.now() + expiresInSeconds * 1000); +} + +function isUsableAccessTokenExpiresAt(expiresAt: Date | null) { + if (!expiresAt) { + return true; + } + + return expiresAt.getTime() - Date.now() > 5 * 60 * 1000; +} + +async function requestGitHubAppUserToken(params: Record) { + const response = await fetch("https://github.com/login/oauth/access_token", { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams(params), + }); + const payload = (await response.json()) as GitHubTokenResponse; + if (!response.ok || !payload.access_token) { + const message = + payload.error_description ?? payload.error ?? response.statusText; + throw new Error(`GitHub App user token request failed: ${message}`); + } + + return payload; +} + +export async function exchangeGitHubAppUserCode({ + code, + redirectUri, + userId, +}: { + code: string; + redirectUri?: string; + userId: string; +}) { + const githubApp = getGitHubAppAuthConfig(); + const payload = await requestGitHubAppUserToken({ + client_id: githubApp.clientId, + client_secret: githubApp.clientSecret, + code, + ...(redirectUri ? { redirect_uri: redirectUri } : {}), + }); + + await saveGitHubAppUserToken({ + userId, + payload, + }); +} + +async function refreshGitHubAppUserToken({ + refreshToken, + userId, +}: { + refreshToken: string; + userId: string; +}) { + const githubApp = getGitHubAppAuthConfig(); + const payload = await requestGitHubAppUserToken({ + client_id: githubApp.clientId, + client_secret: githubApp.clientSecret, + grant_type: "refresh_token", + refresh_token: refreshToken, + }); + + await saveGitHubAppUserToken({ + userId, + payload, + }); + + return payload.access_token; +} + +async function saveGitHubAppUserToken({ + payload, + userId, +}: { + payload: GitHubTokenResponse; + userId: string; +}) { + if (!payload.access_token) { + throw new Error("GitHub App user token response did not include a token."); + } + + const db = getDb(); + const now = new Date(); + const existingAccount = await getGitHubAppUserAccountByUserId(userId); + const oauthAccount = await getGitHubOAuthAccountByUserId(userId); + const values = { + accountId: oauthAccount?.accountId ?? userId, + providerId: GITHUB_APP_USER_PROVIDER_ID, + userId, + accessToken: payload.access_token, + refreshToken: payload.refresh_token ?? null, + accessTokenExpiresAt: getTokenExpiresAt(payload.expires_in), + refreshTokenExpiresAt: getTokenExpiresAt(payload.refresh_token_expires_in), + scope: payload.scope ?? null, + updatedAt: now, + }; + + if (existingAccount) { + await db + .update(account) + .set(values) + .where(eq(account.id, existingAccount.id)); + return; + } + + await db.insert(account).values({ + id: crypto.randomUUID(), + ...values, + idToken: null, + password: null, + createdAt: now, + }); +} + +export async function getGitHubAppUserAccessTokenByUserId(userId: string) { + const githubAccount = await getGitHubAppUserAccountByUserId(userId); if (!githubAccount?.accessToken) { - throw new Error("No GitHub account linked"); + return null; } - return githubAccount.accessToken; + if (isUsableAccessTokenExpiresAt(githubAccount.accessTokenExpiresAt)) { + return githubAccount.accessToken; + } + + if ( + !githubAccount.refreshToken || + !isUsableAccessTokenExpiresAt(githubAccount.refreshTokenExpiresAt) + ) { + return null; + } + + return refreshGitHubAppUserToken({ + refreshToken: githubAccount.refreshToken, + userId, + }); } function fromHex(hex: string) { diff --git a/apps/dashboard/src/lib/github-private-key.ts b/apps/dashboard/src/lib/github-private-key.ts new file mode 100644 index 0000000..6bb18f0 --- /dev/null +++ b/apps/dashboard/src/lib/github-private-key.ts @@ -0,0 +1,95 @@ +function base64ToBytes(base64: string) { + const binary = atob(base64.replace(/\s+/g, "")); + const bytes = new Uint8Array(binary.length); + for (let index = 0; index < binary.length; index += 1) { + bytes[index] = binary.charCodeAt(index); + } + + return bytes; +} + +function bytesToBase64(bytes: Uint8Array) { + let binary = ""; + const chunkSize = 0x8000; + for (let index = 0; index < bytes.length; index += chunkSize) { + const chunk = bytes.subarray(index, index + chunkSize); + binary += String.fromCharCode(...chunk); + } + + return btoa(binary); +} + +function encodeDerLength(length: number) { + if (length < 0x80) { + return Uint8Array.from([length]); + } + + const bytes: number[] = []; + let remaining = length; + while (remaining > 0) { + bytes.unshift(remaining & 0xff); + remaining >>= 8; + } + + return Uint8Array.from([0x80 | bytes.length, ...bytes]); +} + +function encodeDer(tag: number, content: Uint8Array) { + const length = encodeDerLength(content.length); + const output = new Uint8Array(1 + length.length + content.length); + output[0] = tag; + output.set(length, 1); + output.set(content, 1 + length.length); + return output; +} + +function concatBytes(...parts: Uint8Array[]) { + const length = parts.reduce((total, part) => total + part.length, 0); + const output = new Uint8Array(length); + let offset = 0; + for (const part of parts) { + output.set(part, offset); + offset += part.length; + } + + return output; +} + +function wrapBase64(base64: string) { + return base64.match(/.{1,64}/g)?.join("\n") ?? base64; +} + +function extractPemBody(privateKey: string, label: string) { + return privateKey + .replace(`-----BEGIN ${label}-----`, "") + .replace(`-----END ${label}-----`, "") + .replace(/\s+/g, ""); +} + +function convertPkcs1ToPkcs8(privateKey: string) { + const pkcs1Der = base64ToBytes(extractPemBody(privateKey, "RSA PRIVATE KEY")); + const version = Uint8Array.from([0x02, 0x01, 0x00]); + const rsaEncryptionOid = Uint8Array.from([ + 0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x01, 0x01, + ]); + const algorithmIdentifier = encodeDer( + 0x30, + concatBytes(rsaEncryptionOid, Uint8Array.from([0x05, 0x00])), + ); + const privateKeyOctetString = encodeDer(0x04, pkcs1Der); + const privateKeyInfo = encodeDer( + 0x30, + concatBytes(version, algorithmIdentifier, privateKeyOctetString), + ); + + return `-----BEGIN PRIVATE KEY-----\n${wrapBase64(bytesToBase64(privateKeyInfo))}\n-----END PRIVATE KEY-----`; +} + +export function normalizeGitHubAppPrivateKey(privateKey: string) { + const normalizedPrivateKey = privateKey.replace(/\\n/g, "\n").trim(); + if (normalizedPrivateKey.includes("-----BEGIN RSA PRIVATE KEY-----")) { + return convertPkcs1ToPkcs8(normalizedPrivateKey); + } + + return normalizedPrivateKey; +} diff --git a/apps/dashboard/src/lib/github.functions.ts b/apps/dashboard/src/lib/github.functions.ts index f2fd2f7..947f1f7 100644 --- a/apps/dashboard/src/lib/github.functions.ts +++ b/apps/dashboard/src/lib/github.functions.ts @@ -32,6 +32,7 @@ import type { UserRepoSummary, } from "./github.types"; import { + buildGitHubAppAuthorizePath, buildGitHubAppInstallUrl, type GitHubAppAccessState, type GitHubAppInstallation, @@ -95,6 +96,41 @@ type RepoPullFile = Awaited< type RepoPullReviewComment = Awaited< ReturnType >["data"][number]; +type RepositoryPermissions = { + admin?: boolean; + maintain?: boolean; + push?: boolean; + triage?: boolean; + pull?: boolean; +}; +type GitHubBranchRule = { + ruleset_id?: number; +}; +type GitHubRepositoryRuleset = { + current_user_can_bypass?: + | "always" + | "pull_requests_only" + | "never" + | "exempt"; +}; +type GitHubBranchProtection = { + enforce_admins?: { + enabled?: boolean; + }; + required_pull_request_reviews?: { + bypass_pull_request_allowances?: { + users?: Array<{ login?: string }>; + teams?: Array<{ slug?: string }>; + apps?: Array<{ slug?: string }>; + }; + }; +}; +type GitHubUserTeam = { + slug?: string; + organization?: { + login?: string; + }; +}; type RepoState = "all" | "closed" | "open"; type PullSort = "created" | "long-running" | "popularity" | "updated"; @@ -116,6 +152,8 @@ async function bustPullDetailCaches(userId: string, params: PullCacheParams) { bustGitHubCache(userId, "pulls.detail.raw", params), bustGitHubCache(userId, "pulls.status.raw", params), bustGitHubCache(userId, "pulls.status.v1", params), + bustGitHubCache(userId, "pulls.status.v2", params), + bustGitHubCache(userId, "pulls.status.v3", params), ]); } @@ -141,6 +179,7 @@ type GitHubApiLabel = { }; type GitHubInstallationAccountPayload = { + id?: number; login?: string; avatar_url?: string | null; type?: string; @@ -531,6 +570,419 @@ async function getGitHubContext(): Promise { }; } +function toRepositorySelection(value: string | undefined) { + return value === "all" || value === "selected" ? value : "unknown"; +} + +function mapGitHubAppInstallations( + payload: GitHubUserInstallationsPayload, +): GitHubAppInstallation[] { + return (payload.installations ?? []).flatMap((installation) => { + if (!installation.id || !installation.account?.login) { + return []; + } + + const targetType = toInstallationTargetType(installation.target_type); + + return [ + { + id: installation.id, + account: { + id: installation.account.id ?? null, + login: installation.account.login, + name: null, + avatarUrl: installation.account.avatar_url ?? null, + type: toInstallationTargetType(installation.account.type), + }, + targetType, + repositorySelection: toRepositorySelection( + installation.repository_selection, + ), + manageUrl: installation.html_url ?? null, + suspendedAt: installation.suspended_at ?? null, + }, + ]; + }); +} + +async function getGitHubAppUserInstallations(userId: string): Promise<{ + installations: GitHubAppInstallation[]; + installationsAvailable: boolean; +}> { + const { getGitHubAppUserClientByUserId } = await import("./auth-runtime"); + const appUserOctokit = await getGitHubAppUserClientByUserId(userId); + if (!appUserOctokit) { + return { installations: [], installationsAvailable: false }; + } + + try { + const installationsResponse = await appUserOctokit.request( + "GET /user/installations", + { + per_page: 100, + }, + ); + return { + installations: mapGitHubAppInstallations( + installationsResponse.data as GitHubUserInstallationsPayload, + ), + installationsAvailable: true, + }; + } catch (error) { + console.error("[github-access] failed to load app installations", error); + return { installations: [], installationsAvailable: false }; + } +} + +async function getGitHubContextForOwner(owner: string) { + const context = await getGitHubContext(); + if (!context) { + return null; + } + + const { installations } = await getGitHubAppUserInstallations( + context.session.user.id, + ); + const installation = findGitHubAppInstallationForOwner(installations, owner); + if (!installation) { + return context; + } + + try { + const { getGitHubInstallationClient } = await import("./github.server"); + return { + ...context, + octokit: await getGitHubInstallationClient(installation.id), + }; + } catch (error) { + console.error( + "[github-access] failed to create installation client", + error, + ); + return context; + } +} + +async function getGitHubContextForRepository(input: { + owner: string; + repo: string; +}) { + return getGitHubContextForOwner(input.owner); +} + +function findGitHubAppInstallationForOwner( + installations: GitHubAppInstallation[], + owner: string, +) { + const normalizedOwner = owner.toLowerCase(); + return installations.find( + (candidate) => + candidate.account.login.toLowerCase() === normalizedOwner || + (candidate.targetType === "User" && + candidate.account.login.toLowerCase() === normalizedOwner), + ); +} + +async function getGitHubUserContextForOwner(owner: string) { + const context = await getGitHubContext(); + if (!context) { + return null; + } + + const { getGitHubAppUserClientByUserId } = await import("./auth-runtime"); + const appUserOctokit = await getGitHubAppUserClientByUserId( + context.session.user.id, + ); + if (!appUserOctokit) { + return context; + } + + const { installations } = await getGitHubAppUserInstallations( + context.session.user.id, + ); + const installation = findGitHubAppInstallationForOwner(installations, owner); + if (!installation) { + return context; + } + + return { + ...context, + octokit: appUserOctokit, + }; +} + +async function getGitHubUserContextForRepository(input: { + owner: string; + repo: string; +}) { + return getGitHubUserContextForOwner(input.owner); +} + +function isBypassableRulesetMode( + mode: GitHubRepositoryRuleset["current_user_can_bypass"] | undefined, +) { + return ( + mode === "always" || mode === "pull_requests_only" || mode === "exempt" + ); +} + +function mergeRepositoryPermissions( + ...permissionsList: Array +): RepositoryPermissions | undefined { + const permissions = permissionsList.filter( + (candidate): candidate is RepositoryPermissions => Boolean(candidate), + ); + if (permissions.length === 0) { + return undefined; + } + + return { + admin: permissions.some((permission) => permission.admin === true), + maintain: permissions.some((permission) => permission.maintain === true), + push: permissions.some((permission) => permission.push === true), + triage: permissions.some((permission) => permission.triage === true), + pull: permissions.some((permission) => permission.pull === true), + }; +} + +async function getRepositoryPermissions( + context: GitHubContext | null, + owner: string, + repo: string, +) { + if (!context) { + return null; + } + + const response = await context.octokit.rest.repos + .get({ owner, repo }) + .catch(() => null); + return response?.data.permissions ?? null; +} + +async function getRulesetPullRequestBypassState({ + branch, + context, + owner, + repo, +}: { + branch: string; + context: GitHubContext; + owner: string; + repo: string; +}) { + try { + const rulesResponse = await context.octokit.request( + "GET /repos/{owner}/{repo}/rules/branches/{branch}", + { + owner, + repo, + branch, + per_page: 100, + }, + ); + const rules = rulesResponse.data as GitHubBranchRule[]; + const rulesetIds = Array.from( + new Set( + rules + .map((rule) => rule.ruleset_id) + .filter((id): id is number => typeof id === "number"), + ), + ); + + if (rulesetIds.length === 0) { + return null; + } + + const rulesets = await Promise.all( + rulesetIds.map(async (rulesetId) => { + const response = await context.octokit.request( + "GET /repos/{owner}/{repo}/rulesets/{ruleset_id}", + { + owner, + repo, + ruleset_id: rulesetId, + includes_parents: true, + }, + ); + return response.data as GitHubRepositoryRuleset; + }), + ); + + if ( + rulesets.some((ruleset) => ruleset.current_user_can_bypass === "never") + ) { + return false; + } + + if ( + rulesets.every((ruleset) => + isBypassableRulesetMode(ruleset.current_user_can_bypass), + ) + ) { + return true; + } + + return null; + } catch { + return null; + } +} + +function loginMatches(left: string | undefined, right: string) { + return left?.toLowerCase() === right.toLowerCase(); +} + +async function getAuthenticatedUserTeamSlugsForOrg( + context: GitHubContext, + org: string, +) { + const teamSlugs = new Set(); + let page = 1; + + while (true) { + const response = await context.octokit.request("GET /user/teams", { + page, + per_page: 100, + }); + const teams = response.data as GitHubUserTeam[]; + for (const team of teams) { + if (loginMatches(team.organization?.login, org) && team.slug) { + teamSlugs.add(team.slug.toLowerCase()); + } + } + + if (teams.length < 100) { + break; + } + + page += 1; + } + + return teamSlugs; +} + +async function legacyBranchProtectionAllowsPullRequestBypass({ + branch, + context, + owner, + permissions, + repo, + viewerLogin, +}: { + branch: string; + context: GitHubContext; + owner: string; + permissions: RepositoryPermissions | undefined; + repo: string; + viewerLogin: string; +}) { + try { + const response = await context.octokit.request( + "GET /repos/{owner}/{repo}/branches/{branch}/protection", + { + owner, + repo, + branch, + }, + ); + const protection = response.data as GitHubBranchProtection; + const allowances = + protection.required_pull_request_reviews?.bypass_pull_request_allowances; + const allowedUsers = allowances?.users ?? []; + if (allowedUsers.some((user) => loginMatches(user.login, viewerLogin))) { + return true; + } + + const allowedTeamSlugs = (allowances?.teams ?? []).flatMap((team) => + team.slug ? [team.slug.toLowerCase()] : [], + ); + if (allowedTeamSlugs.length > 0) { + const userTeamSlugs = await getAuthenticatedUserTeamSlugsForOrg( + context, + owner, + ); + if (allowedTeamSlugs.some((slug) => userTeamSlugs.has(slug))) { + return true; + } + } + + return ( + permissions?.admin === true && protection.enforce_admins?.enabled !== true + ); + } catch { + return null; + } +} + +async function getPullRequestBypassState({ + branch, + context, + fallbackContext, + owner, + permissions, + repo, +}: { + branch: string; + context: GitHubContext; + fallbackContext: GitHubContext | null; + owner: string; + permissions: RepositoryPermissions | undefined; + repo: string; +}) { + const contexts = [context, fallbackContext].filter( + (candidate, index, candidates): candidate is GitHubContext => + Boolean(candidate) && candidates.indexOf(candidate) === index, + ); + + let rulesetState: boolean | null = null; + for (const candidate of contexts) { + const candidateRulesetState = await getRulesetPullRequestBypassState({ + branch, + context: candidate, + owner, + repo, + }); + if (candidateRulesetState === false) { + rulesetState ??= false; + } + if (candidateRulesetState === true) { + rulesetState = true; + } + } + + let legacyState: boolean | null = null; + for (const candidate of contexts) { + try { + const viewerResponse = + await candidate.octokit.rest.users.getAuthenticated(); + const candidateLegacyState = + await legacyBranchProtectionAllowsPullRequestBypass({ + branch, + context: candidate, + owner, + permissions, + repo, + viewerLogin: viewerResponse.data.login, + }); + if (candidateLegacyState === false) { + legacyState ??= false; + } + if (candidateLegacyState === true) { + legacyState = true; + } + } catch {} + } + + if (rulesetState === false || legacyState === false) { + return false; + } + + return ( + rulesetState === true || legacyState === true || permissions?.admin === true + ); +} + function buildUserSearchQuery({ itemType, role, @@ -1252,25 +1704,34 @@ async function computePullStatus( data: PullFromRepoInput, pull: RepoPullDetail, ): Promise { - const [reviewsResponse, checksResponse, repoResponse] = await Promise.all([ - context.octokit.rest.pulls.listReviews({ - owner: data.owner, - repo: data.repo, - pull_number: data.pullNumber, - per_page: 100, - }), - context.octokit.rest.checks - .listForRef({ + const [reviewsResponse, checksResponse, userContext, oauthContext] = + await Promise.all([ + context.octokit.rest.pulls.listReviews({ owner: data.owner, repo: data.repo, - ref: pull.head.sha, + pull_number: data.pullNumber, per_page: 100, - }) - .catch(() => null), - context.octokit.rest.repos - .get({ owner: data.owner, repo: data.repo }) - .catch(() => null), - ]); + }), + context.octokit.rest.checks + .listForRef({ + owner: data.owner, + repo: data.repo, + ref: pull.head.sha, + per_page: 100, + }) + .catch(() => null), + getGitHubUserContextForRepository(data), + getGitHubContext(), + ]); + const permissions = mergeRepositoryPermissions( + await getRepositoryPermissions( + userContext ?? context, + data.owner, + data.repo, + ), + await getRepositoryPermissions(oauthContext, data.owner, data.repo), + pull.base.repo.permissions, + ); const latestReviews = new Map< string, @@ -1321,11 +1782,16 @@ async function computePullStatus( behindBy = null; } - const permissions = - repoResponse?.data.permissions ?? pull.base.repo.permissions; const canUpdateBranch = !permissions || permissions.push === true || permissions.admin === true; - const canBypassProtections = permissions?.admin === true; + const canBypassProtections = await getPullRequestBypassState({ + branch: pull.base.ref, + context: userContext ?? context, + fallbackContext: oauthContext, + owner: data.owner, + permissions, + repo: data.repo, + }); return { reviews: Array.from(latestReviews.values()), @@ -1368,7 +1834,7 @@ async function getPullStatusResult( return getOrRevalidateGitHubResource({ userId: context.session.user.id, - resource: "pulls.status.v1", + resource: "pulls.status.v3", params: data, freshForMs: githubCachePolicy.status.staleTimeMs, signalKeys: [pullNamespaceKey], @@ -1733,52 +2199,10 @@ export const getGitHubAppAccessState = createServerFn({ const viewer = await getViewer(context); const appSlug = getGitHubAppSlug(); + const appAuthorizationUrl = buildGitHubAppAuthorizePath(); const publicInstallUrl = buildGitHubAppInstallUrl(appSlug); - - // GET /user/installations requires a GitHub App user-to-server token (ghu_). - // With an OAuth App token (gho_), this endpoint returns 403 — expected behavior. - let installations: GitHubAppInstallation[] = []; - let installationsAvailable = false; - try { - const installationsResponse = await context.octokit.request( - "GET /user/installations", - { - per_page: 100, - }, - ); - installationsAvailable = true; - const payload = - installationsResponse.data as GitHubUserInstallationsPayload; - installations = (payload.installations ?? []).flatMap((installation) => { - if (!installation.id || !installation.account?.login) { - return []; - } - - const targetType = toInstallationTargetType(installation.target_type); - - return [ - { - id: installation.id, - account: { - login: installation.account.login, - name: null, - avatarUrl: installation.account.avatar_url ?? null, - type: toInstallationTargetType(installation.account.type), - }, - targetType, - repositorySelection: - installation.repository_selection === "all" || - installation.repository_selection === "selected" - ? installation.repository_selection - : "unknown", - manageUrl: installation.html_url ?? null, - suspendedAt: installation.suspended_at ?? null, - }, - ]; - }); - } catch { - // Silently ignored — OAuth App tokens cannot list GitHub App installations. - } + const { installations, installationsAvailable } = + await getGitHubAppUserInstallations(context.session.user.id); let organizations: GitHubOrganization[] = []; try { @@ -1817,6 +2241,22 @@ export const getGitHubAppAccessState = createServerFn({ const orgInstallations = installations.filter( (installation) => installation.targetType === "Organization", ); + const organizationByLogin = new Map( + organizations.map((organization) => [ + organization.login.toLowerCase(), + organization, + ]), + ); + for (const installation of orgInstallations) { + if (!organizationByLogin.has(installation.account.login.toLowerCase())) { + organizationByLogin.set(installation.account.login.toLowerCase(), { + id: installation.account.id ?? installation.id, + login: installation.account.login, + avatarUrl: installation.account.avatarUrl, + }); + } + } + organizations = [...organizationByLogin.values()]; const installedOrganizationLogins = new Set( orgInstallations.map((installation) => installation.account.login.toLowerCase(), @@ -1826,6 +2266,7 @@ export const getGitHubAppAccessState = createServerFn({ return { viewerLogin, appSlug, + appAuthorizationUrl, publicInstallUrl, installationsAvailable, personalInstallation, @@ -1977,7 +2418,7 @@ export const getPullsFromUser = createServerFn({ method: "GET" }) export const getPullsFromRepo = createServerFn({ method: "GET" }) .inputValidator(identityValidator) .handler(async ({ data }): Promise => { - const context = await getGitHubContext(); + const context = await getGitHubContextForRepository(data); if (!context) { return []; } @@ -2020,7 +2461,7 @@ export const getPullsFromRepo = createServerFn({ method: "GET" }) export const getCommentPage = createServerFn({ method: "GET" }) .inputValidator(identityValidator) .handler(async ({ data }) => { - const context = await getGitHubContext(); + const context = await getGitHubContextForRepository(data); if (!context) { return { comments: [], total: 0 }; } @@ -2038,7 +2479,7 @@ type TimelineEventPageInput = { export const getTimelineEventPage = createServerFn({ method: "GET" }) .inputValidator(identityValidator) .handler(async ({ data }) => { - const context = await getGitHubContext(); + const context = await getGitHubContextForRepository(data); if (!context) { return { events: [], hasMore: false }; } @@ -2049,7 +2490,7 @@ export const getTimelineEventPage = createServerFn({ method: "GET" }) export const getPullFromRepo = createServerFn({ method: "GET" }) .inputValidator(identityValidator) .handler(async ({ data }): Promise => { - const context = await getGitHubContext(); + const context = await getGitHubContextForRepository(data); if (!context) { return null; } @@ -2060,7 +2501,7 @@ export const getPullFromRepo = createServerFn({ method: "GET" }) export const getPullComments = createServerFn({ method: "GET" }) .inputValidator(identityValidator) .handler(async ({ data }): Promise => { - const context = await getGitHubContext(); + const context = await getGitHubContextForRepository(data); if (!context) { return []; } @@ -2154,7 +2595,7 @@ export const getIssuesFromUser = createServerFn({ method: "GET" }) export const getIssuesFromRepo = createServerFn({ method: "GET" }) .inputValidator(identityValidator) .handler(async ({ data }): Promise => { - const context = await getGitHubContext(); + const context = await getGitHubContextForRepository(data); if (!context) { return []; } @@ -2201,7 +2642,7 @@ export const getIssuesFromRepo = createServerFn({ method: "GET" }) export const getIssueFromRepo = createServerFn({ method: "GET" }) .inputValidator(identityValidator) .handler(async ({ data }): Promise => { - const context = await getGitHubContext(); + const context = await getGitHubContextForRepository(data); if (!context) { return null; } @@ -2212,7 +2653,7 @@ export const getIssueFromRepo = createServerFn({ method: "GET" }) export const getIssueComments = createServerFn({ method: "GET" }) .inputValidator(identityValidator) .handler(async ({ data }): Promise => { - const context = await getGitHubContext(); + const context = await getGitHubContextForRepository(data); if (!context) { return []; } @@ -2223,7 +2664,7 @@ export const getIssueComments = createServerFn({ method: "GET" }) export const getIssuePageData = createServerFn({ method: "GET" }) .inputValidator(identityValidator) .handler(async ({ data }): Promise => { - const context = await getGitHubContext(); + const context = await getGitHubContextForRepository(data); if (!context) { return null; } @@ -2234,7 +2675,7 @@ export const getIssuePageData = createServerFn({ method: "GET" }) export const getPullStatus = createServerFn({ method: "GET" }) .inputValidator(identityValidator) .handler(async ({ data }): Promise => { - const context = await getGitHubContext(); + const context = await getGitHubContextForRepository(data); if (!context) { return null; } @@ -2245,7 +2686,7 @@ export const getPullStatus = createServerFn({ method: "GET" }) export const getPullPageData = createServerFn({ method: "GET" }) .inputValidator(identityValidator) .handler(async ({ data }): Promise => { - const context = await getGitHubContext(); + const context = await getGitHubContextForRepository(data); if (!context) { return null; } @@ -2258,7 +2699,7 @@ type UpdatePullBodyInput = PullFromRepoInput & { body: string }; export const updatePullBody = createServerFn({ method: "POST" }) .inputValidator(identityValidator) .handler(async ({ data }): Promise => { - const context = await getGitHubContext(); + const context = await getGitHubContextForRepository(data); if (!context) { return false; } @@ -2280,7 +2721,7 @@ export const updatePullBody = createServerFn({ method: "POST" }) export const updatePullBranch = createServerFn({ method: "POST" }) .inputValidator(identityValidator) .handler(async ({ data }): Promise => { - const context = await getGitHubContext(); + const context = await getGitHubContextForRepository(data); if (!context) { return { ok: false, error: "Not authenticated" }; } @@ -2306,7 +2747,7 @@ export type MergePullInput = PullFromRepoInput & { export const mergePullRequest = createServerFn({ method: "POST" }) .inputValidator(identityValidator) .handler(async ({ data }): Promise => { - const context = await getGitHubContext(); + const context = await getGitHubUserContextForRepository(data); if (!context) { return { ok: false, error: "Not authenticated" }; } @@ -2330,7 +2771,7 @@ export const deleteBranch = createServerFn({ method: "POST" }) identityValidator<{ owner: string; repo: string; branch: string }>, ) .handler(async ({ data }): Promise => { - const context = await getGitHubContext(); + const context = await getGitHubContextForRepository(data); if (!context) { return { ok: false, error: "Not authenticated" }; } @@ -2439,7 +2880,7 @@ async function getPullFileSummariesResult( export const getPullFileSummaries = createServerFn({ method: "GET" }) .inputValidator(identityValidator) .handler(async ({ data }): Promise => { - const context = await getGitHubContext(); + const context = await getGitHubContextForRepository(data); if (!context) { return []; } @@ -2450,7 +2891,7 @@ export const getPullFileSummaries = createServerFn({ method: "GET" }) export const getPullFiles = createServerFn({ method: "GET" }) .inputValidator(identityValidator) .handler(async ({ data }): Promise => { - const context = await getGitHubContext(); + const context = await getGitHubContextForRepository(data); if (!context) { return { files: [], nextPage: null }; } @@ -2513,7 +2954,7 @@ async function getPullReviewCommentsResult( export const getPullReviewComments = createServerFn({ method: "GET" }) .inputValidator(identityValidator) .handler(async ({ data }): Promise => { - const context = await getGitHubContext(); + const context = await getGitHubContextForRepository(data); if (!context) { return []; } @@ -2524,7 +2965,7 @@ export const getPullReviewComments = createServerFn({ method: "GET" }) export const submitPullReview = createServerFn({ method: "POST" }) .inputValidator(identityValidator) .handler(async ({ data }): Promise => { - const context = await getGitHubContext(); + const context = await getGitHubContextForRepository(data); if (!context) { return false; } @@ -2560,7 +3001,7 @@ export const submitPullReview = createServerFn({ method: "POST" }) export const createReviewComment = createServerFn({ method: "POST" }) .inputValidator(identityValidator) .handler(async ({ data }): Promise => { - const context = await getGitHubContext(); + const context = await getGitHubContextForRepository(data); if (!context) { return null; } @@ -2616,7 +3057,7 @@ export type RepoCollaboratorsInput = { export const getRepoCollaborators = createServerFn({ method: "GET" }) .inputValidator(identityValidator) .handler(async ({ data }): Promise => { - const context = await getGitHubContext(); + const context = await getGitHubContextForRepository(data); if (!context) { return []; } @@ -2665,7 +3106,7 @@ export type OrgTeamsInput = { export const getOrgTeams = createServerFn({ method: "GET" }) .inputValidator(identityValidator) .handler(async ({ data }): Promise => { - const context = await getGitHubContext(); + const context = await getGitHubContextForOwner(data.org); if (!context) { return []; } @@ -2699,7 +3140,7 @@ export const getOrgTeams = createServerFn({ method: "GET" }) export const requestPullReviewers = createServerFn({ method: "POST" }) .inputValidator(identityValidator) .handler(async ({ data }): Promise => { - const context = await getGitHubContext(); + const context = await getGitHubContextForRepository(data); if (!context) { return { ok: false, error: "Not authenticated" }; } @@ -2726,7 +3167,7 @@ export const requestPullReviewers = createServerFn({ method: "POST" }) export const removeReviewRequest = createServerFn({ method: "POST" }) .inputValidator(identityValidator) .handler(async ({ data }): Promise => { - const context = await getGitHubContext(); + const context = await getGitHubContextForRepository(data); if (!context) { return { ok: false, error: "Not authenticated" }; } @@ -2761,7 +3202,7 @@ export type DismissReviewInput = { export const dismissPullReview = createServerFn({ method: "POST" }) .inputValidator(identityValidator) .handler(async ({ data }): Promise => { - const context = await getGitHubContext(); + const context = await getGitHubContextForRepository(data); if (!context) { return { ok: false, error: "Not authenticated" }; } @@ -2793,7 +3234,7 @@ export type RepoLabelsInput = { export const getRepoLabels = createServerFn({ method: "GET" }) .inputValidator(identityValidator) .handler(async ({ data }): Promise => { - const context = await getGitHubContext(); + const context = await getGitHubContextForRepository(data); if (!context) { return []; } @@ -2834,7 +3275,7 @@ export const getRepoLabels = createServerFn({ method: "GET" }) export const setIssueLabels = createServerFn({ method: "POST" }) .inputValidator(identityValidator) .handler(async ({ data }): Promise => { - const context = await getGitHubContext(); + const context = await getGitHubContextForRepository(data); if (!context) { return false; } @@ -2868,7 +3309,7 @@ export const setIssueLabels = createServerFn({ method: "POST" }) export const createRepoLabel = createServerFn({ method: "POST" }) .inputValidator(identityValidator) .handler(async ({ data }): Promise => { - const context = await getGitHubContext(); + const context = await getGitHubContextForRepository(data); if (!context) { return null; } diff --git a/apps/dashboard/src/lib/github.server.test.ts b/apps/dashboard/src/lib/github.server.test.ts index 165e5bb..f530d06 100644 --- a/apps/dashboard/src/lib/github.server.test.ts +++ b/apps/dashboard/src/lib/github.server.test.ts @@ -24,20 +24,29 @@ const octokitConstructor = vi.fn((options: Record) => { log: instance.log, }; }); +const appConstructor = vi.fn(); const getGitHubAccessTokenByUserId = vi.fn(async () => "github-token"); +const getGitHubAppId = vi.fn(() => "12345"); +const getGitHubAppPrivateKey = vi.fn(() => "private-key"); vi.mock("octokit", () => ({ + App: appConstructor, Octokit: octokitConstructor, })); vi.mock("./github-app.server", () => ({ getGitHubAccessTokenByUserId, + getGitHubAppId, + getGitHubAppPrivateKey, })); beforeEach(() => { octokitInstances.length = 0; octokitConstructor.mockClear(); + appConstructor.mockClear(); getGitHubAccessTokenByUserId.mockClear(); + getGitHubAppId.mockClear(); + getGitHubAppPrivateKey.mockClear(); }); describe("getGitHubClient", () => { @@ -142,4 +151,27 @@ describe("getGitHubClient", () => { expect(instance.log.warn).toHaveBeenCalled(); expect(instance.log.info).toHaveBeenCalledTimes(1); }); + + it("creates GitHub App installation clients from app credentials", async () => { + const installationOctokit = { + hook: { + before: vi.fn(), + }, + }; + appConstructor.mockImplementationOnce(() => ({ + getInstallationOctokit: vi.fn(async () => installationOctokit), + })); + const { getGitHubInstallationClient } = await import("./github.server"); + + await getGitHubInstallationClient(987); + + expect(getGitHubAppId).toHaveBeenCalled(); + expect(getGitHubAppPrivateKey).toHaveBeenCalled(); + expect(appConstructor).toHaveBeenCalledWith({ + appId: "12345", + privateKey: "private-key", + Octokit: octokitConstructor, + }); + expect(installationOctokit.hook.before).toHaveBeenCalledTimes(1); + }); }); diff --git a/apps/dashboard/src/lib/github.server.ts b/apps/dashboard/src/lib/github.server.ts index 736e665..cf371b7 100644 --- a/apps/dashboard/src/lib/github.server.ts +++ b/apps/dashboard/src/lib/github.server.ts @@ -1,6 +1,10 @@ import "@tanstack/react-start/server-only"; -import { Octokit, type Octokit as OctokitType } from "octokit"; -import { getGitHubAccessTokenByUserId } from "./github-app.server"; +import { App, Octokit, type Octokit as OctokitType } from "octokit"; +import { + getGitHubAccessTokenByUserId, + getGitHubAppId, + getGitHubAppPrivateKey, +} from "./github-app.server"; const GITHUB_CLIENT_USER_AGENT = "quickhub-dashboard"; const GITHUB_READ_RETRY_COUNT = 2; @@ -106,3 +110,26 @@ export async function getGitHubClient(userId: string): Promise { return octokit; } + +export async function getGitHubInstallationClient( + installationId: number, +): Promise { + const appId = getGitHubAppId(); + const privateKey = getGitHubAppPrivateKey(); + if (!appId || !privateKey) { + throw new Error( + "Missing GitHub App installation credentials. Set GITHUB_APP_ID and GITHUB_APP_PRIVATE_KEY.", + ); + } + + const app = new App({ + appId, + privateKey, + Octokit, + }); + const octokit = await app.getInstallationOctokit(installationId); + + configureGitHubRequestPolicies(octokit); + + return octokit; +} diff --git a/apps/dashboard/src/routeTree.gen.ts b/apps/dashboard/src/routeTree.gen.ts index 37dd367..66a1dd0 100644 --- a/apps/dashboard/src/routeTree.gen.ts +++ b/apps/dashboard/src/routeTree.gen.ts @@ -19,6 +19,8 @@ import { Route as ProtectedPullsRouteImport } from './routes/_protected/pulls' import { Route as ProtectedIssuesRouteImport } from './routes/_protected/issues' import { Route as ApiWebhooksGithubRouteImport } from './routes/api/webhooks/github' import { Route as ApiAuthSplatRouteImport } from './routes/api/auth/$' +import { Route as ApiGithubAppCallbackRouteImport } from './routes/api/github/app/callback' +import { Route as ApiGithubAppAuthorizeRouteImport } from './routes/api/github/app/authorize' import { Route as ProtectedOwnerRepoReviewPullIdRouteImport } from './routes/_protected/$owner/$repo/review.$pullId' import { Route as ProtectedOwnerRepoPullPullIdRouteImport } from './routes/_protected/$owner/$repo/pull.$pullId' import { Route as ProtectedOwnerRepoIssuesIssueIdRouteImport } from './routes/_protected/$owner/$repo/issues.$issueId' @@ -72,6 +74,16 @@ const ApiAuthSplatRoute = ApiAuthSplatRouteImport.update({ path: '/api/auth/$', getParentRoute: () => rootRouteImport, } as any) +const ApiGithubAppCallbackRoute = ApiGithubAppCallbackRouteImport.update({ + id: '/api/github/app/callback', + path: '/api/github/app/callback', + getParentRoute: () => rootRouteImport, +} as any) +const ApiGithubAppAuthorizeRoute = ApiGithubAppAuthorizeRouteImport.update({ + id: '/api/github/app/authorize', + path: '/api/github/app/authorize', + getParentRoute: () => rootRouteImport, +} as any) const ProtectedOwnerRepoReviewPullIdRoute = ProtectedOwnerRepoReviewPullIdRouteImport.update({ id: '/$owner/$repo/review/$pullId', @@ -101,6 +113,8 @@ export interface FileRoutesByFullPath { '/reviews': typeof ProtectedReviewsRoute '/api/auth/$': typeof ApiAuthSplatRoute '/api/webhooks/github': typeof ApiWebhooksGithubRoute + '/api/github/app/authorize': typeof ApiGithubAppAuthorizeRoute + '/api/github/app/callback': typeof ApiGithubAppCallbackRoute '/$owner/$repo/issues/$issueId': typeof ProtectedOwnerRepoIssuesIssueIdRoute '/$owner/$repo/pull/$pullId': typeof ProtectedOwnerRepoPullPullIdRoute '/$owner/$repo/review/$pullId': typeof ProtectedOwnerRepoReviewPullIdRoute @@ -115,6 +129,8 @@ export interface FileRoutesByTo { '/': typeof ProtectedIndexRoute '/api/auth/$': typeof ApiAuthSplatRoute '/api/webhooks/github': typeof ApiWebhooksGithubRoute + '/api/github/app/authorize': typeof ApiGithubAppAuthorizeRoute + '/api/github/app/callback': typeof ApiGithubAppCallbackRoute '/$owner/$repo/issues/$issueId': typeof ProtectedOwnerRepoIssuesIssueIdRoute '/$owner/$repo/pull/$pullId': typeof ProtectedOwnerRepoPullPullIdRoute '/$owner/$repo/review/$pullId': typeof ProtectedOwnerRepoReviewPullIdRoute @@ -131,6 +147,8 @@ export interface FileRoutesById { '/_protected/': typeof ProtectedIndexRoute '/api/auth/$': typeof ApiAuthSplatRoute '/api/webhooks/github': typeof ApiWebhooksGithubRoute + '/api/github/app/authorize': typeof ApiGithubAppAuthorizeRoute + '/api/github/app/callback': typeof ApiGithubAppCallbackRoute '/_protected/$owner/$repo/issues/$issueId': typeof ProtectedOwnerRepoIssuesIssueIdRoute '/_protected/$owner/$repo/pull/$pullId': typeof ProtectedOwnerRepoPullPullIdRoute '/_protected/$owner/$repo/review/$pullId': typeof ProtectedOwnerRepoReviewPullIdRoute @@ -147,6 +165,8 @@ export interface FileRouteTypes { | '/reviews' | '/api/auth/$' | '/api/webhooks/github' + | '/api/github/app/authorize' + | '/api/github/app/callback' | '/$owner/$repo/issues/$issueId' | '/$owner/$repo/pull/$pullId' | '/$owner/$repo/review/$pullId' @@ -161,6 +181,8 @@ export interface FileRouteTypes { | '/' | '/api/auth/$' | '/api/webhooks/github' + | '/api/github/app/authorize' + | '/api/github/app/callback' | '/$owner/$repo/issues/$issueId' | '/$owner/$repo/pull/$pullId' | '/$owner/$repo/review/$pullId' @@ -176,6 +198,8 @@ export interface FileRouteTypes { | '/_protected/' | '/api/auth/$' | '/api/webhooks/github' + | '/api/github/app/authorize' + | '/api/github/app/callback' | '/_protected/$owner/$repo/issues/$issueId' | '/_protected/$owner/$repo/pull/$pullId' | '/_protected/$owner/$repo/review/$pullId' @@ -188,6 +212,8 @@ export interface RootRouteChildren { SitemapDotxmlRoute: typeof SitemapDotxmlRoute ApiAuthSplatRoute: typeof ApiAuthSplatRoute ApiWebhooksGithubRoute: typeof ApiWebhooksGithubRoute + ApiGithubAppAuthorizeRoute: typeof ApiGithubAppAuthorizeRoute + ApiGithubAppCallbackRoute: typeof ApiGithubAppCallbackRoute } declare module '@tanstack/react-router' { @@ -262,6 +288,20 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiAuthSplatRouteImport parentRoute: typeof rootRouteImport } + '/api/github/app/callback': { + id: '/api/github/app/callback' + path: '/api/github/app/callback' + fullPath: '/api/github/app/callback' + preLoaderRoute: typeof ApiGithubAppCallbackRouteImport + parentRoute: typeof rootRouteImport + } + '/api/github/app/authorize': { + id: '/api/github/app/authorize' + path: '/api/github/app/authorize' + fullPath: '/api/github/app/authorize' + preLoaderRoute: typeof ApiGithubAppAuthorizeRouteImport + parentRoute: typeof rootRouteImport + } '/_protected/$owner/$repo/review/$pullId': { id: '/_protected/$owner/$repo/review/$pullId' path: '/$owner/$repo/review/$pullId' @@ -317,6 +357,8 @@ const rootRouteChildren: RootRouteChildren = { SitemapDotxmlRoute: SitemapDotxmlRoute, ApiAuthSplatRoute: ApiAuthSplatRoute, ApiWebhooksGithubRoute: ApiWebhooksGithubRoute, + ApiGithubAppAuthorizeRoute: ApiGithubAppAuthorizeRoute, + ApiGithubAppCallbackRoute: ApiGithubAppCallbackRoute, } export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) diff --git a/apps/dashboard/src/routes/api/github/app/authorize.ts b/apps/dashboard/src/routes/api/github/app/authorize.ts new file mode 100644 index 0000000..55b5cc6 --- /dev/null +++ b/apps/dashboard/src/routes/api/github/app/authorize.ts @@ -0,0 +1,88 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { getAuth } from "#/lib/auth.server"; +import { getGitHubAppAuthConfig } from "#/lib/github-app.server"; +import { PRIVATE_ROUTE_HEADERS } from "#/lib/seo"; + +const STATE_COOKIE = "github_app_oauth_state"; +const RETURN_TO_COOKIE = "github_app_oauth_return_to"; +const DEFAULT_RETURN_TO = "/?show-org-setup=true"; + +function normalizeReturnTo(value: string | null) { + if (!value || !value.startsWith("/") || value.startsWith("//")) { + return DEFAULT_RETURN_TO; + } + + return value; +} + +function serializeCookie({ + maxAge, + name, + requestUrl, + value, +}: { + maxAge: number; + name: string; + requestUrl: string; + value: string; +}) { + const secure = new URL(requestUrl).protocol === "https:" ? "; Secure" : ""; + return `${name}=${encodeURIComponent(value)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${maxAge}${secure}`; +} + +export const Route = createFileRoute("/api/github/app/authorize")({ + headers: () => PRIVATE_ROUTE_HEADERS, + server: { + handlers: { + GET: async ({ request }) => { + const auth = getAuth(); + const session = await auth.api.getSession({ headers: request.headers }); + const requestUrl = new URL(request.url); + const returnTo = normalizeReturnTo( + requestUrl.searchParams.get("returnTo"), + ); + + if (!session) { + const loginUrl = new URL("/login", request.url); + loginUrl.searchParams.set("redirect", returnTo); + return Response.redirect(loginUrl.toString(), 302); + } + + const state = crypto.randomUUID(); + const githubApp = getGitHubAppAuthConfig(); + const callbackUrl = new URL("/api/github/app/callback", request.url); + const authorizeUrl = new URL( + "https://github.com/login/oauth/authorize", + ); + authorizeUrl.searchParams.set("client_id", githubApp.clientId); + authorizeUrl.searchParams.set("redirect_uri", callbackUrl.toString()); + authorizeUrl.searchParams.set("state", state); + + const response = new Response(null, { + status: 302, + headers: { Location: authorizeUrl.toString() }, + }); + response.headers.append( + "Set-Cookie", + serializeCookie({ + maxAge: 10 * 60, + name: STATE_COOKIE, + requestUrl: request.url, + value: state, + }), + ); + response.headers.append( + "Set-Cookie", + serializeCookie({ + maxAge: 10 * 60, + name: RETURN_TO_COOKIE, + requestUrl: request.url, + value: returnTo, + }), + ); + + return response; + }, + }, + }, +}); diff --git a/apps/dashboard/src/routes/api/github/app/callback.ts b/apps/dashboard/src/routes/api/github/app/callback.ts new file mode 100644 index 0000000..dc885dc --- /dev/null +++ b/apps/dashboard/src/routes/api/github/app/callback.ts @@ -0,0 +1,99 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { getAuth } from "#/lib/auth.server"; +import { exchangeGitHubAppUserCode } from "#/lib/github-app.server"; +import { PRIVATE_ROUTE_HEADERS } from "#/lib/seo"; + +const STATE_COOKIE = "github_app_oauth_state"; +const RETURN_TO_COOKIE = "github_app_oauth_return_to"; +const DEFAULT_RETURN_TO = "/?show-org-setup=true"; + +function getCookie(request: Request, name: string) { + const cookieHeader = request.headers.get("cookie") ?? ""; + for (const cookie of cookieHeader.split(";")) { + const [rawName, ...rawValue] = cookie.trim().split("="); + if (rawName === name) { + return decodeURIComponent(rawValue.join("=")); + } + } + + return null; +} + +function clearCookie(name: string, requestUrl: string) { + const secure = new URL(requestUrl).protocol === "https:" ? "; Secure" : ""; + return `${name}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0${secure}`; +} + +function normalizeReturnTo(value: string | null) { + if (!value || !value.startsWith("/") || value.startsWith("//")) { + return DEFAULT_RETURN_TO; + } + + return value; +} + +function redirectWithClearedCookies(request: Request, href: string) { + const response = new Response(null, { + status: 302, + headers: { Location: new URL(href, request.url).toString() }, + }); + response.headers.append("Set-Cookie", clearCookie(STATE_COOKIE, request.url)); + response.headers.append( + "Set-Cookie", + clearCookie(RETURN_TO_COOKIE, request.url), + ); + return response; +} + +export const Route = createFileRoute("/api/github/app/callback")({ + headers: () => PRIVATE_ROUTE_HEADERS, + server: { + handlers: { + GET: async ({ request }) => { + const requestUrl = new URL(request.url); + const code = requestUrl.searchParams.get("code"); + const state = requestUrl.searchParams.get("state"); + const expectedState = getCookie(request, STATE_COOKIE); + const returnTo = normalizeReturnTo( + getCookie(request, RETURN_TO_COOKIE), + ); + + if (!code || !state || !expectedState || state !== expectedState) { + return redirectWithClearedCookies( + request, + `${DEFAULT_RETURN_TO}&github-app-error=invalid-state`, + ); + } + + const auth = getAuth(); + const session = await auth.api.getSession({ headers: request.headers }); + if (!session) { + const loginParams = new URLSearchParams({ redirect: returnTo }); + return redirectWithClearedCookies( + request, + `/login?${loginParams.toString()}`, + ); + } + + try { + await exchangeGitHubAppUserCode({ + code, + redirectUri: new URL( + "/api/github/app/callback", + request.url, + ).toString(), + userId: session.user.id, + }); + } catch (error) { + console.error("[github-app-oauth] failed to exchange code", error); + return redirectWithClearedCookies( + request, + `${DEFAULT_RETURN_TO}&github-app-error=exchange-failed`, + ); + } + + return redirectWithClearedCookies(request, returnTo); + }, + }, + }, +});