import { createHmac, createPrivateKey, createSign, timingSafeEqual } from "node:crypto"; import { logger } from "../logging.js"; export class GitHubAppError extends Error { readonly status: number; constructor(message: string, status = 500) { super(message); this.name = "GitHubAppError"; this.status = status; } } export interface GitHubOAuthSession { accessToken: string; scopes: string[]; } export interface GitHubViewerIdentity { id: string; login: string; name: string; email: string | null; } export interface GitHubOrgIdentity { id: string; login: string; name: string | null; } export interface GitHubInstallationRecord { id: number; accountLogin: string; } export interface GitHubRepositoryRecord { fullName: string; cloneUrl: string; private: boolean; } interface GitHubTokenResponse { access_token?: string; scope?: string; error?: string; error_description?: string; } interface GitHubPageResponse { items: T[]; nextUrl: string | null; } const githubOAuthLogger = logger.child({ scope: "github-oauth", }); export interface GitHubWebhookEvent { action?: string; installation?: { id: number; account?: { login?: string; type?: string; id?: number } | null }; repositories_added?: Array<{ id: number; full_name: string; private: boolean }>; repositories_removed?: Array<{ id: number; full_name: string }>; repository?: { id: number; full_name: string; clone_url?: string; private?: boolean; owner?: { login?: string } }; pull_request?: { number: number; title?: string; state?: string; head?: { ref?: string }; base?: { ref?: string } }; sender?: { login?: string; id?: number }; [key: string]: unknown; } export interface GitHubAppClientOptions { apiBaseUrl?: string; authBaseUrl?: string; clientId?: string; clientSecret?: string; redirectUri?: string; appId?: string; appPrivateKey?: string; webhookSecret?: string; } function normalizePem(value: string | undefined): string | undefined { if (!value) { return value; } return value.includes("\\n") ? value.replace(/\\n/g, "\n") : value; } export class GitHubAppClient { private readonly apiBaseUrl: string; private readonly authBaseUrl: string; private readonly clientId?: string; private readonly clientSecret?: string; private readonly redirectUri?: string; private readonly appId?: string; private readonly appPrivateKey?: string; private readonly webhookSecret?: string; constructor(options: GitHubAppClientOptions = {}) { this.apiBaseUrl = (options.apiBaseUrl ?? "https://api.github.com").replace(/\/$/, ""); this.authBaseUrl = (options.authBaseUrl ?? "https://github.com").replace(/\/$/, ""); this.clientId = options.clientId ?? process.env.GITHUB_CLIENT_ID; this.clientSecret = options.clientSecret ?? process.env.GITHUB_CLIENT_SECRET; this.redirectUri = options.redirectUri ?? process.env.GITHUB_REDIRECT_URI; this.appId = options.appId ?? process.env.GITHUB_APP_ID; this.appPrivateKey = normalizePem(options.appPrivateKey ?? process.env.GITHUB_APP_PRIVATE_KEY); this.webhookSecret = options.webhookSecret ?? process.env.GITHUB_WEBHOOK_SECRET; } isOauthConfigured(): boolean { return Boolean(this.clientId && this.clientSecret && this.redirectUri); } isAppConfigured(): boolean { return Boolean(this.appId && this.appPrivateKey); } isWebhookConfigured(): boolean { return Boolean(this.webhookSecret); } verifyWebhookEvent(payload: string, signatureHeader: string | null, eventHeader: string | null): { event: string; body: GitHubWebhookEvent } { if (!this.webhookSecret) { throw new GitHubAppError("GitHub webhook secret is not configured", 500); } if (!signatureHeader) { throw new GitHubAppError("Missing GitHub signature header", 400); } if (!eventHeader) { throw new GitHubAppError("Missing GitHub event header", 400); } const expectedSignature = signatureHeader.startsWith("sha256=") ? signatureHeader.slice(7) : null; if (!expectedSignature) { throw new GitHubAppError("Malformed GitHub signature header", 400); } const computed = createHmac("sha256", this.webhookSecret).update(payload).digest("hex"); const computedBuffer = Buffer.from(computed, "utf8"); const expectedBuffer = Buffer.from(expectedSignature, "utf8"); if (computedBuffer.length !== expectedBuffer.length || !timingSafeEqual(computedBuffer, expectedBuffer)) { throw new GitHubAppError("GitHub webhook signature verification failed", 400); } return { event: eventHeader, body: JSON.parse(payload) as GitHubWebhookEvent, }; } buildAuthorizeUrl(state: string): string { if (!this.clientId || !this.redirectUri) { throw new GitHubAppError("GitHub OAuth is not configured", 500); } const url = new URL(`${this.authBaseUrl}/login/oauth/authorize`); url.searchParams.set("client_id", this.clientId); url.searchParams.set("redirect_uri", this.redirectUri); url.searchParams.set("scope", "read:user user:email read:org repo"); url.searchParams.set("state", state); return url.toString(); } async exchangeCode(code: string): Promise { if (!this.clientId || !this.clientSecret || !this.redirectUri) { throw new GitHubAppError("GitHub OAuth is not configured", 500); } const exchangeBody = { client_id: this.clientId, client_secret: this.clientSecret, code, redirect_uri: this.redirectUri, }; githubOAuthLogger.debug( { url: `${this.authBaseUrl}/login/oauth/access_token`, clientId: this.clientId, redirectUri: this.redirectUri, codeLength: code.length, codePrefix: code.slice(0, 6), }, "exchange_code_request", ); const response = await fetch(`${this.authBaseUrl}/login/oauth/access_token`, { method: "POST", headers: { Accept: "application/json", "Content-Type": "application/json", }, body: JSON.stringify(exchangeBody), }); const responseText = await response.text(); githubOAuthLogger.debug( { status: response.status, bodyPreview: responseText.slice(0, 300), }, "exchange_code_response", ); let payload: GitHubTokenResponse; try { payload = JSON.parse(responseText) as GitHubTokenResponse; } catch { // GitHub may return URL-encoded responses despite Accept: application/json const params = new URLSearchParams(responseText); if (params.has("access_token")) { payload = { access_token: params.get("access_token")!, scope: params.get("scope") ?? "", }; } else { throw new GitHubAppError( params.get("error_description") ?? params.get("error") ?? `GitHub token exchange failed: ${responseText.slice(0, 200)}`, response.status || 502, ); } } if (!response.ok || !payload.access_token) { throw new GitHubAppError(payload.error_description ?? payload.error ?? `GitHub token exchange failed with ${response.status}`, response.status); } return { accessToken: payload.access_token, scopes: payload.scope ?.split(",") .map((value) => value.trim()) .filter((value) => value.length > 0) ?? [], }; } async getViewer(accessToken: string): Promise { const user = await this.requestJson<{ id: number; login: string; name?: string | null; email?: string | null; }>("/user", accessToken); let email = user.email ?? null; if (!email) { try { const emails = await this.requestJson>("/user/emails", accessToken); const primary = emails.find((candidate) => candidate.primary && candidate.verified) ?? emails[0] ?? null; email = primary?.email ?? null; } catch (error) { if (!(error instanceof GitHubAppError) || error.status !== 404) { throw error; } } } return { id: String(user.id), login: user.login, name: user.name?.trim() || user.login, email, }; } async listOrganizations(accessToken: string): Promise { const organizations = await this.paginate<{ id: number; login: string; name?: string | null }>("/user/orgs?per_page=100", accessToken); return organizations.map((organization) => ({ id: String(organization.id), login: organization.login, name: organization.name?.trim() || organization.login, })); } async listInstallations(accessToken: string): Promise { if (!this.isAppConfigured()) { return []; } try { const payload = await this.requestJson<{ installations?: Array<{ id: number; account?: { login?: string } | null }>; }>("/user/installations", accessToken); return (payload.installations ?? []) .map((installation) => ({ id: installation.id, accountLogin: installation.account?.login?.trim() ?? "", })) .filter((installation) => installation.accountLogin.length > 0); } catch (error) { if (!(error instanceof GitHubAppError) || (error.status !== 401 && error.status !== 403)) { throw error; } } const installations = await this.paginateApp<{ id: number; account?: { login?: string } | null }>("/app/installations?per_page=100"); return installations .map((installation) => ({ id: installation.id, accountLogin: installation.account?.login?.trim() ?? "", })) .filter((installation) => installation.accountLogin.length > 0); } async listUserRepositories(accessToken: string): Promise { const repositories = await this.paginate<{ full_name: string; clone_url: string; private: boolean; }>("/user/repos?per_page=100&affiliation=owner,collaborator,organization_member&sort=updated", accessToken); return repositories.map((repository) => ({ fullName: repository.full_name, cloneUrl: repository.clone_url, private: repository.private, })); } async listInstallationRepositories(installationId: number): Promise { const accessToken = await this.createInstallationAccessToken(installationId); const repositories = await this.paginate<{ full_name: string; clone_url: string; private: boolean; }>("/installation/repositories?per_page=100", accessToken); return repositories.map((repository) => ({ fullName: repository.full_name, cloneUrl: repository.clone_url, private: repository.private, })); } async buildInstallationUrl(organizationLogin: string, state: string): Promise { if (!this.isAppConfigured()) { throw new GitHubAppError("GitHub App is not configured", 500); } const app = await this.requestAppJson<{ slug?: string }>("/app"); if (!app.slug) { throw new GitHubAppError("GitHub App slug is unavailable", 500); } const url = new URL(`${this.authBaseUrl}/apps/${app.slug}/installations/new`); url.searchParams.set("state", state); void organizationLogin; return url.toString(); } private async createInstallationAccessToken(installationId: number): Promise { if (!this.appId || !this.appPrivateKey) { throw new GitHubAppError("GitHub App is not configured", 500); } const response = await fetch(`${this.apiBaseUrl}/app/installations/${installationId}/access_tokens`, { method: "POST", headers: { Accept: "application/vnd.github+json", Authorization: `Bearer ${this.createAppJwt()}`, "X-GitHub-Api-Version": "2022-11-28", }, }); const payload = (await response.json()) as { token?: string; message?: string }; if (!response.ok || !payload.token) { throw new GitHubAppError(payload.message ?? "Unable to mint GitHub installation token", response.status); } return payload.token; } private createAppJwt(): string { if (!this.appId || !this.appPrivateKey) { throw new GitHubAppError("GitHub App is not configured", 500); } const header = base64UrlEncode(JSON.stringify({ alg: "RS256", typ: "JWT" })); const now = Math.floor(Date.now() / 1000); const payload = base64UrlEncode( JSON.stringify({ iat: now - 60, exp: now + 540, iss: this.appId, }), ); const signer = createSign("RSA-SHA256"); signer.update(`${header}.${payload}`); signer.end(); const key = createPrivateKey(this.appPrivateKey); const signature = signer.sign(key); return `${header}.${payload}.${base64UrlEncode(signature)}`; } private async requestAppJson(path: string): Promise { const response = await fetch(`${this.apiBaseUrl}${path}`, { headers: { Accept: "application/vnd.github+json", Authorization: `Bearer ${this.createAppJwt()}`, "X-GitHub-Api-Version": "2022-11-28", }, }); const payload = (await response.json()) as T | { message?: string }; if (!response.ok) { throw new GitHubAppError( typeof payload === "object" && payload && "message" in payload ? (payload.message ?? "GitHub request failed") : "GitHub request failed", response.status, ); } return payload as T; } private async paginateApp(path: string): Promise { let nextUrl = `${this.apiBaseUrl}${path.startsWith("/") ? path : `/${path}`}`; const items: T[] = []; while (nextUrl) { const page = await this.requestAppPage(nextUrl); items.push(...page.items); nextUrl = page.nextUrl ?? ""; } return items; } private async requestJson(path: string, accessToken: string): Promise { const response = await fetch(`${this.apiBaseUrl}${path}`, { headers: { Accept: "application/vnd.github+json", Authorization: `Bearer ${accessToken}`, "X-GitHub-Api-Version": "2022-11-28", }, }); const payload = (await response.json()) as T | { message?: string }; if (!response.ok) { throw new GitHubAppError( typeof payload === "object" && payload && "message" in payload ? (payload.message ?? "GitHub request failed") : "GitHub request failed", response.status, ); } return payload as T; } private async paginate(path: string, accessToken: string): Promise { let nextUrl = `${this.apiBaseUrl}${path.startsWith("/") ? path : `/${path}`}`; const items: T[] = []; while (nextUrl) { const page = await this.requestPage(nextUrl, accessToken); items.push(...page.items); nextUrl = page.nextUrl ?? ""; } return items; } private async requestPage(url: string, accessToken: string): Promise> { const response = await fetch(url, { headers: { Accept: "application/vnd.github+json", Authorization: `Bearer ${accessToken}`, "X-GitHub-Api-Version": "2022-11-28", }, }); const payload = (await response.json()) as T[] | { repositories?: T[]; message?: string }; if (!response.ok) { throw new GitHubAppError( typeof payload === "object" && payload && "message" in payload ? (payload.message ?? "GitHub request failed") : "GitHub request failed", response.status, ); } const items = Array.isArray(payload) ? payload : (payload.repositories ?? []); return { items, nextUrl: parseNextLink(response.headers.get("link")), }; } private async requestAppPage(url: string): Promise> { const response = await fetch(url, { headers: { Accept: "application/vnd.github+json", Authorization: `Bearer ${this.createAppJwt()}`, "X-GitHub-Api-Version": "2022-11-28", }, }); const payload = (await response.json()) as T[] | { installations?: T[]; message?: string }; if (!response.ok) { throw new GitHubAppError( typeof payload === "object" && payload && "message" in payload ? (payload.message ?? "GitHub request failed") : "GitHub request failed", response.status, ); } const items = Array.isArray(payload) ? payload : (payload.installations ?? []); return { items, nextUrl: parseNextLink(response.headers.get("link")), }; } } function parseNextLink(linkHeader: string | null): string | null { if (!linkHeader) { return null; } for (const part of linkHeader.split(",")) { const [urlPart, relPart] = part.split(";").map((value) => value.trim()); if (!urlPart || !relPart || !relPart.includes('rel="next"')) { continue; } return urlPart.replace(/^<|>$/g, ""); } return null; } function base64UrlEncode(value: string | Buffer): string { const source = typeof value === "string" ? Buffer.from(value, "utf8") : value; return source.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, ""); }